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