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

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 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 so light mode tracks the previous pipeline closely while the axis stays
142
+ contrast-uniform; the dark window is a clean default rather than a curve fit
143
+ (the old Möbius curve was intentionally non-uniform, which is what we are
144
+ replacing). The windows were calibrated by minimizing RMSE against the legacy
145
+ light/dark lightness mapping over the authored 0–100 range, with `eps` pinned to
146
+ the reference value `0.05` so the tone axis stays WCAG-uniform.
147
+
148
+ | Config | lo | hi | eps |
149
+ |---|---|---|---|
150
+ | `lightTone` | 13 | 100 | 0.05 |
151
+ | `darkTone` | 10 | 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenphi/glaze",
3
- "version": "0.0.0-snapshot.78261ef",
3
+ "version": "0.0.0-snapshot.7dca259",
4
4
  "description": "OKHSL-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",
@@ -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": {
@@ -73,10 +74,5 @@
73
74
  "typescript-eslint": "^8.56.0",
74
75
  "vitest": "^4.0.18"
75
76
  },
76
- "pnpm": {
77
- "onlyBuiltDependencies": [
78
- "esbuild"
79
- ]
80
- },
81
- "packageManager": "pnpm@10.29.3"
77
+ "packageManager": "pnpm@11.0.8"
82
78
  }