@tenphi/glaze 0.0.0-snapshot.c84faa6 → 0.0.0-snapshot.d38eee5
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 +30 -862
- package/dist/index.cjs +2488 -735
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +857 -92
- package/dist/index.d.mts +857 -92
- package/dist/index.mjs +2467 -735
- package/dist/index.mjs.map +1 -1
- package/docs/api.md +1287 -0
- package/docs/methodology.md +346 -0
- package/docs/migration.md +308 -0
- package/docs/okhst.md +259 -0
- package/package.json +9 -9
package/docs/okhst.md
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
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),
|
|
159
|
+
`autoFlip = true`.
|
|
160
|
+
|
|
161
|
+
Reference: `REF_EPS = 0.05`.
|
|
162
|
+
|
|
163
|
+
## Contrast metric (unified)
|
|
164
|
+
|
|
165
|
+
`contrast` is a single prop with a pluggable metric:
|
|
166
|
+
|
|
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> };
|
|
173
|
+
|
|
174
|
+
contrast?: HCPair<ContrastSpec>;
|
|
175
|
+
```
|
|
176
|
+
|
|
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:
|
|
223
|
+
|
|
224
|
+
```ts
|
|
225
|
+
type ApcaPreset = 'preferred' | 'body' | 'content' | 'large' | 'non-text' | 'min';
|
|
226
|
+
```
|
|
227
|
+
|
|
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" |
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
contrast: { apca: 'content' } // Lc 60
|
|
239
|
+
contrast: { apca: ['content', 'body'] } // HC pair: 60 normal, 75 high-contrast
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
## Verification (APCA / WCAG drift)
|
|
243
|
+
|
|
244
|
+
Because chromatic swatches inherit gray's tone-derived lightness but drift in
|
|
245
|
+
real luminance, a color resolved with a `base` + `contrast` may land slightly
|
|
246
|
+
under the contrast its tone implies. After resolving such a color, Glaze
|
|
247
|
+
computes the actual WCAG ratio and APCA Lc of the chromatic result against its
|
|
248
|
+
base and emits a deduped `console.warn` when it drifts below the gray-tone
|
|
249
|
+
expectation. This is advisory: it surfaces the one approximation rather than
|
|
250
|
+
hiding it. The dedupe cache is the existing 256-entry cache in
|
|
251
|
+
[warnings.ts](../src/warnings.ts).
|
|
252
|
+
|
|
253
|
+
## Migration from `lightness`
|
|
254
|
+
|
|
255
|
+
`lightness` (OKHSL `l`, 0–100) is replaced by `tone` (0–100). They are **not**
|
|
256
|
+
the same number — tone is the contrast-uniform reparameterization. To convert an
|
|
257
|
+
old absolute `lightness: L` to the equivalent `tone`, use
|
|
258
|
+
`toTone(L/100, 0.05)`. See [migration.md](migration.md) for the full guide and
|
|
259
|
+
a conversion table.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tenphi/glaze",
|
|
3
|
-
"version": "0.0.0-snapshot.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.0.0-snapshot.d38eee5",
|
|
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",
|
|
7
7
|
"module": "./dist/index.mjs",
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
}
|
|
20
20
|
},
|
|
21
21
|
"files": [
|
|
22
|
-
"dist"
|
|
22
|
+
"dist",
|
|
23
|
+
"docs"
|
|
23
24
|
],
|
|
24
25
|
"sideEffects": false,
|
|
25
26
|
"engines": {
|
|
@@ -27,6 +28,8 @@
|
|
|
27
28
|
},
|
|
28
29
|
"scripts": {
|
|
29
30
|
"build": "tsdown",
|
|
31
|
+
"playground": "vite playground",
|
|
32
|
+
"build:playground": "vite build playground",
|
|
30
33
|
"test": "vitest run",
|
|
31
34
|
"test:watch": "vitest",
|
|
32
35
|
"test:coverage": "vitest run --coverage",
|
|
@@ -47,6 +50,7 @@
|
|
|
47
50
|
"keywords": [
|
|
48
51
|
"color",
|
|
49
52
|
"theme",
|
|
53
|
+
"okhst",
|
|
50
54
|
"okhsl",
|
|
51
55
|
"contrast",
|
|
52
56
|
"wcag",
|
|
@@ -71,12 +75,8 @@
|
|
|
71
75
|
"tsdown": "^0.20.3",
|
|
72
76
|
"typescript": "^5.9.3",
|
|
73
77
|
"typescript-eslint": "^8.56.0",
|
|
78
|
+
"vite": "^8.0.16",
|
|
74
79
|
"vitest": "^4.0.18"
|
|
75
80
|
},
|
|
76
|
-
"
|
|
77
|
-
"onlyBuiltDependencies": [
|
|
78
|
-
"esbuild"
|
|
79
|
-
]
|
|
80
|
-
},
|
|
81
|
-
"packageManager": "pnpm@10.29.3"
|
|
81
|
+
"packageManager": "pnpm@11.0.8"
|
|
82
82
|
}
|