Theme-builder core in uix.color: one brand seed -> hierarchy role seeds
(primary/secondary/tertiary/neutral/neutralVariant). Ports M3's HCT CorePalette
to OKLCH — secondary = same hue/low chroma, tertiary = hue+60deg, neutral =
near-gray; variants tonal/vibrant/monochrome (structured for more). harmonize()
nudges hues toward the brand (M3 blend.harmonize). The 6 canonical intents are
NOT derived (an error is always red); APCA replaces HCT's tone->contrast. Pure
+ isomorphic — produces values behind the frozen --color-{role}-{slot}
contract, so zero component impact.
Docs: COLOR_ENGINE_RFC.md §6.2 + color README theme-builder section.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
active-uix
parent
71f97cc470
commit
60dd14ec74
@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Scheme derivation — one brand seed → the hierarchy role seeds, ported from
|
||||
* Material 3's HCT `CorePalette` to OKLCH. This is the core of a theme builder:
|
||||
*
|
||||
* seed → deriveScheme(seed, variant) → { primary, secondary, tertiary, … }
|
||||
* → generateScale(eachSeed, template) → 12-step scales → tokens
|
||||
*
|
||||
* Material's rule (HCT): secondary = same hue, low chroma; tertiary = hue + 60°,
|
||||
* moderate chroma; neutral = same hue, near-zero chroma. The STRUCTURE is
|
||||
* model-agnostic, so it ports cleanly; only the absolute chroma numbers are
|
||||
* recalibrated (HCT chroma 0..~120 ≠ OKLCH chroma 0..~0.37). Tone→contrast (HCT's
|
||||
* other trick) is NOT ported — the framework decides contrast with APCA instead.
|
||||
*
|
||||
* The 6 canonical intents (affirm/risk/threat/…) are NOT derived here — they are
|
||||
* book-defined hues (an error is always red). Use `harmonize` to nudge them toward
|
||||
* the brand if a builder wants cohesion.
|
||||
*/
|
||||
|
||||
import type { Oklch } from './types';
|
||||
|
||||
/**
|
||||
* Material-style scheme flavors. More can be added (expressive / neutral /
|
||||
* content) as `{ secondary, tertiary, neutral, neutralVariant }` rule sets.
|
||||
*/
|
||||
export type SchemeVariant = 'tonal' | 'vibrant' | 'monochrome';
|
||||
|
||||
export interface DerivedScheme {
|
||||
/** The brand seed (verbatim, or desaturated under `monochrome`). */
|
||||
readonly primary: Oklch;
|
||||
/** Same hue as primary, desaturated — a quiet sibling. */
|
||||
readonly secondary: Oklch;
|
||||
/** Primary's hue rotated ~+60° — a harmonious accent. */
|
||||
readonly tertiary: Oklch;
|
||||
/** Near-gray with a hint of the brand hue — drives surfaces. */
|
||||
readonly neutral: Oklch;
|
||||
/** Slightly more tinted gray — drives borders / variant surfaces. */
|
||||
readonly neutralVariant: Oklch;
|
||||
}
|
||||
|
||||
const norm360 = (h: number): number => ((h % 360) + 360) % 360;
|
||||
|
||||
/** Per-role rule: hue delta from the seed + an absolute OKLCH chroma target. */
|
||||
interface RoleRule {
|
||||
readonly dh: number;
|
||||
readonly c: number;
|
||||
}
|
||||
interface VariantRules {
|
||||
readonly secondary: RoleRule;
|
||||
readonly tertiary: RoleRule;
|
||||
readonly neutral: RoleRule;
|
||||
readonly neutralVariant: RoleRule;
|
||||
}
|
||||
|
||||
// OKLCH-calibrated ports of M3's CorePalette chroma targets (HCT secondary 16 ·
|
||||
// tertiary +60/24 · neutral 4 · neutral-variant 8). Tertiary's +60° rotation is the
|
||||
// canonical Material tertiary rule.
|
||||
const VARIANTS: Record<SchemeVariant, VariantRules> = {
|
||||
tonal: {
|
||||
secondary: { dh: 0, c: 0.04 },
|
||||
tertiary: { dh: 60, c: 0.09 },
|
||||
neutral: { dh: 0, c: 0.008 },
|
||||
neutralVariant: { dh: 0, c: 0.016 }
|
||||
},
|
||||
vibrant: {
|
||||
// More colorful: secondary picks up a small rotation + chroma; tertiary stays
|
||||
// at +60° but saturated; the neutrals carry a touch more brand tint.
|
||||
secondary: { dh: 20, c: 0.1 },
|
||||
tertiary: { dh: 60, c: 0.16 },
|
||||
neutral: { dh: 0, c: 0.02 },
|
||||
neutralVariant: { dh: 0, c: 0.03 }
|
||||
},
|
||||
monochrome: {
|
||||
// Grayscale ink (Vercel / Linear look): the hierarchy collapses to one neutral
|
||||
// by design — differentiate via tone steps + emphasis, not hue.
|
||||
secondary: { dh: 0, c: 0 },
|
||||
tertiary: { dh: 0, c: 0 },
|
||||
neutral: { dh: 0, c: 0 },
|
||||
neutralVariant: { dh: 0, c: 0 }
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Derive the hierarchy role seeds from one brand seed (Material 3 `CorePalette`
|
||||
* ported to OKLCH). All roles share the seed's lightness (so their solids are
|
||||
* tonally consistent); they vary by hue rotation + chroma per the variant. Primary
|
||||
* is the seed verbatim (brand fidelity), except `monochrome` desaturates it.
|
||||
*
|
||||
* Output seeds are raw OKLCH (may be wide-gamut); `generateScale` / `oklchToHex`
|
||||
* gamut-map them at emit time. Feed each seed to `generateScale` for the 12 steps.
|
||||
*/
|
||||
export function deriveScheme(seed: Oklch, variant: SchemeVariant = 'tonal'): DerivedScheme {
|
||||
const [l, c, h] = seed;
|
||||
const rules = VARIANTS[variant];
|
||||
const role = (rule: RoleRule): Oklch => [l, rule.c, norm360(h + rule.dh)];
|
||||
return {
|
||||
primary: variant === 'monochrome' ? [l, 0, h] : [l, c, h],
|
||||
secondary: role(rules.secondary),
|
||||
tertiary: role(rules.tertiary),
|
||||
neutral: role(rules.neutral),
|
||||
neutralVariant: role(rules.neutralVariant)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Nudge `color`'s hue toward `toward`'s hue by `amount` (0..1) of the angular
|
||||
* distance — Material 3's `blend.harmonize`. Use it to pull the canonical intents
|
||||
* a little toward the brand for cohesion (opt-in; keeps lightness + chroma).
|
||||
*/
|
||||
export function harmonize(color: Oklch, toward: Oklch, amount = 0.15): Oklch {
|
||||
const [l, c, h] = color;
|
||||
let dh = norm360(toward[2]) - norm360(h);
|
||||
if (dh > 180) dh -= 360;
|
||||
if (dh < -180) dh += 360;
|
||||
return [l, c, norm360(h + dh * amount)];
|
||||
}
|
||||
Loading…
Reference in new issue