@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.
- package/agent/START-HERE.md +29 -10
- package/agent/components/Anchor.json +6 -1
- package/agent/components/AppShell.json +1 -1
- package/agent/components/AreaChart.json +19 -1
- package/agent/components/BarChart.json +11 -1
- package/agent/components/Cascader.json +1 -1
- package/agent/components/CompactBarTrend.json +1 -1
- package/agent/components/DataState.json +1 -1
- package/agent/components/DataTable.json +4 -4
- package/agent/components/FormField.json +1 -1
- package/agent/components/InfiniteQueryState.json +1 -1
- package/agent/components/Input.json +1 -1
- package/agent/components/LineChart.json +20 -2
- package/agent/components/ListRow.json +1 -1
- package/agent/components/Masonry.json +1 -1
- package/agent/components/MasterDetail.json +1 -1
- package/agent/components/PasswordStrength.json +1 -1
- package/agent/components/PermissionMatrix.json +1 -1
- package/agent/components/Select.json +1 -0
- package/agent/components/Sidebar.json +1 -1
- package/agent/components/Table.json +8 -3
- package/agent/components/ThemeScope.json +49 -0
- package/agent/components/Topbar.json +1 -0
- package/agent/components/TopbarItem.json +2 -1
- package/agent/components/Transfer.json +1 -1
- package/agent/components/TreeSelect.json +1 -1
- package/agent/components/UploadCropDialog.json +1 -1
- package/agent/components/formatDate.json +1 -1
- package/agent/components-index.json +5 -0
- package/agent/components.json +138 -30
- package/agent/index.json +19 -9
- package/agent/llms.txt +10 -10
- package/agent/patterns/tenant-brand-color.json +28 -0
- package/agent/patterns-index.json +27 -0
- package/agent/patterns.json +28 -0
- package/agent/rules.json +15 -0
- package/agent/tokens.json +4965 -970
- package/dist/app/index.d.ts +3 -0
- package/dist/app/index.js +3 -0
- package/dist/app/tenant-theme.d.ts +80 -0
- package/dist/app/tenant-theme.js +154 -0
- package/dist/app/theme-axes.d.ts +14 -1
- package/dist/app/theme-axes.js +24 -31
- package/dist/components/charts/chart-cartesian.d.ts +5 -1
- package/dist/components/charts/chart-cartesian.js +15 -8
- package/dist/components/data-display/badge.d.ts +1 -1
- package/dist/components/data-display/badge.js +20 -2
- package/dist/components/data-display/carousel.js +4 -4
- package/dist/components/data-display/data-table.js +13 -2
- package/dist/components/data-display/permission-matrix.js +1 -1
- package/dist/components/data-display/table.d.ts +11 -2
- package/dist/components/data-display/table.js +18 -2
- package/dist/components/data-entry/control-appearance.d.ts +12 -6
- package/dist/components/data-entry/control-appearance.js +1 -1
- package/dist/components/data-entry/select.js +4 -3
- package/dist/components/feedback/dialog.js +6 -3
- package/dist/components/feedback/overlay-header-tone.d.ts +7 -0
- package/dist/components/feedback/overlay-header-tone.js +4 -4
- package/dist/components/feedback/sheet.d.ts +1 -1
- package/dist/components/feedback/sheet.js +6 -9
- package/dist/components/feedback/sonner.js +16 -3
- package/dist/components/general/button.js +22 -5
- package/dist/components/layout/affix.js +15 -1
- package/dist/components/layout/sidebar.js +7 -1
- package/dist/components/navigation/anchor.d.ts +1 -1
- package/dist/components/navigation/anchor.js +5 -4
- package/dist/components/navigation/app-setting-picker.js +1 -1
- package/dist/components/navigation/pagination.js +1 -1
- package/dist/components/navigation/tabs.js +15 -2
- package/dist/components/query/infinite-query-state.d.ts +22 -6
- package/dist/lib/control-styles.d.ts +31 -11
- package/dist/lib/control-styles.js +6 -6
- package/dist/lib/overlay-portal.d.ts +20 -0
- package/dist/lib/overlay-portal.js +93 -0
- package/dist/props/components/app.prop.d.ts +12 -0
- package/dist/props/components/charts.prop.d.ts +30 -0
- package/dist/props/components/index.d.ts +1 -1
- package/dist/props/components/navigation.prop.d.ts +21 -2
- package/dist/props/components/query.prop.d.ts +36 -2
- package/dist/props/registry.d.ts +46 -1
- package/dist/props/registry.js +38 -3
- package/dist/styles/alert-layout.css +34 -14
- package/dist/styles/badge-layout.css +10 -6
- package/dist/styles/base.css +14 -5
- package/dist/styles/card-layout.css +19 -8
- package/dist/styles/chart-layout.css +22 -3
- package/dist/styles/control.css +165 -59
- package/dist/styles/data-display-layout.css +129 -36
- package/dist/styles/data-entry-layout.css +23 -87
- package/dist/styles/dialog-layout.css +49 -19
- package/dist/styles/float-button-layout.css +5 -5
- package/dist/styles/focus-ring.css +9 -5
- package/dist/styles/layout.css +42 -15
- package/dist/styles/logo-layout.css +1 -1
- package/dist/styles/motion.css +1 -1
- package/dist/styles/navigation-layout.css +90 -29
- package/dist/styles/shell-layout.css +63 -40
- package/dist/styles/table-layout.css +56 -17
- package/dist/styles/text-layout.css +13 -4
- package/dist/styles/toggle.css +8 -2
- package/dist/tokens/components/actions.css +1 -1
- package/dist/tokens/components/attachments.css +4 -4
- package/dist/tokens/components/badge.css +4 -4
- package/dist/tokens/components/callout.css +1 -1
- package/dist/tokens/components/card.css +9 -4
- package/dist/tokens/components/chart.css +10 -1
- package/dist/tokens/components/chat-bubble.css +1 -1
- package/dist/tokens/components/control.css +28 -10
- package/dist/tokens/components/conversations.css +2 -1
- package/dist/tokens/components/data-display.css +12 -7
- package/dist/tokens/components/descriptions.css +1 -1
- package/dist/tokens/components/draggable-panel.css +1 -1
- package/dist/tokens/components/feedback.css +28 -8
- package/dist/tokens/components/float-button.css +1 -1
- package/dist/tokens/components/legal-document.css +1 -1
- package/dist/tokens/components/logo.css +1 -1
- package/dist/tokens/components/mega-menu.css +5 -3
- package/dist/tokens/components/navigation.css +21 -7
- package/dist/tokens/components/segmented.css +8 -3
- package/dist/tokens/components/shell.css +19 -5
- package/dist/tokens/components/table.css +9 -1
- package/dist/tokens/components/thought-chain.css +1 -1
- package/dist/tokens/components/toggle.css +2 -0
- package/dist/tokens/components/tree.css +3 -1
- package/dist/tokens/components/upload.css +6 -6
- package/dist/tokens/components/welcome.css +1 -1
- package/dist/tokens/foundation.css +28 -1
- package/docs/COMPOSITION-VS-COMPONENT.md +31 -0
- package/docs/CUSTOMER-THEMING.md +637 -1
- package/docs/FRAME-COVERAGE-REPORT.md +3 -2
- package/docs/GLASSMORPHISM-STANDARD.md +196 -0
- package/docs/THEME-API-COVERAGE.md +538 -0
- package/docs/TOKEN-RESOLUTION.md +195 -0
- package/docs/TOKENS.md +63 -24
- package/docs/asset-modules.d.ts +7 -0
- package/docs/data-display/charts.tsx +80 -0
- package/docs/data-display/data-table/index.tsx +30 -0
- package/docs/data-display/popover.tsx +1 -1
- package/docs/data-display/table.tsx +52 -0
- package/docs/feedback/sheet.tsx +10 -10
- package/docs/foundation/density.tsx +4 -4
- package/docs/i18n/messages/en.json +502 -0
- package/docs/i18n/messages/ja.json +502 -0
- package/docs/i18n/messages/vi.json +502 -0
- package/docs/layout/account-chip.tsx +2 -2
- package/docs/layout/responsive-grid.tsx +1 -1
- package/docs/navigation/toolbar.tsx +20 -12
- package/docs/providers/theme-scope.tsx +186 -0
- package/docs/showcase/caimono-price-comparison.tsx +911 -0
- package/docs/showcase/case4-login.tsx +2 -2
- package/docs/showcase/permission-matrix.tsx +13 -5
- package/docs/showcase/table-pagination.tsx +2 -1
- package/docs/showcase/tenant-brand-color.tsx +338 -0
- package/docs/showcase/theme-lab.tsx +2125 -0
- package/docs/themes/flat.css +462 -0
- package/docs/themes/glassmorphism.css +958 -0
- package/docs/themes/index.ts +200 -0
- package/package.json +4 -3
- package/scripts/explain-token.mjs +382 -0
package/docs/CUSTOMER-THEMING.md
CHANGED
|
@@ -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`
|
|
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.
|