Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/accent-fill-page-floor.md
Original file line number Diff line number Diff line change
@@ -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.
36 changes: 36 additions & 0 deletions .changeset/palette-seed-union.md
Original file line number Diff line number Diff line change
@@ -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` / `<Root palette>` / `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.<status>: { hue?, saturation? }` | unchanged, and now also `themes.<status>: '#…'` |
| `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 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.

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 `#<theme>-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.
2 changes: 1 addition & 1 deletion src/components/Root.docs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>` — 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
Expand Down
9 changes: 5 additions & 4 deletions src/components/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Root>` with no `palette` cannot clobber a host's imperative
Expand Down
10 changes: 9 additions & 1 deletion src/components/data/TableBase/column-tint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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. */
Expand Down
Loading
Loading