You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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):
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
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:
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.
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.
Same file, box-shadow. Replace the single [/^shadow(-|$)/, 'shadow'] entry (currently L79) with:
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)].
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.
Update the helper header comment (L6-9), which lists the properties the merger covers, so the new ones are named there.
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.
Extend the tests (next section), then run node --test packages/ui/test/cn-helper.test.js test/ui/cn-copies-in-sync.test.mjs.
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-*.
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
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
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 bareflex/griddisplay 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.tsatdfefe3ec(the #1247 merge commit):bg-clip-*is the one most likely to bite: the gradient-text idiom isbg-linear-to-r bg-clip-text text-transparent, and any laterbg-*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-500againstshadow-lg) that still collide, so the fix here is narrower than it looks.Implementation plan
Decision: extend the existing
GROUPStable inpackages/ui/packages/registry/lib/utils.tswith 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-stylesit aboveborderGroups()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 fromtailwind-merge3.5.0, which solves this exact problem and whose class-group ids for the affected properties arebg-clip,bg-origin,bg-blend,bg-position,bg-size,shadow,shadow-color,text-shadow, andtext-shadow-color(read fromdist/bundle-mjs.mjsin 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.tsL3798-3826 definesbg-clip-{border,padding,content,text},bg-origin-{border,padding,content}, and the sixteenbg-blend-*modes, L2674-2711 defines the size / attachment / position / repeat / image families, and L5567-5670 plus L5418-5510 defineshadowandtext-shadow. The shadow SIZE vs COLOUR boundary is Tailwind's own resolution order, which special-casesnoneandinherit, then looks the value up in the--shadow-*theme scale, then falls through to a colour, so the size side is the enumerable scale fromtheme.cssL406-412 plus the deprecated-but-live--shadowand--shadow-innerat L505-506, and everything else aftershadow-is a colour. An arbitrary value with no type hint is classified by the same ruletailwind-mergeuses, whose source comment states it outright ("Shadow always begins with x and y offset separated by underscore optionally prepended by inset"), soshadow-[0_0_10px_red]is a box-shadow andshadow-[#fff]is a colour.Rejected:
tailwind-mergeitself. 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.text-shadowto the negative lookahead of thetext-colorpattern (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.shadow-size.tailwind-mergecalls itshadow, the key in the table is alreadyshadow, and renaming churns the entry for no behaviour change.borderGroups()-style value-parsing helper forbg-orshadow-. 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.CONFLICTSentries. No background or shadow utility is a shorthand that subsumes another, unlike padding, margin, and border sides, so shorthand subsumption does not apply here.inset-shadow-*,drop-shadow-*, orring-*groups. They are ungrouped today, which means they never evict anything, and that is the safe direction. Out of scope.Steps
packages/ui/packages/registry/lib/utils.ts, theGROUPSarray, background block (currently L58-64). Keepbg-image(L58-59),bg-repeat(L60), andbg-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-size-[auto_100px]andbg-position-[center_top]are the v4 functional spellings (utilities.ts L2677 and L2697) and the compound keywordsbg-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 intobg-colortoday and evicts a real background colour.text-sizeentry and its comment (currently L65-66), so notext-shadowtoken can ever reach thetext-colorpattern at L68:theme.cssL425-431 plusnone. There is no--text-shadowkey, so a baretext-shadowis not a real v4 utility; it is matched into the size group anyway for symmetry withshadow, which costs nothing.text-shadow-initialandtext-shadow-inheritset the shadow COLOUR (utilities.ts L5418-5420 and L5486-5487) and correctly land intext-shadow-colorthrough the third entry.[/^shadow(-|$)/, 'shadow']entry (currently L79) with:/<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/25reaches the samealphabranch asshadow-red-500/50in utilities.ts L5569-5600).shadow-noneis a size (it setsbox-shadow), whileshadow-inheritandshadow-initialare 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 conventionborderGroups()already documents forborder-[var(--x)].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 hintedshadow-[color:red]keeps its isolatedhint:shadow:colorbucket and stops deduping against the namedshadow-red-500that sets the identical property.hintedGroup()'s prefix regex already parsestext-shadow-[color:red]into the prefixtext-shadowand the hintcolor, so no change to the function is needed. Nothing else inhintedGroup()moves.examples/blog/lib/utils/cn.ts. It is a hand-synced duplicate with no mechanical link, and itsGROUPS/HINTED_GROUPSregion sits at the same line numbers as the registry copy, so the edits transfer verbatim.node --test packages/ui/test/cn-helper.test.js test/ui/cn-copies-in-sync.test.mjs.Tests
packages/ui/test/cn-helper.test.js, asserting both orders ofcn('bg-clip-text', 'bg-primary'),cn('bg-origin-border', 'bg-primary'),cn('bg-blend-multiply', 'bg-primary'),cn('shadow-lg', 'shadow-red-500'), andcn('text-primary', 'text-shadow-lg')keep both classes; that same-property pairs still collapse to the later one (bg-clip-textagainstbg-clip-border,shadow-smagainstshadow-lg,shadow-red-500againstshadow-blue-500,text-shadow-smagainsttext-shadow-lg); that the value classification lands on the right side (shadow-[0_0_10px_red]andshadow-nonecollapse againstshadow-lg,shadow-[#fff]andshadow-inheritcollapse againstshadow-red-500,shadow-lg/25is 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')andcn('bg-size-[auto_100px]', 'bg-primary')keep both,cn('bg-top-left', 'bg-center')collapses).test/ui/cn-copies-in-sync.test.mjs, addingbg-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, andtext-shadow-red-500to theTOKENSbattery (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.jswalks all oftest/and is not on itsDENYLIST.cnis a pure string function with no DOM, network, or runtime-sensitive surface. No newtest/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.GROUPSandHINTED_GROUPSedits 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.mdL73, whose sentence "some prefixes are still grouped coarsely and a less common pair can collide (bg-clip-textagainstbg-primary,shadow-lgagainstshadow-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 fulltailwind-mergefidelity.packages/ui/AGENTS.mdL473-477, the "coarse by design" bullet, which namesbg-clip-*andbg-origin-*sitting inbg-colorandshadow-lgsharingshadowwithshadow-red-500as the live examples. Same treatment. Keep the bullet itself, since the merger is still nottailwind-merge; only the examples are stale.packages/ui/packages/registry/lib/utils.tsL6-9 (and the matching header inexamples/blog/lib/utils/cn.ts), the parenthetical listing which properties the merger handles.git grepfinds no page underwebsite/that documents thecnconflict groups, so there is nothing to sync there.Implementation notes (for the implementing agent)
Line numbers below are against
origin/mainatbfa0eb49. A local checkout may be behind; #1247 (dfefe3ec) must be in your history before these anchors mean anything.packages/ui/packages/registry/lib/utils.ts, theGROUPSarray. Thebg-entries are L58-63 and the catch-all[/^bg-/, 'bg-color']is L64; thetext-sizeentry is L66 and thetext-colorcatch-all with its negative lookahead is L68;[/^shadow(-|$)/, 'shadow']is L79. Order inGROUPSmatters (first match wins per token), so every new specific entry goes BEFORE the catch-all it is carving out of.examples/blog/lib/utils/cn.tsis a hand-synced duplicate with no mechanical link, and the SAME change must land in it.test/ui/cn-copies-in-sync.test.mjsmerges a token battery through both copies and fails on drift, so add the new tokens to that battery. Every othercn.tson disk is generated from the registry copy and must not be hand-edited (see the note at the top of that test file).text-shadow-*is kept out oftext-colorpurely by ORDER: the threetext-shadowentries go above thetext-sizeentry 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.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.^shadow-and^text-shadow-colour catch-alls, or every size lands in the colour group and the bug inverts rather than being fixed.HINTED_GROUPSDOES change here (step 4), which reverses the guidance an earlier draft of this issue carried. The old advice was right when there was noshadow-colorgroup to point at; once the split creates one, leavingshadow:colorunmapped means a hinted colour silently stops deduping against a named colour of the same property. Do not touchhintedGroup()itself, and do not add hint entries for thebg-clip/bg-origin/bg-blendgroups, which have no arbitrary-value spelling.inset-shadow-*does not match^shadowand is already ungrouped. Leave it alone. Same fordrop-shadow-*andring-*.packages/ui/AGENTS.md.@webjsdev/ui(package invariant 2, hand-rolledcn); shadcn API parity; a conflict group is one CSS property, never one class prefix (packages/ui/AGENTS.mdL434, the class-helper conventions section).Acceptance criteria
cn('bg-clip-text', 'bg-primary')keeps both, in both orderscn('bg-origin-border', 'bg-primary')andcn('bg-blend-multiply', 'bg-primary')keep bothcn('bg-top-left', 'bg-primary')andcn('bg-size-[auto_100px]', 'bg-primary')keep both, andcn('bg-top-left', 'bg-center')collapses tobg-centercn('shadow-lg', 'shadow-red-500')keeps both, in both orderscn('text-primary', 'text-shadow-lg')keeps both, in both orderscn('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')cn('shadow-lg', 'shadow-[0_0_10px_red]')andcn('shadow-lg', 'shadow-none')collapse,cn('shadow-red-500', 'shadow-[#fff]')andcn('shadow-red-500', 'shadow-inherit')collapse,cn('shadow-sm', 'shadow-lg/25')collapsescn('shadow-red-500', 'shadow-[color:red]')collapses, whilecn('shadow-[color:red]', 'shadow-lg')still keeps bothcn('bg-red-500', 'bg-blue-500'),cn('text-sm', 'text-primary'),cn('flex', 'flex-1'),cn('border-2', 'border-primary')examples/blog/lib/utils/cn.tsstay identical in the changed logic, and the new tokens are in the shared battery.agents/skills/webjs/references/styling.mdand thepackages/ui/AGENTS.mdcoarse-groups bullet no longer cite these pairs as unhandled