diff --git a/apps/storybook/src/Foundations.stories.tsx b/apps/storybook/src/Foundations.stories.tsx index 76c476d..112c54a 100644 --- a/apps/storybook/src/Foundations.stories.tsx +++ b/apps/storybook/src/Foundations.stories.tsx @@ -562,48 +562,345 @@ function Elevation() { /* ------------------------------------------------------------------- Motion */ -const EASES = ['standard', 'emphasized', 'decelerate']; -const DURATIONS = ['fast', 'base', 'slow']; +const DURATIONS = ['micro', 'fast', 'base', 'slow', 'slower'] as const; +const EASES = ['standard', 'emphasized', 'decelerate', 'spring', 'spring-strong'] as const; + +// Each shipped `animate-*` preset, mapped to the duration + ease tokens it composes +// (mirrors packages/roots/preset-motion.css). `spring-strong` has no preset by design. +// Full literal class strings (NOT template-built) so Tailwind v4's scanner emits each +// utility - the same rule the Radii/Elevation sections note. The `cls` is applied verbatim +// on a key-remounted element so clicking Play replays the real Tailwind animation. +const PRESETS: { + name: string; + cls: string; + duration: string; + ease: string; + anim: string; + note?: string; +}[] = [ + { + name: 'pop-in', + cls: 'animate-pop-in', + duration: 'base', + ease: 'spring', + anim: 'pop-in', + }, + { + name: 'pop-out', + cls: 'animate-pop-out', + duration: 'fast', + ease: 'standard', + anim: 'pop-out', + }, + { + name: 'shake', + cls: 'animate-shake motion-reduce:animate-none', + duration: 'slow', + ease: 'standard', + anim: 'shake', + note: 'A repeated translate is a vestibular trigger, so gating this preset behind prefers-reduced-motion is mandatory - not optional polish.', + }, + { + name: 'fade-in', + cls: 'animate-fade-in', + duration: 'base', + ease: 'standard', + anim: 'fade-in', + }, + { + name: 'fade-out', + cls: 'animate-fade-out', + duration: 'fast', + ease: 'standard', + anim: 'fade-out', + }, +]; + +const mono: React.CSSProperties = { + fontFamily: 'var(--font-mono)', + fontSize: 'var(--text-xs)', + color: 'var(--color-text-subtle)', +}; + +const playBtn: React.CSSProperties = { + fontFamily: 'var(--font-sans)', + fontSize: 'var(--text-xs)', + fontWeight: 600, + color: 'var(--color-primary-foreground)', + background: 'var(--color-primary)', + border: 'none', + borderRadius: 'var(--radius-md)', + padding: '0.25rem 0.75rem', + cursor: 'pointer', +}; + +const th: React.CSSProperties = { padding: '6px 16px 6px 0', textAlign: 'left' }; +const td: React.CSSProperties = { padding: '8px 16px 8px 0', verticalAlign: 'middle' }; + +// Durations - each row's bar travels the track for exactly its own duration. Clicking Play +// bumps a counter used as the bar's React `key`, remounting it so the CSS transition restarts. +function DurationRow({ name }: { name: string }) { + const [tick, setTick] = useState(0); + return ( + + duration-{name} + {tokenVal(`duration-${name}`)} + +
+
+
+ + + + + + ); +} + +// Easings - a dot travels a fixed duration (--duration-slow) using each curve. Clicking the +// track (or the Play button) bumps a counter used as the dot's React `key`, remounting it so +// the CSS animation restarts. The track is sized so the spring curves' OVERSHOOT (past the +// target, then settle back) stays fully inside it - see the geometry note in the CSS below. +function EaseRow({ name }: { name: string }) { + const [tick, setTick] = useState(0); + const replay = () => setTick((t) => t + 1); + return ( + + ease-{name} + {tokenVal(`ease-${name}`)} + +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + replay(); + } + }} + > +
+
+ + + + + + ); +} + +// A preset player - clicking Play remounts the sample with the literal `animate-*` class so +// the ACTUAL Tailwind utility runs. `animationName` is the preset's label, also used as the +// badge text; the remount (key bump) restarts the "out" presets so they visibly animate away. +function PresetPlayer({ + cls, + animationName, +}: { + cls: string; + animationName: string; +}) { + const [tick, setTick] = useState(0); + return ( +
+
+ 0 ? `preset-badge ${cls}` : 'preset-badge'}> + {animationName} + +
+ +
+ ); +} function Motion() { return (
-

Motion - durations & easing

-

- Hover a bar to play its easing/duration token. +

Motion - durations, easing & presets

+

+ All motion composes the --duration-* and --ease-* tokens - the + durations and curves below, and the shipped animate-* presets, all read their + values from Roots. Consumers gate presets with motion-reduce:animate-none.

- {EASES.map((e, i) => ( -
- - ease-{e} · duration-{DURATIONS[i]} ({tokenVal(`duration-${DURATIONS[i]}`)}) - -
-
-
-
- ))} + +

Durations

+

+ Each bar travels the track for exactly its token duration. Click Play to replay. +

+ + + + + + + + + + {DURATIONS.map((d) => ( + + ))} + +
TokenValueTrack +
+ +

Easings

+

+ A dot crosses using each curve over --duration-slow. Click the track or the + Play button to replay. The spring and spring-strong curves + overshoot the target and settle back. +

+ + + + + + + + + + {EASES.map((e) => ( + + ))} + +
TokenValueTrack +
+ +

Presets (animate-* utilities)

+

+ Each preset runs the real Tailwind utility on a sample; Play remounts it to replay. The + composition line is derived from the tokens, so it can't drift. Gate every preset with{' '} + motion-reduce:animate-none. +

+ + + + + + + + + + {PRESETS.map((p) => ( + + + + + + ))} + +
PresetSampleComposition
animate-{p.name} + + + + animate-{p.name} = {p.anim} . duration-{p.duration} . ease-{p.ease} + +
+ {tokenVal(`duration-${p.duration}`)} · {tokenVal(`ease-${p.ease}`)} +
+ {p.note && ( +
+ + Reduced motion required + + {p.note} +
+ )} +
); } diff --git a/docs/feedback/0016-verify-motion-in-flight-not-at-rest.md b/docs/feedback/0016-verify-motion-in-flight-not-at-rest.md new file mode 100644 index 0000000..c4d015a --- /dev/null +++ b/docs/feedback/0016-verify-motion-in-flight-not-at-rest.md @@ -0,0 +1,37 @@ +# 0016 - A static screenshot verifies layout, not motion + +## Symptom + +The Storybook motion page (spec 0034) shipped its first pass with two motion defects that passed a +build + a full-page screenshot: (1) the easing track (`360px`, `overflow:hidden`) clipped +`ease-spring-strong`'s overshoot - the dot's peak (~392px) was cut off by ~30px, hiding the exact +bounce the row exists to demonstrate; and (2) a "hover the track to play" affordance was a CSS +no-op (the `:hover` rule re-declared the same `animation-name`, which does not restart a CSS +animation). Both were caught by the designer + engineer review, not by the pre-PR verification. + +## Root cause + +The change was verified by a single static full-page screenshot taken after load. A settled +screenshot captures the animation's REST state, so a mid-flight clip (or an overshoot that never +fit) is invisible - the dot had already returned inside the track by capture time. And the hover +"replay" was never actually watched firing; re-declaring an identical `animation-name` on `:hover` +produces no restart, so the affordance looked plausible in code but did nothing. + +## Fix + +- Size an overshoot/spring stage for the PEAK, not the rest position: peak = travel x + (max bezier y). Widened the track 360 -> 480px so the 392.5px peak clears with room. +- Verify motion at its EXTREMES: RAF-sampled the running animation to catch the max right edge, and + additionally froze the dot at the computed peak and screenshotted that - not just a settled frame. +- Replaced the dead `:hover` with a real click-to-replay (remounts via the state key), and only + claimed the affordance after watching it fire. + +## Learning + +When you verify MOTION, capture it IN FLIGHT, not at rest. A single settled screenshot proves +layout, not animation - it cannot see clipping, overshoot, or a mid-flight glitch. For any +spring/overshoot demo, size the container for the PEAK position (compute it, add clearance) and +prove the extreme is in-bounds by frame-sampling the live animation or freezing at the computed +peak. And never ship a motion affordance you have not watched trigger - a CSS `:hover` that +re-declares an identical `animation-name` is a no-op. Generalizes past this page to any motion demo +or animated component this design system builds. diff --git a/docs/overview/features.md b/docs/overview/features.md index 044598a..9e2b748 100644 --- a/docs/overview/features.md +++ b/docs/overview/features.md @@ -61,7 +61,10 @@ theme only; the dark remap + runtime switching are 0004. (`p-4` = 1rem) derive from a single `--spacing: 0.25rem` base. - **Foundations stories** - Storybook `Foundations` section renders ramps + semantic swatches, the Figtree specimen + type scale + weights + leading, spacing, radii, elevation, motion, and - a WCAG AA contrast table. The visual lock surface. + a WCAG AA contrast table. The visual lock surface. **Motion** documents the durations and + easings tables (literal values from `tokens`, with replayable bars and overshooting spring + tracks) plus interactive pop / shake / fade preset players that run the real `animate-*` + utilities and show each preset's token composition; `shake` is gated `motion-reduce:animate-none`. - **Contrast** - all primary text roles meet WCAG AA (≥ 4.5:1) on their intended surfaces; `text-subtle` (tertiary) meets AA-large (≥ 3:1), documented as for large/non-essential text. Guarded by an **executable contrast test** (`tokens.test.ts`) that resolves each role to its diff --git a/docs/overview/learnings.md b/docs/overview/learnings.md index 4f14f84..efc8881 100644 --- a/docs/overview/learnings.md +++ b/docs/overview/learnings.md @@ -521,3 +521,17 @@ encode the test that **fails** if it regresses (e.g. an occurrence-count guard), presence check can't see a duplicate. This recurs with [keyboard/controlled coverage (0014)](../feedback/0014-interactive-component-test-coverage.md): the shared root is testing the happy representative instead of the whole contract. + +## Verify motion in flight, not at rest + +The Storybook motion page (0034) shipped a first pass where the easing track clipped +`spring-strong`'s overshoot and a "hover to play" affordance was a dead CSS no-op - both passed a +build and a full-page screenshot, because a settled screenshot captures the REST state and the +overshoot had already returned in-bounds by capture time. Caught by review. See +[`docs/feedback/0016-verify-motion-in-flight-not-at-rest.md`](../feedback/0016-verify-motion-in-flight-not-at-rest.md). + +**Apply it:** a static screenshot proves layout, not animation - it cannot see clipping, overshoot, +or a mid-flight glitch. For any spring/overshoot demo, size the stage for the **peak** (compute +`travel x max-bezier-y`, add clearance) and prove the extreme is in-bounds by frame-sampling the +live animation or freezing at the computed peak. Never ship a motion affordance you have not +watched fire (a CSS `:hover` re-declaring an identical `animation-name` restarts nothing). diff --git a/docs/specs/0034-motion-foundations-page.md b/docs/specs/0034-motion-foundations-page.md new file mode 100644 index 0000000..9d64e6d --- /dev/null +++ b/docs/specs/0034-motion-foundations-page.md @@ -0,0 +1,78 @@ +# 0034 - Motion foundations page (tokens table + interactive presets) + +## Problem + +Canopy's Storybook Foundations has a `Motion` story, but it predates spec 0033: it shows only the +old three easings (`standard/emphasized/decelerate`) and three durations (`fast/base/slow`), paired +1:1, with no literal-value table and no demo of the animation presets. Since 0033 the token set is +larger (5 easings incl. `spring`/`spring-strong`, 5 durations incl. `micro`/`slower`) and now ships +ready-made `animate-*` presets (`pop-in/out`, `shake`, `fade-in/out`) - none of which are +documented or shown in motion. A designer or consumer has no single page to read the literal values +AND feel the motion. + +Who it is for: designers picking a curve/duration, and engineers choosing a preset - both want the +numbers and the motion side by side, in the living Storybook. + +## Outcome + +The Foundations `Motion` story becomes a complete motion reference, driven by the generated Roots +tokens (no hardcoded values), reading correctly in light and dark: + +- **Durations** - a table of all five (`micro` 80ms, `fast` 120ms, `base` 200ms, `slow` 320ms, + `slower` 480ms) with the literal value pulled from `tokens`, each with a replayable bar that + travels for exactly that duration. +- **Easings** - a table of all five (`standard`, `emphasized`, `decelerate`, `spring`, + `spring-strong`) with the literal `cubic-bezier(...)` value from `tokens`, each with an + interactive track (click/hover to send a dot across using that curve) so the overshoot of the + spring curves is visible. +- **Presets** - each `animate-*` preset (`pop-in`, `pop-out`, `shake`, `fade-in`, `fade-out`) + shown with: a Play button that runs the ACTUAL Tailwind utility on a sample element (re-triggered + via a React key remount), and its composition (e.g. `animate-pop-in = pop-in . duration-base . + ease-spring`) read from the token values. The `shake` demo is gated with + `motion-reduce:animate-none` and carries a visible note that this gating is mandatory (a + vestibular trigger), modelling correct consumer usage. +- A short intro paragraph noting all motion composes the `--duration-*` / `--ease-*` tokens and + that consumers gate presets with `motion-reduce`. + +## Scope + +In: + +- Rewrite the `Motion` component in `apps/storybook/src/Foundations.stories.tsx` (still the + `Foundations` / `Motion` story - this IS the core-tokens page; no new story file). +- Use FULL literal class names for the `animate-*` utilities (`animate-pop-in`, ... ) so Tailwind + v4's source scanner emits them (same rule the file already notes for radii/shadows). +- Values come from the typed `tokens` export (already imported), matching the other Foundations + sections; nothing hardcoded. + +Out: + +- No new Roots tokens or preset changes (0033 shipped those; this only documents them). +- No changes to other Foundations sections (colours, type, spacing, radii, elevation, contrast). +- `spring-strong` has no shipped `animate-*` preset (by design); it appears in the easings table + + track, not as a preset player. Not adding a preset just to demo it. +- No visual-regression/Playwright test committed to the repo (rendering is verified during build). + +## Approach + +Mirror the existing Foundations idioms exactly: the `wrap`/`h2` styles, `tokenVal()` reader, a +scoped `