diff --git a/.ladle/components.tsx b/.ladle/components.tsx
index c5c5fa37..7e954d10 100644
--- a/.ladle/components.tsx
+++ b/.ladle/components.tsx
@@ -14,6 +14,10 @@ const Provider: GlobalProvider = ({ globalState, children }) => {
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
const isDark = globalState.theme === "dark" || (globalState.theme === "auto" && prefersDark);
document.documentElement.classList.toggle("dark", isDark);
+ // theme.css now follows the OS when neither class is set, so "light" must
+ // be pinned explicitly — removing `.dark` alone still renders dark on a
+ // dark-OS dev box.
+ document.documentElement.classList.toggle("light", globalState.theme === "light");
}, [globalState.theme]);
return children;
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 81f58bec..9baa3d3d 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -96,13 +96,18 @@ Tailwind resolves `@source` relative to the file containing it, so it means `src
build the standalone sheet and `dist/` when a consumer imports it out of `node_modules`.
Since there is only one file, the tokens and keyframes cannot drift between the two.
-Dark mode is a `dark` class on an ancestor. The stylesheet reads it
-(`@custom-variant dark (&:is(.dark *))`); it does not manage it. A host can
-set the class itself (``). `ThemeProvider` is an
-optional helper that applies that class (and `data-theme` presets plus
-`color-scheme`) and persists the choice; the host mounts it and keys storage
-per user. `useTheme` / `ThemeToggle` require the provider; every other
-component does not.
+Dark mode is a `dark` class on an ancestor, with an OS fallback when no
+class is set. The stylesheet reads `.dark` (`@custom-variant dark (&:is(.dark *))`)
+and, under `@media (prefers-color-scheme: dark)`, applies the dark tokens to
+`:root` when neither `.dark` nor `.light` is present — so a zero-JS host on a
+dark OS first-paints dark, and removing `.dark` alone is not light. `.light`
+is a `:root`-only sentinel that opts out of the media query; it carries no
+token block, so it must live on `documentElement`. `dark:` utilities still
+require a real `.dark` ancestor — the media path moves tokens only.
+`ThemeProvider` is an optional helper that applies `.dark`/`.light` (and
+`data-theme` presets plus `color-scheme`) and persists the choice; the host
+mounts it and keys storage per user. `useTheme` / `ThemeToggle` require the
+provider; every other component does not.
### Contrast is gated
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 46ab9055..026c8291 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -47,6 +47,13 @@ contract — modules that are not exported are internal and may change in any re
family deliberately leaves matching to the caller. `commandMatches` is gone
with the component. The `./ui/command` subpath is gone. `lib/command-registry`
(and `CommandAction`) is also in the unconsumed prune above.
+- **A classless root now follows `prefers-color-scheme`.** Under
+ `@media (prefers-color-scheme: dark)`, `:root` receives the dark tokens when neither
+ `.dark` nor `.light` is present — a host that toggled dark mode by adding and removing
+ `.dark` must now also add `.light` to `documentElement` for explicit light, or dark-OS
+ users will see dark anyway. `dark:` utilities still require a real `.dark` ancestor.
+ `ThemeProvider` writes `.light` for resolved-light mode, so hosts on the provider need
+ no change.
## [0.1.0] - 2026-09-17
diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md
index 83facb5e..c1da5e20 100644
--- a/IMPLEMENTATION.md
+++ b/IMPLEMENTATION.md
@@ -50,18 +50,22 @@ Two published sheets, both generated from `src/theme.css`:
- `@corbits/react-ui/theme.css` → `dist/theme.css`, a byte copy of
`src/theme.css`. For a host already on Tailwind v4.
-Dark mode is the CSS custom variant `@custom-variant dark (&:is(.dark *));`.
-The sheet reads a `dark` class on an ancestor; it does not set it. Named
-presets are `data-theme` values (`default` / `warm` / `cool`). Brand faces
-are `--font-sans: "Red Hat Display", …` and `--font-mono: "Space Mono", …`;
+Dark mode is the CSS custom variant `@custom-variant dark (&:is(.dark *));`
+plus a `@media (prefers-color-scheme: dark)` fallback that applies dark
+tokens to `:root` when neither `.dark` nor `.light` is set. Removing `.dark`
+alone is not light on a dark-OS host — `.light` on `documentElement` is the
+explicit opt-out. `dark:` utilities still require a real `.dark` ancestor.
+Named presets are `data-theme` values (`default` / `warm` / `cool`). Brand
+faces are `--font-sans: "Red Hat Display", …` and `--font-mono: "Space Mono", …`;
no font files ship.
`ThemeProvider` (`src/ui/theme-provider.tsx`) is optional host wiring. It
-writes `.dark`, `data-theme`, and `color-scheme` on a root (default
-`document.documentElement`) and persists `{ mode, preset }` JSON under
-`localStorage` key `corbits-theme` (override with `storageKey`). Default
-mode is `"system"`. `ThemeToggle` / `useTheme` require the provider; the
-rest of the library does not.
+writes `.dark`/`.light`, `data-theme`, and `color-scheme` on a root (default
+`document.documentElement` — a scoped `root` cannot escape the OS-following
+media query, which only inspects `:root`) and persists `{ mode, preset }`
+JSON under `localStorage` key `corbits-theme` (override with `storageKey`).
+Default mode is `"system"`. `ThemeToggle` / `useTheme` require the provider;
+the rest of the library does not.
## Publish
diff --git a/PRODUCT.md b/PRODUCT.md
index e577828a..40682bcb 100644
--- a/PRODUCT.md
+++ b/PRODUCT.md
@@ -27,9 +27,11 @@ It is not a design-system starter, not a CLI, and not a template app.
- Style with a single prebuilt sheet (`@corbits/react-ui/styles.css`) and no
Tailwind, or import `@corbits/react-ui/theme.css` into an existing Tailwind
v4 build.
-- Choose dark mode by putting a `dark` class on an ancestor. The stylesheet
- reads that class; the host owns switching. Mounting `ThemeProvider` is
- optional — persistence and named presets, not a requirement to go dark.
+- Choose dark mode by putting a `dark` class on an ancestor — or nothing at
+ all: with no class set, the stylesheet follows `prefers-color-scheme`.
+ `light` on `documentElement` is the explicit light opt-out. Mounting
+ `ThemeProvider` is optional — persistence and named presets, not a
+ requirement to go dark.
- Mark their own React Server Components boundary. This package ships no
`"use client"` directives.
diff --git a/README.md b/README.md
index 103f62e3..db4d9709 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,11 @@ Import the prebuilt stylesheet once at the app root (no Tailwind build required)
import "@corbits/react-ui/styles.css";
```
-That sheet includes Tailwind preflight and a base layer — it restyles the page. If you already use Tailwind v4, import `@corbits/react-ui/theme.css` instead and let your own build generate utilities. Dark mode is a `dark` class on an ancestor; the stylesheet reads it and does not manage it. Mount `ThemeProvider` only if you want the library to persist the choice and apply named presets.
+That sheet includes Tailwind preflight and a base layer — it restyles the page, and neither import is safe to skip: without one of them the components render unstyled markup, not a fallback look. If you already use Tailwind v4, import `@corbits/react-ui/theme.css` instead and let your own build generate utilities; don't import both.
+
+Dark mode is a `dark` class on an ancestor; the stylesheet reads it and does not manage it. With neither `.dark` nor `.light` set, the theme follows the OS — a `@media (prefers-color-scheme: dark)` block applies the dark tokens to `:root` and sets `color-scheme: dark` so native controls follow, so a zero-JS install already renders dark on a dark-OS host. That media-query path is tokens only — `dark:` utilities still need a `.dark` ancestor.
+
+Removing `.dark` is not light: hosts that toggle by adding and removing `.dark` must now add `.light` for an explicit light choice (or mount `ThemeProvider`, which toggles both), or a dark-OS user falls back to OS dark. An explicit-light choice on a dark OS first-paints dark then swaps when JS adds `.light`; to block the flash, apply it before first paint *only when the stored choice is light* — `` — adding it unconditionally pins light for dark-OS users too. `ThemeProvider` still wins once it mounts — mount it only if you want the library to persist the choice and apply named presets.
```tsx
import { Button } from "@corbits/react-ui/ui/button";
@@ -31,6 +35,8 @@ import { Button } from "@corbits/react-ui/ui/button";
Every component is importable by subpath (`@corbits/react-ui/ui/button`) or from the root (`@corbits/react-ui`).
+The brand faces (Red Hat Display, Space Mono) are named by the theme but not bundled. Load them yourself, or the stack falls through to system fonts; to use a face loaded under a generated name, override `--font-sans` / `--font-mono`.
+
```tsx
import "@corbits/react-ui/styles.css";
diff --git a/src/lib/theme.test.ts b/src/lib/theme.test.ts
index 12b7f9d3..d3e2a580 100644
--- a/src/lib/theme.test.ts
+++ b/src/lib/theme.test.ts
@@ -1,4 +1,6 @@
import { describe, expect, test } from "bun:test";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
import {
applyThemeToRoot,
@@ -8,6 +10,35 @@ import {
serializeThemePreference,
} from "./theme.js";
+const theme = readFileSync(join(import.meta.dir, "../theme.css"), "utf-8");
+
+/** Body of the first `{ ... }` after `needle`, respecting nested braces. */
+function ruleBodyAfter(css: string, needle: string): string {
+ const at = css.indexOf(needle);
+ if (at === -1) throw new Error(`theme.css: no \`${needle}\``);
+ const open = css.indexOf("{", at);
+ let depth = 0;
+ for (let i = open; i < css.length; i++) {
+ if (css[i] === "{") depth += 1;
+ else if (css[i] === "}") {
+ depth -= 1;
+ if (depth === 0) return css.slice(open + 1, i);
+ }
+ }
+ throw new Error(`theme.css: unclosed rule after \`${needle}\``);
+}
+
+function declarations(body: string): Record {
+ const out: Record = {};
+ for (const match of body.matchAll(/^\s*([a-z0-9-]+):\s*([^;]+);/gm)) {
+ const name = match[1];
+ const value = match[2];
+ if (name === undefined || value === undefined) continue;
+ out[name] = value.trim();
+ }
+ return out;
+}
+
describe("resolveThemeMode", () => {
test("light and dark ignore the system preference", () => {
expect(resolveThemeMode("light", true)).toBe("light");
@@ -88,4 +119,25 @@ describe("applyThemeToRoot", () => {
expect(root.hasAttribute("data-theme")).toBe(false);
expect(root.style.colorScheme).toBe("light");
});
+
+ test("toggles a `.light` escape hatch for explicit light on a dark-OS host", () => {
+ const root = document.createElement("div");
+ applyThemeToRoot(root, "light", "default");
+ expect(root.classList.contains("light")).toBe(true);
+ expect(root.classList.contains("dark")).toBe(false);
+
+ applyThemeToRoot(root, "dark", "default");
+ expect(root.classList.contains("light")).toBe(false);
+ expect(root.classList.contains("dark")).toBe(true);
+ });
+});
+
+describe("zero-JS prefers-color-scheme dark", () => {
+ test("the media-query :root block matches .dark (tokens + color-scheme) so they cannot drift", () => {
+ const dark = declarations(ruleBodyAfter(theme, "\n.dark {"));
+ const media = ruleBodyAfter(theme, "@media (prefers-color-scheme: dark)");
+ const mediaRoot = declarations(ruleBodyAfter(media, ":root:not(.dark):not(.light)"));
+ expect(mediaRoot).toEqual(dark);
+ expect(mediaRoot["color-scheme"]).toBe("dark");
+ });
});
diff --git a/src/lib/theme.ts b/src/lib/theme.ts
index 74016368..13d91951 100644
--- a/src/lib/theme.ts
+++ b/src/lib/theme.ts
@@ -1,8 +1,11 @@
/** Theme mode and preset resolution for ThemeProvider.
- * Dark mode is opt-in via a `.dark` class on an ancestor (see theme.css
- * `@custom-variant dark`). This module owns the pure resolution rules;
- * ThemeProvider applies them to the document and persists the choice.
+ * Dark mode is a `.dark` class on an ancestor, with an OS-following fallback
+ * when neither `.dark` nor `.light` is set (see theme.css — `@custom-variant
+ * dark` plus the `prefers-color-scheme` block). `.light` is the explicit
+ * opt-out and only works on `documentElement`. This module owns the pure
+ * resolution rules; ThemeProvider applies them to the document and persists
+ * the choice.
*/
export type ThemeMode = "system" | "light" | "dark";
@@ -76,6 +79,10 @@ export function applyThemeToRoot(
preset: ThemePreset,
): void {
root.classList.toggle("dark", resolved === "dark");
+ // `.light` is the explicit-light escape hatch from the `prefers-color-scheme`
+ // media query in theme.css — without it, "light" mode chosen on a dark-OS
+ // host would be overridden back to dark tokens by the media query.
+ root.classList.toggle("light", resolved === "light");
if (preset === "default") {
root.removeAttribute("data-theme");
} else {
diff --git a/src/theme.css b/src/theme.css
index 04dd236f..36bab07f 100644
--- a/src/theme.css
+++ b/src/theme.css
@@ -158,9 +158,60 @@
--chart-3: #9fd8a8;
--chart-4: #9db8e8;
--chart-5: #cc4f47;
+ color-scheme: dark;
}
-
+/* Dark by default: a fresh install on a dark-OS host gets dark tokens with
+ zero JS and zero explicit `.dark` class. The `:not(.dark):not(.light)`
+ guard means ThemeProvider still wins in both directions once it mounts —
+ an explicit "dark" adds `.dark` (already true, no-op here) and an explicit
+ "light" adds `.light`, which this selector excludes.
+
+ Native CSS cannot attach one declaration list to both `.dark` (any OS) and
+ a media-qualified `:root` without `light-dark()` — and `light-dark()` would
+ break the contrast gate, which parses hex off `.dark {`. So the two blocks
+ stay token-identical on purpose; tests/theme.test.ts fails the build if
+ they drift. `color-scheme: dark` is part of that shared set so zero-JS
+ dark paints native controls dark too. */
+@media (prefers-color-scheme: dark) {
+ :root:not(.dark):not(.light) {
+ --background: #000000;
+ --foreground: #e4d5bc;
+ --card: #2b2627;
+ --card-foreground: #e4d5bc;
+ --popover: #332c2d;
+ --popover-foreground: #e4d5bc;
+ --primary: #bf6b20;
+ --primary-foreground: #000000;
+ --primary-active: #d4791f;
+ --primary-emphasis: #e98428;
+ --secondary: #2d455c;
+ --secondary-foreground: #e4d5bc;
+ --muted: #1a1718;
+ --muted-foreground: #a99c86;
+ --accent: #2d455c;
+ --accent-foreground: #e4d5bc;
+ --success: #425a3d;
+ --success-foreground: #e4d5bc;
+ --destructive: #e6796f;
+ --destructive-foreground: #000000;
+ --border: #423a3b;
+ --border-strong: #5c5254;
+ --input: #8a8080;
+ --ring: #e98428;
+ --shadow: none;
+
+ --ok: #5cb573;
+ --warn: #d9a441;
+
+ --chart-1: #5a95d8;
+ --chart-2: #63a86a;
+ --chart-3: #9fd8a8;
+ --chart-4: #9db8e8;
+ --chart-5: #cc4f47;
+ color-scheme: dark;
+ }
+}
/* Named preset overlays. Applied as data-theme on the same root that carries
`.dark` (see ThemeProvider). Only surface tints shift — foreground, primary,
diff --git a/src/ui/theme-provider.tsx b/src/ui/theme-provider.tsx
index 2ab623d5..7dd10bba 100644
--- a/src/ui/theme-provider.tsx
+++ b/src/ui/theme-provider.tsx
@@ -70,8 +70,10 @@ export type ThemeProviderProps = {
readonly defaultMode?: ThemeMode;
readonly defaultPreset?: ThemePreset;
/**
- * Element that receives `.dark`, `data-theme`, and `color-scheme`.
- * Defaults to `document.documentElement`.
+ * Element that receives `.dark`/`.light`, `data-theme`, and `color-scheme`.
+ * Defaults to `document.documentElement`. A scoped root still inherits the
+ * OS-following media query, which only inspects `:root` — explicit light on
+ * a dark-OS host requires `.light` on `documentElement` regardless.
*/
readonly root?: Element | null;
};
diff --git a/src/ui/toast-edge.test.tsx b/src/ui/toast-edge.test.tsx
index 350fc716..f4dd354e 100644
--- a/src/ui/toast-edge.test.tsx
+++ b/src/ui/toast-edge.test.tsx
@@ -1,6 +1,7 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, test } from "bun:test";
+import { toast as sonnerToast } from "sonner";
import { toast, Toaster } from "./toast.js";
@@ -40,6 +41,12 @@ describe("toast replace-semantics edges", () => {
let mounted: Mounted | undefined;
afterEach(() => {
+ // Sonner keeps a process-wide toast store and replays active toasts to
+ // the next Toaster that subscribes. Dismiss before unmount so a later
+ // test does not inherit the previous confirmation.
+ act(() => {
+ sonnerToast.dismiss();
+ });
mounted?.unmount();
mounted = undefined;
});
diff --git a/src/ui/toast.test.tsx b/src/ui/toast.test.tsx
index b907ebf8..3d850021 100644
--- a/src/ui/toast.test.tsx
+++ b/src/ui/toast.test.tsx
@@ -1,6 +1,7 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, test } from "bun:test";
+import { toast as sonnerToast } from "sonner";
import { toast, Toaster } from "./toast.js";
@@ -40,6 +41,12 @@ describe("Toaster", () => {
let mounted: Mounted | undefined;
afterEach(() => {
+ // Sonner keeps a process-wide toast store and replays active toasts to
+ // the next Toaster that subscribes. Dismiss before unmount so a later
+ // test does not inherit the previous confirmation.
+ act(() => {
+ sonnerToast.dismiss();
+ });
mounted?.unmount();
mounted = undefined;
});