diff --git a/docs/api/use-theme.md b/docs/api/use-theme.md
index 76f0c0a4..3658217c 100644
--- a/docs/api/use-theme.md
+++ b/docs/api/use-theme.md
@@ -12,9 +12,9 @@ React hook to access and control the current appearance theme.
Exported types:
```typescript
-export type Theme = "light" | "dark" | "deep-dark" | "cream" | "bloom" | "system";
+export type Theme = "light" | "dark" | "cream" | "bloom" | "system";
-export type ResolvedTheme = "light" | "dark" | "deep-dark" | "cream" | "bloom";
+export type ResolvedTheme = "light" | "dark" | "cream" | "bloom";
export type ThemeOption = { readonly value: Theme; readonly label: string };
@@ -37,12 +37,12 @@ const useTheme: () => {
### `theme`
- **Type:** `Theme`
-- **Description:** Stored theme preference. **`system`** means “follow OS light/dark” for the **default** light/dark palettes only (not cream, bloom, or deep-dark).
+- **Description:** Stored theme preference. **`system`** means “follow OS light/dark” for the **default** light/dark palettes only (not cream or bloom).
### `resolvedTheme`
- **Type:** `ResolvedTheme`
-- **Description:** Concrete palette after resolving **`system`** to **`light`** or **`dark`**. **`cream`**, **`bloom`**, and **`deep-dark`** are never produced by **`system`**; pick them explicitly with **`setTheme`**.
+- **Description:** Concrete palette after resolving **`system`** to **`light`** or **`dark`**. **`cream`** and **`bloom`** are never produced by **`system`**; pick them explicitly with **`setTheme`**.
When **`resolvedTheme`** changes, **`document.documentElement`** gets **`data-theme`** set to this value and a **`light`** / **`dark`** class for Tailwind **`dark`** variant compatibility.
@@ -51,7 +51,7 @@ When **`resolvedTheme`** changes, **`document.documentElement`** gets **`data-th
- **Type:** `(theme: Theme) => void`
- **Description:** Set the theme. Persisted to **`localStorage`** (key **`appshell-ui-theme`** when using AppShell’s built-in provider).
-Previously saved ids **`tailor-light`**, **`tailor-bloom`**, **`tailor-dark`** are mapped on read to **`cream`**, **`bloom`**, **`deep-dark`** (see **`LEGACY_THEME_IDS`** in **`theme-context.tsx`**).
+Previously saved ids **`tailor-light`**, **`tailor-bloom`**, **`tailor-dark`** are mapped on read to **`cream`**, **`bloom`**, **`dark`** (see **`LEGACY_THEME_IDS`** in **`theme-context.tsx`**).
## Usage
@@ -114,13 +114,12 @@ function CustomThemeList() {
### Logo or assets by lightness
-Use **`resolvedTheme`** and treat **`light`**, **`cream`**, and **`bloom`** like “light” palettes and **`dark`** and **`deep-dark`** like “dark” for monochrome assets:
+Use **`resolvedTheme`** and treat **`light`**, **`cream`**, and **`bloom`** like “light” palettes and **`dark`** like “dark” for monochrome assets:
```typescript
function Logo() {
const { resolvedTheme } = useTheme();
- const darkish =
- resolvedTheme === "dark" || resolvedTheme === "deep-dark";
+ const darkish = resolvedTheme === "dark";
return ;
}
@@ -132,6 +131,52 @@ The built-in **`ThemeProvider`** (used inside **`AppShell`**) persists the **`th
Use **`AppShell`**’s **`defaultTheme`** prop for the initial value when nothing is stored.
+# useFont
+
+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`**.
+
+## Signature
+
+```typescript
+export type Font = "inter" | "geist";
+
+export type FontOption = { readonly value: Font; readonly label: string };
+
+export const FONT_OPTIONS: readonly FontOption[];
+```
+
+```typescript
+const useFont: () => {
+ font: Font;
+ setFont: (font: Font) => void;
+};
+```
+
+## Return value
+
+### `font`
+
+- **Type:** `Font`
+- **Description:** Stored font preference. Applied to **``** as **`data-font`**, which the package CSS uses to set **`font-family`** on **`body`** and headings.
+
+### `setFont(font)`
+
+- **Type:** `(font: Font) => void`
+- **Description:** Set the font. Persisted to **`localStorage`** (key **`appshell-ui-font`** when using AppShell’s built-in provider).
+
+## Usage
+
+```typescript
+import { useFont } from "@tailor-platform/app-shell";
+
+function UseInter() {
+ const { setFont } = useFont();
+ return setFont("inter")}>Inter ;
+}
+```
+
+Use **`AppShell`**’s **`defaultFont`** prop for the initial value when nothing is stored. Default is **`"geist"`**.
+
## Related
- [Styling & Theming](../concepts/styling-theming.md)
diff --git a/docs/concepts/styling-theming.md b/docs/concepts/styling-theming.md
index 23bd29fd..8f702eba 100644
--- a/docs/concepts/styling-theming.md
+++ b/docs/concepts/styling-theming.md
@@ -36,12 +36,22 @@ AppShell ships **five** named semantic palettes controlled by **`data-theme`** o
| `dark` | Default neutral dark |
| `cream` | Tailor brand — cream shell, violet accents |
| `bloom` | Lavender shell (**Bloom**) and cream accents |
-| `deep-dark` | Tailor brand — near-black tuning surface |
-**`system`** resolves to **`light`** or **`dark`** only — not **`cream`**, **`bloom`**, or **`deep-dark`**. Set those with **`setTheme`** or **`AppShell`** **`defaultTheme`**.
+**`system`** resolves to **`light`** or **`dark`** only — not **`cream`** or **`bloom`**. Set those with **`setTheme`** or **`AppShell`** **`defaultTheme`**.
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='…']`**.
+### Font axis
+
+Font is an independent axis from color theme — any palette works with either face.
+
+| Font | When you'd pick it |
+| ------- | -------------------------------------------------------- |
+| `inter` | Neutral workhorse; matches most ERP / dashboard UIs |
+| `geist` | Vercel-flavoured display sans; default for Tailor themes |
+
+Selected font is applied to **``** 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"`**).
+
## A note on AppShell component class names
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.
diff --git a/packages/core/__snapshots__/src__components__button.test.tsx.snap b/packages/core/__snapshots__/src__components__button.test.tsx.snap
index 2ab69968..4aa5dc49 100644
--- a/packages/core/__snapshots__/src__components__button.test.tsx.snap
+++ b/packages/core/__snapshots__/src__components__button.test.tsx.snap
@@ -18,7 +18,7 @@ exports[`Button > snapshots > link variant 1`] = `" snapshots > outline variant 1`] = `"Outline "`;
-exports[`Button > snapshots > secondary variant 1`] = `"Secondary "`;
+exports[`Button > snapshots > secondary variant 1`] = `"Secondary "`;
exports[`Button > snapshots > small size 1`] = `"Small "`;
diff --git a/packages/core/src/assets/theme.css b/packages/core/src/assets/theme.css
index 43a20eb6..4bf9563c 100644
--- a/packages/core/src/assets/theme.css
+++ b/packages/core/src/assets/theme.css
@@ -73,7 +73,8 @@
--chart-3: rgba(245, 158, 11, 1);
--chart-4: rgba(168, 85, 247, 1);
--chart-5: rgba(244, 63, 94, 1);
- --sidebar: rgba(23, 23, 23, 1);
+ /* Match --background so sidebar blends with the app surface. */
+ --sidebar: rgba(10, 10, 10, 1);
--sidebar-foreground: rgba(250, 250, 250, 1);
--sidebar-primary: rgba(29, 78, 216, 1);
--sidebar-primary-foreground: rgba(250, 250, 250, 1);
@@ -96,8 +97,9 @@ html[data-theme="cream"] {
color-scheme: light;
/* Off-white app shell; white cards for elevation */
--background: rgba(248, 243, 228, 1);
- /* Bottom stop for fixed shell gradient (globals.css); same cream family, lighter toward white. */
- --shell-gradient-end: color-mix(in srgb, var(--background) 40%, rgb(255, 255, 255));
+ /* Shell gradient stops (globals.css): light cream tint at top → white at bottom. */
+ --shell-gradient-start: color-mix(in srgb, var(--background) 55%, rgb(255, 255, 255));
+ --shell-gradient-end: rgb(255, 255, 255);
--foreground: rgba(16, 18, 43, 1);
--card: rgba(255, 255, 255, 1);
--card-foreground: rgba(16, 18, 43, 1);
@@ -105,15 +107,16 @@ html[data-theme="cream"] {
--popover-foreground: rgba(16, 18, 43, 1);
--primary: rgba(83, 90, 232, 1);
--primary-foreground: rgba(255, 255, 255, 1);
- /* Light violet surfaces; dark green text (brand text-on-violet pairing) */
+ /* Light violet surfaces; near-black text (match bloom; drop the brand dark-green pairing). */
--secondary: rgba(226, 212, 254, 1);
- --secondary-foreground: rgba(1, 55, 66, 1);
+ --secondary-foreground: rgba(16, 18, 43, 1);
/* Row hovers / `bg-muted`: subtle neutral lift (same alpha family as `--border`), not a second brand tint. */
--muted: rgba(0, 0, 0, 0.08);
--muted-foreground: rgba(16, 18, 43, 0.72);
--accent: rgba(226, 212, 254, 1);
- --accent-foreground: rgba(1, 55, 66, 1);
- --destructive: rgba(185, 28, 28, 1);
+ --accent-foreground: rgba(16, 18, 43, 1);
+ /* Destructive matches default light theme so the brand red stays consistent across light/cream/bloom. */
+ --destructive: rgba(220, 38, 38, 1);
--destructive-foreground: rgba(254, 242, 242, 1);
--border: rgba(0, 0, 0, 0.08);
--input: rgba(0, 0, 0, 0.08);
@@ -130,7 +133,7 @@ html[data-theme="cream"] {
--sidebar-primary: rgba(83, 90, 232, 1);
--sidebar-primary-foreground: rgba(255, 255, 255, 1);
--sidebar-accent: rgba(226, 212, 254, 1);
- --sidebar-accent-foreground: rgba(1, 55, 66, 1);
+ --sidebar-accent-foreground: rgba(16, 18, 43, 1);
--sidebar-border: rgba(0, 0, 0, 0.08);
--sidebar-ring: rgba(83, 90, 232, 0.45);
--status-default: rgba(16, 18, 43, 0.55);
@@ -150,8 +153,9 @@ html[data-theme="cream"] {
html[data-theme="bloom"] {
color-scheme: light;
--background: rgba(239, 232, 255, 1);
- /* Bottom stop for shell gradient (globals.css); lavender family, lighter than top. */
- --shell-gradient-end: color-mix(in srgb, var(--background) 40%, rgb(255, 255, 255));
+ /* Shell gradient stops (globals.css): light lavender tint at top → white at bottom. */
+ --shell-gradient-start: color-mix(in srgb, var(--background) 55%, rgb(255, 255, 255));
+ --shell-gradient-end: rgb(255, 255, 255);
--foreground: rgba(16, 18, 43, 1);
--card: rgba(255, 255, 255, 1);
--card-foreground: rgba(16, 18, 43, 1);
@@ -159,16 +163,17 @@ html[data-theme="bloom"] {
--popover-foreground: rgba(16, 18, 43, 1);
--primary: rgba(83, 90, 232, 1);
--primary-foreground: rgba(255, 255, 255, 1);
- --secondary: rgba(248, 243, 228, 1);
- --secondary-foreground: rgba(16, 18, 43, 1);
+ /* Match light theme — neutral pale grey reads as a soft pill on the lavender shell. */
+ --secondary: rgba(245, 245, 245, 1);
+ --secondary-foreground: rgba(23, 23, 23, 1);
/* Row hovers / `bg-muted`: subtle neutral lift (same alpha family as `--border`). */
--muted: rgba(0, 0, 0, 0.08);
--muted-foreground: rgba(16, 18, 43, 0.72);
--accent: rgba(248, 243, 228, 1);
--accent-foreground: rgba(16, 18, 43, 1);
- /* Softer reds than cream — less harsh against lavender gradient / cream fills */
- --destructive: rgba(176, 45, 64, 1);
- --destructive-foreground: rgba(255, 251, 252, 1);
+ /* Destructive matches default light theme so the brand red stays consistent across light/cream/bloom. */
+ --destructive: rgba(220, 38, 38, 1);
+ --destructive-foreground: rgba(254, 242, 242, 1);
--border: rgba(0, 0, 0, 0.08);
--input: rgba(0, 0, 0, 0.08);
--ring: rgba(83, 90, 232, 0.45);
@@ -199,61 +204,6 @@ html[data-theme="bloom"] {
--semantic-shadow-lg: 0 10px 15px -3px rgb(16 18 43 / 0.12), 0 4px 6px -4px rgb(83 90 232 / 0.1);
}
-/**
- * Tailor brand — dark palette (`deep-dark`): near-black shell; same primaries / secondaries as cream.
- */
-html[data-theme="deep-dark"] {
- color-scheme: dark;
- --background: rgba(10, 10, 11, 1);
- --foreground: rgba(237, 238, 242, 1);
- --card: rgba(18, 18, 21, 1);
- --card-foreground: rgba(237, 238, 242, 1);
- --popover: rgba(22, 22, 26, 1);
- --popover-foreground: rgba(237, 238, 242, 1);
- /* Match cream — Primary #535AE8 */
- --primary: rgba(83, 90, 232, 1);
- --primary-foreground: rgba(255, 255, 255, 1);
- /* Light violet + dark green text (same pairing as cream) */
- --secondary: rgba(226, 212, 254, 1);
- --secondary-foreground: rgba(1, 55, 66, 1);
- --muted: rgba(28, 28, 34, 1);
- --muted-foreground: rgba(163, 167, 180, 1);
- --accent: rgba(226, 212, 254, 1);
- --accent-foreground: rgba(1, 55, 66, 1);
- --destructive: rgba(185, 28, 28, 1);
- --destructive-foreground: rgba(254, 242, 242, 1);
- --border: rgba(255, 255, 255, 0.1);
- --input: rgba(255, 255, 255, 0.12);
- --ring: rgba(83, 90, 232, 0.45);
- --chart-1: rgba(83, 90, 232, 1);
- --chart-2: rgba(0, 151, 156, 1);
- --chart-3: rgba(1, 55, 66, 1);
- --chart-4: rgba(110, 95, 195, 1);
- --chart-5: rgba(217, 119, 6, 1);
- --radius: 1rem;
- --sidebar: rgba(10, 10, 11, 1);
- --sidebar-foreground: rgba(237, 238, 242, 1);
- --sidebar-primary: rgba(83, 90, 232, 1);
- --sidebar-primary-foreground: rgba(255, 255, 255, 1);
- /*
- * Hover + current row: dark elevated chip on near-black sidebar — light label.
- * (Lavender wash reads as a “light tab” here; a muted lift matches dark UI better.)
- */
- --sidebar-accent: rgba(42, 43, 54, 1);
- --sidebar-accent-foreground: rgba(238, 239, 246, 1);
- --sidebar-border: rgba(255, 255, 255, 0.1);
- --sidebar-ring: rgba(83, 90, 232, 0.45);
- --status-default: rgba(144, 150, 168, 0.88);
- --status-neutral: #00979c;
- --status-completed: #00979c;
- --status-attention: #d97706;
- --status-danger: #dc2626;
- --semantic-shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.42);
- --semantic-shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.42), 0 1px 2px -1px rgb(0 0 0 / 0.36);
- --semantic-shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.46), 0 2px 4px -2px rgb(0 0 0 / 0.36);
- --semantic-shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.54), 0 4px 6px -4px rgb(83 90 232 / 0.12);
-}
-
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
diff --git a/packages/core/src/components/appshell.tsx b/packages/core/src/components/appshell.tsx
index 583e77e4..911640a4 100644
--- a/packages/core/src/components/appshell.tsx
+++ b/packages/core/src/components/appshell.tsx
@@ -8,7 +8,7 @@ import {
type ContextData,
} from "@/contexts/appshell-context";
import { RouterContainer } from "@/routing/router";
-import { ThemeProvider, type Theme } from "@/contexts/theme-context";
+import { ThemeProvider, type Theme, type Font } from "@/contexts/theme-context";
import { BreadcrumbOverrideProvider } from "@/contexts/breadcrumb-context";
import { CommandPaletteProvider, type SearchSource } from "@/contexts/command-palette-context";
import { BuiltInCommandPalette } from "@/components/command-palette";
@@ -173,12 +173,20 @@ type SharedAppShellProps = React.PropsWithChildren<{
* Initial theme before any value is loaded from localStorage (`appshell-ui-theme`).
* Does not replace a stored preference.
*
- * Named palettes **`cream`**, **`bloom`**, **`deep-dark`**, plus default **`light`** / **`dark`**, in addition to
- * default light/dark and `system` (OS preference maps to **default** light or dark only).
+ * Named palettes **`cream`**, **`bloom`**, plus default **`light`** / **`dark`**, in addition to
+ * `system` (OS preference maps to **default** light or dark only — not cream or bloom).
*
- * @default "system"
+ * @default "bloom"
*/
defaultTheme?: Theme;
+
+ /**
+ * Initial font axis before any value is loaded from localStorage (`appshell-ui-font`).
+ * Independent of `defaultTheme`; any color theme works with either font.
+ *
+ * @default "geist"
+ */
+ defaultFont?: Font;
}>;
/**
@@ -286,8 +294,10 @@ export const AppShell = (props: AppShellProps) => {
{props.children}
diff --git a/packages/core/src/components/badge.tsx b/packages/core/src/components/badge.tsx
index 2da14835..2254056c 100644
--- a/packages/core/src/components/badge.tsx
+++ b/packages/core/src/components/badge.tsx
@@ -16,7 +16,7 @@ const badgeVariants = cva(
error:
"astw:border-transparent astw:bg-destructive astw:text-destructive-foreground astw:hover:bg-destructive/80",
neutral:
- "astw:border-transparent astw:bg-secondary astw:text-secondary-foreground astw:hover:bg-secondary/80",
+ "astw:border-transparent astw:bg-neutral-200 astw:text-neutral-700 astw:hover:bg-neutral-300 astw:dark:bg-neutral-800 astw:dark:text-neutral-200 astw:dark:hover:bg-neutral-700",
// Outline variants with status dots - matches Figma design
"outline-success":
"astw:gap-0.5 astw:pl-1.5 astw:pr-2 astw:border-border astw:bg-card astw:text-foreground",
diff --git a/packages/core/src/components/button.tsx b/packages/core/src/components/button.tsx
index f237cf91..fadd977b 100644
--- a/packages/core/src/components/button.tsx
+++ b/packages/core/src/components/button.tsx
@@ -16,7 +16,7 @@ const buttonVariants = cva(
outline:
"astw:border astw:bg-background astw:shadow-xs astw:hover:bg-accent astw:hover:text-accent-foreground astw:dark:bg-input/30 astw:dark:border-input astw:dark:hover:bg-input/50 astw:dark:hover:text-foreground",
secondary:
- "astw:bg-secondary astw:text-secondary-foreground astw:shadow-xs astw:hover:bg-secondary/80",
+ "astw:bg-secondary astw:text-secondary-foreground astw:shadow-xs astw:hover:brightness-95 astw:dark:hover:brightness-110",
ghost:
"astw:hover:bg-accent astw:hover:text-accent-foreground astw:dark:hover:bg-accent/50 astw:dark:hover:text-foreground",
link: "astw:text-primary astw:underline-offset-4 astw:hover:underline",
diff --git a/packages/core/src/components/theme-switcher.test.tsx b/packages/core/src/components/theme-switcher.test.tsx
index d4f25430..9f10f691 100644
--- a/packages/core/src/components/theme-switcher.test.tsx
+++ b/packages/core/src/components/theme-switcher.test.tsx
@@ -2,11 +2,12 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
-import { THEME_OPTIONS, ThemeProvider } from "@/contexts/theme-context";
+import { FONT_OPTIONS, THEME_OPTIONS, ThemeProvider } from "@/contexts/theme-context";
import { ThemeSwitcher } from "./theme-switcher";
const storageKey = "theme-switcher-test-key";
+const fontStorageKey = "theme-switcher-test-font-key";
/** happy-dom / Node can omit a full `localStorage`; ThemeProvider persists via it. */
function installLocalStorageStub() {
@@ -44,34 +45,39 @@ afterEach(() => {
});
describe("ThemeSwitcher", () => {
- it("opens a menu listing every theme option", async () => {
+ it("opens a menu listing every theme and font option", async () => {
const user = userEvent.setup();
render(
-
+
,
);
- await user.click(screen.getByRole("button", { name: "Theme" }));
+ await user.click(screen.getByRole("button", { name: "Appearance" }));
await waitFor(() => {
- expect(screen.getAllByRole("menuitemradio").length).toBe(THEME_OPTIONS.length);
+ 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: "Theme" });
+ 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);
});
@@ -80,12 +86,12 @@ describe("ThemeSwitcher", () => {
const user = userEvent.setup();
render(
-
+
,
);
- await user.click(screen.getByRole("button", { name: "Theme" }));
+ await user.click(screen.getByRole("button", { name: "Appearance" }));
await waitFor(() => {
expect(screen.getByRole("menu")).toBeDefined();
@@ -98,4 +104,32 @@ describe("ThemeSwitcher", () => {
});
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
index 6b7cf583..71136711 100644
--- a/packages/core/src/components/theme-switcher.tsx
+++ b/packages/core/src/components/theme-switcher.tsx
@@ -3,12 +3,19 @@ import { Palette } from "lucide-react";
import { Menu } from "@/components/menu";
import { Button } from "@/components/button";
import { cn } from "@/lib/utils";
-import { useTheme, type ResolvedTheme, type Theme, THEME_OPTIONS } from "@/contexts/theme-context";
+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",
- "deep-dark": "Deep dark",
cream: "Cream",
bloom: "Bloom",
};
@@ -19,16 +26,27 @@ const RESOLVED_THEME_SHORT: Record = {
const THEME_PREVIEW: Record = {
light: { a: "#ffffff", b: "#d4d4d8" },
dark: { a: "#3f3f46", b: "#d4d4d8" },
- "deep-dark": { a: "#09090b", b: "#a1a1aa" },
cream: { a: "#f8f3e4", b: "#e2d4fe" },
bloom: { a: "#535ae8", b: "#f8f3e4" },
system: { a: "#52525b", b: "#7c73e6" },
};
+/**
+ * Font preview — `font-family` used for the "Aa" sample so users see the candidate face before selecting.
+ */
+const FONT_PREVIEW: Record = {
+ geist: '"Geist Sans", ui-sans-serif, system-ui, sans-serif',
+ inter: '"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);
+}
+
function ThemePreviewSwatches({ themeId }: { themeId: Theme }) {
const { a, b } = THEME_PREVIEW[themeId];
return (
@@ -54,17 +72,34 @@ function ThemePreviewSwatches({ themeId }: { themeId: Theme }) {
);
}
+function FontPreview({ fontId }: { fontId: Font }) {
+ return (
+
+
+ Aa
+
+
+ );
+}
+
/**
- * Appearance menu: visual grid of every palette plus **System**.
- * Bound to stored `theme` (not resolved paint alone) so **System** stays explicit.
+ * 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 triggerTitle =
theme === "system"
- ? `Following system — currently ${RESOLVED_THEME_SHORT[resolvedTheme]}`
- : "Choose appearance theme";
+ ? `Following system — currently ${RESOLVED_THEME_SHORT[resolvedTheme]} · ${FONT_OPTIONS.find((o) => o.value === font)?.label ?? font}`
+ : "Choose appearance — color + font";
return (
@@ -75,7 +110,7 @@ function ThemeSwitcher() {
variant="outline"
size="icon"
className="astw:shrink-0"
- aria-label="Theme"
+ aria-label="Appearance"
title={triggerTitle}
/>
}
@@ -86,40 +121,85 @@ function ThemeSwitcher() {
position={{ side: "bottom", align: "end", sideOffset: 4 }}
className="astw:min-w-[16.5rem] astw:rounded-xl astw:p-3"
>
- {
- if (typeof value === "string" && isTheme(value)) setTheme(value);
- }}
- >
- {THEME_OPTIONS.map((opt) => (
-
-
- {opt.label}
-
-
- {opt.label}
-
- ))}
-
+
+ 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}
+
+
+ ))}
+
+
);
diff --git a/packages/core/src/contexts/theme-context.tsx b/packages/core/src/contexts/theme-context.tsx
index 6d06d39c..303dc908 100644
--- a/packages/core/src/contexts/theme-context.tsx
+++ b/packages/core/src/contexts/theme-context.tsx
@@ -1,37 +1,41 @@
import { createContext, useContext, useEffect, useMemo, useState } from "react";
-/** User-selectable theme. `system` follows OS light/dark (default palettes only — not cream/bloom/deep-dark). */
-export type Theme = "light" | "dark" | "deep-dark" | "cream" | "bloom" | "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" | "deep-dark" | "cream" | "bloom";
-
-const ALL_THEMES: readonly Theme[] = [
- "light",
- "dark",
- "deep-dark",
- "cream",
- "bloom",
- "system",
-] as const;
+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: "deep-dark", label: "Deep dark" },
{ value: "cream", label: "Cream" },
- { value: "bloom", label: "Bloom" },
{ 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": "deep-dark",
+ "tailor-dark": "dark",
};
function parseStoredTheme(value: string | null, fallback: Theme): Theme {
@@ -42,24 +46,34 @@ function parseStoredTheme(value: string | null, fallback: Theme): Theme {
return fallback;
}
-function readStoredTheme(storageKey: string, fallback: Theme): Theme {
+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 parseStoredTheme(getItem(storageKey), fallback);
+ return parse(getItem(storageKey), fallback);
} catch {
return fallback;
}
}
-function writeStoredTheme(storageKey: string, theme: Theme) {
+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, theme);
+ ls.setItem(storageKey, value);
} catch {
/* storage full or forbidden */
}
@@ -68,19 +82,25 @@ function writeStoredTheme(storageKey: string, theme: Theme) {
type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: Theme;
+ defaultFont?: Font;
storageKey: string;
+ fontStorageKey: string;
};
type ThemeProviderState = {
theme: Theme;
resolvedTheme: ResolvedTheme;
setTheme: (theme: Theme) => void;
+ font: Font;
+ setFont: (font: Font) => void;
};
const initialState: ThemeProviderState = {
- resolvedTheme: "light",
- theme: "system",
+ resolvedTheme: "bloom",
+ theme: "bloom",
setTheme: () => null,
+ font: "geist",
+ setFont: () => null,
};
const ThemeProviderContext = createContext(initialState);
@@ -94,29 +114,43 @@ function resolveTheme(theme: Theme): ResolvedTheme {
export function ThemeProvider({
children,
storageKey,
- defaultTheme = "system",
+ fontStorageKey,
+ defaultTheme = "bloom",
+ defaultFont = "geist",
...props
}: ThemeProviderProps) {
- const [theme, setThemeState] = useState(() => readStoredTheme(storageKey, defaultTheme));
+ const [theme, setThemeState] = useState(() =>
+ readStored(storageKey, defaultTheme, parseStoredTheme),
+ );
+ const [font, setFontState] = useState(() =>
+ readStored(fontStorageKey, defaultFont, parseStoredFont),
+ );
const resolvedTheme = useMemo(() => resolveTheme(theme), [theme]);
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove("light", "dark");
- root.classList.add(
- resolvedTheme === "dark" || resolvedTheme === "deep-dark" ? "dark" : "light",
- );
+ root.classList.add(resolvedTheme === "dark" ? "dark" : "light");
root.dataset.theme = resolvedTheme;
}, [resolvedTheme]);
+ useEffect(() => {
+ window.document.documentElement.dataset.font = font;
+ }, [font]);
+
const value = {
resolvedTheme,
theme,
setTheme: (newTheme: Theme) => {
- writeStoredTheme(storageKey, newTheme);
+ writeStored(storageKey, newTheme);
setThemeState(newTheme);
},
+ font,
+ setFont: (newFont: Font) => {
+ writeStored(fontStorageKey, newFont);
+ setFontState(newFont);
+ },
};
return (
@@ -131,5 +165,15 @@ export const useTheme = () => {
if (context === undefined) throw new Error("useTheme must be used within a ThemeProvider");
- return context;
+ const { theme, resolvedTheme, setTheme } = context;
+ return { 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 { font, setFont };
};
diff --git a/packages/core/src/globals.css b/packages/core/src/globals.css
index 13e3bdfd..915133cb 100644
--- a/packages/core/src/globals.css
+++ b/packages/core/src/globals.css
@@ -2,6 +2,10 @@
@import url("https://cdn.jsdelivr.net/npm/@fontsource/geist-sans@5.2.5/500.css");
@import url("https://cdn.jsdelivr.net/npm/@fontsource/geist-sans@5.2.5/600.css");
@import url("https://cdn.jsdelivr.net/npm/@fontsource/geist-sans@5.2.5/700.css");
+@import url("https://cdn.jsdelivr.net/npm/@fontsource/inter@5.2.5/400.css");
+@import url("https://cdn.jsdelivr.net/npm/@fontsource/inter@5.2.5/500.css");
+@import url("https://cdn.jsdelivr.net/npm/@fontsource/inter@5.2.5/600.css");
+@import url("https://cdn.jsdelivr.net/npm/@fontsource/inter@5.2.5/700.css");
@import "tailwindcss" prefix(astw);
@import "tw-animate-css";
@@ -41,14 +45,23 @@
}
/*
- * Cream / Bloom — vertical shell gradient (top = --background; bottom lighter via --shell-gradient-end).
+ * 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(--background), var(--shell-gradient-end));
+ 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%;
@@ -70,29 +83,32 @@
}
/*
- Tailor light / dark: Geist Sans + Apple-style corners (squircle) when the engine supports corner-shape (Chromium).
- Circles (.rounded-full) stay round; browsers without support keep standard border-radius.
- */
- :is(html[data-theme="cream"], html[data-theme="bloom"]) body,
- html[data-theme="deep-dark"] body {
+ * Font axis — applied independently of color theme via `data-font` on ``.
+ * Default Tailwind sans is the fallback when no `data-font` is set.
+ */
+ html[data-font="geist"] body,
+ html[data-font="geist"] :where(h1, h2, h3, h4, h5, h6) {
font-family: "Geist Sans", ui-sans-serif, system-ui, sans-serif;
}
- :is(html[data-theme="cream"], html[data-theme="bloom"]) :where(h1, h2, h3, h4, h5, h6),
- html[data-theme="deep-dark"] :where(h1, h2, h3, h4, h5, h6) {
+ html[data-font="inter"] body,
+ html[data-font="inter"] :where(h1, h2, h3, h4, h5, h6) {
+ font-family: "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;
- font-family: "Geist Sans", ui-sans-serif, system-ui, sans-serif;
}
+ /* 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"]) *,
- html[data-theme="deep-dark"] * {
+ :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"],
- html[data-theme="deep-dark"] [class*="rounded-full"] {
+ :is(html[data-theme="cream"], html[data-theme="bloom"]) [class*="rounded-full"] {
corner-shape: round;
}
}
@@ -121,290 +137,40 @@
}
/*
- * Cream / Bloom / deep-dark — tactile buttons: solid fill + 2px darker bottom lip (raised);
- * pressed: borders match fill (lip vanishes visually, no jump) + inset shade + slight translateY on label.
- * These themes use the same brand tokens and rim recipes (theme.css).
- *
- * ` }>` and ` }>` keep
- * `data-slot="dialog-trigger"` / `data-slot="dialog-close"` on the merged control; targets include those plus `button`.
+ * 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 but wins on specificity.
*/
@layer utilities {
- :is(html[data-theme="cream"], html[data-theme="bloom"]) :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"])[class*="shadow-xs"],
- html[data-theme="deep-dark"] :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"])[class*="shadow-xs"] {
- background-image: none;
- transition:
- filter 0.12s ease,
- border-color 0.12s ease,
- box-shadow 0.12s ease,
- transform 0.08s ease;
- }
-
- :is(html[data-theme="cream"], html[data-theme="bloom"])
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"])[class*="shadow-xs"][class*="astw:bg-primary"],
- html[data-theme="deep-dark"] :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"])[class*="shadow-xs"][class*="astw:bg-primary"] {
- background-color: var(--primary);
- color: var(--primary-foreground);
- border: 1px solid color-mix(in srgb, #10122b 38%, rgb(106 114 226));
- border-bottom: 2px solid color-mix(in srgb, #10122b 58%, rgb(73 76 205));
- box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.22);
- }
-
- :is(html[data-theme="cream"], html[data-theme="bloom"])
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"])[class*="shadow-xs"][class*="astw:bg-primary"]:hover:not(:disabled),
- html[data-theme="deep-dark"]
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"])[class*="shadow-xs"][class*="astw:bg-primary"]:hover:not(:disabled) {
- filter: brightness(1.05);
- }
-
- :is(html[data-theme="cream"], html[data-theme="bloom"])
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"])[class*="shadow-xs"][class*="astw:bg-primary"]:active:not(:disabled),
- html[data-theme="deep-dark"]
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"])[class*="shadow-xs"][class*="astw:bg-primary"]:active:not(:disabled) {
- filter: brightness(0.96);
- border: 1px solid var(--primary);
- border-bottom: 2px solid var(--primary);
- box-shadow: inset 0 3px 8px rgb(8 14 82 / 0.36);
- transform: translateY(2px);
- }
-
- :is(html[data-theme="cream"], html[data-theme="bloom"])
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"])[class*="shadow-xs"][class*="astw:bg-primary"]:focus-visible,
- html[data-theme="deep-dark"]
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"])[class*="shadow-xs"][class*="astw:bg-primary"]:focus-visible {
- box-shadow:
- inset 0 1px 0 rgb(255 255 255 / 0.22),
- 0 0 0 3px var(--ring) !important;
- }
-
- :is(html[data-theme="cream"], html[data-theme="bloom"])
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"])[class*="shadow-xs"][class*="astw:bg-secondary"],
- html[data-theme="deep-dark"]
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"])[class*="shadow-xs"][class*="astw:bg-secondary"] {
- background-color: var(--secondary);
- color: var(--secondary-foreground);
- border: 1px solid color-mix(in srgb, var(--secondary) 18%, rgb(110 94 164));
- border-bottom: 2px solid color-mix(in srgb, rgb(226 212 254) 30%, rgb(98 74 154));
- box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.65);
- }
-
- /*
- * Bloom: cream secondary on lavender shell — rims from --background (lighter than cream’s violet lip).
- */
- html[data-theme="bloom"] :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"])[class*="shadow-xs"][class*="astw:bg-secondary"] {
- border: 1px solid color-mix(in srgb, var(--background) 88%, rgb(118 108 168));
- border-bottom: 2px solid color-mix(in srgb, var(--background) 70%, rgb(105 96 152));
- }
-
- :is(html[data-theme="cream"], html[data-theme="bloom"])
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"])[class*="shadow-xs"][class*="astw:bg-secondary"]:hover:not(:disabled),
- html[data-theme="deep-dark"]
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"])[class*="shadow-xs"][class*="astw:bg-secondary"]:hover:not(:disabled) {
- filter: brightness(1.035);
- }
-
- :is(html[data-theme="cream"], html[data-theme="bloom"])
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"])[class*="shadow-xs"][class*="astw:bg-secondary"]:active:not(:disabled),
- html[data-theme="deep-dark"]
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"])[class*="shadow-xs"][class*="astw:bg-secondary"]:active:not(:disabled) {
- filter: brightness(0.98);
- border: 1px solid var(--secondary);
- border-bottom: 2px solid var(--secondary);
- box-shadow: inset 0 3px 8px rgb(50 42 118 / 0.22);
- transform: translateY(2px);
- }
-
+ /* Outline button on cream/bloom: transparent fill so the shell gradient shows through; keep border. */
:is(html[data-theme="cream"], html[data-theme="bloom"])
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"])[class*="shadow-xs"][class*="astw:bg-secondary"]:focus-visible,
- html[data-theme="deep-dark"]
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"])[class*="shadow-xs"][class*="astw:bg-secondary"]:focus-visible {
- box-shadow:
- inset 0 1px 0 rgb(255 255 255 / 0.65),
- 0 0 0 3px var(--ring) !important;
- }
-
- :is(html[data-theme="cream"], html[data-theme="bloom"])
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"])[class*="shadow-xs"][class*="astw:bg-destructive"],
- html[data-theme="deep-dark"]
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"])[class*="shadow-xs"][class*="astw:bg-destructive"] {
- background-color: var(--destructive);
- color: var(--destructive-foreground);
- border: 1px solid color-mix(in srgb, var(--destructive) 62%, rgb(92 26 26));
- border-bottom: 2px solid color-mix(in srgb, var(--destructive) 52%, rgb(76 22 26));
- box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.2);
- }
-
- :is(html[data-theme="cream"], html[data-theme="bloom"])
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"])[class*="shadow-xs"][class*="astw:bg-destructive"]:hover:not(:disabled),
- html[data-theme="deep-dark"]
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"])[class*="shadow-xs"][class*="astw:bg-destructive"]:hover:not(:disabled) {
- filter: brightness(1.04);
- }
-
- :is(html[data-theme="cream"], html[data-theme="bloom"])
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"])[class*="shadow-xs"][class*="astw:bg-destructive"]:active:not(:disabled),
- html[data-theme="deep-dark"]
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"])[class*="shadow-xs"][class*="astw:bg-destructive"]:active:not(:disabled) {
- filter: brightness(0.96);
- border: 1px solid var(--destructive);
- border-bottom: 2px solid var(--destructive);
- box-shadow: inset 0 4px 10px rgb(70 14 14 / 0.45);
- transform: translateY(2px);
- }
-
- :is(html[data-theme="cream"], html[data-theme="bloom"])
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"])[class*="shadow-xs"][class*="astw:bg-destructive"]:focus-visible,
- html[data-theme="deep-dark"]
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"])[class*="shadow-xs"][class*="astw:bg-destructive"]:focus-visible {
- box-shadow:
- inset 0 1px 0 rgb(255 255 255 / 0.2),
- 0 0 0 3px color-mix(in srgb, var(--destructive) 42%, transparent) !important;
- }
-
- :is(html[data-theme="cream"], html[data-theme="bloom"])
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"])[class*="shadow-xs"][class*="astw:bg-background"][class*="astw:border"] {
- background-color: var(--card);
- color: var(--card-foreground);
- border: 1px solid rgb(203 200 225 / 0.48);
- border-bottom: 2px solid rgb(154 158 184 / 0.62);
- box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.9);
- }
-
- /* Dark: subtler rims so outline doesn’t read as a bright halo on near-black UI. */
- html[data-theme="deep-dark"]
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"])[class*="shadow-xs"][class*="astw:bg-background"][class*="astw:border"] {
- background-color: var(--card);
- color: var(--card-foreground);
- border: 1px solid rgb(58 60 72 / 0.55);
- border-bottom: 2px solid rgb(34 36 46 / 0.82);
- box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.04);
- }
-
- :is(html[data-theme="cream"], html[data-theme="bloom"])
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"])[class*="shadow-xs"][class*="astw:bg-background"][class*="astw:border"]:hover:not(
- :disabled
- ) {
- border-color: rgb(185 180 218 / 0.42);
- border-bottom-color: rgb(135 142 188 / 0.58);
- filter: brightness(1.015);
- box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.98);
- }
-
- html[data-theme="deep-dark"]
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"])[class*="shadow-xs"][class*="astw:bg-background"][class*="astw:border"]:hover:not(
- :disabled
- ) {
- border-color: rgb(78 80 94 / 0.5);
- border-bottom-color: rgb(52 54 68 / 0.85);
- filter: brightness(1.04);
- box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.06);
- }
-
- :is(html[data-theme="cream"], html[data-theme="bloom"])
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"])[class*="shadow-xs"][class*="astw:bg-background"][class*="astw:border"]:active:not(
- :disabled
- ),
- html[data-theme="deep-dark"]
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"])[class*="shadow-xs"][class*="astw:bg-background"][class*="astw:border"]:active:not(
- :disabled
- ) {
- border: 1px solid var(--card);
- border-bottom: 2px solid var(--card);
- box-shadow: inset 0 3px 8px rgb(16 18 43 / 0.12);
- filter: none;
- transform: translateY(2px);
- }
-
- :is(html[data-theme="cream"], html[data-theme="bloom"])
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"])[class*="shadow-xs"][class*="astw:bg-background"][class*="astw:border"]:focus-visible {
- box-shadow:
- inset 0 1px 0 rgb(255 255 255 / 0.9),
- 0 0 0 3px var(--ring) !important;
- }
-
- html[data-theme="deep-dark"]
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"])[class*="shadow-xs"][class*="astw:bg-background"][class*="astw:border"]:focus-visible {
- box-shadow:
- inset 0 1px 0 rgb(255 255 255 / 0.05),
- 0 0 0 3px var(--ring) !important;
- }
-
- /*
- * Ghost (no shadow-xs): hover / pressed / focus match tactile outline (card + rim), not secondary.
- * Invisible border at rest matches outline lip widths so chrome does not shift layout.
- */
- :is(html[data-theme="cream"], html[data-theme="bloom"])
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"]):not([class*="shadow-xs"])[class*="astw:hover:bg-accent"],
- html[data-theme="deep-dark"]
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"]):not([class*="shadow-xs"])[class*="astw:hover:bg-accent"] {
- background-image: none;
- border: 1px solid transparent;
- border-bottom: 2px solid transparent;
+ [data-slot="button"][class*="astw:bg-background"][class*="astw:border"] {
+ background-color: transparent;
transition:
- filter 0.12s ease,
- border-color 0.12s ease,
- box-shadow 0.12s ease,
- transform 0.08s ease,
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"])
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"]):not([class*="shadow-xs"])[class*="astw:hover:bg-accent"]:hover:not(
- :disabled
- ) {
- background-color: var(--card);
- color: var(--card-foreground);
- border: 1px solid rgb(185 180 218 / 0.42);
- border-bottom: 2px solid rgb(135 142 188 / 0.58);
- filter: brightness(1.015);
- box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.98);
+ [data-slot="button"][class*="astw:bg-background"][class*="astw:border"]:hover:not(:disabled) {
+ background-color: var(--accent);
+ color: var(--accent-foreground);
}
- html[data-theme="deep-dark"]
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"]):not([class*="shadow-xs"])[class*="astw:hover:bg-accent"]:hover:not(
- :disabled
- ) {
- background-color: var(--card);
- color: var(--card-foreground);
- border: 1px solid rgb(78 80 94 / 0.5);
- border-bottom: 2px solid rgb(52 54 68 / 0.85);
- filter: brightness(1.04);
- box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.06);
- }
-
- :is(html[data-theme="cream"], html[data-theme="bloom"])
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"]):not([class*="shadow-xs"])[class*="astw:hover:bg-accent"]:active:not(
- :disabled
- ),
- html[data-theme="deep-dark"]
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"]):not([class*="shadow-xs"])[class*="astw:hover:bg-accent"]:active:not(
- :disabled
- ) {
- border: 1px solid var(--card);
- border-bottom: 2px solid var(--card);
- box-shadow: inset 0 3px 8px rgb(16 18 43 / 0.12);
- filter: none;
- transform: translateY(2px);
- background-color: var(--card);
- color: var(--card-foreground);
- }
-
- /* Match outline tactile: focus-visible adds ring atop existing rim (outline default already painted). */
- :is(html[data-theme="cream"], html[data-theme="bloom"])
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"]):not([class*="shadow-xs"])[class*="astw:hover:bg-accent"]:focus-visible:not(
- :disabled
- ) {
- box-shadow:
- inset 0 1px 0 rgb(255 255 255 / 0.9),
- 0 0 0 3px var(--ring) !important;
- }
-
- html[data-theme="deep-dark"]
- :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"]):not([class*="shadow-xs"])[class*="astw:hover:bg-accent"]:focus-visible:not(
- :disabled
- ) {
- box-shadow:
- inset 0 1px 0 rgb(255 255 255 / 0.05),
- 0 0 0 3px var(--ring) !important;
+ /*
+ * 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 123abcb3..8ee7f14c 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -26,10 +26,14 @@ export { WithGuard, type WithGuardProps } from "./components/with-guard";
export { useAppShell, useAppShellConfig, useAppShellData } from "./contexts/appshell-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 { type I18nLabels, defineI18nLabels } from "./hooks/i18n";