@tenphi/glaze 0.0.0-snapshot.c3d484c → 0.0.0-snapshot.c61bbdf

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
@@ -368,6 +368,27 @@ const sRGBGammaToLinear = (val) => {
368
368
  const abs = Math.abs(val);
369
369
  return abs <= .04045 ? val / 12.92 : sign * Math.pow((abs + .055) / 1.055, 2.4);
370
370
  };
371
+ /** Gamma-encoded sRGB clamped to the display gamut — what a browser paints. */
372
+ function toDisplayGamma(linearRgb) {
373
+ return [
374
+ Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[0]))),
375
+ Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[1]))),
376
+ Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[2])))
377
+ ];
378
+ }
379
+ /**
380
+ * Browser alpha compositing: `source-over` interpolates the gamma-encoded
381
+ * channels, not linear light (50% black over white paints `#808080`).
382
+ * Takes display-gamma inputs and returns the composite as linear sRGB for
383
+ * the luminance pipeline.
384
+ */
385
+ function compositeOverGamma(baseGamma, overlayGamma, alpha) {
386
+ return [
387
+ sRGBGammaToLinear(baseGamma[0] + (overlayGamma[0] - baseGamma[0]) * alpha),
388
+ sRGBGammaToLinear(baseGamma[1] + (overlayGamma[1] - baseGamma[1]) * alpha),
389
+ sRGBGammaToLinear(baseGamma[2] + (overlayGamma[2] - baseGamma[2]) * alpha)
390
+ ];
391
+ }
371
392
  /**
372
393
  * Convert OKHSL to gamma-encoded sRGB (clamped to 0–1).
373
394
  * h: 0–360, s: 0–1, l: 0–1. Returns [r, g, b] with each channel 0–1.
@@ -777,6 +798,7 @@ function configure(config) {
777
798
  highContrast: config.modes?.highContrast ?? globalConfig.modes.highContrast
778
799
  },
779
800
  shadowTuning: config.shadowTuning ?? globalConfig.shadowTuning,
801
+ glowTuning: config.glowTuning ?? globalConfig.glowTuning,
780
802
  autoFlip: config.autoFlip ?? globalConfig.autoFlip,
781
803
  pastel: false,
782
804
  inferRole: config.inferRole ?? globalConfig.inferRole,
@@ -808,6 +830,7 @@ function mergeConfig(base, override) {
808
830
  states: base.states,
809
831
  modes: base.modes,
810
832
  shadowTuning: override.shadowTuning ?? base.shadowTuning,
833
+ glowTuning: override.glowTuning ?? base.glowTuning,
811
834
  autoFlip: override.autoFlip ?? base.autoFlip,
812
835
  pastel: override.pastel ?? false,
813
836
  inferRole: override.inferRole ?? base.inferRole,
@@ -817,8 +840,8 @@ function mergeConfig(base, override) {
817
840
  /**
818
841
  * Freeze `getConfig() ∪ instanceLocal ∪ exportArg` as a plain, resolve-relevant
819
842
  * override for authoring export, so restored snapshots pin these fields. Later
820
- * wins; nested objects (`shadowTuning`) replace wholesale. The final
821
- * `structuredClone` detaches tone-window / shadow objects that `mergeConfig`
843
+ * wins; nested objects (`shadowTuning` / `glowTuning`) replace wholesale. The
844
+ * final `structuredClone` detaches tone-window / tuning objects that `mergeConfig`
822
845
  * may still share with the live global config.
823
846
  */
