Skip to content

feat: seed a theme color from a literal value with from - #89

Merged
tenphi merged 2 commits into
mainfrom
feat/from-on-theme-colors
Aug 13, 2026
Merged

feat: seed a theme color from a literal value with from#89
tenphi merged 2 commits into
mainfrom
feat/from-on-theme-colors

Conversation

@tenphi

@tenphi tenphi commented Aug 13, 2026

Copy link
Copy Markdown
Owner

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 saturation is 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:

// Before — three coordinated steps in two places.
const { h, s, l } = glaze.color('#2F5BFF').resolve().light;
const theme = glaze(h, s * 100);              // ← re-seeds EVERYTHING
theme.colors({
  brand: { base: 'surface', tone: toTone(l), contrast: 3, mode: 'static' },
});

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 / note as a side effect — and the fix was a pile of code restoring those themes, none of which was about the brand color.

// After.
const theme = glaze(280, 80);                 // seed untouched
theme.colors({
  surface: { tone: 100, saturation: 0.12 },
  brand: { from: '#2f5bff', base: 'surface', contrast: 3 },
});

Glaze already had the right vocabulary — glaze.color({ from, ...overrides }) — just not in the theme authoring path, where it was silently ignored.

Semantics

from accepts the same values as glaze.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 of glaze.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 contrast floor 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. A saturation written alongside from reverts to its usual meaning (a factor of the seed). A from color needs neither base nor tone — it is placed absolutely, so it stands as a root on its own.

Implementation

  • types.tsfrom?: GlazeColorValue on RegularColorDef.
  • resolver.tsfromSeed() (memoized on the def via a WeakMap) derives hue / absolute saturation / tone; resolveChannels bypasses the seed multiplication for both the light and dark branches; configForColor() hands the color a local lightTone: false; the dependent path defaults its authored tone to the carried one.
  • validation.tshasAbsoluteTone() so a bare { from } satisfies the root/dependent requirement instead of throwing.
  • channels.ts — a from color gets its own --{name}-hue var 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 for darkHue-only colors in fix: give a darkHue-only color its own hue var in both schemes #87.
  • color-value.ts (new) — the GlazeColorValue parsing/validation block moved out of color-token.ts so the resolver can reach extractOkhslFromValue without closing an import cycle (color-token.ts already 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:

  • reproduces the value exactly in light at seed saturations 5 / 50 / 100 — the assertion that the seed ceiling is gone
  • stands alone as a root with no base and no tone
  • accepts every value form (hex, rgb(), {r,g,b}, oklch())
  • dark adapts rather than pinning
  • a floor is a floor: #2f5bff (clears 3:1) is untouched, #ffd400 (1.4:1) moves to the floor and no further
  • an explicit hue / saturation overrides the carried one
  • its own hue var under splitHue
  • round-trips through export() / themeFrom()
  • carries into a child theme through extend()

build, lint, format:check, typecheck and 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:

  • a tone-preserving adaptation mode between fixed and static
  • onUnreachable on contrast floors, for when an unmeetable target pins to an extreme and inverts an intended ordering (autoFlip covers much of this)

🤖 Generated with Claude Code

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>
@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

📦 Snapshot release

Published 0.0.0-snapshot.94c4efb.

pnpm add @tenphi/glaze@0.0.0-snapshot.94c4efb

tenphi added a commit to cube-js/cube-ui-kit that referenced this pull request Aug 13, 2026
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>
tenphi added a commit to cube-js/cube-ui-kit that referenced this pull request Aug 13, 2026
`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>
@tenphi

tenphi commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Review pass — three fixes

Reviewed this adversarially rather than trusting the green suite. Two verifications and three real bugs.

Verified

  • The color-value.ts extraction is pure code motion. Diffed the moved block against main's color-token.ts line by line: identical modulo the added export keywords, 235 lines each.
  • The public surface is unchanged. Built both branches and compared the emitted .d.mts export lists: 130 exports on each, zero diff. from is purely additive on RegularColorDef.

Fixed (bc01c75)

A def-keyed cache that outlived its own invalidation. I had memoized the parsed from in a WeakMap keyed on the def object. Defs are stored by reference, so mutating one in place and re-setting it invalidates the theme's cache while the memo goes on serving the previous color:

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 darkSaturation suppressed the dark haircut. explicitDark means "a dark saturation was authored", and it gates mapSaturationDark. But a from color never reads the seed, so honoring the theme's value left the same color more saturated in dark (1.0) than in a theme that sets no dark seed at all (0.9) — backwards. Now consistent at 0.9 either way.

An unparseable from did not name the color. It surfaced the parser's own error from inside the resolver — unsupported color string "rebeccapurple" — which tells you the string but not which of fifty tokens carries it. Now rejected in validateColorDefs:

glaze: color "brand" has an invalid "from" value. glaze: unsupported color string "rebeccapurple".

Scope checks that came back clean

from + relative tone with no base still throws the existing error; from + opacity / mode: 'static' / darkHue / darkSaturation on the def all behave; a from color works as a mix target; css() and tokens() emit correctly.

365 tests (three added for the fixes above), and build / lint / format:check / typecheck all pass.

@tenphi
tenphi merged commit 19cdde6 into main Aug 13, 2026
3 checks passed
@github-actions github-actions Bot mentioned this pull request Aug 13, 2026
tenphi added a commit to cube-js/cube-ui-kit that referenced this pull request Aug 13, 2026
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>
tenphi added a commit to cube-js/cube-ui-kit that referenced this pull request Aug 14, 2026
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 added a commit to cube-js/cube-ui-kit that referenced this pull request Aug 14, 2026
`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>
tenphi added a commit to cube-js/cube-ui-kit that referenced this pull request Aug 14, 2026
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>
tenphi added a commit to cube-js/cube-ui-kit that referenced this pull request Aug 14, 2026
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 added a commit to cube-js/cube-ui-kit that referenced this pull request Aug 14, 2026
`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>
tenphi added a commit to cube-js/cube-ui-kit that referenced this pull request Aug 14, 2026
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>
tenphi added a commit to cube-js/cube-ui-kit that referenced this pull request Aug 14, 2026
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 added a commit to cube-js/cube-ui-kit that referenced this pull request Aug 14, 2026
`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>
tenphi added a commit to cube-js/cube-ui-kit that referenced this pull request Aug 14, 2026
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>
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