trykeyline 0.1.1 → 0.1.2

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 (47) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/bin/keyline.mjs +2 -1
  3. package/package.json +1 -1
  4. package/plugins/keyline/.claude-plugin/plugin.json +1 -1
  5. package/plugins/keyline/plugin.json +1 -1
  6. package/plugins/keyline/skills/keyline/domains/motion/guide.md +1 -0
  7. package/plugins/keyline/skills/keyline/domains/patterns/guide.md +1 -1
  8. package/plugins/keyline/skills/keyline/domains/type/guide.md +1 -1
  9. package/plugins/keyline/skills/keyline/domains/type/ladders.md +1 -1
  10. package/plugins/keyline/skills/keyline/glass/color/assets/color-tokens.css +34 -145
  11. package/plugins/keyline/skills/keyline/glass/color/assets/theme.ts +10 -70
  12. package/plugins/keyline/skills/keyline/glass/color/guide.md +1 -1
  13. package/plugins/keyline/skills/keyline/glass/color/intent-ramp.md +2 -34
  14. package/plugins/keyline/skills/keyline/glass/color/theming.md +4 -65
  15. package/plugins/keyline/skills/keyline/glass/depth/assets/GlassPanel.tsx +14 -121
  16. package/plugins/keyline/skills/keyline/glass/depth/assets/glass.css +29 -188
  17. package/plugins/keyline/skills/keyline/glass/depth/depth-cues.md +1 -16
  18. package/plugins/keyline/skills/keyline/glass/depth/performance-and-fallbacks.md +3 -11
  19. package/plugins/keyline/skills/keyline/glass/layout/adaptive-presentations.md +5 -91
  20. package/plugins/keyline/skills/keyline/glass/layout/assets/SectionNav.tsx +3 -6
  21. package/plugins/keyline/skills/keyline/glass/layout/assets/layout-tokens.css +11 -140
  22. package/plugins/keyline/skills/keyline/glass/layout/guide.md +2 -8
  23. package/plugins/keyline/skills/keyline/glass/layout/measure-and-rhythm.md +0 -2
  24. package/plugins/keyline/skills/keyline/glass/layout/radius-and-elevation.md +1 -1
  25. package/plugins/keyline/skills/keyline/glass/motion/assets/DropMenu.tsx +4 -6
  26. package/plugins/keyline/skills/keyline/glass/motion/assets/motion-presets.ts +10 -184
  27. package/plugins/keyline/skills/keyline/glass/motion/assets/motion-tokens.css +21 -105
  28. package/plugins/keyline/skills/keyline/glass/motion/easing-and-duration.md +2 -31
  29. package/plugins/keyline/skills/keyline/glass/motion/guide.md +3 -3
  30. package/plugins/keyline/skills/keyline/glass/motion/reduced-motion.md +1 -7
  31. package/plugins/keyline/skills/keyline/glass/motion/scroll-performance.md +3 -32
  32. package/plugins/keyline/skills/keyline/glass/motion/sound.md +10 -83
  33. package/plugins/keyline/skills/keyline/glass/type/assets/prose.tsx +8 -43
  34. package/plugins/keyline/skills/keyline/glass/type/assets/type-tokens.css +46 -85
  35. package/plugins/keyline/skills/keyline/glass/type/guide.md +3 -3
  36. package/plugins/keyline/skills/keyline/glass/type/long-form-prose.md +9 -74
  37. package/plugins/keyline/skills/keyline/glass/type/scale.md +2 -6
  38. package/plugins/keyline/skills/keyline/glass/type/weight-and-tracking.md +4 -20
  39. package/plugins/keyline/skills/keyline/scripts/check/css.mjs +5 -14
  40. package/plugins/keyline/skills/keyline/scripts/check/design.mjs +3 -1
  41. package/plugins/keyline/skills/keyline/scripts/check/files.mjs +70 -26
  42. package/plugins/keyline/skills/keyline/scripts/check/markup.mjs +3 -3
  43. package/plugins/keyline/skills/keyline/scripts/check/rules.mjs +9 -1
  44. package/plugins/keyline/skills/keyline/scripts/check.mjs +2 -2
  45. package/plugins/keyline/skills/keyline/scripts/contrast.mjs +2 -0
  46. package/plugins/keyline/skills/keyline/scripts/scan.mjs +29 -45
  47. package/plugins/keyline/skills/keyline/system/guide.md +1 -1
