feat(palette): one seed per zone, and status themes from a color - #1336
Merged
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
📦 NPM canary releaseDeployed canary version 0.0.0-canary-8244e3a. |
Contributor
🏋️ Size limit report
Compared against main at f777bbb — run 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. |
Contributor
🧪 Storybook is successfully deployed!
|
🦋 Changeset detectedLatest commit: 0cde069 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
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>
…at/palette-seed-union
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Every palette zone —
accent,baseand each status theme — now takes the samePaletteSeed: 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:hue,saturationaccent: { hue?, saturation? }accentColoraccent: '#…'baseHue,baseSaturationbase: { hue?, saturation? }baseColorbase: '#…'themes.<status>: { hue?, saturation? }themes.<status>: '#…'themes.code: { saturation? }ResolvedPaletteConfigkeeps its flat shape (hue,baseHue,saturation,baseSaturation,accentColor,accentTone,accentSaturation), sopalette.ts,color-theme.ts,column-tint.tsandrenderPaletteTokensbarely move. Its four status entries gaincolorandcolorTone.PaletteThemeSeed→PaletteSeed;PaletteNumericSeedandResolvedThemeSeedare 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 (
hueoutrankedaccentColor). 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
#b91c1ccould 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 — everytype="primary"item on a status theme paints#whiteon that fill, so a pale color is pulled down rather than shipped as a white label on white. No new contrast constants: it reusesaccentColors' color arm andcappedAccent.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 at100beside a muted fill would give a fully tinted banner under a washed-out button. Because the fourextend()calls already forwardthemes.<name>.saturation, re-seeding is the multiplication —TINTED_SURFACE_RAMP,tintedSurfaceOverrideandtintRecipeare untouched, which is also what keepsprimary/purple/ the runtime tints byte-identical.Reviewer notes
Three consequences beyond the rename, all documented in the changeset and
Theming.docs.mdx:hueoutrankingaccentColorletresolvePaletteConfig({ 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.#danger/#success/#warning/#notealiases 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
PaletteModeTabsfollow 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
palette.test.tskept their assertions unchanged, which is the evidence the rename is faithful — the resolved shape and every expected token are identical.#0EA5E9at 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#8d6e63case because the realistic status hexes all measure above 88 chroma and would let a 4% drift pass for a proportion.#b91c1cmoved 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) emitsaccent-surfaceoklch(0.5106 0.2301 276.97)for#4f46e5anddanger-accent-surfaceoklch(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.