feat(palette): seed the brand from a color, not just a hue - #1314
feat(palette): seed the brand from a color, not just a hue#1314tenphi wants to merge 19 commits into
Conversation
🦋 Changeset detectedLatest commit: 0c5f136 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-c7b9b33. |
🏋️ Size limit report
Click here if you want to find out what is changed in this build |
🧪 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. |
`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>
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>
425e52b to
1fba805
Compare
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`.
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 0c5f136. Configure here.
| baseHue: Math.round(palette.baseHue), | ||
| baseSaturation: Math.round(palette.baseSaturation * 2) / 2, | ||
| }, | ||
| ) |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit 0c5f136. Configure here.


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
Medium Risk
Global palette resolution and manual
contrastLevelbehavior 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
accentColorandbaseColor(hex,rgb(),hsl(),oklch(), etc.), with newcolorSeed()and resolution inpalette-config.accentColordrives hue, chroma, and tone so light normal-contrast can match the literal brand (Glazefrom);baseColordrives hue and saturation (capped at exportedMAX_BASE_SATURATION) but not tone. Brand chroma no longer inflates palettesaturation, so status themes stay isolated;pastelvssaturationare explicit paths (saturationturns 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) andbaseSaturation, 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 withdata-contrast="high"except at level 100.Components & fields:
ColorSwatchis a documented field withsizeand$size-aware auto-fit;ColorInput/ColorPickerfireonChangeon every channel drag and commit form values without blur;HueSlidershows degrees;RadioGroupusesborder-boxfor 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
colorSeedand 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.