Skip to content

Latest commit

 

History

History
169 lines (143 loc) · 43.7 KB

AGENTS.md

File metadata and controls

169 lines (143 loc) · 43.7 KB

AGENTS.md — @tenphi/glaze

Package Overview

  • Package: @tenphi/glaze
  • Repository: https://github.com/tenphi/glaze
  • 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.
  • Runtime: Node >= 20, browsers, edge. Pure math, no I/O.
  • Output formats: ESM + CJS, with full .d.ts.

Project Structure

glaze/
├── src/                       Source
├── docs/                      Long-form docs: concepts, guides, reference (md/mdx, rendered by the site)
├── docs-site/                 Site-only files: home page + live-example components
├── assets/                    Logo + brand assets
├── scripts/                   Repo helpers
├── README.md                  Compact intro
├── AGENTS.md                  This file
├── CHANGELOG.md               Auto-generated by Changesets
├── package.json
├── tsconfig.json
├── tsconfig.test.json         Typecheck config for src/**/*.test.ts (Vitest globals)
├── tsdown.config.ts           Build config
├── vitest.config.ts           Test config
├── astro.config.ts            Docs site (Astro + Cookbook)
├── docs.config.ts             Docs site config: navigation, theme, `@hc` state
└── eslint.config.js

src/ files

File Description
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, formatSrgb and formatSrgbLinear), and the HDR channel helpers hdrToSrgb / hdrToLinearSrgb.
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.
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.
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.
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.
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.
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.
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; a shortfall warns at once only in an authored pass (isAuthoredPass: a normal pass at 'auto' or level 0). The passes where Glaze moves surfaces itself — high contrast, and any other manual level — hold theirs on ResolveContext.lightingShortfalls, and after all passes warnMovedLightingShortfalls warns only for a step short in both light and dark of that pair (light HC + dark HC, or light + dark at the level); the variant is the same pinned solve either way; 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 }).
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.
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.
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 / hdrToSrgb (both public) rebuild the unclamped linear / extended-sRGB channels the writers use.
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.
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.
src/format-guard.ts Export-time guards: assertNativeFormat (okhsl / okhst are tasty()-only) and assertAllPastel (splitHue needs every emitted color pastel). Both public.
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.
src/formatters.ts Variant→string dispatch (okhsl / rgb / hsl / oklch), the HDR writer formatHdrVariant (extended color(srgb R G B) whatever the SDR format, or color(srgb-linear R G B) on request), and the token-map shapes Glaze emits: buildTokenMap (Tasty #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 extended color(srgb …), 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).
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 the unclamped HDR writers formatSrgb (extended sRGB channels, the sRGB curve continued past 1) and formatSrgbLinear (linear channels), both 4 decimals. 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.
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.
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.
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; lightingFallsShort is the same check, which the resolver uses to hold a shortfall from a pass where Glaze moved the surface), 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.
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' | 'srgb-linear' ('srgb', extended color(srgb …), is the default and what every exporter writes; GlazeDtcgHdr.value is extended srgb too); GlazeCssResult has an optional hdr: GlazeCssSchemeBlocks; GlazeGlowInput takes a GlowTuning.
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, unreachable steps on surfaces Glaze moves (a level-faded page shadow quiet at every level, the dark high-contrast page, authored shortfalls still warning at 'auto' / level 0, a step short in both light and dark warning in each, level 100), the manual contrast level, glaze(hue, config) and a null saturation, and the standalone color token round-trip.
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.
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.
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 (incl. ones held from a manual level's passes), 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 (extended color(srgb …) and srgb-linear as one color, hdrToSrgb / hdrToLinearSrgb); 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.
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.
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).
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.
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.
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).
src/hc-pair.test.ts Tests for the pure HC-pair selection and interpolation helpers, and showValue.
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.

docs/ files

The documentation site (Cookbook on Astro) renders docs/**/*.{md,mdx} at the path without the extension, plus docs-site/index.mdx as the home page. Navigation lives in docs.config.ts. Concept and guide pages are .mdx with live examples; reference pages are plain Markdown.

File Description
docs/getting-started.mdx Install, a first theme built step by step, four schemes, and export.
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. Also hosts the ramp explorer (a Cookbook Preview built by docs-site/components/okhst/ramp-explorer.ts), which replaced the standalone playground; astro.config.ts redirects /playground to it.
docs/schemes.mdx Light, dark, and high contrast: inversion + tone windows, adaptation modes, dark seed, HC pairs, the manual contrast level.
docs/contrast.mdx Contrast solving: WCAG / APCA floors, roles and polarity, autoFlip, HC escalation, warnings.
docs/shadows-and-glows.mdx shadow / glow: exact tone steps, object influence, state layers, elevation, tuning, unreachable steps (and "When Glaze moves the surface": which passes warn in high contrast and under a manual level). SDR only; it links to the HDR page once.
docs/hdr.mdx HDR glows (headroom): when a glow gets an HDR value, the extended color(srgb …) format (and why not rec2100-*), opting in and every exporter's HDR tier, where it shows (browsers, WebGPU, native apps), and a WebGPU showcase (a Cookbook Preview built by docs-site/components/shadows-and-glows/hdr-preview.ts). The one HDR guide: other guide pages only link to it, and the reference pages keep their exact API entries (output, color-definitions, configuration, utilities, palette).
docs/mixes.mdx Opaque and transparent mixes, blend spaces, contrast on mixes.
docs/methodology.mdx Palette design methodology — building a complete palette step by step, with the palette shown live as it grows.
docs/exporting.mdx Choosing an export shape, wiring tokens into Tasty / CSS / JSON / DTCG / Tailwind, prefix maps, the primary alias, and splitHue (HDR output lives in docs/hdr.mdx).
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.
docs/reference/ API reference: index (map of every export + conventions), theme, color-definitions, contrast, color-tokens, palette, output, configuration, utilities. Lookup, not tutorial.

