feat(css): adopt clickui cascade layers for predictable overrides#1148
feat(css): adopt clickui cascade layers for predictable overrides#1148DreaminDani wants to merge 4 commits into
Conversation
Add a PostCSS transform that wraps every component rule in a nested `@layer clickui.block, clickui.elem, clickui.mod` set, routed by BEM role (single `_` = modifier, `__` = element, else block). Ordering the sub-layers block -> elem -> mod means a modifier resolves above its base from layer order rather than specificity, and any unlayered consumer style beats every clickui layer — so consumer overrides win regardless of CSS load/source order. The transform runs in the shared CSS pipeline (before name scoping, so it sees original BEM names): `css.postcss` in vite.config.ts covers dev, Storybook, the visual-regression suite, and the combined dist click-ui.css; the same plugin in css-preprocess.ts covers the per-component dist CSS — keeping their cascade identical. No rendering change: the full visual-regression suite (1234 snapshots) passes unchanged. Specificity hacks are removed in a follow-up commit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
With component styles in the clickui.block/elem/mod layers, the internal specificity hacks are redundant and are removed library-wide: - `:where()` zero-specificity bases → normal single-class selectors (Container, BaseButton, EmptyButton). Their modifiers win via the mod layer; unlayered consumer overrides win via the layer being layered. - doubled/tripled-class boosts → single class (Checkbox, Switch, RadioGroup, SidebarNavigationTitle, Flyout, Badge, CardPrimary, CodeBlock, Pagination, DatePicker). Base-vs-modifier precedence now comes from block→elem→mod order. - Label's `:not()` state-exclusion chains → plain `.label:hover/:focus`; the error/disabled modifiers (mod layer) beat hover/focus (block) automatically. Cross-component overrides — where one component composes another and must override the composed component's own block/elem/mod rules on the same node — can't be expressed by the block/elem/mod order alone. Add a fourth, highest internal sub-layer `clickui.override`, opted into per-rule with a `/* @clickui-layer override */` comment, still below any unlayered consumer style. Route the cross-component overrides to it (Pagination↔Select, Flyout↔Container, Badge↔Icon, CodeBlock↔IconButton, CardHorizontal↔Container, CrossButton↔EmptyButton, DatePicker↔Panel/Dropdown/IconButton/InputWrapper). The full visual-regression suite (1234 snapshots) passes unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- README: explain that click-ui styles live in the `clickui` cascade layer, so any unlayered app style (or a layer declared after `clickui`) overrides them without specificity wars. - stylelint: note that `selector-class-pattern` also backs the layer classifier (which relies on `_` being only a modifier separator and `__` only an element separator). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 9b50785 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 |
Storybook Preview Deployed✅ Preview URL: https://click-o0e8rn60e-clickhouse.vercel.app Built from commit: |
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 9b50785. Configure here.
| cursor: pointer; | ||
| } | ||
|
|
||
| .wrapper.wrapper:hover { |
There was a problem hiding this comment.
Trigger elem beats title wrapper
High Severity
Removing the .wrapper.wrapper specificity hack caused styling regressions. SidebarCollapsibleTitle loses its intended display, color, font, cursor, and pseudo-state styles because Collapsible.Trigger (in clickui.elem) now overrides it (in clickui.block). Similarly, Checkbox, Switch, and RadioGroup controls may misalign as their align-items property became dependent on stylesheet load order within clickui.block.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 9b50785. Configure here.
|
@ariser what do you think of the AI's reasoning around adding an "override" layer. I'm not convinced... but I'm also not sure what a better solution is when two modifier-level classes are competing.
|
|
@DreaminDani is there more context/details? I don't see how it can break. When you use component A and compose it with a component B's element, elemB styles should always win. And when the layers are block, elem, mod, with “block loses to elem loses to mod”, the correct layer will always have priority. Modifier overrides base styles of both blocks and elems. Elem styles override block styles, and it's correct when you use a block as another block's elem. What is a use case when it breaks? If it's about “we need to wrap everything in correct layers” — well, duh. But it doesn't make it a footgun. This is a library, its code is its product. It needs to have strict rules, and maintainers should see that they are followed at all times. We should be ready for “well people will forget to write an annotation” in control-plane code. We should not allow or expect this in click-ui code. |
|
If we think that this cannot be automated, and it's too verbose to have as a rule, we should think again. There is probably another way to do it. But I need full context to start thinking about it, right now I'm not sure about what problem specifically we're trying to avoid. |
|
Hmmmmm, I think I got it.
— this took some time to internalize To make it even easier to understand. We can have this: <div class="block-a">
<div class="block-b block-b_pretty block-a__elem"></div>
</div>— in this case, if This is 100% an expected composition as well, e.g. const Card = () => {
return (
<div className="card">
<Title level="h2" className="card__title"/> {/* ← will have class="card__title title title_level_h2" */}
</div>
)
}I'm going to think about it |
|
Ok, I have two solutions. One eases off on the layer model, the other one doubles down. 1. Make it a single layer(yes claude you're smart) I'd avoid :where() tbh, they make it weird to me. But maybe I'm just old. But either way, we'll have to do something like .blockA .blockA__elem {}This requires more thought on case by case basis. When you implement a component, you'll have to spend more time thinking about proper selectors composition. Nothing new for css veterans ain't it. 2. Make it 4 layers
— this solves my example above. Block mods have lower priority than elems or elem mods. Elem mods have the highest. Possible combinations, arrows point at the winner:
why n/aElements of an external component aren't exposed, you can't assign classnames to them unless explicitly invited to by smth like how can external block + consumer block happen<BlockA as={BlockB} />how can external block + consumer block-mod happen<BlockA as={BlockB} blockAProp />how can external block-mod + consumer block happenconst BlockB = ({ blockBProp = 'default' }) => {...}
<BlockA as={BlockB} />the remaining issueLooks like in case of consumer block + external block-mod, consumer block should win. But it won't. 3. Make it 2? An afterthought
then the table above will be correct, with the caveat in which we will end up with some of the selectors being at the same level. Because the top left corner will all be “=”. Thus being resolved by styles order. Which is the thing we're trying to avoid. A question here: does this bring enough value to have this inconsistency, or is it better to just go with the single layer to avoid two differents ways of composing things? (Thanks for coming to my TED talk) |
|
↑ this is all human generated content btw, enjoy I lean towards single layer tbh after all this |
|
@ariser I could tell it was written by you because a robot would never use I gave our css-module-migration bot all the context you shared above (except I didn't tell it you were leaning towards a single layer). Here are its thoughts, verbatim: This is excellent feedback, and ariser is right on the key point. Let me synthesize where I land. I agree: the
|
The one thing that for quite some time to quite some people was the tell if something is ai now comes to rescue. Yay. I win.
Let's go with that. Some points we might want to store somewhere in docs or guidelines:
poor argumentation I say, let's build the architecture here, and cleanup separately |
|
Closing this in favor of #1149 |



What
Wraps all click-ui component CSS in a nested set of cascade layers under a single top-level
clickuilayer, so overrides become predictable without specificity hacks — and removes every internal specificity hack this makes redundant.Layer order:
clickui.block → clickui.elem → clickui.mod → clickui.override. Cascade-layer precedence isunlayered > last-declared > … > first-declared, and specificity only matters within a layer. This buys two guarantees:styled(...)override wins regardless of stylesheet order or specificity, with zero config.How
A small PostCSS plugin (
plugins/css-colocate/postcss-clickui-layers.ts) wraps each rule by BEM role (single_→ modifier,__→ element, else block). It runs in the shared CSS pipeline —css.postcssinvite.config.ts(dev, Storybook, the visual-regression suite, and the combineddist/click-ui.css) and incss-preprocess.ts(per-componentdistCSS) — so the cascade that ships is exactly the one the visual-regression suite validates. That parity is what makes removing the hacks safe.Then, library-wide, the now-redundant hacks are removed:
:where()zero-specificity bases → normal single-class selectors (Container, BaseButton, EmptyButton):not()state-exclusion chains → plain.label:hover/:focus(error/disabled modifiers in the mod layer beat them automatically)clickui.overridesub-layerThe
block/elem/modorder handles precedence within a component. It cannot express a cross-component override — one component that composes another (viaas=/styled()) and must override the composed component's own block/elem/mod rules on the same node (e.g.Flyout.BodyoverridingContainer's width; DatePicker overriding aPaneldefault modifier; Badge overridingIcon's size modifier). Several of these previously relied on doubled classes or on a:where()base being zero-specificity.To resolve these by layer order rather than reintroducing specificity hacks, this adds a fourth, highest internal sub-layer
clickui.override, opted into per-rule with a/* @clickui-layer override */comment. It still sits below any unlayered consumer style, so consumer overrides keep winning. Used by: Pagination↔Select, Flyout↔Container, Badge↔Icon, CodeBlock↔IconButton, CardHorizontal↔Container, CrossButton↔EmptyButton, and DatePicker↔Panel/Dropdown/IconButton/InputWrapper.The public contract stays "just the
clickuilayer" — the sub-layer names are an internal detail.Consumer impact
Consumer overrides that previously tied with a component class (both at
(0,1,0)) and won/lost by CSS bundle order now always win (unlayered beats any layer). Control-plane can drop its override workarounds; this is coordinated separately and does not block this PR.Requires a browser with
@layersupport (Chrome/Edge 99+, Firefox 97+, Safari 15.4+). Verified the library's browserslist resolves to Safari ≥ 15.6 / iOS ≥ 15.6 — no pre-15.4 gap.Verification
@mediasplit, keyframes left unlayered,overrideannotation, idempotency).yarn buildemits the layer-order declaration and correctly-wrapped rules in bothdist/**/click-ui.cssand the per-componentdist/**/*.css.Commits
overridelayer)clickui-layer override contract + stylelint note🤖 Generated with Claude Code