Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .agents/skills/webjs/references/ui-kit.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,20 @@ dropdown-menu, hover-card, sonner, tabs, tooltip, plus toggle and toggle-group
the tokens are missing (re-run `npx webjsdev ui init` or let `add` self-heal them).
- Custom elements are display-only-safe at SSR and hydrate in the browser, the
standard WebJs component model (`references/components.md`).
- A registry module should do no work at module scope, because the elision
analyser reads a module-scope call or a `document` reference as client work
and then the page that imports it ships whole (#1320). `cn` itself is clean,
so importing it never pins a page. Six modules still trip the analyser and DO
pin an importing page: `checkbox`, `radio-group`, `pagination`, `progress`,
`sonner`, `tabs`. The first two inject a stylesheet for real; the other four
are an analyser precision gap (an arrow with an expression body puts its call
at brace depth 0). Either way the page ships, so treat the list as fact rather
than as a technicality. Keep your own copies clean when you edit them, and run
`npx webjsdev elision`, which names the blocker whenever a page ships.
- `native-select`'s `<option>` colours ride the design tokens, not the module.
An app with no theme block gets the browser default `<option>` colours along
with everything else unstyled, fixed the same way (re-run `init`, or let `add`
plant the block). An app whose block predates the rule keeps the default until
the rule is added by hand, because `init` never rewrites an existing block.

Full per-package reference lives in the installed `@webjsdev/ui/AGENTS.md`.
195 changes: 101 additions & 94 deletions examples/blog/lib/utils/cn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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; }
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/server/src/component-elision.js
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@ const PURE_DATA_CONSTRUCTORS = new Set([
'BigInt64Array', 'BigUint64Array',
]);

function hasModuleScopeSideEffect(src, literals) {
export function hasModuleScopeSideEffect(src, literals) {
let redacted = src;
if (!literals) {
const r = redactToPlaceholders(src);
Expand Down
Loading
Loading