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,183 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+
3
+ /**
4
+ * The one scroll shape: a `ticking` flag, one requestAnimationFrame, and a
5
+ * passive listener. Every scroll-driven behaviour in the system uses it.
6
+ *
7
+ * `passive: true` tells the browser the handler will never preventDefault, so
8
+ * it can begin scrolling without waiting for your JS. Omitting it is the most
9
+ * common cause of scroll jank.
10
+ *
11
+ * The `ticking` flag collapses a burst of scroll events into one measurement
12
+ * per frame. Don't substitute a time-based throttle — it's either faster than
13
+ * the frame rate (wasted work) or slower (dropped frames). rAF is
14
+ * frame-aligned by construction.
15
+ *
16
+ * `resize` shares the handler because a viewport change invalidates the same
17
+ * measurements a scroll does.
18
+ */
19
+ export function useScrollTick(update: () => void, deps: unknown[] = []) {
20
+ const fn = useRef(update);
21
+ fn.current = update;
22
+
23
+ useEffect(() => {
24
+ let ticking = false;
25
+
26
+ const onScroll = () => {
27
+ if (!ticking) {
28
+ ticking = true;
29
+ requestAnimationFrame(() => {
30
+ ticking = false;
31
+ fn.current();
32
+ });
33
+ }
34
+ };
35
+
36
+ // Measure once on mount, so a page loaded already scrolled (a deep link,
37
+ // a restored scroll position) starts in the right state.
38
+ fn.current();
39
+
40
+ window.addEventListener("scroll", onScroll, { passive: true });
41
+ window.addEventListener("resize", onScroll);
42
+ return () => {
43
+ window.removeEventListener("scroll", onScroll);
44
+ window.removeEventListener("resize", onScroll);
45
+ };
46
+ // eslint-disable-next-line react-hooks/exhaustive-deps
47
+ }, deps);
48
+ }
49
+
50
+ /**
51
+ * Collapse-on-scroll-down with hysteresis. Returns true while the element
52
+ * should be collapsed.
53
+ *
54
+ * Three things at once:
55
+ *
56
+ * · THRESHOLD px of movement required before the state flips. Without a
57
+ * dead zone, a 1px jitter — trackpad, rubber-band bounce, or your own
58
+ * state change altering layout — oscillates forever.
59
+ *
60
+ * · A zone at the top where it's always expanded. Landing at the top of a
61
+ * page should look the same however you got there.
62
+ *
63
+ * · A bottom zone, so a collapsed element never sits over the footer. The
64
+ * `- 2` absorbs subpixel rounding in the height calculation; an exact
65
+ * comparison intermittently fails at fractional device pixel ratios.
66
+ *
67
+ * The top/bottom overrides are checked FIRST so they beat the direction
68
+ * logic.
69
+ *
70
+ * `hoverOnly` (default true) bails out entirely on touch devices. This
71
+ * pattern is only safe where a cursor can bring the element back — on a
72
+ * touch screen there is no hover, so collapsing would strand whatever the
73
+ * element contains. The capability check belongs at the point where you
74
+ * decide to HIDE something, not where you reveal it.
75
+ */
76
+ export function useCollapseOnScroll({
77
+ threshold = 6,
78
+ topZone = 24,
79
+ hoverOnly = true,
80
+ }: { threshold?: number; topZone?: number; hoverOnly?: boolean } = {}) {
81
+ const [collapsed, setCollapsed] = useState(false);
82
+
83
+ useEffect(() => {
84
+ if (hoverOnly && !window.matchMedia("(hover: hover) and (pointer: fine)").matches) return;
85
+
86
+ let last = window.scrollY;
87
+ let ticking = false;
88
+
89
+ const update = () => {
90
+ ticking = false;
91
+ const y = window.scrollY;
92
+ const atBottom =
93
+ y + window.innerHeight >= document.documentElement.scrollHeight - 2;
94
+
95
+ if (y < topZone || atBottom) setCollapsed(false);
96
+ else if (y > last + threshold) setCollapsed(true);
97
+ else if (y < last - threshold) setCollapsed(false);
98
+ last = y;
99
+ };
100
+
101
+ const onScroll = () => {
102
+ if (!ticking) {
103
+ ticking = true;
104
+ requestAnimationFrame(update);
105
+ }
106
+ };
107
+
108
+ window.addEventListener("scroll", onScroll, { passive: true });
109
+ return () => window.removeEventListener("scroll", onScroll);
110
+ }, [threshold, topZone, hoverOnly]);
111
+
112
+ return collapsed;
113
+ }
114
+
115
+ /**
116
+ * Which of the given section ids the reader is currently in — for a table of
117
+ * contents that marks its place.
118
+ *
119
+ * A reading line, not an IntersectionObserver. An observer tells you WHETHER
120
+ * an element is visible, which is the wrong question: several sections are
121
+ * visible at once and the answer changes with element height. A single line
122
+ * answers the actual question — which heading did I most recently pass?
123
+ *
124
+ * Ids must be in document order; the loop breaks at the first section that
125
+ * hasn't crossed the line.
126
+ *
127
+ * IMPORTANT: give every heading a scroll-margin-top just under READING_LINE
128
+ * (scroll-mt-28 = 112px against 120px here). Without it, a jump scrolls the
129
+ * heading to y=0 — ABOVE the line — so the section you just jumped to isn't
130
+ * the active one and the TOC highlights the previous one.
131
+ */
132
+ const READING_LINE = 120;
133
+
134
+ export function useActiveSection(ids: string[]): string {
135
+ const [active, setActive] = useState(ids[0] ?? "");
136
+
137
+ // Ids arrive as a fresh array each render, so [ids] would tear down and
138
+ // rebuild the listener continuously. Key the effect on the contents.
139
+ const key = ids.join("|");
140
+
141
+ useEffect(() => {
142
+ const list = key ? key.split("|") : [];
143
+ if (list.length === 0) return;
144
+
145
+ let ticking = false;
146
+
147
+ const update = () => {
148
+ ticking = false;
149
+ let current = list[0];
150
+ for (const id of list) {
151
+ const el = document.getElementById(id);
152
+ if (!el) continue;
153
+ if (el.getBoundingClientRect().top > READING_LINE) break;
154
+ current = id;
155
+ }
156
+
157
+ // Without this clamp a short final section can never become active:
158
+ // you reach the end of the page before its heading crosses the line.
159
+ const atBottom =
160
+ window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 2;
161
+ if (atBottom) current = list[list.length - 1];
162
+
163
+ setActive((prev) => (prev === current ? prev : current));
164
+ };
165
+
166
+ const onScroll = () => {
167
+ if (!ticking) {
168
+ ticking = true;
169
+ requestAnimationFrame(update);
170
+ }
171
+ };
172
+
173
+ update();
174
+ window.addEventListener("scroll", onScroll, { passive: true });
175
+ window.addEventListener("resize", onScroll);
176
+ return () => {
177
+ window.removeEventListener("scroll", onScroll);
178
+ window.removeEventListener("resize", onScroll);
179
+ };
180
+ }, [key]);
181
+
182
+ return active;
183
+ }
@@ -0,0 +1,108 @@
1
+ # Easing and duration
2
+
3
+ The glass ladder in code, and the reasons behind its values. Why duration tracks area, and why a hover over ~150ms lags across a list, are in `domains/motion/ladder-and-curves.md`; the tiers themselves are in §1 of `glass/motion/guide.md`.
4
+
5
+ ## The 100ms tier
6
+
7
+ ```tsx
8
+ className="transition-opacity duration-100 ease-out hover:opacity-60"
9
+ ```
10
+
11
+ Text links fade to 60% opacity in 100ms with `ease-out`, not the house curve: opacity clamps at 1, so the overshoot is a wasted tail (law 5). 100ms is at the edge of perceptible, which is the point: a hover response should feel like it *already happened*.
12
+
13
+ ## The 150ms tier does most of the work
14
+
15
+ Colour, background, small transforms, icon swaps, the marker that grows in a table of contents:
16
+
17
+ ```tsx
18
+ "transition-colors duration-150 ease-geist"
19
+ "transition-[width,background-color] duration-150 ease-geist"
20
+ "transition-[color,background-color,box-shadow,transform] duration-150 ease-geist"
21
+ ```
22
+
23
+ Every property enumerated, never `transition-all` (law 13).
24
+
25
+ ## Where 200ms is right
26
+
27
+ The gap between 150 and 200 maps to *whether other things move too*:
28
+
29
+ - A chevron rotating 180° in a disclosure: the rotation is small, but the panel opening below it isn't.
30
+ - A hover card scaling in: a new object with content, appearing over existing content.
31
+ - The menu glyph's two bars rotating into a cross: two elements moving in opposition read as one compound gesture.
32
+ - A hover wash across a whole card or panel: the 100ms that suits a link reads as a cut over that much area, and a pointer settles on a card rather than sweeping a column of them.
33
+
34
+ ## Why route transitions are 260ms
35
+
36
+ ```css
37
+ .route-fade { animation: routeFade 260ms var(--ease-geist) both; }
38
+ @keyframes routeFade { from { opacity: 0 } to { opacity: 1 } }
39
+ ```
40
+
41
+ 260ms is the shortest crossfade that still reads as intentional; below ~200ms a full-viewport opacity change looks like a rendering artefact (law 3 caps it). Opacity only, because a directional transition implies a spatial relationship between routes, and the site map (home, experience, writing, playground) is flat.
42
+
43
+ Keying the wrapper on the pathname replays the CSS animation on every navigation:
44
+
45
+ ```tsx
46
+ <div key={pathname} className="route-fade">
47
+ ```
48
+
49
+ `both` on the fill mode holds the `from` state before the animation starts, preventing a one-frame flash of fully opaque content.
50
+
51
+ ## Why the theme crossfade breaks both rules
52
+
53
+ ```css
54
+ @media (prefers-reduced-motion: no-preference) {
55
+ :root.theme-transition,
56
+ :root.theme-transition *,
57
+ :root.theme-transition *::before,
58
+ :root.theme-transition *::after {
59
+ transition: background-color 400ms ease, color 400ms ease, border-color 400ms ease,
60
+ fill 400ms ease, stroke 400ms ease, box-shadow 400ms ease !important;
61
+ }
62
+ }
63
+ ```
64
+
65
+ **400ms, longest on the ladder**: every surface, text colour, border, icon fill and shadow at once. At 150ms it reads as a jarring flash; at 400ms, as the lights coming up. **Plain `ease`, not the house curve**, because colour has no mass (law 5).
66
+
67
+ The class is applied only on an explicit toggle, then removed:
68
+
69
+ ```ts
70
+ export function setThemeChoice(choice: ThemeChoice): void {
71
+ localStorage.setItem(KEY, choice);
72
+ const root = document.documentElement;
73
+ root.classList.add("theme-transition");
74
+ applyTheme(choice);
75
+ window.setTimeout(() => root.classList.remove("theme-transition"), 440);
76
+ }
77
+ ```
78
+
79
+ It exists for ~440ms, the duration plus a 40ms margin, and its `!important` beats every component's own `transition-colors duration-150` for that window. Why it's scoped this way is in `domains/motion/patterns.md`, theme cross-fade.
80
+
81
+ ## The overshoot in the house curve
82
+
83
+ ```
84
+ cubic-bezier(0.175, 0.885, 0.32, 1.1)
85
+ ```
86
+
87
+ The `1.1` in the final control point takes the value past its target near the end and back: a small (~10%), brief overshoot. That number makes 150ms colour transitions feel like something *happened* rather than was *set*. Character beyond it belongs to springs (law 5).
88
+
89
+ ## The filter curve
90
+
91
+ The class of properties the house curve can't have, and why, is law 5. Glass's monotone curve:
92
+
93
+ ```ts
94
+ // Lines staggering in behind a blur: filter: blur(4px) → blur(0px).
95
+ // --ease-geist would pass through a NEGATIVE radius to settle back.
96
+ const EASE = [0.16, 1, 0.3, 1] as const; // expo-out: decelerates hard, never exceeds 1
97
+ ```
98
+
99
+ ## Durations that legitimately sit off the ladder
100
+
101
+ The pair that shows the discipline, from one gallery component (§1 of `glass/motion/guide.md`):
102
+
103
+ ```ts
104
+ const DISSOLVE = 0.62; // stepping between photographs — the subject
105
+ const FLIGHT = 0.42; // the same photo moving between layouts — a state change
106
+ ```
107
+
108
+ The dissolve is the content being watched, so it gets 620ms to read as a dissolve rather than a cut; the flight only has to be *followable*, with the photograph on screen at both ends, so it stays inside the overlay tier. The greeting's gating is §14 of the guide.
@@ -0,0 +1,286 @@
1
+ # Glass motion
2
+
3
+ > **Glass preset.** The motion system running on [modimihir.com](https://modimihir.com), with its real values, meant to be copied whole. Read it beside `domains/motion/guide.md`: the laws live there, and this guide holds only what glass sets — its durations, curve, springs and tokens, its recipes and code, and the decisions behind them. Under any other preset it is only a worked example.
4
+
5
+ State is CSS transitions; springs and presence use [Motion](https://motion.dev). "Law" below means a motion law in the domain guide.
6
+
7
+ ## 1. The duration ladder
8
+
9
+ Tiers by area, not importance (law 2):
10
+
11
+ | Tier | Duration | What moves | Easing |
12
+ |---|---|---|---|
13
+ | Instant | 0ms | Press feedback, focus rings | none |
14
+ | Micro | 100ms | Link opacity on hover | `ease-out` |
15
+ | State | **150ms** | Colour, background, small transforms, icon swaps | the house curve |
16
+ | Popover | **200ms** | A disclosure chevron, a hover card, a menu glyph | the house curve |
17
+ | Overlay | **300ms** | Reveal-on-scroll, a collapsing region | the house curve |
18
+ | Route | 260ms | An incoming page fading in | the house curve |
19
+ | Theme | 400ms | Every colour on the page at once | plain `ease` |
20
+
21
+ Three numbers carry almost everything: **150 / 200 / 300**. **Route is 260ms**, not 300: 260 is the shortest crossfade on the critical path that doesn't read as a flicker. **Theme is 400ms with plain `ease`**, the largest change on the longest duration, and colour gets no overshoot. Nothing on the ladder exceeds 400ms.
22
+
23
+ **Hover takes its tier by area:** a link's opacity 100ms, a wash across a whole card 200ms.
24
+
25
+ ### Two things that sit off the ladder
26
+
27
+ Glass uses two of law 4's classes:
28
+
29
+ **The subject of the moment.** Stepping between gallery photographs dissolves over **620ms**: the photograph *is* the content, and a noise-thresholded dissolve needs time to read as a dissolve rather than a cut. The same photo's flight between two layouts stays at 420ms, in the overlay tier, because that *is* a state change with the photo on screen at both ends. **Same component, two durations, decided by whether the motion is the subject or the report.**
30
+
31
+ **A once-a-session set piece.** The opening screen runs 560ms in, holds 1400ms, about 2.5 seconds in all, defensible only because it plays once per tab (§14).
32
+
33
+ ## 2. One easing curve
34
+
35
+ ```css
36
+ --ease-geist: cubic-bezier(0.175, 0.885, 0.32, 1.1);
37
+ ```
38
+
39
+ One curve for the whole site. The `1.1` end control point overshoots slightly and settles back, physical but not bouncy. The exceptions:
40
+
41
+ - **Opacity-only micro-transitions**: `ease-out` at 100ms.
42
+ - **The theme crossfade**: plain `ease`.
43
+ - **Anything spring-driven**: no bezier at all.
44
+ - **Any track that also drives a filter**, or any property floored at zero (law 5): expo-out, `cubic-bezier(0.16, 1, 0.3, 1)`, which never exceeds 1. It is the same intent in a curve legal for the property, not a second character.
45
+
46
+ → The tiers in code, the theme crossfade, and the overshoot: `glass/motion/easing-and-duration.md`
47
+
48
+ ## 3. Springs, tuned by character
49
+
50
+ Three springs, each with a job (law 6), plus the close:
51
+
52
+ ```ts
53
+ // Highlight or tooltip re-targeting: moving, not arriving — almost no bounce.
54
+ const SLIDE = { type: "spring", bounce: 0.2, duration: 0.4,
55
+ opacity: { duration: 0.16, ease: "easeOut" } } as const;
56
+
57
+ // Cards rising out of a folder. Playful on purpose — the only place that is.
58
+ const LIFT = { type: "spring", bounce: 0.28, duration: 0.5 } as const;
59
+
60
+ // Opening a menu: two springs that DISAGREE (§4).
61
+ const GROW = { type: "spring", stiffness: 620, damping: 19, mass: 0.85 } as const;
62
+ const CROSS = { type: "spring", stiffness: 380, damping: 30 } as const;
63
+
64
+ // Closing: flat and quick.
65
+ const CLOSE = { duration: 0.13, ease: "easeIn" } as const;
66
+ ```
67
+
68
+ Opacity is split out of every spring, as `SLIDE` does (law 6).
69
+
70
+ → How these numbers were arrived at: `glass/motion/springs.md`
71
+
72
+ ## 4. Squash and stretch, out of physics
73
+
74
+ The best motion detail in the system, in four lines of config. A menu scales open from its anchored edge: `GROW` is light and underdamped, and sails past its final size; `CROSS` is heavily damped and arrives late. Through the overshoot the panel is briefly **taller and narrower** than it ends up.
75
+
76
+ ```tsx
77
+ transition={{
78
+ scaleY: GROW, // the grow axis overshoots
79
+ y: GROW, // travel rides the same spring, so they stay locked
80
+ scaleX: CROSS, // the cross axis lags
81
+ opacity: { duration: 0.14, ease: "easeOut" },
82
+ }}
83
+ ```
84
+
85
+ → The principle: `domains/motion/ladder-and-curves.md`, squash and stretch. The component: `glass/motion/assets/DropMenu.tsx`
86
+
87
+ ## 5. Asymmetric open and close
88
+
89
+ | Direction | Character | Duration |
90
+ |---|---|---|
91
+ | Open | spring, overshoots | ~400ms effective |
92
+ | Close | tween, `easeIn`, flat | 130ms |
93
+
94
+ Why exits get out of the way is law 7. The exit offsets are smaller too: open starts from `scaleY: 0.82, scaleX: 0.94`, exit only goes to `scaleY: 0.9, scaleX: 0.97`.
95
+
96
+ ## 6. Shared elements
97
+
98
+ In the dock and in lists, **one** highlight and **one** tooltip animate their `x / y / width / height` to whichever item is hovered (law 8):
99
+
100
+ ```tsx
101
+ // One element. It re-targets. It does not remount.
102
+ <m.div
103
+ aria-hidden
104
+ className="pointer-events-none absolute left-0 top-0 -z-10 rounded-[12px] bg-highlight"
105
+ initial={false}
106
+ animate={hovered
107
+ ? { x: hovered.x, y: hovered.y, width: hovered.w, height: hovered.h, opacity: 1 }
108
+ : { opacity: 0 }}
109
+ transition={SLIDE}
110
+ />
111
+ ```
112
+
113
+ The details that keep it from flickering are in `domains/motion/patterns.md`, the shared highlight; in Tailwind they are `isolate` on the container and `-z-10` on the highlight.
114
+
115
+ → The tooltip and vertical-list variants, and the active state: `glass/motion/shared-element.md`
116
+
117
+ ### When the DOM element isn't enough
118
+
119
+ A *photograph* travelling between layouts escalates to the GPU (law 13): one viewport-sized canvas above the gallery draws the open photo into a screen rect, four numbers in a uniform, no reflow. The wall stays DOM, since a hundred tiles as textures is ~590MB before atlasing; the open photo is `alt=""`, its labels sitting beside it; failure returns `null` and keeps the caller's `<img>`.
120
+
121
+ **Carry the reading position across**: open the other layout on the tile nearest the viewport centre, measured before React commits it, and give each end its own rect around a shared centre (law 8).
122
+
123
+ → The measurement (`centreFrame`) and the WebGL notes: `glass/motion/shared-element.md`, escalating to the GPU
124
+
125
+ ## 7. Reduced motion
126
+
127
+ Every animated component takes its own branch (law 10):
128
+
129
+ ```tsx
130
+ const reduce = useReducedMotion();
131
+ const tween = reduce ? { duration: 0.15 } : SLIDE;
132
+ ```
133
+
134
+ The glass substitutes: a spring slide becomes a 150ms tween; the squash-and-stretch open becomes a plain fade.
135
+
136
+ ```tsx
137
+ // No elastic scaling, but you still see it appear.
138
+ initial={reduce ? { opacity: 0 } : { opacity: 0, scaleY: 0.82, scaleX: 0.94, y: 4 }}
139
+ ```
140
+
141
+ Imperative APIs get the preference passed in: `scrollIntoView({ behavior: reduce ? "auto" : "smooth" })`. Reduced transparency and high contrast are §8 of `glass/depth/guide.md`.
142
+
143
+ → Every glass substitute: `glass/motion/reduced-motion.md`
144
+
145
+ ## 8. Scroll
146
+
147
+ One passive listener and one rAF per behaviour (law 12; the shape is in `domains/motion/performance.md`).
148
+
149
+ **The dock's dead zone** is 6px of intent either way, and it is always open within 24px of the top or at the bottom. **It collapses only where `(hover: hover) and (pointer: fine)` matches**, so a hover can reopen it.
150
+
151
+ → Scroll spy, `scroll-mt` alignment, the grid collapse: `glass/motion/scroll-performance.md`
152
+
153
+ ## 9. Reveal on scroll
154
+
155
+ CSS animates; JavaScript only marks an element as arrived:
156
+
157
+ ```css
158
+ .reveal { opacity: 0; transform: translateY(8px);
159
+ transition: opacity 300ms var(--ease-geist), transform 300ms var(--ease-geist); }
160
+ .reveal[data-in] { opacity: 1; transform: translateY(0); }
161
+
162
+ @media (prefers-reduced-motion: reduce) {
163
+ .reveal { opacity: 1; transform: none; transition: none; }
164
+ }
165
+ ```
166
+
167
+ `threshold: 0` with `rootMargin: "0px 0px -40px 0px"`, one-shot, re-run on the pathname. 8px of travel registers without a page of paragraphs seeming to slide. Every guard is law 11 and `domains/motion/guards.md`; the two the site hit:
168
+
169
+ - **A fractional threshold on a tall element.** Every rendered SKILL.md on the site, eight to thirteen thousand pixels in one `.reveal`, once sat invisible at opacity 0 behind `0.1`.
170
+ - **A class the framework owns.** Tag-filtered rows went blank as the pointer left, when React rewrote a state-dependent `className` and wiped an `in` added with `classList.add`. Hence `data-in`.
171
+
172
+ The no-JavaScript reset, in the head:
173
+
174
+ ```html
175
+ <noscript>
176
+ <style>.reveal { opacity: 1 !important; transform: none !important; }</style>
177
+ </noscript>
178
+ ```
179
+
180
+ ## 10. Performance: `m` not `motion`
181
+
182
+ `m.div` inside `<LazyMotion features={domAnimation} strict>`, never a bare `motion.*` (`domains/motion/performance.md`). The menu animates a bare `<m.div>` around the `glass-panel`, so the wrapper scales and the child carries the blur (law 13).
183
+
184
+ ## 11. Hover intent
185
+
186
+ A 100ms grace period on close (law 14):
187
+
188
+ ```ts
189
+ const closeBoxSoon = () => {
190
+ clearTimeout(closeTimer.current);
191
+ closeTimer.current = window.setTimeout(() => setConfigOpen(false), 100);
192
+ };
193
+ ```
194
+
195
+ The hover bridge here is `pt-2.5` of wrapper padding between a button and the pill below it.
196
+
197
+ **Hover is a wash, never a translate-up** (law 1). The folder's rising cards (`LIFT`) are the one exception, and being the only one is the point (`glass/motion/springs.md`).
198
+
199
+ ## 12. Press and sound
200
+
201
+ ```
202
+ active:scale-[0.92] /* 8% — felt, not seen */
203
+ ```
204
+
205
+ On `pointerdown` (law 3).
206
+
207
+ The site synthesises a "tok" on hover and click (Web Audio, no asset files): a sine fundamental, a fast-decaying marimba-like 3.9:1 partial, and a lowpassed noise transient for the wooden attack. Hover is quiet and high (1200Hz, 0.09s, 0.05 gain), click lower and louder (900Hz, 0.14s, 0.13 gain); it's gated on the autoplay policy, muteable, and persisted. Dense surfaces opt out of the hover tick with `[data-quiet-hover]`; clicks still sound. The rules for arming and muting are in `domains/motion/patterns.md`, sound.
208
+
209
+ **Sound can be a phrase, not just an event.** The opening screen's run, a note per photograph, is written out in A major pentatonic, where no two notes clash, so a run cut short by a failed image still resolves; the landing note, the only accented one, sits a fifth *below* the start, closing the phrase rather than adding a step.
210
+
211
+ Sound is *on* by default here, which is defensible only on a personal site and only if you're confident. → Synthesis walkthrough and delegation pattern: `glass/motion/sound.md`
212
+
213
+ ## 13. Fill means chosen
214
+
215
+ Law 9, in glass's mechanism: the filled variant is the outline with its interior painted, layered *over* the stroke at the same coordinates, so the layers cross-fade in place.
216
+
217
+ ```tsx
218
+ export const fillFade = (on: boolean) =>
219
+ `transition-opacity duration-200 ease-out motion-reduce:transition-none ${on ? "opacity-100" : "opacity-0"}`;
220
+
221
+ // The inverse, for interior lines the fill swallows (a briefcase's divider).
222
+ export const detailFade = (on: boolean) =>
223
+ `transition-opacity duration-200 ease-out motion-reduce:transition-none ${on ? "opacity-0" : "opacity-100"}`;
224
+ ```
225
+
226
+ **200ms `ease-out`, not the house curve**: overshoot suits travel, not a fade. The knockout mask for interior lines is in `domains/motion/patterns.md`, fill means chosen.
227
+
228
+ **How the marks that can't fill signal instead:**
229
+
230
+ | Mark | Why fill is wrong | What it does instead |
231
+ |---|---|---|
232
+ | Brand marks — socials, a tech stack | Official solid silhouettes; an outline is an off-brand redrawing | Colour: quiet at rest, full on hover |
233
+ | Open strokes — chevrons, arrows, checks, menu bars | No interior to paint | A stroke-weight ladder by role |
234
+ | A mark on static content, or a lone toggle | Never hovered, never current — or the only mark on screen | Stays in the default state |
235
+
236
+ ## 14. Set pieces play once
237
+
238
+ Two set pieces, each once a session (law 15): the opening screen, and a summary panel's first reveal.
239
+
240
+ ```ts
241
+ // Once a session, per item. The theatre is a welcome; a re-run is a delay.
242
+ const seenKey = (slug: string) => `short-version:${slug}`;
243
+ ```
244
+
245
+ The inline gate's glass condition:
246
+
247
+ ```js
248
+ // Home page, first landing, not a deep link (/#work), never under reduced motion.
249
+ document.documentElement.dataset.intro = "on";
250
+ ```
251
+
252
+ The summary panel's lines branch the transition, never the style, on the client-only flag:
253
+
254
+ ```tsx
255
+ variants={{
256
+ shut: { opacity: 0, y: 6, filter: "blur(4px)" }, // constant, both renders
257
+ open: { opacity: 1, y: 0, filter: "blur(0px)",
258
+ transition: { duration: reduce ? 0 : theatre ? 0.34 : 0.16, ease: EASE } },
259
+ }}
260
+ ```
261
+
262
+ "Seen" is recorded in `onAnimationComplete`, which also makes a second open in one visit as quiet as a second visit. The gate, the hydration rule and the "seen" bookkeeping are in `domains/motion/guards.md`.
263
+
264
+ Collapsibles animate height and stay mounted (law 11), so the content stays in the served HTML for a crawler or an `llms.txt`.
265
+
266
+ ## Assets
267
+
268
+ - `glass/motion/assets/motion-tokens.css` — easing curve, duration ladder, `.reveal`, route fade, theme crossfade.
269
+ - `glass/motion/assets/motion-presets.ts` — every spring and tween above, plus reduced-motion substitutes.
270
+ - `glass/motion/assets/DropMenu.tsx` — the squash-and-stretch menu.
271
+ - `glass/motion/assets/useScrollTick.ts` — rAF-throttled scroll with hysteresis.
272
+ - `glass/motion/assets/useReveal.ts` — the IntersectionObserver hook and its guards.
273
+
274
+ ## Checklist
275
+
276
+ The domain checklist first; then glass's values:
277
+
278
+ - [ ] Every duration on the ladder: no 250ms, 500ms or 1s.
279
+ - [ ] Off-ladder timing is the subject of the moment (the 620ms dissolve) or a once-a-session set piece, and says which.
280
+ - [ ] `--ease-geist` everywhere, plus `ease-out` for opacity-only, plain `ease` for the theme fade, and expo-out on any track driving a filter.
281
+ - [ ] Menus open on the `GROW`/`CROSS` pair and close on `CLOSE`.
282
+ - [ ] Hover timed by area: a link 100ms, a card-sized wash 200ms.
283
+ - [ ] No translate on hover, bar the folder's rising cards.
284
+ - [ ] Icon fills cross-fade at 200ms `ease-out`.
285
+ - [ ] `LazyMotion` + `m` + `strict`; no bare `motion.*`.
286
+ - [ ] Dense hover surfaces carry `data-quiet-hover`.
@@ -0,0 +1,81 @@
1
+ # Reduced motion
2
+
3
+ Glass's substitutes and the code that takes them. The principle, the full substitution table, imperative APIs and testing are law 10 and `domains/motion/guards.md`; reduced transparency and high contrast are §8 of `glass/depth/guide.md`.
4
+
5
+ ## The glass substitutes
6
+
7
+ The rows where glass sets a value or has a component the domain table doesn't name; every other row is as `domains/motion/guards.md`.
8
+
9
+ | Original | Reduced-motion path | Reasoning |
10
+ |---|---|---|
11
+ | Spring slide (highlight, tooltip) | 150ms tween | The state change is the message; physics is decoration. |
12
+ | Squash-and-stretch open | Plain 150ms fade | Presence still communicated, no scaling. |
13
+ | Card lift on hover | No hover variant at all | Motion is the whole interaction; static is correct. |
14
+ | Press scale | **Keep it** | 8% on a 40px button, 0ms: below any threshold of concern. |
15
+ | First-open theatre (staggered lines) | Same open, zero-length transitions | The disclosure opens; the choreography doesn't run. |
16
+ | A GPU dissolve between images | Fall back to the plain `<img>` | The no-WebGL path, already built. |
17
+ | Icon fill crossfade | Instant swap (`motion-reduce:transition-none`) | The state is the message; the 200ms fade isn't. |
18
+
19
+ ## In JavaScript
20
+
21
+ `useReducedMotion()` from Motion; branch the *transition*, keep the animation: `reduce ? { duration: 0.15 } : SLIDE`.
22
+
23
+ Where motion is the whole point of a variant-driven interaction, drop the trigger, on focus as well as hover:
24
+
25
+ ```tsx
26
+ <m.div
27
+ variants={container}
28
+ initial="rest"
29
+ animate="rest"
30
+ whileHover={reduce ? undefined : "hover"}
31
+ whileFocus={reduce ? undefined : "hover"}
32
+ >
33
+ ```
34
+
35
+ For presence, branch the offsets themselves:
36
+
37
+ ```tsx
38
+ initial={reduce ? { opacity: 0 } : { opacity: 0, scaleY: 0.82, scaleX: 0.94, y: away }}
39
+ animate={reduce ? { opacity: 1 } : { opacity: 1, scaleY: 1, scaleX: 1, y: 0 }}
40
+ exit={reduce
41
+ ? { opacity: 0, transition: CLOSE }
42
+ : { opacity: 0, scaleY: 0.9, scaleX: 0.97, y: away, transition: CLOSE }}
43
+ ```
44
+
45
+ The exit keeps `CLOSE` in both branches: 130ms `easeIn` is already flat, so it needs no substitute.
46
+
47
+ ## The blur crossfade
48
+
49
+ ```tsx
50
+ const blur = reduce ? "blur(0px)" : "blur(4px)";
51
+
52
+ <m.span
53
+ initial={{ opacity: 0, filter: blur }}
54
+ animate={{ opacity: 1, filter: "blur(0px)", transition: { duration: 0.28, ease: "easeOut" } }}
55
+ exit={{ opacity: 0, filter: blur, transition: { duration: 0.18, ease: "easeIn" } }}
56
+ >
57
+ ```
58
+
59
+ One variable turns a blur-crossfade into a plain crossfade; `blur(0px)` → `blur(0px)` is a no-op the browser optimises away, so there's no second code path. Note the asymmetry: 280ms in, 180ms out.
60
+
61
+ ## In CSS
62
+
63
+ The `.reveal` rule is §9 of `glass/motion/guide.md`; the theme crossfade's opt-in block is in `glass/motion/easing-and-duration.md`. The route fade:
64
+
65
+ ```css
66
+ @media (prefers-reduced-motion: reduce) {
67
+ .route-fade { animation: none; }
68
+ }
69
+ ```
70
+
71
+ ### Tailwind's `motion-reduce:` variant
72
+
73
+ ```tsx
74
+ className="transition-transform duration-200 ease-geist motion-reduce:transition-none"
75
+ ```
76
+
77
+ Apply it to every `transition-*` that moves something. Colour-only ones don't strictly need it (a 150ms colour fade isn't a vestibular trigger), but consistency costs nothing.
78
+
79
+ ## Set pieces
80
+
81
+ The greeting never plays under reduced motion, decided in its inline gate (§14 of the guide). **The `?intro` replay override, for development, does not defeat the preference**: it's a preference, not a cache. The "seen" flag is still written.