@tenphi/glaze 0.4.0 → 0.5.1

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/README.md CHANGED
@@ -22,9 +22,10 @@ Glaze generates robust **light**, **dark**, and **high-contrast** color schemes
22
22
 
23
23
  - **OKHSL color space** — perceptually uniform hue and saturation
24
24
  - **WCAG 2 contrast solving** — automatic lightness adjustment to meet AA/AAA targets
25
+ - **Shadow colors** — OKHSL-native shadow computation with automatic alpha, fg/bg tinting, and per-scheme adaptation
25
26
  - **Light + Dark + High-Contrast** — all schemes from one definition
26
27
  - **Per-color hue override** — absolute or relative hue shifts within a theme
27
- - **Multi-format output** — `okhsl`, `rgb`, `hsl`, `oklch`
28
+ - **Multi-format output** — `okhsl`, `rgb`, `hsl`, `oklch` with modern CSS space syntax
28
29
  - **CSS custom properties export** — ready-to-use `--var: value;` declarations per scheme
29
30
  - **Import/Export** — serialize and restore theme configurations
30
31
  - **Create from hex/RGB** — start from an existing brand color
@@ -290,34 +291,165 @@ brand.colors({
290
291
  });
291
292
  ```
292
293
 
294
+ ## Shadow Colors
295
+
296
+ Shadow colors are colors with computed alpha. Instead of a parallel shadow system, they extend the existing color pipeline. All math is done natively in OKHSL.
297
+
298
+ ### Defining Shadow Colors
299
+
300
+ Shadow colors use `type: 'shadow'` and reference a `bg` (background) color and optionally an `fg` (foreground) color for tinting and intensity modulation:
301
+
302
+ ```ts
303
+ theme.colors({
304
+ surface: { lightness: 95 },
305
+ text: { base: 'surface', lightness: '-52', contrast: 'AAA' },
306
+
307
+ 'shadow-sm': { type: 'shadow', bg: 'surface', fg: 'text', intensity: 5 },
308
+ 'shadow-md': { type: 'shadow', bg: 'surface', fg: 'text', intensity: 10 },
309
+ 'shadow-lg': { type: 'shadow', bg: 'surface', fg: 'text', intensity: 20 },
310
+ });
311
+ ```
312
+
313
+ Shadow colors are included in all output methods (`tokens()`, `tasty()`, `css()`, `json()`) alongside regular colors:
314
+
315
+ ```ts
316
+ theme.tokens({ format: 'oklch' });
317
+ // light: { 'shadow-md': 'oklch(0.15 0.009 282 / 0.1)', ... }
318
+ // dark: { 'shadow-md': 'oklch(0.06 0.004 0 / 0.49)', ... }
319
+ ```
320
+
321
+ ### How Shadows Work
322
+
323
+ The shadow algorithm computes a dark, low-saturation pigment color and an alpha value that produces the desired visual intensity:
324
+
325
+ 1. **Contrast weight** — when `fg` is provided, shadow strength scales with `|l_bg - l_fg|`. Dark text on a light background produces a strong shadow; near-background-lightness elements produce barely visible shadows.
326
+ 2. **Pigment color** — hue blended between fg and bg, low saturation, dark lightness.
327
+ 3. **Alpha** — computed via a `tanh` curve that saturates smoothly toward `alphaMax` (default 0.6), ensuring well-separated shadow levels even on dark backgrounds.
328
+
329
+ ### Achromatic Shadows
330
+
331
+ Omit `fg` for a pure achromatic shadow at full user-specified intensity:
332
+
333
+ ```ts
334
+ theme.colors({
335
+ 'drop-shadow': { type: 'shadow', bg: 'surface', intensity: 12 },
336
+ });
337
+ ```
338
+
339
+ ### High-Contrast Intensity
340
+
341
+ `intensity` supports `[normal, highContrast]` pairs:
342
+
343
+ ```ts
344
+ theme.colors({
345
+ 'shadow-card': { type: 'shadow', bg: 'surface', fg: 'text', intensity: [10, 20] },
346
+ });
347
+ ```
348
+
349
+ ### Fixed Opacity (Regular Colors)
350
+
351
+ For a simple fixed-alpha color (no shadow algorithm), use `opacity` on a regular color:
352
+
353
+ ```ts
354
+ theme.colors({
355
+ overlay: { lightness: 0, opacity: 0.5 },
356
+ });
357
+ // → 'oklch(0 0 0 / 0.5)'
358
+ ```
359
+
360
+ ### Shadow Tuning
361
+
362
+ Fine-tune shadow behavior per-color or globally:
363
+
364
+ ```ts
365
+ // Per-color tuning
366
+ theme.colors({
367
+ 'shadow-soft': {
368
+ type: 'shadow', bg: 'surface', intensity: 10,
369
+ tuning: { alphaMax: 0.3, saturationFactor: 0.1 },
370
+ },
371
+ });
372
+
373
+ // Global tuning
374
+ glaze.configure({
375
+ shadowTuning: { alphaMax: 0.5, bgHueBlend: 0.3 },
376
+ });
377
+ ```
378
+
379
+ Available tuning parameters:
380
+
381
+ | Parameter | Default | Description |
382
+ |---|---|---|
383
+ | `saturationFactor` | 0.18 | Fraction of fg saturation kept in pigment |
384
+ | `maxSaturation` | 0.25 | Upper clamp on pigment saturation |
385
+ | `lightnessFactor` | 0.25 | Multiplier for bg lightness to pigment lightness |
386
+ | `lightnessBounds` | [0.05, 0.20] | Clamp range for pigment lightness |
387
+ | `minGapTarget` | 0.05 | Target minimum gap between pigment and bg lightness |
388
+ | `alphaMax` | 0.6 | Asymptotic maximum alpha |
389
+ | `bgHueBlend` | 0.2 | Blend weight pulling pigment hue toward bg hue |
390
+
391
+ ### Standalone Shadow Computation
392
+
393
+ Compute a shadow outside of a theme:
394
+
395
+ ```ts
396
+ const v = glaze.shadow({
397
+ bg: '#f0eef5',
398
+ fg: '#1a1a2e',
399
+ intensity: 10,
400
+ });
401
+ // → { h: 280, s: 0.14, l: 0.2, alpha: 0.1 }
402
+
403
+ const css = glaze.format(v, 'oklch');
404
+ // → 'oklch(0.15 0.014 280 / 0.1)'
405
+ ```
406
+
407
+ ### Consuming in CSS
408
+
409
+ ```css
410
+ .card {
411
+ box-shadow: 0 2px 6px var(--shadow-sm-color),
412
+ 0 8px 24px var(--shadow-md-color);
413
+ }
414
+ ```
415
+
293
416
  ## Output Formats
294
417
 
295
418
  Control the color format in exports with the `format` option:
296
419
 
297
420
  ```ts
