Skip to content

Commit cc9ed83

Browse files
CopilotIzumiSy
andauthored
refactor(theme): rename mode APIs to color mode names
Co-authored-by: IzumiSy <982850+IzumiSy@users.noreply.github.com>
1 parent b21f489 commit cc9ed83

7 files changed

Lines changed: 53 additions & 53 deletions

File tree

.changeset/theme-mode-palette-axes.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44

55
Split theming into two independent axes — **mode** (`light` / `dark` / `system`) and **theme/palette** (`default` / `cream` / `bloom`) — so every palette works in both light and dark.
66

7-
- New `useMode()` hook + `MODE_OPTIONS` for the end-user appearance control (light/dark/system). `useTheme()` now returns the color palette: `{ theme, setTheme }`. `ResolvedTheme` is replaced by `ResolvedMode`.
8-
- `<AppShell>` gains `defaultMode` (default `"system"`, persisted as the user preference); `defaultTheme` now selects the palette (default `"default"`). The palette is a developer config driven by the prop — it is **not** persisted to localStorage, so a stale stored value can't shadow the configured brand. Only mode (and font) persist.
7+
- New `useColorMode()` hook + `COLOR_MODE_OPTIONS` for the end-user appearance control (light/dark/system). `useTheme()` now returns the color palette: `{ theme, setTheme }`. `ResolvedTheme` is replaced by `ResolvedColorMode`.
8+
- `<AppShell>` gains `defaultColorMode` (default `"system"`, persisted as the user preference); `defaultTheme` now selects the palette (default `"default"`). The palette is a developer config driven by the prop — it is **not** persisted to localStorage, so a stale stored value can't shadow the configured brand. Only mode (and font) persist.
99
- DOM: `<html>` carries the mode as the `.light` / `.dark` class and the palette as `data-theme`.
1010
- CSS: `light.css` + `dark.css` are merged into `default.css`; **Cream** and **Bloom** gain dark variants. The shell gradient is parameterized per mode.
1111
- The appearance switcher now offers Light / Dark / System only — the palette is a developer configuration. The font axis is unchanged.

