feat: apply token opacity with color-mix() - #268
Conversation
📦 Snapshot releasePublished |
`#purple.5` now emits `color-mix(in oklab, var(--purple-color) 50%,
transparent)` where it emitted `oklch(var(--purple-color-oklch) / .5)` before.
The colour is unchanged — mixing premultiplied against a fully transparent
colour leaves the channels alone and sets the alpha — but it no longer needs the
token decomposed into channels first, so the suffix works where it used to
break: a token holding a `color-mix()` or a `light-dark()` with no channels to
decompose, and a `--name-color` declared in hand-authored CSS with no companion
variable behind it, which previously fell back to the `@property` initial value
and silently rendered black.
The mixing space is always `oklab`, whatever `colorSpace` is set to: alpha
application is space-independent, and oklab is unbounded, so a wide-gamut colour
survives a round trip that `in srgb` would clamp.
`--name-color-{space}` companions are untouched — still generated, still
registered as `@property`, and still what addresses a token's channels. An
opacity suffix does not move them: the wrapper is peeled off before the chain is
converted, so `color="#purple.5"` still reports `#purple`'s own channels.
Generated CSS changes for every `#token.alpha` value, so consumer snapshots over
emitted CSS or computed styles need updating, and the effective support floor
moves to Safari 16.2. The public API is unchanged.
Also fixes `color(name, 0.07)` emitting `7.000000000000001%`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1364735 to
bf472cc
Compare
`RE_ALPHA_MIX` took the alpha percentage as the last run of non-space, non-comma characters, which is wrong for a dynamic one: a `$prop` suffix builds `calc(var(--fade) * 100%)`, whose own spaces split the match in the middle — colour `var(--a-color) calc(var(--fade) *` and percentage `100%)`. Both callers happened to survive it (the colour still began with a matchable `var()`, and the mangled percentage failed the `%` check that reports a dynamic alpha as no number), so nothing was visibly broken. Replaced with `parseAlphaMix()`, which finds the last space at paren depth zero, and made the comma whitespace optional while there: the parser drops it when it re-serializes a colour function, so a hand-written `color-mix(in oklab, #purple 50%, transparent)` came back spaceless and missed the peel, leaving the components companion on the indirect `from color-mix(…) r g b` instead of the token's own channels. Found while testing the snapshot against cube-ui-kit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tenphi
left a comment
There was a problem hiding this comment.
I found two uncovered regressions in the opacity-suffix semantics.
| return { | ||
| bucket: Bucket.Color, | ||
| processed: `${getColorSpaceFunc()}(var(--${base}-color-${getColorSpaceSuffix()}) / ${alpha})`, | ||
| processed: mixAlpha(`var(--${base}-color)`, rawAlpha), |
There was a problem hiding this comment.
[P1] Preserve alpha-replacement semantics for runtime tokens
Wrapping the full variable multiplies any alpha already present in the token instead of replacing it as the old component/slash path did (and as the replace-token path still does). For a runtime token whose value is rgb(255 0 0 / .8), #token.5 now computes to alpha 0.4; before this change, and for the equivalent replace token today, it is 0.5. I confirmed the two results in Chromium. The same regression is present in the changed color(name, opacity) helper, which now also wraps --name-color. This makes runtime and replace tokens behave differently and visibly changes valid existing inputs, so both paths need to keep overriding the token's existing alpha (or the behavior needs to be treated as an intentional breaking change).
| alpha = `.${rawAlpha}`; | ||
| } | ||
|
|
||
| // Opacity composes onto the color variable itself, not onto its channel |
There was a problem hiding this comment.
[P1] Keep percentage-valued opacity variables valid
calc(var(--fade) * 100%) only works when --fade resolves to a unitless number. The previous slash-alpha form accepted either a <number> or a <percentage> (and the property resolver explicitly registers opacity variables as <number> | <percentage>). With --fade: 50%, this becomes percentage-times-percentage, so Chromium drops the whole color-mix() declaration; the same expression works for --fade: .5. Please preserve percentage inputs as well, or narrow and validate the public contract rather than silently emitting invalid CSS.
| /** Apply an opacity suffix to a color. */ | ||
| function mixAlpha(color: string, rawAlpha: string): string { | ||
| return `color-mix(in oklab, ${color} ${alphaSuffixToPercentage(rawAlpha)}, transparent)`; | ||
| return mixColorAlpha(color, alphaSuffixToPercentage(rawAlpha)); |
There was a problem hiding this comment.
[P2] Normalize numeric suffixes before emitting percentages
Now that this helper is used for every color token, ordinary DSL inputs inherit the same floating-point artifact that color() explicitly fixes in this PR: #token.07 emits 7.000000000000001%, and #token.29 emits 28.999999999999996%. The old slash form preserved the authored digits (.07, .29), so this is unnecessary generated-CSS churn and can leak through parseColor().opacity too. Please use the same bounded formatting here (or convert the digit string without floating-point multiplication).
Review of #268 found two real regressions in `color-mix(in oklab, C p%, transparent)`, both confirmed in Chromium: - It *composes* alpha instead of replacing it. A token holding `rgb(255 0 0 / .8)` faded to `.5` came out at `.4`, where the channel- components form gave `.5` — and where a statically-known colour still gives `.5`, so runtime and replace tokens disagreed. - `calc(var(--fade) * 100%)` is only valid when the property holds a unitless number. The old slash form took a `<number>` or a `<percentage>`, which is exactly how the property resolver registers `--*-opacity`. With `--fade: 50%` the whole declaration was dropped. `oklch(from <colour> l c h / <alpha>)` fixes both: it writes the alpha slot rather than compositing, and that slot takes either form, so the reference passes straight through. The authored digits survive too, which removes the third finding — `.07` no longer becomes `7.000000000000001%`. oklch over oklab because the two are interchangeable here (channels are copied, not interpolated — verified identical for achromatic origins, where a polar space could have carried an undefined hue) and oklch is the shape the computed value was already reported in, so consumers snapshotting computed styles see no change at all. It stays fixed regardless of `colorSpace`: a gamut-limited space would clamp a wide-gamut colour, now covered by a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
All three findings addressed in 2439331. I reproduced each in Chromium first — both P1s are exactly as described:
Fix: relative color syntax rather than a mix. Everything the mix was chosen for is retained — it still needs nothing from the colour but that it is one, so a
Semantics now consistent between runtime and replace tokens: both replace the alpha. A statically-known colour is still faded in place ( New coverage: alpha replacement over an existing alpha, an opacity property in both number and percentage form, authored digits surviving verbatim, gamut preservation, and 2075 tests pass, hygiene/knip clean, size within limits. Changeset stays |
Chromatic on cube-ui-kit caught a real visual regression: disabled+selected buttons on the `current` theme came out 2.5x more opaque. `#current.N` and `#token.N` have never meant the same thing. A token *names* a colour, so fading it sets its alpha — that is what the channel-components form did, and what relative colour syntax now does. `currentcolor` is the colour an element *inherits*, which an ancestor may already have faded, so `#current.4` means "40% of what reaches me"; it has always gone through `color-mix()` and composed. Moving everything onto relative syntax quietly unified them onto replace, and ui-kit's `current` ramp is authored against the composition — its own comment reads ".18 renders as ~.07", which measures 0.072 composing and 0.18 replacing. So `#current` goes back to `color-mix()`, unchanged from before this PR, and the two paths stay distinct with a test pinning the difference. Net behaviour change for both is now zero; only the mechanism behind the token path moved. Its percentage is derived by shifting the decimal point rather than multiplying, so `#current.07` emits `7%` rather than `7.000000000000001%` — the third review finding, which the token path avoids by keeping the authored digits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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): 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>
Follow-up to #266.
What
The color-token opacity suffix now applies alpha to the token's color, not to its decomposed channel components:
Same color. Mixing premultiplied against a fully transparent color leaves the channels untouched and sets the alpha, so the result is exactly what the channel-level
/ <alpha>produced — verified against the old form in headless Chromium, including adisplay-p3color thatin srgbwould have clamped.Why
The old form needed the token decomposed into numbers before opacity could be applied. That is not always possible, and when it failed it failed silently:
--ink-colordeclared in your own CSS, no Tasty token definition--ink-color-oklchexists →@propertyinitial value → renders black'#brand': 'color-mix(…)'then#brand.5@property … syntax: "*"to survive#current.5color-mix()So this removes a class of silent wrong-color bugs and collapses three code paths into one.
The mixing space is always
oklab, whatevercolorSpaceis set to — alpha application is space-independent, and oklab is unbounded, so a wide-gamut color survives the round trip.What is not changing
--name-color-{space}companions stay. Still generated, still registered as@property, still typed<number>+where the channels are numeric, and still what you reach for to address a token's channels. They're just no longer load-bearing for opacity.convertColorChainToComponentChainpeels the wrapper before converting, socolor="#purple.5"still emits--current-color-oklch: var(--purple-color-oklch)— components carry no alpha.#brand: 'hsl(220 90% 50%)'→#brand.5→hsl(220 90% 50% / .5)) and a derived one is wrapped. Shorter output, no needless space conversion, and thecolor()/okhsl()plugin paths keep working unchanged.What changes for consumers
#token.alpha. The color is the same, but consumer snapshots over emitted CSS orgetComputedStyleneed updating — computed colors serialize asoklab(…)now.color-mix()is required on the common path, moving the effective floor to Safari 16.2 (from 15.4). Chrome 111 and Firefox 113 unchanged;#current.5already required it.parseColor().colorreturns the wrapper..nameand.opacityare unchanged — both are read through it.The public API is unchanged, so the changeset is
minor.Also fixed
color(name, 0.07)emitted7.000000000000001%—opacity * 100is not exact in IEEE 754 for 8 of the 99 hundredths.Tests
pnpm test— 2066 pass (70 files). New coverage:src/applied-styles.test.tsx— the headline case (a colour variable Tasty never registered, faded correctly), and an equivalence check provingcolor-mixalpha == channel-slash alpha for sRGB, oklch and display-p3 colours.src/parser/parser.test.ts— the new form for.N,.NN,.0,.$prop, a hex token, and that the mixing space ignorescolorSpace.src/utils/styles.test.ts—parseColorreading name and opacity through the wrapper, dynamic alpha reporting no number, and thecolor()float-artifact cases.src/styles.test.ts— the companion still naming the faded token's own channels, viacolorStyleand via a#tokendefinition.tasty.test.tsx.snapshow--current-color-oklchsurviving the change intact.pnpm hygieneclean,knipclean,pnpm sizewithin limits (static18.86/19.25 kB), public API snapshot unchanged.🤖 Generated with Claude Code