fix: make format* take s/l/t on the 0–1 scale - #94
Merged
Conversation
The format* writers took s/l/t on a 0-100 percentage scale while every producer in the library — resolve(), variantToOkhsl, srgbToOkhsl, oklabToOkhsl, okhslToSrgb — returns them on 0-1. Composing the two was off by 100x and failed silently, since 0.7 is a legal percentage: the result was a valid CSS string naming a near-black color. The writers now take the factors and scale to percent themselves, so the library speaks one scale end to end. A value above 1 can only be old percentage-scale input, so warn once per writer instead of emitting a plausible wrong color. Also documents okhslToSrgb's scale, the one converter that stated no units. Closes #93 Claude-Session: https://claude.ai/code/session_01JgZkbB2yZjca1WMVpsHjTq
Contributor
📦 Snapshot releasePublished |
- The api.md composition snippet spread `Object.values(variantToOkhsl(v))` into `formatRgb`, which does not typecheck (TS2556) and would bind a future `alpha` to the `pastel` parameter. Destructure instead, in the doc, the `variantToOkhsl` JSDoc, and the test that asserted it. - "the scale every converter returns" overstated it: `toTone` / `fromTone` still speak the 0-100 tone axis the authoring API takes. Say so where the claim is made, and mark the scale on both rows. - Fix the `variantToOkhsl` row, which claimed an `alpha` it never returns. - Drop the copied 32-entry cap on the scale-warning cache: it is keyed by writer name, so it tops out at five entries and the branch was dead. Claude-Session: https://claude.ai/code/session_01JgZkbB2yZjca1WMVpsHjTq
Merged
tenphi
added a commit
to tenphi/tasty
that referenced
this pull request
Aug 24, 2026
Checked whether the producer/writer scale mismatch fixed in tenphi/glaze#94 applies here. It does not: `createColorFunc` already takes channel factors and scales to percentages on output, which is what that PR changed glaze's writers to do. Verified every other boundary too — `hslStringToRgb`, `oklchStringToRgb`, `strToRgb`, and `okhstToSrgb`'s `fromTone(t * 100)` bridge between the 0-1 lightness scale and the 0-100 tone scale — all convert correctly. One sibling of that bug was here, though. `parsePercentage` reads a unitless channel as the factor it looks like, so `okhsl(280 .8 .52)` and `okhsl(280 80% 52%)` are the same color. Drop the `%` and `80` lands in a 0-1 slot, clamps to full saturation, and renders as white — which looks like a color rather than like a mistake, exactly the silent-plausible-output failure glaze#94 also set out to remove. A unitless channel above 1 cannot be a factor, so it now warns once per function in development through the existing `warnOnceDev`, which `resetConfig()` already clears. `1` stays silent, being a legitimate factor, and the emitted color is unchanged. The warning tests are mutation-checked: replacing `warnOnceDev` with a bare `console.warn` fails three of them, including the production case. That one also uses a channel value no earlier test touches — `createColorFunc`'s LRU lives for the process, so a cached value would return before reaching the check and the assertion would have passed for the wrong reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tenphi
added a commit
to tenphi/tasty
that referenced
this pull request
Aug 24, 2026
* refactor(color): drop --name-color-{space} companion variables
The channel-components companion existed to give `#token.alpha` something to
write an alpha into. Opacity moved to relative color syntax in #268, and since
then nothing inside Tasty has read a companion — so every color declaration,
every `tokens` prop entry and every color `@property` carried a second variable
with no consumer.
Removing it takes one declaration off each color rule, one `@property`
registration off each color token on all four render paths, and a whole
pre-pass off `PropertyTypeResolver.scanDeclarations`. ~0.8 kB brotli off `main`,
0.9 off `core`; size limits tightened back to ~0.5 kB headroom.
No public contract moves — nothing exported changes and class name hashes are
identical. The companions were internal implementation that happened to be
visible in the emitted CSS, so hand-authored CSS that reached for one needs the
token itself instead: `oklch(from var(--purple-color) l c h)` rather than
`oklch(var(--purple-color-oklch))`, which also works on the colors the companion
could never decompose. Migration note in docs/configuration.md.
`colorSpace` keeps its job of choosing the space a statically known color is
emitted in. Opacity is unaffected — it was already always written in `oklch`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(color): collapse the paths the companion removal left behind
Follow-up to the companion removal, plus the tests that were missing under it.
`createStyle`'s color branch is one expression. `parseColor` derives `name` from
the chain it resolved, so `name` implies `color` and the `var(--name-color)`
fallback was unreachable; and a value `strToColorSpace` can convert is a literal,
which never resolves to a `var()` chain, so `name` and a conversion cannot
co-occur either. Both facts are now pinned by a test rather than assumed.
Trying the conversion first also keeps the heavier `parseColor` off the path for
plain literals, and drops a spurious `unable to parse color` warning for a token
set to a bare keyword like `red`.
`parseSameSpaceFunc`/`buildSameSpaceString` merge into `normalizeSameSpaceFunc`
now that only one caller is left, and `SPACE_FUNCS`/`CANONICAL_FUNC` go with
them — every space is named after its own function plus an optional legacy `a`.
`processTokens`' two branches converged once the companion left, so they share
one resolver that differs only in the property name and in how `true` reads.
Tests, placed where the behavior lives:
- `styles.test.ts` — the full `createStyle` color-value table, the mutual-
exclusion invariant the collapse rests on, the no-spurious-warning case, and
`--current-color` republishing including the `#current` self-reference guard,
which had no coverage at all.
- `color-space.test.ts` — cross-space conversion and hue units (`deg`/`turn`/
`rad`, negative, >360), which had only ever been exercised through the
companion assertions, moved onto `strToColorSpace` where the conversion
happens; plus same-space name canonicalization for all three spaces.
- `process-tokens.test.ts` — one property per token key, and the `$` cases the
shared resolver now covers.
Migration note added under Color space in docs/configuration.md, and the
changeset reframed: no public contract moves, but the emitted CSS does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(color): emit colors as authored, deprecate colorSpace
The companion removal left the color-space conversion with nothing load-bearing
to do. It existed so an opacity suffix had numeric channels to write an alpha
into; relative color syntax has the browser read the channels instead, so a
token's value no longer needs rewriting into any particular space.
So it isn't. `#brand: '#ff8800'` declares `--brand-color: #ff8800` — same for a
native color function, a bare CSS color name, a `color-mix()`. A `#token`
reference still resolves to its `var()` chain, and a plugin color function like
`okhsl()` is still resolved by the parser.
`configure({ colorSpace })` is deprecated: still accepted, inert, warns in
development, removed in the next major. If you relied on the uniform output,
author the token in the space you want it emitted in.
That drops the whole sRGB round-trip — `strToColorSpace`, `resolveToRgbaValues`,
`normalizeSameSpaceFunc`, `rgbToHsl`, `rgbToOklch`, `hexToRgbaValues`, and the
LRU that memoized it. `color-space.ts` goes from 706 lines to 86, and is now
only about how an alpha is applied. Against `main`: ~2.0 kB brotli off `main` and
`core`, ~2.6 kB off `static`, `zero` and `babel-plugin`.
Fixes a real bug the conversion was masking. `parseColor`'s first-character
dispatch answered `false` for any named color starting with a letter it had a
shape for, so `red`, `hotpink`, `lime`, `orange`, `violet`, `coral` and `teal`
were rejected and warned about, while `blue` and `green` were accepted. A miss
now falls through to the named-color lookup.
Coverage moved with the behavior rather than being deleted with the functions:
cross-space conversion and hue units onto `strToRgb` (still public, still
converts), plugin-color-function resolution onto `resolveFunctionColor` (the
generic path that replaced the okhsl branches), and `colorSpace`'s inertness is
pinned across all three values.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(color-math): drop exports nothing calls
`knip` treats test files as entry points, so a helper reachable only from its own
test reads as used. Five were: `srgbToOkhsl`, `toTone`, `hexToRgbValues`,
`okhslStringToRgb`, `okhstStringToRgb`.
Three come out, along with `hexCharToNum` which went with them.
`okhslStringToRgb`/`okhstStringToRgb` are leftovers from v3 turning okhsl/okhst
into ordinary plugins — the parser resolves those calls through
`resolveFunctionColor` now, which is covered on its own.
Two stay, and are documented as to why: `srgbToOkhsl` and `toTone` are the
reverse halves of `okhslToSrgb` and `fromTone`, which the plugins do use. The
engine never calls them, but the tests round-trip through them to check the
forward conversion's accuracy — including the OKHSL green-region cases — and a
round trip that checks itself is worth more than the fixtures that would replace
it.
No bundle change: none of these were reachable from a package entry point, so
they were already being tree-shaken. `color-math.ts` loses 234 lines.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test: fail the build if test-only code reaches it
`color-math` keeps the reverse direction of two conversions the engine never
calls, so the forward ones — which the `okhsl()`/`okhst()` plugins do use — can
be round-tripped in tests rather than pinned to fixtures the implementation
itself produced.
Nothing re-exports them from a package entry point, so rolldown already drops
them: the constants survive only in a source map, not in any emitted `.js`. That
was true by accident, though, and `knip` cannot notice if it stops being true —
`knip.json` lists test files as entry points, so anything a test imports reads as
used.
`scripts/check-test-only-code.mjs` makes it explicit. It carries the reverse-only
closure (both exports plus the private helpers only they reach) and fails if any
of them appears in an emitted file. Matching is on `name(` / `name =`, so a
`{@link}` in a preserved doc comment is not a false positive.
It also asserts each registered name is still declared in `src`, so renaming or
deleting one fails the check instead of leaving it silently asserting nothing.
Verified all three ways: passes clean; fails on a forced production re-export,
naming the whole closure that comes in with it; fails on a stale registry entry.
Wired into CI after the knip step.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(plugins): warn on percentage-scale channels in a color function
Checked whether the producer/writer scale mismatch fixed in tenphi/glaze#94
applies here. It does not: `createColorFunc` already takes channel factors and
scales to percentages on output, which is what that PR changed glaze's writers
to do. Verified every other boundary too — `hslStringToRgb`, `oklchStringToRgb`,
`strToRgb`, and `okhstToSrgb`'s `fromTone(t * 100)` bridge between the 0-1
lightness scale and the 0-100 tone scale — all convert correctly.
One sibling of that bug was here, though. `parsePercentage` reads a unitless
channel as the factor it looks like, so `okhsl(280 .8 .52)` and
`okhsl(280 80% 52%)` are the same color. Drop the `%` and `80` lands in a 0-1
slot, clamps to full saturation, and renders as white — which looks like a color
rather than like a mistake, exactly the silent-plausible-output failure glaze#94
also set out to remove.
A unitless channel above 1 cannot be a factor, so it now warns once per function
in development through the existing `warnOnceDev`, which `resetConfig()` already
clears. `1` stays silent, being a legitimate factor, and the emitted color is
unchanged.
The warning tests are mutation-checked: replacing `warnOnceDev` with a bare
`console.warn` fails three of them, including the production case. That one also
uses a channel value no earlier test touches — `createColorFunc`'s LRU lives for
the process, so a cached value would return before reaching the check and the
assertion would have passed for the wrong reason.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: stop promising the companion in public typings
Four token JSDoc blocks still said `#name` creates `--name-color` and
`--name-color-{colorSpace}` — `src/types.ts`, `src/config.ts`,
`src/styles/types.ts` and `src/plugins/types.ts`. They ship in the emitted
`.d.ts`, so editor tooltips were pointing at a property no rendering path emits
any more.
My sweep missed them because it grepped for the resolved suffixes
(`-color-oklch`, `-color-rgb`, …) rather than the templated form, so
`--name-color-{colorSpace}` matched nothing. Re-swept on `colorSpace` and
`color-{` across all of `src`, which also caught a stale "with no companion
variable" aside in `overrideColorAlpha`'s doc.
Verified against the built output: no emitted `.d.ts` mentions the companion,
and the corrected line is present in the published declarations.
Reported by @tenphi in review.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: resolve #current through --current-color, not the keyword
Relative colour syntax takes a concrete origin from Safari 16.4, but
`oklch(from currentcolor …)` needs Safari 18. A token defined as `#current`
emitted the bare keyword, so fading it — `{ '#ink': '#current', fill: '#ink.5' }`
— produced exactly that unsupported form. `#current` now emits
`var(--current-color)`, so the origin is a real colour wherever a `color` style
published one.
Three things keep the swap invisible everywhere else:
- `--current-color` is registered with `initial-value: currentcolor` instead of
`transparent`. A registered `<color>` property keeps the keyword as its
computed value and resolves it against each element's own colour, so an
unpublished `#current` is indistinguishable from the keyword. With
`transparent` it would have rendered invisible — the same failure mode as the
`@property` amplification behind the ui-kit outage.
- `colorStyle` publishes `--current-color` for every colour, not only a named
token. A literal `color: 'red'` has to displace an ancestor's token colour, or
a descendant's `#current` reads the ancestor's.
- `#current.N` deliberately keeps `currentcolor` inside its `color-mix()`. The
mix composes, so a nested fade must read the already-faded colour that reaches
it; the variable would carry the outer fade's own operand and mix it twice.
`#current.4` with `#current.18` under it still lands at `.072`.
A value that already reads the inherited colour is not republished: publishing
`var(--current-color)` into itself is a self-reference, which invalidates the
declaration silently, and republishing a `color-mix()` over the keyword would
resolve it again one level down.
Behaviour verified on Safari 16.5.1, 17.3 and 18.4 before writing this, and the
whole chain is pinned by computed-style tests in a real engine — including the
regression I hit on the way, where an unpublished `#current` went transparent
because `DEFAULT_PROPERTIES` still registered it as such.
Still uncovered, unchanged from before: a token defined as `#current` and faded
where nothing published the variable. That origin is the keyword again, so that
one case needs Safari 18.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(plugins): warn only for a unitless misscaled channel
The check ran on the parsed number, so `150%` — legitimate over-saturation that
clamps — parsed to `1.5` and tripped the missing-`%` warning. Worse, the warning
is deduped per function, so that false positive burned the slot and silenced the
genuine `80` typo that came after it.
The raw token decides now, not the parsed value: a channel is misscaled only when
it carries no `%` and still exceeds 1. The message names just the offending
channels rather than both.
Also moved the spy and env cleanup out of the test bodies into `afterEach`. An
assertion that threw mid-test used to skip `mockRestore()`, leaking a mocked
`console.warn` — call history included — into the following tests and turning one
real failure into six. Verified: breaking one assertion now fails exactly one
test.
Reported by @tenphi in review.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #93.
Takes suggestion 1 from the issue — the stated first preference: make the writers accept 0–1 so the library is self-consistent — plus suggestion 4's doc fixes.
What changed
formatOkhsl/formatOkhst/formatRgb/formatHsl/formatOklchnow reads/l/ton the 0–1 factor scale every producer in the library returns (resolve(),variantToOkhsl,srgbToOkhsl,oklabToOkhsl,okhslToSrgb). The percentages are an output detail: the writers scale by 100 themselves where the CSS syntax asks for one. So the compositionvariantToOkhsl's doc points at is now correct with nothing in between:and it round-trips:
glaze.color(formatOkhst(v.h, v.s, v.t))comes back with the sames/t.The old call shape no longer fails silently either. A value above 1 cannot be a factor, so it can only be pre-2.0 percentage-scale input — the writers
console.warnonce per writer (deduped for the process) and emit an obviously-broken7040.68%rather than a plausible near-black.Docs, per suggestion 4:
okhslToSrgbnow states its scale (the one converter that stated no units), andvariantToOkhsl's comment spells out thats/lcome back on the same 0–1 scale the writers take, so it no longer points at a bug.Compatibility
Breaking for direct callers of the writers, so the changeset is
major. The fix is to drop the* 100:Nothing else moves —
hwas always 0–360,alphaalways 0–1 — and no export method changes its output:css()/tokens()/tasty()/json()/dtcg()/tailwind()/glaze.format()were compensating internally, and now just stop.I diffed the full export surface (4 theme seeds × every format × css/json/tasty/tailwind/dtcg/splitHue, before vs. after) to confirm. One string differs anywhere: a fully-desaturated shadow emits
hsl(0 0% 18.07% / 0.3554)where it used to emithsl(340 0% 18.07% / 0.3554). Dropping the redundant×100 ÷100round-trip moves the sRGB channels by ~1.7e-16, which flips which channelformatHslpicks asmaxwhen deriving hue — and at0%saturation the hue term names no color either way. Same color, one fewer float round-trip.If you'd rather ship this as a
minor(these are the advanced-use re-exports, not the main surface), the only edit needed is the bump in.changeset/plain-scales-align.md.Tests
New
src/okhsl-color-math.test.ts(12 tests) covers the scale contract, agreement with the converters each writer wraps, the pastel recompute on the new scale, the percentage-scale guard (warns on 0–100 input, once per writer, silent at the1endpoint), and theresolve()→ writer → parser round-trip from the issue.pnpm typecheck,pnpm lint,pnpm format:check,pnpm build, andpnpm test(386 tests) all pass; the pre-existing 374 are untouched.Generated by Claude Code