@@ -1,56 +1,20 @@
1
1
  /* ─────────────────────────────────────────────────────────────────────────
2
2
  Interface motion — tokens and CSS-driven effects
3
3
  Drop-in for Tailwind v4 (@theme) or plain CSS (the :root fallback below).
4
+ The ladder and the reasons for each value: glass/motion/guide.md §1–2.
4
5
  ───────────────────────────────────────────────────────────────────────── */
5
6
 
6
7
  @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. */
8
+ /* One curve for the whole system; the 1.1 overshoots ~10% and settles.
9
+ Character beyond it belongs to springs, never a second bezier. */
15
10
  --ease-geist: cubic-bezier(0.175, 0.885, 0.32, 1.1);
16
11
 
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);
12
+ /* The state tiers these rules use. The full ladder, and the off-ladder
13
+ subject and set-piece timings, are in the guide. */
14
+ --duration-state: 150ms;
15
+ --duration-route: 260ms;
16
+ --duration-overlay: 300ms;
17
+ --duration-theme: 400ms;
54
18
  }
55
19
 
56
20
  /* Plain-CSS fallback if you're not on Tailwind v4. */
@@ -60,24 +24,20 @@
60
24
 
61
25
  @layer components {
62
26
  /* ── 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. */
27
+ CSS animates; JS only sets `data-in` (useReveal.ts, which also holds the
28
+ <noscript> reset this needs). 8px of travel. */
66
29
  .reveal {
67
30
  opacity: 0;
68
31
  transform: translateY(8px);
69
32
  transition: opacity var(--duration-overlay) var(--ease-geist),
70
33
  transform var(--duration-overlay) var(--ease-geist);
71
34
  }
72
- /* An attribute, not a class — see useReveal.ts for why a class can't survive. */
73
35
  .reveal[data-in] {
74
36
  opacity: 1;
75
37
  transform: translateY(0);
76
38
  }
77
39
 
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. */
40
+ /* Resolves to opacity: 1 — never leave reveal content stuck at 0. */
81
41
  @media (prefers-reduced-motion: reduce) {
82
42
  .reveal {
83
43
  opacity: 1;
@@ -86,8 +46,8 @@
86
46
  }
87
47
  }
88
48
 
89
- /* Print is a real presentation, and scroll-triggered content prints blank
90
- without this. The most-forgotten line in the file. */
49
+ /* Scroll-triggered content prints blank without this. The one copy of the
50
+ print reset; layout-tokens.css's print block relies on it. */
91
51
  @media print {
92
52
  .reveal {
93
53
  opacity: 1;
@@ -95,29 +55,9 @@
95
55
  }
96
56
  }
97
57
 
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
58
  /* ── 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. */
59
+ Opacity only, 260ms; `both` holds the from-state so there's no one-frame
60
+ flash. Key the wrapper on the pathname (glass/motion/easing-and-duration.md). */
121
61
  .route-fade {
122
62
  animation: routeFade var(--duration-route) var(--ease-geist) both;
123
63
  }
@@ -130,21 +70,10 @@
130
70
  }
131
71
 
132
72
  /* ── 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. */
73
+ setThemeChoice (glass/color/assets/theme.ts) adds the class on an
74
+ explicit toggle for ~440ms, then removes it; the !important beats every
75
+ component's own duration for that window only. Plain `ease`, and opt-in
76
+ under no-preference. Why: glass/motion/easing-and-duration.md. */
148
77
  @media (prefers-reduced-motion: no-preference) {
149
78
  :root.theme-transition,
150
79
  :root.theme-transition *,
@@ -159,18 +88,5 @@
159
88
  }
