Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/plain-scales-align.md
Original file line number Diff line number Diff line change
@@ -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).
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,15 @@ 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. |
| [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 }`. |
| `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

Expand Down
47 changes: 38 additions & 9 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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
Expand All @@ -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
Expand Down
36 changes: 36 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions playground/palette.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions src/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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})`;
Expand Down
Loading
Loading