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.
- package/.claude-plugin/marketplace.json +1 -1
- package/bin/keyline.mjs +2 -1
- package/package.json +1 -1
- package/plugins/keyline/.claude-plugin/plugin.json +1 -1
- package/plugins/keyline/plugin.json +1 -1
- package/plugins/keyline/skills/keyline/domains/motion/guide.md +1 -0
- package/plugins/keyline/skills/keyline/domains/patterns/guide.md +1 -1
- package/plugins/keyline/skills/keyline/domains/type/guide.md +1 -1
- package/plugins/keyline/skills/keyline/domains/type/ladders.md +1 -1
- package/plugins/keyline/skills/keyline/glass/color/assets/color-tokens.css +34 -145
- package/plugins/keyline/skills/keyline/glass/color/assets/theme.ts +10 -70
- package/plugins/keyline/skills/keyline/glass/color/guide.md +1 -1
- package/plugins/keyline/skills/keyline/glass/color/intent-ramp.md +2 -34
- package/plugins/keyline/skills/keyline/glass/color/theming.md +4 -65
- package/plugins/keyline/skills/keyline/glass/depth/assets/GlassPanel.tsx +14 -121
- package/plugins/keyline/skills/keyline/glass/depth/assets/glass.css +29 -188
- package/plugins/keyline/skills/keyline/glass/depth/depth-cues.md +1 -16
- package/plugins/keyline/skills/keyline/glass/depth/performance-and-fallbacks.md +3 -11
- package/plugins/keyline/skills/keyline/glass/layout/adaptive-presentations.md +5 -91
- package/plugins/keyline/skills/keyline/glass/layout/assets/SectionNav.tsx +3 -6
- package/plugins/keyline/skills/keyline/glass/layout/assets/layout-tokens.css +11 -140
- package/plugins/keyline/skills/keyline/glass/layout/guide.md +2 -8
- package/plugins/keyline/skills/keyline/glass/layout/measure-and-rhythm.md +0 -2
- package/plugins/keyline/skills/keyline/glass/layout/radius-and-elevation.md +1 -1
- package/plugins/keyline/skills/keyline/glass/motion/assets/DropMenu.tsx +4 -6
- package/plugins/keyline/skills/keyline/glass/motion/assets/motion-presets.ts +10 -184
- package/plugins/keyline/skills/keyline/glass/motion/assets/motion-tokens.css +21 -105
- package/plugins/keyline/skills/keyline/glass/motion/easing-and-duration.md +2 -31
- package/plugins/keyline/skills/keyline/glass/motion/guide.md +3 -3
- package/plugins/keyline/skills/keyline/glass/motion/reduced-motion.md +1 -7
- package/plugins/keyline/skills/keyline/glass/motion/scroll-performance.md +3 -32
- package/plugins/keyline/skills/keyline/glass/motion/sound.md +10 -83
- package/plugins/keyline/skills/keyline/glass/type/assets/prose.tsx +8 -43
- package/plugins/keyline/skills/keyline/glass/type/assets/type-tokens.css +46 -85
- package/plugins/keyline/skills/keyline/glass/type/guide.md +3 -3
- package/plugins/keyline/skills/keyline/glass/type/long-form-prose.md +9 -74
- package/plugins/keyline/skills/keyline/glass/type/scale.md +2 -6
- package/plugins/keyline/skills/keyline/glass/type/weight-and-tracking.md +4 -20
- package/plugins/keyline/skills/keyline/scripts/check/css.mjs +5 -14
- package/plugins/keyline/skills/keyline/scripts/check/design.mjs +3 -1
- package/plugins/keyline/skills/keyline/scripts/check/files.mjs +70 -26
- package/plugins/keyline/skills/keyline/scripts/check/markup.mjs +3 -3
- package/plugins/keyline/skills/keyline/scripts/check/rules.mjs +9 -1
- package/plugins/keyline/skills/keyline/scripts/check.mjs +2 -2
- package/plugins/keyline/skills/keyline/scripts/contrast.mjs +2 -0
- package/plugins/keyline/skills/keyline/scripts/scan.mjs +29 -45
- 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
|
-
/*
|
|
8
|
-
|
|
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
|
-
/*
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
--duration-
|
|
21
|
-
--duration-
|
|
22
|
-
--duration-
|
|
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` (
|
|
64
|
-
|
|
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
|
-
/*
|
|
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
|
-
/*
|
|
90
|
-
|
|
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
|
|
112
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
/*
|
|
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
|
-
|
|
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
|
|
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,
|
|
238
|
-
- `glass/motion/assets/motion-presets.ts` —
|
|
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`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
77
|
+
## Wiring
|
|
78
78
|
|
|
79
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
|
192
|
-
*
|
|
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]+)\)$/;
|