feat: seed a theme color from a literal value with from - #89
Conversation
Most of Glaze answers "design me a palette". This answers the other question,
"honor this color" — white-label products, multi-tenant branding and imported
design tokens all arrive with a value already chosen, and it is a contract
rather than a starting point.
`from` accepts the same values as `glaze.color()` and supplies hue, tone, and —
uniquely among theme colors — an ABSOLUTE saturation. That last part is what
makes it worth having: every other color's `saturation` is a 0–1 factor of the
theme seed, so the seed is a ceiling, and the only way to place a color more
saturated than its theme was to re-seed the theme and drag every sibling along.
The light, normal-contrast variant reproduces the value exactly, via a local
`lightTone: false` — the same default the value-shorthand form of
`glaze.color()` already applies, now addressable per color. Dark and high
contrast adapt as usual, because those are the variants a reader reaches for
when the normal one does not work for them.
Also: a `from` color gets its own `--{name}-hue` var under `splitHue` (it
authors a hue that is not the theme's, so tracking the theme var would re-skin
it — the same failure fixed for `darkHue`-only colors in 1.3.1), and the
`GlazeColorValue` parser moved to a new internal `color-value.ts` so the
resolver can reach it without an import cycle. No public export changed.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
📦 Snapshot releasePublished |
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>
Drop the def-keyed memo on the parsed `from` value. Defs are stored by reference, so mutating one in place and re-setting it invalidates the theme's own cache while a def-keyed cache goes on serving the previous color. The parse is a few microseconds against a resolve that is already cached — not a trade worth a staleness bug. Stop a theme-level `darkSaturation` from suppressing the dark haircut on a `from` color. That flag means "a dark saturation was authored", but a `from` color never reads the seed, so honoring it left the same color MORE saturated in dark than it is in a theme that sets no dark seed at all. Reject an unparseable `from` in `validateColorDefs`, naming the color. The parser's own error names the offending string but not the token it came from, which in a large palette is the half you need. 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>
Review pass — three fixesReviewed this adversarially rather than trusting the green suite. Two verifications and three real bugs. Verified
Fixed (bc01c75)A def-keyed cache that outlived its own invalidation. I had memoized the parsed const def = { from: '#2f5bff' };
theme.colors({ brand: def }); // → hue 266.2
def.from = '#ffd400';
theme.colors({ brand: def }); // → still 266.2 ✗Dropped the memo entirely. The parse is a few microseconds against a resolve that is already cached — not a trade worth a staleness bug. A theme-level An unparseable Scope checks that came back clean
365 tests (three added for the fixes above), and |
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>
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>
`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>
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>
`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>
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>
`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>
The gap
Most of Glaze answers "design me a palette" — you pick seeds and everything derives from them. There is a second, very common job it has no primitive for: "honor this color."
White-label products, multi-tenant branding, and design tokens imported from Figma or a DTCG file all arrive with a value already chosen. There the color is an input and a contract, not a starting point.
Why it wasn't already expressible
The blocker is not tone — it's saturation. Every theme color's
saturationis a 0–1 factor of the theme seed, so the seed is a ceiling. The only way to place a color more saturated than its theme was to re-seed the whole theme:That middle step has real blast radius. In the palette that motivated this, the accent seed is shared with five status themes, so honoring one brand color re-chromatized
danger/success/warning/noteas a side effect — and the fix was a pile of code restoring those themes, none of which was about the brand color.Glaze already had the right vocabulary —
glaze.color({ from, ...overrides })— just not in the theme authoring path, where it was silently ignored.Semantics
fromaccepts the same values asglaze.color()and supplies hue, tone, and — uniquely among theme colors — an absolute saturation.Exactness is scoped to light / normal contrast. That variant reproduces the value via a local
lightTone: false— the same default the value-shorthand form ofglaze.color()already applies, now addressable per color rather than per config. Dark and high contrast adapt as usual.That asymmetry is the design, not a limitation. Those are the variants a reader reaches for when the normal one does not work for them, so readability outranks fidelity there — and a color pinned across all four would just be a worse
mode: 'static'.A
contrastfloor still applies everywhere and remains a floor rather than a target: a value that already clears it is emitted untouched. Because the floor is solved per scheme, which scheme comes out exact depends on the color — a light brand cannot clear 3:1 on a white page but clears it easily on a dark one.Sibling fields win over the value, so
{ from: '#2f5bff', hue: 300 }keeps saturation and tone and rotates the hue. Asaturationwritten alongsidefromreverts to its usual meaning (a factor of the seed). Afromcolor needs neitherbasenortone— it is placed absolutely, so it stands as a root on its own.Implementation
types.ts—from?: GlazeColorValueonRegularColorDef.resolver.ts—fromSeed()(memoized on the def via aWeakMap) derives hue / absolute saturation / tone;resolveChannelsbypasses the seed multiplication for both the light and dark branches;configForColor()hands the color a locallightTone: false; the dependent path defaults its authored tone to the carried one.validation.ts—hasAbsoluteTone()so a bare{ from }satisfies the root/dependent requirement instead of throwing.channels.ts— afromcolor gets its own--{name}-huevar in both schemes. It authors a hue that is not the theme's, so pointing it at the theme var would re-skin it on the next re-seed — the same failure mode fixed fordarkHue-only colors in fix: give a darkHue-only color its own hue var in both schemes #87.color-value.ts(new) — theGlazeColorValueparsing/validation block moved out ofcolor-token.tsso the resolver can reachextractOkhslFromValuewithout closing an import cycle (color-token.tsalready imports the resolver). Pure code motion; no public export changed.Tests
Nine new cases under
describe('from (literal color)'), plus all 353 existing tests unchanged:baseand notonergb(),{r,g,b},oklch())#2f5bff(clears 3:1) is untouched,#ffd400(1.4:1) moves to the floor and no furtherhue/saturationoverrides the carried onesplitHueexport()/themeFrom()extend()build,lint,format:check,typecheckand 362 tests all pass locally.Not included
Two related ideas were considered and deliberately left out — the per-scheme scoping above makes the first unnecessary, and the second already has a workaround:
fixedandstaticonUnreachableon contrast floors, for when an unmeetable target pins to an extreme and inverts an intended ordering (autoFlipcovers much of this)🤖 Generated with Claude Code