utils/unified-design-system/motion.md
Krzysztof kuhy Rudnicki f7418969cf Freeze the motion, haptic and sound vocabularies, and ship them as tokens
dopamine-ux prompt 01. The token layer froze colour, spacing, radius and type,
but every animation in every app picked its own duration and curve out of the
air -- so "200ms ease-in-out" and "180ms ease-out" both existed and neither was
wrong, because nothing said what right was.

unified-design-system/motion.md is the freeze: four durations, three curves,
a haptic vocabulary mapped onto Flutter's HapticFeedback, and three sound cues,
each with its one-line rationale. Shipped as AppDuration/AppCurve in Dart and
--duration-*/--ease-* in CSS, so a consumer imports the scale instead of
transcribing it.

Three things worth stating, because they are the parts easy to get wrong:

- Reduced motion collapses the *tokens themselves* (CSS @media, Flutter
  disableAnimations), so no consumer has to remember to branch.
- Haptics and sound are NOT motion and are deliberately left alone by that
  preference. Suppressing animation is not a request to lose confirmation that
  a tap landed; the sound opt-out is a separate, user-facing setting.
- Reward the recording, never the number recorded. The haptic fires on logging
  a meal, not on hitting a calorie target -- the latter rewards eating less
  rather than recording honestly, and corrupts the app's own data.

gatelock stays untouched: Tkinter has no animation framework, so motion there
is marked `n/a (tk)` rather than shipping a dead constant that reads as a
supported feature.

Tests assert the frozen values on both sides, and that the reduced-motion block
collapses every non-zero step -- verified by mutation: deleting one collapse
line fails the suite. flutter analyze / tsc / eslint clean, 68 Dart and 88 TS
tests pass, palette_check and the 250-line cap still exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 17:28:02 +02:00

122 lines
7.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.