@tenphi/glaze 0.0.0-snapshot.0fcd6cf → 0.0.0-snapshot.12fd135

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/dist/index.mjs CHANGED
@@ -95,6 +95,12 @@ const K1 = .206;
95
95
  const K2 = .03;
96
96
  const K3 = (1 + K1) / (1 + K2);
97
97
  const EPSILON = 1e-10;
98
+ /**
99
+ * OKLab chroma under which an sRGB input counts as gray. An exact sRGB gray
100
+ * leaves float residue in OKLab a / b (at most 3.7e-8, at white); the faintest
101
+ * deliberate tint, one channel 0.01 of 255 off gray, has 1.06e-5.
102
+ */
103
+ const GRAY_CHROMA = 1e-6;
98
104
  const constrainAngle = (angle) => (angle % 360 + 360) % 360;
99
105
  /**
100
106
  * OKHSL toe function: maps OKLab lightness L to perceptual lightness l.
@@ -488,13 +494,21 @@ const oklabToOkhsl = (lab, pastel = false) => {
488
494
  /**
489
495
  * Convert gamma-encoded sRGB (0–1 per channel) to OKHSL.
490
496
  * Returns [h, s, l] where h: 0–360, s: 0–1, l: 0–1.
497
+ *
498
+ * A gray reads with saturation 0 (its computed hue is kept). Its OKLab
499
+ * residue would otherwise come out as a saturation that grows toward white
500
+ * (`#fefefe` reads 1.6e-5, `rgb(254.999 254.999 254.999)` 1.6e-2), which a
501
+ * color carries into every scheme and an OKHSL mix reads as a hue.
491
502
  */
492
503
  function srgbToOkhsl(rgb, pastel = false) {
493
- return oklabToOkhsl(linearSrgbToOklab([
504
+ const oklab = linearSrgbToOklab([
494
505
  sRGBGammaToLinear(rgb[0]),
495
506
  sRGBGammaToLinear(rgb[1]),
496
507
  sRGBGammaToLinear(rgb[2])
497
- ]), pastel);
508
+ ]);
509
+ const okhsl = oklabToOkhsl(oklab, pastel);
510
+ if (Math.hypot(oklab[1], oklab[2]) < GRAY_CHROMA) okhsl[1] = 0;
511
+ return okhsl;
498
512
  }
499
513
  /**
500
514
  * Convert CSS HSL (sRGB-based) to gamma-encoded sRGB [r, g, b] in 0–1 range.
@@ -668,13 +682,26 @@ function formatOklch(h, s, l, pastel = false) {
668
682
  * Format linear sRGB channels as a CSS `color(srgb-linear R G B)` string with
669
683
  * 4 decimals. Unlike the other writers it takes linear light, not OKHSL, and
670
684
  * does not clamp: a channel may exceed 1, which is how an HDR glow value
671
- * (`glaze.formatHdr()`) goes past SDR white — `1` is SDR white, `2` one stop
672
- * above it. Opaque only; HDR values carry no alpha.
685
+ * written with `glaze.formatHdr(v, 'srgb-linear')` goes past SDR white — `1`
686
+ * is SDR white, `2` one stop above it (the default extended form is
687
+ * `formatSrgb`). Opaque only; HDR values carry no alpha.
673
688
  */
674
689
  function formatSrgbLinear(rgb) {
675
690
  return `color(srgb-linear ${fmt$1(rgb[0], 4)} ${fmt$1(rgb[1], 4)} ${fmt$1(rgb[2], 4)})`;
676
691
  }
677
692
  /**
693
+ * Format extended sRGB channels — gamma-encoded, with the transfer function
694
+ * continued past 1 (`sRGBLinearToGamma` does) — as a CSS `color(srgb R G B)`
695
+ * string with 4 decimals. Like `formatSrgbLinear` it takes the channels of
696
+ * its own space and does not clamp, so an HDR glow value
697
+ * (`glaze.formatHdr()`) can pass SDR white: `1` is SDR white, about `1.3533`
698
+ * one stop above it. CSS reads it as the same color as the matching
699
+ * `color(srgb-linear …)`. Opaque only; HDR values carry no alpha.
700
+ */
701
+ function formatSrgb(rgb) {
702
+ return `color(srgb ${fmt$1(rgb[0], 4)} ${fmt$1(rgb[1], 4)} ${fmt$1(rgb[2], 4)})`;
703
+ }
704
+ /**
678
705
  * Convert gamma-encoded sRGB channels (0–1) to a 6-digit lowercase hex
679
706
  * string (`#rrggbb`). Channels are clamped to [0,1] and rounded to 8-bit.
680
707
  * Alpha is not encoded here — DTCG carries it as a separate `alpha` field.
@@ -714,18 +741,26 @@ function clamp(v, min, max) {
714
741
  function lerp(a, b, f) {
715
742
  return a + (b - a) * f;
716
743
  }
744
+ /** Brackets `undefined` or a non-finite number while JSON writes it. */
745
+ const UNWRITABLE_MARK = "";
746
+ const UNWRITABLE_RE = /"\uE000([^"]*)\uE000"/g;
717
747
  /**
718
748
  * A rejected input as a validation error shows it. Numbers and `undefined`
719
749
  * via `String` (`JSON.stringify(NaN)` is "null"), a bigint with its `n`
720
750
  * (JSON can't write one), anything else via JSON so a string shows its quotes
721
- * and an array its shape — falling back to `String` for what JSON can't write
722
- * (a symbol, a function, a cycle).
751
+ * and an array its shape, with `undefined` or a non-finite number inside
752
+ * written as it prints (`[10,NaN]`, `{"wcag":undefined}`; JSON would write
753
+ * `null` or drop the key) — falling back to `String` for what JSON can't
754
+ * write (a symbol, a function, a cycle).
723
755
  */
