utils/unified-design-system/DOCS-motion.md
Krzysztof kuhy Rudnicki a10d190b65
Some checks failed
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
design_system tests / test (push) Has been cancelled
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (3.10) (push) Has been cancelled
gatelock tests / test (3.11) (push) Has been cancelled
gatelock tests / test (3.12) (push) Has been cancelled
web_ui tests / test (push) Has been cancelled
Add the shared markdown-naming gate and migrate utils' own docs
Markdown across every repo had accumulated a dozen ad-hoc names
(refactor_claude_todo.md x23, DESIGN_AUDIT_TODO.md, NEXT_SESSION,
HANDOFF, PLAN, SPEC) with nothing distinguishing a live task from a
finished record. An audit found docs asserting work was pending that
shipped months earlier, and one asserting completion while the work
was open.

Four namespaces now: README.md, CLAUDE*.md, DOCS*.md, TODO*.md. Task
files carry the literal "REMOVE ME AFTER FINISH" and are deleted when
the work lands.

Enforced bidirectionally -- a TODO without the marker fails, AND any
file carrying the marker that is not named TODO*.md fails. Without the
reverse rule a task can be renamed into the permanent namespace and
quietly live forever, which is the failure being fixed.

md_naming/ mirrors the file_length topology and imports its tables
(EXCLUDED_DIRS, THIRD_PARTY_REPOS, VENDORED_ANYWHERE, GENERATED_PATTERN)
rather than restating them, so the two gates cannot disagree about the
same file. 71 tests, 100% branch coverage.

stale.py warns on TODOs past 90 days but never fails: no machine can
prove a task is finished, so the honest mechanism is a nudge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A47aX5DJwmA3QFn3pkEWi7
2026-08-24 15:53:12 +02:00

7.3 KiB
Raw Blame History

Motion, haptics and sound (frozen values)

Split out of tokens.md on 2026-08-22 to hold every file under the shared 250-line cap, when the motion scale was added. Both are part of the same token spec — this is a filing change, not a scoping change.

Not yet checked by a script. scripts/palette_check.py parses #RRGGBB only, so every value below is invisible to it — a --duration-fast: 120ms matches none of its four parsers. palette_map.py:159 claims structural values are "checked by the scale check"; no such script exists yet. Until dopamine-ux prompt 02 writes it, these three stacks are kept in agreement by hand, and editing a value here means editing it in web_ui/src/tokens.css and design_system/lib/src/tokens.dart in the same commit.

Duration

Four steps. The scale is deliberately short: a duration vocabulary with ten entries gets picked from at random, which is drift wearing a token's name.

Token Value Rationale
instant 0ms No animation. The reduced-motion collapse target, and the honest choice for anything that must feel direct.
fast 120ms State on an element already under the cursor/finger — hover, press, checkbox, ripple. Below ~100ms reads as instant, so this is the floor at which motion is perceived as motion.
base 200ms The default. Anything that enters, leaves or moves within the current view: toasts, expanding rows, tab switches.
slow 320ms Full-surface change — sheets, dialogs, page transitions. Above ~350ms the interface feels like it is waiting on itself, so this is the ceiling, not a midpoint.

Anything needing a duration not on this scale is a signal the interaction is wrong, not that the scale is short.

Easing

Three curves, as cubic-bezier control points. Named for what the motion does at its end, because that is the part the eye actually reads.

Token Cubic-bezier Rationale
standard (0.2, 0, 0, 1) The default for anything that starts and ends on screen. Fast out of the gate, settles gently.
decelerate (0, 0, 0, 1) Entering the screen. Starts at full speed (the element is already "in motion" from off-stage) and eases to rest.
accelerate (0.3, 0, 1, 1) Leaving the screen. Eases in, exits at speed — no settle, because there is nothing to settle onto.

Never ease-in-out on an element that enters or exits: symmetric easing makes an arrival look like it is braking into place and a departure look reluctant.

Reduced motion — mandatory

Motion tokens collapse to 0ms. Easing becomes irrelevant at zero duration and needs no separate handling.

Stack How it is detected
web/React @media (prefers-reduced-motion: reduce)
Flutter/Dart MediaQuery.of(context).disableAnimations
Python/Tk n/a (tk) — see below

Haptics and sound are unaffected. They are not motion, and a user who suppresses animation has not asked to be denied confirmation that a tap landed. This is the rule most often got wrong by collapsing all feedback under one flag.

The OS motion preference is honoured automatically and is not the sound toggle. The sound opt-out is a separate, user-facing setting (below).

Haptics

Mapped onto Flutter's HapticFeedback API. This is the highest-value row in this file: HapticFeedback comes from flutter/services.dart and needs no new dependency, so an app can adopt it the day it reads this.

Semantic event Call Rationale
Selection changed (tab, chip, picker) selectionClick The lightest cue available. Fires often, so it must be nearly free.
Recorded something (meal, workout, task) lightImpact The confirming tap. Fires on the recording, not on the number recorded — see the corollary below.
Completed a whole flow (finished a workout, closed a streak) mediumImpact Reserved for genuine completion. If everything is mediumImpact, nothing is.
Error / rejected input heavyImpact Distinct by weight, not just by pattern — the one cue that must be felt without looking.

Reward the recording, never the number recorded. The haptic on logging a meal is legitimate; a haptic on hitting a calorie target rewards eating less rather than recording honestly, and corrupts the app's own data. Same shape in workout_app (reward logging a workout, not beating a PB) and todo (reward capture, not completion count).

Sound

Three named semantic cues. Each app owns its own asset files — this file freezes the vocabulary and the policy, not the audio.

Cue Fires when
confirm An action was recorded. Pairs with lightImpact.
complete A flow finished. Pairs with mediumImpact.
error Input was rejected. Pairs with heavyImpact.

Policy:

  1. Sound ships on, as an opt-out. A cue nobody ever hears is not a cue. The toggle is per-app and user-facing.
  2. The sound toggle is not the reduced-motion preference, and neither implies the other. Two settings, two meanings.
  3. Never a cue without its haptic sibling. Sound is the part that fails — silenced phone, no speaker, noisy room — so it is the redundant channel, and the haptic carries the meaning alone whenever it has to.
  4. Keep cues under ~300ms. A sound outlasting slow is still playing after the thing it describes has finished.

Stack mapping

Stack File Form
Flutter/Dart design_system/lib/src/tokens.dart class AppDuration / class AppCurve
web/React web_ui/src/tokens.css --duration-* / --ease-* custom props
Python/Tk gatelock/gatelock/_window.py n/a (tk)

n/a (tk): Tkinter has no animation framework, and gatelock's LockConfig carries no motion values. Adding them for symmetry would ship a dead constant that reads as a supported feature — an honest n/a beats a lie about scope. Haptics and sound are likewise n/a there: it is a fullscreen desktop lock surface with no haptic hardware.

Only ~/todo consumes design_system as a package; diet-guard, workout_app and wake_alarm hand-transcribe these values into local lib/ui/theme.dart copies. Tokens added here therefore reach exactly one app automatically — the rest is per-app work, which is what dopamine-ux prompts 03–09 are for.