@tenphi/glaze 0.0.0-snapshot.94c4efb → 0.0.0-snapshot.9a14acf

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/AGENTS.md ADDED
@@ -0,0 +1,132 @@
1
+ # AGENTS.md — @tenphi/glaze
2
+
3
+ ## Package Overview
4
+
5
+ - **Package:** `@tenphi/glaze`
6
+ - **Repository:** [https://github.com/tenphi/glaze](https://github.com/tenphi/glaze)
7
+ - **Description:** Zero-dependency color theme generator built on OKHST (OKHSL with a contrast-uniform **tone** axis), with WCAG + APCA contrast solving for light, dark, and high-contrast schemes.
8
+ - **Runtime:** Node >= 20, browsers, edge. Pure math, no I/O.
9
+ - **Output formats:** ESM + CJS, with full `.d.ts`.
10
+
11
+ ## Project Structure
12
+
13
+ ```
14
+ glaze/
15
+ ├── src/ Source
16
+ ├── docs/ Long-form docs (okhst / api / methodology / migration)
17
+ ├── assets/ Logo + brand assets
18
+ ├── scripts/ Repo helpers
19
+ ├── README.md Compact intro
20
+ ├── AGENTS.md This file
21
+ ├── CHANGELOG.md Auto-generated by Changesets
22
+ ├── package.json
23
+ ├── tsconfig.json
24
+ ├── tsdown.config.ts Build config
25
+ ├── vitest.config.ts Test config
26
+ └── eslint.config.js
27
+ ```
28
+
29
+ ### `src/` files
30
+
31
+ | File | Description |
32
+ |---|---|
33
+ | [src/index.ts](src/index.ts) | Public barrel; re-exports `glaze`, every public type, the contrast-solver utilities, and the OKHSL/sRGB/OKLab math helpers. |
34
+ | [src/glaze.ts](src/glaze.ts) | `glaze()` factory + attached statics: `palette`, `paletteFrom`, `color`, `colorFrom`, `themeFrom`, `from` (alias), `fromHex`, `fromRgb`, `shadow`, `format`, `configure`, `getConfig`, `resetConfig`, `isThemeExport` / `isColorTokenExport` / `isPaletteExport`. Thin wiring layer over the focused modules below. |
35
+ | [src/theme.ts](src/theme.ts) | Single-theme factory (`createTheme`). Owns the mutable `ColorMap`, the `resolve()` cache (versioned against `getConfigVersion()`), and the `tokens` / `tasty` / `json` / `css` / `extend` / `export` methods. `export(override?)` deep-clones defs and freezes effective config with `kind`/`version`. |
36
+ | [src/palette.ts](src/palette.ts) | Multi-theme composition (`createPalette` / `createPaletteFromExport`). Shared per-theme driver `buildPaletteOutput` handles prefix resolution, primary duplication, collision filtering. Authoring: `export(override?)`, `theme` / `themes` / `list` / `primary`. |
37
+ | [src/color-token.ts](src/color-token.ts) | Standalone `glaze.color()` tokens. Parses values through `color-value.ts`; owns the structured-input validator, the two factory paths, sparse local config + live resolve, and the JSON-safe export / `glaze.colorFrom` rehydrate round-trip. |
38
+ | [src/color-value.ts](src/color-value.ts) | `GlazeColorValue` parsing + validation: hex (3/6/8), the `rgb()` / `hsl()` / `okhsl()` / `okhst()` / `oklch()` functions, and the four value-object shapes, all normalized to OKHSL by `extractOkhslFromValue`. Split out of `color-token.ts` so the resolver can reach it for a theme color's `from` without closing an import cycle. Leaf module — depends only on the color math. |
39
+ | [src/serialize.ts](src/serialize.ts) | Authoring-export helpers: `GLAZE_EXPORT_VERSION`, `assertExportKind` / `assertExportVersion`, and `isThemeExport` / `isColorTokenExport` / `isPaletteExport` type guards. |
40
+ | [src/resolver.ts](src/resolver.ts) | Four-pass solver (light → light-HC → dark → dark-HC), dropping to two-pass (light → dark) with mirrored HC slots only at `contrastLevel: 100`, where the normal passes already are the HC ones (`hcMirrorsNormal`). Under any other manual level `passTone` / `passNumber` / `resolveContrastSpec` feed interpolated inputs into the two *normal* passes and a probe solve at the nearer endpoint pins which side of its base a contrast-solved color sits on; the HC passes bypass the level and the probe entirely, so the HC tier is bit-identical to `'auto'`. Stores canonical tone (`t`) in variants; per-scheme branches for regular, shadow, and mix defs; integrates the contrast solver, and the OKHST tone helpers. `resolveChannels` owns per-scheme hue/saturation (incl. the `darkHue` / `darkSaturation` seed + def overrides, the `darkDesaturation` bypass, and the absolute hue/saturation a `from` color carries in place of the seed factor) and feeds both the emitted variant and the contrast solver. `configForColor` gives a `from` color a local `lightTone: false` so its light variant reproduces the authored value. Converts to/from OKHSL lightness only at the mix/shadow edges. Pre-seeds externally-resolved bases for `glaze.color({ base })`. |
41
+ | [src/okhst.ts](src/okhst.ts) | The OKHST tone layer. `REF_EPS`, tone↔lightness transfers (`toTone`/`fromTone`, `toneFromY`/`yFromTone`), OKHST↔OKHSL conversions, `variantToOkhsl` (tone→lightness at render), `normalizeToneWindow` (`[lo,hi]` / `{lo,hi,eps}` / `false` → `{lo,hi,eps}`), `mapToneForScheme` (scheme inversion + window remap, HC bypass), `mapSaturationDark` (the `darkDesaturation` reducer the resolver skips when a dark saturation is authored), and `schemeToneRange` for the solver. `activeWindow` owns the HC window bypass and its continuous form under `contrastLevel`. Only tone adapts here — hue/saturation are the resolver's business. |
42
+ | [src/contrast-solver.ts](src/contrast-solver.ts) | Tone-based binary-search solver for WCAG **and** APCA. Public API: `findToneForContrast` (incl. the `preferInitial` tie-break the manual level uses on its normal passes to keep a color's side stable), `findValueForMixContrast`, `resolveContrastForMode`, `resolveContrastForLevel` (both ends resolved, target interpolated for a manual `contrastLevel`; throws on a metric switch), `contrastMetricOf`, `resolveMinContrast`, `apcaContrast`. Closed-form WCAG seed + tone search. |
43
+ | [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. |
44
+ | [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. |
45
+ | [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` / `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. |
47
+ | [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. |
48
+ | [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. |
49
+ | [src/warnings.ts](src/warnings.ts) | Deduped contrast-unmet warnings. Caps cache at 256 entries to keep dev-server output bounded. |
50
+ | [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 }`. |
51
+ | `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. |
52
+ | `src/contrast-solver.test.ts` | Tests for the binary-search solver in isolation. |
53
+ | `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. |
55
+
56
+ ### `docs/` files
57
+
58
+ | File | Description |
59
+ |---|---|
60
+ | [docs/okhst.md](docs/okhst.md) | The OKHST color model spec — tone math, `REF_EPS`, the T↔L invariant, the scheme pipeline, calibrated window defaults, the contrast metric, and the APCA verification step. Source of truth for the color model. |
61
+ | [docs/api.md](docs/api.md) | Full API reference. Every method, every option, every type. Reads as a lookup, not a tutorial. |
62
+ | [docs/methodology.md](docs/methodology.md) | Palette design methodology — how to build a palette from scratch. Covers naming conventions, surface ladders, the accent anchor pattern, contrast-driven disabled chips, shadows, and `extend` composition. |
63
+ | [docs/migration.md](docs/migration.md) | Migration & integration guide — choosing an export shape, wiring tokens into [Tasty](https://tasty.style) / CSS / JSON, prefix-map strategies, migrating from a legacy color system, and common pitfalls. |
64
+
65
+ ## Commands
66
+
67
+ | Command | Description |
68
+ |---|---|
69
+ | `pnpm build` | Build ESM + CJS via tsdown (with `.d.ts` and sourcemaps). |
70
+ | `pnpm test` | Run all tests (Vitest 4). |
71
+ | `pnpm test -- <pattern>` | Run a subset of tests. |
72
+ | `pnpm test:watch` | Tests in watch mode. |
73
+ | `pnpm test:coverage` | Tests with coverage report. |
74
+ | `pnpm typecheck` | Type-check without emitting. |
75
+ | `pnpm lint` | ESLint over `src/`. |
76
+ | `pnpm lint:fix` | ESLint with `--fix`. |
77
+ | `pnpm format` | Prettier write `src/**/*.ts`. |
78
+ | `pnpm format:check` | Prettier check (no writes). |
79
+ | `pnpm changeset` | Create a changeset entry. |
80
+ | `pnpm release` | Build and publish (used by CI). |
81
+
82
+ ## Stack
83
+
84
+ - **Language:** TypeScript (strict mode, `consistent-type-imports` enforced)
85
+ - **Build:** [tsdown](https://github.com/rolldown/tsdown) — ESM + CJS, unbundled, dts + sourcemaps
86
+ - **Test:** [Vitest 4](https://vitest.dev/) with globals enabled
87
+ - **Lint:** ESLint 10 + typescript-eslint + `eslint-config-prettier`
88
+ - **Format:** Prettier — single quotes, semicolons, trailing commas, 80 cols
89
+ - **Versioning:** [Changesets](https://github.com/changesets/changesets)
90
+ - **Package manager:** pnpm 11
91
+
92
+ ## Code Conventions
93
+
94
+ - TypeScript strict mode; `consistent-type-imports` enforced.
95
+ - Test files use `*.test.ts` and live next to the module they test in `src/`.
96
+ - Unused vars prefixed with `_` are allowed.
97
+ - **No runtime dependencies** — every bit of math is implemented from scratch.
98
+ - Functional API pattern: `glaze()` factory with attached static methods; no classes.
99
+ - Each module starts with a JSDoc block describing its responsibility — keep these in sync when behavior changes.
100
+
101
+ ## Adding a New Color Type
102
+
103
+ If you need to add a fourth `ColorDef` variant (alongside regular / shadow / mix), touch these files in order:
104
+
105
+ 1. [src/types.ts](src/types.ts) — add the new interface and extend the `ColorDef` discriminated union.
106
+ 2. [src/shadow.ts](src/shadow.ts) — add a discriminator predicate (`isFooDef`).
107
+ 3. [src/validation.ts](src/validation.ts) — extend `validateColorDefs` (missing-reference / cycle handling) and `topoSort` (record the new dependency edges).
108
+ 4. [src/resolver.ts](src/resolver.ts) — add a per-scheme `resolveFooForScheme` branch and dispatch from `resolveColorForScheme`.
109
+ 5. [src/formatters.ts](src/formatters.ts) — only if the output shape differs (it usually doesn't; the resolver returns a normal `ResolvedColorVariant`).
110
+ 6. `src/glaze.test.ts` — add coverage for the new def, including HC pairs, dark-mode behavior, and inheritance through `extend`.
111
+
112
+ For a new public method on `glaze` (e.g. another statics entry), the wiring lives entirely in [src/glaze.ts](src/glaze.ts); the implementation should still go in a focused module.
113
+
114
+ ## CI/CD & Release
115
+
116
+ - **CI:** Runs lint, format check, typecheck, build, and tests on push to `main` and PRs.
117
+ - **Release:** On push to `main`, `changesets/action` either creates a version PR or publishes to npm.
118
+ - **Snapshot:** Comment `/snapshot` on a PR to publish a `0.0.0-snapshot.<sha>` release.
119
+ - **npm trusted publishing:** Uses OIDC provenance via the `release` GitHub environment; the workflow requires `environment: release` for npm to accept the OIDC token.
120
+ - **`RELEASE_TOKEN` secret:** Fine-grained GitHub PAT (repo-scoped, `contents: write` + `pull-requests: write`) used by `changesets/action` for Git pushes and PR creation. Rotate before expiry.
121
+ - **Repo settings required:** Settings → Actions → General → Workflow permissions → "Allow GitHub Actions to create and approve pull requests" must be enabled.
122
+
123
+ ## Before Pushing Changes
124
+
125
+ Follow [`.cursor/commands/submit-changes.md`](.cursor/commands/submit-changes.md) for the full release-oriented workflow. At minimum:
126
+
127
+ 1. **Typecheck** — `pnpm typecheck`. Stop and fix errors before continuing.
128
+ 2. **Lint** — `pnpm lint`. Stop and fix errors before continuing.
129
+ 3. **Format** — `pnpm format` so the diff matches Prettier output.
130
+ 4. **Changeset** — for any user-visible change (features, fixes, refactors, perf, docs that affect published behavior), add a markdown file in `.changeset/` with a `patch` / `minor` / `major` bump and a concise user-facing summary. Skip only for purely internal changes (CI, repo churn, tests with no behavior change).
131
+ 5. **Commit** — Conventional Commits (`feat`, `fix`, `refactor`, `test`, `docs`, `chore`, `perf`, `ci`). Optional scope. Keep the subject short. Include the changeset file in the same commit.
132
+ 6. **Push** — never push to `main`. Confirm the current branch, then `git push -u origin HEAD`.
package/README.md CHANGED
@@ -32,7 +32,7 @@ contrast floor.
32
32
  - **Mix colors** — blend two colors with OKHSL or sRGB interpolation, opaque or transparent, with optional contrast solving
33
33
  - **Shadow colors** — OKHSL-native shadow computation with automatic alpha, fg/bg tinting, and per-scheme adaptation
34
34
  - **Light + Dark + High-Contrast** — all schemes from one definition
35
- - **Manual contrast level** — swap the high-contrast tier for a `0–100` slider; `0` is your normal palette, `100` the high-contrast one, and every level in between is solved, not approximated
35
+ - **Manual contrast level** — put your normal palette on a `0–100` slider between itself and the high-contrast one; every level in between is solved, not approximated, and the high-contrast tier stays available on top
36
36
  - **Per-color hue override** — absolute or relative hue shifts within a theme
37
37
  - **Multi-format output** — native `rgb`, `hsl`, and `oklch`, plus
38
38
  [Tasty](https://tasty.style)-compatible `okhsl` and `okhst`
@@ -143,14 +143,15 @@ In Glaze, a **scheme variant** is one of `light`, `dark`, `lightContrast`, or
143
143
 
144
144
  ## Documentation
145
145
 
146
+ - Explore the tone axis in the [interactive playground](https://glaze.tenphi.me/playground/).
146
147
  - Start with [the methodology](docs/methodology.md) to design a palette.
147
148
  - Use [migration and integration](docs/migration.md) to wire it into an
148
149
  application or replace an existing color system.
149
150
  - Keep the [API reference](docs/api.md) nearby for every method and option.
150
151
  - Read [OKHST in Glaze](docs/okhst.md) for the product-level color model, or
151
152
  the [full OKHST specification](https://github.com/tenphi/okhst) for its math.
152
- - [`AGENTS.md`](AGENTS.md) — source-tree orientation for contributors.
153
+ - [`AGENTS.md`](https://github.com/tenphi/glaze/blob/main/AGENTS.md) — source-tree orientation for contributors.
153
154
 
154
155
  ## License
155
156
 
156
- [MIT](LICENSE)
157
+ [MIT](https://github.com/tenphi/glaze/blob/main/LICENSE)
package/dist/index.cjs CHANGED
@@ -372,6 +372,7 @@ const sRGBGammaToLinear = (val) => {
372
372
  };
373
373
  /**
374
374
  * Convert OKHSL to gamma-encoded sRGB (clamped to 0–1).
375
+ * h: 0–360, s: 0–1, l: 0–1. Returns [r, g, b] with each channel 0–1.
375
376
  */
376
377
  function okhslToSrgb(h, s, l, pastel = false) {
377
378
  const lin = okhslToLinearSrgb(h, s, l, pastel);
@@ -581,47 +582,72 @@ function fmt$1(value, decimals) {
581
582
  return parseFloat(value.toFixed(decimals)).toString();
582
583
  }
583
584
  /**
585
+ * Every `format*` writer takes `s` / `l` / `t` on the **0–1 factor scale** —
586
+ * the same scale every producer in the library hands back (`resolve()`,
587
+ * `variantToOkhsl`, `srgbToOkhsl`, `oklabToOkhsl`, `okhslToOklab`,
588
+ * `okhslToSrgb`). The percentages are an output detail: the writers multiply
589
+ * by 100 themselves when the CSS syntax asks for a percentage.
590
+ *
591
+ * Before 2.0 the writers took 0–100 while everything producing those values
592
+ * returned 0–1, so the obvious composition was off by 100x and failed
593
+ * silently — `0.7` is a legal percentage, so the result was a valid CSS string
594
+ * naming a near-black color. A value above 1 can only be that old
595
+ * percentage-scale input, so warn about it loudly rather than emit a wrong
596
+ * color quietly.
597
+ */
598
+ const scaleWarnCache = /* @__PURE__ */ new Set();
599
+ function warnPercentScale(fn, ...values) {
600
+ if (!values.some((v) => v > 1.000001)) return;
601
+ if (!scaleWarnCache.has(fn)) {
602
+ scaleWarnCache.add(fn);
603
+ 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.`);
604
+ }
605
+ }
606
+ /**
584
607
  * Format OKHSL values as a CSS `okhsl(H S% L%)` string.
585
- * h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
608
+ * h: 0–360, s: 0–1, l: 0–1 (factor scale, as returned by every converter).
586
609
  */
587
610
  function formatOkhsl(h, s, l, pastel = false) {
611
+ warnPercentScale("formatOkhsl", s, l);
588
612
  let outS = s;
589
- if (pastel) outS = oklabToOkhsl(okhslToOklab(h, s / 100, l / 100, true), false)[1] * 100;
590
- return `okhsl(${fmt$1(h, 2)} ${fmt$1(outS, 2)}% ${fmt$1(l, 2)}%)`;
613
+ if (pastel) outS = oklabToOkhsl(okhslToOklab(h, s, l, true), false)[1];
614
+ return `okhsl(${fmt$1(h, 2)} ${fmt$1(outS * 100, 2)}% ${fmt$1(l * 100, 2)}%)`;
591
615
  }
592
616
  /**
593
617
  * Format OKHST values as a CSS `okhst(H S% T%)` string.
594
- * h: 0–360, s: 0–100, t: 0–100 (percentage scale for s and t).
618
+ * h: 0–360, s: 0–1, t: 0–1 (factor scale, matching `ResolvedColorVariant`).
595
619
  *
596
620
  * Pastel recompute matches `formatOkhsl`: convert via OKLab so external
597
621
  * parsers that only understand non-pastel OKHST render identically.
598
622
  */
599
623
  function formatOkhst(h, s, t, pastel = false) {
624
+ warnPercentScale("formatOkhst", s, t);
600
625
  let outS = s;
601
626
  if (pastel) {
602
627
  const REF_EPS = .05;
603
628
  const den = Math.log(1 + REF_EPS) - Math.log(REF_EPS);
604
- const y = Math.exp(t / 100 * den + Math.log(REF_EPS)) - REF_EPS;
605
- const l = toe(Math.cbrt(Math.max(0, y)));
606
- outS = oklabToOkhsl(okhslToOklab(h, s / 100, l, true), false)[1] * 100;
629
+ const y = Math.exp(t * den + Math.log(REF_EPS)) - REF_EPS;
630
+ outS = oklabToOkhsl(okhslToOklab(h, s, toe(Math.cbrt(Math.max(0, y))), true), false)[1];
607
631
  }
608
- return `okhst(${fmt$1(h, 2)} ${fmt$1(outS, 2)}% ${fmt$1(t, 2)}%)`;
632
+ return `okhst(${fmt$1(h, 2)} ${fmt$1(outS * 100, 2)}% ${fmt$1(t * 100, 2)}%)`;
609
633
  }
610
634
  /**
611
635
  * Format OKHSL values as a CSS `rgb(R G B)` string.
612
636
  * Uses 2 decimal places to avoid 8-bit quantization contrast loss.
613
- * h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
637
+ * h: 0–360, s: 0–1, l: 0–1 (factor scale, as returned by every converter).
614
638
  */
615
639
  function formatRgb(h, s, l, pastel = false) {
616
- const [r, g, b] = okhslToSrgb(h, s / 100, l / 100, pastel);
640
+ warnPercentScale("formatRgb", s, l);
641
+ const [r, g, b] = okhslToSrgb(h, s, l, pastel);
617
642
  return `rgb(${parseFloat((r * 255).toFixed(2))} ${parseFloat((g * 255).toFixed(2))} ${parseFloat((b * 255).toFixed(2))})`;
618
643
  }
619
644
  /**
620
645
  * Format OKHSL values as a CSS `hsl(H S% L%)` string.
621
- * h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
646
+ * h: 0–360, s: 0–1, l: 0–1 (factor scale, as returned by every converter).
622
647
  */
623
648
  function formatHsl(h, s, l, pastel = false) {
624
- const [r, g, b] = okhslToSrgb(h, s / 100, l / 100, pastel);
649
+ warnPercentScale("formatHsl", s, l);
650
+ const [r, g, b] = okhslToSrgb(h, s, l, pastel);
625
651
  const max = Math.max(r, g, b);
626
652
  const min = Math.min(r, g, b);
627
653
  const delta = max - min;
@@ -638,10 +664,11 @@ function formatHsl(h, s, l, pastel = false) {
638
664
  }
639
665
  /**
640
666
  * Format OKHSL values as a CSS `oklch(L C H)` string.
641
- * h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
667
+ * h: 0–360, s: 0–1, l: 0–1 (factor scale, as returned by every converter).
642
668
  */
643
669
  function formatOklch(h, s, l, pastel = false) {
644
- const [L, C, hh] = okhslToOklch(h, s / 100, l / 100, pastel);
670
+ warnPercentScale("formatOklch", s, l);
671
+ const [L, C, hh] = okhslToOklch(h, s, l, pastel);
645
672
  return `oklch(${fmt$1(L, 4)} ${fmt$1(C, 4)} ${fmt$1(hh, 2)})`;
646
673
  }
647
674
  /**
@@ -906,10 +933,14 @@ function levelFraction(level) {
906
933
  }
907
934
  /**
908
935
  * Manual-contrast blend fraction (0–1) for a resolved config, or `undefined`
909
- * in `'auto'` mode (the two-tier normal + high-contrast model).
936
+ * in `'auto'` mode, where the normal variants take the authored normal entries
937
+ * as-is.
910
938
  *
911
- * Note `contrastLevel: 0` yields `0`, not `undefined`: "pinned to normal
912
- * contrast with no high-contrast tier" is a distinct state from `'auto'`.
939
+ * Note `contrastLevel: 0` yields `0`, not `undefined`. The two select different
940
+ * branches, and an authored `0` is a slider position that `.export()` freezes
941
+ * where an absent level is not — but they are not an *output* distinction: at
942
+ * fraction 0 every interpolation returns its normal entry verbatim, so level 0
943
+ * reproduces `'auto'` bit for bit.
913
944
  */
914
945
  function contrastFraction(config) {
915
946
  const level = config.contrastLevel;
@@ -917,6 +948,20 @@ function contrastFraction(config) {
917
948
  return levelFraction(level);
918
949
  }
919
950
  /**
951
+ * Whether the high-contrast variants are exact duplicates of the normal ones —
952
+ * true only at `contrastLevel: 100`, where the normal passes already resolve at
953
+ * full contrast.
954
+ *
955
+ * The level otherwise says nothing about high contrast: it positions the normal
956
+ * variants, while `lightContrast` / `darkContrast` stay the true high-contrast
957
+ * resolution at every level. This predicate marks the one level where that tier
958
+ * carries no new information, so callers can skip the two high-contrast passes,
959
+ * their drift checks, and the redundant output tier.
960
+ */
961
+ function hcMirrorsNormal(config) {
962
+ return contrastFraction(config) === 1;
963
+ }
964
+ /**
920
965
  * A numeric HC pair at a blend fraction (shadow `intensity`, mix `value`).
921
966
  *
922
967
  * The endpoints return the authored entry *by identity* rather than through
@@ -1101,7 +1146,11 @@ function okhslToOkhst(c) {
1101
1146
  }
1102
1147
  /**
1103
1148
  * Edge adapter: a resolved variant stores canonical tone `t` (0–1). Convert
1104
- * it to the OKHSL `{ h, s, l }` the formatters and luminance pipeline expect.
1149
+ * it to the OKHSL `{ h, s, l }` the formatters and luminance pipeline expect —
1150
+ * `s` and `l` on the same 0–1 factor scale, so `const { h, s, l } =
1151
+ * variantToOkhsl(v)` destructures straight into `formatOkhsl` / `formatRgb` /
1152
+ * `okhslToLinearSrgb` with no rescaling. `alpha` stays on the variant; use
1153
+ * `glaze.format(v, format)` for a string that carries it.
1105
1154
  */
1106
1155
  function variantToOkhsl(v) {
1107
1156
  return {
@@ -2276,12 +2325,14 @@ function warnContrastDrift(name, isDark, isHighContrast, contrast, yColor, yBase
2276
2325
  * Color resolution engine.
2277
2326
  *
2278
2327
  * Runs the four-pass solver (light → light-HC → dark → dark-HC) that
2279
- * turns a `ColorMap` into a fully resolved `ResolvedColor` per name — or,
2280
- * under a manual `contrastLevel`, a two-pass solver (light → dark) whose
2281
- * authored HC pairs, tone window, and contrast targets are interpolated at
2282
- * that level and whose high-contrast slots mirror the normal ones.
2283
- * Owns the per-scheme resolve helpers for regular, shadow, and mix
2284
- * color defs.
2328
+ * turns a `ColorMap` into a fully resolved `ResolvedColor` per name. A manual
2329
+ * `contrastLevel` interpolates the authored HC pairs, tone window, and contrast
2330
+ * targets at that level for the two *normal* passes only; the high-contrast
2331
+ * passes bypass the level entirely, so the high-contrast slots are the same
2332
+ * values `'auto'` resolves. At level 100 the normal passes already are the
2333
+ * high-contrast ones, so the solver drops to two passes (light → dark) and
2334
+ * mirrors the slots. Owns the per-scheme resolve helpers for regular, shadow,
2335
+ * and mix color defs.
2285
2336
  *
2286
2337
  * Variants are stored in OKHST: `h` / `s` are OKHSL hue/saturation and
2287
2338
  * `t` is the canonical contrast-uniform tone (0–1, reference eps). The
@@ -2538,7 +2589,7 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, channels,
2538
2589
  flip,
2539
2590
  pastel
2540
2591
  };
2541
- const level = contrastFraction(ctx.config);
2592
+ const level = isHighContrast ? void 0 : contrastFraction(config);
2542
2593
  let preferInitial = false;
2543
2594
  if (level !== void 0 && level > 0 && level < 1) {
2544
2595
  initialDirection = findToneForContrast({
@@ -2727,10 +2778,11 @@ function seedField(order, ctx, field, source) {
2727
2778
  * resolved with a `base` + `contrast` may land slightly under the contrast
2728
2779
  * its tone implies because chromatic luminance drifts from the gray tone.
2729
2780
  *
2730
- * Under a manual `contrastLevel` only the two emitted variants are checked
2731
- * (the high-contrast slots are mirrors), and the spec is resolved at the level
2732
- * so the check measures the output against the target it was actually solved
2733
- * for.
2781
+ * Under a manual `contrastLevel` the spec is resolved at the level, so the check
2782
+ * measures each normal variant against the target it was actually solved for. At
2783
+ * level 100 the high-contrast slots are mirrors and are skipped: the normal
2784
+ * schemes already carry that check, and repeating it would emit a byte-identical
2785
+ * advisory under a different scheme label, which dedupes as a separate key.
2734
2786
  */
2735
2787
  function verifyContrastDrift(order, defs, result, config) {
2736
2788
  const roles = /* @__PURE__ */ new Map();
@@ -2765,9 +2817,9 @@ function verifyContrastDrift(order, defs, result, config) {
2765
2817
  field: "darkContrast"
2766
2818
  }
2767
2819
  ];
2768
- const manual = contrastFraction(config) !== void 0;
2820
+ const mirrored = hcMirrorsNormal(config);
2769
2821
  for (const s of schemes) {
2770
- if (manual && s.isHighContrast) continue;
2822
+ if (mirrored && s.isHighContrast) continue;
2771
2823
  const spec = resolveContrastSpec(regDef.contrast, s.isHighContrast, config, polarity);
2772
2824
  const cVariant = color[s.field];
2773
2825
  const bVariant = base[s.field];
@@ -2792,10 +2844,10 @@ function resolveAllColors(seed, defs, config, externalBases) {
2792
2844
  roles: /* @__PURE__ */ new Map()
2793
2845
  };
2794
2846
  if (externalBases) for (const [name, color] of externalBases) ctx.resolved.set(name, color);
2795
- const manual = contrastFraction(config) !== void 0;
2847
+ const mirrored = hcMirrorsNormal(config);
2796
2848
  const lightMap = runPass(order, defs, ctx, false, false, "light");
2797
2849
  let lightHCMap = lightMap;
2798
- if (!manual) {
2850
+ if (!mirrored) {
2799
2851
  seedField(order, ctx, "lightContrast", lightMap);
2800
2852
  lightHCMap = runPass(order, defs, ctx, false, true, "lightContrast");
2801
2853
  }
@@ -2803,7 +2855,7 @@ function resolveAllColors(seed, defs, config, externalBases) {
2803
2855
  seedField(order, ctx, "darkContrast", lightHCMap);
2804
2856
  const darkMap = runPass(order, defs, ctx, true, false, "dark");
2805
2857
  let darkHCMap = darkMap;
2806
- if (!manual) {
2858
+ if (!mirrored) {
2807
2859
  seedField(order, ctx, "darkContrast", darkMap);
2808
2860
  darkHCMap = runPass(order, defs, ctx, true, true, "darkContrast");
2809
2861
  }
@@ -3005,10 +3057,10 @@ function fmt(value, decimals) {
3005
3057
  function formatVariant(v, format = "oklch", pastel = false) {
3006
3058
  const effectivePastel = v.pastel ?? pastel;
3007
3059
  let base;
3008
- if (format === "okhst") base = formatOkhst(v.h, v.s * 100, v.t * 100, effectivePastel);
3060
+ if (format === "okhst") base = formatOkhst(v.h, v.s, v.t, effectivePastel);
3009
3061
  else {
3010
3062
  const { l } = variantToOkhsl(v);
3011
- base = formatters[format](v.h, v.s * 100, l * 100, effectivePastel);
3063
+ base = formatters[format](v.h, v.s, l, effectivePastel);
3012
3064
  }
3013
3065
  if (v.alpha >= 1) return base;
3014
3066
  const closing = base.lastIndexOf(")");
@@ -3024,7 +3076,7 @@ function formatVariantHue(v, plan, pastel = false) {
3024
3076
  const [L, C] = okhslToOklch(v.h, v.s, l, effectivePastel);
3025
3077
  let base;
3026
3078
  if (plan.inline) if (v.s <= 1e-6) base = `oklch(${fmt(L, 4)} 0 0)`;
3027
- else base = formatOklch(v.h, v.s * 100, l * 100, effectivePastel);
3079
+ else base = formatOklch(v.h, v.s, l, effectivePastel);
3028
3080
  else base = `oklch(${fmt(L, 4)} ${fmt(C, 4)} ${plan.hueVar})`;
3029
3081
  if (v.alpha >= 1) return base;
3030
3082
  const closing = base.lastIndexOf(")");
@@ -3037,23 +3089,28 @@ function formatColorValue(v, format, pastel, huePlan) {
3037
3089
  /**
3038
3090
  * Resolve the effective output modes for an export.
3039
3091
  *
3040
- * A global manual `contrastLevel` turns off high-contrast output outright: the
3041
- * level *is* the contrast preference, so a separate high-contrast tier has no
3042
- * meaning alongside it. `modes.highContrast` therefore reads as "emit a separate
3043
- * high-contrast set **when** contrast is automatic" — it goes inert under a
3044
- * manual level rather than fighting it, and silently, since switching a
3045
- * preference from auto to manual is normal use and not a mistake to report.
3092
+ * `modes.highContrast` and `contrastLevel` are independent: the level positions
3093
+ * the normal variants, the high-contrast tier stays the true high-contrast
3094
+ * resolution, and the two compose — a slider raises the baseline while a
3095
+ * `prefers-contrast: more` block still escalates on top of it.
3096
+ *
3097
+ * The one exception is a **global** level of 100, where the normal variants
3098
+ * already *are* the high-contrast ones. The tier would be an exact duplicate, so
3099
+ * it is dropped and a single light/dark set is emitted — including against an
3100
+ * explicit `highContrast: true` override, which has nothing different left to
3101
+ * ask for.
3046
3102
  *
3047
3103
  * A level set on a single theme or token does not change which modes are
3048
- * emitted: sibling themes in a palette may still have a real high-contrast
3049
- * tier, and the manual one correctly reports its own resolved values there —
3050
- * the alternative would drop its colors from that tier entirely.
3104
+ * emitted: `modes` is global-only, and a palette must not have one sibling
3105
+ * collapse the shared token structure. A per-instance level of 100 therefore
3106
+ * still reports into the tier, with values equal to its own normal ones.
3051
3107
  */
3052
3108
  function resolveModes(override) {
3053
3109
  const cfg = getConfig();
3110
+ const highContrast = override?.highContrast ?? cfg.modes.highContrast;
3054
3111
  return {
3055
3112
  dark: override?.dark ?? cfg.modes.dark,
3056
- highContrast: contrastFraction(cfg) !== void 0 ? false : override?.highContrast ?? cfg.modes.highContrast
3113
+ highContrast: highContrast && !hcMirrorsNormal(cfg)
3057
3114
  };
3058
3115
  }
3059
3116
  function buildTokenMap(resolved, prefix, states, modes, format = "oklch", pastel = false, channelCtx) {