@godxjp/ui 28.13.0 → 29.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 (159) hide show
  1. package/agent/START-HERE.md +29 -10
  2. package/agent/components/Anchor.json +6 -1
  3. package/agent/components/AppShell.json +1 -1
  4. package/agent/components/AreaChart.json +19 -1
  5. package/agent/components/BarChart.json +11 -1
  6. package/agent/components/Cascader.json +1 -1
  7. package/agent/components/CompactBarTrend.json +1 -1
  8. package/agent/components/DataState.json +1 -1
  9. package/agent/components/DataTable.json +4 -4
  10. package/agent/components/FormField.json +1 -1
  11. package/agent/components/InfiniteQueryState.json +1 -1
  12. package/agent/components/Input.json +1 -1
  13. package/agent/components/LineChart.json +20 -2
  14. package/agent/components/ListRow.json +1 -1
  15. package/agent/components/Masonry.json +1 -1
  16. package/agent/components/MasterDetail.json +1 -1
  17. package/agent/components/PasswordStrength.json +1 -1
  18. package/agent/components/PermissionMatrix.json +1 -1
  19. package/agent/components/Select.json +1 -0
  20. package/agent/components/Sidebar.json +1 -1
  21. package/agent/components/Table.json +8 -3
  22. package/agent/components/ThemeScope.json +49 -0
  23. package/agent/components/Topbar.json +1 -0
  24. package/agent/components/TopbarItem.json +2 -1
  25. package/agent/components/Transfer.json +1 -1
  26. package/agent/components/TreeSelect.json +1 -1
  27. package/agent/components/UploadCropDialog.json +1 -1
  28. package/agent/components/formatDate.json +1 -1
  29. package/agent/components-index.json +5 -0
  30. package/agent/components.json +138 -30
  31. package/agent/index.json +19 -9
  32. package/agent/llms.txt +10 -10
  33. package/agent/patterns/tenant-brand-color.json +28 -0
  34. package/agent/patterns-index.json +27 -0
  35. package/agent/patterns.json +28 -0
  36. package/agent/rules.json +15 -0
  37. package/agent/tokens.json +4965 -970
  38. package/dist/app/index.d.ts +3 -0
  39. package/dist/app/index.js +3 -0
  40. package/dist/app/tenant-theme.d.ts +80 -0
  41. package/dist/app/tenant-theme.js +154 -0
  42. package/dist/app/theme-axes.d.ts +14 -1
  43. package/dist/app/theme-axes.js +24 -31
  44. package/dist/components/charts/chart-cartesian.d.ts +5 -1
  45. package/dist/components/charts/chart-cartesian.js +15 -8
  46. package/dist/components/data-display/badge.d.ts +1 -1
  47. package/dist/components/data-display/badge.js +20 -2
  48. package/dist/components/data-display/carousel.js +4 -4
  49. package/dist/components/data-display/data-table.js +13 -2
  50. package/dist/components/data-display/permission-matrix.js +1 -1
  51. package/dist/components/data-display/table.d.ts +11 -2
  52. package/dist/components/data-display/table.js +18 -2
  53. package/dist/components/data-entry/control-appearance.d.ts +12 -6
  54. package/dist/components/data-entry/control-appearance.js +1 -1
  55. package/dist/components/data-entry/select.js +4 -3
  56. package/dist/components/feedback/dialog.js +6 -3
  57. package/dist/components/feedback/overlay-header-tone.d.ts +7 -0
  58. package/dist/components/feedback/overlay-header-tone.js +4 -4
  59. package/dist/components/feedback/sheet.d.ts +1 -1
  60. package/dist/components/feedback/sheet.js +6 -9
  61. package/dist/components/feedback/sonner.js +16 -3
  62. package/dist/components/general/button.js +22 -5
  63. package/dist/components/layout/affix.js +15 -1
  64. package/dist/components/layout/sidebar.js +7 -1
  65. package/dist/components/navigation/anchor.d.ts +1 -1
  66. package/dist/components/navigation/anchor.js +5 -4
  67. package/dist/components/navigation/app-setting-picker.js +1 -1
  68. package/dist/components/navigation/pagination.js +1 -1
  69. package/dist/components/navigation/tabs.js +15 -2
  70. package/dist/components/query/infinite-query-state.d.ts +22 -6
  71. package/dist/lib/control-styles.d.ts +31 -11
  72. package/dist/lib/control-styles.js +6 -6
  73. package/dist/lib/overlay-portal.d.ts +20 -0
  74. package/dist/lib/overlay-portal.js +93 -0
  75. package/dist/props/components/app.prop.d.ts +12 -0
  76. package/dist/props/components/charts.prop.d.ts +30 -0
  77. package/dist/props/components/index.d.ts +1 -1
  78. package/dist/props/components/navigation.prop.d.ts +21 -2
  79. package/dist/props/components/query.prop.d.ts +36 -2
  80. package/dist/props/registry.d.ts +46 -1
  81. package/dist/props/registry.js +38 -3
  82. package/dist/styles/alert-layout.css +34 -14
  83. package/dist/styles/badge-layout.css +10 -6
  84. package/dist/styles/base.css +14 -5
  85. package/dist/styles/card-layout.css +19 -8
  86. package/dist/styles/chart-layout.css +22 -3
  87. package/dist/styles/control.css +165 -59
  88. package/dist/styles/data-display-layout.css +129 -36
  89. package/dist/styles/data-entry-layout.css +23 -87
  90. package/dist/styles/dialog-layout.css +49 -19
  91. package/dist/styles/float-button-layout.css +5 -5
  92. package/dist/styles/focus-ring.css +9 -5
  93. package/dist/styles/layout.css +42 -15
  94. package/dist/styles/logo-layout.css +1 -1
  95. package/dist/styles/motion.css +1 -1
  96. package/dist/styles/navigation-layout.css +90 -29
  97. package/dist/styles/shell-layout.css +63 -40
  98. package/dist/styles/table-layout.css +56 -17
  99. package/dist/styles/text-layout.css +13 -4
  100. package/dist/styles/toggle.css +8 -2
  101. package/dist/tokens/components/actions.css +1 -1
  102. package/dist/tokens/components/attachments.css +4 -4
  103. package/dist/tokens/components/badge.css +4 -4
  104. package/dist/tokens/components/callout.css +1 -1
  105. package/dist/tokens/components/card.css +9 -4
  106. package/dist/tokens/components/chart.css +10 -1
  107. package/dist/tokens/components/chat-bubble.css +1 -1
  108. package/dist/tokens/components/control.css +28 -10
  109. package/dist/tokens/components/conversations.css +2 -1
  110. package/dist/tokens/components/data-display.css +12 -7
  111. package/dist/tokens/components/descriptions.css +1 -1
  112. package/dist/tokens/components/draggable-panel.css +1 -1
  113. package/dist/tokens/components/feedback.css +28 -8
  114. package/dist/tokens/components/float-button.css +1 -1
  115. package/dist/tokens/components/legal-document.css +1 -1
  116. package/dist/tokens/components/logo.css +1 -1
  117. package/dist/tokens/components/mega-menu.css +5 -3
  118. package/dist/tokens/components/navigation.css +21 -7
  119. package/dist/tokens/components/segmented.css +8 -3
  120. package/dist/tokens/components/shell.css +19 -5
  121. package/dist/tokens/components/table.css +9 -1
  122. package/dist/tokens/components/thought-chain.css +1 -1
  123. package/dist/tokens/components/toggle.css +2 -0
  124. package/dist/tokens/components/tree.css +3 -1
  125. package/dist/tokens/components/upload.css +6 -6
  126. package/dist/tokens/components/welcome.css +1 -1
  127. package/dist/tokens/foundation.css +28 -1
  128. package/docs/COMPOSITION-VS-COMPONENT.md +31 -0
  129. package/docs/CUSTOMER-THEMING.md +637 -1
  130. package/docs/FRAME-COVERAGE-REPORT.md +3 -2
  131. package/docs/GLASSMORPHISM-STANDARD.md +196 -0
  132. package/docs/THEME-API-COVERAGE.md +538 -0
  133. package/docs/TOKEN-RESOLUTION.md +195 -0
  134. package/docs/TOKENS.md +63 -24
  135. package/docs/asset-modules.d.ts +7 -0
  136. package/docs/data-display/charts.tsx +80 -0
  137. package/docs/data-display/data-table/index.tsx +30 -0
  138. package/docs/data-display/popover.tsx +1 -1
  139. package/docs/data-display/table.tsx +52 -0
  140. package/docs/feedback/sheet.tsx +10 -10
  141. package/docs/foundation/density.tsx +4 -4
  142. package/docs/i18n/messages/en.json +502 -0
  143. package/docs/i18n/messages/ja.json +502 -0
  144. package/docs/i18n/messages/vi.json +502 -0
  145. package/docs/layout/account-chip.tsx +2 -2
  146. package/docs/layout/responsive-grid.tsx +1 -1
  147. package/docs/navigation/toolbar.tsx +20 -12
  148. package/docs/providers/theme-scope.tsx +186 -0
  149. package/docs/showcase/caimono-price-comparison.tsx +911 -0
  150. package/docs/showcase/case4-login.tsx +2 -2
  151. package/docs/showcase/permission-matrix.tsx +13 -5
  152. package/docs/showcase/table-pagination.tsx +2 -1
  153. package/docs/showcase/tenant-brand-color.tsx +338 -0
  154. package/docs/showcase/theme-lab.tsx +2125 -0
  155. package/docs/themes/flat.css +462 -0
  156. package/docs/themes/glassmorphism.css +958 -0
  157. package/docs/themes/index.ts +200 -0
  158. package/package.json +4 -3
  159. package/scripts/explain-token.mjs +382 -0
