Skip to content

feat(components): introduce CLASSES sibling-file convention#194

Merged
martinezdiego merged 2 commits intomasterfrom
feat/css-classes-convention
Apr 12, 2026
Merged

feat(components): introduce CLASSES sibling-file convention#194
martinezdiego merged 2 commits intomasterfrom
feat/css-classes-convention

Conversation

@kybik44
Copy link
Copy Markdown
Collaborator

@kybik44 kybik44 commented Apr 11, 2026

Adds a <ComponentName>.classes.ts file next to each component .tsx, with a single exported CLASSES const literal that holds every CSS class the component can render. The component imports the const and references every class through CLASSES.* — no class strings live in the .tsx file anymore.

This is the authoring-side groundwork for the upcoming css-purge plugin (the port of @pathscale/rollup-plugin-vue3-ui-css-purge to the new stack). The analyzer reads each .classes.ts file directly as data — no JSX parsing, no clsx/twMerge inspection, no conditional reasoning — and uses the result as the per-component database the plugin's safelist is built from.

Three example components are included to cover the common shapes:

  • button/ single component, slots base + variant + size + flag
  • breadcrumbs/ compound (Root + Item) in one file, base as array
  • navbar/ compound across 4 files with one shared classes.ts;
    exercises base (string and array), variant, flag, and a
    new color slot for ComponentColor

Slot reference (uniform across components):
base always rendered (string or readonly string[])
variant enum prop value -> class
size same shape, conventional name for the size prop
flag boolean prop NAME -> class (rendered when prop is truthy)
color enum prop value -> class (used for ComponentColor)

New slot names are allowed when a component needs them; the analyzer treats every nested object identically.

Where it makes sense, types are derived from the const so they cannot drift from the class map:
export type ButtonVariant = keyof typeof CLASSES.variant;

No consumer-facing API changes for any of the three components. Verified with bunx tsc --noEmit (clean) and bun run build (all .classes.js + .classes.d.ts artifacts produced in dist/).

Diego: copy the shape from these three examples for the rest of the lib. The inviolable rule is "no class strings outside the .classes.ts file".

@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 11, 2026

🚀 Preview deployment is ready!

You can view the preview at: https://pr-ui-preview-194.surge.sh

Alexey Budilovich and others added 2 commits April 12, 2026 11:21
Adds a `<ComponentName>.classes.ts` file next to each component .tsx, with a
single exported `CLASSES` const literal that holds every CSS class the
component can render. The component imports the const and references every
class through `CLASSES.*` — no class strings live in the .tsx file anymore.

This is the authoring-side groundwork for the upcoming css-purge plugin (the
port of @pathscale/rollup-plugin-vue3-ui-css-purge to the new stack). The
analyzer reads each .classes.ts file directly as data — no JSX parsing, no
clsx/twMerge inspection, no conditional reasoning — and uses the result as
the per-component database the plugin's safelist is built from.

Three example components are included to cover the common shapes:

- button/         single component, slots base + variant + size + flag
- breadcrumbs/    compound (Root + Item) in one file, base as array
- navbar/         compound across 4 files with one shared classes.ts;
                  exercises base (string and array), variant, flag, and a
                  new color slot for ComponentColor

Slot reference (uniform across components):
  base     always rendered (string or readonly string[])
  variant  enum prop value -> class
  size     same shape, conventional name for the size prop
  flag     boolean prop NAME -> class (rendered when prop is truthy)
  color    enum prop value -> class (used for ComponentColor)

New slot names are allowed when a component needs them; the analyzer treats
every nested object identically.

Where it makes sense, types are derived from the const so they cannot drift
from the class map:
  export type ButtonVariant = keyof typeof CLASSES.variant;

No consumer-facing API changes for any of the three components. Verified
with `bunx tsc --noEmit` (clean) and `bun run build` (all .classes.js +
.classes.d.ts artifacts produced in dist/).

