Skip to content

Commit 4bf0f7f

Browse files
itspradeclaude
authored andcommitted
feat(theme): split theming into mode and palette axes
Introduce two independent theming axes: mode (light/dark/system) as the end-user accessibility preference via useMode, and palette/theme (default/cream/bloom) as a developer configuration via useTheme. Mode is persisted; palette is prop-driven. The <html> element carries mode as the .light/.dark class and palette as data-theme. The font axis is untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4a74017 commit 4bf0f7f

9 files changed

Lines changed: 285 additions & 114 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@tailor-platform/app-shell": minor
3+
---
4+
5+
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.
6+
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.
9+
- DOM: `<html>` carries the mode as the `.light` / `.dark` class and the palette as `data-theme`.
10+
- CSS: `light.css` + `dark.css` are merged into `default.css`; **Cream** and **Bloom** gain dark variants. The shell gradient is parameterized per mode.
11+
- The appearance switcher now offers Light / Dark / System only — the palette is a developer configuration. The font axis is unchanged.

examples/nextjs-app/src/app/dashboard/[[...props]]/page.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const App = () => {
1313
const appShellConfig: AppShellProps = {
1414
title: "AppShell",
1515
basePath: "dashboard",
16+
defaultTheme: "cream",
1617
modules: [customPageModule],
1718
rootComponent: () => <div>Dashboard Home (accessible at /dashboard)</div>,
1819
settingsResources: [profileResource],

examples/vite-app/src/App.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ const searchSources: SearchSource[] = [
2525

2626
const App = () => {
2727
return (
28-
<AppShell title="File-Based Routing Demo" searchSources={searchSources}>
28+
<AppShell title="File-Based Routing Demo" searchSources={searchSources} defaultTheme="bloom">
2929
<SidebarLayout
3030
sidebar={
3131
<DefaultSidebar>

packages/core/src/components/appshell.tsx

Lines changed: 22 additions & 8 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 Theme, type Font } from "@/contexts/theme-context";
20+
import { ThemeProvider, type Mode, 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";
@@ -179,19 +179,29 @@ type SharedAppShellProps = React.PropsWithChildren<{
179179
searchSources?: readonly SearchSource[];
180180

181181
/**
182-
* Initial theme before any value is loaded from localStorage (`appshell-ui-theme`).
183-
* Does not replace a stored preference.
182+
* Initial color mode before any value is loaded from localStorage (`appshell-ui-mode`).
183+
* This is the end-user accessibility preference; does not replace a stored preference.
184184
*
185-
* Named palettes **`cream`**, **`bloom`**, plus default **`light`** / **`dark`**, in addition to
186-
* `system` (OS preference maps to **default** light or dark only — not cream or bloom).
185+
* One of **`light`**, **`dark`**, or **`system`** (follows the OS). Works with any palette.
187186
*
188-
* @default "bloom"
187+
* @default "system"
188+
*/
189+
defaultMode?: Mode;
190+
191+
/**
192+
* Color palette / brand (developer configuration), applied as `data-theme`.
193+
* One of **`default`**, **`cream`**, or **`bloom`** — each ships both light and dark
194+
* variants, so the active variant follows {@link AppShellProps.defaultMode}.
195+
* This is set by the product (not a user-facing picker), so it is driven by this
196+
* prop and not persisted to localStorage.
197+
*
198+
* @default "default"
189199
*/
190200
defaultTheme?: Theme;
191201

192202
/**
193203
* Initial font axis before any value is loaded from localStorage (`appshell-ui-font`).
194-
* Independent of `defaultTheme`; any color theme works with either font.
204+
* Independent of `defaultTheme`; any palette works with either font.
195205
*
196206
* @default "geist"
197207
*/
@@ -339,7 +349,11 @@ export const AppShell = (props: AppShellProps) => {
339349
<AppShellDataContext.Provider value={dataValue}>
340350
<BreadcrumbOverrideProvider>
341351
<CommandPaletteProvider searchSources={props.searchSources}>
342-
<ThemeProvider defaultTheme={props.defaultTheme} defaultFont={props.defaultFont}>
352+
<ThemeProvider
353+
defaultMode={props.defaultMode}
354+
defaultTheme={props.defaultTheme}
355+
defaultFont={props.defaultFont}
356+
>
343357
<RouterContainer>
344358
{props.children}
345359
<BuiltInCommandPalette />

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

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

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

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

@@ -30,6 +30,22 @@ function installLocalStorageStub() {
3030
return map;
3131
}
3232

33+
function installMatchMediaStub(matches: boolean) {
34+
Object.defineProperty(window, "matchMedia", {
35+
configurable: true,
36+
value: vi.fn().mockImplementation((query: string) => ({
37+
matches,
38+
media: query,
39+
onchange: null,
40+
addListener: vi.fn(),
41+
removeListener: vi.fn(),
42+
addEventListener: vi.fn(),
43+
removeEventListener: vi.fn(),
44+
dispatchEvent: vi.fn(),
45+
})),
46+
});
47+
}
48+
3349
let storageMap: Map<string, string>;
3450

3551
beforeAll(() => {
@@ -38,18 +54,22 @@ beforeAll(() => {
3854

3955
beforeEach(() => {
4056
storageMap.clear();
57+
installMatchMediaStub(false);
58+
document.documentElement.removeAttribute("data-theme");
59+
document.documentElement.removeAttribute("data-font");
60+
document.documentElement.classList.remove("light", "dark");
4161
});
4262

4363
afterEach(() => {
4464
cleanup();
4565
});
4666

4767
describe("ThemeSwitcher", () => {
48-
it("opens a menu listing every theme and font option", async () => {
68+
it("opens a menu listing every mode and font option", async () => {
4969
const user = userEvent.setup();
5070

5171
render(
52-
<ThemeProvider defaultTheme="light">
72+
<ThemeProvider defaultMode="light">
5373
<ThemeSwitcher />
5474
</ThemeProvider>,
5575
);
@@ -58,21 +78,40 @@ describe("ThemeSwitcher", () => {
5878

5979
await waitFor(() => {
6080
expect(screen.getAllByRole("menuitemradio").length).toBe(
61-
THEME_OPTIONS.length + FONT_OPTIONS.length,
81+
MODE_OPTIONS.length + FONT_OPTIONS.length,
6282
);
6383
});
6484

65-
for (const opt of THEME_OPTIONS) {
85+
for (const opt of MODE_OPTIONS) {
6686
expect(screen.getByRole("menuitemradio", { name: opt.label })).toBeDefined();
6787
}
6888
for (const opt of FONT_OPTIONS) {
6989
expect(screen.getByRole("menuitemradio", { name: opt.label })).toBeDefined();
7090
}
7191
});
7292

73-
it("exposes resolved palette on the trigger when system mode is selected", () => {
93+
it("does not list color palettes (palette is a developer config)", async () => {
94+
const user = userEvent.setup();
95+
96+
render(
97+
<ThemeProvider>
98+
<ThemeSwitcher />
99+
</ThemeProvider>,
100+
);
101+
102+
await user.click(screen.getByRole("button", { name: "Appearance" }));
103+
104+
await waitFor(() => {
105+
expect(screen.getByRole("menu")).toBeDefined();
106+
});
107+
108+
expect(screen.queryByRole("menuitemradio", { name: "Cream" })).toBeNull();
109+
expect(screen.queryByRole("menuitemradio", { name: "Bloom" })).toBeNull();
110+
});
111+
112+
it("exposes the resolved mode on the trigger when system mode is selected", () => {
74113
render(
75-
<ThemeProvider defaultTheme="system">
114+
<ThemeProvider defaultMode="system">
76115
<ThemeSwitcher />
77116
</ThemeProvider>,
78117
);
@@ -82,11 +121,11 @@ describe("ThemeSwitcher", () => {
82121
expect(btn.getAttribute("title")).toMatch(/currently light|currently dark/i);
83122
});
84123

85-
it("applies selected palette when a radio item is activated", async () => {
124+
it("applies the selected mode when a radio item is activated", async () => {
86125
const user = userEvent.setup();
87126

88127
render(
89-
<ThemeProvider defaultTheme="light">
128+
<ThemeProvider defaultMode="light">
90129
<ThemeSwitcher />
91130
</ThemeProvider>,
92131
);
@@ -97,19 +136,19 @@ describe("ThemeSwitcher", () => {
97136
expect(screen.getByRole("menu")).toBeDefined();
98137
});
99138

100-
await user.click(screen.getByRole("menuitemradio", { name: "Bloom" }));
139+
await user.click(screen.getByRole("menuitemradio", { name: "Dark" }));
101140

102141
await waitFor(() => {
103-
expect(document.documentElement.dataset.theme).toBe("bloom");
142+
expect(document.documentElement.classList.contains("dark")).toBe(true);
104143
});
105-
expect(localStorage.getItem("appshell-ui-theme")).toBe("bloom");
144+
expect(localStorage.getItem("appshell-ui-mode")).toBe("dark");
106145
});
107146

108147
it("applies selected font when a font radio item is activated", async () => {
109148
const user = userEvent.setup();
110149

111150
render(
112-
<ThemeProvider defaultTheme="light" defaultFont="geist">
151+
<ThemeProvider defaultMode="light" defaultFont="geist">
113152
<ThemeSwitcher />
114153
</ThemeProvider>,
115154
);

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

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

16-
const RESOLVED_THEME_SHORT: Record<ResolvedTheme, string> = {
16+
const RESOLVED_MODE_SHORT: Record<ResolvedMode, string> = {
1717
light: "Light",
1818
dark: "Dark",
19-
cream: "Cream",
20-
bloom: "Bloom",
2119
};
2220

2321
/**
24-
* Decorative dual swatches — approximates each palette pair (accent + neutral)
25-
* for the picker grid.
26-
* Kept as static hex previews so the swatches render even before a theme stylesheet has loaded;
27-
* keep these in sync with the palette tokens in `assets/theme.css`.
22+
* Decorative dual swatches for the mode picker. Light/dark show their surface
23+
* tones; system splits the two to signal "follows the OS".
24+
* Kept as static hex previews so they render before any stylesheet loads.
2825
*/
29-
const THEME_PREVIEW: Record<Theme, { readonly a: `#${string}`; readonly b: `#${string}` }> = {
26+
const MODE_PREVIEW: Record<Mode, { readonly a: `#${string}`; readonly b: `#${string}` }> = {
3027
light: { a: "#ffffff", b: "#d4d4d8" },
31-
dark: { a: "#3f3f46", b: "#d4d4d8" },
32-
cream: { a: "#f8f3e4", b: "#e2d4fe" },
33-
bloom: { a: "#535ae8", b: "#f8f3e4" },
34-
system: { a: "#52525b", b: "#7c73e6" },
28+
dark: { a: "#3f3f46", b: "#71717a" },
29+
system: { a: "#ffffff", b: "#3f3f46" },
3530
};
3631

3732
/** Font preview — `font-family` for the "Aa" sample so users see the face before selecting.
@@ -41,15 +36,15 @@ const FONT_PREVIEW: Record<Font, string> = {
4136
inter: '"Inter Variable", "Inter", ui-sans-serif, system-ui, sans-serif',
4237
};
4338

44-
function isTheme(value: string): value is Theme {
45-
return THEME_OPTIONS.some((o) => o.value === value);
39+
function isMode(value: string): value is Mode {
40+
return MODE_OPTIONS.some((o) => o.value === value);
4641
}
4742

4843
function isFont(value: string): value is Font {
4944
return FONT_OPTIONS.some((o) => o.value === value);
5045
}
5146

52-
/** Shared radio-item chrome — used by both the color and font grids. */
47+
/** Shared radio-item chrome — used by both the mode and font grids. */
5348
function radioItemClasses(active: boolean) {
5449
return cn(
5550
"astw:relative astw:flex astw:h-auto astw:w-full astw:cursor-default astw:select-none astw:flex-col astw:items-center astw:justify-center astw:gap-1.5 astw:rounded-xl astw:border-0 astw:bg-transparent astw:px-2 astw:py-2 astw:text-center astw:text-xs astw:font-medium astw:leading-tight astw:outline-hidden",
@@ -61,8 +56,8 @@ function radioItemClasses(active: boolean) {
6156
);
6257
}
6358

64-
function ThemePreviewSwatches({ themeId }: { themeId: Theme }) {
65-
const { a, b } = THEME_PREVIEW[themeId];
59+
function ModePreviewSwatches({ modeId }: { modeId: Mode }) {
60+
const { a, b } = MODE_PREVIEW[modeId];
6661
return (
6762
<div
6863
className="astw:flex astw:h-10 astw:w-full astw:max-w-[5.5rem] astw:items-center astw:justify-center astw:gap-1.5 astw:rounded-md astw:border astw:border-border/80 astw:bg-card astw:px-2"
@@ -97,18 +92,19 @@ function FontPreview({ fontId }: { fontId: Font }) {
9792
}
9893

9994
/**
100-
* Appearance menu: two independent axes — color palette (top) and font (bottom).
101-
* Bound to stored `theme` and `font`; **System** stays explicit on the color axis.
95+
* Appearance menu. The end-user controls **mode** (light / dark / system) and
96+
* **font**; the color palette/brand is a developer configuration, so it is not
97+
* shown here. **System** stays explicit on the mode axis.
10298
*/
10399
function ThemeSwitcher() {
104-
const { theme, resolvedTheme, setTheme } = useTheme();
100+
const { mode, resolvedMode, setMode } = useMode();
105101
const { font, setFont } = useFont();
106102

107103
const fontLabel = FONT_OPTIONS.find((o) => o.value === font)?.label ?? font;
108104
const triggerTitle =
109-
theme === "system"
110-
? `Following system — currently ${RESOLVED_THEME_SHORT[resolvedTheme]} · ${fontLabel}`
111-
: "Choose appearance — color + font";
105+
mode === "system"
106+
? `Following system — currently ${RESOLVED_MODE_SHORT[resolvedMode]} · ${fontLabel}`
107+
: "Choose appearance — mode + font";
112108

113109
return (
114110
<Menu.Root modal={false}>
@@ -131,24 +127,24 @@ function ThemeSwitcher() {
131127
className="astw:min-w-[16.5rem] astw:rounded-xl astw:p-3"
132128
>
133129
<Menu.Group>
134-
<Menu.GroupLabel className="astw:px-0 astw:pb-2 astw:pt-0">Colors</Menu.GroupLabel>
130+
<Menu.GroupLabel className="astw:px-0 astw:pb-2 astw:pt-0">Appearance</Menu.GroupLabel>
135131
<Menu.RadioGroup
136132
className="astw:grid astw:grid-cols-3 astw:gap-2"
137-
value={theme}
133+
value={mode}
138134
onValueChange={(value) => {
139-
if (typeof value === "string" && isTheme(value)) setTheme(value);
135+
if (typeof value === "string" && isMode(value)) setMode(value);
140136
}}
141137
>
142-
{THEME_OPTIONS.map((opt) => (
138+
{MODE_OPTIONS.map((opt) => (
143139
<Menu.RadioItem
144140
key={opt.value}
145141
value={opt.value}
146-
className={radioItemClasses(theme === opt.value)}
142+
className={radioItemClasses(mode === opt.value)}
147143
>
148144
<Menu.RadioItemIndicator className="astw:sr-only">
149145
{opt.label}
150146
</Menu.RadioItemIndicator>
151-
<ThemePreviewSwatches themeId={opt.value} />
147+
<ModePreviewSwatches modeId={opt.value} />
152148
<span className="astw:block astw:w-full astw:truncate astw:px-0.5">
153149
{opt.label}
154150
</span>

0 commit comments

Comments
 (0)