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,195 @@
1
+ # UI sound
2
+
3
+ The most optional thing in the system: "if you're going to do it, here's how", not a recommendation. Whether a preset allows sound, and the rules for arming, hover, dense surfaces and mute, are in `domains/motion/patterns.md`, sound; this file is glass's synthesis and the decisions behind it.
4
+
5
+ ## Synthesise, don't ship files
6
+
7
+ The whole sound design is ~100 lines of Web Audio, no assets: two sounds, generated on demand. Tuning is editing numbers, not re-exporting audio.
8
+
9
+ ## The "tok"
10
+
11
+ A woody marimba-like tick: a sine fundamental, a fast-decaying partial, and a noise transient for the attack.
12
+
13
+ ```ts
14
+ function tok(base: number, duration: number, volume: number): void {
15
+ if (muted) return;
16
+ const c = getCtx();
17
+ if (!c || c.state !== "running") return;
18
+ const now = c.currentTime;
19
+
20
+ // Fundamental + a 3.9:1 partial that decays faster than the body.
21
+ const tones: [number, number, number][] = [
22
+ [1, 1, duration],
23
+ [3.9, 0.32, duration * 0.45],
24
+ ];
25
+ for (const [ratio, vmul, dur] of tones) {
26
+ const osc = c.createOscillator();
27
+ osc.type = "sine";
28
+ osc.frequency.value = base * ratio;
29
+ const gain = c.createGain();
30
+ gain.gain.setValueAtTime(0.0001, now);
31
+ gain.gain.exponentialRampToValueAtTime(volume * vmul, now + 0.002);
32
+ gain.gain.exponentialRampToValueAtTime(0.0001, now + dur);
33
+ osc.connect(gain).connect(c.destination);
34
+ osc.start(now);
35
+ osc.stop(now + dur + 0.02);
36
+ }
37
+
38
+ // Wooden contact tick: short lowpassed noise for the "k".
39
+ const transient = 0.012;
40
+ const noise = c.createBufferSource();
41
+ noise.buffer = getNoise(c);
42
+ const lp = c.createBiquadFilter();
43
+ lp.type = "lowpass";
44
+ lp.frequency.value = 2600;
45
+ const noiseGain = c.createGain();
46
+ noiseGain.gain.setValueAtTime(volume * 0.7, now);
47
+ noiseGain.gain.exponentialRampToValueAtTime(0.0001, now + transient);
48
+ noise.connect(lp).connect(noiseGain).connect(c.destination);
49
+ noise.start(now);
50
+ noise.stop(now + transient + 0.01);
51
+ }
52
+
53
+ export const playHover = () => tok(1200, 0.09, 0.05);
54
+ export const playClick = () => tok(900, 0.14, 0.13);
55
+ ```
56
+
57
+ ### Why each piece is there
58
+
59
+ **The 3.9:1 partial.** A pure sine reads as a test tone. Struck wooden bars have a strong inharmonic partial near the 4th, and slightly *off* 4.0 reads as physical rather than as a musical interval. It's quieter (0.32×) and decays in 45% of the time, as a real bar's overtones die first.
60
+
61
+ **The noise transient.** 12ms of lowpassed white noise at the attack is the "k" in "tok", the contact before the note's body: without it a pitched beep, with it something *struck*. Lowpassed at 2600Hz so it's a thud, not a click.
62
+
63
+ **Exponential ramps, not linear.** Loudness perception is logarithmic, so a linear ramp seems to decay too fast and then hang. `exponentialRampToValueAtTime` matches perception, and can't target zero, hence `0.0001` at both ends.
64
+
65
+ **The 2ms attack** avoids the click of a discontinuity while still feeling instantaneous.
66
+
67
+ ### Hover vs click
68
+
69
+ | | Hover | Click |
70
+ |---|---|---|
71
+ | Base | 1200Hz | 900Hz |
72
+ | Duration | 90ms | 140ms |
73
+ | Gain | 0.05 | 0.13 |
74
+
75
+ Hover is higher, shorter and **2.6× quieter**: you cross dozens of links moving around a page, and hover at click volume is intolerable within seconds. Click, a completed action, is louder and lower, since lower pitch reads as more consequential. Roughly a fourth apart, they sound pleasant when a click follows a hover.
76
+
77
+ ## The autoplay policy
78
+
79
+ An `AudioContext` starts `suspended` and resumes only from a user gesture, so hover sounds stay silent until the reader's first real interaction unlocks it, permanently.
80
+
81
+ ```ts
82
+ export function unlockAudio(): void {
83
+ const c = getCtx();
84
+ if (c && c.state === "suspended") void c.resume();
85
+ }
86
+ ```
87
+
88
+ Every playback path checks state rather than assuming:
89
+
90
+ ```ts
91
+ if (!c || c.state !== "running") return;
92
+ ```
93
+
94
+ That's a feature, not a workaround: a page you've merely scrolled past makes no sound. Fail silently; never surface an error for this.
95
+
96
+ ### Arm it on every activation gesture
97
+
98
+ ```ts
99
+ const UNLOCK_EVENTS = ["pointerdown", "keydown", "touchstart"] as const;
100
+
101
+ /* Idempotent — resume() on a running context is a no-op, which is cheaper
102
+ than the bookkeeping to detach these after the first one lands. */
103
+ const onUnlock = () => unlockAudio();
104
+ for (const type of UNLOCK_EVENTS) {
105
+ document.addEventListener(type, onUnlock, { passive: true });
106
+ }
107
+ ```
108
+
109
+ The reference implementation shipped `pointerdown` alone for months. A reader who only moved the mouse across the dock heard nothing — *the hover was the interaction* — which hid because testers click something within seconds; and keyboard-only readers never heard the site. `keydown` is the fix that matters most: the affected group is the one least able to work around it.
110
+
111
+ ## Delegation, not per-element wiring
112
+
113
+ One set of document listeners covers every link and button on the site.
114
+
115
+ ```ts
116
+ const SELECTOR = "a, button, [role='button'], summary";
117
+
118
+ const onOver = (e: PointerEvent) => {
119
+ // Mouse only — touch has no hover, and we don't want a tick on tap.
120
+ if (e.pointerType && e.pointerType !== "mouse") return;
121
+ const el = (e.target as Element | null)?.closest?.(SELECTOR) ?? null;
122
+ if (el === lastHover) return; // don't re-fire while moving within one element
123
+ lastHover = el;
124
+ if (el) playHover();
125
+ };
126
+
127
+ document.addEventListener("pointerover", onOver);
128
+ document.addEventListener("click", onClick);
129
+ ```
130
+
131
+ - **`pointerType !== "mouse"` bails out**, or every tap plays both sounds 15ms apart, like a stutter.
132
+ - **`lastHover` deduplication.** `pointerover` fires for descendants too, so an icon *inside* a button would re-trigger without the identity check.
133
+ - **`closest(SELECTOR)`** covers anything interactive, including elements added later, with no per-component wiring. `summary` is in it because a `<details>` disclosure is a real control that should feel like one.
134
+
135
+ ### Dense surfaces opt out of the hover tick
136
+
137
+ ```ts
138
+ // Surfaces of a hundred small targets (an image wall, a filmstrip) opt out with
139
+ // data-quiet-hover: a tick per tile is a machine gun. Clicks still tick.
140
+ if (el && !el.closest("[data-quiet-hover]")) playHover();
141
+ ```
142
+
143
+ One attribute on the container quiets the subtree on hover while clicks stay audible. It's a rate limit, not "this surface is unimportant".
144
+
145
+ ## Sound as a phrase
146
+
147
+ Occasionally you want a *run*: the reference implementation plays one note per photograph as they flick past in the opening screen, then a landing note as the real mark arrives.
148
+
149
+ ```ts
150
+ /* An A major pentatonic run, written out rather than computed from an
151
+ interval: five notes want to BE a phrase, and pentatonic is the scale
152
+ where no two of them can sound wrong together. */
153
+ const RIFFLE = [880, 990, 1100, 1320, 1467];
154
+
155
+ export function playRiffleStep(i: number): void {
156
+ tok(RIFFLE[Math.min(Math.max(i | 0, 0), RIFFLE.length - 1)], 0.1, 0.06);
157
+ }
158
+
159
+ /** The mark arriving last, and the run resolving onto it. */
160
+ export const playLanding = () => tok(587, 0.26, 0.16);
161
+ ```
162
+
163
+ - **Pentatonic, because the phrase can be truncated.** A photograph that fails to load drops its turn and the run comes up short; with no dissonant pair in the scale, it still sounds finished.
164
+ - **Quieter than a click** (0.06 against 0.13). Five notes inside a second should read as a flutter, not a drum fill.
165
+ - **The landing note resolves *downward*.** It sits a fifth below where the run started and is the only accented note. Landing above the run sounds like one more step, not an ending.
166
+
167
+ It's still a set piece, not a texture — once a session. See §14 of `glass/motion/guide.md`.
168
+
169
+ ## Mute, and persistence
170
+
171
+ ```ts
172
+ // Every storage access is guarded: it throws in some private modes.
173
+ let muted = (() => {
174
+ try { return localStorage.getItem("ui-sound") === "off"; } catch { return false; }
175
+ })();
176
+
177
+ export function setMuted(value: boolean): void {
178
+ muted = value;
179
+ try { localStorage.setItem("ui-sound", value ? "off" : "on"); } catch {}
180
+ }
181
+ ```
182
+
183
+ The mute control sits in the nav's settings box beside the theme toggle, with an `aria-pressed` state and a label.
184
+
185
+ ## Should the default be on?
186
+
187
+ Arguable. On a personal site where sound is part of the character, on is defensible — the only way most people will discover it, and it's quiet, short and muteable. On anything the user didn't choose to visit (a tool, an app, a client site), default to off. If it's on, the mute control must be *findable within seconds*; that's the price.
188
+
189
+ ## What's deliberately absent
190
+
191
+ **Haptics.** `navigator.vibrate()` isn't implemented: Safari on iOS lacks it, so it would fire only on Android, and a feedback channel on one platform is worse than none. If you add it, keep it under 10ms and tie it to the same mute preference.
192
+
193
+ **Sounds for anything but hover, click and the one greeting phrase** — no navigation, reveal or error sounds. The riffle earns its place by playing once per tab.
194
+
195
+ **Pitch variation** by element type or position. It turns an ambient texture into something the user starts trying to interpret.
@@ -0,0 +1,51 @@
1
+ # Springs
2
+
3
+ How glass's springs were arrived at. The two vocabularies, tuning by symptom and where springs are wrong are in `domains/motion/ladder-and-curves.md` and law 6.
4
+
5
+ ## SLIDE — a shared element re-targeting
6
+
7
+ ```ts
8
+ const SLIDE = {
9
+ type: "spring", bounce: 0.2, duration: 0.4,
10
+ opacity: { duration: 0.16, ease: "easeOut" },
11
+ } as const;
12
+ ```
13
+
14
+ `bounce: 0.2` sits near the floor of "perceptibly springy", as a highlight should (`spring.snappy` in the domain reference).
15
+
16
+ `duration: 0.4` is longer than it sounds: the spring covers most of the distance in the first ~150ms and spends the tail settling, landing about as fast as a 200ms tween while staying re-targetable. The 160ms `easeOut` opacity outpaces the position spring, so the element is fully visible while it settles, which makes the settle readable.
17
+
18
+ ## LIFT — cards rising out of a folder
19
+
20
+ ```ts
21
+ const LIFT = { type: "spring", bounce: 0.28, duration: 0.5 } as const;
22
+ ```
23
+
24
+ The one playful moment: hovering a folder on the home page lifts four preview cards out of a frosted pocket, a delight beat with 40% more bounce and 25% more duration than anything else. Being the *only* one is why it works.
25
+
26
+ ## GROW / CROSS — the disagreeing pair
27
+
28
+ ```ts
29
+ const GROW = { type: "spring", stiffness: 620, damping: 19, mass: 0.85 } as const;
30
+ const CROSS = { type: "spring", stiffness: 380, damping: 30 } as const;
31
+ ```
32
+
33
+ | | GROW | CROSS | Effect |
34
+ |---|---|---|---|
35
+ | stiffness | 620 | 380 | GROW pulls harder → moves sooner |
36
+ | damping | 19 | 30 | GROW resists less → overshoots |
37
+ | mass | 0.85 | 1 (default) | GROW is lighter → accelerates faster |
38
+
39
+ All three push the same way — GROW fast and loose, CROSS slow and controlled — so on `scaleY` and `scaleX` of one panel it's briefly **taller and narrower** than at rest, then settles. `y` rides `GROW` alongside `scaleY` (§4 of `glass/motion/guide.md`).
40
+
41
+ ## CLOSE — a tween, not a spring
42
+
43
+ ```ts
44
+ const CLOSE = { duration: 0.13, ease: "easeIn" } as const;
45
+ ```
46
+
47
+ 130ms, under the state tier, on `easeIn` (law 7).
48
+
49
+ ## Reduced-motion substitutes
50
+
51
+ Every spring has a flat replacement at the state-tier duration: 150ms, default easing, no overshoot. See `reduced-motion.md` for the complete set.
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Long-form prose renderer — the reading half of the type system.
3
+ *
4
+ * The reading column is TWO SIZES: 16px for headings, 14px for everything a
5
+ * reader reads. Which means size does no work below the headings, and the
6
+ * blocks sharing 14px have to separate on other axes — each on a different
7
+ * one, or they collide:
8
+ *
9
+ * lead leading, 1.9 against the body's 1.75
10
+ * keyline weight 500, and 36px of air on both sides
11
+ * body 1.75 — the baseline the others are read against
12
+ * quote a 3px rounded bar and a 16px indent
13
+ * note 13px, the faint tier, and a 1px rule
14
+ *
15
+ * The rule that produced this: A TIER MUST NEVER OUTRANK THE HEADING IT SITS
16
+ * UNDER. An earlier pass ran the keyline at 17px, above the 16px heading it
17
+ * belonged to, and the page read as though the paragraphs were shouting over
18
+ * the titles. Pulling everything to one size removes any chance of that
19
+ * returning.
20
+ *
21
+ * Spacing is deliberately asymmetric around headings: 40px above, 12px below,
22
+ * so a heading belongs to the paragraph that follows it rather than floating
23
+ * between two sections. The keyline is the one symmetric block, because it
24
+ * belongs to neither neighbour.
25
+ *
26
+ * Colour comes from the Glass colour guide (`glass/color/guide.md`): `gray-1000`/`gray-900` for
27
+ * the two upper text tiers, and the `muted`/`quiet`/`faint` tokens below them
28
+ * (which are deliberately NOT ramp steps — 500–800 are border values and fail
29
+ * as text in a light theme). `gray-500` stays on decorative marks, which carry
30
+ * no text. `reveal` comes from glass/motion.
31
+ */
32
+
33
+ import { Fragment, type ReactNode } from "react";
34
+
35
+ export type Block =
36
+ | { type: "p" | "lead" | "keyline" | "note"; text: string }
37
+ | { type: "h2"; text: string }
38
+ | { type: "quote"; text: string; cite?: string }
39
+ | { type: "figure"; src: string; alt: string; caption: string; aspect?: string }
40
+ | { type: "ul" | "ol"; items: string[] };
41
+
42
+ export function Prose({ blocks }: { blocks: Block[] }) {
43
+ return (
44
+ <div className="text-[14px] leading-[1.75] text-gray-1000">
45
+ {blocks.map((b, i) => {
46
+ switch (b.type) {
47
+ /* The opening paragraph. Same size as the body; the 1.9 leading is
48
+ the entire difference, and it's what stops a piece reading as one
49
+ undifferentiated block from the first screen. */
50
+ case "lead":
51
+ return (
52
+ <p key={i} className="reveal mb-8 text-[14px] leading-[1.9] text-gray-1000">
53
+ {renderInline(b.text)}
54
+ </p>
55
+ );
56
+
57
+ case "p":
58
+ return (
59
+ <p key={i} className="reveal mb-6">
60
+ {renderInline(b.text)}
61
+ </p>
62
+ );
63
+
64
+ /* The one sentence a section is for. Authored by promoting a
65
+ sentence out of a paragraph and DELETING it from the paragraph —
66
+ a section that gains a keyline gets shorter. One per section.
67
+ Everything that makes it a landmark comes from weight and from
68
+ the 36px of air, so it can never outrank the heading above it. */
69
+ case "keyline":
70
+ return (
71
+ <p
72
+ key={i}
73
+ className="reveal my-9 text-[14px] font-medium leading-[1.6] text-gray-1000"
74
+ >
75
+ {renderInline(b.text)}
76
+ </p>
77
+ );
78
+
79
+ /* An aside set outside the argument — a caveat, a scope limit, a
80
+ piece of provenance. Its rule is 1px against the quote's 3px and
81
+ its type is four steps down, so two indented rules on one page
82
+ can't be mistaken for each other. */
83
+ case "note":
84
+ return (
85
+ <p
86
+ key={i}
87
+ className="reveal my-7 border-l border-gray-alpha-300 pl-4 text-[13px] leading-[1.6] text-faint"
88
+ >
89
+ {renderInline(b.text)}
90
+ </p>
91
+ );
92
+
93
+ /* 16px against 14px body — one step, and the weight, the tight
94
+ leading and the 40px above do the real work. This is the same
95
+ size as the page's own title, separated by position alone. */
96
+ case "h2":
97
+ return (
98
+ <h2
99
+ key={i}
100
+ className="reveal mt-10 mb-3 text-[16px] font-medium leading-[1.2] tracking-[-0.015em] text-gray-1000"
101
+ >
102
+ {renderInline(b.text)}
103
+ </h2>
104
+ );
105
+
106
+ /* The type is identical to the body around it, so THE BAR IS THE
107
+ ENTIRE SIGNAL — which is why it's a real one. It used to be a 2px
108
+ alpha-300 border, ten percent black, near enough invisible on a
109
+ white page; survivable only while the quote was also two steps
110
+ larger than the body. When you remove one signal, audit the ones
111
+ you left in.
112
+
113
+ A flex child rather than a border-left, because a border can't be
114
+ rounded on its own and a squared-off bar reads as a table rule.
115
+ No vertical margin on it, so flex's default `stretch` runs it the
116
+ full height of the quote including the citation. */
117
+ case "quote":
118
+ return (
119
+ <blockquote key={i} className="reveal my-9 flex gap-4">
120
+ <span aria-hidden className="w-[3px] shrink-0 rounded-full bg-gray-500" />
121
+ <div className="text-[14px] leading-[1.75] text-gray-1000">
122
+ {renderInline(b.text)}
123
+ {/* not-italic is required — <cite> carries UA italics that the
124
+ global `em, i { font-style: normal }` rule doesn't reach. */}
125
+ {b.cite && (
126
+ <cite className="mt-2 block text-[13px] not-italic text-quiet">— {b.cite}</cite>
127
+ )}
128
+ </div>
129
+ </blockquote>
130
+ );
131
+
132
+ /* Aspect ratio declared on a wrapper so the box is reserved before
133
+ the image loads and nothing shifts. `ring-1` rather than a border,
134
+ so the outline doesn't participate in the box model and the corner
135
+ radius stays exact.
136
+
137
+ The caption is the point of the figure, not a label on it — what
138
+ the thing was for, and which constraint shaped it. Set at the note
139
+ tier so it reads as apparatus; at body size it would compete with
140
+ the paragraph after it. */
141
+ case "figure":
142
+ return (
143
+ <figure key={i} className="reveal my-9">
144
+ <div className="overflow-hidden rounded-[10px] bg-gray-100 ring-1 ring-gray-alpha-300">
145
+ <div style={{ aspectRatio: b.aspect ?? "16 / 9" }}>
146
+ <img
147
+ src={b.src}
148
+ alt={b.alt}
149
+ loading="lazy"
150
+ decoding="async"
151
+ className="h-full w-full object-cover"
152
+ />
153
+ </div>
154
+ </div>
155
+ <figcaption className="mt-2.5 text-[13px] leading-[1.55] text-faint">
156
+ {renderInline(b.caption)}
157
+ </figcaption>
158
+ </figure>
159
+ );
160
+
161
+ /* marker: drops the bullets to the quiet tier. At the text colour
162
+ they read as a column of dark dots pulling the eye down the left
163
+ edge; quiet, they read as structure. space-y-2 inside vs mb-6
164
+ below — items belong to each other more than to what follows. */
165
+ case "ul":
166
+ return (
167
+ <ul key={i} className="reveal mb-6 list-disc space-y-2 pl-5 marker:text-gray-500">
168
+ {b.items.map((it, j) => (
169
+ <li key={j}>{renderInline(it)}</li>
170
+ ))}
171
+ </ul>
172
+ );
173
+ case "ol":
174
+ return (
175
+ <ol key={i} className="reveal mb-6 list-decimal space-y-2 pl-5 marker:text-gray-500">
176
+ {b.items.map((it, j) => (
177
+ <li key={j}>{renderInline(it)}</li>
178
+ ))}
179
+ </ol>
180
+ );
181
+
182
+ default:
183
+ return null;
184
+ }
185
+ })}
186
+ </div>
187
+ );
188
+ }
189
+
190
+ /**
191
+ * Inline markdown for **strong**, *em* and [links](/there), mapped onto the
192
+ * system's two axes instead of bold and italic:
193
+ *
194
+ * **strong** → weight 500 + primary colour (advances)
195
+ * *em* → muted colour, no slant (recedes)
196
+ *
197
+ * This gives genuinely two-directional emphasis, which a bold-only system
198
+ * can't express. The semantic tags stay — screen readers still announce them.
199
+ *
200
+ * One caution specific to a two-size reading column: <strong> and the keyline
201
+ * are now the same weight at the same size, so a paragraph with a long bolded
202
+ * clause reads as a keyline that failed to get its own line. Keep strong
203
+ * emphasis to a few words; anything longer wants to BE a keyline.
204
+ *
205
+ * Links are here because the strongest sentences often live in data rather
206
+ * than JSX, and a claim that can't carry a link to its own evidence is just an
207
+ * assertion. One link treatment everywhere prose carries one.
208
+ */
209
+ const TOKENS = /(\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^)\s]+\))/g;
210
+ const LINK = /^\[([^\]]+)\]\(([^)\s]+)\)$/;
211
+
212
+ const LINK_CLASS =
213
+ "underline decoration-gray-alpha-500 decoration-1 underline-offset-[3px] " +
214
+ "transition-[opacity,text-decoration-color] duration-150 ease-geist " +
215
+ "hover:decoration-gray-1000 hover:opacity-70";
216
+
217
+ export function renderInline(text: string): ReactNode {
218
+ const tokens = text.split(TOKENS).filter(Boolean);
219
+ return (
220
+ <>
221
+ {tokens.map((tok, i) => {
222
+ if (tok.startsWith("**") && tok.endsWith("**")) {
223
+ return (
224
+ <strong key={i} className="font-medium text-gray-1000">
225
+ {tok.slice(2, -2)}
226
+ </strong>
227
+ );
228
+ }
229
+
230
+ const link = LINK.exec(tok);
231
+ if (link) {
232
+ const [, label, href] = link;
233
+ // In-app paths route through the framework's Link; everything else
234
+ // (mailto:, other origins, files) is a plain anchor, and an http(s)
235
+ // one opens away from the site.
236
+ const external = /^https?:/.test(href);
237
+ return (
238
+ <a
239
+ key={i}
240
+ href={href}
241
+ className={LINK_CLASS}
242
+ {...(external ? { target: "_blank", rel: "noopener" } : {})}
243
+ >
244
+ {label}
245
+ </a>
246
+ );
247
+ }
248
+
249
+ if (tok.startsWith("*") && tok.endsWith("*")) {
250
+ return (
251
+ <em key={i} className="text-muted">
252
+ {tok.slice(1, -1)}
253
+ </em>
254
+ );
255
+ }
256
+ return <Fragment key={i}>{tok}</Fragment>;
257
+ })}
258
+ </>
259
+ );
260
+ }
261
+
262
+ /** The same string with its markers removed — for titles, meta and plain text. */
263
+ export function stripInlineMarkup(text: string): string {
264
+ return text
265
+ .replace(/\[([^\]]+)\]\([^)\s]+\)/g, "$1")
266
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
267
+ .replace(/\*([^*]+)\*/g, "$1");
268
+ }