Skip to content

fix: substitute $name refs in pass-through style values - #264

Merged
tenphi merged 7 commits into
mainfrom
fix/custom-property-refs-passthrough
Aug 17, 2026
Merged

fix: substitute $name refs in pass-through style values#264
tenphi merged 7 commits into
mainfrom
fix/custom-property-refs-passthrough

Conversation

@tenphi

@tenphi tenphi commented Aug 17, 2026

Copy link
Copy Markdown
Owner

What

$name custom-property references leaked into CSS unsubstituted from any style value a handler emits verbatim.

The parser was doing its job — parseStyle('$current-fill-hover') returns output: 'var(--current-fill-hover)'. The problem is which bucket the token lands in: classify.ts puts a $name reference in the color bucket only when the name ends with -color, otherwise in the value bucket. fillStyle reads only the color bucket and, finding it empty, fell back to the raw input:

result['background-color'] = firstColor || colorValue; // '$current-fill-hover'

So the parsed var(...) was computed and thrown away, and the browser dropped the declaration.

// Previously emitted `background-color: $current-fill-hover` and applied nothing.
tasty({
  styles: {
    '$current-fill-hover': '#current.04',
    fill: { '': '#current.0', hovered: '$current-fill-hover' },
  },
});

Scope

Sweeping all 84 registered style props with $my-prop, $my-prop-color, and ($my-prop, 1x) turned up 23 leaking props — two mirror-image variants of the same mistake:

  • Bucket fallback to raw inputfill / backgroundColor, svgFill, plus the reverse case where a $name-color reference used as a length falls out of values[]: fontSize, lineHeight, letterSpacing, fontWeight, outlineOffset.
  • Never parsed at all — handlers for keyword-valued properties, which pass input straight through since there are no units or color tokens to resolve: display, overflow, whiteSpace, flow, place / align / justify and their longhands, textTransform, font / fontFamily, color, background-clip / -origin / -repeat / -attachment.

The reported case was invalid usage (a color held in a custom property not named $*-color), but display: '$my-display' is perfectly reasonable authoring and was equally broken.

How

Adds resolveCustomProperties() in src/utils/styles.ts and routes every pass-through value and raw fallback through it.

The one detail worth a reviewer's attention: it early-returns unless the value contains a $, because parseStyle case-folds. Blanket-parsing these values would have turned fontFamily: '"Inter", Arial' into '"inter", arial' and var(--myColor) into var(--mycolor) — custom property names are case-sensitive in CSS. Gating on $ means values that were already valid CSS take exactly the path they took before, and the blast radius is limited to values that emit invalid CSS today.

Left alone

border: '1bw \$my-fill' and outline: '1bw \$my-fill' still drop the reference and substitute var(--border-color) / var(--outline-color). The shorthand parsers need a color-bucket token to place it and can't guess — that's the documented consequence of the -color suffix rule (parser.md:167), and \$my-fill-color works there. Making those warn in dev instead of silently defaulting is a separate change.

Testing

  • New src/styles/custom-property-refs.test.ts sweeps every registered style prop and asserts no $ survives into declarations, so a new pass-through handler can't reintroduce this class of bug. Plus per-handler assertions for both bucket directions, and negative cases pinning that $-free values are not case-folded.
  • Full suite green: 1980 tests across 69 files (node + headless Chromium).
  • tsc --noEmit, eslint src, and knip all clean.

🤖 Generated with Claude Code

A `$name` reference is classified as a color only when the name ends with
`-color`; otherwise it lands in the parser's value bucket. Handlers that read
one bucket and fall back to their raw input emitted the authored DSL verbatim,
which browsers drop as an invalid declaration — `fill: '$my-fill'` produced
`background-color: $my-fill`. The mirror case broke too: a `$name-color`
reference used as a length falls out of `values[]`, so `fontSize:
'$my-size-color'` emitted `font-size: $my-size-color`.

Handlers for keyword-valued properties never parsed their input at all, since
there are no units or color tokens to resolve, so every one of them leaked the
raw reference: display, overflow, whiteSpace, flow, place/align/justify and
their longhands, textTransform, font/fontFamily, color, background-clip /
-origin / -repeat / -attachment, and outlineOffset.

Adds `resolveCustomProperties()` and routes those values through it. It
early-returns unless the value contains a `$`, because the parser case-folds:
blanket-parsing would turn `fontFamily: '"Inter", Arial'` into `'"inter",
arial'` and `var(--myColor)` into `var(--mycolor)`. Values that were already
valid CSS take exactly the path they took before.

The new suite sweeps every registered style prop and asserts no `$` survives
into declarations, so a new pass-through handler cannot reintroduce this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

📦 Snapshot release

Published 0.0.0-snapshot.5dd4884.

pnpm add @tenphi/tasty@0.0.0-snapshot.5dd4884

`#name` is the color syntax; the `$name-color` form the parser buckets as a
color exists to reference a raw CSS custom property, not as the way colors are
written. So a plain `$name` token in a `<width> <style> <color>` shorthand
should not be competing for the color slot — it belongs to the style slot, which
has no keyword for a custom property to match and was dropped entirely:
`border: '1bw $my-style'` emitted `1px solid var(--border-color, currentColor)`.

A reference now fills the first free slot — width, then style. Lengths are left
alone: a second length is not valid in these shorthands, and promoting one to
the style slot would emit an invalid declaration instead of ignoring the extra
value. `$name-color` references still land in the color slot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tenphi

tenphi commented Aug 17, 2026

Copy link
Copy Markdown
Owner Author

Follow-up pushed: border / outline now take a $name reference as the line style instead of dropping it.

Rationale (per review): #name is the color syntax. The $name-color form that the parser buckets as a color exists to reference a raw CSS custom property — a recovery path, not the main syntax — so a plain $name token in a <width> <style> <color> shorthand should never have been competing for the color slot. The style slot has no keyword for a custom property to match, which is exactly why the reference was being dropped.

// Before: `1px solid var(--border-color, currentColor)` — reference dropped.
// After:  `1px var(--my-style) var(--border-color, currentColor)`
tasty({ styles: { border: '1bw $my-style' } });

A reference fills the first free slot — width, then style — so border="$my-width $my-style" fills both, and a lone $my-width still takes the width slot.

Two boundaries worth noting:

  • Lengths are left alone. border="1bw 2px" still ignores the second length rather than promoting it to the style slot; two lengths aren't valid in the shorthand, so promoting would emit an invalid declaration instead of dropping an extra value.
  • $name-color still goes to the color slot, so the recovery path keeps working.

Applies per comma group in multi-group syntax (border="1bw $a, 2bw $b top") and alongside the slash offset in outline. Shared extractLineStyle() helper in styles/shared.ts drives both handlers.

Also updated the border / outline JSDoc in styles/types.ts to state the slot rule, since this changes documented shorthand semantics.

Tests: 11 new cases across border.test.ts / outline.test.ts; full suite 1991 passing, tsc / eslint / knip clean.

Filling only width and style left `border: '1bw dashed $my-color'` dropping the
reference: both value slots were taken, the color slot was free, and nothing
claimed it. A reference now fills the first free slot in shorthand order —
width, then style, then color — which is the same rule stated once instead of
stopping at the style slot. An explicit `#name` token still wins the color slot,
and lengths are still left alone.

Also fixes a `whiteSpace` leak the one-prop-at-a-time sweep hid: the value is
emitted a second time by `processTextOverflow` for the clamp, so
`{ textOverflow: 'ellipsis', whiteSpace: '$my-ws' }` still reached CSS as raw
DSL. It is now resolved once at handler entry and passed down. The sweep renders
every prop under companion contexts (`display`, `textOverflow`) so branches that
need a second prop can no longer pass by returning early.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three ways a reference could silently do nothing, all rooted in the parser
guessing at something it cannot know.

A `-color` suffix is the only hint the parser has about an untyped reference, and
it used to decide the bucket outright: the reference went to `colors` alone, so a
handler reading `values` came up empty and emitted its own default. Across 35
style props the authored value vanished — `padding: '$brand-color'` became
`padding: var(--gap)`. Such references are now filed under both buckets
(Bucket.ColorValue), listed once in `all`; border/outline place them once rather
than in both the style and color slots.

The parser lowercased its whole input before classifying, folding
custom-property names, which are case-sensitive in CSS. A camelCase name could
never resolve: the definition emitted `--myVar`, the reference asked for
`var(--myvar)`. Identifier bodies now keep their case via foldDslCase, and every
site deriving a CSS name from one shares normalizeDslName, so definitions and
references agree. A leading capital is not a supported name and folds instead of
being kebab-cased (`$Foo` → `--foo`). Keywords, units, function names and hex
literals fold exactly as before, and predefined-token lookup stays
case-insensitive — only the emitted name preserves case.

`preset` and `transition` interpolate their input into a custom-property *name*,
which cannot be indirected through a reference: the name is needed at build time
and a reference only resolves in the browser. They built
`var(--var(--x)-font-size)` — valid syntax, unusable name, dropped silently by
the browser. Both now warn once in dev and fall back (preset to `inherit`,
transition by skipping the entry). Value slots are untouched.

