From c6aa695ee3ca8be359dca83cc8cee1c981915b44 Mon Sep 17 00:00:00 2001 From: itsprade Date: Wed, 13 May 2026 21:47:05 +0530 Subject: [PATCH 1/2] refactor(themes): drop deep-dark, default to bloom, flatten button + sidebar chrome - Remove deep-dark palette + types + switcher entry + globals selectors; dark covers the same need. - Default theme now bloom (existing localStorage choices preserved); legacy tailor-dark id remaps to dark. - Strip tactile button overrides; cream/bloom/dark buttons inherit default shadcn Button styling. - Drop sidebar right-edge divider (border-r/border-l and inset-variant border-x). - Dark --sidebar matches --background; sidebar blends with app surface. - Unify --destructive at #dc2626 across light/cream/bloom for brand-consistent red. - Neutral badge uses Tailwind neutral palette so lavender --secondary doesn't bleed in. - Outline button transparent on cream/bloom only (rule sits in @layer utilities to beat Tailwind's astw:bg-background which also lives in utilities). - Secondary button hover now brightness-based (visible regardless of token value). Co-Authored-By: Claude Opus 4.7 --- docs/api/use-theme.md | 15 +- docs/concepts/styling-theming.md | 3 +- .../src__components__button.test.tsx.snap | 2 +- packages/core/src/assets/theme.css | 67 +--- packages/core/src/components/appshell.tsx | 8 +- packages/core/src/components/badge.tsx | 2 +- packages/core/src/components/button.tsx | 2 +- packages/core/src/components/sidebar.tsx | 6 +- .../core/src/components/theme-switcher.tsx | 2 - packages/core/src/contexts/theme-context.tsx | 30 +- packages/core/src/globals.css | 302 +----------------- 11 files changed, 45 insertions(+), 394 deletions(-) diff --git a/docs/api/use-theme.md b/docs/api/use-theme.md index 76f0c0a4..4a68dc19 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 Logo; } diff --git a/docs/concepts/styling-theming.md b/docs/concepts/styling-theming.md index 23bd29fd..9db638ae 100644 --- a/docs/concepts/styling-theming.md +++ b/docs/concepts/styling-theming.md @@ -36,9 +36,8 @@ 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='…']`**. 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`] = `""`; -exports[`Button > snapshots > secondary variant 1`] = `""`; +exports[`Button > snapshots > secondary variant 1`] = `""`; exports[`Button > snapshots > small size 1`] = `""`; diff --git a/packages/core/src/assets/theme.css b/packages/core/src/assets/theme.css index 43a20eb6..2601670f 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); @@ -113,7 +114,8 @@ html[data-theme="cream"] { --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); + /* 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); @@ -166,9 +168,9 @@ html[data-theme="bloom"] { --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 +201,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..7a89c6e4 100644 --- a/packages/core/src/components/appshell.tsx +++ b/packages/core/src/components/appshell.tsx @@ -173,10 +173,10 @@ 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; }>; @@ -286,7 +286,7 @@ export const AppShell = (props: AppShellProps) => { 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/sidebar.tsx b/packages/core/src/components/sidebar.tsx index dcf593d5..8f7962ab 100644 --- a/packages/core/src/components/sidebar.tsx +++ b/packages/core/src/components/sidebar.tsx @@ -243,8 +243,7 @@ function Sidebar({ side === "left" ? "astw:left-0" : "astw:right-0", variant === "floating" || variant === "inset" ? "astw:p-2 astw:group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]" - : "astw:group-data-[collapsible=icon]:w-(--sidebar-width-icon) astw:group-data-[side=left]:border-r astw:group-data-[side=right]:border-l", - variant === "inset" && "astw:border-x astw:border-x-border", + : "astw:group-data-[collapsible=icon]:w-(--sidebar-width-icon)", )} >
= { light: "Light", dark: "Dark", - "deep-dark": "Deep dark", cream: "Cream", bloom: "Bloom", }; @@ -19,7 +18,6 @@ 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" }, diff --git a/packages/core/src/contexts/theme-context.tsx b/packages/core/src/contexts/theme-context.tsx index 6d06d39c..6746cf58 100644 --- a/packages/core/src/contexts/theme-context.tsx +++ b/packages/core/src/contexts/theme-context.tsx @@ -1,19 +1,12 @@ 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 }; @@ -21,7 +14,6 @@ export type ThemeOption = { readonly value: Theme; readonly label: string }; export const THEME_OPTIONS: readonly ThemeOption[] = [ { 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" }, @@ -31,7 +23,7 @@ export const THEME_OPTIONS: readonly ThemeOption[] = [ 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 { @@ -78,8 +70,8 @@ type ThemeProviderState = { }; const initialState: ThemeProviderState = { - resolvedTheme: "light", - theme: "system", + resolvedTheme: "bloom", + theme: "bloom", setTheme: () => null, }; @@ -94,7 +86,7 @@ function resolveTheme(theme: Theme): ResolvedTheme { export function ThemeProvider({ children, storageKey, - defaultTheme = "system", + defaultTheme = "bloom", ...props }: ThemeProviderProps) { const [theme, setThemeState] = useState(() => readStoredTheme(storageKey, defaultTheme)); @@ -104,9 +96,7 @@ export function ThemeProvider({ 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]); diff --git a/packages/core/src/globals.css b/packages/core/src/globals.css index 13e3bdfd..9760d2fa 100644 --- a/packages/core/src/globals.css +++ b/packages/core/src/globals.css @@ -69,30 +69,27 @@ background-color: transparent !important; } + /* - Tailor light / dark: Geist Sans + Apple-style corners (squircle) when the engine supports corner-shape (Chromium). + Cream / Bloom: 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 { + :is(html[data-theme="cream"], html[data-theme="bloom"]) body { 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) { + :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; } @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 +118,13 @@ } /* - * 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); - } - - :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; - } - + /* 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-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; - 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; - } - - :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); - } - - 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; + [data-slot="button"][class*="astw:bg-background"][class*="astw:border"] { + background-color: transparent; } } From ba6c8303765c963aab67e4aabb2e23b92c53f6bb Mon Sep 17 00:00:00 2001 From: itsprade Date: Fri, 15 May 2026 14:28:56 +0530 Subject: [PATCH 2/2] 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) --- docs/api/use-theme.md | 46 +++++ docs/concepts/styling-theming.md | 11 ++ packages/core/src/assets/theme.css | 23 +-- packages/core/src/components/appshell.tsx | 12 +- packages/core/src/components/sidebar.tsx | 6 +- .../src/components/theme-switcher.test.tsx | 52 +++++- .../core/src/components/theme-switcher.tsx | 162 +++++++++++++----- packages/core/src/contexts/theme-context.tsx | 70 +++++++- packages/core/src/globals.css | 62 ++++++- packages/core/src/index.ts | 4 + 10 files changed, 370 insertions(+), 78 deletions(-) diff --git a/docs/api/use-theme.md b/docs/api/use-theme.md index 4a68dc19..3658217c 100644 --- a/docs/api/use-theme.md +++ b/docs/api/use-theme.md @@ -131,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 ; +} +``` + +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 9db638ae..8f702eba 100644 --- a/docs/concepts/styling-theming.md +++ b/docs/concepts/styling-theming.md @@ -41,6 +41,17 @@ AppShell ships **five** named semantic palettes controlled by **`data-theme`** o 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/src/assets/theme.css b/packages/core/src/assets/theme.css index 2601670f..4bf9563c 100644 --- a/packages/core/src/assets/theme.css +++ b/packages/core/src/assets/theme.css @@ -97,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); @@ -106,14 +107,14 @@ 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); + --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); @@ -132,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); @@ -152,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); @@ -161,8 +163,9 @@ 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); diff --git a/packages/core/src/components/appshell.tsx b/packages/core/src/components/appshell.tsx index 7a89c6e4..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"; @@ -179,6 +179,14 @@ type SharedAppShellProps = React.PropsWithChildren<{ * @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; }>; /** @@ -287,7 +295,9 @@ export const AppShell = (props: AppShellProps) => { {props.children} diff --git a/packages/core/src/components/sidebar.tsx b/packages/core/src/components/sidebar.tsx index 8f7962ab..dcf593d5 100644 --- a/packages/core/src/components/sidebar.tsx +++ b/packages/core/src/components/sidebar.tsx @@ -243,7 +243,8 @@ function Sidebar({ side === "left" ? "astw:left-0" : "astw:right-0", variant === "floating" || variant === "inset" ? "astw:p-2 astw:group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]" - : "astw:group-data-[collapsible=icon]:w-(--sidebar-width-icon)", + : "astw:group-data-[collapsible=icon]:w-(--sidebar-width-icon) astw:group-data-[side=left]:border-r astw:group-data-[side=right]:border-l", + variant === "inset" && "astw:border-x astw:border-x-border", )} >
{ }); 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 dd1b1b1f..71136711 100644 --- a/packages/core/src/components/theme-switcher.tsx +++ b/packages/core/src/components/theme-switcher.tsx @@ -3,7 +3,15 @@ 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", @@ -23,10 +31,22 @@ const THEME_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 ( @@ -52,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 ( @@ -73,7 +110,7 @@ function ThemeSwitcher() { variant="outline" size="icon" className="astw:shrink-0" - aria-label="Theme" + aria-label="Appearance" title={triggerTitle} /> } @@ -84,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 6746cf58..303dc908 100644 --- a/packages/core/src/contexts/theme-context.tsx +++ b/packages/core/src/contexts/theme-context.tsx @@ -12,13 +12,25 @@ const ALL_THEMES: readonly Theme[] = ["light", "dark", "cream", "bloom", "system 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: "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", @@ -34,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 */ } @@ -60,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: "bloom", theme: "bloom", setTheme: () => null, + font: "geist", + setFont: () => null, }; const ThemeProviderContext = createContext(initialState); @@ -86,10 +114,17 @@ function resolveTheme(theme: Theme): ResolvedTheme { export function ThemeProvider({ children, storageKey, + 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]); @@ -100,13 +135,22 @@ export function ThemeProvider({ 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 ( @@ -121,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 9760d2fa..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%; @@ -69,21 +82,27 @@ background-color: transparent !important; } - /* - Cream / Bloom: 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 { + * 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; } + 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"]) * { corner-shape: squircle; @@ -126,5 +145,32 @@ :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 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";