@tenphi/glaze 0.1.1 → 0.3.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,7 +23,9 @@ 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`
28
+ - **CSS custom properties export** — ready-to-use `--var: value;` declarations per scheme
27
29
  - **Import/Export** — serialize and restore theme configurations
28
30
  - **Create from hex/RGB** — start from an existing brand color
29
31
  - **Zero dependencies** — pure math, runs anywhere (Node.js, browser, edge)
@@ -54,11 +56,11 @@ const primary = glaze(280, 80);
54
56
 
55
57
  // Define colors with explicit lightness and contrast relationships
56
58
  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' },
59
+ surface: { lightness: 97, saturation: 0.75 },
60
+ text: { base: 'surface', lightness: '-52', contrast: 'AAA' },
61
+ border: { base: 'surface', lightness: ['-7', '-20'], contrast: 'AA-large' },
62
+ 'accent-fill': { lightness: 52, mode: 'fixed' },
63
+ 'accent-text': { base: 'accent-fill', lightness: '+48', contrast: 'AA', mode: 'fixed' },
62
64
  });
63
65
 
64
66
  // Create status themes by rotating the hue
@@ -77,6 +79,8 @@ const tokens = palette.tokens({ prefix: true });
77
79
 
78
80
  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
81
 
82
+ 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.
83
+
80
84
  ### Color Definitions
81
85
 
82
86
  Every color is defined explicitly. No implicit roles — every value is stated.
@@ -85,48 +89,73 @@ Every color is defined explicitly. No implicit roles — every value is stated.
85
89
 
86
90
  ```ts
87
91
  primary.colors({
88
- surface: { l: 97, sat: 0.75 },
89
- border: { l: 90, sat: 0.20 },
92
+ surface: { lightness: 97, saturation: 0.75 },
93
+ border: { lightness: 90, saturation: 0.20 },
90
94
  });
91
95
  ```
92
96
 
93
- - `l` — lightness in the light scheme (0–100)
94
- - `sat` — saturation factor applied to the seed saturation (0–1, default: `1`)
97
+ - `lightness` — lightness in the light scheme (0–100)
98
+ - `saturation` — saturation factor applied to the seed saturation (0–1, default: `1`)
95
99
 
96
100
  #### Dependent Colors (relative to base)
97
101
 
98
102
  ```ts
99
103
  primary.colors({
100
- surface: { l: 97, sat: 0.75 },
101
- text: { base: 'surface', contrast: 52, ensureContrast: 'AAA' },
104
+ surface: { lightness: 97, saturation: 0.75 },
105
+ text: { base: 'surface', lightness: '-52', contrast: 'AAA' },
102
106
  });
103
107
  ```
104
108
 
105
109
  - `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
