trykeyline 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +18 -0
- package/LICENSE +21 -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/plugin.json +22 -0
- package/plugins/keyline/skills/keyline/SKILL.md +47 -0
- package/plugins/keyline/skills/keyline/commands/audit.md +30 -0
- package/plugins/keyline/skills/keyline/commands/color.md +7 -0
- package/plugins/keyline/skills/keyline/commands/critique.md +20 -0
- package/plugins/keyline/skills/keyline/commands/depth.md +7 -0
- package/plugins/keyline/skills/keyline/commands/init.md +16 -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 +19 -0
- package/plugins/keyline/skills/keyline/commands/sync.md +15 -0
- package/plugins/keyline/skills/keyline/commands/type.md +7 -0
- package/plugins/keyline/skills/keyline/commands/ux-laws.md +26 -0
- package/plugins/keyline/skills/keyline/domains/color/deriving-a-ramp.md +144 -0
- package/plugins/keyline/skills/keyline/domains/color/guide.md +207 -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 +185 -0
- package/plugins/keyline/skills/keyline/domains/depth/fallbacks-and-performance.md +132 -0
- package/plugins/keyline/skills/keyline/domains/depth/guide.md +226 -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 +259 -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 +198 -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 +174 -0
- package/plugins/keyline/skills/keyline/domains/patterns/iteration.md +70 -0
- package/plugins/keyline/skills/keyline/domains/type/display-tier.md +108 -0
- package/plugins/keyline/skills/keyline/domains/type/guide.md +227 -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 +87 -0
- package/plugins/keyline/skills/keyline/glass/color/assets/color-tokens.css +299 -0
- package/plugins/keyline/skills/keyline/glass/color/assets/theme.ts +119 -0
- package/plugins/keyline/skills/keyline/glass/color/guide.md +195 -0
- package/plugins/keyline/skills/keyline/glass/color/intent-ramp.md +136 -0
- package/plugins/keyline/skills/keyline/glass/color/theming.md +106 -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 +147 -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 +201 -0
- package/plugins/keyline/skills/keyline/glass/layout/guide.md +259 -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 +103 -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 +255 -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 +195 -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 +416 -0
- package/plugins/keyline/skills/keyline/glass/type/guide.md +220 -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 +297 -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 +97 -0
- package/plugins/keyline/skills/keyline/system/presets.md +58 -0
- package/plugins/keyline/skills/keyline/system/roles.md +150 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Easing and duration
|
|
2
|
+
|
|
3
|
+
The glass ladder in code, and the reasons behind its values. Why duration tracks area, and why a hover over ~150ms lags across a list, are in `domains/motion/ladder-and-curves.md`; the tiers themselves are in §1 of `glass/motion/guide.md`.
|
|
4
|
+
|
|
5
|
+
## The 100ms tier
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
className="transition-opacity duration-100 ease-out hover:opacity-60"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Text links fade to 60% opacity in 100ms with `ease-out`, not the house curve: opacity clamps at 1, so the overshoot is a wasted tail (law 5). 100ms is at the edge of perceptible, which is the point: a hover response should feel like it *already happened*.
|
|
12
|
+
|
|
13
|
+
## The 150ms tier does most of the work
|
|
14
|
+
|
|
15
|
+
Colour, background, small transforms, icon swaps, the marker that grows in a table of contents:
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
"transition-colors duration-150 ease-geist"
|
|
19
|
+
"transition-[width,background-color] duration-150 ease-geist"
|
|
20
|
+
"transition-[color,background-color,box-shadow,transform] duration-150 ease-geist"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Every property enumerated, never `transition-all` (law 13).
|
|
24
|
+
|
|
25
|
+
## Where 200ms is right
|
|
26
|
+
|
|
27
|
+
The gap between 150 and 200 maps to *whether other things move too*:
|
|
28
|
+
|
|
29
|
+
- A chevron rotating 180° in a disclosure: the rotation is small, but the panel opening below it isn't.
|
|
30
|
+
- A hover card scaling in: a new object with content, appearing over existing content.
|
|
31
|
+
- The menu glyph's two bars rotating into a cross: two elements moving in opposition read as one compound gesture.
|
|
32
|
+
- A hover wash across a whole card or panel: the 100ms that suits a link reads as a cut over that much area, and a pointer settles on a card rather than sweeping a column of them.
|
|
33
|
+
|
|
34
|
+
## Why route transitions are 260ms
|
|
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.
|
|
42
|
+
|
|
43
|
+
Keying the wrapper on the pathname replays the CSS animation on every navigation:
|
|
44
|
+
|
|
45
|
+
```tsx
|
|
46
|
+
<div key={pathname} className="route-fade">
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`both` on the fill mode holds the `from` state before the animation starts, preventing a one-frame flash of fully opaque content.
|
|
50
|
+
|
|
51
|
+
## Why the theme crossfade breaks both rules
|
|
52
|
+
|
|
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
|
+
**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
|
+
|
|
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.
|
|
80
|
+
|
|
81
|
+
## The overshoot in the house curve
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
cubic-bezier(0.175, 0.885, 0.32, 1.1)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The `1.1` in the final control point takes the value past its target near the end and back: a small (~10%), brief overshoot. That number makes 150ms colour transitions feel like something *happened* rather than was *set*. Character beyond it belongs to springs (law 5).
|
|
88
|
+
|
|
89
|
+
## The filter curve
|
|
90
|
+
|
|
91
|
+
The class of properties the house curve can't have, and why, is law 5. Glass's monotone curve:
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
// Lines staggering in behind a blur: filter: blur(4px) → blur(0px).
|
|
95
|
+
// --ease-geist would pass through a NEGATIVE radius to settle back.
|
|
96
|
+
const EASE = [0.16, 1, 0.3, 1] as const; // expo-out: decelerates hard, never exceeds 1
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Durations that legitimately sit off the ladder
|
|
100
|
+
|
|
101
|
+
The pair that shows the discipline, from one gallery component (§1 of `glass/motion/guide.md`):
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
const DISSOLVE = 0.62; // stepping between photographs — the subject
|
|
105
|
+
const FLIGHT = 0.42; // the same photo moving between layouts — a state change
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The dissolve is the content being watched, so it gets 620ms to read as a dissolve rather than a cut; the flight only has to be *followable*, with the photograph on screen at both ends, so it stays inside the overlay tier. The greeting's gating is §14 of the guide.
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# Glass motion
|
|
2
|
+
|
|
3
|
+
> **Glass preset.** The motion system running on [modimihir.com](https://modimihir.com), with its real values, meant to be copied whole. Read it beside `domains/motion/guide.md`: the laws live there, and this guide holds only what glass sets — its durations, curve, springs and tokens, its recipes and code, and the decisions behind them. Under any other preset it is only a worked example.
|
|
4
|
+
|
|
5
|
+
State is CSS transitions; springs and presence use [Motion](https://motion.dev). "Law" below means a motion law in the domain guide.
|
|
6
|
+
|
|
7
|
+
## 1. The duration ladder
|
|
8
|
+
|
|
9
|
+
Tiers by area, not importance (law 2):
|
|
10
|
+
|
|
11
|
+
| Tier | Duration | What moves | Easing |
|
|
12
|
+
|---|---|---|---|
|
|
13
|
+
| Instant | 0ms | Press feedback, focus rings | none |
|
|
14
|
+
| Micro | 100ms | Link opacity on hover | `ease-out` |
|
|
15
|
+
| State | **150ms** | Colour, background, small transforms, icon swaps | the house curve |
|
|
16
|
+
| Popover | **200ms** | A disclosure chevron, a hover card, a menu glyph | the house curve |
|
|
17
|
+
| Overlay | **300ms** | Reveal-on-scroll, a collapsing region | the house curve |
|
|
18
|
+
| Route | 260ms | An incoming page fading in | the house curve |
|
|
19
|
+
| Theme | 400ms | Every colour on the page at once | plain `ease` |
|
|
20
|
+
|
|
21
|
+
Three numbers carry almost everything: **150 / 200 / 300**. **Route is 260ms**, not 300: 260 is the shortest crossfade on the critical path that doesn't read as a flicker. **Theme is 400ms with plain `ease`**, the largest change on the longest duration, and colour gets no overshoot. Nothing on the ladder exceeds 400ms.
|
|
22
|
+
|
|
23
|
+
**Hover takes its tier by area:** a link's opacity 100ms, a wash across a whole card 200ms.
|
|
24
|
+
|
|
25
|
+
### Two things that sit off the ladder
|
|
26
|
+
|
|
27
|
+
Glass uses two of law 4's classes:
|
|
28
|
+
|
|
29
|
+
**The subject of the moment.** Stepping between gallery photographs dissolves over **620ms**: the photograph *is* the content, and a noise-thresholded dissolve needs time to read as a dissolve rather than a cut. The same photo's flight between two layouts stays at 420ms, in the overlay tier, because that *is* a state change with the photo on screen at both ends. **Same component, two durations, decided by whether the motion is the subject or the report.**
|
|
30
|
+
|
|
31
|
+
**A once-a-session set piece.** The opening screen runs 560ms in, holds 1400ms, about 2.5 seconds in all, defensible only because it plays once per tab (§14).
|
|
32
|
+
|
|
33
|
+
## 2. One easing curve
|
|
34
|
+
|
|
35
|
+
```css
|
|
36
|
+
--ease-geist: cubic-bezier(0.175, 0.885, 0.32, 1.1);
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
One curve for the whole site. The `1.1` end control point overshoots slightly and settles back, physical but not bouncy. The exceptions:
|
|
40
|
+
|
|
41
|
+
- **100ms opacity hovers and icon fills** (§13): `ease-out`. Opacity entrances (`.reveal`, the route fade) keep the house curve on purpose: on opacity the overshoot clamps at 1.
|
|
42
|
+
- **The theme crossfade**: plain `ease`.
|
|
43
|
+
- **Anything spring-driven**: no bezier at all.
|
|
44
|
+
- **Any track that also drives a filter**, or any property floored at zero (law 5): expo-out, `cubic-bezier(0.16, 1, 0.3, 1)`, which never exceeds 1. It is the same intent in a curve legal for the property, not a second character.
|
|
45
|
+
|
|
46
|
+
→ The tiers in code, the theme crossfade, and the overshoot: `glass/motion/easing-and-duration.md`
|
|
47
|
+
|
|
48
|
+
## 3. Springs, tuned by character
|
|
49
|
+
|
|
50
|
+
Three springs, each with a job (law 6), plus the close:
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
// Highlight or tooltip re-targeting: moving, not arriving — almost no bounce.
|
|
54
|
+
const SLIDE = { type: "spring", bounce: 0.2, duration: 0.4,
|
|
55
|
+
opacity: { duration: 0.16, ease: "easeOut" } } as const;
|
|
56
|
+
|
|
57
|
+
// Cards rising out of a folder. Playful on purpose — the only place that is.
|
|
58
|
+
const LIFT = { type: "spring", bounce: 0.28, duration: 0.5 } as const;
|
|
59
|
+
|
|
60
|
+
// Opening a menu: two springs that DISAGREE (§4).
|
|
61
|
+
const GROW = { type: "spring", stiffness: 620, damping: 19, mass: 0.85 } as const;
|
|
62
|
+
const CROSS = { type: "spring", stiffness: 380, damping: 30 } as const;
|
|
63
|
+
|
|
64
|
+
// Closing: flat and quick.
|
|
65
|
+
const CLOSE = { duration: 0.13, ease: "easeIn" } as const;
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Opacity is split out of every spring, as `SLIDE` does (law 6).
|
|
69
|
+
|
|
70
|
+
→ How these numbers were arrived at: `glass/motion/springs.md`
|
|
71
|
+
|
|
72
|
+
## 4. Squash and stretch, out of physics
|
|
73
|
+
|
|
74
|
+
The best motion detail in the system, in four lines of config. A menu scales open from its anchored edge: `GROW` is light and underdamped, and sails past its final size; `CROSS` is heavily damped and arrives late. Through the overshoot the panel is briefly **taller and narrower** than it ends up.
|
|
75
|
+
|
|
76
|
+
```tsx
|
|
77
|
+
transition={{
|
|
78
|
+
scaleY: GROW, // the grow axis overshoots
|
|
79
|
+
y: GROW, // travel rides the same spring, so they stay locked
|
|
80
|
+
scaleX: CROSS, // the cross axis lags
|
|
81
|
+
opacity: { duration: 0.14, ease: "easeOut" },
|
|
82
|
+
}}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
→ The principle: `domains/motion/ladder-and-curves.md`, squash and stretch. The component: `glass/motion/assets/DropMenu.tsx`
|
|
86
|
+
|
|
87
|
+
## 5. Asymmetric open and close
|
|
88
|
+
|
|
89
|
+
| Direction | Character | Duration |
|
|
90
|
+
|---|---|---|
|
|
91
|
+
| Open | spring, overshoots | ~400ms effective |
|
|
92
|
+
| Close | tween, `easeIn`, flat | 130ms |
|
|
93
|
+
|
|
94
|
+
Why exits get out of the way is law 7. The exit offsets are smaller too: open starts from `scaleY: 0.82, scaleX: 0.94`, exit only goes to `scaleY: 0.9, scaleX: 0.97`.
|
|
95
|
+
|
|
96
|
+
## 6. Shared elements
|
|
97
|
+
|
|
98
|
+
In the dock and in lists, **one** highlight and **one** tooltip animate their `x / y / width / height` to whichever item is hovered (law 8):
|
|
99
|
+
|
|
100
|
+
```tsx
|
|
101
|
+
// One element. It re-targets. It does not remount.
|
|
102
|
+
<m.div
|
|
103
|
+
aria-hidden
|
|
104
|
+
className="pointer-events-none absolute left-0 top-0 -z-10 rounded-[12px] bg-highlight"
|
|
105
|
+
initial={false}
|
|
106
|
+
animate={hovered
|
|
107
|
+
? { x: hovered.x, y: hovered.y, width: hovered.w, height: hovered.h, opacity: 1 }
|
|
108
|
+
: { opacity: 0 }}
|
|
109
|
+
transition={SLIDE}
|
|
110
|
+
/>
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The details that keep it from flickering are in `domains/motion/patterns.md`, the shared highlight; in Tailwind they are `isolate` on the container and `-z-10` on the highlight.
|
|
114
|
+
|
|
115
|
+
→ The tooltip and vertical-list variants, and the active state: `glass/motion/shared-element.md`
|
|
116
|
+
|
|
117
|
+
### When the DOM element isn't enough
|
|
118
|
+
|
|
119
|
+
A *photograph* travelling between layouts escalates to the GPU (law 13), opening on the tile nearest the viewport centre (law 8): `glass/motion/shared-element.md`, escalating to the GPU
|
|
120
|
+
|
|
121
|
+
## 7. Reduced motion
|
|
122
|
+
|
|
123
|
+
Every animated component takes its own branch (law 10):
|
|
124
|
+
|
|
125
|
+
```tsx
|
|
126
|
+
const reduce = useReducedMotion();
|
|
127
|
+
const tween = reduce ? { duration: 0.15 } : SLIDE;
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The glass substitutes: a spring slide becomes a 150ms tween; the squash-and-stretch open becomes a plain fade.
|
|
131
|
+
|
|
132
|
+
```tsx
|
|
133
|
+
// No elastic scaling, but you still see it appear.
|
|
134
|
+
initial={reduce ? { opacity: 0 } : { opacity: 0, scaleY: 0.82, scaleX: 0.94, y: 4 }}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Imperative APIs get the preference passed in: `scrollIntoView({ behavior: reduce ? "auto" : "smooth" })`. Reduced transparency and high contrast are §8 of `glass/depth/guide.md`.
|
|
138
|
+
|
|
139
|
+
→ Every glass substitute: `glass/motion/reduced-motion.md`
|
|
140
|
+
|
|
141
|
+
## 8. Scroll
|
|
142
|
+
|
|
143
|
+
One passive listener and one rAF per behaviour (law 12; the shape is in `domains/motion/performance.md`).
|
|
144
|
+
|
|
145
|
+
**The dock's dead zone** is 6px of intent either way, and it is always open within 24px of the top or at the bottom. **It collapses only where `(hover: hover) and (pointer: fine)` matches**, so a hover can reopen it.
|
|
146
|
+
|
|
147
|
+
→ Scroll spy, `scroll-mt` alignment, the grid collapse: `glass/motion/scroll-performance.md`
|
|
148
|
+
|
|
149
|
+
## 9. Reveal on scroll
|
|
150
|
+
|
|
151
|
+
300ms on the house curve, 8px of travel, `threshold: 0`, `rootMargin: "0px 0px -40px 0px"`, marked by `data-in`; the guards are law 11, whose two bugs are this site's. → `glass/motion/assets/motion-tokens.css`, `glass/motion/assets/useReveal.ts`
|
|
152
|
+
|
|
153
|
+
## 10. Performance: `m` not `motion`
|
|
154
|
+
|
|
155
|
+
`m.div` inside `<LazyMotion features={domAnimation} strict>`, never a bare `motion.*` (`domains/motion/performance.md`). The menu's wrapper scales and its `glass-panel` child carries the blur (law 13; `glass/depth/guide.md` §3).
|
|
156
|
+
|
|
157
|
+
## 11. Hover intent
|
|
158
|
+
|
|
159
|
+
A 100ms grace period on close (law 14):
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
const closeBoxSoon = () => {
|
|
163
|
+
clearTimeout(closeTimer.current);
|
|
164
|
+
closeTimer.current = window.setTimeout(() => setConfigOpen(false), 100);
|
|
165
|
+
};
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
The hover bridge here is `pt-2.5` of wrapper padding between a button and the pill below it.
|
|
169
|
+
|
|
170
|
+
**Hover is a wash, never a translate-up** (law 1). The folder's rising cards (`LIFT`) are the one exception, and being the only one is the point (`glass/motion/springs.md`).
|
|
171
|
+
|
|
172
|
+
## 12. Press and sound
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
active:scale-[0.92] /* 8% — felt, not seen */
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
On `pointerdown` (law 3).
|
|
179
|
+
|
|
180
|
+
Sound is *on* by default here, defensible only on a personal site: a synthesised "tok" on hover and click, muteable; `[data-quiet-hover]` silences hover on dense surfaces. → `glass/motion/sound.md`; the rules: `domains/motion/patterns.md`, sound
|
|
181
|
+
|
|
182
|
+
## 13. Fill means chosen
|
|
183
|
+
|
|
184
|
+
Law 9, in glass's mechanism: the filled variant is the outline with its interior painted, layered *over* the stroke at the same coordinates, so the layers cross-fade in place.
|
|
185
|
+
|
|
186
|
+
```tsx
|
|
187
|
+
export const fillFade = (on: boolean) =>
|
|
188
|
+
`transition-opacity duration-200 ease-out motion-reduce:transition-none ${on ? "opacity-100" : "opacity-0"}`;
|
|
189
|
+
|
|
190
|
+
// The inverse, for interior lines the fill swallows (a briefcase's divider).
|
|
191
|
+
export const detailFade = (on: boolean) =>
|
|
192
|
+
`transition-opacity duration-200 ease-out motion-reduce:transition-none ${on ? "opacity-0" : "opacity-100"}`;
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
**200ms `ease-out`, not the house curve** (§2). The knockout mask for interior lines is in `domains/motion/patterns.md`, fill means chosen.
|
|
196
|
+
|
|
197
|
+
**How the marks that can't fill signal instead:**
|
|
198
|
+
|
|
199
|
+
| Mark | Why fill is wrong | What it does instead |
|
|
200
|
+
|---|---|---|
|
|
201
|
+
| Brand marks — socials, a tech stack | Official solid silhouettes; an outline is an off-brand redrawing | Colour: quiet at rest, full on hover |
|
|
202
|
+
| Open strokes — chevrons, arrows, checks, menu bars | No interior to paint | A stroke-weight ladder by role |
|
|
203
|
+
| A mark on static content, or a lone toggle | Never hovered, never current — or the only mark on screen | Stays in the default state |
|
|
204
|
+
|
|
205
|
+
## 14. Set pieces play once
|
|
206
|
+
|
|
207
|
+
Two set pieces, each once a session (law 15): the opening screen, and a summary panel's first reveal.
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
// Once a session, per item. The theatre is a welcome; a re-run is a delay.
|
|
211
|
+
const seenKey = (slug: string) => `short-version:${slug}`;
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
The inline gate's glass condition:
|
|
215
|
+
|
|
216
|
+
```js
|
|
217
|
+
// Home page, first landing, not a deep link (/#work), never under reduced motion.
|
|
218
|
+
document.documentElement.dataset.intro = "on";
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
The summary panel's lines branch the transition, never the style, on the client-only flag:
|
|
222
|
+
|
|
223
|
+
```tsx
|
|
224
|
+
variants={{
|
|
225
|
+
shut: { opacity: 0, y: 6, filter: "blur(4px)" }, // constant, both renders
|
|
226
|
+
open: { opacity: 1, y: 0, filter: "blur(0px)",
|
|
227
|
+
transition: { duration: reduce ? 0 : theatre ? 0.34 : 0.16, ease: EASE } },
|
|
228
|
+
}}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
"Seen" is recorded in `onAnimationComplete`, which also makes a second open in one visit as quiet as a second visit. The gate, the hydration rule and the "seen" bookkeeping are in `domains/motion/guards.md`.
|
|
232
|
+
|
|
233
|
+
Collapsibles animate height and stay mounted (law 11), so the content stays in the served HTML for a crawler or an `llms.txt`.
|
|
234
|
+
|
|
235
|
+
## Assets
|
|
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.
|
|
239
|
+
- `glass/motion/assets/DropMenu.tsx` — the squash-and-stretch menu.
|
|
240
|
+
- `glass/motion/assets/useScrollTick.ts` — rAF-throttled scroll with hysteresis.
|
|
241
|
+
- `glass/motion/assets/useReveal.ts` — the IntersectionObserver hook and its guards.
|
|
242
|
+
|
|
243
|
+
## Checklist
|
|
244
|
+
|
|
245
|
+
The domain checklist first; then glass's values:
|
|
246
|
+
|
|
247
|
+
- [ ] Every duration on the ladder: no 250ms, 500ms or 1s.
|
|
248
|
+
- [ ] Off-ladder timing is the subject of the moment (the 620ms dissolve) or a once-a-session set piece, and says which.
|
|
249
|
+
- [ ] `--ease-geist` everywhere, opacity entrances included, plus `ease-out` for 100ms opacity hovers and icon fills, plain `ease` for the theme fade, and expo-out on any track driving a filter.
|
|
250
|
+
- [ ] Menus open on the `GROW`/`CROSS` pair and close on `CLOSE`.
|
|
251
|
+
- [ ] Hover timed by area: a link 100ms, a card-sized wash 200ms.
|
|
252
|
+
- [ ] No translate on hover, bar the folder's rising cards.
|
|
253
|
+
- [ ] Icon fills cross-fade at 200ms `ease-out`.
|
|
254
|
+
- [ ] `LazyMotion` + `m` + `strict`; no bare `motion.*`.
|
|
255
|
+
- [ ] Dense hover surfaces carry `data-quiet-hover`.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Reduced motion
|
|
2
|
+
|
|
3
|
+
Glass's substitutes and the code that takes them. The principle, the full substitution table, imperative APIs and testing are law 10 and `domains/motion/guards.md`; reduced transparency and high contrast are §8 of `glass/depth/guide.md`.
|
|
4
|
+
|
|
5
|
+
## The glass substitutes
|
|
6
|
+
|
|
7
|
+
The rows where glass sets a value or has a component the domain table doesn't name; every other row is as `domains/motion/guards.md`.
|
|
8
|
+
|
|
9
|
+
| Original | Reduced-motion path | Reasoning |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| Spring slide (highlight, tooltip) | 150ms tween | The state change is the message; physics is decoration. |
|
|
12
|
+
| Squash-and-stretch open | Plain 150ms fade | Presence still communicated, no scaling. |
|
|
13
|
+
| Card lift on hover | No hover variant at all | Motion is the whole interaction; static is correct. |
|
|
14
|
+
| Press scale | **Keep it** | 8% on a 40px button, 0ms: below any threshold of concern. |
|
|
15
|
+
| First-open theatre (staggered lines) | Same open, zero-length transitions | The disclosure opens; the choreography doesn't run. |
|
|
16
|
+
| A GPU dissolve between images | Fall back to the plain `<img>` | The no-WebGL path, already built. |
|
|
17
|
+
| Icon fill crossfade | Instant swap (`motion-reduce:transition-none`) | The state is the message; the 200ms fade isn't. |
|
|
18
|
+
|
|
19
|
+
## In JavaScript
|
|
20
|
+
|
|
21
|
+
`useReducedMotion()` from Motion; branch the *transition*, keep the animation: `reduce ? { duration: 0.15 } : SLIDE`.
|
|
22
|
+
|
|
23
|
+
Where motion is the whole point of a variant-driven interaction, drop the trigger, on focus as well as hover:
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
<m.div
|
|
27
|
+
variants={container}
|
|
28
|
+
initial="rest"
|
|
29
|
+
animate="rest"
|
|
30
|
+
whileHover={reduce ? undefined : "hover"}
|
|
31
|
+
whileFocus={reduce ? undefined : "hover"}
|
|
32
|
+
>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
For presence, branch the offsets themselves:
|
|
36
|
+
|
|
37
|
+
```tsx
|
|
38
|
+
initial={reduce ? { opacity: 0 } : { opacity: 0, scaleY: 0.82, scaleX: 0.94, y: away }}
|
|
39
|
+
animate={reduce ? { opacity: 1 } : { opacity: 1, scaleY: 1, scaleX: 1, y: 0 }}
|
|
40
|
+
exit={reduce
|
|
41
|
+
? { opacity: 0, transition: CLOSE }
|
|
42
|
+
: { opacity: 0, scaleY: 0.9, scaleX: 0.97, y: away, transition: CLOSE }}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The exit keeps `CLOSE` in both branches: 130ms `easeIn` is already flat, so it needs no substitute.
|
|
46
|
+
|
|
47
|
+
## The blur crossfade
|
|
48
|
+
|
|
49
|
+
```tsx
|
|
50
|
+
const blur = reduce ? "blur(0px)" : "blur(4px)";
|
|
51
|
+
|
|
52
|
+
<m.span
|
|
53
|
+
initial={{ opacity: 0, filter: blur }}
|
|
54
|
+
animate={{ opacity: 1, filter: "blur(0px)", transition: { duration: 0.28, ease: "easeOut" } }}
|
|
55
|
+
exit={{ opacity: 0, filter: blur, transition: { duration: 0.18, ease: "easeIn" } }}
|
|
56
|
+
>
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
One variable turns a blur-crossfade into a plain crossfade; `blur(0px)` → `blur(0px)` is a no-op the browser optimises away, so there's no second code path. Note the asymmetry: 280ms in, 180ms out.
|
|
60
|
+
|
|
61
|
+
## In CSS
|
|
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
|
+
```
|
|
70
|
+
|
|
71
|
+
### Tailwind's `motion-reduce:` variant
|
|
72
|
+
|
|
73
|
+
```tsx
|
|
74
|
+
className="transition-transform duration-200 ease-geist motion-reduce:transition-none"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Apply it to every `transition-*` that moves something. Colour-only ones don't strictly need it (a 150ms colour fade isn't a vestibular trigger), but consistency costs nothing.
|
|
78
|
+
|
|
79
|
+
## Set pieces
|
|
80
|
+
|
|
81
|
+
The greeting never plays under reduced motion, decided in its inline gate (§14 of the guide). **The `?intro` replay override, for development, does not defeat the preference**: it's a preference, not a cache. The "seen" flag is still written.
|
|
@@ -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, since a hundred tiles as textures is ~590MB before atlasing, 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.
|