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,210 @@
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.
14
+ */
15
+
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. */
22
+ export const SLIDE = {
23
+ type: "spring",
24
+ bounce: 0.2,
25
+ duration: 0.4,
26
+ opacity: { duration: 0.16, ease: "easeOut" },
27
+ } as const;
28
+
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. */
34
+ export const LIFT = { type: "spring", bounce: 0.28, duration: 0.5 } as const;
35
+
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. */
51
+ export const GROW = { type: "spring", stiffness: 620, damping: 19, mass: 0.85 } as const;
52
+ export const CROSS = { type: "spring", stiffness: 380, damping: 30 } as const;
53
+
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. */
61
+ export const CLOSE = { duration: 0.13, ease: "easeIn" } as const;
62
+
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. */
200
+ export const fillFade = (on: boolean) =>
201
+ `transition-opacity duration-200 ease-out motion-reduce:transition-none ${
202
+ on ? "opacity-100" : "opacity-0"
203
+ }`;
204
+
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. */
207
+ export const detailFade = (on: boolean) =>
208
+ `transition-opacity duration-200 ease-out motion-reduce:transition-none ${
209
+ on ? "opacity-0" : "opacity-100"
210
+ }`;
@@ -0,0 +1,176 @@
1
+ /* ─────────────────────────────────────────────────────────────────────────
2
+ Interface motion — tokens and CSS-driven effects
3
+ Drop-in for Tailwind v4 (@theme) or plain CSS (the :root fallback below).
4
+ ───────────────────────────────────────────────────────────────────────── */
5
+
6
+ @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. */
15
+ --ease-geist: cubic-bezier(0.175, 0.885, 0.32, 1.1);
16
+
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);
54
+ }
55
+
56
+ /* Plain-CSS fallback if you're not on Tailwind v4. */
57
+ :root {
58
+ --ease-geist: cubic-bezier(0.175, 0.885, 0.32, 1.1);
59
+ }
60
+
61
+ @layer components {
62
+ /* ── 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. */
66
+ .reveal {
67
+ opacity: 0;
68
+ transform: translateY(8px);
69
+ transition: opacity var(--duration-overlay) var(--ease-geist),
70
+ transform var(--duration-overlay) var(--ease-geist);
71
+ }
72
+ /* An attribute, not a class — see useReveal.ts for why a class can't survive. */
73
+ .reveal[data-in] {
74
+ opacity: 1;
75
+ transform: translateY(0);
76
+ }
77
+
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. */
81
+ @media (prefers-reduced-motion: reduce) {
82
+ .reveal {
83
+ opacity: 1;
84
+ transform: none;
85
+ transition: none;
86
+ }
87
+ }
88
+
89
+ /* Print is a real presentation, and scroll-triggered content prints blank
90
+ without this. The most-forgotten line in the file. */
91
+ @media print {
92
+ .reveal {
93
+ opacity: 1;
94
+ transform: none;
95
+ }
96
+ }
97
+
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
+ /* ── 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. */
121
+ .route-fade {
122
+ animation: routeFade var(--duration-route) var(--ease-geist) both;
123
+ }
124
+ @keyframes routeFade {
125
+ from { opacity: 0; }
126
+ to { opacity: 1; }
127
+ }
128
+ @media (prefers-reduced-motion: reduce) {
129
+ .route-fade { animation: none; }
130
+ }
131
+
132
+ /* ── 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. */
148
+ @media (prefers-reduced-motion: no-preference) {
149
+ :root.theme-transition,
150
+ :root.theme-transition *,
151
+ :root.theme-transition *::before,
152
+ :root.theme-transition *::after {
153
+ transition: background-color var(--duration-theme) ease,
154
+ color var(--duration-theme) ease,
155
+ border-color var(--duration-theme) ease,
156
+ fill var(--duration-theme) ease,
157
+ stroke var(--duration-theme) ease,
158
+ box-shadow var(--duration-theme) ease !important;
159
+ }
160
+ }
161
+
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
+ }
176
+ }
@@ -0,0 +1,65 @@
1
+ import { useEffect } from "react";
2
+
3
+ /**
4
+ * Marks any `.reveal` element as arrived, with `data-in`, when it scrolls into
5
+ * view. The CSS does all the animating (see motion-tokens.css); this only sets
6
+ * an attribute.
7
+ *
8
+ * An attribute rather than a class, and that is not a style choice. `class` is a
9
+ * prop React owns: the moment any `.reveal` element gets a className that depends
10
+ * on state, React rewrites the whole attribute from its own value and a class
11
+ * added out here is gone. The element then has `.reveal` with nothing to say it
12
+ * arrived, so it fades to `opacity: 0` — and the observer has already unobserved
13
+ * it, so it never comes back. React doesn't render `data-in`, so it leaves it be.
14
+ *
15
+ * Pass a value that changes on navigation (e.g. the pathname) so the observer
16
+ * re-attaches to the new page's `.reveal` elements after a route change —
17
+ * without it, a client-side navigation leaves the incoming page's content
18
+ * permanently at opacity: 0.
19
+ *
20
+ * THE THIRD GUARD IS NOT IN THIS FILE, and it's the one people miss. A feature
21
+ * check can't catch "JavaScript never ran at all": the CSS ships, `.reveal`
22
+ * applies, and nothing arrives to set `data-in`. The reader gets a correct document
23
+ * they cannot see. Put this in the document head, beside where you load the app:
24
+ *
25
+ * <noscript>
26
+ * <style>.reveal { opacity: 1 !important; transform: none !important; }</style>
27
+ * </noscript>
28
+ *
29
+ * The general rule: any CSS that hides content pending JavaScript needs a
30
+ * <noscript> reset. This is a correctness bug, not a nicety — the failure mode
31
+ * is a blank page rather than a plain one.
32
+ */
33
+ export function useReveal(dep?: unknown) {
34
+ useEffect(() => {
35
+ // No IntersectionObserver → show everything immediately. Never leave
36
+ // content invisible behind a feature check.
37
+ if (typeof IntersectionObserver === "undefined") {
38
+ document.querySelectorAll(".reveal").forEach((el) => el.setAttribute("data-in", ""));
39
+ return;
40
+ }
41
+
42
+ const io = new IntersectionObserver(
43
+ (entries) => {
44
+ for (const entry of entries) {
45
+ if (entry.isIntersecting) {
46
+ entry.target.setAttribute("data-in", "");
47
+ // A reveal is one-shot. Re-animating on scroll-back is nauseating,
48
+ // and unobserving keeps the observer's element list shrinking.
49
+ io.unobserve(entry.target);
50
+ }
51
+ }
52
+ },
53
+ // No fractional threshold, deliberately. An element's intersection ratio
54
+ // can never exceed the viewport's height divided by its own, so anything
55
+ // more than ten screens tall can't reach 0.1 however far you scroll, and
56
+ // it sits at opacity 0 for the whole page. The bottom margin does the job
57
+ // the threshold was doing: reveal once the element is 40px inside the
58
+ // viewport, at any height.
59
+ { threshold: 0, rootMargin: "0px 0px -40px 0px" }
60
+ );
61
+
62
+ document.querySelectorAll(".reveal").forEach((el) => io.observe(el));
63
+ return () => io.disconnect();
64
+ }, [dep]);
65
+ }
@@ -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
+ }