@tenphi/glaze 0.0.0-snapshot.e26a2e0 → 0.0.0-snapshot.e34cc7a
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +170 -0
- package/README.md +109 -1016
- package/dist/index.cjs +5599 -1013
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1933 -220
- package/dist/index.d.mts +1933 -220
- package/dist/index.mjs +5565 -1013
- package/dist/index.mjs.map +1 -1
- package/docs/contrast.mdx +751 -0
- package/docs/exporting.mdx +852 -0
- package/docs/getting-started.mdx +488 -0
- package/docs/hdr.mdx +290 -0
- package/docs/methodology.mdx +977 -0
- package/docs/migration.md +543 -0
- package/docs/mixes.mdx +348 -0
- package/docs/okhst.mdx +680 -0
- package/docs/reference/color-definitions.md +554 -0
- package/docs/reference/color-tokens.md +421 -0
- package/docs/reference/configuration.md +527 -0
- package/docs/reference/contrast.md +277 -0
- package/docs/reference/index.md +223 -0
- package/docs/reference/output.md +835 -0
- package/docs/reference/palette.md +435 -0
- package/docs/reference/theme.md +332 -0
- package/docs/reference/utilities.md +629 -0
- package/docs/schemes.mdx +637 -0
- package/docs/shadows-and-glows.mdx +519 -0
- package/package.json +20 -10
package/AGENTS.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# AGENTS.md — @tenphi/glaze
|
|
2
|
+
|
|
3
|
+
## Package Overview
|
|
4
|
+
|
|
5
|
+
- **Package:** `@tenphi/glaze`
|
|
6
|
+
- **Repository:** [https://github.com/tenphi/glaze](https://github.com/tenphi/glaze)
|
|
7
|
+
- **Description:** Zero-dependency color theme generator built on OKHST (OKHSL with a contrast-uniform **tone** axis), with WCAG + APCA contrast solving for light, dark, and high-contrast schemes.
|
|
8
|
+
- **Runtime:** Node >= 20, browsers, edge. Pure math, no I/O.
|
|
9
|
+
- **Output formats:** ESM + CJS, with full `.d.ts`.
|
|
10
|
+
|
|
11
|
+
## Project Structure
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
glaze/
|
|
15
|
+
├── src/ Source
|
|
16
|
+
├── docs/ Long-form docs: concepts, guides, reference (md/mdx, rendered by the site)
|
|
17
|
+
├── docs-site/ Site-only files: home page + live-example components
|
|
18
|
+
├── playground/ Interactive playground (Vite)
|
|
19
|
+
├── assets/ Logo + brand assets
|
|
20
|
+
├── scripts/ Repo helpers
|
|
21
|
+
├── README.md Compact intro
|
|
22
|
+
├── AGENTS.md This file
|
|
23
|
+
├── CHANGELOG.md Auto-generated by Changesets
|
|
24
|
+
├── package.json
|
|
25
|
+
├── tsconfig.json
|
|
26
|
+
├── tsconfig.test.json Typecheck config for src/**/*.test.ts (Vitest globals)
|
|
27
|
+
├── tsdown.config.ts Build config
|
|
28
|
+
├── vitest.config.ts Test config
|
|
29
|
+
├── astro.config.ts Docs site (Astro + Cookbook)
|
|
30
|
+
├── docs.config.ts Docs site config: navigation, theme, `@hc` state
|
|
31
|
+
└── eslint.config.js
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### `src/` files
|
|
35
|
+
|
|
36
|
+
| File | Description |
|
|
37
|
+
|---|---|
|
|
38
|
+
| [src/index.ts](src/index.ts) | Public barrel; re-exports `glaze`, `GLAZE_EXPORT_VERSION`, the snapshot type guards, every public type (incl. the HDR ones: `ResolvedHdr`, `GlazeHdrFormat`, `GlazeCssSchemeBlocks`, `GlazeDtcgHdr`, `GlazeDtcgExtensions`), the `splitHue` planning types, the export guards, the contrast-solver utilities and constants, the role helpers, the OKHST tone utilities, and the OKHSL/sRGB/OKLab math helpers (incl. `apcaLuminanceFromLinearRgb` and `formatSrgbLinear`). |
|
|
39
|
+
| [src/glaze.ts](src/glaze.ts) | `glaze()` factory (`glaze(hue, saturation?, config?)` or `glaze(seed, config?)`; the old `glaze(seed, undefined, config)` still works, deprecated) + attached statics: `palette`, `paletteFrom`, `color`, `colorFrom`, `themeFrom`, `from` (alias), `fromHex`, `fromRgb`, `shadow`, `glow` (returns `hdr` when its step passes white with `headroom` > 0), `format`, `formatHdr`, `configure`, `getConfig`, `resetConfig`, `isThemeExport` / `isColorTokenExport` / `isPaletteExport`. `shadow` / `glow` require a `bg` and a finite `intensity` (the parser reads a `bg` / `fg` past white or black as white or black), validate their `tuning` with `normalizeLightingTuning`, and warn through `warnLightingShortfall`. The untyped `glaze(hue, config)` takes the config when no third argument does, and a `null` saturation or config reads as unset. The snapshot guards are cast to explicit guard types so the declaration bundler can't turn them into `any`. Thin wiring layer over the focused modules below. |
|
|
40
|
+
| [src/theme.ts](src/theme.ts) | Single-theme factory (`createTheme`; `assertValidSeed` rejects a non-finite seed hue / saturation). Owns the mutable `ColorMap`, the `resolve()` cache (versioned against `getConfigVersion()`), and the `tokens` / `tasty` / `json` / `css` / `dtcg` / `dtcgResolver` / `tailwind` / `extend` / `export` methods. The config override is copied once at creation (`copyConfigOverride`); `extend()` merges the parent's copy with a copy of `options.config`; `getConfig()` returns `copyResolvedConfig(effectiveConfig)`, a deep copy. Exports carry the HDR tier only with `modes.hdr` (`css()`: `options.hdr ?? modes.hdr`); `tasty()` keys it with `states.hdr`. `tasty()` / `css()` with `splitHue` run its guards first (`assertSplitHueFormat`, `assertAllPastel`, `claimHueVars`), and `css()` also claims each color's value property (`claimValueProps`). `export(override?)` deep-clones defs and freezes effective config with `kind`/`version`. |
|
|
41
|
+
| [src/palette.ts](src/palette.ts) | Multi-theme composition (`createPalette` / `createPaletteFromExport`, plus `createThemeFromExport` behind `glaze.themeFrom`). Shared per-theme driver `buildPaletteOutput` handles prefix resolution, primary duplication, collision filtering for `tokens` / `tasty` / `css` / `dtcg` / `dtcgResolver` / `tailwind`; `json()` keys by theme name with no prefix, primary or collisions. The HDR tier is built from the same filtered, prefixed maps and the primary alias pass (`css()` accumulates `{ sdr, hdr }` lines; `tailwind()` merges with `mergeTailwindLines` / `emptyTailwindLines`). `tasty()` / `css()` with `splitHue` share one owners map per call, so hue vars and (`css()`) value properties, primary aliases included, are claimed across every pass. Authoring: `export(override?)`, `theme` / `themes` / `list` / `primary`. |
|
|
42
|
+
| [src/color-token.ts](src/color-token.ts) | Standalone `glaze.color()` tokens. Parses values through `color-value.ts`; owns the structured-input validator, the two factory paths, sparse local config (copied at creation with `copyConfigOverride`, in both factories) + live resolve, and the JSON-safe export / `glaze.colorFrom` rehydrate round-trip. Pins the role: an explicit `role`, else `'text'` (the token is the foreground against its base or its own input color; `name` is a label and never drives inference). A link resolves against its base token's own variants in every scheme (what the base paints, under its own config), so a light `'+0'` lands on it and a light floor holds against it. The token copies its input by property access, so getters and proxies copy too (`copyTokenOptions` reads each `TOKEN_OPTION_KEYS` field, then `copyColorValue` for a value and a raw `base`, `copyPair` for tone pairs, `copyContrast`; the structured validator runs on the copy), and `export()` builds every object fresh the same way, so a snapshot and the live token never share one. A `base` is exported as its token's own snapshot — a raw one as the token it was wrapped in at creation — config frozen. The hidden `static` seed is marked with `anchorAtAuthoredTone`, so a delta on it inverts in dark like one on the same color in a theme. A structured `darkHue` must be a finite number or a `'+N'` / `'-N'` offset. Every token registers an internal dark-delta anchor hook (a `WeakMap` keyed by token), cached per pass next to its resolve cache, and resolves its base inline, so a `'+N'` link steps from where the base sits under the base's own windows and a chain of links adds one resolve frame per link. `css()` with `splitHue` claims the token's `--{name}-hue` and its value property in one namespace. Tokens are never glows, so HDR options don't change their output. |
|
|
43
|
+
| [src/color-value.ts](src/color-value.ts) | `GlazeColorValue` parsing + validation: hex (3/4/6/8 digits, every digit checked), the `rgb()` / `rgba()` / `hsl()` / `hsla()` / `okhsl()` / `okhst()` / `oklch()` functions (each component read strictly by `readComponent`: a number with an optional unit, `none` as 0, anything else throws; a hue takes `deg` / `turn` / `rad` / `grad`; out-of-range channels, lightness and saturation clamp, and a negative chroma to 0, as CSS clamps them; alpha — `/ A` or a fourth component — is dropped with a warning, once per string), and the four value-object shapes (a negative component rejected, naming it; a 0–1 field up to 1.5 clamps to 1), all copied by `copyColorValue`, all normalized to OKHSL by `extractOkhslFromValue` (an sRGB gray to saturation 0, by `srgbToOkhsl`). Split out of `color-token.ts` so the resolver can reach it for a theme color's `from` without closing an import cycle. Leaf module — depends only on the color math. |
|
|
44
|
+
| [src/serialize.ts](src/serialize.ts) | Authoring-export helpers: `GLAZE_EXPORT_VERSION` (2 since Glaze 3; HDR `headroom` rides in `glowTuning` / defs without a bump), `assertExportKind` / `assertExportVersion`, `assertNoLegacyShadows` (rejects a version-1 theme snapshot with shadow defs or `shadowTuning`, since shadows changed meaning in 3.0; palettes pass their version down to each theme), and `isThemeExport` / `isColorTokenExport` / `isPaletteExport` type guards. |
|
|
45
|
+
| [src/resolver.ts](src/resolver.ts) | Four-pass solver (light → light-HC → dark → dark-HC), dropping to two-pass (light → dark) with mirrored HC slots only at `contrastLevel: 100`, where the normal passes already are the HC ones (`hcMirrorsNormal`). Under any other manual level `passTone` / `passNumber` / `resolveContrastSpec` feed interpolated inputs into the two *normal* passes and a probe solve at the nearer endpoint pins which side of its base a contrast-solved color sits on (a probe that lands on its base pins no side); the HC passes bypass the level and the probe entirely, so the HC tier is bit-identical to `'auto'`. `sideOfBase` treats a preferred tone within 1e-9 of its base as on neither side, in the main solve and the probe, so float noise never picks the side. Stores canonical tone (`t`) in variants; per-scheme branches for regular, shadow / glow (one shared `resolveLightingForScheme`, which attaches a glow's `variant.hdr` via `toResolvedHdr` and warns through `warnLightingShortfall`), and mix defs; integrates the contrast solver, and the OKHST tone helpers. A dark `auto` tone delta steps from `darkAnchor` (memoized per pass on `ResolveContext.anchors`; a chain of tone-less aliases is walked in a loop, not recursed): the authored tone for an `auto` base placed by a tone or for a def marked with `anchorAtAuthoredTone` (a standalone token's hidden `static` seed), its base's anchor plus the delta for an `auto` delta, and for any other base — `fixed` / `static`, or an `auto` contrast-solved, mix, or `'max'` / `'min'`-on-a-base one — its own dark tone read back through the dark window (`unmapDarkTone`), so an `auto` dependent mirrors around wherever the base sits. `stepFromAnchor` clamps the step to 0–100 widened to reach an anchor past the window, and `readsStoredLightTone` reads a light-side anchor's stored light tone in HC and at level 100 at the REF light eps, so those stay bit-identical. External `glaze.color()` bases supply their anchor through a `DarkAnchorHook` (`{ tone, config }`, passed as the `externalAnchors` argument of `resolveAllColors`; `darkAnchorOf` is what the hook calls); an external base under another dark window (`sameDarkWindow`), or one with no hook, is read back through this pass's. `resolveChannels` owns per-scheme hue/saturation (incl. the `darkHue` / `darkSaturation` seed + def overrides, the `darkDesaturation` bypass, and the absolute hue/saturation a `from` color carries in place of the seed factor) and feeds both the emitted variant and the contrast solver. `configForColor` gives a `from` color a local `lightTone: false` so its light variant reproduces the authored value. Converts to/from OKHSL lightness only at the mix / shadow / glow edges. A mix re-reads the saturation of a base or target whose `pastel` flag differs from its own in its own gamut, from the same OKLab color (hue and lightness carry over and a pastel mix caps a vivid one at the pastel limit; a gray, by `mixHue`'s `MIX_SAT_EPSILON`, is left as is, and a white or black is re-read just inside the lightness range (`MIX_EXTREME_L`), so it keeps its hue and the mix stays continuous at tone 0 / 100), and one `mixAt` / `paintAt` pair per blend feeds both the emitted variant and the contrast search, so the floor is measured on the painted color (an `srgb` lerp is measured raw until the pastel cap clamps it). Pre-seeds externally-resolved bases for `glaze.color({ base })`. |
|
|
46
|
+
| [src/okhst.ts](src/okhst.ts) | The OKHST tone layer. `REF_EPS`, tone↔lightness transfers (`toTone`/`fromTone`, `toneFromY`/`yFromTone`, unclamped, so an HDR value's tone can pass 100), OKHST↔OKHSL conversions, `variantToOkhsl` (tone→lightness at render), `normalizeToneWindow` (`[lo,hi]` / `{lo,hi,eps}` / `false` → `{lo,hi,eps}`; throws on any other shape, `lo > hi`, a non-finite endpoint, or an `eps` outside (0, 1e6] (`MAX_WINDOW_EPS`), then clamps endpoints to 0–100), `mapToneForScheme` (scheme inversion + window remap, HC bypass) and its dark inverse `unmapDarkTone` (`darkAnchor` reads a `fixed` / `static`, solved, mix, or extreme base back through the dark window, which comes back outside 0–100 for a tone past the window), `mapDarkToneUnclamped` (the dark `auto` map without the authored-scale clamp, bit-identical to `mapToneForScheme` inside 0–100, for a step from a base past the window), `sameDarkWindow` (whether two configs give a pass the same active dark window), `mapSaturationDark` (the `darkDesaturation` reducer the resolver skips when a dark saturation is authored), and `schemeToneRange` (the window in tone, used only to clamp a contrast-solved color's preferred tone; the solver itself searches the full range). `activeWindow` owns the HC window bypass and its continuous form under `contrastLevel`. Only tone adapts here — hue/saturation are the resolver's business. |
|
|
47
|
+
| [src/contrast-solver.ts](src/contrast-solver.ts) | Tone-based binary-search solver for WCAG **and** APCA. Public API: `findToneForContrast` (incl. the `preferInitial` tie-break the manual level uses on its normal passes to keep a color's side stable), `findValueForMixContrast` (no `targetLinearRgb`: the mix is described entirely by `luminanceAtValue`), `resolveContrastForMode`, `resolveContrastForLevel` (both ends resolved, target interpolated for a manual `contrastLevel`; throws on a metric switch), `resolveMinContrast`, `resolveApcaTarget`, `apcaContrast`, plus the `APCA_PRESETS` / `APCA_HC_ENHANCEMENT` / `APCA_MAX_LC` constants (the HC boost stops at `APCA_MAX_LC` but never lowers a normal target already past it). A spec is `{ apca }` only when its `apca` is set (`isApcaSpec`: a key holding `undefined` is unset). Internal: `isContrastPreset` / `isApcaPreset` (validation.ts's spec check), `metricLuminance` (the resolver's mix closures and drift check), `metricScore` (the resolver's pastel-cap check) and `contrastMetricOf` (validation.ts's WCAG-vs-APCA pair check). Searches aim 1% (WCAG) / 0.5 Lc (APCA) past the floor; the closed-form WCAG gray seed is the initial branch's first probe, never a bound, so a chromatic color lands on its own crossing. The two branches meet at the preferred position, so each reaches only its own side of the base (a branch that misses reports its outer extreme, never the position next to the other branch), and a nearest-wins gap within twice the search resolution is a tie that keeps the initial side. When no searched side meets the floor but the preferred position clears the bare target, that position is returned, met (tone solver: `branch: 'preferred'`, also for a met starting side whose only position is the preferred tone), so a value-100 mix keeps a passing pure target; an unmet result names the side whose bound it pinned to. With no `initialDirection`, a search starts toward the end that scores higher, and a float-noise tie between them (a gray base at tone 50) starts darker / lower. |
|
|
48
|
+
| [src/lighting.ts](src/lighting.ts) | Shadow / glow / mix def predicates (`isShadowDef`, `isGlowDef`, `isLightingDef`, `isMixDef`), `DEFAULT_LIGHTING_TUNING` + `resolveLightingTuning` (defaults ← global `shadowTuning` / `glowTuning` ← per-color, field by field with `??`, so a field set to `undefined` is unset, into a `ResolvedLightingTuning` whose `headroom` defaults to 0 and clamps to 0–`MAX_HEADROOM` (2)), `circularLerp`, and the exact solver `computeLighting`: object factor `k = max(0, 1 + β·d·(t_bg − t_fg)/100)` (`objectFactor`), the pigment (pure black / white unless `reach` / `tint` / `hueBleed` say otherwise; a neutral surface — saturation ≤ 1e-4, which a gray given as a value always is — gives a tint the object's hue with `hueBleed`, else none; a pigment that moves the surface by no more than float noise stays transparent), and `solveOpacity`, a bisection on opacity against browser compositing so the composite's luminance tone lands exactly `intensity × k` tones darker (shadow) / lighter (glow). Out-of-reach steps pin to the pigment's strongest composite (a golden-section search covers tinted shadow pigments whose channels diverge) and report `reached` for the warning. HDR glows (`exposeGlow`, closed form after the untouched SDR solve): with `headroom` > 0 and a target tone `T* = surface tone + step` past 100, a glow whose SDR is pinned at its brightest (`pinnedBase`: the pigment at alpha 1, or a white surface — tone ≥ 100 − 1e-6 — at alpha 0) gets `hdr`: that opaque base, taken exactly from its OKHSL source as standard (non-pastel) OKHSL, times `2^exposure`, with gain `min(Y(T*) / Y(base), 2^headroom / max channel)`. A pigment that cannot lift a non-white surface gets none and keeps the SDR shortfall. `toResolvedHdr` stores it in OKHST (`ResolvedHdr`); `hdrToLinearSrgb` rebuilds the unclamped linear channels the writers use. |
|
|
49
|
+
| [src/hc-pair.ts](src/hc-pair.ts) | Tiny shared helpers: HC-pair selection (`pairNormal` / `pairHC`), HC-pair interpolation for the manual contrast level (`contrastFraction`, `hcMirrorsNormal` — the level-100 "HC slots are duplicates" predicate, `levelFraction`, `numberAt`, `parseToneValueAt`, `PAIR_SWITCH`), tone-value parsing (`parseToneValue` for absolute / relative / `'max'`/`'min'` extremes, `isExtremeTone`), root-tone discrimination (`isAbsoluteTone`, now incl. extremes), `clamp` / `lerp`, hue resolution (`resolveEffectiveHue`), relative-value parsing, and `showValue` (writes a rejected input into a validation error, an `undefined` or `NaN` inside an array or object included — via a private-use marker, skipped when the caller's value holds it; shared by `normalizeToneWindow`, `normalizeHeadroom`, `normalizeContrastLevel`, and `normalizeDarkDesaturation`). Leaf module — types only, so every other module can import it. |
|
|
50
|
+
| [src/channels.ts](src/channels.ts) | Hue-channel planning for `splitHue` (`buildHuePlan` / `buildHuePlans` / `buildHueDeclarations`): per-color `var()` hue references and the `--*-hue` declarations behind them, collected per scheme with the dark set emitted only when it differs. A `mode: 'static'` color is pinned at its one hue when the dark seed hue differs, and otherwise ignores its `darkHue` (as the resolver does), keeping its light reference in both schemes. Every scheme renders with the light plan, so a color inline in light (achromatic there) is inline everywhere and declares no var in any scheme. Hue values are rounded to 2 decimals, like `oklch()` output. Also the `splitHue` guards: `assertSplitHueFormat` (only `oklch` takes a `var()` hue), and `claimHueVars` + `claimValueProps`, which share one `SplitHueOwners` map so the theme var, each color's own hue var, and (`css()` only) each value property keep one owner, throwing with fix text that names both sides. Public types `ChannelCtx`, `HuePlan`, `HueDeclaration`. |
|
|
51
|
+
| [src/format-guard.ts](src/format-guard.ts) | Export-time guards: `assertNativeFormat` (`okhsl` / `okhst` are `tasty()`-only) and `assertAllPastel` (`splitHue` needs every emitted color pastel). Both public. |
|
|
52
|
+
| [src/roles.ts](src/roles.ts) | Semantic roles for APCA polarity: alias keyword sets, name tokenization (`inferRoleFromName`), `normalizeRole`, `roleToPolarity`, `oppositeRole`. The per-color resolution chain (explicit → name inference → opposite of the base's → `'text'`) runs in the resolver's `resolveRole`. No dependencies. |
|
|
53
|
+
| [src/formatters.ts](src/formatters.ts) | Variant→string dispatch (`okhsl` / `rgb` / `hsl` / `oklch`), the HDR writer `formatHdrVariant` (`color(srgb-linear R G B)` whatever the SDR format), and the token-map shapes Glaze emits: `buildTokenMap` ([Tasty](https://tasty.style) `#name` keys + state aliases), `buildFlatTokenMap` (per-variant maps), `buildJsonMap` (per-color JSON), `buildCssMap` (CSS custom-property declarations), `buildDtcgMap` / `buildDtcgResolver` (DTCG 2025.10 documents and the Resolver-Module document), and `buildTailwindLines` / `buildTailwindMap` / `emitTailwindCss` (Tailwind v4 `@theme` + dark / HC overrides). For `splitHue` it emits the light hue declarations plus, via `buildHueDeclarations` in `channels.ts`, the whole dark set when a `darkHue` makes it differ. The HDR tier (`variant.hdr`) is emitted only with `modes.hdr` (`css()`: its `hdr` option), always as a literal `color(srgb-linear …)`, even under `splitHue`: Tasty gets a sparse `states.hdr` key after each SDR key that has one (Tasty gives later keys priority); `json` sparse `<scheme>Hdr` keys; `tokens` dense `<scheme>Hdr` maps; `css` dense `hdr` blocks over all four schemes; Tailwind dense blocks after all SDR blocks under `hdrSelector` (`DEFAULT_TAILWIND_HDR_SELECTOR`); DTCG `$extensions['me.tenphi.glaze'].hdr` (`dtcgHdrValue`) next to an unchanged SDR `$value`. Shared helpers: `SCHEMES`, `emittedSchemes`, `hasHdrIn`, `joinSchemeLines` / `emptySchemeLines`, `emptyTailwindLines` / `mergeTailwindLines`. Also the `resolveModes` helper used everywhere (`{ dark, highContrast, hdr }`, override ?? global). |
|
|
54
|
+
| [src/okhsl-color-math.ts](src/okhsl-color-math.ts) | OKHSL ↔ linear-sRGB ↔ gamma-sRGB ↔ OKLab conversions, strict hex parsing (`parseHex` / `parseHexAlpha`: 3/4/6/8 digits, every digit checked), `srgbToOkhsl` reading a gray (OKLab chroma under `GRAY_CHROMA`, 1e-6, above an exact gray's float residue) as saturation 0 with its hue kept, gamut clamping, the two luminances the contrast solver scores (WCAG `gamutClampedLuminance`, APCA `Ys` via `apcaLuminanceFromLinearRgb`, both public), browser compositing (`toDisplayGamma` / `compositeOverGamma` — `source-over` on gamma-encoded sRGB, shared by transparent mixes and shadows / glows), and the `formatOkhsl` / `formatOkhst` / `formatRgb` / `formatHsl` / `formatOklch` writers plus `formatSrgbLinear` (linear channels, 4 decimals, unclamped, for HDR values). Converters and writers share one scale — `h` 0–360, `s` / `l` / `t` 0–1 — so any converter composes with any writer (the 0–100 tone axis lives in `okhst.ts`'s `toTone` / `fromTone`, not here); `warnPercentScale` catches pre-2.0 percentage-scale input instead of emitting a plausible wrong color. The only file with the actual color science. |
|
|
55
|
+
| [src/config.ts](src/config.ts) | Global config singleton. `defaultConfig()` is the one source of truth for defaults (incl. `states.hdr` `'@media(dynamic-range: high)'` and `modes.hdr: false`); `configure()` validates every field into a new config (merging `states` / `modes` field by field; a call that throws changes nothing) and bumps a monotonic `configVersion` so theme caches invalidate. No config object is shared with a caller: `configure()` copies what it keeps, themes and tokens copy their override once where they store it (`copyConfigOverride`, which reads each `GlazeConfigOverride` key by property access, so getters, inherited fields and proxies work, and reads `null` as no override; also used for `extend({ config })` and `export(override)`), and `snapshotConfig()` (`glaze.getConfig()`) / `copyResolvedConfig()` (`theme.getConfig()`, a cheap field-wise copy) hand out deep copies. `mergeConfig()` merges an override over the live global at resolve (`pastel` is instance-only; an override's `shadowTuning` / `glowTuning` replaces the global one wholesale). `freezeConfigForExport()` freezes resolve-relevant fields for authoring export (`contrastLevel` only when instance-authored, since a global level is a live preference; never `modes` / `states`). `normalizeContrastLevel()` / `normalizeDarkDesaturation()` / `normalizeToneWindowInput()` validate and clamp the manual contrast level (0–100), `darkDesaturation` (0–1; non-finite throws), and tone windows, in `configure()` and (for per-instance overrides, at resolve) `mergeConfig()`; they and the window check treat only `undefined` as "keep", so `null` throws. `normalizeHeadroom()` / `normalizeLightingTuning()` (shared with validation.ts and `glaze.shadow()` / `glow()`) reject a non-finite `objectInfluence` / `tint` / `reach` / `hueBleed`, a non-finite or negative glow `headroom`, and any shadow `headroom`, and clamp one above `MAX_HEADROOM` with a warning that `configure()` / `mergeConfig()` queue (`DeferredWarnings`) until every field has passed. Imports `lighting.ts` and `warnings.ts`. |
|
|
56
|
+
| [src/validation.ts](src/validation.ts) | `validateColorDefs` (missing references, a shadow / glow without `bg`, a shadow / glow `bg` / `fg` cannot be a shadow or glow, a malformed `contrast` spec (`assertValidContrast`: a misspelled preset, a non-finite number, a bad shape; only `wcag` / `apca` keys that are set count, and `isPairOf` reads both entries by index, so a sparse `[, 7]` fails), a numeric field the resolver can't place (`assertField`: non-finite `tone` / `hue` / `darkHue` / `saturation` / `darkSaturation` / mix `value` / shadow `intensity`, a malformed `'+N'` — `isRelativeValue`, which allows surrounding whitespace and also checks a token's structured `darkHue` —, an `opacity` outside 0–1), mixes and regular bases cannot reference one, contrast-without-base, relative-without-base — `hasRelativeTone` checks both entries of an HC pair, so `tone: [50, '+20']` on a root throws — WCAG-vs-APCA metric switch in a `contrast` pair, and per-color lighting tuning through `normalizeLightingTuning`: a shadow `headroom` throws, a bad glow one throws, and clamp warnings are flushed only after every def and the cycle check pass) + `topoSort` so the resolver processes each color after its dependencies. |
|
|
57
|
+
| [src/warnings.ts](src/warnings.ts) | Deduped resolve-time warnings: contrast unmet (`warnContrastUnmet`, and `warnMixContrastUnmet` for mixes, whose hint names the pastel cap when it is why the floor is missed: the target painted in full would meet it), chromatic contrast drift (`warnContrastDrift`, which the resolver skips for unmet and achromatic variants), out-of-reach shadow / glow steps (`warnLightingShortfall` dispatches to the SDR `warnLightingUnreachable` or, when a glow has an HDR value, `warnHdrUnreachable`, which fires only when the headroom cap leaves the step short), and a glow `headroom` clamped to its maximum (`warnHeadroomClamped`, deduped per field and value). The three contrast warnings share one slack check (`missesFloor`: 2% under a WCAG ratio, 1.5 Lc under an APCA target); the lighting ones use a 0.05-tone slack (`LIGHTING_WARN_SLACK`). All share one dedupe cache, cleared at 256 entries to keep dev-server output bounded. `DeferredWarnings` / `flushWarnings` let a validator hold a warning until the whole input has passed. |
|
|
58
|
+
| [src/types.ts](src/types.ts) | All public TypeScript types: `HCPair`, `MinContrast`, `ContrastSpec`, `Role` / `RoleInput`, `RelativeValue`, `ExtremeValue` (`'max'`/`'min'`), `ToneValue`, `AdaptationMode`, `OkhstColor`, `ToneWindow` (`[lo,hi]` \| `{lo,hi,eps}` \| `false`), `ColorDef` discriminated union (regular with `tone` + `autoFlip` + `darkHue` / `darkSaturation`; `ShadowColorDef` / `GlowColorDef`; `MixColorDef`), `LightingTuning` (`ShadowTuning` is an alias; `GlowTuning` extends it with `headroom`), `GlazeConfig` (with `lightTone` / `darkTone` / `autoFlip` / `contrastLevel` / `shadowTuning` / `glowTuning`, and `states.hdr` / `modes.hdr`, always present on `GlazeConfigResolved`), `GlazeOutputModes` (`dark` / `highContrast` / `hdr`), `GlazeThemeSeed` (`hue` / `saturation` + optional dark pair), `GlazeTheme`, `GlazePalette`, `GlazeColorToken`, the per-method option types (`GlazeTastyOptions`, `GlazeCssOptions` with `hdr`, `GlazeTailwindOptions` with `hdrSelector`, and their palette / token variants), the `*Export` shapes, the DTCG shapes (`DtcgColorToken.$extensions` → `GlazeDtcgExtensions` / `GlazeDtcgHdr`), etc. `ResolvedColorVariant` stores `{ h, s, t, alpha }`, plus `pastel?` (the effective flag, which says which saturation space `s` is in) and `hdr?: ResolvedHdr` (`{ h, s, t, exposure }`, `s` standard OKHSL) on a glow past SDR white; `GlazeHdrFormat` is `'srgb-linear'`; `GlazeCssResult` has an optional `hdr: GlazeCssSchemeBlocks`; `GlazeGlowInput` takes a `GlowTuning`. |
|
|
59
|
+
| `src/glaze.test.ts` | Main test suite — covers the factory surface, resolver behavior, palette composition, every export format, shadow / glow (per-surface step uniformity, object influence, unreachable steps, v1 snapshot rejection) and mix algorithms (incl. transparent-mix contrast against browser compositing), gray values and sRGB blends of grays as mix endpoints, mixes whose base or target has another `pastel` flag (exact endpoints, a gray endpoint staying gray, the pastel cap, floors measured on the painted color), shadow / glow tuning (a tint on a neutral surface, a pigment that can't move the surface) and the standalone `glaze.shadow()` / `glaze.glow()` input checks, the manual contrast level, `glaze(hue, config)` and a `null` saturation, and the standalone color token round-trip. |
|
|
60
|
+
| `src/resolver.test.ts` | Dark tone deltas: the dark window's scale × the delta on every base (chains, `from` bases, solved / mixed / extreme bases, `fixed` / `static` bases incl. a `fixed` delta riding an `auto` base past the window, token links incl. a fixed base and a base with its own dark window), exact HC steps (custom light eps included), level 100 = HC, the identity-window and stored-light-tone cases, the low-base clamp, steps from a base past the window, one anchor read per linked token per pass, a token delta with no base stepping like one on its own color linked as a base, and long chains (of deltas, and of tone-less aliases) without deep recursion; plus `unmapDarkTone` / `mapDarkToneUnclamped` and the zero-delta / level-probe side ties. |
|
|
61
|
+
| `src/contrast-solver.test.ts` | Tests for the binary-search solver: spec resolution (`resolveMinContrast` / `resolveContrastForMode` / `resolveContrastForLevel`), `preferInitial`, `apcaContrast`, WCAG and APCA solves, branch-side sweeps (gray WCAG; APCA fg / bg and saturated hues, sided by metric luminance), float-noise side stability and the tone-50 start, the preferred tone a search can't improve on (kept, labelled `'preferred'`, met when there's no room), the gray-seed probe against a fine scan, and `findValueForMixContrast` (incl. value-0 / value-1 keeps and a value-100 mix theme check); the APCA HC boost never lowering a target past its cap, and a non-finite level throwing. |
|
|
62
|
+
| `src/hdr.test.ts` | HDR glows: SDR bit-identity at headroom 0 / 1 (variants and every exporter), exact steps (tinted and pastel bases to 1e-9), the trigger (white theme surface, standalone `'#ffffff'`, never for a pigment that can't lift a non-white surface), the headroom cap and its warnings, continuity at T* → 100 and the reach < 1 jump, theme behavior (HC, `contrastLevel`, `extend()`, palettes, `export()` round-trips, `resetConfig()`), validation and deferred clamp warnings, the writers; then every exporter for themes and palettes behind `modes.hdr` — incl. a `@tenphi/tasty` compile test that evaluates the compiled `@media` rules in each dark / HC / HDR context — and tokens staying untouched. |
|
|
63
|
+
| `src/config.test.ts` | Config isolation and validation: deep-copy `glaze.getConfig()` / `theme.getConfig()`, overrides copied where stored (incl. getters, proxies, `extend()`, both token forms), `darkDesaturation` clamping and errors (incl. `null` for it and `contrastLevel`), lighting tuning fields (non-finite throws, `undefined` is unset), tone-window validation and clamping (global and per-instance at resolve, `null`, the 1e6 eps cap, frozen into exports), `normalizeToneWindow` / `normalizeToneWindowInput`. |
|
|
64
|
+
| `src/color-token.test.ts` | Standalone `glaze.color()` roles (the `'text'` default with and without a base, explicit roles and aliases, `name` as a label only, roles through export / `colorFrom`), value links on a structured base landing on and solving against what the base paints under its own config, the component scales of value strings (incl. hue units, `none`, clamping, rejected components, and the alpha forms that warn, once per string), value objects and structured / override inputs read by property access (getters, inherited fields, non-enumerable fields, proxies), value-object fields clamped between 1 and 1.5, and detached input and snapshots (a raw `base` exported with its config frozen, a proxy `contrast`, a 1000-link chain exported in linear time). |
|
|
65
|
+
| `src/formatters.test.ts` | Output options: removed options rejected at the type level (`@ts-expect-error`, enforced by `pnpm typecheck`), `splitHue` requiring `oklch`, hue-var and value-property collision guards across themes / palettes / primary aliases / tokens, the light-plan rules for colors achromatic in light, hue rounding, and a `static` color with a `darkHue` keeping its light hue reference. |
|
|
66
|
+
| `src/validation.test.ts` | `hasRelativeTone` over both HC-pair entries and the relative-tone-without-base error (a relative HC entry on a root throws at every level; a token's is anchored to its own color); malformed `contrast` specs (and set-vs-`undefined` metric keys), non-finite or malformed numeric fields (relative strings with whitespace allowed), a shadow / glow without `bg`, and non-finite theme seeds. |
|
|
67
|
+
| `src/warnings.test.ts` | Mix contrast-unmet warnings (opaque and transparent, once per scheme, the value-100 fallback, the pastel-cap hint, slack) and the chromatic drift advisory (skipped for unmet and achromatic colors). |
|
|
68
|
+
| `src/hc-pair.test.ts` | Tests for the pure HC-pair selection and interpolation helpers, and `showValue`. |
|
|
69
|
+
| `src/okhsl-color-math.test.ts` | Tests for the format writers' 0–1 scale contract, the percentage-scale guard, producer→writer composition, strict hex parsing (every digit in all four forms; bad input returns `null` from `parseHex` / `parseHexAlpha` and throws from the public entry points), `srgbToOkhsl` reading every gray as saturation 0 and keeping the faintest tints, and `apcaLuminanceFromLinearRgb` reproducing the solver's APCA Lc. |
|
|
70
|
+
|
|
71
|
+
### `docs/` files
|
|
72
|
+
|
|
73
|
+
The documentation site (Cookbook on Astro) renders `docs/**/*.{md,mdx}` at the
|
|
74
|
+
path without the extension, plus `docs-site/index.mdx` as the home page.
|
|
75
|
+
Navigation lives in `docs.config.ts`. Concept and guide pages are `.mdx` with
|
|
76
|
+
live examples; reference pages are plain Markdown.
|
|
77
|
+
|
|
78
|
+
| File | Description |
|
|
79
|
+
|---|---|
|
|
80
|
+
| [docs/getting-started.mdx](docs/getting-started.mdx) | Install, a first theme built step by step, four schemes, and export. |
|
|
81
|
+
| [docs/okhst.mdx](docs/okhst.mdx) | The OKHST color model spec — tone math, `REF_EPS`, tone above 100 (HDR), the T↔L invariant, the scheme pipeline, calibrated window defaults, the contrast metric, and the APCA verification step. Source of truth for the color model. |
|
|
82
|
+
| [docs/schemes.mdx](docs/schemes.mdx) | Light, dark, and high contrast: inversion + tone windows, adaptation modes, dark seed, HC pairs, the manual contrast level. |
|
|
83
|
+
| [docs/contrast.mdx](docs/contrast.mdx) | Contrast solving: WCAG / APCA floors, roles and polarity, `autoFlip`, HC escalation, warnings. |
|
|
84
|
+
| [docs/shadows-and-glows.mdx](docs/shadows-and-glows.mdx) | `shadow` / `glow`: exact tone steps, object influence, state layers, elevation, tuning. SDR only; it links to the HDR page once. |
|
|
85
|
+
| [docs/hdr.mdx](docs/hdr.mdx) | HDR glows (`headroom`): when a glow gets an HDR value, opting in, browsers and native apps, and a WebGPU showcase (a Cookbook `Preview` built by `docs-site/components/shadows-and-glows/hdr-preview.ts`). The one page for HDR. |
|
|
86
|
+
| [docs/mixes.mdx](docs/mixes.mdx) | Opaque and transparent mixes, blend spaces, contrast on mixes. |
|
|
87
|
+
| [docs/methodology.mdx](docs/methodology.mdx) | Palette design methodology — building a complete palette step by step, with the palette shown live as it grows. |
|
|
88
|
+
| [docs/exporting.mdx](docs/exporting.mdx) | Choosing an export shape, wiring tokens into [Tasty](https://tasty.style) / CSS / JSON / DTCG / Tailwind, prefix maps, the `primary` alias, `splitHue`, and the opt-in HDR tier. |
|
|
89
|
+
| [docs/migration.md](docs/migration.md) | Migrating an existing color system, upgrading between majors (one `3.0 — …` section per breaking change; `3.0 — shadows and glows` is referenced by `src/serialize.ts` errors), common pitfalls. |
|
|
90
|
+
| [docs/reference/](docs/reference/index.md) | API reference: `index` (map of every export + conventions), `theme`, `color-definitions`, `contrast`, `color-tokens`, `palette`, `output`, `configuration`, `utilities`. Lookup, not tutorial. |
|
|
91
|
+
|
|
92
|
+
### `docs-site/` files
|
|
93
|
+
|
|
94
|
+
| File | Description |
|
|
95
|
+
|---|---|
|
|
96
|
+
| [docs-site/index.mdx](docs-site/index.mdx) | Home page. |
|
|
97
|
+
| [docs-site/components/](docs-site/components/README.md) | Live-example components (`Example`, `Swatches`, `UiPreview`, `SchemeGrid`, `ToneRamp`, page-specific folders). They resolve themes from `src/` at build time and pass colors through Tasty `styles` — Cookbook rejects `style` attributes and non-Tasty CSS. See the folder's README before adding one. |
|
|
98
|
+
|
|
99
|
+
## Commands
|
|
100
|
+
|
|
101
|
+
| Command | Description |
|
|
102
|
+
|---|---|
|
|
103
|
+
| `pnpm build` | Build ESM + CJS via tsdown (with `.d.ts` and sourcemaps). |
|
|
104
|
+
| `pnpm test` | Run all tests (Vitest 4). |
|
|
105
|
+
| `pnpm test -- <pattern>` | Run a subset of tests. |
|
|
106
|
+
| `pnpm test:watch` | Tests in watch mode. |
|
|
107
|
+
| `pnpm test:coverage` | Tests with coverage report. |
|
|
108
|
+
| `pnpm typecheck` | Type-check the library (`tsconfig.json`) and the test files (`tsconfig.test.json`, Vitest globals) without emitting. |
|
|
109
|
+
| `pnpm lint` | ESLint over `src/`. |
|
|
110
|
+
| `pnpm lint:fix` | ESLint with `--fix`. |
|
|
111
|
+
| `pnpm format` | Prettier write `src/**/*.ts`. |
|
|
112
|
+
| `pnpm format:check` | Prettier check (no writes). |
|
|
113
|
+
| `pnpm changeset` | Create a changeset entry. |
|
|
114
|
+
| `pnpm release` | Build and publish (used by CI). |
|
|
115
|
+
| `pnpm docs` | Docs site dev server (Astro + Cookbook). |
|
|
116
|
+
| `pnpm build:docs` | Build the docs site into `dist/` (overwrites the library build there). |
|
|
117
|
+
| `pnpm build:site` | Docs site + playground, as deployed to GitHub Pages. |
|
|
118
|
+
| `pnpm docs:doctor` | Cookbook configuration / content checks. |
|
|
119
|
+
|
|
120
|
+
## Stack
|
|
121
|
+
|
|
122
|
+
- **Language:** TypeScript (strict mode, `consistent-type-imports` enforced)
|
|
123
|
+
- **Build:** [tsdown](https://github.com/rolldown/tsdown) — ESM + CJS, unbundled, dts + sourcemaps
|
|
124
|
+
- **Test:** [Vitest 4](https://vitest.dev/) with globals enabled; `@tenphi/tasty` is a devDependency only, for the compile test in `src/hdr.test.ts` that renders real `tasty()` output and checks which value each dark / high-contrast / HDR context gets
|
|
125
|
+
- **Lint:** ESLint 10 + typescript-eslint + `eslint-config-prettier`
|
|
126
|
+
- **Format:** Prettier — single quotes, semicolons, trailing commas, 80 cols
|
|
127
|
+
- **Versioning:** [Changesets](https://github.com/changesets/changesets)
|
|
128
|
+
- **Package manager:** pnpm 11
|
|
129
|
+
|
|
130
|
+
## Code Conventions
|
|
131
|
+
|
|
132
|
+
- TypeScript strict mode; `consistent-type-imports` enforced.
|
|
133
|
+
- Test files use `*.test.ts` and live next to the module they test in `src/`.
|
|
134
|
+
- Unused vars prefixed with `_` are allowed.
|
|
135
|
+
- **No runtime dependencies** — every bit of math is implemented from scratch.
|
|
136
|
+
- Functional API pattern: `glaze()` factory with attached static methods; no classes.
|
|
137
|
+
- Each module starts with a JSDoc block describing its responsibility — keep these in sync when behavior changes.
|
|
138
|
+
|
|
139
|
+
## Adding a New Color Type
|
|
140
|
+
|
|
141
|
+
If you need to add another `ColorDef` variant (alongside regular / shadow / glow / mix), touch these files in order:
|
|
142
|
+
|
|
143
|
+
1. [src/types.ts](src/types.ts) — add the new interface and extend the `ColorDef` discriminated union.
|
|
144
|
+
2. [src/lighting.ts](src/lighting.ts) — add a discriminator predicate (`isFooDef`).
|
|
145
|
+
3. [src/validation.ts](src/validation.ts) — extend `validateColorDefs` (missing-reference / cycle handling) and `topoSort` (record the new dependency edges).
|
|
146
|
+
4. [src/resolver.ts](src/resolver.ts) — add a per-scheme `resolveFooForScheme` branch and dispatch from `resolveColorForScheme`.
|
|
147
|
+
5. [src/formatters.ts](src/formatters.ts) — only if the output shape differs (it usually doesn't; the resolver returns a normal `ResolvedColorVariant`).
|
|
148
|
+
6. `src/glaze.test.ts` — add coverage for the new def, including HC pairs, dark-mode behavior, and inheritance through `extend`.
|
|
149
|
+
|
|
150
|
+
For a new public method on `glaze` (e.g. another statics entry), the wiring lives entirely in [src/glaze.ts](src/glaze.ts); the implementation should still go in a focused module.
|
|
151
|
+
|
|
152
|
+
## CI/CD & Release
|
|
153
|
+
|
|
154
|
+
- **CI:** Runs lint, format check, typecheck, build, and tests on push to `main` and PRs.
|
|
155
|
+
- **Release:** On push to `main`, `changesets/action` either creates a version PR or publishes to npm.
|
|
156
|
+
- **Snapshot:** Comment `/snapshot` on a PR to publish a `0.0.0-snapshot.<sha>` release.
|
|
157
|
+
- **npm trusted publishing:** Uses OIDC provenance via the `release` GitHub environment; the workflow requires `environment: release` for npm to accept the OIDC token.
|
|
158
|
+
- **`RELEASE_TOKEN` secret:** Fine-grained GitHub PAT (repo-scoped, `contents: write` + `pull-requests: write`) used by `changesets/action` for Git pushes and PR creation. Rotate before expiry.
|
|
159
|
+
- **Repo settings required:** Settings → Actions → General → Workflow permissions → "Allow GitHub Actions to create and approve pull requests" must be enabled.
|
|
160
|
+
|
|
161
|
+
## Before Pushing Changes
|
|
162
|
+
|
|
163
|
+
Follow [`.cursor/commands/submit-changes.md`](.cursor/commands/submit-changes.md) for the full release-oriented workflow. At minimum:
|
|
164
|
+
|
|
165
|
+
1. **Typecheck** — `pnpm typecheck` (the library and the test files). Stop and fix errors before continuing.
|
|
166
|
+
2. **Lint** — `pnpm lint`. Stop and fix errors before continuing.
|
|
167
|
+
3. **Format** — `pnpm format` so the diff matches Prettier output.
|
|
168
|
+
4. **Changeset** — for any user-visible change (features, fixes, refactors, perf, docs that affect published behavior), add a markdown file in `.changeset/` with a `patch` / `minor` / `major` bump and a concise user-facing summary. Skip only for purely internal changes (CI, repo churn, tests with no behavior change).
|
|
169
|
+
5. **Commit** — Conventional Commits (`feat`, `fix`, `refactor`, `test`, `docs`, `chore`, `perf`, `ci`). Optional scope. Keep the subject short. Include the changeset file in the same commit.
|
|
170
|
+
6. **Push** — never push to `main`. Confirm the current branch, then `git push -u origin HEAD`.
|