Skip to content

fix: teach cn() the Tailwind v4 parenthesis hint spelling - #1357

Merged
vivek7405 merged 2 commits into
mainfrom
fix/cn-paren-hint-spelling
Aug 9, 2026
Merged

fix: teach cn() the Tailwind v4 parenthesis hint spelling#1357
vivek7405 merged 2 commits into
mainfrom
fix/cn-paren-hint-spelling

Conversation

@vivek7405

@vivek7405 vivek7405 commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Closes #1338

Tailwind v4 added a second spelling for a type-hinted arbitrary value that uses parentheses instead of brackets, and cn() could not read it. variantPrefix counted only bracket depth, so the colon inside shadow-(color:--x) read as a variant separator and the group matcher was handed the fragment --x), which matches nothing. The utility ended up ungrouped. That failed in the safe direction, since nothing was dropped, but it gave up the other half of the guarantee: two utilities setting the identical property did not collapse, so the winner fell to compiled stylesheet order.

What changed

Three edits, in each of the two hand-synced copies (packages/ui/packages/registry/lib/utils.ts and examples/blog/lib/utils/cn.ts):

  1. variantPrefix() counts parens in a SECOND counter and splits only where both depths are zero.
  2. hintedGroup()'s regex accepts -( alongside -[, one character class.
  3. borderGroups()'s width fragment gains one alternation branch for (length:...).

No new HINTED_GROUPS entries. Both spellings produce the identical <prefix>:<hint> key the map is already keyed on, which is why the fix is this small.

Two counters, not one

