trykeyline 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +18 -0
- package/LICENSE +37 -0
- package/README.md +117 -2
- package/bin/keyline.mjs +280 -0
- package/package.json +38 -4
- package/plugins/keyline/.claude-plugin/plugin.json +14 -0
- package/plugins/keyline/skills/keyline/SKILL.md +57 -0
- package/plugins/keyline/skills/keyline/commands/audit.md +25 -0
- package/plugins/keyline/skills/keyline/commands/color.md +7 -0
- package/plugins/keyline/skills/keyline/commands/critique.md +43 -0
- package/plugins/keyline/skills/keyline/commands/depth.md +7 -0
- package/plugins/keyline/skills/keyline/commands/init.md +15 -0
- package/plugins/keyline/skills/keyline/commands/layout.md +7 -0
- package/plugins/keyline/skills/keyline/commands/motion.md +7 -0
- package/plugins/keyline/skills/keyline/commands/patterns.md +7 -0
- package/plugins/keyline/skills/keyline/commands/polish.md +19 -0
- package/plugins/keyline/skills/keyline/commands/refine.md +29 -0
- package/plugins/keyline/skills/keyline/commands/sync.md +17 -0
- package/plugins/keyline/skills/keyline/commands/type.md +7 -0
- package/plugins/keyline/skills/keyline/domains/color/deriving-a-ramp.md +144 -0
- package/plugins/keyline/skills/keyline/domains/color/guide.md +243 -0
- package/plugins/keyline/skills/keyline/domains/color/status-and-exceptions.md +174 -0
- package/plugins/keyline/skills/keyline/domains/color/theming-mechanics.md +121 -0
- package/plugins/keyline/skills/keyline/domains/depth/elevation-recipes.md +191 -0
- package/plugins/keyline/skills/keyline/domains/depth/fallbacks-and-performance.md +132 -0
- package/plugins/keyline/skills/keyline/domains/depth/guide.md +255 -0
- package/plugins/keyline/skills/keyline/domains/depth/translucent-materials.md +159 -0
- package/plugins/keyline/skills/keyline/domains/layout/forks-gating-and-disclosures.md +201 -0
- package/plugins/keyline/skills/keyline/domains/layout/guide.md +284 -0
- package/plugins/keyline/skills/keyline/domains/layout/measure-and-rhythm.md +193 -0
- package/plugins/keyline/skills/keyline/domains/layout/radius-lines-and-layers.md +122 -0
- package/plugins/keyline/skills/keyline/domains/motion/guards.md +162 -0
- package/plugins/keyline/skills/keyline/domains/motion/guide.md +234 -0
- package/plugins/keyline/skills/keyline/domains/motion/ladder-and-curves.md +139 -0
- package/plugins/keyline/skills/keyline/domains/motion/patterns.md +196 -0
- package/plugins/keyline/skills/keyline/domains/motion/performance.md +121 -0
- package/plugins/keyline/skills/keyline/domains/patterns/actions-and-forms.md +204 -0
- package/plugins/keyline/skills/keyline/domains/patterns/agents-and-live-states.md +111 -0
- package/plugins/keyline/skills/keyline/domains/patterns/collections-and-dashboards.md +131 -0
- package/plugins/keyline/skills/keyline/domains/patterns/copy.md +123 -0
- package/plugins/keyline/skills/keyline/domains/patterns/guide.md +216 -0
- package/plugins/keyline/skills/keyline/domains/patterns/iteration.md +78 -0
- package/plugins/keyline/skills/keyline/domains/type/display-tier.md +108 -0
- package/plugins/keyline/skills/keyline/domains/type/guide.md +251 -0
- package/plugins/keyline/skills/keyline/domains/type/ladders.md +169 -0
- package/plugins/keyline/skills/keyline/domains/type/numerals-and-loading.md +107 -0
- package/plugins/keyline/skills/keyline/domains/type/prose-and-emphasis.md +125 -0
- package/plugins/keyline/skills/keyline/domains/type/type-audit.md +90 -0
- package/plugins/keyline/skills/keyline/glass/color/assets/color-tokens.css +319 -0
- package/plugins/keyline/skills/keyline/glass/color/assets/theme.ts +119 -0
- package/plugins/keyline/skills/keyline/glass/color/guide.md +222 -0
- package/plugins/keyline/skills/keyline/glass/color/intent-ramp.md +136 -0
- package/plugins/keyline/skills/keyline/glass/color/theming.md +112 -0
- package/plugins/keyline/skills/keyline/glass/depth/assets/GlassPanel.tsx +171 -0
- package/plugins/keyline/skills/keyline/glass/depth/assets/glass.css +322 -0
- package/plugins/keyline/skills/keyline/glass/depth/blur-and-tint.md +50 -0
- package/plugins/keyline/skills/keyline/glass/depth/depth-cues.md +87 -0
- package/plugins/keyline/skills/keyline/glass/depth/guide.md +216 -0
- package/plugins/keyline/skills/keyline/glass/depth/performance-and-fallbacks.md +37 -0
- package/plugins/keyline/skills/keyline/glass/layout/adaptive-presentations.md +231 -0
- package/plugins/keyline/skills/keyline/glass/layout/assets/SectionNav.tsx +244 -0
- package/plugins/keyline/skills/keyline/glass/layout/assets/layout-tokens.css +199 -0
- package/plugins/keyline/skills/keyline/glass/layout/guide.md +258 -0
- package/plugins/keyline/skills/keyline/glass/layout/measure-and-rhythm.md +134 -0
- package/plugins/keyline/skills/keyline/glass/layout/radius-and-elevation.md +102 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/DropMenu.tsx +106 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/motion-presets.ts +210 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/motion-tokens.css +176 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/useReveal.ts +65 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/useScrollTick.ts +183 -0
- package/plugins/keyline/skills/keyline/glass/motion/easing-and-duration.md +108 -0
- package/plugins/keyline/skills/keyline/glass/motion/guide.md +286 -0
- package/plugins/keyline/skills/keyline/glass/motion/reduced-motion.md +81 -0
- package/plugins/keyline/skills/keyline/glass/motion/scroll-performance.md +58 -0
- package/plugins/keyline/skills/keyline/glass/motion/shared-element.md +161 -0
- package/plugins/keyline/skills/keyline/glass/motion/sound.md +192 -0
- package/plugins/keyline/skills/keyline/glass/motion/springs.md +51 -0
- package/plugins/keyline/skills/keyline/glass/type/assets/prose.tsx +268 -0
- package/plugins/keyline/skills/keyline/glass/type/assets/type-tokens.css +414 -0
- package/plugins/keyline/skills/keyline/glass/type/guide.md +222 -0
- package/plugins/keyline/skills/keyline/glass/type/long-form-prose.md +151 -0
- package/plugins/keyline/skills/keyline/glass/type/scale.md +69 -0
- package/plugins/keyline/skills/keyline/glass/type/weight-and-tracking.md +61 -0
- package/plugins/keyline/skills/keyline/scripts/check/css.mjs +112 -0
- package/plugins/keyline/skills/keyline/scripts/check/design.mjs +67 -0
- package/plugins/keyline/skills/keyline/scripts/check/files.mjs +44 -0
- package/plugins/keyline/skills/keyline/scripts/check/markup.mjs +149 -0
- package/plugins/keyline/skills/keyline/scripts/check/rules.mjs +584 -0
- package/plugins/keyline/skills/keyline/scripts/check.mjs +294 -0
- package/plugins/keyline/skills/keyline/scripts/contrast.mjs +153 -0
- package/plugins/keyline/skills/keyline/scripts/scan.mjs +340 -0
- package/plugins/keyline/skills/keyline/system/design-md.md +136 -0
- package/plugins/keyline/skills/keyline/system/detection.md +53 -0
- package/plugins/keyline/skills/keyline/system/guide.md +101 -0
- package/plugins/keyline/skills/keyline/system/presets.md +58 -0
- package/plugins/keyline/skills/keyline/system/roles.md +150 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Scroll and performance
|
|
2
|
+
|
|
3
|
+
Glass's values for the scroll and compositor rules. The scroll shape, hysteresis, the touch gate, scroll spies and what to animate are laws 12 and 13 and `domains/motion/performance.md`; the React hooks are in `glass/motion/assets/useScrollTick.ts`.
|
|
4
|
+
|
|
5
|
+
## The dock
|
|
6
|
+
|
|
7
|
+
The dock (`useCollapseOnScroll`) collapses on scroll-down with the domain's dead zone — 6px of intent either way, always expanded within 24px of the top and at `scrollHeight - 2`. It collapses only where `(hover: hover) and (pointer: fine)` matches; touch gets its own presentation (the Glass layout guide, `glass/layout/guide.md`, on adaptive presentations).
|
|
8
|
+
|
|
9
|
+
## Scroll spy
|
|
10
|
+
|
|
11
|
+
The spy is `useActiveSection`. Its reading line sits 120px below the viewport top (`READING_LINE = 120`), with the bottom clamp.
|
|
12
|
+
|
|
13
|
+
### Matching `scroll-mt`
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
const ANCHOR = "scroll-mt-28"; // 112px — matched to READING_LINE = 120
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Every heading carries `scroll-mt-28`, so a jump lands it 112px below the viewport top, just above the 120px line.
|
|
20
|
+
|
|
21
|
+
### Deep links without the router
|
|
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
|
+
```
|
|
53
|
+
|
|
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.
|
|
55
|
+
|
|
56
|
+
## Timers
|
|
57
|
+
|
|
58
|
+
The hover-intent timer is `closeTimer`, cleared on unmount and reset on `pathname` (law 14, `domains/motion/patterns.md`).
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# The shared-element highlight
|
|
2
|
+
|
|
3
|
+
The signature interaction of this system: one highlight rectangle and one tooltip per container, animating position and size to whichever child is hovered or focused. Why it beats per-item backgrounds, and when it's the wrong tool, is law 8; the details that keep it from flickering are in `domains/motion/patterns.md`, the shared highlight.
|
|
4
|
+
|
|
5
|
+
## The implementation
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
type Hover = { x: number; y: number; w: number; h: number; cx: number; label: string };
|
|
9
|
+
|
|
10
|
+
const [hovered, setHovered] = useState<Hover | null>(null);
|
|
11
|
+
|
|
12
|
+
const enter = (el: HTMLElement, label: string) =>
|
|
13
|
+
setHovered({
|
|
14
|
+
x: el.offsetLeft,
|
|
15
|
+
y: el.offsetTop,
|
|
16
|
+
w: el.offsetWidth,
|
|
17
|
+
h: el.offsetHeight,
|
|
18
|
+
cx: el.offsetLeft + el.offsetWidth / 2, // centre, for the tooltip
|
|
19
|
+
label,
|
|
20
|
+
});
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```tsx
|
|
24
|
+
<ul className="relative isolate flex items-center …" onMouseLeave={() => setHovered(null)}>
|
|
25
|
+
|
|
26
|
+
{/* The highlight — behind the icons, inside its own stacking context */}
|
|
27
|
+
<m.div
|
|
28
|
+
aria-hidden
|
|
29
|
+
className="pointer-events-none absolute left-0 top-0 -z-10 rounded-[12px] bg-highlight"
|
|
30
|
+
initial={false}
|
|
31
|
+
animate={hovered
|
|
32
|
+
? { x: hovered.x, y: hovered.y, width: hovered.w, height: hovered.h, opacity: 1 }
|
|
33
|
+
: { opacity: 0 }}
|
|
34
|
+
transition={tween}
|
|
35
|
+
/>
|
|
36
|
+
|
|
37
|
+
{items.map((item) => (
|
|
38
|
+
<a
|
|
39
|
+
key={item.label}
|
|
40
|
+
onMouseEnter={(e) => enter(e.currentTarget, item.label)}
|
|
41
|
+
onFocus={(e) => enter(e.currentTarget, item.label)}
|
|
42
|
+
…
|
|
43
|
+
/>
|
|
44
|
+
))}
|
|
45
|
+
</ul>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Pair `onFocus` with `onBlur` where the list can lose focus without the pointer leaving.
|
|
49
|
+
|
|
50
|
+
## The tooltip variant
|
|
51
|
+
|
|
52
|
+
Same state, same spring, one axis:
|
|
53
|
+
|
|
54
|
+
```tsx
|
|
55
|
+
<m.div
|
|
56
|
+
aria-hidden
|
|
57
|
+
className="pointer-events-none absolute bottom-full left-0 mb-2"
|
|
58
|
+
initial={false}
|
|
59
|
+
animate={hovered ? { x: hovered.cx, opacity: 1 } : { opacity: 0 }}
|
|
60
|
+
transition={tween}
|
|
61
|
+
>
|
|
62
|
+
<div className="-translate-x-1/2 whitespace-nowrap rounded-md bg-gray-1000 px-2 py-1
|
|
63
|
+
text-[11px] leading-none text-background-100
|
|
64
|
+
shadow-[0_4px_10px_-4px_rgba(0,0,0,0.3)]">
|
|
65
|
+
{hovered?.label}
|
|
66
|
+
</div>
|
|
67
|
+
</m.div>
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`bottom-full mb-2` places it above the container at any height; flip to `top-full mt-2` when the container is itself a dropdown, so the tooltip doesn't cover what opened it. It's `aria-hidden` because every item already carries an `aria-label` with the same text.
|
|
71
|
+
|
|
72
|
+
## The vertical list variant
|
|
73
|
+
|
|
74
|
+
For rows rather than icons only the container class changes: items carry the padding, so the bar is the same box everywhere, and a negative horizontal margin lets it bleed past the text column:
|
|
75
|
+
|
|
76
|
+
```tsx
|
|
77
|
+
<HighlightList
|
|
78
|
+
className="reveal -mx-3 flex flex-col text-[14px] leading-[1.55]"
|
|
79
|
+
itemClassName="group relative flex gap-10 px-3 py-2.5"
|
|
80
|
+
items={…}
|
|
81
|
+
/>
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`-mx-3` against `px-3` on each item: the bar extends 12px past the measure on both sides while the text sits exactly on it.
|
|
85
|
+
|
|
86
|
+
Here the bar uses `bg-gray-100` rather than `bg-highlight`: in a list on the page surface the subtle-surface token is right; in floating glass chrome, the elevated one.
|
|
87
|
+
|
|
88
|
+
## Interaction with active state
|
|
89
|
+
|
|
90
|
+
The active item shows a static background and drops it while the dock is hovered (`domains/motion/patterns.md`, current versus hovered):
|
|
91
|
+
|
|
92
|
+
```tsx
|
|
93
|
+
isActive
|
|
94
|
+
? `text-gray-1000 ${dockHover ? "" : "bg-highlight shadow-[0_2px_2px_rgba(0,0,0,0.04)]"}`
|
|
95
|
+
: "text-gray-600 hover:text-gray-1000"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The glyph fills both when current and when hovered (§13 of `glass/motion/guide.md`), and the chip tells the two apart when both are on screen.
|
|
99
|
+
|
|
100
|
+
## Escalating to the GPU
|
|
101
|
+
|
|
102
|
+
What broke the DOM approach: a gallery with a masonry wall of ~100 tiles and a "roll" with one photo open above a filmstrip, the same photograph travelling between them. Three things break at once:
|
|
103
|
+
|
|
104
|
+
1. **Aspect ratios differ.** Consecutive photographs are 1:1 then 16:9; one `<img>` animated between the boxes distorts for the whole trip.
|
|
105
|
+
2. **The wall is expensive to re-lay-out.** Layout on 100 tiles is not a per-frame budget.
|
|
106
|
+
3. **The endpoints exist at different moments.** One rect is measurable only while the outgoing layout is rendered, the other only after the incoming one commits.
|
|
107
|
+
|
|
108
|
+
One viewport-sized canvas above the gallery draws a single photo into an arbitrary screen rect, so the trip is four numbers in a uniform and costs no reflow at any size:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
export type Rect = { x: number; y: number; width: number; height: number };
|
|
112
|
+
|
|
113
|
+
/** Straight-line interpolation. Equal-aspect endpoints keep their aspect throughout. */
|
|
114
|
+
export function lerpRect(a: Rect, b: Rect, t: number): Rect { /* … */ }
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The rules are law 13 and `domains/motion/performance.md`, escalating to the GPU. Here the wall stays DOM and the open photo is `alt=""`, its labels in the strip beside it.
|
|
118
|
+
|
|
119
|
+
**Failing returns `null`:**
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
export function createPhotoStage(canvas: HTMLCanvasElement): PhotoStage | null {
|
|
123
|
+
try {
|
|
124
|
+
/* … */
|
|
125
|
+
// A failed shader link only warns in some libraries; the throw lands on the
|
|
126
|
+
// first draw. Check the artefact that proves setup completed.
|
|
127
|
+
if (!program.uniformLocations) return null;
|
|
128
|
+
/* … */
|
|
129
|
+
} catch {
|
|
130
|
+
return null; // No WebGL, or a context that can't be set up.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
**Carry the reading position across** (law 8). Before the switch, find the tile nearest the viewport centre and open the other layout on *that* item, from *that* rect:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
// Read while the OUTGOING layout is still rendered — from the layout store's
|
|
139
|
+
// synchronous subscriber, before React commits the incoming one.
|
|
140
|
+
export function centreFrame(): { id: string; rect: Rect } | null {
|
|
141
|
+
const midX = window.innerWidth / 2, midY = window.innerHeight / 2;
|
|
142
|
+
let best = null;
|
|
143
|
+
for (const el of document.querySelectorAll<HTMLImageElement>("img[data-frame]")) {
|
|
144
|
+
const r = el.getBoundingClientRect();
|
|
145
|
+
if (r.width <= 0 || r.bottom <= 0 || r.top >= window.innerHeight) continue;
|
|
146
|
+
const d = Math.hypot(r.left + r.width / 2 - midX, r.top + r.height / 2 - midY);
|
|
147
|
+
if (!best || d < best.d) best = { id: el.dataset.frame!, rect: r, d };
|
|
148
|
+
}
|
|
149
|
+
return best;
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
**Each end keeps its own shape**: two rects around a shared centre, each keeping its aspect through the linear lerp. The GPU path then matches the `<img>` fallback exactly: two absolutely-centred images, each at its own aspect, crossfading.
|
|
154
|
+
|
|
155
|
+
### Implementation notes that cost real time to find
|
|
156
|
+
|
|
157
|
+
**Mipmaps.** A 2000px photo in a 355px tile samples one texel in five, so without a mip chain it shimmers as the rect moves sub-pixel. Use trilinear (`LINEAR_MIPMAP_LINEAR`) so crossing mip levels as the photo grows doesn't step.
|
|
158
|
+
|
|
159
|
+
**Fade the last pixel of the edge** with one `smoothstep` across a pixel's width, fading *alpha only* on a non-premultiplied context, or the edge darkens toward black instead of disappearing.
|
|
160
|
+
|
|
161
|
+
**Don't call `WEBGL_lose_context` on teardown.** React reuses the node across a remount (StrictMode does on every mount), and the next context silently fails to compile its shaders. Drop the textures and references; the context goes with the element.
|
|
@@ -0,0 +1,192 @@
|
|
|
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
|
+
let muted = localStorage.getItem("ui-sound") === "off";
|
|
173
|
+
|
|
174
|
+
export function setMuted(value: boolean): void {
|
|
175
|
+
muted = value;
|
|
176
|
+
try { localStorage.setItem("ui-sound", value ? "off" : "on"); } catch {}
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The mute control sits in the nav's settings box beside the theme toggle, with an `aria-pressed` state and a label.
|
|
181
|
+
|
|
182
|
+
## Should the default be on?
|
|
183
|
+
|
|
184
|
+
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.
|
|
185
|
+
|
|
186
|
+
## What's deliberately absent
|
|
187
|
+
|
|
188
|
+
**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.
|
|
189
|
+
|
|
190
|
+
**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.
|
|
191
|
+
|
|
192
|
+
**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.
|