diff --git a/docs/app/global.css b/docs/app/global.css index 14b7b4f9b..acaf5db5f 100644 --- a/docs/app/global.css +++ b/docs/app/global.css @@ -10,7 +10,7 @@ @import "fumadocs-ui/css/neutral.css"; @import "fumadocs-ui/css/preset.css"; @import "../shared/design-system/styles/variables/index.css"; -@import "@openuidev/react-ui/styles/index.css"; +@import "@openuidev/react-ui/layered/styles/index.css"; body { --color-fd-background: var(--openui-background); diff --git a/docs/content/docs/api-reference/react-ui.mdx b/docs/content/docs/api-reference/react-ui.mdx index 629c89f5d..5bd9b9f7c 100644 --- a/docs/content/docs/api-reference/react-ui.mdx +++ b/docs/content/docs/api-reference/react-ui.mdx @@ -13,9 +13,11 @@ import { Copilot, FullScreen, BottomTray } from "@openuidev/react-ui"; ### Cascade-layer contract -`@openuidev/react-ui/components.css` wraps every component rule in `@layer openui`. Unlayered consumer CSS overrides OpenUI components without specificity matching or `!important`. See [Chat → Theming](/docs/chat/theming#override-component-styles-with-css) for override patterns, and [Chat → Installation](/docs/chat/installation#3-for-tailwind-v4-set-the-cascade-layer-order) for the Tailwind v4 layer-order setup. +The default stylesheet exports (`./components.css`, `./styles/*`) are **unlayered**. Two parallel exports — `@openuidev/react-ui/layered-components.css` and `@openuidev/react-ui/layered/styles/*` — ship the same rules wrapped in `@layer openui`; with the layered variant, unlayered consumer CSS overrides OpenUI components without specificity matching or `!important`. When using the layered variant, import it from exactly one place in your app — multiple import sites under chunk-splitting bundlers can register the `openui` layer in the wrong order. See [Chat → Theming](/docs/chat/theming#override-component-styles-with-css) for override patterns, and [Chat → Installation](/docs/chat/installation#3-optional-opt-into-cascade-layered-styles) for the Tailwind v4 layer-order setup. -Browser support: Chrome 99+, Firefox 97+, Safari 15.4+, Edge 99+ (all baseline from March 2022). +`./defaults.css` and the `ThemeProvider` runtime style injection are always unlayered, so theme tokens override component defaults in both modes. + +Browser support for the layered variant: Chrome 99+, Firefox 97+, Safari 15.4+, Edge 99+ (all baseline from March 2022); older browsers drop the `@layer` block entirely. The default unlayered styles have no such floor. ## Layout components diff --git a/docs/content/docs/chat/installation.mdx b/docs/content/docs/chat/installation.mdx index 1f52d2ee2..7ce202964 100644 --- a/docs/content/docs/chat/installation.mdx +++ b/docs/content/docs/chat/installation.mdx @@ -57,19 +57,39 @@ export default function RootLayout({ children }: { children: React.ReactNode }) These imports give you the default chat layout styling and theme tokens. -## 3. (For Tailwind v4) Set the cascade-layer order +## 3. (Optional) Opt into cascade-layered styles -OpenUI's component styles live in `@layer openui`. If your app uses Tailwind v4, declare the layer order in your global stylesheet so `openui` sits above Tailwind's reset (`base`) but below `components` and `utilities`: +The default import above ships **unlayered** CSS — overrides work via normal specificity, exactly as in earlier versions. If you prefer your plain app CSS to win over OpenUI styles without specificity matching (recommended for Tailwind v4 apps), import the **layered** variant instead. + +Import it from a **single place** — your global stylesheet, not the root layout — so its `@layer openui` block registers in a predictable order: + +```css +/* app/globals.css */ +@import "@openuidev/react-ui/layered-components.css"; +``` + +```tsx +// app/layout.tsx — import only globals.css (drop the react-ui CSS imports from step 2) +import "./globals.css"; +``` + +With Tailwind v4, declare the layer order at the top of `globals.css`, **before** the layered import, so `openui` sits above Tailwind's reset (`base`) but below `components` and `utilities`: ```css /* app/globals.css */ @layer theme, base, openui, components, utilities; @import "tailwindcss"; +@import "@openuidev/react-ui/layered-components.css"; ``` -This places OpenUI above Tailwind's Preflight (so its element resets don't override component styles) while keeping Tailwind utilities like `bg-red-500` winning over OpenUI. Without this declaration, the cascade order is bundler-dependent and `openui` may end up declared *after* `utilities`, which prevents utility overrides from taking effect. +This places OpenUI above Tailwind's Preflight (so its element resets don't override component styles) while keeping Tailwind utilities like `bg-red-500` winning over OpenUI. The layered import must come **after** the `@layer` declaration: loading it first registers the `openui` layer before the order statement and locks the wrong order (and without the declaration at all, the cascade order is bundler-dependent — `openui` may end up after `utilities`, preventing utility overrides). + +Two rules when using the layered variant: + +- Import OpenUI CSS from **exactly one place** (your root layout or global stylesheet, not both) — multiple import sites under chunk-splitting bundlers like Turbopack can lock the wrong layer order. +- Wrap your own global resets in a layer below `openui` (e.g. `@layer base { ... }`) — unlayered resets beat all layered styles regardless of specificity. -Tailwind v3, plain CSS, CSS Modules, and CSS-in-JS need no configuration — their styles are unlayered and beat anything in `@layer openui` automatically. +The layered variant requires cascade-layer support: Chrome 99+, Firefox 97+, Safari 15.4+, Edge 99+ (March 2022 baseline). Tailwind v3, plain CSS, CSS Modules, and CSS-in-JS need no configuration with either variant. See [`@openuidev/react-ui`](/docs/api-reference/react-ui#cascade-layer-contract) for the full styling integration contract. diff --git a/docs/content/docs/chat/theming.mdx b/docs/content/docs/chat/theming.mdx index 81218c053..fa51cb762 100644 --- a/docs/content/docs/chat/theming.mdx +++ b/docs/content/docs/chat/theming.mdx @@ -56,7 +56,9 @@ import { FullScreen } from "@openuidev/react-ui"; ## Override component styles with CSS -OpenUI's component styles live in `@layer openui`. Any unlayered consumer CSS overrides them without `!important` or matching specificity: +By default OpenUI ships **unlayered** styles: override them like any other stylesheet — with a selector of equal or higher specificity loaded after the component CSS. + +If you import the **layered** variant (`@openuidev/react-ui/layered-components.css`), all component styles live in `@layer openui`, and any unlayered consumer CSS overrides them without `!important` or specificity matching: ```css .openui-button-base-primary { @@ -64,9 +66,9 @@ OpenUI's component styles live in `@layer openui`. Any unlayered consumer CSS ov } ``` -For Tailwind v4 apps, declare `@layer theme, base, openui, components, utilities;` ahead of `@import "tailwindcss";` in your global stylesheet (see [Installation](/docs/chat/installation#3-for-tailwind-v4-set-the-cascade-layer-order)) so utility classes also override OpenUI styles. CSS Modules, CSS-in-JS, and Tailwind v3 utilities emit unlayered CSS and override OpenUI automatically with no configuration. +For Tailwind v4 apps using the layered variant, declare `@layer theme, base, openui, components, utilities;` ahead of `@import "tailwindcss";` in your global stylesheet (see [Installation](/docs/chat/installation#3-optional-opt-into-cascade-layered-styles)) so utility classes also override OpenUI styles. CSS Modules, CSS-in-JS, and Tailwind v3 utilities emit unlayered CSS and override the layered variant automatically with no configuration. -Browser support: Chrome 99+, Firefox 97+, Safari 15.4+, Edge 99+ (all baseline from March 2022). +Browser support for the layered variant: Chrome 99+, Firefox 97+, Safari 15.4+, Edge 99+ (all baseline from March 2022). The default unlayered styles have no browser floor.
diff --git a/docs/content/docs/openui-lang/standard-library.mdx b/docs/content/docs/openui-lang/standard-library.mdx index 3bebcde91..b9ab590fe 100644 --- a/docs/content/docs/openui-lang/standard-library.mdx +++ b/docs/content/docs/openui-lang/standard-library.mdx @@ -21,7 +21,7 @@ import { openuiLibrary } from "@openuidev/react-ui"; ; ``` -The compiled stylesheet wraps component rules in `@layer openui` so your own CSS overrides them without specificity matching. See [Chat → Theming](/docs/chat/theming#override-component-styles-with-css) for the override contract, or [Chat → Installation](/docs/chat/installation#3-for-tailwind-v4-set-the-cascade-layer-order) for the Tailwind v4 setup. +The `components.css` import ships unlayered styles, so you can override them via normal CSS specificity. If you prefer cascade-layered styles, import `@openuidev/react-ui/layered-components.css` instead — see [Chat → Installation](/docs/chat/installation#3-optional-opt-into-cascade-layered-styles) for details. ## Generate prompt diff --git a/examples/hands-on-table-chat/src/app/page.tsx b/examples/hands-on-table-chat/src/app/page.tsx index c07c8cdb1..18883a032 100644 --- a/examples/hands-on-table-chat/src/app/page.tsx +++ b/examples/hands-on-table-chat/src/app/page.tsx @@ -1,18 +1,15 @@ "use client"; -import "@openuidev/react-ui/components.css"; -import { openAIMessageFormat, openAIAdapter } from "@openuidev/react-headless"; -import { Copilot } from "@openuidev/react-ui"; import { spreadsheetLibrary } from "@/lib/spreadsheet-library"; -import { TableProvider, useTableContext } from "./TableContext"; -import { useState, useEffect, useCallback } from "react"; +import { openAIAdapter, openAIMessageFormat } from "@openuidev/react-headless"; +import { Copilot } from "@openuidev/react-ui"; +import "@openuidev/react-ui/layered-components.css"; import { MessageSquare, PanelRightClose } from "lucide-react"; import dynamic from "next/dynamic"; +import { useCallback, useEffect, useState } from "react"; +import { TableProvider, useTableContext } from "./TableContext"; -const PersistentSpreadsheet = dynamic( - () => import("./PersistentSpreadsheet"), - { ssr: false } -); +const PersistentSpreadsheet = dynamic(() => import("./PersistentSpreadsheet"), { ssr: false }); function ChatPanel({ onClose }: { onClose: () => void }) { const { threadId } = useTableContext(); @@ -42,16 +39,14 @@ function ChatPanel({ onClose }: { onClose: () => void }) { agentName="Spreadsheet AI" welcomeMessage={{ title: "Spreadsheet AI", - description: - "I can help you analyze, visualize, and modify your product revenue data.", + description: "I can help you analyze, visualize, and modify your product revenue data.", }} conversationStarters={{ variant: "long", options: [ { displayText: "Chart revenue by quarter", - prompt: - "Show me a bar chart comparing Q1 through Q4 revenue for all products.", + prompt: "Show me a bar chart comparing Q1 through Q4 revenue for all products.", }, { displayText: "Add Vision Pro to the lineup", @@ -102,11 +97,7 @@ export default function Home() { {chatOpen && } {!chatOpen && ( - diff --git a/examples/mastra-chat/src/app/page.tsx b/examples/mastra-chat/src/app/page.tsx index 14e9ffc63..6aaf5a093 100644 --- a/examples/mastra-chat/src/app/page.tsx +++ b/examples/mastra-chat/src/app/page.tsx @@ -1,5 +1,5 @@ "use client"; -import "@openuidev/react-ui/components.css"; +import "@openuidev/react-ui/layered-components.css"; import { useTheme } from "@/hooks/use-system-theme"; import { agUIAdapter } from "@openuidev/react-headless"; diff --git a/examples/multi-agent-chat/src/app/globals.css b/examples/multi-agent-chat/src/app/globals.css index c45470e58..7e1cd2a6f 100644 --- a/examples/multi-agent-chat/src/app/globals.css +++ b/examples/multi-agent-chat/src/app/globals.css @@ -1,3 +1,3 @@ @layer theme, base, openui, components, utilities; @import "tailwindcss"; -@import "@openuidev/react-ui/components.css"; +@import "@openuidev/react-ui/layered-components.css"; diff --git a/examples/multi-agent-chat/src/app/page.tsx b/examples/multi-agent-chat/src/app/page.tsx index d60879ff6..e0b76e74a 100644 --- a/examples/multi-agent-chat/src/app/page.tsx +++ b/examples/multi-agent-chat/src/app/page.tsx @@ -1,18 +1,16 @@ "use client"; -import "@openuidev/react-ui/components.css"; - -import { useChat } from "@ai-sdk/react"; -import { useRef, useEffect, useState } from "react"; -import { useTheme } from "@/hooks/use-system-theme"; -import { useThreads } from "@/hooks/use-threads"; +import { AssistantMessage } from "@/components/assistant-message"; import { ChatHeader } from "@/components/chat-header"; import { ChatInput } from "@/components/chat-input"; import { ConversationStarters } from "@/components/conversation-starters"; -import { AssistantMessage } from "@/components/assistant-message"; -import { UserMessage } from "@/components/user-message"; -import { ThinkingIndicator } from "@/components/thinking-indicator"; import { Sidebar } from "@/components/sidebar"; +import { ThinkingIndicator } from "@/components/thinking-indicator"; +import { UserMessage } from "@/components/user-message"; +import { useTheme } from "@/hooks/use-system-theme"; +import { useThreads } from "@/hooks/use-threads"; +import { useChat } from "@ai-sdk/react"; +import { useEffect, useRef, useState } from "react"; export default function Page() { useTheme(); @@ -88,10 +86,7 @@ export default function Page() { sidebarOpen ? "md:ml-[280px]" : "ml-0" }`} > - setSidebarOpen((o) => !o)} - /> + setSidebarOpen((o) => !o)} />
{isEmpty ? ( @@ -100,13 +95,7 @@ export default function Page() {
{messages.map((m) => { if (m.role === "assistant") { - return ( - - ); + return ; } if (m.role === "user") { return ; diff --git a/examples/openui-artifact-demo/src/app/page.tsx b/examples/openui-artifact-demo/src/app/page.tsx index a68e6c6f9..b4a173e23 100644 --- a/examples/openui-artifact-demo/src/app/page.tsx +++ b/examples/openui-artifact-demo/src/app/page.tsx @@ -1,10 +1,10 @@ "use client"; -import "@openuidev/react-ui/components.css"; +import "@openuidev/react-ui/layered-components.css"; import { useTheme } from "@/hooks/use-system-theme"; +import { artifactDemoLibrary } from "@/library"; import { openAIAdapter, openAIMessageFormat } from "@openuidev/react-headless"; import { FullScreen } from "@openuidev/react-ui"; -import { artifactDemoLibrary } from "@/library"; export default function Page() { const mode = useTheme(); @@ -31,23 +31,19 @@ export default function Page() { options: [ { displayText: "React login form", - prompt: - "Build me a React login form with email and password validation", + prompt: "Build me a React login form with email and password validation", }, { displayText: "Python REST API", - prompt: - "Create a FastAPI REST API with CRUD endpoints for a todo app", + prompt: "Create a FastAPI REST API with CRUD endpoints for a todo app", }, { displayText: "CSS animation", - prompt: - "Write a CSS animation for a bouncing loading indicator", + prompt: "Write a CSS animation for a bouncing loading indicator", }, { displayText: "SQL schema", - prompt: - "Design a SQL schema for a blog with users, posts, and comments", + prompt: "Design a SQL schema for a blog with users, posts, and comments", }, ], }} diff --git a/examples/openui-chat/src/app/page.tsx b/examples/openui-chat/src/app/page.tsx index 29cc3daf4..87253bb38 100644 --- a/examples/openui-chat/src/app/page.tsx +++ b/examples/openui-chat/src/app/page.tsx @@ -1,5 +1,5 @@ "use client"; -import "@openuidev/react-ui/components.css"; +import "@openuidev/react-ui/layered-components.css"; import { useTheme } from "@/hooks/use-system-theme"; import { openAIAdapter, openAIMessageFormat } from "@openuidev/react-headless"; diff --git a/examples/openui-dashboard/src/components/OpenUIDashboard/index.tsx b/examples/openui-dashboard/src/components/OpenUIDashboard/index.tsx index 1fc4de287..2a0588d92 100644 --- a/examples/openui-dashboard/src/components/OpenUIDashboard/index.tsx +++ b/examples/openui-dashboard/src/components/OpenUIDashboard/index.tsx @@ -2,7 +2,7 @@ import type { Starter } from "@/starters"; import type { Library } from "@openuidev/react-lang"; -import "@openuidev/react-ui/components.css"; +import "@openuidev/react-ui/layered-components.css"; import { useRef, useState } from "react"; import { DashboardProvider, useDashboard } from "./context"; import { ConversationPanel } from "./ConversationPanel"; diff --git a/examples/shadcn-chat/src/app/page.tsx b/examples/shadcn-chat/src/app/page.tsx index a59a3d9ee..ccc91e26c 100644 --- a/examples/shadcn-chat/src/app/page.tsx +++ b/examples/shadcn-chat/src/app/page.tsx @@ -1,5 +1,5 @@ "use client"; -import "@openuidev/react-ui/components.css"; +import "@openuidev/react-ui/layered-components.css"; import { useTheme } from "@/hooks/use-system-theme"; import { shadcnChatLibrary } from "@/lib/shadcn-genui"; diff --git a/examples/supabase-chat/src/app/page.tsx b/examples/supabase-chat/src/app/page.tsx index 7f503c18c..ad49df06a 100644 --- a/examples/supabase-chat/src/app/page.tsx +++ b/examples/supabase-chat/src/app/page.tsx @@ -1,6 +1,6 @@ "use client"; -import "@openuidev/react-ui/components.css"; +import "@openuidev/react-ui/layered-components.css"; import { openAIAdapter, openAIMessageFormat } from "@openuidev/react-headless"; import { FullScreen } from "@openuidev/react-ui"; diff --git a/examples/vercel-ai-chat/src/app/globals.css b/examples/vercel-ai-chat/src/app/globals.css index c45470e58..7e1cd2a6f 100644 --- a/examples/vercel-ai-chat/src/app/globals.css +++ b/examples/vercel-ai-chat/src/app/globals.css @@ -1,3 +1,3 @@ @layer theme, base, openui, components, utilities; @import "tailwindcss"; -@import "@openuidev/react-ui/components.css"; +@import "@openuidev/react-ui/layered-components.css"; diff --git a/examples/vercel-ai-chat/src/app/page.tsx b/examples/vercel-ai-chat/src/app/page.tsx index b08b0a679..b64c52090 100644 --- a/examples/vercel-ai-chat/src/app/page.tsx +++ b/examples/vercel-ai-chat/src/app/page.tsx @@ -1,18 +1,16 @@ "use client"; -import "@openuidev/react-ui/components.css"; - -import { useChat } from "@ai-sdk/react"; -import { useRef, useEffect, useState } from "react"; -import { useTheme } from "@/hooks/use-system-theme"; -import { useThreads } from "@/hooks/use-threads"; +import { AssistantMessage } from "@/components/assistant-message"; import { ChatHeader } from "@/components/chat-header"; import { ChatInput } from "@/components/chat-input"; import { ConversationStarters } from "@/components/conversation-starters"; -import { AssistantMessage } from "@/components/assistant-message"; -import { UserMessage } from "@/components/user-message"; -import { ThinkingIndicator } from "@/components/thinking-indicator"; import { Sidebar } from "@/components/sidebar"; +import { ThinkingIndicator } from "@/components/thinking-indicator"; +import { UserMessage } from "@/components/user-message"; +import { useTheme } from "@/hooks/use-system-theme"; +import { useThreads } from "@/hooks/use-threads"; +import { useChat } from "@ai-sdk/react"; +import { useEffect, useRef, useState } from "react"; export default function Page() { useTheme(); @@ -78,10 +76,7 @@ export default function Page() { sidebarOpen ? "md:ml-[280px]" : "ml-0" }`} > - setSidebarOpen((o) => !o)} - /> + setSidebarOpen((o) => !o)} />
{isEmpty ? ( diff --git a/packages/react-ui/README.md b/packages/react-ui/README.md index 2f3044661..655514018 100644 --- a/packages/react-ui/README.md +++ b/packages/react-ui/README.md @@ -141,34 +141,45 @@ function App() { ## Styling integration -OpenUI's component styles live inside a CSS cascade layer named `openui`. Any unlayered consumer CSS overrides OpenUI without `!important` or specificity matching: +OpenUI ships its component styles in two variants: + +| Import | Cascade behavior | +| --- | --- | +| `@openuidev/react-ui/components.css` (default) | Unlayered — override via normal CSS specificity, as in 0.11.x and earlier | +| `@openuidev/react-ui/layered-components.css` (opt-in) | Wrapped in `@layer openui` — any unlayered consumer CSS wins | + +Per-component granular imports follow the same split: `./styles/*` (unlayered) and `./layered/styles/*` (layered). + +With the layered variant, plain CSS overrides OpenUI without `!important` or specificity matching: ```css -@import "@openuidev/react-ui/components.css"; +@import "@openuidev/react-ui/layered-components.css"; /* Wins, no specificity tricks needed */ .openui-button-base-primary { background: hotpink; } ``` -### With Tailwind v4 +### With Tailwind v4 (layered variant) Declare layer order at the top of your entry stylesheet so `openui` sits above Tailwind's reset but below `components` and `utilities`: ```css @layer theme, base, openui, components, utilities; -@import "@openuidev/react-ui/components.css"; @import "tailwindcss"; +@import "@openuidev/react-ui/layered-components.css"; ``` This places Tailwind's Preflight (in `base`) below OpenUI components so its element resets don't override them, while keeping utilities (`bg-red-500`, etc.) winning over OpenUI styles. -### With Tailwind v3, CSS Modules, or CSS-in-JS +### Rules for the layered variant -No configuration needed — these all emit unlayered CSS, which automatically beats anything in `@layer openui`. +- Import OpenUI CSS from **exactly one place** — multiple import sites under chunk-splitting bundlers (e.g. Turbopack) can register `openui` before your layer-order statement and lock the wrong order. +- Wrap app-wide resets in a layer below `openui` (e.g. `@layer base { * { margin: 0; } }`) — unlayered resets beat all layered styles regardless of specificity. +- `./defaults.css` and the `ThemeProvider` runtime style injection stay unlayered in both modes so runtime theming always overrides component defaults. ### Browser support -CSS cascade layers require Chrome 99+, Firefox 97+, Safari 15.4+, or Edge 99+ (all baseline from March 2022). On older browsers, the `@layer { ... }` block is dropped entirely and components render unstyled. The package declares this floor via the `browserslist` field in its `package.json`. +The layered variant requires CSS cascade layers: Chrome 99+, Firefox 97+, Safari 15.4+, Edge 99+ (all baseline from March 2022). On older browsers the `@layer { ... }` block is dropped entirely and components render unstyled. The default unlayered styles have no such floor. ## Components @@ -200,9 +211,11 @@ import { Charts } from "@openuidev/react-ui/Charts"; | :--- | :--- | | `@openuidev/react-ui` | All components and libraries | | `@openuidev/react-ui/components.css` | Compiled component styles | +| `@openuidev/react-ui/layered-components.css` | Opt-in aggregate stylesheet wrapped in `@layer openui` | +| `@openuidev/react-ui/defaults.css` | Theme tokens, always unlayered | | `@openuidev/react-ui/genui-lib` | OpenUI Lang libraries and prompt options | -| `@openuidev/react-ui/tailwind` | Tailwind CSS plugin | -| `@openuidev/react-ui/styles/*` | SCSS utilities | +| `@openuidev/react-ui/styles/*` | Per-component compiled styles (unlayered) | +| `@openuidev/react-ui/layered/styles/*` | Per-component styles wrapped in `@layer openui` | | `@openuidev/react-ui/scssUtils` | SCSS utility functions | | `@openuidev/react-ui/` | Per-component entry points | diff --git a/packages/react-ui/check-css-artifacts.js b/packages/react-ui/check-css-artifacts.js new file mode 100644 index 000000000..1f972fb88 --- /dev/null +++ b/packages/react-ui/check-css-artifacts.js @@ -0,0 +1,74 @@ +// Pre-publish guard for the CSS artifact contract: +// - default exports stay UNLAYERED (./components.css, ./styles/*) +// - the layered mirror is wrapped in @layer openui and BOM-free +// - openui-defaults.css is unlayered in both trees (runtime theming contract) +// Born out of the 2026-06 BOM incident: a U+FEFF pushed inside the layer +// block silently killed the :root theme tokens in the packed tarball. +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const dirname = path.dirname(fileURLToPath(import.meta.url)); +const dist = path.join(dirname, "dist"); +const failures = []; + +const read = (rel) => fs.readFileSync(path.join(dist, rel), "utf8"); +const assert = (cond, msg) => { + if (!cond) failures.push(msg); +}; + +assert( + !/^\s*@layer/.test(read("components/index.css")), + "components/index.css must stay unlayered", +); +assert(!/^\s*@layer/.test(read("styles/index.css")), "styles/index.css must stay unlayered"); +assert( + read("layered/components/index.css").startsWith("@layer openui{"), + "layered/components/index.css must start with @layer openui{", +); +assert( + !/^\s*@layer/.test(read("styles/openui-defaults.css")), + "styles/openui-defaults.css must stay unlayered", +); +assert( + !/^\s*@layer/.test(read("layered/styles/openui-defaults.css")), + "layered/styles/openui-defaults.css must stay unlayered", +); + +const unlayered = fs.readdirSync(path.join(dist, "styles")).filter((f) => f.endsWith(".css")); +const layered = fs + .readdirSync(path.join(dist, "layered", "styles")) + .filter((f) => f.endsWith(".css")); +assert( + unlayered.length === layered.length, + `layered mirror has ${layered.length} css files, unlayered has ${unlayered.length}`, +); + +// Every per-component DEFAULT style must stay unlayered too — not just the +// index files checked above. Guards against a regression that re-wraps +// dist/styles/*.css in place (the `wrapComponentCssInPlace` behavior this +// contract intentionally removed); since consumers can import individual +// ./styles/.css, an index-only check would miss it. +// openui-defaults.css is asserted unlayered separately above. +for (const name of unlayered) { + if (name === "openui-defaults.css") continue; + assert(!/^\s*@layer/.test(read(path.join("styles", name))), `styles/${name} must stay unlayered`); +} + +for (const f of [ + ...layered.map((n) => path.join("layered", "styles", n)), + "layered/components/index.css", +]) { + const content = read(f); + assert(!content.includes("\uFEFF"), `${f} contains a BOM`); + const base = path.basename(f); + if (base !== "openui-defaults.css" && content.trim() !== "") { + assert(content.startsWith("@layer openui{"), `${f} is not wrapped in @layer openui`); + } +} + +if (failures.length > 0) { + console.error("CSS artifact check FAILED:\n - " + failures.join("\n - ")); + process.exit(1); +} +console.log(`CSS artifact check passed (${layered.length} layered files verified).`); diff --git a/packages/react-ui/cp-css.js b/packages/react-ui/cp-css.js index dc8f9b78a..2c645f860 100644 --- a/packages/react-ui/cp-css.js +++ b/packages/react-ui/cp-css.js @@ -2,6 +2,7 @@ import fs from "fs"; import { camelCase } from "lodash-es"; import path from "path"; import { fileURLToPath } from "url"; +import { mirrorStylesWithLayer, writeLayeredCopy } from "./css-layer-utils.mjs"; const dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -12,29 +13,6 @@ function ensureDirectoryExists(dirPath) { } } -// Wrap a CSS file's contents in @layer openui { ... } if not already wrapped. -// Idempotency check protects watch-mode and back-to-back builds. -function wrapInLayer(content) { - if (content.trim() === "") return content; - if (/^\s*@layer\s+openui\b/.test(content)) return content; - return `@layer openui{${content}}`; -} - -// Walk dist/components and wrap every emitted .css file in @layer openui. -// *.module.css are Storybook CSS Modules — locally scoped, not shipped, not wrapped. -function wrapComponentCssInPlace(dir) { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - wrapComponentCssInPlace(full); - } else if (entry.name.endsWith(".css") && !entry.name.endsWith(".module.css")) { - const content = fs.readFileSync(full, "utf8"); - const wrapped = wrapInLayer(content); - if (wrapped !== content) fs.writeFileSync(full, wrapped, "utf8"); - } - } -} - // Replace .scss imports with .css imports in compiled JS files function fixScssImportsInJs(dir) { const entries = fs.readdirSync(dir); @@ -60,12 +38,6 @@ function copyCssFiles() { const srcDir = path.join(dirname, "dist", "components"); const distDir = path.join(dirname, "dist", "styles"); - // Wrap every emitted component CSS in @layer openui before copying. - // dist/openui-defaults.css lives outside dist/components and stays unwrapped - // so the defaults.css export remains in the unlayered cascade — matching the - // ThemeProvider runtime injection contract. - wrapComponentCssInPlace(srcDir); - // Ensure the dist/styles directory exists ensureDirectoryExists(distDir); @@ -101,6 +73,15 @@ function copyCssFiles() { fs.copyFileSync(defaultsCssPath, path.join(distDir, "openui-defaults.css")); } + // Emit the opt-in layered mirror (./layered-components.css and + // ./layered/styles/*). The default exports above stay unlayered — see + // README "Styling integration". + writeLayeredCopy( + path.join(srcDir, "index.css"), + path.join(dirname, "dist", "layered", "components", "index.css"), + ); + mirrorStylesWithLayer(distDir, path.join(dirname, "dist", "layered", "styles")); + // Fix .scss imports in compiled JS to point to .css files instead fixScssImportsInJs(path.join(dirname, "dist")); } diff --git a/packages/react-ui/css-layer-utils.mjs b/packages/react-ui/css-layer-utils.mjs new file mode 100644 index 000000000..a4799c68d --- /dev/null +++ b/packages/react-ui/css-layer-utils.mjs @@ -0,0 +1,40 @@ +import fs from "fs"; +import path from "path"; + +// Wrap a CSS file's contents in @layer openui { ... } if not already wrapped. +// Idempotency check protects watch-mode and back-to-back builds. +export function wrapInLayer(content) { + // Sass emits a UTF-8 BOM for files with non-ASCII output. At byte 0 the + // decoder strips it, but wrapping would push it inside the layer block, + // where U+FEFF parses as an identifier and kills the first rule + // (e.g. the :root theme tokens). Strip it before wrapping. + content = content.replace(/^\uFEFF/, ""); + if (content.trim() === "") return content; + if (/^\s*@layer\s+openui\b/.test(content)) return content; + return `@layer openui{${content}}`; +} + +// Write a layered copy of srcFile at destFile, creating parent directories. +export function writeLayeredCopy(srcFile, destFile) { + const content = fs.readFileSync(srcFile, "utf8"); + fs.mkdirSync(path.dirname(destFile), { recursive: true }); + fs.writeFileSync(destFile, wrapInLayer(content), "utf8"); +} + +// Mirror every top-level *.css file in srcDir into destDir wrapped in +// @layer openui. Files named in `unwrapped` are copied verbatim — they must +// stay in the unlayered cascade (openui-defaults.css backs the runtime +// theming override contract). Non-CSS files (e.g. cssUtils.scss) are skipped. +export function mirrorStylesWithLayer(srcDir, destDir, unwrapped = ["openui-defaults.css"]) { + fs.mkdirSync(destDir, { recursive: true }); + for (const name of fs.readdirSync(srcDir)) { + if (!name.endsWith(".css")) continue; + const src = path.join(srcDir, name); + if (!fs.statSync(src).isFile()) continue; + if (unwrapped.includes(name)) { + fs.copyFileSync(src, path.join(destDir, name)); + } else { + writeLayeredCopy(src, path.join(destDir, name)); + } + } +} diff --git a/packages/react-ui/css-layer-utils.test.mjs b/packages/react-ui/css-layer-utils.test.mjs new file mode 100644 index 000000000..fa4e4be63 --- /dev/null +++ b/packages/react-ui/css-layer-utils.test.mjs @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { mirrorStylesWithLayer, wrapInLayer, writeLayeredCopy } from "./css-layer-utils.mjs"; + +describe("wrapInLayer", () => { + it("wraps plain css in @layer openui", () => { + expect(wrapInLayer(".a{color:red}")).toBe("@layer openui{.a{color:red}}"); + }); + + it("strips a leading BOM before wrapping so the first rule stays valid", () => { + // U+FEFF inside a layer block parses as an identifier and kills the + // first rule (e.g. the :root theme tokens) — the 2026-06 BOM incident. + expect(wrapInLayer("\uFEFF:root{--x:1}")).toBe("@layer openui{:root{--x:1}}"); + }); + + it("is idempotent", () => { + const once = wrapInLayer(".a{color:red}"); + expect(wrapInLayer(once)).toBe(once); + }); + + it("leaves empty/whitespace-only content untouched", () => { + expect(wrapInLayer("")).toBe(""); + expect(wrapInLayer(" \n")).toBe(" \n"); + }); +}); + +describe("writeLayeredCopy", () => { + it("writes a wrapped copy, creating parent directories", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "css-layer-")); + const src = path.join(dir, "in.css"); + const dest = path.join(dir, "nested", "out.css"); + fs.writeFileSync(src, ".a{color:red}"); + writeLayeredCopy(src, dest); + expect(fs.readFileSync(dest, "utf8")).toBe("@layer openui{.a{color:red}}"); + }); +}); + +describe("mirrorStylesWithLayer", () => { + it("wraps css files, copies unwrapped names verbatim, skips non-css", () => { + const src = fs.mkdtempSync(path.join(os.tmpdir(), "css-layer-src-")); + const dest = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "css-layer-dest-")), "layered"); + fs.writeFileSync(path.join(src, "button.css"), ".b{color:red}"); + fs.writeFileSync(path.join(src, "openui-defaults.css"), ":root{--x:1}"); + fs.writeFileSync(path.join(src, "cssUtils.scss"), "$x: 1;"); + mirrorStylesWithLayer(src, dest); + expect(fs.readFileSync(path.join(dest, "button.css"), "utf8")).toBe("@layer openui{.b{color:red}}"); + expect(fs.readFileSync(path.join(dest, "openui-defaults.css"), "utf8")).toBe(":root{--x:1}"); + expect(fs.existsSync(path.join(dest, "cssUtils.scss"))).toBe(false); + }); +}); diff --git a/packages/react-ui/package.json b/packages/react-ui/package.json index 9c3889a47..937012550 100644 --- a/packages/react-ui/package.json +++ b/packages/react-ui/package.json @@ -21,12 +21,18 @@ "./components.css": { "default": "./dist/components/index.css" }, + "./layered-components.css": { + "default": "./dist/layered/components/index.css" + }, "./defaults.css": { "default": "./dist/styles/openui-defaults.css" }, "./styles/*": { "default": "./dist/styles/*" }, + "./layered/styles/*": { + "default": "./dist/layered/styles/*" + }, "./genui-lib": { "import": { "types": "./dist/genui-lib/index.d.mts", @@ -60,11 +66,11 @@ "README.md" ], "scripts": { - "test": "vitest run --passWithNoTests", + "test": "vitest run", "copy-css": "node cp-css.js", "generate-scss-index": "node src/scripts/scss-import.js", "generate:css-utils": "tsx src/scripts/generate-css-utils.ts", - "build": "rimraf dist && pnpm generate:css-utils && pnpm build:scss && pnpm build:tsc && pnpm build:cjs && pnpm run copy-css", + "build": "rimraf dist && pnpm generate:css-utils && pnpm build:scss && pnpm build:tsc && pnpm build:cjs && pnpm run copy-css && pnpm run check:css", "typecheck": "tsc --noEmit", "build:tsc": "tsc -p . || node -e \"process.exit(0)\"", "build:cjs": "tsdown", @@ -76,11 +82,12 @@ "lint:fix": "eslint ./src --fix", "format:fix": "prettier --write ./src", "format:check": "prettier --check ./src", + "check:css": "node check-css-artifacts.js", "check:publint": "publint", "check:attw": "attw --pack . --ignore-rules no-resolution", "prepare": "pnpm run build", - "prepublishOnly": "pnpm run check:publint && pnpm run check:attw", - "ci": "pnpm run lint:check && pnpm run format:check" + "prepublishOnly": "pnpm run check:css && pnpm run check:publint && pnpm run check:attw", + "ci": "pnpm run lint:check && pnpm run format:check && pnpm run test" }, "peerDependencies": { "@openuidev/react-headless": "workspace:^", @@ -196,7 +203,6 @@ "bugs": { "url": "https://github.com/thesysdev/openui/issues" }, - "browserslist": "defaults and supports css-cascade-layers", "eslintConfig": { "extends": [ "plugin:storybook/recommended" diff --git a/packages/react-ui/src/components/ThemeProvider/ThemeProvider.tsx b/packages/react-ui/src/components/ThemeProvider/ThemeProvider.tsx index 424fab01a..d9e54675d 100644 --- a/packages/react-ui/src/components/ThemeProvider/ThemeProvider.tsx +++ b/packages/react-ui/src/components/ThemeProvider/ThemeProvider.tsx @@ -236,8 +236,9 @@ export const ThemeProvider = ({ const useAutoScope = isNested && !hasExplicitSelector; const styleSelector = useAutoScope ? `.${scopedClassName}` : effectiveCssSelector; - // Intentionally unlayered — must override @layer openui so runtime theme - // switching takes effect. See README "Styling integration" before changing. + // Intentionally unlayered — must override component styles in both modes, + // including when consumers opt into layered-components.css (@layer openui), + // so runtime theming always wins. See README "Styling integration" before changing. useInsertionEffect(() => { const style = document.createElement("style"); style.setAttribute("data-openui-theme", id);