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.
Files changed (96) hide show
  1. package/.claude-plugin/marketplace.json +18 -0
  2. package/LICENSE +37 -0
  3. package/README.md +117 -2
  4. package/bin/keyline.mjs +280 -0
  5. package/package.json +38 -4
  6. package/plugins/keyline/.claude-plugin/plugin.json +14 -0
  7. package/plugins/keyline/skills/keyline/SKILL.md +57 -0
  8. package/plugins/keyline/skills/keyline/commands/audit.md +25 -0
  9. package/plugins/keyline/skills/keyline/commands/color.md +7 -0
  10. package/plugins/keyline/skills/keyline/commands/critique.md +43 -0
  11. package/plugins/keyline/skills/keyline/commands/depth.md +7 -0
  12. package/plugins/keyline/skills/keyline/commands/init.md +15 -0
  13. package/plugins/keyline/skills/keyline/commands/layout.md +7 -0
  14. package/plugins/keyline/skills/keyline/commands/motion.md +7 -0
  15. package/plugins/keyline/skills/keyline/commands/patterns.md +7 -0
  16. package/plugins/keyline/skills/keyline/commands/polish.md +19 -0
  17. package/plugins/keyline/skills/keyline/commands/refine.md +29 -0
  18. package/plugins/keyline/skills/keyline/commands/sync.md +17 -0
  19. package/plugins/keyline/skills/keyline/commands/type.md +7 -0
  20. package/plugins/keyline/skills/keyline/domains/color/deriving-a-ramp.md +144 -0
  21. package/plugins/keyline/skills/keyline/domains/color/guide.md +243 -0
  22. package/plugins/keyline/skills/keyline/domains/color/status-and-exceptions.md +174 -0
  23. package/plugins/keyline/skills/keyline/domains/color/theming-mechanics.md +121 -0
  24. package/plugins/keyline/skills/keyline/domains/depth/elevation-recipes.md +191 -0
  25. package/plugins/keyline/skills/keyline/domains/depth/fallbacks-and-performance.md +132 -0
  26. package/plugins/keyline/skills/keyline/domains/depth/guide.md +255 -0
  27. package/plugins/keyline/skills/keyline/domains/depth/translucent-materials.md +159 -0
  28. package/plugins/keyline/skills/keyline/domains/layout/forks-gating-and-disclosures.md +201 -0
  29. package/plugins/keyline/skills/keyline/domains/layout/guide.md +284 -0
  30. package/plugins/keyline/skills/keyline/domains/layout/measure-and-rhythm.md +193 -0
  31. package/plugins/keyline/skills/keyline/domains/layout/radius-lines-and-layers.md +122 -0
  32. package/plugins/keyline/skills/keyline/domains/motion/guards.md +162 -0
  33. package/plugins/keyline/skills/keyline/domains/motion/guide.md +234 -0
  34. package/plugins/keyline/skills/keyline/domains/motion/ladder-and-curves.md +139 -0
  35. package/plugins/keyline/skills/keyline/domains/motion/patterns.md +196 -0
  36. package/plugins/keyline/skills/keyline/domains/motion/performance.md +121 -0
  37. package/plugins/keyline/skills/keyline/domains/patterns/actions-and-forms.md +204 -0
  38. package/plugins/keyline/skills/keyline/domains/patterns/agents-and-live-states.md +111 -0
  39. package/plugins/keyline/skills/keyline/domains/patterns/collections-and-dashboards.md +131 -0
  40. package/plugins/keyline/skills/keyline/domains/patterns/copy.md +123 -0
  41. package/plugins/keyline/skills/keyline/domains/patterns/guide.md +216 -0
  42. package/plugins/keyline/skills/keyline/domains/patterns/iteration.md +78 -0
  43. package/plugins/keyline/skills/keyline/domains/type/display-tier.md +108 -0
  44. package/plugins/keyline/skills/keyline/domains/type/guide.md +251 -0
  45. package/plugins/keyline/skills/keyline/domains/type/ladders.md +169 -0
  46. package/plugins/keyline/skills/keyline/domains/type/numerals-and-loading.md +107 -0
  47. package/plugins/keyline/skills/keyline/domains/type/prose-and-emphasis.md +125 -0
  48. package/plugins/keyline/skills/keyline/domains/type/type-audit.md +90 -0
  49. package/plugins/keyline/skills/keyline/glass/color/assets/color-tokens.css +319 -0
  50. package/plugins/keyline/skills/keyline/glass/color/assets/theme.ts +119 -0
  51. package/plugins/keyline/skills/keyline/glass/color/guide.md +222 -0
  52. package/plugins/keyline/skills/keyline/glass/color/intent-ramp.md +136 -0
  53. package/plugins/keyline/skills/keyline/glass/color/theming.md +112 -0
  54. package/plugins/keyline/skills/keyline/glass/depth/assets/GlassPanel.tsx +171 -0
  55. package/plugins/keyline/skills/keyline/glass/depth/assets/glass.css +322 -0
  56. package/plugins/keyline/skills/keyline/glass/depth/blur-and-tint.md +50 -0
  57. package/plugins/keyline/skills/keyline/glass/depth/depth-cues.md +87 -0
  58. package/plugins/keyline/skills/keyline/glass/depth/guide.md +216 -0
  59. package/plugins/keyline/skills/keyline/glass/depth/performance-and-fallbacks.md +37 -0
  60. package/plugins/keyline/skills/keyline/glass/layout/adaptive-presentations.md +231 -0
  61. package/plugins/keyline/skills/keyline/glass/layout/assets/SectionNav.tsx +244 -0
  62. package/plugins/keyline/skills/keyline/glass/layout/assets/layout-tokens.css +199 -0
  63. package/plugins/keyline/skills/keyline/glass/layout/guide.md +258 -0
  64. package/plugins/keyline/skills/keyline/glass/layout/measure-and-rhythm.md +134 -0
  65. package/plugins/keyline/skills/keyline/glass/layout/radius-and-elevation.md +102 -0
  66. package/plugins/keyline/skills/keyline/glass/motion/assets/DropMenu.tsx +106 -0
  67. package/plugins/keyline/skills/keyline/glass/motion/assets/motion-presets.ts +210 -0
  68. package/plugins/keyline/skills/keyline/glass/motion/assets/motion-tokens.css +176 -0
  69. package/plugins/keyline/skills/keyline/glass/motion/assets/useReveal.ts +65 -0
  70. package/plugins/keyline/skills/keyline/glass/motion/assets/useScrollTick.ts +183 -0
  71. package/plugins/keyline/skills/keyline/glass/motion/easing-and-duration.md +108 -0
  72. package/plugins/keyline/skills/keyline/glass/motion/guide.md +286 -0
  73. package/plugins/keyline/skills/keyline/glass/motion/reduced-motion.md +81 -0
  74. package/plugins/keyline/skills/keyline/glass/motion/scroll-performance.md +58 -0
  75. package/plugins/keyline/skills/keyline/glass/motion/shared-element.md +161 -0
  76. package/plugins/keyline/skills/keyline/glass/motion/sound.md +192 -0
  77. package/plugins/keyline/skills/keyline/glass/motion/springs.md +51 -0
  78. package/plugins/keyline/skills/keyline/glass/type/assets/prose.tsx +268 -0
  79. package/plugins/keyline/skills/keyline/glass/type/assets/type-tokens.css +414 -0
  80. package/plugins/keyline/skills/keyline/glass/type/guide.md +222 -0
  81. package/plugins/keyline/skills/keyline/glass/type/long-form-prose.md +151 -0
  82. package/plugins/keyline/skills/keyline/glass/type/scale.md +69 -0
  83. package/plugins/keyline/skills/keyline/glass/type/weight-and-tracking.md +61 -0
  84. package/plugins/keyline/skills/keyline/scripts/check/css.mjs +112 -0
  85. package/plugins/keyline/skills/keyline/scripts/check/design.mjs +67 -0
  86. package/plugins/keyline/skills/keyline/scripts/check/files.mjs +44 -0
  87. package/plugins/keyline/skills/keyline/scripts/check/markup.mjs +149 -0
  88. package/plugins/keyline/skills/keyline/scripts/check/rules.mjs +584 -0
  89. package/plugins/keyline/skills/keyline/scripts/check.mjs +294 -0
  90. package/plugins/keyline/skills/keyline/scripts/contrast.mjs +153 -0
  91. package/plugins/keyline/skills/keyline/scripts/scan.mjs +340 -0
  92. package/plugins/keyline/skills/keyline/system/design-md.md +136 -0
  93. package/plugins/keyline/skills/keyline/system/detection.md +53 -0
  94. package/plugins/keyline/skills/keyline/system/guide.md +101 -0
  95. package/plugins/keyline/skills/keyline/system/presets.md +58 -0
  96. package/plugins/keyline/skills/keyline/system/roles.md +150 -0
