Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions packages/react-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,21 @@ This places Tailwind's Preflight (in `base`) below OpenUI components so its elem
- 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.

### Single-scheme apps

The default token stylesheets follow the device's `prefers-color-scheme`: dark tokens only apply on dark-scheme devices. An app that forces one scheme (`<ThemeProvider mode="dark">`) would otherwise render the stock light theme on light-scheme devices wherever the runtime style injection isn't in effect — static first paint, server-rendered HTML before hydration, or with JavaScript disabled.

To pin the scheme statically, import the matching scheme-pinned defaults after the component styles (and before your own token overrides, which win by source order):

```css
@layer theme, base, openui, components, utilities;
@import "tailwindcss";
@import "@openuidev/react-ui/layered/styles/index.css";
@import "@openuidev/react-ui/defaults-dark.css"; /* unlayered — pins dark statically */
```

Light-only apps use `@openuidev/react-ui/defaults-light.css` the same way. Both files are plain unlayered `:root` blocks with the full token set and no media query, in both the default and layered trees.

### Browser support

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.
Expand Down Expand Up @@ -218,6 +233,8 @@ import { Charts } from "@openuidev/react-ui/Charts";
| `@openuidev/react-ui/styles/index.css` | Full compiled stylesheet, unlayered (default import) |
| `@openuidev/react-ui/layered/styles/index.css` | Full stylesheet wrapped in `@layer openui` (opt-in) |
| `@openuidev/react-ui/defaults.css` | Theme tokens, always unlayered |
| `@openuidev/react-ui/defaults-dark.css` | Dark tokens pinned, no media query, unlayered |
| `@openuidev/react-ui/defaults-light.css` | Light tokens pinned, no media query, unlayered |
| `@openuidev/react-ui/genui-lib` | OpenUI Lang libraries and prompt options |
| `@openuidev/react-ui/styles/*` | Per-component compiled styles (unlayered) |
| `@openuidev/react-ui/layered/styles/*` | Per-component styles wrapped in `@layer openui` |
Expand Down
35 changes: 28 additions & 7 deletions packages/react-ui/check-css-artifacts.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { DEFAULTS_CSS_FILES } from "./css-layer-utils.mjs";

const dirname = path.dirname(fileURLToPath(import.meta.url));
const dist = path.join(dirname, "dist");
Expand All @@ -26,13 +27,33 @@ assert(
read("layered/components/index.css").startsWith("@layer openui{"),
"layered/components/index.css must start with @layer openui{",
);
for (const name of DEFAULTS_CSS_FILES) {
assert(!/^\s*@layer/.test(read(`styles/${name}`)), `styles/${name} must stay unlayered`);
assert(
!/^\s*@layer/.test(read(`layered/styles/${name}`)),
`layered/styles/${name} must stay unlayered`,
);
}

// The scheme-pinned defaults exist to pin one scheme statically — a
// prefers-color-scheme gate (or any media query) sneaking back in silently
// recreates the wrong-scheme fallback they were added to fix. Token-count
// parity guards against one scheme's set drifting from the other, and against
// the combined file diverging from the pinned pair.
const countTokens = (css) => (css.match(/--openui-/g) || []).length;
const darkPinned = read("styles/openui-defaults-dark.css");
const lightPinned = read("styles/openui-defaults-light.css");
assert(!darkPinned.includes("@media"), "openui-defaults-dark.css must not contain a media query");
assert(!lightPinned.includes("@media"), "openui-defaults-light.css must not contain a media query");
assert(countTokens(darkPinned) > 0, "openui-defaults-dark.css has no --openui- tokens");
assert(
!/^\s*@layer/.test(read("styles/openui-defaults.css")),
"styles/openui-defaults.css must stay unlayered",
countTokens(darkPinned) === countTokens(lightPinned),
"dark and light pinned defaults declare different token counts",
);
assert(
!/^\s*@layer/.test(read("layered/styles/openui-defaults.css")),
"layered/styles/openui-defaults.css must stay unlayered",
countTokens(darkPinned) + countTokens(lightPinned) ===
countTokens(read("styles/openui-defaults.css")),
"pinned defaults token counts do not add up to openui-defaults.css",
);

