- #97
3366b6bThanks @tenphi! - IncludeAGENTS.mdin the npm package so documentation links remain valid.
-
#94
a50183dThanks @tenphi! - Breaking: theformat*writers now takes/l/ton the 0–1 scale the Glaze converters return.formatOkhsl,formatOkhst,formatRgb,formatHsl, andformatOklchtook 0–100 percentages whileresolve(),variantToOkhsl,srgbToOkhsl,oklabToOkhsl, andokhslToSrgball return 0–1 — so composing a producer with a writer was off by 100x and failed silently, since0.7is a legal percentage and the result was a valid CSS string naming a near-black color. The library now speaks one scale end to end. (toTone/fromTonestill speak the 0–100 tone axis the authoring API takes — divide by 100 before handing a tone toformatOkhst.)Drop the
* 100at the call site (formatOkhst(v.h, v.s, v.t)); a leftover one now warns instead of shifting the color quietly. Every export method —css(),tokens(),tasty(),json(),dtcg(),tailwind(),glaze.format()— emits the same colors as before: they were compensating internally, and dropping the redundant×100 ÷100round-trip only moves float noise (visible nowhere except the meaningless hue term of a fully-desaturatedhsl()string).
-
#91
5bf6195Thanks @tenphi! - A manualcontrastLevelno longer suppresses high-contrast output.The level now does one thing: it positions the normal
light/darkvariants on the 0–100 slider. The high-contrast tier stays the true high-contrast resolution — bit-identical to what'auto'resolves — at every level, andmodes.highContrastalone decides whether it is emitted. The two compose: a slider raises the baseline while aprefers-contrast: moreblock still escalates on top of it.Most visible in
css(), which has nomodesoption and always returns four strings: at a mid level itslightContrast/darkContrastblocks now carry genuinely escalated values where they previously repeated the normal declarations.Two consequences of the corrected model:
contrastLevel: 0now reproduces'auto'output exactly, high-contrast tier included. It no longer implies "no high-contrast tier" — that ismodes.highContrast: false, still the default.- At a global
contrastLevel: 100the normal variants already are the high-contrast ones, so a separate tier would duplicate them: a single light/dark set is emitted, even against an explicitmodes.highContrast: true.
Also fixes the side-stability probe leaking into the high-contrast passes, which made a mid-level high-contrast variant diverge from its
'auto'counterpart.
-
#89
19cdde6Thanks @tenphi! - Addfromon theme color definitions. A color can now be seeded from a literal value — the same formsglaze.color()accepts — instead of from the theme:theme.colors({ surface: { tone: 100, saturation: 0.12 }, brand: { from: '#2f5bff', base: 'surface', contrast: 3 }, });
Most of Glaze answers "design me a palette". This answers the other question, "honor this color" — white-label products, multi-tenant branding and imported design tokens all arrive with a value already chosen, and it is a contract rather than a starting point.
fromsupplieshue,tone, and — uniquely among theme colors — an absolute saturation. That last part is what makes the feature worth having. Every other color'ssaturationis a 0–1 factor of the theme seed, so the seed is a ceiling: the only way to place a color more saturated than its theme was to re-seed the theme, which drags every sibling along. A palette whose accent seed is shared with its status themes could not honor one brand color without re-chromatizingdanger,successand the rest as a side effect. Afromcolor carries its own chroma and is unaffected by the seed.The light, normal-contrast variant reproduces the value exactly (a local
lightTone: false, matching the value-shorthand form ofglaze.color()). Dark and high contrast adapt as usual — those are the variants a reader reaches for when the normal one does not work for them, so readability outranks fidelity there, and a color pinned across all four would just be a worsemode: 'static'. Acontrastfloor still applies everywhere and is still a floor rather than a target: a value that already clears it is emitted untouched.Sibling fields override what the value supplied, so
{ from: '#2f5bff', hue: 300 }keeps the saturation and tone and rotates the hue. Afromcolor needs neitherbasenortone— it is placed absolutely, so it stands as a root on its own.An unparseable
fromis rejected byvalidateColorDefswith the color's name in the message, rather than surfacing the parser's own error from inside the resolver — the string alone does not tell you which of fifty tokens carries it.Two smaller consequences. Under
splitHue, afromcolor now gets its own--{name}-huecustom property in both schemes rather than referencing the theme's: it authors a hue that is not the theme's, so tracking the theme var would re-skin it on the next re-seed — the same failure mode fixed fordarkHue-only colors in 1.3.1. And the value parsing / validation forGlazeColorValuemoved fromcolor-token.tsto a new internalcolor-value.tsso the resolver can reach it without an import cycle; no public export changed.
-
#87
b5eb888Thanks @tenphi! - FixsplitHueexports dropping a per-colordarkHue. A color that authored onlydarkHue(no lighthue) referenced the theme's--{name}-huevar in both schemes, so the--{color}-huedeclaration emitted in the dark block was never read and the dark hue was silently ignored. A color that authors a hue in either scheme now gets its own hue custom property in both, tracking the theme hue in the scheme it did not author, so the sharedvar()reference stays valid and runtime re-skinning still works. Affects bothcss({ splitHue: true })and the Tasty token map.Also corrects the
tone: 'max'/'min'with abasedocumentation: the high-contrast variants are not exempt from the light-shift replay. They follow the same rule, and because their tone window is already the full range the replay reproduces the plain mapping unless the base itself sits asymmetrically across schemes — amode: 'fixed'or contrast-solved base, for example. The behavior is unchanged; only the docs and changelog claimed otherwise.
-
#85
ff20d49Thanks @tenphi! - AddcontrastLevel— a manual contrast level that turns the two-tier high-contrast model into a0–100slider. Set it globally viaglaze.configure(), or per theme / per token / throughextend():glaze.configure({ contrastLevel: 60 }); const theme = glaze(280, 80, { contrastLevel: 60 }); glaze.color('#26fcb2', { contrastLevel: 60 });
Level
0reproduces the normallight/darkoutput and level100thelightContrast/darkContrastoutput, bit for bit. Levels in between are resolved at that level, not interpolated after the fact: Glaze interpolates the three things that make high contrast differ — authored[normal, highContrast]pairs, the tone-window widening (light[10,100] → [0,100], dark[15,95] → [0,100]), and theAA → AAA/ APCA+15 Lcescalation — and feeds those through the ordinary resolve. A contrast floor is therefore genuinely solved at every level (contrast: 'AA'at level 50 solves for 5.75), and the floor is met at every level.While a level is set there is no separate high-contrast tier:
lightContrast/darkContrastmirror their normal counterparts, and a global level turns high-contrast output off outright so no exporter emits the tier.modes.highContrastgoes inert rather than fighting it — it reads as "emit a separate high-contrast set when contrast is automatic" — so a build config that leaveshighContrast: trueset keeps working, silently, when a user switches their preference from auto to manual.css()keeps its four-string shape with the high-contrast strings repeating the normal declarations, so existing@media (prefers-contrast: more)wiring keeps working untouched. A level set on one theme of a palette leaves its siblings' high-contrast tier alone.A color never swaps sides of its base mid-slider.
autoFlip's tie-break — when both directions meet the floor, take the one nearer the authored tone — depends on the target, so along a ramp it would let a color leap across its base. The side is now decided once from the nearer endpoint and preferred throughout that half of the ramp: a color whose two ends agree never changes side, and one whose ends genuinely disagree changes exactly once, at level 50. Flipping is only re-ordered, never disabled, so a side that cannot physically reach the target still falls back. This is exposed as a newpreferInitialoption onfindToneForContrast.Un-interpolable tone pairs (
[50, 'max'],[50, '+20'],['max', 'min']) switch at level 50 rather than blending across kinds.Also exports
resolveContrastForLevel(spec, level, polarity?).A
contrastpair may no longer switch metric.[4.5, { apca: 75 }]now throws a validation error: a WCAG ratio and an APCA Lc are different scales, so no target exists between them and the two variants are incomparable even without a manual level. Pair values inside one metric instead —{ wcag: [4.5, 7] }or{ apca: [60, 90] }. Previously such a pair resolved silently.Nothing else changes by default:
contrastLeveldefaults to'auto', which is today's behavior exactly, and no existing type, signature, or output shape moved. As a bonus, a manual resolve runs two passes instead of four.
-
#82
7002c2dThanks @tenphi! - Preserve contrast fortone: 'max'/'min'on colors with abase. The extreme is no longer re-mapped through the dark tone window (which compressed the base-to-extreme span and lowered contrast in dark). Glaze now measures the tone shift the light scheme applied between the base and the extreme and replays it against the base's resolved dark tone — mirrored undermode: 'auto', same-signed under'fixed'. The result is clamped to[0, 100]only, so it may cross thedarkToneboundary, and pins at the extreme when the shift does not fit. Root extremes (nobase),mode: 'static', high-contrast variants, and standaloneglaze.color()tokens are unchanged. -
#84
5809f18Thanks @tenphi! - AdddarkHue/darkSaturationso a palette can seed a different hue and saturation for the dark schemes instead of relying on the flatdarkDesaturationhaircut.Set them on the theme seed for a whole-palette shift, or on a single color to retune just that token:
const theme = glaze({ hue: 280, saturation: 80, darkHue: 268, // dark schemes seed from 268 darkSaturation: 65, // and from 65 rather than 80 }); theme.colors({ accent: { tone: 55, hue: '+20', darkHue: '+35' }, warning: { tone: 60, saturation: 0.9, darkSaturation: 0.6 }, });
Both apply to the
darkanddarkContrastvariants, and both fall back to their light counterparts, so existing themes resolve exactly as before. Seed values use the same units ashue/saturation(0–360 and 0–100); a color def'sdarkSaturationis a 0–1 factor like itssaturation. RelativedarkHue: '+N'anchors to the theme's dark seed hue. Authoring any dark saturation bypassesdarkDesaturationrather than stacking with it, andmode: 'static'ignores both. Shadows and mixes need no new fields — they derive their channels from the colors they reference.glaze.color()gains the same controls asdarkHue,darkSaturation(0–100 seed) anddarkSaturationFactor(0–1), all round-tripping throughexport()/glaze.colorFrom().splitHueexports stay correct: when a dark hue is authored, the hue custom properties are re-declared in the dark block and under the Tasty dark state.
- #80
9d0487eThanks @tenphi! - Fix relativetonewithautoFlip: when mirroring an overshooting delta still leaves[0, 100], keep the original direction and clamp instead of pinning to the wrong extreme.
- #78
8690d87Thanks @tenphi! - Add palette authoring round-trip (palette.export()/glaze.paletteFrom()), a restore triad withglaze.themeFrom(andglaze.fromas alias),kind/versionon all authoring exports, palette theme introspection (theme/themes/list/primary), and export type guards. Authoring.export(override?)freezesgetConfig() ∪ instance local ∪ overrideat call time (nested colorbaseexports receive the same override). Live themes and color tokens keep a sparse local override and track liveconfigure()for omitted fields. Removepastelfromglaze.configure()/GlazeConfig— set it per-theme, per-token, or per-color instead.
- #75
04e794fThanks @tenphi! - Tasty exports now default to media-query state aliases (@media(prefers-color-scheme: dark)and@media(prefers-contrast: more)) instead of the custom@dark/@high-contrastaliases. Tokens now react to the OS preference out of the box without registering custom Tasty states. Override viaglaze.configure({ states })or per-exportstatesto keep the old aliases.
-
8b6cd07Thanks @tenphi! - High-contrast mode now auto-enhances a bare contrast target (no[normal, hc]pair at either the outercontrastor inner metric level). An explicit HC value via either pair always overrides.- APCA: a bare APCA scalar is boosted by the APCA-W3 "Enhanced Level" +15 Lc delta, clamped to 106 Lc.
- WCAG: a bare WCAG preset is promoted to its spec-defined "Enhanced" successor (SC 1.4.3 → 1.4.6) —
AA→AAA(4.5 → 7) andAA-large→AAA-large(3 → 4.5).AAA/AAA-large(top tier) and bare numeric targets are left unchanged.
New exports:
APCA_HC_ENHANCEMENT(15),APCA_MAX_LC(106);resolveContrastForModegains an optionalouterExplicitHCparameter.
10c0c0aThanks @tenphi! - Rename the per-colorflipconfiguration property toautoFlipto match the global configuration key and avoid confusion with dark-mode/scheme tone inversion.
fa25edaThanks @tenphi! - Default export format is nowoklchfor all exporters (tasty(),token(),css(), and internal formatter defaults). Use{ format: 'okhsl' }or{ format: 'rgb' }to opt into other formats.
-
#69
86c3d27Thanks @tenphi! - Add opt-in DTCG Resolver-Module export (dtcgResolver())- New export.
theme.dtcgResolver(),palette.dtcgResolver(), andglaze.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 singleschememodifier with a context per variant (light/dark/lightContrast/darkContrast) and aresolutionOrder. An alternative todtcg()'s per-scheme files for resolver tools such as Dispersa. - Why one modifier. Glaze resolves
darkContrastindependently (it is notdark+lightContrastlayered), so the four-context shape keeps every resolved value exact. Two independent modifiers would compose additively and produce wrong dark + high-contrast values. - Options.
GlazeDtcgResolverOptionsextendsGlazeDtcgOptions(modes+colorSpacepass through) withsetName(default'base'),modifierName(default'scheme'),contextNames(rename the four contexts), andversion(default'2025.10'). Standaloneglaze.color().dtcgResolver()requiresname. - New public types:
GlazeDtcgResolverDocument,GlazeDtcgResolverOptions,GlazeColorDtcgResolverOptions,DtcgTokenTree,DtcgResolverSet,DtcgResolverModifier,DtcgResolverRef.
- New export.
-
#69
86c3d27Thanks @tenphi! - Add DTCG and Tailwind CSS v4 token exportsdtcg()— W3C Design Tokens Format Module (2025.10) export. Available on themes, palettes, and standaloneglaze.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. ThecolorSpaceoption selects the$valuerepresentation:'srgb'(default — gamma sRGBcomponentsin 0–1 plus ahexhint) 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@themeblock (light baseline) plus dark / high-contrast overrides under configurable selectors. The--color-*namespace (configurable vianamespace) auto-generatesbg-*/text-*/border-*utilities.darkSelector(default.dark) accepts an at-rule like'@media (prefers-color-scheme: dark)'(nests:rootautomatically);highContrastSelector(default.high-contrast) covers the HC variants, with the combined block at${darkSelector}${highContrastSelector}. Defaultformatis'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) andokhslToOklch(h, s, l, pastel?)(OKHSL →[L, C, H]), shared byformatOklchand the DTCG exporter. - Both new exports honor
modes(dark / high-contrast gating) and the paletteprefix/primaryoptions. Onpalette.tailwind(), the palette theme-prefixprefixis separate fromGlazeTailwindOptions.namespace(the--color-*CSS namespace).
-
#69
86c3d27Thanks @tenphi! - feat+breaking: oklch hue channel splitting (pastel-only); add okhst tasty-only output; okhsl/okhst are tasty-only; tokens/json default to oklch- Add
splitHueoncss()/tasty()(theme + palette) and standalonecolor.css()— emits hue as a separate custom property referenced viavar()inoklchvalues. Requires every exported color to be pastel. - Add
'okhst'output format (okhst(H S% T%)) for Tasty exports. okhslandokhstthrow on non-Tasty exports (css,tailwind,tokens,json).tokens()/json()default format changes fromokhsltooklch(theme, palette, standalone.json()).
- Add
-
#69
86c3d27Thanks @tenphi! - Add semantic color roles with APCA polarity and APCA presets- Roles. Colors now carry a semantic
role('text'|'surface'|'border', with aliases likebg/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 totext(foreground), preserving previous behavior. Setglaze.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' }orcontrast: { apca: ['content', 'body'] }. Presets are role-independent. roleis also available onMixColorDefand standaloneglaze.color()inputs and survives theexport()/glaze.colorFrom()round-trip.
- Roles. Colors now carry a semantic
-
#67
3f70089Thanks @tenphi! - Allowpastelto be set per color, not just globally.Every color definition (
RegularColorDef,ShadowColorDef,MixColorDef) andglaze.color()token now accepts an optionalpastel?: booleanthat overrides the global / per-themepastelconfig for that color only. Omit it to keep inheriting the config default.
-
#65
7663cb8Thanks @tenphi! - Addpastelconfig option,getConfig()toGlazeTheme, and exportcuspLightness.pastel: A new configuration option (boolean, defaultfalse) has been added toGlazeConfigandFindToneForContrastOptions. When enabled, it uses a hue-independent "safe" chroma limit across all colors so that scaling saturation never exceeds the sRGB boundary at any hue for the given lightness.getConfig(): Added toGlazeThemeto allow retrieving the effective configuration (GlazeConfigResolved) for a theme.cuspLightness(h): Exported fromokhsl-color-mathto allow retrieving the OKHSL lightness of the gamut cusp for a given hue.
-
#65
7663cb8Thanks @tenphi! - Remove the cusp-anchored saturation taper completely.Breaking change
saturationTaperhas been removed fromGlazeConfigandGlazeConfigOverrideas well as fromglaze.configure(). The concept of tapering/clamping saturation at lightness extremes is no longer supported, and colors are allowed to maintain their requested saturation across the entire lightness spectrum.saturationTaperhas also been removed fromFindToneForContrastOptions.
-
#63
3b6e2a6Thanks @tenphi! - Replace the OKHSL lightness axis with a contrast-uniform tone axis (OKHST) and remove the Möbius dark-mode curve.Breaking changes
- The
lightnessauthoring prop is gone. Usetone(0–100, contrast-uniform) everywhere — theme colors,glaze.color()structured input, and relative offsets. Equal tone steps now give equal WCAG contrast, so numeric values won't map to the same OKHSL lightness as before; re-check absolute mid-range values. - Config windows changed:
lightLightness/darkLightness→lightTone/darkTone. A window is[lo, hi](reference eps — the common form),{ lo, hi, eps }(advanced eps tuning), orfalseto disable clamping.falseremoves the boundaries (full[0, 100]range) but keeps the contrast-uniform tone curve.darkCurvewas removed. ResolvedColorVariantnow stores{ h, s, t, alpha }(tone) instead of{ h, s, l }. Use the newvariantToOkhsl()helper to recover OKHSL lightness.- Export snapshots now carry
lightTone/darkTonewindows. - Relative
toneoffsets that overshoot[0, 100]now mirror to the other side of the base by default (the newflip, inheritingautoFlip) instead of clamping. Setflip: false(orautoFlip: false) to restore clamping.
New
tone: 'max'/'min'forces a color to the scheme's tone extreme (lightest / darkest) with nobaseand no contrast hack; undermode: 'auto'they invert in dark like any tone.flipper-color prop (default: globalautoFlip): mirrors out-of-bounds relativetoneovershoot and unmetcontrastto the opposite side of the base, or clamps whenfalse.- Tone windows accept the
[lo, hi]array shorthand alongside{ lo, hi, eps }andfalse. contrastaccepts a metric selector: a bare number/preset is WCAG,{ wcag }/{ apca }picks the metric, and the[normal, hc]pair may live at the outer level or inside the metric ({ wcag: [4.5, 7] }).- APCA Lc contrast solving alongside WCAG, plus an APCA-based drift verification warning for chromatic swatches.
- OKHST input:
okhst(H S% T%)strings and{ h, s, t }objects (input only — never emitted). saturationTaperconfig knob (default0.15) gently rolls off saturation toward the tone extremes.- New exports:
toTone,fromTone,toneFromY,yFromTone,okhstToOkhsl,okhslToOkhst,variantToOkhsl,REF_EPS,findToneForContrast,resolveContrastForMode,apcaContrast, and theContrastSpec/OkhstColor/ToneWindow/ExtremeValue/ToneValuetypes.
- The
- #63
3b6e2a6Thanks @tenphi! - Adjust the default tone-window floors:lightToneis now[10, 100](was[13, 100]) anddarkToneis now[15, 95](was[10, 95]). The OKHST migration made dark schemes bottom out darker than the legacy pipeline for the same input; lifting the dark floor keeps the darkest dark-mode surfaces closer to the previous output, and lowering the light floor widens the usable light range. Override withlightTone: [13, 100]/darkTone: [10, 95]to restore the prior values.
-
#61
43be630Thanks @tenphi! - Redesignglaze.color()input API and add per-instance config overrides.Breaking:
glaze.color()arg layout changedThe old two-overload signature (
value, overrides?, scaling?/structured, scaling?) is replaced by a single unified signature:glaze.color(color, config?)
color(arg 1): four shapes discriminated by structure:- Bare string →
glaze.color('#26fcb2') - Value object →
glaze.color({ h: 152, s: 0.95, l: 0.74 }) - New
{ from, ...overrides }→glaze.color({ from: '#fff', base: bg, contrast: 'AA' }) - Structured →
glaze.color({ hue: 152, saturation: 95, lightness: 74 })
- Bare string →
config(arg 2, optional):GlazeConfigOverride— overrides resolve-relevant config fields for this token only.
Migration
// Before glaze.color('#fff', { base: bg, contrast: 'AA' }); glaze.color('#fff', undefined, { darkLightness: false }); glaze.color('#fff', { opacity: 0.5 }, { lightLightness: false }); // After glaze.color({ from: '#fff', base: bg, contrast: 'AA' }); glaze.color('#fff', { darkLightness: false }); glaze.color({ from: '#fff', opacity: 0.5 }, { lightLightness: false });
New: per-instance config override (
GlazeConfigOverride)- Applies to both
glaze.color()andglaze()themes (second arg). - Fields:
lightLightness,darkLightness,darkDesaturation,darkCurve,autoFlip,shadowTuning. falsefor a lightness window disables clamping globally (= [0, 100]). Now accepted everywhere:configure(), per-token, and per-theme.- Token config is snapshotted at creation. Theme config merges over the live global at resolve time (non-overridden fields still react to
configure()). extend({ config })inherits and merges parent + child overrides.theme.export()/glaze.from()round-trip the config.
New:
{ from }form for value colors with overridesPass a raw color value alongside color overrides in one object:
const text = glaze.color({ from: '#1a1a2e', base: bg, contrast: 'AA' });
New: full-range base conversion
When a value/
fromcolor links to abasecreated via the structured form, the contrast/lightness anchor uses the raw input lightness (not the windowed output). This ensures accurate anchoring without affecting the base's own resolved output.Breaking: removed
GlazeColorScalingGlazeColorScalingis removed and replaced byGlazeConfigOverride. Scaling was limited to lightness windows; the new type covers the full resolve-relevant config.
- #60
5fef8c5Thanks @tenphi! - Breaking:glaze.color()value-shorthand changes:- Removed RGB tuple
[r, g, b]— use{ r, g, b }instead. - Added
RgbColor({ r, g, b }) andOklchColor({ l, c, h }) object inputs (also accepted byglaze.shadow()). - Unified scaling for all value-shorthand (strings and literal objects):
lightLightness: false,darkLightness: globalConfig.darkLightness(snapshotted). Strings no longer use the extended[darkLo, 100]dark window — the default#000→ white dark flip is gone unless you pass explicitscaling: { darkLightness: [lo, 100] }. - Object/tuple value-shorthand no longer remap light lightness through
globalConfig.lightLightness(structured{ hue, saturation, lightness }still does). Opt back in withscaling: { lightLightness: [10, 100], ... }.
- Removed RGB tuple
- #58
9fdb6bbThanks @tenphi! - Respect authored lightness direction when contrast auto-flip is disabled.
-
#56
978bf60Thanks @tenphi! - Restructure documentation into focused README, API, methodology, and migration guides. -
#56
978bf60Thanks @tenphi! - Internal refactor: split the 2636-linesrc/glaze.tsinto focused, flat modules (config,hc-pair,shadow,warnings,scheme-mapping,validation,resolver,formatters,theme,palette,color-token) and dedupe a few parallel structures:- The resolver's four-pass loop is now a single
runPass()+seedField()helper called four times. - The palette
tokens/tasty/cssexporters share abuildPaletteOutput()driver instead of duplicating the per-theme loop / prefix resolution / collision filtering / primary-duplication logic. - The default-config literal is no longer duplicated between module
init and
resetConfig(); both call a shareddefaultConfig(). themeexports now cache the resolve result and invalidate it on any def mutation orconfigure()/resetConfig()call (via a newconfigVersioncounter), so back-to-back exports don't re-run the four-pass resolver.
No public API or behavior changes.
- The resolver's four-pass loop is now a single
-
#54
88be8a4Thanks @tenphi! -glaze.color()now defaults tomode: 'auto'across every input form, so non-string inputs adapt between light and dark like an ordinary theme color instead of being preserved verbatim with a linear dark mapping.- Object value-shorthand (
{ h, s, l }), RGB tuple ([r, g, b]), and structured form ({ hue, saturation, lightness, ... }) now default tomode: 'auto'with snapshotted scaling{ lightLightness: globalConfig.lightLightness, darkLightness: globalConfig.darkLightness }. The dark variant is Möbius-inverted intoglobalConfig.darkLightness(default[15, 95]), and the light variant is mapped throughglobalConfig.lightLightness(default[10, 100]) — exactly the same windows a theme color uses. - String value-shorthand (hex /
rgb()/hsl()/okhsl()/oklch()) is unchanged. It already defaulted tomode: 'auto'with{ lightLightness: false, darkLightness: [lo, 100] }, preserving the#000↔#fffflip.
Behavior change (minor bump):
glaze.color({ hue: H, saturation: S, lightness: 80 }).resolve()(and the equivalent object / tuple forms) now produces a near-darkdark.l(e.g. ~0.42forlightness: 80under defaults) instead of staying near0.79.light.lfor object / tuple / structured inputs is now mapped throughglobalConfig.lightLightnessrather than preserved verbatim (e.g.lightness: 0now resolves tolight.l ≈ 0.10by default).- To restore the previous fixed-linear behavior, pass
{ mode: 'fixed' }on the input or in the overrides. To restore the previous "preserve light lightness verbatim" behavior, pass{ lightLightness: false }as the trailingscalingargument.
The new scaling shape is also reflected in
token.export()snapshots — object / tuple / structured tokens now serialize{ lightLightness: [10, 100], darkLightness: [15, 95] }(with the liveglobalConfigvalues frozen at create time) instead of{ lightLightness: false, darkLightness: [15, 95] }. Rehydration viaglaze.colorFrom()round-trips byte-for-byte. - Object value-shorthand (
- #52
1988ff8Thanks @tenphi! - FixsrgbToOkhsl(and downstreamglaze.color()) returning a bogus saturated hue/saturation for pure white (#FFFFFF) and other colors at the OKHSL lightness extremes. Floating-point residue fromlinearSrgbToOklabslipped past the existing chroma epsilon, sending the chromatic path through a degenerate gamut where saturation divides by ~zero. White now correctly resolves tookhsl(0 0% 100%)(light) /okhsl(0 0% 15%)(dark) instead ofokhsl(89.88 55.83% 100%).
-
#50
6e2d42dThanks @tenphi! - Revampglaze.color()with a value-shorthand overload, seed-anchored contrast solving, a per-call lightness-scaling argument, and a.css()export.glaze.shadow()now accepts the same value forms asglaze.color().New defaults for
glaze.color()— split by input form so end-user string values (color picker / theme settings) get a natural light/dark inversion, while programmatic object / tuple / structured inputs keep predictable linear behavior:- String value-shorthand (hex,
rgb(),hsl(),okhsl(),oklch()):mode: 'auto'with snapshotted scaling{ lightLightness: false, darkLightness: [globalConfig.darkLightness[0], 100] }. Light preserves the input exactly; dark Möbius-inverts up to100, soglaze.color('#000')renders as#fffin dark mode andglaze.color('#fff')falls to the darklofloor (default0.15). The darklois snapshotted fromglobalConfigat color-creation time, matching how an explicitscaling.darkLightness: [lo, hi]behaves. - Object / tuple value-shorthand (
{ h, s, l },[r, g, b]) and structured form:mode: 'fixed'with light preserved and dark linearly mapped intoglobalConfig.darkLightness(default[15, 95]), also snapshotted at create time so laterglaze.configure()calls don't retroactively change already-created tokens. - Override per call via the new third positional argument
GlazeColorScaling:{ lightLightness?: false | [lo, hi]; darkLightness?: false | [lo, hi] }.falsedisables the remap, a tuple sets a custom window. To opt string inputs back into the previous fixed-linear default, pass{ mode: 'fixed' }as the second arg or supply an explicitscaling.
Behavior change (minor bump):
- String value-shorthand callers will see a Möbius-inverted dark
variant by default —
glaze.color('#000').resolve().dark.lis now≈ 1.0, not0.15. To preserve the old fixed-linear behavior pass{ mode: 'fixed' }as the second argument. - Structured callers without an explicit
modewill seeglaze.color({...}).resolve().light.lmatch the input lightness exactly instead of being remapped toglobalConfig.lightLightness. To preserve the old behavior pass{ lightLightness: globalConfig.lightLightness }as the second argument. - The default lightness windows for object / tuple / structured
inputs are now snapshotted from
globalConfig.darkLightnessat color-creation time, matching the existing behavior for string inputs. Tokens created before aglaze.configure()call no longer pick up the new dark window on their next.resolve(). To get the old "live config" behavior, recreate the token afterconfigure().
Value shorthand additions:
- Accepts hex (
#rgb/#rrggbb/#rrggbbaa), the four CSS color functions Glaze itself emits (rgb(),hsl(),okhsl(),oklch()),OkhslColorobjects ({ h, s, l }), and[r, g, b](0–255) tuples as the first argument. Every string emitted bytheme.tasty() / .json() / .css()round-trips back throughglaze.color(). - 8-digit hex and
rgba()/hsla()/ slash-alpha alpha components are parsed and dropped with aconsole.warn(standalone colors have no opacity field). oklch()chroma now correctly interprets percent values per CSS Color 4 (100% → 0.4).OkhslColorand[r, g, b]inputs are validated up front with helpful error messages — passing 0–100-scales/lthrows with a hint to use the structured form, and out-of-range RGB tuples throw with the offending value in the message.
Anchor model: by default, relative
lightness: '+N'andcontrast: <ratio>are anchored to the literal seed (the value passed toglaze.color()), so the contrast solver compares against the unmapped user-provided color across every variant. Passoverrides.base(aGlazeColorToken) to anchor against another color's resolved variant per scheme instead.Color pairing via
base:GlazeColorOverrides.baselets one standalone color depend on another. Accepts either aGlazeColorTokenor anyGlazeColorValue(hex /rgb()/OkhslColor/[r, g, b]); raw values are auto-wrapped viaglaze.color(value)and inherit the same string-vs-object defaults. When set:contrastis solved per scheme against the base's resolved variant (light / dark / lightContrast / darkContrast).- Relative
lightness: '+N'/'-N'is anchored to the base's lightness per scheme (matches theme behavior for dependent colors). - Relative
hue: '+N'still anchors to the seed (the value passed toglaze.color()), not the base. modeis the per-pair knob — passmode: 'fixed'to disable Möbius inversion for the dependent color,mode: 'auto'to keep it.
The base token's
.resolve()is called lazily on first resolve and the result is captured by reference, matching existing snapshot semantics. Internally,resolveAllColorsaccepts pre-resolved external bases and seeds them into the resolution context;validateColorDefsandtopoSorttreat external base names as leaves.opacityandnameonglaze.color():GlazeColorOverrides.opacity(and the same field onGlazeColorInput) sets a fixed alpha 0–1 that surfaces in every scheme variant. Combining withcontrastis not recommended (perceived lightness becomes unpredictable) —glazeemits aconsole.warnin that case.GlazeColorOverrides.name(and the same field onGlazeColorInput) is a human-readable label that surfaces in error and warning messages in place of the internal"value"sentinel. Empty / whitespace-only names and reserved internal names ("value","seed","externalBase") are rejected with a clear error.
Structured form parity: the
glaze.color({...})overload now acceptsopacity,contrast,base, andnamein addition to the existinghue,saturation,lightness,saturationFactor, andmode.contrastwithoutbasesynthesizes a hidden static seed from the input's normal-mode lightness so the contrast solver always has an anchor (mirrors value-form behavior).hue(finite),saturation/lightness(0–100),saturationFactor(0–1), andopacity(0–1) are range-checked up front with helpful error messages — non-finite or out-of-range values fail at creation rather than producing a NaN-laden token.Contrast warning: when the contrast solver cannot meet the requested target (e.g. AAA against a mid-grey base — physically unreachable),
glazeemits a singleconsole.warnper(name, scheme, target)triple naming the affected color, scheme, and the actual achieved ratio. The token still resolves to the closest passing variant. Use thenameoverride to make the warning easier to trace.Persisting standalone colors:
token.export()returns a JSON-safe snapshot containing the originalvalue(or structured input), the overrides, and the capturedscaling. Token-typedbaseis recursively serialized; value-typedbaseis preserved as the raw value. Pass the result toglaze.colorFrom(data)to rehydrate a token that resolves byte-for-byte identically to the original — acrossglaze.configure()calls and across processes. The capturedscalingsnapshots bothlightLightnessanddarkLightnessfromglobalConfigat create time, so laterglaze.configure()calls don't retroactively change exported tokens regardless of input form..css({ name })export: new method on the standalone color token reaches export parity withtheme.css(). Existing.token() / .tasty() / .json()continue to work unchanged.glaze.shadow()upgrade:bgandfgnow accept anyGlazeColorValueform — hex,rgb()/hsl()/okhsl()/oklch()strings,OkhslColorobjects, or[r, g, b]tuples — sharing the same parser asglaze.color().Internal: standalone color tokens now memoize the underlying resolve across
.resolve() / .token() / .tasty() / .json() / .css()calls.Public type additions:
GlazeColorValue,GlazeColorOverrides,GlazeColorOverridesExport,GlazeColorCssOptions,GlazeColorScaling,GlazeColorTokenExport,GlazeColorInputExport. Newglaze.colorFrom(data)factory andtoken.export()method onGlazeColorToken. NewhslToSrgb,oklabToOkhsl, andparseHexAlphamath helpers re-exported from the package root. - String value-shorthand (hex,
- #46
85111caThanks @tenphi! - Addinheritflag to color definitions to prevent inheritance duringextend()
- #44
50340c4Thanks @tenphi! -darkCurvenow accepts a[normal, highContrast]pair for separate HC tuning.
- #43
0575838Thanks @tenphi! - Moveprimaryoption from per-export toglaze.palette()creation, add collision detection (warn + first-write-wins) across all palette export methods.
-
#40
c47cfd2Thanks @tenphi! - Unify dark/HC lightness mapping into a single code path vialightnessWindowhelper. -
#42
941338aThanks @tenphi! -darkCurvenow accepts a[normal, highContrast]pair for separate HC tuning.
- #37
8b0b62bThanks @tenphi! - Bypass lightLightness and darkLightness window constraints in high-contrast mode, allowing colors to use the full 0–100 lightness spectrum for increased perceivable contrast.
-
#39
f9f6defThanks @tenphi! - AdddarkCurveconfig option for perceptual dark-theme lightness inversion using a power curve. Expands subtle near-white distinctions in dark mode. Default0.5; set to1for legacy linear behavior. Widen contrast solver search range to[0, 1]so contrast targets are met regardless of dark lightness window. -
#39
f9f6defThanks @tenphi! - Replace power-curve dark lightness mapping with Möbius transformation for proportional expansion of lightness deltas across all sizes.
- #34
2278b4eThanks @tenphi! - Addprimaryoption to palette exports (tokens,tasty,css) that duplicates one theme's tokens without prefix. Palette prefix now defaults totrue.
- #36
e9e6ef6Thanks @tenphi! - Fix contrast solver undershooting WCAG targets when using OKLCH output format. Increase OKLCH hue precision to 2dp and widen solver overshoot margin.
-
d148498Thanks @tenphi! - ApplylightLightnessmapping to dependent colors with absolute lightness, matchingdarkLightnessbehavior. -
#32
90bd23cThanks @tenphi! - Propagate scheme lightness range to contrast solver for dependent colors, preventing pure black/white output when contrast-solving against extreme lightness values.
- #30
06be989Thanks @tenphi! - Use 1% proportional contrast overshoot to reliably meet WCAG targets after 8-bit RGB quantization.
- #28
8085e0fThanks @tenphi! - Increase contrast search overshoot to avoid floating-point rounding below WCAG threshold.
- #25
7462021Thanks @tenphi! - Add mix color type for blending two colors with optional contrast solving- New
MixColorDefwithtype: 'mix'— blend two referenced colors viabaseandtarget - Opaque blend: interpolates in OKHSL or sRGB space, producing a solid color
- Transparent blend: outputs the target color with controlled opacity (alpha = value/100)
spaceoption:'okhsl'(default, perceptually uniform) or'srgb'(matches browser compositing)contrastoption: adjusts mix ratio or opacity to meet a WCAG contrast floor against the base- Achromatic hue handling: when mixing with unsaturated colors (e.g. white/black), the hue is taken from the saturated color
valueandcontrastsupport[normal, highContrast]pairs- Mix colors can reference other mix colors (chaining) but not shadow colors
- New
- #27
175fc81Thanks @tenphi! - Fix green-channel k4 sign error in OKHSL gamut mapping, deduplicate internal color math, add round-trip regression tests.
- #23
a06a843Thanks @tenphi! - Fix contrast solver using wrong target variable for preferred lightness check, and increase decimal precision in OKHSL/HSL color formatting from 1 to 2 digits.
d7f00b1Thanks @tenphi! - Fix contrast solver computing WCAG luminance from unclamped linear sRGB, which caused it to overestimate contrast for high-saturation colors near gamut boundaries (e.g. lime green). The solver now matches the browser rendering pipeline by gamma-encoding, clamping to sRGB gamut, then linearizing before computing luminance.
5acac86Thanks @tenphi! - Add +0.01 margin to the contrast solver's internal search target to prevent floating-point rounding from producing contrast ratios like 4.4999… that fail Lighthouse's exact WCAG AA threshold check.
3b8cdbfThanks @tenphi! - Remove spurious warning when a color has both absolutelightnessandbase. This is a valid configuration —baseis still used for minimum contrast calculation.
- #18
b4f799bThanks @tenphi! - Add lightLightness configuration option for controlling lightness bounds in light schemes.
- #16
f34cb5eThanks @tenphi! - Updated defaultdarkLightnessrange from[10, 90]to[15, 95]for improved dark mode color mapping.
- #14
6baff6bThanks @tenphi! - Fix relative lightness application: allow absolute lightness values when using base colors for contrast solving. Previously, colors with bothbaseand absolutelightnesswere incorrectly rejected during validation and topological sorting.
- #12
e869204Thanks @tenphi! - Fix shadow intensity normalization to properly scale alpha values across different background/foreground contrast pairs. Shadow alpha now correctly reaches alphaMax (default 1.0) at intensity=100 with maximum contrast.
-
#10
79253fcThanks @tenphi! - Add CSS custom property export method for themes and palettes -
#10
79253fcThanks @tenphi! - Add shadow color support and standalone shadow/format APIs- Shadow colors via
ShadowColorDef(type: 'shadow') with OKHSL-native algorithm usingtanhalpha curve glaze.shadow()standalone factory for one-off shadow computationglaze.format()to format anyResolvedColorVariantas CSSopacityfield onRegularColorDeffor fixed alpha on regular colorsalphafield onResolvedColorVariant(default 1)shadowTuningonGlazeConfigfor global shadow defaultsResolvedColor.modeis now optional (omitted for shadow colors)- Intensity is clamped to
[0, 100] - Validation: shadow bg/fg cannot reference other shadows; regular color base cannot reference a shadow
- Shadow colors via
-
#10
79253fcThanks @tenphi! - SwitchformatRgbandformatHslfrom comma syntax to modern CSS space syntaxrgb(R, G, B)→rgb(R G B)andhsl(H, S%, L%)→hsl(H S% L%).rgboutput now uses rounded integers instead of fractional values. This enables alpha support via the/ alphaseparator and aligns with modern CSS (supported since Chrome 65+, Firefox 52+, Safari 12.1+).Downstream code that parses Glaze's CSS output using comma-separated patterns must update to space-separated syntax.