Skip to content

Commit ec264a9

Browse files
committed
feat(themes): theme foundation — palettes, ThemeProvider refactor, font axis
- Add cream/bloom color palettes with CSS custom properties (theme.css) - Shell gradient, font-axis CSS, squircle corners, autofill overrides (globals.css) - Refactor ThemeProvider: Theme/ResolvedTheme/Font types, useFont hook, legacy ID migration, memoized context - Add defaultTheme/defaultFont props to AppShell - Export new types and hooks from package index - Add ThemeProvider unit tests
1 parent 98270c5 commit ec264a9

6 files changed

Lines changed: 620 additions & 41 deletions

File tree

packages/core/src/assets/theme.css

Lines changed: 143 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,17 @@
3434
--sidebar-accent-foreground: rgba(23, 23, 23, 1);
3535
--sidebar-border: rgba(229, 229, 229, 1);
3636
--sidebar-ring: rgba(163, 163, 163, 1);
37+
/* Status (overridable per theme) */
38+
--status-default: #737373;
39+
--status-neutral: #0ea5e9;
40+
--status-completed: #22c55e;
41+
--status-attention: #f59e0b;
42+
--status-danger: #ef4444;
43+
/* Elevation (wired through @theme; override per Tailor palettes) */
44+
--semantic-shadow-xs: 0 1px 2px 0 rgb(15 23 42 / 0.06);
45+
--semantic-shadow-sm: 0 1px 3px 0 rgb(15 23 42 / 0.08), 0 1px 2px -1px rgb(15 23 42 / 0.08);
46+
--semantic-shadow-md: 0 4px 6px -1px rgb(15 23 42 / 0.09), 0 2px 4px -2px rgb(15 23 42 / 0.06);
47+
--semantic-shadow-lg: 0 10px 15px -3px rgb(15 23 42 / 0.12), 0 4px 6px -4px rgb(15 23 42 / 0.09);
3748
}
3849

