diff --git a/.changeset/glaze-color-value-shorthand.md b/.changeset/glaze-color-value-shorthand.md new file mode 100644 index 0000000..e16d39a --- /dev/null +++ b/.changeset/glaze-color-value-shorthand.md @@ -0,0 +1,159 @@ +--- +'@tenphi/glaze': minor +--- + +Revamp `glaze.color()` with a value-shorthand overload, seed-anchored +contrast solving, a per-call lightness-scaling argument, and a `.css()` +export. `glaze.shadow()` now accepts the same value forms as `glaze.color()`. + +**New defaults for `glaze.color()`** — split by input form so end-user +string values (color picker / theme settings) get a natural light/dark +inversion, while programmatic object / tuple / structured inputs keep +predictable linear behavior: + +- **String value-shorthand** (hex, `rgb()`, `hsl()`, `okhsl()`, + `oklch()`): `mode: 'auto'` with snapshotted scaling + `{ lightLightness: false, darkLightness: [globalConfig.darkLightness[0], 100] }`. + Light preserves the input exactly; dark Möbius-inverts up to `100`, + so `glaze.color('#000')` renders as `#fff` in dark mode and + `glaze.color('#fff')` falls to the dark `lo` floor (default `0.15`). + The dark `lo` is snapshotted from `globalConfig` at color-creation + time, matching how an explicit `scaling.darkLightness: [lo, hi]` + behaves. +- **Object / tuple value-shorthand** (`{ h, s, l }`, `[r, g, b]`) and + **structured form**: `mode: 'fixed'` with light preserved and dark + linearly mapped into `globalConfig.darkLightness` (default `[15, 95]`), + also snapshotted at create time so later `glaze.configure()` calls + don't retroactively change already-created tokens. +- Override per call via the new third positional argument + `GlazeColorScaling`: `{ lightLightness?: false | [lo, hi]; darkLightness?: false | [lo, hi] }`. + `false` disables the remap, a tuple sets a custom window. To opt + string inputs back into the previous fixed-linear default, pass + `{ mode: 'fixed' }` as the second arg or supply an explicit + `scaling`. + +**Behavior change (minor bump):** + +- String value-shorthand callers will see a Möbius-inverted dark + variant by default — `glaze.color('#000').resolve().dark.l` is now + `≈ 1.0`, not `0.15`. To preserve the old fixed-linear behavior pass + `{ mode: 'fixed' }` as the second argument. +- Structured callers without an explicit `mode` will see + `glaze.color({...}).resolve().light.l` match the input lightness + exactly instead of being remapped to `globalConfig.lightLightness`. + To preserve the old behavior pass + `{ lightLightness: globalConfig.lightLightness }` as the second + argument. +- The default lightness windows for object / tuple / structured + inputs are now snapshotted from `globalConfig.darkLightness` at + color-creation time, matching the existing behavior for string + inputs. Tokens created before a `glaze.configure()` call no longer + pick up the new dark window on their next `.resolve()`. To get the + old "live config" behavior, recreate the token after `configure()`. + +**Value shorthand additions:** + +- Accepts hex (`#rgb` / `#rrggbb` / `#rrggbbaa`), the four CSS color + functions Glaze itself emits (`rgb()`, `hsl()`, `okhsl()`, `oklch()`), + `OkhslColor` objects (`{ h, s, l }`), and `[r, g, b]` (0–255) tuples + as the first argument. Every string emitted by `theme.tasty() / .json() / .css()` + round-trips back through `glaze.color()`. +- 8-digit hex and `rgba()` / `hsla()` / slash-alpha alpha components are + parsed and dropped with a `console.warn` (standalone colors have no + opacity field). +- `oklch()` chroma now correctly interprets percent values per CSS Color 4 + (`100% → 0.4`). +- `OkhslColor` and `[r, g, b]` inputs are validated up front with helpful + error messages — passing 0–100-scale `s`/`l` throws with a hint to use + the structured form, and out-of-range RGB tuples throw with the offending + value in the message. + +**Anchor model:** by default, relative `lightness: '+N'` and +`contrast: ` are anchored to the literal seed (the value passed +to `glaze.color()`), so the contrast solver compares against the +unmapped user-provided color across every variant. Pass +`overrides.base` (a `GlazeColorToken`) to anchor against another +color's resolved variant per scheme instead. + +**Color pairing via `base`:** `GlazeColorOverrides.base` lets one +standalone color depend on another. Accepts either a `GlazeColorToken` +or any `GlazeColorValue` (hex / `rgb()` / `OkhslColor` / `[r, g, b]`); +raw values are auto-wrapped via `glaze.color(value)` and inherit the +same string-vs-object defaults. When set: + +- `contrast` is solved per scheme against the base's resolved variant + (light / dark / lightContrast / darkContrast). +- Relative `lightness: '+N'` / `'-N'` is anchored to the base's + lightness per scheme (matches theme behavior for dependent colors). +- Relative `hue: '+N'` still anchors to the seed (the value passed to + `glaze.color()`), not the base. +- `mode` is the per-pair knob — pass `mode: 'fixed'` to disable Möbius + inversion for the dependent color, `mode: 'auto'` to keep it. + +The base token's `.resolve()` is called lazily on first resolve and +the result is captured by reference, matching existing snapshot +semantics. Internally, `resolveAllColors` accepts pre-resolved +external bases and seeds them into the resolution context; +`validateColorDefs` and `topoSort` treat external base names as leaves. + +**`opacity` and `name` on `glaze.color()`:** + +- `GlazeColorOverrides.opacity` (and the same field on + `GlazeColorInput`) sets a fixed alpha 0–1 that surfaces in every + scheme variant. Combining with `contrast` is not recommended (perceived + lightness becomes unpredictable) — `glaze` emits a `console.warn` in + that case. +- `GlazeColorOverrides.name` (and the same field on `GlazeColorInput`) + is a human-readable label that surfaces in error and warning messages + in place of the internal `"value"` sentinel. Empty / whitespace-only + names and reserved internal names (`"value"`, `"seed"`, + `"externalBase"`) are rejected with a clear error. + +**Structured form parity:** the `glaze.color({...})` overload now +accepts `opacity`, `contrast`, `base`, and `name` in addition to the +existing `hue`, `saturation`, `lightness`, `saturationFactor`, and +`mode`. `contrast` without `base` synthesizes a hidden static seed +from the input's normal-mode lightness so the contrast solver always +has an anchor (mirrors value-form behavior). `hue` (finite), +`saturation` / `lightness` (0–100), `saturationFactor` (0–1), and +`opacity` (0–1) are range-checked up front with helpful error +messages — non-finite or out-of-range values fail at creation rather +than producing a NaN-laden token. + +**Contrast warning:** when the contrast solver cannot meet the +requested target (e.g. AAA against a mid-grey base — physically +unreachable), `glaze` emits a single `console.warn` per +`(name, scheme, target)` triple naming the affected color, scheme, and +the actual achieved ratio. The token still resolves to the closest +passing variant. Use the `name` override to make the warning easier to +trace. + +**Persisting standalone colors:** `token.export()` returns a JSON-safe +snapshot containing the original `value` (or structured input), the +overrides, and the captured `scaling`. Token-typed `base` is +recursively serialized; value-typed `base` is preserved as the raw +value. Pass the result to `glaze.colorFrom(data)` to rehydrate a token +that resolves byte-for-byte identically to the original — across +`glaze.configure()` calls and across processes. The captured `scaling` +snapshots both `lightLightness` and `darkLightness` from `globalConfig` +at create time, so later `glaze.configure()` calls don't retroactively +change exported tokens regardless of input form. + +**`.css({ name })` export:** new method on the standalone color token +reaches export parity with `theme.css()`. Existing +`.token() / .tasty() / .json()` continue to work unchanged. + +**`glaze.shadow()` upgrade:** `bg` and `fg` now accept any +`GlazeColorValue` form — hex, `rgb()` / `hsl()` / `okhsl()` / `oklch()` +strings, `OkhslColor` objects, or `[r, g, b]` tuples — sharing the same +parser as `glaze.color()`. + +**Internal:** standalone color tokens now memoize the underlying resolve +across `.resolve() / .token() / .tasty() / .json() / .css()` calls. + +**Public type additions:** `GlazeColorValue`, `GlazeColorOverrides`, +`GlazeColorOverridesExport`, `GlazeColorCssOptions`, +`GlazeColorScaling`, `GlazeColorTokenExport`, `GlazeColorInputExport`. +New `glaze.colorFrom(data)` factory and `token.export()` method on +`GlazeColorToken`. New `hslToSrgb`, `oklabToOkhsl`, and `parseHexAlpha` +math helpers re-exported from the package root. diff --git a/README.md b/README.md index cf29964..6fb85af 100644 --- a/README.md +++ b/README.md @@ -263,15 +263,300 @@ The export contains only the configuration — not resolved color values. Resolv Create a single color token without a full theme: ```ts -const accent = glaze.color({ hue: 280, saturation: 80, lightness: 52, mode: 'fixed' }); +const accent = glaze.color({ hue: 280, saturation: 80, lightness: 52 }); -accent.resolve(); // → ResolvedColor with light/dark/lightContrast/darkContrast -accent.token(); // → { '': 'okhsl(...)', '@dark': 'okhsl(...)' } (tasty format) -accent.tasty(); // → { '': 'okhsl(...)', '@dark': 'okhsl(...)' } (same as token) -accent.json(); // → { light: 'okhsl(...)', dark: 'okhsl(...)' } +accent.resolve(); // → ResolvedColor with light/dark/lightContrast/darkContrast +accent.token(); // → { '': 'okhsl(...)', '@dark': 'okhsl(...)' } (tasty format) +accent.tasty(); // → { '': 'okhsl(...)', '@dark': 'okhsl(...)' } (same as token) +accent.json(); // → { light: 'okhsl(...)', dark: 'okhsl(...)' } +accent.css({ name: 'accent' }); +// → { light: '--accent-color: rgb(...);', dark: '--accent-color: rgb(...);', ... } +accent.export(); // → JSON-safe snapshot — pass to `glaze.colorFrom(...)` to rehydrate ``` -Standalone colors are always root colors (no `base`/`contrast`). +### Defaults + +`glaze.color()` is tuned for "render this exact color, but adapt the +dark variant" — different from theme colors, which are seeds that +adapt to both lightness windows. The defaults vary by input form, +because string inputs are typically end-user values (color pickers, +theme settings) where natural light/dark inversion is the expectation: + +- **String value-shorthand** (hex, `rgb()`, `hsl()`, `okhsl()`, + `oklch()`): + - Light variant preserves the input lightness exactly. + - Dark variant is **Möbius-inverted** into `[globalConfig.darkLightness[0], 100]`, + so `glaze.color('#000')` renders as `#fff` in dark mode and + `glaze.color('#fff')` falls to the dark `lo` floor (default `0.15`). + - Adaptation mode defaults to `'auto'`. + - The dark `lo` is snapshotted from `globalConfig` at color-creation + time, matching how an explicit `scaling.darkLightness: [lo, hi]` + behaves. + +- **Object / tuple value-shorthand** (`{ h, s, l }`, `[r, g, b]`) and + the **structured form** (`{ hue, saturation, lightness, ... }`): + - Light variant preserves the input lightness exactly. + - Dark variant is linearly mapped into `globalConfig.darkLightness` + (default `[15, 95]`), snapshotted at color-creation time so later + `glaze.configure()` calls don't retroactively change exported tokens. + - Adaptation mode defaults to `'fixed'` (linear, no Möbius curve). + +To opt back into the old fixed-linear default for string inputs, pass +either `{ mode: 'fixed' }` as the second arg, or supply an explicit +`scaling` as the third arg (see [Lightness scaling](#lightness-scaling)). + +```ts +// Default: pure black inverts to pure white in dark mode. +glaze.color('#000000').tasty(); +// → { '': 'okhsl(0 0% 0%)', '@dark': 'okhsl(... 100%)' } + +// Opt back into the fixed-linear behavior: +glaze.color('#000000', { mode: 'fixed' }).tasty(); +// → { '': 'okhsl(0 0% 0%)', '@dark': 'okhsl(... 15%)' } +``` + +### Value Shorthand + +The first argument can also be a color value — Glaze extracts the seed +hue/saturation/lightness for you. All forms support the same exports +(`resolve / token / tasty / json / css`): + +```ts +// Hex (3, 6, or 8 digits — alpha dropped with warning) +glaze.color('#26fcb2').tasty(); +glaze.color('#26fcb2ff').tasty(); // alpha dropped + +// CSS color functions Glaze itself emits (`rgb()`, `hsl()`, `okhsl()`, `oklch()`) +// — anything from theme.tasty()/json()/css() round-trips back in. +glaze.color('rgb(38 252 178)').tasty(); +glaze.color('hsl(152 97% 57%)').tasty(); +glaze.color('okhsl(152 95% 74%)').tasty(); +glaze.color('oklch(0.85 0.18 152)').tasty(); + +// OKHSL object — Glaze's native shape (h: 0–360, s/l: 0–1). +// Passing 0–100 values for s/l throws with a hint to use the +// structured form { hue, saturation, lightness }. +glaze.color({ h: 152, s: 0.95, l: 0.74 }).tasty(); + +// RGB tuple, 0–255 (same range as glaze.fromRgb). +glaze.color([38, 252, 178]).tasty(); +``` + +The optional second argument supplies overrides — the WCAG `contrast` +solver, relative `hue` / `lightness`, plus the usual seed knobs: + +```ts +// Brand color seeded from a hex, with saturation/mode overrides +glaze.color('#26fcb2', { saturation: 80, mode: 'fixed' }).tasty(); + +// Brand text guaranteed AAA against the seed itself. +// Relative `lightness: '+48'` is anchored to the literal seed value. +glaze.color('#1a1a2e', { + lightness: '+48', + contrast: 'AAA', +}).tasty(); +``` + +By default, relative `lightness: '+N'` and `contrast: ` are +anchored to the literal seed (the value passed to `glaze.color()`). +Internally Glaze synthesizes a hidden `mode: 'static'` reference of +the seed so the contrast solver compares against the unmapped color +across every variant. Pass `base` (another `glaze.color()` token) to +anchor against another color's resolved variant per scheme instead — +see [Pairing Colors](#pairing-colors). + +All overrides: + +| Option | Notes | +|---|---| +| `hue` | Number (absolute 0–360) or `'+N'`/`'-N'` (relative to seed — never to `base`) | +| `saturation` | Override seed saturation (0–100) | +| `lightness` | Number (absolute 0–100) or `'+N'`/`'-N'`. Without `base`, relative is anchored to the literal seed; with `base`, anchored to `base`'s lightness per scheme. Supports `[normal, hc]` pairs | +| `saturationFactor` | Multiplier on seed (0–1, default 1) | +| `mode` | `'auto'` (default for string inputs) / `'fixed'` (default for object / tuple / structured inputs) / `'static'` — see [Adaptation Modes](#adaptation-modes) | +| `contrast` | WCAG floor. Without `base`, anchored to the literal seed; with `base`, solved per scheme against `base`'s resolved variant. Same shape as `RegularColorDef.contrast`. When the target can't be physically met, `glaze` emits a `console.warn` and returns the closest passing variant | +| `base` | Another `glaze.color()` token **or** a raw `GlazeColorValue` (hex / `rgb()` / `OkhslColor` / `[r, g, b]`). Raw values are auto-wrapped via `glaze.color(value)` so they pick up the same auto-invert defaults as an explicit wrap. When set, `contrast` and relative `lightness` anchor to it per scheme; relative `hue` still anchors to the seed | +| `opacity` | Fixed alpha 0–1 applied to every variant. Surfaces in `rgb(... / A)`, `okhsl(... / A)`, etc. Combining with `contrast` is not recommended (perceived lightness becomes unpredictable) — `glaze` emits a `console.warn` | +| `name` | **Debug label only** — surfaces in error and `console.warn` messages instead of the internal `"value"` sentinel. Does **not** change `.token()` / `.tasty()` / `.json()` / `.css()` output keys (those still use `''`, `light`, etc.). Reserved names (`"value"`, `"seed"`, `"externalBase"`) are rejected | + +Alpha components in `rgb(... / A)` / `hsl(... / A)` / `rgba(...)` / +`hsla(...)` and 8-digit hex (`#rrggbbaa` / `#rgba`) are parsed but the +alpha channel is dropped with a `console.warn`. To set a fixed alpha +on a standalone color, use the `opacity` override (or `opacity` on a +theme color). Named CSS colors (`'red'`, `'blueviolet'`) are not +supported. + +### Lightness Scaling + +The optional third positional argument lets you override the lightness +windows used by `glaze.color()`. Both keys mirror the field names from +`GlazeConfig`: + +```ts +// Preserve raw lightness in dark mode too: +glaze.color('#26fcb2', undefined, { darkLightness: false }).tasty(); + +// Or opt back into a theme-style window: +glaze.color('#26fcb2', undefined, { + lightLightness: [10, 100], + darkLightness: [15, 95], +}).tasty(); + +// Structured form takes scaling as the second positional arg: +glaze + .color({ hue: 152, saturation: 95, lightness: 74 }, { darkLightness: false }) + .tasty(); +``` + +| Key | Default for `glaze.color()` (string input) | Default for `glaze.color()` (object / tuple / structured) | Effect | +|---|---|---|---| +| `lightLightness` | `false` | `false` | `false` = preserve input. Pass `[lo, hi]` to opt into a remap window. | +| `darkLightness` | `[globalConfig.darkLightness[0], 100]` (snapshotted; default `[15, 100]`) | `globalConfig.darkLightness` (snapshotted; default `[15, 95]`) | `false` = preserve input in dark too. Pass `[lo, hi]` to override the window. | + +> Note: `scaling` is all-or-nothing — passing it replaces both fields +> at once. To keep one field's default, restate it explicitly. The +> default windows are snapshotted from `globalConfig` at color-creation +> time, so later `glaze.configure()` calls don't retroactively change +> already-created tokens (and `token.export()` round-trips +> byte-for-byte across `configure()` changes). + +### Pairing Colors + +`glaze.color()` accepts an optional `base` override that ties one +standalone color to another. When you set `base`, the WCAG contrast +solver and relative `lightness` offsets switch their anchor from the +literal seed to the base's resolved variant per scheme — so the same +text color automatically lands at AA against its background in light, +dark, and high-contrast modes. + +```ts +const bg = glaze.color('#1a1a2e'); + +// Text guaranteed AA against `bg` in every scheme. +const text = glaze.color('#ffffff', { base: bg, contrast: 'AA' }); + +// Border 8 lightness units lighter than `bg` in each scheme. +const border = glaze.color('#000000', { + base: bg, + lightness: '+8', + mode: 'fixed', +}); +``` + +`base` also accepts a raw `GlazeColorValue` for one-off pairs without +a separate token binding: + +```ts +// Equivalent to `base: glaze.color('#1a1a2e')` — `glaze` auto-wraps it. +const text = glaze.color('#ffffff', { base: '#1a1a2e', contrast: 'AA' }); +``` + +Behavior with `base`: + +- `contrast` is solved per scheme against `base`'s resolved variant + (light / dark / lightContrast / darkContrast). +- Relative `lightness: '+N'` / `'-N'` is anchored to `base`'s lightness + per scheme (matches theme behavior). +- Relative `hue: '+N'` still anchors to the **seed** (the value passed + to `glaze.color()`), not the base. Absolute hue overrides take + precedence as usual. +- `mode` works as a per-pair knob — pass `mode: 'fixed'` to disable + Möbius inversion for the dependent color, or `mode: 'auto'` to keep + it (defaults follow the same string-vs-object rules as standalone). +- The base token's `.resolve()` is called lazily on the first resolve + of the dependent and the result is captured by reference; later + mutations to the base don't apply (matches existing snapshot + semantics for `scaling.darkLightness`). +- Raw value bases (`base: '#fff'`, `base: { h, s, l }`, `base: [r, g, b]`) + are auto-wrapped via `glaze.color(value)` and inherit the same + string-vs-object defaults. To skip auto-invert on the base, wrap it + yourself: `base: glaze.color(value, undefined, { darkLightness: false })`. +- When the contrast target is physically unreachable (e.g. AAA against + a mid-grey base), `glaze` emits a single `console.warn` per + `(name, scheme, target)` triple and returns the closest passing + variant. Use the `name` override to make the warning more + identifiable in your logs. + +Chains compose: + +```ts +const bg = glaze.color('#000000'); +const surface = glaze.color('#222222', { base: bg, contrast: 'AAA' }); +const text = glaze.color('#ffffff', { base: surface, contrast: 'AA' }); +// Each level meets its contrast budget against its base in every scheme. +``` + +### Naming Standalone Colors + +The `name` override is a **debug label**, not an output key: + +```ts +const cardBg = glaze.color('#1a1a2e', { + name: 'card-bg', // surfaces in `console.warn` / Error messages +}); + +cardBg.token(); // → { '': 'okhsl(...)', '@dark': 'okhsl(...)' } +cardBg.json(); // → { light: 'okhsl(...)', dark: 'okhsl(...)' } +cardBg.css({ name: 'card' }); // CSS variable name comes from `css({ name })`, + // NOT from the override above +``` + +Use it to make warnings traceable when you have many `glaze.color()` +calls in a project — without it, `glaze` falls back to the internal +sentinel `"value"`: + +```ts +// With name: +// > glaze: color "card-bg" cannot meet contrast "AAA" (7.00) in dark scheme... + +// Without name: +// > glaze: color "value" cannot meet contrast "AAA" (7.00) in dark scheme... +``` + +The reserved internal sentinels (`"value"`, `"seed"`, `"externalBase"`) +are rejected with a clear error pointing at the conflict. + +### Persisting Standalone Colors + +`glaze.color()` tokens can be serialized to JSON-safe data and +rehydrated later — useful for color pickers, theme settings UIs, and +URL state. + +```ts +const text = glaze.color('#1a1a1a', { + contrast: 'AA', + opacity: 0.9, + name: 'profile-text', +}); + +const data = text.export(); // JSON-safe snapshot +const json = JSON.stringify(data); // ship to localStorage / API / URL +const restored = glaze.colorFrom(JSON.parse(json)); +// `restored.resolve()` matches `text.resolve()` byte-for-byte. +``` + +The export captures the original `value`, all overrides, and the +effective `scaling` (snapshotted from `globalConfig` at create time so +later `glaze.configure()` calls don't change exported tokens). +Token-typed `base` is recursively serialized, value-typed `base` is +preserved as the raw value. + +Both forms round-trip: + +```ts +// Value form +const a = glaze.color('#26fcb2', { contrast: 'AA' }); +const aBack = glaze.colorFrom(a.export()); + +// Structured form +const b = glaze.color({ + hue: 280, + saturation: 50, + lightness: 50, + opacity: 0.5, +}); +const bBack = glaze.colorFrom(b.export()); +``` ## From Existing Colors @@ -393,7 +678,10 @@ Available tuning parameters: ### Standalone Shadow Computation -Compute a shadow outside of a theme: +Compute a shadow outside of a theme. `bg` and `fg` accept any +`GlazeColorValue`: hex (`#rgb` / `#rrggbb` / `#rrggbbaa`), `rgb()` / +`hsl()` / `okhsl()` / `oklch()` strings, OKHSL objects, or `[r, g, b]` +(0–255) tuples. ```ts const v = glaze.shadow({ @@ -403,6 +691,13 @@ const v = glaze.shadow({ }); // → { h: 280, s: 0.14, l: 0.2, alpha: 0.1 } +// Equivalent with non-hex inputs: +glaze.shadow({ + bg: 'rgb(240 238 245)', + fg: { h: 280, s: 0.06, l: 0.13 }, + intensity: 10, +}); + const css = glaze.format(v, 'oklch'); // → 'oklch(0.15 0.014 280 / 0.1)' ``` @@ -567,7 +862,7 @@ theme.tokens({ format: 'hsl' }); // → 'hsl(270.5 45.2% 95.8%)' theme.tokens({ format: 'oklch' }); // → 'oklch(0.965 0.0123 280)' ``` -The `format` option works on all export methods: `theme.tokens()`, `theme.tasty()`, `theme.json()`, `theme.css()`, `palette.tokens()`, `palette.tasty()`, `palette.json()`, `palette.css()`, and standalone `glaze.color().token()` / `.tasty()` / `.json()`. +The `format` option works on all export methods: `theme.tokens()`, `theme.tasty()`, `theme.json()`, `theme.css()`, `palette.tokens()`, `palette.tasty()`, `palette.json()`, `palette.css()`, and standalone `glaze.color().token()` / `.tasty()` / `.json()` / `.css()`. Colors with `alpha < 1` (shadow colors, or regular colors with `opacity`) include an alpha component: @@ -1127,8 +1422,10 @@ brand.colors({ surface: { lightness: 97 }, text: { base: 'surface', lightness: ' | `glaze.from(data)` | Create a theme from an exported configuration | | `glaze.fromHex(hex)` | Create a theme from a hex color (`#rgb` or `#rrggbb`) | | `glaze.fromRgb(r, g, b)` | Create a theme from RGB values (0–255) | -| `glaze.color(input)` | Create a standalone color token | -| `glaze.shadow(input)` | Compute a standalone shadow color (returns `ResolvedColorVariant`) | +| `glaze.color(input, scaling?)` | Create a standalone color token from `{ hue, saturation, lightness, opacity?, contrast?, base?, name?, ... }`. Optional `scaling` overrides the lightness windows | +| `glaze.color(value, overrides?, scaling?)` | Create a standalone color token from a hex string (3/6/8 digits), an `rgb()` / `hsl()` / `okhsl()` / `oklch()` string, an `{ h, s, l }` OKHSL object, or an `[r, g, b]` (0–255) tuple. Overrides accept absolute or relative `hue` / `lightness`, `saturation`, `mode`, `contrast`, `opacity`, `name`, and `base` (a `GlazeColorToken` or any `GlazeColorValue`; raw values are auto-wrapped). When `base` is set, `contrast` and relative `lightness` are anchored to the base per scheme — see [Pairing Colors](#pairing-colors). String inputs default to `mode: 'auto'` with the dark window extended to upper `100`; object / tuple inputs default to `mode: 'fixed'`. | +| `glaze.colorFrom(data)` | Rehydrate a `glaze.color()` token from a `.export()` snapshot. Inverse of `token.export()` — see [Persisting Standalone Colors](#persisting-standalone-colors) | +| `glaze.shadow(input)` | Compute a standalone shadow color (returns `ResolvedColorVariant`). `bg` / `fg` accept any `GlazeColorValue` form | | `glaze.format(variant, format?)` | Format any `ResolvedColorVariant` as a CSS string | ### Theme Methods diff --git a/src/glaze.test.ts b/src/glaze.test.ts index ca15378..4a7f40e 100644 --- a/src/glaze.test.ts +++ b/src/glaze.test.ts @@ -1,5 +1,21 @@ import { glaze } from './glaze'; -import type { ResolvedColorVariant } from './types'; +import { + contrastRatioFromLuminance, + okhslToLinearSrgb, + parseHex, + relativeLuminanceFromLinearRgb, + srgbToOkhsl, +} from './okhsl-color-math'; +import type { GlazeColorTokenExport, ResolvedColorVariant } from './types'; + +function variantContrast( + a: ResolvedColorVariant, + b: ResolvedColorVariant, +): number { + const yA = relativeLuminanceFromLinearRgb(okhslToLinearSrgb(a.h, a.s, a.l)); + const yB = relativeLuminanceFromLinearRgb(okhslToLinearSrgb(b.h, b.s, b.l)); + return contrastRatioFromLuminance(yA, yB); +} describe('glaze', () => { beforeEach(() => { @@ -1601,8 +1617,40 @@ describe('glaze', () => { const resolved = color.resolve(); expect(resolved.light.h).toBe(280); + // Default scaling preserves light input lightness exactly. + expect(resolved.light.l).toBeCloseTo(0.52, 2); + }); + + it('default scaling adapts dark into globalConfig.darkLightness', () => { + const color = glaze.color({ hue: 280, saturation: 80, lightness: 52 }); + const resolved = color.resolve(); + + // mode 'fixed' + darkLightness [15, 95]: 52 * 0.8 + 15 = 56.6 + expect(resolved.dark.l).toBeCloseTo(0.566, 2); + }); + + it('third arg overrides the dark window', () => { + const color = glaze.color( + { hue: 280, saturation: 80, lightness: 52 }, + { darkLightness: false }, + ); + const resolved = color.resolve(); + + // darkLightness: false → preserve raw lightness in dark too. + expect(resolved.dark.l).toBeCloseTo(0.52, 2); + }); + + it('third arg can opt back into a light window', () => { + const color = glaze.color( + { hue: 280, saturation: 80, lightness: 52 }, + { lightLightness: [10, 100], darkLightness: [15, 95] }, + ); + const resolved = color.resolve(); + // lightLightness [10, 100]: 52 * 0.9 + 10 = 56.8 expect(resolved.light.l).toBeCloseTo(0.568, 2); + // darkLightness [15, 95]: 52 * 0.8 + 15 = 56.6 + expect(resolved.dark.l).toBeCloseTo(0.566, 2); }); it('exports token for a standalone color', () => { @@ -1648,6 +1696,1338 @@ describe('glaze', () => { const hslJson = color.json({ format: 'hsl' }); expect(hslJson.light).toMatch(/^hsl\(/); }); + + describe('value-shorthand (hex)', () => { + it('accepts a 6-digit hex string', () => { + const color = glaze.color('#26fcb2'); + const resolved = color.resolve(); + const [expectedH] = srgbToOkhsl(parseHex('#26fcb2')!); + expect(resolved.light.h).toBeCloseTo(expectedH, 1); + expect(resolved.light.s).toBeGreaterThan(0); + }); + + it('extracts hue/saturation/lightness from the hex', () => { + const rgb = parseHex('#26fcb2')!; + const [h, s, l] = srgbToOkhsl(rgb); + const resolved = glaze.color('#26fcb2').resolve(); + expect(resolved.light.h).toBeCloseTo(h, 1); + expect(resolved.light.s).toBeCloseTo(s, 2); + expect(resolved.light.l).toBeCloseTo(l, 2); + }); + + it('accepts an 8-digit hex string (alpha dropped with warn)', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { + /* silenced */ + }); + try { + const rgb = parseHex('#26fcb2')!; + const [h, s, l] = srgbToOkhsl(rgb); + const resolved = glaze.color('#26fcb2ff').resolve(); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatch(/alpha/); + expect(resolved.light.h).toBeCloseTo(h, 1); + expect(resolved.light.s).toBeCloseTo(s, 2); + expect(resolved.light.l).toBeCloseTo(l, 2); + } finally { + warnSpy.mockRestore(); + } + }); + + it('accepts a 4-digit hex string (alpha dropped with warn)', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { + /* silenced */ + }); + try { + expect(() => glaze.color('#abcf').resolve()).not.toThrow(); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatch(/alpha/); + } finally { + warnSpy.mockRestore(); + } + }); + + it('accepts a 3-digit hex string', () => { + expect(() => glaze.color('#abc').resolve()).not.toThrow(); + }); + + it('throws on invalid hex', () => { + expect(() => glaze.color('#zzz').resolve()).toThrow('invalid hex'); + }); + + it('matches the structured form when seeded with the same numbers', () => { + const rgb = parseHex('#26fcb2')!; + const [h, s, l] = srgbToOkhsl(rgb); + const fromHex = glaze.color('#26fcb2').resolve(); + const fromStructured = glaze + .color({ hue: h, saturation: s * 100, lightness: l * 100 }) + .resolve(); + expect(fromHex.light.h).toBeCloseTo(fromStructured.light.h, 4); + expect(fromHex.light.s).toBeCloseTo(fromStructured.light.s, 4); + expect(fromHex.light.l).toBeCloseTo(fromStructured.light.l, 4); + }); + }); + + describe('value-shorthand (CSS color functions)', () => { + it('parses rgb() with modern space syntax', () => { + const color = glaze.color('rgb(38 252 178)'); + const fromHex = glaze.color('#26fcb2'); + const a = color.resolve().light; + const b = fromHex.resolve().light; + expect(a.h).toBeCloseTo(b.h, 1); + expect(a.s).toBeCloseTo(b.s, 2); + expect(a.l).toBeCloseTo(b.l, 2); + }); + + it('parses rgb() with legacy comma syntax', () => { + const color = glaze.color('rgb(38, 252, 178)'); + const fromHex = glaze.color('#26fcb2'); + expect(color.resolve().light.h).toBeCloseTo( + fromHex.resolve().light.h, + 1, + ); + }); + + it('parses rgb() with percent components', () => { + const color = glaze.color('rgb(100% 0% 0%)'); + const fromHex = glaze.color('#ff0000'); + expect(color.resolve().light.h).toBeCloseTo( + fromHex.resolve().light.h, + 1, + ); + }); + + it('round-trips okhsl(...) emitted by formatOkhsl', () => { + const seed = glaze.color('#26fcb2'); + const json = seed.json({ format: 'okhsl' }); + const reparsed = glaze.color(json.light).resolve().light; + const original = seed.resolve().light; + expect(reparsed.h).toBeCloseTo(original.h, 1); + expect(reparsed.s).toBeCloseTo(original.s, 3); + expect(reparsed.l).toBeCloseTo(original.l, 3); + }); + + it('round-trips hsl(...) emitted by formatHsl', () => { + const seed = glaze.color('#26fcb2'); + const json = seed.json({ format: 'hsl' }); + const reparsed = glaze.color(json.light).resolve().light; + const original = seed.resolve().light; + expect(reparsed.h).toBeCloseTo(original.h, 0); + expect(reparsed.s).toBeCloseTo(original.s, 1); + expect(reparsed.l).toBeCloseTo(original.l, 1); + }); + + it('round-trips oklch(...) emitted by formatOklch', () => { + const seed = glaze.color('#26fcb2'); + const json = seed.json({ format: 'oklch' }); + const reparsed = glaze.color(json.light).resolve().light; + const original = seed.resolve().light; + expect(reparsed.h).toBeCloseTo(original.h, 0); + expect(reparsed.s).toBeCloseTo(original.s, 1); + expect(reparsed.l).toBeCloseTo(original.l, 1); + }); + + it('round-trips rgb(...) emitted by formatRgb', () => { + const seed = glaze.color('#26fcb2'); + const json = seed.json({ format: 'rgb' }); + const reparsed = glaze.color(json.light).resolve().light; + const original = seed.resolve().light; + expect(reparsed.h).toBeCloseTo(original.h, 0); + expect(reparsed.s).toBeCloseTo(original.s, 1); + expect(reparsed.l).toBeCloseTo(original.l, 1); + }); + + it('drops alpha component with a console.warn', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { + /* silenced for assertion */ + }); + try { + glaze.color('rgb(38 252 178 / 0.5)').resolve(); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatch(/alpha/); + } finally { + warnSpy.mockRestore(); + } + }); + + it('parses oklch() with percent chroma per CSS Color 4', () => { + // 100% chroma → 0.4 in OKLCh; equivalent oklch with raw 0.4 chroma + // should produce essentially the same OKHSL. + const fromPercent = glaze.color('oklch(50% 100% 30)').resolve().light; + const fromAbsolute = glaze.color('oklch(0.5 0.4 30)').resolve().light; + expect(fromPercent.h).toBeCloseTo(fromAbsolute.h, 1); + expect(fromPercent.s).toBeCloseTo(fromAbsolute.s, 3); + expect(fromPercent.l).toBeCloseTo(fromAbsolute.l, 3); + }); + + it('throws on unsupported color string', () => { + expect(() => glaze.color('red').resolve()).toThrow( + /unsupported color string/, + ); + expect(() => glaze.color('rebeccapurple').resolve()).toThrow( + /unsupported color string/, + ); + expect(() => glaze.color('lab(50% 40 30)').resolve()).toThrow( + /unsupported color string/, + ); + }); + }); + + describe('value-shorthand (OKHSL object and RGB tuple)', () => { + it('accepts an OkhslColor object identical to structured form', () => { + const fromObject = glaze.color({ h: 152, s: 0.95, l: 0.74 }).resolve(); + const fromStructured = glaze + .color({ + hue: 152, + saturation: 95, + lightness: 74, + }) + .resolve(); + expect(fromObject.light.h).toBeCloseTo(fromStructured.light.h, 1); + expect(fromObject.light.s).toBeCloseTo(fromStructured.light.s, 3); + expect(fromObject.light.l).toBeCloseTo(fromStructured.light.l, 3); + }); + + it('accepts an [r, g, b] tuple in 0–255', () => { + const fromTuple = glaze.color([38, 252, 178]).resolve(); + const fromHex = glaze.color('#26fcb2').resolve(); + expect(fromTuple.light.h).toBeCloseTo(fromHex.light.h, 1); + expect(fromTuple.light.s).toBeCloseTo(fromHex.light.s, 3); + expect(fromTuple.light.l).toBeCloseTo(fromHex.light.l, 3); + }); + + it('throws on OkhslColor with 0–100-scale s/l (common mistake)', () => { + expect(() => + glaze + .color({ h: 152, s: 95, l: 74 } as unknown as { + h: number; + s: number; + l: number; + }) + .resolve(), + ).toThrow(/0–1 range/); + }); + + it('throws on OkhslColor with non-finite components', () => { + expect(() => glaze.color({ h: NaN, s: 0.5, l: 0.5 }).resolve()).toThrow( + /finite numbers/, + ); + }); + + it('throws on out-of-range RGB tuple components', () => { + expect(() => glaze.color([300, -10, 999]).resolve()).toThrow(/0–255/); + expect(() => glaze.color([NaN, 0, 0]).resolve()).toThrow(/0–255/); + }); + }); + + describe('string-input defaults (mode auto + extended dark)', () => { + it('totally-black hex inverts to (near-)totally-white in dark', () => { + const resolved = glaze.color('#000000').resolve(); + // Light preserves the input exactly (lightLightness: false default). + expect(resolved.light.l).toBeCloseTo(0, 3); + // Dark Möbius-inverts to the extended upper bound (= 100). + expect(resolved.dark.l).toBeGreaterThanOrEqual(0.99); + }); + + it('totally-white hex falls to the dark `lo` floor in dark', () => { + const resolved = glaze.color('#ffffff').resolve(); + expect(resolved.light.l).toBeCloseTo(1, 3); + // Dark = darkLo = globalConfig.darkLightness[0] = 15 → 0.15 + expect(resolved.dark.l).toBeCloseTo(0.15, 2); + }); + + it('rgb()/hsl()/okhsl()/oklch() string inputs share the auto-invert default', () => { + const cases = [ + 'rgb(0 0 0)', + 'hsl(0 0% 0%)', + 'okhsl(0 0% 0%)', + 'oklch(0 0 0)', + ]; + for (const value of cases) { + const resolved = glaze.color(value).resolve(); + expect(resolved.light.l).toBeCloseTo(0, 2); + expect(resolved.dark.l).toBeGreaterThanOrEqual(0.99); + } + }); + + it('mid-lightness hex inverts (light < dark for low input, light > dark for high input)', () => { + const dark = glaze.color('#1a1a2e').resolve(); + const light = glaze.color('#f0e0d0').resolve(); + expect(dark.dark.l).toBeGreaterThan(dark.light.l); + expect(light.dark.l).toBeLessThan(light.light.l); + }); + + it('OkhslColor object input keeps the old fixed default (no inversion)', () => { + const resolved = glaze.color({ h: 0, s: 0, l: 0 }).resolve(); + expect(resolved.light.l).toBeCloseTo(0, 3); + // mode 'fixed' + darkLightness [15, 95]: 0 * 0.8 + 15 = 15 → 0.15 + expect(resolved.dark.l).toBeCloseTo(0.15, 2); + }); + + it('RGB tuple input keeps the old fixed default (no inversion)', () => { + const resolved = glaze.color([0, 0, 0]).resolve(); + expect(resolved.light.l).toBeCloseTo(0, 3); + expect(resolved.dark.l).toBeCloseTo(0.15, 2); + }); + + it('mode override on a string input wins over the auto default', () => { + const resolved = glaze.color('#000000', { mode: 'fixed' }).resolve(); + expect(resolved.light.l).toBeCloseTo(0, 3); + // Fixed: 0 * 0.8 + 15 = 15 → 0.15 (no inversion to white) + expect(resolved.dark.l).toBeCloseTo(0.15, 2); + }); + + it('explicit scaling fully replaces the string-input default', () => { + const resolved = glaze + .color('#000000', undefined, { darkLightness: [15, 95] }) + .resolve(); + // mode is still 'auto' (mode default for strings); dark is mapped into + // the user-supplied window: t = 1, dark = 15 + 80*1 = 95 → 0.95 + expect(resolved.dark.l).toBeCloseTo(0.95, 2); + }); + + it('snapshots globalConfig.darkLightness[0] at color() creation time', () => { + const before = glaze.color('#ffffff'); + glaze.configure({ darkLightness: [40, 80] }); + try { + // Token created BEFORE the configure() call still snaps at the + // original lo = 15 (dark.l ≈ 0.15), not the new lo = 40. + expect(before.resolve().dark.l).toBeCloseTo(0.15, 2); + // A token created AFTER the configure() call picks up the new lo. + expect(glaze.color('#ffffff').resolve().dark.l).toBeCloseTo(0.4, 2); + } finally { + glaze.resetConfig(); + } + }); + + it('snapshots globalConfig.darkLightness for object inputs at create time', () => { + const before = glaze.color({ h: 0, s: 0, l: 0 }); + glaze.configure({ darkLightness: [40, 80] }); + try { + // Object input snapshots `globalConfig.darkLightness = [15, 95]`, + // so dark.l = 0 * 0.8 + 15 = 0.15 — unchanged after `configure`. + expect(before.resolve().dark.l).toBeCloseTo(0.15, 2); + // A new token created after configure picks up the new window. + expect( + glaze.color({ h: 0, s: 0, l: 0 }).resolve().dark.l, + ).toBeCloseTo(0.4, 2); + } finally { + glaze.resetConfig(); + } + }); + + it('snapshots globalConfig.darkLightness for structured inputs at create time', () => { + const before = glaze.color({ + hue: 0, + saturation: 0, + lightness: 0, + }); + glaze.configure({ darkLightness: [40, 80] }); + try { + expect(before.resolve().dark.l).toBeCloseTo(0.15, 2); + expect( + glaze.color({ hue: 0, saturation: 0, lightness: 0 }).resolve().dark + .l, + ).toBeCloseTo(0.4, 2); + } finally { + glaze.resetConfig(); + } + }); + }); + + describe('base dependency on another color token', () => { + it('solves AA contrast against the base in every scheme', () => { + const bg = glaze.color('#1a1a2e'); + const text = glaze.color('#ffffff', { base: bg, contrast: 'AA' }); + const bgR = bg.resolve(); + const textR = text.resolve(); + expect(variantContrast(textR.light, bgR.light)).toBeGreaterThanOrEqual( + 4.5, + ); + expect(variantContrast(textR.dark, bgR.dark)).toBeGreaterThanOrEqual( + 4.5, + ); + expect( + variantContrast(textR.lightContrast, bgR.lightContrast), + ).toBeGreaterThanOrEqual(4.5); + expect( + variantContrast(textR.darkContrast, bgR.darkContrast), + ).toBeGreaterThanOrEqual(4.5); + }); + + it('relative lightness anchors to the base per-scheme', () => { + const bg = glaze.color({ h: 0, s: 0, l: 0.4 }); + const text = glaze.color('#000000', { + base: bg, + lightness: '+30', + mode: 'fixed', + }); + const bgR = bg.resolve(); + const textR = text.resolve(); + // Light: bg.light.l = 0.4, text.light.l = bg.light.l + 0.3 = 0.7 + expect(textR.light.l).toBeCloseTo(bgR.light.l + 0.3, 2); + // Dark: bg.dark.l is mapped per globalConfig, text.dark.l should land + // at bg.dark.l + 0.3 (clamped). + expect(textR.dark.l).toBeCloseTo(bgR.dark.l + 0.3, 2); + }); + + it('relative hue still anchors to the seed (not the base)', () => { + const bg = glaze.color({ h: 200, s: 0.5, l: 0.5 }); // base hue: 200 + const text = glaze.color('#26fcb2', { + base: bg, + hue: '+10', + contrast: 'AA', + }); + // Seed hue from #26fcb2 is ~152; relative `+10` should give ~162, + // not 210 (which would be bg.hue + 10). + const [seedH] = srgbToOkhsl(parseHex('#26fcb2')!); + expect(text.resolve().light.h).toBeCloseTo((seedH + 10) % 360, 1); + }); + + it('mode override on the dependent (fixed vs auto) changes dark mapping', () => { + const bg = glaze.color('#1a1a2e'); + const fixed = glaze + .color('#ffffff', { base: bg, contrast: 'AA', mode: 'fixed' }) + .resolve(); + const auto = glaze + .color('#ffffff', { base: bg, contrast: 'AA', mode: 'auto' }) + .resolve(); + // Both must still meet AA in dark, but the mapping differs. + expect(fixed.dark.l).not.toBeCloseTo(auto.dark.l, 2); + }); + + it('base without contrast or relative lightness resolves cleanly', () => { + const bg = glaze.color('#1a1a2e'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { + /* silenced */ + }); + try { + expect(() => + glaze.color('#ffffff', { base: bg }).resolve(), + ).not.toThrow(); + expect(warnSpy).not.toHaveBeenCalled(); + } finally { + warnSpy.mockRestore(); + } + }); + + it('chains: text -> mid -> bg, contrast met at each level', () => { + const bg = glaze.color('#000000'); + const mid = glaze.color('#888888', { base: bg, contrast: 'AA' }); + const top = glaze.color('#ffffff', { base: mid, contrast: 'AA' }); + const bgR = bg.resolve(); + const midR = mid.resolve(); + const topR = top.resolve(); + for (const variant of [ + 'light', + 'dark', + 'lightContrast', + 'darkContrast', + ] as const) { + expect( + variantContrast(midR[variant], bgR[variant]), + ).toBeGreaterThanOrEqual(4.5); + expect( + variantContrast(topR[variant], midR[variant]), + ).toBeGreaterThanOrEqual(4.5); + } + }); + + it('memoizes resolve and does not re-resolve the base on each call', () => { + const bg = glaze.color('#1a1a2e'); + const baseSpy = vi.spyOn(bg, 'resolve'); + const text = glaze.color('#ffffff', { base: bg, contrast: 'AA' }); + const a = text.resolve(); + const b = text.resolve(); + const c = text.resolve(); + expect(a).toBe(b); + expect(b).toBe(c); + // bg.resolve() is invoked at most once during text's first resolve. + expect(baseSpy).toHaveBeenCalledTimes(1); + baseSpy.mockRestore(); + }); + + it('exports (token / tasty / json / css) work with a base reference', () => { + const bg = glaze.color('#1a1a2e'); + const text = glaze.color('#ffffff', { base: bg, contrast: 'AAA' }); + expect(text.token()['']).toMatch(/^okhsl\(/); + expect(text.tasty()['']).toMatch(/^okhsl\(/); + expect(text.json().light).toMatch(/^okhsl\(/); + expect(text.css({ name: 'paired' }).light).toMatch( + /^--paired-color:\s*rgb\(/, + ); + }); + }); + + describe('base accepts a raw GlazeColorValue', () => { + it('hex string base is auto-wrapped into a token', () => { + const text = glaze.color('#000000', { + base: '#ffffff', + contrast: 'AA', + }); + const variants = text.resolve(); + const baseToken = glaze.color('#ffffff'); + const wrappedBase = baseToken.resolve(); + // text adapts against the auto-wrapped white background per scheme. + const cr = variantContrast(variants.light, wrappedBase.light); + expect(cr).toBeGreaterThanOrEqual(4.5); + }); + + it('OkhslColor object base is auto-wrapped into a token', () => { + const text = glaze.color('#000000', { + base: { h: 0, s: 0, l: 1 }, + contrast: 'AA', + }); + const baseToken = glaze.color({ h: 0, s: 0, l: 1 }); + const cr = variantContrast( + text.resolve().light, + baseToken.resolve().light, + ); + expect(cr).toBeGreaterThanOrEqual(4.5); + }); + + it('RGB tuple base is auto-wrapped into a token', () => { + const text = glaze.color('#000000', { + base: [255, 255, 255], + contrast: 'AA', + }); + const baseToken = glaze.color([255, 255, 255]); + const cr = variantContrast( + text.resolve().light, + baseToken.resolve().light, + ); + expect(cr).toBeGreaterThanOrEqual(4.5); + }); + + it('value-base auto-wrap produces same result as explicit wrap', () => { + const explicit = glaze.color('#000000', { + base: glaze.color('#ffffff'), + contrast: 'AA', + }); + const inferred = glaze.color('#000000', { + base: '#ffffff', + contrast: 'AA', + }); + const a = explicit.resolve(); + const b = inferred.resolve(); + for (const scheme of [ + 'light', + 'dark', + 'lightContrast', + 'darkContrast', + ] as const) { + expect(b[scheme].l).toBeCloseTo(a[scheme].l, 6); + expect(b[scheme].s).toBeCloseTo(a[scheme].s, 6); + expect(b[scheme].h).toBeCloseTo(a[scheme].h, 6); + } + }); + }); + + describe('opacity override', () => { + it('opacity propagates to all scheme variants', () => { + const resolved = glaze.color('#26fcb2', { opacity: 0.5 }).resolve(); + expect(resolved.light.alpha).toBeCloseTo(0.5, 6); + expect(resolved.dark.alpha).toBeCloseTo(0.5, 6); + expect(resolved.lightContrast.alpha).toBeCloseTo(0.5, 6); + expect(resolved.darkContrast.alpha).toBeCloseTo(0.5, 6); + }); + + it('opacity surfaces in token / json / css output', () => { + const tok = glaze.color('#26fcb2', { opacity: 0.4 }); + expect(tok.token({ format: 'rgb' })['']).toMatch(/rgb\(.*\/\s*0\.4/); + expect(tok.json().light).toMatch(/^okhsl\(.*\/\s*0\.4/); + }); + + it('rejects out-of-range opacity', () => { + expect(() => glaze.color('#26fcb2', { opacity: -0.1 })).toThrow( + /opacity must be a finite number in 0–1/, + ); + expect(() => glaze.color('#26fcb2', { opacity: 1.5 })).toThrow( + /opacity must be a finite number in 0–1/, + ); + expect(() => glaze.color('#26fcb2', { opacity: 5 })).toThrow( + /opacity must be a finite number in 0–1/, + ); + }); + + it('rejects non-finite opacity', () => { + expect(() => glaze.color('#26fcb2', { opacity: Number.NaN })).toThrow( + /opacity must be a finite number in 0–1/, + ); + expect(() => + glaze.color('#26fcb2', { opacity: Number.POSITIVE_INFINITY }), + ).toThrow(/opacity must be a finite number in 0–1/); + }); + + it('rejects out-of-range opacity on the structured form', () => { + expect(() => + glaze.color({ + hue: 280, + saturation: 50, + lightness: 50, + opacity: 2, + }), + ).toThrow(/opacity must be a finite number in 0–1/); + }); + + it('threads user-supplied name into the opacity+contrast warning', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(vi.fn()); + try { + glaze + .color('#26fcb2', { + name: 'profile-overlay', + opacity: 0.5, + contrast: 'AA', + }) + .resolve(); + const matched = warn.mock.calls + .map((c) => String(c[0])) + .some( + (m) => + m.includes('"profile-overlay"') && + m.includes('"contrast" and "opacity"'), + ); + expect(matched).toBe(true); + } finally { + warn.mockRestore(); + } + }); + }); + + describe('name override', () => { + it('uses name as the def key in error messages', () => { + // `contrast` against a unreachable base surfaces the name in + // the warn message instead of the internal "value" sentinel. + const warn = vi.spyOn(console, 'warn').mockImplementation(vi.fn()); + try { + const bg = glaze.color('#7f7f7f'); + glaze + .color('#808080', { + name: 'profile-text', + base: bg, + contrast: 'AAA', + }) + .resolve(); + const seenNames = warn.mock.calls + .map((c) => String(c[0])) + .filter((m) => m.includes('cannot meet contrast')); + expect(seenNames.length).toBeGreaterThan(0); + for (const message of seenNames) { + expect(message).toContain('"profile-text"'); + expect(message).not.toContain('"value"'); + } + } finally { + warn.mockRestore(); + } + }); + + it('rejects reserved internal names', () => { + expect(() => glaze.color('#000', { name: 'value' })).toThrow( + /reserved/, + ); + expect(() => glaze.color('#000', { name: 'seed' })).toThrow(/reserved/); + expect(() => glaze.color('#000', { name: 'externalBase' })).toThrow( + /reserved/, + ); + }); + + it('reserved-name error lists the full reserved set', () => { + try { + glaze.color('#000', { name: 'value' }); + throw new Error('expected throw'); + } catch (err) { + const message = (err as Error).message; + expect(message).toContain('"value"'); + expect(message).toContain('"seed"'); + expect(message).toContain('"externalBase"'); + expect(message).toContain('Pick a different name'); + } + }); + + it('rejects empty / whitespace-only names', () => { + expect(() => glaze.color('#000', { name: '' })).toThrow( + /name must be a non-empty string/, + ); + expect(() => glaze.color('#000', { name: ' ' })).toThrow( + /name must be a non-empty string/, + ); + expect(() => + glaze.color({ + hue: 0, + saturation: 0, + lightness: 0, + name: '', + }), + ).toThrow(/name must be a non-empty string/); + }); + }); + + describe('structured-input validation', () => { + it('rejects non-finite hue', () => { + expect(() => + glaze.color({ hue: NaN, saturation: 50, lightness: 50 }), + ).toThrow(/structured hue must be a finite number/); + expect(() => + glaze.color({ + hue: Number.POSITIVE_INFINITY, + saturation: 50, + lightness: 50, + }), + ).toThrow(/structured hue must be a finite number/); + }); + + it('rejects out-of-range saturation', () => { + expect(() => + glaze.color({ hue: 0, saturation: -1, lightness: 50 }), + ).toThrow(/structured saturation must be a finite number in 0–100/); + expect(() => + glaze.color({ hue: 0, saturation: 101, lightness: 50 }), + ).toThrow(/structured saturation must be a finite number in 0–100/); + expect(() => + glaze.color({ hue: 0, saturation: NaN, lightness: 50 }), + ).toThrow(/structured saturation must be a finite number in 0–100/); + }); + + it('rejects out-of-range lightness', () => { + expect(() => + glaze.color({ hue: 0, saturation: 50, lightness: -1 }), + ).toThrow(/structured lightness must be a finite number in 0–100/); + expect(() => + glaze.color({ hue: 0, saturation: 50, lightness: 200 }), + ).toThrow(/structured lightness must be a finite number in 0–100/); + }); + + it('rejects out-of-range HC-pair lightness components', () => { + expect(() => + glaze.color({ + hue: 0, + saturation: 50, + lightness: [50, 200], + }), + ).toThrow( + /structured lightness\[hc\] must be a finite number in 0–100/, + ); + expect(() => + glaze.color({ + hue: 0, + saturation: 50, + lightness: [-1, 50], + }), + ).toThrow( + /structured lightness\[normal\] must be a finite number in 0–100/, + ); + }); + + it('rejects out-of-range saturationFactor', () => { + expect(() => + glaze.color({ + hue: 0, + saturation: 50, + lightness: 50, + saturationFactor: 1.5, + }), + ).toThrow(/structured saturationFactor must be a finite number in 0–1/); + }); + + it('accepts valid bounds (inclusive) without throwing', () => { + expect(() => + glaze.color({ hue: 0, saturation: 0, lightness: 0 }), + ).not.toThrow(); + expect(() => + glaze.color({ + hue: 360, + saturation: 100, + lightness: 100, + saturationFactor: 1, + }), + ).not.toThrow(); + }); + }); + + describe('contrast warning', () => { + it('warns when target contrast cannot be met', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(vi.fn()); + try { + // Mid-grey base: max attainable contrast on either branch + // (towards black or towards white) is ~5.2 / ~4.0 — AAA (7) + // is physically unreachable. + glaze + .color('#808080', { + base: glaze.color('#7f7f7f'), + contrast: 'AAA', + name: 'unreachable-fg', + }) + .resolve(); + const matched = warn.mock.calls + .map((c) => String(c[0])) + .some( + (m) => + m.includes('"unreachable-fg"') && + m.includes('cannot meet contrast'), + ); + expect(matched).toBe(true); + } finally { + warn.mockRestore(); + } + }); + + it('does not warn when target contrast is comfortably met', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(vi.fn()); + try { + glaze + .color('#000000', { + base: glaze.color('#ffffff'), + contrast: 'AA', + name: 'easy-fg', + }) + .resolve(); + const matched = warn.mock.calls + .map((c) => String(c[0])) + .filter((m) => m.includes('"easy-fg"')); + expect(matched).toEqual([]); + } finally { + warn.mockRestore(); + } + }); + }); + + describe('structured form base + opacity + contrast + name', () => { + it('structured form supports base + contrast', () => { + const bg = glaze.color('#1a1a2e'); + const text = glaze.color({ + hue: 0, + saturation: 0, + lightness: 100, + base: bg, + contrast: 'AA', + name: 'structured-text', + }); + const bgResolved = bg.resolve(); + for (const scheme of [ + 'light', + 'dark', + 'lightContrast', + 'darkContrast', + ] as const) { + const cr = variantContrast( + text.resolve()[scheme], + bgResolved[scheme], + ); + expect(cr).toBeGreaterThanOrEqual(4.4); + } + }); + + it('structured form respects opacity', () => { + const tok = glaze.color({ + hue: 280, + saturation: 50, + lightness: 50, + opacity: 0.25, + }); + const resolved = tok.resolve(); + expect(resolved.light.alpha).toBeCloseTo(0.25, 6); + expect(resolved.dark.alpha).toBeCloseTo(0.25, 6); + }); + + it('structured form auto-wraps a value-typed base', () => { + const text = glaze.color({ + hue: 0, + saturation: 0, + lightness: 100, + base: '#1a1a2e', + contrast: 'AA', + }); + // No throw + valid output. + expect(text.resolve().light.l).toBeGreaterThan(0); + }); + + it('structured form rejects reserved name', () => { + expect(() => + glaze.color({ + hue: 0, + saturation: 0, + lightness: 50, + name: 'value', + }), + ).toThrow(/reserved/); + }); + }); + + describe('export / colorFrom round-trip', () => { + it('value-form export round-trips identically', () => { + const original = glaze.color('#26fcb2', { contrast: 'AA' }); + const data = original.export(); + const json = JSON.parse(JSON.stringify(data)); + const restored = glaze.colorFrom(json); + const a = original.resolve(); + const b = restored.resolve(); + for (const scheme of [ + 'light', + 'dark', + 'lightContrast', + 'darkContrast', + ] as const) { + expect(b[scheme].l).toBeCloseTo(a[scheme].l, 6); + expect(b[scheme].s).toBeCloseTo(a[scheme].s, 6); + expect(b[scheme].h).toBeCloseTo(a[scheme].h, 6); + expect(b[scheme].alpha).toBeCloseTo(a[scheme].alpha, 6); + } + }); + + it('export captures opacity, name, and scaling', () => { + const tok = glaze.color( + '#26fcb2', + { opacity: 0.5, name: 'cell-bg' }, + { lightLightness: false, darkLightness: [10, 100] }, + ); + const data = tok.export(); + expect(data.form).toBe('value'); + expect(data.input).toBe('#26fcb2'); + expect(data.overrides?.opacity).toBe(0.5); + expect(data.overrides?.name).toBe('cell-bg'); + expect(data.scaling).toEqual({ + lightLightness: false, + darkLightness: [10, 100], + }); + }); + + it('value-form export captures inferred string-input scaling snapshot', () => { + const tok = glaze.color('#26fcb2'); + const data = tok.export(); + // String-input default is captured (snapshot of globalConfig at create time). + expect(data.scaling).toBeDefined(); + expect(data.scaling?.lightLightness).toBe(false); + expect(Array.isArray(data.scaling?.darkLightness)).toBe(true); + }); + + it('value-form export of an OkhslColor input snapshots dark window', () => { + const tok = glaze.color({ h: 280, s: 0.5, l: 0.5 }); + const data = tok.export(); + expect(data.form).toBe('value'); + // Object inputs snapshot `globalConfig.darkLightness` verbatim. + expect(data.scaling).toEqual({ + lightLightness: false, + darkLightness: [15, 95], + }); + }); + + it('structured-form export snapshots dark window', () => { + const tok = glaze.color({ + hue: 280, + saturation: 50, + lightness: 50, + }); + const data = tok.export(); + expect(data.form).toBe('structured'); + expect(data.scaling).toEqual({ + lightLightness: false, + darkLightness: [15, 95], + }); + }); + + it('export snapshots survive `glaze.configure()` after create', () => { + const tok = glaze.color({ h: 0, s: 0, l: 0 }); + glaze.configure({ darkLightness: [40, 80] }); + try { + const data = tok.export(); + // Snapshot still reflects the create-time window, not the new one. + expect(data.scaling?.darkLightness).toEqual([15, 95]); + // And the rehydrated token resolves identically to the original. + const restored = glaze.colorFrom(JSON.parse(JSON.stringify(data))); + expect(restored.resolve().dark.l).toBeCloseTo( + tok.resolve().dark.l, + 6, + ); + } finally { + glaze.resetConfig(); + } + }); + + it('export recursively serializes a token-typed base', () => { + const bg = glaze.color('#1a1a2e', { name: 'card-bg' }); + const text = glaze.color('#ffffff', { + base: bg, + contrast: 'AA', + name: 'card-text', + }); + const data = text.export(); + expect(data.overrides?.base).toBeDefined(); + expect(typeof data.overrides?.base).toBe('object'); + expect((data.overrides!.base as { form: string }).form).toBe('value'); + expect((data.overrides!.base as { input: string }).input).toBe( + '#1a1a2e', + ); + expect( + (data.overrides!.base as { overrides?: { name?: string } }).overrides + ?.name, + ).toBe('card-bg'); + }); + + it('export preserves a value-typed base as a raw value', () => { + const text = glaze.color('#ffffff', { + base: '#1a1a2e', + contrast: 'AA', + }); + const data = text.export(); + expect(data.overrides?.base).toBe('#1a1a2e'); + }); + + it('round-trip with token-typed base produces identical resolved values', () => { + const bg = glaze.color('#1a1a2e'); + const text = glaze.color('#ffffff', { base: bg, contrast: 'AA' }); + const data = JSON.parse(JSON.stringify(text.export())); + const restored = glaze.colorFrom(data); + const a = text.resolve(); + const b = restored.resolve(); + for (const scheme of [ + 'light', + 'dark', + 'lightContrast', + 'darkContrast', + ] as const) { + expect(b[scheme].l).toBeCloseTo(a[scheme].l, 6); + } + }); + + it('structured form export round-trips identically', () => { + const original = glaze.color({ + hue: 280, + saturation: 50, + lightness: 50, + opacity: 0.5, + }); + const data = original.export(); + const restored = glaze.colorFrom(JSON.parse(JSON.stringify(data))); + expect(restored.resolve().light.l).toBeCloseTo( + original.resolve().light.l, + 6, + ); + expect(restored.resolve().light.alpha).toBeCloseTo(0.5, 6); + }); + + it('structured form export with base survives JSON round-trip', () => { + const bg = glaze.color('#1a1a2e'); + const text = glaze.color({ + hue: 0, + saturation: 0, + lightness: 100, + base: bg, + contrast: 'AA', + }); + const data = JSON.parse(JSON.stringify(text.export())); + const restored = glaze.colorFrom(data); + const a = text.resolve(); + const b = restored.resolve(); + expect(b.light.l).toBeCloseTo(a.light.l, 6); + expect(b.dark.l).toBeCloseTo(a.dark.l, 6); + }); + + it('snapshot shape is stable across export → restore → re-export', () => { + const bg = glaze.color('#1a1a2e', { name: 'card-bg' }); + const text = glaze.color('#ffffff', { + base: bg, + contrast: 'AA', + opacity: 0.95, + name: 'card-text', + }); + const original = JSON.stringify(text.export()); + const restored = glaze.colorFrom(JSON.parse(original)); + const reExported = JSON.stringify(restored.export()); + expect(reExported).toBe(original); + }); + + it('snapshot shape is stable for value-only inputs (no overrides)', () => { + const tok = glaze.color('#26fcb2'); + const original = JSON.stringify(tok.export()); + const reExported = JSON.stringify( + glaze.colorFrom(JSON.parse(original)).export(), + ); + expect(reExported).toBe(original); + }); + + it('snapshot shape is stable for the structured form', () => { + const tok = glaze.color({ + hue: 280, + saturation: 50, + lightness: 50, + opacity: 0.5, + name: 'panel', + }); + const original = JSON.stringify(tok.export()); + const reExported = JSON.stringify( + glaze.colorFrom(JSON.parse(original)).export(), + ); + expect(reExported).toBe(original); + }); + }); + + describe('colorFrom shape guards', () => { + it('throws on non-object input', () => { + expect(() => + glaze.colorFrom(null as unknown as GlazeColorTokenExport), + ).toThrow(/expected an object from token\.export/); + expect(() => + glaze.colorFrom('hello' as unknown as GlazeColorTokenExport), + ).toThrow(/expected an object from token\.export/); + }); + + it('throws on missing or invalid form field', () => { + expect(() => + glaze.colorFrom({} as unknown as GlazeColorTokenExport), + ).toThrow(/invalid "form" field/); + expect(() => + glaze.colorFrom({ + form: 'wrong', + input: '#000', + } as unknown as GlazeColorTokenExport), + ).toThrow(/invalid "form" field/); + }); + + it('throws on missing input field', () => { + expect(() => + glaze.colorFrom({ + form: 'value', + } as unknown as GlazeColorTokenExport), + ).toThrow(/missing "input" field/); + expect(() => + glaze.colorFrom({ + form: 'structured', + } as unknown as GlazeColorTokenExport), + ).toThrow(/missing "input" field/); + }); + }); + + describe('overrides', () => { + it('saturation override changes seed saturation', () => { + const high = glaze + .color('#26fcb2', { saturation: 100 }) + .resolve().light; + const low = glaze.color('#26fcb2', { saturation: 20 }).resolve().light; + expect(high.s).toBeGreaterThan(low.s); + }); + + it('mode override changes dark mapping', () => { + const fixed = glaze.color('#26fcb2', { mode: 'fixed' }).resolve(); + const auto = glaze.color('#26fcb2', { mode: 'auto' }).resolve(); + expect(fixed.dark.l).not.toBeCloseTo(auto.dark.l, 2); + }); + + it('lightness override sets absolute lightness', () => { + const resolved = glaze.color('#26fcb2', { lightness: 50 }).resolve(); + expect(resolved.light.l).toBeCloseTo(0.5, 2); + }); + + it('hue override sets absolute seed hue', () => { + const resolved = glaze.color('#26fcb2', { hue: 200 }).resolve(); + expect(resolved.light.h).toBeCloseTo(200, 1); + }); + + it('relative hue offset shifts from seed hue', () => { + const baseline = glaze.color('#26fcb2').resolve().light.h; + const shifted = glaze.color('#26fcb2', { hue: '+10' }).resolve() + .light.h; + expect(shifted).toBeCloseTo((baseline + 10) % 360, 1); + }); + }); + + describe('contrast and relative lightness anchored to seed', () => { + it('relative lightness resolves against the literal seed', () => { + const seedHex = '#26fcb2'; + const result = glaze + .color(seedHex, { lightness: '+5' }) + .resolve().light; + const [, , seedL] = srgbToOkhsl(parseHex(seedHex)!); + // Light variant preserves raw lightness with default scaling. + expect(result.l * 100).toBeCloseTo(seedL * 100 + 5, 0); + }); + + it('contrast solver meets AAA against the literal seed in every variant', () => { + const seedHex = '#1a1a2e'; + const seedOkhsl = srgbToOkhsl(parseHex(seedHex)!); + const seedVariant: ResolvedColorVariant = { + h: seedOkhsl[0], + s: seedOkhsl[1], + l: seedOkhsl[2], + alpha: 1, + }; + const color = glaze.color(seedHex, { contrast: 'AAA' }); + const resolved = color.resolve(); + + for (const variant of [ + 'light', + 'dark', + 'lightContrast', + 'darkContrast', + ] as const) { + const ratio = variantContrast(resolved[variant], seedVariant); + expect(ratio).toBeGreaterThanOrEqual(7 - 0.05); + } + }); + + it('lifts lightness above the relative anchor when AAA requires', () => { + // The seed is mid-lightness purple, so AAA (7:1) against the seed + // is physically unreachable — the solver returns the closest fit + // and `glaze` warns. Silence the warn here; we exercise that + // behavior explicitly in the `contrast warning` describe block. + const warn = vi.spyOn(console, 'warn').mockImplementation(vi.fn()); + try { + const seedHex = '#7a4dbf'; + const [, , seedL] = srgbToOkhsl(parseHex(seedHex)!); + const result = glaze + .color(seedHex, { lightness: '+10', contrast: 'AAA' }) + .resolve().light; + expect(result.l * 100).toBeGreaterThan(seedL * 100 + 10); + expect(warn).toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + + it('relative lightness works without contrast', () => { + // No throw — the seed is an implicit anchor. + expect(() => + glaze.color('#26fcb2', { lightness: '+10' }).resolve(), + ).not.toThrow(); + }); + + it('contrast works without explicit base', () => { + // No throw — the seed is an implicit anchor. + expect(() => + glaze.color('#26fcb2', { contrast: 'AA' }).resolve(), + ).not.toThrow(); + }); + + it('high-contrast contrast pair tightens HC variants', () => { + const seedHex = '#1a1a2e'; + const seedOkhsl = srgbToOkhsl(parseHex(seedHex)!); + const seedVariant: ResolvedColorVariant = { + h: seedOkhsl[0], + s: seedOkhsl[1], + l: seedOkhsl[2], + alpha: 1, + }; + const color = glaze.color(seedHex, { contrast: ['AA', 'AAA'] }); + const resolved = color.resolve(); + const lightHcRatio = variantContrast( + resolved.lightContrast, + seedVariant, + ); + expect(lightHcRatio).toBeGreaterThanOrEqual(7 - 0.05); + }); + + it('error messages do not leak internal STANDALONE_VALUE / STANDALONE_SEED names', () => { + // Even though seed-anchored colors no longer throw for relative/contrast + // without base, ensure no leaked internal names appear in any error + // path that does fire (e.g. malformed override would route through + // validation). This sanity check guards against future regressions. + try { + glaze + .color('#26fcb2', { + lightness: 'invalid' as unknown as `+${number}`, + }) + .resolve(); + } catch (err) { + const msg = (err as Error).message; + expect(msg).not.toMatch(/__color__/); + expect(msg).not.toMatch(/__base__/); + } + }); + }); + + describe('full export coverage from value-shorthand', () => { + it('resolve() returns all four scheme variants', () => { + const resolved = glaze.color('#26fcb2').resolve(); + expect(resolved.light).toBeDefined(); + expect(resolved.dark).toBeDefined(); + expect(resolved.lightContrast).toBeDefined(); + expect(resolved.darkContrast).toBeDefined(); + }); + + it('token() / tasty() / json() work from a hex input', () => { + const color = glaze.color('#26fcb2', { mode: 'fixed' }); + expect(color.token()['']).toMatch(/^okhsl\(/); + expect(color.tasty()['']).toMatch(/^okhsl\(/); + expect(color.json().light).toMatch(/^okhsl\(/); + }); + + it('css({ name }) emits --name-color declarations across variants', () => { + const css = glaze.color('#26fcb2').css({ name: 'brand' }); + expect(css.light).toMatch(/^--brand-color:\s*rgb\(/); + expect(css.dark).toMatch(/^--brand-color:\s*rgb\(/); + expect(css.lightContrast).toMatch(/^--brand-color:\s*rgb\(/); + expect(css.darkContrast).toMatch(/^--brand-color:\s*rgb\(/); + }); + + it('css() honors suffix and format options', () => { + const css = glaze.color('#26fcb2').css({ + name: 'brand', + suffix: '', + format: 'oklch', + }); + expect(css.light).toMatch(/^--brand:\s*oklch\(/); + }); + + it('format option still works through the value overload', () => { + const color = glaze.color('#26fcb2'); + expect(color.token({ format: 'rgb' })['']).toMatch(/^rgb\(/); + expect(color.tasty({ format: 'oklch' })['']).toMatch(/^oklch\(/); + expect(color.json({ format: 'hsl' }).light).toMatch(/^hsl\(/); + }); + + it('css() works on the structured form too', () => { + const css = glaze + .color({ hue: 152, saturation: 95, lightness: 74 }) + .css({ name: 'brand' }); + expect(css.light).toMatch(/^--brand-color:\s*rgb\(/); + }); + + it('exports work on a seed-anchored contrast color', () => { + const color = glaze.color('#1a1a2e', { contrast: 'AAA' }); + expect(color.tasty()['']).toMatch(/^okhsl\(/); + expect(color.css({ name: 'brand-text' }).light).toMatch( + /^--brand-text-color:\s*rgb\(/, + ); + }); + + it('resolveOnce memoization returns identical references across calls', () => { + const color = glaze.color('#26fcb2'); + const a = color.resolve(); + const b = color.resolve(); + // Same memoized ResolvedColor is returned across repeated calls. + expect(a).toBe(b); + }); + }); + + describe('glaze.shadow accepts the full GlazeColorValue surface', () => { + it('accepts rgb() / hsl() / oklch() / okhsl() / OKHSL object / RGB tuple', () => { + const cases: unknown[] = [ + 'rgb(38 252 178)', + 'hsl(152 97% 57%)', + 'okhsl(152 95% 74%)', + 'oklch(0.85 0.18 152)', + { h: 152, s: 0.95, l: 0.74 }, + [38, 252, 178] as [number, number, number], + ]; + for (const bg of cases) { + expect(() => + glaze.shadow({ + bg: bg as Parameters[0]['bg'], + intensity: 50, + }), + ).not.toThrow(); + } + }); + + it('matches hex bg with rgb() string bg', () => { + const fromHex = glaze.shadow({ bg: '#26fcb2', intensity: 50 }); + const fromRgb = glaze.shadow({ + bg: 'rgb(38 252 178)', + intensity: 50, + }); + expect(fromRgb.h).toBeCloseTo(fromHex.h, 1); + expect(fromRgb.s).toBeCloseTo(fromHex.s, 3); + expect(fromRgb.l).toBeCloseTo(fromHex.l, 3); + }); + }); }); describe('glaze.fromHex / fromRgb', () => { diff --git a/src/glaze.ts b/src/glaze.ts index 217cd22..8daf961 100644 --- a/src/glaze.ts +++ b/src/glaze.ts @@ -14,13 +14,17 @@ import { formatHsl, formatOklch, srgbToOkhsl, + hslToSrgb, + oklabToOkhsl, parseHex, + parseHexAlpha, } from './okhsl-color-math'; import { findLightnessForContrast, findValueForMixContrast, + resolveMinContrast, } from './contrast-solver'; -import type { LinearRgb } from './contrast-solver'; +import type { LinearRgb, MinContrast } from './contrast-solver'; import type { HCPair, AdaptationMode, @@ -47,11 +51,73 @@ import type { GlazePaletteOptions, GlazePaletteExportOptions, GlazeColorInput, + GlazeColorInputExport, GlazeColorToken, + GlazeColorTokenExport, + GlazeColorValue, + GlazeColorOverrides, + GlazeColorOverridesExport, + GlazeColorCssOptions, + GlazeColorScaling, GlazeShadowInput, OkhslColor, } from './types'; +// ============================================================================ +// Standalone color constants +// ============================================================================ + +/** Internal name of the user-facing standalone color in the synthesized def map. */ +const STANDALONE_VALUE = 'value'; +/** Internal name of the hidden static-anchor seed used for relative lightness / contrast. */ +const STANDALONE_SEED = 'seed'; +/** Internal name of an externally-resolved `GlazeColorToken` injected as a base reference. */ +const STANDALONE_BASE = 'externalBase'; + +/** + * Build the create-time scaling snapshot used when the caller did not + * pass an explicit `scaling`. All windows are snapshotted from the + * current `globalConfig` so later `glaze.configure()` calls don't + * retroactively change the resolved variants of an already-created + * token (matches the documented "frozen at create time" semantics). + * + * String value-shorthand inputs use an extended dark window + * `[globalConfig.darkLightness[0], 100]` so a totally-black input can + * Möbius-invert to totally-white in dark mode; object / tuple / + * structured inputs use `globalConfig.darkLightness` verbatim. + */ +function defaultStandaloneScaling(extendDark: boolean): GlazeColorScaling { + const [lo, hi] = globalConfig.darkLightness; + return { + lightLightness: false, + darkLightness: extendDark ? [lo, 100] : [lo, hi], + }; +} + +/** Reserved internal names that user-supplied `name` must not collide with. */ +const RESERVED_STANDALONE_NAMES = new Set([ + STANDALONE_VALUE, + STANDALONE_SEED, + STANDALONE_BASE, +]); + +/** + * Discriminate a `GlazeColorToken` from a raw `GlazeColorValue`. + * Used to widen `base?` so it accepts either a token reference or a + * raw value (auto-wrapped into `glaze.color(value)`). + */ +function isGlazeColorToken( + candidate: GlazeColorToken | GlazeColorValue, +): candidate is GlazeColorToken { + return ( + typeof candidate === 'object' && + candidate !== null && + !Array.isArray(candidate) && + 'resolve' in candidate && + typeof (candidate as { resolve?: unknown }).resolve === 'function' + ); +} + // ============================================================================ // Global configuration // ============================================================================ @@ -83,6 +149,70 @@ function pairHC(p: HCPair): T { return Array.isArray(p) ? p[1] : p; } +// ============================================================================ +// Contrast warning +// ============================================================================ + +/** + * Dedupe contrast warnings within a single process. The cache survives + * the lifetime of a token because tokens memoize their resolution; the + * limit is a soft cap to keep noise bounded across long-lived sessions + * (e.g. dev servers with HMR re-resolving themes repeatedly). + */ +const CONTRAST_WARN_CACHE_LIMIT = 256; +const contrastWarnCache = new Set(); + +function schemeLabel(isDark: boolean, isHighContrast: boolean): string { + if (isDark && isHighContrast) return 'darkContrast'; + if (isDark) return 'dark'; + if (isHighContrast) return 'lightContrast'; + return 'light'; +} + +function formatContrastTarget(input: MinContrast, ratio: number): string { + return typeof input === 'string' + ? `"${input}" (${ratio.toFixed(2)})` + : ratio.toFixed(2); +} + +/** + * Slack factor below the requested target before we emit a warning. + * The contrast solver already overshoots by `OVERSHOOT` (currently 1%) + * to absorb rounding noise (`see findLightnessForContrast` in + * `contrast-solver.ts`), so an `actual` ratio within ~2x that overshoot + * is effectively a pass and not worth nagging the user about. + */ +const CONTRAST_WARN_SLACK = 0.98; + +function warnContrastUnmet( + name: string, + isDark: boolean, + isHighContrast: boolean, + target: MinContrast, + actual: number, +): void { + const targetRatio = resolveMinContrast(target); + if (actual >= targetRatio * CONTRAST_WARN_SLACK) return; + + const scheme = schemeLabel(isDark, isHighContrast); + const key = `${name}|${scheme}|${targetRatio.toFixed(3)}|${actual.toFixed(2)}`; + if (contrastWarnCache.has(key)) return; + + if (contrastWarnCache.size >= CONTRAST_WARN_CACHE_LIMIT) { + contrastWarnCache.clear(); + } + contrastWarnCache.add(key); + + console.warn( + `glaze: color "${name}" cannot meet contrast ${formatContrastTarget( + target, + targetRatio, + )} in ${scheme} scheme (got ${actual.toFixed(2)}). ` + + `Try widening the lightness window, lowering the contrast target, ` + + `or picking a base color further from this color's lightness.`, + ); +} + // ============================================================================ // Shadow helpers // ============================================================================ @@ -182,28 +312,35 @@ function computeShadow( // Validation // ============================================================================ -function validateColorDefs(defs: ColorMap): void { - const names = new Set(Object.keys(defs)); +function validateColorDefs( + defs: ColorMap, + externalBases?: Map, +): void { + const localNames = new Set(Object.keys(defs)); + const allNames = new Set([ + ...localNames, + ...(externalBases ? externalBases.keys() : []), + ]); for (const [name, def] of Object.entries(defs)) { if (isShadowDef(def)) { - if (!names.has(def.bg)) { + if (!allNames.has(def.bg)) { throw new Error( `glaze: shadow "${name}" references non-existent bg "${def.bg}".`, ); } - if (isShadowDef(defs[def.bg])) { + if (localNames.has(def.bg) && isShadowDef(defs[def.bg])) { throw new Error( `glaze: shadow "${name}" bg "${def.bg}" references another shadow color.`, ); } if (def.fg !== undefined) { - if (!names.has(def.fg)) { + if (!allNames.has(def.fg)) { throw new Error( `glaze: shadow "${name}" references non-existent fg "${def.fg}".`, ); } - if (isShadowDef(defs[def.fg])) { + if (localNames.has(def.fg) && isShadowDef(defs[def.fg])) { throw new Error( `glaze: shadow "${name}" fg "${def.fg}" references another shadow color.`, ); @@ -213,22 +350,22 @@ function validateColorDefs(defs: ColorMap): void { } if (isMixDef(def)) { - if (!names.has(def.base)) { + if (!allNames.has(def.base)) { throw new Error( `glaze: mix "${name}" references non-existent base "${def.base}".`, ); } - if (!names.has(def.target)) { + if (!allNames.has(def.target)) { throw new Error( `glaze: mix "${name}" references non-existent target "${def.target}".`, ); } - if (isShadowDef(defs[def.base])) { + if (localNames.has(def.base) && isShadowDef(defs[def.base])) { throw new Error( `glaze: mix "${name}" base "${def.base}" references a shadow color.`, ); } - if (isShadowDef(defs[def.target])) { + if (localNames.has(def.target) && isShadowDef(defs[def.target])) { throw new Error( `glaze: mix "${name}" target "${def.target}" references a shadow color.`, ); @@ -252,13 +389,17 @@ function validateColorDefs(defs: ColorMap): void { ); } - if (regDef.base && !names.has(regDef.base)) { + if (regDef.base && !allNames.has(regDef.base)) { throw new Error( `glaze: color "${name}" references non-existent base "${regDef.base}".`, ); } - if (regDef.base && isShadowDef(defs[regDef.base])) { + if ( + regDef.base && + localNames.has(regDef.base) && + isShadowDef(defs[regDef.base]) + ) { throw new Error( `glaze: color "${name}" base "${regDef.base}" references a shadow color.`, ); @@ -277,11 +418,14 @@ function validateColorDefs(defs: ColorMap): void { } } - // Check for circular references (follows base, bg, fg edges) + // Check for circular references (follows base, bg, fg edges). + // External bases are leaves (no outgoing edges in `defs`), so they can't + // form a cycle and we short-circuit there. const visited = new Set(); const inStack = new Set(); function dfs(name: string): void { + if (!localNames.has(name)) return; if (inStack.has(name)) { throw new Error( `glaze: circular base reference detected involving "${name}".`, @@ -307,7 +451,7 @@ function validateColorDefs(defs: ColorMap): void { visited.add(name); } - for (const name of names) { + for (const name of localNames) { dfs(name); } } @@ -325,6 +469,9 @@ function topoSort(defs: ColorMap): string[] { visited.add(name); const def = defs[name]; + // External base references (not in `defs`) are leaves — they're already + // pre-seeded into `ctx.resolved` and don't participate in the local sort. + if (def === undefined) return; if (isShadowDef(def)) { visit(def.bg); if (def.fg) visit(def.fg); @@ -352,11 +499,24 @@ function topoSort(defs: ColorMap): string[] { // Lightness window selection // ============================================================================ +/** + * Resolve the active lightness window for a scheme. + * - HC variants always return `[0, 100]` (existing behavior, predates per-call overrides). + * - Otherwise, per-call `scaling` (e.g. from `glaze.color()`'s third arg) wins; + * `false` is interpreted as `[0, 100]` (no remap). Falls back to `globalConfig.*Lightness`. + */ function lightnessWindow( isHighContrast: boolean, kind: 'light' | 'dark', + scaling?: GlazeColorScaling, ): [number, number] { if (isHighContrast) return [0, 100]; + if (scaling) { + const override = + kind === 'dark' ? scaling.darkLightness : scaling.lightLightness; + if (override === false) return [0, 100]; + if (override !== undefined) return override; + } return kind === 'dark' ? globalConfig.darkLightness : globalConfig.lightLightness; @@ -370,9 +530,10 @@ function mapLightnessLight( l: number, mode: AdaptationMode, isHighContrast: boolean, + scaling?: GlazeColorScaling, ): number { if (mode === 'static') return l; - const [lo, hi] = lightnessWindow(isHighContrast, 'light'); + const [lo, hi] = lightnessWindow(isHighContrast, 'light', scaling); return (l * (hi - lo)) / 100 + lo; } @@ -389,30 +550,35 @@ function mapLightnessDark( l: number, mode: AdaptationMode, isHighContrast: boolean, + scaling?: GlazeColorScaling, ): number { if (mode === 'static') return l; const beta = isHighContrast ? pairHC(globalConfig.darkCurve) : pairNormal(globalConfig.darkCurve); - const [darkLo, darkHi] = lightnessWindow(isHighContrast, 'dark'); + const [darkLo, darkHi] = lightnessWindow(isHighContrast, 'dark', scaling); if (mode === 'fixed') { return (l * (darkHi - darkLo)) / 100 + darkLo; } - const [lightLo, lightHi] = lightnessWindow(isHighContrast, 'light'); + const [lightLo, lightHi] = lightnessWindow(isHighContrast, 'light', scaling); const lightL = (l * (lightHi - lightLo)) / 100 + lightLo; const t = (lightHi - lightL) / (lightHi - lightLo); return darkLo + (darkHi - darkLo) * mobiusCurve(t, beta); } -function lightMappedToDark(lightL: number, isHighContrast: boolean): number { +function lightMappedToDark( + lightL: number, + isHighContrast: boolean, + scaling?: GlazeColorScaling, +): number { const beta = isHighContrast ? pairHC(globalConfig.darkCurve) : pairNormal(globalConfig.darkCurve); - const [lightLo, lightHi] = lightnessWindow(isHighContrast, 'light'); - const [darkLo, darkHi] = lightnessWindow(isHighContrast, 'dark'); + const [lightLo, lightHi] = lightnessWindow(isHighContrast, 'light', scaling); + const [darkLo, darkHi] = lightnessWindow(isHighContrast, 'dark', scaling); const clamped = clamp(lightL, lightLo, lightHi); const t = (lightHi - clamped) / (lightHi - lightLo); return darkLo + (darkHi - darkLo) * mobiusCurve(t, beta); @@ -427,9 +593,14 @@ function schemeLightnessRange( isDark: boolean, mode: AdaptationMode, isHighContrast: boolean, + scaling?: GlazeColorScaling, ): [number, number] { if (mode === 'static') return [0, 1]; - const [lo, hi] = lightnessWindow(isHighContrast, isDark ? 'dark' : 'light'); + const [lo, hi] = lightnessWindow( + isHighContrast, + isDark ? 'dark' : 'light', + scaling, + ); return [lo / 100, hi / 100]; } @@ -492,6 +663,12 @@ interface ResolveContext { saturation: number; defs: ColorMap; resolved: Map; + /** + * Optional per-resolve scaling overrides for the lightness windows. + * Used by `glaze.color()` to preserve light input by default while + * still adapting dark to `globalConfig.darkLightness`. + */ + scaling?: GlazeColorScaling; } function resolveRootColor( @@ -550,15 +727,29 @@ function resolveDependentColor( isHighContrast, ); const absoluteLightL = clamp(baseLightVariant.l * 100 + delta, 0, 100); - preferredL = lightMappedToDark(absoluteLightL, isHighContrast); + preferredL = lightMappedToDark( + absoluteLightL, + isHighContrast, + ctx.scaling, + ); } else { preferredL = clamp(baseL + delta, 0, 100); } } else { if (isDark) { - preferredL = mapLightnessDark(parsed.value, mode, isHighContrast); + preferredL = mapLightnessDark( + parsed.value, + mode, + isHighContrast, + ctx.scaling, + ); } else { - preferredL = mapLightnessLight(parsed.value, mode, isHighContrast); + preferredL = mapLightnessLight( + parsed.value, + mode, + isHighContrast, + ctx.scaling, + ); } } } @@ -579,7 +770,12 @@ function resolveDependentColor( baseVariant.l, ); - const windowRange = schemeLightnessRange(isDark, mode, isHighContrast); + const windowRange = schemeLightnessRange( + isDark, + mode, + isHighContrast, + ctx.scaling, + ); const result = findLightnessForContrast({ hue: effectiveHue, @@ -594,6 +790,10 @@ function resolveDependentColor( lightnessRange: [0, 1], }); + if (!result.met) { + warnContrastUnmet(name, isDark, isHighContrast, minCr, result.contrast); + } + return { l: result.lightness * 100, satFactor }; } @@ -655,13 +855,13 @@ function resolveColorForScheme( let finalSat: number; if (isDark && isRoot) { - finalL = mapLightnessDark(lightL, mode, isHighContrast); + finalL = mapLightnessDark(lightL, mode, isHighContrast, ctx.scaling); finalSat = mapSaturationDark((satFactor * ctx.saturation) / 100, mode); } else if (isDark && !isRoot) { finalL = lightL; finalSat = mapSaturationDark((satFactor * ctx.saturation) / 100, mode); } else if (isRoot) { - finalL = mapLightnessLight(lightL, mode, isHighContrast); + finalL = mapLightnessLight(lightL, mode, isHighContrast, ctx.scaling); finalSat = (satFactor * ctx.saturation) / 100; } else { finalL = lightL; @@ -826,8 +1026,10 @@ function resolveAllColors( hue: number, saturation: number, defs: ColorMap, + scaling?: GlazeColorScaling, + externalBases?: Map, ): Map { - validateColorDefs(defs); + validateColorDefs(defs, externalBases); const order = topoSort(defs); const ctx: ResolveContext = { @@ -835,8 +1037,18 @@ function resolveAllColors( saturation, defs, resolved: new Map(), + scaling, }; + // Pre-seed externally-resolved bases. The per-pass `for (const name of order)` + // loops below only iterate `defs` keys, so external entries persist across + // all four passes and are read via `getSchemeVariant` per scheme. + if (externalBases) { + for (const [name, color] of externalBases) { + ctx.resolved.set(name, color); + } + } + function defMode(def: ColorDef): AdaptationMode | undefined { if (isShadowDef(def) || isMixDef(def)) return undefined; return (def as RegularColorDef).mode ?? 'auto'; @@ -1494,64 +1706,682 @@ function createPalette( // Standalone color token // ============================================================================ -function createColorToken(input: GlazeColorInput): GlazeColorToken { - const colorDef: RegularColorDef = { - lightness: input.lightness, - saturation: input.saturationFactor, - mode: input.mode, +/** + * 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|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 '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}".`); +} + +/** + * 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, lightness } (which uses 0–100)?', + ); + } +} + +/** + * Validate a user-supplied `[r, g, b]` tuple in 0-255. + */ +function validateRgbTuple(value: readonly [number, number, number]): void { + for (const n of value) { + if (!Number.isFinite(n) || n < 0 || n > 255) { + throw new Error( + `glaze.color: RGB tuple components must be finite numbers in 0–255 (got [${value.join(', ')}]).`, + ); + } + } +} + +/** + * Validate a user-supplied `opacity` override on `glaze.color()`. + * Must be a finite number in `0..=1`. + */ +function validateStandaloneOpacity(value: number): void { + if (!Number.isFinite(value) || value < 0 || value > 1) { + throw new Error( + `glaze.color: opacity must be a finite number in 0–1 (got ${value}).`, + ); + } +} + +/** + * Validate a structured `GlazeColorInput`. Range-checks the `hue` / + * `saturation` / `lightness` numerics (and any HC-pair second value) + * before the resolver sees them so out-of-range or non-finite inputs + * fail with a helpful, top-level error rather than producing a + * NaN-laden token. `opacity` is checked here too so all input + * validation lives in one place. + */ +function validateStructuredInput(input: GlazeColorInput): void { + if (!Number.isFinite(input.hue)) { + throw new Error( + `glaze.color: structured hue must be a finite number (got ${input.hue}).`, + ); + } + if ( + !Number.isFinite(input.saturation) || + input.saturation < 0 || + input.saturation > 100 + ) { + throw new Error( + `glaze.color: structured saturation must be a finite number in 0–100 (got ${input.saturation}).`, + ); + } + const checkLightness = (value: number, label: string): void => { + if (!Number.isFinite(value) || value < 0 || value > 100) { + throw new Error( + `glaze.color: structured ${label} must be a finite number in 0–100 (got ${value}).`, + ); + } + }; + if (Array.isArray(input.lightness)) { + checkLightness(input.lightness[0], 'lightness[normal]'); + checkLightness(input.lightness[1], 'lightness[hc]'); + } else { + checkLightness(input.lightness, 'lightness'); + } + if (input.saturationFactor !== undefined) { + if ( + !Number.isFinite(input.saturationFactor) || + input.saturationFactor < 0 || + input.saturationFactor > 1 + ) { + throw new Error( + `glaze.color: structured saturationFactor must be a finite number in 0–1 (got ${input.saturationFactor}).`, + ); + } + } + if (input.opacity !== undefined) validateStandaloneOpacity(input.opacity); +} + +/** + * Validate a user-supplied `name` override. Rejects empty / whitespace-only + * strings and names colliding with `glaze`'s reserved internal sentinels. + */ +function validateStandaloneName(name: string): void { + if (typeof name !== 'string' || name.trim() === '') { + throw new Error( + 'glaze.color: name must be a non-empty string. ' + + 'Omit `name` if you do not want to set a debug label.', + ); + } + if (RESERVED_STANDALONE_NAMES.has(name)) { + const reserved = [...RESERVED_STANDALONE_NAMES] + .map((n) => `"${n}"`) + .join(', '); + throw new Error( + `glaze.color: name "${name}" is reserved (used internally). ` + + `Reserved names are: ${reserved}. Pick a different name.`, + ); + } +} + +/** + * Extract an OKHSL color from any `GlazeColorValue` form. Also used by + * `glaze.shadow()` so all shadow inputs (hex, color functions, OKHSL, + * RGB tuple) go through one parser. + */ +function extractOkhslFromValue(value: GlazeColorValue): OkhslColor { + if (typeof value === 'string') return parseColorString(value); + if (Array.isArray(value)) { + const tuple = value as readonly [number, number, number]; + validateRgbTuple(tuple); + const [r, g, b] = tuple; + const [h, s, l] = srgbToOkhsl([r / 255, g / 255, b / 255]); + return { h, s, l }; + } + validateOkhslColor(value as OkhslColor); + return value as OkhslColor; +} + +interface ValueDefsResult { + seedHue: number; + seedSaturation: number; + defs: ColorMap; + primary: string; +} + +/** + * Build the `ColorMap` for a value-shorthand `glaze.color()` call. + * + * The user-facing color (`STANDALONE_VALUE`) defaults to `mode: 'auto'` + * for string inputs (Möbius-inverted dark variant — pairs with the + * extended dark window so a totally-black input renders as totally-white + * in dark mode) and `mode: 'fixed'` for `OkhslColor` / RGB-tuple inputs + * (linear, no inversion). + * + * When the user requests `contrast` or relative `lightness`, a hidden + * `STANDALONE_SEED` def is synthesized at `mode: 'static'`. That keeps + * the seed pinned to the literal user-provided color across all four + * variants, so the contrast solver always anchors against it. + */ +function buildStandaloneValueDefs( + main: OkhslColor, + options: GlazeColorOverrides | undefined, + inputIsString: boolean, +): ValueDefsResult { + const seedHue = typeof options?.hue === 'number' ? options.hue : main.h; + const seedSaturation = options?.saturation ?? main.s * 100; + const relativeHue = + typeof options?.hue === 'string' ? options.hue : undefined; + + const lightnessOption = options?.lightness; + const hasExternalBase = options?.base !== undefined; + // Seed-anchor synthesis only kicks in when the user did NOT supply their + // own base — in that case `contrast` and relative `lightness` anchor to + // the literal seed via the hidden `STANDALONE_SEED` def. + const needsSeedAnchor = + !hasExternalBase && + (options?.contrast !== undefined || + (lightnessOption !== undefined && !isAbsoluteLightness(lightnessOption))); + + if (options?.opacity !== undefined) + validateStandaloneOpacity(options.opacity); + + // User-supplied `name` becomes the def key (and surfaces in error / warn + // messages). It must not collide with internal reserved names; we throw + // a clear error rather than silently shadowing them. + const userName = options?.name; + if (userName !== undefined) validateStandaloneName(userName); + const primary = userName ?? STANDALONE_VALUE; + + const valueDef: RegularColorDef = { + hue: relativeHue, + saturation: options?.saturationFactor, + lightness: lightnessOption ?? main.l * 100, + contrast: options?.contrast, + mode: options?.mode ?? (inputIsString ? 'auto' : 'fixed'), + opacity: options?.opacity, + base: hasExternalBase + ? STANDALONE_BASE + : needsSeedAnchor + ? STANDALONE_SEED + : undefined, + }; + + const defs: ColorMap = { [primary]: valueDef }; + + if (needsSeedAnchor) { + // `saturation: 1` is the default factor; combined with seedSaturation + // = main.s * 100, the seed renders at exactly the user-provided color. + defs[STANDALONE_SEED] = { + hue: main.h, + saturation: 1, + lightness: main.l * 100, + mode: 'static', + }; + } + + return { + seedHue, + seedSaturation, + defs, + primary, + }; +} + +function createColorTokenFromDefs( + seedHue: number, + seedSaturation: number, + defs: ColorMap, + primary: string, + effectiveScaling: GlazeColorScaling, + baseToken: GlazeColorToken | undefined, + exportData: () => GlazeColorTokenExport, +): GlazeColorToken { + // Cache the resolve result across token / tasty / json / css / resolve calls. + // The base token's `.resolve()` is called lazily on first resolve and the + // result is captured by reference, so subsequent base mutations don't apply + // (matches the existing snapshot semantics for `scaling.darkLightness`). + let cached: Map | undefined; + const resolveOnce = (): Map => { + if (cached) return cached; + const externalBases = baseToken + ? new Map([[STANDALONE_BASE, baseToken.resolve()]]) + : undefined; + cached = resolveAllColors( + seedHue, + seedSaturation, + defs, + effectiveScaling, + externalBases, + ); + return cached; }; - const defs: ColorMap = { __color__: colorDef }; + const resolveStates = (options?: GlazeTokenOptions) => ({ + dark: options?.states?.dark ?? globalConfig.states.dark, + highContrast: + options?.states?.highContrast ?? globalConfig.states.highContrast, + }); + + const tokenLike = (options?: GlazeTokenOptions): Record => { + const tokenMap = buildTokenMap( + resolveOnce(), + '', + resolveStates(options), + resolveModes(options?.modes), + options?.format, + ); + return tokenMap[`#${primary}`]; + }; return { resolve(): ResolvedColor { - const resolved = resolveAllColors(input.hue, input.saturation, defs); - return resolved.get('__color__')!; + return resolveOnce().get(primary)!; }, - token(options?: GlazeTokenOptions): Record { - const resolved = resolveAllColors(input.hue, input.saturation, defs); - const states = { - dark: options?.states?.dark ?? globalConfig.states.dark, - highContrast: - options?.states?.highContrast ?? globalConfig.states.highContrast, - }; - const modes = resolveModes(options?.modes); - const tokenMap = buildTokenMap( - resolved, - '', - states, - modes, + token: tokenLike, + tasty: tokenLike, + + json(options?: GlazeJsonOptions): Record { + const jsonMap = buildJsonMap( + resolveOnce(), + resolveModes(options?.modes), options?.format, ); - return tokenMap['#__color__']; + return jsonMap[primary]; }, - tasty(options?: GlazeTokenOptions): Record { - const resolved = resolveAllColors(input.hue, input.saturation, defs); - const states = { - dark: options?.states?.dark ?? globalConfig.states.dark, - highContrast: - options?.states?.highContrast ?? globalConfig.states.highContrast, - }; - const modes = resolveModes(options?.modes); - const tokenMap = buildTokenMap( - resolved, + css(options: GlazeColorCssOptions): GlazeCssResult { + const renamed = new Map([ + [options.name, resolveOnce().get(primary)!], + ]); + return buildCssMap( + renamed, '', - states, - modes, - options?.format, + options.suffix ?? '-color', + options.format ?? 'rgb', ); - return tokenMap['#__color__']; }, - json(options?: GlazeJsonOptions): Record { - const resolved = resolveAllColors(input.hue, input.saturation, defs); - const modes = resolveModes(options?.modes); - const jsonMap = buildJsonMap(resolved, modes, options?.format); - return jsonMap['__color__']; + export: exportData, + }; +} + +/** + * Resolve `base` (which may be a token reference or a raw color value) + * into a `GlazeColorToken`. Raw values are auto-wrapped via + * `glaze.color(value)` so they pick up the same auto-invert defaults as + * an explicit wrap. Returns `undefined` when no base is provided. + */ +function resolveBaseToken( + base: GlazeColorToken | GlazeColorValue | undefined, +): GlazeColorToken | undefined { + if (base === undefined) return undefined; + if (isGlazeColorToken(base)) return base; + return createColorTokenFromValue(base, undefined, undefined); +} + +/** + * Build a JSON-safe snapshot of `GlazeColorOverrides`. `base` is + * recursively serialized when it was originally a token; raw values are + * preserved as-is so `glaze.colorFrom(...)` round-trips them. + */ +function buildOverridesExport( + options: GlazeColorOverrides, +): GlazeColorOverridesExport { + const out: GlazeColorOverridesExport = {}; + if (options.hue !== undefined) out.hue = options.hue; + if (options.saturation !== undefined) out.saturation = options.saturation; + if (options.lightness !== undefined) out.lightness = options.lightness; + if (options.saturationFactor !== undefined) { + out.saturationFactor = options.saturationFactor; + } + if (options.mode !== undefined) out.mode = options.mode; + if (options.contrast !== undefined) out.contrast = options.contrast; + if (options.opacity !== undefined) out.opacity = options.opacity; + if (options.name !== undefined) out.name = options.name; + if (options.base !== undefined) { + out.base = isGlazeColorToken(options.base) + ? options.base.export() + : options.base; + } + return out; +} + +function buildStructuredInputExport( + input: GlazeColorInput, +): GlazeColorInputExport { + const out: GlazeColorInputExport = { + hue: input.hue, + saturation: input.saturation, + lightness: input.lightness, + }; + if (input.saturationFactor !== undefined) { + out.saturationFactor = input.saturationFactor; + } + if (input.mode !== undefined) out.mode = input.mode; + if (input.opacity !== undefined) out.opacity = input.opacity; + if (input.contrast !== undefined) out.contrast = input.contrast; + if (input.name !== undefined) out.name = input.name; + if (input.base !== undefined) { + out.base = isGlazeColorToken(input.base) ? input.base.export() : input.base; + } + return out; +} + +function createColorToken( + input: GlazeColorInput, + scaling: GlazeColorScaling | undefined, +): GlazeColorToken { + validateStructuredInput(input); + + const userName = input.name; + if (userName !== undefined) validateStandaloneName(userName); + const primary = userName ?? STANDALONE_VALUE; + + const baseToken = resolveBaseToken(input.base); + const hasExternalBase = baseToken !== undefined; + // Mirror value-form behavior: when `contrast` is provided without an + // external base, synthesize a hidden static seed so contrast anchors + // against the input's own normal-mode lightness. + const needsSeedAnchor = !hasExternalBase && input.contrast !== undefined; + + const defs: ColorMap = { + [primary]: { + lightness: input.lightness, + saturation: input.saturationFactor, + mode: input.mode ?? 'fixed', + contrast: input.contrast, + opacity: input.opacity, + base: hasExternalBase + ? STANDALONE_BASE + : needsSeedAnchor + ? STANDALONE_SEED + : undefined, }, }; + + if (needsSeedAnchor) { + defs[STANDALONE_SEED] = { + lightness: pairNormal(input.lightness), + saturation: 1, + mode: 'static', + }; + } + + // Structured form uses the same snapshotted default as object / tuple + // value-shorthand: light preserved, dark linearly mapped into a + // create-time copy of `globalConfig.darkLightness`. + const effectiveScaling: GlazeColorScaling = + scaling ?? defaultStandaloneScaling(false); + + const exportData = (): GlazeColorTokenExport => ({ + form: 'structured', + input: buildStructuredInputExport(input), + scaling: effectiveScaling, + }); + + return createColorTokenFromDefs( + input.hue, + input.saturation, + defs, + primary, + effectiveScaling, + baseToken, + exportData, + ); +} + +function createColorTokenFromValue( + value: GlazeColorValue, + options: GlazeColorOverrides | undefined, + scaling: GlazeColorScaling | undefined, +): GlazeColorToken { + const inputIsString = typeof value === 'string'; + const main = extractOkhslFromValue(value); + const baseToken = resolveBaseToken(options?.base); + const { seedHue, seedSaturation, defs, primary } = buildStandaloneValueDefs( + main, + options, + inputIsString, + ); + // Default scaling is snapshotted from `globalConfig` at create time: + // - String inputs (typical end-user values from a color picker / theme + // setting) default to "light preserves input, dark Möbius-inverts up + // to 100" so the natural `#000` ↔ `#fff` flip works out of the box. + // - Object / tuple inputs default to `globalConfig.darkLightness` for + // dark — same windows as the structured form. + // Both forms freeze the windows at create time so later `glaze.configure()` + // calls don't retroactively change exported tokens. + const effectiveScaling: GlazeColorScaling = + scaling ?? defaultStandaloneScaling(inputIsString); + + const exportData = (): GlazeColorTokenExport => ({ + form: 'value', + input: value, + ...(options !== undefined + ? { overrides: buildOverridesExport(options) } + : {}), + scaling: effectiveScaling, + }); + + return createColorTokenFromDefs( + seedHue, + seedSaturation, + defs, + primary, + effectiveScaling, + baseToken, + exportData, + ); +} + +/** + * Rehydrate a token from its `.export()` snapshot. Recursively rebuilds + * any base dependency. Inverse of `GlazeColorToken.export()`. + */ +function colorFromExport(data: GlazeColorTokenExport): GlazeColorToken { + // Shape guard: rehydration takes untrusted JSON (localStorage, URL, + // remote API), so a corrupted blob shouldn't blow up deep inside the + // resolver with confusing errors. + if (data === null || typeof data !== 'object') { + throw new Error( + `glaze.colorFrom: expected an object from token.export(), got ${data === null ? 'null' : typeof data}.`, + ); + } + if (data.form !== 'value' && data.form !== 'structured') { + throw new Error( + `glaze.colorFrom: invalid "form" field — expected "value" or "structured" (got ${JSON.stringify((data as { form?: unknown }).form)}).`, + ); + } + if (data.input === undefined) { + throw new Error( + `glaze.colorFrom: missing "input" field — expected the original ${data.form === 'value' ? 'GlazeColorValue' : 'GlazeColorInput'}.`, + ); + } + + if (data.form === 'value') { + const value = data.input as GlazeColorValue; + const overrides = data.overrides + ? rehydrateOverrides(data.overrides) + : undefined; + return createColorTokenFromValue(value, overrides, data.scaling); + } + const input = rehydrateStructuredInput(data.input as GlazeColorInputExport); + return createColorToken(input, data.scaling); +} + +function rehydrateOverrides( + data: GlazeColorOverridesExport, +): GlazeColorOverrides { + const out: GlazeColorOverrides = {}; + if (data.hue !== undefined) out.hue = data.hue; + if (data.saturation !== undefined) out.saturation = data.saturation; + if (data.lightness !== undefined) out.lightness = data.lightness; + if (data.saturationFactor !== undefined) { + out.saturationFactor = data.saturationFactor; + } + if (data.mode !== undefined) out.mode = data.mode; + if (data.contrast !== undefined) out.contrast = data.contrast; + if (data.opacity !== undefined) out.opacity = data.opacity; + if (data.name !== undefined) out.name = data.name; + if (data.base !== undefined) { + out.base = isExportedToken(data.base) + ? colorFromExport(data.base) + : data.base; + } + return out; +} + +function rehydrateStructuredInput( + data: GlazeColorInputExport, +): GlazeColorInput { + const out: GlazeColorInput = { + hue: data.hue, + saturation: data.saturation, + lightness: data.lightness, + }; + if (data.saturationFactor !== undefined) { + out.saturationFactor = data.saturationFactor; + } + if (data.mode !== undefined) out.mode = data.mode; + if (data.opacity !== undefined) out.opacity = data.opacity; + if (data.contrast !== undefined) out.contrast = data.contrast; + if (data.name !== undefined) out.name = data.name; + if (data.base !== undefined) { + out.base = isExportedToken(data.base) + ? colorFromExport(data.base) + : data.base; + } + return out; +} + +/** + * Discriminate a `GlazeColorTokenExport` from a raw `GlazeColorValue`. + * `GlazeColorTokenExport` always has a `form` field set to either + * `'value'` or `'structured'`; raw values never do. + */ +function isExportedToken( + candidate: GlazeColorTokenExport | GlazeColorValue, +): candidate is GlazeColorTokenExport { + return ( + typeof candidate === 'object' && + candidate !== null && + !Array.isArray(candidate) && + 'form' in candidate && + ((candidate as GlazeColorTokenExport).form === 'value' || + (candidate as GlazeColorTokenExport).form === 'structured') + ); } // ============================================================================ @@ -1618,19 +2448,86 @@ glaze.from = function from(data: GlazeThemeExport): GlazeTheme { return createTheme(data.hue, data.saturation, data.colors); }; +function isStructuredColorInput( + input: GlazeColorInput | GlazeColorValue, +): input is GlazeColorInput { + return ( + typeof input === 'object' && + input !== null && + !Array.isArray(input) && + 'hue' in input && + 'lightness' in input + ); +} + /** * Create a standalone single-color token. + * + * Two overloads: + * - `glaze.color(input, scaling?)` — structured form: + * `{ hue, saturation, lightness, ... }` plus an optional per-call + * lightness-window override. + * - `glaze.color(value, overrides?, scaling?)` — value-shorthand: a hex + * string (3/6/8 digits), one of the CSS color functions Glaze itself + * emits (`rgb()`, `hsl()`, `okhsl()`, `oklch()`), an `OkhslColor` + * object `{ h, s, l }` (0–1 ranges), or an `[r, g, b]` (0–255) tuple. + * + * Defaults vary by input form: + * - String value-shorthand: `mode: 'auto'` with snapshotted scaling + * `{ lightLightness: false, darkLightness: [globalConfig.darkLightness[0], 100] }`. + * Light preserves the input exactly; dark Möbius-inverts up to 100, so + * `glaze.color('#000')` renders as `#fff` in dark mode (and + * `glaze.color('#fff')` falls to the dark `lo` floor). + * - `OkhslColor` object / RGB-tuple value-shorthand: `mode: 'fixed'` + * with `scaling: { lightLightness: false }` — light preserves the + * input; dark linearly maps into `globalConfig.darkLightness`. + * - Structured form (`{ hue, saturation, lightness, ... }`): + * `mode: 'fixed'`; both windows come from `globalConfig`. + * + * Relative `lightness: '+N'` and `contrast: ` are anchored to + * the literal seed (the value passed in) by default, pinned at + * `mode: 'static'` across all four variants. Pass `overrides.base` (a + * `GlazeColorToken`) to anchor `contrast` and relative `lightness` + * against another color's resolved variant per scheme instead. Relative + * `hue: '+N'` always anchors to the seed. + * + * Alpha components in `rgba()` / `hsla()` / slash-alpha syntax and + * 8-digit hex are parsed but dropped with a `console.warn`. */ -glaze.color = function color(input: GlazeColorInput): GlazeColorToken { - return createColorToken(input); +glaze.color = function color( + input: GlazeColorInput | GlazeColorValue, + arg2?: GlazeColorOverrides | GlazeColorScaling, + arg3?: GlazeColorScaling, +): GlazeColorToken { + if (isStructuredColorInput(input)) { + return createColorToken(input, arg2 as GlazeColorScaling | undefined); + } + return createColorTokenFromValue( + input, + arg2 as GlazeColorOverrides | undefined, + arg3, + ); +} as { + (input: GlazeColorInput, scaling?: GlazeColorScaling): GlazeColorToken; + ( + value: GlazeColorValue, + overrides?: GlazeColorOverrides, + scaling?: GlazeColorScaling, + ): GlazeColorToken; }; /** * Compute a shadow color from a bg/fg pair and intensity. + * + * Both `bg` and `fg` accept any `GlazeColorValue` form: hex (`#rgb` / + * `#rrggbb` / `#rrggbbaa`), `rgb()` / `hsl()` / `okhsl()` / `oklch()` + * strings, `OkhslColor` objects, or `[r, g, b]` (0–255) tuples. */ glaze.shadow = function shadow(input: GlazeShadowInput): ResolvedColorVariant { - const bg = parseOkhslInput(input.bg); - const fg = input.fg ? parseOkhslInput(input.fg) : undefined; + const bg = extractOkhslFromValue(input.bg as GlazeColorValue); + const fg = input.fg + ? extractOkhslFromValue(input.fg as GlazeColorValue) + : undefined; const tuning = resolveShadowTuning(input.tuning); return computeShadow( { ...bg, alpha: 1 }, @@ -1650,16 +2547,6 @@ glaze.format = function format( return formatVariant(variant, colorFormat); }; -function parseOkhslInput(input: string | OkhslColor): OkhslColor { - if (typeof input === 'string') { - const rgb = parseHex(input); - if (!rgb) throw new Error(`glaze: invalid hex color "${input}".`); - const [h, s, l] = srgbToOkhsl(rgb); - return { h, s, l }; - } - return input; -} - /** * Create a theme from a hex color string. * Extracts hue and saturation from the color. @@ -1682,6 +2569,29 @@ glaze.fromRgb = function fromRgb(r: number, g: number, b: number): GlazeTheme { return createTheme(h, s * 100); }; +/** + * Rehydrate a `glaze.color()` token from a `.export()` snapshot. + * + * The snapshot is a plain JSON-safe object containing the original + * input value, overrides (with any `base` token recursively serialized), + * and the captured scaling. The reconstructed token is identical in + * behavior to the original at the time of export. + * + * @example + * ```ts + * const text = glaze.color('#1a1a1a', { contrast: 'AA' }); + * const data = text.export(); // JSON-safe + * localStorage.setItem('text', JSON.stringify(data)); + * // ...later... + * const restored = glaze.colorFrom(JSON.parse(localStorage.getItem('text')!)); + * ``` + */ +glaze.colorFrom = function colorFrom( + data: GlazeColorTokenExport, +): GlazeColorToken { + return colorFromExport(data); +}; + /** * Get the current global configuration (for testing/debugging). */ diff --git a/src/index.ts b/src/index.ts index 1b2b4d3..70956ed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,7 +34,14 @@ export type { GlazeCssOptions, GlazeCssResult, GlazeColorInput, + GlazeColorInputExport, GlazeColorToken, + GlazeColorTokenExport, + GlazeColorValue, + GlazeColorOverrides, + GlazeColorOverridesExport, + GlazeColorCssOptions, + GlazeColorScaling, GlazeShadowInput, GlazePalette, GlazePaletteOptions, @@ -60,8 +67,11 @@ export { okhslToLinearSrgb, okhslToSrgb, okhslToOklab, + oklabToOkhsl, srgbToOkhsl, + hslToSrgb, parseHex, + parseHexAlpha, relativeLuminanceFromLinearRgb, contrastRatioFromLuminance, gamutClampedLuminance, diff --git a/src/okhsl-color-math.ts b/src/okhsl-color-math.ts index 6359b75..4003545 100644 --- a/src/okhsl-color-math.ts +++ b/src/okhsl-color-math.ts @@ -444,7 +444,12 @@ const linearSrgbToOklab = (rgb: Vec3): Vec3 => { return transform(lms_, LMS_to_OKLab_M); }; -const oklabToOkhsl = (lab: Vec3): Vec3 => { +/** + * Convert OKLab to OKHSL. + * Input: [L, a, b] where L: 0–1, a/b: roughly -0.5 to 0.5. + * Returns [h, s, l] where h: 0–360, s: 0–1, l: 0–1. + */ +export const oklabToOkhsl = (lab: Vec3): Vec3 => { const L = lab[0]; const a = lab[1]; const b = lab[2]; @@ -505,11 +510,63 @@ export function srgbToOkhsl( return oklabToOkhsl(oklab) as [number, number, number]; } +/** + * Convert CSS HSL (sRGB-based) to gamma-encoded sRGB [r, g, b] in 0–1 range. + * h: 0–360, s: 0–1, l: 0–1. + * + * Note: CSS HSL is not the same as OKHSL — it's HSL in the sRGB color space. + * Use this when parsing `hsl(...)` strings before passing to `srgbToOkhsl`. + */ +export function hslToSrgb( + h: number, + s: number, + l: number, +): [number, number, number] { + const hh = (((h % 360) + 360) % 360) / 360; + const ss = clampVal(s, 0, 1); + const ll = clampVal(l, 0, 1); + + if (ss === 0) { + return [ll, ll, ll]; + } + + const q = ll < 0.5 ? ll * (1 + ss) : ll + ss - ll * ss; + const p = 2 * ll - q; + + const hueToChannel = (t: number): number => { + let tt = t; + if (tt < 0) tt += 1; + if (tt > 1) tt -= 1; + if (tt < 1 / 6) return p + (q - p) * 6 * tt; + if (tt < 1 / 2) return q; + if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6; + return p; + }; + + return [hueToChannel(hh + 1 / 3), hueToChannel(hh), hueToChannel(hh - 1 / 3)]; +} + /** * Parse a hex color string (#rgb or #rrggbb) to sRGB [r, g, b] in 0–1 range. * Returns null if the string is not a valid hex color. + * + * For 8-digit hex (`#rrggbbaa`) and 4-digit hex (`#rgba`) with alpha, + * use {@link parseHexAlpha}. */ export function parseHex(hex: string): [number, number, number] | null { + const result = parseHexAlpha(hex); + if (!result || result.alpha !== undefined) return null; + return result.rgb; +} + +/** + * Parse a hex color string (#rgb, #rrggbb, #rgba, or #rrggbbaa) to + * sRGB [r, g, b] in 0–1 range plus an optional alpha (0–1). + * Returns null if the string is not a valid hex color. + */ +export function parseHexAlpha( + hex: string, +): { rgb: [number, number, number]; alpha?: number } | null { const h = hex.startsWith('#') ? hex.slice(1) : hex; if (h.length === 3) { @@ -517,7 +574,16 @@ export function parseHex(hex: string): [number, number, number] | null { const g = parseInt(h[1] + h[1], 16); const b = parseInt(h[2] + h[2], 16); if (isNaN(r) || isNaN(g) || isNaN(b)) return null; - return [r / 255, g / 255, b / 255]; + return { rgb: [r / 255, g / 255, b / 255] }; + } + + if (h.length === 4) { + const r = parseInt(h[0] + h[0], 16); + const g = parseInt(h[1] + h[1], 16); + const b = parseInt(h[2] + h[2], 16); + const a = parseInt(h[3] + h[3], 16); + if (isNaN(r) || isNaN(g) || isNaN(b) || isNaN(a)) return null; + return { rgb: [r / 255, g / 255, b / 255], alpha: a / 255 }; } if (h.length === 6) { @@ -525,7 +591,16 @@ export function parseHex(hex: string): [number, number, number] | null { const g = parseInt(h.slice(2, 4), 16); const b = parseInt(h.slice(4, 6), 16); if (isNaN(r) || isNaN(g) || isNaN(b)) return null; - return [r / 255, g / 255, b / 255]; + return { rgb: [r / 255, g / 255, b / 255] }; + } + + if (h.length === 8) { + const r = parseInt(h.slice(0, 2), 16); + const g = parseInt(h.slice(2, 4), 16); + const b = parseInt(h.slice(4, 6), 16); + const a = parseInt(h.slice(6, 8), 16); + if (isNaN(r) || isNaN(g) || isNaN(b) || isNaN(a)) return null; + return { rgb: [r / 255, g / 255, b / 255], alpha: a / 255 }; } return null; diff --git a/src/types.ts b/src/types.ts index 451516e..d691979 100644 --- a/src/types.ts +++ b/src/types.ts @@ -269,10 +269,18 @@ export interface GlazeThemeExport { /** Input for `glaze.shadow()` standalone factory. */ export interface GlazeShadowInput { - /** Background color — hex string or OKHSL { h, s (0-1), l (0-1) }. */ - bg: HexColor | OkhslColor; - /** Foreground color for tinting + intensity modulation. */ - fg?: HexColor | OkhslColor; + /** + * Background color — accepts any `GlazeColorValue` form: hex + * (`#rgb` / `#rrggbb` / `#rrggbbaa`), `rgb()` / `hsl()` / `okhsl()` + * / `oklch()` strings, an `OkhslColor` object, or an `[r, g, b]` + * (0–255) tuple. Alpha components are dropped with a warning. + */ + bg: GlazeColorValue; + /** + * Foreground color for tinting + intensity modulation. Accepts the + * same forms as `bg`. + */ + fg?: GlazeColorValue; /** Intensity 0-100. */ intensity: number; tuning?: ShadowTuning; @@ -282,13 +290,184 @@ export interface GlazeShadowInput { // Standalone color token // ============================================================================ -/** Input for `glaze.color()` standalone factory. */ +/** Input for the structured `glaze.color()` overload. */ export interface GlazeColorInput { hue: number; saturation: number; lightness: HCPair; saturationFactor?: number; mode?: AdaptationMode; + /** + * Fixed opacity (0–1). Output includes alpha in the CSS value. + * Combining with `contrast` is not recommended (perceived lightness + * becomes unpredictable) — a `console.warn` is emitted in that case. + */ + opacity?: number; + /** + * Optional dependency on another color. Same semantics as + * `GlazeColorOverrides.base` — `contrast` and relative `lightness` + * anchor to the base per scheme. + */ + base?: GlazeColorToken | GlazeColorValue; + /** + * WCAG contrast floor against `base`. Requires `base` to be set. + */ + contrast?: HCPair; + /** + * Optional human-readable name for the token. Used in error and + * warning messages (otherwise an internal name like `"value"` is + * used). Does not affect output keys. + */ + name?: string; +} + +/** + * Any single-color input form accepted by the value-shorthand + * overload of `glaze.color()`. + * + * Strings cover hex (`#rgb` / `#rrggbb` / `#rrggbbaa`, alpha dropped + * with a warning) and the four CSS color functions Glaze itself emits: + * `rgb()`, `hsl()`, `okhsl()`, `oklch()` (alpha components also dropped + * with a warning). + * + * The OKHSL object form `{ h, s, l }` matches Glaze's native shape + * (h: 0–360, s/l: 0–1). Passing 0–100 values for `s`/`l` throws with + * a hint to use the structured `{ hue, saturation, lightness }` form. + * + * The tuple form is `[r, g, b]` in 0–255, matching `glaze.fromRgb`'s + * range. Out-of-range or non-finite components throw. + */ +export type GlazeColorValue = + | string + | OkhslColor + | readonly [number, number, number]; + +/** Optional overrides for `glaze.color(value, overrides?)`. */ +export interface GlazeColorOverrides { + /** + * Override hue. Number is absolute (0–360); `'+N'`/`'-N'` is relative + * to the extracted (or overridden) seed hue — same semantics as + * `RegularColorDef.hue`. + */ + hue?: number | RelativeValue; + /** Override seed saturation (0–100). Default: extracted from value. */ + saturation?: number; + /** + * Override lightness. Number is absolute (0–100); `'+N'`/`'-N'` is + * relative to the literal seed (the value passed to `glaze.color()`). + * Supports HCPair for high-contrast. + */ + lightness?: HCPair; + /** Saturation multiplier on the seed (0–1). Default: 1. */ + saturationFactor?: number; + /** + * Adaptation mode. Defaults vary by input form: + * - String inputs (`'#1a1a1a'`, `'rgb(...)'`, etc.): `'auto'` (Möbius + * curve — pairs with the extended dark window to invert + * `#000` ↔ `#fff` between light and dark). + * - `OkhslColor` and `[r, g, b]` tuple inputs: `'fixed'` (linear, + * preserves light lightness exactly). + * + * Pass `'fixed'` explicitly to opt a string input back into the + * linear, non-inverting mapping; pass `'static'` to pin the same + * lightness across every variant. + */ + mode?: AdaptationMode; + + /** + * WCAG contrast floor. By default solved against the literal seed + * (the value itself); when `base` is set, solved against the base's + * resolved variant per scheme. Same shape as `RegularColorDef.contrast`. + */ + contrast?: HCPair; + + /** + * Optional dependency on another color. Accepts either a + * `GlazeColorToken` (returned by another `glaze.color()`) or a raw + * `GlazeColorValue` (hex / `rgb()` / `OkhslColor` / `[r, g, b]`), + * which is automatically wrapped in `glaze.color(value)`. + * + * When set: + * - `contrast` is solved against the base's resolved variant + * per-scheme (light / dark / lightContrast / darkContrast). + * - Relative `lightness: '+N'` / `'-N'` is anchored to the base's + * lightness per-scheme (matches theme behavior for dependent colors). + * - Relative `hue: '+N'` / `'-N'` still anchors to the seed (the + * value passed to `glaze.color()`), not the base. + * + * The base token's `.resolve()` is called lazily on first resolve and + * its result is captured by reference; later mutations to the base's + * defining call don't apply (matches existing token snapshot semantics). + */ + base?: GlazeColorToken | GlazeColorValue; + + /** + * Fixed opacity (0–1). Output includes alpha in the CSS value. + * Combining with `contrast` is not recommended (perceived lightness + * becomes unpredictable) — a `console.warn` is emitted in that case. + */ + opacity?: number; + + /** + * Optional human-readable name for the token. Used in error and + * warning messages (otherwise an internal name like `"value"` is + * used). Does not affect output keys. + */ + name?: string; +} + +/** + * Per-call lightness-window overrides for `glaze.color()`. Mirrors + * the field names from `GlazeConfig`. + * + * Defaults for `glaze.color()` vary by input form, and both fields are + * snapshotted from `globalConfig` at color-creation time so later + * `glaze.configure()` calls don't retroactively change already-created + * tokens (and `token.export()` round-trips byte-for-byte): + * + * - **String inputs** (`'#1a1a1a'`, `'rgb(...)'`, `'okhsl(...)'`, ...): + * - `lightLightness: false` — preserve input exactly. + * - `darkLightness: [globalConfig.darkLightness[0], 100]` — extended + * dark window so the auto-mode dark variant can Möbius-invert all + * the way up to white. + * + * - **`OkhslColor` / `[r, g, b]` tuple / structured inputs**: + * - `lightLightness: false` — preserve input exactly. + * - `darkLightness: globalConfig.darkLightness` — same window + * theme colors use, snapshotted at create time. + * + * Passing this object replaces both fields at once. To keep one + * field's default while overriding the other, restate the default + * explicitly. + */ +export interface GlazeColorScaling { + /** Light-mode lightness window. `false` (default) preserves input. */ + lightLightness?: false | [number, number]; + /** + * Dark-mode lightness window. Snapshotted from `globalConfig` at + * create time: extended `[globalConfig.darkLightness[0], 100]` for + * string inputs, plain `globalConfig.darkLightness` for object / + * tuple / structured inputs. Pass `false` to preserve input + * lightness in dark mode too. + */ + darkLightness?: false | [number, number]; +} + +/** Options for `GlazeColorToken.css()`. */ +export interface GlazeColorCssOptions { + /** + * Custom property base name (without leading `--`). Required. + * Becomes the variable identifier in the output, e.g. + * `name: 'brand'` → `--brand-color: …`. + */ + name: string; + /** Output color format. Default: 'rgb' (matches `theme.css` default). */ + format?: GlazeColorFormat; + /** + * Suffix appended to the name. Default: '-color' (matches + * `theme.css` default). + */ + suffix?: string; } /** Return type for `glaze.color()`. */ @@ -305,6 +484,69 @@ export interface GlazeColorToken { tasty(options?: GlazeTokenOptions): Record; /** Export as a flat JSON map (no color name key). */ json(options?: GlazeJsonOptions): Record; + /** Export as CSS custom property declarations grouped by scheme variant. */ + css(options: GlazeColorCssOptions): GlazeCssResult; + /** + * Serialize the token as a JSON-safe object. Captures the original + * input value, overrides, and scaling so it can be rehydrated via + * `glaze.colorFrom(...)`. `base` is recursively serialized. + */ + export(): GlazeColorTokenExport; +} + +/** + * JSON-safe serialization of a `glaze.color()` token. Pass to + * `glaze.colorFrom(...)` to rehydrate. + */ +export interface GlazeColorTokenExport { + /** + * Discriminator for the source overload that created the token. + * - `'value'`: created via `glaze.color(value, overrides?, scaling?)`. + * - `'structured'`: created via `glaze.color({ hue, saturation, ... }, scaling?)`. + */ + form: 'value' | 'structured'; + /** Original input. For `form: 'value'` this is the raw `GlazeColorValue`; for `form: 'structured'` this is the structured input. */ + input: GlazeColorValue | GlazeColorInputExport; + /** + * Overrides recorded at creation time. `base` is recursively + * serialized. Only present for `form: 'value'`. + */ + overrides?: GlazeColorOverridesExport; + /** Lightness scaling override, if any. */ + scaling?: GlazeColorScaling; +} + +/** + * Serializable shape of a structured `glaze.color({...})` input. + * Differs from `GlazeColorInput` only in that `base` is replaced by an + * `export` instead of a token reference. + */ +export interface GlazeColorInputExport { + hue: number; + saturation: number; + lightness: HCPair; + saturationFactor?: number; + mode?: AdaptationMode; + opacity?: number; + base?: GlazeColorTokenExport | GlazeColorValue; + contrast?: HCPair; + name?: string; +} + +/** + * Serializable shape of `GlazeColorOverrides`. `base` is replaced by + * its export (or left as a `GlazeColorValue` if it was originally a value). + */ +export interface GlazeColorOverridesExport { + hue?: number | RelativeValue; + saturation?: number; + lightness?: HCPair; + saturationFactor?: number; + mode?: AdaptationMode; + contrast?: HCPair; + base?: GlazeColorTokenExport | GlazeColorValue; + opacity?: number; + name?: string; } // ============================================================================