trykeyline 0.0.0-stage → 0.1.1

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 (98) hide show
  1. package/.claude-plugin/marketplace.json +18 -0
  2. package/LICENSE +21 -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/plugin.json +22 -0
  8. package/plugins/keyline/skills/keyline/SKILL.md +47 -0
  9. package/plugins/keyline/skills/keyline/commands/audit.md +30 -0
  10. package/plugins/keyline/skills/keyline/commands/color.md +7 -0
  11. package/plugins/keyline/skills/keyline/commands/critique.md +20 -0
  12. package/plugins/keyline/skills/keyline/commands/depth.md +7 -0
  13. package/plugins/keyline/skills/keyline/commands/init.md +16 -0
  14. package/plugins/keyline/skills/keyline/commands/layout.md +7 -0
  15. package/plugins/keyline/skills/keyline/commands/motion.md +7 -0
  16. package/plugins/keyline/skills/keyline/commands/patterns.md +7 -0
  17. package/plugins/keyline/skills/keyline/commands/polish.md +19 -0
  18. package/plugins/keyline/skills/keyline/commands/refine.md +19 -0
  19. package/plugins/keyline/skills/keyline/commands/sync.md +15 -0
  20. package/plugins/keyline/skills/keyline/commands/type.md +7 -0
  21. package/plugins/keyline/skills/keyline/commands/ux-laws.md +26 -0
  22. package/plugins/keyline/skills/keyline/domains/color/deriving-a-ramp.md +144 -0
  23. package/plugins/keyline/skills/keyline/domains/color/guide.md +207 -0
  24. package/plugins/keyline/skills/keyline/domains/color/status-and-exceptions.md +174 -0
  25. package/plugins/keyline/skills/keyline/domains/color/theming-mechanics.md +121 -0
  26. package/plugins/keyline/skills/keyline/domains/depth/elevation-recipes.md +185 -0
  27. package/plugins/keyline/skills/keyline/domains/depth/fallbacks-and-performance.md +132 -0
  28. package/plugins/keyline/skills/keyline/domains/depth/guide.md +226 -0
  29. package/plugins/keyline/skills/keyline/domains/depth/translucent-materials.md +159 -0
  30. package/plugins/keyline/skills/keyline/domains/layout/forks-gating-and-disclosures.md +201 -0
  31. package/plugins/keyline/skills/keyline/domains/layout/guide.md +259 -0
  32. package/plugins/keyline/skills/keyline/domains/layout/measure-and-rhythm.md +193 -0
  33. package/plugins/keyline/skills/keyline/domains/layout/radius-lines-and-layers.md +122 -0
  34. package/plugins/keyline/skills/keyline/domains/motion/guards.md +162 -0
  35. package/plugins/keyline/skills/keyline/domains/motion/guide.md +198 -0
  36. package/plugins/keyline/skills/keyline/domains/motion/ladder-and-curves.md +139 -0
  37. package/plugins/keyline/skills/keyline/domains/motion/patterns.md +196 -0
  38. package/plugins/keyline/skills/keyline/domains/motion/performance.md +121 -0
  39. package/plugins/keyline/skills/keyline/domains/patterns/actions-and-forms.md +204 -0
  40. package/plugins/keyline/skills/keyline/domains/patterns/agents-and-live-states.md +111 -0
  41. package/plugins/keyline/skills/keyline/domains/patterns/collections-and-dashboards.md +131 -0
  42. package/plugins/keyline/skills/keyline/domains/patterns/copy.md +123 -0
  43. package/plugins/keyline/skills/keyline/domains/patterns/guide.md +174 -0
  44. package/plugins/keyline/skills/keyline/domains/patterns/iteration.md +70 -0
  45. package/plugins/keyline/skills/keyline/domains/type/display-tier.md +108 -0
  46. package/plugins/keyline/skills/keyline/domains/type/guide.md +227 -0
  47. package/plugins/keyline/skills/keyline/domains/type/ladders.md +169 -0
  48. package/plugins/keyline/skills/keyline/domains/type/numerals-and-loading.md +107 -0
  49. package/plugins/keyline/skills/keyline/domains/type/prose-and-emphasis.md +125 -0
  50. package/plugins/keyline/skills/keyline/domains/type/type-audit.md +87 -0
  51. package/plugins/keyline/skills/keyline/glass/color/assets/color-tokens.css +299 -0
  52. package/plugins/keyline/skills/keyline/glass/color/assets/theme.ts +119 -0
  53. package/plugins/keyline/skills/keyline/glass/color/guide.md +195 -0
  54. package/plugins/keyline/skills/keyline/glass/color/intent-ramp.md +136 -0
  55. package/plugins/keyline/skills/keyline/glass/color/theming.md +106 -0
  56. package/plugins/keyline/skills/keyline/glass/depth/assets/GlassPanel.tsx +171 -0
  57. package/plugins/keyline/skills/keyline/glass/depth/assets/glass.css +322 -0
  58. package/plugins/keyline/skills/keyline/glass/depth/blur-and-tint.md +50 -0
  59. package/plugins/keyline/skills/keyline/glass/depth/depth-cues.md +87 -0
  60. package/plugins/keyline/skills/keyline/glass/depth/guide.md +147 -0
  61. package/plugins/keyline/skills/keyline/glass/depth/performance-and-fallbacks.md +37 -0
  62. package/plugins/keyline/skills/keyline/glass/layout/adaptive-presentations.md +231 -0
  63. package/plugins/keyline/skills/keyline/glass/layout/assets/SectionNav.tsx +244 -0
  64. package/plugins/keyline/skills/keyline/glass/layout/assets/layout-tokens.css +201 -0
  65. package/plugins/keyline/skills/keyline/glass/layout/guide.md +259 -0
  66. package/plugins/keyline/skills/keyline/glass/layout/measure-and-rhythm.md +134 -0
  67. package/plugins/keyline/skills/keyline/glass/layout/radius-and-elevation.md +103 -0
  68. package/plugins/keyline/skills/keyline/glass/motion/assets/DropMenu.tsx +106 -0
  69. package/plugins/keyline/skills/keyline/glass/motion/assets/motion-presets.ts +210 -0
  70. package/plugins/keyline/skills/keyline/glass/motion/assets/motion-tokens.css +176 -0
  71. package/plugins/keyline/skills/keyline/glass/motion/assets/useReveal.ts +65 -0
  72. package/plugins/keyline/skills/keyline/glass/motion/assets/useScrollTick.ts +183 -0
  73. package/plugins/keyline/skills/keyline/glass/motion/easing-and-duration.md +108 -0
  74. package/plugins/keyline/skills/keyline/glass/motion/guide.md +255 -0
  75. package/plugins/keyline/skills/keyline/glass/motion/reduced-motion.md +81 -0
  76. package/plugins/keyline/skills/keyline/glass/motion/scroll-performance.md +58 -0
  77. package/plugins/keyline/skills/keyline/glass/motion/shared-element.md +161 -0
  78. package/plugins/keyline/skills/keyline/glass/motion/sound.md +195 -0
  79. package/plugins/keyline/skills/keyline/glass/motion/springs.md +51 -0
  80. package/plugins/keyline/skills/keyline/glass/type/assets/prose.tsx +268 -0
  81. package/plugins/keyline/skills/keyline/glass/type/assets/type-tokens.css +416 -0
  82. package/plugins/keyline/skills/keyline/glass/type/guide.md +220 -0
  83. package/plugins/keyline/skills/keyline/glass/type/long-form-prose.md +151 -0
  84. package/plugins/keyline/skills/keyline/glass/type/scale.md +69 -0
  85. package/plugins/keyline/skills/keyline/glass/type/weight-and-tracking.md +61 -0
  86. package/plugins/keyline/skills/keyline/scripts/check/css.mjs +112 -0
  87. package/plugins/keyline/skills/keyline/scripts/check/design.mjs +67 -0
  88. package/plugins/keyline/skills/keyline/scripts/check/files.mjs +44 -0
  89. package/plugins/keyline/skills/keyline/scripts/check/markup.mjs +149 -0
  90. package/plugins/keyline/skills/keyline/scripts/check/rules.mjs +584 -0
  91. package/plugins/keyline/skills/keyline/scripts/check.mjs +297 -0
  92. package/plugins/keyline/skills/keyline/scripts/contrast.mjs +153 -0
  93. package/plugins/keyline/skills/keyline/scripts/scan.mjs +340 -0
  94. package/plugins/keyline/skills/keyline/system/design-md.md +136 -0
  95. package/plugins/keyline/skills/keyline/system/detection.md +53 -0
  96. package/plugins/keyline/skills/keyline/system/guide.md +97 -0
  97. package/plugins/keyline/skills/keyline/system/presets.md +58 -0
  98. package/plugins/keyline/skills/keyline/system/roles.md +150 -0