3950
.dark {
@@ -62,14 +73,135 @@
6273
--chart-3: rgba(245, 158, 11, 1);
6374
--chart-4: rgba(168, 85, 247, 1);
6475
--chart-5: rgba(244, 63, 94, 1);
65-
--sidebar: rgba(23, 23, 23, 1);
76+
/* Match --background so sidebar blends with the app surface. */
77+
--sidebar: rgba(10, 10, 10, 1);
6678
--sidebar-foreground: rgba(250, 250, 250, 1);
6779
--sidebar-primary: rgba(29, 78, 216, 1);
6880
--sidebar-primary-foreground: rgba(250, 250, 250, 1);
6981
--sidebar-accent: rgba(38, 38, 38, 1);
7082
--sidebar-accent-foreground: rgba(250, 250, 250, 1);
7183
--sidebar-border: rgba(255, 255, 255, 0.10000000149011612);
7284
--sidebar-ring: rgba(82, 82, 82, 1);
85+
--semantic-shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.35);
86+
--semantic-shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.38), 0 1px 2px -1px rgb(0 0 0 / 0.32);
87+
--semantic-shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.35), 0 2px 4px -2px rgb(0 0 0 / 0.28);
88+
--semantic-shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.42), 0 4px 6px -4px rgb(0 0 0 / 0.35);
89+
}
90+
91+
/**
92+
* Tailor brand — light palette (Tailor brand guidelines).
93+
* Primary: #535AE8 · Text: #10122B · White: #FFFFFF
94+
* Secondary: Deep Cyan #00979C · Dark Green #013742 · Off-White #F8F3E4 · Neutral #EEEEEE · Light Violet #E2D4FE
95+
*/
96+
html[data-theme="cream"] {
97+
color-scheme: light;
98+
/* Off-white app shell; white cards for elevation */
99+
--background: rgba(248, 243, 228, 1);
100+
/* Shell gradient stops (globals.css): light cream tint at top → white at bottom. */
101+
--shell-gradient-start: color-mix(in srgb, var(--background) 55%, rgb(255, 255, 255));
102+
--shell-gradient-end: rgb(255, 255, 255);
103+
--foreground: rgba(16, 18, 43, 1);
104+
--card: rgba(255, 255, 255, 1);
105+
--card-foreground: rgba(16, 18, 43, 1);
106+
--popover: rgba(255, 255, 255, 1);
107+
--popover-foreground: rgba(16, 18, 43, 1);
108+
--primary: rgba(83, 90, 232, 1);
109+
--primary-foreground: rgba(255, 255, 255, 1);
110+
/* Light violet surfaces; near-black text (match bloom; drop the brand dark-green pairing). */
111+
--secondary: rgba(226, 212, 254, 1);
112+
--secondary-foreground: rgba(16, 18, 43, 1);
113+
/* Row hovers / `bg-muted`: subtle neutral lift (same alpha family as `--border`), not a second brand tint. */
114+
--muted: rgba(0, 0, 0, 0.08);
115+
--muted-foreground: rgba(16, 18, 43, 0.72);
116+
--accent: rgba(226, 212, 254, 1);
117+
--accent-foreground: rgba(16, 18, 43, 1);
118+
/* Destructive matches default light theme so the brand red stays consistent across light/cream/bloom. */
119+
--destructive: rgba(220, 38, 38, 1);
120+
--destructive-foreground: rgba(254, 242, 242, 1);
121+
--border: rgba(0, 0, 0, 0.08);
122+
--input: rgba(0, 0, 0, 0.08);
123+
--ring: rgba(83, 90, 232, 0.45);
124+
--chart-1: rgba(83, 90, 232, 1);
125+
--chart-2: rgba(0, 151, 156, 1);
126+
--chart-3: rgba(1, 55, 66, 1);
127+
--chart-4: rgba(110, 95, 195, 1);
128+
--chart-5: rgba(217, 119, 6, 1);
129+
/* Larger corner scale; squircle overlays via globals.css where supported */
130+
--radius: 1rem;
131+
--sidebar: rgba(248, 243, 228, 1);
132+
--sidebar-foreground: rgba(16, 18, 43, 1);
133+
--sidebar-primary: rgba(83, 90, 232, 1);
134+
--sidebar-primary-foreground: rgba(255, 255, 255, 1);
135+
--sidebar-accent: rgba(226, 212, 254, 1);
136+
--sidebar-accent-foreground: rgba(16, 18, 43, 1);
137+
--sidebar-border: rgba(0, 0, 0, 0.08);
138+
--sidebar-ring: rgba(83, 90, 232, 0.45);
139+
--status-default: rgba(16, 18, 43, 0.55);
140+
--status-neutral: #00979c;
141+
--status-completed: #00979c;
142+
--status-attention: #d97706;
143+
--status-danger: #dc2626;
144+
--semantic-shadow-xs: 0 1px 2px 0 rgb(16 18 43 / 0.07);
145+
--semantic-shadow-sm: 0 1px 3px 0 rgb(16 18 43 / 0.08), 0 1px 2px -1px rgb(16 18 43 / 0.06);
146+
--semantic-shadow-md: 0 4px 6px -1px rgb(16 18 43 / 0.1), 0 2px 4px -2px rgb(16 18 43 / 0.08);
147+
--semantic-shadow-lg: 0 10px 15px -3px rgb(16 18 43 / 0.12), 0 4px 6px -4px rgb(83 90 232 / 0.1);
148+
}
149+
150+
/**
151+
* Bloom — light lavender app shell; cream secondary/accent surfaces (inverse of Tailor light).
152+
*/
153+
html[data-theme="bloom"] {
154+
color-scheme: light;
155+
--background: rgba(239, 232, 255, 1);
156+
/* Shell gradient stops (globals.css): light lavender tint at top → white at bottom. */
157+
--shell-gradient-start: color-mix(in srgb, var(--background) 55%, rgb(255, 255, 255));
158+
--shell-gradient-end: rgb(255, 255, 255);
159+
--foreground: rgba(16, 18, 43, 1);
160+
--card: rgba(255, 255, 255, 1);
161+
--card-foreground: rgba(16, 18, 43, 1);
162+
--popover: rgba(255, 255, 255, 1);
163+
--popover-foreground: rgba(16, 18, 43, 1);
164+
--primary: rgba(83, 90, 232, 1);
165+
--primary-foreground: rgba(255, 255, 255, 1);
166+
/* Match light theme — neutral pale grey reads as a soft pill on the lavender shell. */
167+
--secondary: rgba(245, 245, 245, 1);
168+
--secondary-foreground: rgba(23, 23, 23, 1);
169+
/* Row hovers / `bg-muted`: subtle neutral lift (same alpha family as `--border`). */
170+
--muted: rgba(0, 0, 0, 0.08);
171+
--muted-foreground: rgba(16, 18, 43, 0.72);
172+
--accent: rgba(248, 243, 228, 1);
173+
--accent-foreground: rgba(16, 18, 43, 1);
174+
/* Destructive matches default light theme so the brand red stays consistent across light/cream/bloom. */
175+
--destructive: rgba(220, 38, 38, 1);
176+
--destructive-foreground: rgba(254, 242, 242, 1);
177+
--border: rgba(0, 0, 0, 0.08);
178+
--input: rgba(0, 0, 0, 0.08);
179+
--ring: rgba(83, 90, 232, 0.45);
180+
--chart-1: rgba(83, 90, 232, 1);
181+
--chart-2: rgba(0, 151, 156, 1);
182+
--chart-3: rgba(1, 55, 66, 1);
183+
--chart-4: rgba(110, 95, 195, 1);
184+
--chart-5: rgba(217, 119, 6, 1);
185+
--radius: 1rem;
186+
--sidebar: rgba(239, 232, 255, 1);
187+
--sidebar-foreground: rgba(16, 18, 43, 1);
188+
--sidebar-primary: rgba(83, 90, 232, 1);
189+
--sidebar-primary-foreground: rgba(255, 255, 255, 1);
190+
/* White elevated row on lavender shell (readable vs lavender-on-lavender) */
191+
--sidebar-accent: rgba(255, 255, 255, 1);
192+
--sidebar-accent-foreground: rgba(16, 18, 43, 1);
193+
--sidebar-border: rgba(0, 0, 0, 0.08);
194+
--sidebar-ring: rgba(83, 90, 232, 0.45);
195+
--status-default: rgba(16, 18, 43, 0.55);
196+
/* Bloom: teal + violet-tint foregrounds; amber + dusty rose harmonize with shell */
197+
--status-neutral: #0b8c9a;
198+
--status-completed: #0d7668;
199+
--status-attention: #ae6f12;
200+
--status-danger: #ae2438;
201+
--semantic-shadow-xs: 0 1px 2px 0 rgb(16 18 43 / 0.07);
202+
--semantic-shadow-sm: 0 1px 3px 0 rgb(16 18 43 / 0.08), 0 1px 2px -1px rgb(16 18 43 / 0.06);
203+
--semantic-shadow-md: 0 4px 6px -1px rgb(16 18 43 / 0.1), 0 2px 4px -2px rgb(16 18 43 / 0.08);
204+
--semantic-shadow-lg: 0 10px 15px -3px rgb(16 18 43 / 0.12), 0 4px 6px -4px rgb(83 90 232 / 0.1);
73205
}
74206

