@fiestaboard/ui 3.4.0 → 5.0.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 (79) hide show
  1. package/README.md +13 -1
  2. package/dist/components/board/board-backdrop.d.ts +60 -0
  3. package/dist/components/board/board-backdrop.js +71 -0
  4. package/dist/components/board/board-backdrop.js.map +1 -0
  5. package/dist/components/board/board-display.js +123 -123
  6. package/dist/components/board/board-display.js.map +1 -1
  7. package/dist/components/board/static-board-display.js +32 -32
  8. package/dist/components/chrome/fiesta-logo.js +2 -2
  9. package/dist/components/chrome/fiesta-logo.js.map +1 -1
  10. package/dist/components/chrome/page-header.d.ts +66 -4
  11. package/dist/components/chrome/page-header.js +31 -11
  12. package/dist/components/chrome/page-header.js.map +1 -1
  13. package/dist/components/chrome/page-layout.js +1 -1
  14. package/dist/components/chrome/page-layout.js.map +1 -1
  15. package/dist/components/chrome/sidebar.d.ts +2 -5
  16. package/dist/components/chrome/sidebar.js +152 -173
  17. package/dist/components/chrome/sidebar.js.map +1 -1
  18. package/dist/components/containment/json-tree.js +2 -2
  19. package/dist/components/containment/json-tree.js.map +1 -1
  20. package/dist/components/editor/node-views/wrapped-text-view.js +1 -1
  21. package/dist/components/editor/node-views/wrapped-text-view.js.map +1 -1
  22. package/dist/components/editor/variable-picker-content.js +4 -4
  23. package/dist/components/feedback/alert.js +4 -4
  24. package/dist/components/feedback/alert.js.map +1 -1
  25. package/dist/components/feedback/badge.js +4 -4
  26. package/dist/components/feedback/badge.js.map +1 -1
  27. package/dist/components/forms/button.js +5 -5
  28. package/dist/components/forms/button.js.map +1 -1
  29. package/dist/components/layout/flex.d.ts +1 -1
  30. package/dist/components/layout/grid.d.ts +1 -1
  31. package/dist/components/layout/stack.d.ts +1 -1
  32. package/dist/components/plugin/board-showcase.js +2 -2
  33. package/dist/components/plugin/board-showcase.js.map +1 -1
  34. package/dist/components/plugin/plugin-category-badge.d.ts +27 -1
  35. package/dist/components/plugin/plugin-category-badge.js +18 -14
  36. package/dist/components/plugin/plugin-category-badge.js.map +1 -1
  37. package/dist/components/typography/text.js +1 -1
  38. package/dist/components/typography/text.js.map +1 -1
  39. package/dist/components/wizard/wizard-progress.d.ts +29 -0
  40. package/dist/components/wizard/wizard-progress.js +27 -0
  41. package/dist/components/wizard/wizard-progress.js.map +1 -0
  42. package/dist/components/wizard/wizard-shell.d.ts +63 -0
  43. package/dist/components/wizard/wizard-shell.js +69 -0
  44. package/dist/components/wizard/wizard-shell.js.map +1 -0
  45. package/dist/editor.css +10 -3
  46. package/dist/fonts.css +16 -8
  47. package/dist/index.d.ts +3 -5
  48. package/dist/index.js +90 -92
  49. package/dist/theme.css +684 -519
  50. package/package.json +4 -6
  51. package/dist/components/effects/aurora.d.ts +0 -9
  52. package/dist/components/effects/aurora.js +0 -121
  53. package/dist/components/effects/aurora.js.map +0 -1
  54. package/dist/components/effects/react-bits/decrypted-text.d.ts +0 -14
  55. package/dist/components/effects/react-bits/decrypted-text.js +0 -88
  56. package/dist/components/effects/react-bits/decrypted-text.js.map +0 -1
  57. package/dist/components/seasons/aurora-canvas.d.ts +0 -22
  58. package/dist/components/seasons/aurora-canvas.js +0 -91
  59. package/dist/components/seasons/aurora-canvas.js.map +0 -1
  60. package/dist/components/seasons/sidebar-aurora-horizontal.d.ts +0 -3
  61. package/dist/components/seasons/sidebar-aurora-horizontal.js +0 -47
  62. package/dist/components/seasons/sidebar-aurora-horizontal.js.map +0 -1
  63. package/dist/components/seasons/sidebar-aurora.d.ts +0 -3
  64. package/dist/components/seasons/sidebar-aurora.js +0 -57
  65. package/dist/components/seasons/sidebar-aurora.js.map +0 -1
  66. package/dist/lib/seasons-drafts.d.ts +0 -26
  67. package/dist/lib/seasons-drafts.js +0 -106
  68. package/dist/lib/seasons-drafts.js.map +0 -1
  69. package/dist/lib/seasons.d.ts +0 -52
  70. package/dist/lib/seasons.js +0 -59
  71. package/dist/lib/seasons.js.map +0 -1
  72. package/dist/seasons/christmas.css +0 -64
  73. package/dist/seasons/easter.css +0 -62
  74. package/dist/seasons/fathers-day.css +0 -62
  75. package/dist/seasons/halloween.css +0 -69
  76. package/dist/seasons/mothers-day.css +0 -63
  77. package/dist/seasons/new-year.css +0 -66
  78. package/dist/seasons/pride.css +0 -68
  79. package/dist/seasons/thanksgiving.css +0 -64
package/dist/theme.css CHANGED
@@ -5,8 +5,8 @@
5
5
  * app/globals.css. It must be imported into a Tailwind v4 pipeline:
6
6
  *
7
7
  * @import "tailwindcss";
8
- * @import "@fiestaboard/ui/fonts.css"; <- opt-in Geist @font-face registrations;
9
- * @import "@fiestaboard/ui/theme.css"; skip fonts.css if you self-host Geist
8
+ * @import "@fiestaboard/ui/fonts.css"; <- opt-in @font-face registrations;
9
+ * @import "@fiestaboard/ui/theme.css"; skip fonts.css if you self-host
10
10
  * @source "../node_modules/@fiestaboard/ui/dist";
11
11
  *
12
12
  * Dark mode is class-based: toggle the `dark` class on <html>.
@@ -15,23 +15,14 @@
15
15
  * token values here without a parity check on the consumer side.
16
16
  */
17
17
  @import "tw-animate-css";
18
- /* Seasonal themes — one file per season, inert unless the season class
19
- is stamped on <html>. Only the LIVE season (pride) is inlined here so
20
- consumers get it automatically; the design drafts are NOT imported —
21
- they'd ship ~18 KB of rule families keyed on classes the app never
22
- stamps. A consumer that promotes a draft opts in with:
23
- @import "@fiestaboard/ui/seasons/<id>.css";
24
- (see README "Seasonal theming"). Storybook imports every draft
25
- directly in storybook.css so the Season toolbar can preview them. */
26
- @import "./seasons/pride.css";
27
18
 
28
19
  @custom-variant dark (&:is(.dark *));
29
20
 
30
21
  @theme inline {
31
22
  --color-background: var(--background);
32
23
  --color-foreground: var(--foreground);
33
- --font-sans: var(--font-geist-sans);
34
- --font-mono: var(--font-geist-mono);
24
+ --font-sans: var(--font-sans-stack);
25
+ --font-mono: var(--font-mono-stack);
35
26
  --color-sidebar-ring: var(--sidebar-ring);
36
27
  --color-sidebar-border: var(--sidebar-border);
37
28
  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -104,6 +95,14 @@
104
95
  --color-category-home: var(--category-home);
105
96
  --color-category-home-foreground: var(--category-home-foreground);
106
97
 
98
+ /* The six brand hues at an ink lightness — icons, rules, text. */
99
+ --color-hue-red: var(--hue-red);
100
+ --color-hue-orange: var(--hue-orange);
101
+ --color-hue-yellow: var(--hue-yellow);
102
+ --color-hue-green: var(--hue-green);
103
+ --color-hue-blue: var(--hue-blue);
104
+ --color-hue-violet: var(--hue-violet);
105
+
107
106
  /* Overlay */
108
107
  --color-overlay: var(--overlay);
109
108
 
@@ -111,6 +110,13 @@
111
110
  --color-brand: var(--brand);
112
111
  --color-brand-foreground: var(--brand-foreground);
113
112
  --color-brand-emphasis: var(--brand-emphasis);
113
+ /* The literal tile. Field only — see the brand note in :root. */
114
+ --color-brand-tile: var(--brand-tile);
115
+ --color-primary-hover: var(--primary-hover);
116
+ --color-primary-active: var(--primary-active);
117
+ --color-ring-edge: var(--ring-edge);
118
+ --color-brand-tile-foreground: var(--brand-tile-foreground);
119
+ --color-brand-hover: var(--brand-hover);
114
120
 
115
121
  /* Navigation active state (indigo) */
116
122
  --color-nav-active: var(--nav-active);
@@ -190,17 +196,55 @@
190
196
  }
191
197
 
