@tenphi/glaze 0.1.0 → 0.2.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
@@ -23,6 +23,7 @@ Glaze generates robust **light**, **dark**, and **high-contrast** color schemes
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
25
  - **Light + Dark + High-Contrast** — all schemes from one definition
26
+ - **Per-color hue override** — absolute or relative hue shifts within a theme
26
27
  - **Multi-format output** — `okhsl`, `rgb`, `hsl`, `oklch`
27
28
  - **Import/Export** — serialize and restore theme configurations
28
29
  - **Create from hex/RGB** — start from an existing brand color
@@ -54,11 +55,11 @@ const primary = glaze(280, 80);
54
55
 
55
56
  // Define colors with explicit lightness and contrast relationships
56
57
  primary.colors({
57
- surface: { l: 97, sat: 0.75 },
58
- text: { base: 'surface', contrast: 52, ensureContrast: 'AAA' },
59
- border: { base: 'surface', contrast: [7, 20], ensureContrast: 'AA-large' },
60
- 'accent-fill': { l: 52, mode: 'fixed' },
61
- 'accent-text': { base: 'accent-fill', contrast: 48, ensureContrast: 'AA', mode: 'fixed' },
58
+ surface: { lightness: 97, saturation: 0.75 },
59
+ text: { base: 'surface', lightness: '-52', contrast: 'AAA' },
60
+ border: { base: 'surface', lightness: ['-7', '-20'], contrast: 'AA-large' },
61
+ 'accent-fill': { lightness: 52, mode: 'fixed' },
62
+ 'accent-text': { base: 'accent-fill', lightness: '+48', contrast: 'AA', mode: 'fixed' },
62
63
  });
63
64
 
64
65
  // Create status themes by rotating the hue
@@ -77,6 +78,8 @@ const tokens = palette.tokens({ prefix: true });
77
78
 
78
79
  A single `glaze` theme is tied to one hue/saturation seed. Status colors (danger, success, warning) are derived via `extend`, which inherits all color definitions and replaces the seed.
79
80
 
