Skip to content

Dark only apps render the stock light theme on light scheme devices because there is no scheme pinned CSS export #736

Description

@ankit-thesys

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:

  1. ./defaults-dark.css containing a plain unlayered :root block with the full dark token set and no media query.
  2. ./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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions