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.
- package/.claude-plugin/marketplace.json +18 -0
- package/LICENSE +21 -0
- package/README.md +117 -2
- package/bin/keyline.mjs +280 -0
- package/package.json +38 -4
- package/plugins/keyline/.claude-plugin/plugin.json +14 -0
- package/plugins/keyline/plugin.json +22 -0
- package/plugins/keyline/skills/keyline/SKILL.md +47 -0
- package/plugins/keyline/skills/keyline/commands/audit.md +30 -0
- package/plugins/keyline/skills/keyline/commands/color.md +7 -0
- package/plugins/keyline/skills/keyline/commands/critique.md +20 -0
- package/plugins/keyline/skills/keyline/commands/depth.md +7 -0
- package/plugins/keyline/skills/keyline/commands/init.md +16 -0
- package/plugins/keyline/skills/keyline/commands/layout.md +7 -0
- package/plugins/keyline/skills/keyline/commands/motion.md +7 -0
- package/plugins/keyline/skills/keyline/commands/patterns.md +7 -0
- package/plugins/keyline/skills/keyline/commands/polish.md +19 -0
- package/plugins/keyline/skills/keyline/commands/refine.md +19 -0
- package/plugins/keyline/skills/keyline/commands/sync.md +15 -0
- package/plugins/keyline/skills/keyline/commands/type.md +7 -0
- package/plugins/keyline/skills/keyline/commands/ux-laws.md +26 -0
- package/plugins/keyline/skills/keyline/domains/color/deriving-a-ramp.md +144 -0
- package/plugins/keyline/skills/keyline/domains/color/guide.md +207 -0
- package/plugins/keyline/skills/keyline/domains/color/status-and-exceptions.md +174 -0
- package/plugins/keyline/skills/keyline/domains/color/theming-mechanics.md +121 -0
- package/plugins/keyline/skills/keyline/domains/depth/elevation-recipes.md +185 -0
- package/plugins/keyline/skills/keyline/domains/depth/fallbacks-and-performance.md +132 -0
- package/plugins/keyline/skills/keyline/domains/depth/guide.md +226 -0
- package/plugins/keyline/skills/keyline/domains/depth/translucent-materials.md +159 -0
- package/plugins/keyline/skills/keyline/domains/layout/forks-gating-and-disclosures.md +201 -0
- package/plugins/keyline/skills/keyline/domains/layout/guide.md +259 -0
- package/plugins/keyline/skills/keyline/domains/layout/measure-and-rhythm.md +193 -0
- package/plugins/keyline/skills/keyline/domains/layout/radius-lines-and-layers.md +122 -0
- package/plugins/keyline/skills/keyline/domains/motion/guards.md +162 -0
- package/plugins/keyline/skills/keyline/domains/motion/guide.md +198 -0
- package/plugins/keyline/skills/keyline/domains/motion/ladder-and-curves.md +139 -0
- package/plugins/keyline/skills/keyline/domains/motion/patterns.md +196 -0
- package/plugins/keyline/skills/keyline/domains/motion/performance.md +121 -0
- package/plugins/keyline/skills/keyline/domains/patterns/actions-and-forms.md +204 -0
- package/plugins/keyline/skills/keyline/domains/patterns/agents-and-live-states.md +111 -0
- package/plugins/keyline/skills/keyline/domains/patterns/collections-and-dashboards.md +131 -0
- package/plugins/keyline/skills/keyline/domains/patterns/copy.md +123 -0
- package/plugins/keyline/skills/keyline/domains/patterns/guide.md +174 -0
- package/plugins/keyline/skills/keyline/domains/patterns/iteration.md +70 -0
- package/plugins/keyline/skills/keyline/domains/type/display-tier.md +108 -0
- package/plugins/keyline/skills/keyline/domains/type/guide.md +227 -0
- package/plugins/keyline/skills/keyline/domains/type/ladders.md +169 -0
- package/plugins/keyline/skills/keyline/domains/type/numerals-and-loading.md +107 -0
- package/plugins/keyline/skills/keyline/domains/type/prose-and-emphasis.md +125 -0
- package/plugins/keyline/skills/keyline/domains/type/type-audit.md +87 -0
- package/plugins/keyline/skills/keyline/glass/color/assets/color-tokens.css +299 -0
- package/plugins/keyline/skills/keyline/glass/color/assets/theme.ts +119 -0
- package/plugins/keyline/skills/keyline/glass/color/guide.md +195 -0
- package/plugins/keyline/skills/keyline/glass/color/intent-ramp.md +136 -0
- package/plugins/keyline/skills/keyline/glass/color/theming.md +106 -0
- package/plugins/keyline/skills/keyline/glass/depth/assets/GlassPanel.tsx +171 -0
- package/plugins/keyline/skills/keyline/glass/depth/assets/glass.css +322 -0
- package/plugins/keyline/skills/keyline/glass/depth/blur-and-tint.md +50 -0
- package/plugins/keyline/skills/keyline/glass/depth/depth-cues.md +87 -0
- package/plugins/keyline/skills/keyline/glass/depth/guide.md +147 -0
- package/plugins/keyline/skills/keyline/glass/depth/performance-and-fallbacks.md +37 -0
- package/plugins/keyline/skills/keyline/glass/layout/adaptive-presentations.md +231 -0
- package/plugins/keyline/skills/keyline/glass/layout/assets/SectionNav.tsx +244 -0
- package/plugins/keyline/skills/keyline/glass/layout/assets/layout-tokens.css +201 -0
- package/plugins/keyline/skills/keyline/glass/layout/guide.md +259 -0
- package/plugins/keyline/skills/keyline/glass/layout/measure-and-rhythm.md +134 -0
- package/plugins/keyline/skills/keyline/glass/layout/radius-and-elevation.md +103 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/DropMenu.tsx +106 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/motion-presets.ts +210 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/motion-tokens.css +176 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/useReveal.ts +65 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/useScrollTick.ts +183 -0
- package/plugins/keyline/skills/keyline/glass/motion/easing-and-duration.md +108 -0
- package/plugins/keyline/skills/keyline/glass/motion/guide.md +255 -0
- package/plugins/keyline/skills/keyline/glass/motion/reduced-motion.md +81 -0
- package/plugins/keyline/skills/keyline/glass/motion/scroll-performance.md +58 -0
- package/plugins/keyline/skills/keyline/glass/motion/shared-element.md +161 -0
- package/plugins/keyline/skills/keyline/glass/motion/sound.md +195 -0
- package/plugins/keyline/skills/keyline/glass/motion/springs.md +51 -0
- package/plugins/keyline/skills/keyline/glass/type/assets/prose.tsx +268 -0
- package/plugins/keyline/skills/keyline/glass/type/assets/type-tokens.css +416 -0
- package/plugins/keyline/skills/keyline/glass/type/guide.md +220 -0
- package/plugins/keyline/skills/keyline/glass/type/long-form-prose.md +151 -0
- package/plugins/keyline/skills/keyline/glass/type/scale.md +69 -0
- package/plugins/keyline/skills/keyline/glass/type/weight-and-tracking.md +61 -0
- package/plugins/keyline/skills/keyline/scripts/check/css.mjs +112 -0
- package/plugins/keyline/skills/keyline/scripts/check/design.mjs +67 -0
- package/plugins/keyline/skills/keyline/scripts/check/files.mjs +44 -0
- package/plugins/keyline/skills/keyline/scripts/check/markup.mjs +149 -0
- package/plugins/keyline/skills/keyline/scripts/check/rules.mjs +584 -0
- package/plugins/keyline/skills/keyline/scripts/check.mjs +297 -0
- package/plugins/keyline/skills/keyline/scripts/contrast.mjs +153 -0
- package/plugins/keyline/skills/keyline/scripts/scan.mjs +340 -0
- package/plugins/keyline/skills/keyline/system/design-md.md +136 -0
- package/plugins/keyline/skills/keyline/system/detection.md +53 -0
- package/plugins/keyline/skills/keyline/system/guide.md +97 -0
- package/plugins/keyline/skills/keyline/system/presets.md +58 -0
- 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
|
+
}
|