81
+ Individual colors can override the hue via the `hue` prop (see [Per-Color Hue Override](#per-color-hue-override)), but the primary purpose of a theme is to scope colors with the same hue.
82
+
80
83
  ### Color Definitions
81
84
 
82
85
  Every color is defined explicitly. No implicit roles — every value is stated.
@@ -85,48 +88,73 @@ Every color is defined explicitly. No implicit roles — every value is stated.
85
88
 
86
89
  ```ts
87
90
  primary.colors({
88
- surface: { l: 97, sat: 0.75 },
89
- border: { l: 90, sat: 0.20 },
91
+ surface: { lightness: 97, saturation: 0.75 },
92
+ border: { lightness: 90, saturation: 0.20 },
90
93
  });
91
94
  ```
92
95
 
93
- - `l` — lightness in the light scheme (0–100)
94
- - `sat` — saturation factor applied to the seed saturation (0–1, default: `1`)
96
+ - `lightness` — lightness in the light scheme (0–100)
97
+ - `saturation` — saturation factor applied to the seed saturation (0–1, default: `1`)
95
98
 
96
99
  #### Dependent Colors (relative to base)
97
100
 
98
101
  ```ts
99
102
  primary.colors({
100
- surface: { l: 97, sat: 0.75 },
101
- text: { base: 'surface', contrast: 52, ensureContrast: 'AAA' },
103
+ surface: { lightness: 97, saturation: 0.75 },
104
+ text: { base: 'surface', lightness: '-52', contrast: 'AAA' },
102
105
  });
103
106
  ```
104
107
 
105
108
  - `base` — name of another color in the same theme
106
- - `contrast` — lightness delta from the base color
107
- - `ensureContrast` — ensures the WCAG contrast ratio meets a target floor against the base
109
+ - `lightness` — position of this color (see [Lightness Values](#lightness-values))
110
+ - `contrast` — ensures the WCAG contrast ratio meets a target floor against the base
108
111
 
109
- Both `contrast` and `ensureContrast` are considered. The effective lightness satisfies both constraints — the more demanding one wins.
112
+ ### Lightness Values
110
113
 
111
- ### Contrast Sign Convention
114
+ The `lightness` prop accepts two forms:
112
115
 
113
- | Sign | Behavior |
114
- |---|---|
115
- | Negative (`-52`) | Always darker than base |
116
- | Positive (`+48`) | Always lighter than base |
117
- | Unsigned (`52`) | Auto-resolved: if `base_L + contrast > 100`, flips to negative |
116
+ | Form | Example | Meaning |
117
+ |---|---|---|
118
+ | Number (absolute) | `lightness: 45` | Absolute lightness 0–100 |
119
+ | String (relative) | `lightness: '-52'` | Relative to base color's lightness |
120
+
121
+ **Absolute lightness** on a dependent color (with `base`) positions the color independently. In dark mode, it is dark-mapped on its own. The `contrast` WCAG solver acts as a safety net.
122
+
123
+ **Relative lightness** applies a signed delta to the base color's resolved lightness. In dark mode with `auto` adaptation, the sign flips automatically.
118
124
 
119
125
  ```ts
120
- // Surface L=97
121
- 'text': { base: 'surface', contrast: 52 }
122
- // → 97 + 52 = 149 > 100 → flips to 97 - 52 = 45 ✓
126
+ // Relative: 97 - 52 = 45 in light mode
127
+ 'text': { base: 'surface', lightness: '-52' }
128
+
129
+ // Absolute: lightness 45 in light mode, dark-mapped independently
130
+ 'text': { base: 'surface', lightness: 45 }
131
+ ```
132
+
133
+ A dependent color with `base` but no `lightness` inherits the base's lightness (equivalent to a delta of 0).
134
+
135
+ ### Per-Color Hue Override
123
136
 
124
- // Button fill L=52
125
- 'accent-text': { base: 'accent-fill', contrast: 48 }
126
- // 52 + 48 = 100 → keeps as 100 ✓
137
+ Individual colors can override the theme's hue. The `hue` prop accepts:
138
+
139
+ | Form | Example | Meaning |
140
+ |---|---|---|
141
+ | Number (absolute) | `hue: 120` | Absolute hue 0–360 |
142
+ | String (relative) | `hue: '+20'` | Relative to the **theme seed** hue |
143
+
144
+ **Important:** Relative hue is always relative to the **theme seed hue**, not to a base color's hue.
145
+
146
+ ```ts
147
+ const theme = glaze(280, 80);
148
+ theme.colors({
149
+ surface: { lightness: 97 },
150
+ // Gradient end — slight hue shift from seed (280 + 20 = 300)
151
+ gradientEnd: { lightness: 90, hue: '+20' },
152
+ // Entirely different hue
153
+ warning: { lightness: 60, hue: 40 },
154
+ });
127
155
  ```
128
156
 
129
- ### ensureContrast (WCAG Floor)
157
+ ### contrast (WCAG Floor)
130
158
 
131
159
  Ensures the WCAG contrast ratio meets a target floor. Accepts a numeric ratio or a preset string:
132
160
 
@@ -141,26 +169,26 @@ type MinContrast = number | 'AA' | 'AAA' | 'AA-large' | 'AAA-large';
141
169
  | `'AA-large'` | 3 |
142
170
  | `'AAA-large'` | 4.5 |
143
171
 
144
- You can also pass any numeric ratio directly (e.g., `ensureContrast: 4.5`, `ensureContrast: 7`, `ensureContrast: 11`).
172
+ You can also pass any numeric ratio directly (e.g., `contrast: 4.5`, `contrast: 7`, `contrast: 11`).
145
173
 
146
- The constraint is applied independently for each scheme. If the `contrast` delta already satisfies the floor, it's kept. Otherwise, the solver adjusts lightness until the target is met.
174
+ The constraint is applied independently for each scheme. If the `lightness` already satisfies the floor, it's kept. Otherwise, the solver adjusts lightness until the target is met.
147
175
 
148
176
  ### High-Contrast via Array Values
149
177
 
150
- `contrast`, `ensureContrast`, and `l` accept a `[normal, high-contrast]` pair:
178
+ `lightness` and `contrast` accept a `[normal, high-contrast]` pair:
151
179
 
152
180
  ```ts
153
- 'border': { base: 'surface', contrast: [7, 20], ensureContrast: 'AA-large' }
154
- //
155
- // normal high-contrast
181
+ 'border': { base: 'surface', lightness: ['-7', '-20'], contrast: 'AA-large' }
182
+ //
183
+ // normal high-contrast
156
184
  ```
157
185
 
158
186
  A single value applies to both modes. All control is local and explicit.
159
187
 
160
188
  ```ts
161
- 'text': { base: 'surface', contrast: 52, ensureContrast: 'AAA' }
162
- 'border': { base: 'surface', contrast: [7, 20], ensureContrast: 'AA-large' }
163
- 'muted': { base: 'surface', contrast: [35, 50], ensureContrast: ['AA-large', 'AA'] }
189
+ 'text': { base: 'surface', lightness: '-52', contrast: 'AAA' }
190
+ 'border': { base: 'surface', lightness: ['-7', '-20'], contrast: 'AA-large' }
191
+ 'muted': { base: 'surface', lightness: ['-35', '-50'], contrast: ['AA-large', 'AA'] }
164
192
  ```
165
193
 
166
194
  ## Theme Color Management
@@ -171,8 +199,8 @@ A single value applies to both modes. All control is local and explicit.
171
199
 
172
200
  ```ts
173
201
  const theme = glaze(280, 80);
174
- theme.colors({ surface: { l: 97 } });
175
- theme.colors({ text: { l: 30 } });
202
+ theme.colors({ surface: { lightness: 97 } });
203
+ theme.colors({ text: { lightness: 30 } });
176
204
  // Both 'surface' and 'text' are now defined
177
205
  ```
178
206
 
@@ -181,8 +209,8 @@ theme.colors({ text: { l: 30 } });
181
209
  `.color(name)` returns the definition, `.color(name, def)` sets it:
182
210
 
183
211
  ```ts
184
- theme.color('surface', { l: 97, sat: 0.75 }); // set
185
- const def = theme.color('surface'); // get → { l: 97, sat: 0.75 }
212
+ theme.color('surface', { lightness: 97, saturation: 0.75 }); // set
213
+ const def = theme.color('surface'); // get → { lightness: 97, saturation: 0.75 }
186
214
  ```
187
215
 
188
216
  ### Removing Colors
@@ -214,7 +242,7 @@ Serialize a theme's configuration (hue, saturation, color definitions) to a plai
214
242
  ```ts
215
243
  // Export
216
244
  const snapshot = theme.export();
217
- // → { hue: 280, saturation: 80, colors: { surface: { l: 97, sat: 0.75 }, ... } }
245
+ // → { hue: 280, saturation: 80, colors: { surface: { lightness: 97, saturation: 0.75 }, ... } }
218
246
 
219
247
  const jsonString = JSON.stringify(snapshot);
220
248
 
@@ -230,14 +258,14 @@ The export contains only the configuration — not resolved color values. Resolv
230
258
  Create a single color token without a full theme:
231
259
 
232
260
  ```ts
233
- const accent = glaze.color({ hue: 280, saturation: 80, l: 52, mode: 'fixed' });
261
+ const accent = glaze.color({ hue: 280, saturation: 80, lightness: 52, mode: 'fixed' });
234
262
 
235
263
  accent.resolve(); // → ResolvedColor with light/dark/lightContrast/darkContrast
236
264
  accent.token(); // → { '': 'okhsl(...)', '@dark': 'okhsl(...)' }
237
265
  accent.json(); // → { light: 'okhsl(...)', dark: 'okhsl(...)' }
238
266
  ```
239
267
 
240
- Standalone colors are always root colors (no `base`/`contrast`/`ensureContrast`).
268
+ Standalone colors are always root colors (no `base`/`contrast`).
241
269
 
242
270
  ## From Existing Colors
243
271
 
@@ -255,8 +283,8 @@ The resulting theme has the extracted hue and saturation. Add colors as usual:
255
283
 
256
284
  ```ts
257
285
  brand.colors({
258
- surface: { l: 97, sat: 0.75 },
259
- text: { base: 'surface', contrast: 52, ensureContrast: 'AAA' },
286
+ surface: { lightness: 97, saturation: 0.75 },
287
+ text: { base: 'surface', lightness: '-52', contrast: 'AAA' },
260
288
  });
261
289
  ```
262
290
 
@@ -299,20 +327,20 @@ Modes control how colors adapt across schemes:
299
327
  | `'fixed'` | Color stays recognizable. Only safety corrections. For brand buttons, CTAs. |
300
328
  | `'static'` | No adaptation. Same value in every scheme. |
301
329
 
302
- ### How `contrast` Adapts
330
+ ### How Relative Lightness Adapts
303
331
 
304
- **`auto` mode** — contrast sign flips in dark scheme:
332
+ **`auto` mode** — relative lightness sign flips in dark scheme:
305
333
 
306
334
  ```ts
307
- // Light: surface L=97, text contrast=52 → L=45 (dark text on light bg)
335
+ // Light: surface L=97, text lightness='-52' → L=45 (dark text on light bg)
308
336
  // Dark: surface inverts to L≈14, sign flips → L=14+52=66
309
- // ensureContrast solver may push further (light text on dark bg)
337
+ // contrast solver may push further (light text on dark bg)
310
338
  ```
311
339
 
312
- **`fixed` mode** — lightness is mapped (not inverted), contrast sign preserved:
340
+ **`fixed` mode** — lightness is mapped (not inverted), relative sign preserved:
313
341
 
314
342
  ```ts
315
- // Light: accent-fill L=52, accent-text contrast=+48 → L=100 (white on brand)
343
+ // Light: accent-fill L=52, accent-text lightness='+48' → L=100 (white on brand)
316
344
  // Dark: accent-fill maps to L≈51.6, sign preserved → L≈99.6
317
345
  ```
318
346
 
@@ -367,7 +395,7 @@ Override individual colors (additive merge):
367
395
  ```ts
368
396
  const danger = primary.extend({
369
397
  hue: 23,
370
- colors: { 'accent-fill': { l: 48, mode: 'fixed' } },
398
+ colors: { 'accent-fill': { lightness: 48, mode: 'fixed' } },
371
399
  });
372
400
  ```
373
401
 
@@ -446,33 +474,43 @@ glaze.configure({
446
474
  ## Color Definition Shape
447
475
 
448
476
  ```ts
477
+ type RelativeValue = `+${number}` | `-${number}`;
449
478
  type HCPair<T> = T | [T, T]; // [normal, high-contrast]
450
479
 
451
480
  interface ColorDef {
452
- // Root color (explicit position)
453
- l?: HCPair<number>; // 0–100, light scheme lightness
454
- sat?: number; // 0–1, saturation factor (default: 1)
481
+ // Lightness
482
+ lightness?: HCPair<number | RelativeValue>;
483
+ // Number: absolute (0–100)
484
+ // String: relative to base ('+N' / '-N')
485
+
486
+ // Hue override
487
+ hue?: number | RelativeValue;
488
+ // Number: absolute (0–360)
489
+ // String: relative to theme seed ('+N' / '-N')
490
+
491
+ // Saturation factor (0–1, default: 1)
492
+ saturation?: number;
455
493
 
456
- // Dependent color (relative to base)
494
+ // Dependency
457
495
  base?: string; // name of another color
458
- contrast?: HCPair<number>; // lightness delta from base
459
- ensureContrast?: HCPair<MinContrast>; // ensures WCAG contrast ratio meets target floor
496
+ contrast?: HCPair<MinContrast>; // WCAG contrast ratio floor against base
460
497
 
461
498
  // Adaptation mode
462
499
  mode?: 'auto' | 'fixed' | 'static'; // default: 'auto'
463
500
  }
464
501
  ```
465
502
 
466
- Every color must have either `l` (root) or `base` + `contrast` (dependent).
503
+ A root color must have absolute `lightness` (a number). A dependent color must have `base`. Relative `lightness` (a string) requires `base`.
467
504
 
468
505
  ## Validation
469
506
 
470
507
  | Condition | Behavior |
471
508
  |---|---|
472
- | Both `l` and `base` on same color | Warning, `l` takes precedence |
509
+ | Both absolute `lightness` and `base` on same color | Warning, `lightness` takes precedence |
473
510
  | `contrast` without `base` | Validation error |
474
- | `l` resolves outside 0–100 | Clamp silently |
475
- | `sat` outside 0–1 | Clamp silently |
511
+ | Relative `lightness` without `base` | Validation error |
512
+ | `lightness` resolves outside 0–100 | Clamp silently |
513
+ | `saturation` outside 0–1 | Clamp silently |
476
514
  | Circular `base` references | Validation error |
477
515
  | `base` references non-existent name | Validation error |
478
516
 
@@ -506,14 +544,14 @@ import { glaze } from '@tenphi/glaze';
506
544
  const primary = glaze(280, 80);
507
545
 
508
546
  primary.colors({
509
- surface: { l: 97, sat: 0.75 },
510
- text: { base: 'surface', contrast: 52, ensureContrast: 'AAA' },
511
- border: { base: 'surface', contrast: [7, 20], ensureContrast: 'AA-large' },
512
- bg: { l: 97, sat: 0.75 },
513
- icon: { l: 60, sat: 0.94 },
514
- 'accent-fill': { l: 52, mode: 'fixed' },
515
- 'accent-text': { base: 'accent-fill', contrast: 48, ensureContrast: 'AA', mode: 'fixed' },
516
- disabled: { l: 81, sat: 0.40 },
547
+ surface: { lightness: 97, saturation: 0.75 },
548
+ text: { base: 'surface', lightness: '-52', contrast: 'AAA' },
549
+ border: { base: 'surface', lightness: ['-7', '-20'], contrast: 'AA-large' },
550
+ bg: { lightness: 97, saturation: 0.75 },
551
+ icon: { lightness: 60, saturation: 0.94 },
552
+ 'accent-fill': { lightness: 52, mode: 'fixed' },
553
+ 'accent-text': { base: 'accent-fill', lightness: '+48', contrast: 'AA', mode: 'fixed' },
554
+ disabled: { lightness: 81, saturation: 0.4 },
517
555
  });
518
556
 
519
557
  const danger = primary.extend({ hue: 23 });
@@ -535,7 +573,7 @@ const restored = glaze.from(snapshot);
535
573
 
536
574
  // Create from an existing brand color
537
575
  const brand = glaze.fromHex('#7a4dbf');
538
- brand.colors({ surface: { l: 97 }, text: { base: 'surface', contrast: 52 } });
576
+ brand.colors({ surface: { lightness: 97 }, text: { base: 'surface', lightness: '-52' } });
539
577
  ```
540
578
 
541
579
  ## API Reference
package/dist/index.cjs CHANGED
@@ -495,7 +495,7 @@ function formatOklch(h, s, l) {
495
495
  *
496
496
  * Finds the closest OKHSL lightness that satisfies a WCAG 2 contrast target
497
497
  * against a base color. Used by glaze when resolving dependent colors
498
- * with `ensureContrast`.
498
+ * with `contrast`.
499
499
  */
500
500
  const CONTRAST_PRESETS = {
501
501
  AA: 4.5,
@@ -633,8 +633,8 @@ function coarseScan(h, s, lo, hi, yBase, target, epsilon, maxIter) {
633
633
  * against a base color, staying as close to `preferredLightness` as possible.
634
634
  */
635
635
  function findLightnessForContrast(options) {
636
- const { hue, saturation, preferredLightness, baseLinearRgb, ensureContrast: ensureContrastInput, lightnessRange = [0, 1], epsilon = 1e-4, maxIterations = 14 } = options;
637
- const target = resolveMinContrast(ensureContrastInput);
636
+ const { hue, saturation, preferredLightness, baseLinearRgb, contrast: contrastInput, lightnessRange = [0, 1], epsilon = 1e-4, maxIterations = 14 } = options;
637
+ const target = resolveMinContrast(contrastInput);
638
638
  const yBase = relativeLuminanceFromLinearRgb(baseLinearRgb);
639
639
  const crPref = contrastRatioFromLuminance(cachedLuminance(hue, saturation, preferredLightness), yBase);
640
640
  if (crPref >= target) return {
@@ -715,9 +715,10 @@ function validateColorDefs(defs) {
715
715
  const names = new Set(Object.keys(defs));
716
716
  for (const [name, def] of Object.entries(defs)) {
717
717
  if (def.contrast !== void 0 && !def.base) throw new Error(`glaze: color "${name}" has "contrast" without "base".`);
718
- if (def.l !== void 0 && def.base !== void 0) console.warn(`glaze: color "${name}" has both "l" and "base". "l" takes precedence.`);
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.`);
719
720
  if (def.base && !names.has(def.base)) throw new Error(`glaze: color "${name}" references non-existent base "${def.base}".`);
720
- if (def.l === void 0 && def.base === void 0) throw new Error(`glaze: color "${name}" must have either "l" (root) or "base" + "contrast" (dependent).`);
721
+ if (!isAbsoluteLightness(def.lightness) && def.base === void 0) throw new Error(`glaze: color "${name}" must have either absolute "lightness" (root) or "base" (dependent).`);
721
722
  }
722
723
  const visited = /* @__PURE__ */ new Set();
723
724
  const inStack = /* @__PURE__ */ new Set();
@@ -726,7 +727,7 @@ function validateColorDefs(defs) {
726
727
  if (visited.has(name)) return;
727
728
  inStack.add(name);
728
729
  const def = defs[name];
729
- if (def.base && def.l === void 0) dfs(def.base);
730
+ if (def.base && !isAbsoluteLightness(def.lightness)) dfs(def.base);
730
731
  inStack.delete(name);
731
732
  visited.add(name);
732
733
  }
@@ -739,7 +740,7 @@ function topoSort(defs) {
739
740
  if (visited.has(name)) return;
740
741
  visited.add(name);
741
742
  const def = defs[name];
742
- if (def.base && def.l === void 0) visit(def.base);
743
+ if (def.base && !isAbsoluteLightness(def.lightness)) visit(def.base);
743
744
  result.push(name);
744
745
  }
745
746
  for (const name of Object.keys(defs)) visit(name);
@@ -755,44 +756,75 @@ function mapSaturationDark(s, mode) {
755
756
  if (mode === "static") return s;
756
757
  return s * (1 - globalConfig.darkDesaturation);
757
758
  }
759
+ function clamp(v, min, max) {
760
+ return Math.max(min, Math.min(max, v));
761
+ }
758
762
  /**
759
- * Resolve the effective lightness from a contrast delta.
763
+ * Parse a value that can be absolute (number) or relative (signed string).
764
+ * Returns the numeric value and whether it's relative.
760
765
  */
761
- function resolveContrastLightness(baseLightness, contrast) {
762
- if (contrast < 0) return clamp(baseLightness + contrast, 0, 100);
763
- const candidate = baseLightness + contrast;
764
- if (candidate > 100) return clamp(baseLightness - contrast, 0, 100);
765
- return clamp(candidate, 0, 100);
766
+ function parseRelativeOrAbsolute(value) {
767
+ if (typeof value === "number") return {
768
+ value,
769
+ relative: false
770
+ };
771
+ return {
772
+ value: parseFloat(value),
773
+ relative: true
774
+ };
766
775
  }
767
- function clamp(v, min, max) {
768
- return Math.max(min, Math.min(max, v));
776
+ /**
777
+ * Compute the effective hue for a color, given the theme seed hue
778
+ * and an optional per-color hue override.
779
+ */
780
+ function resolveEffectiveHue(seedHue, defHue) {
781
+ if (defHue === void 0) return seedHue;
782
+ const parsed = parseRelativeOrAbsolute(defHue);
783
+ if (parsed.relative) return ((seedHue + parsed.value) % 360 + 360) % 360;
784
+ return (parsed.value % 360 + 360) % 360;
785
+ }
786
+ /**
787
+ * Check whether a lightness value represents an absolute root definition
788
+ * (i.e. a number, not a relative string).
789
+ */
790
+ function isAbsoluteLightness(lightness) {
791
+ if (lightness === void 0) return false;
792
+ return typeof (Array.isArray(lightness) ? lightness[0] : lightness) === "number";
769
793
  }
770
794
  function resolveRootColor(_name, def, _ctx, isHighContrast) {
771
- const rawL = def.l;
795
+ const rawL = def.lightness;
772
796
  return {
773
- lightL: clamp(isHighContrast ? pairHC(rawL) : pairNormal(rawL), 0, 100),
774
- sat: clamp(def.sat ?? 1, 0, 1)
797
+ lightL: clamp(parseRelativeOrAbsolute(isHighContrast ? pairHC(rawL) : pairNormal(rawL)).value, 0, 100),
798
+ satFactor: clamp(def.saturation ?? 1, 0, 1)
775
799
  };
776
800
  }
777
- function resolveDependentColor(name, def, ctx, isHighContrast, isDark) {
801
+ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, effectiveHue) {
778
802
  const baseName = def.base;
779
803
  const baseResolved = ctx.resolved.get(baseName);
780
804
  if (!baseResolved) throw new Error(`glaze: base "${baseName}" not yet resolved for "${name}".`);
781
805
  const mode = def.mode ?? "auto";
782
- const sat = clamp(def.sat ?? 1, 0, 1);
806
+ const satFactor = clamp(def.saturation ?? 1, 0, 1);
783
807
  let baseL;
784
808
  if (isDark && isHighContrast) baseL = baseResolved.darkContrast.l * 100;
785
809
  else if (isDark) baseL = baseResolved.dark.l * 100;
786
810
  else if (isHighContrast) baseL = baseResolved.lightContrast.l * 100;
787
811
  else baseL = baseResolved.light.l * 100;
788
- const rawContrast = def.contrast ?? 0;
789
- let contrast = isHighContrast ? pairHC(rawContrast) : pairNormal(rawContrast);
790
- if (isDark && mode === "auto") contrast = -contrast;
791
- const preferredL = resolveContrastLightness(baseL, contrast);
792
- const rawEnsureContrast = def.ensureContrast;
793
- if (rawEnsureContrast !== void 0) {
794
- const minCr = isHighContrast ? pairHC(rawEnsureContrast) : pairNormal(rawEnsureContrast);
795
- const effectiveSat = isDark ? mapSaturationDark(sat * ctx.saturation / 100, mode) : sat * ctx.saturation / 100;
812
+ let preferredL;
813
+ const rawLightness = def.lightness;
814
+ if (rawLightness === void 0) preferredL = baseL;
815
+ else {
816
+ const parsed = parseRelativeOrAbsolute(isHighContrast ? pairHC(rawLightness) : pairNormal(rawLightness));
817
+ if (parsed.relative) {
818
+ let delta = parsed.value;
819
+ if (isDark && mode === "auto") delta = -delta;
820
+ preferredL = clamp(baseL + delta, 0, 100);
821
+ } else if (isDark) preferredL = mapLightnessDark(parsed.value, mode);
822
+ else preferredL = clamp(parsed.value, 0, 100);
823
+ }
824
+ const rawContrast = def.contrast;
825
+ if (rawContrast !== void 0) {
826
+ const minCr = isHighContrast ? pairHC(rawContrast) : pairNormal(rawContrast);
827
+ const effectiveSat = isDark ? mapSaturationDark(satFactor * ctx.saturation / 100, mode) : satFactor * ctx.saturation / 100;
796
828
  let baseH;
797
829
  let baseS;
798
830
  let baseLNorm;
@@ -816,48 +848,49 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark) {
816
848
  const baseLinearRgb = okhslToLinearSrgb(baseH, baseS, baseLNorm);
817
849
  return {
818
850
  l: findLightnessForContrast({
819
- hue: ctx.hue,
851
+ hue: effectiveHue,
820
852
  saturation: effectiveSat,
821
853
  preferredLightness: preferredL / 100,
822
854
  baseLinearRgb,
823
- ensureContrast: minCr
855
+ contrast: minCr
824
856
  }).lightness * 100,
825
- sat
857
+ satFactor
826
858
  };
827
859
  }
828
860
  return {
829
861
  l: clamp(preferredL, 0, 100),
830
- sat
862
+ satFactor
831
863
  };
832
864
  }
833
865
  function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
834
866
  const mode = def.mode ?? "auto";
835
- const isRoot = def.l !== void 0;
867
+ const isRoot = isAbsoluteLightness(def.lightness) && !def.base;
868
+ const effectiveHue = resolveEffectiveHue(ctx.hue, def.hue);
836
869
  let lightL;
837
- let sat;
870
+ let satFactor;
838
871
  if (isRoot) {
839
872
  const root = resolveRootColor(name, def, ctx, isHighContrast);
840
873
  lightL = root.lightL;
841
- sat = root.sat;
874
+ satFactor = root.satFactor;
842
875
  } else {
843
- const dep = resolveDependentColor(name, def, ctx, isHighContrast, isDark);
876
+ const dep = resolveDependentColor(name, def, ctx, isHighContrast, isDark, effectiveHue);
844
877
  lightL = dep.l;
845
- sat = dep.sat;
878
+ satFactor = dep.satFactor;
846
879
  }
847
880
  let finalL;
848
881
  let finalSat;
849
882
  if (isDark && isRoot) {
850
883
  finalL = mapLightnessDark(lightL, mode);
851
- finalSat = mapSaturationDark(sat * ctx.saturation / 100, mode);
884
+ finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode);
852
885
  } else if (isDark && !isRoot) {
853
886
  finalL = lightL;
854
- finalSat = mapSaturationDark(sat * ctx.saturation / 100, mode);
887
+ finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode);
855
888
  } else {
856
889
  finalL = lightL;
857
- finalSat = sat * ctx.saturation / 100;
890
+ finalSat = satFactor * ctx.saturation / 100;
858
891
  }
859
892
  return {
860
- h: ctx.hue,
893
+ h: effectiveHue,
861
894
  s: clamp(finalSat, 0, 1),
862
895
  l: clamp(finalL / 100, 0, 1)
863
896
  };
@@ -1064,8 +1097,8 @@ function createPalette(themes) {
1064
1097
  }
1065
1098
  function createColorToken(input) {
1066
1099
  const defs = { __color__: {
1067
- l: input.l,
1068
- sat: input.sat,
1100
+ lightness: input.lightness,
1101
+ saturation: input.saturationFactor,
1069
1102
  mode: input.mode
1070
1103
  } };
1071
1104
  return {