trykeyline 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/.claude-plugin/marketplace.json +18 -0
  2. package/LICENSE +37 -0
  3. package/README.md +117 -2
  4. package/bin/keyline.mjs +280 -0
  5. package/package.json +38 -4
  6. package/plugins/keyline/.claude-plugin/plugin.json +14 -0
  7. package/plugins/keyline/skills/keyline/SKILL.md +57 -0
  8. package/plugins/keyline/skills/keyline/commands/audit.md +25 -0
  9. package/plugins/keyline/skills/keyline/commands/color.md +7 -0
  10. package/plugins/keyline/skills/keyline/commands/critique.md +43 -0
  11. package/plugins/keyline/skills/keyline/commands/depth.md +7 -0
  12. package/plugins/keyline/skills/keyline/commands/init.md +15 -0
  13. package/plugins/keyline/skills/keyline/commands/layout.md +7 -0
  14. package/plugins/keyline/skills/keyline/commands/motion.md +7 -0
  15. package/plugins/keyline/skills/keyline/commands/patterns.md +7 -0
  16. package/plugins/keyline/skills/keyline/commands/polish.md +19 -0
  17. package/plugins/keyline/skills/keyline/commands/refine.md +29 -0
  18. package/plugins/keyline/skills/keyline/commands/sync.md +17 -0
  19. package/plugins/keyline/skills/keyline/commands/type.md +7 -0
  20. package/plugins/keyline/skills/keyline/domains/color/deriving-a-ramp.md +144 -0
  21. package/plugins/keyline/skills/keyline/domains/color/guide.md +243 -0
  22. package/plugins/keyline/skills/keyline/domains/color/status-and-exceptions.md +174 -0
  23. package/plugins/keyline/skills/keyline/domains/color/theming-mechanics.md +121 -0
  24. package/plugins/keyline/skills/keyline/domains/depth/elevation-recipes.md +191 -0
  25. package/plugins/keyline/skills/keyline/domains/depth/fallbacks-and-performance.md +132 -0
  26. package/plugins/keyline/skills/keyline/domains/depth/guide.md +255 -0
  27. package/plugins/keyline/skills/keyline/domains/depth/translucent-materials.md +159 -0
  28. package/plugins/keyline/skills/keyline/domains/layout/forks-gating-and-disclosures.md +201 -0
  29. package/plugins/keyline/skills/keyline/domains/layout/guide.md +284 -0
  30. package/plugins/keyline/skills/keyline/domains/layout/measure-and-rhythm.md +193 -0
  31. package/plugins/keyline/skills/keyline/domains/layout/radius-lines-and-layers.md +122 -0
  32. package/plugins/keyline/skills/keyline/domains/motion/guards.md +162 -0
  33. package/plugins/keyline/skills/keyline/domains/motion/guide.md +234 -0
  34. package/plugins/keyline/skills/keyline/domains/motion/ladder-and-curves.md +139 -0
  35. package/plugins/keyline/skills/keyline/domains/motion/patterns.md +196 -0
  36. package/plugins/keyline/skills/keyline/domains/motion/performance.md +121 -0
  37. package/plugins/keyline/skills/keyline/domains/patterns/actions-and-forms.md +204 -0
  38. package/plugins/keyline/skills/keyline/domains/patterns/agents-and-live-states.md +111 -0
  39. package/plugins/keyline/skills/keyline/domains/patterns/collections-and-dashboards.md +131 -0
  40. package/plugins/keyline/skills/keyline/domains/patterns/copy.md +123 -0
  41. package/plugins/keyline/skills/keyline/domains/patterns/guide.md +216 -0
  42. package/plugins/keyline/skills/keyline/domains/patterns/iteration.md +78 -0
  43. package/plugins/keyline/skills/keyline/domains/type/display-tier.md +108 -0
  44. package/plugins/keyline/skills/keyline/domains/type/guide.md +251 -0
  45. package/plugins/keyline/skills/keyline/domains/type/ladders.md +169 -0
  46. package/plugins/keyline/skills/keyline/domains/type/numerals-and-loading.md +107 -0
  47. package/plugins/keyline/skills/keyline/domains/type/prose-and-emphasis.md +125 -0
  48. package/plugins/keyline/skills/keyline/domains/type/type-audit.md +90 -0
  49. package/plugins/keyline/skills/keyline/glass/color/assets/color-tokens.css +319 -0
  50. package/plugins/keyline/skills/keyline/glass/color/assets/theme.ts +119 -0
  51. package/plugins/keyline/skills/keyline/glass/color/guide.md +222 -0
  52. package/plugins/keyline/skills/keyline/glass/color/intent-ramp.md +136 -0
  53. package/plugins/keyline/skills/keyline/glass/color/theming.md +112 -0
  54. package/plugins/keyline/skills/keyline/glass/depth/assets/GlassPanel.tsx +171 -0
  55. package/plugins/keyline/skills/keyline/glass/depth/assets/glass.css +322 -0
  56. package/plugins/keyline/skills/keyline/glass/depth/blur-and-tint.md +50 -0
  57. package/plugins/keyline/skills/keyline/glass/depth/depth-cues.md +87 -0
  58. package/plugins/keyline/skills/keyline/glass/depth/guide.md +216 -0
  59. package/plugins/keyline/skills/keyline/glass/depth/performance-and-fallbacks.md +37 -0
  60. package/plugins/keyline/skills/keyline/glass/layout/adaptive-presentations.md +231 -0
  61. package/plugins/keyline/skills/keyline/glass/layout/assets/SectionNav.tsx +244 -0
  62. package/plugins/keyline/skills/keyline/glass/layout/assets/layout-tokens.css +199 -0
  63. package/plugins/keyline/skills/keyline/glass/layout/guide.md +258 -0
  64. package/plugins/keyline/skills/keyline/glass/layout/measure-and-rhythm.md +134 -0
  65. package/plugins/keyline/skills/keyline/glass/layout/radius-and-elevation.md +102 -0
  66. package/plugins/keyline/skills/keyline/glass/motion/assets/DropMenu.tsx +106 -0
  67. package/plugins/keyline/skills/keyline/glass/motion/assets/motion-presets.ts +210 -0
  68. package/plugins/keyline/skills/keyline/glass/motion/assets/motion-tokens.css +176 -0
  69. package/plugins/keyline/skills/keyline/glass/motion/assets/useReveal.ts +65 -0
  70. package/plugins/keyline/skills/keyline/glass/motion/assets/useScrollTick.ts +183 -0
  71. package/plugins/keyline/skills/keyline/glass/motion/easing-and-duration.md +108 -0
  72. package/plugins/keyline/skills/keyline/glass/motion/guide.md +286 -0
  73. package/plugins/keyline/skills/keyline/glass/motion/reduced-motion.md +81 -0
  74. package/plugins/keyline/skills/keyline/glass/motion/scroll-performance.md +58 -0
  75. package/plugins/keyline/skills/keyline/glass/motion/shared-element.md +161 -0
  76. package/plugins/keyline/skills/keyline/glass/motion/sound.md +192 -0
  77. package/plugins/keyline/skills/keyline/glass/motion/springs.md +51 -0
  78. package/plugins/keyline/skills/keyline/glass/type/assets/prose.tsx +268 -0
  79. package/plugins/keyline/skills/keyline/glass/type/assets/type-tokens.css +414 -0
  80. package/plugins/keyline/skills/keyline/glass/type/guide.md +222 -0
  81. package/plugins/keyline/skills/keyline/glass/type/long-form-prose.md +151 -0
  82. package/plugins/keyline/skills/keyline/glass/type/scale.md +69 -0
  83. package/plugins/keyline/skills/keyline/glass/type/weight-and-tracking.md +61 -0
  84. package/plugins/keyline/skills/keyline/scripts/check/css.mjs +112 -0
  85. package/plugins/keyline/skills/keyline/scripts/check/design.mjs +67 -0
  86. package/plugins/keyline/skills/keyline/scripts/check/files.mjs +44 -0
  87. package/plugins/keyline/skills/keyline/scripts/check/markup.mjs +149 -0
  88. package/plugins/keyline/skills/keyline/scripts/check/rules.mjs +584 -0
  89. package/plugins/keyline/skills/keyline/scripts/check.mjs +294 -0
  90. package/plugins/keyline/skills/keyline/scripts/contrast.mjs +153 -0
  91. package/plugins/keyline/skills/keyline/scripts/scan.mjs +340 -0
  92. package/plugins/keyline/skills/keyline/system/design-md.md +136 -0
  93. package/plugins/keyline/skills/keyline/system/detection.md +53 -0
  94. package/plugins/keyline/skills/keyline/system/guide.md +101 -0
  95. package/plugins/keyline/skills/keyline/system/presets.md +58 -0
  96. package/plugins/keyline/skills/keyline/system/roles.md +150 -0
