trykeyline 0.1.0 → 0.1.2

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 (73) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/LICENSE +0 -16
  3. package/bin/keyline.mjs +2 -1
  4. package/package.json +1 -1
  5. package/plugins/keyline/.claude-plugin/plugin.json +1 -1
  6. package/plugins/keyline/plugin.json +22 -0
  7. package/plugins/keyline/skills/keyline/SKILL.md +22 -32
  8. package/plugins/keyline/skills/keyline/commands/audit.md +8 -3
  9. package/plugins/keyline/skills/keyline/commands/critique.md +3 -26
  10. package/plugins/keyline/skills/keyline/commands/init.md +5 -4
  11. package/plugins/keyline/skills/keyline/commands/polish.md +2 -2
  12. package/plugins/keyline/skills/keyline/commands/refine.md +6 -16
  13. package/plugins/keyline/skills/keyline/commands/sync.md +1 -3
  14. package/plugins/keyline/skills/keyline/commands/ux-laws.md +26 -0
  15. package/plugins/keyline/skills/keyline/domains/color/deriving-a-ramp.md +1 -1
  16. package/plugins/keyline/skills/keyline/domains/color/guide.md +10 -46
  17. package/plugins/keyline/skills/keyline/domains/color/status-and-exceptions.md +2 -2
  18. package/plugins/keyline/skills/keyline/domains/depth/elevation-recipes.md +2 -8
  19. package/plugins/keyline/skills/keyline/domains/depth/guide.md +12 -41
  20. package/plugins/keyline/skills/keyline/domains/layout/guide.md +31 -56
  21. package/plugins/keyline/skills/keyline/domains/layout/measure-and-rhythm.md +16 -16
  22. package/plugins/keyline/skills/keyline/domains/layout/radius-lines-and-layers.md +2 -2
  23. package/plugins/keyline/skills/keyline/domains/motion/guide.md +12 -47
  24. package/plugins/keyline/skills/keyline/domains/motion/patterns.md +1 -1
  25. package/plugins/keyline/skills/keyline/domains/motion/performance.md +1 -1
  26. package/plugins/keyline/skills/keyline/domains/patterns/guide.md +20 -62
  27. package/plugins/keyline/skills/keyline/domains/patterns/iteration.md +1 -9
  28. package/plugins/keyline/skills/keyline/domains/type/guide.md +13 -37
  29. package/plugins/keyline/skills/keyline/domains/type/ladders.md +1 -1
  30. package/plugins/keyline/skills/keyline/domains/type/type-audit.md +5 -8
  31. package/plugins/keyline/skills/keyline/glass/color/assets/color-tokens.css +34 -165
  32. package/plugins/keyline/skills/keyline/glass/color/assets/theme.ts +10 -70
  33. package/plugins/keyline/skills/keyline/glass/color/guide.md +3 -30
  34. package/plugins/keyline/skills/keyline/glass/color/intent-ramp.md +2 -34
  35. package/plugins/keyline/skills/keyline/glass/color/theming.md +5 -72
  36. package/plugins/keyline/skills/keyline/glass/depth/assets/GlassPanel.tsx +14 -121
  37. package/plugins/keyline/skills/keyline/glass/depth/assets/glass.css +29 -188
  38. package/plugins/keyline/skills/keyline/glass/depth/depth-cues.md +1 -16
  39. package/plugins/keyline/skills/keyline/glass/depth/guide.md +4 -73
  40. package/plugins/keyline/skills/keyline/glass/depth/performance-and-fallbacks.md +3 -11
  41. package/plugins/keyline/skills/keyline/glass/layout/adaptive-presentations.md +5 -91
  42. package/plugins/keyline/skills/keyline/glass/layout/assets/SectionNav.tsx +3 -6
  43. package/plugins/keyline/skills/keyline/glass/layout/assets/layout-tokens.css +11 -138
  44. package/plugins/keyline/skills/keyline/glass/layout/guide.md +10 -15
  45. package/plugins/keyline/skills/keyline/glass/layout/measure-and-rhythm.md +4 -6
  46. package/plugins/keyline/skills/keyline/glass/layout/radius-and-elevation.md +3 -2
  47. package/plugins/keyline/skills/keyline/glass/motion/assets/DropMenu.tsx +4 -6
  48. package/plugins/keyline/skills/keyline/glass/motion/assets/motion-presets.ts +10 -184
  49. package/plugins/keyline/skills/keyline/glass/motion/assets/motion-tokens.css +21 -105
  50. package/plugins/keyline/skills/keyline/glass/motion/easing-and-duration.md +2 -31
  51. package/plugins/keyline/skills/keyline/glass/motion/guide.md +10 -41
  52. package/plugins/keyline/skills/keyline/glass/motion/reduced-motion.md +1 -7
  53. package/plugins/keyline/skills/keyline/glass/motion/scroll-performance.md +3 -32
  54. package/plugins/keyline/skills/keyline/glass/motion/shared-element.md +1 -1
  55. package/plugins/keyline/skills/keyline/glass/motion/sound.md +10 -80
  56. package/plugins/keyline/skills/keyline/glass/type/assets/prose.tsx +8 -43
  57. package/plugins/keyline/skills/keyline/glass/type/assets/type-tokens.css +49 -86
  58. package/plugins/keyline/skills/keyline/glass/type/guide.md +5 -7
  59. package/plugins/keyline/skills/keyline/glass/type/long-form-prose.md +9 -74
  60. package/plugins/keyline/skills/keyline/glass/type/scale.md +2 -6
  61. package/plugins/keyline/skills/keyline/glass/type/weight-and-tracking.md +4 -20
  62. package/plugins/keyline/skills/keyline/scripts/check/css.mjs +5 -14
  63. package/plugins/keyline/skills/keyline/scripts/check/design.mjs +3 -1
  64. package/plugins/keyline/skills/keyline/scripts/check/files.mjs +70 -26
  65. package/plugins/keyline/skills/keyline/scripts/check/markup.mjs +3 -3
  66. package/plugins/keyline/skills/keyline/scripts/check/rules.mjs +9 -1
  67. package/plugins/keyline/skills/keyline/scripts/check.mjs +6 -3
  68. package/plugins/keyline/skills/keyline/scripts/contrast.mjs +2 -0
  69. package/plugins/keyline/skills/keyline/scripts/scan.mjs +29 -45
  70. package/plugins/keyline/skills/keyline/system/design-md.md +4 -4
  71. package/plugins/keyline/skills/keyline/system/guide.md +4 -8
  72. package/plugins/keyline/skills/keyline/system/presets.md +16 -16
  73. package/plugins/keyline/skills/keyline/system/roles.md +8 -8
