trykeyline 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/.claude-plugin/marketplace.json +18 -0
  2. package/LICENSE +37 -0
  3. package/README.md +117 -2
  4. package/bin/keyline.mjs +280 -0
  5. package/package.json +38 -4
  6. package/plugins/keyline/.claude-plugin/plugin.json +14 -0
  7. package/plugins/keyline/skills/keyline/SKILL.md +57 -0
  8. package/plugins/keyline/skills/keyline/commands/audit.md +25 -0
  9. package/plugins/keyline/skills/keyline/commands/color.md +7 -0
  10. package/plugins/keyline/skills/keyline/commands/critique.md +43 -0
  11. package/plugins/keyline/skills/keyline/commands/depth.md +7 -0
  12. package/plugins/keyline/skills/keyline/commands/init.md +15 -0
  13. package/plugins/keyline/skills/keyline/commands/layout.md +7 -0
  14. package/plugins/keyline/skills/keyline/commands/motion.md +7 -0
  15. package/plugins/keyline/skills/keyline/commands/patterns.md +7 -0
  16. package/plugins/keyline/skills/keyline/commands/polish.md +19 -0
  17. package/plugins/keyline/skills/keyline/commands/refine.md +29 -0
  18. package/plugins/keyline/skills/keyline/commands/sync.md +17 -0
  19. package/plugins/keyline/skills/keyline/commands/type.md +7 -0
  20. package/plugins/keyline/skills/keyline/domains/color/deriving-a-ramp.md +144 -0
  21. package/plugins/keyline/skills/keyline/domains/color/guide.md +243 -0
  22. package/plugins/keyline/skills/keyline/domains/color/status-and-exceptions.md +174 -0
  23. package/plugins/keyline/skills/keyline/domains/color/theming-mechanics.md +121 -0
  24. package/plugins/keyline/skills/keyline/domains/depth/elevation-recipes.md +191 -0
  25. package/plugins/keyline/skills/keyline/domains/depth/fallbacks-and-performance.md +132 -0
  26. package/plugins/keyline/skills/keyline/domains/depth/guide.md +255 -0
  27. package/plugins/keyline/skills/keyline/domains/depth/translucent-materials.md +159 -0
  28. package/plugins/keyline/skills/keyline/domains/layout/forks-gating-and-disclosures.md +201 -0
  29. package/plugins/keyline/skills/keyline/domains/layout/guide.md +284 -0
  30. package/plugins/keyline/skills/keyline/domains/layout/measure-and-rhythm.md +193 -0
  31. package/plugins/keyline/skills/keyline/domains/layout/radius-lines-and-layers.md +122 -0
  32. package/plugins/keyline/skills/keyline/domains/motion/guards.md +162 -0
  33. package/plugins/keyline/skills/keyline/domains/motion/guide.md +234 -0
  34. package/plugins/keyline/skills/keyline/domains/motion/ladder-and-curves.md +139 -0
  35. package/plugins/keyline/skills/keyline/domains/motion/patterns.md +196 -0
  36. package/plugins/keyline/skills/keyline/domains/motion/performance.md +121 -0
  37. package/plugins/keyline/skills/keyline/domains/patterns/actions-and-forms.md +204 -0
  38. package/plugins/keyline/skills/keyline/domains/patterns/agents-and-live-states.md +111 -0
  39. package/plugins/keyline/skills/keyline/domains/patterns/collections-and-dashboards.md +131 -0
  40. package/plugins/keyline/skills/keyline/domains/patterns/copy.md +123 -0
  41. package/plugins/keyline/skills/keyline/domains/patterns/guide.md +216 -0
  42. package/plugins/keyline/skills/keyline/domains/patterns/iteration.md +78 -0
  43. package/plugins/keyline/skills/keyline/domains/type/display-tier.md +108 -0
  44. package/plugins/keyline/skills/keyline/domains/type/guide.md +251 -0
  45. package/plugins/keyline/skills/keyline/domains/type/ladders.md +169 -0
  46. package/plugins/keyline/skills/keyline/domains/type/numerals-and-loading.md +107 -0
  47. package/plugins/keyline/skills/keyline/domains/type/prose-and-emphasis.md +125 -0
  48. package/plugins/keyline/skills/keyline/domains/type/type-audit.md +90 -0
  49. package/plugins/keyline/skills/keyline/glass/color/assets/color-tokens.css +319 -0
  50. package/plugins/keyline/skills/keyline/glass/color/assets/theme.ts +119 -0
  51. package/plugins/keyline/skills/keyline/glass/color/guide.md +222 -0
  52. package/plugins/keyline/skills/keyline/glass/color/intent-ramp.md +136 -0
  53. package/plugins/keyline/skills/keyline/glass/color/theming.md +112 -0
  54. package/plugins/keyline/skills/keyline/glass/depth/assets/GlassPanel.tsx +171 -0
  55. package/plugins/keyline/skills/keyline/glass/depth/assets/glass.css +322 -0
  56. package/plugins/keyline/skills/keyline/glass/depth/blur-and-tint.md +50 -0
  57. package/plugins/keyline/skills/keyline/glass/depth/depth-cues.md +87 -0
  58. package/plugins/keyline/skills/keyline/glass/depth/guide.md +216 -0
  59. package/plugins/keyline/skills/keyline/glass/depth/performance-and-fallbacks.md +37 -0
  60. package/plugins/keyline/skills/keyline/glass/layout/adaptive-presentations.md +231 -0
  61. package/plugins/keyline/skills/keyline/glass/layout/assets/SectionNav.tsx +244 -0
  62. package/plugins/keyline/skills/keyline/glass/layout/assets/layout-tokens.css +199 -0
  63. package/plugins/keyline/skills/keyline/glass/layout/guide.md +258 -0
  64. package/plugins/keyline/skills/keyline/glass/layout/measure-and-rhythm.md +134 -0
  65. package/plugins/keyline/skills/keyline/glass/layout/radius-and-elevation.md +102 -0
  66. package/plugins/keyline/skills/keyline/glass/motion/assets/DropMenu.tsx +106 -0
  67. package/plugins/keyline/skills/keyline/glass/motion/assets/motion-presets.ts +210 -0
  68. package/plugins/keyline/skills/keyline/glass/motion/assets/motion-tokens.css +176 -0
  69. package/plugins/keyline/skills/keyline/glass/motion/assets/useReveal.ts +65 -0
  70. package/plugins/keyline/skills/keyline/glass/motion/assets/useScrollTick.ts +183 -0
  71. package/plugins/keyline/skills/keyline/glass/motion/easing-and-duration.md +108 -0
  72. package/plugins/keyline/skills/keyline/glass/motion/guide.md +286 -0
  73. package/plugins/keyline/skills/keyline/glass/motion/reduced-motion.md +81 -0
  74. package/plugins/keyline/skills/keyline/glass/motion/scroll-performance.md +58 -0
  75. package/plugins/keyline/skills/keyline/glass/motion/shared-element.md +161 -0
  76. package/plugins/keyline/skills/keyline/glass/motion/sound.md +192 -0
  77. package/plugins/keyline/skills/keyline/glass/motion/springs.md +51 -0
  78. package/plugins/keyline/skills/keyline/glass/type/assets/prose.tsx +268 -0
  79. package/plugins/keyline/skills/keyline/glass/type/assets/type-tokens.css +414 -0
  80. package/plugins/keyline/skills/keyline/glass/type/guide.md +222 -0
  81. package/plugins/keyline/skills/keyline/glass/type/long-form-prose.md +151 -0
  82. package/plugins/keyline/skills/keyline/glass/type/scale.md +69 -0
  83. package/plugins/keyline/skills/keyline/glass/type/weight-and-tracking.md +61 -0
  84. package/plugins/keyline/skills/keyline/scripts/check/css.mjs +112 -0
  85. package/plugins/keyline/skills/keyline/scripts/check/design.mjs +67 -0
  86. package/plugins/keyline/skills/keyline/scripts/check/files.mjs +44 -0
  87. package/plugins/keyline/skills/keyline/scripts/check/markup.mjs +149 -0
  88. package/plugins/keyline/skills/keyline/scripts/check/rules.mjs +584 -0
  89. package/plugins/keyline/skills/keyline/scripts/check.mjs +294 -0
  90. package/plugins/keyline/skills/keyline/scripts/contrast.mjs +153 -0
  91. package/plugins/keyline/skills/keyline/scripts/scan.mjs +340 -0
  92. package/plugins/keyline/skills/keyline/system/design-md.md +136 -0
  93. package/plugins/keyline/skills/keyline/system/detection.md +53 -0
  94. package/plugins/keyline/skills/keyline/system/guide.md +101 -0
  95. package/plugins/keyline/skills/keyline/system/presets.md +58 -0
  96. package/plugins/keyline/skills/keyline/system/roles.md +150 -0