docs-site/ files

File Description
docs-site/index.mdx Home page. Opens with the theme playground: a Cookbook Preview built by docs-site/components/home/theme-playground.ts that re-solves the UiPreview card's theme in the browser as the reader moves hue, saturation, and contrast-level sliders.
docs-site/components/ 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. glaze-preview.ts holds what the Preview frames that run Glaze share. See the folder's README before adding one.
docs-site/demo-bundle.ts Astro integration that builds src/index.ts into public/demo/glaze.iife.js (a Glaze global; generated and git-ignored) when the site starts or builds, and again in dev on a source change. A Preview that runs Glaze inlines it through scripts.

Commands

Command Description
pnpm build Build ESM + CJS via tsdown (with .d.ts and sourcemaps).
pnpm test Run all tests (Vitest 4).
pnpm test -- <pattern> Run a subset of tests.
pnpm test:watch Tests in watch mode.
pnpm test:coverage Tests with coverage report.
pnpm typecheck Type-check the library (tsconfig.json) and the test files (tsconfig.test.json, Vitest globals) without emitting.
pnpm lint ESLint over src/.
pnpm lint:fix ESLint with --fix.
pnpm format Prettier write src/**/*.ts.
pnpm format:check Prettier check (no writes).
pnpm changeset Create a changeset entry.
pnpm release Build and publish (used by CI).
pnpm docs Docs site dev server (Astro + Cookbook).
pnpm build:docs Build the docs site into dist/ (overwrites the library build there), as deployed to GitHub Pages.
pnpm docs:doctor Cookbook configuration / content checks.

Stack

  • Language: TypeScript (strict mode, consistent-type-imports enforced)
  • Build: tsdown — ESM + CJS, unbundled, dts + sourcemaps
  • Test: Vitest 4 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
  • Lint: ESLint 10 + typescript-eslint + eslint-config-prettier
  • Format: Prettier — single quotes, semicolons, trailing commas, 80 cols
  • Versioning: Changesets
  • Package manager: pnpm 11

Code Conventions

  • TypeScript strict mode; consistent-type-imports enforced.
  • Test files use *.test.ts and live next to the module they test in src/.
  • Unused vars prefixed with _ are allowed.
  • No runtime dependencies — every bit of math is implemented from scratch.
  • Functional API pattern: glaze() factory with attached static methods; no classes.
  • Each module starts with a JSDoc block describing its responsibility — keep these in sync when behavior changes.

Adding a New Color Type

If you need to add another ColorDef variant (alongside regular / shadow / glow / mix), touch these files in order:

  1. src/types.ts — add the new interface and extend the ColorDef discriminated union.
  2. src/lighting.ts — add a discriminator predicate (isFooDef).
  3. src/validation.ts — extend validateColorDefs (missing-reference / cycle handling) and topoSort (record the new dependency edges).
  4. src/resolver.ts — add a per-scheme resolveFooForScheme branch and dispatch from resolveColorForScheme.
  5. src/formatters.ts — only if the output shape differs (it usually doesn't; the resolver returns a normal ResolvedColorVariant).
  6. src/glaze.test.ts — add coverage for the new def, including HC pairs, dark-mode behavior, and inheritance through extend.

For a new public method on glaze (e.g. another statics entry), the wiring lives entirely in src/glaze.ts; the implementation should still go in a focused module.

CI/CD & Release

  • CI: Runs lint, format check, typecheck, build, and tests on push to main and PRs.
  • Release: On push to main, changesets/action either creates a version PR or publishes to npm.
  • Snapshot: Comment /snapshot on a PR to publish a 0.0.0-snapshot.<sha> release.
  • npm trusted publishing: Uses OIDC provenance via the release GitHub environment; the workflow requires environment: release for npm to accept the OIDC token.
  • 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.
  • Repo settings required: Settings → Actions → General → Workflow permissions → "Allow GitHub Actions to create and approve pull requests" must be enabled.

Before Pushing Changes

Follow .cursor/commands/submit-changes.md for the full release-oriented workflow. At minimum:

  1. Typecheck — pnpm typecheck (the library and the test files). Stop and fix errors before continuing.
  2. Lint — pnpm lint. Stop and fix errors before continuing.
  3. Format — pnpm format so the diff matches Prettier output.
  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).
  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.
  6. Push — never push to main. Confirm the current branch, then git push -u origin HEAD.