298
421
  // Default: OKHSL
299
- theme.tokens(); // → 'okhsl(280.0 60.0% 97.0%)'
422
+ theme.tokens(); // → 'okhsl(280 60% 97%)'
300
423
 
301
- // RGB with fractional precision
302
- theme.tokens({ format: 'rgb' }); // → 'rgb(244.123, 240.456, 249.789)'
424
+ // RGB (modern space syntax, rounded integers)
425
+ theme.tokens({ format: 'rgb' }); // → 'rgb(244 240 250)'
303
426
 
304
- // HSL
305
- theme.tokens({ format: 'hsl' }); // → 'hsl(270.5, 45.2%, 95.8%)'
427
+ // HSL (modern space syntax)
428
+ theme.tokens({ format: 'hsl' }); // → 'hsl(270.5 45.2% 95.8%)'
306
429
 
307
430
  // OKLCH
308
- theme.tokens({ format: 'oklch' }); // → 'oklch(96.5% 0.0123 280.0)'
431
+ theme.tokens({ format: 'oklch' }); // → 'oklch(0.965 0.0123 280)'
309
432
  ```
310
433
 
311
434
  The `format` option works on all export methods: `theme.tokens()`, `theme.tasty()`, `theme.json()`, `theme.css()`, `palette.tokens()`, `palette.tasty()`, `palette.json()`, `palette.css()`, and standalone `glaze.color().token()` / `.tasty()` / `.json()`.
312
435
 
436
+ Colors with `alpha < 1` (shadow colors, or regular colors with `opacity`) include an alpha component:
437
+
438
+ ```ts
439
+ // → 'oklch(0.15 0.009 282 / 0.1)'
440
+ // → 'rgb(34 28 42 / 0.1)'
441
+ ```
442
+
313
443
  Available formats:
314
444
 
315
- | Format | Output | Notes |
316
- |---|---|---|
317
- | `'okhsl'` (default) | `okhsl(H S% L%)` | Native format, perceptually uniform |
318
- | `'rgb'` | `rgb(R, G, B)` | Fractional 0–255 values (3 decimals) |
319
- | `'hsl'` | `hsl(H, S%, L%)` | Standard CSS HSL |
320
- | `'oklch'` | `oklch(L% C H)` | OKLab-based LCH |
445
+ | Format | Output (alpha = 1) | Output (alpha < 1) | Notes |
446
+ |---|---|---|---|
447
+ | `'okhsl'` (default) | `okhsl(H S% L%)` | `okhsl(H S% L% / A)` | Native format, not a CSS function |
448
+ | `'rgb'` | `rgb(R G B)` | `rgb(R G B / A)` | Rounded integers, space syntax |
449
+ | `'hsl'` | `hsl(H S% L%)` | `hsl(H S% L% / A)` | Modern space syntax |
450
+ | `'oklch'` | `oklch(L C H)` | `oklch(L C H / A)` | OKLab-based LCH |
451
+
452
+ All numeric output strips trailing zeros for cleaner CSS (e.g., `95` not `95.0`).
321
453
 
322
454
  ## Adaptation Modes
323
455
 
@@ -597,39 +729,40 @@ glaze.configure({
597
729
  dark: true, // Include dark variants in exports
598
730
  highContrast: false, // Include high-contrast variants
599
731
  },
732
+ shadowTuning: { // Default tuning for all shadow colors
733
+ alphaMax: 0.6,
734
+ bgHueBlend: 0.2,
735
+ },
600
736
  });
601
737
  ```
