From 0742524b87142a5f1c6a60c4f090f522b7918e2c Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Thu, 20 Aug 2026 16:07:38 +0200 Subject: [PATCH 1/2] feat(palette): one seed per zone, and status themes from a color MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every palette zone — `accent`, `base` and each status theme — now takes the same `PaletteSeed`: a color string, or `{ hue?, saturation? }`. That replaces the six flat fields (`hue`, `saturation`, `accentColor`, `baseHue`, `baseSaturation`, `baseColor`), which spelled one idea four different ways and still left status themes unable to take a color at all. The union is the exclusivity. A zone was always seeded either by a color or by numbers, but the old shape let you write both and needed a precedence rule to settle it — so there is no rule to learn now, no contradiction to warn about, and a patch that switches form replaces rather than merges. The one capability this removes is the hybrid precedence allowed: previewing "this brand, rotated, tone intact". `ResolvedPaletteConfig` keeps its flat shape, so everything reading the resolved config is unaffected; its four status entries gain `color` and `colorTone`. Status themes can now be seeded by a color, which is what the union was blocking. The theme's accent family renders that color on the brand path's terms — softened APCA floors in place of the white-anchored ladder's WCAG ones, and the same tone cap so the `#white` label a `type="primary"` item paints on the fill survives. Its chroma also becomes the theme's seed, unlike the accent's: nothing inherits from a status theme, so there is nothing to re-chromatise, and moving the seed is what holds the tinted banner, border and text ramp at their shipped proportions to the fill. The Theme Builder's Color tab now covers all six zones. Entering it converts the four status themes to the fill each is already emitting rather than to a sample hex, and leaving it pins their hues back — hue only, so a saturation pinned on the way out cannot survive into Pastel where it is inert. The shipped palette is unchanged: a config with no color seed resolves bit for bit as before, and every existing assertion in palette.test.ts holds against rewritten inputs. Co-Authored-By: Claude Opus 5 --- .changeset/palette-seed-union.md | 36 + src/components/Root.docs.mdx | 2 +- src/components/Root.tsx | 9 +- src/components/data/TableBase/column-tint.ts | 10 +- src/stories/Theming.docs.mdx | 358 +++++---- src/stories/Theming.stories.tsx | 515 +++++++++---- src/stories/Usage.docs.mdx | 4 +- src/tokens/color-theme.test.ts | 6 +- src/tokens/color-theme.ts | 3 +- src/tokens/index.ts | 4 +- src/tokens/palette-config.ts | 721 ++++++++++++------- src/tokens/palette.test.ts | 637 ++++++++++++---- src/tokens/palette.ts | 85 ++- 13 files changed, 1660 insertions(+), 730 deletions(-) create mode 100644 .changeset/palette-seed-union.md diff --git a/.changeset/palette-seed-union.md b/.changeset/palette-seed-union.md new file mode 100644 index 000000000..7daa4b7d3 --- /dev/null +++ b/.changeset/palette-seed-union.md @@ -0,0 +1,36 @@ +--- +'@cube-dev/ui-kit': minor +--- + +Give every palette zone one seed, and let a status theme take a color. + +**BREAKING (`setPaletteConfig` / `` / `renderColorTokens` / `renderPaletteTokens`).** The six flat seed fields collapse into one `PaletteSeed` per zone — a color string, or `{ hue?, saturation? }`: + +| was | is | +| --- | --- | +| `hue`, `saturation` | `accent: { hue?, saturation? }` | +| `accentColor` | `accent: '#…'` | +| `baseHue`, `baseSaturation` | `base: { hue?, saturation? }` | +| `baseColor` | `base: '#…'` | +| `themes.: { hue?, saturation? }` | unchanged, and now also `themes.: '#…'` | +| `themes.code: { saturation? }` | unchanged — it takes no hue and no color, by design | + +```ts +setPaletteConfig({ + accent: '#2F5BFF', + base: '#7A7269', + themes: { danger: '#b91c1c', success: { hue: 150 } }, +}); +``` + +The union **is** the exclusivity. A zone was always seeded either by a color or by numbers, but the old shape let you write both and needed a precedence rule to settle it (`hue` outranked `accentColor`). Now it cannot be written, so there is no rule to learn — and a patch that switches form replaces rather than merges. The one capability this removes is the hybrid that precedence allowed: `resolvePaletteConfig({ hue: 30 })` over a stored brand color previewed "this brand, rotated, tone intact". A numeric seed now takes the zone over outright. + +`ResolvedPaletteConfig` keeps its flat shape — `hue`, `baseHue`, `saturation`, `baseSaturation`, `accentColor`, `accentTone`, `accentSaturation` — so anything reading the resolved config is unaffected. Its four status entries gain `color` and `colorTone`. `PaletteThemeSeed` is replaced by `PaletteSeed`; `PaletteNumericSeed` and `ResolvedThemeSeed` are new. + +**Status themes can now be seeded by a color**, which is what the union was blocking. `themes.danger: '#b91c1c'` renders that red on `#danger-accent-surface` — reproduced in light at normal contrast, adapting in dark and high contrast — and reaches `#danger-accent-text`, `-text-soft` and `-icon`. It inherits the brand path's softened APCA floors (Lc 45 against the page, Lc 45 against the white label, escalating to Lc 60 in high contrast) in place of the white-anchored ladder's `['AA','AAA']`, and the same tone cap, so a pale status color is pulled down rather than shipped as a white `type="primary"` label on white. + +One rule differs from the accent's, deliberately: **a status color's chroma becomes that theme's seed.** An accent color's does not, because all four status themes inherit the accent's saturation and raising it would re-chromatise every one of them; nothing inherits from a status theme, so there is nothing to protect. Moving the seed is also what holds the theme together — its tinted banner surface, border and text ramp are authored as factors of the seed (`0.2`, `0.3`, `0.25`), so leaving it at `100` beside a muted fill would give a fully tinted banner under a washed-out button. Moving it keeps the shipped `1.0 : 0.2 : 0.3 : 0.25` ratio exactly. + +Two consequences worth stating. A muted `saturation` *beside* an accent color is no longer expressible — a color leaves the inherited seed at its default, so mute the status themes individually if you want that. And the legacy `#danger` / `#success` / `#warning` / `#note` aliases resolve to `#-accent-surface`, so a status color moves every one of them across a consuming app; that is the point, but it is the blast radius. + +The Theme Builder's **Color** tab now covers all six zones: each status chip opens on a color field with its hue and saturation sliders gone, entering the tab converts the four status themes to the fill each is already emitting rather than to a sample hex, and leaving it pins their hues back. The shipped palette is unchanged — a config with no color seed resolves bit for bit as before. diff --git a/src/components/Root.docs.mdx b/src/components/Root.docs.mdx index 1cc560f2c..7887ba55d 100644 --- a/src/components/Root.docs.mdx +++ b/src/components/Root.docs.mdx @@ -17,7 +17,7 @@ The root application wrapper. Provides theme context, global styles, portal cont - **`navigation`** `NavigationAdapter` — Custom navigation adapter for routing - **`tracking`** `TrackingProps` — Analytics/tracking event configuration - **`tokens`** `Record` — Custom design tokens (CSS custom properties) -- **`palette`** `PaletteConfig` — Tune the generated color palette: brand `hue` / `saturation`, per-status theme seeds, `pastel`, `contrastLevel`. Applied during render, so the first paint is already correct. Describes the whole palette, so dropping a field drops that customization. A convenience wrapper over `setPaletteConfig()` — the palette is process-global, not per tree. See [Theming](/docs/getting-started-theming--docs) +- **`palette`** `PaletteConfig` — Tune the generated color palette. Every zone — `accent`, `base` and each status theme — takes the same seed: a color, or `{ hue, saturation }`. Plus the global `surfaceMode`, `pastel` and `contrastLevel`. Applied during render, so the first paint is already correct. Describes the whole palette, so dropping a field drops that customization. A convenience wrapper over `setPaletteConfig()` — the palette is process-global, not per tree. See [Theming](/docs/getting-started-theming--docs) - **`fonts`** `boolean` — Whether to load default fonts - **`font`** `string` — Custom font family name - **`monospaceFont`** `string` — Custom monospace font family name diff --git a/src/components/Root.tsx b/src/components/Root.tsx index f176b87c1..04db1d9fc 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -123,10 +123,11 @@ const STYLES = [...BASE_STYLES, ...BLOCK_STYLES]; export interface CubeRootProps extends BaseProps { tokens?: { [key: string]: string }; /** - * Tune the generated color palette — the brand as a hue or as a color - * (`accentColor` / `baseColor`), saturation, per-status theme seeds, `pastel`, and - * `contrastLevel`. Omitted fields take their default, so this prop describes the - * whole palette and dropping a field from it drops the customization. + * Tune the generated color palette. Every zone — `accent`, `base` and each status + * theme — takes the same seed: a color, or `{ hue, saturation }`. Plus the global + * `surfaceMode`, `pastel` and `contrastLevel`. Omitted fields take their default, so + * this prop describes the whole palette and dropping a field from it drops the + * customization. * * Removing the prop entirely is the one thing that does *not* reset the * palette, so `` with no `palette` cannot clobber a host's imperative diff --git a/src/components/data/TableBase/column-tint.ts b/src/components/data/TableBase/column-tint.ts index 4980ec771..2adf4c6da 100644 --- a/src/components/data/TableBase/column-tint.ts +++ b/src/components/data/TableBase/column-tint.ts @@ -72,6 +72,12 @@ function isThemeName(value: string): value is CubeTableColumnTheme { * * `primary` and `purple` are the brand itself, which is why they read `hue` * rather than a `themes` entry — the palette derives them from the brand hue too. + * + * 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 + * the seed's `color` and `colorTone` have nothing to say to it and are not part of + * `ColorThemeConfig`. */ function themeSeed(theme: CubeTableColumnTheme): { hue: number; @@ -83,7 +89,9 @@ function themeSeed(theme: CubeTableColumnTheme): { return { hue: config.hue, saturation: config.saturation }; } - return config.themes[theme]; + const { hue, saturation } = config.themes[theme]; + + return { hue, saturation }; } /** Normalizes any spec into a theme config, or `null` for the manual form. */ diff --git a/src/stories/Theming.docs.mdx b/src/stories/Theming.docs.mdx index 5fb189763..e68022db9 100644 --- a/src/stories/Theming.docs.mdx +++ b/src/stories/Theming.docs.mdx @@ -6,21 +6,26 @@ import * as ThemingStories from './Theming.stories'; # Theming -The whole palette is generated by [Glaze](https://github.com/tenphi/glaze) from a -handful of seeds: an accent hue, a base hue, a saturation, and a hue per status -theme. Those seeds are tunable at runtime — change one and every token re-resolves, in light, +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 accent and base zones can each be seeded by a **color** instead of a hue, which is -usually what you have: `accentColor: '#2F5BFF'` rather than `hue: 266`. See -[Two zones, two ways to seed them](#two-zones-two-ways-to-seed-them). +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). ```ts import { setPaletteConfig } from '@cube-dev/ui-kit'; setPaletteConfig({ - hue: 210, - saturation: 72, + accent: '#2F5BFF', + themes: { danger: '#b91c1c' }, +}); + +// The same shape, said in numbers. +setPaletteConfig({ + accent: { hue: 210, saturation: 72 }, themes: { danger: { hue: 12 } }, }); ``` @@ -31,20 +36,22 @@ value so this stays true. ## What is tunable -| Option | Scope | Default | -| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | -| `accentColor` | **Accent** zone seeded by a color. Derives the hue, the saturation (with `pastel` off) and the accent **tone**, so the brand fill renders as the color you passed. | unset | -| `baseColor` | **Base** zone seeded by a color. Hue **and saturation** (clipped to 50); its tone is discarded. | unset | -| `hue` | **Accent** hue in degrees — the brand. Drives the `accent-*` family, `primary` / `purple` / `special`, and the brand-tinted `focus`, loading faces and disabled chip. | `280.3` | -| `baseHue` | **Base** hue in degrees — the neutral chrome: `surface` and its ladder, the `surface-text*` ramp, `border`, `placeholder`. | inherits `hue` | -| `saturation` | Seed saturation (0–100) for the `default` theme, and the fallback for every theme that sets none. Setting it **turns `pastel` off**, which is the path it belongs to. | `100` | -| `baseSaturation` | Seed saturation (0–100) of the **base** zone — the same family `baseHue` governs. On the same scale as `saturation`; the shipped chrome is `12`. | `baseColor`'s, clipped to 50, else `(accentColor ?? saturation) × 0.12` | -| `surfaceMode` | Global. `'tinted'` moves the neutral surface ramp two tones off the end of the tone scale, which is what gives `baseSaturation` room to reach the page surface. | `'neutral'` | -| `themes..hue` | Hue for `success` / `danger` / `warning` / `note`. | `156.9` / `23.1` / `84.3` / `302.3` | -| `themes..saturation` | Saturation for that status theme. | inherits `saturation` | -| `themes.code.saturation` | Saturation for the `code-*` syntax family. Hues are fixed, and this does **not** inherit `saturation`, nor track its default. | `80` | -| `pastel` | Global. Relaxes the sRGB-safe chroma limit, and **pins `saturation` to 100**. Wins wherever both are set. Every theme except `code`. | `true` | -| `contrastLevel` | Global. `'auto'`, or a manual `0`–`100` level. | `'auto'` | +Every zone below takes a `PaletteSeed`: either a color string, or `{ hue?, saturation? }`. + +| Option | Scope | Default | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | +| `accent` | The **accent** zone — the brand. Drives the `accent-*` family on every theme, `primary` / `purple` / `special`, and the brand-tinted `focus`, loading faces and disabled chip. | `{ hue: 280.3 }` | +| `accent` as a color | Contributes hue, chroma **and tone**, so the brand fill renders as the color you passed. Its chroma reaches the accent family through Glaze's `from`, not through the zone's seed. | unset | +| `accent.saturation` | Seed saturation (0–100) for the `default` theme, and the fallback every status theme inherits. Setting it **turns `pastel` off**, which is the path it belongs to. | `100` | +| `base` | The **base** zone — the neutral chrome: `surface` and its ladder, the `surface-text*` ramp, `border`, `placeholder`. Omit it and the zone follows the accent. | follows `accent` | +| `base` as a color | Contributes hue **and saturation**, clipped to 50; its tone is discarded. | unset | +| `base.saturation` | Seed saturation (0–100) of the base zone, on the same scale as the accent's. The shipped chrome is `12`. | `(accent's chroma) × 0.12` | +| `themes.` | Seed for `success` / `danger` / `warning` / `note`. | `156.9` / `23.1` / `84.3` / `302.3` | +| `themes.` as a color | All three components, and its chroma **does** become the theme's seed — unlike the accent's. See [Status themes](#status-themes). | unset | +| `themes.code.saturation` | Saturation for the `code-*` syntax family. Hues are fixed, it takes no color, and it does **not** inherit the accent's saturation nor track its default. | `80` | +| `surfaceMode` | Global. `'tinted'` moves the neutral surface ramp two tones off the end of the tone scale, which is what gives the base saturation room to reach the page surface. | `'neutral'` | +| `pastel` | Global. Relaxes the sRGB-safe chroma limit, and **pins the accent's saturation to 100**. Wins wherever both are set. Every theme except `code`. | `true` | +| `contrastLevel` | Global. `'auto'`, or a manual `0`–`100` level. | `'auto'` | ### The setter replaces @@ -53,8 +60,8 @@ config you pass **is** the config, resolved against the shipped defaults. Nothin accumulates, so removing a customization means removing it from the object: ```ts -setPaletteConfig({ hue: 200, baseHue: 60 }); -setPaletteConfig({ hue: 200 }); // baseHue is gone — back to inheriting `hue` +setPaletteConfig({ accent: { hue: 200 }, base: { hue: 60 } }); +setPaletteConfig({ accent: { hue: 200 } }); // `base` is gone — follows accent again ``` That is what makes the config safe to keep in your own state and re-apply: the @@ -66,9 +73,14 @@ pass an updater. It receives the config **as written**, sparse, so spreading it preserves which fields are pinned and which still inherit: ```ts -setPaletteConfig((config) => ({ ...config, saturation: 55 })); +// A zone holds ONE seed, so a one-field control spreads that seed too — writing a bare +// `{ hue }` would unpin the saturation beside it. +setPaletteConfig((config) => ({ + ...config, + accent: { ...config.accent, saturation: 55 }, +})); -// Nested seeds need their own spread, or the other themes go with them. +// Nested seeds need their own spread as well, or the other themes go with them. setPaletteConfig((config) => ({ ...config, themes: { ...config.themes, warning: { saturation: 95 } }, @@ -78,63 +90,84 @@ setPaletteConfig((config) => ({ `resetPaletteConfig()` drops all tuning at once — the same as `setPaletteConfig({})`, just easier to read at a call site. -### Two zones, two ways to seed them +### One seed, two ways to spell it -Hue is split into an **accent** zone and a **base** zone: +Every zone takes the same `PaletteSeed`, and it comes in two forms: -- **Accent** (`hue` / `accentColor`) — the `accent-*` family on every theme, `primary` / - `purple` / `special`, plus `focus`, the loading faces and the disabled chip. -- **Base** (`baseHue` / `baseColor`) — the neutral chrome: `surface` and its ladder, the - `surface-text*` ramp, `border`, `placeholder`. +```ts +type PaletteSeed = string | { hue?: number; saturation?: number }; +``` -`baseHue` inherits `hue`, so out of the box the chrome carries a faint tint of the -brand. Set it to decouple them — a warm grey UI under a cool blue brand: +A string is a color — anything Glaze parses: hex, `rgb()`, `hsl()`, `okhsl()`, `okhst()`, +`oklch()`. CSS color keywords (`rebeccapurple`) are not supported; an unparseable value +warns once and falls back to the numeric path. + +**The union is the exclusivity.** A zone is seeded one way or the other — there is no +precedence rule to learn, and no way to write a hue that half-overrides a hex. A patch +that switches form therefore *replaces* rather than merges; layering happens within a +path: ```ts -setPaletteConfig({ hue: 235, baseHue: 60 }); +setPaletteConfig({ accent: '#2F5BFF' }); +// A number arriving next to that takes the zone over, tone and all. +setPaletteConfig((config) => ({ ...config, accent: { hue: 300 } })); ``` -Only the `default` theme is affected. A colored theme's tinted `surface` -deliberately follows _its own_ hue, because a danger banner should read as red. +#### The zones + +- **Accent** (`accent`) — the `accent-*` family on every theme, `primary` / `purple` / + `special`, plus `focus`, the loading faces and the disabled chip. Its saturation is + also the fallback every status theme inherits. +- **Base** (`base`) — the neutral chrome: `surface` and its ladder, the `surface-text*` + ramp, `border`, `placeholder`. +- **Status** (`themes.success` / `danger` / `warning` / `note`) — one seed each. See + [Status themes](#status-themes). +- **Syntax** (`themes.code`) — the exception: a saturation and nothing else. See + [The `code-*` family is the exception](#the-code--family-is-the-exception). -Either zone can take a **color** instead, which is usually the form a brand arrives in. -Anything Glaze parses works — hex, `rgb()`, `hsl()`, `okhsl()`, `okhst()`, `oklch()`. -CSS color keywords (`rebeccapurple`) are not supported; an unparseable value warns and -falls back to the numeric seed. +Omit `base` and the chrome follows the accent, so out of the box the greys carry a faint +tint of the brand. Seed it to decouple them — a warm grey UI under a cool blue brand: ```ts -setPaletteConfig({ accentColor: '#2F5BFF', baseColor: '#7A7269' }); +setPaletteConfig({ accent: { hue: 235 }, base: { hue: 60 } }); ``` -The two are not symmetric, and the difference is the point: +Only the `default` theme is affected. A colored theme's tinted `surface` deliberately +follows _its own_ hue, because a danger banner should read as red. -- `accentColor` contributes hue, saturation **and tone**. The tone is what makes the - brand fill actually _be_ your color. Without it the fill is authored as a fixed tone - step off white, so every accent hue lands at roughly the same lightness — a yellow - brand comes out olive. The chroma reaches the accent family through Glaze's `from` - rather than through the `saturation` seed, which is what stops a brand from - re-chromatising the chrome and the status themes. -- `baseColor` contributes **hue and saturation**; its **tone** is discarded, because the - chrome's own lightness ladder is the design. A base color says which way the greys lean - and how far, not how dark they are. +#### What a color contributes, per zone -The base color's saturation is **clipped to 50**. Naming a base color says "the chrome -_is_ this color", so it lands near it rather than at the 12% share `baseSaturation` -otherwise inherits — but a fully saturated chrome stops being chrome, and the base colors -begin to converge above 25 anyway (see [Tinted surfaces](#tinted-surfaces)). The tuner's -manual slider shares the same ceiling, so the two routes agree on what the top of the -range means. +The same string means slightly different things by zone, and the differences are the +point: ```ts -setPaletteConfig({ baseColor: '#6e7076' }); // near-grey in, near-grey chrome out -setPaletteConfig({ baseColor: '#FFD400' }); // saturation 100 in, clipped to 50 +setPaletteConfig({ accent: '#2F5BFF', base: '#7A7269' }); ``` -The numeric fields still win where both are set, and the tone keeps coming from the -color either way — so a preview can rotate the hue without losing the brand's lightness: +- **Accent** — hue, chroma **and tone**. The tone is what makes the brand fill actually + _be_ your color. Without it the fill is authored as a fixed tone step off white, so + every accent hue lands at roughly the same lightness — a yellow brand comes out olive. + The chroma reaches the accent family through Glaze's `from` rather than through the + zone's seed, which is what stops a brand from re-chromatising the chrome and all four + status themes. +- **Base** — hue and saturation; the **tone is discarded**, because the chrome's own + lightness ladder is the design. A base color says which way the greys lean and how far, + not how dark they are. +- **Status** — hue, chroma and tone, and its chroma **does** become that theme's seed. + Nothing inherits from a status theme, so there is nothing to re-chromatise, and moving + the seed is what keeps the banner in proportion to the button. + +The base color's saturation is **clipped to 50**. Naming a base color says "the chrome +_is_ this color", so it lands near it rather than at the 12% share it would otherwise +inherit — but a fully saturated chrome stops being chrome, and the base colors begin to +converge above 25 anyway (see [Tinted surfaces](#tinted-surfaces)). The tuner's manual +slider shares the same ceiling, so the two routes agree on what the top of the range +means. A base **number** is not clipped: a number is the more specific instruction. ```ts -setPaletteConfig({ accentColor: '#2F5BFF', hue: 300 }); // hue 300, tone still #2F5BFF's +setPaletteConfig({ base: '#6e7076' }); // near-grey in, near-grey chrome out +setPaletteConfig({ base: '#FFD400' }); // saturation 100 in, clipped to 50 +setPaletteConfig({ base: { saturation: 100 }, pastel: false }); // 100 stays 100 ``` ### A color seed is a request @@ -171,61 +204,66 @@ across hues, and it sits below where a saturated color would land, so under it a seed can never resolve to itself: `#EF4444` renders `#c47069`. Under pastel the color contributes its hue and its tone; turn pastel off to get its chroma too. +All of it applies per zone. A status color answers to the same two floors on its own +`#-accent-surface`, including the label cap — every `type="primary"` item on a +status theme paints `#white` on that fill too, so a pale `danger` color is pulled down +rather than shipped as a white label on white. + Read the result back from the tokens themselves — `#accent-surface` is the fill, `#accent-text-soft` the rest link color — or watch the requested/resolved chips in the [Theme builder](#theme-builder). ### One saturation scale per theme -Saturation is _not_ split per token, deliberately: `saturation` is the theme's -**seed**, and every color's own `saturation` is a 0–1 factor of it. `surface` sits -at `0.12`, `border` at `0.175`, the text ramp at `0.2`, the accent family at -~`1.0`. +Saturation is _not_ split per token, deliberately: a zone's `saturation` is its theme's +**seed**, and every color's own `saturation` is a 0–1 factor of it. `surface` sits at +`0.12`, `border` at `0.175`, the text ramp at `0.2`, the accent family at ~`1.0`. -So the palette has one saturation _scale_, and everything on it keeps its -proportions: turning `saturation` down mutes the brand fills and the neutral tint -together, in the ratios the palette was designed around. That is the point — the -relationship between a subtle surface tint and a saturated accent is part of the -design, not something to tune per token. +So a theme has one saturation _scale_, and everything on it keeps its proportions: +turning the seed down mutes the brand fills and the neutral tint together, in the ratios +the palette was designed around. That is the point — the relationship between a subtle +surface tint and a saturated accent is part of the design, not something to tune per +token. It is also why a status **color** takes over its theme's seed rather than reaching +only its fill: chroma arriving absolutely through `from` while the seed stayed put is +exactly how those ratios come apart. -The one seam is the **base zone**, and it is the same seam `baseHue` opens: the -chrome is the one family whose job is _not_ to look like the brand. `baseSaturation` -gives it its own seed, on the same 0–100 scale, so a vivid accent over near-grey -chrome — or a muted accent over visibly warm chrome — is one number rather than a -choice between them: +The one seam is the **base zone**, and it is the same seam its own hue opens: the chrome +is the one family whose job is _not_ to look like the brand. `base.saturation` gives it +its own seed, on the same 0–100 scale, so a vivid accent over near-grey chrome — or a +muted accent over visibly warm chrome — is one number rather than a choice between them: ```ts -setPaletteConfig({ pastel: false, saturation: 90, baseSaturation: 3 }); +setPaletteConfig({ accent: { saturation: 90 }, base: { saturation: 3 } }); ``` Unset, it takes `0.12` — `surface`'s own factor — of whatever the accent zone carries: -the `saturation` seed, or an `accentColor`'s own chroma when one is set. So leaving it -alone reproduces the shipped palette exactly, a muted `saturation` still mutes the -chrome along with everything else, and a near-grey brand hex leaves near-grey chrome -rather than 12% of a saturation nobody asked for. A `baseColor` overrides all of that -with its own, clipped to 50. +its seed saturation, or an accent color's own chroma when one is set. So leaving it alone +reproduces the shipped palette exactly, a muted accent still mutes the chrome along with +everything else, and a near-grey brand hex leaves near-grey chrome rather than 12% of a +saturation nobody asked for. A base color overrides all of that with its own, clipped to +50. **The shipped value is `12`** — a faint tint is what a neutral surface _is_ — so the interesting range is the low end. The base colors keep their proportions to one another until the highest of them (`0.475`, `surface-inverse`) hits the top of the scale, around `25`; past that they converge. -Note the asymmetry with `saturation`: writing `baseSaturation` does **not** turn -`pastel` off. How much hue the chrome carries says nothing about which chroma space -the palette is in. +Note the asymmetry with the accent's saturation: writing the base's does **not** turn +`pastel` off. How much hue the chrome carries says nothing about which chroma space the +palette is in. ### Tinted surfaces A neutral `surface` sits at the extreme of the tone scale — pure white in light, the darkest step the dark tone window allows in dark. Chroma needs distance from -the extreme to exist at all, so on a light page `surface` is white whatever -`baseSaturation` asks for. The base seed reaches `surface-2`…`surface-4`, `border`, +the extreme to exist at all, so on a light page `surface` is white whatever the base +saturation asks for. The base seed reaches `surface-2`…`surface-4`, `border`, `placeholder` and the text ramp, and stops at the page itself. `surfaceMode: 'tinted'` moves the whole ramp two tones inward: ```ts -setPaletteConfig({ surfaceMode: 'tinted', baseSaturation: 25 }); +setPaletteConfig({ surfaceMode: 'tinted', base: { saturation: 25 } }); ``` Two tones is not a lightness change you would name — it is _room_. Everything below @@ -254,12 +292,15 @@ second scale on top of it would only undo the evenness. So there are two paths: ```ts setPaletteConfig({ pastel: true }); // even across hues; saturation is 100, full stop -setPaletteConfig({ saturation: 65 }); // your scale, per-hue ceiling — pastel goes off +setPaletteConfig({ accent: { saturation: 65 } }); // your scale, per-hue ceiling ``` -Note the second line does not mention `pastel`. **Setting a `saturation` turns pastel -off**, because tuning a saturation is the non-pastel path by definition — so you pick a -path by asking for what you want from it, not by setting a flag first. +Note the second line does not mention `pastel`. **Setting the accent's `saturation` turns +pastel off**, because tuning a saturation is the non-pastel path by definition — so you +pick a path by asking for what you want from it, not by setting a flag first. A *color* +does not answer that question and does not turn pastel off: `accent: '#FFD400'` on its +own still resolves under the flat ceiling, contributing its hue and tone but not its +chroma. State `pastel` only to override that. It is the coarser of the two choices — a color space rather than a value on one — so it wins wherever both are set, and a saturation it @@ -296,51 +337,60 @@ also why the pinned seed is a floor to respect rather than a number to tidy up. ### Inherited vs pinned -Every per-theme field starts out **inheriting** the palette-level value, so -`baseHue` tracks `hue` until something sets it, and `themes..saturation` -tracks `saturation`. Moving the accent hue therefore appears to move the base hue -too — they are not linked, `baseHue` simply has no value of its own yet. +Every zone's seed starts out **inheriting**: the base zone follows the accent until +something seeds it, and a status theme's saturation tracks the accent's. Moving the accent +hue therefore appears to move the base hue too — they are not linked, the base zone simply +has no value of its own yet. -Writing the field pins it; leaving it out of the config unpins it, so it inherits -again: +Writing a seed pins it; leaving it out of the config unpins it, so it inherits again: ```ts -setPaletteConfig({ baseHue: 60 }); // pin -setPaletteConfig((config) => ({ ...config, hue: 235 })); // baseHue stays at 60 -setPaletteConfig({ hue: 235 }); // baseHue dropped — inherits again → 235 +setPaletteConfig({ base: { hue: 60 } }); // pin +setPaletteConfig((config) => ({ ...config, accent: { hue: 235 } })); // base stays at 60 +setPaletteConfig({ accent: { hue: 235 } }); // base dropped — follows again → 235 // Same thing from inside an updater, when the rest of the config must survive. -setPaletteConfig(({ baseHue, ...config }) => config); +setPaletteConfig(({ base, ...config }) => config); ``` -The color seeds are pinned fields too, which makes the base zone three-state: unset and -it follows the accent, `baseHue` and it is pinned to a number, `baseColor` and it is -derived from a color. A settings UI reads the sparse config to know which of its own -controls is in charge: +A zone is therefore **three-state**, and one field answers all three: absent and it +inherits, an object and it is pinned to numbers, a string and it is derived from a color. +A settings UI reads the sparse config to know which of its own controls is in charge: ```ts const input = getPaletteConfigInput(); -const accentMode = input.accentColor !== undefined ? 'color' : 'hue'; +const accentMode = typeof input.accent === 'string' ? 'color' : 'numbers'; +const baseIsOwn = input.base !== undefined; ``` -Swapping one form for the other counts as a change even when the numbers agree — a -`hue: 45` replaced by a color that derives hue 45 resolves identically, but the UI still -has to re-render to move its mode control. +`base: {}` and no `base` at all resolve to the same colors, but they are not the same +config — one has a seed of its own that pins nothing, the other has no seed. The two read +back differently and both count as changes, because that distinction is exactly what a +**Follow accent / Own** control is. + +Swapping one form for the other counts as a change even when the numbers agree — an +`accent: { hue: 45 }` replaced by a color that derives hue 45 resolves identically, but +the UI still has to re-render to move its mode control. So does replacing one unparseable +string with another, which is why the colors are compared by value rather than by +presence. -An explicit `baseHue: undefined` is equivalent to omitting it; neither is a value. +An explicit `{ hue: undefined }` inside a seed is equivalent to omitting the field; +neither is a value. -`getPaletteConfig()` resolves everything, which loses that distinction — it cannot -tell you whether `100` was chosen or inherited. `getPaletteConfigInput()` returns the -sparse config as set, which is what a settings UI needs to render an inherited value -as inherited and offer a way back: +`getPaletteConfig()` resolves everything, which loses that distinction — it cannot tell +you whether `100` was chosen or inherited, and it always reports flat numbers whichever +way a zone was seeded. `getPaletteConfigInput()` returns the sparse config as set, which +is what a settings UI needs to render an inherited value as inherited and offer a way +back: ```ts -const pinned = getPaletteConfigInput().baseHue !== undefined; +const seed = getPaletteConfigInput().base; +const pinnedHue = typeof seed === 'object' ? seed.hue : undefined; ``` -Pinning a field to the value it already inherited resolves to the same colors but -still counts as a change, so a UI reading the sparse config re-renders. Re-applying -an _already pinned_ value is a true no-op and costs nothing. +Pinning a field to the value it already inherited resolves to the same colors but still +counts as a change, so a UI reading the sparse config re-renders. Re-applying an _already +pinned_ value is a true no-op and costs nothing. ## Palette playground @@ -361,8 +411,39 @@ Two things worth noticing while you tune: ## Status themes -Each status theme is a Glaze `extend()` of the default theme with its own hue, so -tuning one moves only its own `#-*` family. +Each status theme is a Glaze `extend()` of the default theme with its own seed, so tuning +one moves only its own `#-*` family. + +That seed is a `PaletteSeed` like any other, so `danger` can be the red your product +already ships rather than a hue you reverse-engineered from it: + +```ts +setPaletteConfig({ themes: { danger: '#b91c1c', success: { hue: 150 } } }); +``` + +A status color behaves like the brand's in the ways that matter — the light, +normal-contrast fill reproduces it, dark and high contrast adapt, and the softened APCA +floors apply instead of the white-anchored ladder's WCAG ones, including the cap that +keeps the `#white` label on a `type="primary"` button readable. + +**One thing is deliberately different: its chroma becomes the theme's seed.** An accent +color's does not, because all four status themes inherit the accent's saturation and +raising it would re-chromatise every one of them. Nothing inherits from a status theme, +so there is nothing to protect — and moving the seed is what holds the rest of the theme +in proportion to the fill. The tinted banner surface, the border and the text ramp are +authored as factors of the seed (`0.2`, `0.3`, `0.25`), so with the seed left at `100` +beside a muted fill the banner would read as fully tinted under a washed-out button. +Moving it keeps the shipped `1.0 : 0.2 : 0.3 : 0.25` ratio exactly. + +Two consequences worth stating. A vivid status color under a muted palette gives one +vivid theme beside muted siblings — that is the point of naming a color. And under +`pastel` the flat ceiling can hold the fill below the color's own chroma while the banner +still sits at `0.2` of it, so the ratio drifts slightly; turn pastel off for an exact +color, exactly as with the brand. + +A muted seed *beside* an accent color is not expressible, because the zone is seeded one +way or the other: a color there leaves the inherited saturation at its default. Mute the +status themes individually if you want that. @@ -409,7 +490,7 @@ variant. Following is the default behaviour rather than a third choice. On the theme side, the panel is grouped by **what a knob reaches** rather than by what it is named after: **Global** for the ones that govern both zones — `pastel`, -`saturation`, `surfaceMode` and `contrastLevel` — then **Accent** and **Base** for +the accent saturation, `surfaceMode` and `contrastLevel` — then **Accent** and **Base** for the ones that do not. `surfaceMode` files under Global despite moving the *base* surfaces, because the status themes' tinted surfaces follow it too, and so does the mirrored surface the syntax palette solves against. @@ -435,7 +516,7 @@ The one button you reach for when an experiment goes wrong should not be behind the controls that caused it. `Export` opens the config as a `setPaletteConfig()` call to copy; `JSON` downloads `palette.json` on the press, with no intermediate state worth a second click. Both carry the config _as written_ rather than the -resolved one, so an inherited `baseHue` stays inherited instead of being frozen to +resolved one, so an inherited `base` stays inherited instead of being frozen to whatever the accent happened to be. Every caveat on the panel is an **InfoBadge** beside the control it qualifies rather @@ -451,7 +532,7 @@ it has selected: | ------------ | ----------------------- | ------------------------------------ | ------------------------- | | **Accent** | Hue | Hue + Saturation | Color (hue+chroma+tone) | | **Base** | Hue, or _Follow accent_ | Hue + Saturation, or _Follow accent_ | Color, or _Follow accent_ | -| **Status** | Hue | Hue + Saturation | Hue + Saturation | +| **Status** | Hue | Hue + Saturation | Color | | **Syntax** | Saturation | Saturation | Saturation | `Advanced` and `Color` are the **same chroma space** — the per-hue ceiling — and differ @@ -482,7 +563,18 @@ is about whether the chrome has a seed at all, not about how a seed is spelled. Every transition hands the incoming controls the values the outgoing ones were displaying, so switching changes who is in charge without repainting on the way. Leaving `Color` therefore pins each zone at what its color derived and drops the hex — including on the -way to `Pastel`, which cannot honour a color at all. +way to `Pastel`, which cannot honour a color at all. Status themes pin their **hue** only: +a saturation pinned on the way out would survive into `Pastel`, where it is inert but +still in effect, and that is not a state to manufacture on a tab press. + +The two halves of the way *in* differ, and both are deliberate. The brand zones open on a +fixed sample hex, so the flip changes which control is in charge rather than reading the +page back to itself. The four status themes convert instead — each to the fill it is +already emitting — because four sample hexes would repaint every banner on a tab press, +and a status theme's identity is a *meaning* a sample color has no business overwriting. +Coming from `Advanced`, the same chroma space, that conversion lands the banner back where +it was; coming from `Pastel` the palette repaints regardless, since the tabs turn `pastel` +off and a change of chroma space is the one thing no hand-over can carry across. In color mode the panel shows where the color actually landed — **Accent Fill** and **Accent Text**, the two tokens the seed's tone reaches. The requested color is not @@ -511,7 +603,7 @@ import { renderColorTokens, tasty } from '@cube-dev/ui-kit'; const Region = tasty({ styles: { fill: '#surface', color: '#surface-text' } }); const preview = useMemo( - () => renderColorTokens({ hue: 210, scheme: 'dark' }), + () => renderColorTokens({ accent: { hue: 210 }, scheme: 'dark' }), [], ); @@ -585,7 +677,7 @@ import { // on change. `palette` is the resolved config; the updater's argument is sparse. const [palette, setPalette] = usePaletteConfig(); -setPalette((config) => ({ ...config, hue: 200 })); +setPalette((config) => ({ ...config, accent: { ...config.accent, hue: 200 } })); // Outside React. getPaletteConfig(); // every field resolved to a concrete value @@ -604,7 +696,7 @@ it. `DEFAULT_PALETTE_CONFIG` is the shipped baseline in the same shape. paint is already correct: ```tsx - + ``` @@ -640,10 +732,10 @@ therefore re-tunes nothing — a seed picked in one space lands somewhere else i other, and the warm statuses move most. Re-tune the seeds alongside it, in whichever direction you flip. -It also owns `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 an `accentColor` short of itself: the ceiling sits below where a -saturated color lands, so `#EF4444` resolves to `#c47069`. Both are visible in the +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. diff --git a/src/stories/Theming.stories.tsx b/src/stories/Theming.stories.tsx index f62abfbe6..4a33dbf3a 100644 --- a/src/stories/Theming.stories.tsx +++ b/src/stories/Theming.stories.tsx @@ -40,8 +40,9 @@ import type { Styles, Tokens } from '@tenphi/tasty'; import type { ReactNode } from 'react'; import type { PaletteConfig, + PaletteNumericSeed, + PaletteSeed, PaletteThemeName, - PaletteThemeSeed, RenderPaletteOptions, SurfaceMode, } from '../index'; @@ -292,7 +293,7 @@ function ColorResolution({ resolved }: { resolved?: Tokens }) { const [palette] = usePaletteConfig(); - if (!getPaletteConfigInput().accentColor) return null; + if (typeof getPaletteConfigInput().accent !== 'string') return null; // 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 @@ -342,6 +343,65 @@ function ColorResolution({ resolved }: { resolved?: Tokens }) { const ACCENT_COLOR_START = '#7a4dbf'; const BASE_COLOR_START = '#6c717f'; +type StatusThemeName = Exclude; + +const STATUS_THEMES = ['success', 'danger', 'warning', 'note'] as const; + +/** + * The five zones that take a {@link PaletteSeed}, as one table. + * + * The mode detector, the mode tabs and the per-zone controls all ask a zone the same two + * questions — what is your seed, and here is a new one — and answering them once is what + * keeps the tab bar from re-listing every field of the config by hand. It used to name + * `accentColor`, `baseColor`, `hue`, `baseHue` and `baseSaturation` one at a time and + * still reach no status theme at all. + * + * `code` is absent: it takes a saturation and nothing else, so it has no path to be on. + */ +const ZONES: { + name: 'accent' | 'base' | StatusThemeName; + read: (config: PaletteConfig) => PaletteSeed | undefined; + write: ( + config: PaletteConfig, + seed: PaletteSeed | undefined, + ) => PaletteConfig; +}[] = [ + { + name: 'accent', + read: (config) => config.accent, + write: (config, accent) => ({ ...config, accent }), + }, + { + name: 'base', + read: (config) => config.base, + write: (config, base) => ({ ...config, base }), + }, + ...STATUS_THEMES.map((name) => ({ + name, + read: (config: PaletteConfig) => config.themes?.[name], + write: (config: PaletteConfig, seed: PaletteSeed | undefined) => ({ + ...config, + themes: { ...config.themes, [name]: seed }, + }), + })), +]; + +/** A seed's color, or `null` for a zone on the numeric path. */ +function seedColor(seed: PaletteSeed | undefined): string | null { + return typeof seed === 'string' ? seed : null; +} + +/** + * A seed's numbers, or an empty set — so a one-field slider can patch one of them. + * + * A zone on the color path has no numbers, and a slider that lands on one is switching + * path rather than patching, which is what makes the empty object the right answer + * instead of a guard at every call site. + */ +function seedNumbers(seed: PaletteSeed | undefined): PaletteNumericSeed { + return typeof seed === 'object' ? seed : {}; +} + /** * The whole palette in one of three states, read off the SPARSE config. * @@ -372,9 +432,11 @@ function usePaletteMode(): PaletteMode { // A color wins even next to `pastel: true`, which the tabs cannot produce but a // pasted config can. Reporting `color` is the more informative of the two, and the // colors are what the reader will be looking for. - if (input.accentColor !== undefined || input.baseColor !== undefined) { + // + // ANY zone, not just the two at the top: a config whose only hex is on `danger` is + // still a color-seeded config, and the status chips are where its reader will look. + if (ZONES.some((zone) => typeof zone.read(input) === 'string')) return 'color'; - } return getPaletteConfig().pastel ? 'pastel' : 'advanced'; } @@ -408,21 +470,20 @@ function AccentSourceControls({ resolved }: { resolved?: Tokens }) { label="Color" size="small" tooltip="Hue, chroma and tone all come from here — the tone is what makes the brand fill actually be your color rather than a shade re-derived at a fixed lightness. Glaze reproduces it exactly in the light, normal-contrast variant; dark and high contrast adapt. Two APCA floors apply everywhere: Lc 45 against the page so the button reads as a shape, and Lc 45 against the white label it carries. Both are APCA rather than WCAG, so a fill can legitimately sit under 3:1 — #0EA5E9 lands at 2.77:1 and is correct there." - value={input.accentColor ?? null} - onChange={(accentColor) => - // Clearing the field is a mode change, so it lands back on a hue seed + value={seedColor(input.accent)} + onChange={(accent) => + // Clearing the field is a change of path, so it lands back on a hue seed // pinned where the color left it rather than on a half-set config. // - // `hue` comes back OUT of the config when a color arrives, and has to: - // the pin the previous clear left behind outranks a color-derived hue in - // `resolveConfig`, so spreading it back would accept the new color and - // then ignore its hue — the field would show cyan while the palette - // stayed yellow. - setPalette(({ accentColor: previous, hue, ...config }) => - accentColor - ? { ...config, accentColor } - : { ...config, hue: Math.round(palette.hue) }, - ) + // Nothing has to be cleared on the way in or out: a zone holds one seed, so + // writing either form replaces the other. This used to have to pull `hue` + // back out of the config by hand, because a pin left behind by a previous + // clear outranked a color-derived hue — the field would show cyan while the + // palette stayed yellow. + setPalette((config) => ({ + ...config, + accent: accent ?? { hue: Math.round(palette.hue) }, + })) } /> @@ -438,7 +499,14 @@ function AccentSourceControls({ resolved }: { resolved?: Tokens }) { label="Hue" tooltip="Drives the whole accent family, `primary` / `purple` / `special`, and the brand-tinted odds and ends — the focus ring, the loading faces, the disabled chip." value={Math.round(palette.hue)} - onChange={(hue) => setPalette((config) => ({ ...config, hue }))} + onChange={(hue) => + setPalette((config) => ({ + ...config, + // Spread the seed rather than replacing it: the two sliders share one field + // now, so writing a bare `{ hue }` would unpin the saturation beside it. + accent: { ...seedNumbers(config.accent), hue }, + })) + } /> {palette.pastel ? null : ( - setPalette((config) => ({ ...config, saturation })) + setPalette((config) => ({ + ...config, + accent: { ...seedNumbers(config.accent), saturation }, + })) } /> )} @@ -460,13 +531,14 @@ function BaseSourceControls() { const seedMode = useSeedMode(); // Two states now, not three. Whether the zone has a seed of its own is one question; - // how that seed is spelled is the global one. `baseHue`/`baseSaturation` follow the - // accent until something sets one, so without the first state inheritance would read - // as a stuck control. - const isOwn = - input.baseColor !== undefined || - input.baseHue !== undefined || - input.baseSaturation !== undefined; + // how that seed is spelled is the global one. The base zone follows the accent until + // something seeds it, so without the first state inheritance would read as a stuck + // control. + // + // One field answers it, where three used to have to agree. `base: {}` and no `base` at + // all resolve identically but read back differently, which is exactly the distinction + // this radio is: `pinSignature` keeps them apart for it. + const isOwn = input.base !== undefined; return (
@@ -476,19 +548,19 @@ function BaseSourceControls() { type="button" value={isOwn ? 'own' : 'accent'} onChange={(next) => - setPalette(({ baseColor, baseHue, baseSaturation, ...config }) => + setPalette(({ base, ...config }) => next === 'own' - ? seedMode === 'color' - ? { ...config, baseColor: BASE_COLOR_START } - : // Hand the sliders the values they were *showing*, so taking over - // is not also a repaint. - { - ...config, - baseHue: Math.round(palette.baseHue), - baseSaturation: Math.round(palette.baseSaturation * 2) / 2, - } - : // Dropping all three is what re-links the zone: an absent `baseHue` - // means "inherit again", where keeping it would mean "stay at 60". + ? { + ...config, + base: + seedMode === 'color' + ? BASE_COLOR_START + : // Hand the sliders the values they were *showing*, so taking + // over is not also a repaint. + numbersForZone('base', palette), + } + : // Dropping the field is what re-links the zone: an absent `base` means + // "inherit again", where keeping it would mean "stay at 60". config, ) } @@ -502,22 +574,15 @@ function BaseSourceControls() { label="Color" size="small" tooltip={`The hue and the saturation are taken; the tone is discarded, because the chrome's own lightness ladder is the design. A base color says which way the greys lean and how far, not how dark they are — and its saturation is clipped at ${MAX_BASE_SATURATION}, since a fully saturated chrome stops being chrome.`} - value={input.baseColor ?? null} - onChange={(baseColor) => - // Same shape as the accent field above, and for the same reason: the - // numeric pins a previous clear left behind outrank what a color derives, - // so they come out when one arrives. - setPalette( - ({ baseColor: previous, baseHue, baseSaturation, ...config }) => - baseColor - ? { ...config, baseColor } - : { - ...config, - baseHue: Math.round(palette.baseHue), - baseSaturation: - Math.round(palette.baseSaturation * 2) / 2, - }, - ) + value={seedColor(input.base)} + onChange={(base) => + // Same shape as the accent field above, and for the same reason: one seed + // per zone, so clearing the color has to leave the numbers it derived + // behind rather than an empty seed the radio would read as Own. + setPalette((config) => ({ + ...config, + base: base ?? numbersForZone('base', palette), + })) } /> ) : null} @@ -535,8 +600,11 @@ function BaseSourceControls() { label="Hue" tooltip="The neutral chrome — surface and its ladder, the surface-text ramp, border, placeholder. A colored theme's tinted surface deliberately follows its own hue instead, because a danger banner should read as red." value={Math.round(palette.baseHue)} - onChange={(baseHue) => - setPalette((config) => ({ ...config, baseHue })) + onChange={(hue) => + setPalette((config) => ({ + ...config, + base: { ...seedNumbers(config.base), hue }, + })) } /> {palette.pastel ? null : ( @@ -554,8 +622,11 @@ function BaseSourceControls() { maxValue={MAX_BASE_SATURATION} step={0.5} value={Math.min(palette.baseSaturation, MAX_BASE_SATURATION)} - onChange={(baseSaturation) => - setPalette((config) => ({ ...config, baseSaturation })) + onChange={(saturation) => + setPalette((config) => ({ + ...config, + base: { ...seedNumbers(config.base), saturation }, + })) } /> )} @@ -565,6 +636,55 @@ function BaseSourceControls() { ); } +/** + * What a zone's color field opens on when the palette flips into Color mode. + * + * The two halves of this transition follow deliberately different rules. The brand zones + * open on a fixed START HEX — see {@link ACCENT_COLOR_START} — so the flip changes which + * control is in charge rather than reading the page back to itself. The four status + * themes convert instead, each to the fill it is already emitting: four demo hexes would + * repaint every banner on a tab press, and a status theme's identity is a *meaning* that + * a sample color has no business overwriting. + * + * The conversion is as neutral as the transition allows rather than lossless. From + * Advanced — the same chroma space — a derived fill sits at roughly factor 1.0 of its + * theme's seed, so re-seeding from it puts the banner back where it was. From Pastel the + * palette repaints regardless: the tabs turn `pastel` off, and a change of chroma space + * is the one thing no hand-over can carry across. + */ +function colorForZone(zone: (typeof ZONES)[number]['name']): string | null { + if (zone === 'accent') return ACCENT_COLOR_START; + if (zone === 'base') return BASE_COLOR_START; + + const fill = resolvedValue(`#${zone}-accent-surface`); + + // `resolvedValue` reports an em dash for a token it cannot find. Nothing to convert + // from, so the zone stays on the numbers rather than taking a hex of a dash. + return fill.startsWith('—') ? null : fill; +} + +/** + * The numbers a zone's color was deriving, pinned in its place on the way out. + * + * Status themes pin the **hue only**. A saturation pinned here would manufacture exactly + * the "set while pastel was off, and still in effect" state `StatusThemeButton` warns + * about — and it would be inert on the way to Pastel, which is half of where this goes. + */ +function numbersForZone( + zone: (typeof ZONES)[number]['name'], + palette: ReturnType, +): PaletteNumericSeed { + if (zone === 'accent') return { hue: Math.round(palette.hue) }; + if (zone === 'base') { + return { + hue: Math.round(palette.baseHue), + saturation: Math.round(palette.baseSaturation * 2) / 2, + }; + } + + return { hue: Math.round(palette.themes[zone].hue) }; +} + /** Full-width tabs, so the three states divide the column rather than huddling left. */ const MODE_TABS_STYLES: Styles = { width: '100%' }; const MODE_TAB_STYLES: Styles = { flexGrow: 1 }; @@ -586,21 +706,37 @@ function PaletteModeTabs() { const [palette, setPalette] = usePaletteConfig(); const mode = usePaletteMode(); - /** Colors out, and the numbers they were deriving pinned in their place. */ - const toNumbers = (config: PaletteConfig): PaletteConfig => ({ - ...config, - accentColor: undefined, - baseColor: undefined, - ...(config.accentColor !== undefined - ? { hue: Math.round(palette.hue) } - : null), - ...(config.baseColor !== undefined - ? { - baseHue: Math.round(palette.baseHue), - baseSaturation: Math.round(palette.baseSaturation * 2) / 2, - } - : null), - }); + /** + * Colors out, and the numbers they were deriving pinned in their place. + * + * One pass over the zone table, where this used to name five fields by hand and reach + * no status theme. Writing a numeric seed *is* dropping the color — that is what the + * union buys — so there is nothing to clear separately. + */ + const toNumbers = (config: PaletteConfig): PaletteConfig => + ZONES.reduce( + (next, zone) => + seedColor(zone.read(next)) === null + ? next + : zone.write(next, numbersForZone(zone.name, palette)), + config, + ); + + /** + * Numbers out, colors in — for every zone that has a seed to spell. + * + * The base zone is the exception, and it is not a special case so much as a different + * question: whether the chrome has a seed at all is what its own **Follow accent** + * radio governs, so one still following the accent keeps following it. + */ + const toColors = (config: PaletteConfig): PaletteConfig => + ZONES.reduce((next, zone) => { + if (zone.name === 'base' && zone.read(next) === undefined) return next; + + const color = colorForZone(zone.name); + + return color === null ? next : zone.write(next, color); + }, config); return ( - setPalette((config) => { - if (next === 'color') { - return { - ...config, - pastel: false, - hue: undefined, - accentColor: ACCENT_COLOR_START, - // Only a zone that had a seed of its own gets a color; one following the - // accent keeps following it. - ...(config.baseHue !== undefined || - config.baseSaturation !== undefined - ? { - baseHue: undefined, - baseSaturation: undefined, - baseColor: BASE_COLOR_START, - } - : null), - }; - } - - // Pastel and Advanced are the same seeds in two chroma spaces, so both take - // the numeric form — a flat ceiling has nothing to do with a color, and - // `pastel` has to be explicit either way: an absent one is inferred from - // whether a `saturation` is pinned. - return { ...toNumbers(config), pastel: next === 'pastel' }; - }) + setPalette((config) => + next === 'color' + ? // `pastel` off explicitly: it is the other chroma space, and a color + // cannot be honoured inside a flat ceiling. It is also not inferrable + // here — a color pins no saturation, so an absent `pastel` would read as + // pastel-on and cap the very chroma the hex was handed over for. + { ...toColors(config), pastel: false } + : // Pastel and Advanced are the same seeds in two chroma spaces, so both + // take the numeric form — a flat ceiling has nothing to do with a color, + // and `pastel` has to be explicit either way: an absent one is inferred + // from whether a saturation is pinned. + { ...toNumbers(config), pastel: next === 'pastel' }, + ) } > Color @@ -732,27 +855,32 @@ function BrandControls() { ); } -type StatusThemeName = Exclude; - -const STATUS_THEMES = ['success', 'danger', 'warning', 'note'] as const; - /** - * Patch one status theme's seed, leaving the rest of the config — and the other - * three themes — alone. The setter replaces, so a one-field control has to spread - * rather than send a bare `{ themes: { danger: … } }`. + * Patch one status theme's seed, leaving the rest of the config — and the other three + * themes — alone. The setter replaces, so a one-field control has to spread rather than + * send a bare `{ themes: { danger: … } }`. + * + * `patch` receives the seed **as written**, so a hue slider can preserve a pinned + * saturation beside it; a control that changes path returns a whole new seed and ignores + * what it was handed. */ -function statusSeed(name: StatusThemeName, seed: PaletteThemeSeed) { +function statusSeed( + name: StatusThemeName, + patch: (seed: PaletteSeed | undefined) => PaletteSeed | undefined, +) { return (config: PaletteConfig): PaletteConfig => ({ ...config, themes: { ...config.themes, - [name]: { ...config.themes?.[name], ...seed }, + [name]: patch(config.themes?.[name]), }, }); } function StatusControls() { const [palette, setPalette] = usePaletteConfig(); + const input = getPaletteConfigInput(); + const seedMode = useSeedMode(); return ( @@ -761,22 +889,51 @@ function StatusControls() { return (
- setPalette(statusSeed(name, { hue }))} - /> - {/* Hidden under pastel, for the reason `StatusThemeButton` spells - out: pastel is one flat ceiling with the palette seed pinned to - the top of it, so a per-theme scale underneath contradicts it. */} - {palette.pastel ? null : ( - - setPalette(statusSeed(name, { saturation })) + {seedMode === 'color' ? ( + + setPalette( + statusSeed(name, () => + color ? color : { hue: Math.round(seed.hue) }, + ), + ) } /> + ) : ( + <> + + setPalette( + statusSeed(name, (current) => ({ + ...seedNumbers(current), + hue, + })), + ) + } + /> + {/* Hidden under pastel, for the reason `StatusThemeButton` spells + out: pastel is one flat ceiling with the palette seed pinned to + the top of it, so a per-theme scale underneath contradicts it. */} + {palette.pastel ? null : ( + + setPalette( + statusSeed(name, (current) => ({ + ...seedNumbers(current), + saturation, + })), + ) + } + /> + )} + )}
); @@ -999,11 +1156,10 @@ function ComponentPanel() { const SAMPLE_CODE = `import { setPaletteConfig } from '@cube-dev/ui-kit'; -// Re-seed the brand, and give danger its own hue. +// Re-seed the brand, and give danger the red the product already ships. setPaletteConfig({ - hue: 210, - saturation: 72, - themes: { danger: { hue: 12 } }, + accent: { hue: 210, saturation: 72 }, + themes: { danger: '#b91c1c' }, });`; function CodePanel() { @@ -1171,11 +1327,12 @@ const ControlGroup = tasty({ * while its siblings are pinned off. `Cube` stays empty by design: it is the shipped * palette, so it should follow the default wherever the default goes. * - * All five depart on the *numbers*. There is deliberately no preset for the other - * half of the API — seeding a zone from a real color — because a preset that only - * demonstrated the mechanism would be a worse teacher than the switch itself: flip - * **Accent seeded by** to Color and the field opens on a color you can replace with - * your own, which is the thing you actually came to do. + * Five depart on the *numbers* and one on colors. `Brand` is the whole other half of + * the API in one press — every zone spelled as a hex, statuses included — and it earns a + * preset now that a status theme can take one: the mechanism is no longer the point, the + * *combination* is, and six hexes that read as one product's palette is a thing the tab + * bar cannot show you by itself. Its colors are placeholders to replace with your own, + * which is still what you came to do. */ const THEME_PRESETS: { label: string; config: PaletteConfig }[] = [ // Empty on purpose: the setter replaces, so this *is* the shipped palette — the @@ -1184,8 +1341,7 @@ const THEME_PRESETS: { label: string; config: PaletteConfig }[] = [ { label: 'Ocean', config: { - hue: 235, - saturation: 70, + accent: { hue: 235, saturation: 70 }, // Pinned off. These three were authored against a non-pastel default and // their saturation seeds are tuned for the per-hue chroma ceiling; now that // the shipped palette is pastel, inheriting it would quietly restyle them @@ -1206,8 +1362,7 @@ const THEME_PRESETS: { label: string; config: PaletteConfig }[] = [ { label: 'Forest', config: { - hue: 128, - saturation: 65, + accent: { hue: 128, saturation: 65 }, pastel: false, themes: { success: { hue: 172 }, @@ -1220,8 +1375,7 @@ const THEME_PRESETS: { label: string; config: PaletteConfig }[] = [ { label: 'Ember', config: { - hue: 48, - saturation: 85, + accent: { hue: 48, saturation: 85 }, pastel: false, themes: { success: { hue: 155 }, @@ -1234,15 +1388,15 @@ const THEME_PRESETS: { label: string; config: PaletteConfig }[] = [ { label: 'Slate', config: { - hue: 250, + accent: { hue: 250 }, // Pastel alone is what mutes this one relative to the three above, which pin // it off — and it needs nothing else, because pastel swaps the per-hue chroma // ceiling for a flat one that sits below where a saturated hue would land. A // pastel accent at the pinned 100 resolves softer than `Ember`'s non-pastel 85, // and evenly across hues rather than letting the warm statuses run ahead. // - // No `saturation` here on purpose: pastel pins it to 100, so a number would be - // inert and would read as doing work it is not doing. + // No `saturation` on the accent here on purpose: pastel pins it to 100, so a + // number would be inert and would read as doing work it is not doing. pastel: true, themes: { success: { hue: 160 }, @@ -1255,6 +1409,29 @@ const THEME_PRESETS: { label: string; config: PaletteConfig }[] = [ }, }, }, + { + label: 'Brand', + config: { + // Every zone as a hex — which is how a brand actually arrives. `pastel` off + // because these are requests for particular colors and a flat ceiling would cap + // the chroma they were chosen for; a color pins no saturation, so leaving it + // absent would infer pastel back on. + pastel: false, + accent: '#4f46e5', + // A base color contributes hue and saturation and discards the tone, so this is + // "lean the chrome slightly warm", not "make the page this dark". + base: '#78716c', + themes: { + // The status hexes a product usually already owns, sitting where the numeric + // presets put their hues — legible as danger/success/warning/aside, and far + // enough from the indigo brand not to read as a second accent. + success: '#15803d', + danger: '#b91c1c', + warning: '#a16207', + note: '#a21caf', + }, + }, + }, ]; /** @@ -1330,9 +1507,11 @@ function StatusThemeButton({ }) { const [palette, setPalette] = usePaletteConfig(); const seed = palette.themes[name]; + const written = getPaletteConfigInput().themes?.[name]; + const seedMode = useSeedMode(); // The SPARSE config: only a saturation that was actually written counts as // pinned. The resolved one always carries a number, inherited or not. - const pinnedSaturation = getPaletteConfigInput().themes?.[name]?.saturation; + const pinnedSaturation = seedNumbers(written).saturation; return (
- setPalette(statusSeed(name, { hue }))} - /> + {/* One control, not two plus a switch. Which form a seed takes is the tab + bar's question, and a derived control is not shown — so in Color mode the + hex is the whole panel and the sliders are gone rather than disabled. */} + {seedMode === 'color' ? ( + + // Clearing lands back on a hue pinned where the color left it. There is + // nothing to unset first: one seed per zone, so writing either form + // replaces the other. + setPalette( + statusSeed(name, () => + color ? color : { hue: Math.round(seed.hue) }, + ), + ) + } + /> + ) : ( + + setPalette( + statusSeed(name, (current) => ({ + ...seedNumbers(current), + hue, + })), + ) + } + /> + )} {/* No saturation slider under pastel. Pastel is one flat chroma ceiling and the palette-level saturation is @@ -1374,7 +1582,7 @@ function StatusThemeButton({ The engine still honours a `themes..saturation` under pastel, which is why the pinned case below gets said out loud rather than hidden: a number set before pastel went on is still in effect. */} - {palette.pastel ? ( + {seedMode === 'color' ? null : palette.pastel ? ( pinnedSaturation !== undefined ? ( Saturation {pinnedSaturation} — pinned @@ -1390,7 +1598,12 @@ function StatusThemeButton({ tooltip="Inherits the palette saturation until you move it, and stays pinned afterwards — so a re-seeded palette leaves this theme where you put it." value={seed.saturation} onChange={(saturation) => - setPalette(statusSeed(name, { saturation })) + setPalette( + statusSeed(name, (current) => ({ + ...seedNumbers(current), + saturation, + })), + ) } /> )} @@ -1404,7 +1617,7 @@ function StatusThemeButton({ * The theme, on its way out of this page. * * The sparse input rather than the resolved config, in both forms: it is far - * shorter, and it is the honest answer, since an inherited `baseHue` written out + * shorter, and it is the honest answer, since an inherited `base` written out * as a number would stop following the accent the moment it was pasted. */ const EXPORT_POPOVER_STYLES: Styles = { diff --git a/src/stories/Usage.docs.mdx b/src/stories/Usage.docs.mdx index 3104228f4..d61b13246 100644 --- a/src/stories/Usage.docs.mdx +++ b/src/stories/Usage.docs.mdx @@ -183,7 +183,7 @@ For example, a success banner uses `fill: '#success-surface'` + `color: '#succes ```ts import { resetPaletteConfig, setPaletteConfig } from '@cube-dev/ui-kit'; -setPaletteConfig({ hue: 210, themes: { danger: { hue: 12 } } }); +setPaletteConfig({ accent: { hue: 210 }, themes: { danger: '#b91c1c' } }); resetPaletteConfig(); ``` @@ -192,7 +192,7 @@ Also available: `getPaletteConfig()`, `subscribePaletteConfig()`, the `usePalett 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: ```tsx - + ``` diff --git a/src/tokens/color-theme.test.ts b/src/tokens/color-theme.test.ts index 441ec5d28..1eebc0167 100644 --- a/src/tokens/color-theme.test.ts +++ b/src/tokens/color-theme.test.ts @@ -163,12 +163,12 @@ describe('getColorTheme', () => { // a near-neutral tint it shifts chroma in the 4th decimal (0.0027 → 0.0015), // which is close enough to round together that the assertion failed in CI // and could not be reproduced locally. - setPaletteConfig({ saturation: 80 }); + setPaletteConfig({ accent: { saturation: 80 } }); const before = getColorTheme({ hue: 200 }); const beforeSurface = before.tokens[before.colors.surface]; - setPaletteConfig({ saturation: 10 }); + setPaletteConfig({ accent: { saturation: 10 } }); const after = getColorTheme({ hue: 200 }); @@ -198,7 +198,7 @@ describe('getColorTheme', () => { // `hue`, `saturation` and `pastel` are all pinned, so re-seeding the brand // hue cannot move this theme — and its slot is replaced, not duplicated. - setPaletteConfig({ hue: 12 }); + setPaletteConfig({ accent: { hue: 12 } }); expect(getColorTheme(config).name).toBe(before.name); }); diff --git a/src/tokens/color-theme.ts b/src/tokens/color-theme.ts index a29721a9d..2b7734ded 100644 --- a/src/tokens/color-theme.ts +++ b/src/tokens/color-theme.ts @@ -133,7 +133,8 @@ 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 - * `accentColor` keeps the tone, because a brand fill has to *be* the colour. + * The palette's accent zone keeps the tone, because a brand fill has to *be* the + * colour. * * An unparseable value falls back to the shipped seed rather than throwing — a hue * typed into a settings field should not take the render down. diff --git a/src/tokens/index.ts b/src/tokens/index.ts index 8c841c355..410adea03 100644 --- a/src/tokens/index.ts +++ b/src/tokens/index.ts @@ -105,9 +105,11 @@ export { export type { PaletteCodeSeed, PaletteConfig, + PaletteNumericSeed, + PaletteSeed, PaletteThemeName, - PaletteThemeSeed, ResolvedPaletteConfig, + ResolvedThemeSeed, SurfaceMode, } from './palette-config'; export { SIZES, SIZE_NAME_TO_KEY, SIZE_TOKENS } from './sizes'; diff --git a/src/tokens/palette-config.ts b/src/tokens/palette-config.ts index 8c6594dd5..ebf621f6f 100644 --- a/src/tokens/palette-config.ts +++ b/src/tokens/palette-config.ts @@ -42,8 +42,7 @@ export const DEFAULT_SATURATION = 100; export const SURFACE_SATURATION_SHARE = 0.12; /** - * Ceiling on a {@link PaletteConfig.baseSaturation} derived from a - * {@link PaletteConfig.baseColor}. + * Ceiling on a base saturation derived from a {@link PaletteConfig.base} color. * * A named base color lands the chrome near itself rather than at the 12% share the * accent lends it, which is the whole reason to name one — but a fully saturated @@ -74,27 +73,46 @@ export const DEFAULT_CODE_SATURATION = 80; // Types // ============================================================================ -/** Hue / saturation seed override for one colored theme. */ -export interface PaletteThemeSeed { +/** The numeric arm of a {@link PaletteSeed}. */ +export interface PaletteNumericSeed { /** Hue in degrees (0–360). */ hue?: number; - /** Saturation (0–100). Defaults to the palette-level `saturation`. */ + /** Saturation (0–100). */ saturation?: number; } +/** + * One zone's seed: a **color**, or the **numbers**. + * + * A string is a color — anything Glaze parses: hex, `rgb()`, `hsl()`, `okhsl()`, + * `okhst()`, `oklch()`. CSS color keywords (`rebeccapurple`) are not supported, and an + * unparseable value warns once and falls back to the numeric path with nothing pinned. + * + * The union **is** the exclusivity. A zone is seeded one way or the other — never both, + * never layered — so there is no precedence rule to learn and no contradiction to warn + * about. It also means a patch that switches form *replaces* rather than merges; layering + * happens within a path. See {@link resolvePaletteConfig}. + * + * What a color supplies differs by zone, and each zone's own field says so: + * {@link PaletteConfig.accent} keeps the tone but lends the palette none of its chroma, + * {@link PaletteConfig.base} discards the tone, and a status theme keeps the tone *and* + * takes the chroma as its seed. + */ +export type PaletteSeed = string | PaletteNumericSeed; + /** * The `code-*` syntax family takes a saturation and nothing else — and answers to * nothing else either. This is the one knob that moves it. * * Its hues are absolute literals by design, so syntax colors never rotate with the - * brand — strings would collide with numbers the moment the brand went green. Unlike - * every other theme, its saturation does **not** inherit the palette-level one: the - * code palette is calibrated once and stays there, so re-seeding the app cannot - * quietly wash out a code block. {@link PaletteConfig.pastel} skips it for the same - * reason. + * brand — strings would collide with numbers the moment the brand went green, which is + * also why it is not a {@link PaletteSeed} and cannot take a color. Unlike every other + * theme, its saturation does **not** inherit the palette-level one: the code palette is + * calibrated once and stays there, so re-seeding the app cannot quietly wash out a code + * block. {@link PaletteConfig.pastel} skips it for the same reason. */ export interface PaletteCodeSeed { - /** Saturation (0–100). Defaults to {@link DEFAULT_CODE_SATURATION}, not to `saturation`. */ + /** Saturation (0–100). Defaults to {@link DEFAULT_CODE_SATURATION}, not to the accent's. */ saturation?: number; } @@ -113,106 +131,109 @@ export type PaletteThemeName = | 'code'; /** - * Palette tuning. Every field is optional; omitted fields keep their default, - * and per-theme fields left unset inherit the palette-level value. + * Palette tuning. Every field is optional; omitted fields keep their default, and + * per-theme fields left unset inherit the palette-level value. + * + * Every zone takes the same {@link PaletteSeed} — a color or the numbers — so the whole + * config is one idea spelled once, and a settings UI can drive all six zones with one + * control. */ export interface PaletteConfig { /** - * Accent zone seeded by a **color** rather than a hue — the brand, as you have it. - * - * Anything Glaze parses: hex, `rgb()`, `hsl()`, `okhsl()`, `okhst()`, `oklch()`. - * CSS color keywords (`rebeccapurple`) are not supported. An unparseable value - * warns and falls back to the numeric seed. - * - * Unlike {@link ColorThemeConfig.hue}, which keeps only hue and saturation, this - * keeps the **tone** as well — which is the whole point. Without it the brand fill - * is authored as a fixed tone step off white, so every accent hue lands at roughly - * the same lightness and the color you asked for never actually appears. - * - * The **light, normal-contrast** variant reproduces the color; dark and high - * contrast adapt, as every other color in the palette does. Two things cost - * exactness even there: {@link PaletteConfig.pastel} caps chroma (so `#FFD400` - * softens), and the fill answers to two APCA floors — **Lc 45 against `surface`** - * so the button reads as a shape, and **Lc 45 against the white label** it carries - * — moving only as far as the nearer one requires. - * - * 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 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; Lc 45 is the guarantee, not the ratio. - * - * {@link PaletteConfig.hue} and {@link PaletteConfig.saturation} still win when - * set — the number is the more specific instruction — and the tone keeps coming - * from here either way, so a preview can rotate the hue without losing the brand's - * lightness. - */ - accentColor?: string; - /** - * Base zone seeded by a **color** rather than a hue and a saturation. + * The **accent** zone — the brand. Drives the `accent-*` family on every theme, + * `primary` / `purple` / `special`, and the brand-tinted odds and ends (`focus`, the + * loading faces, the disabled chip). Its hue is also the default for + * {@link PaletteConfig.base}, and its saturation the default every status theme + * inherits. * - * Its **hue and saturation** are read; its **tone is discarded**, because the - * chrome's lightness ladder is the design — a base color says which way the greys - * lean and how far, not how dark they are. + * **As numbers** — `{ hue, saturation }`. One saturation scale for the whole theme, by + * design: every color's own `saturation` is a 0–1 factor of this seed — `surface` at + * 0.12, `border` at 0.175, the text ramp at 0.2, the accent family at ~1.0 — so moving + * it rescales the palette while keeping those proportions. Unlike hue, which is + * configured separately for the brand and for each status theme, saturation is + * deliberately not split: the ratio between a subtle surface tint and a saturated + * accent is part of the design. * - * The saturation is clipped to {@link MAX_BASE_SATURATION}. Naming a base color - * says "the chrome is this color", so it lands near it rather than at the 12% - * share {@link PaletteConfig.baseSaturation} inherits from the accent — but a - * fully saturated chrome is no longer chrome, so there is a ceiling on how far - * "near" goes. + * A `saturation` **belongs to the non-pastel path**, and writing one says so: with no + * {@link PaletteConfig.pastel} beside it, it turns pastel off, because tuning a + * saturation is the non-pastel path by definition. Pastel is one flat chroma ceiling, + * so under it there is exactly one seed and it is the top of the scale. A + * `pastel: true` written next to a saturation wins and the saturation is ignored (with + * a dev warning) — but it is kept rather than dropped, so turning pastel back off + * restores your number. * - * {@link PaletteConfig.baseHue} and {@link PaletteConfig.baseSaturation} each win - * over it when set — the number is the more specific instruction, the same - * precedence {@link PaletteConfig.accentColor} sits under. - */ - baseColor?: string; - /** - * Accent hue in degrees (0–360) — the brand. + * **As a color** — the brand, as you have it. Unlike a + * {@link PaletteConfig.base} color, which keeps only hue and saturation, this keeps + * the **tone** as well — which is the whole point. Without it the brand fill is + * authored as a fixed tone step off white, so every accent hue lands at roughly the + * same lightness and the color you asked for never actually appears. * - * Drives the `accent-*` family on every theme, `primary` / `purple` / `special`, - * and the brand-tinted odds and ends (`focus`, the loading faces, the disabled - * chip). Also the default for {@link PaletteConfig.baseHue}. - */ - hue?: number; - /** - * Hue of the **base** zone in degrees (0–360): the neutral chrome — `surface` - * and its ladder, the `surface-text*` ramp, `border`, `placeholder`. Defaults to - * `hue`, so the chrome carries a faint tint of the brand unless you say otherwise. - * - * Splitting it lets the chrome sit on a different hue from the accent — a warm - * grey UI with a cool blue brand, say. Only the `default` theme is affected: a - * colored theme's tinted `surface` deliberately follows *its own* hue, because a - * danger banner should read as red. + * The **light, normal-contrast** variant reproduces the color; dark and high contrast + * adapt, as every other color in the palette does. Two things cost exactness even + * there: {@link PaletteConfig.pastel} caps chroma (so `#FFD400` softens), and the fill + * answers to two APCA floors — **Lc 45 against `surface`** so the button reads as a + * shape, and **Lc 45 against the white label** it carries — moving only as far as the + * nearer one requires. + * + * 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 + * 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; Lc 45 + * is the guarantee, not the ratio. + * + * A color's own chroma is **not** adopted as the zone's seed saturation, which stays at + * its default. The accent family gets the chroma through Glaze's `from`, where it is + * absolute and seed-independent, and leaving the seed alone is what keeps a saturated + * brand out of every status theme that inherits it. A status theme's color behaves the + * other way round for the same reason inverted — nothing inherits from a status theme. */ - baseHue?: number; + accent?: PaletteSeed; /** - * Saturation seed (0–100) of the **base** zone — the same family - * {@link PaletteConfig.baseHue} governs: `surface` and its ladder, the + * The **base** zone: the neutral chrome — `surface` and its ladder, the * `surface-text*` ramp, `border`, `placeholder`. * - * On the same 0–100 scale as {@link PaletteConfig.saturation}, and read the same - * way: `100` is a fully saturated base hue. **The shipped chrome is `12`** — a - * faint tint is what a neutral surface is — so the useful range is mostly below - * a third, and the numbers above it are a deliberately tinted theme rather than - * a neutral one. - * - * Left unset it is `saturation × 0.12`, which reproduces the recipe's own factor - * exactly and keeps a muted palette muting the chrome along with everything - * else. Set it and the base zone stops following the brand: a vivid accent over - * near-grey chrome, or a muted accent over visibly warm chrome, are both one - * number away and neither is reachable from a single scale. - * - * The base colors keep their proportions to one another — `border` more than - * `surface`, the text ramp more than `border` — until the highest of them hits - * the top of the scale, which happens around `25`. Past that they converge. - * - * Under {@link PaletteConfig.surfaceMode} `'neutral'` this reaches - * `surface-2`…`surface-4`, `border`, `placeholder` and the text ramp, but not - * the page surface: at the end of the tone scale there is no room for chroma, - * whatever the seed says. `'tinted'` is what gives it somewhere to land. + * **Omit it and the zone follows the accent** — the chrome carries a faint tint of the + * brand, at {@link SURFACE_SATURATION_SHARE} of its chroma, unless you say otherwise. + * That is the third state, and the reason this field is worth reading back from + * {@link getPaletteConfigInput}: absent, an object, or a string are three different + * answers to "does the chrome have a seed of its own". + * + * **As numbers** — `{ hue, saturation }`, each independently optional. Splitting the + * hue off lets the chrome sit on a different one from the accent — a warm grey UI with + * a cool blue brand, say. Only the `default` theme is affected: a colored theme's + * tinted `surface` deliberately follows *its own* hue, because a danger banner should + * read as red. + * + * The saturation is on the same 0–100 scale as the accent's, and read the same way. + * **The shipped chrome is `12`** — a faint tint is what a neutral surface is — so the + * useful range is mostly below a third, and the numbers above it are a deliberately + * tinted theme rather than a neutral one. Set it and the base zone stops following the + * brand: a vivid accent over near-grey chrome, or a muted accent over visibly warm + * chrome, are both one number away and neither is reachable from a single scale. + * + * The base colors keep their proportions to one another — `border` more than `surface`, + * the text ramp more than `border` — until the highest of them hits the top of the + * scale, which happens around `25`. Past that they converge. + * + * Under {@link PaletteConfig.surfaceMode} `'neutral'` it reaches `surface-2`… + * `surface-4`, `border`, `placeholder` and the text ramp, but not the page surface: at + * the end of the tone scale there is no room for chroma, whatever the seed says. + * `'tinted'` is what gives it somewhere to land. + * + * **As a color** — its **hue and saturation** are read; its **tone is discarded**, + * because the chrome's lightness ladder is the design. A base color says which way the + * greys lean and how far, not how dark they are. + * + * The saturation is clipped to {@link MAX_BASE_SATURATION}. Naming a base color says + * "the chrome is this color", so it lands near it rather than at the share it would + * inherit from the accent — but a fully saturated chrome is no longer chrome, so there + * is a ceiling on how far "near" goes. + * + * Unlike the accent's, writing a base saturation does *not* turn pastel off: how much + * hue the chrome carries says nothing about which chroma space the palette is in. */ - baseSaturation?: number; + base?: PaletteSeed; /** * Global. Where the neutral surface ramp sits on the tone scale. * @@ -223,57 +244,35 @@ export interface PaletteConfig { * * Two tones is not a visible lightness change; what it buys is *room*. Chroma * needs distance from white to exist at all, so at the extreme a light page is - * white no matter what {@link PaletteConfig.baseSaturation} says. Tinted trades - * two tones of headroom for a page that actually carries its base hue. + * white no matter what the base saturation says. Tinted trades two tones of + * headroom for a page that actually carries its base hue. * * Everything below `surface` is positioned relative to it, so the ladder, the * borders and the text ramp all follow — and the contrast floors on the text * re-solve against the new background rather than drifting. */ surfaceMode?: SurfaceMode; - /** - * Seed saturation (0–100), and the fallback for every theme that does not set - * its own. - * - * **Belongs to the non-pastel path**, and setting it says so: with no - * {@link PaletteConfig.pastel} beside it, this turns pastel off, because tuning a - * saturation is the non-pastel path by definition. Pastel is one flat chroma ceiling, - * so under it there is exactly one seed and it is the top of the scale. - * - * A `pastel: true` written next to a saturation wins and the saturation is ignored - * (with a dev warning) — but it is kept rather than dropped, so turning pastel back - * off restores your number. - * - * One scale for the whole theme, by design. Every color's own `saturation` is a - * 0–1 factor of this seed — `surface` at 0.12, `border` at 0.175, the text ramp - * at 0.2, the accent family at ~1.0 — so moving it rescales the palette while - * keeping those proportions. Unlike hue, which is configured separately for the - * brand and for each status theme, saturation is deliberately not split: the - * ratio between a subtle surface tint and a saturated accent is part of the - * design. - */ - saturation?: number; /** * Global. Widens the usable chroma range by relaxing the sRGB-safe limit, * producing a softer, more even palette across hues. Glaze treats `pastel` as * instance-level, so it is threaded into every theme. * - * It also **pins {@link PaletteConfig.saturation} to 100**: the even, hue-independent - * ceiling is what pastel is for, and a second saturation scale on top of it would - * only undo that. Two paths, then — pastel with no saturation knob, or - * `pastel: false` with a free 0–100 one. + * It also **pins the accent's saturation to 100**: the even, hue-independent ceiling + * is what pastel is for, and a second saturation scale on top of it would only undo + * that. Two paths, then — pastel with no saturation knob, or `pastel: false` with a + * free 0–100 one. * - * Which is why a lone `saturation` picks the second path for you. State this field - * only to override that: it is the coarser of the two choices, so it wins wherever - * both are set, and a saturation it shadows is ignored with a dev warning. + * Which is why a lone `accent: { saturation }` picks the second path for you. State + * this field only to override that: it is the coarser of the two choices, so it wins + * wherever both are set, and a saturation it shadows is ignored with a dev warning. * - * The same ceiling is why an {@link PaletteConfig.accentColor} cannot render - * exactly under pastel — `#FFD400` softens to `#e4d8ad`. Under pastel a color - * contributes its hue and its tone; turn pastel off to get its chroma too. + * The same ceiling is why a color seed cannot render exactly under pastel — `#FFD400` + * softens to `#e4d8ad`. Under pastel a color contributes its hue and its tone; turn + * pastel off to get its chroma too. * - * Every theme except `code`. The syntax family is calibrated on its own - * saturation and is deliberately left out — softening it collapses the chroma - * spread the syntax hues rely on to stay apart. To soften a code block, lower + * Every theme except `code`. The syntax family is calibrated on its own saturation and + * is deliberately left out — softening it collapses the chroma spread the syntax hues + * rely on to stay apart. To soften a code block, lower * {@link PaletteCodeSeed.saturation} instead. */ pastel?: boolean; @@ -294,12 +293,31 @@ export interface PaletteConfig { * second tier would only duplicate them. */ contrastLevel?: number | 'auto'; - /** Per-theme seed overrides. */ + /** + * Per-theme seed overrides. Each status theme takes the same {@link PaletteSeed} the + * accent zone does, and inherits the accent's saturation until it sets its own. + * + * **As numbers** — `{ hue, saturation }`. Status hues have to stay semantically + * legible (danger red, warning amber, success green) and far enough apart from each + * other and from the brand to read as different things. + * + * **As a color** — the theme's accent family becomes that color, on the same terms + * {@link PaletteConfig.accent} sets: the light/normal-contrast variant reproduces it, + * the softened APCA floors apply instead of the white-anchored ladder's WCAG ones, and + * the tone is capped so the `#white` label every `type="primary"` item paints on the + * fill survives. + * + * Here the color's chroma **does** become the theme's seed saturation, unlike the + * accent's. Nothing inherits from a status theme, so there is nothing to + * re-chromatize — and it is what keeps the theme's tinted banner surface, border and + * text ramp at their shipped proportions to the fill instead of drifting relative to + * it. + */ themes?: { - success?: PaletteThemeSeed; - danger?: PaletteThemeSeed; - warning?: PaletteThemeSeed; - note?: PaletteThemeSeed; + success?: PaletteSeed; + danger?: PaletteSeed; + warning?: PaletteSeed; + note?: PaletteSeed; code?: PaletteCodeSeed; }; } @@ -343,24 +361,61 @@ export interface ResolvedPaletteConfig { /** * The saturation of {@link ResolvedPaletteConfig.accentColor}, or `null` beside it. * - * On the palette's 0–100 scale, like every other saturation here. Kept because the - * accent seed is rebuilt from these three numbers rather than from the literal: - * {@link PaletteConfig.hue} outranks the color's own hue, so handing Glaze the - * original string would let `accent-surface` keep a hue the rest of the ramp has - * already rotated away from. + * On the palette's 0–100 scale, like every other saturation here — and deliberately + * *not* the same number as {@link ResolvedPaletteConfig.saturation}, which a color + * never raises. The accent family gets this chroma through Glaze's `from`, where it is + * absolute; the seed stays where it was so the status themes that inherit it are left + * alone. + * + * Kept as a number because the accent seed is rebuilt from its three components rather + * than handed over as the literal: the tone is capped so a white label survives on the + * fill, and a capped tone means reconstructing the color anyway. */ accentSaturation: number | null; pastel: boolean; contrastLevel: number | 'auto'; themes: { - success: { hue: number; saturation: number }; - danger: { hue: number; saturation: number }; - warning: { hue: number; saturation: number }; - note: { hue: number; saturation: number }; + success: ResolvedThemeSeed; + danger: ResolvedThemeSeed; + warning: ResolvedThemeSeed; + note: ResolvedThemeSeed; code: { saturation: number }; }; } +/** One status theme's seed, resolved. */ +export interface ResolvedThemeSeed { + hue: number; + /** + * The theme's Glaze seed, and a ceiling on every color in it. + * + * On the numeric path this inherits the accent's saturation until the theme pins its + * own. On the color path it **is** the color's chroma, which is what keeps the theme's + * tinted surface, border and text ramp at their shipped proportions to a fill whose + * chroma arrives absolute through `from`. See {@link PaletteConfig.themes} for why the + * accent zone does the opposite. + */ + saturation: number; + /** + * The color as given, handed to Glaze's `from` so this theme's accent family renders + * as that literal value rather than as a shade re-derived from the seed. + * + * `null` — the common case — means the theme is on the numeric path and its accent + * family keeps the white-anchored derivation, which is the only arrangement that + * reproduces the shipped palette bit for bit. + */ + color: string | null; + /** + * The tone of {@link ResolvedThemeSeed.color}, or `null` alongside it. + * + * `from` carries the tone itself, so this exists for the two things that need the + * *number*: the hover brand text sits a fixed tone step past the rest one, and the + * tone is capped so the `#white` label a `type="primary"` item paints on the fill + * survives. Both are arithmetic, and neither can be done to a string. + */ + colorTone: number | null; +} + // ============================================================================ // Resolution // ============================================================================ @@ -380,63 +435,117 @@ const DEFAULT_THEME_HUES = { */ let warnedAboutPastelSaturation = false; +/** + * One zone's seed, normalized to the four things a resolver can ask for. + * + * The single place a {@link PaletteSeed} is taken apart, so the six zones cannot drift + * in how they read one. A string that {@link colorSeed} cannot parse comes back as the + * numeric path with nothing pinned, which is exactly the fallback the docs promise: the + * zone keeps its defaults rather than the render going down over a typo. + */ +function seedOf(seed: PaletteSeed | undefined): { + color: string | null; + hue: number | undefined; + saturation: number | undefined; + tone: number | null; +} { + if (typeof seed === 'string') { + const parsed = colorSeed(seed); + + return parsed + ? { + color: seed, + hue: parsed.hue, + saturation: parsed.saturation, + tone: parsed.tone, + } + : { color: null, hue: undefined, saturation: undefined, tone: null }; + } + + return { + color: null, + hue: seed?.hue, + saturation: seed?.saturation, + tone: null, + }; +} + +/** + * Whether a zone pinned a saturation *as a number*. + * + * A color's chroma is not a pinned saturation — it reaches the palette through `from`, + * not through the seed — so it must not answer the question `pastel` asks below. + */ +function pinnedSaturation(seed: PaletteSeed | undefined): number | undefined { + return typeof seed === 'string' ? undefined : seed?.saturation; +} + function resolveConfig(input: PaletteConfig): ResolvedPaletteConfig { const themes = input.themes ?? {}; - const accent = input.accentColor ? colorSeed(input.accentColor) : null; - const base = input.baseColor ? colorSeed(input.baseColor) : null; + const accent = seedOf(input.accent); + const base = seedOf(input.base); - // A `saturation` with no `pastel` beside it turns pastel OFF. + // A numeric `saturation` with no `pastel` beside it turns pastel OFF. // // Under pastel there is one saturation and it is the top of the scale, so the two // fields cannot both be honoured — but writing a saturation is only ever a request to // tune it, which is the non-pastel path by definition. Reading it as one keeps - // `setPaletteConfig({ saturation: 55 })` doing what it always did. + // `setPaletteConfig({ accent: { saturation: 55 } })` doing what it always did. + // + // A COLOR does not answer this question. `accent: '#7a4dbf'` alone still resolves + // pastel-on, so the color contributes its hue and its tone but not its chroma — the + // documented behaviour, and the reason the tuner writes `pastel: false` explicitly + // when it hands a zone a hex. // // An explicit `pastel` wins, both ways: it is the coarser choice of the two, and a // config that states it is choosing a color space rather than a value on one. - const pastel = input.pastel ?? input.saturation === undefined; + const accentSaturationPin = pinnedSaturation(input.accent); + const pastel = input.pastel ?? accentSaturationPin === undefined; - // Explicit number > derived from a color > shipped default, for both zones. The - // number is the more specific instruction, and keeping it ahead of the color is - // what lets `resolvePaletteConfig({ hue: 30 })` rotate a stored `accentColor` - // without discarding its tone. - const hue = input.hue ?? accent?.hue ?? DEFAULT_HUE; + // The zone's own seed, or the shipped default. There is no third arm to rank: the + // paths are exclusive, so `accent.hue` is the color's on one and the written number on + // the other, and never both. + const hue = accent.hue ?? DEFAULT_HUE; // `base.tone` is never read — that is the whole enforcement of "a base color says // which way the greys lean and how far, not how dark they are". Its saturation // *is* read, below. - const baseHue = input.baseHue ?? base?.hue ?? hue; + const baseHue = base.hue ?? hue; // Only a CONTRADICTION warns — `pastel: true` written next to a saturation it will - // ignore. The inference above means a lone `saturation` is not a contradiction, and a + // ignore. The inference above means a lone saturation is not a contradiction, and a // saturation of exactly 100 is not one either: that is the value pastel pins it to. if ( input.pastel === true && - input.saturation !== undefined && - input.saturation !== DEFAULT_SATURATION && + accentSaturationPin !== undefined && + accentSaturationPin !== DEFAULT_SATURATION && !warnedAboutPastelSaturation ) { warnedAboutPastelSaturation = true; console.warn( - `[cube-ui-kit] palette \`saturation\` (${input.saturation}) is ignored because ` + - `\`pastel\` is on — pastel pins it to ${DEFAULT_SATURATION}. Drop \`pastel\`, ` + - `or set it to \`false\`, to tune saturation yourself.`, + `[cube-ui-kit] palette \`accent.saturation\` (${accentSaturationPin}) is ignored ` + + `because \`pastel\` is on — pastel pins it to ${DEFAULT_SATURATION}. Drop ` + + `\`pastel\`, or set it to \`false\`, to tune saturation yourself.`, ); } - // Deliberately NOT `?? accent?.saturation`: the accent family carries the color's - // own chroma through Glaze's `from`, so the palette-level seed no longer has to be - // raised to reach it. Leaving it alone is what keeps a saturated brand out of every - // status theme, which all inherit this number. + // Deliberately the numeric pin only, NOT `accent.saturation`: the accent family + // carries a color's own chroma through Glaze's `from`, so the palette-level seed no + // longer has to be raised to reach it. Leaving it alone is what keeps a saturated + // brand out of every status theme, which all inherit this number. // // The neutral chrome is the one exception, and it is deliberate — `baseSaturation` // below takes its share of the accent's own chroma so a near-grey brand leaves // near-grey chrome. That is scoped to the base zone and capped by this seed; it does // not pass through here. + // + // A status theme's color is the mirror image, and for the mirror reason: nothing + // inherits from a status theme, so its chroma DOES become its seed. See + // {@link ResolvedThemeSeed.saturation}. const saturation = pastel ? DEFAULT_SATURATION - : input.saturation ?? DEFAULT_SATURATION; + : accentSaturationPin ?? DEFAULT_SATURATION; return { hue, @@ -444,60 +553,48 @@ function resolveConfig(input: PaletteConfig): ResolvedPaletteConfig { saturation, // Three arms, and the middle two are on deliberately different scales. // - // A named `baseColor` means "the chrome IS this color", so it lands near it, - // clipped at `MAX_BASE_SATURATION`. Base merely FOLLOWING the accent means "a - // faint tint of the brand", so it stays the 12% share it has always been — now - // of the accent's own chroma, whether that arrived as a number or as an - // `accentColor`. Reading `accent?.saturation` here is the one place a brand color - // reaches the base zone, and it has to: without it, picking a near-grey brand - // would leave the chrome carrying 12% of a saturation nobody asked for. + // A named base COLOR means "the chrome IS this color", so it lands near it, clipped + // at `MAX_BASE_SATURATION`. Base merely FOLLOWING the accent means "a faint tint of + // the brand", so it stays the 12% share it has always been — now of the accent's own + // chroma, whether that arrived as a number or as a color. Reading `accent.saturation` + // here is the one place a brand color reaches the base zone, and it has to: without + // it, picking a near-grey brand would leave the chrome carrying 12% of a saturation + // nobody asked for. // - // BOTH arms are also capped by `saturation`, and that is load-bearing rather than - // defensive. `baseSaturationScale` divides by the seed, so the chrome's absolute - // chroma is a function of this field ALONE — without the cap, an accent color - // would cancel the seed out of the base zone entirely and a muted - // `saturation: 20` would leave the chrome 4.4x more chromatic than asked for. - // The seed is a ceiling everywhere else in the palette; it is one here too. + // BOTH derived arms are also capped by `saturation`, and that is load-bearing rather + // than defensive. `baseSaturationScale` divides by the seed, so the chrome's absolute + // chroma is a function of this field ALONE — without the cap, an accent color would + // cancel the seed out of the base zone entirely and a muted `saturation: 20` would + // leave the chrome 4.4x more chromatic than asked for. The seed is a ceiling + // everywhere else in the palette; it is one here too. // - // Nothing here reaches the status themes, which is what keeps the guarantee that - // a brand color cannot re-chromatize them. + // A numeric pin is NOT clipped: a number is the more specific instruction, and a + // tuner that offers the range is entitled to the top of it. // - // Unlike the palette seed, writing this does *not* turn pastel off: how much hue - // the chrome carries says nothing about which chroma space the palette is in. + // Nothing here reaches the status themes, which is what keeps the guarantee that an + // accent color cannot re-chromatize them. // // The accent arm sits INSIDE the parentheses on purpose: hoisting it to - // `accent?.saturation ?? saturation * SHARE` would apply the share to only one of - // the two and move the shipped default. + // `accent.saturation ?? saturation * SHARE` would apply the share to only one of the + // two and move the shipped default. baseSaturation: - input.baseSaturation ?? - (base - ? Math.min(base.saturation, MAX_BASE_SATURATION, saturation) - : Math.min(accent?.saturation ?? saturation, saturation) * + pinnedSaturation(input.base) ?? + (base.color + ? Math.min(base.saturation!, MAX_BASE_SATURATION, saturation) + : Math.min(accent.saturation ?? saturation, saturation) * SURFACE_SATURATION_SHARE), surfaceMode: input.surfaceMode ?? 'neutral', - accentColor: accent ? input.accentColor! : null, - baseColor: base ? input.baseColor! : null, - accentTone: accent?.tone ?? null, - accentSaturation: accent?.saturation ?? null, + accentColor: accent.color, + baseColor: base.color, + accentTone: accent.tone, + accentSaturation: accent.color === null ? null : accent.saturation!, pastel, contrastLevel: input.contrastLevel ?? 'auto', themes: { - success: { - hue: themes.success?.hue ?? DEFAULT_THEME_HUES.success, - saturation: themes.success?.saturation ?? saturation, - }, - danger: { - hue: themes.danger?.hue ?? DEFAULT_THEME_HUES.danger, - saturation: themes.danger?.saturation ?? saturation, - }, - warning: { - hue: themes.warning?.hue ?? DEFAULT_THEME_HUES.warning, - saturation: themes.warning?.saturation ?? saturation, - }, - note: { - hue: themes.note?.hue ?? DEFAULT_THEME_HUES.note, - saturation: themes.note?.saturation ?? saturation, - }, + success: resolveThemeSeed('success', themes.success, saturation), + danger: resolveThemeSeed('danger', themes.danger, saturation), + warning: resolveThemeSeed('warning', themes.warning, saturation), + note: resolveThemeSeed('note', themes.note, saturation), // Deliberately NOT `?? saturation`: the code palette is calibrated once and // does not follow the palette-level seed. See `PaletteCodeSeed`. code: { saturation: themes.code?.saturation ?? DEFAULT_CODE_SATURATION }, @@ -505,6 +602,39 @@ function resolveConfig(input: PaletteConfig): ResolvedPaletteConfig { }; } +/** + * One status theme's seed, on whichever path it is on. + * + * The color arm takes all three of the color's components — hue, chroma and tone — where + * the accent zone deliberately leaves the chroma out of its seed. The asymmetry is the + * point: this seed is inherited by nothing, so raising it re-chromatizes nothing, and + * raising it is what holds the theme's tinted surface, border and text ramp in the same + * proportion to the fill that the shipped derivation gives them. + */ +function resolveThemeSeed( + name: keyof typeof DEFAULT_THEME_HUES, + seed: PaletteSeed | undefined, + saturation: number, +): ResolvedThemeSeed { + const parsed = seedOf(seed); + + if (parsed.color !== null) { + return { + hue: parsed.hue!, + saturation: parsed.saturation!, + color: parsed.color, + colorTone: parsed.tone, + }; + } + + return { + hue: parsed.hue ?? DEFAULT_THEME_HUES[name], + saturation: parsed.saturation ?? saturation, + color: null, + colorTone: null, + }; +} + /** * The palette as it ships, with no tuning applied. Frozen — it is the baseline * every resolution falls back to, so a stray write to it would move the defaults @@ -530,13 +660,30 @@ let version = 0; const listeners = new Set<() => void>(); -function mergeSeed( - base: T | undefined, - patch: T | undefined, -): T | undefined { +function mergeSeed( + base: PaletteSeed | undefined, + patch: PaletteSeed | undefined, +): PaletteSeed | undefined { + // A patch that omits the zone entirely says nothing about it, so the base survives. + // Clearing one of its fields is `{ zone: { hue: undefined } }`. + if (patch === undefined) return base; + if (base === undefined) return patch; + + // Either side being a color makes this a change of PATH, not of value, and a path is + // replaced rather than merged — spreading a hex into an object is meaningless in one + // direction and would resurrect a discarded color in the other. Layering is a + // within-path operation, which is the whole reason `PaletteSeed` is a union. + if (typeof base === 'string' || typeof patch === 'string') return patch; + + return { ...base, ...patch }; +} + +/** {@link mergeSeed} for the code seed, which is not a {@link PaletteSeed}. */ +function mergeCodeSeed( + base: PaletteCodeSeed | undefined, + patch: PaletteCodeSeed | undefined, +): PaletteCodeSeed | undefined { if (!base) return patch; - // A patch that omits the theme entirely says nothing about it, so the base - // survives. Clearing one of its fields is `{ theme: { hue: undefined } }`. if (!patch) return base; return { ...base, ...patch }; @@ -547,7 +694,18 @@ function mergeSeed( * previews — *not* by {@link setPaletteConfig}, which replaces. */ function mergeInput(base: PaletteConfig, patch: PaletteConfig): PaletteConfig { - const next: PaletteConfig = { ...base, ...patch }; + const next: PaletteConfig = { + ...base, + ...patch, + // Spread alone would replace a zone's whole seed, so a preview of + // `{ accent: { hue: 30 } }` over a pinned saturation would silently drop it. + ...(base.accent !== undefined || patch.accent !== undefined + ? { accent: mergeSeed(base.accent, patch.accent) } + : null), + ...(base.base !== undefined || patch.base !== undefined + ? { base: mergeSeed(base.base, patch.base) } + : null), + }; if (base.themes || patch.themes) { next.themes = { @@ -555,7 +713,7 @@ function mergeInput(base: PaletteConfig, patch: PaletteConfig): PaletteConfig { danger: mergeSeed(base.themes?.danger, patch.themes?.danger), warning: mergeSeed(base.themes?.warning, patch.themes?.warning), note: mergeSeed(base.themes?.note, patch.themes?.note), - code: mergeSeed(base.themes?.code, patch.themes?.code), + code: mergeCodeSeed(base.themes?.code, patch.themes?.code), }; } @@ -569,8 +727,8 @@ function isSameConfig(a: ResolvedPaletteConfig, b: ResolvedPaletteConfig) { a.saturation === b.saturation && a.baseSaturation === b.baseSaturation && a.surfaceMode === b.surfaceMode && - // The colors themselves, not just what they derived: `hue` no longer carries the - // accent's chroma (the family gets that through Glaze's `from`), so two brands can + // The colors themselves, not just what they derived: a color's chroma no longer + // reaches `saturation` (the family gets it through Glaze's `from`), so two brands can // agree on every numeric seed and still render differently. And a base color's // derived pair collapses — everything above `MAX_BASE_SATURATION` on one hue lands // on the same two numbers, and its tone is discarded outright — so comparing the @@ -584,7 +742,11 @@ function isSameConfig(a: ResolvedPaletteConfig, b: ResolvedPaletteConfig) { (['success', 'danger', 'warning', 'note'] as const).every( (name) => a.themes[name].hue === b.themes[name].hue && - a.themes[name].saturation === b.themes[name].saturation, + a.themes[name].saturation === b.themes[name].saturation && + // A status color's hue and chroma DO land in the two numbers above, so this + // catches only what they cannot: the tone, and one unparseable string replacing + // another. + a.themes[name].color === b.themes[name].color, ) ); } @@ -593,43 +755,51 @@ function isSameConfig(a: ResolvedPaletteConfig, b: ResolvedPaletteConfig) { * Which fields are *explicitly set*, as an order-independent signature. * * Two configs can resolve to identical values yet differ in which of those values - * are pinned rather than inherited — pinning `primary.saturation` to the number it - * already inherited is the obvious case. That difference is invisible to + * are pinned rather than inherited — pinning a status theme's saturation to the number + * it already inherited is the obvious case. That difference is invisible to * {@link isSameConfig} but visible through {@link getPaletteConfigInput}, so it has * to count as a change or a settings UI reading it would never re-render. */ function pinSignature(config: PaletteConfig): string { const set = (value: unknown) => (value === undefined ? '0' : '1'); - // The two color fields carry their VALUE, not just their presence. Presence alone - // cannot tell one unparseable string from another: both resolve to `null`, so - // `isSameConfig` sees no movement either, and replacing `'bad-one'` with - // `'bad-two'` returned early — leaving `getPaletteConfigInput().accentColor` on - // the first string with no notification that the write was dropped. - // - // It also means two spellings of the same color (`'#ff0000'` / `'rgb(255 0 0)'`) - // now bump the version. That is the same argument the presence check was already - // making: what a settings UI reads back changed, so it has to re-render. - const seed = (value: string | undefined) => JSON.stringify(value ?? null); + + /** + * One zone, in three states — absent, numbers, or a color. + * + * **Absent is its own state**, not "numbers with nothing pinned". `base: {}` and no + * `base` at all resolve identically, but they read back differently, and a settings UI + * asks exactly that question to decide whether the chrome has a seed of its own or is + * still following the accent. + * + * A color carries its **value**, not just its presence. Presence alone cannot tell one + * unparseable string from another: both resolve to `null`, so `isSameConfig` sees no + * movement either, and replacing `'bad-one'` with `'bad-two'` returned early — leaving + * the field on the first string with no notification that the write was dropped. + * + * It also means two spellings of the same color (`'#ff0000'` / `'rgb(255 0 0)'`) bump + * the version. That is the same argument the presence check was already making: what a + * settings UI reads back changed, so it has to re-render. And a zone switching from + * `{ hue: 45 }` to a color that happens to derive hue 45 resolves to the same numbers, + * so without this the version would never bump and the control would look stuck. + */ + const seed = (value: PaletteSeed | undefined) => + value === undefined + ? '-' + : typeof value === 'string' + ? JSON.stringify(value) + : `${set(value.hue)}${set(value.saturation)}`; return [ - // Both color seeds count, and they have to: a UI switching `{ hue: 45 }` for an - // `accentColor` that happens to derive hue 45 resolves to the same numbers, so - // without these the version would never bump and the control would look stuck. - seed(config.accentColor), - seed(config.baseColor), - set(config.hue), - set(config.baseHue), - set(config.saturation), - set(config.baseSaturation), + seed(config.accent), + seed(config.base), set(config.surfaceMode), set(config.pastel), set(config.contrastLevel), set(config.themes?.code?.saturation), - ...(['success', 'danger', 'warning', 'note'] as const).flatMap((name) => [ - set(config.themes?.[name]?.hue), - set(config.themes?.[name]?.saturation), - ]), - ].join(''); + ...(['success', 'danger', 'warning', 'note'] as const).map((name) => + seed(config.themes?.[name]), + ), + ].join('|'); } /** @@ -648,28 +818,48 @@ function freezeConfig( const themes = config.themes; if (themes) { + // A string seed is already immutable, and `Object.freeze` on one is a no-op that + // TypeScript would rather we did not ask for. for (const seed of Object.values(themes)) { - if (seed) Object.freeze(seed); + if (seed && typeof seed === 'object') Object.freeze(seed); } Object.freeze(themes); } + // Only the sparse INPUT carries zone seeds; the resolved config is flat, so this half + // is a no-op for it rather than a branch either caller has to know about. + const zones = config as Partial; + + if (typeof zones.accent === 'object') Object.freeze(zones.accent); + if (typeof zones.base === 'object') Object.freeze(zones.base); + return Object.freeze(config); } /** {@link freezeConfig} on a copy, for a config we did not create ourselves. */ function snapshotConfig(config: PaletteConfig): PaletteConfig { const themes = config.themes; + // Copy the object arm; a string is already a value. + const copy = (seed: PaletteSeed | undefined) => + typeof seed === 'object' ? { ...seed } : seed; return freezeConfig({ ...config, + ...(config.accent !== undefined ? { accent: copy(config.accent) } : null), + ...(config.base !== undefined ? { base: copy(config.base) } : null), ...(themes ? { themes: { - ...(themes.success ? { success: { ...themes.success } } : null), - ...(themes.danger ? { danger: { ...themes.danger } } : null), - ...(themes.warning ? { warning: { ...themes.warning } } : null), - ...(themes.note ? { note: { ...themes.note } } : null), + ...(themes.success !== undefined + ? { success: copy(themes.success) } + : null), + ...(themes.danger !== undefined + ? { danger: copy(themes.danger) } + : null), + ...(themes.warning !== undefined + ? { warning: copy(themes.warning) } + : null), + ...(themes.note !== undefined ? { note: copy(themes.note) } : null), ...(themes.code ? { code: { ...themes.code } } : null), }, } @@ -706,8 +896,8 @@ function commit(nextInput: PaletteConfig) { * object, and re-applying the same object twice is the same as applying it once. * * ```ts - * setPaletteConfig({ hue: 200, baseHue: 60 }); - * setPaletteConfig({ hue: 200 }); // baseHue is gone — back to inheriting `hue` + * setPaletteConfig({ accent: { hue: 200 }, base: { hue: 60 } }); + * setPaletteConfig({ accent: { hue: 200 } }); // `base` is gone — follows accent again * ``` * * To adjust one field of the config already in place — a slider in a settings UI — @@ -715,7 +905,7 @@ function commit(nextInput: PaletteConfig) { * preserves which fields are pinned and which still inherit: * * ```ts - * setPaletteConfig((config) => ({ ...config, hue: 200 })); + * setPaletteConfig((config) => ({ ...config, accent: { hue: 200 } })); * ``` * * The palette is rebuilt lazily on the next token read, and any mounted `` @@ -763,10 +953,10 @@ export function getPaletteConfig(): ResolvedPaletteConfig { * to offer a way back: * * ```ts - * const pinned = getPaletteConfigInput().baseHue !== undefined; + * const own = getPaletteConfigInput().base !== undefined; * * // Re-link it to the brand by dropping the field. - * setPaletteConfig(({ baseHue, ...config }) => config); + * setPaletteConfig(({ base, ...config }) => config); * ``` * * It is also the value handed to a {@link setPaletteConfig} updater. @@ -835,12 +1025,13 @@ export function usePaletteVersion(): number { * * setPalette((config) => ({ ...config, hue }))} + * onChange={(hue) => setPalette((config) => ({ ...config, accent: { hue } }))} * /> * ``` * - * The first element is the *resolved* config, so `palette.hue` is always a number. - * The updater's argument is the sparse one — see {@link getPaletteConfigInput}. + * The first element is the *resolved* config, so `palette.hue` is always a number — it + * keeps the flat shape the recipe reads, whichever way the zones were seeded. The + * updater's argument is the sparse one — see {@link getPaletteConfigInput}. * * The config is global process state, so every consumer of this hook — and every * mounted `` — sees the same palette. diff --git a/src/tokens/palette.test.ts b/src/tokens/palette.test.ts index dd4174040..dc4f2b4c1 100644 --- a/src/tokens/palette.test.ts +++ b/src/tokens/palette.test.ts @@ -20,6 +20,8 @@ import { } from './palette'; import { DEFAULT_CODE_SATURATION, + DEFAULT_DANGER_HUE, + DEFAULT_NOTE_HUE, DEFAULT_PALETTE_CONFIG, getPaletteConfig, getPaletteConfigInput, @@ -27,15 +29,29 @@ import { invalidatePaletteTokens, MAX_BASE_SATURATION, resetPaletteConfig, + resolvePaletteConfig, setPaletteConfig, subscribePaletteConfig, } from './palette-config'; import type { Styles, Tokens } from '@tenphi/tasty'; -import type { PaletteConfig } from './palette-config'; +import type { PaletteConfig, PaletteSeed } from './palette-config'; type TokenStates = Record; +/** + * The numeric arm of a seed read back from the sparse config, or `undefined`. + * + * A zone on the color path has no numbers to report — the point of the union — so + * asking one for its `hue` is a question with no answer rather than a type error to + * cast away. + */ +function pinnedNumbers( + seed: PaletteSeed | undefined, +): { hue?: number; saturation?: number } | undefined { + return typeof seed === 'object' ? seed : undefined; +} + const CODE_TOKENS = [ '#code-comment', '#code-punctuation', @@ -308,7 +324,7 @@ describe('setPaletteConfig', () => { }); it('re-seeds the brand hue and restores it on reset', () => { - setPaletteConfig({ hue: 30 }); + setPaletteConfig({ accent: { hue: 30 } }); const tuned = dumpTokens(getPaletteTokens()); @@ -332,7 +348,7 @@ describe('setPaletteConfig', () => { // Re-seed the brand onto `code-number`'s hue: with `code-string` still // tracking the brand seed, strings and numbers would become // indistinguishable in a code block. - setPaletteConfig({ hue: 156.9 }); + setPaletteConfig({ accent: { hue: 156.9 } }); const after = getPaletteTokens(); @@ -358,7 +374,7 @@ describe('setPaletteConfig', () => { }); it('moves the base zone without touching the accent zone', () => { - setPaletteConfig({ baseHue: 60 }); + setPaletteConfig({ base: { hue: 60 } }); const tuned = dumpTokens(getPaletteTokens()); const moved = Object.keys(baseline).filter( @@ -388,7 +404,7 @@ describe('setPaletteConfig', () => { }); it('leaves a colored theme tinted with its own hue, not the base hue', () => { - setPaletteConfig({ baseHue: 60 }); + setPaletteConfig({ base: { hue: 60 } }); const tuned = dumpTokens(getPaletteTokens()); @@ -402,7 +418,7 @@ describe('setPaletteConfig', () => { it('ships with a white page surface that no saturation can tint', () => { // The premise `surfaceMode: 'tinted'` exists to fix: at the end of the tone // scale there is no room for chroma, so the base seed has nothing to act on. - setPaletteConfig({ pastel: false, baseSaturation: 100 }); + setPaletteConfig({ base: { saturation: 100 }, pastel: false }); expect(chromaOf(variant(getPaletteTokens(), '')['#surface'])).toBe(0); }); @@ -473,7 +489,7 @@ describe('setPaletteConfig', () => { renderColorTokens({ surfaceMode: 'tinted', pastel: false, - baseSaturation, + base: { saturation: baseSaturation }, })['#surface'] as string ).toString(), ); @@ -484,12 +500,16 @@ describe('setPaletteConfig', () => { }); it('keeps the accent zone still while the base seed moves', () => { - setPaletteConfig({ pastel: false, saturation: 80 }); + setPaletteConfig({ accent: { saturation: 80 }, pastel: false }); const baseline80 = variant(getPaletteTokens(), ''); // Under `80` the base zone sits at `9.6`; `2` is genuinely below it. - setPaletteConfig({ pastel: false, saturation: 80, baseSaturation: 2 }); + setPaletteConfig({ + accent: { saturation: 80 }, + base: { saturation: 2 }, + pastel: false, + }); const muted = variant(getPaletteTokens(), ''); @@ -500,7 +520,11 @@ describe('setPaletteConfig', () => { ); // …and takes on more when asked, rather than only ever less. - setPaletteConfig({ pastel: false, saturation: 80, baseSaturation: 30 }); + setPaletteConfig({ + accent: { saturation: 80 }, + base: { saturation: 30 }, + pastel: false, + }); const tinted = variant(getPaletteTokens(), ''); @@ -517,12 +541,16 @@ describe('setPaletteConfig', () => { }); it('follows the palette seed at the recipe share until the base seed is set', () => { - setPaletteConfig({ pastel: false, saturation: 55 }); + setPaletteConfig({ accent: { saturation: 55 }, pastel: false }); // 12% of the seed — the factor `surface` carries in the recipe. expect(getPaletteConfig().baseSaturation).toBeCloseTo(6.6, 5); - setPaletteConfig({ pastel: false, saturation: 55, baseSaturation: 20 }); + setPaletteConfig({ + accent: { saturation: 55 }, + base: { saturation: 20 }, + pastel: false, + }); expect(getPaletteConfig().baseSaturation).toBe(20); expect(getPaletteConfig().saturation).toBe(55); @@ -533,7 +561,7 @@ describe('setPaletteConfig', () => { // Stating the number the default resolves to has to be a no-op, or the two // halves of the default have drifted apart. - setPaletteConfig({ baseSaturation: 100 * 0.12 }); + setPaletteConfig({ base: { saturation: 100 * 0.12 } }); const stated = variant(getPaletteTokens(), ''); @@ -551,40 +579,44 @@ describe('setPaletteConfig', () => { // A palette-level `saturation` turns pastel off, because tuning it is the // non-pastel path. How much hue the chrome carries says nothing about which // chroma space the palette is in, so this one must not. - setPaletteConfig({ baseSaturation: 40 }); + setPaletteConfig({ base: { saturation: 40 } }); expect(getPaletteConfig().pastel).toBe(true); }); it('unlinks a status saturation from the palette seed once set explicitly', () => { // Until it is set, `warning` follows the palette seed… - setPaletteConfig({ saturation: 50 }); + setPaletteConfig({ accent: { saturation: 50 } }); expect(getPaletteConfig().themes.warning.saturation).toBe(50); // …setting it pins it… setPaletteConfig({ - saturation: 50, + accent: { saturation: 50 }, themes: { warning: { saturation: 90 } }, }); expect(getPaletteConfig().themes.warning.saturation).toBe(90); // …and it then stays put while the seed keeps moving, as long as the config // carrying it survives — which is what the updater form is for. - setPaletteConfig((config) => ({ ...config, saturation: 30 })); + setPaletteConfig((config) => ({ ...config, accent: { saturation: 30 } })); expect(getPaletteConfig().saturation).toBe(30); expect(getPaletteConfig().themes.warning.saturation).toBe(90); // Dropping the pin makes it inherit again. - setPaletteConfig({ saturation: 20 }); + setPaletteConfig({ accent: { saturation: 20 } }); expect(getPaletteConfig().themes.warning.saturation).toBe(20); }); it('reports which fields are pinned and which still inherit', () => { - expect(getPaletteConfigInput().themes?.warning?.saturation).toBeUndefined(); + expect( + pinnedNumbers(getPaletteConfigInput().themes?.warning)?.saturation, + ).toBeUndefined(); setPaletteConfig({ themes: { warning: { saturation: 90 } } }); - expect(getPaletteConfigInput().themes?.warning?.saturation).toBe(90); + expect( + pinnedNumbers(getPaletteConfigInput().themes?.warning)?.saturation, + ).toBe(90); }); it('notifies when only the pinned-ness changes, not the values', () => { @@ -599,23 +631,27 @@ describe('setPaletteConfig', () => { setPaletteConfig({ themes: { warning: { saturation: inherited } } }); expect(seen).toHaveLength(1); - expect(getPaletteConfigInput().themes?.warning?.saturation).toBe(inherited); + expect( + pinnedNumbers(getPaletteConfigInput().themes?.warning)?.saturation, + ).toBe(inherited); // Clearing it again is likewise observable. setPaletteConfig({ themes: { warning: { saturation: undefined } } }); expect(seen).toHaveLength(2); - expect(getPaletteConfigInput().themes?.warning?.saturation).toBeUndefined(); + expect( + pinnedNumbers(getPaletteConfigInput().themes?.warning)?.saturation, + ).toBeUndefined(); // Pinning a field that was inherited is itself a change, so the first write // notifies… - setPaletteConfig({ hue: 200 }); + setPaletteConfig({ accent: { hue: 200 } }); expect(seen).toHaveLength(3); // …but re-applying an already-pinned value costs nothing. This is the // `` case: an inline literal on every render. - setPaletteConfig({ hue: 200 }); - setPaletteConfig({ hue: 200 }); + setPaletteConfig({ accent: { hue: 200 } }); + setPaletteConfig({ accent: { hue: 200 } }); expect(seen).toHaveLength(3); @@ -624,33 +660,35 @@ describe('setPaletteConfig', () => { it('drops a field that the new config leaves out', () => { setPaletteConfig({ - saturation: 50, + accent: { saturation: 50 }, themes: { warning: { saturation: 90 } }, }); expect(getPaletteConfig().themes.warning.saturation).toBe(90); // Omitting the pin is how you remove it — no `undefined` needed. - setPaletteConfig({ saturation: 50 }); + setPaletteConfig({ accent: { saturation: 50 } }); expect(getPaletteConfig().themes.warning.saturation).toBe(50); - expect(getPaletteConfigInput().themes?.warning?.saturation).toBeUndefined(); + expect( + pinnedNumbers(getPaletteConfigInput().themes?.warning)?.saturation, + ).toBeUndefined(); // An explicit `undefined` is equivalent, since neither is a value. - setPaletteConfig({ saturation: undefined }); + setPaletteConfig({ accent: { saturation: undefined } }); expect(getPaletteConfig().saturation).toBe( DEFAULT_PALETTE_CONFIG.saturation, ); // Dropping it from inside an updater works the same way. - setPaletteConfig({ hue: 200, baseHue: 60 }); - setPaletteConfig(({ baseHue, ...config }) => config); + setPaletteConfig({ accent: { hue: 200 }, base: { hue: 60 } }); + setPaletteConfig(({ base, ...config }) => config); expect(getPaletteConfig().baseHue).toBe(200); }); it('cascades a palette-level saturation into themes that set none', () => { - setPaletteConfig({ saturation: 40 }); + setPaletteConfig({ accent: { saturation: 40 } }); expect(getPaletteConfig().themes.danger.saturation).toBe(40); expect(getPaletteConfig().themes.warning.saturation).toBe(40); @@ -661,7 +699,7 @@ describe('setPaletteConfig', () => { ...config, themes: { warning: { saturation: 95 } }, })); - setPaletteConfig((config) => ({ ...config, saturation: 55 })); + setPaletteConfig((config) => ({ ...config, accent: { saturation: 55 } })); expect(getPaletteConfig().themes.warning.saturation).toBe(95); expect(getPaletteConfig().themes.danger.saturation).toBe(55); @@ -727,7 +765,7 @@ describe('setPaletteConfig', () => { }); it('replaces the whole config rather than accumulating', () => { - setPaletteConfig({ hue: 30 }); + setPaletteConfig({ accent: { hue: 30 } }); setPaletteConfig({ themes: { note: { hue: 12 } } }); // The second call did not mention `hue`, so there is no `hue` any more. @@ -738,7 +776,7 @@ describe('setPaletteConfig', () => { it('keeps sibling themes when an updater patches one nested seed', () => { // The shape every one-field control in a settings UI needs: `themes` is one // field, so patching a seed without spreading it drops the other three. - setPaletteConfig({ hue: 30, themes: { danger: { hue: 12 } } }); + setPaletteConfig({ accent: { hue: 30 }, themes: { danger: { hue: 12 } } }); setPaletteConfig((config) => ({ ...config, themes: { ...config.themes, note: { hue: 200 } }, @@ -752,7 +790,7 @@ describe('setPaletteConfig', () => { }); it('layers onto the current config from an updater', () => { - setPaletteConfig({ hue: 30 }); + setPaletteConfig({ accent: { hue: 30 } }); setPaletteConfig((config) => ({ ...config, themes: { note: { hue: 12 } }, @@ -763,7 +801,7 @@ describe('setPaletteConfig', () => { }); it('hands the updater the sparse config, not the resolved one', () => { - setPaletteConfig({ hue: 30 }); + setPaletteConfig({ accent: { hue: 30 } }); let seen: PaletteConfig | undefined; setPaletteConfig((config) => { @@ -772,13 +810,13 @@ describe('setPaletteConfig', () => { return config; }); - // `baseHue` inherits `hue`, and the updater has to be able to tell that from - // a `baseHue` pinned to 30 — otherwise spreading would silently pin it. - expect(seen).toEqual({ hue: 30 }); + // The base zone inherits the accent, and the updater has to be able to tell that + // from a `base` pinned to the same hue — otherwise spreading would silently pin it. + expect(seen).toEqual({ accent: { hue: 30 } }); }); it('resets to the shipped config', () => { - setPaletteConfig({ hue: 30, pastel: true, contrastLevel: 40 }); + setPaletteConfig({ accent: { hue: 30 }, pastel: true, contrastLevel: 40 }); resetPaletteConfig(); expect(getPaletteConfig()).toEqual(DEFAULT_PALETTE_CONFIG); @@ -787,7 +825,7 @@ describe('setPaletteConfig', () => { it('invalidates the downstream token caches', () => { const before = getColorTokens()['#surface']; - setPaletteConfig({ hue: 30 }); + setPaletteConfig({ accent: { hue: 30 } }); expect(getColorTokens()['#surface']).not.toEqual(before); }); @@ -871,11 +909,11 @@ describe('code syntax tokens', () => { const before = CODE_TOKENS.map(lightOf); // Halving the palette saturation used to halve the syntax chroma with it. - setPaletteConfig({ saturation: 40 }); + setPaletteConfig({ accent: { saturation: 40 } }); expect(CODE_TOKENS.map(lightOf)).toEqual(before); - setPaletteConfig({ saturation: 100 }); + setPaletteConfig({ accent: { saturation: 100 } }); expect(CODE_TOKENS.map(lightOf)).toEqual(before); }); @@ -890,7 +928,7 @@ describe('code syntax tokens', () => { it('keeps its own saturation while the rest of the palette moves', () => { setPaletteConfig({ - saturation: 30, + accent: { saturation: 30 }, themes: { code: { saturation: 90 } }, }); @@ -898,12 +936,12 @@ describe('code syntax tokens', () => { // Every other theme follows the palette-level seed… expect(getPaletteConfig().themes.danger.saturation).toBe(30); // …and `code` keeps its own even after the palette-level one moves again. - setPaletteConfig((config) => ({ ...config, saturation: 70 })); + setPaletteConfig((config) => ({ ...config, accent: { saturation: 70 } })); expect(getPaletteConfig().themes.code.saturation).toBe(90); }); it('defaults to the shipped saturation rather than inheriting', () => { - setPaletteConfig({ saturation: 20 }); + setPaletteConfig({ accent: { saturation: 20 } }); // `DEFAULT_CODE_SATURATION`, not `DEFAULT_PALETTE_CONFIG.saturation`: the two // parted ways when the app seed moved to 100 for pastel. Asserting against the @@ -969,7 +1007,10 @@ describe('code syntax tokens', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); // 0.12 * 100 / 5 = 2.4, past Glaze's 0–1 factor range. - setPaletteConfig({ saturation: 100, themes: { code: { saturation: 5 } } }); + setPaletteConfig({ + accent: { saturation: 100 }, + themes: { code: { saturation: 5 } }, + }); getPaletteTokens(); expect(warn).toHaveBeenCalledWith( @@ -1025,7 +1066,10 @@ describe('renderPaletteTokens', () => { const before = dumpTokens(getPaletteTokens()); const baseline = renderPaletteTokens({ scheme: 'light' }); - const preview = renderPaletteTokens({ hue: 30, scheme: 'light' }); + const preview = renderPaletteTokens({ + accent: { hue: 30 }, + scheme: 'light', + }); expect(preview['#accent-surface']).not.toBe(baseline['#accent-surface']); // The live palette and the stored config must be untouched. @@ -1034,7 +1078,7 @@ describe('renderPaletteTokens', () => { }); it('merges over the current config rather than the shipped defaults', () => { - setPaletteConfig({ saturation: 20 }); + setPaletteConfig({ accent: { saturation: 20 } }); expect(renderPaletteTokens({ scheme: 'light' })['#accent-surface']).toBe( getPaletteTokens()['#accent-surface']?.[''], @@ -1104,13 +1148,15 @@ describe('renderPaletteTokens', () => { // 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 })).toEqual( - renderPaletteTokens({ contrastLevel: 'auto', scheme }), + expect(renderPaletteTokens({ contrastLevel: 0, scheme: scheme })).toEqual( + renderPaletteTokens({ contrastLevel: 'auto', scheme: scheme }), ); - expect(renderPaletteTokens({ contrastLevel: 100, scheme })).toEqual( + expect( + renderPaletteTokens({ contrastLevel: 100, scheme: scheme }), + ).toEqual( renderPaletteTokens({ contrastLevel: 'auto', - scheme, + scheme: scheme, highContrast: true, }), ); @@ -1119,7 +1165,7 @@ describe('renderPaletteTokens', () => { it('interpolates between the tiers at intermediate levels', () => { const at = (contrastLevel: number | 'auto') => - renderPaletteTokens({ contrastLevel, scheme: 'light' }); + renderPaletteTokens({ contrastLevel: contrastLevel, scheme: 'light' }); const low = at(0); const mid = at(50); @@ -1260,17 +1306,20 @@ describe('config immutability', () => { }); it('does not follow the caller mutating the object afterwards', () => { - const config: PaletteConfig = { hue: 200 }; + const accent = { hue: 200 }; + const config: PaletteConfig = { accent }; setPaletteConfig(config); - config.hue = 300; + // Both levels: the config object the caller still holds, and the seed inside it. + config.accent = { hue: 400 }; + accent.hue = 300; expect(getPaletteConfig().hue).toBe(200); - expect(getPaletteConfigInput().hue).toBe(200); + expect(pinnedNumbers(getPaletteConfigInput().accent)?.hue).toBe(200); }); it('freezes what it hands out, so a stray write cannot desync the caches', () => { - setPaletteConfig({ hue: 200, themes: { danger: { hue: 12 } } }); + setPaletteConfig({ accent: { hue: 200 }, themes: { danger: { hue: 12 } } }); // Silent corruption is the failure being prevented: a write that landed // would move the config without bumping the version, leaving every token @@ -1326,7 +1375,7 @@ describe('accent color seeds', () => { it('leaves the palette untouched when no color is given', () => { const baseline = dumpTokens(getPaletteTokens()); - setPaletteConfig({ hue: DEFAULT_PALETTE_CONFIG.hue }); + setPaletteConfig({ accent: { hue: DEFAULT_PALETTE_CONFIG.hue } }); expect(getPaletteConfig().accentTone).toBeNull(); expect(dumpTokens(getPaletteTokens())).toEqual(baseline); @@ -1346,7 +1395,7 @@ describe('accent color seeds', () => { for (const accentColor of BRANDS) { const tokens = renderPaletteTokens({ ...EXACT, - accentColor, + accent: accentColor, scheme: 'light', }); const fill = String(tokens['#accent-surface']); @@ -1391,7 +1440,7 @@ describe('accent color seeds', () => { for (const accentColor of BRANDS) { const tokens = renderPaletteTokens({ ...EXACT, - accentColor, + accent: accentColor, scheme: 'dark', }); const fill = String(tokens['#accent-surface']); @@ -1405,7 +1454,7 @@ describe('accent color seeds', () => { // …and it really is a different value, for a color the window has to move. const dark = renderPaletteTokens({ ...EXACT, - accentColor: '#FFD400', + accent: '#FFD400', scheme: 'dark', }); expect(hexOf(String(dark['#accent-surface']))).not.toBe('#ffd400'); @@ -1419,7 +1468,7 @@ describe('accent color seeds', () => { // is what keeps them apart. const hc = renderPaletteTokens({ ...EXACT, - accentColor: '#0EA5E9', + accent: '#0EA5E9', scheme: 'light', highContrast: true, }); @@ -1447,8 +1496,8 @@ describe('accent color seeds', () => { for (const scheme of ['light', 'dark'] as const) { const hc = renderPaletteTokens({ ...EXACT, - accentColor, - scheme, + accent: accentColor, + scheme: scheme, highContrast: true, }); @@ -1476,9 +1525,9 @@ describe('accent color seeds', () => { for (const highContrast of [false, true]) { const tokens = renderPaletteTokens({ ...EXACT, - accentColor, - scheme, - highContrast, + accent: accentColor, + scheme: scheme, + highContrast: highContrast, }); const base = String(tokens['#accent-selected-fill']); const label = `${accentColor} ${scheme}${highContrast ? ' hc' : ''}`; @@ -1520,11 +1569,11 @@ describe('accent color seeds', () => { for (const accentColor of BRANDS.filter((brand) => brand !== '#FFD400')) { for (const scheme of ['light', 'dark'] as const) { invalidatePaletteTokens(); - renderPaletteTokens({ ...EXACT, accentColor, scheme }); + renderPaletteTokens({ ...EXACT, accent: accentColor, scheme: scheme }); renderPaletteTokens({ ...EXACT, - accentColor, - scheme, + accent: accentColor, + scheme: scheme, highContrast: true, }); } @@ -1551,7 +1600,7 @@ describe('accent color seeds', () => { for (const accentColor of ['#7A4DBF', '#EF4444']) { const tokens = renderPaletteTokens({ ...EXACT, - accentColor, + accent: accentColor, scheme: 'light', }); const drift = Math.abs( @@ -1569,7 +1618,11 @@ describe('accent color seeds', () => { // `#accent-text` exists for. for (const accentColor of BRANDS) { for (const scheme of ['light', 'dark'] as const) { - const tokens = renderPaletteTokens({ ...EXACT, accentColor, scheme }); + const tokens = renderPaletteTokens({ + ...EXACT, + accent: accentColor, + scheme: scheme, + }); expect(tokens['#accent-text'], `${accentColor} ${scheme}`).not.toBe( tokens['#accent-text-soft'], @@ -1591,7 +1644,7 @@ describe('accent color seeds', () => { // nothing left to leak into a status theme. const seeded = renderPaletteTokens({ ...EXACT, - accentColor: '#FFD400', + accent: '#FFD400', scheme: 'light', }); const baseline = renderPaletteTokens({ ...EXACT, scheme: 'light' }); @@ -1623,30 +1676,30 @@ describe('accent color seeds', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const seen: string[] = []; const unsubscribe = subscribePaletteConfig(() => { - seen.push(String(getPaletteConfigInput().accentColor)); + seen.push(String(getPaletteConfigInput().accent)); }); - setPaletteConfig({ accentColor: 'bad-one' }); - setPaletteConfig({ accentColor: 'bad-two' }); + setPaletteConfig({ accent: 'bad-one' }); + setPaletteConfig({ accent: 'bad-two' }); - expect(getPaletteConfigInput().accentColor).toBe('bad-two'); + expect(getPaletteConfigInput().accent).toBe('bad-two'); expect(seen).toEqual(['bad-one', 'bad-two']); unsubscribe(); warn.mockRestore(); }); - it('rotates the whole accent ramp when a hue outranks the color', () => { - // `resolveConfig` ranks an explicit `hue` above the one an `accentColor` carries, - // so the fill has to follow it like every sibling does. Handing Glaze the literal - // instead pinned `accent-surface` to the color's own hue while `-2`, `-3` and - // `hover` followed the theme — a primary button that changed hue on hover. + it('keeps the whole accent ramp on one hue under a color seed', () => { + // The fill is pinned by `from` while `-2`, `-3` and `hover` are tone steps off it, + // so the four have to agree on a hue. They stopped agreeing once — the seed was + // rebuilt from resolved components while the fill kept the literal's own hue — and + // a primary button changed hue on hover. // - // Asserted on the EMITTED tokens rather than on `getPaletteConfig().hue`, which - // was already correct while the ramp was split. + // Asserted on the EMITTED tokens rather than on `getPaletteConfig().hue`, which was + // already correct while the ramp was split. + const seed = colorSeed('#0EA5E9')!; const tokens = renderPaletteTokens({ - accentColor: '#0EA5E9', - hue: 94, + accent: '#0EA5E9', scheme: 'light', }); const ramp = [ @@ -1656,7 +1709,7 @@ describe('accent color seeds', () => { '#accent-surface-hover', ].map((name) => hueOf(String(tokens[name]))); - for (const hue of ramp) expect(hue).toBeCloseTo(94, 1); + for (const hue of ramp) expect(hue).toBeCloseTo(seed.hue, 1); }); it('keeps a white label readable on the emitted fill, every hue and tier', () => { @@ -1671,9 +1724,9 @@ describe('accent color seeds', () => { for (const highContrast of [false, true]) { const seed = `okhst(${hue} ${saturation}% ${tone}%)`; const tokens = renderPaletteTokens({ - accentColor: seed, - scheme, - highContrast, + accent: seed, + scheme: scheme, + highContrast: highContrast, }); const label = `${seed} ${scheme}${highContrast ? ' hc' : ''}`; @@ -1708,7 +1761,7 @@ describe('accent color seeds', () => { // unchanged. const tokens = renderPaletteTokens({ ...EXACT, - accentColor: '#FFD400', + accent: '#FFD400', scheme: 'light', }); const baseline = renderPaletteTokens({ ...EXACT, scheme: 'light' }); @@ -1729,7 +1782,7 @@ describe('accent color seeds', () => { const seed = colorSeed('#FFD400')!; const baseline = renderPaletteTokens({ scheme: 'light' }); - setPaletteConfig({ baseColor: '#FFD400' }); + setPaletteConfig({ base: '#FFD400' }); expect(getPaletteConfig().baseHue).toBeCloseTo(seed.hue, 6); expect(getPaletteConfig().baseSaturation).toBe(MAX_BASE_SATURATION); @@ -1758,7 +1811,7 @@ describe('accent color seeds', () => { expect(muted.saturation).toBeLessThan(MAX_BASE_SATURATION); - setPaletteConfig({ baseColor: '#6e7076' }); + setPaletteConfig({ base: '#6e7076' }); expect(getPaletteConfig().baseSaturation).toBeCloseTo(muted.saturation, 6); // …and the near-grey seed genuinely mutes the chrome, rather than leaving it on @@ -1786,9 +1839,9 @@ describe('accent color seeds', () => { expect(statuses.length).toBeGreaterThan(10); for (const config of [ - { baseColor: '#FFD400' }, - { accentColor: '#7A7269' }, - { accentColor: '#2F5BFF', baseColor: '#6e7076' }, + { base: '#FFD400' }, + { accent: '#7A7269' }, + { accent: '#2F5BFF', base: '#6e7076' }, ] as PaletteConfig[]) { setPaletteConfig(config); @@ -1807,7 +1860,7 @@ describe('accent color seeds', () => { // asked for. const grey = colorSeed('#7A7269')!; - setPaletteConfig({ accentColor: '#7A7269' }); + setPaletteConfig({ accent: '#7A7269' }); expect(getPaletteConfig().baseSaturation).toBeCloseTo( grey.saturation * 0.12, @@ -1816,7 +1869,7 @@ describe('accent color seeds', () => { // A vivid brand lands where the shipped palette does, because its saturation is // the 100 the seed already carried. - setPaletteConfig({ accentColor: '#2F5BFF' }); + setPaletteConfig({ accent: '#2F5BFF' }); expect(getPaletteConfig().baseSaturation).toBeCloseTo( colorSeed('#2F5BFF')!.saturation * 0.12, @@ -1824,36 +1877,40 @@ describe('accent color seeds', () => { ); }); - it('prefers an explicit base saturation over the color it would derive', () => { - setPaletteConfig({ baseColor: '#FFD400', baseSaturation: 3 }); + it('clips a base color to the ceiling but leaves a base number alone', () => { + // The two arms are on deliberately different scales. A color says "the chrome IS + // this", so it is clipped — a fully saturated chrome is no longer chrome. A number + // is the more specific instruction, and a tuner offering the range is entitled to + // the top of it. + setPaletteConfig({ base: '#FFD400', pastel: false }); - expect(getPaletteConfig().baseSaturation).toBe(3); - }); + expect(getPaletteConfig().baseSaturation).toBe(MAX_BASE_SATURATION); - it('keeps the palette seed a ceiling on the chrome under either color', () => { - // `baseSaturationScale` divides by the seed, so the chrome's absolute chroma is a - // function of `baseSaturation` alone. Without the seed as a cap, a color seed - // would cancel `saturation` out of the base zone and a muted palette would leave - // an unmuted chrome — the invariant this pins. - setPaletteConfig({ pastel: false, saturation: 20 }); + setPaletteConfig({ base: { saturation: 100 }, pastel: false }); - const seedOnly = getPaletteConfig().baseSaturation; + expect(getPaletteConfig().baseSaturation).toBe(100); + }); + it('keeps the palette seed a ceiling on the chrome under a base color', () => { + // `baseSaturationScale` divides by the seed, so the chrome's absolute chroma is a + // function of `baseSaturation` alone. Without the seed as a cap, a color seed would + // cancel `saturation` out of the base zone and a muted palette would leave an + // unmuted chrome — the invariant this pins. + // + // A muted seed beside an ACCENT color is no longer expressible: the zone is seeded + // one way or the other, so a color there leaves the inherited seed at its default. + // The base zone is where a color and a number still meet, and where the cap bites. setPaletteConfig({ + accent: { saturation: 20 }, + base: '#FFD400', pastel: false, - saturation: 20, - accentColor: '#EF4444', }); - expect(getPaletteConfig().baseSaturation).toBe(seedOnly); - - setPaletteConfig({ pastel: false, saturation: 20, baseColor: '#FFD400' }); - expect(getPaletteConfig().baseSaturation).toBe(20); // …and the cap only bites when the seed is the lower of the two: at full // saturation the brand's own chroma is what the chrome takes its share of. - setPaletteConfig({ pastel: false, accentColor: '#EF4444' }); + setPaletteConfig({ accent: '#EF4444', pastel: false }); expect(getPaletteConfig().baseSaturation).toBeCloseTo( colorSeed('#EF4444')!.saturation * 0.12, @@ -1861,16 +1918,20 @@ describe('accent color seeds', () => { ); }); - it('prefers an explicit hue over the derived one, keeping the tone', () => { - // The number is the more specific instruction. Keeping the tone regardless is what - // lets a preview rotate the hue of a stored brand without discarding its - // lightness. - const seed = colorSeed('#FFD400')!; + it('lets a numeric seed replace a color one rather than layering over it', () => { + // The union is the exclusivity: a zone is seeded by a color or by numbers, so a + // patch that switches form REPLACES. There is no "this brand, rotated" — a hue + // arriving next to a stored color takes the zone over, tone and all, and + // `accentTone` goes back to null. + setPaletteConfig({ ...EXACT, accent: '#FFD400' }); - setPaletteConfig({ accentColor: '#FFD400', hue: 200, ...EXACT }); + expect(getPaletteConfig().accentTone).not.toBeNull(); - expect(getPaletteConfig().hue).toBe(200); - expect(getPaletteConfig().accentTone).toBeCloseTo(seed.tone, 6); + const rotated = resolvePaletteConfig({ accent: { hue: 200 } }); + + expect(rotated.hue).toBe(200); + expect(rotated.accentColor).toBeNull(); + expect(rotated.accentTone).toBeNull(); }); it('leaves the palette seed alone whatever the color’s own chroma is', () => { @@ -1884,7 +1945,7 @@ describe('accent color seeds', () => { 1, ); - setPaletteConfig({ accentColor: '#7A4DBF', ...EXACT }); + setPaletteConfig({ ...EXACT, accent: '#7A4DBF' }); expect(getPaletteConfig().saturation).toBe( DEFAULT_PALETTE_CONFIG.saturation, @@ -1894,7 +1955,7 @@ describe('accent color seeds', () => { it('takes the hue and tone but not the chroma of a color under pastel', () => { const seed = colorSeed('#EF4444')!; - setPaletteConfig({ accentColor: '#EF4444' }); + setPaletteConfig({ accent: '#EF4444' }); // Pastel is one flat ceiling, so there is one saturation and it is the top of the // scale — the color's own is deliberately dropped. @@ -1911,12 +1972,12 @@ describe('accent color seeds', () => { // …and softer is measurable: pastel cannot reproduce the color, non-pastel can. // This is the divergence the Theme Builder shows as requested-vs-resolved chips. const softened = renderPaletteTokens({ - accentColor: '#EF4444', + accent: '#EF4444', scheme: 'light', }); const exact = renderPaletteTokens({ ...EXACT, - accentColor: '#EF4444', + accent: '#EF4444', scheme: 'light', }); @@ -1930,9 +1991,9 @@ describe('accent color seeds', () => { it('reads a lone saturation as a request to leave pastel', () => { // Tuning a saturation IS the non-pastel path, so writing one picks it. This is - // also what keeps `setPaletteConfig({ saturation: 55 })` doing what it always + // also what keeps `setPaletteConfig({ accent: { saturation: 55 } })` doing what it always // did, rather than silently resolving to the value pastel pins. - setPaletteConfig({ saturation: 55 }); + setPaletteConfig({ accent: { saturation: 55 } }); expect(getPaletteConfig().pastel).toBe(false); expect(getPaletteConfig().saturation).toBe(55); @@ -1943,7 +2004,7 @@ describe('accent color seeds', () => { }); it('lets an explicit pastel override a saturation, and remembers the number', () => { - setPaletteConfig({ saturation: 40, pastel: true }); + setPaletteConfig({ accent: { saturation: 40 }, pastel: true }); // `pastel` is the coarser of the two choices — a color space rather than a value // on one — so it wins wherever both are set. @@ -1954,7 +2015,7 @@ describe('accent color seeds', () => { // Kept rather than dropped, so turning pastel off restores the caller's number // instead of resetting it. That is what makes the two paths a toggle rather than // a one-way door — and what lets the Theme Builder's Pastel switch round-trip. - expect(getPaletteConfigInput().saturation).toBe(40); + expect(pinnedNumbers(getPaletteConfigInput().accent)?.saturation).toBe(40); setPaletteConfig((config) => ({ ...config, pastel: false })); expect(getPaletteConfig().saturation).toBe(40); @@ -1966,7 +2027,7 @@ describe('accent color seeds', () => { // A CSS keyword is the likely typo in a settings field: it looks like a color and // Glaze rejects it. Taking the render down over it would be the wrong trade. - setPaletteConfig({ accentColor: 'rebeccapurple' }); + setPaletteConfig({ accent: 'rebeccapurple' }); expect(warn).toHaveBeenCalled(); expect(getPaletteConfig().hue).toBe(DEFAULT_PALETTE_CONFIG.hue); @@ -1984,15 +2045,18 @@ describe('accent color seeds', () => { const seed = colorSeed('#FFD400')!; - setPaletteConfig({ hue: seed.hue, saturation: seed.saturation, ...EXACT }); + setPaletteConfig({ + ...EXACT, + accent: { hue: seed.hue, saturation: seed.saturation }, + }); expect(seen).toHaveLength(1); // The same resolved hue and saturation, said a different WAY. A settings UI reads // the sparse config to decide whether its hue slider or its color field is in // charge, so the swap has to be observable even where the numbers agree. - setPaletteConfig({ accentColor: '#FFD400', ...EXACT }); + setPaletteConfig({ ...EXACT, accent: '#FFD400' }); expect(seen).toHaveLength(2); - expect(getPaletteConfigInput().accentColor).toBe('#FFD400'); + expect(getPaletteConfigInput().accent).toBe('#FFD400'); unsubscribe(); }); @@ -2000,7 +2064,7 @@ describe('accent color seeds', () => { it('previews a color seed without applying it', () => { const preview = renderPaletteTokens({ ...EXACT, - accentColor: '#FFD400', + accent: '#FFD400', scheme: 'light', }); @@ -2010,3 +2074,298 @@ describe('accent color seeds', () => { expect(getPaletteConfig()).toEqual(DEFAULT_PALETTE_CONFIG); }); }); + +/** + * The same `from` machinery, pointed at a status theme instead of the brand. + * + * What is genuinely different here is the CHROMA. The accent zone deliberately keeps a + * color's chroma out of the palette seed, because every status theme inherits it. A + * status theme inherits to nobody, so its seed takes the color's chroma outright — which + * is what holds its tinted banner, border and text ramp in the proportion to the fill + * that the shipped derivation gives them. + */ +describe('status color seeds', () => { + const EXACT = { pastel: false } as const; + + /** The four factors the shipped recipe authors these at, relative to the theme seed. */ + const FACTORS = { + '#danger-surface': 0.2, + '#danger-border': 0.3, + '#danger-surface-text': 0.25, + '#danger-surface-text-soft': 0.25, + '#danger-surface-text-soft-2': 0.25, + }; + + afterEach(() => { + resetPaletteConfig(); + }); + + it('leaves the palette untouched when no theme names a color', () => { + const baseline = dumpTokens(getPaletteTokens()); + + setPaletteConfig({ themes: { danger: { hue: DEFAULT_DANGER_HUE } } }); + + expect(getPaletteConfig().themes.danger.color).toBeNull(); + expect(dumpTokens(getPaletteTokens())).toEqual(baseline); + }); + + it('renders the requested color exactly wherever the floor allows', () => { + // The same contract the brand fill answers to, restated per theme: the fill is + // EITHER the color asked for, OR sitting on the APCA floor — and floored only when + // the color could not clear it alone. + for (const color of [ + '#b91c1c', + '#15803d', + '#a16207', + '#a21caf', + '#FFD400', + ]) { + const tokens = renderPaletteTokens({ + ...EXACT, + themes: { danger: color }, + scheme: 'light', + }); + const fill = String(tokens['#danger-accent-surface']); + const surface = String(tokens['#surface']); + + const wanted = apcaOf(surface, color); + + if (wanted >= 45) { + expectSameColor(hexOf(fill), color.toLowerCase(), color); + } else { + expect(apcaOf(surface, fill), color).toBeGreaterThanOrEqual(45 - 0.5); + } + } + }); + + it('reaches the theme’s text and icon, hover a step past rest', () => { + setPaletteConfig({ ...EXACT, themes: { danger: '#b91c1c' } }); + + const tokens = renderPaletteTokens({ scheme: 'light' }); + const seed = colorSeed('#b91c1c')!; + + // The rest link IS the brand — the visible payoff of a color seed — and the hover + // steps past it so the intensify survives. + expect(hueOf(String(tokens['#danger-accent-text-soft']))).toBeCloseTo( + seed.hue, + 0, + ); + expect(hueOf(String(tokens['#danger-accent-icon']))).toBeCloseTo( + seed.hue, + 0, + ); + expect(toneOf(String(tokens['#danger-accent-text']))).toBeLessThan( + toneOf(String(tokens['#danger-accent-text-soft'])), + ); + }); + + it('holds the banner’s chroma in proportion to the fill', () => { + // The assertion the whole chroma design turns on. A color's chroma reaches the fill + // absolutely, through `from`, so unless the theme's SEED moves with it the banner + // keeps answering to a number the fill no longer has anything to do with — 0.2 of + // 100 beside a fill at 23, which is a washed-out button on a fully tinted banner. + // + // Measured as a ratio against the SAME hue seeded numerically rather than as an + // absolute chroma, so it pins the proportion and not the gamut. + // + // Two colors, and the muted one is the one with teeth: the status hexes a product + // actually ships (`#b91c1c` and friends) all measure above 88, so on their own they + // would let a four-percent drift pass for a proportion. + for (const color of ['#b91c1c', '#8d6e63']) { + const seed = colorSeed(color)!; + + const seeded = renderPaletteTokens({ + ...EXACT, + themes: { danger: color }, + scheme: 'light', + }); + const numeric = renderPaletteTokens({ + ...EXACT, + themes: { danger: { hue: seed.hue, saturation: seed.saturation } }, + scheme: 'light', + }); + + for (const name of Object.keys(FACTORS)) { + expect(chromaOf(String(seeded[name])), `${color} ${name}`).toBeCloseTo( + chromaOf(String(numeric[name])), + 4, + ); + } + } + + // And it really does move. At the default seed of 100 the banner is markedly more + // chromatic than a muted color asks for — that gap is the drift being prevented. + const muted = renderPaletteTokens({ + ...EXACT, + themes: { danger: '#8d6e63' }, + scheme: 'light', + }); + const unseeded = renderPaletteTokens({ ...EXACT, scheme: 'light' }); + + expect(chromaOf(String(muted['#danger-border'])) * 3).toBeLessThan( + chromaOf(String(unseeded['#danger-border'])), + ); + }); + + it('softens the fill’s floor the way the brand’s is softened', () => { + // A status fill under a color seed answers to APCA Lc 45, not the white-anchored + // ladder's `['AA','AAA']`. `#0EA5E9` is the case that separates them: it renders at + // WCAG 2.77:1 against a white page and is CORRECT there, where a 3:1 floor would + // have darkened it away from the color asked for. + const tokens = renderPaletteTokens({ + ...EXACT, + themes: { note: '#0EA5E9' }, + scheme: 'light', + }); + const fill = String(tokens['#note-accent-surface']); + + expectSameColor(hexOf(fill), '#0ea5e9', 'note'); + expect(contrastOf(fill, String(tokens['#surface']))).toBeLessThan(3); + expect(apcaOf(String(tokens['#surface']), fill)).toBeGreaterThanOrEqual( + 45 - 0.5, + ); + }); + + it('keeps a white label readable on a pale status fill', () => { + // Every `type="primary"` item on a status theme paints `#white` on this fill, so the + // tone cap has to apply here exactly as it does to the brand. A near-white seed is + // 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) { + const tokens = renderPaletteTokens({ + ...EXACT, + themes: { [name]: 'okhst(20 80% 96%)' }, + scheme, + highContrast, + }); + const fill = String(tokens[`#${name}-accent-surface`]); + + // 44.9 rather than 45, and the same allowance the brand's own sweep takes: + // the emitted token is rounded on the way out, so the last tenth is the + // serializer's rather than the solver's. + expect( + apcaOf('#ffffff', fill), + `${name} ${scheme}${highContrast ? ' hc' : ''}`, + ).toBeGreaterThanOrEqual(44.9); + } + } + } + }); + + it('scopes a color to its own theme, in both directions', () => { + const baseline = renderPaletteTokens({ ...EXACT, scheme: 'light' }); + const seeded = renderPaletteTokens({ + ...EXACT, + themes: { danger: '#b91c1c' }, + scheme: 'light', + }); + + // Nothing outside `danger` may move — not the other three statuses, not the brand + // themes, not the neutral chrome, not the syntax palette. + const untouched = Object.keys(baseline).filter( + (name) => !name.startsWith('#danger-'), + ); + + expect(untouched.length).toBeGreaterThan(50); + + for (const name of untouched) { + expect(seeded[name], name).toBe(baseline[name]); + } + + // …and the other direction: a BRAND color still leaves the three unseeded statuses + // on the baseline while `danger` follows its own. + const both = renderPaletteTokens({ + ...EXACT, + accent: '#FFD400', + themes: { danger: '#b91c1c' }, + scheme: 'light', + }); + + for (const name of [ + '#success-accent-surface', + '#warning-accent-surface', + '#note-accent-surface', + ]) { + expect(both[name], name).toBe(baseline[name]); + } + expect(both['#danger-accent-surface']).toBe( + seeded['#danger-accent-surface'], + ); + }); + + it('falls back to the theme’s default hue on a color it cannot parse', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // A string no earlier test has tried: `colorSeed` caches its failures, and the + // cached `null` is what dedupes the warning to once per process per value. + setPaletteConfig({ themes: { danger: 'crimson' } }); + + expect(getPaletteConfig().themes.danger.color).toBeNull(); + expect(getPaletteConfig().themes.danger.hue).toBe(DEFAULT_DANGER_HUE); + expect(getPaletteConfig().themes.danger.saturation).toBe( + DEFAULT_PALETTE_CONFIG.saturation, + ); + expect(warn).toHaveBeenCalled(); + + warn.mockRestore(); + }); + + it('counts a status color as a pinned field', () => { + const seen: number[] = []; + const unsubscribe = subscribePaletteConfig(() => + seen.push(getPaletteVersion()), + ); + + // The same resolved hue, said a different WAY: a color that derives the shipped + // danger hue resolves to the same number, so only the pin signature can see it. + const color = 'okhst(23.1 100% 49%)'; + + setPaletteConfig({ themes: { danger: { hue: colorSeed(color)!.hue } } }); + expect(seen).toHaveLength(1); + + setPaletteConfig({ themes: { danger: color } }); + expect(seen).toHaveLength(2); + expect(getPaletteConfigInput().themes?.danger).toBe(color); + + // Two unparseable strings resolve identically — the value in the signature is the + // only thing that keeps the second write from being dropped silently. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + setPaletteConfig({ themes: { danger: 'bad-one' } }); + setPaletteConfig({ themes: { danger: 'bad-two' } }); + + expect(seen).toHaveLength(4); + expect(getPaletteConfigInput().themes?.danger).toBe('bad-two'); + + warn.mockRestore(); + unsubscribe(); + }); + + it('previews a status color without applying it', () => { + const preview = renderPaletteTokens({ + ...EXACT, + themes: { danger: '#b91c1c' }, + scheme: 'light', + }); + + expect(preview['#danger-accent-surface']).not.toBe( + renderPaletteTokens({ scheme: 'light' })['#danger-accent-surface'], + ); + expect(getPaletteConfig()).toEqual(DEFAULT_PALETTE_CONFIG); + }); + + it('replaces a color with numbers rather than layering them', () => { + setPaletteConfig({ ...EXACT, themes: { danger: '#b91c1c' } }); + + expect(getPaletteConfig().themes.danger.colorTone).not.toBeNull(); + + const rotated = resolvePaletteConfig({ themes: { danger: { hue: 200 } } }); + + expect(rotated.themes.danger.hue).toBe(200); + expect(rotated.themes.danger.color).toBeNull(); + expect(rotated.themes.danger.colorTone).toBeNull(); + // The other three are untouched by the patch, which is what `mergeSeed` is for. + expect(rotated.themes.note.hue).toBe(DEFAULT_NOTE_HUE); + }); +}); diff --git a/src/tokens/palette.ts b/src/tokens/palette.ts index f8a8c5f19..809fc62b6 100644 --- a/src/tokens/palette.ts +++ b/src/tokens/palette.ts @@ -22,7 +22,11 @@ import type { GlazeTheme, } from '@tenphi/glaze'; import type { Styles, Tokens } from '@tenphi/tasty'; -import type { PaletteConfig, ResolvedPaletteConfig } from './palette-config'; +import type { + PaletteConfig, + ResolvedPaletteConfig, + ResolvedThemeSeed, +} from './palette-config'; /** Which resolved scheme variant {@link renderPaletteTokens} should return. */ export interface RenderPaletteOptions extends PaletteConfig { @@ -78,7 +82,7 @@ const CODE_STRING_HUE = 280.3; * Shared with the code theme, which mirrors `surface` to solve its contrast floors * against the real page background — the two must not drift apart. Shared with the * config too, which needs it to work out the share of the accent zone's chroma the - * chrome takes when no `baseColor` names one outright; it lives there because the + * chrome takes when no base COLOR names one outright; it lives there because the * config cannot import from here, and is re-exported under this name because that is * what the recipe calls it. */ @@ -396,11 +400,15 @@ function accentToneCeiling(hue: number, saturation: number): number { } /** - * The brand seed as the three numbers Glaze consumes, with its tone capped. + * A color seed as the three numbers Glaze consumes, with its tone capped. * - * Takes the RESOLVED hue rather than the one the caller's literal carries, because - * `resolveConfig` ranks an explicit {@link PaletteConfig.hue} above a color's own — - * see the call site for what handing over the literal instead used to break. + * Takes the RESOLVED components rather than the caller's literal because the tone is + * capped — {@link capAccentTone} only ever lowers it — and a capped tone means rebuilding + * the color regardless. Passing `from: ` would emit the tone the + * white label cannot survive. + * + * Shared by the accent zone and by any status theme on the color path: they resolve their + * components differently, but a fill is a fill once the numbers are in hand. */ function cappedAccent( hue: number, @@ -966,12 +974,10 @@ function buildPalette( contrastLevel, } = config; - // Built from the RESOLVED hue, not the literal. `resolveConfig` ranks an explicit - // `hue` above the one a color carries — that is what lets a preview rotate a stored - // `accentColor` without discarding its tone — but `from: ` - // would hand Glaze the color's own hue and pin `accent-surface` to it while every - // sibling followed the theme. The ramp then splits: the fill one hue, its `-2`, - // `-3` and hover another, so a primary button changed hue on hover. + // Built from the RESOLVED components, not the literal — see `cappedAccent`. Handing + // Glaze the original string would pin `accent-surface` to an uncapped tone while its + // `-2`, `-3` and hover siblings stepped off the capped one, splitting the ramp so a + // primary button changed weight on hover. const accent: AccentSeed = accentColor !== null && accentTone !== null && accentSaturation !== null ? cappedAccent(hue, accentSaturation, accentTone) @@ -1310,41 +1316,62 @@ function buildPalette( colors: tintedSurface, }); - // A status theme takes the ACCENT COLOR'S TONE back out. - // - // The tone is the brand's, and only the brand's. Inherited, a light brand would put - // `#danger-accent-surface` at tone 88 in a red hue — a pale pink danger button, which - // is not a danger button. Status themes carry a *meaning* their hue exists to signal, - // so they keep the white-anchored derivation that lands every hue at a comparable - // weight. `extend({ colors })` redefines each listed color from scratch, so restating - // the null arrangement is enough to undo it. - // - // With no accent color this is the tinted-surface override itself, so the - // shipped palette is provably untouched. - const statusColors: ColorMap = - accent == null + /** + * One status theme's overrides, on whichever path its own seed is on. + * + * **Its own color** — the family becomes that color, exactly as the accent zone's does, + * so the softened APCA floors in `accentColors`' color arm come with it rather than + * being restated here. The theme's Glaze seed is the color's chroma (see + * `ResolvedThemeSeed.saturation`), so the tinted surface, the border and the text ramp + * re-solve at their shipped factors OF that chroma — the banner keeps its proportion to + * the button instead of drifting relative to a fill whose chroma arrives absolute. + * + * **No color of its own, but the ACCENT has one** — the accent color's tone is taken + * back out. The tone is the brand's, and only the brand's. Inherited, a light brand + * would put `#danger-accent-surface` at tone 88 in a red hue — a pale pink danger + * button, which is not a danger button. A status theme carries a *meaning* its hue + * exists to signal, so absent an instruction of its own it keeps the white-anchored + * derivation that lands every hue at a comparable weight. `extend({ colors })` + * redefines each listed color from scratch, so restating the null arrangement is enough + * to undo it. + * + * **Neither** — the tinted-surface override itself, so the shipped palette is provably + * untouched. + */ + const statusColors = (theme: ResolvedThemeSeed): ColorMap => { + if (theme.color !== null && theme.colorTone !== null) { + return { + ...tintedSurface, + ...accentColors( + cappedAccent(theme.hue, theme.saturation, theme.colorTone), + ), + }; + } + + return accent == null ? tintedSurface : { ...tintedSurface, ...accentColors(null) }; + }; const successTheme = defaultTheme.extend({ hue: themes.success.hue, saturation: themes.success.saturation, - colors: statusColors, + colors: statusColors(themes.success), }); const dangerTheme = defaultTheme.extend({ hue: themes.danger.hue, saturation: themes.danger.saturation, - colors: statusColors, + colors: statusColors(themes.danger), }); const warningTheme = defaultTheme.extend({ hue: themes.warning.hue, saturation: themes.warning.saturation, - colors: statusColors, + colors: statusColors(themes.warning), }); const noteTheme = defaultTheme.extend({ hue: themes.note.hue, saturation: themes.note.saturation, - colors: statusColors, + colors: statusColors(themes.note), }); // -------------------------------------------------------------------------- From 6bee4ab9488e81e9c7d2a090c68e2b6c5d5cca8f Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Thu, 20 Aug 2026 16:42:24 +0200 Subject: [PATCH 2/2] fix(palette): size the brand fill's page floor for a shape, not for text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A color-seeded `accent-surface` collapsed to a single value across the dark half of the tone range: every brand darker than the floor solved to the same fill, while light passed the same seeds through untouched. The fill answers to two APCA constraints and they were sized the same. The `#white` label it carries needs Lc 45 — text strength, because it is text. The page it sits on was asked for Lc 45 too, escalating to 60 in high contrast, which is a demand that a filled shape reach text-grade contrast against the background. Nothing in the palette meets that: measured on the emitted tokens, the SHIPPED `accent-surface` sits at Lc 25.5 off the dark page and Lc 19.3 in dark high contrast, where the ladder darkens the fill toward its label. So a color-seeded fill was held to 1.8x and 3.1x what the design system's own button achieves. The two look identical in light, which is how it went unnoticed: there `surface` IS white, so one measurement is both constraints at once. In dark the page is near-black, and because a floor can only lighten, the surplus flattened the axis. Measured across the axis at one hue, the dark fill was pinned at tone 66 for every seed from 5 to 65, and in dark high contrast the floor met the label cap and left a window of a single value. The page floor is now Lc 25 in both tiers, calibrated to the shipped fill rather than to a text threshold. The same sweep tracks the seed from tone 47 up — where the shipped fill sits in dark — so the dark range went from 7.7 tones to ~27 against light's ~45. Both entries of the pair are written out to suppress APCA's automatic +15 in high contrast: that tier is a request for separation over brand, but not from the PAGE, since the same fill carries the label. The white label never depended on this number — `accentToneCeiling` guarantees it against pure white across all four variants, and a lower floor lightens less, so the margin gets safer. Light is unchanged (a dark brand on a white page measures Lc 100+, so this never bound there), and palettes with no color seed are untouched. Two tests encoded the old floor and now state the two claims separately: the label at text strength, the page calibrated against the shipped ladder. A new test guards the axis itself — span plus monotonicity, in both schemes. Co-Authored-By: Claude Opus 5 --- .changeset/accent-fill-page-floor.md | 17 ++++ .changeset/palette-seed-union.md | 2 +- src/stories/Theming.docs.mdx | 35 +++++--- src/stories/Theming.stories.tsx | 6 +- src/tokens/palette-config.ts | 11 +-- src/tokens/palette.test.ts | 118 +++++++++++++++++++++------ src/tokens/palette.ts | 82 +++++++++++-------- 7 files changed, 195 insertions(+), 76 deletions(-) create mode 100644 .changeset/accent-fill-page-floor.md diff --git a/.changeset/accent-fill-page-floor.md b/.changeset/accent-fill-page-floor.md new file mode 100644 index 000000000..4912ef55a --- /dev/null +++ b/.changeset/accent-fill-page-floor.md @@ -0,0 +1,17 @@ +--- +'@cube-dev/ui-kit': patch +--- + +Fix a color-seeded accent fill collapsing to one value across the dark tone range. + +The brand fill answers to two APCA constraints, and they were sized the same. The `#white` label it carries needs Lc 45 — text strength, because it is text. The page it sits on was asked for Lc 45 too, escalating to Lc 60 in high contrast, which is a demand that a filled shape reach text-grade contrast against the background. + +Nothing in the palette meets that. Measured on the emitted tokens, the **shipped** `accent-surface` — the white-anchored ladder every primary button used before color seeds existed — sits at **Lc 25.5** off the dark page and **Lc 19.3** in dark high contrast, where the ladder darkens the fill toward its label. So a color-seeded fill was being held to 1.8x and 3.1x what the design system's own button achieves. + +The two look identical in light, which is how it went unnoticed: there `surface` **is** white, so one measurement is both constraints at once and Lc 45 is right for the pair. In dark the page is near-black, and because a floor can only lighten, the surplus flattened the tone axis: every seed below the floor solved to the same fill. Measured across the axis at one hue, the dark fill was pinned at tone 66 for every seed from 5 to 65 — a brand's whole dark half collapsing onto one lavender — while light passed the same seeds through untouched. In dark high contrast the floor met the label cap and left a window of a single value. + +The page floor is now **Lc 25 in both tiers**, calibrated to the shipped fill rather than to a text threshold. The same sweep now tracks the seed from tone 47 up, and 47 is where the shipped fill sits in dark, so the dark range went from 7.7 tones to ~27 against light's ~45. The pair is written with both entries equal in order to suppress APCA's automatic +15 Lc enhancement in high contrast: that tier 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 white label is unaffected. It never depended on this number: it is guaranteed by the tone cap on the seed, which searches all four variants against pure white. A lower page floor lightens less, so it makes that guarantee safer rather than weaker. + +Light mode is unchanged — a dark brand on a white page measures Lc 100+, so this floor never bound there. Palettes with no color seed are untouched: the white-anchored ladder keeps its `['AA','AAA']` floors. diff --git a/.changeset/palette-seed-union.md b/.changeset/palette-seed-union.md index 7daa4b7d3..264b62832 100644 --- a/.changeset/palette-seed-union.md +++ b/.changeset/palette-seed-union.md @@ -27,7 +27,7 @@ The union **is** the exclusivity. A zone was always seeded either by a color or `ResolvedPaletteConfig` keeps its flat shape — `hue`, `baseHue`, `saturation`, `baseSaturation`, `accentColor`, `accentTone`, `accentSaturation` — so anything reading the resolved config is unaffected. Its four status entries gain `color` and `colorTone`. `PaletteThemeSeed` is replaced by `PaletteSeed`; `PaletteNumericSeed` and `ResolvedThemeSeed` are new. -**Status themes can now be seeded by a color**, which is what the union was blocking. `themes.danger: '#b91c1c'` renders that red on `#danger-accent-surface` — reproduced in light at normal contrast, adapting in dark and high contrast — and reaches `#danger-accent-text`, `-text-soft` and `-icon`. It inherits the brand path's softened APCA floors (Lc 45 against the page, Lc 45 against the white label, escalating to Lc 60 in high contrast) in place of the white-anchored ladder's `['AA','AAA']`, and the same tone cap, so a pale status color is pulled down rather than shipped as a white `type="primary"` label on white. +**Status themes can now be seeded by a color**, which is what the union was blocking. `themes.danger: '#b91c1c'` renders that red on `#danger-accent-surface` — reproduced in light at normal contrast, adapting in dark and high contrast — and reaches `#danger-accent-text`, `-text-soft` and `-icon`. It inherits the brand path's softened APCA floors (Lc 45 against the white label, Lc 25 against the page) in place of the white-anchored ladder's `['AA','AAA']`, and the same tone cap, so a pale status color is pulled down rather than shipped as a white `type="primary"` label on white. One rule differs from the accent's, deliberately: **a status color's chroma becomes that theme's seed.** An accent color's does not, because all four status themes inherit the accent's saturation and raising it would re-chromatise every one of them; nothing inherits from a status theme, so there is nothing to protect. Moving the seed is also what holds the theme together — its tinted banner surface, border and text ramp are authored as factors of the seed (`0.2`, `0.3`, `0.25`), so leaving it at `100` beside a muted fill would give a fully tinted banner under a washed-out button. Moving it keeps the shipped `1.0 : 0.2 : 0.3 : 0.25` ratio exactly. diff --git a/src/stories/Theming.docs.mdx b/src/stories/Theming.docs.mdx index e68022db9..7cf804474 100644 --- a/src/stories/Theming.docs.mdx +++ b/src/stories/Theming.docs.mdx @@ -180,31 +180,42 @@ that reproduces your color. Dark and high contrast adapt, as every other color i palette does — they are different pages, and a brand pinned across all four would be less faithful, not more. What holds everywhere is the floor below. -**The fill answers to two floors, both APCA Lc 45.** One against the page, so the -button reads as a shape on the surface; one against the `#white` label it carries, so -what is written on it stays readable. They are floors and not targets — a brand already -past both is emitted exactly as given, and one that misses moves only as far as the -nearer floor requires. +**The fill answers to two constraints, and they are different sizes.** The `#white` +label it carries needs **APCA Lc 45** — text strength, because it is text. The page it +sits on needs only **Lc 25**, because a solid fill is a shape, not text, and Lc 25 is +what the palette's own shipped fill achieves against a dark page. Both are floors rather +than targets: a brand already past them is emitted exactly as given, and one that misses +moves only as far as the nearer requires. + +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 — Lc 45 is the guarantee, not the ratio. +a white page and is correct there — the Lc is the guarantee, not the ratio. -High contrast escalates the page floor to Lc 60 and stops. It stops because the two -floors pull opposite ways in dark — one wants a lighter fill, the other a darker one — -and the window they share closes entirely somewhere past 60. A fill driven further off -the page is one its own label has disappeared from, so the tier escalates only as far -as the label can follow. +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. **`pastel` caps chroma.** The flat, hue-independent ceiling is what makes pastel even across hues, and it sits below where a saturated color would land, so under it a color seed can never resolve to itself: `#EF4444` renders `#c47069`. Under pastel the color contributes its hue and its tone; turn pastel off to get its chroma too. -All of it applies per zone. A status color answers to the same two floors on its own +All of it applies per zone. A status color answers to the same two constraints on its own `#-accent-surface`, including the label cap — every `type="primary"` item on a status theme paints `#white` on that fill too, so a pale `danger` color is pulled down rather than shipped as a white label on white. diff --git a/src/stories/Theming.stories.tsx b/src/stories/Theming.stories.tsx index 4a33dbf3a..c328044e8 100644 --- a/src/stories/Theming.stories.tsx +++ b/src/stories/Theming.stories.tsx @@ -279,7 +279,7 @@ const RESOLUTION_SWATCH_STYLES: Styles = { * A color seed is a REQUEST, and two things can stop it arriving. Pastel caps chroma, * so a saturated brand can never resolve to itself under it — `#FFD400` softens to * `#e4d8ad`. And a light brand has to darken to clear the fill's APCA floors: Lc 45 - * off the page, and Lc 45 under the white label it carries. Both are correct; both + * under the white label it carries, and Lc 25 off the page. Both are correct; both * look like a bug if the only thing on screen is the color you typed. * * The requested color is deliberately NOT repeated here: the field above holds it, and @@ -469,7 +469,7 @@ function AccentSourceControls({ resolved }: { resolved?: Tokens }) { // Clearing the field is a change of path, so it lands back on a hue seed @@ -1542,7 +1542,7 @@ function StatusThemeButton({ // Clearing lands back on a hue pinned where the color left it. There is diff --git a/src/tokens/palette-config.ts b/src/tokens/palette-config.ts index ebf621f6f..627fea528 100644 --- a/src/tokens/palette-config.ts +++ b/src/tokens/palette-config.ts @@ -171,16 +171,17 @@ export interface PaletteConfig { * The **light, normal-contrast** variant reproduces the color; dark and high contrast * adapt, as every other color in the palette does. Two things cost exactness even * there: {@link PaletteConfig.pastel} caps chroma (so `#FFD400` softens), and the fill - * answers to two APCA floors — **Lc 45 against `surface`** so the button reads as a - * shape, and **Lc 45 against the white label** it carries — moving only as far as the - * nearer one requires. + * answers to two APCA floors of deliberately different sizes — **Lc 45 against the + * white label** it carries, because a label is text, and only **Lc 25 against + * `surface`**, because a fill is a shape. It moves as far as the nearer one requires + * 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 * 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; Lc 45 - * is the guarantee, not the ratio. + * `#0EA5E9` renders at 2.77:1 against a white page and is correct at that value; the + * Lc is the guarantee, not the ratio. * * A color's own chroma is **not** adopted as the zone's seed saturation, which stays at * its default. The accent family gets the chroma through Glaze's `from`, where it is diff --git a/src/tokens/palette.test.ts b/src/tokens/palette.test.ts index dc4f2b4c1..5c5f70695 100644 --- a/src/tokens/palette.test.ts +++ b/src/tokens/palette.test.ts @@ -1484,32 +1484,98 @@ describe('accent color seeds', () => { }); /** - * High contrast keeps AAA whatever the caller asked for. + * High contrast escalates the page floor, but only as far as the label can follow. * - * Fidelity to a requested color is a preference; the high-contrast tier is not. It is - * selected by `prefers-contrast: more` or an explicit `data-contrast="high"`, so - * anyone reading it has asked for separation over brand — and the relaxed normal floor - * must not follow them into it. + * Fidelity to a requested color is a preference; the high-contrast tier is not — it is + * selected by `prefers-contrast: more` or an explicit `data-contrast="high"`, so anyone + * reading it has asked for separation over brand. What that cannot mean is text-grade + * separation from the page: the same fill carries a white label, and driving it off the + * page drives the label off it. The shipped ladder makes the same trade the other way + * round, ending up with LESS page separation in high contrast than in normal. */ - it('holds Lc 60 on the brand fill in high contrast, whatever the color', () => { - for (const accentColor of BRANDS) { - for (const scheme of ['light', 'dark'] as const) { - const hc = renderPaletteTokens({ - ...EXACT, - accent: accentColor, - scheme: scheme, - highContrast: true, - }); + it('calibrates the page floor to the shipped fill, not above it', () => { + // The floor's job is page separation, and the palette's OWN fill is the only honest + // reference for how much of it a filled shape needs: the white-anchored ladder every + // primary button used before color seeds existed. Holding a color-seeded fill to more + // than that is what flattened the dark tone axis — see `ACCENT_FILL_CONTRAST`. + // + // Measured in DARK, and on a brand dark enough that the floor is what binds. Light is + // not the same claim: there the fill reproduces the requested color and may sit well + // BELOW the shipped ladder's separation, which is the documented consequence of + // rendering a brand rather than a shade of one. + // + // Asserted as a band around the shipped value rather than as a number, so the floor + // cannot drift from the ladder it is calibrated against in either direction. + for (const highContrast of [false, true]) { + const shipped = renderPaletteTokens({ + ...EXACT, + scheme: 'dark', + highContrast, + }); + const reference = apcaOf( + String(shipped['#surface']), + String(shipped['#accent-surface']), + ); - // 60, not the old AAA, and it is a ceiling geometry imposed rather than a - // preference: the same fill also answers to the white label above it, and in - // dark those two floors pull opposite ways. Asking 85 of the page empties the - // window they share — see `ACCENT_FILL_CONTRAST`. - expect( - apcaOf(String(hc['#surface']), String(hc['#accent-surface'])), - `${accentColor} ${scheme}`, - ).toBeGreaterThan(59.9); + const seeded = renderPaletteTokens({ + ...EXACT, + accent: 'okhst(280 70% 10%)', + scheme: 'dark', + highContrast, + }); + const separation = apcaOf( + String(seeded['#surface']), + String(seeded['#accent-surface']), + ); + const label = `hc=${highContrast} shipped ${reference.toFixed(1)}`; + + expect(separation, label).toBeGreaterThan(reference - 0.5); + // The upper bound is the point of the test: 45/60 put this at 1.8x and 3.1x the + // reference. 1.5x leaves room for the one place the two paths legitimately differ — + // in high contrast the shipped ladder sacrifices page separation to strengthen its + // white label, where a color-seeded fill has that label guaranteed by the tone + // ceiling instead and so keeps the separation it had. + expect(separation, label).toBeLessThan(reference * 1.5); + } + }); + + it('tracks the seed down the tone axis in dark, not just in light', () => { + // The regression this exists for: the page floor was set for the white LABEL (Lc 45, + // rising to 60 in high contrast) rather than for page separation. A floor can only + // lighten, so in dark every seed below it solved to one value — the whole dark half + // of a brand's tone range collapsed onto a single lavender, while light passed the + // same seeds through untouched. + // + // Guarded as SPAN plus monotonicity rather than as exact tones. Below the floor the + // axis is flat by geometry — a fill darker than the page can carry no shape — so what + // broke was not the flat part but how much of the axis it swallowed: 7.7 tones of + // range survived in dark against light's 45. + const tones = [30, 45, 60, 75]; + + for (const scheme of ['light', 'dark'] as const) { + const emitted = tones.map((tone) => + toneOf( + String( + renderPaletteTokens({ + ...EXACT, + accent: `okhst(280 70% ${tone}%)`, + scheme, + })['#accent-surface'], + ), + ), + ); + const label = `${scheme}: ${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++) { + expect(emitted[i], label).toBeGreaterThanOrEqual(emitted[i - 1] - 0.1); } + + // And the axis stays a range rather than a sliver. The old floor left 7.7 tones + // here in dark; this one leaves ~27, against light's ~45. + expect(emitted[emitted.length - 1] - emitted[0], label).toBeGreaterThan( + 20, + ); } }); @@ -1730,17 +1796,23 @@ describe('accent color seeds', () => { }); const label = `${seed} ${scheme}${highContrast ? ' hc' : ''}`; + // The white label — the guarantee `accentToneCeiling` exists for, and the + // one that has to hold at text strength. expect( apcaOf('#ffffff', String(tokens['#accent-surface'])), label, ).toBeGreaterThanOrEqual(44.9); + // The page is a separate, weaker claim: a filled shape, not text. It used + // to be asserted at the label's 44.9 too, which is exactly the conflation + // that flattened the dark axis. `ACCENT_FILL_CONTRAST` is the floor here, + // and the comparison against the shipped ladder is asserted above. expect( apcaOf( String(tokens['#surface']), String(tokens['#accent-surface']), ), label, - ).toBeGreaterThanOrEqual(44.9); + ).toBeGreaterThanOrEqual(24.9); } } } diff --git a/src/tokens/palette.ts b/src/tokens/palette.ts index 809fc62b6..2391f04b8 100644 --- a/src/tokens/palette.ts +++ b/src/tokens/palette.ts @@ -167,48 +167,66 @@ function baseSaturationScale(config: ResolvedPaletteConfig): number { } /** - * The floor for a pinned brand FILL, measured from white — APCA `large`, Lc 45. + * How far a pinned brand FILL has to separate from the page it sits on — APCA Lc 25, in + * both tiers. * - * "From white" falls out of the anchor rather than replacing it. The base stays - * `surface`, and `roleToPolarity('surface')` gives this the `bg` polarity, so Glaze - * solves `apcaContrast(surface, fill)` — in light, where `surface` IS `oklch(1 0 0)`, - * that is literally white-on-fill, the pair every `type="primary"` label depends on. + * The base is `surface`, and `roleToPolarity('surface')` gives this the `bg` polarity, + * so Glaze solves `apcaContrast(surface, fill)`. * - * Re-anchoring to `accent-surface-text` to say "from white" in both schemes was tried - * and is wrong: in dark the label root is near-white while the page is not, so the - * floor stops constraining the fill against the page and a dark brand disappears into - * it — `#111827` came out at WCAG 1.16 against the dark surface. Keeping `surface` - * means the floor reads as the label pair in light and as page separation in dark, - * which is the constraint that actually matters in each. + * **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 + * 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 + * text-grade contrast against it. Nothing in the palette meets that: measured on the + * emitted tokens, the SHIPPED `accent-surface` — the white-anchored ladder every primary + * button used before color seeds existed — sits at **Lc 25.5** off the dark page, and at + * **Lc 19.3** in dark high contrast, where the ladder darkens the fill toward its label. + * So 45/60 held a color-seeded fill to 1.8x and 3.1x what the design system's own button + * achieves. + * + * Because a floor can only LIGHTEN in dark, that surplus flattened the tone axis: every + * seed below the floor solved to the same fill. Measured across the axis at one hue, the + * dark fill was pinned at tone 66 for every seed from 5 to 65 — a brand's whole dark half + * collapsed onto one lavender — and in dark high contrast the floor met + * {@link ACCENT_LABEL_LC}'s ceiling and left a window of a single value. At Lc 25 the + * same sweep tracks the seed from tone 47 up, and 47 is where the shipped fill sits in + * dark, so the floor now coincides with the palette's own reference point instead of + * overshooting it. + * + * The label is not left unguarded by this — it never depended on this number. The white + * `type="primary"` label is guaranteed by {@link accentToneCeiling}, which caps the seed + * against pure white across all four variants, with {@link ACCENT_LABEL_MARGIN} covering + * the lightening this floor can still add. A LOWER floor lightens less, so it makes that + * margin safer rather than riskier. + * + * What the floor cannot do is invent contrast that a dark page has no room for. A brand + * darker than the shipped fill's own dark tone still lands on the floor, because a fill + * beneath it is not a shape on that page. The fix is that the floor now sits where the + * design system already put it, not two tiers above. + * + * Still a FLOOR, not a target: a brand already past it is emitted exactly as given — + * which is why light is untouched by this change. A dark brand on a white page measures + * Lc 100+, so this never bound there; the ceiling is what light answers to. * * Why APCA and not a WCAG ratio. WCAG 2.x is polarity-blind, so one number means two * very different things: the previous `3` measured Lc 56 in light but only Lc 23 in * 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. Lc 45 is one number that means one + * dark ones sailed through under the same rule. An Lc is one number that means one * thing in both schemes. * - * Still a FLOOR, not a target: a brand already past it is emitted exactly as given. - * - * The HC entry is `60` — APCA's `content` tier — and it is a CEILING imposed by - * geometry, not a preference. The fill answers to two floors that pull opposite ways - * in dark: this one pushes it away from a dark page (lighter), while - * {@link ACCENT_LABEL_LC} pushes it away from the white label (darker). Measured on - * the emitted tokens, the window where both hold in dark high contrast is - * `L ∈ [0.605, 0.735]`; asking 85 of the page empties it outright, and a 3072-case - * sweep put the white primary label at Lc 20.7 on a fill that satisfied the page. - * 60 is the largest value that keeps the window open — 65 reopens 768 failures. - * - * So high contrast escalates the fill only as far as the label can follow. That is - * the right way round: the tier exists to be READ, and a fill driven to Lc 85 off the - * page is one its own label has vanished from. - * - * It cannot be written as a WCAG ratio either — Glaze rejects a `contrast` pair that - * switches metric, which is a fair guard — so the tier moves to APCA with the rest. - * For the record, WCAG 7 measures Lc 83.5 in light but only Lc 54.4 in dark, so no - * single Lc could have restated the old AAA pair in both schemes regardless. + * 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 + * contrast, which is the escalation this must not make. High contrast is a request for + * separation over brand, but not for separation from the PAGE — the same fill carries a + * white 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, at Lc 19.3: *less* page separation + * in high contrast than in normal, because it darkens the fill toward its label. Here the + * label is protected by {@link accentToneCeiling} instead, which searches the + * high-contrast variants too, so the tier can leave this number alone. */ -const ACCENT_FILL_CONTRAST: ContrastSpec = { apca: [45, 60] }; +const ACCENT_FILL_CONTRAST: ContrastSpec = { apca: [25, 25] }; /** * Floors for the two brand TEXT tokens: rest, then hover — APCA `content` and `body`.