@@ -0,0 +1,162 @@
1
+ # Guards
2
+
3
+ Reduced motion, content that starts hidden, and set pieces: where motion can take something away. Each section's fallbacks must all exist, because each covers a different reader.
4
+
5
+ ## Reduced motion: substitute, don't subtract
6
+
7
+ For every animation, ask what it communicates, then find the cheapest thing that still communicates it.
8
+
9
+ | Original | Reduced path | Why |
10
+ |---|---|---|
11
+ | Spring slide, re-targeting highlight | Flat tween at `duration.state` | The move is the message; the physics is decoration |
12
+ | Scale, squash or slide entrance | Plain fade at the same tier | Presence still shown; nothing travels |
13
+ | Blur cross-fade | Cross-fade with `blur(0px)` at both ends | Blur is the trigger; the fade isn't |
14
+ | Reveal-on-scroll | Off; content in its final visible state | The animation is the whole effect |
15
+ | Route fade, ambient loop | Off | Ditto |
16
+ | Live-state loop (level bars, a travelling stroke) | Held still on a representative frame | Shape and colour already say "live"; the movement only restates it |
17
+ | Hover lift whose motion is the whole interaction | No hover variant at all | Static is correct |
18
+ | Theme cross-fade | Instant | Whole-viewport interpolation |
19
+ | Programmatic smooth scroll | `behavior: "auto"` | Whole-viewport motion is among the strongest triggers |
20
+ | Icon fill cross-fade | Instant swap | The state is the message |
21
+ | Press scale at `duration.instant` | Keep it | Small, short, below any threshold of concern |
22
+ | Close tween (`ease.exit`) | Keep it | Already flat and short |
23
+ | Set piece | Never plays | A flourish by definition; don't substitute a quieter one |
24
+
25
+ ### CSS
26
+
27
+ ```css
28
+ /* Branch the travel, keep the fade */
29
+ :root { --motion-travel: 12px; }
30
+ @media (prefers-reduced-motion: reduce) { :root { --motion-travel: 0px; } }
31
+
32
+ /* Reveal: off entirely — resolving to the FINAL state, not the initial one */
33
+ @media (prefers-reduced-motion: reduce) {
34
+ .reveal { opacity: 1; transform: none; transition: none; }
35
+ }
36
+ ```
37
+
38
+ The theme cross-fade inverts the pattern: its whole rule sits inside `prefers-reduced-motion: no-preference`, so it's opt-in.
39
+
40
+ ### Libraries
41
+
42
+ ```tsx
43
+ // Motion
44
+ const reduce = useReducedMotion();
45
+ transition={reduce ? { duration: <duration.state> } : <spring.snappy>}
46
+ whileHover={reduce ? undefined : "hover"} // undefined registers no listener; "rest" still does
47
+ ```
48
+
49
+ ```ts
50
+ // GSAP
51
+ const mm = gsap.matchMedia();
52
+ mm.add({ reduce: "(prefers-reduced-motion: reduce)", ok: "(prefers-reduced-motion: no-preference)" }, (ctx) => {
53
+ const { reduce } = ctx.conditions!;
54
+ gsap.from(".sheet", { opacity: 0, y: reduce ? 0 : 12, duration: 0.2, ease: "power3.out" });
55
+ });
56
+ ```
57
+
58
+ ```ts
59
+ // React Spring
60
+ const reduce = useReducedMotion();
61
+ const styles = useSpring({ opacity: open ? 1 : 0, y: open || reduce ? 0 : 12, config: snappy });
62
+ ```
63
+
64
+ `MotionConfig reducedMotion="user"` (Motion) drops transforms and keeps opacity, a reasonable floor; `Globals.assign({ skipAnimation: true })` (React Spring) drops everything, the regression this section prevents. Neither replaces the per-component branch.
65
+
66
+ ### Imperative APIs
67
+
68
+ Nothing called from JavaScript reads the media query: `scrollIntoView`, `scrollTo`, `Element.animate`, a GSAP timeline built outside `matchMedia`, a canvas or WebGL loop. Pass the preference in, and listen for its `change` event if the component lives long.
69
+
70
+ ### Testing
71
+
72
+ macOS: Accessibility → Display → Reduce motion. iOS: Accessibility → Motion. Windows: Accessibility → Visual effects → Animation effects. Chrome DevTools: Rendering → Emulate `prefers-reduced-motion`. Walk the whole interface with it on, looking not for jank but for a menu that appears with no sign it opened, or a section that never appears.
73
+
74
+ Related preferences take the same shape elsewhere: `prefers-reduced-transparency` (translucent surfaces become opaque) in the depth guide, `prefers-contrast: more` in the colour guide.
75
+
76
+ ## Content that starts hidden
77
+
78
+ A reveal every reader sees:
79
+
80
+ ```css
81
+ .reveal { opacity: 0; translate: 0 var(--reveal-travel);
82
+ transition: opacity <duration.overlay> <ease.colour>, translate <duration.overlay> <ease.standard>; }
83
+ .reveal[data-in] { opacity: 1; translate: 0 0; }
84
+ @media (prefers-reduced-motion: reduce) { .reveal { opacity: 1; translate: none; transition: none; } }
85
+ @media print { .reveal { opacity: 1; translate: none; } }
86
+ ```
87
+
88
+ ```html
89
+ <noscript><style>.reveal { opacity: 1 !important; translate: none !important; }</style></noscript>
90
+ ```
91
+
92
+ ```ts
93
+ export function observeReveals(): () => void {
94
+ const els = document.querySelectorAll<HTMLElement>(".reveal:not([data-in])");
95
+ if (typeof IntersectionObserver === "undefined") {
96
+ els.forEach((el) => el.setAttribute("data-in", "")); // never leave content hidden behind a feature check
97
+ return () => {};
98
+ }
99
+ const io = new IntersectionObserver((entries) => {
100
+ for (const e of entries) {
101
+ if (!e.isIntersecting) continue;
102
+ e.target.setAttribute("data-in", "");
103
+ io.unobserve(e.target); // one-shot: re-animating on scroll-back is nauseating
104
+ }
105
+ }, { threshold: 0, rootMargin: "0px 0px -40px 0px" });
106
+ els.forEach((el) => io.observe(el));
107
+ return () => io.disconnect();
108
+ }
109
+ // Call again on every client-side navigation, keyed on the path.
110
+ ```
111
+
112
+ Each guard saves a reader who would otherwise see nothing:
113
+
114
+ | Guard | The reader it saves |
115
+ |---|---|
116
+ | `threshold: 0` with a root margin | Anyone reading an element taller than the viewport. A threshold is a fraction of the element's *own* area, and an element can never exceed viewport ÷ its height, so `0.1` is unreachable for anything over ten screens tall — a long article, exactly. A root margin works at any height |
117
+ | `data-in`, not a class | Anyone whose element re-renders. A framework rewrites `class` from its own value on every render, dropping a class added from outside; attributes it doesn't render, it leaves alone |
118
+ | No-observer fallback | Old or stripped-down browsers |
119
+ | `<noscript>` reset | Readers with JavaScript off, and crawlers; a feature check can't catch "the script never ran" |
120
+ | Print rule | Anyone printing; scroll-triggered content prints blank without it |
121
+ | Re-observe on navigation | Anyone arriving on a second page client-side |
122
+ | Reduced-motion rule | Readers who asked for less motion |
123
+
124
+ The re-render failure hides well: a utility applied during a hover can outrank the reveal's opacity, so the page breaks only when the pointer *leaves*. Test the state after the interaction, not during it.
125
+
126
+ **Any CSS that hides content pending JavaScript needs a `<noscript>` reset**, whether a reveal, a hydration class or a skeleton.
127
+
128
+ ### Collapsibles
129
+
130
+ Animate the height (`grid-template-rows: 0fr` ↔ `1fr`) and keep the content mounted, `inert` and `aria-hidden` while shut, so a tab press or screen reader meets the trigger, not hidden prose, and search still finds the content in the served HTML.
131
+
132
+ ## Set pieces
133
+
134
+ Decide before the first pixel, in a blocking inline script in the document head:
135
+
136
+ ```html
137
+ <script>
138
+ (function () {
139
+ try {
140
+ if (matchMedia("(prefers-reduced-motion: reduce)").matches) return; // beats any replay override
141
+ if (location.hash) return; // a deep link is someone asking for that section
142
+ if (sessionStorage.getItem("intro-seen")) return; // once a session
143
+ document.documentElement.dataset.intro = "on"; // CSS shows the overlay at paint
144
+ } catch (e) {} // storage can throw; failing means no set piece
145
+ })();
146
+ </script>
147
+ ```
148
+
149
+ The component only *ends* it — flips the attribute — and records "seen" in its completion callback; recording on start lands in the same render as the play and cancels the animation it records. Write "seen" under reduced motion too, so the reader's next tab doesn't surprise them.
150
+
151
+ **Branch client-only flags on the transition, never on a rendered style.** "Has this played?" is false on the server for everyone and true on the client for most: a style branched on it paints differently on each, while a branched transition paints the same and differs only in how long the change takes:
152
+
153
+ ```tsx
154
+ variants={{
155
+ shut: { opacity: 0, y: 6 }, // constant on both renders
156
+ open: { opacity: 1, y: 0, transition: { duration: reduce ? 0 : firstTime ? <set-piece> : <duration.state> } },
157
+ }}
158
+ ```
159
+
160
+ A filling CSS animation (`animation-fill-mode: both`) can't be transitioned out of with a plain declaration, because the animation's end state wins; replace the animation to end it.
161
+
162
+ **Nothing waits behind it.** The page underneath is rendered and interactive; the skip link is the first focusable element, and focusing it ends the piece; any key or click ends it at once.
@@ -0,0 +1,198 @@
1
+ # Interface Motion
2
+
3
+ Motion in an interface has one job: **make a change legible.** Not to delight, not to demonstrate craft. Something opened, moved or was chosen, and the animation lets the user follow it. If removing an animation leaves the user no less clear about what happened, remove it.
4
+
5
+ The constraint also makes the few places motion *is* spent land hard, because nothing around them competes. The laws hold under every preset; the dials are where an essay, a dashboard and a launch page legitimately differ.
6
+
7
+ ## Before you start
8
+
9
+ Reads `DESIGN.md` `## Motion`.
10
+
11
+ ## Laws
12
+
13
+ ### 1. Motion makes a change legible, or it goes
14
+
15
+ **Every animation must answer "what just changed?" — and one that answers nothing is removed, not shortened.** Motion is the strongest attention signal an interface has; spent on nothing, it trains the eye to ignore it, and the animation that matters — a save confirming, a row arriving — goes unseen. A pulsing "New" badge beside a live sync indicator is the classic: the user tunes out both. Nothing animates to attract attention, and nothing loops except to report a state still in progress, like a spinner or a progress bar; any other loop is decoration, under law 4.
16
+
17
+ **Paused means still.** A loop reporting a live state — level bars on a recorder, a stroke travelling round a card while something works — stops or settles low when that state pauses; still moving, it claims the thing is still listening. Only a progress indicator loops through a wait, because waiting is its state. On hover or focus a live indicator morphs in place into the action that ends it — bars into a stop square — so the control's next move appears where the eye already is.
18
+
19
+ Hover travel answers nothing either: a card rising under the pointer claims it moved, or can be picked up. Hover is a wash; how far, if at all, a preset lets a hovered thing travel is a dial (`domains/motion/ladder-and-curves.md`).
20
+
21
+ → Live indicators, pausing and the morph to their action: `domains/motion/patterns.md`
22
+
23
+ ### 2. Duration grows with how much of the screen changes
24
+
25
+ **Duration is set by how much the eye has to re-read, never by how important the element feels, and every tier is longer than the one below it.** Tie it to importance and the primary button gets a 500ms flourish, the navigation 150ms: the most-used control is the slowest, and nothing feels like one system.
26
+
27
+ | Role | What changes |
28
+ |---|---|
29
+ | `duration.instant` | A pressed element's own response; a focus ring |
30
+ | `duration.micro` | One property with no position — opacity on hover |
31
+ | `duration.state` | One small element — colour, background, icon swap, small transform |
32
+ | `duration.popover` | A small element *and* its surroundings — a menu, a hover card |
33
+ | `duration.overlay` | A large region entering or collapsing — a sheet, a panel, a reveal |
34
+ | `duration.theme` | Everything on the page at once |
35
+
36
+ An element tied to a larger change takes its tier: a disclosure chevron runs at `duration.popover` and lands with the panel opening below it. Hover is no exception: a wash across a large card takes a longer tier than a row swept in bulk (Dials). `duration.route` is the one tier size doesn't set; law 3 caps it. The numbers are dials; the ordering is not.
37
+
38
+ **`duration.theme` runs only when the page itself switches theme**, under a class held on the root for the length of the switch. Everyday colour transitions leave out the page background and are suspended during a theme change: otherwise an OS switch by media query, which has no class to hold, cross-fades every control at its own duration while the page around it snaps.
39
+
40
+ ### 3. Nothing repeated waits on an animation
41
+
42
+ **Anything a user does many times a session must never wait on motion: feedback starts on the input, every transition can be interrupted, and anything on the path to content is capped.** An animation is watched the first time and endured the fortieth: a tab switch that slides for 300ms is charming once and a tax by lunchtime, and a dialog that ignores clicks until its exit completes turns a double-click into a missed one.
43
+
44
+ - Press feedback lands on `pointerdown` at `duration.instant`; waiting for `click` feels dead.
45
+ - Controls stay live while animating: a new target re-targets the motion in flight, never queues.
46
+ - A route transition sits between "I clicked" and "I can read": opacity only, never longer than `duration.overlay`, the incoming page interactive from its first frame.
47
+
48
+ ### 4. Off-ladder motion names its exemption
49
+
50
+ **The ladder is for state. Anything longer must be classed as not-state — the subject of the moment, a set piece, or ambient — with the class written down beside it.** Forced onto the ladder these get worse; unclassed, a 900ms flourish ends up on a menu.
51
+
52
+ | Class | What it is | What it must earn |
53
+ |---|---|---|
54
+ | Subject | The motion *is* the content being looked at — a dissolve between photographs, a chart drawing itself once | It isn't chrome reporting a change; the same component's *state* changes stay on the ladder |
55
+ | Set piece | A greeting, a first-run sequence, a launch moment | Law 15: once a session, blocks nothing |
56
+ | Ambient | A background loop in a hero | Never behind text being read, paused off screen, absent under reduced motion; `expressive` only. Where nothing can pause it off screen, it is finite — a few iterations, then rest — or removed |
57
+
58
+ The test for all three: **would a user see this more than a handful of times a session?** If yes, it's state, and on the ladder.
59
+
60
+ ### 5. One standard curve; colour never overshoots
61
+
62
+ **Everything that moves shares `ease.standard`; colour and opacity use `ease.colour`; and no curve may pass its target on a property with a floor.** One shared curve makes unrelated elements moving together feel like one mechanism; the urge for a second bezier is the urge for a spring, where character belongs (law 6).
63
+
64
+ Colour has no mass, so overshoot on a background is a wobble in the light; opacity clamps at 1, so overshoot is a wasted tail that mistimes the fade. On a property with a hard floor — `blur()` or `brightness()` toward 0, a radius toward 0, a scale that mustn't invert — browsers clamp rather than error, and the track *stalls* for its last few frames, which is hard to trace. **Check what else rides the curve:** transform with opacity is fine; add a filter and the whole track needs a monotone curve. Whether `ease.standard` overshoots is a dial; this law bites only where it does.
65
+
66
+ ### 6. Springs are named by character, and move only things with position
67
+
68
+ **A spring is named for how it feels (`spring.snappy`, `spring.soft`), used wherever something can be re-targeted mid-flight or grabbed, and never drives opacity, colour or filter.** A bezier restarts from a standstill at each new target, so a highlight chasing the pointer across five items stutters five times; a spring keeps its velocity. **If the user could grab it, or it could change target mid-flight, it's a spring.** A number isn't a decision: `spring-400-30` says nothing about when to reach for it. Opacity splits out as its own short tween, because a spring's overshoot on opacity hitches at the tail of every fade.
69
+
70
+ Springs are wrong for discrete flips with no travel, anything that must land in step with a bezier, and long distances at low stiffness: a soft spring across 800px takes over a second and feels broken.
71
+
72
+ → Curves and springs per preset in four libraries, the opacity split in code, squash and stretch, and tuning by symptom: `domains/motion/ladder-and-curves.md`
73
+
74
+ ### 7. Exits get out of the way
75
+
76
+ **Closing is faster than opening, flat, and eases in — no spring, no overshoot, and it travels less far.** Opening answers the user's intent and can afford character; closing comes after they've moved on, so a bounce reads as reluctance and stands in their way, and a disappearing element needn't travel the full distance. In the Glass preset a menu opens on a spring of about 400ms and closes on a 130ms `ease-in` tween. The law is for things leaving; a hover wash fading behind a pointer that has already moved on stands in nobody's way, so its leave may run longer than its arrival.
77
+
78
+ ### 8. Where identity persists, the element travels
79
+
80
+ **If the same thing is on screen before and after a change, it moves between the two positions rather than fading out in one place and in at another.** Per-item hover backgrounds across a row of icons give overlapping fades, one brightening as its neighbour dims: a stutter, not an object. One shared highlight that re-targets its position and size can't flicker, because there is only one; it skips its mount animation, or it flies in from the corner on first paint.
81
+
82
+ A view switch keeps the reader's place (layout law 9), so **measure the reading position before the outgoing layout unmounts** and open the incoming one there, neither end stretched to the other's aspect. A shared element is the wrong tool for a lone item, items that reflow while hovered, items in different scroll containers, and a grid crossed diagonally.
83
+
84
+ → Highlight, tooltip and layout-switch patterns in View Transitions, Motion `layoutId` and GSAP Flip: `domains/motion/patterns.md`
85
+
86
+ ### 9. One highlight travels between hovered and current
87
+
88
+ **Where a group shows both a current item and the one under the pointer — a nav, a segmented control — one highlight travels with the pointer and comes to rest on the current item, rather than a second treatment appearing beside the first.** What current looks like is colour law 7's, never re-picked here, and weight never changes on hover (type law 5). Previewing a choice and reporting one are the same gesture a beat apart, so they share one moving shape (law 8): two signals each doing half the job, never one doing both. Outside such a group, hover is a wash (the dials). Nothing is filled because it looks better.
89
+
90
+ A filled icon is the same outline with its interior painted, cross-faded in place at `duration.popover` on `ease.colour`, so the silhouette never moves. Marks that can't take it — a brand's official solid mark, a chevron with no interior, a lone toggle with nothing to be picked out from — signal another way. **Fill that distinguishes nothing is just weight.** Nor is a step from `text.secondary` to `text.primary` a signal (colour law 6).
91
+
92
+ ### 10. Reduced motion is a branch with a substitute
93
+
94
+ **Every animated component reads `prefers-reduced-motion` and takes its own reduced path that still communicates the change; a global off switch is a regression.** The preference exists because large-area and accelerating motion causes nausea and dizziness, not to remove visible state: `* { animation: none !important }` strips the signals with the decoration, and a menu appears with no sign it opened.
95
+
96
+ Imperative APIs — `scrollIntoView`, `scrollTo`, `Element.animate`, a GSAP timeline, a canvas loop — don't read the preference; pass it in. Motion's `MotionConfig reducedMotion="user"` and React Spring's `skipAnimation` are a floor, not the plan.
97
+
98
+ → Every substitute in CSS, Motion, GSAP and React Spring, and how to test: `domains/motion/guards.md`
99
+
100
+ ### 11. Motion never owns whether content is visible
101
+
102
+ **Any style that hides content until an animation runs must resolve to visible whenever that animation can't run** — no JavaScript, no observer, reduced motion, print, or an element too tall to trip its trigger. The failure is not a plainer page but a correct document the reader can't see. The usual causes: an observer threshold a very tall section can't reach, and a class added outside the framework, which a re-render rewrites away.
103
+
104
+ So reveals are one-shot, with a zero threshold and a root margin, re-observed on client navigation, marked by a data attribute the framework doesn't own, and reset by a `<noscript>` style and a print rule. A collapsible animates its height but stays mounted (layout law 11).
105
+
106
+ ### 12. Scroll work runs once a frame, and never hides what it can't bring back
107
+
108
+ **Each scroll-driven behaviour is one passive listener collapsed to one `requestAnimationFrame`, with a dead zone on anything that toggles on direction — and nothing collapses on a device that can't expand it again.** A rAF flag gives one measurement per painted frame, which no time-based throttle can. A dead zone of a few pixels stops trackpad jitter toggling a collapsing header forever. Whether it may collapse at all on touch is layout law 8.
109
+
110
+ ### 13. Animate what the compositor can; escalate one element, never a view
111
+
112
+ **Animate `transform` and `opacity`, list every transitioned property by name, justify any layout property you animate, and when the DOM can't carry a transition, move one decorative element to the GPU — never the view.** `transition: all` catches properties you never meant, including layout ones a parent adds later. Animating `width` is defensible only where nothing reflows, as on one absolutely positioned element outside the flow; otherwise use a transform, or the `0fr`↔`1fr` grid track to "animate to auto". The moving wrapper and a translucent surface are separate elements (depth law 9).
113
+
114
+ GPU escalation is for one travelling element with no semantics of its own, never a view of tiles, whose semantics a canvas can't carry and whose textures cost hundreds of megabytes; its setup returns `null` rather than throwing, the path reduced motion and no-WebGL already take.
115
+
116
+ → The scroll shape with hysteresis, scroll spies, the grid technique, GPU rules and bundle discipline: `domains/motion/performance.md`
117
+
118
+ ### 14. Pointer gaps get intent, not luck
119
+
120
+ **A hover-opened element gets a close grace period of about 100ms, or a hover bridge across the gap; the timer is cleared on unmount and on navigation; and whatever hover reveals, focus reveals too, with a separate path for touch.** Without it, the menu closes in the gap; where trigger and menu touch, a bridge of padding is cheaper, because the pointer never leaves. A timer left running across a navigation reopens a stale menu on the next page. Delay the *close*, never the open (law 3).
121
+
122
+ ### 15. Set pieces play once and block nothing
123
+
124
+ **A greeting, a first-run sequence or a launch flourish plays at most once a session, is decided before the first paint, skips for deep links and reduced motion, and never covers content or the skip link.** Its long budget is defensible only because it never recurs. Gate it in a blocking inline script that sets a root attribute for CSS to answer at paint: the server can't see session storage, and a component decides a frame too late. Record "seen" on *completion*, or the write cancels the animation it records; branch client-only flags on the *transition*, never a rendered style, or server and client paint differently. Focusing the skip link, pressing a key or following a link ends it at once.
125
+
126
+ → The inline gate, the hydration-safe variant and the "seen" bookkeeping: `domains/motion/guards.md`
127
+
128
+ ## Dials
129
+
130
+ | Dial | editorial | product | expressive |
131
+ |---|---|---|---|
132
+ | `duration.micro` | 100ms | 80ms | 120ms |
133
+ | `duration.state` | 150ms | 100ms | 200ms |
134
+ | `duration.popover` | 200ms | 150ms | 280ms |
135
+ | `duration.overlay` | 300ms | 200ms | 450ms |
136
+ | `duration.route` | 250ms opacity fade | none | 400ms fade; directional only where routes have a real order |
137
+ | `duration.theme` | 400ms | 250ms | 600ms |
138
+ | Exit duration | ≈ ⅔ of the entrance (popover exit 130ms) | 100ms | 180ms |
139
+ | `ease.standard` | `cubic-bezier(0.22, 1, 0.36, 1)` — no overshoot | `cubic-bezier(0.2, 0, 0, 1)` — no overshoot | `cubic-bezier(0.34, 1.2, 0.64, 1)` — slight overshoot |
140
+ | `ease.colour` · `ease.exit` | `ease` · `ease-in` | `ease-out` · `ease-in` | `ease` · `ease-in` |
141
+ | Springs | `snappy` 0.15 / 0.35s, `soft` 0.2 / 0.45s, one `playful` 0.3 / 0.5s | `snappy` 0 / 0.25s, for drag, throw and a re-targeting indicator only | `snappy` 0.2 / 0.4s, `soft` 0.3 / 0.6s, `playful` 0.45 / 0.7s |
142
+ | Reveal-on-scroll | 8px travel, `duration.overlay`, no stagger | none | up to 24px, stagger ≤ 80ms, whole group ≤ 600ms |
143
+ | Press feedback | scale 0.96 on small controls | `surface.pressed` step, no scale | scale 0.92–0.95; wider than ≈ 200px, `scale(0.98)` or a 1px translate |
144
+ | Hover on a card or large surface | a wash, 200ms in · 250ms out; no travel | a wash, 200ms in · 250ms out; no travel, no shadow change | a wash, 280ms in · 350ms out; a lift ≤ 4px on primary navigation only |
145
+ | Set pieces | at most one on the site | none | a few, each named |
146
+ | Ambient loops | none | progress indicators, and live-state indicators while the state is live | a hero only, paused off screen; where nothing can pause it, one finite motion — a few iterations, then rest |
147
+ | Sound | absent, or opt-in behind a visible toggle | absent | opt-in, default off, visible toggle |
148
+
149
+ Springs are Motion's `bounce / duration`.
150
+
151
+ The presets move the tiers together, so law 2's ordering survives any row. Each set piece and ambient loop is a named entry in `DESIGN.md` with its law 4 class; exceeding the preset's allowance is a decision with a reason, not a drift.
152
+
153
+ → Why each dial sits where it does: `domains/motion/ladder-and-curves.md`. Sound's rules: `domains/motion/patterns.md`
154
+
155
+ ## Building it for a project
156
+
157
+ 1. **Read the preset and its areas.** A `product` app with an `expressive` marketing area gets two ladders; the area's paths say which a file uses.
158
+ 2. **Inventory what moves**, built or planned, with what each changes and how much screen it covers. The ladder comes from this list, not a token file.
159
+ 3. **Assign tiers by area** (law 2), promoting anything tied to a larger change. What doesn't fit is a stray to snap or an exemption to class under law 4; nothing is left unclassed.
160
+ 4. **Take the preset's numbers** and check them against the project: a consistent existing value within ~20ms of a dial is kept and recorded; scattered strays snap to the ladder. Confirm the tiers strictly ascend.
161
+ 5. **Fix the curves.** One `ease.standard`, one `ease.colour`, `ease.exit` only if exits differ. If the standard curve overshoots, find every track carrying a filter or floored property.
162
+ 6. **Name only the springs the inventory needs**, by character; with no drag and no re-targeting indicator, maybe none.
163
+ 7. **Write the reduced path for every inventory row** (law 10), and the visible end state for everything that starts hidden (law 11).
164
+ 8. **Emit tokens in the project's mechanism**: custom properties, a Tailwind theme or a JS theme object.
165
+
166
+ → Per-preset tables, the *Ledgerline* token file with Tailwind mappings, and a derivation from a messy codebase: `domains/motion/ladder-and-curves.md`
167
+
168
+ ## Auditing existing code
169
+
170
+ Collect evidence, then report:
171
+
172
+ - **Off-ladder durations:** `rg -n "\d+ms|duration[-:]\s*\d|duration-\[" src`; each value is a tier, a stray or an unclassed exemption.
173
+ - **Curves:** `rg -n "cubic-bezier|ease-in-out|ease:" src`; more distinct beziers than the preset's two or three is a finding, as is an overshooting one on a track with a filter.
174
+ - **Entrances on `ease-in`**, exits on springs: `rg -n "exit=|ease-in" src`.
175
+ - **Opacity inside a spring:** `type: "spring"` with no per-property opacity override.
176
+ - **`transition: all`**, `transition-all`, animated `width|height|top|left|margin`; a colour transition on the page background outside the theme switch's class.
177
+ - **Reduced motion:** animated components against `useReducedMotion|prefers-reduced-motion|matchMedia` branches; global `animation: none !important`; `scrollIntoView|scrollTo|\.animate\(` with no branch.
178
+ - **Hidden until animated:** `opacity: 0` initial states, reveal classes, `threshold:` above 0, `classList.add` on framework-rendered elements; a missing `<noscript>` reset or print rule.
179
+ - **Scroll:** `addEventListener\("scroll"` without `passive` or rAF, or several per behaviour.
180
+ - **Loops:** `infinite`, `repeat: Infinity`, `setInterval` driving UI; an ambient `infinite` that nothing pauses off screen.
181
+ - **Hover:** timers never cleared on unmount; any hover style, decoration included, outside a `(hover: hover) and (pointer: fine)` gate; `rg -n "hover:-?translate|:hover[^{]*\{[^}]*transform" src` for cards that lift where the preset says wash.
182
+ - **Live indicators:** loops with no paused branch.
183
+
184
+ Then tab through with reduced motion on, and with JavaScript off, for anything that never appears or appears with no sign it opened. Check hover states on *leave* as well as enter; the leave state is where transition-property mistakes live.
185
+
186
+ ## Writing back to DESIGN.md
187
+
188
+ Under `## Motion`: ladder rows (role, name here, value, used for); curves; springs by character; the reduced-motion policy in a sentence; each off-ladder entry as a row in the **Named exemptions** table (exemption, values, why — its law 4 class and where it plays), which the detector reads; the sound policy, if any.
189
+
190
+ Under `## Decisions`, besides moved dials: a set piece added, sound turned on.
191
+
192
+ ## Checklist
193
+
194
+ - [ ] Live indicators morph in place to their action on hover and focus.
195
+ - [ ] `duration.theme` runs only under the page's own switch class; everyday colour transitions leave out the page background.
196
+ - [ ] Shared elements skip their mount animation; layout switches keep the reading position.
197
+ - [ ] Set pieces record "seen" on completion and never block the skip link.
198
+ - [ ] Sound only if the preset allows it, off by default behind a visible, persisted toggle.
@@ -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."*