mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:07:17 +01:00
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:
parent
fff46de4c5
commit
f7418969cf
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
121
unified-design-system/motion.md
Normal file
121
unified-design-system/motion.md
Normal 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.
|
||||
31
web_ui/dist/tokens.css
vendored
31
web_ui/dist/tokens.css
vendored
@ -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);
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user