feat(palette): seed the brand from a color, not just a hue - #1314
Conversation
🦋 Changeset detectedLatest commit: 1643098 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 |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📦 NPM canary releaseDeployed canary version 0.0.0-canary-b368091. |
🏋️ Size limit report
Compared against main at f70ca4b — run 32243581885, 2026-08-19T10:37:26Z.To see which modules changed, download the size-limit-statoscope-report artifact from this run and open report.html. |
🧪 Storybook is successfully deployed!
|
Now built on
|
Now on the released
|
233bf4d to
ee98ce3
Compare
ee98ce3 to
2e1a483
Compare
Bugbot is paused — on-demand spend limit reachedBugbot uses usage-based billing for this team and has hit its on-demand spend limit. A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue. |
Bugbot is paused — on-demand spend limit reachedBugbot uses usage-based billing for this team and has hit its on-demand spend limit. A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue. |
One conflict, in `package.json`: both sides bumped a different dependency. Kept the newer of each — the branch's `@tenphi/glaze` 1.5.0 (which is what `from` and the composing contrast level need) and main's `@tenphi/tasty` ^3.0.2.
`main` at 500.29 kB and this branch at 501.60 kB with both sides rebuilt on the same machine, so the palette work is +1.31 kB and the old 501 kB limit was 596 B short of it. Rounded up to the next 5 kB step instead of fitted to the reading: the local number runs about 1.6 kB under the Linux runner's, so a limit set just above a macOS measurement lands below what CI will report. The Button budget is untouched — 123.32 kB on both sides, so a consumer importing only a Button pays nothing for any of this.
… ratio
Custom color mode only; the shipped palette runs the `null` arrangement and
is untouched, snapshots included.
Measured with Glaze's own `apcaContrast` over 12 hues at 30° steps, the fill
sitting exactly at the old `3` comes out at Lc 56.2 in light (55.8–56.5) and
Lc 23.3 in dark (22.7–24.4). Hue barely moves it — under 2 Lc across the
whole wheel — so the spread is polarity, not hue: one WCAG number was 2.4x
stricter in light than in dark. That is why light brands kept getting
crushed, and it left dark fills under APCA's own `non-text` floor of 30.
accent-surface { apca: [45, 85] } large
accent-text-soft { apca: [60, 85] } content
accent-text { apca: [75, 92] } body
The base stays `surface`. With the `bg` polarity that makes the light-mode
measurement `apcaContrast(white, fill)` — the white primary label on the
fill — while dark keeps measuring the fill against the dark page. Anchoring
to `accent-surface-text` instead, to make "from white" literal in both, was
tried and reverted: it stops constraining the fill against a dark page, and
`#111827` came out at WCAG 1.16 there.
In light the floor drops from ~WCAG 3.0 to ~WCAG 2.3, roughly 8 tone points
more headroom before a brand is darkened.
Two limits are now documented rather than assumed. The HC tier cannot say
"AAA in both schemes" in APCA — WCAG 7 is Lc 83.5 in light but Lc 54.4 in
dark — so `85` is the closest single value, landing ~6.1 in light and ~15 in
dark. And it has to be APCA at all because Glaze rejects a `contrast` pair
that switches metric between entries. The two tests that asserted the old
WCAG guarantees now assert them in the metric actually in use.
Choosing a white brand produced a white button. `accent-surface` is floored against `surface`, and in dark that is the page, not the label — so `#FFFFFF` cleared the floor at WCAG 14.4 while the `#white` every `type="primary"` item paints on it landed at Lc 0.0, the label exactly its own fill. `#FFD400` was Lc 28.5. Pre-existing: the old WCAG `[3, 7]` did the same thing, byte for byte, because the constraint was never the label pair except in light, where the page happens to be white. The fill needs both floors. Dropping either gives the mirror of the other's failure: with only the label floor, `#111827` puts the fill at Lc 0.0 against the dark page. The border does not cover for it — it is deliberately low-contrast — so the fill has to carry the shape itself. Glaze allows one `base` per color, so the page floor stays a `contrast` and the label floor becomes a cap on the seed tone, bisected against Glaze's own fixed-mode resolution instead of a reimplementation of the dark tone window, and checked on all four variants. It only lowers, so brands that were fine are untouched. #FFFFFF dark label Lc 0.0 -> 46.8 #FFD400 dark label Lc 28.5 -> 46.8 Both floors now hold at >= 45 on every brand in the set, in both schemes. The cap reaches `special` too, which paints `#white` on the same fill. Its test asserted the brand literal survived unchanged; exact equality there was a demand that the hero button's own label be unreadable, so it now asserts the hue arrived.
…riter v2's one breaking change is the fix for the scale mismatch reported in tenphi/glaze#93: the `format*` writers now take `s` / `l` / `t` on the 0–1 scale every producer already returns, so the composition that used to be silently 100x off is now the correct one. Two call sites drop their compensation: - `formatColor` in the color field, across all five notations. Its tests assert exact strings (`okhst(29.23 100% 58.59%)`), so they are the proof this is right rather than merely type-clean. - The accent label cap, which built its probe seed through `formatOkhst`. Output is unchanged either way — the palette's four-variant token values are byte-identical across the upgrade, and Glaze's export methods were compensating internally. The tone axis is the one exception the release notes call out: `toTone` / `fromTone` still speak the authoring API's 0–100, so a tone is divided on its way into `formatOkhst` while a saturation read off `resolve()` goes straight through. Commented at the call site, since the asymmetry reads as a typo.
`from` takes `OkhslColor | OkhstColor | RgbColor | OklchColor` as well as a string, so the cap's probe seed never needed formatting. Dropping `formatOkhst` takes the writers' 0–1 vs 0–100 question off this path and, more usefully, the two decimals `okhst()` rounds to: `#7A4DBF` came back 0.450200 against a true 0.450191. `AccentSeed.color` widens to `GlazeColorValue`, which it can because the field is only ever handed to `from`. An uncapped brand still passes through as the caller's own literal. No Glaze release involved — `OkhstColor` is existing 2.0.0 API. The floor test moves by an epsilon. It measures the emitted token, and the `oklch()` string carries four decimals, so `#FFD400` reads Lc 44.9925 where it used to read a hair over 45 — the round-trip had been rounding it up. Same treatment the high-contrast assertion already had at 84.9 for 85.
P1 — an explicit `hue` now rotates the whole ramp. `resolveConfig` ranks a
numeric hue above a color's own, but `buildPalette` passed Glaze the literal,
so `accent-surface` kept the color's hue while `-2`, `-3` and `hover`
followed the theme. The seed is built from resolved components now.
The white-label floor now holds on the EMITTED fill, not just the seed. A
3072-case sweep found 720 failures, worst at Lc 20.7. Three causes:
- the ceiling only searched when the requested tone already failed, so a
dark tone probed first cached "no ceiling" and later light tones at the
same hue escaped. It is a property of hue+chroma now, not of the tone.
- it searched to exactly 45, but the page floor runs afterwards and can
only lighten. It searches to +3 Lc now, a measured worst case of 1.8.
- the HC page floor of 85 is unsatisfiable alongside it. In dark the two
floors share `L ∈ [0.605, 0.735]`; 85 empties it. 60 is the largest that
keeps it open — 65 reopens 768 failures.
Sweep is 0/3072 after, min label Lc 46.1, min page Lc 45.5.
The cap cache is keyed on the palette version, so `glaze.configure()` plus
`invalidatePaletteTokens()` no longer reuses a cap computed against the old
dark tone window.
`pinSignature` carries the two color values instead of their presence, so
replacing one unparseable color with another stops returning early.
The Theme Builder's two color fields drop the numeric pins a previous clear
left behind, which outranked the incoming color and made it look ignored.
Docs no longer promise WCAG 3:1 — JSDoc, tooltip, docs.mdx and the earlier
changeset. The floors are APCA Lc 45 and a fill can sit under 3:1.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 51ff437. Configure here.
Giving `ColorSwatch` a size of its own resized both fields by accident. `ColorInput` passed the field's size through, so a medium field's swatch went 20px -> 24px and a large one 20px -> 28px; the `ColorPicker` trigger tracked its button and went 20px -> 24px. Measured in a real browser against main at all three field sizes. The swatch in a colour field is a badge showing a value, not part of the control, so both pin to 20px — identical to main everywhere. `size` and the automatic fit are untouched and stay right for a swatch placed in a `Button` or an `Item`, which have the padding that makes it work; a text input hangs its prefix off the border with none, which is why the fields opt out. The earlier changeset sold the tracking as a feature of these two, so it is corrected too, along with a docs line that gave the large auto-fit as 32px where it is 28px.

