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,37 @@
|
|
|
1
|
+
# Performance and fallbacks
|
|
2
|
+
|
|
3
|
+
Glass's budget and fallbacks, for depth laws 15 and 16. The cost model, what to avoid, how to measure, containment and the fallback order in general are in `domains/depth/fallbacks-and-performance.md`; glass's fallback code is in §8 of the guide.
|
|
4
|
+
|
|
5
|
+
## What makes it affordable here
|
|
6
|
+
|
|
7
|
+
The reference implementation runs three or four glass surfaces and stays smooth on a phone.
|
|
8
|
+
|
|
9
|
+
**Small surfaces.** The dock is a few hundred pixels wide and 48px tall; the settings box is smaller. Nothing spans the viewport.
|
|
10
|
+
|
|
11
|
+
**Few of them, and only while needed.** The menus **unmount** when closed rather than sitting at `opacity: 0`:
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
<AnimatePresence>
|
|
15
|
+
{open && <m.div …>{children}</m.div>}
|
|
16
|
+
</AnimatePresence>
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
**No animated blur**, and **nothing animating behind the glass**: the backdrop is text and static images.
|
|
20
|
+
|
|
21
|
+
**The wrapper animates, the surface filters.** The `DropMenu` split in §3 of the guide is a double win: the wrapper animates on the compositor, and the child's filter isn't recomputed against a moving containing block every frame.
|
|
22
|
+
|
|
23
|
+
## Reduced transparency, per material
|
|
24
|
+
|
|
25
|
+
The substitutions are per material, not blanket-white: chrome goes to `gray-100`, the raised-surface token, so the dock still reads as raised; the decorative pocket goes to `background-100`, fully opaque, since its whole purpose was translucency. The block names both `:root` and `:root[data-theme="dark"]`, or it wins in one theme only.
|
|
26
|
+
|
|
27
|
+
## `prefers-contrast: more`
|
|
28
|
+
|
|
29
|
+
An 8%-alpha hairline is the first thing to disappear under a contrast preference, so the edge goes to `gray-1000`, primary text colour, and the surface stays.
|
|
30
|
+
|
|
31
|
+
## `@supports`
|
|
32
|
+
|
|
33
|
+
Without the guard, the reference implementation's surfaces get a bare tint where `backdrop-filter` is missing: legible but weak, marginal at 72% over a photograph. Support is very good (Firefox shipped it unflagged in 103, mid-2022), so it's a small risk, not a live bug. `glass/depth/assets/glass.css` in this guide ships with the guard in place.
|
|
34
|
+
|
|
35
|
+
## The `-webkit-` prefix on the site
|
|
36
|
+
|
|
37
|
+
Safari still requires it, and Safari is where glass looks best, but the build adds it (`domains/depth/fallbacks-and-performance.md`, the prefix and the pipeline). The reference implementation hand-wrote the pair and shipped the bug: `.glass-nav`, `.glass-menu`, `.glass-panel` and `.folder-glass` all compile prefixed-only, so Firefox loses every blur. Hand-write the prefixed `none` in the reduced-transparency block and Firefox keeps blurring for the users who asked it to stop.
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# Adaptive presentations
|
|
2
|
+
|
|
3
|
+
Glass's forks, views and breakpoints, for layout laws 8, 9 and 11; the fork test, capability queries, the touch audit and container breakpoints in general are in `domains/layout/forks-gating-and-disclosures.md`.
|
|
4
|
+
|
|
5
|
+
## Case 1: the navigation dock
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
export function BottomNav() {
|
|
9
|
+
return (
|
|
10
|
+
<nav aria-label="Primary"
|
|
11
|
+
className="pointer-events-none fixed inset-x-0 bottom-5 z-50 flex justify-center">
|
|
12
|
+
<MobileDock /> {/* sm:hidden */}
|
|
13
|
+
<DesktopDock /> {/* hidden sm:block */}
|
|
14
|
+
</nav>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
**`DesktopDock`** — icon buttons with a shared sliding highlight and tooltip; hovering an arrow opens a settings drop-up. Scrolling down collapses it to the avatar; hovering, scrolling up, or reaching either end of the page expands it.
|
|
20
|
+
|
|
21
|
+
**`MobileDock`** — the avatar and a menu button opening a drop-up sheet: pages as text rows, then socials, sound and theme. Toggling a preference leaves the sheet open; navigating or tapping outside closes it.
|
|
22
|
+
|
|
23
|
+
Almost nothing is shared, because every desktop interaction is hover-driven and touch fires none of them:
|
|
24
|
+
|
|
25
|
+
| Behaviour | Desktop | Mobile |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| Reveal pages | Always visible, collapse on scroll | Inside a sheet |
|
|
28
|
+
| Reveal settings | Hover an arrow | A row in the sheet |
|
|
29
|
+
| Item feedback | Sliding highlight + tooltip | `active:bg-highlight` |
|
|
30
|
+
| Labels | Tooltip on hover | Text in the row |
|
|
31
|
+
| Collapse on scroll | Yes | No — nothing brings it back |
|
|
32
|
+
| Press feedback | `active:scale-[0.92]` | `active:scale-[0.92]` |
|
|
33
|
+
|
|
34
|
+
What *is* shared is extracted — the destinations array, the icons, the preferences hook:
|
|
35
|
+
|
|
36
|
+
```tsx
|
|
37
|
+
function useNavPrefs() {
|
|
38
|
+
const [soundOn, setSoundOn] = useState(true);
|
|
39
|
+
const [theme, setTheme] = useState<ThemeChoice>(getThemeChoice);
|
|
40
|
+
// …
|
|
41
|
+
return { soundOn, setSound, theme, pickTheme, sync };
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The collapse-on-scroll effect bails out entirely unless `(hover: hover) and (pointer: fine)` matches, so touch never attaches the listener (layout law 8).
|
|
46
|
+
|
|
47
|
+
### The `sync` subtlety
|
|
48
|
+
|
|
49
|
+
Both docks are always mounted (hidden by CSS), each with its own copy of the preferences, so a change in the desktop settings box before the viewport crossed the breakpoint would leave the mobile sheet showing a stale toggle.
|
|
50
|
+
|
|
51
|
+
```tsx
|
|
52
|
+
const toggle = () => {
|
|
53
|
+
if (!open) sync(); // re-read the stored values as the sheet opens
|
|
54
|
+
setOpen((o) => !o);
|
|
55
|
+
};
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The desktop panel needs no equivalent: it *mounts fresh* on open and reads the values then, whereas the mobile trigger lives in the always-present dock, so its state outlives the panel.
|
|
59
|
+
|
|
60
|
+
## Case 2: the table of contents
|
|
61
|
+
|
|
62
|
+
Same tree, two containers, shared row rendering.
|
|
63
|
+
|
|
64
|
+
```tsx
|
|
65
|
+
/** Shared — used by both presentations. */
|
|
66
|
+
function Rows({ nodes, active, parents, onJump }) { … }
|
|
67
|
+
|
|
68
|
+
/** The sticky rail, in the left margin on wide screens. */
|
|
69
|
+
export function SectionNav({ nodes, active, onJump }) {
|
|
70
|
+
return (
|
|
71
|
+
<nav aria-label="On this page">
|
|
72
|
+
<ul className="flex flex-col">
|
|
73
|
+
<Rows … />
|
|
74
|
+
</ul>
|
|
75
|
+
</nav>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The narrow-screen disclosure — one line above the article. */
|
|
80
|
+
export function SectionNavCompact({ nodes, active, onJump }) {
|
|
81
|
+
const [open, setOpen] = useState(false);
|
|
82
|
+
const jump = (id: string) => { setOpen(false); onJump(id); };
|
|
83
|
+
return (
|
|
84
|
+
<details open={open} onToggle={(e) => setOpen(e.currentTarget.open)}
|
|
85
|
+
className="group rounded-xl border border-gray-alpha-300">
|
|
86
|
+
<summary className="… [&::-webkit-details-marker]:hidden">
|
|
87
|
+
On this page
|
|
88
|
+
<span className="… group-open:rotate-180 motion-reduce:transition-none">
|
|
89
|
+
<ChevronDownIcon />
|
|
90
|
+
</span>
|
|
91
|
+
</summary>
|
|
92
|
+
<ul className="… border-t border-gray-alpha-300">
|
|
93
|
+
<Rows … onJump={jump} />
|
|
94
|
+
</ul>
|
|
95
|
+
</details>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Both are rendered; CSS picks one:
|
|
101
|
+
|
|
102
|
+
```tsx
|
|
103
|
+
<div className="absolute inset-y-0 left-8 hidden w-[196px] xl:block">
|
|
104
|
+
<div className="sticky top-28 max-h-[calc(100dvh-11rem)] overflow-y-auto">
|
|
105
|
+
<SectionNav {...nav} />
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<div className="reveal mt-8 xl:hidden">
|
|
110
|
+
<SectionNavCompact {...nav} />
|
|
111
|
+
</div>
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The props are memoised so both get a stable object:
|
|
115
|
+
|
|
116
|
+
```tsx
|
|
117
|
+
const nav = useMemo(() => ({ nodes: toc, active, onJump: jump }), [active, jump]);
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The compact version is a `<details>` (layout law 11); `open` is mirrored into React state only so it can close itself after a jump. `[&::-webkit-details-marker]:hidden` plus `list-none` removes the default triangle so the chevron can be styled, and `group-open:rotate-180` reads the parent's `open` attribute, so the rotation needs no state. It costs **one line** above the article, which is what makes a nav in the reading flow acceptable.
|
|
121
|
+
|
|
122
|
+
## Case 3: two views of one collection, chosen by the reader
|
|
123
|
+
|
|
124
|
+
The first two cases fork on a *device* capability; the gallery's **wall** and **roll** fork on a preference. The interaction model changes, not just the arrangement (layout law 8).
|
|
125
|
+
|
|
126
|
+
### The state can't live in either component
|
|
127
|
+
|
|
128
|
+
The control and what it controls are siblings, so a third party owns the value:
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
export type GalleryLayout = "grid" | "roll";
|
|
132
|
+
let current: GalleryLayout = "grid";
|
|
133
|
+
|
|
134
|
+
const listeners = new Set<(layout: GalleryLayout) => void>();
|
|
135
|
+
export function onGalleryLayoutChange(fn: (l: GalleryLayout) => void) {
|
|
136
|
+
listeners.add(fn);
|
|
137
|
+
return () => { listeners.delete(fn); };
|
|
138
|
+
}
|
|
139
|
+
export function setGalleryLayout(layout: GalleryLayout) {
|
|
140
|
+
current = layout;
|
|
141
|
+
for (const fn of listeners) fn(layout);
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The shape of a theme or sound store, for the same reason: neither consumer can be the other's parent without distorting the layout.
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
// Mirror it in a hook. Read in an EFFECT, not during render: on a
|
|
149
|
+
// server-rendered page the module and the server agree on "grid" for a fresh
|
|
150
|
+
// load, but arriving from another gallery with "roll" already picked would
|
|
151
|
+
// have the client's first paint disagree with the markup.
|
|
152
|
+
export function useGalleryLayout() {
|
|
153
|
+
const [layout, setLayout] = useState<GalleryLayout>("grid");
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
setLayout(getGalleryLayout());
|
|
156
|
+
return onGalleryLayoutChange(setLayout);
|
|
157
|
+
}, []);
|
|
158
|
+
return { layout, pickLayout: setGalleryLayout };
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### The choice doesn't persist
|
|
163
|
+
|
|
164
|
+
The theme persists to `localStorage` forever; the gallery layout lives in module memory, so a fresh load, a reload and a move between galleries all open on the wall (layout law 9; the reason is in §6 of the guide).
|
|
165
|
+
|
|
166
|
+
### The switch carries the position
|
|
167
|
+
|
|
168
|
+
Find the item nearest the viewport's centre before the outgoing layout unmounts and open the incoming one there — the measurement is `centreFrame` in `glass/motion/shared-element.md`.
|
|
169
|
+
|
|
170
|
+
### Each tile declares its own aspect ratio
|
|
171
|
+
|
|
172
|
+
```tsx
|
|
173
|
+
<figure style={{ aspectRatio: `${photo.w} / ${photo.h}` }}>
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Nothing is cropped, so `object-cover` has nothing to do; nothing shifts on load; and one image travels between layouts undistorted, because both ends compute to the same shape.
|
|
177
|
+
|
|
178
|
+
## Hover in Tailwind
|
|
179
|
+
|
|
180
|
+
Tailwind v4's `hover:` variant already compiles to a `(hover: hover)` guard, so utility hover states are safe by default. Imperative JavaScript needs the explicit check.
|
|
181
|
+
|
|
182
|
+
## The touch-versus-hover audit
|
|
183
|
+
|
|
184
|
+
Glass's answers to the audit in `domains/layout/forks-gating-and-disclosures.md`:
|
|
185
|
+
|
|
186
|
+
| Hover behaviour | Touch equivalent |
|
|
187
|
+
|---|---|
|
|
188
|
+
| Icon label as a tooltip | Text label in the sheet |
|
|
189
|
+
| Settings box on hover | A row in the sheet |
|
|
190
|
+
| Company card on row hover | Sits below the row instead of beside it, at a wider breakpoint |
|
|
191
|
+
| Folder cards rising | Not available — but the folder is still a link to the same page |
|
|
192
|
+
| Dock collapse on scroll | Doesn't happen |
|
|
193
|
+
|
|
194
|
+
The folder-card lift is the honest case: a delight beat carrying no information, so touch users lose nothing they need.
|
|
195
|
+
|
|
196
|
+
## Where responsive utilities are still right
|
|
197
|
+
|
|
198
|
+
Arrangement, not interaction:
|
|
199
|
+
|
|
200
|
+
```tsx
|
|
201
|
+
"grid gap-4 sm:gap-6 grid-cols-3" // grid density
|
|
202
|
+
"columns-3 gap-1 sm:columns-4" // masonry columns
|
|
203
|
+
"pt-28 sm:pt-20" // page padding under fixed chrome
|
|
204
|
+
"absolute left-3 right-3 top-full … xl:left-full xl:top-1/2 xl:ml-6 xl:w-60"
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
The last is a hover card *below* its row on narrow screens and *beside* it at `xl`, where there's finally margin: only position moves, so it's correctly a breakpoint.
|
|
208
|
+
|
|
209
|
+
## Breakpoints in use
|
|
210
|
+
|
|
211
|
+
| Breakpoint | Width | What changes |
|
|
212
|
+
|---|---|---|
|
|
213
|
+
| `sm` | 640px | Mobile dock ↔ desktop dock. Page padding. Masonry columns. Stack columns. |
|
|
214
|
+
| `xl` | 1280px | The TOC rail appears. The hover card moves beside its row. |
|
|
215
|
+
|
|
216
|
+
Two for the whole site, both derived from content. Both are viewport breakpoints only because here the column *is* the page. `sm` coincides with the reading measure: below it the column is padding-limited, the natural place for the layout to change character. `xl` is where a 640px column finally leaves margin for a 196px rail. Type size is *not* on the list: a 30px article title once stepped to 26px below `sm`, until an 18px type ceiling removed the need (`glass/layout/measure-and-rhythm.md`; the Glass type guide, `glass/type/guide.md`).
|
|
217
|
+
|
|
218
|
+
## Breakpoints on the container
|
|
219
|
+
|
|
220
|
+
Where a pane shares the screen with something that opens and closes, it breaks on its own width (layout law 8). In Tailwind:
|
|
221
|
+
|
|
222
|
+
```tsx
|
|
223
|
+
<section className="@container"> {/* the pane, not the page */}
|
|
224
|
+
<div className="grid @2xl:grid-cols-[minmax(240px,1fr)_2fr]">
|
|
225
|
+
<List />
|
|
226
|
+
<Detail className="@container" /> {/* sizes its own insides */}
|
|
227
|
+
</div>
|
|
228
|
+
</section>
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Tailwind v4 ships `@container` and the `@sm:`…`@7xl:` variants; plain CSS is `container-type: inline-size` and `@container (min-width: …)`. The list is clamped with `minmax` so it narrows before the detail does, and the nested `@container` lets the detail size its own gutters, rail and labels.
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two presentations of one tree — the canonical "fork the container, share
|
|
3
|
+
* the data" example.
|
|
4
|
+
*
|
|
5
|
+
* SectionNav the sticky rail that lives in the left margin
|
|
6
|
+
* SectionNavCompact the disclosure that replaces it when there's no margin
|
|
7
|
+
*
|
|
8
|
+
* Both render the same nodes through the same `Rows` component. Only the
|
|
9
|
+
* container differs, because only the container's interaction model differs.
|
|
10
|
+
*
|
|
11
|
+
* Rows are real in-page anchors, so they work without JavaScript and can be
|
|
12
|
+
* copied as links; the click handler takes over to scroll smoothly and keep
|
|
13
|
+
* the URL in step.
|
|
14
|
+
*
|
|
15
|
+
* Pair with useActiveSection (`glass/motion/guide.md`) for the scroll spy, and
|
|
16
|
+
* give every heading a scroll-margin-top just under its READING_LINE.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { useMemo, useState } from "react";
|
|
20
|
+
|
|
21
|
+
export type TocNode = {
|
|
22
|
+
/** Id of the heading this row jumps to. */
|
|
23
|
+
id: string;
|
|
24
|
+
label: string;
|
|
25
|
+
children?: TocNode[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Flattens the tree into document order — what the scroll spy measures. */
|
|
29
|
+
export function tocIds(nodes: TocNode[]): string[] {
|
|
30
|
+
return nodes.flatMap((n) => [n.id, ...tocIds(n.children ?? [])]);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** child id → parent id, so the section an active row belongs to stays lit. */
|
|
34
|
+
function parentMap(nodes: TocNode[]): Map<string, string> {
|
|
35
|
+
const map = new Map<string, string>();
|
|
36
|
+
for (const n of nodes) for (const c of n.children ?? []) map.set(c.id, n.id);
|
|
37
|
+
return map;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type RowState = "active" | "ancestor" | "rest";
|
|
41
|
+
|
|
42
|
+
function ChevronDownIcon() {
|
|
43
|
+
return (
|
|
44
|
+
<svg
|
|
45
|
+
viewBox="0 0 24 24"
|
|
46
|
+
fill="none"
|
|
47
|
+
stroke="currentColor"
|
|
48
|
+
strokeWidth="2"
|
|
49
|
+
strokeLinecap="round"
|
|
50
|
+
strokeLinejoin="round"
|
|
51
|
+
aria-hidden
|
|
52
|
+
className="h-3.5 w-3.5"
|
|
53
|
+
>
|
|
54
|
+
<path d="M6 9l6 6 6-6" />
|
|
55
|
+
</svg>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* One row.
|
|
61
|
+
*
|
|
62
|
+
* Top-level rows sit flush and carry no marker — they read as the headings
|
|
63
|
+
* they are, and the active one takes a heavier WEIGHT so its state isn't
|
|
64
|
+
* signalled by colour alone (WCAG 1.4.1). Weight is a genuine second channel:
|
|
65
|
+
* it survives greyscale, colour-blindness and low-contrast displays, and it's
|
|
66
|
+
* cheaper than a background pill in a list that's meant to be quiet.
|
|
67
|
+
*
|
|
68
|
+
* Nested rows carry a horizontal marker that extends and darkens when active,
|
|
69
|
+
* and which doubles as their indent. It sits in a FIXED-WIDTH box so growing
|
|
70
|
+
* it never nudges the label.
|
|
71
|
+
*
|
|
72
|
+
* Three tiers of tone and marker, not two: `ancestor` (the section you're
|
|
73
|
+
* inside, when a child is active) sits between active and rest, so you never
|
|
74
|
+
* lose the larger context.
|
|
75
|
+
*/
|
|
76
|
+
function Row({
|
|
77
|
+
node,
|
|
78
|
+
state,
|
|
79
|
+
nested,
|
|
80
|
+
onJump,
|
|
81
|
+
}: {
|
|
82
|
+
node: TocNode;
|
|
83
|
+
state: RowState;
|
|
84
|
+
nested?: boolean;
|
|
85
|
+
onJump: (id: string) => void;
|
|
86
|
+
}) {
|
|
87
|
+
const tone =
|
|
88
|
+
state === "active"
|
|
89
|
+
? "text-gray-1000"
|
|
90
|
+
: state === "ancestor"
|
|
91
|
+
? "text-gray-900"
|
|
92
|
+
: "text-quiet hover:text-gray-1000";
|
|
93
|
+
|
|
94
|
+
const marker =
|
|
95
|
+
state === "active"
|
|
96
|
+
? "w-4 bg-gray-1000"
|
|
97
|
+
: state === "ancestor"
|
|
98
|
+
? "w-2.5 bg-gray-600"
|
|
99
|
+
: "w-2 bg-gray-500";
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<a
|
|
103
|
+
href={`#${node.id}`}
|
|
104
|
+
aria-current={state === "active" ? "true" : undefined}
|
|
105
|
+
onClick={(e) => {
|
|
106
|
+
e.preventDefault();
|
|
107
|
+
onJump(node.id);
|
|
108
|
+
}}
|
|
109
|
+
className={`flex items-start gap-2.5 py-[3px] text-[13px] leading-[1.4] transition-colors duration-150 ease-geist ${tone} ${
|
|
110
|
+
!nested && state === "active" ? "font-medium" : ""
|
|
111
|
+
}`}
|
|
112
|
+
>
|
|
113
|
+
{nested && (
|
|
114
|
+
<span aria-hidden className="mt-[9px] w-4 shrink-0">
|
|
115
|
+
<span
|
|
116
|
+
className={`block h-px transition-[width,background-color] duration-150 ease-geist motion-reduce:transition-none ${marker}`}
|
|
117
|
+
/>
|
|
118
|
+
</span>
|
|
119
|
+
)}
|
|
120
|
+
{node.label}
|
|
121
|
+
</a>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Shared tree rendering. Both presentations go through this. */
|
|
126
|
+
function Rows({
|
|
127
|
+
nodes,
|
|
128
|
+
active,
|
|
129
|
+
parents,
|
|
130
|
+
onJump,
|
|
131
|
+
}: {
|
|
132
|
+
nodes: TocNode[];
|
|
133
|
+
active: string;
|
|
134
|
+
parents: Map<string, string>;
|
|
135
|
+
onJump: (id: string) => void;
|
|
136
|
+
}) {
|
|
137
|
+
const state = (id: string): RowState =>
|
|
138
|
+
id === active ? "active" : parents.get(active) === id ? "ancestor" : "rest";
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<>
|
|
142
|
+
{nodes.map((n) => (
|
|
143
|
+
<li key={n.id}>
|
|
144
|
+
<Row node={n} state={state(n.id)} onJump={onJump} />
|
|
145
|
+
{n.children && (
|
|
146
|
+
<ul className="flex flex-col">
|
|
147
|
+
{n.children.map((c) => (
|
|
148
|
+
<li key={c.id}>
|
|
149
|
+
<Row node={c} state={state(c.id)} nested onJump={onJump} />
|
|
150
|
+
</li>
|
|
151
|
+
))}
|
|
152
|
+
</ul>
|
|
153
|
+
)}
|
|
154
|
+
</li>
|
|
155
|
+
))}
|
|
156
|
+
</>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The sticky rail. Mount it as an absolute child of the wider frame, so it
|
|
162
|
+
* sits outside the reading column:
|
|
163
|
+
*
|
|
164
|
+
* <div className="relative mx-auto max-w-[1280px]">
|
|
165
|
+
* <div className="absolute inset-y-0 left-8 hidden w-[196px] xl:block">
|
|
166
|
+
* <div className="sticky top-28 max-h-[calc(100dvh-11rem)] overflow-y-auto">
|
|
167
|
+
* <SectionNav {...nav} />
|
|
168
|
+
* </div>
|
|
169
|
+
* </div>
|
|
170
|
+
* <div className="col">…</div>
|
|
171
|
+
* </div>
|
|
172
|
+
*
|
|
173
|
+
* The absolute wrapper spans the section's full height so the sticky child
|
|
174
|
+
* has a track to travel. The dvh cap plus overflow-y-auto means a long
|
|
175
|
+
* contents scrolls inside itself rather than running off the screen.
|
|
176
|
+
*/
|
|
177
|
+
export function SectionNav({
|
|
178
|
+
nodes,
|
|
179
|
+
active,
|
|
180
|
+
onJump,
|
|
181
|
+
}: {
|
|
182
|
+
nodes: TocNode[];
|
|
183
|
+
active: string;
|
|
184
|
+
onJump: (id: string) => void;
|
|
185
|
+
}) {
|
|
186
|
+
const parents = useMemo(() => parentMap(nodes), [nodes]);
|
|
187
|
+
|
|
188
|
+
return (
|
|
189
|
+
<nav aria-label="On this page">
|
|
190
|
+
<ul className="flex flex-col">
|
|
191
|
+
<Rows nodes={nodes} active={active} parents={parents} onJump={onJump} />
|
|
192
|
+
</ul>
|
|
193
|
+
</nav>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The narrow-screen contents: collapsed to a single row, so it costs one line
|
|
199
|
+
* above the article — which is the constraint that makes putting a nav in the
|
|
200
|
+
* reading flow acceptable at all. Closes itself once you've jumped.
|
|
201
|
+
*
|
|
202
|
+
* <details> rather than a div and state, because it brings keyboard behaviour,
|
|
203
|
+
* aria-expanded, and find-in-page (browsers open a closed <details> to reveal
|
|
204
|
+
* a match) for free, and it works with JS disabled. Mirroring `open` into
|
|
205
|
+
* state is only so the disclosure can close itself after a jump.
|
|
206
|
+
*
|
|
207
|
+
* group-open:rotate-180 reads the parent's `open` attribute directly — no
|
|
208
|
+
* state needed for the chevron.
|
|
209
|
+
*/
|
|
210
|
+
export function SectionNavCompact({
|
|
211
|
+
nodes,
|
|
212
|
+
active,
|
|
213
|
+
onJump,
|
|
214
|
+
}: {
|
|
215
|
+
nodes: TocNode[];
|
|
216
|
+
active: string;
|
|
217
|
+
onJump: (id: string) => void;
|
|
218
|
+
}) {
|
|
219
|
+
const parents = useMemo(() => parentMap(nodes), [nodes]);
|
|
220
|
+
const [open, setOpen] = useState(false);
|
|
221
|
+
|
|
222
|
+
const jump = (id: string) => {
|
|
223
|
+
setOpen(false);
|
|
224
|
+
onJump(id);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<details
|
|
229
|
+
open={open}
|
|
230
|
+
onToggle={(e) => setOpen(e.currentTarget.open)}
|
|
231
|
+
className="group rounded-xl border border-gray-alpha-300"
|
|
232
|
+
>
|
|
233
|
+
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-3.5 py-2.5 text-[13px] text-gray-1000 [&::-webkit-details-marker]:hidden">
|
|
234
|
+
On this page
|
|
235
|
+
<span className="text-quiet transition-transform duration-200 ease-geist group-open:rotate-180 motion-reduce:transition-none">
|
|
236
|
+
<ChevronDownIcon />
|
|
237
|
+
</span>
|
|
238
|
+
</summary>
|
|
239
|
+
<ul className="flex flex-col border-t border-gray-alpha-300 px-3.5 py-2">
|
|
240
|
+
<Rows nodes={nodes} active={active} parents={parents} onJump={jump} />
|
|
241
|
+
</ul>
|
|
242
|
+
</details>
|
|
243
|
+
);
|
|
244
|
+
}
|