@@ -0,0 +1,139 @@
1
+ # Ladder, curves and springs
2
+
3
+ The numbers behind the `## Dials` table, the reasoning that sets them, and how to derive a project's ladder from what it already ships.
4
+
5
+ ## Why area, not importance
6
+
7
+ The eye needs time in proportion to the distance it travels and the area it re-reads: a background colour changing under a 40px button is one fixation, a panel sliding in is a new region to parse, and the whole page changing colour is a re-read of everything.
8
+
9
+ | Area changing | Role | editorial | product | expressive |
10
+ |---|---|---|---|---|
11
+ | A pressed element's own fill or scale | `duration.instant` | 0 | 0 | 0 |
12
+ | One property, no position | `duration.micro` | 100ms | 80ms | 120ms |
13
+ | One small element | `duration.state` | 150ms | 100ms | 200ms |
14
+ | A small element and its surroundings | `duration.popover` | 200ms | 150ms | 280ms |
15
+ | A large region entering or collapsing | `duration.overlay` | 300ms | 200ms | 450ms |
16
+ | The viewport, on the critical path | `duration.route` | 250ms | — | 400ms |
17
+ | Everything, at once | `duration.theme` | 400ms | 250ms | 600ms |
18
+
19
+ `duration.route` sits below `duration.overlay` because law 3 caps it: a page transition is latency the user blames on the product. Below ~200ms a full-viewport fade looks like a rendering glitch, so `product` drops it rather than shortening it.
20
+
21
+ **If unsure which tier, it's `duration.state`**, which does most of the work in every preset. `product` is shortest because its user is fluent and repeats everything; `expressive` is longest on entrances because they are usually seen once.
22
+
23
+ **Above ~150ms on a hover state**, a fast pointer across a list or a dense table leaves a visible trail. That caps `duration.micro` and `duration.state` for anything hovered in bulk, in any preset, so `expressive` spends its longer `duration.state` on entrances and single controls and keeps list-row hovers at `duration.micro`. Above ~200ms, `editorial`'s `duration.state` makes reading chrome feel heavy.
24
+
25
+ **A large surface is the opposite case.** A row is a thin strip the pointer crosses; a card on a board is an area the eye rests on, and the same 80ms colour change across it looks instant, not smooth. The area sets the tier here as everywhere, so a card's wash takes about 200ms in and a slower 250ms out: the pointer has already left, the fade blocks nothing, and a sweep across the board fades behind it instead of strobing. Name only `background-color` (and `border-color` if it moves), never `transform` or `box-shadow`: in `product` a card that rises or darkens its shadow under the pointer claims it can be picked up. `expressive` lets primary navigation lift, because there the travel is the invitation.
26
+
27
+ ## Exits
28
+
29
+ An exit runs at about two-thirds of its entrance, on `ease.exit` (an `ease-in`, accelerating away, so the element reads as leaving rather than deleted), and travels about half the entrance's offset.
30
+
31
+ | Entrance | editorial exit | product exit | expressive exit |
32
+ |---|---|---|---|
33
+ | A menu or popover | 130ms | 100ms | 180ms |
34
+ | A sheet or panel | 200ms | 130ms | 280ms |
35
+
36
+ ## Curves
37
+
38
+ | Role | editorial | product | expressive |
39
+ |---|---|---|---|
40
+ | `ease.standard` | `cubic-bezier(0.22, 1, 0.36, 1)` | `cubic-bezier(0.2, 0, 0, 1)` | `cubic-bezier(0.34, 1.2, 0.64, 1)` |
41
+ | `ease.colour` | `ease` | `ease-out` | `ease` |
42
+ | `ease.exit` | `ease-in` | `ease-in` | `ease-in` |
43
+
44
+ `editorial`'s curve decelerates hard and never leaves [0, 1], so it is legal on every property. `product`'s front-loads even more, so a 100ms transition has visibly arrived by 50ms, and doesn't overshoot either. `expressive`'s end control point above 1 overshoots slightly — physical on a transform, never a bounce — which costs law 5's floor check on every track.
45
+
46
+ **Where `ease.standard` overshoots, find every track carrying a floored property** — `blur()`, `brightness()`, `saturate()` toward 0, a radius toward 0, a scale that must not invert — and give it a monotone curve. `cubic-bezier(0.16, 1, 0.3, 1)` arrives as decisively without passing its target: the same intent, legal for the property, not a second character. Record it in `DESIGN.md` as a proposed role, `ease.floor`, if needed.
47
+
48
+ A theme cross-fade is always monotone too: it is the largest change available, and overshoot on colour is a wobble in the light.
49
+
50
+ ### Library equivalents
51
+
52
+ | | CSS | Motion | GSAP |
53
+ |---|---|---|---|
54
+ | A bezier | `cubic-bezier(a, b, c, d)` | `ease: [a, b, c, d]` | `CustomEase.create("standard", "a,b,c,d")` |
55
+ | `ease-in` | `ease-in` | `"easeIn"` | `"power1.in"` |
56
+ | `ease-out` | `ease-out` | `"easeOut"` | `"power1.out"` |
57
+
58
+ React Spring is physics-only; use a spring config, or its `easings` with a `duration` for the colour track.
59
+
60
+ ## Springs
61
+
62
+ Two vocabularies describe the same physics:
63
+
64
+ - **`bounce` + `duration`** (Motion): `bounce` 0 is critically damped and `duration` is roughly the settle time, so arrival is predictable beside a bezier.
65
+ - **`stiffness` + `damping` + `mass`** (Motion; React Spring's `tension` / `friction` / `mass`): needed when the relationship between two springs is the point, as in squash and stretch.
66
+
67
+ For intuition rather than conversion, damping ratio ζ = damping ÷ (2 · √(stiffness · mass)), and bounce ≈ 1 − ζ. Tune each spring in one vocabulary.
68
+
69
+ | Character | Used for | editorial | product | expressive |
70
+ |---|---|---|---|---|
71
+ | `spring.snappy` | A shared element re-targeting; a dragged item released | bounce 0.15, 0.35s · 400 / 34 | bounce 0, 0.25s · 600 / 49 | bounce 0.2, 0.4s · 380 / 31 |
72
+ | `spring.soft` | A panel or card entering | bounce 0.2, 0.45s · 260 / 26 | — (tween) | bounce 0.3, 0.6s · 180 / 19 |
73
+ | `spring.playful` | The one delight beat | bounce 0.3, 0.5s · 300 / 24, once | — | bounce 0.45, 0.7s · 220 / 16 |
74
+
75
+ Stiffness / damping pairs assume mass 1. `snappy` stays near the floor of "perceptibly springy": a highlight is a thing moving, not arriving, and one that settles at every stop draws attention to itself instead of what it marks. `product` keeps springs to real momentum — a dragged card, a thrown sheet — because a fluent user notices a spring's variable settle time. `editorial`'s one `playful` spring is one delight beat: against restraint, one exuberant interaction reads as craft, three as a template.
76
+
77
+ ```ts
78
+ // Motion
79
+ const snappy = { type: "spring", bounce: 0.15, duration: 0.35 };
80
+ // Opacity always split out as its own short tween (in GSAP, a separate tween beside the move)
81
+ transition={{ ...<spring.snappy>, opacity: { duration: <duration.micro>, ease: <ease.colour> } }}
82
+ // React Spring
83
+ const snappy = { tension: 400, friction: 34 };
84
+ // GSAP has no spring solver; to re-target use gsap.quickTo(el, "x", { duration: 0.35, ease: "power3.out" }),
85
+ // which re-targets smoothly from the current value.
86
+ // CSS: linear() can sample a spring's curve but can't re-target mid-flight — entrances only.
87
+ ```
88
+
89
+ ### Tuning by symptom
90
+
91
+ Change one control at a time.
92
+
93
+ | Symptom | Fix |
94
+ |---|---|
95
+ | Floaty, slow to commit | ↑ stiffness, or ↓ duration |
96
+ | Wobbles too many times | ↑ damping, or ↓ bounce |
97
+ | Snaps, feels mechanical | ↓ damping slightly |
98
+ | Slow to start moving | ↓ mass |
99
+ | Too eager, no weight | ↑ mass |
100
+ | Flickers at the end of a fade | opacity is still on the spring — split it out |
101
+ | Takes over a second across a long distance | raise stiffness with distance, or use a tween |
102
+
103
+ ### Squash and stretch
104
+
105
+ A light, underdamped spring on the grow axis (high stiffness, low damping, mass below 1) and a stiffer, heavily damped one on the cross axis: through the overshoot the element is briefly taller and narrower, then settles — the animation principle from physics, interruptible for free, with deformation reading as mass. Travel on the grow axis rides the same spring as its scale, or the anchored edge detaches from the trigger mid-flight. In the Glass preset the pair is 620 / 19 / 0.85 against 380 / 30. `editorial` may spend this once; `product` never; `expressive` may use it on menus throughout.
106
+
107
+ ## Emitting tokens
108
+
109
+ A fictional project, *Ledgerline* — bookkeeping software on `product`, with `app/(site)/**` on `expressive`. The app's tokens, in its existing `--motion-*` style:
110
+
111
+ ```css
112
+ :root {
113
+ --motion-micro: 80ms; /* duration.micro — row opacity on hover */
114
+ --motion-state: 100ms; /* duration.state — cell colour, icon swaps */
115
+ --motion-popover: 150ms; /* duration.popover — menus, date picker */
116
+ --motion-overlay: 200ms; /* duration.overlay — inspector panel, sheets */
117
+ --motion-theme: 250ms; /* duration.theme */
118
+ --motion-exit: 100ms; /* duration.exit — ease-in, flat */
119
+ --motion-ease: cubic-bezier(0.2, 0, 0, 1); /* ease.standard */
120
+ --motion-ease-colour: ease-out; /* ease.colour */
121
+ --motion-travel: 12px; /* a sheet's slide as it fades in */
122
+ }
123
+ @media (prefers-reduced-motion: reduce) {
124
+ :root { --motion-travel: 0px; } /* still fades, no longer slides */
125
+ }
126
+ ```
127
+
128
+ In Tailwind v4, `--ease-*` in `@theme` generates `ease-*` utilities; durations have no theme namespace, so use `duration-(--motion-state)`. A v3 config extends `transitionDuration` and `transitionTimingFunction`; a JS theme object carries `motion.duration.state` and `motion.spring.snappy` beside its colours.
129
+
130
+ ## Deriving a project's ladder from its code
131
+
132
+ A worked case: a codebase with 31 distinct durations.
133
+
134
+ 1. **Cluster by value and by what moves.** 150ms, 160ms and 175ms are all hover colours: one tier. 300ms and 350ms are both drawers: one tier. A lone 1200ms is a toast's auto-dismiss, a timer rather than motion, so out of scope.
135
+ 2. **Name each cluster's role** by the area it changes, not its name in code: a `--slow` token on every tooltip is `duration.popover`.
136
+ 3. **Compare with the preset.** A consistent cluster within ~20ms of the dial keeps its value, recorded, because users already know its rhythm; a scattered one snaps to the dial.
137
+ 4. **Check the ordering.** Tooltips (popover) that outrun drawers (overlay) put the ladder upside down: an open issue whatever the numbers.
138
+ 5. **Class what's left** under law 4 or list it as a stray; nothing remains unclassed. One component can hold both: in a Glass preset gallery, a photograph dissolving to the next is the subject, but the same photograph moving between layouts is state, in the overlay tier.
139
+ 6. **Write it to `DESIGN.md`** with the counts: *"`duration.state` → `--dur-fast` 150ms; 42 uses; 9 strays at 160–180ms snapped."*
@@ -0,0 +1,196 @@
1
+ # Patterns
2
+
3
+ Code uses role placeholders (`<duration.state>`) and generic custom properties; swap in the names `DESIGN.md` maps them to.
4
+
5
+ ## The shared highlight
6
+
7
+ One highlight per group, animating its position and size to whichever item is hovered or focused. It never remounts.
8
+
9
+ ```tsx
10
+ // Motion. The same shape works with GSAP quickTo or React Spring's useSpring.
11
+ const [hover, setHover] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
12
+ const enter = (el: HTMLElement) =>
13
+ setHover({ x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight });
14
+
15
+ <ul className="group-shell" onPointerLeave={() => setHover(null)}> {/* position: relative; isolation: isolate */}
16
+ <m.div
17
+ aria-hidden
18
+ className="highlight" /* absolute; z-index: -1; pointer-events: none */
19
+ initial={false}
20
+ animate={hover ? { x: hover.x, y: hover.y, width: hover.w, height: hover.h, opacity: 1 } : { opacity: 0 }}
21
+ transition={reduce ? { duration: <duration.state> } : { ...<spring.snappy>, opacity: { duration: <duration.micro> } }}
22
+ />
23
+ {items.map((it) => (
24
+ <a key={it.id} onPointerEnter={(e) => enter(e.currentTarget)} onFocus={(e) => enter(e.currentTarget)} … />
25
+ ))}
26
+ </ul>
27
+ ```
28
+
29
+ Six details, each a bug without it:
30
+
31
+ - **`initial={false}`** (outside Motion, set the first position with no transition), or the highlight flies in from the corner on first paint.
32
+ - **Offsets, not `getBoundingClientRect()`.** `offsetLeft` / `offsetTop` are relative to the positioned parent the highlight lives in; viewport rectangles need subtraction and break if the page scrolls between measure and render.
33
+ - **`isolation: isolate` on the group, `z-index: -1` on the highlight**: behind the items but inside the group's stacking context, so it can't drop behind the group's own surface.
34
+ - **`pointer-events: none`**: the highlight sits under the pointer by definition, and would otherwise steal the enter event and oscillate.
35
+ - **Focus alongside hover**, so keyboard users get the same feedback.
36
+ - **Leave on the group, not the items**: per-item leave fires in the gaps and blanks the highlight between items.
37
+
38
+ A tooltip rides the same state on one axis: an outer element animates `x` to the item's centre; an inner one is statically `translate: -50% 0`. A plain coordinate animates cleanly; `calc(centre - 50%)` asks the library to interpolate mixed units.
39
+
40
+ **Current versus hovered.** The current item's static surface drops while the group is hovered, so the travelling highlight owns the surface; two at once make the selection ambiguous. The current item keeps its label colour (colour law 7) throughout, so its place is never lost.
41
+
42
+ ## Layout switches and page-level shared elements
43
+
44
+ For an element that persists across a DOM change — a card becoming a detail view, a thumbnail becoming the open image:
45
+
46
+ ```css
47
+ /* CSS View Transitions — same-document via document.startViewTransition(update),
48
+ cross-document via @view-transition { navigation: auto; } */
49
+ .card-image { view-transition-name: var(--vt-name); } /* unique per element, e.g. set inline per card */
50
+ ::view-transition-group(*) { animation-duration: <duration.overlay>; animation-timing-function: <ease.standard>; }
51
+ @media (prefers-reduced-motion: reduce) {
52
+ ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none; }
53
+ }
54
+ ```
55
+
56
+ - **Motion:** give both ends the same `layoutId`; the library measures and travels between them.
57
+ - **GSAP:** `const state = Flip.getState(els); /* change the DOM */ Flip.from(state, { duration, ease });`
58
+
59
+ Whichever tool, **measure the reading position while the outgoing layout is still rendered** — the item nearest the viewport's centre — and open the incoming layout on it. Where the ends differ in aspect ratio, cross-fade two elements, each at its own aspect and sharing a centre, rather than stretching one into the other's box.
60
+
61
+ ## Presence: open and close
62
+
63
+ ```css
64
+ /* CSS only: a popover or dialog entering from display: none */
65
+ .menu {
66
+ transition: opacity <duration.popover> <ease.colour>, translate <duration.popover> <ease.standard>,
67
+ display <duration.popover> allow-discrete, overlay <duration.popover> allow-discrete;
68
+ opacity: 1; translate: 0 0;
69
+ }
70
+ @starting-style { .menu:popover-open { opacity: 0; translate: 0 calc(var(--motion-travel) * -1); } }
71
+ .menu:not(:popover-open) {
72
+ opacity: 0; translate: 0 calc(var(--motion-travel) * -0.5); /* the exit travels half as far */
73
+ transition-duration: var(--motion-exit); transition-timing-function: <ease.exit>;
74
+ }
75
+ ```
76
+
77
+ ```tsx
78
+ // Motion: AnimatePresence keeps the element mounted for its exit.
79
+ <AnimatePresence>
80
+ {open && (
81
+ <m.div
82
+ initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: -4 }}
83
+ animate={{ opacity: 1, scale: 1, y: 0, transition: reduce ? { duration: <duration.state> } : <spring.soft> }}
84
+ exit={{ opacity: 0, ...(reduce ? {} : { scale: 0.98, y: -2 }), transition: { duration: <exit>, ease: <ease.exit> } }}
85
+ />
86
+ )}
87
+ </AnimatePresence>
88
+ ```
89
+
90
+ Scale from the edge the element is anchored to (`transform-origin` at the trigger): a menu growing from its own middle seems to come from nowhere. The wrapper moves; a child carries the translucent material.
91
+
92
+ ## Fill means chosen, in code
93
+
94
+ The filled state is a second layer over the outline, at the same coordinates, cross-fading in place:
95
+
96
+ ```css
97
+ .icon .fill { opacity: 0; transition: opacity <duration.popover> <ease.colour>; }
98
+ .icon .detail { opacity: 1; transition: opacity <duration.popover> <ease.colour>; } /* interior lines a solid shape swallows */
99
+ .is-current .icon .fill, .item:hover .icon .fill { opacity: 1; }
100
+ .is-current .icon .detail, .item:hover .icon .detail { opacity: 0; }
101
+ @media (prefers-reduced-motion: reduce) { .icon .fill, .icon .detail { transition: none; } }
102
+ ```
103
+
104
+ Where the solid shape would swallow an interior line, knock it out with a mask rather than deleting it: the silhouette in white (fill and stroke, matching the outline exactly), the interior lines in black at the outline's stroke width. A solid chip chosen instead of fill follows the same rule: one treatment, one meaning.
105
+
106
+ ## Hover intent
107
+
108
+ ```ts
109
+ const closeSoon = () => {
110
+ clearTimeout(timer.current);
111
+ timer.current = window.setTimeout(() => setOpen(false), 100);
112
+ };
113
+ const cancelClose = () => clearTimeout(timer.current);
114
+ useEffect(() => () => clearTimeout(timer.current), []); // unmount
115
+ useEffect(() => setOpen(false), [pathname]); // navigation
116
+ ```
117
+
118
+ Bind `closeSoon` to leaving the trigger *and* the panel, `cancelClose` to entering either. Any `setTimeout` driving UI state is cleared on unmount and reset on navigation, or a menu opened on one page reappears 100ms into the next. Where they're adjacent, a hover bridge is cheaper: padding on the wrapper spanning the visual gap, so the pointer never leaves the hover region.
119
+
120
+ ## Hover on a card
121
+
122
+ A wash, with the leave on the resting rule and the arrival on the hovered one, so each direction takes its own time:
123
+
124
+ ```css
125
+ .card { transition: background-color <hover out> <ease.colour>; } /* the leave */
126
+ .card:hover { background-color: var(--surface-hover); transition-duration: <hover in>; }
127
+ ```
128
+
129
+ No `translate`, no shadow change, one named property; the bundle stays where the depth guide put it. Rows keep `duration.micro` both ways.
130
+
131
+ ## Press
132
+
133
+ `transform: scale(<press>)` on `:active`, `transition-duration: 0` in and `duration.state` back out, on `pointerdown`, not `click`. In `product`, prefer the `surface.pressed` step: a toolbar of twenty shrinking buttons is noise. Anything wider than about 200px presses with `scale(0.98)` or a 1px `translate` instead: at 0.92–0.95 a full-width button loses about 15px and reads as collapsing, not pressed.
134
+
135
+ ## Live indicators
136
+
137
+ An indicator that reports a live state — level bars while recording, a stroke travelling round a card while something is generated — moves only while the state does, and turns into its action on demand.
138
+
139
+ ```css
140
+ .live .bars span { animation: level 900ms <ease.standard> infinite alternate; }
141
+ .live[data-paused] .bars span { animation: none; transform: scaleY(0.25); } /* settle low, don't freeze mid-peak */
142
+ .live .stop, .live .bars { transition: opacity <duration.state> <ease.colour>; }
143
+ .live .stop { opacity: 0; }
144
+ .live:is(:hover, :focus-visible) .stop { opacity: 1; }
145
+ .live:is(:hover, :focus-visible) .bars { opacity: 0; }
146
+ @media (prefers-reduced-motion: reduce) { .live .bars span { animation: none; } }
147
+ ```
148
+
149
+ - **Paused settles, it doesn't freeze.** A loop stopped mid-cycle leaves a peak that looks like a glitch; hold a low resting frame, or stop a travelling stroke and dim its glow. Bars driven by a real input level fall on their own; a decorative loop has to be told.
150
+ - **The morph is in place.** Bars and stop square share one box and cross-fade, so the control never moves under the pointer.
151
+ - **The name is the action throughout** — a button named "Stop recording" — because on touch nothing hovers and the tap is the stop.
152
+
153
+ ## Theme cross-fade
154
+
155
+ A universal colour transition left in the stylesheet makes every hover on the site `duration.theme` and sluggish. Apply it only for the toggle:
156
+
157
+ ```ts
158
+ root.classList.add("theme-transition");
159
+ applyTheme(choice);
160
+ setTimeout(() => root.classList.remove("theme-transition"), THEME_MS + 40);
161
+ ```
162
+
163
+ ```css
164
+ @media (prefers-reduced-motion: no-preference) {
165
+ :root.theme-transition, :root.theme-transition *, :root.theme-transition *::before, :root.theme-transition *::after {
166
+ transition: background-color <duration.theme> <ease.colour>, color <duration.theme> <ease.colour>,
167
+ border-color <duration.theme> <ease.colour>, fill <duration.theme> <ease.colour>,
168
+ stroke <duration.theme> <ease.colour> !important;
169
+ }
170
+ }
171
+ ```
172
+
173
+ The `!important` beats each component's own transition for that window; removing the class stops it poisoning everything after. Pseudo-elements are listed because `*` doesn't match them, and decoration built with them would snap. The curve is `ease.colour`, never one that overshoots: an overshooting colour passes its target and returns. `duration.theme` runs only under this class, when the page itself switches.
174
+
175
+ A theme that follows the OS by media query changes with no toggle to hook, and every control carrying its own colour transition fades at its own duration while the page around it snaps. So everyday colour transitions never include the page background, and a theme change suspends them: listen for the query's `change` and hold a class for one frame.
176
+
177
+ ```ts
178
+ matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
179
+ if (readChoice() !== "system") return; // the reader chose a theme: the OS doesn't override it
180
+ root.classList.add("theme-instant"); /* :root.theme-instant * { transition: none !important; } */
181
+ applyTheme(e.matches ? "dark" : "light");
182
+ requestAnimationFrame(() => requestAnimationFrame(() => root.classList.remove("theme-instant")));
183
+ });
184
+ ```
185
+
186
+ The theme mechanism and flash-free first paint belong to the colour guide.
187
+
188
+ ## Sound, if the preset allows it
189
+
190
+ Sound is off wherever the user didn't choose to be there. Where it exists, synthesise it with Web Audio rather than shipping files — no request, no decode latency, so it lands with the input — and, non-negotiably:
191
+
192
+ - **Arm the context on every activation gesture:** `pointerdown`, `keydown` and `touchstart`. Hover is activation in no engine, and a keyboard Enter dispatches `click`, not `pointerdown`, so a pointer-only unlock leaves keyboard users in silence for ever. `wheel` activates nothing; leave it out.
193
+ - **Hover sounds for `pointerType === "mouse"` only**, deduplicated per element: a touch tap fires `pointerover` just before `click`, and plays both.
194
+ - **Dense surfaces opt out of hover sound** with an attribute on the container; above about three ticks a second it stops being feedback.
195
+ - **A visible, persisted mute**, checked at the top of playback rather than at the listener, so it applies to in-flight input; storage access wrapped so it can't throw.
196
+ - **Two sounds is a texture; five is a soundtrack.** Press and, at most, hover.
@@ -0,0 +1,121 @@
1
+ # Performance
2
+
3
+ ## The one scroll shape
4
+
5
+ Every scroll-driven behaviour uses one structure. At its smallest, with `update` clearing the flag:
6
+
7
+ ```ts
8
+ let ticking = false;
9
+ const onScroll = () => { if (!ticking) { ticking = true; requestAnimationFrame(update); } };
10
+ window.addEventListener("scroll", onScroll, { passive: true });
11
+ ```
12
+
13
+ In full, with teardown:
14
+
15
+ ```ts
16
+ function onScrollFrame(update: () => void): () => void {
17
+ let ticking = false;
18
+ const run = () => { ticking = false; update(); };
19
+ const onScroll = () => { if (!ticking) { ticking = true; requestAnimationFrame(run); } };
20
+ update(); // correct on a page loaded already scrolled
21
+ window.addEventListener("scroll", onScroll, { passive: true });
22
+ window.addEventListener("resize", onScroll); // a resize invalidates the same measurements
23
+ return () => {
24
+ window.removeEventListener("scroll", onScroll);
25
+ window.removeEventListener("resize", onScroll);
26
+ };
27
+ }
28
+ ```
29
+
30
+ - **`passive: true`**, as the handler never calls `preventDefault()`; without it every scroll event blocks on your JavaScript, the commonest cause of scroll jank.
31
+ - **The `ticking` flag** makes a burst of events (a high-resolution trackpad fires many per frame) one measurement per painted frame.
32
+ - **No `throttle` or `debounce`.** A time-based throttle is faster than the frame rate, wasting work, or slower, dropping frames.
33
+ - **One listener per behaviour**, torn down on unmount. In a component, make the effect's dependency a value, not a fresh array — `ids.join("|")` — or the listener rebuilds every render.
34
+
35
+ A purely visual effect can use CSS scroll-driven animations (`animation-timeline: scroll()` or `view()`), off the main thread with no listener, behind `@supports (animation-timeline: scroll())` with a static fallback and inside `prefers-reduced-motion: no-preference`.
36
+
37
+ ## Hysteresis
38
+
39
+ Anything toggled by scroll *direction* needs a dead zone, or a pixel of jitter — trackpad noise, a rubber-band bounce, your own layout change — flips it for ever.
40
+
41
+ ```ts
42
+ const update = () => {
43
+ const y = window.scrollY;
44
+ const atBottom = y + window.innerHeight >= document.documentElement.scrollHeight - 2;
45
+ if (y < 24 || atBottom) setCollapsed(false); // always expanded at either end — checked first
46
+ else if (y > last + 6) setCollapsed(true); // 6px of downward intent
47
+ else if (y < last - 6) setCollapsed(false); // 6px of upward intent
48
+ last = y;
49
+ };
50
+ ```
51
+
52
+ The end zones come first, or a small upward scroll near the foot leaves a collapsed bar over the footer. The `- 2` absorbs sub-pixel rounding on fractional device pixel ratios.
53
+
54
+ ## Never hide an affordance where it can't come back
55
+
56
+ Gate the effect before attaching anything — layout law 8, with the code in `domains/layout/forks-gating-and-disclosures.md`.
57
+
58
+ ## Scroll spies
59
+
60
+ Tracking the section being read, for a contents rail:
61
+
62
+ ```ts
63
+ const READING_LINE = 120; // px below the viewport top
64
+ const update = () => {
65
+ let current = ids[0];
66
+ for (const id of ids) {
67
+ const el = document.getElementById(id);
68
+ if (!el) continue;
69
+ if (el.getBoundingClientRect().top > READING_LINE) break; // document order: stop at the first not yet passed
70
+ current = id;
71
+ }
72
+ if (window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 2) current = ids.at(-1)!;
73
+ setActive((prev) => (prev === current ? prev : current));
74
+ };
75
+ ```
76
+
77
+ - **A reading line, not an `IntersectionObserver`**, because several sections are visible at once and the question is *which heading did I last pass?*
78
+ - **The bottom clamp.** A short final section never crosses the line before the page ends, so the rail would mark the one before it.
79
+ - **Match `scroll-margin-top` to the line**, a little smaller, within ~10px. A contents link that scrolls its heading to y = 0 puts it above the line, and the rail highlights the previous section.
80
+ - **`history.replaceState` for the hash**, not router navigation, which runs the app's own scroll handling and cuts the smooth scroll short.
81
+
82
+ ## What to animate
83
+
84
+ `width`, `height`, `top`, `left`, `margin` and `padding` lay out on every frame, for the element and possibly its siblings; `transform` and `opacity` don't.
85
+
86
+ **Name every property.** `transition: all` picks up properties you never intended, including layout ones a parent adds later. The explicit list — `transition: color, background-color, translate` — also makes a hover's *leave* predictable: exactly the listed properties transition out.
87
+
88
+ **A justified exception** looks like the shared highlight: one absolutely positioned, `pointer-events: none` element outside the flow, whose size change reflows nothing. If you can't say that about an element, scale it instead.
89
+
90
+ **Animating to auto** without measuring:
91
+
92
+ ```css
93
+ .region { display: grid; grid-template-rows: 0fr; transition: grid-template-rows <duration.overlay> <ease.standard>; }
94
+ .region[data-open] { grid-template-rows: 1fr; }
95
+ .region > * { min-height: 0; overflow: hidden; }
96
+ ```
97
+
98
+ The same works on columns with `min-width: 0`. Where supported, `interpolate-size: allow-keywords` lets `height: auto` transition directly.
99
+
100
+ **Transforms and `backdrop-filter`.** A transform on an ancestor creates a new containing block and weakens or breaks the blur beneath it, so the moving wrapper and the translucent surface are different elements; the depth guide covers how much the tint carries while it animates.
101
+
102
+ **`will-change`** only on an element about to animate, removed after; left on everything, it pins memory for layers that never move.
103
+
104
+ ## Escalating to the GPU
105
+
106
+ Some shared-element trips can't be done in the DOM: the ends differ in aspect ratio, so one image distorts all the way, or the view is too large to re-lay-out per frame. Then draw one element on a canvas above the view and animate four numbers — a rectangle — in a uniform.
107
+
108
+ - **One decorative element, never the view.** A hundred tiles as textures costs hundreds of megabytes, and the tiles carry keyboard and screen-reader semantics a canvas can't. The drawn element has none of its own (`alt=""`, labels elsewhere).
109
+ - **Setup returns `null`, never throws.** Inside an effect, an exception takes the view down instead of falling back. Check for the artefact that proves setup completed — a linked program — rather than trusting the constructor.
110
+ - **`null` is the ordinary path:** the caller keeps its `<img>`, which also serves reduced motion, no WebGL, and a context lost later.
111
+ - **Mipmaps, trilinear filtering**, or a large image drawn small shimmers as it moves sub-pixel.
112
+ - **Soften the last pixel of the edge**, or a rectangle on fractional pixels crawls column by column. Fade alpha only, on a non-premultiplied context.
113
+ - **Don't force-lose the context on teardown.** Frameworks reuse the canvas node across remounts, and a force-lost context silently poisons it.
114
+
115
+ ## Keeping the motion code small
116
+
117
+ - **Motion:** `LazyMotion` with `domAnimation` and the `m` component; `strict` makes a stray full `motion.*` import a build error. Scope a `domMax` island to the one component that needs layout animations.
118
+ - **GSAP:** register only the plugins in use; import `Flip` or `ScrollTrigger` where they're used, not in a global setup file.
119
+ - **React Spring:** import from `@react-spring/web` only.
120
+ - **CSS first.** A hover, a colour change, a reveal and most presence animations need no library; reach for one when something re-targets, is dragged, or must exit after unmounting.
121
+