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