724
756
  function showValue(value) {
725
757
  if (typeof value === "number" || value === void 0) return String(value);
726
758
  if (typeof value === "bigint") return `${value}n`;
727
759
  try {
728
- return JSON.stringify(value) ?? String(value);
760
+ const plain = JSON.stringify(value);
761
+ if (plain === void 0) return String(value);
762
+ if (plain.includes(UNWRITABLE_MARK)) return plain;
763
+ return JSON.stringify(value, (_key, v) => v === void 0 || typeof v === "number" && !Number.isFinite(v) ? `${UNWRITABLE_MARK}${v}${UNWRITABLE_MARK}` : v)?.replace(UNWRITABLE_RE, "$1") ?? String(value);
729
764
  } catch {
730
765
  return String(value);
731
766
  }
@@ -1176,8 +1211,8 @@ function schemeToneRange(isDark, mode, isHighContrast, config) {
1176
1211
  * surface at all gets none and keeps the SDR shortfall warning. Since the
1177
1212
  * value lands the whole step, only a full-reach glow meets SDR at the boundary
1178
1213
  * (`exposure` → 0); one with `reach` below 1 jumps from its pigment to the
1179
- * exact step. `hdrToLinearSrgb` turns a resolved HDR value back into linear
1180
- * sRGB for the writers.
1214
+ * exact step. `hdrToLinearSrgb` / `hdrToSrgb` turn a resolved HDR value back
1215
+ * into linear or extended sRGB channels for the writers (both public).
1181
1216
  */
1182
1217
  /** Most stops of HDR headroom a glow may use; larger values clamp to it. */
1183
1218
  const MAX_HEADROOM = 2;
@@ -1207,11 +1242,13 @@ const DEFAULT_LIGHTING_TUNING = {
1207
1242
  * warned about one above the maximum.
1208
1243
  */
1209
1244
  function resolveLightingTuning(perColor, globalTuning) {
1245
+ const field = (key) => perColor?.[key] ?? globalTuning?.[key] ?? DEFAULT_LIGHTING_TUNING[key];
1210
1246
  const headroom = perColor?.headroom ?? globalTuning?.headroom ?? 0;
1211
1247
  return {
1212
- ...DEFAULT_LIGHTING_TUNING,
1213
- ...globalTuning,
1214
- ...perColor,
1248
+ objectInfluence: field("objectInfluence"),
1249
+ tint: field("tint"),
1250
+ reach: field("reach"),
1251
+ hueBleed: field("hueBleed"),
1215
1252
  headroom: clamp(headroom, 0, MAX_HEADROOM)
1216
1253
  };
1217
1254
  }
@@ -1221,11 +1258,18 @@ function circularLerp(a, b, t) {
1221
1258
  else if (diff < -180) diff += 360;
1222
1259
  return ((a + diff * t) % 360 + 360) % 360;
1223
1260
  }
1224
- const SAT_EPSILON = 1e-6;
1261
+ /**
1262
+ * Saturation at or below which a color has no hue of its own. A gray given as
1263
+ * a value parses with saturation 0; a near-gray under this has a hue too
1264
+ * faint to give a tint.
1265
+ */
1266
+ const HUE_EPSILON = 1e-4;
1225
1267
  const BISECT_ITERATIONS = 40;
1226
1268
  const GOLDEN = (Math.sqrt(5) - 1) / 2;
1227
1269
  /** Float noise a target tone may sit above 100 without counting as HDR. */
1228
1270
  const HDR_TRIGGER_EPS = 1e-9;
1271
+ /** Float noise a pigment may move the surface by and still count as not. */
1272
+ const NO_LIFT_EPS = 1e-9;
1229
1273
  /** Float noise a surface tone may sit below 100 and still count as white. */
1230
1274
  const WHITE_SURFACE_EPS = 1e-6;
1231
1275
  /**
@@ -1243,16 +1287,30 @@ function objectFactor(kind, bgTone, fgTone, objectInfluence) {
1243
1287
  * The pigment: pure black (shadow) / pure white (glow) by default. `reach`
1244
1288
  * below 1 parks it part of the way from the surface's tone to that extreme so
1245
1289
  * a `tint` — saturation in the surface's hue, optionally pulled toward the
1246
- * object's hue by `hueBleed` — can show.
1290
+ * object's hue by `hueBleed` — can show. A neutral surface has no hue to give:
1291
+ * the tint takes the object's hue when `hueBleed` is above 0, and the pigment
1292
+ * stays neutral otherwise.
1247
1293
  */
1248
1294
  function pigmentFor(kind, bg, fg, bgTone, tuning) {
1249
1295
  const reach = clamp(tuning.reach, 0, 1);
1250
1296
  const tone = kind === "shadow" ? bgTone * (1 - reach) : bgTone + (100 - bgTone) * reach;
1251
1297
  const bleed = clamp(tuning.hueBleed, 0, 1);
1298
+ const l = clamp(fromTone(tone), 0, 1);
1299
+ const tint = clamp(tuning.tint, 0, 1);
1300
+ const objectHue = fg !== void 0 && fg.s > HUE_EPSILON && bleed > 0 ? fg.h : void 0;
1301
+ if (bg.s <= HUE_EPSILON) return objectHue === void 0 ? {
1302
+ h: bg.h,
1303
+ s: 0,
1304
+ l
1305
+ } : {
1306
+ h: objectHue,
1307
+ s: tint,
1308
+ l
1309
+ };
1252
1310
  return {
1253
- h: fg !== void 0 && fg.s > SAT_EPSILON && bleed > 0 ? circularLerp(bg.h, fg.h, bleed) : bg.h,
1254
- s: clamp(tuning.tint, 0, 1),
1255
- l: clamp(fromTone(tone), 0, 1)
1311
+ h: objectHue === void 0 ? bg.h : circularLerp(bg.h, objectHue, bleed),
1312
+ s: tint,
1313
+ l
1256
1314
  };
1257
1315
  }
1258
1316
  /**
@@ -1309,7 +1367,7 @@ function solveOpacity(kind, gain, step) {
1309
1367
  if (gain(1) < step) {
1310
1368
  hi = kind === "shadow" ? strongestShadowAlpha(gain) : 1;
1311
1369
  const reached = gain(hi);
1312
- if (reached < step) return reached > 0 ? {
1370
+ if (reached < step) return reached > NO_LIFT_EPS ? {
1313
1371
  alpha: hi,
1314
1372
  reached
1315
1373
  } : {
@@ -1420,6 +1478,21 @@ function hdrToLinearSrgb(hdr) {
1420
1478
  ];
1421
1479
  }
1422
1480
  /**
1481
+ * Extended sRGB of a resolved HDR value: `hdrToLinearSrgb` through the sRGB
1482
+ * transfer function, continued past 1 the way CSS and the platform
1483
+ * extended-sRGB spaces continue it, unclamped. These are the channels of
1484
+ * `color(srgb R G B)` (`glaze.formatHdr()`), and what a WebGPU `'srgb'`
1485
+ * canvas, `CGColorSpace.extendedSRGB`, and Android's `EXTENDED_SRGB` take.
1486
+ */
1487
+ function hdrToSrgb(hdr) {
1488
+ const [r, g, b] = hdrToLinearSrgb(hdr);
1489
+ return [
1490
+ sRGBLinearToGamma(r),
1491
+ sRGBLinearToGamma(g),
1492
+ sRGBLinearToGamma(b)
1493
+ ];
1494
+ }
1495
+ /**
1423
1496
  * Opacity of a shadow pigment's darkest composite. Full opacity for pure
1424
1497
  * black; a tinted pigment whose channels pull in different directions can
1425
1498
  * bottom out earlier, so a golden-section search over the unimodal gain finds
@@ -1519,6 +1592,18 @@ const WCAG_HC_PROMOTION = {
1519
1592
  AA: "AAA",
1520
1593
  "AA-large": "AAA-large"
1521
1594
  };
1595
+ /** An `{ apca }` spec: its `apca` is set (a key holding `undefined` is not). */
1596
+ function isApcaSpec(spec) {
1597
+ return spec.apca !== void 0;
1598
+ }
1599
+ /** Whether `value` names a WCAG preset (internal; see validation.ts). */
1600
+ function isContrastPreset(value) {
1601
+ return typeof value === "string" && Object.hasOwn(CONTRAST_PRESETS, value);
1602
+ }
1603
+ /** Whether `value` names an APCA preset (internal; see validation.ts). */
1604
+ function isApcaPreset(value) {
1605
+ return typeof value === "string" && Object.hasOwn(APCA_PRESETS, value);
1606
+ }
1522
1607
  function resolveMinContrast(value) {
1523
1608
  if (typeof value === "number") return Math.max(1, value);
1524
1609
  return CONTRAST_PRESETS[value];
@@ -1544,7 +1629,7 @@ function pickPair(p, isHighContrast) {
1544
1629
  * presets are `'wcag'`; only an `{ apca }` object is `'apca'`.
1545
1630
  */
1546
1631
  function contrastMetricOf(spec) {
1547
- return typeof spec === "object" && "apca" in spec ? "apca" : "wcag";
1632
+ return typeof spec === "object" && isApcaSpec(spec) ? "apca" : "wcag";
1548
1633
  }
1549
1634
  /**
1550
1635
  * Resolve a `ContrastSpec` (already selected from any outer HC pair) for a
@@ -1567,12 +1652,12 @@ function resolveContrastForMode(spec, isHighContrast, polarity, outerExplicitHC)
1567
1652
  metric: "wcag",
1568
1653
  target: resolveWcagTarget(spec, isHighContrast, !!outerExplicitHC)
1569
1654
  };
1570
- if ("apca" in spec) {
1655
+ if (isApcaSpec(spec)) {
1571
1656
  const baseTarget = resolveApcaTarget(pickPair(spec.apca, isHighContrast));
1572
1657
  const innerExplicitHC = Array.isArray(spec.apca);
1573
1658
  return {
1574
1659
  metric: "apca",
1575
- target: isHighContrast && !outerExplicitHC && !innerExplicitHC ? Math.min(baseTarget + APCA_HC_ENHANCEMENT, APCA_MAX_LC) : baseTarget,
1660
+ target: isHighContrast && !outerExplicitHC && !innerExplicitHC ? Math.max(baseTarget, Math.min(baseTarget + APCA_HC_ENHANCEMENT, APCA_MAX_LC)) : baseTarget,
1576
1661
  polarity: polarity ?? "fg"
1577
1662
  };
1578
1663
  }
@@ -1600,6 +1685,7 @@ function resolveContrastForMode(spec, isHighContrast, polarity, outerExplicitHC)
1600
1685
  * for direct callers.
1601
1686
  */
1602
1687
  function resolveContrastForLevel(spec, level, polarity) {
1688
+ if (!Number.isFinite(level)) throw new Error(`glaze: resolveContrastForLevel needs a finite level (got ${level}).`);
1603
1689
  const outerExplicitHC = Array.isArray(spec);
1604
1690
  const f = levelFraction(level);
1605
1691
  const normal = resolveContrastForMode(pairNormal(spec), false, polarity, outerExplicitHC);
@@ -1945,10 +2031,11 @@ function dedupe(key) {
1945
2031
  }
1946
2032
  /**
1947
2033
  * Whether `actual` is short of the floor by more than the slack. The solver
1948
- * aims slightly past the target, so a smaller shortfall is rounding noise.
2034
+ * aims slightly past the target, so a smaller shortfall is rounding noise. A
2035
+ * non-finite target or measurement counts as a miss, so it is reported.
1949
2036
  */
1950
2037
  function missesFloor(contrast, actual) {
1951
- return actual < (contrast.metric === "apca" ? contrast.target - CONTRAST_WARN_SLACK_APCA : contrast.target * CONTRAST_WARN_SLACK_WCAG);
2038
+ return !(actual >= (contrast.metric === "apca" ? contrast.target - CONTRAST_WARN_SLACK_APCA : contrast.target * CONTRAST_WARN_SLACK_WCAG));
1952
2039
  }
1953
2040
  function contrastKey(kind, name, scheme, contrast, actual) {
1954
2041
  return `${kind}|${name}|${scheme}|${contrast.metric}|${contrast.target.toFixed(2)}|${actual.toFixed(2)}`;
@@ -1964,13 +2051,16 @@ function warnContrastUnmet(name, isDark, isHighContrast, contrast, actual) {
1964
2051
  * Warn when a mix's ratio / opacity search could not reach its contrast floor
1965
2052
  * against the mix's `base`. An unmet mix means even the pure target falls
1966
2053
  * short, so the fix is the target color or the floor, not a tone window.
1967
- * `actual` is the contrast of the value the mix settled at.
2054
+ * `actual` is the contrast of the value the mix settled at. `pastelCapped`
2055
+ * says the pastel cap is why: the mix's pastel gamut paints the target with
2056
+ * less saturation, and the target painted in full would meet the floor, so
2057
+ * the hint offers `pastel: false`.
1968
2058
  */
1969
- function warnMixContrastUnmet(name, isDark, isHighContrast, contrast, actual) {
2059
+ function warnMixContrastUnmet(name, isDark, isHighContrast, contrast, actual, pastelCapped = false) {
1970
2060
  if (!missesFloor(contrast, actual)) return;
1971
2061
  const scheme = schemeLabel(isDark, isHighContrast);
1972
2062
  if (dedupe(contrastKey("unmet-mix", name, scheme, contrast, actual))) return;
1973
- console.warn(`glaze: mix "${name}" cannot meet ${metricLabel(contrast)} against its base in ${scheme} scheme (got ${actual.toFixed(2)}). Even the pure target falls short; try lowering the contrast target or mixing toward a color further from the base.`);
2063
+ console.warn(`glaze: mix "${name}" cannot meet ${metricLabel(contrast)} against its base in ${scheme} scheme (got ${actual.toFixed(2)}). ` + (pastelCapped ? "Even the pure target falls short once the mix's pastel gamut caps its saturation; try `pastel: false` on the mix, lowering the contrast target, or mixing toward a color further from the base." : "Even the pure target falls short; try lowering the contrast target or mixing toward a color further from the base."));
1974
2064
  }
1975
2065
  /**
1976
2066
  * Verification (the drift check in docs/okhst.mdx): a chromatic swatch
@@ -2124,6 +2214,13 @@ function defaultConfig() {
2124
2214
  };
2125
2215
  }
2126
2216
  /**
2217
+ * `value`, or `fallback` when it is `undefined`. Only `undefined` means
2218
+ * "keep": a `null` is passed on so the field's validator rejects it.
2219
+ */
2220
+ function keepIfUndefined(value, fallback) {
2221
+ return value !== void 0 ? value : fallback;
2222
+ }
2223
+ /**
2127
2224
  * Validate an authored `contrastLevel`. Numbers clamp to 0–100 so a slider
2128
2225
  * can't produce an out-of-range level; a non-finite number throws rather than
2129
2226
  * silently reading as `'auto'`, which would turn a `NaN` slider value into an
@@ -2181,15 +2278,26 @@ function normalizeHeadroom(value, where, deferred) {
2181
2278
  return value;
2182
2279
  }
2183
2280
  /**
2184
- * Validate and copy a lighting tuning: a detached copy (or `undefined`) with a
2185
- * glow's `headroom` checked and clamped by `normalizeHeadroom`. A shadow has no
2186
- * headroom — it darkens toward black, and nothing goes below black — so a
2187
- * `headroom` on shadow tuning throws. Only `undefined` counts as unset.
2188
- * `deferred` queues the clamp warning (see `normalizeHeadroom`).
2281
+ * Validate and copy a lighting tuning: a detached copy (or `undefined`) with
2282
+ * `objectInfluence` / `tint` / `reach` / `hueBleed` checked to be finite
2283
+ * numbers (the 0–1 fields clamp at use) and a glow's `headroom` checked and
2284
+ * clamped by `normalizeHeadroom`. A shadow has no headroom — it darkens toward
2285
+ * black, and nothing goes below black — so a `headroom` on shadow tuning
2286
+ * throws. Only `undefined` counts as unset. `deferred` queues the clamp
2287
+ * warning (see `normalizeHeadroom`).
2189
2288
  */
2190
2289
  function normalizeLightingTuning(kind, tuning, where, deferred) {
2191
2290
  if (tuning === void 0) return void 0;
2192
2291
  const out = { ...tuning };
2292
+ for (const field of [
2293
+ "objectInfluence",
2294
+ "tint",
2295
+ "reach",
2296
+ "hueBleed"
2297
+ ]) {
2298
+ const value = out[field];
2299
+ if (value !== void 0 && (typeof value !== "number" || !Number.isFinite(value))) throw new Error(`glaze: ${where}.${field} must be a finite number (got ${showValue(value)}).`);
2300
+ }
2193
2301
  const headroom = tuning?.headroom;
2194
2302
  if (headroom === void 0) return out;
2195
2303
  if (kind === "shadow") throw new Error(`glaze: ${where}.headroom is not supported — a shadow darkens toward black, and nothing goes below black. Headroom is for glows (glowTuning or a glow's tuning).`);
@@ -2272,7 +2380,8 @@ const OVERRIDE_FIELD_COPY = {
2272
2380
  * through unchanged.
2273
2381
  */
2274
2382
  function copyConfigOverride(override) {
2275
- if (typeof override !== "object" || override === null) return override;
2383
+ if (override === null) return void 0;
2384
+ if (typeof override !== "object") return override;
2276
2385
  const source = override;
2277
2386
  const out = {};
2278
2387
  for (const [key, copy] of Object.entries(OVERRIDE_FIELD_COPY)) if (key in source) out[key] = copy(source[key]);
@@ -2315,7 +2424,7 @@ function configure(config) {
2315
2424
  const next = {
2316
2425
  lightTone: normalizeToneWindowInput(config.lightTone !== void 0 ? config.lightTone : globalConfig.lightTone, "lightTone"),
2317
2426
  darkTone: normalizeToneWindowInput(config.darkTone !== void 0 ? config.darkTone : globalConfig.darkTone, "darkTone"),
2318
- darkDesaturation: normalizeDarkDesaturation(config.darkDesaturation ?? globalConfig.darkDesaturation),
2427
+ darkDesaturation: normalizeDarkDesaturation(keepIfUndefined(config.darkDesaturation, globalConfig.darkDesaturation)),
2319
2428
  states: {
2320
2429
  dark: config.states?.dark ?? globalConfig.states.dark,
2321
2430
  highContrast: config.states?.highContrast ?? globalConfig.states.highContrast,
@@ -2331,7 +2440,7 @@ function configure(config) {
2331
2440
  autoFlip: config.autoFlip ?? globalConfig.autoFlip,
2332
2441
  pastel: false,
2333
2442
  inferRole: config.inferRole ?? globalConfig.inferRole,
2334
- contrastLevel: normalizeContrastLevel(config.contrastLevel ?? globalConfig.contrastLevel)
2443
+ contrastLevel: normalizeContrastLevel(keepIfUndefined(config.contrastLevel, globalConfig.contrastLevel))
2335
2444
  };
2336
2445
  flushWarnings(deferred);
2337
2446
  configVersion++;
@@ -2361,7 +2470,7 @@ function mergeConfig(base, override = {}) {
2361
2470
  const merged = {
2362
2471
  lightTone: normalizeToneWindowInput(override.lightTone !== void 0 ? override.lightTone : base.lightTone, "lightTone"),
2363
2472
  darkTone: normalizeToneWindowInput(override.darkTone !== void 0 ? override.darkTone : base.darkTone, "darkTone"),
2364
- darkDesaturation: normalizeDarkDesaturation(override.darkDesaturation ?? base.darkDesaturation),
2473
+ darkDesaturation: normalizeDarkDesaturation(keepIfUndefined(override.darkDesaturation, base.darkDesaturation)),
2365
2474
  states: { ...base.states },
2366
2475
  modes: { ...base.modes },
2367
2476
  shadowTuning: normalizeLightingTuning("shadow", override.shadowTuning ?? base.shadowTuning, "shadowTuning"),
@@ -2369,7 +2478,7 @@ function mergeConfig(base, override = {}) {
2369
2478
  autoFlip: override.autoFlip ?? base.autoFlip,
2370
2479
  pastel: override.pastel ?? false,
2371
2480
  inferRole: override.inferRole ?? base.inferRole,
2372
- contrastLevel: normalizeContrastLevel(override.contrastLevel ?? base.contrastLevel)
2481
+ contrastLevel: normalizeContrastLevel(keepIfUndefined(override.contrastLevel, base.contrastLevel))
2373
2482
  };
2374
2483
  flushWarnings(deferred);
2375
2484
  return merged;
@@ -2436,7 +2545,7 @@ function assertExportVersion(data, factory) {
2436
2545
  if (data.version > GLAZE_EXPORT_VERSION) throw new Error(`${factory}: unsupported export version ${data.version} (this library supports version ${GLAZE_EXPORT_VERSION}). Upgrade @tenphi/glaze to load this snapshot.`);
2437
2546
  }
2438
2547
  /**
2439
- * Reject a version-1 (Glaze 2) theme snapshot that carries shadow colors or a
2548
+ * Reject a version-1 (Glaze 1.1–2) theme snapshot that carries shadow colors or a
2440
2549
  * `shadowTuning`. Glaze 3 changed what shadow `intensity`, `fg` and tuning
2441
2550
  * mean, so restoring one would silently re-render every shadow differently.
2442
2551
  * Version-1 snapshots without shadows load as before. `fallbackVersion` is the
@@ -2449,7 +2558,7 @@ function assertNoLegacyShadows(data, factory, fallbackVersion) {
2449
2558
  const hasTuning = isPlainObject(data.config) && data.config.shadowTuning !== void 0;
2450
2559
  if (shadows.length === 0 && !hasTuning) return;
2451
2560
  const what = shadows.length > 0 ? `shadow colors (${shadows.map((n) => JSON.stringify(n)).join(", ")})` : "a \"shadowTuning\" config";
2452
- throw new Error(`${factory}: this snapshot is export version 1 (Glaze 2) and contains ${what}. Glaze 3 changed what shadow "intensity", "fg" and tuning mean, so it would restore with different shadows. Re-author them as described in docs/migration.md ("3.0 — shadows and glows") and export again.`);
2561
+ throw new Error(`${factory}: this snapshot is export version 1 (Glaze 1.1–2) and contains ${what}. Glaze 3 changed what shadow "intensity", "fg" and tuning mean, so it would restore with different shadows. Re-author them as described in docs/migration.md ("3.0 — shadows and glows") and export again.`);
2453
2562
  }
2454
2563
  /**
2455
2564
  * When `kind` is present, it must match the factory. Missing `kind` is
@@ -2550,7 +2659,8 @@ function themeHuePlan(name, def, variant, ctx, scheme) {
2550
2659
  const regDef = def;
2551
2660
  const baseHueVar = `var(--${ctx.baseName}-hue)`;
2552
2661
  const prop = cssProp(ctx.prefix, name, "-hue");
2553
- if ((regDef.mode ?? "auto") === "static" && schemeSeedHue(ctx, "dark") !== ctx.seedHue) return {
2662
+ const isStatic = (regDef.mode ?? "auto") === "static";
2663
+ if (isStatic && schemeSeedHue(ctx, "dark") !== ctx.seedHue) return {
2554
2664
  hueVar: `var(${prop})`,
2555
2665
  inline: false,
2556
2666
  declarations: [{
@@ -2558,12 +2668,13 @@ function themeHuePlan(name, def, variant, ctx, scheme) {
2558
2668
  value: hueValue(variant.h)
2559
2669
  }]
2560
2670
  };
2561
- if (regDef.hue === void 0 && regDef.darkHue === void 0 && regDef.from === void 0) return {
2671
+ const darkHue = isStatic ? void 0 : regDef.darkHue;
2672
+ if (regDef.hue === void 0 && darkHue === void 0 && regDef.from === void 0) return {
2562
2673
  hueVar: baseHueVar,
2563
2674
  inline: false,
2564
2675
  declarations: []
2565
2676
  };
2566
- const authored = scheme === "dark" ? regDef.darkHue ?? regDef.hue : regDef.hue;
2677
+ const authored = scheme === "dark" ? darkHue ?? regDef.hue : regDef.hue;
2567
2678
  if (authored === void 0) {
2568
2679
  if (regDef.from !== void 0) return {
2569
2680
  hueVar: `var(${prop})`,
@@ -2783,23 +2894,49 @@ function claimValueProps(resolved, prefix, suffix, owners, labels) {
2783
2894
  * Matches the color functions Glaze itself emits (`rgb()`, `hsl()`, `okhsl()`,
2784
2895
  * `okhst()`, `oklch()`) plus their legacy alpha aliases (`rgba()`, `hsla()`).
2785
2896
  *
2786
- * Only numeric components are supported; named colors (`red`) and
2787
- * relative-color syntax (`from <color> ...`) are out of scope. Hue is read with
2788
- * `parseFloat`, so a bare number or `deg` is degrees and any other angle unit
2789
- * (`turn`, `rad`, `grad`) is misread as degrees.
2897
+ * Only numeric components are supported, plus the CSS keyword `none` (a missing
2898
+ * component, read as 0 as CSS renders it); named colors (`red`) and
2899
+ * relative-color syntax (`from <color> ...`) are out of scope. A hue takes a
2900
+ * bare number (degrees) or `deg` / `turn` / `rad` / `grad`.
2790
2901
  */
2791
2902
  const COLOR_FN_RE = /^(rgba?|hsla?|okhsl|okhst|oklch)\(\s*([^)]*)\s*\)$/i;
2792
- function parseNumberOrPercent(raw, percentScale) {
2793
- if (raw.endsWith("%")) return parseFloat(raw) / 100 * percentScale;
2794
- return parseFloat(raw);
2903
+ /** One numeric component: a CSS number with an optional unit. */
2904
+ const COMPONENT_RE = /^([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)([a-z%]*)$/i;
2905
+ /** Degrees per unit of each CSS angle unit. */
2906
+ const ANGLE_UNITS = {
2907
+ "": 1,
2908
+ deg: 1,
2909
+ turn: 360,
2910
+ rad: 180 / Math.PI,
2911
+ grad: .9
2912
+ };
2913
+ function readComponent(raw, input, units) {
2914
+ if (raw.toLowerCase() === "none") return 0;
2915
+ const m = COMPONENT_RE.exec(raw);
2916
+ const scale = m ? units[m[2].toLowerCase()] : void 0;
2917
+ const value = m ? parseFloat(m[1]) * (scale ?? NaN) : NaN;
2918
+ if (!Number.isFinite(value)) throw new Error(`glaze: invalid component "${raw}" in "${input}".`);
2919
+ return value;
2920
+ }
2921
+ /** A number, or a percentage of `percentScale`. */
2922
+ function parseNumberOrPercent(raw, percentScale, input) {
2923
+ return readComponent(raw, input, {
2924
+ "": 1,
2925
+ "%": percentScale / 100
2926
+ });
2927
+ }
2928
+ /** A hue in degrees. */
2929
+ function parseHue(raw, input) {
2930
+ return readComponent(raw, input, ANGLE_UNITS);
2795
2931
  }
2932
+ const clamp01 = (value) => Math.min(1, Math.max(0, value));
2796
2933
  /**
2797
2934
  * Split the body of a CSS color function into its components and detect
2798
2935
  * whether an alpha channel was present.
2799
2936
  *
2800
2937
  * Handles both modern slash syntax (`R G B / A` or `R, G, B / A`) and
2801
2938
  * legacy comma syntax (`R, G, B, A`). The alpha value itself is discarded
2802
- * by the caller — standalone Glaze colors have no opacity field.
2939
+ * by the caller, with a warning: translucency is a color's `opacity` field.
2803
2940
  */
2804
2941
  function splitColorBody(body) {
2805
2942
  const slashIdx = body.indexOf("/");
@@ -2820,8 +2957,13 @@ function splitColorBody(body) {
2820
2957
  hadAlpha: false
2821
2958
  };
2822
2959
  }
2960
+ /** Inputs already warned about, so a re-parsed `from` warns once. */
2961
+ const warnedAlpha = /* @__PURE__ */ new Set();
2823
2962
  function warnDroppedAlpha(input) {
2824
- console.warn(`glaze: alpha component dropped from "${input}" (standalone color has no opacity field).`);
2963
+ if (warnedAlpha.has(input)) return;
2964
+ if (warnedAlpha.size >= 256) warnedAlpha.clear();
2965
+ warnedAlpha.add(input);
2966
+ console.warn(`glaze: alpha component dropped from "${input}"; use the color's \`opacity\` (or a transparent mix) for translucency.`);
2825
2967
  }
2826
2968
  function parseColorString(input) {
2827
2969
  if (input.startsWith("#")) {
@@ -2841,13 +2983,14 @@ function parseColorString(input) {
2841
2983
  const { components, hadAlpha } = splitColorBody(m[2].trim());
2842
2984
  if (hadAlpha) warnDroppedAlpha(input);
2843
2985
  if (components.length !== 3) throw new Error(`glaze: expected 3 components in "${input}".`);
2986
+ const [c0, c1, c2] = components;
2844
2987
  switch (fn) {
2845
2988
  case "rgb":
2846
2989
  case "rgba": {
2847
2990
  const [h, s, l] = srgbToOkhsl([
2848
- parseNumberOrPercent(components[0], 255) / 255,
2849
- parseNumberOrPercent(components[1], 255) / 255,
2850
- parseNumberOrPercent(components[2], 255) / 255
2991
+ clamp01(parseNumberOrPercent(c0, 255, input) / 255),
2992
+ clamp01(parseNumberOrPercent(c1, 255, input) / 255),
2993
+ clamp01(parseNumberOrPercent(c2, 255, input) / 255)
2851
2994
  ]);
2852
2995
  return {
2853
2996
  h,
@@ -2857,7 +3000,7 @@ function parseColorString(input) {
2857
3000
  }
2858
3001
  case "hsl":
2859
3002
  case "hsla": {
2860
- const [oh, os, ol] = srgbToOkhsl(hslToSrgb(parseFloat(components[0]), parseNumberOrPercent(components[1], 1), parseNumberOrPercent(components[2], 1)));
3003
+ const [oh, os, ol] = srgbToOkhsl(hslToSrgb(parseHue(c0, input), clamp01(parseNumberOrPercent(c1, 1, input)), clamp01(parseNumberOrPercent(c2, 1, input))));
2861
3004
  return {
2862
3005
  h: oh,
2863
3006
  s: os,
@@ -2865,19 +3008,19 @@ function parseColorString(input) {
2865
3008
  };
2866
3009
  }
2867
3010
  case "okhsl": return {
2868
- h: parseFloat(components[0]),
2869
- s: parseNumberOrPercent(components[1], 1),
2870
- l: parseNumberOrPercent(components[2], 1)
3011
+ h: parseHue(c0, input),
3012
+ s: clamp01(parseNumberOrPercent(c1, 1, input)),
3013
+ l: clamp01(parseNumberOrPercent(c2, 1, input))
2871
3014
  };
2872
3015
  case "okhst": return okhstToOkhsl({
2873
- h: parseFloat(components[0]),
2874
- s: parseNumberOrPercent(components[1], 1),
2875
- t: parseNumberOrPercent(components[2], 1)
3016
+ h: parseHue(c0, input),
3017
+ s: clamp01(parseNumberOrPercent(c1, 1, input)),
3018
+ t: clamp01(parseNumberOrPercent(c2, 1, input))
2876
3019
  });
2877
3020
  case "oklch": {
2878
- const L = parseNumberOrPercent(components[0], 1);
2879
- const C = parseNumberOrPercent(components[1], .4);
2880
- const hRad = parseFloat(components[2]) * Math.PI / 180;
3021
+ const L = clamp01(parseNumberOrPercent(c0, 1, input));
3022
+ const C = Math.max(0, parseNumberOrPercent(c1, .4, input));
3023
+ const hRad = parseHue(c2, input) * Math.PI / 180;
2881
3024
  const [h, s, l] = oklabToOkhsl([
2882
3025
  L,
2883
3026
  C * Math.cos(hRad),
@@ -2899,8 +3042,16 @@ function parseColorString(input) {
2899
3042
  function validateOkhslColor(value) {
2900
3043
  const { h, s, l } = value;
2901
3044
  if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(l)) throw new Error("glaze.color: OkhslColor h/s/l must be finite numbers.");
3045
+ assertNonNegative("OkhslColor", {
3046
+ s,
3047
+ l
3048
+ });
2902
3049
  if (s > 1.5 || l > 1.5) throw new Error("glaze.color: OkhslColor s/l must be in 0–1 range. Did you mean the structured form { hue, saturation, tone } (which uses 0–100)?");
2903
3050
  }
3051
+ /** Reject a negative 0–1 component, naming it: no color has one. */
3052
+ function assertNonNegative(kind, fields) {
3053
+ for (const [key, n] of Object.entries(fields)) if (n < 0) throw new Error(`glaze.color: ${kind} ${key} must be 0 or more (got ${n}).`);
3054
+ }
2904
3055
  /** Validate a user-supplied `{ r, g, b }` object in 0–255. */
2905
3056
  function validateRgbColor(value) {
2906
3057
  for (const key of [
@@ -2916,6 +3067,10 @@ function validateRgbColor(value) {
2916
3067
  function validateOklchColor(value) {
2917
3068
  const { l, c, h } = value;
2918
3069
  if (!Number.isFinite(l) || !Number.isFinite(c) || !Number.isFinite(h)) throw new Error("glaze.color: OklchColor l/c/h must be finite numbers.");
3070
+ assertNonNegative("OklchColor", {
3071
+ l,
3072
+ c
3073
+ });
2919
3074
  if (l > 1.5 || c > 1.5) throw new Error("glaze.color: OklchColor l/c must be in 0–1 range (matching oklch() strings).");
2920
3075
  }
2921
3076
  function oklchComponentsToOkhsl(l, c, hDeg) {
@@ -2931,6 +3086,36 @@ function oklchComponentsToOkhsl(l, c, hDeg) {
2931
3086
  l: outL
2932
3087
  };
2933
3088
  }
3089
+ /**
3090
+ * A detached copy of a color value. A value object is read by property access
3091
+ * once its shape is known, so a getter, an inherited field or a proxy copies
3092
+ * like a plain field (a spread would drop the first two). Strings, and shapes
3093
+ * the parser rejects anyway, pass through.
3094
+ */
3095
+ function copyColorValue(value) {
3096
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return value;
3097
+ const v = value;
3098
+ if (isRgbColorObject(value)) return {
3099
+ r: v.r,
3100
+ g: v.g,
3101
+ b: v.b
3102
+ };
3103
+ if (isOklchColorObject(value)) return {
3104
+ l: v.l,
3105
+ c: v.c,
3106
+ h: v.h
3107
+ };
3108
+ if (isOkhstColorObject(value)) return {
3109
+ h: v.h,
3110
+ s: v.s,
3111
+ t: v.t
3112
+ };
3113
+ return {
3114
+ h: v.h,
3115
+ s: v.s,
3116
+ l: v.l
3117
+ };
3118
+ }
2934
3119
  function isRgbColorObject(value) {
2935
3120
  return "r" in value && "g" in value && "b" in value;
2936
3121
  }
@@ -2944,12 +3129,18 @@ function isOkhstColorObject(value) {
2944
3129
  function validateOkhstColor(value) {
2945
3130
  const { h, s, t } = value;
2946
3131
  if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(t)) throw new Error("glaze.color: OkhstColor h/s/t must be finite numbers.");
3132
+ assertNonNegative("OkhstColor", {
3133
+ s,
3134
+ t
3135
+ });
2947
3136
  if (s > 1.5 || t > 1.5) throw new Error("glaze.color: OkhstColor s/t must be in 0–1 range. Did you mean the structured form { hue, saturation, tone } (which uses 0–100)?");
2948
3137
  }
2949
3138
  /**
2950
3139
  * Extract an OKHSL color from any `GlazeColorValue` form. Also used by
2951
3140
  * `glaze.shadow()` / `glaze.glow()` so all their inputs (hex, color
2952
- * functions, OKHSL, literal objects) go through one parser.
3141
+ * functions, OKHSL, literal objects) go through one parser. A value object's
3142
+ * 0–1 field that passed its check above 1 (up to 1.5) clamps to 1, as the
3143
+ * same value in a color function does.
2953
3144
  */
2954
3145
  function extractOkhslFromValue(value) {
2955
3146
  if (typeof value === "string") return parseColorString(value);
@@ -2969,14 +3160,22 @@ function extractOkhslFromValue(value) {
2969
3160
  }
2970
3161
  if (isOklchColorObject(value)) {
2971
3162
  validateOklchColor(value);
2972
- return oklchComponentsToOkhsl(value.l, value.c, value.h);
3163
+ return oklchComponentsToOkhsl(clamp01(value.l), value.c, value.h);
2973
3164
  }
2974
3165
  if (isOkhstColorObject(value)) {
2975
3166
  validateOkhstColor(value);
2976
- return okhstToOkhsl(value);
3167
+ return okhstToOkhsl({
3168
+ h: value.h,
3169
+ s: clamp01(value.s),
3170
+ t: clamp01(value.t)
3171
+ });
2977
3172
  }
2978
3173
  validateOkhslColor(value);
2979
- return value;
3174
+ return {
3175
+ h: value.h,
3176
+ s: clamp01(value.s),
3177
+ l: clamp01(value.l)
3178
+ };
2980
3179
  }
2981
3180
 
2982
3181
  //#endregion
@@ -3140,7 +3339,10 @@ function oppositeRole(role) {
3140
3339
  *
3141
3340
  * `validateColorDefs` rejects bad references (missing / shadow- or
3142
3341
  * glow-referencing / base/contrast/tone mismatches — a relative tone in either
3143
- * entry of an HC pair needs a base) and bad per-color lighting tuning (a
3342
+ * entry of an HC pair needs a base), a malformed `contrast` spec (a misspelled
3343
+ * preset, a non-finite number, a bad shape), a numeric field the resolver can't
3344
+ * place (non-finite, a malformed `'+N'`, an opacity outside 0–1), and bad
3345
+ * per-color lighting tuning (a
3144
3346
  * `headroom` on a shadow; a glow `headroom` that is not a finite number ≥ 0,
3145
3347
  * with one above the maximum warned about — once every def has passed — and
3146
3348
  * clamped at resolve), and detects cycles before the resolver runs. `topoSort` orders defs so each color is
@@ -3166,6 +3368,56 @@ function hasRelativeTone(tone) {
3166
3368
  if (tone === void 0) return false;
3167
3369
  return !isAbsoluteTone(pairNormal(tone)) || !isAbsoluteTone(pairHC(tone));
3168
3370
  }
3371
+ const isWcagValue = (value) => typeof value === "number" && Number.isFinite(value) || isContrastPreset(value);
3372
+ const isApcaValue = (value) => typeof value === "number" && Number.isFinite(value) || isApcaPreset(value);
3373
+ /**
3374
+ * A value, or a `[normal, highContrast]` pair of two, that passes `isValue`.
3375
+ * Both entries are read by index: `every` would skip a hole (`[, 7]`).
3376
+ */
3377
+ function isPairOf(value, isValue) {
3378
+ return Array.isArray(value) ? value.length === 2 && isValue(value[0]) && isValue(value[1]) : isValue(value);
3379
+ }
3380
+ /**
3381
+ * A WCAG value, or an object with exactly one of `wcag` / `apca` set. A key
3382
+ * holding `undefined` is unset, as everywhere else; other keys are ignored.
3383
+ */
3384
+ function isContrastSpec(spec) {
3385
+ if (isWcagValue(spec)) return true;
3386
+ if (typeof spec !== "object" || spec === null || Array.isArray(spec)) return false;
3387
+ const { wcag, apca } = spec;
3388
+ if (wcag === void 0 === (apca === void 0)) return false;
3389
+ return wcag !== void 0 ? isPairOf(wcag, isWcagValue) : isPairOf(apca, isApcaValue);
3390
+ }
3391
+ const SIGNED_NUMBER_RE = /^[+-](?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i;
3392
+ const isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
3393
+ /**
3394
+ * A relative `'+N'` / `'-N'` string with a finite `N`. Whitespace around it
3395
+ * is allowed, as the resolver's `parseFloat` reads past it. Also checks a
3396
+ * token's structured `darkHue`.
3397
+ */
3398
+ const isRelativeValue = (value) => typeof value === "string" && SIGNED_NUMBER_RE.test(value.trim()) && Number.isFinite(parseFloat(value));
3399
+ const isToneValue = (value) => isFiniteNumber(value) || value === "max" || value === "min" || isRelativeValue(value);
3400
+ const isHueValue = (value) => isFiniteNumber(value) || isRelativeValue(value);
3401
+ const isOpacity = (value) => isFiniteNumber(value) && value >= 0 && value <= 1;
3402
+ /**
3403
+ * Reject a numeric field the resolver can't place: `NaN`, `Infinity`, a
3404
+ * relative string that isn't `'+N'` / `'-N'` (`'+-5'`, `'+abc'`), an opacity
3405
+ * outside 0–1. Each would otherwise reach the output as `NaN`.
3406
+ */
3407
+ function assertField(name, field, value, isValid) {
3408
+ if (value === void 0 || isValid(value)) return;
3409
+ throw new Error(`glaze: color "${name}" has an invalid "${field}" ${showValue(value)}.`);
3410
+ }
3411
+ /**
3412
+ * Reject a malformed `contrast`: a misspelled preset, a non-finite number, an
3413
+ * object without exactly one of `wcag` / `apca`, or a pair without exactly two
3414
+ * entries. The solver would read each as no target at all and pin the color
3415
+ * to the scheme's extreme without a word.
3416
+ */
3417
+ function assertValidContrast(name, contrast) {
3418
+ if (contrast === void 0 || isPairOf(contrast, isContrastSpec)) return;
3419
+ throw new Error(`glaze: color "${name}" has an invalid "contrast" ${showValue(contrast)}. Use a WCAG ratio or 'AA' / 'AAA' / 'AA-large' / 'AAA-large', { wcag: … }, { apca: … }, or a [normal, highContrast] pair of these.`);
3420
+ }
3169
3421
  /**
3170
3422
  * Reject a `contrast` pair whose two entries measure in different metrics.
3171
3423
  *
@@ -3199,11 +3451,15 @@ function validateColorDefs(defs, externalBases) {
3199
3451
  const kind = def.type;
3200
3452
  for (const field of ["bg", "fg"]) {
3201
3453
  const ref = def[field];
3202
- if (ref === void 0) continue;
3454
+ if (ref === void 0) {
3455
+ if (field === "fg") continue;
3456
+ throw new Error(`glaze: ${kind} "${name}" is missing "bg".`);
3457
+ }
3203
3458
  if (!allNames.has(ref)) throw new Error(`glaze: ${kind} "${name}" references non-existent ${field} "${ref}".`);
3204
3459
  const refKind = overlayKindOf(defs, ref);
3205
3460
  if (refKind !== void 0) throw new Error(`glaze: ${kind} "${name}" ${field} "${ref}" references a ${refKind} color.`);
3206
3461
  }
3462
+ assertField(name, "intensity", def.intensity, (v) => isPairOf(v, isFiniteNumber));
3207
3463
  normalizeLightingTuning(kind, def.tuning, `${kind} "${name}" tuning`, deferred);
3208
3464
  continue;
3209
3465
  }
@@ -3215,10 +3471,19 @@ function validateColorDefs(defs, externalBases) {
3215
3471
  const refKind = overlayKindOf(defs, ref);
3216
3472
  if (refKind !== void 0) throw new Error(`glaze: mix "${name}" ${field} "${ref}" references a ${refKind} color.`);
3217
3473
  }
3474
+ assertField(name, "value", def.value, (v) => isPairOf(v, isFiniteNumber));
3475
+ assertValidContrast(name, def.contrast);
3218
3476
  assertConsistentContrastMetric(name, def.contrast);
3219
3477
  continue;
3220
3478
  }
3221
3479
  const regDef = def;
3480
+ assertField(name, "tone", regDef.tone, (v) => isPairOf(v, isToneValue));
3481
+ assertField(name, "hue", regDef.hue, isHueValue);
3482
+ assertField(name, "darkHue", regDef.darkHue, isHueValue);
3483
+ assertField(name, "saturation", regDef.saturation, isFiniteNumber);
3484
+ assertField(name, "darkSaturation", regDef.darkSaturation, isFiniteNumber);
3485
+ assertField(name, "opacity", regDef.opacity, isOpacity);
3486
+ assertValidContrast(name, regDef.contrast);
3222
3487
  assertConsistentContrastMetric(name, regDef.contrast);
3223
3488
  if (regDef.from !== void 0) try {
3224
3489
  extractOkhslFromValue(regDef.from);
@@ -3318,16 +3583,43 @@ function topoSort(defs) {
3318
3583
  * Variants are stored in OKHST: `h` / `s` are OKHSL hue/saturation and
3319
3584
  * `t` is the canonical contrast-uniform tone (0–1, reference eps). The
3320
3585
  * resolver works in tone for regular colors and converts to/from OKHSL
3321
- * lightness only at the mix / shadow / glow and luminance edges. A glow whose
3322
- * step crosses white with `headroom` above 0 also carries `hdr` — its opaque
3323
- * base in OKHST plus an exposure in stops — and swaps the SDR shortfall
3324
- * warning for one that fires only when the headroom cap falls short. HDR
3325
- * values never feed another color: nothing may build on a glow.
3586
+ * lightness only at the mix / shadow / glow and luminance edges. A mix reads
3587
+ * a base or target under another `pastel` flag in its own gamut, through the
3588
+ * color that endpoint paints, and measures its contrast floor on the color it
3589
+ * emits. A glow whose step crosses white with `headroom` above 0 also carries
3590
+ * `hdr` — its opaque base in OKHST plus an exposure in stops — and swaps the
3591
+ * SDR shortfall warning for one that fires only when the headroom cap falls
3592
+ * short. HDR values never feed another color: nothing may build on a glow.
3326
3593
  *
3327
3594
  * Every function receives a single `GlazeConfigResolved` so the full
3328
3595
  * per-instance config (including overrides) is available without
3329
3596
  * re-reading the global singleton mid-resolve.
3330
3597
  */
3598
+ /**
3599
+ * Defs anchored at their authored tone whatever their mode (see `darkAnchor`).
3600
+ * A `glaze.color()` token's hidden seed is `static` only so that its token's
3601
+ * `contrast` floor measures against the literal input; a delta on it still
3602
+ * steps in dark like one on an `auto` color authored at that tone.
3603
+ */
3604
+ const authoredToneAnchors = /* @__PURE__ */ new WeakSet();
3605
+ /**
3606
+ * Anchor `def` at its authored tone in dark, whatever its mode (internal, for
3607
+ * a token's hidden seed; see `authoredToneAnchors`).
3608
+ */
3609
+ function anchorAtAuthoredTone(def) {
3610
+ authoredToneAnchors.add(def);
3611
+ return def;
3612
+ }
3613
+ /**
3614
+ * The base of a tone-less, contrast-less `auto` color, which takes its base's
3615
+ * dark anchor as is; `undefined` for any other def.
3616
+ */
3617
+ function aliasBaseOf(def) {
3618
+ if (def === void 0 || isMixDef(def) || isLightingDef(def)) return;
3619
+ const regDef = def;
3620
+ if ((regDef.mode ?? "auto") !== "auto" || regDef.contrast !== void 0 || regDef.tone !== void 0 || regDef.from !== void 0) return;
3621
+ return regDef.base;
3622
+ }
3331
3623
  function getSchemeVariant(color, isDark, isHighContrast) {
3332
3624
  if (isDark && isHighContrast) return color.darkContrast;
3333
3625
  if (isDark) return color.dark;
@@ -3548,11 +3840,11 @@ function readsStoredLightTone(isHighContrast, config) {
3548
3840
  * Where a step of `delta` from `anchor` lands on the authored scale: clamped to
3549
3841
  * `0–100`, widened to reach an anchor past the dark window (a base a contrast
3550
3842
  * floor, an extreme's replay, or its own fixed / static placement put there).
3551
- * A step back toward the scale then
3552
- * moves by the full delta, and one that points further out stops at the anchor
3553
- * — the base itself — instead of snapping back to the window's edge. Either
3554
- * way a step stops at the window's edge or at its base, whichever is further
3555
- * out, so the result moves continuously as the base crosses the edge.
3843
+ * A step back toward the scale then moves by the full delta, and one that
3844
+ * points further out stops at the anchor — the base itself — instead of
3845
+ * snapping back to the window's edge. Either way a step stops at the window's
3846
+ * edge or at its base, whichever is further out, so the result moves
3847
+ * continuously as the base crosses the edge.
3556
3848
  */
3557
3849
  function stepFromAnchor(anchor, delta) {
3558
3850
  return clamp(anchor + delta, Math.min(0, anchor), Math.max(100, anchor));
@@ -3584,6 +3876,9 @@ function stepFromAnchor(anchor, delta) {
3584
3876
  * is this pass's. A base under another dark window — or one with no hook — is
3585
3877
  * read back through this pass's dark window, so a `'+0'` lands on it and the
3586
3878
  * step is this window's scale times the delta.
3879
+ * - A token's hidden seed (`anchorAtAuthoredTone`) is `static`, but returns its
3880
+ * tone as authored, like an `auto` color placed by a tone, so a delta on it
3881
+ * inverts in dark as it would from the same color in a theme.
3587
3882
  *
3588
3883
  * In high contrast and at `contrastLevel: 100` at the reference light eps
3589
3884
  * (`readsStoredLightTone`), a light-side color's light tone *is* its authored
@@ -3593,14 +3888,29 @@ function stepFromAnchor(anchor, delta) {
3593
3888
  *
3594
3889
  * Memoized per resolve and pass on `ctx.anchors`, so a long chain of deltas
3595
3890
  * resolves in linear time without deep recursion: the dark passes run in topo
3596
- * order, so a base's anchor is cached before its dependents ask for it.
3891
+ * order, so a base's anchor is cached before its dependents ask for it. A
3892
+ * tone-less alias never asks for its own, so a chain of aliases is walked in a
3893
+ * loop instead.
3597
3894
  */
3598
3895
  function darkAnchor(name, ctx, isHighContrast) {
3599
3896
  const memo = ctx.anchors[isHighContrast ? 1 : 0];
3600
3897
  const cached = memo.get(name);
3601
3898
  if (cached) return cached;
3899
+ if (aliasBaseOf(ctx.defs[name]) !== void 0) {
3900
+ const chain = [];
3901
+ let target = name;
3902
+ for (let base = aliasBaseOf(ctx.defs[target]); base !== void 0 && !memo.has(target); base = aliasBaseOf(ctx.defs[target])) {
3903
+ chain.push(target);
3904
+ target = base;
3905
+ }
3906
+ let anchor = darkAnchor(target, ctx, isHighContrast);
3907
+ for (let i = chain.length - 1; i >= 0; i--) {
3908
+ anchor = withStoredLightTone(chain[i], anchor, ctx, isHighContrast);
3909
+ memo.set(chain[i], anchor);
3910
+ }
3911
+ return anchor;
3912
+ }
3602
3913
  const color = ctx.resolved.get(name);
3603
- const lightTone = getSchemeVariant(color, false, isHighContrast).t * 100;
3604
3914
  const fromDark = (config) => ({
3605
3915
  tone: unmapDarkTone(getSchemeVariant(color, true, isHighContrast).t * 100, "auto", isHighContrast, config),
3606
3916
  lightSide: false
@@ -3620,7 +3930,7 @@ function darkAnchor(name, ctx, isHighContrast) {
3620
3930
  const config = configForColor(regDef, ctx.config);
3621
3931
  const rawTone = regDef.tone ?? fromSeed(regDef)?.tone;
3622
3932
  const parsed = rawTone === void 0 ? void 0 : passTone(rawTone, isHighContrast, config);
3623
- if (mode !== "auto" || regDef.contrast !== void 0) anchor = fromDark(config);
3933
+ if (mode !== "auto" && !authoredToneAnchors.has(regDef) || regDef.contrast !== void 0) anchor = fromDark(config);
3624
3934
  else if (parsed === void 0) anchor = darkAnchor(regDef.base, ctx, isHighContrast);
3625
3935
  else if (parsed.kind === "relative") {
3626
3936
  const base = darkAnchor(regDef.base, ctx, isHighContrast);
@@ -3636,14 +3946,22 @@ function darkAnchor(name, ctx, isHighContrast) {
3636
3946
  lightSide: true
3637
3947
  };
3638
3948
  }
3639
- if (anchor.lightSide && readsStoredLightTone(isHighContrast, ctx.config)) anchor = {
3640
- tone: lightTone,
3641
- lightSide: true
3642
- };
3949
+ anchor = withStoredLightTone(name, anchor, ctx, isHighContrast);
3643
3950
  memo.set(name, anchor);
3644
3951
  return anchor;
3645
3952
  }
3646
3953
  /**
3954
+ * A light-side anchor read as the color's stored light tone where that is its
3955
+ * authored tone (`readsStoredLightTone`; see `darkAnchor`).
3956
+ */
3957
+ function withStoredLightTone(name, anchor, ctx, isHighContrast) {
3958
+ if (!anchor.lightSide || !readsStoredLightTone(isHighContrast, ctx.config)) return anchor;
3959
+ return {
3960
+ tone: getSchemeVariant(ctx.resolved.get(name), false, isHighContrast).t * 100,
3961
+ lightSide: true
3962
+ };
3963
+ }
3964
+ /**
3647
3965
  * A resolved color's dark-delta anchor for one pass (see `darkAnchor`), read
3648
3966
  * after its resolve. This is what a `glaze.color()` token hands the resolver
3649
3967
  * (with its config, and cached per pass) when another token links to it as a
@@ -3795,6 +4113,10 @@ function resolveLightingForScheme(name, def, ctx, isDark, isHighContrast) {
3795
4113
  function okhslVariantToLinearRgb(v, pastel) {
3796
4114
  return okhslToLinearSrgb(v.h, v.s, v.l, pastel);
3797
4115
  }
4116
+ /** Saturation at or below which a mix endpoint counts as gray (no hue). */
4117
+ const MIX_SAT_EPSILON = 1e-6;
4118
+ /** Lightness margin from 0 and 1 for re-reading a mix endpoint's saturation. */
4119
+ const MIX_EXTREME_L = 1e-4;
3798
4120
  /**
3799
4121
  * Resolve hue for OKHSL mixing, handling achromatic colors.
3800
4122
  * When one color has no saturation, its hue is meaningless —
@@ -3802,9 +4124,8 @@ function okhslVariantToLinearRgb(v, pastel) {
3802
4124
  * color-mix "missing component" behavior).
3803
4125
  */
3804
4126
  function mixHue(base, target, t) {
3805
- const SAT_EPSILON = 1e-6;
3806
- const baseHasSat = base.s > SAT_EPSILON;
3807
- const targetHasSat = target.s > SAT_EPSILON;
4127
+ const baseHasSat = base.s > MIX_SAT_EPSILON;
4128
+ const targetHasSat = target.s > MIX_SAT_EPSILON;
3808
4129
  if (baseHasSat && targetHasSat) return circularLerp(base.h, target.h, t);
3809
4130
  if (targetHasSat) return target.h;
3810
4131
  return base.h;
@@ -3816,18 +4137,20 @@ function linearSrgbLerp(base, target, t) {
3816
4137
  base[2] + (target[2] - base[2]) * t
3817
4138
  ];
3818
4139
  }
3819
- function linearRgbToToneVariant(rgb, pastel) {
3820
- const [h, s, l] = srgbToOkhsl([
3821
- Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[0]))),
3822
- Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[1]))),
3823
- Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[2])))
3824
- ], pastel);
3825
- return toToneVariant({
4140
+ /**
4141
+ * Linear sRGB → OKHSL in one saturation space. Channels are clamped to sRGB
4142
+ * first, and a pastel read caps saturation at the pastel limit (same hue and
4143
+ * lightness), so the result is a color that space can paint.
4144
+ */
4145
+ function linearRgbToOkhsl(rgb, pastel) {
4146
+ const [h, s, l] = srgbToOkhsl(toDisplayGamma(rgb), pastel);
4147
+ return {
3826
4148
  h,
3827
4149
  s,
3828
4150
  l,
3829
- alpha: 1
3830
- });
4151
+ alpha: 1,
4152
+ pastel
4153
+ };
3831
4154
  }
3832
4155
  function resolveMixForScheme(name, def, ctx, isDark, isHighContrast) {
3833
4156
  const baseResolved = ctx.resolved.get(def.base);
@@ -3839,50 +4162,64 @@ function resolveMixForScheme(name, def, ctx, isDark, isHighContrast) {
3839
4162
  const space = def.space ?? "okhsl";
3840
4163
  const polarity = roleToPolarity(resolveRole(name, def, ctx));
3841
4164
  const pastel = def.pastel ?? ctx.config.pastel;
3842
- const baseLinear = okhslVariantToLinearRgb(baseVariant, baseVariant.pastel ?? ctx.config.pastel);
3843
- const targetLinear = okhslVariantToLinearRgb(targetVariant, targetVariant.pastel ?? ctx.config.pastel);
4165
+ const basePastel = baseVariant.pastel ?? ctx.config.pastel;
4166
+ const targetPastel = targetVariant.pastel ?? ctx.config.pastel;
4167
+ const baseLinear = okhslVariantToLinearRgb(baseVariant, basePastel);
4168
+ const targetLinear = okhslVariantToLinearRgb(targetVariant, targetPastel);
4169
+ const inMixSpace = (v, own) => {
4170
+ if (own === pastel || v.s <= MIX_SAT_EPSILON) return v;
4171
+ const l = clamp(v.l, MIX_EXTREME_L, 1 - MIX_EXTREME_L);
4172
+ const s = oklabToOkhsl(okhslToOklab(v.h, v.s, l, own), pastel)[1];
4173
+ return {
4174
+ ...v,
4175
+ s,
4176
+ pastel
4177
+ };
4178
+ };
4179
+ let mixAt;
4180
+ let paintAt;
4181
+ if (blend === "transparent") {
4182
+ const overlay = inMixSpace(targetVariant, targetPastel);
4183
+ const baseGamma = toDisplayGamma(baseLinear);
4184
+ const overlayGamma = toDisplayGamma(okhslVariantToLinearRgb(overlay, pastel));
4185
+ mixAt = (v) => ({
4186
+ ...overlay,
4187
+ alpha: clamp(v, 0, 1)
4188
+ });
4189
+ paintAt = (v) => compositeOverGamma(baseGamma, overlayGamma, v);
4190
+ } else if (space === "srgb") {
4191
+ const lerpAt = (v) => linearSrgbLerp(baseLinear, targetLinear, v);
4192
+ mixAt = (v) => linearRgbToOkhsl(lerpAt(v), pastel);
4193
+ paintAt = pastel ? (v) => {
4194
+ const mixed = mixAt(v);
4195
+ return mixed.s < 1 ? lerpAt(v) : okhslVariantToLinearRgb(mixed, true);
4196
+ } : lerpAt;
4197
+ } else {
4198
+ const from = inMixSpace(baseVariant, basePastel);
4199
+ const to = inMixSpace(targetVariant, targetPastel);
4200
+ mixAt = (v) => ({
4201
+ h: mixHue(from, to, v),
4202
+ s: clamp(from.s + (to.s - from.s) * v, 0, 1),
4203
+ l: clamp(from.l + (to.l - from.l) * v, 0, 1),
4204
+ alpha: 1
4205
+ });
4206
+ paintAt = (v) => okhslVariantToLinearRgb(mixAt(v), pastel);
4207
+ }
3844
4208
  if (def.contrast !== void 0) {
3845
4209
  const resolvedContrast = resolveContrastSpec(def.contrast, isHighContrast, ctx.config, polarity);
3846
4210
  const metric = resolvedContrast.metric;
3847
- let luminanceAt;
3848
- if (blend === "transparent") {
3849
- const baseGamma = toDisplayGamma(baseLinear);
3850
- const targetGamma = toDisplayGamma(targetLinear);
3851
- luminanceAt = (v) => metricLuminance(metric, compositeOverGamma(baseGamma, targetGamma, v));
3852
- } else if (space === "srgb") luminanceAt = (v) => metricLuminance(metric, linearSrgbLerp(baseLinear, targetLinear, v));
3853
- else luminanceAt = (v) => {
3854
- return metricLuminance(metric, okhslToLinearSrgb(mixHue(baseVariant, targetVariant, v), baseVariant.s + (targetVariant.s - baseVariant.s) * v, baseVariant.l + (targetVariant.l - baseVariant.l) * v, pastel));
3855
- };
3856
4211
  const result = findValueForMixContrast({
3857
4212
  preferredValue: t,
3858
4213
  baseLinearRgb: baseLinear,
3859
4214
  contrast: resolvedContrast,
3860
- luminanceAtValue: luminanceAt,
4215
+ luminanceAtValue: (v) => metricLuminance(metric, paintAt(v)),
3861
4216
  flip: ctx.config.autoFlip
3862
4217
  });
3863
4218
  t = result.value;
3864
- if (!result.met) warnMixContrastUnmet(name, isDark, isHighContrast, resolvedContrast, result.contrast);
4219
+ if (!result.met) warnMixContrastUnmet(name, isDark, isHighContrast, resolvedContrast, result.contrast, pastel && !targetPastel && metricScore(metric, metricLuminance(metric, targetLinear), metricLuminance(metric, baseLinear), resolvedContrast.polarity) >= resolvedContrast.target);
3865
4220
  }
3866
- if (blend === "transparent") return {
3867
- ...toToneVariant({
3868
- h: targetVariant.h,
3869
- s: targetVariant.s,
3870
- l: targetVariant.l,
3871
- alpha: clamp(t, 0, 1)
3872
- }),
3873
- pastel
3874
- };
3875
- if (space === "srgb") return {
3876
- ...linearRgbToToneVariant(linearSrgbLerp(baseLinear, targetLinear, t), pastel),
3877
- pastel
3878
- };
3879
4221
  return {
3880
- ...toToneVariant({
3881
- h: mixHue(baseVariant, targetVariant, t),
3882
- s: clamp(baseVariant.s + (targetVariant.s - baseVariant.s) * t, 0, 1),
3883
- l: clamp(baseVariant.l + (targetVariant.l - baseVariant.l) * t, 0, 1),
3884
- alpha: 1
3885
- }),
4222
+ ...toToneVariant(mixAt(t)),
3886
4223
  pastel
3887
4224
  };
3888
4225
  }
@@ -4052,8 +4389,8 @@ function resolveAllColors(seed, defs, config, externalBases, externalAnchors) {
4052
4389
  * Output formatting for resolved color maps.
4053
4390
  *
4054
4391
  * Owns the CSS-string formatter dispatch table (`okhsl` / `rgb` / `hsl` /
4055
- * `oklch`), the HDR glow writer (`formatHdrVariant`, `color(srgb-linear …)`),
4056
- * and the token-map shapes Glaze emits:
4392
+ * `oklch`), the HDR glow writer (`formatHdrVariant`: extended `color(srgb …)`,
4393
+ * or `color(srgb-linear …)` on request), and the token-map shapes Glaze emits:
4057
4394
  * - `buildTokenMap` — Tasty style-to-state bindings (`#name` keys, state aliases).
4058
4395
  * - `buildFlatTokenMap` — `{ light, dark, ... }` per-variant maps.
4059
4396
  * - `buildJsonMap` — `{ name: { light, dark, ... } }` per-color JSON.
@@ -4064,7 +4401,8 @@ function resolveAllColors(seed, defs, config, externalBases, externalAnchors) {
4064
4401
  *
4065
4402
  * The HDR tier (`variant.hdr`, a glow past SDR white) is emitted only with
4066
4403
  * `modes.hdr` (`css()`: its `hdr` option), and always as a literal
4067
- * `color(srgb-linear R G B)` — whatever `format`, even under `splitHue`.
4404
+ * `color(srgb R G B)` with channels past 1 — whatever `format`, even under
4405
+ * `splitHue`.
4068
4406
  * Without it every shape is byte-identical to SDR. Each shape carries it the
4069
4407
  * way its consumer composes values:
4070
4408
  * - Tasty: a sparse `hdr` state key after each SDR key that has one (`''`,
@@ -4144,15 +4482,16 @@ function formatVariant(v, format = "oklch", pastel = false) {
4144
4482
  }
4145
4483
  /**
4146
4484
  * Format a variant's HDR value (`variant.hdr`, set on a glow past white with
4147
- * `headroom`) as `color(srgb-linear R G B)`, or `undefined` when it has none.
4148
- * Always that form, whatever the SDR format: OKLCH lightness clamps at parse
4149
- * time and `rgb()` / `hsl()` cannot pass white. Emitted as a literal, never
4150
- * with a split hue.
4151
- */
4152
- function formatHdrVariant(v, format = "srgb-linear") {
4153
- if (format !== "srgb-linear") throw new Error(`glaze: unknown HDR format ${JSON.stringify(format)}. Use 'srgb-linear'.`);
4485
+ * `headroom`) as extended `color(srgb R G B)` (default) or
4486
+ * `color(srgb-linear R G B)` — the same color, with the brightest channel
4487
+ * past 1 — or `undefined` when it has none. One of these whatever the SDR
4488
+ * format: OKLCH lightness clamps at parse time and `rgb()` / `hsl()` cannot
4489
+ * pass white. Emitted as a literal, never with a split hue.
4490
+ */
4491
+ function formatHdrVariant(v, format = "srgb") {
4492
+ if (format !== "srgb" && format !== "srgb-linear") throw new Error(`glaze: unknown HDR format ${JSON.stringify(format)}. Use 'srgb' or 'srgb-linear'.`);
4154
4493
  if (v.hdr === void 0) return void 0;
4155
- return formatSrgbLinear(hdrToLinearSrgb(v.hdr));
4494
+ return format === "srgb" ? formatSrgb(hdrToSrgb(v.hdr)) : formatSrgbLinear(hdrToLinearSrgb(v.hdr));
4156
4495
  }
4157
4496
  /**
4158
4497
  * Format a resolved variant as `oklch(L C <hueVar>)`, splicing a CSS hue var
@@ -4379,8 +4718,8 @@ const DTCG_EXTENSION_NAMESPACE = "me.tenphi.glaze";
4379
4718
  * The DTCG form of an HDR value: `exposure` in stops, the luminance `tone`
4380
4719
  * it lands (past 100 unless `headroom` caps a saturated base short of it), the
4381
4720
  * opaque `base` as a DTCG sRGB color (always sRGB, whatever `colorSpace`, and
4382
- * never pastel — the base is standard OKHSL), and the HDR color as linear sRGB
4383
- * components, the brightest above 1.
4721
+ * never pastel — the base is standard OKHSL), and the HDR color as extended
4722
+ * sRGB components (the numbers of `glaze.formatHdr()`), the brightest above 1.
4384
4723
  */
4385
4724
  function dtcgHdrValue(hdr) {
4386
4725
  const linear = hdrToLinearSrgb(hdr);
@@ -4390,11 +4729,11 @@ function dtcgHdrValue(hdr) {
4390
4729
  tone: roundTo(toneFromY(relativeLuminanceFromLinearRgb(linear)), 4),
4391
4730
  base: dtcgSrgbValue(h, s, l, false),
4392
4731
  value: {
4393
- colorSpace: "srgb-linear",
4732
+ colorSpace: "srgb",
4394
4733
  components: [
4395
- roundTo(linear[0], 6),
4396
- roundTo(linear[1], 6),
4397
- roundTo(linear[2], 6)
4734
+ roundTo(sRGBLinearToGamma(linear[0]), 6),
4735
+ roundTo(sRGBLinearToGamma(linear[1]), 6),
4736
+ roundTo(sRGBLinearToGamma(linear[2]), 6)
4398
4737
  ]
4399
4738
  }
4400
4739
  };
@@ -4663,7 +5002,7 @@ function validateStructuredInput(input) {
4663
5002
  if (input.saturationFactor !== void 0) {
4664
5003
  if (!Number.isFinite(input.saturationFactor) || input.saturationFactor < 0 || input.saturationFactor > 1) throw new Error(`glaze.color: structured saturationFactor must be a finite number in 0–1 (got ${input.saturationFactor}).`);
4665
5004
  }
4666
- if (typeof input.darkHue === "number" && !Number.isFinite(input.darkHue)) throw new Error(`glaze.color: structured darkHue must be a finite number (got ${input.darkHue}).`);
5005
+ if (input.darkHue !== void 0 && !(typeof input.darkHue === "number" ? Number.isFinite(input.darkHue) : isRelativeValue(input.darkHue))) throw new Error(`glaze.color: structured darkHue must be a finite number or a '+N' / '-N' offset (got ${showValue(input.darkHue)}).`);
4667
5006
  if (input.darkSaturation !== void 0) {
4668
5007
  if (!Number.isFinite(input.darkSaturation) || input.darkSaturation < 0 || input.darkSaturation > 100) throw new Error(`glaze.color: structured darkSaturation must be a finite number in 0–100 (got ${input.darkSaturation}).`);
4669
5008
  }
@@ -4722,7 +5061,9 @@ function standaloneRole(role) {
4722
5061
  * When the user requests `contrast` or relative `tone`, a hidden
4723
5062
  * `STANDALONE_SEED` def is synthesized at `mode: 'static'`. That keeps
4724
5063
  * the seed pinned to the literal user-provided color across all four
4725
- * variants, so the contrast solver always anchors against it.
5064
+ * variants, so the contrast solver always anchors against it. Its dark-delta
5065
+ * anchor is still its authored tone (`anchorAtAuthoredTone`), so a delta on it
5066
+ * inverts in dark like one on the same color in a theme.
4726
5067
  */
4727
5068
  function buildStandaloneValueDefs(main, options) {
4728
5069
  const seedHue = typeof options?.hue === "number" ? options.hue : main.h;
@@ -4752,13 +5093,13 @@ function buildStandaloneValueDefs(main, options) {
4752
5093
  base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor ? STANDALONE_SEED : void 0
4753
5094
  };
4754
5095
  const defs = { [primary]: valueDef };
4755
- if (needsSeedAnchor) defs[STANDALONE_SEED] = {
5096
+ if (needsSeedAnchor) defs[STANDALONE_SEED] = anchorAtAuthoredTone({
4756
5097
  hue: main.h,
4757
5098
  saturation: 1,
4758
5099
  tone: seedTone,
4759
5100
  mode: "static",
4760
5101
  role: "surface"
4761
- };
5102
+ });
4762
5103
  return {
4763
5104
  seed: {
4764
5105
  hue: seedHue,
@@ -4926,7 +5267,8 @@ function resolveBaseToken(base) {
4926
5267
  function isGlazeColorToken(candidate) {
4927
5268
  return typeof candidate === "object" && candidate !== null && !Array.isArray(candidate) && "resolve" in candidate && typeof candidate.resolve === "function";
4928
5269
  }
4929
- function createColorToken(input, configOverride) {
5270
+ function createColorToken(rawInput, configOverride) {
5271
+ const input = copyTokenOptions(rawInput);
4930
5272
  validateStructuredInput(input);
4931
5273
  const userName = input.name;
4932
5274
  if (userName !== void 0) validateStandaloneName(userName);
@@ -4947,12 +5289,12 @@ function createColorToken(input, configOverride) {
4947
5289
  role: standaloneRole(input.role),
4948
5290
  base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor ? STANDALONE_SEED : void 0
4949
5291
  } };
4950
- if (needsSeedAnchor) defs[STANDALONE_SEED] = {
5292
+ if (needsSeedAnchor) defs[STANDALONE_SEED] = anchorAtAuthoredTone({
4951
5293
  tone: pinExtremeTone(pairNormal(input.tone)),
4952
5294
  saturation: 1,
4953
5295
  mode: "static",
4954
5296
  role: "surface"
4955
- };
5297
+ });
4956
5298
  const localOverride = copyConfigOverride(configOverride);
4957
5299
  return createColorTokenFromDefs({
4958
5300
  hue: input.hue,
@@ -4962,11 +5304,13 @@ function createColorToken(input, configOverride) {
4962
5304
  kind: "color",
4963
5305
  version: GLAZE_EXPORT_VERSION,
4964
5306
  form: "structured",
4965
- input: buildStructuredInputExport(input, exportArg),
5307
+ input: buildStructuredInputExport(input, baseToken, exportArg),
4966
5308
  config: freezeConfigForExport(localOverride, exportArg)
4967
5309
  }));
4968
5310
  }
4969
- function createColorTokenFromValue(value, options, configOverride) {
5311
+ function createColorTokenFromValue(input, inputOptions, configOverride) {
5312
+ const value = copyColorValue(input);
5313
+ const options = inputOptions && copyTokenOptions(inputOptions);
4970
5314
  const main = extractOkhslFromValue(value);
4971
5315
  const baseToken = resolveBaseToken(options?.base);
4972
5316
  const { seed, defs, primary } = buildStandaloneValueDefs(main, options);
@@ -4975,40 +5319,43 @@ function createColorTokenFromValue(value, options, configOverride) {
4975
5319
  kind: "color",
4976
5320
  version: GLAZE_EXPORT_VERSION,
4977
5321
  form: "value",
4978
- input: value,
4979
- ...options !== void 0 ? { overrides: buildOverridesExport(options, exportArg) } : {},
5322
+ input: copyColorValue(value),
5323
+ ...options !== void 0 ? { overrides: buildOverridesExport(options, baseToken, exportArg) } : {},
4980
5324
  config: freezeConfigForExport(localOverride, exportArg)
4981
5325
  }));
4982
5326
  }
4983
5327
  /**
4984
- * Build a JSON-safe snapshot of `GlazeColorOverrides`. `base` is
4985
- * recursively serialized when it was originally a token; raw values are
4986
- * preserved as-is so `glaze.colorFrom(...)` round-trips them.
5328
+ * Build a JSON-safe snapshot of `GlazeColorOverrides`. `base` is serialized
5329
+ * as its token's own snapshot: a raw value as the token it is wrapped in, so
5330
+ * its config is frozen with the rest and a restored token doesn't follow later
5331
+ * `glaze.configure()` calls through its base. Every object in it is a fresh
5332
+ * copy, so editing a snapshot never reaches the live token.
4987
5333
  */
4988
- function buildOverridesExport(options, exportArg) {
5334
+ function buildOverridesExport(options, baseToken, exportArg) {
4989
5335
  const out = {};
4990
5336
  if (options.hue !== void 0) out.hue = options.hue;
4991
5337
  if (options.saturation !== void 0) out.saturation = options.saturation;
4992
- if (options.tone !== void 0) out.tone = options.tone;
5338
+ if (options.tone !== void 0) out.tone = copyPair(options.tone);
4993
5339
  if (options.saturationFactor !== void 0) out.saturationFactor = options.saturationFactor;
4994
5340
  if (options.darkHue !== void 0) out.darkHue = options.darkHue;
4995
5341
  if (options.darkSaturation !== void 0) out.darkSaturation = options.darkSaturation;
4996
5342
  if (options.darkSaturationFactor !== void 0) out.darkSaturationFactor = options.darkSaturationFactor;
4997
5343
  if (options.mode !== void 0) out.mode = options.mode;
4998
5344
  if (options.autoFlip !== void 0) out.autoFlip = options.autoFlip;
4999
- if (options.contrast !== void 0) out.contrast = options.contrast;
5345
+ if (options.contrast !== void 0) out.contrast = copyContrast(options.contrast);
5000
5346
  if (options.opacity !== void 0) out.opacity = options.opacity;
5001
5347
  if (options.name !== void 0) out.name = options.name;
5002
5348
  if (options.pastel !== void 0) out.pastel = options.pastel;
5003
5349
  if (options.role !== void 0) out.role = options.role;
5004
- if (options.base !== void 0) out.base = isGlazeColorToken(options.base) ? options.base.export(exportArg) : options.base;
5350
+ if (baseToken !== void 0) out.base = baseToken.export(exportArg);
5005
5351
  return out;
5006
5352
  }
5007
- function buildStructuredInputExport(input, exportArg) {
5353
+ /** The structured-input counterpart of `buildOverridesExport`. */
5354
+ function buildStructuredInputExport(input, baseToken, exportArg) {
5008
5355
  const out = {
5009
5356
  hue: input.hue,
5010
5357
  saturation: input.saturation,
5011
- tone: input.tone
5358
+ tone: copyPair(input.tone)
5012
5359
  };
5013
5360
  if (input.saturationFactor !== void 0) out.saturationFactor = input.saturationFactor;
5014
5361
  if (input.darkHue !== void 0) out.darkHue = input.darkHue;
@@ -5017,13 +5364,68 @@ function buildStructuredInputExport(input, exportArg) {
5017
5364
  if (input.mode !== void 0) out.mode = input.mode;
5018
5365
  if (input.autoFlip !== void 0) out.autoFlip = input.autoFlip;
5019
5366
  if (input.opacity !== void 0) out.opacity = input.opacity;
5020
- if (input.contrast !== void 0) out.contrast = input.contrast;
5367
+ if (input.contrast !== void 0) out.contrast = copyContrast(input.contrast);
5021
5368
  if (input.name !== void 0) out.name = input.name;
5022
5369
  if (input.pastel !== void 0) out.pastel = input.pastel;
5023
5370
  if (input.role !== void 0) out.role = input.role;
5024
- if (input.base !== void 0) out.base = isGlazeColorToken(input.base) ? input.base.export(exportArg) : input.base;
5371
+ if (baseToken !== void 0) out.base = baseToken.export(exportArg);
5025
5372
  return out;
5026
5373
  }
5374
+ /** A copy of a `[normal, highContrast]` pair, or the value itself. */
5375
+ function copyPair(value) {
5376
+ return Array.isArray(value) ? [...value] : value;
5377
+ }
5378
+ /**
5379
+ * A copy of a `contrast` value, read by property access so a proxy (reactive
5380
+ * state) copies too. Other keys of a spec object are kept for the validator.
5381
+ */
5382
+ function copyContrast(contrast) {
5383
+ const copySpec = (spec) => {
5384
+ if (typeof spec !== "object" || spec === null) return spec;
5385
+ const out = { ...spec };
5386
+ for (const key of ["wcag", "apca"]) {
5387
+ const value = spec[key];
5388
+ if (value !== void 0) out[key] = copyPair(value);
5389
+ }
5390
+ return out;
5391
+ };
5392
+ return Array.isArray(contrast) ? contrast.map(copySpec) : copySpec(contrast);
5393
+ }
5394
+ /** Every field of `GlazeColorInput` / `GlazeColorOverrides`. */
5395
+ const TOKEN_OPTION_KEYS = [
5396
+ "hue",
5397
+ "saturation",
5398
+ "tone",
5399
+ "saturationFactor",
5400
+ "darkHue",
5401
+ "darkSaturation",
5402
+ "darkSaturationFactor",
5403
+ "mode",
5404
+ "autoFlip",
5405
+ "opacity",
5406
+ "contrast",
5407
+ "base",
5408
+ "name",
5409
+ "pastel",
5410
+ "role"
5411
+ ];
5412
+ /**
5413
+ * A detached copy of a token's input or overrides, each field read by property
5414
+ * access so a getter, an inherited field or a proxy copies like a plain one.
5415
+ * The pairs, `contrast` and a raw value `base` are copied too, so a caller
5416
+ * editing them later doesn't change the token (a token `base` is kept, as
5417
+ * tokens are immutable). Other keys (a value form's `from`) are left out.
5418
+ */
5419
+ function copyTokenOptions(options) {
5420
+ const source = options;
5421
+ const out = {};
5422
+ for (const key of TOKEN_OPTION_KEYS) if (source[key] !== void 0) out[key] = source[key];
5423
+ const copy = out;
5424
+ if (copy.tone !== void 0) copy.tone = copyPair(copy.tone);
5425
+ if (copy.contrast !== void 0) copy.contrast = copyContrast(copy.contrast);
5426
+ if (copy.base !== void 0 && !isGlazeColorToken(copy.base)) copy.base = copyColorValue(copy.base);
5427
+ return copy;
5428
+ }
5027
5429
  /**
5028
5430
  * Discriminate a `GlazeColorTokenExport` from a raw `GlazeColorValue`.
5029
5431
  */
@@ -5117,8 +5519,25 @@ function colorFromExport(data) {
5117
5519
  * property, so a `suffix` that lands one on a hue var (`'-hue'`, or `''` with a
5118
5520
  * color named `*-hue`) throws too.
5119
5521
  */
5522
+ /**
5523
+ * Reject a seed the resolver can't use: every color multiplies its hue and
5524
+ * saturation, so a non-finite one paints the whole theme `NaN`.
5525
+ */
5526
+ function assertValidSeed(seed) {
5527
+ for (const field of [
5528
+ "hue",
5529
+ "saturation",
5530
+ "darkHue",
5531
+ "darkSaturation"
5532
+ ]) {
5533
+ const value = seed?.[field];
5534
+ if ((field === "darkHue" || field === "darkSaturation") && value === void 0) continue;
5535
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`glaze: theme seed ${field} must be a finite number (got ${showValue(value)}).`);
5536
+ }
5537
+ }
5120
5538
  function createTheme(seed, initialColors, callerOverride) {
5121
5539
  const configOverride = copyConfigOverride(callerOverride);
5540
+ assertValidSeed(seed);
5122
5541
  const { hue, saturation, darkHue, darkSaturation } = seed;
5123
5542
  let colorDefs = initialColors ? { ...initialColors } : {};
5124
5543
  let cache = null;
@@ -5585,8 +6004,8 @@ function glaze(hueOrOptions, saturationOrConfig, config) {
5585
6004
  if (typeof hueOrOptions === "number") return createTheme({
5586
6005
  hue: hueOrOptions,
5587
6006
  saturation: (typeof saturationOrConfig === "number" ? saturationOrConfig : void 0) ?? 100
5588
- }, void 0, config);
5589
- return createTheme(hueOrOptions, void 0, typeof saturationOrConfig === "object" ? saturationOrConfig : config);
6007
+ }, void 0, (config === void 0 || config === null) && typeof saturationOrConfig === "object" && saturationOrConfig !== null ? saturationOrConfig : config);
6008
+ return createTheme(hueOrOptions, void 0, typeof saturationOrConfig === "object" && saturationOrConfig !== null ? saturationOrConfig : config);
5590
6009
  }
5591
6010
  /** Configure global glaze settings. */
5592
6011
  glaze.configure = function configure$1(config) {
@@ -5651,14 +6070,16 @@ glaze.color = function color(input, config) {
5651
6070
  if (typeof input === "string") return createColorTokenFromValue(input, void 0, config);
5652
6071
  const obj = input;
5653
6072
  if ("from" in obj) {
5654
- const { from, ...overrides } = input;
5655
- return createColorTokenFromValue(from, overrides, config);
6073
+ const fromInput = input;
6074
+ return createColorTokenFromValue(fromInput.from, fromInput, config);
5656
6075
  }
5657
6076
  if ("hue" in obj) return createColorToken(input, config);
5658
6077
  return createColorTokenFromValue(input, void 0, config);
5659
6078
  };
5660
6079
  /** Shared body of `glaze.shadow()` / `glaze.glow()`. */
5661
6080
  function standaloneLighting(kind, input) {
6081
+ if (input?.bg === void 0) throw new Error(`glaze.${kind}(): "bg" is required.`);
6082
+ if (typeof input.intensity !== "number" || !Number.isFinite(input.intensity)) throw new Error(`glaze.${kind}(): "intensity" must be a finite number (got ${showValue(input.intensity)}).`);
5662
6083
  const bg = extractOkhslFromValue(input.bg);
5663
6084
  const fg = input.fg ? extractOkhslFromValue(input.fg) : void 0;
5664
6085
  const cfg = getConfig();
@@ -5712,19 +6133,19 @@ glaze.format = function format(variant, colorFormat, pastel) {
5712
6133
  };
5713
6134
  /**
5714
6135
  * Format a variant's HDR value — set on a glow whose step crosses SDR white
5715
- * with `headroom` above 0 — as `color(srgb-linear R G B)`, with channels that
5716
- * may exceed 1. Returns `undefined` for a variant without one. Only
5717
- * `'srgb-linear'` is supported: OKLCH lightness clamps at parse time and
5718
- * `rgb()` / `hsl()` cannot go past white.
6136
+ * with `headroom` above 0 — as extended `color(srgb R G B)`, with channels
6137
+ * that may exceed 1 (`1` is SDR white), or as the same color in linear light
6138
+ * with `'srgb-linear'`. Returns `undefined` for a variant without one.
5719
6139
  *
5720
6140
  * @example
5721
6141
  * ```ts
5722
6142
  * const v = glaze.glow({ bg: '#ffffff', intensity: 12, tuning: { headroom: 1 } });
5723
- * glaze.format(v); // SDR: 'oklch(1 0 0)'
5724
- * glaze.formatHdr(v); // 'color(srgb-linear 1.4631 1.4631 1.4631)'
6143
+ * glaze.format(v); // SDR: 'oklch(1 0 0)'
6144
+ * glaze.formatHdr(v); // 'color(srgb 1.1813 1.1813 1.1813)'
6145
+ * glaze.formatHdr(v, 'srgb-linear'); // 'color(srgb-linear 1.4631 1.4631 1.4631)'
5725
6146
  * ```
5726
6147
  */
5727
- glaze.formatHdr = function formatHdr(variant, hdrFormat = "srgb-linear") {
6148
+ glaze.formatHdr = function formatHdr(variant, hdrFormat = "srgb") {
5728
6149
  return formatHdrVariant(variant, hdrFormat);
5729
6150
  };
5730
6151
  /**
@@ -5797,5 +6218,5 @@ glaze.resetConfig = function resetConfig$1() {
5797
6218
  };
5798
6219
 
5799
6220
  //#endregion
5800
- export { APCA_HC_ENHANCEMENT, APCA_MAX_LC, APCA_PRESETS, GLAZE_EXPORT_VERSION, REF_EPS, apcaContrast, apcaLuminanceFromLinearRgb, assertAllPastel, assertNativeFormat, contrastRatioFromLuminance, cuspLightness, findToneForContrast, findValueForMixContrast, formatHsl, formatOkhsl, formatOkhst, formatOklch, formatRgb, formatSrgbLinear, fromTone, gamutClampedLuminance, glaze, hslToSrgb, inferRoleFromName, isColorTokenExport, isPaletteExport, isThemeExport, normalizeRole, okhslToLinearSrgb, okhslToOkhst, okhslToOklab, okhslToOklch, okhslToSrgb, okhstToOkhsl, oklabToOkhsl, oppositeRole, parseHex, parseHexAlpha, relativeLuminanceFromLinearRgb, resolveApcaTarget, resolveContrastForLevel, resolveContrastForMode, resolveMinContrast, roleToPolarity, srgbToHex, srgbToOkhsl, toTone, toneFromY, variantToOkhsl, yFromTone };
6221
+ export { APCA_HC_ENHANCEMENT, APCA_MAX_LC, APCA_PRESETS, GLAZE_EXPORT_VERSION, REF_EPS, apcaContrast, apcaLuminanceFromLinearRgb, assertAllPastel, assertNativeFormat, contrastRatioFromLuminance, cuspLightness, findToneForContrast, findValueForMixContrast, formatHsl, formatOkhsl, formatOkhst, formatOklch, formatRgb, formatSrgb, formatSrgbLinear, fromTone, gamutClampedLuminance, glaze, hdrToLinearSrgb, hdrToSrgb, hslToSrgb, inferRoleFromName, isColorTokenExport, isPaletteExport, isThemeExport, normalizeRole, okhslToLinearSrgb, okhslToOkhst, okhslToOklab, okhslToOklch, okhslToSrgb, okhstToOkhsl, oklabToOkhsl, oppositeRole, parseHex, parseHexAlpha, relativeLuminanceFromLinearRgb, resolveApcaTarget, resolveContrastForLevel, resolveContrastForMode, resolveMinContrast, roleToPolarity, srgbToHex, srgbToOkhsl, toTone, toneFromY, variantToOkhsl, yFromTone };
5801
6222
  //# sourceMappingURL=index.mjs.map