From e27c06c0f4abb335b2c4acb266b8842f9a58c667 Mon Sep 17 00:00:00 2001 From: itsprade Date: Tue, 28 Apr 2026 22:10:28 +0530 Subject: [PATCH 01/18] feat(themes): tailor-light/dark palettes, ThemeProvider API, tactile buttons Made-with: Cursor --- .changeset/tailor-theme-palettes.md | 26 +++ CLAUDE.md | 2 +- README.md | 4 +- docs/api/use-theme.md | 100 ++++---- docs/concepts/styling-theming.md | 15 ++ examples/nextjs-app/next.config.ts | 10 +- examples/nextjs-app/package.json | 1 + packages/core/src/assets/theme.css | 133 ++++++++++- packages/core/src/components/appshell.tsx | 18 +- packages/core/src/components/sidebar.tsx | 2 +- .../src/components/sidebar/sidebar-layout.tsx | 10 +- packages/core/src/contexts/theme-context.tsx | 68 +++++- packages/core/src/globals.css | 215 ++++++++++++++++++ packages/core/src/index.ts | 2 +- 14 files changed, 536 insertions(+), 70 deletions(-) create mode 100644 .changeset/tailor-theme-palettes.md diff --git a/.changeset/tailor-theme-palettes.md b/.changeset/tailor-theme-palettes.md new file mode 100644 index 00000000..bac1d77c --- /dev/null +++ b/.changeset/tailor-theme-palettes.md @@ -0,0 +1,26 @@ +--- +"@tailor-platform/app-shell": minor +--- + +Adds **Tailor** brand theme presets alongside the existing default light and dark palettes. `useTheme` / `ThemeProvider` accept `light`, `dark`, `tailor-light`, `tailor-dark`, and `system`. The document root sets `data-theme` to the resolved palette and keeps `class="light"` or `class="dark"` for Tailwind `dark` mode (`tailor-dark` uses the `dark` class). + +Semantic tokens in `theme.css` include placeholder **Tailor light** and **Tailor dark** values (indigo / slate–emerald styling) you can tune toward final brand colors. Status colors and elevation shadows (`--semantic-shadow-*`, mapped to `@theme` shadow keys) are centralized so each palette can override them. + +`AppShell` accepts an optional `defaultTheme` when no value is stored in `localStorage`. Exports: `Theme`, `ResolvedTheme`. + +The sidebar floating menu outline hover style now uses `var(--sidebar-border)` / `var(--sidebar-accent)` instead of `hsl(...)`, fixing invalid color math with `rgba`-based CSS variables. + +```tsx +import { AppShell, useTheme, type Theme } from "@tailor-platform/app-shell"; + +{/* ... */}; + +function Switcher() { + const { setTheme } = useTheme(); + return ( + + ); +} +``` diff --git a/CLAUDE.md b/CLAUDE.md index 0c6695e6..7955204a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,7 +55,7 @@ Tailor Platform AppShell - A React-based framework for building ERP applications # Install dependencies pnpm install -# Start dev server (opens localhost:3000 with example app) +# Start Next.js example (localhost:3000). Use `pnpm dev:examples` only if you need every example dev server. pnpm dev ``` diff --git a/README.md b/README.md index e79641bd..87c63e1c 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,9 @@ pnpm install ### Commands ```bash -pnpm dev # Start all packages in development mode with hot reloading +pnpm dev # Next.js example only (localhost:3000) — recommended default +pnpm dev:examples # All examples in parallel (Next + Vite + app-module watch; heavy) +pnpm dev:vite # Vite example only pnpm build # Build all packages for production pnpm type-check # Run type checking across all packages ``` diff --git a/docs/api/use-theme.md b/docs/api/use-theme.md index 7f3821ea..01b96182 100644 --- a/docs/api/use-theme.md +++ b/docs/api/use-theme.md @@ -1,103 +1,119 @@ --- title: useTheme -description: Hook for accessing and controlling theme (light/dark mode) +description: Hook for accessing and controlling theme appearance (default and Tailor palettes) --- # useTheme -React hook to access and control the current theme (light/dark mode). +React hook to access and control the current appearance theme. ## Signature +Exported types: + +```typescript +export type Theme = "light" | "dark" | "tailor-light" | "tailor-dark" | "system"; + +export type ResolvedTheme = "light" | "dark" | "tailor-light" | "tailor-dark"; +``` + +Hook return value: + ```typescript const useTheme: () => { - theme: "light" | "dark" | "system"; - setTheme: (theme: "light" | "dark" | "system") => void; - systemTheme: "light" | "dark"; + theme: Theme; + resolvedTheme: ResolvedTheme; + setTheme: (theme: Theme) => void; }; ``` -## Return Value +## Return value ### `theme` -- **Type:** `"light" | "dark" | "system"` -- **Description:** Current theme setting +- **Type:** `Theme` +- **Description:** Stored theme preference. **`system`** means “follow OS light/dark” for the **default** light/dark palettes only (not Tailor). -### `setTheme()` +### `resolvedTheme` -- **Type:** `(theme: "light" | "dark" | "system") => void` -- **Description:** Set the theme. Persisted to localStorage. +- **Type:** `ResolvedTheme` +- **Description:** Concrete palette after resolving **`system`** to **`light`** or **`dark`**. **`tailor-light`** and **`tailor-dark`** are never produced by **`system`**; pick them explicitly with **`setTheme`**. -### `systemTheme` +When **`resolvedTheme`** changes, **`document.documentElement`** gets **`data-theme`** set to this value and a **`light`** / **`dark`** class for Tailwind **`dark`** variant compatibility. -- **Type:** `"light" | "dark"` -- **Description:** System preference (from OS settings) +### `setTheme(theme)` + +- **Type:** `(theme: Theme) => void` +- **Description:** Set the theme. Persisted to **`localStorage`** (key **`appshell-ui-theme`** when using AppShell’s built-in provider). ## Usage -### Display Current Theme +### Display current themes ```typescript import { useTheme } from "@tailor-platform/app-shell"; function ThemeDisplay() { - const { theme } = useTheme(); + const { theme, resolvedTheme } = useTheme(); - return
Current theme: {theme}
; + return ( +
+ Preference: {theme} · Effective: {resolvedTheme} +
+ ); } ``` -### Theme Toggle +### Set a named palette ```typescript -function ThemeToggle() { - const { theme, setTheme } = useTheme(); - - const toggleTheme = () => { - setTheme(theme === "dark" ? "light" : "dark"); - }; - - return ; +function UseTailorLight() { + const { setTheme } = useTheme(); + return ; } ``` -### Theme Selector +### Theme selector ```typescript function ThemeSelector() { const { theme, setTheme } = useTheme(); return ( - setTheme(e.target.value as Theme)} + > + + + + ); } ``` -### Conditional Rendering +### Logo or assets by lightness + +Use **`resolvedTheme`** and treat **`tailor-light`** like light and **`tailor-dark`** like dark for monochrome assets: ```typescript function Logo() { - const { theme, systemTheme } = useTheme(); - const effectiveTheme = theme === "system" ? systemTheme : theme; + const { resolvedTheme } = useTheme(); + const darkish = + resolvedTheme === "dark" || resolvedTheme === "tailor-dark"; - return ( - Logo - ); + return Logo; } ``` -## Theme Persistence +## Theme persistence + +The built-in **`ThemeProvider`** (used inside **`AppShell`**) persists the **`theme`** value to **`localStorage`** and restores it on reload. -Theme preference is automatically saved to localStorage and restored on page load. +Use **`AppShell`**’s **`defaultTheme`** prop for the initial value when nothing is stored. ## Related -- [Styling & Theming](../concepts/styling-theming.md) - Theme customization +- [Styling & Theming](../concepts/styling-theming.md) diff --git a/docs/concepts/styling-theming.md b/docs/concepts/styling-theming.md index 60c13ae7..cf86a58a 100644 --- a/docs/concepts/styling-theming.md +++ b/docs/concepts/styling-theming.md @@ -26,6 +26,21 @@ E.g. Note, many of these are default Tailwind colors, but there are some differences. If you omit this, much of the UI will look the same, but we will lose some of the Tailor-preferred colors. +## Built-in palettes (default and Tailor) + +AppShell ships **four** named semantic palettes controlled by **`data-theme`** on **``** (see [`useTheme`](../api/use-theme.md)): + +| Resolved theme | Purpose | +| -------------- | -------------------------------------------- | +| `light` | Default neutral light (historic AppShell UI) | +| `dark` | Default neutral dark | +| `tailor-light` | Tailor brand — light tuning surface | +| `tailor-dark` | Tailor brand — dark tuning surface | + +**`system`** resolves to **`light`** or **`dark`** only — not **`tailor-*`**. Apps that want Tailor must set **`tailor-light`** or **`tailor-dark`** explicitly. + +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='…']`**. + ## 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/examples/nextjs-app/next.config.ts b/examples/nextjs-app/next.config.ts index e9ffa308..3e263b8c 100644 --- a/examples/nextjs-app/next.config.ts +++ b/examples/nextjs-app/next.config.ts @@ -1,7 +1,15 @@ +import path from "node:path"; import type { NextConfig } from "next"; +const monorepoRoot = path.join(__dirname, "..", ".."); + const nextConfig: NextConfig = { - /* config options here */ + turbopack: { + root: monorepoRoot, + }, + experimental: { + turbopackMemoryLimit: 1536 * 1024 * 1024, + }, }; export default nextConfig; diff --git a/examples/nextjs-app/package.json b/examples/nextjs-app/package.json index 533c70c4..5cf07751 100644 --- a/examples/nextjs-app/package.json +++ b/examples/nextjs-app/package.json @@ -3,6 +3,7 @@ "private": true, "scripts": { "dev": "next dev --turbopack", + "dev:webpack": "next dev", "build": "next build", "start": "next start", "lint": "oxlint -c .oxlintrc.jsonc", diff --git a/packages/core/src/assets/theme.css b/packages/core/src/assets/theme.css index a594613b..6a38cc61 100644 --- a/packages/core/src/assets/theme.css +++ b/packages/core/src/assets/theme.css @@ -34,6 +34,17 @@ --sidebar-accent-foreground: rgba(23, 23, 23, 1); --sidebar-border: rgba(229, 229, 229, 1); --sidebar-ring: rgba(163, 163, 163, 1); + /* Status (overridable per theme) */ + --status-default: #737373; + --status-neutral: #0ea5e9; + --status-completed: #22c55e; + --status-attention: #f59e0b; + --status-danger: #ef4444; + /* Elevation (wired through @theme; override per Tailor palettes) */ + --semantic-shadow-xs: 0 1px 2px 0 rgb(15 23 42 / 0.06); + --semantic-shadow-sm: 0 1px 3px 0 rgb(15 23 42 / 0.08), 0 1px 2px -1px rgb(15 23 42 / 0.08); + --semantic-shadow-md: 0 4px 6px -1px rgb(15 23 42 / 0.09), 0 2px 4px -2px rgb(15 23 42 / 0.06); + --semantic-shadow-lg: 0 10px 15px -3px rgb(15 23 42 / 0.12), 0 4px 6px -4px rgb(15 23 42 / 0.09); } .dark { @@ -70,6 +81,112 @@ --sidebar-accent-foreground: rgba(250, 250, 250, 1); --sidebar-border: rgba(255, 255, 255, 0.10000000149011612); --sidebar-ring: rgba(82, 82, 82, 1); + --semantic-shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.35); + --semantic-shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.38), 0 1px 2px -1px rgb(0 0 0 / 0.32); + --semantic-shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.35), 0 2px 4px -2px rgb(0 0 0 / 0.28); + --semantic-shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.42), 0 4px 6px -4px rgb(0 0 0 / 0.35); +} + +/** + * Tailor brand — light palette (Tailor brand guidelines). + * Primary: #535AE8 · Text: #10122B · White: #FFFFFF + * Secondary: Deep Cyan #00979C · Dark Green #013742 · Off-White #F8F3E4 · Neutral #EEEEEE · Light Violet #E2D4FE + */ +html[data-theme="tailor-light"] { + color-scheme: light; + /* Off-white app shell; white cards for elevation */ + --background: rgba(248, 243, 228, 1); + --foreground: rgba(16, 18, 43, 1); + --card: rgba(255, 255, 255, 1); + --card-foreground: rgba(16, 18, 43, 1); + --popover: rgba(255, 255, 255, 1); + --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) */ + --secondary: rgba(226, 212, 254, 1); + --secondary-foreground: rgba(1, 55, 66, 1); + --muted: rgba(238, 238, 238, 1); + --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-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); + --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); + /* Larger corner scale; squircle overlays via globals.css where supported */ + --radius: 1rem; + --sidebar: rgba(248, 243, 228, 1); + --sidebar-foreground: rgba(16, 18, 43, 1); + --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-border: rgba(0, 0, 0, 0.08); + --sidebar-ring: rgba(83, 90, 232, 0.45); + --status-default: rgba(16, 18, 43, 0.55); + --status-neutral: #00979c; + --status-completed: #00979c; + --status-attention: #d97706; + --status-danger: #dc2626; + --semantic-shadow-xs: 0 1px 2px 0 rgb(16 18 43 / 0.07); + --semantic-shadow-sm: 0 1px 3px 0 rgb(16 18 43 / 0.08), 0 1px 2px -1px rgb(16 18 43 / 0.06); + --semantic-shadow-md: 0 4px 6px -1px rgb(16 18 43 / 0.1), 0 2px 4px -2px rgb(16 18 43 / 0.08); + --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 (distinct from default dark for tuning). + */ +html[data-theme="tailor-dark"] { + color-scheme: dark; + --background: rgba(15, 23, 42, 1); + --foreground: rgba(241, 245, 249, 1); + --card: rgba(30, 41, 59, 1); + --card-foreground: rgba(241, 245, 249, 1); + --popover: rgba(30, 41, 59, 1); + --popover-foreground: rgba(241, 245, 249, 1); + --primary: rgba(52, 211, 153, 1); + --primary-foreground: rgba(6, 78, 59, 1); + --secondary: rgba(51, 65, 85, 1); + --secondary-foreground: rgba(241, 245, 249, 1); + --muted: rgba(51, 65, 85, 1); + --muted-foreground: rgba(148, 163, 184, 1); + --accent: rgba(71, 85, 105, 1); + --accent-foreground: rgba(241, 245, 249, 1); + --destructive: rgba(248, 113, 113, 1); + --destructive-foreground: rgba(254, 242, 242, 1); + --border: rgba(71, 85, 105, 1); + --input: rgba(71, 85, 105, 1); + --ring: rgba(52, 211, 153, 1); + --chart-1: rgba(52, 211, 153, 1); + --chart-2: rgba(94, 234, 212, 1); + --chart-3: rgba(251, 191, 36, 1); + --chart-4: rgba(196, 181, 253, 1); + --chart-5: rgba(251, 113, 133, 1); + --sidebar: rgba(15, 23, 42, 1); + --sidebar-foreground: rgba(241, 245, 249, 1); + --sidebar-primary: rgba(16, 185, 129, 1); + --sidebar-primary-foreground: rgba(6, 78, 59, 1); + --sidebar-accent: rgba(51, 65, 85, 1); + --sidebar-accent-foreground: rgba(241, 245, 249, 1); + --sidebar-border: rgba(71, 85, 105, 1); + --sidebar-ring: rgba(52, 211, 153, 1); + --status-default: #94a3b8; + --status-neutral: #38bdf8; + --status-completed: #34d399; + --status-attention: #fbbf24; + --status-danger: #f87171; + --semantic-shadow-xs: 0 1px 2px 0 rgb(15 118 110 / 0.22); + --semantic-shadow-sm: 0 1px 3px 0 rgb(16 185 129 / 0.28), 0 1px 2px -1px rgb(16 185 129 / 0.22); + --semantic-shadow-md: 0 4px 6px -1px rgb(6 95 70 / 0.35), 0 2px 4px -2px rgb(6 95 70 / 0.28); + --semantic-shadow-lg: 0 10px 15px -3px rgb(6 78 59 / 0.45), 0 4px 6px -4px rgb(6 78 59 / 0.35); } @theme inline { @@ -110,10 +227,14 @@ --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); - /* Statuses */ - --color-status-default: #737373; - --color-status-neutral: #0ea5e9; - --color-status-completed: #22c55e; - --color-status-attention: #f59e0b; - --color-status-danger: #ef4444; + --color-status-default: var(--status-default); + --color-status-neutral: var(--status-neutral); + --color-status-completed: var(--status-completed); + --color-status-attention: var(--status-attention); + --color-status-danger: var(--status-danger); + + --shadow-xs: var(--semantic-shadow-xs); + --shadow-sm: var(--semantic-shadow-sm); + --shadow-md: var(--semantic-shadow-md); + --shadow-lg: var(--semantic-shadow-lg); } diff --git a/packages/core/src/components/appshell.tsx b/packages/core/src/components/appshell.tsx index bf4d8376..036faddc 100644 --- a/packages/core/src/components/appshell.tsx +++ b/packages/core/src/components/appshell.tsx @@ -17,7 +17,7 @@ import { type ContextData, } from "@/contexts/appshell-context"; import { RouterContainer } from "@/routing/router"; -import { ThemeProvider } from "@/contexts/theme-context"; +import { ThemeProvider, type Theme } 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"; @@ -177,6 +177,17 @@ type SharedAppShellProps = React.PropsWithChildren<{ * ``` */ searchSources?: readonly SearchSource[]; + + /** + * Initial theme before any value is loaded from localStorage (`appshell-ui-theme`). + * Does not replace a stored preference. + * + * Includes **Tailor** brand palettes (`tailor-light`, `tailor-dark`) in addition to + * default light/dark and `system` (OS preference maps to **default** light or dark only). + * + * @default "system" + */ + defaultTheme?: Theme; }>; /** @@ -320,7 +331,10 @@ export const AppShell = (props: AppShellProps) => { - + {props.children} diff --git a/packages/core/src/components/sidebar.tsx b/packages/core/src/components/sidebar.tsx index 6b9356bc..c7eb7ed8 100644 --- a/packages/core/src/components/sidebar.tsx +++ b/packages/core/src/components/sidebar.tsx @@ -556,7 +556,7 @@ const sidebarMenuButtonVariants = cva( variant: { default: "astw:hover:bg-sidebar-accent astw:hover:text-sidebar-accent-foreground", outline: - "astw:bg-background astw:shadow-[0_0_0_1px_hsl(var(--sidebar-border))] astw:hover:bg-sidebar-accent astw:hover:text-sidebar-accent-foreground astw:hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]", + "astw:bg-background astw:shadow-[0_0_0_1px_var(--sidebar-border)] astw:hover:bg-sidebar-accent astw:hover:text-sidebar-accent-foreground astw:hover:shadow-[0_0_0_1px_var(--sidebar-accent)]", }, size: { default: "astw:h-8 astw:text-sm", diff --git a/packages/core/src/components/sidebar/sidebar-layout.tsx b/packages/core/src/components/sidebar/sidebar-layout.tsx index 30961c25..fda3ffbc 100644 --- a/packages/core/src/components/sidebar/sidebar-layout.tsx +++ b/packages/core/src/components/sidebar/sidebar-layout.tsx @@ -2,7 +2,9 @@ import { SidebarProvider, SidebarInset, SidebarTrigger, useSidebar } from "@/com import { SunIcon } from "lucide-react"; import { AppShellOutlet } from "@/components/content"; import { Button } from "@/components/button"; -import { useTheme } from "@/contexts/theme-context"; +import { useTheme, type ResolvedTheme } from "@/contexts/theme-context"; + +const RESOLVED_THEME_CYCLE: ResolvedTheme[] = ["light", "dark", "tailor-light", "tailor-dark"]; import { DefaultSidebar } from "./default-sidebar"; import { DynamicBreadcrumb } from "@/components/dynamic-breadcrumb"; @@ -68,9 +70,11 @@ const HidableSidebarTrigger = () => { export const SidebarLayout = (props: SidebarLayoutProps) => { const Children = props.children ? props.children({ Outlet: AppShellOutlet }) : null; - const themeContext = useTheme(); + const { resolvedTheme, setTheme } = useTheme(); const toggleTheme = () => { - themeContext.setTheme(themeContext.theme === "dark" ? "light" : "dark"); + const i = RESOLVED_THEME_CYCLE.indexOf(resolvedTheme); + const next = RESOLVED_THEME_CYCLE[(i === -1 ? 0 : i + 1) % RESOLVED_THEME_CYCLE.length]; + setTheme(next); }; return ( diff --git a/packages/core/src/contexts/theme-context.tsx b/packages/core/src/contexts/theme-context.tsx index 77f62dc6..45457c84 100644 --- a/packages/core/src/contexts/theme-context.tsx +++ b/packages/core/src/contexts/theme-context.tsx @@ -1,6 +1,46 @@ import { createContext, useContext, useEffect, useMemo, useState } from "react"; -type Theme = "dark" | "light" | "system"; +/** User-selectable theme. `system` follows OS light/dark (default palettes only — B1). */ +export type Theme = "light" | "dark" | "tailor-light" | "tailor-dark" | "system"; + +/** Resolved paint after applying `system`. */ +export type ResolvedTheme = "light" | "dark" | "tailor-light" | "tailor-dark"; + +const ALL_THEMES: readonly Theme[] = [ + "light", + "dark", + "tailor-light", + "tailor-dark", + "system", +] as const; + +function parseStoredTheme(value: string | null, fallback: Theme): Theme { + if (value && (ALL_THEMES as readonly string[]).includes(value)) return value as Theme; + return fallback; +} + +function readStoredTheme(storageKey: string, fallback: Theme): Theme { + 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); + } catch { + return fallback; + } +} + +function writeStoredTheme(storageKey: string, theme: Theme) { + if (typeof window === "undefined") return; + const ls = window.localStorage; + if (!ls || typeof ls.setItem !== "function") return; + try { + ls.setItem(storageKey, theme); + } catch { + /* storage full or forbidden */ + } +} type ThemeProviderProps = { children: React.ReactNode; @@ -10,7 +50,7 @@ type ThemeProviderProps = { type ThemeProviderState = { theme: Theme; - resolvedTheme: Omit; + resolvedTheme: ResolvedTheme; setTheme: (theme: Theme) => void; }; @@ -22,33 +62,37 @@ const initialState: ThemeProviderState = { const ThemeProviderContext = createContext(initialState); +function resolveTheme(theme: Theme): ResolvedTheme { + if (theme !== "system") return theme; + if (typeof window === "undefined") return "light"; + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +} + export function ThemeProvider({ children, storageKey, defaultTheme = "system", ...props }: ThemeProviderProps) { - const [theme, setTheme] = useState( - () => (localStorage.getItem(storageKey) as Theme) || defaultTheme, - ); + const [theme, setThemeState] = useState(() => readStoredTheme(storageKey, defaultTheme)); - const resolvedTheme = useMemo(() => { - if (theme !== "system") return theme; - return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; - }, [theme]); + const resolvedTheme = useMemo(() => resolveTheme(theme), [theme]); useEffect(() => { const root = window.document.documentElement; root.classList.remove("light", "dark"); - root.classList.add(resolvedTheme); + root.classList.add( + resolvedTheme === "dark" || resolvedTheme === "tailor-dark" ? "dark" : "light", + ); + root.dataset.theme = resolvedTheme; }, [resolvedTheme]); const value = { resolvedTheme, theme, setTheme: (newTheme: Theme) => { - localStorage.setItem(storageKey, newTheme); - setTheme(newTheme); + writeStoredTheme(storageKey, newTheme); + setThemeState(newTheme); }, }; diff --git a/packages/core/src/globals.css b/packages/core/src/globals.css index f55ebe89..e77fd5f5 100644 --- a/packages/core/src/globals.css +++ b/packages/core/src/globals.css @@ -1,3 +1,8 @@ +@import url("https://cdn.jsdelivr.net/npm/@fontsource/geist-sans@5.2.5/400.css"); +@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 "tailwindcss" prefix(astw); @import "tw-animate-css"; @import "./assets/theme.css"; @@ -34,6 +39,29 @@ body { @apply astw:font-sans astw:antialiased astw:bg-background astw:text-foreground; } + /* + Tailor light: 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. + */ + html[data-theme="tailor-light"] body { + font-family: "Geist Sans", ui-sans-serif, system-ui, sans-serif; + } + + html[data-theme="tailor-light"] :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) { + html[data-theme="tailor-light"] * { + corner-shape: squircle; + } + /* Round radii (.rounded-full) stay circular squircles, not elongated “capsules” */ + html[data-theme="tailor-light"] [class*="rounded-full"] { + corner-shape: round; + } + } ::-webkit-scrollbar { @apply astw:w-2 astw:h-2 astw:bg-muted; } @@ -44,3 +72,190 @@ @apply astw:bg-muted-foreground; } } + +/* + * Tailor light — tactile buttons: inset highlight + bottom shade + outer rim. + * Corner radius comes from Button’s rounded-* utilities like other themes (no pill override). + * Only html[data-theme="tailor-light"]; raised variants share shadow-xs in button.tsx. + * Focus ring is folded into box-shadow so it does not clash with stacked shadows. + */ +@layer utilities { + html[data-theme="tailor-light"] [data-slot="button"][class*="shadow-xs"] { + transition: + filter 0.15s ease, + box-shadow 0.15s ease, + background-image 0.15s ease; + } + + /* Primary (brand chroma gradient + dark rim + inset gloss) */ + html[data-theme="tailor-light"] + [data-slot="button"][class*="shadow-xs"][class*="astw:bg-primary"] { + background-color: transparent; + border: 1px solid color-mix(in srgb, #10122b 70%, rgb(83 90 232 / 35%)); + background-image: linear-gradient( + 180deg, + rgb(126 133 246) 0%, + rgb(126 133 246) 10%, + rgb(68 73 218) 25%, + rgb(68 73 218) 85%, + rgb(54 62 178) 100% + ); + box-shadow: + inset 0 2px 1px rgb(255 255 255 / 0.28), + inset 0 -4px 5px rgb(0 10 54 / 0.36), + 0 1px 2px rgb(16 18 43 / 0.2); + } + + html[data-theme="tailor-light"] + [data-slot="button"][class*="shadow-xs"][class*="astw:bg-primary"]:hover:not(:disabled) { + filter: brightness(1.055); + box-shadow: + inset 0 2px 1px rgb(255 255 255 / 0.32), + inset 0 -4px 5px rgb(0 10 54 / 0.32), + 0 1px 3px rgb(16 18 43 / 0.18); + } + + html[data-theme="tailor-light"] + [data-slot="button"][class*="shadow-xs"][class*="astw:bg-primary"]:active:not(:disabled) { + filter: brightness(0.96); + box-shadow: + inset 0 3px 6px rgb(0 0 0 / 0.38), + inset 0 -1px 1px rgb(255 255 255 / 0.12), + 0 1px 1px rgb(16 18 43 / 0.12); + } + + html[data-theme="tailor-light"] + [data-slot="button"][class*="shadow-xs"][class*="astw:bg-primary"]:focus-visible { + box-shadow: + inset 0 2px 1px rgb(255 255 255 / 0.28), + inset 0 -4px 5px rgb(0 10 54 / 0.36), + 0 1px 2px rgb(16 18 43 / 0.2), + 0 0 0 3px var(--ring) !important; + } + + /* Secondary — light violet tactile surface */ + html[data-theme="tailor-light"] + [data-slot="button"][class*="shadow-xs"][class*="astw:bg-secondary"] { + background-color: transparent; + border: 1px solid rgb(154 148 209 / 0.45); + background-image: linear-gradient( + 180deg, + rgb(239 229 253) 0%, + rgb(216 188 246) 50%, + rgb(206 174 239) 100% + ); + box-shadow: + inset 0 2px 1px rgb(255 255 255 / 0.75), + inset 0 -3px 4px rgb(50 42 118 / 0.18), + 0 1px 2px rgb(16 18 43 / 0.1); + } + + html[data-theme="tailor-light"] + [data-slot="button"][class*="shadow-xs"][class*="astw:bg-secondary"]:hover:not(:disabled) { + filter: brightness(1.04); + } + + html[data-theme="tailor-light"] + [data-slot="button"][class*="shadow-xs"][class*="astw:bg-secondary"]:active:not(:disabled) { + filter: brightness(0.97); + box-shadow: + inset 0 3px 5px rgb(50 42 118 / 0.22), + inset 0 -1px 1px rgb(255 255 255 / 0.6), + 0 1px 1px rgb(16 18 43 / 0.08); + } + + html[data-theme="tailor-light"] + [data-slot="button"][class*="shadow-xs"][class*="astw:bg-secondary"]:focus-visible { + box-shadow: + inset 0 2px 1px rgb(255 255 255 / 0.75), + inset 0 -3px 4px rgb(50 42 118 / 0.18), + 0 1px 2px rgb(16 18 43 / 0.1), + 0 0 0 3px var(--ring) !important; + } + + /* Destructive */ + html[data-theme="tailor-light"] + [data-slot="button"][class*="shadow-xs"][class*="astw:bg-destructive"] { + background-color: transparent; + border: 1px solid color-mix(in srgb, #10122b 45%, rgb(156 42 42)); + background-image: linear-gradient( + 180deg, + rgb(219 71 71) 0%, + rgb(175 42 42) 50%, + rgb(154 38 42) 100% + ); + box-shadow: + inset 0 2px 1px rgb(255 255 255 / 0.26), + inset 0 -4px 5px rgb(60 12 14 / 0.45), + 0 1px 2px rgb(16 18 43 / 0.18); + } + + html[data-theme="tailor-light"] + [data-slot="button"][class*="shadow-xs"][class*="astw:bg-destructive"]:hover:not(:disabled) { + filter: brightness(1.05); + } + + html[data-theme="tailor-light"] + [data-slot="button"][class*="shadow-xs"][class*="astw:bg-destructive"]:active:not(:disabled) { + filter: brightness(0.96); + box-shadow: + inset 0 3px 6px rgb(40 12 14 / 0.45), + inset 0 -1px 1px rgb(255 255 255 / 0.2), + 0 1px 1px rgb(16 18 43 / 0.12); + } + + html[data-theme="tailor-light"] + [data-slot="button"][class*="shadow-xs"][class*="astw:bg-destructive"]:focus-visible { + box-shadow: + inset 0 2px 1px rgb(255 255 255 / 0.26), + inset 0 -4px 5px rgb(60 12 14 / 0.45), + 0 1px 2px rgb(16 18 43 / 0.18), + 0 0 0 3px color-mix(in srgb, var(--destructive) 42%, transparent) !important; + } + + /* Outline — white tactile + gray rim (neutral “secondary” chrome) */ + html[data-theme="tailor-light"] + [data-slot="button"][class*="shadow-xs"][class*="astw:bg-background"][class*="astw:border"] { + background-color: transparent; + color: rgb(16 18 43); + border-color: rgb(185 183 206 / 0.65); + background-image: linear-gradient(180deg, rgb(255 255 255) 0%, rgb(246 246 251) 100%); + box-shadow: + inset 0 2px 1px rgb(255 255 255 / 0.95), + inset 0 -3px 4px rgb(16 18 43 / 0.08), + 0 1px 2px rgb(16 18 43 / 0.08); + } + + html[data-theme="tailor-light"] + [data-slot="button"][class*="shadow-xs"][class*="astw:bg-background"][class*="astw:border"]:hover:not( + :disabled + ) { + border-color: rgb(165 160 205 / 0.55); + background-image: linear-gradient(180deg, rgb(252 246 253) 0%, rgb(237 229 251) 100%); + box-shadow: + inset 0 2px 1px rgb(255 255 255 / 1), + inset 0 -3px 5px rgb(80 74 149 / 0.12), + 0 1px 3px rgb(16 18 43 / 0.1); + filter: none; + } + + html[data-theme="tailor-light"] + [data-slot="button"][class*="shadow-xs"][class*="astw:bg-background"][class*="astw:border"]:active:not( + :disabled + ) { + box-shadow: + inset 0 3px 5px rgb(16 18 43 / 0.1), + inset 0 -1px 1px rgb(255 255 255 / 0.7), + 0 1px 1px rgb(16 18 43 / 0.06); + filter: none; + } + + html[data-theme="tailor-light"] + [data-slot="button"][class*="shadow-xs"][class*="astw:bg-background"][class*="astw:border"]:focus-visible { + box-shadow: + inset 0 2px 1px rgb(255 255 255 / 0.95), + inset 0 -3px 4px rgb(16 18 43 / 0.08), + 0 1px 2px rgb(16 18 43 / 0.08), + 0 0 0 3px var(--ring) !important; + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index daef45a1..aaa268cd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -24,7 +24,7 @@ export { export { WithGuard, type WithGuardProps } from "./components/with-guard"; export { useAppShell, useAppShellConfig, useAppShellData } from "./contexts/appshell-context"; -export { useTheme } from "./contexts/theme-context"; +export { useTheme, type ResolvedTheme, type Theme } from "./contexts/theme-context"; export { type I18nLabels, defineI18nLabels } from "./hooks/i18n"; export { AuthProvider, From cb7c346f492c3e64e265911339f60bc4e1c83830 Mon Sep 17 00:00:00 2001 From: itsprade Date: Tue, 28 Apr 2026 22:11:11 +0530 Subject: [PATCH 02/18] style(tailor-light): tactile buttons with solid fills, 2px bottom lip, border 0 on press Made-with: Cursor --- packages/core/src/globals.css | 144 +++++++++++----------------------- 1 file changed, 46 insertions(+), 98 deletions(-) diff --git a/packages/core/src/globals.css b/packages/core/src/globals.css index e77fd5f5..4e108a68 100644 --- a/packages/core/src/globals.css +++ b/packages/core/src/globals.css @@ -74,156 +74,111 @@ } /* - * Tailor light — tactile buttons: inset highlight + bottom shade + outer rim. - * Corner radius comes from Button’s rounded-* utilities like other themes (no pill override). + * Tailor light — tactile buttons: solid fill + 2px darker bottom lip (raised); + * pressed: border 0 + inset shade. Corner radius stays from Button utilities. * Only html[data-theme="tailor-light"]; raised variants share shadow-xs in button.tsx. - * Focus ring is folded into box-shadow so it does not clash with stacked shadows. */ @layer utilities { html[data-theme="tailor-light"] [data-slot="button"][class*="shadow-xs"] { + background-image: none; transition: - filter 0.15s ease, - box-shadow 0.15s ease, - background-image 0.15s ease; + filter 0.12s ease, + border-width 0.12s ease, + border-color 0.12s ease, + box-shadow 0.12s ease; } - /* Primary (brand chroma gradient + dark rim + inset gloss) */ html[data-theme="tailor-light"] [data-slot="button"][class*="shadow-xs"][class*="astw:bg-primary"] { - background-color: transparent; - border: 1px solid color-mix(in srgb, #10122b 70%, rgb(83 90 232 / 35%)); - background-image: linear-gradient( - 180deg, - rgb(126 133 246) 0%, - rgb(126 133 246) 10%, - rgb(68 73 218) 25%, - rgb(68 73 218) 85%, - rgb(54 62 178) 100% - ); - box-shadow: - inset 0 2px 1px rgb(255 255 255 / 0.28), - inset 0 -4px 5px rgb(0 10 54 / 0.36), - 0 1px 2px rgb(16 18 43 / 0.2); + 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); } html[data-theme="tailor-light"] [data-slot="button"][class*="shadow-xs"][class*="astw:bg-primary"]:hover:not(:disabled) { - filter: brightness(1.055); - box-shadow: - inset 0 2px 1px rgb(255 255 255 / 0.32), - inset 0 -4px 5px rgb(0 10 54 / 0.32), - 0 1px 3px rgb(16 18 43 / 0.18); + filter: brightness(1.05); } html[data-theme="tailor-light"] [data-slot="button"][class*="shadow-xs"][class*="astw:bg-primary"]:active:not(:disabled) { + border: 0; filter: brightness(0.96); - box-shadow: - inset 0 3px 6px rgb(0 0 0 / 0.38), - inset 0 -1px 1px rgb(255 255 255 / 0.12), - 0 1px 1px rgb(16 18 43 / 0.12); + box-shadow: inset 0 4px 10px rgb(8 14 82 / 0.38); } html[data-theme="tailor-light"] [data-slot="button"][class*="shadow-xs"][class*="astw:bg-primary"]:focus-visible { box-shadow: - inset 0 2px 1px rgb(255 255 255 / 0.28), - inset 0 -4px 5px rgb(0 10 54 / 0.36), - 0 1px 2px rgb(16 18 43 / 0.2), + inset 0 1px 0 rgb(255 255 255 / 0.22), 0 0 0 3px var(--ring) !important; } - /* Secondary — light violet tactile surface */ html[data-theme="tailor-light"] [data-slot="button"][class*="shadow-xs"][class*="astw:bg-secondary"] { - background-color: transparent; - border: 1px solid rgb(154 148 209 / 0.45); - background-image: linear-gradient( - 180deg, - rgb(239 229 253) 0%, - rgb(216 188 246) 50%, - rgb(206 174 239) 100% - ); - box-shadow: - inset 0 2px 1px rgb(255 255 255 / 0.75), - inset 0 -3px 4px rgb(50 42 118 / 0.18), - 0 1px 2px rgb(16 18 43 / 0.1); + 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); } html[data-theme="tailor-light"] [data-slot="button"][class*="shadow-xs"][class*="astw:bg-secondary"]:hover:not(:disabled) { - filter: brightness(1.04); + filter: brightness(1.035); } html[data-theme="tailor-light"] [data-slot="button"][class*="shadow-xs"][class*="astw:bg-secondary"]:active:not(:disabled) { - filter: brightness(0.97); - box-shadow: - inset 0 3px 5px rgb(50 42 118 / 0.22), - inset 0 -1px 1px rgb(255 255 255 / 0.6), - 0 1px 1px rgb(16 18 43 / 0.08); + border: 0; + filter: brightness(0.98); + box-shadow: inset 0 3px 8px rgb(50 42 118 / 0.22); } html[data-theme="tailor-light"] [data-slot="button"][class*="shadow-xs"][class*="astw:bg-secondary"]:focus-visible { box-shadow: - inset 0 2px 1px rgb(255 255 255 / 0.75), - inset 0 -3px 4px rgb(50 42 118 / 0.18), - 0 1px 2px rgb(16 18 43 / 0.1), + inset 0 1px 0 rgb(255 255 255 / 0.65), 0 0 0 3px var(--ring) !important; } - /* Destructive */ html[data-theme="tailor-light"] [data-slot="button"][class*="shadow-xs"][class*="astw:bg-destructive"] { - background-color: transparent; - border: 1px solid color-mix(in srgb, #10122b 45%, rgb(156 42 42)); - background-image: linear-gradient( - 180deg, - rgb(219 71 71) 0%, - rgb(175 42 42) 50%, - rgb(154 38 42) 100% - ); - box-shadow: - inset 0 2px 1px rgb(255 255 255 / 0.26), - inset 0 -4px 5px rgb(60 12 14 / 0.45), - 0 1px 2px rgb(16 18 43 / 0.18); + 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); } html[data-theme="tailor-light"] [data-slot="button"][class*="shadow-xs"][class*="astw:bg-destructive"]:hover:not(:disabled) { - filter: brightness(1.05); + filter: brightness(1.04); } html[data-theme="tailor-light"] [data-slot="button"][class*="shadow-xs"][class*="astw:bg-destructive"]:active:not(:disabled) { + border: 0; filter: brightness(0.96); - box-shadow: - inset 0 3px 6px rgb(40 12 14 / 0.45), - inset 0 -1px 1px rgb(255 255 255 / 0.2), - 0 1px 1px rgb(16 18 43 / 0.12); + box-shadow: inset 0 4px 10px rgb(70 14 14 / 0.45); } html[data-theme="tailor-light"] [data-slot="button"][class*="shadow-xs"][class*="astw:bg-destructive"]:focus-visible { box-shadow: - inset 0 2px 1px rgb(255 255 255 / 0.26), - inset 0 -4px 5px rgb(60 12 14 / 0.45), - 0 1px 2px rgb(16 18 43 / 0.18), + inset 0 1px 0 rgb(255 255 255 / 0.2), 0 0 0 3px color-mix(in srgb, var(--destructive) 42%, transparent) !important; } - /* Outline — white tactile + gray rim (neutral “secondary” chrome) */ html[data-theme="tailor-light"] [data-slot="button"][class*="shadow-xs"][class*="astw:bg-background"][class*="astw:border"] { - background-color: transparent; - color: rgb(16 18 43); - border-color: rgb(185 183 206 / 0.65); - background-image: linear-gradient(180deg, rgb(255 255 255) 0%, rgb(246 246 251) 100%); - box-shadow: - inset 0 2px 1px rgb(255 255 255 / 0.95), - inset 0 -3px 4px rgb(16 18 43 / 0.08), - 0 1px 2px rgb(16 18 43 / 0.08); + background-color: var(--card); + color: var(--card-foreground); + border: 1px solid rgb(185 183 206 / 0.65); + border-bottom: 2px solid rgb(130 134 164 / 0.85); + box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.9); } html[data-theme="tailor-light"] @@ -231,31 +186,24 @@ :disabled ) { border-color: rgb(165 160 205 / 0.55); - background-image: linear-gradient(180deg, rgb(252 246 253) 0%, rgb(237 229 251) 100%); - box-shadow: - inset 0 2px 1px rgb(255 255 255 / 1), - inset 0 -3px 5px rgb(80 74 149 / 0.12), - 0 1px 3px rgb(16 18 43 / 0.1); - filter: none; + border-bottom-color: rgb(110 118 168 / 0.75); + filter: brightness(1.015); + box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.98); } html[data-theme="tailor-light"] [data-slot="button"][class*="shadow-xs"][class*="astw:bg-background"][class*="astw:border"]:active:not( :disabled ) { - box-shadow: - inset 0 3px 5px rgb(16 18 43 / 0.1), - inset 0 -1px 1px rgb(255 255 255 / 0.7), - 0 1px 1px rgb(16 18 43 / 0.06); + border: 0; + box-shadow: inset 0 3px 8px rgb(16 18 43 / 0.12); filter: none; } html[data-theme="tailor-light"] [data-slot="button"][class*="shadow-xs"][class*="astw:bg-background"][class*="astw:border"]:focus-visible { box-shadow: - inset 0 2px 1px rgb(255 255 255 / 0.95), - inset 0 -3px 4px rgb(16 18 43 / 0.08), - 0 1px 2px rgb(16 18 43 / 0.08), + inset 0 1px 0 rgb(255 255 255 / 0.9), 0 0 0 3px var(--ring) !important; } } From 1821c31f66c5c1f9591eda6e49e83ccb4c3f7d70 Mon Sep 17 00:00:00 2001 From: itsprade Date: Tue, 28 Apr 2026 23:57:46 +0530 Subject: [PATCH 03/18] feat(themes): preset ids light, dark, deep-dark, cream, bloom Rename theme API and html[data-theme] from tailor-* to short names. LocalStorage values tailor-light, tailor-bloom, tailor-dark map to cream, bloom, deep-dark on read. deep-dark keeps Tailwind dark class. Includes palette CSS, shell gradients, tactile globals, docs, and changesets. Checkpoint: cream + bloom + deep-dark brand themes alongside default light/dark. Made-with: Cursor --- .changeset/bloom-tailor-theme.md | 5 + .changeset/tailor-theme-palettes.md | 10 +- docs/api/use-theme.md | 25 +- docs/concepts/styling-theming.md | 11 +- .../nextjs-app/src/modules/sidebar-menu.tsx | 2 +- ...nts__autocomplete-standalone.test.tsx.snap | 6 +- ...rc__components__autocomplete.test.tsx.snap | 6 +- .../src__components__button.test.tsx.snap | 4 +- ...ponents__combobox-standalone.test.tsx.snap | 8 +- .../src__components__combobox.test.tsx.snap | 8 +- .../src__components__field.test.tsx.snap | 10 +- .../src__components__form.test.tsx.snap | 4 +- .../src__components__input.test.tsx.snap | 12 +- ...omponents__select-standalone.test.tsx.snap | 8 +- .../src__components__select.test.tsx.snap | 49 +++- packages/core/src/assets/theme.css | 149 +++++++--- packages/core/src/components/appshell.tsx | 2 +- packages/core/src/components/autocomplete.tsx | 2 +- packages/core/src/components/button.tsx | 4 +- packages/core/src/components/combobox.tsx | 4 +- packages/core/src/components/select.tsx | 2 +- .../components/sidebar/default-sidebar.tsx | 4 +- .../src/components/sidebar/sidebar-group.tsx | 6 +- .../src/components/sidebar/sidebar-item.tsx | 12 +- .../src/components/sidebar/sidebar-layout.tsx | 8 +- packages/core/src/contexts/theme-context.tsx | 25 +- packages/core/src/globals.css | 272 +++++++++++++++--- packages/core/src/lib/input-classes.ts | 12 +- 28 files changed, 505 insertions(+), 165 deletions(-) create mode 100644 .changeset/bloom-tailor-theme.md diff --git a/.changeset/bloom-tailor-theme.md b/.changeset/bloom-tailor-theme.md new file mode 100644 index 00000000..08e32fb4 --- /dev/null +++ b/.changeset/bloom-tailor-theme.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/app-shell": minor +--- + +Add the **Bloom** light palette (`bloom`): lavender shell with cream secondary surfaces — set with `defaultTheme`, `ThemeProvider`, or **`setTheme("bloom")`**. Sidebar theme cycling includes Bloom when using the layout toggle. diff --git a/.changeset/tailor-theme-palettes.md b/.changeset/tailor-theme-palettes.md index bac1d77c..44cb94dd 100644 --- a/.changeset/tailor-theme-palettes.md +++ b/.changeset/tailor-theme-palettes.md @@ -2,9 +2,9 @@ "@tailor-platform/app-shell": minor --- -Adds **Tailor** brand theme presets alongside the existing default light and dark palettes. `useTheme` / `ThemeProvider` accept `light`, `dark`, `tailor-light`, `tailor-dark`, and `system`. The document root sets `data-theme` to the resolved palette and keeps `class="light"` or `class="dark"` for Tailwind `dark` mode (`tailor-dark` uses the `dark` class). +Adds **Tailor** brand theme presets alongside the existing default light and dark palettes. `useTheme` / `ThemeProvider` accept `light`, `dark`, `deep-dark`, `cream`, `bloom`, and `system`. The document root sets `data-theme` to the resolved palette and keeps `class="light"` or `class="dark"` for Tailwind `dark` mode (`deep-dark` uses the `dark` class). -Semantic tokens in `theme.css` include placeholder **Tailor light** and **Tailor dark** values (indigo / slate–emerald styling) you can tune toward final brand colors. Status colors and elevation shadows (`--semantic-shadow-*`, mapped to `@theme` shadow keys) are centralized so each palette can override them. +Semantic tokens in `theme.css` include **cream** and **deep-dark** values (indigo / slate–emerald styling) you can tune toward final brand colors. Status colors and elevation shadows (`--semantic-shadow-*`, mapped to `@theme` shadow keys) are centralized so each palette can override them. `AppShell` accepts an optional `defaultTheme` when no value is stored in `localStorage`. Exports: `Theme`, `ResolvedTheme`. @@ -13,13 +13,13 @@ The sidebar floating menu outline hover style now uses `var(--sidebar-border)` / ```tsx import { AppShell, useTheme, type Theme } from "@tailor-platform/app-shell"; -{/* ... */}; +{/* ... */}; function Switcher() { const { setTheme } = useTheme(); return ( - ); } diff --git a/docs/api/use-theme.md b/docs/api/use-theme.md index 01b96182..778f0099 100644 --- a/docs/api/use-theme.md +++ b/docs/api/use-theme.md @@ -1,6 +1,6 @@ --- title: useTheme -description: Hook for accessing and controlling theme appearance (default and Tailor palettes) +description: Hook for accessing and controlling theme appearance (named palettes including Cream and Bloom) --- # useTheme @@ -12,9 +12,9 @@ React hook to access and control the current appearance theme. Exported types: ```typescript -export type Theme = "light" | "dark" | "tailor-light" | "tailor-dark" | "system"; +export type Theme = "light" | "dark" | "deep-dark" | "cream" | "bloom" | "system"; -export type ResolvedTheme = "light" | "dark" | "tailor-light" | "tailor-dark"; +export type ResolvedTheme = "light" | "dark" | "deep-dark" | "cream" | "bloom"; ``` Hook return value: @@ -32,12 +32,12 @@ const useTheme: () => { ### `theme` - **Type:** `Theme` -- **Description:** Stored theme preference. **`system`** means “follow OS light/dark” for the **default** light/dark palettes only (not Tailor). +- **Description:** Stored theme preference. **`system`** means “follow OS light/dark” for the **default** light/dark palettes only (not cream, bloom, or deep-dark). ### `resolvedTheme` - **Type:** `ResolvedTheme` -- **Description:** Concrete palette after resolving **`system`** to **`light`** or **`dark`**. **`tailor-light`** and **`tailor-dark`** are never produced by **`system`**; pick them explicitly with **`setTheme`**. +- **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`**. When **`resolvedTheme`** changes, **`document.documentElement`** gets **`data-theme`** set to this value and a **`light`** / **`dark`** class for Tailwind **`dark`** variant compatibility. @@ -46,6 +46,8 @@ 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`**). + ## Usage ### Display current themes @@ -67,9 +69,9 @@ function ThemeDisplay() { ### Set a named palette ```typescript -function UseTailorLight() { +function UseCream() { const { setTheme } = useTheme(); - return ; + return ; } ``` @@ -86,8 +88,9 @@ function ThemeSelector() { > - - + + + ); @@ -96,13 +99,13 @@ function ThemeSelector() { ### Logo or assets by lightness -Use **`resolvedTheme`** and treat **`tailor-light`** like light and **`tailor-dark`** like dark for monochrome assets: +Use **`resolvedTheme`** and treat **`light`**, **`cream`**, and **`bloom`** like “light” palettes and **`dark`** and **`deep-dark`** like “dark” for monochrome assets: ```typescript function Logo() { const { resolvedTheme } = useTheme(); const darkish = - resolvedTheme === "dark" || resolvedTheme === "tailor-dark"; + resolvedTheme === "dark" || resolvedTheme === "deep-dark"; return Logo; } diff --git a/docs/concepts/styling-theming.md b/docs/concepts/styling-theming.md index cf86a58a..50733984 100644 --- a/docs/concepts/styling-theming.md +++ b/docs/concepts/styling-theming.md @@ -26,18 +26,19 @@ E.g. Note, many of these are default Tailwind colors, but there are some differences. If you omit this, much of the UI will look the same, but we will lose some of the Tailor-preferred colors. -## Built-in palettes (default and Tailor) +## Built-in palettes -AppShell ships **four** named semantic palettes controlled by **`data-theme`** on **``** (see [`useTheme`](../api/use-theme.md)): +AppShell ships **five** named semantic palettes controlled by **`data-theme`** on **``** (see [`useTheme`](../api/use-theme.md)): | Resolved theme | Purpose | | -------------- | -------------------------------------------- | | `light` | Default neutral light (historic AppShell UI) | | `dark` | Default neutral dark | -| `tailor-light` | Tailor brand — light tuning surface | -| `tailor-dark` | Tailor brand — dark tuning surface | +| `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 **`tailor-*`**. Apps that want Tailor must set **`tailor-light`** or **`tailor-dark`** explicitly. +**`system`** resolves to **`light`** or **`dark`** only — not **`cream`**, **`bloom`**, or **`deep-dark`**. 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/examples/nextjs-app/src/modules/sidebar-menu.tsx b/examples/nextjs-app/src/modules/sidebar-menu.tsx index dbdc74a6..9e775abb 100644 --- a/examples/nextjs-app/src/modules/sidebar-menu.tsx +++ b/examples/nextjs-app/src/modules/sidebar-menu.tsx @@ -33,7 +33,7 @@ export const SidebarMenu = () => { border: "1px solid var(--sidebar-border)", borderRadius: "4px", backgroundColor: "var(--sidebar-accent)", - color: "var(--sidebar-foreground)", + color: "var(--sidebar-accent-foreground)", }} > diff --git a/packages/core/__snapshots__/src__components__autocomplete-standalone.test.tsx.snap b/packages/core/__snapshots__/src__components__autocomplete-standalone.test.tsx.snap index 4dabbc25..40ee5b87 100644 --- a/packages/core/__snapshots__/src__components__autocomplete-standalone.test.tsx.snap +++ b/packages/core/__snapshots__/src__components__autocomplete-standalone.test.tsx.snap @@ -1,7 +1,7 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Autocomplete (standalone) > snapshots > default with string items 1`] = `"
"`; +exports[`Autocomplete (standalone) > snapshots > default with string items 1`] = `"
"`; -exports[`Autocomplete (standalone) > snapshots > with custom className 1`] = `"
"`; +exports[`Autocomplete (standalone) > snapshots > with custom className 1`] = `"
"`; -exports[`Autocomplete (standalone) > snapshots > with custom mapItem 1`] = `"
"`; +exports[`Autocomplete (standalone) > snapshots > with custom mapItem 1`] = `"
"`; diff --git a/packages/core/__snapshots__/src__components__autocomplete.test.tsx.snap b/packages/core/__snapshots__/src__components__autocomplete.test.tsx.snap index 2d92cb8b..b477a6c7 100644 --- a/packages/core/__snapshots__/src__components__autocomplete.test.tsx.snap +++ b/packages/core/__snapshots__/src__components__autocomplete.test.tsx.snap @@ -1,7 +1,7 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Autocomplete.Parts > snapshots > closed autocomplete with placeholder 1`] = `"
"`; +exports[`Autocomplete.Parts > snapshots > closed autocomplete with placeholder 1`] = `"
"`; -exports[`Autocomplete.Parts > snapshots > open autocomplete 1`] = `"
"`; +exports[`Autocomplete.Parts > snapshots > open autocomplete 1`] = `"
"`; -exports[`Autocomplete.Parts > snapshots > with groups 1`] = `""`; +exports[`Autocomplete.Parts > snapshots > with groups 1`] = `""`; diff --git a/packages/core/__snapshots__/src__components__button.test.tsx.snap b/packages/core/__snapshots__/src__components__button.test.tsx.snap index 9c82e9fc..2ab69968 100644 --- a/packages/core/__snapshots__/src__components__button.test.tsx.snap +++ b/packages/core/__snapshots__/src__components__button.test.tsx.snap @@ -8,7 +8,7 @@ exports[`Button > snapshots > disabled state 1`] = `""`; -exports[`Button > snapshots > ghost variant 1`] = `""`; +exports[`Button > snapshots > ghost variant 1`] = `""`; exports[`Button > snapshots > icon size 1`] = `""`; @@ -16,7 +16,7 @@ exports[`Button > snapshots > large size 1`] = `""`; -exports[`Button > snapshots > outline variant 1`] = `""`; +exports[`Button > snapshots > outline variant 1`] = `""`; exports[`Button > snapshots > secondary variant 1`] = `""`; diff --git a/packages/core/__snapshots__/src__components__combobox-standalone.test.tsx.snap b/packages/core/__snapshots__/src__components__combobox-standalone.test.tsx.snap index c504aafa..19f4968e 100644 --- a/packages/core/__snapshots__/src__components__combobox-standalone.test.tsx.snap +++ b/packages/core/__snapshots__/src__components__combobox-standalone.test.tsx.snap @@ -1,9 +1,9 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Combobox (standalone) > snapshots > default with string items 1`] = `"
"`; +exports[`Combobox (standalone) > snapshots > default with string items 1`] = `"
"`; -exports[`Combobox (standalone) > snapshots > disabled 1`] = `"
"`; +exports[`Combobox (standalone) > snapshots > disabled 1`] = `"
"`; -exports[`Combobox (standalone) > snapshots > multiple mode 1`] = `"
"`; +exports[`Combobox (standalone) > snapshots > multiple mode 1`] = `"
"`; -exports[`Combobox (standalone) > snapshots > with custom className 1`] = `"
"`; +exports[`Combobox (standalone) > snapshots > with custom className 1`] = `"
"`; diff --git a/packages/core/__snapshots__/src__components__combobox.test.tsx.snap b/packages/core/__snapshots__/src__components__combobox.test.tsx.snap index 2dd6861e..33172b85 100644 --- a/packages/core/__snapshots__/src__components__combobox.test.tsx.snap +++ b/packages/core/__snapshots__/src__components__combobox.test.tsx.snap @@ -1,9 +1,9 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Combobox.Parts > snapshots > closed combobox with placeholder 1`] = `""`; +exports[`Combobox.Parts > snapshots > closed combobox with placeholder 1`] = `""`; -exports[`Combobox.Parts > snapshots > open combobox 1`] = `"
"`; +exports[`Combobox.Parts > snapshots > open combobox 1`] = `"
"`; -exports[`Combobox.Parts > snapshots > with InputGroup, Clear, and Trigger 1`] = `"
"`; +exports[`Combobox.Parts > snapshots > with InputGroup, Clear, and Trigger 1`] = `"
"`; -exports[`Combobox.Parts > snapshots > with groups 1`] = `""`; +exports[`Combobox.Parts > snapshots > with groups 1`] = `""`; diff --git a/packages/core/__snapshots__/src__components__field.test.tsx.snap b/packages/core/__snapshots__/src__components__field.test.tsx.snap index 4a468f85..9a009393 100644 --- a/packages/core/__snapshots__/src__components__field.test.tsx.snap +++ b/packages/core/__snapshots__/src__components__field.test.tsx.snap @@ -1,11 +1,11 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Field > snapshots > basic field with label and control 1`] = `"
"`; +exports[`Field > snapshots > basic field with label and control 1`] = `"
"`; -exports[`Field > snapshots > disabled field 1`] = `"
"`; +exports[`Field > snapshots > disabled field 1`] = `"
"`; -exports[`Field > snapshots > field with custom className 1`] = `"
"`; +exports[`Field > snapshots > field with custom className 1`] = `"
"`; -exports[`Field > snapshots > field with description 1`] = `"

