Skip to content

Commit 46e8b4d

Browse files
tenphiclaude
andcommitted
refactor(palette): render the brand through Glaze's from
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>
1 parent dff3f48 commit 46e8b4d

8 files changed

Lines changed: 176 additions & 140 deletions

File tree

.changeset/palette-color-seeds.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ setPaletteConfig({ accentColor: '#2F5BFF', baseColor: '#7A7269' });
1010

1111
The two are deliberately asymmetric. `accentColor` contributes hue, saturation and **tone**, and the tone is the point: the brand fill was previously 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. `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.
1212

13-
The requested color is rendered exactly wherever a 3:1 floor against the page allows, and moved only as far as that floor requires. The floor is solved per scheme, so a light brand renders byte-exact on a dark page and darkens on a light one. High contrast keeps its AAA floor regardless — that tier is selected by `prefers-contrast: more` or `data-contrast="high"`, so anyone reading it has asked for separation over brand. Status themes deliberately do **not** inherit the tone: a light brand would otherwise turn `#danger-accent-surface` into a pale pink. `special` does follow it, since it is the brand-on-dark CTA.
13+
The color is handed to Glaze's `from`, so the **light, normal-contrast** variant reproduces it exactly — the fill, the link and the icon all render the value you passed. Dark and high contrast adapt as every other color does. A 3:1 floor against the page applies everywhere and is a floor rather than a target: `#7A4DBF` measures 5.8:1 and is emitted untouched, while `#FFD400` cannot clear 3:1 on white and darkens to `#b09200` and no further. High contrast tightens back to AAA regardless, since that tier is selected by `prefers-contrast: more` or `data-contrast="high"` and anyone reading it has asked for separation over brand.
14+
15+
Because the accent family now carries its own chroma, a brand color no longer raises the palette-level `saturation` to reach it — so it cannot leak into the neutral chrome or the status themes. `#danger-accent-surface` is now identical whatever the brand is. Status themes also do not inherit the literal itself (`extend()` copies defs, so an inherited `from` would make a danger button the brand color outright); `special` does follow it, being the brand-on-dark CTA.
1416

1517
`ResolvedPaletteConfig` gains `accentTone`, and `colorSeed()` is exported for reading hue / saturation / tone off a color directly. The shipped palette is unchanged — a config with no color seed resolves bit for bit as before.
1618

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@
9090
"@react-types/shared": "^3.32.1",
9191
"@tabler/icons-react": "^3.31.0",
9292
"@tanstack/react-virtual": "^3.13.12",
93-
"@tenphi/glaze": "1.3.0",
93+
"@tenphi/glaze": "0.0.0-snapshot.84edd5e",
9494
"@tenphi/tasty": "^3.0.0",
9595
"clipboard-copy": "^4.0.1",
9696
"clsx": "^1.1.1",

pnpm-lock.yaml

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/stories/Theming.docs.mdx

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -126,16 +126,20 @@ setPaletteConfig({ accentColor: '#2F5BFF', hue: 300 }); // hue 300, tone still #
126126
The palette renders the color you asked for, and moves it only as far as it has to. Two
127127
things can make it move.
128128

129+
First, **exactness is scoped to the light, normal-contrast variant.** That is the one
130+
that reproduces your color. Dark and high contrast adapt, as every other color in the
131+
palette does — they are different pages, and a brand pinned across all four would be
132+
less faithful, not more. What holds everywhere is the floor below.
133+
129134
**The fill has a 3:1 floor against the page.** That is WCAG's non-text floor: enough
130135
that a button reads as a shape on the surface. It is a floor and not a target — a brand
131-
already at 5:1, or 17:1, is emitted exactly as given. It is also solved **per scheme**,
132-
so which scheme is exact depends on the color: `#FFD400` cannot clear 3:1 against a
133-
white page and darkens in light, while on a dark page it clears it easily and renders
134-
byte-exact. A near-black brand is the mirror image.
135-
136-
High contrast is the exception, deliberately. There the floor goes back to AAA whatever
137-
you passed, because that tier is selected by `prefers-contrast: more` or an explicit
138-
`data-contrast="high"` — anyone reading it has asked for separation over brand.
136+
already at 5:1, or 17:1, is emitted exactly as given, and one that misses moves only as
137+
far as the floor. `#FFD400` cannot clear 3:1 on a white page, so it darkens to
138+
`#b09200`; `#7A4DBF` measures 5.8:1 and is left alone.
139+
140+
High contrast tightens it further, deliberately: there the floor goes back to AAA
141+
whatever you passed, because that tier is selected by `prefers-contrast: more` or an
142+
explicit `data-contrast="high"` — anyone reading it has asked for separation over brand.
139143

140144
**`pastel` caps chroma.** The flat, hue-independent ceiling is what makes pastel even
141145
across hues, and it sits below where a saturated color would land, so under it a color

src/stories/Theming.stories.tsx

Lines changed: 13 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -479,13 +479,6 @@ function BaseSourceControls() {
479479

480480
function SaturationControls() {
481481
const [palette, setPalette] = usePaletteConfig();
482-
const input = getPaletteConfigInput();
483-
const mode = useAccentMode();
484-
485-
// With a color seed and pastel off, the color's own chroma IS the saturation, so the
486-
// slider is reporting a derived value rather than one it owns.
487-
const fromColor =
488-
mode === 'color' && !palette.pastel && input.saturation === undefined;
489482

490483
return (
491484
<Section>
@@ -497,19 +490,13 @@ function SaturationControls() {
497490
Pastel
498491
</Switch>
499492
<Slider
500-
// Rounded like the hue labels, and for a new reason as well: a saturation read
501-
// off a color is a float, so the raw value would print as 99.9998610927005.
502-
label={`Saturation — ${Math.round(palette.saturation)}${
503-
palette.pastel
504-
? ' (pinned by pastel)'
505-
: fromColor
506-
? ' (from the color)'
507-
: ''
493+
label={`Saturation — ${palette.saturation}${
494+
palette.pastel ? ' (pinned by pastel)' : ''
508495
}`}
509496
minValue={0}
510497
maxValue={100}
511-
isDisabled={palette.pastel || fromColor}
512-
value={Math.round(palette.saturation)}
498+
isDisabled={palette.pastel}
499+
value={palette.saturation}
513500
onChange={(saturation) =>
514501
setPalette((config) => ({ ...config, saturation }))
515502
}
@@ -520,7 +507,13 @@ function SaturationControls() {
520507
ceiling is what makes it even across hues, and a second scale on top
521508
of it would only undo that. Turn pastel off for a free 0–100 scale.
522509
</Note>
523-
) : null}
510+
) : (
511+
<Note>
512+
Independent of the accent color: the brand family carries its own
513+
chroma, so a color seed no longer moves this — and cannot wash the
514+
neutral chrome or the status themes along with it.
515+
</Note>
516+
)}
524517
</Section>
525518
);
526519
}
@@ -570,12 +563,10 @@ function StatusControls() {
570563
onChange={(hue) => setPalette(statusSeed(name, { hue }))}
571564
/>
572565
<Slider
573-
// Rounded because this inherits the palette seed until it is pinned, and
574-
// a palette seed read off an accent color is a float.
575-
label={`${name} saturation — ${Math.round(seed.saturation)}`}
566+
label={`${name} saturation — ${seed.saturation}`}
576567
minValue={0}
577568
maxValue={100}
578-
value={Math.round(seed.saturation)}
569+
value={seed.saturation}
579570
onChange={(saturation) =>
580571
setPalette(statusSeed(name, { saturation }))
581572
}

src/tokens/palette-config.ts

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,11 @@ export interface PaletteConfig {
9595
* is authored as a fixed tone step off white, so every accent hue lands at roughly
9696
* the same lightness and the color you asked for never actually appears.
9797
*
98-
* The color is rendered exactly wherever a 3:1 floor against `surface` allows, and
99-
* moved only as far as that floor requires. Two things cost exactness:
100-
* {@link PaletteConfig.pastel} caps chroma (so `#FFD400` softens to `#e4d8ad`),
101-
* and a light color on a light page has to darken to stay distinguishable.
98+
* The **light, normal-contrast** variant reproduces the color; dark and high
99+
* contrast adapt, as every other color in the palette does. Two things cost
100+
* exactness even there: {@link PaletteConfig.pastel} caps chroma (so `#FFD400`
101+
* softens), and a color that misses the 3:1 floor against `surface` darkens until
102+
* it clears it — and no further.
102103
*
103104
* {@link PaletteConfig.hue} and {@link PaletteConfig.saturation} still win when
104105
* set — the number is the more specific instruction — and the tone keeps coming
@@ -208,13 +209,21 @@ export interface ResolvedPaletteConfig {
208209
baseHue: number;
209210
saturation: number;
210211
/**
211-
* Tone (0–100, OKHST) the brand fill is pinned to, read off
212-
* {@link PaletteConfig.accentColor}.
212+
* The accent color as given, handed to Glaze's `from` so the brand family renders
213+
* as that literal value rather than as a shade re-derived from the seed.
213214
*
214-
* `null` — the common case — means no accent color was given and the fill keeps its
215-
* white-anchored derivation, which is the only arrangement that reproduces the
215+
* `null` — the common case — means no accent color was supplied and the family keeps
216+
* its white-anchored derivation, which is the only arrangement that reproduces the
216217
* shipped palette bit for bit.
217218
*/
219+
accentColor: string | null;
220+
/**
221+
* The tone of {@link ResolvedPaletteConfig.accentColor}, or `null` alongside it.
222+
*
223+
* `from` carries the tone itself, so this exists for the one thing that needs the
224+
* *number*: the hover brand text sits a fixed tone step past the rest one, and a
225+
* step has to be computed.
226+
*/
218227
accentTone: number | null;
219228
pastel: boolean;
220229
contrastLevel: number | 'auto';
@@ -290,14 +299,19 @@ function resolveConfig(input: PaletteConfig): ResolvedPaletteConfig {
290299
);
291300
}
292301

302+
// Deliberately NOT `?? accent?.saturation`: the accent family carries the color's
303+
// own chroma through Glaze's `from`, so the palette-level seed no longer has to be
304+
// raised to reach it. Leaving it alone is what keeps a saturated brand from
305+
// re-chromatizing the neutral chrome and every status theme as a side effect.
293306
const saturation = pastel
294307
? DEFAULT_SATURATION
295-
: input.saturation ?? accent?.saturation ?? DEFAULT_SATURATION;
308+
: input.saturation ?? DEFAULT_SATURATION;
296309

297310
return {
298311
hue,
299312
baseHue,
300313
saturation,
314+
accentColor: accent ? input.accentColor! : null,
301315
accentTone: accent?.tone ?? null,
302316
pastel,
303317
contrastLevel: input.contrastLevel ?? 'auto',
@@ -387,10 +401,10 @@ function isSameConfig(a: ResolvedPaletteConfig, b: ResolvedPaletteConfig) {
387401
a.hue === b.hue &&
388402
a.baseHue === b.baseHue &&
389403
a.saturation === b.saturation &&
390-
// `hue` and `saturation` already carry whatever an accent color derived, so tone
391-
// is the one reading they cannot discriminate: two colors can agree on both and
392-
// still be a different lightness of the brand.
393-
a.accentTone === b.accentTone &&
404+
// The color itself, not just what it derived: `hue` no longer carries its chroma
405+
// (the accent family gets that through Glaze's `from`), so two different brands
406+
// can agree on every numeric seed and still render differently.
407+
a.accentColor === b.accentColor &&
394408
a.pastel === b.pastel &&
395409
a.contrastLevel === b.contrastLevel &&
396410
a.themes.code.saturation === b.themes.code.saturation &&

src/tokens/palette.test.ts

Lines changed: 70 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,11 @@ function toneOf(value: string): number {
132132
);
133133
}
134134

135+
/** Hue of an emitted `oklch(L C H)` token value. */
136+
function hueOf(value: string): number {
137+
return Number(value.trim().split(/\s+/)[2].replace(')', ''));
138+
}
139+
135140
/** WCAG contrast ratio between two emitted token values. */
136141
function contrastOf(a: string, b: string): number {
137142
const luminance = (value: string) => {
@@ -1045,55 +1050,57 @@ describe('accent color seeds', () => {
10451050
*/
10461051
it('renders the requested color exactly wherever the floor allows', () => {
10471052
for (const accentColor of BRANDS) {
1048-
for (const scheme of ['light', 'dark'] as const) {
1049-
const tokens = renderPaletteTokens({ ...EXACT, accentColor, scheme });
1050-
const fill = String(tokens['#accent-surface']);
1051-
const surface = String(tokens['#surface']);
1052-
const label = `${accentColor} ${scheme}`;
1053-
1054-
const wanted = contrastOf(accentColor, surface);
1055-
const got = contrastOf(fill, surface);
1056-
1057-
if (wanted >= 3) {
1058-
// Nothing to solve, so nothing may move.
1059-
expectSameColor(hexOf(fill), accentColor.toLowerCase(), label);
1060-
expect(got, label).toBeCloseTo(wanted, 2);
1061-
} else {
1062-
// Solved to the floor and stopped there — not to AA, not to the far side.
1063-
expect(got, label).toBeGreaterThanOrEqual(3);
1064-
expect(got, label).toBeLessThan(3.2);
1065-
}
1053+
const tokens = renderPaletteTokens({
1054+
...EXACT,
1055+
accentColor,
1056+
scheme: 'light',
1057+
});
1058+
const fill = String(tokens['#accent-surface']);
1059+
const surface = String(tokens['#surface']);
1060+
1061+
const wanted = contrastOf(accentColor, surface);
1062+
const got = contrastOf(fill, surface);
1063+
1064+
if (wanted >= 3) {
1065+
// Nothing to solve, so nothing may move.
1066+
expectSameColor(hexOf(fill), accentColor.toLowerCase(), accentColor);
1067+
expect(got, accentColor).toBeCloseTo(wanted, 2);
1068+
} else {
1069+
// Solved to the floor and stopped there — not to AA, not to the far side.
1070+
expect(got, accentColor).toBeGreaterThanOrEqual(3);
1071+
expect(got, accentColor).toBeLessThan(3.2);
10661072
}
10671073
}
10681074
});
10691075

1070-
it('flips which scheme is exact according to the brand’s own lightness', () => {
1071-
// The concrete shape of the invariant above, and the evidence that the floor is
1072-
// solved per scheme rather than once: a light brand cannot clear 3:1 on a white
1073-
// page but clears it easily on a dark one, so `#FFD400` is exact in dark and
1074-
// floored in light. A dark brand is the mirror image.
1075-
const light = (c: string) =>
1076-
hexOf(
1077-
String(
1078-
renderPaletteTokens({ ...EXACT, accentColor: c, scheme: 'light' })[
1079-
'#accent-surface'
1080-
],
1081-
),
1082-
);
1083-
const dark = (c: string) =>
1084-
hexOf(
1085-
String(
1086-
renderPaletteTokens({ ...EXACT, accentColor: c, scheme: 'dark' })[
1087-
'#accent-surface'
1088-
],
1089-
),
1090-
);
1076+
it('lets dark adapt rather than pinning the color across schemes', () => {
1077+
// Exactness is scoped to light / normal contrast on purpose. Dark is a
1078+
// different page, and a fill pinned to one lightness across both would be a
1079+
// worse `mode: 'fixed'` rather than a faithful brand — so the dark variant
1080+
// maps through the dark tone window like any other fixed-mode color.
1081+
//
1082+
// What must hold in dark is the floor, not the value.
1083+
for (const accentColor of BRANDS) {
1084+
const tokens = renderPaletteTokens({
1085+
...EXACT,
1086+
accentColor,
1087+
scheme: 'dark',
1088+
});
1089+
const fill = String(tokens['#accent-surface']);
10911090

1092-
expectSameColor(dark('#FFD400'), '#ffd400', '#FFD400 dark');
1093-
expect(light('#FFD400')).not.toBe('#ffd400');
1091+
expect(
1092+
contrastOf(fill, String(tokens['#surface'])),
1093+
accentColor,
1094+
).toBeGreaterThanOrEqual(3);
1095+
}
10941096

1095-
expectSameColor(light('#111827'), '#111827', '#111827 light');
1096-
expect(dark('#111827')).not.toBe('#111827');
1097+
// …and it really is a different value, for a color the window has to move.
1098+
const dark = renderPaletteTokens({
1099+
...EXACT,
1100+
accentColor: '#FFD400',
1101+
scheme: 'dark',
1102+
});
1103+
expect(hexOf(String(dark['#accent-surface']))).not.toBe('#ffd400');
10971104
});
10981105

10991106
it('keeps the fill ramp separated in high contrast', () => {
@@ -1245,32 +1252,39 @@ describe('accent color seeds', () => {
12451252

12461253
it('leaves the status themes on their own fill derivation', () => {
12471254
// A status hue signals a meaning, so it keeps the white-anchored derivation that
1248-
// lands every hue at a comparable weight. Inherited, a light brand would put
1249-
// `#danger-accent-surface` at tone 88 in a red hue — a pale pink danger button.
1255+
// lands every hue at a comparable weight. The stakes are higher now that the
1256+
// brand is a literal: `extend()` copies defs, so an inherited `from` would make
1257+
// `#danger-accent-surface` the brand color outright — a yellow danger button, not
1258+
// merely a washed-out one.
12501259
//
1251-
// Compared at a matched saturation rather than against the shipped default,
1252-
// because `saturation` is one palette-wide scale and a color seed legitimately
1253-
// moves it for every theme. The tone is the part that must not travel.
1254-
const seed = colorSeed('#FFD400')!;
1260+
// Compared against the untouched baseline, which is the strong form of the claim.
1261+
// It holds because the accent family carries its own chroma through `from` and no
1262+
// longer raises the palette-level `saturation` to reach it — so a brand color has
1263+
// nothing left to leak into a status theme.
12551264
const seeded = renderPaletteTokens({
12561265
...EXACT,
12571266
accentColor: '#FFD400',
12581267
scheme: 'light',
12591268
});
1260-
const reference = renderPaletteTokens({
1261-
...EXACT,
1262-
saturation: seed.saturation,
1263-
scheme: 'light',
1264-
});
1269+
const baseline = renderPaletteTokens({ ...EXACT, scheme: 'light' });
12651270

12661271
for (const name of [
12671272
'#danger-accent-surface',
12681273
'#success-accent-surface',
12691274
'#warning-accent-surface',
12701275
'#note-accent-surface',
12711276
]) {
1272-
expect(seeded[name], name).toBe(reference[name]);
1277+
expect(seeded[name], name).toBe(baseline[name]);
12731278
}
1279+
1280+
// The neutral chrome still re-hues, and should: `baseHue` inherits the accent
1281+
// hue, so the greys keep their faint tint of the brand. What it no longer does is
1282+
// change *chroma* — only the hue moved.
1283+
expect(seeded['#border']).not.toBe(baseline['#border']);
1284+
expect(hueOf(String(seeded['#border']))).toBeCloseTo(
1285+
colorSeed('#FFD400')!.hue,
1286+
1,
1287+
);
12741288
});
12751289

12761290
it('carries the brand into the special theme', () => {
@@ -1280,7 +1294,7 @@ describe('accent color seeds', () => {
12801294
const tokens = renderPaletteTokens({
12811295
...EXACT,
12821296
accentColor: '#FFD400',
1283-
scheme: 'dark',
1297+
scheme: 'light',
12841298
});
12851299

12861300
expectSameColor(

0 commit comments

Comments
 (0)