@tenphi/glaze 2.0.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,976 @@
1
+ ---
2
+ title: Designing a palette
3
+ description: Build a complete Glaze palette step by step, from the surface ladder to status themes, and see it grow in every scheme.
4
+ ---
5
+
6
+ import { Callout, Steps } from '@tenphi/cookbook/components';
7
+ import PaletteStep from '../docs-site/components/methodology/PaletteStep.tsx';
8
+ import PalettePreview from '../docs-site/components/methodology/PalettePreview.tsx';
9
+ import StatusThemes from '../docs-site/components/methodology/StatusThemes.tsx';
10
+ import PaletteOutput from '../docs-site/components/methodology/PaletteOutput.astro';
11
+ import { PURPLE_HUE } from '../docs-site/components/methodology/palette.ts';
12
+
13
+ A practical way to design a Glaze palette without fighting dark mode. Start
14
+ from tone relationships, add contrast only where a role needs a readable floor,
15
+ and let `extend()` carry the same decisions across status hues.
16
+
17
+ This guide builds one palette from scratch: a neutral default theme with
18
+ surfaces, text, accents, disabled states, and effects, then a set of status
19
+ themes extended from it. After each step you see the tokens it adds and the
20
+ product UI built from the palette so far, resolved live in the scheme you are
21
+ viewing. Switch the Appearance menu to check dark and high contrast as you go.
22
+ This is where the guide ends up:
23
+
24
+ <PalettePreview caption="The finished palette, in the scheme you are viewing. Every part of this UI appears in the step that defines its token." />
25
+
26
+ For the color model behind tone, see [Tone and OKHST](./okhst.mdx); the
27
+ derivation lives in the
28
+ [canonical OKHST specification](https://github.com/tenphi/okhst). Every option
29
+ used here is documented in the [API reference](./reference/index.md).
30
+
31
+ ## The mental model
32
+
33
+ Glaze palettes work best as one default neutral theme plus a few colored
34
+ sibling themes:
35
+
36
+ - `default` owns the neutral system most components consume: `#surface`,
37
+ `#surface-text`, `#border`, `#disabled-surface`, and so on.
38
+ - Status themes (`success`, `danger`, `warning`, `note`, …) are created with
39
+ `extend()`. They swap the hue and keep only the inherited tokens that should
40
+ become status-aware.
41
+ - `inherit: false` keeps a token local to the parent theme. Use it for neutral
42
+ ladders, shadows, code colors, scrims, and anything that should not be
43
+ repeated for every status hue.
44
+
45
+ The main simplification is OKHST tone. Dark mode is a single inversion
46
+ (`100 - t`) plus a scheme tone window, so tone relationships carry over from
47
+ light to dark. A relative **tone delta** such as `tone: '-4'` is an exact tone
48
+ step from its base in light. In dark it steps from where the base sits on the
49
+ authored scale and is inverted and remapped like any authored tone, so it flips
50
+ with its base and keeps its place in the ladder, slightly compressed by the
51
+ dark window (about 0.87× at the defaults; high contrast keeps it exact). The
52
+ step is exactly contrast-even for neutrals and approximate for chromatic
53
+ colors; add a `contrast` floor when the measured result matters.
54
+ [How relative tone adapts](./schemes.mdx#how-relative-tone-adapts) shows the
55
+ mapping.
56
+
57
+ Use `glaze.color()` instead of a theme when you need one standalone color or
58
+ one base/dependent pair and do not need a named palette. The same tone,
59
+ adaptation, and contrast rules apply; see
60
+ [Color tokens](./reference/color-tokens.md).
61
+
62
+ ## Implementation workflow
63
+
64
+ Build in dependency order so every decision has a clear base:
65
+
66
+ <Steps>
67
+ <li>Configure the output schemes and application states once.</li>
68
+ <li>Choose the default theme's hue and saturation seed.</li>
69
+ <li>Define root surfaces with absolute tones.</li>
70
+ <li>Add dependent surfaces, text, borders, and icons with tone deltas.</li>
71
+ <li>Add contrast floors only to roles that need measured readability or recognizability.</li>
72
+ <li>Choose adaptation per color: `auto`, `fixed`, or `static`.</li>
73
+ <li>Add high-contrast pairs to tones and effects that should separate more in high contrast.</li>
74
+ <li>Mark default-only tokens `inherit: false`, then extend the shared definitions into status themes.</li>
75
+ <li>Compose and export the palette in the shape your application consumes.</li>
76
+ <li>Verify complete screens in all emitted scheme variants.</li>
77
+ </Steps>
78
+
79
+ The sections below follow this order and build one palette incrementally.
80
+
81
+ ## Authoring decisions
82
+
83
+ Use this order when defining a token:
84
+
85
+ 1. Pick the base it visually belongs to.
86
+ 2. Use an absolute numeric `tone` for independent placement. Use a signed
87
+ **tone delta** (`'+N'` / `'-N'`) for distance from a base: surface ladders,
88
+ soft chips, disabled states, and similar relationships. Under `auto`, the
89
+ dark step mirrors the light one around its base, wherever the base sits,
90
+ but a `fixed` base doesn't invert while the delta does, so the delta lands
91
+ on the other side of it in dark: make a delta off a `fixed` base `fixed`
92
+ too when it should keep its side. A delta has no floor of its own, so give
93
+ a hover that must stay readable its own `contrast`, or use a
94
+ [state layer](#state-layers) on fills.
95
+ 3. Add `contrast` only when readability or recognizability needs a measured
96
+ floor.
97
+ 4. Prefer APCA presets for content-like colors when perceptual readability is
98
+ the design goal: `contrast: { apca: 'content' }`.
99
+ 5. Use WCAG numbers or presets when compatibility, policy, or migration
100
+ requires a WCAG ratio: `contrast: 4.5`, `contrast: 'AAA'`, or
101
+ `contrast: { wcag: [4.5, 7] }`.
102
+ 6. Let token names infer APCA roles. A name whose last recognized keyword is
103
+ `text`, `label`, `border`, `surface`, `fill`, `bg`, or a similar alias
104
+ already tells Glaze which side is foreground or background, so
105
+ `surface-text-soft` is text and `accent-surface-hover` is a surface. Set
106
+ `role` only when a name is ambiguous.
107
+ 7. Let high contrast escalate floors on its own: an APCA floor rises by Lc 15,
108
+ `'AA'` becomes `'AAA'`, and `'AA-large'` becomes `'AAA-large'`. Add a
109
+ high-contrast pair where a value does not escalate by itself (tone, shadow /
110
+ glow intensity, mix value, a bare WCAG number) or where you want a different
111
+ high-contrast target.
112
+
113
+ [Contrast solving](./contrast.mdx) explains the two metrics and the presets;
114
+ [Roles and APCA polarity](./contrast.mdx#roles-and-apca-polarity) covers name
115
+ inference.
116
+
117
+ ### Choosing adaptation mode
118
+
119
+ Pick the mode by role. This palette uses all three:
120
+
121
+ | Mode | Roles |
122
+ | --- | --- |
123
+ | `auto` (default) | Surfaces, text, borders, icons, and accent foregrounds on neutral UI: they trade places with their background in dark. |
124
+ | `fixed` | Accent fills, their label, and the inverse surface: they keep their side of the tone scale. |
125
+ | `static` | The modal scrim, which must stay pure black in every scheme. |
126
+
127
+ [Adaptation modes](./schemes.mdx#adaptation-modes) shows what each mode does
128
+ per scheme. Dark tone inversion is controlled by `mode` alone; `autoFlip` only
129
+ lets an overshooting tone delta or an unmet contrast direction reverse around
130
+ its base.
131
+
132
+ ### Root or dependent?
133
+
134
+ - Use a root color when its tone has meaning on its own: the page surface, a
135
+ fixed brand anchor, or a scheme extreme.
136
+ - Use a dependent color when its purpose exists relative to another token:
137
+ text on a surface, a border around a fill, or a tint of an accent.
138
+ - Setting `base` with an absolute tone is valid when the color needs a
139
+ contrast relationship but not a tone delta. The absolute position is resolved
140
+ independently; `contrast` acts as a safety floor. Without `contrast`, such a
141
+ base does not change the resolved color; it only records which surface the
142
+ color belongs to.
143
+
144
+ ## Seed and configure
145
+
146
+ Keep hue decisions named and configure output modes once:
147
+
148
+ ```ts
149
+ import { glaze } from '@tenphi/glaze';
150
+
151
+ const PURPLE_HUE = 280.3;
152
+ const SUCCESS_HUE = 156.9;
153
+ const DANGER_HUE = 23.1;
154
+ const WARNING_HUE = 84.3;
155
+ const NOTE_HUE = 302.3;
156
+
157
+ const SEED_SATURATION = 80;
158
+
159
+ glaze.configure({
160
+ states: { dark: '@dark', highContrast: '@hc' },
161
+ modes: { dark: true, highContrast: true },
162
+ });
163
+ ```
164
+
165
+ `states` names the selectors your app uses for dark and high contrast, and
166
+ `modes` turns on the variants Glaze emits; both are covered in
167
+ [Configuration](./reference/configuration.md).
168
+
169
+ Per-color `saturation` is a factor of the theme seed, not an absolute
170
+ saturation. With `SEED_SATURATION = 80`, `saturation: 0.25` means one quarter
171
+ of that seed.
172
+
173
+ ### When dark needs its own chroma
174
+
175
+ Dark schemes get a flat `darkDesaturation` haircut (10% by default), which is
176
+ enough for most palettes. Reach for a second seed only when dark is wrong in a
177
+ way a uniform reduction cannot fix — a hue that reads muddy against dark
178
+ surfaces, or an accent that stays gaudy no matter how much you dial the global
179
+ knob. Pass the dark seed with the object form when you create the theme:
180
+
181
+ ```ts
182
+ const defaultTheme = glaze({
183
+ hue: PURPLE_HUE,
184
+ saturation: SEED_SATURATION,
185
+ darkHue: PURPLE_HUE - 12, // cooler purple on dark surfaces
186
+ darkSaturation: 62, // calmer than a 10% haircut would give
187
+ });
188
+ ```
189
+
190
+ <PalettePreview
191
+ columns={[
192
+ { label: 'Dark · seed only', scheme: 'dark' },
193
+ {
194
+ label: 'Dark · with the dark seed',
195
+ scheme: 'dark',
196
+ darkHue: PURPLE_HUE - 12,
197
+ darkSaturation: 62,
198
+ },
199
+ ]}
200
+ caption="The finished palette in dark, without and with the dark seed above. The accent fill's dark saturation goes from 72 (the seed's 80 less the 10% haircut) to exactly 62."
201
+ />
202
+
203
+ An explicit `darkSaturation` replaces `darkDesaturation` rather than stacking
204
+ with it, so the number you write is the number you get. Prefer tuning the seed
205
+ over per-color `darkHue` / `darkSaturation`: one seed keeps the palette
206
+ coherent, while scattered per-color dark overrides drift apart as the palette
207
+ grows. Save the per-color form for the one or two tokens that genuinely need
208
+ it. [Dark seed](./schemes.mdx#dark-seed) shows the effect per scheme, and
209
+ [Color definitions](./reference/color-definitions.md) has the per-color rules.
210
+ The rest of this guide builds the default theme without a dark seed, so its
211
+ previews show the plain haircut.
212
+
213
+ ## Naming
214
+
215
+ Prefer purpose first and variant last:
216
+
217
+ - Surfaces: `surface`, `surface-2`, `surface-3`.
218
+ - Foregrounds: `surface-text`, `surface-text-soft`, `surface-text-soft-2`.
219
+ - Structure: `border`, `divider`, `outline`, `placeholder`, `focus`.
220
+ - Fills: `accent-surface`, `accent-surface-2`, `accent-surface-hover`.
221
+ - Foregrounds on neutral surfaces: `accent-text`, `accent-text-soft`,
222
+ `accent-icon`.
223
+ - Disabled states: `disabled-surface`, `disabled-surface-text`,
224
+ `accent-disabled-surface`, `accent-disabled-surface-text`.
225
+ - Effects: `shadow-sm`, `shadow-md`, `shadow-lg`, `surface-hover`,
226
+ `accent-surface-pressed`, `accent-surface-highlight`, `scrim`, `hover`,
227
+ `tint`.
228
+
229
+ These names are not only readable. They also help APCA role inference pick the
230
+ right polarity. For example, `button-text` is foreground, `input-bg` is a
231
+ surface, and `card-outline` is a border.
232
+
233
+ ## Build the default theme
234
+
235
+ ### The surface ladder
236
+
237
+ Start with the surface family. It is mostly tone, with small saturation changes
238
+ to keep the ladder visually coherent:
239
+
240
+ ```ts
241
+ const defaultTheme = glaze(PURPLE_HUE, SEED_SATURATION);
242
+
243
+ defaultTheme.colors({
244
+ surface: { tone: 100, saturation: 0.11 },
245
+ 'surface-2': {
246
+ base: 'surface',
247
+ tone: '-2',
248
+ saturation: 0.15,
249
+ inherit: false,
250
+ },
251
+ 'surface-3': {
252
+ base: 'surface',
253
+ tone: '-4',
254
+ saturation: 0.19,
255
+ inherit: false,
256
+ },
257
+ });
258
+ ```
259
+
260
+ <PaletteStep
261
+ step="surfaces"
262
+ schemes={['light', 'dark']}
263
+ caption="The ladder in light and dark. Each step moves away from surface's end of the scale in both schemes. Before any text exists, the UI is a wireframe: the page is surface-2, the cards are surface, and the bars and track are surface-3."
264
+ />
265
+
266
+ Each tone delta is an exact step from `surface` in light; in dark it steps from
267
+ `surface`'s authored tone and is mapped like an authored tone, so the ladder
268
+ keeps its order everywhere and flips with `surface` in dark. The dark window
269
+ compresses the steps a little: `'-2'` and `'-4'` land 1.7 and 3.5 tones from
270
+ `surface` in dark, and high contrast keeps them exact. These small deltas are
271
+ enough to define the ladder; there is no separate dark-mode curve to tune.
272
+
273
+ ### Text and borders
274
+
275
+ Use a hard edge tone for maximum-prominence text, and APCA floors for softer
276
+ content:
277
+
278
+ ```ts
279
+ defaultTheme.colors({
280
+ 'surface-text': {
281
+ base: 'surface',
282
+ tone: 2,
283
+ saturation: 0.475,
284
+ },
285
+ 'surface-text-soft': {
286
+ base: 'surface',
287
+ tone: '-1',
288
+ saturation: 0.375,
289
+ contrast: { apca: ['content', 'body'] },
290
+ inherit: false,
291
+ },
292
+ 'surface-text-soft-2': {
293
+ base: 'surface',
294
+ tone: '-1',
295
+ saturation: 0.24,
296
+ contrast: { apca: ['large', 'content'] },
297
+ inherit: false,
298
+ },
299
+ border: {
300
+ base: 'surface',
301
+ tone: ['-10', '-20'],
302
+ saturation: 0.175,
303
+ inherit: false,
304
+ },
305
+ });
306
+ ```
307
+
308
+ <PaletteStep
309
+ step="text"
310
+ caption="Each foreground on its base, with the APCA Lc it reaches against surface in your scheme. The soft variants land on their floors (Lc 60 and 45, or 75 and 60 in high contrast). The border has no floor; APCA rates very dark pairs at Lc 0, so in dark it reads 0 although it sits about 9 tones off surface (20 in high contrast)."
311
+ />
312
+
313
+ `surface-text` uses an absolute `tone: 2`: it is intentionally edge-anchored,
314
+ and with no `contrast` floor its `base` only records which surface it belongs
315
+ to. The soft variants use a `-1` tone delta as the preferred direction and
316
+ APCA as the readable floor. Their pairs only spell out what high contrast does
317
+ anyway (an APCA floor rises by Lc 15), so a bare `{ apca: 'content' }`
318
+ resolves the same; this guide writes them out to keep the high-contrast target
319
+ visible. `border` needs its tone pair, because tones do not escalate on their
320
+ own and borders usually need a larger visible step in high contrast.
321
+
322
+ Repeat the same pattern for `surface-2` and `surface-3` only if components need
323
+ text directly on those surfaces.
324
+
325
+ ### Neutral utility tokens
326
+
327
+ Keep neutral-only primitives local to the default theme:
328
+
329
+ ```ts
330
+ defaultTheme.colors({
331
+ placeholder: {
332
+ base: 'surface',
333
+ tone: 67,
334
+ saturation: 0.175,
335
+ inherit: false,
336
+ },
337
+ focus: {
338
+ base: 'surface',
339
+ tone: 71,
340
+ saturation: 0.8625,
341
+ inherit: false,
342
+ },
343
+ disabled: {
344
+ tone: 80.8,
345
+ saturation: 0.4,
346
+ inherit: false,
347
+ },
348
+ });
349
+ ```
350
+
351
+ <PaletteStep
352
+ step="utility"
353
+ caption="A search field with its placeholder and focus ring, and an unavailable option drawn in disabled."
354
+ />
355
+
356
+ Absolute tones are fine for primitives whose job is visual placement rather
357
+ than a strict relationship to a specific surface.
358
+
359
+ ## Chips and disabled states
360
+
361
+ For subtle fills, tone is usually clearer than contrast:
362
+
363
+ ```ts
364
+ defaultTheme.colors({
365
+ 'disabled-surface': {
366
+ base: 'surface',
367
+ tone: '-3',
368
+ saturation: 0.2,
369
+ inherit: false,
370
+ },
371
+ 'disabled-surface-text': {
372
+ base: 'disabled-surface',
373
+ tone: '-18',
374
+ saturation: 0.3,
375
+ autoFlip: false,
376
+ inherit: false,
377
+ },
378
+ });
379
+ ```
380
+
381
+ <PaletteStep
382
+ step="chips"
383
+ caption="A disabled chip: a small tone step off surface, with a deliberately faint label 16 to 18 tones from the chip in every scheme. In dark its Lc drops toward 0 for the same reason the border's does."
384
+ />
385
+
386
+ This says exactly what the pair should do: the chip sits a small tone delta off
387
+ `surface`, and the label sits a muted delta from the chip. `autoFlip: false`
388
+ keeps the label on the authored side when the delta reaches the edge. Point the
389
+ delta away from the nearer edge: the chip sits at tone 97 in light, so `'+18'`
390
+ would have only three tones of room, and with `autoFlip: false` it would clamp
391
+ at 100 and the label would all but vanish.
392
+
393
+ Use contrast instead when the chip must hit an explicit accessibility floor:
394
+
395
+ ```ts
396
+ defaultTheme.colors({
397
+ 'disabled-surface-text': {
398
+ base: 'disabled-surface',
399
+ tone: '+1',
400
+ saturation: 0.3,
401
+ contrast: { apca: 'non-text' },
402
+ inherit: false,
403
+ },
404
+ });
405
+ ```
406
+
407
+ <PaletteStep
408
+ step="chips"
409
+ extra="disabled-floor"
410
+ preview={false}
411
+ caption="The same chip with the floor instead: the label reaches the same APCA Lc in light and dark (30, raised automatically to 45 in high contrast). The '+1' preference is too close to meet it, so the solver crosses to the other side of the chip."
412
+ />
413
+
414
+ When a token needs the scheme extreme, use `tone: 'min'` or `tone: 'max'`
415
+ directly. Avoid large magic numbers or fake contrast floors just to push a
416
+ color to the edge.
417
+
418
+ Pair the extreme with a `base` when the gap between the two has to survive the
419
+ scheme switch. Glaze then replays the light-scheme tone shift against the
420
+ resolved dark base instead of re-mapping the extreme through the dark window,
421
+ so a knockout on a surface keeps the same contrast in both schemes.
422
+ [Extremes in dark](./schemes.mdx#extremes-in-dark) shows the replay.
423
+
424
+ ## Fixed surfaces and accent fills
425
+
426
+ Use `mode: 'fixed'` when the authored color should stay recognizable across
427
+ schemes.
428
+
429
+ ```ts
430
+ defaultTheme.colors({
431
+ 'surface-inverse': {
432
+ tone: 12,
433
+ saturation: 0.475,
434
+ mode: 'fixed',
435
+ inherit: false,
436
+ },
437
+
438
+ 'accent-surface-text': {
439
+ tone: 100,
440
+ mode: 'fixed',
441
+ },
442
+ 'accent-surface': {
443
+ base: 'accent-surface-text',
444
+ tone: '-1',
445
+ contrast: { apca: ['content', 'body'] },
446
+ mode: 'fixed',
447
+ },
448
+ 'accent-surface-2': {
449
+ base: 'accent-surface-text',
450
+ tone: '-1',
451
+ contrast: { apca: [65, 80] },
452
+ mode: 'fixed',
453
+ },
454
+ 'accent-surface-hover': {
455
+ base: 'accent-surface-text',
456
+ tone: '-1',
457
+ contrast: { apca: ['body', 'preferred'] },
458
+ mode: 'fixed',
459
+ },
460
+ });
461
+ ```
462
+
463
+ <PaletteStep
464
+ step="fixed"
465
+ schemes={['light', 'dark']}
466
+ caption="Fixed tokens keep their side of the scale: surface-inverse stays dark and the accent fills keep a light label in both schemes. Lc is measured with accent-surface-text as the label on each fill."
467
+ />
468
+
469
+ The accent fill family is a fixed chain against a fixed text anchor. The names
470
+ infer `surface` and `text` roles, so APCA gets the right polarity without extra
471
+ fields.
472
+
473
+ ## Adaptive accent foregrounds
474
+
475
+ Brand foregrounds that sit on neutral surfaces should stay adaptive:
476
+
477
+ ```ts
478
+ defaultTheme.colors({
479
+ 'accent-text': {
480
+ base: 'surface',
481
+ tone: '-1',
482
+ saturation: 1,
483
+ contrast: { apca: ['content', 'body'] },
484
+ },
485
+ 'accent-text-soft': {
486
+ base: 'surface',
487
+ tone: '-1',
488
+ saturation: 1,
489
+ contrast: { apca: ['large', 'content'] },
490
+ },
491
+ 'accent-icon': {
492
+ base: 'surface',
493
+ tone: '-1',
494
+ saturation: 0.9375,
495
+ contrast: { apca: ['non-text', 'large'] },
496
+ },
497
+ });
498
+ ```
499
+
500
+ <PaletteStep
501
+ step="adaptive"
502
+ caption="A link, its softer caption, and an icon, each floored against surface."
503
+ />
504
+
505
+ Anchor these to `surface`, not to `accent-surface`. Their real job is to remain
506
+ readable on neutral UI, so `mode: 'auto'` and a surface base are the right
507
+ defaults.
508
+
509
+ Brand-tinted disabled states can usually be pure tone:
510
+
511
+ ```ts
512
+ defaultTheme.colors({
513
+ 'accent-disabled-surface': {
514
+ base: 'surface',
515
+ tone: '+3',
516
+ saturation: 0.5,
517
+ },
518
+ 'accent-disabled-surface-text': {
519
+ base: 'accent-disabled-surface',
520
+ tone: '-18',
521
+ saturation: 0.4,
522
+ autoFlip: false,
523
+ },
524
+ });
525
+ ```
526
+
527
+ <PaletteStep
528
+ step="accent-disabled"
529
+ caption="The brand-tinted disabled button."
530
+ />
531
+
532
+ On the white default `surface` there is no room for `'+3'`, so `autoFlip`
533
+ (on by default) mirrors the chip just below `surface`; on the tinted status
534
+ surfaces later in this guide it lands above. The label follows the same rule as
535
+ the neutral disabled chip.
536
+
537
+ These are inherited, so status themes automatically get
538
+ `success-accent-disabled-surface`, `danger-accent-disabled-surface`, and the
539
+ matching text tokens.
540
+
541
+ ## Special-purpose colors
542
+
543
+ Use absolute `hue` overrides for tokens that should come from another hue
544
+ family but keep the same adaptation behavior:
545
+
546
+ ```ts
547
+ defaultTheme.colors({
548
+ 'code-comment': {
549
+ base: 'surface',
550
+ hue: 280,
551
+ saturation: 0.1,
552
+ tone: '-1',
553
+ contrast: { apca: ['large', 'content'] },
554
+ inherit: false,
555
+ },
556
+ 'code-keyword': {
557
+ base: 'surface',
558
+ hue: 348,
559
+ saturation: 1,
560
+ tone: '-1',
561
+ contrast: { apca: ['content', 'body'] },
562
+ inherit: false,
563
+ },
564
+ 'code-string': {
565
+ base: 'surface',
566
+ hue: SUCCESS_HUE,
567
+ saturation: 1,
568
+ tone: '-1',
569
+ contrast: { apca: ['large', 'content'] },
570
+ inherit: false,
571
+ },
572
+ });
573
+ ```
574
+
575
+ <PaletteStep
576
+ step="code"
577
+ caption="Syntax colors from three hue families, each floored against surface like any other foreground."
578
+ />
579
+
580
+ Use small tone ramps for decorative motion:
581
+
582
+ ```ts
583
+ defaultTheme.colors({
584
+ 'loading-face-1': {
585
+ base: 'surface',
586
+ tone: 98,
587
+ saturation: 0.3,
588
+ inherit: false,
589
+ },
590
+ 'loading-face-2': {
591
+ base: 'surface',
592
+ tone: 91,
593
+ saturation: 0.62,
594
+ inherit: false,
595
+ },
596
+ 'loading-face-3': {
597
+ base: 'surface',
598
+ tone: 79,
599
+ saturation: 0.66,
600
+ inherit: false,
601
+ },
602
+ });
603
+ ```
604
+
605
+ <PaletteStep
606
+ step="loading"
607
+ caption="A loading skeleton built from the three faces."
608
+ />
609
+
610
+ Absolute tones invert and remap the same way in every scheme, so the ramp keeps
611
+ its order and close to its spacing without involving the contrast solver: the
612
+ tone windows narrow it to about 95% in light and 87% in dark, and high contrast
613
+ keeps it exact. Use a high-contrast tone pair only when the animation should
614
+ become more pronounced in high contrast.
615
+
616
+ ## Effects
617
+
618
+ Shadows and glows are translucent overlays with an exact tone step:
619
+ `intensity` is how many tones darker (`shadow`) or lighter (`glow`) the surface
620
+ ends up once the browser composites it. The opacity is solved per surface, so
621
+ the same number reads the same on white, on a mid-tone accent and on a dark
622
+ surface. Direction is fixed per type: a `shadow` always darkens and a `glow`
623
+ always lightens, in every scheme. [Shadows and glows](./shadows-and-glows.mdx)
624
+ covers the model in depth.
625
+
626
+ ### Shadows
627
+
628
+ Define one neutral shadow system. Point `bg` at the surface the shadow falls on
629
+ and `fg` at the element that casts it — here a `surface` card on the
630
+ `surface-2` page:
631
+
632
+ ```ts
633
+ defaultTheme.colors({
634
+ 'shadow-sm': {
635
+ type: 'shadow',
636
+ bg: 'surface-2',
637
+ fg: 'surface',
638
+ intensity: 4,
639
+ inherit: false,
640
+ },
641
+ 'shadow-md': {
642
+ type: 'shadow',
643
+ bg: 'surface-2',
644
+ fg: 'surface',
645
+ intensity: [8, 14],
646
+ inherit: false,
647
+ },
648
+ 'shadow-lg': {
649
+ type: 'shadow',
650
+ bg: 'surface-2',
651
+ fg: 'surface',
652
+ intensity: [12, 20],
653
+ inherit: false,
654
+ },
655
+ });
656
+ ```
657
+
658
+ <PaletteStep
659
+ step="shadows"
660
+ schemes={['light', 'dark', 'lightContrast', 'darkContrast']}
661
+ preview={false}
662
+ caption="A surface card casting each shadow on surface-2, in all four schemes. The value line is the solved opacity and the tone step the composite actually lands."
663
+ />
664
+
665
+ `fg` modulates the step the way bounce light does: an object at the surface's
666
+ own tone gives exactly `intensity`, a darker object casts a deeper shadow and a
667
+ lighter one a softer shadow. Do not point `fg` at text — it is the object, not
668
+ a contrast reference. Use a high-contrast pair for shadows that should deepen
669
+ in high contrast.
670
+
671
+ <Callout kind="caution" title="Shadows need room below the surface">
672
+ A shadow can only darken as far as black. The page surface here,
673
+ `surface-2`, sits at tone 98 in light but about 11 in dark and 2 in dark
674
+ high contrast, so `shadow-lg` falls just short of its step in dark, and in
675
+ dark high contrast all three pin at full opacity and still land only about 2
676
+ tones down. Glaze warns for each step it cannot reach. Every color resolves
677
+ in all four schemes, so not binding a shadow in dark only keeps the pinned
678
+ value off screen; the warning fires anyway. The fix is a lower `intensity`,
679
+ or a surface with room below it in every scheme. For elevation
680
+ in dark, reach for [glows](./shadows-and-glows.mdx#dark-mode-elevation)
681
+ instead. This palette keeps its near-white page, so these warnings are
682
+ expected.
683
+ </Callout>
684
+
685
+ ### State layers
686
+
687
+ Without `fg`, a shadow or glow is an exact shade or tint — the right tool for
688
+ state layers, which need the same visible change on every fill:
689
+
690
+ ```ts
691
+ defaultTheme.colors({
692
+ 'surface-hover': { type: 'shadow', bg: 'surface', intensity: 3 },
693
+ 'accent-surface-pressed': {
694
+ type: 'shadow',
695
+ bg: 'accent-surface',
696
+ intensity: [6, 10],
697
+ },
698
+ 'accent-surface-highlight': {
699
+ type: 'glow',
700
+ bg: 'accent-surface',
701
+ intensity: 5,
702
+ },
703
+ });
704
+ ```
705
+
706
+ <PaletteStep
707
+ step="state-layers"
708
+ caption="Each layer over the fill it targets, then in the UI: the hovered row and the pressed and highlighted buttons. The cards now carry shadow-md and shadow-sm."
709
+ />
710
+
711
+ A fixed opacity cannot do this: an 8% black layer darkens white by about 6
712
+ tones but a tone-10 surface by about 1
713
+ ([The same step on every surface](./shadows-and-glows.mdx#the-same-step-on-every-surface)
714
+ compares the two). The same limit applies, though: in dark high
715
+ contrast `surface` sits at tone 0, so `surface-hover` has nothing to darken
716
+ there and resolves transparent, with a warning.
717
+
718
+ Glows are the mirror, for dark-mode elevation and edge highlights. A glow needs
719
+ room above its surface, so a glow on a white light-mode surface has nothing to
720
+ lighten — Glaze leaves it transparent and warns, even if you only use the token
721
+ in dark. Give glows a surface with room in every scheme, as
722
+ `accent-surface-highlight` has on the mid-tone accent fill. With `headroom`
723
+ above 0, an [HDR glow](./hdr.mdx) can carry a value past white for platforms
724
+ that can show it, but its standard value on white stays transparent.
725
+
726
+ ### Fixed opacity and mixes
727
+
728
+ Use `opacity` for one fixed-alpha color, such as a modal scrim:
729
+
730
+ ```ts
731
+ defaultTheme.colors({
732
+ scrim: {
733
+ tone: 0,
734
+ saturation: 0,
735
+ mode: 'static',
736
+ opacity: 0.5,
737
+ inherit: false,
738
+ },
739
+ });
740
+ ```
741
+
742
+ <PaletteStep
743
+ step="scrim"
744
+ schemes={['light', 'dark']}
745
+ preview={false}
746
+ caption="The scrim at a fixed 50% over surface: black in light and dark, and in both high-contrast variants."
747
+ />
748
+
749
+ `mode: 'static'` keeps the scrim pure black. Under the default `auto`, tone 0
750
+ would be remapped to tone 4.95 in light and inverted to 95.9 in dark, turning
751
+ the scrim into a light 50% wash there.
752
+
753
+ Use mixes when one color should tint through another:
754
+
755
+ ```ts
756
+ defaultTheme.colors({
757
+ hover: {
758
+ type: 'mix',
759
+ base: 'surface',
760
+ target: 'accent-surface',
761
+ value: 8,
762
+ blend: 'transparent',
763
+ },
764
+ tint: {
765
+ type: 'mix',
766
+ base: 'surface',
767
+ target: 'accent-surface',
768
+ value: 20,
769
+ },
770
+ });
771
+ ```
772
+
773
+ <PaletteStep
774
+ step="mixes"
775
+ caption="A transparent accent hover over surface and a solid 20% tint, then the complete default theme."
776
+ />
777
+
778
+ Transparent mixes are good for tinted hover washes. Opaque mixes are good for
779
+ solid tints. Opaque mixes default to perceptual OKHSL interpolation; set
780
+ `space: 'srgb'` for linear-light channel interpolation. Transparent mixes are
781
+ composited by the browser, and their contrast solve models that compositing.
782
+ Mix colors can also use `contrast`; the solver adjusts the value or opacity to
783
+ meet the floor. If the floor is out of reach, the mix settles at the pure target
784
+ (the pure base when its value is already 100) and Glaze warns, as it does for a
785
+ regular color. See [Mixes and transparency](./mixes.mdx).
786
+
787
+ ## High contrast
788
+
789
+ High contrast is not a separate palette. Contrast floors escalate on their own
790
+ in the high-contrast variants: an APCA floor rises by Lc 15, `'AA'` becomes
791
+ `'AAA'`, and `'AA-large'` becomes `'AAA-large'` (a bare WCAG number stays as
792
+ written). Text, icons, and accent fills tighten with no extra fields;
793
+ [Contrast solving](./contrast.mdx#high-contrast) has the full table.
794
+
795
+ Tones, shadow / glow `intensity`, and mix `value` do not escalate. Give them a
796
+ high-contrast pair where users should get more separation:
797
+
798
+ - Borders: `tone: ['-10', '-20']`.
799
+ - Shadows and glows: `intensity: [8, 14]`.
800
+ - Decorative ramps that must stay perceivable.
801
+
802
+ A contrast pair is only needed for a different high-contrast target than the
803
+ automatic one, such as `{ apca: ['content', 'preferred'] }`.
804
+
805
+ In high-contrast variants, Glaze bypasses the normal tone window and uses the
806
+ full `[0, 100]` range. Edge tones can reach the edge; contrast floors have more
807
+ room to solve.
808
+
809
+ <PalettePreview
810
+ schemes={['light', 'dark', 'lightContrast', 'darkContrast']}
811
+ caption="The finished palette in all four schemes. In high contrast the borders deepen, soft text and accent fills move to their high-contrast floors, and the surfaces reach the ends of the scale."
812
+ />
813
+
814
+ ### A slider through the tier
815
+
816
+ The two-tier model assumes contrast is a binary OS preference. When contrast is
817
+ instead a setting _your users_ control, set
818
+ [`contrastLevel`](./reference/contrast.md#manual-contrast-level) to a number
819
+ and your **normal** palette slides along a `0–100` ramp: level `0` is the
820
+ palette you authored, `100` is its high-contrast variant, and everything
821
+ between is resolved at that level.
822
+
823
+ <PalettePreview
824
+ columns={[
825
+ { label: 'Light · level 0', scheme: 'light', contrastLevel: 0 },
826
+ { label: 'Light · level 50', scheme: 'light', contrastLevel: 50 },
827
+ { label: 'Light · level 100', scheme: 'light', contrastLevel: 100 },
828
+ ]}
829
+ caption="The finished palette's light variant at three levels. No new fields: the slider ramps toward the high-contrast values, escalated floors and pairs alike."
830
+ />
831
+
832
+ The authoring work is the same: the slider ramps toward the values the
833
+ high-contrast tier uses, so a palette tuned for high contrast needs no new
834
+ fields. Two things worth planning for:
835
+
836
+ - The slider and the tier compose. The high-contrast tier keeps resolving at
837
+ full contrast regardless of the level, so wire the level into your build or
838
+ runtime _and_ keep your `prefers-contrast: more` block: a user who has raised
839
+ the slider and asked their OS for more contrast still gets the escalation on
840
+ top. The one exception is a global level of `100`
841
+ (`glaze.configure({ contrastLevel: 100 })`), where the two coincide and the
842
+ tier is dropped as a duplicate. A per-theme level of `100` still emits the
843
+ tier, with values equal to the normal ones.
844
+ - `contrastLevel: 0` is the natural rest position for a slider you ship but
845
+ default off — it reproduces the `'auto'` output bit for bit.
846
+
847
+ [Manual contrast level](./schemes.mdx#manual-contrast-level) shows the level
848
+ as a strip; [Contrast and high contrast](./reference/contrast.md#manual-contrast-level)
849
+ in the API reference has the exact interpolation rules.
850
+
851
+ ## Extend into status themes
852
+
853
+ Once the default theme is shaped, create colored siblings by replacing hue and
854
+ overriding only the root surface that should become visibly tinted:
855
+
856
+ ```ts
857
+ const TINTED_SURFACE_OVERRIDE = {
858
+ surface: { tone: 96, saturation: 0.8 },
859
+ };
860
+
861
+ const primaryTheme = defaultTheme.extend({
862
+ colors: TINTED_SURFACE_OVERRIDE,
863
+ });
864
+ const successTheme = defaultTheme.extend({
865
+ hue: SUCCESS_HUE,
866
+ colors: TINTED_SURFACE_OVERRIDE,
867
+ });
868
+ const dangerTheme = defaultTheme.extend({
869
+ hue: DANGER_HUE,
870
+ colors: TINTED_SURFACE_OVERRIDE,
871
+ });
872
+ const warningTheme = defaultTheme.extend({
873
+ hue: WARNING_HUE,
874
+ colors: TINTED_SURFACE_OVERRIDE,
875
+ });
876
+ const noteTheme = defaultTheme.extend({
877
+ hue: NOTE_HUE,
878
+ colors: TINTED_SURFACE_OVERRIDE,
879
+ });
880
+ ```
881
+
882
+ <StatusThemes caption="Each theme painted only with its own tokens. The token count shows inherit: false at work: the default theme carries the whole palette, each sibling only the inherited subset." />
883
+
884
+ The inherited accent tokens (including `accent-disabled-*`) now resolve in each
885
+ status hue. Tokens marked `inherit: false` stay default-only, so sibling themes
886
+ remain small. [`theme.extend()`](./reference/theme.md#themeextendoptions)
887
+ documents how inherited and overriding definitions merge.
888
+
889
+ <Callout kind="caution" title="A dark seed is inherited too">
890
+ `extend()` keeps the parent's `darkHue` and `darkSaturation` unless you pass
891
+ new ones. If the default theme has the dark seed from
892
+ [When dark needs its own chroma](#when-dark-needs-its-own-chroma), a status
893
+ theme that only sets `hue` turns purple in dark. Pass its own `darkHue` next
894
+ to `hue`, for example `hue: SUCCESS_HUE, darkHue: SUCCESS_HUE - 12`.
895
+ </Callout>
896
+
897
+ ## Export the palette
898
+
899
+ Compose the themes once:
900
+
901
+ ```ts
902
+ const palette = glaze.palette({
903
+ default: defaultTheme,
904
+ primary: primaryTheme,
905
+ success: successTheme,
906
+ danger: dangerTheme,
907
+ warning: warningTheme,
908
+ note: noteTheme,
909
+ });
910
+ ```
911
+
912
+ The usual export shape is default unprefixed and status themes prefixed:
913
+
914
+ ```ts
915
+ const prefix = {
916
+ default: '',
917
+ primary: 'primary-',
918
+ success: 'success-',
919
+ danger: 'danger-',
920
+ warning: 'warning-',
921
+ note: 'note-',
922
+ };
923
+
924
+ palette.tasty({ prefix });
925
+ ```
926
+
927
+ <PaletteOutput />
928
+
929
+ An explicit prefix map is the clearest choice when the palette has a neutral
930
+ `default` theme. The separate palette `primary` option serves another pattern:
931
+ it duplicates one named theme without a prefix while retaining its prefixed
932
+ tokens. Do not combine the two accidentally; choose the token namespace your
933
+ components expect.
934
+
935
+ The palette design is independent of the exporter:
936
+
937
+ ```ts
938
+ palette.tokens({ prefix }); // JavaScript maps, native oklch by default
939
+ palette.css({ prefix }); // CSS custom-property declarations
940
+ palette.dtcg({ prefix }); // one design-token tree per scheme
941
+ palette.tailwind({ prefix }); // Tailwind CSS v4 theme
942
+ ```
943
+
944
+ Use `palette.tasty({ prefix })` for [Tasty](https://tasty.style) state
945
+ bindings. [Choosing an export](./exporting.mdx#choosing-an-export) covers
946
+ output shapes and application wiring, and
947
+ [the `primary` alias](./exporting.mdx#the-primary-alias) the alias pattern;
948
+ every palette option is in the [Palettes](./reference/palette.md) reference.
949
+
950
+ ## Checklist
951
+
952
+ Before shipping a palette, verify:
953
+
954
+ - Text, icon, and content tokens either have APCA/WCAG contrast or are
955
+ deliberately edge-anchored.
956
+ - Accent fills use `mode: 'fixed'`; accent foregrounds on neutral UI stay
957
+ `mode: 'auto'` and are based on `surface`.
958
+ - Ambiguous APCA tokens have an explicit `role`; obvious names rely on
959
+ inference.
960
+ - Low-stakes visual relationships use tone deltas instead of fake contrast
961
+ floors.
962
+ - `inherit: false` is set on default-only tokens so status themes stay
963
+ focused.
964
+ - Tones, shadow / glow intensities, and mix values that should change in high
965
+ contrast have a high-contrast pair. Contrast floors escalate on their own;
966
+ both feed a manual `contrastLevel`.
967
+ - Status themes extended from a theme with a dark seed pass their own
968
+ `darkHue`.
969
+ - `glaze.configure({ states, modes })` matches the states registered in the
970
+ app.
971
+ - Every emitted scheme (`light`, `dark`, `lightContrast`, `darkContrast`) has
972
+ been reviewed on complete screens, not only in a token grid.
973
+ - Rendered WCAG/APCA results have been checked for chromatic foreground/base
974
+ pairs that carry accessibility requirements.
975
+ - Resolution emits no unexplained unreachable-contrast (colors and mixes),
976
+ unreachable shadow / glow, or token-collision warnings.