diff --git a/packages/core/src/components/table.tsx b/packages/core/src/components/table.tsx
index a03a72fe..c48d8416 100644
--- a/packages/core/src/components/table.tsx
+++ b/packages/core/src/components/table.tsx
@@ -84,7 +84,7 @@ function Row({ className, ...props }: React.ComponentProps<"tr">) {
();
+ const ls = {
+ getItem: (k: string) => map.get(k) ?? null,
+ setItem: (k: string, v: string) => {
+ map.set(k, v);
+ },
+ removeItem: (k: string) => {
+ map.delete(k);
+ },
+ clear: () => map.clear(),
+ key: (i: number) => [...map.keys()][i] ?? null,
+ get length() {
+ return map.size;
+ },
+ };
+ Object.defineProperty(globalThis, "localStorage", { configurable: true, value: ls });
+ return map;
+}
+
+let storageMap: Map;
+
+beforeAll(() => {
+ storageMap = installLocalStorageStub();
+});
+
+beforeEach(() => {
+ storageMap.clear();
+});
+
+afterEach(() => {
+ cleanup();
+});
+
+describe("ThemeSwitcher", () => {
+ it("opens a menu listing every theme and font option", async () => {
+ const user = userEvent.setup();
+
+ render(
+
+
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: "Appearance" }));
+
+ await waitFor(() => {
+ expect(screen.getAllByRole("menuitemradio").length).toBe(
+ THEME_OPTIONS.length + FONT_OPTIONS.length,
+ );
+ });
+
+ for (const opt of THEME_OPTIONS) {
+ expect(screen.getByRole("menuitemradio", { name: opt.label })).toBeDefined();
+ }
+ for (const opt of FONT_OPTIONS) {
+ expect(screen.getByRole("menuitemradio", { name: opt.label })).toBeDefined();
+ }
+ });
+
+ it("exposes resolved palette on the trigger when system mode is selected", () => {
+ render(
+
+
+ ,
+ );
+
+ const btn = screen.getByRole("button", { name: "Appearance" });
+ expect(btn.getAttribute("title")).toMatch(/following system/i);
+ expect(btn.getAttribute("title")).toMatch(/currently light|currently dark/i);
+ });
+
+ it("applies selected palette when a radio item is activated", async () => {
+ const user = userEvent.setup();
+
+ render(
+
+
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: "Appearance" }));
+
+ await waitFor(() => {
+ expect(screen.getByRole("menu")).toBeDefined();
+ });
+
+ await user.click(screen.getByRole("menuitemradio", { name: "Bloom" }));
+
+ await waitFor(() => {
+ expect(document.documentElement.dataset.theme).toBe("bloom");
+ });
+ expect(localStorage.getItem(storageKey)).toBe("bloom");
+ });
+
+ it("applies selected font when a font radio item is activated", async () => {
+ const user = userEvent.setup();
+
+ render(
+
+
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: "Appearance" }));
+
+ await waitFor(() => {
+ expect(screen.getByRole("menu")).toBeDefined();
+ });
+
+ await user.click(screen.getByRole("menuitemradio", { name: "Inter" }));
+
+ await waitFor(() => {
+ expect(document.documentElement.dataset.font).toBe("inter");
+ });
+ expect(localStorage.getItem(fontStorageKey)).toBe("inter");
+ });
+});
diff --git a/packages/core/src/components/theme-switcher.tsx b/packages/core/src/components/theme-switcher.tsx
new file mode 100644
index 00000000..c0103d15
--- /dev/null
+++ b/packages/core/src/components/theme-switcher.tsx
@@ -0,0 +1,190 @@
+import { Palette } from "lucide-react";
+
+import { Menu } from "@/components/menu";
+import { Button } from "@/components/button";
+import { cn } from "@/lib/utils";
+import {
+ useTheme,
+ useFont,
+ type ResolvedTheme,
+ type Theme,
+ type Font,
+ THEME_OPTIONS,
+ FONT_OPTIONS,
+} from "@/contexts/theme-context";
+
+const RESOLVED_THEME_SHORT: Record = {
+ light: "Light",
+ dark: "Dark",
+ cream: "Cream",
+ bloom: "Bloom",
+};
+
+/**
+ * Decorative dual swatches — approximates each palette pair (accent + neutral) for the picker grid.
+ * Kept as static hex previews so the swatches render even before a theme stylesheet has loaded;
+ * keep these in sync with the palette tokens in `assets/theme.css`.
+ */
+const THEME_PREVIEW: Record = {
+ light: { a: "#ffffff", b: "#d4d4d8" },
+ dark: { a: "#3f3f46", b: "#d4d4d8" },
+ cream: { a: "#f8f3e4", b: "#e2d4fe" },
+ bloom: { a: "#535ae8", b: "#f8f3e4" },
+ system: { a: "#52525b", b: "#7c73e6" },
+};
+
+/** Font preview — `font-family` for the "Aa" sample so users see the face before selecting.
+ * Mirrors the chain in `globals.css` (variable build first, then static family fallback). */
+const FONT_PREVIEW: Record = {
+ geist: '"Geist Variable", "Geist Sans", ui-sans-serif, system-ui, sans-serif',
+ inter: '"Inter Variable", "Inter", ui-sans-serif, system-ui, sans-serif',
+};
+
+function isTheme(value: string): value is Theme {
+ return THEME_OPTIONS.some((o) => o.value === value);
+}
+
+function isFont(value: string): value is Font {
+ return FONT_OPTIONS.some((o) => o.value === value);
+}
+
+/** Shared radio-item chrome — used by both the color and font grids. */
+function radioItemClasses(active: boolean) {
+ return cn(
+ "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",
+ "astw:data-highlighted:bg-muted/80 astw:data-highlighted:text-foreground",
+ "astw:data-disabled:pointer-events-none astw:data-disabled:opacity-50",
+ "[&_[data-slot=menu-radio-item-indicator]]:astw:hidden",
+ active &&
+ "astw:bg-primary/12 astw:ring-1 astw:ring-primary/25 astw:data-highlighted:bg-primary/[0.14]",
+ );
+}
+
+function ThemePreviewSwatches({ themeId }: { themeId: Theme }) {
+ const { a, b } = THEME_PREVIEW[themeId];
+ return (
+
+
+
+
+ );
+}
+
+function FontPreview({ fontId }: { fontId: Font }) {
+ return (
+
+
+ Aa
+
+
+ );
+}
+
+/**
+ * Appearance menu: two independent axes — color palette (top) and font (bottom).
+ * Bound to stored `theme` and `font`; **System** stays explicit on the color axis.
+ */
+function ThemeSwitcher() {
+ const { theme, resolvedTheme, setTheme } = useTheme();
+ const { font, setFont } = useFont();
+
+ const fontLabel = FONT_OPTIONS.find((o) => o.value === font)?.label ?? font;
+ const triggerTitle =
+ theme === "system"
+ ? `Following system — currently ${RESOLVED_THEME_SHORT[resolvedTheme]} · ${fontLabel}`
+ : "Choose appearance — color + font";
+
+ return (
+
+
+ }
+ >
+
+
+
+
+ Colors
+ {
+ if (typeof value === "string" && isTheme(value)) setTheme(value);
+ }}
+ >
+ {THEME_OPTIONS.map((opt) => (
+
+
+ {opt.label}
+
+
+
+ {opt.label}
+
+
+ ))}
+
+
+
+
+ Font
+ {
+ if (typeof value === "string" && isFont(value)) setFont(value);
+ }}
+ >
+ {FONT_OPTIONS.map((opt) => (
+
+
+ {opt.label}
+
+
+
+ {opt.label}
+
+
+ ))}
+
+
+
+
+ );
+}
+
+export { ThemeSwitcher };
diff --git a/packages/core/src/contexts/theme-context.test.tsx b/packages/core/src/contexts/theme-context.test.tsx
new file mode 100644
index 00000000..9d5c0af4
--- /dev/null
+++ b/packages/core/src/contexts/theme-context.test.tsx
@@ -0,0 +1,174 @@
+import { cleanup, render, waitFor } from "@testing-library/react";
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { ThemeProvider, useTheme, useFont } from "./theme-context";
+
+const storageKey = "theme-context-test-theme";
+const fontStorageKey = "theme-context-test-font";
+
+/** happy-dom / Node can omit a full `localStorage`; ThemeProvider persists via it. */
+function installLocalStorageStub() {
+ const map = new Map();
+ const ls = {
+ getItem: (k: string) => map.get(k) ?? null,
+ setItem: (k: string, v: string) => {
+ map.set(k, v);
+ },
+ removeItem: (k: string) => {
+ map.delete(k);
+ },
+ clear: () => map.clear(),
+ key: (i: number) => [...map.keys()][i] ?? null,
+ get length() {
+ return map.size;
+ },
+ };
+ Object.defineProperty(globalThis, "localStorage", { configurable: true, value: ls });
+ return map;
+}
+
+/** `matchMedia` is not implemented in some test runtimes — stub to a controllable shape. */
+function installMatchMediaStub(matches: boolean) {
+ Object.defineProperty(window, "matchMedia", {
+ configurable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+}
+
+let storageMap: Map;
+
+beforeAll(() => {
+ storageMap = installLocalStorageStub();
+});
+
+beforeEach(() => {
+ storageMap.clear();
+ document.documentElement.removeAttribute("data-theme");
+ document.documentElement.removeAttribute("data-font");
+ document.documentElement.classList.remove("light", "dark");
+});
+
+afterEach(() => {
+ cleanup();
+});
+
+function ThemeProbe() {
+ const { theme, resolvedTheme } = useTheme();
+ return (
+
+ {theme}
+ {resolvedTheme}
+
+ );
+}
+
+function FontProbe() {
+ const { font } = useFont();
+ return {font};
+}
+
+describe("ThemeProvider — legacy id migration", () => {
+ it.each([
+ ["tailor-light", "cream"],
+ ["tailor-bloom", "bloom"],
+ ["tailor-dark", "dark"],
+ ])("maps stored %s → %s on first render", async (stored, expected) => {
+ storageMap.set(storageKey, stored);
+
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ expect(getByTestId("theme").textContent).toBe(expected);
+ await waitFor(() => {
+ expect(document.documentElement.dataset.theme).toBe(expected);
+ });
+ });
+
+ it("falls back to defaultTheme for an unrecognized stored value", () => {
+ storageMap.set(storageKey, "totally-not-a-theme");
+
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ expect(getByTestId("theme").textContent).toBe("light");
+ });
+
+ it("falls back to defaultFont for an unrecognized stored font", () => {
+ storageMap.set(fontStorageKey, "wingdings");
+
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ expect(getByTestId("font").textContent).toBe("inter");
+ });
+});
+
+describe("ThemeProvider — system resolution", () => {
+ it("resolves system → dark when prefers-color-scheme: dark matches", async () => {
+ installMatchMediaStub(true);
+ storageMap.set(storageKey, "system");
+
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ expect(getByTestId("theme").textContent).toBe("system");
+ expect(getByTestId("resolved").textContent).toBe("dark");
+ await waitFor(() => {
+ expect(document.documentElement.dataset.theme).toBe("dark");
+ expect(document.documentElement.classList.contains("dark")).toBe(true);
+ });
+ });
+
+ it("resolves system → light when prefers-color-scheme: dark does not match", async () => {
+ installMatchMediaStub(false);
+ storageMap.set(storageKey, "system");
+
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ expect(getByTestId("resolved").textContent).toBe("light");
+ await waitFor(() => {
+ expect(document.documentElement.dataset.theme).toBe("light");
+ expect(document.documentElement.classList.contains("light")).toBe(true);
+ });
+ });
+});
+
+describe("useTheme / useFont — provider guard", () => {
+ it("throws when useTheme is called outside ThemeProvider", () => {
+ // Silence React's expected error log for this assertion.
+ const spy = vi.spyOn(console, "error").mockImplementation(() => {});
+ expect(() => render()).toThrow(/useTheme must be used within a ThemeProvider/);
+ spy.mockRestore();
+ });
+
+ it("throws when useFont is called outside ThemeProvider", () => {
+ const spy = vi.spyOn(console, "error").mockImplementation(() => {});
+ expect(() => render()).toThrow(/useFont must be used within a ThemeProvider/);
+ spy.mockRestore();
+ });
+});
diff --git a/packages/core/src/contexts/theme-context.tsx b/packages/core/src/contexts/theme-context.tsx
index 77f62dc6..82237b63 100644
--- a/packages/core/src/contexts/theme-context.tsx
+++ b/packages/core/src/contexts/theme-context.tsx
@@ -1,68 +1,171 @@
-import { createContext, useContext, useEffect, useMemo, useState } from "react";
+import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
-type Theme = "dark" | "light" | "system";
+/** User-selectable theme. `system` follows OS light/dark (default palettes only — not cream/bloom). */
+export type Theme = "light" | "dark" | "cream" | "bloom" | "system";
+
+/** Resolved paint after applying `system`. */
+export type ResolvedTheme = "light" | "dark" | "cream" | "bloom";
+
+const ALL_THEMES: readonly Theme[] = ["light", "dark", "cream", "bloom", "system"] as const;
+
+/** Dropdown / switcher entries: order matches selectable themes; labels are user-facing. */
+export type ThemeOption = { readonly value: Theme; readonly label: string };
+
+export const THEME_OPTIONS: readonly ThemeOption[] = [
+ { value: "bloom", label: "Bloom" },
+ { value: "light", label: "Light" },
+ { value: "dark", label: "Dark" },
+ { value: "cream", label: "Cream" },
+ { value: "system", label: "System" },
+] as const;
+
+/** Font axis — independent of color theme. Applied to `` as `data-font`. */
+export type Font = "geist" | "inter";
+
+const ALL_FONTS: readonly Font[] = ["geist", "inter"] as const;
+
+export type FontOption = { readonly value: Font; readonly label: string };
+
+export const FONT_OPTIONS: readonly FontOption[] = [
+ { value: "geist", label: "Geist" },
+ { value: "inter", label: "Inter" },
+] as const;
+
+/** Migrate stored values from legacy `tailor-*` ids before the public rename. */
+const LEGACY_THEME_IDS: Partial> = {
+ "tailor-light": "cream",
+ "tailor-bloom": "bloom",
+ "tailor-dark": "dark",
+};
+
+function parseStoredTheme(value: string | null, fallback: Theme): Theme {
+ if (!value) return fallback;
+ const legacy = LEGACY_THEME_IDS[value];
+ if (legacy) return legacy;
+ if ((ALL_THEMES as readonly string[]).includes(value)) return value as Theme;
+ return fallback;
+}
+
+function parseStoredFont(value: string | null, fallback: Font): Font {
+ if (!value) return fallback;
+ if ((ALL_FONTS as readonly string[]).includes(value)) return value as Font;
+ return fallback;
+}
+
+function readStored(
+ storageKey: string,
+ fallback: T,
+ parse: (value: string | null, fallback: T) => T,
+): T {
+ if (typeof window === "undefined") return fallback;
+ const ls = window.localStorage;
+ const getItem = ls && typeof ls.getItem === "function" ? ls.getItem.bind(ls) : null;
+ if (!getItem) return fallback;
+ try {
+ return parse(getItem(storageKey), fallback);
+ } catch {
+ return fallback;
+ }
+}
+
+function writeStored(storageKey: string, value: T) {
+ if (typeof window === "undefined") return;
+ const ls = window.localStorage;
+ if (!ls || typeof ls.setItem !== "function") return;
+ try {
+ ls.setItem(storageKey, value);
+ } catch {
+ /* storage full or forbidden */
+ }
+}
type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: Theme;
+ defaultFont?: Font;
storageKey: string;
+ fontStorageKey: string;
};
type ThemeProviderState = {
theme: Theme;
- resolvedTheme: Omit;
+ resolvedTheme: ResolvedTheme;
setTheme: (theme: Theme) => void;
+ font: Font;
+ setFont: (font: Font) => void;
};
-const initialState: ThemeProviderState = {
- resolvedTheme: "light",
- theme: "system",
- setTheme: () => null,
-};
+const ThemeProviderContext = createContext(undefined);
-const ThemeProviderContext = createContext(initialState);
+function resolveTheme(theme: Theme): ResolvedTheme {
+ if (theme !== "system") return theme;
+ if (typeof window === "undefined") return "light";
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
+}
export function ThemeProvider({
children,
storageKey,
- defaultTheme = "system",
- ...props
+ fontStorageKey,
+ defaultTheme = "bloom",
+ defaultFont = "geist",
}: ThemeProviderProps) {
- const [theme, setTheme] = useState(
- () => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
+ const [theme, setThemeState] = useState(() =>
+ readStored(storageKey, defaultTheme, parseStoredTheme),
+ );
+ const [font, setFontState] = useState(() =>
+ readStored(fontStorageKey, defaultFont, parseStoredFont),
);
- const resolvedTheme = useMemo(() => {
- if (theme !== "system") return theme;
- return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
- }, [theme]);
+ const resolvedTheme = useMemo(() => resolveTheme(theme), [theme]);
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove("light", "dark");
- root.classList.add(resolvedTheme);
+ root.classList.add(resolvedTheme === "dark" ? "dark" : "light");
+ root.dataset.theme = resolvedTheme;
}, [resolvedTheme]);
- const value = {
- resolvedTheme,
- theme,
- setTheme: (newTheme: Theme) => {
- localStorage.setItem(storageKey, newTheme);
- setTheme(newTheme);
+ useEffect(() => {
+ window.document.documentElement.dataset.font = font;
+ }, [font]);
+
+ const setTheme = useCallback(
+ (newTheme: Theme) => {
+ writeStored(storageKey, newTheme);
+ setThemeState(newTheme);
+ },
+ [storageKey],
+ );
+
+ const setFont = useCallback(
+ (newFont: Font) => {
+ writeStored(fontStorageKey, newFont);
+ setFontState(newFont);
},
- };
+ [fontStorageKey],
+ );
- return (
-
- {children}
-
+ const value = useMemo(
+ () => ({ theme, resolvedTheme, setTheme, font, setFont }),
+ [theme, resolvedTheme, setTheme, font, setFont],
);
+
+ return {children};
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
-
if (context === undefined) throw new Error("useTheme must be used within a ThemeProvider");
- return context;
+ const { theme, resolvedTheme, setTheme } = context;
+ return useMemo(() => ({ theme, resolvedTheme, setTheme }), [theme, resolvedTheme, setTheme]);
+};
+
+export const useFont = () => {
+ const context = useContext(ThemeProviderContext);
+ if (context === undefined) throw new Error("useFont must be used within a ThemeProvider");
+
+ const { font, setFont } = context;
+ return useMemo(() => ({ font, setFont }), [font, setFont]);
};
diff --git a/packages/core/src/globals.css b/packages/core/src/globals.css
index f55ebe89..17da3132 100644
--- a/packages/core/src/globals.css
+++ b/packages/core/src/globals.css
@@ -34,6 +34,65 @@
body {
@apply astw:font-sans astw:antialiased astw:bg-background astw:text-foreground;
}
+
+ /*
+ * Cream / Bloom — vertical shell gradient (top = light tint via --shell-gradient-start → bottom = white via --shell-gradient-end).
+ * Paint on `html` (fixed); shell chrome uses transparent bg so `bg-background` on buttons/cards stays solid.
+ */
+ :is(html[data-theme="cream"], html[data-theme="bloom"]) {
+ min-height: 100vh;
+ min-height: 100dvh;
+ background-color: var(--background);
+ background-image: linear-gradient(
+ to bottom,
+ var(--shell-gradient-start) 0%,
+ color-mix(in srgb, var(--background) 45%, white) 20%,
+ color-mix(in srgb, var(--background) 30%, white) 40%,
+ color-mix(in srgb, var(--background) 15%, white) 55%,
+ color-mix(in srgb, var(--background) 6%, white) 65%,
+ var(--shell-gradient-end) 70%,
+ var(--shell-gradient-end) 100%
+ );
+ background-attachment: fixed;
+ background-repeat: no-repeat;
+ background-size: 100% 100%;
+ }
+
+ /*
+ * Font axis — applied independently of color theme via `data-font` on ``.
+ *
+ * Family-name chain prefers the variable build (loaded by
+ * `@tailor-platform/app-shell/fonts`), then the conventional static family
+ * name (`Geist Sans` / `Inter` — what `next/font/google` and most CDNs
+ * register), then the system stack. Consumers who load their own font under
+ * either name automatically participate; no font loads when nothing matches.
+ */
+ html[data-font="geist"] body,
+ html[data-font="geist"] :where(h1, h2, h3, h4, h5, h6) {
+ font-family: "Geist Variable", "Geist Sans", ui-sans-serif, system-ui, sans-serif;
+ }
+
+ html[data-font="inter"] body,
+ html[data-font="inter"] :where(h1, h2, h3, h4, h5, h6) {
+ font-family: "Inter Variable", "Inter", ui-sans-serif, system-ui, sans-serif;
+ }
+
+ /* Tighter heading rhythm on cream/bloom — matches the showcase palettes' display feel. */
+ :is(html[data-theme="cream"], html[data-theme="bloom"]) :where(h1, h2, h3, h4, h5, h6) {
+ letter-spacing: -0.03em;
+ line-height: 1.2;
+ }
+
+ /* Apple-style squircle corners on cream/bloom where engine supports corner-shape (Chromium). */
+ @supports (corner-shape: squircle) {
+ :is(html[data-theme="cream"], html[data-theme="bloom"]) * {
+ corner-shape: squircle;
+ }
+ /* Round radii (.rounded-full) stay circular squircles, not elongated “capsules” */
+ :is(html[data-theme="cream"], html[data-theme="bloom"]) [class*="rounded-full"] {
+ corner-shape: round;
+ }
+ }
::-webkit-scrollbar {
@apply astw:w-2 astw:h-2 astw:bg-muted;
}
@@ -43,4 +102,77 @@
::-webkit-scrollbar-corner {
@apply astw:bg-muted-foreground;
}
+
+ /*
+ * WebKit / Firefox autofill: override system yellow so fills follow design tokens
+ * (typically white card surfaces in cream / bloom; --card resolves per theme).
+ */
+ input:is(:-webkit-autofill, :autofill),
+ textarea:is(:-webkit-autofill, :autofill),
+ select:is(:-webkit-autofill, :autofill) {
+ -webkit-box-shadow: 0 0 0 1000px var(--card) inset !important;
+ box-shadow: 0 0 0 1000px var(--card) inset !important;
+ caret-color: var(--foreground);
+ transition: background-color 9999s ease-out 0s;
+ }
+}
+
+/*
+ * Theme-specific overrides that must beat Tailwind utility classes (which sit in `@layer utilities`).
+ * Keep this block in `@layer utilities` so it loses to no Tailwind utility in earlier layers and wins
+ * on selector specificity (no `!important` needed).
+ */
+@layer utilities {
+ /*
+ * Cream / Bloom: body and sidebar chrome stay transparent so the html-level shell gradient shows
+ * through. Wins over `body { bg-background }` (base) and the Tailwind `bg-sidebar*` utilities on
+ * sidebar slots via the `:is(html[data-theme=...])` prefix raising specificity.
+ */
+ :is(html[data-theme="cream"], html[data-theme="bloom"]) body {
+ background-color: transparent;
+ background-image: none;
+ }
+
+ :is(html[data-theme="cream"], html[data-theme="bloom"]) [data-slot="sidebar-wrapper"],
+ :is(html[data-theme="cream"], html[data-theme="bloom"]) [data-slot="sidebar-inner"],
+ :is(html[data-theme="cream"], html[data-theme="bloom"]) main[data-slot="sidebar-inset"],
+ :is(html[data-theme="cream"], html[data-theme="bloom"])
+ [data-slot="sidebar"][class*="bg-sidebar"]:not([data-mobile="true"]):not(
+ [data-icon-mode="true"]
+ ) {
+ background-color: transparent;
+ }
+
+ /* Outline button on cream/bloom: transparent fill so the shell gradient shows through; keep border. */
+ :is(html[data-theme="cream"], html[data-theme="bloom"])
+ [data-slot="button"][class*="astw:bg-background"][class*="astw:border"] {
+ background-color: transparent;
+ transition:
+ background-color 0.12s ease,
+ color 0.12s ease;
+ }
+
+ /* Hover state — restore the accent fill that the Tailwind `hover:bg-accent` utility would have provided
+ * if our transparent rule above weren't winning on specificity. */
+ :is(html[data-theme="cream"], html[data-theme="bloom"])
+ [data-slot="button"][class*="astw:bg-background"][class*="astw:border"]:hover:not(:disabled) {
+ background-color: var(--accent);
+ color: var(--accent-foreground);
+ }
+
+ /*
+ * Sidebar active item — hairline border (~0.5px @ 30% black). Wrapper components in
+ * `components/sidebar/*` apply the active state via direct `bg-sidebar-accent` classes
+ * (not the `data-active` attribute), so target the class signature. Inset box-shadow
+ * (not outline) because Tailwind's `outline-hidden` utility sets a transparent 2px
+ * outline that would override us. Lives in `@layer utilities` to win the cascade.
+ */
+ :is(
+ [data-slot="sidebar-menu-button"],
+ [data-slot="sidebar-menu-sub-button"]
+ )[class~="astw:bg-sidebar-accent"] {
+ outline: 0.5px solid var(--border);
+ outline-offset: -0.5px;
+ box-shadow: var(--semantic-shadow-xs);
+ }
}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index daef45a1..e07a1ed5 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -24,7 +24,19 @@ export {
export { WithGuard, type WithGuardProps } from "./components/with-guard";
export { useAppShell, useAppShellConfig, useAppShellData } from "./contexts/appshell-context";
-export { useTheme } from "./contexts/theme-context";
+export {
+ useTheme,
+ useFont,
+ THEME_OPTIONS,
+ FONT_OPTIONS,
+ type ResolvedTheme,
+ type Theme,
+ type ThemeOption,
+ type Font,
+ type FontOption,
+} from "./contexts/theme-context";
+export { ThemeSwitcher } from "./components/theme-switcher";
+export { getInitialAppearanceScript } from "./lib/initial-appearance";
export { type I18nLabels, defineI18nLabels } from "./hooks/i18n";
export {
AuthProvider,
diff --git a/packages/core/src/lib/initial-appearance.test.ts b/packages/core/src/lib/initial-appearance.test.ts
new file mode 100644
index 00000000..c3ac32c7
--- /dev/null
+++ b/packages/core/src/lib/initial-appearance.test.ts
@@ -0,0 +1,120 @@
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { getInitialAppearanceScript } from "./initial-appearance";
+
+function installLocalStorageStub() {
+ const map = new Map();
+ Object.defineProperty(globalThis, "localStorage", {
+ configurable: true,
+ value: {
+ getItem: (k: string) => map.get(k) ?? null,
+ setItem: (k: string, v: string) => {
+ map.set(k, v);
+ },
+ removeItem: (k: string) => map.delete(k),
+ clear: () => map.clear(),
+ key: (i: number) => [...map.keys()][i] ?? null,
+ get length() {
+ return map.size;
+ },
+ },
+ });
+ return map;
+}
+
+function installMatchMediaStub(matches: boolean) {
+ Object.defineProperty(window, "matchMedia", {
+ configurable: true,
+ value: vi.fn().mockReturnValue({ matches }),
+ });
+}
+
+let storage: Map;
+
+beforeAll(() => {
+ storage = installLocalStorageStub();
+});
+
+beforeEach(() => {
+ storage.clear();
+ document.documentElement.removeAttribute("data-theme");
+ document.documentElement.removeAttribute("data-font");
+ document.documentElement.classList.remove("light", "dark");
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+/** Run the IIFE source as if a `