@tenphi/glaze 1.1.1 → 1.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 +1 -0
- package/dist/index.cjs +507 -129
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +181 -7
- package/dist/index.d.mts +181 -7
- package/dist/index.mjs +507 -130
- package/dist/index.mjs.map +1 -1
- package/docs/api.md +241 -11
- package/docs/methodology.md +49 -1
- package/docs/okhst.md +34 -1
- package/package.json +1 -1
package/docs/api.md
CHANGED
|
@@ -10,7 +10,9 @@ Full reference for every public method, option, and type exported by `@tenphi/gl
|
|
|
10
10
|
- [DTCG Resolver-Module](#themedtcgresolveroptions)
|
|
11
11
|
- [Tailwind CSS](#themetailwindoptions)
|
|
12
12
|
- [High-contrast pairs](#high-contrast-pairs)
|
|
13
|
+
- [Manual contrast level](#manual-contrast-level)
|
|
13
14
|
- [Color definitions](#color-definitions)
|
|
15
|
+
- [Dark seed](#dark-seed-darkhue--darksaturation)
|
|
14
16
|
- [Standalone color tokens](#standalone-color-tokens)
|
|
15
17
|
- [Shadows](#shadows)
|
|
16
18
|
- [Mix colors](#mix-colors)
|
|
@@ -31,7 +33,7 @@ Full reference for every public method, option, and type exported by `@tenphi/gl
|
|
|
31
33
|
| Method | Description |
|
|
32
34
|
| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
|
33
35
|
| `glaze(hue, saturation?, config?)` | Create a theme from hue (0–360) and saturation (0–100). Optional `config` overrides the global config for this theme. |
|
|
34
|
-
| `glaze({ hue, saturation }, config?)` | Create a theme from an options object, with optional per-theme config override.
|
|
36
|
+
| `glaze({ hue, saturation, darkHue?, darkSaturation? }, config?)` | Create a theme from an options object, with optional per-theme config override. `darkHue` / `darkSaturation` seed the dark schemes — see [Dark seed](#dark-seed-darkhue--darksaturation). |
|
|
35
37
|
| `glaze.themeFrom(data)` | Create a theme from a `theme.export()` snapshot (`kind: 'theme'`). |
|
|
36
38
|
| `glaze.from(data)` | Compat alias for `glaze.themeFrom`. |
|
|
37
39
|
| `glaze.fromHex(hex)` | Create a theme from a hex color (`#rgb` or `#rrggbb`). Extracts hue and saturation. |
|
|
@@ -45,6 +47,7 @@ Full reference for every public method, option, and type exported by `@tenphi/gl
|
|
|
45
47
|
```ts
|
|
46
48
|
const a = glaze(280, 80);
|
|
47
49
|
const b = glaze({ hue: 280, saturation: 80 });
|
|
50
|
+
const bDark = glaze({ hue: 280, saturation: 80, darkHue: 268, darkSaturation: 65 });
|
|
48
51
|
const c = glaze.fromHex('#7a4dbf');
|
|
49
52
|
const d = glaze.fromRgb(122, 77, 191);
|
|
50
53
|
const e = glaze.themeFrom(a.export());
|
|
@@ -75,6 +78,8 @@ A `GlazeTheme` exposes:
|
|
|
75
78
|
| ------------------------------- | --------------------------------------------------------------------------------------------------- |
|
|
76
79
|
| `theme.hue` (readonly) | The hue seed (0–360). |
|
|
77
80
|
| `theme.saturation` (readonly) | The saturation seed (0–100). |
|
|
81
|
+
| `theme.darkHue` (readonly) | The dark-scheme hue seed (0–360), or `undefined` when `hue` is reused. |
|
|
82
|
+
| `theme.darkSaturation` (readonly) | The dark-scheme saturation seed (0–100), or `undefined` when `saturation` is reused. |
|
|
78
83
|
| `theme.colors(defs)` | Add/replace colors (additive merge — adds new, overwrites existing by name, doesn't remove others). |
|
|
79
84
|
| `theme.color(name)` | Get a color definition by name. |
|
|
80
85
|
| `theme.color(name, def)` | Set a single color definition. |
|
|
@@ -128,6 +133,8 @@ const highSat = base.extend({ config: { darkTone: [10, 100] } });
|
|
|
128
133
|
| ------------ | --------------------- | -------------------------------------------------------------------------------------------- |
|
|
129
134
|
| `hue` | `number` | Replace the hue seed. Defaults to the parent's hue. |
|
|
130
135
|
| `saturation` | `number` | Replace the saturation seed. Defaults to the parent's saturation. |
|
|
136
|
+
| `darkHue` | `number` | Replace the dark hue seed. Defaults to the parent's `darkHue`. |
|
|
137
|
+
| `darkSaturation` | `number` | Replace the dark saturation seed. Defaults to the parent's `darkSaturation`. |
|
|
131
138
|
| `colors` | `ColorMap` | Per-theme overrides (additive merge over the inherited map). |
|
|
132
139
|
| `config` | `GlazeConfigOverride` | Config override for the child. Shallow-merged with the parent's override — child fields win. |
|
|
133
140
|
|
|
@@ -402,6 +409,144 @@ promotion for that color.
|
|
|
402
409
|
|
|
403
410
|
---
|
|
404
411
|
|
|
412
|
+
## Manual contrast level
|
|
413
|
+
|
|
414
|
+
`contrastLevel` turns contrast from a two-tier switch into a `0–100` slider.
|
|
415
|
+
It is a config field, so it works globally, per theme, per token, and through
|
|
416
|
+
`extend()`:
|
|
417
|
+
|
|
418
|
+
```ts
|
|
419
|
+
glaze.configure({ contrastLevel: 60 });
|
|
420
|
+
const theme = glaze(280, 80, { contrastLevel: 60 });
|
|
421
|
+
glaze.color('#26fcb2', { contrastLevel: 60 });
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
| Value | Meaning |
|
|
425
|
+
| ---------------- | -------------------------------------------------------------------- |
|
|
426
|
+
| `'auto'` | Default. The two-tier model: normal variants plus a high-contrast tier. |
|
|
427
|
+
| `0` | Normal contrast, with **no** high-contrast tier. |
|
|
428
|
+
| `100` | The high-contrast scheme as the only scheme. |
|
|
429
|
+
| anything between | Resolved *at* that level. |
|
|
430
|
+
|
|
431
|
+
Levels `0` and `100` reproduce the classic `light` / `dark` and
|
|
432
|
+
`lightContrast` / `darkContrast` output **bit for bit**.
|
|
433
|
+
|
|
434
|
+
### What the level interpolates
|
|
435
|
+
|
|
436
|
+
Exactly the three things that make a high-contrast variant differ from its
|
|
437
|
+
normal counterpart:
|
|
438
|
+
|
|
439
|
+
| Mechanism | At level `L` |
|
|
440
|
+
| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
|
441
|
+
| Authored `HCPair`s — `tone`, `contrast`, shadow `intensity`, mix `value` | the two ends are interpolated |
|
|
442
|
+
| The tone-window bypass | each endpoint moves toward the full range: light `10→0`, dark `15→0 / 95→100` |
|
|
443
|
+
| Contrast escalation — `AA → AAA`, APCA `+15 Lc` | the two numeric targets are interpolated (`AA` at level 50 solves for 5.75) |
|
|
444
|
+
|
|
445
|
+
The interpolated values are then fed through the ordinary resolve, so a
|
|
446
|
+
contrast floor at any level is **solved**, not approximated, and `autoFlip`
|
|
447
|
+
decides once per level.
|
|
448
|
+
|
|
449
|
+
Everything that does not vary by high contrast today — hue, saturation,
|
|
450
|
+
`darkDesaturation`, `opacity`, `pastel` — is unaffected by the level.
|
|
451
|
+
|
|
452
|
+
### Un-interpolable tone pairs switch at 50
|
|
453
|
+
|
|
454
|
+
Two ends of different kinds have no midpoint, because blending them would
|
|
455
|
+
change *which* resolution rule applies partway up the ramp. These switch from
|
|
456
|
+
the normal entry to the high-contrast one at level 50:
|
|
457
|
+
|
|
458
|
+
```ts
|
|
459
|
+
tone: [50, 'max']; // absolute vs. extreme
|
|
460
|
+
tone: [50, '+20']; // absolute vs. relative
|
|
461
|
+
tone: ['max', 'min']; // opposite extremes
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
Same-kind pairs (`[30, 20]`, `['-6', '-12']`, `{ wcag: [4.5, 7] }`,
|
|
465
|
+
`[{ apca: 60 }, { apca: 90 }]`) interpolate smoothly.
|
|
466
|
+
|
|
467
|
+
A `contrast` pair may **not** switch metric — `[4.5, { apca: 75 }]` throws. A
|
|
468
|
+
WCAG ratio and an APCA Lc are different scales, so no target exists between
|
|
469
|
+
them, and even in `'auto'` mode the two variants would be incomparable. Pair
|
|
470
|
+
values inside one metric instead: `{ wcag: [4.5, 7] }` or `{ apca: [60, 90] }`.
|
|
471
|
+
|
|
472
|
+
### A color never swaps sides mid-slider
|
|
473
|
+
|
|
474
|
+
[`autoFlip`](#autoflip) decides per solve, and its tie-break — when both
|
|
475
|
+
directions meet the floor, take the one nearer the authored tone — depends on
|
|
476
|
+
the target. Along a ramp of targets that criterion shifts, so a naive
|
|
477
|
+
implementation lets a color leap across its own base as the slider moves.
|
|
478
|
+
|
|
479
|
+
Under a manual level the side is instead decided once, from the nearer
|
|
480
|
+
endpoint's target, and preferred at every level in that half of the ramp.
|
|
481
|
+
Consequences:
|
|
482
|
+
|
|
483
|
+
- A color whose two ends land on the same side of its base **never** changes
|
|
484
|
+
side, at any level.
|
|
485
|
+
- A color whose ends genuinely disagree — no single side can satisfy both its
|
|
486
|
+
normal and its high-contrast floor — changes side exactly once, at level 50,
|
|
487
|
+
the same place un-interpolable tone pairs switch.
|
|
488
|
+
- Flipping is not disabled, only re-ordered, so a side that physically cannot
|
|
489
|
+
reach the requested contrast still falls back to the opposite one. The floor
|
|
490
|
+
is met at every level.
|
|
491
|
+
|
|
492
|
+
The underlying solver option is `preferInitial` on
|
|
493
|
+
[`findToneForContrast`](#contrast-solver), should you need the same behavior
|
|
494
|
+
directly.
|
|
495
|
+
|
|
496
|
+
### High-contrast output
|
|
497
|
+
|
|
498
|
+
A manual level already carries the contrast preference, so there is no second
|
|
499
|
+
tier to emit:
|
|
500
|
+
|
|
501
|
+
- `resolve()` still returns four variants, but `lightContrast` / `darkContrast`
|
|
502
|
+
**mirror** `light` / `dark`.
|
|
503
|
+
- A **global** level turns high-contrast output off outright, so
|
|
504
|
+
`tokens()` / `tasty()` / `json()` / `dtcg()` / `dtcgResolver()` / `tailwind()`
|
|
505
|
+
emit no high-contrast tier. `modes.highContrast` goes inert — it reads as
|
|
506
|
+
"emit a separate high-contrast set *when* contrast is automatic" — so leaving
|
|
507
|
+
`highContrast: true` set in a build config while a user switches their
|
|
508
|
+
preference from auto to manual is fine, and silent.
|
|
509
|
+
- `css()` always returns four strings and ignores `modes`, so its
|
|
510
|
+
`lightContrast` / `darkContrast` strings repeat the normal declarations —
|
|
511
|
+
an existing `@media (prefers-contrast: more)` block keeps matching the base
|
|
512
|
+
block as the level changes, with nothing to rewire.
|
|
513
|
+
- A level on a **single theme or token** does not change which modes are
|
|
514
|
+
emitted. Sibling themes in a palette keep their real high-contrast tier, and
|
|
515
|
+
the manual one reports its own resolved values there.
|
|
516
|
+
|
|
517
|
+
### Driving it at runtime
|
|
518
|
+
|
|
519
|
+
`configure()` bumps the config version, which invalidates every theme and token
|
|
520
|
+
cache, so a slider only needs to re-export:
|
|
521
|
+
|
|
522
|
+
```ts
|
|
523
|
+
slider.oninput = () => {
|
|
524
|
+
glaze.configure({ contrastLevel: slider.valueAsNumber });
|
|
525
|
+
apply(theme.css());
|
|
526
|
+
};
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
Because a manual level skips the two high-contrast passes, a manual resolve is
|
|
530
|
+
also half the work of an `'auto'` one.
|
|
531
|
+
|
|
532
|
+
### Clearing it, exporting it, base links
|
|
533
|
+
|
|
534
|
+
- **`configure()` never clears a field by omission.** Pass `'auto'` explicitly
|
|
535
|
+
to leave manual mode — globally, or on one theme of a palette.
|
|
536
|
+
- **`.export()` freezes only an *authored* level.** A level set on the instance
|
|
537
|
+
(or passed to `.export()`) is authored intent and is written to the snapshot;
|
|
538
|
+
a level merely inherited from the global config is a live user preference and
|
|
539
|
+
is left out, so a restored theme still follows the current slider. This
|
|
540
|
+
matches how `modes` and `states` are treated.
|
|
541
|
+
- **Set the level globally, or on both sides of a base link.** A per-token level
|
|
542
|
+
on a dependent but not on its base anchors the two at different levels — the
|
|
543
|
+
same caveat as a per-instance `lightTone` override.
|
|
544
|
+
|
|
545
|
+
`resolveContrastForLevel(spec, level, polarity?)` is exported for advanced use;
|
|
546
|
+
see [Contrast solver](#contrast-solver).
|
|
547
|
+
|
|
548
|
+
---
|
|
549
|
+
|
|
405
550
|
## Color definitions
|
|
406
551
|
|
|
407
552
|
`ColorDef` is a discriminated union:
|
|
@@ -417,6 +562,8 @@ type ColorDef = RegularColorDef | ShadowColorDef | MixColorDef;
|
|
|
417
562
|
| `tone` | `HCPair<ToneValue>` | Number = absolute (0–100). `'+N'`/`'-N'` = a signed **tone delta** from the base (requires `base`). `'max'`/`'min'` = forced to the scheme's tone extreme (no `base`). Optional HC pair `[normal, hc]`. |
|
|
418
563
|
| `saturation` | `number` | Saturation factor applied to the seed saturation (0–1). Default: `1`. |
|
|
419
564
|
| `hue` | `number \| RelativeValue` | Number = absolute (0–360). String (`'+N'`/`'-N'`) = relative to the **theme seed hue** (never to a base color). |
|
|
565
|
+
| `darkHue` | `number \| RelativeValue` | Dark-scheme hue. Number = absolute (0–360). String = relative to the **theme dark seed hue**. Falls back to `hue`. See [Dark seed](#dark-seed-darkhue--darksaturation). |
|
|
566
|
+
| `darkSaturation` | `number` | Dark-scheme saturation factor (0–1) over the dark seed saturation. Falls back to `saturation`. When set, the global `darkDesaturation` reduction is **not** applied on top. |
|
|
420
567
|
| `base` | `string` | Name of another color in the same theme — makes this a _dependent_ color. |
|
|
421
568
|
| `contrast` | `HCPair<ContrastSpec>` | Contrast floor against `base`. Requires `base`. See [`contrast`](#contrast-floor). |
|
|
422
569
|
| `mode` | `'auto' \| 'fixed' \| 'static'` | Adaptation mode. Default: `'auto'`. See [Adaptation modes](#adaptation-modes). |
|
|
@@ -437,7 +584,7 @@ can drift in measured luminance. See [OKHST in Glaze](okhst.md). To port old
|
|
|
437
584
|
| ------------------- | ----------------------- | --------------------------------------------------------------------------------------------- |
|
|
438
585
|
| Number (absolute) | `tone: 45` | Absolute tone 0–100. |
|
|
439
586
|
| String (tone delta) | `tone: '-52'` | Signed difference from the base color's resolved tone (requires `base`). |
|
|
440
|
-
| Extreme | `tone: 'max'` / `'min'` | Force to the scheme's highest (`'max'` = 100) or lowest (`'min'` = 0) tone. No `base` needed. |
|
|
587
|
+
| Extreme | `tone: 'max'` / `'min'` | Force to the scheme's highest (`'max'` = 100) or lowest (`'min'` = 0) tone. With a `base`, the light-scheme shift is replayed in dark. No `base` needed. |
|
|
441
588
|
| HC pair | `tone: ['-7', '-20']` | `[normal, high-contrast]`. A single value applies to both. |
|
|
442
589
|
|
|
443
590
|
**Absolute tone** on a dependent color (`base` set) positions the color independently. In dark mode it is tone-mapped (inverted + windowed) on its own. The `contrast` solver acts as a safety net.
|
|
@@ -450,7 +597,18 @@ is clamped to the boundary, or — with `autoFlip` (default on) — mirrored to
|
|
|
450
597
|
other side of the base. If the mirrored target is also out of range, the original
|
|
451
598
|
delta is kept and clamped on the authored side.
|
|
452
599
|
|
|
453
|
-
**Extreme tone** (`'max'` / `'min'`) forces the color to the scheme's tone extreme without a contrast hack or a magic number. `'max'` resolves to author tone 100 and `'min'` to 0
|
|
600
|
+
**Extreme tone** (`'max'` / `'min'`) forces the color to the scheme's tone extreme without a contrast hack or a magic number. `'max'` resolves to author tone 100 and `'min'` to 0. Without a `base`, both flow through scheme mapping like an absolute tone, so under `mode: 'auto'` they invert in dark (`'max'` is lightest in light, darkest in dark). Use `mode: 'static'` to pin the same extreme across schemes, or `mode: 'fixed'` to keep the same end without inverting. No `base` required.
|
|
601
|
+
|
|
602
|
+
**Extreme tone with a `base`** keeps the pair's contrast across schemes. The tone windows are asymmetric by default (`lightTone: [10, 100]`, `darkTone: [15, 95]`), so re-mapping the extreme through the dark window would squeeze the base-to-extreme span and cost contrast. Instead Glaze measures the tone shift the light scheme applied between the base and the extreme, then replays it against the base's resolved dark tone — mirrored under `mode: 'auto'` (both ends invert), same-signed under `'fixed'`. The result is clamped to `[0, 100]` only, so it may cross the `darkTone` window edge; that is intentional, since the author asked for the extreme. When the shift does not fit above or below the dark base, the color pins at tone 100 / 0. `mode: 'static'` is unaffected, and high-contrast variants already use the full range.
|
|
603
|
+
|
|
604
|
+
```ts
|
|
605
|
+
theme.colors({
|
|
606
|
+
bg: { tone: 60 },
|
|
607
|
+
fg: { base: 'bg', tone: 'max' },
|
|
608
|
+
});
|
|
609
|
+
// light: bg 62.0, fg 100.0 — shift +38.0, contrast 3.18:1
|
|
610
|
+
// dark: bg 43.9, fg 5.9 — shift -38.0, contrast 3.18:1
|
|
611
|
+
```
|
|
454
612
|
|
|
455
613
|
A dependent color with `base` but no `tone` inherits the base's tone (equivalent to a delta of 0).
|
|
456
614
|
|
|
@@ -471,7 +629,7 @@ type ContrastSpec =
|
|
|
471
629
|
| number // bare WCAG ratio
|
|
472
630
|
| ContrastPreset // named WCAG preset
|
|
473
631
|
| { wcag: HCPair<number | ContrastPreset> }
|
|
474
|
-
| { apca: HCPair<number> }; // APCA Lc target
|
|
632
|
+
| { apca: HCPair<number | ApcaPreset> }; // APCA Lc target
|
|
475
633
|
```
|
|
476
634
|
|
|
477
635
|
| Preset | WCAG ratio |
|
|
@@ -549,6 +707,48 @@ theme.colors({
|
|
|
549
707
|
|
|
550
708
|
Relative hue is always relative to the **theme seed hue**, not to a base color.
|
|
551
709
|
|
|
710
|
+
#### Dark seed (`darkHue` / `darkSaturation`)
|
|
711
|
+
|
|
712
|
+
Tone inverts automatically between light and dark, but hue and saturation do
|
|
713
|
+
not — by default every scheme reuses the one seed, with dark getting a flat
|
|
714
|
+
`darkDesaturation` haircut. When a palette needs a genuinely different chroma in
|
|
715
|
+
dark (a cooler accent, a calmer surface tint), author a second seed:
|
|
716
|
+
|
|
717
|
+
```ts
|
|
718
|
+
const theme = glaze({
|
|
719
|
+
hue: 280,
|
|
720
|
+
saturation: 80,
|
|
721
|
+
darkHue: 268, // dark schemes seed from 268 instead of 280
|
|
722
|
+
darkSaturation: 65, // and from 65 instead of 80
|
|
723
|
+
});
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
Individual colors can override either channel for dark on their own:
|
|
727
|
+
|
|
728
|
+
```ts
|
|
729
|
+
theme.colors({
|
|
730
|
+
surface: { tone: 97 },
|
|
731
|
+
accent: { tone: 55, hue: '+20', darkHue: '+35' },
|
|
732
|
+
warning: { tone: 60, saturation: 0.9, darkSaturation: 0.6 },
|
|
733
|
+
});
|
|
734
|
+
```
|
|
735
|
+
|
|
736
|
+
Both apply to the `dark` **and** `darkContrast` variants. The rules:
|
|
737
|
+
|
|
738
|
+
| Rule | Behavior |
|
|
739
|
+
| ---- | -------- |
|
|
740
|
+
| Units | Seed-level `darkSaturation` is `0–100` (like `saturation` on the theme). Def-level `darkSaturation` is a `0–1` factor (like `saturation` on a color def). |
|
|
741
|
+
| Fallback | `darkHue` falls back to `hue`, `darkSaturation` to `saturation`, and the def falls back to the seed. Omitting everything reproduces the previous behavior exactly. |
|
|
742
|
+
| Relative hue | `darkHue: '+N'` anchors to the theme's **dark** seed hue. A plain `hue: '+N'` with no `darkHue` also re-anchors to the dark seed, so a whole palette rotates together. |
|
|
743
|
+
| Absolute hue | An absolute `hue` (no `darkHue`) is used verbatim in dark — absolute means absolute in every scheme. |
|
|
744
|
+
| `darkDesaturation` | Bypassed as soon as any explicit dark saturation is authored, at either level. The value you write is the value you get. |
|
|
745
|
+
| `mode: 'static'` | Ignores both. A static color is pinned to one hue and saturation across every scheme. |
|
|
746
|
+
| Shadows and mixes | Have no channels of their own — they derive hue and saturation from their `bg` / `fg` / `target`, so they inherit dark overrides automatically. |
|
|
747
|
+
|
|
748
|
+
With [`splitHue`](#hue-channel-splitting-splithue), a dark seed makes the hue
|
|
749
|
+
custom properties scheme-dependent; Glaze re-declares them in the dark block
|
|
750
|
+
automatically.
|
|
751
|
+
|
|
552
752
|
#### Per-color `pastel`
|
|
553
753
|
|
|
554
754
|
`pastel: true` on a single color def overrides the per-theme / per-token `pastel` override for that color only. It toggles the hue-independent "safe" chroma limit used in every OKHSL↔sRGB conversion that touches this color: luminance calculations during contrast solving, gamut clamping during sRGB blend / mix edges, and output formatting. The effective flag is carried on the resolved variant (`ResolvedColorVariant.pastel`) so formatting matches the gamut mapping applied during resolution.
|
|
@@ -688,6 +888,9 @@ glaze.color(color: GlazeFromInput | GlazeColorInput | GlazeColorValue, config?:
|
|
|
688
888
|
| `saturation` | `number` | 0–100. |
|
|
689
889
|
| `tone` | `HCPair<number \| ExtremeValue>` | 0–100 (contrast-shaped) or `'max'`/`'min'`, optional HC pair. |
|
|
690
890
|
| `saturationFactor` | `number` | Multiplier on the seed (0–1). Default: `1`. |
|
|
891
|
+
| `darkHue` | `number \| RelativeValue` | Dark-scheme hue. Absolute (0–360) or `'+N'`/`'-N'` relative to the dark seed hue (which defaults to `hue`). Falls back to `hue`. |
|
|
892
|
+
| `darkSaturation` | `number` | Dark-scheme seed saturation (0–100). Falls back to `saturation`. |
|
|
893
|
+
| `darkSaturationFactor` | `number` | Dark-scheme multiplier on the dark seed (0–1). Falls back to `saturationFactor`. |
|
|
691
894
|
| `mode` | `AdaptationMode` | Default: `'auto'`. |
|
|
692
895
|
| `autoFlip` | `boolean` | Flip out-of-bounds results instead of clamping. Default: global `autoFlip`. |
|
|
693
896
|
| `opacity` | `number` | Fixed alpha 0–1. |
|
|
@@ -706,6 +909,9 @@ glaze.color(color: GlazeFromInput | GlazeColorInput | GlazeColorValue, config?:
|
|
|
706
909
|
| `saturation` | Override seed saturation (0–100). |
|
|
707
910
|
| `tone` | Number (absolute 0–100), `'+N'`/`'-N'`, or `'max'`/`'min'`. Without `base`, relative anchors to the seed; with `base`, anchors to `base`'s tone per scheme. |
|
|
708
911
|
| `saturationFactor` | Multiplier on the seed (0–1). |
|
|
912
|
+
| `darkHue` | Dark-scheme hue: absolute (0–360) or `'+N'`/`'-N'` relative to the dark seed hue. Falls back to `hue`. |
|
|
913
|
+
| `darkSaturation` | Dark-scheme seed saturation (0–100). Falls back to `saturation`. |
|
|
914
|
+
| `darkSaturationFactor` | Dark-scheme multiplier on the dark seed (0–1). Falls back to `saturationFactor`. |
|
|
709
915
|
| `mode` | `'auto'` (default) / `'fixed'` / `'static'`. |
|
|
710
916
|
| `autoFlip` | Flip out-of-bounds results instead of clamping. Default: global `autoFlip`. |
|
|
711
917
|
| `contrast` | Contrast floor (WCAG or APCA). Without `base`, anchored to the literal seed; with `base`, solved per scheme. |
|
|
@@ -1358,6 +1564,21 @@ On `theme.css()`, `theme.tasty()`, `palette.css()`, `palette.tasty()`, and stand
|
|
|
1358
1564
|
|
|
1359
1565
|
**Requirements:** every exported color must be pastel (`pastel: true` on the theme/token override or per-color). Pastel mode bounds chroma by the hue-independent safe chroma at each lightness, so emitted `C` stays in sRGB for any rotated hue. Non-pastel palettes throw rather than emit values that would clip under rotation.
|
|
1360
1566
|
|
|
1567
|
+
**Dark hues:** hue vars are scheme-independent unless the theme or a color authors a [dark hue](#dark-seed-darkhue--darksaturation). When one does, the whole hue set is re-declared in the dark block (and in the Tasty dark state, gated by `modes.dark`):
|
|
1568
|
+
|
|
1569
|
+
```css
|
|
1570
|
+
/* glaze({ hue: 240, saturation: 18, darkHue: 200 }) */
|
|
1571
|
+
:root {
|
|
1572
|
+
--brand-hue: 240;
|
|
1573
|
+
--accent-hue: calc(var(--brand-hue) + 20);
|
|
1574
|
+
}
|
|
1575
|
+
/* dark */
|
|
1576
|
+
--brand-hue: 200;
|
|
1577
|
+
--accent-hue: calc(var(--brand-hue) + 20);
|
|
1578
|
+
```
|
|
1579
|
+
|
|
1580
|
+
`--accent-hue` repeats verbatim on purpose: a custom property substitutes `var()` at computed-value time on the element that declares it, so it has to be re-declared to pick up the new `--brand-hue`. Colors with `mode: 'static'` are pinned to an absolute hue instead, so they don't drift with the dark seed.
|
|
1581
|
+
|
|
1361
1582
|
**Limitations:** `oklch` only (native CSS `var()` in the hue slot). Shadow and mix colors stay inline (blended hue). Standalone `.token()` / `.tasty()` do not support `splitHue` (return shape cannot carry the `$name-hue` declaration).
|
|
1362
1583
|
|
|
1363
1584
|
`theme.dtcg()` / `theme.dtcgResolver()` / `palette.dtcg()` / `palette.dtcgResolver()` ignore `format` — DTCG emits structured `$value` objects, not CSS strings. Use the `colorSpace` option (`'srgb'` or `'oklch'`) to pick the color representation instead.
|
|
@@ -1471,6 +1692,7 @@ glaze.configure({
|
|
|
1471
1692
|
alphaMax: 0.6,
|
|
1472
1693
|
bgHueBlend: 0.2,
|
|
1473
1694
|
},
|
|
1695
|
+
contrastLevel: 'auto', // or 0–100 for a manual contrast slider
|
|
1474
1696
|
});
|
|
1475
1697
|
```
|
|
1476
1698
|
|
|
@@ -1483,16 +1705,17 @@ boundaries, not the tone transfer.
|
|
|
1483
1705
|
|
|
1484
1706
|
| Field | Default | Description |
|
|
1485
1707
|
| --------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1486
|
-
| `lightTone` | `[10, 100]` | Light scheme tone window: `[lo, hi]`, `{ lo, hi, eps }`, or `false` to disable clamping. Bypassed in HC.
|
|
1487
|
-
| `darkTone` | `[15, 95]` | Dark scheme tone window: `[lo, hi]`, `{ lo, hi, eps }`, or `false` to disable clamping. Bypassed in HC.
|
|
1708
|
+
| `lightTone` | `[10, 100]` | Light scheme tone window: `[lo, hi]`, `{ lo, hi, eps }`, or `false` to disable clamping. Bypassed in HC; widened continuously by [`contrastLevel`](#manual-contrast-level). |
|
|
1709
|
+
| `darkTone` | `[15, 95]` | Dark scheme tone window: `[lo, hi]`, `{ lo, hi, eps }`, or `false` to disable clamping. Bypassed in HC; widened continuously by [`contrastLevel`](#manual-contrast-level). |
|
|
1488
1710
|
| `darkDesaturation` | `0.1` | Saturation reduction in dark scheme (0–1). |
|
|
1489
1711
|
| `states.dark` | `'@media(prefers-color-scheme: dark)'` | State alias for dark mode tokens ([Tasty](https://tasty.style) export). Defaults to a media query so tokens react to the OS preference without registering custom states. |
|
|
1490
1712
|
| `states.highContrast` | `'@media(prefers-contrast: more)'` | State alias for HC tokens ([Tasty](https://tasty.style) export). |
|
|
1491
1713
|
| `modes.dark` | `true` | Include dark variants in exports. |
|
|
1492
|
-
| `modes.highContrast` | `false` | Include HC variants.
|
|
1714
|
+
| `modes.highContrast` | `false` | Include HC variants. Inert while a global [`contrastLevel`](#manual-contrast-level) is set — a manual level has no separate HC tier, so this reads as "emit HC variants when contrast is automatic". |
|
|
1493
1715
|
| `shadowTuning` | `undefined` | Default tuning for all shadow colors. Per-color tuning merges field-by-field. |
|
|
1494
1716
|
| `autoFlip` | `true` | Default for each color's `autoFlip`. When solving `contrast` (or applying a relative `tone` that overshoots `[0, 100]`), allow crossing to the opposite side instead of clamping. With `false`, only the requested direction is considered; unmet contrasts pin the tone to that direction's extreme (and emit a warning) and overshooting offsets clamp to the boundary. Override per color via [`autoFlip`](#autoflip). |
|
|
1495
1717
|
| `inferRole` | `true` | Infer each color's [`role`](#roles) from its name when no explicit `role` is set. Set to `false` to opt out of name-based inference (the base-opposite and foreground-default fallbacks still apply). |
|
|
1718
|
+
| `contrastLevel` | `'auto'` | Manual contrast level, `0`–`100`, replacing the two-tier high-contrast model with a slider. `0` reproduces the normal output and `100` the high-contrast output, bit for bit. See [Manual contrast level](#manual-contrast-level). |
|
|
1496
1719
|
|
|
1497
1720
|
| Method | Description |
|
|
1498
1721
|
| ------------------------- | ----------------------------------------------------------------------------------- |
|
|
@@ -1500,7 +1723,7 @@ boundaries, not the tone transfer.
|
|
|
1500
1723
|
| `glaze.getConfig()` | Snapshot the current resolved config (shallow copy). |
|
|
1501
1724
|
| `glaze.resetConfig()` | Reset to defaults (also bumps the version counter). |
|
|
1502
1725
|
|
|
1503
|
-
Themes and standalone color tokens keep a sparse local `GlazeConfigOverride` and merge the live global at resolve time for omitted fields. Authoring `.export(override?)` freezes the effective merge at call time; restored instances pin that freeze. `pastel` is instance-only (theme/token override or per-color) — not set via `configure()`.
|
|
1726
|
+
Themes and standalone color tokens keep a sparse local `GlazeConfigOverride` and merge the live global at resolve time for omitted fields. Authoring `.export(override?)` freezes the effective merge at call time; restored instances pin that freeze. `pastel` is instance-only (theme/token override or per-color) — not set via `configure()`. `contrastLevel` is the one field the freeze treats as a live preference: only an instance-authored level is written to the snapshot ([why](#two-rules-worth-knowing)).
|
|
1504
1727
|
|
|
1505
1728
|
---
|
|
1506
1729
|
|
|
@@ -1522,9 +1745,12 @@ palette.tokens({ modes: { dark: true, highContrast: true } });
|
|
|
1522
1745
|
|
|
1523
1746
|
Resolution priority (highest first):
|
|
1524
1747
|
|
|
1525
|
-
1.
|
|
1526
|
-
|
|
1527
|
-
|
|
1748
|
+
1. A global [`contrastLevel`](#manual-contrast-level) — pins
|
|
1749
|
+
`highContrast: false`, since a manual level leaves no separate high-contrast
|
|
1750
|
+
tier to emit. `highContrast` is inert while it is set.
|
|
1751
|
+
2. Per-call `modes` option on `tokens` / `tasty` / `json`.
|
|
1752
|
+
3. `glaze.configure({ modes })` — global config.
|
|
1753
|
+
4. Built-in default: `{ dark: true, highContrast: false }`.
|
|
1528
1754
|
|
|
1529
1755
|
---
|
|
1530
1756
|
|
|
@@ -1539,6 +1765,7 @@ available result.
|
|
|
1539
1765
|
| Condition | Behavior |
|
|
1540
1766
|
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
|
1541
1767
|
| `contrast` without `base` in a **theme** color | Validation error |
|
|
1768
|
+
| `contrast` pair switching metric (`[4.5, { apca: 75 }]`) | Validation error — a WCAG ratio and an APCA Lc are different scales |
|
|
1542
1769
|
| Relative `tone` without `base` in a **theme** color | Validation error |
|
|
1543
1770
|
| `contrast` without `base` in `glaze.color()` | Anchors against the literal seed (no error) |
|
|
1544
1771
|
| Relative `tone` without `base` in `glaze.color()` | Anchors against the literal seed (no error) |
|
|
@@ -1650,6 +1877,7 @@ import {
|
|
|
1650
1877
|
findToneForContrast,
|
|
1651
1878
|
findValueForMixContrast,
|
|
1652
1879
|
resolveContrastForMode,
|
|
1880
|
+
resolveContrastForLevel,
|
|
1653
1881
|
resolveMinContrast,
|
|
1654
1882
|
apcaContrast,
|
|
1655
1883
|
} from '@tenphi/glaze';
|
|
@@ -1660,6 +1888,7 @@ import {
|
|
|
1660
1888
|
| `findToneForContrast(opts)` | Binary-search for the tone (0–1) that meets a contrast floor (WCAG or APCA) against a base color. Returns `{ tone, contrast, met, branch, flipped? }`. |
|
|
1661
1889
|
| `findValueForMixContrast(opts)` | Same, but searches for a mix `value` (0–1) that meets a contrast floor between a base and a target. |
|
|
1662
1890
|
| `resolveContrastForMode(spec, isHC, polarity?, outerExplicitHC?)` | Resolves a `ContrastSpec` to `{ metric: 'wcag' \| 'apca', target }` for the requested mode (picks the normal or HC entry of any pair). In HC, applies the metric's auto-enhancement unless `outerExplicitHC` is set or the inner metric pair carries an explicit HC value: APCA +15 Lc (clamped to 106); WCAG AA → AAA / AA-large → AAA-large (AAA-family and bare numbers unchanged). |
|
|
1891
|
+
| `resolveContrastForLevel(spec, level, polarity?)` | Resolves a `ContrastSpec` — including its outer `[normal, highContrast]` pair — at a [manual contrast level](#manual-contrast-level) (0–100), interpolating the two numeric targets. Levels 0 / 100 delegate verbatim to `resolveContrastForMode`; a WCAG-vs-APCA metric switch across the pair has no midpoint and switches at 50. |
|
|
1663
1892
|
| `resolveMinContrast(value)` | Resolves a `MinContrast` (WCAG preset or number) to a numeric ratio. |
|
|
1664
1893
|
| `apcaContrast(yText, yBg)` | APCA Lc magnitude (0–106) for two relative luminances. |
|
|
1665
1894
|
|
|
@@ -1679,5 +1908,6 @@ Exported constants: `APCA_PRESETS`, `APCA_HC_ENHANCEMENT` (`15`, the Enhanced Le
|
|
|
1679
1908
|
| `maxIterations` | `18` | Max binary-search iterations per branch. |
|
|
1680
1909
|
| `initialDirection` | higher-contrast side | Direction to search first (`'lighter'` or `'darker'`). |
|
|
1681
1910
|
| `flip` | `false` | When `true`, try the opposite direction if the initial one doesn't meet the target. When `false`, only the initial direction is searched — unmet contrasts pin the result to that direction's extreme. |
|
|
1911
|
+
| `preferInitial` | `false` | With `flip` on and **both** directions meeting the target, keep `initialDirection` instead of taking whichever result lands nearer `preferredTone`. Makes the chosen side independent of the target — what [`contrastLevel`](#manual-contrast-level) uses to keep a color on one side of its base across the ramp. The flip fallback is unaffected. |
|
|
1682
1912
|
|
|
1683
1913
|
Result: `{ tone, contrast, met, branch: 'lighter' | 'darker' | 'preferred', flipped? }`. `flipped: true` indicates the initial direction failed and the opposite direction satisfied the target.
|
package/docs/methodology.md
CHANGED
|
@@ -123,6 +123,29 @@ Per-color `saturation` is a factor of the theme seed, not an absolute
|
|
|
123
123
|
saturation. With `SEED_SATURATION = 80`, `saturation: 0.25` means one quarter of
|
|
124
124
|
that seed.
|
|
125
125
|
|
|
126
|
+
### When dark needs its own chroma
|
|
127
|
+
|
|
128
|
+
Dark schemes get a flat `darkDesaturation` haircut (10% by default), which is
|
|
129
|
+
enough for most palettes. Reach for a second seed only when dark is wrong in a
|
|
130
|
+
way a uniform reduction cannot fix — a hue that reads muddy against dark
|
|
131
|
+
surfaces, or an accent that stays gaudy no matter how much you dial the global
|
|
132
|
+
knob:
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
const primary = glaze({
|
|
136
|
+
hue: PURPLE_HUE,
|
|
137
|
+
saturation: SEED_SATURATION,
|
|
138
|
+
darkHue: PURPLE_HUE - 12, // cooler purple on dark surfaces
|
|
139
|
+
darkSaturation: 62, // calmer than a 10% haircut would give
|
|
140
|
+
});
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
An explicit `darkSaturation` replaces `darkDesaturation` rather than stacking
|
|
144
|
+
with it, so the number you write is the number you get. Prefer tuning the seed
|
|
145
|
+
over per-color `darkHue` / `darkSaturation`: one seed keeps the palette
|
|
146
|
+
coherent, while scattered per-color dark overrides drift apart as the palette
|
|
147
|
+
grows. Save the per-color form for the one or two tokens that genuinely need it.
|
|
148
|
+
|
|
126
149
|
## Naming
|
|
127
150
|
|
|
128
151
|
Prefer purpose first and variant last:
|
|
@@ -288,6 +311,11 @@ When a token needs the scheme extreme, use `tone: 'min'` or `tone: 'max'`
|
|
|
288
311
|
directly. Avoid large magic numbers or fake contrast floors just to push a color
|
|
289
312
|
to the edge.
|
|
290
313
|
|
|
314
|
+
Pair the extreme with a `base` when the gap between the two has to survive the
|
|
315
|
+
scheme switch. Glaze then replays the light-scheme tone shift against the
|
|
316
|
+
resolved dark base instead of re-mapping the extreme through the dark window,
|
|
317
|
+
so a knockout on a surface keeps the same contrast in both schemes.
|
|
318
|
+
|
|
291
319
|
## Fixed surfaces and accent fills
|
|
292
320
|
|
|
293
321
|
Use `mode: 'fixed'` when the authored color should stay recognizable across
|
|
@@ -614,6 +642,25 @@ In HC variants, Glaze bypasses the normal tone window and uses the full
|
|
|
614
642
|
`[0, 100]` range. Edge tones can reach the edge; contrast floors have more room
|
|
615
643
|
to solve.
|
|
616
644
|
|
|
645
|
+
### A slider instead of a tier
|
|
646
|
+
|
|
647
|
+
The two-tier model assumes contrast is a binary OS preference. When contrast is
|
|
648
|
+
instead a setting *your users* control, set
|
|
649
|
+
[`contrastLevel`](api.md#manual-contrast-level) to a number and the HC tier
|
|
650
|
+
becomes a `0–100` ramp: level `0` is your normal palette, `100` is the HC
|
|
651
|
+
palette you already authored, and everything between is resolved at that level.
|
|
652
|
+
|
|
653
|
+
The authoring work is the same — the HC pairs above are what the slider ramps
|
|
654
|
+
toward, so a palette tuned for high contrast needs no new fields. Two
|
|
655
|
+
consequences worth planning for:
|
|
656
|
+
|
|
657
|
+
- The separate HC tier stops being emitted, so wire the level itself into your
|
|
658
|
+
build or runtime rather than a `prefers-contrast: more` block. Existing
|
|
659
|
+
media-query wiring keeps working; it just receives the same values as the base
|
|
660
|
+
block.
|
|
661
|
+
- `contrastLevel: 0` is a useful state in its own right: normal contrast with no
|
|
662
|
+
HC tier at all, for products that ship the slider but default it off.
|
|
663
|
+
|
|
617
664
|
## Checklist
|
|
618
665
|
|
|
619
666
|
Before shipping a palette, verify:
|
|
@@ -626,7 +673,8 @@ Before shipping a palette, verify:
|
|
|
626
673
|
- Low-stakes visual relationships use tone deltas instead of fake contrast
|
|
627
674
|
floors.
|
|
628
675
|
- `inherit: false` is set on default-only tokens so status themes stay focused.
|
|
629
|
-
- HC pairs exist where high contrast should visibly tighten
|
|
676
|
+
- HC pairs exist where high contrast should visibly tighten (they double as the
|
|
677
|
+
ramp for a manual `contrastLevel`).
|
|
630
678
|
- `glaze.configure({ states, modes })` matches the states registered in the app.
|
|
631
679
|
- Every emitted scheme (`light`, `dark`, `lightContrast`, `darkContrast`) has
|
|
632
680
|
been reviewed on complete screens, not only in a token grid.
|
package/docs/okhst.md
CHANGED
|
@@ -44,7 +44,9 @@ surface: {
|
|
|
44
44
|
}
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
`'max'` and `'min'` select the corresponding end of the active scheme.
|
|
47
|
+
`'max'` and `'min'` select the corresponding end of the active scheme. With a
|
|
48
|
+
`base`, they instead preserve their distance from that base across schemes —
|
|
49
|
+
see [Scheme adaptation](#scheme-adaptation).
|
|
48
50
|
|
|
49
51
|
### Tone delta
|
|
50
52
|
|
|
@@ -83,6 +85,19 @@ lightTone: { lo: 10, hi: 100, eps: 0.05 }
|
|
|
83
85
|
Pass `false` to use the full range. High-contrast variants always bypass the
|
|
84
86
|
ordinary boundaries and use the full range.
|
|
85
87
|
|
|
88
|
+
A [manual contrast level](api.md#manual-contrast-level) generalizes that bypass
|
|
89
|
+
continuously: with `contrastLevel: L` (`f = L / 100`), each boundary moves toward
|
|
90
|
+
the full range instead of jumping to it —
|
|
91
|
+
|
|
92
|
+
```text
|
|
93
|
+
lo(f) = lo + (0 - lo) * f
|
|
94
|
+
hi(f) = hi + (100 - hi) * f
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
— so the light default walks `[10, 100] → [0, 100]` and the dark default
|
|
98
|
+
`[15, 95] → [0, 100]`. The render `eps` is not interpolated, and a `false` window
|
|
99
|
+
is already the full range and therefore level-invariant.
|
|
100
|
+
|
|
86
101
|
## Scheme adaptation
|
|
87
102
|
|
|
88
103
|
Each regular color has an adaptation `mode`:
|
|
@@ -115,6 +130,24 @@ authored tone
|
|
|
115
130
|
|
|
116
131
|
Dark schemes also apply `darkDesaturation` unless the color is `static`.
|
|
117
132
|
|
|
133
|
+
**Hue and saturation are authoring inputs, not part of the tone transfer.** The
|
|
134
|
+
tone axis is the only channel OKHST adapts across schemes; `h` and `s` pass
|
|
135
|
+
through verbatim, with `darkDesaturation` as the one automatic adjustment.
|
|
136
|
+
A theme (or a single color) can seed a different hue and saturation for the
|
|
137
|
+
dark schemes via `darkHue` / `darkSaturation` — see
|
|
138
|
+
[api.md](api.md#dark-seed-darkhue--darksaturation). Those pick the `h` and `s`
|
|
139
|
+
that enter the pipeline above; they never change how tone is transferred.
|
|
140
|
+
Authoring a dark saturation replaces the `darkDesaturation` step rather than
|
|
141
|
+
stacking with it, and `static` colors ignore both.
|
|
142
|
+
|
|
143
|
+
**Extremes with a base** take a different dark path. Because the light and dark
|
|
144
|
+
windows are asymmetric, running an extreme through the dark window compresses
|
|
145
|
+
the span between it and its base, which lowers contrast. For a color with both
|
|
146
|
+
`base` and `tone: 'max'` / `'min'`, Glaze measures the tone shift the light
|
|
147
|
+
scheme produced between the two, then replays it against the base's resolved
|
|
148
|
+
dark tone (mirrored under `auto`, same-signed under `fixed`). Only `[0, 100]`
|
|
149
|
+
clamping applies, so such a color may render past the `darkTone` boundary.
|
|
150
|
+
|
|
118
151
|
## Reference and render epsilon
|
|
119
152
|
|
|
120
153
|
`REF_EPS = 0.05` defines Glaze's canonical tone scale. It is used for OKHST
|
package/package.json
CHANGED