feat(css): wrap component styles in a single clickui cascade layer#1149
Conversation
Add a PostCSS plugin (plugins/css-colocate/postcss-clickui-layers.ts) that
wraps all component CSS in one `@layer clickui { … }`, and wire it into both
CSS pipelines: the shared Vite `css.postcss` (dev, Storybook, the
visual-regression suite, and the combined dist `click-ui.css`) and the
per-component `css-preprocess.ts` dist output. Runs before CSS-Modules name
scoping so it sees the original selectors; keeps @Keyframes unlayered.
Cascade layers give consumers a zero-config override guarantee: any unlayered
app style — a plain rule, a CSS Module class, or a `styled(...)` override —
beats every click-ui style regardless of stylesheet order or specificity.
Inside the layer, precedence is ordinary CSS (specificity + source order), so
click-ui's own rendering is unchanged: verified byte-for-byte against main by
the full 1234-snapshot visual-regression suite (image rebuilt so the plugin
actually runs).
Internal conflicts between click-ui components are intentionally left to be
resolved with normal CSS specificity; removing the now-redundant specificity
hacks is a separate follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…on discipline README: how consumers override click-ui styles (unlayered always wins; order your own layer after `clickui`). CONVENTIONS: the layer solves consumer overridability and nothing more — conflicts between click-ui components still need normal CSS discipline, and since composition is deterministic it is the composing component's responsibility to write selectors that win. A component may know what it composes; it must never guess how it will be composed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 2f6ad90 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
Pull request overview
Introduces a single top-level @layer clickui { … } wrapper around Click UI component CSS to make consumer overrides deterministic while preserving internal cascade behavior, implemented via a PostCSS plugin wired into both the Vite and per-component CSS build pipelines.
Changes:
- Add a PostCSS plugin to wrap rules (and conditional groups like
@media) in a singleclickuicascade layer while leaving@keyframesunlayered. - Wire the plugin into Vite’s CSS pipeline and the per-component CSS preprocessing pipeline; expand Vitest include paths to run plugin tests.
- Document the cascade-layer override contract in README, conventions, and a changeset.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| vite.config.ts | Adds the PostCSS plugin to Vite CSS processing and includes plugin tests in Vitest. |
| README.md | Documents how consumers can reliably override layered Click UI styles. |
| plugins/css-colocate/postcss-clickui-layers.ts | Implements the single-layer wrapping PostCSS plugin. |
| plugins/css-colocate/postcss-clickui-layers.test.ts | Adds unit tests for layering behavior, keyframes handling, and idempotency. |
| plugins/css-colocate/css-preprocess.ts | Applies the layering plugin before CSS Modules scoping in the per-component dist pipeline. |
| .llm/CONVENTIONS.md | Documents the layering contract and composition discipline expectations. |
| .changeset/feat-clickui-cascade-layer.md | Announces the user-facing change and override guidance in release notes. |
Vite's `css.postcss` runs on every stylesheet, so the plugin was also wrapping the global theme token files (theme/styles/tokens-*.css, imported by ThemeProvider) in `@layer clickui`. Those are copied to dist verbatim by copyCssFiles, so the combined click-ui.css would have disagreed with the per-module dist on whether design tokens are layered — and layering the tokens was never the intent. Gate the transform on the `.module.css` suffix (root.source.input.file) so both pipelines behave identically; css-preprocess.ts already only feeds it `*.module.css`. Re-verified byte-for-byte (1234 snapshots). Also swap the misleading `as unknown as never` cast on the Vite postcss plugin entry for a clearer `as any` with an explanatory comment (the cast works around Vite bundling its own nominal-duplicate `postcss` type). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ariser
left a comment
There was a problem hiding this comment.
Direction is right — close one architectural gap before merge: layer the tokens
The single-layer approach is the correct call, and the rationale for dropping the sub-layer/override scheme is sound. One gap keeps this from being the architecture it describes: the design tokens are left out of the clickui layer, and that breaks the override contract this PR documents.
The architecture is "all click-ui styles live in the clickui layer, so overrides are predictable." Tokens are click-ui styles. Leaving them unlayered means a consumer who follows the README — putting overrides in a layer declared after clickui — beats our component rules but loses to our tokens, because an unlayered declaration outranks every layer. So the guarantee silently stops at the theming API, the surface consumers most want to override.
This is rendering-safe: tokens are declared on [data-cui-theme="…"] and components read them via var() on their own elements, so the two never compete on one element — click-ui's own rendering is byte-for-byte identical either way, and the VR suite can't observe a difference (no consumer present). The only thing that changes is consumer override resolution, which is the point.
The inline comments break down the change: (1) wrap the tokens too, in both shipped surfaces; (2) simplify the plugin from a whitelist to a blacklist, so "wrap everything, exclude only what can't be wrapped" falls out naturally.
Verification: re-run the full VR suite with the Docker image rebuilt (expect it unchanged), and add a consumer smoke test — an unlayered token override and an @layer app token override should both win over click-ui's token.
|
It took like a whole conversation to convince claude he's wrong and to land on this ↑ version. Omg. |
The override contract is "all click-ui styles live in the clickui layer", and the design tokens are click-ui styles. Leaving them unlayered broke the contract at the theming API: a consumer's `@layer app` override beat our component rules but LOST to our tokens, because an unlayered declaration outranks every layer. - Remove the `.module.css` file gate and invert the plugin from a whitelist (only rules + conditional at-rules go inside) to a blacklist: wrap everything, hoist out only the at-rules CSS forbids inside `@layer` (@charset/@import/ @namespace). @keyframes/@font-face/@Property are valid inside a layer, so they stay wrapped. Deletes CONDITIONAL_AT_RULES/isLayerable; whole-file wrapping keeps comments adjacent to their rules. - Wrap non-module globals (the theme token files) on the copyCssFiles path too — fs.copy bypassed PostCSS and would have shipped the standalone dist token files unlayered while the bundle was layered. Now both surfaces layer identically. - Rewrite the plugin docstring to present-tense (drop the implementation-history phrasing flagged in review). - README/CONVENTIONS: note theming variables follow the same override rule. Verified byte-for-byte: full visual-regression suite unchanged (1234 snapshots), Docker image rebuilt so the plugin runs. Adds a consumer smoke test proving an unlayered token override and an `@layer app` token override both beat click-ui's layered token (1237 total). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit b35db0b. Configure here.
The idempotency guard matched any top-level `@layer clickui` at-rule, including a bare `@layer clickui;` order statement (postcss `nodes === undefined`) that declares the layer but wraps nothing — which would short-circuit and leave the following rules unlayered. Require the block form. Not reachable by real click-ui CSS (source never hand-writes `@layer`, and the plugin only ever emits the block form), so compiled output is unchanged; hardened for correctness with a regression test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ariser
left a comment
There was a problem hiding this comment.
a couple of new notes, but lgtm in general
thanks!
Address review notes: - Detect the at-rules to hoist out of the layer by structure — block-less "statement" at-rules (PostCSS `nodes === undefined`) — instead of a hardcoded `@charset`/`@import`/`@namespace` name set. Block-less at-rules are exactly the ones CSS forbids inside `@layer` and carry no style rules, so the test is equivalent today and forward-compatible with any future statement at-rule. - Trim the docstring: drop the redundant note that @keyframes/@font-face/ @Property stay wrapped, and the enumeration of which pipelines call the plugin (not the module's concern). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Storybook Preview Deployed✅ Preview URL: https://click-4kqgt5lvi-clickhouse.vercel.app Built from commit: |

What
Wraps all click-ui component CSS in a single top-level
@layer clickui { … }so consumer overrides are predictable — with zero changes to any component's rendering.This is the single-layer approach we landed on in the design discussion on #1148 (thanks @ariser). It supersedes the nested
block/elem/mod/overridesub-layer scheme explored there: BEM-role sub-layers can't soundly order composition conflicts (they get thecard__titlecase backwards and can't break same-role ties at all), and theoverridelayer was a band-aid that relied on humans remembering an annotation. A single layer delivers the actual goal without any of that.How
A small PostCSS plugin (
plugins/css-colocate/postcss-clickui-layers.ts) wraps every component rule in oneclickuilayer. It runs in both CSS pipelines, before CSS-Modules name scoping:css.postcssinvite.config.ts— dev, Storybook, the visual-regression suite, and the combineddist/click-ui.csscss-preprocess.ts— the per-componentdistCSSso the cascade that ships is exactly the one the visual-regression suite validates.
@keyframesis left unlayered (it doesn't participate in the property cascade);@media/@supports/@containergo inside the layer.Why a single layer works
Cascade-layer precedence is
unlayered > layered. So the one guarantee we actually need falls out for free:Inside the layer, precedence is ordinary CSS (specificity + source order) — identical to what click-ui ships today. So this changes nothing about click-ui's own rendering; it only draws the boundary that lets consumers win.
Conflicts between click-ui components are intentionally resolved the normal way — with specific-enough selectors — not by layer tricks. Composition is deterministic: a component knows what it composes, so it's that component's job to write selectors that win. The composition-discipline guidelines are documented in
.llm/CONVENTIONS.md.Scope
Architecture only. This PR keeps every existing selector untouched — including the
:where()bases and doubled-class boosts that the layer now makes redundant. That's what keeps it byte-for-byte withmain. Removing those hacks is a separate follow-up (they don't hurt anything in the meantime).Consumer impact
Consumer overrides that previously tied with a component class (both
(0,1,0)) and won/lost by CSS bundle order now always win. Requires a browser with@layersupport (Chrome/Edge 99+, Firefox 97+, Safari 15.4+) — within the library's existing browserslist range.Verification
main, with the Docker image rebuilt so the plugin actually runs (that rebuild was the missing step that previously hid regressions).@mediainside,@keyframesunlayered, comment association, idempotency).🤖 Generated with Claude Code