@tenphi/glaze 0.0.0-snapshot.4c063ef → 0.0.0-snapshot.4e8eab7

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,6 +22,7 @@ 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
+ - **Mix colors** — blend two colors with OKHSL or sRGB interpolation, opaque or transparent, with optional contrast solving
25
26
  - **Shadow colors** — OKHSL-native shadow computation with automatic alpha, fg/bg tinting, and per-scheme adaptation
26
27
  - **Light + Dark + High-Contrast** — all schemes from one definition
27
28
  - **Per-color hue override** — absolute or relative hue shifts within a theme
@@ -69,9 +70,9 @@ const danger = primary.extend({ hue: 23 });
69
70
  const success = primary.extend({ hue: 157 });
70
71
 
71
72
  // Compose into a palette and export
72
- const palette = glaze.palette({ primary, danger, success });
73
- const tokens = palette.tokens({ prefix: true });
74
- // → { light: { 'primary-surface': 'okhsl(...)', ... }, dark: { 'primary-surface': 'okhsl(...)', ... } }
73
+ const palette = glaze.palette({ primary, danger, success }, { primary: 'primary' });
74
+ const tokens = palette.tokens();
75
+ // → { light: { 'primary-surface': 'okhsl(...)', 'surface': 'okhsl(...)', ... }, dark: { ... } }
75
76
  ```
76
77
 
77
78
  ## Core Concepts
@@ -193,6 +194,8 @@ A single value applies to both modes. All control is local and explicit.
193
194
  'muted': { base: 'surface', lightness: ['-35', '-50'], contrast: ['AA-large', 'AA'] }
194
195
  ```
195
196
 
197
+ **Full lightness spectrum in HC mode:** In high-contrast variants, the `lightLightness` and `darkLightness` window constraints are bypassed entirely. Colors can reach the full 0–100 lightness range, maximizing perceivable contrast. Normal (non-HC) variants continue to use the configured windows.
198
+
196
199
  ## Theme Color Management
197
200
 
198
201
  ### Adding Colors
@@ -260,15 +263,306 @@ The export contains only the configuration — not resolved color values. Resolv
260
263
  Create a single color token without a full theme:
261
264
 
262
265
  ```ts
263
- const accent = glaze.color({ hue: 280, saturation: 80, lightness: 52, mode: 'fixed' });
266
+ const accent = glaze.color({ hue: 280, saturation: 80, lightness: 52 });
267
+
268
+ accent.resolve(); // → ResolvedColor with light/dark/lightContrast/darkContrast
269
+ accent.token(); // → { '': 'okhsl(...)', '@dark': 'okhsl(...)' } (tasty format)
270
+ accent.tasty(); // → { '': 'okhsl(...)', '@dark': 'okhsl(...)' } (same as token)
271
+ accent.json(); // → { light: 'okhsl(...)', dark: 'okhsl(...)' }
272
+ accent.css({ name: 'accent' });
273
+ // → { light: '--accent-color: rgb(...);', dark: '--accent-color: rgb(...);', ... }
274
+ accent.export(); // → JSON-safe snapshot — pass to `glaze.colorFrom(...)` to rehydrate
275
+ ```
276
+
277
+ ### Defaults
278
+
279
+ `glaze.color()` is tuned for "render this exact color, but adapt the
280
+ dark variant" — different from theme colors, which are seeds that
281
+ adapt to both lightness windows. The defaults vary by input form,
282
+ because string inputs are typically end-user values (color pickers,
283
+ theme settings) where natural light/dark inversion is the expectation:
284
+
285
+ Every input form defaults to **`mode: 'auto'`** so the resolved token
286
+ adapts between light and dark like an ordinary theme color. The
287
+ *scaling* snapshot taken at create time differs by input form:
288
+
289
+ - **String value-shorthand** (hex, `rgb()`, `hsl()`, `okhsl()`,
290
+ `oklch()`):
291
+ - Light variant preserves the input lightness exactly.
292
+ - Dark variant is **Möbius-inverted** into `[globalConfig.darkLightness[0], 100]`,
293
+ so `glaze.color('#000')` renders as `#fff` in dark mode and
294
+ `glaze.color('#fff')` falls to the dark `lo` floor (default `0.15`).
295
+ - The dark `lo` is snapshotted from `globalConfig` at color-creation
296
+ time, matching how an explicit `scaling.darkLightness: [lo, hi]`
297
+ behaves.
298
+
299
+ - **Object / tuple value-shorthand** (`{ h, s, l }`, `[r, g, b]`) and
300
+ the **structured form** (`{ hue, saturation, lightness, ... }`):
301
+ - Both light and dark variants are mapped through
302
+ `globalConfig.lightLightness` / `globalConfig.darkLightness`
303
+ (defaults `[10, 100]` / `[15, 95]`) — the same windows a theme color
304
+ uses. With the `'auto'` default the dark variant is Möbius-inverted
305
+ into that dark window, so a near-white seed lands at a near-dark
306
+ dark variant.
307
+ - Both windows are snapshotted at color-creation time so later
308
+ `glaze.configure()` calls don't retroactively change exported tokens.
309
+
310
+ To opt back into the legacy fixed-linear default (no Möbius inversion),
311
+ pass `{ mode: 'fixed' }` as the second arg, or supply an explicit
312
+ `scaling` as the third arg (see [Lightness scaling](#lightness-scaling)).
313
+
314
+ ```ts
315
+ // Default: pure black inverts to pure white in dark mode.
316
+ glaze.color('#000000').tasty();
317
+ // → { '': 'okhsl(0 0% 0%)', '@dark': 'okhsl(... 100%)' }
318
+
319
+ // Opt back into the fixed-linear behavior:
320
+ glaze.color('#000000', { mode: 'fixed' }).tasty();
321
+ // → { '': 'okhsl(0 0% 0%)', '@dark': 'okhsl(... 15%)' }
322
+ ```
323
+
324
+ ### Value Shorthand
325
+
326
+ The first argument can also be a color value — Glaze extracts the seed
327
+ hue/saturation/lightness for you. All forms support the same exports
328
+ (`resolve / token / tasty / json / css`):
329
+
330
+ ```ts
331
+ // Hex (3, 6, or 8 digits — alpha dropped with warning)
332
+ glaze.color('#26fcb2').tasty();
333
+ glaze.color('#26fcb2ff').tasty(); // alpha dropped
334
+
335
+ // CSS color functions Glaze itself emits (`rgb()`, `hsl()`, `okhsl()`, `oklch()`)
336
+ // — anything from theme.tasty()/json()/css() round-trips back in.
337
+ glaze.color('rgb(38 252 178)').tasty();
338
+ glaze.color('hsl(152 97% 57%)').tasty();
339
+ glaze.color('okhsl(152 95% 74%)').tasty();
340
+ glaze.color('oklch(0.85 0.18 152)').tasty();
341
+
342
+ // OKHSL object — Glaze's native shape (h: 0–360, s/l: 0–1).
343
+ // Passing 0–100 values for s/l throws with a hint to use the
344
+ // structured form { hue, saturation, lightness }.
345
+ glaze.color({ h: 152, s: 0.95, l: 0.74 }).tasty();
346
+
347
+ // RGB tuple, 0–255 (same range as glaze.fromRgb).
348
+ glaze.color([38, 252, 178]).tasty();
349
+ ```
350
+
351
+ The optional second argument supplies overrides — the WCAG `contrast`
352
+ solver, relative `hue` / `lightness`, plus the usual seed knobs:
353
+
354
+ ```ts
355
+ // Brand color seeded from a hex, with saturation/mode overrides
356
+ glaze.color('#26fcb2', { saturation: 80, mode: 'fixed' }).tasty();
357
+
358
+ // Brand text guaranteed AAA against the seed itself.
359
+ // Relative `lightness: '+48'` is anchored to the literal seed value.
360
+ glaze.color('#1a1a2e', {
361
+ lightness: '+48',
362
+ contrast: 'AAA',
363
+ }).tasty();
364
+ ```
365
+
366
+ By default, relative `lightness: '+N'` and `contrast: <ratio>` are
367
+ anchored to the literal seed (the value passed to `glaze.color()`).
368
+ Internally Glaze synthesizes a hidden `mode: 'static'` reference of
369
+ the seed so the contrast solver compares against the unmapped color
370
+ across every variant. Pass `base` (another `glaze.color()` token) to
371
+ anchor against another color's resolved variant per scheme instead —
372
+ see [Pairing Colors](#pairing-colors).
373
+
374
+ All overrides:
375
+
376
+ | Option | Notes |
377
+ |---|---|
378
+ | `hue` | Number (absolute 0–360) or `'+N'`/`'-N'` (relative to seed — never to `base`) |
379
+ | `saturation` | Override seed saturation (0–100) |
380
+ | `lightness` | Number (absolute 0–100) or `'+N'`/`'-N'`. Without `base`, relative is anchored to the literal seed; with `base`, anchored to `base`'s lightness per scheme. Supports `[normal, hc]` pairs |
381
+ | `saturationFactor` | Multiplier on seed (0–1, default 1) |
382
+ | `mode` | `'auto'` (default for every input form) / `'fixed'` / `'static'` — see [Adaptation Modes](#adaptation-modes) |
383
+ | `contrast` | WCAG floor. Without `base`, anchored to the literal seed; with `base`, solved per scheme against `base`'s resolved variant. Same shape as `RegularColorDef.contrast`. When the target can't be physically met, `glaze` emits a `console.warn` and returns the closest passing variant |
384
+ | `base` | Another `glaze.color()` token **or** a raw `GlazeColorValue` (hex / `rgb()` / `OkhslColor` / `[r, g, b]`). Raw values are auto-wrapped via `glaze.color(value)` so they pick up the same auto-invert defaults as an explicit wrap. When set, `contrast` and relative `lightness` anchor to it per scheme; relative `hue` still anchors to the seed |
385
+ | `opacity` | Fixed alpha 0–1 applied to every variant. Surfaces in `rgb(... / A)`, `okhsl(... / A)`, etc. Combining with `contrast` is not recommended (perceived lightness becomes unpredictable) — `glaze` emits a `console.warn` |
386
+ | `name` | **Debug label only** — surfaces in error and `console.warn` messages instead of the internal `"value"` sentinel. Does **not** change `.token()` / `.tasty()` / `.json()` / `.css()` output keys (those still use `''`, `light`, etc.). Reserved names (`"value"`, `"seed"`, `"externalBase"`) are rejected |
387
+
388
+ Alpha components in `rgb(... / A)` / `hsl(... / A)` / `rgba(...)` /
389
+ `hsla(...)` and 8-digit hex (`#rrggbbaa` / `#rgba`) are parsed but the
390
+ alpha channel is dropped with a `console.warn`. To set a fixed alpha
391
+ on a standalone color, use the `opacity` override (or `opacity` on a
392
+ theme color). Named CSS colors (`'red'`, `'blueviolet'`) are not
393
+ supported.
394
+
395
+ ### Lightness Scaling
396
+
397
+ The optional third positional argument lets you override the lightness
398
+ windows used by `glaze.color()`. Both keys mirror the field names from
399
+ `GlazeConfig`:
400
+
401
+ ```ts
402
+ // Preserve raw lightness in dark mode too:
403
+ glaze.color('#26fcb2', undefined, { darkLightness: false }).tasty();
404
+
405
+ // Or opt back into a theme-style window:
406
+ glaze.color('#26fcb2', undefined, {
407
+ lightLightness: [10, 100],
408
+ darkLightness: [15, 95],
409
+ }).tasty();
410
+
411
+ // Structured form takes scaling as the second positional arg:
412
+ glaze
413
+ .color({ hue: 152, saturation: 95, lightness: 74 }, { darkLightness: false })
414
+ .tasty();
415
+ ```
416
+
417
+ | Key | Default for `glaze.color()` (string input) | Default for `glaze.color()` (object / tuple / structured) | Effect |
418
+ |---|---|---|---|
419
+ | `lightLightness` | `false` | `false` | `false` = preserve input. Pass `[lo, hi]` to opt into a remap window. |
420
+ | `darkLightness` | `[globalConfig.darkLightness[0], 100]` (snapshotted; default `[15, 100]`) | `globalConfig.darkLightness` (snapshotted; default `[15, 95]`) | `false` = preserve input in dark too. Pass `[lo, hi]` to override the window. |
421
+
422
+ > Note: `scaling` is all-or-nothing — passing it replaces both fields
423
+ > at once. To keep one field's default, restate it explicitly. The
424
+ > default windows are snapshotted from `globalConfig` at color-creation
425
+ > time, so later `glaze.configure()` calls don't retroactively change
426
+ > already-created tokens (and `token.export()` round-trips
427
+ > byte-for-byte across `configure()` changes).
428
+
429
+ ### Pairing Colors
264
430
 
265
- accent.resolve(); // ResolvedColor with light/dark/lightContrast/darkContrast
266
- accent.token(); // { '': 'okhsl(...)', '@dark': 'okhsl(...)' } (tasty format)
267
- accent.tasty(); // { '': 'okhsl(...)', '@dark': 'okhsl(...)' } (same as token)
268
- accent.json(); // { light: 'okhsl(...)', dark: 'okhsl(...)' }
431
+ `glaze.color()` accepts an optional `base` override that ties one
432
+ standalone color to another. When you set `base`, the WCAG contrast
433
+ solver and relative `lightness` offsets switch their anchor from the
434
+ literal seed to the base's resolved variant per scheme — so the same
435
+ text color automatically lands at AA against its background in light,
436
+ dark, and high-contrast modes.
437
+
438
+ ```ts
439
+ const bg = glaze.color('#1a1a2e');
440
+
441
+ // Text guaranteed AA against `bg` in every scheme.
442
+ const text = glaze.color('#ffffff', { base: bg, contrast: 'AA' });
443
+
444
+ // Border 8 lightness units lighter than `bg` in each scheme.
445
+ const border = glaze.color('#000000', {
446
+ base: bg,
447
+ lightness: '+8',
448
+ mode: 'fixed',
449
+ });
269
450
  ```
270
451
 
271
- Standalone colors are always root colors (no `base`/`contrast`).
452
+ `base` also accepts a raw `GlazeColorValue` for one-off pairs without
453
+ a separate token binding:
454
+
455
+ ```ts
456
+ // Equivalent to `base: glaze.color('#1a1a2e')` — `glaze` auto-wraps it.
457
+ const text = glaze.color('#ffffff', { base: '#1a1a2e', contrast: 'AA' });
458
+ ```
459
+
460
+ Behavior with `base`:
461
+
462
+ - `contrast` is solved per scheme against `base`'s resolved variant
463
+ (light / dark / lightContrast / darkContrast).
464
+ - Relative `lightness: '+N'` / `'-N'` is anchored to `base`'s lightness
465
+ per scheme (matches theme behavior).
466
+ - Relative `hue: '+N'` still anchors to the **seed** (the value passed
467
+ to `glaze.color()`), not the base. Absolute hue overrides take
468
+ precedence as usual.
469
+ - `mode` works as a per-pair knob — pass `mode: 'fixed'` to disable
470
+ Möbius inversion for the dependent color, or `mode: 'auto'` to keep
471
+ it (defaults follow the same string-vs-object rules as standalone).
472
+ - The base token's `.resolve()` is called lazily on the first resolve
473
+ of the dependent and the result is captured by reference; later
474
+ mutations to the base don't apply (matches existing snapshot
475
+ semantics for `scaling.darkLightness`).
476
+ - Raw value bases (`base: '#fff'`, `base: { h, s, l }`, `base: [r, g, b]`)
477
+ are auto-wrapped via `glaze.color(value)` and inherit the same
478
+ string-vs-object defaults. To skip auto-invert on the base, wrap it
479
+ yourself: `base: glaze.color(value, undefined, { darkLightness: false })`.
480
+ - When the contrast target is physically unreachable (e.g. AAA against
481
+ a mid-grey base), `glaze` emits a single `console.warn` per
482
+ `(name, scheme, target)` triple and returns the closest passing
483
+ variant. Use the `name` override to make the warning more
484
+ identifiable in your logs.
485
+
486
+ Chains compose:
487
+
488
+ ```ts
489
+ const bg = glaze.color('#000000');
490
+ const surface = glaze.color('#222222', { base: bg, contrast: 'AAA' });
491
+ const text = glaze.color('#ffffff', { base: surface, contrast: 'AA' });
492
+ // Each level meets its contrast budget against its base in every scheme.
493
+ ```
494
+
495
+ ### Naming Standalone Colors
496
+
497
+ The `name` override is a **debug label**, not an output key:
498
+
499
+ ```ts
500
+ const cardBg = glaze.color('#1a1a2e', {
501
+ name: 'card-bg', // surfaces in `console.warn` / Error messages
502
+ });
503
+
504
+ cardBg.token(); // → { '': 'okhsl(...)', '@dark': 'okhsl(...)' }
505
+ cardBg.json(); // → { light: 'okhsl(...)', dark: 'okhsl(...)' }
506
+ cardBg.css({ name: 'card' }); // CSS variable name comes from `css({ name })`,
507
+ // NOT from the override above
508
+ ```
509
+
510
+ Use it to make warnings traceable when you have many `glaze.color()`
511
+ calls in a project — without it, `glaze` falls back to the internal
512
+ sentinel `"value"`:
513
+
514
+ ```ts
515
+ // With name:
516
+ // > glaze: color "card-bg" cannot meet contrast "AAA" (7.00) in dark scheme...
517
+
518
+ // Without name:
519
+ // > glaze: color "value" cannot meet contrast "AAA" (7.00) in dark scheme...
520
+ ```
521
+
522
+ The reserved internal sentinels (`"value"`, `"seed"`, `"externalBase"`)
523
+ are rejected with a clear error pointing at the conflict.
524
+
525
+ ### Persisting Standalone Colors
526
+
527
+ `glaze.color()` tokens can be serialized to JSON-safe data and
528
+ rehydrated later — useful for color pickers, theme settings UIs, and
529
+ URL state.
530
+
531
+ ```ts
532
+ const text = glaze.color('#1a1a1a', {
533
+ contrast: 'AA',
534
+ opacity: 0.9,
535
+ name: 'profile-text',
536
+ });
537
+
538
+ const data = text.export(); // JSON-safe snapshot
539
+ const json = JSON.stringify(data); // ship to localStorage / API / URL
540
+ const restored = glaze.colorFrom(JSON.parse(json));
541
+ // `restored.resolve()` matches `text.resolve()` byte-for-byte.
542
+ ```
543
+
544
+ The export captures the original `value`, all overrides, and the
545
+ effective `scaling` (snapshotted from `globalConfig` at create time so
546
+ later `glaze.configure()` calls don't change exported tokens).
547
+ Token-typed `base` is recursively serialized, value-typed `base` is
548
+ preserved as the raw value.
549
+
550
+ Both forms round-trip:
551
+
552
+ ```ts
553
+ // Value form
554
+ const a = glaze.color('#26fcb2', { contrast: 'AA' });
555
+ const aBack = glaze.colorFrom(a.export());
556
+
557
+ // Structured form
558
+ const b = glaze.color({
559
+ hue: 280,
560
+ saturation: 50,
561
+ lightness: 50,
562
+ opacity: 0.5,
563
+ });
564
+ const bBack = glaze.colorFrom(b.export());
565
+ ```
272
566
 
273
567
  ## From Existing Colors
274
568
 
@@ -390,7 +684,10 @@ Available tuning parameters:
390
684
 
391
685
  ### Standalone Shadow Computation
392
686
 
393
- Compute a shadow outside of a theme:
687
+ Compute a shadow outside of a theme. `bg` and `fg` accept any
688
+ `GlazeColorValue`: hex (`#rgb` / `#rrggbb` / `#rrggbbaa`), `rgb()` /
689
+ `hsl()` / `okhsl()` / `oklch()` strings, OKHSL objects, or `[r, g, b]`
690
+ (0–255) tuples.
394
691
 
395
692
  ```ts
396
693
  const v = glaze.shadow({
@@ -400,6 +697,13 @@ const v = glaze.shadow({
400
697
  });
401
698
  // → { h: 280, s: 0.14, l: 0.2, alpha: 0.1 }
402
699
 
700
+ // Equivalent with non-hex inputs:
701
+ glaze.shadow({
702
+ bg: 'rgb(240 238 245)',
703
+ fg: { h: 280, s: 0.06, l: 0.13 },
704
+ intensity: 10,
705
+ });
706
+
403
707
  const css = glaze.format(v, 'oklch');
404
708
  // → 'oklch(0.15 0.014 280 / 0.1)'
405
709
  ```
@@ -413,6 +717,139 @@ const css = glaze.format(v, 'oklch');
413
717
  }
414
718
  ```
415
719
 
720
+ ## Mix Colors
721
+
722
+ Mix colors blend two existing colors together. Use them for hover overlays, tints, shades, and any derived color that sits between two reference colors.
723
+
724
+ ### Opaque Mix
725
+
726
+ Produces a solid color by interpolating between `base` and `target`:
727
+
728
+ ```ts
729
+ theme.colors({
730
+ surface: { lightness: 95 },
731
+ accent: { lightness: 30 },
732
+
733
+ // 30% of the way from surface toward accent
734
+ tint: { type: 'mix', base: 'surface', target: 'accent', value: 30 },
735
+ });
736
+ ```
737
+
738
+ - `value` — mix ratio 0–100 (0 = pure base, 100 = pure target)
739
+ - The result is a fully opaque color (alpha = 1)
740
+ - Adapts to light/dark/HC schemes automatically via the resolved base and target
741
+
742
+ ### Transparent Mix
743
+
744
+ Produces the target color with a controlled opacity — useful for hover overlays:
745
+
746
+ ```ts
747
+ theme.colors({
748
+ surface: { lightness: 95 },
749
+ black: { lightness: 0, saturation: 0 },
750
+
751
+ hover: {
752
+ type: 'mix',
753
+ base: 'surface',
754
+ target: 'black',
755
+ value: 8,
756
+ blend: 'transparent',
757
+ },
758
+ });
759
+ // hover → target color (black) with alpha = 0.08
760
+ ```
761
+
762
+ The output color has `h`, `s`, `l` from the target and `alpha = value / 100`.
763
+
764
+ ### Blend Space
765
+
766
+ By default, opaque mixing interpolates in OKHSL (perceptually uniform, consistent with Glaze's model). Use `space: 'srgb'` for linear sRGB interpolation, which matches browser compositing:
767
+
768
+ ```ts
769
+ theme.colors({
770
+ surface: { lightness: 95 },
771
+ accent: { lightness: 30 },
772
+
773
+ // sRGB blend — matches what the browser would render
774
+ hover: { type: 'mix', base: 'surface', target: 'accent', value: 20, space: 'srgb' },
775
+ });
776
+ ```
777
+
778
+ | Space | Behavior | Best for |
779
+ |---|---|---|
780
+ | `'okhsl'` (default) | Perceptually uniform OKHSL interpolation | Design token derivation |
781
+ | `'srgb'` | Linear sRGB channel interpolation | Matching browser compositing |
782
+
783
+ The `space` option only affects opaque blending. Transparent blending always composites in linear sRGB (matching browser alpha compositing).
784
+
785
+ ### Contrast Solving
786
+
787
+ Mix colors support the same `contrast` prop as regular colors. The solver adjusts the mix ratio (opaque) or opacity (transparent) to meet the WCAG target:
788
+
789
+ ```ts
790
+ theme.colors({
791
+ surface: { lightness: 95 },
792
+ accent: { lightness: 30 },
793
+
794
+ // Ensure the mixed color has at least AA contrast against surface
795
+ tint: {
796
+ type: 'mix',
797
+ base: 'surface',
798
+ target: 'accent',
799
+ value: 10,
800
+ contrast: 'AA',
801
+ },
802
+
803
+ // Ensure the transparent overlay has at least 3:1 contrast
804
+ overlay: {
805
+ type: 'mix',
806
+ base: 'surface',
807
+ target: 'accent',
808
+ value: 5,
809
+ blend: 'transparent',
810
+ contrast: 3,
811
+ },
812
+ });
813
+ ```
814
+
815
+ ### High-Contrast Pairs
816
+
817
+ Both `value` and `contrast` support `[normal, highContrast]` pairs:
818
+
819
+ ```ts
820
+ theme.colors({
821
+ surface: { lightness: 95 },
822
+ accent: { lightness: 30 },
823
+
824
+ tint: {
825
+ type: 'mix',
826
+ base: 'surface',
827
+ target: 'accent',
828
+ value: [20, 40], // stronger mix in high-contrast mode
829
+ contrast: [3, 'AAA'], // stricter contrast in high-contrast mode
830
+ },
831
+ });
832
+ ```
833
+
834
+ ### Achromatic Colors
835
+
836
+ When mixing with achromatic colors (saturation near zero, e.g., white or black) in `okhsl` space, the hue comes from whichever color has saturation. This prevents meaningless hue artifacts and matches CSS `color-mix()` "missing component" behavior. For purely achromatic mixes, prefer `space: 'srgb'` where hue is irrelevant.
837
+
838
+ ### Mix Chaining
839
+
840
+ Mix colors can reference other mix colors, enabling multi-step derivations:
841
+
842
+ ```ts
843
+ theme.colors({
844
+ white: { lightness: 100, saturation: 0 },
845
+ black: { lightness: 0, saturation: 0 },
846
+ gray: { type: 'mix', base: 'white', target: 'black', value: 50, space: 'srgb' },
847
+ lightGray: { type: 'mix', base: 'white', target: 'gray', value: 50, space: 'srgb' },
848
+ });
849
+ ```
850
+
851
+ Mix colors cannot reference shadow colors (same restriction as regular dependent colors).
852
+
416
853
  ## Output Formats
417
854
 
418
855
  Control the color format in exports with the `format` option:
@@ -431,7 +868,7 @@ theme.tokens({ format: 'hsl' }); // → 'hsl(270.5 45.2% 95.8%)'
431
868
  theme.tokens({ format: 'oklch' }); // → 'oklch(0.965 0.0123 280)'
432
869
  ```
433
870
 
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()`.
871
+ 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()` / `.css()`.
435
872
 
436
873
  Colors with `alpha < 1` (shadow colors, or regular colors with `opacity`) include an alpha component:
437
874
 
@@ -467,7 +904,7 @@ Modes control how colors adapt across schemes:
467
904
 
468
905
  ```ts
469
906
  // Light: surface L=97, text lightness='-52' → L=45 (dark text on light bg)
470
- // Dark: surface inverts to L≈14, sign flips → L=14+52=66
907
+ // Dark: surface inverts to L≈20 (Möbius curve), sign flips → L=20+52=72
471
908
  // contrast solver may push further (light text on dark bg)
472
909
  ```
473
910
 
@@ -484,14 +921,14 @@ Modes control how colors adapt across schemes:
484
921
 
485
922
  ### Lightness
486
923
 
487
- Root color lightness is mapped linearly within the configured `lightLightness` window:
924
+ Absolute lightness values (both root colors and dependent colors with absolute lightness) are mapped linearly within the configured `lightLightness` window:
488
925
 
489
926
  ```ts
490
927
  const [lo, hi] = lightLightness; // default: [10, 100]
491
928
  const mappedL = (lightness * (hi - lo)) / 100 + lo;
492
929
  ```
493
930
 
494
- Both `auto` and `fixed` modes use the same linear formula. `static` mode bypasses the mapping entirely.
931
+ Both `auto` and `fixed` modes use the same linear formula. `static` mode and high-contrast variants bypass the mapping entirely (identity: `mappedL = l`).
495
932
 
496
933
  | Color | Raw L | Mapped L (default [10, 100]) |
497
934
  |---|---|---|
@@ -503,24 +940,29 @@ Both `auto` and `fixed` modes use the same linear formula. `static` mode bypasse
503
940
 
504
941
  ### Lightness
505
942
 
506
- **`auto`** — inverted within the configured window:
943
+ **`auto`** — inverted with a Möbius transformation within the configured window:
507
944
 
508
945
  ```ts
509
946
  const [lo, hi] = darkLightness; // default: [15, 95]
510
- const invertedL = ((100 - lightness) * (hi - lo)) / 100 + lo;
947
+ const t = (100 - lightness) / 100;
948
+ const invertedL = lo + (hi - lo) * t / (t + darkCurve * (1 - t)); // darkCurve default: 0.5
511
949
  ```
512
950
 
513
- **`fixed`** mapped without inversion:
951
+ The `darkCurve` parameter (default `0.5`, range 0–1) controls how much the dark-mode inversion expands lightness deltas. Lower values produce stronger expansion; `1` gives linear (legacy) behavior. Accepts a `[normal, highContrast]` pair for separate HC tuning (e.g. `darkCurve: [0.5, 0.3]`); a single number applies to both. Unlike a power curve, the Möbius transformation provides **proportional expansion** — small and large deltas are scaled by similar ratios, preserving the visual hierarchy of the light theme.
952
+
953
+ **`fixed`** — mapped without inversion (not affected by `darkCurve`):
514
954
 
515
955
  ```ts
516
956
  const mappedL = (lightness * (hi - lo)) / 100 + lo;
517
957
  ```
518
958
 
519
- | Color | Light L | Auto (inverted) | Fixed (mapped) |
520
- |---|---|---|---|
521
- | surface (L=97) | 97 | 17.4 | 92.6 |
522
- | accent-fill (L=52) | 52 | 53.4 | 56.6 |
523
- | accent-text (L=100) | 100 | 15 | 95 |
959
+ | Color | Light L | Auto (curve=0.5) | Auto (curve=1, linear) | Fixed (mapped) |
960
+ |---|---|---|---|---|
961
+ | surface (L=97) | 97 | 19.7 | 17.4 | 92.6 |
962
+ | accent-fill (L=52) | 52 | 66.9 | 53.4 | 56.6 |
963
+ | accent-text (L=100) | 100 | 15 | 15 | 95 |
964
+
965
+ In high-contrast variants, the `darkLightness` window is bypassed — auto uses the Möbius curve over the full [0, 100] range, and fixed uses identity (`L`). To use a different curve shape for HC, pass a `[normal, hc]` pair to `darkCurve` (e.g. `darkCurve: [0.5, 0.3]`).
524
966
 
525
967
  ### Saturation
526
968
 
@@ -560,12 +1002,21 @@ Combine multiple themes into a single palette:
560
1002
  const palette = glaze.palette({ primary, danger, success, warning });
561
1003
  ```
562
1004
 
563
- ### Token Export
1005
+ Optionally designate a primary theme at creation time:
564
1006
 
565
- Tokens are grouped by scheme variant, with plain color names as keys:
1007
+ ```ts
1008
+ const palette = glaze.palette(
1009
+ { primary, danger, success, warning },
1010
+ { primary: 'primary' },
1011
+ );
1012
+ ```
1013
+
1014
+ ### Prefix Behavior
1015
+
1016
+ Palette export methods (`tokens()`, `tasty()`, `css()`) default to `prefix: true` — all tokens are automatically prefixed with the theme name to avoid collisions:
566
1017
 
567
1018
  ```ts
568
- const tokens = palette.tokens({ prefix: true });
1019
+ const tokens = palette.tokens();
569
1020
  // → {
570
1021
  // light: { 'primary-surface': 'okhsl(...)', 'danger-surface': 'okhsl(...)' },
571
1022
  // dark: { 'primary-surface': 'okhsl(...)', 'danger-surface': 'okhsl(...)' },
@@ -578,15 +1029,68 @@ Custom prefix mapping:
578
1029
  palette.tokens({ prefix: { primary: 'brand-', danger: 'error-' } });
579
1030
  ```
580
1031
 
581
- ### Tasty Export (for [Tasty](https://cube-ui-kit.vercel.app/?path=/docs/tasty-documentation--docs) style system)
1032
+ To disable prefixing entirely, pass `prefix: false` explicitly.
1033
+
1034
+ ### Collision Detection
1035
+
1036
+ When two themes produce the same output key (via `prefix: false`, custom prefix maps, or primary unprefixed aliases), the first-written value wins and a `console.warn` is emitted:
1037
+
1038
+ ```ts
1039
+ const palette = glaze.palette({ a, b });
1040
+ palette.tokens({ prefix: false });
1041
+ // ⚠ glaze: token "surface" from theme "b" collides with theme "a" — skipping.
1042
+ ```
1043
+
1044
+ ### Primary Theme
582
1045
 
583
- The `tasty()` method exports tokens in the [Tasty](https://cube-ui-kit.vercel.app/?path=/docs/tasty-documentation--docs) style-to-state binding format `#name` color token keys with state aliases (`''`, `@dark`, etc.):
1046
+ The primary theme's tokens are duplicated without prefix, providing convenient short aliases alongside the prefixed versions. Set at palette creation to apply to all exports automatically:
584
1047
 
585
1048
  ```ts
586
- const tastyTokens = palette.tasty({ prefix: true });
1049
+ const palette = glaze.palette(
1050
+ { primary, danger, success },
1051
+ { primary: 'primary' },
1052
+ );
1053
+ const tokens = palette.tokens();
1054
+ // → {
1055
+ // light: {
1056
+ // 'primary-surface': 'okhsl(...)', // prefixed (all themes)
1057
+ // 'danger-surface': 'okhsl(...)',
1058
+ // 'success-surface': 'okhsl(...)',
1059
+ // 'surface': 'okhsl(...)', // unprefixed alias (primary only)
1060
+ // },
1061
+ // }
1062
+ ```
1063
+
1064
+ Override or disable per-export:
1065
+
1066
+ ```ts
1067
+ palette.tokens({ primary: 'danger' }); // use danger as primary for this call
1068
+ palette.tokens({ primary: false }); // no primary for this call
1069
+ ```
1070
+
1071
+ The `primary` option works on `tokens()`, `tasty()`, and `css()`. It combines with any prefix mode — when using a custom prefix map, primary tokens are still duplicated without prefix:
1072
+
1073
+ ```ts
1074
+ palette.tokens({ prefix: { primary: 'p-', danger: 'd-' } });
1075
+ // → 'p-surface' + 'surface' (alias from palette-level primary) + 'd-surface'
1076
+ ```
1077
+
1078
+ An error is thrown if the primary name doesn't match any theme in the palette.
1079
+
1080
+ ### Tasty Export (for [Tasty](https://tasty.style) style system)
1081
+
1082
+ The `tasty()` method exports tokens in the [Tasty](https://tasty.style/docs) style-to-state binding format — `#name` color token keys with state aliases (`''`, `@dark`, etc.). See the [Playground](https://tasty.style/playground) for live examples of Glaze integration:
1083
+
1084
+ ```ts
1085
+ const palette = glaze.palette(
1086
+ { primary, danger, success },
1087
+ { primary: 'primary' },
1088
+ );
1089
+ const tastyTokens = palette.tasty();
587
1090
  // → {
588
1091
  // '#primary-surface': { '': 'okhsl(...)', '@dark': 'okhsl(...)' },
589
1092
  // '#danger-surface': { '': 'okhsl(...)', '@dark': 'okhsl(...)' },
1093
+ // '#surface': { '': 'okhsl(...)', '@dark': 'okhsl(...)' }, // alias
590
1094
  // }
591
1095
  ```
592
1096
 
@@ -653,8 +1157,10 @@ palette.tasty({ states: { dark: '@dark', highContrast: '@hc' } });
653
1157
 
654
1158
  ### JSON Export (Framework-Agnostic)
655
1159
 
1160
+ JSON export groups by theme name (no prefix needed):
1161
+
656
1162
  ```ts
657
- const data = palette.json({ prefix: true });
1163
+ const data = palette.json();
658
1164
  // → {
659
1165
  // primary: { surface: { light: 'okhsl(...)', dark: 'okhsl(...)' } },
660
1166
  // danger: { surface: { light: 'okhsl(...)', dark: 'okhsl(...)' } },
@@ -676,7 +1182,11 @@ const css = theme.css();
676
1182
  Use in a stylesheet:
677
1183
 
678
1184
  ```ts
679
- const css = palette.css({ prefix: true });
1185
+ const palette = glaze.palette(
1186
+ { primary, danger, success },
1187
+ { primary: 'primary' },
1188
+ );
1189
+ const css = palette.css();
680
1190
 
681
1191
  const stylesheet = `
682
1192
  :root { ${css.light} }
@@ -692,7 +1202,8 @@ Options:
692
1202
  |---|---|---|
693
1203
  | `format` | `'rgb'` | Color format (`'rgb'`, `'hsl'`, `'okhsl'`, `'oklch'`) |
694
1204
  | `suffix` | `'-color'` | Suffix appended to each CSS property name |
695
- | `prefix` | | (palette only) Same prefix behavior as `tokens()` |
1205
+ | `prefix` | `true` (palette) | (palette only) `true` uses `"<themeName>-"`, or provide a custom map |
1206
+ | `primary` | inherited | (palette only) Override or disable (`false`) the palette-level primary for this call |
696
1207
 
697
1208
  ```ts
698
1209
  // Custom suffix
@@ -703,9 +1214,9 @@ theme.css({ suffix: '' });
703
1214
  theme.css({ format: 'hsl' });
704
1215
  // → "--surface-color: hsl(...);"
705
1216
 
706
- // Palette with prefix
707
- palette.css({ prefix: true });
708
- // → "--primary-surface-color: rgb(...);\n--danger-surface-color: rgb(...);"
1217
+ // Palette with primary (inherited from palette creation)
1218
+ palette.css();
1219
+ // → "--primary-surface-color: rgb(...);\n--surface-color: rgb(...);\n--danger-surface-color: rgb(...);"
709
1220
  ```
710
1221
 
711
1222
  ## Output Modes
@@ -738,9 +1249,10 @@ Resolution priority (highest first):
738
1249
 
739
1250
  ```ts
740
1251
  glaze.configure({
741
- lightLightness: [10, 100], // Light scheme lightness window [lo, hi]
742
- darkLightness: [15, 95], // Dark scheme lightness window [lo, hi]
1252
+ lightLightness: [10, 100], // Light scheme lightness window [lo, hi] (bypassed in HC)
1253
+ darkLightness: [15, 95], // Dark scheme lightness window [lo, hi] (bypassed in HC)
743
1254
  darkDesaturation: 0.1, // Saturation reduction in dark scheme (0–1)
1255
+ darkCurve: 0.5, // Möbius beta for dark auto-inversion (0–1); or [normal, hc] pair
744
1256
  states: {
745
1257
  dark: '@dark', // State alias for dark mode tokens
746
1258
  highContrast: '@high-contrast',
@@ -758,10 +1270,10 @@ glaze.configure({
758
1270
 
759
1271
  ## Color Definition Shape
760
1272
 
761
- `ColorDef` is a discriminated union of regular colors and shadow colors:
1273
+ `ColorDef` is a discriminated union of regular colors, shadow colors, and mix colors:
762
1274
 
763
1275
  ```ts
764
- type ColorDef = RegularColorDef | ShadowColorDef;
1276
+ type ColorDef = RegularColorDef | ShadowColorDef | MixColorDef;
765
1277
 
766
1278
  interface RegularColorDef {
767
1279
  lightness?: HCPair<number | RelativeValue>;
@@ -780,15 +1292,24 @@ interface ShadowColorDef {
780
1292
  intensity: HCPair<number>; // 0–100
781
1293
  tuning?: ShadowTuning;
782
1294
  }
1295
+
1296
+ interface MixColorDef {
1297
+ type: 'mix';
1298
+ base: string; // "from" color name
1299
+ target: string; // "to" color name
1300
+ value: HCPair<number>; // 0–100 (mix ratio or opacity)
1301
+ blend?: 'opaque' | 'transparent'; // default: 'opaque'
1302
+ space?: 'okhsl' | 'srgb'; // default: 'okhsl'
1303
+ contrast?: HCPair<MinContrast>;
1304
+ }
783
1305
  ```
784
1306
 
785
- 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.
1307
+ 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. Mix colors use `type: 'mix'` and must reference two non-shadow colors.
786
1308
 
787
1309
  ## Validation
788
1310
 
789
1311
  | Condition | Behavior |
790
1312
  |---|---|
791
- | Both absolute `lightness` and `base` on same color | Warning, `lightness` takes precedence |
792
1313
  | `contrast` without `base` | Validation error |
793
1314
  | Relative `lightness` without `base` | Validation error |
794
1315
  | `lightness` resolves outside 0–100 | Clamp silently |
@@ -802,6 +1323,12 @@ A root color must have absolute `lightness` (a number). A dependent color must h
802
1323
  | Regular color `base` references a shadow color | Validation error |
803
1324
  | Shadow `intensity` outside 0–100 | Clamp silently |
804
1325
  | `contrast` + `opacity` combined | Warning |
1326
+ | Mix `base` references non-existent color | Validation error |
1327
+ | Mix `target` references non-existent color | Validation error |
1328
+ | Mix `base` references a shadow color | Validation error |
1329
+ | Mix `target` references a shadow color | Validation error |
1330
+ | Mix `value` outside 0–100 | Clamp silently |
1331
+ | Circular references involving mix colors | Validation error |
805
1332
 
806
1333
  ## Advanced: Color Math Utilities
807
1334
 
@@ -847,6 +1374,10 @@ primary.colors({
847
1374
  'shadow-md': { type: 'shadow', bg: 'surface', fg: 'text', intensity: 10 },
848
1375
  'shadow-lg': { type: 'shadow', bg: 'surface', fg: 'text', intensity: 20 },
849
1376
 
1377
+ // Mix colors — hover overlays and tints
1378
+ 'hover': { type: 'mix', base: 'surface', target: 'accent-fill', value: 8, blend: 'transparent' },
1379
+ 'tint': { type: 'mix', base: 'surface', target: 'accent-fill', value: 20 },
1380
+
850
1381
  // Fixed-alpha overlay
851
1382
  overlay: { lightness: 0, opacity: 0.5 },
852
1383
  });
@@ -856,18 +1387,21 @@ const success = primary.extend({ hue: 157 });
856
1387
  const warning = primary.extend({ hue: 84 });
857
1388
  const note = primary.extend({ hue: 302 });
858
1389
 
859
- const palette = glaze.palette({ primary, danger, success, warning, note });
1390
+ const palette = glaze.palette(
1391
+ { primary, danger, success, warning, note },
1392
+ { primary: 'primary' },
1393
+ );
860
1394
 
861
- // Export as flat token map grouped by variant
862
- const tokens = palette.tokens({ prefix: true });
863
- // tokens.light → { 'primary-surface': 'okhsl(...)', 'primary-shadow-md': 'okhsl(... / 0.1)' }
1395
+ // Export as flat token map grouped by variant (prefix defaults to true)
1396
+ const tokens = palette.tokens();
1397
+ // tokens.light → { 'primary-surface': '...', 'surface': '...', 'danger-surface': '...' }
864
1398
 
865
1399
  // Export as tasty style-to-state bindings (for Tasty style system)
866
- const tastyTokens = palette.tasty({ prefix: true });
1400
+ const tastyTokens = palette.tasty();
867
1401
 
868
1402
  // Export as CSS custom properties (rgb format by default)
869
- const css = palette.css({ prefix: true });
870
- // css.light → "--primary-surface-color: rgb(...);\n--primary-shadow-md-color: rgb(... / 0.1);"
1403
+ const css = palette.css();
1404
+ // css.light → "--primary-surface-color: rgb(...);\n--surface-color: rgb(...);\n--danger-surface-color: rgb(...);"
871
1405
 
872
1406
  // Standalone shadow computation
873
1407
  const v = glaze.shadow({ bg: '#f0eef5', fg: '#1a1a2e', intensity: 10 });
@@ -894,8 +1428,10 @@ brand.colors({ surface: { lightness: 97 }, text: { base: 'surface', lightness: '
894
1428
  | `glaze.from(data)` | Create a theme from an exported configuration |
895
1429
  | `glaze.fromHex(hex)` | Create a theme from a hex color (`#rgb` or `#rrggbb`) |
896
1430
  | `glaze.fromRgb(r, g, b)` | Create a theme from RGB values (0–255) |
897
- | `glaze.color(input)` | Create a standalone color token |
898
- | `glaze.shadow(input)` | Compute a standalone shadow color (returns `ResolvedColorVariant`) |
1431
+ | `glaze.color(input, scaling?)` | Create a standalone color token from `{ hue, saturation, lightness, opacity?, contrast?, base?, name?, ... }`. Optional `scaling` overrides the lightness windows |
1432
+ | `glaze.color(value, overrides?, scaling?)` | Create a standalone color token from a hex string (3/6/8 digits), an `rgb()` / `hsl()` / `okhsl()` / `oklch()` string, an `{ h, s, l }` OKHSL object, or an `[r, g, b]` (0–255) tuple. Overrides accept absolute or relative `hue` / `lightness`, `saturation`, `mode`, `contrast`, `opacity`, `name`, and `base` (a `GlazeColorToken` or any `GlazeColorValue`; raw values are auto-wrapped). When `base` is set, `contrast` and relative `lightness` are anchored to the base per scheme — see [Pairing Colors](#pairing-colors). Every input form defaults to `mode: 'auto'`; string inputs additionally preserve light lightness exactly and extend the dark window to `[lo, 100]`, while object / tuple / structured inputs snapshot both windows from `globalConfig.lightLightness` / `globalConfig.darkLightness`. |
1433
+ | `glaze.colorFrom(data)` | Rehydrate a `glaze.color()` token from a `.export()` snapshot. Inverse of `token.export()` — see [Persisting Standalone Colors](#persisting-standalone-colors) |
1434
+ | `glaze.shadow(input)` | Compute a standalone shadow color (returns `ResolvedColorVariant`). `bg` / `fg` accept any `GlazeColorValue` form |
899
1435
  | `glaze.format(variant, format?)` | Format any `ResolvedColorVariant` as a CSS string |
900
1436
 
901
1437
  ### Theme Methods
@@ -913,7 +1449,7 @@ brand.colors({ surface: { lightness: 97 }, text: { base: 'surface', lightness: '
913
1449
  | `theme.extend(options)` | Create a child theme |
914
1450
  | `theme.resolve()` | Resolve all colors |
915
1451
  | `theme.tokens(options?)` | Export as flat token map grouped by variant |
916
- | `theme.tasty(options?)` | Export as [Tasty](https://cube-ui-kit.vercel.app/?path=/docs/tasty-documentation--docs) style-to-state bindings |
1452
+ | `theme.tasty(options?)` | Export as [Tasty](https://tasty.style/docs) style-to-state bindings |
917
1453
  | `theme.json(options?)` | Export as plain JSON |
918
1454
  | `theme.css(options?)` | Export as CSS custom property declarations |
919
1455
 
@@ -922,7 +1458,7 @@ brand.colors({ surface: { lightness: 97 }, text: { base: 'surface', lightness: '
922
1458
  | Method | Description |
923
1459
  |---|---|
924
1460
  | `glaze.configure(config)` | Set global configuration |
925
- | `glaze.palette(themes)` | Compose themes into a palette |
1461
+ | `glaze.palette(themes, options?)` | Compose themes into a palette (options: `{ primary? }`) |
926
1462
  | `glaze.getConfig()` | Get current global config |
927
1463
  | `glaze.resetConfig()` | Reset to defaults |
928
1464