160
89
  }
161
90
 
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
- }
91
+ /* The skip link lives in glass/layout/assets/layout-tokens.css. */
176
92
  }
@@ -33,12 +33,7 @@ The gap between 150 and 200 maps to *whether other things move too*:
33
33
 
34
34
  ## Why route transitions are 260ms
35
35
 
36
- ```css
37
- .route-fade { animation: routeFade 260ms var(--ease-geist) both; }
38
- @keyframes routeFade { from { opacity: 0 } to { opacity: 1 } }
39
- ```
40
-
41
- 260ms is the shortest crossfade that still reads as intentional; below ~200ms a full-viewport opacity change looks like a rendering artefact (law 3 caps it). Opacity only, because a directional transition implies a spatial relationship between routes, and the site map (home, experience, writing, playground) is flat.
36
+ The rule is `.route-fade` in `glass/motion/assets/motion-tokens.css`. 260ms is the shortest crossfade that still reads as intentional; below ~200ms a full-viewport opacity change looks like a rendering artefact (law 3 caps it). Opacity only, because a directional transition implies a spatial relationship between routes, and the site map (home, experience, writing, playground) is flat.
42
37
 
43
38
  Keying the wrapper on the pathname replays the CSS animation on every navigation:
44
39
 
@@ -50,33 +45,9 @@ Keying the wrapper on the pathname replays the CSS animation on every navigation
50
45
 
51
46
  ## Why the theme crossfade breaks both rules
52
47
 
53
- ```css
54
- @media (prefers-reduced-motion: no-preference) {
55
- :root.theme-transition,
56
- :root.theme-transition *,
57
- :root.theme-transition *::before,
58
- :root.theme-transition *::after {
59
- transition: background-color 400ms ease, color 400ms ease, border-color 400ms ease,
60
- fill 400ms ease, stroke 400ms ease, box-shadow 400ms ease !important;
61
- }
62
- }
63
- ```
64
-
65
48
  **400ms, longest on the ladder**: every surface, text colour, border, icon fill and shadow at once. At 150ms it reads as a jarring flash; at 400ms, as the lights coming up. **Plain `ease`, not the house curve**, because colour has no mass (law 5).
66
49
 
67
- The class is applied only on an explicit toggle, then removed:
68
-
69
- ```ts
70
- export function setThemeChoice(choice: ThemeChoice): void {
71
- try { localStorage.setItem(KEY, choice); } catch { /* won't persist */ }
72
- const root = document.documentElement;
73
- root.classList.add("theme-transition");
74
- applyTheme(choice);
75
- window.setTimeout(() => root.classList.remove("theme-transition"), 440);
76
- }
77
- ```
78
-
79
- It exists for ~440ms, the duration plus a 40ms margin, and its `!important` beats every component's own `transition-colors duration-150` for that window. Why it's scoped this way is in `domains/motion/patterns.md`, theme cross-fade.
50
+ The `.theme-transition` rule is in `glass/motion/assets/motion-tokens.css`; `setThemeChoice` in `glass/color/assets/theme.ts` adds it only on an explicit toggle and removes it after ~440ms, the duration plus a 40ms margin, so its `!important` beats every component's own `transition-colors duration-150` for that window only. Why it's scoped this way is in `domains/motion/patterns.md`, theme cross-fade.
80
51
 
81
52
  ## The overshoot in the house curve
82
53
 
@@ -204,7 +204,7 @@ export const detailFade = (on: boolean) =>
204
204
 
205
205
  ## 14. Set pieces play once
206
206
 