@@ -0,0 +1,174 @@
1
+ # Status colours and chromatic exceptions
2
+
3
+ A status colour says *"what state is this in?"* from a small, fixed vocabulary; a hue family says what a thing is, where it lives or who; an exception asks *"may this element announce itself?"* and has to earn it. Each colour has exactly one of those jobs, written down. Mixing them grows the palette: a success green ends up on a "new" badge because it was there.
4
+
5
+ ## Does the project need a status palette?
6
+
7
+ `product` requires one: a working reader meets errors, warnings and confirmations daily, each to be known at a glance. Elsewhere, only if something reports state — a form, a subscription flow, a build status, a changelog's breaking-change marker; four unused pairs are four tokens nobody maintains.
8
+
9
+ ## The pair, and what each half must pass
10
+
11
+ Each status role is a **foreground** and a **background tint**, both re-picked per theme.
12
+
13
+ | Half | Job | Must clear |
14
+ |---|---|---|
15
+ | Foreground | The icon, the message's lead word, a border on a field in error | 4.5:1 on `surface.page`, on its own tint, and on any surface a status row can sit on (`surface.hover` in a table) |
16
+ | Background tint | The banner, the toast, the row, the field's fill | Reads as a surface, not a highlighter — about the `surface.hover` ratio against the page, with a little chroma |
17
+
18
+ Banner body text stays in `text.primary` and `text.secondary`; only the icon and lead word take the foreground. Re-measure the lowest tier used inside on the tint.
19
+
20
+ A **solid** fill — a destructive button — can often reuse the foreground: in light, a foreground at 4.5:1 on white gives white text 4.5:1 on it; in dark it takes dark `text.on-accent`-style text. Named separately: `status.danger.solid`.
21
+
22
+ In `DESIGN.md`: `status.danger.fg` and `status.danger.bg`, one row each, per theme.
23
+
24
+ ### Destructive actions take red ground, not red decoration
25
+
26
+ In a menu, a destructive item is a `status.danger.bg` row at rest and `status.danger.solid` with white text when highlighted. A red icon and red label on a neutral row is decoration, the coloured paragraph again, and reads as a one-off. Its icon, place and the items that take it are patterns law 3.
27
+
28
+ ## Figures take colour only while they report
29
+
30
+ A count or stat is in ink unless it reports a problem or progress: overdue or failed in `status.danger.fg`, pending or due soon in `status.warning.fg`, done or logged in `status.success.fg` where the preset colours done. Plain counts stay in ink, and so does every zero; "0 overdue" in red is an alarm with nothing behind it. The label beside the figure always names what it counts, so the colour confirms a word rather than replacing one. One colour per figure: a chip beside it that would compete goes neutral.
31
+
32
+ ## Deriving the four
33
+
34
+ 1. **Hues.** Danger around OKLCH h 25, warning around 70 (amber, not yellow), success around 150, info around 250. Keep each at least ~40° from the `accent` and `focus` hue; an info that collides takes the accent's hue and loses its token, or drops colour — information rarely needs one.
35
+ 2. **Light tints.** L 0.965, chroma 0.015–0.02; push chroma until the script reports clipping, then back off.
36
+ 3. **Light foregrounds.** Chroma 0.10–0.12; solve lightness for the tertiary target against the *tint*, which is darker than the page and so the worst surface.
37
+ 4. **Dark tints.** L 0.25, chroma about 0.035 — tinted, not darkened, so the banner doesn't punch a hole in the page.
38
+ 5. **Dark foregrounds.** Chroma about 0.11; solve against the dark tint, then check against the lightest surface a status can sit on.
39
+
40
+ **Amber, not yellow:** no yellow foreground at usable chroma clears 4.5:1 on white — by then it is brown-olive. An insisted-on yellow warning becomes the *tint*, with an amber or near-black foreground.
41
+
42
+ ### Harbour's status set
43
+
44
+ From `deriving-a-ramp.md`; foreground on page / own tint / `surface.hover`:
45
+
46
+ | Role | Light fg | Light bg | Ratios | Dark fg | Dark bg | Ratios |
47
+ |---|---|---|---|---|---|---|
48
+ | `status.danger` | `oklch(0.53 0.12 27)` | `oklch(0.965 0.015 27)` | 5.60 / 5.04 / 4.93 | `oklch(0.66 0.11 27)` | `oklch(0.25 0.035 27)` | 5.85 / 4.95 / 5.02 |
49
+ | `status.warning` | `oklch(0.52 0.1 70)` | `oklch(0.965 0.02 70)` | 5.63 / 5.08 / 4.95 | `oklch(0.66 0.11 70)` | `oklch(0.25 0.035 70)` | 6.01 / 5.06 / 5.17 |
50
+ | `status.success` | `oklch(0.51 0.12 150)` | `oklch(0.965 0.02 150)` | 5.44 / 4.95 / 4.78 | `oklch(0.64 0.11 150)` | `oklch(0.25 0.035 150)` | 5.95 / 4.93 / 5.12 |
51
+ | `status.info` | `oklch(0.52 0.12 250)` | `oklch(0.965 0.015 250)` | 5.50 / 4.97 / 4.83 | `oklch(0.65 0.11 250)` | `oklch(0.25 0.035 250)` | 5.93 / 4.97 / 5.10 |
52
+
53
+ Info at 250 sits 55° from Harbour's teal accent at h 195, so an info banner never reads as a focused button.
54
+
55
+ ## The non-colour half of every signal
56
+
57
+ Name the carrier for each status and interactive state:
58
+
59
+ | Signal | Carrier that survives without colour |
60
+ |---|---|
61
+ | A field in error | A message below it, in words, linked with `aria-describedby`; an icon; the border change is the third signal, not the first |
62
+ | A status banner | A distinct icon *shape* per status (a cross, a triangle, a tick, an "i"), and a lead word — "Error", "Saved" |
63
+ | A work item's state | A glyph whose shape moves with the work: empty ring, dashed ring, half, three-quarters, filled check; a cross for cancelled or failed, a "!" for blocked |
64
+ | Priority | Bars, filled from one to three |
65
+ | A toast | The same, plus `role="status"` or `role="alert"` so it is announced |
66
+ | The current nav item | Its tinted pill (the shape carries it), a marker, a rule or a weight — and `aria-current` |
67
+ | A selected row | `surface.selected` *and* a check, a rule or a bar at the edge |
68
+ | A disabled control | Reduced contrast *and* no hover or pointer, plus `disabled` or `aria-disabled` |
69
+
70
+ A field in error, in roles — the message and icon carry it, the colour confirms it:
71
+
72
+ ```html
73
+ <label for="dob">Date of birth</label>
74
+ <input id="dob" aria-invalid="true" aria-describedby="dob-error" />
75
+ <p id="dob-error" class="field-error">
76
+ <svg aria-hidden="true"><!-- a cross, not a dot --></svg>
77
+ Enter a date in the past.
78
+ </p>
79
+ ```
80
+
81
+ ```css
82
+ input[aria-invalid="true"] { border-color: var(--status-danger-fg); }
83
+ .field-error { color: var(--text-secondary); } /* the sentence stays in ink */
84
+ .field-error svg { color: var(--status-danger-fg); } /* only the mark takes the colour */
85
+ ```
86
+
87
+ The neutral case: a contents rail moving the current label from `text.secondary` to `text.primary` — 7.7:1 to 15:1, clear in a table, nearly invisible in daylight, to low vision, or mid-scroll. A 2px rule or marker at the edge carries the state; the colour change reinforces it.
88
+
89
+ A ring that fills as work moves is legible in greyscale, so its colour only confirms what the shape says; that is what lets a product colour done without colour carrying it.
90
+
91
+ ## Done, and where the reader is
92
+
93
+ Law 7's dial, decided by whether completion is the news:
94
+
95
+ | The list is… | Done looks like | Why |
96
+ |---|---|---|
97
+ | A sequence the reader passes through: setup steps, passed checks, a wizard | An ink check on a neutral circle | Completion is expected; the pending item is the only news, and a column of green ticks outshouts it |
98
+ | Work people open to see what got done: a task board, a review queue | `status.success` on a filled check | Completion is what they track. The glyph's shape already says done, and late or failed items take warning or danger, so the eye still finds what needs a person |
99
+ | A long list of ticked checkboxes | A light grey box with an ink tick | An inverted ink or accent block per tick shouts down a long list; the tick's shape carries the state |
100
+ | A switch | Its strong fill, on | A switch has no tick, so the fill is the state |
101
+
102
+ The second row reverses the first on purpose: it is what the "too grey" product in law 7 changed on its boards. Record which lists take which row under `## Decisions`, so the next audit doesn't "fix" one into the other. Under `expressive`, completion may take a brand colour in any row, counted among its exceptions.
103
+
104
+ Where the reader *is* in the interface (the current nav item or tab) takes the accent tint under product and expressive and stays neutral under editorial and glass (colour law 7). A place in the data with one member is different: on a calendar board, today's column may take a filled `accent` with `text.on-accent`, the one time the brand marks a place rather than an action. Record it as `exception.today`; it doesn't make a second.
105
+
106
+ ## Hue families: colour with a job
107
+
108
+ Where the reader sorts by kind, place or person at a glance, each question gets its own family, and no family answers another's.
109
+
110
+ | Question | Family | Takes the colour | Never |
111
+ |---|---|---|---|
112
+ | What is it? | `exception.type.<name>`, one per record type | Its icon, its tag, its chart bar | Text, a state, a whole row |
113
+ | How is it doing? | `status.*` | Its glyph, a figure (above), a field's edge, a banner's tint | A kind, a place, anything merely live |
114
+ | Where does it live? | `exception.area.<name>`, one per folder or area | Nav icon, breadcrumb step, page-title icon | Text, a state |
115
+ | Who? | `exception.person.<n>`, a fixed set assigned from the person's id | Avatars only, coloured rather than grey circles | Names, rows |
116
+
117
+ - **Marks only.** The family colours the glyph; the words beside it stay in ink.
118
+ - **No crossing.** A late task's type icon keeps its hue; the status glyph beside it turns. Where a family hue falls near a status hue or the accent, move its lightness or chroma until it can't be read as one, and never reuse a status value.
119
+ - **A brand avatar is not a family.** If the design gives every avatar the brand's colour or gradient, keep it: one shared treatment is identity, and it reads as the product. A person family is for when the reader must tell people apart at a glance, and it is added on top of the brand, never swapped in for it.
120
+ - **Muted is fine for people.** When a person family is needed and the hues near the accent and the status colours are ruled out, leaving one that clips (an olive `#686800`), lower the chroma rather than force a saturated hue. Avatars and other "who" marks only need telling apart.
121
+ - **3:1 on every ground, per theme.** A mark the reader needs is a non-text graphic: measure it with `contrast.mjs --min 3` on each surface it sits on (panel, frame, hover, selected) in each theme, and re-pick the family per theme like any accent.
122
+ - **Listed.** Under **Exceptions** in `DESIGN.md`, each family with its question, its members and its marks: "`exception.area.*` — where a record lives. Nav icon, breadcrumb step, page-title icon. Never text, never state."
123
+
124
+ ## Chromatic exceptions
125
+
126
+ ### The test
127
+
128
+ **Content, or the interface speaking about it?** An offer ("the short version?"), a way in, a warning before the reader begins is a category the eye is entitled to find quickly, and most pages have very few. The Glass preset's "short version" panel is the interface asking whether you want three lines instead of three thousand words; its colour is a gradient edge and a single accent mark, and the summary inside stays in the page's ink.
129
+
130
+ This test is for colour being added. Colour the design already spends on a job stays (colour law 7), so the test is never a reason to strip it.
131
+
132
+ Reasons that can't spread name a category with one member: "it's the only offer on the page", "it's the one call to action in the flow", "it's the live indicator, and only one thing is ever live". Reasons that can, and will: "it's important", "it's new", "it's premium", "it's the brand colour", "the page felt grey".
133
+
134
+ ### Containment
135
+
136
+ - **Only the mark takes the colour** — the edge, the glyph, the icon, the dot; text inside stays in the page's ink.
137
+ - **Its own named tokens**, `exception.<what-it's-for>-*`, so nothing borrows them by accident.
138
+ - **Every stop moves per theme** by roughly the same lightness (law 8), with the fill tinted rather than darkened.
139
+
140
+ ### Counting
141
+
142
+ Within the dial's allowance, an expressive palette still names each hue's place: "coral only on section backgrounds in the pricing band", "violet only on the one hero illustration". A hue without a written place is an uncounted exception. What weight, position and space can't make findable is usually what must be found before reading starts.
143
+
144
+ ### Live is not danger
145
+
146
+ A recording or live state borrows nothing from `status.danger`: a red-filled record button reads as an error and as the destructive colour, and looks wrong. Live is the brand's: a well tinted with the accent, a 1px accent ring, a soft accent glow, and level bars that move with the input. Red survives only as a small live dot. The control shows Stop on hover and focus, and holds still while paused; both belong to the motion guide. Name it `exception.live`.
147
+
148
+ ### One gradient, for AI working now
149
+
150
+ If a product has one gradient, it means one thing. A thin conic stroke in the accent's hues travels round the card where AI is working live, with a soft glow; everything else stays flat. Paused, the stroke stops and the glow dims; under reduced motion it holds still; under `prefers-contrast: more` it becomes a solid accent edge. Its stops are runtime values, every one moved per theme (laws 8 and 10). Name it `exception.ai-live`.
151
+
152
+ ### Marking AI output
153
+
154
+ A small sparkle glyph in the accent beside what the AI wrote: not a robot avatar, not a coloured bubble. A bubble colours the content, which the reader judges in ink like any other, and an avatar gives a tool a face. Only the mark takes the colour, and a mark, never a fill, doesn't compete with the one action the accent marks.
155
+
156
+ ### When the brand hue is also a status hue
157
+
158
+ A red brand and a red danger, or a green brand and a green success, read as each other; one gives way. Usually the status moves — danger to a deeper, bluer red, success to a teal-green — because a status must be unambiguous and a brand is also recognised by shape and placement. Where the brand colour must stay on a destructive action (a red brand's "Delete"), the word, the icon and a confirmation step say "destructive". Record the choice under `## Decisions`.
159
+
160
+ ### The accent is not an exception
161
+
162
+ It is the one planned colour, its jobs set by the dial; colour law 5 covers the ring it can hide when over-spent.
163
+
164
+ ## Charts are a different palette
165
+
166
+ Categorical series colours are neither status nor exception and need their own method (distinguishable in colour-vision deficiency, legible in both themes, ordered). Never borrow status foregrounds: a red line reads as "error" whatever it measures. The one overlap: series that *are* a family's members, such as record types, take that family's hues, since they answer the same question.
167
+
168
+ | Series | Colour |
169
+ |---|---|
170
+ | One primary series | `accent` — it is what the screen is about, so the brand stays on it |
171
+ | Comparison, previous period, target | Neutral: `mark` or a text tier, dashed or thinner where it shares an axis with the primary |
172
+ | Three or more of equal standing | `chart.series.<n>`, an ordered set, below |
173
+
174
+ The ordered set: start from the accent hue and step round, each hue at least ~40° from the status hues, alternating lightness so neighbours still part in greyscale and colour-vision deficiency. Every series clears 3:1 on the chart's surface in each theme, re-picked per theme like any accent. Past six or seven, group the tail as "Other" in a neutral; a legend nobody can match isn't a palette. Record the set in `DESIGN.md` `## Colour` with its order.
@@ -0,0 +1,121 @@
1
+ # Theming mechanics
2
+
3
+ The machinery behind laws 9 and 10, with notes where a framework changes the answer.
4
+
5
+ ## The switch: an attribute, a class, or the media query
6
+
7
+ | The project has… | Mechanism |
8
+ |---|---|
9
+ | One theme | None. Declare `color-scheme` and stop |
10
+ | Two themes, no toggle, follows the OS | `@media (prefers-color-scheme: dark)` re-declares the tokens. One source of truth, so the media query is fine |
11
+ | A toggle | An attribute (`data-theme="dark"`) or a class (`.dark`) on `<html>`, set by script. The media query only *seeds* the first visit |
12
+
13
+ An attribute suits more than two values. Every theme block keys off it:
14
+
15
+ ```css
16
+ :root { color-scheme: light; --surface-page: …; --text-primary: …; }
17
+ :root[data-theme="dark"] { color-scheme: dark; --surface-page: …; --text-primary: …; }
18
+ ```
19
+
20
+ Utility frameworks need telling. In Tailwind v4, `@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));` — the `:where()` keeps specificity at zero, so a `dark:` utility doesn't outrank an unprefixed one just for the attribute selector. Tailwind v3: `darkMode: ["selector", '[data-theme="dark"]']`. A CSS-in-JS provider reads the same attribute instead of holding its own state.
21
+
22
+ ### Theme islands
23
+
24
+ A region that must stay light in a dark page — an embedded document preview, a print-styled code sample — can't nest `data-theme="light"` if the dark selector matches descendants: the ancestor's match applies all the way down. Re-declare the custom properties and `color-scheme` on its container; since components reference role variables, everything inside re-themes. A dark band on a light-only page — a testimonial strip, a closing call to action — is the same island, and `--focus` is among what it re-declares, measured at 3:1 against the band.
25
+
26
+ ## No flash of wrong theme
27
+
28
+ The theme must be on `<html>` before first paint:
29
+
30
+ ```html
31
+ <script>
32
+ (function () {
33
+ try {
34
+ var t = localStorage.getItem("theme");
35
+ document.documentElement.dataset.theme = t === "dark" || t === "light" ? t
36
+ : matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
37
+ } catch (e) {}
38
+ })();
39
+ </script>
40
+ ```
41
+
42
+ Inline, since an external script is a round trip the page paints during; blocking, with no `async`, `defer` or `type="module"` (which defers implicitly); before the stylesheet, so the attribute is there when the CSS applies; in `try/catch`, because storage throws in some private-browsing modes and cookie policies, and an uncaught throw stops every script after it. It duplicates the theme module on purpose; comment each to point at the other. Setting the theme in a mount effect instead flashes every dark-mode visitor white on every load.
43
+
44
+ **Under SSR** a cookie is the only way, since the server can't read storage. Check that a theming library injects a blocking head script or renders server-side — some apply the theme only after hydration.
45
+
46
+ ## Persistence
47
+
48
+ Keep three functions apart; they have different callers:
49
+
50
+ ```ts
51
+ type Theme = "light" | "dark";
52
+
53
+ export function readTheme(): Theme { // initial UI state — mirrors the head script
54
+ try {
55
+ const t = localStorage.getItem("theme");
56
+ if (t === "dark" || t === "light") return t;
57
+ } catch {}
58
+ return matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
59
+ }
60
+ export function applyTheme(t: Theme): void { // the DOM write
61
+ document.documentElement.dataset.theme = t;
62
+ }
63
+ export function chooseTheme(t: Theme): void { // the user's action
64
+ try { localStorage.setItem("theme", t); } catch {}
65
+ applyTheme(t); // plus the crossfade, below
66
+ }
67
+ ```
68
+
69
+ **Compare, don't parse:** exact matching lets a stale, corrupted or older-version value fall through to the default. **Wrap every storage access:** a lost preference is an annoyance; a thrown exception is a broken page. Keeping `applyTheme` apart from `chooseTheme` lets the toggle animate and the first paint not.
70
+
71
+ ## Two states or three
72
+
73
+ **Two (light, dark)** is predictable, and two buttons with `aria-pressed` are simpler to build and explain than a tri-state.
74
+
75
+ **Three (light, dark, system)** costs two things; skipping either is the common bug:
76
+
77
+ 1. The head script resolves `system` too, with `matchMedia` inline, and a `readChoice()` returning the stored three-way choice sits beside `readTheme()`, which returns the resolved theme.
78
+ 2. A live listener, so the page follows the OS *while it's open*:
79
+
80
+ The listener, and the transition hold it needs so an OS switch doesn't cross-fade, are in `domains/motion/patterns.md` (*Theme cross-fade*).
81
+
82
+ Following the OS on load and then silently stopping is worse than not offering the option. Which to ship is a dial (the colour guide's theme control).
83
+
84
+ ## The crossfade on toggle
85
+
86
+ A reader's toggle cross-fades and an OS switch doesn't; the CSS, the timing and the reduced-motion branch are motion's (`domains/motion/patterns.md`, *Theme cross-fade*), since `duration.theme` and `ease.colour` are its tokens. Call it around `applyTheme`.
87
+
88
+ ## `color-scheme`
89
+
90
+ In every theme block. Beyond scrollbar tracks and thumbs it re-tints checkbox, radio and select chrome, date pickers, autofill and the default canvas before your background paints; a white scrollbar track reads as broken dark mode like little else.
91
+
92
+ ## Runtime or build-time
93
+
94
+ | Value | Where it lives | Why |
95
+ |---|---|---|
96
+ | The role vocabulary — surfaces, text tiers, borders, accent | Custom properties per theme, surfaced through the framework's token system | Themes re-declare them |
97
+ | Translucent material tints | Runtime custom properties | `prefers-reduced-transparency` swaps them for solids |
98
+ | Lower text tiers, if the project supports it | Runtime custom properties | `prefers-contrast: more` may raise them |
99
+ | Gradients, shadow stacks, filter chains | Runtime custom properties or one shared constant | No colour-token system has a slot for them |
100
+
101
+ "Build-time" is any value compiled to a literal — a Sass variable, an inlined JS constant, a token pipeline resolving to hex, Tailwind v4's `@theme inline` holding a literal — which no media query or theme attribute can re-declare. Keep literals in the theme blocks and point the build-time layer at them: in Tailwind v4, `@theme` entries, or `@theme inline { --color-surface: var(--surface-page); }` so themes can re-declare them; in a Tailwind config, `theme.extend.colors` entries set to `var(--…)`, as in `colors: { surface: "var(--surface-page)" }`; in a JS theme object, keys holding the same `var()` references.
102
+
103
+ ### Preference overrides
104
+
105
+ After every theme block, naming every theme selector:
106
+
107
+ ```css
108
+ @media (prefers-reduced-transparency: reduce) {
109
+ :root, :root[data-theme="dark"] {
110
+ --material-nav-tint: var(--surface-raised);
111
+ }
112
+ }
113
+ ```
114
+
115
+ That swaps the tint only; dropping the blur is the other half, owned by the depth guide.
116
+
117
+ In **forced-colours mode** system colours replace the palette: backgrounds and box-shadows vanish, outlines and borders survive — and with them goes a `box-shadow` focus ring and any state carried only by a background tint (law 6).
118
+
119
+ ## Where per-theme variants are still needed
120
+
121
+ Tokens re-tint, so most components need no theme branching. The exceptions change *structure*: a shadow whose inset highlight weakens while its ambient layers strengthen several-fold; a two-toned edge that must nearly vanish in dark, where it reads as a glowing line. The test: **can this be one token whose value changes?** If not, it needs a variant — once, as one shared constant holding both theme forms.
@@ -0,0 +1,191 @@
1
+ # Elevation recipes
2
+
3
+ Starting values for each level's bundle and shadow; the depth guide's Dials set their strength, `DESIGN.md` the project's choice.
4
+
5
+ ## The bundle, part by part
6
+
7
+ | Part | Comes from | Job | Fails as |
8
+ |---|---|---|---|
9
+ | Surface | `surface.page`, `surface.raised`, `surface.inverse` (the colour guide) | Which object this is | Indistinguishable from the level below, especially in dark themes |
10
+ | Edge | `border.default` or `border.layered`, at a `hairline.*` weight (the layout guide) | Exactly where it ends | Mushy outline; vanishes in forced colours if it was only a shadow |
11
+ | Shadow | This guide | That it is above, and how far | Fog, halo, or a shape read as a second object |
12
+ | Layer | `z.*` (the layout guide) | Makes paint order agree with the shadow | A shadow cast over something painted on top of it |
13
+
14
+ Check the four together: three right and one borrowed is the commonest depth bug, and no single-property lint catches it.
15
+
16
+ ## The four levels
17
+
18
+ | Level | Behaviour | Surface | Edge | Shadow | Layer | Scrim |
19
+ |---|---|---|---|---|---|---|
20
+ | `elevation.flat` | Scrolls with the page | `surface.page` or `surface.subtle` | `border.default`, or none with a surface step | None | Page flow | No |
21
+ | `elevation.raised` | Picked up, dragged, or sticky over passing content | `surface.raised` | `border.default` | Contact + 1 ambient | `z.raised`, sticky chrome at `z.nav` | No |
22
+ | `elevation.floating` | Summoned, dismissed on its own | `surface.raised` (dark: one step lighter) | `border.default` / `border.layered` | Contact + 1–2 ambient, optional highlight | `z.popover` | No — a transparent catcher at `z.dismiss` |
23
+ | `elevation.overlay` | Blocks until answered | `surface.raised` (dark: two steps lighter) | `border.default` | Contact + 2 ambient, longest | `z.overlay` | Yes |
24
+
25
+ Sticky chrome is raised in behaviour but high on the z ladder, because content passes under it — the one place level and layer differ, and why the layer is part of the bundle.
26
+
27
+ A tooltip is floating and transient: no contact layer, a short ambient and, on `surface.inverse`, one shadow for both themes, its own surface being what the shadow separates.
28
+
29
+ ## Building a shadow
30
+
31
+ Derive every layer from `Y`, the level's near-ambient offset; fixed proportions read as one light at different heights.
32
+
33
+ ```
34
+ contact 0 1px 1px 0 α × 0.35
35
+ near ambient 0 Y 2Y −Y/2 α × 0.7
36
+ far ambient 0 3Y 4Y −Y α (floating and overlay only)
37
+ inset highlight inset 0 1px 0 white at h (optional, per preset)
38
+ ```
39
+
40
+ `α` is the level's peak opacity, from the Dials. Typical `Y`: raised 2px, floating 8px, overlay 16px. At `Y = 8px`, `α = 0.06`:
41
+
42
+ ```css
43
+ --elevation-floating:
44
+ inset 0 1px 0 rgb(255 255 255 / 0.5),
45
+ 0 1px 1px rgb(0 0 0 / 0.02),
46
+ 0 8px 16px -4px rgb(0 0 0 / 0.04),
47
+ 0 24px 32px -8px rgb(0 0 0 / 0.06);
48
+ ```
49
+
50
+ **Why each proportion.** Blur at twice the offset keeps the penumbra soft without detaching; spread at minus half keeps the shadow under the footprint, displaced downward — tucked, not haloed. Opacity rises with distance, as ambient occlusion: the larger far layer is fainter per pixel, so needs slightly more alpha.
51
+
52
+ **The contact layer is for resting things**, chrome and cards; tooltips and drag previews in flight hover, and skip it. **The inset highlight**, the specular catch on the top edge, is optional per preset; product usually omits it, a bright line on every menu in a dense screen being noise.
53
+
54
+ ## Per preset
55
+
56
+ Light-theme floating shadows at the dial strengths; raised and overlay follow from the `Y` values above.
57
+
58
+ | Preset | Floating recipe (light) |
59
+ |---|---|
60
+ | `editorial` | `inset 0 1px 0 rgb(255 255 255 / 0.5), 0 1px 1px rgb(0 0 0 / 0.02), 0 8px 16px -4px rgb(0 0 0 / 0.04), 0 24px 32px -8px rgb(0 0 0 / 0.06)` |
61
+ | `product` | `0 1px 1px rgb(0 0 0 / 0.04), 0 12px 24px -8px rgb(0 0 0 / 0.10)` — edges carry the rest |
62
+ | `expressive` | `inset 0 1px 0 rgb(255 255 255 / 0.6), 0 1px 2px rgb(0 0 0 / 0.06), 0 10px 20px -6px rgb(0 0 0 / 0.10), 0 32px 48px -12px rgb(0 0 0 / 0.16)` |
63
+
64
+ Product leans on `border.default` with short shadows, so dense screens don't fill with grey, and draws that edge as a ring inside the shadow on cards, menus and dialogs alike (below). Expressive may add one decorative level — a branded pocket, a hero card — named, with its own bundle.
65
+
66
+ ## Dark theme: a different recipe
67
+
68
+ Three things change; none is "the same shadow, darker":
69
+
70
+ 1. **The surface lifts** by the Dials' OKLCH step: page 0.18 → raised 0.22 → floating 0.26 → overlay 0.30 at +0.04. It carries most of the separation.
71
+ 2. **Drop layers rise to the dials' dark peak** (depth guide, *Floating shadow*). A 4% black shadow on a near-black page is invisible.
72
+ 3. **The highlight drops to a sixth.** `0.5` white along a dark top edge reads as a lit wire; `0.08` defines the edge without lighting it.
73
+
74
+ ```css
75
+ [data-theme="dark"] {
76
+ --elevation-floating:
77
+ inset 0 1px 0 rgb(255 255 255 / 0.08),
78
+ 0 1px 1px rgb(0 0 0 / 0.3),
79
+ 0 8px 16px -4px rgb(0 0 0 / 0.4),
80
+ 0 24px 32px -8px rgb(0 0 0 / 0.5); /* editorial's 50% dark peak */
81
+ }
82
+ ```
83
+
84
+ A `#000` page leaves drop layers no room, so only the surface step separates and the top level must climb further. Then switch shadows off: if surface and edge can't tell floating from raised, the dark step is too small.
85
+
86
+ ## Shadows on tinted grounds
87
+
88
+ On a tinted ground — warm off-white, a brand band — neutral black reads as dirt. Take the shadow from the ground's hue:
89
+
90
+ ```css
91
+ --shadow-ink: oklch(from var(--surface-page) 0.25 calc(c * 1.5) h);
92
+ --elevation-raised: 0 1px 1px rgb(from var(--shadow-ink) r g b / 0.05),
93
+ 0 2px 4px -1px rgb(from var(--shadow-ink) r g b / 0.08);
94
+ ```
95
+
96
+ Without relative colour syntax, pick it by hand (the ground's hue, low lightness, slightly more chroma) and record it under its surface role, so re-tinting the ground moves the shadow.
97
+
98
+ ## Edges that survive
99
+
100
+ A card animating by transform, as in a reordering stack, can't change its border without shifting layout; use `outline: 1px solid <border.default>; outline-offset: -1px`, which, unlike a `0 0 0 1px` shadow ring, survives forced colours. An edgeless surface carries `border: 1px solid transparent` — a boundary for exactly the users who asked for one.
101
+
102
+ **The ring edge** (`product`): the edge as the first layer of the level's one shadow definition, so edge and shadow change together and every card, menu and dialog gets the same line.
103
+
104
+ ```css
105
+ :root {
106
+ --elevation-floating:
107
+ 0 0 0 0.5px var(--border-default), /* the edge */
108
+ 0 1px 1px rgb(0 0 0 / 0.04),
109
+ 0 12px 24px -8px rgb(0 0 0 / 0.10);
110
+ }
111
+ .card, .menu, .dialog { border: 1px solid transparent; box-shadow: var(--elevation-floating); }
112
+ @media (prefers-contrast: more) {
113
+ .card, .menu, .dialog { border-color: var(--text-primary); } /* a real 1px edge, as for materials */
114
+ }
115
+ ```
116
+
117
+ Half a pixel is one device pixel on a 2x screen and rounds to a faint 1px on 1x, which is the point — quieter than any border — and why the contrast preference gets a real one. Forced colours paints the transparent border.
118
+
119
+ ## Moving between levels
120
+
121
+ - **Hover on a raised card** may deepen the shadow, as raised's own state; if the lift overlaps a neighbour it also takes `z.raised`, or the next sibling paints over it. In `product`, hover is a surface wash and the bundle stays put; the motion guide says why.
122
+ - **Drag** moves it to floating, whole — shadow, layer, dark surface — and drop moves it back.
123
+ - **A popover promoted to a dialog** changes to overlay and gains a scrim at the same moment.
124
+
125
+ ## Closing an overlay
126
+
127
+ A dialog hands focus back to its opener. Primitives do this for a trigger they rendered; a dialog opened from state — a row's menu item, a keyboard shortcut, a bulk selection — has none, and focus falls to `<body>`. Store the opener as it opens:
128
+
129
+ ```tsx
130
+ const opener = useRef<HTMLElement | null>(null);
131
+ const openDialog = () => { opener.current = document.activeElement as HTMLElement; setOpen(true); };
132
+
133
+ <Dialog open={open} onOpenChange={setOpen}
134
+ onCloseAutoFocus={(e) => { // or the primitive's equivalent close hook
135
+ e.preventDefault();
136
+ const back = opener.current?.isConnected ? opener.current : fallback();
137
+ back?.focus();
138
+ }}>
139
+ ```
140
+
141
+ - **Opened from a menu item:** the item unmounted with the menu; store the menu's trigger, not the item.
142
+ - **The opener is gone** — the row was just deleted: `fallback()` focuses its neighbour, or the list's heading, never the page top.
143
+
144
+ ## The art panel
145
+
146
+ Where a product keeps art (depth law 6) it is one inset panel; where it sits on a sign-in page is patterns law 13.
147
+
148
+ - **Its own surface, not a level.** Inset by the page gutter, at `radius.container`, no shadow: it is part of the page, not above it.
149
+ - **Its theme is scoped.** Art designed for one ground looks wrong on the other, so the panel pins a theme (`data-theme="dark"` on the panel, its tokens re-resolving inside) and reads the same in both.
150
+ - **Generative over photographic.** A photograph or a soft gradient wash reads dated; art generated in the brand's colours reads current. Whether it moves is the motion guide's ambient class (motion law 4).
151
+ - **A frosted element sits over still art** only, as the pocket in `translucent-materials.md`, with its opaque fallback.
152
+
153
+ ## One definition per level
154
+
155
+ One custom property per level per theme, or one class, never inline copies. Utility arbitrary values add a hazard: spaces become underscores, commas stay unspaced, and a typo yields a class the build silently drops — no shadow, no error.
156
+
157
+ ## A worked example
158
+
159
+ **Tally**, a fictional invoicing app on `product` with `app/(marketing)/**` on `expressive`, whose tokens call surfaces `canvas` and `panel` and the edge `line`: four opaque levels in the app, one chrome tier on the marketing header.
160
+
161
+ ```css
162
+ :root {
163
+ --elevation-raised: 0 1px 1px rgb(20 20 30 / 0.04), 0 2px 6px -2px rgb(20 20 30 / 0.06);
164
+ --elevation-floating: 0 1px 1px rgb(20 20 30 / 0.05), 0 12px 24px -8px rgb(20 20 30 / 0.10);
165
+ --elevation-overlay: 0 1px 1px rgb(20 20 30 / 0.06), 0 24px 48px -12px rgb(20 20 30 / 0.16);
166
+ --scrim: rgb(20 20 30 / 0.40);
167
+ }
168
+ [data-theme="dark"] {
169
+ --color-panel: oklch(0.25 0.01 270); /* canvas is 0.21; each level +0.04 */
170
+ --color-panel-float: oklch(0.29 0.01 270); /* proposed: surface.floating */
171
+ --elevation-floating: 0 1px 1px rgb(0 0 0 / 0.40), 0 12px 24px -8px rgb(0 0 0 / 0.55);
172
+ --scrim: rgb(0 0 0 / 0.60);
173
+ }
174
+ ```
175
+
176
+ The theme-varying value stays a runtime custom property: Tailwind v4 `@theme inline { --shadow-floating: var(--elevation-floating); }`, so the utility follows each theme; v3 `boxShadow: { floating: 'var(--elevation-floating)' }`; a JS theme object `shadows.floating: 'var(--elevation-floating)'`.
177
+
178
+ ## Recording it
179
+
180
+ ```markdown
181
+ ## Surfaces
182
+
183
+ | Level | Surface | Edge | Shadow | Layer |
184
+ |---|---|---|---|---|
185
+ | `elevation.flat` | `bg-canvas` | `border-line` | — | — |
186
+ | `elevation.raised` | `bg-panel` | `border-line` | `shadow-raised` | `z-raised` |
187
+ | `elevation.floating` | `bg-panel` · dark `bg-panel-float` (proposed) | `border-line` | `shadow-floating` | `z-popover` |
188
+ | `elevation.overlay` | `bg-panel` · dark `bg-panel-overlay` (proposed) | `border-line` | `shadow-overlay` | `z-overlay` |
189
+
190
+ **Scrim** — `bg-scrim`: 40% light, 60% dark. **Materials** — opaque only.
191
+ ```
@@ -0,0 +1,132 @@
1
+ # Fallbacks and performance
2
+
3
+ Every material ships four more paths — no support, reduced transparency, increased contrast, forced colours; opaque surfaces need only the last, or the last two where their edge is a shadow ring — and a cost model sets its budget.
4
+
5
+ ## The order
6
+
7
+ The fallbacks re-declare the theme blocks' properties at the same specificity, so source order decides.
8
+
9
+ ```css
10
+ /* 1. Theme blocks — tints and shadows per theme (tint colours: color-and-theming) */
11
+ :root { --chrome-tint: rgb(245 245 247 / 0.76); }
12
+ [data-theme="dark"] { --chrome-tint: rgb(34 34 38 / 0.76); }
13
+
14
+ /* 2. Opaque baseline — the default, and so the tested path */
15
+ .material-chrome {
16
+ background: var(--surface-raised);
17
+ border: 1px solid var(--border-layered);
18
+ }
19
+
20
+ /* 3. The material, as an enhancement */
21
+ @supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
22
+ .material-chrome {
23
+ background: var(--chrome-tint);
24
+ backdrop-filter: blur(20px) saturate(1.7);
25
+ }
26
+ }
27
+
28
+ /* 4. Preferences — after everything above */
29
+ @media (prefers-reduced-transparency: reduce) {
30
+ :root, [data-theme="dark"] { --chrome-tint: var(--surface-raised); }
31
+ .material-chrome { backdrop-filter: none; }
32
+ }
33
+ @media (prefers-contrast: more) {
34
+ .material-chrome { border-color: var(--text-primary); }
35
+ }
36
+ @media (forced-colors: active) {
37
+ .material-chrome { border-color: CanvasText; }
38
+ }
39
+ ```
40
+
41
+ **The reduced-transparency block names every selector that set the tint**: if the dark theme is `:root[data-theme="dark"]`, so is the fallback, or it wins in light only.
42
+
43
+ **`--surface-raised` is an existing surface, chosen per material:** chrome falls back to `surface.raised` and still reads as raised; a decorative soften-tier pocket, whose whole purpose was translucency, to `surface.page`.
44
+
45
+ **Opaque is the default, the material layered on**; a fallback bolted underneath is the path nobody sees, so the one that ships broken.
46
+
47
+ ## Reduced transparency: both halves
48
+
49
+ Blur is expensive on weak devices and translucency hurts reading; an opaque surface answers both, with both halves:
50
+
51
+ | Only… | Result |
52
+ |---|---|
53
+ | Drops the filter | A thin tint over live content, suppressing nothing: text over text, worse than before |
54
+ | Swaps the tint | Opaque, but still paying for the blur — half the preference's reason |
55
+
56
+ It also makes text contrast computable and true for the users most likely to need it. Test with macOS *Reduce transparency*, Windows *Transparency effects* off, or the browser's rendering emulation.
57
+
58
+ ## Increased contrast
59
+
60
+ A faint `border.layered` goes first. Promote the edge to `text.primary` and leave the surface — high contrast is about boundaries and text, not the material — so it composes with reduced transparency: opaque surface, strong edge. A half-pixel ring edge goes the same way, swapped for a real 1px border (`elevation-recipes.md`).
61
+
62
+ ## Forced colours
63
+
64
+ System colours replace backgrounds and borders, and every `box-shadow` goes, leaving only law 3's edge — `1px solid transparent` on an edgeless surface, painted as `CanvasText`. **`background-clip: text` with transparent text** can render as nothing, which is why a sheen removes its class when it ends.
65
+
66
+ Leave `forced-color-adjust: none` alone unless a component is broken without it; the user chose their colours.
67
+
68
+ ## The prefix and the pipeline
69
+
70
+ Safari still needs `-webkit-backdrop-filter`; hand-writing it depends on the pipeline.
71
+
72
+ - **Pipeline prefixes for you** (Lightning CSS — which Tailwind v4 uses — or Autoprefixer, with targets including Safari): write only `backdrop-filter`. Lightning CSS treats a hand-written `-webkit-` line beside the standard one as a duplicate and keeps the last, shipping the prefix alone: Firefox ignores it and loses every blur, invisibly in Chrome and Safari.
73
+ - **No prefixing step:** write both, standard property last.
74
+
75
+ The Glass preset's hand-written prefix did this on its live site. Verify the built output:
76
+
77
+ ```sh
78
+ # the rule as shipped — both properties must appear
79
+ grep -o '\.material-chrome{[^}]*}' dist/**/*.css
80
+ ```
81
+
82
+ Or in Firefox, `getComputedStyle(el).backdropFilter` on a filtered element: `"none"` means the standard property didn't ship.
83
+
84
+ ## The cost model
85
+
86
+ Each frame a visible filtered element's backdrop changes, the browser paints behind it, copies the region, filters and composites. So:
87
+
88
+ - **Cost scales with area**: one full-viewport surface costs far more than five small ones of the same total perimeter.
89
+ - **It re-runs whenever the backdrop changes** — every scroll frame for chrome over a scrolling page, the common case.
90
+ - **It can't be cached** while the backdrop moves; a layer of its own doesn't help.
91
+ - **Radius matters less than you'd think.** Implementations downsample before blurring, so trimming 20px to 12px buys little; reduce area and count.
92
+
93
+ | Avoid | Why |
94
+ |---|---|
95
+ | Full-viewport translucent surfaces | Worst case of an area-proportional cost |
96
+ | A translucent surface per list row or card | Multiplies passes by row count |
97
+ | Animating the blur radius | A fresh pass at a new radius every frame |
98
+ | Translucency over video, canvas or animation | Re-filters every backdrop frame, even when nothing in front moves |
99
+ | Closed menus at `opacity: 0` or `visibility: hidden` | Still pay; visibility is decided after filtering. Unmount, or `display: none` |
100
+ | A filter over a flat single-colour backdrop | Pays a pass to produce a plain tint |
101
+ | `will-change: backdrop-filter` | Hints an unwanted animation; may force a needless layer |
102
+
103
+ `contain: paint` on the filtered surface sometimes helps; measure before and after.
104
+
105
+ **A blurred scrim**, the one full-viewport filter the Dials allow (`expressive` only), is affordable because the page behind a modal doesn't scroll, so it settles after the open transition. Fade its opacity, never its radius.
106
+
107
+ ## The budget
108
+
109
+ Count what is on screen at once, not what exists — a header, a dock and one open menu are three — against the Dials' count and area ceiling.
110
+
111
+ ## Measuring
112
+
113
+ - **Performance panel**, recording a scroll over a long page of images: filters show as long paint and composite blocks, not scripting.
114
+ - **Paint flashing**, to see a filtered surface repaint its whole area every scroll frame.
115
+ - **Layers panel**, to confirm you haven't made dozens of composited layers.
116
+
117
+ Test on the slowest supported device — desktop GPUs hide this, mid-range phones don't — and watch for dropped frames during scroll.
118
+
119
+ ## Moving shadows cost too
120
+
121
+ Transitioning `box-shadow` repaints every frame: fine on one card, but across a long list or a hovered grid, fade a pseudo-element's opacity, which the compositor handles:
122
+
123
+ ```css
124
+ .card { position: relative; box-shadow: var(--elevation-raised); }
125
+ .card::after {
126
+ content: ""; position: absolute; inset: 0; border-radius: inherit;
127
+ box-shadow: var(--elevation-raised-hover);
128
+ opacity: 0; transition: opacity <duration.state> <ease.standard>;
129
+ pointer-events: none;
130
+ }
131
+ .card:hover::after { opacity: 1; }
132
+ ```