Describe changes
The palette could only be seeded from a hue, so a consumer holding a brand hex had to reverse-engineer one — and even then the brand never actually appeared.
accent-surfacewas authored as a fixedtone: '-49'off white, so every accent hue resolved to roughly the same lightness: a yellow brand came out olive.This adds color-valued seeds and settles the
pastel/saturationoverlap.accentColor/baseColorDeliberately asymmetric:
accentColorcontributes hue, saturation and tone. The tone is the point — it is what makes the brand fill actually be the color.baseColorcontributes hue only. Its tone and saturation are discarded, because the chrome's lightness ladder and its 0.10–0.20 saturation factors are the design. A base color says which way the greys lean, nothing more.Derivation lives in a new leaf module,
src/tokens/color-seed.ts, sopalette-config.tscan resolve a color insideresolveConfigwithout forming a cycle withpalette.ts. That is safe because a bare-string Glaze token defaults tolightTone: false, making all three readings independent of the global config thatpalette.tsinstalls at module scope — there is a test pinning exactly that.colorThemeSeednow delegates to it and no longer throws on an unparseable value.Fidelity: the floor is a floor, not a target
The fill is
mode: 'static'at the color's own absolute tone withcontrast: [3, 7]. A brand already at 5:1 — or 17:1 — is emitted exactly as given; only a color that cannot clear 3:1 is moved, and only as far as the floor. Measured:#7A4DBF,#EF4444,#111827are byte-exact in light;#FFD400,#22C55E,#F5F5F5are byte-exact in dark, because the floor is solved per scheme.mode: 'fixed'was rejected: it remaps the authored tone through the light tone window and lands ~3 tone units off even when nothing binds (#7A4DBFemits#8053c6).High contrast keeps AAA regardless of the requested color. Fidelity is a preference; that tier is selected by
prefers-contrast: moreor an explicitdata-contrast="high", so anyone reading it has asked for separation over brand.Two things worth a reviewer's attention
Status themes must not inherit the tone.
extend({ colors })would otherwise put#danger-accent-surfaceat a yellow brand's tone 88 with a red hue — a pale-pink danger button. They get the white-anchored chain restored explicitly.specialdoes follow the brand, being the brand-on-dark CTA.The brand-text HC target is 9, and that number is measured. The shipped
11is unreachable for a saturated hue against a chromatic base:#FFD400in dark high contrast pinned to pure black and measured 2.23 against its base while the rest link sat at 7.07 — a hover state less readable than the state it intensifies. 9 is the highest target reachable across six brands in both HC schemes, holding a ~2.0 gap. A test asserts zero unreachable-contrast warnings so this cannot regress silently.pastelandsaturationbecome two paths, not two knobs that fightPastel is one flat chroma ceiling, so a second saturation scale on top of it only undid the evenness it exists for. Under
pastelthe seed is pinned to100; setting asaturationtherefore turns pastel off, since tuning a saturation is the non-pastel path by definition. SosetPaletteConfig({ saturation: 55 })keeps resolving to 55 exactly as before — not a breaking change. An explicitpastel: truewritten beside a saturation wins and warns in dev, but the number is kept so the toggle round-trips.Fixed in passing
Under a color seed,
accent-surfaceandaccent-surface-2previously solved to the same value in high contrast, so the hover step disappeared. Re-anchoring the ramp onto the fill as a plain tone step keeps them apart.The shipped palette is unchanged
src/tokens/__snapshots__/palette.test.ts.snapis untouched — that is the contract, and it is why the relaxed chain is conditional on a color seed rather than applied globally. Relaxing the floors unconditionally would have movedaccent-textlight tone 38.76 → 48.63 andaccent-surfaceHC tone 36.08 → 51.00.Checklist
Closes: N/A
Other information
Storybook.
Getting Started / Theming → Theme Buildergains a Seeded by switch per zone (Hue / Color, plus Follow accent for base), requested-vs-resolved swatches that make the pastel chroma cap visible rather than mysterious, and a color-seededCobaltpreset. One rule governs the panel: a control is disabled exactly when the config field it writes is not the field in charge — which is why the hue slider stays on screen but disabled under a color seed, showing the hue that color derived.The three brand control clusters are now shared between
ThemeBuilderandPlayground, removing a near-duplicate that existed before this PR.Verified in the browser, not only in tests: applying
Cobaltrenders#accent-surfaceas exactly#2f5bff; switching the preview to High contrast tightens it to 8.10:1 while the hover link stays above the rest link (10.19 vs 8.14); turning Pastel on visibly separates the requested and resolved swatches.Also fixed:
Slate'ssaturation: 60became inert under the pastel pin, so it was dropped along with the comment that explained it. And saturation labels are now rounded — a saturation read off a color is a float and printed as99.9998610927005.🤖 Generated with Claude Code
Note
High Risk
Touches global palette generation, contrast/accessibility behavior for branded colors, and a major Glaze upgrade—consumers using
accentColor,contrastLevel, or direct Glaze formatting are most exposed.Overview
Palette seeding adds
accentColor/baseColor(via newcolorSeed()), plusbaseSaturation,surfaceMode: 'tinted', and clearer pastel vs saturation paths. A color-seeded accent keeps hue/chroma/tone so the brand fill can match the literal in light; status themes stay isolated from that chroma. Pinned brands use APCA Lc floors (page + white label) instead of WCAG ratios, with follow-up fixes so the whole ramp tracks resolved hue, label caps hold on the emitted fill, and config signatures include color strings.Contrast changes so a manual
contrastLevelonly moves the normal tier while high contrast still composes withdata-contrast="high"(except at level 100).@tenphi/glazebumps to 2.0.0; colorformat*calls drop erroneous* 100scaling.UI kit:
ColorSwatchis a documented component with sizes and host-aware sizing; color fields keep a fixed 20px swatch.HueSlidershows degrees;RadioGroupusesborder-boxfor tab layouts. ColorInput/ColorPicker fireonChangeper slider step and update forms without blur. Theme Builder / Theming docs gain Pastel/Advanced/Color tabs, export, tinted surfaces, and preview-only light/dark + HC switches. Bundle size limit raised to 505 kB.Reviewed by Cursor Bugbot for commit 1643098. Bugbot is set up for automated code reviews on this repo. Configure here.