@tenphi/glaze 0.12.0 → 0.14.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.
- package/README.md +12 -10
- package/dist/index.cjs +980 -574
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +323 -193
- package/dist/index.d.mts +323 -193
- package/dist/index.mjs +970 -574
- package/dist/index.mjs.map +1 -1
- package/docs/api.md +300 -165
- package/docs/methodology.md +64 -54
- package/docs/migration.md +81 -10
- package/docs/okhst.md +224 -0
- package/package.json +1 -1
package/docs/methodology.md
CHANGED
|
@@ -65,58 +65,58 @@ A tight, predictable vocabulary that the rest of the doc relies on:
|
|
|
65
65
|
|
|
66
66
|
## Surfaces (root colors)
|
|
67
67
|
|
|
68
|
-
`surface` is a root color (absolute `
|
|
68
|
+
`surface` is a root color (absolute `tone`, no `base`) with a low saturation factor. The ladder chains off it via small relative offsets:
|
|
69
69
|
|
|
70
70
|
```ts
|
|
71
71
|
defaultTheme.colors({
|
|
72
|
-
surface: {
|
|
73
|
-
'surface-2': { base: 'surface',
|
|
74
|
-
'surface-3': { base: 'surface',
|
|
72
|
+
surface: { tone: 100, saturation: 0.11 },
|
|
73
|
+
'surface-2': { base: 'surface', tone: '-2', saturation: 0.15, inherit: false },
|
|
74
|
+
'surface-3': { base: 'surface', tone: '-4', saturation: 0.19, inherit: false },
|
|
75
75
|
});
|
|
76
76
|
```
|
|
77
77
|
|
|
78
|
-
A factor of `0.11` of the seed gives a barely-noticeable hue shift — enough that light/dark surfaces feel branded, not enough to look tinted. The slight saturation bump on `-2` / `-3` compensates for perceived saturation dropping as
|
|
78
|
+
A factor of `0.11` of the seed gives a barely-noticeable hue shift — enough that light/dark surfaces feel branded, not enough to look tinted. The slight saturation bump on `-2` / `-3` compensates for perceived saturation dropping as tone drops, so the ladder reads as one consistent surface family.
|
|
79
79
|
|
|
80
|
-
`mode: 'auto'` (the default)
|
|
80
|
+
`mode: 'auto'` (the default) inverts these in tone (`100 − t`) and remaps into the dark window, so a `tone: 100` light-mode surface lands at the dark window's floor in dark mode. Because tone is contrast-uniform, the relative deltas (`-2`, `-4`) translate to the same contrast steps in both schemes — no fitted curve required. `inherit: false` on `-2` / `-3` keeps colored sibling themes lean — they only need a single tinted `surface`, not the whole ladder.
|
|
81
81
|
|
|
82
82
|
## Text on surfaces (anchor at the edge)
|
|
83
83
|
|
|
84
|
-
The headline trick of the whole methodology. Strong text uses an **absolute `
|
|
84
|
+
The headline trick of the whole methodology. Strong text uses an **absolute `tone` near the edge of the window**; soft variants use a **directional relative hint plus a numeric `contrast`**.
|
|
85
85
|
|
|
86
86
|
```ts
|
|
87
87
|
'surface-text': {
|
|
88
|
-
base: 'surface',
|
|
88
|
+
base: 'surface', tone: 2, saturation: 0.475,
|
|
89
89
|
},
|
|
90
90
|
'surface-text-soft': {
|
|
91
|
-
base: 'surface',
|
|
91
|
+
base: 'surface', tone: '-1', saturation: 0.375,
|
|
92
92
|
contrast: [9, 11], inherit: false,
|
|
93
93
|
},
|
|
94
94
|
'surface-text-soft-2': {
|
|
95
|
-
base: 'surface',
|
|
95
|
+
base: 'surface', tone: '-1', saturation: 0.24,
|
|
96
96
|
contrast: [4.5, 5.5], inherit: false,
|
|
97
97
|
},
|
|
98
98
|
```
|
|
99
99
|
|
|
100
100
|
Repeat the same triple anchored to each subordinate surface (`surface-2-text`, `surface-2-text-soft`, `surface-3-text`, …) so the ladder stays self-consistent.
|
|
101
101
|
|
|
102
|
-
The strong-text `
|
|
102
|
+
The strong-text `tone: 2` pins the light-mode resolved value near the bottom of the light window (close to black against the near-white surface) and inverts to near-white in dark mode — both yielding very high contrast against their surface. A `contrast: 'AAA'` solver pass would have stopped at the AAA floor (cr = 7) and no further. **Anchoring at the edge** beats the contrast solver because the solver only needs to *meet* the floor, not exceed it.
|
|
103
103
|
|
|
104
|
-
The soft variants use `
|
|
104
|
+
The soft variants use `tone: '-1'` only as a *directional hint* — the real positioning comes from the numeric `contrast`. Numeric ratios give designers precise perceived weight where presets would only guarantee the AA/AAA floor.
|
|
105
105
|
|
|
106
|
-
In high-contrast mode the
|
|
106
|
+
In high-contrast mode the tone window is bypassed entirely (full `[0, 100]` range), so `tone: 2` reaches close to black in light HC and close to white in dark HC — maximal contrast against the surface in both.
|
|
107
107
|
|
|
108
108
|
## Other neutral primitives
|
|
109
109
|
|
|
110
|
-
Borders, placeholders, focus rings, and the floating "muted text"
|
|
110
|
+
Borders, placeholders, focus rings, and the floating "muted text" tone — all default-only:
|
|
111
111
|
|
|
112
112
|
```ts
|
|
113
|
-
border: { base: 'surface',
|
|
114
|
-
placeholder: { base: 'surface',
|
|
115
|
-
focus: { base: 'surface',
|
|
116
|
-
disabled: {
|
|
113
|
+
border: { base: 'surface', tone: ['-10', '-20'], saturation: 0.175, inherit: false },
|
|
114
|
+
placeholder: { base: 'surface', tone: 67, saturation: 0.175, inherit: false },
|
|
115
|
+
focus: { base: 'surface', tone: 71, saturation: 0.8625, inherit: false },
|
|
116
|
+
disabled: { tone: 80.8, saturation: 0.4, inherit: false },
|
|
117
117
|
```
|
|
118
118
|
|
|
119
|
-
`border` uses an HC pair — the border darkens twice as much in high-contrast mode for visibility. `placeholder` and `focus` give a `base` for namespacing but use absolute
|
|
119
|
+
`border` uses an HC pair — the border darkens twice as much in high-contrast mode for visibility. `placeholder` and `focus` give a `base` for namespacing but use absolute tone independently. `disabled` is a root color (no `base`) — it's used as a plain "muted text" token in some places, free of the surface chain.
|
|
120
120
|
|
|
121
121
|
## Disabled chip (contrast-driven for scheme symmetry)
|
|
122
122
|
|
|
@@ -124,11 +124,11 @@ The disabled chip + label pair uses `mode: 'auto'` and **explicit numeric contra
|
|
|
124
124
|
|
|
125
125
|
```ts
|
|
126
126
|
'disabled-surface': {
|
|
127
|
-
base: 'surface',
|
|
127
|
+
base: 'surface', tone: '-1', saturation: 0.2,
|
|
128
128
|
contrast: [1.5, 2], inherit: false,
|
|
129
129
|
},
|
|
130
130
|
'disabled-surface-text': {
|
|
131
|
-
base: 'disabled-surface',
|
|
131
|
+
base: 'disabled-surface', tone: '+1', saturation: 0.3,
|
|
132
132
|
contrast: 3, inherit: false,
|
|
133
133
|
},
|
|
134
134
|
```
|
|
@@ -141,11 +141,11 @@ The general rule: when a color needs to *feel the same across schemes*, anchor i
|
|
|
141
141
|
|
|
142
142
|
```ts
|
|
143
143
|
'surface-inverse': {
|
|
144
|
-
|
|
144
|
+
tone: 12, saturation: 0.475, mode: 'fixed', inherit: false,
|
|
145
145
|
},
|
|
146
146
|
```
|
|
147
147
|
|
|
148
|
-
`mode: 'fixed'` skips the dark-scheme
|
|
148
|
+
`mode: 'fixed'` skips the dark-scheme tone inversion and only remaps the tone into the dark window, so `surface-inverse` reads as a dark surface in *every* scheme — light, dark, and HC. In high-contrast variants the window is bypassed entirely (identity), so the color stays at its raw tone across all four schemes.
|
|
149
149
|
|
|
150
150
|
Use it for tooltips, code blocks, popovers with their own dark theme. Pair with `#white` for foreground text.
|
|
151
151
|
|
|
@@ -153,21 +153,21 @@ This is the canonical "I want this color to stay recognizable" pattern. The othe
|
|
|
153
153
|
|
|
154
154
|
## Accent system (anchor pattern)
|
|
155
155
|
|
|
156
|
-
The load-bearing trick. Define a single fixed white anchor `accent-surface-text`, then derive every accent surface from it with a small relative
|
|
156
|
+
The load-bearing trick. Define a single fixed white anchor `accent-surface-text`, then derive every accent surface from it with a small relative tone offset and a numeric contrast under `mode: 'fixed'`:
|
|
157
157
|
|
|
158
158
|
```ts
|
|
159
|
-
'accent-surface-text': {
|
|
159
|
+
'accent-surface-text': { tone: 100, mode: 'fixed' },
|
|
160
160
|
|
|
161
|
-
'accent-surface': { base: 'accent-surface-text',
|
|
162
|
-
'accent-surface-2': { base: 'accent-surface-text',
|
|
163
|
-
'accent-surface-3': { base: 'accent-surface-text',
|
|
164
|
-
'accent-surface-hover': { base: 'accent-surface-text',
|
|
161
|
+
'accent-surface': { base: 'accent-surface-text', tone: '-1', contrast: [4.5, 7], mode: 'fixed' },
|
|
162
|
+
'accent-surface-2': { base: 'accent-surface-text', tone: '-1', contrast: [4.8, 7.5], mode: 'fixed' },
|
|
163
|
+
'accent-surface-3': { base: 'accent-surface-text', tone: '-1', contrast: [5.2, 8], mode: 'fixed' },
|
|
164
|
+
'accent-surface-hover': { base: 'accent-surface-text', tone: '-1', contrast: [6, 8.5], mode: 'fixed' },
|
|
165
165
|
```
|
|
166
166
|
|
|
167
167
|
Three things make this work:
|
|
168
168
|
|
|
169
169
|
- **One anchor, one chain.** All accent surfaces stay in the same hue family because they all derive from `accent-surface-text`.
|
|
170
|
-
- **`mode: 'fixed'` keeps the brand recognizable.** Without it, the dark-scheme
|
|
170
|
+
- **`mode: 'fixed'` keeps the brand recognizable.** Without it, the dark-scheme tone inversion would turn the brand fill into a tone-inverted counterpart that may no longer read as the intended brand surface. Fixed remaps the tone into the dark window without inverting, so a `tone: 52` brand color stays mid-toned in dark mode — still recognizably the same color.
|
|
171
171
|
- **Numeric contrasts, not presets.** `'AA'` / `'AAA'` would let the solver push the color far away from its anchor in dark schemes, breaking the relationship between `accent-surface` and its neighbors. Numeric ratios make the darkening between `accent-surface` (4.5/7), `-2` (4.8/7.5), `-3` (5.2/8), and `-hover` (6/8.5) a tight, designed sequence — a stepped gradient rather than four solver-generated outliers.
|
|
172
172
|
|
|
173
173
|
The hover variant is a dedicated *fixed* token. Reusing `accent-text` (which is `mode: 'auto'` and inverts direction in dark) would break the hover feel.
|
|
@@ -177,31 +177,41 @@ The hover variant is a dedicated *fixed* token. Reusing `accent-text` (which is
|
|
|
177
177
|
The opposite of the fills. Brand-colored *foregrounds* are anchored to **`surface`, not `accent-surface`**, with `mode: 'auto'` (default) and full saturation:
|
|
178
178
|
|
|
179
179
|
```ts
|
|
180
|
-
'accent-text': { base: 'surface',
|
|
181
|
-
'accent-text-soft': { base: 'surface',
|
|
182
|
-
'accent-icon': { base: 'surface',
|
|
180
|
+
'accent-text': { base: 'surface', tone: '-1', saturation: 1, contrast: [6.4, 10] },
|
|
181
|
+
'accent-text-soft': { base: 'surface', tone: '-1', saturation: 1, contrast: [4.5, 7] },
|
|
182
|
+
'accent-icon': { base: 'surface', tone: '-1', saturation: 0.9375, contrast: [3.2, 5] },
|
|
183
183
|
```
|
|
184
184
|
|
|
185
|
-
Foregrounds need to stay readable on the surface they actually sit on — anchoring to the brand fill would only enforce contrast against that fill, leaving the dark-mode color washed out against the actual surface (e.g. SECONDARY button labels sit on `surface`, not on the brand fill). Anchoring to `surface` + `mode: 'auto'` lets the solver lift the
|
|
185
|
+
Foregrounds need to stay readable on the surface they actually sit on — anchoring to the brand fill would only enforce contrast against that fill, leaving the dark-mode color washed out against the actual surface (e.g. SECONDARY button labels sit on `surface`, not on the brand fill). Anchoring to `surface` + `mode: 'auto'` lets the solver lift the tone in dark mode so the contrast floor holds in both schemes.
|
|
186
186
|
|
|
187
187
|
`accent-text-soft` shares the anchor and saturation but relaxes the contrast floor for a visibly less prominent secondary foreground (link base color, subdued labels). Critically, it stays `mode: 'auto'` — a fixed version would collapse to cr≈3 against the dark surface and break AA.
|
|
188
188
|
|
|
189
189
|
## Brand-tinted disabled
|
|
190
190
|
|
|
191
|
-
Mirrors the neutral disabled pair from above but with higher saturation so the chip reads as a *muted brand color* rather than fully neutral grey:
|
|
191
|
+
Mirrors the neutral disabled pair from above but with higher saturation so the chip reads as a *muted brand color* rather than fully neutral grey. A disabled chip has no contrast *requirement* — it just needs to sit a hair off the surface and carry a faint label. That's a job for **tone**, not the contrast solver:
|
|
192
192
|
|
|
193
193
|
```ts
|
|
194
194
|
'accent-disabled-surface': {
|
|
195
|
-
base: 'surface',
|
|
196
|
-
contrast: [1.4, 1.3],
|
|
195
|
+
base: 'surface', tone: '+3', saturation: 0.5,
|
|
197
196
|
},
|
|
198
197
|
'accent-disabled-surface-text': {
|
|
199
|
-
base: 'accent-disabled-surface',
|
|
200
|
-
|
|
198
|
+
base: 'accent-disabled-surface', tone: '+18', saturation: 0.4,
|
|
199
|
+
flip: false,
|
|
201
200
|
},
|
|
202
201
|
```
|
|
203
202
|
|
|
204
|
-
|
|
203
|
+
Tone offsets say what we mean directly: the chip is three tone steps off `surface`, the label eighteen steps off the chip — a deliberately low-contrast pair, no solver involved. Because tone is contrast-uniform, those steps look the same in light and dark, and `mode: 'auto'` (the default) keeps the relationship intact when the scheme inverts.
|
|
204
|
+
|
|
205
|
+
`flip: false` on the label is the safety rail. A relative `tone: '+18'` lands above the chip; if the chip is already near the top of the range (a light surface), `+18` would overshoot 100. With flip on (the default), Glaze mirrors the offset to `-18` so it reflects back into range — handy for contrast tokens, but here it would put the label on the *wrong* side of the chip. Turning flip off clamps to the boundary instead, keeping the label on the side you authored.
|
|
206
|
+
|
|
207
|
+
When a token genuinely needs the extreme — the lightest or darkest tone the scheme allows — reach for `'max'` / `'min'` rather than a large number or a contrast hack:
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
'card-floor': { tone: 'min' }, // darkest tone (root, no base needed)
|
|
211
|
+
'card-ceil': { tone: 'max' }, // lightest tone
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
`'max'` resolves to the scheme's highest authored tone (100) and `'min'` to the lowest (0); neither needs a `base`. They flow through scheme mapping like any absolute tone, so under `mode: 'auto'` they invert in dark — `'max'` is the lightest tone in light mode and, after inversion, the *darkest* in dark. Use `mode: 'static'` if you want the same extreme pinned across every scheme, or `mode: 'fixed'` to keep it on the same end without inverting. Either way: no magic numbers, no contrast floor standing in for "push it all the way".
|
|
205
215
|
|
|
206
216
|
These are inherited (no `inherit: false`), so each colored sibling theme automatically emits `<theme>-accent-disabled-surface` and `<theme>-accent-disabled-surface-text`. PRIMARY-style disabled buttons stay tinted with the active theme's hue (danger-tinted danger button, success-tinted success button), preserving brand identity even in the disabled state.
|
|
207
217
|
|
|
@@ -210,9 +220,9 @@ These are inherited (no `inherit: false`), so each colored sibling theme automat
|
|
|
210
220
|
The `code-*` tokens use **absolute `hue` numbers** regardless of the seed. Each is `base: 'surface'` with `mode: 'auto'`, a per-token saturation, and a numeric contrast floor:
|
|
211
221
|
|
|
212
222
|
```ts
|
|
213
|
-
'code-comment': { base: 'surface', hue: 280, saturation: 0.1,
|
|
214
|
-
'code-keyword': { base: 'surface', hue: 348, saturation: 1,
|
|
215
|
-
'code-string': { base: 'surface', hue: SUCCESS_HUE, saturation: 1,
|
|
223
|
+
'code-comment': { base: 'surface', hue: 280, saturation: 0.1, tone: '-1', contrast: [4.5, 7], inherit: false },
|
|
224
|
+
'code-keyword': { base: 'surface', hue: 348, saturation: 1, tone: '-1', contrast: [5, 7.5], inherit: false },
|
|
225
|
+
'code-string': { base: 'surface', hue: SUCCESS_HUE, saturation: 1, tone: '-1', contrast: [4.5, 7], inherit: false },
|
|
216
226
|
// …code-punctuation, code-number, code-function, code-attribute follow the same shape
|
|
217
227
|
```
|
|
218
228
|
|
|
@@ -220,15 +230,15 @@ The canonical pattern for "I want a color from a different hue family but the sa
|
|
|
220
230
|
|
|
221
231
|
## Loading-animation faces
|
|
222
232
|
|
|
223
|
-
A 3-step ramp using *absolute*
|
|
233
|
+
A 3-step ramp using *absolute* tones with high saturation factors and tight numeric contrasts:
|
|
224
234
|
|
|
225
235
|
```ts
|
|
226
|
-
'loading-face-1': { base: 'surface',
|
|
227
|
-
'loading-face-2': { base: 'surface',
|
|
228
|
-
'loading-face-3': { base: 'surface',
|
|
236
|
+
'loading-face-1': { base: 'surface', tone: 98, saturation: 0.3, contrast: [1.04, 1.5], inherit: false },
|
|
237
|
+
'loading-face-2': { base: 'surface', tone: 91, saturation: 0.62, contrast: [1.24, 2.5], inherit: false },
|
|
238
|
+
'loading-face-3': { base: 'surface', tone: 79, saturation: 0.66, contrast: [1.75, 4], inherit: false },
|
|
229
239
|
```
|
|
230
240
|
|
|
231
|
-
Combines absolute
|
|
241
|
+
Combines absolute tone positioning (so the ramp is deterministic in light mode) with a numeric contrast floor (so the ramp still reads in dark and HC). The HC contrast jumps significantly (`1.04 → 1.5`, `1.24 → 2.5`, `1.75 → 4`) so the animation stays perceivable for low-vision users.
|
|
232
242
|
|
|
233
243
|
## Shadows
|
|
234
244
|
|
|
@@ -247,7 +257,7 @@ For HC, pass `intensity: [normal, hc]` (e.g. `[10, 20]`) to deepen shadows in hi
|
|
|
247
257
|
## Overlay (fixed opacity)
|
|
248
258
|
|
|
249
259
|
```ts
|
|
250
|
-
overlay: {
|
|
260
|
+
overlay: { tone: 10, opacity: 0.5, inherit: false },
|
|
251
261
|
```
|
|
252
262
|
|
|
253
263
|
The shortcut for *one solid color with a fixed alpha* — no shadow algorithm, no mix. `opacity` on a regular color attaches an alpha component to every variant. Use it for backdrops, scrims, modal overlays. (Combining `opacity` with `contrast` is not recommended — perceived lightness becomes unpredictable when alpha is fixed; Glaze emits a `console.warn`.)
|
|
@@ -280,7 +290,7 @@ One shared `TINTED_SURFACE_OVERRIDE`, applied to every colored theme, with only
|
|
|
280
290
|
|
|
281
291
|
```ts
|
|
282
292
|
const TINTED_SURFACE_OVERRIDE: ColorMap = {
|
|
283
|
-
surface: {
|
|
293
|
+
surface: { tone: 96, saturation: 0.8 },
|
|
284
294
|
};
|
|
285
295
|
|
|
286
296
|
const primaryTheme = defaultTheme.extend({ colors: TINTED_SURFACE_OVERRIDE });
|
|
@@ -313,21 +323,21 @@ The default theme is conventionally exported unprefixed (its tokens land as `#su
|
|
|
313
323
|
|
|
314
324
|
## High-contrast strategy
|
|
315
325
|
|
|
316
|
-
Glaze's high-contrast mode is opt-in per token: anywhere `
|
|
326
|
+
Glaze's high-contrast mode is opt-in per token: anywhere `tone`, `contrast`, `intensity`, or `value` accepts an HC pair, you can pass `[normal, hc]` to tighten the HC variant. The heuristic is to pair anything that's already contrast-driven:
|
|
317
327
|
|
|
318
328
|
- Text-against-surface contrasts (`[9, 11]`, `[4.5, 5.5]`, `[6.4, 10]`).
|
|
319
329
|
- The accent surface ladder (`[4.5, 7]` → `[5.2, 8]` → `[6, 8.5]`).
|
|
320
330
|
- The loading ramp's contrasts.
|
|
321
331
|
- Shadow `intensity` (e.g. `intensity: [10, 20]`).
|
|
322
|
-
- `border`
|
|
332
|
+
- `border` tone (e.g. `tone: ['-10', '-20']`).
|
|
323
333
|
|
|
324
|
-
In HC the
|
|
334
|
+
In HC the tone window is **bypassed entirely** — light HC and dark HC operate on the full `[0, 100]` range. That's why edge-anchored absolute tones like `surface-text: { tone: 2 }` reach close to black in light HC and close to white in dark HC, exactly what you want for maximum contrast.
|
|
325
335
|
|
|
326
336
|
## Closing checklist
|
|
327
337
|
|
|
328
338
|
Before shipping a palette, verify:
|
|
329
339
|
|
|
330
|
-
- [ ] Every text token has an explicit `contrast` *or* an edge-anchored absolute `
|
|
340
|
+
- [ ] Every text token has an explicit `contrast` *or* an edge-anchored absolute `tone`.
|
|
331
341
|
- [ ] Every accent surface uses `mode: 'fixed'` + numeric `contrast` (not preset `'AA'` / `'AAA'`).
|
|
332
342
|
- [ ] Every brand foreground (`accent-text*`, `accent-icon`) is anchored to `surface`, **not** to `accent-surface`.
|
|
333
343
|
- [ ] Every `inherit: false` is intentional — colored sibling themes only carry the tokens they actually need.
|
package/docs/migration.md
CHANGED
|
@@ -4,6 +4,76 @@ How to plug a Glaze palette into an existing app — exporting tokens in the rig
|
|
|
4
4
|
|
|
5
5
|
If you're starting from scratch, see [methodology.md](methodology.md) first — that's about *designing* the palette. This doc is about *consuming* it.
|
|
6
6
|
|
|
7
|
+
## Upgrading to the tone model (`lightness` → `tone`)
|
|
8
|
+
|
|
9
|
+
Glaze replaced OKHSL **lightness** with a contrast-uniform **tone** axis (OKHST). The Möbius dark-mode curve is gone — dark mode is now a single tone inversion remapped into a per-mode window. See [`docs/okhst.md`](okhst.md) for the model. This is a breaking change; here's what to update.
|
|
10
|
+
|
|
11
|
+
### Rename the authoring axis
|
|
12
|
+
|
|
13
|
+
`lightness` is gone. Replace it with `tone` everywhere:
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
// before
|
|
17
|
+
theme.colors({ surface: { lightness: 97 }, text: { base: 'surface', lightness: '-52' } });
|
|
18
|
+
// after
|
|
19
|
+
theme.colors({ surface: { tone: 97 }, text: { base: 'surface', tone: '-52' } });
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The same applies to `glaze.color({ ..., lightness })` (structured form) → `tone`, and the `{ h, s, l }` value object is unchanged (still OKHSL) — but you can now also pass `{ h, s, t }` (OKHST) or an `okhst(H S% T%)` string.
|
|
23
|
+
|
|
24
|
+
Tone is **0–100** like the old lightness, but the *scale is contrast-uniform*, not perceptual-lightness-uniform. Numeric values won't land at the same OKHSL lightness — equal tone steps now give equal WCAG contrast. Re-eyeball absolute values (especially mid-range ones); relative deltas and contrast-floored tokens usually need no change because they were already contrast-driven.
|
|
25
|
+
|
|
26
|
+
### Config window shape
|
|
27
|
+
|
|
28
|
+
The lightness windows became tone windows, and `darkCurve` was removed (no curve to tune). The `[lo, hi]` tuple form carries over directly:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
// before
|
|
32
|
+
glaze.configure({ lightLightness: [10, 100], darkLightness: [15, 95], darkCurve: 0.5 });
|
|
33
|
+
// after
|
|
34
|
+
glaze.configure({
|
|
35
|
+
lightTone: [10, 100],
|
|
36
|
+
darkTone: [15, 95],
|
|
37
|
+
// darkCurve removed; saturationTaper: 0.15 is the new gentle-extremes knob
|
|
38
|
+
});
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`lightLightness`/`darkLightness` → `lightTone`/`darkTone`. The window value is `[lo, hi]` (reference eps — the common form), `{ lo, hi, eps }` (advanced: explicit render curvature), or `false` to disable clamping. `false` removes the *boundaries* (full `[0, 100]` range), not the contrast-uniform tone curve. Per-token `glaze.color(value, config)` overrides use the same shape.
|
|
42
|
+
|
|
43
|
+
### The `contrast` prop now selects a metric
|
|
44
|
+
|
|
45
|
+
A bare number or preset is still **WCAG** and needs no change. To use APCA or split a pair across the metric, use the object form:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
contrast: 4.5 // unchanged — WCAG 4.5
|
|
49
|
+
contrast: { wcag: 6 } // explicit WCAG
|
|
50
|
+
contrast: { apca: 60 } // APCA Lc floor
|
|
51
|
+
contrast: { wcag: [4.5, 7] } // pair inside the metric
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Forcing extremes: `'max'` / `'min'`
|
|
55
|
+
|
|
56
|
+
For colors that should sit at the scheme's tone extreme (pure-white knockouts, near-black scrims, deliberately faint disabled chips), reach for `tone: 'max'` / `tone: 'min'` instead of a large absolute number or a low contrast floor standing in for "push it all the way". `'max'` resolves to author tone 100, `'min'` to 0, and both flow through scheme mapping (so they invert in dark under `mode: 'auto'`). No `base` required.
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
// before — low contrast as a proxy for "stay near the surface"
|
|
60
|
+
'disabled-text': { base: 'chip', tone: '+1', contrast: 1.51, mode: 'fixed' }
|
|
61
|
+
// after — say it directly with tone
|
|
62
|
+
'disabled-text': { base: 'chip', tone: '+18', saturation: 0.4, flip: false }
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### The `flip` prop
|
|
66
|
+
|
|
67
|
+
Relative `tone` offsets that overshoot `[0, 100]` now **mirror to the other side of the base by default** (controlled by the new per-color `flip`, which inherits the global `autoFlip`, default `true`). Previously such offsets always clamped to the boundary. If you relied on clamping — e.g. `tone: '+48'` to stack a color up to 100 — set `flip: false` on that color (or `glaze.configure({ autoFlip: false })` globally) to restore the clamping behavior. `flip` also governs the contrast solver's direction (its previous sole role).
|
|
68
|
+
|
|
69
|
+
### Resolved variants store tone
|
|
70
|
+
|
|
71
|
+
`ResolvedColorVariant` now exposes `t` (tone, 0–1) instead of `l`. If you read resolved internals, convert with `variantToOkhsl(variant).l`. Token/CSS/JSON output is unchanged — Glaze still emits `okhsl(...)` / `rgb(...)` etc. (`okhst` is input-only and is never emitted).
|
|
72
|
+
|
|
73
|
+
### Export snapshots
|
|
74
|
+
|
|
75
|
+
`theme.export()` / `color.export()` snapshots now carry `lightTone` / `darkTone` window objects (not `lightLightness` / `darkLightness`). Old exported JSON with the legacy keys will need its `config` block rewritten before `glaze.from()` / `glaze.colorFrom()`.
|
|
76
|
+
|
|
7
77
|
## Choosing an export
|
|
8
78
|
|
|
9
79
|
Glaze emits the same resolved colors in four shapes. Pick one based on your renderer.
|
|
@@ -176,15 +246,15 @@ Walk your current color system and bucket every token into one of these categori
|
|
|
176
246
|
- **Shadows / overlays** — elevation, scrims.
|
|
177
247
|
- **One-off colors** — syntax highlighting, charts, illustrations.
|
|
178
248
|
|
|
179
|
-
Each bucket maps cleanly to one of the patterns in [methodology.md](methodology.md). The bucket determines the *shape* of the Glaze definition (root vs dependent, `mode: 'auto'` vs `'fixed'`, contrast-floor vs absolute
|
|
249
|
+
Each bucket maps cleanly to one of the patterns in [methodology.md](methodology.md). The bucket determines the *shape* of the Glaze definition (root vs dependent, `mode: 'auto'` vs `'fixed'`, contrast-floor vs absolute tone), not the value.
|
|
180
250
|
|
|
181
251
|
### 2. Reproduce the existing values
|
|
182
252
|
|
|
183
|
-
Pick a Glaze definition shape that lands the new color *close to* the legacy hex in light mode. The methodology doc explains the shape per bucket; the API doc covers the levers (`
|
|
253
|
+
Pick a Glaze definition shape that lands the new color *close to* the legacy hex in light mode. The methodology doc explains the shape per bucket; the API doc covers the levers (`tone`, `saturation`, `contrast`, `mode`, `hue`).
|
|
184
254
|
|
|
185
255
|
Two tactics that make matching easier:
|
|
186
256
|
|
|
187
|
-
- **Anchor strong text at the edge** instead of solving for `'AAA'`. The contrast solver stops at the floor (cr=7), which usually leaves text noticeably softer than a legacy hex like `#1a1a1a`. An absolute `
|
|
257
|
+
- **Anchor strong text at the edge** instead of solving for `'AAA'`. The contrast solver stops at the floor (cr=7), which usually leaves text noticeably softer than a legacy hex like `#1a1a1a`. An absolute `tone: 2` (or wherever the legacy token sits) preserves the look.
|
|
188
258
|
- **Use numeric `contrast` ratios** for soft / accent / disabled tokens. Presets give you the WCAG floor and nothing more — for matching a designed palette you usually want a specific perceived weight, not the floor.
|
|
189
259
|
|
|
190
260
|
### 3. Keep the old token names
|
|
@@ -196,9 +266,9 @@ Use a custom `prefix` map (and theme aliases if needed) so the names your compon
|
|
|
196
266
|
// New: define them in Glaze, map the prefix so they emit unchanged.
|
|
197
267
|
|
|
198
268
|
defaultTheme.colors({
|
|
199
|
-
dark: { base: 'surface',
|
|
200
|
-
'dark-02': { base: 'surface',
|
|
201
|
-
'dark-03': { base: 'surface',
|
|
269
|
+
dark: { base: 'surface', tone: 2, saturation: 0.475 },
|
|
270
|
+
'dark-02': { base: 'surface', tone: '-1', saturation: 0.375, contrast: [9, 11] },
|
|
271
|
+
'dark-03': { base: 'surface', tone: '-1', saturation: 0.24, contrast: [4.5, 5.5] },
|
|
202
272
|
});
|
|
203
273
|
|
|
204
274
|
palette.tasty({ prefix: { default: '' } });
|
|
@@ -211,8 +281,8 @@ Once consumers are off the legacy names, rename the Glaze tokens to match your c
|
|
|
211
281
|
|
|
212
282
|
Glaze gives you light/dark/HC for free, but only the light mode is matched against the legacy palette. Before promoting the migration:
|
|
213
283
|
|
|
214
|
-
- Spot-check every surface, text, accent, and disabled pair in dark mode. The
|
|
215
|
-
- If the legacy system had no high-contrast mode, audit the HC variants Glaze emits. Anywhere the resolved cr is too low or the color blows out, add an HC pair (`
|
|
284
|
+
- Spot-check every surface, text, accent, and disabled pair in dark mode. The tone inversion plus per-color `mode` choices may produce results that *look right* in light but feel off in dark (typical fix: switch a brand color to `mode: 'fixed'`, or anchor a foreground to `surface` instead of the brand fill — see [methodology.md](methodology.md)).
|
|
285
|
+
- If the legacy system had no high-contrast mode, audit the HC variants Glaze emits. Anywhere the resolved cr is too low or the color blows out, add an HC pair (`tone: ['-7', '-20']`, `contrast: [4.5, 7]`, etc.).
|
|
216
286
|
- Run real screens, not just the token grid. The interaction of multiple Glaze tokens against each other (text on chip, hover bg vs. fill, disabled label on disabled chip) is where mismatches show up.
|
|
217
287
|
|
|
218
288
|
### 5. Trim what `extend()` doesn't need
|
|
@@ -224,9 +294,10 @@ After migration, mark every default-only token (borders, shadows, disabled chip,
|
|
|
224
294
|
| Symptom | Cause | Fix |
|
|
225
295
|
|---|---|---|
|
|
226
296
|
| Disabled state stops looking disabled in dark mode. | Alpha-tinted overlay on `surface-text` (which inverts), giving asymmetric perceived contrast. | Replace with a `mode: 'auto'` color anchored to `surface` with a numeric `contrast` (see [methodology.md → Disabled chip](methodology.md#disabled-chip-contrast-driven-for-scheme-symmetry)). |
|
|
227
|
-
| Brand color flips to its complement in dark mode. | Default `mode: 'auto'`
|
|
297
|
+
| Brand color flips to its complement in dark mode. | Default `mode: 'auto'` inverts the tone. | Set `mode: 'fixed'` so the tone is remapped (not inverted). |
|
|
228
298
|
| Brand text washes out against the dark surface. | Foreground was anchored to `accent-surface` (the brand fill), so contrast was only enforced against that fill — not the actual surface. | Anchor `accent-text` etc. to `surface` with `mode: 'auto'`. |
|
|
229
|
-
| Tokens look right in light, broken in HC. | The HC pass bypasses the
|
|
299
|
+
| Tokens look right in light, broken in HC. | The HC pass bypasses the tone window — solver runs over the full `[0, 100]` range. | Add explicit `[normal, hc]` pairs to `tone` / `contrast` for the affected tokens. |
|
|
300
|
+
| A relative `tone` like `'+48'` lands on the *wrong* (darker) side of its base. | Overshooting offsets now mirror to the other side of the base by default (`flip` inherits `autoFlip`). | Set `flip: false` on the color to clamp to the boundary instead, or use `tone: 'max'`/`'min'` to force the extreme. |
|
|
230
301
|
| `palette.tokens()` emits unexpected unprefixed names. | A `primary` was set on the palette (or per-call) and is duplicating the theme's tokens without prefix. | Pass `primary: false` to disable for that export, or rename `glaze.palette(themes, { primary })`. |
|
|
231
302
|
| `console.warn: token "foo" collides with theme "bar"`. | Two themes resolved to the same output key under your prefix config. | Adjust the prefix map so each token is unique, or accept the first-write-wins behavior. |
|
|
232
303
|
| `console.warn: color "X" cannot meet contrast`. | The requested contrast target is physically unreachable for the color's hue/saturation against its base. | Lower the floor, change the base, or accept the closest passing variant. Use the `name` override on standalone colors to make the warning identifiable. |
|
package/docs/okhst.md
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# OKHST — the contrast-uniform tone space
|
|
2
|
+
|
|
3
|
+
This is the canonical specification for the color model Glaze uses internally
|
|
4
|
+
and accepts as input. It is the source of truth that [api.md](api.md),
|
|
5
|
+
[methodology.md](methodology.md), and [migration.md](migration.md) reference.
|
|
6
|
+
|
|
7
|
+
## What OKHST is
|
|
8
|
+
|
|
9
|
+
**OKHST is OKHSL with its lightness axis replaced by a contrast-uniform _tone_
|
|
10
|
+
axis.** It shares OKHSL's hue (`h`, 0–360) and saturation (`s`, 0–1) verbatim;
|
|
11
|
+
only the third coordinate changes:
|
|
12
|
+
|
|
13
|
+
| Space | Coords | Third axis |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| OKHSL | `h, s, l` | `l` — perceptual lightness (toe-adjusted OKLab L) |
|
|
16
|
+
| OKHST | `h, s, t` | `t` — tone: a normalized log of luminance |
|
|
17
|
+
|
|
18
|
+
OKHST exists for one reason: in OKHSL, _equal lightness steps_ are perceptually
|
|
19
|
+
even but produce _uneven contrast_ (the ratio between adjacent steps drifts).
|
|
20
|
+
OKHST's tone axis is shaped so that _equal tone steps_ produce _even WCAG
|
|
21
|
+
contrast_ between steps. Authoring ramps in tone gives you contrast-even ladders
|
|
22
|
+
for free, and dark-mode inversion becomes a single subtraction (`100 - t`)
|
|
23
|
+
instead of a fitted curve.
|
|
24
|
+
|
|
25
|
+
OKHST is an **input space only**. It is parseable as an `okhst(H S% T%)` string
|
|
26
|
+
and an `{ h, s, t }` object, but it is **never emitted** — there is no CSS
|
|
27
|
+
`okhst()` function, so output formats stay `okhsl | rgb | hsl | oklch`.
|
|
28
|
+
|
|
29
|
+
## The tone transfer
|
|
30
|
+
|
|
31
|
+
For a gray (s = 0) at OKHSL lightness `l`, luminance is closed-form through the
|
|
32
|
+
OKHSL toe and OKLab cube:
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
Y = toeInv(l) ** 3 // OKLab L = toeInv(l); luminance ≈ L³
|
|
36
|
+
l = toe(cbrt(Y)) // exact inverse
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
(`toe` / `toeInv` already exist in [okhsl-color-math.ts](../src/okhsl-color-math.ts).)
|
|
40
|
+
|
|
41
|
+
Tone is a normalized natural-log of `Y`, offset by a small `eps`:
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
toTone(Y, eps) = (ln(Y + eps) - ln(eps)) / (ln(1 + eps) - ln(eps)) * 100
|
|
45
|
+
fromTone(T, eps) = exp( (T / 100) * (ln(1 + eps) - ln(eps)) + ln(eps) ) - eps
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`toTone` and `fromTone` are exact analytic inverses, so a round-trip is lossless
|
|
49
|
+
to ~1e-15. `toTone(0) = 0` and `toTone(1) = 100` for any `eps`, so tone is always
|
|
50
|
+
a clean 0–100 scale.
|
|
51
|
+
|
|
52
|
+
### Why `eps ≈ 0.05` makes tone contrast-uniform
|
|
53
|
+
|
|
54
|
+
WCAG 2 contrast is `(Y_hi + 0.05) / (Y_lo + 0.05)`. Pick `eps = 0.05` and the
|
|
55
|
+
tone transfer becomes a normalized `ln(Y + 0.05)`. Two colors that differ by a
|
|
56
|
+
fixed tone delta `ΔT` then differ by a fixed _ratio_ of `(Y + 0.05)` — i.e. a
|
|
57
|
+
fixed WCAG contrast ratio — regardless of where on the scale they sit:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
cr(T2, T1) = (Y2 + 0.05) / (Y1 + 0.05)
|
|
61
|
+
= exp( (T2 - T1)/100 * (ln(1.05) - ln(0.05)) )
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Empirically (gray, `eps = 0.05`), each `+10` tone multiplies contrast-vs-black by
|
|
65
|
+
a near-constant factor:
|
|
66
|
+
|
|
67
|
+
| tone | cr vs black |
|
|
68
|
+
|---|---|
|
|
69
|
+
| 10 | 1.36 |
|
|
70
|
+
| 30 | 2.49 |
|
|
71
|
+
| 50 | 4.58 |
|
|
72
|
+
| 70 | 8.43 |
|
|
73
|
+
| 90 | 15.49 |
|
|
74
|
+
| 100 | 21.00 |
|
|
75
|
+
|
|
76
|
+
So a tone ramp `[20, 40, 60, 80]` has _constant_ contrast between adjacent
|
|
77
|
+
stops. That is the whole point.
|
|
78
|
+
|
|
79
|
+
## Core invariant: `T → L` is independent of `H` and `S`
|
|
80
|
+
|
|
81
|
+
`okhstToOkhsl({ h, s, t })` passes `h` and `s` through unchanged and sets
|
|
82
|
+
`l = fromTone(t)`. `fromTone` is a pure function of `(t, eps)`; OKHSL's
|
|
83
|
+
`l → OKLab L = toeInv(l)` map has no hue/saturation term — `h`/`s` enter only
|
|
84
|
+
the chroma/cusp math. Therefore:
|
|
85
|
+
|
|
86
|
+
> **A given tone yields the same OKHSL lightness for every hue and saturation.**
|
|
87
|
+
|
|
88
|
+
OKHST inherits OKHSL's gamut and reversibility exactly: every `(h, s, t)` is
|
|
89
|
+
realizable and round-trips.
|
|
90
|
+
|
|
91
|
+
**This uniformity is in lightness, not luminance.** Equal tone gives equal
|
|
92
|
+
OKHSL `L` for all `h`/`s`, but equal _WCAG/APCA contrast_ only for grays.
|
|
93
|
+
A saturated yellow and a saturated blue at the same tone share a lightness yet
|
|
94
|
+
differ in real luminance `Y`. This chromatic drift is the one honest
|
|
95
|
+
approximation in the design — see [§10 Verification](#verification-apca--wcag-drift).
|
|
96
|
+
The single deliberate exception to the invariant is the optional `contrast`
|
|
97
|
+
solver, which shifts a stop's tone per `h`/`s` to meet a luminance-based floor.
|
|
98
|
+
|
|
99
|
+
## Reference eps vs per-mode eps
|
|
100
|
+
|
|
101
|
+
Two distinct roles, kept separate on purpose:
|
|
102
|
+
|
|
103
|
+
- **Reference eps (`0.05`, fixed).** Defines the OKHST _color space_ and the
|
|
104
|
+
canonical stored tone. `okhst()` input, `{ h, s, t }` input, the internal
|
|
105
|
+
`ResolvedColorVariant.t`, relative `tone` offsets, and the contrast solver all
|
|
106
|
+
use the reference eps. This is what makes OKHST stable and scheme-independent.
|
|
107
|
+
- **Per-mode eps (`lightTone.eps`, `darkTone.eps`).** A _rendering_ curvature
|
|
108
|
+
knob per scheme. It only affects how authored tone is mapped through a scheme
|
|
109
|
+
window before the result is stored. Defaults to the reference value, so by
|
|
110
|
+
default the two coincide and there is nothing to reconcile.
|
|
111
|
+
|
|
112
|
+
When a mode's eps differs from the reference, `mapToneForScheme` maps using the
|
|
113
|
+
mode eps to land a final OKHSL `l`, then stores `toTone(l, REF_EPS)` so offsets
|
|
114
|
+
and contrast stay comparable across schemes.
|
|
115
|
+
|
|
116
|
+
## Scheme pipeline (no Möbius)
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
author tone T (0–100)
|
|
120
|
+
→ mode branch:
|
|
121
|
+
auto + dark : invert T' = 100 - T
|
|
122
|
+
fixed / light: keep T' = T
|
|
123
|
+
static : identity, skip window
|
|
124
|
+
→ window remap: T' into the scheme window [lo, hi] (tone units)
|
|
125
|
+
→ render curvature (mode eps) → OKHSL l
|
|
126
|
+
→ store canonical tone t = toTone(l, REF_EPS) // variant {h, s, t, alpha}
|
|
127
|
+
→ (edge only) fromTone(t, REF_EPS) → l → sRGB / luminance
|
|
128
|
+
optional: contrast floor (wcag/apca) searches in tone, overriding t
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
High-contrast is **not** a separate curve. It reuses the same math with the
|
|
132
|
+
window forced to the full range `[0, 100]`, keeping the mode's eps. There is no
|
|
133
|
+
`darkCurve` and no separate HC curve.
|
|
134
|
+
|
|
135
|
+
`fixed` mode remaps into the window but does **not** invert (brand colors stay
|
|
136
|
+
recognizable). `static` skips the window entirely (identity) so the same tone
|
|
137
|
+
renders in every scheme.
|
|
138
|
+
|
|
139
|
+
## Calibrated constants (defaults)
|
|
140
|
+
|
|
141
|
+
Chosen as clean defaults that keep light mode close to the previous pipeline
|
|
142
|
+
while the axis stays contrast-uniform (the old Möbius curve was intentionally
|
|
143
|
+
non-uniform, which is what we are replacing). The light floor sits at `lo = 10`
|
|
144
|
+
and the dark floor at `lo = 15`, so neither scheme bottoms out darker than the
|
|
145
|
+
legacy pipeline produced. `eps` is pinned to the reference value `0.05` so the
|
|
146
|
+
tone axis stays WCAG-uniform.
|
|
147
|
+
|
|
148
|
+
| Config | lo | hi | eps |
|
|
149
|
+
|---|---|---|---|
|
|
150
|
+
| `lightTone` | 10 | 100 | 0.05 |
|
|
151
|
+
| `darkTone` | 15 | 95 | 0.05 |
|
|
152
|
+
|
|
153
|
+
A window is authored as `[lo, hi]` (reference eps — the common form),
|
|
154
|
+
`{ lo, hi, eps }` (advanced: explicit per-mode render curvature), or `false`
|
|
155
|
+
to disable clamping. `false` is the full range `[0, 100]` at the reference eps —
|
|
156
|
+
it removes the **boundaries**, not the tone curve.
|
|
157
|
+
|
|
158
|
+
Other defaults: `darkDesaturation = 0.1` (unchanged), `saturationTaper = 0.15`,
|
|
159
|
+
`autoFlip = true`.
|
|
160
|
+
|
|
161
|
+
Reference: `REF_EPS = 0.05`.
|
|
162
|
+
|
|
163
|
+
## Saturation taper
|
|
164
|
+
|
|
165
|
+
At the tone extremes the in-gamut chroma collapses, so high saturation near
|
|
166
|
+
white/black reads as noise. `saturationEnvelope(s, toneFinal, taper)` applies a
|
|
167
|
+
smoothstep rolloff over the outer `taper` fraction of the tone range (default
|
|
168
|
+
`0.15` → outer 15% on each end). `taper = 0` disables it. It is conservative by
|
|
169
|
+
design: mid-tones are untouched, so existing ramps barely shift.
|
|
170
|
+
|
|
171
|
+
## Contrast metric (unified)
|
|
172
|
+
|
|
173
|
+
`contrast` is a single prop with a pluggable metric:
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
type ContrastSpec =
|
|
177
|
+
| number // bare WCAG ratio
|
|
178
|
+
| ContrastPreset // 'AA' | 'AAA' | 'AA-large' | 'AAA-large' (WCAG)
|
|
179
|
+
| { wcag: HCPair<number | ContrastPreset> }
|
|
180
|
+
| { apca: HCPair<number> };
|
|
181
|
+
|
|
182
|
+
contrast?: HCPair<ContrastSpec>;
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
A bare number or preset means WCAG. The `[normal, highContrast]` pair may live at
|
|
186
|
+
the outer level (`[4.5, 7]`, `[{ wcag: 4.5 }, { wcag: 7 }]`) **or** inside the
|
|
187
|
+
metric (`{ wcag: [4.5, 7] }`, `{ apca: [45, 60] }`). `resolveContrastForMode`
|
|
188
|
+
peels the outer pair by mode, then the inner metric pair by the same mode, then
|
|
189
|
+
resolves presets, returning `{ metric, target }`.
|
|
190
|
+
|
|
191
|
+
The solver searches in **tone** (contrast-uniform → fast convergence and a
|
|
192
|
+
closed-form WCAG seed). For WCAG, the seed is the tone whose gray luminance hits
|
|
193
|
+
`Y = R·(Y_base + 0.05) − 0.05`; chromatic drift is then refined by binary search.
|
|
194
|
+
For APCA, it binary-searches tone against the APCA Lc target.
|
|
195
|
+
|
|
196
|
+
### APCA
|
|
197
|
+
|
|
198
|
+
`apcaContrast(yText, yBg)` implements SAPC/APCA Lc (soft-clamp of low luminances
|
|
199
|
+
plus the polarity exponents for normal vs reverse contrast), returning a signed
|
|
200
|
+
Lc whose magnitude the solver compares against the target. Its inputs are APCA
|
|
201
|
+
*screen* luminances `Ys = 0.2126·R^2.4 + 0.7152·G^2.4 + 0.0722·B^2.4` over the
|
|
202
|
+
gamma-encoded channels (`apcaLuminanceFromLinearRgb`), **not** WCAG relative
|
|
203
|
+
luminance — the soft-clamp constants are calibrated against `Ys`, so the solver
|
|
204
|
+
feeds it the matching basis. This is a faithful-but-simplified APCA (it omits
|
|
205
|
+
the spatial/font-size lookup that maps Lc to a usable text size).
|
|
206
|
+
|
|
207
|
+
## Verification (APCA / WCAG drift)
|
|
208
|
+
|
|
209
|
+
Because chromatic swatches inherit gray's tone-derived lightness but drift in
|
|
210
|
+
real luminance, a color resolved with a `base` + `contrast` may land slightly
|
|
211
|
+
under the contrast its tone implies. After resolving such a color, Glaze
|
|
212
|
+
computes the actual WCAG ratio and APCA Lc of the chromatic result against its
|
|
213
|
+
base and emits a deduped `console.warn` when it drifts below the gray-tone
|
|
214
|
+
expectation. This is advisory: it surfaces the one approximation rather than
|
|
215
|
+
hiding it. The dedupe cache is the existing 256-entry cache in
|
|
216
|
+
[warnings.ts](../src/warnings.ts).
|
|
217
|
+
|
|
218
|
+
## Migration from `lightness`
|
|
219
|
+
|
|
220
|
+
`lightness` (OKHSL `l`, 0–100) is replaced by `tone` (0–100). They are **not**
|
|
221
|
+
the same number — tone is the contrast-uniform reparameterization. To convert an
|
|
222
|
+
old absolute `lightness: L` to the equivalent `tone`, use
|
|
223
|
+
`toTone(L/100, 0.05)`. See [migration.md](migration.md) for the full guide and
|
|
224
|
+
a conversion table.
|