Diego: copy the shape from these three examples for the rest of the lib.
The inviolable rule is "no class strings outside the .classes.ts file".
@martinezdiego martinezdiego force-pushed the feat/css-classes-convention branch from a3bdb78 to 9a2fa18 Compare April 12, 2026 15:41
@martinezdiego martinezdiego merged commit 172a996 into master Apr 12, 2026
2 checks passed
@martinezdiego martinezdiego deleted the feat/css-classes-convention branch April 12, 2026 15:41
// and over-including is safe (purges less aggressively) while under-including breaks UI.
base: ["breadcrumbs__item", "breadcrumbs__link", "breadcrumbs__separator"],
},
} as const;
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1.1 File extension mismatch: .classes.ts vs .classnames.ts
The PR uses .classes.ts everywhere, but our branch renamed the convention to .classnames.ts (commit b640615). Your branched before the rename. All 34 files need renaming to .classnames.ts + updating imports in all .tsx files.

Action: follow-up PR to batch-rename .classes.ts → .classnames.ts.

1.2 Text.classnames.ts is incomplete

Text.classes.ts only defines base: "text", but Text.tsx has TextSize (5 values) and TextVariant (5 values) rendered via data-size and data-variant attributes. These are not captured in the classes file. The purge analyzer will miss them.

// Current (incomplete)
export const CLASSES = { base: "text" } as const;

// Should be something like:
export const CLASSES = {
base: "text",
variant: { body: "text--body", heading: "text--heading", ... },
size: { sm: "text--sm", md: "text--md", ... },
} as const

1.3 Missing attrs slot across all components

The template Button.classnames.ts on our branch includes an attrs slot for Level 2 purge (commit a3bdb78). you rollout drops it entirely - no component has attrs.
Any component that conditionally sets data-* or aria-* attributes needs this slot for the analyzer.

Action: audit which components set conditional data-/aria- attributes, add attrs slots.

  1. Suggested Changes (request changes)
    2.1 Duplicated class strings between DateField and TimeField

Both files define identical strings for Group, Input, InputContainer, Segment, Prefix, Suffix (all date-input-group__*). This is a maintenance risk - changing one without the other causes drift.

Fix: Extract shared date-input-group classes into a shared file, or have TimeField.classes.ts re-export from DateField.classes.ts for the shared parts.

2.2 Tag.classes.ts uses lowercase slot as compound key
Every other compound component uses PascalCase keys (Root, Item, Section), but Tag uses:

export const CLASSES = {
Root: { ... },
slot: { icon: "...", removeButton: "..." }, // ← lowercase
} as const;

Should be Slot for consistency, or restructured as flat slot keys within Root (like Button does).

2.3 Navbar uses raw Tailwind utility strings

All other components use single BEM class names ("button--primary"), but Navbar embeds multi-token Tailwind strings:

  flag: {                                                                                                                         
    sticky: "sticky top-0 z-30",                                                                                                                                               container: "max-w-screen-xl mx-auto px-4",                                                                                                                          
  }                                                                                                                                                                      

This breaks the assumption that each value is a single class. The purge analyzer may not handle multi-token values correctly.

Fix: Replace with BEM classes ("navbar-stack--sticky", "navbar-stack--container") and move the Tailwind utilities into the CSS file where those BEM classes are defined.

2.4 Navbar.Section has no base slot

It's the only sub-component across all 34 files that omits base entirely - it only has variant. Every other part has at least base. If this is intentional (section has no unconditional class), document it with a comment.

  1. Nice to Have (non-blocking)

3.1 Type derivation not applied broadly

Only Button.tsx derives types from CLASSES (type ButtonVariant = keyof typeof CLASSES.variant). Components like Card, Separator, Surface, CheckboxGroup etc. that have variants could benefit from the same pattern. Not required, but reduces type drift.

3.2 Custom slot names undocumented

Several components introduce non-standard slot names without comments explaining why:

  • ButtonGroup, Separator → orientation (alongside/instead of variant)
  • InputOTP.Slot → value, caret - ComboBox → icon, empty, label, indicator
  • Menu.ItemIndicator → submenu

These are all fine semantically, but a brief comment in each .classes.ts explaining why a custom name was chosen over a standard one would help future contributors.

3.3 clsx cleanup is clean All clsx imports were properly removed and replaced with CLASSES.* + twMerge. No leftover dead imports.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants