Skip to content

feat(palette): one seed per zone, and status themes from a color - #1336

Merged
tenphi merged 5 commits into
mainfrom
feat/palette-seed-union
Aug 20, 2026
Merged

feat(palette): one seed per zone, and status themes from a color#1336
tenphi merged 5 commits into
mainfrom
feat/palette-seed-union

Conversation

@tenphi

@tenphi tenphi commented Aug 20, 2026

Copy link
Copy Markdown
Member

What

Every palette zone — accent, base and each status theme — now takes the same PaletteSeed: a color string, or { hue?, saturation? }. And a status theme can be seeded by a color, which is what the old shape was blocking.

Breaking for setPaletteConfig / <Root palette> / renderColorTokens / renderPaletteTokens:

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
setPaletteConfig({
  accent: '#2F5BFF',
  base: '#7A7269',
  themes: { danger: '#b91c1c', success: { hue: 150 } },
});

ResolvedPaletteConfig keeps its flat shape (hue, baseHue, saturation, baseSaturation, accentColor, accentTone, accentSaturation), so palette.ts, color-theme.ts, column-tint.ts and renderPaletteTokens barely move. Its four status entries gain color and colorTone. PaletteThemeSeedPaletteSeed; PaletteNumericSeed and ResolvedThemeSeed are new.

Why

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). The union makes the exclusivity structural: nothing to learn, nothing to warn about, and a patch that switches form replaces rather than merges. It also collapses the Theme Builder's mode machinery from a hand-written list of five fields into one pass over a zone table — which is how the status themes got reached at all.

The immediate motivation was the hole in the tuner's own grid: in Color mode, Accent and Base took a hex while Status still took Hue + Saturation, so a product whose danger red is #b91c1c could only approximate it with a hue — and would miss, because the white-anchored ladder pins every status hue at roughly tone 49 whatever goes in.

Status colors

The theme's accent family renders the color on the brand path's terms: the light/normal-contrast variant reproduces it, dark and high contrast adapt, the softened APCA floors apply in place of the white-anchored ladder's ['AA','AAA'], and the same tone cap applies — every type="primary" item on a status theme paints #white on that fill, so a pale color is pulled down rather than shipped as a white label on white. No new contrast constants: it reuses accentColors' color arm and cappedAccent.

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 — and moving the seed is 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. Because the four extend() calls already forward themes.<name>.saturation, re-seeding is the multiplication — TINTED_SURFACE_RAMP, tintedSurfaceOverride and tintRecipe are untouched, which is also what keeps primary / purple / the runtime tints byte-identical.

Reviewer notes

Three consequences beyond the rename, all documented in the changeset and Theming.docs.mdx:

  • A documented capability is gone, by design. hue outranking accentColor let resolvePaletteConfig({ hue: 30 }) preview "this brand, rotated, tone intact". Under the union an object seed replaces a string seed. The test that asserted the precedence now asserts the replacement.
  • { accentColor: X, saturation: 20 } is no longer expressible — a color leaves the inherited seed at its default. It was unreachable from the tuner anyway (the saturation slider is hidden in Color mode) and only one test exercised it; that test was rewritten around the base-color arm, where a color and a number still meet and the cap still bites. Mute status themes individually if you want that.
  • 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 payoff, but it is the blast radius.

Also worth a look: the two halves of the Color transition in PaletteModeTabs follow different rules on purpose. The brand zones open on a fixed sample hex (existing, documented behaviour — the flip announces itself), while the four status themes convert to the fill each is already emitting, because four sample hexes would repaint every banner on a tab press.

Verification

  • 2001 tests pass (93 files). The ~109 rewritten input literals in palette.test.ts kept their assertions unchanged, which is the evidence the rename is faithful — the resolved shape and every expected token are identical.
  • 11 new status-color tests: exact rendering where the floor allows, the chroma ratio, scoping in both directions, the white label across all four themes × 2 schemes × 2 tiers, the softened floor (#0EA5E9 at WCAG 2.77:1 and correct there), unparseable fallback, pin signature, preview, and path replacement. The ratio assertion was mutation-tested — it fails when the seed stops moving — and includes a muted #8d6e63 case because the realistic status hexes all measure above 88 chroma and would let a 4% drift pass for a proportion.
  • Typecheck and lint clean for every touched file; prettier clean.
  • Checked live in the Theme Builder. Pressing Color converted all four status themes to their emitted fills; each chip's popover shows only a color field; setting #b91c1c moved the danger fill, banner and border to its hue while success stayed put; leaving Color pinned hues only. The new Brand preset (every zone a hex) emits accent-surface oklch(0.5106 0.2301 276.97) for #4f46e5 and danger-accent-surface oklch(0.5054 0.1905 27.52) for #b91c1c — exact — and holds in dark and high contrast.

🤖 Generated with Claude Code


Note

Cursor Bugbot is generating a summary for commit 0742524. Configure here.

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 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cube-ui-kit Ready Ready Preview Aug 20, 2026 2:47pm

Request Review

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

📦 NPM canary release

Deployed canary version 0.0.0-canary-8244e3a.

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

🏋️ Size limit report

Name Size Passed?
All 492.48 KB (+0.06% 🔺) Yes 🎉
Tree shaking (just a Button) 120.73 KB (0% 🟰) Yes 🎉

Compared against main at f777bbbrun 32379716198, 2026-08-20T14:22:10Z.

To see which modules changed, download the size-limit-statoscope-report artifact from this run and open report.html.

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

🧪 Storybook is successfully deployed!

@changeset-bot

changeset-bot Bot commented Aug 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0cde069

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@cube-dev/ui-kit Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

tenphi and others added 2 commits August 20, 2026 16:42
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 <noreply@anthropic.com>
@tenphi
tenphi merged commit acb9ab5 into main Aug 20, 2026
16 checks passed
@tenphi
tenphi deleted the feat/palette-seed-union branch August 20, 2026 14:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant