Skip to content

feat: treat modern CSS color functions as colors - #266

Merged
tenphi merged 2 commits into
mainfrom
feat/modern-color-functions
Aug 21, 2026
Merged

feat: treat modern CSS color functions as colors#266
tenphi merged 2 commits into
mainfrom
feat/modern-color-functions

Conversation

@tenphi

@tenphi tenphi commented Aug 21, 2026

Copy link
Copy Markdown
Owner

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.

fill: 'color-mix(in oklab, #primary 50%, #surface)',
color: 'light-dark(#dark, #light)',
color: 'contrast-color(#primary)',
fill: 'color(display-p3 1 .5 0)',
border: '1bw solid oklch(from #primary l c h / 50%)',
shadow: '0 0 1x color-mix(in oklab, #dark 20%, transparent)',

light-dark() and contrast-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:

Input Before After
fill: 'light-dark(#a, #b)' background-color: light-dark(#a, #b) — raw DSL, tokens unresolved tokens expanded
border: '2bw solid light-dark(#a, #b)' colour dropped, fell back to var(--border-color, currentColor) colour applied
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 by split(',') layer kept whole
'#brand': 'color-mix(…)' (token) --brand-color-oklch silently took the first operand's components, so #brand.5 rendered one operand instead of the mix companion expressed by reference
#brand.5 where #brand is a color-mix() replace token color-mix(in oklab red 50% blue / .5) — invalid; top-level commas were replaced with spaces color-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 hide padding: '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.
  • Derived vs channel functions. color-mix, color-contrast, contrast-color and light-dark build a colour out of other colours and take no alpha channel, so an opacity suffix wraps the call in color-mix() — the same treatment #current.5 already got. Channel functions (rgb, oklch, color, …) keep the slash alpha.
  • shadow splits layers through the parser rather than split(','). The parser already groups on top-level commas, so a colour function's own commas survive.
  • Components companions that cannot be computed are expressed by reference with CSS relative colour syntax — --brand-color-oklch: from color-mix(…) l c h — so #brand.alpha still 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 a var() reference found inside a colour function as the colour's own name: color-mix(in oklab, #purple 50%, #red) is not named purple.
  • The tokens prop keeps the whole fallback chain in the companion: (#primary, #fallback) now yields var(--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.tsfill, border, outline, shadow, color and a #token definition, plus padding: '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@property typing of a referenced companion.
  • src/applied-styles.test.tsx — proof in headless Chromium that both a color-mix() fill and an opacity suffix on a derived-colour token reach getComputedStyle with the right value.

pnpm hygiene clean, pnpm size within 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

`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>
@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

📦 Snapshot release

Published 0.0.0-snapshot.320aa48.

pnpm add @tenphi/tasty@0.0.0-snapshot.320aa48

Comment thread src/properties/property-type-resolver.ts Dismissed
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>
@tenphi
tenphi merged commit b90760c into main Aug 21, 2026
6 of 7 checks passed
@github-actions github-actions Bot mentioned this pull request Aug 21, 2026
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.

2 participants