Skip to content

feat(palette): seed the brand from a color, not just a hue - #1314

Merged
tenphi merged 28 commits into
mainfrom
feat/palette-color-seeds
Aug 19, 2026
Merged

feat(palette): seed the brand from a color, not just a hue#1314
tenphi merged 28 commits into
mainfrom
feat/palette-color-seeds

Conversation

@tenphi

@tenphi tenphi commented Aug 13, 2026

Copy link
Copy Markdown
Member

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-surface was authored as a fixed tone: '-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 / saturation overlap.

accentColor / baseColor

setPaletteConfig({ accentColor: '#2F5BFF', baseColor: '#7A7269' });

Deliberately asymmetric:

  • accentColor contributes hue, saturation and tone. The tone is the point — it is what makes the brand fill actually be the color.
  • baseColor contributes 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, so palette-config.ts can resolve a color inside resolveConfig without forming a cycle with palette.ts. That is safe because a bare-string Glaze token defaults to lightTone: false, making all three readings independent of the global config that palette.ts installs at module scope — there is a test pinning exactly that. colorThemeSeed now 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 with contrast: [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, #111827 are byte-exact in light; #FFD400, #22C55E, #F5F5F5 are 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 (#7A4DBF emits #8053c6).

High contrast keeps AAA regardless of the requested color. Fidelity is a preference; that tier is selected by prefers-contrast: more or an explicit data-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-surface at a yellow brand's tone 88 with a red hue — a pale-pink danger button. They get the white-anchored chain restored explicitly. special does follow the brand, being the brand-on-dark CTA.

The brand-text HC target is 9, and that number is measured. The shipped 11 is unreachable for a saturated hue against a chromatic base: #FFD400 in 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.

pastel and saturation become two paths, not two knobs that fight

Pastel is one flat chroma ceiling, so a second saturation scale on top of it only undid the evenness it exists for. Under pastel the seed is pinned to 100; setting a saturation therefore turns pastel off, since tuning a saturation is the non-pastel path by definition. So setPaletteConfig({ saturation: 55 }) keeps resolving to 55 exactly as before — not a breaking change. An explicit pastel: true written 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-surface and accent-surface-2 previously 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.snap is 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 moved accent-text light tone 38.76 → 48.63 and accent-surface HC tone 36.08 → 51.00.

Checklist
  • Pipeline is passed
  • Tests are added (including unit tests and stories in the storybook)
  • Tests are passed successfully
  • If you're adding a new component/new props, add stories that describe how this component/prop works
  • Changeset(s) is(are) added
  • You have passed the threshold of the library size
  • Commit message follows commit guidelines

Closes: N/A

Other information

Storybook. Getting Started / Theming → Theme Builder gains 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-seeded Cobalt preset. 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 ThemeBuilder and Playground, removing a near-duplicate that existed before this PR.

Verified in the browser, not only in tests: applying Cobalt renders #accent-surface as 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's saturation: 60 became 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 as 99.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 new colorSeed()), plus baseSaturation, 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 contrastLevel only moves the normal tier while high contrast still composes with data-contrast="high" (except at level 100). @tenphi/glaze bumps to 2.0.0; color format* calls drop erroneous * 100 scaling.

UI kit: ColorSwatch is a documented component with sizes and host-aware sizing; color fields keep a fixed 20px swatch. HueSlider shows degrees; RadioGroup uses border-box for tab layouts. ColorInput/ColorPicker fire onChange per 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.

@changeset-bot

changeset-bot Bot commented Aug 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1643098

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

@vercel

vercel Bot commented Aug 13, 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 19, 2026 10:39am

Request Review

@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

📦 NPM canary release

Deployed canary version 0.0.0-canary-b368091.

@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

🏋️ Size limit report

Name Size Passed?
All 490.37 KB (+0.37% 🔺) Yes 🎉
Tree shaking (just a Button) 120.43 KB (0% 🟰) Yes 🎉

Compared against main at f70ca4brun 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.

@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

🧪 Storybook is successfully deployed!

@tenphi

tenphi commented Aug 13, 2026

Copy link
Copy Markdown
Member Author

Now built on from (tenphi/glaze#89)

The recipe no longer derives-and-re-seeds; it hands the color to Glaze's new from and pins the PR snapshot 0.0.0-snapshot.84edd5e.

⚠️ Do not merge until @tenphi/glaze is repointed at a released version. The dependency currently resolves to a PR snapshot.

Three things improved rather than merely getting shorter:

  • The brand no longer leaks into the rest of the palette. from carries an absolute saturation, so the palette-level saturation is no longer raised to reach a vivid brand. That was the worst side effect of the previous approach — honoring one brand color re-chromatized the neutral chrome and all four status themes. #danger-accent-surface is now identical whatever the brand is, and the test asserts that against the untouched baseline instead of a saturation-matched reference.
  • The brand text and icon are exact too, not just the fill. from bypasses the light tone window per color, so #2F5BFF now renders #2f5bff as the link and the icon where it previously landed on #3764ff.
  • mode: 'static' is gone. Exactness is scoped to light / normal contrast, which is what from guarantees, so the chain returns to mode: 'fixed' and dark maps through its window like any other fixed color.

The status-theme restore stays, and is now more necessary: extend() copies defs, so an inherited from would make a danger button the brand color outright rather than a washed-out version of it.

Verified live in the Theme Builder — applying Cobalt renders #accent-surface, #accent-text-soft and #accent-icon all as exactly #2f5bff, with the warm-grey border at hue 70.7° from baseColor and danger holding its own hue. The default-palette snapshot is still untouched.

@tenphi

tenphi commented Aug 13, 2026

Copy link
Copy Markdown
Member Author

Now on the released @tenphi/glaze@1.4.0

tenphi/glaze#89 is merged and released as 1.4.0, so the dependency is off the PR snapshot and the merge blocker is cleared.

Same code either way — the built bundle measures 496,215 bytes against both 0.0.0-snapshot.94c4efb and 1.4.0, and the Theme Builder renders identically (Cobalt still resolves #accent-surface, #accent-text-soft and #accent-icon all to exactly #2f5bff).

1846 tests pass, and the size check is inside the 498 kB budget.

Remaining before merge: the two Chromatic baselines need accepting.

@cursor

cursor Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Bugbot is paused — on-demand spend limit reached

Bugbot 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.

@cursor

cursor Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Bugbot is paused — on-demand spend limit reached

Bugbot 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.

tenphi added 2 commits August 18, 2026 10:34
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.
Comment thread src/tokens/palette.ts
…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.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread src/tokens/palette.ts
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.
@tenphi
tenphi merged commit 781973c into main Aug 19, 2026
18 of 19 checks passed
@tenphi
tenphi deleted the feat/palette-color-seeds branch August 19, 2026 11:30
@tenphi tenphi mentioned this pull request Aug 19, 2026
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