@@ -1,24 +1,9 @@
1
1
  /**
2
- * Motion presets — every spring and tween in the system, with the
3
- * reduced-motion substitutes.
4
- *
5
- * Two vocabularies appear here on purpose:
6
- * bounce + duration — a designer's controls; predictable arrival
7
- * stiffness + damping + mass — needed when the RELATIONSHIP between two
8
- * springs is the effect (see GROW / CROSS)
9
- *
10
- * The one rule that applies to all of them: opacity never springs. Position
11
- * wants physics, opacity wants a curve. A spring on opacity overshoots past
12
- * its clamp and hitches on the way back — a visible flicker at the tail of
13
- * every fade.
2
+ * Motion presets — the springs and tweens glass uses. Why each number is what
3
+ * it is: glass/motion/springs.md. Opacity never springs (motion law 6).
14
4
  */
15
5
 
16
- /* ── Shared element re-targeting ─────────────────────────────────────────
17
- A highlight or tooltip sliding to a new item. bounce is near the floor of
18
- "perceptibly springy" on purpose: this is a thing MOVING, not a thing
19
- ARRIVING. Bounce implies a moment of settling, and a highlight that
20
- settles at every stop draws attention to itself instead of to the item
21
- it's marking. */
6
+ /** Shared highlight or tooltip re-targeting: moving, not arriving. */
22
7
  export const SLIDE = {
23
8
  type: "spring",
24
9
  bounce: 0.2,
@@ -26,184 +11,25 @@ export const SLIDE = {
26
11
  opacity: { duration: 0.16, ease: "easeOut" },
27
12
  } as const;
28
13
 
29
- /* ── The one playful moment ──────────────────────────────────────────────
30
- Cards rising out of a folder on hover. 40% more bounce and 25% more
31
- duration than anything else in the system. That it's the ONLY such moment
32
- is why it works: against a baseline of restraint, one exuberant
33
- interaction reads as craft. Three of them read as a template. */
14
+ /** Cards rising out of a folder — the one playful moment. */
34
15
  export const LIFT = { type: "spring", bounce: 0.28, duration: 0.5 } as const;
35
16
 
36
- /* ── Squash and stretch, out of physics ──────────────────────────────────
37
- Two springs of deliberately different character, applied to the two axes
38
- of one panel. Every difference pushes the same direction — GROW is fast
39
- and loose, CROSS is slow and controlled:
40
-
41
- stiffness 620 vs 380 GROW pulls harder, moves sooner
42
- damping 19 vs 30 GROW resists less, overshoots
43
- mass 0.85 vs 1 GROW is lighter, accelerates faster
44
-
45
- Through the overshoot the panel is briefly TALLER and NARROWER than it
46
- ends up, then settles. Squash and stretch emerging from physics rather
47
- than a hand-authored timeline — interruptible and velocity-aware for free.
48
-
49
- Generalises: when two axes of one element move together, giving them
50
- different springs creates deformation, and deformation reads as mass. */
17
+ /** Squash and stretch: GROW on scaleY and y, CROSS on scaleX (glass/motion/guide.md §4). */
51
18
  export const GROW = { type: "spring", stiffness: 620, damping: 19, mass: 0.85 } as const;
52
19
  export const CROSS = { type: "spring", stiffness: 380, damping: 30 } as const;
53
20
 
54
- /* ── Closing ─────────────────────────────────────────────────────────────
55
- A tween, not a spring. easeIn, so it accelerates away rather than
56
- decelerating in. 130ms, well under the state tier.
57
-
58
- A bounce on the way out reads as the interface being reluctant, and it
59
- stands between the user and whatever they're reaching for next.
60
- Entrances can have character; exits should get out of the way. */
21
+ /** Closing: a flat tween that gets out of the way. */
61
22
  export const CLOSE = { duration: 0.13, ease: "easeIn" } as const;
62
23
 
63
- /* ── Reduced motion ──────────────────────────────────────────────────────
64
- Substitute, don't subtract. The preference exists because large-area or
65
- accelerating motion is physically unpleasant for some users — it is not a
66
- preference for a static interface. A user who sets it still needs to know
67
- that a menu opened.
68
-
69
- Flat, state-tier, no overshoot. The state change stays legible; the
70
- physics goes away. */
71
- export const FLAT = { duration: 0.15 } as const;
72
-
73
- /** Pick the spring or its flat substitute. `reduce` from useReducedMotion(). */
74
- export function motionOr<T>(reduce: boolean | null, animated: T) {
75
- return reduce ? FLAT : animated;
76
- }
77
-
78
- /* ── Duration ladder, for inline JS use ──────────────────────────────────
79
- Mirrors motion-tokens.css. Seconds, because that's what Motion takes. */
80
- export const DURATION = {
81
- micro: 0.1,
82
- state: 0.15,
83
- popover: 0.2,
84
- route: 0.26,
85
- overlay: 0.3,
86
- theme: 0.4,
87
- } as const;
88
-
89
- /* ── Off the ladder, deliberately ────────────────────────────────────────
90
- The ladder is for STATE: something changed and the user has to follow it.
91
- Two categories aren't state, and squeezing them onto the ladder makes
92
- them worse rather than tighter.
93
-
94
- THE SUBJECT OF THE MOMENT. When the thing animating IS the content, the
95
- user is looking directly at it and the motion has to read in its own
96
- right. Note the discipline in the pair below: same component, two
97
- durations, decided by whether the motion is the subject or the report. */
98
- export const SUBJECT = {
99
- /** Stepping between images — a noise-thresholded dissolve, not a cut. */
100
- dissolve: 0.62,
101
- /** The same image moving between two layouts. On screen at both ends, so
102
- it only has to be followable — back inside the overlay tier. */
103
- flight: 0.42,
104
- } as const;
105
-
106
- /* A SET PIECE gets its own budget by never appearing again: once per tab,
107
- never blocking a deep link, never played under reduced motion. See §14 of
108
- glass/motion/guide.md for the gating. */
109
- export const SET_PIECE = {
110
- /** The first open of a disclosure, per line. */
111
- theatreLine: 0.34,
112
- /** Every subsequent open of the same thing. */
113
- quietLine: 0.16,
114
- /** Between staggered children on the first open only. */
115
- stagger: 0.09,
116
- } as const;
117
-
118
- /* ── The curve for a track that also drives a filter ─────────────────────
119
- NOT --ease-geist. The house curve overshoots past 1, and a blur animating
120
- to 0px would have to pass through a NEGATIVE radius to settle back.
121
- Browsers clamp rather than crash, so the symptom isn't an error — it's a
122
- value stuck at zero for the tail while everything else is still moving,
123
- which reads as the animation stalling right at the end.
124
-
125
- Applies to any property with a hard floor: blur, brightness, a radius
126
- going to 0, a scale you've promised won't invert. Expo-out has the same
127
- "arrives decisively" character without ever leaving [0, 1]. */
128
- export const EASE_FILTER = [0.16, 1, 0.3, 1] as const;
129
-
130
- /* ── Blur crossfade ──────────────────────────────────────────────────────
131
- Text swapping in place — a wordmark revealing its tagline on hover, say.
132
- Note the asymmetry (280 in / 180 out): same principle as the springs.
133
-
134
- Blur is the vestibular trigger; the fade isn't. So reduced motion swaps
135
- only the blur amount to 0px and keeps the crossfade. Animating
136
- blur(0px) → blur(0px) is a no-op the browser optimises away, so this needs
137
- no second code path. */
138
- export function blurCrossfade(reduce: boolean | null) {
139
- const blur = reduce ? "blur(0px)" : "blur(4px)";
140
- return {
141
- initial: { opacity: 0, filter: blur },
142
- animate: { opacity: 1, filter: "blur(0px)", transition: { duration: 0.28, ease: "easeOut" } },
143
- exit: { opacity: 0, filter: blur, transition: { duration: 0.18, ease: "easeIn" } },
144
- } as const;
145
- }
146
-
147
- /* ── First-open theatre ──────────────────────────────────────────────────
148
- Staggered lines behind a blur, on the FIRST open only; every subsequent
149
- open is a quick fade. `theatre` comes from sessionStorage, so it is false
150
- on the server for everyone and true on the client for most.
151
-
152
- Which is why the shut state below is a CONSTANT. Branch a rendered style
153
- on a client-only flag and React hands you two different first paints and
154
- says so in the console. Only the transition reads the flags — a transition
155
- isn't a style, so there's nothing for hydration to disagree about, and a
156
- reader who asked for less motion gets a zero-length transition to the same
157
- end state rather than a different starting position.
158
-
159
- Record "seen" on animation COMPLETE, not on start: setting it earlier
160
- lands in the same render batch as the open and cancels the very animation
161
- it's recording. */
162
- export function theatreLine(reduce: boolean | null, theatre: boolean) {
163
- return {
164
- shut: { opacity: 0, y: 6, filter: "blur(4px)" },
165
- open: {
166
- opacity: 1,
167
- y: 0,
168
- filter: "blur(0px)",
169
- transition: {
170
- duration: reduce ? 0 : theatre ? SET_PIECE.theatreLine : SET_PIECE.quietLine,
171
- // A blur is on this track — see EASE_FILTER above.
172
- ease: EASE_FILTER,
173
- },
174
- },
175
- } as const;
176
- }
177
-
178
- /** The parent's stagger. Zero after the first open, so a re-open is instant. */
179
- export function theatreList(theatre: boolean) {
180
- return {
181
- open: {
182
- transition: {
183
- staggerChildren: theatre ? SET_PIECE.stagger : 0,
184
- delayChildren: theatre ? 0.06 : 0,
185
- },
186
- },
187
- } as const;
188
- }
189
-
190
- /* ── Icon fill ───────────────────────────────────────────────────────────
191
- Fill means CHOSEN — the page you're on, or the one you're pointing at.
192
- Nothing is filled because it looks better.
193
-
194
- The filled variant is not a second drawing: it's the same outline with its
195
- interior painted in, layered OVER the stroke at the same coordinates. The
196
- silhouette never moves, so the two crossfade in place — no pop, no reflow.
197
-
198
- ease-out, not the house curve: the overshoot is right for travel and wrong
199
- for a fade. Same split the springs make between position and opacity. */
24
+ /* Icon fill: the filled layer sits over the outline at the same coordinates
25
+ and cross-fades in place (glass/motion/guide.md §13). ease-out, not the
26
+ house curve. */
200
27
  export const fillFade = (on: boolean) =>
201
28
  `transition-opacity duration-200 ease-out motion-reduce:transition-none ${
202
29
  on ? "opacity-100" : "opacity-0"
203
30
  }`;
204
31
 
205
- /** The inverse — interior lines a solid shape swallows (a briefcase's
206
- divider, a document's rules) have to leave as the fill arrives. */
32
+ /** The inverse — interior lines the fill swallows (a briefcase's divider). */
207
33
  export const detailFade = (on: boolean) =>
208
34
  `transition-opacity duration-200 ease-out motion-reduce:transition-none ${
209
35
  on ? "opacity-0" : "opacity-100"
@@ -1,56 +1,20 @@
1
1
  /* ─────────────────────────────────────────────────────────────────────────
2
2
  Interface motion — tokens and CSS-driven effects
3
3
  Drop-in for Tailwind v4 (@theme) or plain CSS (the :root fallback below).
4
+ The ladder and the reasons for each value: glass/motion/guide.md §1–2.
4
5
  ───────────────────────────────────────────────────────────────────────── */
5
6
 
6
7
  @theme {
7
- /* ── One curve for the whole system ─────────────────────────────────────
8
- The 1.1 end control point overshoots ~10% and settles back — enough to
9
- feel physical, not enough to read as bounce. A single shared curve is
10
- why unrelated elements moving at once still feel like one mechanism.
11
-
12
- Do NOT add a second bezier "because this needs a different feel." If an
13
- element needs distinct character it needs a spring — that's the axis
14
- where character belongs without fragmenting the system. */
8
+ /* One curve for the whole system; the 1.1 overshoots ~10% and settles.
9
+ Character beyond it belongs to springs, never a second bezier. */
15
10
  --ease-geist: cubic-bezier(0.175, 0.885, 0.32, 1.1);
16
11
 
17
- /* ── Duration ladder ────────────────────────────────────────────────────
18
- Set by how much of the screen changes, not by how important the element
19
- feels. Three numbers carry almost everything: 150 / 200 / 300. */
20
- --duration-micro: 100ms; /* one property, no position — link opacity */
21
- --duration-state: 150ms; /* colour, background, small transform, icon */
22
- --duration-popover: 200ms; /* chevron, hover card, compound glyph */
23
- --duration-route: 260ms; /* whole viewport, on the critical path */
24
- --duration-overlay: 300ms; /* large region entering or collapsing */
25
- --duration-theme: 400ms; /* literally everything, at once */
26
-
27
- /* ── Off the ladder, deliberately ───────────────────────────────────────
28
- The ladder is for STATE. Two things aren't state, and squeezing them
29
- onto it makes them worse rather than tighter.
30
-
31
- THE SUBJECT OF THE MOMENT — the thing animating IS the content, so the
32
- user is looking directly at it and the motion has to read in its own
33
- right. Note the pair: the dissolve is the subject, the flight is a
34
- report about it, so the flight stays inside the overlay tier. */
35
- --duration-dissolve: 620ms; /* stepping between images */
36
- --duration-flight: 420ms; /* one image moving between two layouts */
37
-
38
- /* A SET PIECE earns its budget by never appearing again — once per tab,
39
- never blocking a deep link, never played under reduced motion. */
40
- --duration-setpiece-in: 560ms;
41
- --duration-setpiece-out: 320ms;
42
-
43
- /* ── The curve for a track that also drives a filter ────────────────────
44
- NOT --ease-geist. The house curve overshoots past 1, and a blur
45
- animating to 0px would have to pass through a NEGATIVE radius to settle
46
- back. Browsers clamp rather than crash, so the symptom is a value stuck
47
- at zero for the tail of the transition — which reads as the animation
48
- stalling right at the end, and is very hard to attribute.
49
-
50
- Applies to any property with a hard floor: blur, brightness, a radius
51
- going to 0, a scale you've promised won't invert. Expo-out has the same
52
- "arrives decisively" character without ever leaving [0, 1]. */
53
- --ease-filter: cubic-bezier(0.16, 1, 0.3, 1);
12
+ /* The state tiers these rules use. The full ladder, and the off-ladder
13
+ subject and set-piece timings, are in the guide. */
14
+ --duration-state: 150ms;
15
+ --duration-route: 260ms;
16
+ --duration-overlay: 300ms;
17
+ --duration-theme: 400ms;
54
18
  }
55
19
 
56
20
  /* Plain-CSS fallback if you're not on Tailwind v4. */
@@ -60,24 +24,20 @@
60
24
 
61
25
  @layer components {
62
26
  /* ── Reveal on scroll ────────────────────────────────────────────────────
63
- CSS animates; JS only sets `data-in` (see useReveal.ts). 8px of travel —
64
- enough to register, small enough that a page of revealing paragraphs
65
- doesn't feel like it's sliding around. */
27
+ CSS animates; JS only sets `data-in` (useReveal.ts, which also holds the
28
+ <noscript> reset this needs). 8px of travel. */
66
29
  .reveal {
67
30
  opacity: 0;
68
31
  transform: translateY(8px);
69
32
  transition: opacity var(--duration-overlay) var(--ease-geist),
70
33
  transform var(--duration-overlay) var(--ease-geist);
71
34
  }
72
- /* An attribute, not a class — see useReveal.ts for why a class can't survive. */
73
35
  .reveal[data-in] {
74
36
  opacity: 1;
75
37
  transform: translateY(0);
76
38
  }
77
39
 
78
- /* Off entirely — the animation IS the effect, so there's nothing to
79
- degrade to. Note it resolves to opacity: 1; the danger with any
80
- reveal-on-scroll is leaving content stuck at opacity: 0. */
40
+ /* Resolves to opacity: 1 — never leave reveal content stuck at 0. */
81
41
  @media (prefers-reduced-motion: reduce) {
82
42
  .reveal {
83
43
  opacity: 1;
@@ -86,8 +46,8 @@
86
46
  }
87
47
  }
88
48
 
89
- /* Print is a real presentation, and scroll-triggered content prints blank
90
- without this. The most-forgotten line in the file. */
49
+ /* Scroll-triggered content prints blank without this. The one copy of the
50
+ print reset; layout-tokens.css's print block relies on it. */
91
51
  @media print {
92
52
  .reveal {
93
53
  opacity: 1;
@@ -95,29 +55,9 @@
95
55
  }
96
56
  }
97
57
 
98
- /* THE THIRD GUARD IS NOT CSS. A feature check can't catch "JavaScript
99
- never ran at all": the rules above ship, `.reveal` applies, and nothing
100
- arrives to add `.in`. The reader gets a correct document they cannot
101
- see — a blank page rather than a plain one. Put this in the head:
102
-
103
- <noscript>
104
- <style>.reveal { opacity: 1 !important; transform: none !important; }</style>
105
- </noscript>
106
-
107
- The general rule: any CSS that hides content pending JavaScript needs a
108
- <noscript> reset. */
109
-
110
58
  /* ── Route transition ───────────────────────────────────────────────────
111
- Opacity only — no slide, no scale. Directional motion implies a spatial
112
- relationship between routes; in a flat site map there isn't one.
113
-
114
- 260ms rather than 300: a page transition sits directly between "I
115
- clicked" and "I can read," and that latency reads as the site being
116
- slow. 260 is the shortest crossfade that isn't mistaken for a flicker.
117
-
118
- `both` holds the from-state before the animation starts, preventing a
119
- one-frame flash of opaque content. Key the wrapper on the pathname to
120
- replay it on each navigation. */
59
+ Opacity only, 260ms; `both` holds the from-state so there's no one-frame
60
+ flash. Key the wrapper on the pathname (glass/motion/easing-and-duration.md). */
121
61
  .route-fade {
122
62
  animation: routeFade var(--duration-route) var(--ease-geist) both;
123
63
  }
@@ -130,21 +70,10 @@
130
70
  }
131
71
 
132
72
  /* ── Theme crossfade ────────────────────────────────────────────────────
133
- Added by the theme setter for ~440ms on an explicit toggle only, then
134
- removed. Left in permanently it would make EVERY hover state on the site
135
- 400ms and sluggish — the !important is what lets it beat each
136
- component's own duration-150 for those 440ms, and the removal is what
137
- stops it poisoning everything else.
138
-
139
- 400ms because this is the largest possible change: every surface, text
140
- colour, border, icon fill and shadow simultaneously.
141
-
142
- Plain `ease`, not the house curve — overshoot on a position is momentum;
143
- overshoot on a colour interpolation means the background travels past
144
- its target and comes back, which reads as a wobble in the light.
145
-
146
- The whole rule sits inside no-preference, so it's opt-in. Reduced-motion
147
- users still get the theme change, just instantly. */
73
+ setThemeChoice (glass/color/assets/theme.ts) adds the class on an
74
+ explicit toggle for ~440ms, then removes it; the !important beats every
75
+ component's own duration for that window only. Plain `ease`, and opt-in
76
+ under no-preference. Why: glass/motion/easing-and-duration.md. */
148
77
  @media (prefers-reduced-motion: no-preference) {
149
78
  :root.theme-transition,
150
79
  :root.theme-transition *,
@@ -159,18 +88,5 @@
159
88
  }
160
89
  }
161
90
 
162
- /* ── Skip link ──────────────────────────────────────────────────────────
163
- Parked off-screen with a transform (not display:none, which would take
164
- it out of the tab order) and slid in on focus at the state tier. */
165
- .skip {
166
- position: absolute;
167
- left: 16px;
168
- top: 16px;
169
- z-index: 100;
170
- transform: translateY(-200%);
171
- transition: transform var(--duration-state) var(--ease-geist);
172
- }
173
- .skip:focus {
174
- transform: translateY(0);
175
- }
91
+ /* The skip link lives in glass/layout/assets/layout-tokens.css. */
176
92
  }
@@ -33,12 +33,7 @@ The gap between 150 and 200 maps to *whether other things move too*:
33
33
 
34
34
  ## Why route transitions are 260ms
35
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.
36
+ The rule is `.route-fade` in `glass/motion/assets/motion-tokens.css`. 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
37
 
43
38
  Keying the wrapper on the pathname replays the CSS animation on every navigation:
44
39
 
@@ -50,33 +45,9 @@ Keying the wrapper on the pathname replays the CSS animation on every navigation
50
45
 
51
46
  ## Why the theme crossfade breaks both rules
52
47
 
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
48
  **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
49
 
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.
50
+ The `.theme-transition` rule is in `glass/motion/assets/motion-tokens.css`; `setThemeChoice` in `glass/color/assets/theme.ts` adds it only on an explicit toggle and removes it after ~440ms, the duration plus a 40ms margin, so its `!important` beats every component's own `transition-colors duration-150` for that window only. Why it's scoped this way is in `domains/motion/patterns.md`, theme cross-fade.
80
51
 
81
52
  ## The overshoot in the house curve
82
53
 
@@ -38,7 +38,7 @@ Glass uses two of law 4's classes:
38
38
 
39
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
40
 
41
- - **Opacity-only micro-transitions**: `ease-out` at 100ms.
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
42
  - **The theme crossfade**: plain `ease`.
43
43
  - **Anything spring-driven**: no bezier at all.
44
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.
@@ -116,11 +116,7 @@ The details that keep it from flickering are in `domains/motion/patterns.md`, th
116
116
 
117
117
  ### When the DOM element isn't enough
118
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
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
124
120
 
125
121
  ## 7. Reduced motion
126
122
 
@@ -152,34 +148,11 @@ One passive listener and one rAF per behaviour (law 12; the shape is in `domains
152
148
 
153
149
  ## 9. Reveal on scroll
154
150
 
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
- ```
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`
179
152
 
180
153
  ## 10. Performance: `m` not `motion`
181
154
 
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).
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).
183
156
 
