feat: treat modern CSS color functions as colors - #266
Merged
Conversation
`light-dark()` and `contrast-color()` join `color-mix()`, `color-contrast()`,
`color()` and the channel functions as recognized colors, so a call lands in the
color slot of whichever style property uses it, with the tokens inside it
expanded.
`light-dark()` is filed by content — CSS lets it pick between values of any
type, so `light-dark(#dark, #light)` is a color while `light-dark(1x, 2x)` stays
a value.
Along the way:
- `shadow` splits layers through the parser instead of `split(',')`, so a color
function's own commas no longer tear a layer apart.
- An opacity suffix on a replace token resolving to a derived color function
wraps the call in `color-mix()` rather than appending a slash alpha the
function has no channel for.
- A color that cannot be decomposed at build time gets its
`--name-color-{space}` companion expressed by reference with relative color
syntax, registered as `@property … syntax: "*"` so the engine keeps it.
- `parseColor()` no longer reports a `var()` reference found inside a color
function as the color's own name.
- The `tokens` prop keeps the whole fallback chain in the companion instead of
only the last fallback.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
📦 Snapshot releasePublished |
The colour-function support adds roughly 200 B brotli to `static`, which put it 138 B over its limit. Recovered part of that first: - `COLOR_FUNCS` is one Set literal reusing the derived list, instead of two arrays spread into a third Set. - `POLYMORPHIC_COLOR_FUNCS` was a Set of one; it is a string constant now. - `RE_FUNC_CALL` is shared with the predefined-token path, which carried an identical inline regex. - Channel names are a lookup, and the relative-components helper is module-private. The rest is the feature's real cost, so every limit moves up to leave ~0.5 kB of headroom — `zero` and `babel-plugin` were within 10-60 B of theirs and would have failed on the next unrelated change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merged
This was referenced Aug 21, 2026
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.
What
Modern CSS color functions are now recognized as colors, so a call lands in the color slot of whichever style property uses it — and the tokens inside it get expanded.
light-dark()andcontrast-color()are new to the recognized set;color-mix(),color-contrast(),color()and the channel functions were already there but only reached the color slot in some places.Why
Before this, several of these silently produced broken CSS:
fill: 'light-dark(#a, #b)'background-color: light-dark(#a, #b)— raw DSL, tokens unresolvedborder: '2bw solid light-dark(#a, #b)'var(--border-color, currentColor)shadow: '0 0 4px color-mix(in oklab, #a 50%, #b)'box-shadow: 0 0 4px , 50% var(--a-color), var(--b)-color)— the layer was torn apart bysplit(',')'#brand': 'color-mix(…)'(token)--brand-color-oklchsilently took the first operand's components, so#brand.5rendered one operand instead of the mix#brand.5where#brandis acolor-mix()replace tokencolor-mix(in oklab red 50% blue / .5)— invalid; top-level commas were replaced with spacescolor-mix(in oklab, color-mix(…) 50%, transparent)How
light-dark()is filed by content. CSS lets it pick between values of any type, so bucketing it as a colour unconditionally would hidepadding: 'light-dark(1x, 2x)'from the padding handler. Colour when its arguments hold one (a token, a nested colour function, a CSS named colour), value otherwise.color-mix,color-contrast,contrast-colorandlight-darkbuild a colour out of other colours and take no alpha channel, so an opacity suffix wraps the call incolor-mix()— the same treatment#current.5already got. Channel functions (rgb,oklch,color, …) keep the slash alpha.shadowsplits layers through the parser rather thansplit(','). The parser already groups on top-level commas, so a colour function's own commas survive.--brand-color-oklch: from color-mix(…) l c h— so#brand.alphastill resolves, with the browser working out the channels. Such a companion is registered as@property … syntax: "*"; numeric companions keep<number>+and stay animatable. The SSR collector no longer emits a second, conflicting companion rule for it.parseColor()no longer reports the name of avar()reference found inside a colour function as the colour's own name:color-mix(in oklab, #purple 50%, #red)is not namedpurple.tokensprop keeps the whole fallback chain in the companion:(#primary, #fallback)now yieldsvar(--primary-color-{space}, var(--fallback-color-{space}))instead of only the last fallback — matching what style-level tokens already did.Tests
pnpm test— 2048 pass (70 files). New coverage:src/parser/parser.test.ts— classification of each function,light-dark()both ways, nesting, relative colour syntax, opacity suffixes on derived vs channel tokens.src/styles.test.ts—fill,border,outline,shadow,colorand a#tokendefinition, pluspadding: 'light-dark(1x, 2x)'.src/utils/process-tokens.test.ts— companions for derived functions, the operand-vs-token-colour distinction, the fallback chain.src/properties/property-type-resolver.test.ts,src/ssr/ssr.test.ts—@propertytyping of a referenced companion.src/applied-styles.test.tsx— proof in headless Chromium that both acolor-mix()fill and an opacity suffix on a derived-colour token reachgetComputedStylewith the right value.pnpm hygieneclean,pnpm sizewithin limits, public API snapshot unchanged.Follow-up under discussion
Whether all opacity suffixes should go through
color-mix()rather than the components companion — it would work for any colour, including ones defined in plain CSS with no companion at all. Not in this PR.🤖 Generated with Claude Code