75207
@theme inline {
@@ -110,10 +242,14 @@
110242
--color-sidebar-border: var(--sidebar-border);
111243
--color-sidebar-ring: var(--sidebar-ring);
112244

113-
/* Statuses */
114-
--color-status-default: #737373;
115-
--color-status-neutral: #0ea5e9;
116-
--color-status-completed: #22c55e;
117-
--color-status-attention: #f59e0b;
118-
--color-status-danger: #ef4444;
245+
--color-status-default: var(--status-default);
246+
--color-status-neutral: var(--status-neutral);
247+
--color-status-completed: var(--status-completed);
248+
--color-status-attention: var(--status-attention);
249+
--color-status-danger: var(--status-danger);
250+
251+
--shadow-xs: var(--semantic-shadow-xs);
252+
--shadow-sm: var(--semantic-shadow-sm);
253+
--shadow-md: var(--semantic-shadow-md);
254+
--shadow-lg: var(--semantic-shadow-lg);
119255
}

packages/core/src/components/appshell.tsx

Lines changed: 26 additions & 2 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 } from "@/contexts/theme-context";
20+
import { ThemeProvider, 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";
@@ -177,6 +177,25 @@ type SharedAppShellProps = React.PropsWithChildren<{
177177
* ```
178178
*/
179179
searchSources?: readonly SearchSource[];
180+
181+
/**
182+
* Initial theme before any value is loaded from localStorage (`appshell-ui-theme`).
183+
* Does not replace a stored preference.
184+
*
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).
187+
*
188+
* @default "bloom"
189+
*/
190+
defaultTheme?: Theme;
191+
192+
/**
193+
* Initial font axis before any value is loaded from localStorage (`appshell-ui-font`).
194+
* Independent of `defaultTheme`; any color theme works with either font.
195+
*
196+
* @default "geist"
197+
*/
198+
defaultFont?: Font;
180199
}>;
181200

182201
/**
@@ -320,7 +339,12 @@ export const AppShell = (props: AppShellProps) => {
320339
<AppShellDataContext.Provider value={dataValue}>
321340
<BreadcrumbOverrideProvider>
322341
<CommandPaletteProvider searchSources={props.searchSources}>
323-
<ThemeProvider defaultTheme="system" storageKey="appshell-ui-theme">
342+
<ThemeProvider
343+
defaultTheme={props.defaultTheme}
344+
defaultFont={props.defaultFont}
345+
storageKey="appshell-ui-theme"
346+
fontStorageKey="appshell-ui-font"
347+
>
324348
<RouterContainer>
325349
{props.children}
326350
<BuiltInCommandPalette />
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { cleanup, render, waitFor } from "@testing-library/react";
2+
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
import { ThemeProvider, useTheme, useFont } from "./theme-context";
5+
6+
const storageKey = "theme-context-test-theme";
7+
const fontStorageKey = "theme-context-test-font";
8+
9+
/** happy-dom / Node can omit a full `localStorage`; ThemeProvider persists via it. */
10+
function installLocalStorageStub() {
11+
const map = new Map<string, string>();
12+
const ls = {
13+
getItem: (k: string) => map.get(k) ?? null,
14+
setItem: (k: string, v: string) => {
15+
map.set(k, v);
16+
},
17+
removeItem: (k: string) => {
18+
map.delete(k);
19+
},
20+
clear: () => map.clear(),
21+
key: (i: number) => [...map.keys()][i] ?? null,
22+
get length() {
23+
return map.size;
24+
},
25+
};
26+
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: ls });
27+
return map;
28+
}
29+
30+
/** `matchMedia` is not implemented in some test runtimes — stub to a controllable shape. */
31+
function installMatchMediaStub(matches: boolean) {
32+
Object.defineProperty(window, "matchMedia", {
33+
configurable: true,
34+
value: vi.fn().mockImplementation((query: string) => ({
35+
matches,
36+
media: query,
37+
onchange: null,
38+
addListener: vi.fn(),
39+
removeListener: vi.fn(),
40+
addEventListener: vi.fn(),
41+
removeEventListener: vi.fn(),
42+
dispatchEvent: vi.fn(),
43+
})),
44+
});
45+
}
46+
47+
let storageMap: Map<string, string>;
48+
49+
beforeAll(() => {
50+
storageMap = installLocalStorageStub();
51+
});
52+
53+
beforeEach(() => {
54+
storageMap.clear();
55+
document.documentElement.removeAttribute("data-theme");
56+
document.documentElement.removeAttribute("data-font");
57+
document.documentElement.classList.remove("light", "dark");
58+
});
59+
60+
afterEach(() => {
61+
cleanup();
62+
});
63+
64+
function ThemeProbe() {
65+
const { theme, resolvedTheme } = useTheme();
66+
return (
67+
<div>
68+
<span data-testid="theme">{theme}</span>
69+
<span data-testid="resolved">{resolvedTheme}</span>
70+
</div>
71+
);
72+
}
73+
74+
function FontProbe() {
75+
const { font } = useFont();
76+
return <span data-testid="font">{font}</span>;
77+
}
78+
79+
describe("ThemeProvider — legacy id migration", () => {
80+
it.each([
81+
["tailor-light", "cream"],
82+
["tailor-bloom", "bloom"],
83+
["tailor-dark", "dark"],
84+
])("maps stored %s → %s on first render", async (stored, expected) => {
85+
storageMap.set(storageKey, stored);
86+
87+
const { getByTestId } = render(
88+
<ThemeProvider storageKey={storageKey} fontStorageKey={fontStorageKey} defaultTheme="bloom">
89+
<ThemeProbe />
90+
</ThemeProvider>,
91+
);
92+
93+
expect(getByTestId("theme").textContent).toBe(expected);
94+
await waitFor(() => {
95+
expect(document.documentElement.dataset.theme).toBe(expected);
96+
});
97+
});
98+
99+
it("falls back to defaultTheme for an unrecognized stored value", () => {
100+
storageMap.set(storageKey, "totally-not-a-theme");
101+
102+
const { getByTestId } = render(
103+
<ThemeProvider storageKey={storageKey} fontStorageKey={fontStorageKey} defaultTheme="light">
104+
<ThemeProbe />
105+
</ThemeProvider>,
106+
);
107+
108+
expect(getByTestId("theme").textContent).toBe("light");
109+
});
110+
111+
it("falls back to defaultFont for an unrecognized stored font", () => {
112+
storageMap.set(fontStorageKey, "wingdings");
113+
114+
const { getByTestId } = render(
115+
<ThemeProvider storageKey={storageKey} fontStorageKey={fontStorageKey} defaultFont="inter">
116+
<FontProbe />
117+
</ThemeProvider>,
118+
);
119+
120+
expect(getByTestId("font").textContent).toBe("inter");
121+
});
122+
});
123+
124+
describe("ThemeProvider — system resolution", () => {
125+
it("resolves system → dark when prefers-color-scheme: dark matches", async () => {
126+
installMatchMediaStub(true);
127+
storageMap.set(storageKey, "system");
128+
129+
const { getByTestId } = render(
130+
<ThemeProvider storageKey={storageKey} fontStorageKey={fontStorageKey}>
131+
<ThemeProbe />
132+
</ThemeProvider>,
133+
);
134+
135+
expect(getByTestId("theme").textContent).toBe("system");
136+
expect(getByTestId("resolved").textContent).toBe("dark");
137+
await waitFor(() => {
138+
expect(document.documentElement.dataset.theme).toBe("dark");
139+
expect(document.documentElement.classList.contains("dark")).toBe(true);
140+
});
141+
});
142+
143+
it("resolves system → light when prefers-color-scheme: dark does not match", async () => {
144+
installMatchMediaStub(false);
145+
storageMap.set(storageKey, "system");
146+
147+
const { getByTestId } = render(
148+
<ThemeProvider storageKey={storageKey} fontStorageKey={fontStorageKey}>
149+
<ThemeProbe />
150+
</ThemeProvider>,
151+
);
152+
153+
expect(getByTestId("resolved").textContent).toBe("light");
154+
await waitFor(() => {
155+
expect(document.documentElement.dataset.theme).toBe("light");
156+
expect(document.documentElement.classList.contains("light")).toBe(true);
157+
});
158+
});
159+
});
160+
161+
describe("useTheme / useFont — provider guard", () => {
162+
it("throws when useTheme is called outside ThemeProvider", () => {
163+
// Silence React's expected error log for this assertion.
164+
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
165+
expect(() => render(<ThemeProbe />)).toThrow(/useTheme must be used within a ThemeProvider/);
166+
spy.mockRestore();
167+
});
168+
169+
it("throws when useFont is called outside ThemeProvider", () => {
170+
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
171+
expect(() => render(<FontProbe />)).toThrow(/useFont must be used within a ThemeProvider/);
172+
spy.mockRestore();
173+
});
174+
});

0 commit comments

Comments
 (0)