diff --git a/.changeset/plain-scales-align.md b/.changeset/plain-scales-align.md new file mode 100644 index 0000000..a42e6a6 --- /dev/null +++ b/.changeset/plain-scales-align.md @@ -0,0 +1,9 @@ +--- +'@tenphi/glaze': major +--- + +**Breaking:** the `format*` writers now take `s` / `l` / `t` on the 0–1 scale the Glaze converters return. + +`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`.) + +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). diff --git a/AGENTS.md b/AGENTS.md index 149ab67..4a6a7de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ glaze/ | [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. | | [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. | | [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. | -| [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. | +| [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. | | [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. | | [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. | | [src/warnings.ts](src/warnings.ts) | Deduped contrast-unmet warnings. Caps cache at 256 entries to keep dev-server output bounded. | @@ -51,6 +51,7 @@ glaze/ | `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. | | `src/contrast-solver.test.ts` | Tests for the binary-search solver in isolation. | | `src/hc-pair.test.ts` | Tests for the pure HC-pair selection and interpolation helpers. | +| `src/okhsl-color-math.test.ts` | Tests for the format writers' 0–1 scale contract, the percentage-scale guard, and producer→writer composition. | ### `docs/` files diff --git a/docs/api.md b/docs/api.md index 0f3d990..7df16c8 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1896,7 +1896,7 @@ import { | Function | Description | | ------------------------------------- | ------------------------------------------------------------------------ | | `okhslToLinearSrgb(h, s, l)` | OKHSL (h: 0–360, s/l: 0–1) → linear sRGB tuple. | -| `okhslToSrgb(h, s, l)` | OKHSL → gamma-encoded sRGB tuple (0–1 per channel). | +| `okhslToSrgb(h, s, l)` | OKHSL (h: 0–360, s/l: 0–1) → gamma-encoded sRGB tuple (0–1 per channel). | | `okhslToOklab([h, s, l])` | OKHSL → OKLab `[L, a, b]`. | | `oklabToOkhsl([L, a, b])` | OKLab → OKHSL. | | `srgbToOkhsl([r, g, b])` | Gamma sRGB (0–1) → OKHSL. | @@ -1909,15 +1909,44 @@ import { ### Format writers +Every writer takes `h` on 0–360 and `s` / `l` / `t` on **0–1** — the scale every +conversion above *returns*, and the scale `resolve()` stores in a +`ResolvedColorVariant`. The percentages are an output detail: the writers scale +by 100 themselves where the CSS syntax asks for one. (The one thing on a +different scale is the pair of tone transfers below, `toTone` / `fromTone`, +which speak the 0–100 tone axis the authoring API takes.) + ```ts -import { formatOkhsl, formatRgb, formatHsl, formatOklch } from '@tenphi/glaze'; +import { + formatOkhsl, + formatOkhst, + formatRgb, + formatHsl, + formatOklch, +} from '@tenphi/glaze'; -formatOkhsl(280, 60, 95); // 'okhsl(280 60% 95%)' -formatRgb(280, 60, 95); // 'rgb(244 240 250)' -formatHsl(280, 60, 95); // 'hsl(280 60% 95%)' -formatOklch(280, 60, 95); // 'oklch(0.95 ... 280)' +formatOkhsl(280, 0.6, 0.95); // 'okhsl(280 60% 95%)' +formatOkhst(280, 0.6, 0.95); // 'okhst(280 60% 95%)' +formatRgb(280, 0.6, 0.95); // 'rgb(238.45 239.95 251.1)' +formatHsl(280, 0.6, 0.95); // 'hsl(232.92 61.87% 95.99%)' +formatOklch(280, 0.6, 0.95); // 'oklch(0.9571 0.015 280)' ``` +So a producer composes with a writer directly, with nothing to rescale in +between: + +```ts +const v = glaze.color('#7A4DBF').resolve().light; +const { h, s, l } = variantToOkhsl(v); + +formatOkhst(v.h, v.s, v.t); // 'okhst(298.52 70.41% 45.02%)' +formatOkhsl(h, s, l); // 'okhsl(298.52 70.41% 45.27%)' +formatRgb(h, s, l); // 'rgb(122 77 191)' +``` + +A value above 1 can only be pre-2.0 percentage-scale input, so the writers +`console.warn` about it once per writer rather than emit a wrong color quietly. + To attach an alpha component, use `glaze.format(variant, format)` on a `ResolvedColorVariant` (which carries the `alpha` channel) instead of these raw writers. ### OKHST tone utilities @@ -1937,12 +1966,12 @@ import { | Function | Description | | ------------------------------------------- | ------------------------------------------------------------------------- | -| `toTone(l, eps?)` | OKHSL lightness (0–1) → tone (0–100). Defaults to `REF_EPS`. | -| `fromTone(t, eps?)` | Tone (0–100) → OKHSL lightness (0–1). Inverse of `toTone`. | +| `toTone(l, eps?)` | OKHSL lightness (0–1) → tone (**0–100**, the authoring scale — divide by 100 for `formatOkhst`). Defaults to `REF_EPS`. | +| `fromTone(t, eps?)` | Tone (**0–100**) → OKHSL lightness (0–1). Inverse of `toTone`. | | `toneFromY(y, eps?)` / `yFromTone(t, eps?)` | Same transfer in luminance space (0–1). | | `okhstToOkhsl({ h, s, t })` | OKHST → OKHSL (`{ h, s, l }`). | | `okhslToOkhst({ h, s, l })` | OKHSL → OKHST (`{ h, s, t }`). | -| `variantToOkhsl(variant)` | `ResolvedColorVariant` (stores `t`) → `{ h, s, l, alpha }` for rendering. | +| `variantToOkhsl(variant)` | `ResolvedColorVariant` (stores `t`) → `{ h, s, l }` for rendering (`alpha` stays on the variant). | | `REF_EPS` | Reference epsilon (`0.05`) for the canonical tone axis. | `ResolvedColorVariant` stores `{ h, s, t, alpha }` (tone, not lightness). Use diff --git a/docs/migration.md b/docs/migration.md index c6d9244..033925c 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -12,6 +12,7 @@ If you're starting from scratch, see [methodology.md](methodology.md) first — - [Wiring exports into the app](#wiring-exports-into-the-app) - [Prefix map strategies](#prefix-map-strategies) - [Migrating an existing color system](#migrating-from-an-existing-color-system) +- [Upgrading Glaze](#upgrading-glaze) - [Common pitfalls](#common-pitfalls) ## Choosing an export @@ -296,6 +297,40 @@ Glaze gives you light/dark/HC for free, but only the light mode is matched again 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. +## Upgrading Glaze + +### 2.0 — `format*` takes 0–1 + +The `format*` writers (`formatOkhsl`, `formatOkhst`, `formatRgb`, `formatHsl`, +`formatOklch`) used to take `s` / `l` / `t` on a 0–100 percentage scale while +everything that *produces* those values — `resolve()`, `variantToOkhsl`, +`srgbToOkhsl`, `oklabToOkhsl`, `okhslToSrgb` — returns them on 0–1. Composing +the two, which is the obvious thing to do, was off by 100x and failed silently: +`0.7` is a legal percentage, so you got a valid CSS string naming the wrong +(near-black) color. + +The writers now take the 0–1 factors, so the whole library speaks one scale. +Drop the `* 100`: + +```diff +- formatOkhsl(v.h, v.s * 100, l * 100); ++ formatOkhsl(v.h, v.s, l); + +- formatOkhst(v.h, v.s * 100, v.t * 100); ++ formatOkhst(v.h, v.s, v.t); +``` + +Nothing else moves: `h` was always 0–360, `alpha` was always 0–1, and every +export method (`css()` / `tokens()` / `json()` / `tasty()` / `dtcg()` / +`tailwind()` / `glaze.format()`) emits the same colors — they were compensating +internally. Dropping the redundant `×100 ÷100` round-trip shifts float noise by +an ULP, which shows up nowhere except the (meaningless) hue term of a +fully-desaturated `hsl()` string. + +A leftover `* 100` is not silent any more: a value above 1 cannot be a factor, +so the writers `console.warn` once per writer and you get an obviously-broken +`7040.68%` instead of a plausible wrong color. + ## Common pitfalls | Symptom | Cause | Fix | @@ -307,6 +342,7 @@ After migration, mark every default-only token (borders, shadows, disabled chip, | 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. | | `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 })`. | | `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. | +| `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). | | `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. | ## See also diff --git a/playground/palette.js b/playground/palette.js index d316c25..bbd5705 100644 --- a/playground/palette.js +++ b/playground/palette.js @@ -65,9 +65,9 @@ export function buildStep(hue, saturation, tone, pastel = false, lo = 0, hi = 10 const okhsl = variantToOkhsl(variant); const rgb = okhslToSrgb(okhsl.h, okhsl.s, okhsl.l, pastel); - const fmtOkhsl = formatOkhsl(okhsl.h, okhsl.s * 100, okhsl.l * 100, pastel); - const fmtOklch = formatOklch(okhsl.h, okhsl.s * 100, okhsl.l * 100, pastel); - const fmtRgb = formatRgb(okhsl.h, okhsl.s * 100, okhsl.l * 100, pastel); + const fmtOkhsl = formatOkhsl(okhsl.h, okhsl.s, okhsl.l, pastel); + const fmtOklch = formatOklch(okhsl.h, okhsl.s, okhsl.l, pastel); + const fmtRgb = formatRgb(okhsl.h, okhsl.s, okhsl.l, pastel); const fmtOkhst = `okhst(${parseFloat(hue.toFixed(2))} ${parseFloat(saturation.toFixed(2))}% ${parseFloat(tone.toFixed(2))})`; return { diff --git a/src/formatters.ts b/src/formatters.ts index 6c7c2c5..045a025 100644 --- a/src/formatters.ts +++ b/src/formatters.ts @@ -75,10 +75,10 @@ export function formatVariant( let base: string; if (format === 'okhst') { - base = formatOkhst(v.h, v.s * 100, v.t * 100, effectivePastel); + base = formatOkhst(v.h, v.s, v.t, effectivePastel); } else { const { l } = variantToOkhsl(v); - base = formatters[format](v.h, v.s * 100, l * 100, effectivePastel); + base = formatters[format](v.h, v.s, l, effectivePastel); } if (v.alpha >= 1) return base; @@ -104,7 +104,7 @@ export function formatVariantHue( if (v.s <= 1e-6) { base = `oklch(${fmt(L, 4)} 0 0)`; } else { - base = formatOklch(v.h, v.s * 100, l * 100, effectivePastel); + base = formatOklch(v.h, v.s, l, effectivePastel); } } else { base = `oklch(${fmt(L, 4)} ${fmt(C, 4)} ${plan.hueVar})`; diff --git a/src/okhsl-color-math.test.ts b/src/okhsl-color-math.test.ts new file mode 100644 index 0000000..07d332f --- /dev/null +++ b/src/okhsl-color-math.test.ts @@ -0,0 +1,171 @@ +import { + formatHsl, + formatOkhsl, + formatOkhst, + formatOklch, + formatRgb, + okhslToOklab, + okhslToOklch, + okhslToSrgb, + oklabToOkhsl, + parseHex, + resetScaleWarnings, + srgbToHex, + srgbToOkhsl, +} from './okhsl-color-math'; +import { glaze } from './glaze'; +import { okhslToOkhst, variantToOkhsl } from './okhst'; + +/** + * The scale contract: every `format*` writer takes `s` / `l` / `t` on the same + * 0–1 factor scale every converter in the library *returns*, so composing a + * producer with a formatter is correct with no rescaling in between. The + * percentages CSS wants are the writers' own business. + * + * This is the whole point of the tests below — the previous 0–100 signature + * made the obvious composition off by 100x, and it failed silently because + * `0.7` is a legal percentage. + */ +describe('format* scale', () => { + const HEX = '#7A4DBF'; + + it('reads s / l as 0–1 and emits them as percentages', () => { + expect(formatOkhsl(280, 0.6, 0.95)).toBe('okhsl(280 60% 95%)'); + expect(formatOkhst(280, 0.6, 0.95)).toBe('okhst(280 60% 95%)'); + }); + + it('treats 1 as full saturation / lightness, not 1%', () => { + // s = 1 is the top of the scale, so the emitted percentage is 100%. + expect(formatOkhsl(280, 1, 1)).toBe('okhsl(280 100% 100%)'); + // ...and the color is the corresponding near-white, not a near-black. + expect(srgbToHex(okhslToSrgb(280, 1, 1))).toBe('#ffffff'); + }); + + it('agrees with the converters it wraps, given the same 0–1 inputs', () => { + const [h, s, l] = srgbToOkhsl(parseHex(HEX)!); + + const [r, g, b] = okhslToSrgb(h, s, l); + expect(formatRgb(h, s, l)).toBe( + `rgb(${parseFloat((r * 255).toFixed(2))} ${parseFloat((g * 255).toFixed(2))} ${parseFloat((b * 255).toFixed(2))})`, + ); + + const [L, C, hh] = okhslToOklch(h, s, l); + expect(formatOklch(h, s, l)).toBe( + `oklch(${parseFloat(L.toFixed(4))} ${parseFloat(C.toFixed(4))} ${parseFloat(hh.toFixed(2))})`, + ); + }); + + it('round-trips a hex through srgbToOkhsl and the rgb writer', () => { + const [h, s, l] = srgbToOkhsl(parseHex(HEX)!); + const rgb = formatRgb(h, s, l) + .slice(4, -1) + .split(' ') + .map((n) => Math.round(Number(n)) / 255) as [number, number, number]; + expect(srgbToHex(rgb)).toBe(HEX.toLowerCase()); + }); + + it('recomputes a pastel saturation on the 0–1 scale', () => { + const [h, s, l] = [280, 0.8, 0.6]; + // The pastel branch re-derives the equivalent non-pastel `s` via OKLab so + // external parsers render it identically; it must feed the converter the + // factor it was given, not a factor scaled by 100. + const expected = oklabToOkhsl(okhslToOklab(h, s, l, true), false)[1]; + expect(formatOkhsl(h, s, l, true)).toBe( + `okhsl(280 ${parseFloat((expected * 100).toFixed(2))}% 60%)`, + ); + }); + + it('produces a near-black only for a genuinely near-black tone', () => { + // The old failure mode: s / t of 0.7 / 0.45 read as percentages. Those are + // legitimate values now, and 0.007 / 0.0045 is what actually names the + // near-black they used to be mistaken for. + expect(formatOkhst(298.52, 0.7, 0.45)).toBe('okhst(298.52 70% 45%)'); + expect(formatOkhst(298.52, 0.007, 0.0045)).toBe('okhst(298.52 0.7% 0.45%)'); + }); + + /** + * Values above 1 cannot be factors, so they can only be pre-2.0 + * percentage-scale input. The point of the warning is that the old call + * shape stops being silent — it used to emit a plausible wrong color. + */ + describe('percentage-scale guard', () => { + let warn: ReturnType; + + beforeEach(() => { + resetScaleWarnings(); + warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + warn.mockRestore(); + resetScaleWarnings(); + }); + + it('warns when handed 0–100 input', () => { + formatOkhsl(280, 60, 95); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain('formatOkhsl'); + }); + + it('warns once per writer, not once per call', () => { + formatOkhsl(280, 60, 95); + formatOkhsl(280, 40, 20); + expect(warn).toHaveBeenCalledTimes(1); + + formatOklch(280, 60, 95); + expect(warn).toHaveBeenCalledTimes(2); + }); + + it('stays quiet for in-range values, including the endpoints', () => { + for (const [s, l] of [ + [0, 0], + [0.6, 0.95], + [1, 1], + ]) { + formatOkhsl(280, s, l); + formatOkhst(280, s, l); + formatRgb(280, s, l); + formatHsl(280, s, l); + formatOklch(280, s, l); + } + expect(warn).not.toHaveBeenCalled(); + }); + + it('ignores hue, which is on its own 0–360 scale', () => { + formatOkhsl(280, 0.6, 0.95); + expect(warn).not.toHaveBeenCalled(); + }); + }); + + /** + * The composition the issue reported: read a variant off `resolve()` and + * re-emit it. Nothing in between rescales, and the value survives a + * round-trip back through the parser. + */ + describe('composes with resolve()', () => { + it('re-emits a resolved variant without rescaling', () => { + const v = glaze.color(HEX).resolve().light; + + const okhst = formatOkhst(v.h, v.s, v.t); + const rt = glaze.color(okhst).resolve().light; + expect(rt.s).toBeCloseTo(v.s, 3); + expect(rt.t).toBeCloseTo(v.t, 3); + + const okhsl = variantToOkhsl(v); + const rtl = glaze + .color(formatOkhsl(okhsl.h, okhsl.s, okhsl.l)) + .resolve().light; + expect(rtl.s).toBeCloseTo(v.s, 3); + expect(okhslToOkhst(variantToOkhsl(rtl)).t).toBeCloseTo(v.t, 3); + }); + + it('keeps the color when destructured out of variantToOkhsl', () => { + // `variantToOkhsl`'s doc points at exactly this composition. + const v = glaze.color(HEX).resolve().light; + const { h, s, l } = variantToOkhsl(v); + expect(srgbToHex(okhslToSrgb(h, s, l))).toBe(HEX.toLowerCase()); + // The writer sees the same color the converter does. + expect(formatRgb(h, s, l)).toBe('rgb(122 77 191)'); + }); + }); +}); diff --git a/src/okhsl-color-math.ts b/src/okhsl-color-math.ts index 4cecbe1..46a13bb 100644 --- a/src/okhsl-color-math.ts +++ b/src/okhsl-color-math.ts @@ -460,6 +460,7 @@ export const sRGBGammaToLinear = (val: number): number => { /** * Convert OKHSL to gamma-encoded sRGB (clamped to 0–1). + * h: 0–360, s: 0–1, l: 0–1. Returns [r, g, b] with each channel 0–1. */ export function okhslToSrgb( h: number, @@ -730,9 +731,41 @@ function fmt(value: number, decimals: number): string { return parseFloat(value.toFixed(decimals)).toString(); } +/** + * Every `format*` writer takes `s` / `l` / `t` on the **0–1 factor scale** — + * the same scale every producer in the library hands back (`resolve()`, + * `variantToOkhsl`, `srgbToOkhsl`, `oklabToOkhsl`, `okhslToOklab`, + * `okhslToSrgb`). The percentages are an output detail: the writers multiply + * by 100 themselves when the CSS syntax asks for a percentage. + * + * Before 2.0 the writers took 0–100 while everything producing those values + * returned 0–1, so the obvious composition was off by 100x and failed + * silently — `0.7` is a legal percentage, so the result was a valid CSS string + * naming a near-black color. A value above 1 can only be that old + * percentage-scale input, so warn about it loudly rather than emit a wrong + * color quietly. + */ +// Keyed by writer name, so the cache is bounded by the five writers below and +// needs no cap of its own (unlike the per-color contrast warnings). +const scaleWarnCache = new Set(); + +function warnPercentScale(fn: string, ...values: number[]): void { + if (!values.some((v) => v > 1 + 1e-6)) return; + + if (!scaleWarnCache.has(fn)) { + scaleWarnCache.add(fn); + console.warn( + `glaze: ${fn}() got a value above 1 — it takes the 0-1 factors every ` + + `Glaze producer returns (resolve(), variantToOkhsl, srgbToOkhsl), not ` + + `0-100 percentages. Divide by 100, or drop the "* 100" left over from ` + + `the pre-2.0 signature.`, + ); + } +} + /** * Format OKHSL values as a CSS `okhsl(H S% L%)` string. - * h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l). + * h: 0–360, s: 0–1, l: 0–1 (factor scale, as returned by every converter). */ export function formatOkhsl( h: number, @@ -740,20 +773,22 @@ export function formatOkhsl( l: number, pastel = false, ): string { + warnPercentScale('formatOkhsl', s, l); + let outS = s; if (pastel) { // If it's a pastel color, we need to find the equivalent normal OKHSL `s` // so it renders identically in external parsers that don't know about `pastel`. - const oklab = okhslToOklab(h, s / 100, l / 100, true); + const oklab = okhslToOklab(h, s, l, true); const normalOkhsl = oklabToOkhsl(oklab, false); - outS = normalOkhsl[1] * 100; + outS = normalOkhsl[1]; } - return `okhsl(${fmt(h, 2)} ${fmt(outS, 2)}% ${fmt(l, 2)}%)`; + return `okhsl(${fmt(h, 2)} ${fmt(outS * 100, 2)}% ${fmt(l * 100, 2)}%)`; } /** * Format OKHST values as a CSS `okhst(H S% T%)` string. - * h: 0–360, s: 0–100, t: 0–100 (percentage scale for s and t). + * h: 0–360, s: 0–1, t: 0–1 (factor scale, matching `ResolvedColorVariant`). * * Pastel recompute matches `formatOkhsl`: convert via OKLab so external * parsers that only understand non-pastel OKHST render identically. @@ -764,23 +799,25 @@ export function formatOkhst( t: number, pastel = false, ): string { + warnPercentScale('formatOkhst', s, t); + let outS = s; if (pastel) { const REF_EPS = 0.05; const den = Math.log(1 + REF_EPS) - Math.log(REF_EPS); - const y = Math.exp((t / 100) * den + Math.log(REF_EPS)) - REF_EPS; + const y = Math.exp(t * den + Math.log(REF_EPS)) - REF_EPS; const l = toe(Math.cbrt(Math.max(0, y))); - const oklab = okhslToOklab(h, s / 100, l, true); + const oklab = okhslToOklab(h, s, l, true); const normalOkhsl = oklabToOkhsl(oklab, false); - outS = normalOkhsl[1] * 100; + outS = normalOkhsl[1]; } - return `okhst(${fmt(h, 2)} ${fmt(outS, 2)}% ${fmt(t, 2)}%)`; + return `okhst(${fmt(h, 2)} ${fmt(outS * 100, 2)}% ${fmt(t * 100, 2)}%)`; } /** * Format OKHSL values as a CSS `rgb(R G B)` string. * Uses 2 decimal places to avoid 8-bit quantization contrast loss. - * h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l). + * h: 0–360, s: 0–1, l: 0–1 (factor scale, as returned by every converter). */ export function formatRgb( h: number, @@ -788,13 +825,15 @@ export function formatRgb( l: number, pastel = false, ): string { - const [r, g, b] = okhslToSrgb(h, s / 100, l / 100, pastel); + warnPercentScale('formatRgb', s, l); + + const [r, g, b] = okhslToSrgb(h, s, l, pastel); return `rgb(${parseFloat((r * 255).toFixed(2))} ${parseFloat((g * 255).toFixed(2))} ${parseFloat((b * 255).toFixed(2))})`; } /** * Format OKHSL values as a CSS `hsl(H S% L%)` string. - * h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l). + * h: 0–360, s: 0–1, l: 0–1 (factor scale, as returned by every converter). */ export function formatHsl( h: number, @@ -802,7 +841,9 @@ export function formatHsl( l: number, pastel = false, ): string { - const [r, g, b] = okhslToSrgb(h, s / 100, l / 100, pastel); + warnPercentScale('formatHsl', s, l); + + const [r, g, b] = okhslToSrgb(h, s, l, pastel); const max = Math.max(r, g, b); const min = Math.min(r, g, b); @@ -829,7 +870,7 @@ export function formatHsl( /** * Format OKHSL values as a CSS `oklch(L C H)` string. - * h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l). + * h: 0–360, s: 0–1, l: 0–1 (factor scale, as returned by every converter). */ export function formatOklch( h: number, @@ -837,10 +878,21 @@ export function formatOklch( l: number, pastel = false, ): string { - const [L, C, hh] = okhslToOklch(h, s / 100, l / 100, pastel); + warnPercentScale('formatOklch', s, l); + + const [L, C, hh] = okhslToOklch(h, s, l, pastel); return `oklch(${fmt(L, 4)} ${fmt(C, 4)} ${fmt(hh, 2)})`; } +/** + * Reset the `format*` percentage-scale warning cache. Test-only seam — the + * warning is deduped per function for the life of the process, which a test + * asserting on it has to be able to clear. + */ +export function resetScaleWarnings(): void { + scaleWarnCache.clear(); +} + // ============================================================================ // Structured (non-string) color accessors — used by the DTCG exporter. // ============================================================================ diff --git a/src/okhst.ts b/src/okhst.ts index 72ba62a..699afb7 100644 --- a/src/okhst.ts +++ b/src/okhst.ts @@ -108,7 +108,11 @@ export function okhslToOkhst(c: { h: number; s: number; l: number }): { /** * Edge adapter: a resolved variant stores canonical tone `t` (0–1). Convert - * it to the OKHSL `{ h, s, l }` the formatters and luminance pipeline expect. + * it to the OKHSL `{ h, s, l }` the formatters and luminance pipeline expect — + * `s` and `l` on the same 0–1 factor scale, so `const { h, s, l } = + * variantToOkhsl(v)` destructures straight into `formatOkhsl` / `formatRgb` / + * `okhslToLinearSrgb` with no rescaling. `alpha` stays on the variant; use + * `glaze.format(v, format)` for a string that carries it. */ export function variantToOkhsl(v: { h: number; s: number; t: number }): { h: number;