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>
This commit is contained in:
Krzysztof Rudnicki 2026-08-22 17:28:02 +02:00
parent fff46de4c5
commit f7418969cf
8 changed files with 317 additions and 2 deletions

View File

@ -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

View File

@ -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

View File

@ -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));
});
});
}

View File

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

View File

@ -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);

View File

@ -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.",

View File

@ -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);

View File

@ -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);