diff --git a/.changeset/dtcg-resolver-module.md b/.changeset/dtcg-resolver-module.md new file mode 100644 index 0000000..bc72f96 --- /dev/null +++ b/.changeset/dtcg-resolver-module.md @@ -0,0 +1,10 @@ +--- +'@tenphi/glaze': minor +--- + +Add opt-in DTCG Resolver-Module export (`dtcgResolver()`) + +- **New export.** `theme.dtcgResolver()`, `palette.dtcgResolver()`, and `glaze.color().dtcgResolver()` emit a single W3C DTCG Resolver-Module document describing every scheme variant in one file — `sets` (the light tokens as the default source) plus a single `scheme` modifier with a context per variant (`light` / `dark` / `lightContrast` / `darkContrast`) and a `resolutionOrder`. An alternative to `dtcg()`'s per-scheme files for resolver tools such as Dispersa. +- **Why one modifier.** Glaze resolves `darkContrast` independently (it is not `dark` + `lightContrast` layered), so the four-context shape keeps every resolved value exact. Two independent modifiers would compose additively and produce wrong dark + high-contrast values. +- **Options.** `GlazeDtcgResolverOptions` extends `GlazeDtcgOptions` (`modes` + `colorSpace` pass through) with `setName` (default `'base'`), `modifierName` (default `'scheme'`), `contextNames` (rename the four contexts), and `version` (default `'2025.10'`). Standalone `glaze.color().dtcgResolver()` requires `name`. +- New public types: `GlazeDtcgResolverDocument`, `GlazeDtcgResolverOptions`, `GlazeColorDtcgResolverOptions`, `DtcgTokenTree`, `DtcgResolverSet`, `DtcgResolverModifier`, `DtcgResolverRef`. diff --git a/.changeset/dtcg-tailwind-exports.md b/.changeset/dtcg-tailwind-exports.md new file mode 100644 index 0000000..3202156 --- /dev/null +++ b/.changeset/dtcg-tailwind-exports.md @@ -0,0 +1,11 @@ +--- +'@tenphi/glaze': minor +--- + +Add DTCG and Tailwind CSS v4 token exports + +- **`dtcg()`** — W3C [Design Tokens Format Module (2025.10)](https://www.designtokens.org/) export. Available on themes, palettes, and standalone `glaze.color()` tokens. Returns one spec-conformant token document per scheme variant (`light` / `dark` / `lightContrast` / `darkContrast``), each a `{ name: { $type: 'color', $value } }` tree consumable by Figma, Tokens Studio, Style Dictionary v4+, Terrazzo, Penpot, and every DTCG-compatible tool. The `colorSpace` option selects the `$value` representation: `'srgb'` (default — gamma sRGB `components` in 0–1 plus a `hex` hint) or `'oklch'` (Glaze-native, wide-gamut `[L, C, H]`, no hex). `alpha` is emitted only when opacity is below 1. One document per scheme is the most tool-compatible convention (one file per Style Dictionary theme / Tokens Studio set / Figma variable mode). +- **`tailwind()`** — Tailwind CSS v4 export. Returns a single ready-to-paste CSS string: an `@theme` block (light baseline) plus dark / high-contrast overrides under configurable selectors. The `--color-*` namespace (configurable via `namespace`) auto-generates `bg-*` / `text-*` / `border-*` utilities. `darkSelector` (default `.dark`) accepts an at-rule like `'@media (prefers-color-scheme: dark)'` (nests `:root` automatically); `highContrastSelector` (default `.high-contrast`) covers the HC variants, with the combined block at `${darkSelector}${highContrastSelector}`. Default `format` is `'oklch'`. +- **New types** exported from the package entry: `DtcgColorSpace`, `DtcgSrgbColorValue`, `DtcgOklchColorValue`, `DtcgColorValue`, `DtcgColorToken`, `DtcgDocument`, `GlazeDtcgResult`, `GlazeColorDtcgResult`, `GlazeDtcgOptions`, `GlazeTailwindOptions`, `GlazeColorTailwindOptions`. +- **New color-math helpers** exported for advanced use: `srgbToHex(rgb)` (sRGB 0–1 → `#rrggbb`) and `okhslToOklch(h, s, l, pastel?)` (OKHSL → `[L, C, H]`), shared by `formatOklch` and the DTCG exporter. +- Both new exports honor `modes` (dark / high-contrast gating) and the palette `prefix` / `primary` options. On `palette.tailwind()`, the palette theme-prefix `prefix` is separate from `GlazeTailwindOptions.namespace` (the `--color-*` CSS namespace). diff --git a/.changeset/oklch-hue-splitting-okhst.md b/.changeset/oklch-hue-splitting-okhst.md new file mode 100644 index 0000000..43f0074 --- /dev/null +++ b/.changeset/oklch-hue-splitting-okhst.md @@ -0,0 +1,10 @@ +--- +'@tenphi/glaze': minor +--- + +feat+breaking: oklch hue channel splitting (pastel-only); add okhst tasty-only output; okhsl/okhst are tasty-only; tokens/json default to oklch + +- Add `splitHue` on `css()` / `tasty()` (theme + palette) and standalone `color.css()` — emits hue as a separate custom property referenced via `var()` in `oklch` values. Requires every exported color to be pastel. +- Add `'okhst'` output format (`okhst(H S% T%)`) for Tasty exports. +- `okhsl` and `okhst` throw on non-Tasty exports (`css`, `tailwind`, `tokens`, `json`). +- `tokens()` / `json()` default format changes from `okhsl` to `oklch` (theme, palette, standalone `.json()`). diff --git a/.changeset/role-aware-apca.md b/.changeset/role-aware-apca.md new file mode 100644 index 0000000..66550cd --- /dev/null +++ b/.changeset/role-aware-apca.md @@ -0,0 +1,10 @@ +--- +'@tenphi/glaze': minor +--- + +Add semantic color roles with APCA polarity and APCA presets + +- **Roles.** Colors now carry a semantic `role` (`'text'` | `'surface'` | `'border'`, with aliases like `bg`/`fg`/`divider`/`outline`/`fill`/`ink`/…). The role fixes **APCA contrast polarity** — which side is the foreground vs the background — so the APCA solver uses the correct argument order instead of always treating the resolved color as text. WCAG is symmetric and unaffected. +- **Role inference.** Roles are inferred from the color name by default (`inferRole: true`), with the last recognized token winning (`button-text` → `text`, `input-bg` → `surface`, `card-outline` → `border`). When a name doesn't infer, the opposite of the base's role is used; otherwise the color defaults to `text` (foreground), preserving previous behavior. Set `glaze.configure({ inferRole: false })` to opt out of name inference. +- **APCA presets.** APCA targets accept named Bronze Simple Mode presets: `'preferred'` (Lc 90), `'body'` (75), `'content'` (60, ~AA), `'large'` (45, ~3:1), `'non-text'` (30), `'min'` (15). Use anywhere an APCA target is accepted, e.g. `contrast: { apca: 'content' }` or `contrast: { apca: ['content', 'body'] }`. Presets are role-independent. +- `role` is also available on `MixColorDef` and standalone `glaze.color()` inputs and survives the `export()` / `glaze.colorFrom()` round-trip. diff --git a/README.md b/README.md index 79d419b..510d2be 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,9 @@ Glaze generates robust **light**, **dark**, and **high-contrast** color schemes - **Per-color hue override** — absolute or relative hue shifts within a theme - **Multi-format output** — `okhsl`, `rgb`, `hsl`, `oklch` with modern CSS space syntax - **CSS custom properties export** — ready-to-use `--var: value;` declarations per scheme +- **W3C DTCG export** — spec-conformant `.tokens.json` (2025.10) for Figma, Tokens Studio, Style Dictionary, and every DTCG tool +- **W3C DTCG Resolver-Module export** — opt-in single-document `dtcgResolver()` (sets + a `scheme` modifier with a context per variant) for resolver tools such as Dispersa +- **Tailwind CSS v4 export** — `@theme` block + dark / high-contrast overrides - **Import/Export** — serialize and restore theme configurations - **Create from hex/RGB** — start from an existing brand color - **Zero dependencies** — pure math, runs anywhere (Node.js, browser, edge) diff --git a/docs/api.md b/docs/api.md index 032cae7..8b8bd0d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -115,14 +115,14 @@ Flat token map grouped by scheme variant. ```ts theme.tokens() -// → { light: { surface: 'okhsl(...)' }, dark: { surface: 'okhsl(...)' } } +// → { light: { surface: 'oklch(...)' }, dark: { surface: 'oklch(...)' } } ``` `GlazeJsonOptions`: | Option | Default | Description | |---|---|---| -| `format` | `'okhsl'` | Output color format. One of `'okhsl' \| 'rgb' \| 'hsl' \| 'oklch'`. | +| `format` | `'oklch'` | Output color format. One of `'rgb' \| 'hsl' \| 'oklch'`. `'okhsl'` and `'okhst'` throw — use `tasty()` for those. | | `modes` | `{ dark: true, highContrast: false }` (or global config) | Which scheme variants to include. | ### `theme.tasty(options?)` @@ -141,10 +141,12 @@ theme.tasty() | Option | Default | Description | |---|---|---| -| `format` | `'okhsl'` | Output color format. | +| `format` | `'okhsl'` | Output color format. `'okhsl'` and `'okhst'` are supported here (Tasty-only spaces). | | `modes` | global config | Which scheme variants to include. | | `states.dark` | `'@dark'` (or global config) | State alias for dark mode tokens. | | `states.highContrast` | `'@high-contrast'` (or global config) | State alias for high-contrast tokens. | +| `splitHue` | `false` | Emit hue as a separate custom property (`#name-hue` token + `var()` in `oklch` values). Requires `format: 'oklch'` and every color to be pastel. | +| `name` | `'theme'` | Base name for the theme-level hue var (`#theme-hue` / `--theme-hue`). Palette export auto-derives this from the theme name. | | `prefix` | (palette only) | See [Palette](#palette). | When both `dark` and `highContrast` modes are enabled, dark high-contrast variants are emitted under the combined key ` & ` (e.g. `'@dark & @high-contrast'`). @@ -156,8 +158,8 @@ Per-color JSON map. ```ts theme.json() // → { -// surface: { light: 'okhsl(...)', dark: 'okhsl(...)' }, -// text: { light: 'okhsl(...)', dark: 'okhsl(...)' }, +// surface: { light: 'oklch(...)', dark: 'oklch(...)' }, +// text: { light: 'oklch(...)', dark: 'oklch(...)' }, // } ``` @@ -181,11 +183,137 @@ theme.css(); | Option | Default | Description | |---|---|---| -| `format` | `'rgb'` | Output color format. | +| `format` | `'rgb'` | Output color format. `'okhsl'` and `'okhst'` throw — use `tasty()` for those. | | `suffix` | `'-color'` | Suffix appended to each CSS property name. Pass `''` for bare property names. | +| `splitHue` | `false` | Emit hue as a separate `--*-hue` custom property referenced via `var()` in `oklch` color values. Requires `format: 'oklch'` and every color to be pastel. Shadow/mix colors stay inline (blended hue; they do not follow `--hue` rotation). | +| `name` | `'theme'` | Base name for the theme-level hue var (`--theme-hue`). Palette export auto-derives this from the theme name. | `GlazeCssResult` always contains all four keys (`light`, `dark`, `lightContrast`, `darkContrast`); empty if no colors are defined for that variant. +### `theme.dtcg(options?)` + +W3C [Design Tokens Format Module (2025.10)](https://www.designtokens.org/) documents — the vendor-neutral JSON format consumed by Figma, Tokens Studio, Style Dictionary v4+, Terrazzo, Penpot, and every DTCG-compatible tool. Returns one spec-conformant token tree per scheme variant. + +```ts +theme.dtcg() +// → { +// light: { +// surface: { +// $type: 'color', +// $value: { colorSpace: 'srgb', components: [0.96, 0.94, 0.98], hex: '#f5f0fa' }, +// }, +// }, +// dark: { +// surface: { +// $type: 'color', +// $value: { colorSpace: 'srgb', components: [0.16, 0.14, 0.2], hex: '#292333' }, +// }, +// }, +// } +``` + +Write each document to its own `.tokens.json` file — one file per scheme is the most tool-compatible convention (one per Style Dictionary theme / Tokens Studio set / Figma variable mode). + +`GlazeDtcgOptions`: + +| Option | Default | Description | +|---|---|---| +| `colorSpace` | `'srgb'` | Color space for `$value`. `'srgb'` emits gamma sRGB `components` (0–1) plus a `hex` hint — universally understood. `'oklch'` emits `[L, C, H]` components with no hex — Glaze-native, wide-gamut. | +| `modes` | global config | Which scheme variants to include. `light` is always present. | + +`alpha` is included on `$value` only when the color's opacity is below 1. `$type` is always `'color'`. + +### `theme.dtcgResolver(options?)` + +A single W3C [DTCG Resolver-Module](https://www.designtokens.org/) document describing **every scheme variant in one file** — an alternative to `dtcg()`'s per-scheme files for tools that resolve sets + modifiers (e.g. Dispersa). The light document becomes `sets.base.sources[0]` (the default context); each other variant becomes a context override on a single `scheme` modifier. + +```ts +theme.dtcgResolver({ modes: { highContrast: true } }) +// → { +// version: '2025.10', +// sets: { +// base: { +// sources: [ +// { +// surface: { +// $type: 'color', +// $value: { colorSpace: 'srgb', components: [0.96, 0.94, 0.98], hex: '#f5f0fa' }, +// }, +// }, +// ], +// }, +// }, +// modifiers: { +// scheme: { +// default: 'light', +// contexts: { +// light: [], +// dark: [ +// { +// surface: { +// $type: 'color', +// $value: { colorSpace: 'srgb', components: [0.16, 0.14, 0.2], hex: '#292333' }, +// }, +// }, +// ], +// lightContrast: [ /* … */ ], +// darkContrast: [ /* … */ ], +// }, +// }, +// }, +// resolutionOrder: [ +// { $ref: '#/sets/base' }, +// { $ref: '#/modifiers/scheme' }, +// ], +// } +``` + +**Why one modifier with four contexts.** Glaze resolves `darkContrast` independently — it is not `dark` + `lightContrast` layered. The resolver model composes modifiers additively (last in `resolutionOrder` wins on conflict), so two independent modifiers (`scheme` × `contrast`) would produce wrong values for the dark + high-contrast permutation. One `scheme` modifier with a context per variant keeps every resolved value exact. Choose `dtcgResolver()` when you want single-file theming and feed it to a resolver tool; choose `dtcg()` for maximum per-file tool compatibility. + +`GlazeDtcgResolverOptions` (extends `GlazeDtcgOptions`, so `modes` and `colorSpace` pass through): + +| Option | Default | Description | +|---|---|---| +| `colorSpace` | `'srgb'` | Same as `dtcg()` — flows through to every source and context. | +| `modes` | global config | Which scheme variants to emit as contexts. `light` is always present (the default); absent variants are omitted. | +| `setName` | `'base'` | Name of the single set holding the default (light) token tree. | +| `modifierName` | `'scheme'` | Name of the modifier describing the scheme axis. | +| `contextNames` | identity | Override the four context names (`light` / `dark` / `lightContrast` / `darkContrast`) — e.g. `{ dark: 'night' }`. | +| `version` | `'2025.10'` | Resolver document version. | + +### `theme.tailwind(options?)` + +A Tailwind CSS v4 `@theme` block (light baseline) plus dark / high-contrast overrides under configurable selectors. Returns a single ready-to-paste CSS string. The `--color-*` namespace auto-generates `bg-*` / `text-*` / `border-*` utilities. + +```css +@theme { + --color-surface: oklch(0.96 0.01 280); + --color-text: oklch(0.3 0.05 280); +} +.dark { + --color-surface: oklch(0.16 0.01 280); + --color-text: oklch(0.85 0.05 280); +} +.high-contrast { + --color-surface: oklch(0.98 0.01 280); + --color-text: oklch(0.1 0.05 280); +} +.dark.high-contrast { + --color-surface: oklch(0.05 0.01 280); + --color-text: oklch(0.95 0.05 280); +} +``` + +`GlazeTailwindOptions`: + +| Option | Default | Description | +|---|---|---| +| `format` | `'oklch'` | Output color format for the values. | +| `namespace` | `'color-'` | CSS custom property namespace, forming `--` (e.g. `--color-surface`). Named `namespace` to avoid clashing with the palette theme-prefix option. | +| `darkSelector` | `'.dark'` | Selector wrapping the dark overrides. Pass an at-rule like `'@media (prefers-color-scheme: dark)'` to drive dark mode from the OS preference (it nests `:root` automatically). | +| `highContrastSelector` | `'.high-contrast'` | Selector wrapping the light high-contrast overrides. The combined dark + high-contrast block uses `${darkSelector}${highContrastSelector}` (e.g. `.dark.high-contrast`). | +| `modes` | global config | Which scheme variants to include. The `@theme` block (light) is always emitted when colors exist. | + ### `theme.export()` ```ts @@ -220,6 +348,7 @@ type ColorDef = RegularColorDef | ShadowColorDef | MixColorDef; | `flip` | `boolean` | Flip out-of-bounds results (relative `tone` overshoot / unmet `contrast`) to the opposite side instead of clamping. Default: the global `autoFlip` (`true`). See [`flip`](#flip). | | `opacity` | `number` | Fixed alpha 0–1. Output includes alpha in the CSS value. Combining with `contrast` is not recommended (a `console.warn` is emitted). | | `pastel` | `boolean` | Per-color override for the hue-independent "safe" chroma limit used in OKHSL↔sRGB conversions (luminance, contrast solving, output formatting). Falls through to the global / per-theme `pastel` config when omitted. Default: unset. See [Per-color `pastel`](#per-color-pastel). | +| `role` | `RoleInput` | Semantic role against `base` (`'text'` / `'surface'` / `'border'` or an alias). Fixes APCA contrast polarity. Resolved via: explicit `role` → name inference → opposite of the base's role → `'text'`. See [Roles](#roles). | | `inherit` | `boolean` | Whether this color is inherited by child themes via `extend()`. Default: `true`. Set to `false` to make the color local to the current theme. | #### Tone values @@ -277,8 +406,15 @@ contrast: { wcag: 6 } // WCAG 6 contrast: { wcag: [4.5, 7] } // WCAG 4.5 normal / 7 high-contrast contrast: { apca: 60 } // APCA Lc 60 contrast: { apca: [45, 60] } // APCA Lc 45 normal / 60 high-contrast +contrast: { apca: 'content' } // APCA preset -> Lc 60 +contrast: { apca: ['content', 'body'] } // Lc 60 normal / 75 high-contrast ``` +APCA preset keywords (Bronze Simple Mode conformance levels, role-independent): +`'preferred'` (Lc 90), `'body'` (75), `'content'` (60, ~AA), `'large'` (45, ~3:1), +`'non-text'` (30), `'min'` (15, point of invisibility). See +[`docs/okhst.md`](okhst.md) §APCA. + The floor is applied independently per scheme — if the `tone` already satisfies it the tone is kept, otherwise the solver searches in tone (contrast-uniform → a closed-form WCAG seed and fast convergence) until the target is met. By default, the solver crosses to the opposite side of the base color when the requested tone direction cannot satisfy the floor. This is controlled per-color by [`flip`](#flip) (which defaults to the global `autoFlip`). Set `glaze.configure({ autoFlip: false })` — or `flip: false` on a single color — to keep strict directionality: unmet colors pin to that direction's 0 or 100 tone extreme instead of falling back to the original requested value. @@ -332,6 +468,36 @@ const child = parent.extend({ > > Standalone `glaze.color()` tokens accept the same `pastel` field on both the structured (`GlazeColorInput`) and value-shorthand (`GlazeColorOverrides`) forms, and it survives the `export()` / `glaze.colorFrom()` round-trip. +#### Roles + +A color's `role` describes how it is used against its `base` and fixes **APCA contrast polarity** — which side is the foreground vs the background. APCA is asymmetric (`|apca(a,b)| ≠ |apca(b,a)|`), so the role picks the correct argument order; WCAG is symmetric and unaffected. + +| Role | Polarity | Use | Aliases (name inference) | +|---|---|---|---| +| `'text'` | fg | Text / icons / foreground content | `text`, `fg`, `foreground`, `content`, `ink`, `label`, `stroke` | +| `'border'` | fg | Non-text spot elements (borders, dividers, outlines) | `border`, `divider`, `outline`, `separator`, `hairline`, `rule` | +| `'surface'` | bg | Backgrounds / fills | `surface`, `bg`, `background`, `fill`, `canvas`, `paper`, `layer` | + +Resolution chain (per color): + +1. Explicit `role` (normalized from an alias) wins. +2. Else, when `inferRole` is enabled (default), infer from the color name — the **last** recognized token wins (`button-text` → `text`, `input-bg` → `surface`, `card-outline` → `border`). +3. Else, the opposite of the base's role (a `surface` base ⇒ this is `text`). +4. Else, `'text'` (foreground) — i.e. the base is treated as the background. + +```ts +const theme = glaze(280, 60); +theme.colors({ + surface: { tone: 90 }, + text: { base: 'surface', contrast: { apca: 'content' } }, // inferred text + border: { base: 'surface', tone: '-10' }, // inferred border +}); +// role fixes APCA polarity; set `pastel: true` explicitly if a border +// needs the hue-independent safe chroma limit. +``` + +Disable name inference with `glaze.configure({ inferRole: false })` (the base-opposite and foreground-default fallbacks still apply). + ### `ShadowColorDef` | Field | Type | Description | @@ -358,6 +524,7 @@ See [Shadows](#shadows) below for the algorithm and tuning details. | `space` | `'okhsl' \| 'srgb'` | Interpolation space for opaque blending. Default `'okhsl'`. Ignored for `'transparent'` (always composites in linear sRGB). | | `contrast` | `HCPair` | Optional contrast floor against `base` (WCAG or APCA — see [`contrast`](#contrast-floor)). The solver adjusts the mix ratio (opaque) or opacity (transparent). | | `pastel` | `boolean` | Per-color `pastel` override. See [Per-color `pastel`](#per-color-pastel). | +| `role` | `RoleInput` | Semantic role of the mixed result against `base`. Same semantics as `RegularColorDef.role` (see [Roles](#roles)). | | `inherit` | `boolean` | Inheritance flag, default `true`. | See [Mix colors](#mix-colors) below. @@ -414,6 +581,7 @@ glaze.color(color: GlazeFromInput | GlazeColorInput | GlazeColorValue, config?: | `base` | `GlazeColorToken \| GlazeColorValue` | Optional dependency. See [Pairing colors](#pairing-colors). | | `contrast` | `HCPair` | Contrast floor against `base` (WCAG or APCA). Without `base`, anchored to the literal seed. | | `pastel` | `boolean` | Per-color `pastel` override. Falls through to the global / per-theme `pastel` config when omitted. See [Per-color `pastel`](#per-color-pastel). | +| `role` | `RoleInput` | Semantic role against `base` / the seed (see [Roles](#roles)). Fixes APCA polarity. | | `name` | `string` | Debug label for warnings; doesn't change output keys. Reserved names (`'value'`, `'seed'`, `'externalBase'`) are rejected. | `GlazeFromInput` (from form) is `{ from: GlazeColorValue, ...colorOverrides }`: @@ -431,6 +599,7 @@ glaze.color(color: GlazeFromInput | GlazeColorInput | GlazeColorValue, config?: | `base` | `GlazeColorToken` or raw `GlazeColorValue`. See [Pairing colors](#pairing-colors). | | `opacity` | Fixed alpha 0–1. Combining with `contrast` is not recommended — `console.warn` is emitted. | | `pastel` | Per-color `pastel` override. Falls through to the global / per-theme `pastel` config when omitted. See [Per-color `pastel`](#per-color-pastel). | +| `role` | Semantic role against `base` / the seed (see [Roles](#roles)). Fixes APCA polarity. | | `name` | Debug label only — surfaces in warnings/errors. Does not change output keys. | Named CSS colors (`'red'`, `'blueviolet'`) are not supported. @@ -471,9 +640,12 @@ A `GlazeColorToken` exposes: |---|---| | `token.resolve()` | Resolve as a `ResolvedColor` (light/dark/lightContrast/darkContrast variants). | | `token.token(options?)` | Flat token map (no color-name key). Options: `format`, `modes`, `states`. | -| `token.tasty(options?)` | Tasty state map (no color-name key). Same options as `token.token`. | +| `token.tasty(options?)` | [Tasty](https://tasty.style) state map (no color-name key). Same options as `token.token`. | | `token.json(options?)` | JSON map (no color-name key). Options: `format`, `modes`. | | `token.css({ name, format?, suffix? })` | CSS custom property declarations grouped by scheme variant. `name` is **required** and becomes the variable identifier (`'brand'` → `--brand-color`). Defaults: `format: 'rgb'`, `suffix: '-color'` (matches `theme.css`). | +| `token.dtcg(options?)` | DTCG color tokens, one per scheme variant (no color-name key). Each entry is a full `{ $type: 'color', $value }` token. Options: `colorSpace` (`'srgb'` \| `'oklch'`), `modes`. | +| `token.dtcgResolver({ name, ... })` | A single DTCG Resolver-Module document for this color, keyed by `name` across all scheme variants. `name` is **required**. Same options as `theme.dtcgResolver()` plus `name`. | +| `token.tailwind({ name, ... })` | Tailwind v4 `@theme` block + dark / high-contrast overrides for this color. `name` is **required** (forms `--color-`). Same options as `theme.tailwind()` plus `name`. | | `token.export()` | JSON-safe snapshot — pass to `glaze.colorFrom(...)` to rehydrate. | ### Per-instance config override @@ -922,6 +1094,56 @@ const stylesheet = ` `palette.css()` accepts the same `GlazeCssOptions` as `theme.css()` plus `GlazePaletteExportOptions`. It does not accept `modes`; all four result fields are always returned. +### `palette.dtcg()` + +DTCG export for a palette. Prefix defaults to `true` and the palette-level `primary` is honored (the primary theme's tokens are duplicated without prefix as aliases). + +```ts +palette.dtcg() +// → { +// light: { +// 'primary-surface': { $type: 'color', $value: { ... } }, +// 'surface': { $type: 'color', $value: { ... } }, // unprefixed alias +// 'danger-surface': { $type: 'color', $value: { ... } }, +// }, +// dark: { ... }, +// } +``` + +Accepts `GlazeDtcgOptions` plus `GlazePaletteExportOptions`. + +### `palette.dtcgResolver()` + +Resolver-Module export for a palette. Same as `theme.dtcgResolver()` but merges every theme (with prefix / `primary` aliasing) into the single `sets.base` source and each `scheme` context. Prefix defaults to `true`; the palette-level `primary` is honored. + +```ts +palette.dtcgResolver() +// → { +// version: '2025.10', +// sets: { base: { sources: [ { 'primary-surface': {…}, 'surface': {…}, 'danger-surface': {…} } ] } }, +// modifiers: { scheme: { default: 'light', contexts: { light: [], dark: [ {…} ] } } }, +// resolutionOrder: [ { $ref: '#/sets/base' }, { $ref: '#/modifiers/scheme' } ], +// } +``` + +Accepts `GlazeDtcgResolverOptions` plus `GlazePaletteExportOptions`. + +### `palette.tailwind()` + +Tailwind export for a palette. All themes are merged into a single `@theme` block (plus dark / high-contrast overrides), so each color is reachable as a Tailwind utility. Prefix defaults to `true`. + +```ts +const css = palette.tailwind(); +// @theme { +// --color-primary-surface: oklch(...); +// --color-surface: oklch(...); /* unprefixed alias */ +// --color-danger-surface: oklch(...); +// } +// .dark { ... } +``` + +Accepts `GlazeTailwindOptions` plus `GlazePaletteExportOptions`. The palette `prefix` option (theme prefixing) is separate from `GlazeTailwindOptions.namespace` (the `--color-*` CSS namespace). + --- ## Output formats @@ -930,21 +1152,40 @@ Control the color format with the `format` option on any export method: | Format | Output (alpha = 1) | Output (alpha < 1) | Notes | |---|---|---|---| -| `'okhsl'` (default for tokens/tasty/json) | `okhsl(H S% L%)` | `okhsl(H S% L% / A)` | Glaze's native format, not a CSS function. | -| `'rgb'` (default for css) | `rgb(R G B)` | `rgb(R G B / A)` | Rounded integers, modern space syntax. | +| `'okhsl'` (default for `tasty()`) | `okhsl(H S% L%)` | `okhsl(H S% L% / A)` | Glaze's native format, not a CSS function. **Tasty-only** (`tasty()`, `token()`, `.tasty()`). | +| `'okhst'` | `okhst(H S% T%)` | `okhst(H S% T% / A)` | OKHST tone axis. **Tasty-only** — same restriction as `okhsl`. | +| `'oklch'` (default for `tokens()` / `json()`) | `oklch(L C H)` | `oklch(L C H / A)` | OKLab-based LCH. Native CSS. Required for `splitHue`. | +| `'rgb'` (default for `css()`) | `rgb(R G B)` | `rgb(R G B / A)` | Rounded integers, modern space syntax. | | `'hsl'` | `hsl(H S% L%)` | `hsl(H S% L% / A)` | Modern space syntax. | -| `'oklch'` | `oklch(L C H)` | `oklch(L C H / A)` | OKLab-based LCH. | ```ts -theme.tokens(); // 'okhsl(280 60% 97%)' +theme.tokens(); // 'oklch(0.965 0.0123 280)' (default) theme.tokens({ format: 'rgb' }); // 'rgb(244 240 250)' -theme.tokens({ format: 'hsl' }); // 'hsl(270.5 45.2% 95.8%)' -theme.tokens({ format: 'oklch' }); // 'oklch(0.965 0.0123 280)' +theme.tasty(); // 'okhsl(280 60% 97%)' (default) +theme.tasty({ format: 'okhst' }); // 'okhst(280 60% 97%)' ``` All numeric output strips trailing zeros for cleaner CSS (e.g. `95` not `95.0`). -The `format` option works on every export: `theme.tokens()`, `theme.tasty()`, `theme.json()`, `theme.css()`, the same on `palette`, and on `token.token()` / `.tasty()` / `.json()` / `.css()`. +The `format` option works on CSS-string exports: `theme.tokens()`, `theme.tasty()`, `theme.json()`, `theme.css()`, `theme.tailwind()`, the same on `palette`, and on `token.token()` / `.tasty()` / `.json()` / `.css()` / `.tailwind()`. **`okhsl` and `okhst` throw on non-Tasty exports** (`tokens`, `json`, `css`, `tailwind`) — they are not native CSS color spaces. + +### Hue channel splitting (`splitHue`) + +On `theme.css()`, `theme.tasty()`, `palette.css()`, `palette.tasty()`, and standalone `color.css()` with `format: 'oklch'`, set `splitHue: true` to emit hue as its own custom property so consumers can re-skin at runtime: + +```css +/* theme.css({ format: 'oklch', splitHue: true, name: 'brand' }) */ +--brand-hue: 240; +--accent-hue: calc(var(--brand-hue) + 20); +--surface-color: oklch(0.52 0.06 var(--brand-hue)); +--accent-color: oklch(0.62 0.03 var(--accent-hue)); +``` + +**Requirements:** every exported color must be pastel (`pastel: true` globally or per-color). Pastel mode bounds chroma by the hue-independent safe chroma at each lightness, so emitted `C` stays in sRGB for any rotated hue. Non-pastel palettes throw rather than emit values that would clip under rotation. + +**Limitations:** `oklch` only (native CSS `var()` in the hue slot). Shadow and mix colors stay inline (blended hue). Standalone `.token()` / `.tasty()` do not support `splitHue` (return shape cannot carry the `#name-hue` declaration). + +`theme.dtcg()` / `theme.dtcgResolver()` / `palette.dtcg()` / `palette.dtcgResolver()` ignore `format` — DTCG emits structured `$value` objects, not CSS strings. Use the `colorSpace` option (`'srgb'` or `'oklch'`) to pick the color representation instead. --- @@ -1064,6 +1305,8 @@ A `ToneWindow` is `[lo, hi]` (OKHSL-lightness endpoints, reference eps — the c | `modes.highContrast` | `false` | Include HC variants. | | `shadowTuning` | `undefined` | Default tuning for all shadow colors. Per-color tuning merges field-by-field. | | `autoFlip` | `true` | Default for each color's `flip`. When solving `contrast` (or applying a relative `tone` that overshoots `[0, 100]`), allow crossing to the opposite side instead of clamping. With `false`, only the requested direction is considered; unmet contrasts pin the tone to that direction's extreme (and emit a warning) and overshooting offsets clamp to the boundary. Override per color via [`flip`](#flip). | +| `pastel` | `false` | Hue-independent "safe" chroma limit across all colors so scaling saturation never exceeds the sRGB boundary at any hue for the given lightness. Override per color via [`pastel`](#per-color-pastel). | +| `inferRole` | `true` | Infer each color's [`role`](#roles) from its name when no explicit `role` is set. Set to `false` to opt out of name-based inference (the base-opposite and foreground-default fallbacks still apply). | | Method | Description | |---|---| diff --git a/docs/methodology.md b/docs/methodology.md index f612b98..9a70ce7 100644 --- a/docs/methodology.md +++ b/docs/methodology.md @@ -1,346 +1,561 @@ # Methodology -A practical guide for designing a real, production-grade Glaze palette. Recipe-ordered: the sections follow the same sequence you'd actually build a palette in, and each one ties the choice back to a Glaze behavior. +A practical way to design a Glaze palette without fighting dark mode. Start from +tone relationships, add contrast only where a role needs a readable floor, and +let `extend()` carry the same decisions across status hues. -## The mental model - -A Glaze palette is a **default neutral theme** plus a small fan of **colored sibling themes** (`success`, `danger`, `warning`, `note`, …) created via `extend()`. Most colors live in the default theme as neutrals; brand-tinted colors come from `extend()` swapping the hue. - -The default theme is what most components consume — its tokens are emitted unprefixed (`#surface`, `#border`). Colored themes are scoped to status surfaces and accent variants, emitted with a theme-name prefix (`#success-surface`, `#danger-accent-surface`). - -You design the default theme once, and `extend()` propagates that design across every status hue. - -Every color definition has an **`inherit`** flag (default: `true`) controlling whether it flows into child themes via `extend()`. Set `inherit: false` to scope a color to its parent theme only — this is how sibling themes stay lean, carrying only the tokens they actually need. +For the full API surface, see [api.md](api.md). For the color model details, see +[okhst.md](okhst.md). -## Hue / saturation seeds +## The mental model -Declare hues as named constants up top, plus a single shared seed saturation: +Glaze palettes work best as one default neutral theme plus a few colored sibling +themes: + +- `default` owns the neutral system most components consume: `#surface`, + `#surface-text`, `#border`, `#disabled-surface`, and so on. +- Status themes (`success`, `danger`, `warning`, `note`, ...) are created with + `extend()`. They swap the hue and keep only the inherited tokens that should + become status-aware. +- `inherit: false` keeps a token local to the parent theme. Use it for neutral + ladders, shadows, code colors, overlays, and anything that should not be + repeated for every status hue. + +The main simplification is OKHST tone. Dark mode is now a single inversion +(`100 - t`) plus a scheme window, so relative tone shifts stay consistent between +light and dark. A token that is `-4` tone from its base is the same kind of step +in both schemes. + +## Authoring Rules + +Use this order when defining a token: + +1. Pick the base it visually belongs to. +2. Use `tone` for visual distance: surface ladders, soft chips, disabled states, + hover ramps, and other low-stakes relationships. +3. Add `contrast` only when readability or recognizability needs a measured + floor. +4. Use APCA presets for content-like colors: + `contrast: { apca: 'content' }` or + `contrast: { apca: ['content', 'body'] }`. +5. Use WCAG numbers or presets when you specifically need a ratio target: + `contrast: 4.5`, `contrast: 'AAA'`, or `contrast: { wcag: [4.5, 7] }`. +6. Let token names infer APCA roles. Names ending in `text`, `label`, `border`, + `surface`, `fill`, `bg`, and similar aliases already tell Glaze which side is + foreground or background. Set `role` only when a name is ambiguous. +7. Add high-contrast pairs only where HC should intentionally tighten: + text/content contrast, border tone, shadow intensity, mix value, or similar. + +`mode: 'auto'` is the default and should be the default choice. Use +`mode: 'fixed'` for brand fills or inverse surfaces that must stay recognizable +instead of tone-inverting. Use `mode: 'static'` only when the exact authored tone +must render in every scheme. + +## Seed and Configure + +Keep hue decisions named and configure output modes once: ```ts -const PURPLE_HUE = 280.3; +const PURPLE_HUE = 280.3; const SUCCESS_HUE = 156.9; -const DANGER_HUE = 23.1; +const DANGER_HUE = 23.1; const WARNING_HUE = 84.3; -const NOTE_HUE = 302.3; +const NOTE_HUE = 302.3; const SEED_SATURATION = 80; -``` - -Hues are design tokens too — keeping them named in one place beats burying numbers in `extend()` calls. The shared `SEED_SATURATION` keeps every status theme on the same saturation budget; per-color `saturation` factors below are 0–1 *of this seed*, not absolute. - -## Global `glaze.configure()` -Configure state aliases and output modes once at module load: - -```ts glaze.configure({ states: { dark: '@dark', highContrast: '@hc' }, - modes: { dark: true, highContrast: true }, + modes: { dark: true, highContrast: true }, }); ``` -Match the state alias names to whatever your app wires into the global predefined states (`@dark` / `@hc` is what Tasty expects). Setting `modes.highContrast: true` makes every export emit four variants — HC tokens are then available globally without per-call overrides. +Per-color `saturation` is a factor of the theme seed, not an absolute +saturation. With `SEED_SATURATION = 80`, `saturation: 0.25` means one quarter of +that seed. -## Naming conventions +## Naming -A tight, predictable vocabulary that the rest of the doc relies on: +Prefer purpose first and variant last: -| Pattern | Tokens | -|---|---| -| Surface ladder | `surface`, `surface-2`, `surface-3` | -| Text on surface (decreasing prominence) | `-text`, `-text-soft`, `-text-soft-2` | -| Misc neutral primitives | `border`, `placeholder`, `focus`, `disabled` | -| Neutral disabled chip | `disabled-surface`, `disabled-surface-text` | -| Fixed-mode dark surface | `surface-inverse` | -| Brand fills | `accent-surface`, `accent-surface-2`, `accent-surface-3`, `accent-surface-hover` | -| Brand fill anchor | `accent-surface-text` (the fixed white token everything anchors to) | -| Brand foregrounds on neutrals | `accent-text`, `accent-text-soft`, `accent-icon` | -| Brand-tinted disabled | `accent-disabled-surface`, `accent-disabled-surface-text` | -| Code syntax highlighting | `code-comment`, `code-keyword`, `code-string`, `code-number`, … | -| Loading animation | `loading-face-1`, `loading-face-2`, `loading-face-3` | -| Shadows | `shadow-sm`, `shadow-md`, `shadow-lg` | -| Backdrop | `overlay` | +- Surfaces: `surface`, `surface-2`, `surface-3`. +- Foregrounds: `surface-text`, `surface-text-soft`, `surface-text-soft-2`. +- Structure: `border`, `divider`, `outline`, `placeholder`, `focus`. +- Fills: `accent-surface`, `accent-surface-2`, + `accent-surface-hover`. +- Foregrounds on neutral surfaces: `accent-text`, `accent-text-soft`, + `accent-icon`. +- Disabled states: `disabled-surface`, `disabled-surface-text`, + `accent-disabled-surface`, `accent-disabled-surface-text`. +- Effects: `shadow-sm`, `shadow-md`, `shadow-lg`, `overlay`, `hover`, `tint`. -**Rule of thumb:** *purpose-name first, variant suffix last* (`-2`, `-text`, `-soft`, `-hover`, `-disabled`). +These names are not only readable. They also help APCA role inference pick the +right polarity. For example, `button-text` is foreground, `input-bg` is a +surface, and `card-outline` is a border. -## Surfaces (root colors) +## Build the Default Theme -`surface` is a root color (absolute `tone`, no `base`) with a low saturation factor. The ladder chains off it via small relative offsets: +Start with the surface family. It is mostly tone, with small saturation changes +to keep the ladder visually coherent: ```ts +const defaultTheme = glaze(PURPLE_HUE, SEED_SATURATION); + defaultTheme.colors({ - surface: { tone: 100, saturation: 0.11 }, - 'surface-2': { base: 'surface', tone: '-2', saturation: 0.15, inherit: false }, - 'surface-3': { base: 'surface', tone: '-4', saturation: 0.19, inherit: false }, + surface: { tone: 100, saturation: 0.11 }, + 'surface-2': { + base: 'surface', + tone: '-2', + saturation: 0.15, + inherit: false, + }, + 'surface-3': { + base: 'surface', + tone: '-4', + saturation: 0.19, + inherit: false, + }, }); ``` -A factor of `0.11` of the seed gives a barely-noticeable hue shift — enough that light/dark surfaces feel branded, not enough to look tinted. The slight saturation bump on `-2` / `-3` compensates for perceived saturation dropping as tone drops, so the ladder reads as one consistent surface family. +Because tone shifts are consistent across schemes, these small relative offsets +are enough. There is no separate dark-mode curve to tune. -`mode: 'auto'` (the default) inverts these in tone (`100 − t`) and remaps into the dark window, so a `tone: 100` light-mode surface lands at the dark window's floor in dark mode. Because tone is contrast-uniform, the relative deltas (`-2`, `-4`) translate to the same contrast steps in both schemes — no fitted curve required. `inherit: false` on `-2` / `-3` keeps colored sibling themes lean — they only need a single tinted `surface`, not the whole ladder. +### Text and Borders -## Text on surfaces (anchor at the edge) - -The headline trick of the whole methodology. Strong text uses an **absolute `tone` near the edge of the window**; soft variants use a **directional relative hint plus a numeric `contrast`**. +Use a hard edge tone for maximum-prominence text, and APCA floors for softer +content: ```ts -'surface-text': { - base: 'surface', tone: 2, saturation: 0.475, -}, -'surface-text-soft': { - base: 'surface', tone: '-1', saturation: 0.375, - contrast: [9, 11], inherit: false, -}, -'surface-text-soft-2': { - base: 'surface', tone: '-1', saturation: 0.24, - contrast: [4.5, 5.5], inherit: false, -}, +defaultTheme.colors({ + 'surface-text': { + base: 'surface', + tone: 2, + saturation: 0.475, + }, + 'surface-text-soft': { + base: 'surface', + tone: '-1', + saturation: 0.375, + contrast: { apca: ['content', 'body'] }, + inherit: false, + }, + 'surface-text-soft-2': { + base: 'surface', + tone: '-1', + saturation: 0.24, + contrast: { apca: ['large', 'content'] }, + inherit: false, + }, + border: { + base: 'surface', + tone: ['-10', '-20'], + saturation: 0.175, + inherit: false, + }, +}); ``` -Repeat the same triple anchored to each subordinate surface (`surface-2-text`, `surface-2-text-soft`, `surface-3-text`, …) so the ladder stays self-consistent. - -The strong-text `tone: 2` pins the light-mode resolved value near the bottom of the light window (close to black against the near-white surface) and inverts to near-white in dark mode — both yielding very high contrast against their surface. A `contrast: 'AAA'` solver pass would have stopped at the AAA floor (cr = 7) and no further. **Anchoring at the edge** beats the contrast solver because the solver only needs to *meet* the floor, not exceed it. - -The soft variants use `tone: '-1'` only as a *directional hint* — the real positioning comes from the numeric `contrast`. Numeric ratios give designers precise perceived weight where presets would only guarantee the AA/AAA floor. - -In high-contrast mode the tone window is bypassed entirely (full `[0, 100]` range), so `tone: 2` reaches close to black in light HC and close to white in dark HC — maximal contrast against the surface in both. +`surface-text` does not need a contrast floor because it is intentionally pinned +near the edge. The soft variants use `tone: '-1'` as the direction and APCA as +the readable floor. `border` uses an HC tone pair because borders usually need a +larger visible step in high contrast. -## Other neutral primitives +Repeat the same pattern for `surface-2` and `surface-3` only if components need +text directly on those surfaces. -Borders, placeholders, focus rings, and the floating "muted text" tone — all default-only: +### Neutral Utility Tokens -```ts -border: { base: 'surface', tone: ['-10', '-20'], saturation: 0.175, inherit: false }, -placeholder: { base: 'surface', tone: 67, saturation: 0.175, inherit: false }, -focus: { base: 'surface', tone: 71, saturation: 0.8625, inherit: false }, -disabled: { tone: 80.8, saturation: 0.4, inherit: false }, -``` - -`border` uses an HC pair — the border darkens twice as much in high-contrast mode for visibility. `placeholder` and `focus` give a `base` for namespacing but use absolute tone independently. `disabled` is a root color (no `base`) — it's used as a plain "muted text" token in some places, free of the surface chain. - -## Disabled chip (contrast-driven for scheme symmetry) - -The disabled chip + label pair uses `mode: 'auto'` and **explicit numeric contrast** against `surface`, not preset `'AA'` / `'AAA'`: +Keep neutral-only primitives local to the default theme: ```ts -'disabled-surface': { - base: 'surface', tone: '-1', saturation: 0.2, - contrast: [1.5, 2], inherit: false, -}, -'disabled-surface-text': { - base: 'disabled-surface', tone: '+1', saturation: 0.3, - contrast: 3, inherit: false, -}, +defaultTheme.colors({ + placeholder: { + base: 'surface', + tone: 67, + saturation: 0.175, + inherit: false, + }, + focus: { + base: 'surface', + tone: 71, + saturation: 0.8625, + inherit: false, + }, + disabled: { + tone: 80.8, + saturation: 0.4, + inherit: false, + }, +}); ``` -Each token anchors to its immediate parent surface — `*-surface` contrasts against the root `surface`, while `*-surface-text` contrasts against its own chip (`disabled-surface`). This keeps the disabled state self-contained and resolves to consistent ratios in light, dark, and HC (chip ≈ 1.5–2× vs surface, label ≈ 3× on chip). An alpha-tinted overlay would have asymmetric behavior — composited alpha against a near-white light surface produces a much weaker chip than the same overlay against a near-dark dark surface, and the disabled state would stop *looking* disabled in one of the schemes. +Absolute tones are fine for primitives whose job is visual placement rather than +a strict relationship to a specific surface. -The general rule: when a color needs to *feel the same across schemes*, anchor it with `mode: 'auto'` + a numeric contrast against a surface, not with a preset. +## Chips and Disabled States -## `surface-inverse` (the fixed-mode escape hatch) +For subtle fills, tone is usually clearer than contrast: ```ts -'surface-inverse': { - tone: 12, saturation: 0.475, mode: 'fixed', inherit: false, -}, +defaultTheme.colors({ + 'disabled-surface': { + base: 'surface', + tone: '-3', + saturation: 0.2, + inherit: false, + }, + 'disabled-surface-text': { + base: 'disabled-surface', + tone: '+18', + saturation: 0.3, + flip: false, + inherit: false, + }, +}); ``` -`mode: 'fixed'` skips the dark-scheme tone inversion and only remaps the tone into the dark window, so `surface-inverse` reads as a dark surface in *every* scheme — light, dark, and HC. In high-contrast variants the window is bypassed entirely (identity), so the color stays at its raw tone across all four schemes. +This says exactly what the pair should do: the chip sits a few tone steps off +the page, and the label sits a muted distance from the chip. `flip: false` keeps +the relative label offset on the authored side when it reaches the edge. -Use it for tooltips, code blocks, popovers with their own dark theme. Pair with `#white` for foreground text. - -This is the canonical "I want this color to stay recognizable" pattern. The other `mode: 'fixed'` use is the entire accent system below. - -## Accent system (anchor pattern) - -The load-bearing trick. Define a single fixed white anchor `accent-surface-text`, then derive every accent surface from it with a small relative tone offset and a numeric contrast under `mode: 'fixed'`: +Use contrast instead when the chip must hit an explicit accessibility floor: ```ts -'accent-surface-text': { tone: 100, mode: 'fixed' }, - -'accent-surface': { base: 'accent-surface-text', tone: '-1', contrast: [4.5, 7], mode: 'fixed' }, -'accent-surface-2': { base: 'accent-surface-text', tone: '-1', contrast: [4.8, 7.5], mode: 'fixed' }, -'accent-surface-3': { base: 'accent-surface-text', tone: '-1', contrast: [5.2, 8], mode: 'fixed' }, -'accent-surface-hover': { base: 'accent-surface-text', tone: '-1', contrast: [6, 8.5], mode: 'fixed' }, +defaultTheme.colors({ + 'disabled-surface-text': { + base: 'disabled-surface', + tone: '+1', + saturation: 0.3, + contrast: { apca: 'non-text' }, + inherit: false, + }, +}); ``` -Three things make this work: - -- **One anchor, one chain.** All accent surfaces stay in the same hue family because they all derive from `accent-surface-text`. -- **`mode: 'fixed'` keeps the brand recognizable.** Without it, the dark-scheme tone inversion would turn the brand fill into a tone-inverted counterpart that may no longer read as the intended brand surface. Fixed remaps the tone into the dark window without inverting, so a `tone: 52` brand color stays mid-toned in dark mode — still recognizably the same color. -- **Numeric contrasts, not presets.** `'AA'` / `'AAA'` would let the solver push the color far away from its anchor in dark schemes, breaking the relationship between `accent-surface` and its neighbors. Numeric ratios make the darkening between `accent-surface` (4.5/7), `-2` (4.8/7.5), `-3` (5.2/8), and `-hover` (6/8.5) a tight, designed sequence — a stepped gradient rather than four solver-generated outliers. +When a token needs the scheme extreme, use `tone: 'min'` or `tone: 'max'` +directly. Avoid large magic numbers or fake contrast floors just to push a color +to the edge. -The hover variant is a dedicated *fixed* token. Reusing `accent-text` (which is `mode: 'auto'` and inverts direction in dark) would break the hover feel. +## Fixed Surfaces and Accent Fills -## Adaptive accent foregrounds - -The opposite of the fills. Brand-colored *foregrounds* are anchored to **`surface`, not `accent-surface`**, with `mode: 'auto'` (default) and full saturation: +Use `mode: 'fixed'` when the authored color should stay recognizable across +schemes. ```ts -'accent-text': { base: 'surface', tone: '-1', saturation: 1, contrast: [6.4, 10] }, -'accent-text-soft': { base: 'surface', tone: '-1', saturation: 1, contrast: [4.5, 7] }, -'accent-icon': { base: 'surface', tone: '-1', saturation: 0.9375, contrast: [3.2, 5] }, +defaultTheme.colors({ + 'surface-inverse': { + tone: 12, + saturation: 0.475, + mode: 'fixed', + inherit: false, + }, + + 'accent-surface-text': { + tone: 100, + mode: 'fixed', + }, + 'accent-surface': { + base: 'accent-surface-text', + tone: '-1', + contrast: { apca: ['content', 'body'] }, + mode: 'fixed', + }, + 'accent-surface-2': { + base: 'accent-surface-text', + tone: '-1', + contrast: { apca: [65, 80] }, + mode: 'fixed', + }, + 'accent-surface-hover': { + base: 'accent-surface-text', + tone: '-1', + contrast: { apca: ['body', 'preferred'] }, + mode: 'fixed', + }, +}); ``` -Foregrounds need to stay readable on the surface they actually sit on — anchoring to the brand fill would only enforce contrast against that fill, leaving the dark-mode color washed out against the actual surface (e.g. SECONDARY button labels sit on `surface`, not on the brand fill). Anchoring to `surface` + `mode: 'auto'` lets the solver lift the tone in dark mode so the contrast floor holds in both schemes. - -`accent-text-soft` shares the anchor and saturation but relaxes the contrast floor for a visibly less prominent secondary foreground (link base color, subdued labels). Critically, it stays `mode: 'auto'` — a fixed version would collapse to cr≈3 against the dark surface and break AA. +The accent fill family is a fixed chain against a fixed text anchor. The names +infer `surface` and `text` roles, so APCA gets the right polarity without extra +fields. -## Brand-tinted disabled +## Adaptive Accent Foregrounds -Mirrors the neutral disabled pair from above but with higher saturation so the chip reads as a *muted brand color* rather than fully neutral grey. A disabled chip has no contrast *requirement* — it just needs to sit a hair off the surface and carry a faint label. That's a job for **tone**, not the contrast solver: +Brand foregrounds that sit on neutral surfaces should stay adaptive: ```ts -'accent-disabled-surface': { - base: 'surface', tone: '+3', saturation: 0.5, -}, -'accent-disabled-surface-text': { - base: 'accent-disabled-surface', tone: '+18', saturation: 0.4, - flip: false, -}, +defaultTheme.colors({ + 'accent-text': { + base: 'surface', + tone: '-1', + saturation: 1, + contrast: { apca: ['content', 'body'] }, + }, + 'accent-text-soft': { + base: 'surface', + tone: '-1', + saturation: 1, + contrast: { apca: ['large', 'content'] }, + }, + 'accent-icon': { + base: 'surface', + tone: '-1', + saturation: 0.9375, + contrast: { apca: ['non-text', 'large'] }, + }, +}); ``` -Tone offsets say what we mean directly: the chip is three tone steps off `surface`, the label eighteen steps off the chip — a deliberately low-contrast pair, no solver involved. Because tone is contrast-uniform, those steps look the same in light and dark, and `mode: 'auto'` (the default) keeps the relationship intact when the scheme inverts. - -`flip: false` on the label is the safety rail. A relative `tone: '+18'` lands above the chip; if the chip is already near the top of the range (a light surface), `+18` would overshoot 100. With flip on (the default), Glaze mirrors the offset to `-18` so it reflects back into range — handy for contrast tokens, but here it would put the label on the *wrong* side of the chip. Turning flip off clamps to the boundary instead, keeping the label on the side you authored. +Anchor these to `surface`, not to `accent-surface`. Their real job is to remain +readable on neutral UI, so `mode: 'auto'` and a surface base are the right +defaults. -When a token genuinely needs the extreme — the lightest or darkest tone the scheme allows — reach for `'max'` / `'min'` rather than a large number or a contrast hack: +Brand-tinted disabled states can usually be pure tone: ```ts -'card-floor': { tone: 'min' }, // darkest tone (root, no base needed) -'card-ceil': { tone: 'max' }, // lightest tone +defaultTheme.colors({ + 'accent-disabled-surface': { + base: 'surface', + tone: '+3', + saturation: 0.5, + }, + 'accent-disabled-surface-text': { + base: 'accent-disabled-surface', + tone: '+18', + saturation: 0.4, + flip: false, + }, +}); ``` -`'max'` resolves to the scheme's highest authored tone (100) and `'min'` to the lowest (0); neither needs a `base`. They flow through scheme mapping like any absolute tone, so under `mode: 'auto'` they invert in dark — `'max'` is the lightest tone in light mode and, after inversion, the *darkest* in dark. Use `mode: 'static'` if you want the same extreme pinned across every scheme, or `mode: 'fixed'` to keep it on the same end without inverting. Either way: no magic numbers, no contrast floor standing in for "push it all the way". +These are inherited, so status themes automatically get +`success-accent-disabled-surface`, `danger-accent-disabled-surface`, and the +matching text tokens. -These are inherited (no `inherit: false`), so each colored sibling theme automatically emits `-accent-disabled-surface` and `-accent-disabled-surface-text`. PRIMARY-style disabled buttons stay tinted with the active theme's hue (danger-tinted danger button, success-tinted success button), preserving brand identity even in the disabled state. +## Special Purpose Colors -## Per-color hue overrides (code highlighting) - -The `code-*` tokens use **absolute `hue` numbers** regardless of the seed. Each is `base: 'surface'` with `mode: 'auto'`, a per-token saturation, and a numeric contrast floor: +Use absolute `hue` overrides for tokens that should come from another hue family +but keep the same adaptation behavior: ```ts -'code-comment': { base: 'surface', hue: 280, saturation: 0.1, tone: '-1', contrast: [4.5, 7], inherit: false }, -'code-keyword': { base: 'surface', hue: 348, saturation: 1, tone: '-1', contrast: [5, 7.5], inherit: false }, -'code-string': { base: 'surface', hue: SUCCESS_HUE, saturation: 1, tone: '-1', contrast: [4.5, 7], inherit: false }, -// …code-punctuation, code-number, code-function, code-attribute follow the same shape +defaultTheme.colors({ + 'code-comment': { + base: 'surface', + hue: 280, + saturation: 0.1, + tone: '-1', + contrast: { apca: ['large', 'content'] }, + inherit: false, + }, + 'code-keyword': { + base: 'surface', + hue: 348, + saturation: 1, + tone: '-1', + contrast: { apca: ['content', 'body'] }, + inherit: false, + }, + 'code-string': { + base: 'surface', + hue: SUCCESS_HUE, + saturation: 1, + tone: '-1', + contrast: { apca: ['large', 'content'] }, + inherit: false, + }, +}); ``` -The canonical pattern for "I want a color from a different hue family but the same adaptive behavior". Absolute `hue` overrides the theme seed for a single color; everything else (contrast against `surface`, dark adaptation, HC tightening) still works. `inherit: false` because syntax highlighting is a default-only concern. - -## Loading-animation faces - -A 3-step ramp using *absolute* tones with high saturation factors and tight numeric contrasts: +Use small tone ramps for decorative motion: ```ts -'loading-face-1': { base: 'surface', tone: 98, saturation: 0.3, contrast: [1.04, 1.5], inherit: false }, -'loading-face-2': { base: 'surface', tone: 91, saturation: 0.62, contrast: [1.24, 2.5], inherit: false }, -'loading-face-3': { base: 'surface', tone: 79, saturation: 0.66, contrast: [1.75, 4], inherit: false }, +defaultTheme.colors({ + 'loading-face-1': { + base: 'surface', + tone: 98, + saturation: 0.3, + inherit: false, + }, + 'loading-face-2': { + base: 'surface', + tone: 91, + saturation: 0.62, + inherit: false, + }, + 'loading-face-3': { + base: 'surface', + tone: 79, + saturation: 0.66, + inherit: false, + }, +}); ``` -Combines absolute tone positioning (so the ramp is deterministic in light mode) with a numeric contrast floor (so the ramp still reads in dark and HC). The HC contrast jumps significantly (`1.04 → 1.5`, `1.24 → 2.5`, `1.75 → 4`) so the animation stays perceivable for low-vision users. +Since tone steps now invert consistently across schemes, the same ramp keeps its +spacing in light and dark without involving the contrast solver. Use an HC tone +pair only when the animation should become more pronounced in high contrast. -## Shadows +## Effects -Three sizes, all sharing `bg: 'surface'` and `fg: 'surface-text'`, varying only `intensity`: +Define one neutral shadow system: ```ts -'shadow-sm': { type: 'shadow', bg: 'surface', fg: 'surface-text', intensity: 5, inherit: false }, -'shadow-md': { type: 'shadow', bg: 'surface', fg: 'surface-text', intensity: 10, inherit: false }, -'shadow-lg': { type: 'shadow', bg: 'surface', fg: 'surface-text', intensity: 15, inherit: false }, +defaultTheme.colors({ + 'shadow-sm': { + type: 'shadow', + bg: 'surface', + fg: 'surface-text', + intensity: 5, + inherit: false, + }, + 'shadow-md': { + type: 'shadow', + bg: 'surface', + fg: 'surface-text', + intensity: [10, 20], + inherit: false, + }, + 'shadow-lg': { + type: 'shadow', + bg: 'surface', + fg: 'surface-text', + intensity: [15, 30], + inherit: false, + }, +}); ``` -Including `fg` matters: shadow strength scales with `|l_bg − l_fg|`, so anchoring `fg` to `surface-text` (which is anchored at the edge of the window) makes shadows *automatically deeper* in dark mode where the bg/fg gap is larger. All shadows are `inherit: false` — there's only one shadow system for the whole UI, and colored sibling themes don't carry their own. +Including `fg` lets shadow strength follow the resolved foreground/background +gap. Use an HC pair for shadows that should deepen in high contrast. -For HC, pass `intensity: [normal, hc]` (e.g. `[10, 20]`) to deepen shadows in high-contrast mode. The full algorithm and tuning knobs are in [api.md → Shadows](api.md#shadows). - -## Overlay (fixed opacity) +Use `opacity` for one fixed-alpha color: ```ts -overlay: { tone: 10, opacity: 0.5, inherit: false }, +defaultTheme.colors({ + overlay: { tone: 10, opacity: 0.5, inherit: false }, +}); ``` -The shortcut for *one solid color with a fixed alpha* — no shadow algorithm, no mix. `opacity` on a regular color attaches an alpha component to every variant. Use it for backdrops, scrims, modal overlays. (Combining `opacity` with `contrast` is not recommended — perceived lightness becomes unpredictable when alpha is fixed; Glaze emits a `console.warn`.) - -## Mixes for hover / tint - -Reach for mix tokens when you want one color to "tint through" another: +Use mixes when one color should tint through another: ```ts -hover: { - type: 'mix', base: 'surface', target: 'accent-surface', - value: 8, blend: 'transparent', -}, -// hover → accent-surface with alpha = 0.08 - -tint: { - type: 'mix', base: 'surface', target: 'accent-surface', - value: 20, -}, +defaultTheme.colors({ + hover: { + type: 'mix', + base: 'surface', + target: 'accent-surface', + value: 8, + blend: 'transparent', + }, + tint: { + type: 'mix', + base: 'surface', + target: 'accent-surface', + value: 20, + }, +}); ``` -- **Transparent mix** — *the target color with controlled alpha*. Useful for hover overlays. -- **Opaque mix** — solid blend of two colors. Good for subtle tints. +Transparent mixes are good for hover overlays. Opaque mixes are good for solid +tints. Mix colors can also use `contrast`; the solver adjusts the value or +opacity to hit the floor. -Choose `space: 'okhsl'` (default) for design tokens — perceptually uniform, consistent with the rest of Glaze. Choose `space: 'srgb'` to match what the browser would render with a plain CSS overlay. Mix colors support the same `contrast` prop as regular colors; the solver adjusts the mix ratio (opaque) or opacity (transparent) to meet the target. +## Extend into Status Themes -## Colored sibling themes via `extend()` - -One shared `TINTED_SURFACE_OVERRIDE`, applied to every colored theme, with only the `hue` changing per status: +Once the default theme is shaped, create colored siblings by replacing hue and +overriding only the root surface that should become visibly tinted: ```ts -const TINTED_SURFACE_OVERRIDE: ColorMap = { +const TINTED_SURFACE_OVERRIDE = { surface: { tone: 96, saturation: 0.8 }, }; -const primaryTheme = defaultTheme.extend({ colors: TINTED_SURFACE_OVERRIDE }); -const successTheme = defaultTheme.extend({ hue: SUCCESS_HUE, colors: TINTED_SURFACE_OVERRIDE }); -const dangerTheme = defaultTheme.extend({ hue: DANGER_HUE, colors: TINTED_SURFACE_OVERRIDE }); -const warningTheme = defaultTheme.extend({ hue: WARNING_HUE, colors: TINTED_SURFACE_OVERRIDE }); -const noteTheme = defaultTheme.extend({ hue: NOTE_HUE, colors: TINTED_SURFACE_OVERRIDE }); +const primaryTheme = defaultTheme.extend({ + colors: TINTED_SURFACE_OVERRIDE, +}); +const successTheme = defaultTheme.extend({ + hue: SUCCESS_HUE, + colors: TINTED_SURFACE_OVERRIDE, +}); +const dangerTheme = defaultTheme.extend({ + hue: DANGER_HUE, + colors: TINTED_SURFACE_OVERRIDE, +}); +const warningTheme = defaultTheme.extend({ + hue: WARNING_HUE, + colors: TINTED_SURFACE_OVERRIDE, +}); +const noteTheme = defaultTheme.extend({ + hue: NOTE_HUE, + colors: TINTED_SURFACE_OVERRIDE, +}); ``` -Colored themes need a visibly tinted surface for status banners — saturation jumps from the neutral `0.11` (default theme) to `0.8`. The `inherit: false` discipline pays off here: because most neutrals (`surface-2`, `surface-3`, `border`, `placeholder`, `disabled-*`, `code-*`, `loading-*`, `shadow-*`) are flagged default-only, each colored theme inherits *only* the accent + tinted surface chain and emits a small, focused token set. - -`primaryTheme` keeps the default hue but gets the tinted surface — useful for places that want a brand-tinted banner without semantic status meaning. +The inherited accent and disabled tokens now resolve in each status hue. Tokens +marked `inherit: false` stay default-only, so sibling themes remain small. -## Palette composition +## Export the Palette -Compose all themes into a palette so they can be exported as one token set: +Compose the themes once: ```ts const palette = glaze.palette({ default: defaultTheme, primary: primaryTheme, success: successTheme, - danger: dangerTheme, + danger: dangerTheme, warning: warningTheme, - note: noteTheme, + note: noteTheme, }); ``` -The default theme is conventionally exported unprefixed (its tokens land as `#surface`, `#border`); colored themes are prefixed with their name. See [migration.md](migration.md) for the prefix map shape, alias patterns, and how to wire the resulting tokens into Tasty / CSS / framework-agnostic JSON. +The usual export shape is default unprefixed and status themes prefixed: + +```ts +palette.tasty({ + prefix: { + default: '', + primary: 'primary-', + success: 'success-', + danger: 'danger-', + warning: 'warning-', + note: 'note-', + }, +}); +``` + +See [migration.md](migration.md) for export shapes, prefix maps, Tasty wiring, +CSS variables, and JSON integration. + +## High Contrast -## High-contrast strategy +High contrast is not a separate palette. Any value that accepts an HC pair can +tighten the HC variant: `tone`, `contrast`, shadow `intensity`, and mix `value`. -Glaze's high-contrast mode is opt-in per token: anywhere `tone`, `contrast`, `intensity`, or `value` accepts an HC pair, you can pass `[normal, hc]` to tighten the HC variant. The heuristic is to pair anything that's already contrast-driven: +Use HC pairs where users should actually get more separation: -- Text-against-surface contrasts (`[9, 11]`, `[4.5, 5.5]`, `[6.4, 10]`). -- The accent surface ladder (`[4.5, 7]` → `[5.2, 8]` → `[6, 8.5]`). -- The loading ramp's contrasts. -- Shadow `intensity` (e.g. `intensity: [10, 20]`). -- `border` tone (e.g. `tone: ['-10', '-20']`). +- Text/content contrast: `{ apca: ['content', 'body'] }`. +- Accent fills: `{ apca: ['content', 'body'] }` or stronger. +- Borders: `tone: ['-10', '-20']`. +- Shadows: `intensity: [10, 20]`. +- Decorative ramps that must stay perceivable. -In HC the tone window is **bypassed entirely** — light HC and dark HC operate on the full `[0, 100]` range. That's why edge-anchored absolute tones like `surface-text: { tone: 2 }` reach close to black in light HC and close to white in dark HC, exactly what you want for maximum contrast. +In HC variants, Glaze bypasses the normal tone window and uses the full +`[0, 100]` range. Edge tones can reach the edge; contrast floors have more room +to solve. -## Closing checklist +## Checklist Before shipping a palette, verify: -- [ ] Every text token has an explicit `contrast` *or* an edge-anchored absolute `tone`. -- [ ] Every accent surface uses `mode: 'fixed'` + numeric `contrast` (not preset `'AA'` / `'AAA'`). -- [ ] Every brand foreground (`accent-text*`, `accent-icon`) is anchored to `surface`, **not** to `accent-surface`. -- [ ] Every `inherit: false` is intentional — colored sibling themes only carry the tokens they actually need. -- [ ] HC pairs are present on every contrast-driven token, not just the strong ones. -- [ ] Shadow `fg` is set when you want shadows to deepen in dark mode. -- [ ] `glaze.configure({ states, modes })` matches the global predefined states wired in your app's root. +- Text, icon, and content tokens either have APCA/WCAG contrast or are + deliberately edge-anchored. +- Accent fills use `mode: 'fixed'`; accent foregrounds on neutral UI stay + `mode: 'auto'` and are based on `surface`. +- Ambiguous APCA tokens have an explicit `role`; obvious names rely on inference. +- Low-stakes visual relationships use relative `tone` instead of fake contrast + floors. +- `inherit: false` is set on default-only tokens so status themes stay focused. +- HC pairs exist where high contrast should visibly tighten. +- `glaze.configure({ states, modes })` matches the states registered in the app. diff --git a/docs/migration.md b/docs/migration.md index 673daa7..2a8dacc 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -68,7 +68,31 @@ Relative `tone` offsets that overshoot `[0, 100]` now **mirror to the other side ### Resolved variants store tone -`ResolvedColorVariant` now exposes `t` (tone, 0–1) instead of `l`. If you read resolved internals, convert with `variantToOkhsl(variant).l`. Token/CSS/JSON output is unchanged — Glaze still emits `okhsl(...)` / `rgb(...)` etc. (`okhst` is input-only and is never emitted). +`ResolvedColorVariant` now exposes `t` (tone, 0–1) instead of `l`. If you read resolved internals, convert with `variantToOkhsl(variant).l`. Token/CSS/JSON output uses native formats by default (`oklch` for `tokens()` / `json()`, `okhsl` for `tasty()`); use `tasty({ format: 'okhsl' })` or `tasty({ format: 'okhst' })` for Glaze-native spaces. + +### 0.16.0 — output format defaults and Tasty-only spaces + +**Breaking changes in 0.16.0:** + +| Export | Old default | New default | +|---|---|---| +| `tokens()` / `json()` (theme + palette) | `okhsl` | `oklch` | +| Standalone `.json()` | `okhsl` | `oklch` | +| `tasty()` | `okhsl` (unchanged) | `okhsl` | + +`'okhsl'` and the new `'okhst'` output format are **Tasty-only**. Passing either to `css()`, `tailwind()`, `tokens()`, or `json()` throws. Migrate: + +```ts +// before +theme.tokens({ format: 'okhsl' }) +theme.json() + +// after — pick one +theme.tasty({ format: 'okhsl' }) // Tasty #name keys, okhsl strings +theme.tokens({ format: 'oklch' }) // native CSS (new default) +``` + +**New:** `splitHue` on `css()` / `tasty()` (theme + palette) and standalone `color.css()` emits hue as a separate custom property for runtime re-skinning. Requires `format: 'oklch'` and every color to be pastel. See [api.md → Hue channel splitting](api.md#hue-channel-splitting-splithue). ### Export snapshots @@ -76,16 +100,19 @@ Relative `tone` offsets that overshoot `[0, 100]` now **mirror to the other side ## Choosing an export -Glaze emits the same resolved colors in four shapes. Pick one based on your renderer. +Glaze emits the same resolved colors in six shapes. Pick one based on your renderer / tooling. | Method | Output shape | Use it for | |---|---|---| | `palette.tasty(options?)` | `{ '#name': { '': value, '@dark': value, '@hc': value } }` | The [Tasty](https://tasty.style/docs) style system. Single object, state aliases keyed inside each token. | | `palette.tokens(options?)` | `{ light: { name: value }, dark: { name: value }, ... }` | Most CSS-in-JS systems. Per-variant flat maps, easy to feed into a `:root { ... }` selector via your framework's globals. | | `palette.css(options?)` | `{ light: '--name-color: rgb(...);', dark: '...', ... }` | Framework-free CSS / static stylesheets. Variant-grouped CSS custom property strings ready to wrap in `:root` and `prefers-color-scheme` queries. | -| `palette.json(options?)` | `{ themeName: { name: { light, dark, ... } } }` | Tooling, JSON pipelines, design-token exporters (Style Dictionary, etc.). | +| `palette.json(options?)` | `{ themeName: { name: { light, dark, ... } } }` | Tooling, JSON pipelines. | +| `palette.dtcg(options?)` | `{ light: { name: { $type, $value } }, dark: { ... }, ... }` | W3C [DTCG 2025.10](https://www.designtokens.org/) `.tokens.json` — Figma, Tokens Studio, Style Dictionary, Terrazzo, Penpot. One document per scheme. | +| `palette.dtcgResolver(options?)` | `{ version, sets, modifiers, resolutionOrder }` | W3C DTCG **Resolver-Module** — a single document describing every scheme variant as `sets` + a `scheme` modifier with a context per variant. For resolver tools such as Dispersa. | +| `palette.tailwind(options?)` | `'@theme { --color-*: ... } .dark { ... } ...'` | Tailwind CSS v4. A single ready-to-paste `@theme` block plus dark / high-contrast overrides. | -All four accept `format` (`'okhsl' \| 'rgb' \| 'hsl' \| 'oklch'`). `tokens()`, `tasty()`, and `json()` also accept `modes` (`{ dark, highContrast }`). `css()` always returns all four strings (`light`, `dark`, `lightContrast`, `darkContrast`). See [api.md → Palette](api.md#palette) for full options. +`tasty()`, `tokens()`, `json()`, `dtcg()`, `dtcgResolver()`, and `tailwind()` accept `modes` (`{ dark, highContrast }`). `css()` always returns all four strings (`light`, `dark`, `lightContrast`, `darkContrast`). The CSS-string exports accept `format` (`'rgb' \| 'hsl' \| 'oklch'` on `tokens`/`json`/`css`/`tailwind`; `'okhsl' \| 'okhst'` on `tasty()` only); `dtcg()` and `dtcgResolver()` use `colorSpace` (`'srgb' \| 'oklch'`) instead. See [api.md → Palette](api.md#palette) for full options. ## Wiring exports into the app @@ -164,9 +191,47 @@ const data = palette.json(); Feed into your tooling pipeline. Each color is grouped by theme name, then by token name, then by variant — no prefix logic to undo. +### W3C DTCG (`.tokens.json`) + +```ts +import { writeFileSync } from 'node:fs'; +const dtcg = palette.dtcg(); + +writeFileSync('tokens.light.tokens.json', JSON.stringify(dtcg.light, null, 2)); +if (dtcg.dark) { + writeFileSync('tokens.dark.tokens.json', JSON.stringify(dtcg.dark, null, 2)); +} +``` + +Each document is a spec-conformant token tree. One file per scheme is the most tool-compatible convention — Style Dictionary treats them as themes, Tokens Studio as sets, and Figma as variable modes. Use `colorSpace: 'oklch'` for wide-gamut, Glaze-native values (no `hex`); the default `'srgb'` emits components plus a `hex` hint that every reader understands. Feed the files straight into Style Dictionary v4+, Tokens Studio, or any DTCG-compatible tool — no Glaze-specific transform needed. + +### W3C DTCG Resolver-Module (single document) + +When you want **one file** describing every scheme variant — for a resolver tool such as [Dispersa](https://github.com/dispersa-core/dispersa) — use `dtcgResolver()` instead of `dtcg()`: + +```ts +import { writeFileSync } from 'node:fs'; +const resolver = palette.dtcgResolver({ modes: { highContrast: true } }); + +writeFileSync('resolver.json', JSON.stringify(resolver, null, 2)); +``` + +The document places the light tokens in `sets.base.sources[0]` (the default context) and emits a single `scheme` modifier with a context per variant (`light` / `dark` / `lightContrast` / `darkContrast`). Each non-default context holds that variant's exact resolved tokens — Glaze resolves `darkContrast` independently, so the four-context shape keeps every value correct (two independent modifiers would compose additively and produce wrong dark + high-contrast values). Rename the set, modifier, or contexts via `setName` / `modifierName` / `contextNames` if your resolver expects different labels. Prefer `dtcg()` when you need maximum per-file tool compatibility; prefer `dtcgResolver()` when a resolver tool consumes the document for you. + +### Tailwind CSS v4 + +```ts +const css = palette.tailwind(); +// Write to your CSS entry, or paste into a `@import "tailwindcss"` stylesheet: +// @theme { --color-primary-surface: oklch(...); ... } +// .dark { --color-primary-surface: oklch(...); ... } +``` + +The `--color-*` namespace makes every color available as `bg-*` / `text-*` / `border-*`. Drive dark mode from the OS preference instead of a class with `darkSelector: '@media (prefers-color-scheme: dark)'` (it nests `:root` automatically). High-contrast overrides land under `.high-contrast` and `.dark.high-contrast` by default; both are omitted unless `modes.highContrast` is enabled. + ## Prefix map strategies -`palette.tokens()` / `tasty()` / `css()` accept a `prefix` option: +`palette.tokens()` / `tasty()` / `css()` / `dtcg()` / `tailwind()` accept a `prefix` option: | Value | Result | |---|---| diff --git a/docs/okhst.md b/docs/okhst.md index 5e99487..cc00102 100644 --- a/docs/okhst.md +++ b/docs/okhst.md @@ -169,14 +169,14 @@ type ContrastSpec = | number // bare WCAG ratio | ContrastPreset // 'AA' | 'AAA' | 'AA-large' | 'AAA-large' (WCAG) | { wcag: HCPair } - | { apca: HCPair }; + | { apca: HCPair }; contrast?: HCPair; ``` A bare number or preset means WCAG. The `[normal, highContrast]` pair may live at the outer level (`[4.5, 7]`, `[{ wcag: 4.5 }, { wcag: 7 }]`) **or** inside the -metric (`{ wcag: [4.5, 7] }`, `{ apca: [45, 60] }`). `resolveContrastForMode` +metric (`{ wcag: [4.5, 7] }`, `{ apca: [45, 60] }`, `{ apca: ['content', 'body'] }`). `resolveContrastForMode` peels the outer pair by mode, then the inner metric pair by the same mode, then resolves presets, returning `{ metric, target }`. @@ -196,6 +196,49 @@ luminance — the soft-clamp constants are calibrated against `Ys`, so the solve feeds it the matching basis. This is a faithful-but-simplified APCA (it omits the spatial/font-size lookup that maps Lc to a usable text size). +#### Polarity (roles) + +APCA is **asymmetric**: `|apcaContrast(a, b)| ≠ |apcaContrast(b, a)|`, because +the normal-polarity exponents (dark text on light bg) differ from the +reverse-polarity exponents (light text on dark bg). Glaze picks the argument +order from each color's semantic **role** against its base: + +| Role | Polarity | Argument order | Aliases (name inference) | +| --------- | -------- | ------------------------------ | ------------------------------------------- | +| `text` | `fg` | `apcaContrast(candidate, base)`| `text`, `fg`, `foreground`, `content`, `ink`, `label`, `stroke` | +| `border` | `fg` | `apcaContrast(candidate, base)`| `border`, `divider`, `outline`, `separator`, `hairline`, `rule` | +| `surface` | `bg` | `apcaContrast(base, candidate)`| `surface`, `bg`, `background`, `fill`, `canvas`, `paper`, `layer` | + +A color's role is resolved per the chain: explicit `role` → inferred from the +color name (last recognized token wins, so `button-text` → `text`, +`input-bg` → `surface`) → the opposite of the base's role → `'text'` (foreground) +default. Name inference is on by default (`config.inferRole: true`) and can be +disabled. WCAG is symmetric, so role never changes WCAG results — it only fixes +APCA argument order. + +#### APCA presets + +APCA targets may be a raw Lc number or a named preset (APCA Bronze Simple Mode +conformance levels), independent of role: + +```ts +type ApcaPreset = 'preferred' | 'body' | 'content' | 'large' | 'non-text' | 'min'; +``` + +| Preset | Lc | Use case | +| ------------- | --- | ---------------------------------------------------- | +| `'preferred'` | 90 | Preferred body / column text | +| `'body'` | 75 | Minimum body / column text | +| `'content'` | 60 | Readable non-body content (~WCAG AA 4.5:1) | +| `'large'` | 45 | Large/bold headlines; fine icons/outlines (~3:1) | +| `'non-text'` | 30 | Solid icons/controls; placeholder/disabled text | +| `'min'` | 15 | Dividers/decorative; APCA "point of invisibility" | + +```ts +contrast: { apca: 'content' } // Lc 60 +contrast: { apca: ['content', 'body'] } // HC pair: 60 normal, 75 high-contrast +``` + ## Verification (APCA / WCAG drift) Because chromatic swatches inherit gray's tone-derived lightness but drift in diff --git a/src/channels.ts b/src/channels.ts new file mode 100644 index 0000000..53bd90d --- /dev/null +++ b/src/channels.ts @@ -0,0 +1,179 @@ +/** + * Hue channel planning for `splitHue` exports. + * + * Builds per-color hue var references and scheme-independent `--*-hue` + * declarations for oklch CSS / Tasty output when every color is pastel. + */ + +import { parseRelativeOrAbsolute } from './hc-pair'; +import { isMixDef, isShadowDef } from './shadow'; +import type { + ColorDef, + ColorMap, + RegularColorDef, + ResolvedColor, + ResolvedColorVariant, +} from './types'; + +const ACHROMATIC_EPSILON = 1e-6; + +export interface HueDeclaration { + prop: string; + value: string; +} + +export interface HuePlan { + /** CSS `var()` reference spliced into `oklch(L C )`. */ + hueVar: string; + /** When true, emit a full inline color (shadow/mix/achromatic). */ + inline: boolean; + /** Scheme-independent `--*-hue` declarations for this color. */ + declarations: HueDeclaration[]; +} + +export interface ChannelCtx { + seedHue: number; + /** Theme-level hue var base name (without `--` / `-hue`). */ + baseName: string; + /** Token / custom-property name prefix used for hue var naming (`brand-` etc.). */ + prefix: string; + defs: ColorMap; + mode: 'theme' | 'standalone'; + /** Standalone: resolved hue from the primary variant (scheme-independent). */ + resolvedHue?: number; + /** + * When false, hue declarations are not emitted (the pass only references + * hue vars already declared by a sibling pass). Used by palette primary + * unprefixed aliases so they reference the themed `--{themeName}-*-hue` + * vars without re-declaring (and colliding with) other themes' base vars. + * Defaults to true. + */ + emitDeclarations?: boolean; +} + +function cssProp(prefix: string, name: string, suffix: string): string { + return `--${prefix}${name}${suffix}`; +} + +function isAchromatic(v: ResolvedColorVariant): boolean { + return v.s <= ACHROMATIC_EPSILON; +} + +function themeHuePlan( + name: string, + def: ColorDef | undefined, + variant: ResolvedColorVariant, + ctx: ChannelCtx, +): HuePlan { + if ( + def === undefined || + isShadowDef(def) || + isMixDef(def) || + isAchromatic(variant) + ) { + return { hueVar: '', inline: true, declarations: [] }; + } + + const regDef = def as RegularColorDef; + const baseHueVar = `var(--${ctx.baseName}-hue)`; + + if (regDef.hue === undefined) { + return { hueVar: baseHueVar, inline: false, declarations: [] }; + } + + const parsed = parseRelativeOrAbsolute(regDef.hue); + const prop = cssProp(ctx.prefix, name, '-hue'); + + if (parsed.relative) { + const sign = parsed.value >= 0 ? '+' : '-'; + const magnitude = Math.abs(parsed.value); + const value = `calc(var(--${ctx.baseName}-hue) ${sign} ${magnitude})`; + return { + hueVar: `var(${prop})`, + inline: false, + declarations: [{ prop, value }], + }; + } + + const absHue = ((parsed.value % 360) + 360) % 360; + return { + hueVar: `var(${prop})`, + inline: false, + declarations: [{ prop, value: String(absHue) }], + }; +} + +function standaloneHuePlan( + name: string, + variant: ResolvedColorVariant, + ctx: ChannelCtx, +): HuePlan { + if (isAchromatic(variant)) { + return { hueVar: '', inline: true, declarations: [] }; + } + + const hue = ctx.resolvedHue ?? variant.h; + const prop = cssProp(ctx.prefix, name, '-hue'); + return { + hueVar: `var(${prop})`, + inline: false, + declarations: [{ prop, value: String(hue) }], + }; +} + +export function buildHuePlan( + name: string, + def: ColorDef | undefined, + variant: ResolvedColorVariant, + ctx: ChannelCtx, +): HuePlan { + if (ctx.mode === 'standalone') { + return standaloneHuePlan(name, variant, ctx); + } + return themeHuePlan(name, def, variant, ctx); +} + +/** Collect unique hue declarations across all colors (theme + per-color). */ +export function collectHueDeclarations( + resolved: Map, + ctx: ChannelCtx, +): HueDeclaration[] { + if (ctx.emitDeclarations === false) return []; + + const seen = new Set(); + const out: HueDeclaration[] = []; + + const push = (decl: HueDeclaration): void => { + if (seen.has(decl.prop)) return; + seen.add(decl.prop); + out.push(decl); + }; + + if (ctx.mode === 'theme') { + push({ + prop: `--${ctx.baseName}-hue`, + value: String(ctx.seedHue), + }); + } + + for (const [name, color] of resolved) { + const def = ctx.defs[name]; + const plan = buildHuePlan(name, def, color.light, ctx); + for (const decl of plan.declarations) { + push(decl); + } + } + + return out; +} + +export function buildHuePlans( + resolved: Map, + ctx: ChannelCtx, +): Map { + const plans = new Map(); + for (const [name, color] of resolved) { + plans.set(name, buildHuePlan(name, ctx.defs[name], color.light, ctx)); + } + return plans; +} diff --git a/src/color-token.ts b/src/color-token.ts index 49b1025..efe12bb 100644 --- a/src/color-token.ts +++ b/src/color-token.ts @@ -15,6 +15,8 @@ */ import { defaultConfig, getConfig, mergeConfig } from './config'; +import type { ChannelCtx } from './channels'; +import { assertAllPastel, assertNativeFormat } from './format-guard'; import { hslToSrgb, oklabToOkhsl, @@ -26,23 +28,32 @@ import { isAbsoluteTone, pairNormal } from './hc-pair'; import { resolveAllColors } from './resolver'; import { buildCssMap, + buildDtcgMap, + buildDtcgResolver, buildJsonMap, + buildTailwindMap, buildTokenMap, resolveModes, } from './formatters'; import type { ColorMap, GlazeColorCssOptions, + GlazeColorDtcgResolverOptions, + GlazeColorDtcgResult, GlazeColorInput, GlazeColorInputExport, GlazeColorOverrides, GlazeColorOverridesExport, + GlazeColorTailwindOptions, GlazeColorToken, GlazeColorTokenExport, GlazeColorValue, GlazeCssResult, GlazeConfigOverride, GlazeConfigResolved, + GlazeDtcgOptions, + GlazeDtcgResolverDocument, + GlazeDtcgResult, GlazeJsonOptions, GlazeTokenOptions, OkhslColor, @@ -526,6 +537,7 @@ function buildStandaloneValueDefs( flip: options?.flip, opacity: options?.opacity, pastel: options?.pastel, + role: options?.role, base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor @@ -607,24 +619,105 @@ function createColorTokenFromDefs( tasty: tokenLike, json(options?: GlazeJsonOptions): Record { + const format = options?.format ?? 'oklch'; + assertNativeFormat(format, 'json'); const jsonMap = buildJsonMap( resolveOnce(), resolveModes(options?.modes), - options?.format, + format, effectiveConfig.pastel, ); return jsonMap[primary]; }, css(options: GlazeColorCssOptions): GlazeCssResult { + const format = options.format ?? 'rgb'; + assertNativeFormat(format, 'css'); + const resolved = resolveOnce().get(primary)!; const renamed = new Map([ - [options.name, resolveOnce().get(primary)!], + [options.name, resolved], ]); + + let channelCtx: ChannelCtx | undefined; + if (options.splitHue && format === 'oklch') { + const modes = resolveModes(); + assertAllPastel(renamed, modes); + channelCtx = { + seedHue, + baseName: options.name, + prefix: '', + defs: { [options.name]: defs[primary] }, + mode: 'standalone', + resolvedHue: resolved.light.h, + }; + } + return buildCssMap( renamed, '', options.suffix ?? '-color', - options.format ?? 'rgb', + format, + effectiveConfig.pastel, + channelCtx, + ); + }, + + dtcg(options?: GlazeDtcgOptions): GlazeColorDtcgResult { + const modes = resolveModes(options?.modes); + const doc = buildDtcgMap( + resolveOnce(), + '', + modes, + options?.colorSpace ?? 'srgb', + effectiveConfig.pastel, + ); + const result: GlazeColorDtcgResult = { light: doc.light[primary] }; + if (doc.dark) result.dark = doc.dark[primary]; + if (doc.lightContrast) { + result.lightContrast = doc.lightContrast[primary]; + } + if (doc.darkContrast) result.darkContrast = doc.darkContrast[primary]; + return result; + }, + + dtcgResolver( + options: GlazeColorDtcgResolverOptions, + ): GlazeDtcgResolverDocument { + const doc = buildDtcgMap( + resolveOnce(), + '', + resolveModes(options?.modes), + options?.colorSpace ?? 'srgb', + effectiveConfig.pastel, + ); + const name = options.name; + const result: GlazeDtcgResult = { + light: { [name]: doc.light[primary] }, + }; + if (doc.dark) result.dark = { [name]: doc.dark[primary] }; + if (doc.lightContrast) { + result.lightContrast = { [name]: doc.lightContrast[primary] }; + } + if (doc.darkContrast) { + result.darkContrast = { [name]: doc.darkContrast[primary] }; + } + return buildDtcgResolver(result, options); + }, + + tailwind(options: GlazeColorTailwindOptions): string { + const format = options.format ?? 'oklch'; + assertNativeFormat(format, 'tailwind'); + const renamed = new Map([ + [options.name, resolveOnce().get(primary)!], + ]); + return buildTailwindMap( + renamed, + '', + options.namespace ?? 'color-', + resolveModes(options?.modes), + format, + options.darkSelector ?? '.dark', + options.highContrastSelector ?? '.high-contrast', effectiveConfig.pastel, ); }, @@ -709,6 +802,7 @@ export function createColorToken( contrast: input.contrast, opacity: input.opacity, pastel: input.pastel, + role: input.role, base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor @@ -816,6 +910,7 @@ function buildOverridesExport( if (options.opacity !== undefined) out.opacity = options.opacity; if (options.name !== undefined) out.name = options.name; if (options.pastel !== undefined) out.pastel = options.pastel; + if (options.role !== undefined) out.role = options.role; if (options.base !== undefined) { out.base = isGlazeColorToken(options.base) ? options.base.export() @@ -841,6 +936,7 @@ function buildStructuredInputExport( if (input.contrast !== undefined) out.contrast = input.contrast; if (input.name !== undefined) out.name = input.name; if (input.pastel !== undefined) out.pastel = input.pastel; + if (input.role !== undefined) out.role = input.role; if (input.base !== undefined) { out.base = isGlazeColorToken(input.base) ? input.base.export() : input.base; } @@ -879,6 +975,7 @@ function rehydrateOverrides( if (data.opacity !== undefined) out.opacity = data.opacity; if (data.name !== undefined) out.name = data.name; if (data.pastel !== undefined) out.pastel = data.pastel; + if (data.role !== undefined) out.role = data.role; if (data.base !== undefined) { out.base = isExportedToken(data.base) ? colorFromExport(data.base) @@ -904,6 +1001,7 @@ function rehydrateStructuredInput( if (data.contrast !== undefined) out.contrast = data.contrast; if (data.name !== undefined) out.name = data.name; if (data.pastel !== undefined) out.pastel = data.pastel; + if (data.role !== undefined) out.role = data.role; if (data.base !== undefined) { out.base = isExportedToken(data.base) ? colorFromExport(data.base) diff --git a/src/config.ts b/src/config.ts index d3fd9b1..f25422d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -33,6 +33,7 @@ export function defaultConfig(): GlazeConfigResolved { }, autoFlip: true, pastel: false, + inferRole: true, }; } @@ -81,6 +82,7 @@ export function configure(config: GlazeConfig): void { shadowTuning: config.shadowTuning ?? globalConfig.shadowTuning, autoFlip: config.autoFlip ?? globalConfig.autoFlip, pastel: config.pastel ?? globalConfig.pastel, + inferRole: config.inferRole ?? globalConfig.inferRole, }; } @@ -111,5 +113,6 @@ export function mergeConfig( shadowTuning: override.shadowTuning ?? base.shadowTuning, autoFlip: override.autoFlip ?? base.autoFlip, pastel: override.pastel ?? base.pastel, + inferRole: override.inferRole ?? base.inferRole, }; } diff --git a/src/contrast-solver.test.ts b/src/contrast-solver.test.ts index 62e4edc..e1a1658 100644 --- a/src/contrast-solver.test.ts +++ b/src/contrast-solver.test.ts @@ -12,6 +12,7 @@ import { relativeLuminanceFromLinearRgb, contrastRatioFromLuminance, gamutClampedLuminance, + apcaLuminanceFromLinearRgb, } from './okhsl-color-math'; import { fromTone, REF_EPS } from './okhst'; import { glaze } from './glaze'; @@ -89,9 +90,26 @@ describe('contrast-solver', () => { expect(resolveContrastForMode({ apca: 60 }, false)).toEqual({ metric: 'apca', target: 60, + polarity: 'fg', }); }); + it('passes the polarity through to the APCA result', () => { + expect(resolveContrastForMode({ apca: 60 }, false, 'bg')).toEqual({ + metric: 'apca', + target: 60, + polarity: 'bg', + }); + }); + + it('resolves APCA preset keywords to Lc', () => { + expect(resolveContrastForMode({ apca: 'content' }, false).target).toBe( + 60, + ); + expect(resolveContrastForMode({ apca: 'body' }, false).target).toBe(75); + expect(resolveContrastForMode({ apca: 'min' }, false).target).toBe(15); + }); + it('resolves the inner { apca } HC pair by mode', () => { expect(resolveContrastForMode({ apca: [45, 60] }, false).target).toBe(45); expect(resolveContrastForMode({ apca: [45, 60] }, true).target).toBe(60); @@ -281,6 +299,41 @@ describe('contrast-solver', () => { expect(result.met).toBe(true); expect(result.contrast).toBeGreaterThanOrEqual(60); }); + + it('polarity orders APCA arguments (fg vs bg converge differently)', () => { + const baseLinearRgb = okhslToLinearSrgb(0, 0, 0.5); + const yBase = apcaLuminanceFromLinearRgb(baseLinearRgb); + + const fg = findToneForContrast({ + hue: 0, + saturation: 0, + preferredTone: 0.5, + baseLinearRgb, + contrast: { metric: 'apca', target: 45, polarity: 'fg' }, + }); + const bg = findToneForContrast({ + hue: 0, + saturation: 0, + preferredTone: 0.5, + baseLinearRgb, + contrast: { metric: 'apca', target: 45, polarity: 'bg' }, + }); + + expect(fg.met).toBe(true); + expect(bg.met).toBe(true); + // APCA is asymmetric, so the two argument orders converge to different tones. + expect(Math.abs(fg.tone - bg.tone)).toBeGreaterThan(0.01); + + // Each result meets the floor measured in its own polarity order. + const yFg = apcaLuminanceFromLinearRgb( + okhslToLinearSrgb(0, 0, fromTone(fg.tone * 100, REF_EPS)), + ); + expect(Math.abs(apcaContrast(yFg, yBase))).toBeGreaterThanOrEqual(45); + const yBg = apcaLuminanceFromLinearRgb( + okhslToLinearSrgb(0, 0, fromTone(bg.tone * 100, REF_EPS)), + ); + expect(Math.abs(apcaContrast(yBase, yBg))).toBeGreaterThanOrEqual(45); + }); }); describe('tone contrast-uniformity', () => { diff --git a/src/contrast-solver.ts b/src/contrast-solver.ts index 36b0768..350f33e 100644 --- a/src/contrast-solver.ts +++ b/src/contrast-solver.ts @@ -44,11 +44,57 @@ export function metricLuminance( export type ContrastPreset = 'AA' | 'AAA' | 'AA-large' | 'AAA-large'; export type MinContrast = number | ContrastPreset; +/** + * Named APCA Lc floor presets (APCA Bronze Simple Mode conformance levels), + * independent of role. Use them anywhere an APCA target is accepted. + * + * | Preset | Lc | Use case | + * | ------------- | --- | ---------------------------------------------------- | + * | `'preferred'` | 90 | Preferred body / column text | + * | `'body'` | 75 | Minimum body / column text | + * | `'content'` | 60 | Readable non-body content (~WCAG AA 4.5:1) | + * | `'large'` | 45 | Large/bold headlines; fine icons/outlines (~3:1) | + * | `'non-text'` | 30 | Solid icons/controls; placeholder/disabled text | + * | `'min'` | 15 | Dividers/decorative; APCA "point of invisibility" | + */ +export type ApcaPreset = + | 'preferred' + | 'body' + | 'content' + | 'large' + | 'non-text' + | 'min'; + +export const APCA_PRESETS: Record = { + preferred: 90, + body: 75, + content: 60, + large: 45, + 'non-text': 30, + min: 15, +}; + +/** + * Resolve an APCA target — a raw Lc number (kept as-is) or an `ApcaPreset` + * keyword mapped to its Lc value. The magnitude is forced non-negative. + */ +export function resolveApcaTarget(value: number | ApcaPreset): number { + if (typeof value === 'number') return Math.abs(value); + return APCA_PRESETS[value]; +} + /** Metric + numeric target after resolving a `ContrastSpec` for a mode. */ export interface ResolvedContrast { metric: 'wcag' | 'apca'; /** WCAG ratio (>= 1) or APCA Lc magnitude (0–106). */ target: number; + /** + * APCA argument order: which side the resolved (candidate) color plays + * against the base. `'fg'` (default) → `apcaContrast(yCandidate, yBase)`; + * `'bg'` → `apcaContrast(yBase, yCandidate)`. Always `'fg'` for WCAG + * (symmetric, ignored). + */ + polarity?: 'fg' | 'bg'; } // ============================================================================ @@ -76,19 +122,23 @@ function pickPair(p: HCPair, isHighContrast: boolean): T { /** * Resolve a `ContrastSpec` (already selected from any outer HC pair) for a * given mode into `{ metric, target }`. Handles the inner metric HC pair and - * preset resolution. + * preset resolution. `polarity` is passed through to the result for the APCA + * branch (it controls argument order in the solver); WCAG ignores it. */ export function resolveContrastForMode( spec: ContrastSpec, isHighContrast: boolean, + polarity?: 'fg' | 'bg', ): ResolvedContrast { if (typeof spec === 'number' || typeof spec === 'string') { + // A bare string here is a WCAG preset ('AA' / 'AAA' / ...). return { metric: 'wcag', target: resolveMinContrast(spec) }; } if ('apca' in spec) { return { metric: 'apca', - target: Math.abs(pickPair(spec.apca, isHighContrast)), + target: resolveApcaTarget(pickPair(spec.apca, isHighContrast)), + polarity: polarity ?? 'fg', }; } return { @@ -194,15 +244,24 @@ function cachedLuminance( /** * Score a candidate luminance against the base for a metric. Returns a value * that is `>= target` exactly when the floor is met (WCAG ratio, or APCA Lc - * magnitude). + * magnitude). For APCA, `polarity` selects the argument order: `'fg'` (the + * default) treats the candidate as the text against a background base + * (`apcaContrast(yCandidate, yBase)`); `'bg'` treats the candidate as the + * background (`apcaContrast(yBase, yCandidate)`). The magnitude is taken + * either way. WCAG is symmetric, so polarity is ignored there. */ function metricScore( metric: 'wcag' | 'apca', yCandidate: number, yBase: number, + polarity?: 'fg' | 'bg', ): number { if (metric === 'wcag') return contrastRatioFromLuminance(yCandidate, yBase); - return Math.abs(apcaContrast(yCandidate, yBase)); + const lc = + polarity === 'bg' + ? apcaContrast(yBase, yCandidate) + : apcaContrast(yCandidate, yBase); + return Math.abs(lc); } // ============================================================================ @@ -275,9 +334,10 @@ function searchBranch( epsilon: number, maxIter: number, anchor: number, + polarity?: 'fg' | 'bg', ): BranchResult { - const scoreLo = metricScore(metric, lum(lo), yBase); - const scoreHi = metricScore(metric, lum(hi), yBase); + const scoreLo = metricScore(metric, lum(lo), yBase, polarity); + const scoreHi = metricScore(metric, lum(hi), yBase, polarity); if (scoreLo < target && scoreHi < target) { return scoreLo >= scoreHi @@ -291,7 +351,7 @@ function searchBranch( for (let i = 0; i < maxIter; i++) { if (high - low < epsilon) break; const mid = (low + high) / 2; - const scoreMid = metricScore(metric, lum(mid), yBase); + const scoreMid = metricScore(metric, lum(mid), yBase, polarity); if (scoreMid >= target) { if (mid < anchor) low = mid; @@ -302,8 +362,8 @@ function searchBranch( } } - const scoreLow = metricScore(metric, lum(low), yBase); - const scoreHigh = metricScore(metric, lum(high), yBase); + const scoreLow = metricScore(metric, lum(low), yBase, polarity); + const scoreHigh = metricScore(metric, lum(high), yBase, polarity); const lowPasses = scoreLow >= target; const highPasses = scoreHigh >= target; @@ -360,8 +420,10 @@ interface SolveCoreOptions { epsilon: number; maxIterations: number; flip: boolean; - /** Force the first branch ('lower' searches `[lo, anchor]'). */ + /** Force the first branch ('lower' searches `[lo, anchor]`). */ initialIsLower: boolean; + /** APCA argument order; ignored for WCAG. Default `'fg'`. */ + polarity?: 'fg' | 'bg'; } interface SolveCoreResult { @@ -388,6 +450,7 @@ function solveNearestContrast(opts: SolveCoreOptions): SolveCoreResult { maxIterations, flip, initialIsLower, + polarity, } = opts; const runBranch = (lower: boolean): BranchResult => @@ -402,6 +465,7 @@ function solveNearestContrast(opts: SolveCoreOptions): SolveCoreResult { epsilon, maxIterations, searchAnchor, + polarity, ) : searchBranch( lum, @@ -413,6 +477,7 @@ function solveNearestContrast(opts: SolveCoreOptions): SolveCoreResult { epsilon, maxIterations, searchAnchor, + polarity, ); const initialResult = runBranch(initialIsLower); @@ -446,7 +511,7 @@ function solveNearestContrast(opts: SolveCoreOptions): SolveCoreResult { // Failure: pin to the initial direction's extreme. const extreme = initialIsLower ? lo : hi; - const scoreExtreme = metricScore(metric, lum(extreme), yBase); + const scoreExtreme = metricScore(metric, lum(extreme), yBase, polarity); return { pos: extreme, contrast: scoreExtreme, @@ -474,7 +539,7 @@ export function findToneForContrast( pastel = false, } = options; - const { metric, target } = contrast; + const { metric, target, polarity } = contrast; // Overshoot absorbs rounding in the OKHSL/OKLCH formatting pipeline. const searchTarget = metric === 'wcag' ? target * 1.01 : target + 0.5; const yBase = metricLuminance(metric, baseLinearRgb); @@ -483,7 +548,7 @@ export function findToneForContrast( const lum = (t: number): number => cachedLuminance(metric, hue, saturation, t, pastel); - const scorePref = metricScore(metric, lum(preferredTone), yBase); + const scorePref = metricScore(metric, lum(preferredTone), yBase, polarity); if (scorePref >= searchTarget) { return { @@ -513,8 +578,8 @@ export function findToneForContrast( branch: 'preferred', }; } else { - const scoreMin = metricScore(metric, lum(minT), yBase); - const scoreMax = metricScore(metric, lum(maxT), yBase); + const scoreMin = metricScore(metric, lum(minT), yBase, polarity); + const scoreMax = metricScore(metric, lum(maxT), yBase, polarity); initialIsDarker = scoreMin >= scoreMax; } @@ -546,6 +611,7 @@ export function findToneForContrast( maxIterations, flip: options.flip ?? false, initialIsLower: initialIsDarker, + polarity, }); return { @@ -603,7 +669,7 @@ export function findValueForMixContrast( maxIterations = 20, } = options; - const { metric, target } = contrast; + const { metric, target, polarity } = contrast; const searchTarget = metric === 'wcag' ? target * 1.01 : target + 0.5; const yBase = metricLuminance(metric, baseLinearRgb); @@ -611,6 +677,7 @@ export function findValueForMixContrast( metric, luminanceAtValue(preferredValue), yBase, + polarity, ); if (scorePref >= searchTarget) { return { value: preferredValue, contrast: scorePref, met: true }; @@ -626,8 +693,18 @@ export function findValueForMixContrast( } else if (!canLower && !canUpper) { return { value: preferredValue, contrast: scorePref, met: false }; } else { - const scoreLower = metricScore(metric, luminanceAtValue(0), yBase); - const scoreUpper = metricScore(metric, luminanceAtValue(1), yBase); + const scoreLower = metricScore( + metric, + luminanceAtValue(0), + yBase, + polarity, + ); + const scoreUpper = metricScore( + metric, + luminanceAtValue(1), + yBase, + polarity, + ); initialIsLower = scoreLower >= scoreUpper; } @@ -645,6 +722,7 @@ export function findValueForMixContrast( maxIterations, flip: options.flip ?? false, initialIsLower, + polarity, }); return { diff --git a/src/format-guard.ts b/src/format-guard.ts new file mode 100644 index 0000000..2fe448e --- /dev/null +++ b/src/format-guard.ts @@ -0,0 +1,74 @@ +/** + * Export-time guards for color format and channel-splitting prerequisites. + */ + +import type { + GlazeColorFormat, + GlazeOutputModes, + ResolvedColor, +} from './types'; + +const NON_NATIVE_FORMATS = new Set(['okhsl', 'okhst']); + +/** + * Throw when a non-native Glaze color space is requested for an export that + * emits raw CSS or non-Tasty token maps. + */ +export function assertNativeFormat( + format: GlazeColorFormat | undefined, + method: string, +): void { + if (format !== undefined && NON_NATIVE_FORMATS.has(format)) { + throw new Error( + `glaze: ${format} output is only supported by tasty() (not a native CSS color space). ` + + `Use tasty({ format: '${format}' }) or pick a native format (oklch|hsl|rgb) for ${method}().`, + ); + } +} + +type SchemeField = 'light' | 'dark' | 'lightContrast' | 'darkContrast'; + +const SCHEME_FIELDS: { + field: SchemeField; + modes: (modes: Required) => boolean; +}[] = [ + { field: 'light', modes: () => true }, + { field: 'dark', modes: (m) => m.dark }, + { field: 'lightContrast', modes: (m) => m.highContrast }, + { + field: 'darkContrast', + modes: (m) => m.dark && m.highContrast, + }, +]; + +/** + * Throw when `splitHue` is enabled but any exported color is not pastel. + * Hue rotation is only clip-free when chroma is bounded by the hue-independent + * safe chroma (`computeSafeChromaOKLCH`). + */ +export function assertAllPastel( + resolved: Map, + modes: Required, +): void { + const nonPastel: string[] = []; + + for (const [name, color] of resolved) { + for (const { field, modes: active } of SCHEME_FIELDS) { + if (!active(modes)) continue; + const variant = color[field]; + if (variant.pastel !== true) { + if (!nonPastel.includes(name)) nonPastel.push(name); + break; + } + } + } + + if (nonPastel.length === 0) return; + + throw new Error( + 'glaze: splitHue requires every color to be pastel (hue rotation is only ' + + 'clip-free when chroma is bounded by the hue-independent safe chroma). ' + + `Non-pastel: ${nonPastel.join(', ')}. ` + + 'Set pastel: true (global or per-color) or drop splitHue.', + ); +} diff --git a/src/formatters.ts b/src/formatters.ts index 6ef5adf..41e7cc6 100644 --- a/src/formatters.ts +++ b/src/formatters.ts @@ -2,31 +2,54 @@ * Output formatting for resolved color maps. * * Owns the CSS-string formatter dispatch table (`okhsl` / `rgb` / `hsl` / - * `oklch`) and the four token-map shapes Glaze emits: + * `oklch`) and the token-map shapes Glaze emits: * - `buildTokenMap` — Tasty style-to-state bindings (`#name` keys, state aliases). * - `buildFlatTokenMap` — `{ light, dark, ... }` per-variant maps. * - `buildJsonMap` — `{ name: { light, dark, ... } }` per-color JSON. * - `buildCssMap` — CSS custom property declaration strings per variant. + * - `buildDtcgMap` — W3C DTCG (2025.10) token documents, one per scheme. + * - `buildDtcgResolver` — W3C DTCG Resolver-Module document (one modifier, a context per scheme). + * - `buildTailwindMap` — Tailwind v4 `@theme` block + dark/HC overrides. */ +import { + buildHuePlans, + collectHueDeclarations, + type ChannelCtx, + type HuePlan, +} from './channels'; import { formatHsl, formatOkhsl, + formatOkhst, formatOklch, formatRgb, + okhslToOklch, + okhslToSrgb, + srgbToHex, } from './okhsl-color-math'; import { variantToOkhsl } from './okhst'; import { getConfig } from './config'; import type { + DtcgColorSpace, + DtcgColorToken, + DtcgColorValue, + DtcgDocument, + DtcgTokenTree, GlazeColorFormat, GlazeCssResult, + GlazeDtcgResolverDocument, + GlazeDtcgResolverOptions, + GlazeDtcgResult, GlazeOutputModes, ResolvedColor, ResolvedColorVariant, } from './types'; +export type { ChannelCtx } from './channels'; + const formatters: Record< - GlazeColorFormat, + Exclude, (h: number, s: number, l: number, pastel: boolean) => string > = { okhsl: formatOkhsl, @@ -48,13 +71,61 @@ export function formatVariant( // Per-variant `pastel` (set by the resolver from def or config fallback) // wins over the format-time fallback, so output matches resolution. const effectivePastel = v.pastel ?? pastel; + + let base: string; + if (format === 'okhst') { + base = formatOkhst(v.h, v.s * 100, v.t * 100, effectivePastel); + } else { + const { l } = variantToOkhsl(v); + base = formatters[format](v.h, v.s * 100, l * 100, effectivePastel); + } + + if (v.alpha >= 1) return base; + const closing = base.lastIndexOf(')'); + return `${base.slice(0, closing)} / ${fmt(v.alpha, 4)})`; +} + +/** + * Format a resolved variant as `oklch(L C )`, splicing a CSS hue var + * for `splitHue` exports. Falls back to inline when the plan is inline. + */ +export function formatVariantHue( + v: ResolvedColorVariant, + plan: HuePlan, + pastel = false, +): string { + const effectivePastel = v.pastel ?? pastel; const { l } = variantToOkhsl(v); - const base = formatters[format](v.h, v.s * 100, l * 100, effectivePastel); + const [L, C] = okhslToOklch(v.h, v.s, l, effectivePastel); + + let base: string; + if (plan.inline) { + if (v.s <= 1e-6) { + base = `oklch(${fmt(L, 4)} 0 0)`; + } else { + base = formatOklch(v.h, v.s * 100, l * 100, effectivePastel); + } + } else { + base = `oklch(${fmt(L, 4)} ${fmt(C, 4)} ${plan.hueVar})`; + } + if (v.alpha >= 1) return base; const closing = base.lastIndexOf(')'); return `${base.slice(0, closing)} / ${fmt(v.alpha, 4)})`; } +function formatColorValue( + v: ResolvedColorVariant, + format: GlazeColorFormat, + pastel: boolean, + huePlan?: HuePlan, +): string { + if (format === 'oklch' && huePlan !== undefined) { + return formatVariantHue(v, huePlan, pastel); + } + return formatVariant(v, format, pastel); +} + export function resolveModes( override?: GlazeOutputModes, ): Required { @@ -72,39 +143,88 @@ export function buildTokenMap( modes: Required, format: GlazeColorFormat = 'okhsl', pastel = false, + channelCtx?: ChannelCtx, ): Record> { const tokens: Record> = {}; + const huePlans = + channelCtx !== undefined && format === 'oklch' + ? buildHuePlans(resolved, channelCtx) + : undefined; - for (const [name, color] of resolved) { - const key = `#${prefix}${name}`; - const entry: Record = { - '': formatVariant(color.light, format, pastel), - }; - - if (modes.dark) { - entry[states.dark] = formatVariant(color.dark, format, pastel); + if (huePlans !== undefined && channelCtx !== undefined) { + const emitDecls = channelCtx.emitDeclarations !== false; + if (emitDecls && channelCtx.mode === 'theme') { + tokens[`#${channelCtx.baseName}-hue`] = { + '': String(channelCtx.seedHue), + }; } - if (modes.highContrast) { - entry[states.highContrast] = formatVariant( - color.lightContrast, - format, - pastel, - ); - } - if (modes.dark && modes.highContrast) { - entry[`${states.dark} & ${states.highContrast}`] = formatVariant( - color.darkContrast, + for (const [name, color] of resolved) { + const plan = huePlans.get(name)!; + if (emitDecls) { + for (const decl of plan.declarations) { + const key = `#${decl.prop.slice(2)}`; + if (!(key in tokens)) { + tokens[key] = { '': decl.value }; + } + } + } + const colorKey = `#${prefix}${name}`; + const planForColor = huePlans.get(name); + tokens[colorKey] = buildTokenEntry( + color, + states, + modes, format, pastel, + planForColor, ); } + return tokens; + } - tokens[key] = entry; + for (const [name, color] of resolved) { + const key = `#${prefix}${name}`; + tokens[key] = buildTokenEntry(color, states, modes, format, pastel); } return tokens; } +function buildTokenEntry( + color: ResolvedColor, + states: { dark: string; highContrast: string }, + modes: Required, + format: GlazeColorFormat, + pastel: boolean, + huePlan?: HuePlan, +): Record { + const entry: Record = { + '': formatColorValue(color.light, format, pastel, huePlan), + }; + + if (modes.dark) { + entry[states.dark] = formatColorValue(color.dark, format, pastel, huePlan); + } + if (modes.highContrast) { + entry[states.highContrast] = formatColorValue( + color.lightContrast, + format, + pastel, + huePlan, + ); + } + if (modes.dark && modes.highContrast) { + entry[`${states.dark} & ${states.highContrast}`] = formatColorValue( + color.darkContrast, + format, + pastel, + huePlan, + ); + } + + return entry; +} + export function buildFlatTokenMap( resolved: Map, prefix: string, @@ -188,6 +308,7 @@ export function buildCssMap( suffix: string, format: GlazeColorFormat, pastel = false, + channelCtx?: ChannelCtx, ): GlazeCssResult { const lines: Record = { light: [], @@ -196,15 +317,31 @@ export function buildCssMap( darkContrast: [], }; + const huePlans = + channelCtx !== undefined && format === 'oklch' + ? buildHuePlans(resolved, channelCtx) + : undefined; + + if (huePlans !== undefined && channelCtx !== undefined) { + for (const decl of collectHueDeclarations(resolved, channelCtx)) { + lines.light.push(`${decl.prop}: ${decl.value};`); + } + } + for (const [name, color] of resolved) { const prop = `--${prefix}${name}${suffix}`; - lines.light.push(`${prop}: ${formatVariant(color.light, format, pastel)};`); - lines.dark.push(`${prop}: ${formatVariant(color.dark, format, pastel)};`); + const plan = huePlans?.get(name); + lines.light.push( + `${prop}: ${formatColorValue(color.light, format, pastel, plan)};`, + ); + lines.dark.push( + `${prop}: ${formatColorValue(color.dark, format, pastel, plan)};`, + ); lines.lightContrast.push( - `${prop}: ${formatVariant(color.lightContrast, format, pastel)};`, + `${prop}: ${formatColorValue(color.lightContrast, format, pastel, plan)};`, ); lines.darkContrast.push( - `${prop}: ${formatVariant(color.darkContrast, format, pastel)};`, + `${prop}: ${formatColorValue(color.darkContrast, format, pastel, plan)};`, ); } @@ -215,3 +352,301 @@ export function buildCssMap( darkContrast: lines.darkContrast.join('\n'), }; } + +// ============================================================================ +// DTCG (W3C Design Tokens Format Module 2025.10) +// ============================================================================ + +function roundTo(value: number, decimals: number): number { + return parseFloat(value.toFixed(decimals)); +} + +/** + * Build a DTCG `$value` color object for a resolved variant. + * + * `srgb` (default) emits gamma sRGB components in 0–1 plus a 6-digit `hex` + * hint — the most universally understood form (Figma, Tokens Studio, Style + * Dictionary). `oklch` emits `[L, C, H]` components with no hex — Glaze-native + * and wide-gamut. `alpha` is included only when below 1. + */ +export function dtcgColorValue( + v: ResolvedColorVariant, + colorSpace: DtcgColorSpace = 'srgb', + pastel = false, +): DtcgColorValue { + const effectivePastel = v.pastel ?? pastel; + const { l } = variantToOkhsl(v); + const alpha = v.alpha < 1 ? roundTo(v.alpha, 6) : undefined; + + if (colorSpace === 'oklch') { + const [L, C, H] = okhslToOklch(v.h, v.s, l, effectivePastel); + const value: DtcgColorValue = { + colorSpace: 'oklch', + components: [roundTo(L, 6), roundTo(C, 6), roundTo(H, 4)], + }; + if (alpha !== undefined) value.alpha = alpha; + return value; + } + + const [r, g, b] = okhslToSrgb(v.h, v.s, l, effectivePastel); + const value: DtcgColorValue = { + colorSpace: 'srgb', + components: [roundTo(r, 6), roundTo(g, 6), roundTo(b, 6)], + hex: srgbToHex([r, g, b]), + }; + if (alpha !== undefined) value.alpha = alpha; + return value; +} + +function dtcgToken( + v: ResolvedColorVariant, + colorSpace: DtcgColorSpace, + pastel: boolean, +): DtcgColorToken { + return { $type: 'color', $value: dtcgColorValue(v, colorSpace, pastel) }; +} + +/** + * Build a `GlazeDtcgResult`: one spec-conformant DTCG token document per + * scheme variant, gated by `modes`. Light is always present. + */ +export function buildDtcgMap( + resolved: Map, + prefix: string, + modes: Required, + colorSpace: DtcgColorSpace = 'srgb', + pastel = false, +): GlazeDtcgResult { + const light: DtcgDocument = {}; + const dark: DtcgDocument | undefined = modes.dark ? {} : undefined; + const lightContrast: DtcgDocument | undefined = modes.highContrast + ? {} + : undefined; + const darkContrast: DtcgDocument | undefined = + modes.dark && modes.highContrast ? {} : undefined; + + for (const [name, color] of resolved) { + const key = `${prefix}${name}`; + light[key] = dtcgToken(color.light, colorSpace, pastel); + if (dark) dark[key] = dtcgToken(color.dark, colorSpace, pastel); + if (lightContrast) { + lightContrast[key] = dtcgToken(color.lightContrast, colorSpace, pastel); + } + if (darkContrast) { + darkContrast[key] = dtcgToken(color.darkContrast, colorSpace, pastel); + } + } + + return { light, dark, lightContrast, darkContrast }; +} + +// ============================================================================ +// DTCG Resolver Module (single document for all scheme variants) +// ============================================================================ + +/** + * Default context names emitted on the `scheme` modifier — the Glaze variant + * keys, so the resolver document mirrors `GlazeDtcgResult` exactly. + */ +const DEFAULT_DTCG_CONTEXT_NAMES = { + light: 'light', + dark: 'dark', + lightContrast: 'lightContrast', + darkContrast: 'darkContrast', +} as const; + +/** + * Wrap a per-scheme `GlazeDtcgResult` into a single W3C DTCG Resolver-Module + * document. The light document becomes `sets[setName].sources[0]` (the default + * context); each other present variant becomes a `contexts[ctx]` override + * array on a single `modifiers[modifierName]`. Absent variants (per the + * `modes` already applied to `result`) are omitted — light is always present + * and is the modifier `default`. Only the resolver-specific options are read; + * `modes` / `colorSpace` were already consumed by the `buildDtcgMap` call that + * produced `result`. + */ +export function buildDtcgResolver( + result: GlazeDtcgResult, + options?: GlazeDtcgResolverOptions, +): GlazeDtcgResolverDocument { + const setName = options?.setName ?? 'base'; + const modifierName = options?.modifierName ?? 'scheme'; + const ctx = { + ...DEFAULT_DTCG_CONTEXT_NAMES, + ...options?.contextNames, + }; + const contexts: Record = { + [ctx.light]: [], + }; + if (result.dark) contexts[ctx.dark] = [result.dark]; + if (result.lightContrast) { + contexts[ctx.lightContrast] = [result.lightContrast]; + } + if (result.darkContrast) contexts[ctx.darkContrast] = [result.darkContrast]; + + return { + version: options?.version ?? '2025.10', + sets: { + [setName]: { sources: [result.light] }, + }, + modifiers: { + [modifierName]: { + default: ctx.light, + contexts, + }, + }, + resolutionOrder: [ + { $ref: `#/sets/${setName}` }, + { $ref: `#/modifiers/${modifierName}` }, + ], + }; +} + +// ============================================================================ +// Tailwind CSS v4 (@theme) +// ============================================================================ + +/** Per-scheme declaration lines (`--prop: value;`) accumulated for emission. */ +export interface GlazeTailwindLines { + light: string[]; + dark: string[]; + lightContrast: string[]; + darkContrast: string[]; +} + +function tailwindLinesFor( + resolved: Map, + themePrefix: string, + cssPrefix: string, + format: GlazeColorFormat, + pastel: boolean, +): GlazeTailwindLines { + const lines: GlazeTailwindLines = { + light: [], + dark: [], + lightContrast: [], + darkContrast: [], + }; + for (const [name, color] of resolved) { + const prop = `--${cssPrefix}${themePrefix}${name}`; + lines.light.push(`${prop}: ${formatVariant(color.light, format, pastel)};`); + lines.dark.push(`${prop}: ${formatVariant(color.dark, format, pastel)};`); + lines.lightContrast.push( + `${prop}: ${formatVariant(color.lightContrast, format, pastel)};`, + ); + lines.darkContrast.push( + `${prop}: ${formatVariant(color.darkContrast, format, pastel)};`, + ); + } + return lines; +} + +function indentBlock(text: string, pad: string): string { + return text + .split('\n') + .map((line) => (line.length === 0 ? line : pad + line)) + .join('\n'); +} + +function emitRule(selector: string, body: string): string { + return `${selector} {\n${indentBlock(body, ' ')}\n}`; +} + +/** + * Emit a CSS block for a set of declarations scoped by one or more selectors + * / at-rules. Class-like selectors concatenate (`.dark.high-contrast`); + * at-rules (`@media …`) nest `:root` (or the chained selector) inside. + */ +function emitScoped( + scopes: string[], + declarations: string[], +): string | undefined { + if (declarations.length === 0) return undefined; + + const atRules: string[] = []; + let selectorChain = ''; + for (const scope of scopes) { + if (scope.startsWith('@')) atRules.push(scope); + else selectorChain += scope; + } + const selector = selectorChain || ':root'; + + let css = emitRule(selector, declarations.join('\n')); + for (const rule of atRules) { + css = emitRule(rule, css); + } + return css; +} + +/** + * Render accumulated per-scheme declaration lines as a Tailwind v4 CSS string: + * an `@theme` block (light baseline) plus dark / high-contrast overrides under + * the configured selectors. Empty blocks are skipped. + */ +export function emitTailwindCss( + lines: GlazeTailwindLines, + modes: Required, + darkSelector: string, + highContrastSelector: string, +): string { + const blocks: string[] = []; + + if (lines.light.length > 0) { + blocks.push(emitRule('@theme', lines.light.join('\n'))); + } + + if (modes.dark) { + const dark = emitScoped([darkSelector], lines.dark); + if (dark) blocks.push(dark); + } + if (modes.highContrast) { + const hc = emitScoped([highContrastSelector], lines.lightContrast); + if (hc) blocks.push(hc); + } + if (modes.dark && modes.highContrast) { + const dhc = emitScoped( + [darkSelector, highContrastSelector], + lines.darkContrast, + ); + if (dhc) blocks.push(dhc); + } + + return blocks.join('\n\n'); +} + +/** + * Build per-scheme declaration lines for a single theme (used by + * `theme.tailwind()` and as the palette `buildOne` step). + */ +export function buildTailwindLines( + resolved: Map, + themePrefix: string, + cssPrefix: string, + format: GlazeColorFormat, + pastel: boolean, +): GlazeTailwindLines { + return tailwindLinesFor(resolved, themePrefix, cssPrefix, format, pastel); +} + +/** + * Build a complete Tailwind v4 CSS string for a single theme. + */ +export function buildTailwindMap( + resolved: Map, + themePrefix: string, + cssPrefix: string, + modes: Required, + format: GlazeColorFormat, + darkSelector: string, + highContrastSelector: string, + pastel = false, +): string { + const lines = tailwindLinesFor( + resolved, + themePrefix, + cssPrefix, + format, + pastel, + ); + return emitTailwindCss(lines, modes, darkSelector, highContrastSelector); +} diff --git a/src/glaze.test.ts b/src/glaze.test.ts index 9e661f0..a9377b1 100644 --- a/src/glaze.test.ts +++ b/src/glaze.test.ts @@ -3,10 +3,16 @@ import { contrastRatioFromLuminance, okhslToLinearSrgb, gamutClampedLuminance, + apcaLuminanceFromLinearRgb, parseHex, } from './okhsl-color-math'; +import { apcaContrast } from './contrast-solver'; import { variantToOkhsl } from './okhst'; -import type { GlazeColorTokenExport, ResolvedColorVariant } from './types'; +import type { + DtcgColorToken, + GlazeColorTokenExport, + ResolvedColorVariant, +} from './types'; /** OKHSL lightness (0–1) of a resolved variant (stored as tone). */ function llOf(v: ResolvedColorVariant): number { @@ -24,6 +30,25 @@ function variantContrast( return contrastRatioFromLuminance(yA, yB); } +/** APCA Lc magnitude of `candidate` against `base`, ordered by `polarity`. */ +function variantApca( + candidate: ResolvedColorVariant, + base: ResolvedColorVariant, + polarity: 'fg' | 'bg', +): number { + const cc = variantToOkhsl(candidate); + const cb = variantToOkhsl(base); + const yC = apcaLuminanceFromLinearRgb( + okhslToLinearSrgb(cc.h, cc.s, cc.l, candidate.pastel), + ); + const yB = apcaLuminanceFromLinearRgb( + okhslToLinearSrgb(cb.h, cb.s, cb.l, base.pastel), + ); + return Math.abs( + polarity === 'bg' ? apcaContrast(yB, yC) : apcaContrast(yC, yB), + ); +} + describe('glaze', () => { beforeEach(() => { glaze.resetConfig(); @@ -774,6 +799,149 @@ describe('glaze', () => { }); }); + describe('role inference', () => { + it("infers 'border' from the name with no special pastel default", () => { + const theme = glaze(280, 60); + theme.colors({ + surface: { tone: 90 }, + border: { base: 'surface', tone: '-10' }, + }); + const r = theme.resolve(); + // Borders fall through to the config pastel default (no special default). + expect(r.get('border')!.light.pastel).toBe(false); + }); + + it('border pastel follows the global config like any other color', () => { + glaze.configure({ pastel: true }); + const theme = glaze(280, 60); + theme.colors({ + surface: { tone: 90 }, + border: { base: 'surface', tone: '-10' }, + text: { base: 'surface', tone: '-40' }, + }); + const r = theme.resolve(); + expect(r.get('border')!.light.pastel).toBe(true); + expect(r.get('text')!.light.pastel).toBe(true); + }); + + it('explicit pastel on a border still applies', () => { + const theme = glaze(280, 60); + theme.colors({ + surface: { tone: 90 }, + border: { base: 'surface', tone: '-10', pastel: true }, + }); + const r = theme.resolve(); + expect(r.get('border')!.light.pastel).toBe(true); + }); + + it('non-border names keep the config pastel default (false)', () => { + const theme = glaze(280, 60); + theme.colors({ + surface: { tone: 90 }, + text: { base: 'surface', tone: '-40' }, + }); + const r = theme.resolve(); + expect(r.get('text')!.light.pastel).toBe(false); + }); + + it('last recognized name token wins (button-text -> text, input-bg -> surface)', () => { + // Observable via APCA polarity: a text (fg) and a surface (bg) against + // the same base converge to different tones for the same Lc floor. + const theme = glaze(0, 50); + theme.colors({ + bg: { tone: 80 }, + 'button-text': { base: 'bg', contrast: { apca: 45 } }, + 'input-bg': { base: 'bg', contrast: { apca: 45 } }, + }); + const r = theme.resolve(); + const asText = r.get('button-text')!.light; + const asSurface = r.get('input-bg')!.light; + const base = r.get('bg')!.light; + // 'button-text' infers text (fg); 'input-bg' infers surface (bg). + expect(variantApca(asText, base, 'fg')).toBeGreaterThanOrEqual(45); + expect(variantApca(asSurface, base, 'bg')).toBeGreaterThanOrEqual(45); + expect(Math.abs(llOf(asText) - llOf(asSurface))).toBeGreaterThan(0.01); + }); + + it('explicit role overrides name inference', () => { + const theme = glaze(0, 50); + theme.colors({ + bg: { tone: 90 }, + // Named like text but forced to a surface role. + text: { base: 'bg', role: 'surface', contrast: { apca: 45 } }, + // A plain text name with default role (inferred text). + label: { base: 'bg', contrast: { apca: 45 } }, + }); + const r = theme.resolve(); + const asSurface = r.get('text')!.light; + const asText = r.get('label')!.light; + const base = r.get('bg')!.light; + // Polarity flips the APCA argument order, so the two converge differently. + expect(Math.abs(llOf(asSurface) - llOf(asText))).toBeGreaterThan(0.01); + expect(variantApca(asSurface, base, 'bg')).toBeGreaterThanOrEqual(45); + expect(variantApca(asText, base, 'fg')).toBeGreaterThanOrEqual(45); + }); + + it("uses the opposite of the base's role when the name does not infer", () => { + const theme = glaze(0, 50); + theme.colors({ + bg: { tone: 90 }, // name 'bg' infers surface + accent: { base: 'bg', contrast: { apca: 45 } }, // no keyword -> opposite of base + }); + const r = theme.resolve(); + // base 'bg' is a surface -> 'accent' defaults to text (fg polarity). + const base = r.get('bg')!.light; + const accent = r.get('accent')!.light; + expect(variantApca(accent, base, 'fg')).toBeGreaterThanOrEqual(45); + }); + + it('inferRole: false skips name inference and falls back to the base opposite', () => { + const theme = glaze(0, 50, { inferRole: false }); + theme.colors({ + surface: { tone: 90 }, + border: { base: 'surface', tone: '-10' }, + }); + const r = theme.resolve(); + // Without inference, 'border' is just a name; base 'surface' infers... + // but inference is off, so 'surface' falls to its default (root -> text), + // and 'border' takes the opposite -> surface. No pastel default applies. + expect(r.get('border')!.light.pastel).toBe(false); + }); + + it('APCA preset keywords resolve to role-independent Lc floors', () => { + const theme = glaze(0, 0); + theme.colors({ + bg: { tone: 97 }, + body: { base: 'bg', contrast: { apca: 'content' } }, + divider: { base: 'bg', role: 'border', contrast: { apca: 'min' } }, + }); + const r = theme.resolve(); + const base = r.get('bg')!.light; + // 'content' -> Lc 60 + expect( + variantApca(r.get('body')!.light, base, 'fg'), + ).toBeGreaterThanOrEqual(60); + // 'min' -> Lc 15 (border role -> bg-ordered? border is fg polarity) + expect( + variantApca(r.get('divider')!.light, base, 'fg'), + ).toBeGreaterThanOrEqual(15); + }); + + it('back-compat: a dependent with no role defaults to foreground (fg)', () => { + const theme = glaze(0, 0); + theme.colors({ + bg: { tone: 97 }, + accent: { base: 'bg', tone: 50, contrast: { apca: 60 } }, + }); + const r = theme.resolve(); + // 'accent' name does not infer; base 'bg' infers surface -> accent is fg. + const base = r.get('bg')!.light; + expect( + variantApca(r.get('accent')!.light, base, 'fg'), + ).toBeGreaterThanOrEqual(60); + }); + }); + describe('extend', () => { it('inherits color defs and overrides the seed hue', () => { const base = glaze(280, 80); @@ -807,7 +975,7 @@ describe('glaze', () => { const theme = glaze(280, 80); theme.colors({ surface: { tone: 97 } }); const tokens = theme.tokens(); - expect(tokens.light.surface).toMatch(/^okhsl\(/); + expect(tokens.light.surface).toMatch(/^oklch\(/); expect(tokens.dark.surface).toBeDefined(); }); }); @@ -817,7 +985,7 @@ describe('glaze', () => { const theme = glaze(280, 80); theme.colors({ surface: { tone: 97 } }); const json = theme.json(); - expect(json.surface.light).toMatch(/^okhsl\(/); + expect(json.surface.light).toMatch(/^oklch\(/); expect(json.surface.dark).toBeDefined(); }); }); @@ -855,7 +1023,7 @@ describe('glaze', () => { const json = setup().json(); expect(json.primary).toBeDefined(); expect(json.danger).toBeDefined(); - expect(json.primary.surface.light).toMatch(/^okhsl\(/); + expect(json.primary.surface.light).toMatch(/^oklch\(/); }); it('defaults to prefix: true for palette tokens', () => { @@ -981,13 +1149,36 @@ describe('glaze', () => { }); describe('format option', () => { - it('supports rgb / hsl / okhsl / oklch output', () => { + it('supports rgb / hsl / oklch output on tokens and json', () => { const theme = glaze(280, 80); theme.colors({ surface: { tone: 97 } }); expect(theme.tokens({ format: 'rgb' }).light.surface).toMatch(/^rgb/); expect(theme.tokens({ format: 'hsl' }).light.surface).toMatch(/^hsl/); - expect(theme.tokens({ format: 'okhsl' }).light.surface).toMatch(/^okhsl/); expect(theme.tokens({ format: 'oklch' }).light.surface).toMatch(/^oklch/); + expect(theme.json({ format: 'oklch' }).surface.light).toMatch(/^oklch\(/); + }); + + it('rejects okhsl and okhst on non-tasty exports', () => { + const theme = glaze(280, 80); + theme.colors({ surface: { tone: 97 } }); + for (const format of ['okhsl', 'okhst'] as const) { + expect(() => theme.tokens({ format })).toThrow( + /only supported by tasty/, + ); + expect(() => theme.json({ format })).toThrow(/only supported by tasty/); + expect(() => theme.css({ format })).toThrow(/only supported by tasty/); + expect(() => theme.tailwind({ format })).toThrow( + /only supported by tasty/, + ); + } + }); + + it('emits okhst via tasty()', () => { + const theme = glaze(280, 80); + theme.colors({ surface: { tone: 97 } }); + expect(theme.tasty({ format: 'okhst' })['#surface']['']).toMatch( + /^okhst\(/, + ); }); }); @@ -1001,6 +1192,470 @@ describe('glaze', () => { }); }); + describe('splitHue export', () => { + function pastelTheme() { + glaze.configure({ pastel: true }); + const theme = glaze(240, 18); + theme.colors({ + surface: { tone: 35 }, + accent: { hue: '+20', tone: 52, saturation: 0.5 }, + }); + return theme; + } + + it('throws when any color is not pastel', () => { + const theme = glaze(240, 18); + theme.colors({ surface: { tone: 35 } }); + expect(() => theme.css({ format: 'oklch', splitHue: true })).toThrow( + /requires every color to be pastel/, + ); + }); + + it('css emits theme hue var and var()-referenced oklch colors', () => { + const theme = pastelTheme(); + const css = theme.css({ + format: 'oklch', + splitHue: true, + name: 'brand', + }); + expect(css.light).toContain('--brand-hue: 240;'); + expect(css.light).toContain('--accent-hue: calc(var(--brand-hue) + 20);'); + expect(css.light).toMatch( + /--surface-color: oklch\([^)]*var\(--brand-hue\)/, + ); + expect(css.light).toMatch( + /--accent-color: oklch\([^)]*var\(--accent-hue\)/, + ); + }); + + it('tasty emits #brand-hue and var()-referenced oklch colors', () => { + const theme = pastelTheme(); + const tokens = theme.tasty({ + format: 'oklch', + splitHue: true, + name: 'brand', + }); + expect(tokens['#brand-hue']['']).toBe('240'); + expect(tokens['#accent-hue']['']).toBe('calc(var(--brand-hue) + 20)'); + expect(tokens['#surface']['']).toMatch(/oklch\([^)]*var\(--brand-hue\)/); + expect(tokens['#accent']['']).toMatch(/oklch\([^)]*var\(--accent-hue\)/); + }); + + it('is a no-op for hsl and rgb formats', () => { + const theme = pastelTheme(); + const inline = theme.css({ format: 'rgb' }); + const withFlag = theme.css({ format: 'rgb', splitHue: true }); + expect(withFlag.light).toBe(inline.light); + }); + + it('palette scopes hue vars per theme', () => { + glaze.configure({ pastel: true }); + const brand = glaze(240, 18); + brand.colors({ surface: { tone: 35 } }); + const accent = brand.extend({ hue: 23 }); + accent.colors({ surface: { tone: 40 } }); + const palette = glaze.palette({ brand, accent }, { primary: 'brand' }); + const css = palette.css({ + format: 'oklch', + splitHue: true, + }); + expect(css.light).toContain('--brand-hue: 240;'); + expect(css.light).toContain('--accent-hue: 23;'); + expect(css.light).toMatch( + /--surface-color: oklch\([^)]*var\(--brand-hue\)/, + ); + expect(css.light).toMatch( + /--accent-surface-color: oklch\([^)]*var\(--accent-hue\)/, + ); + }); + + it('standalone css emits constant --name-hue for pastel tokens', () => { + const color = glaze.color({ + hue: 240, + saturation: 18, + tone: 52, + pastel: true, + }); + const css = color.css({ + name: 'brand', + format: 'oklch', + splitHue: true, + }); + expect(css.light).toContain('--brand-hue: 240;'); + expect(css.light).toMatch( + /--brand-color: oklch\([^)]*var\(--brand-hue\)/, + ); + }); + + it('standalone css throws when token is not pastel', () => { + const color = glaze.color({ hue: 240, saturation: 18, tone: 52 }); + expect(() => + color.css({ name: 'brand', format: 'oklch', splitHue: true }), + ).toThrow(/requires every color to be pastel/); + }); + + it('inlines achromatic, shadow, and mix colors and preserves alpha', () => { + glaze.configure({ pastel: true }); + const theme = glaze(240, 18); + theme.colors({ + surface: { tone: 50 }, + accent: { hue: 280, tone: 52 }, + border: { tone: 50, saturation: 0 }, + text: { base: 'surface', tone: 5, contrast: 4.5 }, + shadow: { + type: 'shadow', + bg: 'surface', + fg: 'text', + intensity: 0.5, + }, + ghost: { + type: 'mix', + base: 'surface', + target: 'accent', + value: 0.5, + }, + overlay: { tone: 50, opacity: 0.5 }, + }); + const css = theme.css({ + format: 'oklch', + splitHue: true, + name: 'brand', + }); + // absolute hue override → per-color var + expect(css.light).toContain('--accent-hue: 280;'); + // achromatic → inline oklch(L 0 0), no hue var + expect(css.light).toMatch(/--border-color: oklch\([\d.]+ 0 0\)/); + // shadow → inline (no var()), with alpha + expect(css.light).toMatch( + /--shadow-color: oklch\([\d.]+ [\d.]+ [\d.]+ \/ [\d.]+\)/, + ); + expect(css.light).not.toMatch(/--shadow-color:[^;]*var\(/); + // mix → inline (no var()) + expect(css.light).toMatch(/--ghost-color: oklch\([\d.]+ [\d.]+ [\d.]+\)/); + expect(css.light).not.toMatch(/--ghost-color:[^;]*var\(/); + // alpha < 1 preserved with hue var + expect(css.light).toMatch( + /--overlay-color: oklch\([^)]*var\(--brand-hue\) \/ 0.5\)/, + ); + }); + + it('does not re-emit hue vars for the palette primary unprefixed alias', () => { + glaze.configure({ pastel: true }); + const brand = glaze(240, 18); + brand.colors({ surface: { tone: 35 }, accent: { hue: '+20', tone: 52 } }); + const warning = glaze(23, 18); + warning.colors({ surface: { tone: 40 } }); + const palette = glaze.palette({ brand, warning }, { primary: 'brand' }); + const css = palette.css({ format: 'oklch', splitHue: true }); + // brand-hue declared once (by the prefixed pass) + expect(css.light.match(/--brand-hue: 240;/g)).toHaveLength(1); + // unprefixed primary alias references the themed per-color hue var + expect(css.light).toMatch( + /--accent-color: oklch\([^)]*var\(--brand-accent-hue\)/, + ); + // no unprefixed --accent-hue colliding with the warning theme's base + expect(css.light).not.toMatch(/--accent-hue: calc/); + }); + + it('okhst round-trips through the color parser', () => { + const color = glaze.color('okhst(280 60% 52%)'); + expect(color.tasty({ format: 'okhst' })['']).toBe('okhst(280 60% 52%)'); + }); + + it('okhst pastel output renders identically to the non-pastel equivalent', () => { + const pastel = glaze.color( + { hue: 280, saturation: 80, tone: 52 }, + { pastel: true }, + ); + const okhstStr = pastel.tasty({ format: 'okhst' })['']; + // Re-parse the emitted okhst string as a non-pastel color; it should + // render the same 8-bit RGB as the original pastel token (2-decimal + // saturation rounding stays within 8-bit quantization). + const reparsed = glaze.color(okhstStr); + const round8 = (s: string): string => + s + .match(/[\d.]+/g)! + .map((n) => String(Math.round(Number(n)))) + .join(' '); + expect(round8(reparsed.css({ name: 'x', format: 'rgb' }).light)).toBe( + round8(pastel.css({ name: 'x', format: 'rgb' }).light), + ); + }); + }); + + describe('DTCG export', () => { + it('emits a spec-conformant color token per scheme (srgb)', () => { + const theme = glaze(280, 80); + theme.colors({ surface: { tone: 97 } }); + const dtcg = theme.dtcg(); + const lightToken = dtcg.light.surface; + expect(lightToken.$type).toBe('color'); + const value = lightToken.$value; + expect(value.colorSpace).toBe('srgb'); + expect(value.components).toHaveLength(3); + for (const c of value.components) { + expect(c).toBeGreaterThanOrEqual(0); + expect(c).toBeLessThanOrEqual(1); + } + // No alpha when opaque. + expect(value.alpha).toBeUndefined(); + // hex is a 7-char #rrggbb and parses back to the same components. + expect(value.hex).toMatch(/^#[0-9a-f]{6}$/); + const [r, g, b] = parseHex(value.hex)!; + expect(r).toBeCloseTo(value.components[0], 2); + expect(g).toBeCloseTo(value.components[1], 2); + expect(b).toBeCloseTo(value.components[2], 2); + // dark is present by default. + expect(dtcg.dark?.surface.$value.colorSpace).toBe('srgb'); + }); + + it('emits oklch components with no hex', () => { + const theme = glaze(280, 80); + theme.colors({ surface: { tone: 97 } }); + const value = theme.dtcg({ colorSpace: 'oklch' }).light.surface.$value; + expect(value.colorSpace).toBe('oklch'); + expect(value.components).toHaveLength(3); + // L in 0..1, C >= 0, H in 0..360. + expect(value.components[0]).toBeGreaterThanOrEqual(0); + expect(value.components[0]).toBeLessThanOrEqual(1); + expect(value.components[1]).toBeGreaterThanOrEqual(0); + expect(value.components[2]).toBeGreaterThanOrEqual(0); + expect(value.components[2]).toBeLessThanOrEqual(360); + expect((value as { hex?: string }).hex).toBeUndefined(); + }); + + it('gates dark / high-contrast by modes', () => { + const theme = glaze(280, 80); + theme.colors({ surface: { tone: 97 } }); + const noDark = theme.dtcg({ modes: { dark: false } }); + expect(noDark.light).toBeDefined(); + expect(noDark.dark).toBeUndefined(); + const withHc = theme.dtcg({ modes: { highContrast: true } }); + expect(withHc.lightContrast).toBeDefined(); + expect(withHc.darkContrast).toBeDefined(); + }); + + it('includes alpha when opacity is below 1', () => { + const theme = glaze(280, 80); + theme.colors({ surface: { tone: 97, opacity: 0.5 } }); + const value = theme.dtcg().light.surface.$value; + expect(value.alpha).toBeCloseTo(0.5, 4); + }); + + it('palette dtcg prefixes and duplicates the primary theme', () => { + const primary = glaze(280, 80); + primary.colors({ surface: { tone: 97 } }); + const danger = primary.extend({ hue: 23 }); + const palette = glaze.palette({ primary, danger }); + const dtcg = palette.dtcg({ primary: 'primary' }); + expect(dtcg.light['primary-surface']).toBeDefined(); + expect(dtcg.light['danger-surface']).toBeDefined(); + // primary duplication → unprefixed alias + expect(dtcg.light['surface']).toBeDefined(); + expect(dtcg.light['surface']).toEqual(dtcg.light['primary-surface']); + }); + }); + + describe('DTCG Resolver-Module export', () => { + it('wraps every scheme variant into one resolver document', () => { + const theme = glaze(280, 80); + theme.colors({ surface: { tone: 97 } }); + const doc = theme.dtcgResolver({ modes: { highContrast: true } }); + const full = theme.dtcg({ modes: { highContrast: true } }); + + expect(doc.version).toBe('2025.10'); + // The light document is the default set source. + expect(doc.sets.base.sources[0]).toEqual(full.light); + // Single `scheme` modifier, light is the default context (no overrides). + expect(doc.modifiers.scheme.default).toBe('light'); + expect(doc.modifiers.scheme.contexts.light).toEqual([]); + // Each other context holds that variant's full document. + expect(doc.modifiers.scheme.contexts.dark?.[0]).toEqual(full.dark); + expect(doc.modifiers.scheme.contexts.lightContrast?.[0]).toEqual( + full.lightContrast, + ); + expect(doc.modifiers.scheme.contexts.darkContrast?.[0]).toEqual( + full.darkContrast, + ); + expect(doc.resolutionOrder).toEqual([ + { $ref: '#/sets/base' }, + { $ref: '#/modifiers/scheme' }, + ]); + }); + + it('gates dark / high-contrast contexts by modes', () => { + const theme = glaze(280, 80); + theme.colors({ surface: { tone: 97 } }); + const noHc = theme.dtcgResolver({ modes: { highContrast: false } }); + expect(noHc.modifiers.scheme.contexts.dark).toBeDefined(); + expect(noHc.modifiers.scheme.contexts.lightContrast).toBeUndefined(); + expect(noHc.modifiers.scheme.contexts.darkContrast).toBeUndefined(); + + const noDark = theme.dtcgResolver({ modes: { dark: false } }); + expect(noDark.modifiers.scheme.contexts.dark).toBeUndefined(); + expect(noDark.modifiers.scheme.contexts.light).toEqual([]); + }); + + it('flows colorSpace through to every source and context', () => { + const theme = glaze(280, 80); + theme.colors({ surface: { tone: 97 } }); + const doc = theme.dtcgResolver({ colorSpace: 'oklch' }); + const base = doc.sets.base.sources[0] as Record; + const dark = doc.modifiers.scheme.contexts.dark?.[0] as Record< + string, + DtcgColorToken + >; + expect(base.surface.$value.colorSpace).toBe('oklch'); + expect((base.surface.$value as { hex?: string }).hex).toBeUndefined(); + expect(dark.surface.$value.colorSpace).toBe('oklch'); + }); + + it('honors custom set / modifier / context names', () => { + const theme = glaze(280, 80); + theme.colors({ surface: { tone: 97 } }); + const doc = theme.dtcgResolver({ + setName: 'tokens', + modifierName: 'theme', + contextNames: { dark: 'night' }, + }); + expect(doc.sets.tokens).toBeDefined(); + expect(doc.modifiers.theme).toBeDefined(); + expect(doc.modifiers.theme.default).toBe('light'); + expect(doc.modifiers.theme.contexts.night).toBeDefined(); + expect(doc.modifiers.theme.contexts.dark).toBeUndefined(); + expect(doc.resolutionOrder).toEqual([ + { $ref: '#/sets/tokens' }, + { $ref: '#/modifiers/theme' }, + ]); + }); + + it('includes alpha when opacity is below 1', () => { + const theme = glaze(280, 80); + theme.colors({ surface: { tone: 97, opacity: 0.5 } }); + const doc = theme.dtcgResolver(); + const base = doc.sets.base.sources[0] as Record; + expect(base.surface.$value.alpha).toBeCloseTo(0.5, 4); + }); + + it('palette dtcgResolver prefixes and duplicates the primary theme', () => { + const primary = glaze(280, 80); + primary.colors({ surface: { tone: 97 } }); + const danger = primary.extend({ hue: 23 }); + const palette = glaze.palette({ primary, danger }); + const doc = palette.dtcgResolver({ primary: 'primary' }); + + const baseKeys = Object.keys(doc.sets.base.sources[0]); + expect(baseKeys).toContain('primary-surface'); + expect(baseKeys).toContain('danger-surface'); + // primary duplication → unprefixed alias + expect(baseKeys).toContain('surface'); + // The dark context mirrors the same prefixed / aliased keys. + const darkKeys = Object.keys(doc.modifiers.scheme.contexts.dark[0]); + expect(darkKeys).toContain('primary-surface'); + expect(darkKeys).toContain('surface'); + expect(darkKeys).toContain('danger-surface'); + }); + + it('standalone color dtcgResolver keys the token by name per context', () => { + const color = glaze.color({ hue: 280, saturation: 80, tone: 52 }); + const doc = color.dtcgResolver({ + name: 'brand', + modes: { highContrast: true }, + }); + const base = doc.sets.base.sources[0] as Record; + expect(base.brand.$type).toBe('color'); + expect(base.brand.$value.colorSpace).toBe('srgb'); + const dark = doc.modifiers.scheme.contexts.dark?.[0] as Record< + string, + DtcgColorToken + >; + const darkContrast = doc.modifiers.scheme.contexts + .darkContrast?.[0] as Record; + // dark and darkContrast are distinct, resolved variants — not layered. + expect(dark.brand.$value).not.toEqual(base.brand.$value); + expect(darkContrast.brand.$value).not.toEqual(dark.brand.$value); + }); + }); + + describe('Tailwind export', () => { + it('emits an @theme block plus a .dark override', () => { + const theme = glaze(280, 80); + theme.colors({ surface: { tone: 97 } }); + const css = theme.tailwind(); + expect(css).toContain('@theme'); + expect(css).toMatch(/--color-surface:\s*oklch\(/); + expect(css).toContain('.dark'); + // The @theme block precedes the .dark override. + expect(css.indexOf('@theme')).toBeLessThan(css.indexOf('.dark')); + }); + + it('gates dark / high-contrast overrides by modes', () => { + const theme = glaze(280, 80); + theme.colors({ surface: { tone: 97 } }); + const noDark = theme.tailwind({ modes: { dark: false } }); + expect(noDark).toContain('@theme'); + expect(noDark).not.toContain('.dark'); + const withHc = theme.tailwind({ modes: { highContrast: true } }); + expect(withHc).toContain('.high-contrast'); + expect(withHc).toContain('.dark.high-contrast'); + }); + + it('honors custom namespace, format, and dark selector', () => { + const theme = glaze(280, 80); + theme.colors({ surface: { tone: 97 } }); + const css = theme.tailwind({ + namespace: 'tw-', + format: 'rgb', + darkSelector: '[data-theme="dark"]', + }); + expect(css).toMatch(/--tw-surface:\s*rgb\(/); + expect(css).toContain('[data-theme="dark"]'); + expect(css).not.toContain('.dark'); + }); + + it('nests :root inside an at-rule dark selector', () => { + const theme = glaze(280, 80); + theme.colors({ surface: { tone: 97 } }); + const css = theme.tailwind({ + darkSelector: '@media (prefers-color-scheme: dark)', + }); + expect(css).toContain('@media (prefers-color-scheme: dark)'); + // The dark declarations live inside a :root nested in the media query. + const mediaIdx = css.indexOf('@media'); + const rootIdx = css.indexOf(':root', mediaIdx); + expect(rootIdx).toBeGreaterThan(mediaIdx); + }); + + it('palette tailwind merges themes under one @theme block', () => { + const primary = glaze(280, 80); + primary.colors({ surface: { tone: 97 } }); + const danger = primary.extend({ hue: 23 }); + const palette = glaze.palette({ primary, danger }); + const css = palette.tailwind({ primary: 'primary' }); + // Exactly one @theme block, containing both prefixed keys + the + // unprefixed primary alias. + expect(css.match(/@theme/g)).toHaveLength(1); + expect(css).toMatch(/--color-primary-surface:/); + expect(css).toMatch(/--color-danger-surface:/); + expect(css).toMatch(/--color-surface:/); + }); + + it('palette tailwind separates theme prefix from the css namespace', () => { + const primary = glaze(280, 80); + primary.colors({ surface: { tone: 97 } }); + const danger = primary.extend({ hue: 23 }); + const palette = glaze.palette({ primary, danger }); + // `prefix` controls theme prefixing; `namespace` controls --. + const css = palette.tailwind({ + prefix: { primary: 'p-', danger: 'd-' }, + namespace: 'color-', + }); + expect(css).toMatch(/--color-p-surface:/); + expect(css).toMatch(/--color-d-surface:/); + expect(css).not.toMatch(/--color-primary-surface:/); + }); + }); + describe('glaze.color standalone', () => { it('resolves a structured color with tone', () => { const color = glaze.color({ hue: 280, saturation: 80, tone: 52 }); @@ -1037,11 +1692,30 @@ describe('glaze', () => { expect(resolved.dark.t).toBeGreaterThan(0.4); }); - it('exports token / tasty / json with okhsl strings', () => { + it('exports token / tasty with okhsl and json with oklch by default', () => { const color = glaze.color({ hue: 280, saturation: 80, tone: 52 }); expect(color.token()['']).toMatch(/^okhsl\(/); expect(color.tasty()['']).toMatch(/^okhsl\(/); - expect(color.json().light).toMatch(/^okhsl\(/); + expect(color.json().light).toMatch(/^oklch\(/); + }); + + it('emits okhst via token() and tasty()', () => { + const color = glaze.color({ hue: 280, saturation: 80, tone: 52 }); + expect(color.token({ format: 'okhst' })['']).toMatch(/^okhst\(/); + expect(color.tasty({ format: 'okhst' })['']).toMatch(/^okhst\(/); + }); + + it('rejects okhsl and okhst on css / json / tailwind', () => { + const color = glaze.color({ hue: 280, saturation: 80, tone: 52 }); + for (const format of ['okhsl', 'okhst'] as const) { + expect(() => color.css({ name: 'brand', format })).toThrow( + /only supported by tasty/, + ); + expect(() => color.json({ format })).toThrow(/only supported by tasty/); + expect(() => color.tailwind({ name: 'brand', format })).toThrow( + /only supported by tasty/, + ); + } }); it('supports format option', () => { @@ -1050,6 +1724,30 @@ describe('glaze', () => { expect(color.json({ format: 'hsl' }).light).toMatch(/^hsl\(/); }); + it('exports dtcg tokens per scheme', () => { + const color = glaze.color({ hue: 280, saturation: 80, tone: 52 }); + const dtcg = color.dtcg(); + expect(dtcg.light.$type).toBe('color'); + expect(dtcg.light.$value.colorSpace).toBe('srgb'); + expect(dtcg.light.$value.components).toHaveLength(3); + expect(dtcg.dark?.$value.colorSpace).toBe('srgb'); + }); + + it('exports dtcg in oklch color space', () => { + const color = glaze.color({ hue: 280, saturation: 80, tone: 52 }); + const value = color.dtcg({ colorSpace: 'oklch' }).light.$value; + expect(value.colorSpace).toBe('oklch'); + expect((value as { hex?: string }).hex).toBeUndefined(); + }); + + it('exports a tailwind @theme block for a given name', () => { + const color = glaze.color({ hue: 280, saturation: 80, tone: 52 }); + const css = color.tailwind({ name: 'brand' }); + expect(css).toContain('@theme'); + expect(css).toMatch(/--color-brand:\s*oklch\(/); + expect(css).toContain('.dark'); + }); + describe('value-shorthand', () => { it('accepts a 6-digit hex and preserves it in light', () => { const resolved = glaze.color('#26fcb2').resolve(); diff --git a/src/index.ts b/src/index.ts index d8ff78e..461d3fb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -51,23 +51,49 @@ export type { GlazeColorOverrides, GlazeColorOverridesExport, GlazeColorCssOptions, + GlazeDtcgOptions, + GlazeDtcgResult, + GlazeColorDtcgResult, + GlazeTailwindOptions, + GlazeColorTailwindOptions, + DtcgColorSpace, + DtcgSrgbColorValue, + DtcgOklchColorValue, + DtcgColorValue, + DtcgColorToken, + DtcgDocument, + DtcgTokenTree, + DtcgResolverSet, + DtcgResolverModifier, + DtcgResolverRef, + GlazeDtcgResolverDocument, + GlazeDtcgResolverOptions, + GlazeDtcgResolverContextNames, + GlazeColorDtcgResolverOptions, GlazeFromInput, GlazeShadowInput, GlazePalette, GlazePaletteOptions, GlazePaletteExportOptions, + Role, + RoleInput, } from './types'; +export type { ChannelCtx, HuePlan, HueDeclaration } from './channels'; +export { assertNativeFormat, assertAllPastel } from './format-guard'; + // Re-export contrast solver utilities for advanced use export { findToneForContrast, findValueForMixContrast, resolveMinContrast, resolveContrastForMode, + resolveApcaTarget, apcaContrast, } from './contrast-solver'; export type { ContrastPreset, + ApcaPreset, ResolvedContrast, FindToneForContrastOptions, FindToneForContrastResult, @@ -75,6 +101,15 @@ export type { FindValueForMixContrastResult, } from './contrast-solver'; +// Re-export role helpers for advanced use +export { + normalizeRole, + inferRoleFromName, + roleToPolarity, + oppositeRole, +} from './roles'; +export type { Polarity } from './roles'; + // Re-export OKHST tone utilities for advanced use export { toTone, @@ -104,5 +139,8 @@ export { formatOkhsl, formatRgb, formatHsl, + formatOkhst, formatOklch, + srgbToHex, + okhslToOklch, } from './okhsl-color-math'; diff --git a/src/okhsl-color-math.ts b/src/okhsl-color-math.ts index 23e4ba4..4cecbe1 100644 --- a/src/okhsl-color-math.ts +++ b/src/okhsl-color-math.ts @@ -751,6 +751,32 @@ export function formatOkhsl( return `okhsl(${fmt(h, 2)} ${fmt(outS, 2)}% ${fmt(l, 2)}%)`; } +/** + * Format OKHST values as a CSS `okhst(H S% T%)` string. + * h: 0–360, s: 0–100, t: 0–100 (percentage scale for s and t). + * + * Pastel recompute matches `formatOkhsl`: convert via OKLab so external + * parsers that only understand non-pastel OKHST render identically. + */ +export function formatOkhst( + h: number, + s: number, + t: number, + pastel = false, +): string { + let outS = s; + if (pastel) { + const REF_EPS = 0.05; + const den = Math.log(1 + REF_EPS) - Math.log(REF_EPS); + const y = Math.exp((t / 100) * den + Math.log(REF_EPS)) - REF_EPS; + const l = toe(Math.cbrt(Math.max(0, y))); + const oklab = okhslToOklab(h, s / 100, l, true); + const normalOkhsl = oklabToOkhsl(oklab, false); + outS = normalOkhsl[1] * 100; + } + return `okhst(${fmt(h, 2)} ${fmt(outS, 2)}% ${fmt(t, 2)}%)`; +} + /** * Format OKHSL values as a CSS `rgb(R G B)` string. * Uses 2 decimal places to avoid 8-bit quantization contrast loss. @@ -811,10 +837,41 @@ export function formatOklch( l: number, pastel = false, ): string { - const [L, a, b] = okhslToOklab(h, s / 100, l / 100, pastel); - const C = Math.sqrt(a * a + b * b); - let hh = Math.atan2(b, a) * (180 / Math.PI); - hh = constrainAngle(hh); - + const [L, C, hh] = okhslToOklch(h, s / 100, l / 100, pastel); return `oklch(${fmt(L, 4)} ${fmt(C, 4)} ${fmt(hh, 2)})`; } + +// ============================================================================ +// Structured (non-string) color accessors — used by the DTCG exporter. +// ============================================================================ + +/** + * Convert gamma-encoded sRGB channels (0–1) to a 6-digit lowercase hex + * string (`#rrggbb`). Channels are clamped to [0,1] and rounded to 8-bit. + * Alpha is not encoded here — DTCG carries it as a separate `alpha` field. + */ +export function srgbToHex(rgb: [number, number, number]): `#${string}` { + const toByte = (c: number): number => + Math.max(0, Math.min(255, Math.round(c * 255))); + const r = toByte(rgb[0]); + const g = toByte(rgb[1]); + const b = toByte(rgb[2]); + return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; +} + +/** + * Convert OKHSL (h: 0–360, s: 0–1, l: 0–1) to OKLCH components `[L, C, H]`. + * L: 0–1, C: 0–~0.4 (chroma), H: 0–360 (hue). Shared by `formatOklch` and + * the DTCG `oklch` colorSpace exporter so the two never drift apart. + */ +export function okhslToOklch( + h: number, + s: number, + l: number, + pastel = false, +): [number, number, number] { + const [L, a, b] = okhslToOklab(h, s, l, pastel); + const C = Math.sqrt(a * a + b * b); + const hh = constrainAngle(Math.atan2(b, a) * (180 / Math.PI)); + return [L, C, hh]; +} diff --git a/src/palette.ts b/src/palette.ts index 4858480..ed5ac16 100644 --- a/src/palette.ts +++ b/src/palette.ts @@ -3,31 +3,44 @@ * * Composes multiple themes into a single token namespace with optional * theme-name prefixes and a "primary theme" that also surfaces an - * unprefixed copy of its tokens. All four export methods (`tokens` / - * `tasty` / `json` / `css`) share a `buildPaletteOutput` driver that - * handles validation, per-theme iteration, prefix resolution, collision - * filtering, and primary duplication. + * unprefixed copy of its tokens. All seven export methods (`tokens` / + * `tasty` / `json` / `css` / `dtcg` / `dtcgResolver` / `tailwind`) share a + * `buildPaletteOutput` driver that handles validation, per-theme iteration, + * prefix resolution, collision filtering, and primary duplication. */ +import type { ChannelCtx } from './channels'; import { getConfig } from './config'; +import { assertAllPastel, assertNativeFormat } from './format-guard'; import { buildCssMap, + buildDtcgMap, + buildDtcgResolver, buildFlatTokenMap, buildJsonMap, + buildTailwindLines, buildTokenMap, + emitTailwindCss, resolveModes, } from './formatters'; import type { + ColorMap, GlazeCssOptions, GlazeCssResult, + GlazeDtcgOptions, + GlazeDtcgResolverDocument, + GlazeDtcgResolverOptions, + GlazeDtcgResult, GlazeJsonOptions, GlazePalette, GlazePaletteExportOptions, GlazePaletteOptions, + GlazeTailwindOptions, GlazeTheme, GlazeTokenOptions, ResolvedColor, } from './types'; +import type { GlazeTailwindLines } from './formatters'; type PaletteInput = Record; @@ -99,6 +112,42 @@ function filterCollisions( return filtered; } +function colorMapFromTheme(theme: GlazeTheme): ColorMap { + const defs: ColorMap = {}; + for (const name of theme.list()) { + const def = theme.color(name); + if (def !== undefined) defs[name] = def; + } + return defs; +} + +function channelCtxForTheme( + theme: GlazeTheme, + themeName: string, + passPrefix: string, + themedPrefix: string, + splitHue: boolean | undefined, + format: string, + modes: ReturnType, + filtered: Map, +): ChannelCtx | undefined { + if (!splitHue || format !== 'oklch') return undefined; + assertAllPastel(filtered, modes); + return { + seedHue: theme.hue, + baseName: themeName, + // Hue var names always follow the themed prefix so the primary's + // unprefixed alias references `--{themeName}-*-hue` rather than colliding + // with other themes' base vars. + prefix: themedPrefix, + defs: colorMapFromTheme(theme), + mode: 'theme', + // Emit declarations only in the pass whose color-prop prefix matches the + // themed prefix (the prefixed pass, or the single pass when prefix:false). + emitDeclarations: passPrefix === themedPrefix, + }; +} + /** * Shared per-theme driver for `tokens` / `tasty` / `css`. `json` skips * this because it doesn't do collision filtering or primary duplication. @@ -116,6 +165,8 @@ function buildPaletteOutput( resolved: Map, prefix: string, pastel: boolean, + themeName: string, + theme: GlazeTheme, ) => T, merge: (acc: R, part: T) => void, empty: () => R, @@ -136,7 +187,7 @@ function buildPaletteOutput( const pastel = theme.getConfig().pastel; const prefix = resolvePrefix(options, themeName, true); const filtered = filterCollisions(resolved, prefix, seen, themeName); - merge(acc, buildOne(filtered, prefix, pastel)); + merge(acc, buildOne(filtered, prefix, pastel, themeName, theme)); if (themeName === effectivePrimary) { const primaryFiltered = filterCollisions( @@ -146,7 +197,7 @@ function buildPaletteOutput( themeName, true, ); - merge(acc, buildOne(primaryFiltered, '', pastel)); + merge(acc, buildOne(primaryFiltered, '', pastel, themeName, theme)); } } @@ -159,10 +210,45 @@ export function createPalette( ): GlazePalette { validatePrimaryTheme(paletteOptions?.primary, themes); + const buildDtcgResult = ( + options?: GlazeDtcgOptions & GlazePaletteExportOptions, + ): GlazeDtcgResult => { + const modes = resolveModes(options?.modes); + const colorSpace = options?.colorSpace ?? 'srgb'; + return buildPaletteOutput( + themes, + paletteOptions, + options, + (filtered, prefix, pastel, _themeName, _theme) => + buildDtcgMap(filtered, prefix, modes, colorSpace, pastel), + (acc, part) => { + Object.assign(acc.light, part.light); + if (part.dark) { + acc.dark = Object.assign(acc.dark ?? {}, part.dark); + } + if (part.lightContrast) { + acc.lightContrast = Object.assign( + acc.lightContrast ?? {}, + part.lightContrast, + ); + } + if (part.darkContrast) { + acc.darkContrast = Object.assign( + acc.darkContrast ?? {}, + part.darkContrast, + ); + } + }, + () => ({ light: {} }), + ); + }; + return { tokens( options?: GlazeJsonOptions & GlazePaletteExportOptions, ): Record> { + const format = options?.format ?? 'oklch'; + assertNativeFormat(format, 'tokens'); const modes = resolveModes(options?.modes); return buildPaletteOutput< Record>, @@ -172,7 +258,7 @@ export function createPalette( paletteOptions, options, (filtered, prefix, pastel) => - buildFlatTokenMap(filtered, prefix, modes, options?.format, pastel), + buildFlatTokenMap(filtered, prefix, modes, format, pastel), (acc, part) => { for (const variant of Object.keys(part)) { if (!acc[variant]) { @@ -194,6 +280,7 @@ export function createPalette( highContrast: options?.states?.highContrast ?? cfg.states.highContrast, }; const modes = resolveModes(options?.modes); + const format = options?.format ?? 'okhsl'; return buildPaletteOutput< Record>, Record> @@ -201,15 +288,28 @@ export function createPalette( themes, paletteOptions, options, - (filtered, prefix, pastel) => - buildTokenMap( + (filtered, prefix, pastel, themeName, theme) => { + const themedPrefix = resolvePrefix(options, themeName, true); + const channelCtx = channelCtxForTheme( + theme, + themeName, + prefix, + themedPrefix, + options?.splitHue, + format, + modes, + filtered, + ); + return buildTokenMap( filtered, prefix, states, modes, - options?.format, + format, pastel, - ), + channelCtx, + ); + }, (acc, part) => Object.assign(acc, part), () => ({}), ); @@ -220,6 +320,8 @@ export function createPalette( prefix?: boolean | Record; }, ): Record>> { + const format = options?.format ?? 'oklch'; + assertNativeFormat(format, 'json'); const modes = resolveModes(options?.modes); const result: Record>> = {}; @@ -228,7 +330,7 @@ export function createPalette( result[themeName] = buildJsonMap( resolved, modes, - options?.format, + format, theme.getConfig().pastel, ); } @@ -239,6 +341,8 @@ export function createPalette( css(options?: GlazeCssOptions & GlazePaletteExportOptions): GlazeCssResult { const suffix = options?.suffix ?? '-color'; const format = options?.format ?? 'rgb'; + assertNativeFormat(format, 'css'); + const modes = resolveModes(); const lines = buildPaletteOutput< GlazeCssResult, @@ -247,8 +351,27 @@ export function createPalette( themes, paletteOptions, options, - (filtered, prefix, pastel) => - buildCssMap(filtered, prefix, suffix, format, pastel), + (filtered, prefix, pastel, themeName, theme) => { + const themedPrefix = resolvePrefix(options, themeName, true); + const channelCtx = channelCtxForTheme( + theme, + themeName, + prefix, + themedPrefix, + options?.splitHue, + format, + modes, + filtered, + ); + return buildCssMap( + filtered, + prefix, + suffix, + format, + pastel, + channelCtx, + ); + }, (acc, part) => { for (const key of [ 'light', @@ -276,5 +399,55 @@ export function createPalette( darkContrast: lines.darkContrast.join('\n'), }; }, + + dtcg( + options?: GlazeDtcgOptions & GlazePaletteExportOptions, + ): GlazeDtcgResult { + return buildDtcgResult(options); + }, + + dtcgResolver( + options?: GlazeDtcgResolverOptions & GlazePaletteExportOptions, + ): GlazeDtcgResolverDocument { + return buildDtcgResolver(buildDtcgResult(options), options); + }, + + tailwind( + options?: GlazeTailwindOptions & GlazePaletteExportOptions, + ): string { + const modes = resolveModes(options?.modes); + const cssPrefix = options?.namespace ?? 'color-'; + const format = options?.format ?? 'oklch'; + assertNativeFormat(format, 'tailwind'); + const darkSelector = options?.darkSelector ?? '.dark'; + const highContrastSelector = + options?.highContrastSelector ?? '.high-contrast'; + + const lines = buildPaletteOutput( + themes, + paletteOptions, + options, + (filtered, prefix, pastel, _themeName, _theme) => + buildTailwindLines(filtered, prefix, cssPrefix, format, pastel), + (acc, part) => { + for (const variant of [ + 'light', + 'dark', + 'lightContrast', + 'darkContrast', + ] as const) { + acc[variant].push(...part[variant]); + } + }, + () => ({ + light: [], + dark: [], + lightContrast: [], + darkContrast: [], + }), + ); + + return emitTailwindCss(lines, modes, darkSelector, highContrastSelector); + }, }; } diff --git a/src/resolver.ts b/src/resolver.ts index 83dc6f8..efa3c45 100644 --- a/src/resolver.ts +++ b/src/resolver.ts @@ -36,6 +36,12 @@ import { parseToneValue, resolveEffectiveHue, } from './hc-pair'; +import { + inferRoleFromName, + normalizeRole, + oppositeRole, + roleToPolarity, +} from './roles'; import { computeShadow, circularLerp, @@ -65,6 +71,7 @@ import type { RegularColorDef, ResolvedColor, ResolvedColorVariant, + Role, ShadowColorDef, } from './types'; @@ -75,6 +82,8 @@ export interface ResolveContext { resolved: Map; /** Fully-merged effective config for this resolve pass. */ config: GlazeConfigResolved; + /** Per-name role memo (filled lazily by `resolveRole`). */ + roles: Map; } type ResolvedField = 'light' | 'dark' | 'lightContrast' | 'darkContrast'; @@ -112,12 +121,90 @@ function toToneVariant(v: OkhslVariant): ResolvedColorVariant { return { h: c.h, s: c.s, t: c.t, alpha: v.alpha }; } +// ============================================================================ +// Role resolution +// ============================================================================ + +/** + * Resolve the role of a base color referenced by `baseName`, returning the + * role the *dependent* color should take (the opposite of the base's role). + * A base that lives in `defs` recursively resolves and is inverted via + * `oppositeRole`; an external base (no local def, e.g. an injected standalone + * token) is treated as a background, so the dependent defaults to foreground + * (`'text'`). + */ +function resolveBaseRoleInMap( + baseName: string | undefined, + defs: ColorMap, + inferRole: boolean, + roles: Map, +): Role | undefined { + if (!baseName) return undefined; + const baseDef = defs[baseName]; + if (!baseDef) return 'text'; + return oppositeRole( + resolveRoleInMap(baseName, baseDef, defs, inferRole, roles), + ); +} + +/** + * Role-resolution core that does not need a full `ResolveContext`. Shared by + * the resolver (via `resolveRole`) and `verifyContrastDrift`. + */ +function resolveRoleInMap( + name: string, + def: ColorDef, + defs: ColorMap, + inferRole: boolean, + roles: Map, +): Role { + const cached = roles.get(name); + if (cached) return cached; + + let role: Role | undefined; + if (isShadowDef(def)) { + role = 'surface'; + } else if (isMixDef(def)) { + role = + normalizeRole(def.role) ?? + (inferRole ? inferRoleFromName(name) : undefined) ?? + resolveBaseRoleInMap(def.base, defs, inferRole, roles) ?? + 'text'; + } else { + const regDef = def as RegularColorDef; + role = + normalizeRole(regDef.role) ?? + (inferRole ? inferRoleFromName(name) : undefined) ?? + resolveBaseRoleInMap(regDef.base, defs, inferRole, roles) ?? + 'text'; + } + + const finalRole = role ?? 'text'; + roles.set(name, finalRole); + return finalRole; +} + +/** + * Resolve a color's semantic `role` (text / surface / border) per the chain: + * 1. explicit `def.role` (normalized) + * 2. inferred from the color name when `config.inferRole` is on + * 3. opposite of the base's role + * 4. `'text'` (foreground) default + * + * Memoized on `ctx.roles` so the four scheme passes share one resolution. + * Shadows have no contrast participation and default to `'surface'`. + */ +function resolveRole(name: string, def: ColorDef, ctx: ResolveContext): Role { + return resolveRoleInMap(name, def, ctx.defs, ctx.config.inferRole, ctx.roles); +} + function resolveContrastSpec( spec: HCPair, isHighContrast: boolean, + polarity?: 'fg' | 'bg', ): ResolvedContrast { const outer = isHighContrast ? pairHC(spec) : pairNormal(spec); - return resolveContrastForMode(outer, isHighContrast); + return resolveContrastForMode(outer, isHighContrast, polarity); } /** @@ -155,6 +242,8 @@ function resolveDependentColor( isHighContrast: boolean, isDark: boolean, effectiveHue: number, + polarity: 'fg' | 'bg', + effectivePastel: boolean, ): { tone: number; satFactor: number } { const baseName = def.base!; const baseResolved = ctx.resolved.get(baseName); @@ -167,7 +256,7 @@ function resolveDependentColor( const mode = def.mode ?? 'auto'; const satFactor = clamp(def.saturation ?? 1, 0, 1); const flip = def.flip ?? ctx.config.autoFlip; - const pastel = def.pastel ?? ctx.config.pastel; + const pastel = effectivePastel; const baseVariant = getSchemeVariant(baseResolved, isDark, isHighContrast); const baseTone = baseVariant.t * 100; @@ -221,7 +310,11 @@ function resolveDependentColor( const rawContrast = def.contrast; if (rawContrast !== undefined) { - const resolvedContrast = resolveContrastSpec(rawContrast, isHighContrast); + const resolvedContrast = resolveContrastSpec( + rawContrast, + isHighContrast, + polarity, + ); const effectiveSat = isDark ? mapSaturationDark((satFactor * ctx.saturation) / 100, mode, ctx.config) @@ -284,13 +377,15 @@ function resolveColorForScheme( } if (isMixDef(def)) { - return resolveMixForScheme(def, ctx, isDark, isHighContrast); + return resolveMixForScheme(name, def, ctx, isDark, isHighContrast); } const regDef = def as RegularColorDef; const mode = regDef.mode ?? 'auto'; const isRoot = isAbsoluteTone(regDef.tone) && !regDef.base; const effectiveHue = resolveEffectiveHue(ctx.hue, regDef.hue); + const role = resolveRole(name, def, ctx); + const polarity = roleToPolarity(role); const pastel = regDef.pastel ?? ctx.config.pastel; let finalTone: number; @@ -314,6 +409,8 @@ function resolveColorForScheme( isHighContrast, isDark, effectiveHue, + polarity, + pastel, ); finalTone = dep.tone; satFactor = dep.satFactor; @@ -411,6 +508,7 @@ function linearRgbToToneVariant( } function resolveMixForScheme( + name: string, def: MixColorDef, ctx: ResolveContext, isDark: boolean, @@ -430,6 +528,8 @@ function resolveMixForScheme( const blend = def.blend ?? 'opaque'; const space = def.space ?? 'okhsl'; + const role = resolveRole(name, def, ctx); + const polarity = roleToPolarity(role); const pastel = def.pastel ?? ctx.config.pastel; const baseLinear = okhslVariantToLinearRgb( baseVariant, @@ -441,7 +541,11 @@ function resolveMixForScheme( ); if (def.contrast !== undefined) { - const resolvedContrast = resolveContrastSpec(def.contrast, isHighContrast); + const resolvedContrast = resolveContrastSpec( + def.contrast, + isHighContrast, + polarity, + ); const metric = resolvedContrast.metric; let luminanceAt: (v: number) => number; @@ -570,6 +674,7 @@ function verifyContrastDrift( result: Map, config: GlazeConfigResolved, ): void { + const roles = new Map(); for (const name of order) { const def = defs[name]; if (isShadowDef(def) || isMixDef(def)) continue; @@ -579,6 +684,9 @@ function verifyContrastDrift( const base = result.get(regDef.base); if (!color || !base) continue; + const role = resolveRoleInMap(name, def, defs, config.inferRole, roles); + const polarity = roleToPolarity(role); + const schemes: { isDark: boolean; isHighContrast: boolean; @@ -591,7 +699,11 @@ function verifyContrastDrift( ]; for (const s of schemes) { - const spec = resolveContrastSpec(regDef.contrast, s.isHighContrast); + const spec = resolveContrastSpec( + regDef.contrast, + s.isHighContrast, + polarity, + ); const cVariant = color[s.field]; const bVariant = base[s.field]; const cOkhsl = toOkhslVariant(cVariant); @@ -631,6 +743,7 @@ export function resolveAllColors( defs, resolved: new Map(), config, + roles: new Map(), }; // Pre-seed externally-resolved bases. The per-pass loops iterate only diff --git a/src/roles.ts b/src/roles.ts new file mode 100644 index 0000000..9fd1546 --- /dev/null +++ b/src/roles.ts @@ -0,0 +1,157 @@ +/** + * Semantic color role resolution. + * + * A `role` fixes APCA contrast polarity (which side is the foreground vs the + * background). Roles are resolved per color via a four-step chain (see + * `resolveRole`): explicit `def.role` → name inference → opposite of the + * base's role → `'text'` foreground default. + * + * This module owns the alias keyword sets, name tokenization, and the + * role → polarity / opposite-role mappings. It has no dependencies. + */ + +import type { Role, RoleInput } from './types'; + +// ============================================================================ +// Keyword sets +// ============================================================================ + +const SURFACE_KEYWORDS = new Set([ + 'surface', + 'bg', + 'background', + 'fill', + 'canvas', + 'paper', + 'layer', +]); + +const TEXT_KEYWORDS = new Set([ + 'text', + 'fg', + 'foreground', + 'content', + 'ink', + 'label', + 'stroke', +]); + +const BORDER_KEYWORDS = new Set([ + 'border', + 'divider', + 'outline', + 'separator', + 'hairline', + 'rule', +]); + +const ALIAS_TO_ROLE: Record = { + // surface + surface: 'surface', + bg: 'surface', + background: 'surface', + fill: 'surface', + canvas: 'surface', + paper: 'surface', + layer: 'surface', + // text + text: 'text', + fg: 'text', + foreground: 'text', + content: 'text', + ink: 'text', + label: 'text', + stroke: 'text', + // border + border: 'border', + divider: 'border', + outline: 'border', + separator: 'border', + hairline: 'border', + rule: 'border', +}; + +// ============================================================================ +// Normalization +// ============================================================================ + +/** + * Normalize a `RoleInput` (canonical value or alias) into a canonical `Role`. + * Returns `undefined` for unrecognized strings so callers can fall through to + * the next step of the resolution chain. + */ +export function normalizeRole(input: RoleInput | undefined): Role | undefined { + if (input === undefined) return undefined; + return ALIAS_TO_ROLE[input]; +} + +// ============================================================================ +// Name inference +// ============================================================================ + +/** + * Tokenize a color name into lowercase keyword tokens, splitting on + * non-alphanumeric boundaries and at camelCase boundaries. Examples: + * - `'button-text'` → `['button', 'text']` + * - `'inputBg'` → `['input', 'bg']` + * - `'card_border-outline'` → `['card', 'border', 'outline']` + */ +function tokenizeName(name: string): string[] { + // Split on non-alphanumeric, then split camelCase within each piece. + const pieces = name.split(/[^0-9a-zA-Z]+/).filter(Boolean); + const tokens: string[] = []; + for (const piece of pieces) { + // Split at the boundary between a lowercase/digit run and an uppercase + // letter (camelCase humps), e.g. "inputBg" → ["input", "Bg"]. + const sub = piece + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .split(/\s+/) + .filter(Boolean); + for (const s of sub) tokens.push(s.toLowerCase()); + } + return tokens; +} + +/** + * Infer a `Role` from a color name by matching its tokens against the role + * keyword sets. When multiple tokens match, the **last** recognized token + * wins (so `button-text` → `text`, `input-bg` → `surface`, `card-border` → + * `border`). Returns `undefined` when no token matches. + */ +export function inferRoleFromName(name: string): Role | undefined { + const tokens = tokenizeName(name); + let inferred: Role | undefined; + for (const token of tokens) { + if (SURFACE_KEYWORDS.has(token)) inferred = 'surface'; + else if (TEXT_KEYWORDS.has(token)) inferred = 'text'; + else if (BORDER_KEYWORDS.has(token)) inferred = 'border'; + } + return inferred; +} + +// ============================================================================ +// Polarity + opposites +// ============================================================================ + +/** APCA argument order: which side the resolved color plays. */ +export type Polarity = 'fg' | 'bg'; + +/** + * Map a role to its APCA polarity. `text` and `border` are foreground spots + * against their base (the candidate is the text argument); `surface` is the + * background (the base is the text argument). + */ +export function roleToPolarity(role: Role): Polarity { + return role === 'surface' ? 'bg' : 'fg'; +} + +/** + * The opposite role of `role`, used when a color with no explicit role and no + * inferable name depends on a base: the dependent color plays the opposite + * role of its base. `surface` ↔ `text`; `border` is treated as a foreground + * spot, so its opposite is `surface`. + */ +export function oppositeRole(role: Role): Role { + if (role === 'surface') return 'text'; + return 'surface'; +} diff --git a/src/theme.ts b/src/theme.ts index 5a0cc67..06b44d8 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -3,7 +3,8 @@ * * Wraps a hue/saturation seed, a mutable `ColorMap`, and an optional * per-theme `GlazeConfigOverride`. Exposes `tokens()` / `tasty()` / - * `json()` / `css()` / `resolve()` / `export()` / `extend()`. + * `json()` / `css()` / `dtcg()` / `dtcgResolver()` / `tailwind()` / `resolve()` / + * `export()` / `extend()`. * * The per-theme config override is **merged over the live global config at * resolve time** so the theme still reacts to later `configure()` calls @@ -11,11 +12,16 @@ * `configVersion` to avoid rebuilding it on every export call. */ +import type { ChannelCtx } from './channels'; import { getConfig, getConfigVersion, mergeConfig } from './config'; +import { assertAllPastel, assertNativeFormat } from './format-guard'; import { buildCssMap, + buildDtcgMap, + buildDtcgResolver, buildFlatTokenMap, buildJsonMap, + buildTailwindMap, buildTokenMap, resolveModes, } from './formatters'; @@ -27,8 +33,13 @@ import type { GlazeCssResult, GlazeConfigOverride, GlazeConfigResolved, + GlazeDtcgOptions, + GlazeDtcgResolverDocument, + GlazeDtcgResolverOptions, + GlazeDtcgResult, GlazeExtendOptions, GlazeJsonOptions, + GlazeTailwindOptions, GlazeTheme, GlazeThemeExport, GlazeTokenOptions, @@ -68,6 +79,32 @@ export function createTheme( cache = null; } + function channelCtxFor( + options: + | { + splitHue?: boolean; + name?: string; + format?: GlazeCssOptions['format']; + modes?: GlazeJsonOptions['modes']; + } + | undefined, + formatDefault: 'rgb' | 'oklch' | 'okhsl', + prefix: string, + ): ChannelCtx | undefined { + const format = options?.format ?? formatDefault; + if (!options?.splitHue || format !== 'oklch') return undefined; + const resolved = resolveCached(); + const modes = resolveModes(options?.modes); + assertAllPastel(resolved, modes); + return { + seedHue: hue, + baseName: options.name ?? 'theme', + prefix, + defs: colorDefs, + mode: 'theme', + }; + } + const theme: GlazeTheme = { get hue() { return hue; @@ -155,12 +192,14 @@ export function createTheme( }, tokens(options?: GlazeJsonOptions): Record> { + const format = options?.format ?? 'oklch'; + assertNativeFormat(format, 'tokens'); const modes = resolveModes(options?.modes); return buildFlatTokenMap( resolveCached(), '', modes, - options?.format, + format, getEffectiveConfig().pastel, ); }, @@ -172,32 +211,81 @@ export function createTheme( highContrast: options?.states?.highContrast ?? cfg.states.highContrast, }; const modes = resolveModes(options?.modes); + const format = options?.format ?? 'okhsl'; + const channelCtx = channelCtxFor(options, 'okhsl', ''); return buildTokenMap( resolveCached(), '', states, modes, - options?.format, + format, cfg.pastel, + channelCtx, ); }, json(options?: GlazeJsonOptions): Record> { + const format = options?.format ?? 'oklch'; + assertNativeFormat(format, 'json'); const modes = resolveModes(options?.modes); return buildJsonMap( resolveCached(), modes, - options?.format, + format, getEffectiveConfig().pastel, ); }, css(options?: GlazeCssOptions): GlazeCssResult { + const format = options?.format ?? 'rgb'; + assertNativeFormat(format, 'css'); + const channelCtx = channelCtxFor(options, 'rgb', ''); return buildCssMap( resolveCached(), '', options?.suffix ?? '-color', - options?.format ?? 'rgb', + format, + getEffectiveConfig().pastel, + channelCtx, + ); + }, + + dtcg(options?: GlazeDtcgOptions): GlazeDtcgResult { + const modes = resolveModes(options?.modes); + return buildDtcgMap( + resolveCached(), + '', + modes, + options?.colorSpace ?? 'srgb', + getEffectiveConfig().pastel, + ); + }, + + dtcgResolver( + options?: GlazeDtcgResolverOptions, + ): GlazeDtcgResolverDocument { + const result = buildDtcgMap( + resolveCached(), + '', + resolveModes(options?.modes), + options?.colorSpace ?? 'srgb', + getEffectiveConfig().pastel, + ); + return buildDtcgResolver(result, options); + }, + + tailwind(options?: GlazeTailwindOptions): string { + const format = options?.format ?? 'oklch'; + assertNativeFormat(format, 'tailwind'); + const modes = resolveModes(options?.modes); + return buildTailwindMap( + resolveCached(), + '', + options?.namespace ?? 'color-', + modes, + format, + options?.darkSelector ?? '.dark', + options?.highContrastSelector ?? '.high-contrast', getEffectiveConfig().pastel, ); }, diff --git a/src/types.ts b/src/types.ts index 2bcd440..7a474a3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,7 +2,7 @@ * Glaze type definitions. */ -import type { ContrastPreset } from './contrast-solver'; +import type { ApcaPreset, ContrastPreset } from './contrast-solver'; // ============================================================================ // Value types @@ -19,17 +19,54 @@ export type MinContrast = number | ContrastPreset; * * - `number` / `ContrastPreset`: a WCAG ratio (bare form). * - `{ wcag }`: WCAG ratio or preset, optionally an HC pair. - * - `{ apca }`: APCA Lc target (absolute value), optionally an HC pair. + * - `{ apca }`: APCA Lc target (absolute value or preset), optionally an HC pair. * * The `[normal, highContrast]` pair may live at the outer level * (`[4.5, 7]`, `[{ wcag: 4.5 }, { wcag: 7 }]`) or inside the metric - * (`{ wcag: [4.5, 7] }`, `{ apca: [45, 60] }`). + * (`{ wcag: [4.5, 7] }`, `{ apca: [45, 60] }`, `{ apca: ['content', 'body'] }`). */ export type ContrastSpec = | number | ContrastPreset | { wcag: HCPair } - | { apca: HCPair }; + | { apca: HCPair }; + +// ============================================================================ +// Color role +// ============================================================================ + +/** + * The semantic role a color plays against its base, used to fix APCA contrast + * polarity (which side is the foreground vs the background). WCAG is + * symmetric, so role never changes WCAG results. + */ +export type Role = 'text' | 'surface' | 'border'; + +/** + * Any string accepted as a `role`. Canonical values plus aliases normalized by + * `normalizeRole` (see `roles.ts`): `surface` (bg/background/fill/canvas/ + * paper/layer), `text` (fg/foreground/content/ink/label/stroke), `border` + * (divider/outline/separator/hairline/rule). + */ +export type RoleInput = + | Role + | 'bg' + | 'background' + | 'fill' + | 'canvas' + | 'paper' + | 'layer' + | 'fg' + | 'foreground' + | 'content' + | 'ink' + | 'label' + | 'stroke' + | 'divider' + | 'outline' + | 'separator' + | 'hairline' + | 'rule'; export type AdaptationMode = 'auto' | 'fixed' | 'static'; @@ -55,7 +92,7 @@ export type ExtremeValue = 'max' | 'min'; export type ToneValue = number | RelativeValue | ExtremeValue; /** Color format for output. */ -export type GlazeColorFormat = 'okhsl' | 'rgb' | 'hsl' | 'oklch'; +export type GlazeColorFormat = 'okhsl' | 'okhst' | 'rgb' | 'hsl' | 'oklch'; /** * Controls which scheme variants are generated in the export. @@ -164,6 +201,17 @@ export interface RegularColorDef { */ pastel?: boolean; + /** + * Semantic role against `base`: how this color is used. Fixes APCA contrast + * polarity (the argument order in `apcaContrast`). WCAG is symmetric so it + * never affects WCAG results. + * + * Resolution: explicit `role` wins; else inferred from the color name when + * `inferRole` is enabled (default); else the opposite of the base's role; + * else defaults to `'text'` (foreground). + */ + role?: RoleInput; + /** * Whether this color is inherited by child themes created via `extend()`. * Default: true. Set to false to make this color local to the current theme. @@ -274,6 +322,13 @@ export interface MixColorDef { */ pastel?: boolean; + /** + * Semantic role of the mixed result against `base`. Same semantics as + * `RegularColorDef.role` (fixes APCA polarity). Resolution and defaults + * are identical. + */ + role?: RoleInput; + /** * Whether this color is inherited by child themes created via `extend()`. * Default: true. Set to false to make this color local to the current theme. @@ -380,6 +435,13 @@ export interface GlazeConfig { * @default false */ pastel?: boolean; + /** + * If true (default), infer a color's `role` from its name when no explicit + * `role` is set. Set to `false` to opt out of name-based inference (the + * base-opposite and default-foreground fallbacks still apply). + * @default true + */ + inferRole?: boolean; } export interface GlazeConfigResolved { @@ -394,6 +456,7 @@ export interface GlazeConfigResolved { shadowTuning?: ShadowTuning; autoFlip: boolean; pastel: boolean; + inferRole: boolean; } /** @@ -418,6 +481,11 @@ export interface GlazeConfigOverride { * for the given lightness. */ pastel?: boolean; + /** + * If true, infer a color's `role` from its name when no explicit `role` is + * set. Falls through to the live global at resolve time when omitted. + */ + inferRole?: boolean; /** * Shadow tuning defaults. Only meaningful for themes; harmless on * standalone color tokens. @@ -505,6 +573,15 @@ export interface GlazeColorInput { * @see GlazeConfig.pastel */ pastel?: boolean; + + /** + * Semantic role against `base` / the literal seed: how this token is used. + * Fixes APCA contrast polarity. Same resolution chain as + * `RegularColorDef.role` (explicit → name inference → opposite of base → + * `'text'`). For standalone tokens the name is internal, so set `role` + * explicitly or rely on the base-opposite / foreground default. + */ + role?: RoleInput; } /** @@ -625,6 +702,13 @@ export interface GlazeColorOverrides { * @see GlazeConfig.pastel */ pastel?: boolean; + + /** + * Semantic role against `base` / the literal seed: how this token is used. + * Fixes APCA contrast polarity. Same resolution chain as + * `RegularColorDef.role`. + */ + role?: RoleInput; } /** @@ -656,6 +740,11 @@ export interface GlazeColorCssOptions { * `theme.css` default). */ suffix?: string; + /** + * Emit hue as a separate custom property, referenced via `var()`. + * Requires `format: 'oklch'` and a pastel token. oklch + all-pastel only. + */ + splitHue?: boolean; } /** Return type for `glaze.color()`. */ @@ -674,6 +763,27 @@ export interface GlazeColorToken { json(options?: GlazeJsonOptions): Record; /** Export as CSS custom property declarations grouped by scheme variant. */ css(options: GlazeColorCssOptions): GlazeCssResult; + /** + * Export as W3C DTCG color tokens (one per scheme variant, no color name + * key). Each entry is a full `{ $type: 'color', $value }` token. + * @see https://www.designtokens.org/ + */ + dtcg(options?: GlazeDtcgOptions): GlazeColorDtcgResult; + /** + * Export as a single W3C DTCG Resolver-Module document for this color, + * keyed by `name` across all scheme variants. `name` is required. + * @see https://www.designtokens.org/ + */ + dtcgResolver( + options: GlazeColorDtcgResolverOptions, + ): GlazeDtcgResolverDocument; + /** + * Export as a Tailwind CSS v4 `@theme` block (light baseline) plus dark / + * high-contrast overrides. Returns a single ready-to-paste CSS string. + * `name` is required (forms `--color-`). + * @see https://tailwindcss.com/docs/theme + */ + tailwind(options: GlazeColorTailwindOptions): string; /** * Serialize the token as a JSON-safe object. Captures the original * input value, overrides, and config so it can be rehydrated via @@ -727,6 +837,7 @@ export interface GlazeColorInputExport { contrast?: HCPair; name?: string; pastel?: boolean; + role?: RoleInput; } /** @@ -745,6 +856,7 @@ export interface GlazeColorOverridesExport { opacity?: number; name?: string; pastel?: boolean; + role?: RoleInput; } // ============================================================================ @@ -811,6 +923,30 @@ export interface GlazeTheme { /** Export as CSS custom property declarations. */ css(options?: GlazeCssOptions): GlazeCssResult; + + /** + * Export as W3C Design Tokens Format Module (2025.10) documents, one per + * scheme variant. Consumable by Figma, Tokens Studio, Style Dictionary, + * Terrazzo, and any DTCG-compatible tool. + * @see https://www.designtokens.org/ + */ + dtcg(options?: GlazeDtcgOptions): GlazeDtcgResult; + + /** + * Export as a single W3C DTCG Resolver-Module document describing every + * scheme variant as one `sets` entry plus a single `scheme` modifier with a + * context per variant. Consumable by resolver tools such as Dispersa. + * @see https://www.designtokens.org/ + */ + dtcgResolver(options?: GlazeDtcgResolverOptions): GlazeDtcgResolverDocument; + + /** + * Export as a Tailwind CSS v4 `@theme` block (light baseline) plus dark / + * high-contrast overrides under configurable selectors. Returns a single + * ready-to-paste CSS string. + * @see https://tailwindcss.com/docs/theme + */ + tailwind(options?: GlazeTailwindOptions): string; } export interface GlazeExtendOptions { @@ -837,6 +973,16 @@ export interface GlazeTokenOptions { modes?: GlazeOutputModes; /** Output color format. Default: 'okhsl'. */ format?: GlazeColorFormat; + /** + * Emit hue as a separate custom property, referenced via `var()`. + * Requires `format: 'oklch'` and every color to be pastel. + */ + splitHue?: boolean; + /** + * Base name for the theme-level hue var (`--{name}-hue`). Default: `'theme'`. + * Palette export auto-derives this from the theme name. + */ + name?: string; } export interface GlazeJsonOptions { @@ -851,6 +997,16 @@ export interface GlazeCssOptions { format?: GlazeColorFormat; /** Suffix appended to each CSS custom property name. Default: '-color'. */ suffix?: string; + /** + * Emit hue as a separate custom property, referenced via `var()`. + * Requires `format: 'oklch'` and every color to be pastel. + */ + splitHue?: boolean; + /** + * Base name for the theme-level hue var (`--{name}-hue`). Default: `'theme'`. + * Palette export auto-derives this from the theme name. + */ + name?: string; } /** CSS custom property declarations grouped by scheme variant. */ @@ -861,6 +1017,210 @@ export interface GlazeCssResult { darkContrast: string; } +// ============================================================================ +// DTCG & Tailwind export types +// ============================================================================ + +/** + * Color space used for DTCG `$value` color objects. + * @see https://www.designtokens.org/ + */ +export type DtcgColorSpace = 'srgb' | 'oklch'; + +/** + * A DTCG color `$value` in the sRGB color space: gamma sRGB components in + * 0–1 plus a 6-digit `hex` hint. Universally understood by Figma, Tokens + * Studio, Style Dictionary, and every DTCG reader. + */ +export interface DtcgSrgbColorValue { + colorSpace: 'srgb'; + components: [number, number, number]; + hex: HexColor; + /** Opacity 0–1. Omitted when fully opaque (alpha = 1). */ + alpha?: number; +} + +/** + * A DTCG color `$value` in the OKLCH color space: `[L, C, H]` components + * (L/C: 0–1, H: degrees). Wide-gamut and Glaze-native; no `hex` is emitted. + */ +export interface DtcgOklchColorValue { + colorSpace: 'oklch'; + components: [number, number, number]; + /** Opacity 0–1. Omitted when fully opaque (alpha = 1). */ + alpha?: number; +} + +/** A DTCG `$value` for a color token, in either supported color space. */ +export type DtcgColorValue = DtcgSrgbColorValue | DtcgOklchColorValue; + +/** + * A single W3C DTCG color token: `{ $type: 'color', $value }`. + * `$description` is optional and not populated by Glaze today. + */ +export interface DtcgColorToken { + $type: 'color'; + $value: DtcgColorValue; + $description?: string; +} + +/** + * A W3C Design Tokens Format Module (2025.10) token tree for one scheme. + * Glaze emits one document per scheme variant — the most tool-compatible + * convention (one file per Style Dictionary theme / Tokens Studio set / + * Figma variable mode). + */ +export type DtcgDocument = Record; + +/** DTCG token documents grouped by scheme variant. Light is always present. */ +export interface GlazeDtcgResult { + light: DtcgDocument; + dark?: DtcgDocument; + lightContrast?: DtcgDocument; + darkContrast?: DtcgDocument; +} + +/** A single DTCG color token grouped by scheme variant (standalone tokens). */ +export interface GlazeColorDtcgResult { + light: DtcgColorToken; + dark?: DtcgColorToken; + lightContrast?: DtcgColorToken; + darkContrast?: DtcgColorToken; +} + +/** Options for `theme.dtcg()` / `palette.dtcg()`. */ +export interface GlazeDtcgOptions { + /** Override which scheme variants to include. */ + modes?: GlazeOutputModes; + /** + * DTCG color space. `'srgb'` (default) emits sRGB components plus a `hex` + * hint — universally understood (Figma, Tokens Studio). `'oklch'` emits + * OKLCH components with no hex — Glaze-native, wide-gamut. + */ + colorSpace?: DtcgColorSpace; +} + +// ============================================================================ +// DTCG Resolver-Module export types +// (W3C Design Tokens Resolver Module — one document for all scheme variants) +// ============================================================================ + +/** + * A node in a DTCG Resolver-Module token tree: either a leaf color token or a + * nested group. A flat `DtcgDocument` (top-level color-name keys) is a valid + * shallow tree, so Glaze's per-scheme documents slot directly into + * `sets..sources` and `modifiers..contexts.` entries. + */ +export interface DtcgTokenTree { + [key: string]: DtcgColorToken | DtcgTokenTree; +} + +/** A named set in a resolver document: a list of token-tree sources. */ +export interface DtcgResolverSet { + sources: DtcgTokenTree[]; +} + +/** + * A named modifier (an axis such as `scheme`) in a resolver document. + * `default` names the context applied when no override is selected; `contexts` + * maps each context name to the token-tree overrides applied for it. + */ +export interface DtcgResolverModifier { + default: string; + contexts: Record; +} + +/** A JSON-pointer `$ref` entry in a resolver document's `resolutionOrder`. */ +export interface DtcgResolverRef { + $ref: string; +} + +/** + * A W3C DTCG Resolver-Module document. Describes every scheme variant in a + * single file as `sets` (base token sources) plus `modifiers` (per-context + * overrides) composed in `resolutionOrder`. Consumable by resolver tools such + * as Dispersa. + * @see https://www.designtokens.org/ + */ +export interface GlazeDtcgResolverDocument { + version: string; + sets: Record; + modifiers: Record; + resolutionOrder: DtcgResolverRef[]; +} + +/** Renaming of the four scheme variants as resolver-modifier context names. */ +export interface GlazeDtcgResolverContextNames { + light?: string; + dark?: string; + lightContrast?: string; + darkContrast?: string; +} + +/** + * Options for `theme.dtcgResolver()` / `palette.dtcgResolver()`. Extends + * `GlazeDtcgOptions` so `modes` + `colorSpace` pass through to the underlying + * per-scheme `dtcg()` build. + */ +export interface GlazeDtcgResolverOptions extends GlazeDtcgOptions { + /** Name of the single set holding the default (light) token tree. Default `'base'`. */ + setName?: string; + /** + * Name of the modifier describing the scheme axis. Default `'scheme'` + * (Glaze's term for the light / dark / high-contrast axis). + */ + modifierName?: string; + /** + * Override the four context names emitted on the modifier. Defaults to the + * Glaze variant keys: `light` / `dark` / `lightContrast` / `darkContrast`. + */ + contextNames?: GlazeDtcgResolverContextNames; + /** Resolver document version. Default `'2025.10'`. */ + version?: string; +} + +/** Options for `glaze.color().dtcgResolver()`. `name` is required. */ +export interface GlazeColorDtcgResolverOptions extends GlazeDtcgResolverOptions { + /** Token name keying the color within each token tree. Required. */ + name: string; +} + +/** Options for `theme.tailwind()` / `palette.tailwind()`. */ +export interface GlazeTailwindOptions { + /** Override which scheme variants to include. */ + modes?: GlazeOutputModes; + /** Output color format. Default: 'oklch'. */ + format?: GlazeColorFormat; + /** + * CSS custom property namespace, forming `--`. + * Default: `'color-'` → `--color-surface`. (Tailwind v4's `--color-*` + * convention, which auto-generates `bg-*` / `text-*` / `border-*`.) + * + * Named `namespace` rather than `prefix` to avoid clashing with the + * palette theme-prefix option on `palette.tailwind()`. + */ + namespace?: string; + /** + * Selector wrapping the dark-scheme overrides. Default: `'.dark'`. + * Pass a media query such as `'@media (prefers-color-scheme: dark)'` to + * drive dark mode from the OS preference instead of a class. + */ + darkSelector?: string; + /** + * Selector wrapping the light high-contrast overrides. Default: + * `'.high-contrast'`. The combined dark + high-contrast overrides are + * emitted under `${darkSelector}${highContrastSelector}` (e.g. + * `.dark.high-contrast`). + */ + highContrastSelector?: string; +} + +/** Options for `glaze.color().tailwind()`. `name` is required. */ +export interface GlazeColorTailwindOptions extends GlazeTailwindOptions { + /** Custom property base name (without leading `--`). Required. */ + name: string; +} + /** Options for `glaze.palette()` creation. */ export interface GlazePaletteOptions { /** @@ -893,6 +1253,11 @@ export interface GlazePaletteExportOptions { * When omitted, inherits the palette-level `primary`. */ primary?: string | false; + /** + * Emit hue as a separate custom property, referenced via `var()`. + * Requires `format: 'oklch'` and every color to be pastel. + */ + splitHue?: boolean; } export interface GlazePalette { @@ -930,4 +1295,28 @@ export interface GlazePalette { /** Export all themes as CSS custom property declarations. */ css(options?: GlazeCssOptions & GlazePaletteExportOptions): GlazeCssResult; + + /** + * Export all themes as W3C DTCG documents, one per scheme variant. + * Prefix defaults to `true`. Inherits the palette-level `primary`. + * @see https://www.designtokens.org/ + */ + dtcg(options?: GlazeDtcgOptions & GlazePaletteExportOptions): GlazeDtcgResult; + + /** + * Export all themes as a single W3C DTCG Resolver-Module document. Prefix + * defaults to `true`. Inherits the palette-level `primary`. + * @see https://www.designtokens.org/ + */ + dtcgResolver( + options?: GlazeDtcgResolverOptions & GlazePaletteExportOptions, + ): GlazeDtcgResolverDocument; + + /** + * Export all themes as a Tailwind CSS v4 `@theme` block plus dark / + * high-contrast overrides. Returns a single ready-to-paste CSS string. + * Prefix defaults to `true`. + * @see https://tailwindcss.com/docs/theme + */ + tailwind(options?: GlazeTailwindOptions & GlazePaletteExportOptions): string; } diff --git a/src/warnings.ts b/src/warnings.ts index 4d6529c..6232d8c 100644 --- a/src/warnings.ts +++ b/src/warnings.ts @@ -89,7 +89,11 @@ export function warnContrastDrift( ): void { const actual = contrast.metric === 'apca' - ? Math.abs(apcaContrast(yColor, yBase)) + ? Math.abs( + contrast.polarity === 'bg' + ? apcaContrast(yBase, yColor) + : apcaContrast(yColor, yBase), + ) : contrastRatioFromLuminance(yColor, yBase); const slack =