@@ -0,0 +1,102 @@
1
+ # Radius, corners, and stacking
2
+
3
+ Glass's values for layout laws 5–7; the rules themselves, and the nesting worked through in general, are in `domains/layout/radius-lines-and-layers.md`.
4
+
5
+ ## The nesting rule, through the site
6
+
7
+ Layout law 5, in the reference implementation's dock:
8
+
9
+ ```
10
+ Dock: rounded-2xl (16px) p-1 (4px padding)
11
+ Icon button: rounded-[12px] (12px) 16 − 4 = 12 ✓
12
+ Avatar: rounded-[10px] (10px) inset ~2px 12 − 2 = 10 ✓
13
+ ```
14
+
15
+ Three levels, each derived. The avatar is `h-9 w-9` inside an `h-10 w-10` button — a 2px inset all round, so 10px.
16
+
17
+ The mobile sheet snaps to the ladder:
18
+
19
+ ```
20
+ Sheet: rounded-2xl (16px) p-1.5 (6px)
21
+ Row: rounded-xl (12px) 16 − 6 = 10, rounded up to 12
22
+ ```
23
+
24
+ ## The ladder
25
+
26
+ | Radius | Class | For |
27
+ |---|---|---|
28
+ | full | `rounded-full` | Pills, tags, dots, markers |
29
+ | 2px | — | The focus ring's own radius |
30
+ | 5px | `rounded-[5px]` | A lettermark box (tiny, so a small radius) |
31
+ | 6px | `rounded-md` | Tooltips, the skip link |
32
+ | 9px | `rounded-[9px]` | Small cards in a stack |
33
+ | 10px | `rounded-[10px]` | Avatar inside a button; a list highlight bar |
34
+ | 12px | `rounded-xl` / `rounded-[12px]` | Cards, hover panels, disclosures, icon buttons |
35
+ | 16px | `rounded-2xl` / `rounded-[16px]` | Outer containers — a dock, a sheet, a pocket |
36
+
37
+ Radius scales with size, not importance (layout law 5): a 40px icon button gets 12px; a 300px panel gets 16px.
38
+
39
+ ## Continuous corners
40
+
41
+ The rule is in §4 of the guide: `superellipse(1.5)` on every element and pseudo-element. **1.5 rather than 2** because a full squircle is noticeable — at small sizes it looks like a slightly swollen rectangle. Halfway reads as a well-drawn corner rather than an effect.
42
+
43
+ ## Hairline weights
44
+
45
+ | Weight | Class | For |
46
+ |---|---|---|
47
+ | 0.5px | `border-[0.5px]`, `h-[0.5px]` | Chrome borders, dividers, glass edges |
48
+ | 1px | `border`, `ring-1`, `h-px` | Card borders, TOC markers |
49
+ | 2px | `border-l-2` | Quote rules, focus rings |
50
+
51
+ By job (layout law 6): two groups of icons within one dock, 0.5px; where a card ends, 1px; a blockquote's left rule, 2px.
52
+
53
+ Vertical dividers use `w-[0.5px]` with an explicit height and `self-center`:
54
+
55
+ ```tsx
56
+ <span aria-hidden className="mx-0.5 h-6 w-[0.5px] self-center bg-gray-alpha-300" />
57
+ ```
58
+
59
+ A 24px line in a 40px-tall row, one step lighter than the dock's `alpha-400` edge, because it's an internal articulation, not a boundary.
60
+
61
+ **The box form is a gap the reference implementation still has.** `h-[0.5px]` and `w-[0.5px]` with a background are boxes, which layout law 6 says a 1x screen can drop. Draw every hairline as a border instead: `border-t-[0.5px]` on the element, or an empty span carrying `border-l-[0.5px]` for a vertical divider, the same weight and colour by the more reliable route.
62
+
63
+ ## Elevation tiers
64
+
65
+ Glass's shadow tiers — chrome, pocket, panel, card, transient — are depth's: `glass/depth/depth-cues.md`.
66
+
67
+ ## The z-index ladder
68
+
69
+ The ladder is in §8 of the guide. **The 40/50 pair** in code:
70
+
71
+ ```tsx
72
+ {open && (
73
+ <div aria-hidden onClick={() => setOpen(false)}
74
+ className="pointer-events-auto fixed inset-0 z-40 sm:hidden" />
75
+ )}
76
+ <div className="pointer-events-auto relative z-50 sm:hidden">
77
+ {/* the menu */}
78
+ </div>
79
+ ```
80
+
81
+ **`z-index: 10` for a hover lift:**
82
+
83
+ ```tsx
84
+ <Link className="group relative block transition-[z-index] hover:z-10 focus-within:z-10">
85
+ ```
86
+
87
+ Folder cards rising out of a pocket would otherwise be clipped by the next folder; `focus-within:z-10` gives keyboard focus the same lift.
88
+
89
+ ## Stacking-context techniques
90
+
91
+ The general techniques are in `domains/layout/radius-lines-and-layers.md`. Glass's cases:
92
+
93
+ - **`isolate` plus negative z** scopes the dock's sliding highlight behind its icons but in front of the glass: the code is in `glass/depth/depth-cues.md`.
94
+ - **The nav strip** passes clicks through and carries no transform (§8 of the guide); a transform would trap the mobile sheet's full-bleed `w-screen` overlay inside the strip.
95
+
96
+ ### `transform-origin` for menus
97
+
98
+ ```tsx
99
+ const anchor = placement === "up" ? "origin-bottom" : "origin-top";
100
+ ```
101
+
102
+ A menu scales **from its trigger's edge**, or it seems to detach and drift; off one side, use the corner: `origin-top-left`.
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The squash-and-stretch floating menu — the shared open/close motion for
3
+ * every menu in the system (a phone nav sheet, a settings drop-up, a socials
4
+ * pill). All of them grow out of a trigger sitting against one edge, so all
5
+ * of them stretch from that edge.
6
+ *
7
+ * The effect comes from letting two springs DISAGREE. See motion-presets.ts
8
+ * for the numbers and the reasoning.
9
+ *
10
+ * Two structural decisions worth keeping:
11
+ *
12
+ * 1. The panel mounts only while open, so a closed menu is out of the tab
13
+ * order and off the paint. That matters here because the panel carries
14
+ * backdrop-filter, which is expensive even when invisible.
15
+ *
16
+ * 2. The wrapper is ALWAYS present, so a trigger's aria-controls still
17
+ * resolves to a real element when the menu is closed.
18
+ *
19
+ * Note this component animates a bare m.div and expects its child to carry
20
+ * the glass. A transform on an ancestor weakens backdrop-filter in its
21
+ * subtree — the wrapper scales, the child blurs, and they must be different
22
+ * elements.
23
+ */
24
+
25
+ import { AnimatePresence, LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
26
+ import type { CSSProperties, ReactNode } from "react";
27
+ import { GROW, CROSS, CLOSE } from "./motion-presets";
28
+
29
+ export type DropPlacement = "up" | "down";
30
+
31
+ export function DropMenu({
32
+ open,
33
+ placement = "up",
34
+ origin,
35
+ id,
36
+ className = "",
37
+ style,
38
+ onMouseEnter,
39
+ onMouseLeave,
40
+ children,
41
+ }: {
42
+ open: boolean;
43
+ placement?: DropPlacement;
44
+ /** transform-origin class — defaults to the edge the menu grows from. */
45
+ origin?: string;
46
+ id?: string;
47
+ className?: string;
48
+ style?: CSSProperties;
49
+ onMouseEnter?: () => void;
50
+ onMouseLeave?: () => void;
51
+ children: ReactNode;
52
+ }) {
53
+ const reduce = useReducedMotion();
54
+
55
+ // Start nudged back toward the trigger, so it reads as coming out of it.
56
+ const away = placement === "up" ? 4 : -4;
57
+
58
+ // Scale from the anchored edge, or the caller's corner for a menu that
59
+ // hangs off one side of its trigger (e.g. origin-top-left).
60
+ const anchor = origin ?? (placement === "up" ? "origin-bottom" : "origin-top");
61
+
62
+ return (
63
+ <div
64
+ id={id}
65
+ style={style}
66
+ onMouseEnter={onMouseEnter}
67
+ onMouseLeave={onMouseLeave}
68
+ // Empty and inert when closed, so it can't sit over the page as a trap.
69
+ className={`${className} ${open ? "" : "pointer-events-none"}`}
70
+ >
71
+ <LazyMotion features={domAnimation} strict>
72
+ <AnimatePresence>
73
+ {open && (
74
+ <m.div
75
+ className={anchor}
76
+ /* Open squashes from a real offset; exit only travels part of
77
+ the way back, because it's disappearing anyway. */
78
+ initial={
79
+ reduce ? { opacity: 0 } : { opacity: 0, scaleY: 0.82, scaleX: 0.94, y: away }
80
+ }
81
+ animate={reduce ? { opacity: 1 } : { opacity: 1, scaleY: 1, scaleX: 1, y: 0 }}
82
+ exit={
83
+ reduce
84
+ ? { opacity: 0, transition: CLOSE }
85
+ : { opacity: 0, scaleY: 0.9, scaleX: 0.97, y: away, transition: CLOSE }
86
+ }
87
+ transition={
88
+ reduce
89
+ ? { duration: 0.15 }
90
+ : {
91
+ scaleY: GROW, // the grow axis overshoots
92
+ y: GROW, // travel rides the same spring, or the
93
+ // anchored edge detaches mid-overshoot
94
+ scaleX: CROSS, // the cross axis arrives late
95
+ opacity: { duration: 0.14, ease: "easeOut" },
96
+ }
97
+ }
98
+ >
99
+ {children}
100
+ </m.div>
101
+ )}
102
+ </AnimatePresence>
103
+ </LazyMotion>
104
+ </div>
105
+ );
106
+ }
@@ -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
+ }