@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.
@@ -0,0 +1,702 @@
1
+ ---
2
+ title: Contrast solving
3
+ description: Give a color a WCAG or APCA floor against its base and Glaze finds the tone that meets it in every scheme.
4
+ ---
5
+
6
+ import { Callout } from '@tenphi/cookbook/components';
7
+ import ContrastExample from '../docs-site/components/contrast/ContrastExample.astro';
8
+ import TokenExample from '../docs-site/components/contrast/TokenExample.astro';
9
+
10
+ Tone deltas space colors evenly; a **contrast floor** guarantees that a pair is
11
+ readable. Give a color a `base` and a `contrast` spec, and Glaze measures the
12
+ pair in light, dark, and both high-contrast schemes, then moves the color's
13
+ tone only when the measurement falls short.
14
+
15
+ ```ts
16
+ theme.colors({
17
+ surface: { tone: 97 },
18
+ 'surface-text': {
19
+ base: 'surface',
20
+ tone: '-60',
21
+ contrast: 'AA',
22
+ },
23
+ });
24
+ ```
25
+
26
+ The floor is a minimum, not a target: a color that already clears it keeps its
27
+ authored tone. Use tone for the relationship you want and `contrast` as the
28
+ safety net under it. The tone can be a delta from the base or an absolute tone:
29
+ a `base` with an absolute `tone` is a valid way to get a contrast guarantee
30
+ without a tone relationship.
31
+
32
+ ## Floors at a glance
33
+
34
+ Each text color below asks for a soft tone, `'-20'` from the surface, and lets
35
+ its floor decide how far to move. The numbers are measured from the resolved
36
+ colors in the scheme you are viewing; switch the site's appearance to see the
37
+ other three.
38
+
39
+ <ContrastExample
40
+ hue={280}
41
+ saturation={80}
42
+ colors={{
43
+ surface: { tone: 97, saturation: 0.1 },
44
+ 'aa-text': { base: 'surface', tone: '-20', saturation: 0.4, contrast: 'AA' },
45
+ 'aaa-text': { base: 'surface', tone: '-20', saturation: 0.4, contrast: 'AAA' },
46
+ 'content-text': {
47
+ base: 'surface',
48
+ tone: '-20',
49
+ saturation: 0.4,
50
+ contrast: { apca: 'content' },
51
+ },
52
+ 'body-text': {
53
+ base: 'surface',
54
+ tone: '-20',
55
+ saturation: 0.4,
56
+ contrast: { apca: 'body' },
57
+ },
58
+ 'preferred-text': {
59
+ base: 'surface',
60
+ tone: '-20',
61
+ saturation: 0.4,
62
+ contrast: { apca: 'preferred' },
63
+ },
64
+ }}
65
+ rows={['aa-text', 'aaa-text', 'content-text', 'body-text', 'preferred-text']}
66
+ caption="Each row is measured against surface in the active scheme. The first number is the floor's own metric; tone is the color's resolved tone (0–100)."
67
+ />
68
+
69
+ Compare the WCAG and APCA rows across light and dark: the two metrics disagree
70
+ most about light text on a dark background, which is why the choice of metric
71
+ matters.
72
+
73
+ ## WCAG and APCA
74
+
75
+ Glaze solves two metrics. Pick one per color.
76
+
77
+ ### WCAG 2 ratios
78
+
79
+ The WCAG 2 contrast ratio compares the relative luminance of two colors on a
80
+ scale from `1:1` to `21:1`. It is symmetric: swapping text and background gives
81
+ the same ratio. A bare number or a preset name means WCAG.
82
+
83
+ | Preset | Ratio | Use |
84
+ | ------------- | ----- | ---------------------------------------- |
85
+ | `'AA-large'` | 3 | Large or bold text (WCAG SC 1.4.3) |
86
+ | `'AA'` | 4.5 | Body text (WCAG SC 1.4.3) |
87
+ | `'AAA-large'` | 4.5 | Large or bold text, enhanced (SC 1.4.6) |
88
+ | `'AAA'` | 7 | Body text, enhanced (SC 1.4.6) |
89
+
90
+ ### APCA lightness contrast
91
+
92
+ APCA (the Accessible Perceptual Contrast Algorithm) reports a lightness
93
+ contrast, Lc, from 0 to about 106. It models how text actually reads, so it is
94
+ **polarity-aware**: dark text on a light background and light text on a dark
95
+ background score differently for the same pair of colors. Glaze takes the
96
+ magnitude and needs to know which color is the text; see
97
+ [Roles and APCA polarity](#roles-and-apca-polarity).
98
+
99
+ | Preset | Lc | Use |
100
+ | ------------- | --- | ----------------------------------------------------- |
101
+ | `'preferred'` | 90 | Preferred body and column text |
102
+ | `'body'` | 75 | Minimum for body and column text |
103
+ | `'content'` | 60 | Readable non-body content (about WCAG AA) |
104
+ | `'large'` | 45 | Large or bold headlines, fine icons and outlines |
105
+ | `'non-text'` | 30 | Solid icons and controls, placeholder or disabled text |
106
+ | `'min'` | 15 | Dividers and decoration; APCA's point of invisibility |
107
+
108
+ The presets are APCA's Bronze Simple Mode conformance levels and do not depend
109
+ on the role. You can also pass a raw Lc number.
110
+
111
+ ### Which one to use
112
+
113
+ - Prefer **APCA presets** for text, icons, and other content when perceptual
114
+ readability is the goal: `contrast: { apca: 'content' }`, or with an explicit
115
+ high-contrast step, `contrast: { apca: ['content', 'body'] }`.
116
+ - Use **WCAG** when compliance, policy, or a migration requires a WCAG ratio:
117
+ `contrast: 4.5`, `contrast: 'AAA'`, `contrast: { wcag: [4.5, 7] }`.
118
+ - Add a floor only where readability or recognizability needs a measured
119
+ guarantee. Low-stakes relationships — surface ladders, hover steps, subtle
120
+ fills — read better as tone deltas than as fake contrast floors.
121
+
122
+ ## Writing a contrast spec
123
+
124
+ ```ts
125
+ type ContrastPreset = 'AA' | 'AAA' | 'AA-large' | 'AAA-large';
126
+ type ApcaPreset =
127
+ | 'preferred'
128
+ | 'body'
129
+ | 'content'
130
+ | 'large'
131
+ | 'non-text'
132
+ | 'min';
133
+ type HCPair<T> = T | [T, T];
134
+
135
+ type ContrastSpec =
136
+ | number // WCAG ratio
137
+ | ContrastPreset // WCAG preset
138
+ | { wcag: HCPair<number | ContrastPreset> }
139
+ | { apca: HCPair<number | ApcaPreset> };
140
+
141
+ // On a regular or mix color:
142
+ // contrast?: HCPair<ContrastSpec>;
143
+ ```
144
+
145
+ `HCPair<T>` is either one value for every scheme or a
146
+ `[normal, highContrast]` pair. The pair can wrap the whole spec or sit inside
147
+ the metric:
148
+
149
+ ```ts
150
+ contrast: 4.5; // WCAG 4.5
151
+ contrast: 'AAA'; // WCAG 7
152
+ contrast: { wcag: 6 }; // WCAG 6
153
+ contrast: [4.5, 7]; // WCAG 4.5 normal / 7 high contrast
154
+ contrast: { wcag: [4.5, 7] }; // the same pair, inside the metric
155
+ contrast: { apca: 60 }; // Lc 60 normal / 75 high contrast (automatic)
156
+ contrast: { apca: 'content' }; // the same, by preset
157
+ contrast: { apca: [45, 60] }; // Lc 45 / 60 high contrast (explicit)
158
+ contrast: { apca: ['content', 'body'] }; // Lc 60 / 75 (explicit)
159
+ ```
160
+
161
+ A WCAG number below 1 is treated as 1, and a negative APCA number uses its
162
+ magnitude.
163
+
164
+ Two definitions are rejected before anything resolves:
165
+
166
+ - `contrast` without `base` on a theme color — there is nothing to measure
167
+ against:
168
+ `glaze: color "text" has "contrast" without "base".`
169
+ - A pair that switches metric, such as `[4.5, { apca: 75 }]`. A ratio and an Lc
170
+ are different scales, so Glaze cannot pick between them or interpolate them.
171
+ The error starts with
172
+ `glaze: color "text" has a "contrast" pair that switches metric (wcag → apca).`
173
+ and suggests the fix: use one metric for both entries, e.g.
174
+ `{ wcag: [4.5, 7] }`.
175
+
176
+ Every form, preset, and escalation rule is listed in the API reference under
177
+ [Contrast and high contrast](./reference/contrast.md).
178
+
179
+ ## How the solver finds a tone
180
+
181
+ For each scheme, Glaze resolves the base first, then the dependent color:
182
+
183
+ 1. **Preferred tone.** The color's authored tone is placed for this scheme and
184
+ clamped into the scheme's [tone window](./schemes.mdx#tone-windows). An
185
+ absolute tone is mapped through the scheme. A tone delta is applied to the
186
+ base's tone in that scheme, except in dark under the default
187
+ `mode: 'auto'`: there it is applied to the base's light tone and the result
188
+ is inverted and remapped like an absolute tone, so the dark step is about
189
+ 0.87× the delta on a light base and uneven on lower ones; high contrast
190
+ keeps it exact
191
+ ([How relative tone adapts](./schemes.mdx#how-relative-tone-adapts)). With
192
+ no `tone`, the color starts at the base's own tone.
193
+ 2. **Keep it if it passes.** The pair is measured with the chosen metric. If
194
+ it clears the floor (with a small safety margin), the preferred tone is the
195
+ result.
196
+ 3. **Search one side.** Otherwise the solver searches on the side the authored
197
+ tone points to: darker when the preferred tone is below the base, lighter
198
+ when it is above. A color with no tone of its own starts on whichever side
199
+ reaches higher contrast. For WCAG, the search starts from the gray tone that
200
+ gives exactly the ratio — tone is contrast-uniform for neutrals, so this
201
+ seed is closed-form — and a binary search along tone then measures the real,
202
+ chromatic color. The passing tone nearest the preferred one wins.
203
+ 4. **Cross over if needed.** With [`autoFlip`](#autoflip-crossing-to-the-other-side)
204
+ on (the default), the opposite side is searched too.
205
+
206
+ The solver aims slightly above the target — 1% over a WCAG ratio, 0.5 Lc over
207
+ an APCA target — so rounding in the output formats cannot push the final color
208
+ under the floor. Measured values therefore sit at or just above the floor, and
209
+ a chromatic color can land a little higher when the gray seed already clears it.
210
+
211
+ <ContrastExample
212
+ hue={280}
213
+ saturation={80}
214
+ colors={{
215
+ surface: { tone: 97, saturation: 0.1 },
216
+ 'muted-text': {
217
+ base: 'surface',
218
+ tone: '-30',
219
+ saturation: 0.4,
220
+ contrast: 'AA',
221
+ },
222
+ 'strong-text': {
223
+ base: 'surface',
224
+ tone: '-85',
225
+ saturation: 0.4,
226
+ contrast: 'AA',
227
+ },
228
+ }}
229
+ rows={['muted-text', 'strong-text']}
230
+ compare="unsolved"
231
+ caption="“Without the floor” is the same theme with contrast removed. muted-text is moved only as far as AA requires; strong-text already clears it and keeps its authored tone."
232
+ />
233
+
234
+ The preferred tone is clamped into the scheme's tone window, but the search
235
+ covers the full `0–100` tone range. A demanding floor can therefore push a color
236
+ past the window edge — for example, dark-scheme text that needs a very high Lc.
237
+
238
+ <Callout kind="note">
239
+ Tone is exactly contrast-uniform only for neutrals. Because the solver measures
240
+ the actual chromatic color, a saturated color still meets its floor; it just
241
+ ends at a different tone than a gray would. See
242
+ [Tone and OKHST](./okhst.mdx) for the model behind this.
243
+ </Callout>
244
+
245
+ ## autoFlip: crossing to the other side
246
+
247
+ Sometimes the authored side cannot reach the floor at all — a light label on a
248
+ mid-tone button, for instance. `autoFlip` decides what happens then:
249
+
250
+ - **On (default):** the solver also searches the opposite side of the base and
251
+ takes the passing tone nearest the preferred one. The light label becomes a
252
+ dark one.
253
+ - **Off:** only the authored side is searched. When it falls short, the color
254
+ pins to that side's extreme (tone `0` or `100`) and Glaze warns.
255
+
256
+ <ContrastExample
257
+ hue={280}
258
+ saturation={80}
259
+ colors={{
260
+ button: { tone: 68, saturation: 0.6 },
261
+ 'button-label': { base: 'button', tone: '+25', contrast: 'AA' },
262
+ 'button-label-pinned': {
263
+ base: 'button',
264
+ tone: '+25',
265
+ contrast: 'AA',
266
+ autoFlip: false,
267
+ },
268
+ }}
269
+ rows={['button-label', 'button-label-pinned']}
270
+ scheme="light"
271
+ sample="Publish"
272
+ caption="Light scheme. Both labels are authored lighter than the button. With autoFlip the first crosses to the darker side to meet AA; the pinned one stays on the lighter side and misses it. In dark the scheme inverts the authored direction, so the two swap sides."
273
+ />
274
+
275
+ The same switch governs **tone-delta overshoot**. When `base + delta` falls
276
+ outside `0–100`, `autoFlip` mirrors the delta to the other side of the base
277
+ (`'+30'` on a tone-90 surface becomes `'-30'`) instead of clamping at the
278
+ boundary. If the mirrored tone is also out of range, the original delta is
279
+ kept and clamped on the authored side.
280
+
281
+ ```ts
282
+ theme.colors({
283
+ surface: { tone: 90 },
284
+ // '+30' overshoots 100: mirrored to '-30'
285
+ raised: { base: 'surface', tone: '+30' },
286
+ // clamped at 100 instead
287
+ 'raised-pinned': {
288
+ base: 'surface',
289
+ tone: '+30',
290
+ autoFlip: false,
291
+ },
292
+ });
293
+ ```
294
+
295
+ Set it per color, per theme, or globally:
296
+
297
+ ```ts
298
+ // one color
299
+ theme.colors({
300
+ 'fill-label': { base: 'fill', contrast: 'AA', autoFlip: false },
301
+ });
302
+
303
+ // one theme
304
+ const strict = glaze(280, 80, { autoFlip: false });
305
+
306
+ // every theme and token
307
+ glaze.configure({ autoFlip: false });
308
+ ```
309
+
310
+ Use `autoFlip: false` when a color must stay on its side of the base — a
311
+ tone delta that should never cross over, or an unmet floor you would rather
312
+ see pinned to one extreme. A mix color has no per-color field; it follows the
313
+ theme's (or global) `autoFlip`.
314
+
315
+ `autoFlip` is unrelated to dark-mode inversion. A color swapping ends between
316
+ light and dark is its [adaptation mode](./schemes.mdx#adaptation-modes) at
317
+ work; `autoFlip` only reverses a delta or a contrast search around the base.
318
+
319
+ When both sides pass, "nearest wins" can pick a different side as the target
320
+ grows. Under a [manual contrast level](#manual-contrast-level), Glaze therefore
321
+ decides the side once per half of the slider, so a color changes side at most
322
+ once, at level 50, instead of jumping back and forth.
323
+
324
+ ## Roles and APCA polarity
325
+
326
+ APCA is asymmetric — the Lc of `a` on `b` differs from `b` on `a` — so Glaze
327
+ needs to know which of the pair is the foreground. A color's **role** decides
328
+ it. WCAG is symmetric and ignores roles.
329
+
330
+ | Role (polarity) | Name keywords |
331
+ | ------------------------ | ----------------------------------------------------------------- |
332
+ | `'text'` (foreground) | `text`, `fg`, `foreground`, `content`, `ink`, `label`, `stroke` |
333
+ | `'border'` (foreground) | `border`, `divider`, `outline`, `separator`, `hairline`, `rule` |
334
+ | `'surface'` (background) | `surface`, `bg`, `background`, `fill`, `canvas`, `paper`, `layer` |
335
+
336
+ A foreground color — text, icons, and other content, or a border, divider, or
337
+ outline — is measured as text over its base. A surface color — a background or
338
+ fill — is measured as the background, with its base as the text on top.
339
+
340
+ Below, two colors share one definition. The only difference is the name:
341
+ `accent-text` resolves as text and `accent-fill` as a surface, so the solver
342
+ measures them in opposite directions and lands on different tones.
343
+
344
+ <ContrastExample
345
+ hue={280}
346
+ saturation={80}
347
+ colors={{
348
+ surface: { tone: 97, saturation: 0.1 },
349
+ 'accent-text': { base: 'surface', tone: '-30', contrast: { apca: 'body' } },
350
+ 'accent-fill': { base: 'surface', tone: '-30', contrast: { apca: 'body' } },
351
+ }}
352
+ rows={['accent-text', 'accent-fill']}
353
+ scheme="light"
354
+ showRole
355
+ caption="Light scheme, where the difference is largest. accent-text is drawn as text on surface; accent-fill as a fill with surface on top. Lc is measured in the direction each role implies; roles swapped is the Lc the same pair would score used the other way round."
356
+ />
357
+
358
+ ### How a role is chosen
359
+
360
+ For each color, the first rule that applies wins:
361
+
362
+ 1. An explicit `role` (a role name or any keyword above as an alias).
363
+ 2. Inference from the color's name, when `inferRole` is on (the default). The
364
+ name is split on non-alphanumerics and camelCase humps, and the **last**
365
+ recognized keyword wins: `button-text` is text, `input-bg` is a surface,
366
+ `card-outline` is a border.
367
+ 3. The opposite of the base's role: a color on a surface is text; a color on
368
+ text or a border is a surface.
369
+ 4. `'text'` — the base is treated as the background.
370
+
371
+ ```ts
372
+ const theme = glaze(280, 60);
373
+
374
+ theme.colors({
375
+ surface: { tone: 90 },
376
+ // role 'text', from the name
377
+ text: { base: 'surface', contrast: { apca: 'content' } },
378
+ // role 'border', from the name
379
+ border: { base: 'surface', tone: '-10' },
380
+ // a fill with no keyword in its name would resolve as text
381
+ // (rule 3), so set the role explicitly
382
+ badge: {
383
+ base: 'surface',
384
+ contrast: { apca: 'content' },
385
+ role: 'surface',
386
+ },
387
+ });
388
+ ```
389
+
390
+ <Callout kind="tip" title="Name the pair">
391
+ A root color with no keyword in its name resolves as `'text'` (rule 4), so a
392
+ dependent of it that also has no keyword resolves as `'surface'` (rule 3). If
393
+ `brand` is a fill and `on-brand` is its label, name them `brand-fill` /
394
+ `brand-label`, or set `role` explicitly.
395
+ </Callout>
396
+
397
+ Turn name inference off with `glaze.configure({ inferRole: false })` or per
398
+ theme with `glaze(hue, saturation, { inferRole: false })`; rules 1, 3, and 4
399
+ still apply. Shadows and glows always resolve as surfaces. Mix colors take the
400
+ same chain and the same `role` field. Good token names do double duty: they
401
+ describe the palette and give APCA the right polarity without any `role`
402
+ fields.
403
+
404
+ ## High contrast
405
+
406
+ High-contrast variants (`lightContrast`, `darkContrast`) escalate the floor
407
+ automatically, so most colors need no high-contrast entry at all:
408
+
409
+ | Authored floor | Normal | High contrast |
410
+ | ---------------------------------- | ------ | -------------------------------- |
411
+ | `'AA'` | 4.5 | 7 (`'AAA'`) |
412
+ | `'AA-large'` | 3 | 4.5 (`'AAA-large'`) |
413
+ | `'AAA'`, `'AAA-large'` | as is | unchanged — already the top tier |
414
+ | a bare WCAG number | as is | unchanged — no successor tier |
415
+ | any APCA target | Lc | Lc + 15, capped at 106 |
416
+ | an explicit pair, outer or inner | first | second — no escalation |
417
+
418
+ The WCAG step follows the spec's own Enhanced level (SC 1.4.3 → SC 1.4.6). The
419
+ APCA step is the Lc analog of AAA over AA. An explicit high-contrast value, in
420
+ either the outer `contrast` pair or the inner `wcag` / `apca` pair, replaces the
421
+ automatic step. Use one to hold a ceiling: for large or bold text, where APCA
422
+ caps useful contrast at Lc 90 to avoid glare, `{ apca: ['preferred', 'preferred'] }`
423
+ keeps high contrast at Lc 90 instead of boosting it past the cap.
424
+
425
+ The automatic step can also outrun what a surface allows. An escalated
426
+ `'preferred'` becomes Lc 105, which dark text reaches only on a surface within
427
+ about a tone of white. On an off-white or tinted surface it pins to black and
428
+ warns in `lightContrast` — the `preferred-text` row in
429
+ [Floors at a glance](#floors-at-a-glance) does this. Give it an explicit,
430
+ reachable high-contrast entry there, such as `{ apca: [90, 95] }`.
431
+
432
+ High-contrast variants also ignore the [tone windows](./schemes.mdx#tone-windows): both the
433
+ placement and the search use the full `0–100` range, which gives the escalated
434
+ floors room to land.
435
+
436
+ <ContrastExample
437
+ hue={280}
438
+ saturation={80}
439
+ colors={{
440
+ surface: { tone: 97, saturation: 0.1 },
441
+ 'aa-text': { base: 'surface', tone: '-20', saturation: 0.4, contrast: 'AA' },
442
+ 'large-text': {
443
+ base: 'surface',
444
+ tone: '-20',
445
+ saturation: 0.4,
446
+ contrast: 'AA-large',
447
+ },
448
+ 'aaa-text': { base: 'surface', tone: '-20', saturation: 0.4, contrast: 'AAA' },
449
+ 'ratio-text': { base: 'surface', tone: '-20', saturation: 0.4, contrast: 4.5 },
450
+ 'content-text': {
451
+ base: 'surface',
452
+ tone: '-20',
453
+ saturation: 0.4,
454
+ contrast: { apca: 'content' },
455
+ },
456
+ 'explicit-text': {
457
+ base: 'surface',
458
+ tone: '-20',
459
+ saturation: 0.4,
460
+ contrast: { apca: ['content', 'content'] },
461
+ },
462
+ }}
463
+ rows={[
464
+ 'aa-text',
465
+ 'large-text',
466
+ 'aaa-text',
467
+ 'ratio-text',
468
+ 'content-text',
469
+ 'explicit-text',
470
+ ]}
471
+ compare="hc"
472
+ caption="Normal and high-contrast variants of each color, in your current light or dark setting. The floor shown is the one the solver used for that variant."
473
+ />
474
+
475
+ Any other value that takes an `HCPair` — `tone`, shadow and glow `intensity`,
476
+ mix `value` — can tighten the high-contrast variant the same way; see
477
+ [High-contrast pairs](./schemes.mdx#high-contrast-pairs).
478
+
479
+ ### Manual contrast level
480
+
481
+ With [`contrastLevel`](./reference/contrast.md#manual-contrast-level) set to a number, the normal
482
+ variants solve for a target between the normal and high-contrast floors. Both
483
+ ends are resolved with the rules above first, so `'AA'` ramps from 4.5 at level
484
+ `0` to 7 at level `100`, and an explicit pair ramps between its two entries. A
485
+ pair that switches metric has no midpoint, which is one reason validation
486
+ rejects it. Under a level, the side of the base a color sits on is decided once
487
+ per half of the ramp, so it changes side at most once — at level 50 — instead of
488
+ jumping back and forth. The high-contrast variants are not affected by the
489
+ level. The concept is covered in
490
+ [Light, dark, and high contrast](./schemes.mdx#manual-contrast-level); the full
491
+ rules are in
492
+ [Contrast and high contrast](./reference/contrast.md#manual-contrast-level).
493
+
494
+ ## When a floor can't be met
495
+
496
+ Some floors are physically out of reach. A mid-gray badge has no tone of text
497
+ that reaches 7:1 — not black, not white. When neither side passes, the color
498
+ pins to the extreme of the side it searched first, and Glaze warns.
499
+
500
+ <ContrastExample
501
+ hue={0}
502
+ saturation={0}
503
+ colors={{
504
+ badge: { tone: 50 },
505
+ 'badge-text': { base: 'badge', contrast: 'AAA' },
506
+ 'badge-text-light': { base: 'badge', tone: '+10', contrast: 'AAA' },
507
+ }}
508
+ rows={['badge-text', 'badge-text-light']}
509
+ scheme="light"
510
+ caption="Light scheme. Neither color can reach 7:1 on a mid-gray badge. badge-text has no tone of its own, so it starts on the stronger side; badge-text-light keeps its authored direction and pins to that side's extreme. In dark the inverted '+10' points darker, so both pin to black."
511
+ />
512
+
513
+ The side matters: the solver pins to the side it searched first, even when the
514
+ other extreme would get closer. A color with no tone of its own starts on the
515
+ stronger side; a color with an authored direction keeps that direction.
516
+
517
+ The warning names the color, the scheme, the target, and what was reached:
518
+
519
+ ```text
520
+ glaze: color "badge-text" cannot meet WCAG 7.00 in light scheme
521
+ (got 4.94).
522
+ Try widening the tone window, lowering the contrast target, or picking a
523
+ base color further from this color's tone.
524
+ ```
525
+
526
+ It fires when the result is more than 2% under a WCAG target or more than
527
+ 1.5 Lc under an APCA target. Each distinct warning prints once per session (the
528
+ dedupe cache holds 256 entries and starts over when full), so a dev server that
529
+ re-resolves a theme on every change does not flood the console. For a
530
+ standalone `glaze.color()`, pass `name` so the warning says which token it is.
531
+
532
+ To fix one:
533
+
534
+ - **Lower the target**, or give the high-contrast variant an explicit, reachable
535
+ entry: `contrast: ['AA', 'AA']` instead of letting AA escalate to AAA.
536
+ - **Move the base** further from the middle of the tone range.
537
+ - **Widen the tone window** when the normal scheme is clamping the base.
538
+ High-contrast variants already use the full range.
539
+
540
+ ### Chromatic drift advisory
541
+
542
+ After every scheme is resolved, Glaze re-measures each regular color that has a
543
+ floor against its base in that metric and emits an advisory when the measured value
544
+ is under the target by the same margin:
545
+
546
+ ```text
547
+ glaze: color "badge-text" drifts below WCAG 7.00 in light scheme
548
+ (measured 4.94).
549
+ Chromatic luminance differs from the gray tone; nudge the tone or
550
+ saturation if the floor matters.
551
+ ```
552
+
553
+ It is a safety net for the gap between tone and a chromatic color's real
554
+ luminance. Because the solver itself measures the chromatic color, you will
555
+ mostly see this advisory next to a "cannot meet" warning for the same color and
556
+ scheme rather than on its own. The model behind it is in
557
+ [Tone and OKHST](./okhst.mdx#the-drift-check).
558
+
559
+ ### Contrast and opacity
560
+
561
+ A color with both `contrast` and `opacity` resolves, but Glaze warns:
562
+
563
+ ```text
564
+ glaze: color "text" has both "contrast" and "opacity".
565
+ Opacity makes perceived tone unpredictable.
566
+ ```
567
+
568
+ The floor is solved for the opaque color; what the reader sees depends on what
569
+ shows through. For a translucent color with a floor, use a
570
+ [transparent mix](./mixes.mdx#transparent-mix), whose solver models the
571
+ browser's compositing.
572
+
573
+ ## Literal colors with a floor
574
+
575
+ A [`from`](./reference/color-definitions.md#from) color reproduces an exact value in
576
+ the light, normal-contrast scheme. A floor on it is still a floor: a value that
577
+ already clears it is emitted untouched, and one that misses moves only as far as
578
+ the floor requires. Dark and high contrast adapt as usual, and the floor is
579
+ solved in each of them.
580
+
581
+ <ContrastExample
582
+ hue={280}
583
+ saturation={80}
584
+ colors={{
585
+ surface: { tone: 100, saturation: 0.12 },
586
+ brand: { from: '#2f5bff', base: 'surface', contrast: 3 },
587
+ sky: { from: '#7cc8ff', base: 'surface', contrast: 3 },
588
+ }}
589
+ rows={['brand', 'sky']}
590
+ compare="unsolved"
591
+ sample={{ brand: 'Brand', sky: 'Sky' }}
592
+ caption="In light, brand already clears 3:1 on white and keeps its exact value; sky does not, so it darkens just enough."
593
+ />
594
+
595
+ A `from` color works as a base like any other color. Here the label is solved
596
+ against whatever the brand resolves to in each scheme:
597
+
598
+ ```ts
599
+ theme.colors({
600
+ surface: { tone: 100, saturation: 0.12 },
601
+ brand: { from: '#2f5bff', base: 'surface', contrast: 3 },
602
+ 'brand-label': {
603
+ base: 'brand',
604
+ tone: 100,
605
+ contrast: 'AA-large',
606
+ },
607
+ });
608
+ ```
609
+
610
+ ## Pairing standalone colors
611
+
612
+ [`glaze.color()`](./reference/color-tokens.md) tokens use the same solver. Give
613
+ a token a `base` — another token, or a raw color value that Glaze wraps with
614
+ `glaze.color(value)` — and its `contrast` is solved against the base's resolved
615
+ variant in every scheme.
616
+
617
+ <TokenExample
618
+ base="#1a1a2e"
619
+ color={{ from: '#ffffff', contrast: 'AA' }}
620
+ caption="White text paired with a dark navy token, measured in the active scheme. In dark, the navy base inverts and the text follows it."
621
+ />
622
+
623
+ With a `base`:
624
+
625
+ - `contrast` is solved per scheme against the base's `light`, `dark`,
626
+ `lightContrast`, and `darkContrast` variants.
627
+ - A tone delta (`tone: '+N'` / `'-N'`) is measured from the base. Under the
628
+ default `mode: 'auto'`, the dark variant is derived from the base's light
629
+ tone, so the step flips direction and comes out about 0.87× the delta on
630
+ every base. A token's base has no light-window lift, so unlike a theme color
631
+ ([How relative tone adapts](./schemes.mdx#how-relative-tone-adapts)) the dark
632
+ step is even. A relative `hue` still anchors to the token's own seed value.
633
+ - Pairs chain: a paired token can be the base of the next one.
634
+
635
+ ```ts
636
+ const page = glaze.color('#ffffff');
637
+ const button = glaze.color({
638
+ from: '#1e40af',
639
+ base: page,
640
+ contrast: 3,
641
+ name: 'button-fill',
642
+ });
643
+ const label = glaze.color({
644
+ from: '#ffffff',
645
+ base: button,
646
+ contrast: 'AA',
647
+ name: 'button-label',
648
+ });
649
+ ```
650
+
651
+ In light all three keep their exact values, since each already clears its floor.
652
+ In dark they invert, and the label is solved against the inverted button.
653
+
654
+ Without a `base`, a token's `contrast` (and a tone delta) is measured
655
+ against the token's own literal value, held fixed in every scheme. Base
656
+ resolution timing, structured-base linking, and the `name` label are covered in
657
+ [Color tokens](./reference/color-tokens.md#pairing-colors).
658
+
659
+ ## Contrast on mixes
660
+
661
+ Mix colors take the same `contrast` field. Instead of a tone, the solver adjusts
662
+ the mix ratio of an opaque mix or the opacity of a transparent one, against the
663
+ mix's `base`:
664
+
665
+ ```ts
666
+ theme.colors({
667
+ tint: {
668
+ type: 'mix',
669
+ base: 'surface',
670
+ target: 'accent',
671
+ value: 10,
672
+ contrast: 'AA',
673
+ },
674
+ overlay: {
675
+ type: 'mix',
676
+ base: 'surface',
677
+ target: 'accent',
678
+ value: 5,
679
+ blend: 'transparent',
680
+ contrast: 3,
681
+ },
682
+ });
683
+ ```
684
+
685
+ Both `value` and `contrast` accept `[normal, highContrast]` pairs. When even the
686
+ pure target can't meet the floor, the mix settles at the pure target. A `value`
687
+ of 100 has no room above, so it falls back to the pure base instead, and a
688
+ transparent mix ends at alpha 0; that includes a high-contrast entry of 100, as
689
+ in `value: [80, 100]`. Either way the mix resolves **without a warning**; the
690
+ drift advisory skips mixes too. Measure a mix yourself when its floor matters. See
691
+ [Contrast on mixes](./mixes.mdx#contrast-on-mixes).
692
+
693
+ ## Using the solver directly
694
+
695
+ The solver is exported for tools that work outside a theme:
696
+ `findToneForContrast`, `findValueForMixContrast`, `resolveContrastForMode`,
697
+ `resolveContrastForLevel`, `resolveMinContrast`, `resolveApcaTarget`,
698
+ `apcaContrast`, and the constants `APCA_PRESETS`, `APCA_HC_ENHANCEMENT` (15),
699
+ and `APCA_MAX_LC` (106). The role helpers — `normalizeRole`,
700
+ `inferRoleFromName`, `roleToPolarity`, `oppositeRole` — are exported too. Their
701
+ signatures and options are in the API reference under
702
+ [Utilities](./reference/utilities.md#contrast-solver).