@@ -312,7 +312,9 @@ A `data-*` attribute the package never writes is a selector you can hold a style
312
312
  }
313
313
  ```
314
314
 
315
- Use `--text-error`, not `--destructive`: the fill tier is tuned for a white label on top of it and measures 2.95:1 as ink on the dark card (gh#610). The whole family is `--prose-link-color` (default `hsl(var(--primary))`, resolved at the anchor so a scoped re-tint reaches it) and `--prose-link-decoration-line` (default `underline`). Set the first on `[data-tenant]` to re-tint every wiki link at once.
315
+ Use `--text-error`, not `--destructive`: the fill tier is tuned for a white label on top of it and measures 2.95:1 as ink on the dark card (gh#610). The whole family is `--prose-link-color` and `--prose-link-decoration-line` (default `underline`). Set the first on `[data-tenant]` to re-tint every wiki link at once.
316
+
317
+ `--prose-link-color`'s default is now `hsl(var(--text-link, var(--primary)))`, not `hsl(var(--primary))` (gh#887). The brand FILL was being used as INK here, and a fill has no contrast guarantee as ink — measured across five seeds, prose links ran 1.25:1 to 3.64:1 on the surfaces they land on. `--text-link` is the INK role, which `tenantTheme` clamps to 4.5:1, and `--primary` remains the last fallback so a consumer who set neither sees no change. Set `--text-link` once and every brand-as-ink surface follows; set `--prose-link-color` only to make prose links differ from the rest.
316
318
 
317
319
  There is **no prop** naming the attribute, deliberately: the attribute IS the API, the same way `data-expanded-row` and `data-axe-open` are. A prop would make the package own a name only your renderer knows, and would let exactly one state be marked.
318
320
 
@@ -350,6 +352,7 @@ The interaction states are **derived from the `--primary` in scope, at the eleme
350
352
  | `--sidebar-item-active-foreground` | **yes** — defaults to the live `--primary-active` | — |
351
353
  | `--focus-outline-color` | **yes, through `--ring`** — every keyboard-focus outline (gh#687) | `var(--focus-ring-color, var(--ring))`, read at the focused element — so it follows the `--ring` you set in the scope |
352
354
  | Radio button bar selected (`--choice-button-*`), Slider active dot, BackTop progress, `.ui-brand-glow` | **yes** (gh#687) | `var(--primary)` / `var(--primary-foreground)` at the call site |
355
+ | `--text-link`, `--text-brand`, `--text-primary` | **yes, and they are the BRAND AS INK** — Button `variant="link"`, prose and Anchor links, the active sidebar/NavList label, MegaMenu's current trigger, Timeline's current title, tinted Avatar, the checked radio dot, `Text tone="primary"` and 20 more (gh#887) | `tenantTheme` returns all three as literals, each walked away from the surface until it clears 4.5:1. They are NOT a step on the `--primary` ramp: a fill is tuned for a label ON it, and the same colour used as ink on a page surface has no guarantee at all — measured at 1.24:1 on the lightest seed before this. |
353
356
  | Topbar item / AppLauncher tile / Segmented / filled-control hover | **follow `--accent`** (gh#687) | `var(--accent)` / `var(--accent-foreground)` at the call site |
354
357
  | `--primary-foreground` | **no — set it** | the label on a filled primary; you choose it for your seed |
355
358
  | `--ring` | **only on the element that declares `--primary`** — set it in a nested scope | `var(--primary)` on `:root` / `.dark`. `--ring` is a public role read as `hsl(var(--ring))` in consumer CSS, so it cannot become a live default; a scope below `<html>` inherits the root's |
@@ -379,3 +382,636 @@ Two more CSS-inheritance caveats for the **scoped** case (a single `:root` brand
379
382
 
380
383
  - **Radius & shadow-tint don't cascade from a scoped anchor.** `--radius-{xs…2xl}`, `--card-radius`, `--control-radius` and the `--shadow-{xs…2xl}` ramp are computed at their declaring element, so a scoped `--radius` / `--shadow-color` override won't reach them. For a scoped re-theme, re-declare the derived tokens you need (e.g. `--card-radius: var(--radius)`, or set `--card-shadow` to a literal value).
381
384
  ````
385
+
386
+ ---
387
+
388
+ ## A CUSTOMER's colour, at runtime — `tenantTheme()` (gh#861, gh#868)
389
+
390
+ Everything above is a colour **you** author, in a stylesheet. This section is the other case: a hex
391
+ your customer picked in a settings screen, which reaches the page as data and must paint one region
392
+ of it — an app launcher, a partner portal, a tenant switcher showing two tenants at once.
393
+
394
+ There is no `<TenantTheme>` component, and `docs/COMPOSITION-VS-COMPONENT.md` §3.2 records why (it
395
+ fails C3, C4 and C6: a `<div>` with a `style`, a screen-shaped API, and no text or role for the
396
+ international contract to apply to). What ships instead is the part two consumer repos each
397
+ hand-rolled — the arithmetic:
398
+
399
+ ```tsx
400
+ import { tenantTheme } from "@godxjp/ui/app";
401
+
402
+ const brand = tenantTheme(tenant.primary_color); // "#0071bd", 3- or 6-digit
403
+
404
+ <div data-tenant={tenant.slug} style={brand.vars}>
405
+ <Topbar … />
406
+ </div>;
407
+ ```
408
+
409
+ `brand.vars` is exactly five declarations, and each one is there for a stated reason:
410
+
411
+ | declaration | why |
412
+ | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
413
+ | `--primary` | the seed, as an `H S% L%` triplet — `hexToHsl()` is the same conversion, exported separately |
414
+ | `--primary-foreground` | **does not follow the seed** (see the table above): the label is chosen by contrast, here |
415
+ | `--ring` | `derived.css` binds `--ring: var(--primary)` at `:root`, where a `var()` substitutes ONCE — a scope below `<html>` inherits the root's ring unless it restates it (the freeze rule) |
416
+ | `--primary-hover` | a LITERAL, not `initial` — see "no silent half-application" below |
417
+ | `--primary-active` | the same |
418
+
419
+ It never throws and never returns null. An unusable hex yields **empty `vars`**, so the region
420
+ falls back to the app's own theme instead of breaking the page — the same contract `ColorPicker`
421
+ already has.
422
+
423
+ ### The contrast rule, stated
424
+
425
+ **WCAG 2.2 SC 1.4.3 Contrast (Minimum), normal text, 4.5:1.** Relative luminance is WCAG's own
426
+ definition (`c′ = c ≤ 0.03928 ? c/12.92 : ((c+0.055)/1.055)^2.4`, then
427
+ `L = 0.2126·R′ + 0.7152·G′ + 0.0722·B′`), and the ratio is `(L_lighter + 0.05) / (L_darker + 0.05)`.
428
+
429
+ The label is whichever of `#ffffff` / `#000000` scores higher, i.e. the pivot at **L = 0.179**. That
430
+ is not "usually fine": at the pivot BOTH candidates measure 4.58:1, and away from it the winner only
431
+ rises — so the chosen label clears AA for **every** sRGB seed, by construction. The claim is swept
432
+ over the whole cube in `src/app/__tests__/tenant-theme.test.ts`; the worst seed in it measures
433
+ 4.58:1.
434
+
435
+ Measured, for the two shapes where a naive rule fails:
436
+
437
+ | customer hex | label chosen | ratio | what "always white" would have given |
438
+ | ------------ | ------------ | ------- | ------------------------------------ |
439
+ | `#0071bd` | `#ffffff` | 5.13:1 | 5.13:1 |
440
+ | `#7a00ff` | `#ffffff` | 6.42:1 | 6.42:1 |
441
+ | `#FFD400` | `#000000` | 14.67:1 | **1.29:1** |
442
+ | `#0A1F44` | `#ffffff` | 16.25:1 | 16.25:1 |
443
+ | `#E30613` | `#ffffff` | 4.88:1 | 4.88:1 |
444
+ | `#00A86B` | `#000000` | 6.81:1 | **2.34:1** |
445
+ | `#767676` | `#000000` | 4.62:1 | 4.54:1 |
446
+
447
+ **If you already computed the pair server-side, pass it and it is used as given** — it is not
448
+ silently second-guessed — but the result reports what it actually achieves, and a dev build warns
449
+ under 4.5:1:
450
+
451
+ ```ts
452
+ const brand = tenantTheme(hex, { foreground: tenant.theme_tokens.foreground });
453
+ if (!brand.meetsAA) {
454
+ // brand.contrast is the number. The API SAYS no rather than quietly returning white.
455
+ }
456
+ ```
457
+
458
+ `contrastRatio(a, b)` and `relativeLuminance(hex)` are exported for the same reason: so the
459
+ arithmetic can be checked rather than trusted.
460
+
461
+ ### No silent half-application
462
+
463
+ The derived family (`--primary-hover` & co.) resolves at the painting element with CSS relative
464
+ colour. An engine without it (Chrome/Edge 111–118) gets the `:root` literals of the PACKAGE seed
465
+ instead — so a tenant colour set as a bare `--primary` applies at REST and not on HOVER, with no
466
+ error and no warning. That is the shape a consumer repo was guarding by hand with
467
+ `CSS.supports("color", "hsl(from red h s l)")`, and it is worse than not applying at all.
468
+
469
+ `tenantTheme` therefore computes the two steps in JS, from the same channel formulas, and emits
470
+ them as literal triplets on the same element that carries the seed. **No feature detection, every
471
+ engine, both states.** It is not a return to the gh#648 defect, because that was a literal on
472
+ `:root` — ABOVE every scope that re-seeds. These are written by the same call that writes
473
+ `--primary`, so a nested region that re-seeds re-emits its own pair and nothing freezes.
474
+ `src/app/__tests__/tenant-theme.test.ts` holds the literals EQUAL to the CSS formula, evaluated the
475
+ way the browser would, over a sweep of seeds and both label polarities.
476
+
477
+ `--primary-border` and `--control-outline` are left to derive: their channels are per-THEME rather
478
+ than per-label, so JS cannot compute them without being told the theme, and they are a hairline and
479
+ an 11%-alpha halo rather than a state a reader tracks.
480
+
481
+ ### The brand as INK, and the surface it has to clear (gh#887)
482
+
483
+ The pair above is a FILL and the label on it. It says nothing about the brand used as INK on a page
484
+ surface — which a sidebar active item, a NavList item, a MegaMenu trigger, an Anchor link, a
485
+ `Text link` and `Button variant="link"` all are. So a brand could pass the library's own contrast
486
+ computation and still ship a navigation nobody can read: measured on `/showcase/theme-lab?theme=base`,
487
+ `#FFD400` gave **1.18–2.06:1** and `#E2564A` **2.16–3.64:1**, at rest and hovered.
488
+
489
+ The three ink roles already existed. What they lacked was a FLOOR: each was a plain lightness step
490
+ off the seed (`l - 8.4` for the link, `l - 15.5` for the pressed ink), and a step is not a contrast
491
+ guarantee — `#FFD400` minus 8.4 lightness is `#d4b000`, 2.06:1 on the page. `tenantTheme` now emits
492
+ all three as literals, each walked AWAY from the surface until the painted pixel clears 4.5:1 and no
493
+ further:
494
+
495
+ ```tsx
496
+ tenantTheme("#FFD400").vars["--text-brand"]; // "49.88 100% 24.4%" — 4.56:1, not 1.73:1
497
+ tenantTheme("#7C3AED").vars["--text-brand"]; // "262.12 83.26% 49.44%" — the ramp step, untouched
498
+ ```
499
+
500
+ **A seed whose ramp step already clears is emitted unchanged**, so the package's own violet is
501
+ byte-identical to what it shipped before, and so are the middle seeds. `tenant-theme.test.ts` sweeps
502
+ the whole sRGB cube: the worst case is 4.5:1 on either surface, for all three roles.
503
+
504
+ **`surface` is the darkest surface the ink lands on, not the page canvas.** It defaults to the
505
+ package light `--accent` `#ebe9e5` — the hover fill under a nav row — because an ink clamped to
506
+ 4.5:1 on `--background` `#fdfdfc` measures 3.78:1 the moment the row is hovered, and the hovered
507
+ state is where the lab measured 1.18:1. A region inside the DARK theme must say so, or its ink is
508
+ walked the wrong way:
509
+
510
+ ```tsx
511
+ tenantTheme(tenant.primary_color, { surface: "#3c3a34" }); // --accent in the dark theme
512
+ tenantTheme(tenant.primary_color, { surface: myThemedSidebarFill });
513
+ ```
514
+
515
+ **The guarantee travels with `tenantTheme()`, not with the token.** A consumer who sets `--primary`
516
+ by hand in a stylesheet gets the CSS ramp step and no floor, because WCAG relative luminance cannot
517
+ be computed in CSS — a lightness clamp is hue-blind, and a yellow and a blue at the same lightness
518
+ are four stops apart in luminance. If you re-seed in raw CSS, set the three ink roles yourself and
519
+ check them with `contrastRatio()`.
520
+
521
+ Worked screen: `docs/showcase/tenant-brand-color.tsx` (`/showcase/tenant-brand-color`) — scope
522
+ proof inside vs outside, the contrast table above, and three run cases (an invalid hex, a
523
+ server-supplied pair below AA, and the brand-ink boundary).
524
+
525
+ ### A scoped theme needs `ThemeScope` to reach the overlays (gh#877)
526
+
527
+ Every overlay in this library portals to `document.body`, so custom-property inheritance stops at
528
+ the portal boundary: a themed region themes its own subtree and **nothing it opens**. Measured with
529
+ the trigger inside the scope, the region read `--primary 204 100% 37%` and the Dialog it opened read
530
+ `268.7 100% 50%`, the package default — the button right, the panel it opens wrong.
531
+
532
+ Wrap the region in `ThemeScope` (`@godxjp/ui/app`) and the Dialog, Select listbox, Popover,
533
+ DropdownMenu, Tooltip, Sheet and Toast all follow it:
534
+
535
+ ```tsx
536
+ <ThemeScope data-tenant={tenant.slug} style={tenantTheme(tenant.primary_color).vars}>
537
+ {region}
538
+ </ThemeScope>
539
+ ```
540
+
541
+ It reads the COMPUTED tokens at its own element, so it does not care whether the theme came from
542
+ `tenantTheme(...).vars`, from `className="dark"`, or from a `[data-tenant]` rule in your own
543
+ stylesheet — the path this page recommends. `docs/providers/theme-scope.tsx`
544
+ (`/isolate/providers-theme-scope`) is the worked screen.
545
+
546
+ ### `applyPrimaryColor` vs `tenantTheme`
547
+
548
+ Both land on the same arithmetic. Use `tenantTheme` when you are painting a region declaratively —
549
+ no effect, no unmount restore, no SSR flash. Use `applyPrimaryColor(el, hex)` when you are
550
+ RE-SEEDING a tree that is already themed (a project switcher, a service theme): it additionally
551
+ resets `--primary-border` / `--control-outline` / the brand ink roles to `initial`, so a literal an
552
+ ancestor pinned for the previous brand cannot outrank the new seed, and it returns a cleanup.
553
+
554
+ One consequence of that reset, stated rather than discovered: because `applyPrimaryColor` writes
555
+ `--text-link` / `--text-brand` / `--text-primary` back to `initial` AFTER spreading
556
+ `tenantTheme(...).vars`, a re-seeded tree gets the CSS ramp step and **not** the 4.5:1 ink floor the
557
+ section above describes. On a pale brand that is the gh#887 defect again. Until that reset is
558
+ reconciled with the floor, prefer `tenantTheme` + `ThemeScope` for a pale seed, or re-apply the
559
+ three ink declarations yourself after the call.
560
+
561
+ ---
562
+
563
+ ## Building a COMPLEX theme — the eight things that cost the most
564
+
565
+ Everything above changes a **colour**. This section is for a theme that changes the **material** of
566
+ every surface — glass, a dark console, a high-contrast skin, anything where the page is no longer
567
+ "the default with a different hue".
568
+
569
+ It was written by building one (`docs/themes/glassmorphism.css`, `/showcase/glassmorphism`) from the
570
+ public token API and nothing else, as a deliberate stress test. It worked. But every failure along
571
+ the way was one a consumer would hit with no way to know, and the expensive ones **fail silently**:
572
+ nothing errors, nothing warns, and the surface simply keeps the colour it had. Where the cause is a
573
+ gap on our side rather than a rule you should have guessed, the section says so.
574
+
575
+ Do them in this order. Each one makes the next one decidable:
576
+
577
+ | # | decide | why it is first |
578
+ | --- | ---------------------------------------------------------------------- | ------------------------------------------------------------- |
579
+ | 1 | which **value form** each knob takes | a knob set in the wrong form paints nothing, and says nothing |
580
+ | 2 | the **polarity** of every surface — light panes or dark | it determines the ink ramp, and one ramp cannot serve both |
581
+ | 3 | `--background`, the canvas under the whole shell | four visible defects at once if you skip it |
582
+ | 4 | the **ink ramp**, measured against the worst ground | |
583
+ | 5 | the **resting** fill of every surface | |
584
+ | 6 | the **interaction states** — 95 tokens, and resting is not one of them | what you ship broken |
585
+ | 7 | the **overlay opacity tiers** — a dialog is not a card | |
586
+ | 8 | your own `@supports` / `prefers-reduced-transparency` fallbacks | the library cannot write them for you |
587
+
588
+ Then measure. On composited pixels, never declared colours.
589
+
590
+ ### 1 · There are TWO token value forms, and the name does not tell you which
591
+
592
+ ```css
593
+ HSL COMPONENTS --card: 60 33% 99% read at the call site as hsl(var(--card))
594
+ COMPLETE COLOUR --card-tint: hsl(var(--primary) / 4%) read as var(--card-tint)
595
+ ```
596
+
597
+ A components token holds three (or four) bare numbers with no function around them. A complete
598
+ token holds a finished CSS colour — `hsl(…)`, `rgb(…)`, `oklch(…)`, `#hex`, `color-mix(…)`.
599
+
600
+ **The name is not a signal, and neither is the tier.** Five knobs that all paint the hover or
601
+ active fill of a navigable row, all defaulting to the same `--accent` role, in three components:
602
+
603
+ | knob | the declaration that reads it | form |
604
+ | ----------------------------------- | --------------------------------------------------------------------------------------------------------------- | ------------------- |
605
+ | `--menu-item-hover-background` | `src/styles/navigation-layout.css:828` — `background: var(--menu-item-hover-background, hsl(var(--accent)))` | **complete colour** |
606
+ | `--tree-node-hover-background` | `src/styles/data-display-layout.css:2525` — `background: var(--tree-node-hover-background, hsl(var(--accent)))` | **complete colour** |
607
+ | `--table-row-hover-background` | `src/styles/table-layout.css:591` — `var(--table-row-hover-background, hsl(var(--accent) / 0.7))` | **complete colour** |
608
+ | `--segmented-item-hover-background` | `src/styles/control.css:1559` — `background: hsl(var(--segmented-item-hover-background, var(--accent)))` | **HSL components** |
609
+ | `--topbar-item-hover-background` | `src/styles/shell-layout.css:2852` — `background: hsl(var(--topbar-item-hover-background, var(--accent)))` | **HSL components** |
610
+
611
+ And the two halves of the same `.ui-card` rule disagree with each other:
612
+ `--card-background` is components (`src/styles/card-layout.css:62`, `hsl(var(--card-background,
613
+ var(--card)))`) while `--card-tint` three lines above it is a complete colour.
614
+
615
+ **How to tell, today: read the call site.** The knob's own declaration in
616
+ `dist/tokens/**` is `initial` and tells you nothing; the read site tells you everything.
617
+
618
+ ```sh
619
+ # in your app, after installing the package
620
+ grep -rho 'hsl(var(--tabs-list-background\|var(--tabs-list-background' node_modules/@godxjp/ui/dist
621
+ # var(--tabs-list-background → COMPLETE COLOUR
622
+ # hsl(var(--tabs-list-background → HSL COMPONENTS
623
+ ```
624
+
625
+ Grep `dist` whole, not `dist/styles` — a few knobs are read from a component's JS
626
+ (`--toast-background` is read in `sonner.tsx` as `var(--toast-background, hsl(var(--popover)))`,
627
+ because sonner renders the toast body itself).
628
+
629
+ Measured on this repo's sources: **346 published tokens hold a colour. 96 take HSL components, 249
630
+ take a complete colour, 1 (`--brand-foreground`) is published for your CSS and read by none of
631
+ ours.** There is no rule that predicts the split. The older semantic roles — `--primary`,
632
+ `--background`, `--foreground`, `--card`, `--muted`, `--accent`, `--border`, `--input`, `--ring`,
633
+ the `--text-*` ramp, the four status pairs — are components. Most component-tier knobs added since
634
+ are complete colours. "Most" is not a rule you can theme against.
635
+
636
+ #### The failure is silent, and it does NOT fall back
637
+
638
+ Set a knob in the wrong form and the substituted declaration is **invalid at computed-value time**.
639
+ The property takes **its own initial value** — and _not_ the call-site fallback, which is the part
640
+ that makes this read like a knob that does not work. Measured in Chromium, both forms, both
641
+ mistakes:
642
+
643
+ | the call site | what you set | computed `background-color` |
644
+ | ---------------------------- | ---------------- | --------------------------------- |
645
+ | `var(--k, hsl(120 50% 50%))` | nothing | `rgb(64, 191, 64)` — the fallback |
646
+ | `var(--k, hsl(120 50% 50%))` | `rgb(255 0 0)` ✓ | `rgb(255, 0, 0)` |
647
+ | `var(--k, hsl(120 50% 50%))` | `0 100% 50%` ✗ | **`rgba(0, 0, 0, 0)`** |
648
+ | `hsl(var(--k, 120 50% 50%))` | nothing | `rgb(64, 191, 64)` — the fallback |
649
+ | `hsl(var(--k, 120 50% 50%))` | `0 100% 50%` ✓ | `rgb(255, 0, 0)` |
650
+ | `hsl(var(--k, 120 50% 50%))` | `rgb(255 0 0)` ✗ | **`rgba(0, 0, 0, 0)`** |
651
+
652
+ A transparent background shows whatever is painted behind it, so the surface looks like it kept its
653
+ old colour. A table header set in the wrong form measured **1.03:1** against light ink — because it
654
+ was showing the card beneath it, not the fill that was written for it.
655
+
656
+ **The tell, when you are debugging:** in DevTools the custom property is set to exactly what you
657
+ wrote, and the painted property is `rgba(0, 0, 0, 0)`. If both of those are true, it is the form.
658
+
659
+ #### The sub-trap: an alpha baked into a components role
660
+
661
+ A components role may legally carry an alpha — `--card: 0 0% 100% / 42%` is how a glass theme makes
662
+ every card translucent in one line, and it works. But **83 declarations across 15 roles read a role
663
+ and apply their own alpha** (`hsl(var(--accent) / 0.7)`, `hsl(var(--primary) / 0.12)`, …). A role
664
+ that already carries one produces a second `/` in those, which is a parse error. Measured:
665
+
666
+ | `--role` | read as `hsl(var(--role))` | read as `hsl(var(--role) / 0.9)` |
667
+ | ----------------- | --------------------------- | -------------------------------- |
668
+ | `0 0% 100%` | `rgb(255, 255, 255)` | `rgba(255, 255, 255, 0.9)` |
669
+ | `0 0% 100% / 42%` | `rgba(255, 255, 255, 0.42)` | **`rgba(0, 0, 0, 0)`** |
670
+
671
+ The 15 roles with at least one such reader, worst first: `--primary` (18 sites), `--muted` (15),
672
+ `--destructive` (8), `--warning` (6), `--success` (5), `--info` (5), `--accent` (5), `--foreground`
673
+ (5), `--muted-foreground` (4), `--table-row-tone-color` (4), `--background` (3),
674
+ `--destructive-foreground` (2), `--secondary` (1), `--accent-foreground` (1), `--ring` (1).
675
+ `--card` and `--popover` have none, which is exactly why baking an alpha into those two is the
676
+ glass theme's main move.
677
+
678
+ #### This is our defect, not your oversight
679
+
680
+ `get_tokens`, `agent/tokens.json` and `mcp/src/data/component-tokens.generated.ts` all report a
681
+ knob as `value: "initial"` plus prose. **3 of the 346 colour tokens say which value form they
682
+ take** (`--background`, `--focus-ring-color`, `--rating-star-filled-color`, each by accident of
683
+ someone writing it into a source comment). `scripts/explain-token.mjs` prints every read site of a
684
+ token but not the text of the read, and it is not in the package's `files` list, so a consumer does
685
+ not have it. Until the catalog carries the form as a field, the grep above is the answer, and it
686
+ should not have to be. Cardinal rule **48** states the rule; the gap is what the rule is for.
687
+
688
+ ### 2 · Ninety-five interaction-state tokens exist, and resting state is not one of them
689
+
690
+ ```sh
691
+ node -e "const t=require('./node_modules/@godxjp/ui/agent/tokens.json'); \
692
+ console.log(t.filter(x => /-(hover|active|selected|checked|pressed|focus)(-|\$)/.test(x.name)).length)"
693
+ # 95
694
+ ```
695
+
696
+ **95** published tokens paint only in a non-resting state; **62** of them paint a colour, an edge or
697
+ an elevation. The glass theme, after three rounds of fixes, sets **5**. Every defect reported off a
698
+ screenshot during that build was one of the missing 90:
699
+
700
+ - a sidebar row at `[data-active="true"]` reading dark violet on a violet tint —
701
+ `--sidebar-item-active-foreground` defaults to the live `--primary-active`
702
+ (`src/styles/shell-layout.css:2289-2291`), which is legible against the page only for a seed that is
703
+ itself legible as text on the page. A dark seed on a dark theme is not. The same caveat is in
704
+ "What follows `--primary`" above; it bites hardest in a re-materialised theme.
705
+ - a Segmented item with a dark hover fill under dark hover text — `--segmented-item-hover-background`
706
+ and `--segmented-item-hover-color` are separate knobs and the first was set alone.
707
+ - a Topbar hover block — `--topbar-item-hover-background`, same shape.
708
+
709
+ **`--focus-ring-color` is on that list, and it is the worst one to get wrong.** Every ring in the
710
+ package is painted by one rule in `src/styles/focus-ring.css`:
711
+
712
+ ```css
713
+ outline-width: var(--focus-ring-width);
714
+ outline-style: solid;
715
+ outline-color: hsl(
716
+ var(--focus-outline-color, var(--focus-ring-color, var(--ring))) / var(--focus-ring-opacity, 1)
717
+ );
718
+ ```
719
+
720
+ So it is **HSL components** — and it is read with an alpha applied, so both mistakes from §1 (write
721
+ `hsl(...)` into it, or bake an alpha into it) make the colour invalid at computed-value time. The
722
+ three longhands are what contains the damage (gh#885): only `outline-color` is discarded, falling
723
+ back to `currentcolor`, so you get a ring in the wrong colour. **As a single `outline` shorthand it
724
+ used to take `outline-width` and `outline-style` down with it and delete the indicator outright** —
725
+ Chromium reported `outline-width: 3px; outline-style: none`, which reads in a measurement tool that
726
+ ignores `outline-style` as "a 3px ring in the brand colour". Fix the FORM of the token; a
727
+ `currentcolor` ring is not the ring you asked for. Unset it follows `--ring`, which is the
728
+ package's, not your theme's. A ring tuned for a light page is a WCAG
729
+ 2.2 SC 1.4.11 defect on a dark one (3:1 against **every** surface a control sits on) and an SC 2.4.7
730
+ failure if it vanishes. See the focus-ring section above for the switch, the weight and the
731
+ per-component knobs.
732
+
733
+ **The method:** for each component your theme re-materialises, list its tokens
734
+ (`get_component`, or `grep -r -- '--<component>-' node_modules/@godxjp/ui/dist/tokens/components/`)
735
+ and
736
+ set the hover / active / selected / checked row **at the same time as the resting one**. A state
737
+ knob left at its default resolves against the _live role_ at the painting element — which is the
738
+ right default and exactly why it can be wrong for you: your `--accent` is now a dark violet, and the
739
+ item's ink still assumes the package's pale one.
740
+
741
+ ### 3 · One ink ramp only works if every surface shares a POLARITY
742
+
743
+ `docs/GLASSMORPHISM-STANDARD.md` §3 says "one ink ramp for the whole theme". That is right and
744
+ incomplete, and measuring found where: **a single ramp only serves surfaces of the same polarity.**
745
+
746
+ Light panes take dark ink. A toned tint over a **dark** page is a dark pane and takes light ink. The
747
+ glass theme had one dark ramp serving both; the Cards were fine and the Alert measured **1.27:1 and
748
+ 1.35:1** — not a contrast bug in the ink but a polarity bug, two surfaces of opposite value asking
749
+ one ramp to serve both.
750
+
751
+ **So decide polarity first, then derive one ramp per polarity.** If you want a single ramp — and you
752
+ should, because it is the only way "muted" stays consistent — then every surface has to be the same
753
+ polarity, which for a tinted status pane means making it a **light pane tinted by hue** rather than a
754
+ dark pane darkened by it. The four knobs for that are
755
+ `--surface-success` / `--surface-warning` / `--surface-info` / `--surface-destructive`
756
+ (`src/tokens/foundation.css:235-238`): independently chosen grounds for the four status tones, so
757
+ the ground stops being derived from the ink through an alpha. They are **finished colours**, and
758
+ `foundation.css:217` says so in as many words — "FINISHED COLOURS, NOT HSL COMPONENTS — the one
759
+ place this tier differs from the three above". See §1.
760
+
761
+ And on any re-materialised surface, `muted` cannot mean lower contrast. Hierarchy is carried by
762
+ size and weight at the **same** ratio — body at 7:1 so it keeps a margin when the ground shifts
763
+ under it, secondary just inside 4.5:1.
764
+
765
+ ### 4 · `--background` is the canvas under the whole shell
766
+
767
+ `--gradient-glow` paints on the shell's **main region only** — `.app-main`
768
+ (`src/styles/shell-layout.css:412`), and the two other shells' equivalents,
769
+ `.ui-centered-shell-main` (`:897`) and `.ui-mobile-shell-main` (`:3384`). Sidebar and Topbar are
770
+ `.app-main`'s **siblings** in the same CSS grid (`grid-area: sidebar` at `:217`, `grid-area:
771
+ topbar` at `:384`), so none of the gradient is behind them. What is behind them is
772
+ `.app-root { background: hsl(var(--background)) }` (`:28`) — and `body`
773
+ (`src/styles/base.css:260`) under that.
774
+
775
+ A theme that sets `--foreground` to near-white and never sets its pair therefore gets **light chrome
776
+ carrying near-white labels**. In the glass build, one missing line was four reported defects: a pink
777
+ Sidebar, a Topbar running pink on the left and blue on the right, an unreadable near-white table
778
+ header strip, and a washed-out dropdown. The fix was `--background` plus the family that derives
779
+ from the same canvas decision — `--muted`, `--accent`, `--secondary`, `--input` and their
780
+ `-foreground` pairs.
781
+
782
+ **`--foreground` and `--background` are one decision — never set only one of them.** The canvas
783
+ family, in full:
784
+
785
+ ```css
786
+ --background --foreground
787
+ --card --card-foreground
788
+ --popover --popover-foreground
789
+ --muted --muted-foreground
790
+ --accent --accent-foreground
791
+ --secondary --secondary-foreground
792
+ --border --input --ring
793
+ ```
794
+
795
+ ### 5 · A scoped theme cannot re-ink its own text
796
+
797
+ `src/styles/base.css:261` sets `color: hsl(var(--foreground))` on `body`. `body` is above every
798
+ scope you can make, so that `var()` substitutes against the **root's** `--foreground` exactly once
799
+ and every element below inherits the already-resolved colour. A plain `<div>` that sets your theme's
800
+ tokens therefore retints every **surface** and no **text**. Measured on the showcase before it was
801
+ fixed: the title's own computed `--foreground` was the theme's near-white while its `color` stayed
802
+ `rgb(36, 35, 30)` — real-pixel contrast **1.34:1**, every element in the subtree reporting that it
803
+ was inside the scope. It is the `:root` freeze rule (`docs/TOKEN-RESOLUTION.md` §3) applied to a
804
+ _property_ instead of a token, and worse there: a token can be given a knob, and `color` on `body`
805
+ has none to give.
806
+
807
+ Two remedies, and they are not interchangeable:
808
+
809
+ - **React** — wrap the region in `ThemeScope` (`@godxjp/ui/app`). It re-states
810
+ `color: "hsl(var(--foreground))"` on its own element (`src/lib/overlay-portal.tsx:270`, with
811
+ `display: contents` so it removes the box and not the inheritance) **and** on the `body`-level
812
+ host it creates for portalled overlays (`:203`) — which is the other half of the problem, because
813
+ a Dialog portals to `document.body` and would otherwise wear the root's theme. `...style` is
814
+ spread after, so your own `color` still wins.
815
+ - **A stylesheet-only scope** — state it yourself, on your own element:
816
+
817
+ ```css
818
+ [data-theme="glass"] {
819
+ --foreground: 48 20% 96%;
820
+ --background: 230 40% 9%;
821
+ /* … */
822
+ color: hsl(var(--foreground)); /* NOT optional */
823
+ }
824
+ ```
825
+
826
+ That is a declaration on _your_ element, not a selector into this package, so it does not breach
827
+ "a consumer sets tokens, never selectors" (`docs/TOKEN-RESOLUTION.md` §5 rule 5). It does **not**
828
+ reach portalled overlays; for those you still need `ThemeScope` or a scope on the portal
829
+ container. `docs/TOKEN-RESOLUTION.md` §5 rule 7 is the short form; gh#881 is the incident.
830
+
831
+ ### 6 · A theme owns its own transparency fallbacks
832
+
833
+ If your theme makes a surface translucent, **your theme** writes its
834
+ `@media (prefers-reduced-transparency: reduce)` and
835
+ `@supports not (backdrop-filter: blur(1px))` branches. The library does not, and cannot: it does
836
+ not know what opaque colour you want, and guessing at the role's own value is not safe — a theme
837
+ whose `--card` is `0 0% 100% / 42%` would get a "fallback" that is still 42% translucent.
838
+
839
+ We shipped a generic fallback for a while and it was **actively wrong**: under `reduce` it left a
840
+ Card translucent and painted the Topbar fully transparent — invisible. It is removed.
841
+ `docs/TOKEN-RESOLUTION.md` §5 rule 6 is the standing rule. Keep both branches to
842
+ custom-property declarations on your theme's own selector, and repaint exactly the surfaces you
843
+ made translucent; leave borders alone.
844
+
845
+ `prefers-reduced-transparency` is an OS accessibility setting. Honouring it is not optional.
846
+
847
+ ### 7 · Overlays are not cards
848
+
849
+ A dialog or a drawer at a card's alpha is read straight through, and both were, at 20%. A card sits
850
+ on a ground you chose; an overlay sits over **arbitrary** content, and the smaller it is the less
851
+ the reader can use context to recover a word. So every overlay belongs at the opaque end of the
852
+ range and the card belongs at the translucent end — the gap is much bigger than it looks when you
853
+ are picking numbers in a token file.
854
+
855
+ Where the glass theme landed, after the first pass at 20% was reported unreadable:
856
+
857
+ | surface | fill |
858
+ | --------------------------------- | ---------------------------------------------------------------------------------- |
859
+ | drawer (`Sheet`) | **92%** — covers content that must stay unreadable-but-present |
860
+ | `Toast` | **90%** |
861
+ | dialog panel | **88%**, over `--dialog-overlay-alpha: 62%` — a scrim that both darkens and blurs |
862
+ | `DropdownMenu` / `Select` listbox | **88%** |
863
+ | `Card` | **12%** — the recipe's own number, and the one that makes the backdrop the palette |
864
+
865
+ Two mechanics worth knowing before you tune those numbers:
866
+
867
+ - **A modal's blur belongs on the SCRIM, not the panel.** `backdrop-filter` filters what is behind
868
+ the element, and the scrim is the layer that covers the page. It also makes the element the
869
+ containing block for its `position: fixed` descendants, which is why every blur knob in this
870
+ package is `initial` rather than `blur(0px)` — unset, the whole declaration is invalid at
871
+ computed-value time, `backdrop-filter` keeps its own `none`, and no backdrop root is created.
872
+ `--dialog-overlay-backdrop-blur-size` and `--sheet-overlay-backdrop-blur-size` are the scrim
873
+ knobs; there is deliberately no knob for a blur on the panel itself.
874
+ - **Do not double-blur.** A dropdown inside an already-blurred panel blurs a blurred copy and reads
875
+ as dirty glass. Blur the few surfaces that define depth — chrome and overlays — not every card,
876
+ row and chip. It is also GPU-expensive.
877
+
878
+ `docs/GLASSMORPHISM-STANDARD.md` §4 has the reasoning per surface, and §4b covers form fields, which
879
+ are the hardest case in the system: a field has to read as _a place you can type_ before it reads as
880
+ anything else, and translucency destroys exactly the two signals that say so.
881
+
882
+ ### 8 · Measure the COMPOSITED pixel, against the worst region
883
+
884
+ A translucent fill over a gradient is not the colour you declared, and `getComputedStyle` cannot
885
+ tell you what it became — it reports the element's own `background-color` with its own alpha, not
886
+ the stack. **Screenshot the rendered page, sample the pixel under the text, compute the ratio.**
887
+
888
+ Two more rules that come from getting this wrong four times in one session:
889
+
890
+ - **Sample the worst region the backdrop can produce**, not a convenient one. A radial gradient has
891
+ a light lobe and a dark one and the pane must be legible over both. The glass build's worst ground
892
+ sampled `rgb(172, 159, 172)`; against it the stock `--muted-foreground` `rgb(104, 102, 94)`
893
+ measures **2.28:1**, and solving from that number rather than guessing gave the ramp: `L ≤ 22.5%`
894
+ for 4.5:1, `L ≤ 9.5%` for 7:1.
895
+ - **Sanity-check the instrument before you believe it.** Two signatures of a broken measurement,
896
+ both seen: a ratio that is _identical_ for several visibly different colours (the script failed to
897
+ resolve a custom property and scored the same fallback every time), and a ratio that contradicts
898
+ what you can see (`1.03:1` on a surface that looks fine — or the reverse). **When a fix does not
899
+ hold, suspect the diagnosis before re-applying the cure.** A custom property read with
900
+ `getPropertyValue()` comes back as `initial`, as an unresolved `var()` chain, or as three bare
901
+ numbers that are not a colour at all; a parser that accepts all three without complaint will
902
+ report a number for every one of them.
903
+
904
+ ### What to read next
905
+
906
+ - `docs/TOKEN-RESOLUTION.md` — §3 the freeze rule (why a knob is `initial` and its default lives at
907
+ the call site), §5 the seven rules for anyone setting or adding a token.
908
+ - `docs/GLASSMORPHISM-STANDARD.md` — the worked case: the four signals of the style, the contrast
909
+ rules, overlays, and why form fields are the hardest surface.
910
+ - `docs/THEME-API-COVERAGE.md` — how far the token API reaches today, per component, with the
911
+ hard-coded declarations listed. Read it before you conclude a knob is missing.
912
+
913
+ ---
914
+
915
+ ## Artwork & third-party marks — where the token rule ends (gh#867)
916
+
917
+ Every section above assumes the colour arrives as CSS on an element this library renders. Artwork
918
+ is the case the rule does not state: **an SVG loaded through `<img src>` runs inside its own
919
+ document boundary and cannot see the page's custom properties.** No token reaches it, scoped or
920
+ otherwise — that is a browser fact, not a library omission, and no component API can change it. So
921
+ "brand colour enters only through scoped tokens" has one silent exception: artwork that arrives as
922
+ a file. Four techniques, and the boundary between them:
923
+
924
+ | the asset | the technique |
925
+ | ----------------------------------------------------------- | ---------------------------------------------------------------- |
926
+ | a multi-colour brand mark | **1 · two masters** — ship both variants, switch by scheme |
927
+ | a single-colour glyph that should follow the text beside it | **2 · inline + `currentColor`** |
928
+ | a single-colour glyph that should sit at a token colour | **3 · `mask-image`** — shape from the asset, colour from a token |
929
+ | everything else — screenshots, illustrations, uploads | **4 · do not recolour** |
930
+
931
+ ### 1 · Two masters, switched by colour scheme
932
+
933
+ What this library does for its own mark, and the right answer for any multi-colour brand: both
934
+ masters ship, and CSS shows one per scheme. `src/components/general/logo.tsx` renders the light
935
+ and dark artworks as siblings, and `src/styles/logo-layout.css` picks between them — an explicit
936
+ light choice, an explicit dark choice, and the un-stamped default where only the OS preference
937
+ separates them. The brand guidelines quoted in the component's comment (brand identity v2.3)
938
+ forbid all three shortcuts: no re-tinting the master to a module's colour, no inverting it with a
939
+ filter, and the correct sáng/tối variant rendered as-is.
940
+
941
+ Two properties of that implementation matter if you copy it:
942
+
943
+ - **The masters are different files and stay different files.** They arrive as separate assets in
944
+ the brand kit and land verbatim in `src/brand/godx-artwork.generated.ts` — not one artwork whose
945
+ token values get swapped per theme. `grep -c currentColor src/brand/` → `0`: the package's own
946
+ brand artwork carries no token hooks at all. This technique involves no tokens on purpose.
947
+ - **Inlining is for fidelity and document validity, not tokenisation.** The markup is inlined
948
+ (`dangerouslySetInnerHTML`) because 8 gradients, a clipPath and 10 paths must stay byte-exact,
949
+ and because SVG ids are unique per document — two `<Logo>`s on one page would otherwise emit
950
+ duplicate ids. Both comments in `logo.tsx` state this explicitly and claim no theming benefit.
951
+
952
+ The colours of a multi-colour mark ARE the brand; they do not follow `--primary` — which is exactly
953
+ why the generator keeps `--brand` / `--brand-foreground` independent of the action colour (gh#250).
954
+
955
+ ### 2 · Inline the SVG and use `currentColor` — the single-colour glyph
956
+
957
+ For a single-colour glyph that should follow the text it sits beside. Inline the file and paint
958
+ the path with `currentColor`:
959
+
960
+ ```css
961
+ /* your app's stylesheet — a role, not a literal, so scopes and dark mode reach it */
962
+ .ui-mark {
963
+ color: hsl(var(--primary)); /* or --text-link, or whatever text role it accompanies */
964
+ }
965
+ ```
966
+
967
+ ```tsx
968
+ <svg className="ui-mark" viewBox="…" aria-hidden="true">
969
+ <path fill="currentColor" d="…" />
970
+ </svg>
971
+ ```
972
+
973
+ The glyph now follows `color` like any text: a `[data-tenant]` scope, a link's `--text-link`, dark
974
+ mode — anything that sets `color` above it re-tints the glyph with it. It is one colour by
975
+ construction; if the glyph needs two, this is the wrong technique — go back to 1.
976
+
977
+ ### 3 · `mask-image` — shape from the asset, colour from a token
978
+
979
+ When the asset should sit at a token colour regardless of the text around it, take the shape from
980
+ the file and the colour from CSS:
981
+
982
+ ```css
983
+ .ui-partner-mark {
984
+ width: 1.25em;
985
+ height: 1.25em;
986
+ background: hsl(var(--primary)); /* the token does the colouring */
987
+ mask-image: url("./partner-mark.svg"); /* the asset's transparency supplies the shape */
988
+ mask-size: contain;
989
+ mask-repeat: no-repeat;
990
+ }
991
+ ```
992
+
993
+ The mask reads the asset's alpha as the shape; `background` supplies the only colour, so a token
994
+ change, a `[data-tenant]` scope and dark mode all reach it — the colour is ordinary CSS. It is
995
+ single-colour by construction: a mask has no way to carry a second colour.
996
+
997
+ Stated honestly, this library uses CSS masking in exactly **two** places — the Marquee's edge fade
998
+ (`src/styles/motion.css`) and the FloatButton (`src/styles/float-button-layout.css`). This is a
999
+ technique documented here, not a house pattern; reach for it for one-colour glyphs, not as the
1000
+ default answer to "how do I theme an image".
1001
+
1002
+ ### 4 · Do not recolour
1003
+
1004
+ `Thumbnail` (`src/components/data-display/thumbnail.tsx`) is a framed `<img>`, and `src` artwork
1005
+ keeps its authored colours in every theme — it always has. Screenshots, illustrations, photos,
1006
+ uploaded logos: they render exactly as authored, and that is the answer, not a gap and not a
1007
+ roadmap item. If the artwork must read on both schemes, choose or commission artwork that does;
1008
+ the frame and the surface around it are yours to theme — the pixels inside them are not.
1009
+
1010
+ ### The caveat that is not a footnote
1011
+
1012
+ Techniques 1–3 inline SVG, and inlined SVG is markup in your document. An upload that lands as
1013
+ markup is an XSS surface — `<script>`, event attributes, `javascript:` hrefs all execute. The
1014
+ boundary `logo.tsx` states in its own comment — _"package-owned generated content, never user
1015
+ input"_ — is the whole rule: **inlining is for artwork you generate**, at build time, from your
1016
+ own kit. Anything a customer uploads either passes a real sanitiser before it is inlined, or goes
1017
+ through `<img>` and stays unthemed — which is technique 4, and a legitimate outcome.