110
+ - `lightness` — position of this color (see [Lightness Values](#lightness-values))
111
+ - `contrast` — ensures the WCAG contrast ratio meets a target floor against the base
108
112
 
109
- Both `contrast` and `ensureContrast` are considered. The effective lightness satisfies both constraints — the more demanding one wins.
113
+ ### Lightness Values
110
114
 
111
- ### Contrast Sign Convention
115
+ The `lightness` prop accepts two forms:
112
116
 
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 |
117
+ | Form | Example | Meaning |
118
+ |---|---|---|
119
+ | Number (absolute) | `lightness: 45` | Absolute lightness 0–100 |
120
+ | String (relative) | `lightness: '-52'` | Relative to base color's lightness |
121
+
122
+ **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.
123
+
124
+ **Relative lightness** applies a signed delta to the base color's resolved lightness. In dark mode with `auto` adaptation, the sign flips automatically.
118
125
 
119
126
  ```ts
120
- // Surface L=97
121
- 'text': { base: 'surface', contrast: 52 }
122
- // → 97 + 52 = 149 > 100 → flips to 97 - 52 = 45 ✓
127
+ // Relative: 97 - 52 = 45 in light mode
128
+ 'text': { base: 'surface', lightness: '-52' }
123
129
 
124
- // Button fill L=52
125
- 'accent-text': { base: 'accent-fill', contrast: 48 }
126
- // → 52 + 48 = 100 → keeps as 100 ✓
130
+ // Absolute: lightness 45 in light mode, dark-mapped independently
131
+ 'text': { base: 'surface', lightness: 45 }
127
132
  ```
128
133
 
129
- ### ensureContrast (WCAG Floor)
134
+ A dependent color with `base` but no `lightness` inherits the base's lightness (equivalent to a delta of 0).
135
+
136
+ ### Per-Color Hue Override
137
+
138
+ Individual colors can override the theme's hue. The `hue` prop accepts:
139
+
140
+ | Form | Example | Meaning |
141
+ |---|---|---|
142
+ | Number (absolute) | `hue: 120` | Absolute hue 0–360 |
143
+ | String (relative) | `hue: '+20'` | Relative to the **theme seed** hue |
144
+
145
+ **Important:** Relative hue is always relative to the **theme seed hue**, not to a base color's hue.
146
+
147
+ ```ts
148
+ const theme = glaze(280, 80);
149
+ theme.colors({
150
+ surface: { lightness: 97 },
151
+ // Gradient end — slight hue shift from seed (280 + 20 = 300)
152
+ gradientEnd: { lightness: 90, hue: '+20' },
153
+ // Entirely different hue
154
+ warning: { lightness: 60, hue: 40 },
155
+ });
156
+ ```
157
+
158
+ ### contrast (WCAG Floor)
130
159
 
131
160
  Ensures the WCAG contrast ratio meets a target floor. Accepts a numeric ratio or a preset string:
132
161
 
@@ -141,26 +170,26 @@ type MinContrast = number | 'AA' | 'AAA' | 'AA-large' | 'AAA-large';
141
170
  | `'AA-large'` | 3 |
142
171
  | `'AAA-large'` | 4.5 |
143
172
 
144
- You can also pass any numeric ratio directly (e.g., `ensureContrast: 4.5`, `ensureContrast: 7`, `ensureContrast: 11`).
173
+ You can also pass any numeric ratio directly (e.g., `contrast: 4.5`, `contrast: 7`, `contrast: 11`).
145
174
 
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.
175
+ 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
176
 
148
177
  ### High-Contrast via Array Values
149
178
 
150
- `contrast`, `ensureContrast`, and `l` accept a `[normal, high-contrast]` pair:
179
+ `lightness` and `contrast` accept a `[normal, high-contrast]` pair:
151
180
 
152
181
  ```ts
153
- 'border': { base: 'surface', contrast: [7, 20], ensureContrast: 'AA-large' }
154
- //
155
- // normal high-contrast
182
+ 'border': { base: 'surface', lightness: ['-7', '-20'], contrast: 'AA-large' }
183
+ //
184
+ // normal high-contrast
156
185
  ```
157
186
 
158
187
  A single value applies to both modes. All control is local and explicit.
159
188
 
160
189
  ```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'] }
190
+ 'text': { base: 'surface', lightness: '-52', contrast: 'AAA' }
191
+ 'border': { base: 'surface', lightness: ['-7', '-20'], contrast: 'AA-large' }
192
+ 'muted': { base: 'surface', lightness: ['-35', '-50'], contrast: ['AA-large', 'AA'] }
164
193
  ```
165
194
 
166
195
  ## Theme Color Management
@@ -171,8 +200,8 @@ A single value applies to both modes. All control is local and explicit.
171
200
 
172
201
  ```ts
173
202
  const theme = glaze(280, 80);
174
- theme.colors({ surface: { l: 97 } });
175
- theme.colors({ text: { l: 30 } });
203
+ theme.colors({ surface: { lightness: 97 } });
204
+ theme.colors({ text: { lightness: 30 } });
176
205
  // Both 'surface' and 'text' are now defined
177
206
  ```
178
207
 
@@ -181,8 +210,8 @@ theme.colors({ text: { l: 30 } });
181
210
  `.color(name)` returns the definition, `.color(name, def)` sets it:
182
211
 
183
212
  ```ts
184
- theme.color('surface', { l: 97, sat: 0.75 }); // set
185
- const def = theme.color('surface'); // get → { l: 97, sat: 0.75 }
213
+ theme.color('surface', { lightness: 97, saturation: 0.75 }); // set
214
+ const def = theme.color('surface'); // get → { lightness: 97, saturation: 0.75 }
186
215
  ```
187
216
 
188
217
  ### Removing Colors
@@ -214,7 +243,7 @@ Serialize a theme's configuration (hue, saturation, color definitions) to a plai
214
243
  ```ts
215
244
  // Export
216
245
  const snapshot = theme.export();
217
- // → { hue: 280, saturation: 80, colors: { surface: { l: 97, sat: 0.75 }, ... } }
246
+ // → { hue: 280, saturation: 80, colors: { surface: { lightness: 97, saturation: 0.75 }, ... } }
218
247
 
219
248
  const jsonString = JSON.stringify(snapshot);
220
249
 
@@ -230,14 +259,14 @@ The export contains only the configuration — not resolved color values. Resolv
230
259
  Create a single color token without a full theme:
231
260
 
232
261
  ```ts
233
- const accent = glaze.color({ hue: 280, saturation: 80, l: 52, mode: 'fixed' });
262
+ const accent = glaze.color({ hue: 280, saturation: 80, lightness: 52, mode: 'fixed' });
234
263
 
235
264
  accent.resolve(); // → ResolvedColor with light/dark/lightContrast/darkContrast
236
265
  accent.token(); // → { '': 'okhsl(...)', '@dark': 'okhsl(...)' }
237
266
  accent.json(); // → { light: 'okhsl(...)', dark: 'okhsl(...)' }
238
267
  ```
239
268
 
240
- Standalone colors are always root colors (no `base`/`contrast`/`ensureContrast`).
269
+ Standalone colors are always root colors (no `base`/`contrast`).
241
270
 
242
271
  ## From Existing Colors
243
272
 
@@ -255,8 +284,8 @@ The resulting theme has the extracted hue and saturation. Add colors as usual:
255
284
 
256
285
  ```ts
257
286
  brand.colors({
258
- surface: { l: 97, sat: 0.75 },
259
- text: { base: 'surface', contrast: 52, ensureContrast: 'AAA' },
287
+ surface: { lightness: 97, saturation: 0.75 },
288
+ text: { base: 'surface', lightness: '-52', contrast: 'AAA' },
260
289
  });
261
290
  ```
262
291
 
@@ -278,7 +307,7 @@ theme.tokens({ format: 'hsl' }); // → 'hsl(270.5, 45.2%, 95.8%)'
278
307
  theme.tokens({ format: 'oklch' }); // → 'oklch(96.5% 0.0123 280.0)'
279
308
  ```
280
309
 
281
- The `format` option works on all export methods: `theme.tokens()`, `theme.json()`, `palette.tokens()`, `palette.json()`, and standalone `glaze.color().token()` / `.json()`.
310
+ The `format` option works on all export methods: `theme.tokens()`, `theme.json()`, `theme.css()`, `palette.tokens()`, `palette.json()`, `palette.css()`, and standalone `glaze.color().token()` / `.json()`.
282
311
 
283
312
  Available formats:
284
313
 
@@ -299,20 +328,20 @@ Modes control how colors adapt across schemes:
299
328
  | `'fixed'` | Color stays recognizable. Only safety corrections. For brand buttons, CTAs. |
300
329
  | `'static'` | No adaptation. Same value in every scheme. |
301
330
 
302
- ### How `contrast` Adapts
331
+ ### How Relative Lightness Adapts
303
332
 
304
- **`auto` mode** — contrast sign flips in dark scheme:
333
+ **`auto` mode** — relative lightness sign flips in dark scheme:
305
334
 
306
335
  ```ts
307
- // Light: surface L=97, text contrast=52 → L=45 (dark text on light bg)
336
+ // Light: surface L=97, text lightness='-52' → L=45 (dark text on light bg)
308
337
  // Dark: surface inverts to L≈14, sign flips → L=14+52=66
309
- // ensureContrast solver may push further (light text on dark bg)
338
+ // contrast solver may push further (light text on dark bg)
310
339
  ```
311
340
 
312
- **`fixed` mode** — lightness is mapped (not inverted), contrast sign preserved:
341
+ **`fixed` mode** — lightness is mapped (not inverted), relative sign preserved:
313
342
 
314
343
  ```ts
315
- // Light: accent-fill L=52, accent-text contrast=+48 → L=100 (white on brand)
344
+ // Light: accent-fill L=52, accent-text lightness='+48' → L=100 (white on brand)
316
345
  // Dark: accent-fill maps to L≈51.6, sign preserved → L≈99.6
317
346
  ```
318
347
 
@@ -367,7 +396,7 @@ Override individual colors (additive merge):
367
396
  ```ts
368
397
  const danger = primary.extend({
369
398
  hue: 23,
370
- colors: { 'accent-fill': { l: 48, mode: 'fixed' } },
399
+ colors: { 'accent-fill': { lightness: 48, mode: 'fixed' } },
371
400
  });
372
401
  ```
373
402
 
@@ -405,6 +434,53 @@ const data = palette.json({ prefix: true });
405
434
  // }
406
435
  ```
407
436
 
437
+ ### CSS Export
438
+
439
+ Export as CSS custom property declarations, grouped by scheme variant. Each variant is a string of `--name-color: value;` lines that you can wrap in your own selectors and media queries.
440
+
441
+ ```ts
442
+ const css = theme.css();
443
+ // css.light → "--surface-color: rgb(...);\n--text-color: rgb(...);"
444
+ // css.dark → "--surface-color: rgb(...);\n--text-color: rgb(...);"
445
+ // css.lightContrast → "--surface-color: rgb(...);\n--text-color: rgb(...);"
446
+ // css.darkContrast → "--surface-color: rgb(...);\n--text-color: rgb(...);"
447
+ ```
448
+
449
+ Use in a stylesheet:
450
+
451
+ ```ts
452
+ const css = palette.css({ prefix: true });
453
+
454
+ const stylesheet = `
455
+ :root { ${css.light} }
456
+ @media (prefers-color-scheme: dark) {
457
+ :root { ${css.dark} }
458
+ }
459
+ `;
460
+ ```
461
+
462
+ Options:
463
+
464
+ | Option | Default | Description |
465
+ |---|---|---|
466
+ | `format` | `'rgb'` | Color format (`'rgb'`, `'hsl'`, `'okhsl'`, `'oklch'`) |
467
+ | `suffix` | `'-color'` | Suffix appended to each CSS property name |
468
+ | `prefix` | — | (palette only) Same prefix behavior as `tokens()` |
469
+
470
+ ```ts
471
+ // Custom suffix
472
+ theme.css({ suffix: '' });
473
+ // → "--surface: rgb(...);"
474
+
475
+ // Custom format
476
+ theme.css({ format: 'hsl' });
477
+ // → "--surface-color: hsl(...);"
478
+
479
+ // Palette with prefix
480
+ palette.css({ prefix: true });
481
+ // → "--primary-surface-color: rgb(...);\n--danger-surface-color: rgb(...);"
482
+ ```
483
+
408
484
  ## Output Modes
409
485
 
410
486
  Control which scheme variants appear in exports:
@@ -422,7 +498,7 @@ palette.tokens({ modes: { dark: true, highContrast: true } });
422
498
 
423
499
  Resolution priority (highest first):
424
500
 
425
- 1. `tokens({ modes })` / `json({ modes })` — per-call override
501
+ 1. `tokens({ modes })` / `json({ modes })` / `css({ ... })` — per-call override
426
502
  2. `glaze.configure({ modes })` — global config
427
503
  3. Built-in default: `{ dark: true, highContrast: false }`
428
504
 
@@ -446,33 +522,43 @@ glaze.configure({
446
522
  ## Color Definition Shape
447
523
 
448
524
  ```ts
525
+ type RelativeValue = `+${number}` | `-${number}`;
449
526
  type HCPair<T> = T | [T, T]; // [normal, high-contrast]
450
527
 
451
528
  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)
529
+ // Lightness
530
+ lightness?: HCPair<number | RelativeValue>;
531
+ // Number: absolute (0–100)
532
+ // String: relative to base ('+N' / '-N')
533
+
534
+ // Hue override
535
+ hue?: number | RelativeValue;
536
+ // Number: absolute (0–360)
537
+ // String: relative to theme seed ('+N' / '-N')
455
538
 
456
- // Dependent color (relative to base)
539
+ // Saturation factor (0–1, default: 1)
540
+ saturation?: number;
541
+
542
+ // Dependency
457
543
  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
544
+ contrast?: HCPair<MinContrast>; // WCAG contrast ratio floor against base
460
545
 
461
546
  // Adaptation mode
462
547
  mode?: 'auto' | 'fixed' | 'static'; // default: 'auto'
463
548
  }
