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,226 @@
|
|
|
1
|
+
# Surfaces and Depth
|
|
2
|
+
|
|
3
|
+
Every surface claims a place: a table row, that it is part of the page; a sticky header, that content passes under it; a menu, that it arrived over the page and will leave; a dialog, that nothing else can be touched until it is answered. A surface whose depth contradicts its behaviour is lying — a dialog that looks like a menu, a card that seems to float but scrolls with the page.
|
|
4
|
+
|
|
5
|
+
## Before you start
|
|
6
|
+
|
|
7
|
+
Reads `DESIGN.md` `## Surfaces`.
|
|
8
|
+
|
|
9
|
+
Boundaries: the radius ladder and nesting rule, hairline weights and the z-index ladder are the layout guide's; surface, edge and tint colours, and why translucent tints are runtime values, the colour guide's; the timing of anything that lifts, glows or opens, the motion guide's; a confirmation's flow and a sign-in page's layout, the patterns guide's. This guide picks which a surface takes and checks they agree.
|
|
10
|
+
|
|
11
|
+
## Laws
|
|
12
|
+
|
|
13
|
+
### 1. Elevation is a named ladder of bundles, applied whole
|
|
14
|
+
|
|
15
|
+
**Every surface sits on exactly one level — `elevation.flat`, `.raised`, `.floating` or `.overlay` — and takes that level's surface, edge, shadow and z-layer together: four spellings of one fact.** The level follows what the thing *does* — sits in the flow, is lifted or stuck, is summoned and dismissed, or blocks until answered — not how important it feels.
|
|
16
|
+
|
|
17
|
+
Mixing parts is the bug the ladder prevents: a card given the menu's shadow on hover but not its layer is painted over by the next sibling, clipping the lift. A state change moves the whole bundle — a dragged card becomes floating in all four parts — while a hover that deepens a raised card's shadow is raised's own hover state.
|
|
18
|
+
|
|
19
|
+
A level is also one component. Every dialog is the system's dialog, a destructive confirmation included: a one-off with its own red icon circle, red heading and padding reads as out of the system however right its level. The danger goes on the one action that does it (the colour guide); surface, edge, shadow and scrim stay the overlay's.
|
|
20
|
+
|
|
21
|
+
The ladder has only the rungs this project uses; the dial lists the most a preset may have. A dead rung is worse than an absent one: the next person assumes it is load-bearing.
|
|
22
|
+
|
|
23
|
+
### 2. A card is one object, and never holds another card
|
|
24
|
+
|
|
25
|
+
**A card — a surface with its own edge — is reserved for one object the reader acts on or takes in whole, and a card never holds another card.** A box says "this is a thing". Put one round every section, stat and list and every region claims to be a thing, so none is; each nesting adds an edge, a padding and a radius, and a screen built of boxes in boxes reads as a template however good its type. **Which regions get a surface at all is decided once, in layout law 3's table**; this law builds what that table gives a surface, and keeps everything else off one.
|
|
26
|
+
|
|
27
|
+
- **One object** — a decision awaiting approval, a document, a side rail — is a card. A rail with two topics is one card in two parts, parted by a divider; several items of one kind (three pending approvals) share one card the same way, not a card each.
|
|
28
|
+
- **A section panel** (from layout law 3) is `elevation.flat`: an edge and no shadow, because it doesn't float. Nothing inside it is boxed again: its rows part by `border.divider`.
|
|
29
|
+
- **A note** — an aside or caveat — takes a hairline rule on its leading edge, not a tinted box.
|
|
30
|
+
|
|
31
|
+
The quiet devices that replace a box are layout law 3's. The test: take the edge away. If the object still reads as one thing from space and its heading, the edge was decoration. Then check the measure: columns of text left under layout law 1's floor want fewer columns.
|
|
32
|
+
|
|
33
|
+
### 3. Depth needs an edge as well as a shadow
|
|
34
|
+
|
|
35
|
+
**Any surface above `elevation.flat` has an edge that says where it ends, not only a shadow that says it is above.** A shadow is a soft gradient and cannot draw a boundary; without an edge the outline is mush, worst in dark themes, where shadows have almost no headroom. The edge is `border.default` on an opaque surface; on a translucent one or any backdrop it doesn't control, the translucent `border.layered` (colour law 4). Where the preset draws the edge as a half-pixel ring at the head of the shadow stack (Dials), it is still the edge: `prefers-contrast: more` swaps in a real 1px border, whose whole job there is a boundary you can find.
|
|
36
|
+
|
|
37
|
+
`forced-colors` drops every `box-shadow` but keeps the edge, so a surface designed with no visible edge still carries `1px solid transparent`, painted in the system text colour. `elevation.flat` has no shadow: an edge or a surface step, never a faint shadow posing as a border.
|
|
38
|
+
|
|
39
|
+
### 4. A shadow models one light, in layers, and lives in one definition
|
|
40
|
+
|
|
41
|
+
**A shadow is a contact layer plus one or two ambient layers from a single light above, with negative spread, felt rather than seen.** The faint 1px contact layer makes a surface look *resting on* something; two ambient layers fall off like a penumbra where one large blur reads as fog; negative spread keeps each tucked under the element — without it, stock presets look like glows. An optional inset top highlight does more for floating than any drop layer. Higher levels get more and longer layers; a transient tooltip drops the contact layer — it hovers.
|
|
42
|
+
|
|
43
|
+
Strength follows **what is immediately behind the surface**, not the theme: a tooltip on `surface.inverse` needs the same strong shadow in both themes, since its own surface is what the shadow separates. On a tinted ground the shadow takes the ground's hue, darkened; neutral black on a warm cream page reads as dirt.
|
|
44
|
+
|
|
45
|
+
Nobody spots a `0.04` that became `0.05` in one of five copies, so **a layered shadow has one definition per level, extracted the moment it appears twice**, which every use points at.
|
|
46
|
+
|
|
47
|
+
### 5. Dark themes lift surfaces by lightening them
|
|
48
|
+
|
|
49
|
+
**In a dark theme each level above the page is lighter than the one beneath it; the shadow is a secondary cue.** Raised means more contrast against the page. Light-theme raised surfaces may sit a step either side of it — white cards on a grey page, grey panels on a white one — so long as every level steps the same way, and whether there are cards at all is layout law 3's call; dark-theme ones are always lighter, because a black shadow on a near-black page has nowhere to go, and strengthening shadows alone leaves every level looking like the page. The dark shadow is its own per-theme recipe — drop layers far stronger, at the dial's dark peak, the highlight at a fraction of its strength — and the page is never `#000` (colour law 8). Hence the Glass preset's dark chrome tint, lighter than its dark page, and shadow opacities of 2–6% in light, 30–60% in dark.
|
|
50
|
+
|
|
51
|
+
### 6. Translucency has to be earned, and usually isn't
|
|
52
|
+
|
|
53
|
+
**A translucent surface claims that content continues underneath and that seeing it helps. Where either half is false, the surface is opaque.** It holds for chrome that content scrolls under, for a floating layer whose context helps the reader, and for art — a frosted pocket on an illustration, whose backdrop is drawn, still and meant to be seen. Art earns it more often than chrome: nobody reads through it. A product keeps its art, generative or illustrated, translucent or not, to one inset panel — the half of a sign-in page beside an unboxed form — and never frosts over art that is still moving. It fails for:
|
|
54
|
+
|
|
55
|
+
- **A section, panel or sidebar in the flow** — it's the page, not floating.
|
|
56
|
+
- **A card in a grid or a row in a list** — nothing meaningful is behind it, and a filter per row is the worst cost profile.
|
|
57
|
+
- **Anything holding reading copy** (law 10).
|
|
58
|
+
- **A flat, single-colour backdrop** — nothing to reveal; use the tint.
|
|
59
|
+
- **Content behind that must stay readable** — that wants an opaque, shadowed surface beside it, not a blur over it.
|
|
60
|
+
- **Video, canvas or anything animating** — the filter re-runs every backdrop frame.
|
|
61
|
+
- **A preset that disallows it** (Dials) — in fluent product tools borders and opaque surfaces do the work, and translucency is decoration that costs contrast.
|
|
62
|
+
|
|
63
|
+
Even a preset built around the material uses it in four places, all floating chrome: the ceiling, not the target.
|
|
64
|
+
|
|
65
|
+
### 7. Blur scales with uncertainty about the backdrop
|
|
66
|
+
|
|
67
|
+
**Blur radius is set by how little you know about what's behind the surface, not by its size or importance.**
|
|
68
|
+
|
|
69
|
+
| Tier | Blur | Saturate | Tint opacity | Backdrop |
|
|
70
|
+
|---|---|---|---|---|
|
|
71
|
+
| Soften | 6–10px | 1.2–1.4 | 35–60% | Content you want recognised |
|
|
72
|
+
| Chrome | 16–24px | 1.6–1.8 | 70–80% | Arbitrary content passing under |
|
|
73
|
+
| Defensive | 20–28px | 1.6–1.8 | 92–97% | Anything, including other translucent surfaces |
|
|
74
|
+
|
|
75
|
+
Role-tier ranges, not dial values; the preset picks which tiers exist (Dials). Cost scales with area, not radius, so don't trim blur to save frames.
|
|
76
|
+
|
|
77
|
+
### 8. Blur needs saturation
|
|
78
|
+
|
|
79
|
+
**Every `backdrop-filter` blur carries a `saturate()` correction.** Blur averages colours toward grey, and blur plus a pale tint alone is the cloudy, dirty wash "glassmorphism" describes; the correction is free in the same pass. Go lower where the backdrop is itself the subject; above about 2.0, colours turn radioactive.
|
|
80
|
+
|
|
81
|
+
### 9. The tint carries legibility when the blur can't
|
|
82
|
+
|
|
83
|
+
**A translucent surface is legible from its tint alone; the blur is a bonus.** A transformed or filtered ancestor — a menu's scaling wrapper, for the whole animation — weakens or breaks `backdrop-filter` beneath it, and a 72% tint without its blur is unreadable over a busy page. Animate one element and filter another; failing that, thicken the tint to the defensive tier (→ `domains/depth/translucent-materials.md`, the wrapper and the surface).
|
|
84
|
+
|
|
85
|
+
### 10. Translucent text is checked against the worst backdrop
|
|
86
|
+
|
|
87
|
+
**Text contrast on a translucent surface is measured against the tint composited over the lightest *and* darkest backdrop it can land on — never against the tint as if opaque.** A 72% near-white tint is near-white over a white page and mid-grey over a dark photograph: `text.tertiary` clears 5.1:1 on one and manages 2.6:1 on the other, as scrolling carries it between them. The opaque-tint number is a fiction.
|
|
88
|
+
|
|
89
|
+
Make it true, in order: raise the tint until it is effectively opaque behind text (at 95% the backdrop contributes a few per cent); over arbitrary content at the chrome tier, carry only `text.primary`, and icons at their label's tier — at 72% over a dark image even `text.secondary` falls to about 4.2:1; keep reading copy off translucent surfaces.
|
|
90
|
+
|
|
91
|
+
→ The compositing arithmetic and a worked check: `domains/depth/translucent-materials.md`
|
|
92
|
+
|
|
93
|
+
### 11. Translucency over translucency needs a tier gap
|
|
94
|
+
|
|
95
|
+
**Where one translucent surface overlaps another, the upper one is a visibly different tier — much heavier tint, or opaque.** Stacked blurs don't compose, and two surfaces at one tint read as one object: a panel over a dock at the dock's tint looks like the dock grew. At most one meaningful blur in any vertical stack.
|
|
96
|
+
|
|
97
|
+
### 12. A scrim says "modal", and only modal
|
|
98
|
+
|
|
99
|
+
**`elevation.overlay` brings a `scrim`; nothing below it does.** It says the rest of the interface is paused: a menu with one falsely claims to be modal; a dialog without one looks like a menu that won't close. A floating layer's outside-click catcher is transparent. The scrim darkens toward the ground's hue, stronger in dark themes, where dimming near-black barely registers and the overlay's surface step (law 5) does most of the separating. A blurred scrim — a full-viewport filter, the costliest surface there is — appears only where the preset allows and never animates its radius.
|
|
100
|
+
|
|
101
|
+
Modal also hands back what it took: **on close, focus returns to whatever opened it.** A dialog opened from state — a row's menu item, a shortcut, a selection — has no trigger for the primitive to fall back to, so store the opener before opening and restore it, or the next Tab starts from the top of the page.
|
|
102
|
+
|
|
103
|
+
→ Restoring focus, including when the opener is gone: `domains/depth/elevation-recipes.md`
|
|
104
|
+
|
|
105
|
+
### 13. Gradients on resizing elements are anchored in pixels
|
|
106
|
+
|
|
107
|
+
**A gradient stroke or fill on an element that changes size places its stops in absolute units, and a gradient stroke is two background layers on one box.** Percentage stops rescale with the box, so a panel opening from a 46px bar runs its whole sweep inside the closed bar — louder closed than open — and shimmers as it grows. Anchored at `0px / 120px / 240px`, the top holds still and the colour grows downward. The stroke also needs `background-origin: border-box`, or every side gets the wrong end of the ramp — which looks almost right.
|
|
108
|
+
|
|
109
|
+
→ The stroke's two layers, with no wrapper or mask: `domains/depth/translucent-materials.md`
|
|
110
|
+
|
|
111
|
+
### 14. Light is emitted from the sides and passes once
|
|
112
|
+
|
|
113
|
+
**A coloured glow is weighted to the sides so it reads as emission; a sheen crosses once and then stops.** One centred coloured shadow is a drop shadow that happens to be blue. A glow reporting a state — hovered, open — holds as long as the state. A looping sheen claims the thing is still working, so it runs once and is removed: `background-clip: text` with transparent text left in place is a selection and forced-colours hazard. Under reduced motion: no sweep, the plain colour.
|
|
114
|
+
|
|
115
|
+
### 15. Every material has its fallbacks, and they come last
|
|
116
|
+
|
|
117
|
+
**Each material ships an opaque baseline, then the material inside `@supports`, then its `prefers-reduced-transparency`, `prefers-contrast: more` and `forced-colors` rules — after every theme block that sets the same properties.** The order is the law: opaque first makes the non-supporting path the default, and so the tested one; why preferences go after every theme block is colour law 10.
|
|
118
|
+
|
|
119
|
+
Reduced transparency needs both halves, an opaque surface *and* no filter: filter only leaves a thin tint over live content, tint only still pays for the blur. High contrast promotes the edge to a real border and leaves the surface alone; forced colours needs law 3's edge. Read the *compiled* CSS: a prefixing pipeline may treat a hand-written `-webkit-backdrop-filter` for Safari as a duplicate and drop the standard property Firefox needs.
|
|
120
|
+
|
|
121
|
+
→ The full fallback block, in order: `domains/depth/fallbacks-and-performance.md`
|
|
122
|
+
|
|
123
|
+
### 16. Translucency has a budget, counted in area
|
|
124
|
+
|
|
125
|
+
**A `backdrop-filter` costs its area on every frame its backdrop changes — for chrome over a scrolling page, every frame of every scroll.** The budget is a count of surfaces and a ceiling on their size, per preset (Dials). Closed menus unmount, since at `opacity: 0` or `visibility: hidden` a filtered element still pays its pass; blur radius never animates; nothing filtered sits over video. Transitioning `box-shadow` repaints every frame too: across a list, put the lifted shadow on a pseudo-element and fade its opacity.
|
|
126
|
+
|
|
127
|
+
## Dials
|
|
128
|
+
|
|
129
|
+
| Dial | editorial | product | expressive |
|
|
130
|
+
|---|---|---|---|
|
|
131
|
+
| What gets a surface | Layout law 3's table; a card only for one object | Layout law 3's table; a card only for one object | Layout law 3's table; a card for one object or a named brand object |
|
|
132
|
+
| Cards nested in cards | Never | Never | Never |
|
|
133
|
+
| Translucency | Chrome only (a sticky header, a dock), or none | None on chrome or content — hairlines and opaque surfaces do the work; art only | Brand material, each use named |
|
|
134
|
+
| Translucent tiers | ≤ 2 (chrome, defensive) | Soften, on art only | ≤ 3 |
|
|
135
|
+
| Translucent surfaces on screen at once | ≤ 3, none wider than the reading measure except fixed chrome | 0, or 1 on art | ≤ 4; full-viewport only as a static overlay scrim |
|
|
136
|
+
| Buttons | Flat | Flat | Flat; the primary call to action may carry the page's one raised accent, a small shadow at `elevation.raised` |
|
|
137
|
+
| Elevation levels, at most | 3: flat, floating, overlay (raised only for sticky chrome) | 4: flat, raised, floating, overlay | 4, plus at most one named decorative level |
|
|
138
|
+
| `elevation.flat` separates by | Space or a surface step; edges rare | Space, and hairlines between rows (`border.divider`) — never an edge round a group of unlike content; a data table, one object, may take one hairline frame and a tinted header row | Surface step or edge, per brand |
|
|
139
|
+
| Edge above `elevation.flat` | A `hairline.*` border | A 0.5px `border.default` ring at the head of the shadow stack; a real 1px border under `prefers-contrast: more` | A border, or a named gradient stroke |
|
|
140
|
+
| Floating shadow, peak layer opacity (light / dark) | 6% / 50% | 10% / 55% | 16% / 60% |
|
|
141
|
+
| Shadow layers on floating | Contact + 2 ambient + inset highlight | Contact + 1 ambient | Contact + 2 ambient + inset highlight |
|
|
142
|
+
| Dark surface lift per level (OKLCH L) | +0.05 | +0.04 | +0.06 |
|
|
143
|
+
| Scrim (black-toward-ground opacity, light / dark) | 32% / 56% | 40% / 60% | 48% / 64%, brand-tinted allowed |
|
|
144
|
+
| Scrim blur | None | None | ≤ 8px, never animated |
|
|
145
|
+
| Gradient strokes and glows | At most one, as the colour system's exception | None | Allowed, each named for its job |
|
|
146
|
+
|
|
147
|
+
**Translucency.** Moving up a preset trades contrast certainty, a fallback path and a filter pass per surface for atmosphere. Fluent, daily product readers gain nothing from atmosphere and lose a lot to a header dimming to mid-grey over a data table. Editorial allows it on chrome because seeing the page being read scroll under keeps the reader oriented. Product's one opening is art: its backdrop is drawn, still and carries no text, so the material costs no contrast, and keeping it there is what lets the rest of the product stay opaque.
|
|
148
|
+
|
|
149
|
+
**Edge.** A ring inside the shadow sits quieter than a border beside rows parted by hairlines and moves with the shadow as one definition; it is still law 3's edge, so the contrast swap and the transparent border for forced colours go with it.
|
|
150
|
+
|
|
151
|
+
**Levels, at most.** A ceiling, not a target: a project ships only the levels it has a job for. Editorial pages mostly have nothing to pick up, so `elevation.raised` has no job; inventing one makes cards look liftable when they aren't. Product's dense screens hold draggable, sticky, floating and modal things at once. A fifth level usually means two differ too little to tell apart.
|
|
152
|
+
|
|
153
|
+
**Buttons.** A button sits in the flow, so it is flat. On an expressive landing page the one primary call to action may lift a step as the page's one raised accent, its own solid fill serving as law 3's edge; a shadow on every button is a shadow on none.
|
|
154
|
+
|
|
155
|
+
**Shadow strength.** Stronger shadows buy separation on busy screens at the cost of calm; past a point the eye reads a shadow as a shape. In every preset, if you can point at the shadow as a shadow, it's one step too strong for editorial.
|
|
156
|
+
|
|
157
|
+
Radii and corner shape are the layout guide's (layout law 5 and its dials).
|
|
158
|
+
|
|
159
|
+
## Building it for a project
|
|
160
|
+
|
|
161
|
+
**1. Classify every real component**, not a hypothetical list:
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
One object, or a region layout law 3 gives a surface? no → on the page, divided as layout law 3 says. Stop.
|
|
165
|
+
Scrolls with its neighbours, part of the page flow? → elevation.flat. Edge or surface step. Stop.
|
|
166
|
+
Picked up, dragged, or stuck while content passes under? → elevation.raised
|
|
167
|
+
Summoned over content, dismissed on its own? → elevation.floating (no scrim)
|
|
168
|
+
Blocks everything until answered? → elevation.overlay (with scrim)
|
|
169
|
+
|
|
170
|
+
Then, for raised and above, the material:
|
|
171
|
+
Preset allows translucency here? no → material.opaque
|
|
172
|
+
Content keeps moving under it, and seeing it helps? no → material.opaque
|
|
173
|
+
Holds reading copy? yes → material.opaque
|
|
174
|
+
Carries text below text.primary over an unknown backdrop? yes → the defensive tier
|
|
175
|
+
It or an ancestor animates a transform? yes → split wrapper/surface, or defensive
|
|
176
|
+
Over another translucent surface? yes → a tier gap, or opaque
|
|
177
|
+
Otherwise → material.translucent at the tier law 7 gives its backdrop.
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
**2. Build each level as a bundle**: surface from the colour guide (`surface.raised`, `surface.inverse`, the dark lift from the Dials), edge from `border.default` or `border.layered` at a `hairline.*` weight, shadow per law 4 at the preset's strength, layer from the layout guide's `z.*` ladder; one shadow per level per theme.
|
|
181
|
+
|
|
182
|
+
**3. Derive the dark theme separately**: lift each surface by the dial's step, rebuild the shadow at dark opacities with a weaker highlight, and check the edge alone still separates each level from the one below.
|
|
183
|
+
|
|
184
|
+
**4. Add materials last**, fallbacks in order (law 15), counted against the budget.
|
|
185
|
+
|
|
186
|
+
**5. Measure** every text tier on every translucent surface against its worst backdrop (law 10) with `contrast.mjs --over` (colour law 3); the focus ring at 3:1 on each level's surface.
|
|
187
|
+
|
|
188
|
+
→ Bundle tables per preset and theme, shadow derivation and a worked example: `domains/depth/elevation-recipes.md`
|
|
189
|
+
→ Blur, saturation, tint, gradient strokes, glows and sheens: `domains/depth/translucent-materials.md`
|
|
190
|
+
|
|
191
|
+
## Auditing existing code
|
|
192
|
+
|
|
193
|
+
Start from the setup scan's `Shadows` and `Backdrop filters` tables, then grep for what it can't pair:
|
|
194
|
+
|
|
195
|
+
```sh
|
|
196
|
+
rg -n "box-shadow|shadow-\[|shadow-(sm|md|lg|xl|2xl)" src
|
|
197
|
+
rg -n "backdrop-filter|backdrop-blur|-webkit-backdrop" src
|
|
198
|
+
rg -n "prefers-reduced-transparency|prefers-contrast|forced-colors|@supports" src
|
|
199
|
+
rg -n "z-index|z-\[|\bz-[0-9]" src
|
|
200
|
+
rg -n "background-clip:\s*text|animation:.*infinite" src
|
|
201
|
+
rg -n "border rounded|rounded-\S+ border|border-\S+ rounded|<Card\b" src # boxes: count per screen
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Then collect evidence per law:
|
|
205
|
+
|
|
206
|
+
- **Boxes**, per screen: every edged or shadowed region, and what it holds. A card round a section, a list, a table or a single figure, a card inside a card, a tile per stat, a tinted strip behind a label — each is a finding. Screenshot the screen and count edges; a working screen rarely needs more than a few cards.
|
|
207
|
+
|
|
208
|
+
- **Distinct shadows**, clustered: more than levels means improvised ones; list each stray with its file.
|
|
209
|
+
- **Bundles** for each component above flat: a shadow with no edge, a floating shadow on a flat element, a modal without a scrim, a menu with one — each is a finding.
|
|
210
|
+
- **Dark theme**: does each surface lift, or just its shadow? Is the page `#000`?
|
|
211
|
+
- **Each filtered element**: saturation, tint, ancestors' transforms, what it can overlap, text tiers against the worst backdrop.
|
|
212
|
+
- **Fallbacks**: present, both halves of reduced transparency, *after* the theme blocks; the prefix in compiled CSS.
|
|
213
|
+
- **Budget**: filtered surfaces visible at once, any at `opacity: 0` rather than unmounted, any animated blur.
|
|
214
|
+
|
|
215
|
+
## Writing back to DESIGN.md
|
|
216
|
+
|
|
217
|
+
- **`## Surfaces`** — the bundle table (`Level | Surface | Edge | Shadow | Layer`) in the project's names, per theme where surface or shadow differs, plus a material table (`Tier | Blur | Saturate | Tint | Fallback | Used on`) if it uses translucency. Write it on first build or audit; update it as levels change.
|
|
218
|
+
- **Proposed roles** — level surfaces the canonical list lacks (`surface.floating`, `surface.overlay`), marked `proposed`.
|
|
219
|
+
|
|
220
|
+
## Checklist
|
|
221
|
+
|
|
222
|
+
- [ ] Every dialog, destructive confirmations included, is the system's dialog; closing one returns focus to its opener, stored when it opened from state.
|
|
223
|
+
- [ ] A ring edge becomes a real 1px border under `prefers-contrast: more`; an edgeless surface carries `1px solid transparent` for `forced-colors`.
|
|
224
|
+
- [ ] Animating wrapper and filtered surface are separate elements, or the tint is defensive.
|
|
225
|
+
- [ ] Compiled CSS has both `backdrop-filter` and `-webkit-backdrop-filter`.
|
|
226
|
+
- [ ] Closed filtered surfaces unmount; no animated blur; nothing filtered over video.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# Translucent materials
|
|
2
|
+
|
|
3
|
+
Only for presets that allow translucency, and surfaces that passed the decision flow in `domains/depth/guide.md` (*Building it for a project*).
|
|
4
|
+
|
|
5
|
+
## What convinces
|
|
6
|
+
|
|
7
|
+
Material, not effect, takes all five; drop one and it stops convincing.
|
|
8
|
+
|
|
9
|
+
| | Property | Job | Without it |
|
|
10
|
+
|---|---|---|---|
|
|
11
|
+
| 1 | `blur()` | Suppress the backdrop so the surface's own content reads | Text over text |
|
|
12
|
+
| 2 | `saturate()` | Undo the greying that blur causes | Cloudy, dirty wash |
|
|
13
|
+
| 3 | Tint | Carry legibility, identity and the theme | Illegible whenever the blur fails |
|
|
14
|
+
| 4 | Edge (`border.layered`) | Say where the surface ends | Mush at the boundary |
|
|
15
|
+
| 5 | Shadow (the level's) | Say it is above | A flat patch, not a floating object |
|
|
16
|
+
|
|
17
|
+
Properties 4 and 5 are the level's, not the material's: a translucent floating menu takes an opaque one's edge weight, shadow and layer. The material only swaps the surface.
|
|
18
|
+
|
|
19
|
+
## Choosing the tier
|
|
20
|
+
|
|
21
|
+
One question per surface: **how much do you know about what's behind it?**
|
|
22
|
+
|
|
23
|
+
| You know… | Tier | Blur | Saturate | Tint |
|
|
24
|
+
|---|---|---|---|---|
|
|
25
|
+
| Exactly — and you want it recognised (cards behind a frosted pocket) | Soften | 6–10px | 1.2–1.4 | 35–60%, may be a gradient |
|
|
26
|
+
| Only that page content scrolls under (a sticky header, a dock) | Chrome | 16–24px | 1.6–1.8 | 70–80% |
|
|
27
|
+
| Nothing — it may open over images or other translucent surfaces, or its wrapper animates | Defensive | 20–28px | 1.6–1.8 | 92–97% |
|
|
28
|
+
|
|
29
|
+
Most projects need one tier, chrome; a second when a floating panel opens over it, a third only when something decorative wants its backdrop seen. Define tiers for real surfaces, never expected ones; an unused tier is dead CSS someone will treat as load-bearing. Name tiers by job under `material.translucent` — `chrome`, `defensive`, `soften` — in the project's naming style.
|
|
30
|
+
|
|
31
|
+
## Why saturation
|
|
32
|
+
|
|
33
|
+
Blur is a convolution: each pixel averages its neighbours, pulling colours toward grey, more so the wider the blur. Filters apply in order, so `saturate()` after `blur()` corrects the already-blurred result:
|
|
34
|
+
|
|
35
|
+
```css
|
|
36
|
+
backdrop-filter: blur(20px) saturate(1.7);
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
More blur wants more saturation; softening tiers sit lower, since boosting a backdrop that is the subject makes the pocket louder than its contents. Above about 2.0, colours look radioactive. `brightness()` and `contrast()` are second-order; `grayscale()` on a backdrop defeats the material.
|
|
40
|
+
|
|
41
|
+
## The tint
|
|
42
|
+
|
|
43
|
+
Its colours belong to the colour guide, as does why they are runtime custom properties, not build-time tokens (a media query replaces them; a gradient isn't a colour token systems accept). This guide sets **opacity** by tier, plus three depth rules:
|
|
44
|
+
|
|
45
|
+
- **In light themes, not pure white.** A tint matching `surface.raised`, a step off the page, reads as an object on it; white on a white page reads as a hole.
|
|
46
|
+
- **In dark themes, lighter than the page**, since raised means more contrast; a darker tint is a hole the other way.
|
|
47
|
+
- **On a large surface, a gradient**, brighter at the top where the light is — the inset highlight at a scale where a 1px line would be lost. In dark themes keep the direction, change the base: a white-based gradient over a dark page reads as fog.
|
|
48
|
+
|
|
49
|
+
## Text against the worst backdrop
|
|
50
|
+
|
|
51
|
+
Browsers composite a translucent tint per channel:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
shown = α × tint + (1 − α) × backdrop
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Blur doesn't rescue the worst case; a large dark patch stays dark after averaging. Composite over the lightest and darkest possible backdrop — `#fff` and `#000` if arbitrary — and check every tier against both.
|
|
58
|
+
|
|
59
|
+
A worked check: chrome tint `rgb(250 250 250 / 0.72)`, light-theme ladder `text.primary #16161a`, `text.secondary #4b4b52`, `text.tertiary #6b6b73`.
|
|
60
|
+
|
|
61
|
+
| Backdrop | Composite | `text.primary` | `text.secondary` | `text.tertiary` |
|
|
62
|
+
|---|---|---|---|---|
|
|
63
|
+
| White page | `#fbfbfb` | passes | passes | 5.1:1 |
|
|
64
|
+
| Black photograph | `#b4b4b4` | 8.7:1 | **4.2:1** | **2.6:1** |
|
|
65
|
+
|
|
66
|
+
At 95% the dark composite becomes `#eeeeee`: `text.secondary` 7.5:1, `text.tertiary` 4.55:1 — passing, just. Hence the defensive tier for anything below `text.primary` over arbitrary content.
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
# --over composites the tint over the backdrop, then checks the ladder on the result (#b4b4b4)
|
|
70
|
+
node <keyline>/scripts/contrast.mjs --over "#000" --ladder "rgba(250,250,250,0.72)" "#16161a" "#4b4b52" "#6b6b73"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Record the ratio against the worst composite in `DESIGN.md`, naming the backdrop.
|
|
74
|
+
|
|
75
|
+
## A pocket on art
|
|
76
|
+
|
|
77
|
+
The one material a `product` preset keeps: a frosted pocket on an illustration — a folder's front panel showing the sheets behind it — at the soften tier.
|
|
78
|
+
|
|
79
|
+
```css
|
|
80
|
+
.pocket { background: var(--surface-page); } /* opaque baseline */
|
|
81
|
+
@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
|
|
82
|
+
.pocket {
|
|
83
|
+
background: linear-gradient(180deg, var(--pocket-tint-top), var(--pocket-tint-bottom));
|
|
84
|
+
backdrop-filter: blur(8px) saturate(1.4);
|
|
85
|
+
box-shadow: inset 0 1px 0 var(--pocket-catch); /* catch light on the top edge */
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
@media (prefers-reduced-transparency: reduce) {
|
|
89
|
+
.pocket { background: var(--surface-page); backdrop-filter: none; }
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The backdrop is drawn by you and holds still, so saturation stays low (it is the subject) and nothing needs measuring for text. Over art that animates, skip the filter and draw the frost into the art.
|
|
94
|
+
|
|
95
|
+
## Glass over glass
|
|
96
|
+
|
|
97
|
+
The upper filter samples the lower one's filtered output, so the result varies with stacking context, paint order and browser rather than doubling the frost; and two surfaces at one tint read as one object. The upper surface goes defensive or opaque, its blur decoration. **At most one meaningful blur in any vertical stack.**
|
|
98
|
+
|
|
99
|
+
## The wrapper and the surface
|
|
100
|
+
|
|
101
|
+
A `transform`, `filter`, `perspective`, `contain: paint` or `will-change: transform` on an element or any ancestor changes what `backdrop-filter` samples in its subtree — on an animating wrapper, every frame. Animate one element, filter another:
|
|
102
|
+
|
|
103
|
+
```html
|
|
104
|
+
<div class="menu-motion"> <!-- animates scale and translate; no filter -->
|
|
105
|
+
<ul class="material-defensive …"> <!-- filters; never transformed -->
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The same properties make an ancestor the containing block for `position: fixed` descendants, trapping a full-bleed overlay in a transformed strip. When a filtered or fixed element misbehaves, walk the ancestors for all five first.
|
|
109
|
+
|
|
110
|
+
## Gradient strokes
|
|
111
|
+
|
|
112
|
+
A 1px edge that changes colour down its height, with no wrapper and no mask:
|
|
113
|
+
|
|
114
|
+
```css
|
|
115
|
+
.stroke-panel {
|
|
116
|
+
border: 1px solid transparent;
|
|
117
|
+
background-image:
|
|
118
|
+
linear-gradient(var(--panel-fill-top), var(--panel-fill-bottom)), /* fill */
|
|
119
|
+
linear-gradient(180deg, /* stroke */
|
|
120
|
+
var(--panel-edge-top) 0px,
|
|
121
|
+
var(--panel-edge-mid) 120px,
|
|
122
|
+
var(--panel-edge-bottom) 240px);
|
|
123
|
+
background-origin: border-box;
|
|
124
|
+
background-clip: padding-box, border-box;
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The fill clips to `padding-box`, the stroke to `border-box`, and `background-origin: border-box` sizes both to the border box; without it the stroke is sized to the padding box but clipped to the border box, and every side shows the wrong end of the ramp — which looks almost right. Pixel stops because the panel resizes: percentages re-fit every frame, so a closed bar runs the whole sweep in its own height and shimmers as it grows. They suit only a fixed-size box.
|
|
129
|
+
|
|
130
|
+
The stroke colours are a chromatic spend — in most presets the colour system's one exception — and the colour guide's to name.
|
|
131
|
+
|
|
132
|
+
## Glows
|
|
133
|
+
|
|
134
|
+
```css
|
|
135
|
+
--panel-glow:
|
|
136
|
+
0 1px 2px rgb(from var(--panel-ink) r g b / 0.05), /* contact */
|
|
137
|
+
-10px 0 20px -18px rgb(from var(--panel-hue) r g b / 0.38), /* left */
|
|
138
|
+
10px 0 20px -18px rgb(from var(--panel-hue) r g b / 0.38), /* right */
|
|
139
|
+
0 10px 24px -16px rgb(from var(--panel-hue) r g b / 0.30); /* below */
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
The two horizontal casts make it emission, and heavy negative spread keeps each tucked. A glow reporting state holds for the state — `:hover, [data-open="true"]` — so the light agrees with the chevron. Timing is the motion guide's; under reduced motion the change is instant, not removed.
|
|
143
|
+
|
|
144
|
+
## Sheens
|
|
145
|
+
|
|
146
|
+
```css
|
|
147
|
+
.sheen {
|
|
148
|
+
background-image: linear-gradient(100deg, var(--label) 40%, var(--label-hi) 50%, var(--label) 60%);
|
|
149
|
+
background-size: 250% 100%;
|
|
150
|
+
background-clip: text;
|
|
151
|
+
color: transparent;
|
|
152
|
+
animation: sheen 700ms <ease.standard> both; /* once — never infinite */
|
|
153
|
+
}
|
|
154
|
+
@media (prefers-reduced-motion: reduce) {
|
|
155
|
+
.sheen { animation: none; background-image: none; color: var(--label); }
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Remove the class on `animationend` so the label returns to painted text; clipped-background text left in place breaks selection highlighting and forced colours.
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# Forks, gating and disclosures
|
|
2
|
+
|
|
3
|
+
## The fork test
|
|
4
|
+
|
|
5
|
+
**Does the interaction model change, or only the arrangement?**
|
|
6
|
+
|
|
7
|
+
| Change | Kind | Tool |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| A grid goes from one column to three | Arrangement | Breakpoint |
|
|
10
|
+
| A hover card sits below its row on narrow screens, beside it on wide | Arrangement | Breakpoint — only position moves |
|
|
11
|
+
| Padding shrinks, a row wraps, a sidebar stacks above content | Arrangement | Breakpoint |
|
|
12
|
+
| Navigation revealed on hover and collapsed on scroll, versus opened by tap into a sheet | Interaction model | Two components |
|
|
13
|
+
| A sticky table of contents in the margin, versus a one-line disclosure above the article | Interaction model | Two components |
|
|
14
|
+
| A data table with column resizing and row hover actions, versus a stacked card list with a swipe or a menu | Interaction model | Two components |
|
|
15
|
+
|
|
16
|
+
When it forks, **share the data and the row rendering; fork the container**: destinations, tree, item component and preferences hook live once, and each container owns only how they are revealed and dismissed.
|
|
17
|
+
|
|
18
|
+
```tsx
|
|
19
|
+
function Rows({ nodes, active, onJump }) { /* shared: one row per node */ }
|
|
20
|
+
|
|
21
|
+
export function ContentsRail(props) { return <nav aria-label="On this page"><ul><Rows {...props} /></ul></nav>; }
|
|
22
|
+
export function ContentsCompact(props) { return <details><summary>On this page</summary><ul><Rows {...props} /></ul></details>; }
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Both render and CSS shows one, so both stay in the served HTML and the swap costs nothing at runtime.
|
|
26
|
+
|
|
27
|
+
**The synchronisation hazard.** Two mounted presentations each holding a copy of some state disagree once the viewport crosses the breakpoint — a preference toggled in the desktop panel shows stale in the mobile sheet. Have the longer-lived one re-read the stored values as its panel opens or, once more than one value is involved, lift the state into a store both subscribe to.
|
|
28
|
+
|
|
29
|
+
## Capability, not width
|
|
30
|
+
|
|
31
|
+
| Query | Means |
|
|
32
|
+
|---|---|
|
|
33
|
+
| `(hover: hover)` | The primary input can hover |
|
|
34
|
+
| `(pointer: fine)` | The primary input is precise — mouse, trackpad, stylus |
|
|
35
|
+
| `(pointer: coarse)` | The primary input is a finger |
|
|
36
|
+
| `(any-hover: hover)` | *Some* input can hover — too permissive for a touchscreen laptop used by finger |
|
|
37
|
+
|
|
38
|
+
Gate every hover style, decoration included, on `(hover: hover) and (pointer: fine)` together, in CSS and in script; a framework's `hover:` utilities may already wrap themselves in a hover media query, but imperative handlers never do.
|
|
39
|
+
|
|
40
|
+
**Check where you hide, not where you reveal.** A dock that collapses on scroll and expands on hover must never collapse on touch, where nothing brings it back, so bail out of the whole effect and attach no scroll listener:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!matchMedia("(hover: hover) and (pointer: fine)").matches) return;
|
|
45
|
+
// attach the collapse-on-scroll listener
|
|
46
|
+
}, []);
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## The touch audit
|
|
50
|
+
|
|
51
|
+
For every hover behaviour, write down what a touch user gets; "nothing" is a bug unless it is purely decorative.
|
|
52
|
+
|
|
53
|
+
| Hover behaviour | Touch equivalent |
|
|
54
|
+
|---|---|
|
|
55
|
+
| A tooltip naming an icon | A visible text label |
|
|
56
|
+
| A settings panel opened on hover | A row in the menu sheet |
|
|
57
|
+
| Row actions revealed on hover | A persistent overflow button, or a long-press menu |
|
|
58
|
+
| A detail card beside a row | The same card below the row, or on the row's own page |
|
|
59
|
+
| A decorative lift on a card | None — the card is still a link; the lift is still gated |
|
|
60
|
+
|
|
61
|
+
## Breakpoints from content
|
|
62
|
+
|
|
63
|
+
Resize slowly and note where a specific decision fails — the rail no longer fits beside the column, the three-up grid's cards drop below a readable width, the inspector squeezes the content under its minimum — then name the breakpoint for it (`bp.rail`, `bp.grid-3`, `bp.inspector`) and record why it sits there. A breakpoint from a device list never lines up with the layout.
|
|
64
|
+
|
|
65
|
+
### On the container, inside a shell
|
|
66
|
+
|
|
67
|
+
A shell's content panel is the window minus whatever the sidebar and inspector take, and that changes while the window doesn't. A list and detail view split on a window query showed both panes at a width where, with the sidebar open, the detail had about 150px. Query the panel instead, and let each pane size itself from its own width:
|
|
68
|
+
|
|
69
|
+
```css
|
|
70
|
+
.panel { container: panel / inline-size; }
|
|
71
|
+
.split { display: grid; grid-template-columns: 1fr; }
|
|
72
|
+
@container panel (min-width: 720px) {
|
|
73
|
+
.split { grid-template-columns: clamp(240px, 32cqi, 360px) 1fr; } /* the list gives way first */
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.detail { container: detail / inline-size; padding-inline: var(--gutter); }
|
|
77
|
+
@container detail (min-width: 560px) { .detail { padding-inline: calc(var(--gutter) * 2); } }
|
|
78
|
+
@container detail (max-width: 480px) { .detail-actions .label { display: none; } } /* icons keep their tooltips */
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The list is clamped so it yields before the detail does, and the detail decides its own gutters, side rail and header labels, so its actions shrink to icons rather than clip. Name container breakpoints like any other (`bp.split`, `bp.detail-rail`).
|
|
82
|
+
|
|
83
|
+
### Size and count are separate
|
|
84
|
+
|
|
85
|
+
"Make the folders 25% smaller" is a request about size: the items shrink and the row keeps its count. Fitting more per row is a different change, to the grid, and happens only when asked. Treat the two as separate tokens — an item width and a column count — so one can't move the other by accident.
|
|
86
|
+
|
|
87
|
+
## Reader-chosen views
|
|
88
|
+
|
|
89
|
+
### State in a module both sides read
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
type View = "grid" | "focus";
|
|
93
|
+
let current: View = "grid";
|
|
94
|
+
const listeners = new Set<(v: View) => void>();
|
|
95
|
+
|
|
96
|
+
export const getView = () => current;
|
|
97
|
+
export function setView(v: View) { current = v; listeners.forEach((fn) => fn(v)); }
|
|
98
|
+
export function onView(fn: (v: View) => void) { listeners.add(fn); return () => listeners.delete(fn); }
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The toggle and the view are siblings, so neither owns the value. On a server-rendered page, read it after mount, not during render: the server always renders the default, and a client holding another value would paint something the markup disagrees with.
|
|
102
|
+
|
|
103
|
+
### Whether it persists
|
|
104
|
+
|
|
105
|
+
| Preference about… | Examples | Persist? |
|
|
106
|
+
|---|---|---|
|
|
107
|
+
| The reader | Theme, sound, reduced motion, density | Yes |
|
|
108
|
+
| A thing they were looking at | Grid versus list for a gallery, which tab of a spec was open | No — reset on arrival |
|
|
109
|
+
| A working setup the user built on purpose | A saved board view, a filtered table with a name | Yes, as a named, visible object, and recorded under `## Decisions` |
|
|
110
|
+
| How they work a page they return to daily (product) | Board or list for the task page, a week or a three-day span | Yes, per page, with the switch always visible in the page header, recorded under `## Decisions` |
|
|
111
|
+
| A default that is the point | A folder view that always opens on its folders | No — the default wins even where the page's other choices persist |
|
|
112
|
+
|
|
113
|
+
A persisted toggle lands a reader in a mode picked weeks ago for a different collection, and they assume the page is broken; a saved view escapes this because it is visible and named, and a per-page choice because the switch showing it sits in the header of the page it governs.
|
|
114
|
+
|
|
115
|
+
### Which views a collection gets
|
|
116
|
+
|
|
117
|
+
Which second view shows how a collection moves — a board, a week board — is patterns law 10. A collection that doesn't move — a directory, an archive — gets a list and no board.
|
|
118
|
+
|
|
119
|
+
### Carry the position across
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
function nearestToCentre(items: Element[]) {
|
|
123
|
+
const mid = innerHeight / 2;
|
|
124
|
+
return items.reduce((best, el) => {
|
|
125
|
+
const r = el.getBoundingClientRect();
|
|
126
|
+
const d = Math.abs(r.top + r.height / 2 - mid);
|
|
127
|
+
return d < best.d ? { el, d } : best;
|
|
128
|
+
}, { el: items[0], d: Infinity }).el;
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Open the incoming view on the item nearest the viewport centre, found before the outgoing view unmounts. Tiles at their content's own aspect ratio (`aspect-ratio: w / h`), not a uniform box, crop nothing, don't shift on load, and let one item move between layouts undistorted; the transition belongs to the motion guide.
|
|
133
|
+
|
|
134
|
+
## Content gating
|
|
135
|
+
|
|
136
|
+
### Owner-curated collections: one flag, every consumer
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
export const has = { writing: posts.length > 0, talks: talks.length > 0 };
|
|
140
|
+
export const routeEnabled: Record<string, boolean> = { "/writing": has.writing, "/talks": has.talks };
|
|
141
|
+
|
|
142
|
+
const pages = [has.writing && { to: "/writing", label: "Writing" }, has.talks && { to: "/talks", label: "Talks" }].filter(Boolean);
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Navigation, route table, home-page sections, sitemap and metadata all read the same flag. On a file-based router, where the file is the route, the gate moves into the page and a catch-all sends everything else somewhere real:
|
|
146
|
+
|
|
147
|
+
```tsx
|
|
148
|
+
export default function Page() { if (!has.writing) redirect("/"); return <Writing />; }
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Keep `routeEnabled` even when the router ignores it: the sitemap, metadata and nav still need to know which paths exist, because that is content and the router is one consumer. **Filter before placing dividers**, so no rule hangs above an absent section:
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
const shown = sections.filter((s) => hasContent[s]); // then place rules between `shown`
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Grids at every count
|
|
158
|
+
|
|
159
|
+
One or two items in a three-up grid stay item-sized and centred, not stretched:
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
const COLS = ["", "grid-cols-1", "grid-cols-2", "grid-cols-3"];
|
|
163
|
+
const WIDTH = ["", "max-w-[200px]", "max-w-[420px]", ""];
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Where the styling system scans source for class names, write literal strings; an interpolated `grid-cols-${n}` never reaches the stylesheet.
|
|
167
|
+
|
|
168
|
+
### User data: an empty state that teaches
|
|
169
|
+
|
|
170
|
+
| State | What it says | What it offers |
|
|
171
|
+
|---|---|---|
|
|
172
|
+
| Empty because new | What goes here and why it's worth having — one or two sentences at `measure.reading` | The one action that creates the first item; optionally an import or a sample |
|
|
173
|
+
| Empty because filtered | "No shipments match *Delayed, this week*" — naming the filter | Clear the filter; never implies the data is gone |
|
|
174
|
+
| Empty because failed | What failed, in plain words | Retry, and where to get help if it fails again |
|
|
175
|
+
| Loading | Nothing, or a skeleton shaped like the content | — never an empty-state message, which flashes before the data |
|
|
176
|
+
|
|
177
|
+
An illustration is optional; the action is not — a friendly drawing above "Nothing here yet" teaches nothing.
|
|
178
|
+
|
|
179
|
+
## Disclosures
|
|
180
|
+
|
|
181
|
+
Prefer `<details>`/`<summary>`: keyboard behaviour, expanded state, find-in-page (browsers open a closed `<details>` to reveal a match) and no-JS support come free. Hide the default marker and drive a chevron from the parent's `open` attribute. Where a custom animation rules it out, collapse without unmounting:
|
|
182
|
+
|
|
183
|
+
```html
|
|
184
|
+
<div class="panel" style="overflow: hidden; block-size: 0" inert aria-hidden="true">…</div>
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Animate `block-size` to the content's height on open and remove `inert` and `aria-hidden` together. Unmount only content that is genuinely expensive (a heavy chart, a video) or genuinely absent.
|
|
188
|
+
|
|
189
|
+
## Structural details
|
|
190
|
+
|
|
191
|
+
```css
|
|
192
|
+
.skip { position: absolute; inset-inline-start: 16px; top: 16px; z-index: var(--z-skip-link); transform: translateY(-200%); }
|
|
193
|
+
.skip:focus { transform: none; }
|
|
194
|
+
:where(h2, h3, h4)[id] { scroll-margin-top: var(--reading-line); }
|
|
195
|
+
|
|
196
|
+
@media print {
|
|
197
|
+
nav, footer, .skip { display: none; } /* reveals: motion/guards.md */
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
`--reading-line` is the scroll spy's offset; if the two disagree, a jump lands a heading just above the line and the contents highlights the previous section.
|