Skip to content

Commit a50183d

Browse files
authored
fix: make format* take s/l/t on the 0–1 scale (#94)
1 parent 2b503ae commit a50183d

9 files changed

Lines changed: 334 additions & 32 deletions

File tree

.changeset/plain-scales-align.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@tenphi/glaze': major
3+
---
4+
5+
**Breaking:** the `format*` writers now take `s` / `l` / `t` on the 0–1 scale the Glaze converters return.
6+
7+
`formatOkhsl`, `formatOkhst`, `formatRgb`, `formatHsl`, and `formatOklch` took 0–100 percentages while `resolve()`, `variantToOkhsl`, `srgbToOkhsl`, `oklabToOkhsl`, and `okhslToSrgb` all return 0–1 — so composing a producer with a writer was off by 100x and failed silently, since `0.7` is a legal percentage and the result was a valid CSS string naming a near-black color. The library now speaks one scale end to end. (`toTone` / `fromTone` still speak the 0–100 tone axis the authoring API takes — divide by 100 before handing a tone to `formatOkhst`.)
8+
9+
Drop the `* 100` at the call site (`formatOkhst(v.h, v.s, v.t)`); a leftover one now warns instead of shifting the color quietly. Every export method — `css()`, `tokens()`, `tasty()`, `json()`, `dtcg()`, `tailwind()`, `glaze.format()` — emits the same colors as before: they were compensating internally, and dropping the redundant `×100 ÷100` round-trip only moves float noise (visible nowhere except the meaningless hue term of a fully-desaturated `hsl()` string).

AGENTS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,15 @@ glaze/
4343
| [src/shadow.ts](src/shadow.ts) | Shadow + mix def predicates (`isShadowDef`, `isMixDef`), default `ShadowTuning`, tuning merge, the actual `computeShadow` math (hue blend, saturation cap, lightness clamp, `tanh` alpha curve) operating on OKHSL lightness at the edge, and `circularLerp` for hue. |
4444
| [src/hc-pair.ts](src/hc-pair.ts) | Tiny shared helpers: HC-pair selection (`pairNormal` / `pairHC`), HC-pair interpolation for the manual contrast level (`contrastFraction`, `hcMirrorsNormal` — the level-100 "HC slots are duplicates" predicate, `levelFraction`, `numberAt`, `parseToneValueAt`, `PAIR_SWITCH`), tone-value parsing (`parseToneValue` for absolute / relative / `'max'`/`'min'` extremes, `isExtremeTone`), root-tone discrimination (`isAbsoluteTone`, now incl. extremes), `clamp` / `lerp`, hue resolution (`resolveEffectiveHue`), relative-value parsing. Leaf module — types only, so every other module can import it. |
4545
| [src/formatters.ts](src/formatters.ts) | Variant→string dispatch (`okhsl` / `rgb` / `hsl` / `oklch`) and the four token-map shapes Glaze emits: `buildTokenMap` ([Tasty](https://tasty.style) `#name` keys + state aliases), `buildFlatTokenMap` (per-variant maps), `buildJsonMap` (per-color JSON), `buildCssMap` (CSS custom-property declarations). For `splitHue` it emits the light hue declarations plus, via `darkHueDeclarations` in `channels.ts`, the whole dark set when a `darkHue` makes it differ. Also the `resolveModes` helper used everywhere. |
46-
| [src/okhsl-color-math.ts](src/okhsl-color-math.ts) | OKHSL ↔ linear-sRGB ↔ gamma-sRGB ↔ OKLab conversions, hex parsing (3/6/8 digits), gamut clamping, and the `formatOkhsl` / `formatRgb` / `formatHsl` / `formatOklch` writers. The only file with the actual color science. |
46+
| [src/okhsl-color-math.ts](src/okhsl-color-math.ts) | OKHSL ↔ linear-sRGB ↔ gamma-sRGB ↔ OKLab conversions, hex parsing (3/6/8 digits), gamut clamping, and the `formatOkhsl` / `formatOkhst` / `formatRgb` / `formatHsl` / `formatOklch` writers. Converters and writers share one scale — `h` 0–360, `s` / `l` / `t` 0–1 — so any converter composes with any writer (the 0–100 tone axis lives in `okhst.ts`'s `toTone` / `fromTone`, not here); `warnPercentScale` catches pre-2.0 percentage-scale input instead of emitting a plausible wrong color. The only file with the actual color science. |
4747
| [src/config.ts](src/config.ts) | Global config singleton. `defaultConfig()` is the one source of truth for defaults; `configure()` mutates the live object and bumps a monotonic `configVersion` so theme caches invalidate. `snapshotConfig()` powers `glaze.getConfig()`. `freezeConfigForExport()` freezes resolve-relevant fields for authoring export (`pastel` is instance-only; `contrastLevel` is frozen only when instance-authored, since a global level is a live preference). `normalizeContrastLevel()` validates the manual contrast level. |
4848
| [src/validation.ts](src/validation.ts) | `validateColorDefs` (missing references, shadow-bg-cannot-be-shadow, mix-cannot-target-shadow, contrast-without-base, relative-without-base, WCAG-vs-APCA metric switch in a `contrast` pair, etc.) + `topoSort` so the resolver processes each color after its dependencies. |
4949
| [src/warnings.ts](src/warnings.ts) | Deduped contrast-unmet warnings. Caps cache at 256 entries to keep dev-server output bounded. |
5050
| [src/types.ts](src/types.ts) | All public TypeScript types: `HCPair`, `MinContrast`, `ContrastSpec`, `RelativeValue`, `ExtremeValue` (`'max'`/`'min'`), `ToneValue`, `AdaptationMode`, `OkhstColor`, `ToneWindow` (`[lo,hi]` \| `{lo,hi,eps}` \| `false`), `ColorDef` discriminated union (with `tone` + `flip` + `darkHue` / `darkSaturation`), `GlazeConfig` (with `lightTone` / `darkTone` / `autoFlip` / `contrastLevel`), `GlazeThemeSeed` (`hue` / `saturation` + optional dark pair), `GlazeTheme`, `GlazePalette`, `GlazeColorToken`, the `*Export` shapes, etc. `ResolvedColorVariant` stores `{ h, s, t, alpha }`. |
5151
| `src/glaze.test.ts` | Main test suite — covers the factory surface, resolver behavior, palette composition, shadow/mix algorithms, the manual contrast level, and the standalone color token round-trip. |
5252
| `src/contrast-solver.test.ts` | Tests for the binary-search solver in isolation. |
5353
| `src/hc-pair.test.ts` | Tests for the pure HC-pair selection and interpolation helpers. |
54+
| `src/okhsl-color-math.test.ts` | Tests for the format writers' 0–1 scale contract, the percentage-scale guard, and producer→writer composition. |
5455

5556
### `docs/` files
5657

docs/api.md

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1896,7 +1896,7 @@ import {
18961896
| Function | Description |
18971897
| ------------------------------------- | ------------------------------------------------------------------------ |
18981898
| `okhslToLinearSrgb(h, s, l)` | OKHSL (h: 0–360, s/l: 0–1) → linear sRGB tuple. |
1899-
| `okhslToSrgb(h, s, l)` | OKHSL → gamma-encoded sRGB tuple (0–1 per channel). |
1899+
| `okhslToSrgb(h, s, l)` | OKHSL (h: 0–360, s/l: 0–1) → gamma-encoded sRGB tuple (0–1 per channel). |
19001900
| `okhslToOklab([h, s, l])` | OKHSL → OKLab `[L, a, b]`. |
19011901
| `oklabToOkhsl([L, a, b])` | OKLab → OKHSL. |
19021902
| `srgbToOkhsl([r, g, b])` | Gamma sRGB (0–1) → OKHSL. |
@@ -1909,15 +1909,44 @@ import {
19091909

19101910
### Format writers
19111911

1912+
Every writer takes `h` on 0–360 and `s` / `l` / `t` on **0–1** — the scale every
1913+
conversion above *returns*, and the scale `resolve()` stores in a
1914+
`ResolvedColorVariant`. The percentages are an output detail: the writers scale
1915+
by 100 themselves where the CSS syntax asks for one. (The one thing on a
1916+
different scale is the pair of tone transfers below, `toTone` / `fromTone`,
1917+
which speak the 0–100 tone axis the authoring API takes.)
1918+
19121919
```ts
1913-
import { formatOkhsl, formatRgb, formatHsl, formatOklch } from '@tenphi/glaze';
1920+
import {
1921+
formatOkhsl,
1922+
formatOkhst,
1923+
formatRgb,
1924+
formatHsl,
1925+
formatOklch,
1926+
} from '@tenphi/glaze';
19141927

1915-
formatOkhsl(280, 60, 95); // 'okhsl(280 60% 95%)'
1916-
formatRgb(280, 60, 95); // 'rgb(244 240 250)'
1917-
formatHsl(280, 60, 95); // 'hsl(280 60% 95%)'
1918-
formatOklch(280, 60, 95); // 'oklch(0.95 ... 280)'
1928+
formatOkhsl(280, 0.6, 0.95); // 'okhsl(280 60% 95%)'
1929+
formatOkhst(280, 0.6, 0.95); // 'okhst(280 60% 95%)'
1930+
formatRgb(280, 0.6, 0.95); // 'rgb(238.45 239.95 251.1)'
1931+
formatHsl(280, 0.6, 0.95); // 'hsl(232.92 61.87% 95.99%)'
1932+
formatOklch(280, 0.6, 0.95); // 'oklch(0.9571 0.015 280)'
19191933
```
19201934

1935+
So a producer composes with a writer directly, with nothing to rescale in
1936+
between:
1937+
1938+
```ts
1939+
const v = glaze.color('#7A4DBF').resolve().light;
1940+
const { h, s, l } = variantToOkhsl(v);
1941+
1942+
formatOkhst(v.h, v.s, v.t); // 'okhst(298.52 70.41% 45.02%)'
1943+
formatOkhsl(h, s, l); // 'okhsl(298.52 70.41% 45.27%)'
1944+
formatRgb(h, s, l); // 'rgb(122 77 191)'
1945+
```
1946+
1947+
A value above 1 can only be pre-2.0 percentage-scale input, so the writers
1948+
`console.warn` about it once per writer rather than emit a wrong color quietly.
1949+
19211950
To attach an alpha component, use `glaze.format(variant, format)` on a `ResolvedColorVariant` (which carries the `alpha` channel) instead of these raw writers.
19221951

19231952
### OKHST tone utilities
@@ -1937,12 +1966,12 @@ import {
19371966

19381967
| Function | Description |
19391968
| ------------------------------------------- | ------------------------------------------------------------------------- |
1940-
| `toTone(l, eps?)` | OKHSL lightness (0–1) → tone (0–100). Defaults to `REF_EPS`. |
1941-
| `fromTone(t, eps?)` | Tone (0–100) → OKHSL lightness (0–1). Inverse of `toTone`. |
1969+
| `toTone(l, eps?)` | OKHSL lightness (0–1) → tone (**0–100**, the authoring scale — divide by 100 for `formatOkhst`). Defaults to `REF_EPS`. |
1970+
| `fromTone(t, eps?)` | Tone (**0–100**) → OKHSL lightness (0–1). Inverse of `toTone`. |
19421971
| `toneFromY(y, eps?)` / `yFromTone(t, eps?)` | Same transfer in luminance space (0–1). |
19431972
| `okhstToOkhsl({ h, s, t })` | OKHST → OKHSL (`{ h, s, l }`). |
19441973
| `okhslToOkhst({ h, s, l })` | OKHSL → OKHST (`{ h, s, t }`). |
1945-
| `variantToOkhsl(variant)` | `ResolvedColorVariant` (stores `t`) → `{ h, s, l, alpha }` for rendering. |
1974+
| `variantToOkhsl(variant)` | `ResolvedColorVariant` (stores `t`) → `{ h, s, l }` for rendering (`alpha` stays on the variant). |
19461975
| `REF_EPS` | Reference epsilon (`0.05`) for the canonical tone axis. |
19471976

19481977
`ResolvedColorVariant` stores `{ h, s, t, alpha }` (tone, not lightness). Use

docs/migration.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ If you're starting from scratch, see [methodology.md](methodology.md) first —
1212
- [Wiring exports into the app](#wiring-exports-into-the-app)
1313
- [Prefix map strategies](#prefix-map-strategies)
1414
- [Migrating an existing color system](#migrating-from-an-existing-color-system)
15+
- [Upgrading Glaze](#upgrading-glaze)
1516
- [Common pitfalls](#common-pitfalls)
1617

1718
## Choosing an export
@@ -296,6 +297,40 @@ Glaze gives you light/dark/HC for free, but only the light mode is matched again
296297

297298
After migration, mark every default-only token (borders, shadows, disabled chip, code highlighting, etc.) `inherit: false`. Colored sibling themes only need the accent + tinted-surface chain — flagging the rest cuts the emitted token set per theme dramatically.
298299

300+
## Upgrading Glaze
301+
302+
### 2.0 — `format*` takes 0–1
303+
304+
The `format*` writers (`formatOkhsl`, `formatOkhst`, `formatRgb`, `formatHsl`,
305+
`formatOklch`) used to take `s` / `l` / `t` on a 0–100 percentage scale while
306+
everything that *produces* those values — `resolve()`, `variantToOkhsl`,
307+
`srgbToOkhsl`, `oklabToOkhsl`, `okhslToSrgb` — returns them on 0–1. Composing
308+
the two, which is the obvious thing to do, was off by 100x and failed silently:
309+
`0.7` is a legal percentage, so you got a valid CSS string naming the wrong
310+
(near-black) color.
311+
312+
The writers now take the 0–1 factors, so the whole library speaks one scale.
313+
Drop the `* 100`:
314+
315+
```diff
316+
- formatOkhsl(v.h, v.s * 100, l * 100);
317+
+ formatOkhsl(v.h, v.s, l);
318+
319+
- formatOkhst(v.h, v.s * 100, v.t * 100);
320+
+ formatOkhst(v.h, v.s, v.t);
321+
```
322+
323+
Nothing else moves: `h` was always 0–360, `alpha` was always 0–1, and every
324+
export method (`css()` / `tokens()` / `json()` / `tasty()` / `dtcg()` /
325+
`tailwind()` / `glaze.format()`) emits the same colors — they were compensating
326+
internally. Dropping the redundant `×100 ÷100` round-trip shifts float noise by
327+
an ULP, which shows up nowhere except the (meaningless) hue term of a
328+
fully-desaturated `hsl()` string.
329+
330+
A leftover `* 100` is not silent any more: a value above 1 cannot be a factor,
331+
so the writers `console.warn` once per writer and you get an obviously-broken
332+
`7040.68%` instead of a plausible wrong color.
333+
299334
## Common pitfalls
300335

301336
| Symptom | Cause | Fix |
@@ -307,6 +342,7 @@ After migration, mark every default-only token (borders, shadows, disabled chip,
307342
| 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 (`autoFlip` inherits `autoFlip`). | Set `autoFlip: false` on the color to clamp to the boundary instead, or use `tone: 'max'`/`'min'` to force the extreme. |
308343
| `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 })`. |
309344
| `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. |
345+
| `console.warn: formatOkhsl() got a value above 1`. | Pre-2.0 percentage-scale input to a `format*` writer (a leftover `* 100`). | Pass the 0–1 factors the converters return — see [2.0 — `format*` takes 0–1](#20--format-takes-01). |
310346
| `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. |
311347

312348
## See also

playground/palette.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,9 @@ export function buildStep(hue, saturation, tone, pastel = false, lo = 0, hi = 10
6565
const okhsl = variantToOkhsl(variant);
6666
const rgb = okhslToSrgb(okhsl.h, okhsl.s, okhsl.l, pastel);
6767

68-
const fmtOkhsl = formatOkhsl(okhsl.h, okhsl.s * 100, okhsl.l * 100, pastel);
69-
const fmtOklch = formatOklch(okhsl.h, okhsl.s * 100, okhsl.l * 100, pastel);
70-
const fmtRgb = formatRgb(okhsl.h, okhsl.s * 100, okhsl.l * 100, pastel);
68+
const fmtOkhsl = formatOkhsl(okhsl.h, okhsl.s, okhsl.l, pastel);
69+
const fmtOklch = formatOklch(okhsl.h, okhsl.s, okhsl.l, pastel);
70+
const fmtRgb = formatRgb(okhsl.h, okhsl.s, okhsl.l, pastel);
7171
const fmtOkhst = `okhst(${parseFloat(hue.toFixed(2))} ${parseFloat(saturation.toFixed(2))}% ${parseFloat(tone.toFixed(2))})`;
7272

7373
return {

src/formatters.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,10 @@ export function formatVariant(
7575

7676
let base: string;
7777
if (format === 'okhst') {
78-
base = formatOkhst(v.h, v.s * 100, v.t * 100, effectivePastel);
78+
base = formatOkhst(v.h, v.s, v.t, effectivePastel);
7979
} else {
8080
const { l } = variantToOkhsl(v);
81-
base = formatters[format](v.h, v.s * 100, l * 100, effectivePastel);
81+
base = formatters[format](v.h, v.s, l, effectivePastel);
8282
}
8383

8484
if (v.alpha >= 1) return base;
@@ -104,7 +104,7 @@ export function formatVariantHue(
104104
if (v.s <= 1e-6) {
105105
base = `oklch(${fmt(L, 4)} 0 0)`;
106106
} else {
107-
base = formatOklch(v.h, v.s * 100, l * 100, effectivePastel);
107+
base = formatOklch(v.h, v.s, l, effectivePastel);
108108
}
109109
} else {
110110
base = `oklch(${fmt(L, 4)} ${fmt(C, 4)} ${plan.hueVar})`;

0 commit comments

Comments
 (0)