diff --git a/.changeset/from-on-theme-colors.md b/.changeset/from-on-theme-colors.md new file mode 100644 index 0000000..b7b4ecc --- /dev/null +++ b/.changeset/from-on-theme-colors.md @@ -0,0 +1,52 @@ +--- +'@tenphi/glaze': minor +--- + +Add `from` on theme color definitions. A color can now be seeded from a literal +value — the same forms `glaze.color()` accepts — instead of from the theme: + +```ts +theme.colors({ + surface: { tone: 100, saturation: 0.12 }, + brand: { from: '#2f5bff', base: 'surface', contrast: 3 }, +}); +``` + +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` supplies `hue`, `tone`, and — uniquely among theme colors — an +**absolute saturation**. That last part is what makes the feature worth having. +Every other 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 theme, which drags every sibling along. A palette whose accent seed +is shared with its status themes could not honor one brand color without +re-chromatizing `danger`, `success` and the rest as a side effect. A `from` +color carries its own chroma and is unaffected by the seed. + +The **light, normal-contrast** variant reproduces the value exactly (a local +`lightTone: false`, matching the value-shorthand form of `glaze.color()`). Dark +and high contrast adapt as usual — 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 is still a +floor rather than a target: a value that already clears it is emitted untouched. + +Sibling fields override what the value supplied, so +`{ from: '#2f5bff', hue: 300 }` keeps the saturation and tone and rotates the +hue. A `from` color needs neither `base` nor `tone` — it is placed absolutely, +so it stands as a root on its own. + +An unparseable `from` is rejected by `validateColorDefs` with the color's name in +the message, rather than surfacing the parser's own error from inside the +resolver — the string alone does not tell you which of fifty tokens carries it. + +Two smaller consequences. Under `splitHue`, a `from` color now gets its own +`--{name}-hue` custom property in both schemes rather than referencing the +theme's: it authors a hue that is not the theme's, so tracking the theme var +would re-skin it on the next re-seed — the same failure mode fixed for +`darkHue`-only colors in 1.3.1. And the value parsing / validation for +`GlazeColorValue` moved from `color-token.ts` to a new internal `color-value.ts` +so the resolver can reach it without an import cycle; no public export changed. diff --git a/AGENTS.md b/AGENTS.md index 45e055d..e163f4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,9 +34,10 @@ glaze/ | [src/glaze.ts](src/glaze.ts) | `glaze()` factory + attached statics: `palette`, `paletteFrom`, `color`, `colorFrom`, `themeFrom`, `from` (alias), `fromHex`, `fromRgb`, `shadow`, `format`, `configure`, `getConfig`, `resetConfig`, `isThemeExport` / `isColorTokenExport` / `isPaletteExport`. Thin wiring layer over the focused modules below. | | [src/theme.ts](src/theme.ts) | Single-theme factory (`createTheme`). Owns the mutable `ColorMap`, the `resolve()` cache (versioned against `getConfigVersion()`), and the `tokens` / `tasty` / `json` / `css` / `extend` / `export` methods. `export(override?)` deep-clones defs and freezes effective config with `kind`/`version`. | | [src/palette.ts](src/palette.ts) | Multi-theme composition (`createPalette` / `createPaletteFromExport`). Shared per-theme driver `buildPaletteOutput` handles prefix resolution, primary duplication, collision filtering. Authoring: `export(override?)`, `theme` / `themes` / `list` / `primary`. | -| [src/color-token.ts](src/color-token.ts) | Standalone `glaze.color()` tokens. Owns the value-shorthand parser (hex 3/6/8, `rgb()` / `hsl()` / `okhsl()` / `oklch()`, `{ r, g, b }`, `{ h, s, l }`, `{ l, c, h }`), the structured-input validator, the two factory paths, sparse local config + live resolve, and the JSON-safe export / `glaze.colorFrom` rehydrate round-trip. | +| [src/color-token.ts](src/color-token.ts) | Standalone `glaze.color()` tokens. Parses values through `color-value.ts`; owns the structured-input validator, the two factory paths, sparse local config + live resolve, and the JSON-safe export / `glaze.colorFrom` rehydrate round-trip. | +| [src/color-value.ts](src/color-value.ts) | `GlazeColorValue` parsing + validation: hex (3/6/8), the `rgb()` / `hsl()` / `okhsl()` / `okhst()` / `oklch()` functions, and the four value-object shapes, all normalized to OKHSL by `extractOkhslFromValue`. Split out of `color-token.ts` so the resolver can reach it for a theme color's `from` without closing an import cycle. Leaf module — depends only on the color math. | | [src/serialize.ts](src/serialize.ts) | Authoring-export helpers: `GLAZE_EXPORT_VERSION`, `assertExportKind` / `assertExportVersion`, and `isThemeExport` / `isColorTokenExport` / `isPaletteExport` type guards. | -| [src/resolver.ts](src/resolver.ts) | Four-pass solver (light → light-HC → dark → dark-HC), or two-pass (light → dark) under a manual `contrastLevel`, where the HC slots mirror the normal ones, `passTone` / `passNumber` / `resolveContrastSpec` feed interpolated inputs into the ordinary pass, and a probe solve at the nearer endpoint pins which side of its base a contrast-solved color sits on. Stores canonical tone (`t`) in variants; per-scheme branches for regular, shadow, and mix defs; integrates the contrast solver, and the OKHST tone helpers. `resolveChannels` owns per-scheme hue/saturation (incl. the `darkHue` / `darkSaturation` seed + def overrides and the `darkDesaturation` bypass) and feeds both the emitted variant and the contrast solver. Converts to/from OKHSL lightness only at the mix/shadow edges. Pre-seeds externally-resolved bases for `glaze.color({ base })`. | +| [src/resolver.ts](src/resolver.ts) | Four-pass solver (light → light-HC → dark → dark-HC), or two-pass (light → dark) under a manual `contrastLevel`, where the HC slots mirror the normal ones, `passTone` / `passNumber` / `resolveContrastSpec` feed interpolated inputs into the ordinary pass, and a probe solve at the nearer endpoint pins which side of its base a contrast-solved color sits on. Stores canonical tone (`t`) in variants; per-scheme branches for regular, shadow, and mix defs; integrates the contrast solver, and the OKHST tone helpers. `resolveChannels` owns per-scheme hue/saturation (incl. the `darkHue` / `darkSaturation` seed + def overrides, the `darkDesaturation` bypass, and the absolute hue/saturation a `from` color carries in place of the seed factor) and feeds both the emitted variant and the contrast solver. `configForColor` gives a `from` color a local `lightTone: false` so its light variant reproduces the authored value. Converts to/from OKHSL lightness only at the mix/shadow edges. Pre-seeds externally-resolved bases for `glaze.color({ base })`. | | [src/okhst.ts](src/okhst.ts) | The OKHST tone layer. `REF_EPS`, tone↔lightness transfers (`toTone`/`fromTone`, `toneFromY`/`yFromTone`), OKHST↔OKHSL conversions, `variantToOkhsl` (tone→lightness at render), `normalizeToneWindow` (`[lo,hi]` / `{lo,hi,eps}` / `false` → `{lo,hi,eps}`), `mapToneForScheme` (scheme inversion + window remap, HC bypass), `mapSaturationDark` (the `darkDesaturation` reducer the resolver skips when a dark saturation is authored), and `schemeToneRange` for the solver. `activeWindow` owns the HC window bypass and its continuous form under `contrastLevel`. Only tone adapts here — hue/saturation are the resolver's business. | | [src/contrast-solver.ts](src/contrast-solver.ts) | Tone-based binary-search solver for WCAG **and** APCA. Public API: `findToneForContrast` (incl. the `preferInitial` tie-break the manual level uses to keep a color's side stable), `findValueForMixContrast`, `resolveContrastForMode`, `resolveContrastForLevel` (both ends resolved, target interpolated for a manual `contrastLevel`; throws on a metric switch), `contrastMetricOf`, `resolveMinContrast`, `apcaContrast`. Closed-form WCAG seed + tone search. | | [src/shadow.ts](src/shadow.ts) | Shadow + mix def predicates (`isShadowDef`, `isMixDef`), default `ShadowTuning`, tuning merge, the actual `computeShadow` math (hue blend, saturation cap, lightness clamp, `tanh` alpha curve) operating on OKHSL lightness at the edge, and `circularLerp` for hue. | diff --git a/docs/api.md b/docs/api.md index 087dbd6..71c5f87 100644 --- a/docs/api.md +++ b/docs/api.md @@ -559,8 +559,9 @@ type ColorDef = RegularColorDef | ShadowColorDef | MixColorDef; | Field | Type | Description | | ------------ | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `tone` | `HCPair` | Number = absolute (0–100). `'+N'`/`'-N'` = a signed **tone delta** from the base (requires `base`). `'max'`/`'min'` = forced to the scheme's tone extreme (no `base`). Optional HC pair `[normal, hc]`. | -| `saturation` | `number` | Saturation factor applied to the seed saturation (0–1). Default: `1`. | +| `from` | `GlazeColorValue` | Seed this color from a literal value (hex, `rgb()`, `hsl()`, `okhsl()`, `okhst()`, `oklch()`, or a value object). Supplies `hue`, `tone`, and an **absolute** saturation — the one way a theme color escapes the seed ceiling. Light/normal-contrast reproduces it exactly. See [`from`](#from-a-literal-color). | +| `tone` | `HCPair` | Number = absolute (0–100). `'+N'`/`'-N'` = a signed **tone delta** from the base (requires `base`). `'max'`/`'min'` = forced to the scheme's tone extreme (no `base`). Optional HC pair `[normal, hc]`. Defaults to the tone of `from`. | +| `saturation` | `number` | Saturation factor applied to the seed saturation (0–1). Default: `1`. With `from`, setting it overrides the color's absolute saturation and reverts to a factor of the seed. | | `hue` | `number \| RelativeValue` | Number = absolute (0–360). String (`'+N'`/`'-N'`) = relative to the **theme seed hue** (never to a base color). | | `darkHue` | `number \| RelativeValue` | Dark-scheme hue. Number = absolute (0–360). String = relative to the **theme dark seed hue**. Falls back to `hue`. See [Dark seed](#dark-seed-darkhue--darksaturation). | | `darkSaturation` | `number` | Dark-scheme saturation factor (0–1) over the dark seed saturation. Falls back to `saturation`. When set, the global `darkDesaturation` reduction is **not** applied on top. | @@ -573,6 +574,61 @@ type ColorDef = RegularColorDef | ShadowColorDef | MixColorDef; | `role` | `RoleInput` | Semantic role against `base` (`'text'` / `'surface'` / `'border'` or an alias). Fixes APCA contrast polarity. Resolved via: explicit `role` → name inference → opposite of the base's role → `'text'`. See [Roles](#roles). | | `inherit` | `boolean` | Whether this color is inherited by child themes via `extend()`. Default: `true`. Set to `false` to make the color local to the current theme. | +#### `from` (a literal color) + +Most of Glaze answers the question "design me a palette". `from` answers the +other one: **"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. + +```ts +const theme = glaze(280, 80); + +theme.colors({ + surface: { tone: 100, saturation: 0.12 }, + brand: { from: '#2f5bff', base: 'surface', contrast: 3 }, +}); +``` + +It accepts the same values as [`glaze.color()`](#input-forms) and supplies three +things at once: `hue`, `tone`, and — uniquely among theme colors — an +**absolute saturation**. + +That last one is the point. Every other color's `saturation` is a 0–1 factor of +the theme seed, so the seed is a ceiling: without `from`, the only way to place +a color more saturated than its theme is to re-seed the whole theme, dragging +every sibling along. A `from` color carries its own chroma and is unaffected by +the seed: + +```ts +// Identical output at every seed saturation — the color is the color. +for (const seed of [5, 50, 100]) { + glaze(280, seed).colors({ brand: { from: '#2f5bff' } }); // → #2f5bff +} +``` + +**What "exactly" covers.** The **light, normal-contrast** variant reproduces the +value (a local `lightTone: false`, the same default the value-shorthand form of +`glaze.color()` applies). Dark and high contrast adapt as usual. That asymmetry +is deliberate: 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 in every scheme and is still a floor, not a +target: a value that already clears it is emitted untouched, and one that misses +moves only as far as the floor. 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 what the value supplied, so `{ from: '#2f5bff', hue: 300 }` +keeps the saturation and tone and rotates the hue. Note that a `saturation` +written alongside `from` reverts to its usual meaning — a factor of the seed, +not of the color. + +A `from` color needs no `base` and no `tone`: it is placed absolutely, so it +stands as a root on its own. Add `base` + `contrast` when it has to stay legible +against something. + #### Tone values `tone` (0–100) replaces authored OKHSL lightness with a contrast-shaped axis. diff --git a/src/channels.ts b/src/channels.ts index e9ab916..02ff667 100644 --- a/src/channels.ts +++ b/src/channels.ts @@ -116,15 +116,34 @@ function themeHuePlan( // A color needs its own var as soon as *either* scheme authors a hue: the // `var()` reference is shared across schemes, so it can't be the theme var // in one and a per-color var in the other. - if (regDef.hue === undefined && regDef.darkHue === undefined) { + // + // `from` counts as authoring one. It carries a hue that is not the theme's, + // so pointing the color at the theme hue var would re-skin it to whatever the + // theme is seeded with — the same failure a `darkHue`-only color used to have. + if ( + regDef.hue === undefined && + regDef.darkHue === undefined && + regDef.from === undefined + ) { return { hueVar: baseHueVar, inline: false, declarations: [] }; } const authored = scheme === 'dark' ? (regDef.darkHue ?? regDef.hue) : regDef.hue; - // Only the other scheme authored a hue; track the theme var in this one. if (authored === undefined) { + // `from` supplied the hue for this scheme, so pin the resolved literal — + // tracking the theme var would re-skin the color to the theme's hue, which + // is the one thing a literal color must not do. + if (regDef.from !== undefined) { + return { + hueVar: `var(${prop})`, + inline: false, + declarations: [{ prop, value: String(variant.h) }], + }; + } + + // Only the other scheme authored a hue; track the theme var in this one. return { hueVar: `var(${prop})`, inline: false, diff --git a/src/color-token.ts b/src/color-token.ts index 17340c2..56f5c29 100644 --- a/src/color-token.ts +++ b/src/color-token.ts @@ -25,14 +25,9 @@ import { isColorTokenExport, } from './serialize'; import type { ChannelCtx } from './channels'; +import { extractOkhslFromValue } from './color-value'; import { assertAllPastel, assertNativeFormat } from './format-guard'; -import { - hslToSrgb, - oklabToOkhsl, - parseHexAlpha, - srgbToOkhsl, -} from './okhsl-color-math'; -import { okhstToOkhsl, toTone } from './okhst'; +import { toTone } from './okhst'; import { isAbsoluteTone, pairNormal } from './hc-pair'; import { resolveAllColors } from './resolver'; import { @@ -68,9 +63,6 @@ import type { GlazeTokenOptions, HCPair, OkhslColor, - OkhstColor, - OklchColor, - RgbColor, RegularColorDef, ResolvedColor, ToneValue, @@ -112,213 +104,10 @@ function sparseValueFormLocal( }; } -// ============================================================================ -// Color string parsing -// ============================================================================ - -/** - * Matches the CSS color functions Glaze itself emits (`rgb()`, `hsl()`, - * `okhsl()`, `oklch()`) plus their legacy alpha aliases (`rgba()`, `hsla()`). - * - * Only bare numeric components are supported. Named colors (`red`), - * relative-color syntax (`from ...`), and angle units other - * than bare degrees (`deg` is the only suffix tolerated by `parseFloat`) - * are out of scope. - */ -const COLOR_FN_RE = /^(rgba?|hsla?|okhsl|okhst|oklch)\(\s*([^)]*)\s*\)$/i; - -function parseNumberOrPercent(raw: string, percentScale: number): number { - if (raw.endsWith('%')) { - return (parseFloat(raw) / 100) * percentScale; - } - return parseFloat(raw); -} - -/** - * Split the body of a CSS color function into its components and detect - * whether an alpha channel was present. - * - * Handles both modern slash syntax (`R G B / A` or `R, G, B / A`) and - * legacy comma syntax (`R, G, B, A`). The alpha value itself is discarded - * by the caller — standalone Glaze colors have no opacity field. - */ -function splitColorBody(body: string): { - components: string[]; - hadAlpha: boolean; -} { - const slashIdx = body.indexOf('/'); - if (slashIdx !== -1) { - const components = body - .slice(0, slashIdx) - .trim() - .split(/[\s,]+/) - .filter(Boolean); - const hadAlpha = body.slice(slashIdx + 1).trim().length > 0; - return { components, hadAlpha }; - } - - const components = body.split(/[\s,]+/).filter(Boolean); - if (components.length === 4) { - components.pop(); - return { components, hadAlpha: true }; - } - return { components, hadAlpha: false }; -} - -function warnDroppedAlpha(input: string): void { - console.warn( - `glaze: alpha component dropped from "${input}" (standalone color has no opacity field).`, - ); -} - -function parseColorString(input: string): OkhslColor { - if (input.startsWith('#')) { - const parsed = parseHexAlpha(input); - if (!parsed) throw new Error(`glaze: invalid hex color "${input}".`); - if (parsed.alpha !== undefined) warnDroppedAlpha(input); - const [h, s, l] = srgbToOkhsl(parsed.rgb); - return { h, s, l }; - } - - const m = input.match(COLOR_FN_RE); - if (!m) { - throw new Error(`glaze: unsupported color string "${input}".`); - } - - const fn = m[1].toLowerCase(); - const { components, hadAlpha } = splitColorBody(m[2].trim()); - - if (hadAlpha) warnDroppedAlpha(input); - if (components.length !== 3) { - throw new Error(`glaze: expected 3 components in "${input}".`); - } - - switch (fn) { - case 'rgb': - case 'rgba': { - const r = parseNumberOrPercent(components[0], 255) / 255; - const g = parseNumberOrPercent(components[1], 255) / 255; - const b = parseNumberOrPercent(components[2], 255) / 255; - const [h, s, l] = srgbToOkhsl([r, g, b]); - return { h, s, l }; - } - case 'hsl': - case 'hsla': { - const h = parseFloat(components[0]); - const s = parseNumberOrPercent(components[1], 1); - const l = parseNumberOrPercent(components[2], 1); - const [oh, os, ol] = srgbToOkhsl(hslToSrgb(h, s, l)); - return { h: oh, s: os, l: ol }; - } - case 'okhsl': { - const h = parseFloat(components[0]); - const s = parseNumberOrPercent(components[1], 1); - const l = parseNumberOrPercent(components[2], 1); - return { h, s, l }; - } - case 'okhst': { - const h = parseFloat(components[0]); - const s = parseNumberOrPercent(components[1], 1); - const t = parseNumberOrPercent(components[2], 1); - return okhstToOkhsl({ h, s, t }); - } - case 'oklch': { - const L = parseNumberOrPercent(components[0], 1); - // Per CSS Color 4: chroma percent maps `100% → 0.4`. - const C = parseNumberOrPercent(components[1], 0.4); - const hDeg = parseFloat(components[2]); - const hRad = (hDeg * Math.PI) / 180; - const a = C * Math.cos(hRad); - const b = C * Math.sin(hRad); - const [h, s, l] = oklabToOkhsl([L, a, b]); - return { h, s, l }; - } - } - throw new Error(`glaze: unsupported color function "${fn}".`); -} - // ============================================================================ // Input validation // ============================================================================ -/** - * Validate a user-supplied `OkhslColor`. Catches the common 0-100 vs 0-1 - * confusion (the structured form uses 0-100, OKHSL objects use 0-1). - */ -function validateOkhslColor(value: OkhslColor): void { - const { h, s, l } = value; - if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(l)) { - throw new Error('glaze.color: OkhslColor h/s/l must be finite numbers.'); - } - if (s > 1.5 || l > 1.5) { - throw new Error( - 'glaze.color: OkhslColor s/l must be in 0–1 range. Did you mean the structured form { hue, saturation, tone } (which uses 0–100)?', - ); - } -} - -/** Validate a user-supplied `{ r, g, b }` object in 0–255. */ -function validateRgbColor(value: RgbColor): void { - for (const key of ['r', 'g', 'b'] as const) { - const n = value[key]; - if (!Number.isFinite(n) || n < 0 || n > 255) { - throw new Error( - `glaze.color: RgbColor ${key} must be a finite number in 0–255 (got ${n}).`, - ); - } - } -} - -/** Validate a user-supplied `{ l, c, h }` OKLCh object. */ -function validateOklchColor(value: OklchColor): void { - const { l, c, h } = value; - if (!Number.isFinite(l) || !Number.isFinite(c) || !Number.isFinite(h)) { - throw new Error('glaze.color: OklchColor l/c/h must be finite numbers.'); - } - if (l > 1.5 || c > 1.5) { - throw new Error( - 'glaze.color: OklchColor l/c must be in 0–1 range (matching oklch() strings).', - ); - } -} - -function oklchComponentsToOkhsl( - l: number, - c: number, - hDeg: number, -): OkhslColor { - const hRad = (hDeg * Math.PI) / 180; - const a = c * Math.cos(hRad); - const b = c * Math.sin(hRad); - const [h, s, outL] = oklabToOkhsl([l, a, b]); - return { h, s, l: outL }; -} - -function isRgbColorObject(value: object): value is RgbColor { - return 'r' in value && 'g' in value && 'b' in value; -} - -function isOklchColorObject(value: object): value is OklchColor { - return 'c' in value && 'l' in value && 'h' in value; -} - -function isOkhstColorObject(value: object): value is OkhstColor { - return 't' in value && 'h' in value && 's' in value; -} - -/** Validate a user-supplied `{ h, s, t }` OKHST object (s/t in 0–1). */ -function validateOkhstColor(value: OkhstColor): void { - const { h, s, t } = value; - if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(t)) { - throw new Error('glaze.color: OkhstColor h/s/t must be finite numbers.'); - } - if (s > 1.5 || t > 1.5) { - throw new Error( - 'glaze.color: OkhstColor s/t must be in 0–1 range. Did you mean the structured form { hue, saturation, tone } (which uses 0–100)?', - ); - } -} - /** * Validate a user-supplied `opacity` override on `glaze.color()`. * Must be a finite number in `0..=1`. @@ -437,39 +226,6 @@ function validateStandaloneName(name: string): void { } } -/** - * Extract an OKHSL color from any `GlazeColorValue` form. Also used by - * `glaze.shadow()` so all shadow inputs (hex, color functions, OKHSL, - * literal objects) go through one parser. - */ -export function extractOkhslFromValue(value: GlazeColorValue): OkhslColor { - if (typeof value === 'string') return parseColorString(value); - if (Array.isArray(value)) { - throw new Error( - 'glaze.color: RGB tuple [r, g, b] is no longer supported — use { r, g, b } instead.', - ); - } - if (isRgbColorObject(value)) { - validateRgbColor(value); - const [h, s, l] = srgbToOkhsl([ - value.r / 255, - value.g / 255, - value.b / 255, - ]); - return { h, s, l }; - } - if (isOklchColorObject(value)) { - validateOklchColor(value); - return oklchComponentsToOkhsl(value.l, value.c, value.h); - } - if (isOkhstColorObject(value)) { - validateOkhstColor(value); - return okhstToOkhsl(value); - } - validateOkhslColor(value); - return value; -} - // ============================================================================ // Factory: shared helpers // ============================================================================ diff --git a/src/color-value.ts b/src/color-value.ts new file mode 100644 index 0000000..b73ae2d --- /dev/null +++ b/src/color-value.ts @@ -0,0 +1,260 @@ +/** + * Parsing and validation for `GlazeColorValue` — the literal color forms. + * + * Hex, the CSS color functions Glaze emits, and the four value-object shapes all + * land here and come out as OKHSL. Split out of `color-token.ts` so both + * consumers can reach it without an import cycle: standalone `glaze.color()` + * tokens build on it, and the resolver needs it for a theme color's `from`. It + * depends on nothing but the color math, which is what keeps that true. + */ + +import { + hslToSrgb, + oklabToOkhsl, + parseHexAlpha, + srgbToOkhsl, +} from './okhsl-color-math'; +import { okhstToOkhsl } from './okhst'; +import type { + GlazeColorValue, + OkhslColor, + OkhstColor, + OklchColor, + RgbColor, +} from './types'; + +/** + * Matches the CSS color functions Glaze itself emits (`rgb()`, `hsl()`, + * `okhsl()`, `oklch()`) plus their legacy alpha aliases (`rgba()`, `hsla()`). + * + * Only bare numeric components are supported. Named colors (`red`), + * relative-color syntax (`from ...`), and angle units other + * than bare degrees (`deg` is the only suffix tolerated by `parseFloat`) + * are out of scope. + */ +const COLOR_FN_RE = /^(rgba?|hsla?|okhsl|okhst|oklch)\(\s*([^)]*)\s*\)$/i; + +function parseNumberOrPercent(raw: string, percentScale: number): number { + if (raw.endsWith('%')) { + return (parseFloat(raw) / 100) * percentScale; + } + return parseFloat(raw); +} + +/** + * Split the body of a CSS color function into its components and detect + * whether an alpha channel was present. + * + * Handles both modern slash syntax (`R G B / A` or `R, G, B / A`) and + * legacy comma syntax (`R, G, B, A`). The alpha value itself is discarded + * by the caller — standalone Glaze colors have no opacity field. + */ +function splitColorBody(body: string): { + components: string[]; + hadAlpha: boolean; +} { + const slashIdx = body.indexOf('/'); + if (slashIdx !== -1) { + const components = body + .slice(0, slashIdx) + .trim() + .split(/[\s,]+/) + .filter(Boolean); + const hadAlpha = body.slice(slashIdx + 1).trim().length > 0; + return { components, hadAlpha }; + } + + const components = body.split(/[\s,]+/).filter(Boolean); + if (components.length === 4) { + components.pop(); + return { components, hadAlpha: true }; + } + return { components, hadAlpha: false }; +} + +function warnDroppedAlpha(input: string): void { + console.warn( + `glaze: alpha component dropped from "${input}" (standalone color has no opacity field).`, + ); +} + +export function parseColorString(input: string): OkhslColor { + if (input.startsWith('#')) { + const parsed = parseHexAlpha(input); + if (!parsed) throw new Error(`glaze: invalid hex color "${input}".`); + if (parsed.alpha !== undefined) warnDroppedAlpha(input); + const [h, s, l] = srgbToOkhsl(parsed.rgb); + return { h, s, l }; + } + + const m = input.match(COLOR_FN_RE); + if (!m) { + throw new Error(`glaze: unsupported color string "${input}".`); + } + + const fn = m[1].toLowerCase(); + const { components, hadAlpha } = splitColorBody(m[2].trim()); + + if (hadAlpha) warnDroppedAlpha(input); + if (components.length !== 3) { + throw new Error(`glaze: expected 3 components in "${input}".`); + } + + switch (fn) { + case 'rgb': + case 'rgba': { + const r = parseNumberOrPercent(components[0], 255) / 255; + const g = parseNumberOrPercent(components[1], 255) / 255; + const b = parseNumberOrPercent(components[2], 255) / 255; + const [h, s, l] = srgbToOkhsl([r, g, b]); + return { h, s, l }; + } + case 'hsl': + case 'hsla': { + const h = parseFloat(components[0]); + const s = parseNumberOrPercent(components[1], 1); + const l = parseNumberOrPercent(components[2], 1); + const [oh, os, ol] = srgbToOkhsl(hslToSrgb(h, s, l)); + return { h: oh, s: os, l: ol }; + } + case 'okhsl': { + const h = parseFloat(components[0]); + const s = parseNumberOrPercent(components[1], 1); + const l = parseNumberOrPercent(components[2], 1); + return { h, s, l }; + } + case 'okhst': { + const h = parseFloat(components[0]); + const s = parseNumberOrPercent(components[1], 1); + const t = parseNumberOrPercent(components[2], 1); + return okhstToOkhsl({ h, s, t }); + } + case 'oklch': { + const L = parseNumberOrPercent(components[0], 1); + // Per CSS Color 4: chroma percent maps `100% → 0.4`. + const C = parseNumberOrPercent(components[1], 0.4); + const hDeg = parseFloat(components[2]); + const hRad = (hDeg * Math.PI) / 180; + const a = C * Math.cos(hRad); + const b = C * Math.sin(hRad); + const [h, s, l] = oklabToOkhsl([L, a, b]); + return { h, s, l }; + } + } + throw new Error(`glaze: unsupported color function "${fn}".`); +} + +// ============================================================================ +// Input validation +// ============================================================================ + +/** + * Validate a user-supplied `OkhslColor`. Catches the common 0-100 vs 0-1 + * confusion (the structured form uses 0-100, OKHSL objects use 0-1). + */ +export function validateOkhslColor(value: OkhslColor): void { + const { h, s, l } = value; + if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(l)) { + throw new Error('glaze.color: OkhslColor h/s/l must be finite numbers.'); + } + if (s > 1.5 || l > 1.5) { + throw new Error( + 'glaze.color: OkhslColor s/l must be in 0–1 range. Did you mean the structured form { hue, saturation, tone } (which uses 0–100)?', + ); + } +} + +/** Validate a user-supplied `{ r, g, b }` object in 0–255. */ +export function validateRgbColor(value: RgbColor): void { + for (const key of ['r', 'g', 'b'] as const) { + const n = value[key]; + if (!Number.isFinite(n) || n < 0 || n > 255) { + throw new Error( + `glaze.color: RgbColor ${key} must be a finite number in 0–255 (got ${n}).`, + ); + } + } +} + +/** Validate a user-supplied `{ l, c, h }` OKLCh object. */ +export function validateOklchColor(value: OklchColor): void { + const { l, c, h } = value; + if (!Number.isFinite(l) || !Number.isFinite(c) || !Number.isFinite(h)) { + throw new Error('glaze.color: OklchColor l/c/h must be finite numbers.'); + } + if (l > 1.5 || c > 1.5) { + throw new Error( + 'glaze.color: OklchColor l/c must be in 0–1 range (matching oklch() strings).', + ); + } +} + +export function oklchComponentsToOkhsl( + l: number, + c: number, + hDeg: number, +): OkhslColor { + const hRad = (hDeg * Math.PI) / 180; + const a = c * Math.cos(hRad); + const b = c * Math.sin(hRad); + const [h, s, outL] = oklabToOkhsl([l, a, b]); + return { h, s, l: outL }; +} + +export function isRgbColorObject(value: object): value is RgbColor { + return 'r' in value && 'g' in value && 'b' in value; +} + +export function isOklchColorObject(value: object): value is OklchColor { + return 'c' in value && 'l' in value && 'h' in value; +} + +export function isOkhstColorObject(value: object): value is OkhstColor { + return 't' in value && 'h' in value && 's' in value; +} + +/** Validate a user-supplied `{ h, s, t }` OKHST object (s/t in 0–1). */ +export function validateOkhstColor(value: OkhstColor): void { + const { h, s, t } = value; + if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(t)) { + throw new Error('glaze.color: OkhstColor h/s/t must be finite numbers.'); + } + if (s > 1.5 || t > 1.5) { + throw new Error( + 'glaze.color: OkhstColor s/t must be in 0–1 range. Did you mean the structured form { hue, saturation, tone } (which uses 0–100)?', + ); + } +} + +/** + * Extract an OKHSL color from any `GlazeColorValue` form. Also used by + * `glaze.shadow()` so all shadow inputs (hex, color functions, OKHSL, + * literal objects) go through one parser. + */ +export function extractOkhslFromValue(value: GlazeColorValue): OkhslColor { + if (typeof value === 'string') return parseColorString(value); + if (Array.isArray(value)) { + throw new Error( + 'glaze.color: RGB tuple [r, g, b] is no longer supported — use { r, g, b } instead.', + ); + } + if (isRgbColorObject(value)) { + validateRgbColor(value); + const [h, s, l] = srgbToOkhsl([ + value.r / 255, + value.g / 255, + value.b / 255, + ]); + return { h, s, l }; + } + if (isOklchColorObject(value)) { + validateOklchColor(value); + return oklchComponentsToOkhsl(value.l, value.c, value.h); + } + if (isOkhstColorObject(value)) { + validateOkhstColor(value); + return okhstToOkhsl(value); + } + validateOkhslColor(value); + return value; +} diff --git a/src/glaze.test.ts b/src/glaze.test.ts index 7bc35e6..27416b1 100644 --- a/src/glaze.test.ts +++ b/src/glaze.test.ts @@ -2,9 +2,11 @@ import { glaze } from './glaze'; import { contrastRatioFromLuminance, okhslToLinearSrgb, + okhslToSrgb, gamutClampedLuminance, apcaLuminanceFromLinearRgb, parseHex, + srgbToHex, } from './okhsl-color-math'; import { apcaContrast } from './contrast-solver'; import { variantToOkhsl } from './okhst'; @@ -898,6 +900,196 @@ describe('glaze', () => { }); }); + /** + * `from` — a theme color seeded by a literal value. + * + * The contract is narrow on purpose: the **light, normal-contrast** variant is + * the value, and everything else is free to adapt. Dark and high contrast are + * where readability outranks fidelity, so pinning a color across all four + * would be the wrong trade — and would make `from` a worse `mode: 'static'` + * rather than its own thing. + */ + describe('from (literal color)', () => { + /** Hex of a resolved variant, for exact-reproduction assertions. */ + const hexOf = (v: ResolvedColorVariant): string => { + const c = variantToOkhsl(v); + return srgbToHex(okhslToSrgb(c.h, c.s, c.l, v.pastel)); + }; + + it('reproduces the value exactly in light, whatever the theme seed', () => { + // The seed is a ceiling for every other color, since `saturation` is a + // 0–1 factor of it. A `from` color escapes that — which is the whole + // reason it exists, and why a caller no longer has to re-seed a theme to + // be given the color they asked for. + for (const seedSaturation of [5, 50, 100]) { + const theme = glaze(280, seedSaturation); + theme.colors({ + surface: { tone: 100, saturation: 0.12 }, + brand: { from: '#2f5bff', base: 'surface', contrast: 3 }, + }); + + const brand = theme.resolve().get('brand')!; + expect(hexOf(brand.light), `seed ${seedSaturation}`).toBe('#2f5bff'); + } + }); + + it('stands alone as a root, with no base and no tone', () => { + const theme = glaze(280, 80); + theme.colors({ brand: { from: '#ffd400' } }); + + expect(hexOf(theme.resolve().get('brand')!.light)).toBe('#ffd400'); + }); + + it('accepts every value form `glaze.color()` does', () => { + const theme = glaze(280, 80); + theme.colors({ + a: { from: '#0ea5e9' }, + b: { from: 'rgb(14 165 233)' }, + c: { from: { r: 14, g: 165, b: 233 } }, + d: { from: 'oklch(0.6847 0.1479 237.32)' }, + }); + + const resolved = theme.resolve(); + for (const name of ['a', 'b', 'c', 'd']) { + expect(hexOf(resolved.get(name)!.light), name).toBe('#0ea5e9'); + } + }); + + it('lets dark and high contrast adapt', () => { + const theme = glaze(280, 80); + theme.colors({ brand: { from: '#2f5bff' } }); + const brand = theme.resolve().get('brand')!; + + // `mode: 'auto'` still inverts into the dark window — a link or a fill + // pinned to one lightness would be unreadable on the opposite scheme. + expect(hexOf(brand.dark)).not.toBe('#2f5bff'); + expect(brand.dark.t).toBeGreaterThan(brand.light.t); + }); + + it('treats a contrast floor as a floor, not a target', () => { + const theme = glaze(280, 80); + theme.colors({ + surface: { tone: 100, saturation: 0.12 }, + // #2f5bff measures ~5.2:1 on white, so a floor of 3 has nothing to do. + clears: { from: '#2f5bff', base: 'surface', contrast: 3 }, + // #ffd400 measures ~1.4:1 and has to move. + floored: { from: '#ffd400', base: 'surface', contrast: 3 }, + }); + + const resolved = theme.resolve(); + const surface = resolved.get('surface')!; + + expect(hexOf(resolved.get('clears')!.light)).toBe('#2f5bff'); + + const moved = resolved.get('floored')!; + expect(hexOf(moved.light)).not.toBe('#ffd400'); + expect( + variantContrast(moved.light, surface.light), + ).toBeGreaterThanOrEqual(3); + // …and no further than the floor asked for. + expect(variantContrast(moved.light, surface.light)).toBeLessThan(3.2); + }); + + it('yields to an explicit hue or saturation on the same def', () => { + const theme = glaze(280, 80); + theme.colors({ + rotated: { from: '#2f5bff', hue: 30 }, + muted: { from: '#2f5bff', saturation: 0.25 }, + }); + + const resolved = theme.resolve(); + const plain = glaze(280, 80); + plain.colors({ brand: { from: '#2f5bff' } }); + const reference = plain.resolve().get('brand')!; + + expect(resolved.get('rotated')!.light.h).toBe(30); + // The saturation factor is still a factor OF THE SEED, so it reads 0.25 of + // 80 rather than 0.25 of the color's own chroma. + expect(resolved.get('muted')!.light.s).toBeCloseTo(0.2, 4); + expect(reference.light.s).toBeGreaterThan(0.9); + }); + + it('gives the color its own hue var under splitHue', () => { + // A `from` color authors a hue that is not the theme's. Pointing it at the + // theme's `--*-hue` var would re-skin it on the next re-seed — the same + // failure a `darkHue`-only color used to have. (`splitHue` requires + // pastel, hence the theme-level override.) + const theme = glaze(280, 80, { pastel: true }); + theme.colors({ brand: { from: '#2f5bff' } }); + + const { light } = theme.css({ splitHue: true }); + + expect(light).toMatch(/--brand-hue:\s*266\./); + expect(light).not.toMatch(/--brand-hue:\s*var\(/); + }); + + it('names the color when the value cannot be parsed', () => { + // The parser's own error names the string but not the color it came from, + // which in a palette of fifty tokens is the half you actually need. + const theme = glaze(280, 80); + theme.colors({ brand: { from: 'rebeccapurple' } }); + + expect(() => theme.resolve()).toThrow( + /color "brand" has an invalid "from"/, + ); + }); + + it('re-reads a def whose value was mutated in place', () => { + // Defs are stored by reference, so mutate-and-reset is a legitimate way to + // change one. A cache keyed on the def object would survive the theme's own + // invalidation and keep serving the previous color. + const def: { from: string } = { from: '#2f5bff' }; + const theme = glaze(280, 80); + + theme.colors({ brand: def }); + expect(theme.resolve().get('brand')!.light.h).toBeCloseTo(266.16, 1); + + def.from = '#ffd400'; + theme.colors({ brand: def }); + + expect(theme.resolve().get('brand')!.light.h).toBeCloseTo(94.02, 1); + }); + + it('applies the dark haircut whether or not the theme seeds a dark saturation', () => { + // A theme-level `darkSaturation` is only "authored" for a color that reads + // the seed. A `from` color does not, so letting the theme's value suppress + // the `darkDesaturation` haircut would leave the same color MORE saturated + // in dark than it is in a theme that never set one. + const plain = glaze(280, 80); + const seeded = glaze({ hue: 280, saturation: 80, darkSaturation: 30 }); + + plain.colors({ brand: { from: '#2f5bff' } }); + seeded.colors({ brand: { from: '#2f5bff' } }); + + expect(seeded.resolve().get('brand')!.dark.s).toBeCloseTo( + plain.resolve().get('brand')!.dark.s, + 6, + ); + }); + + it('round-trips through export / themeFrom', () => { + const theme = glaze(280, 80); + theme.colors({ + surface: { tone: 100, saturation: 0.12 }, + brand: { from: '#2f5bff', base: 'surface', contrast: 3 }, + }); + + const restored = glaze.themeFrom(theme.export()); + + expect(hexOf(restored.resolve().get('brand')!.light)).toBe('#2f5bff'); + }); + + it('carries into a child theme through extend', () => { + const parent = glaze(280, 80); + parent.colors({ brand: { from: '#2f5bff' } }); + const child = parent.extend({ hue: 30 }); + + // The literal wins over the child's own seed — that is what makes it a + // literal. A child that wants the seed's hue drops the `from`. + expect(hexOf(child.resolve().get('brand')!.light)).toBe('#2f5bff'); + }); + }); + describe('dark hue / saturation', () => { it('theme dark seeds drive the dark and dark-HC variants', () => { const theme = glaze({ diff --git a/src/glaze.ts b/src/glaze.ts index 006ef89..2c3141f 100644 --- a/src/glaze.ts +++ b/src/glaze.ts @@ -23,8 +23,8 @@ import { colorFromExport, createColorToken, createColorTokenFromValue, - extractOkhslFromValue, } from './color-token'; +import { extractOkhslFromValue } from './color-value'; import { formatVariant } from './formatters'; import { computeShadow, resolveShadowTuning } from './shadow'; import { okhslToOkhst } from './okhst'; diff --git a/src/resolver.ts b/src/resolver.ts index b26484f..9d6fa69 100644 --- a/src/resolver.ts +++ b/src/resolver.ts @@ -36,7 +36,6 @@ import { PAIR_SWITCH, clamp, contrastFraction, - isAbsoluteTone, numberAt, pairHC, pairNormal, @@ -66,7 +65,8 @@ import { toTone, variantToOkhsl, } from './okhst'; -import { topoSort, validateColorDefs } from './validation'; +import { extractOkhslFromValue } from './color-value'; +import { hasAbsoluteTone, topoSort, validateColorDefs } from './validation'; import { warnContrastUnmet, warnContrastDrift } from './warnings'; import type { AdaptationMode, @@ -306,6 +306,46 @@ function extremeDarkTone( return clamp(darkBase.t * 100 + (mode === 'auto' ? -shift : shift), 0, 100); } +/** + * Hue, absolute saturation (0–1) and tone (0–100) read off a color def's `from`. + * + * Deliberately not memoized. Keying a cache on the def object looks free — a + * resolve reads this a handful of times per color — but defs are stored by + * reference, so mutating one in place and re-setting it would invalidate the + * theme's own cache while a def-keyed cache quietly served the old color. The + * parse is a few microseconds against a resolve that is already cached; that is + * not a trade worth a staleness bug. + */ +function fromSeed( + def: RegularColorDef, +): { hue: number; saturation: number; tone: number } | undefined { + if (def.from === undefined) return undefined; + + // `toTone` already returns the 0–100 tone; `s` stays the OKHSL 0–1 saturation + // the resolver emits. + const { h, s, l } = extractOkhslFromValue(def.from); + + return { hue: h, saturation: s, tone: toTone(l) }; +} + +/** + * The config this color resolves against. + * + * A `from` color carries a literal value, so its light variant has to survive + * the light tone window intact — hence a local `lightTone: false`, the same + * default the value-shorthand form of `glaze.color()` applies. Only the light + * window is dropped: dark and high contrast keep theirs, because that is where + * the color is expected to adapt. + */ +function configForColor( + def: RegularColorDef, + config: GlazeConfigResolved, +): GlazeConfigResolved { + if (def.from === undefined) return config; + + return { ...config, lightTone: false }; +} + function resolveRootColor( def: RegularColorDef, isHighContrast: boolean, @@ -313,7 +353,9 @@ function resolveRootColor( ): number { // Root tone is absolute or extreme ('max' = 100, 'min' = 0); both flow // through mapToneForScheme (and invert in dark under mode 'auto'). - const parsed = passTone(def.tone!, isHighContrast, config); + if (def.tone === undefined) return clamp(fromSeed(def)!.tone, 0, 100); + + const parsed = passTone(def.tone, isHighContrast, config); return clamp(parsed.value, 0, 100); } @@ -338,21 +380,51 @@ function resolveChannels( const mode = def.mode ?? 'auto'; const satFactor = clamp(def.saturation ?? 1, 0, 1); + // A `from` color carries its own hue and an ABSOLUTE saturation, so it stands + // outside the seed entirely: the theme seed is a ceiling for every other color + // (`saturation` is a 0–1 factor of it), and a literal color has to be able to + // exceed it or the caller would have to re-seed the theme to be honored. + // An explicit `hue` / `saturation` on the same def still wins — those are the + // more specific instruction, and `saturation` keeps its factor-of-seed meaning. + const seed = fromSeed(def); + const fromHue = + seed !== undefined && def.hue === undefined ? seed.hue : undefined; + const fromSaturation = + seed !== undefined && def.saturation === undefined + ? seed.saturation + : undefined; + if (!isDark || mode === 'static') { return { - hue: resolveEffectiveHue(ctx.hue, def.hue), - saturation: clamp((satFactor * ctx.saturation) / 100, 0, 1), + hue: fromHue ?? resolveEffectiveHue(ctx.hue, def.hue), + saturation: + fromSaturation ?? clamp((satFactor * ctx.saturation) / 100, 0, 1), }; } const darkSeedSaturation = ctx.darkSaturation ?? ctx.saturation; const darkFactor = clamp(def.darkSaturation ?? satFactor, 0, 1); - const explicitDark = - def.darkSaturation !== undefined || ctx.darkSaturation !== undefined; - const raw = (darkFactor * darkSeedSaturation) / 100; + // Dark keeps the color's own saturation as its starting point too, but unlike + // light it is still subject to `darkDesaturation` — dark is where the color is + // allowed to move, so the global haircut applies as it does to any other color. + const fromDark = + fromSaturation !== undefined && def.darkSaturation === undefined; + const raw = fromDark + ? fromSaturation + : (darkFactor * darkSeedSaturation) / 100; + // A theme-level `darkSaturation` only counts as "authored" for a color that + // actually reads the seed. A `from` color does not, so letting the theme's + // value suppress the haircut here would leave the same color MORE saturated in + // dark than it is in a theme that never set one. + const explicitDark = fromDark + ? false + : def.darkSaturation !== undefined || ctx.darkSaturation !== undefined; return { - hue: resolveEffectiveHue(ctx.darkHue ?? ctx.hue, def.darkHue ?? def.hue), + hue: + fromHue !== undefined && def.darkHue === undefined + ? fromHue + : resolveEffectiveHue(ctx.darkHue ?? ctx.hue, def.darkHue ?? def.hue), saturation: clamp( explicitDark ? raw : mapSaturationDark(raw, mode, ctx.config), 0, @@ -382,18 +454,23 @@ function resolveDependentColor( const mode = def.mode ?? 'auto'; const flip = def.autoFlip ?? ctx.config.autoFlip; const pastel = effectivePastel; + const config = configForColor(def, ctx.config); const baseVariant = getSchemeVariant(baseResolved, isDark, isHighContrast); const baseTone = baseVariant.t * 100; let preferredTone: number; let isExtreme = false; - const rawTone = def.tone; + // A `from` color with no authored `tone` is placed at the tone it carries — + // not at its base's, which is what an ordinary dependent color would inherit. + const seed = fromSeed(def); + const rawTone: HCPair | undefined = + def.tone ?? (seed !== undefined ? seed.tone : undefined); if (rawTone === undefined) { preferredTone = baseTone; } else { - const parsed = passTone(rawTone, isHighContrast, ctx.config); + const parsed = passTone(rawTone, isHighContrast, config); if (parsed.kind === 'relative') { if (isDark && mode === 'auto') { @@ -415,7 +492,7 @@ function resolveDependentColor( 'auto', true, isHighContrast, - ctx.config, + config, ); } else { const delta = applyToneFlip(parsed.value, baseTone, flip); @@ -432,7 +509,7 @@ function resolveDependentColor( mode, isHighContrast, baseResolved, - ctx.config, + config, ); } else { preferredTone = mapToneForScheme( @@ -440,7 +517,7 @@ function resolveDependentColor( mode, isDark, isHighContrast, - ctx.config, + config, ); } } @@ -451,7 +528,7 @@ function resolveDependentColor( const resolvedContrast = resolveContrastSpec( rawContrast, isHighContrast, - ctx.config, + config, polarity, ); @@ -467,7 +544,7 @@ function resolveDependentColor( // window would undo the shift the extreme asked for. const preferredRange = isExtreme ? ([0, 1] as const) - : schemeToneRange(isDark, mode, isHighContrast, ctx.config); + : schemeToneRange(isDark, mode, isHighContrast, config); let initialDirection: 'lighter' | 'darker' | undefined; if (preferredTone < baseTone) { @@ -563,19 +640,22 @@ function resolveColorForScheme( const regDef = def as RegularColorDef; const mode = regDef.mode ?? 'auto'; - const isRoot = isAbsoluteTone(regDef.tone) && !regDef.base; + // `from` supplies an absolute tone, so a color that carries one is a root even + // with no authored `tone` — same as any other absolutely-placed color. + const isRoot = hasAbsoluteTone(regDef) && !regDef.base; const channels = resolveChannels(regDef, ctx, isDark); const role = resolveRole(name, def, ctx); const polarity = roleToPolarity(role); const pastel = regDef.pastel ?? ctx.config.pastel; + const config = configForColor(regDef, ctx.config); const finalTone = isRoot ? mapToneForScheme( - resolveRootColor(regDef, isHighContrast, ctx.config), + resolveRootColor(regDef, isHighContrast, config), mode, isDark, isHighContrast, - ctx.config, + config, ) : resolveDependentColor( name, diff --git a/src/types.ts b/src/types.ts index 6f5df9f..af94ac5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -151,14 +151,44 @@ export interface OklchColor { } export interface RegularColorDef { + /** + * Seed this color from a literal color instead of from the theme. + * + * Accepts the same values as `glaze.color()` — hex, `rgb()`, `hsl()`, + * `okhsl()`, `okhst()`, `oklch()`, or a value object. It supplies three + * things at once: `hue`, `tone`, and — unlike every other color in a theme — + * an **absolute saturation** rather than a factor of the seed. That last + * part is the point: a color can now be more saturated than its theme, so + * honoring a brand color no longer means re-seeding the whole theme to reach + * it. + * + * The **light, normal-contrast** variant reproduces the value exactly + * (a local `lightTone: false`, matching the value-shorthand form of + * `glaze.color()`). Dark and high-contrast variants adapt as usual — they + * are where readability outranks fidelity, and a color pinned across every + * scheme would defeat both. A `contrast` floor still applies everywhere and + * still only moves the color when the authored value misses it. + * + * Sibling fields override what the value supplied, so + * `{ from: '#2F5BFF', hue: 300 }` keeps its saturation and tone but rotates + * the hue. + */ + from?: GlazeColorValue; /** * Tone value (0–100, contrast-uniform — see `docs/okhst.md`). * - Number: absolute tone. * - String ('+N' / '-N'): relative to base color's tone (requires `base`). * - `'max'` / `'min'`: force to the scheme's tone extreme (no base needed). + * + * Defaults to the tone of `from` when that is set. */ tone?: HCPair; - /** Saturation factor applied to the seed saturation (0–1, default: 1). */ + /** + * Saturation factor applied to the seed saturation (0–1, default: 1). + * + * With `from`, an explicit value here is still a factor of the seed and + * overrides the absolute saturation the color carried. + */ saturation?: number; /** * Hue override for this color. diff --git a/src/validation.ts b/src/validation.ts index 4ff7fa0..93f3522 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -7,6 +7,7 @@ * its base / bg / fg / target dependencies. */ +import { extractOkhslFromValue } from './color-value'; import { contrastMetricOf } from './contrast-solver'; import { isAbsoluteTone, pairHC, pairNormal } from './hc-pair'; import { isMixDef, isShadowDef } from './shadow'; @@ -18,6 +19,17 @@ import type { ResolvedColor, } from './types'; +/** + * Does this color sit at an absolute tone — either authored, or carried by a + * `from` value? That is the test for "root" (placed on its own) as opposed to + * "dependent" (placed relative to a base), and it is what lets a bare + * `{ from: '#2F5BFF' }` stand as a complete color definition. + */ +export function hasAbsoluteTone(def: RegularColorDef): boolean { + if (def.tone === undefined) return def.from !== undefined; + return isAbsoluteTone(def.tone); +} + /** * Reject a `contrast` pair whose two entries measure in different metrics. * @@ -108,6 +120,22 @@ export function validateColorDefs( assertConsistentContrastMetric(name, regDef.contrast); + // Parse `from` here rather than letting the resolver hit it. The parser's own + // error names the offending string but not the color it came from, which in a + // palette of fifty tokens is the half you actually need. + if (regDef.from !== undefined) { + try { + extractOkhslFromValue(regDef.from); + } catch (error) { + throw new Error( + `glaze: color "${name}" has an invalid "from" value. ${ + error instanceof Error ? error.message : String(error) + }`, + { cause: error }, + ); + } + } + if (regDef.contrast !== undefined && !regDef.base) { throw new Error(`glaze: color "${name}" has "contrast" without "base".`); } @@ -138,9 +166,9 @@ export function validateColorDefs( ); } - if (!isAbsoluteTone(regDef.tone) && regDef.base === undefined) { + if (!hasAbsoluteTone(regDef) && regDef.base === undefined) { throw new Error( - `glaze: color "${name}" must have either absolute "tone" (root) or "base" (dependent).`, + `glaze: color "${name}" must have either absolute "tone" (root), "from" (a literal color), or "base" (dependent).`, ); }