464
549
  ```
465
550
 
466
- Every color must have either `l` (root) or `base` + `contrast` (dependent).
551
+ A root color must have absolute `lightness` (a number). A dependent color must have `base`. Relative `lightness` (a string) requires `base`.
467
552
 
468
553
  ## Validation
469
554
 
470
555
  | Condition | Behavior |
471
556
  |---|---|
472
- | Both `l` and `base` on same color | Warning, `l` takes precedence |
557
+ | Both absolute `lightness` and `base` on same color | Warning, `lightness` takes precedence |
473
558
  | `contrast` without `base` | Validation error |
474
- | `l` resolves outside 0–100 | Clamp silently |
475
- | `sat` outside 0–1 | Clamp silently |
559
+ | Relative `lightness` without `base` | Validation error |
560
+ | `lightness` resolves outside 0–100 | Clamp silently |
561
+ | `saturation` outside 0–1 | Clamp silently |
476
562
  | Circular `base` references | Validation error |
477
563
  | `base` references non-existent name | Validation error |
478
564
 
@@ -506,14 +592,14 @@ import { glaze } from '@tenphi/glaze';
506
592
  const primary = glaze(280, 80);
507
593
 
508
594
  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 },
595
+ surface: { lightness: 97, saturation: 0.75 },
596
+ text: { base: 'surface', lightness: '-52', contrast: 'AAA' },
597
+ border: { base: 'surface', lightness: ['-7', '-20'], contrast: 'AA-large' },
598
+ bg: { lightness: 97, saturation: 0.75 },
599
+ icon: { lightness: 60, saturation: 0.94 },
600
+ 'accent-fill': { lightness: 52, mode: 'fixed' },
601
+ 'accent-text': { base: 'accent-fill', lightness: '+48', contrast: 'AA', mode: 'fixed' },
602
+ disabled: { lightness: 81, saturation: 0.4 },
517
603
  });
518
604
 
519
605
  const danger = primary.extend({ hue: 23 });
@@ -529,13 +615,18 @@ const tokens = palette.tokens({ prefix: true });
529
615
  // Export as RGB for broader CSS compatibility
530
616
  const rgbTokens = palette.tokens({ prefix: true, format: 'rgb' });
531
617
 
618
+ // Export as CSS custom properties (rgb format by default)
619
+ const css = palette.css({ prefix: true });
620
+ // css.light → "--primary-surface-color: rgb(...);\n--danger-surface-color: rgb(...);"
621
+ // css.dark → "--primary-surface-color: rgb(...);\n--danger-surface-color: rgb(...);"
622
+
532
623
  // Save and restore a theme
533
624
  const snapshot = primary.export();
534
625
  const restored = glaze.from(snapshot);
535
626
 
536
627
  // Create from an existing brand color
537
628
  const brand = glaze.fromHex('#7a4dbf');
538
- brand.colors({ surface: { l: 97 }, text: { base: 'surface', contrast: 52 } });
629
+ brand.colors({ surface: { lightness: 97 }, text: { base: 'surface', lightness: '-52' } });
539
630
  ```
540
631
 
541
632
  ## API Reference
@@ -567,6 +658,7 @@ brand.colors({ surface: { l: 97 }, text: { base: 'surface', contrast: 52 } });
567
658
  | `theme.resolve()` | Resolve all colors |
568
659
  | `theme.tokens(options?)` | Export as token map |
569
660
  | `theme.json(options?)` | Export as plain JSON |
661
+ | `theme.css(options?)` | Export as CSS custom property declarations |
570
662
 
571
663
  ### Global Configuration
572
664