const unlayered = fs.readdirSync(path.join(dist, "styles")).filter((f) => f.endsWith(".css"));
Expand All @@ -49,9 +70,9 @@ assert(
// dist/styles/*.css in place (the `wrapComponentCssInPlace` behavior this
// contract intentionally removed); since consumers can import individual
// ./styles/<component>.css, an index-only check would miss it.
// openui-defaults.css is asserted unlayered separately above.
// The defaults files are asserted unlayered separately above.
for (const name of unlayered) {
if (name === "openui-defaults.css") continue;
if (DEFAULTS_CSS_FILES.includes(name)) continue;
assert(!/^\s*@layer/.test(read(path.join("styles", name))), `styles/${name} must stay unlayered`);
}

Expand All @@ -70,7 +91,7 @@ for (const f of [
const content = read(f);
assert(!content.includes("\uFEFF"), `${f} contains a BOM`);
const base = path.basename(f);
if (base !== "openui-defaults.css" && content.trim() !== "") {
if (!DEFAULTS_CSS_FILES.includes(base) && content.trim() !== "") {
assert(content.startsWith("@layer openui{"), `${f} is not wrapped in @layer openui`);
}
}
Expand Down
27 changes: 18 additions & 9 deletions packages/react-ui/cp-css.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import fs from "fs";
import { camelCase } from "lodash-es";
import path from "path";
import { fileURLToPath } from "url";
import { mirrorStylesWithLayer, stripBom, writeLayeredCopy } from "./css-layer-utils.mjs";
import {
DEFAULTS_CSS_FILES,
mirrorStylesWithLayer,
stripBom,
writeLayeredCopy,
} from "./css-layer-utils.mjs";

const dirname = path.dirname(fileURLToPath(import.meta.url));

Expand Down Expand Up @@ -61,11 +66,13 @@ function copyCssFiles() {
// Strip Sass's leading BOM from the unlayered sass output before copying, so
// the default exports (./components.css, ./styles/*) ship BOM-free.
stripBomFromCssInDir(srcDir);
const defaultsCssSrc = path.join(dirname, "dist", "openui-defaults.css");
if (fs.existsSync(defaultsCssSrc)) {
const content = fs.readFileSync(defaultsCssSrc, "utf8");
const stripped = stripBom(content);
if (stripped !== content) fs.writeFileSync(defaultsCssSrc, stripped, "utf8");
for (const name of DEFAULTS_CSS_FILES) {
const defaultsCssSrc = path.join(dirname, "dist", name);
if (fs.existsSync(defaultsCssSrc)) {
const content = fs.readFileSync(defaultsCssSrc, "utf8");
const stripped = stripBom(content);
if (stripped !== content) fs.writeFileSync(defaultsCssSrc, stripped, "utf8");
}
}

// Read all component directories
Expand Down Expand Up @@ -95,9 +102,11 @@ function copyCssFiles() {
const cssUtilsSrc = fs.readFileSync(path.join(dirname, "src", "cssUtils.scss"), "utf8");
fs.writeFileSync(path.join(distDir, "cssUtils.scss"), cssUtilsSrc);

const defaultsCssPath = path.join(dirname, "dist", "openui-defaults.css");
if (fs.existsSync(defaultsCssPath)) {
fs.copyFileSync(defaultsCssPath, path.join(distDir, "openui-defaults.css"));
for (const name of DEFAULTS_CSS_FILES) {
const defaultsCssPath = path.join(dirname, "dist", name);
if (fs.existsSync(defaultsCssPath)) {
fs.copyFileSync(defaultsCssPath, path.join(distDir, name));
}
}

// Emit the opt-in layered mirror (./layered-components.css and
Expand Down
13 changes: 12 additions & 1 deletion packages/react-ui/css-layer-utils.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,22 @@ export function writeLayeredCopy(srcFile, destFile) {
fs.writeFileSync(destFile, wrapInLayer(content), "utf8");
}

// The generated theme-token defaults. All three must stay in the unlayered
// cascade in both style trees: openui-defaults.css backs the runtime theming
// override contract, and the scheme-pinned variants (./defaults-dark.css,
// ./defaults-light.css) exist precisely to beat the layered, media-gated
// tokens for single-scheme apps.
export const DEFAULTS_CSS_FILES = [
"openui-defaults.css",
"openui-defaults-dark.css",
"openui-defaults-light.css",
];

// 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"]) {
export function mirrorStylesWithLayer(srcDir, destDir, unwrapped = DEFAULTS_CSS_FILES) {
fs.mkdirSync(destDir, { recursive: true });
for (const name of fs.readdirSync(srcDir)) {
if (!name.endsWith(".css")) continue;
Expand Down
14 changes: 14 additions & 0 deletions packages/react-ui/css-layer-utils.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,18 @@ describe("mirrorStylesWithLayer", () => {
expect(fs.readFileSync(path.join(dest, "openui-defaults.css"), "utf8")).toBe(":root{--x:1}");
expect(fs.existsSync(path.join(dest, "cssUtils.scss"))).toBe(false);
});

it("copies the scheme-pinned defaults verbatim by default", () => {
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, "openui-defaults-dark.css"), ":root{--x:dark}");
fs.writeFileSync(path.join(src, "openui-defaults-light.css"), ":root{--x:light}");
mirrorStylesWithLayer(src, dest);
expect(fs.readFileSync(path.join(dest, "openui-defaults-dark.css"), "utf8")).toBe(
":root{--x:dark}",
);
expect(fs.readFileSync(path.join(dest, "openui-defaults-light.css"), "utf8")).toBe(
":root{--x:light}",
);
});
});
6 changes: 6 additions & 0 deletions packages/react-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@
"./defaults.css": {
"default": "./dist/styles/openui-defaults.css"
},
"./defaults-dark.css": {
"default": "./dist/styles/openui-defaults-dark.css"
},
"./defaults-light.css": {
"default": "./dist/styles/openui-defaults-light.css"
},
"./styles/*": {
"default": "./dist/styles/*"
},
Expand Down
Loading
Loading