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,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
+ try { localStorage.setItem(KEY, choice); } catch { /* won't persist */ }
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,255 @@
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
+ - **100ms opacity hovers and icon fills** (§13): `ease-out`. Opacity entrances (`.reveal`, the route fade) keep the house curve on purpose: on opacity the overshoot clamps at 1.
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), opening on the tile nearest the viewport centre (law 8): `glass/motion/shared-element.md`, escalating to the GPU
120
+
121
+ ## 7. Reduced motion
122
+
123
+ Every animated component takes its own branch (law 10):
124
+
125
+ ```tsx
126
+ const reduce = useReducedMotion();
127
+ const tween = reduce ? { duration: 0.15 } : SLIDE;
128
+ ```
129
+
130
+ The glass substitutes: a spring slide becomes a 150ms tween; the squash-and-stretch open becomes a plain fade.
131
+
132
+ ```tsx
133
+ // No elastic scaling, but you still see it appear.
134
+ initial={reduce ? { opacity: 0 } : { opacity: 0, scaleY: 0.82, scaleX: 0.94, y: 4 }}
135
+ ```
136
+
137
+ Imperative APIs get the preference passed in: `scrollIntoView({ behavior: reduce ? "auto" : "smooth" })`. Reduced transparency and high contrast are §8 of `glass/depth/guide.md`.
138
+
139
+ → Every glass substitute: `glass/motion/reduced-motion.md`
140
+
141
+ ## 8. Scroll
142
+
143
+ One passive listener and one rAF per behaviour (law 12; the shape is in `domains/motion/performance.md`).
144
+
145
+ **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.
146
+
147
+ → Scroll spy, `scroll-mt` alignment, the grid collapse: `glass/motion/scroll-performance.md`
148
+
149
+ ## 9. Reveal on scroll
150
+
151
+ 300ms on the house curve, 8px of travel, `threshold: 0`, `rootMargin: "0px 0px -40px 0px"`, marked by `data-in`; the guards are law 11, whose two bugs are this site's. → `glass/motion/assets/motion-tokens.css`, `glass/motion/assets/useReveal.ts`
152
+
153
+ ## 10. Performance: `m` not `motion`
154
+
155
+ `m.div` inside `<LazyMotion features={domAnimation} strict>`, never a bare `motion.*` (`domains/motion/performance.md`). The menu's wrapper scales and its `glass-panel` child carries the blur (law 13; `glass/depth/guide.md` §3).
156
+
157
+ ## 11. Hover intent
158
+
159
+ A 100ms grace period on close (law 14):
160
+
161
+ ```ts
162
+ const closeBoxSoon = () => {
163
+ clearTimeout(closeTimer.current);
164
+ closeTimer.current = window.setTimeout(() => setConfigOpen(false), 100);
165
+ };
166
+ ```
167
+
168
+ The hover bridge here is `pt-2.5` of wrapper padding between a button and the pill below it.
169
+
170
+ **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`).
171
+
172
+ ## 12. Press and sound
173
+
174
+ ```
175
+ active:scale-[0.92] /* 8% — felt, not seen */
176
+ ```
177
+
178
+ On `pointerdown` (law 3).
179
+
180
+ Sound is *on* by default here, defensible only on a personal site: a synthesised "tok" on hover and click, muteable; `[data-quiet-hover]` silences hover on dense surfaces. → `glass/motion/sound.md`; the rules: `domains/motion/patterns.md`, sound
181
+
182
+ ## 13. Fill means chosen
183
+
184
+ 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.
185
+
186
+ ```tsx
187
+ export const fillFade = (on: boolean) =>
188
+ `transition-opacity duration-200 ease-out motion-reduce:transition-none ${on ? "opacity-100" : "opacity-0"}`;
189
+
190
+ // The inverse, for interior lines the fill swallows (a briefcase's divider).
191
+ export const detailFade = (on: boolean) =>
192
+ `transition-opacity duration-200 ease-out motion-reduce:transition-none ${on ? "opacity-0" : "opacity-100"}`;
193
+ ```
194
+
195
+ **200ms `ease-out`, not the house curve** (§2). The knockout mask for interior lines is in `domains/motion/patterns.md`, fill means chosen.
196
+
197
+ **How the marks that can't fill signal instead:**
198
+
199
+ | Mark | Why fill is wrong | What it does instead |
200
+ |---|---|---|
201
+ | Brand marks — socials, a tech stack | Official solid silhouettes; an outline is an off-brand redrawing | Colour: quiet at rest, full on hover |
202
+ | Open strokes — chevrons, arrows, checks, menu bars | No interior to paint | A stroke-weight ladder by role |
203
+ | A mark on static content, or a lone toggle | Never hovered, never current — or the only mark on screen | Stays in the default state |
204
+
205
+ ## 14. Set pieces play once
206
+
207
+ Two set pieces, each once a session (law 15): the opening screen, and a summary panel's first reveal.
208
+
209
+ ```ts
210
+ // Once a session, per item. The theatre is a welcome; a re-run is a delay.
211
+ const seenKey = (slug: string) => `short-version:${slug}`;
212
+ ```
213
+
214
+ The inline gate's glass condition:
215
+
216
+ ```js
217
+ // Home page, first landing, not a deep link (/#work), never under reduced motion.
218
+ document.documentElement.dataset.intro = "on";
219
+ ```
220
+
221
+ The summary panel's lines branch the transition, never the style, on the client-only flag:
222
+
223
+ ```tsx
224
+ variants={{
225
+ shut: { opacity: 0, y: 6, filter: "blur(4px)" }, // constant, both renders
226
+ open: { opacity: 1, y: 0, filter: "blur(0px)",
227
+ transition: { duration: reduce ? 0 : theatre ? 0.34 : 0.16, ease: EASE } },
228
+ }}
229
+ ```
230
+
231
+ "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`.
232
+
233
+ Collapsibles animate height and stay mounted (law 11), so the content stays in the served HTML for a crawler or an `llms.txt`.
234
+
235
+ ## Assets
236
+
237
+ - `glass/motion/assets/motion-tokens.css` — easing curve, duration ladder, `.reveal`, route fade, theme crossfade.
238
+ - `glass/motion/assets/motion-presets.ts` — every spring and tween above, plus reduced-motion substitutes.
239
+ - `glass/motion/assets/DropMenu.tsx` — the squash-and-stretch menu.
240
+ - `glass/motion/assets/useScrollTick.ts` — rAF-throttled scroll with hysteresis.
241
+ - `glass/motion/assets/useReveal.ts` — the IntersectionObserver hook and its guards.
242
+
243
+ ## Checklist
244
+
245
+ The domain checklist first; then glass's values:
246
+
247
+ - [ ] Every duration on the ladder: no 250ms, 500ms or 1s.
248
+ - [ ] Off-ladder timing is the subject of the moment (the 620ms dissolve) or a once-a-session set piece, and says which.
249
+ - [ ] `--ease-geist` everywhere, opacity entrances included, plus `ease-out` for 100ms opacity hovers and icon fills, plain `ease` for the theme fade, and expo-out on any track driving a filter.
250
+ - [ ] Menus open on the `GROW`/`CROSS` pair and close on `CLOSE`.
251
+ - [ ] Hover timed by area: a link 100ms, a card-sized wash 200ms.
252
+ - [ ] No translate on hover, bar the folder's rising cards.
253
+ - [ ] Icon fills cross-fade at 200ms `ease-out`.
254
+ - [ ] `LazyMotion` + `m` + `strict`; no bare `motion.*`.
255
+ - [ ] 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 in `glass/motion/assets/motion-tokens.css`; 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.
@@ -0,0 +1,58 @@
1
+ # Scroll and performance
2
+
3
+ Glass's values for the scroll and compositor rules. The scroll shape, hysteresis, the touch gate, scroll spies and what to animate are laws 12 and 13 and `domains/motion/performance.md`; the React hooks are in `glass/motion/assets/useScrollTick.ts`.
4
+
5
+ ## The dock
6
+
7
+ The dock (`useCollapseOnScroll`) collapses on scroll-down with the domain's dead zone — 6px of intent either way, always expanded within 24px of the top and at `scrollHeight - 2`. It collapses only where `(hover: hover) and (pointer: fine)` matches; touch gets its own presentation (the Glass layout guide, `glass/layout/guide.md`, on adaptive presentations).
8
+
9
+ ## Scroll spy
10
+
11
+ The spy is `useActiveSection`. Its reading line sits 120px below the viewport top (`READING_LINE = 120`), with the bottom clamp.
12
+
13
+ ### Matching `scroll-mt`
14
+
15
+ ```tsx
16
+ const ANCHOR = "scroll-mt-28"; // 112px — matched to READING_LINE = 120
17
+ ```
18
+
19
+ Every heading carries `scroll-mt-28`, so a jump lands it 112px below the viewport top, just above the 120px line.
20
+
21
+ ### Deep links without the router
22
+
23
+ ```tsx
24
+ el.scrollIntoView({ behavior: reduce ? "auto" : "smooth", block: "start" });
25
+ window.history.replaceState(null, "", `#${id}`);
26
+ ```
27
+
28
+ The URL stays copyable without a second scroll fighting the first.
29
+
30
+ ## The `1fr` ↔ `0fr` grid trick, in Tailwind
31
+
32
+ Collapsing a region to zero width without animating `width`:
33
+
34
+ ```tsx
35
+ <li
36
+ className="grid transition-[grid-template-columns] duration-300 ease-geist motion-reduce:transition-none"
37
+ style={{ gridTemplateColumns: expanded ? "1fr" : "0fr" }}
38
+ >
39
+ <div className="flex min-w-0 items-center overflow-hidden">{children}</div>
40
+ </li>
41
+ ```
42
+
43
+ `min-w-0` on the child is required (flex children default to `min-width: auto` and won't shrink below their content); `overflow-hidden` clips during the collapse.
44
+
45
+ ## `backdrop-filter` and transforms
46
+
47
+ The animating wrapper and the glass surface are different elements (law 13):
48
+
49
+ ```tsx
50
+ <DropMenu open={open}> {/* animates scale + y */}
51
+ <ul className="glass-panel …"> {/* carries the blur, never transformed */}
52
+ ```
53
+
54
+ Where the panel itself must animate, carry more of the effect in the tint than the blur: `glass-panel` sits at 97% opacity because its blur is compromised while it animates.
55
+
56
+ ## Timers
57
+
58
+ The hover-intent timer is `closeTimer`, cleared on unmount and reset on `pathname` (law 14, `domains/motion/patterns.md`).
@@ -0,0 +1,161 @@
1
+ # The shared-element highlight
2
+
3
+ The signature interaction of this system: one highlight rectangle and one tooltip per container, animating position and size to whichever child is hovered or focused. Why it beats per-item backgrounds, and when it's the wrong tool, is law 8; the details that keep it from flickering are in `domains/motion/patterns.md`, the shared highlight.
4
+
5
+ ## The implementation
6
+
7
+ ```tsx
8
+ type Hover = { x: number; y: number; w: number; h: number; cx: number; label: string };
9
+
10
+ const [hovered, setHovered] = useState<Hover | null>(null);
11
+
12
+ const enter = (el: HTMLElement, label: string) =>
13
+ setHovered({
14
+ x: el.offsetLeft,
15
+ y: el.offsetTop,
16
+ w: el.offsetWidth,
17
+ h: el.offsetHeight,
18
+ cx: el.offsetLeft + el.offsetWidth / 2, // centre, for the tooltip
19
+ label,
20
+ });
21
+ ```
22
+
23
+ ```tsx
24
+ <ul className="relative isolate flex items-center …" onMouseLeave={() => setHovered(null)}>
25
+
26
+ {/* The highlight — behind the icons, inside its own stacking context */}
27
+ <m.div
28
+ aria-hidden
29
+ className="pointer-events-none absolute left-0 top-0 -z-10 rounded-[12px] bg-highlight"
30
+ initial={false}
31
+ animate={hovered
32
+ ? { x: hovered.x, y: hovered.y, width: hovered.w, height: hovered.h, opacity: 1 }
33
+ : { opacity: 0 }}
34
+ transition={tween}
35
+ />
36
+
37
+ {items.map((item) => (
38
+ <a
39
+ key={item.label}
40
+ onMouseEnter={(e) => enter(e.currentTarget, item.label)}
41
+ onFocus={(e) => enter(e.currentTarget, item.label)}
42
+ …
43
+ />
44
+ ))}
45
+ </ul>
46
+ ```
47
+
48
+ Pair `onFocus` with `onBlur` where the list can lose focus without the pointer leaving.
49
+
50
+ ## The tooltip variant
51
+
52
+ Same state, same spring, one axis:
53
+
54
+ ```tsx
55
+ <m.div
56
+ aria-hidden
57
+ className="pointer-events-none absolute bottom-full left-0 mb-2"
58
+ initial={false}
59
+ animate={hovered ? { x: hovered.cx, opacity: 1 } : { opacity: 0 }}
60
+ transition={tween}
61
+ >
62
+ <div className="-translate-x-1/2 whitespace-nowrap rounded-md bg-gray-1000 px-2 py-1
63
+ text-[11px] leading-none text-background-100
64
+ shadow-[0_4px_10px_-4px_rgba(0,0,0,0.3)]">
65
+ {hovered?.label}
66
+ </div>
67
+ </m.div>
68
+ ```
69
+
70
+ `bottom-full mb-2` places it above the container at any height; flip to `top-full mt-2` when the container is itself a dropdown, so the tooltip doesn't cover what opened it. It's `aria-hidden` because every item already carries an `aria-label` with the same text.
71
+
72
+ ## The vertical list variant
73
+
74
+ For rows rather than icons only the container class changes: items carry the padding, so the bar is the same box everywhere, and a negative horizontal margin lets it bleed past the text column:
75
+
76
+ ```tsx
77
+ <HighlightList
78
+ className="reveal -mx-3 flex flex-col text-[14px] leading-[1.55]"
79
+ itemClassName="group relative flex gap-10 px-3 py-2.5"
80
+ items={…}
81
+ />
82
+ ```
83
+
84
+ `-mx-3` against `px-3` on each item: the bar extends 12px past the measure on both sides while the text sits exactly on it.
85
+
86
+ Here the bar uses `bg-gray-100` rather than `bg-highlight`: in a list on the page surface the subtle-surface token is right; in floating glass chrome, the elevated one.
87
+
88
+ ## Interaction with active state
89
+
90
+ The active item shows a static background and drops it while the dock is hovered (`domains/motion/patterns.md`, current versus hovered):
91
+
92
+ ```tsx
93
+ isActive
94
+ ? `text-gray-1000 ${dockHover ? "" : "bg-highlight shadow-[0_2px_2px_rgba(0,0,0,0.04)]"}`
95
+ : "text-gray-600 hover:text-gray-1000"
96
+ ```
97
+
98
+ The glyph fills both when current and when hovered (§13 of `glass/motion/guide.md`), and the chip tells the two apart when both are on screen.
99
+
100
+ ## Escalating to the GPU
101
+
102
+ What broke the DOM approach: a gallery with a masonry wall of ~100 tiles and a "roll" with one photo open above a filmstrip, the same photograph travelling between them. Three things break at once:
103
+
104
+ 1. **Aspect ratios differ.** Consecutive photographs are 1:1 then 16:9; one `<img>` animated between the boxes distorts for the whole trip.
105
+ 2. **The wall is expensive to re-lay-out.** Layout on 100 tiles is not a per-frame budget.
106
+ 3. **The endpoints exist at different moments.** One rect is measurable only while the outgoing layout is rendered, the other only after the incoming one commits.
107
+
108
+ One viewport-sized canvas above the gallery draws a single photo into an arbitrary screen rect, so the trip is four numbers in a uniform and costs no reflow at any size:
109
+
110
+ ```ts
111
+ export type Rect = { x: number; y: number; width: number; height: number };
112
+
113
+ /** Straight-line interpolation. Equal-aspect endpoints keep their aspect throughout. */
114
+ export function lerpRect(a: Rect, b: Rect, t: number): Rect { /* … */ }
115
+ ```
116
+
117
+ The rules are law 13 and `domains/motion/performance.md`, escalating to the GPU. Here the wall stays DOM, since a hundred tiles as textures is ~590MB before atlasing, and the open photo is `alt=""`, its labels in the strip beside it.
118
+
119
+ **Failing returns `null`:**
120
+
121
+ ```ts
122
+ export function createPhotoStage(canvas: HTMLCanvasElement): PhotoStage | null {
123
+ try {
124
+ /* … */
125
+ // A failed shader link only warns in some libraries; the throw lands on the
126
+ // first draw. Check the artefact that proves setup completed.
127
+ if (!program.uniformLocations) return null;
128
+ /* … */
129
+ } catch {
130
+ return null; // No WebGL, or a context that can't be set up.
131
+ }
132
+ }
133
+ ```
134
+
135
+ **Carry the reading position across** (law 8). Before the switch, find the tile nearest the viewport centre and open the other layout on *that* item, from *that* rect:
136
+
137
+ ```ts
138
+ // Read while the OUTGOING layout is still rendered — from the layout store's
139
+ // synchronous subscriber, before React commits the incoming one.
140
+ export function centreFrame(): { id: string; rect: Rect } | null {
141
+ const midX = window.innerWidth / 2, midY = window.innerHeight / 2;
142
+ let best = null;
143
+ for (const el of document.querySelectorAll<HTMLImageElement>("img[data-frame]")) {
144
+ const r = el.getBoundingClientRect();
145
+ if (r.width <= 0 || r.bottom <= 0 || r.top >= window.innerHeight) continue;
146
+ const d = Math.hypot(r.left + r.width / 2 - midX, r.top + r.height / 2 - midY);
147
+ if (!best || d < best.d) best = { id: el.dataset.frame!, rect: r, d };
148
+ }
149
+ return best;
150
+ }
151
+ ```
152
+
153
+ **Each end keeps its own shape**: two rects around a shared centre, each keeping its aspect through the linear lerp. The GPU path then matches the `<img>` fallback exactly: two absolutely-centred images, each at its own aspect, crossfading.
154
+
155
+ ### Implementation notes that cost real time to find
156
+
157
+ **Mipmaps.** A 2000px photo in a 355px tile samples one texel in five, so without a mip chain it shimmers as the rect moves sub-pixel. Use trilinear (`LINEAR_MIPMAP_LINEAR`) so crossing mip levels as the photo grows doesn't step.
158
+
159
+ **Fade the last pixel of the edge** with one `smoothstep` across a pixel's width, fading *alpha only* on a non-premultiplied context, or the edge darkens toward black instead of disappearing.
160
+
161
+ **Don't call `WEBGL_lose_context` on teardown.** React reuses the node across a remount (StrictMode does on every mount), and the next context silently fails to compile its shaders. Drop the textures and references; the context goes with the element.