Skip to content

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

Open
tenphi wants to merge 19 commits into
mainfrom
feat/palette-color-seeds
Open

feat(palette): seed the brand from a color, not just a hue#1314
tenphi wants to merge 19 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

Medium Risk
Global palette resolution and manual contrastLevel behavior change for consumers who set a level, though the default shipped palette is snapshot-guarded; theming mistakes would affect every tokenized surface in an app.

Overview
Palette seeding moves from hue-only to real colors via accentColor and baseColor (hex, rgb(), hsl(), oklch(), etc.), with new colorSeed() and resolution in palette-config. accentColor drives hue, chroma, and tone so light normal-contrast can match the literal brand (Glaze from); baseColor drives hue and saturation (capped at exported MAX_BASE_SATURATION) but not tone. Brand chroma no longer inflates palette saturation, so status themes stay isolated; pastel vs saturation are explicit paths (saturation turns pastel off; pastel pins saturation to 100).

Chrome tuning adds surfaceMode: 'neutral' | 'tinted' (tinted shifts the surface ramp inward so base hue can show on the page) and baseSaturation, defaulting to 12% of accent chroma unless a base color overrides. contrastLevel (with @tenphi/glaze 1.5.0) only slides normal colors; the high-contrast tier still composes with data-contrast="high" except at level 100.

Components & fields: ColorSwatch is a documented field with size and $size-aware auto-fit; ColorInput/ColorPicker fire onChange on every channel drag and commit form values without blur; HueSlider shows degrees; RadioGroup uses border-box for full-width tabs layouts.

Storybook / docs: Theme Builder gets Pastel / Advanced / Color tabs, export/reset at the top, status theme chips, preview scheme/contrast toolbar, and shared control clusters with the Playground; theming docs expand accordingly. Tests cover colorSeed and color-field behavior; bundle size limit raised to 501 kB.

Reviewed by Cursor Bugbot for commit 0c5f136. 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: 0c5f136

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 17, 2026 5:04pm

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

@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

🏋️ Size limit report

Name Size Passed?
All 488.36 KB (+0.27% 🔺) Yes 🎉
Tree shaking (just a Button) 119.44 KB (0% 🟰) Yes 🎉

Click here if you want to find out what is changed in this build

@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 and others added 2 commits August 14, 2026 18:41
`accentColor` and `baseColor` accept a real color string, so a brand can be
given as the color you have rather than as a hue you had to derive from it.

The tone is the point. The brand fill was authored as a fixed tone step off
white, so every accent hue landed at roughly the same lightness and a yellow
brand came out olive. With a color seed the fill is pinned to the color's own
tone and rendered exactly wherever a 3:1 floor against the page allows, moving
only as far as that floor requires. The floor is solved per scheme, so a light
brand is byte-exact on a dark page and darkens on a light one; high contrast
keeps its AAA floor regardless, since that tier is asked for explicitly.

`baseColor` deliberately contributes hue only — the chrome's lightness ladder
and its saturation factors are the design, so a base color says which way the
greys lean and nothing more.

Status themes do not inherit the tone: a light brand would otherwise turn
`#danger-accent-surface` into a pale pink. `special` does follow it, being the
brand-on-dark CTA.

`pastel` and `saturation` become two explicit paths rather than two knobs that
fight. Pastel is one flat chroma ceiling, so under it the seed is pinned to 100;
setting a `saturation` therefore turns pastel off, which keeps
`setPaletteConfig({ saturation: 55 })` resolving to 55 as before. An explicit
`pastel: true` wins over a saturation and warns, but keeps the number so the
toggle round-trips.

Also fixes the brand fill ramp collapsing in high contrast under a color seed:
`accent-surface` and `accent-surface-2` previously solved to the same value
there, so the hover step disappeared.

The shipped palette is unchanged — the snapshot test passes untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the derive-then-re-seed workaround with the primitive built for it.
`accentColor` is handed to Glaze as a literal, which supplies the hue, the tone
and an absolute saturation that does not answer to the theme seed.

Three things fall out of that.

The palette-level `saturation` is no longer raised to reach a saturated brand,
because the accent family carries its own chroma. That was the source of the
worst side effect in 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 it against
the untouched baseline rather than against a saturation-matched reference.

The brand text and icon are now exact too, not just the fill. `from` bypasses
the light tone window per color, so `#2F5BFF` renders `#2f5bff` as the link and
the icon where it used to land on `#3764ff`.