foldDslCase is on the parse hot path, so it early-returns for input that was
already lowercase or holds no sigil, keeping the common cases at ~13ns over the
single fold it replaced; the parser bench is unchanged. Size budgets are raised
0.25-0.5 kB per entry for the ~250 B of added logic, with headroom kept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tenphi

tenphi commented Aug 17, 2026

Copy link
Copy Markdown
Owner Author

Fixed all three follow-up issues. Each traced back to the parser guessing at something it cannot know about an untyped reference.

1. A -color suffix no longer confines a reference to color slots

Worse than I first reported: sweeping all 84 props showed 35 silently substituting a default, not just gap.

tasty({ styles: { padding: '$brand-color' } });
// Before: padding: var(--gap)        ← authored value gone
// After:  padding: var(--brand-color)

The suffix used to decide the bucket outright, so a handler reading values came up empty and fell back to its own default. Since 35 handlers is not a patchable surface, the fix is in the parser: such a reference is filed under both buckets (Bucket.ColorValue), appearing once in all. A color slot can read it, and so can a value slot. border / outline place it once rather than in both the style and color slots.

The suffix may now add reach but can never cost a slot — asserted by a sweep over every registered prop.

2. Custom-property names keep their case

parser.ts lowercased its whole input before classifying, folding custom-property names — which are case-sensitive in CSS. A camelCase name could therefore never resolve:

tasty({ styles: { '$myVar': '2x', padding: '$myVar' } });
// Before: --myVar: 16px; padding: var(--myvar);   ← two different properties
// After:  --myVar: 16px; padding: var(--myVar);

Per your rule, a leading capital is not a supported name: it folds rather than being kebab-cased ($Foo--foo, previously ---foo), and later characters are untouched. foldDslCase preserves identifier bodies while lowercasing everything else, and all four name-derivation sites (parser, createStyle, processTokens, color tokens) now share normalizeDslName, so definitions and references always agree.

Deliberately unchanged: keywords, units, function names, and hex literals still fold (#FF0000 is a color, not a name), and predefined-token lookup stays case-insensitive — only the emitted name preserves case.

3. A reference where a token name is expected warns instead of emitting dead CSS

preset and transition interpolate their input into a custom-property name, which cannot be indirected through a reference — the name is needed at build time, a reference only resolves in the browser.

tasty({ styles: { preset: '$my-preset' } });
// Before: font-size: var(--var(--my-preset)-font-size, …)  ← valid syntax, unusable name, dropped silently
// After:  font-size: inherit  + a dev warning

transition skips the offending entry (keeping the usable ones in a list) and returns null if none remain. Value slots are untouched: transition: 'fill $my-duration' still works.

Notes for review

  • Hot path: foldDslCase replaced a single .toLowerCase() in the parser. It early-returns when the input was already lowercase or holds no sigil, which keeps realistic inputs at ~13ns over the old fold (down from ~120–300ns in my first version). Parser bench is back at baseline: color tokens 697k vs 632k ops/s, mixed 384k vs 371k, cached path unchanged.
  • Size budgets raised 0.25–0.5 kB per entry for ~250 B of added logic. Flagging as a judgment call: every entry had under 350 B of headroom, so this trips regardless of implementation. I trimmed a dev warning string and deduped a hex regex against the parser's RE_HEX first; happy to revert the bump if you'd rather I shrink further.
  • Two parser tests changed intentionally — they asserted the old single-bucket contract for ($primary-color, $fallback-color).

Tests: 2014 passing (70 files, node + Chromium), +20 including a new src/parser/case.test.ts covering the round-trip that was previously impossible. tsc, eslint, knip, prettier, and size-limit all clean. Docs updated: parser.md §5/§10, configuration.md, ai-agents.md.

The snapshot release job published `0.0.0-snapshot.<sha>` successfully and then
failed posting its PR comment with a 503, during GitHub's 2026-08-17 incident.
Re-running that job cannot succeed for the same sha — npm already holds the
version — so this empty commit gives it a fresh one. No source change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Second attempt: the previous run published its snapshot and then failed posting
the PR comment while GitHub's Issues API was degraded. That API is answering
again, so retry with a fresh sha (npm holds the version for the old one).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread src/parser/parser.ts Dismissed
@tenphi
tenphi merged commit 40fa041 into main Aug 17, 2026
6 of 7 checks passed
@github-actions github-actions Bot mentioned this pull request Aug 17, 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