Skip to content

feat(css): wrap component styles in a single clickui cascade layer#1149

Merged
DreaminDani merged 6 commits into
mainfrom
cui-219-clickui-cascade-layer
Jul 9, 2026
Merged

feat(css): wrap component styles in a single clickui cascade layer#1149
DreaminDani merged 6 commits into
mainfrom
cui-219-clickui-cascade-layer

Conversation

@DreaminDani

Copy link
Copy Markdown
Contributor

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/override sub-layer scheme explored there: BEM-role sub-layers can't soundly order composition conflicts (they get the card__title case backwards and can't break same-role ties at all), and the override layer 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 one clickui layer. It runs in both CSS pipelines, before CSS-Modules name scoping:

  • css.postcss in vite.config.ts — dev, Storybook, the visual-regression suite, and the combined dist/click-ui.css
  • css-preprocess.ts — the per-component dist CSS

so the cascade that ships is exactly the one the visual-regression suite validates. @keyframes is left unlayered (it doesn't participate in the property cascade); @media/@supports/@container go 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:

Any unlayered consumer style — a plain rule, a CSS Module class, or a styled(...) override — beats every click-ui style, regardless of stylesheet order or selector specificity.

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 with main. 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 @layer support (Chrome/Edge 99+, Firefox 97+, Safari 15.4+) — within the library's existing browserslist range.

Verification

  • ✅ Full visual-regression suite — 1234 snapshots pass unchanged against main, with the Docker image rebuilt so the plugin actually runs (that rebuild was the missing step that previously hid regressions).
  • ✅ Unit tests for the plugin (single-layer wrapping, @media inside, @keyframes unlayered, comment association, idempotency).
  • ✅ typecheck, prettier pass.

🤖 Generated with Claude Code

DreaminDani and others added 2 commits July 9, 2026 08:50
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-bot

changeset-bot Bot commented Jul 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2f6ad90

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@clickhouse/click-ui Minor

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 single clickui cascade layer while leaving @keyframes unlayered.
  • 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.

Comment thread plugins/css-colocate/postcss-clickui-layers.ts
Comment thread vite.config.ts
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 ariser left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment thread plugins/css-colocate/postcss-clickui-layers.ts Outdated
Comment thread plugins/css-colocate/postcss-clickui-layers.ts Outdated
Comment thread plugins/css-colocate/postcss-clickui-layers.ts Outdated
Comment thread README.md Outdated
@ariser

ariser commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

It took like a whole conversation to convince claude he's wrong and to land on this ↑ version. Omg.
I asked to write the review in a way that will prevent the implementing agent from spiraling into the same reasoning mess that we went through, hopefully it'll work.

Comment thread plugins/css-colocate/postcss-clickui-layers.ts Outdated
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>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread plugins/css-colocate/postcss-clickui-layers.ts
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 ariser left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

a couple of new notes, but lgtm in general
thanks!

Comment thread plugins/css-colocate/postcss-clickui-layers.ts Outdated
Comment thread plugins/css-colocate/postcss-clickui-layers.ts Outdated
Comment thread plugins/css-colocate/postcss-clickui-layers.ts Outdated
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>
@DreaminDani DreaminDani enabled auto-merge (squash) July 9, 2026 23:46
@workflow-authentication-public

Copy link
Copy Markdown
Contributor

Storybook Preview Deployed

✅ Preview URL: https://click-4kqgt5lvi-clickhouse.vercel.app

Built from commit: bbd2816f6342099eab210b4e277fc8f93bb42f64

@DreaminDani DreaminDani merged commit 8be07c6 into main Jul 9, 2026
9 checks passed
@DreaminDani DreaminDani deleted the cui-219-clickui-cascade-layer branch July 9, 2026 23:47
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.

3 participants