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 against border colour, the bare flex / grid display value, and type-hinted arbitrary values). Three prefix-keyed groups were left as they stood, because they were pre-existing and neither of those two issues needed them. 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.

Measured against packages/ui/packages/registry/lib/utils.ts at HEAD 5ac991ce (line anchors throughout this issue are dated to that commit; #1247 merged as dfefe3ec and must be in your history before any anchor here means anything):

cn('bg-clip-text', 'bg-primary')       => 'bg-primary'        background-clip DROPPED
cn('bg-primary', 'bg-clip-text')       => 'bg-clip-text'      background-color 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('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.

Enumerating the whole of each family (rather than only the reported pairs) surfaced four more collisions of the identical shape, all measured on the same commit:

cn('bg-top-left', 'bg-primary')              => 'bg-primary'   background-position DROPPED
cn('bg-size-[auto_100px]', 'bg-primary')     => 'bg-primary'   background-size DROPPED
cn('bg-position-[center_top]', 'bg-primary') => 'bg-primary'   background-position DROPPED
cn('bg-top-left', 'bg-center')  => 'bg-top-left bg-center'     two positions FAIL TO COLLAPSE

The compound position keywords (bg-top-left, bg-top-right, bg-bottom-left, bg-bottom-right) and the v4 functional spellings (bg-size-*, bg-position-*) are all missed by the $-anchored alternations at L62 and L63, so each one falls into the bg-color catch-all at L64 and evicts a real background colour. They are the same bug under the same prefix and ride along here.

Three further pairs fail to collapse today because the property has no group at all, so both classes are emitted and the winner is decided by compiled stylesheet order rather than by class order (the #1065 failure mode):

cn('text-left', 'text-center')      => 'text-left text-center'
cn('text-wrap', 'text-nowrap')      => 'text-wrap text-nowrap'
cn('text-ellipsis', 'text-clip')    => 'text-ellipsis text-clip'

Note shadow-[color:red] is already routed correctly by #1247's type-hint path, so the arbitrary-value half of the shadow problem is already solved and the fix here is narrower than it looks. Precisely three shadow spellings still need handling, and none of them is a hint: the named colour scale (shadow-red-500, shadow-primary), the CSS-wide keywords Tailwind maps to the shadow colour (shadow-inherit, shadow-initial, shadow-current, shadow-transparent), and the unhinted bracket forms (shadow-[#fff] and shadow-[var(--x)] on the colour side, shadow-[0_0_10px_red] on the size side). The same three apply to text-shadow-*.

Correction to the prior draft of this issue. An earlier revision claimed HINTED_GROUPS must not change. That was right only while there was no shadow-color group to point at. Once the split creates one, leaving shadow:color unmapped means the hinted colour keeps its isolated hint:shadow:color bucket and silently stops deduping against a named colour setting the identical property, which is measurable today:

cn('shadow-red-500', 'shadow-[color:red]')  => 'shadow-red-500 shadow-[color:red]'

So HINTED_GROUPS DOES change here (step 4). Every other line anchor the prior draft carried was re-verified against 5ac991ce and is still correct.

Design / approach

Extend the existing GROUPS table, do not add a second mechanism. More specific entries go BEFORE the catch-all they carve out of, which is exactly what #1247 already does for the border sub-properties. Quoting the shape that is being matched, packages/ui/packages/registry/lib/utils.ts L69-74:

  // Border sub-properties that are neither a width nor a colour. These come
  // FIRST so the width / colour classifier below never sees them.
  [/^border-(collapse|separate)$/, 'border-collapse'],
  [/^border-spacing(-[xy])?-/, 'border-spacing'],
  [/^border-(solid|dashed|dotted|double|hidden|none)$/, 'border-style'],
  ...borderGroups(),

dedupeUtilities() takes the FIRST matching entry (L231-234, for (const [re, g] of GROUPS) { if (re.test(bare)) { gk = g; break; } }), so ordering is the whole carve-out mechanism. No new helper function, no new scheme.

Group names come from tailwind-merge 3.5.0, whose published conflict-group table is the industry reference for this exact problem. Read from an installed copy at /home/vivek/Documents/Projects/crisp/node_modules/tailwind-merge/dist/bundle-mjs.mjs, the relevant class-group ids are bg-clip (L1697), bg-origin (L1704), bg-position (L1711), bg-repeat (L1718), bg-size (L1725), bg-image (L1732), bg-color (L1745), bg-blend (L2243), shadow (L2132), shadow-color (L2141), text-shadow (L2215), and text-shadow-color (L2222). Using its ids verbatim makes the WebJs table auditable line by line against the reference. Existing WebJs names that already match (bg-image, bg-repeat, bg-size, bg-position, bg-color, shadow) are left alone; bg-attach keeps its existing WebJs name rather than churning to bg-attachment for no behaviour change.

Utility names come from the Tailwind v4 source, not from memory. Read at /home/vivek/Documents/Projects/frameworks/tailwindcss (v4.3.3):

  • packages/tailwindcss/src/utilities.ts L3798-3801 defines bg-clip-{text,border,padding,content}, L3803-3805 defines bg-origin-{border,padding,content}, and L3807-3826 loops the sixteen bg-blend-* modes.
  • Same file L2674-2677 defines bg-{auto,cover,contain} plus the functional bg-size-*; L2684-2686 the attachment keywords; L2688-2697 the nine position keywords plus the functional bg-position-*; L2704-2709 the six repeat keywords; L2711 bg-none.
  • Same file L5569-5670 is shadow's resolution order, and it is the authority for the size-versus-colour boundary. It special-cases a missing value (the deprecated --shadow theme key), then an arbitrary value by its data type, then none (box-shadow) and inherit (colour), then a --shadow-<value> theme lookup (size), then falls through to a colour. shadow-initial is a separate static utility at L5567 and sets --tw-shadow-color, so it is a colour.
  • Same file L5418-5510 is the identical structure for text-shadow, with text-shadow-initial at L5418 on the colour side.
  • packages/tailwindcss/theme.css L406-412 is the --shadow-* scale (2xs xs sm md lg xl 2xl) and L505-506 adds the deprecated-but-live --shadow (the bare shadow utility) and --shadow-inner. L425-431 is the --text-shadow-* scale (2xs xs sm md lg). There is no --text-shadow key, so a bare text-shadow resolves to nothing in the default theme.

The classification rule, settled. For each family the rule is a LONGEST-KNOWN-PREFIX table read top to bottom, made of three kinds of entry in this order:

  1. An anchored known-scale alternation, with an optional /<alpha> modifier tail. Tailwind accepts an alpha modifier on a shadow SIZE as well as on a colour (shadow-lg/25 reaches the same alpha branch as shadow-red-500/50, utilities.ts L5571-5580), so the tail is required or shadow-lg/25 falls through to the colour group.
  2. An anchored arbitrary-value alternation for the unhinted bracket form. The test is the one tailwind-merge uses, and its source states the reasoning outright at bundle-mjs.mjs L495-496: // Shadow always begins with x and y offset separated by underscore optionally prepended by inset, backing shadowRegex = /^(inset_)?-?((\d+)?\.?(\d+)[a-z]+|0)_-?((\d+)?\.?(\d+)[a-z]+|0)/. WebJs only needs the first character to discriminate, since everything else after the prefix is a colour, so the entry is \[(inset|-|\.|\d).
  3. A bare prefix catch-all to the colour group.

An ambiguous shadow-[var(--x)] falls to colour, matching the convention borderGroups() already documents for border-[var(--x)] at L110-111.

For the keyword-only families (bg-clip, bg-origin, bg-blend, text-align, text-wrap, text-overflow) the sets are finite and closed, so each is a single $-anchored alternation and no value parsing is involved at all.

The complete property table, ready to encode

Every column-three group name is what the GROUPS entry must emit.

Background

CSS property Utilities in scope (Tailwind v4.3.3) Group
background-color bg-<color>, bg-<color>/<alpha>, bg-[#fff], bg-[var(--x)], bg-[color:…] (hinted) bg-color
background-image bg-none, bg-linear-to-{t,tr,r,br,b,bl,l,tl}, bg-linear-<angle>, bg-radial, bg-radial-*, bg-conic, bg-conic-<angle>, bg-[url(…)], bg-[linear-gradient(…)], bg-[radial-gradient(…)], bg-[conic-gradient(…)], bg-[image:…] (hinted) bg-image
background-clip bg-clip-border, bg-clip-padding, bg-clip-content, bg-clip-text bg-clip
background-origin bg-origin-border, bg-origin-padding, bg-origin-content bg-origin
background-blend-mode bg-blend-{normal,multiply,screen,overlay,darken,lighten,color-dodge,color-burn,hard-light,soft-light,difference,exclusion,hue,saturation,color,luminosity} bg-blend
background-position bg-{top,bottom,left,right,center}, bg-{top,bottom}-{left,right}, the v4.1-deprecated reversed spellings bg-{left,right}-{top,bottom}, bg-position-<value>, bg-[position:…] (hinted) bg-position
background-repeat bg-repeat, bg-no-repeat, bg-repeat-x, bg-repeat-y, bg-repeat-round, bg-repeat-space bg-repeat
background-size bg-auto, bg-cover, bg-contain, bg-size-<value>, bg-[size:…] and bg-[length:…] (hinted) bg-size
background-attachment bg-fixed, bg-local, bg-scroll bg-attach

Box shadow

CSS property Utilities in scope Group
box-shadow bare shadow, shadow-{2xs,xs,sm,md,lg,xl,2xl}, shadow-inner, shadow-none, any of those with a /<alpha> modifier, shadow-[<offsets>] (an unhinted arbitrary opening with inset, -, ., or a digit) shadow
--tw-shadow-color shadow-<color>, shadow-<color>/<alpha>, shadow-inherit, shadow-initial, shadow-current, shadow-transparent, shadow-[#fff], shadow-[rgb(…)], shadow-[var(--x)], shadow-[color:…] (hinted) shadow-color

Text

CSS property Utilities in scope Group
color text-<color>, text-<color>/<alpha>, text-[#fff], text-[var(--x)], text-[color:…] (hinted) text-color
font-size text-{xs,sm,base,lg,xl,2xl,3xl,4xl,5xl,6xl,7xl,8xl,9xl}, text-[length:…] (hinted) text-size
text-align text-{left,center,right,justify,start,end} text-align
text-wrap text-{wrap,nowrap,balance,pretty} text-wrap
text-overflow text-ellipsis, text-clip text-overflow
text-shadow text-shadow-{2xs,xs,sm,md,lg}, text-shadow-none, any of those with a /<alpha> modifier, text-shadow-[<offsets>] text-shadow
--tw-text-shadow-color text-shadow-<color>, text-shadow-inherit, text-shadow-initial, text-shadow-[#fff], text-shadow-[color:…] (hinted) text-shadow-color

Bare text-shadow with no suffix has no --text-shadow theme key, so it is not a real v4 utility. It is admitted into the size group anyway for symmetry with bare shadow, which costs nothing.

Alternatives rejected

  • Swap the hand-rolled merger for tailwind-merge itself. Package invariant 2 (packages/ui/AGENTS.md) forbids third-party runtime deps in @webjsdev/ui, and the helper header at L11-18 already documents the swap as a per-project opt-out.
  • Add text-shadow to the negative lookahead of the text-color pattern. Ordering already carves it out completely, and a carve-out expressed through two mechanisms at once is drift bait.
  • Keep the text-color negative lookahead at L68 while adding the align / wrap / overflow groups above it. Every term in that lookahead would then be dead. The size terms (xs$|sm$|base$|lg$|xl$|\d?xl$) are ALREADY dead today, because the text-size entry at L66 sits above it. Replacing the whole thing with a plain [/^text-/, 'text-color'] is the version with one mechanism per carve-out. The only lookahead term with no replacement group is align-, which guards a text-align-* utility that does not exist in Tailwind v4; dropping it is a no-op against the real utility set.
  • 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.
  • Name the box-shadow size group shadow-size. tailwind-merge calls it shadow, the existing key is already shadow, and renaming churns the entry for no behaviour change.
  • New CONFLICTS entries. No background, shadow, or text utility here is a shorthand that subsumes another, unlike padding, margin, and border sides, so shorthand subsumption does not apply.
  • Give inset-shadow-*, drop-shadow-*, ring-*, or inset-ring-* groups. They are ungrouped today, which means they never evict anything. That is the safe direction to fail, and fix: key cn() conflicts on the CSS property, not the class prefix #1247 states that invariant in packages/ui/AGENTS.md. Out of scope.
  • Omit the v4.1-deprecated reversed position spellings (bg-left-top and friends). They are gone from Tailwind 4.3.3's source, but tailwind-merge still carries them (bundle-mjs.mjs L622-630, scalePosition()), and here the safe direction argues for including them: an UNMATCHED bg-* token today falls into the bg-color catch-all and evicts a real colour, so leaving a spelling out is the dangerous choice, not the conservative one. Four extra alternations buy that.

Implementation plan

Every anchor below is against HEAD 5ac991ce. The two hand-synced copies carry the GROUPS / HINTED_GROUPS region at IDENTICAL line numbers, so every anchor applies to both.

Step 1: split the background groups

File packages/ui/packages/registry/lib/utils.ts, the GROUPS array. Today L62-64 read:

  [/^bg-(auto|cover|contain)$/, 'bg-size'],
  [/^bg-(bottom|center|left|right|top)$/, 'bg-position'],
  [/^bg-/, 'bg-color'],

Replace those three lines with:

  [/^bg-(auto|cover|contain)$/, 'bg-size'],
  [/^bg-size-/, 'bg-size'],
  [/^bg-(top|bottom)(-(left|right))?$/, 'bg-position'],
  [/^bg-(left|right|center)(-(top|bottom))?$/, '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'],
  [/^bg-/, 'bg-color'],

L58-61 (bg-image x2, bg-repeat, bg-attach) are unchanged. The two position entries are a pair rather than one alternation because the first covers the live v4.3 compounds (bg-top-left) and the second covers the bare keywords plus the deprecated reversed compounds (bg-left-top) in one shape.

Step 2: split the text groups

Today L65-68 read:

  // Font size: explicit list of Tailwind size scale.
  [/^text-(xs|sm|base|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|8xl|9xl)$/, 'text-size'],
  // Text color: anything else starting with text- that isn't alignment / wrap / overflow.
  [/^text-(?!align-|left$|right$|center$|justify$|start$|end$|wrap$|nowrap$|balance$|pretty$|clip$|ellipsis$|xs$|sm$|base$|lg$|xl$|\d?xl$)/, 'text-color'],

Replace that block with:

  // text-shadow is its own property, and its size scale and its colour are two
  // properties again. All three entries precede the text- patterns below, which
  // is what keeps a `text-shadow-*` token out of `text-size` and `text-color`.
  [/^text-shadow(-(2xs|xs|sm|md|lg|none))?(\/([\d.]+|\[[^\]]*\]))?$/, 'text-shadow'],
  [/^text-shadow-\[(inset|-|\.|\d)/, 'text-shadow'],
  [/^text-shadow-/, 'text-shadow-color'],
  // Font size: explicit list of Tailwind size scale.
  [/^text-(xs|sm|base|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|8xl|9xl)$/, 'text-size'],
  // Alignment, wrapping, and overflow are three more properties under the same
  // prefix. Each was previously excluded from text-color by a lookahead and then
  // matched nothing at all, so two alignments never collapsed.
  [/^text-(left|center|right|justify|start|end)$/, 'text-align'],
  [/^text-(wrap|nowrap|balance|pretty)$/, 'text-wrap'],
  [/^text-(ellipsis|clip)$/, 'text-overflow'],
  // Text color: anything else under the prefix. The specific groups above are
  // the whole carve-out, so no negative lookahead is needed here as well.
  [/^text-/, 'text-color'],

text-shadow-initial and text-shadow-inherit set the shadow COLOUR (utilities.ts L5418-5420 and L5486-5487) and land in text-shadow-color through the third entry, which is correct.

Step 3: split the box-shadow group

Today L79 reads:

  [/^shadow(-|$)/, 'shadow'],

Replace it with:

  [/^shadow(-(2xs|xs|sm|md|lg|xl|2xl|inner|none))?(\/([\d.]+|\[[^\]]*\]))?$/, 'shadow'],
  [/^shadow-\[(inset|-|\.|\d)/, 'shadow'],
  [/^shadow-/, 'shadow-color'],

Three things must survive this edit. Bare shadow is a real box-shadow utility and stays in the size group via the optional value segment. 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. The two size entries must be tried BEFORE the ^shadow- colour catch-all, or every size lands in the colour group and the bug inverts rather than being fixed.

Step 4: map the two colour hints

HINTED_GROUPS is L160-168 today. Add two entries:

  'shadow:color': 'shadow-color',
  'text-shadow:color': 'text-shadow-color',

This is only correct AFTER steps 2 and 3, because those hints name groups that do not exist until the split lands. hintedGroup()'s prefix regex at L175 (/^([a-z][a-z-]*)-\[([a-z][a-z-]*):/) already parses text-shadow-[color:red] into the prefix text-shadow and the hint color, so hintedGroup() itself does not change. Do NOT add hint entries for bg-clip, bg-origin, or bg-blend, which have no arbitrary-value spelling.

Step 5: refresh the helper header

The header comment at L6-9 lists the properties the merger covers. Extend the parenthetical so the new ones are named.

Step 6: mirror steps 1 through 5 into the blog copy

examples/blog/lib/utils/cn.ts is a hand-synced duplicate with no mechanical link, and its GROUPS / HINTED_GROUPS region sits at the same line numbers (verified by diff at 5ac991ce, which differs from the registry copy only in a comment block at L190-196 and in the helper exports below L266). The edits transfer verbatim.

website/lib/utils/cn.ts is GENERATED (gitignored via website/.gitignore L8, written by website/scripts/copy-registry.mjs) and must NOT be hand-edited. A scaffolded app's copy is generated too, by packages/cli/lib/create.js L155-160, which copies the registry file verbatim. Those two hand-synced sources are the whole inventory, matching the file set #1247 itself touched.

Step 7: extend the tests, then run them

See the next section. Run node --test packages/ui/test/cn-helper.test.js test/ui/cn-copies-in-sync.test.mjs, then the Bun leg per below.

Step 8: update the doc surfaces

See the Docs section. Both surfaces currently assert in prose that these exact pairs still collide, so both become false the moment this lands.

Tests

Unit, packages/ui/test/cn-helper.test.js

The file already loads the registry copy through stripTypeScript and asserts with assert.equal(cn(a, b), expected). Add table-driven tests in that style. Every pair below was run against a working prototype of steps 1 through 4 and produced the stated result, so the expectations are measured, not predicted.

Both orders keep both classes (the eight reported cases):

('bg-clip-text','bg-primary')  ('bg-primary','bg-clip-text')
('bg-origin-border','bg-primary')  ('bg-primary','bg-origin-border')
('bg-blend-multiply','bg-primary')  ('bg-primary','bg-blend-multiply')
('shadow-lg','shadow-red-500')  ('shadow-red-500','shadow-lg')
('text-primary','text-shadow-lg')  ('text-shadow-lg','text-primary')
('text-shadow-sm','text-primary')  ('text-primary','text-shadow-sm')

Background, same property collapses to the later class: ('bg-clip-text','bg-clip-border'), ('bg-clip-padding','bg-clip-content'), ('bg-origin-border','bg-origin-content'), ('bg-origin-padding','bg-origin-border'), ('bg-blend-multiply','bg-blend-screen'), ('bg-blend-luminosity','bg-blend-normal'), ('bg-auto','bg-cover'), ('bg-cover','bg-size-[auto_100px]'), ('bg-top-left','bg-center'), ('bg-bottom-right','bg-top'), ('bg-center','bg-position-[center_top]'), ('bg-repeat','bg-no-repeat'), ('bg-repeat-x','bg-repeat-space'), ('bg-fixed','bg-scroll'), ('bg-none','bg-linear-to-r'), ('bg-linear-to-r','bg-radial'), ('bg-conic-180','bg-none'), ('bg-red-500','bg-blue-500').

Background, different properties keep both: ('bg-top-left','bg-primary'), ('bg-size-[auto_100px]','bg-primary'), ('bg-position-[center_top]','bg-primary'), ('bg-linear-to-r','bg-clip-text'), ('bg-clip-text','bg-origin-border'), ('bg-origin-border','bg-blend-multiply'), ('bg-blend-multiply','bg-cover'), ('bg-cover','bg-center'), ('bg-center','bg-no-repeat'), ('bg-no-repeat','bg-fixed'), ('bg-fixed','bg-primary'), ('bg-none','bg-primary'), ('bg-[url(/a.png)]','bg-primary'), ('bg-[#fff]','bg-clip-text').

Box-shadow size side collapses together: ('shadow','shadow-lg'), ('shadow-2xs','shadow-2xl'), ('shadow-sm','shadow-lg'), ('shadow-lg','shadow-none'), ('shadow-lg','shadow-inner'), ('shadow-lg','shadow-[0_0_10px_red]'), ('shadow-lg','shadow-[inset_0_2px_4px_#000]'), ('shadow-lg','shadow-[-2px_0_4px_red]'), ('shadow-sm','shadow-lg/25'), ('shadow-lg/25','shadow-xl/[0.5]').

Box-shadow colour side collapses together: ('shadow-red-500','shadow-blue-500'), ('shadow-red-500','shadow-inherit'), ('shadow-red-500','shadow-initial'), ('shadow-red-500','shadow-current'), ('shadow-red-500','shadow-[#fff]'), ('shadow-red-500','shadow-[var(--x)]'), ('shadow-red-500/50','shadow-primary'), ('shadow-red-500','shadow-[color:red]'), ('shadow-[color:red]','shadow-blue-500').

Box-shadow across the two properties keeps both: ('shadow-lg','shadow-[color:red]'), ('shadow-[color:red]','shadow-lg'), ('shadow-none','shadow-red-500'), ('shadow-inner','shadow-red-500'), ('shadow','shadow-primary'), ('shadow-[0_0_10px_red]','shadow-blue-500'), ('shadow-lg/25','shadow-red-500/50').

Text-shadow, collapse: ('text-shadow-2xs','text-shadow-lg'), ('text-shadow-sm','text-shadow-none'), ('text-shadow-lg','text-shadow-[0_1px_2px_red]'), ('text-shadow-sm','text-shadow-lg/25'), ('text-shadow-red-500','text-shadow-blue-500'), ('text-shadow-red-500','text-shadow-initial'), ('text-shadow-red-500','text-shadow-inherit'), ('text-shadow-red-500','text-shadow-[#fff]'), ('text-shadow-red-500','text-shadow-[color:red]'). Keep both: ('text-shadow-lg','text-shadow-red-500'), ('text-shadow-red-500','text-shadow-lg'), ('text-shadow-lg','text-shadow-[color:red]'), ('text-lg','text-shadow-lg'), ('text-shadow-lg','text-lg'), ('text-shadow-red-500','text-primary').

The rest of the text family, collapse: ('text-sm','text-lg'), ('text-9xl','text-4xl'), ('text-left','text-center'), ('text-justify','text-end'), ('text-wrap','text-nowrap'), ('text-balance','text-pretty'), ('text-ellipsis','text-clip'), ('text-primary','text-foreground'). Keep both: ('text-sm','text-primary'), ('text-left','text-primary'), ('text-left','text-sm'), ('text-nowrap','text-primary'), ('text-clip','text-primary'), ('text-clip','text-nowrap'), ('text-2xl','text-primary'), ('text-[length:14px]','text-primary'), ('text-[color:red]','text-lg').

Neighbouring prefixes stay ungrouped and always survive: ('inset-shadow-sm','inset-shadow-red-500'), ('drop-shadow-lg','drop-shadow-red-500'), ('ring-2','ring-red-500'), ('inset-ring-2','inset-ring-red-500').

Variant scoping still holds: cn('hover:shadow-lg','hover:shadow-red-500') keeps both, and the existing variant tests at L87-93 and L129-133 stay green.

Nothing already correct regresses: ('flex','flex-1'), ('border-2','border-primary'), ('border-border','border-accent'), ('p-2','p-4'), ('rounded-md','rounded-full'). All 16 existing tests in this file were run unmodified against the prototype and passed, including L163-172, which already assert the hinted shadow shapes.

Drift and Bun parity, test/ui/cn-copies-in-sync.test.mjs

Add these to the TOKENS battery at L50-74, which merges every pair through BOTH copies and compares: bg-clip-text, bg-clip-border, bg-origin-border, bg-blend-multiply, bg-top-left, bg-size-[auto_100px], bg-position-[center_top], bg-fixed, text-left, text-nowrap, text-ellipsis, 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, text-shadow-red-500.

Also add direct assertions to the second test at L94-104 (cn: the blog copy carries the conflict-group fixes), which exists precisely so the guard cannot stay green by both copies regressing together. At minimum: blogCn('bg-clip-text','bg-primary'), blogCn('shadow-lg','shadow-red-500'), and blogCn('text-primary','text-shadow-lg') each keep both classes. Widen the test title to name this issue alongside #1065 and #1072.

This file runs under Bun automatically. scripts/run-bun-tests.js walks test/, packages/core/test/, and packages/server/test/ (L78-80), and test/ui/cn-copies-in-sync.test.mjs is not on its DENYLIST (L39). Run node scripts/run-bun-tests.js and report the result. Note that packages/ui/test/ is NOT walked, so the unit file above runs on Node only, which is why the cross-runtime coverage has to be in the drift file.

Layers that do not apply, and why

  • Browser and e2e. cn is a pure string function with no DOM, no network, and no custom-element upgrade. There is nothing a browser assertion could observe that the unit assertion does not.
  • Smoke. No scaffolded-app surface changes. The scaffold's copy is generated verbatim from the registry file, so it inherits the fix with no generator change.
  • A new test/bun/<feature>.mjs. The runtime-sensitive surface list in AGENTS.md is the serializer, the listener and request path, SSR / action / CSRF dispatch, streams, node:crypto, the TS stripper, and auth / session / cors. A regex table in a pure helper is none of those, 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, keep every new test, and confirm the new both-orders assertions RED against the pre-fix table. That is what proves the tests test the fix rather than restate it. Measured pre-fix outputs for the eight headline cases are in the Problem section above, so the expected failure text is known in advance.

Docs

  • .agents/skills/webjs/references/styling.md L73. The sentence "It is a small hand-rolled merger, not tailwind-merge, so 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)" names two pairs that this change fixes. Keep the honest caveat that the merger is small and does not claim full tailwind-merge fidelity, and either drop the parenthetical or replace it with a pair that genuinely still collides (inset-shadow-sm against inset-shadow-red-500 is one, since that prefix stays ungrouped). Consider adding cn('shadow-lg', 'shadow-red-500') to the list of prefix collisions that do NOT evict, alongside the border-2 and flex examples already there.
  • 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, 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 (step 5).
  • No website or docs-site surface. git grep finds no page under website/app/docs/ or website/app/ui/ that documents the cn conflict groups; website/app/ui/page.ts L150 mentions tailwind-merge only to say the kit ships without it, which stays true.
  • No packages/cli/templates/ surface. The scaffold ships no styling reference of its own, and the app's cn.ts is generated from the registry file rather than templated.
  • No README surface. packages/ui/README.md L97 names the helper file without describing its groups.
  • No MCP surface. The ui tool projects component inventories and examples, not the merger's internals.

This is the same doc set #1247 itself touched (git show --stat dfefe3ec lists exactly styling.md, packages/ui/AGENTS.md, the two cn sources, and the two test files), which is the precedent to follow.

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, in both orders
  • cn('shadow-lg', 'shadow-red-500') keeps both, in both orders
  • cn('text-primary', 'text-shadow-lg') and cn('text-shadow-sm', 'text-primary') keep both, in both orders
  • cn('bg-top-left', 'bg-primary'), cn('bg-size-[auto_100px]', 'bg-primary'), and cn('bg-position-[center_top]', 'bg-primary') keep both
  • Same-property pairs collapse to the later class: cn('bg-clip-text','bg-clip-border'), cn('bg-origin-border','bg-origin-content'), cn('bg-blend-multiply','bg-blend-screen'), cn('bg-top-left','bg-center'), cn('shadow-sm','shadow-lg'), cn('shadow-red-500','shadow-blue-500'), cn('text-shadow-sm','text-shadow-lg'), cn('text-shadow-red-500','text-shadow-blue-500')
  • The three properties that previously had no group now collapse: cn('text-left','text-center'), cn('text-wrap','text-nowrap'), cn('text-ellipsis','text-clip')
  • Shadow value classification lands on the right side in both directions: cn('shadow-lg','shadow-[0_0_10px_red]'), cn('shadow-lg','shadow-none'), and cn('shadow-sm','shadow-lg/25') collapse; cn('shadow-red-500','shadow-[#fff]') and cn('shadow-red-500','shadow-inherit') collapse; cn('shadow-none','shadow-red-500') and cn('shadow','shadow-primary') keep both
  • The hinted spelling joins its named group: cn('shadow-red-500','shadow-[color:red]') collapses, while cn('shadow-lg','shadow-[color:red]') still keeps both
  • Ungrouped neighbours are untouched: cn('inset-shadow-sm','inset-shadow-red-500'), cn('drop-shadow-lg','drop-shadow-red-500'), cn('ring-2','ring-red-500') each keep 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'), and every one of the 16 pre-existing tests in packages/ui/test/cn-helper.test.js pass unmodified
  • A counterfactual proves the new assertions fail against the pre-fix table
  • examples/blog/lib/utils/cn.ts carries the identical logic, the new tokens are in the shared TOKENS battery, and test/ui/cn-copies-in-sync.test.mjs passes on Node AND under node scripts/run-bun-tests.js
  • webjs check is clean, and webjs doctor is clean on examples/blog (the app whose copy moved)
  • .agents/skills/webjs/references/styling.md and the packages/ui/AGENTS.md coarse-groups bullet no longer cite these pairs as unhandled

Out of scope

  • inset-shadow-*, drop-shadow-*, ring-*, inset-ring-*, and mix-blend-*. Ungrouped today, so they never evict anything. Leave them ungrouped.
  • Any group outside the three families named here. Do not opportunistically audit p-, m-, flex-, grid-, font-, or rounded-.
  • Replacing the merger with tailwind-merge, or adding any runtime dependency to @webjsdev/ui (package invariant 2).
  • Touching hintedGroup() itself, variantPrefix(), CONFLICTS, or dedupeUtilities(). Only the GROUPS and HINTED_GROUPS data tables change.
  • Hand-editing any GENERATED cn.ts (website/lib/utils/cn.ts, a scaffolded app's copy). Re-run the generator instead.
  • Filing follow-up issues. Fold any small tweak in a file this change already touches into the same PR; report anything genuinely separate in the PR description instead.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

Status
Ready

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions