Files
decePubClient/tools/css/motion.py
T
thepraandClaude Opus 5.5 697b2c8897
Build / Build (push) Successful in 33s
Animations, GIFs and the globe now move for every reader
decePub no longer looks at the system's "reduce motion" setting, so everything moves the same way whatever it says:
- a GIF video in a post plays by itself, muted and looping, instead of waiting for a tap; it now also starts on a page
  just opened, before the first tap, where the browser used to hold it still;
- animated custom emoji move in the emoji picker instead of showing a still picture;
- the globe turns by itself while no located post is in view, flies to the posts, and its pulses, arcs and Sun move;
- going back to the top of a feed scrolls smoothly;
- tooltips, dropdowns, menus, folding sections, toasts, loading spinners, placeholders, progress bars, radio buttons
  and ratings keep their animations: the build now takes daisyUI's reduce-motion rules out of the stylesheet
  (tools/css/motion.py, run after every Tailwind build, in watch mode too).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4YtEKqxKjKyHkFzuRVMNA
2026-11-09 20:48:47 +01:00

72 lines
2.3 KiB
Python

import re
import sys
from pathlib import Path
MEDIA = re.compile(r'@media([^{;]*prefers-reduced-motion[^{;]*)\{')
MOTION = re.compile(r'\(\s*prefers-reduced-motion\s*:\s*(reduce|no-preference)\s*\)')
def block_end(css, start):
depth, i, quote = 1, start, None
while depth:
ch = css[i]
if quote:
if ch == '\\':
i += 1
elif ch == quote:
quote = None
elif ch in '"\'':
quote = ch
elif ch == '{':
depth += 1
elif ch == '}':
depth -= 1
i += 1
return i
def without_motion(prelude):
if ',' in prelude or re.search(r'\b(or|not|only)\b', prelude):
raise ValueError(f'@media{prelude}')
parts = [part.strip() for part in re.split(r'\band\b', prelude)]
wanted = [MOTION.fullmatch(part).group(1) for part in parts if MOTION.fullmatch(part)]
if len(wanted) != 1 or any('prefers-reduced-motion' in part and not MOTION.fullmatch(part) for part in parts):
raise ValueError(f'@media{prelude}')
return wanted[0], ' and '.join(part for part in parts if not MOTION.fullmatch(part))
def everyone_moves(css):
out, i = [], 0
while match := MEDIA.search(css, i):
out.append(css[i:match.start()])
end = block_end(css, match.end())
wanted, rest = without_motion(match.group(1))
if wanted == 'no-preference':
inner = everyone_moves(css[match.end():end - 1])
if not rest and inner.strip() and not inner.rstrip().endswith((';', '}')):
inner += ';'
out.append(f'@media {rest}{{{inner}}}' if rest else inner)
i = end
out.append(css[i:])
return ''.join(out)
failed = False
for name in sys.argv[1:]:
path = Path(name)
before = path.read_text(encoding='utf-8')
try:
after = everyone_moves(before)
except ValueError as query:
print(f'{name}: cannot rewrite {query}', file=sys.stderr)
failed = True
continue
if 'prefers-reduced-motion' in after:
print(f'{name}: a prefers-reduced-motion query is still there', file=sys.stderr)
failed = True
continue
if after != before:
path.write_text(after, encoding='utf-8')
print(f'{name}: motion now plays for everyone')
sys.exit(1 if failed else 0)