184
157
  ## 11. Hover intent
185
158
 
@@ -204,11 +177,7 @@ active:scale-[0.92] /* 8% — felt, not seen */
204
177
 
205
178
  On `pointerdown` (law 3).
206
179
 
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`
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
212
181
 
213
182
  ## 13. Fill means chosen
214
183
 
@@ -223,7 +192,7 @@ export const detailFade = (on: boolean) =>
223
192
  `transition-opacity duration-200 ease-out motion-reduce:transition-none ${on ? "opacity-0" : "opacity-100"}`;
224
193
  ```
225
194
 
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.
195
+ **200ms `ease-out`, not the house curve** (§2). The knockout mask for interior lines is in `domains/motion/patterns.md`, fill means chosen.
227
196
 
228
197
  **How the marks that can't fill signal instead:**
229
198
 
@@ -235,7 +204,7 @@ export const detailFade = (on: boolean) =>
235
204
 
236
205
  ## 14. Set pieces play once
237
206
 
238
- Two set pieces, each once a session (law 15): the opening screen, and a summary panel's first reveal.
207
+ Two set pieces, each once a session (law 15): the opening screen, and a summary panel's first reveal. The first open runs each line at 340ms, its children staggered 90ms after a 60ms delay; every later open runs 160ms with no stagger, and reduced motion runs none.
239
208
 
