Skip to content

feat(react-ui): adopt CSS cascade layers (@layer openui) via build-side post-process#589

Open
ankit-thesys wants to merge 6 commits into
thesysdev:mainfrom
ankit-thesys:feat/css-cascade-layers-postprocess
Open

feat(react-ui): adopt CSS cascade layers (@layer openui) via build-side post-process#589
ankit-thesys wants to merge 6 commits into
thesysdev:mainfrom
ankit-thesys:feat/css-cascade-layers-postprocess

Conversation

@ankit-thesys
Copy link
Copy Markdown
Contributor

Closes #588.

Summary

Adopts CSS cascade layers (@layer openui) for OpenUI's compiled component CSS so consumers can override component styles without !important, selector mirroring, or specificity stacking. Implementation is build-side post-process in cp-css.js — component SCSS sources are unchanged.

  • Compiled component CSS (dist/components/**/*.css) is wrapped in @layer openui { ... } after Sass emits it.
  • defaults.css (the :root { --openui-* } token export) stays unlayered — ThemeProvider runtime injection contract is unchanged.
  • All 9 example apps include the recommended Tailwind v4 layer-order declaration in their globals.css.
  • README + chat/installation + chat/theming + api-reference + standard-library docs document the contract.

What changes for consumers

OpenUI components now lose to:

  • Unlayered consumer CSS — plain CSS, CSS Modules, CSS-in-JS, Tailwind v3 utilities. Zero configuration needed.
  • Tailwind v4 utilities, with this one-line addition to globals.css:
    @layer theme, base, openui, components, utilities;
    @import "tailwindcss";