packages/core/src/components/appshell.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
type ContextData,
1818
} from "@/contexts/appshell-context";
1919
import { RouterContainer } from "@/routing/router";
20-
import { ThemeProvider, type Mode, type Theme, type Font } from "@/contexts/theme-context";
20+
import { ThemeProvider, type ColorMode, type Theme, type Font } from "@/contexts/theme-context";
2121
import { BreadcrumbOverrideProvider } from "@/contexts/breadcrumb-context";
2222
import { CommandPaletteProvider, type SearchSource } from "@/contexts/command-palette-context";
2323
import { BuiltInCommandPalette } from "@/components/command-palette";
@@ -186,12 +186,12 @@ type SharedAppShellProps = React.PropsWithChildren<{
186186
*
187187
* @default "system"
188188
*/
189-
defaultMode?: Mode;
189+
defaultColorMode?: ColorMode;
190190

191191
/**
192192
* Color palette / brand (developer configuration), applied as `data-theme`.
193193
* One of **`default`**, **`cream`**, or **`bloom`** — each ships both light and dark
194-
* variants, so the active variant follows {@link AppShellProps.defaultMode}.
194+
* variants, so the active variant follows {@link AppShellProps.defaultColorMode}.
195195
* This is set by the product (not a user-facing picker), so it is driven by this
196196
* prop and not persisted to localStorage.
197197
*
@@ -350,7 +350,7 @@ export const AppShell = (props: AppShellProps) => {
350350
<BreadcrumbOverrideProvider>
351351
<CommandPaletteProvider searchSources={props.searchSources}>
352352
<ThemeProvider
353-
defaultMode={props.defaultMode}
353+
defaultColorMode={props.defaultColorMode}
354354
defaultTheme={props.defaultTheme}
355355
defaultFont={props.defaultFont}
356356
>

packages/core/src/components/theme-switcher.test.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react";
22
import userEvent from "@testing-library/user-event";
33
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
44

5-
import { FONT_OPTIONS, MODE_OPTIONS, ThemeProvider } from "@/contexts/theme-context";
5+
import { FONT_OPTIONS, COLOR_MODE_OPTIONS, ThemeProvider } from "@/contexts/theme-context";
66

77
import { ThemeSwitcher } from "./theme-switcher";
88

@@ -69,7 +69,7 @@ describe("ThemeSwitcher", () => {
6969
const user = userEvent.setup();
7070

7171
render(
72-
<ThemeProvider defaultMode="light">
72+
<ThemeProvider defaultColorMode="light">
7373
<ThemeSwitcher />
7474
</ThemeProvider>,
7575
);
@@ -78,11 +78,11 @@ describe("ThemeSwitcher", () => {
7878

7979
await waitFor(() => {
8080
expect(screen.getAllByRole("menuitemradio").length).toBe(
81-
MODE_OPTIONS.length + FONT_OPTIONS.length,
81+
COLOR_MODE_OPTIONS.length + FONT_OPTIONS.length,
8282
);
8383
});
8484

85-
for (const opt of MODE_OPTIONS) {
85+
for (const opt of COLOR_MODE_OPTIONS) {
8686
expect(screen.getByRole("menuitemradio", { name: opt.label })).toBeDefined();
8787
}
8888
for (const opt of FONT_OPTIONS) {
@@ -111,7 +111,7 @@ describe("ThemeSwitcher", () => {
111111

112112
it("exposes the resolved mode on the trigger when system mode is selected", () => {
113113
render(
114-
<ThemeProvider defaultMode="system">
114+
<ThemeProvider defaultColorMode="system">
115115
<ThemeSwitcher />
116116
</ThemeProvider>,
117117
);
@@ -125,7 +125,7 @@ describe("ThemeSwitcher", () => {
125125
const user = userEvent.setup();
126126

127127
render(
128-
<ThemeProvider defaultMode="light">
128+
<ThemeProvider defaultColorMode="light">
129129
<ThemeSwitcher />
130130
</ThemeProvider>,
131131
);
@@ -148,7 +148,7 @@ describe("ThemeSwitcher", () => {
148148
const user = userEvent.setup();
149149

150150
render(
151-
<ThemeProvider defaultMode="light" defaultFont="geist">
151+
<ThemeProvider defaultColorMode="light" defaultFont="geist">
152152
<ThemeSwitcher />
153153
</ThemeProvider>,
154154
);

packages/core/src/components/theme-switcher.tsx

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,16 @@ import { Menu } from "@/components/menu";
44
import { Button } from "@/components/button";
55
import { cn } from "@/lib/utils";
66
import {
7-
useMode,
7+
useColorMode,
88
useFont,
9-
type ResolvedMode,
10-
type Mode,
9+
type ResolvedColorMode,
10+
type ColorMode,
1111
type Font,
12-
MODE_OPTIONS,
12+
COLOR_MODE_OPTIONS,
1313
FONT_OPTIONS,
1414
} from "@/contexts/theme-context";
1515

16-
const RESOLVED_MODE_SHORT: Record<ResolvedMode, string> = {
16+
const RESOLVED_MODE_SHORT: Record<ResolvedColorMode, string> = {
1717
light: "Light",
1818
dark: "Dark",
1919
};
@@ -23,7 +23,7 @@ const RESOLVED_MODE_SHORT: Record<ResolvedMode, string> = {
2323
* tones; system splits the two to signal "follows the OS".
2424
* Kept as static hex previews so they render before any stylesheet loads.
2525
*/
26-
const MODE_PREVIEW: Record<Mode, { readonly a: `#${string}`; readonly b: `#${string}` }> = {
26+
const MODE_PREVIEW: Record<ColorMode, { readonly a: `#${string}`; readonly b: `#${string}` }> = {
2727
light: { a: "#ffffff", b: "#d4d4d8" },
2828
dark: { a: "#3f3f46", b: "#71717a" },
2929
system: { a: "#ffffff", b: "#3f3f46" },
@@ -36,8 +36,8 @@ const FONT_PREVIEW: Record<Font, string> = {
3636
inter: '"Inter Variable", "Inter", ui-sans-serif, system-ui, sans-serif',
3737
};
3838

39-
function isMode(value: string): value is Mode {
40-
return MODE_OPTIONS.some((o) => o.value === value);
39+
function isMode(value: string): value is ColorMode {
40+
return COLOR_MODE_OPTIONS.some((o) => o.value === value);
4141
}
4242

4343
function isFont(value: string): value is Font {
@@ -56,7 +56,7 @@ function radioItemClasses(active: boolean) {
5656
);
5757
}
5858

59-
function ModePreviewSwatches({ modeId }: { modeId: Mode }) {
59+
function ModePreviewSwatches({ modeId }: { modeId: ColorMode }) {
6060
const { a, b } = MODE_PREVIEW[modeId];
6161
return (
6262
<div
@@ -97,7 +97,7 @@ function FontPreview({ fontId }: { fontId: Font }) {
9797
* shown here. **System** stays explicit on the mode axis.
9898
*/
9999
function ThemeSwitcher() {
100-
const { mode, resolvedMode, setMode } = useMode();
100+
const { mode, resolvedMode, setMode } = useColorMode();
101101
const { font, setFont } = useFont();
102102

103103
const fontLabel = FONT_OPTIONS.find((o) => o.value === font)?.label ?? font;
@@ -135,7 +135,7 @@ function ThemeSwitcher() {
135135
if (typeof value === "string" && isMode(value)) setMode(value);
136136
}}
137137
>
138-
{MODE_OPTIONS.map((opt) => (
138+
{COLOR_MODE_OPTIONS.map((opt) => (
139139
<Menu.RadioItem
140140
key={opt.value}
141141
value={opt.value}

packages/core/src/contexts/theme-context.test.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { cleanup, render, act, waitFor } from "@testing-library/react";
22
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
33

4-
import { ThemeProvider, useMode, useTheme, useFont } from "./theme-context";
4+
import { ThemeProvider, useColorMode, useTheme, useFont } from "./theme-context";
55

66
/** happy-dom / Node can omit a full `localStorage`; ThemeProvider persists via it. */
77
function installLocalStorageStub() {
@@ -72,7 +72,7 @@ afterEach(() => {
7272
});
7373

7474
function Probe() {
75-
const { mode, resolvedMode } = useMode();
75+
const { mode, resolvedMode } = useColorMode();
7676
const { theme } = useTheme();
7777
return (
7878
<div>
@@ -89,11 +89,11 @@ function FontProbe() {
8989
}
9090

9191
describe("ThemeProvider — storage validation", () => {
92-
it("falls back to defaultMode for an unrecognized stored mode", () => {
92+
it("falls back to defaultColorMode for an unrecognized stored mode", () => {
9393
storageMap.set("appshell-ui-mode", "totally-not-a-mode");
9494

9595
const { getByTestId } = render(
96-
<ThemeProvider defaultMode="dark">
96+
<ThemeProvider defaultColorMode="dark">
9797
<Probe />
9898
</ThemeProvider>,
9999
);
@@ -235,13 +235,13 @@ describe("ThemeProvider — system mode resolution", () => {
235235
});
236236

237237
describe("provider guards", () => {
238-
it("throws when useMode is called outside ThemeProvider", () => {
238+
it("throws when useColorMode is called outside ThemeProvider", () => {
239239
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
240240
const ModeProbe = () => {
241-
useMode();
241+
useColorMode();
242242
return null;
243243
};
244-
expect(() => render(<ModeProbe />)).toThrow(/useMode must be used within a ThemeProvider/);
244+
expect(() => render(<ModeProbe />)).toThrow(/useColorMode must be used within a ThemeProvider/);
245245
spy.mockRestore();
246246
});
247247

packages/core/src/contexts/theme-context.tsx

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,17 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState }
44
* Color mode — the end-user accessibility preference. `system` follows the OS
55
* light/dark setting. Applied to `<html>` as the `.light` / `.dark` class.
66
*/
7-
export type Mode = "light" | "dark" | "system";
7+
export type ColorMode = "light" | "dark" | "system";
88

99
/** Mode after resolving `system` to a concrete value. */
10-
export type ResolvedMode = "light" | "dark";
10+
export type ResolvedColorMode = "light" | "dark";
1111

12-
const ALL_MODES: readonly Mode[] = ["light", "dark", "system"] as const;
12+
const ALL_COLOR_MODES: readonly ColorMode[] = ["light", "dark", "system"] as const;
1313

1414
/** Switcher entries for the appearance (mode) control. */
15-
export type ModeOption = { readonly value: Mode; readonly label: string };
15+
export type ColorModeOption = { readonly value: ColorMode; readonly label: string };
1616

17-
export const MODE_OPTIONS: readonly ModeOption[] = [
17+
export const COLOR_MODE_OPTIONS: readonly ColorModeOption[] = [
1818
{ value: "light", label: "Light" },
1919
{ value: "dark", label: "Dark" },
2020
{ value: "system", label: "System" },
@@ -23,7 +23,7 @@ export const MODE_OPTIONS: readonly ModeOption[] = [
2323
/**
2424
* Color palette / brand — a developer/product configuration (not a user-facing
2525
* picker). Each palette ships both a light and a dark variant; the active
26-
* variant is chosen by the {@link Mode} axis. Applied to `<html>` as `data-theme`.
26+
* variant is chosen by the {@link ColorMode} axis. Applied to `<html>` as `data-theme`.
2727
*/
2828
export type Theme = "default" | "cream" | "bloom";
2929

@@ -64,17 +64,17 @@ function readStored<T extends string>(key: string, allowList: readonly T[], fall
6464
type ThemeProviderProps = {
6565
children: React.ReactNode;
6666
/** Initial color mode (user preference). @default "system" */
67-
defaultMode?: Mode;
67+
defaultColorMode?: ColorMode;
6868
/** Color palette / brand (developer config). @default "default" */
6969
defaultTheme?: Theme;
7070
/** Initial font. @default "geist" */
7171
defaultFont?: Font;
7272
};
7373

7474
type ThemeProviderState = {
75-
mode: Mode;
76-
resolvedMode: ResolvedMode;
77-
setMode: (mode: Mode) => void;
75+
mode: ColorMode;
76+
resolvedMode: ResolvedColorMode;
77+
setMode: (mode: ColorMode) => void;
7878
theme: Theme;
7979
setTheme: (theme: Theme) => void;
8080
font: Font;
@@ -85,12 +85,12 @@ const ThemeProviderContext = createContext<ThemeProviderState | undefined>(undef
8585

8686
export function ThemeProvider({
8787
children,
88-
defaultMode = "system",
88+
defaultColorMode = "system",
8989
defaultTheme = "default",
9090
defaultFont = "geist",
9191
}: ThemeProviderProps) {
92-
const [mode, setModeState] = useState<Mode>(() =>
93-
readStored(MODE_STORAGE_KEY, ALL_MODES, defaultMode),
92+
const [mode, setModeState] = useState<ColorMode>(() =>
93+
readStored(MODE_STORAGE_KEY, ALL_COLOR_MODES, defaultColorMode),
9494
);
9595
// Palette is a developer configuration, not a user preference — it comes from
9696
// `defaultTheme` (the brand the product chose) and is NOT read from / persisted
@@ -115,7 +115,7 @@ export function ThemeProvider({
115115
return () => mql.removeEventListener("change", handler);
116116
}, []);
117117

118-
const resolvedMode: ResolvedMode = (() => {
118+
const resolvedMode: ResolvedColorMode = (() => {
119119
if (mode !== "system") return mode;
120120
return systemDark ? "dark" : "light";
121121
})();
@@ -133,7 +133,7 @@ export function ThemeProvider({
133133
window.document.documentElement.dataset.font = font;
134134
}, [font]);
135135

136-
const setMode = useCallback((newMode: Mode) => {
136+
const setMode = useCallback((newMode: ColorMode) => {
137137
try {
138138
localStorage.setItem(MODE_STORAGE_KEY, newMode);
139139
} catch {
@@ -164,9 +164,9 @@ export function ThemeProvider({
164164
}
165165

166166
/** Color mode (light / dark / system) — the end-user accessibility preference. */
167-
export const useMode = () => {
167+
export const useColorMode = () => {
168168
const context = useContext(ThemeProviderContext);
169-
if (context === undefined) throw new Error("useMode must be used within a ThemeProvider");
169+
if (context === undefined) throw new Error("useColorMode must be used within a ThemeProvider");
170170

171171
const { mode, resolvedMode, setMode } = context;
172172
return useMemo(() => ({ mode, resolvedMode, setMode }), [mode, resolvedMode, setMode]);

packages/core/src/index.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,15 @@ export { WithGuard, type WithGuardProps } from "./components/with-guard";
2525

2626
export { useAppShell, useAppShellConfig, useAppShellData } from "./contexts/appshell-context";
2727
export {
28-
useMode,
28+
useColorMode,
2929
useTheme,
3030
useFont,
31-
MODE_OPTIONS,
31+
COLOR_MODE_OPTIONS,
3232
THEME_OPTIONS,
3333
FONT_OPTIONS,
34-
type Mode,
35-
type ResolvedMode,
36-
type ModeOption,
34+
type ColorMode,
35+
type ResolvedColorMode,
36+
type ColorModeOption,
3737
type Theme,
3838
type ThemeOption,
3939
type Font,

0 commit comments

Comments
 (0)