240
209
  ```ts
241
210
  // Once a session, per item. The theatre is a welcome; a re-run is a delay.
@@ -265,8 +234,8 @@ Collapsibles animate height and stay mounted (law 11), so the content stays in t
265
234
 
266
235
  ## Assets
267
236
 
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.
237
+ - `glass/motion/assets/motion-tokens.css` — easing curve, state durations, `.reveal`, route fade, theme crossfade.
238
+ - `glass/motion/assets/motion-presets.ts` — the springs, the close and the icon-fill fades.
270
239
  - `glass/motion/assets/DropMenu.tsx` — the squash-and-stretch menu.
271
240
  - `glass/motion/assets/useScrollTick.ts` — rAF-throttled scroll with hysteresis.
272
241
  - `glass/motion/assets/useReveal.ts` — the IntersectionObserver hook and its guards.
@@ -277,7 +246,7 @@ The domain checklist first; then glass's values:
277
246
 
278
247
  - [ ] Every duration on the ladder: no 250ms, 500ms or 1s.
279
248
  - [ ] 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.
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.
281
250
  - [ ] Menus open on the `GROW`/`CROSS` pair and close on `CLOSE`.
282
251
  - [ ] Hover timed by area: a link 100ms, a card-sized wash 200ms.
283
252
  - [ ] No translate on hover, bar the folder's rising cards.
@@ -60,13 +60,7 @@ One variable turns a blur-crossfade into a plain crossfade; `blur(0px)` → `blu
60
60
 
