@tenphi/glaze 0.4.0 → 0.5.0

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,70 @@ 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: .6,
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
+ function computeShadow(bg, fg, intensity, tuning) {
744
+ const EPSILON = 1e-6;
745
+ const clampedIntensity = clamp(intensity, 0, 100);
746
+ const contrastWeight = fg ? Math.abs(bg.l - fg.l) : 1;
747
+ const deltaL = clampedIntensity / 100 * contrastWeight;
748
+ const h = fg ? circularLerp(fg.h, bg.h, tuning.bgHueBlend) : bg.h;
749
+ const s = fg ? Math.min(fg.s * tuning.saturationFactor, tuning.maxSaturation) : 0;
750
+ let lSh = clamp(bg.l * tuning.lightnessFactor, tuning.lightnessBounds[0], tuning.lightnessBounds[1]);
751
+ lSh = Math.max(Math.min(lSh, bg.l - tuning.minGapTarget), 0);
752
+ const t = deltaL / Math.max(bg.l - lSh, EPSILON);
753
+ const alpha = tuning.alphaMax * Math.tanh(t / tuning.alphaMax);
754
+ return {
755
+ h,
756
+ s,
757
+ l: lSh,
758
+ alpha
759
+ };
760
+ }
714
761
  function validateColorDefs(defs) {
715
762
  const names = new Set(Object.keys(defs));
716
763
  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).`);
764
+ if (isShadowDef(def)) {
765
+ if (!names.has(def.bg)) throw new Error(`glaze: shadow "${name}" references non-existent bg "${def.bg}".`);
766
+ if (isShadowDef(defs[def.bg])) throw new Error(`glaze: shadow "${name}" bg "${def.bg}" references another shadow color.`);
767
+ if (def.fg !== void 0) {
768
+ if (!names.has(def.fg)) throw new Error(`glaze: shadow "${name}" references non-existent fg "${def.fg}".`);
769
+ if (isShadowDef(defs[def.fg])) throw new Error(`glaze: shadow "${name}" fg "${def.fg}" references another shadow color.`);
770
+ }
771
+ continue;
772
+ }
773
+ const regDef = def;
774
+ if (regDef.contrast !== void 0 && !regDef.base) throw new Error(`glaze: color "${name}" has "contrast" without "base".`);
775
+ if (regDef.lightness !== void 0 && !isAbsoluteLightness(regDef.lightness) && !regDef.base) throw new Error(`glaze: color "${name}" has relative "lightness" without "base".`);
776
+ if (isAbsoluteLightness(regDef.lightness) && regDef.base !== void 0) console.warn(`glaze: color "${name}" has absolute "lightness" and "base". Absolute lightness takes precedence.`);
777
+ if (regDef.base && !names.has(regDef.base)) throw new Error(`glaze: color "${name}" references non-existent base "${regDef.base}".`);
778
+ if (regDef.base && isShadowDef(defs[regDef.base])) throw new Error(`glaze: color "${name}" base "${regDef.base}" references a shadow color.`);
779
+ if (!isAbsoluteLightness(regDef.lightness) && regDef.base === void 0) throw new Error(`glaze: color "${name}" must have either absolute "lightness" (root) or "base" (dependent).`);
780
+ 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
781
  }
723
782
  const visited = /* @__PURE__ */ new Set();
724
783
  const inStack = /* @__PURE__ */ new Set();
@@ -727,7 +786,13 @@ function validateColorDefs(defs) {
727
786
  if (visited.has(name)) return;
728
787
  inStack.add(name);
729
788
  const def = defs[name];
730
- if (def.base && !isAbsoluteLightness(def.lightness)) dfs(def.base);
789
+ if (isShadowDef(def)) {
790
+ dfs(def.bg);
791
+ if (def.fg) dfs(def.fg);
792
+ } else {
793
+ const regDef = def;
794
+ if (regDef.base && !isAbsoluteLightness(regDef.lightness)) dfs(regDef.base);
795
+ }
731
796
  inStack.delete(name);
732
797
  visited.add(name);
733
798
  }
@@ -740,7 +805,13 @@ function topoSort(defs) {
740
805
  if (visited.has(name)) return;
741
806
  visited.add(name);
742
807
  const def = defs[name];
743
- if (def.base && !isAbsoluteLightness(def.lightness)) visit(def.base);
808
+ if (isShadowDef(def)) {
809
+ visit(def.bg);
810
+ if (def.fg) visit(def.fg);
811
+ } else {
812
+ const regDef = def;
813
+ if (regDef.base && !isAbsoluteLightness(regDef.lightness)) visit(regDef.base);
814
+ }
744
815
  result.push(name);
745
816
  }
746
817
  for (const name of Object.keys(defs)) visit(name);
@@ -804,11 +875,8 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, effective
804
875
  if (!baseResolved) throw new Error(`glaze: base "${baseName}" not yet resolved for "${name}".`);
805
876
  const mode = def.mode ?? "auto";
806
877
  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;
878
+ const baseVariant = getSchemeVariant(baseResolved, isDark, isHighContrast);
879
+ const baseL = baseVariant.l * 100;
812
880
  let preferredL;
813
881
  const rawLightness = def.lightness;
814
882
  if (rawLightness === void 0) preferredL = baseL;
@@ -825,27 +893,7 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, effective
825
893
  if (rawContrast !== void 0) {
826
894
  const minCr = isHighContrast ? pairHC(rawContrast) : pairNormal(rawContrast);
827
895
  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);
896
+ const baseLinearRgb = okhslToLinearSrgb(baseVariant.h, baseVariant.s, baseVariant.l);
849
897
  return {
850
898
  l: findLightnessForContrast({
851
899
  hue: effectiveHue,
@@ -862,18 +910,26 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, effective
862
910
  satFactor
863
911
  };
864
912
  }
913
+ function getSchemeVariant(color, isDark, isHighContrast) {
914
+ if (isDark && isHighContrast) return color.darkContrast;
915
+ if (isDark) return color.dark;
916
+ if (isHighContrast) return color.lightContrast;
917
+ return color.light;
918
+ }
865
919
  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);
920
+ if (isShadowDef(def)) return resolveShadowForScheme(def, ctx, isDark, isHighContrast);
921
+ const regDef = def;
922
+ const mode = regDef.mode ?? "auto";
923
+ const isRoot = isAbsoluteLightness(regDef.lightness) && !regDef.base;
924
+ const effectiveHue = resolveEffectiveHue(ctx.hue, regDef.hue);
869
925
  let lightL;
870
926
  let satFactor;
871
927
  if (isRoot) {
872
- const root = resolveRootColor(name, def, ctx, isHighContrast);
928
+ const root = resolveRootColor(name, regDef, ctx, isHighContrast);
873
929
  lightL = root.lightL;
874
930
  satFactor = root.satFactor;
875
931
  } else {
876
- const dep = resolveDependentColor(name, def, ctx, isHighContrast, isDark, effectiveHue);
932
+ const dep = resolveDependentColor(name, regDef, ctx, isHighContrast, isDark, effectiveHue);
877
933
  lightL = dep.l;
878
934
  satFactor = dep.satFactor;
879
935
  }
@@ -892,9 +948,18 @@ function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
892
948
  return {
893
949
  h: effectiveHue,
894
950
  s: clamp(finalSat, 0, 1),
895
- l: clamp(finalL / 100, 0, 1)
951
+ l: clamp(finalL / 100, 0, 1),
952
+ alpha: regDef.opacity ?? 1
896
953
  };
897
954
  }
955
+ function resolveShadowForScheme(def, ctx, isDark, isHighContrast) {
956
+ const bgVariant = getSchemeVariant(ctx.resolved.get(def.bg), isDark, isHighContrast);
957
+ let fgVariant;
958
+ if (def.fg) fgVariant = getSchemeVariant(ctx.resolved.get(def.fg), isDark, isHighContrast);
959
+ const intensity = isHighContrast ? pairHC(def.intensity) : pairNormal(def.intensity);
960
+ const tuning = resolveShadowTuning(def.tuning);
961
+ return computeShadow(bgVariant, fgVariant, intensity, tuning);
962
+ }
898
963
  function resolveAllColors(hue, saturation, defs) {
899
964
  validateColorDefs(defs);
900
965
  const order = topoSort(defs);
@@ -904,6 +969,9 @@ function resolveAllColors(hue, saturation, defs) {
904
969
  defs,
905
970
  resolved: /* @__PURE__ */ new Map()
906
971
  };
972
+ function defMode(def) {
973
+ return isShadowDef(def) ? void 0 : def.mode ?? "auto";
974
+ }
907
975
  const lightMap = /* @__PURE__ */ new Map();
908
976
  for (const name of order) {
909
977
  const variant = resolveColorForScheme(name, defs[name], ctx, false, false);
@@ -914,7 +982,7 @@ function resolveAllColors(hue, saturation, defs) {
914
982
  dark: variant,
915
983
  lightContrast: variant,
916
984
  darkContrast: variant,
917
- mode: defs[name].mode ?? "auto"
985
+ mode: defMode(defs[name])
918
986
  });
919
987
  }
920
988
  const lightHCMap = /* @__PURE__ */ new Map();
@@ -937,7 +1005,7 @@ function resolveAllColors(hue, saturation, defs) {
937
1005
  dark: lightMap.get(name),
938
1006
  lightContrast: lightHCMap.get(name),
939
1007
  darkContrast: lightHCMap.get(name),
940
- mode: defs[name].mode ?? "auto"
1008
+ mode: defMode(defs[name])
941
1009
  });
942
1010
  for (const name of order) {
943
1011
  const variant = resolveColorForScheme(name, defs[name], ctx, true, false);
@@ -967,7 +1035,7 @@ function resolveAllColors(hue, saturation, defs) {
967
1035
  dark: darkMap.get(name),
968
1036
  lightContrast: lightHCMap.get(name),
969
1037
  darkContrast: darkHCMap.get(name),
970
- mode: defs[name].mode ?? "auto"
1038
+ mode: defMode(defs[name])
971
1039
  });
972
1040
  return result;
973
1041
  }
@@ -977,8 +1045,14 @@ const formatters = {
977
1045
  hsl: formatHsl,
978
1046
  oklch: formatOklch
979
1047
  };
1048
+ function fmt(value, decimals) {
1049
+ return parseFloat(value.toFixed(decimals)).toString();
1050
+ }
980
1051
  function formatVariant(v, format = "okhsl") {
981
- return formatters[format](v.h, v.s * 100, v.l * 100);
1052
+ const base = formatters[format](v.h, v.s * 100, v.l * 100);
1053
+ if (v.alpha >= 1) return base;
1054
+ const closing = base.lastIndexOf(")");
1055
+ return `${base.slice(0, closing)} / ${fmt(v.alpha, 4)})`;
982
1056
  }
983
1057
  function resolveModes(override) {
984
1058
  return {
@@ -1229,7 +1303,8 @@ glaze.configure = function configure(config) {
1229
1303
  modes: {
1230
1304
  dark: config.modes?.dark ?? globalConfig.modes.dark,
1231
1305
  highContrast: config.modes?.highContrast ?? globalConfig.modes.highContrast
1232
- }
1306
+ },
1307
+ shadowTuning: config.shadowTuning ?? globalConfig.shadowTuning
1233
1308
  };
1234
1309
  };
1235
1310
  /**
@@ -1251,6 +1326,40 @@ glaze.color = function color(input) {
1251
1326
  return createColorToken(input);
1252
1327
  };
1253
1328
  /**
1329
+ * Compute a shadow color from a bg/fg pair and intensity.
1330
+ */
1331
+ glaze.shadow = function shadow(input) {
1332
+ const bg = parseOkhslInput(input.bg);
1333
+ const fg = input.fg ? parseOkhslInput(input.fg) : void 0;
1334
+ const tuning = resolveShadowTuning(input.tuning);
1335
+ return computeShadow({
1336
+ ...bg,
1337
+ alpha: 1
1338
+ }, fg ? {
1339
+ ...fg,
1340
+ alpha: 1
1341
+ } : void 0, input.intensity, tuning);
1342
+ };
1343
+ /**
1344
+ * Format a resolved color variant as a CSS string.
1345
+ */
1346
+ glaze.format = function format(variant, colorFormat) {
1347
+ return formatVariant(variant, colorFormat);
1348
+ };
1349
+ function parseOkhslInput(input) {
1350
+ if (typeof input === "string") {
1351
+ const rgb = parseHex(input);
1352
+ if (!rgb) throw new Error(`glaze: invalid hex color "${input}".`);
1353
+ const [h, s, l] = srgbToOkhsl(rgb);
1354
+ return {
1355
+ h,
1356
+ s,
1357
+ l
1358
+ };
1359
+ }
1360
+ return input;
1361
+ }
1362
+ /**
1254
1363
  * Create a theme from a hex color string.
1255
1364
  * Extracts hue and saturation from the color.
1256
1365
  */