@tenphi/glaze 0.19.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/okhst.md CHANGED
@@ -1,308 +1,170 @@
1
- # OKHST the contrast-uniform tone space
1
+ # OKHST in Glaze
2
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.
3
+ Glaze uses [OKHST](https://github.com/tenphi/okhst), an OKHSL-derived color
4
+ space with a tone axis for stable palette authoring. This page explains how
5
+ Glaze applies the model. The standalone repository is the canonical
6
+ specification for the transfer functions, derivation, and color-model
7
+ invariants.
6
8
 
7
- ## What OKHST is
9
+ For practical palette design, continue to [methodology.md](methodology.md). For
10
+ every related option and utility, see [api.md](api.md).
8
11
 
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
+ ## What tone means
12
13
 
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 |
14
+ OKHST keeps OKHSL hue and saturation and replaces authored lightness with
15
+ **tone**:
17
16
 
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.
17
+ | Space | Coordinates | Third coordinate |
18
+ | ----- | ----------- | -------------------- |
19
+ | OKHSL | `h, s, l` | Perceptual lightness |
20
+ | OKHST | `h, s, t` | Contrast-shaped tone |
24
21
 
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`.
22
+ Tone uses a `0–100` authoring scale. Its primary invariant is:
28
23
 
29
- ## The tone transfer
24
+ > The same tone maps to the same OKHSL lightness for every hue and saturation.
30
25
 
31
- For a gray (s = 0) at OKHSL lightness `l`, luminance is closed-form through the
32
- OKHSL toe and OKLab cube:
26
+ For neutral colors, equal tone differences produce equal WCAG contrast ratios.
27
+ For chromatic colors, they remain a useful progression but are not exact:
28
+ hue, saturation, and gamut mapping can change the final sRGB luminance.
33
29
 
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`:
30
+ This distinction informs two authoring rules:
42
31
 
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
- ```
32
+ - Use **tone deltas** for visual spacing within ramps and related UI colors.
33
+ - Use `contrast` when a rendered color must meet a measured WCAG or APCA floor.
47
34
 
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.
35
+ ## Tone vocabulary
51
36
 
52
- ### Why `eps ≈ 0.05` makes tone contrast-uniform
37
+ ### Absolute tone
53
38
 
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:
39
+ A numeric `tone` places a color independently on the `0–100` authoring scale:
58
40
 
59
- ```
60
- cr(T2, T1) = (Y2 + 0.05) / (Y1 + 0.05)
61
- = exp( (T2 - T1)/100 * (ln(1.05) - ln(0.05)) )
41
+ ```ts
42
+ surface: {
43
+ tone: 97;
44
+ }
62
45
  ```
63
46
 
64
- Empirically (gray, `eps = 0.05`), each `+10` tone multiplies contrast-vs-black by
65
- a near-constant factor:
47
+ `'max'` and `'min'` select the corresponding end of the active scheme.
66
48
 
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 |
49
+ ### Tone delta
75
50
 
76
- So a tone ramp `[20, 40, 60, 80]` has _constant_ contrast between adjacent
77
- stops. That is the whole point.
51
+ A **tone delta** is the signed difference between a dependent color and its
52
+ base. It is authored as a string:
78
53
 
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.
54
+ ```ts
55
+ border: { base: 'surface', tone: '-8' }
56
+ ```
98
57
 
99
- ## Reference eps vs per-mode eps
58
+ Here, `-8` is the tone delta. The base is resolved separately for every scheme,
59
+ then the delta is applied to that resolved base. This keeps related colors
60
+ visually ordered across light, dark, and high-contrast variants.
100
61
 
101
- Two distinct roles, kept separate on purpose:
62
+ ### Tone window
102
63
 
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.
64
+ A **tone window** is the scheme-specific render range configured by
65
+ `lightTone` or `darkTone`:
111
66
 
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.
67
+ ```ts
68
+ glaze.configure({
69
+ lightTone: [10, 100],
70
+ darkTone: [15, 95],
71
+ });
72
+ ```
115
73
 
116
- ## Scheme pipeline (no Möbius)
74
+ In Glaze, `lo` and `hi` are OKHSL-lightness boundaries. Authored tone is
75
+ positioned within the corresponding tone interval, then converted back to
76
+ OKHSL lightness for rendering. The object form adds an advanced render
77
+ curvature:
117
78
 
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
79
+ ```ts
80
+ lightTone: { lo: 10, hi: 100, eps: 0.05 }
129
81
  ```
130
82
 
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.
83
+ Pass `false` to use the full range. High-contrast variants always bypass the
84
+ ordinary boundaries and use the full range.
134
85
 
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.
86
+ ## Scheme adaptation
138
87
 
139
- ## Calibrated constants (defaults)
88
+ Each regular color has an adaptation `mode`:
140
89
 
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.
90
+ | Mode | Light scheme | Dark scheme |
91
+ | ---------- | ------------------------------ | -------------------------------------------------------------------- |
92
+ | `'auto'` | Map into the light tone window | Apply dark tone inversion (`100 - t`), then map into the dark window |
93
+ | `'fixed'` | Map into the light tone window | Map into the dark window without inversion |
94
+ | `'static'` | Keep the authored tone | Keep the authored tone |
147
95
 
148
- | Config | lo | hi | eps |
149
- |---|---|---|---|
150
- | `lightTone` | 10 | 100 | 0.05 |
151
- | `darkTone` | 15 | 95 | 0.05 |
96
+ Use `auto` for surfaces and foregrounds that should exchange light/dark
97
+ positions. Use `fixed` for brand fills and inverse surfaces that should stay on
98
+ the same side of the scale. Reserve `static` for colors that must not adapt.
152
99
 
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.
100
+ **Dark tone inversion** is different from `autoFlip`. Inversion is normal
101
+ scheme adaptation controlled by `mode`. `autoFlip` handles a different local
102
+ problem: it may reverse an overshooting tone delta or let the contrast solver
103
+ try the opposite side of a base.
157
104
 
158
- Other defaults: `darkDesaturation = 0.1` (unchanged),
159
- `autoFlip = true`.
105
+ The regular pipeline is:
160
106
 
161
- Reference: `REF_EPS = 0.05`.
107
+ ```text
108
+ authored tone
109
+ -> choose the color's adaptation mode
110
+ -> invert for dark + auto
111
+ -> map through the active tone window
112
+ -> store canonical tone
113
+ -> convert to OKHSL at the rendering edge
114
+ ```
162
115
 
163
- ## Contrast metric (unified)
116
+ Dark schemes also apply `darkDesaturation` unless the color is `static`.
164
117
 
165
- `contrast` is a single prop with a pluggable metric:
118
+ ## Reference and render epsilon
166
119
 
167
- ```ts
168
- type ContrastSpec =
169
- | number // bare WCAG ratio
170
- | ContrastPreset // 'AA' | 'AAA' | 'AA-large' | 'AAA-large' (WCAG)
171
- | { wcag: HCPair<number | ContrastPreset> }
172
- | { apca: HCPair<number | ApcaPreset> };
120
+ `REF_EPS = 0.05` defines Glaze's canonical tone scale. It is used for OKHST
121
+ input, stored resolved tone, tone deltas, and contrast search.
173
122
 
174
- contrast?: HCPair<ContrastSpec>;
175
- ```
123
+ An object tone window may supply another `eps` as a scheme-specific rendering
124
+ control. Glaze still converts the result back to the reference scale so tone
125
+ deltas and contrast calculations remain comparable between schemes. Most
126
+ palettes should use the default.
176
127
 
177
- A bare number or preset means WCAG. The `[normal, highContrast]` pair may live at
178
- the outer level (`[4.5, 7]`, `[{ wcag: 4.5 }, { wcag: 7 }]`) **or** inside the
179
- metric (`{ wcag: [4.5, 7] }`, `{ apca: [45, 60] }`, `{ apca: ['content', 'body'] }`). `resolveContrastForMode`
180
- peels the outer pair by mode, then the inner metric pair by the same mode, then
181
- resolves presets, returning `{ metric, target }`.
182
-
183
- The solver searches in **tone** (contrast-uniform → fast convergence and a
184
- closed-form WCAG seed). For WCAG, the seed is the tone whose gray luminance hits
185
- `Y = R·(Y_base + 0.05) − 0.05`; chromatic drift is then refined by binary search.
186
- For APCA, it binary-searches tone against the APCA Lc target.
187
-
188
- ### APCA
189
-
190
- `apcaContrast(yText, yBg)` implements SAPC/APCA Lc (soft-clamp of low luminances
191
- plus the polarity exponents for normal vs reverse contrast), returning a signed
192
- Lc whose magnitude the solver compares against the target. Its inputs are APCA
193
- *screen* luminances `Ys = 0.2126·R^2.4 + 0.7152·G^2.4 + 0.0722·B^2.4` over the
194
- gamma-encoded channels (`apcaLuminanceFromLinearRgb`), **not** WCAG relative
195
- luminance — the soft-clamp constants are calibrated against `Ys`, so the solver
196
- feeds it the matching basis. This is a faithful-but-simplified APCA (it omits
197
- the spatial/font-size lookup that maps Lc to a usable text size).
198
-
199
- #### Polarity (roles)
200
-
201
- APCA is **asymmetric**: `|apcaContrast(a, b)| ≠ |apcaContrast(b, a)|`, because
202
- the normal-polarity exponents (dark text on light bg) differ from the
203
- reverse-polarity exponents (light text on dark bg). Glaze picks the argument
204
- order from each color's semantic **role** against its base:
205
-
206
- | Role | Polarity | Argument order | Aliases (name inference) |
207
- | --------- | -------- | ------------------------------ | ------------------------------------------- |
208
- | `text` | `fg` | `apcaContrast(candidate, base)`| `text`, `fg`, `foreground`, `content`, `ink`, `label`, `stroke` |
209
- | `border` | `fg` | `apcaContrast(candidate, base)`| `border`, `divider`, `outline`, `separator`, `hairline`, `rule` |
210
- | `surface` | `bg` | `apcaContrast(base, candidate)`| `surface`, `bg`, `background`, `fill`, `canvas`, `paper`, `layer` |
211
-
212
- A color's role is resolved per the chain: explicit `role` → inferred from the
213
- color name (last recognized token wins, so `button-text` → `text`,
214
- `input-bg` → `surface`) → the opposite of the base's role → `'text'` (foreground)
215
- default. Name inference is on by default (`config.inferRole: true`) and can be
216
- disabled. WCAG is symmetric, so role never changes WCAG results — it only fixes
217
- APCA argument order.
218
-
219
- #### APCA presets
220
-
221
- APCA targets may be a raw Lc number or a named preset (APCA Bronze Simple Mode
222
- conformance levels), independent of role:
128
+ ## Contrast verification
223
129
 
224
- ```ts
225
- type ApcaPreset = 'preferred' | 'body' | 'content' | 'large' | 'non-text' | 'min';
226
- ```
130
+ Tone provides an efficient starting point for contrast solving, but Glaze does
131
+ not assume a chromatic tone has the neutral color's luminance. For a color with
132
+ `base` and `contrast`, Glaze:
227
133
 
228
- | Preset | Lc | Use case |
229
- | ------------- | --- | ---------------------------------------------------- |
230
- | `'preferred'` | 90 | Preferred body / column text |
231
- | `'body'` | 75 | Minimum body / column text |
232
- | `'content'` | 60 | Readable non-body content (~WCAG AA 4.5:1) |
233
- | `'large'` | 45 | Large/bold headlines; fine icons/outlines (~3:1) |
234
- | `'non-text'` | 30 | Solid icons/controls; placeholder/disabled text |
235
- | `'min'` | 15 | Dividers/decorative; APCA "point of invisibility" |
134
+ 1. resolves both colors for the active scheme;
135
+ 2. measures the requested WCAG ratio or APCA Lc;
136
+ 3. searches tone when the requested position misses the floor; and
137
+ 4. warns when the target is physically unreachable or rendered chroma drifts
138
+ below the expected result.
236
139
 
237
- ```ts
238
- contrast: { apca: 'content' } // Lc 60
239
- contrast: { apca: ['content', 'body'] } // HC pair: 60 normal, 75 high-contrast
240
- ```
140
+ `contrast` is therefore a floor, not a replacement for tone relationships.
141
+ Detailed presets, HC promotion, polarity, and role inference are documented
142
+ under [`contrast`](api.md#contrast-floor) and [roles](api.md#roles).
241
143
 
242
- #### Enhanced Level (high-contrast auto-boost)
144
+ ## Input and output
243
145
 
244
- When an APCA target is given as a **bare scalar** (no `[normal, hc]` pair at
245
- either the outer `contrast` level or the inner `apca` level), Glaze
246
- automatically applies the APCA-W3 "Enhanced Level" delta — **+15 Lc** — in
247
- high-contrast mode, analogous to WCAG's AAA-over-AA step. This is on by
248
- default; provide an explicit HC value via either pair to override it.
146
+ Glaze accepts OKHST through:
249
147
 
250
148
  ```ts
251
- contrast: { apca: 60 } // Lc 60 normal, Lc 75 high-contrast (auto)
252
- contrast: { apca: 'content' } // Lc 60 normal, Lc 75 high-contrast (auto)
253
- contrast: { apca: [60, 90] } // Lc 60 normal, Lc 90 high-contrast (explicit)
254
- contrast: [{ apca: 60 }, { apca: 90 }] // outer pair: explicit HC, no boost
255
- contrast: { apca: 'preferred' } // Lc 90 normal, Lc 105 high-contrast (auto)
256
- contrast: { apca: 100 } // Lc 100 normal, Lc 106 high-contrast (clamped)
149
+ glaze.color('okhst(152 95% 70%)');
150
+ glaze.color({ h: 152, s: 0.95, t: 0.7 });
151
+ glaze.color({ hue: 152, saturation: 95, tone: 70 });
257
152
  ```
258
153
 
259
- The enhanced target is clamped to `APCA_MAX_LC` (106).
260
-
261
- > **Large-text glare caveat.** APCA-W3 also defines Lc 90 as a *maximum* for
262
- > very large/bold text (>36px bold) and large areas of color, to prevent
263
- > excessive glare. The +15 HC boost can therefore push a high baseline (e.g.
264
- > `preferred` Lc 90 → 105) past that glare ceiling. This is intentional for
265
- > small/standard text in HC (low-vision readability trumps glare, and small
266
- > fonts have no APCA maximum), but if your HC tokens serve large/bold text,
267
- > pass an explicit HC pair (`{ apca: [90, 90] }`) to hold the glare ceiling.
268
- > Glaze does not model font size, so it can't enforce the large-text cap
269
- > automatically.
270
-
271
- #### WCAG HC auto-promotion
272
-
273
- A bare WCAG preset (no `[normal, hc]` pair at either the outer `contrast` or
274
- inner `wcag` level) is automatically promoted to its spec-defined "Enhanced"
275
- successor in high-contrast mode — WCAG SC 1.4.3 (Minimum) → SC 1.4.6
276
- (Enhanced), the direct analog of APCA's Enhanced Level:
154
+ There is no native CSS `okhst()` function. Native exports use `oklch`, `rgb`,
155
+ or `hsl`. [Tasty](https://tasty.style) integrations may request `format: 'okhst'` because it can
156
+ consume Glaze's custom serialization:
277
157
 
278
158
  ```ts
279
- contrast: 'AA' // 4.5 normal, 7 high-contrast (auto -> AAA)
280
- contrast: 'AA-large' // 3 normal, 4.5 high-contrast (auto -> AAA-large)
281
- contrast: 'AAA' // 7 both modes (top tier, unchanged)
282
- contrast: 'AAA-large' // 4.5 both modes (top tier, unchanged)
283
- contrast: 5.5 // 5.5 both modes (bare number, no successor tier)
284
- contrast: { wcag: ['AA', 'AA'] } // 4.5 both modes (explicit HC, no promotion)
159
+ theme.tasty({ format: 'okhst' });
285
160
  ```
286
161
 
287
- `AAA` and `AAA-large` are already the top WCAG tier and are left unchanged; bare
288
- numeric targets have no defined successor tier and are also left unchanged. An
289
- explicit HC value via either pair overrides and skips the promotion.
290
-
291
- ## Verification (APCA / WCAG drift)
292
-
293
- Because chromatic swatches inherit gray's tone-derived lightness but drift in
294
- real luminance, a color resolved with a `base` + `contrast` may land slightly
295
- under the contrast its tone implies. After resolving such a color, Glaze
296
- computes the actual WCAG ratio and APCA Lc of the chromatic result against its
297
- base and emits a deduped `console.warn` when it drifts below the gray-tone
298
- expectation. This is advisory: it surfaces the one approximation rather than
299
- hiding it. The dedupe cache is the existing 256-entry cache in
300
- [warnings.ts](../src/warnings.ts).
162
+ ## Bringing existing colors into Glaze
301
163
 
302
- ## Migration from `lightness`
164
+ When moving raw CSS colors or design tokens into Glaze, use `fromHex()`,
165
+ `fromRgb()`, or `glaze.color()` to preserve the source color while you establish
166
+ theme seeds and semantic relationships. Then replace one-off values with roots,
167
+ tone deltas, and contrast floors as appropriate.
303
168
 
304
- `lightness` (OKHSL `l`, 0–100) is replaced by `tone` (0–100). They are **not**
305
- the same number — tone is the contrast-uniform reparameterization. To convert an
306
- old absolute `lightness: L` to the equivalent `tone`, use
307
- `toTone(L/100, 0.05)`. See [migration.md](migration.md) for the full guide and
308
- a conversion table.
169
+ See [migration.md](migration.md#migrating-from-an-existing-color-system) for
170
+ the implementation workflow.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenphi/glaze",
3
- "version": "0.19.0",
3
+ "version": "1.0.0",
4
4
  "description": "OKHST-based color theme generator with WCAG contrast solving for light, dark, and high-contrast schemes",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",