We will never share your email.

"`; +exports[`Field > snapshots > field with description 1`] = `"

We will never share your email.

"`; -exports[`Field > snapshots > field with error 1`] = `"
Please enter a valid URL.
"`; +exports[`Field > snapshots > field with error 1`] = `"
Please enter a valid URL.
"`; diff --git a/packages/core/__snapshots__/src__components__form.test.tsx.snap b/packages/core/__snapshots__/src__components__form.test.tsx.snap index 5728bc2d..15d557d6 100644 --- a/packages/core/__snapshots__/src__components__form.test.tsx.snap +++ b/packages/core/__snapshots__/src__components__form.test.tsx.snap @@ -1,7 +1,7 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Form > snapshots > basic form with a field 1`] = `"
"`; +exports[`Form > snapshots > basic form with a field 1`] = `"
"`; exports[`Form > snapshots > form with custom className 1`] = `"
Content
"`; -exports[`Form > snapshots > form with noValidate 1`] = `"
"`; +exports[`Form > snapshots > form with noValidate 1`] = `"
"`; diff --git a/packages/core/__snapshots__/src__components__input.test.tsx.snap b/packages/core/__snapshots__/src__components__input.test.tsx.snap index bf53423a..a83b490d 100644 --- a/packages/core/__snapshots__/src__components__input.test.tsx.snap +++ b/packages/core/__snapshots__/src__components__input.test.tsx.snap @@ -1,13 +1,13 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Input > snapshots > default text input 1`] = `""`; +exports[`Input > snapshots > default text input 1`] = `""`; -exports[`Input > snapshots > disabled input 1`] = `""`; +exports[`Input > snapshots > disabled input 1`] = `""`; -exports[`Input > snapshots > email input 1`] = `""`; +exports[`Input > snapshots > email input 1`] = `""`; -exports[`Input > snapshots > file input 1`] = `""`; +exports[`Input > snapshots > file input 1`] = `""`; -exports[`Input > snapshots > password input 1`] = `""`; +exports[`Input > snapshots > password input 1`] = `""`; -exports[`Input > snapshots > with custom className 1`] = `""`; +exports[`Input > snapshots > with custom className 1`] = `""`; diff --git a/packages/core/__snapshots__/src__components__select-standalone.test.tsx.snap b/packages/core/__snapshots__/src__components__select-standalone.test.tsx.snap index 0f0e515f..dac82fdf 100644 --- a/packages/core/__snapshots__/src__components__select-standalone.test.tsx.snap +++ b/packages/core/__snapshots__/src__components__select-standalone.test.tsx.snap @@ -1,9 +1,9 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Select (standalone) > snapshots > default with string items 1`] = `"
"`; +exports[`Select (standalone) > snapshots > default with string items 1`] = `"
"`; -exports[`Select (standalone) > snapshots > disabled 1`] = `"
"`; +exports[`Select (standalone) > snapshots > disabled 1`] = `"
"`; -exports[`Select (standalone) > snapshots > multiple mode 1`] = `"
"`; +exports[`Select (standalone) > snapshots > multiple mode 1`] = `"
"`; -exports[`Select (standalone) > snapshots > with custom mapItem 1`] = `"
"`; +exports[`Select (standalone) > snapshots > with custom mapItem 1`] = `"
"`; diff --git a/packages/core/__snapshots__/src__components__select.test.tsx.snap b/packages/core/__snapshots__/src__components__select.test.tsx.snap index d5d4afc2..c14ab1ce 100644 --- a/packages/core/__snapshots__/src__components__select.test.tsx.snap +++ b/packages/core/__snapshots__/src__components__select.test.tsx.snap @@ -1,11 +1,50 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Select > snapshots > closed select with placeholder 1`] = `""`; +exports[`Select > snapshots > closed select with placeholder 1`] = `""`; -exports[`Select > snapshots > disabled item 1`] = `"
Apple
Banana
"`; +exports[`Select > snapshots > disabled item 1`] = ` +"
Apple
Banana
" +`; -exports[`Select > snapshots > disabled select 1`] = `""`; +exports[`Select > snapshots > disabled select 1`] = `""`; -exports[`Select > snapshots > open select 1`] = `"
Apple
Banana
"`; +exports[`Select > snapshots > open select 1`] = ` +"
Apple
Banana
" +`; -exports[`Select > snapshots > with groups and separator 1`] = `"
Fruits
Apple
Vegetables
Carrot
"`; +exports[`Select > snapshots > with groups and separator 1`] = ` +"
Fruits
Apple
Vegetables
Carrot
" +`; diff --git a/packages/core/src/assets/theme.css b/packages/core/src/assets/theme.css index 6a38cc61..f3c151c6 100644 --- a/packages/core/src/assets/theme.css +++ b/packages/core/src/assets/theme.css @@ -92,10 +92,12 @@ * Primary: #535AE8 · Text: #10122B · White: #FFFFFF * Secondary: Deep Cyan #00979C · Dark Green #013742 · Off-White #F8F3E4 · Neutral #EEEEEE · Light Violet #E2D4FE */ -html[data-theme="tailor-light"] { +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)); --foreground: rgba(16, 18, 43, 1); --card: rgba(255, 255, 255, 1); --card-foreground: rgba(16, 18, 43, 1); @@ -142,51 +144,112 @@ html[data-theme="tailor-light"] { } /** - * Tailor brand — dark palette (distinct from default dark for tuning). + * Bloom — light lavender app shell; cream secondary/accent surfaces (inverse of Tailor light). */ -html[data-theme="tailor-dark"] { +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)); + --foreground: rgba(16, 18, 43, 1); + --card: rgba(255, 255, 255, 1); + --card-foreground: rgba(16, 18, 43, 1); + --popover: rgba(255, 255, 255, 1); + --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); + --muted: rgba(238, 238, 238, 1); + --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); + --border: rgba(0, 0, 0, 0.08); + --input: rgba(0, 0, 0, 0.08); + --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(239, 232, 255, 1); + --sidebar-foreground: rgba(16, 18, 43, 1); + --sidebar-primary: rgba(83, 90, 232, 1); + --sidebar-primary-foreground: rgba(255, 255, 255, 1); + /* White elevated row on lavender shell (readable vs lavender-on-lavender) */ + --sidebar-accent: rgba(255, 255, 255, 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); + /* Bloom: teal + violet-tint foregrounds; amber + dusty rose harmonize with shell */ + --status-neutral: #0b8c9a; + --status-completed: #0d7668; + --status-attention: #ae6f12; + --status-danger: #ae2438; + --semantic-shadow-xs: 0 1px 2px 0 rgb(16 18 43 / 0.07); + --semantic-shadow-sm: 0 1px 3px 0 rgb(16 18 43 / 0.08), 0 1px 2px -1px rgb(16 18 43 / 0.06); + --semantic-shadow-md: 0 4px 6px -1px rgb(16 18 43 / 0.1), 0 2px 4px -2px rgb(16 18 43 / 0.08); + --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(15, 23, 42, 1); - --foreground: rgba(241, 245, 249, 1); - --card: rgba(30, 41, 59, 1); - --card-foreground: rgba(241, 245, 249, 1); - --popover: rgba(30, 41, 59, 1); - --popover-foreground: rgba(241, 245, 249, 1); - --primary: rgba(52, 211, 153, 1); - --primary-foreground: rgba(6, 78, 59, 1); - --secondary: rgba(51, 65, 85, 1); - --secondary-foreground: rgba(241, 245, 249, 1); - --muted: rgba(51, 65, 85, 1); - --muted-foreground: rgba(148, 163, 184, 1); - --accent: rgba(71, 85, 105, 1); - --accent-foreground: rgba(241, 245, 249, 1); - --destructive: rgba(248, 113, 113, 1); + --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(71, 85, 105, 1); - --input: rgba(71, 85, 105, 1); - --ring: rgba(52, 211, 153, 1); - --chart-1: rgba(52, 211, 153, 1); - --chart-2: rgba(94, 234, 212, 1); - --chart-3: rgba(251, 191, 36, 1); - --chart-4: rgba(196, 181, 253, 1); - --chart-5: rgba(251, 113, 133, 1); - --sidebar: rgba(15, 23, 42, 1); - --sidebar-foreground: rgba(241, 245, 249, 1); - --sidebar-primary: rgba(16, 185, 129, 1); - --sidebar-primary-foreground: rgba(6, 78, 59, 1); - --sidebar-accent: rgba(51, 65, 85, 1); - --sidebar-accent-foreground: rgba(241, 245, 249, 1); - --sidebar-border: rgba(71, 85, 105, 1); - --sidebar-ring: rgba(52, 211, 153, 1); - --status-default: #94a3b8; - --status-neutral: #38bdf8; - --status-completed: #34d399; - --status-attention: #fbbf24; - --status-danger: #f87171; - --semantic-shadow-xs: 0 1px 2px 0 rgb(15 118 110 / 0.22); - --semantic-shadow-sm: 0 1px 3px 0 rgb(16 185 129 / 0.28), 0 1px 2px -1px rgb(16 185 129 / 0.22); - --semantic-shadow-md: 0 4px 6px -1px rgb(6 95 70 / 0.35), 0 2px 4px -2px rgb(6 95 70 / 0.28); - --semantic-shadow-lg: 0 10px 15px -3px rgb(6 78 59 / 0.45), 0 4px 6px -4px rgb(6 78 59 / 0.35); + --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 { diff --git a/packages/core/src/components/appshell.tsx b/packages/core/src/components/appshell.tsx index 036faddc..4b96c8b8 100644 --- a/packages/core/src/components/appshell.tsx +++ b/packages/core/src/components/appshell.tsx @@ -182,7 +182,7 @@ type SharedAppShellProps = React.PropsWithChildren<{ * Initial theme before any value is loaded from localStorage (`appshell-ui-theme`). * Does not replace a stored preference. * - * Includes **Tailor** brand palettes (`tailor-light`, `tailor-dark`) in addition to + * 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). * * @default "system" diff --git a/packages/core/src/components/autocomplete.tsx b/packages/core/src/components/autocomplete.tsx index eadfebe5..4a1abb09 100644 --- a/packages/core/src/components/autocomplete.tsx +++ b/packages/core/src/components/autocomplete.tsx @@ -39,7 +39,7 @@ function AutocompleteInput({ ; currentPath: string }) to={item.url as string} className={ isActivePath(item.url, props.currentPath) - ? "astw:bg-sidebar-accent astw:font-medium" + ? "astw:bg-sidebar-accent astw:font-medium astw:text-sidebar-accent-foreground" : undefined } /> @@ -238,7 +238,7 @@ const AutoSidebarItems = (props: { items: Array; currentPath: string }) to={subItem.url!} className={ isActivePath(subItem.url, props.currentPath) - ? "astw:bg-sidebar-accent astw:font-medium" + ? "astw:bg-sidebar-accent astw:font-medium astw:text-sidebar-accent-foreground" : undefined } /> diff --git a/packages/core/src/components/sidebar/sidebar-group.tsx b/packages/core/src/components/sidebar/sidebar-group.tsx index f094f354..906bc389 100644 --- a/packages/core/src/components/sidebar/sidebar-group.tsx +++ b/packages/core/src/components/sidebar/sidebar-group.tsx @@ -85,7 +85,11 @@ export const SidebarGroup = (props: SidebarGroupProps) => { render={ } tooltip={resolvedTitle} diff --git a/packages/core/src/components/sidebar/sidebar-item.tsx b/packages/core/src/components/sidebar/sidebar-item.tsx index 03949b88..50d0bb62 100644 --- a/packages/core/src/components/sidebar/sidebar-item.tsx +++ b/packages/core/src/components/sidebar/sidebar-item.tsx @@ -123,7 +123,11 @@ export const SidebarItem = (props: SidebarItemProps) => { href={to} target="_blank" rel="noopener noreferrer" - className={isActive ? "astw:bg-sidebar-accent astw:font-medium" : undefined} + className={ + isActive + ? "astw:bg-sidebar-accent astw:font-medium astw:text-sidebar-accent-foreground" + : undefined + } /> } tooltip={title} @@ -148,7 +152,11 @@ export const SidebarItem = (props: SidebarItemProps) => { render={ } tooltip={title} diff --git a/packages/core/src/components/sidebar/sidebar-layout.tsx b/packages/core/src/components/sidebar/sidebar-layout.tsx index fda3ffbc..ad663326 100644 --- a/packages/core/src/components/sidebar/sidebar-layout.tsx +++ b/packages/core/src/components/sidebar/sidebar-layout.tsx @@ -4,7 +4,13 @@ import { AppShellOutlet } from "@/components/content"; import { Button } from "@/components/button"; import { useTheme, type ResolvedTheme } from "@/contexts/theme-context"; -const RESOLVED_THEME_CYCLE: ResolvedTheme[] = ["light", "dark", "tailor-light", "tailor-dark"]; +const RESOLVED_THEME_CYCLE: ResolvedTheme[] = [ + "light", + "dark", + "deep-dark", + "cream", + "bloom", +]; import { DefaultSidebar } from "./default-sidebar"; import { DynamicBreadcrumb } from "@/components/dynamic-breadcrumb"; diff --git a/packages/core/src/contexts/theme-context.tsx b/packages/core/src/contexts/theme-context.tsx index 45457c84..2c73ff98 100644 --- a/packages/core/src/contexts/theme-context.tsx +++ b/packages/core/src/contexts/theme-context.tsx @@ -1,21 +1,32 @@ import { createContext, useContext, useEffect, useMemo, useState } from "react"; -/** User-selectable theme. `system` follows OS light/dark (default palettes only — B1). */ -export type Theme = "light" | "dark" | "tailor-light" | "tailor-dark" | "system"; +/** 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"; /** Resolved paint after applying `system`. */ -export type ResolvedTheme = "light" | "dark" | "tailor-light" | "tailor-dark"; +export type ResolvedTheme = "light" | "dark" | "deep-dark" | "cream" | "bloom"; const ALL_THEMES: readonly Theme[] = [ "light", "dark", - "tailor-light", - "tailor-dark", + "deep-dark", + "cream", + "bloom", "system", ] 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", +}; + function parseStoredTheme(value: string | null, fallback: Theme): Theme { - if (value && (ALL_THEMES as readonly string[]).includes(value)) return value as Theme; + if (!value) return fallback; + const legacy = LEGACY_THEME_IDS[value]; + if (legacy) return legacy; + if ((ALL_THEMES as readonly string[]).includes(value)) return value as Theme; return fallback; } @@ -82,7 +93,7 @@ export function ThemeProvider({ const root = window.document.documentElement; root.classList.remove("light", "dark"); root.classList.add( - resolvedTheme === "dark" || resolvedTheme === "tailor-dark" ? "dark" : "light", + resolvedTheme === "dark" || resolvedTheme === "deep-dark" ? "dark" : "light", ); root.dataset.theme = resolvedTheme; }, [resolvedTheme]); diff --git a/packages/core/src/globals.css b/packages/core/src/globals.css index 4e108a68..d6996f9f 100644 --- a/packages/core/src/globals.css +++ b/packages/core/src/globals.css @@ -39,26 +39,60 @@ body { @apply astw:font-sans astw:antialiased astw:bg-background astw:text-foreground; } + + /* + * Cream / Bloom — vertical shell gradient (top = --background; bottom lighter 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-attachment: fixed; + background-repeat: no-repeat; + background-size: 100% 100%; + } + + :is(html[data-theme="cream"], html[data-theme="bloom"]) body { + background-color: transparent !important; + background-image: none; + } + + :is(html[data-theme="cream"], html[data-theme="bloom"]) [data-slot="sidebar-wrapper"], + :is(html[data-theme="cream"], html[data-theme="bloom"]) [data-slot="sidebar-inner"], + :is(html[data-theme="cream"], html[data-theme="bloom"]) main[data-slot="sidebar-inset"], + :is(html[data-theme="cream"], html[data-theme="bloom"]) + [data-slot="sidebar"][class*="bg-sidebar"]:not([data-mobile="true"]):not( + [data-icon-mode="true"] + ) { + background-color: transparent !important; + } + /* - Tailor light: Geist Sans + Apple-style corners (squircle) when the engine supports corner-shape (Chromium). + 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. */ - html[data-theme="tailor-light"] body { + :is(html[data-theme="cream"], html[data-theme="bloom"]) body, + html[data-theme="deep-dark"] body { font-family: "Geist Sans", ui-sans-serif, system-ui, sans-serif; } - html[data-theme="tailor-light"] :where(h1, h2, h3, h4, h5, h6) { + :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) { letter-spacing: -0.03em; line-height: 1.2; font-family: "Geist Sans", ui-sans-serif, system-ui, sans-serif; } @supports (corner-shape: squircle) { - html[data-theme="tailor-light"] * { + :is(html[data-theme="cream"], html[data-theme="bloom"]) *, + html[data-theme="deep-dark"] * { corner-shape: squircle; } /* Round radii (.rounded-full) stay circular squircles, not elongated “capsules” */ - html[data-theme="tailor-light"] [class*="rounded-full"] { + :is(html[data-theme="cream"], html[data-theme="bloom"]) [class*="rounded-full"], + html[data-theme="deep-dark"] [class*="rounded-full"] { corner-shape: round; } } @@ -71,25 +105,40 @@ ::-webkit-scrollbar-corner { @apply astw:bg-muted-foreground; } + + /* + * WebKit / Firefox autofill: override system yellow so fills follow design tokens + * (typically white card surfaces in cream / bloom; --card resolves per theme). + */ + input:is(:-webkit-autofill, :autofill), + textarea:is(:-webkit-autofill, :autofill), + select:is(:-webkit-autofill, :autofill) { + -webkit-box-shadow: 0 0 0 1000px var(--card) inset !important; + box-shadow: 0 0 0 1000px var(--card) inset !important; + caret-color: var(--foreground); + transition: background-color 9999s ease-out 0s; + } } /* - * Tailor light — tactile buttons: solid fill + 2px darker bottom lip (raised); - * pressed: border 0 + inset shade. Corner radius stays from Button utilities. - * Only html[data-theme="tailor-light"]; raised variants share shadow-xs in button.tsx. + * 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). */ @layer utilities { - html[data-theme="tailor-light"] [data-slot="button"][class*="shadow-xs"] { + :is(html[data-theme="cream"], html[data-theme="bloom"]) [data-slot="button"][class*="shadow-xs"], + html[data-theme="deep-dark"] [data-slot="button"][class*="shadow-xs"] { background-image: none; transition: filter 0.12s ease, - border-width 0.12s ease, border-color 0.12s ease, - box-shadow 0.12s ease; + box-shadow 0.12s ease, + transform 0.08s ease; } - html[data-theme="tailor-light"] - [data-slot="button"][class*="shadow-xs"][class*="astw:bg-primary"] { + :is(html[data-theme="cream"], html[data-theme="bloom"]) + [data-slot="button"][class*="shadow-xs"][class*="astw:bg-primary"], + html[data-theme="deep-dark"] [data-slot="button"][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)); @@ -97,26 +146,36 @@ box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.22); } - html[data-theme="tailor-light"] + :is(html[data-theme="cream"], html[data-theme="bloom"]) + [data-slot="button"][class*="shadow-xs"][class*="astw:bg-primary"]:hover:not(:disabled), + html[data-theme="deep-dark"] [data-slot="button"][class*="shadow-xs"][class*="astw:bg-primary"]:hover:not(:disabled) { filter: brightness(1.05); } - html[data-theme="tailor-light"] + :is(html[data-theme="cream"], html[data-theme="bloom"]) + [data-slot="button"][class*="shadow-xs"][class*="astw:bg-primary"]:active:not(:disabled), + html[data-theme="deep-dark"] [data-slot="button"][class*="shadow-xs"][class*="astw:bg-primary"]:active:not(:disabled) { - border: 0; filter: brightness(0.96); - box-shadow: inset 0 4px 10px rgb(8 14 82 / 0.38); + 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); } - html[data-theme="tailor-light"] + :is(html[data-theme="cream"], html[data-theme="bloom"]) + [data-slot="button"][class*="shadow-xs"][class*="astw:bg-primary"]:focus-visible, + html[data-theme="deep-dark"] [data-slot="button"][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; } - html[data-theme="tailor-light"] + :is(html[data-theme="cream"], html[data-theme="bloom"]) + [data-slot="button"][class*="shadow-xs"][class*="astw:bg-secondary"], + html[data-theme="deep-dark"] [data-slot="button"][class*="shadow-xs"][class*="astw:bg-secondary"] { background-color: var(--secondary); color: var(--secondary-foreground); @@ -125,26 +184,44 @@ box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.65); } - html[data-theme="tailor-light"] + /* + * Bloom: cream secondary on lavender shell — rims from --background (lighter than cream’s violet lip). + */ + html[data-theme="bloom"] [data-slot="button"][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"]) + [data-slot="button"][class*="shadow-xs"][class*="astw:bg-secondary"]:hover:not(:disabled), + html[data-theme="deep-dark"] [data-slot="button"][class*="shadow-xs"][class*="astw:bg-secondary"]:hover:not(:disabled) { filter: brightness(1.035); } - html[data-theme="tailor-light"] + :is(html[data-theme="cream"], html[data-theme="bloom"]) + [data-slot="button"][class*="shadow-xs"][class*="astw:bg-secondary"]:active:not(:disabled), + html[data-theme="deep-dark"] [data-slot="button"][class*="shadow-xs"][class*="astw:bg-secondary"]:active:not(:disabled) { - border: 0; 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); } - html[data-theme="tailor-light"] + :is(html[data-theme="cream"], html[data-theme="bloom"]) + [data-slot="button"][class*="shadow-xs"][class*="astw:bg-secondary"]:focus-visible, + html[data-theme="deep-dark"] [data-slot="button"][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; } - html[data-theme="tailor-light"] + :is(html[data-theme="cream"], html[data-theme="bloom"]) + [data-slot="button"][class*="shadow-xs"][class*="astw:bg-destructive"], + html[data-theme="deep-dark"] [data-slot="button"][class*="shadow-xs"][class*="astw:bg-destructive"] { background-color: var(--destructive); color: var(--destructive-foreground); @@ -153,57 +230,178 @@ box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.2); } - html[data-theme="tailor-light"] + :is(html[data-theme="cream"], html[data-theme="bloom"]) + [data-slot="button"][class*="shadow-xs"][class*="astw:bg-destructive"]:hover:not(:disabled), + html[data-theme="deep-dark"] [data-slot="button"][class*="shadow-xs"][class*="astw:bg-destructive"]:hover:not(:disabled) { filter: brightness(1.04); } - html[data-theme="tailor-light"] + :is(html[data-theme="cream"], html[data-theme="bloom"]) + [data-slot="button"][class*="shadow-xs"][class*="astw:bg-destructive"]:active:not(:disabled), + html[data-theme="deep-dark"] [data-slot="button"][class*="shadow-xs"][class*="astw:bg-destructive"]:active:not(:disabled) { - border: 0; 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); } - html[data-theme="tailor-light"] + :is(html[data-theme="cream"], html[data-theme="bloom"]) + [data-slot="button"][class*="shadow-xs"][class*="astw:bg-destructive"]:focus-visible, + html[data-theme="deep-dark"] [data-slot="button"][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; } - html[data-theme="tailor-light"] + :is(html[data-theme="cream"], html[data-theme="bloom"]) [data-slot="button"][class*="shadow-xs"][class*="astw:bg-background"][class*="astw:border"] { background-color: var(--card); color: var(--card-foreground); - border: 1px solid rgb(185 183 206 / 0.65); - border-bottom: 2px solid rgb(130 134 164 / 0.85); + 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); } - html[data-theme="tailor-light"] + /* Dark: subtler rims so outline doesn’t read as a bright halo on near-black UI. */ + html[data-theme="deep-dark"] + [data-slot="button"][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"]) [data-slot="button"][class*="shadow-xs"][class*="astw:bg-background"][class*="astw:border"]:hover:not( :disabled ) { - border-color: rgb(165 160 205 / 0.55); - border-bottom-color: rgb(110 118 168 / 0.75); + 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="tailor-light"] + html[data-theme="deep-dark"] + [data-slot="button"][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"]) + [data-slot="button"][class*="shadow-xs"][class*="astw:bg-background"][class*="astw:border"]:active:not( + :disabled + ), + html[data-theme="deep-dark"] [data-slot="button"][class*="shadow-xs"][class*="astw:bg-background"][class*="astw:border"]:active:not( :disabled ) { - border: 0; + 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); } - html[data-theme="tailor-light"] + :is(html[data-theme="cream"], html[data-theme="bloom"]) [data-slot="button"][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"] + [data-slot="button"][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"]) + [data-slot="button"]:not([class*="shadow-xs"])[class*="astw:hover:bg-accent"], + html[data-theme="deep-dark"] + [data-slot="button"]: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"]) + [data-slot="button"]: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"] + [data-slot="button"]: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"]) + [data-slot="button"]:not([class*="shadow-xs"])[class*="astw:hover:bg-accent"]:active:not( + :disabled + ), + html[data-theme="deep-dark"] + [data-slot="button"]: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"]) + [data-slot="button"]: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"] + [data-slot="button"]: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; + } } diff --git a/packages/core/src/lib/input-classes.ts b/packages/core/src/lib/input-classes.ts index f814a266..87c89c62 100644 --- a/packages/core/src/lib/input-classes.ts +++ b/packages/core/src/lib/input-classes.ts @@ -1,13 +1,15 @@ /** * Shared base Tailwind classes for text-input-like controls. * - * Used by both `Input` and `Field.Control` to keep their visual appearance - * in sync. Each consumer layers on context-specific classes (e.g. - * `aria-invalid` vs `data-invalid`, file-input utilities). + * Surfaces use **transparent** fill so the control picks up whatever is behind it + * (e.g. white `Card`, cream page). In `.dark`, a light input wash applies. + * + * Used by both `Input` and `Field.Control`. Select/Combobox/Autocomplete triggers + * repeat the same transparent + dark line for the same behavior. */ export const inputBaseClasses = [ - "astw:border-input astw:flex astw:h-9 astw:w-full astw:min-w-0 astw:rounded-md astw:border astw:bg-transparent astw:px-3 astw:py-1 astw:text-base astw:shadow-xs astw:outline-none astw:md:text-sm", - "astw:dark:bg-input/30 astw:transition-[color,box-shadow]", + "astw:border-input astw:flex astw:h-9 astw:w-full astw:min-w-0 astw:rounded-md astw:border astw:px-3 astw:py-1 astw:text-base astw:shadow-xs astw:outline-none astw:md:text-sm", + "astw:bg-transparent astw:dark:bg-input/30 astw:transition-[color,box-shadow]", "astw:selection:bg-primary astw:selection:text-primary-foreground", "astw:placeholder:text-muted-foreground", "astw:focus-visible:border-ring astw:focus-visible:ring-ring/50 astw:focus-visible:ring-[3px]", From d79378f8056234c556cce477c35b17d06e4d3969 Mon Sep 17 00:00:00 2001 From: itsprade Date: Wed, 29 Apr 2026 15:53:27 +0530 Subject: [PATCH 04/18] feat(themes): theme switcher, tactile slots, Bloom/Cream refinements - Add ThemeSwitcher (grid) and Tailor palettes (cream, bloom, deep-dark); wire sidebar slot; export THEMES/options. - Theme context: bloom label, resolver updates; muted token comments aligned. - Globals: tactile Cream/Bloom/deep-dark rules for composed controls (dialog-close/trigger slots); table layout fix. - Dialog: card surface for content; footer/close compose with branded buttons. - Vite example: CSS load order note (Tailwind before app-shell styles). - Docs: use-theme and sidebar-layout; styling-theming palettes table. Made-with: Cursor --- docs/api/use-theme.md | 41 ++++-- docs/components/sidebar-layout.md | 16 ++- docs/concepts/styling-theming.md | 2 +- examples/vite-app/src/index.css | 6 + .../src__components__dialog.test.tsx.snap | 4 +- .../src__components__table.test.tsx.snap | 10 +- packages/core/src/assets/theme.css | 6 +- packages/core/src/components/dialog.tsx | 12 +- packages/core/src/components/sidebar/index.ts | 1 + .../src/components/sidebar/sidebar-layout.tsx | 39 +++--- packages/core/src/components/table.tsx | 2 +- .../src/components/theme-switcher.test.tsx | 101 ++++++++++++++ .../core/src/components/theme-switcher.tsx | 128 ++++++++++++++++++ packages/core/src/contexts/theme-context.tsx | 12 ++ packages/core/src/globals.css | 89 ++++++------ packages/core/src/index.ts | 9 +- 16 files changed, 379 insertions(+), 99 deletions(-) create mode 100644 packages/core/src/components/theme-switcher.test.tsx create mode 100644 packages/core/src/components/theme-switcher.tsx diff --git a/docs/api/use-theme.md b/docs/api/use-theme.md index 778f0099..76f0c0a4 100644 --- a/docs/api/use-theme.md +++ b/docs/api/use-theme.md @@ -15,6 +15,11 @@ Exported types: export type Theme = "light" | "dark" | "deep-dark" | "cream" | "bloom" | "system"; export type ResolvedTheme = "light" | "dark" | "deep-dark" | "cream" | "bloom"; + +export type ThemeOption = { readonly value: Theme; readonly label: string }; + +/** Ordered labels for UI (e.g. theme menus); includes System last. */ +export const THEME_OPTIONS: readonly ThemeOption[]; ``` Hook return value: @@ -75,24 +80,34 @@ function UseCream() { } ``` -### Theme selector +### Theme menu (**`ThemeSwitcher`**) + +`SidebarLayout` includes a header **`ThemeSwitcher`** by default. You can reuse it elsewhere or build a custom control from **`THEME_OPTIONS`**: + +```tsx +import { ThemeSwitcher } from "@tailor-platform/app-shell"; + +function Toolbar() { + return ; +} +``` ```typescript -function ThemeSelector() { +import { THEME_OPTIONS, useTheme, type Theme } from "@tailor-platform/app-shell"; + +function CustomThemeList() { const { theme, setTheme } = useTheme(); return ( - +
    + {THEME_OPTIONS.map(({ value, label }) => ( +
  • + +
  • + ))} +
); } ``` diff --git a/docs/components/sidebar-layout.md b/docs/components/sidebar-layout.md index f855c558..40e3373e 100644 --- a/docs/components/sidebar-layout.md +++ b/docs/components/sidebar-layout.md @@ -1,11 +1,11 @@ --- title: SidebarLayout -description: The default layout component with sidebar navigation, breadcrumbs, and theme toggle +description: The default layout component with sidebar navigation, breadcrumbs, and theme menu --- # SidebarLayout -`SidebarLayout` is the default layout component that provides a responsive sidebar navigation, breadcrumb trail, and theme toggle. It's designed to work seamlessly with AppShell's module system. +`SidebarLayout` is the default layout component that provides a responsive sidebar navigation, breadcrumb trail, and theme menu (named palettes plus **System**). It's designed to work seamlessly with AppShell's module system. ## Import @@ -31,7 +31,7 @@ This gives you: - ✅ Responsive sidebar with auto-generated navigation from modules - ✅ Breadcrumb navigation -- ✅ Theme toggle (light/dark mode) +- ✅ Theme menu (all palettes + **System**) - ✅ Mobile-friendly collapsible sidebar ## Props @@ -55,6 +55,12 @@ This gives you: The `Outlet` component renders your current route's component. +### themeSwitcher + +- **Type:** `React.ReactNode` (optional) +- **Default:** `` — dropdown listing every [`Theme`](../api/use-theme.md) plus **System** +- **Description:** Pass **`null`** to hide the header theme control, or pass a custom node to replace it. + ### sidebar - **Type:** `React.ReactNode` (optional) @@ -119,9 +125,9 @@ Dashboard > Products > Product Details Breadcrumbs update automatically as users navigate through your application. -### Theme Toggle +### Theme menu -A sun/moon icon button in the header allows users to switch between light and dark themes. The theme preference is persisted to localStorage. +A palette icon button in the header opens a grid of every palette (each with a two-color preview) plus **System**. When **System** is selected, the button’s **tooltip** (`title`) summarizes the effective palette. The choice is persisted to `localStorage`. Override or hide via the **`themeSwitcher`** prop. ## Customization Examples diff --git a/docs/concepts/styling-theming.md b/docs/concepts/styling-theming.md index 50733984..23bd29fd 100644 --- a/docs/concepts/styling-theming.md +++ b/docs/concepts/styling-theming.md @@ -35,7 +35,7 @@ AppShell ships **five** named semantic palettes controlled by **`data-theme`** o | `light` | Default neutral light (historic AppShell UI) | | `dark` | Default neutral dark | | `cream` | Tailor brand — cream shell, violet accents | -| `bloom` | Lavender shell (**Bloom**) and cream 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`**. diff --git a/examples/vite-app/src/index.css b/examples/vite-app/src/index.css index c280a9f7..89caf9b8 100644 --- a/examples/vite-app/src/index.css +++ b/examples/vite-app/src/index.css @@ -1,3 +1,9 @@ +/* Unprefixed Tailwind for this app (`mb-4`, `text-muted-foreground`, etc.) must load first. + * App Shell styles MUST come second so layered base rules (semantic `border-border` / `--border`) + * are not overwritten by Tailwind preflight — otherwise AppShell borders look harsh/black everywhere. + * + * Theme switcher grid does not rely on this order (inline fallback styles on `ThemeSwitcher`). + */ @import "tailwindcss"; @import "@tailor-platform/app-shell/styles"; diff --git a/packages/core/__snapshots__/src__components__dialog.test.tsx.snap b/packages/core/__snapshots__/src__components__dialog.test.tsx.snap index cdfb4194..67039ef6 100644 --- a/packages/core/__snapshots__/src__components__dialog.test.tsx.snap +++ b/packages/core/__snapshots__/src__components__dialog.test.tsx.snap @@ -2,6 +2,6 @@ exports[`Dialog > snapshots > closed dialog (trigger only) 1`] = `""`; -exports[`Dialog > snapshots > open dialog with header 1`] = `"
"`; +exports[`Dialog > snapshots > open dialog with header 1`] = `"
"`; -exports[`Dialog > snapshots > open dialog with header and footer 1`] = `"
"`; +exports[`Dialog > snapshots > open dialog with header and footer 1`] = `"
"`; diff --git a/packages/core/__snapshots__/src__components__table.test.tsx.snap b/packages/core/__snapshots__/src__components__table.test.tsx.snap index ba7e659e..abb364ae 100644 --- a/packages/core/__snapshots__/src__components__table.test.tsx.snap +++ b/packages/core/__snapshots__/src__components__table.test.tsx.snap @@ -1,11 +1,11 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Table > snapshots > basic table with header and body 1`] = `"
NameStatus
Item 1Active
"`; +exports[`Table > snapshots > basic table with header and body 1`] = `"
NameStatus
Item 1Active
"`; -exports[`Table > snapshots > empty table 1`] = `"
NameStatus
"`; +exports[`Table > snapshots > empty table 1`] = `"
NameStatus
"`; -exports[`Table > snapshots > table with caption 1`] = `"
A list of items
Name
Item
"`; +exports[`Table > snapshots > table with caption 1`] = `"
A list of items
Name
Item
"`; -exports[`Table > snapshots > table with containerClassName 1`] = `"
Name
Item
"`; +exports[`Table > snapshots > table with containerClassName 1`] = `"
Name
Item
"`; -exports[`Table > snapshots > table with footer 1`] = `"
ProductPrice
Widget$10
Total$10
"`; +exports[`Table > snapshots > table with footer 1`] = `"
ProductPrice
Widget$10
Total$10
"`; diff --git a/packages/core/src/assets/theme.css b/packages/core/src/assets/theme.css index f3c151c6..43a20eb6 100644 --- a/packages/core/src/assets/theme.css +++ b/packages/core/src/assets/theme.css @@ -108,7 +108,8 @@ html[data-theme="cream"] { /* Light violet surfaces; dark green text (brand text-on-violet pairing) */ --secondary: rgba(226, 212, 254, 1); --secondary-foreground: rgba(1, 55, 66, 1); - --muted: rgba(238, 238, 238, 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); @@ -160,7 +161,8 @@ html[data-theme="bloom"] { --primary-foreground: rgba(255, 255, 255, 1); --secondary: rgba(248, 243, 228, 1); --secondary-foreground: rgba(16, 18, 43, 1); - --muted: rgba(238, 238, 238, 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); diff --git a/packages/core/src/components/dialog.tsx b/packages/core/src/components/dialog.tsx index d5dbbe30..9a18a857 100644 --- a/packages/core/src/components/dialog.tsx +++ b/packages/core/src/components/dialog.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { Dialog as BaseDialog } from "@base-ui/react/dialog"; import { XIcon } from "lucide-react"; +import { Button } from "@/components/button"; import { cn } from "@/lib/utils"; // Only the props relevant to the Dialog abstraction are picked from BaseDialog.Root. @@ -80,16 +81,21 @@ function Content({ className, children, ...props }: React.ComponentProps {children} - + ); diff --git a/packages/core/src/components/sidebar/index.ts b/packages/core/src/components/sidebar/index.ts index a751bfda..92665e9c 100644 --- a/packages/core/src/components/sidebar/index.ts +++ b/packages/core/src/components/sidebar/index.ts @@ -3,3 +3,4 @@ export { SidebarGroup, type SidebarGroupProps } from "./sidebar-group"; export { SidebarSeparator } from "./sidebar-separator"; export { DefaultSidebar, type DefaultSidebarProps } from "./default-sidebar"; export { SidebarLayout, type SidebarLayoutProps } from "./sidebar-layout"; +export { ThemeSwitcher } from "../theme-switcher"; diff --git a/packages/core/src/components/sidebar/sidebar-layout.tsx b/packages/core/src/components/sidebar/sidebar-layout.tsx index ad663326..b80146e5 100644 --- a/packages/core/src/components/sidebar/sidebar-layout.tsx +++ b/packages/core/src/components/sidebar/sidebar-layout.tsx @@ -1,20 +1,20 @@ +import type { ReactNode } from "react"; + import { SidebarProvider, SidebarInset, SidebarTrigger, useSidebar } from "@/components/sidebar"; -import { SunIcon } from "lucide-react"; import { AppShellOutlet } from "@/components/content"; -import { Button } from "@/components/button"; -import { useTheme, type ResolvedTheme } from "@/contexts/theme-context"; - -const RESOLVED_THEME_CYCLE: ResolvedTheme[] = [ - "light", - "dark", - "deep-dark", - "cream", - "bloom", -]; +import { ThemeSwitcher } from "@/components/theme-switcher"; import { DefaultSidebar } from "./default-sidebar"; import { DynamicBreadcrumb } from "@/components/dynamic-breadcrumb"; export type SidebarLayoutProps = { + /** + * Header theme control. + * + * @default Built-in **`ThemeSwitcher`** menu (all themes + **System**). + * Pass **`null`** to hide. Pass a custom **`ReactNode`** to replace. + */ + themeSwitcher?: ReactNode; + /** * Custom content renderer. * @@ -31,7 +31,7 @@ export type SidebarLayoutProps = { * * ``` */ - children?: (props: { Outlet: () => React.ReactNode }) => React.ReactNode; + children?: (props: { Outlet: () => ReactNode }) => ReactNode; /** * Custom sidebar content. @@ -76,12 +76,7 @@ const HidableSidebarTrigger = () => { export const SidebarLayout = (props: SidebarLayoutProps) => { const Children = props.children ? props.children({ Outlet: AppShellOutlet }) : null; - const { resolvedTheme, setTheme } = useTheme(); - const toggleTheme = () => { - const i = RESOLVED_THEME_CYCLE.indexOf(resolvedTheme); - const next = RESOLVED_THEME_CYCLE[(i === -1 ? 0 : i + 1) % RESOLVED_THEME_CYCLE.length]; - setTheme(next); - }; + const themeSwitcher = props.themeSwitcher !== undefined ? props.themeSwitcher : ; return ( { -
- -
+ {themeSwitcher !== null ? ( +
{themeSwitcher}
+ ) : null}
diff --git a/packages/core/src/components/table.tsx b/packages/core/src/components/table.tsx index a03a72fe..c48d8416 100644 --- a/packages/core/src/components/table.tsx +++ b/packages/core/src/components/table.tsx @@ -84,7 +84,7 @@ function Row({ className, ...props }: React.ComponentProps<"tr">) { (); + const ls = { + getItem: (k: string) => map.get(k) ?? null, + setItem: (k: string, v: string) => { + map.set(k, v); + }, + removeItem: (k: string) => { + map.delete(k); + }, + clear: () => map.clear(), + key: (i: number) => [...map.keys()][i] ?? null, + get length() { + return map.size; + }, + }; + Object.defineProperty(globalThis, "localStorage", { configurable: true, value: ls }); + return map; +} + +let storageMap: Map; + +beforeAll(() => { + storageMap = installLocalStorageStub(); +}); + +beforeEach(() => { + storageMap.clear(); +}); + +afterEach(() => { + cleanup(); +}); + +describe("ThemeSwitcher", () => { + it("opens a menu listing every theme option", async () => { + const user = userEvent.setup(); + + render( + + + , + ); + + await user.click(screen.getByRole("button", { name: "Theme" })); + + await waitFor(() => { + expect(screen.getAllByRole("menuitemradio").length).toBe(THEME_OPTIONS.length); + }); + + for (const opt of THEME_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" }); + expect(btn.getAttribute("title")).toMatch(/following system/i); + expect(btn.getAttribute("title")).toMatch(/currently light|currently dark/i); + }); + + it("applies selected palette when a radio item is activated", async () => { + const user = userEvent.setup(); + + render( + + + , + ); + + await user.click(screen.getByRole("button", { name: "Theme" })); + + await waitFor(() => { + expect(screen.getByRole("menu")).toBeDefined(); + }); + + await user.click(screen.getByRole("menuitemradio", { name: "Bloom" })); + + await waitFor(() => { + expect(document.documentElement.dataset.theme).toBe("bloom"); + }); + expect(localStorage.getItem(storageKey)).toBe("bloom"); + }); +}); diff --git a/packages/core/src/components/theme-switcher.tsx b/packages/core/src/components/theme-switcher.tsx new file mode 100644 index 00000000..6b7cf583 --- /dev/null +++ b/packages/core/src/components/theme-switcher.tsx @@ -0,0 +1,128 @@ +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"; + +const RESOLVED_THEME_SHORT: Record = { + light: "Light", + dark: "Dark", + "deep-dark": "Deep dark", + cream: "Cream", + bloom: "Bloom", +}; + +/** + * Decorative dual swatches — approximates each palette pair (accent + neutral) for the picker grid. + */ +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" }, +}; + +function isTheme(value: string): value is Theme { + return THEME_OPTIONS.some((o) => o.value === value); +} + +function ThemePreviewSwatches({ themeId }: { themeId: Theme }) { + const { a, b } = THEME_PREVIEW[themeId]; + return ( +
+ + +
+ ); +} + +/** + * Appearance menu: visual grid of every palette plus **System**. + * Bound to stored `theme` (not resolved paint alone) so **System** stays explicit. + */ +function ThemeSwitcher() { + const { theme, resolvedTheme, setTheme } = useTheme(); + + const triggerTitle = + theme === "system" + ? `Following system — currently ${RESOLVED_THEME_SHORT[resolvedTheme]}` + : "Choose appearance theme"; + + return ( + + + } + > + + + + { + if (typeof value === "string" && isTheme(value)) setTheme(value); + }} + > + {THEME_OPTIONS.map((opt) => ( + + + {opt.label} + + + {opt.label} + + ))} + + + + ); +} + +export { ThemeSwitcher }; diff --git a/packages/core/src/contexts/theme-context.tsx b/packages/core/src/contexts/theme-context.tsx index 2c73ff98..6d06d39c 100644 --- a/packages/core/src/contexts/theme-context.tsx +++ b/packages/core/src/contexts/theme-context.tsx @@ -15,6 +15,18 @@ const ALL_THEMES: readonly Theme[] = [ "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: "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; + /** Migrate stored values from legacy `tailor-*` ids before the public rename. */ const LEGACY_THEME_IDS: Partial> = { "tailor-light": "cream", diff --git a/packages/core/src/globals.css b/packages/core/src/globals.css index d6996f9f..13e3bdfd 100644 --- a/packages/core/src/globals.css +++ b/packages/core/src/globals.css @@ -124,10 +124,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`. */ @layer utilities { - :is(html[data-theme="cream"], html[data-theme="bloom"]) [data-slot="button"][class*="shadow-xs"], - html[data-theme="deep-dark"] [data-slot="button"][class*="shadow-xs"] { + :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, @@ -137,8 +140,8 @@ } :is(html[data-theme="cream"], html[data-theme="bloom"]) - [data-slot="button"][class*="shadow-xs"][class*="astw:bg-primary"], - html[data-theme="deep-dark"] [data-slot="button"][class*="shadow-xs"][class*="astw:bg-primary"] { + :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)); @@ -147,16 +150,16 @@ } :is(html[data-theme="cream"], html[data-theme="bloom"]) - [data-slot="button"][class*="shadow-xs"][class*="astw:bg-primary"]:hover:not(:disabled), + :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"] - [data-slot="button"][class*="shadow-xs"][class*="astw:bg-primary"]:hover:not(:disabled) { + :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"]) - [data-slot="button"][class*="shadow-xs"][class*="astw:bg-primary"]:active:not(:disabled), + :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"] - [data-slot="button"][class*="shadow-xs"][class*="astw:bg-primary"]:active:not(:disabled) { + :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); @@ -165,18 +168,18 @@ } :is(html[data-theme="cream"], html[data-theme="bloom"]) - [data-slot="button"][class*="shadow-xs"][class*="astw:bg-primary"]:focus-visible, + :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"] - [data-slot="button"][class*="shadow-xs"][class*="astw:bg-primary"]:focus-visible { + :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"]) - [data-slot="button"][class*="shadow-xs"][class*="astw:bg-secondary"], + :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"])[class*="shadow-xs"][class*="astw:bg-secondary"], html[data-theme="deep-dark"] - [data-slot="button"][class*="shadow-xs"][class*="astw:bg-secondary"] { + :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)); @@ -187,22 +190,22 @@ /* * Bloom: cream secondary on lavender shell — rims from --background (lighter than cream’s violet lip). */ - html[data-theme="bloom"] [data-slot="button"][class*="shadow-xs"][class*="astw:bg-secondary"] { + 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"]) - [data-slot="button"][class*="shadow-xs"][class*="astw:bg-secondary"]:hover:not(:disabled), + :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"] - [data-slot="button"][class*="shadow-xs"][class*="astw:bg-secondary"]:hover:not(:disabled) { + :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"]) - [data-slot="button"][class*="shadow-xs"][class*="astw:bg-secondary"]:active:not(:disabled), + :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"] - [data-slot="button"][class*="shadow-xs"][class*="astw:bg-secondary"]:active:not(:disabled) { + :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); @@ -211,18 +214,18 @@ } :is(html[data-theme="cream"], html[data-theme="bloom"]) - [data-slot="button"][class*="shadow-xs"][class*="astw:bg-secondary"]:focus-visible, + :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"] - [data-slot="button"][class*="shadow-xs"][class*="astw:bg-secondary"]:focus-visible { + :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"]) - [data-slot="button"][class*="shadow-xs"][class*="astw:bg-destructive"], + :is([data-slot="button"], [data-slot="dialog-close"], [data-slot="dialog-trigger"])[class*="shadow-xs"][class*="astw:bg-destructive"], html[data-theme="deep-dark"] - [data-slot="button"][class*="shadow-xs"][class*="astw:bg-destructive"] { + :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)); @@ -231,16 +234,16 @@ } :is(html[data-theme="cream"], html[data-theme="bloom"]) - [data-slot="button"][class*="shadow-xs"][class*="astw:bg-destructive"]:hover:not(:disabled), + :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"] - [data-slot="button"][class*="shadow-xs"][class*="astw:bg-destructive"]:hover:not(:disabled) { + :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"]) - [data-slot="button"][class*="shadow-xs"][class*="astw:bg-destructive"]:active:not(:disabled), + :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"] - [data-slot="button"][class*="shadow-xs"][class*="astw:bg-destructive"]:active:not(:disabled) { + :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); @@ -249,16 +252,16 @@ } :is(html[data-theme="cream"], html[data-theme="bloom"]) - [data-slot="button"][class*="shadow-xs"][class*="astw:bg-destructive"]:focus-visible, + :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"] - [data-slot="button"][class*="shadow-xs"][class*="astw:bg-destructive"]:focus-visible { + :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"]) - [data-slot="button"][class*="shadow-xs"][class*="astw:bg-background"][class*="astw:border"] { + :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); @@ -268,7 +271,7 @@ /* Dark: subtler rims so outline doesn’t read as a bright halo on near-black UI. */ html[data-theme="deep-dark"] - [data-slot="button"][class*="shadow-xs"][class*="astw:bg-background"][class*="astw:border"] { + :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); @@ -277,7 +280,7 @@ } :is(html[data-theme="cream"], html[data-theme="bloom"]) - [data-slot="button"][class*="shadow-xs"][class*="astw:bg-background"][class*="astw:border"]:hover:not( + :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); @@ -287,7 +290,7 @@ } html[data-theme="deep-dark"] - [data-slot="button"][class*="shadow-xs"][class*="astw:bg-background"][class*="astw:border"]:hover:not( + :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); @@ -297,11 +300,11 @@ } :is(html[data-theme="cream"], html[data-theme="bloom"]) - [data-slot="button"][class*="shadow-xs"][class*="astw:bg-background"][class*="astw:border"]:active:not( + :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"] - [data-slot="button"][class*="shadow-xs"][class*="astw:bg-background"][class*="astw:border"]:active:not( + :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); @@ -312,14 +315,14 @@ } :is(html[data-theme="cream"], html[data-theme="bloom"]) - [data-slot="button"][class*="shadow-xs"][class*="astw:bg-background"][class*="astw:border"]:focus-visible { + :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"] - [data-slot="button"][class*="shadow-xs"][class*="astw:bg-background"][class*="astw:border"]:focus-visible { + :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; @@ -330,9 +333,9 @@ * Invisible border at rest matches outline lip widths so chrome does not shift layout. */ :is(html[data-theme="cream"], html[data-theme="bloom"]) - [data-slot="button"]:not([class*="shadow-xs"])[class*="astw:hover:bg-accent"], + :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"] - [data-slot="button"]:not([class*="shadow-xs"])[class*="astw:hover:bg-accent"] { + :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; @@ -346,7 +349,7 @@ } :is(html[data-theme="cream"], html[data-theme="bloom"]) - [data-slot="button"]:not([class*="shadow-xs"])[class*="astw:hover:bg-accent"]:hover:not( + :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); @@ -358,7 +361,7 @@ } html[data-theme="deep-dark"] - [data-slot="button"]:not([class*="shadow-xs"])[class*="astw:hover:bg-accent"]:hover:not( + :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); @@ -370,11 +373,11 @@ } :is(html[data-theme="cream"], html[data-theme="bloom"]) - [data-slot="button"]:not([class*="shadow-xs"])[class*="astw:hover:bg-accent"]:active:not( + :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"] - [data-slot="button"]:not([class*="shadow-xs"])[class*="astw:hover:bg-accent"]:active:not( + :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); @@ -388,7 +391,7 @@ /* Match outline tactile: focus-visible adds ring atop existing rim (outline default already painted). */ :is(html[data-theme="cream"], html[data-theme="bloom"]) - [data-slot="button"]:not([class*="shadow-xs"])[class*="astw:hover:bg-accent"]:focus-visible:not( + :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: @@ -397,7 +400,7 @@ } html[data-theme="deep-dark"] - [data-slot="button"]:not([class*="shadow-xs"])[class*="astw:hover:bg-accent"]:focus-visible:not( + :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: diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index aaa268cd..123abcb3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -24,7 +24,14 @@ export { export { WithGuard, type WithGuardProps } from "./components/with-guard"; export { useAppShell, useAppShellConfig, useAppShellData } from "./contexts/appshell-context"; -export { useTheme, type ResolvedTheme, type Theme } from "./contexts/theme-context"; +export { + useTheme, + THEME_OPTIONS, + type ResolvedTheme, + type Theme, + type ThemeOption, +} from "./contexts/theme-context"; +export { ThemeSwitcher } from "./components/theme-switcher"; export { type I18nLabels, defineI18nLabels } from "./hooks/i18n"; export { AuthProvider, From 64855c3e824cfa348930a55b6ad3e8227b95fc6f Mon Sep 17 00:00:00 2001 From: itsprade Date: Wed, 29 Apr 2026 16:11:00 +0530 Subject: [PATCH 05/18] chore: align README, CLAUDE, Next example configs with main After rebase onto latest main, restore dev/docs and nextjs-app local config to match origin/main so theme work stays separate from dev ergonomics (turbopack root, extra scripts). Made-with: Cursor --- CLAUDE.md | 2 +- README.md | 4 +--- examples/nextjs-app/next.config.ts | 10 +--------- examples/nextjs-app/package.json | 1 - 4 files changed, 3 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7955204a..0c6695e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,7 +55,7 @@ Tailor Platform AppShell - A React-based framework for building ERP applications # Install dependencies pnpm install -# Start Next.js example (localhost:3000). Use `pnpm dev:examples` only if you need every example dev server. +# Start dev server (opens localhost:3000 with example app) pnpm dev ``` diff --git a/README.md b/README.md index 87c63e1c..e79641bd 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,7 @@ pnpm install ### Commands ```bash -pnpm dev # Next.js example only (localhost:3000) — recommended default -pnpm dev:examples # All examples in parallel (Next + Vite + app-module watch; heavy) -pnpm dev:vite # Vite example only +pnpm dev # Start all packages in development mode with hot reloading pnpm build # Build all packages for production pnpm type-check # Run type checking across all packages ``` diff --git a/examples/nextjs-app/next.config.ts b/examples/nextjs-app/next.config.ts index 3e263b8c..e9ffa308 100644 --- a/examples/nextjs-app/next.config.ts +++ b/examples/nextjs-app/next.config.ts @@ -1,15 +1,7 @@ -import path from "node:path"; import type { NextConfig } from "next"; -const monorepoRoot = path.join(__dirname, "..", ".."); - const nextConfig: NextConfig = { - turbopack: { - root: monorepoRoot, - }, - experimental: { - turbopackMemoryLimit: 1536 * 1024 * 1024, - }, + /* config options here */ }; export default nextConfig; diff --git a/examples/nextjs-app/package.json b/examples/nextjs-app/package.json index 5cf07751..533c70c4 100644 --- a/examples/nextjs-app/package.json +++ b/examples/nextjs-app/package.json @@ -3,7 +3,6 @@ "private": true, "scripts": { "dev": "next dev --turbopack", - "dev:webpack": "next dev", "build": "next build", "start": "next start", "lint": "oxlint -c .oxlintrc.jsonc", From 8372c7b1d9b82d71ea7141bf3cf8b1583329f4ec Mon Sep 17 00:00:00 2001 From: itsprade Date: Wed, 29 Apr 2026 16:11:25 +0530 Subject: [PATCH 06/18] test: refresh menu and table snapshots after rebase onto main Align snapshot output with upstream DataTable/menu DOM after merging main. Made-with: Cursor --- .../src__components__menu.test.tsx.snap | 75 +++++++++++++++++-- .../src__components__table.test.tsx.snap | 10 +-- 2 files changed, 75 insertions(+), 10 deletions(-) diff --git a/packages/core/__snapshots__/src__components__menu.test.tsx.snap b/packages/core/__snapshots__/src__components__menu.test.tsx.snap index 09d750af..96c9180d 100644 --- a/packages/core/__snapshots__/src__components__menu.test.tsx.snap +++ b/packages/core/__snapshots__/src__components__menu.test.tsx.snap @@ -2,14 +2,79 @@ exports[`Menu > snapshots > closed menu (trigger only) 1`] = `""`; -exports[`Menu > snapshots > open menu with items 1`] = `"
"`; +exports[`Menu > snapshots > open menu with items 1`] = ` +"
" +`; -exports[`Menu > snapshots > with checkbox items 1`] = `"
"`; +exports[`Menu > snapshots > with checkbox items 1`] = ` +"
" +`; -exports[`Menu > snapshots > with disabled item 1`] = `"
"`; +exports[`Menu > snapshots > with disabled item 1`] = ` +"
" +`; -exports[`Menu > snapshots > with groups and group labels 1`] = `"
"`; +exports[`Menu > snapshots > with groups and group labels 1`] = ` +"
" +`; -exports[`Menu > snapshots > with radio group 1`] = `"
"`; +exports[`Menu > snapshots > with radio group 1`] = ` +"
" +`; exports[`Menu > snapshots > with submenu 1`] = `""`; diff --git a/packages/core/__snapshots__/src__components__table.test.tsx.snap b/packages/core/__snapshots__/src__components__table.test.tsx.snap index abb364ae..857c1b90 100644 --- a/packages/core/__snapshots__/src__components__table.test.tsx.snap +++ b/packages/core/__snapshots__/src__components__table.test.tsx.snap @@ -1,11 +1,11 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Table > snapshots > basic table with header and body 1`] = `"
NameStatus
Item 1Active
"`; +exports[`Table > snapshots > basic table with header and body 1`] = `"
NameStatus
Item 1Active
"`; -exports[`Table > snapshots > empty table 1`] = `"
NameStatus
"`; +exports[`Table > snapshots > empty table 1`] = `"
NameStatus
"`; -exports[`Table > snapshots > table with caption 1`] = `"
A list of items
Name
Item
"`; +exports[`Table > snapshots > table with caption 1`] = `"
A list of items
Name
Item
"`; -exports[`Table > snapshots > table with containerClassName 1`] = `"
Name
Item
"`; +exports[`Table > snapshots > table with containerClassName 1`] = `"
Name
Item
"`; -exports[`Table > snapshots > table with footer 1`] = `"
ProductPrice
Widget$10
Total$10
"`; +exports[`Table > snapshots > table with footer 1`] = `"
ProductPrice
Widget$10
Total$10
"`; From 8efe8d3bb0857ec6082a529238a5b1e4df784aef Mon Sep 17 00:00:00 2001 From: itsprade Date: Wed, 13 May 2026 21:47:05 +0530 Subject: [PATCH 07/18] 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 4b96c8b8..efdbcbd2 100644 --- a/packages/core/src/components/appshell.tsx +++ b/packages/core/src/components/appshell.tsx @@ -182,10 +182,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; }>; @@ -332,7 +332,7 @@ export const AppShell = (props: AppShellProps) => { diff --git a/packages/core/src/components/badge.tsx b/packages/core/src/components/badge.tsx index 0a6f556a..d9dd3f4c 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", "subtle-success": "astw:border-transparent astw:bg-green-500/10 astw:text-green-700 astw:hover:bg-green-500/20 astw:dark:text-green-500", "subtle-warning": 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 c7eb7ed8..4fdbe3e3 100644 --- a/packages/core/src/components/sidebar.tsx +++ b/packages/core/src/components/sidebar.tsx @@ -249,8 +249,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 5defc35904f3ffff3c42f5602da93870eef2941f Mon Sep 17 00:00:00 2001 From: itsprade Date: Fri, 15 May 2026 14:28:56 +0530 Subject: [PATCH 08/18] 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 efdbcbd2..d677a78e 100644 --- a/packages/core/src/components/appshell.tsx +++ b/packages/core/src/components/appshell.tsx @@ -17,7 +17,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"; @@ -188,6 +188,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; }>; /** @@ -333,7 +341,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 4fdbe3e3..c7eb7ed8 100644 --- a/packages/core/src/components/sidebar.tsx +++ b/packages/core/src/components/sidebar.tsx @@ -249,7 +249,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"; From 62252fb06946723e4c59044edfb28123d801e8a1 Mon Sep 17 00:00:00 2001 From: itsprade Date: Fri, 15 May 2026 15:26:27 +0530 Subject: [PATCH 09/18] refactor(themes): memoize ThemeProvider value + stable hook returns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wrap `value` in `useMemo`; wrap `setTheme`/`setFont` in `useCallback` so consumers don't re-render when ThemeProvider re-renders for an unrelated reason and identity-comparison code (effect deps, memo selectors) stays stable across renders. - `useTheme` / `useFont` return a memoized projection of the context so the hook return identity matches the underlying state slice. - Change `ThemeProviderContext` default to `undefined` so the `useTheme must be used within a ThemeProvider` runtime guard actually fires instead of silently returning no-op setters from a fallback `initialState`. - Drop redundant `defaultTheme ?? "bloom"` / `defaultFont ?? "geist"` in `AppShell` — `ThemeProvider` already has the same defaults; single source. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/core/src/components/appshell.tsx | 4 +- packages/core/src/contexts/theme-context.tsx | 44 ++++++++------------ 2 files changed, 20 insertions(+), 28 deletions(-) diff --git a/packages/core/src/components/appshell.tsx b/packages/core/src/components/appshell.tsx index d677a78e..c9c26c69 100644 --- a/packages/core/src/components/appshell.tsx +++ b/packages/core/src/components/appshell.tsx @@ -340,8 +340,8 @@ export const AppShell = (props: AppShellProps) => { diff --git a/packages/core/src/contexts/theme-context.tsx b/packages/core/src/contexts/theme-context.tsx index 303dc908..82237b63 100644 --- a/packages/core/src/contexts/theme-context.tsx +++ b/packages/core/src/contexts/theme-context.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useEffect, useMemo, useState } from "react"; +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; /** User-selectable theme. `system` follows OS light/dark (default palettes only — not cream/bloom). */ export type Theme = "light" | "dark" | "cream" | "bloom" | "system"; @@ -95,15 +95,7 @@ type ThemeProviderState = { setFont: (font: Font) => void; }; -const initialState: ThemeProviderState = { - resolvedTheme: "bloom", - theme: "bloom", - setTheme: () => null, - font: "geist", - setFont: () => null, -}; - -const ThemeProviderContext = createContext(initialState); +const ThemeProviderContext = createContext(undefined); function resolveTheme(theme: Theme): ResolvedTheme { if (theme !== "system") return theme; @@ -117,7 +109,6 @@ export function ThemeProvider({ fontStorageKey, defaultTheme = "bloom", defaultFont = "geist", - ...props }: ThemeProviderProps) { const [theme, setThemeState] = useState(() => readStored(storageKey, defaultTheme, parseStoredTheme), @@ -139,41 +130,42 @@ export function ThemeProvider({ window.document.documentElement.dataset.font = font; }, [font]); - const value = { - resolvedTheme, - theme, - setTheme: (newTheme: Theme) => { + const setTheme = useCallback( + (newTheme: Theme) => { writeStored(storageKey, newTheme); setThemeState(newTheme); }, - font, - setFont: (newFont: Font) => { + [storageKey], + ); + + const setFont = useCallback( + (newFont: Font) => { writeStored(fontStorageKey, newFont); setFontState(newFont); }, - }; + [fontStorageKey], + ); - return ( - - {children} - + const value = useMemo( + () => ({ theme, resolvedTheme, setTheme, font, setFont }), + [theme, resolvedTheme, setTheme, font, setFont], ); + + return {children}; } export const useTheme = () => { const context = useContext(ThemeProviderContext); - if (context === undefined) throw new Error("useTheme must be used within a ThemeProvider"); const { theme, resolvedTheme, setTheme } = context; - return { theme, resolvedTheme, setTheme }; + return useMemo(() => ({ theme, resolvedTheme, setTheme }), [theme, resolvedTheme, setTheme]); }; export const useFont = () => { const context = useContext(ThemeProviderContext); - if (context === undefined) throw new Error("useFont must be used within a ThemeProvider"); const { font, setFont } = context; - return { font, setFont }; + return useMemo(() => ({ font, setFont }), [font, setFont]); }; From 15b5c804b3394cb18f4473cb93d3f6680de09b98 Mon Sep 17 00:00:00 2001 From: itsprade Date: Fri, 15 May 2026 15:27:26 +0530 Subject: [PATCH 10/18] refactor(themes): drop !important on cream/bloom transparent overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the cream/bloom `body` and sidebar-chrome transparency rules from `@layer base` to `@layer utilities` so they win the cascade against Tailwind utilities (`bg-sidebar*` on sidebar slots) on layer order, and drop the `!important` declarations — selector specificity now suffices. `!important` is retained only on the WebKit/Firefox autofill rule, where it's the documented way to defeat browser default styling. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/core/src/globals.css | 38 ++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/packages/core/src/globals.css b/packages/core/src/globals.css index 915133cb..d937a351 100644 --- a/packages/core/src/globals.css +++ b/packages/core/src/globals.css @@ -67,21 +67,6 @@ background-size: 100% 100%; } - :is(html[data-theme="cream"], html[data-theme="bloom"]) body { - background-color: transparent !important; - background-image: none; - } - - :is(html[data-theme="cream"], html[data-theme="bloom"]) [data-slot="sidebar-wrapper"], - :is(html[data-theme="cream"], html[data-theme="bloom"]) [data-slot="sidebar-inner"], - :is(html[data-theme="cream"], html[data-theme="bloom"]) main[data-slot="sidebar-inset"], - :is(html[data-theme="cream"], html[data-theme="bloom"]) - [data-slot="sidebar"][class*="bg-sidebar"]:not([data-mobile="true"]):not( - [data-icon-mode="true"] - ) { - background-color: transparent !important; - } - /* * Font axis — applied independently of color theme via `data-font` on ``. * Default Tailwind sans is the fallback when no `data-font` is set. @@ -138,9 +123,30 @@ /* * 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. + * Keep this block in `@layer utilities` so it loses to no Tailwind utility in earlier layers and wins + * on selector specificity (no `!important` needed). */ @layer utilities { + /* + * Cream / Bloom: body and sidebar chrome stay transparent so the html-level shell gradient shows + * through. Wins over `body { bg-background }` (base) and the Tailwind `bg-sidebar*` utilities on + * sidebar slots via the `:is(html[data-theme=...])` prefix raising specificity. + */ + :is(html[data-theme="cream"], html[data-theme="bloom"]) body { + background-color: transparent; + background-image: none; + } + + :is(html[data-theme="cream"], html[data-theme="bloom"]) [data-slot="sidebar-wrapper"], + :is(html[data-theme="cream"], html[data-theme="bloom"]) [data-slot="sidebar-inner"], + :is(html[data-theme="cream"], html[data-theme="bloom"]) main[data-slot="sidebar-inset"], + :is(html[data-theme="cream"], html[data-theme="bloom"]) + [data-slot="sidebar"][class*="bg-sidebar"]:not([data-mobile="true"]):not( + [data-icon-mode="true"] + ) { + background-color: transparent; + } + /* Outline button on cream/bloom: transparent fill so the shell gradient shows through; keep border. */ :is(html[data-theme="cream"], html[data-theme="bloom"]) [data-slot="button"][class*="astw:bg-background"][class*="astw:border"] { From 017c3dae6e4059705664c5c463e903a169eace15 Mon Sep 17 00:00:00 2001 From: itsprade Date: Fri, 15 May 2026 15:28:46 +0530 Subject: [PATCH 11/18] refactor(theme-switcher): dedup radio-item classes, drop inline-style fallbacks - Extract `radioItemClasses(active)` shared by the color and font grids; the two grids were carrying byte-identical 7-line class strings. - Remove the inline `style={{ display: "grid", gridTemplateColumns: ..., gap: ... }}` fallbacks on `RadioGroup` and the matching `display: flex` block on `ThemePreviewSwatches`. The Tailwind `astw:grid astw:grid-cols-N astw:gap-2` utilities cover the same layout and are now the single source of truth, in line with the project rule that components must be self-contained / portable (`.agents/skills/add-component/SKILL.md`). - Drop the now-obsolete "inline fallback" note from the vite example's `index.css`; the load-order rationale remains. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/vite-app/src/index.css | 2 - .../core/src/components/theme-switcher.tsx | 56 ++++++------------- 2 files changed, 18 insertions(+), 40 deletions(-) diff --git a/examples/vite-app/src/index.css b/examples/vite-app/src/index.css index 89caf9b8..b71cbfd1 100644 --- a/examples/vite-app/src/index.css +++ b/examples/vite-app/src/index.css @@ -1,8 +1,6 @@ /* Unprefixed Tailwind for this app (`mb-4`, `text-muted-foreground`, etc.) must load first. * App Shell styles MUST come second so layered base rules (semantic `border-border` / `--border`) * are not overwritten by Tailwind preflight — otherwise AppShell borders look harsh/black everywhere. - * - * Theme switcher grid does not rely on this order (inline fallback styles on `ThemeSwitcher`). */ @import "tailwindcss"; @import "@tailor-platform/app-shell/styles"; diff --git a/packages/core/src/components/theme-switcher.tsx b/packages/core/src/components/theme-switcher.tsx index 71136711..5423c0c8 100644 --- a/packages/core/src/components/theme-switcher.tsx +++ b/packages/core/src/components/theme-switcher.tsx @@ -22,6 +22,8 @@ const RESOLVED_THEME_SHORT: Record = { /** * Decorative dual swatches — approximates each palette pair (accent + neutral) for the picker grid. + * Kept as static hex previews so the swatches render even before a theme stylesheet has loaded; + * keep these in sync with the palette tokens in `assets/theme.css`. */ const THEME_PREVIEW: Record = { light: { a: "#ffffff", b: "#d4d4d8" }, @@ -31,9 +33,7 @@ const THEME_PREVIEW: Record = { geist: '"Geist Sans", ui-sans-serif, system-ui, sans-serif', inter: '"Inter", ui-sans-serif, system-ui, sans-serif', @@ -47,17 +47,22 @@ function isFont(value: string): value is Font { return FONT_OPTIONS.some((o) => o.value === value); } +/** Shared radio-item chrome — used by both the color and font grids. */ +function radioItemClasses(active: boolean) { + return cn( + "astw:relative astw:flex astw:h-auto astw:w-full astw:cursor-default astw:select-none astw:flex-col astw:items-center astw:justify-center astw:gap-1.5 astw:rounded-xl astw:border-0 astw:bg-transparent astw:px-2 astw:py-2 astw:text-center astw:text-xs astw:font-medium astw:leading-tight astw:outline-hidden", + "astw:data-highlighted:bg-muted/80 astw:data-highlighted:text-foreground", + "astw:data-disabled:pointer-events-none astw:data-disabled:opacity-50", + "[&_[data-slot=menu-radio-item-indicator]]:astw:hidden", + active && "astw:bg-primary/12 astw:ring-1 astw:ring-primary/25 astw:data-highlighted:bg-primary/[0.14]", + ); +} + function ThemePreviewSwatches({ themeId }: { themeId: Theme }) { const { a, b } = THEME_PREVIEW[themeId]; return (
o.value === font)?.label ?? font; const triggerTitle = theme === "system" - ? `Following system — currently ${RESOLVED_THEME_SHORT[resolvedTheme]} · ${FONT_OPTIONS.find((o) => o.value === font)?.label ?? font}` + ? `Following system — currently ${RESOLVED_THEME_SHORT[resolvedTheme]} · ${fontLabel}` : "Choose appearance — color + font"; return ( @@ -125,11 +131,6 @@ function ThemeSwitcher() { Colors { if (typeof value === "string" && isTheme(value)) setTheme(value); @@ -139,15 +140,7 @@ function ThemeSwitcher() { {opt.label} @@ -165,11 +158,6 @@ function ThemeSwitcher() { Font { if (typeof value === "string" && isFont(value)) setFont(value); @@ -179,15 +167,7 @@ function ThemeSwitcher() { {opt.label} From 1e37dc70a3024d470af10a740a6896d70023f04c Mon Sep 17 00:00:00 2001 From: itsprade Date: Fri, 15 May 2026 15:34:37 +0530 Subject: [PATCH 12/18] feat(themes): self-host fonts via optional @tailor-platform/app-shell/fonts subpath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the eight runtime `@import url("https://cdn.jsdelivr.net/...")` lines from `globals.css`. The default `@import "@tailor-platform/app-shell/styles"` is now font-free, eliminating the third-party CDN dependency (supply-chain risk, request privacy, render-blocking imports, no offline support, not lockfile-pinned). In its place, AppShell ships a separate, opt-in stylesheet at `@tailor-platform/app-shell/fonts` which pulls in `@fontsource-variable/geist` and `@fontsource-variable/inter` — self-hosted, lockfile-pinned, single variable font per family (~30KB each vs. 4× static weights). Consumers pick a loading strategy: 1. `@import "@tailor-platform/app-shell/fonts"` — zero-config. 2. `next/font/google` with the conventional family name (`Geist`, `Inter`) — the new `font-family` fallback chain `"Geist Variable", "Geist Sans", …` catches it. 3. Bring their own font; AppShell's `data-font` attribute still drives which axis is active. Wiring: - New `packages/core/src/assets/fonts.css` (copied to `dist/fonts.css` by `publicDir` during build). - New `"./fonts": "./dist/fonts.css"` entry in `package.json` exports. - `@fontsource-variable/{geist,inter}` added to core deps. - Example apps updated to import the subpath so demos still render Geist. - `docs/concepts/styling-theming.md` documents the three loading patterns. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/concepts/styling-theming.md | 38 +++++++++++++++++++ examples/nextjs-app/src/app/globals.css | 1 + examples/vite-app/src/index.css | 1 + packages/core/package.json | 3 ++ packages/core/src/assets/fonts.css | 13 +++++++ .../core/src/components/theme-switcher.tsx | 7 ++-- packages/core/src/globals.css | 20 ++++------ pnpm-lock.yaml | 16 ++++++++ 8 files changed, 84 insertions(+), 15 deletions(-) create mode 100644 packages/core/src/assets/fonts.css diff --git a/docs/concepts/styling-theming.md b/docs/concepts/styling-theming.md index 8f702eba..3d8337e7 100644 --- a/docs/concepts/styling-theming.md +++ b/docs/concepts/styling-theming.md @@ -52,6 +52,44 @@ Font is an independent axis from color theme — any palette works with either f 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"`**). +### Loading the fonts + +AppShell's main stylesheet (**`@tailor-platform/app-shell/styles`**) references the family names but **does not load the font files** — that's up to the consumer so you can choose your own loading strategy. Three common options: + +**1. Use the bundled fonts (zero-config)** + +```css +/* Your app's global CSS — typically app/globals.css or src/index.css */ +@import "@tailor-platform/app-shell/styles"; +@import "@tailor-platform/app-shell/fonts"; /* ships Geist + Inter variable */ +``` + +This pulls **`@fontsource-variable/geist`** and **`@fontsource-variable/inter`** in via the AppShell package — self-hosted, lockfile-pinned, no third-party CDN. + +**2. Use Next.js font loader (best for Next.js apps)** + +```tsx +// app/layout.tsx +import { Geist, Inter } from "next/font/google"; + +const geist = Geist({ subsets: ["latin"], variable: "--font-geist" }); +const inter = Inter({ subsets: ["latin"], variable: "--font-inter" }); + +export default function RootLayout({ children }) { + return ( + + {children} + + ); +} +``` + +AppShell's family chain is **`"Geist Variable", "Geist Sans", …`** — Next's loader registers Geist under **`"Geist Sans"`**, so it participates via the fallback. No extra CSS needed. + +**3. Self-host other fonts** + +If you don't want either Geist or Inter, you can replace the family on `body` in your own global CSS (post-import order). AppShell's `data-font` attribute still drives which axis is active. + ## 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/examples/nextjs-app/src/app/globals.css b/examples/nextjs-app/src/app/globals.css index 91056708..80fa9727 100644 --- a/examples/nextjs-app/src/app/globals.css +++ b/examples/nextjs-app/src/app/globals.css @@ -1,5 +1,6 @@ @import "tailwindcss"; @import "tw-animate-css"; +@import "@tailor-platform/app-shell/fonts"; @custom-variant dark (&:is(.dark *)); diff --git a/examples/vite-app/src/index.css b/examples/vite-app/src/index.css index b71cbfd1..1df94d6e 100644 --- a/examples/vite-app/src/index.css +++ b/examples/vite-app/src/index.css @@ -4,6 +4,7 @@ */ @import "tailwindcss"; @import "@tailor-platform/app-shell/styles"; +@import "@tailor-platform/app-shell/fonts"; html, body { diff --git a/packages/core/package.json b/packages/core/package.json index c21ce67b..b5bd01d1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -28,6 +28,7 @@ "exports": { "./styles": "./dist/app-shell.css", "./theme.css": "./dist/theme.css", + "./fonts": "./dist/fonts.css", ".": { "types": "./dist/app-shell.d.ts", "default": "./dist/app-shell.js" @@ -49,6 +50,8 @@ }, "dependencies": { "@base-ui/react": "^1.4.1", + "@fontsource-variable/geist": "^5.2.8", + "@fontsource-variable/inter": "^5.2.8", "@standard-schema/spec": "^1.1.0", "@tailor-platform/app-shell-vite-plugin": "workspace:*", "@tailor-platform/auth-public-client": "^0.5.0", diff --git a/packages/core/src/assets/fonts.css b/packages/core/src/assets/fonts.css new file mode 100644 index 00000000..b5c22ac7 --- /dev/null +++ b/packages/core/src/assets/fonts.css @@ -0,0 +1,13 @@ +/* + * Self-hosted variable fonts used by AppShell's appearance axis. Shipped as a + * separate, opt-in stylesheet via the `@tailor-platform/app-shell/fonts` + * subpath so the default `@import "@tailor-platform/app-shell/styles"` stays + * font-free and consumers can choose their own loading strategy (e.g. + * `next/font/local`, `next/font/google`, or a CDN). + * + * Family names: `"Geist Variable"`, `"Inter Variable"`. AppShell's + * `globals.css` falls back to `"Geist Sans"` / `"Inter"` after these so + * consumers who self-host with the conventional family names also work. + */ +@import "@fontsource-variable/geist"; +@import "@fontsource-variable/inter"; diff --git a/packages/core/src/components/theme-switcher.tsx b/packages/core/src/components/theme-switcher.tsx index 5423c0c8..f8cd0da9 100644 --- a/packages/core/src/components/theme-switcher.tsx +++ b/packages/core/src/components/theme-switcher.tsx @@ -33,10 +33,11 @@ const THEME_PREVIEW: Record = { - geist: '"Geist Sans", ui-sans-serif, system-ui, sans-serif', - inter: '"Inter", ui-sans-serif, system-ui, sans-serif', + geist: '"Geist Variable", "Geist Sans", ui-sans-serif, system-ui, sans-serif', + inter: '"Inter Variable", "Inter", ui-sans-serif, system-ui, sans-serif', }; function isTheme(value: string): value is Theme { diff --git a/packages/core/src/globals.css b/packages/core/src/globals.css index d937a351..17da3132 100644 --- a/packages/core/src/globals.css +++ b/packages/core/src/globals.css @@ -1,12 +1,3 @@ -@import url("https://cdn.jsdelivr.net/npm/@fontsource/geist-sans@5.2.5/400.css"); -@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"; @import "./assets/theme.css"; @@ -69,16 +60,21 @@ /* * Font axis — applied independently of color theme via `data-font` on ``. - * Default Tailwind sans is the fallback when no `data-font` is set. + * + * Family-name chain prefers the variable build (loaded by + * `@tailor-platform/app-shell/fonts`), then the conventional static family + * name (`Geist Sans` / `Inter` — what `next/font/google` and most CDNs + * register), then the system stack. Consumers who load their own font under + * either name automatically participate; no font loads when nothing matches. */ html[data-font="geist"] body, html[data-font="geist"] :where(h1, h2, h3, h4, h5, h6) { - font-family: "Geist Sans", ui-sans-serif, system-ui, sans-serif; + font-family: "Geist Variable", "Geist Sans", ui-sans-serif, system-ui, sans-serif; } html[data-font="inter"] body, html[data-font="inter"] :where(h1, h2, h3, h4, h5, h6) { - font-family: "Inter", ui-sans-serif, system-ui, sans-serif; + font-family: "Inter Variable", "Inter", ui-sans-serif, system-ui, sans-serif; } /* Tighter heading rhythm on cream/bloom — matches the showcase palettes' display feel. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86586d98..ea37fd54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -142,6 +142,12 @@ importers: '@base-ui/react': specifier: ^1.4.1 version: 1.4.1(@types/react@19.2.13)(date-fns@4.1.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@fontsource-variable/geist': + specifier: ^5.2.8 + version: 5.2.8 + '@fontsource-variable/inter': + specifier: ^5.2.8 + version: 5.2.8 '@standard-schema/spec': specifier: ^1.1.0 version: 1.1.0 @@ -936,6 +942,12 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@fontsource-variable/geist@5.2.8': + resolution: {integrity: sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw==} + + '@fontsource-variable/inter@5.2.8': + resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==} + '@hookform/resolvers@5.2.2': resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==} peerDependencies: @@ -6108,6 +6120,10 @@ snapshots: '@floating-ui/utils@0.2.11': {} + '@fontsource-variable/geist@5.2.8': {} + + '@fontsource-variable/inter@5.2.8': {} + '@hookform/resolvers@5.2.2(react-hook-form@7.71.2(react@19.2.5))': dependencies: '@standard-schema/utils': 0.3.0 From cd1e5679854e3ded3e2b9190b509971970a7117f Mon Sep 17 00:00:00 2001 From: itsprade Date: Fri, 15 May 2026 15:36:29 +0530 Subject: [PATCH 13/18] feat(themes): getInitialAppearanceScript() helper for pre-paint hydration Add a public helper that returns the source of a tiny IIFE consumers inline in `` so the stored theme + font are applied **before** React mounts. Closes the FOUC / hydration-warning gap left by `ThemeProvider`'s post-mount effect. The returned script reads `localStorage`, runs the same legacy-id migration as `parseStoredTheme`, resolves `system` via `matchMedia`, and writes `data-theme`, `data-font`, and `class="light"|"dark"` on ``. Wired into the Next.js example via a ` {children} diff --git a/examples/nextjs-app/src/app/layout.tsx b/examples/nextjs-app/src/app/layout.tsx index fe9a3801..b2c5c4ef 100644 --- a/examples/nextjs-app/src/app/layout.tsx +++ b/examples/nextjs-app/src/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import Script from "next/script"; import { Geist, Geist_Mono } from "next/font/google"; import { getInitialAppearanceScript } from "@tailor-platform/app-shell/initial-appearance"; import "@tailor-platform/app-shell/styles"; @@ -28,7 +29,9 @@ export default function RootLayout({ {/* Apply stored appearance before paint to avoid FOUC + hydration warnings. */} - {children}