A single shared counter lets a stray ) cancel a live [, so x-[y)-z:w reads as having a top-level colon and the matcher gets the fragment w. That is precisely the bug this PR removes, wearing a different hat. Tailwind's own top-level splitter is a matched-pair stack, and two counters agree with it on every well-formed class and every unbalanced-delimiter case in the issue's corpus. Porting the full stack was considered and rejected: the only input it decides differently is a string Tailwind cannot compile, and cn() is deliberately small and auditable (package invariant 2).

Why all three edits land together

A variantPrefix-only fix is worse than the bug. It hands an intact bg-(image:--g) to a matcher that cannot read the hint, the token falls through to the ^bg- catch-all as bg-color, and it evicts a real background colour. Same for text-, and same for border- if the borderGroups() fragment is left out. That is the #1065 defect class, so the halves are one change.

Test plan

  • Unit packages/ui/test/cn-helper.test.js: 21/21. The two fix: split the coarse bg, shadow and text-shadow cn() groups by property #1332 pinning assertions are inverted with their comment rewritten, and a new #1338 test covers 30 assertions across the prefixes a partial fix damages. The other 201 pre-existing assertions pass unchanged.
  • Unit + cross-runtime test/ui/cn-copies-in-sync.test.mjs: 2/2 on Node and under Bun. Thirteen paren tokens joined the shared TOKENS battery, so every ordered pair merges through both copies and is compared.
  • Counterfactual, each of the three logic edits reverted individually against the committed fix, all three individually load-bearing:
    • variantPrefix's paren counter reverted: 2 test failures (the new #1338 test and the box-shadow test carrying the two inverted pins).
    • hintedGroup()'s regex reverted: 1 test failure. The shadow pins survive here, because shadow-(color:--x) still reaches the ^shadow- catch-all through GROUPS once variantPrefix stops mangling it.
    • borderGroups()'s width fragment reverted: 1 test failure, and it is a DROP rather than a non-collapse. cn('border-(length:--w)', 'border-primary') returns border-primary, losing the width. That is the defect the third edit exists to prevent.
  • Repo-root npm test: 4150 tests, 4142 pass, 7 fail, and all 7 are pre-existing. Five are the known linked-worktree baseline (the listener pair and three elision assertions, which pass in a primary checkout and in CI). The other two are test/scaffolds/gallery-coverage.test.js, which I reproduced on an untouched primary checkout at 79fc28fc with none of this change present.
  • npm test --workspace=@webjsdev/ui: 210/210.
  • webjs check from examples/blog: all checks pass.
  • Layers N/A: browser, e2e and smoke. cn() is a pure string function with no DOM, no network and no app-boot behaviour, and the N by N sweep in the issue shows zero changed pairs where neither token contains a parenthesis.
  • New test/bun/<feature>.mjs N/A: the Bun parity hook gates on ^packages/([^/]+/src|editors/[^/]+/src|cli/lib)/, which neither touched source path matches. Cross-runtime coverage is real regardless, through the drift file above.

Docs

  • Updated packages/ui/AGENTS.md: the paren-gap bullet described the gap as live behaviour and is rewritten rather than deleted, since the hintedGroup() centrality lesson still applies.
  • Updated the variantPrefix, HINTED_GROUPS and borderGroups() header comments in both copies.
  • N/A .agents/skills/webjs/references/styling.md: its coarseness caveat names two other gaps and never named this one, so nothing there became false.
  • N/A the docs site and marketing website: neither mentions cn()'s hint handling. website/lib/utils/cn.ts is generated from the registry and inherits the fix.
  • N/A scaffold templates: a scaffolded app's lib/utils/cn.ts is copied verbatim from the registry at create time.

Merge dependency

#1320 edits the same utils.ts in a different region (it memoises the GROUPS table at L53 to L145 and its read site at L286). This diff stays strictly inside variantPrefix(), hintedGroup() and one string literal in borderGroups(), so the two land on separate hunks. #1320 merges first; this branch rebases onto main and re-runs before merging.

@vivek7405 vivek7405 self-assigned this Aug 9, 2026
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Design rationale: two rejected ways to avoid touching borderGroups()

The borderGroups() width-fragment edit is the only line of this change that sits outside the two functions the issue title names, and it is also the line closest to #1320's region in the same file. So it is worth recording why I took it anyway, because both ways of avoiding it look cheaper than they are.

The first is to give the paren border hint its own isolated hint:border:length bucket, by returning the key instead of null from the border branch in hintedGroup(). That is not a regression: the bucket collides only with itself, so both classes survive, and it needs no borderGroups() edit at all, which would remove every overlap with #1320. I rejected it because it makes the paren spelling behave differently from its bracket sibling. border-[length:var(--w)] collapses against border-2 today and the paren form would not, and removing exactly that asymmetry is the whole point of the change.

The second is to have hintedGroup() return the border group name directly, computing border-w${seg} from the prefix. That keeps the entire diff inside hintedGroup(). I rejected it because it duplicates borderGroups()'s group-naming scheme in a second place, which the comment right above that branch explicitly warns against: the side list has to match the one borderGroups() enumerates, logical inline sides included, or the hinted and plain spellings of one utility land in different groups. One naming authority is worth the small merge risk, and the merge risk really is small since the width literal sits about 70 lines from #1320's nearest edit.

A related judgement: hintedGroup() does not check that a -( hint closes with ) rather than ]. The group is the CSS property the hint names, and how the value terminates cannot change which property that is. A mismatched delimiter is not a compilable Tailwind class either way, and the bracket branch has never validated its own close, so validating only the paren half would manufacture a fresh asymmetry between the two spellings.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Read the whole diff. The three logic edits are right, and I checked the part I most wanted checked: the change is surgical, every pair it moves involves a paren token, and the two hand-synced copies still agree on the full ordered-pair sweep. The two-counter choice is the one call here that could have quietly gone wrong and it holds up against Tailwind's own matched-pair splitter.

What I did miss is doc sync, in the file an agent reads first. Two findings there, both on unchanged lines so they cannot be anchored inline:

packages/ui/AGENTS.md L481 to L484 still says a variant prefix is split on the last colon OUTSIDE square brackets, which is exactly what the bullet I rewrote further down now contradicts. Both source copies got that sentence updated and this one did not, so the file argues with itself about what variantPrefix does. That is worse than having left the whole thing alone.