192
198
  :root {
193
- /* Font family tokens. The "Geist Variable" / "Geist Mono Variable"
194
- names match the @font-face declarations registered by the opt-in
195
- @fiestaboard/ui/fonts.css subpath (which wraps
196
- @fontsource-variable/geist and @fontsource-variable/geist-mono).
197
- Consumers that self-host Geist (next/font, CDN, subset) skip
198
- fonts.css; without it these tokens degrade gracefully to the
199
- system stacks below. Previously these CSS vars were set by
200
- Next.js's next/font wrapper in layout.tsx — moved here so they're
201
- owned by the stylesheet, not a build-time JS wrapper. */
202
- --font-geist-sans: "Geist Variable", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
203
- --font-geist-mono: "Geist Mono Variable", ui-monospace, SFMono-Regular, "SF Mono", Monaco, Menlo, monospace;
199
+ /* ============================================================
200
+ * FONT FAMILY TOKENS.
201
+ *
202
+ * The family names here match the @font-face declarations registered by
203
+ * the opt-in @fiestaboard/ui/fonts.css subpath. Consumers that self-host
204
+ * the faces skip fonts.css; without it these tokens degrade gracefully to
205
+ * the system stacks below. These CSS vars were once set by Next.js's
206
+ * next/font wrapper in layout.tsx — moved here so they're owned by the
207
+ * stylesheet, not a build-time JS wrapper.
208
+ *
209
+ * The token NAMES are deliberately vendor-neutral. They used to be
210
+ * --font-geist-sans / --font-geist-mono, which meant the design system
211
+ * published a specific foundry's name as its public contract and every
212
+ * future typeface change was a rename. A design system names the ROLE,
213
+ * not the vendor. The old names are aliased below for one minor.
214
+ *
215
+ * WHY ARCHIVO. It replaced Geist, which was never chosen so much as
216
+ * inherited — Geist is Vercel's house font, so the product read as
217
+ * "built with Next.js" rather than as FiestaBoard. Archivo is drawn from
218
+ * American grotesque signage, which is the world the hardware already
219
+ * lives in, and it carries a wdth 62-125 axis that no other candidate
220
+ * offered: normal width is a disciplined UI workhorse, expanded is a
221
+ * departure board, from one family.
222
+ *
223
+ * WHY SPLINE SANS MONO. The mono token is not a code-editor setting: every
224
+ * split-flap glyph the product draws is `font-mono font-semibold` (see
225
+ * board-display.tsx and static-board-display.tsx), so this face renders the
226
+ * hardware preview, and the template editor that previews it. It was chosen
227
+ * on the two metrics that govern a fixed character cell read at a distance:
228
+ * cap height 72.7% of em (against Geist Mono's 71.0%) and glyph advance
229
+ * 60.0%. Weight range is 300-700; the only weights paired with `font-mono`
230
+ * in this package are 500 and 600, so nothing synthesizes.
231
+ *
232
+ * NOTE: fonts.css registers the WEIGHT-ONLY file (34 KB latin), so
233
+ * font-stretch is inert today. Nothing in this package sets font-stretch
234
+ * yet, and shipping an axis nothing uses is the same mistake as shipping
235
+ * an undesigned palette (see the #173 note above). When display-width
236
+ * type actually lands, switch fonts.css to
237
+ * @fontsource-variable/archivo/wdth.css (88 KB latin) and the same
238
+ * "Archivo Variable" family name gains the axis with no token change.
239
+ * ============================================================ */
240
+ --font-sans-stack: "Archivo Variable", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
241
+ --font-mono-stack: "Spline Sans Mono Variable", ui-monospace, SFMono-Regular, "SF Mono", Monaco, Menlo, monospace;
242
+ /* DEPRECATED, removed in the next major: the pre-Archivo token names.
243
+ Aliased so a consumer referencing var(--font-geist-sans) directly does
244
+ not break on upgrade — they now resolve to whatever the role tokens
245
+ above hold, which is the point. */
246
+ --font-geist-sans: var(--font-sans-stack);
247
+ --font-geist-mono: var(--font-mono-stack);
204
248
  /* Tell the UA which form-control palette to paint (#160). Without this,
205
249
  every browser-drawn widget — accent-color'd checkboxes, file/date/number
206
250
  chrome, scrollbars, autofill — renders in LIGHT chrome, which on the dark
@@ -208,12 +252,31 @@
208
252
  Declared next to the palette it belongs to, once per theme. */
209
253
  color-scheme: light;
210
254
  --radius: 0.625rem;
211
- --background: oklch(0.985 0 0);
212
- --foreground: oklch(0.145 0 0);
213
- --card: oklch(1 0 0);
214
- --card-foreground: oklch(0.145 0 0);
215
- --popover: oklch(1 0 0);
216
- --popover-foreground: oklch(0.145 0 0);
255
+ /* ============================================================
256
+ * HUE LOCK (#231). Every chromatic token in this file resolves to one
257
+ * of the SIX hues the hardware can physically flip, converted from
258
+ * lib/board-colors.ts:
259
+ *
260
+ * red 29 · orange 73 · yellow 104 · green 133 · blue 251 · violet 315
261
+ *
262
+ * Hue is theme-invariant: a semantic token keeps its hue in light and
263
+ * dark, and only LIGHTNESS moves, set by the contrast target. Before
264
+ * this, the palette carried ~20 hue angles with no ramp behind them and
265
+ * --warning swung 25deg between themes, so a token could re-encode its
266
+ * own meaning on a theme toggle.
267
+ *
268
+ * Surfaces are no longer chroma-0: they carry a 0.003-0.016 warm cast at
269
+ * hue 73 (the board's orange) so the neutral 90% of the product reads as
270
+ * chosen rather than inherited from stock shadcn.
271
+ *
272
+ * Do not add a seventh hue. token-registry.ts asserts this in CI.
273
+ * ============================================================ */
274
+ --background: oklch(0.965 0.003 73);
275
+ --foreground: oklch(0.16 0.008 73);
276
+ --card: oklch(0.995 0.003 73);
277
+ --card-foreground: oklch(0.16 0.008 73);
278
+ --popover: oklch(0.995 0.003 73);
279
+ --popover-foreground: oklch(0.16 0.008 73);
217
280
  /* ============================================================
218
281
  * INVARIANT — "one token means 'this control is on'" (#156/#159).
219
282
  * --primary is the single pigment that says a control is the primary
@@ -224,18 +287,48 @@
224
287
  * into separate hues, and do not let --primary drift to a neutral —
225
288
  * that is what made the dark theme brandless (see .dark below).
226
289
  * --primary carries the FiestaBoard warm orange in BOTH themes.
290
+ *
291
+ * --primary IS THE TILE, and the label on it is board ink (#231).
292
+ *
293
+ * It was oklch(0.52 0.111 73) = #8f5d00 — the board's orange darkened until
294
+ * a WHITE label cleared AA on it. But maxC(0.52, 73) is exactly 0.111, so
295
+ * that value was not a badly-tuned orange, it was the most orange anything
296
+ * can be at that lightness: a mustard. The brand had been WCAG-darkened into
297
+ * a different colour, which is the failure the audit opened with.
298
+ *
299
+ * The white label was the only reason it had to be dark, and the white label
300
+ * was never designed — it is stock shadcn's --primary-foreground that nobody
301
+ * re-asked when the FiestaBoard palette landed. The dark theme had already
302
+ * worked this out and used board ink.
303
+ *
304
+ * So: --primary is the literal #f5a623, theme-invariant, with board ink on
305
+ * it at 8.84:1 — better than white on #8f5d00 ever managed (5.63:1). The
306
+ * accessible option was the MORE saturated one the whole time. It also makes
307
+ * the most-clicked pixel in the product the exact colour the hardware flips.
308
+ *
309
+ * White on this field is 2.03:1 and is banned; there is exactly one
310
+ * on-primary foreground token so that is enforceable.
227
311
  * ============================================================ */
228
- --primary: oklch(0.52 0.16 60);
229
- --primary-foreground: oklch(0.985 0 0);
230
- --secondary: oklch(0.96 0 0);
231
- --secondary-foreground: oklch(0.205 0 0);
232
- --muted: oklch(0.955 0 0);
233
- /* Muted text needs >=4.5:1 contrast vs --background to meet WCAG AA. */
234
- --muted-foreground: oklch(0.4 0 0);
235
- --accent: oklch(0.95 0 0);
236
- --accent-foreground: oklch(0.205 0 0);
237
- --destructive: oklch(0.43 0.245 27.325);
238
- --destructive-foreground: oklch(0.985 0 0);
312
+ --primary: #f5a623;
313
+ --primary-foreground: oklch(0.205 0.008 73);
314
+ /* Hover RAISES label contrast (8.84 -> 10.78); press lowers it to 7.04. */
315
+ --primary-hover: oklch(0.84 0.128 73);
316
+ --primary-active: oklch(0.72 0.153 73);
317
+ /* Board ink hairline that bounds the focus ring. The ring is --primary, and
318
+ an orange band is only 1.83:1 against a light page, so the INK carries the
319
+ boundary and the orange carries the identity. */
320
+ --ring-edge: oklch(0.205 0.008 73);
321
+ --secondary: oklch(0.925 0.003 73);
322
+ --secondary-foreground: oklch(0.16 0.008 73);
323
+ --muted: oklch(0.935 0.003 73);
324
+ /* Muted text needs >=4.5:1 contrast vs --background to meet WCAG AA.
325
+ 8.32:1 on --background, 9.07:1 on --card, 7.61:1 on --muted. */
326
+ --muted-foreground: oklch(0.4 0.006 73);
327
+ --accent: oklch(0.945 0.003 73);
328
+ --accent-foreground: oklch(0.16 0.008 73);
329
+ /* Board red. 5.36:1 on --background; white label 5.93:1. */
330
+ --destructive: oklch(0.52 0.16 29);
331
+ --destructive-foreground: oklch(1 0 0);
239
332
  /* ============================================================
240
333
  * --border vs --input: TWO DIFFERENT INTENTS, NOT ONE VALUE (#161).
241
334
  *
@@ -255,64 +348,231 @@
255
348
  * Derived from --foreground as an alpha fraction so the boundary
256
349
  * tracks the text color in every theme and season instead of being a
257
350
  * hand-tuned grey per palette. Percentages are the smallest that clear
258
- * 3:1 on both surfaces with headroom (light 48% -> 3.45/3.48,
259
- * dark 38% -> 3.37/3.44); they differ because contrast ramps faster
260
- * for light-on-dark than dark-on-light.
351
+ * 3:1 on both surfaces with headroom (light 50% -> 3.55/3.62,
352
+ * dark 40% -> 3.51/3.56); they differ because contrast ramps faster
353
+ * for light-on-dark than dark-on-light. They moved up 2 points with the
354
+ * hue lock because --foreground itself moved.
355
+ *
356
+ * --border now joins the same derivation instead of being a hand-tuned
357
+ * grey, so decoration tracks the text through every theme and season too.
261
358
  *
262
359
  * DO NOT collapse these two tokens into one value in either direction.
263
360
  * ============================================================ */
264
- --border: oklch(0.905 0 0);
265
- --input: color-mix(in oklch, var(--foreground) 48%, transparent);
266
- --ring: oklch(0.52 0.16 60); /* = --primary, see the invariant above */
267
- --sidebar: oklch(0.975 0 0);
268
- --sidebar-foreground: oklch(0.145 0 0);
269
- --sidebar-accent: oklch(0.92 0 0);
270
- --sidebar-accent-foreground: oklch(0.205 0 0);
271
- --sidebar-border: oklch(0.915 0 0);
272
- --sidebar-ring: oklch(0.52 0.16 60); /* = --primary, see the invariant above */
273
- --info: oklch(0.45 0.15 250);
274
- --info-foreground: oklch(0.985 0 0);
275
- --success: oklch(0.4 0.16 150);
276
- --success-foreground: oklch(0.985 0 0);
277
- /* --warning must never collide with --brand (#157). In light they share a
278
- near hue (70 vs 75) but are pulled apart by lightness1.61:1 between
279
- them so caution still reads as caution next to a brand accent. The
280
- dark pair is separated on hue instead; see .dark. */
281
- --warning: oklch(0.45 0.16 70);
282
- --warning-foreground: oklch(0.985 0 0);
283
- --tag-variable: oklch(0.45 0.2 280);
284
- --tag-variable-foreground: oklch(0.35 0.1 280);
285
- --tag-success: oklch(0.45 0.2 160);
286
- --tag-success-foreground: oklch(0.35 0.1 160);
287
- --tag-formula: oklch(0.45 0.15 80);
288
- --tag-formula-foreground: oklch(0.35 0.1 80);
289
- --category-weather: oklch(0.55 0.14 245);
290
- --category-weather-foreground: oklch(0.42 0.11 245);
291
- --category-transit: oklch(0.55 0.15 145);
292
- --category-transit-foreground: oklch(0.42 0.12 145);
293
- --category-data: oklch(0.55 0.16 305);
294
- --category-data-foreground: oklch(0.42 0.13 305);
295
- --category-entertainment: oklch(0.55 0.14 75);
296
- --category-entertainment-foreground: oklch(0.42 0.11 75);
297
- --category-art: oklch(0.55 0.16 30);
298
- --category-art-foreground: oklch(0.42 0.13 30);
299
- --category-utility: oklch(0.55 0.03 255);
300
- --category-utility-foreground: oklch(0.42 0.03 255);
301
- --category-home: oklch(0.55 0.12 180);
302
- --category-home-foreground: oklch(0.42 0.1 180);
303
- --overlay: oklch(0 0 0 / 80%);
304
- /* Brand accent - darkened for WCAG 2.2 AA (≥4.5:1 on light bg) */
305
- --brand: oklch(0.56 0.15 75);
306
- --brand-emphasis: oklch(0.52 0.16 60);
307
- --brand-foreground: oklch(1 0 0);
308
- /* Navigation active state indigo palette */
309
- --nav-active: oklch(0.44 0.19 265); /* indigo-600 */
310
- --nav-active-foreground: oklch(1 0 0); /* white */
311
- --nav-active-hover: oklch(0.5 0.17 265 / 15%); /* indigo tint hover */
312
- /* Fiesta gradient palette — used by sidebar and page icon gradient */
313
- --fiesta-red: #c97a72;
314
- --fiesta-orange: #c99662;
315
- --fiesta-purple: #9b7bb0;
361
+ --border: color-mix(in oklch, var(--foreground) 11%, transparent);
362
+ --input: color-mix(in oklch, var(--foreground) 50%, transparent);
363
+ --ring: var(--primary); /* = --primary, see the invariant above */
364
+ /* ============================================================
365
+ * THE RAIL FOLLOWS THE THEME (#231).
366
+ *
367
+ * It was theme-invariant for a while — one warm graphite rail in both
368
+ * modes, argued as "a fixture, like the board bezel". That argument was
369
+ * measured and it does not hold: the same hex is 14.87:1 against the
370
+ * light page and 1.20:1 against the dark one. It was never invariant in
371
+ * the way that matters. In light mode it was the single highest-contrast
372
+ * object on the screen, which is the exact "the chrome out-shouts the
373
+ * product" fault that got the gradient deleted.
374
+ *
375
+ * So light mode gets a light rail at L 0.93a step DOWN from the page,
376
+ * not up. Chrome separation in light mode is always a low ratio (1.11:1
377
+ * here) and that is correct: the edge is carried by the derived border,
378
+ * not by a luminance cliff. Going lighter than the page instead lands at
379
+ * 1.03-1.05:1, which is genuinely invisible.
380
+ *
381
+ * The fork is deliberately ONE PAIR — this token and its foreground.
382
+ * Everything else on the rail derives from --sidebar-foreground (see
383
+ * .sidebar-gradient), which is what stops this from re-becoming the
384
+ * many-valued fork that shipped black labels onto a dark rail.
385
+ * ============================================================ */
386
+ --sidebar: oklch(0.93 0.003 73);
387
+ --sidebar-foreground: oklch(0.16 0.008 73);
388
+ --sidebar-accent: oklch(1 0 0 / 14%);
389
+ --sidebar-accent-foreground: oklch(0.985 0.002 73);
390
+ --sidebar-border: oklch(1 0 0 / 10%);
391
+ --sidebar-ring: var(--primary); /* = --primary, see the invariant above */
392
+ /* Board blue. 5.01:1 on --background; white label 5.54:1. */
393
+ --info: oklch(0.52 0.151 251);
394
+ --info-foreground: oklch(1 0 0);
395
+ /* Board green. 4.76:1 on --background; white label 5.27:1. */
396
+ --success: oklch(0.52 0.147 133);
397
+ --success-foreground: oklch(1 0 0);
398
+ /* Board yellow. --warning and --brand used to sit 5deg apart on the hue
399
+ wheel and were held apart by lightness alone (1.61:1). Under the hue
400
+ lock they are 31deg apart — genuinely different pigments — so the
401
+ separation no longer depends on a lightness budget that light mode
402
+ could not afford. 1.56:1 of luminance remains between them.
403
+ NOTE: yellow cannot be both dark enough to read as text on white and
404
+ light enough to read as yellow. --warning is therefore a FILL, ICON
405
+ and BORDER color, never a text color: its label is board ink at 4.93:1.
406
+ Alert enforces this status color never touches a sentence. */
407
+ --warning: oklch(0.62 0.131 104);
408
+ --warning-foreground: oklch(0.205 0.008 73);
409
+ --tag-variable: oklch(0.52 0.16 315);
410
+ --tag-variable-foreground: oklch(0.4 0.1 315);
411
+ --tag-success: oklch(0.52 0.147 133);
412
+ --tag-success-foreground: oklch(0.4 0.1 133);
413
+ --tag-formula: oklch(0.52 0.151 251);
414
+ --tag-formula-foreground: oklch(0.4 0.1 251);
415
+ /* ============================================================
416
+ * PLUGIN CATEGORIES ARE LITERAL TILES (#231), theme-invariant.
417
+ *
418
+ * These are the exact hardware hexes from --color-board-* above, not an
419
+ * approximation of them: a physical tile does not have a theme, and it
420
+ * does not have a rounding error either. They are consumed ONLY by
421
+ * PluginCategoryBadge, which renders them as a tile square on the board's
422
+ * own dark surface — never as a text color, never as a page fill.
423
+ *
424
+ * The old set shared ONE lightness across all seven, so the badges
425
+ * measured 1.001:1 against each other and collapsed completely under
426
+ * deuteranopia. The tiles span 4.21:1 to 19.65:1 against board black, so
427
+ * six of the seven separate on lightness alone with hue removed.
428
+ * The exception is home/orange vs transit/green (dL 0.001) — that pair is
429
+ * told apart by the word beside the tile, not by the tile.
430
+ *
431
+ * The mapping is NOT claimed to come from the hardware docs, which
432
+ * publish a temperature-and-status vocabulary (red = alerts and hot),
433
+ * not a content taxonomy. It is argued on its own terms: transit is go,
434
+ * weather is sky, art is the brightest pigment the board owns, and
435
+ * utility is the white tile because a utility should be the quietest
436
+ * loud thing in the set.
437
+ * ============================================================ */
438
+ --category-entertainment: #eb4034; /* board red */
439
+ --category-entertainment-foreground: oklch(0.52 0.16 29);
440
+ --category-home: #f5a623; /* board orange */
441
+ --category-home-foreground: oklch(0.52 0.111 73);
442
+ --category-art: #f8e71c; /* board yellow */
443
+ --category-art-foreground: oklch(0.52 0.11 104);
444
+ --category-transit: #7ed321; /* board green */
445
+ --category-transit-foreground: oklch(0.52 0.147 133);
446
+ --category-weather: #4a90d9; /* board blue */
447
+ --category-weather-foreground: oklch(0.52 0.151 251);
448
+ --category-data: #9b59b6; /* board violet */
449
+ --category-data-foreground: oklch(0.52 0.16 315);
450
+ --category-utility: #ffffff; /* board white */
451
+ --category-utility-foreground: oklch(0.4 0.014 73);
452
+ /* ============================================================
453
+ * THE SIX HUES AT AN INK LIGHTNESS (#231).
454
+ *
455
+ * The --category-* tokens above are the literal tiles: correct on the
456
+ * board's black surface, useless anywhere else. Board yellow on light
457
+ * paper is 1.15:1 and board orange is 1.83:1 — neither can draw a 2px
458
+ * icon stroke, let alone text.
459
+ *
460
+ * These are the same six hues moved to the ink plateau, where they can.
461
+ * Every one clears 4.7:1 on --background and 5.1:1 on --card in light,
462
+ * and 9.2:1 / 8.5:1 in dark — so they are legal for icons, rules, and
463
+ * text alike. Hue is identical to the tile; only lightness differs.
464
+ *
465
+ * This is what a surface reaches for when it wants "one of the brand's
466
+ * colours" and is not a board.
467
+ * ============================================================ */
468
+ --hue-red: oklch(0.52 0.16 29);
469
+ --hue-orange: oklch(0.52 0.111 73);
470
+ --hue-yellow: oklch(0.52 0.11 104);
471
+ --hue-green: oklch(0.52 0.147 133);
472
+ --hue-blue: oklch(0.52 0.151 251);
473
+ --hue-violet: oklch(0.52 0.16 315);
474
+ /* Scrim. In LIGHT a dialog reads 5.39:1 against the scrimmed page.
475
+ In DARK that number is meaningless — WCAG ratio compresses to ~1.3:1
476
+ between any two near-black surfaces no matter how they are tuned — so
477
+ modality in dark is carried by --elevation-modal's ring and shadow,
478
+ not by a surface contrast ratio. Do not "fix" the dark figure; it is
479
+ not measuring what it looks like it is measuring. */
480
+ --overlay: oklch(0.16 0.012 73 / 60%);
481
+ /* ============================================================
482
+ * BRAND: ONE PIGMENT, THREE JOBS (#231).
483
+ *
484
+ * --brand is an INVARIANT ALIAS of --primary, declared the same way
485
+ * --ring is. They used to be two tokens that were byte-identical in
486
+ * light and 0.17 lightness apart in dark, so a consumer picked a variant
487
+ * in light mode and the theme toggle silently changed their mind. One
488
+ * pigment, stated as a rule, is the honest version of that.
489
+ *
490
+ * --brand-tile is the LITERAL #f5a623 tile, identical in both themes,
491
+ * and is a FIELD ONLY. It is 1.83:1 against a light page, so it can be a
492
+ * button face but can never be a link or a text color; its label must be
493
+ * --brand-tile-foreground (board ink, 8.83:1), never white (2.03:1).
494
+ *
495
+ * --brand-hover always moves TOWARD --foreground, so hover increases
496
+ * contrast in both themes (light 5.08 -> 6.51, dark 8.34 -> 9.51)
497
+ * instead of decreasing it, which is what the old --brand-emphasis
498
+ * hover did on the docs site (9.86 -> 3.99, below AA).
499
+ * ============================================================ */
500
+ /* --brand is the TEXT step and is no longer an alias of --primary. Now that
501
+ --primary is the literal tile, it is 1.83:1 on a light page — legal as a
502
+ field, illegal as a link. --hue-orange is the same hue at the ink plateau
503
+ (5.08:1 light / 9.63:1 dark), which is what body copy and links need. */
504
+ --brand: var(--hue-orange);
505
+ --brand-foreground: oklch(0.205 0.008 73);
506
+ --brand-tile: var(--primary); /* DEPRECATED alias — same pigment now */
507
+ /* ============================================================
508
+ * THE WORDMARK IS A LOGOTYPE, and WCAG 1.4.3 exempts logotypes from
509
+ * contrast outright: "text that is part of a logo or brand name has no
510
+ * minimum contrast requirement."
511
+ *
512
+ * It was reading --brand, which is --hue-orange — the token that exists
513
+ * SPECIFICALLY to clear AA as body text. So the one element the spec
514
+ * excuses was the one held to the strictest version of the rule, and it
515
+ * paid in saturation: #8f5d00, a dark ochre, sitting directly beside a
516
+ * taco mark full of bright orange. The wordmark looked duller than the
517
+ * icon it is locked up with.
518
+ *
519
+ * Freed from a floor it never had, it goes where it belongs — in the
520
+ * middle of the mark's own shell tones. 2.60:1 on the light rail.
521
+ * ============================================================ */
522
+ --brand-wordmark: oklch(0.66 0.141 73);
523
+ /* The backdrop is a DARK board in both themes, so light mode needs the
524
+ lower number: a full-strength wall of black tiles on paper stops being a
525
+ background and becomes the subject, with the card marooned in a hole
526
+ punched through it.
527
+ At this strength the field reads as TEXTURE rather than as text, and that
528
+ is the right call once it covers the whole viewport — the grid carries the
529
+ brand on its own, and legible words wallpapered edge to edge would compete
530
+ with the form the screen exists to get filled in. */
531
+ --board-backdrop-opacity: 0.16;
532
+ --brand-tile-foreground: oklch(0.205 0.008 73);
533
+ --brand-hover: color-mix(in oklch, var(--brand) 82%, var(--foreground));
534
+ /* DEPRECATED alias, removed in the next minor. Kept so consumers pinned
535
+ to an older minor — and the docs site's build canary, which greps the
536
+ compiled CSS for `bg-brand-emphasis` and exits(1) if it is missing —
537
+ do not break on the same commit that changes every value. */
538
+ --brand-emphasis: var(--brand);
539
+ /* Navigation active state — board violet, theme-invariant.
540
+ Was indigo-600, the only cool accent in a gold-and-terracotta brand
541
+ and the one token in the file carrying no rationale. White label
542
+ 9.11:1. The fill is only 1.81:1 against the rail, so the 3:1 state
543
+ indicator SC 1.4.11 asks for is carried by the gold left marker on
544
+ .nav-active, not by the fill.
545
+
546
+ NO LONGER TRUE, and the marker is gone with it. The pill was dark violet
547
+ because it carried a WHITE label, which forced it down to 1.81:1 against
548
+ the rail — under SC 1.4.11's 3:1 for a state — and the gold bar was a
549
+ patch for that number, not a design. Flipping the label to board ink frees
550
+ the fill to go bright — and the fill it goes to is --primary itself, so
551
+ "the current page" becomes the same pigment as "the focused control" and
552
+ "the primary action". That is the #156/#159 invariant finally covering the
553
+ thing it always claimed to. 8.12:1 against the rail, ink label 8.84:1, so
554
+ the fill carries the state on its own: 1.4.11 asks for 3:1, not for a
555
+ marker, and does not care which pixels supply it.
556
+
557
+ A bright violet also cleared the floor (5.66:1) and was rejected on
558
+ hierarchy, not on arithmetic: it became the loudest object on the screen,
559
+ louder than the primary action, which is backwards for a nav state. */
560
+ /* THE ONE PLACE THE PILL CANNOT BE --primary. The tile is L 0.78, so it
561
+ needs a dark surround: against a light rail it tops out at 2.00:1 even
562
+ if the rail were pure white, under 1.4.11's 3:1 for a state. On a light
563
+ rail the pill inverts instead — ink fill, page-coloured label, 16.78:1
564
+ against the rail. The dark rail keeps the tile (8.12:1), so "one pigment
565
+ means on" still holds everywhere it physically can. */
566
+ --nav-active: var(--foreground);
567
+ --nav-active-foreground: var(--background);
568
+ --nav-active-hover: oklch(1 0 0 / 14%);
569
+ /* Fiesta gradient palette — three board hues at ONE lightness (0.46),
570
+ identical in both themes. Because that surface never changes luminance
571
+ across its 117s loop, the contrast of anything sitting on it is a
572
+ constant rather than a function of wall-clock time. */
573
+ --fiesta-red: oklch(0.46 0.15 29);
574
+ --fiesta-orange: oklch(0.46 0.098 73);
575
+ --fiesta-purple: oklch(0.46 0.15 315);
316
576
  /* Page icon gradient stops — 6 vars so pride month can expand to a full rainbow */
317
577
  --icon-g1: var(--fiesta-red);
318
578
  --icon-g2: var(--fiesta-orange);
@@ -320,10 +580,12 @@
320
580
  --icon-g4: var(--fiesta-orange);
321
581
  --icon-g5: var(--fiesta-purple);
322
582
  --icon-g6: var(--fiesta-purple);
323
- /* Elevation - subtle card and modal shadows */
324
- --elevation-card: 0 1px 2px 0 rgb(0 0 0 / 0.04), 0 2px 8px -2px rgb(0 0 0 / 0.06);
325
- --elevation-card-hover: 0 2px 8px -2px rgb(0 0 0 / 0.08), 0 4px 16px -4px rgb(0 0 0 / 0.06);
326
- --elevation-modal: 0 4px 12px -2px rgb(0 0 0 / 0.08), 0 4px 6px -4px rgb(0 0 0 / 0.04);
583
+ /* Elevation - subtle card and modal shadows.
584
+ Warm-tinted rather than neutral black: a grey shadow under a warm card
585
+ on a warm page reads as a rendering artifact, not as depth. */
586
+ --elevation-card: 0 1px 2px 0 oklch(0.3 0.03 73 / 0.05), 0 2px 8px -2px oklch(0.3 0.03 73 / 0.07);
587
+ --elevation-card-hover: 0 2px 8px -2px oklch(0.3 0.03 73 / 0.09), 0 4px 16px -4px oklch(0.3 0.03 73 / 0.07);
588
+ --elevation-modal: 0 16px 40px -12px oklch(0.3 0.03 73 / 0.2), 0 4px 10px -4px oklch(0.3 0.03 73 / 0.1);
327
589
 
328
590
  /* ============================================================
329
591
  * Motion tokens (B4) — one duration scale + named easings.
@@ -379,15 +641,15 @@
379
641
  * surface | --radius-surface | rounded-lg | 10px | modal surfaces (dialog, alert-dialog), alert, sidebar nav pills
380
642
  * card | --radius-card | rounded-xl | 12px | card, page-header icon tile
381
643
  * pill | --radius-pill | rounded-full | ∞ | badge, switch, slider, overlay close (dialog + sheet), scroll-area thumb, sidebar edge toggle, empty-state icon
382
- * chrome | --radius-chrome | (css/inline) | 14px | desktop sidebar shell + its aurora inset
383
- * chrome-mobile | --radius-chrome-mobile | (css/inline) | 16px | mobile header/menu shell, its aurora inset, menu clip-path reveal
644
+ * chrome | --radius-chrome | (css/inline) | 14px | desktop sidebar shell
645
+ * chrome-mobile | --radius-chrome-mobile | (css/inline) | 16px | mobile header/menu shell, menu clip-path reveal
384
646
  *
385
647
  * Components KEEP their Tailwind rounded-* classes — those already
386
648
  * resolve through the --radius calc chain, so each role token below
387
649
  * is defined with the same math and stays in lockstep with them.
388
650
  * New components pick a ROLE from this table instead of a raw size.
389
651
  * The chrome radii are deliberately fixed px (not on the rem-based
390
- * --radius chain): they match the aurora canvas insets exactly.
652
+ * --radius chain).
391
653
  *
392
654
  * Deliberately off-scale (left as-is): pride-burst particle 2px,
393
655
  * scroll-area viewport rounded-[inherit]. Both are arbitrary-value
@@ -411,7 +673,7 @@
411
673
  * --z-sidebar 50 desktop sidebar rail
412
674
  * --z-sidebar-toggle 51 edge collapse button — LOCAL value: the
413
675
  * fixed z-50 aside is its own stacking
414
- * context, so 51 only beats the aurora
676
+ * context, so 51 only beats the rail's own
415
677
  * canvas / z-[1] content column inside it
416
678
  * --z-mobile-backdrop 90 dimming layer behind the mobile menu
417
679
  * --z-mobile-menu 95 mobile slide-down menu panel
@@ -443,7 +705,7 @@
443
705
  *
444
706
  * Not on the scale (local stacking / one-offs): select scroll
445
707
  * buttons z-10 (sticky inside the popup), sidebar/header content
446
- * z-[1] (above the aurora canvas), board-switch view-transition
708
+ * z-[1] (above the rail surface), board-switch view-transition
447
709
  * groups 0/1 (::view-transition pseudo-tree only).
448
710
  * ============================================================ */
449
711
  --z-popover: 50;
@@ -462,12 +724,16 @@
462
724
 
463
725
  .dark {
464
726
  color-scheme: dark; /* see the note in :root (#160) */
465
- --background: oklch(0.13 0 0);
466
- --foreground: oklch(0.985 0 0);
467
- --card: oklch(0.16 0 0);
468
- --card-foreground: oklch(0.985 0 0);
469
- --popover: oklch(0.16 0 0);
470
- --popover-foreground: oklch(0.985 0 0);
727
+ /* Hue lock applies here too: same six hues, same hue angles as :root.
728
+ Only lightness moves. Surfaces carry the same warm cast at hue 73, and
729
+ the card lift went 0.030 -> 0.050 so a dark card reads as an object
730
+ rather than an outline drawn on a void. */
731
+ --background: oklch(0.145 0.004 73);
732
+ --foreground: oklch(0.965 0.003 73);
733
+ --card: oklch(0.195 0.004 73);
734
+ --card-foreground: oklch(0.965 0.003 73);
735
+ --popover: oklch(0.235 0.004 73);
736
+ --popover-foreground: oklch(0.965 0.003 73);
471
737
  /* Brand-bearing primary (#156). This used to be oklch(0.922 0 0) — stock
472
738
  shadcn's achromatic dark primary, never re-designed when the FiestaBoard
473
739
  palette landed. Dark is the DEFAULT theme, so every default Button,
@@ -476,67 +742,94 @@
476
742
  surface (light 0.52 -> dark 0.72) and held on the same hue family.
477
743
  Upholds the "one token means 'this control is on'" invariant declared in
478
744
  :root: --ring and --sidebar-ring below repeat this exact value. */
479
- --primary: oklch(0.72 0.15 62);
480
- /* Near-black on-accent foreground, matching every other dark accent
481
- foreground in this block (destructive/info/success/warning all 0.15). */
482
- --primary-foreground: oklch(0.15 0 0);
483
- --secondary: oklch(0.215 0 0);
484
- --secondary-foreground: oklch(0.985 0 0);
485
- --muted: oklch(0.2 0 0);
486
- --muted-foreground: oklch(0.83 0 0);
487
- --accent: oklch(0.19 0 0);
488
- --accent-foreground: oklch(0.985 0 0);
489
- --destructive: oklch(0.8 0.191 22.216);
490
- --destructive-foreground: oklch(0.15 0 0);
745
+ /* Theme-invariant: a physical tile does not have a theme. Identical to
746
+ :root see the note there. 9.77:1 on --background here. */
747
+ --primary: #f5a623;
748
+ --primary-foreground: oklch(0.205 0.008 73);
749
+ --primary-hover: oklch(0.84 0.128 73);
750
+ --primary-active: oklch(0.72 0.153 73);
751
+ --ring-edge: oklch(0.205 0.008 73);
752
+ --secondary: oklch(0.255 0.004 73);
753
+ --secondary-foreground: oklch(0.965 0.003 73);
754
+ --muted: oklch(0.235 0.004 73);
755
+ /* 8.10:1 on --background, 7.51:1 on --card, 6.86:1 on --muted. */
756
+ --muted-foreground: oklch(0.725 0.004 73);
757
+ --accent: oklch(0.225 0.004 73);
758
+ --accent-foreground: oklch(0.965 0.003 73);
759
+ /* Board red. 6.88:1 on --background; board-ink label 6.23:1. */
760
+ --destructive: oklch(0.7 0.17 29);
761
+ --destructive-foreground: oklch(0.205 0.008 73);
491
762
  /* --border stays decorative-subtle; --input is the 3:1 control boundary.
492
763
  Full rationale in the :root block — do not merge the two. */
493
- --border: oklch(1 0 0 / 10%);
494
- --input: color-mix(in oklch, var(--foreground) 38%, transparent);
495
- --ring: oklch(0.72 0.15 62); /* = --primary, see the invariant in :root */
496
- --sidebar: oklch(0.145 0 0);
497
- --sidebar-foreground: oklch(0.985 0 0);
498
- --sidebar-accent: oklch(0.28 0 0);
499
- --sidebar-accent-foreground: oklch(0.985 0 0);
500
- --sidebar-border: oklch(1 0 0 / 8%);
501
- --sidebar-ring: oklch(0.72 0.15 62); /* = --primary, see the invariant in :root */
502
- --info: oklch(0.75 0.18 250);
503
- --info-foreground: oklch(0.15 0 0);
504
- --success: oklch(0.68 0.18 145);
505
- --success-foreground: oklch(0.15 0 0);
506
- /* Amber caution, deliberately OFF the brand hue (#157). This was
507
- oklch(0.78 0.14 75) byte-identical to --brand below, so "this is
508
- FiestaBoard" and "be careful" rendered as the same pigment and neither
509
- could be learned. Moved +20deg toward yellow and +0.08 lighter, which
510
- puts 1.34:1 of luminance and a clear hue break between the two. The
511
- 22deg drift from the light --warning (hue 70) is intentional and small:
512
- light must separate warning from brand on LIGHTNESS (it has to stay dark
513
- enough to be readable text on white), dark separates them on HUE. */
514
- --warning: oklch(0.86 0.16 95);
515
- --warning-foreground: oklch(0.15 0 0);
516
- --tag-variable: oklch(0.65 0.15 280);
517
- --tag-variable-foreground: oklch(0.85 0.08 280);
518
- --tag-success: oklch(0.65 0.15 160);
519
- --tag-success-foreground: oklch(0.85 0.08 160);
520
- --tag-formula: oklch(0.65 0.15 80);
521
- --tag-formula-foreground: oklch(0.85 0.08 80);
522
- --category-weather: oklch(0.68 0.13 245);
523
- --category-weather-foreground: oklch(0.85 0.08 245);
524
- --category-transit: oklch(0.68 0.14 145);
525
- --category-transit-foreground: oklch(0.85 0.09 145);
526
- --category-data: oklch(0.68 0.14 305);
527
- --category-data-foreground: oklch(0.85 0.09 305);
528
- --category-entertainment: oklch(0.68 0.13 75);
529
- --category-entertainment-foreground: oklch(0.85 0.08 75);
530
- --category-art: oklch(0.68 0.14 30);
531
- --category-art-foreground: oklch(0.85 0.09 30);
532
- --category-utility: oklch(0.68 0.03 255);
533
- --category-utility-foreground: oklch(0.85 0.02 255);
534
- --category-home: oklch(0.68 0.11 180);
535
- --category-home-foreground: oklch(0.85 0.07 180);
536
- --overlay: oklch(0 0 0 / 80%);
537
- --brand: oklch(0.78 0.14 75);
538
- --brand-emphasis: oklch(0.55 0.16 60);
539
- --brand-foreground: oklch(1 0 0);
764
+ --border: color-mix(in oklch, var(--foreground) 12%, transparent);
765
+ --input: color-mix(in oklch, var(--foreground) 40%, transparent);
766
+ --ring: var(--primary); /* = --primary, see the invariant in :root */
767
+ /* Warm graphite, one step up from --card so the rail reads as housing
768
+ rather than as more page. See the note in :root. */
769
+ --sidebar: oklch(0.24 0.005 73);
770
+ --sidebar-foreground: oklch(0.985 0.002 73);
771
+ --sidebar-accent: oklch(1 0 0 / 14%);
772
+ --sidebar-accent-foreground: oklch(0.985 0.002 73);
773
+ --sidebar-border: oklch(1 0 0 / 10%);
774
+ --sidebar-ring: var(--primary); /* = --primary, see the invariant in :root */
775
+ /* Board blue. 8.57:1 on --background; board-ink label 7.76:1. */
776
+ --info: oklch(0.74 0.139 251);
777
+ --info-foreground: oklch(0.205 0.008 73);
778
+ /* Board green. 10.40:1 on --background; board-ink label 9.42:1. */
779
+ --success: oklch(0.78 0.17 133);
780
+ --success-foreground: oklch(0.205 0.008 73);
781
+ /* Board yellow, the SAME hue as the light --warning (104). The old pair
782
+ drifted 25deg between themes — the only hue-unstable token in the file
783
+ — because light had to separate warning from brand on lightness while
784
+ dark separated them on hue. Under the hue lock the separation is
785
+ structural in both themes: warning is yellow, brand is orange, 31deg
786
+ apart, and neither has to move to stay legible.
787
+ 13.05:1 on --background; board-ink label 11.81:1. */
788
+ --warning: oklch(0.86 0.17 104);
789
+ --warning-foreground: oklch(0.205 0.008 73);
790
+ --tag-variable: oklch(0.78 0.164 315);
791
+ --tag-variable-foreground: oklch(0.86 0.1 315);
792
+ --tag-success: oklch(0.78 0.17 133);
793
+ --tag-success-foreground: oklch(0.86 0.1 133);
794
+ --tag-formula: oklch(0.78 0.116 251);
795
+ --tag-formula-foreground: oklch(0.86 0.09 251);
796
+ /* Categories are literal tiles and therefore theme-invariant these are
797
+ byte-identical to :root on purpose. See the note there. */
798
+ --category-entertainment: #eb4034; /* board red */
799
+ --category-entertainment-foreground: oklch(0.78 0.129 29);
800
+ --category-home: #f5a623; /* board orange */
801
+ --category-home-foreground: oklch(0.78 0.166 73);
802
+ --category-art: #f8e71c; /* board yellow */
803
+ --category-art-foreground: oklch(0.78 0.164 104);
804
+ --category-transit: #7ed321; /* board green */
805
+ --category-transit-foreground: oklch(0.78 0.17 133);
806
+ --category-weather: #4a90d9; /* board blue */
807
+ --category-weather-foreground: oklch(0.78 0.116 251);
808
+ --category-data: #9b59b6; /* board violet */
809
+ --category-data-foreground: oklch(0.78 0.164 315);
810
+ --category-utility: #ffffff; /* board white */
811
+ --category-utility-foreground: oklch(0.725 0.012 73);
812
+ /* Same six hues, lifted to the dark ink plateau. See :root. */
813
+ --hue-red: oklch(0.78 0.129 29);
814
+ --hue-orange: oklch(0.78 0.166 73);
815
+ --hue-yellow: oklch(0.78 0.164 104);
816
+ --hue-green: oklch(0.78 0.17 133);
817
+ --hue-blue: oklch(0.78 0.116 251);
818
+ --hue-violet: oklch(0.78 0.164 315);
819
+ /* See the :root note: the dark figure here is not a contrast ratio worth
820
+ quoting. Modality in dark is carried by --elevation-modal. */
821
+ --overlay: oklch(0.08 0.006 73 / 72%);
822
+ --brand: var(--hue-orange); /* text step — see :root */
823
+ --brand-foreground: oklch(0.205 0.008 73);
824
+ --brand-tile: var(--primary); /* DEPRECATED alias — same pigment now */
825
+ /* On a dark rail the wordmark can be the literal tile — the exact pigment
826
+ the hardware flips, and the one the icon beside it already uses. 8.12:1. */
827
+ --brand-wordmark: var(--primary);
828
+ /* Dark tiles on a dark page need far less to read. See :root. */
829
+ --board-backdrop-opacity: 0.28;
830
+ --brand-tile-foreground: oklch(0.205 0.008 73);
831
+ --brand-hover: color-mix(in oklch, var(--brand) 82%, var(--foreground));
832
+ --brand-emphasis: var(--brand); /* DEPRECATED — see :root */
540
833
  /* Navigation active state — indigo palette.
541
834
  NOT "lighter for dark mode" (#162): --nav-active is the solid FALLBACK
542
835
  fill for the active nav pill, used whenever the frosted-glass treatment
@@ -547,17 +840,29 @@
547
840
  one. Darkened to clear AAA (7.47:1) instead. Kept as light as AAA allows
548
841
  rather than the 0.42 the audit suggested (8.91:1), because darkening the
549
842
  pill trades text contrast against the pill's own separation from the
550
- surface behind it — see the fallback rules near .nav-active. */
551
- --nav-active: oklch(0.46 0.19 265);
552
- --nav-active-foreground: oklch(1 0 0); /* white */
553
- --nav-active-hover: oklch(0.59 0.19 265 / 20%); /* indigo tint hover */
554
- /* Fiesta gradient palette lighter for visibility on dark card backgrounds */
555
- --fiesta-red: #e08a82;
556
- --fiesta-orange: #d4a870;
557
- --fiesta-purple: #b08ec8;
558
- --elevation-card: 0 1px 3px 0 rgb(0 0 0 / 0.2), 0 0 0 1px oklch(1 0 0 / 6%);
559
- --elevation-card-hover: 0 2px 8px -2px rgb(0 0 0 / 0.3), 0 0 0 1px oklch(1 0 0 / 10%);
560
- --elevation-modal: 0 8px 24px -4px rgb(0 0 0 / 0.25), 0 4px 8px -4px rgb(0 0 0 / 0.15);
843
+ surface behind it — see the fallback rules near .nav-active.
844
+
845
+ Now board violet and theme-invariant identical to :root. Because the
846
+ rail is a flat, known surface, the pill no
847
+ longer needs a per-theme value, and its contrast no longer depends on
848
+ where a 117-second animation happens to be. */
849
+ --nav-active: var(--primary); /* see :root */
850
+ --nav-active-foreground: var(--primary-foreground);
851
+ --nav-active-hover: oklch(1 0 0 / 14%);
852
+ /* Identical to :root. See the note there. */
853
+ --fiesta-red: oklch(0.46 0.15 29);
854
+ --fiesta-orange: oklch(0.46 0.098 73);
855
+ --fiesta-purple: oklch(0.46 0.15 315);
856
+ /* A dark card is an object, not an outline. This is the same lit-top /
857
+ shadowed-base recipe that makes a character tile read as a raised flap
858
+ (see components/board/board-surfaces.ts charLeafBoxShadow.black), so
859
+ depth in the chrome and depth on the board are one idea. */
860
+ --elevation-card:
861
+ inset 0 1px 0 rgb(255 255 255 / 0.1), inset 0 -1px 0 rgb(0 0 0 / 0.55), 0 2px 8px -3px rgb(0 0 0 / 0.55);
862
+ --elevation-card-hover:
863
+ inset 0 1px 0 rgb(255 255 255 / 0.14), inset 0 -1px 0 rgb(0 0 0 / 0.55), 0 4px 14px -4px rgb(0 0 0 / 0.6);
864
+ /* Carries modality in dark, where a surface contrast ratio cannot. */
865
+ --elevation-modal: 0 24px 48px -12px rgb(0 0 0 / 0.75), inset 0 0 0 1px oklch(1 0 0 / 0.14);
561
866
  }
562
867
 
563
868
  @layer base {
@@ -570,7 +875,16 @@
570
875
  }
571
876
  body {
572
877
  @apply bg-background text-foreground;
573
- font-feature-settings: "ss01";
878
+ /* `font-feature-settings: "ss01"` used to sit here, inherited from
879
+ FiestaBoard's globals.css. It was already a no-op before the typeface
880
+ change: neither the Geist nor the Geist Mono builds this package used
881
+ to ship exposed an ss01 feature (their GSUB tables carry
882
+ ccmp/kern/liga/locl/pnum/tnum and no stylistic sets), and neither
883
+ Archivo nor Spline Sans Mono does either. Left in place it would be
884
+ a trap — the first future typeface that DOES define ss01 would
885
+ silently switch on an arbitrary alternate glyph set nobody chose.
886
+ If a stylistic set is ever wanted, name the feature and the face it
887
+ belongs to at the call site. */
574
888
  /* Clip horizontal overflow at the document root so the body itself stays the
575
889
  * vertical scroll container. Applying overflow-x to <main> would force its
576
890
  * computed overflow-y to `auto`, recreating a nested scrollbar. */
@@ -668,54 +982,6 @@
668
982
  * Consumed by the chrome components (Sidebar, PageHeader, …).
669
983
  * ============================================================ */
670
984
 
671
- /* Pride styling lives in seasons/pride.css alongside the draft seasons. */
672
-
673
- /* Season easter egg — confetti burst particles spawned by JS on logo click
674
- (class name kept as pride-burst-* for app compatibility; every season's
675
- fireSeasonBurst() uses it) */
676
- @keyframes pride-burst {
677
- 0% {
678
- transform: translate(0, 0) rotate(0deg) scale(1);
679
- opacity: 1;
680
- }
681
- 100% {
682
- transform: translate(var(--tx), var(--ty)) rotate(var(--rot)) scale(0.2);
683
- opacity: 0;
684
- }
685
- }
686
- /* Wrapper so the 48 particles append to the live tree in a single mutation
687
- and share one animationend listener. Fixed (out of flow) so the empty
688
- container never affects body layout; no transform/filter/contain here, so
689
- the fixed particles still resolve against the viewport. */
690
- .pride-burst {
691
- position: fixed;
692
- top: 0;
693
- left: 0;
694
- /* The fixed container forms a stacking context, trapping the particles'
695
- z-index inside it — so the container itself must sit at confetti level
696
- or the burst paints under sheets/modals/tooltips. */
697
- z-index: var(--z-confetti);
698
- pointer-events: none;
699
- }
700
- .pride-burst-particle {
701
- position: fixed;
702
- width: 7px;
703
- height: 7px;
704
- border-radius: 2px;
705
- pointer-events: none;
706
- z-index: var(--z-confetti);
707
- /* 0.8s default is a one-off (JS overrides via --dur), so it stays inline */
708
- animation: pride-burst var(--dur, 0.8s) var(--motion-ease-out) forwards;
709
- }
710
-
711
- /* fireSeasonBurst() already skips the burst under reduced motion, but mirror
712
- every other motion surface in this file with a CSS gate as a backstop. */
713
- @media (prefers-reduced-motion: reduce) {
714
- .pride-burst-particle {
715
- animation: none !important;
716
- }
717
- }
718
-
719
985
  /* Board switch transition — the app-wide board selector wraps its state
720
986
  update in document.startViewTransition() (see current-board-context.tsx),
721
987
  stamping html[data-board-switch="forward"|"backward"]. Only the page
@@ -880,118 +1146,84 @@ html[data-board-switch="backward"]::view-transition-new(board-page) {
880
1146
  /* Sidebar & mobile header: Fiesta-brand gradient (red, orange, purple).
881
1147
  Repeating gradient for seamless loop (no flash).
882
1148
  Static when prefers-reduced-motion. */
883
- @keyframes sidebar-gradient-flow {
884
- /* Runs on .sidebar-gradient-layer::before (see below): translate exactly one
885
- repeat period (300% of the container = 75% of the 400% layer) for a
886
- seamless loop — no flash at end. transform-only so the loop stays on the
887
- compositor with zero per-frame repaint (issue #57). */
888
- 0% {
889
- transform: translateY(0);
890
- }
891
- 100% {
892
- transform: translateY(-75%);
893
- }
894
- }
895
-
896
- /* Logo in light mode: deep warm brown Fiesta + black Board for contrast across gradient */
897
- .sidebar-gradient:not(.dark) .logo-on-gradient,
898
- .sidebar-gradient-horizontal:not(.dark) .logo-on-gradient {
899
- --brand: oklch(0.28 0.08 50);
900
- --sidebar-foreground: oklch(0 0 0);
901
- }
902
1149
 
903
- /* Logo in dark mode: white Board text, brighter brand for contrast on dark gradient */
904
- .dark .sidebar-gradient .logo-on-gradient,
905
- .dark .sidebar-gradient-horizontal .logo-on-gradient {
906
- --brand: oklch(0.78 0.14 75);
907
- --sidebar-foreground: oklch(1 0 0);
908
- }
1150
+ /* One logo treatment, both themes. These used to fork near-black "Board" on
1151
+ a light coral rail in light mode, white on a dark rail in dark mode. The
1152
+ rail is now a fixture that does not change with the theme, so the fork is
1153
+ not just unnecessary, it was actively wrong: it painted black text onto a
1154
+ dark rail the moment the light stops were darkened. */
1155
+ /* The `.logo-on-gradient` override is gone: FiestaLogo now reads
1156
+ --brand-wordmark and --foreground directly, so it is correct on the rail, on
1157
+ a wizard card and anywhere else without a scoped rule pinning it. That rule
1158
+ had already caused two bugs by pinning what should have been a token — white
1159
+ "Board" that vanished on a light rail, then a tile-orange "Fiesta" that would
1160
+ have. */
909
1161
 
910
- /* Light mode: soft gradient (coral → peach → lavender).
911
- Stops derive from the canonical --fiesta-* brand gradient tokens
912
- never fork the hexes here; seasons retint via --sidebar-fiesta-*. */
1162
+ /* ============================================================
1163
+ * THE RAIL IS QUIET; THE LOCKUP CARRIES THE BRAND (#231).
1164
+ *
1165
+ * This used to be a full-height animated gradient in two hand-forked
1166
+ * palettes — a light coral/peach ramp in light mode, deep oxblood-to-olive
1167
+ * in dark. Three things were wrong with it, and they compounded:
1168
+ *
1169
+ * 1. On an empty install the rail was the brightest, most saturated
1170
+ * object on the screen. The chrome out-shouted the product: the board
1171
+ * is the thing to look at, and it was losing to the navigation.
1172
+ * 2. A red → orange → violet ramp spends its middle third in the browns
1173
+ * and olives between those hues. Over 800px of rail that reads as mud,
1174
+ * and no amount of retuning the stops fixes an interpolation problem.
1175
+ * 3. Nothing sitting on it could be designed, because its background was
1176
+ * a function of wall-clock time.
1177
+ *
1178
+ * The gradient is now confined to the LOCKUP BAND at the top — behind the
1179
+ * logo and the board switcher, where identity belongs and where 88px of
1180
+ * colour reads as a brand mark rather than as a wall. Below that it fades
1181
+ * into the flat rail. The nav body sits on one predictable surface, which
1182
+ * is what lets the active pill and the labels be tuned once instead of
1183
+ * sampled per frame.
1184
+ *
1185
+ * The rail is the same warm graphite in both themes (see --sidebar), so the
1186
+ * whole light/dark fork below it is gone — along with the class of bug where
1187
+ * darkening the light stops left black labels on a dark rail.
1188
+ * ============================================================ */
1189
+ /* Every local override here is DERIVED from --sidebar-foreground rather than
1190
+ hardcoded to white. That is the whole reason the rail can follow the theme
1191
+ again: the previous set assumed a dark rail, so the moment the rail went
1192
+ light it painted white labels on a light surface. Deriving means the rail
1193
+ has exactly one value to fork (--sidebar and its foreground) and everything
1194
+ sitting on it follows for free. */
913
1195
  .sidebar-gradient {
914
- --sidebar-fiesta-red: var(--fiesta-red);
915
- --sidebar-fiesta-orange: var(--fiesta-orange);
916
- --sidebar-fiesta-purple: var(--fiesta-purple);
917
- background: repeating-linear-gradient(
918
- 170deg,
919
- var(--sidebar-fiesta-red) 0%,
920
- var(--sidebar-fiesta-orange) 33.33%,
921
- var(--sidebar-fiesta-purple) 66.66%,
922
- var(--sidebar-fiesta-red) 100%
923
- );
924
- background-size: 100% 300%;
925
- background-position: 50% 0%;
926
- background-repeat: repeat-y;
927
- color: oklch(0 0 0);
1196
+ background: var(--sidebar);
1197
+ color: var(--sidebar-foreground);
928
1198
  border-radius: var(--radius-chrome);
929
- --sidebar-foreground: oklch(0 0 0);
930
- --sidebar-accent: oklch(1 0 0 / 35%);
931
- --sidebar-accent-foreground: oklch(0 0 0);
932
- --sidebar-border: oklch(0 0 0 / 12%);
933
- --foreground: oklch(0 0 0);
934
- --muted-foreground: oklch(0.15 0 0);
935
- --accent: oklch(1 0 0 / 28%);
936
- --accent-foreground: oklch(0 0 0);
937
- --border: oklch(0 0 0 / 12%);
938
- }
939
- /* Dark mode: rich Fiesta gradient — deep-toned stops defined ONCE for both
940
- the vertical sidebar and the mobile horizontal header. */
941
- .dark .sidebar-gradient,
942
- .dark .sidebar-gradient-horizontal {
943
- --sidebar-fiesta-red: #7a2a24;
944
- --sidebar-fiesta-orange: #6d5014;
945
- --sidebar-fiesta-purple: #4e3070; /* board violet, darker for dark theme */
946
- }
947
- .dark .sidebar-gradient {
948
- color: oklch(1 0 0);
949
- --sidebar-foreground: oklch(1 0 0);
950
- --sidebar-accent: oklch(1 0 0 / 24%);
951
- --sidebar-accent-foreground: oklch(1 0 0);
952
- --sidebar-border: oklch(1 0 0 / 18%);
953
- --foreground: oklch(1 0 0);
954
- --muted-foreground: oklch(1 0 0 / 85%);
955
- --accent: oklch(1 0 0 / 20%);
956
- --accent-foreground: oklch(1 0 0);
957
- --border: oklch(1 0 0 / 18%);
1199
+ --sidebar-accent: color-mix(in oklch, var(--sidebar-foreground) 10%, transparent);
1200
+ --sidebar-accent-foreground: var(--sidebar-foreground);
1201
+ --sidebar-border: color-mix(in oklch, var(--sidebar-foreground) 14%, transparent);
1202
+ --foreground: var(--sidebar-foreground);
1203
+ --muted-foreground: color-mix(in oklch, var(--sidebar-foreground) 72%, transparent);
1204
+ --accent: color-mix(in oklch, var(--sidebar-foreground) 10%, transparent);
1205
+ --accent-foreground: var(--sidebar-foreground);
1206
+ --border: color-mix(in oklch, var(--sidebar-foreground) 14%, transparent);
958
1207
  }
959
1208
 
1209
+ /* No CSS gradient paints here any more (#231). The rail is a flat surface;
1210
+ colour arrives through the wordmark, the active pill, and the per-page hue
1211
+ on the page icon. */
1212
+
960
1213
  .sidebar-gradient-horizontal {
961
- /* Light mode: identical stops to the vertical sidebar one brand gradient
962
- on every viewport (previously the mobile header ran subtly warmer). */
963
- --sidebar-fiesta-red: var(--fiesta-red);
964
- --sidebar-fiesta-orange: var(--fiesta-orange);
965
- --sidebar-fiesta-purple: var(--fiesta-purple);
966
- background: repeating-linear-gradient(
967
- 135deg,
968
- var(--sidebar-fiesta-red) 0,
969
- var(--sidebar-fiesta-orange) 33.33%,
970
- var(--sidebar-fiesta-purple) 66.66%,
971
- var(--sidebar-fiesta-red) 100%
972
- );
973
- background-size: 400% 100%;
974
- background-position: 0% 50%;
975
- color: oklch(0 0 0);
1214
+ /* Flat, like the desktop rail (#231). The mobile bar and the rail are the
1215
+ same object at two widths, so they get the same treatment a gradient on
1216
+ one and not the other is how two chromes start disagreeing. */
1217
+ background: var(--sidebar);
1218
+ color: var(--sidebar-foreground);
976
1219
  border-radius: var(--radius-chrome-mobile);
977
- --foreground: oklch(0 0 0);
978
- --sidebar-accent: oklch(1 0 0 / 35%);
979
- --sidebar-accent-foreground: oklch(0 0 0);
980
- --muted-foreground: oklch(0.15 0 0);
981
- --accent: oklch(1 0 0 / 28%);
982
- --accent-foreground: oklch(0 0 0);
983
- --border: oklch(0 0 0 / 12%);
984
- }
985
- @keyframes sidebar-gradient-flow-h {
986
- /* Runs on .sidebar-gradient-layer::before: one period (33.33% of the 400%
987
- background box = 100% of the container = 50% of the 200% layer) per loop —
988
- same wrap as the old background-position scroll, compositor-only. */
989
- 0% {
990
- transform: translateX(0);
991
- }
992
- 100% {
993
- transform: translateX(-50%);
994
- }
1220
+ --foreground: var(--sidebar-foreground);
1221
+ --sidebar-accent: color-mix(in oklch, var(--sidebar-foreground) 12%, transparent);
1222
+ --sidebar-accent-foreground: var(--sidebar-foreground);
1223
+ --muted-foreground: color-mix(in oklch, var(--sidebar-foreground) 78%, transparent);
1224
+ --accent: color-mix(in oklch, var(--sidebar-foreground) 12%, transparent);
1225
+ --accent-foreground: var(--sidebar-foreground);
1226
+ --border: color-mix(in oklch, var(--sidebar-foreground) 16%, transparent);
995
1227
  }
996
1228
  .dark .sidebar-gradient-horizontal {
997
1229
  color: oklch(1 0 0);
@@ -1004,67 +1236,12 @@ html[data-board-switch="backward"]::view-transition-new(board-page) {
1004
1236
  --border: oklch(1 0 0 / 18%);
1005
1237
  }
1006
1238
 
1007
- /* Compositor-friendly gradient flow (issue #57).
1008
- The .sidebar-gradient(-horizontal) containers above keep a STATIC copy of
1009
- the gradient as their background the single surface seasons override
1010
- (light retint via --sidebar-fiesta-*, dark seasons flat-hex via
1011
- `background` + `animation: none`) and the graceful static fallback for
1012
- markup without the layer element. The moving copy lives on
1013
- .sidebar-gradient-layer::before: it re-inherits that same background
1014
- (container → layer → ::before), overscans it by exactly one repeat
1015
- period, and translates with a transform loop — so the flow runs on the
1016
- compositor with no per-frame rasterization, and the .nav-active
1017
- backdrop-filter pills get a cacheable backdrop (#93). Dark seasons stay
1018
- correct for free: the inherited background is their flat hex, so the
1019
- layer paints the same flat color. The layer clips itself to the rounded
1020
- corners because the desktop <aside> cannot take overflow:hidden (the
1021
- edge toggle button overhangs it). */
1022
- .sidebar-gradient,
1023
- .sidebar-gradient-horizontal {
1024
- /* Keep the z-index:-1 layer above this element's own background even when
1025
- the container isn't otherwise a stacking context (e.g. story wrappers). */
1026
- isolation: isolate;
1027
- }
1028
- .sidebar-gradient-layer {
1029
- position: absolute;
1030
- inset: 0;
1031
- z-index: -1; /* under the container's content, above its background */
1032
- overflow: hidden;
1033
- border-radius: inherit;
1034
- background: inherit; /* inheritance link: ::before re-inherits from here */
1035
- pointer-events: none;
1036
- }
1037
- .sidebar-gradient-layer::before {
1038
- content: "";
1039
- position: absolute;
1040
- top: 0;
1041
- left: 0;
1042
- background: inherit;
1043
- will-change: transform;
1044
- }
1045
- /* Vertical: the container's gradient box is 100% × 300%. The ::before
1046
- overscans to 400% height (100% visible + one 300% travel period), so
1047
- translateY(-75%) [= -300% of the container] wraps onto the repeat. */
1048
- .sidebar-gradient .sidebar-gradient-layer::before {
1049
- width: 100%;
1050
- height: 400%;
1051
- background-size: 100% 75%; /* same box as the container's 100% 300% */
1052
- background-position: 50% 0%; /* pinned to the container's t=0 anchor */
1053
- background-repeat: repeat-y;
1054
- animation: sidebar-gradient-flow 117s linear infinite;
1055
- }
1056
- /* Horizontal: the container's gradient box is 400% × 100%; one loop travels
1057
- 33.33% background-position = 100% of the container, so the ::before
1058
- overscans to 200% width and translateX(-50%) reproduces one period. */
1059
- .sidebar-gradient-horizontal .sidebar-gradient-layer::before {
1060
- width: 200%;
1061
- height: 100%;
1062
- background-size: 200% 100%; /* same box as the container's 400% 100% */
1063
- background-position: 0% 50%;
1064
- background-repeat: repeat;
1065
- animation: sidebar-gradient-flow-h 117s linear 0.35s infinite;
1066
- }
1067
-
1239
+ /* .sidebar-gradient-layer is gone (#57 is closed). It existed to host a
1240
+ compositor-only copy of the scrolling gradient, and later to give the
1241
+ seasonal WebGL aurora a stacking context under the rail's content. Both of
1242
+ those are gone now, and a flat background clips to its own
1243
+ border-radius, so the element, the `isolation: isolate` it required, and the
1244
+ will-change hint it carried all had nothing left to do. */
1068
1245
  /* Navigation entrance animations */
1069
1246
  @keyframes nav-slide-in-left {
1070
1247
  from {
@@ -1086,17 +1263,10 @@ html[data-board-switch="backward"]::view-transition-new(board-page) {
1086
1263
  transform: translateY(0);
1087
1264
  }
1088
1265
  }
1089
- /* Desktop: entrance on the container; the gradient flow runs on
1090
- .sidebar-gradient-layer::before and starts after 400ms. */
1091
- /* 117s gradient loop is an ambient one-off; its 0.4s/0.35s start delays
1092
- mirror the entrance durations (slowest/slower) and stay literal with them */
1093
1266
  @media (min-width: 1024px) {
1094
1267
  .sidebar-gradient {
1095
1268
  animation: nav-slide-in-left var(--motion-duration-slowest) var(--motion-ease-out) both;
1096
1269
  }
1097
- .sidebar-gradient .sidebar-gradient-layer::before {
1098
- animation-delay: 0.4s;
1099
- }
1100
1270
  }
1101
1271
  .sidebar-gradient-horizontal {
1102
1272
  animation: nav-slide-in-top var(--motion-duration-slower) var(--motion-ease-out) both;
@@ -1132,157 +1302,149 @@ html[data-board-switch="backward"]::view-transition-new(board-page) {
1132
1302
  .content-shift {
1133
1303
  transition: none;
1134
1304
  }
1135
- .sidebar-gradient {
1136
- animation: none;
1137
- background-position: 50% 25%;
1138
- }
1305
+ .sidebar-gradient,
1139
1306
  .sidebar-gradient-horizontal {
1140
1307
  animation: none;
1141
- background-position: 25% 50%;
1142
- }
1143
- /* Freeze the compositor layer at the same intended mid-gradient pose
1144
- (equivalent to background-position 50% 25% / 25% 50% on the container). */
1145
- .sidebar-gradient .sidebar-gradient-layer::before {
1146
- animation: none;
1147
- transform: translateY(-12.5%); /* -50% of the container = bg-position 50% 25% */
1148
- }
1149
- .sidebar-gradient-horizontal .sidebar-gradient-layer::before {
1150
- animation: none;
1151
- transform: translateX(-37.5%); /* -75% of the container = bg-position 25% 50% */
1152
1308
  }
1153
1309
  }
1154
1310
  .reduce-motion .sidebar-transition,
1155
1311
  .reduce-motion .content-shift {
1156
1312
  transition: none;
1157
1313
  }
1158
- .reduce-motion .sidebar-gradient {
1159
- animation: none;
1160
- background-position: 50% 25%;
1161
- }
1314
+ .reduce-motion .sidebar-gradient,
1162
1315
  .reduce-motion .sidebar-gradient-horizontal {
1163
1316
  animation: none;
1164
- background-position: 25% 50%;
1165
- }
1166
- /* Same freeze pose as the prefers-reduced-motion block above. */
1167
- .reduce-motion .sidebar-gradient .sidebar-gradient-layer::before {
1168
- animation: none;
1169
- transform: translateY(-12.5%);
1170
- }
1171
- .reduce-motion .sidebar-gradient-horizontal .sidebar-gradient-layer::before {
1172
- animation: none;
1173
- transform: translateX(-37.5%);
1174
1317
  }
1175
1318
 
1176
- /* Navigation active state — solid fallback (non-gradient surfaces, no backdrop support) */
1319
+ /* ============================================================
1320
+ * Navigation active state — ONE treatment, every surface (#231).
1321
+ *
1322
+ * This used to be five: a frosted backdrop-filter pill over the gradient,
1323
+ * a darker frosted variant for dark mode, and three fallback tiers
1324
+ * (@supports, prefers-reduced-transparency, prefers-contrast) that each
1325
+ * re-declared the solid fill. All five existed because the pill sat on an
1326
+ * animated gradient whose luminance changed over a 117-second loop, so its
1327
+ * contrast could not be computed — only sampled at runtime.
1328
+ *
1329
+ * The rail is now one flat lightness, so the surface
1330
+ * behind the pill is a known quantity and the pill can simply be opaque.
1331
+ * A solid board-violet fill carries a white label at 9.11:1 in both themes.
1332
+ *
1333
+ * NO MARKER. There was a 3px gold bar on the leading edge, and it existed for
1334
+ * one reason: the pill was dark violet with a white label, which put it at
1335
+ * 1.81:1 against the rail, under SC 1.4.11's 3:1 for a state. The bar was a
1336
+ * patch for a number, and a left accent rail is a stock device besides.
1337
+ *
1338
+ * Flipping the label to board ink lets the fill go bright — 5.66:1 against the
1339
+ * rail — so the state carries itself. 1.4.11 asks for 3:1; it does not ask for
1340
+ * a marker, and it does not care which pixels supply it. `font-semibold` on
1341
+ * the label remains as the non-colour second signal.
1342
+ * ============================================================ */
1177
1343
  .nav-active {
1178
1344
  background: var(--nav-active);
1179
1345
  color: var(--nav-active-foreground);
1180
1346
  }
1181
- /* The dark --nav-active fill is deliberately dark enough for AAA white labels
1182
- (#162), which necessarily brings it closer in luminance to the near-black
1183
- surfaces it sits on. A 1px light inset keeps the pill's OWN boundary
1184
- identifiable (SC 1.4.11 — "state" of a component) without reintroducing the
1185
- transparency these fallbacks exist to avoid. Light mode needs no help: its
1186
- pill already sits 7.6:1 off --sidebar. */
1187
- .dark .nav-active {
1188
- box-shadow: inset 0 0 0 1px oklch(1 0 0 / 0.22);
1189
- }
1347
+
1190
1348
  .nav-active-hover:hover {
1191
1349
  background: var(--nav-active-hover);
1192
1350
  }
1193
1351
 
1194
- /*
1195
- * Fiesta sidebar active pill: frosted glass over the animated gradient.
1196
- * backdrop-filter samples the colors behind the row so the highlight moves with the gradient.
1197
- * A minimum-opacity cool veil keeps white text WCAG 2.2 AA on the lightest gradient stops.
1198
- */
1199
- .sidebar-gradient .nav-active,
1200
- .sidebar-gradient-horizontal .nav-active {
1201
- background: oklch(0.26 0.09 285 / 0.62);
1202
- color: oklch(1 0 0);
1203
- box-shadow: inset 0 0 0 1px oklch(1 0 0 / 0.18);
1204
- backdrop-filter: blur(14px) saturate(1.35);
1205
- -webkit-backdrop-filter: blur(14px) saturate(1.35);
1352
+ /* The hover tint is the only chrome token left carrying alpha, so this is
1353
+ the one transparency branch that still has work to do. */
1354
+ /* A literal, not a color-mix. Mixing hue 315 into the hue-73 rail takes the
1355
+ shorter arc and lands around hue 47 a seventh hue, and a brown one, inside
1356
+ a palette whose whole rule is six. */
1357
+ @media (prefers-reduced-transparency: reduce) {
1358
+ .nav-active-hover:hover {
1359
+ background: oklch(0.34 0.02 73);
1360
+ }
1206
1361
  }
1207
1362
 
1208
- .dark .sidebar-gradient .nav-active,
1209
- .dark .sidebar-gradient-horizontal .nav-active {
1210
- background: oklch(0.22 0.08 285 / 0.48);
1211
- box-shadow: inset 0 0 0 1px oklch(1 0 0 / 0.14);
1212
- backdrop-filter: blur(16px) saturate(1.5) brightness(1.06);
1213
- -webkit-backdrop-filter: blur(16px) saturate(1.5) brightness(1.06);
1363
+ /* Users who ask for more contrast get the most legible path, not the least
1364
+ (#162) a heavier marker and a white hairline around the pill. */
1365
+ /* More contrast means MORE CONTRAST, not more chrome: the same pigment, lifted.
1366
+ Fill vs rail 8.12 -> 10.60:1, ink label 8.84 -> 11.54:1. No rim, no marker. */
1367
+ @media (prefers-contrast: more) {
1368
+ .nav-active {
1369
+ --nav-active: oklch(0.86 0.115 73);
1370
+ }
1371
+ .nav-active-hover:hover {
1372
+ background: oklch(1 0 0 / 24%);
1373
+ }
1214
1374
  }
1215
1375
 
1216
- .sidebar-gradient .nav-active-hover:hover,
1217
- .sidebar-gradient-horizontal .nav-active-hover:hover {
1218
- background: oklch(1 0 0 / 0.18);
1219
- backdrop-filter: blur(10px) saturate(1.15);
1220
- -webkit-backdrop-filter: blur(10px) saturate(1.15);
1376
+ /* ============================================================
1377
+ * FOCUS RING — two-tone, because --ring is now the tile.
1378
+ *
1379
+ * The old recipe was `focus-visible:ring-ring/50 focus-visible:ring-[3px]`.
1380
+ * Two problems. The alpha: composited in gamma space a 50% band measures
1381
+ * 2.04-2.19:1 in light and 2.63-2.76:1 in dark against the surfaces it lands
1382
+ * on — under SC 2.4.11's 3:1, on every control in the system, and that defect
1383
+ * predates this palette. And the pigment: --ring follows --primary, which is
1384
+ * now #f5a623 at 1.83:1 on a light page, so a solid orange band would not fix
1385
+ * it either.
1386
+ *
1387
+ * So the ring is bounded by board ink. The ink hairlines carry the boundary
1388
+ * (16:1 on light surfaces) and the orange band carries the identity, which is
1389
+ * the same division of labour the tile chip uses. In dark the outer hairline
1390
+ * merges into the page (1.11:1) and the orange band — 9.77:1 there — carries
1391
+ * the indicator directly, which is why the recipe is safe in both themes for
1392
+ * different reasons.
1393
+ * ============================================================ */
1394
+ .focus-ring {
1395
+ outline: none;
1221
1396
  }
1222
-
1223
- .dark .sidebar-gradient .nav-active-hover:hover,
1224
- .dark .sidebar-gradient-horizontal .nav-active-hover:hover {
1225
- background: oklch(1 0 0 / 0.12);
1226
- backdrop-filter: blur(12px) saturate(1.25);
1227
- -webkit-backdrop-filter: blur(12px) saturate(1.25);
1397
+ .focus-ring:focus-visible {
1398
+ outline: none;
1399
+ box-shadow:
1400
+ 0 0 0 1px var(--ring-edge),
1401
+ 0 0 0 3px var(--ring),
1402
+ 0 0 0 4px var(--ring-edge);
1228
1403
  }
1229
1404
 
1230
- @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
1231
- .sidebar-gradient .nav-active,
1232
- .sidebar-gradient-horizontal .nav-active {
1233
- background: var(--nav-active);
1234
- /* Keep the hairline (was `none`): the solid fill is only ~1.2-1.7:1 off
1235
- the gradient stops behind it in either theme, so the ring is what makes
1236
- the pill an identifiable shape (#162). */
1237
- box-shadow: inset 0 0 0 1px oklch(1 0 0 / 0.22);
1238
- backdrop-filter: none;
1239
- -webkit-backdrop-filter: none;
1405
+ /* ============================================================
1406
+ * BOARD BACKDROP — the one-shot settle (#231).
1407
+ *
1408
+ * Each row drops and settles once on mount, like a row of flaps landing, and
1409
+ * then STOPS. That is the whole performance contract: a first-run screen is
1410
+ * disproportionately likely to be on a phone, and an ambient loop there costs
1411
+ * battery for as long as the screen is open. The WebGL aurora this replaces
1412
+ * held a GL context and ran a shader every frame; this runs for ~700ms and
1413
+ * then the field is inert DOM.
1414
+ *
1415
+ * transform and opacity only, so it stays on the compositor: no layout, no
1416
+ * paint, no main-thread work per frame.
1417
+ * ============================================================ */
1418
+ @keyframes board-backdrop-settle {
1419
+ from {
1420
+ opacity: 0;
1421
+ transform: translateY(-8px) scaleY(0.72);
1240
1422
  }
1241
- .sidebar-gradient .nav-active-hover:hover,
1242
- .sidebar-gradient-horizontal .nav-active-hover:hover {
1243
- background: var(--nav-active-hover);
1244
- backdrop-filter: none;
1245
- -webkit-backdrop-filter: none;
1423
+ to {
1424
+ opacity: var(--board-backdrop-opacity, 0.28);
1425
+ transform: translateY(0) scaleY(1);
1246
1426
  }
1247
1427
  }
1248
1428
 
1249
- @media (prefers-reduced-transparency: reduce) {
1250
- .sidebar-gradient .nav-active,
1251
- .sidebar-gradient-horizontal .nav-active {
1252
- background: var(--nav-active);
1253
- /* Same reasoning as the @supports fallback above (#162). */
1254
- box-shadow: inset 0 0 0 1px oklch(1 0 0 / 0.22);
1255
- backdrop-filter: none;
1256
- -webkit-backdrop-filter: none;
1257
- }
1258
- .sidebar-gradient .nav-active-hover:hover,
1259
- .sidebar-gradient-horizontal .nav-active-hover:hover {
1260
- background: var(--nav-active-hover);
1261
- backdrop-filter: none;
1262
- -webkit-backdrop-filter: none;
1263
- }
1429
+ .board-backdrop-row {
1430
+ opacity: var(--board-backdrop-opacity, 0.28);
1431
+ animation: board-backdrop-settle 0.42s var(--motion-ease-standard) both;
1264
1432
  }
1265
1433
 
1266
- /* This branch is where the old fallback was most wrong: users who explicitly
1267
- asked for MORE contrast got 4.26:1 in dark, below the 4.5:1 the default
1268
- frosted path achieved. --nav-active now targets AAA (7.47:1) so this path
1269
- is deliberately the most legible one, not the least (#162). */
1270
- @media (prefers-contrast: more) {
1271
- .sidebar-gradient .nav-active,
1272
- .sidebar-gradient-horizontal .nav-active {
1273
- background: var(--nav-active);
1274
- color: var(--nav-active-foreground);
1275
- box-shadow: inset 0 0 0 2px oklch(1 0 0 / 0.35);
1276
- backdrop-filter: none;
1277
- -webkit-backdrop-filter: none;
1278
- }
1279
- .sidebar-gradient .nav-active-hover:hover,
1280
- .sidebar-gradient-horizontal .nav-active-hover:hover {
1281
- background: var(--nav-active-hover);
1282
- backdrop-filter: none;
1283
- -webkit-backdrop-filter: none;
1434
+ /* Already settled, nothing declared. Not `animation: none` on top of a rule
1435
+ that also sets the from-state the row must not be left at opacity 0. */
1436
+ @media (prefers-reduced-motion: reduce) {
1437
+ .board-backdrop-row {
1438
+ animation: none;
1439
+ opacity: var(--board-backdrop-opacity, 0.28);
1440
+ transform: none;
1284
1441
  }
1285
1442
  }
1443
+ .reduce-motion .board-backdrop-row {
1444
+ animation: none;
1445
+ opacity: var(--board-backdrop-opacity, 0.28);
1446
+ transform: none;
1447
+ }
1286
1448
 
1287
1449
  /* Card fade-in animation (inspired by reactbits CardNav) */
1288
1450
  @keyframes card-fade-in {
@@ -1303,8 +1465,11 @@ html[data-board-switch="backward"]::view-transition-new(board-page) {
1303
1465
 
1304
1466
  /* Type scale: ratio 1.25; body 1rem (16px). Tailwind text-xs/sm/base/lg/xl/2xl/3xl/4xl align. */
1305
1467
  /* Page typography - consistent titles and descriptions */
1468
+ /* The page's H1 has to outrank CardTitle, which is text-base/600. At
1469
+ text-lg/500 it did not — a section heading inside a sibling card read as
1470
+ more important than the name of the page (#231). Now two full steps up. */
1306
1471
  .page-title {
1307
- @apply text-lg sm:text-xl font-medium tracking-tight;
1472
+ @apply text-2xl sm:text-3xl font-semibold tracking-tight;
1308
1473
  text-wrap: balance;
1309
1474
  }
1310
1475
  .page-description {