Skip to content

Split the coarse bg-, shadow-, and text-shadow cn() groups by property #1265

Description

@vivek7405

Problem

PR #1247 established the rule that a cn() conflict group is one CSS PROPERTY, never one class prefix, and fixed the cases #1065 and #1072 reported (border width vs colour, the bare flex / grid display value, and type-hinted arbitrary values). Three prefix-keyed groups were left as they were, because they are pre-existing and none of them is needed for those two issues. They are the same defect class: a utility is evicted by an unrelated utility that merely shares its prefix, and the dropped class is silently gone.

Reproduced against packages/ui/packages/registry/lib/utils.ts at dfefe3ec (the #1247 merge commit):

cn('bg-clip-text', 'bg-primary')       => 'bg-primary'        background-clip DROPPED
cn('bg-origin-border', 'bg-primary')   => 'bg-primary'        background-origin DROPPED
cn('bg-blend-multiply', 'bg-primary')  => 'bg-primary'        background-blend-mode DROPPED
cn('bg-primary', 'bg-clip-text')       => 'bg-clip-text'      background-color DROPPED
cn('shadow-lg', 'shadow-red-500')      => 'shadow-red-500'    box-shadow DROPPED
cn('shadow-red-500', 'shadow-lg')      => 'shadow-lg'         box-shadow-color DROPPED
cn('text-primary', 'text-shadow-lg')   => 'text-shadow-lg'    color DROPPED
cn('text-shadow-sm', 'text-primary')   => 'text-primary'      text-shadow DROPPED

bg-clip-* is the one most likely to bite: the gradient-text idiom is bg-linear-to-r bg-clip-text text-transparent, and any later bg-* colour on the same element silently removes the clip.

Note shadow-[color:red] is already correct, because #1247 routes a type-hinted arbitrary value by its hint. It is only the named-scale spellings (shadow-red-500 against shadow-lg) that still collide, so the fix here is narrower than it looks.

Implementation plan

Decision: extend the existing GROUPS table in packages/ui/packages/registry/lib/utils.ts with more specific entries placed BEFORE the catch-all they carve out of, which is exactly the mechanism #1247 already uses for the border sub-properties (border-collapse / border-spacing / border-style sit above borderGroups() with the comment "These come FIRST so the width / colour classifier below never sees them"). No new helper function, no second scheme. The group boundaries and their key names are taken from tailwind-merge 3.5.0, which solves this exact problem and whose class-group ids for the affected properties are bg-clip, bg-origin, bg-blend, bg-position, bg-size, shadow, shadow-color, text-shadow, and text-shadow-color (read from dist/bundle-mjs.mjs in an installed copy). Using its ids verbatim makes the WebJs table auditable line by line against the industry reference. The keyword sets are enumerated from the real Tailwind v4 source rather than from memory: /home/vivek/Documents/Projects/frameworks/tailwindcss/packages/tailwindcss/src/utilities.ts L3798-3826 defines bg-clip-{border,padding,content,text}, bg-origin-{border,padding,content}, and the sixteen bg-blend-* modes, L2674-2711 defines the size / attachment / position / repeat / image families, and L5567-5670 plus L5418-5510 define shadow and text-shadow. The shadow SIZE vs COLOUR boundary is Tailwind's own resolution order, which special-cases none and inherit, then looks the value up in the --shadow-* theme scale, then falls through to a colour, so the size side is the enumerable scale from theme.css L406-412 plus the deprecated-but-live --shadow and --shadow-inner at L505-506, and everything else after shadow- is a colour. An arbitrary value with no type hint is classified by the same rule tailwind-merge uses, whose source comment states it outright ("Shadow always begins with x and y offset separated by underscore optionally prepended by inset"), so shadow-[0_0_10px_red] is a box-shadow and shadow-[#fff] is a colour.

Rejected:

  • Swapping the hand-rolled merger for tailwind-merge itself. Package invariant 2 forbids third-party runtime deps in @webjsdev/ui, and the helper header already documents the swap as a per-project opt-out.
  • Adding text-shadow to the negative lookahead of the text-color pattern (what the older notes on this issue proposed). Ordering already carves it out completely, and a carve-out expressed through two mechanisms at once is drift bait.
  • Naming the box-shadow size group shadow-size. tailwind-merge calls it shadow, the key in the table is already shadow, and renaming churns the entry for no behaviour change.
  • A borderGroups()-style value-parsing helper for bg- or shadow-. These keyword sets are finite and enumerable, so a parser buys nothing, and the one parsed case (an unhinted arbitrary shadow) is a single anchored alternation.
  • New CONFLICTS entries. No background or shadow utility is a shorthand that subsumes another, unlike padding, margin, and border sides, so shorthand subsumption does not apply here.
  • Giving inset-shadow-*, drop-shadow-*, or ring-* groups. They are ungrouped today, which means they never evict anything, and that is the safe direction. Out of scope.

Steps

  1. packages/ui/packages/registry/lib/utils.ts, the GROUPS array, background block (currently L58-64). Keep bg-image (L58-59), bg-repeat (L60), and bg-attach (L61) as they are. Replace the position entry and extend the size entry, then add the three new property groups, all of it above the [/^bg-/, 'bg-color'] catch-all at L64:
    [/^bg-(auto|cover|contain)$/, 'bg-size'],
    [/^bg-size-/, 'bg-size'],
    [/^bg-(top(-left|-right)?|bottom(-left|-right)?|left|right|center)$/, 'bg-position'],
    [/^bg-position-/, 'bg-position'],
    [/^bg-clip-(border|padding|content|text)$/, 'bg-clip'],
    [/^bg-origin-(border|padding|content)$/, 'bg-origin'],
    [/^bg-blend-(normal|multiply|screen|overlay|darken|lighten|color-dodge|color-burn|hard-light|soft-light|difference|exclusion|hue|saturation|color|luminosity)$/, 'bg-blend'],
    The two extensions ride along because they are the identical defect under the identical prefix: bg-size-[auto_100px] and bg-position-[center_top] are the v4 functional spellings (utilities.ts L2677 and L2697) and the compound keywords bg-top-left / bg-top-right / bg-bottom-left / bg-bottom-right (L2689-2693) are all missed by the current $-anchored alternations, so each one falls into bg-color today and evicts a real background colour.
  2. Same file, text-shadow. Insert immediately ABOVE the text-size entry and its comment (currently L65-66), so no text-shadow token can ever reach the text-color pattern at L68:
    // text-shadow is its own property, never `color`, and its size scale and its
    // colour are two properties again. Both entries precede the text- patterns
    // below, which is what keeps a `text-shadow-*` token out of `text-color`.
    [/^text-shadow(-(2xs|xs|sm|md|lg|none))?(\/([\d.]+|\[[^\]]*\]))?$/, 'text-shadow'],
    [/^text-shadow-\[(inset|-|\.|\d)/, 'text-shadow'],
    [/^text-shadow-/, 'text-shadow-color'],
    The scale is theme.css L425-431 plus none. There is no --text-shadow key, so a bare text-shadow is not a real v4 utility; it is matched into the size group anyway for symmetry with shadow, which costs nothing. text-shadow-initial and text-shadow-inherit set the shadow COLOUR (utilities.ts L5418-5420 and L5486-5487) and correctly land in text-shadow-color through the third entry.
  3. Same file, box-shadow. Replace the single [/^shadow(-|$)/, 'shadow'] entry (currently L79) with:
    [/^shadow(-(2xs|xs|sm|md|lg|xl|2xl|none|inner))?(\/([\d.]+|\[[^\]]*\]))?$/, 'shadow'],
    [/^shadow-\[(inset|-|\.|\d)/, 'shadow'],
    [/^shadow-/, 'shadow-color'],
    The optional /<alpha> tail is required on the size entry because Tailwind accepts an alpha modifier on a size as well as on a colour (shadow-lg/25 reaches the same alpha branch as shadow-red-500/50 in utilities.ts L5569-5600). shadow-none is a size (it sets box-shadow), while shadow-inherit and shadow-initial are colours (they set --tw-shadow-color), and the entries above put each on the right side. shadow-[var(--x)] is genuinely ambiguous and falls to colour, the same convention borderGroups() already documents for border-[var(--x)].
  4. Same file, HINTED_GROUPS (currently L160-168). Add 'shadow:color': 'shadow-color' and 'text-shadow:color': 'text-shadow-color'. This is only correct AFTER step 2 and step 3, because those hints name groups that do not exist until the split lands. Without the entries a hinted shadow-[color:red] keeps its isolated hint:shadow:color bucket and stops deduping against the named shadow-red-500 that sets the identical property. hintedGroup()'s prefix regex already parses text-shadow-[color:red] into the prefix text-shadow and the hint color, so no change to the function is needed. Nothing else in hintedGroup() moves.
  5. Update the helper header comment (L6-9), which lists the properties the merger covers, so the new ones are named there.
  6. Mirror steps 1 through 5 into examples/blog/lib/utils/cn.ts. It is a hand-synced duplicate with no mechanical link, and its GROUPS / HINTED_GROUPS region sits at the same line numbers as the registry copy, so the edits transfer verbatim.
  7. Extend the tests (next section), then run node --test packages/ui/test/cn-helper.test.js test/ui/cn-copies-in-sync.test.mjs.
  8. Update the doc surfaces (listed below), each of which currently asserts in prose that these exact pairs still collide.

Tests

  • Unit: packages/ui/test/cn-helper.test.js, asserting both orders of cn('bg-clip-text', 'bg-primary'), cn('bg-origin-border', 'bg-primary'), cn('bg-blend-multiply', 'bg-primary'), cn('shadow-lg', 'shadow-red-500'), and cn('text-primary', 'text-shadow-lg') keep both classes; that same-property pairs still collapse to the later one (bg-clip-text against bg-clip-border, shadow-sm against shadow-lg, shadow-red-500 against shadow-blue-500, text-shadow-sm against text-shadow-lg); that the value classification lands on the right side (shadow-[0_0_10px_red] and shadow-none collapse against shadow-lg, shadow-[#fff] and shadow-inherit collapse against shadow-red-500, shadow-lg/25 is still a size); that the hinted spellings now join their named group (cn('shadow-red-500', 'shadow-[color:red]') collapses, cn('shadow-[color:red]', 'shadow-lg') still keeps both); and that the extended background entries work (cn('bg-top-left', 'bg-primary') and cn('bg-size-[auto_100px]', 'bg-primary') keep both, cn('bg-top-left', 'bg-center') collapses).
  • Drift + Bun parity: test/ui/cn-copies-in-sync.test.mjs, adding bg-clip-text, bg-clip-border, bg-origin-border, bg-blend-multiply, bg-top-left, bg-size-[auto_100px], bg-position-[center_top], shadow, shadow-none, shadow-inner, shadow-lg/25, shadow-red-500, shadow-red-500/50, shadow-inherit, shadow-[#fff], text-shadow-lg, text-shadow-none, and text-shadow-red-500 to the TOKENS battery (which is merged pairwise through both copies), plus a direct assertion in the "the blog copy carries the conflict-group fixes" test so the guard cannot stay green by both copies regressing together. This file runs under Bun automatically: scripts/run-bun-tests.js walks all of test/ and is not on its DENYLIST.
  • Browser / e2e: N/A, because cn is a pure string function with no DOM, network, or runtime-sensitive surface. No new test/bun/* file is needed for the same reason (nothing here touches the serializer, the listener, streams, node:crypto, or the TS stripper), and the existing Bun coverage above already executes the merger on both runtimes.
  • Counterfactual: revert ONLY the GROUPS and HINTED_GROUPS edits in the registry copy and keep the tests. The new both-orders assertions must red on the pre-fix table, which is what proves they are testing the fix rather than restating it.

Doc surfaces

  • .agents/skills/webjs/references/styling.md L73, whose sentence "some prefixes are still grouped coarsely and a less common pair can collide (bg-clip-text against bg-primary, shadow-lg against shadow-red-500)" becomes false the moment this lands. Replace the two named pairs with ones that still collide, or drop the parenthetical and keep the honest caveat that the merger is small and does not claim full tailwind-merge fidelity.
  • packages/ui/AGENTS.md L473-477, the "coarse by design" bullet, which names bg-clip-* and bg-origin-* sitting in bg-color and shadow-lg sharing shadow with shadow-red-500 as the live examples. Same treatment. Keep the bullet itself, since the merger is still not tailwind-merge; only the examples are stale.
  • packages/ui/packages/registry/lib/utils.ts L6-9 (and the matching header in examples/blog/lib/utils/cn.ts), the parenthetical listing which properties the merger handles.
  • No website or docs-site surface. git grep finds no page under website/ that documents the cn conflict groups, so there is nothing to sync there.

Implementation notes (for the implementing agent)

Line numbers below are against origin/main at bfa0eb49. A local checkout may be behind; #1247 (dfefe3ec) must be in your history before these anchors mean anything.

  • Where to edit: packages/ui/packages/registry/lib/utils.ts, the GROUPS array. The bg- entries are L58-63 and the catch-all [/^bg-/, 'bg-color'] is L64; the text-size entry is L66 and the text-color catch-all with its negative lookahead is L68; [/^shadow(-|$)/, 'shadow'] is L79. Order in GROUPS matters (first match wins per token), so every new specific entry goes BEFORE the catch-all it is carving out of.
  • The second copy. examples/blog/lib/utils/cn.ts is a hand-synced duplicate with no mechanical link, and the SAME change must land in it. test/ui/cn-copies-in-sync.test.mjs merges a token battery through both copies and fails on drift, so add the new tokens to that battery. Every other cn.ts on disk is generated from the registry copy and must not be hand-edited (see the note at the top of that test file).
  • Landmines:
    • text-shadow-* is kept out of text-color purely by ORDER: the three text-shadow entries go above the text-size entry at L66. The negative lookahead at L68 is deliberately NOT touched, because a ^text-shadow- catch-all placed earlier is already total over that prefix. Adding a lookahead term as well would leave two mechanisms guarding one carve-out.
    • Bare shadow (no suffix) is a real box-shadow utility and must stay in the size group. The replacement pattern keeps it via the optional value segment, so do not lose it while splitting.
    • The size entries must be tried BEFORE the ^shadow- and ^text-shadow- colour catch-alls, or every size lands in the colour group and the bug inverts rather than being fixed.
    • HINTED_GROUPS DOES change here (step 4), which reverses the guidance an earlier draft of this issue carried. The old advice was right when there was no shadow-color group to point at; once the split creates one, leaving shadow:color unmapped means a hinted colour silently stops deduping against a named colour of the same property. Do not touch hintedGroup() itself, and do not add hint entries for the bg-clip / bg-origin / bg-blend groups, which have no arbitrary-value spelling.
    • inset-shadow-* does not match ^shadow and is already ungrouped. Leave it alone. Same for drop-shadow-* and ring-*.
    • An unmapped or unknown token should stay UNGROUPED and survive. The safe direction to fail is an extra class rendering, never a dropped one, and fix: key cn() conflicts on the CSS property, not the class prefix #1247 states that invariant in packages/ui/AGENTS.md.
  • Invariants to respect: no third-party runtime deps in @webjsdev/ui (package invariant 2, hand-rolled cn); shadcn API parity; a conflict group is one CSS property, never one class prefix (packages/ui/AGENTS.md L434, the class-helper conventions section).

Acceptance criteria

  • cn('bg-clip-text', 'bg-primary') keeps both, in both orders
  • cn('bg-origin-border', 'bg-primary') and cn('bg-blend-multiply', 'bg-primary') keep both
  • cn('bg-top-left', 'bg-primary') and cn('bg-size-[auto_100px]', 'bg-primary') keep both, and cn('bg-top-left', 'bg-center') collapses to bg-center
  • cn('shadow-lg', 'shadow-red-500') keeps both, in both orders
  • cn('text-primary', 'text-shadow-lg') keeps both, in both orders
  • Same-property pairs still collapse: cn('bg-clip-text', 'bg-clip-border'), cn('shadow-sm', 'shadow-lg'), cn('shadow-red-500', 'shadow-blue-500'), cn('text-shadow-sm', 'text-shadow-lg')
  • Value classification is right on both sides: cn('shadow-lg', 'shadow-[0_0_10px_red]') and cn('shadow-lg', 'shadow-none') collapse, cn('shadow-red-500', 'shadow-[#fff]') and cn('shadow-red-500', 'shadow-inherit') collapse, cn('shadow-sm', 'shadow-lg/25') collapses
  • The hinted spelling joins its named group: cn('shadow-red-500', 'shadow-[color:red]') collapses, while cn('shadow-[color:red]', 'shadow-lg') still keeps both
  • Nothing already correct regresses: cn('bg-red-500', 'bg-blue-500'), cn('text-sm', 'text-primary'), cn('flex', 'flex-1'), cn('border-2', 'border-primary')
  • A counterfactual proves the new tests fail on today's groups
  • The registry copy and examples/blog/lib/utils/cn.ts stay identical in the changed logic, and the new tokens are in the shared battery
  • .agents/skills/webjs/references/styling.md and the packages/ui/AGENTS.md coarse-groups bullet no longer cite these pairs as unhandled

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

Status
Todo

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions