Skip to content

Commit ba6c830

Browse files
itspradeclaude
andcommitted
feat(themes): font axis, neutral cream foregrounds, refined cream/bloom shell
- Font axis (Geist default, Inter): `useFont` hook + `data-font` attr, added to ThemeSwitcher as a second axis alongside the color palette. - Cream theme drops the dark-green text-on-violet pairing; secondary / accent / sidebar-accent foregrounds now use the same near-black as bloom. - Cream/Bloom shell gradient: multi-stop fade with pure white covering the bottom 30% for a softer, more blended feel. - Sidebar active item: hairline outline (`var(--border)`) + `--semantic-shadow-xs` drop so the selected row reads as elevated. Rule lives in `@layer utilities` so it wins against Tailwind's `outline-hidden` utility. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c6aa695 commit ba6c830

10 files changed

Lines changed: 370 additions & 78 deletions

File tree

docs/api/use-theme.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,52 @@ The built-in **`ThemeProvider`** (used inside **`AppShell`**) persists the **`th
131131

132132
Use **`AppShell`**’s **`defaultTheme`** prop for the initial value when nothing is stored.
133133

134+
# useFont
135+
136+
Sibling hook to **`useTheme`** that controls the **font axis**, independent of color theme. Any color theme works with either font; users pick them separately in **`ThemeSwitcher`**.
137+
138+
## Signature
139+
140+
```typescript
141+
export type Font = "inter" | "geist";
142+
143+
export type FontOption = { readonly value: Font; readonly label: string };
144+
145+
export const FONT_OPTIONS: readonly FontOption[];
146+
```
147+
148+
```typescript
149+
const useFont: () => {
150+
font: Font;
151+
setFont: (font: Font) => void;
152+
};
153+
```
154+
155+
## Return value
156+
157+
### `font`
158+
159+
- **Type:** `Font`
160+
- **Description:** Stored font preference. Applied to **`<html>`** as **`data-font`**, which the package CSS uses to set **`font-family`** on **`body`** and headings.
161+
162+
### `setFont(font)`
163+
164+
- **Type:** `(font: Font) => void`
165+
- **Description:** Set the font. Persisted to **`localStorage`** (key **`appshell-ui-font`** when using AppShell’s built-in provider).
166+
167+
## Usage
168+
169+
```typescript
170+
import { useFont } from "@tailor-platform/app-shell";
171+
172+
function UseInter() {
173+
const { setFont } = useFont();
174+
return <button onClick={() => setFont("inter")}>Inter</button>;
175+
}
176+
```
177+
178+
Use **`AppShell`**’s **`defaultFont`** prop for the initial value when nothing is stored. Default is **`"geist"`**.
179+
134180
## Related
135181

136182
- [Styling & Theming](../concepts/styling-theming.md)

docs/concepts/styling-theming.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,17 @@ AppShell ships **five** named semantic palettes controlled by **`data-theme`** o
4141

4242
Semantic tokens (**`--background`**, **`--primary`**, **`--border`**, sidebar tokens, **`--semantic-shadow-*`**, statuses, **`--radius`**, …) live in **`theme.css`**; override there or in host CSS keyed off **`html[data-theme='…']`**.
4343

44+
### Font axis
45+
46+
Font is an independent axis from color theme — any palette works with either face.
47+
48+
| Font | When you'd pick it |
49+
| ------- | -------------------------------------------------------- |
50+
| `inter` | Neutral workhorse; matches most ERP / dashboard UIs |
51+
| `geist` | Vercel-flavoured display sans; default for Tailor themes |
52+
53+
Selected font is applied to **`<html>`** as **`data-font`**. CSS in **`globals.css`** sets **`font-family`** on **`body`** and headings off that attribute. Persisted to **`localStorage`** under **`appshell-ui-font`**; pick via **`setFont`** (see **`useFont`**) or **`AppShell`**'s **`defaultFont`** prop (default **`"geist"`**).
54+
4455
## A note on AppShell component class names
4556

4657
AppShell components use Tailwind utility classes for their styling. Tailwind classes are generated at build-time, so stylesheet for AppShell components is already built and is separate to the Tailwind stylesheet generated for your application.

packages/core/src/assets/theme.css

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -97,23 +97,24 @@ html[data-theme="cream"] {
9797
color-scheme: light;
9898
/* Off-white app shell; white cards for elevation */
9999
--background: rgba(248, 243, 228, 1);
100-
/* Bottom stop for fixed shell gradient (globals.css); same cream family, lighter toward white. */
101-
--shell-gradient-end: color-mix(in srgb, var(--background) 40%, rgb(255, 255, 255));
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);
102103
--foreground: rgba(16, 18, 43, 1);
103104
--card: rgba(255, 255, 255, 1);
104105
--card-foreground: rgba(16, 18, 43, 1);
105106
--popover: rgba(255, 255, 255, 1);
106107
--popover-foreground: rgba(16, 18, 43, 1);
107108
--primary: rgba(83, 90, 232, 1);
108109
--primary-foreground: rgba(255, 255, 255, 1);
109-
/* Light violet surfaces; dark green text (brand text-on-violet pairing) */
110+
/* Light violet surfaces; near-black text (match bloom; drop the brand dark-green pairing). */
110111
--secondary: rgba(226, 212, 254, 1);
111-
--secondary-foreground: rgba(1, 55, 66, 1);
112+
--secondary-foreground: rgba(16, 18, 43, 1);
112113
/* Row hovers / `bg-muted`: subtle neutral lift (same alpha family as `--border`), not a second brand tint. */
113114
--muted: rgba(0, 0, 0, 0.08);
114115
--muted-foreground: rgba(16, 18, 43, 0.72);
115116
--accent: rgba(226, 212, 254, 1);
116-
--accent-foreground: rgba(1, 55, 66, 1);
117+
--accent-foreground: rgba(16, 18, 43, 1);
117118
/* Destructive matches default light theme so the brand red stays consistent across light/cream/bloom. */
118119
--destructive: rgba(220, 38, 38, 1);
119120
--destructive-foreground: rgba(254, 242, 242, 1);
@@ -132,7 +133,7 @@ html[data-theme="cream"] {
132133
--sidebar-primary: rgba(83, 90, 232, 1);
133134
--sidebar-primary-foreground: rgba(255, 255, 255, 1);
134135
--sidebar-accent: rgba(226, 212, 254, 1);
135-
--sidebar-accent-foreground: rgba(1, 55, 66, 1);
136+
--sidebar-accent-foreground: rgba(16, 18, 43, 1);
136137
--sidebar-border: rgba(0, 0, 0, 0.08);
137138
--sidebar-ring: rgba(83, 90, 232, 0.45);
138139
--status-default: rgba(16, 18, 43, 0.55);
@@ -152,17 +153,19 @@ html[data-theme="cream"] {
152153
html[data-theme="bloom"] {
153154
color-scheme: light;
154155
--background: rgba(239, 232, 255, 1);
155-
/* Bottom stop for shell gradient (globals.css); lavender family, lighter than top. */
156-
--shell-gradient-end: color-mix(in srgb, var(--background) 40%, rgb(255, 255, 255));
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);
157159
--foreground: rgba(16, 18, 43, 1);
158160
--card: rgba(255, 255, 255, 1);
159161
--card-foreground: rgba(16, 18, 43, 1);
160162
--popover: rgba(255, 255, 255, 1);
161163
--popover-foreground: rgba(16, 18, 43, 1);
162164
--primary: rgba(83, 90, 232, 1);
163165
--primary-foreground: rgba(255, 255, 255, 1);
164-
--secondary: rgba(248, 243, 228, 1);
165-
--secondary-foreground: rgba(16, 18, 43, 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);
166169
/* Row hovers / `bg-muted`: subtle neutral lift (same alpha family as `--border`). */
167170
--muted: rgba(0, 0, 0, 0.08);
168171
--muted-foreground: rgba(16, 18, 43, 0.72);

packages/core/src/components/appshell.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
type ContextData,
99
} from "@/contexts/appshell-context";
1010
import { RouterContainer } from "@/routing/router";
11-
import { ThemeProvider, type Theme } from "@/contexts/theme-context";
11+
import { ThemeProvider, type Theme, type Font } from "@/contexts/theme-context";
1212
import { BreadcrumbOverrideProvider } from "@/contexts/breadcrumb-context";
1313
import { CommandPaletteProvider, type SearchSource } from "@/contexts/command-palette-context";
1414
import { BuiltInCommandPalette } from "@/components/command-palette";
@@ -179,6 +179,14 @@ type SharedAppShellProps = React.PropsWithChildren<{
179179
* @default "bloom"
180180
*/
181181
defaultTheme?: Theme;
182+
183+
/**
184+
* Initial font axis before any value is loaded from localStorage (`appshell-ui-font`).
185+
* Independent of `defaultTheme`; any color theme works with either font.
186+
*
187+
* @default "geist"
188+
*/
189+
defaultFont?: Font;
182190
}>;
183191

184192
/**
@@ -287,7 +295,9 @@ export const AppShell = (props: AppShellProps) => {
287295
<CommandPaletteProvider searchSources={props.searchSources}>
288296
<ThemeProvider
289297
defaultTheme={props.defaultTheme ?? "bloom"}
298+
defaultFont={props.defaultFont ?? "geist"}
290299
storageKey="appshell-ui-theme"
300+
fontStorageKey="appshell-ui-font"
291301
>
292302
<RouterContainer rootComponent={props.rootComponent} rootGuards={props.rootGuards}>
293303
{props.children}

packages/core/src/components/sidebar.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,8 @@ function Sidebar({
243243
side === "left" ? "astw:left-0" : "astw:right-0",
244244
variant === "floating" || variant === "inset"
245245
? "astw:p-2 astw:group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
246-
: "astw:group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
246+
: "astw:group-data-[collapsible=icon]:w-(--sidebar-width-icon) astw:group-data-[side=left]:border-r astw:group-data-[side=right]:border-l",
247+
variant === "inset" && "astw:border-x astw:border-x-border",
247248
)}
248249
>
249250
<div
@@ -310,7 +311,8 @@ function Sidebar({
310311
// Adjust the padding for floating and inset variants.
311312
variant === "floating" || variant === "inset"
312313
? "astw:p-2 astw:group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
313-
: "astw:group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
314+
: "astw:group-data-[collapsible=icon]:w-(--sidebar-width-icon) astw:group-data-[side=left]:border-r astw:group-data-[side=right]:border-l",
315+
variant === "inset" && "astw:border-x astw:border-x-border",
314316
className,
315317
)}
316318
{...props}

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

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@ 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 } from "vitest";
44

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

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

99
const storageKey = "theme-switcher-test-key";
10+
const fontStorageKey = "theme-switcher-test-font-key";
1011

1112
/** happy-dom / Node can omit a full `localStorage`; ThemeProvider persists via it. */
1213
function installLocalStorageStub() {
@@ -44,34 +45,39 @@ afterEach(() => {
4445
});
4546

4647
describe("ThemeSwitcher", () => {
47-
it("opens a menu listing every theme option", async () => {
48+
it("opens a menu listing every theme and font option", async () => {
4849
const user = userEvent.setup();
4950

5051
render(
51-
<ThemeProvider storageKey={storageKey} defaultTheme="light">
52+
<ThemeProvider storageKey={storageKey} fontStorageKey={fontStorageKey} defaultTheme="light">
5253
<ThemeSwitcher />
5354
</ThemeProvider>,
5455
);
5556

56-
await user.click(screen.getByRole("button", { name: "Theme" }));
57+
await user.click(screen.getByRole("button", { name: "Appearance" }));
5758

5859
await waitFor(() => {
59-
expect(screen.getAllByRole("menuitemradio").length).toBe(THEME_OPTIONS.length);
60+
expect(screen.getAllByRole("menuitemradio").length).toBe(
61+
THEME_OPTIONS.length + FONT_OPTIONS.length,
62+
);
6063
});
6164

6265
for (const opt of THEME_OPTIONS) {
6366
expect(screen.getByRole("menuitemradio", { name: opt.label })).toBeDefined();
6467
}
68+
for (const opt of FONT_OPTIONS) {
69+
expect(screen.getByRole("menuitemradio", { name: opt.label })).toBeDefined();
70+
}
6571
});
6672

6773
it("exposes resolved palette on the trigger when system mode is selected", () => {
6874
render(
69-
<ThemeProvider storageKey={storageKey} defaultTheme="system">
75+
<ThemeProvider storageKey={storageKey} fontStorageKey={fontStorageKey} defaultTheme="system">
7076
<ThemeSwitcher />
7177
</ThemeProvider>,
7278
);
7379

74-
const btn = screen.getByRole("button", { name: "Theme" });
80+
const btn = screen.getByRole("button", { name: "Appearance" });
7581
expect(btn.getAttribute("title")).toMatch(/following system/i);
7682
expect(btn.getAttribute("title")).toMatch(/currently light|currently dark/i);
7783
});
@@ -80,12 +86,12 @@ describe("ThemeSwitcher", () => {
8086
const user = userEvent.setup();
8187

8288
render(
83-
<ThemeProvider storageKey={storageKey} defaultTheme="light">
89+
<ThemeProvider storageKey={storageKey} fontStorageKey={fontStorageKey} defaultTheme="light">
8490
<ThemeSwitcher />
8591
</ThemeProvider>,
8692
);
8793

88-
await user.click(screen.getByRole("button", { name: "Theme" }));
94+
await user.click(screen.getByRole("button", { name: "Appearance" }));
8995

9096
await waitFor(() => {
9197
expect(screen.getByRole("menu")).toBeDefined();
@@ -98,4 +104,32 @@ describe("ThemeSwitcher", () => {
98104
});
99105
expect(localStorage.getItem(storageKey)).toBe("bloom");
100106
});
107+
108+
it("applies selected font when a font radio item is activated", async () => {
109+
const user = userEvent.setup();
110+
111+
render(
112+
<ThemeProvider
113+
storageKey={storageKey}
114+
fontStorageKey={fontStorageKey}
115+
defaultTheme="light"
116+
defaultFont="geist"
117+
>
118+
<ThemeSwitcher />
119+
</ThemeProvider>,
120+
);
121+
122+
await user.click(screen.getByRole("button", { name: "Appearance" }));
123+
124+
await waitFor(() => {
125+
expect(screen.getByRole("menu")).toBeDefined();
126+
});
127+
128+
await user.click(screen.getByRole("menuitemradio", { name: "Inter" }));
129+
130+
await waitFor(() => {
131+
expect(document.documentElement.dataset.font).toBe("inter");
132+
});
133+
expect(localStorage.getItem(fontStorageKey)).toBe("inter");
134+
});
101135
});

0 commit comments

Comments
 (0)