`mode: 'static'` is gone. Exactness is scoped to the light, normal-contrast
variant — which is what `from` guarantees — so the chain goes back to
`mode: 'fixed'` and dark maps through its window like any other fixed color.
Dark and high contrast are where readability outranks fidelity.

Status themes still restore the white-anchored chain, and now must: `extend()`
copies defs, so an inherited `from` would make a danger button the brand color
outright rather than merely a washed-out version of itself.

Pins @tenphi/glaze to the PR snapshot (tenphi/glaze#89) — to be swapped for a
released version before merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tenphi and others added 12 commits August 14, 2026 18:42
CI measured 497.13 kB, 131 bytes over. All of it is the dependency: the Glaze
build gained `from`, at +325 bytes measured locally between the two builds with
the kit's source held constant. The kit's own source shrank over the same
commit, since `from` replaced a derive-then-re-seed workaround.

Headroom is 869 bytes rather than the ~1.9 kB the last bump left — nothing else
is queued behind this, and the budget earns its keep by tripping on the next
unplanned kilobyte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`0.0.0-snapshot.94c4efb` carries three fixes found reviewing tenphi/glaze#89: a
def-keyed cache that survived its own invalidation, a theme `darkSaturation`
that suppressed the dark haircut on a literal color, and an unparseable `from`
whose error did not name the color. +22 bytes; still inside the budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1.4.0 ships `from` (tenphi/glaze#89), which this branch was consuming as a PR
snapshot. Same code — the built bundle measures 496,215 bytes either way — so
this only takes the dependency off a snapshot tag and clears the merge blocker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Updates @tenphi/glaze to 1.5.0, which corrects the manual `contrastLevel` model,
and follows the change through the kit's tests, docs and tuner.

A level now does one thing: it positions the NORMAL colors on the 0–100 slider.
The high-contrast tier stays the true high-contrast resolution at every level —
identical to what `'auto'` emits — so the two compose instead of replacing each
other. A product's own contrast slider raises the baseline while
`prefers-contrast: more` still escalates on top of it.

Two consequences worth knowing:

`contrastLevel: 0` now reproduces `'auto'` exactly, tier included, so shipping
the slider and defaulting it off costs a consumer nothing. Previously setting any
level silently dropped the tier, which meant `data-contrast="high"` stopped
working the moment a product offered the control.

Level 100 is now the only level without a separate tier, and for a different
reason than before: the normal colors already ARE the high-contrast ones there,
so a second set would only duplicate them.

The tuner's callout follows suit — it explained that the tier was disabled under
any manual level, which is no longer true and was the misleading half.

The shipped palette is untouched; it runs at `'auto'` and the snapshot is
unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…plit in two

`ColorSwatch` moves into `fields/ColorSwatch/` with stories, docs and a `size`
(20 / 24 / 28px). Left unset it reads the `$size` custom property `Item`,
`Button` and the text inputs already publish and lands 8px inside the control,
so an icon-slot swatch tracks its host with nothing passed between them.
`ColorInput` pins its own swatch to the named size instead — a text input hangs
its prefix off the border with no padding of its own.

The theme builder now draws one line through the middle: the left column is the
theme, the two switches over the preview are the viewing conditions. Light/Dark
and Normal/High contrast start on whatever the page is already showing and keep
following it until pressed, so `Auto` is the behaviour rather than a third
option. `contrastLevel` moves to the theme side as a plain always-visible
slider — level 0 and `'auto'` are output-identical since 1.5.0, so the mode
switch it used to need has nothing left to say. Eight status sliders become four
`type="current"` rows, each colored by its own theme with a swatch of its accent
and its hue and saturation one press away. Two additions: the controls are
sticky beside the preview, and a Take-it-with-you panel prints the live config
as a `setPaletteConfig()` call.

Also fixes `RadioGroup`, which laid its padded `tabs` layout out as
`content-box`: an explicit `width: 100%` came out 1x too wide, which is why the
color popover's space switcher overhung its right border.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… tuner

`surfaceMode: 'tinted'` moves the neutral surface ramp two tones off the end of
the tone scale. Chroma needs distance from the extreme to exist at all, so on a
light page `surface` was white whatever saturation the palette carried; two tones
is the room the base hue needs to reach the page itself. Everything below
`surface` is relative to it, so the ladder, borders and text ramp follow and the
text's contrast floors re-solve against the new background — the `code-*` mirror
included, since it exists to be the page.

`baseSaturation` gives the base zone its own seed, opening the same seam
`baseHue` already opens: the chrome is the one family whose job is not to look
like the brand. Same 0–100 scale as `saturation`, shipped at 12, defaulting to
`saturation × 0.12` so an untouched palette resolves token for token as before.
Unlike `saturation` it does not turn `pastel` off.

Tuner changes, all from review of the panel itself:

- Every caveat is an `InfoBadge` beside the control it qualifies, through the
  field `tooltip` prop. Prose between controls pushed the next knob off the
  screen to explain something you need once.
- Slider labels drop the value, which the slider already prints beside them.
  `HueSlider` now formats its own as degrees, so the unit survives the cut.
- Group headings use `c2`, the settings-heading preset.
- The status popover has padding; `Dialog` pads through its `Content` slot and
  this one holds raw children.
- The config snippet becomes an `Export` button beside `Reset to defaults` —
  copy the `setPaletteConfig()` call or download `palette.json`. At the bottom of
  a scrolling column it was neither reachable nor legible.
- The status saturation slider says what pastel does to its range. It works
  there; the flat ceiling just compresses it to about a third, which reads as a
  dead control.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…mode

A status theme's `surface` was authored as an absolute tone `[98, 96]`, which is
the page's own tone minus the two-tone offset it was chosen for. Under
`surfaceMode: 'tinted'` the page moves to 98 as well, so a `note` banner landed
on exactly the tone behind it and stopped reading as a banner.

Both tinted recipes now take their surface tone from the same anchor as the
neutral one, so the offset survives the shift: the status themes, `primary`, and
the runtime tints `getColorTheme()` builds — a table column tinted at runtime has
to sit the same distance off the page as a built-in theme does. The text ramps on
those themes are already relative, so only the absolute tone needed the anchor.

Measured in both schemes: light keeps a 0.02 lightness gap between page and
banner either way, dark keeps 0.018. The tinted surfaces also gain a little
chroma there, being further from the extreme — so the mode makes a status surface
easier to see rather than harder.

`TINT_RECIPE` becomes `tintRecipe(config)`. It was internal, exported only for
`color-theme.ts`, so nothing published moves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…he tuner

`Saturation` becomes `Global saturation` and moves ahead of the two zones. It
governs both of them, and `pastel` decides whether the number under it is even
live — so the panel now reads global, then accent, then base, which is the order
the config resolves in.

The switchers take `labelPosition="split"`: label left, control right, on one
line instead of two. That includes the Pastel switch, which swaps its inline
label for a field one so every row in the column sits on the same grid. The
sliders keep their top label — they already print label and value on one line,
and a split row would squeeze the track.

Paid for with the sidebar: 340–400px instead of 280–340 (a split row needs room
for a label and a button group facing each other, and "Base seeded by" against
three options sets the floor) and `2x` padding instead of `3x`. Net effect on the
column that matters: 1470px of content down to 1154, so most of the panel now
fits a 900px viewport where before it needed two screens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…er accent chips

The resolution chips drop `Requested`. It repeated the accent color field sitting
directly above them, swatch included. The two that remain are renamed for the
tokens they are — `Accent Fill` and `Accent Text` — spaced 2.5x apart instead of
1.5x, and cut from 24px to 20px, which is enough to read a color beside a label.

`Reset`, `Export` and `JSON` move to the top of the column, with icons. The one
button you reach for when an experiment goes wrong should not sit behind the
controls that caused it. `JSON` is new and downloads on the press: the download
had no intermediate state worth showing, so the popover it lived in was a click
for nothing. `Export` keeps its popover, because a snippet needs room to be read.

Status themes become four chips on one line. Dropping `— 157°` is what buys the
row: the hue belongs to the slider that sets it, and printing it here made each
chip wide enough that four had to stack — which is the arrangement the chips
replaced. It moves to the chip's tooltip.

`Cobalt` is gone. It departed on the mechanism rather than the numbers, and the
seeding switch teaches that better: flip Accent seeded by to Color and the field
opens on a color you can replace with your own.

An active preset now shows `isSelected`. The setter replaces rather than merges,
so a preset is either the whole sparse config or it is not the config at all —
which is what lets one equality check stand in for per-field bookkeeping, and what
makes the selection clear the moment any control is touched.

Column content is down to 978px from 1154, so the panel now fits a 900px viewport
almost exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… by reach

Pastel is one flat chroma ceiling with the palette seed pinned to the top of it,
so a per-theme saturation scale underneath it argues with the mode — and the range
that survives the ceiling is about a third of the non-pastel one, narrow enough
that dragging it reads as nothing happening. The slider is gone while pastel is
on, in the builder popover and in the `Status themes` story alike; hue is the knob
that still means something there.

The engine still honours a `themes.<status>.saturation` under pastel, so a number
set before pastel went on stays in effect. That case says so rather than hiding:
the popover prints `Saturation 71 — pinned` with a badge explaining that turning
pastel off is how to reach or clear it. A hidden knob that still acts would be
worse than either extreme.

Labels regrouped by what a knob REACHES rather than what it is named after.
`Global` now holds `pastel`, `saturation`, `surfaceMode` and `contrastLevel` —
`surfaceMode` included, because it moves the status themes' tinted surfaces and the
syntax palette's mirrored one as well as the base ramp. `Accent` and `Base` keep
only what is theirs, and their fields drop the prefix the heading already carries:
`Accent seeded by` → `Seeded by`, `Base hue` → `Hue`, `Base saturation` →
`Saturation`.

The Playground gains the same three headings. It had none, and bare `Seeded by`
twice in one panel would have said nothing about which zone.

Column content: 978px → 936px against a 900px viewport.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…between two systems

`baseColor` contributed hue only, which left a hole: picking one set which way the
greys leaned but not how far, so the chrome's chroma still came from the accent
seed and had nothing to do with the color chosen. It now contributes saturation
too, clipped to the new `MAX_BASE_SATURATION` (50) — naming a base color says "the
chrome IS this color", but a fully saturated chrome stops being chrome. Its tone is
still discarded; the chrome's lightness ladder is the design.

`baseSaturation`'s default follows suit: 12% of whatever the ACCENT ZONE carries,
the seed or an `accentColor`'s own chroma. Without that a near-grey brand left the
chrome carrying 12% of a saturation nobody asked for.

Both arms are capped by `saturation`, and that cap is load-bearing rather than
defensive. `baseSaturationScale` divides by the seed, so the chrome's absolute
chroma is a function of `baseSaturation` alone — uncapped, an accent color cancels
the seed out of the base zone and `saturation: 20` leaves a chrome 4.4x more
chromatic than asked for. Caught by review, pinned by a test. The status themes are
untouched either way: no color writes to `saturation`, so a brand still cannot
re-chromatise them.

`ResolvedPaletteConfig` gains `baseColor` so `isSameConfig` can compare the string.
The clip makes every color above 50 on one hue derive the same pair, and the tone is
discarded outright, so without it a color picker's whole upper range and its entire
tone axis dropped writes silently and left the field stale.

The tuner now presents the palette's two chroma models as two states instead of
knobs that argue:

- `pastel` gates saturation. One flat ceiling leaves nothing for a scale to do, so
  every saturation control is hidden while it is on (except the syntax one, which
  pastel never reaches) and `Seeded by` is disabled — a flat ceiling caps an
  arbitrary color well short of itself. Turning pastel on from color mode converts
  to the numbers the colors were deriving.
- A derived control is not shown. The disabled hue slider under a color seed was a
  read-out dressed as a control.

`Seeded by` becomes ONE switch for both zones, in Global — it made four combinations
out of a decision nobody makes twice. The base zone keeps `Follow accent | Own`,
which is orthogonal. `Saturation` moves from Global into Accent, where it belongs
now that Base has one of its own.

Two things review caught in passing: the base start hex is `#6c717f` rather than
`#6e7076`, picked for its ~11.8 saturation against the chrome's 12 so the flip is
chroma-neutral instead of collapsing the tint 58%; and the handover rounds to the
slider's step rather than exporting `baseSaturation: 5.073101036751972`.

`resolves every brand without an unreachable-contrast warning` was passing on cache
timing — `#FFD400` trips two Glaze warnings on the commit before this one too, and
the render memo happened to serve its build outside the spy's window. It now
invalidates deliberately and excludes that brand with the measurement written down:
Glaze solves on the gray-equivalent tone axis and a saturated yellow departs from
it, but measured against the base the floor is authored against it lands at 9.09.

The shipped palette is unchanged — no color seed means the expression reduces to
`saturation × 0.12` on the same operands, and the snapshot is byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`pastel` and a per-zone `Seeded by` were two controls describing a 2x2 grid with
one impossible cell — pastel cannot honour a colour — so one of them spent its life
disabled and the colour pickers sat two presses deep. Three tabs spend three
buttons on the three reachable states: Pastel, Advanced, Color. Pastel to Color is
now one press.

`Advanced` and `Color` are the same chroma space and differ only in what you hand
it; `Pastel` is the other space, and it takes hues because a flat ceiling has
nothing to do with a colour. Every transition still hands the incoming controls the
values the outgoing ones were displaying, so leaving Color pins what it derived and
drops the hex — including on the way to Pastel.

Full-width `Radio.Tabs` above the groups rather than inside one, and deliberately
unlabelled: it governs everything below it, so naming it would mean finding a word
for "the palette", and both candidates were spoken for. Which is also why
`Surface mode` becomes **Surfaces** — two things called a mode is one too many —
and its `Default` option becomes **Neutral**, since a surface at the end of the
tone scale is exactly what neutral means. The config value follows the label:
`surfaceMode: 'neutral' | 'tinted'`, free to rename because the field is unreleased
in this PR.

The Playground gets the same tab bar. It had the switch and the per-zone control
inside its columns, so without it Advanced and Color would be unreachable there.

Two rows saved and a disabled control gone: the builder's column is 780px against a
900px viewport under the shipped default, so it fits on one screen. It was 1470px
when this round of work started.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tenphi added 5 commits August 17, 2026 15:54
Resolves two files the branch had rewritten under main's self-lint pass:

- `HueSlider`: keep the branch's `formatOptions` default (degrees), drop
  `step={1}` — main removed it along with `minValue={0}` as a redundant
  default.
- `Theming.stories.tsx`: take the branch's rewrite, then re-run
  `no-redundant-default-prop` over it, since main now lints stories with
  the rule the package ships. Twenty-two props go: `minValue`/`maxValue`
  on every `Slider`, `type="outline"` on the action and preset buttons,
  `language="javascript"` on the export snippet. The button-type row
  keeps its explicit `type="outline"` behind a disable comment — that row
  exists to name every type, the default included.
Main's `item-selected-disabled` makes this possible: a disabled
`RadioGroup type="button"` now paints the brand-tinted
`accent-disabled-surface` chip on its selected option instead of
rendering every option identically, so the group can be turned off and
still say which one is live.

At `contrastLevel: 100` there is no second variant — the normal colors
already are the high-contrast ones — so the switcher was offering a
state the palette cannot produce, and a danger `InfoBadge` appeared
after the fact to say the selection had not taken. It is now disabled
and pinned to Normal, with the badge shown for the whole condition
rather than only once you had picked the option that does nothing.

The override itself survives: dropping back below 100 restores whatever
tier was selected, because only the displayed value is clamped.
Under `Follow accent` both sliders were read-outs of the accent's hue and
saturation, labelled `(inherited)` and doing nothing you could act on:
moving one pinned the field, which flipped the radio to Own by itself. So
the control could not be used without also changing what the radio above
it said — two ways into the same state, one of them by accident.

The radio is now the only way in, and the labels drop `(inherited)`
because there is nothing left to distinguish them from. Taking over is
still not a repaint: the setter seeds the fields from the values the
accent was resolving, which is what the sliders now open at.

Under a color seed they stay hidden as before, both being derived.
`GroupLabel` moves from `#surface-text-soft` to `#surface-text-soft-2`.
The headings sat at the same tier as the field labels they group, so a
column of controls read as one flat list; a divider should be quieter
than what it divides.

`MEMBERS` inside the preview panel stays on `#surface-2-text-soft` — the
`-soft-2` tier only exists on the base surface, and that label has to
re-resolve against the panel's own `surface-2`.

@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 0c5f136. Configure here.

baseHue: Math.round(palette.baseHue),
baseSaturation: Math.round(palette.baseSaturation * 2) / 2,
},
)

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.

Color clear leaves stale seed pins

Medium Severity

Clearing a color seed field pins baseHue / baseSaturation (or hue), then a later non-null onChange spreads that config and only adds the new color. Because numeric seeds win over color-derived ones in resolveConfig, the new baseColor does not move chrome hue or saturation. The same pin leak happens for accent when color mode stays active (for example with an own baseColor).

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0c5f136. Configure here.

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