602
738
 
603
739
  ## Color Definition Shape
604
740
 
741
+ `ColorDef` is a discriminated union of regular colors and shadow colors:
742
+
605
743
  ```ts
606
- type RelativeValue = `+${number}` | `-${number}`;
607
- type HCPair<T> = T | [T, T]; // [normal, high-contrast]
744
+ type ColorDef = RegularColorDef | ShadowColorDef;
608
745
 
609
- interface ColorDef {
610
- // Lightness
746
+ interface RegularColorDef {
611
747
  lightness?: HCPair<number | RelativeValue>;
612
- // Number: absolute (0–100)
613
- // String: relative to base ('+N' / '-N')
614
-
615
- // Hue override
616
- hue?: number | RelativeValue;
617
- // Number: absolute (0–360)
618
- // String: relative to theme seed ('+N' / '-N')
619
-
620
- // Saturation factor (0–1, default: 1)
621
748
  saturation?: number;
749
+ hue?: number | RelativeValue;
750
+ base?: string;
751
+ contrast?: HCPair<MinContrast>;
752
+ mode?: 'auto' | 'fixed' | 'static';
753
+ opacity?: number; // fixed alpha (0–1)
754
+ }
622
755
 
623
- // Dependency
624
- base?: string; // name of another color
625
- contrast?: HCPair<MinContrast>; // WCAG contrast ratio floor against base
626
-
627
- // Adaptation mode
628
- mode?: 'auto' | 'fixed' | 'static'; // default: 'auto'
756
+ interface ShadowColorDef {
757
+ type: 'shadow';
758
+ bg: string; // background color name (non-shadow)
759
+ fg?: string; // foreground color name (non-shadow)
760
+ intensity: HCPair<number>; // 0–100
761
+ tuning?: ShadowTuning;
629
762
  }
630
763
  ```
631
764
 
632
- A root color must have absolute `lightness` (a number). A dependent color must have `base`. Relative `lightness` (a string) requires `base`.
765
+ A root color must have absolute `lightness` (a number). A dependent color must have `base`. Relative `lightness` (a string) requires `base`. Shadow colors use `type: 'shadow'` and must reference a non-shadow `bg` color.
633
766
 
634
767
  ## Validation
635
768
 
@@ -642,6 +775,13 @@ A root color must have absolute `lightness` (a number). A dependent color must h
642
775
  | `saturation` outside 0–1 | Clamp silently |
643
776
  | Circular `base` references | Validation error |
644
777
  | `base` references non-existent name | Validation error |
778
+ | Shadow `bg` references non-existent color | Validation error |
779
+ | Shadow `fg` references non-existent color | Validation error |
780
+ | Shadow `bg` references another shadow color | Validation error |
781
+ | Shadow `fg` references another shadow color | Validation error |
782
+ | Regular color `base` references a shadow color | Validation error |
783
+ | Shadow `intensity` outside 0–100 | Clamp silently |
784
+ | `contrast` + `opacity` combined | Warning |
645
785
 
646
786
  ## Advanced: Color Math Utilities
647
787
 
@@ -681,6 +821,14 @@ primary.colors({
681
821
  'accent-fill': { lightness: 52, mode: 'fixed' },
682
822
  'accent-text': { base: 'accent-fill', lightness: '+48', contrast: 'AA', mode: 'fixed' },
683
823
  disabled: { lightness: 81, saturation: 0.4 },
824
+
825
+ // Shadow colors — computed alpha, automatic dark-mode adaptation
826
+ 'shadow-sm': { type: 'shadow', bg: 'surface', fg: 'text', intensity: 5 },
827
+ 'shadow-md': { type: 'shadow', bg: 'surface', fg: 'text', intensity: 10 },
828
+ 'shadow-lg': { type: 'shadow', bg: 'surface', fg: 'text', intensity: 20 },
829
+
830
+ // Fixed-alpha overlay
831
+ overlay: { lightness: 0, opacity: 0.5 },
684
832
  });
685
833
 
686
834
  const danger = primary.extend({ hue: 23 });
