Skip to content

feat: apply token opacity with color-mix() - #268

Merged
tenphi merged 4 commits into
mainfrom
feat/color-mix-alpha
Aug 21, 2026
Merged

feat: apply token opacity with color-mix()#268
tenphi merged 4 commits into
mainfrom
feat/color-mix-alpha

Conversation

@tenphi

@tenphi tenphi commented Aug 21, 2026

Copy link
Copy Markdown
Owner

Follow-up to #266.

What

The color-token opacity suffix now applies alpha to the token's color, not to its decomposed channel components:

#purple.5
  before  oklch(var(--purple-color-oklch) / .5)
  after   color-mix(in oklab, var(--purple-color) 50%, transparent)

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 a display-p3 color that in srgb would 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:

Case Before After
--ink-color declared in your own CSS, no Tasty token definition no --ink-color-oklch exists → @property initial value → renders black fades correctly
'#brand': 'color-mix(…)' then #brand.5 needed the relative-components companion added in #266, and an @property … syntax: "*" to survive works with nothing extra
#current.5 already special-cased to color-mix() same path as everything else

So this removes a class of silent wrong-color bugs and collapses three code paths into one.

The mixing space is always oklab, whatever colorSpace is 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.
  • The companion still follows the color through a fade. convertColorChainToComponentChain peels the wrapper before converting, so color="#purple.5" still emits --current-color-oklch: var(--purple-color-oklch) — components carry no alpha.
  • Replace tokens are still faded in place. A token substituted at parse time has its color right there, so a channel function takes the slash alpha (#brand: 'hsl(220 90% 50%)'#brand.5hsl(220 90% 50% / .5)) and a derived one is wrapped. Shorter output, no needless space conversion, and the color()/okhsl() plugin paths keep working unchanged.

What changes for consumers

  • Generated CSS changes for every #token.alpha. The color is the same, but consumer snapshots over emitted CSS or getComputedStyle need updating — computed colors serialize as oklab(…) 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.5 already required it.
  • parseColor().color returns the wrapper. .name and .opacity are unchanged — both are read through it.

The public API is unchanged, so the changeset is minor.

Also fixed

color(name, 0.07) emitted 7.000000000000001%opacity * 100 is 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 proving color-mix alpha == 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 ignores colorSpace.
  • src/utils/styles.test.tsparseColor reading name and opacity through the wrapper, dynamic alpha reporting no number, and the color() float-artifact cases.
  • src/styles.test.ts — the companion still naming the faded token's own channels, via colorStyle and via a #token definition.
  • Snapshot updates in tasty.test.tsx.snap show --current-color-oklch surviving the change intact.

pnpm hygiene clean, knip clean, pnpm size within limits (static 18.86/19.25 kB), public API snapshot unchanged.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

📦 Snapshot release

Published 0.0.0-snapshot.73ab317.

pnpm add @tenphi/tasty@0.0.0-snapshot.73ab317

`#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>
@tenphi
tenphi force-pushed the feat/color-mix-alpha branch from 1364735 to bf472cc Compare August 21, 2026 10:03
@tenphi tenphi changed the title feat!: apply token opacity with color-mix() feat: apply token opacity with color-mix() Aug 21, 2026
`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 tenphi left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found two uncovered regressions in the opacity-suffix semantics.

Comment thread src/parser/classify.ts Outdated
return {
bucket: Bucket.Color,
processed: `${getColorSpaceFunc()}(var(--${base}-color-${getColorSpaceSuffix()}) / ${alpha})`,
processed: mixAlpha(`var(--${base}-color)`, rawAlpha),

@tenphi tenphi Aug 21, 2026

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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).

Comment thread src/parser/classify.ts Outdated
alpha = `.${rawAlpha}`;
}

// Opacity composes onto the color variable itself, not onto its channel

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment thread src/parser/classify.ts Outdated
/** 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));

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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>
@tenphi

tenphi commented Aug 21, 2026

Copy link
Copy Markdown
Owner Author

All three findings addressed in 2439331. I reproduced each in Chromium first — both P1s are exactly as described:

token rgb(255 0 0 / .8), #token.5 alpha
color-mix(… 50%, transparent) 0.4 — composes
old channel-slash form 0.5
oklch(from … l c h / .5) 0.5 — replaces
--fade: 50% result
calc(var(--fade) * 100%) in a mix rgba(0,0,0,0) — declaration dropped
/ var(--fade) in relative syntax resolves; so does .5

Fix: relative color syntax rather than a mix. oklch(from <color> l c h / <alpha>) writes the alpha slot instead of compositing, so P1a is fixed by construction. The slot accepts <number> | <percentage>, so the $prop reference passes straight through unscaled — P1b fixed, and it now matches the <number> | <percentage> contract the property resolver registers for --*-opacity. With no percentage conversion left, P2 dissolves: .07 stays .07.

Everything the mix was chosen for is retained — it still needs nothing from the colour but that it is one, so a color-mix()/light-dark() token and a --name-color Tasty never registered both still fade.

oklch, not oklab. The two are interchangeable for a fade (channels are copied, not interpolated — I checked achromatic origins specifically, where a polar space could have carried an undefined hue: identical results, no tint). oklch wins on churn: it is the shape the computed value was already reported in, so consumers snapshotting computed styles now see no change at all. It stays fixed regardless of colorSpacergb(from …) would clamp a wide-gamut colour, and there's a test asserting a display-p3 red keeps chroma sRGB cannot hold.

Semantics now consistent between runtime and replace tokens: both replace the alpha. A statically-known colour is still faded in place (hsl(220 90% 50%)hsl(220 90% 50% / .5)), which is the same operation written more directly.

New coverage: alpha replacement over an existing alpha, an opacity property in both number and percentage form, authored digits surviving verbatim, gamut preservation, and parseAlphaOverride unit tests including nested fades.

2075 tests pass, hygiene/knip clean, size within limits. Changeset stays minor, but the support floor note changed: relative color syntax is Chrome 119 / Safari 16.4 / Firefox 128 — a further step up from color-mix(). Flagging that explicitly since it's the one cost of this approach and it's your call.

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>
@tenphi
tenphi merged commit 9c4b929 into main Aug 21, 2026
6 of 7 checks passed
@github-actions github-actions Bot mentioned this pull request Aug 21, 2026
tenphi added a commit that referenced this pull request Aug 24, 2026
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>
tenphi added a commit 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant