@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/schemes.mdx
ADDED
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Light, dark, and high contrast
|
|
3
|
+
description: How one color definition adapts to light, dark, and high-contrast schemes, and the options that steer it.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
import { Callout } from '@tenphi/cookbook/components';
|
|
7
|
+
import SchemeGrid from '../docs-site/components/SchemeGrid.tsx';
|
|
8
|
+
import WindowMap from '../docs-site/components/schemes/WindowMap.tsx';
|
|
9
|
+
import SchemeExample from '../docs-site/components/schemes/SchemeExample.astro';
|
|
10
|
+
|
|
11
|
+
You define each color once. Glaze resolves it four times — light, dark, light
|
|
12
|
+
high contrast, and dark high contrast — by moving its **tone** through a
|
|
13
|
+
per-scheme pipeline. Hue and saturation stay as you authored them, apart from a
|
|
14
|
+
small dark desaturation, unless you give dark a seed of its own.
|
|
15
|
+
|
|
16
|
+
<SchemeGrid caption="One theme in all four schemes. Every color is resolved by Glaze for its scheme; none is tuned by hand." />
|
|
17
|
+
|
|
18
|
+
## Four variants per color
|
|
19
|
+
|
|
20
|
+
Every color resolves to four variants:
|
|
21
|
+
|
|
22
|
+
| Variant | Scheme | Window |
|
|
23
|
+
| --------------- | -------------------- | --------------------------------------------------------------------------------------- |
|
|
24
|
+
| `light` | Light | `lightTone`, default `[10, 100]` in OKHSL lightness (tone 4.95–100) |
|
|
25
|
+
| `dark` | Dark | `darkTone`, default `[15, 95]` in OKHSL lightness (tone 9.25–95.9), inverted for `auto` |
|
|
26
|
+
| `lightContrast` | Light, high contrast | The full range, tone 0–100 |
|
|
27
|
+
| `darkContrast` | Dark, high contrast | The full range, inverted for `auto` |
|
|
28
|
+
|
|
29
|
+
`theme.resolve()` always returns all four. Exports include `light` and `dark`
|
|
30
|
+
by default; [output modes](#output-modes) choose which variants they emit.
|
|
31
|
+
|
|
32
|
+
For a regular color, each variant runs the same pipeline:
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
authored tone
|
|
36
|
+
-> invert (100 - t) when the scheme is dark and mode is 'auto'
|
|
37
|
+
-> remap into the scheme's tone window (the full range in high contrast)
|
|
38
|
+
-> store the canonical tone; convert to OKHSL lightness when formatting
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The tone axis itself — why equal tone steps give equal contrast, and the
|
|
42
|
+
transfer math — is covered in [Tone and OKHST](./okhst.mdx).
|
|
43
|
+
|
|
44
|
+
## Dark is an inversion
|
|
45
|
+
|
|
46
|
+
Under the default `mode: 'auto'`, dark mode is a single inversion: an authored
|
|
47
|
+
tone `t` becomes `100 − t`, which is then remapped into the dark window. There
|
|
48
|
+
is no fitted curve. Because tone is contrast-shaped, the inversion itself keeps
|
|
49
|
+
the authored spacing — exactly for neutrals, approximately for chromatic
|
|
50
|
+
colors, whose luminance drifts a little with hue and saturation. The dark window
|
|
51
|
+
then scales every step to its span, about 0.87× at the defaults, so order and
|
|
52
|
+
direction carry over but steps come out a little smaller in dark. Tone deltas
|
|
53
|
+
have one more wrinkle, covered in
|
|
54
|
+
[How relative tone adapts](#how-relative-tone-adapts).
|
|
55
|
+
|
|
56
|
+
A surface at tone 97 becomes a dark surface, and text 52 tones below it
|
|
57
|
+
becomes light text above it. Hierarchy, ladders, and borders keep their order
|
|
58
|
+
without a second definition.
|
|
59
|
+
|
|
60
|
+
### Tone windows
|
|
61
|
+
|
|
62
|
+
Ordinary schemes don't use the whole tone scale. Each one has a **tone window**:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
glaze.configure({
|
|
66
|
+
lightTone: [10, 100], // default
|
|
67
|
+
darkTone: [15, 95], // default
|
|
68
|
+
});
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The bounds are OKHSL lightness values. Glaze converts them to tone — the light
|
|
72
|
+
window spans tone 4.95 to 100, the dark window 9.25 to 95.9 — and spreads the
|
|
73
|
+
whole authored `0–100` scale across that interval. The window is a remap, not a
|
|
74
|
+
clamp: an authored tone of 0 renders at tone 4.95 in light, and every other tone
|
|
75
|
+
moves proportionally. Per scheme and mode:
|
|
76
|
+
|
|
77
|
+
```text
|
|
78
|
+
light: t' = remap(t, lightTone)
|
|
79
|
+
dark, auto: t' = remap(100 - t, darkTone)
|
|
80
|
+
dark, fixed: t' = remap(t, darkTone)
|
|
81
|
+
static: t' = t
|
|
82
|
+
high contrast: the same, with the window [0, 100]
|
|
83
|
+
|
|
84
|
+
remap(t, [lo, hi]) = tone(lo) + t / 100 × (tone(hi) - tone(lo))
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
<WindowMap caption="Authored tones 0–100 (top row) and the tone Glaze stores for each in light and dark. The light window lifts the bottom of the scale; the dark window reverses it and trims both ends." />
|
|
88
|
+
|
|
89
|
+
A window takes three forms:
|
|
90
|
+
|
|
91
|
+
- `[lo, hi]` — OKHSL lightness bounds at the reference eps. The common form.
|
|
92
|
+
- `{ lo, hi, eps }` — the same bounds with an explicit render eps for that
|
|
93
|
+
scheme (the reference eps is `0.05`). Glaze still stores the result on the
|
|
94
|
+
reference scale, so deltas and contrast stay comparable. Advanced; most
|
|
95
|
+
palettes keep the default.
|
|
96
|
+
- `false` — the full range `[0, 100]`. This removes the boundaries, not the
|
|
97
|
+
tone transfer.
|
|
98
|
+
|
|
99
|
+
Set windows globally with `glaze.configure()` or per theme or token with a
|
|
100
|
+
config override (`glaze(280, 80, { darkTone: [10, 98] })`). The defaults and
|
|
101
|
+
every field are listed in [Configuration](./reference/configuration.md#tone-windows).
|
|
102
|
+
|
|
103
|
+
Some colors skip a window:
|
|
104
|
+
|
|
105
|
+
- **High-contrast variants** ignore both windows and use the full range. See
|
|
106
|
+
[High contrast](#high-contrast).
|
|
107
|
+
- **`mode: 'static'` colors** skip both windows in every scheme. See
|
|
108
|
+
[Adaptation modes](#adaptation-modes).
|
|
109
|
+
- **A color seeded with `from`** (a literal hex, `rgb()`, `oklch()`, … value)
|
|
110
|
+
skips the light window, so its light variant reproduces the literal exactly,
|
|
111
|
+
and so does its light high-contrast variant, whose window is the full range
|
|
112
|
+
anyway. Dark, dark high contrast, and a contrast floor still adapt it. See
|
|
113
|
+
[`from`](./reference/color-definitions.md#from).
|
|
114
|
+
- **An extreme with a base** (`tone: 'max'` / `'min'`) can land outside the dark
|
|
115
|
+
window. See [Extremes in dark](#extremes-in-dark).
|
|
116
|
+
|
|
117
|
+
## Adaptation modes
|
|
118
|
+
|
|
119
|
+
A color's `mode` decides how its tone moves between schemes. In light, `auto`
|
|
120
|
+
and `fixed` both remap the authored tone into the light window, and `static`
|
|
121
|
+
keeps it as authored. They differ in dark:
|
|
122
|
+
|
|
123
|
+
| Mode | In dark | Use it for |
|
|
124
|
+
| ------------------ | -------------------------------------------- | --------------------------------- |
|
|
125
|
+
| `'auto'` (default) | Inverted, then remapped into the dark window | Surfaces, text, borders, icons |
|
|
126
|
+
| `'fixed'` | Remapped into the dark window, not inverted | Brand fills, CTAs, status banners |
|
|
127
|
+
| `'static'` | The authored tone, as in light | Logos and other exact colors |
|
|
128
|
+
|
|
129
|
+
`auto` suits anything that should trade places with its background in dark;
|
|
130
|
+
`fixed` keeps a color on its side of the scale, as brand fills and inverse
|
|
131
|
+
surfaces need.
|
|
132
|
+
|
|
133
|
+
<SchemeExample
|
|
134
|
+
hue={280}
|
|
135
|
+
saturation={60}
|
|
136
|
+
colors={{
|
|
137
|
+
'pale-auto': { tone: 92 },
|
|
138
|
+
'deep-auto': { tone: 25 },
|
|
139
|
+
'pale-fixed': { tone: 92, mode: 'fixed' },
|
|
140
|
+
'deep-fixed': { tone: 25, mode: 'fixed' },
|
|
141
|
+
'pale-static': { tone: 92, mode: 'static' },
|
|
142
|
+
'deep-static': { tone: 25, mode: 'static' },
|
|
143
|
+
}}
|
|
144
|
+
rows={[
|
|
145
|
+
{ label: "mode: 'auto'", show: ['pale-auto', 'deep-auto'] },
|
|
146
|
+
{ label: "mode: 'fixed'", show: ['pale-fixed', 'deep-fixed'] },
|
|
147
|
+
{ label: "mode: 'static'", show: ['pale-static', 'deep-static'] },
|
|
148
|
+
]}
|
|
149
|
+
caption="The same two tones under each mode. auto swaps them, fixed keeps them on their side and only remaps them into the dark window, static renders the authored tone everywhere."
|
|
150
|
+
/>
|
|
151
|
+
|
|
152
|
+
`static` skips more than the window: it also skips
|
|
153
|
+
[dark desaturation](#dark-desaturation) and any [dark seed](#dark-seed), so the
|
|
154
|
+
color is pinned to one tone, hue, and saturation. In high-contrast variants,
|
|
155
|
+
`auto` still inverts and `fixed` / `static` don't.
|
|
156
|
+
|
|
157
|
+
<Callout kind="note" title="Inversion is not autoFlip">
|
|
158
|
+
Dark tone inversion is controlled by `mode` alone. `autoFlip` is unrelated:
|
|
159
|
+
it only lets an overshooting tone delta, or a contrast floor that can't be met
|
|
160
|
+
on the requested side, reverse around its base. See
|
|
161
|
+
[autoFlip](./contrast.mdx#autoflip-crossing-to-the-other-side).
|
|
162
|
+
</Callout>
|
|
163
|
+
|
|
164
|
+
For a worked palette that picks a mode per token — surfaces and text `auto`,
|
|
165
|
+
accent fills `fixed` — see [Designing a palette](./methodology.mdx).
|
|
166
|
+
|
|
167
|
+
## How relative tone adapts
|
|
168
|
+
|
|
169
|
+
A tone delta (`'+N'` / `'-N'`) places a dependent color relative to its base,
|
|
170
|
+
and the base is resolved separately in each scheme. What happens to the delta
|
|
171
|
+
depends on the dependent's mode:
|
|
172
|
+
|
|
173
|
+
- **`auto`** — in light, the delta is applied to the base's light tone as
|
|
174
|
+
written. In dark, Glaze adds the delta to the base's _light_ tone, then
|
|
175
|
+
inverts and remaps that result through the dark window, exactly like an
|
|
176
|
+
authored tone. The step flips direction and keeps its place in the ladder.
|
|
177
|
+
On a base near the top of the scale, such as a light surface, it comes out
|
|
178
|
+
about 0.87× the authored size. The base's light tone already carries the
|
|
179
|
+
light window's lift (about 5 tones at the bottom of the scale, none at the
|
|
180
|
+
top), and that lift is carried into dark, so on a lower base the dark step is
|
|
181
|
+
uneven: on a tone-50 base, `'+4'` becomes −5.6 and `'-4'` becomes +1.3. In
|
|
182
|
+
high contrast the windows are the full range, so the step is exact in both
|
|
183
|
+
schemes.
|
|
184
|
+
- **`fixed`** — the delta is applied to the base's tone in each scheme with its
|
|
185
|
+
sign preserved. A `'+30'` label stays 30 tones lighter than its fill in light
|
|
186
|
+
and in dark. A label on a `fixed` fill should be `fixed` too: the inversion
|
|
187
|
+
follows the label's own mode, so an `auto` label inverts in dark and lands on
|
|
188
|
+
the other side of its fill (30 tones above it in light, 31.5 below it in
|
|
189
|
+
dark).
|
|
190
|
+
- **`static`** — the delta is applied the same way as `fixed`, to the base's
|
|
191
|
+
tone in each scheme, without inversion. A static dependent of an adaptive base
|
|
192
|
+
therefore still moves with that base; only its own tone mapping is skipped.
|
|
193
|
+
|
|
194
|
+
<SchemeExample
|
|
195
|
+
hue={280}
|
|
196
|
+
saturation={30}
|
|
197
|
+
colors={{
|
|
198
|
+
surface: { tone: 97 },
|
|
199
|
+
'surface-2': { base: 'surface', tone: '-4' },
|
|
200
|
+
border: { base: 'surface', tone: '-12' },
|
|
201
|
+
text: { base: 'surface', tone: '-52' },
|
|
202
|
+
fill: { tone: 52, mode: 'fixed' },
|
|
203
|
+
'fill-text': { base: 'fill', tone: '+30', mode: 'fixed' },
|
|
204
|
+
}}
|
|
205
|
+
schemes={['light', 'dark', 'lightContrast', 'darkContrast']}
|
|
206
|
+
delta
|
|
207
|
+
ratio
|
|
208
|
+
caption="Each dependent is drawn over its base, with its tone step from the base (Δ) and the measured WCAG ratio. The auto ladder flips in dark with slightly smaller steps; the fixed pair keeps +30 everywhere; high contrast keeps every step exact."
|
|
209
|
+
/>
|
|
210
|
+
|
|
211
|
+
The `text` row shows why legibility belongs to `contrast`, not to a delta: the
|
|
212
|
+
same `'-52'` step measures 4.93:1 in light and 3.91:1 in dark. A delta is a
|
|
213
|
+
visual relationship; a [contrast floor](./contrast.mdx) is a guarantee that is
|
|
214
|
+
solved in every scheme.
|
|
215
|
+
|
|
216
|
+
### Overshooting deltas
|
|
217
|
+
|
|
218
|
+
When `base + delta` falls outside `[0, 100]`, the delta is mirrored to the other
|
|
219
|
+
side of the base — `'+10'` on a tone-97 surface resolves to 87.1 in light
|
|
220
|
+
instead of clamping at 100. If the mirrored target is also out of range, the
|
|
221
|
+
original delta is kept and clamped on the authored side. This is `autoFlip`,
|
|
222
|
+
on by default; set `autoFlip: false` on the color (or globally) to always clamp
|
|
223
|
+
on the authored side.
|
|
224
|
+
|
|
225
|
+
## Extremes in dark
|
|
226
|
+
|
|
227
|
+
`tone: 'max'` and `tone: 'min'` force a color to the end of the scheme's tone
|
|
228
|
+
scale without a magic number. `'max'` is authored tone 100 and `'min'` is 0, so
|
|
229
|
+
without a `base` they go through the ordinary scheme mapping:
|
|
230
|
+
|
|
231
|
+
- Under `auto`, they invert: `'max'` is the lightest color in light (tone 100)
|
|
232
|
+
and the darkest in dark (tone 9.25, the bottom of the dark window).
|
|
233
|
+
- Under `fixed`, they keep their end: `'max'` stays the lightest (tone 95.9 in
|
|
234
|
+
dark).
|
|
235
|
+
- Under `static`, they're pinned to the absolute end in every scheme.
|
|
236
|
+
|
|
237
|
+
With a `base`, an extreme keeps its contrast with that base instead. Remapping
|
|
238
|
+
the extreme through the narrower dark window would squeeze the span between
|
|
239
|
+
the two, so Glaze measures the tone shift the light scheme applied between base
|
|
240
|
+
and extreme, then replays it against the base's dark tone — mirrored under
|
|
241
|
+
`auto`, same-signed under `fixed`.
|
|
242
|
+
|
|
243
|
+
<SchemeExample
|
|
244
|
+
hue={0}
|
|
245
|
+
saturation={0}
|
|
246
|
+
colors={{
|
|
247
|
+
bg: { tone: 60 },
|
|
248
|
+
fg: { base: 'bg', tone: 'max' },
|
|
249
|
+
top: { tone: 'max' },
|
|
250
|
+
bottom: { tone: 'min' },
|
|
251
|
+
}}
|
|
252
|
+
delta
|
|
253
|
+
ratio
|
|
254
|
+
caption="fg replays the light shift (+38) as -38 in dark, keeping the same ratio. The root extremes top and bottom invert and land on the window edges."
|
|
255
|
+
/>
|
|
256
|
+
|
|
257
|
+
The replayed tone is clamped to `[0, 100]` only, so it can cross the `darkTone`
|
|
258
|
+
boundary — `fg` above lands at tone 5.9, below the dark window's 9.25. That is
|
|
259
|
+
intended: you asked for the extreme. When the shift doesn't fit above or below
|
|
260
|
+
the dark base, the color pins at tone 100 or 0. `static` extremes are
|
|
261
|
+
unaffected. High-contrast variants follow the same rule, but their window is
|
|
262
|
+
already the full range, so the replay matches the plain mapping unless the base
|
|
263
|
+
itself sits asymmetrically across schemes.
|
|
264
|
+
|
|
265
|
+
## Hue and saturation in dark
|
|
266
|
+
|
|
267
|
+
Tone is the only channel the scheme pipeline adapts. Hue and saturation enter
|
|
268
|
+
it as authored, with two adjustments for the dark schemes.
|
|
269
|
+
|
|
270
|
+
### Dark desaturation
|
|
271
|
+
|
|
272
|
+
Dark variants get a flat saturation reduction, set by `darkDesaturation`
|
|
273
|
+
(default `0.1`):
|
|
274
|
+
|
|
275
|
+
```text
|
|
276
|
+
S_dark = S_light × (1 − darkDesaturation)
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
It applies to `dark` and `darkContrast`, skips `static` colors, and is bypassed
|
|
280
|
+
entirely for a color that has an explicit dark saturation (below). Set
|
|
281
|
+
`darkDesaturation` globally or per instance.
|
|
282
|
+
|
|
283
|
+
<SchemeExample
|
|
284
|
+
hue={280}
|
|
285
|
+
saturation={80}
|
|
286
|
+
colors={{
|
|
287
|
+
accent: { tone: 55 },
|
|
288
|
+
logo: { tone: 55, mode: 'static' },
|
|
289
|
+
}}
|
|
290
|
+
value="channels"
|
|
291
|
+
caption="Seed saturation 80: accent loses 10% of its saturation in dark; the static logo keeps it."
|
|
292
|
+
/>
|
|
293
|
+
|
|
294
|
+
### Dark seed
|
|
295
|
+
|
|
296
|
+
When a uniform reduction isn't enough — a hue that turns muddy on dark
|
|
297
|
+
surfaces, an accent that stays loud however far you turn the global knob — give
|
|
298
|
+
the theme a second seed for the dark schemes:
|
|
299
|
+
|
|
300
|
+
<SchemeExample
|
|
301
|
+
hue={280}
|
|
302
|
+
saturation={80}
|
|
303
|
+
darkHue={250}
|
|
304
|
+
darkSaturation={50}
|
|
305
|
+
colors={{
|
|
306
|
+
accent: { tone: 55 },
|
|
307
|
+
'accent-alt': { tone: 55, hue: '+20' },
|
|
308
|
+
warning: { tone: 55, hue: 40 },
|
|
309
|
+
link: { tone: 55, hue: '+20', darkHue: '+35' },
|
|
310
|
+
highlight: { tone: 55, saturation: 0.9, darkSaturation: 0.6 },
|
|
311
|
+
logo: { tone: 55, mode: 'static' },
|
|
312
|
+
}}
|
|
313
|
+
value="channels"
|
|
314
|
+
caption="Hue and saturation per scheme. Relative hues re-anchor to the dark seed, the absolute warning hue stays put, link and highlight override dark on their own, and the static logo ignores the dark seed."
|
|
315
|
+
/>
|
|
316
|
+
|
|
317
|
+
How the dark channels resolve:
|
|
318
|
+
|
|
319
|
+
- **Both levels.** `darkHue` / `darkSaturation` exist on the seed and on each
|
|
320
|
+
color, and apply to `dark` and `darkContrast`. On the seed, `darkSaturation`
|
|
321
|
+
is `0–100` like `saturation`; on a color it is a `0–1` factor of the dark seed
|
|
322
|
+
saturation, like a color's `saturation`.
|
|
323
|
+
- **Fallbacks.** A missing dark value falls back to its light counterpart, and
|
|
324
|
+
a color falls back to the seed. Omitting all of them reproduces the single-seed
|
|
325
|
+
output exactly.
|
|
326
|
+
- **Relative hue follows the dark seed.** `darkHue: '+N'` is relative to the dark
|
|
327
|
+
seed hue, and so is a plain `hue: '+N'` with no `darkHue`, so a whole palette
|
|
328
|
+
rotates together.
|
|
329
|
+
- **Absolute hue stays absolute.** A numeric `hue` with no `darkHue` is used
|
|
330
|
+
verbatim in dark.
|
|
331
|
+
- **No double reduction.** Any explicit dark saturation, on the seed or the
|
|
332
|
+
color, replaces `darkDesaturation` instead of stacking with it. The number you
|
|
333
|
+
write is the number you get. A
|
|
334
|
+
[`from`](./reference/color-definitions.md#from) color with neither
|
|
335
|
+
`saturation` nor `darkSaturation` of its own is the exception: it starts from
|
|
336
|
+
the literal's saturation, ignores a seed-level `darkSaturation`, and still
|
|
337
|
+
gets `darkDesaturation`. Give it a `saturation` and it reads the seed again,
|
|
338
|
+
dark seed included.
|
|
339
|
+
- **`static` ignores both**, and **shadows, glows, and mixes** have no channels
|
|
340
|
+
of their own: they derive hue and saturation from the colors they reference,
|
|
341
|
+
so they inherit dark overrides automatically.
|
|
342
|
+
|
|
343
|
+
With `splitHue` output, a dark seed makes the hue custom properties
|
|
344
|
+
scheme-dependent, and Glaze re-declares them in the dark block — see
|
|
345
|
+
[Dark hues](./reference/output.md#dark-hues). The full field rules are in
|
|
346
|
+
[Color definitions](./reference/color-definitions.md#dark-seed-darkhue--darksaturation); prefer tuning the seed
|
|
347
|
+
over scattering per-color dark overrides, as
|
|
348
|
+
[Designing a palette](./methodology.mdx) explains.
|
|
349
|
+
|
|
350
|
+
## High contrast
|
|
351
|
+
|
|
352
|
+
The `lightContrast` and `darkContrast` variants serve readers who ask for more
|
|
353
|
+
contrast — by default through `@media(prefers-contrast: more)`. They differ from
|
|
354
|
+
the normal variants in three ways:
|
|
355
|
+
|
|
356
|
+
1. **No tone windows.** Both windows are bypassed and the full `[0, 100]` range
|
|
357
|
+
is available, so edge tones reach the edge and contrast floors have more
|
|
358
|
+
room to solve. `auto` still inverts; `fixed` and `static` don't.
|
|
359
|
+
2. **High-contrast pairs.** Any value written as `[normal, highContrast]` takes
|
|
360
|
+
its second entry.
|
|
361
|
+
3. **Escalated contrast floors.** Unless you write an explicit high-contrast
|
|
362
|
+
value, WCAG presets step up (`AA → AAA`, `AA-large → AAA-large`) and APCA
|
|
363
|
+
targets gain 15 Lc. A bare WCAG number (`4.5`, `{ wcag: 6 }`) and the AAA
|
|
364
|
+
presets are not escalated; give them an explicit pair such as `[4.5, 7]`.
|
|
365
|
+
See [Contrast solving](./contrast.mdx#high-contrast).
|
|
366
|
+
|
|
367
|
+
<WindowMap
|
|
368
|
+
rows={[
|
|
369
|
+
{ scheme: 'light' },
|
|
370
|
+
{ scheme: 'lightContrast' },
|
|
371
|
+
{ scheme: 'dark' },
|
|
372
|
+
{ scheme: 'darkContrast' },
|
|
373
|
+
]}
|
|
374
|
+
caption="Authored tones 0–100 in each scheme. The high-contrast rows use the full scale: auto still inverts, but nothing is trimmed."
|
|
375
|
+
/>
|
|
376
|
+
|
|
377
|
+
High-contrast variants are always resolved. Whether they're exported is up to
|
|
378
|
+
[output modes](#output-modes), which leave them out by default.
|
|
379
|
+
|
|
380
|
+
### High-contrast pairs
|
|
381
|
+
|
|
382
|
+
`HCPair<T>` is either one value used in both tiers or an explicit
|
|
383
|
+
`[normal, highContrast]` pair:
|
|
384
|
+
|
|
385
|
+
```ts
|
|
386
|
+
type HCPair<T> = T | [T, T];
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
It is accepted by `tone`, `contrast`, shadow and glow `intensity`, and mix
|
|
390
|
+
`value`:
|
|
391
|
+
|
|
392
|
+
```ts
|
|
393
|
+
theme.colors({
|
|
394
|
+
surface: { tone: 97 },
|
|
395
|
+
// -8 in both tiers
|
|
396
|
+
divider: { base: 'surface', tone: '-8' },
|
|
397
|
+
// -8 normal, -16 high contrast
|
|
398
|
+
border: { base: 'surface', tone: ['-8', '-16'] },
|
|
399
|
+
// APCA preset: Lc 60, escalated to 75 in high contrast
|
|
400
|
+
text: { base: 'surface', contrast: { apca: 'content' } },
|
|
401
|
+
// Explicit pair: Lc 60 normal, Lc 90 high contrast
|
|
402
|
+
'text-strong': {
|
|
403
|
+
base: 'surface',
|
|
404
|
+
contrast: { apca: ['content', 'preferred'] },
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
For `contrast`, the pair can wrap the whole spec (`[4.5, 7]`) or sit inside the
|
|
410
|
+
metric (`{ wcag: [4.5, 7] }`). An explicit high-contrast value turns off the
|
|
411
|
+
automatic WCAG promotion or APCA boost for that color.
|
|
412
|
+
|
|
413
|
+
<SchemeExample
|
|
414
|
+
hue={280}
|
|
415
|
+
saturation={30}
|
|
416
|
+
colors={{
|
|
417
|
+
surface: { tone: 97 },
|
|
418
|
+
border: { base: 'surface', tone: ['-12', '-24'] },
|
|
419
|
+
text: { base: 'surface', tone: '-45', contrast: 'AA' },
|
|
420
|
+
}}
|
|
421
|
+
schemes={['light', 'dark', 'lightContrast', 'darkContrast']}
|
|
422
|
+
ratio
|
|
423
|
+
caption="The border doubles its step through an HC pair; the text's AA floor is promoted to AAA in high contrast."
|
|
424
|
+
/>
|
|
425
|
+
|
|
426
|
+
High contrast is not a separate palette. Add pairs where readers should get
|
|
427
|
+
more separation — text and content contrast, accent fills, borders, shadows
|
|
428
|
+
and glows, decorative ramps that must stay perceivable — and leave the rest to
|
|
429
|
+
the window bypass and the automatic escalation.
|
|
430
|
+
|
|
431
|
+
## Manual contrast level
|
|
432
|
+
|
|
433
|
+
The two tiers assume contrast is a binary OS preference. When it is a setting
|
|
434
|
+
your users control, set `contrastLevel` to a number from `0` to `100`: the
|
|
435
|
+
**normal** variants slide from the palette you authored (`0`) to its
|
|
436
|
+
high-contrast resolution (`100`), and every level in between is resolved at
|
|
437
|
+
that level. The default, `'auto'`, takes the authored normal entries as they
|
|
438
|
+
are. The authoring work is the same: the high-contrast pairs you already wrote
|
|
439
|
+
are what the slider ramps toward.
|
|
440
|
+
|
|
441
|
+
`contrastLevel` is a config field, so it works globally, per theme, per token,
|
|
442
|
+
and through `extend()`:
|
|
443
|
+
|
|
444
|
+
```ts
|
|
445
|
+
glaze.configure({ contrastLevel: 60 });
|
|
446
|
+
const theme = glaze(280, 80, { contrastLevel: 60 });
|
|
447
|
+
glaze.color('#26fcb2', { contrastLevel: 60 });
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
<SchemeExample
|
|
451
|
+
visual="levels"
|
|
452
|
+
hue={280}
|
|
453
|
+
saturation={60}
|
|
454
|
+
colors={{
|
|
455
|
+
surface: { tone: 97, saturation: 0.2 },
|
|
456
|
+
border: { base: 'surface', tone: ['-12', '-24'], saturation: 0.3 },
|
|
457
|
+
text: { base: 'surface', tone: '-45', contrast: 'AA', saturation: 0.5 },
|
|
458
|
+
'accent-label': { tone: 100, mode: 'fixed' },
|
|
459
|
+
accent: {
|
|
460
|
+
base: 'accent-label',
|
|
461
|
+
tone: '-30',
|
|
462
|
+
contrast: { apca: ['content', 'body'] },
|
|
463
|
+
mode: 'fixed',
|
|
464
|
+
},
|
|
465
|
+
}}
|
|
466
|
+
after={
|
|
467
|
+
'// Each column is this theme with a config override:\n' +
|
|
468
|
+
'// { contrastLevel: 0 | 25 | 50 | 75 | 100 }'
|
|
469
|
+
}
|
|
470
|
+
caption="The normal light and dark variants at five levels. Under each: the measured WCAG ratio of text and border against the surface, and the APCA Lc of the accent label on its fill, whose floor ramps from 'content' (Lc 60) to 'body' (Lc 75). Level 0 is the authored palette; level 100 equals the theme's high-contrast tier (last column)."
|
|
471
|
+
/>
|
|
472
|
+
|
|
473
|
+
What the level moves:
|
|
474
|
+
|
|
475
|
+
- **Authored pairs** — `tone`, `contrast`, `intensity`, and mix `value` pairs
|
|
476
|
+
are interpolated between their two entries.
|
|
477
|
+
- **The tone windows** — each endpoint walks toward the full range: light
|
|
478
|
+
`10 → 0`, dark `15 → 0` and `95 → 100`.
|
|
479
|
+
- **Contrast escalation** — the normal and escalated targets are interpolated,
|
|
480
|
+
so `AA` at level 50 solves for 5.75:1.
|
|
481
|
+
|
|
482
|
+
The interpolated values go through the ordinary resolve, so a contrast floor is
|
|
483
|
+
**solved** at every level, not approximated. Hue, saturation,
|
|
484
|
+
`darkDesaturation`, `opacity`, and `pastel` don't change with the level.
|
|
485
|
+
|
|
486
|
+
Level `0` reproduces `'auto'` and level `100` reproduces the high-contrast
|
|
487
|
+
output, bit for bit. The high-contrast tier itself ignores the level:
|
|
488
|
+
`lightContrast` / `darkContrast` stay the true high-contrast resolution, so a
|
|
489
|
+
`prefers-contrast: more` block still escalates on top of the reader's chosen
|
|
490
|
+
level. At a global level of `100` the tier would duplicate the normal set, so
|
|
491
|
+
it is dropped.
|
|
492
|
+
|
|
493
|
+
A pair with no midpoint switches from its normal entry to its high-contrast one
|
|
494
|
+
at level 50: one that mixes kinds (`[50, 'max']`, `[50, '+20']`), or any pair
|
|
495
|
+
of extremes (`['max', 'min']`), which have no magnitude to blend. A
|
|
496
|
+
contrast-solved color is pinned to the side of its base that the nearer
|
|
497
|
+
endpoint (level 0 or 100) resolves to, so it normally changes side at most
|
|
498
|
+
once, at level 50. Where the pinned side can't reach the target, it falls back
|
|
499
|
+
to the other side at that level. The full rules are in
|
|
500
|
+
[Manual contrast level](./reference/contrast.md#manual-contrast-level).
|
|
501
|
+
|
|
502
|
+
Because `configure()` invalidates every theme cache, a runtime slider only needs
|
|
503
|
+
to re-export:
|
|
504
|
+
|
|
505
|
+
```ts
|
|
506
|
+
slider.oninput = () => {
|
|
507
|
+
glaze.configure({ contrastLevel: slider.valueAsNumber });
|
|
508
|
+
apply(theme.css());
|
|
509
|
+
};
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
`contrastLevel: 0` is the natural rest position for a slider you ship but
|
|
513
|
+
default off. `configure()` never clears a field by omission, so pass
|
|
514
|
+
`contrastLevel: 'auto'` to leave manual mode. How `.export()` treats a level,
|
|
515
|
+
and what happens when a dependent and its base sit at different levels, is in
|
|
516
|
+
[Clearing it, exporting it, base links](./reference/contrast.md#clearing-it-exporting-it-base-links).
|
|
517
|
+
|
|
518
|
+
## Output modes
|
|
519
|
+
|
|
520
|
+
Resolution always produces four variants; `modes` decides which ones an export
|
|
521
|
+
includes:
|
|
522
|
+
|
|
523
|
+
```ts
|
|
524
|
+
// Light only
|
|
525
|
+
theme.tokens({ modes: { dark: false } });
|
|
526
|
+
|
|
527
|
+
// Light + dark (the default)
|
|
528
|
+
theme.tokens();
|
|
529
|
+
|
|
530
|
+
// All four
|
|
531
|
+
theme.tokens({ modes: { dark: true, highContrast: true } });
|
|
532
|
+
// → { light, dark, lightContrast, darkContrast }
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
`modes` is accepted by `tokens()`, `tasty()`, `json()`, `dtcg()`,
|
|
536
|
+
`dtcgResolver()`, and `tailwind()`, on themes and palettes alike. `css()` has
|
|
537
|
+
no `modes` option: it always returns all four declaration blocks. A per-call
|
|
538
|
+
`modes` overrides `glaze.configure({ modes })`, and a global `contrastLevel` of
|
|
539
|
+
`100` drops the high-contrast tier even when `modes` asks for it; the full
|
|
540
|
+
precedence is in [Output modes](./reference/output.md#output-modes).
|
|
541
|
+
|
|
542
|
+
The selectors that switch variants in [Tasty](https://tasty.style) output come
|
|
543
|
+
from [`states.dark` and `states.highContrast`](./reference/output.md#state-aliases)
|
|
544
|
+
(by default the `prefers-color-scheme: dark` and `prefers-contrast: more` media
|
|
545
|
+
queries). How each export lays out its variants is in
|
|
546
|
+
[Output formats](./reference/output.md) and [Exporting and integration](./exporting.mdx).
|