824
847
  function freezeConfigForExport(instanceLocal, exportArg) {
@@ -835,6 +858,7 @@ function freezeConfigForExport(instanceLocal, exportArg) {
835
858
  inferRole: effective.inferRole
836
859
  };
837
860
  if (effective.shadowTuning !== void 0) out.shadowTuning = effective.shadowTuning;
861
+ if (effective.glowTuning !== void 0) out.glowTuning = effective.glowTuning;
838
862
  const authoredLevel = exportArg?.contrastLevel ?? instanceLocal?.contrastLevel;
839
863
  if (authoredLevel !== void 0) out.contrastLevel = normalizeContrastLevel(authoredLevel);
840
864
  return structuredClone(out);
@@ -845,8 +869,13 @@ function freezeConfigForExport(instanceLocal, exportArg) {
845
869
  /**
846
870
  * Current authoring-export schema version. Bump when the export shape
847
871
  * changes in a non-compatible way. Written on every `.export()` snapshot.
872
+ *
873
+ * - `1` — Glaze 2.x.
874
+ * - `2` — Glaze 3: `shadow` `intensity` / `fg` / `tuning` changed meaning and
875
+ * `glow` was added. Version-1 theme snapshots that carry shadow defs or a
876
+ * `shadowTuning` are rejected on restore.
848
877
  */
849
- const GLAZE_EXPORT_VERSION = 1;
878
+ const GLAZE_EXPORT_VERSION = 2;
850
879
 
851
880
  //#endregion
852
881
  //#region src/serialize.ts
@@ -868,6 +897,22 @@ function assertExportVersion(data, factory) {
868
897
  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.`);
869
898
  }
870
899
  /**
900
+ * Reject a version-1 (Glaze 2) theme snapshot that carries shadow colors or a
901
+ * `shadowTuning`. Glaze 3 changed what shadow `intensity`, `fg` and tuning
902
+ * mean, so restoring one would silently re-render every shadow differently.
903
+ * Version-1 snapshots without shadows load as before. `fallbackVersion` is the
904
+ * enclosing palette's version, for a theme entry that carries none of its own.
905
+ */
906
+ function assertNoLegacyShadows(data, factory, fallbackVersion) {
907
+ if ((data.version ?? fallbackVersion) !== 1) return;
908
+ const colors = isPlainObject(data.colors) ? data.colors : {};
909
+ const shadows = Object.keys(colors).filter((name) => isPlainObject(colors[name]) && colors[name].type === "shadow");
910
+ const hasTuning = isPlainObject(data.config) && data.config.shadowTuning !== void 0;
911
+ if (shadows.length === 0 && !hasTuning) return;
912
+ const what = shadows.length > 0 ? `shadow colors (${shadows.map((n) => JSON.stringify(n)).join(", ")})` : "a \"shadowTuning\" config";
913
+ 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.`);
914
+ }
915
+ /**
871
916
  * When `kind` is present, it must match the factory. Missing `kind` is
872
917
  * allowed for legacy snapshots.
873
918
  */
@@ -960,7 +1005,7 @@ function hcMirrorsNormal(config) {
960
1005
  return contrastFraction(config) === 1;
961
1006
  }
962
1007
  /**
963
- * A numeric HC pair at a blend fraction (shadow `intensity`, mix `value`).
1008
+ * A numeric HC pair at a blend fraction (shadow / glow `intensity`, mix `value`).
964
1009
  *
965
1010
  * The endpoints return the authored entry *by identity* rather than through
966
1011
  * float arithmetic — `a + (b - a) * 1` is not bit-exactly `b` in IEEE 754, and
@@ -1083,7 +1128,7 @@ function isAbsoluteTone(tone) {
1083
1128
  * - the scheme tone window, including the high-contrast bypass and its
1084
1129
  * continuous generalization under a manual contrast level.
1085
1130
  *
1086
- * See `docs/okhst.md` for the full specification and the calibrated
1131
+ * See `docs/okhst.mdx` for the full specification and the calibrated
1087
1132
  * default constants.
1088
1133
  */
1089
1134
  /**
@@ -1439,8 +1484,8 @@ function validateOkhstColor(value) {
1439
1484
  }
1440
1485
  /**
1441
1486
  * Extract an OKHSL color from any `GlazeColorValue` form. Also used by
1442
- * `glaze.shadow()` so all shadow inputs (hex, color functions, OKHSL,
1443
- * literal objects) go through one parser.
1487
+ * `glaze.shadow()` / `glaze.glow()` so all their inputs (hex, color
1488
+ * functions, OKHSL, literal objects) go through one parser.
1444
1489
  */
1445
1490
  function extractOkhslFromValue(value) {
1446
1491
  if (typeof value === "string") return parseColorString(value);
@@ -2072,36 +2117,46 @@ function oppositeRole(role) {
2072
2117
  }
2073
2118
 
2074
2119
  //#endregion
2075
- //#region src/shadow.ts
2120
+ //#region src/lighting.ts
2076
2121
  /**
2077
- * Shadow color computation.
2122
+ * Shadow and glow color computation.
2078
2123
  *
2079
- * Owns the shadow / mix def predicates, default tuning constants, the
2080
- * tuning merge, and the actual `computeShadow` math (hue blend,
2081
- * saturation cap, lightness clamp, alpha curve). The resolver consumes
2082
- * this module per scheme variant.
2124
+ * Owns the shadow / glow / mix def predicates, the default lighting tuning
2125
+ * and its merge, and the exact lighting solver (`computeLighting`). Both
2126
+ * types emit a translucent pigment over a known surface (`bg`) whose opacity
2127
+ * is solved so the composite lands an exact tone step away from it: darker
2128
+ * for `shadow`, lighter for `glow`, in every scheme. Compositing is modeled
2129
+ * the way browsers paint it — `source-over` on gamma-encoded sRGB — and the
2130
+ * step is measured in the WCAG-shaped tone of the composite's luminance, so
2131
+ * it holds on chromatic surfaces too. An optional object (`fg`) scales the
2132
+ * step the way bounce light does. The resolver consumes this module per
2133
+ * scheme variant; `glaze.shadow()` / `glaze.glow()` call it directly.
2083
2134
  */
2084
2135
  function isShadowDef(def) {
2085
2136
  return def.type === "shadow";
2086
2137
  }
2138
+ function isGlowDef(def) {
2139
+ return def.type === "glow";
2140
+ }
2141
+ /** A `shadow` or `glow` def — the translucent overlay types. */
2142
+ function isLightingDef(def) {
2143
+ return isShadowDef(def) || isGlowDef(def);
2144
+ }
2087
2145
  function isMixDef(def) {
2088
2146
  return def.type === "mix";
2089
2147
  }
2090
- const DEFAULT_SHADOW_TUNING = {
2091
- saturationFactor: .18,
2092
- maxSaturation: .25,
2093
- lightnessFactor: .25,
2094
- lightnessBounds: [.05, .2],
2095
- minGapTarget: .05,
2096
- alphaMax: 1,
2097
- bgHueBlend: .2
2148
+ const DEFAULT_LIGHTING_TUNING = {
2149
+ objectInfluence: .5,
2150
+ tint: 0,
2151
+ reach: 1,
2152
+ hueBleed: 0
2098
2153
  };
2099
- function resolveShadowTuning(perColor, globalTuning) {
2154
+ /** Defaults ← global (`shadowTuning` / `glowTuning`) ← per-color, field by field. */
2155
+ function resolveLightingTuning(perColor, globalTuning) {
2100
2156
  return {
2101
- ...DEFAULT_SHADOW_TUNING,
2157
+ ...DEFAULT_LIGHTING_TUNING,
2102
2158
  ...globalTuning,
2103
- ...perColor,
2104
- lightnessBounds: perColor?.lightnessBounds ?? globalTuning?.lightnessBounds ?? DEFAULT_SHADOW_TUNING.lightnessBounds
2159
+ ...perColor
2105
2160
  };
2106
2161
  }
2107
2162
  function circularLerp(a, b, t) {
@@ -2110,47 +2165,122 @@ function circularLerp(a, b, t) {
2110
2165
  else if (diff < -180) diff += 360;
2111
2166
  return ((a + diff * t) % 360 + 360) % 360;
2112
2167
  }
2168
+ const SAT_EPSILON = 1e-6;
2169
+ const BISECT_ITERATIONS = 40;
2170
+ const GOLDEN = (Math.sqrt(5) - 1) / 2;
2113
2171
  /**
2114
- * Compute the canonical max-contrast reference t value for normalization.
2115
- * Uses bg.l=1, fg.l=0, intensity=100 — the theoretical maximum.
2116
- * This is a fixed constant per tuning configuration, ensuring uniform
2117
- * scaling across all bg/fg pairs at low intensities.
2118
- */
2119
- function computeRefT(tuning) {
2120
- const EPSILON = 1e-6;
2121
- let lShRef = clamp(tuning.lightnessFactor, tuning.lightnessBounds[0], tuning.lightnessBounds[1]);
2122
- lShRef = Math.max(Math.min(lShRef, 1 - tuning.minGapTarget), 0);
2123
- return 1 / Math.max(1 - lShRef, EPSILON);
2124
- }
2125
- function computeShadow(bg, fg, intensity, tuning) {
2126
- const EPSILON = 1e-6;
2127
- const clampedIntensity = clamp(intensity, 0, 100);
2128
- const contrastWeight = fg ? Math.abs(bg.l - fg.l) : 1;
2129
- const deltaL = clampedIntensity / 100 * contrastWeight;
2130
- const h = fg ? circularLerp(fg.h, bg.h, tuning.bgHueBlend) : bg.h;
2131
- const s = fg ? Math.min(fg.s * tuning.saturationFactor, tuning.maxSaturation) : 0;
2132
- let lSh = clamp(bg.l * tuning.lightnessFactor, tuning.lightnessBounds[0], tuning.lightnessBounds[1]);
2133
- lSh = Math.max(Math.min(lSh, bg.l - tuning.minGapTarget), 0);
2134
- const t = deltaL / Math.max(bg.l - lSh, EPSILON);
2135
- const tRef = computeRefT(tuning);
2136
- const norm = Math.tanh(tRef / tuning.alphaMax);
2137
- const alpha = Math.min(tuning.alphaMax * Math.tanh(t / tuning.alphaMax) / norm, tuning.alphaMax);
2172
+ * Object factor `k = max(0, 1 + β · d · (t_bg − t_fg) / 100)`, with `d = +1`
2173
+ * for a shadow and `−1` for a glow. Calibrated so an object at the surface's
2174
+ * own tone — or no object at all — gives exactly the authored step: a darker
2175
+ * object deepens its shadow, a lighter one strengthens its glow.
2176
+ */
2177
+ function objectFactor(kind, bgTone, fgTone, objectInfluence) {
2178
+ if (fgTone === void 0) return 1;
2179
+ const d = kind === "shadow" ? 1 : -1;
2180
+ return Math.max(0, 1 + objectInfluence * d * (bgTone - fgTone) / 100);
2181
+ }
2182
+ /**
2183
+ * The pigment: pure black (shadow) / pure white (glow) by default. `reach`
2184
+ * below 1 parks it part of the way from the surface's tone to that extreme so
2185
+ * a `tint` — saturation in the surface's hue, optionally pulled toward the
2186
+ * object's hue by `hueBleed` — can show.
2187
+ */
2188
+ function pigmentFor(kind, bg, fg, bgTone, tuning) {
2189
+ const reach = clamp(tuning.reach, 0, 1);
2190
+ const tone = kind === "shadow" ? bgTone * (1 - reach) : bgTone + (100 - bgTone) * reach;
2191
+ const bleed = clamp(tuning.hueBleed, 0, 1);
2138
2192
  return {
2139
- h,
2140
- s,
2141
- l: lSh,
2142
- alpha
2193
+ h: fg !== void 0 && fg.s > SAT_EPSILON && bleed > 0 ? circularLerp(bg.h, fg.h, bleed) : bg.h,
2194
+ s: clamp(tuning.tint, 0, 1),
2195
+ l: clamp(fromTone(tone), 0, 1)
2196
+ };
2197
+ }
2198
+ /**
2199
+ * Solve the pigment opacity that moves `bg` exactly `intensity × k` tones
2200
+ * darker (shadow) or lighter (glow) once the browser composites it.
2201
+ *
2202
+ * The composite's luminance is convex in alpha (each gamma channel moves
2203
+ * linearly and the sRGB decode is convex), so the "has reached the step"
2204
+ * predicate is monotone whenever full opacity reaches it and a bisection
2205
+ * lands it exactly. When it doesn't, the step is out of reach — a glow on
2206
+ * white, a deep shadow on near-black — and the result pins to the pigment's
2207
+ * strongest composite, reporting what it reached in `reached`.
2208
+ */
2209
+ function computeLighting(kind, bg, fg, intensity, tuning, pastel) {
2210
+ const bgTone = toTone(bg.l);
2211
+ const k = objectFactor(kind, bgTone, fg === void 0 ? void 0 : toTone(fg.l), tuning.objectInfluence);
2212
+ const step = clamp(intensity, 0, 100) * k;
2213
+ const pigment = pigmentFor(kind, bg, fg, bgTone, tuning);
2214
+ const bgGamma = toDisplayGamma(okhslToLinearSrgb(bg.h, bg.s, bg.l, bg.pastel ?? pastel));
2215
+ const pigmentGamma = toDisplayGamma(okhslToLinearSrgb(pigment.h, pigment.s, pigment.l, pastel));
2216
+ const toneAt = (alpha) => toneFromY(relativeLuminanceFromLinearRgb(compositeOverGamma(bgGamma, pigmentGamma, alpha)));
2217
+ const surfaceTone = toneAt(0);
2218
+ const d = kind === "shadow" ? 1 : -1;
2219
+ const gain = (alpha) => d * (surfaceTone - toneAt(alpha));
2220
+ if (step <= 0) return {
2221
+ ...pigment,
2222
+ alpha: 0,
2223
+ step,
2224
+ reached: 0
2225
+ };
2226
+ let hi = 1;
2227
+ if (gain(1) < step) {
2228
+ hi = kind === "shadow" ? strongestShadowAlpha(gain) : 1;
2229
+ const reached = gain(hi);
2230
+ if (reached < step) return reached > 0 ? {
2231
+ ...pigment,
2232
+ alpha: hi,
2233
+ step,
2234
+ reached
2235
+ } : {
2236
+ ...pigment,
2237
+ alpha: 0,
2238
+ step,
2239
+ reached: 0
2240
+ };
2241
+ }
2242
+ let lo = 0;
2243
+ for (let i = 0; i < BISECT_ITERATIONS; i++) {
2244
+ const mid = (lo + hi) / 2;
2245
+ if (gain(mid) < step) lo = mid;
2246
+ else hi = mid;
2247
+ }
2248
+ return {
2249
+ ...pigment,
2250
+ alpha: hi,
2251
+ step,
2252
+ reached: gain(hi)
2143
2253
  };
2144
2254
  }
2255
+ /**
2256
+ * Opacity of a shadow pigment's darkest composite. Full opacity for pure
2257
+ * black; a tinted pigment whose channels pull in different directions can
2258
+ * bottom out earlier, so a golden-section search over the unimodal gain finds
2259
+ * it and the better of that and full opacity wins. (A glow needs no search:
2260
+ * convex luminance peaks at an endpoint, and full opacity is the one that
2261
+ * lightens.)
2262
+ */
2263
+ function strongestShadowAlpha(gain) {
2264
+ let a = 0;
2265
+ let b = 1;
2266
+ for (let i = 0; i < BISECT_ITERATIONS; i++) {
2267
+ const c = b - GOLDEN * (b - a);
2268
+ const e = a + GOLDEN * (b - a);
2269
+ if (gain(c) < gain(e)) a = c;
2270
+ else b = e;
2271
+ }
2272
+ const peak = (a + b) / 2;
2273
+ return gain(peak) > gain(1) ? peak : 1;
2274
+ }
2145
2275
 
2146
2276
  //#endregion
2147
2277
  //#region src/validation.ts
2148
2278
  /**
2149
2279
  * Color graph validation and topological sort.
2150
2280
  *
2151
- * `validateColorDefs` rejects bad references (missing / shadow-referencing /
2152
- * base/contrast/tone mismatches) and detects cycles before the
2153
- * resolver runs. `topoSort` orders defs so each color is processed after
2281
+ * `validateColorDefs` rejects bad references (missing / shadow- or
2282
+ * glow-referencing / base/contrast/tone mismatches) and detects cycles before
2283
+ * the resolver runs. `topoSort` orders defs so each color is processed after
2154
2284
  * its base / bg / fg / target dependencies.
2155
2285
  */
2156
2286
  /**
@@ -2179,24 +2309,37 @@ function assertConsistentContrastMetric(name, contrast) {
2179
2309
  if (normal === hc) return;
2180
2310
  throw new Error(`glaze: color "${name}" has a "contrast" pair that switches metric (${normal} → ${hc}). A WCAG ratio and an APCA Lc are different scales; use one metric for both entries, e.g. { ${normal}: [normal, highContrast] }.`);
2181
2311
  }
2312
+ /**
2313
+ * `'shadow'` / `'glow'` when `ref` names a local translucent overlay — which
2314
+ * nothing may build on — else `undefined`.
2315
+ */
2316
+ function overlayKindOf(defs, ref) {
2317
+ const def = Object.hasOwn(defs, ref) ? defs[ref] : void 0;
2318
+ return def !== void 0 && isLightingDef(def) ? def.type : void 0;
2319
+ }
2182
2320
  function validateColorDefs(defs, externalBases) {
2183
2321
  const localNames = new Set(Object.keys(defs));
2184
2322
  const allNames = new Set([...localNames, ...externalBases ? externalBases.keys() : []]);
2185
2323
  for (const [name, def] of Object.entries(defs)) {
2186
- if (isShadowDef(def)) {
2187
- if (!allNames.has(def.bg)) throw new Error(`glaze: shadow "${name}" references non-existent bg "${def.bg}".`);
2188
- if (localNames.has(def.bg) && isShadowDef(defs[def.bg])) throw new Error(`glaze: shadow "${name}" bg "${def.bg}" references another shadow color.`);
2189
- if (def.fg !== void 0) {
2190
- if (!allNames.has(def.fg)) throw new Error(`glaze: shadow "${name}" references non-existent fg "${def.fg}".`);
2191
- if (localNames.has(def.fg) && isShadowDef(defs[def.fg])) throw new Error(`glaze: shadow "${name}" fg "${def.fg}" references another shadow color.`);
2324
+ if (isLightingDef(def)) {
2325
+ const kind = def.type;
2326
+ for (const field of ["bg", "fg"]) {
2327
+ const ref = def[field];
2328
+ if (ref === void 0) continue;
2329
+ if (!allNames.has(ref)) throw new Error(`glaze: ${kind} "${name}" references non-existent ${field} "${ref}".`);
2330
+ const refKind = overlayKindOf(defs, ref);
2331
+ if (refKind !== void 0) throw new Error(`glaze: ${kind} "${name}" ${field} "${ref}" references a ${refKind} color.`);
2192
2332
  }
2193
2333
  continue;
2194
2334
  }
2195
2335
  if (isMixDef(def)) {
2196
2336
  if (!allNames.has(def.base)) throw new Error(`glaze: mix "${name}" references non-existent base "${def.base}".`);
2197
2337
  if (!allNames.has(def.target)) throw new Error(`glaze: mix "${name}" references non-existent target "${def.target}".`);
2198
- if (localNames.has(def.base) && isShadowDef(defs[def.base])) throw new Error(`glaze: mix "${name}" base "${def.base}" references a shadow color.`);
2199
- if (localNames.has(def.target) && isShadowDef(defs[def.target])) throw new Error(`glaze: mix "${name}" target "${def.target}" references a shadow color.`);
2338
+ for (const field of ["base", "target"]) {
2339
+ const ref = def[field];
2340
+ const refKind = overlayKindOf(defs, ref);
2341
+ if (refKind !== void 0) throw new Error(`glaze: mix "${name}" ${field} "${ref}" references a ${refKind} color.`);
2342
+ }
2200
2343
  assertConsistentContrastMetric(name, def.contrast);
2201
2344
  continue;
2202
2345
  }
@@ -2210,7 +2353,8 @@ function validateColorDefs(defs, externalBases) {
2210
2353
  if (regDef.contrast !== void 0 && !regDef.base) throw new Error(`glaze: color "${name}" has "contrast" without "base".`);
2211
2354
  if (regDef.tone !== void 0 && !isAbsoluteTone(regDef.tone) && !regDef.base) throw new Error(`glaze: color "${name}" has relative "tone" without "base".`);
2212
2355
  if (regDef.base && !allNames.has(regDef.base)) throw new Error(`glaze: color "${name}" references non-existent base "${regDef.base}".`);
2213
- if (regDef.base && localNames.has(regDef.base) && isShadowDef(defs[regDef.base])) throw new Error(`glaze: color "${name}" base "${regDef.base}" references a shadow color.`);
2356
+ const baseKind = regDef.base && overlayKindOf(defs, regDef.base);
2357
+ if (baseKind) throw new Error(`glaze: color "${name}" base "${regDef.base}" references a ${baseKind} color.`);
2214
2358
  if (!hasAbsoluteTone(regDef) && regDef.base === void 0) throw new Error(`glaze: color "${name}" must have either absolute "tone" (root), "from" (a literal color), or "base" (dependent).`);
2215
2359
  if (regDef.contrast !== void 0 && regDef.opacity !== void 0) console.warn(`glaze: color "${name}" has both "contrast" and "opacity". Opacity makes perceived tone unpredictable.`);
2216
2360
  }
@@ -2222,7 +2366,7 @@ function validateColorDefs(defs, externalBases) {
2222
2366
  if (visited.has(name)) return;
2223
2367
  inStack.add(name);
2224
2368
  const def = defs[name];
2225
- if (isShadowDef(def)) {
2369
+ if (isLightingDef(def)) {
2226
2370
  dfs(def.bg);
2227
2371
  if (def.fg) dfs(def.fg);
2228
2372
  } else if (isMixDef(def)) {
@@ -2245,7 +2389,7 @@ function topoSort(defs) {
2245
2389
  visited.add(name);
2246
2390
  const def = defs[name];
2247
2391
  if (def === void 0) return;
2248
- if (isShadowDef(def)) {
2392
+ if (isLightingDef(def)) {
2249
2393
  visit(def.bg);
2250
2394
  if (def.fg) visit(def.fg);
2251
2395
  } else if (isMixDef(def)) {
@@ -2264,7 +2408,8 @@ function topoSort(defs) {
2264
2408
  //#endregion
2265
2409
  //#region src/warnings.ts
2266
2410
  /**
2267
- * Contrast-warning dispatcher.
2411
+ * Resolve-time warning dispatcher: unmet contrast floors, chromatic contrast
2412
+ * drift, and out-of-reach shadow / glow steps.
2268
2413
  *
2269
2414
  * Tokens memoize their resolution, but a long-lived process (e.g. a dev
2270
2415
  * server with HMR) can re-resolve the same theme many times. The cache
@@ -2304,10 +2449,11 @@ function warnContrastUnmet(name, isDark, isHighContrast, contrast, actual) {
2304
2449
  console.warn(`glaze: color "${name}" cannot meet ${metricLabel(contrast)} in ${scheme} scheme (got ${actual.toFixed(2)}). Try widening the tone window, lowering the contrast target, or picking a base color further from this color's tone.`);
2305
2450
  }
2306
2451
  /**
2307
- * Verification (§10): a chromatic swatch inherits the gray tone's
2308
- * lightness but drifts in real luminance, so a contrast-floored color may
2309
- * land slightly under the contrast its tone implies. Emit an advisory
2310
- * warning when the actual measured contrast drifts below the target.
2452
+ * Verification (the drift check in docs/okhst.mdx): a chromatic swatch
2453
+ * inherits the gray tone's lightness but drifts in real luminance, so a
2454
+ * contrast-floored color may land slightly under the contrast its tone
2455
+ * implies. Emit an advisory warning when the actual measured contrast
2456
+ * drifts below the target.
2311
2457
  */
2312
2458
  function warnContrastDrift(name, isDark, isHighContrast, contrast, yColor, yBase) {
2313
2459
  const actual = contrast.metric === "apca" ? Math.abs(contrast.polarity === "bg" ? apcaContrast(yBase, yColor) : apcaContrast(yColor, yBase)) : contrastRatioFromLuminance(yColor, yBase);
@@ -2316,6 +2462,23 @@ function warnContrastDrift(name, isDark, isHighContrast, contrast, yColor, yBase
2316
2462
  if (dedupe(`drift|${name}|${scheme}|${contrast.metric}|${contrast.target.toFixed(2)}|${actual.toFixed(2)}`)) return;
2317
2463
  console.warn(`glaze: color "${name}" drifts below ${metricLabel(contrast)} in ${scheme} scheme (measured ${actual.toFixed(2)}). Chromatic luminance differs from the gray tone; nudge the tone or saturation if the floor matters.`);
2318
2464
  }
2465
+ /** Tone slack before an out-of-reach shadow / glow step is worth a warning. */
2466
+ const LIGHTING_WARN_SLACK = .05;
2467
+ /**
2468
+ * Warn when a `shadow` / `glow` step is out of reach of its pigment — a glow
2469
+ * on white, a deep shadow on near-black — so the composite pins to the
2470
+ * pigment's strongest and lands short. `name` is omitted for the standalone
2471
+ * `glaze.shadow()` / `glaze.glow()` factories, which have no scheme either.
2472
+ */
2473
+ function warnLightingUnreachable(kind, name, scheme, step, reached) {
2474
+ if (reached >= step - LIGHTING_WARN_SLACK) return;
2475
+ const label = scheme ? schemeLabel(scheme.isDark, scheme.isHighContrast) : void 0;
2476
+ if (dedupe(`lighting|${kind}|${name ?? ""}|${label ?? ""}|${step.toFixed(2)}|${reached.toFixed(2)}`)) return;
2477
+ const subject = name === void 0 ? `glaze.${kind}()` : `${kind} "${name}"`;
2478
+ const where = label === void 0 ? "" : ` in ${label} scheme`;
2479
+ const extreme = kind === "shadow" ? "black" : "white";
2480
+ console.warn(`glaze: ${subject} cannot reach a ${step.toFixed(1)}-tone step${where} (reached ${reached.toFixed(1)} at full strength). Lower the intensity, move the surface away from ${extreme}, or raise a tuned-down \`reach\`.`);
2481
+ }
2319
2482
 
2320
2483
  //#endregion
2321
2484
  //#region src/resolver.ts
@@ -2329,13 +2492,13 @@ function warnContrastDrift(name, isDark, isHighContrast, contrast, yColor, yBase
2329
2492
  * passes bypass the level entirely, so the high-contrast slots are the same
2330
2493
  * values `'auto'` resolves. At level 100 the normal passes already are the
2331
2494
  * high-contrast ones, so the solver drops to two passes (light → dark) and
2332
- * mirrors the slots. Owns the per-scheme resolve helpers for regular, shadow,
2333
- * and mix color defs.
2495
+ * mirrors the slots. Owns the per-scheme resolve helpers for regular, shadow /
2496
+ * glow, and mix color defs.
2334
2497
  *
2335
2498
  * Variants are stored in OKHST: `h` / `s` are OKHSL hue/saturation and
2336
2499
  * `t` is the canonical contrast-uniform tone (0–1, reference eps). The
2337
2500
  * resolver works in tone for regular colors and converts to/from OKHSL
2338
- * lightness only at the mix/shadow and luminance edges.
2501
+ * lightness only at the mix / shadow / glow and luminance edges.
2339
2502
  *
2340
2503
  * Every function receives a single `GlazeConfigResolved` so the full
2341
2504
  * per-instance config (including overrides) is available without
@@ -2394,7 +2557,7 @@ function resolveRoleInMap(name, def, defs, inferRole, roles) {
2394
2557
  const cached = roles.get(name);
2395
2558
  if (cached) return cached;
2396
2559
  let role;
2397
- if (isShadowDef(def)) role = "surface";
2560
+ if (isLightingDef(def)) role = "surface";
2398
2561
  else if (isMixDef(def)) role = normalizeRole(def.role) ?? (inferRole ? inferRoleFromName(name) : void 0) ?? resolveBaseRoleInMap(def.base, defs, inferRole, roles) ?? "text";
2399
2562
  else {
2400
2563
  const regDef = def;
@@ -2412,7 +2575,7 @@ function resolveRoleInMap(name, def, defs, inferRole, roles) {
2412
2575
  * 4. `'text'` (foreground) default
2413
2576
  *
2414
2577
  * Memoized on `ctx.roles` so the four scheme passes share one resolution.
2415
- * Shadows have no contrast participation and default to `'surface'`.
2578
+ * Shadows and glows have no contrast participation and default to `'surface'`.
2416
2579
  */
2417
2580
  function resolveRole(name, def, ctx) {
2418
2581
  return resolveRoleInMap(name, def, ctx.defs, ctx.config.inferRole, ctx.roles);
@@ -2431,7 +2594,7 @@ function passTone(tone, isHighContrast, config) {
2431
2594
  const f = contrastFraction(config);
2432
2595
  return f === void 0 || isHighContrast ? parseToneValue(isHighContrast ? pairHC(tone) : pairNormal(tone)) : parseToneValueAt(tone, f);
2433
2596
  }
2434
- /** The authored numeric pair for this pass (shadow `intensity`, mix `value`). */
2597
+ /** The authored numeric pair for this pass (shadow / glow `intensity`, mix `value`). */
2435
2598
  function passNumber(p, isHighContrast, config) {
2436
2599
  const f = contrastFraction(config);
2437
2600
  if (f === void 0 || isHighContrast) return isHighContrast ? pairHC(p) : pairNormal(p);
@@ -2609,7 +2772,7 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, channels,
2609
2772
  return clamp(preferredTone, 0, 100);
2610
2773
  }
2611
2774
  function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
2612
- if (isShadowDef(def)) return resolveShadowForScheme(def, ctx, isDark, isHighContrast);
2775
+ if (isLightingDef(def)) return resolveLightingForScheme(name, def, ctx, isDark, isHighContrast);
2613
2776
  if (isMixDef(def)) return resolveMixForScheme(name, def, ctx, isDark, isHighContrast);
2614
2777
  const regDef = def;
2615
2778
  const mode = regDef.mode ?? "auto";
@@ -2627,15 +2790,33 @@ function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
2627
2790
  pastel
2628
2791
  };
2629
2792
  }
2630
- function resolveShadowForScheme(def, ctx, isDark, isHighContrast) {
2793
+ /**
2794
+ * Shadow / glow: solve the pigment opacity that lands this scheme's `bg`
2795
+ * exactly `intensity × k` tones darker / lighter. `bg` and `fg` are read per
2796
+ * scheme, so the object factor `k` adapts on its own in dark and high contrast.
2797
+ */
2798
+ function resolveLightingForScheme(name, def, ctx, isDark, isHighContrast) {
2631
2799
  const bgVariant = toOkhslVariant(getSchemeVariant(ctx.resolved.get(def.bg), isDark, isHighContrast));
2632
- let fgVariant;
2633
- if (def.fg) fgVariant = toOkhslVariant(getSchemeVariant(ctx.resolved.get(def.fg), isDark, isHighContrast));
2800
+ const fgVariant = def.fg ? toOkhslVariant(getSchemeVariant(ctx.resolved.get(def.fg), isDark, isHighContrast)) : void 0;
2634
2801
  const intensity = passNumber(def.intensity, isHighContrast, ctx.config);
2635
- const tuning = resolveShadowTuning(def.tuning, ctx.config.shadowTuning);
2802
+ const tuning = resolveLightingTuning(def.tuning, def.type === "shadow" ? ctx.config.shadowTuning : ctx.config.glowTuning);
2803
+ const pastel = def.pastel ?? ctx.config.pastel;
2804
+ const result = computeLighting(def.type, {
2805
+ ...bgVariant,
2806
+ pastel: bgVariant.pastel ?? ctx.config.pastel
2807
+ }, fgVariant, intensity, tuning, pastel);
2808
+ warnLightingUnreachable(def.type, name, {
2809
+ isDark,
2810
+ isHighContrast
2811
+ }, result.step, result.reached);
2636
2812
  return {
2637
- ...toToneVariant(computeShadow(bgVariant, fgVariant, intensity, tuning)),
2638
- pastel: def.pastel ?? ctx.config.pastel
2813
+ ...toToneVariant({
2814
+ h: result.h,
2815
+ s: result.s,
2816
+ l: result.l,
2817
+ alpha: result.alpha
2818
+ }),
2819
+ pastel
2639
2820
  };
2640
2821
  }
2641
2822
  function okhslVariantToLinearRgb(v, pastel) {
@@ -2691,7 +2872,11 @@ function resolveMixForScheme(name, def, ctx, isDark, isHighContrast) {
2691
2872
  const resolvedContrast = resolveContrastSpec(def.contrast, isHighContrast, ctx.config, polarity);
2692
2873
  const metric = resolvedContrast.metric;
2693
2874
  let luminanceAt;
2694
- if (blend === "transparent" || space === "srgb") luminanceAt = (v) => metricLuminance(metric, linearSrgbLerp(baseLinear, targetLinear, v));
2875
+ if (blend === "transparent") {
2876
+ const baseGamma = toDisplayGamma(baseLinear);
2877
+ const targetGamma = toDisplayGamma(targetLinear);
2878
+ luminanceAt = (v) => metricLuminance(metric, compositeOverGamma(baseGamma, targetGamma, v));
2879
+ } else if (space === "srgb") luminanceAt = (v) => metricLuminance(metric, linearSrgbLerp(baseLinear, targetLinear, v));
2695
2880
  else luminanceAt = (v) => {
2696
2881
  return metricLuminance(metric, okhslToLinearSrgb(mixHue(baseVariant, targetVariant, v), baseVariant.s + (targetVariant.s - baseVariant.s) * v, baseVariant.l + (targetVariant.l - baseVariant.l) * v, pastel));
2697
2882
  };
@@ -2728,7 +2913,7 @@ function resolveMixForScheme(name, def, ctx, isDark, isHighContrast) {
2728
2913
  };
2729
2914
  }
2730
2915
  function defMode(def) {
2731
- if (isShadowDef(def) || isMixDef(def)) return void 0;
2916
+ if (isLightingDef(def) || isMixDef(def)) return void 0;
2732
2917
  return def.mode ?? "auto";
2733
2918
  }
2734
2919
  /**
@@ -2772,9 +2957,10 @@ function seedField(order, ctx, field, source) {
2772
2957
  }
2773
2958
  }
2774
2959
  /**
2775
- * After the passes, surface chromatic contrast drift (§10): a color
2776
- * resolved with a `base` + `contrast` may land slightly under the contrast
2777
- * its tone implies because chromatic luminance drifts from the gray tone.
2960
+ * After the passes, surface chromatic contrast drift (the drift check in
2961
+ * docs/okhst.mdx): a color resolved with a `base` + `contrast` may land
2962
+ * slightly under the contrast its tone implies because chromatic luminance
2963
+ * drifts from the gray tone.
2778
2964
  *
2779
2965
  * Under a manual `contrastLevel` the spec is resolved at the level, so the check
2780
2966
  * measures each normal variant against the target it was actually solved for. At
@@ -2786,7 +2972,7 @@ function verifyContrastDrift(order, defs, result, config) {
2786
2972
  const roles = /* @__PURE__ */ new Map();
2787
2973
  for (const name of order) {
2788
2974
  const def = defs[name];
2789
- if (isShadowDef(def) || isMixDef(def)) continue;
2975
+ if (isLightingDef(def) || isMixDef(def)) continue;
2790
2976
  const regDef = def;
2791
2977
  if (regDef.contrast === void 0 || !regDef.base) continue;
2792
2978
  const color = result.get(name);
@@ -2896,7 +3082,7 @@ function schemeSeedHue(ctx, scheme) {
2896
3082
  return scheme === "dark" ? ctx.darkSeedHue ?? ctx.seedHue : ctx.seedHue;
2897
3083
  }
2898
3084
  function themeHuePlan(name, def, variant, ctx, scheme) {
2899
- if (def === void 0 || isShadowDef(def) || isMixDef(def) || isAchromatic(variant)) return {
3085
+ if (def === void 0 || isLightingDef(def) || isMixDef(def) || isAchromatic(variant)) return {
2900
3086
  hueVar: "",
2901
3087
  inline: true,
2902
3088
  declarations: []
@@ -4080,10 +4266,11 @@ function buildPaletteOutput(themes, paletteOptions, options, buildOne, merge, em
4080
4266
  return acc;
4081
4267
  }
4082
4268
  /** Rebuild a theme from a `theme.export()` snapshot. */
4083
- function createThemeFromExport(data, factory = "glaze.themeFrom") {
4269
+ function createThemeFromExport(data, factory = "glaze.themeFrom", fallbackVersion) {
4084
4270
  if (data === null || typeof data !== "object") throw new Error(`${factory}: expected an object from theme.export(), got ${data === null ? "null" : typeof data}.`);
4085
4271
  assertExportKind(data, "theme", factory);
4086
4272
  assertExportVersion(data, factory);
4273
+ assertNoLegacyShadows(data, factory, fallbackVersion);
4087
4274
  if (typeof data.hue !== "number" || typeof data.saturation !== "number") throw new Error(`${factory}: expected numeric "hue" and "saturation" fields.`);
4088
4275
  for (const field of ["darkHue", "darkSaturation"]) if (data[field] !== void 0 && typeof data[field] !== "number") throw new Error(`${factory}: expected "${field}" to be a number when present (got ${JSON.stringify(data[field])}).`);
4089
4276
  return createTheme({
@@ -4102,7 +4289,7 @@ function createPaletteFromExport(data) {
4102
4289
  assertExportVersion(data, "glaze.paletteFrom");
4103
4290
  if (data.themes === null || typeof data.themes !== "object") throw new Error(`glaze.paletteFrom: expected a "themes" object map of theme exports.`);
4104
4291
  const rebuilt = {};
4105
- for (const [name, themeExport] of Object.entries(data.themes)) rebuilt[name] = createThemeFromExport(themeExport, `glaze.paletteFrom (theme "${name}")`);
4292
+ for (const [name, themeExport] of Object.entries(data.themes)) rebuilt[name] = createThemeFromExport(themeExport, `glaze.paletteFrom (theme "${name}")`, data.version);
4106
4293
  return createPalette(rebuilt, { primary: data.primary });
4107
4294
  }
4108
4295
  function createPalette(themes, paletteOptions) {
@@ -4239,42 +4426,17 @@ function createPalette(themes, paletteOptions) {
4239
4426
  * - `theme.ts` — single-theme factory
4240
4427
  * - `palette.ts` — multi-theme composition
4241
4428
  * - `color-token.ts` — standalone single-color tokens (`glaze.color`)
4242
- * - `shadow.ts` — standalone shadow factory (`glaze.shadow`)
4429
+ * - `lighting.ts` — shadow / glow solver (`glaze.shadow`, `glaze.glow`)
4243
4430
  * - `formatters.ts` — variant → string (`glaze.format`)
4244
4431
  * - `config.ts` — global config singleton
4245
4432
  * - `serialize.ts` — authoring export type guards / version checks
4246
4433
  */
4247
- /**
4248
- * Create a single-hue glaze theme.
4249
- *
4250
- * An optional `config` override can be supplied to customize the resolve
4251
- * behavior for this theme (tone windows, etc.). The
4252
- * override is **merged over the live global config at resolve time** —
4253
- * the theme still reacts to later `configure()` calls for fields it
4254
- * didn't override.
4255
- *
4256
- * The object form also accepts `darkHue` / `darkSaturation` — a separate
4257
- * seed pair for the dark and dark-high-contrast schemes. Both default to
4258
- * their light counterparts, and an explicit `darkSaturation` opts the theme
4259
- * out of the global `darkDesaturation` reduction.
4260
- *
4261
- * @example
4262
- * ```ts
4263
- * const primary = glaze(280, 80);
4264
- * // or shorthand:
4265
- * const primary = glaze({ hue: 280, saturation: 80 });
4266
- * // with a retuned dark seed:
4267
- * const primary = glaze({ hue: 280, saturation: 80, darkHue: 268, darkSaturation: 65 });
4268
- * // with config override:
4269
- * const raw = glaze(280, 80, { lightTone: false });
4270
- * ```
4271
- */
4272
- function glaze(hueOrOptions, saturation, config) {
4434
+ function glaze(hueOrOptions, saturationOrConfig, config) {
4273
4435
  if (typeof hueOrOptions === "number") return createTheme({
4274
4436
  hue: hueOrOptions,
4275
- saturation: saturation ?? 100
4437
+ saturation: (typeof saturationOrConfig === "number" ? saturationOrConfig : void 0) ?? 100
4276
4438
  }, void 0, config);
4277
- return createTheme(hueOrOptions, void 0, config);
4439
+ return createTheme(hueOrOptions, void 0, typeof saturationOrConfig === "object" ? saturationOrConfig : config);
4278
4440
  }
4279
4441
  /** Configure global glaze settings. */
4280
4442
  glaze.configure = function configure$1(config) {
@@ -4345,25 +4507,14 @@ glaze.color = function color(input, config) {
4345
4507
  if ("hue" in obj) return createColorToken(input, config);
4346
4508
  return createColorTokenFromValue(input, void 0, config);
4347
4509
  };
4348
- /**
4349
- * Compute a shadow color from a bg/fg pair and intensity.
4350
- *
4351
- * Both `bg` and `fg` accept any `GlazeColorValue` form: hex (`#rgb` /
4352
- * `#rrggbb` / `#rrggbbaa`), `rgb()` / `hsl()` / `okhsl()` / `oklch()`
4353
- * strings, or `{ r, g, b }` / `{ h, s, l }` / `{ l, c, h }` objects.
4354
- */
4355
- glaze.shadow = function shadow(input) {
4510
+ /** Shared body of `glaze.shadow()` / `glaze.glow()`. */
4511
+ function standaloneLighting(kind, input) {
4356
4512
  const bg = extractOkhslFromValue(input.bg);
4357
4513
  const fg = input.fg ? extractOkhslFromValue(input.fg) : void 0;
4358
4514
  const cfg = getConfig();
4359
- const tuning = resolveShadowTuning(input.tuning, cfg.shadowTuning);
4360
- const result = computeShadow({
4361
- ...bg,
4362
- alpha: 1
4363
- }, fg ? {
4364
- ...fg,
4365
- alpha: 1
4366
- } : void 0, input.intensity, tuning);
4515
+ const tuning = resolveLightingTuning(input.tuning, kind === "shadow" ? cfg.shadowTuning : cfg.glowTuning);
4516
+ const result = computeLighting(kind, bg, fg, input.intensity, tuning, false);
4517
+ warnLightingUnreachable(kind, void 0, void 0, result.step, result.reached);
4367
4518
  const { h, s, t } = okhslToOkhst({
4368
4519
  h: result.h,
4369
4520
  s: result.s,
@@ -4375,6 +4526,27 @@ glaze.shadow = function shadow(input) {
4375
4526
  t,
4376
4527
  alpha: result.alpha
4377
4528
  };
4529
+ }
4530
+ /**
4531
+ * Compute a shadow over a surface: a translucent pigment whose opacity lands
4532
+ * `bg` exactly `intensity` tones darker once the browser composites it. The
4533
+ * optional object `fg` scales the step (a darker object casts a deeper
4534
+ * shadow).
4535
+ *
4536
+ * Both `bg` and `fg` accept any `GlazeColorValue` form: hex (`#rgb` /
4537
+ * `#rrggbb` / `#rrggbbaa`), `rgb()` / `hsl()` / `okhsl()` / `oklch()`
4538
+ * strings, or `{ r, g, b }` / `{ h, s, l }` / `{ l, c, h }` objects.
4539
+ */
4540
+ glaze.shadow = function shadow(input) {
4541
+ return standaloneLighting("shadow", input);
4542
+ };
4543
+ /**
4544
+ * Compute a glow over a surface — the mirror of `glaze.shadow()`: the
4545
+ * composite lands `bg` exactly `intensity` tones lighter, and a lighter
4546
+ * object `fg` glows stronger. Accepts the same inputs.
4547
+ */
4548
+ glaze.glow = function glow(input) {
4549
+ return standaloneLighting("glow", input);
4378
4550
  };
4379
4551
  /** Format a resolved color variant as a CSS string. */
4380
4552
  glaze.format = function format(variant, colorFormat, pastel) {