From 0ccf5b857f35162f76ddc67ea81424fc4e00c4ee Mon Sep 17 00:00:00 2001 From: Vivek Date: Sun, 9 Aug 2026 13:42:50 +0530 Subject: [PATCH 1/3] fix(ui): build the cn conflict table on first use, not at import The GROUPS table spread `...borderGroups()` at module scope, which is a real top-level call, so the elision analyser read lib/utils.ts as client-effecting. Every page or layout reaching `cn` on a component-free path then shipped whole instead of being elided, and `cn` is reached by essentially every kit helper, so this cost page elision in every app that runs `webjsui init`. Memoising the table behind a function keeps the same literal and the same output (12,100 pairs and 242,000 triples over the sync test's 110-token battery, zero mismatches) while leaving nothing to run at module load. The blog copy carries the same change: it is the second of the two hand-synced sources, and examples/blog/app/ui-demo/page.ts shipped whole for this reason. --- examples/blog/lib/utils/cn.ts | 195 +++++++++++---------- packages/ui/packages/registry/lib/utils.ts | 195 +++++++++++---------- 2 files changed, 202 insertions(+), 188 deletions(-) diff --git a/examples/blog/lib/utils/cn.ts b/examples/blog/lib/utils/cn.ts index d9eccc0ce..59ced4ea1 100644 --- a/examples/blog/lib/utils/cn.ts +++ b/examples/blog/lib/utils/cn.ts @@ -50,99 +50,106 @@ function walk(value: ClassValue, out: string[]): void { // IMPORTANT: text-size (text-sm, text-xs, text-base, text-lg, …) and // text-color (text-primary, text-foreground, …) are DIFFERENT properties // and must be in different groups. Same for bg-size vs bg-color etc. -const GROUPS: Array<[RegExp, string]> = [ - [/^p-/, 'p'], [/^px-/, 'px'], [/^py-/, 'py'], [/^pt-/, 'pt'], [/^pr-/, 'pr'], [/^pb-/, 'pb'], [/^pl-/, 'pl'], - [/^m-/, 'm'], [/^mx-/, 'mx'], [/^my-/, 'my'], [/^mt-/, 'mt'], [/^mr-/, 'mr'], [/^mb-/, 'mb'], [/^ml-/, 'ml'], - [/^w-/, 'w'], [/^h-/, 'h'], [/^size-/, 'size'], - // A `bg-[url(…)]` / `bg-[linear-gradient(…)]` background image is classified - // by its FUNCTION, since it carries no type hint to classify it by. The - // hinted forms are handled centrally, in `hintedGroup`. - [/^bg-\[(url\(|linear-gradient|radial-gradient|conic-gradient)/, 'bg-image'], - [/^bg-(linear|gradient|conic|radial|none)/, 'bg-image'], - [/^bg-(no-repeat|repeat|repeat-x|repeat-y|repeat-round|repeat-space)$/, 'bg-repeat'], - [/^bg-(fixed|local|scroll)$/, 'bg-attach'], - [/^bg-(auto|cover|contain)$/, 'bg-size'], - [/^bg-size-/, 'bg-size'], - // Two entries rather than one alternation: the first covers the live v4 - // compounds (`bg-top-left`), the second the bare keywords plus the v4.1 - // deprecated reversed compounds (`bg-left-top`), which tailwind-merge still - // carries. An unmatched `bg-*` token falls into the colour catch-all below and - // evicts a real colour, so admitting a dead spelling is the safe direction. - [/^bg-(top|bottom)(-(left|right))?$/, 'bg-position'], - [/^bg-(left|right|center)(-(top|bottom))?$/, 'bg-position'], - [/^bg-position-/, 'bg-position'], - // Clip, origin, and blend mode are three more properties under the same - // prefix. Each sat in `bg-color` before, so `bg-clip-text` evicted a real - // background colour (the gradient-text idiom lost its clip silently). - [/^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'], - // 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|var\()|\(--)/, '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'], - // 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(), - [/^rounded(-[a-z]+)?$/, 'rounded'], - [/^rounded-/, 'rounded'], - [/^opacity-/, 'opacity'], - [/^font-(thin|light|normal|medium|semibold|bold|black|extralight|extrabold)$/, 'font-weight'], - // Box-shadow SIZE and box-shadow COLOUR are two properties (`box-shadow` and - // `--tw-shadow-color`), so they need two groups. `shadow-none` is a size, - // `shadow-inherit` / `shadow-initial` are colours, and Tailwind accepts an - // alpha modifier on a size as well as on a colour, so `shadow-lg/25` has to - // stay on the size side. An unhinted arbitrary value is a SIZE when it opens - // with `inset`, a sign, a dot, or a digit (a shadow offset list) and also - // when it is a bare `var()` or the `(--x)` variable shorthand: Tailwind - // itself resolves an ambiguous arbitrary shadow to `box-shadow` unless the - // value is provably a colour, and `shadow-[var(--shadow-glow)]` is the normal - // way to write a design-token shadow. This is the one place the - // `borderGroups()` convention inverts, because a bare `border-[var(--x)]` is - // far more often a colour while a bare `shadow-[var(--x)]` is far more often - // a shadow. The size entries must precede the colour catch-all, or every - // size lands in the colour group and the bug inverts rather than being fixed. - [/^shadow(-(2xs|xs|sm|md|lg|xl|2xl|inner|none))?(\/([\d.]+|\[[^\]]*\]))?$/, 'shadow'], - [/^shadow-(\[(inset|-|\.|\d|var\()|\(--)/, 'shadow'], - // A bare name the size scale does not list reads as a colour, because - // `shadow-primary` is overwhelmingly more common than a `@theme`-extended - // `--shadow-card`. A project that adds a custom shadow NAME is the residual - // gap, and the docs say so rather than claiming the split is total. - [/^shadow-/, 'shadow-color'], - [/^z-/, 'z'], - // A bare `flex` / `grid` is a DISPLAY value, not a member of the flex / grid - // sub-property groups below, so it must never dedupe against them: an element - // can be both a flex container and a flex child (`class="flex flex-1"`), and - // collapsing the two silently drops `display:flex`. It still belongs to a - // group of its own, alongside every other display keyword, so a repeated one - // collapses and `cn('hidden', open && 'flex')` resolves to one display. - [/^(inline-block|inline-flex|inline-grid|inline-table|inline|block|flex|grid|flow-root|contents|hidden|list-item|table-caption|table-cell|table-column-group|table-column|table-footer-group|table-header-group|table-row-group|table-row|table)$/, 'display'], - // Each sub-utility below gets the group of the real CSS property it sets, so - // none of them collapses against the display value or against each other. - [/^flex-(row|row-reverse|col|col-reverse)$/, 'flex-direction'], - [/^flex-(wrap|wrap-reverse|nowrap)$/, 'flex-wrap'], - [/^flex-(\d+|auto|initial|none|\[[^\]]*\])$/, 'flex'], - [/^grid-cols-/, 'grid-cols'], - [/^grid-rows-/, 'grid-rows'], - [/^grid-flow-/, 'grid-flow'], -]; +// Built on FIRST USE, not at module load. A module-scope `...borderGroups()` +// spread is a real top-level call, so the elision analyser reads this module as +// client-effecting and every page that reaches `cn` on a component-free path +// ships whole instead of being elided (#1320). +let _groups: Array<[RegExp, string]> | undefined; +function GROUPS(): Array<[RegExp, string]> { + return (_groups ??= [ + [/^p-/, 'p'], [/^px-/, 'px'], [/^py-/, 'py'], [/^pt-/, 'pt'], [/^pr-/, 'pr'], [/^pb-/, 'pb'], [/^pl-/, 'pl'], + [/^m-/, 'm'], [/^mx-/, 'mx'], [/^my-/, 'my'], [/^mt-/, 'mt'], [/^mr-/, 'mr'], [/^mb-/, 'mb'], [/^ml-/, 'ml'], + [/^w-/, 'w'], [/^h-/, 'h'], [/^size-/, 'size'], + // A `bg-[url(…)]` / `bg-[linear-gradient(…)]` background image is classified + // by its FUNCTION, since it carries no type hint to classify it by. The + // hinted forms are handled centrally, in `hintedGroup`. + [/^bg-\[(url\(|linear-gradient|radial-gradient|conic-gradient)/, 'bg-image'], + [/^bg-(linear|gradient|conic|radial|none)/, 'bg-image'], + [/^bg-(no-repeat|repeat|repeat-x|repeat-y|repeat-round|repeat-space)$/, 'bg-repeat'], + [/^bg-(fixed|local|scroll)$/, 'bg-attach'], + [/^bg-(auto|cover|contain)$/, 'bg-size'], + [/^bg-size-/, 'bg-size'], + // Two entries rather than one alternation: the first covers the live v4 + // compounds (`bg-top-left`), the second the bare keywords plus the v4.1 + // deprecated reversed compounds (`bg-left-top`), which tailwind-merge still + // carries. An unmatched `bg-*` token falls into the colour catch-all below and + // evicts a real colour, so admitting a dead spelling is the safe direction. + [/^bg-(top|bottom)(-(left|right))?$/, 'bg-position'], + [/^bg-(left|right|center)(-(top|bottom))?$/, 'bg-position'], + [/^bg-position-/, 'bg-position'], + // Clip, origin, and blend mode are three more properties under the same + // prefix. Each sat in `bg-color` before, so `bg-clip-text` evicted a real + // background colour (the gradient-text idiom lost its clip silently). + [/^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'], + // 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|var\()|\(--)/, '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'], + // 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(), + [/^rounded(-[a-z]+)?$/, 'rounded'], + [/^rounded-/, 'rounded'], + [/^opacity-/, 'opacity'], + [/^font-(thin|light|normal|medium|semibold|bold|black|extralight|extrabold)$/, 'font-weight'], + // Box-shadow SIZE and box-shadow COLOUR are two properties (`box-shadow` and + // `--tw-shadow-color`), so they need two groups. `shadow-none` is a size, + // `shadow-inherit` / `shadow-initial` are colours, and Tailwind accepts an + // alpha modifier on a size as well as on a colour, so `shadow-lg/25` has to + // stay on the size side. An unhinted arbitrary value is a SIZE when it opens + // with `inset`, a sign, a dot, or a digit (a shadow offset list) and also + // when it is a bare `var()` or the `(--x)` variable shorthand: Tailwind + // itself resolves an ambiguous arbitrary shadow to `box-shadow` unless the + // value is provably a colour, and `shadow-[var(--shadow-glow)]` is the normal + // way to write a design-token shadow. This is the one place the + // `borderGroups()` convention inverts, because a bare `border-[var(--x)]` is + // far more often a colour while a bare `shadow-[var(--x)]` is far more often + // a shadow. The size entries must precede the colour catch-all, or every + // size lands in the colour group and the bug inverts rather than being fixed. + [/^shadow(-(2xs|xs|sm|md|lg|xl|2xl|inner|none))?(\/([\d.]+|\[[^\]]*\]))?$/, 'shadow'], + [/^shadow-(\[(inset|-|\.|\d|var\()|\(--)/, 'shadow'], + // A bare name the size scale does not list reads as a colour, because + // `shadow-primary` is overwhelmingly more common than a `@theme`-extended + // `--shadow-card`. A project that adds a custom shadow NAME is the residual + // gap, and the docs say so rather than claiming the split is total. + [/^shadow-/, 'shadow-color'], + [/^z-/, 'z'], + // A bare `flex` / `grid` is a DISPLAY value, not a member of the flex / grid + // sub-property groups below, so it must never dedupe against them: an element + // can be both a flex container and a flex child (`class="flex flex-1"`), and + // collapsing the two silently drops `display:flex`. It still belongs to a + // group of its own, alongside every other display keyword, so a repeated one + // collapses and `cn('hidden', open && 'flex')` resolves to one display. + [/^(inline-block|inline-flex|inline-grid|inline-table|inline|block|flex|grid|flow-root|contents|hidden|list-item|table-caption|table-cell|table-column-group|table-column|table-footer-group|table-header-group|table-row-group|table-row|table)$/, 'display'], + // Each sub-utility below gets the group of the real CSS property it sets, so + // none of them collapses against the display value or against each other. + [/^flex-(row|row-reverse|col|col-reverse)$/, 'flex-direction'], + [/^flex-(wrap|wrap-reverse|nowrap)$/, 'flex-wrap'], + [/^flex-(\d+|auto|initial|none|\[[^\]]*\])$/, 'flex'], + [/^grid-cols-/, 'grid-cols'], + [/^grid-rows-/, 'grid-rows'], + [/^grid-flow-/, 'grid-flow'], + ]); +} /** * Border WIDTH and border COLOUR share the `border-` prefix but are different @@ -280,7 +287,7 @@ function dedupeUtilities(input: string): string { const hinted = hintedGroup(bare); let gk: string | null = hinted ?? null; if (hinted === undefined || hinted === null) { - for (const [re, g] of GROUPS) { + for (const [re, g] of GROUPS()) { if (re.test(bare)) { gk = g; break; } } } diff --git a/packages/ui/packages/registry/lib/utils.ts b/packages/ui/packages/registry/lib/utils.ts index f4fa05477..2e210634e 100644 --- a/packages/ui/packages/registry/lib/utils.ts +++ b/packages/ui/packages/registry/lib/utils.ts @@ -50,99 +50,106 @@ function walk(value: ClassValue, out: string[]): void { // IMPORTANT: text-size (text-sm, text-xs, text-base, text-lg, …) and // text-color (text-primary, text-foreground, …) are DIFFERENT properties // and must be in different groups. Same for bg-size vs bg-color etc. -const GROUPS: Array<[RegExp, string]> = [ - [/^p-/, 'p'], [/^px-/, 'px'], [/^py-/, 'py'], [/^pt-/, 'pt'], [/^pr-/, 'pr'], [/^pb-/, 'pb'], [/^pl-/, 'pl'], - [/^m-/, 'm'], [/^mx-/, 'mx'], [/^my-/, 'my'], [/^mt-/, 'mt'], [/^mr-/, 'mr'], [/^mb-/, 'mb'], [/^ml-/, 'ml'], - [/^w-/, 'w'], [/^h-/, 'h'], [/^size-/, 'size'], - // A `bg-[url(…)]` / `bg-[linear-gradient(…)]` background image is classified - // by its FUNCTION, since it carries no type hint to classify it by. The - // hinted forms are handled centrally, in `hintedGroup`. - [/^bg-\[(url\(|linear-gradient|radial-gradient|conic-gradient)/, 'bg-image'], - [/^bg-(linear|gradient|conic|radial|none)/, 'bg-image'], - [/^bg-(no-repeat|repeat|repeat-x|repeat-y|repeat-round|repeat-space)$/, 'bg-repeat'], - [/^bg-(fixed|local|scroll)$/, 'bg-attach'], - [/^bg-(auto|cover|contain)$/, 'bg-size'], - [/^bg-size-/, 'bg-size'], - // Two entries rather than one alternation: the first covers the live v4 - // compounds (`bg-top-left`), the second the bare keywords plus the v4.1 - // deprecated reversed compounds (`bg-left-top`), which tailwind-merge still - // carries. An unmatched `bg-*` token falls into the colour catch-all below and - // evicts a real colour, so admitting a dead spelling is the safe direction. - [/^bg-(top|bottom)(-(left|right))?$/, 'bg-position'], - [/^bg-(left|right|center)(-(top|bottom))?$/, 'bg-position'], - [/^bg-position-/, 'bg-position'], - // Clip, origin, and blend mode are three more properties under the same - // prefix. Each sat in `bg-color` before, so `bg-clip-text` evicted a real - // background colour (the gradient-text idiom lost its clip silently). - [/^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'], - // 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|var\()|\(--)/, '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'], - // 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(), - [/^rounded(-[a-z]+)?$/, 'rounded'], - [/^rounded-/, 'rounded'], - [/^opacity-/, 'opacity'], - [/^font-(thin|light|normal|medium|semibold|bold|black|extralight|extrabold)$/, 'font-weight'], - // Box-shadow SIZE and box-shadow COLOUR are two properties (`box-shadow` and - // `--tw-shadow-color`), so they need two groups. `shadow-none` is a size, - // `shadow-inherit` / `shadow-initial` are colours, and Tailwind accepts an - // alpha modifier on a size as well as on a colour, so `shadow-lg/25` has to - // stay on the size side. An unhinted arbitrary value is a SIZE when it opens - // with `inset`, a sign, a dot, or a digit (a shadow offset list) and also - // when it is a bare `var()` or the `(--x)` variable shorthand: Tailwind - // itself resolves an ambiguous arbitrary shadow to `box-shadow` unless the - // value is provably a colour, and `shadow-[var(--shadow-glow)]` is the normal - // way to write a design-token shadow. This is the one place the - // `borderGroups()` convention inverts, because a bare `border-[var(--x)]` is - // far more often a colour while a bare `shadow-[var(--x)]` is far more often - // a shadow. The size entries must precede the colour catch-all, or every - // size lands in the colour group and the bug inverts rather than being fixed. - [/^shadow(-(2xs|xs|sm|md|lg|xl|2xl|inner|none))?(\/([\d.]+|\[[^\]]*\]))?$/, 'shadow'], - [/^shadow-(\[(inset|-|\.|\d|var\()|\(--)/, 'shadow'], - // A bare name the size scale does not list reads as a colour, because - // `shadow-primary` is overwhelmingly more common than a `@theme`-extended - // `--shadow-card`. A project that adds a custom shadow NAME is the residual - // gap, and the docs say so rather than claiming the split is total. - [/^shadow-/, 'shadow-color'], - [/^z-/, 'z'], - // A bare `flex` / `grid` is a DISPLAY value, not a member of the flex / grid - // sub-property groups below, so it must never dedupe against them: an element - // can be both a flex container and a flex child (`class="flex flex-1"`), and - // collapsing the two silently drops `display:flex`. It still belongs to a - // group of its own, alongside every other display keyword, so a repeated one - // collapses and `cn('hidden', open && 'flex')` resolves to one display. - [/^(inline-block|inline-flex|inline-grid|inline-table|inline|block|flex|grid|flow-root|contents|hidden|list-item|table-caption|table-cell|table-column-group|table-column|table-footer-group|table-header-group|table-row-group|table-row|table)$/, 'display'], - // Each sub-utility below gets the group of the real CSS property it sets, so - // none of them collapses against the display value or against each other. - [/^flex-(row|row-reverse|col|col-reverse)$/, 'flex-direction'], - [/^flex-(wrap|wrap-reverse|nowrap)$/, 'flex-wrap'], - [/^flex-(\d+|auto|initial|none|\[[^\]]*\])$/, 'flex'], - [/^grid-cols-/, 'grid-cols'], - [/^grid-rows-/, 'grid-rows'], - [/^grid-flow-/, 'grid-flow'], -]; +// Built on FIRST USE, not at module load. A module-scope `...borderGroups()` +// spread is a real top-level call, so the elision analyser reads this module as +// client-effecting and every page that reaches `cn` on a component-free path +// ships whole instead of being elided (#1320). +let _groups: Array<[RegExp, string]> | undefined; +function GROUPS(): Array<[RegExp, string]> { + return (_groups ??= [ + [/^p-/, 'p'], [/^px-/, 'px'], [/^py-/, 'py'], [/^pt-/, 'pt'], [/^pr-/, 'pr'], [/^pb-/, 'pb'], [/^pl-/, 'pl'], + [/^m-/, 'm'], [/^mx-/, 'mx'], [/^my-/, 'my'], [/^mt-/, 'mt'], [/^mr-/, 'mr'], [/^mb-/, 'mb'], [/^ml-/, 'ml'], + [/^w-/, 'w'], [/^h-/, 'h'], [/^size-/, 'size'], + // A `bg-[url(…)]` / `bg-[linear-gradient(…)]` background image is classified + // by its FUNCTION, since it carries no type hint to classify it by. The + // hinted forms are handled centrally, in `hintedGroup`. + [/^bg-\[(url\(|linear-gradient|radial-gradient|conic-gradient)/, 'bg-image'], + [/^bg-(linear|gradient|conic|radial|none)/, 'bg-image'], + [/^bg-(no-repeat|repeat|repeat-x|repeat-y|repeat-round|repeat-space)$/, 'bg-repeat'], + [/^bg-(fixed|local|scroll)$/, 'bg-attach'], + [/^bg-(auto|cover|contain)$/, 'bg-size'], + [/^bg-size-/, 'bg-size'], + // Two entries rather than one alternation: the first covers the live v4 + // compounds (`bg-top-left`), the second the bare keywords plus the v4.1 + // deprecated reversed compounds (`bg-left-top`), which tailwind-merge still + // carries. An unmatched `bg-*` token falls into the colour catch-all below and + // evicts a real colour, so admitting a dead spelling is the safe direction. + [/^bg-(top|bottom)(-(left|right))?$/, 'bg-position'], + [/^bg-(left|right|center)(-(top|bottom))?$/, 'bg-position'], + [/^bg-position-/, 'bg-position'], + // Clip, origin, and blend mode are three more properties under the same + // prefix. Each sat in `bg-color` before, so `bg-clip-text` evicted a real + // background colour (the gradient-text idiom lost its clip silently). + [/^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'], + // 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|var\()|\(--)/, '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'], + // 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(), + [/^rounded(-[a-z]+)?$/, 'rounded'], + [/^rounded-/, 'rounded'], + [/^opacity-/, 'opacity'], + [/^font-(thin|light|normal|medium|semibold|bold|black|extralight|extrabold)$/, 'font-weight'], + // Box-shadow SIZE and box-shadow COLOUR are two properties (`box-shadow` and + // `--tw-shadow-color`), so they need two groups. `shadow-none` is a size, + // `shadow-inherit` / `shadow-initial` are colours, and Tailwind accepts an + // alpha modifier on a size as well as on a colour, so `shadow-lg/25` has to + // stay on the size side. An unhinted arbitrary value is a SIZE when it opens + // with `inset`, a sign, a dot, or a digit (a shadow offset list) and also + // when it is a bare `var()` or the `(--x)` variable shorthand: Tailwind + // itself resolves an ambiguous arbitrary shadow to `box-shadow` unless the + // value is provably a colour, and `shadow-[var(--shadow-glow)]` is the normal + // way to write a design-token shadow. This is the one place the + // `borderGroups()` convention inverts, because a bare `border-[var(--x)]` is + // far more often a colour while a bare `shadow-[var(--x)]` is far more often + // a shadow. The size entries must precede the colour catch-all, or every + // size lands in the colour group and the bug inverts rather than being fixed. + [/^shadow(-(2xs|xs|sm|md|lg|xl|2xl|inner|none))?(\/([\d.]+|\[[^\]]*\]))?$/, 'shadow'], + [/^shadow-(\[(inset|-|\.|\d|var\()|\(--)/, 'shadow'], + // A bare name the size scale does not list reads as a colour, because + // `shadow-primary` is overwhelmingly more common than a `@theme`-extended + // `--shadow-card`. A project that adds a custom shadow NAME is the residual + // gap, and the docs say so rather than claiming the split is total. + [/^shadow-/, 'shadow-color'], + [/^z-/, 'z'], + // A bare `flex` / `grid` is a DISPLAY value, not a member of the flex / grid + // sub-property groups below, so it must never dedupe against them: an element + // can be both a flex container and a flex child (`class="flex flex-1"`), and + // collapsing the two silently drops `display:flex`. It still belongs to a + // group of its own, alongside every other display keyword, so a repeated one + // collapses and `cn('hidden', open && 'flex')` resolves to one display. + [/^(inline-block|inline-flex|inline-grid|inline-table|inline|block|flex|grid|flow-root|contents|hidden|list-item|table-caption|table-cell|table-column-group|table-column|table-footer-group|table-header-group|table-row-group|table-row|table)$/, 'display'], + // Each sub-utility below gets the group of the real CSS property it sets, so + // none of them collapses against the display value or against each other. + [/^flex-(row|row-reverse|col|col-reverse)$/, 'flex-direction'], + [/^flex-(wrap|wrap-reverse|nowrap)$/, 'flex-wrap'], + [/^flex-(\d+|auto|initial|none|\[[^\]]*\])$/, 'flex'], + [/^grid-cols-/, 'grid-cols'], + [/^grid-rows-/, 'grid-rows'], + [/^grid-flow-/, 'grid-flow'], + ]); +} /** * Border WIDTH and border COLOUR share the `border-` prefix but are different @@ -283,7 +290,7 @@ function dedupeUtilities(input: string): string { const hinted = hintedGroup(bare); let gk: string | null = hinted ?? null; if (hinted === undefined || hinted === null) { - for (const [re, g] of GROUPS) { + for (const [re, g] of GROUPS()) { if (re.test(bare)) { gk = g; break; } } } From 4b3ae0b5026fc84d63de0bda17e3f425f3f6eb11 Mon Sep 17 00:00:00 2001 From: Vivek Date: Sun, 9 Aug 2026 13:48:47 +0530 Subject: [PATCH 2/3] fix(ui): ship the native-select option colours in the theme, not from JS native-select.ts injected its colour rule from module scope, guarded by a `typeof document` check. That made the module both a top-level call and a browser-global reference, so the elision analyser pinned every page rendering a ` with SVG check on `:checked`. REQUIRES `data-slot="checkbox"` on the input for the check to render. | | 1a | `radio-group` | `radioGroupClass`, `radioClass`, native ``. REQUIRES `data-slot="radio"` on the input for the dot to render. | | 1a | `switch` | `switchInputClass`, `switchTrackClass({ size })`, hidden native checkbox + visible track | -| 1a | `native-select` | `nativeSelectWrapperClass`, `nativeSelectClass`, `nativeSelectIconClass`, `nativeSelectOptionClass`, `nativeSelectOptGroupClass` | +| 1a | `native-select` | `nativeSelectWrapperClass`, `nativeSelectClass`, `nativeSelectIconClass`, `nativeSelectOptionClass`, `nativeSelectOptGroupClass`. The `` colours come from the theme block's `select option, select optgroup` rule, NOT from importing the module (#1320), so they are in the first paint and hold with JavaScript off. | | 1a | `avatar` | `avatarClass`, `avatarImageClass`, `avatarFallbackClass`, `avatarBadgeClass`, `avatarGroupClass`, `avatarGroupCountClass` | | 1a | `separator` | `separatorClass({ orientation })` | | 1a | `skeleton` | `skeletonClass` | @@ -263,7 +263,8 @@ obligations: - `pagination` / `breadcrumb`: a labelled `