@tenphi/glaze 0.0.0-snapshot.c13086f → 0.0.0-snapshot.c61bbdf
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/AGENTS.md +158 -0
- package/README.md +106 -1015
- package/dist/index.cjs +3991 -973
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1559 -157
- package/dist/index.d.mts +1559 -157
- package/dist/index.mjs +3959 -973
- package/dist/index.mjs.map +1 -1
- package/docs/contrast.mdx +702 -0
- package/docs/exporting.mdx +624 -0
- package/docs/getting-started.mdx +486 -0
- package/docs/methodology.mdx +973 -0
- package/docs/migration.md +278 -0
- package/docs/mixes.mdx +268 -0
- package/docs/okhst.mdx +596 -0
- package/docs/reference/color-definitions.md +498 -0
- package/docs/reference/color-tokens.md +390 -0
- package/docs/reference/configuration.md +437 -0
- package/docs/reference/contrast.md +276 -0
- package/docs/reference/index.md +218 -0
- package/docs/reference/output.md +594 -0
- package/docs/reference/palette.md +387 -0
- package/docs/reference/theme.md +326 -0
- package/docs/reference/utilities.md +499 -0
- package/docs/schemes.mdx +546 -0
- package/docs/shadows-and-glows.mdx +509 -0
- package/package.json +18 -9
package/docs/okhst.mdx
ADDED
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Tone and OKHST
|
|
3
|
+
description: The contrast-uniform tone axis Glaze authors every color on, its math and guarantees, and how a tone becomes a rendered color in each scheme.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
import Example from '../docs-site/components/Example.astro';
|
|
7
|
+
import ToneRamp from '../docs-site/components/ToneRamp.tsx';
|
|
8
|
+
import ToneDrift from '../docs-site/components/okhst/ToneDrift.tsx';
|
|
9
|
+
import SchemeRamp from '../docs-site/components/okhst/SchemeRamp.tsx';
|
|
10
|
+
import ToneLadder from '../docs-site/components/okhst/ToneLadder.tsx';
|
|
11
|
+
import FloorCompare from '../docs-site/components/okhst/FloorCompare.tsx';
|
|
12
|
+
|
|
13
|
+
Glaze places every color in [OKHST](https://github.com/tenphi/okhst): OKHSL's
|
|
14
|
+
hue and saturation, with the lightness axis replaced by **tone** — a 0–100
|
|
15
|
+
axis shaped so that equal tone steps give equal WCAG contrast. This page is the
|
|
16
|
+
reference for the color model as Glaze applies it: the transfer math, what tone
|
|
17
|
+
does and does not guarantee, and how an authored tone becomes a rendered color
|
|
18
|
+
in each scheme. The [OKHST repository](https://github.com/tenphi/okhst) is the
|
|
19
|
+
canonical specification of the color space itself: transfer functions,
|
|
20
|
+
derivation, and invariants.
|
|
21
|
+
|
|
22
|
+
To build a palette on this model, continue to
|
|
23
|
+
[Designing a palette](./methodology.mdx). Every option and utility named here is
|
|
24
|
+
listed in the [API reference](./reference/index.md).
|
|
25
|
+
|
|
26
|
+
## OKHSL with a tone axis
|
|
27
|
+
|
|
28
|
+
OKHST keeps OKHSL's hue (`h`, 0–360) and saturation (`s`) verbatim and swaps
|
|
29
|
+
the third coordinate:
|
|
30
|
+
|
|
31
|
+
| Space | Coordinates | Third coordinate |
|
|
32
|
+
| ----- | ----------- | ----------------------------------------------------- |
|
|
33
|
+
| OKHSL | `h, s, l` | `l`: perceptual lightness (toe-adjusted OKLab `L`) |
|
|
34
|
+
| OKHST | `h, s, t` | `t`: tone, a normalized logarithm of luminance |
|
|
35
|
+
|
|
36
|
+
You author tone on a 0–100 scale (`tone: 97`). Resolved variants and the
|
|
37
|
+
`{ h, s, t }` input object carry it on 0–1, like saturation (hue stays 0–360).
|
|
38
|
+
|
|
39
|
+
OKHSL lightness is perceptually even, but equal lightness steps produce uneven
|
|
40
|
+
contrast: the ratio between neighbors changes along the axis. Tone is shaped so
|
|
41
|
+
equal steps produce equal WCAG contrast. Two things follow: a ramp authored in
|
|
42
|
+
tone is a contrast-even ladder for free, and dark-mode inversion is a single
|
|
43
|
+
subtraction (`100 - t`) instead of a fitted curve.
|
|
44
|
+
|
|
45
|
+
### Equal tone steps, equal contrast
|
|
46
|
+
|
|
47
|
+
<ToneRamp
|
|
48
|
+
saturation={0}
|
|
49
|
+
caption="Eleven neutral steps, evenly spaced in tone. The row underneath is the WCAG ratio between neighbors: 1.36:1 at every step."
|
|
50
|
+
/>
|
|
51
|
+
|
|
52
|
+
<ToneRamp
|
|
53
|
+
saturation={0}
|
|
54
|
+
axis="lightness"
|
|
55
|
+
caption="The same eleven steps evenly spaced in OKHSL lightness. They look even, but the ratio between neighbors runs from 1.16:1 at the dark end up to 1.46:1 and back down to 1.29:1."
|
|
56
|
+
/>
|
|
57
|
+
|
|
58
|
+
## The tone transfer
|
|
59
|
+
|
|
60
|
+
For a gray (`s = 0`) at OKHSL lightness `l`, luminance has a closed form through
|
|
61
|
+
the OKHSL toe and the OKLab cube:
|
|
62
|
+
|
|
63
|
+
```text
|
|
64
|
+
Y = toeInv(l)^3 // OKLab L = toeInv(l), cubed
|
|
65
|
+
l = toe(cbrt(Y)) // exact inverse
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Tone is a normalized natural logarithm of `Y`, offset by a small `eps`:
|
|
69
|
+
|
|
70
|
+
```text
|
|
71
|
+
D = ln(1 + eps) - ln(eps)
|
|
72
|
+
|
|
73
|
+
toneFromY(Y, eps) = 100 * (ln(Y + eps) - ln(eps)) / D
|
|
74
|
+
yFromTone(T, eps) = exp(T / 100 * D + ln(eps)) - eps
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`toTone(l)` and `fromTone(t)` compose these with the gray luminance above, so
|
|
78
|
+
they convert between OKHSL lightness (0–1) and tone (0–100) directly. The two
|
|
79
|
+
directions are exact analytic inverses: a round trip is lossless to
|
|
80
|
+
floating-point precision. `toneFromY(0)` is 0 and `toneFromY(1)` is 100 for any
|
|
81
|
+
`eps`, so tone is always a clean 0–100 scale.
|
|
82
|
+
|
|
83
|
+
### Why `eps` is 0.05
|
|
84
|
+
|
|
85
|
+
WCAG 2 contrast is `(Y_hi + 0.05) / (Y_lo + 0.05)`. With `eps = 0.05`, tone is a
|
|
86
|
+
normalized `ln(Y + 0.05)`, so a fixed tone difference is a fixed ratio of
|
|
87
|
+
`Y + 0.05` — a fixed WCAG contrast — wherever it sits on the scale. Since
|
|
88
|
+
`ln(1.05) - ln(0.05) = ln(21)`:
|
|
89
|
+
|
|
90
|
+
```text
|
|
91
|
+
ratio(T2, T1) = (Y2 + 0.05) / (Y1 + 0.05)
|
|
92
|
+
= exp((T2 - T1) / 100 * ln(21))
|
|
93
|
+
= 21 ^ ((T2 - T1) / 100)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The whole scale is 21:1, black to white, and every 10 tones multiply contrast by
|
|
97
|
+
`21 ^ 0.1`, about 1.36. For neutrals:
|
|
98
|
+
|
|
99
|
+
| Tone | Contrast vs. black |
|
|
100
|
+
| ---: | -----------------: |
|
|
101
|
+
| 10 | 1.36:1 |
|
|
102
|
+
| 20 | 1.84:1 |
|
|
103
|
+
| 30 | 2.49:1 |
|
|
104
|
+
| 40 | 3.38:1 |
|
|
105
|
+
| 50 | 4.58:1 |
|
|
106
|
+
| 60 | 6.21:1 |
|
|
107
|
+
| 70 | 8.42:1 |
|
|
108
|
+
| 80 | 11.42:1 |
|
|
109
|
+
| 90 | 15.49:1 |
|
|
110
|
+
| 100 | 21:1 |
|
|
111
|
+
|
|
112
|
+
Inverted, the tone distance between two neutrals for a target ratio `R` is
|
|
113
|
+
`100 * ln(R) / ln(21)`:
|
|
114
|
+
|
|
115
|
+
| Ratio | Tone distance |
|
|
116
|
+
| ---------------------- | ------------: |
|
|
117
|
+
| 3:1 (`'AA-large'`) | 36.08 |
|
|
118
|
+
| 4.5:1 (`'AA'`) | 49.40 |
|
|
119
|
+
| 7:1 (`'AAA'`) | 63.92 |
|
|
120
|
+
|
|
121
|
+
These distances are exact for neutrals only. The next section explains why.
|
|
122
|
+
|
|
123
|
+
## Same tone, same lightness
|
|
124
|
+
|
|
125
|
+
`okhstToOkhsl({ h, s, t })` passes `h` and `s` through unchanged and sets
|
|
126
|
+
`l = fromTone(100 * t)`. `fromTone` depends on the tone (and `eps`) alone, and OKHSL's
|
|
127
|
+
`l → L` map has no hue or saturation term: `h` and `s` enter only the chroma
|
|
128
|
+
math. That gives OKHST its core invariant:
|
|
129
|
+
|
|
130
|
+
> A given tone yields the same OKHSL lightness for every hue and saturation.
|
|
131
|
+
|
|
132
|
+
OKHST inherits OKHSL's gamut and reversibility exactly: every `(h, s, t)` is
|
|
133
|
+
realizable and round-trips.
|
|
134
|
+
|
|
135
|
+
### Uniform in lightness, not luminance
|
|
136
|
+
|
|
137
|
+
The invariant is about lightness. Equal tone gives equal WCAG and APCA contrast
|
|
138
|
+
only for grays: a saturated red and a saturated green at the same tone share an
|
|
139
|
+
OKHSL lightness but differ in real luminance `Y`, because hue, saturation, and
|
|
140
|
+
gamut mapping all change the final sRGB values. This **chromatic drift** is the
|
|
141
|
+
one approximation in the model.
|
|
142
|
+
|
|
143
|
+
<ToneDrift
|
|
144
|
+
rows={[
|
|
145
|
+
{ hue: 0, saturation: 0, label: 'Neutral' },
|
|
146
|
+
{ hue: 25, saturation: 100 },
|
|
147
|
+
{ hue: 25, saturation: 50 },
|
|
148
|
+
{ hue: 110, saturation: 100 },
|
|
149
|
+
{ hue: 150, saturation: 100 },
|
|
150
|
+
{ hue: 300, saturation: 100 },
|
|
151
|
+
]}
|
|
152
|
+
caption="Each column is drawn at the authored tone above it. The label in each cell is its measured tone: the tone of the gray with the same luminance. Neutrals match exactly; chromatic colors drift, and less so at lower saturation."
|
|
153
|
+
/>
|
|
154
|
+
|
|
155
|
+
At full saturation, the rows above read up to 5.6 tones darker than their tone
|
|
156
|
+
(hue 300, purple) and 4.1 darker (hue 25, red), up to 2.7 tones lighter
|
|
157
|
+
(hue 150, green), and within a tone for yellow (hue 110). Across all hues the
|
|
158
|
+
extremes are 5.6 tones darker, near hue 300, and 2.9 lighter, near hue 143.
|
|
159
|
+
Drift shrinks with saturation: hue 25 at tone 50 reads 3.9 tones dark at full
|
|
160
|
+
saturation and 1.6 at half.
|
|
161
|
+
|
|
162
|
+
That splits the work between two tools:
|
|
163
|
+
|
|
164
|
+
- Use **tone deltas** for visual spacing within ramps and between related UI
|
|
165
|
+
colors. The spacing stays even and ordered for every hue.
|
|
166
|
+
- Use **`contrast`** when a rendered color must meet a measured WCAG or APCA
|
|
167
|
+
floor. The solver is the one deliberate exception to the invariant: it moves a
|
|
168
|
+
color's tone per hue and saturation until the measured floor is met. See
|
|
169
|
+
[Contrast floors](#contrast-floors-and-verification).
|
|
170
|
+
|
|
171
|
+
## Tone windows
|
|
172
|
+
|
|
173
|
+
Each scheme renders authored tone into a **tone window**, configured by
|
|
174
|
+
`lightTone` and `darkTone`:
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
import { glaze } from '@tenphi/glaze';
|
|
178
|
+
|
|
179
|
+
glaze.configure({
|
|
180
|
+
lightTone: [10, 100],
|
|
181
|
+
darkTone: [15, 95],
|
|
182
|
+
});
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
The window's `lo` and `hi` are **OKHSL-lightness** boundaries (0–100). Glaze
|
|
186
|
+
converts them to tone, positions the authored tone linearly in that tone
|
|
187
|
+
interval, converts the result back to lightness with the window's `eps`, and
|
|
188
|
+
stores it as canonical tone:
|
|
189
|
+
|
|
190
|
+
```text
|
|
191
|
+
loT = toTone(lo / 100, eps)
|
|
192
|
+
hiT = toTone(hi / 100, eps)
|
|
193
|
+
l = fromTone(loT + (T / 100) * (hiT - loT), eps)
|
|
194
|
+
t = toTone(l) / 100 // canonical tone (0–1), REF_EPS
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
A window takes three forms:
|
|
198
|
+
|
|
199
|
+
| Form | Meaning |
|
|
200
|
+
| ----------------- | ------------------------------------------------------------------------------- |
|
|
201
|
+
| `[lo, hi]` | Boundaries at the reference `eps`. The common form. |
|
|
202
|
+
| `{ lo, hi, eps }` | Boundaries plus a render curvature for this scheme. Advanced. |
|
|
203
|
+
| `false` | The full range `[0, 100]` at the reference `eps`: no boundaries, same tone curve. |
|
|
204
|
+
|
|
205
|
+
### Calibrated defaults
|
|
206
|
+
|
|
207
|
+
| | `lightTone` | `darkTone` |
|
|
208
|
+
| ---------- | ----------: | ---------: |
|
|
209
|
+
| `lo` | 10 | 15 |
|
|
210
|
+
| `hi` | 100 | 95 |
|
|
211
|
+
| `eps` | 0.05 | 0.05 |
|
|
212
|
+
| Tone range | 4.95–100 | 9.25–95.9 |
|
|
213
|
+
|
|
214
|
+
The floors keep mapped tones off pure black in both schemes (OKHSL lightness 10
|
|
215
|
+
in light, 15 in dark), and the dark ceiling keeps them short of white at
|
|
216
|
+
lightness 95. `eps` is pinned to the reference 0.05 so the axis stays
|
|
217
|
+
WCAG-uniform inside the window. Because the remap is linear in tone, it scales
|
|
218
|
+
distances between absolute tones uniformly: about 95% of the authored distance
|
|
219
|
+
in light and 86.6% in dark.
|
|
220
|
+
|
|
221
|
+
<SchemeRamp
|
|
222
|
+
caption="Authored tones 0 to 100 (mode 'auto') resolved in every scheme. Each label is the tone Glaze stores. Light compresses into 4.95–100; dark inverts, then compresses into 9.25–95.9; high contrast uses the full range."
|
|
223
|
+
/>
|
|
224
|
+
|
|
225
|
+
### High contrast uses the full range
|
|
226
|
+
|
|
227
|
+
High-contrast variants run the same math with the window forced to the full
|
|
228
|
+
range `[0, 100]`, keeping the window's `eps`. There is no separate high-contrast
|
|
229
|
+
curve and no fitted dark curve.
|
|
230
|
+
|
|
231
|
+
### Manual contrast level
|
|
232
|
+
|
|
233
|
+
A [manual contrast level](./reference/contrast.md#manual-contrast-level) generalizes that bypass
|
|
234
|
+
continuously. With `contrastLevel: L` and `f = L / 100`, each boundary moves
|
|
235
|
+
toward the full range instead of jumping to it:
|
|
236
|
+
|
|
237
|
+
```text
|
|
238
|
+
lo(f) = lo + (0 - lo) * f
|
|
239
|
+
hi(f) = hi + (100 - hi) * f
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
The light default walks `[10, 100] → [0, 100]` and the dark default
|
|
243
|
+
`[15, 95] → [0, 100]`; at level 50 they are `[5, 100]` (tone 1.63–100) and
|
|
244
|
+
`[7.5, 97.5]` (tone 3.15–97.97). The render `eps` is not interpolated, and a
|
|
245
|
+
`false` window is already the full range, so it is level-invariant.
|
|
246
|
+
|
|
247
|
+
The continuous form applies to the normal variants only. High-contrast variants
|
|
248
|
+
are already at the full range, so the level does not widen them further: the
|
|
249
|
+
bypass is what keeps the high-contrast tier independent of the level. The level
|
|
250
|
+
itself is covered in
|
|
251
|
+
[Light, dark, and high contrast](./schemes.mdx#manual-contrast-level).
|
|
252
|
+
|
|
253
|
+
## Reference and render epsilon
|
|
254
|
+
|
|
255
|
+
Two `eps` values play separate roles:
|
|
256
|
+
|
|
257
|
+
- **Reference eps** — `REF_EPS = 0.05`, fixed. It defines the OKHST color
|
|
258
|
+
space and Glaze's canonical tone. `okhst()` input, `{ h, s, t }` input, the
|
|
259
|
+
stored `ResolvedColorVariant.t`, tone deltas, and the contrast solver all use
|
|
260
|
+
it, which keeps tone comparable between schemes.
|
|
261
|
+
- **Render eps** — the `eps` of an object tone window. A per-scheme curvature
|
|
262
|
+
control that only changes how authored tone maps through that scheme's
|
|
263
|
+
window. It defaults to the reference value, so by default the two coincide.
|
|
264
|
+
|
|
265
|
+
When a window's `eps` differs from the reference, Glaze maps with it to land a
|
|
266
|
+
final OKHSL lightness, then stores `toTone(l, REF_EPS)`, so deltas and contrast
|
|
267
|
+
calculations stay on the reference scale. For example,
|
|
268
|
+
`lightTone: { lo: 10, hi: 100, eps: 0.2 }` stores authored tone 50 at 63.89
|
|
269
|
+
instead of 52.47: mid tones bend lighter. Most palettes should keep the default.
|
|
270
|
+
|
|
271
|
+
## The scheme pipeline
|
|
272
|
+
|
|
273
|
+
A regular color's tone goes through one pipeline per scheme:
|
|
274
|
+
|
|
275
|
+
```text
|
|
276
|
+
authored tone T (0–100)
|
|
277
|
+
-> adaptation mode
|
|
278
|
+
auto + dark invert: T' = 100 - T
|
|
279
|
+
light / fixed keep: T' = T
|
|
280
|
+
static identity; skip the window
|
|
281
|
+
-> remap into the scheme's tone window (full range for high contrast)
|
|
282
|
+
-> render curvature (window eps) -> OKHSL lightness l
|
|
283
|
+
-> store canonical tone t = toTone(l, REF_EPS) / 100
|
|
284
|
+
// variant { h, s, t, alpha }, t on 0–1
|
|
285
|
+
-> at the rendering edge only:
|
|
286
|
+
fromTone(100 * t) -> l -> sRGB / luminance
|
|
287
|
+
optional: a contrast floor searches tone and overrides t
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
### Adaptation modes
|
|
291
|
+
|
|
292
|
+
The first step is the color's `mode`. Use `auto` for surfaces and foregrounds
|
|
293
|
+
that should trade places between light and dark, `fixed` for brand fills and
|
|
294
|
+
inverse surfaces that should stay on the same side of the scale, and `static`
|
|
295
|
+
for colors that must not adapt.
|
|
296
|
+
[Light, dark, and high contrast](./schemes.mdx#adaptation-modes) has the full
|
|
297
|
+
table and shows each mode side by side.
|
|
298
|
+
|
|
299
|
+
**Dark tone inversion is not `autoFlip`.** Inversion is ordinary scheme
|
|
300
|
+
adaptation, controlled by `mode`. `autoFlip` solves a different, local problem:
|
|
301
|
+
it may mirror a tone delta that overshoots `[0, 100]` to the other side of its
|
|
302
|
+
base, or let the contrast solver try the opposite side of a base.
|
|
303
|
+
|
|
304
|
+
### Hue and saturation pass through
|
|
305
|
+
|
|
306
|
+
Tone is the only channel OKHST adapts across schemes; `h` and `s` are authoring
|
|
307
|
+
inputs that pass through the tone transfer untouched. The one automatic
|
|
308
|
+
adjustment is `darkDesaturation`, which dark schemes apply to every color except
|
|
309
|
+
`static` ones. A theme, or a single color, can seed a different hue and
|
|
310
|
+
saturation for the dark schemes with `darkHue` / `darkSaturation`; those pick the
|
|
311
|
+
`h` and `s` that enter the pipeline and never change how tone is transferred.
|
|
312
|
+
An authored dark saturation replaces the `darkDesaturation` step instead of
|
|
313
|
+
stacking with it: a color's own `darkSaturation` always does, and the theme's
|
|
314
|
+
seed does for every color that reads it. A `from` color with no `saturation` of
|
|
315
|
+
its own starts from the literal's saturation rather than the seed, so it keeps
|
|
316
|
+
the step. `static` colors ignore all of it. The rules are in
|
|
317
|
+
[Light, dark, and high contrast](./schemes.mdx#dark-seed) and
|
|
318
|
+
[Color definitions](./reference/color-definitions.md#dark-seed-darkhue--darksaturation).
|
|
319
|
+
|
|
320
|
+
## Tone values
|
|
321
|
+
|
|
322
|
+
A color's `tone` takes one of three kinds of value. The full syntax, including
|
|
323
|
+
`[normal, highContrast]` pairs, is in
|
|
324
|
+
[Color definitions](./reference/color-definitions.md#tone-values).
|
|
325
|
+
|
|
326
|
+
```ts
|
|
327
|
+
import { glaze } from '@tenphi/glaze';
|
|
328
|
+
|
|
329
|
+
const theme = glaze(280, 30);
|
|
330
|
+
|
|
331
|
+
theme.colors({
|
|
332
|
+
surface: { tone: 97 }, // absolute
|
|
333
|
+
border: { base: 'surface', tone: '-8' }, // tone delta
|
|
334
|
+
deep: { tone: 'min' }, // extreme
|
|
335
|
+
ink: { base: 'surface', tone: 'min' }, // extreme anchored to a base
|
|
336
|
+
});
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
### Absolute tone
|
|
340
|
+
|
|
341
|
+
A number places a color on the 0–100 scale independently of every other color.
|
|
342
|
+
Each scheme maps it through the pipeline above, so the stored tone is not the
|
|
343
|
+
authored one: `surface: { tone: 97 }` is stored as 97.15 in light and, inverted
|
|
344
|
+
to 3 and mapped into the dark window, as 11.85 in dark. A dependent color (one
|
|
345
|
+
with a `base`) can still take an absolute tone; it is then mapped on its own and
|
|
346
|
+
the base only matters to a `contrast` floor.
|
|
347
|
+
|
|
348
|
+
### Tone deltas
|
|
349
|
+
|
|
350
|
+
A signed string is a **tone delta**: the signed difference between a dependent
|
|
351
|
+
color and its base.
|
|
352
|
+
|
|
353
|
+
```ts
|
|
354
|
+
border: { base: 'surface', tone: '-8' }
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
How the delta travels depends on the dependent color's `mode`:
|
|
358
|
+
|
|
359
|
+
- **`fixed` and `static` in every scheme, and `auto` in the light schemes:**
|
|
360
|
+
the delta is applied to the base's resolved tone in that scheme, sign
|
|
361
|
+
preserved. It is an exact tone step, so for neutrals it is an exact contrast
|
|
362
|
+
step.
|
|
363
|
+
- **`auto` in the dark schemes:** the delta is applied to the base's *light*
|
|
364
|
+
tone (its light high-contrast tone in dark high contrast), and the result is
|
|
365
|
+
inverted and mapped through the dark window like an absolute tone. Order and
|
|
366
|
+
direction carry over — text that sits below its surface in light sits above
|
|
367
|
+
it in dark — but the step scales with the dark window's span: up to 86.6% of
|
|
368
|
+
the authored delta, on a light base at the default windows. On a mid or low
|
|
369
|
+
base it is also uneven, because the base's light tone carries the light
|
|
370
|
+
window's lift: on a tone-50 base, `'+4'` becomes −5.6 and `'-4'` becomes
|
|
371
|
+
+1.3. In dark high contrast the window is the full range, so the step is
|
|
372
|
+
exact, only mirrored.
|
|
373
|
+
|
|
374
|
+
Order carries over only when the base inverts too. An `auto` delta on a
|
|
375
|
+
`fixed` or `static` base inverts on its own while the base keeps its side, so
|
|
376
|
+
it can cross to the other side of the base in dark: a `'+50'` label on a
|
|
377
|
+
`fixed` tone-30 fill sits 50 tones above it in light and about 12 below it in
|
|
378
|
+
dark. Make such a dependent `fixed` as well.
|
|
379
|
+
|
|
380
|
+
If `base + delta` overshoots `[0, 100]`, `autoFlip` (on by default) mirrors the
|
|
381
|
+
delta to the other side of the base; if the mirror overshoots too, or `autoFlip`
|
|
382
|
+
is off, the delta clamps on its authored side.
|
|
383
|
+
|
|
384
|
+
### Extremes: `'max'` and `'min'`
|
|
385
|
+
|
|
386
|
+
`'max'` is author tone 100 and `'min'` is author tone 0. They select the
|
|
387
|
+
corresponding end of the scheme without a magic number.
|
|
388
|
+
|
|
389
|
+
- **Without a base,** an extreme flows through the pipeline like any absolute
|
|
390
|
+
tone: it lands on the window edge and, under `auto`, inverts in dark — `'min'`
|
|
391
|
+
is the darkest color in light and the lightest in dark. Use `fixed` to keep the
|
|
392
|
+
same end without inverting, or `static` to pin one tone everywhere.
|
|
393
|
+
- **With a base,** the dark scheme takes a different path. The windows are
|
|
394
|
+
asymmetric, so running the extreme through the dark window would squeeze the
|
|
395
|
+
span between it and its base and cost contrast. Instead Glaze measures the
|
|
396
|
+
tone shift the light scheme produced between the base and the extreme, then
|
|
397
|
+
replays it against the base's resolved dark tone: mirrored under `auto` (both
|
|
398
|
+
ends invert), same-signed under `fixed`. Only `[0, 100]` clamping applies, so
|
|
399
|
+
the color may render past the dark window's edge — on purpose, since you asked
|
|
400
|
+
for the extreme. When the shift does not fit, the color pins at tone 100 or 0.
|
|
401
|
+
`static` colors are unaffected. High-contrast variants follow the same rule,
|
|
402
|
+
but their window is already the full range, so the replay matches the plain
|
|
403
|
+
mapping unless the base itself sits asymmetrically across schemes. With a
|
|
404
|
+
`contrast` floor, an anchored extreme's starting tone is not clamped back
|
|
405
|
+
into the scheme window.
|
|
406
|
+
|
|
407
|
+
```ts
|
|
408
|
+
const neutral = glaze(0, 0);
|
|
409
|
+
|
|
410
|
+
neutral.colors({
|
|
411
|
+
bg: { tone: 60 },
|
|
412
|
+
fg: { base: 'bg', tone: 'max' },
|
|
413
|
+
});
|
|
414
|
+
// light: bg 62.0, fg 100.0 (shift +38.0, 3.18:1)
|
|
415
|
+
// dark: bg 43.9, fg 5.9 (shift -38.0, 3.18:1)
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
A root `tone: 'max'` in the same theme lands at the dark window's edge, tone
|
|
419
|
+
9.25, and drops to 2.87:1 against `bg` in dark.
|
|
420
|
+
|
|
421
|
+
### All three, light to dark
|
|
422
|
+
|
|
423
|
+
<Example
|
|
424
|
+
hue={280}
|
|
425
|
+
saturation={30}
|
|
426
|
+
colors={{
|
|
427
|
+
surface: { tone: 97 },
|
|
428
|
+
border: { base: 'surface', tone: '-8' },
|
|
429
|
+
text: { base: 'surface', tone: '-60' },
|
|
430
|
+
deep: { tone: 'min' },
|
|
431
|
+
ink: { base: 'surface', tone: 'min' },
|
|
432
|
+
}}
|
|
433
|
+
visual="none"
|
|
434
|
+
/>
|
|
435
|
+
|
|
436
|
+
<ToneLadder
|
|
437
|
+
hue={280}
|
|
438
|
+
saturation={30}
|
|
439
|
+
colors={{
|
|
440
|
+
surface: { tone: 97 },
|
|
441
|
+
border: { base: 'surface', tone: '-8' },
|
|
442
|
+
text: { base: 'surface', tone: '-60' },
|
|
443
|
+
deep: { tone: 'min' },
|
|
444
|
+
ink: { base: 'surface', tone: 'min' },
|
|
445
|
+
}}
|
|
446
|
+
base="surface"
|
|
447
|
+
schemes={['light', 'dark', 'lightContrast', 'darkContrast']}
|
|
448
|
+
caption="Each chip shows the stored tone, the tone difference from surface, and the measured WCAG ratio against it. In dark, the deltas shrink with the window (−8 becomes +6.8, −60 becomes +51.9), deep stops at the window edge, and ink replays its full light span past it, clamped at 100. In high contrast the window is the full range and the deltas are exact."
|
|
449
|
+
/>
|
|
450
|
+
|
|
451
|
+
The `text` color above has no contrast floor, so its ratio follows its tone
|
|
452
|
+
step: 6.29:1 in light and 4.83:1 in dark. Add `contrast` when the measured
|
|
453
|
+
ratio matters.
|
|
454
|
+
|
|
455
|
+
## Contrast floors and verification
|
|
456
|
+
|
|
457
|
+
Tone is the contrast solver's search axis because it is contrast-uniform: the
|
|
458
|
+
search converges quickly and WCAG gets a closed-form starting point. But Glaze
|
|
459
|
+
never assumes a chromatic color has the luminance of the gray at its tone. It
|
|
460
|
+
measures the real color.
|
|
461
|
+
|
|
462
|
+
`contrast` takes one spec with a pluggable metric: a WCAG ratio (a bare number,
|
|
463
|
+
a preset such as `'AA'`, or `{ wcag }`) or an APCA Lc target (`{ apca }`).
|
|
464
|
+
Forms, presets, roles, and high-contrast escalation are covered in
|
|
465
|
+
[Contrast solving](./contrast.mdx) and
|
|
466
|
+
[Contrast and high contrast](./reference/contrast.md).
|
|
467
|
+
|
|
468
|
+
### How a floor is solved
|
|
469
|
+
|
|
470
|
+
For a color with `base` and `contrast`, in each scheme Glaze:
|
|
471
|
+
|
|
472
|
+
1. resolves the base and the color's preferred tone for that scheme, clamped
|
|
473
|
+
into the scheme's tone window (anchored extremes excepted);
|
|
474
|
+
2. measures the requested WCAG ratio or APCA Lc at the preferred tone, using the
|
|
475
|
+
color's real hue and saturation — if it already clears the floor with a
|
|
476
|
+
small margin (×1.01 for a WCAG ratio, +0.5 Lc for APCA, to absorb rounding
|
|
477
|
+
in the output formats), the tone is kept (`contrast` is a floor, not a
|
|
478
|
+
target);
|
|
479
|
+
3. otherwise searches tone. For WCAG the search starts from the gray tone whose
|
|
480
|
+
luminance hits the ratio exactly — `Y = R * (Y_base + 0.05) - 0.05` on the
|
|
481
|
+
lighter side, `(Y_base + 0.05) / R - 0.05` on the darker — then
|
|
482
|
+
binary-searches against the measured chromatic luminance. For APCA it
|
|
483
|
+
binary-searches against the Lc target directly. The search covers the full
|
|
484
|
+
0–100 range, so a demanding floor can push a color past the window, and it
|
|
485
|
+
aims for the same margin past the target;
|
|
486
|
+
4. warns when the target is physically unreachable in the scheme.
|
|
487
|
+
|
|
488
|
+
```ts
|
|
489
|
+
import { glaze } from '@tenphi/glaze';
|
|
490
|
+
|
|
491
|
+
const theme = glaze(150, 90);
|
|
492
|
+
|
|
493
|
+
theme.colors({
|
|
494
|
+
surface: { tone: 97, saturation: 0 },
|
|
495
|
+
// 49.4 tones below the surface: exactly 4.5:1 for a neutral
|
|
496
|
+
'step-text': { base: 'surface', tone: '-49.4' },
|
|
497
|
+
// Same starting tone, but the solver measures the real color
|
|
498
|
+
'floor-text': { base: 'surface', tone: '-49.4', contrast: 'AA' },
|
|
499
|
+
});
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
<FloorCompare
|
|
503
|
+
hues={[25, 110, 150, 265, 320]}
|
|
504
|
+
caption="Light scheme. Each row is one hue at the neutral AA distance below a neutral surface, without and with contrast: 'AA'. Hues that read lighter than their tone fall short and are moved darker; hues that read darker already pass and keep their tone. The neutral is nudged just past 4.5:1 by the search overshoot."
|
|
505
|
+
/>
|
|
506
|
+
|
|
507
|
+
### APCA
|
|
508
|
+
|
|
509
|
+
`apcaContrast(yText, yBg)` implements APCA-W3 0.1.9 (simplified SAPC): a
|
|
510
|
+
soft clamp for low luminances and separate exponents for normal and reverse
|
|
511
|
+
polarity, returning a signed Lc whose magnitude the solver compares with the
|
|
512
|
+
target. Its inputs are APCA **screen luminances** over the gamma-encoded sRGB
|
|
513
|
+
channels, not WCAG relative luminance:
|
|
514
|
+
|
|
515
|
+
```text
|
|
516
|
+
Ys = 0.2126 * R^2.4 + 0.7152 * G^2.4 + 0.0722 * B^2.4
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
The soft-clamp constants are calibrated against `Ys`, so the solver feeds APCA
|
|
520
|
+
that basis. The implementation omits APCA's font-size lookup that maps an Lc to
|
|
521
|
+
a usable text size.
|
|
522
|
+
|
|
523
|
+
### The drift check
|
|
524
|
+
|
|
525
|
+
After all four scheme passes, Glaze verifies every color that has a `base` and
|
|
526
|
+
a `contrast` floor. It re-measures the color against its base in each scheme, in
|
|
527
|
+
the floor's own metric (APCA screen luminance for APCA, WCAG luminance for
|
|
528
|
+
WCAG) and with each variant's own `pastel` flag, and emits a `console.warn` when
|
|
529
|
+
the result drifts below the target by more than a small slack: 2% of a WCAG
|
|
530
|
+
ratio, or 1.5 Lc. Because the solver already measures the real chromatic color,
|
|
531
|
+
a floor it meets passes this check. In practice the advisory fires only for an
|
|
532
|
+
unreachable floor, next to the solver's "cannot meet" warning for the same
|
|
533
|
+
scheme and with the same measured value; it stays as a safety net for the
|
|
534
|
+
chromatic approximation. Warnings are deduplicated through a 256-key session
|
|
535
|
+
cache that starts over when full. Both warnings are listed in
|
|
536
|
+
[Configuration](./reference/configuration.md#warnings).
|
|
537
|
+
|
|
538
|
+
## Input and output
|
|
539
|
+
|
|
540
|
+
Glaze parses OKHST wherever it takes a color value — `glaze.color()`, a color
|
|
541
|
+
definition's `from`, and the `bg` / `fg` of the standalone `glaze.shadow()` /
|
|
542
|
+
`glaze.glow()`:
|
|
543
|
+
|
|
544
|
+
```ts
|
|
545
|
+
import { glaze } from '@tenphi/glaze';
|
|
546
|
+
|
|
547
|
+
glaze.color('okhst(152 95% 70%)');
|
|
548
|
+
glaze.color({ h: 152, s: 0.95, t: 0.7 });
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
Both value forms reproduce the color exactly in the light scheme. The
|
|
552
|
+
structured form `glaze.color({ hue: 152, saturation: 95, tone: 70 })` is
|
|
553
|
+
different: its `tone` is an authored tone on the 0–100 scale, mapped through the
|
|
554
|
+
light window like a theme color, so it renders at tone 71.48. See
|
|
555
|
+
[Color tokens](./reference/color-tokens.md#glazecolorinput).
|
|
556
|
+
|
|
557
|
+
There is no native CSS `okhst()` function, so native exports use `oklch`,
|
|
558
|
+
`rgb`, or `hsl`. [Tasty](https://tasty.style) can consume Glaze's custom
|
|
559
|
+
serialization, so `tasty()` accepts `format: 'okhst'`, as does `glaze.format()`
|
|
560
|
+
for a single variant. `tokens()`, `json()`, `css()`, and `tailwind()` throw and
|
|
561
|
+
name a native format instead; the DTCG exports pick a color space of their own.
|
|
562
|
+
|
|
563
|
+
```ts
|
|
564
|
+
const theme = glaze(280, 30);
|
|
565
|
+
|
|
566
|
+
theme.colors({ surface: { tone: 97 } });
|
|
567
|
+
theme.tasty({ format: 'okhst' });
|
|
568
|
+
// {
|
|
569
|
+
// '#surface': {
|
|
570
|
+
// '': 'okhst(280 30% 97.15%)',
|
|
571
|
+
// '@media(prefers-color-scheme: dark)': 'okhst(280 27% 11.85%)',
|
|
572
|
+
// },
|
|
573
|
+
// }
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
The color formats are listed in
|
|
577
|
+
[Output formats](./reference/output.md#color-formats); `toTone`, `fromTone`,
|
|
578
|
+
`toneFromY`, `yFromTone`, `okhstToOkhsl`, `okhslToOkhst`, `variantToOkhsl`, and
|
|
579
|
+
`REF_EPS` are exported for your own math (see
|
|
580
|
+
[Utilities](./reference/utilities.md#okhst-tone-utilities)).
|
|
581
|
+
|
|
582
|
+
## Bringing existing colors into Glaze
|
|
583
|
+
|
|
584
|
+
When you move raw CSS colors or design tokens into Glaze, preserve the source
|
|
585
|
+
colors first: `glaze.fromHex()` and `glaze.fromRgb()` seed a theme from a
|
|
586
|
+
color, `glaze.color()` takes any color value, and a color definition's `from`
|
|
587
|
+
places a literal color inside a theme. Then replace one-off values with root
|
|
588
|
+
tones, tone deltas, and contrast floors as the relationships become clear.
|
|
589
|
+
|
|
590
|
+
To carry an OKHSL lightness `L` (0–100) over as a tone, convert it with
|
|
591
|
+
`toTone(L / 100)`; the two are not the same number. That tone is still an
|
|
592
|
+
authored tone, so the light window remaps it and it renders slightly lighter:
|
|
593
|
+
`L` 20 becomes tone 14.36 and renders at lightness 23.8. To keep a source color
|
|
594
|
+
exact in light, place it with `from`, which skips the light window, or turn the
|
|
595
|
+
window off with `lightTone: false`. The full workflow is in
|
|
596
|
+
[Migration and upgrading](./migration.md#migrating-from-an-existing-color-system).
|