207
- Two set pieces, each once a session (law 15): the opening screen, and a summary panel's first reveal.
207
+ Two set pieces, each once a session (law 15): the opening screen, and a summary panel's first reveal. The first open runs each line at 340ms, its children staggered 90ms after a 60ms delay; every later open runs 160ms with no stagger, and reduced motion runs none.
208
208
 
209
209
  ```ts
210
210
  // Once a session, per item. The theatre is a welcome; a re-run is a delay.
@@ -234,8 +234,8 @@ Collapsibles animate height and stay mounted (law 11), so the content stays in t
234
234
 
235
235
  ## Assets
236
236
 
237
- - `glass/motion/assets/motion-tokens.css` — easing curve, duration ladder, `.reveal`, route fade, theme crossfade.
238
- - `glass/motion/assets/motion-presets.ts` — every spring and tween above, plus reduced-motion substitutes.
237
+ - `glass/motion/assets/motion-tokens.css` — easing curve, state durations, `.reveal`, route fade, theme crossfade.
238
+ - `glass/motion/assets/motion-presets.ts` — the springs, the close and the icon-fill fades.
239
239
  - `glass/motion/assets/DropMenu.tsx` — the squash-and-stretch menu.
240
240
  - `glass/motion/assets/useScrollTick.ts` — rAF-throttled scroll with hysteresis.
241
241
  - `glass/motion/assets/useReveal.ts` — the IntersectionObserver hook and its guards.
@@ -60,13 +60,7 @@ One variable turns a blur-crossfade into a plain crossfade; `blur(0px)` → `blu
60
60
 
61
61
  ## In CSS
62
62
 
63
- The `.reveal` rule is in `glass/motion/assets/motion-tokens.css`; the theme crossfade's opt-in block is in `glass/motion/easing-and-duration.md`. The route fade:
64
-
65
- ```css
66
- @media (prefers-reduced-motion: reduce) {
67
- .route-fade { animation: none; }
68
- }
69
- ```
63
+ The `.reveal` reset, the route fade's `animation: none` and the theme crossfade's `no-preference` opt-in are all in `glass/motion/assets/motion-tokens.css`.
70
64
 
71
65
  ### Tailwind's `motion-reduce:` variant
72
66
 
@@ -18,40 +18,11 @@ const ANCHOR = "scroll-mt-28"; // 112px — matched to READING_LINE = 120
18
18
 
19
19
  Every heading carries `scroll-mt-28`, so a jump lands it 112px below the viewport top, just above the 120px line.
20
20
 
21
- ### Deep links without the router
21
+ Deep links scroll with `scrollIntoView({ behavior: reduce ? "auto" : "smooth" })` and set the hash with `history.replaceState` (`domains/motion/performance.md`, scroll spies).
22
22
 
