Summary
Every dark token that @openuidev/react-ui ships is gated behind @media (prefers-color-scheme: dark). This is true for ./index.css, ./layered/styles/index.css, and ./defaults.css. There is no shipped stylesheet that pins a single scheme statically.
An app that forces dark mode with <ThemeProvider mode="dark"> therefore renders the stock light theme on any device whose system scheme is light, for every token the app has not personally overridden. This happens in every situation where the runtime style injection from ThemeProvider is not in effect, which includes the static first paint, server rendered HTML before hydration, hydration failures, and pages with JavaScript disabled. The mode prop only affects the runtime context and the injected style tag. It never changes what the static CSS resolves to.
Real world impact
A downstream dark only consumer hit this in production. The app forced dark mode and bridged about 25 brand tokens with its own unlayered :root overrides, which is the documented theming contract. On a desktop with a dark system scheme everything looked correct. On a phone with a light system scheme the chat interface rendered as a full stock light UI inside an otherwise dark app, because the chat surfaces read tokens the bridge did not override, and those tokens resolved from the light :root block since the dark values sit behind the media query.
The workaround that consumer landed on was to scrape the full dark token block out of the dist CSS with a script at every upgrade, paste roughly 21 KB of duplicated tokens into their own repo, and guard it with a drift test. That is exactly the kind of thing a first class export should replace.
Proposal
Emit two new scheme pinned stylesheets from the same generator that already produces openui-defaults.css (src/scripts/generate-css-utils.ts), and add them to the exports map:
./defaults-dark.css containing a plain unlayered :root block with the full dark token set and no media query.
./defaults-light.css containing the same for light, since light only apps have the mirrored problem on dark scheme devices.
Usage for a dark only app then becomes one extra import and zero scraping:
@layer theme, base, openui, components, utilities;
@import "@openuidev/react-ui/layered/styles/index.css";
@import "@openuidev/react-ui/defaults-dark.css";
/* the consumer's own :root brand token overrides follow and win by source order */
The generator already has defaultDarkTheme in scope and already iterates it for the media gated block, so the new artifacts come from the token source of truth rather than from transforming the output CSS. The build pipeline picks new .scss files in src up automatically. Alongside the generator change this needs a copy step in cp-css.js, two entries in the exports map, and a short docs section explaining the single scheme setup.
A fuller investigation with exact source references lives in docs/dark-scheme-exports-investigation.md.
Summary
Every dark token that
@openuidev/react-uiships is gated behind@media (prefers-color-scheme: dark). This is true for./index.css,./layered/styles/index.css, and./defaults.css. There is no shipped stylesheet that pins a single scheme statically.An app that forces dark mode with
<ThemeProvider mode="dark">therefore renders the stock light theme on any device whose system scheme is light, for every token the app has not personally overridden. This happens in every situation where the runtime style injection from ThemeProvider is not in effect, which includes the static first paint, server rendered HTML before hydration, hydration failures, and pages with JavaScript disabled. The mode prop only affects the runtime context and the injected style tag. It never changes what the static CSS resolves to.Real world impact
A downstream dark only consumer hit this in production. The app forced dark mode and bridged about 25 brand tokens with its own unlayered
:rootoverrides, which is the documented theming contract. On a desktop with a dark system scheme everything looked correct. On a phone with a light system scheme the chat interface rendered as a full stock light UI inside an otherwise dark app, because the chat surfaces read tokens the bridge did not override, and those tokens resolved from the light:rootblock since the dark values sit behind the media query.The workaround that consumer landed on was to scrape the full dark token block out of the dist CSS with a script at every upgrade, paste roughly 21 KB of duplicated tokens into their own repo, and guard it with a drift test. That is exactly the kind of thing a first class export should replace.
Proposal
Emit two new scheme pinned stylesheets from the same generator that already produces
openui-defaults.css(src/scripts/generate-css-utils.ts), and add them to the exports map:./defaults-dark.csscontaining a plain unlayered:rootblock with the full dark token set and no media query../defaults-light.csscontaining the same for light, since light only apps have the mirrored problem on dark scheme devices.Usage for a dark only app then becomes one extra import and zero scraping:
The generator already has
defaultDarkThemein scope and already iterates it for the media gated block, so the new artifacts come from the token source of truth rather than from transforming the output CSS. The build pipeline picks new.scssfiles insrcup automatically. Alongside the generator change this needs a copy step incp-css.js, two entries in the exports map, and a short docs section explaining the single scheme setup.A fuller investigation with exact source references lives in
docs/dark-scheme-exports-investigation.md.