61
61
  ## In CSS
62
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
- ```
63
+ The `.reveal` reset, the route fade's `animation: none` and the theme crossfade's `no-preference` opt-in are all in `glass/motion/assets/motion-tokens.css`.
70
64
 
71
65
  ### Tailwind's `motion-reduce:` variant
72
66
 
@@ -18,40 +18,11 @@ const ANCHOR = "scroll-mt-28"; // 112px — matched to READING_LINE = 120
18
18
 
19
19
  Every heading carries `scroll-mt-28`, so a jump lands it 112px below the viewport top, just above the 120px line.
20
20
 
21
- ### Deep links without the router
21
+ Deep links scroll with `scrollIntoView({ behavior: reduce ? "auto" : "smooth" })` and set the hash with `history.replaceState` (`domains/motion/performance.md`, scroll spies).
22
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
- ```
23
+ ## The dock's collapse
53
24
 
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.
25
+ The dock's icon row collapses on `grid-template-columns` `1fr` ↔ `0fr`, 300ms on the house curve, with `min-w-0 overflow-hidden` on the child (the technique: `domains/motion/performance.md`, what to animate). The menu's wrapper/surface split is §3 of `glass/depth/guide.md`.
55
26
 
56
27
  ## Timers
57
28
 
@@ -114,7 +114,7 @@ export type Rect = { x: number; y: number; width: number; height: number };
114
114
  export function lerpRect(a: Rect, b: Rect, t: number): Rect { /* … */ }
115
115
  ```
116
116
 
117
- The rules are law 13 and `domains/motion/performance.md`, escalating to the GPU. Here the wall stays DOM and the open photo is `alt=""`, its labels in the strip beside it.
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
118
 
119
119
  **Failing returns `null`:**
120
120