From ec0894a4b89cba8406a16e7915e893deb2ff649b Mon Sep 17 00:00:00 2001 From: ankit-thesys Date: Wed, 8 Jul 2026 14:29:07 +0530 Subject: [PATCH] feat(react-ui): add scheme-pinned defaults-dark.css and defaults-light.css exports Every dark token the package ships is gated behind @media (prefers-color-scheme: dark), so an app forcing one scheme () renders the stock light theme on light-scheme devices wherever the runtime style injection is not in effect: static first paint, server-rendered HTML before hydration, hydration failures, and no-JS. A downstream dark-only consumer hit this in production and now scrapes the dark token block out of the dist CSS on every upgrade. Emit plain unlayered :root blocks for each scheme from the same generator that produces openui-defaults.css, ship them as ./defaults-dark.css and ./defaults-light.css (unlayered in both style trees, like ./defaults.css), and guard them in check:css: no media query may sneak back in, and token counts must stay in parity with the combined defaults file. Fixes #736 Linear: TH-2215 Co-Authored-By: Claude Fable 5 --- packages/react-ui/README.md | 17 ++ packages/react-ui/check-css-artifacts.js | 35 ++- packages/react-ui/cp-css.js | 27 +- packages/react-ui/css-layer-utils.mjs | 13 +- packages/react-ui/css-layer-utils.test.mjs | 14 + packages/react-ui/package.json | 6 + .../react-ui/src/openui-defaults-dark.scss | 243 ++++++++++++++++++ .../react-ui/src/openui-defaults-light.scss | 243 ++++++++++++++++++ .../src/scripts/generate-css-utils.ts | 36 +++ 9 files changed, 617 insertions(+), 17 deletions(-) create mode 100644 packages/react-ui/src/openui-defaults-dark.scss create mode 100644 packages/react-ui/src/openui-defaults-light.scss diff --git a/packages/react-ui/README.md b/packages/react-ui/README.md index 544875ec7..e7ea5b7bf 100644 --- a/packages/react-ui/README.md +++ b/packages/react-ui/README.md @@ -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 (``) 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. @@ -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` | diff --git a/packages/react-ui/check-css-artifacts.js b/packages/react-ui/check-css-artifacts.js index dfa0001db..22ce7e207 100644 --- a/packages/react-ui/check-css-artifacts.js +++ b/packages/react-ui/check-css-artifacts.js @@ -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"); @@ -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")); @@ -49,9 +70,9 @@ assert( // 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. +// 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`); } @@ -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`); } } diff --git a/packages/react-ui/cp-css.js b/packages/react-ui/cp-css.js index 321559c75..12f44ebfc 100644 --- a/packages/react-ui/cp-css.js +++ b/packages/react-ui/cp-css.js @@ -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)); @@ -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 @@ -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 diff --git a/packages/react-ui/css-layer-utils.mjs b/packages/react-ui/css-layer-utils.mjs index fa4acd912..96da83ea8 100644 --- a/packages/react-ui/css-layer-utils.mjs +++ b/packages/react-ui/css-layer-utils.mjs @@ -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; diff --git a/packages/react-ui/css-layer-utils.test.mjs b/packages/react-ui/css-layer-utils.test.mjs index fa4e4be63..fc61d4955 100644 --- a/packages/react-ui/css-layer-utils.test.mjs +++ b/packages/react-ui/css-layer-utils.test.mjs @@ -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}", + ); + }); }); diff --git a/packages/react-ui/package.json b/packages/react-ui/package.json index 6e77a454f..6b4df942d 100644 --- a/packages/react-ui/package.json +++ b/packages/react-ui/package.json @@ -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/*" }, diff --git a/packages/react-ui/src/openui-defaults-dark.scss b/packages/react-ui/src/openui-defaults-dark.scss new file mode 100644 index 000000000..48d910cd2 --- /dev/null +++ b/packages/react-ui/src/openui-defaults-dark.scss @@ -0,0 +1,243 @@ +// Auto-generated by src/scripts/generate-css-utils.ts — do not edit manually. + +:root { + // Surface / Background Colors + + --openui-background: oklch(0.145 0 0 / 1); + --openui-foreground: oklch(0.205 0 0 / 1); + --openui-popover-background: oklch(0.205 0 0 / 1); + --openui-sunk-light: oklch(0.994 0 89.876 / 0.02); + --openui-sunk: oklch(0.994 0 89.876 / 0.04); + --openui-sunk-deep: oklch(0.994 0 89.876 / 0.08); + --openui-elevated-light: oklch(0.994 0 89.876 / 0.04); + --openui-elevated: oklch(0.994 0 89.876 / 0.08); + --openui-elevated-strong: oklch(0.994 0 89.876 / 0.16); + --openui-elevated-intense: oklch(0.994 0 89.876 / 0.32); + --openui-overlay: oklch(0 0 0 / 0.6); + --openui-highlight-subtle: oklch(0.994 0 89.876 / 0.02); + --openui-highlight: oklch(0.994 0 89.876 / 0.04); + --openui-highlight-strong: oklch(0.994 0 89.876 / 0.08); + --openui-highlight-intense: oklch(0.994 0 89.876 / 0.3); + --openui-inverted-background: oklch(0.994 0 89.876 / 1); + --openui-info-background: oklch(0.623 0.188 259.815 / 0.12); + --openui-success-background: oklch(0.627 0.17 149.214 / 0.12); + --openui-alert-background: oklch(0.795 0.162 86.047 / 0.16); + --openui-danger-background: oklch(0.577 0.215 27.325 / 0.12); + --openui-purple-background: oklch(0.627 0.233 303.9 / 0.12); + --openui-pink-background: oklch(0.592 0.218 0.584 / 0.12); + + // Text Colors + + --openui-text-neutral-primary: oklch(0.985 0 89.876 / 1); + --openui-text-neutral-secondary: oklch(0.985 0 89.876 / 0.5); + --openui-text-neutral-tertiary: oklch(0.985 0 89.876 / 0.2); + --openui-text-neutral-link: oklch(0.985 0 89.876 / 1); + --openui-text-brand: oklch(0.994 0 89.876 / 1); + --openui-text-white: oklch(1 0 89.876 / 1); + --openui-text-black: oklch(0 0 0 / 1); + --openui-text-accent-primary: oklch(0.097 0 0 / 1); + --openui-text-accent-secondary: oklch(0.097 0 0 / 0.5); + --openui-text-accent-tertiary: oklch(0.097 0 0 / 0.2); + --openui-text-success-primary: oklch(0.871 0.136 154.449 / 1); + --openui-text-success-inverted: oklch(0.962 0.043 156.743 / 1); + --openui-text-alert-primary: oklch(0.905 0.166 98.111 / 1); + --openui-text-alert-inverted: oklch(0.973 0.069 103.193 / 1); + --openui-text-danger-primary: oklch(0.808 0.103 19.571 / 1); + --openui-text-danger-secondary: oklch(0.885 0.059 18.334 / 1); + --openui-text-danger-tertiary: oklch(0.936 0.031 17.717 / 1); + --openui-text-danger-inverted-primary: oklch(0.982 0.009 17.303 / 1); + --openui-text-danger-inverted-secondary: oklch(0.982 0.009 17.303 / 0.5); + --openui-text-danger-inverted-tertiary: oklch(0.982 0.009 17.303 / 0.3); + --openui-text-info-primary: oklch(0.809 0.096 251.813 / 1); + --openui-text-info-inverted: oklch(0.932 0.032 255.585 / 1); + --openui-text-pink-primary: oklch(0.823 0.11 346.018 / 1); + --openui-text-pink-inverted: oklch(0.948 0.028 342.258 / 1); + --openui-text-purple-primary: oklch(0.827 0.108 306.383 / 1); + --openui-text-purple-inverted: oklch(0.946 0.033 307.174 / 1); + + // Interactive Colors + + --openui-interactive-accent-default: oklch(0.994 0 89.876 / 1); + --openui-interactive-accent-hover: oklch(0.994 0 89.876 / 0.8); + --openui-interactive-accent-disabled: oklch(0.994 0 89.876 / 0.4); + --openui-interactive-accent-pressed: oklch(0.994 0 89.876 / 1); + --openui-interactive-destructive-default: oklch(0.577 0.215 27.325 / 0.02); + --openui-interactive-destructive-hover: oklch(0.577 0.215 27.325 / 0.08); + --openui-interactive-destructive-disabled: oklch(0.577 0.215 27.325 / 0.02); + --openui-interactive-destructive-pressed: oklch(0.577 0.215 27.325 / 0.1); + --openui-interactive-destructive-accent-default: oklch(0.577 0.215 27.325 / 1); + --openui-interactive-destructive-accent-hover: oklch(0.637 0.208 25.331 / 1); + --openui-interactive-destructive-accent-pressed: oklch(0.505 0.19 27.518 / 1); + --openui-interactive-destructive-accent-disabled: oklch(0.577 0.215 27.325 / 0.4); + + // Chat Colors + + --openui-chat-user-response-bg: oklch(0.994 0 89.876 / 0.08); + --openui-chat-user-response-text: oklch(0.985 0 89.876 / 1); + + // Border Colors + + --openui-border-default: oklch(0.994 0 89.876 / 0.06); + --openui-border-interactive: oklch(0.994 0 89.876 / 0.12); + --openui-border-interactive-emphasis: oklch(0.994 0 89.876 / 0.4); + --openui-border-interactive-selected: oklch(0.985 0 89.876 / 1); + --openui-border-accent: oklch(0.994 0 89.876 / 0.2); + --openui-border-accent-emphasis: oklch(0.994 0 89.876 / 0.4); + --openui-border-info: oklch(0.623 0.188 259.815 / 0.08); + --openui-border-info-emphasis: oklch(0.546 0.215 262.881 / 1); + --openui-border-alert: oklch(0.861 0.173 91.936 / 0.08); + --openui-border-alert-emphasis: oklch(0.681 0.142 75.834 / 1); + --openui-border-success: oklch(0.627 0.17 149.214 / 0.08); + --openui-border-success-emphasis: oklch(0.627 0.17 149.214 / 1); + --openui-border-danger: oklch(0.577 0.215 27.325 / 0.08); + --openui-border-danger-emphasis: oklch(0.577 0.215 27.325 / 1); + + // Spacing + + --openui-space-000: 0; + --openui-space-3xs: 2px; + --openui-space-2xs: 4px; + --openui-space-xs: 6px; + --openui-space-s: 8px; + --openui-space-s-m: 10px; + --openui-space-m: 12px; + --openui-space-m-l: 16px; + --openui-space-l: 18px; + --openui-space-xl: 24px; + --openui-space-2xl: 36px; + --openui-space-3xl: 48px; + + // Border Radius + + --openui-radius-none: 0; + --openui-radius-3xs: 1px; + --openui-radius-2xs: 2px; + --openui-radius-xs: 4px; + --openui-radius-s: 6px; + --openui-radius-m: 8px; + --openui-radius-l: 10px; + --openui-radius-xl: 12px; + --openui-radius-2xl: 14px; + --openui-radius-3xl: 16px; + --openui-radius-4xl: 20px; + --openui-radius-5xl: 24px; + --openui-radius-6xl: 28px; + --openui-radius-7xl: 32px; + --openui-radius-8xl: 40px; + --openui-radius-9xl: 48px; + --openui-radius-full: 9999px; + + // Typography Tokens + + --openui-font-body: "Inter", sans-serif; + --openui-font-code: "SFMono-Regular", Menlo, monospace; + --openui-font-heading: "Inter", sans-serif; + --openui-font-label: "Inter", sans-serif; + --openui-font-numbers: "Inter", sans-serif; + --openui-font-size-2xs: 10px; + --openui-font-size-xs: 12px; + --openui-font-size-sm: 14px; + --openui-font-size-md: 16px; + --openui-font-size-lg: 18px; + --openui-font-size-xl: 20px; + --openui-font-size-2xl: 24px; + --openui-font-size-3xl: 28px; + --openui-font-size-4xl: 32px; + --openui-font-size-5xl: 36px; + --openui-font-weight-regular: 400; + --openui-font-weight-medium: 500; + --openui-font-weight-bold: 600; + --openui-font-weight-heavy: 700; + --openui-line-height-body: 1.5; + --openui-line-height-heading: 1.25; + --openui-line-height-heading-large: 1.1; + --openui-line-height-label: 1.25; + --openui-line-height-code: 1.5; + --openui-letter-spacing-normal: 0; + --openui-letter-spacing-tight: -0.1px; + --openui-letter-spacing-tighter: -0.2px; + --openui-text-body-xs: 400 12px/1.5 "Inter", sans-serif; + --openui-text-body-xs-letter-spacing: 0; + --openui-text-body-xs-heavy: 500 12px/1.5 "Inter", sans-serif; + --openui-text-body-xs-heavy-letter-spacing: 0; + --openui-text-body-sm: 400 14px/1.5 "Inter", sans-serif; + --openui-text-body-sm-letter-spacing: 0; + --openui-text-body-sm-heavy: 500 14px/1.5 "Inter", sans-serif; + --openui-text-body-sm-heavy-letter-spacing: 0; + --openui-text-body-default: 400 16px/1.5 "Inter", sans-serif; + --openui-text-body-default-letter-spacing: 0; + --openui-text-body-default-heavy: 500 16px/1.5 "Inter", sans-serif; + --openui-text-body-default-heavy-letter-spacing: 0; + --openui-text-body-lg: 400 18px/1.5 "Inter", sans-serif; + --openui-text-body-lg-letter-spacing: 0; + --openui-text-body-lg-heavy: 500 18px/1.5 "Inter", sans-serif; + --openui-text-body-lg-heavy-letter-spacing: 0; + --openui-text-heading-xs: 600 16px/1.25 "Inter", sans-serif; + --openui-text-heading-xs-letter-spacing: 0; + --openui-text-heading-sm: 600 18px/1.25 "Inter", sans-serif; + --openui-text-heading-sm-letter-spacing: 0; + --openui-text-heading-md: 600 24px/1.1 "Inter", sans-serif; + --openui-text-heading-md-letter-spacing: 0; + --openui-text-heading-lg: 600 28px/1.1 "Inter", sans-serif; + --openui-text-heading-lg-letter-spacing: -0.1px; + --openui-text-heading-xl: 700 32px/1.1 "Inter", sans-serif; + --openui-text-heading-xl-letter-spacing: -0.1px; + --openui-text-label-xs: 400 12px/1.25 "Inter", sans-serif; + --openui-text-label-xs-letter-spacing: 0; + --openui-text-label-xs-heavy: 500 12px/1.25 "Inter", sans-serif; + --openui-text-label-xs-heavy-letter-spacing: 0; + --openui-text-label-sm: 400 14px/1.25 "Inter", sans-serif; + --openui-text-label-sm-letter-spacing: 0; + --openui-text-label-sm-heavy: 500 14px/1.25 "Inter", sans-serif; + --openui-text-label-sm-heavy-letter-spacing: 0; + --openui-text-label-default: 400 16px/1.25 "Inter", sans-serif; + --openui-text-label-default-letter-spacing: 0; + --openui-text-label-default-heavy: 500 16px/1.25 "Inter", sans-serif; + --openui-text-label-default-heavy-letter-spacing: 0; + --openui-text-label-lg: 400 18px/1.25 "Inter", sans-serif; + --openui-text-label-lg-letter-spacing: 0; + --openui-text-label-lg-heavy: 500 18px/1.25 "Inter", sans-serif; + --openui-text-label-lg-heavy-letter-spacing: 0; + --openui-text-numbers-xs: 400 12px/1.5 "Inter", sans-serif; + --openui-text-numbers-xs-letter-spacing: 0; + --openui-text-numbers-xs-heavy: 500 12px/1.5 "Inter", sans-serif; + --openui-text-numbers-xs-heavy-letter-spacing: 0; + --openui-text-numbers-sm: 400 14px/1.5 "Inter", sans-serif; + --openui-text-numbers-sm-letter-spacing: 0; + --openui-text-numbers-sm-heavy: 500 14px/1.5 "Inter", sans-serif; + --openui-text-numbers-sm-heavy-letter-spacing: 0; + --openui-text-numbers-default: 400 16px/1.5 "Inter", sans-serif; + --openui-text-numbers-default-letter-spacing: 0; + --openui-text-numbers-default-heavy: 500 16px/1.5 "Inter", sans-serif; + --openui-text-numbers-default-heavy-letter-spacing: 0; + --openui-text-numbers-lg: 400 18px/1.5 "Inter", sans-serif; + --openui-text-numbers-lg-letter-spacing: 0; + --openui-text-numbers-lg-heavy: 500 18px/1.5 "Inter", sans-serif; + --openui-text-numbers-lg-heavy-letter-spacing: 0; + --openui-text-numbers-heading-sm: 600 18px/1.25 "Inter", sans-serif; + --openui-text-numbers-heading-sm-letter-spacing: 0; + --openui-text-numbers-heading-md: 600 24px/1.1 "Inter", sans-serif; + --openui-text-numbers-heading-md-letter-spacing: 0; + --openui-text-numbers-heading-lg: 600 28px/1.1 "Inter", sans-serif; + --openui-text-numbers-heading-lg-letter-spacing: 0; + --openui-text-numbers-heading-xl: 600 32px/1.1 "Inter", sans-serif; + --openui-text-numbers-heading-xl-letter-spacing: 0; + --openui-text-code-sm: 400 12px/1.5 "SFMono-Regular", Menlo, monospace; + --openui-text-code-sm-letter-spacing: 0; + --openui-text-code-sm-heavy: 700 12px/1.5 "SFMono-Regular", Menlo, monospace; + --openui-text-code-sm-heavy-letter-spacing: 0; + --openui-text-code-default: 400 14px/1.5 "SFMono-Regular", Menlo, monospace; + --openui-text-code-default-letter-spacing: 0; + --openui-text-code-default-heavy: 700 14px/1.5 "SFMono-Regular", Menlo, monospace; + --openui-text-code-default-heavy-letter-spacing: 0; + + // Shadows + + --openui-shadow-0: none; + --openui-shadow-s: 0 1px 3px -2px oklch(0 0 0 / 0.06), 0 2px 5px -2px oklch(0 0 0 / 0.10); + --openui-shadow-m: 0 4px 6px -2px oklch(0 0 0 / 0.08), 0 2px 2px -2px oklch(0 0 0 / 0.12); + --openui-shadow-l: 0 4px 4px -2px oklch(0 0 0 / 0.12), 0 4px 8px -2px oklch(0 0 0 / 0.10); + --openui-shadow-xl: 0 8px 16px -4px oklch(0 0 0 / 0.16), 0 16px 32px -6px oklch(0 0 0 / 0.20); + --openui-shadow-2xl: 0 12px 24px -6px oklch(0 0 0 / 0.20), 0 24px 48px -8px oklch(0 0 0 / 0.24); + --openui-shadow-3xl: 0 16px 32px -8px oklch(0 0 0 / 0.24), 0 32px 64px -12px oklch(0 0 0 / 0.28); +} diff --git a/packages/react-ui/src/openui-defaults-light.scss b/packages/react-ui/src/openui-defaults-light.scss new file mode 100644 index 000000000..f6fa52416 --- /dev/null +++ b/packages/react-ui/src/openui-defaults-light.scss @@ -0,0 +1,243 @@ +// Auto-generated by src/scripts/generate-css-utils.ts — do not edit manually. + +:root { + // Surface / Background Colors + + --openui-background: oklch(0.97 0 89.876 / 1); + --openui-foreground: oklch(0.994 0 89.876 / 1); + --openui-popover-background: oklch(0.994 0 89.876 / 1); + --openui-sunk-light: oklch(0.097 0 0 / 0.02); + --openui-sunk: oklch(0.097 0 0 / 0.04); + --openui-sunk-deep: oklch(0.097 0 0 / 0.08); + --openui-elevated-light: oklch(0.097 0 0 / 0.04); + --openui-elevated: oklch(0.097 0 0 / 0.08); + --openui-elevated-strong: oklch(0.097 0 0 / 0.16); + --openui-elevated-intense: oklch(0.097 0 0 / 0.32); + --openui-overlay: oklch(0 0 0 / 0.4); + --openui-highlight-subtle: oklch(0.097 0 0 / 0.02); + --openui-highlight: oklch(0.097 0 0 / 0.04); + --openui-highlight-strong: oklch(0.097 0 0 / 0.08); + --openui-highlight-intense: oklch(0.097 0 0 / 0.32); + --openui-inverted-background: oklch(0.097 0 0 / 1); + --openui-info-background: oklch(0.623 0.188 259.815 / 0.12); + --openui-success-background: oklch(0.627 0.17 149.214 / 0.12); + --openui-alert-background: oklch(0.795 0.162 86.047 / 0.16); + --openui-danger-background: oklch(0.577 0.215 27.325 / 0.12); + --openui-purple-background: oklch(0.627 0.233 303.9 / 0.12); + --openui-pink-background: oklch(0.592 0.218 0.584 / 0.12); + + // Text Colors + + --openui-text-neutral-primary: oklch(0.097 0 0 / 1); + --openui-text-neutral-secondary: oklch(0.097 0 0 / 0.5); + --openui-text-neutral-tertiary: oklch(0.097 0 0 / 0.2); + --openui-text-neutral-link: oklch(0.097 0 0 / 1); + --openui-text-brand: oklch(0.097 0 0 / 1); + --openui-text-white: oklch(1 0 89.876 / 1); + --openui-text-black: oklch(0 0 0 / 1); + --openui-text-accent-primary: oklch(0.994 0 89.876 / 1); + --openui-text-accent-secondary: oklch(0.994 0 89.876 / 0.5); + --openui-text-accent-tertiary: oklch(0.994 0 89.876 / 0.2); + --openui-text-success-primary: oklch(0.448 0.108 151.328 / 1); + --openui-text-success-inverted: oklch(0.962 0.043 156.743 / 1); + --openui-text-alert-primary: oklch(0.476 0.103 61.907 / 1); + --openui-text-alert-inverted: oklch(0.973 0.069 103.193 / 1); + --openui-text-danger-primary: oklch(0.505 0.19 27.518 / 1); + --openui-text-danger-secondary: oklch(0.711 0.166 22.216 / 1); + --openui-text-danger-tertiary: oklch(0.808 0.103 19.571 / 1); + --openui-text-danger-inverted-primary: oklch(0.982 0.009 17.303 / 1); + --openui-text-danger-inverted-secondary: oklch(0.982 0.009 17.303 / 0.5); + --openui-text-danger-inverted-tertiary: oklch(0.982 0.009 17.303 / 0.3); + --openui-text-info-primary: oklch(0.424 0.181 265.638 / 1); + --openui-text-info-inverted: oklch(0.932 0.032 255.585 / 1); + --openui-text-pink-primary: oklch(0.459 0.17 3.815 / 1); + --openui-text-pink-inverted: oklch(0.948 0.028 342.258 / 1); + --openui-text-purple-primary: oklch(0.438 0.198 303.724 / 1); + --openui-text-purple-inverted: oklch(0.946 0.033 307.174 / 1); + + // Interactive Colors + + --openui-interactive-accent-default: oklch(0.097 0 0 / 1); + --openui-interactive-accent-hover: oklch(0.097 0 0 / 0.8); + --openui-interactive-accent-disabled: oklch(0.097 0 0 / 0.4); + --openui-interactive-accent-pressed: oklch(0.097 0 0 / 1); + --openui-interactive-destructive-default: oklch(0.577 0.215 27.325 / 0.02); + --openui-interactive-destructive-hover: oklch(0.577 0.215 27.325 / 0.08); + --openui-interactive-destructive-disabled: oklch(0.577 0.215 27.325 / 0.02); + --openui-interactive-destructive-pressed: oklch(0.577 0.215 27.325 / 0.1); + --openui-interactive-destructive-accent-default: oklch(0.577 0.215 27.325 / 1); + --openui-interactive-destructive-accent-hover: oklch(0.637 0.208 25.331 / 1); + --openui-interactive-destructive-accent-pressed: oklch(0.505 0.19 27.518 / 1); + --openui-interactive-destructive-accent-disabled: oklch(0.577 0.215 27.325 / 0.4); + + // Chat Colors + + --openui-chat-user-response-bg: oklch(0.097 0 0 / 0.08); + --openui-chat-user-response-text: oklch(0.097 0 0 / 1); + + // Border Colors + + --openui-border-default: oklch(0.097 0 0 / 0.06); + --openui-border-interactive: oklch(0.097 0 0 / 0.12); + --openui-border-interactive-emphasis: oklch(0.097 0 0 / 0.3); + --openui-border-interactive-selected: oklch(0.097 0 0 / 1); + --openui-border-accent: oklch(0.097 0 0 / 0.08); + --openui-border-accent-emphasis: oklch(0.097 0 0 / 0.3); + --openui-border-info: oklch(0.623 0.188 259.815 / 0.08); + --openui-border-info-emphasis: oklch(0.546 0.215 262.881 / 1); + --openui-border-alert: oklch(0.861 0.173 91.936 / 0.08); + --openui-border-alert-emphasis: oklch(0.681 0.142 75.834 / 1); + --openui-border-success: oklch(0.627 0.17 149.214 / 0.08); + --openui-border-success-emphasis: oklch(0.627 0.17 149.214 / 1); + --openui-border-danger: oklch(0.577 0.215 27.325 / 0.08); + --openui-border-danger-emphasis: oklch(0.577 0.215 27.325 / 1); + + // Spacing + + --openui-space-000: 0; + --openui-space-3xs: 2px; + --openui-space-2xs: 4px; + --openui-space-xs: 6px; + --openui-space-s: 8px; + --openui-space-s-m: 10px; + --openui-space-m: 12px; + --openui-space-m-l: 16px; + --openui-space-l: 18px; + --openui-space-xl: 24px; + --openui-space-2xl: 36px; + --openui-space-3xl: 48px; + + // Border Radius + + --openui-radius-none: 0; + --openui-radius-3xs: 1px; + --openui-radius-2xs: 2px; + --openui-radius-xs: 4px; + --openui-radius-s: 6px; + --openui-radius-m: 8px; + --openui-radius-l: 10px; + --openui-radius-xl: 12px; + --openui-radius-2xl: 14px; + --openui-radius-3xl: 16px; + --openui-radius-4xl: 20px; + --openui-radius-5xl: 24px; + --openui-radius-6xl: 28px; + --openui-radius-7xl: 32px; + --openui-radius-8xl: 40px; + --openui-radius-9xl: 48px; + --openui-radius-full: 9999px; + + // Typography Tokens + + --openui-font-body: "Inter", sans-serif; + --openui-font-code: "SFMono-Regular", Menlo, monospace; + --openui-font-heading: "Inter", sans-serif; + --openui-font-label: "Inter", sans-serif; + --openui-font-numbers: "Inter", sans-serif; + --openui-font-size-2xs: 10px; + --openui-font-size-xs: 12px; + --openui-font-size-sm: 14px; + --openui-font-size-md: 16px; + --openui-font-size-lg: 18px; + --openui-font-size-xl: 20px; + --openui-font-size-2xl: 24px; + --openui-font-size-3xl: 28px; + --openui-font-size-4xl: 32px; + --openui-font-size-5xl: 36px; + --openui-font-weight-regular: 400; + --openui-font-weight-medium: 500; + --openui-font-weight-bold: 600; + --openui-font-weight-heavy: 700; + --openui-line-height-body: 1.5; + --openui-line-height-heading: 1.25; + --openui-line-height-heading-large: 1.1; + --openui-line-height-label: 1.25; + --openui-line-height-code: 1.5; + --openui-letter-spacing-normal: 0; + --openui-letter-spacing-tight: -0.1px; + --openui-letter-spacing-tighter: -0.2px; + --openui-text-body-xs: 400 12px/1.5 "Inter", sans-serif; + --openui-text-body-xs-letter-spacing: 0; + --openui-text-body-xs-heavy: 500 12px/1.5 "Inter", sans-serif; + --openui-text-body-xs-heavy-letter-spacing: 0; + --openui-text-body-sm: 400 14px/1.5 "Inter", sans-serif; + --openui-text-body-sm-letter-spacing: 0; + --openui-text-body-sm-heavy: 500 14px/1.5 "Inter", sans-serif; + --openui-text-body-sm-heavy-letter-spacing: 0; + --openui-text-body-default: 400 16px/1.5 "Inter", sans-serif; + --openui-text-body-default-letter-spacing: 0; + --openui-text-body-default-heavy: 500 16px/1.5 "Inter", sans-serif; + --openui-text-body-default-heavy-letter-spacing: 0; + --openui-text-body-lg: 400 18px/1.5 "Inter", sans-serif; + --openui-text-body-lg-letter-spacing: 0; + --openui-text-body-lg-heavy: 500 18px/1.5 "Inter", sans-serif; + --openui-text-body-lg-heavy-letter-spacing: 0; + --openui-text-heading-xs: 600 16px/1.25 "Inter", sans-serif; + --openui-text-heading-xs-letter-spacing: 0; + --openui-text-heading-sm: 600 18px/1.25 "Inter", sans-serif; + --openui-text-heading-sm-letter-spacing: 0; + --openui-text-heading-md: 600 24px/1.1 "Inter", sans-serif; + --openui-text-heading-md-letter-spacing: 0; + --openui-text-heading-lg: 600 28px/1.1 "Inter", sans-serif; + --openui-text-heading-lg-letter-spacing: -0.1px; + --openui-text-heading-xl: 700 32px/1.1 "Inter", sans-serif; + --openui-text-heading-xl-letter-spacing: -0.1px; + --openui-text-label-xs: 400 12px/1.25 "Inter", sans-serif; + --openui-text-label-xs-letter-spacing: 0; + --openui-text-label-xs-heavy: 500 12px/1.25 "Inter", sans-serif; + --openui-text-label-xs-heavy-letter-spacing: 0; + --openui-text-label-sm: 400 14px/1.25 "Inter", sans-serif; + --openui-text-label-sm-letter-spacing: 0; + --openui-text-label-sm-heavy: 500 14px/1.25 "Inter", sans-serif; + --openui-text-label-sm-heavy-letter-spacing: 0; + --openui-text-label-default: 400 16px/1.25 "Inter", sans-serif; + --openui-text-label-default-letter-spacing: 0; + --openui-text-label-default-heavy: 500 16px/1.25 "Inter", sans-serif; + --openui-text-label-default-heavy-letter-spacing: 0; + --openui-text-label-lg: 400 18px/1.25 "Inter", sans-serif; + --openui-text-label-lg-letter-spacing: 0; + --openui-text-label-lg-heavy: 500 18px/1.25 "Inter", sans-serif; + --openui-text-label-lg-heavy-letter-spacing: 0; + --openui-text-numbers-xs: 400 12px/1.5 "Inter", sans-serif; + --openui-text-numbers-xs-letter-spacing: 0; + --openui-text-numbers-xs-heavy: 500 12px/1.5 "Inter", sans-serif; + --openui-text-numbers-xs-heavy-letter-spacing: 0; + --openui-text-numbers-sm: 400 14px/1.5 "Inter", sans-serif; + --openui-text-numbers-sm-letter-spacing: 0; + --openui-text-numbers-sm-heavy: 500 14px/1.5 "Inter", sans-serif; + --openui-text-numbers-sm-heavy-letter-spacing: 0; + --openui-text-numbers-default: 400 16px/1.5 "Inter", sans-serif; + --openui-text-numbers-default-letter-spacing: 0; + --openui-text-numbers-default-heavy: 500 16px/1.5 "Inter", sans-serif; + --openui-text-numbers-default-heavy-letter-spacing: 0; + --openui-text-numbers-lg: 400 18px/1.5 "Inter", sans-serif; + --openui-text-numbers-lg-letter-spacing: 0; + --openui-text-numbers-lg-heavy: 500 18px/1.5 "Inter", sans-serif; + --openui-text-numbers-lg-heavy-letter-spacing: 0; + --openui-text-numbers-heading-sm: 600 18px/1.25 "Inter", sans-serif; + --openui-text-numbers-heading-sm-letter-spacing: 0; + --openui-text-numbers-heading-md: 600 24px/1.1 "Inter", sans-serif; + --openui-text-numbers-heading-md-letter-spacing: 0; + --openui-text-numbers-heading-lg: 600 28px/1.1 "Inter", sans-serif; + --openui-text-numbers-heading-lg-letter-spacing: 0; + --openui-text-numbers-heading-xl: 600 32px/1.1 "Inter", sans-serif; + --openui-text-numbers-heading-xl-letter-spacing: 0; + --openui-text-code-sm: 400 12px/1.5 "SFMono-Regular", Menlo, monospace; + --openui-text-code-sm-letter-spacing: 0; + --openui-text-code-sm-heavy: 700 12px/1.5 "SFMono-Regular", Menlo, monospace; + --openui-text-code-sm-heavy-letter-spacing: 0; + --openui-text-code-default: 400 14px/1.5 "SFMono-Regular", Menlo, monospace; + --openui-text-code-default-letter-spacing: 0; + --openui-text-code-default-heavy: 700 14px/1.5 "SFMono-Regular", Menlo, monospace; + --openui-text-code-default-heavy-letter-spacing: 0; + + // Shadows + + --openui-shadow-0: none; + --openui-shadow-s: 0 1px 3px -2px oklch(0 0 0 / 0.02), 0 2px 5px -2px oklch(0 0 0 / 0.04); + --openui-shadow-m: 0 4px 6px -2px oklch(0 0 0 / 0.025), 0 2px 2px -2px oklch(0 0 0 / 0.05); + --openui-shadow-l: 0 4px 4px -2px oklch(0 0 0 / 0.05), 0 4px 8px -2px oklch(0 0 0 / 0.04); + --openui-shadow-xl: 0 8px 16px -4px oklch(0 0 0 / 0.08), 0 16px 32px -6px oklch(0 0 0 / 0.12); + --openui-shadow-2xl: 0 12px 24px -6px oklch(0 0 0 / 0.12), 0 24px 48px -8px oklch(0 0 0 / 0.16); + --openui-shadow-3xl: 0 16px 32px -8px oklch(0 0 0 / 0.16), 0 32px 64px -12px oklch(0 0 0 / 0.22); +} diff --git a/packages/react-ui/src/scripts/generate-css-utils.ts b/packages/react-ui/src/scripts/generate-css-utils.ts index b4d6d2a1b..0faa10607 100644 --- a/packages/react-ui/src/scripts/generate-css-utils.ts +++ b/packages/react-ui/src/scripts/generate-css-utils.ts @@ -167,3 +167,39 @@ defaultsLines.push(""); const defaultsOutputPath = path.resolve(__dirname, "../openui-defaults.scss"); fs.writeFileSync(defaultsOutputPath, defaultsLines.join("\n")); console.info(`Generated openui-defaults.scss at ${defaultsOutputPath}`); + +// ─── Scheme-pinned defaults for single-scheme apps ─────────────────────────── +// Plain :root blocks with no prefers-color-scheme gate, shipped as +// ./defaults-dark.css and ./defaults-light.css. An app that forces one scheme +// (e.g. ) imports the matching file after the +// component styles so the static token floor agrees with the forced mode on +// devices whose system scheme is the opposite one. +function generateSchemePinnedDefaults( + themeEntries: [string, string][], + outputFileName: string, +): void { + const out: string[] = []; + out.push("// Auto-generated by src/scripts/generate-css-utils.ts — do not edit manually."); + out.push(""); + out.push(":root {"); + + for (const [key, value] of themeEntries) { + if (sectionHeaders[key]) { + if (out.length > 3) out.push(""); + out.push(` ${sectionHeaders[key]}`); + out.push(""); + } + const kebab = camelToKebab(key); + out.push(` --openui-${kebab}: ${value};`); + } + + out.push("}"); + out.push(""); + + const outPath = path.resolve(__dirname, `../${outputFileName}`); + fs.writeFileSync(outPath, out.join("\n")); + console.info(`Generated ${outputFileName} at ${outPath}`); +} + +generateSchemePinnedDefaults(entries, "openui-defaults-light.scss"); +generateSchemePinnedDefaults(darkEntries, "openui-defaults-dark.scss");