@@ -692,21 +840,19 @@ const palette = glaze.palette({ primary, danger, success, warning, note });
692
840
 
693
841
  // Export as flat token map grouped by variant
694
842
  const tokens = palette.tokens({ prefix: true });
695
- // tokens.light → { 'primary-surface': 'okhsl(...)', 'danger-surface': 'okhsl(...)' }
696
- // tokens.dark → { 'primary-surface': 'okhsl(...)', 'danger-surface': 'okhsl(...)' }
843
+ // tokens.light → { 'primary-surface': 'okhsl(...)', 'primary-shadow-md': 'okhsl(... / 0.1)' }
697
844
 
698
845
  // Export as tasty style-to-state bindings (for Tasty style system)
699
846
  const tastyTokens = palette.tasty({ prefix: true });
700
- // tastyTokens['#primary-surface'] → { '': 'okhsl(...)', '@dark': 'okhsl(...)' }
701
- // Use as a recipe or spread into component styles (see Tasty Export section)
702
-
703
- // Export as RGB for broader CSS compatibility
704
- const rgbTokens = palette.tokens({ prefix: true, format: 'rgb' });
705
847
 
706
848
  // Export as CSS custom properties (rgb format by default)
707
849
  const css = palette.css({ prefix: true });
708
- // css.light → "--primary-surface-color: rgb(...);\n--danger-surface-color: rgb(...);"
709
- // css.dark → "--primary-surface-color: rgb(...);\n--danger-surface-color: rgb(...);"
850
+ // css.light → "--primary-surface-color: rgb(...);\n--primary-shadow-md-color: rgb(... / 0.1);"
851
+
852
+ // Standalone shadow computation
853
+ const v = glaze.shadow({ bg: '#f0eef5', fg: '#1a1a2e', intensity: 10 });
854
+ const shadowCss = glaze.format(v, 'oklch');
855
+ // → 'oklch(0.15 0.014 280 / 0.1)'
710
856
 
711
857
  // Save and restore a theme
712
858
  const snapshot = primary.export();
