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