diff --git a/.changeset/lucky-hooks-follow-schema.md b/.changeset/lucky-hooks-follow-schema.md new file mode 100644 index 000000000..bbf023778 --- /dev/null +++ b/.changeset/lucky-hooks-follow-schema.md @@ -0,0 +1,5 @@ +--- +'@cube-dev/ui-kit': minor +--- + +Added `useSchema()` and `useHighContrast()` — the JS answer to the `@dark` and `@hc` states, for the two places a state map cannot reach: surfaces that take values rather than CSS (a Vega spec, a CodeMirror/Monaco theme, an iframe) and controls whose value _is_ the ambient condition. Both follow the `` / `` opt-in first and `prefers-color-scheme` / `prefers-contrast` second, exactly as the states do, and re-render on a change. `resolveSchema()`, `resolveHighContrast()` and `subscribeSchema()` cover the same ground outside React. Styling still belongs in a state map. diff --git a/.changeset/schema-not-scheme.md b/.changeset/schema-not-scheme.md new file mode 100644 index 000000000..eb36f4e94 --- /dev/null +++ b/.changeset/schema-not-scheme.md @@ -0,0 +1,11 @@ +--- +'@cube-dev/ui-kit': minor +--- + +**Breaking:** renamed the `scheme` term to `schema` across the API, so one word names the concept the `data-schema` attribute and the `@root(schema=…)` state already use. + +- `renderColorTokens()` / `renderPaletteTokens()` / `RenderPaletteOptions`: the `scheme` option is now `schema` — `renderColorTokens({ schema: 'dark' })`. +- `` / ``: the `scheme` prop is now `schema`. +- The probe's `tokenOptions.scheme` is now `tokenOptions.schema`, and the `pnpm probe` CLI flag `--scheme` is now `--schema` (`--schema hc` still means light + high contrast). + +No aliases: update the call sites. diff --git a/.storybook/preview.jsx b/.storybook/preview.jsx index bad13f79f..5037b7a1c 100644 --- a/.storybook/preview.jsx +++ b/.storybook/preview.jsx @@ -8,7 +8,7 @@ import { create, themes } from 'storybook/theming'; import { Root } from '../src/components/Root'; import { getI18n, LOCALE_LABELS, SUPPORTED_LOCALES } from '../src/i18n'; -import { setToolbarScheme } from '../src/stories/decorators/colorSchemeBridge'; +import { setToolbarSchema } from '../src/stories/decorators/colorSchemaBridge'; // Summarizes DOM/React events before Storybook's action spies see them. Without // it, serializing a focus event over the preview channel costs ~600ms per focus @@ -53,7 +53,7 @@ configure({ testIdAttribute: 'data-qa', asyncUtilTimeout: 10000 }); // initial event after manager/preview channels connect. if (typeof document !== 'undefined') { addons.getChannel().on(DARK_MODE_EVENT_NAME, (isDark) => { - setToolbarScheme(isDark ? 'dark' : 'light'); + setToolbarSchema(isDark ? 'dark' : 'light'); }); } @@ -137,7 +137,7 @@ export const parameters = { // `storybook-dark-mode` configuration. No `current` so the addon resolves // OS `prefers-color-scheme` on first load. `stylePreview: false` keeps the // addon from also injecting dark/light classes on the preview body — the - // `data-schema` attribute set by `colorSchemeBridge` is the only signal + // `data-schema` attribute set by `colorSchemaBridge` is the only signal // we care about (see `src/components/Root.tsx` and `src/tokens/palette.ts`). darkMode: { dark: darkTheme, @@ -147,7 +147,7 @@ export const parameters = { // Storybook's `addon-backgrounds` injects `.sb-show-main { background: … !important }` // when an option is selected, which overrides the body's `#surface` fill from // `src/components/GlobalStyles.tsx`. Disable it globally so the body's - // scheme-aware Glaze background shows through (dark/light). Stories can still + // schema-aware Glaze background shows through (dark/light). Stories can still // override via `parameters.backgrounds = { disable: false, … }`. // NOTE: the addon's parameter is `disable` (not `disabled`) — the latter is // silently ignored, leaving the addon active and its toolbar still able to diff --git a/README.md b/README.md index 976325323..0ef0706c0 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ function App() { } ``` -To ship your own brand color, tune the palette seeds — every token, in every scheme, re-resolves from them: +To ship your own brand color, tune the palette seeds — every token, in every schema, re-resolves from them: ```tsx diff --git a/docs/rules/probe.md b/docs/rules/probe.md index f31c4fea5..f5c1b6c1f 100644 --- a/docs/rules/probe.md +++ b/docs/rules/probe.md @@ -6,7 +6,7 @@ Answering "what CSS does this actually produce?" used to mean hand-writing a thr ```bash pnpm probe styles '{"fill":"#purple","padding":"2x","preset":"t3"}' -pnpm probe tokens --scheme dark --filter surface +pnpm probe tokens --schema dark --filter surface pnpm probe globals pnpm probe render <<'TSX' import { Button } from '@cube-dev/ui-kit'; @@ -25,8 +25,8 @@ TSX **`tokens`** — two shapes of the same palette, labelled rather than merged: -- `resolved` is `renderColorTokens()`: flat literal values for **one** variant, chosen with `--scheme light|dark` and `--hc`. The legacy aliases come back **by reference** (`'#dark': '#surface-text'`) rather than resolved — deliberately, so a region preview re-resolves them against its own tokens — and the probe labels them so you cannot read one as a color. -- `palette` is `getPaletteTokens()`: one tasty state map per token, keyed by scheme (`''` / `'@dark'` / `'@hc'` / `'@dark & @hc'`). This is the **four-variant view**, and it is the one a palette change has to be diffed across — see [`docs/glaze/`](../glaze/) on why light mode alone is misleading. +- `resolved` is `renderColorTokens()`: flat literal values for **one** variant, chosen with `--schema light|dark` and `--hc`. The legacy aliases come back **by reference** (`'#dark': '#surface-text'`) rather than resolved — deliberately, so a region preview re-resolves them against its own tokens — and the probe labels them so you cannot read one as a color. +- `palette` is `getPaletteTokens()`: one tasty state map per token, keyed by schema (`''` / `'@dark'` / `'@hc'` / `'@dark & @hc'`). This is the **four-variant view**, and it is the one a palette change has to be diffed across — see [`docs/glaze/`](../glaze/) on why light mode alone is misleading. ```bash pnpm probe tokens --json > /tmp/tokens-before.json @@ -35,7 +35,7 @@ pnpm probe tokens --json > /tmp/tokens-after.json diff <(jq -S .palette /tmp/tokens-before.json) <(jq -S .palette /tmp/tokens-after.json) ``` -**`render`** — module-level code, then a trailing JSX expression (or an explicit `export default`). The default export is rendered by React as ``, so a snippet may use hooks — `useState` to probe a controlled input or a disclosure is ordinary, not exotic. Each run gets its own `.probe//` directory, so probing in parallel is safe; directories older than an hour are swept on the next run. It reports the markup plus **only the CSS that snippet caused**: the harness renders `` empty, captures, mounts the snippet, captures again and subtracts. Overlays are reported under `PORTALS` — `` is the `PortalProvider` target, so a `Dialog` renders as its _sibling_ and never appears in the inline markup. `--full-css` keeps the baseline; `--canonical` normalises tasty's class hashes and React's `useId` counters so two renders can be diffed byte-for-byte, **on both tiers** — a browser run is exactly where you would diff one scheme or viewport against another. +**`render`** — module-level code, then a trailing JSX expression (or an explicit `export default`). The default export is rendered by React as ``, so a snippet may use hooks — `useState` to probe a controlled input or a disclosure is ordinary, not exotic. Each run gets its own `.probe//` directory, so probing in parallel is safe; directories older than an hour are swept on the next run. It reports the markup plus **only the CSS that snippet caused**: the harness renders `` empty, captures, mounts the snippet, captures again and subtracts. Overlays are reported under `PORTALS` — `` is the `PortalProvider` target, so a `Dialog` renders as its _sibling_ and never appears in the inline markup. `--full-css` keeps the baseline; `--canonical` normalises tasty's class hashes and React's `useId` counters so two renders can be diffed byte-for-byte, **on both tiers** — a browser run is exactly where you would diff one schema or viewport against another. **`globals`** — everything on the page with only `` mounted: the `:root` token block, the body styles, `@font-face`, the keyframes. Note that only a handful of those rules are attributed to a node, and **those** are all `render` subtracts; the token block reaches the page through `useGlobalStyles` / `injectRawCSS`, so it lives on a global sheet that no per-node dump can see and `render` never had to exclude it. (Cube Cloud's console-ui hands its palette to `` through a tasty `tokens` prop instead, so there the same block _is_ node-attributed and the subtraction is what keeps ~119KB out of every answer. Same command, different reason for the same clean output.) @@ -47,15 +47,15 @@ jsdom is the default only because it is quicker: it reports the CSS tasty genera ```bash pnpm probe:browser render --computed '[data-qa="Card"]' backgroundColor padding -pnpm probe:browser render --scheme dark --hc --screenshot +pnpm probe:browser render --schema dark --hc --screenshot pnpm probe:browser render --rect '[data-qa="Card"]' ``` The same component, both tiers: `var(--surface-2-color)` / `calc(3 * var(--gap))` under `probe`, versus `rgb(248, 248, 249)` / `24px` under `probe:browser`. `--computed` and `--rect` take a CSS selector, so give the component a `qa` prop and select on `[data-qa="…"]`. -Scheme and contrast are independent axes, driven through the `` attributes the `@dark` / `@hc` states resolve against — so `--scheme dark --hc` reaches the fourth variant, which no single `--scheme` value can express. `--scheme hc` stays accepted as the spelling Cloud's probe uses and means light + high contrast. +Schema and contrast are independent axes, driven through the `` attributes the `@dark` / `@hc` states resolve against — so `--schema dark --hc` reaches the fourth variant, which no single `--schema` value can express. `--schema hc` stays accepted as the spelling Cloud's probe uses and means light + high contrast. -**Nothing is silently ignored.** `--computed`, `--rect` and `--screenshot` are rejected on the jsdom tier rather than no-oping: asking for computed values and getting none back reads as "no styles applied", the opposite of the truth. Likewise `probe:browser` refuses every mode but `render`; `--scheme` / `--hc` are refused on modes that have no scheme (`styles` and `globals` already report every scheme at once — their state maps and `@media` blocks _are_ the per-scheme answer); an unknown `--scheme` is rejected by the CLI rather than reaching the token renderer, where it surfaces as a stack trace that reads like a harness bug instead of a typo; and a flag that needs a value says so instead of defaulting to off — including when the value it would have swallowed is the next flag (`--computed --scheme dark`). +**Nothing is silently ignored.** `--computed`, `--rect` and `--screenshot` are rejected on the jsdom tier rather than no-oping: asking for computed values and getting none back reads as "no styles applied", the opposite of the truth. Likewise `probe:browser` refuses every mode but `render`; `--schema` / `--hc` are refused on modes that have no schema (`styles` and `globals` already report every schema at once — their state maps and `@media` blocks _are_ the per-schema answer); an unknown `--schema` is rejected by the CLI rather than reaching the token renderer, where it surfaces as a stack trace that reads like a harness bug instead of a typo; and a flag that needs a value says so instead of defaulting to off — including when the value it would have swallowed is the next flag (`--computed --schema dark`). A snippet that does not compile is reported the same way on both tiers: the parse error, with its file, line and code frame. That takes a detour on the browser tier, because Chromium hands the harness only `Failed to fetch dynamically imported module: ` — the real error is in the 500 body it keeps from script, so the harness re-requests the module to read it. When the module itself compiles and the break is in something it _imports_, only Vite's log names the file, so the probe prints that log under the message instead of dropping it. diff --git a/scripts/chromatic-duplicates.mjs b/scripts/chromatic-duplicates.mjs index 36f4f92c5..2a81cf278 100644 --- a/scripts/chromatic-duplicates.mjs +++ b/scripts/chromatic-duplicates.mjs @@ -262,15 +262,15 @@ const FINGERPRINT = () => { ].filter((role) => document.querySelector(`[role="${role}"]`)); // A story can change its whole appearance without touching the root: the - // color-scheme decorators in `src/stories/decorators/withColorScheme.tsx` + // color-schema decorators in `src/stories/decorators/withColorSchema.tsx` // drive `` / ``, which the `@dark` and // `@hc` predefined states resolve against. Fold them into the fingerprint so - // a `DarkScheme` story is not reported as a copy of its light twin. + // a `DarkSchema` story is not reported as a copy of its light twin. const html = document.documentElement; - const scheme = `${html.getAttribute('data-schema') ?? ''}/${html.getAttribute('data-contrast') ?? ''}`; + const schema = `${html.getAttribute('data-schema') ?? ''}/${html.getAttribute('data-contrast') ?? ''}`; return { - html: `${scheme}\n${clone.innerHTML}`, + html: `${schema}\n${clone.innerHTML}`, text: root.innerText?.slice(0, 400) ?? '', width: finite ? Math.round(right - left) : 0, height: finite ? Math.round(bottom - top) : 0, diff --git a/scripts/probe.mjs b/scripts/probe.mjs index 8bb29be94..df048efc6 100644 --- a/scripts/probe.mjs +++ b/scripts/probe.mjs @@ -40,15 +40,15 @@ pnpm probe [options] Modes styles '' CSS for a tasty styles object, under the real ui-kit config (units, recipes, presets, color tokens) - tokens Color tokens: resolved literal values for one scheme, + tokens Color tokens: resolved literal values for one schema, plus the four-variant state maps declares render HTML + the CSS this snippet caused. Snippet on stdin. globals Everything on the page with only mounted: the :root token block, body styles, @font-face, keyframes Options - --scheme tokens: light | dark (default: light) - --hc tokens: resolve the high-contrast variant of --scheme + --schema tokens: light | dark (default: light) + --hc tokens: resolve the high-contrast variant of --schema --filter tokens: only tokens whose name contains this --full-css render: do not subtract the baseline --canonical render: normalise tasty hashes and React IDs (for @@ -59,10 +59,10 @@ Options for 'pnpm probe:browser' only (these need a real browser) --computed [prop...] resolved values — jsdom reports var(...) as text --rect geometry — jsdom sizes everything at 0 --screenshot write a PNG beside the result - --scheme render: drive into light | dark | hc - --hc render: high contrast, composable with --scheme + --schema render: drive into light | dark | hc + --hc render: high contrast, composable with --schema so 'dark --hc' reaches the fourth variant - ('--scheme hc' alone means light + hc) + ('--schema hc' alone means light + hc) Notes The snippet is NOT typechecked — oxc strips types without checking them. @@ -87,8 +87,8 @@ function parseArgs(argv) { * A missing value used to land as `undefined`, which every downstream check * reads as "flag absent" — so `probe:browser render --computed` produced a * plain render and looked like an answer to a computed-values question that was - * never asked. Worse, `--computed --scheme dark` took `--scheme` itself as the - * selector, silently dropping the scheme too. Both are the failure this tool + * never asked. Worse, `--computed --schema dark` took `--schema` itself as the + * selector, silently dropping the schema too. Both are the failure this tool * refuses to commit elsewhere, so they are errors rather than defaults. */ const takeValue = (flag, index) => { @@ -122,8 +122,8 @@ function parseArgs(argv) { options.json = true; } else if (arg === '--hc') { options.highContrast = true; - } else if (arg === '--scheme') { - options.scheme = takeValue(arg, (i += 1)); + } else if (arg === '--schema') { + options.schema = takeValue(arg, (i += 1)); } else if (arg === '--filter') { options.filter = takeValue(arg, (i += 1)); } else if (arg === '--screenshot') { @@ -257,44 +257,44 @@ function main() { } } - // Which modes can actually act on a scheme. Silently ignoring `--scheme dark` - // on a mode that has no scheme is the same failure as silently ignoring + // Which modes can actually act on a schema. Silently ignoring `--schema dark` + // on a mode that has no schema is the same failure as silently ignoring // `--computed`: the answer comes back looking like the light-mode result was // the dark-mode result. - const schemeAware = browser ? ['render'] : ['tokens']; + const schemaAware = browser ? ['render'] : ['tokens']; if ( - (options.scheme || options.highContrast) && - !schemeAware.includes(options.mode) + (options.schema || options.highContrast) && + !schemaAware.includes(options.mode) ) { - const flags = [options.scheme && '--scheme', options.highContrast && '--hc'] + const flags = [options.schema && '--schema', options.highContrast && '--hc'] .filter(Boolean) .join(', '); console.error( `probe ${options.mode}: ${flags} has no effect here.\n` + (options.mode === 'styles' || options.mode === 'globals' - ? `'${options.mode}' reports what tasty generated for every scheme at once — the state ` + - `maps and @media blocks in its output ARE the per-scheme answer.\n` - : `The jsdom tier cannot resolve a scheme's colors at all. Use 'pnpm probe tokens ` + - `--scheme ' for literal values, or 'pnpm probe:browser render --scheme ' ` + + ? `'${options.mode}' reports what tasty generated for every schema at once — the state ` + + `maps and @media blocks in its output ARE the per-schema answer.\n` + : `The jsdom tier cannot resolve a schema's colors at all. Use 'pnpm probe tokens ` + + `--schema ' for literal values, or 'pnpm probe:browser render --schema ' ` + `for what a browser computes.\n`), ); process.exit(1); } - // Validated here rather than left to the palette. An unknown scheme reaches + // Validated here rather than left to the palette. An unknown schema reaches // `renderColorTokens`, misses the variant lookup and throws inside the token // renderer — surfacing as a vitest stack trace about `Object.keys(undefined)`, // which reads as a harness bug rather than as a typo in the flag. - const schemes = browser ? ['light', 'dark', 'hc'] : ['light', 'dark']; + const schemas = browser ? ['light', 'dark', 'hc'] : ['light', 'dark']; - if (options.scheme && !schemes.includes(options.scheme)) { + if (options.schema && !schemas.includes(options.schema)) { console.error( - `probe: unknown --scheme "${options.scheme}". Expected ${schemes.join(' | ')}.` + + `probe: unknown --schema "${options.schema}". Expected ${schemas.join(' | ')}.` + (browser ? `\n('hc' is light + high contrast, the spelling Cube Cloud's probe uses. ` + - `For dark + high contrast, pass '--scheme dark --hc'.)` + `For dark + high contrast, pass '--schema dark --hc'.)` : `\nAdd --hc for the high-contrast variant of either.`), ); process.exit(1); @@ -342,7 +342,7 @@ function main() { if (options.mode === 'tokens') { input.tokenOptions = { - scheme: options.scheme ?? 'light', + schema: options.schema ?? 'light', highContrast: Boolean(options.highContrast), }; } @@ -375,7 +375,7 @@ function main() { // file's contents in through `define` at config-load time, so anything added // after the write never reaches the harness. if (browser) { - input.scheme = options.scheme; + input.schema = options.schema; input.highContrast = Boolean(options.highContrast); input.computed = options.computed; input.computedProps = options.computedProps; @@ -545,7 +545,7 @@ function print(result, options, harnessLog = '') { ([name]) => !options.filter || name.includes(options.filter), ); - console.log(`# resolved — ${result.scheme} (${entries.length} tokens)`); + console.log(`# resolved — ${result.schema} (${entries.length} tokens)`); for (const [name, value] of entries) { // Legacy aliases come back by reference, not resolved — flagged so a // '#surface-text' value is not read as a literal color. diff --git a/src/components/GlobalStyles.tsx b/src/components/GlobalStyles.tsx index 48dfed417..8cb0ea9c7 100644 --- a/src/components/GlobalStyles.tsx +++ b/src/components/GlobalStyles.tsx @@ -20,7 +20,7 @@ interface GlobalStylesProps { /** * Body styles applied via useGlobalStyles. * - * `fill` and `color` use scheme-aware Glaze tokens so the whole page flips + * `fill` and `color` use schema-aware Glaze tokens so the whole page flips * automatically when `` (or `prefers-color-scheme: dark`) * is active — see `src/tokens/palette.ts` and `src/components/Root.tsx`. */ @@ -93,10 +93,10 @@ const STATIC_CSS = ` /* * Prism syntax highlighting. * - * All token colors come from the scheme-aware Glaze \`code-*\` palette + * All token colors come from the schema-aware Glaze \`code-*\` palette * (defined in src/tokens/palette.ts). Each \`code-*\` token has \`mode: 'auto'\` * with a numeric contrast floor of 4.5 against \`#surface\` so every token - * reads at WCAG AA in light AND dark schemes (AAA in high-contrast). + * reads at WCAG AA in light AND dark schemas (AAA in high-contrast). * Diff insertion / deletion re-use the adaptive \`success-*\` and \`danger-*\` * ramps for both the line bg and the token color. */ @@ -161,7 +161,7 @@ const STATIC_CSS = ` white-space: normal; } - /* Diff (re-uses success/danger ramps; both bg and text adapt to scheme) */ + /* Diff (re-uses success/danger ramps; both bg and text adapt to schema) */ .token.inserted-sign { background-color: var(--success-bg-color); color: var(--success-text-color); diff --git a/src/components/Root.tsx b/src/components/Root.tsx index 1954dab35..7599c9fb1 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -17,6 +17,7 @@ import { NavigationAdapter } from '../providers/navigation.types'; import { TrackingProps, TrackingProvider } from '../providers/TrackingProvider'; import { PaletteConfig, setPaletteConfig } from '../tokens/palette-config'; import { EventBusProvider } from '../utils/react/useEventBus'; +import { AMBIENT_PREDEFINED_STATES } from '../utils/react/useSchema'; import { extractStyles } from '../utils/styles'; import { TASTY_VERSION, VERSION } from '../version'; @@ -27,17 +28,16 @@ import { PortalProvider } from './portal'; import type { i18n as I18nInstance } from 'i18next'; -// Color-scheme aliases for the Glaze-generated palette (see `src/tokens/palette.ts`). +// Color-schema aliases for the Glaze-generated palette (see `src/tokens/palette.ts`). // Attribute opt-in wins over system preference: -// → forces dark scheme -// → forces high-contrast scheme +// → forces the dark schema +// → forces the high-contrast schema // Otherwise falls back to the user's `prefers-color-scheme` / `prefers-contrast`. -setGlobalPredefinedStates({ - '@dark': - '@root(schema=dark) | (!@root(schema) & @media(prefers-color-scheme: dark))', - '@hc': - '@root(contrast=high) | (!@root(contrast) & @media(prefers-contrast: more))', -}); +// +// The strings live in `useSchema.ts`, which also reads the same two conditions +// from JS (`useSchema()` / `useHighContrast()`) — one definition, so the CSS and +// the JS answers cannot drift apart. +setGlobalPredefinedStates(AMBIENT_PREDEFINED_STATES); configure({ // Collapse the kit's stylesheet writes into one style invalidation per commit diff --git a/src/components/actions/Banner/Banner.tsx b/src/components/actions/Banner/Banner.tsx index 818fceb8f..4c672fb3c 100644 --- a/src/components/actions/Banner/Banner.tsx +++ b/src/components/actions/Banner/Banner.tsx @@ -63,7 +63,7 @@ const BannerElement = tasty(Item, { // shape rather than the borderless default. // // `current` is the right color source: a banner labels itself `#white` in both -// schemes (see `BannerLinkElement` and `*_PRIMARY_STYLES`), so the action mixes +// schemas (see `BannerLinkElement` and `*_PRIMARY_STYLES`), so the action mixes // its chip, border and label from that white — label cr 4.3-5.0 against every // banner theme, with the chip a subtle 1.06 off the banner. That is what an // action on a saturated surface should be: the banner carries the emphasis, the diff --git a/src/components/actions/Menu/styled.tsx b/src/components/actions/Menu/styled.tsx index 400ab013b..2a76e37a6 100644 --- a/src/components/actions/Menu/styled.tsx +++ b/src/components/actions/Menu/styled.tsx @@ -22,7 +22,7 @@ export const StyledMenuWrapper = tasty({ '': '', // Use the design-system `$shadow` token (resolves to // `0 .5x 2x #shadow-md`) so the popover shadow follows the - // scheme-aware Glaze `#shadow-md` color. The previous literal + // schema-aware Glaze `#shadow-md` color. The previous literal // `0px 5px 15px #dark.05` baked in `#dark` (now aliased to the // adaptive `#surface-text`) which inverts to a *light* shadow on // dark surfaces. diff --git a/src/components/content/Item/Item.stories.tsx b/src/components/content/Item/Item.stories.tsx index 073863e48..0f1f6b75d 100644 --- a/src/components/content/Item/Item.stories.tsx +++ b/src/components/content/Item/Item.stories.tsx @@ -1267,7 +1267,7 @@ DescriptionWithTypes.parameters = { docs: { description: { story: - "Demonstrates how descriptions work with all Item type variants. The description inherits the color scheme and adapts to each type's visual style, ensuring consistent readability across different type configurations.", + "Demonstrates how descriptions work with all Item type variants. The description inherits the color schema and adapts to each type's visual style, ensuring consistent readability across different type configurations.", }, }, }; diff --git a/src/components/content/ItemCard/ItemCard.docs.mdx b/src/components/content/ItemCard/ItemCard.docs.mdx index 27f415d1b..9c81f1788 100644 --- a/src/components/content/ItemCard/ItemCard.docs.mdx +++ b/src/components/content/ItemCard/ItemCard.docs.mdx @@ -64,7 +64,7 @@ Inherits all styling from `Item`. See [Item documentation](/docs/content-item--d ### Themes -Use the `theme` prop to apply a semantic color scheme: +Use the `theme` prop to apply a semantic color schema: - `default` — Standard card appearance - `success` — Green theme for positive outcomes diff --git a/src/components/content/PrismCode/PrismCode.docs.mdx b/src/components/content/PrismCode/PrismCode.docs.mdx index 4ca023af7..0078c2fbe 100644 --- a/src/components/content/PrismCode/PrismCode.docs.mdx +++ b/src/components/content/PrismCode/PrismCode.docs.mdx @@ -34,7 +34,7 @@ Supports [Base properties](/docs/getting-started-base-properties--docs). ## Theme -Syntax colors are driven by a dedicated, scheme-aware palette of `code-*` tokens. Each token uses a different hue so token classes stay visually separable, and every token meets at least **WCAG AA (4.5:1)** contrast against `#surface` in light, dark, and high-contrast schemes (AAA in HC). +Syntax colors are driven by a dedicated, schema-aware palette of `code-*` tokens. Each token uses a different hue so token classes stay visually separable, and every token meets at least **WCAG AA (4.5:1)** contrast against `#surface` in light, dark, and high-contrast schemas (AAA in HC). | Token | Hue | Used for | | --- | --- | --- | @@ -46,7 +46,7 @@ Syntax colors are driven by a dedicated, scheme-aware palette of `code-*` tokens | `#code-function` | pink | functions, class names, regex, variables | | `#code-attribute` | cyan | attribute names, properties, selectors, built-ins, keys | -Diff insertion (`+`) and deletion (`-`) intentionally re-use the existing `#success-*` / `#danger-*` ramps — both the line background tint and the foreground color adapt to the active scheme. +Diff insertion (`+`) and deletion (`-`) intentionally re-use the existing `#success-*` / `#danger-*` ramps — both the line background tint and the foreground color adapt to the active schema. ## Examples diff --git a/src/components/data/AGENTS.md b/src/components/data/AGENTS.md index 4f2142b89..7b3f1b0d6 100644 --- a/src/components/data/AGENTS.md +++ b/src/components/data/AGENTS.md @@ -9,7 +9,7 @@ Nothing under this directory may reference `measure`, `dimension`, `pivot`, `dri The escape hatches that keep this line holdable: - `column.header.menu` / `column.header.actions` take an opaque `ReactNode`, so a caller can mount an arbitrarily complex column menu without the kit knowing what is in it. `onColumnMenuAction(action, columnKey)` reports the pressed key back as a bare string. The single exception is the reserved sort keys, and only because sorting is a capability the table itself owns — `pin` and `hide` are deliberately NOT reserved, because there is no column pin/visibility state behind them to drive. -- `column.color` takes a hue rather than a resolved colour, so the kit derives the ramp and the contrast floor per scheme instead of trusting a caller's hex pair. See `column-tint.ts` and `src/tokens/color-theme.ts`. +- `column.color` takes a hue rather than a resolved colour, so the kit derives the ramp and the contrast floor per schema instead of trusting a caller's hex pair. See `column-tint.ts` and `src/tokens/color-theme.ts`. - `renderCellMenu`, `column.render`, `column.cellStyles` and `renderRow` cover the rest. If something seems to need domain knowledge in here, it needs a new generic prop instead. diff --git a/src/components/data/DataTable/DataTable.browser.test.tsx b/src/components/data/DataTable/DataTable.browser.test.tsx index 4a1d87243..11d867b04 100644 --- a/src/components/data/DataTable/DataTable.browser.test.tsx +++ b/src/components/data/DataTable/DataTable.browser.test.tsx @@ -590,7 +590,7 @@ describe('DataTable column menu visibility', () => { * The three things Cube Cloud's ag-grid version gets wrong are the three things * asserted here: banding survives inside a tinted column, the fill stays opaque * so a pinned column still occludes, and the text clears WCAG AA in every - * scheme rather than only the one it was picked in. + * schema rather than only the one it was picked in. */ describe('DataTable column colors', () => { const TINTED: CubeDataTableColumn[] = [ @@ -722,7 +722,7 @@ describe('DataTable column colors', () => { )!; const [even, odd] = cellsOf('revenue'); - // Both bands and the header. Glaze re-solves the tone per scheme against a + // Both bands and the header. Glaze re-solves the tone per schema against a // real contrast floor, which is exactly what a hex pair picked once in // light mode cannot do. for (const element of [even, odd, header]) { diff --git a/src/components/data/DataTable/DataTable.docs.mdx b/src/components/data/DataTable/DataTable.docs.mdx index 4a7b1f25a..821e861c3 100644 --- a/src/components/data/DataTable/DataTable.docs.mdx +++ b/src/components/data/DataTable/DataTable.docs.mdx @@ -401,7 +401,7 @@ A stale order is safe: keys for columns that no longer exist are ignored, and a `column.color` tints a whole column — header, cells and pinned totals. It takes a palette theme name, any CSS color, a `{ hue, saturation }` seed, or a `{ fill, text }` pair for full manual control. -Every form but the last is _derived_: only the hue and saturation are kept, and the tone ramp plus an AA/AAA text floor are re-solved for light, dark and high-contrast. A column cannot end up unreadable in a scheme nobody checked. +Every form but the last is _derived_: only the hue and saturation are kept, and the tone ramp plus an AA/AAA text floor are re-solved for light, dark and high-contrast. A column cannot end up unreadable in a schema nobody checked. ```tsx const columns = [ diff --git a/src/components/data/DataTable/DataTable.stories.tsx b/src/components/data/DataTable/DataTable.stories.tsx index a55d2e00d..b2b546f29 100644 --- a/src/components/data/DataTable/DataTable.stories.tsx +++ b/src/components/data/DataTable/DataTable.stories.tsx @@ -655,7 +655,7 @@ export const PersistedColumnLayout: Story = { * * A palette theme name (`'success'`, `'note'`, …) is the cheap form. Any CSS * colour works too: only its hue and saturation are kept, and the tone ramp plus - * an AA/AAA text floor are re-solved per scheme by Glaze. Flip the toolbar to + * an AA/AAA text floor are re-solved per schema by Glaze. Flip the toolbar to * dark or high contrast and every column stays readable — which is the point, * and the thing hand-picked hex pairs get wrong. * diff --git a/src/components/data/TableBase/column-tint.ts b/src/components/data/TableBase/column-tint.ts index 2adf4c6da..893edd4f0 100644 --- a/src/components/data/TableBase/column-tint.ts +++ b/src/components/data/TableBase/column-tint.ts @@ -75,7 +75,7 @@ function isThemeName(value: string): value is CubeTableColumnTheme { * * A status theme seeded by a COLOR needs nothing special here: its resolved seed already * carries that color's hue and chroma, so a tinted column follows it for free. Only the - * two numbers are passed on — a runtime tint re-derives its own lightness per scheme, so + * two numbers are passed on — a runtime tint re-derives its own lightness per schema, so * the seed's `color` and `colorTone` have nothing to say to it and are not part of * `ColorThemeConfig`. */ diff --git a/src/components/data/TableBase/types.ts b/src/components/data/TableBase/types.ts index d66f24121..1495ffbdd 100644 --- a/src/components/data/TableBase/types.ts +++ b/src/components/data/TableBase/types.ts @@ -233,7 +233,7 @@ export type CubeTableColumnTheme = * How a column is tinted. * * Every form but the last is *derived*: only a hue and a saturation are kept, and - * the tone ramp plus an `AA`/`AAA` text floor are re-solved per colour scheme. So + * the tone ramp plus an `AA`/`AAA` text floor are re-solved per colour schema. So * a column stays readable in light, dark and high contrast without the caller * checking — which is the part hand-picked hex pairs get wrong. */ @@ -248,7 +248,7 @@ export type CubeTableColumnColor = * Full manual control, as tasty colour strings (`'#note-surface'`, `'#purple.10'`). * * Nothing is derived and nothing is contrast-checked — this is the escape - * hatch, and readability in every scheme becomes the caller's problem. + * hatch, and readability in every schema becomes the caller's problem. * `fillBand` defaults to `fill`, which turns banding off for the column. */ | { fill: string; fillBand?: string; text?: string }; @@ -317,7 +317,7 @@ export interface CubeTableColumn { /** * Tints the column — header, cells and pinned totals — with an adaptive fill - * and a text colour solved to stay readable on it in every scheme. + * and a text colour solved to stay readable on it in every schema. * * Row banding survives: the tint carries its own band one tone step away, so * the stripe still reads down the column instead of being painted over. diff --git a/src/components/fields/Checkbox/Checkbox.tsx b/src/components/fields/Checkbox/Checkbox.tsx index a2c13ec65..40eaa8f09 100644 --- a/src/components/fields/Checkbox/Checkbox.tsx +++ b/src/components/fields/Checkbox/Checkbox.tsx @@ -89,7 +89,7 @@ const CheckboxElement = tasty({ // The check / minus icon is always rendered inside the box, so the // default color must be transparent when the checkbox is neither // checked nor indeterminate — otherwise the white stroke shows through - // the dark `#surface` fill in dark schemes (it used to be hidden + // the dark `#surface` fill in dark schemas (it used to be hidden // accidentally by the legacy white-on-white `fill: '#white'`). color: { '': '#clear', diff --git a/src/components/fields/ColorSwatchGroup/ColorSwatchGroup.tsx b/src/components/fields/ColorSwatchGroup/ColorSwatchGroup.tsx index df3666200..c276c03d3 100644 --- a/src/components/fields/ColorSwatchGroup/ColorSwatchGroup.tsx +++ b/src/components/fields/ColorSwatchGroup/ColorSwatchGroup.tsx @@ -32,7 +32,7 @@ export type CubeColorSwatchItem = string | { color: string; label?: string }; * Two rings drawn inside the swatch, as React Aria marks selection. Outer ring * first: earlier shadows paint over later ones, so this reads as 2bw of * `#surface-text` at the edge and 2bw of `#surface` within it. Two tones that - * flip with the scheme stay visible against any color the swatch holds. + * flip with the schema stay visible against any color the swatch holds. */ const SELECTED_RING = 'inset 0 0 0 2bw #surface-text, inset 0 0 0 4bw #surface'; diff --git a/src/components/fields/Picker/Picker.docs.mdx b/src/components/fields/Picker/Picker.docs.mdx index 7e43015e4..68efb605e 100644 --- a/src/components/fields/Picker/Picker.docs.mdx +++ b/src/components/fields/Picker/Picker.docs.mdx @@ -318,7 +318,7 @@ The trigger button supports various visual styles via the `type` prop: ### Trigger Button Themes -Control the color scheme with the `theme` prop: +Control the color schema with the `theme` prop: - **`default`** — Standard theme (default) - **`danger`** — Red/destructive color (also applied automatically when `isInvalid` is set) diff --git a/src/components/fields/Select/Select.stories.tsx b/src/components/fields/Select/Select.stories.tsx index 04a80bac1..eb1324a38 100644 --- a/src/components/fields/Select/Select.stories.tsx +++ b/src/components/fields/Select/Select.stories.tsx @@ -426,7 +426,7 @@ const options = [ * `surface` is a story-only arg, not a `Select` prop: it paints the container * the select sits on. `addon-backgrounds` is not installed (see the note in * `.storybook/preview.jsx`, which disables it so the body keeps its - * scheme-aware `#surface` fill), so `parameters.backgrounds` does nothing here + * schema-aware `#surface` fill), so `parameters.backgrounds` does nothing here * — the two stories that need a tinted ground paint it themselves, which also * keeps them correct in dark mode. */ diff --git a/src/components/fields/Select/Select.tsx b/src/components/fields/Select/Select.tsx index bae16f086..6059256dd 100644 --- a/src/components/fields/Select/Select.tsx +++ b/src/components/fields/Select/Select.tsx @@ -474,7 +474,7 @@ function Select( // Forwarded so a `special` trigger tells its action which SURFACE it // sits on: `CURRENT_ITEM_STYLES` only steps up to the stronger alpha // ramp on `theme=special`, and against that dark purple base the - // light-scheme alphas are almost invisible. It travels through + // light-schema alphas are almost invisible. It travels through // context rather than as a prop because the prop is what opts an // action out of `current` back to `clear`, and this one needs to stay // `current` so its label keeps inheriting. diff --git a/src/components/fields/color/color.ts b/src/components/fields/color/color.ts index 811f319f1..e4fe25ccb 100644 --- a/src/components/fields/color/color.ts +++ b/src/components/fields/color/color.ts @@ -361,7 +361,7 @@ export function detectFormat(input: string): ColorFormat | null { * Black or white — whichever the WCAG contrast ratio favors on the given * color. Keeps the popover preview label readable at any lightness. Returns a * literal hex rather than a token, because the answer is measured against this - * exact fill and must not adapt with the color scheme. + * exact fill and must not adapt with the color schema. */ export function getContrastingColor(color: ColorValue): '#000000' | '#ffffff' { const luminance = relativeLuminanceFromLinearRgb( diff --git a/src/components/layout/Board/Board.tsx b/src/components/layout/Board/Board.tsx index 55fdaf0a8..582cfe986 100644 --- a/src/components/layout/Board/Board.tsx +++ b/src/components/layout/Board/Board.tsx @@ -166,7 +166,7 @@ const A11yLayer = tasty({ // rectangle inset by the board's resolved padding and sized to the grid content // so the padding reads as a symmetric frame on every edge. Its size, position // and gradients come from the board's position params via inline `style`. The -// fill uses the `#border` token (via its CSS var) so it adapts to the scheme. +// fill uses the `#border` token (via its CSS var) so it adapts to the schema. const GridOverlayElement = tasty({ qa: 'BoardGridOverlay', styles: { diff --git a/src/components/layout/Board/WidgetHost.tsx b/src/components/layout/Board/WidgetHost.tsx index b57ec206b..f92d5d6ad 100644 --- a/src/components/layout/Board/WidgetHost.tsx +++ b/src/components/layout/Board/WidgetHost.tsx @@ -64,7 +64,7 @@ const WidgetElement = tasty({ // ring dimmed, so releasing the pointer only changes the strength of an edge // that is already there — no geometry shift, nothing to re-read. The border // is *not* dimmed with it: `#primary-border` is close to `#border` on a dark - // scheme, so fading it lands below the border a widget already has and the + // schema, so fading it lands below the border a widget already has and the // preview would read as weaker than doing nothing. border: { '': false, @@ -78,7 +78,7 @@ const WidgetElement = tasty({ '0 0 0 1bw #border', 'pre-selected': '0 0 0 1bw #primary.40', selected: '0 0 0 1bw #primary', - // `$dialog-shadow` uses Glaze `#shadow-lg`, which adapts to dark / high-contrast schemes. + // `$dialog-shadow` uses Glaze `#shadow-lg`, which adapts to dark / high-contrast schemas. 'drag | resizing': '$dialog-shadow', }, outline: { diff --git a/src/components/organisms/StatsCard/StatsCard.docs.mdx b/src/components/organisms/StatsCard/StatsCard.docs.mdx index 6a9319294..97352fee4 100644 --- a/src/components/organisms/StatsCard/StatsCard.docs.mdx +++ b/src/components/organisms/StatsCard/StatsCard.docs.mdx @@ -26,7 +26,7 @@ Extends Card with: - `placeContent` — `space-between` - `gap` — `1x` - `border` — none -- `shadow` — `$shadow` (design-system elevation token, scheme-aware via `#shadow-md`) +- `shadow` — `$shadow` (design-system elevation token, schema-aware via `#shadow-md`) - `padding` — `2.5x` ### Base Properties diff --git a/src/components/other/CubeLogo/CubeLogo.docs.mdx b/src/components/other/CubeLogo/CubeLogo.docs.mdx index f3c9cef41..9ea09d8b0 100644 --- a/src/components/other/CubeLogo/CubeLogo.docs.mdx +++ b/src/components/other/CubeLogo/CubeLogo.docs.mdx @@ -43,35 +43,35 @@ Every path is drawn with `currentColor`. Inheriting is the default and usually c ``` -There are **no baked-in brand hexes**, which is what lets the marks work on any surface and in any scheme. +There are **no baked-in brand hexes**, which is what lets the marks work on any surface and in any schema. -## Light and dark schemes +## Light and dark schemas The mark is two different drawings rather than one drawing recoloured — the dark variant is filled differently so it holds its weight against a dark surface. Both are rendered, and the `@dark` state swaps them in CSS. That means the swap costs no re-render, is correct during SSR and before hydration, and needs no prop in the normal case. It also means a snapshot of the DOM always contains both paths; assert on `[data-element="LightMark"]` / `[data-element="DarkMark"]` rather than on a single `path`. -### Pinning a scheme +### Pinning a schema -`@dark` is a _state_, and it is resolved against the **document**. Where the background is known but the document scheme does not describe it, pass `scheme` to pin one mark: +`@dark` is a _state_, and it is resolved against the **document**. Where the background is known but the document schema does not describe it, pass `schema` to pin one mark: ```jsx {/* A fixed-dark panel inside a light app */} - + {/* A themed region — `tokens` overrides token *values*, so it cannot reach a state */} - - + + ``` -Use it only when you know the surface. Omitting it is the right default: a pinned scheme stops following the user's own light/dark preference. +Use it only when you know the surface. Omitting it is the right default: a pinned schema stops following the user's own light/dark preference. ## Properties -- **`scheme`** `'light' | 'dark'` — Pin one mark instead of letting the `@dark` state pick. Omit it unless you know the surface; see [Pinning a scheme](#pinning-a-scheme) +- **`schema`** `'light' | 'dark'` — Pin one mark instead of letting the `@dark` state pick. Omit it unless you know the surface; see [Pinning a schema](#pinning-a-schema) - **`size`** — Maps onto `font-size`. Both axes on `CubeLogo`, height only on `CubeFullLogo` - **`color`** — Any color token. Both marks are drawn with `currentColor`, so this is inherited by default diff --git a/src/components/other/CubeLogo/CubeLogo.stories.tsx b/src/components/other/CubeLogo/CubeLogo.stories.tsx index 0f72e0bf6..57a0eb121 100644 --- a/src/components/other/CubeLogo/CubeLogo.stories.tsx +++ b/src/components/other/CubeLogo/CubeLogo.stories.tsx @@ -70,16 +70,16 @@ export const Colors: Story = { * `@dark` follows the *document*, so a panel whose background does **not** follow * it has to pin the mark itself. Both fills here are fixed (`#white` is a tasty * named color, `#surface-inverse` is `mode: 'fixed'`), so flipping the toolbar - * scheme switch must leave both panels exactly as they are. + * schema switch must leave both panels exactly as they are. */ -export const PinnedScheme: Story = { +export const PinnedSchema: Story = { render: () => ( - + - + ), diff --git a/src/components/other/CubeLogo/CubeLogo.tsx b/src/components/other/CubeLogo/CubeLogo.tsx index 7d014a0c4..d9410726a 100644 --- a/src/components/other/CubeLogo/CubeLogo.tsx +++ b/src/components/other/CubeLogo/CubeLogo.tsx @@ -9,7 +9,7 @@ import { CubeIconProps, Icon } from '../../../icons/Icon'; * The mark is two *different drawings*, not one drawing recoloured: the light * variant is an outlined cube, the dark variant is filled differently so it keeps * its weight against a dark surface. Both are rendered and swapped by the global - * `@dark` state rather than picked in JS, so the logo follows the scheme without a + * `@dark` state rather than picked in JS, so the logo follows the schema without a * re-render and works in SSR. * * Both marks are drawn with `currentColor`, so colour comes from the surrounding @@ -56,7 +56,7 @@ const MarkPaths = () => ( ); -const SCHEME_SWAP = { +const SCHEMA_SWAP = { LightMark: { display: { '': 'block', '@dark': 'none' }, }, @@ -68,13 +68,13 @@ const SCHEME_SWAP = { /** * Pin one mark instead of letting the `@dark` state choose. * - * Needed wherever the background is known but the *document* scheme does not + * Needed wherever the background is known but the *document* schema does not * describe it: a fixed-dark panel in a light app (`#surface-inverse`, the * `special` theme), an exported image, or a themed region — `tokens` overrides * token *values*, and `@dark` is a state, so a region preview cannot reach the * swap on its own. */ -const FORCED_SCHEME = { +const FORCED_SCHEMA = { light: { LightMark: { display: 'block' }, DarkMark: { display: 'none' }, @@ -88,14 +88,14 @@ const FORCED_SCHEME = { export interface CubeLogoProps extends CubeIconProps { /** * Force the light or dark mark. Omit it — the default — to follow the document - * scheme in CSS, which costs no re-render and is correct during SSR. + * schema in CSS, which costs no re-render and is correct during SSR. */ - scheme?: 'light' | 'dark'; + schema?: 'light' | 'dark'; } -/** Caller styles win; a forced scheme only overrides the swap itself. */ -function resolveLogoStyles({ scheme, styles }: CubeLogoProps) { - return scheme ? mergeStyles(FORCED_SCHEME[scheme], styles) : styles; +/** Caller styles win; a forced schema only overrides the swap itself. */ +function resolveLogoStyles({ schema, styles }: CubeLogoProps) { + return schema ? mergeStyles(FORCED_SCHEMA[schema], styles) : styles; } /** @@ -104,14 +104,14 @@ function resolveLogoStyles({ scheme, styles }: CubeLogoProps) { */ const CubeLogoElement = tasty(Icon, { qa: 'CubeLogo', - styles: SCHEME_SWAP, + styles: SCHEMA_SWAP, }); export const CubeLogo = forwardRef(function CubeLogo( props: CubeLogoProps, ref: ForwardedRef, ) { - const { scheme, styles, ...rest } = props; + const { schema, styles, ...rest } = props; return ( , ) { - const { scheme, styles, ...rest } = props; + const { schema, styles, ...rest } = props; return ( ', () => { - it('renders both scheme marks so the swap is CSS-only', () => { + it('renders both schema marks so the swap is CSS-only', () => { renderWithRoot(); const el = screen.getByTestId('CubeLogo'); @@ -20,8 +20,8 @@ describe('', () => { it.each([ ['light', 'block', 'none'], ['dark', 'none', 'block'], - ] as const)('pins the %s mark when asked', (scheme, light, dark) => { - renderWithRoot(); + ] as const)('pins the %s mark when asked', (schema, light, dark) => { + renderWithRoot(); const el = screen.getByTestId('CubeLogo'); const mark = (name: string) => getComputedStyle(el.querySelector(`[data-element="${name}"]`)!).display; @@ -30,10 +30,10 @@ describe('', () => { expect(mark('DarkMark')).toBe(dark); }); - it('keeps scheme off the DOM', () => { - renderWithRoot(); + it('keeps schema off the DOM', () => { + renderWithRoot(); - expect(screen.getByTestId('CubeLogo')).not.toHaveAttribute('scheme'); + expect(screen.getByTestId('CubeLogo')).not.toHaveAttribute('schema'); }); it('drives sizing from the size prop', () => { diff --git a/src/components/other/NoDataIcon/NoDataIcon.stories.tsx b/src/components/other/NoDataIcon/NoDataIcon.stories.tsx index 2ba900ae7..3edfa6242 100644 --- a/src/components/other/NoDataIcon/NoDataIcon.stories.tsx +++ b/src/components/other/NoDataIcon/NoDataIcon.stories.tsx @@ -1,9 +1,9 @@ import { Meta, StoryObj } from '@storybook/react-vite'; import { - withDarkScheme, + withDarkSchema, withHighContrast, -} from '../../../stories/decorators/withColorScheme'; +} from '../../../stories/decorators/withColorSchema'; import { Text } from '../../content/Text'; import { Flow } from '../../layout/Flow'; import { Space } from '../../layout/Space'; @@ -64,11 +64,11 @@ export const WithLoadingAnimation: Story = { /** * The three faces are pinned by a contrast floor against `#surface`, so they - * hold the same separation from the page in every scheme rather than flattening + * hold the same separation from the page in every schema rather than flattening * out in dark — this story and the next are what that claim is checked against. */ -export const DarkScheme: Story = { - decorators: [withDarkScheme], +export const DarkSchema: Story = { + decorators: [withDarkSchema], }; export const HighContrast: Story = { diff --git a/src/components/overlays/Tooltip/Tooltip.docs.mdx b/src/components/overlays/Tooltip/Tooltip.docs.mdx index 81551cc9b..095ed5fad 100644 --- a/src/components/overlays/Tooltip/Tooltip.docs.mdx +++ b/src/components/overlays/Tooltip/Tooltip.docs.mdx @@ -49,7 +49,7 @@ Display container for tooltip content. Has a directional arrow dependent on its - **`showIcon`** `boolean` — Controls whether an icon should be displayed in the tooltip - **`placement`** `PlacementAxis` (default: `'top'`) — Position of the tooltip relative to its trigger. Options: `top`, `bottom`, `left`, `right` - **`isMaterial`** `boolean` — Enables material-style tooltip with auto pointer events -- **`isLight`** `boolean` — Uses light color scheme for the tooltip +- **`isLight`** `boolean` — Uses light color schema for the tooltip - **`isOpen`** `boolean` — Controlled open state of the tooltip - **`defaultOpen`** `boolean` — Whether the tooltip is open by default (uncontrolled) - **`onOpenChange`** `(isOpen: boolean) => void` — Callback when the tooltip open state changes @@ -85,7 +85,7 @@ These properties allow direct style application without using the `styles` prop: The `mods` property on the tooltip accepts the following modifiers you can override: - **`material`** `boolean` — Enables material-style tooltip with pointer events -- **`light`** `boolean` — Uses light color scheme instead of dark +- **`light`** `boolean` — Uses light color schema instead of dark - **`open`** `boolean` — Indicates whether the tooltip is currently visible ## Usage Patterns diff --git a/src/components/overlays/Tooltip/Tooltip.tsx b/src/components/overlays/Tooltip/Tooltip.tsx index aba786e5b..67878da53 100644 --- a/src/components/overlays/Tooltip/Tooltip.tsx +++ b/src/components/overlays/Tooltip/Tooltip.tsx @@ -28,18 +28,18 @@ export type { AriaTooltipProps }; const TooltipElement = tasty({ styles: { display: 'block', - // The DEFAULT (dark) tooltip is intentionally scheme-invariant: + // The DEFAULT (dark) tooltip is intentionally schema-invariant: // `#surface-inverse.85` + `#white` keep it the "always dark" tooltip in - // light, dark, and high-contrast schemes — matching the legacy `#dark.85` + // light, dark, and high-contrast schemas — matching the legacy `#dark.85` // + `#white` design. // // The `light` variant uses the adaptive surface tokens (`#surface` + - // `#surface-text-soft`) so it follows the page scheme. This restores the + // `#surface-text-soft`) so it follows the page schema. This restores the // softer legacy `#dark-02` look in light mode (cr≈9.2) and stays AA-safe // in dark mode (the previous fixed `#surface-inverse` was much darker // than the legacy `#dark-02`, and pairing the fixed `#white` fill with // the adaptive `#surface-text-soft` text — the LEGACY_ALIASES port of - // `#dark-02` — would collapse to cr≈1.8 in dark schemes; using `#surface` + // `#dark-02` — would collapse to cr≈1.8 in dark schemas; using `#surface` // for the fill keeps both ends of the pair adapting together). fill: { '': '#surface-inverse.85', diff --git a/src/components/overlays/Tooltip/TooltipProvider.docs.mdx b/src/components/overlays/Tooltip/TooltipProvider.docs.mdx index 00be860de..3ae79361c 100644 --- a/src/components/overlays/Tooltip/TooltipProvider.docs.mdx +++ b/src/components/overlays/Tooltip/TooltipProvider.docs.mdx @@ -23,7 +23,7 @@ A convenience wrapper that combines `TooltipTrigger` and `Tooltip` into a single - **`delay`** `number` (default: `250`) — The delay time in ms for the tooltip to show up - **`closeDelay`** `number` (default: `500`) — The delay time in ms for the tooltip to close - **`isMaterial`** `boolean` — Enables material-style tooltip with auto pointer events -- **`isLight`** `boolean` — Uses light color scheme for the tooltip +- **`isLight`** `boolean` — Uses light color schema for the tooltip - **`isOpen`** `boolean` — Controlled open state of the tooltip - **`defaultOpen`** `boolean` — Whether the tooltip is open by default (uncontrolled) - **`onOpenChange`** `(isOpen: boolean) => void` — Callback when the tooltip open state changes diff --git a/src/components/status/LoadingAnimation/LoadingAnimation.stories.tsx b/src/components/status/LoadingAnimation/LoadingAnimation.stories.tsx index 0a1bb5738..d11957ad8 100644 --- a/src/components/status/LoadingAnimation/LoadingAnimation.stories.tsx +++ b/src/components/status/LoadingAnimation/LoadingAnimation.stories.tsx @@ -1,9 +1,9 @@ import { Meta, StoryFn } from '@storybook/react-vite'; import { - withDarkScheme, + withDarkSchema, withHighContrast, -} from '../../../stories/decorators/withColorScheme'; +} from '../../../stories/decorators/withColorSchema'; import { baseProps } from '../../../stories/lists/baseProps'; import { @@ -32,9 +32,9 @@ Large.args = { size: 'large', }; -export const DarkScheme = Template.bind({}); -DarkScheme.args = {}; -DarkScheme.decorators = [withDarkScheme]; +export const DarkSchema = Template.bind({}); +DarkSchema.args = {}; +DarkSchema.decorators = [withDarkSchema]; export const HighContrast = Template.bind({}); HighContrast.args = {}; diff --git a/src/data/item-themes.test.ts b/src/data/item-themes.test.ts index 35851e9c7..5de706028 100644 --- a/src/data/item-themes.test.ts +++ b/src/data/item-themes.test.ts @@ -48,7 +48,7 @@ describe('ITEM_VARIANTS', () => { * authored number goes up. See `CURRENT_OUTLINE_STYLES`. * * And `current.clear` steps its enabled states through the custom properties - * of `CURRENT_ITEM_RAMP` — one per scheme and surface — while its disabled + * of `CURRENT_ITEM_RAMP` — one per schema and surface — while its disabled * chip is a plain alpha, so the two are not comparable as strings at all. */ const CURRENT = [ diff --git a/src/data/item-themes.ts b/src/data/item-themes.ts index 9d49bdbaf..0d5b527be 100644 --- a/src/data/item-themes.ts +++ b/src/data/item-themes.ts @@ -81,8 +81,8 @@ export const DEFAULT_PRIMARY_STYLES: Styles = { // The brand ramp `accent-surface` → `-2` → `-3` gives a monotonically // increasing contrast against `#surface` (cr ≈ 4.5 → 4.8 → 5.2 in light, // similar in dark), so hover and pressed read visibly *darker* than the - // default state in both schemes. Disabled uses the brand-tinted, - // scheme-symmetric chip (`accent-disabled-surface` cr ≈ 1.4 vs surface) + // default state in both schemas. Disabled uses the brand-tinted, + // schema-symmetric chip (`accent-disabled-surface` cr ≈ 1.4 vs surface) // so the muted state stays identifiable as a brand color. fill: { '': '#surface #primary-accent-surface', @@ -786,10 +786,10 @@ export const NOTE_ITEM_STYLES: Styles = { // Every color here resolves to a fixed-mode value (built-in `#white`, the // standalone `#special-*` theme in `src/tokens/palette.ts`, or `transparent`). // `mode: 'fixed'` makes the resolved OKHSL identical in light, dark, and -// high-contrast, so the special theme renders the same regardless of scheme. +// high-contrast, so the special theme renders the same regardless of schema. // The only intentionally adaptive colors are `VALIDATION_STYLES.border` // (`#danger-accent-text` / `#success-accent-text`) — validation state is allowed to follow -// the active scheme. +// the active schema. export const SPECIAL_PRIMARY_STYLES: Styles = { // Focus ring uses `#special-accent-text` — a fixed-mode dark-purple that // stays identical across light/dark/HC, matching the special theme's @@ -838,7 +838,7 @@ export const SPECIAL_OUTLINE_STYLES: Styles = { // alpha distinct sidesteps the collision. // // Focus ring uses the fixed-mode `#special-accent-text` so the indicator - // stays scheme-invariant alongside the rest of the special theme — see + // stays schema-invariant alongside the rest of the special theme — see // `SPECIAL_PRIMARY_STYLES.outline` for the full rationale. outline: { '': '0 #special-accent-text.0', @@ -916,7 +916,7 @@ export const SPECIAL_CLEAR_STYLES: Styles = { // the white-alpha variants are solved for. // // Focus ring uses the fixed-mode `#special-accent-text` so the indicator - // stays scheme-invariant alongside the rest of the special theme — see + // stays schema-invariant alongside the rest of the special theme — see // `SPECIAL_PRIMARY_STYLES.outline` for the full rationale. outline: { '': '0 #special-accent-text.0', @@ -1020,8 +1020,8 @@ export const SPECIAL_ITEM_STYLES: Styles = { // written inline in `fill`. Two reasons: // // 1. Unlike the brand tokens, `#current` alphas do NOT adapt to the color -// scheme, so one ramp cannot serve both. Each step therefore carries the base -// entry for the light scheme and an `@dark` counterpart. +// schema, so one ramp cannot serve both. Each step therefore carries the base +// entry for the light schema and an `@dark` counterpart. // 2. Writing both ramps straight into one `fill` map would put twelve alpha // values in a single state-map, and Tasty's `mergeEntriesByValue` pass // coalesces any two equal value strings into one OR-entry at the group's max @@ -1030,7 +1030,7 @@ export const SPECIAL_ITEM_STYLES: Styles = { // the constraint that `SPECIAL_OUTLINE_STYLES` documents the hard way. // // THE DARK STEPS ARE DERIVED, NOT AUTHORED. The same alpha is not the same step -// in both schemes, and the direction is the opposite of what it looks like: near +// in both schemas, and the direction is the opposite of what it looks like: near // the dark end of the scale a small sRGB move is a large perceptual one, so a // light tint on a dark surface reads STRONGER than the same tint of a dark label // on a light page. Each `@dark` value is therefore solved so its OKHST *tone* @@ -1050,11 +1050,11 @@ export const SPECIAL_ITEM_STYLES: Styles = { // because tasty computes the mix percentage as `parseFloat(alpha) * 100`, and // `.132` lands on `13.200000000000001%` in the emitted CSS. // -// Measured against `#surface` / `#surface-text` in each scheme, which is the only +// Measured against `#surface` / `#surface-text` in each schema, which is the only // tractable calibration: `current` paints from an arbitrary inherited color over // an arbitrary container, so a single ramp cannot be exact for all of them. The // neutral page pair is the common case, and matching it is what keeps the two -// schemes recognisably the same ramp. Re-derive with Glaze's `oklabToOkhsl` + +// schemas recognisably the same ramp. Re-derive with Glaze's `oklabToOkhsl` + // `okhslToOkhst` if the neutral tokens move. // // Note that the label keeps its own margin throughout: the weakest dark step @@ -1289,7 +1289,7 @@ export const CURRENT_OUTLINE_2_STYLES: Styles = { // The states are the second fill layer. There is no lighter or darker sibling // of an arbitrary inherited color to step to — the brand ramps walk // `accent-surface` → `-2` → `-3` — so hover and pressed lay a translucent -// `#black` over the same base instead, which darkens in both schemes and so +// `#black` over the same base instead, which darkens in both schemas and so // keeps the same monotonic direction the brand primaries have. // // The label CANNOT go through `color`. `#current` compiles to the literal @@ -1305,9 +1305,9 @@ export const CURRENT_PRIMARY_STYLES: Styles = { ...CURRENT_FOCUS_RING, // Every other `primary` rims its fill with a lighter sibling // (`accent-surface-border` over `accent-surface`, cr 1.48 against it in both - // schemes). An arbitrary inherited color has no such sibling, so the rim comes + // schemas). An arbitrary inherited color has no such sibling, so the rim comes // from the same token the label does — the one color guaranteed to sit on the - // opposite side of the fill in either scheme, and the one a container can + // opposite side of the fill in either schema, and the one a container can // redirect, so the rim cannot come apart from the label it edges. `.25` // measures cr 1.82 in light and 1.55 in dark against the fill: the brand rim's // presence, a shade more so in light, where `current` has no other edge cue. diff --git a/src/data/themes.ts b/src/data/themes.ts index e83247804..db27bf1bd 100644 --- a/src/data/themes.ts +++ b/src/data/themes.ts @@ -13,7 +13,7 @@ // top. We use `#-accent-surface` (fixed mode) which is // anchored to the fixed-white `#-accent-surface-text` // with `contrast: [4.5, 7]`, so white-text-on-fill is -// guaranteed WCAG AA (4.5) / AAA (7) in every scheme — the +// guaranteed WCAG AA (4.5) / AAA (7) in every schema — the // same brand-pill design `special` uses. (Pre-glaze this slot // resolved to `#-accent-text`, which is anchored to // surface with mode 'auto' for "readable text on surface" — diff --git a/src/stories/AdvancedStates.stories.tsx b/src/stories/AdvancedStates.stories.tsx index d7c802807..a077b16eb 100644 --- a/src/stories/AdvancedStates.stories.tsx +++ b/src/stories/AdvancedStates.stories.tsx @@ -223,10 +223,10 @@ Local definitions take precedence over global ones. }; // ============================================================================= -// Color Scheme (Light/Dark Mode) +// Color Schema (Light/Dark Mode) // ============================================================================= -const ColorSchemeBox = tasty({ +const ColorSchemaBox = tasty({ styles: { padding: '3x', radius: '2r', @@ -245,19 +245,19 @@ const ColorSchemeBox = tasty({ }, }); -export const ColorScheme: StoryObj = { +export const ColorSchema: StoryObj = { render: () => ( - Adapts to system color scheme (prefers-color-scheme) + Adapts to system color schema (prefers-color-scheme) - + Light mode: white background, dark text
Dark mode: dark background, white text
-
+ Change your system appearance settings to see the effect @@ -270,7 +270,7 @@ export const ColorScheme: StoryObj = { Use \`@media(prefers-color-scheme: dark)\` to style based on system color preference: \`\`\`tsx -const ColorSchemeBox = tasty({ +const ColorSchemaBox = tasty({ styles: { fill: { '': '#white', diff --git a/src/stories/Colors.docs.mdx b/src/stories/Colors.docs.mdx index fad6bd068..202752320 100644 --- a/src/stories/Colors.docs.mdx +++ b/src/stories/Colors.docs.mdx @@ -6,7 +6,7 @@ import * as ColorsStories from './Colors.stories'; # Colors -Cube UI Kit color tokens are generated by Glaze from a shared OKHST palette. Every adaptive token includes light, dark, and high-contrast variants. Change the active scheme in the Storybook toolbar to review each example. +Cube UI Kit color tokens are generated by Glaze from a shared OKHST palette. Every adaptive token includes light, dark, and high-contrast variants. Change the active schema in the Storybook toolbar to review each example. The colors shown here come from the default seeds. Those seeds — brand hue, saturation, per-status hues — are tunable at runtime; see [Theming](/docs/getting-started-theming--docs) for a live playground. @@ -70,7 +70,7 @@ Syntax tokens are calibrated as a group against `#surface`. Use the semantic tok ## Borders and shadows -Borders use the `1bw` design-system width. Shadow geometry comes from `src/tokens/shadows.ts`; the `#shadow-sm`, `#shadow-md`, and `#shadow-lg` colors adapt with the active scheme. +Borders use the `1bw` design-system width. Shadow geometry comes from `src/tokens/shadows.ts`; the `#shadow-sm`, `#shadow-md`, and `#shadow-lg` colors adapt with the active schema. diff --git a/src/stories/Colors.stories.tsx b/src/stories/Colors.stories.tsx index c3a578780..22f9b2c94 100644 --- a/src/stories/Colors.stories.tsx +++ b/src/stories/Colors.stories.tsx @@ -650,7 +650,7 @@ export const CodeSyntax: Story = { render: () => ( @@ -684,7 +684,7 @@ export const BordersAndShadows: Story = { render: () => (
Borders diff --git a/src/stories/Introduction.docs.mdx b/src/stories/Introduction.docs.mdx index 5f6b5d761..0395c7bb7 100644 --- a/src/stories/Introduction.docs.mdx +++ b/src/stories/Introduction.docs.mdx @@ -21,7 +21,7 @@ Cube UI Kit is built on two independent open-source projects: - [Tasty](https://tasty.style) is the declarative styling engine. It turns token-aware style objects, responsive values, and component states into conflict-free CSS. - [Glaze](https://github.com/tenphi/glaze) generates the semantic color palette. A small set of brand and status seeds resolves into light, dark, and high-contrast token sets. -Use the [Theme Builder](/story/getting-started-theming--theme-builder) to tune a palette and preview the result across schemes and contrast levels. +Use the [Theme Builder](/story/getting-started-theming--theme-builder) to tune a palette and preview the result across schemas and contrast levels. ## Get started diff --git a/src/stories/Theming.docs.mdx b/src/stories/Theming.docs.mdx index eeae1f21d..53c3913e9 100644 --- a/src/stories/Theming.docs.mdx +++ b/src/stories/Theming.docs.mdx @@ -6,7 +6,7 @@ import * as ThemingStories from './Theming.stories'; # Theming -The whole palette is generated by [Glaze](https://github.com/tenphi/glaze) from a handful of seeds, one per **zone**: the accent, the base, and each of the four status themes. Those seeds are tunable at runtime — change one and every token re-resolves, in light, dark, and high-contrast schemes at once. +The whole palette is generated by [Glaze](https://github.com/tenphi/glaze) from a handful of seeds, one per **zone**: the accent, the base, and each of the four status themes. Those seeds are tunable at runtime — change one and every token re-resolves, in light, dark, and high-contrast schemas at once. Every zone takes the same seed, and it comes in two forms: a **color**, which is usually what you actually have, or the **numbers** behind one. Never both — a zone is seeded one way or the other. See [One seed, two ways to spell it](#one-seed-two-ways-to-spell-it). @@ -139,7 +139,7 @@ First, **exactness is scoped to the light, normal-contrast variant.** That is th Sizing those two the same is a mistake worth naming, because this palette made it. In light `surface` **is** white, so one measurement is both constraints at once and Lc 45 looks right for the pair. In dark the page is near-black and the same number becomes a demand that a filled shape reach text contrast against it — which nothing in the palette meets, the shipped fill included. Since a floor can only lighten, the surplus flattened the dark half of the tone axis onto a single value: every brand darker than the floor came out the same colour. The page floor is now sized for a shape and the label is guaranteed separately, by a cap on the seed's tone. -They are APCA rather than WCAG on purpose. A single WCAG ratio means two different things depending on the scheme: measured across twelve hues, a fill sitting exactly at 3:1 comes out at Lc 56 in light but only Lc 23 in dark. That is why light brands kept getting crushed while dark ones sailed through the same rule. **A consequence to state plainly: the emitted fill can sit below WCAG 3:1.** `#0EA5E9` renders at 2.77:1 against a white page and is correct there — the Lc is the guarantee, not the ratio. +They are APCA rather than WCAG on purpose. A single WCAG ratio means two different things depending on the schema: measured across twelve hues, a fill sitting exactly at 3:1 comes out at Lc 56 in light but only Lc 23 in dark. That is why light brands kept getting crushed while dark ones sailed through the same rule. **A consequence to state plainly: the emitted fill can sit below WCAG 3:1.** `#0EA5E9` renders at 2.77:1 against a white page and is correct there — the Lc is the guarantee, not the ratio. High contrast does **not** escalate the page floor. It is a request for separation over brand, but not for separation from the page: the same fill carries the label, and driving it off the page drives the label off it. The shipped ladder makes the same trade and lands the other side of it, ending up with _less_ page separation in high contrast than in normal, because it darkens the fill toward its label. What does escalate is the label cap, which searches the high-contrast variants too. @@ -179,7 +179,7 @@ setPaletteConfig({ surfaceMode: 'tinted', base: { saturation: 25 } }); Two tones is not a lightness change you would name — it is _room_. Everything below `surface` is positioned relative to it, so the ladder, the borders and the text ramp all follow, and the text's `['AA','AAA']` floors re-solve against the new background rather than drifting. The `code-*` family follows too: its mirrored `surface` exists to be the page, so it tracks both the tone and the base seed. -**The tinted surfaces move with it.** A status theme's `surface` — and a runtime tint's, from `getColorTheme()` — is authored as an offset from the page's rather than as an absolute tone, and that offset is exactly the two tones `tinted` shifts by. Anchored absolutely they would land on the page's own new tone and a `note` banner would stop reading as a banner at all; anchored to the page they keep the separation the offset was chosen for, in both schemes. They also pick up a little more chroma there, being further from the extreme — so `tinted` makes a status surface easier to see, not harder. +**The tinted surfaces move with it.** A status theme's `surface` — and a runtime tint's, from `getColorTheme()` — is authored as an offset from the page's rather than as an absolute tone, and that offset is exactly the two tones `tinted` shifts by. Anchored absolutely they would land on the page's own new tone and a `note` banner would stop reading as a banner at all; anchored to the page they keep the separation the offset was chosen for, in both schemas. They also pick up a little more chroma there, being further from the extreme — so `tinted` makes a status surface easier to see, not harder. The tint stays subtle by construction. Near white the sRGB gamut has very little chroma to give, so `tinted` buys a surface that reads as warm or cool rather than one that reads as colored — which is the whole ask. For a genuinely colored panel, reach for a status theme's `surface` instead. @@ -343,7 +343,7 @@ The preview deliberately shows all three surface levels with their own text ramp ## Previewing a theme in a region -`setPaletteConfig()` re-themes the whole document, which is no use for a theme picker — you want several themes visible at once, or a dark preview inside a light page. `renderColorTokens()` does that: it resolves the palette for **one** config and **one** scheme and returns flat literal values, ready to apply to a subtree through a tasty `tokens` prop. +`setPaletteConfig()` re-themes the whole document, which is no use for a theme picker — you want several themes visible at once, or a dark preview inside a light page. `renderColorTokens()` does that: it resolves the palette for **one** config and **one** schema and returns flat literal values, ready to apply to a subtree through a tasty `tokens` prop. ```tsx import { renderColorTokens, tasty } from '@cube-dev/ui-kit'; @@ -351,20 +351,20 @@ import { renderColorTokens, tasty } from '@cube-dev/ui-kit'; const Region = tasty({ styles: { fill: '#surface', color: '#surface-text' } }); const preview = useMemo( - () => renderColorTokens({ accent: { hue: 210 }, scheme: 'dark' }), + () => renderColorTokens({ accent: { hue: 210 }, schema: 'dark' }), [], ); …everything in here renders in that theme…; ``` -The reason this needs its own API: the document palette emits **state maps** (`{ '': …, '@dark': …, '@hc': … }`), so a page can only ever show one scheme at a time — that is what `@dark` means. Collapsing the palette to a chosen scheme removes the conditionality, so several themes can coexist. +The reason this needs its own API: the document palette emits **state maps** (`{ '': …, '@dark': …, '@hc': … }`), so a page can only ever show one schema at a time — that is what `@dark` means. Collapsing the palette to a chosen schema removes the conditionality, so several themes can coexist. | Option | Meaning | | --- | --- | -| every `PaletteConfig` field | merged over the **current** config, so `{ scheme: 'dark' }` previews the active theme in dark | -| `scheme` | `'light'` (default) or `'dark'` | -| `highContrast` | resolve that scheme's high-contrast variant. Default `false` | +| every `PaletteConfig` field | merged over the **current** config, so `{ schema: 'dark' }` previews the active theme in dark | +| `schema` | `'light'` (default) or `'dark'` | +| `highContrast` | resolve that schema's high-contrast variant. Default `false` | Nothing is applied globally — the live palette and the stored config are untouched, so previews are safe to render anywhere. @@ -372,10 +372,10 @@ Note the one place the merge/replace split matters: this call **layers** over th Notes: -- Real components work inside a region. Every color in the kit compiles to a CSS custom property, so overriding those properties on one element re-colors its whole subtree with no scheme attribute and no second ``. +- Real components work inside a region. Every color in the kit compiles to a CSS custom property, so overriding those properties on one element re-colors its whole subtree with no schema attribute and no second ``. - The legacy aliases come along **by reference** (`'#dark': '#surface-text'`), and so do the shadow tokens and scrollbar colors, whose values embed a palette color. Tasty re-declares them on the region so each `var()` resolves against the region's own values; resolving them up front would freeze them to the outer theme instead. - Typography, spacing, sizes and layout are **not** included — nothing in their values references a color, so the region inherits them from ``. -- `contrastLevel` works per region, so the whole 0–100 ramp can be shown at once — something a document cannot do, since it is only ever at one level. Level `0` reproduces the normal tier and `100` the high-contrast tier exactly, in both schemes. `highContrast: true` still returns the genuine high-contrast resolution at any level below `100`, because the level moves the baseline and the tier escalates from where `'auto'` would put it; at `100` the two coincide and it returns the same colors as the normal variant. Note that tokens carrying a contrast _floor_ (most text) can sit still over part of the ramp — the floor is solved, not approximated, so the value only moves once the interpolated target does. Tokens positioned by a plain tone pair, like `#border`, step at every level. +- `contrastLevel` works per region, so the whole 0–100 ramp can be shown at once — something a document cannot do, since it is only ever at one level. Level `0` reproduces the normal tier and `100` the high-contrast tier exactly, in both schemas. `highContrast: true` still returns the genuine high-contrast resolution at any level below `100`, because the level moves the baseline and the tier escalates from where `'auto'` would put it; at `100` the two coincide and it returns the same colors as the normal variant. Note that tokens carrying a contrast _floor_ (most text) can sit still over part of the ramp — the floor is solved, not approximated, so the value only moves once the interpolated target does. Tokens positioned by a plain tone pair, like `#border`, step at every level. - Resolving a palette costs a few milliseconds. The last config is memoized, but `useMemo` on the caller side if you drive it from state. `renderPaletteTokens()` is the same thing without the aliases and companion tokens, if you want only the Glaze palette. @@ -409,6 +409,23 @@ subscribePaletteConfig(() => { `getPaletteConfig()` always returns the fully resolved form, so `getPaletteConfig().themes.danger.saturation` is a number even if nobody ever set it. `DEFAULT_PALETTE_CONFIG` is the shipped baseline in the same shape. +## Reading the ambient schema + +The palette config is what the app _asked for_; the schema and contrast tier are what the viewer is _seeing_ — `` / `prefers-color-scheme`, and `` / `prefers-contrast`. They are not part of the config, so `getPaletteConfig()` says nothing about them: + +```tsx +import { useHighContrast, useSchema } from '@cube-dev/ui-kit'; + +const schema = useSchema(); // 'light' | 'dark' +const isHighContrast = useHighContrast(); // boolean +``` + +Both resolve exactly as the `@dark` / `@hc` states do — attribute opt-in first, media query only when the attribute is absent — and re-render when either input changes. Outside React: `resolveSchema()`, `resolveHighContrast()` and `subscribeSchema(listener)`, which fires on a change to either condition. + +Reach for these only where CSS cannot go. Styling belongs in a state map (`{ '': …, '@dark': …, '@hc': … }`), which repaints on a schema flip with no re-render at all. What the states cannot reach is a surface that takes values rather than CSS — a Vega spec, a CodeMirror or Monaco theme, an iframe — or a control whose value _is_ the condition, like the Light/Dark and Normal/High-contrast selectors over the [theme builder](#theme-builder). + +Note the distinction from `contrastLevel`: the level is a config seed the app supplies and can read back, while high contrast is a viewing condition the document is in. They also pair up — `renderColorTokens({ schema, highContrast })` takes both, so a region can be previewed in the conditions the page is actually showing. + ## `` `` accepts the same config as a prop, applied during render so the first paint is already correct: @@ -434,7 +451,7 @@ This is a convenience wrapper over `setPaletteConfig()`, not a separate scope. * It also owns the accent's `saturation` outright — pinned to `100`, so a number passed _explicitly alongside_ it is inert and warned about (a number passed on its own turns pastel off instead). And it caps a color seed short of itself: the ceiling sits below where a saturated color lands, so `#EF4444` resolves to `#c47069`. Both are visible in the [Theme builder](#theme-builder), the first as a disabled slider and the second as the requested/resolved chips. -**Re-seeding costs about 10 ms.** Rebuilding the eight themes and re-solving ~156 tokens across four scheme variants is not free, though it is inside a frame. A manual `contrastLevel` costs the same — it runs the same four passes — except at level `100`, which skips the two high-contrast ones because the normal pass already produced those values. Setting the config to a value it already holds is free — it does not even bump the version. If you are driving it from a slider, let the input coalesce to frames (a pointer-driven `onChange` already does) rather than writing on every event. +**Re-seeding costs about 10 ms.** Rebuilding the eight themes and re-solving ~156 tokens across four schema variants is not free, though it is inside a frame. A manual `contrastLevel` costs the same — it runs the same four passes — except at level `100`, which skips the two high-contrast ones because the normal pass already produced those values. Setting the config to a value it already holds is free — it does not even bump the version. If you are driving it from a slider, let the input coalesce to frames (a pointer-driven `onChange` already does) rather than writing on every event. **If you call Glaze directly, tell the kit.** `glaze.configure({ darkTone })` and friends invalidate Glaze's own caches, but the kit's token maps are memoized against the palette config version: diff --git a/src/stories/Theming.stories.tsx b/src/stories/Theming.stories.tsx index 41719e3cd..9f1e3944b 100644 --- a/src/stories/Theming.stories.tsx +++ b/src/stories/Theming.stories.tsx @@ -31,14 +31,17 @@ import { Switch, Tag, tasty, + useHighContrast, usePaletteConfig, usePaletteVersion, + useSchema, } from '../index'; import type { Meta, StoryObj } from '@storybook/react-vite'; import type { Styles, Tokens } from '@tenphi/tasty'; import type { ReactNode } from 'react'; import type { + ColorSchema, PaletteConfig, PaletteNumericSeed, PaletteSeed, @@ -182,7 +185,7 @@ function useResetOnUnmount() { useEffect(() => resetPaletteConfig, []); } -/** Light-scheme value of a token, straight out of the resolved palette. */ +/** Light-schema value of a token, straight out of the resolved palette. */ function resolvedValue(name: string): string { const token = getPaletteTokens()[name] as Record | undefined; @@ -297,7 +300,7 @@ function ColorResolution({ resolved }: { resolved?: Tokens }) { // The preview's own tokens when there are any, so the chip answers "what did I get in // the variant I am looking at". `resolvedValue` only ever reports the document's - // light scheme, which is the wrong answer inside a dark preview. + // light schema, which is the wrong answer inside a dark preview. const valueOf = (name: string) => (resolved?.[name] as string | undefined) ?? resolvedValue(name); @@ -1176,7 +1179,7 @@ function CodePanel() { with numbers, and a muted palette would wash the whole block out. Tune it on its own with themes.code.saturation. Every token still keeps an AA/AAA floor against the real surface, so it stays - readable in every scheme. + readable in every schema. @@ -1189,87 +1192,6 @@ function CodePanel() { // Theme builder // ============================================================================ -/** - * A preview renders one concrete variant, so there is no `auto` here: `auto` is a - * *preference* ("follow the OS"), and a flat token value cannot express one. - */ -type SchemeChoice = 'light' | 'dark'; - -/** - * Scheme and contrast tier are **viewing conditions**, not theme settings — the - * same theme renders in all four of them, and a designer moves between them to - * check their work rather than to change it. They live over the preview for - * that reason, and nothing they do reaches the palette config. - */ -interface ViewingConditions { - scheme: SchemeChoice; - highContrast: boolean; -} - -/** What the document is showing right now, per the states `Root` registers. */ -function readViewingConditions(): ViewingConditions { - if (typeof document === 'undefined') { - return { scheme: 'light', highContrast: false }; - } - - const schema = document.documentElement.getAttribute('data-schema'); - const contrast = document.documentElement.getAttribute('data-contrast'); - - return { - scheme: - schema === 'dark' || schema === 'light' - ? schema - : matchMedia('(prefers-color-scheme: dark)').matches - ? 'dark' - : 'light', - highContrast: contrast - ? contrast === 'high' - : matchMedia('(prefers-contrast: more)').matches, - }; -} - -/** - * The document's own scheme and contrast tier, kept live. - * - * This is what makes the two switches over the preview *follow* without an - * `Auto` option to explain: they start on whatever the page is already showing, - * and until someone presses one they keep tracking it. Flipping Storybook's - * dark-mode toolbar with a light preview stranded inside a dark page is exactly - * the state that reads as broken. - * - * Both inputs have to be watched, because both can decide the answer: the - * attribute when `Root`'s opt-in is set, the media query otherwise. - */ -function useDocumentConditions(): ViewingConditions { - const [conditions, setConditions] = useState(readViewingConditions); - - useEffect(() => { - const sync = () => setConditions(readViewingConditions()); - const observer = new MutationObserver(sync); - const queries = [ - matchMedia('(prefers-color-scheme: dark)'), - matchMedia('(prefers-contrast: more)'), - ]; - - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ['data-schema', 'data-contrast'], - }); - queries.forEach((query) => query.addEventListener('change', sync)); - - // The attribute can land before the effect runs, so re-read on mount rather - // than trusting the value the first render happened to see. - sync(); - - return () => { - observer.disconnect(); - queries.forEach((query) => query.removeEventListener('change', sync)); - }; - }, []); - - return conditions; -} - // ---------------------------------------------------------------------------- // Controls // ---------------------------------------------------------------------------- @@ -2062,10 +1984,10 @@ const PREVIEW_NAV = [ function ThemePreview({ tokens, - scheme, + schema, }: { tokens: Tokens; - scheme: SchemeChoice; + schema: ColorSchema; }) { const [tab, setTab] = useState(PREVIEW_TABS[0]); @@ -2074,8 +1996,8 @@ function ThemePreview({ {/* The mark is two drawings swapped by the `@dark` state, which follows the *document* — tokens override token values, not states, so the preview has - to pin the scheme explicitly. Its colour is a token and needs no help. */} - + to pin the schema explicitly. Its colour is a token and needs no help. */} + Quarterly Revenue Draft @@ -2201,11 +2123,18 @@ function ThemePreview({ } function ThemeBuilderPage() { - const documentConditions = useDocumentConditions(); + // Schema and contrast tier are viewing conditions, not theme settings — the + // same theme renders in all four of them. The switches start on whatever the + // page is already showing and keep tracking it until someone presses one, so + // a light preview is never stranded inside a dark page. + const documentSchema = useSchema(); + const documentHighContrast = useHighContrast(); // `null` while the switch is still following the document. Deriving the shown // value from "override ?? document" is what lets a two-option control follow - // without an `Auto` option standing for the third state. - const [schemeOverride, setSchemeOverride] = useState( + // without an `Auto` option standing for the third state — and `auto` could not + // be previewed anyway: it is a *preference*, and a flat token value renders one + // concrete variant. + const [schemaOverride, setSchemaOverride] = useState( null, ); const [contrastOverride, setContrastOverride] = useState( @@ -2213,22 +2142,26 @@ function ThemeBuilderPage() { ); const version = usePaletteVersion(); - const scheme = schemeOverride ?? documentConditions.scheme; - const isHighContrast = contrastOverride ?? documentConditions.highContrast; + const schema = schemaOverride ?? documentSchema; + const isHighContrast = contrastOverride ?? documentHighContrast; // The level lives in the palette config now, so it needs no mention here: it // is part of the theme being built, and both of these pick it up. const tokens = useMemo( - () => renderColorTokens({ scheme, highContrast: isHighContrast }), - [scheme, isHighContrast, version], + () => renderColorTokens({ schema: schema, highContrast: isHighContrast }), + [schema, isHighContrast, version], ); // Resolved separately for the controls, which the *document* paints. Both // calls land on the same memo inside `renderPaletteTokens` — it holds all four // variants of one config — so the second one costs nothing. const documentTokens = useMemo( - () => renderColorTokens(documentConditions), - [documentConditions, version], + () => + renderColorTokens({ + schema: documentSchema, + highContrast: documentHighContrast, + }), + [documentSchema, documentHighContrast, version], ); return ( @@ -2249,10 +2182,10 @@ function ThemeBuilderPage() { setSchemeOverride(value as SchemeChoice)} + value={schema} + onChange={(value) => setSchemaOverride(value as ColorSchema)} > Light Dark @@ -2280,7 +2213,7 @@ function ThemeBuilderPage() { /> )} - + @@ -2313,7 +2246,7 @@ export const Playground: Story = { {DEFAULT_PALETTE_CONFIG.hue}° / saturation{' '} {DEFAULT_PALETTE_CONFIG.saturation}; the hue slider steps by 1°, so use Reset to get back to the exact shipped value. Flip the toolbar - dark-mode switch at any point — both schemes are generated from the + dark-mode switch at any point — both schemas are generated from the same seed. } diff --git a/src/stories/Usage.docs.mdx b/src/stories/Usage.docs.mdx index 83f38c19e..47557a74c 100644 --- a/src/stories/Usage.docs.mdx +++ b/src/stories/Usage.docs.mdx @@ -73,7 +73,7 @@ Defined in `src/tokens/sizes.ts`. Used for component heights and icon sizes. ## Shadow Tokens -Defined in `src/tokens/shadows.ts`. Shadow colors (`#shadow-sm`, `#shadow-md`, `#shadow-lg`) are generated by Glaze and adapt automatically to dark / high-contrast schemes. +Defined in `src/tokens/shadows.ts`. Shadow colors (`#shadow-sm`, `#shadow-md`, `#shadow-lg`) are generated by Glaze and adapt automatically to dark / high-contrast schemas. | Token | Value | | ---------------- | ------------------------- | @@ -96,26 +96,41 @@ Defined in `src/tokens/layout.ts`. All color tokens use `#name` syntax in styles. Opacity variants use `#name.NN` (e.g. `#surface-text.06` for 6% opacity). -The palette is generated by [`@tenphi/glaze`](https://github.com/tenphi/glaze) (see `src/tokens/palette.ts`). Every color token is emitted as a state map with `light`, `@dark`, and `@hc` (high-contrast) variants, so the entire UI Kit adapts to dark / high-contrast schemes automatically. +The palette is generated by [`@tenphi/glaze`](https://github.com/tenphi/glaze) (see `src/tokens/palette.ts`). Every color token is emitted as a state map with `light`, `@dark`, and `@hc` (high-contrast) variants, so the entire UI Kit adapts to dark / high-contrast schemas automatically. The seeds it is generated from — brand hue, saturation, per-status hues, `pastel`, `contrastLevel` — are tunable at runtime. See [Theming](/docs/getting-started-theming--docs). -### Color Schemes +### Color Schemas The `@dark` and `@hc` predefined states are wired up in `src/components/Root.tsx` to support both an attribute opt-in and the user's system preference: ```html - + - + ``` -When the attribute is absent, the scheme falls back to `@media (prefers-color-scheme: dark)` and `@media (prefers-contrast: more)`. +When the attribute is absent, the schema falls back to `@media (prefers-color-scheme: dark)` and `@media (prefers-contrast: more)`. + +#### Reading the schema from JS + +For styling, use the state map — `{ '': light, '@dark': dark, '@hc': hc }`. Two cases it cannot serve: a surface the stylesheet never reaches (a Vega spec, a CodeMirror / Monaco theme, an iframe), and a control whose value _is_ the ambient condition (a Light/Dark preview selector). For those, read the same two conditions from JS: + +```tsx +import { useHighContrast, useSchema } from '@cube-dev/ui-kit'; + +const schema = useSchema(); // 'light' | 'dark' — the JS answer to `@dark` +const isHighContrast = useHighContrast(); // boolean — the JS answer to `@hc` +``` + +Both follow the attribute opt-in first and the media query second, exactly as the states do, and re-render when either changes. Outside React: `resolveSchema()`, `resolveHighContrast()` and `subscribeSchema(listener)` (fires on either condition; re-read in the listener). This is the conditions half of the gap [`resolveTokenValue()`](#resolving-tokens-outside-css) closes for token _values_. + +Root-level only — there is no element-scoped or generic state reader, on purpose. Tasty's state vocabulary mixes element-local states (`hovered`, `pressed`, `disabled`) with ambient ones (`@media(…)`, `@root(…)`), and only the ambient half is answerable without an element. ### Surface & Text Vocabulary @@ -158,7 +173,7 @@ Tokens a **container** sets to steer a component that cannot resolve the color o ```jsx - + ``` @@ -166,7 +181,7 @@ Setting it moves the label, the icon slots and the chip's rim together. It also ### Accent System -Each themed color (default, primary, success, danger, warning, note) provides an `accent-*` family anchored to a fixed white "accent surface text". These tokens stay recognizable across schemes (used for branded buttons, CTAs, etc.): +Each themed color (default, primary, success, danger, warning, note) provides an `accent-*` family anchored to a fixed white "accent surface text". These tokens stay recognizable across schemas (used for branded buttons, CTAs, etc.): | Token (default theme; prefixed in others) | Role | | --- | --- | @@ -207,10 +222,10 @@ resetPaletteConfig(); Also available: `getPaletteConfig()`, `subscribePaletteConfig()`, the `usePaletteConfig()` hook, a `` prop, `DEFAULT_PALETTE_CONFIG`, and `invalidatePaletteTokens()` for when you drive `glaze.configure(...)` yourself after mount. The palette is process-global — one palette per process, not per tree or per request. -To theme a _region_ instead of the document — a theme preview, or a dark panel inside a light page — use `renderColorTokens()`. It resolves one config and one scheme to flat values you apply via a `tokens` prop, without touching the live palette: +To theme a _region_ instead of the document — a theme preview, or a dark panel inside a light page — use `renderColorTokens()`. It resolves one config and one schema to flat values you apply via a `tokens` prop, without touching the live palette: ```tsx - + ``` @@ -329,7 +344,9 @@ resolveTokenValues(['#surface', '#surface-text', '#border']); // keyed by the to resolvePresetValues('s3'); // { fontFamily: 'ui-monospace, …', fontSize: '14px', lineHeight: '20px', … } ``` -`useTokenValue()`, `useTokenValues()` and `usePresetValues()` are the same three readers as hooks, re-resolved whenever the palette is re-seeded or the scheme / contrast tier flips. The object `useTokenValues()` returns keeps its identity while the values do, so it is safe as a dependency of the theme object built from it. +`useTokenValue()`, `useTokenValues()` and `usePresetValues()` are the same three readers as hooks, re-resolved whenever the palette is re-seeded or the schema / contrast tier flips. The object `useTokenValues()` returns keeps its identity while the values do, so it is safe as a dependency of the theme object built from it. + +These resolve token _values_. For the ambient _conditions_ themselves — which schema and contrast tier the document is in — see [Reading the schema from JS](#reading-the-schema-from-js). So these helpers do not inspect the value. They read `$tokens-applied`, a marker the token block declares alongside the tokens, which answers the question that matters — _are the kit's tokens in effect on this element?_ Off that surface every read returns `null` (or `fallback`) and warns once in development; on it, the computed value is taken as the truth. diff --git a/src/stories/decorators/colorSchemeBridge.ts b/src/stories/decorators/colorSchemaBridge.ts similarity index 65% rename from src/stories/decorators/colorSchemeBridge.ts rename to src/stories/decorators/colorSchemaBridge.ts index e00cd57fd..c0b920107 100644 --- a/src/stories/decorators/colorSchemeBridge.ts +++ b/src/stories/decorators/colorSchemaBridge.ts @@ -5,24 +5,24 @@ * Two writers route through here: * - The `storybook-dark-mode` addon's toolbar toggle (via the * `DARK_MODE` channel event in `.storybook/preview.jsx`). - * - The per-story `withColorScheme` decorator in this folder. + * - The per-story `withColorSchema` decorator in this folder. * - * `overrideScheme` always wins over `toolbarScheme`, so a story explicitly + * `overrideSchema` always wins over `toolbarSchema`, so a story explicitly * forced into dark/light cannot be clobbered by the addon's async init. * * NOTE: lives under `src/stories/decorators/` (not `.storybook/`) so the * decorator can import it without crossing the TypeScript include boundary. */ -export type Scheme = 'light' | 'dark'; +export type Schema = 'light' | 'dark'; -let toolbarScheme: Scheme | null = null; -let overrideScheme: Scheme | null = null; +let toolbarSchema: Schema | null = null; +let overrideSchema: Schema | null = null; const apply = (): void => { if (typeof document === 'undefined') return; - const next = overrideScheme ?? toolbarScheme; + const next = overrideSchema ?? toolbarSchema; if (next == null) { document.documentElement.removeAttribute('data-schema'); @@ -32,16 +32,16 @@ const apply = (): void => { }; /** Set by the `storybook-dark-mode` channel listener in `preview.jsx`. */ -export const setToolbarScheme = (scheme: Scheme | null): void => { - toolbarScheme = scheme; +export const setToolbarSchema = (schema: Schema | null): void => { + toolbarSchema = schema; apply(); }; /** - * Set by the per-story `withColorScheme` decorator. + * Set by the per-story `withColorSchema` decorator. * Pass `null` to release the override and fall back to the toolbar value. */ -export const setSchemeOverride = (scheme: Scheme | null): void => { - overrideScheme = scheme; +export const setSchemaOverride = (schema: Schema | null): void => { + overrideSchema = schema; apply(); }; diff --git a/src/stories/decorators/withColorScheme.tsx b/src/stories/decorators/withColorSchema.tsx similarity index 69% rename from src/stories/decorators/withColorScheme.tsx rename to src/stories/decorators/withColorSchema.tsx index f32a6651f..1c5eab6f1 100644 --- a/src/stories/decorators/withColorScheme.tsx +++ b/src/stories/decorators/withColorSchema.tsx @@ -1,6 +1,6 @@ import { useLayoutEffect, useRef } from 'react'; -import { setSchemeOverride } from './colorSchemeBridge'; +import { setSchemaOverride } from './colorSchemaBridge'; import type { ReactElement } from 'react'; @@ -17,10 +17,10 @@ type StoryDecorator = ( /** * Drives the `data-schema` attribute on ``. - * - `'dark'` / `'light'` — force the corresponding scheme + * - `'dark'` / `'light'` — force the corresponding schema * - `'auto'` — clear the attribute and fall back to `prefers-color-scheme` */ -export type ColorScheme = 'light' | 'dark' | 'auto'; +export type ColorSchema = 'light' | 'dark' | 'auto'; /** * Drives the `data-contrast` attribute on ``. @@ -29,15 +29,15 @@ export type ColorScheme = 'light' | 'dark' | 'auto'; */ export type ContrastMode = 'normal' | 'high' | 'auto'; -export interface WithColorSchemeOptions { - /** Color scheme applied via ``. */ - scheme?: ColorScheme; +export interface WithColorSchemaOptions { + /** Color schema applied via ``. */ + schema?: ColorSchema; /** Contrast mode applied via ``. */ contrast?: ContrastMode; } /** - * Storybook decorator that switches a story into a different color scheme by + * Storybook decorator that switches a story into a different color schema by * driving the `data-schema` / `data-contrast` attributes on `` (the same * attributes the global `@dark` / `@hc` predefined states resolve against — * see `src/components/Root.tsx`). @@ -46,25 +46,25 @@ export interface WithColorSchemeOptions { * so flipping `data-schema` is enough to repaint the whole story canvas — no * extra wrappers needed. * - * `data-schema` writes go through `colorSchemeBridge` so the per-story + * `data-schema` writes go through `colorSchemaBridge` so the per-story * override always wins over the `storybook-dark-mode` toolbar (which writes * the same attribute via the channel listener in `.storybook/preview.jsx`). * `data-contrast` is unmanaged by the addon and stays a direct DOM write. * - * Implemented synchronously in `useLayoutEffect`, so the scheme switches + * Implemented synchronously in `useLayoutEffect`, so the schema switches * before paint (no flash). The bridge restores the toolbar value on unmount, * and `data-contrast` restores its prior literal value. * * @example * export const DarkVariant = MyTemplate.bind({}); - * DarkVariant.decorators = [withColorScheme({ scheme: 'dark' })]; + * DarkVariant.decorators = [withColorSchema({ schema: 'dark' })]; */ -export const withColorScheme = ( - options: WithColorSchemeOptions = {}, +export const withColorSchema = ( + options: WithColorSchemaOptions = {}, ): StoryDecorator => { - const { scheme, contrast } = options; + const { schema, contrast } = options; - const ColorSchemeDecorator: StoryDecorator = (Story) => { + const ColorSchemaDecorator: StoryDecorator = (Story) => { const previousContrastRef = useRef(null); useLayoutEffect(() => { @@ -72,10 +72,10 @@ export const withColorScheme = ( previousContrastRef.current = html.getAttribute('data-contrast'); - if (scheme === 'auto') { - setSchemeOverride(null); - } else if (scheme) { - setSchemeOverride(scheme); + if (schema === 'auto') { + setSchemaOverride(null); + } else if (schema) { + setSchemaOverride(schema); } if (contrast === 'auto') { @@ -88,8 +88,8 @@ export const withColorScheme = ( } return () => { - if (scheme) { - setSchemeOverride(null); + if (schema) { + setSchemaOverride(null); } if (contrast) { @@ -107,18 +107,18 @@ export const withColorScheme = ( return ; }; - (ColorSchemeDecorator as { displayName?: string }).displayName = - `WithColorScheme(${scheme ?? 'auto'},${contrast ?? 'auto'})`; + (ColorSchemaDecorator as { displayName?: string }).displayName = + `WithColorSchema(${schema ?? 'auto'},${contrast ?? 'auto'})`; - return ColorSchemeDecorator; + return ColorSchemaDecorator; }; -/** Convenience preset: switches the story into the dark scheme. */ -export const withDarkScheme: StoryDecorator = withColorScheme({ - scheme: 'dark', +/** Convenience preset: switches the story into the dark schema. */ +export const withDarkSchema: StoryDecorator = withColorSchema({ + schema: 'dark', }); -/** Convenience preset: switches the story into the high-contrast scheme. */ -export const withHighContrast: StoryDecorator = withColorScheme({ +/** Convenience preset: switches the story into the high-contrast schema. */ +export const withHighContrast: StoryDecorator = withColorSchema({ contrast: 'high', }); diff --git a/src/test/probe/config-guard.ts b/src/test/probe/config-guard.ts index fe58d1c60..514e27572 100644 --- a/src/test/probe/config-guard.ts +++ b/src/test/probe/config-guard.ts @@ -36,7 +36,7 @@ export function assertConfigApplied(): void { if (!states || !('@dark' in states)) { throw new Error( 'Probe harness: the `@dark` / `@hc` predefined states are missing, so ' + - "``'s module body did not run. Scheme-keyed styles would resolve " + + "``'s module body did not run. Schema-keyed styles would resolve " + 'wrong. Check what the harness imports before `components/Root`.', ); } diff --git a/src/test/probe/harness.browser.probe.tsx b/src/test/probe/harness.browser.probe.tsx index e4bb5698e..890793ca1 100644 --- a/src/test/probe/harness.browser.probe.tsx +++ b/src/test/probe/harness.browser.probe.tsx @@ -36,32 +36,32 @@ const input = JSON.parse(__PROBE_INPUT__) as ProbeInput & { computedProps?: string[]; rect?: string; screenshot?: boolean; - scheme?: 'light' | 'dark' | 'hc'; + schema?: 'light' | 'dark' | 'hc'; highContrast?: boolean; }; /** - * Drive the scheme the way a host app does — through the attributes on `` + * Drive the schema the way a host app does — through the attributes on `` * that the `@dark` / `@hc` predefined states resolve against (see `Root.tsx`). * Setting a token by hand would prove nothing about how the real cascade * behaves. * * The two axes are independent, so they are separate parameters: `@hc` is a - * contrast attribute that composes with either schema. `--scheme hc` stays + * contrast attribute that composes with either schema. `--schema hc` stays * accepted as the spelling Cube Cloud's probe uses, where it means light + high * contrast — but it cannot express dark + high contrast, which is a real palette * variant, so `--hc` is the flag that reaches all four. */ -function applyScheme(scheme: string | undefined, highContrast: boolean): void { +function applySchema(schema: string | undefined, highContrast: boolean): void { const root = document.documentElement; - if (scheme === 'dark') { + if (schema === 'dark') { root.setAttribute('data-schema', 'dark'); } - if (scheme === 'light' || scheme === 'hc') { + if (schema === 'light' || schema === 'hc') { root.setAttribute('data-schema', 'light'); } - if (scheme === 'hc' || highContrast) { + if (schema === 'hc' || highContrast) { root.setAttribute('data-contrast', 'high'); } } @@ -184,7 +184,7 @@ it('probe:browser', async () => { // pixel geometry rather than as visibly missing output. assertConfigApplied(); - applyScheme(input.scheme, Boolean(input.highContrast)); + applySchema(input.schema, Boolean(input.highContrast)); const view = render({null}); const baseline = captureCss(view.baseElement); @@ -258,7 +258,7 @@ it('probe:browser', async () => { const scope = view.baseElement.querySelector('[data-probe-scope]'); // `--canonical` applies on both tiers. It is what makes two runs comparable - // byte-for-byte, and a browser run is exactly where you would diff one scheme + // byte-for-byte, and a browser run is exactly where you would diff one schema // or viewport against another — accepting the flag and returning raw hashes // and `useId` counters would defeat the comparison silently. const normalise = (text: string) => @@ -337,8 +337,8 @@ it('probe:browser', async () => { mode: input.mode, tier: 'browser', ok: true, - scheme: `${input.scheme ?? 'default'}${ - input.highContrast && input.scheme !== 'hc' ? ' + high contrast' : '' + schema: `${input.schema ?? 'default'}${ + input.highContrast && input.schema !== 'hc' ? ' + high contrast' : '' }`, html, portalHtml, diff --git a/src/test/probe/harness.probe.tsx b/src/test/probe/harness.probe.tsx index dc99ef1b2..7275e1475 100644 --- a/src/test/probe/harness.probe.tsx +++ b/src/test/probe/harness.probe.tsx @@ -64,7 +64,7 @@ it('probe', async () => { if (input.mode === 'tokens') { writeResult(input, { mode: 'tokens', - scheme: `${input.tokenOptions?.scheme ?? 'light'}${ + schema: `${input.tokenOptions?.schema ?? 'light'}${ input.tokenOptions?.highContrast ? ' + high contrast' : '' }`, // Flat literal values for one variant. The legacy aliases come back BY @@ -72,7 +72,7 @@ it('probe', async () => { // preview re-resolves them against its own tokens — so they are reported // separately rather than being mistaken for resolved colors. resolved: renderColorTokens(input.tokenOptions), - // The same palette as tasty state maps keyed by scheme + // The same palette as tasty state maps keyed by schema // (`'' | '@dark' | '@hc' | '@dark & @hc'`), which is the four-variant view // a palette change has to be diffed across. A different shape from the // above, so it is labelled rather than merged. diff --git a/src/test/probe/io.ts b/src/test/probe/io.ts index c06b52e55..18d6c7f4d 100644 --- a/src/test/probe/io.ts +++ b/src/test/probe/io.ts @@ -43,7 +43,7 @@ export interface ProbeInput { */ snippetUrl?: string; styles?: Styles; - tokenOptions?: { scheme?: 'light' | 'dark'; highContrast?: boolean }; + tokenOptions?: { schema?: 'light' | 'dark'; highContrast?: boolean }; fullCss?: boolean; canonical?: boolean; } diff --git a/src/tokens/color-theme.test.ts b/src/tokens/color-theme.test.ts index 1eebc0167..7df39bbd3 100644 --- a/src/tokens/color-theme.test.ts +++ b/src/tokens/color-theme.test.ts @@ -13,7 +13,7 @@ import { setPaletteConfig, } from './palette-config'; -const SCHEMES = ['', '@dark', '@hc', '@dark & @hc'] as const; +const SCHEMAS = ['', '@dark', '@hc', '@dark & @hc'] as const; function wcag(background: string, foreground: string): number { const luminance = (value: string) => { @@ -61,7 +61,7 @@ describe('getColorTheme', () => { ); }); - it('covers all four scheme variants', () => { + it('covers all four schema variants', () => { const theme = getColorTheme({ hue: 200 }); for (const token of Object.values(theme.colors)) { @@ -71,12 +71,12 @@ describe('getColorTheme', () => { // `@media(prefers-color-scheme: dark)` keys and no high-contrast tier at // all — a silent failure, since the light values still render. expect(Object.keys(theme.tokens[token] as object).sort()).toEqual( - [...SCHEMES].sort(), + [...SCHEMAS].sort(), ); } }); - it('solves the text contrast in every scheme', () => { + it('solves the text contrast in every schema', () => { const theme = getColorTheme({ hue: 200 }); const band = theme.tokens[theme.colors['surface-2']] as Record< string, @@ -87,11 +87,11 @@ describe('getColorTheme', () => { string >; - for (const scheme of SCHEMES) { + for (const schema of SCHEMAS) { // The Cloud defect this exists to fix: there, contrast was solved once // against a white surface at pick time and never re-checked, so a dark - // scheme could invert both sides into an unreadable pair. - expect(wcag(band[scheme], text[scheme])).toBeGreaterThanOrEqual(4.5); + // schema could invert both sides into an unreadable pair. + expect(wcag(band[schema], text[schema])).toBeGreaterThanOrEqual(4.5); } }); @@ -104,10 +104,10 @@ describe('getColorTheme', () => { >; // The floor is solved against `surface-2`, which is the tighter of the two - // bands in both schemes — so `surface` clears it for free. That is the whole + // bands in both schemas — so `surface` clears it for free. That is the whole // reason the text is anchored there rather than to `surface`. - for (const scheme of SCHEMES) { - expect(wcag(base[scheme], text[scheme])).toBeGreaterThanOrEqual(4.5); + for (const schema of SCHEMAS) { + expect(wcag(base[schema], text[schema])).toBeGreaterThanOrEqual(4.5); } }); @@ -122,8 +122,8 @@ describe('getColorTheme', () => { // Banding that resolves to the same colour is not banding. This is also what // keeps the two out of tasty's value-coalescing trap when they end up in one // state map. - for (const scheme of SCHEMES) { - expect(band[scheme]).not.toBe(base[scheme]); + for (const schema of SCHEMAS) { + expect(band[schema]).not.toBe(base[schema]); } }); diff --git a/src/tokens/color-theme.ts b/src/tokens/color-theme.ts index 2b7734ded..fbcd14cf8 100644 --- a/src/tokens/color-theme.ts +++ b/src/tokens/color-theme.ts @@ -28,7 +28,7 @@ export interface ColorThemeConfig { * `okhsl()`, `okhst()`, `oklch()`. * * From a colour only the HUE and SATURATION are taken; the lightness is - * discarded and re-derived per scheme. That is what makes the result adaptive + * discarded and re-derived per schema. That is what makes the result adaptive * rather than a value that happens to work in one theme. */ hue: number | string; @@ -132,7 +132,7 @@ function stableStringify(value: unknown): string { * ``` * * The tone {@link colorSeed} also reads is dropped here on purpose: a tint theme - * re-derives its lightness per scheme, which is what makes it adaptive. The palette's + * re-derives its lightness per schema, which is what makes it adaptive. The palette's * The palette's accent zone keeps the tone, because a brand fill has to *be* the * colour. * diff --git a/src/tokens/colors.ts b/src/tokens/colors.ts index 016b10df7..e884c8f0e 100644 --- a/src/tokens/colors.ts +++ b/src/tokens/colors.ts @@ -36,21 +36,21 @@ const LEGACY_ALIASES: Styles = { '#dark-05': '#border', // Fixed-mode counterpart to `#dark`. Resolves to the same L≈12 surface - // but uses Glaze `mode: 'fixed'` so it does NOT invert in dark schemes. + // but uses Glaze `mode: 'fixed'` so it does NOT invert in dark schemas. // Use this whenever the design intentionally pins a dark color regardless - // of scheme. Points at `#special-surface` (`mode: 'fixed'`, L=12), + // of schema. Points at `#special-surface` (`mode: 'fixed'`, L=12), // emitted by the standalone `specialTheme` in `palette.ts` — the canonical // source of fixed-mode color tokens for `special`-variant components. '#fixed-dark': '#special-surface', // Fixed-mode counterpart to `#primary-text`. `#primary-text` is anchored // to `surface` with `mode: 'auto'`, so it flips to a *light* purple in - // dark schemes (correct on body content, which also inverts). When the + // dark schemas (correct on body content, which also inverts). When the // local fill is a fixed color instead (an always-white pill, etc.), the // adaptive text loses contrast (light purple on white) in dark mode. // Points at `#special-accent-text` (`mode: 'fixed'`, cr 6–8.5 vs fixed // white) — a dark purple readable on a white surface that stays put - // across schemes. + // across schemas. '#fixed-primary-text': '#special-accent-text', // ---- Misc neutral ---- @@ -60,7 +60,7 @@ const LEGACY_ALIASES: Styles = { '#dark-bg': '#surface-2', '#clear': 'transparent', - // Pink: independent hue, scheme-static (no Glaze adaptation). Kept as a raw + // Pink: independent hue, schema-static (no Glaze adaptation). Kept as a raw // literal rather than folded into a theme — nothing in the palette emits this // hue as a standalone token, and it is a documented public alias (see // `Usage.docs.mdx`, `tasty.config.ts`, and the `pink` key in @@ -70,7 +70,7 @@ const LEGACY_ALIASES: Styles = { // ---- Disabled state aliases ---- // `#disabled-surface` and `#disabled-surface-text` are emitted directly by - // the Glaze palette (`palette.ts`) as scheme-symmetric, contrast-driven + // the Glaze palette (`palette.ts`) as schema-symmetric, contrast-driven // tokens — no alias needed here. `#disabled` stays as a brand-tinted // backwards-compat anchor for the per-theme `#-disabled` aliases below. @@ -214,12 +214,12 @@ const COLOR_DEPENDENT_TOKENS: Styles = { }; /** - * Render every UI Kit color for one config and one scheme, as flat literal + * Render every UI Kit color for one config and one schema, as flat literal * values ready to apply to a **region** via a tasty `tokens` prop. * * ```tsx * const preview = useMemo( - * () => renderColorTokens({ hue: 210, scheme: 'dark' }), + * () => renderColorTokens({ hue: 210, schema: 'dark' }), * [], * ); * @@ -229,7 +229,7 @@ const COLOR_DEPENDENT_TOKENS: Styles = { * ``` * * Config fields merge over the *current* palette config, so - * `renderColorTokens({ scheme: 'dark' })` previews the active theme in dark + * `renderColorTokens({ schema: 'dark' })` previews the active theme in dark * without restating it. Nothing is applied globally — the live palette is * untouched. * diff --git a/src/tokens/palette-config.ts b/src/tokens/palette-config.ts index 627fea528..517a71c2c 100644 --- a/src/tokens/palette-config.ts +++ b/src/tokens/palette-config.ts @@ -177,7 +177,7 @@ export interface PaletteConfig { * and no further. * * Those floors are APCA, not WCAG, and the difference is deliberate: one WCAG ratio - * means two very different things by scheme (3:1 measures Lc 56 in light but only + * means two very different things by schema (3:1 measures Lc 56 in light but only * Lc 23 in dark), which crushed light brands while letting dark ones through. A * consequence worth stating plainly — **the emitted fill can sit below WCAG 3:1**. * `#0EA5E9` renders at 2.77:1 against a white page and is correct at that value; the diff --git a/src/tokens/palette.test.ts b/src/tokens/palette.test.ts index 5c5f70695..6bd6928e9 100644 --- a/src/tokens/palette.test.ts +++ b/src/tokens/palette.test.ts @@ -85,7 +85,7 @@ function dumpTokens(tokens: Styles): Record { return out; } -/** Pick one scheme variant out of every token's state map. */ +/** Pick one schema variant out of every token's state map. */ function variant(tokens: Styles, state: string): Record { const out: Record = {}; @@ -142,7 +142,7 @@ function hexOf(value: string): string { * * Reading the tone rather than the color is what lets a test say "this landed where the * seed asked" for a token whose hue and chroma are settled but whose lightness went - * through a scheme window. + * through a schema window. */ function toneOf(value: string): number { return ( @@ -238,7 +238,7 @@ describe('palette tokens', () => { expect(dump).toMatchSnapshot(); }); - it('emits every token in all four scheme variants by default', () => { + it('emits every token in all four schema variants by default', () => { expect(statesOf(getPaletteTokens())).toEqual([ '', '@dark', @@ -249,14 +249,14 @@ describe('palette tokens', () => { /** * The cube-face ramp's whole point is that a `LoadingAnimation` reads with the - * same weight in every scheme. It is stated as a WCAG floor against `surface` - * rather than as a tone delta precisely because the dark scheme resolves a + * same weight in every schema. It is stated as a WCAG floor against `surface` + * rather than as a tone delta precisely because the dark schema resolves a * delta inside the `darkTone` window and flattened the ramp to ~75% of its * light span. The snapshot above pins the emitted colors; this pins the * property that made them those colors, so a regression reads as "dark went * flat again" rather than as three changed oklch strings. */ - it('holds the cube faces at one contrast ratio in every scheme', () => { + it('holds the cube faces at one contrast ratio in every schema', () => { const tokens = getPaletteTokens(); const FLOORS = [1.2, 1.65, 2.4]; const HC_FLOORS = [1.35, 2.1, 3.2]; @@ -745,7 +745,7 @@ describe('setPaletteConfig', () => { const tuned = dumpTokens(getPaletteTokens()); - // Bit-identical, in all four scheme variants: nothing but the code + // Bit-identical, in all four schema variants: nothing but the code // saturation reaches these. expect(CODE_TOKENS.map((name) => tuned[name])).toEqual(before); expect(getCodeTheme().getConfig().pastel).toBe(false); @@ -1048,27 +1048,27 @@ describe('renderPaletteTokens', () => { return out; }; - expect({ ...renderPaletteTokens({ scheme: 'light' }) }).toEqual( + expect({ ...renderPaletteTokens({ schema: 'light' }) }).toEqual( variantOf(''), ); - expect({ ...renderPaletteTokens({ scheme: 'dark' }) }).toEqual( + expect({ ...renderPaletteTokens({ schema: 'dark' }) }).toEqual( variantOf('@dark'), ); expect({ - ...renderPaletteTokens({ scheme: 'light', highContrast: true }), + ...renderPaletteTokens({ schema: 'light', highContrast: true }), }).toEqual(variantOf('@hc')); expect({ - ...renderPaletteTokens({ scheme: 'dark', highContrast: true }), + ...renderPaletteTokens({ schema: 'dark', highContrast: true }), }).toEqual(variantOf('@dark & @hc')); }); it('renders a config the app is not using, without applying it', () => { const before = dumpTokens(getPaletteTokens()); - const baseline = renderPaletteTokens({ scheme: 'light' }); + const baseline = renderPaletteTokens({ schema: 'light' }); const preview = renderPaletteTokens({ accent: { hue: 30 }, - scheme: 'light', + schema: 'light', }); expect(preview['#accent-surface']).not.toBe(baseline['#accent-surface']); @@ -1080,13 +1080,13 @@ describe('renderPaletteTokens', () => { it('merges over the current config rather than the shipped defaults', () => { setPaletteConfig({ accent: { saturation: 20 } }); - expect(renderPaletteTokens({ scheme: 'light' })['#accent-surface']).toBe( + expect(renderPaletteTokens({ schema: 'light' })['#accent-surface']).toBe( getPaletteTokens()['#accent-surface']?.[''], ); }); it('is independent of the ambient contrast mode', () => { - const hc = renderPaletteTokens({ scheme: 'light', highContrast: true }); + const hc = renderPaletteTokens({ schema: 'light', highContrast: true }); // A manual global level suppresses high-contrast output in Glaze exports; // a preview must not inherit that. @@ -1095,7 +1095,7 @@ describe('renderPaletteTokens', () => { expect( renderPaletteTokens({ contrastLevel: 'auto', - scheme: 'light', + schema: 'light', highContrast: true, }), ).toEqual(hc); @@ -1107,7 +1107,7 @@ describe('renderPaletteTokens', () => { expect(glaze.getConfig().contrastLevel).toBe(60); - renderPaletteTokens({ scheme: 'dark' }); + renderPaletteTokens({ schema: 'dark' }); expect(glaze.getConfig().contrastLevel).toBe(60); }); @@ -1116,15 +1116,15 @@ describe('renderPaletteTokens', () => { // A region asking for high contrast at a mid level gets the genuine // high-contrast resolution, not a copy of its own normal variant — the level // moves the baseline, the tier escalates from wherever `'auto'` would put it. - const normal = renderPaletteTokens({ contrastLevel: 40, scheme: 'light' }); + const normal = renderPaletteTokens({ contrastLevel: 40, schema: 'light' }); const hc = renderPaletteTokens({ contrastLevel: 40, - scheme: 'light', + schema: 'light', highContrast: true, }); const autoHc = renderPaletteTokens({ contrastLevel: 'auto', - scheme: 'light', + schema: 'light', highContrast: true, }); @@ -1133,10 +1133,10 @@ describe('renderPaletteTokens', () => { }); it('emits one tier at level 100, where the two coincide', () => { - const normal = renderPaletteTokens({ contrastLevel: 100, scheme: 'light' }); + const normal = renderPaletteTokens({ contrastLevel: 100, schema: 'light' }); const hc = renderPaletteTokens({ contrastLevel: 100, - scheme: 'light', + schema: 'light', highContrast: true, }); @@ -1147,16 +1147,16 @@ describe('renderPaletteTokens', () => { // Glaze guarantees level 0 === normal and level 100 === high contrast, bit // for bit. If the level did not reach the themes in the region path, these // would silently all be the same. - for (const scheme of ['light', 'dark'] as const) { - expect(renderPaletteTokens({ contrastLevel: 0, scheme: scheme })).toEqual( - renderPaletteTokens({ contrastLevel: 'auto', scheme: scheme }), + for (const schema of ['light', 'dark'] as const) { + expect(renderPaletteTokens({ contrastLevel: 0, schema: schema })).toEqual( + renderPaletteTokens({ contrastLevel: 'auto', schema: schema }), ); expect( - renderPaletteTokens({ contrastLevel: 100, scheme: scheme }), + renderPaletteTokens({ contrastLevel: 100, schema: schema }), ).toEqual( renderPaletteTokens({ contrastLevel: 'auto', - scheme: scheme, + schema: schema, highContrast: true, }), ); @@ -1165,7 +1165,7 @@ describe('renderPaletteTokens', () => { it('interpolates between the tiers at intermediate levels', () => { const at = (contrastLevel: number | 'auto') => - renderPaletteTokens({ contrastLevel: contrastLevel, scheme: 'light' }); + renderPaletteTokens({ contrastLevel: contrastLevel, schema: 'light' }); const low = at(0); const mid = at(50); @@ -1188,10 +1188,10 @@ describe('renderPaletteTokens', () => { it('applies the level to every theme in the palette, not just the default', () => { const auto = renderPaletteTokens({ contrastLevel: 'auto', - scheme: 'light', + schema: 'light', highContrast: true, }); - const full = renderPaletteTokens({ contrastLevel: 100, scheme: 'light' }); + const full = renderPaletteTokens({ contrastLevel: 100, schema: 'light' }); // `special` is a standalone theme and the status themes are `extend()` // children — the level has to reach all of them. @@ -1213,7 +1213,7 @@ describe('renderColorTokens', () => { }); it('adds the legacy aliases by reference, not resolved', () => { - const rendered = renderColorTokens({ scheme: 'dark' }); + const rendered = renderColorTokens({ schema: 'dark' }); // Resolved palette value… expect(rendered['#surface-text']).toMatch(/^oklch\(/); @@ -1233,7 +1233,7 @@ describe('renderColorTokens', () => { }); it('re-declares the tokens whose values embed a palette color', () => { - const rendered = renderColorTokens({ scheme: 'dark' }); + const rendered = renderColorTokens({ schema: 'dark' }); // Declared on , so CSS would have frozen the outer theme's color into // them; they have to ride along by reference to re-resolve in the region. @@ -1291,12 +1291,12 @@ describe('interop with a host driving glaze directly', () => { // only input — `buildPalette` also reads Glaze's global config. Without the // version in that key, a region preview keeps serving the old palette while // the document around it has already moved. - const before = renderColorTokens({ scheme: 'dark' }); + const before = renderColorTokens({ schema: 'dark' }); glaze.configure({ darkDesaturation: 0.5 }); invalidatePaletteTokens(); - expect(renderColorTokens({ scheme: 'dark' })).not.toEqual(before); + expect(renderColorTokens({ schema: 'dark' })).not.toEqual(before); }); }); @@ -1382,7 +1382,7 @@ describe('accent color seeds', () => { }); /** - * The whole contract, as one invariant: for every brand and every scheme, the fill + * The whole contract, as one invariant: for every brand and every schema, the fill * is EITHER exactly the color asked for, OR sitting on the 3:1 floor — and it is * floored only when the color could not clear the floor by itself. * @@ -1396,7 +1396,7 @@ describe('accent color seeds', () => { const tokens = renderPaletteTokens({ ...EXACT, accent: accentColor, - scheme: 'light', + schema: 'light', }); const fill = String(tokens['#accent-surface']); const surface = String(tokens['#surface']); @@ -1430,7 +1430,7 @@ describe('accent color seeds', () => { } }); - it('lets dark adapt rather than pinning the color across schemes', () => { + it('lets dark adapt rather than pinning the color across schemas', () => { // Exactness is scoped to light / normal contrast on purpose. Dark is a // different page, and a fill pinned to one lightness across both would be a // worse `mode: 'fixed'` rather than a faithful brand — so the dark variant @@ -1441,7 +1441,7 @@ describe('accent color seeds', () => { const tokens = renderPaletteTokens({ ...EXACT, accent: accentColor, - scheme: 'dark', + schema: 'dark', }); const fill = String(tokens['#accent-surface']); @@ -1455,7 +1455,7 @@ describe('accent color seeds', () => { const dark = renderPaletteTokens({ ...EXACT, accent: '#FFD400', - scheme: 'dark', + schema: 'dark', }); expect(hexOf(String(dark['#accent-surface']))).not.toBe('#ffd400'); }); @@ -1469,7 +1469,7 @@ describe('accent color seeds', () => { const hc = renderPaletteTokens({ ...EXACT, accent: '#0EA5E9', - scheme: 'light', + schema: 'light', highContrast: true, }); @@ -1509,7 +1509,7 @@ describe('accent color seeds', () => { for (const highContrast of [false, true]) { const shipped = renderPaletteTokens({ ...EXACT, - scheme: 'dark', + schema: 'dark', highContrast, }); const reference = apcaOf( @@ -1520,7 +1520,7 @@ describe('accent color seeds', () => { const seeded = renderPaletteTokens({ ...EXACT, accent: 'okhst(280 70% 10%)', - scheme: 'dark', + schema: 'dark', highContrast, }); const separation = apcaOf( @@ -1552,19 +1552,19 @@ describe('accent color seeds', () => { // range survived in dark against light's 45. const tones = [30, 45, 60, 75]; - for (const scheme of ['light', 'dark'] as const) { + for (const schema of ['light', 'dark'] as const) { const emitted = tones.map((tone) => toneOf( String( renderPaletteTokens({ ...EXACT, accent: `okhst(280 70% ${tone}%)`, - scheme, + schema, })['#accent-surface'], ), ), ); - const label = `${scheme}: ${emitted.map((t) => t.toFixed(1)).join(', ')}`; + const label = `${schema}: ${emitted.map((t) => t.toFixed(1)).join(', ')}`; // Never inverts — a lighter seed cannot emit a darker fill. for (let i = 1; i < emitted.length; i++) { @@ -1587,16 +1587,16 @@ describe('accent color seeds', () => { // saturated hue behaves (`#FFD400` in dark high contrast lands on pure black at // 2.23 against the rest state's 7.07), and it is why the hover target is 9. for (const accentColor of BRANDS) { - for (const scheme of ['light', 'dark'] as const) { + for (const schema of ['light', 'dark'] as const) { for (const highContrast of [false, true]) { const tokens = renderPaletteTokens({ ...EXACT, accent: accentColor, - scheme: scheme, + schema: schema, highContrast: highContrast, }); const base = String(tokens['#accent-selected-fill']); - const label = `${accentColor} ${scheme}${highContrast ? ' hc' : ''}`; + const label = `${accentColor} ${schema}${highContrast ? ' hc' : ''}`; expect( contrastOf(String(tokens['#accent-text']), base), @@ -1633,13 +1633,13 @@ describe('accent color seeds', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); for (const accentColor of BRANDS.filter((brand) => brand !== '#FFD400')) { - for (const scheme of ['light', 'dark'] as const) { + for (const schema of ['light', 'dark'] as const) { invalidatePaletteTokens(); - renderPaletteTokens({ ...EXACT, accent: accentColor, scheme: scheme }); + renderPaletteTokens({ ...EXACT, accent: accentColor, schema: schema }); renderPaletteTokens({ ...EXACT, accent: accentColor, - scheme: scheme, + schema: schema, highContrast: true, }); } @@ -1667,7 +1667,7 @@ describe('accent color seeds', () => { const tokens = renderPaletteTokens({ ...EXACT, accent: accentColor, - scheme: 'light', + schema: 'light', }); const drift = Math.abs( toneOf(String(tokens['#accent-text-soft'])) - @@ -1683,14 +1683,14 @@ describe('accent color seeds', () => { // collapse them into one color and delete the rest→hover intensify that // `#accent-text` exists for. for (const accentColor of BRANDS) { - for (const scheme of ['light', 'dark'] as const) { + for (const schema of ['light', 'dark'] as const) { const tokens = renderPaletteTokens({ ...EXACT, accent: accentColor, - scheme: scheme, + schema: schema, }); - expect(tokens['#accent-text'], `${accentColor} ${scheme}`).not.toBe( + expect(tokens['#accent-text'], `${accentColor} ${schema}`).not.toBe( tokens['#accent-text-soft'], ); } @@ -1711,9 +1711,9 @@ describe('accent color seeds', () => { const seeded = renderPaletteTokens({ ...EXACT, accent: '#FFD400', - scheme: 'light', + schema: 'light', }); - const baseline = renderPaletteTokens({ ...EXACT, scheme: 'light' }); + const baseline = renderPaletteTokens({ ...EXACT, schema: 'light' }); for (const name of [ '#danger-accent-surface', @@ -1766,7 +1766,7 @@ describe('accent color seeds', () => { const seed = colorSeed('#0EA5E9')!; const tokens = renderPaletteTokens({ accent: '#0EA5E9', - scheme: 'light', + schema: 'light', }); const ramp = [ '#accent-surface', @@ -1786,15 +1786,15 @@ describe('accent color seeds', () => { for (let hue = 0; hue < 360; hue += 45) { for (const tone of [20, 60, 88, 100]) { for (const saturation of [0, 60, 100]) { - for (const scheme of ['light', 'dark'] as const) { + for (const schema of ['light', 'dark'] as const) { for (const highContrast of [false, true]) { const seed = `okhst(${hue} ${saturation}% ${tone}%)`; const tokens = renderPaletteTokens({ accent: seed, - scheme: scheme, + schema: schema, highContrast: highContrast, }); - const label = `${seed} ${scheme}${highContrast ? ' hc' : ''}`; + const label = `${seed} ${schema}${highContrast ? ' hc' : ''}`; // The white label — the guarantee `accentToneCeiling` exists for, and the // one that has to hold at text strength. @@ -1834,9 +1834,9 @@ describe('accent color seeds', () => { const tokens = renderPaletteTokens({ ...EXACT, accent: '#FFD400', - scheme: 'light', + schema: 'light', }); - const baseline = renderPaletteTokens({ ...EXACT, scheme: 'light' }); + const baseline = renderPaletteTokens({ ...EXACT, schema: 'light' }); expect(hueOf(String(tokens['#special-accent-surface']))).toBeCloseTo( colorSeed('#FFD400')!.hue, @@ -1852,7 +1852,7 @@ describe('accent color seeds', () => { // `#FFD400` sits on the sRGB gamut boundary, so its saturation is exactly 100 — // which makes it the clearest witness for the clip. const seed = colorSeed('#FFD400')!; - const baseline = renderPaletteTokens({ scheme: 'light' }); + const baseline = renderPaletteTokens({ schema: 'light' }); setPaletteConfig({ base: '#FFD400' }); @@ -1869,7 +1869,7 @@ describe('accent color seeds', () => { // The base zone moved and the accent zone did not. `baseline` is captured before // the write on purpose: `renderPaletteTokens` LAYERS over the live config, so a // baseline taken afterwards would already carry the base color. - const seeded = renderPaletteTokens({ scheme: 'light' }); + const seeded = renderPaletteTokens({ schema: 'light' }); expect(seeded['#border']).not.toBe(baseline['#border']); expect(seeded['#accent-surface']).toBe(baseline['#accent-surface']); @@ -1903,7 +1903,7 @@ describe('accent color seeds', () => { // The one guarantee this must not break: a brand — or a chrome — expressed as a // color cannot re-chromatize the status themes. They inherit the palette seed, // and no color writes to it. - const baseline = renderPaletteTokens({ scheme: 'light' }); + const baseline = renderPaletteTokens({ schema: 'light' }); const statuses = Object.keys(baseline).filter((name) => /^#(success|danger|warning|note)-/.test(name), ); @@ -1917,7 +1917,7 @@ describe('accent color seeds', () => { ] as PaletteConfig[]) { setPaletteConfig(config); - const seeded = renderPaletteTokens({ scheme: 'light' }); + const seeded = renderPaletteTokens({ schema: 'light' }); for (const name of statuses) expect(seeded[name], `${JSON.stringify(config)} ${name}`).toBe( @@ -2045,12 +2045,12 @@ describe('accent color seeds', () => { // This is the divergence the Theme Builder shows as requested-vs-resolved chips. const softened = renderPaletteTokens({ accent: '#EF4444', - scheme: 'light', + schema: 'light', }); const exact = renderPaletteTokens({ ...EXACT, accent: '#EF4444', - scheme: 'light', + schema: 'light', }); expectSameColor( @@ -2137,11 +2137,11 @@ describe('accent color seeds', () => { const preview = renderPaletteTokens({ ...EXACT, accent: '#FFD400', - scheme: 'light', + schema: 'light', }); expect(preview['#accent-surface']).not.toBe( - renderPaletteTokens({ scheme: 'light' })['#accent-surface'], + renderPaletteTokens({ schema: 'light' })['#accent-surface'], ); expect(getPaletteConfig()).toEqual(DEFAULT_PALETTE_CONFIG); }); @@ -2195,7 +2195,7 @@ describe('status color seeds', () => { const tokens = renderPaletteTokens({ ...EXACT, themes: { danger: color }, - scheme: 'light', + schema: 'light', }); const fill = String(tokens['#danger-accent-surface']); const surface = String(tokens['#surface']); @@ -2213,7 +2213,7 @@ describe('status color seeds', () => { it('reaches the theme’s text and icon, hover a step past rest', () => { setPaletteConfig({ ...EXACT, themes: { danger: '#b91c1c' } }); - const tokens = renderPaletteTokens({ scheme: 'light' }); + const tokens = renderPaletteTokens({ schema: 'light' }); const seed = colorSeed('#b91c1c')!; // The rest link IS the brand — the visible payoff of a color seed — and the hover @@ -2249,12 +2249,12 @@ describe('status color seeds', () => { const seeded = renderPaletteTokens({ ...EXACT, themes: { danger: color }, - scheme: 'light', + schema: 'light', }); const numeric = renderPaletteTokens({ ...EXACT, themes: { danger: { hue: seed.hue, saturation: seed.saturation } }, - scheme: 'light', + schema: 'light', }); for (const name of Object.keys(FACTORS)) { @@ -2270,9 +2270,9 @@ describe('status color seeds', () => { const muted = renderPaletteTokens({ ...EXACT, themes: { danger: '#8d6e63' }, - scheme: 'light', + schema: 'light', }); - const unseeded = renderPaletteTokens({ ...EXACT, scheme: 'light' }); + const unseeded = renderPaletteTokens({ ...EXACT, schema: 'light' }); expect(chromaOf(String(muted['#danger-border'])) * 3).toBeLessThan( chromaOf(String(unseeded['#danger-border'])), @@ -2287,7 +2287,7 @@ describe('status color seeds', () => { const tokens = renderPaletteTokens({ ...EXACT, themes: { note: '#0EA5E9' }, - scheme: 'light', + schema: 'light', }); const fill = String(tokens['#note-accent-surface']); @@ -2304,11 +2304,11 @@ describe('status color seeds', () => { // the case that proves it: uncapped, the button would be a white label on white. for (const name of ['danger', 'success', 'warning', 'note'] as const) { for (const highContrast of [false, true]) { - for (const scheme of ['light', 'dark'] as const) { + for (const schema of ['light', 'dark'] as const) { const tokens = renderPaletteTokens({ ...EXACT, themes: { [name]: 'okhst(20 80% 96%)' }, - scheme, + schema, highContrast, }); const fill = String(tokens[`#${name}-accent-surface`]); @@ -2318,7 +2318,7 @@ describe('status color seeds', () => { // serializer's rather than the solver's. expect( apcaOf('#ffffff', fill), - `${name} ${scheme}${highContrast ? ' hc' : ''}`, + `${name} ${schema}${highContrast ? ' hc' : ''}`, ).toBeGreaterThanOrEqual(44.9); } } @@ -2326,11 +2326,11 @@ describe('status color seeds', () => { }); it('scopes a color to its own theme, in both directions', () => { - const baseline = renderPaletteTokens({ ...EXACT, scheme: 'light' }); + const baseline = renderPaletteTokens({ ...EXACT, schema: 'light' }); const seeded = renderPaletteTokens({ ...EXACT, themes: { danger: '#b91c1c' }, - scheme: 'light', + schema: 'light', }); // Nothing outside `danger` may move — not the other three statuses, not the brand @@ -2351,7 +2351,7 @@ describe('status color seeds', () => { ...EXACT, accent: '#FFD400', themes: { danger: '#b91c1c' }, - scheme: 'light', + schema: 'light', }); for (const name of [ @@ -2418,11 +2418,11 @@ describe('status color seeds', () => { const preview = renderPaletteTokens({ ...EXACT, themes: { danger: '#b91c1c' }, - scheme: 'light', + schema: 'light', }); expect(preview['#danger-accent-surface']).not.toBe( - renderPaletteTokens({ scheme: 'light' })['#danger-accent-surface'], + renderPaletteTokens({ schema: 'light' })['#danger-accent-surface'], ); expect(getPaletteConfig()).toEqual(DEFAULT_PALETTE_CONFIG); }); diff --git a/src/tokens/palette.ts b/src/tokens/palette.ts index 9d74ec2f1..2b695ac6a 100644 --- a/src/tokens/palette.ts +++ b/src/tokens/palette.ts @@ -28,11 +28,11 @@ import type { ResolvedThemeSeed, } from './palette-config'; -/** Which resolved scheme variant {@link renderPaletteTokens} should return. */ +/** Which resolved schema variant {@link renderPaletteTokens} should return. */ export interface RenderPaletteOptions extends PaletteConfig { - /** Color scheme to resolve. Default: `'light'`. */ - scheme?: 'light' | 'dark'; - /** Resolve the high-contrast variant of that scheme. Default: `false`. */ + /** Color schema to resolve. Default: `'light'`. */ + schema?: 'light' | 'dark'; + /** Resolve the high-contrast variant of that schema. Default: `false`. */ highContrast?: boolean; } @@ -174,7 +174,7 @@ function baseSaturationScale(config: ResolvedPaletteConfig): number { * so Glaze solves `apcaContrast(surface, fill)`. * * **This number is page separation and nothing else**, and getting that wrong is what - * made the dark scheme unusable. It reads differently by scheme — in light `surface` IS + * made the dark schema unusable. It reads differently by schema — in light `surface` IS * white, so the same measurement happens to be the white-label pair, which invited the * value to be set for the LABEL at Lc 45 (`large` text) with 60 in high contrast. In * dark the page is near-black and the same number is a demand that a filled shape reach @@ -214,7 +214,7 @@ function baseSaturationScale(config: ResolvedPaletteConfig): number { * dark (12 hues, spread under 2 Lc — hue is not a factor, polarity is). That is 2.4x * stricter in light than in dark, which is why light brands kept getting crushed while * dark ones sailed through under the same rule. An Lc is one number that means one - * thing in both schemes. + * thing in both schemas. * * The pair is written out with BOTH entries equal, and that is the whole reason it is a * pair: stating one level would let APCA's automatic +15 Lc enhancement fire in high @@ -247,7 +247,7 @@ const ACCENT_FILL_CONTRAST: ContrastSpec = { apca: [25, 25] }; * `[4.5, 9]` pair, whose `9` existed only because a WCAG ratio that high is unreachable * for a saturated hue against a chromatic base: `#FFD400` in dark high contrast used to * pin to pure black and come out a hover link *less* readable than its rest state. An - * Lc target is reachable in both schemes because it is polarity-aware, so the + * Lc target is reachable in both schemas because it is polarity-aware, so the * pathological case has no equivalent here. */ const ACCENT_TEXT_CONTRAST: ContrastSpec = { apca: [60, 85] }; @@ -271,7 +271,7 @@ const ACCENT_RAMP = { * Both are pinned to the caller's tone otherwise, and a pair at the same tone behind * the same floor resolves to one color — which would silently delete the rest→hover * intensify that `accent-text` exists for. Tone is contrast-uniform, so one step is - * one step in either scheme. + * one step in either schema. */ const ACCENT_TEXT_HOVER_STEP = 6; @@ -309,7 +309,7 @@ const ACCENT_LABEL_LC = 45; * The ceiling is computed on the bare seed, but the emitted `accent-surface` then goes * through the page floor, which can only LIGHTEN — and a lighter fill is a weaker white * label, so the solve eats into the margin the ceiling just established. Measured worst - * case across 3072 hue/chroma/tone/scheme/tier combinations is 1.8 Lc, in dark high + * case across 3072 hue/chroma/tone/schema/tier combinations is 1.8 Lc, in dark high * contrast where the page floor pushes hardest; 3 covers it with room. */ const ACCENT_LABEL_MARGIN = 3; @@ -349,7 +349,7 @@ function labelLcOf(variant: Parameters[0]): number { * * The search runs against Glaze's own fixed-mode resolution — the same mapping * `accent-surface` goes through — rather than reimplementing the dark tone window, and - * checks all four variants so the cap is a property of the seed and not of one scheme. + * checks all four variants so the cap is a property of the seed and not of one schema. * * Only ever lowers, so a brand already dark enough comes back untouched. */ @@ -711,7 +711,7 @@ function tintedSurfaceOverride(config: ResolvedPaletteConfig): ColorMap { * pinned totals, and `surface-2-text`. * * The text is anchored to `surface-2`, not `surface`. `surface-2` has the lower - * contrast headroom in BOTH schemes — a darker background under dark text in + * contrast headroom in BOTH schemas — a darker background under dark text in * light, a lighter one under light text in dark — so solving the floor there * clears it on both bands. The neutral ramp's own `surface-2-text` is shaped the * same way for the same reason. @@ -733,7 +733,7 @@ export function tintRecipe(config: ResolvedPaletteConfig): ColorMap { base: 'surface-2', tone: `${TEXT_TONE - TINTED_SURFACE_TONE_OFFSET - SURFACE_2_TEXT_OFFSET}`, saturation: 0.25, - // The whole point: Glaze binary-searches the tone per scheme until the floor + // The whole point: Glaze binary-searches the tone per schema until the floor // is met, so a caller cannot persist an unreadable pair. contrast: ['AA', 'AAA'], }, @@ -790,7 +790,7 @@ function accentFillColors(accent: AccentSeed): ColorMap { // ---- Accent system (theme-aware, inherited by colored themes) ---- // Everything here is anchored to a fixed white "accent-surface-text" via // `mode: 'fixed'` + relative tone deltas, so accent colors stay visually - // consistent across light/dark/high-contrast schemes (the brand color does + // consistent across light/dark/high-contrast schemas (the brand color does // not flip). The solid fills are white-text-on-brand backgrounds, so they // keep an `['AA','AAA']` contrast floor even though the chosen tone deltas // already exceed it. This leaves room for a future low-contrast scale. @@ -816,7 +816,7 @@ function accentFillColors(accent: AccentSeed): ColorMap { // Hover variant of `accent-surface` — a *fixed*-mode darker shade used as // the hover fill for solid PRIMARY-type buttons. Anchored to the same // accent-surface-text so it stays in the same hue family. The relative tone - // lands a few steps darker than the pressed state in both schemes. + // lands a few steps darker than the pressed state in both schemas. 'accent-surface-hover': { base: 'accent-surface-text', tone: '-58', @@ -974,7 +974,7 @@ function buildPalette( * Carry `contrastLevel` on the theme instances instead of relying on the * global Glaze config. Used when rendering a palette the app is not running, * so a preview cannot disturb the live one. The resolved values are the same - * either way; only which scheme variants get *emitted* differs, and callers + * either way; only which schema variants get *emitted* differs, and callers * that isolate pick their variant explicitly. */ isolateContrastLevel?: boolean; @@ -1160,7 +1160,7 @@ function buildPalette( }, // Disabled fill chip + text — both adaptive (mode 'auto') and positioned // with relative tone deltas against `surface` so the disabled state has the - // same perceived intensity in light, dark, and high-contrast schemes. No + // same perceived intensity in light, dark, and high-contrast schemas. No // numeric contrast prop is needed: tone is already on a WCAG-uniform scale. // // Tone deltas reproduce the legacy palette's disabled appearance exactly: @@ -1186,8 +1186,8 @@ function buildPalette( }, // Fixed-mode "always dark" surface for elements that intentionally stay - // inverted regardless of scheme (tooltips, code blocks, popovers with their - // own dark theme, etc.). `mode: 'fixed'` bypasses the dark-scheme inversion + // inverted regardless of schema (tooltips, code blocks, popovers with their + // own dark theme, etc.). `mode: 'fixed'` bypasses the dark-schema inversion // so the color reads as a dark surface in light, dark, and high-contrast. // Pair with `#white` (built-in) for foreground text. 'surface-inverse': { @@ -1203,7 +1203,7 @@ function buildPalette( ...accentColors(accent), // Brand-tinted disabled chip + label for PRIMARY-style buttons (solid brand - // fill). The chip is scheme-symmetric (`mode: 'fixed'`) so the muted state + // fill). The chip is schema-symmetric (`mode: 'fixed'`) so the muted state // reads the same weight in light/dark/HC; saturation is bumped so it stays // identifiable as a muted brand color. // @@ -1213,9 +1213,9 @@ function buildPalette( // relative `tone: '+15'` with `autoFlip: false`, because Glaze < 1.2.0 // re-mapped the extreme through the dark tone window, compressing the // base-to-extreme span and dropping the dark label's contrast. Glaze 1.2.0 - // (tenphi/glaze#82) instead replays the light scheme's base→extreme tone + // (tenphi/glaze#82) instead replays the light schema's base→extreme tone // shift against the base's resolved dark tone — same-signed under - // `mode: 'fixed'` — so `'max'` holds its intended separation in every scheme + // `mode: 'fixed'` — so `'max'` holds its intended separation in every schema // and the approximation is no longer needed. Inherited per theme. // // The special theme keeps its own relative `+18` pair: its `surface` is a @@ -1274,13 +1274,13 @@ function buildPalette( // follows a re-seeded brand hue without announcing it. // // **Contrast, not tone, is the spec.** A relative tone delta is uniform on - // the OKHST scale but the dark scheme resolves it inside the `darkTone` + // the OKHST scale but the dark schema resolves it inside the `darkTone` // window, which compressed the ramp to ~75% of its light span — measurably // flatter, which is exactly how it looked. Glaze has no per-color // `darkTone`, so the fix is to state the intent as a WCAG floor against - // `surface` and let each scheme solve for it: the authored `tone: '-2'` is + // `surface` and let each schema solve for it: the authored `tone: '-2'` is // deliberately short of every floor, so all three faces are pinned by the - // ratio in every scheme rather than by a delta that means different things + // ratio in every schema rather than by a delta that means different things // in each. Measured on the emitted tokens, light comes out 1.201 / 1.653 / // 2.409 and dark 1.212 / 1.666 / 2.424 — within 1% of each other, against // 1.063 / 1.320 / 1.915 vs 1.053 / 1.264 / 1.735 before. @@ -1421,7 +1421,7 @@ function buildPalette( // -------------------------------------------------------------------------- // // Standalone theme for `special`-variant components (hero CTAs, banners, etc.) - // that intentionally sit on a dark surface regardless of the active scheme. + // that intentionally sit on a dark surface regardless of the active schema. // // Every token here is `mode: 'fixed'` so the resolved value is identical in // light, dark, and high-contrast. The shape is purpose-built (not a full @@ -1447,7 +1447,7 @@ function buildPalette( // - `accent-disabled-surface` / `accent-disabled-surface-text` — // brand-tinted disabled chip + label, positioned with relative tone // deltas against the fixed dark `surface` so the disabled state is - // scheme-symmetric. + // schema-symmetric. const specialTheme = glaze(hue, saturation, instanceConfig); @@ -1641,7 +1641,7 @@ let renderKey: string | null = null; let renderVariants: Record> = {}; /** - * Resolve one scheme variant of a palette to flat, literal color values. + * Resolve one schema variant of a palette to flat, literal color values. * * Unlike {@link getPaletteTokens}, which emits state maps (`@dark` / `@hc`) for * the whole document, this collapses the palette to the single variant you ask @@ -1655,7 +1655,7 @@ let renderVariants: Record> = {}; export function renderPaletteTokens( options: RenderPaletteOptions = {}, ): Tokens { - const { scheme = 'light', highContrast = false, ...config } = options; + const { schema = 'light', highContrast = false, ...config } = options; const resolved = resolvePaletteConfig(config); const key = `${getPaletteVersion()}:${JSON.stringify(resolved)}`; @@ -1699,13 +1699,13 @@ export function renderPaletteTokens( } } - const variant = VARIANT_KEY[`${scheme}:${highContrast}`]; + const variant = VARIANT_KEY[`${schema}:${highContrast}`]; // The fallback is for `contrastLevel: 100` only. There the normal variants // already *are* the high-contrast ones, so Glaze emits a single light/dark set // rather than duplicating it — and `highContrast` correctly resolves to the same // colors. At every other level the contrast variants are present and genuinely // escalated, so the fallback is not taken. - const flat = renderVariants[variant] ?? renderVariants[scheme]; + const flat = renderVariants[variant] ?? renderVariants[schema]; const out: Tokens = {}; for (const name of Object.keys(flat)) out[`#${name}`] = flat[name]; diff --git a/src/tokens/resolve.ts b/src/tokens/resolve.ts index 029e982cd..5eb17b00f 100644 --- a/src/tokens/resolve.ts +++ b/src/tokens/resolve.ts @@ -1,6 +1,7 @@ import { useState, useSyncExternalStore } from 'react'; import { useLayoutEffect } from '../utils/react/useLayoutEffect'; +import { subscribeSchema } from '../utils/react/useSchema'; import { warn } from '../utils/warnings'; import { getTokens } from './all-tokens'; @@ -372,21 +373,15 @@ export function resolvePresetValues( /** * A token's resolved value changes for two reasons: the palette was re-seeded - * (`setPaletteConfig()`, covered by `usePaletteVersion`), or the scheme / + * (`setPaletteConfig()`, covered by `usePaletteVersion`), or the schema / * contrast tier flipped. This store covers the second. * - * Both axes are the ones `@dark` and `@hc` resolve against (see - * `src/components/Root.tsx`): the `data-schema` / `data-contrast` attributes on - * ``, and the `prefers-color-scheme` / `prefers-contrast` media queries - * they fall back to. An attribute flip is invisible to `matchMedia` and a system - * flip is invisible to a `MutationObserver`, so both are watched. + * The watching itself belongs to `subscribeSchema()` (`src/utils/react/useSchema.ts`), + * which owns the definition of both axes — the `data-schema` / `data-contrast` + * attributes and the media queries they fall back to are the same strings + * `` registers `@dark` and `@hc` from. This store only counts the changes, + * so there is one observer for the document rather than one per concern. */ -const APPEARANCE_QUERIES = [ - '(prefers-color-scheme: dark)', - '(prefers-contrast: more)', -]; -const APPEARANCE_ATTRIBUTES = ['data-schema', 'data-contrast']; - let appearanceVersion = 0; let stopWatchingAppearance: (() => void) | null = null; @@ -398,45 +393,11 @@ function onAppearanceChange() { appearanceListeners.forEach((listener) => listener()); } -function watchAppearance(): () => void { - const teardowns: (() => void)[] = []; - - // Guarded rather than assumed: jsdom ships neither `matchMedia` nor a - // `MediaQueryList` in every version, and a consumer's unit tests are exactly - // where a hook like this gets mounted. Losing one source of change is a stale - // value; throwing is a broken render. - if (typeof window.matchMedia === 'function') { - for (const query of APPEARANCE_QUERIES) { - const list = window.matchMedia(query); - - if (typeof list?.addEventListener !== 'function') continue; - - list.addEventListener('change', onAppearanceChange); - teardowns.push(() => - list.removeEventListener('change', onAppearanceChange), - ); - } - } - - if (typeof MutationObserver === 'function' && document.documentElement) { - const observer = new MutationObserver(onAppearanceChange); - - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: APPEARANCE_ATTRIBUTES, - }); - - teardowns.push(() => observer.disconnect()); - } - - return () => teardowns.forEach((teardown) => teardown()); -} - function subscribeAppearance(listener: () => void): () => void { appearanceListeners.add(listener); - if (appearanceListeners.size === 1 && typeof window !== 'undefined') { - stopWatchingAppearance = watchAppearance(); + if (appearanceListeners.size === 1) { + stopWatchingAppearance = subscribeSchema(onAppearanceChange); } return () => { @@ -469,7 +430,7 @@ function getServerAppearanceVersion(): number { } /** - * Re-render on a scheme / contrast change. Returns the version rather than the + * Re-render on a schema / contrast change. Returns the version rather than the * state so the snapshot is a primitive — the same reason `usePaletteVersion` * does, see `src/tokens/palette-config.ts`. */ @@ -487,7 +448,7 @@ function useAppearanceVersion(): number { /** * The value is read out of the DOM, so it re-resolves whenever the palette is - * re-seeded or the scheme / contrast tier flips, and it is `fallback ?? null` + * re-seeded or the schema / contrast tier flips, and it is `fallback ?? null` * during SSR. The layout effect is what covers the first commit: a consumer * rendered in the same pass as `` reads before the token block lands. */ @@ -551,7 +512,7 @@ function isSameRecord>( /** * {@link resolveTokenValue} as a hook: re-renders when the palette is re-seeded - * or the scheme / contrast tier flips. + * or the schema / contrast tier flips. * * ```tsx * const accent = useTokenValue('#purple'); diff --git a/src/tokens/shadows.ts b/src/tokens/shadows.ts index 742bb55e6..0b082a19e 100644 --- a/src/tokens/shadows.ts +++ b/src/tokens/shadows.ts @@ -9,7 +9,7 @@ import type { Styles } from '@tenphi/tasty'; * * The shadow colors (`#shadow-sm` / `#shadow-md` / `#shadow-lg`) are * generated by Glaze and adapt automatically to dark / high-contrast - * schemes — see `src/tokens/palette.ts`. + * schemas — see `src/tokens/palette.ts`. * * Keys use $ prefix for CSS custom properties. */ diff --git a/src/utils/react/index.ts b/src/utils/react/index.ts index 41c289b33..49b0dc6e9 100644 --- a/src/utils/react/index.ts +++ b/src/utils/react/index.ts @@ -16,6 +16,14 @@ export { useLayoutEffect } from './useLayoutEffect'; export { useCombinedRefs, mergeRefs } from './useCombinedRefs'; export { wrapNodeIfPlain } from './wrapNodeIfPlain'; export { useViewportSize } from './useViewportSize'; +export { + useSchema, + useHighContrast, + resolveSchema, + resolveHighContrast, + subscribeSchema, +} from './useSchema'; +export type { ColorSchema } from './useSchema'; export { useQaProps } from './useQaProps'; export { useEventBus, useEventListener, EventBusProvider } from './useEventBus'; export type { EventBusListener, EventBusContextValue } from './useEventBus'; diff --git a/src/utils/react/useSchema.test.tsx b/src/utils/react/useSchema.test.tsx new file mode 100644 index 000000000..fc48e7d68 --- /dev/null +++ b/src/utils/react/useSchema.test.tsx @@ -0,0 +1,173 @@ +import { act, waitFor } from '@testing-library/react'; + +import { renderHook } from '../../test'; + +import { + resolveHighContrast, + resolveSchema, + useHighContrast, + useSchema, +} from './useSchema'; + +const DARK = '(prefers-color-scheme: dark)'; +const MORE_CONTRAST = '(prefers-contrast: more)'; + +/** + * A `matchMedia` stub whose answers can be flipped mid-test, dispatching the + * `change` event the hook subscribes to. jsdom's own implementation always + * answers `false` and never changes, so the media half is untestable without it. + */ +function stubMatchMedia(initial: Record = {}) { + const matches = { ...initial }; + const listeners = new Map void>>(); + + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => + ({ + media: query, + get matches() { + return Boolean(matches[query]); + }, + addEventListener: (_: string, listener: () => void) => { + const set = listeners.get(query) ?? new Set(); + + set.add(listener); + listeners.set(query, set); + }, + removeEventListener: (_: string, listener: () => void) => { + listeners.get(query)?.delete(listener); + }, + }) as unknown as MediaQueryList, + }); + + return function set(query: string, value: boolean) { + matches[query] = value; + listeners.get(query)?.forEach((listener) => listener()); + }; +} + +describe('useSchema / useHighContrast', () => { + const originalMatchMedia = window.matchMedia; + + afterEach(() => { + // The jsdom project shares one environment across a worker, so neither the + // stub nor the attributes may leak into the rest of the suite. + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: originalMatchMedia, + }); + document.documentElement.removeAttribute('data-schema'); + document.documentElement.removeAttribute('data-contrast'); + }); + + describe('resolveSchema', () => { + it('falls back to the media query when no attribute is set', () => { + const set = stubMatchMedia({ [DARK]: true }); + + expect(resolveSchema()).toBe('dark'); + + set(DARK, false); + + expect(resolveSchema()).toBe('light'); + }); + + it('lets the attribute opt-in win over the system preference', () => { + stubMatchMedia({ [DARK]: true }); + + document.documentElement.setAttribute('data-schema', 'light'); + + expect(resolveSchema()).toBe('light'); + + document.documentElement.setAttribute('data-schema', 'dark'); + + expect(resolveSchema()).toBe('dark'); + }); + + it('reads any other attribute value as light, exactly as `@dark` does', () => { + stubMatchMedia({ [DARK]: true }); + + // `@dark` gates the media fallback on the attribute being *absent* + // (`!@root(schema)`), so a present-but-unknown value stays light rather + // than falling through to the preference. + document.documentElement.setAttribute('data-schema', 'sepia'); + + expect(resolveSchema()).toBe('light'); + }); + }); + + describe('resolveHighContrast', () => { + it('follows the media query, then the attribute', () => { + const set = stubMatchMedia({ [MORE_CONTRAST]: true }); + + expect(resolveHighContrast()).toBe(true); + + set(MORE_CONTRAST, false); + + expect(resolveHighContrast()).toBe(false); + + document.documentElement.setAttribute('data-contrast', 'high'); + + expect(resolveHighContrast()).toBe(true); + + document.documentElement.setAttribute('data-contrast', 'normal'); + + expect(resolveHighContrast()).toBe(false); + }); + }); + + it('re-renders when the attribute flips', async () => { + stubMatchMedia(); + + const { result } = renderHook(() => useSchema()); + + expect(result.current).toBe('light'); + + await act(async () => { + document.documentElement.setAttribute('data-schema', 'dark'); + }); + + await waitFor(() => expect(result.current).toBe('dark')); + + await act(async () => { + document.documentElement.removeAttribute('data-schema'); + }); + + await waitFor(() => expect(result.current).toBe('light')); + }); + + it('re-renders when the system preference changes', async () => { + const set = stubMatchMedia({ [DARK]: false, [MORE_CONTRAST]: false }); + + const schema = renderHook(() => useSchema()); + const contrast = renderHook(() => useHighContrast()); + + expect(schema.result.current).toBe('light'); + expect(contrast.result.current).toBe(false); + + await act(async () => set(DARK, true)); + + expect(schema.result.current).toBe('dark'); + expect(contrast.result.current).toBe(false); + + await act(async () => set(MORE_CONTRAST, true)); + + expect(contrast.result.current).toBe(true); + }); + + it('re-attaches its watchers after the last subscriber leaves', async () => { + stubMatchMedia(); + + // The observer and the query listeners are shared and torn down when the + // listener set empties, so a remount has to build them again. + renderHook(() => useSchema()).unmount(); + + const { result } = renderHook(() => useSchema()); + + await act(async () => { + document.documentElement.setAttribute('data-schema', 'dark'); + }); + + await waitFor(() => expect(result.current).toBe('dark')); + }); +}); diff --git a/src/utils/react/useSchema.ts b/src/utils/react/useSchema.ts new file mode 100644 index 000000000..66c058648 --- /dev/null +++ b/src/utils/react/useSchema.ts @@ -0,0 +1,250 @@ +import { useSyncExternalStore } from 'react'; + +/** + * The ambient viewing conditions — the color schema and the contrast tier the + * document is showing right now. + * + * This module is the single owner of the definition: it builds the `@dark` / + * `@hc` predefined states that `src/components/Root.tsx` registers with tasty, + * *and* it answers the same question in JS. One definition, two readers, so the + * CSS and the JS answers cannot drift apart. + * + * **For styling, do not use this.** `{ '': light, '@dark': dark, '@hc': hc }` is + * the answer, and branching styles in JS gives up the conditionality that lets a + * schema flip repaint without a re-render. Two cases the state map cannot serve: + * + * 1. **Surfaces the stylesheet does not reach** — a Vega spec, a CodeMirror or + * Monaco theme, a third-party iframe. They take values, not CSS, so `@dark` + * never applies and the branch has to happen in JS. + * 2. **Control state that is not styling** — seeding or labelling a control from + * the ambient condition, e.g. a Light/Dark or Normal/High-contrast preview + * selector that starts on whatever the page is already showing. + * + * Root-level only, and deliberately not a generic state reader: tasty's state + * vocabulary mixes element-local states (`hovered`, `pressed`, `disabled`) with + * ambient ones (`@media(…)`, `@root(…)`), and only the ambient half is + * answerable without an element. Evaluating the state language in JS would mean + * a second implementation of the state algebra to keep in agreement with the CSS + * one, for a question almost every caller asks about the root anyway. + */ + +// ============================================================================ +// The definition +// ============================================================================ + +/** + * The `data-*` attribute names the opt-in uses, and the media queries it falls + * back to. `@root(schema=dark)` compiles to `:root[data-schema="dark"]`, hence + * the `data-` prefix on the DOM side and the bare key on the tasty side. + */ +const SCHEMA_KEY = 'schema'; +const CONTRAST_KEY = 'contrast'; +const SCHEMA_ATTR = `data-${SCHEMA_KEY}`; +const CONTRAST_ATTR = `data-${CONTRAST_KEY}`; +const DARK_QUERY = '(prefers-color-scheme: dark)'; +const HIGH_CONTRAST_QUERY = '(prefers-contrast: more)'; + +/** + * The `@dark` / `@hc` state aliases, in tasty's DSL — registered globally by + * `` via `setGlobalPredefinedStates()`. + * + * The attribute opt-in wins over the system preference, and the fallback is + * gated on the attribute being *absent* (`!@root(schema)`) rather than on it + * being some other value — so `` stays light inside a + * dark OS, which is the whole point of an opt-in. {@link resolveSchema} and + * {@link resolveHighContrast} read the same way. + */ +export const AMBIENT_PREDEFINED_STATES = { + '@dark': `@root(${SCHEMA_KEY}=dark) | (!@root(${SCHEMA_KEY}) & @media${DARK_QUERY})`, + '@hc': `@root(${CONTRAST_KEY}=high) | (!@root(${CONTRAST_KEY}) & @media${HIGH_CONTRAST_QUERY})`, +} as const; + +/** The color schema the document resolves to — the two arms of the `@dark` state. */ +export type ColorSchema = 'light' | 'dark'; + +// ============================================================================ +// Reading +// ============================================================================ + +/** + * `matchMedia` is called per read rather than cached, so a test that stubs it + * takes effect immediately. Missing (older jsdom, non-DOM runtimes) reads as + * "no preference", which lands on the light / normal-contrast defaults. + */ +function mediaMatches(query: string): boolean { + return typeof window !== 'undefined' && + typeof window.matchMedia === 'function' + ? window.matchMedia(query).matches + : false; +} + +function rootAttribute(name: string): string | null { + return typeof document === 'undefined' + ? null + : document.documentElement.getAttribute(name); +} + +/** + * The document's current color schema, read once — the JS answer to `@dark`. + * + * Outside React (a chart spec built in a module, an editor theme registered at + * import time). In React use {@link useSchema}, which also re-renders on change. + * Returns `'light'` with no DOM. + */ +export function resolveSchema(): ColorSchema { + const attribute = rootAttribute(SCHEMA_ATTR); + + if (attribute !== null) { + return attribute === 'dark' ? 'dark' : 'light'; + } + + return mediaMatches(DARK_QUERY) ? 'dark' : 'light'; +} + +/** + * Whether the document is showing the high-contrast tier, read once — the JS + * answer to `@hc`. + * + * Note this is an *ambient* condition, not the theme's `contrastLevel`: the + * level is a `PaletteConfig` seed the app supplies (read it with + * `getPaletteConfig()`), while this is the tier the viewer asked for. + * Returns `false` with no DOM. + */ +export function resolveHighContrast(): boolean { + const attribute = rootAttribute(CONTRAST_ATTR); + + if (attribute !== null) { + return attribute === 'high'; + } + + return mediaMatches(HIGH_CONTRAST_QUERY); +} + +// ============================================================================ +// Watching +// ============================================================================ + +const listeners = new Set<() => void>(); + +let stopWatching: (() => void) | null = null; + +function notify() { + listeners.forEach((listener) => listener()); +} + +/** + * Both inputs have to be watched, because either can decide the answer: the + * attribute when the opt-in is set, the media query otherwise — an attribute flip + * is invisible to `matchMedia` and a system flip is invisible to a + * `MutationObserver`. One observer and one pair of query listeners are shared by + * every subscriber, and torn down when the last one leaves. + */ +function startWatching(): () => void { + if (typeof document === 'undefined') { + return () => {}; + } + + const teardowns: (() => void)[] = []; + + // Guarded rather than assumed: jsdom ships neither `matchMedia` nor a + // `MediaQueryList` in every version, and a consumer's unit tests are exactly + // where a hook like this gets mounted. Losing one source of change is a stale + // value; throwing is a broken render. + if ( + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' + ) { + for (const query of [DARK_QUERY, HIGH_CONTRAST_QUERY]) { + const list = window.matchMedia(query); + + if (typeof list?.addEventListener !== 'function') continue; + + list.addEventListener('change', notify); + teardowns.push(() => list.removeEventListener('change', notify)); + } + } + + if (typeof MutationObserver === 'function' && document.documentElement) { + const observer = new MutationObserver(notify); + + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: [SCHEMA_ATTR, CONTRAST_ATTR], + }); + + teardowns.push(() => observer.disconnect()); + } + + return () => teardowns.forEach((teardown) => teardown()); +} + +/** + * Subscribe to ambient condition changes — either the schema or the contrast + * tier. Returns an unsubscribe function. + * + * The listener takes no argument: re-read with {@link resolveSchema} / + * {@link resolveHighContrast}, which is what the hooks below do. For non-React + * consumers that own a surface the stylesheet cannot reach — re-theming a Monaco + * instance, re-rendering a chart. + */ +export function subscribeSchema(listener: () => void): () => void { + listeners.add(listener); + + if (!stopWatching) { + stopWatching = startWatching(); + } + + return () => { + listeners.delete(listener); + + if (!listeners.size && stopWatching) { + stopWatching(); + stopWatching = null; + } + }; +} + +// ============================================================================ +// React bindings +// ============================================================================ + +/** + * Snapshots are primitives, so React bails out on an unchanged value and no + * memoization is needed — a contrast change re-runs a `useSchema()` reader's + * `getSnapshot` and stops there. + */ +const getServerSchema = (): ColorSchema => 'light'; +const getServerHighContrast = () => false; + +/** + * The document's color schema, kept live — `'light'` or `'dark'`. + * + * ```tsx + * const schema = useSchema(); + * + * buildSpec(schema), [schema])} />; + * ``` + * + * Follows both the `` opt-in and `prefers-color-scheme`, + * exactly as the `@dark` state does. Under SSR it renders `'light'` and + * re-renders with the real value after hydration. + */ +export function useSchema(): ColorSchema { + return useSyncExternalStore(subscribeSchema, resolveSchema, getServerSchema); +} + +/** + * Whether the document is showing the high-contrast tier, kept live — the hook + * form of {@link resolveHighContrast}, following `` and + * `prefers-contrast` exactly as the `@hc` state does. + * + * Under SSR it renders `false` and re-renders with the real value after + * hydration. + */ +export function useHighContrast(): boolean { + return useSyncExternalStore( + subscribeSchema, + resolveHighContrast, + getServerHighContrast, + ); +}