@tenphi/glaze 0.0.0-snapshot.78261ef → 0.0.0-snapshot.7b8c12f

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,636 @@
1
+ # Methodology
2
+
3
+ A practical way to design a Glaze palette without fighting dark mode. Start from
4
+ tone relationships, add contrast only where a role needs a readable floor, and
5
+ let `extend()` carry the same decisions across status hues.
6
+
7
+ For the full API surface, see [api.md](api.md). For the Glaze color-model
8
+ overview, see [okhst.md](okhst.md); the derivation lives in the
9
+ [canonical OKHST specification](https://github.com/tenphi/okhst).
10
+
11
+ ## The mental model
12
+
13
+ Glaze palettes work best as one default neutral theme plus a few colored sibling
14
+ themes:
15
+
16
+ - `default` owns the neutral system most components consume: `#surface`,
17
+ `#surface-text`, `#border`, `#disabled-surface`, and so on.
18
+ - Status themes (`success`, `danger`, `warning`, `note`, ...) are created with
19
+ `extend()`. They swap the hue and keep only the inherited tokens that should
20
+ become status-aware.
21
+ - `inherit: false` keeps a token local to the parent theme. Use it for neutral
22
+ ladders, shadows, code colors, overlays, and anything that should not be
23
+ repeated for every status hue.
24
+
25
+ The main simplification is OKHST tone. Dark mode is a single inversion
26
+ (`100 - t`) plus a scheme tone window, so a relative **tone delta** stays
27
+ anchored to its base in every scheme. A token authored as `tone: '-4'` remains
28
+ the same kind of visual step in light and dark. The step is exactly
29
+ contrast-even for neutrals and approximate for chromatic colors; add a
30
+ `contrast` floor when the measured result matters.
31
+
32
+ Use `glaze.color()` instead of a theme when you need one standalone color or
33
+ one base/dependent pair and do not need a named palette. The same tone,
34
+ adaptation, and contrast rules apply; see
35
+ [Standalone color tokens](api.md#standalone-color-tokens).
36
+
37
+ ## Implementation workflow
38
+
39
+ Build in dependency order so every decision has a clear base:
40
+
41
+ 1. Configure the output schemes and application states once.
42
+ 2. Choose the default theme's hue and saturation seed.
43
+ 3. Define root surfaces with absolute tones.
44
+ 4. Add dependent surfaces, text, borders, and icons with tone deltas.
45
+ 5. Add contrast floors only to roles that need measured readability or
46
+ recognizability.
47
+ 6. Choose adaptation per color: `auto`, `fixed`, or `static`.
48
+ 7. Add explicit HC pairs where high contrast should increase separation.
49
+ 8. Mark default-only tokens `inherit: false`, then extend the shared
50
+ definitions into status themes.
51
+ 9. Compose and export the palette in the shape your application consumes.
52
+ 10. Verify complete screens in all emitted scheme variants.
53
+
54
+ The sections below follow this order and build one palette incrementally.
55
+
56
+ ## Authoring decisions
57
+
58
+ Use this order when defining a token:
59
+
60
+ 1. Pick the base it visually belongs to.
61
+ 2. Use an absolute numeric `tone` for independent placement. Use a signed
62
+ **tone delta** (`'+N'` / `'-N'`) for distance from a base: surface ladders,
63
+ soft chips, disabled states, hover ramps, and similar relationships.
64
+ 3. Add `contrast` only when readability or recognizability needs a measured
65
+ floor.
66
+ 4. Prefer APCA presets for content-like colors when perceptual readability is
67
+ the design goal:
68
+ `contrast: { apca: 'content' }` or
69
+ `contrast: { apca: ['content', 'body'] }`.
70
+ 5. Use WCAG numbers or presets when compatibility, policy, or migration
71
+ requires a WCAG ratio:
72
+ `contrast: 4.5`, `contrast: 'AAA'`, or `contrast: { wcag: [4.5, 7] }`.
73
+ 6. Let token names infer APCA roles. Names ending in `text`, `label`, `border`,
74
+ `surface`, `fill`, `bg`, and similar aliases already tell Glaze which side is
75
+ foreground or background. Set `role` only when a name is ambiguous.
76
+ 7. Add high-contrast pairs only where HC should intentionally tighten:
77
+ text/content contrast, border tone, shadow intensity, mix value, or similar.
78
+
79
+ ### Choosing adaptation mode
80
+
81
+ | Mode | Choose it when |
82
+ | ---------------- | --------------------------------------------------------------------------------------------------------------------------- |
83
+ | `auto` (default) | The color should exchange light/dark positions through dark tone inversion. Typical for surfaces, text, borders, and icons. |
84
+ | `fixed` | A brand fill, status banner, or inverse surface should stay on the authored side of the tone scale. |
85
+ | `static` | The exact authored tone and saturation must render in every scheme, without tone-window mapping or dark desaturation. |
86
+
87
+ Dark tone inversion is controlled by `mode`; it is unrelated to `autoFlip`.
88
+ `autoFlip` only allows an overshooting tone delta or an unsuccessful contrast
89
+ direction to reverse around its base.
90
+
91
+ ### Root or dependent?
92
+
93
+ - Use a root color when its tone has meaning on its own: the page surface, a
94
+ fixed brand anchor, or a scheme extreme.
95
+ - Use a dependent color when its purpose exists relative to another token:
96
+ text on a surface, a border around a fill, or a tint of an accent.
97
+ - Setting `base` with an absolute tone is valid when the color needs a contrast
98
+ relationship but not a tone delta. The absolute position is resolved
99
+ independently; `contrast` acts as a safety floor.
100
+
101
+ ## Seed and configure
102
+
103
+ Keep hue decisions named and configure output modes once:
104
+
105
+ ```ts
106
+ import { glaze } from '@tenphi/glaze';
107
+
108
+ const PURPLE_HUE = 280.3;
109
+ const SUCCESS_HUE = 156.9;
110
+ const DANGER_HUE = 23.1;
111
+ const WARNING_HUE = 84.3;
112
+ const NOTE_HUE = 302.3;
113
+
114
+ const SEED_SATURATION = 80;
115
+
116
+ glaze.configure({
117
+ states: { dark: '@dark', highContrast: '@hc' },
118
+ modes: { dark: true, highContrast: true },
119
+ });
120
+ ```
121
+
122
+ Per-color `saturation` is a factor of the theme seed, not an absolute
123
+ saturation. With `SEED_SATURATION = 80`, `saturation: 0.25` means one quarter of
124
+ that seed.
125
+
126
+ ## Naming
127
+
128
+ Prefer purpose first and variant last:
129
+
130
+ - Surfaces: `surface`, `surface-2`, `surface-3`.
131
+ - Foregrounds: `surface-text`, `surface-text-soft`, `surface-text-soft-2`.
132
+ - Structure: `border`, `divider`, `outline`, `placeholder`, `focus`.
133
+ - Fills: `accent-surface`, `accent-surface-2`,
134
+ `accent-surface-hover`.
135
+ - Foregrounds on neutral surfaces: `accent-text`, `accent-text-soft`,
136
+ `accent-icon`.
137
+ - Disabled states: `disabled-surface`, `disabled-surface-text`,
138
+ `accent-disabled-surface`, `accent-disabled-surface-text`.
139
+ - Effects: `shadow-sm`, `shadow-md`, `shadow-lg`, `overlay`, `hover`, `tint`.
140
+
141
+ These names are not only readable. They also help APCA role inference pick the
142
+ right polarity. For example, `button-text` is foreground, `input-bg` is a
143
+ surface, and `card-outline` is a border.
144
+
145
+ ## Build the default theme
146
+
147
+ Start with the surface family. It is mostly tone, with small saturation changes
148
+ to keep the ladder visually coherent:
149
+
150
+ ```ts
151
+ const defaultTheme = glaze(PURPLE_HUE, SEED_SATURATION);
152
+
153
+ defaultTheme.colors({
154
+ surface: { tone: 100, saturation: 0.11 },
155
+ 'surface-2': {
156
+ base: 'surface',
157
+ tone: '-2',
158
+ saturation: 0.15,
159
+ inherit: false,
160
+ },
161
+ 'surface-3': {
162
+ base: 'surface',
163
+ tone: '-4',
164
+ saturation: 0.19,
165
+ inherit: false,
166
+ },
167
+ });
168
+ ```
169
+
170
+ Because each tone delta re-anchors to the resolved surface in every scheme,
171
+ these small relative offsets are enough to define the ladder. There is no
172
+ separate dark-mode curve to tune.
173
+
174
+ ### Text and borders
175
+
176
+ Use a hard edge tone for maximum-prominence text, and APCA floors for softer
177
+ content:
178
+
179
+ ```ts
180
+ defaultTheme.colors({
181
+ 'surface-text': {
182
+ base: 'surface',
183
+ tone: 2,
184
+ saturation: 0.475,
185
+ },
186
+ 'surface-text-soft': {
187
+ base: 'surface',
188
+ tone: '-1',
189
+ saturation: 0.375,
190
+ contrast: { apca: ['content', 'body'] },
191
+ inherit: false,
192
+ },
193
+ 'surface-text-soft-2': {
194
+ base: 'surface',
195
+ tone: '-1',
196
+ saturation: 0.24,
197
+ contrast: { apca: ['large', 'content'] },
198
+ inherit: false,
199
+ },
200
+ border: {
201
+ base: 'surface',
202
+ tone: ['-10', '-20'],
203
+ saturation: 0.175,
204
+ inherit: false,
205
+ },
206
+ });
207
+ ```
208
+
209
+ `surface-text` uses an absolute `tone: 2` despite having a base: it is
210
+ intentionally edge-anchored, and the base records the relationship used by
211
+ role inference. The soft variants use a `-1` tone delta as the preferred
212
+ direction and APCA as the readable floor. `border` uses an HC tone-delta pair
213
+ because borders usually need a larger visible step in high contrast.
214
+
215
+ Repeat the same pattern for `surface-2` and `surface-3` only if components need
216
+ text directly on those surfaces.
217
+
218
+ ### Neutral utility tokens
219
+
220
+ Keep neutral-only primitives local to the default theme:
221
+
222
+ ```ts
223
+ defaultTheme.colors({
224
+ placeholder: {
225
+ base: 'surface',
226
+ tone: 67,
227
+ saturation: 0.175,
228
+ inherit: false,
229
+ },
230
+ focus: {
231
+ base: 'surface',
232
+ tone: 71,
233
+ saturation: 0.8625,
234
+ inherit: false,
235
+ },
236
+ disabled: {
237
+ tone: 80.8,
238
+ saturation: 0.4,
239
+ inherit: false,
240
+ },
241
+ });
242
+ ```
243
+
244
+ Absolute tones are fine for primitives whose job is visual placement rather than
245
+ a strict relationship to a specific surface.
246
+
247
+ ## Chips and disabled states
248
+
249
+ For subtle fills, tone is usually clearer than contrast:
250
+
251
+ ```ts
252
+ defaultTheme.colors({
253
+ 'disabled-surface': {
254
+ base: 'surface',
255
+ tone: '-3',
256
+ saturation: 0.2,
257
+ inherit: false,
258
+ },
259
+ 'disabled-surface-text': {
260
+ base: 'disabled-surface',
261
+ tone: '+18',
262
+ saturation: 0.3,
263
+ autoFlip: false,
264
+ inherit: false,
265
+ },
266
+ });
267
+ ```
268
+
269
+ This says exactly what the pair should do: the chip sits a small tone delta off
270
+ the page, and the label sits a muted delta from the chip. `autoFlip: false`
271
+ keeps the label on the authored side when the delta reaches the edge.
272
+
273
+ Use contrast instead when the chip must hit an explicit accessibility floor:
274
+
275
+ ```ts
276
+ defaultTheme.colors({
277
+ 'disabled-surface-text': {
278
+ base: 'disabled-surface',
279
+ tone: '+1',
280
+ saturation: 0.3,
281
+ contrast: { apca: 'non-text' },
282
+ inherit: false,
283
+ },
284
+ });
285
+ ```
286
+
287
+ When a token needs the scheme extreme, use `tone: 'min'` or `tone: 'max'`
288
+ directly. Avoid large magic numbers or fake contrast floors just to push a color
289
+ to the edge.
290
+
291
+ ## Fixed surfaces and accent fills
292
+
293
+ Use `mode: 'fixed'` when the authored color should stay recognizable across
294
+ schemes.
295
+
296
+ ```ts
297
+ defaultTheme.colors({
298
+ 'surface-inverse': {
299
+ tone: 12,
300
+ saturation: 0.475,
301
+ mode: 'fixed',
302
+ inherit: false,
303
+ },
304
+
305
+ 'accent-surface-text': {
306
+ tone: 100,
307
+ mode: 'fixed',
308
+ },
309
+ 'accent-surface': {
310
+ base: 'accent-surface-text',
311
+ tone: '-1',
312
+ contrast: { apca: ['content', 'body'] },
313
+ mode: 'fixed',
314
+ },
315
+ 'accent-surface-2': {
316
+ base: 'accent-surface-text',
317
+ tone: '-1',
318
+ contrast: { apca: [65, 80] },
319
+ mode: 'fixed',
320
+ },
321
+ 'accent-surface-hover': {
322
+ base: 'accent-surface-text',
323
+ tone: '-1',
324
+ contrast: { apca: ['body', 'preferred'] },
325
+ mode: 'fixed',
326
+ },
327
+ });
328
+ ```
329
+
330
+ The accent fill family is a fixed chain against a fixed text anchor. The names
331
+ infer `surface` and `text` roles, so APCA gets the right polarity without extra
332
+ fields.
333
+
334
+ ## Adaptive accent foregrounds
335
+
336
+ Brand foregrounds that sit on neutral surfaces should stay adaptive:
337
+
338
+ ```ts
339
+ defaultTheme.colors({
340
+ 'accent-text': {
341
+ base: 'surface',
342
+ tone: '-1',
343
+ saturation: 1,
344
+ contrast: { apca: ['content', 'body'] },
345
+ },
346
+ 'accent-text-soft': {
347
+ base: 'surface',
348
+ tone: '-1',
349
+ saturation: 1,
350
+ contrast: { apca: ['large', 'content'] },
351
+ },
352
+ 'accent-icon': {
353
+ base: 'surface',
354
+ tone: '-1',
355
+ saturation: 0.9375,
356
+ contrast: { apca: ['non-text', 'large'] },
357
+ },
358
+ });
359
+ ```
360
+
361
+ Anchor these to `surface`, not to `accent-surface`. Their real job is to remain
362
+ readable on neutral UI, so `mode: 'auto'` and a surface base are the right
363
+ defaults.
364
+
365
+ Brand-tinted disabled states can usually be pure tone:
366
+
367
+ ```ts
368
+ defaultTheme.colors({
369
+ 'accent-disabled-surface': {
370
+ base: 'surface',
371
+ tone: '+3',
372
+ saturation: 0.5,
373
+ },
374
+ 'accent-disabled-surface-text': {
375
+ base: 'accent-disabled-surface',
376
+ tone: '+18',
377
+ saturation: 0.4,
378
+ autoFlip: false,
379
+ },
380
+ });
381
+ ```
382
+
383
+ These are inherited, so status themes automatically get
384
+ `success-accent-disabled-surface`, `danger-accent-disabled-surface`, and the
385
+ matching text tokens.
386
+
387
+ ## Special-purpose colors
388
+
389
+ Use absolute `hue` overrides for tokens that should come from another hue family
390
+ but keep the same adaptation behavior:
391
+
392
+ ```ts
393
+ defaultTheme.colors({
394
+ 'code-comment': {
395
+ base: 'surface',
396
+ hue: 280,
397
+ saturation: 0.1,
398
+ tone: '-1',
399
+ contrast: { apca: ['large', 'content'] },
400
+ inherit: false,
401
+ },
402
+ 'code-keyword': {
403
+ base: 'surface',
404
+ hue: 348,
405
+ saturation: 1,
406
+ tone: '-1',
407
+ contrast: { apca: ['content', 'body'] },
408
+ inherit: false,
409
+ },
410
+ 'code-string': {
411
+ base: 'surface',
412
+ hue: SUCCESS_HUE,
413
+ saturation: 1,
414
+ tone: '-1',
415
+ contrast: { apca: ['large', 'content'] },
416
+ inherit: false,
417
+ },
418
+ });
419
+ ```
420
+
421
+ Use small tone ramps for decorative motion:
422
+
423
+ ```ts
424
+ defaultTheme.colors({
425
+ 'loading-face-1': {
426
+ base: 'surface',
427
+ tone: 98,
428
+ saturation: 0.3,
429
+ inherit: false,
430
+ },
431
+ 'loading-face-2': {
432
+ base: 'surface',
433
+ tone: 91,
434
+ saturation: 0.62,
435
+ inherit: false,
436
+ },
437
+ 'loading-face-3': {
438
+ base: 'surface',
439
+ tone: 79,
440
+ saturation: 0.66,
441
+ inherit: false,
442
+ },
443
+ });
444
+ ```
445
+
446
+ Since tone steps now invert consistently across schemes, the same ramp keeps its
447
+ spacing in light and dark without involving the contrast solver. Use an HC tone
448
+ pair only when the animation should become more pronounced in high contrast.
449
+
450
+ ## Effects
451
+
452
+ Define one neutral shadow system:
453
+
454
+ ```ts
455
+ defaultTheme.colors({
456
+ 'shadow-sm': {
457
+ type: 'shadow',
458
+ bg: 'surface',
459
+ fg: 'surface-text',
460
+ intensity: 5,
461
+ inherit: false,
462
+ },
463
+ 'shadow-md': {
464
+ type: 'shadow',
465
+ bg: 'surface',
466
+ fg: 'surface-text',
467
+ intensity: [10, 20],
468
+ inherit: false,
469
+ },
470
+ 'shadow-lg': {
471
+ type: 'shadow',
472
+ bg: 'surface',
473
+ fg: 'surface-text',
474
+ intensity: [15, 30],
475
+ inherit: false,
476
+ },
477
+ });
478
+ ```
479
+
480
+ Including `fg` lets shadow strength follow the resolved foreground/background
481
+ gap. Use an HC pair for shadows that should deepen in high contrast.
482
+
483
+ Use `opacity` for one fixed-alpha color:
484
+
485
+ ```ts
486
+ defaultTheme.colors({
487
+ overlay: { tone: 10, opacity: 0.5, inherit: false },
488
+ });
489
+ ```
490
+
491
+ Use mixes when one color should tint through another:
492
+
493
+ ```ts
494
+ defaultTheme.colors({
495
+ hover: {
496
+ type: 'mix',
497
+ base: 'surface',
498
+ target: 'accent-surface',
499
+ value: 8,
500
+ blend: 'transparent',
501
+ },
502
+ tint: {
503
+ type: 'mix',
504
+ base: 'surface',
505
+ target: 'accent-surface',
506
+ value: 20,
507
+ },
508
+ });
509
+ ```
510
+
511
+ Transparent mixes are good for hover overlays. Opaque mixes are good for solid
512
+ tints. Opaque mixes default to perceptual OKHSL interpolation; choose `srgb`
513
+ when matching channel compositing matters. Transparent mixes always composite
514
+ in linear sRGB. Mix colors can also use `contrast`; the solver adjusts the
515
+ value or opacity to hit the floor. See [Mix colors](api.md#mix-colors).
516
+
517
+ ## Extend into status themes
518
+
519
+ Once the default theme is shaped, create colored siblings by replacing hue and
520
+ overriding only the root surface that should become visibly tinted:
521
+
522
+ ```ts
523
+ const TINTED_SURFACE_OVERRIDE = {
524
+ surface: { tone: 96, saturation: 0.8 },
525
+ };
526
+
527
+ const primaryTheme = defaultTheme.extend({
528
+ colors: TINTED_SURFACE_OVERRIDE,
529
+ });
530
+ const successTheme = defaultTheme.extend({
531
+ hue: SUCCESS_HUE,
532
+ colors: TINTED_SURFACE_OVERRIDE,
533
+ });
534
+ const dangerTheme = defaultTheme.extend({
535
+ hue: DANGER_HUE,
536
+ colors: TINTED_SURFACE_OVERRIDE,
537
+ });
538
+ const warningTheme = defaultTheme.extend({
539
+ hue: WARNING_HUE,
540
+ colors: TINTED_SURFACE_OVERRIDE,
541
+ });
542
+ const noteTheme = defaultTheme.extend({
543
+ hue: NOTE_HUE,
544
+ colors: TINTED_SURFACE_OVERRIDE,
545
+ });
546
+ ```
547
+
548
+ The inherited accent and disabled tokens now resolve in each status hue. Tokens
549
+ marked `inherit: false` stay default-only, so sibling themes remain small.
550
+
551
+ ## Export the palette
552
+
553
+ Compose the themes once:
554
+
555
+ ```ts
556
+ const palette = glaze.palette({
557
+ default: defaultTheme,
558
+ primary: primaryTheme,
559
+ success: successTheme,
560
+ danger: dangerTheme,
561
+ warning: warningTheme,
562
+ note: noteTheme,
563
+ });
564
+ ```
565
+
566
+ The usual export shape is default unprefixed and status themes prefixed:
567
+
568
+ ```ts
569
+ const prefix = {
570
+ default: '',
571
+ primary: 'primary-',
572
+ success: 'success-',
573
+ danger: 'danger-',
574
+ warning: 'warning-',
575
+ note: 'note-',
576
+ };
577
+
578
+ palette.tasty({ prefix });
579
+ ```
580
+
581
+ An explicit prefix map is the clearest choice when the palette has a neutral
582
+ `default` theme. The separate palette `primary` option serves another pattern:
583
+ it duplicates one named theme without a prefix while retaining its prefixed
584
+ tokens. Do not combine the two accidentally; choose the token namespace your
585
+ components expect.
586
+
587
+ The palette design is independent of the exporter:
588
+
589
+ ```ts
590
+ palette.tokens({ prefix }); // JavaScript maps, native oklch by default
591
+ palette.css({ prefix }); // CSS custom-property declarations
592
+ palette.dtcg({ prefix }); // one design-token tree per scheme
593
+ palette.tailwind({ prefix }); // Tailwind CSS v4 theme
594
+ ```
595
+
596
+ Use `palette.tasty({ prefix })` for [Tasty](https://tasty.style) state bindings. See
597
+ [migration.md](migration.md#choosing-an-export) for output shapes, application
598
+ wiring, and the `primary` alias pattern.
599
+
600
+ ## High contrast
601
+
602
+ High contrast is not a separate palette. Any value that accepts an HC pair can
603
+ tighten the HC variant: `tone`, `contrast`, shadow `intensity`, and mix `value`.
604
+
605
+ Use HC pairs where users should actually get more separation:
606
+
607
+ - Text/content contrast: `{ apca: ['content', 'body'] }`.
608
+ - Accent fills: `{ apca: ['content', 'body'] }` or stronger.
609
+ - Borders: `tone: ['-10', '-20']`.
610
+ - Shadows: `intensity: [10, 20]`.
611
+ - Decorative ramps that must stay perceivable.
612
+
613
+ In HC variants, Glaze bypasses the normal tone window and uses the full
614
+ `[0, 100]` range. Edge tones can reach the edge; contrast floors have more room
615
+ to solve.
616
+
617
+ ## Checklist
618
+
619
+ Before shipping a palette, verify:
620
+
621
+ - Text, icon, and content tokens either have APCA/WCAG contrast or are
622
+ deliberately edge-anchored.
623
+ - Accent fills use `mode: 'fixed'`; accent foregrounds on neutral UI stay
624
+ `mode: 'auto'` and are based on `surface`.
625
+ - Ambiguous APCA tokens have an explicit `role`; obvious names rely on inference.
626
+ - Low-stakes visual relationships use tone deltas instead of fake contrast
627
+ floors.
628
+ - `inherit: false` is set on default-only tokens so status themes stay focused.
629
+ - HC pairs exist where high contrast should visibly tighten.
630
+ - `glaze.configure({ states, modes })` matches the states registered in the app.
631
+ - Every emitted scheme (`light`, `dark`, `lightContrast`, `darkContrast`) has
632
+ been reviewed on complete screens, not only in a token grid.
633
+ - Rendered WCAG/APCA results have been checked for chromatic foreground/base
634
+ pairs that carry accessibility requirements.
635
+ - Resolution emits no unexplained unreachable-contrast or token-collision
636
+ warnings.