packages/ui/AGENTS.md L485 opens "Once a bracketed value reaches the matcher, its TYPE HINT names the property", which scopes the hint rule to brackets, but hintedGroup() reads -[ and -( alike now.

Sweeping for that same stale sentence turned up a third the review did not name: packages/ui/test/cn-helper.test.js L123 and L141 carry it too. Folding all of them into this PR.

Comment thread packages/ui/packages/registry/lib/utils.ts Outdated
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Resolution: the two unanchorable AGENTS.md findings, plus a third they led me to

Both doc findings from the review are fixed in e1a55c54, along with one the review did not name.

packages/ui/AGENTS.md L481 now says the split is the last colon at top level, outside both delimiters, and points at the paren bullet for why the two counters are separate. L485 is rescoped from "Once a bracketed value reaches the matcher" to "Once an arbitrary value reaches the matcher, in either spelling".

Grepping every surface for that same stale sentence turned up packages/ui/test/cn-helper.test.js L123 and L141 carrying it too, so those are corrected in the same commit. The sweep also confirmed there is nowhere else: the only other hits are the two source copies and the generated website/lib/utils/cn.ts, all of which already carry the new wording.

Worth naming the pattern, since it is the more useful lesson than the individual lines. Rewriting one bullet in a file and leaving its neighbours asserting the behaviour the rewrite contradicts is a worse outcome than not touching the file at all, because a reader who hits the stale bullet first has no way to know it lost. The issue's docs section enumerated exactly one bullet, and I treated that enumeration as the surface list rather than grepping for the claim itself. The grep is what should have driven it.

@vivek7405
vivek7405 marked this pull request as ready for review August 9, 2026 08:28
@vivek7405
vivek7405 force-pushed the fix/cn-paren-hint-spelling branch from e1a55c5 to d4ab339 Compare August 9, 2026 09:46
Tailwind v4 added `shadow-(color:--x)` as shorthand for
`shadow-[color:var(--x)]`. `variantPrefix` counted only bracket depth, so
the colon inside the parentheses read as a variant separator and the
matcher was handed the fragment `--x)`, which matches nothing. The
utility ended up ungrouped: safe, in that it never evicts, but two
utilities setting the identical property no longer collapse, so the
winner falls to compiled stylesheet order.

Three edits, and all three are load-bearing. `variantPrefix` gains a
second counter for parens and splits only where both depths are zero.
`hintedGroup()`'s regex accepts `-(` alongside `-[`. And
`borderGroups()`'s width fragment reads the paren length hint.

Two separate counters rather than one shared one, because a shared
counter lets a stray `)` cancel a live `[` and reads `x-[y)-z:w` as
having a top-level colon, reintroducing the same fragment bug on a
different input. Tailwind's own splitter is a matched-pair stack, and
two counters match it on every well-formed class.

Teaching `variantPrefix` paren depth alone is worse than leaving the bug
alone: it hands an intact `bg-(image:--g)` to a matcher that cannot read
the hint, which falls through to the `^bg-` catch-all and evicts a real
background colour. That is the #1065 defect class, so the halves land
together.
The paren-gap bullet in packages/ui/AGENTS.md was rewritten, but two
bullets above it still asserted the split is the last colon outside
square brackets, so the file contradicted itself about what
variantPrefix does. Two test comments carried the same stale sentence.

Also qualify the segment() citation. It read as a repo-local path in a
monorepo that has its own packages/ root, and the blog copy had dropped
the path entirely, so the same rationale cited different evidence in the
two hand-synced copies.
@vivek7405
vivek7405 force-pushed the fix/cn-paren-hint-spelling branch from d4ab339 to b0bdcde Compare August 9, 2026 10:21
@vivek7405
vivek7405 merged commit 12a0666 into main Aug 9, 2026
10 checks passed
@vivek7405
vivek7405 deleted the fix/cn-paren-hint-spelling branch August 9, 2026 10:21
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.

Teach cn() the Tailwind v4 parenthesis hint spelling

1 participant