Skip to content
Merged
4 changes: 4 additions & 0 deletions .ladle/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
19 changes: 12 additions & 7 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<html className="dark">`). `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

Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
22 changes: 13 additions & 9 deletions IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 5 additions & 3 deletions PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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* — `<script>if (localStorage.getItem("corbits-theme")?.includes('"light"')) document.documentElement.classList.add("light")</script>` — 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";
Expand All @@ -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";

Expand Down
52 changes: 52 additions & 0 deletions src/lib/theme.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";

import {
applyThemeToRoot,
Expand All @@ -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<string, string> {
const out: Record<string, string> = {};
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");
Expand Down Expand Up @@ -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");
});
});
13 changes: 10 additions & 3 deletions src/lib/theme.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 {
Expand Down
53 changes: 52 additions & 1 deletion src/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions src/ui/theme-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down
7 changes: 7 additions & 0 deletions src/ui/toast-edge.test.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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;
});
Expand Down
7 changes: 7 additions & 0 deletions src/ui/toast.test.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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;
});
Expand Down
Loading