From f7418969cf7e059fdcef92c3686947a6373cc920 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 22 Aug 2026 17:28:02 +0200 Subject: [PATCH] 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) --- design_system/lib/src/tokens.dart | 41 ++++++++++ design_system/pubspec.yaml | 2 +- design_system/test/tokens_test.dart | 60 ++++++++++++++ unified-design-system/motion.md | 121 ++++++++++++++++++++++++++++ web_ui/dist/tokens.css | 31 +++++++ web_ui/package.json | 2 +- web_ui/src/tokens.css | 31 +++++++ web_ui/src/tokens.test.ts | 31 +++++++ 8 files changed, 317 insertions(+), 2 deletions(-) create mode 100644 unified-design-system/motion.md diff --git a/design_system/lib/src/tokens.dart b/design_system/lib/src/tokens.dart index 478d6d2..4479761 100644 --- a/design_system/lib/src/tokens.dart +++ b/design_system/lib/src/tokens.dart @@ -128,6 +128,47 @@ abstract final class AppTextSize { static const double display = 32; } +/// Shared motion durations. Frozen in `unified-design-system/motion.md`, which +/// carries the rationale for each step and the reduced-motion contract. +/// +/// Under `MediaQuery.of(context).disableAnimations` every value here collapses +/// to [instant]. Haptics and sound are *not* motion and are unaffected by that +/// preference — see `AppHaptic` in `motion.md`'s vocabulary. +abstract final class AppDuration { + /// 0ms — no animation. The reduced-motion collapse target. + static const Duration instant = Duration.zero; + + /// 120ms — state on an element already under the cursor/finger: hover, + /// press, checkbox, ripple. The floor at which motion reads *as* motion. + static const Duration fast = Duration(milliseconds: 120); + + /// 200ms — the default. Anything entering, leaving or moving within the + /// current view: toasts, expanding rows, tab switches. + static const Duration base = Duration(milliseconds: 200); + + /// 320ms — full-surface change: sheets, dialogs, page transitions. The + /// ceiling; past ~350ms the interface feels like it is waiting on itself. + static const Duration slow = Duration(milliseconds: 320); +} + +/// Shared easing curves, named for what the motion does at its *end* — that is +/// the part the eye actually reads. +/// +/// Never use a symmetric `ease-in-out` on an element that enters or exits: it +/// makes an arrival look like it is braking and a departure look reluctant. +abstract final class AppCurve { + /// Starts and ends on screen. Fast out of the gate, settles gently. + static const Curve standard = Cubic(0.2, 0, 0, 1); + + /// Entering the screen. Starts at full speed — the element is already in + /// motion from off-stage — and eases to rest. + static const Curve decelerate = Cubic(0, 0, 0, 1); + + /// Leaving the screen. Eases in, exits at speed: no settle, because there is + /// nothing left to settle onto. + static const Curve accelerate = Cubic(0.3, 0, 1, 1); +} + /// Prose/paragraph line-length cap (rule 21). /// /// Desktop builds here are Chrome `--app` windows that can be arbitrarily diff --git a/design_system/pubspec.yaml b/design_system/pubspec.yaml index b2b5cf8..eea98a7 100644 --- a/design_system/pubspec.yaml +++ b/design_system/pubspec.yaml @@ -5,7 +5,7 @@ description: >- helpers that were each reimplemented in three or more repos. Owns the tokens -- consumers import them rather than transcribing the token table by hand. publish_to: 'none' -version: 0.1.0 +version: 0.2.0 environment: sdk: ^3.12.2 diff --git a/design_system/test/tokens_test.dart b/design_system/test/tokens_test.dart index e471ff7..f0e520e 100644 --- a/design_system/test/tokens_test.dart +++ b/design_system/test/tokens_test.dart @@ -81,4 +81,64 @@ void main() { test('kProseMaxWidth caps line length', () { expect(kProseMaxWidth, 640.0); }); + + group('AppDuration', () { + test('matches the frozen scale, ascending', () { + const scale = [ + AppDuration.instant, + AppDuration.fast, + AppDuration.base, + AppDuration.slow, + ]; + expect(scale.map((d) => d.inMilliseconds), [0, 120, 200, 320]); + for (var i = 1; i < scale.length; i++) { + expect( + scale[i] > scale[i - 1], + isTrue, + reason: 'step $i is not longer than the one before it', + ); + } + }); + + test('instant is the zero the reduced-motion contract collapses to', () { + expect(AppDuration.instant, Duration.zero); + }); + + test( + 'stays within the perceptible band: nothing over the 320ms ceiling', + () { + expect(AppDuration.slow.inMilliseconds, lessThanOrEqualTo(350)); + expect(AppDuration.fast.inMilliseconds, greaterThanOrEqualTo(100)); + }, + ); + }); + + group('AppCurve', () { + const curves = { + 'standard': AppCurve.standard, + 'decelerate': AppCurve.decelerate, + 'accelerate': AppCurve.accelerate, + }; + + test('every curve is anchored at both ends', () { + curves.forEach((name, curve) { + expect(curve.transform(0), closeTo(0, 1e-6), reason: '$name at t=0'); + expect(curve.transform(1), closeTo(1, 1e-6), reason: '$name at t=1'); + }); + }); + + test('matches the frozen control points', () { + expect(AppCurve.standard, const Cubic(0.2, 0, 0, 1)); + expect(AppCurve.decelerate, const Cubic(0, 0, 0, 1)); + expect(AppCurve.accelerate, const Cubic(0.3, 0, 1, 1)); + }); + + test('decelerate leads and accelerate trails, as their names promise', () { + // Named for what they do at the END: decelerate has covered most of the + // distance by the midpoint (it is settling), accelerate has covered + // little (it is still winding up). + expect(AppCurve.decelerate.transform(0.5), greaterThan(0.5)); + expect(AppCurve.accelerate.transform(0.5), lessThan(0.5)); + }); + }); } diff --git a/unified-design-system/motion.md b/unified-design-system/motion.md new file mode 100644 index 0000000..9c7ff08 --- /dev/null +++ b/unified-design-system/motion.md @@ -0,0 +1,121 @@ +# 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. diff --git a/web_ui/dist/tokens.css b/web_ui/dist/tokens.css index e25d9de..df37a09 100644 --- a/web_ui/dist/tokens.css +++ b/web_ui/dist/tokens.css @@ -93,6 +93,24 @@ /* Focus ring: the platform default is black, invisible on --bg. */ --focus-ring: #b8862e; /* = accent */ + + /* Motion ----------------------------------------------------------------- */ + /* Frozen in ~/utils/unified-design-system/motion.md, which carries the + * rationale per step. The same numbers ship for Dart as AppDuration / + * AppCurve — a change here is a change there. Four steps only: a longer + * duration vocabulary gets picked from at random, which is drift wearing a + * token's name. */ + --duration-instant: 0ms; /* the reduced-motion collapse target */ + --duration-fast: 120ms; /* hover, press, checkbox — already under the cursor */ + --duration-base: 200ms; /* default: enters/leaves/moves within the view */ + --duration-slow: 320ms; /* sheets, dialogs, page transitions */ + + /* Named for what the motion does at its END — the part the eye reads. Never + * ease-in-out on something that enters or exits: symmetric easing makes an + * arrival look like it is braking and a departure look reluctant. */ + --ease-standard: cubic-bezier(0.2, 0, 0, 1); /* starts and ends on screen */ + --ease-decelerate: cubic-bezier(0, 0, 0, 1); /* entering: full speed -> rest */ + --ease-accelerate: cubic-bezier(0.3, 0, 1, 1); /* leaving: no settle */ } @media (prefers-color-scheme: light) { @@ -117,6 +135,19 @@ } } +/* Mandatory, and it collapses the tokens themselves rather than asking every + * consumer to branch. Easing is irrelevant at zero duration, so the curves are + * left alone. Sound and haptics are NOT motion and are deliberately untouched + * here: a user who suppressed animation did not ask to lose confirmation that + * a tap landed. The sound opt-out is a separate, user-facing setting. */ +@media (prefers-reduced-motion: reduce) { + :root { + --duration-fast: 0ms; + --duration-base: 0ms; + --duration-slow: 0ms; + } +} + body { background: var(--bg); color: var(--text); diff --git a/web_ui/package.json b/web_ui/package.json index 1e8d6c3..c82f892 100644 --- a/web_ui/package.json +++ b/web_ui/package.json @@ -1,6 +1,6 @@ { "name": "@kuhyx/web-ui", - "version": "0.3.1", + "version": "0.3.2", "private": false, "type": "module", "description": "The shared token + component layer for kuhy's web apps: the frozen palette, spacing/radius/type scales as CSS custom properties, and the components that were each reimplemented in two or more repos.", diff --git a/web_ui/src/tokens.css b/web_ui/src/tokens.css index e25d9de..df37a09 100644 --- a/web_ui/src/tokens.css +++ b/web_ui/src/tokens.css @@ -93,6 +93,24 @@ /* Focus ring: the platform default is black, invisible on --bg. */ --focus-ring: #b8862e; /* = accent */ + + /* Motion ----------------------------------------------------------------- */ + /* Frozen in ~/utils/unified-design-system/motion.md, which carries the + * rationale per step. The same numbers ship for Dart as AppDuration / + * AppCurve — a change here is a change there. Four steps only: a longer + * duration vocabulary gets picked from at random, which is drift wearing a + * token's name. */ + --duration-instant: 0ms; /* the reduced-motion collapse target */ + --duration-fast: 120ms; /* hover, press, checkbox — already under the cursor */ + --duration-base: 200ms; /* default: enters/leaves/moves within the view */ + --duration-slow: 320ms; /* sheets, dialogs, page transitions */ + + /* Named for what the motion does at its END — the part the eye reads. Never + * ease-in-out on something that enters or exits: symmetric easing makes an + * arrival look like it is braking and a departure look reluctant. */ + --ease-standard: cubic-bezier(0.2, 0, 0, 1); /* starts and ends on screen */ + --ease-decelerate: cubic-bezier(0, 0, 0, 1); /* entering: full speed -> rest */ + --ease-accelerate: cubic-bezier(0.3, 0, 1, 1); /* leaving: no settle */ } @media (prefers-color-scheme: light) { @@ -117,6 +135,19 @@ } } +/* Mandatory, and it collapses the tokens themselves rather than asking every + * consumer to branch. Easing is irrelevant at zero duration, so the curves are + * left alone. Sound and haptics are NOT motion and are deliberately untouched + * here: a user who suppressed animation did not ask to lose confirmation that + * a tap landed. The sound opt-out is a separate, user-facing setting. */ +@media (prefers-reduced-motion: reduce) { + :root { + --duration-fast: 0ms; + --duration-base: 0ms; + --duration-slow: 0ms; + } +} + body { background: var(--bg); color: var(--text); diff --git a/web_ui/src/tokens.test.ts b/web_ui/src/tokens.test.ts index 28763e7..b9dea41 100644 --- a/web_ui/src/tokens.test.ts +++ b/web_ui/src/tokens.test.ts @@ -104,6 +104,37 @@ describe("tokens.css", () => { expect(css).toMatch(/:focus-visible\s*\{[^}]*outline:\s*2px solid var\(--focus-ring\)/); }); + it("declares the duration scale", () => { + expect( + ["instant", "fast", "base", "slow"].map((s) => declared(`duration-${s}`)[0]), + ).toEqual(["0ms", "120ms", "200ms", "320ms"]); + }); + + it("declares the three easing curves", () => { + expect(["standard", "decelerate", "accelerate"].map((s) => declared(`ease-${s}`)[0])).toEqual([ + "cubic-bezier(0.2, 0, 0, 1)", + "cubic-bezier(0, 0, 0, 1)", + "cubic-bezier(0.3, 0, 1, 1)", + ]); + }); + + it("collapses every non-zero duration under prefers-reduced-motion", () => { + // The collapse happens on the tokens themselves so no consumer has to + // branch. Missing one step here is the whole bug this guards. + const block = /@media \(prefers-reduced-motion: reduce\)\s*\{([\s\S]*?)\n\}/.exec(css)?.[1]; + expect(block).toBeDefined(); + for (const step of ["fast", "base", "slow"]) { + expect(block).toMatch(new RegExp(`--duration-${step}:\\s*0ms;`)); + } + }); + + it("does not silence sound or haptics along with motion", () => { + // Reduced motion is not a request to lose confirmation that a tap landed; + // the sound opt-out is a separate, user-facing setting. + const block = /@media \(prefers-reduced-motion: reduce\)\s*\{([\s\S]*?)\n\}/.exec(css)?.[1]; + expect(block).not.toMatch(/sound|haptic|volume|mute/i); + }); + it("uses no raw black or white literals", () => { // Shadows are ink-tinted rgba; neutrals are never pure. expect(css).not.toMatch(/#fff\b|#ffffff\b|#000\b|#000000\b|rgba\(0,\s*0,\s*0/i);