23
- ```tsx
24
- el.scrollIntoView({ behavior: reduce ? "auto" : "smooth", block: "start" });
25
- window.history.replaceState(null, "", `#${id}`);
26
- ```
27
-
28
- The URL stays copyable without a second scroll fighting the first.
29
-
30
- ## The `1fr` ↔ `0fr` grid trick, in Tailwind
31
-
32
- Collapsing a region to zero width without animating `width`:
33
-
34
- ```tsx
35
- <li
36
- className="grid transition-[grid-template-columns] duration-300 ease-geist motion-reduce:transition-none"
37
- style={{ gridTemplateColumns: expanded ? "1fr" : "0fr" }}
38
- >
39
- <div className="flex min-w-0 items-center overflow-hidden">{children}</div>
40
- </li>
41
- ```
42
-
43
- `min-w-0` on the child is required (flex children default to `min-width: auto` and won't shrink below their content); `overflow-hidden` clips during the collapse.
44
-
45
- ## `backdrop-filter` and transforms
46
-
47
- The animating wrapper and the glass surface are different elements (law 13):
48
-
49
- ```tsx
50
- <DropMenu open={open}> {/* animates scale + y */}
51
- <ul className="glass-panel …"> {/* carries the blur, never transformed */}
52
- ```
23
+ ## The dock's collapse
53
24
 
54
- Where the panel itself must animate, carry more of the effect in the tint than the blur: `glass-panel` sits at 97% opacity because its blur is compromised while it animates.
25
+ The dock's icon row collapses on `grid-template-columns` `1fr` ↔ `0fr`, 300ms on the house curve, with `min-w-0 overflow-hidden` on the child (the technique: `domains/motion/performance.md`, what to animate). The menu's wrapper/surface split is §3 of `glass/depth/guide.md`.
55
26
 
56
27
  ## Timers
57
28
 
@@ -74,73 +74,20 @@ export const playClick = () => tok(900, 0.14, 0.13);
74
74
 
75
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
76
 
77
- ## The autoplay policy
77
+ ## Wiring
78
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.
79
+ The rules — arm on every activation gesture, mouse-only and deduplicated hover, a dense-surface opt-out, a persisted mute checked at playback — are `domains/motion/patterns.md`, sound. Glass's values for them:
80
80
 
81
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();
82
+ const UNLOCK_EVENTS = ["pointerdown", "keydown", "touchstart"] as const; // resume() is idempotent
83
+ const SELECTOR = "a, button, [role='button'], summary"; // one delegated listener set
84
+ if (el && !el.closest("[data-quiet-hover]")) playHover(); // image wall, filmstrip
141
85
  ```
142
86
 
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".
87
+ - Every playback path checks `c.state !== "running"` and fails silently, so a page merely scrolled past makes no sound.
88
+ - The site shipped `pointerdown` alone for months; mouse-only readers and keyboard readers heard nothing, and testers, who click within seconds, never noticed.
89
+ - `summary` is in the selector because a `<details>` disclosure is a real control.
90
+ - Mute persists as `localStorage["ui-sound"] = "off" | "on"`, from an `aria-pressed` control in the nav's settings box beside the theme toggle.
144
91
 
145
92
  ## Sound as a phrase
146
93
 
@@ -166,30 +113,10 @@ export const playLanding = () => tok(587, 0.26, 0.16);
166
113
 
167
114
  It's still a set piece, not a texture — once a session. See §14 of `glass/motion/guide.md`.
168
115
 
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
116
  ## Should the default be on?
186
117
 
187
118
  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
119
 
189
120
  ## What's deliberately absent
190
121
 
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.
122
+ **Haptics**: `navigator.vibrate()` would fire only on Android, and a feedback channel on one platform is worse than none. **Pitch variation** by element type or position, which turns an ambient texture into something the user tries to interpret. And no sound beyond hover, click and the one greeting phrase.
@@ -1,33 +1,12 @@
1
1
  /**
2
2
  * Long-form prose renderer — the reading half of the type system.
3
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:
4
+ * Two sizes: 16px headings, 14px for everything read, so the 14px blocks
5
+ * each separate on a different axis (the table in glass/type/long-form-prose.md).
8
6
  *
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.
7
+ * Spacing: 40px above a heading, 12px below; the keyline is the one
8
+ * symmetric block (glass/type/long-form-prose.md). Text tiers come from
9
+ * glass/color/guide.md; `reveal` from glass/motion.
31
10
  */
32
11
 
33
12
  import { Fragment, type ReactNode } from "react";
@@ -188,23 +167,9 @@ export function Prose({ blocks }: { blocks: Block[] }) {
188
167
  }
189
168
 
190
169
  /**
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.
170
+ * Inline markdown for **strong** (500 + primary), *em* (muted, no slant) and
171
+ * [links](/there) — §5 of glass/type/guide.md. Keep strong to a few words:
172
+ * at this size it is the keyline's weight.
208
173
  */
209
174
  const TOKENS = /(\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^)\s]+\))/g;
210
175
  const LINK = /^\[([^\]]+)\]\(([^)\s]+)\)$/;