@@ -729,6 +875,8 @@ brand.colors({ surface: { lightness: 97 }, text: { base: 'surface', lightness: '
729
875
  | `glaze.fromHex(hex)` | Create a theme from a hex color (`#rgb` or `#rrggbb`) |
730
876
  | `glaze.fromRgb(r, g, b)` | Create a theme from RGB values (0–255) |
731
877
  | `glaze.color(input)` | Create a standalone color token |
878
+ | `glaze.shadow(input)` | Compute a standalone shadow color (returns `ResolvedColorVariant`) |
879
+ | `glaze.format(variant, format?)` | Format any `ResolvedColorVariant` as a CSS string |
732
880
 
733
881
  ### Theme Methods
734
882
 
package/dist/index.cjs CHANGED
@@ -441,23 +441,26 @@ function parseHex(hex) {
441
441
  }
442
442
  return null;
443
443
  }
444
+ function fmt$1(value, decimals) {
445
+ return parseFloat(value.toFixed(decimals)).toString();
446
+ }
444
447
  /**
445
448
  * Format OKHSL values as a CSS `okhsl(H S% L%)` string.
446
449
  * h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
447
450
  */
448
451
  function formatOkhsl(h, s, l) {
449
- return `okhsl(${h.toFixed(1)} ${s.toFixed(1)}% ${l.toFixed(1)}%)`;
452
+ return `okhsl(${fmt$1(h, 1)} ${fmt$1(s, 1)}% ${fmt$1(l, 1)}%)`;
450
453
  }
451
454
  /**
452
- * Format OKHSL values as a CSS `rgb(R, G, B)` string with fractional 0–255 values.
455
+ * Format OKHSL values as a CSS `rgb(R G B)` string with rounded integer values.
453
456
  * h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
454
457
  */
455
458
  function formatRgb(h, s, l) {
456
459
  const [r, g, b] = okhslToSrgb(h, s / 100, l / 100);
457
- return `rgb(${(r * 255).toFixed(3)}, ${(g * 255).toFixed(3)}, ${(b * 255).toFixed(3)})`;
460
+ return `rgb(${Math.round(r * 255)} ${Math.round(g * 255)} ${Math.round(b * 255)})`;
458
461
  }
459
462
  /**
460
- * Format OKHSL values as a CSS `hsl(H, S%, L%)` string.
463
+ * Format OKHSL values as a CSS `hsl(H S% L%)` string.
461
464
  * h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
462
465
  */
463
466
  function formatHsl(h, s, l) {
@@ -474,10 +477,10 @@ function formatHsl(h, s, l) {
474
477
  else if (max === g) hh = ((b - r) / delta + 2) * 60;
475
478
  else hh = ((r - g) / delta + 4) * 60;
476
479
  }
477
- return `hsl(${hh.toFixed(1)}, ${(ss * 100).toFixed(1)}%, ${(ll * 100).toFixed(1)}%)`;
480
+ return `hsl(${fmt$1(hh, 1)} ${fmt$1(ss * 100, 1)}% ${fmt$1(ll * 100, 1)}%)`;
478
481
  }
479
482
  /**
480
- * Format OKHSL values as a CSS `oklch(L% C H)` string.
483
+ * Format OKHSL values as a CSS `oklch(L C H)` string.
481
484
  * h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
482
485
  */
483
486
  function formatOklch(h, s, l) {
@@ -485,7 +488,7 @@ function formatOklch(h, s, l) {
485
488
  const C = Math.sqrt(a * a + b * b);
486
489
  let hh = Math.atan2(b, a) * (180 / Math.PI);
487
490
  hh = constrainAngle(hh);
488
- return `oklch(${(L * 100).toFixed(1)}% ${C.toFixed(4)} ${hh.toFixed(1)})`;
491
+ return `oklch(${fmt$1(L, 4)} ${fmt$1(C, 4)} ${fmt$1(hh, 1)})`;
489
492
  }
490
493
 
491
494
  //#endregion
@@ -711,14 +714,84 @@ function pairNormal(p) {
711
714
  function pairHC(p) {
712
715
  return Array.isArray(p) ? p[1] : p;
713
716
  }
717
+ function isShadowDef(def) {
718
+ return def.type === "shadow";
719
+ }
720
+ const DEFAULT_SHADOW_TUNING = {
721
+ saturationFactor: .18,
722
+ maxSaturation: .25,
723
+ lightnessFactor: .25,
724
+ lightnessBounds: [.05, .2],
725
+ minGapTarget: .05,
726
+ alphaMax: 1,
727
+ bgHueBlend: .2
728
+ };
729
+ function resolveShadowTuning(perColor) {
730
+ return {
731
+ ...DEFAULT_SHADOW_TUNING,
732
+ ...globalConfig.shadowTuning,
733
+ ...perColor,
734
+ lightnessBounds: perColor?.lightnessBounds ?? globalConfig.shadowTuning?.lightnessBounds ?? DEFAULT_SHADOW_TUNING.lightnessBounds
735
+ };
736
+ }
737
+ function circularLerp(a, b, t) {
738
+ let diff = b - a;
739
+ if (diff > 180) diff -= 360;
740
+ else if (diff < -180) diff += 360;
741
+ return ((a + diff * t) % 360 + 360) % 360;
742
+ }
743
+ /**
744
+ * Compute the canonical max-contrast reference t value for normalization.
745
+ * Uses bg.l=1, fg.l=0, intensity=100 — the theoretical maximum.
746
+ * This is a fixed constant per tuning configuration, ensuring uniform
747
+ * scaling across all bg/fg pairs at low intensities.
748
+ */
749
+ function computeRefT(tuning) {
750
+ const EPSILON = 1e-6;
751
+ let lShRef = clamp(tuning.lightnessFactor, tuning.lightnessBounds[0], tuning.lightnessBounds[1]);
752
+ lShRef = Math.max(Math.min(lShRef, 1 - tuning.minGapTarget), 0);
753
+ return 1 / Math.max(1 - lShRef, EPSILON);
754
+ }
755
+ function computeShadow(bg, fg, intensity, tuning) {
756
+ const EPSILON = 1e-6;
757
+ const clampedIntensity = clamp(intensity, 0, 100);
758
+ const contrastWeight = fg ? Math.abs(bg.l - fg.l) : 1;
759
+ const deltaL = clampedIntensity / 100 * contrastWeight;
760
+ const h = fg ? circularLerp(fg.h, bg.h, tuning.bgHueBlend) : bg.h;
761
+ const s = fg ? Math.min(fg.s * tuning.saturationFactor, tuning.maxSaturation) : 0;
762
+ let lSh = clamp(bg.l * tuning.lightnessFactor, tuning.lightnessBounds[0], tuning.lightnessBounds[1]);
763
+ lSh = Math.max(Math.min(lSh, bg.l - tuning.minGapTarget), 0);
764
+ const t = deltaL / Math.max(bg.l - lSh, EPSILON);
765
+ const tRef = computeRefT(tuning);
766
+ const norm = Math.tanh(tRef / tuning.alphaMax);
767
+ const alpha = Math.min(tuning.alphaMax * Math.tanh(t / tuning.alphaMax) / norm, tuning.alphaMax);
768
+ return {
769
+ h,
770
+ s,
771
+ l: lSh,
772
+ alpha
773
+ };
774
+ }
714
775
  function validateColorDefs(defs) {
715
776
  const names = new Set(Object.keys(defs));
716
777
  for (const [name, def] of Object.entries(defs)) {
717
- if (def.contrast !== void 0 && !def.base) throw new Error(`glaze: color "${name}" has "contrast" without "base".`);
718
- if (def.lightness !== void 0 && !isAbsoluteLightness(def.lightness) && !def.base) throw new Error(`glaze: color "${name}" has relative "lightness" without "base".`);
719
- if (isAbsoluteLightness(def.lightness) && def.base !== void 0) console.warn(`glaze: color "${name}" has absolute "lightness" and "base". Absolute lightness takes precedence.`);
720
- if (def.base && !names.has(def.base)) throw new Error(`glaze: color "${name}" references non-existent base "${def.base}".`);
721
- if (!isAbsoluteLightness(def.lightness) && def.base === void 0) throw new Error(`glaze: color "${name}" must have either absolute "lightness" (root) or "base" (dependent).`);
778
+ if (isShadowDef(def)) {
779
+ if (!names.has(def.bg)) throw new Error(`glaze: shadow "${name}" references non-existent bg "${def.bg}".`);
780
+ if (isShadowDef(defs[def.bg])) throw new Error(`glaze: shadow "${name}" bg "${def.bg}" references another shadow color.`);
781
+ if (def.fg !== void 0) {
782
+ if (!names.has(def.fg)) throw new Error(`glaze: shadow "${name}" references non-existent fg "${def.fg}".`);
783
+ if (isShadowDef(defs[def.fg])) throw new Error(`glaze: shadow "${name}" fg "${def.fg}" references another shadow color.`);
784
+ }
785
+ continue;
786
+ }
787
+ const regDef = def;
788
+ if (regDef.contrast !== void 0 && !regDef.base) throw new Error(`glaze: color "${name}" has "contrast" without "base".`);
789
+ if (regDef.lightness !== void 0 && !isAbsoluteLightness(regDef.lightness) && !regDef.base) throw new Error(`glaze: color "${name}" has relative "lightness" without "base".`);
790
+ if (isAbsoluteLightness(regDef.lightness) && regDef.base !== void 0) console.warn(`glaze: color "${name}" has absolute "lightness" and "base". Absolute lightness takes precedence.`);
791
+ if (regDef.base && !names.has(regDef.base)) throw new Error(`glaze: color "${name}" references non-existent base "${regDef.base}".`);
792
+ if (regDef.base && isShadowDef(defs[regDef.base])) throw new Error(`glaze: color "${name}" base "${regDef.base}" references a shadow color.`);
793
+ if (!isAbsoluteLightness(regDef.lightness) && regDef.base === void 0) throw new Error(`glaze: color "${name}" must have either absolute "lightness" (root) or "base" (dependent).`);
794
+ if (regDef.contrast !== void 0 && regDef.opacity !== void 0) console.warn(`glaze: color "${name}" has both "contrast" and "opacity". Opacity makes perceived lightness unpredictable.`);
722
795
  }
723
796
  const visited = /* @__PURE__ */ new Set();
724
797
  const inStack = /* @__PURE__ */ new Set();
@@ -727,7 +800,13 @@ function validateColorDefs(defs) {
727
800
  if (visited.has(name)) return;
728
801
  inStack.add(name);
729
802
  const def = defs[name];
730
- if (def.base && !isAbsoluteLightness(def.lightness)) dfs(def.base);
803
+ if (isShadowDef(def)) {
804
+ dfs(def.bg);
805
+ if (def.fg) dfs(def.fg);
806
+ } else {
807
+ const regDef = def;
808
+ if (regDef.base && !isAbsoluteLightness(regDef.lightness)) dfs(regDef.base);
809
+ }
731
810
  inStack.delete(name);
732
811
  visited.add(name);
733
812
  }
@@ -740,7 +819,13 @@ function topoSort(defs) {
740
819
  if (visited.has(name)) return;
741
820
  visited.add(name);
742
821
  const def = defs[name];
743
- if (def.base && !isAbsoluteLightness(def.lightness)) visit(def.base);
822
+ if (isShadowDef(def)) {
823
+ visit(def.bg);
824
+ if (def.fg) visit(def.fg);
825
+ } else {
826
+ const regDef = def;
827
+ if (regDef.base && !isAbsoluteLightness(regDef.lightness)) visit(regDef.base);
828
+ }
744
829
  result.push(name);
745
830
  }
746
831
  for (const name of Object.keys(defs)) visit(name);
@@ -804,11 +889,8 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, effective
804
889
  if (!baseResolved) throw new Error(`glaze: base "${baseName}" not yet resolved for "${name}".`);
805
890
  const mode = def.mode ?? "auto";
806
891
  const satFactor = clamp(def.saturation ?? 1, 0, 1);
807
- let baseL;
808
- if (isDark && isHighContrast) baseL = baseResolved.darkContrast.l * 100;
809
- else if (isDark) baseL = baseResolved.dark.l * 100;
810
- else if (isHighContrast) baseL = baseResolved.lightContrast.l * 100;
811
- else baseL = baseResolved.light.l * 100;
892
+ const baseVariant = getSchemeVariant(baseResolved, isDark, isHighContrast);
893
+ const baseL = baseVariant.l * 100;
812
894
  let preferredL;
813
895
  const rawLightness = def.lightness;
814
896
  if (rawLightness === void 0) preferredL = baseL;
@@ -825,27 +907,7 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, effective
825
907
  if (rawContrast !== void 0) {
826
908
  const minCr = isHighContrast ? pairHC(rawContrast) : pairNormal(rawContrast);
827
909
  const effectiveSat = isDark ? mapSaturationDark(satFactor * ctx.saturation / 100, mode) : satFactor * ctx.saturation / 100;
828
- let baseH;
829
- let baseS;
830
- let baseLNorm;
831
- if (isDark && isHighContrast) {
832
- baseH = baseResolved.darkContrast.h;
833
- baseS = baseResolved.darkContrast.s;
834
- baseLNorm = baseResolved.darkContrast.l;
835
- } else if (isDark) {
836
- baseH = baseResolved.dark.h;
837
- baseS = baseResolved.dark.s;
838
- baseLNorm = baseResolved.dark.l;
839
- } else if (isHighContrast) {
840
- baseH = baseResolved.lightContrast.h;
841
- baseS = baseResolved.lightContrast.s;
842
- baseLNorm = baseResolved.lightContrast.l;
843
- } else {
844
- baseH = baseResolved.light.h;
845
- baseS = baseResolved.light.s;
846
- baseLNorm = baseResolved.light.l;
847
- }
848
- const baseLinearRgb = okhslToLinearSrgb(baseH, baseS, baseLNorm);
910
+ const baseLinearRgb = okhslToLinearSrgb(baseVariant.h, baseVariant.s, baseVariant.l);
849
911
  return {
850
912
  l: findLightnessForContrast({
851
913
  hue: effectiveHue,
@@ -862,18 +924,26 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, effective
862
924
  satFactor
863
925
  };
864
926
  }
927
+ function getSchemeVariant(color, isDark, isHighContrast) {
928
+ if (isDark && isHighContrast) return color.darkContrast;
929
+ if (isDark) return color.dark;
930
+ if (isHighContrast) return color.lightContrast;
931
+ return color.light;
932
+ }
865
933
  function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
866
- const mode = def.mode ?? "auto";
867
- const isRoot = isAbsoluteLightness(def.lightness) && !def.base;
868
- const effectiveHue = resolveEffectiveHue(ctx.hue, def.hue);
934
+ if (isShadowDef(def)) return resolveShadowForScheme(def, ctx, isDark, isHighContrast);
935
+ const regDef = def;
936
+ const mode = regDef.mode ?? "auto";
937
+ const isRoot = isAbsoluteLightness(regDef.lightness) && !regDef.base;
938
+ const effectiveHue = resolveEffectiveHue(ctx.hue, regDef.hue);
869
939
  let lightL;
870
940
  let satFactor;
871
941
  if (isRoot) {
872
- const root = resolveRootColor(name, def, ctx, isHighContrast);
942
+ const root = resolveRootColor(name, regDef, ctx, isHighContrast);
873
943
  lightL = root.lightL;
874
944
  satFactor = root.satFactor;
875
945
  } else {
876
- const dep = resolveDependentColor(name, def, ctx, isHighContrast, isDark, effectiveHue);
946
+ const dep = resolveDependentColor(name, regDef, ctx, isHighContrast, isDark, effectiveHue);
877
947
  lightL = dep.l;
878
948
  satFactor = dep.satFactor;
879
949
  }
@@ -892,9 +962,18 @@ function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
892
962
  return {
893
963
  h: effectiveHue,
894
964
  s: clamp(finalSat, 0, 1),
895
- l: clamp(finalL / 100, 0, 1)
965
+ l: clamp(finalL / 100, 0, 1),
966
+ alpha: regDef.opacity ?? 1
896
967
  };
897
968
  }
969
+ function resolveShadowForScheme(def, ctx, isDark, isHighContrast) {
970
+ const bgVariant = getSchemeVariant(ctx.resolved.get(def.bg), isDark, isHighContrast);
971
+ let fgVariant;
972
+ if (def.fg) fgVariant = getSchemeVariant(ctx.resolved.get(def.fg), isDark, isHighContrast);
973
+ const intensity = isHighContrast ? pairHC(def.intensity) : pairNormal(def.intensity);
974
+ const tuning = resolveShadowTuning(def.tuning);
975
+ return computeShadow(bgVariant, fgVariant, intensity, tuning);
976
+ }
898
977
  function resolveAllColors(hue, saturation, defs) {
899
978
  validateColorDefs(defs);
900
979
  const order = topoSort(defs);
@@ -904,6 +983,9 @@ function resolveAllColors(hue, saturation, defs) {
904
983
  defs,
905
984
  resolved: /* @__PURE__ */ new Map()
906
985
  };
986
+ function defMode(def) {
987
+ return isShadowDef(def) ? void 0 : def.mode ?? "auto";
988
+ }
907
989
  const lightMap = /* @__PURE__ */ new Map();
908
990
  for (const name of order) {
909
991
  const variant = resolveColorForScheme(name, defs[name], ctx, false, false);
@@ -914,7 +996,7 @@ function resolveAllColors(hue, saturation, defs) {
914
996
  dark: variant,
915
997
  lightContrast: variant,
916
998
  darkContrast: variant,
917
- mode: defs[name].mode ?? "auto"
999
+ mode: defMode(defs[name])
918
1000
  });
919
1001
  }
920
1002
  const lightHCMap = /* @__PURE__ */ new Map();
@@ -937,7 +1019,7 @@ function resolveAllColors(hue, saturation, defs) {
937
1019
  dark: lightMap.get(name),
938
1020
  lightContrast: lightHCMap.get(name),
939
1021
  darkContrast: lightHCMap.get(name),
940
- mode: defs[name].mode ?? "auto"
1022
+ mode: defMode(defs[name])
941
1023
  });
942
1024
  for (const name of order) {
943
1025
  const variant = resolveColorForScheme(name, defs[name], ctx, true, false);
@@ -967,7 +1049,7 @@ function resolveAllColors(hue, saturation, defs) {
967
1049
  dark: darkMap.get(name),
968
1050
  lightContrast: lightHCMap.get(name),
969
1051
  darkContrast: darkHCMap.get(name),
970
- mode: defs[name].mode ?? "auto"
1052
+ mode: defMode(defs[name])
971
1053
  });
972
1054
  return result;
973
1055
  }
@@ -977,8 +1059,14 @@ const formatters = {
977
1059
  hsl: formatHsl,
978
1060
  oklch: formatOklch
979
1061
  };
1062
+ function fmt(value, decimals) {
1063
+ return parseFloat(value.toFixed(decimals)).toString();
1064
+ }
980
1065
  function formatVariant(v, format = "okhsl") {
981
- return formatters[format](v.h, v.s * 100, v.l * 100);
1066
+ const base = formatters[format](v.h, v.s * 100, v.l * 100);
1067
+ if (v.alpha >= 1) return base;
1068
+ const closing = base.lastIndexOf(")");
1069
+ return `${base.slice(0, closing)} / ${fmt(v.alpha, 4)})`;
982
1070
  }
983
1071
  function resolveModes(override) {
984
1072
  return {
@@ -1229,7 +1317,8 @@ glaze.configure = function configure(config) {
1229
1317
  modes: {
1230
1318
  dark: config.modes?.dark ?? globalConfig.modes.dark,
1231
1319
  highContrast: config.modes?.highContrast ?? globalConfig.modes.highContrast
1232
- }
1320
+ },
1321
+ shadowTuning: config.shadowTuning ?? globalConfig.shadowTuning
1233
1322
  };
1234
1323
  };
1235
1324
  /**
@@ -1251,6 +1340,40 @@ glaze.color = function color(input) {
1251
1340
  return createColorToken(input);
1252
1341
  };
1253
1342
  /**
1343
+ * Compute a shadow color from a bg/fg pair and intensity.
1344
+ */
1345
+ glaze.shadow = function shadow(input) {
1346
+ const bg = parseOkhslInput(input.bg);
1347
+ const fg = input.fg ? parseOkhslInput(input.fg) : void 0;
1348
+ const tuning = resolveShadowTuning(input.tuning);
1349
+ return computeShadow({
1350
+ ...bg,
1351
+ alpha: 1
1352
+ }, fg ? {
1353
+ ...fg,
1354
+ alpha: 1
1355
+ } : void 0, input.intensity, tuning);
1356
+ };
1357
+ /**
1358
+ * Format a resolved color variant as a CSS string.
1359
+ */
1360
+ glaze.format = function format(variant, colorFormat) {
1361
+ return formatVariant(variant, colorFormat);
1362
+ };
1363
+ function parseOkhslInput(input) {
1364
+ if (typeof input === "string") {
1365
+ const rgb = parseHex(input);
1366
+ if (!rgb) throw new Error(`glaze: invalid hex color "${input}".`);
1367
+ const [h, s, l] = srgbToOkhsl(rgb);
1368
+ return {
1369
+ h,
1370
+ s,
1371
+ l
1372
+ };
1373
+ }
1374
+ return input;
1375
+ }
1376
+ /**
1254
1377
  * Create a theme from a hex color string.
1255
1378
  * Extracts hue and saturation from the color.
1256
1379
  */