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