Without that declaration, the cascade order is bundler-determined and openui typically ends up declared after utilities — utility overrides silently fail (see #588 Scenario A).

ThemeProvider token customization, var(--openui-*) resolution, and the defaults.css token-only export are unchanged.

Browser support

CSS cascade layers require Chrome 99+, Firefox 97+, Safari 15.4+, Edge 99+ (March 2022 baseline). Declared via browserslist in packages/react-ui/package.json so consumer build tools see the floor explicitly.

Why post-process instead of source-side @layer wraps

  • One change site (cp-css.js) enforces the contract — no per-file discipline.
  • Source SCSS stays clean — no 105-file diff, no skill/rule template, no separate CI guard.
  • Storybook needs no special handling — consumes source SCSS (unlayered → specificity-driven), behaves as before this change. No preview-head.html, no preflight layer wrap.

Trade-off: in Storybook, OpenUI components win on specificity vs consumer overrides; in prod, consumers win via layer order. For "does this component look right" Storybook use, identical. For "does my consumer override beat OpenUI" QA, test in a consumer app.

We prototyped the source-side approach in parallel (105 SCSS source wraps + skill template + path-scoped rule + CI guard + Storybook preflight fix). Both produce identical consumer contracts. Post-process ships ~50× smaller diff (+112/-1 vs +7434/-7150) and avoids the maintenance overhead.

Test plan

  • Pull the branch and run pnpm install && pnpm -r build.
  • Run pnpm --filter @openuidev/react-ui storybook — confirm components render correctly. (Storybook needs no changes for this approach.)
  • cd examples/openui-chat && pnpm dev — confirm components look right in a real consumer app.
  • In the example, write a quick CSS override (e.g. unlayered .openui-button-base-primary { background: hotpink }, or layered @layer utilities { .openui-button-base-primary { background: red } }) and confirm it wins without !important.
  • Confirm pnpm --filter @openuidev/react-ui lint:check and format:check are clean.
  • Spot-check compiled output:
    • head -c 50 packages/react-ui/dist/components/Button/button.css should start with @layer openui{.
    • head -c 50 packages/react-ui/dist/openui-defaults.css should start with :root{ (unlayered, intentional).

Commits

  1. 0efae7b4 — declare browserslist floor for cascade-layers support
  2. f14358e8 — document @layer openui contract in readme
  3. 93707abd — note ThemeProvider runtime injection is intentionally unlayered
  4. 71169060 — adopt cascade-layer recipe for tailwind v4 example apps
  5. 4a25d7f8 — document @layer integration recipe in chat docs
  6. b8e70bae — wrap compiled component CSS in @layer openui via cp-css.js

🤖 Generated with Claude Code

ankit-thesys and others added 6 commits May 29, 2026 14:18
CSS @layer rules require Chrome 99+ / Firefox 97+ / Safari 15.4+ / Edge 99+.
Declaring the floor before the cascade-layer wrap lands so consumers and
tooling can detect incompatible target browsers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a Styling Integration section to the package README covering the
cascade-layer contract: consumer CSS overrides OpenUI components without
specificity matching, with a Tailwind v4 layer-order example, a note
that Tailwind v3 / CSS Modules / CSS-in-JS work zero-config, and the
browser support floor declared via browserslist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… unlayered

Add a two-line comment above the useInsertionEffect that writes
<style> into <head>. The runtime injection is unlayered by design so
it overrides every @layer openui declaration (the static defaults file
and the component CSS). Without this annotation a future refactor
could silently move the injection into a cascade layer and break the
override contract documented in the README.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add @layer theme, base, openui, components, utilities; ahead of
@import "tailwindcss"; in every example app's globals.css. All nine
example apps use Tailwind v4; without this declaration the cascade
order is bundler-dependent and OpenUI typically ends up declared
after @layer utilities, preventing utility classes from overriding
component styles.

With the declaration the order is theme < base < openui < components <
utilities — Preflight resets the baseline, OpenUI applies on top,
Tailwind utilities and consumer overrides win above OpenUI.

Affected examples: multi-agent-chat, mastra-chat, hands-on-table-chat,
openui-chat, openui-artifact-demo, openui-dashboard, shadcn-chat,
supabase-chat, vercel-ai-chat.

Verified in openui-chat at runtime: a rule injected into @layer
utilities now correctly overrides .openui-button-base-primary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Document the cascade-layer integration across the four most relevant
docs pages so consumers see the recipe at every plausible entry point:

- chat/installation.mdx — new Step 3 walks Tailwind v4 users through
  declaring @layer theme, base, openui, components, utilities; ahead
  of @import tailwindcss. Explains why bundler-determined order is
  fragile and notes that non-Tailwind-v4 setups need no configuration.
- chat/theming.mdx — new "Override component styles with CSS" section
  shows the unlayered-CSS override pattern and links to the v4 recipe.
- api-reference/react-ui.mdx — new "Cascade-layer contract" subsection
  under Import gives the one-paragraph summary plus links to the chat
  docs for setup and override patterns.
- openui-lang/standard-library.mdx — brief note in the render-with-OpenUI
  section pointing readers at the chat docs for the contract.

Each addition includes the browser support floor (Chrome 99+, Firefox
97+, Safari 15.4+, Edge 99+; March 2022 baseline). Anchor links across
the four pages cross-reference each other so any entry point reaches
the full picture in one click.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ss.js

Build-side post-process: after Sass emits dist/components/**/*.css,
cp-css.js walks the tree and wraps each file in @layer openui { ... }
before the existing copy step carries it to dist/styles/. Component
SCSS sources stay unchanged.

Why post-process instead of source-side @layer wraps:
- One change site enforces the contract; no per-file discipline.
- Source SCSS stays clean — no 105-file diff, no skill/rule template.
- Storybook consumes source SCSS directly and behaves as before this
  change (unlayered → specificity-driven), so no preview-head.html or
  preflight layer wrap is needed.

Skipped files:
- *.module.css — Storybook CSS Modules, locally scoped, not shipped.
- dist/openui-defaults.css — lives outside dist/components, stays
  unlayered so the defaults.css export remains in the unlayered
  cascade and ThemeProvider runtime injection keeps overriding it.

The aggregate dist/components/index.css includes the :root { --openui-* }
declarations from openui-defaults.scss (via Sass @use). Those land
inside @layer openui in the aggregate; this is benign because var()
lookups ignore layers and ThemeProvider's unlayered runtime injection
plus the unlayered defaults.css export both still override at runtime.

Idempotency check (/^\s*@layer\s+openui\b/) protects watch-mode and
back-to-back builds from double-wrapping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

Component style overrides need !important or selector mirroring — propose @layer openui contract

1 participant