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,231 @@
1
+ # Adaptive presentations
2
+
3
+ Glass's forks, views and breakpoints, for layout laws 8, 9 and 11; the fork test, capability queries, the touch audit and container breakpoints in general are in `domains/layout/forks-gating-and-disclosures.md`.
4
+
5
+ ## Case 1: the navigation dock
6
+
7
+ ```tsx
8
+ export function BottomNav() {
9
+ return (
10
+ <nav aria-label="Primary"
11
+ className="pointer-events-none fixed inset-x-0 bottom-5 z-50 flex justify-center">
12
+ <MobileDock /> {/* sm:hidden */}
13
+ <DesktopDock /> {/* hidden sm:block */}
14
+ </nav>
15
+ );
16
+ }
17
+ ```
18
+
19
+ **`DesktopDock`** — icon buttons with a shared sliding highlight and tooltip; hovering an arrow opens a settings drop-up. Scrolling down collapses it to the avatar; hovering, scrolling up, or reaching either end of the page expands it.
20
+
21
+ **`MobileDock`** — the avatar and a menu button opening a drop-up sheet: pages as text rows, then socials, sound and theme. Toggling a preference leaves the sheet open; navigating or tapping outside closes it.
22
+
23
+ Almost nothing is shared, because every desktop interaction is hover-driven and touch fires none of them:
24
+
25
+ | Behaviour | Desktop | Mobile |
26
+ |---|---|---|
27
+ | Reveal pages | Always visible, collapse on scroll | Inside a sheet |
28
+ | Reveal settings | Hover an arrow | A row in the sheet |
29
+ | Item feedback | Sliding highlight + tooltip | `active:bg-highlight` |
30
+ | Labels | Tooltip on hover | Text in the row |
31
+ | Collapse on scroll | Yes | No — nothing brings it back |
32
+ | Press feedback | `active:scale-[0.92]` | `active:scale-[0.92]` |
33
+
34
+ What *is* shared is extracted — the destinations array, the icons, the preferences hook:
35
+
36
+ ```tsx
37
+ function useNavPrefs() {
38
+ const [soundOn, setSoundOn] = useState(true);
39
+ const [theme, setTheme] = useState<ThemeChoice>(getThemeChoice);
40
+ // …
41
+ return { soundOn, setSound, theme, pickTheme, sync };
42
+ }
43
+ ```
44
+
45
+ The collapse-on-scroll effect bails out entirely unless `(hover: hover) and (pointer: fine)` matches, so touch never attaches the listener (layout law 8).
46
+
47
+ ### The `sync` subtlety
48
+
49
+ Both docks are always mounted (hidden by CSS), each with its own copy of the preferences, so a change in the desktop settings box before the viewport crossed the breakpoint would leave the mobile sheet showing a stale toggle.
50
+
51
+ ```tsx
52
+ const toggle = () => {
53
+ if (!open) sync(); // re-read the stored values as the sheet opens
54
+ setOpen((o) => !o);
55
+ };
56
+ ```
57
+
58
+ The desktop panel needs no equivalent: it *mounts fresh* on open and reads the values then, whereas the mobile trigger lives in the always-present dock, so its state outlives the panel.
59
+
60
+ ## Case 2: the table of contents
61
+
62
+ Same tree, two containers, shared row rendering.
63
+
64
+ ```tsx
65
+ /** Shared — used by both presentations. */
66
+ function Rows({ nodes, active, parents, onJump }) { … }
67
+
68
+ /** The sticky rail, in the left margin on wide screens. */
69
+ export function SectionNav({ nodes, active, onJump }) {
70
+ return (
71
+ <nav aria-label="On this page">
72
+ <ul className="flex flex-col">
73
+ <Rows … />
74
+ </ul>
75
+ </nav>
76
+ );
77
+ }
78
+
79
+ /** The narrow-screen disclosure — one line above the article. */
80
+ export function SectionNavCompact({ nodes, active, onJump }) {
81
+ const [open, setOpen] = useState(false);
82
+ const jump = (id: string) => { setOpen(false); onJump(id); };
83
+ return (
84
+ <details open={open} onToggle={(e) => setOpen(e.currentTarget.open)}
85
+ className="group rounded-xl border border-gray-alpha-300">
86
+ <summary className="… [&::-webkit-details-marker]:hidden">
87
+ On this page
88
+ <span className="… group-open:rotate-180 motion-reduce:transition-none">
89
+ <ChevronDownIcon />
90
+ </span>
91
+ </summary>
92
+ <ul className="… border-t border-gray-alpha-300">
93
+ <Rows … onJump={jump} />
94
+ </ul>
95
+ </details>
96
+ );
97
+ }
98
+ ```
99
+
100
+ Both are rendered; CSS picks one:
101
+
102
+ ```tsx
103
+ <div className="absolute inset-y-0 left-8 hidden w-[196px] xl:block">
104
+ <div className="sticky top-28 max-h-[calc(100dvh-11rem)] overflow-y-auto">
105
+ <SectionNav {...nav} />
106
+ </div>
107
+ </div>
108
+
109
+ <div className="reveal mt-8 xl:hidden">
110
+ <SectionNavCompact {...nav} />
111
+ </div>
112
+ ```
113
+
114
+ The props are memoised so both get a stable object:
115
+
116
+ ```tsx
117
+ const nav = useMemo(() => ({ nodes: toc, active, onJump: jump }), [active, jump]);
118
+ ```
119
+
120
+ The compact version is a `<details>` (layout law 11); `open` is mirrored into React state only so it can close itself after a jump. `[&::-webkit-details-marker]:hidden` plus `list-none` removes the default triangle so the chevron can be styled, and `group-open:rotate-180` reads the parent's `open` attribute, so the rotation needs no state. It costs **one line** above the article, which is what makes a nav in the reading flow acceptable.
121
+
122
+ ## Case 3: two views of one collection, chosen by the reader
123
+
124
+ The first two cases fork on a *device* capability; the gallery's **wall** and **roll** fork on a preference. The interaction model changes, not just the arrangement (layout law 8).
125
+
126
+ ### The state can't live in either component
127
+
128
+ The control and what it controls are siblings, so a third party owns the value:
129
+
130
+ ```ts
131
+ export type GalleryLayout = "grid" | "roll";
132
+ let current: GalleryLayout = "grid";
133
+
134
+ const listeners = new Set<(layout: GalleryLayout) => void>();
135
+ export function onGalleryLayoutChange(fn: (l: GalleryLayout) => void) {
136
+ listeners.add(fn);
137
+ return () => { listeners.delete(fn); };
138
+ }
139
+ export function setGalleryLayout(layout: GalleryLayout) {
140
+ current = layout;
141
+ for (const fn of listeners) fn(layout);
142
+ }
143
+ ```
144
+
145
+ The shape of a theme or sound store, for the same reason: neither consumer can be the other's parent without distorting the layout.
146
+
147
+ ```ts
148
+ // Mirror it in a hook. Read in an EFFECT, not during render: on a
149
+ // server-rendered page the module and the server agree on "grid" for a fresh
150
+ // load, but arriving from another gallery with "roll" already picked would
151
+ // have the client's first paint disagree with the markup.
152
+ export function useGalleryLayout() {
153
+ const [layout, setLayout] = useState<GalleryLayout>("grid");
154
+ useEffect(() => {
155
+ setLayout(getGalleryLayout());
156
+ return onGalleryLayoutChange(setLayout);
157
+ }, []);
158
+ return { layout, pickLayout: setGalleryLayout };
159
+ }
160
+ ```
161
+
162
+ ### The choice doesn't persist
163
+
164
+ The theme persists to `localStorage` forever; the gallery layout lives in module memory, so a fresh load, a reload and a move between galleries all open on the wall (layout law 9; the reason is in §6 of the guide).
165
+
166
+ ### The switch carries the position
167
+
168
+ Find the item nearest the viewport's centre before the outgoing layout unmounts and open the incoming one there — the measurement is `centreFrame` in `glass/motion/shared-element.md`.
169
+
170
+ ### Each tile declares its own aspect ratio
171
+
172
+ ```tsx
173
+ <figure style={{ aspectRatio: `${photo.w} / ${photo.h}` }}>
174
+ ```
175
+
176
+ Nothing is cropped, so `object-cover` has nothing to do; nothing shifts on load; and one image travels between layouts undistorted, because both ends compute to the same shape.
177
+
178
+ ## Hover in Tailwind
179
+
180
+ Tailwind v4's `hover:` variant already compiles to a `(hover: hover)` guard, so utility hover states are safe by default. Imperative JavaScript needs the explicit check.
181
+
182
+ ## The touch-versus-hover audit
183
+
184
+ Glass's answers to the audit in `domains/layout/forks-gating-and-disclosures.md`:
185
+
186
+ | Hover behaviour | Touch equivalent |
187
+ |---|---|
188
+ | Icon label as a tooltip | Text label in the sheet |
189
+ | Settings box on hover | A row in the sheet |
190
+ | Company card on row hover | Sits below the row instead of beside it, at a wider breakpoint |
191
+ | Folder cards rising | Not available — but the folder is still a link to the same page |
192
+ | Dock collapse on scroll | Doesn't happen |
193
+
194
+ The folder-card lift is the honest case: a delight beat carrying no information, so touch users lose nothing they need.
195
+
196
+ ## Where responsive utilities are still right
197
+
198
+ Arrangement, not interaction:
199
+
200
+ ```tsx
201
+ "grid gap-4 sm:gap-6 grid-cols-3" // grid density
202
+ "columns-3 gap-1 sm:columns-4" // masonry columns
203
+ "pt-28 sm:pt-20" // page padding under fixed chrome
204
+ "absolute left-3 right-3 top-full … xl:left-full xl:top-1/2 xl:ml-6 xl:w-60"
205
+ ```
206
+
207
+ The last is a hover card *below* its row on narrow screens and *beside* it at `xl`, where there's finally margin: only position moves, so it's correctly a breakpoint.
208
+
209
+ ## Breakpoints in use
210
+
211
+ | Breakpoint | Width | What changes |
212
+ |---|---|---|
213
+ | `sm` | 640px | Mobile dock ↔ desktop dock. Page padding. Masonry columns. Stack columns. |
214
+ | `xl` | 1280px | The TOC rail appears. The hover card moves beside its row. |
215
+
216
+ Two for the whole site, both derived from content. Both are viewport breakpoints only because here the column *is* the page. `sm` coincides with the reading measure: below it the column is padding-limited, the natural place for the layout to change character. `xl` is where a 640px column finally leaves margin for a 196px rail. Type size is *not* on the list: a 30px article title once stepped to 26px below `sm`, until an 18px type ceiling removed the need (`glass/layout/measure-and-rhythm.md`; the Glass type guide, `glass/type/guide.md`).
217
+
218
+ ## Breakpoints on the container
219
+
220
+ Where a pane shares the screen with something that opens and closes, it breaks on its own width (layout law 8). In Tailwind:
221
+
222
+ ```tsx
223
+ <section className="@container"> {/* the pane, not the page */}
224
+ <div className="grid @2xl:grid-cols-[minmax(240px,1fr)_2fr]">
225
+ <List />
226
+ <Detail className="@container" /> {/* sizes its own insides */}
227
+ </div>
228
+ </section>
229
+ ```
230
+
231
+ Tailwind v4 ships `@container` and the `@sm:`…`@7xl:` variants; plain CSS is `container-type: inline-size` and `@container (min-width: …)`. The list is clamped with `minmax` so it narrows before the detail does, and the nested `@container` lets the detail size its own gutters, rail and labels.
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Two presentations of one tree — the canonical "fork the container, share
3
+ * the data" example.
4
+ *
5
+ * SectionNav the sticky rail that lives in the left margin
6
+ * SectionNavCompact the disclosure that replaces it when there's no margin
7
+ *
8
+ * Both render the same nodes through the same `Rows` component. Only the
9
+ * container differs, because only the container's interaction model differs.
10
+ *
11
+ * Rows are real in-page anchors, so they work without JavaScript and can be
12
+ * copied as links; the click handler takes over to scroll smoothly and keep
13
+ * the URL in step.
14
+ *
15
+ * Pair with useActiveSection (`glass/motion/guide.md`) for the scroll spy, and
16
+ * give every heading a scroll-margin-top just under its READING_LINE.
17
+ */
18
+
19
+ import { useMemo, useState } from "react";
20
+
21
+ export type TocNode = {
22
+ /** Id of the heading this row jumps to. */
23
+ id: string;
24
+ label: string;
25
+ children?: TocNode[];
26
+ };
27
+
28
+ /** Flattens the tree into document order — what the scroll spy measures. */
29
+ export function tocIds(nodes: TocNode[]): string[] {
30
+ return nodes.flatMap((n) => [n.id, ...tocIds(n.children ?? [])]);
31
+ }
32
+
33
+ /** child id → parent id, so the section an active row belongs to stays lit. */
34
+ function parentMap(nodes: TocNode[]): Map<string, string> {
35
+ const map = new Map<string, string>();
36
+ for (const n of nodes) for (const c of n.children ?? []) map.set(c.id, n.id);
37
+ return map;
38
+ }
39
+
40
+ type RowState = "active" | "ancestor" | "rest";
41
+
42
+ function ChevronDownIcon() {
43
+ return (
44
+ <svg
45
+ viewBox="0 0 24 24"
46
+ fill="none"
47
+ stroke="currentColor"
48
+ strokeWidth="2"
49
+ strokeLinecap="round"
50
+ strokeLinejoin="round"
51
+ aria-hidden
52
+ className="h-3.5 w-3.5"
53
+ >
54
+ <path d="M6 9l6 6 6-6" />
55
+ </svg>
56
+ );
57
+ }
58
+
59
+ /**
60
+ * One row.
61
+ *
62
+ * Top-level rows sit flush and carry no marker — they read as the headings
63
+ * they are, and the active one takes a heavier WEIGHT so its state isn't
64
+ * signalled by colour alone (WCAG 1.4.1). Weight is a genuine second channel:
65
+ * it survives greyscale, colour-blindness and low-contrast displays, and it's
66
+ * cheaper than a background pill in a list that's meant to be quiet.
67
+ *
68
+ * Nested rows carry a horizontal marker that extends and darkens when active,
69
+ * and which doubles as their indent. It sits in a FIXED-WIDTH box so growing
70
+ * it never nudges the label.
71
+ *
72
+ * Three tiers of tone and marker, not two: `ancestor` (the section you're
73
+ * inside, when a child is active) sits between active and rest, so you never
74
+ * lose the larger context.
75
+ */
76
+ function Row({
77
+ node,
78
+ state,
79
+ nested,
80
+ onJump,
81
+ }: {
82
+ node: TocNode;
83
+ state: RowState;
84
+ nested?: boolean;
85
+ onJump: (id: string) => void;
86
+ }) {
87
+ const tone =
88
+ state === "active"
89
+ ? "text-gray-1000"
90
+ : state === "ancestor"
91
+ ? "text-gray-900"
92
+ : "text-quiet hover:text-gray-1000";
93
+
94
+ const marker =
95
+ state === "active"
96
+ ? "w-4 bg-gray-1000"
97
+ : state === "ancestor"
98
+ ? "w-2.5 bg-gray-600"
99
+ : "w-2 bg-gray-500";
100
+
101
+ return (
102
+ <a
103
+ href={`#${node.id}`}
104
+ aria-current={state === "active" ? "true" : undefined}
105
+ onClick={(e) => {
106
+ e.preventDefault();
107
+ onJump(node.id);
108
+ }}
109
+ className={`flex items-start gap-2.5 py-[3px] text-[13px] leading-[1.4] transition-colors duration-150 ease-geist ${tone} ${
110
+ !nested && state === "active" ? "font-medium" : ""
111
+ }`}
112
+ >
113
+ {nested && (
114
+ <span aria-hidden className="mt-[9px] w-4 shrink-0">
115
+ <span
116
+ className={`block h-px transition-[width,background-color] duration-150 ease-geist motion-reduce:transition-none ${marker}`}
117
+ />
118
+ </span>
119
+ )}
120
+ {node.label}
121
+ </a>
122
+ );
123
+ }
124
+
125
+ /** Shared tree rendering. Both presentations go through this. */
126
+ function Rows({
127
+ nodes,
128
+ active,
129
+ parents,
130
+ onJump,
131
+ }: {
132
+ nodes: TocNode[];
133
+ active: string;
134
+ parents: Map<string, string>;
135
+ onJump: (id: string) => void;
136
+ }) {
137
+ const state = (id: string): RowState =>
138
+ id === active ? "active" : parents.get(active) === id ? "ancestor" : "rest";
139
+
140
+ return (
141
+ <>
142
+ {nodes.map((n) => (
143
+ <li key={n.id}>
144
+ <Row node={n} state={state(n.id)} onJump={onJump} />
145
+ {n.children && (
146
+ <ul className="flex flex-col">
147
+ {n.children.map((c) => (
148
+ <li key={c.id}>
149
+ <Row node={c} state={state(c.id)} nested onJump={onJump} />
150
+ </li>
151
+ ))}
152
+ </ul>
153
+ )}
154
+ </li>
155
+ ))}
156
+ </>
157
+ );
158
+ }
159
+
160
+ /**
161
+ * The sticky rail. Mount it as an absolute child of the wider frame, so it
162
+ * sits outside the reading column:
163
+ *
164
+ * <div className="relative mx-auto max-w-[1280px]">
165
+ * <div className="absolute inset-y-0 left-8 hidden w-[196px] xl:block">
166
+ * <div className="sticky top-28 max-h-[calc(100dvh-11rem)] overflow-y-auto">
167
+ * <SectionNav {...nav} />
168
+ * </div>
169
+ * </div>
170
+ * <div className="col">…</div>
171
+ * </div>
172
+ *
173
+ * The absolute wrapper spans the section's full height so the sticky child
174
+ * has a track to travel. The dvh cap plus overflow-y-auto means a long
175
+ * contents scrolls inside itself rather than running off the screen.
176
+ */
177
+ export function SectionNav({
178
+ nodes,
179
+ active,
180
+ onJump,
181
+ }: {
182
+ nodes: TocNode[];
183
+ active: string;
184
+ onJump: (id: string) => void;
185
+ }) {
186
+ const parents = useMemo(() => parentMap(nodes), [nodes]);
187
+
188
+ return (
189
+ <nav aria-label="On this page">
190
+ <ul className="flex flex-col">
191
+ <Rows nodes={nodes} active={active} parents={parents} onJump={onJump} />
192
+ </ul>
193
+ </nav>
194
+ );
195
+ }
196
+
197
+ /**
198
+ * The narrow-screen contents: collapsed to a single row, so it costs one line
199
+ * above the article — which is the constraint that makes putting a nav in the
200
+ * reading flow acceptable at all. Closes itself once you've jumped.
201
+ *
202
+ * <details> rather than a div and state, because it brings keyboard behaviour,
203
+ * aria-expanded, and find-in-page (browsers open a closed <details> to reveal
204
+ * a match) for free, and it works with JS disabled. Mirroring `open` into
205
+ * state is only so the disclosure can close itself after a jump.
206
+ *
207
+ * group-open:rotate-180 reads the parent's `open` attribute directly — no
208
+ * state needed for the chevron.
209
+ */
210
+ export function SectionNavCompact({
211
+ nodes,
212
+ active,
213
+ onJump,
214
+ }: {
215
+ nodes: TocNode[];
216
+ active: string;
217
+ onJump: (id: string) => void;
218
+ }) {
219
+ const parents = useMemo(() => parentMap(nodes), [nodes]);
220
+ const [open, setOpen] = useState(false);
221
+
222
+ const jump = (id: string) => {
223
+ setOpen(false);
224
+ onJump(id);
225
+ };
226
+
227
+ return (
228
+ <details
229
+ open={open}
230
+ onToggle={(e) => setOpen(e.currentTarget.open)}
231
+ className="group rounded-xl border border-gray-alpha-300"
232
+ >
233
+ <summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-3.5 py-2.5 text-[13px] text-gray-1000 [&::-webkit-details-marker]:hidden">
234
+ On this page
235
+ <span className="text-quiet transition-transform duration-200 ease-geist group-open:rotate-180 motion-reduce:transition-none">
236
+ <ChevronDownIcon />
237
+ </span>
238
+ </summary>
239
+ <ul className="flex flex-col border-t border-gray-alpha-300 px-3.5 py-2">
240
+ <Rows nodes={nodes} active={active} parents={parents} onJump={jump} />
241
+ </ul>
242
+ </details>
243
+ );
244
+ }
@@ -0,0 +1,201 @@
1
+ /* ─────────────────────────────────────────────────────────────────────────
2
+ Layout and hierarchy — tokens and structural primitives
3
+ ───────────────────────────────────────────────────────────────────────── */
4
+
5
+ @theme {
6
+ /* ── The measure ────────────────────────────────────────────────────────
7
+ 640px outer, 32px padding → 576px of text. At the 14px prose size that's
8
+ ~90 characters per line (85–95): past the comfortable range, which is the
9
+ right place for a site that expects sustained reading (long lines are
10
+ efficient once the reader is committed, and 1.75 leading pays for the
11
+ longer return sweep).
12
+
13
+ EVERY page uses this. The payoff isn't visual consistency, it's
14
+ positional continuity: clicking from a summary into its detail view
15
+ doesn't move the text, so the transition reads as expansion rather than
16
+ as arriving at a different document. */
17
+ --container-col: 640px;
18
+
19
+ /* The outer frame that rails anchor to, alongside the column. Anchoring
20
+ both to the SAME frame is what makes the gap between them hold steady
21
+ from 1280px up — a rail positioned against the viewport instead drifts
22
+ away from the content it indexes as the window grows. */
23
+ --container-frame: 1280px;
24
+ --rail-width: 196px;
25
+ }
26
+
27
+ @layer base {
28
+ /* Apple-style continuous corners on every rounded element.
29
+
30
+ A standard border-radius is a quarter-circle: constant curvature that
31
+ drops abruptly to zero where it meets the straight edge. The eye reads
32
+ that discontinuity as a slight hardness. A superellipse varies curvature
33
+ continuously into the edge.
34
+
35
+ superellipse(k) uses exponent 2^k — k=1 is a normal round corner, k=2 a
36
+ full iOS squircle, so 1.5 lands about halfway. Not 2, because a full
37
+ squircle is noticeable at small sizes (it starts to look like a swollen
38
+ rectangle); halfway reads as "well-drawn corner" rather than as an
39
+ effect.
40
+
41
+ Unsupported browsers ignore it and fall back to normal rounding. Keep it
42
+ that way — nothing else in the layout should depend on it. */
43
+ *,
44
+ *::before,
45
+ *::after {
46
+ corner-shape: superellipse(1.5);
47
+ }
48
+ }
49
+
50
+ @layer components {
51
+ .col {
52
+ max-width: var(--container-col);
53
+ margin: 0 auto;
54
+ padding: 0 32px;
55
+ }
56
+
57
+ /* Skip link. Parked off-screen with a TRANSFORM, never display:none —
58
+ which would take it out of the tab order and defeat the point. Every
59
+ page needs a matching id="main". */
60
+ .skip {
61
+ position: absolute;
62
+ left: 16px;
63
+ top: 16px;
64
+ z-index: 100;
65
+ background: var(--color-gray-1000);
66
+ color: var(--color-background-100);
67
+ padding: 10px 14px;
68
+ font-size: 13px;
69
+ text-decoration: none;
70
+ border-radius: 6px;
71
+ transform: translateY(-200%);
72
+ transition: transform 150ms var(--ease-geist);
73
+ }
74
+ .skip:focus {
75
+ transform: translateY(0);
76
+ }
77
+ }
78
+
79
+ /* ─────────────────────────────────────────────────────────────────────────
80
+ Print. A real presentation, not an afterthought.
81
+
82
+ The .reveal reset is the one people forget: scroll-triggered content is at
83
+ opacity: 0 until an IntersectionObserver fires, and printing never fires
84
+ it — so the page prints blank without this.
85
+ ───────────────────────────────────────────────────────────────────────── */
86
+ @media print {
87
+ nav,
88
+ footer,
89
+ .skip {
90
+ display: none;
91
+ }
92
+
93
+ body {
94
+ background: #fff;
95
+ color: #000;
96
+ font-size: 11pt;
97
+ }
98
+
99
+ .reveal {
100
+ opacity: 1;
101
+ transform: none;
102
+ }
103
+
104
+ /* An underlined URL-less link is noise on paper. */
105
+ a {
106
+ color: inherit;
107
+ text-decoration: none;
108
+ }
109
+ }
110
+
111
+ /* ─────────────────────────────────────────────────────────────────────────
112
+ REFERENCE — apply these in components; they're documentation, not classes.
113
+
114
+ ── The radius ladder ──────────────────────────────────────────────────
115
+ Nesting rule: INNER = OUTER − the padding between them. Geometry, not
116
+ taste — two concentric rounded rectangles share a centre of curvature
117
+ only if their radii differ by exactly the gap between their edges.
118
+
119
+ rounded-full pills, tags, dots, markers
120
+ rounded-[5px] a lettermark box
121
+ rounded-md 6 tooltips, the skip link
122
+ rounded-[9px] 9 small cards in a stack
123
+ rounded-[10px]10 avatar inside a button · a list highlight bar
124
+ rounded-xl 12 cards, hover panels, disclosures, icon buttons
125
+ rounded-2xl 16 outer containers — a dock, a sheet, a pocket
126
+
127
+ Worked example:
128
+ Dock rounded-2xl (16) p-1 (4)
129
+ Icon button rounded-[12px] 16 − 4 = 12 ✓
130
+ Avatar rounded-[10px] 12 − 2 = 10 ✓
131
+
132
+ Radius scales with SIZE, not importance — and sub-proportionally, because
133
+ a large element with a proportional radius reads as a lozenge.
134
+
135
+ ── Hairline weights ───────────────────────────────────────────────────
136
+ The choice is about what the line is DOING:
137
+
138
+ 0.5px internal articulation — a divider between icon groups inside
139
+ one dock. Renders as a true sub-pixel hairline on retina and
140
+ rounds UP to 1px elsewhere, so it degrades rather than vanishes.
141
+ 1px a real edge — where a card ends.
142
+ 2px a focus ring; imported Markdown's blockquote rule.
143
+ 3px the authored pull quote's bar — a rounded flex child, not a
144
+ border, since a border can't be rounded on its own.
145
+
146
+ On translucent surfaces use the ALPHA ramp (border-gray-alpha-400). A
147
+ solid border on glass doesn't participate in what's behind it and reads as
148
+ a pasted-on frame.
149
+
150
+ An <hr> needs border-0 before you give it a background, or you get the UA
151
+ border AND your line. Non-semantic dividers get aria-hidden.
152
+
153
+ ── The z-index ladder ─────────────────────────────────────────────────
154
+ Ordered by PERMANENCE, not importance. Gaps of 10 so something can be
155
+ inserted without renumbering.
156
+
157
+ 100 skip link — must beat everything, including modals
158
+ 60 a once-a-session overlay — over the dock, under the skip link
159
+ 50 fixed navigation
160
+ 40 tap-outside catcher — under the nav, over the page
161
+ 30 a pocket front, above the cards it contains
162
+ 20 hover cards, dropdowns
163
+ 10 a hovered item lifting above its siblings
164
+ -10 a sliding highlight, behind content inside `isolate`
165
+
166
+ 60 is what the gaps were for: a greeting overlay arrived long after this
167
+ ladder was set and slotted in without touching an existing value.
168
+
169
+ Two pairs to understand rather than copy. 40/50: a full-screen catcher
170
+ must be above the page (to catch the click) and below the menu it
171
+ dismisses (or it eats the menu's own clicks). 60/100: a splash overlay
172
+ covers the navigation — or it isn't covering the page — but must never
173
+ cover the skip link, because a keyboard user tabbing during a two-second
174
+ greeting has to be able to get past it.
175
+
176
+ ── Two techniques ─────────────────────────────────────────────────────
177
+ isolate + negative z: a highlight bar behind icons but in front of its
178
+ container's background. `-z-10` alone drops it behind the container;
179
+ `isolate` scopes the negative z inside.
180
+
181
+ pointer-events-none on a full-width strip, auto on the content: a fixed
182
+ nav strip spans the viewport for centring but must not swallow every click
183
+ along the bottom of the page.
184
+
185
+ And the strip carries NO TRANSFORM. A transform makes an element the
186
+ containing block for position:fixed descendants — a full-bleed w-screen
187
+ overlay inside it would be trapped in the strip's bounds. Same class of
188
+ problem as transforms weakening backdrop-filter: when something positioned
189
+ or filtered behaves inexplicably, check every ancestor for transform,
190
+ filter, perspective, contain, and will-change.
191
+
192
+ ── Viewport units ─────────────────────────────────────────────────────
193
+ Use dvh, never vh. `vh` refers to the viewport WITHOUT mobile browser
194
+ chrome, so a 100vh element extends under the address bar and its bottom is
195
+ unreachable. `dvh` tracks the actual visible area.
196
+
197
+ ── Bottom padding ─────────────────────────────────────────────────────
198
+ Anything with fixed bottom chrome needs pb-28 (112px) audited page by
199
+ page, or the last line sits under the nav. The most common "looks fine on
200
+ my long page" bug.
201
+ ───────────────────────────────────────────────────────────────────────── */