diff --git a/.changeset/theme-mode-palette-axes.md b/.changeset/theme-mode-palette-axes.md new file mode 100644 index 00000000..c4f0a2e5 --- /dev/null +++ b/.changeset/theme-mode-palette-axes.md @@ -0,0 +1,27 @@ +--- +"@tailor-platform/app-shell": minor +--- + +Introduce theming support — **ColorTheme** axis, static **theme palettes** via CSS imports, a new `AppearanceSwitcher` component, and bundled Inter variable fonts. + +#### ColorTheme (end-user preference, persisted) + +`ColorTheme` (`"light" | "dark" | "system"`) is the end-user color mode preference. Applied to `` as `.light` / `.dark` class. + +- `` sets the initial preference; user choice is persisted to localStorage. +- `useTheme()` hook returns `{ theme, resolvedTheme, setTheme }`. + +#### Theme Palettes (static CSS imports) + +Each palette (`default`, `cream`, `bloom`) ships both light and dark variants. +Select a palette by importing its CSS file — no prop needed: + +```ts +import "@tailor-platform/app-shell/themes/cream"; +``` + +The default palette is included automatically via `@tailor-platform/app-shell/styles`. + +#### AppearanceSwitcher + +- New `` component for toggling color theme (light / dark / system). diff --git a/.changeset/theme-token-tiers.md b/.changeset/theme-token-tiers.md new file mode 100644 index 00000000..d23802a3 --- /dev/null +++ b/.changeset/theme-token-tiers.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/app-shell": patch +--- + +Set explicit `--accent` values per palette and mode, reorganise palette CSS into numbered tiers with a `_template.css` authoring guide, add `--alert-*` semantic tokens to the default palette, and wire `Alert` variants to those tokens (including lighter dark-mode foregrounds). diff --git a/docs/components/alert.md b/docs/components/alert.md index 98ded8ff..f9936e7f 100644 --- a/docs/components/alert.md +++ b/docs/components/alert.md @@ -78,7 +78,7 @@ Each variant automatically renders a contextual icon: | Variant | Automatic Icon | Color palette | | --------- | ------------------- | --------------- | -| `neutral` | Message circle icon | Secondary/muted | +| `neutral` | Message circle icon | Muted surface | | `success` | Check circle icon | Green | | `warning` | Alert triangle icon | Yellow | | `error` | X circle icon | Red/destructive | diff --git a/docs/concepts/styling-theming.md b/docs/concepts/styling-theming.md index 60c13ae7..88ab8c7a 100644 --- a/docs/concepts/styling-theming.md +++ b/docs/concepts/styling-theming.md @@ -60,3 +60,21 @@ These properties are defined in `:root` and can be overridden in your own CSS: --z-popup: 100; } ``` + +## Adding a palette + +Theme tokens live in `packages/core/src/assets/themes/`. Copy `_template.css` to start a new palette — it lists exactly which sections to fill in for light and dark mode. + +| Section | Required? | What to set | +| --------------------- | --------------------- | ------------------------------------------------------------- | +| **1. Brand** | Yes | `primary`, `secondary`, `accent` (+ foregrounds) — both modes | +| **2. Shell gradient** | Branded palettes only | `--shell-gradient-base`, `--shell-gradient-tint` | +| **3. Derived** | Copy verbatim | Ring, sidebar aliases, gradient stops — do not edit | +| **4. System** | Tune or copy default | Surfaces: background, card, popover, muted, borders | +| **5. Palette** | Optional | Radius, chart colors, shadows | +| **6. Semantic** | Do not duplicate | Status and alert tokens inherit from `default.css` | +| **7. Structural** | Branded palettes | Copy `@layer` blocks from `bloom.css` for gradient shell | + +Then `@import` the new file in `theme.css` and set `defaultThemePalette` on ``. + +Preview token values at `/custom-page/color` in the Next.js example app. diff --git a/e2e/app/src/index.css b/e2e/app/src/index.css index 779fc85b..daf4c253 100644 --- a/e2e/app/src/index.css +++ b/e2e/app/src/index.css @@ -1,3 +1,2 @@ @import "tailwindcss"; @import "@tailor-platform/app-shell/styles"; -@import "@tailor-platform/app-shell/theme.css"; diff --git a/examples/nextjs-app/src/app/layout.tsx b/examples/nextjs-app/src/app/layout.tsx index 002fd32d..e2990d9d 100644 --- a/examples/nextjs-app/src/app/layout.tsx +++ b/examples/nextjs-app/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import "@tailor-platform/app-shell/styles"; +import "@tailor-platform/app-shell/themes/cream"; import "./globals.css"; export const metadata: Metadata = { diff --git a/examples/nextjs-app/src/modules/custom-module.tsx b/examples/nextjs-app/src/modules/custom-module.tsx index 0e518b47..1f4274ef 100644 --- a/examples/nextjs-app/src/modules/custom-module.tsx +++ b/examples/nextjs-app/src/modules/custom-module.tsx @@ -21,6 +21,7 @@ import { dropdownComponentsDemoResource } from "./pages/dropdown-demo"; import { formComponentsDemoResource, zodRHFFormDemoResource } from "./pages/form-demo"; import { csvImporterDemoResource } from "./pages/csv-importer-demo"; import { dataTableDemoResource } from "./pages/data-table-demo"; +import { colorDemoResource } from "./pages/color-demo"; export const customPageModule = defineModule({ path: "custom-page", @@ -40,6 +41,17 @@ export const customPageModule = defineModule({

{t("goToDynamicPage")}

+

+ + Color tokens (swatches & layers) + +

+

+ Aa + + {fgLabel ?? fgVar.replace(/^--/, "")} + +
+ + {name} + + + ); +} + +function SwatchSection({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+
{children}
+
+ ); +} + +const ColorDemoPage = () => { + return ( + + + + + + +

+ Live values from the active palette and color mode. Sections mirror the token tiers in{" "} + packages/core/src/assets/themes/ — see{" "} + _template.css for what to set when + adding a palette. +

+ + + + + + + + + +
+ --ring +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + +
+ {/* Surface stack */} +
+

Surface stack (background → card → popover)

+
+
+ + card + +
+ popover surface +
+ accent (menu hover / sidebar selection) +
+
+
+
+
+ + {/* Button row on background */} +
+

Actions on card

+
+ + + + + +
+
+ + {/* Menu on popover */} +
+

Menu hover (accent on popover)

+ + }>Open menu + + Default item + Hover me + Another item + + +
+ + {/* Sidebar selection simulation */} +
+

Sidebar selection (accent on transparent chrome)

+
+
+ Home +
+
+ Selected item +
+
+ Settings +
+
+
+
+
+
+
+
+ ); +}; + +export const colorDemoResource = defineResource({ + path: "color", + meta: { + title: "Color", + }, + component: ColorDemoPage, +}); diff --git a/examples/vite-app/src/index.css b/examples/vite-app/src/index.css index c280a9f7..80540ae0 100644 --- a/examples/vite-app/src/index.css +++ b/examples/vite-app/src/index.css @@ -1,5 +1,6 @@ @import "tailwindcss"; @import "@tailor-platform/app-shell/styles"; +@import "@tailor-platform/app-shell/themes/bloom"; html, body { diff --git a/packages/core/__snapshots__/src__components__alert.test.tsx.snap b/packages/core/__snapshots__/src__components__alert.test.tsx.snap index ca84761b..38970bd3 100644 --- a/packages/core/__snapshots__/src__components__alert.test.tsx.snap +++ b/packages/core/__snapshots__/src__components__alert.test.tsx.snap @@ -1,13 +1,13 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Alert > snapshots > error variant 1`] = `""`; +exports[`Alert > snapshots > error variant 1`] = `""`; -exports[`Alert > snapshots > info variant 1`] = `""`; +exports[`Alert > snapshots > info variant 1`] = `""`; -exports[`Alert > snapshots > neutral variant (default) 1`] = `""`; +exports[`Alert > snapshots > neutral variant (default) 1`] = `""`; -exports[`Alert > snapshots > success variant 1`] = `""`; +exports[`Alert > snapshots > success variant 1`] = `""`; -exports[`Alert > snapshots > warning variant 1`] = `""`; +exports[`Alert > snapshots > warning variant 1`] = `""`; -exports[`Alert > snapshots > with dismissible 1`] = `""`; +exports[`Alert > snapshots > with dismissible 1`] = `""`; diff --git a/packages/core/package.json b/packages/core/package.json index d52eb5b5..4c2b717d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -28,7 +28,7 @@ "type": "module", "exports": { "./styles": "./dist/app-shell.css", - "./theme.css": "./dist/theme.css", + "./themes/*": "./dist/themes/*.css", ".": { "types": "./dist/app-shell.d.ts", "default": "./dist/app-shell.js" diff --git a/packages/core/src/assets/theme.css b/packages/core/src/assets/theme.css index 97a7551f..b8a977df 100644 --- a/packages/core/src/assets/theme.css +++ b/packages/core/src/assets/theme.css @@ -1,77 +1,33 @@ -@custom-variant dark (&:where(.dark, .dark *)); - -:root { - --background: rgba(250, 250, 250, 1); - --foreground: rgba(10, 10, 10, 1); - --card: rgba(255, 255, 255, 1); - --card-foreground: rgba(10, 10, 10, 1); - --popover: rgba(255, 255, 255, 1); - --popover-foreground: rgba(10, 10, 10, 1); - --primary: rgba(23, 23, 23, 1); - --primary-foreground: rgba(250, 250, 250, 1); - --secondary: rgba(245, 245, 245, 1); - --secondary-foreground: rgba(23, 23, 23, 1); - --muted: rgba(245, 245, 245, 1); - --muted-foreground: rgba(115, 115, 115, 1); - --accent: rgba(245, 245, 245, 1); - --accent-foreground: rgba(23, 23, 23, 1); - --destructive: rgba(220, 38, 38, 1); - --destructive-foreground: rgba(254, 242, 242, 1); - --border: rgba(229, 229, 229, 1); - --input: rgba(229, 229, 229, 1); - --ring: rgba(163, 163, 163, 1); - --chart-1: rgba(234, 88, 12, 1); - --chart-2: rgba(13, 148, 136, 1); - --chart-3: rgba(22, 78, 99, 1); - --chart-4: rgba(251, 191, 36, 1); - --chart-5: rgba(245, 158, 11, 1); - --radius: 0.625rem; - --sidebar: rgba(250, 250, 250, 1); - --sidebar-foreground: rgba(10, 10, 10, 1); - --sidebar-primary: rgba(23, 23, 23, 1); - --sidebar-primary-foreground: rgba(250, 250, 250, 1); - --sidebar-accent: rgba(245, 245, 245, 1); - --sidebar-accent-foreground: rgba(23, 23, 23, 1); - --sidebar-border: rgba(229, 229, 229, 1); - --sidebar-ring: rgba(163, 163, 163, 1); -} +/** + * Theme system + * + * Palettes live in ./themes/*.css. Copy ./_template.css to add a new one. + * + * Palette tiers: + * + * 1. BRAND — required per mode (light + dark): + * primary, secondary, accent (+ foregrounds each). + * Tune accent by eye for menu/sidebar selection. + * 2. SHELL GRADIENT — optional; branded palettes only (bloom, cream). + * 3. SYSTEM — surfaces & chrome; copy default or tune for brand tint + * (foreground, muted, popover, card, borders). + * 4. PALETTE — optional: radius, charts, shadows, destructive. + * + * Inherited from default.css — do not duplicate in new palettes: + * --ring, --sidebar-*, --status-*, --alert-*. + * + * Mode: `.dark` on . Palette: import one theme file after styles + * (e.g. `@tailor-platform/app-shell/themes/cream`). + */ +@import "./themes/default.css" layer(theme.defaults); -.dark { - color-scheme: dark; - --background: rgba(10, 10, 10, 1); - --foreground: rgba(250, 250, 250, 1); - --card: rgba(23, 23, 23, 1); - --card-foreground: rgba(250, 250, 250, 1); - --popover: rgba(38, 38, 38, 1); - --popover-foreground: rgba(250, 250, 250, 1); - --primary: rgba(229, 229, 229, 1); - --primary-foreground: rgba(23, 23, 23, 1); - --secondary: rgba(38, 38, 38, 1); - --secondary-foreground: rgba(250, 250, 250, 1); - --muted: rgba(38, 38, 38, 1); - --muted-foreground: rgba(163, 163, 163, 1); - --accent: rgba(64, 64, 64, 1); - --accent-foreground: rgba(250, 250, 250, 1); - --destructive: rgba(248, 113, 113, 1); - --destructive-foreground: rgba(254, 242, 242, 1); - --border: rgba(255, 255, 255, 0.10000000149011612); - --input: rgba(255, 255, 255, 0.15000000596046448); - --ring: rgba(115, 115, 115, 1); - --chart-1: rgba(29, 78, 216, 1); - --chart-2: rgba(16, 185, 129, 1); - --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); - --sidebar-foreground: rgba(250, 250, 250, 1); - --sidebar-primary: rgba(29, 78, 216, 1); - --sidebar-primary-foreground: rgba(250, 250, 250, 1); - --sidebar-accent: rgba(38, 38, 38, 1); - --sidebar-accent-foreground: rgba(250, 250, 250, 1); - --sidebar-border: rgba(255, 255, 255, 0.10000000149011612); - --sidebar-ring: rgba(82, 82, 82, 1); -} +@custom-variant dark (&:where(.dark, .dark *)); +/** + * Tailwind v4 theme bridge — maps raw CSS custom properties to Tailwind's + * design token namespace so utilities like `bg-background`, `text-primary`, + * `shadow-sm` resolve correctly regardless of the active palette. + */ @theme inline { --font-sans: "Inter Variable", "Inter", ui-sans-serif, system-ui, sans-serif; @@ -112,10 +68,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/assets/themes/_template.css b/packages/core/src/assets/themes/_template.css new file mode 100644 index 00000000..81ad9491 --- /dev/null +++ b/packages/core/src/assets/themes/_template.css @@ -0,0 +1,117 @@ +/** + * Palette template — copy to `themes/{name}.css` when adding a new theme. + * + * ═══════════════════════════════════════════════════════════════════════════ + * NEW PALETTE CHECKLIST + * ═══════════════════════════════════════════════════════════════════════════ + * + * 1. Copy this file → `themes/{name}.css`. + * 2. Set section 1 (BRAND) for light AND dark — 6 tokens per mode. + * 3. Optional: set section 2 (SHELL GRADIENT) and copy the structural block + * from `bloom.css` / `cream.css` if this palette uses a shell gradient. + * 4. Tune section 3 (SYSTEM) if the brand needs warm/cool surfaces — copy + * from `default.css` for neutral palettes, or from `bloom.css` / `cream.css` + * as a starting point for branded shells. Set both modes. + * 5. Optional: section 4 (PALETTE) — radius, chart hues, shadow tint. + * 6. `@import "./themes/{name}.css"` in `theme.css`. + * 7. Set `defaultThemePalette="{name}"` on `` (or let users switch). + * + * DO NOT SET (inherited from `:root` in default.css): + * --ring, --sidebar-*, --status-*, --alert-*, + * --destructive-foreground formulas in light mode + * + * PREVIEW: `/custom-page/color` in the Next.js example app. + * ═══════════════════════════════════════════════════════════════════════════ + */ + +:root { + color-scheme: light; + + /* ── 1. BRAND — required; set explicitly for light and dark ─────────── */ + --primary: /* brand action color */; + --primary-foreground: /* contrast text on primary */; + --secondary: /* filled secondary actions, badges */; + --secondary-foreground: /* contrast text on secondary */; + --accent: /* menu/sidebar hover & selection — tune by eye, not derived */; + --accent-foreground: /* contrast text on accent */; + + /* ── 2. SHELL GRADIENT — optional; omit for flat palettes like default ── */ + --shell-gradient-base: /* page backdrop bottom / dark anchor */; + --shell-gradient-tint: /* page backdrop top / light lift */; + + /* ── 3. SYSTEM — surfaces & chrome; tune for brand tint or copy default ─ */ + --background: rgba(250, 250, 250, 1); + --foreground: rgba(10, 10, 10, 1); + --card: rgba(255, 255, 255, 1); + --card-foreground: rgba(10, 10, 10, 1); + --popover: rgba(255, 255, 255, 1); + --popover-foreground: rgba(10, 10, 10, 1); + --muted: rgba(245, 245, 245, 1); + --muted-foreground: rgba(115, 115, 115, 1); + --border: rgba(229, 229, 229, 1); + --input: rgba(229, 229, 229, 1); + --sidebar: rgba(250, 250, 250, 1); + --sidebar-foreground: rgba(10, 10, 10, 1); + --sidebar-border: rgba(229, 229, 229, 1); + + /* ── 4. PALETTE — optional overrides (else inherited from :root) ──────── */ + --destructive: rgba(220, 38, 38, 1); + --radius: 0.625rem; + --chart-1: /* brand-adjacent */; + --chart-2: /* ... */; + --chart-3: /* ... */; + --chart-4: /* ... */; + --chart-5: /* ... */; + --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); +} + +:root.dark { + color-scheme: dark; + + /* ── 1. BRAND (dark) ─────────────────────────────────────────────────── */ + --primary: /* lighter/desaturated for dark surfaces */; + --primary-foreground: /* dark text on light primary */; + --secondary: /* dark secondary fill */; + --secondary-foreground: /* light text on secondary */; + --accent: /* sidebar/menu selection on dark — tune by eye */; + --accent-foreground: /* light text on accent */; + + /* ── 2. SHELL GRADIENT (dark) ────────────────────────────────────────── */ + --shell-gradient-base: /* dark anchor */; + --shell-gradient-tint: /* slightly lifted dark top */; + + /* ── 3. SYSTEM (dark) ────────────────────────────────────────────────── */ + --background: rgba(10, 10, 10, 1); + --foreground: rgba(250, 250, 250, 1); + --card: rgba(23, 23, 23, 1); + --card-foreground: rgba(250, 250, 250, 1); + --popover: rgba(38, 38, 38, 1); + --popover-foreground: rgba(250, 250, 250, 1); + --muted: rgba(38, 38, 38, 1); + --muted-foreground: rgba(163, 163, 163, 1); + --border: rgba(255, 255, 255, 0.1); + --input: rgba(255, 255, 255, 0.15); + --sidebar: rgba(10, 10, 10, 1); + --sidebar-foreground: rgba(250, 250, 250, 1); + --sidebar-border: rgba(255, 255, 255, 0.1); + + /* ── 4. PALETTE (dark overrides) ─────────────────────────────────────── */ + --destructive: rgba(248, 113, 113, 1); + --chart-1: /* ... */; + --chart-2: /* ... */; + --chart-3: /* ... */; + --chart-4: /* ... */; + --chart-5: /* ... */; + --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); +} + +/* + * Optional: copy the structural overrides block from bloom.css or cream.css + * when using a shell gradient (transparent sidebar, squircle, etc.). + */ diff --git a/packages/core/src/assets/themes/bloom.css b/packages/core/src/assets/themes/bloom.css new file mode 100644 index 00000000..3f1834a6 --- /dev/null +++ b/packages/core/src/assets/themes/bloom.css @@ -0,0 +1,159 @@ +/** + * Bloom — lavender shell + indigo brand. + * Branded palette: overrides brand/system/palette tokens; shared aliases + + * semantic tokens inherit from default.css. + */ +:root { + color-scheme: light; + + /* ── 1. BRAND ─────────────────────────────────────────────────────────── */ + --primary: rgba(83, 90, 232, 1); + --primary-foreground: rgba(255, 255, 255, 1); + --secondary: rgb(216, 217, 244); + --secondary-foreground: rgba(23, 23, 23, 1); + --accent: rgb(233, 234, 248); + --accent-foreground: rgba(16, 18, 43, 1); + + /* ── 2. SHELL GRADIENT ────────────────────────────────────────────────── */ + --shell-gradient-base: rgba(239, 232, 255, 1); + --shell-gradient-tint: rgb(255, 255, 255); + + /* ── 3. SYSTEM — brand-tinted surfaces ────────────────────────────────── */ + --background: rgba(250, 250, 250, 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); + --muted: rgba(246, 242, 255, 1); + --muted-foreground: rgba(16, 18, 43, 0.72); + --border: rgba(0, 0, 0, 0.08); + --input: rgba(0, 0, 0, 0.08); + --sidebar: rgba(250, 250, 250, 1); + --sidebar-foreground: rgba(16, 18, 43, 1); + --sidebar-border: rgba(0, 0, 0, 0.08); + + /* ── 4. PALETTE ───────────────────────────────────────────────────────── */ + --destructive: rgba(220, 38, 38, 1); + --radius: 1rem; + --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); + --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); +} + +:root.dark { + color-scheme: dark; + + /* ── 1. BRAND (dark) ─────────────────────────────────────────────────── */ + --primary: rgba(131, 138, 247, 1); + --primary-foreground: rgba(16, 18, 43, 1); + --secondary: rgb(45, 48, 72); + --secondary-foreground: rgba(248, 249, 252, 1); + --accent: rgb(50, 53, 72); + --accent-foreground: rgba(248, 249, 252, 1); + + /* ── 2. SHELL GRADIENT (dark) ─────────────────────────────────────────── */ + --shell-gradient-base: rgba(9, 10, 17, 1); + --shell-gradient-tint: rgb(16, 17, 26); + + /* ── 3. SYSTEM (dark) ─────────────────────────────────────────────────── */ + --background: rgba(10, 10, 10, 1); + --foreground: rgba(248, 249, 252, 1); + --card: rgba(26, 28, 42, 1); + --card-foreground: rgba(248, 249, 252, 1); + --popover: rgba(34, 37, 54, 1); + --popover-foreground: rgba(248, 249, 252, 1); + --muted: rgba(36, 39, 58, 1); + --muted-foreground: rgba(180, 183, 200, 1); + --border: rgba(255, 255, 255, 0.08); + --input: rgba(255, 255, 255, 0.12); + --sidebar: rgba(10, 10, 10, 1); + --sidebar-foreground: rgba(248, 249, 252, 1); + --sidebar-border: rgba(255, 255, 255, 0.08); + + /* ── 4. PALETTE (dark) ────────────────────────────────────────────────── */ + --destructive: rgba(248, 113, 113, 1); + --chart-1: rgba(131, 138, 247, 1); + --chart-2: rgba(45, 191, 196, 1); + --chart-3: rgba(94, 234, 212, 1); + --chart-4: rgba(167, 139, 250, 1); + --chart-5: rgba(251, 191, 36, 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); +} + +/* + * Structural overrides — shell gradient, transparent chrome, squircle corners, + * tighter heading rhythm. Apply in both modes (gradient reads `--shell-gradient-base` + * + the per-mode `--shell-gradient-tint`). + * + * These must NOT be in a @layer so they override Tailwind utilities (which are layered). + */ + +html { + min-height: 100vh; + min-height: 100dvh; + background-color: var(--shell-gradient-base); + background-image: linear-gradient( + to bottom, + color-mix(in srgb, var(--shell-gradient-base) 55%, var(--shell-gradient-tint)) 0%, + color-mix(in srgb, var(--shell-gradient-base) 45%, var(--shell-gradient-tint)) 20%, + color-mix(in srgb, var(--shell-gradient-base) 30%, var(--shell-gradient-tint)) 40%, + color-mix(in srgb, var(--shell-gradient-base) 15%, var(--shell-gradient-tint)) 55%, + color-mix(in srgb, var(--shell-gradient-base) 6%, var(--shell-gradient-tint)) 65%, + var(--shell-gradient-tint) 70%, + var(--shell-gradient-tint) 100% + ); + background-attachment: fixed; + background-repeat: no-repeat; + background-size: 100% 100%; +} + +html :where(h1, h2, h3, h4, h5, h6) { + letter-spacing: -0.03em; + line-height: 1.2; +} + +@supports (corner-shape: squircle) { + html * { + corner-shape: squircle; + } + html [class*="rounded-full"] { + corner-shape: round; + } +} + +html body { + background-color: transparent; + background-image: none; +} + +html [data-slot="sidebar-wrapper"], +html [data-slot="sidebar-inner"], +html main[data-slot="sidebar-inset"], +html + [data-slot="sidebar"][class*="bg-sidebar"]:not([data-mobile="true"]):not( + [data-icon-mode="true"] + ) { + background-color: transparent; +} + +html [data-slot="button"][class*="astw:bg-background"][class*="astw:border"] { + background-color: transparent; + transition: + background-color 0.12s ease, + color 0.12s ease; +} + +html [data-slot="button"][class*="astw:bg-background"][class*="astw:border"]:hover:not(:disabled) { + background-color: var(--accent); + color: var(--accent-foreground); +} diff --git a/packages/core/src/assets/themes/cream.css b/packages/core/src/assets/themes/cream.css new file mode 100644 index 00000000..508d7f21 --- /dev/null +++ b/packages/core/src/assets/themes/cream.css @@ -0,0 +1,159 @@ +/** + * Cream — warm Tailor brand shell + indigo actions. + * Branded palette: overrides brand/system/palette tokens; shared aliases + + * semantic tokens inherit from default.css. + */ +:root { + color-scheme: light; + + /* ── 1. BRAND ─────────────────────────────────────────────────────────── */ + --primary: rgba(83, 90, 232, 1); + --primary-foreground: rgba(255, 255, 255, 1); + --secondary: rgb(240, 230, 212); + --secondary-foreground: rgba(16, 18, 43, 1); + --accent: rgb(245, 238, 228); + --accent-foreground: rgba(16, 18, 43, 1); + + /* ── 2. SHELL GRADIENT ────────────────────────────────────────────────── */ + --shell-gradient-base: #fef6e3; + --shell-gradient-tint: #fffdfa; + + /* ── 3. SYSTEM — brand-tinted surfaces ────────────────────────────────── */ + --background: rgba(250, 250, 250, 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); + --muted: rgba(251, 248, 240, 1); + --muted-foreground: rgba(16, 18, 43, 0.72); + --border: rgba(0, 0, 0, 0.08); + --input: rgba(0, 0, 0, 0.08); + --sidebar: rgba(250, 250, 250, 1); + --sidebar-foreground: rgba(16, 18, 43, 1); + --sidebar-border: rgba(0, 0, 0, 0.08); + + /* ── 4. PALETTE ───────────────────────────────────────────────────────── */ + --destructive: rgba(220, 38, 38, 1); + --radius: 1rem; + --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); + --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); +} + +:root.dark { + color-scheme: dark; + + /* ── 1. BRAND (dark) ─────────────────────────────────────────────────── */ + --primary: rgba(131, 138, 247, 1); + --primary-foreground: rgba(16, 18, 43, 1); + --secondary: rgb(42, 36, 30); + --secondary-foreground: rgba(247, 244, 238, 1); + --accent: rgb(50, 44, 38); + --accent-foreground: rgba(247, 244, 238, 1); + + /* ── 2. SHELL GRADIENT (dark) ─────────────────────────────────────────── */ + --shell-gradient-base: rgb(8, 6, 5); + --shell-gradient-tint: rgb(17, 14, 10); + + /* ── 3. SYSTEM (dark) ─────────────────────────────────────────────────── */ + --background: rgba(10, 10, 10, 1); + --foreground: rgba(247, 244, 238, 1); + --card: rgb(22, 19, 17); + --card-foreground: rgba(247, 244, 238, 1); + --popover: rgb(37, 33, 28); + --popover-foreground: rgba(247, 244, 238, 1); + --muted: rgba(42, 37, 31, 1); + --muted-foreground: rgba(186, 180, 170, 1); + --border: rgba(255, 255, 255, 0.08); + --input: rgba(255, 255, 255, 0.12); + --sidebar: rgba(10, 10, 10, 1); + --sidebar-foreground: rgba(247, 244, 238, 1); + --sidebar-border: rgba(255, 255, 255, 0.08); + + /* ── 4. PALETTE (dark) ────────────────────────────────────────────────── */ + --destructive: rgba(248, 113, 113, 1); + --chart-1: rgba(131, 138, 247, 1); + --chart-2: rgba(45, 191, 196, 1); + --chart-3: rgba(94, 234, 212, 1); + --chart-4: rgba(167, 139, 250, 1); + --chart-5: rgba(251, 191, 36, 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); +} + +/* + * Structural overrides — shell gradient, transparent chrome, squircle corners, + * tighter heading rhythm. Apply in both modes (gradient reads `--shell-gradient-base` + * + the per-mode `--shell-gradient-tint`). + * + * These must NOT be in a @layer so they override Tailwind utilities (which are layered). + */ + +html { + min-height: 100vh; + min-height: 100dvh; + background-color: var(--shell-gradient-base); + background-image: linear-gradient( + to bottom, + color-mix(in srgb, var(--shell-gradient-base) 55%, var(--shell-gradient-tint)) 0%, + color-mix(in srgb, var(--shell-gradient-base) 45%, var(--shell-gradient-tint)) 20%, + color-mix(in srgb, var(--shell-gradient-base) 30%, var(--shell-gradient-tint)) 40%, + color-mix(in srgb, var(--shell-gradient-base) 15%, var(--shell-gradient-tint)) 55%, + color-mix(in srgb, var(--shell-gradient-base) 6%, var(--shell-gradient-tint)) 65%, + var(--shell-gradient-tint) 70%, + var(--shell-gradient-tint) 100% + ); + background-attachment: fixed; + background-repeat: no-repeat; + background-size: 100% 100%; +} + +html :where(h1, h2, h3, h4, h5, h6) { + letter-spacing: -0.03em; + line-height: 1.2; +} + +@supports (corner-shape: squircle) { + html * { + corner-shape: squircle; + } + html [class*="rounded-full"] { + corner-shape: round; + } +} + +html body { + background-color: transparent; + background-image: none; +} + +html [data-slot="sidebar-wrapper"], +html [data-slot="sidebar-inner"], +html main[data-slot="sidebar-inset"], +html + [data-slot="sidebar"][class*="bg-sidebar"]:not([data-mobile="true"]):not( + [data-icon-mode="true"] + ) { + background-color: transparent; +} + +html [data-slot="button"][class*="astw:bg-background"][class*="astw:border"] { + background-color: transparent; + transition: + background-color 0.12s ease, + color 0.12s ease; +} + +html [data-slot="button"][class*="astw:bg-background"][class*="astw:border"]:hover:not(:disabled) { + background-color: var(--accent); + color: var(--accent-foreground); +} diff --git a/packages/core/src/assets/themes/default.css b/packages/core/src/assets/themes/default.css new file mode 100644 index 00000000..96929f05 --- /dev/null +++ b/packages/core/src/assets/themes/default.css @@ -0,0 +1,162 @@ +/** + * Default palette — base `:root` tokens. + * + * All palettes inherit shared aliases + semantic tokens from here. + * Branded palettes override only palette-specific tokens; see ./_template.css. + */ +:root { + color-scheme: light; + + /* ── 1. BRAND ─────────────────────────────────────────────────────────── */ + --primary: rgba(23, 23, 23, 1); + --primary-foreground: rgba(250, 250, 250, 1); + --secondary: rgba(245, 245, 245, 1); + --secondary-foreground: rgba(23, 23, 23, 1); + --accent: rgb(237, 237, 237); + --accent-foreground: rgba(10, 10, 10, 1); + + /* ── 2. SHELL GRADIENT — not used (flat palette) ──────────────────────── */ + + /* ── 3. SHARED ALIASES — inherited by palettes; usually leave as-is ───── */ + --ring: color-mix(in srgb, var(--primary) 45%, transparent); + --sidebar-primary: var(--primary); + --sidebar-primary-foreground: var(--primary-foreground); + --sidebar-accent: var(--accent); + --sidebar-accent-foreground: var(--accent-foreground); + --sidebar-ring: var(--ring); + + /* ── 4. SYSTEM ────────────────────────────────────────────────────────── */ + --background: rgba(250, 250, 250, 1); + --foreground: rgba(10, 10, 10, 1); + --card: rgba(255, 255, 255, 1); + --card-foreground: rgba(10, 10, 10, 1); + --popover: rgba(255, 255, 255, 1); + --popover-foreground: rgba(10, 10, 10, 1); + --muted: rgba(245, 245, 245, 1); + --muted-foreground: rgba(115, 115, 115, 1); + --border: rgba(229, 229, 229, 1); + --input: rgba(229, 229, 229, 1); + --sidebar: rgba(250, 250, 250, 1); + --sidebar-foreground: rgba(10, 10, 10, 1); + --sidebar-border: rgba(229, 229, 229, 1); + + /* ── 5. PALETTE ───────────────────────────────────────────────────────── */ + --destructive: rgba(220, 38, 38, 1); + --destructive-foreground: rgba(254, 242, 242, 1); + --radius: 0.625rem; + --chart-1: rgba(234, 88, 12, 1); + --chart-2: rgba(13, 148, 136, 1); + --chart-3: rgba(22, 78, 99, 1); + --chart-4: rgba(251, 191, 36, 1); + --chart-5: rgba(245, 158, 11, 1); + --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); + + /* ── 6. SEMANTIC — product-wide; inherited by all palettes, do not override */ + --status-default: #737373; + --status-neutral: #0ea5e9; + --status-completed: #22c55e; + --status-attention: #f59e0b; + --status-danger: #ef4444; + --alert-neutral-background: var(--muted); + --alert-neutral-foreground: var(--foreground); + --alert-neutral-foreground-muted: var(--muted-foreground); + --alert-neutral-border: var(--border); + --alert-success-foreground: #15803d; + --alert-success-foreground-muted: color-mix( + in srgb, + var(--alert-success-foreground) 80%, + transparent + ); + --alert-success-background: color-mix(in srgb, #22c55e 10%, transparent); + --alert-success-border: color-mix(in srgb, #22c55e 20%, transparent); + --alert-warning-foreground: #a16207; + --alert-warning-foreground-muted: color-mix( + in srgb, + var(--alert-warning-foreground) 80%, + transparent + ); + --alert-warning-background: color-mix(in srgb, #eab308 10%, transparent); + --alert-warning-border: color-mix(in srgb, #eab308 20%, transparent); + --alert-error-foreground: var(--destructive); + --alert-error-foreground-muted: color-mix( + in srgb, + var(--alert-error-foreground) 80%, + transparent + ); + --alert-error-background: color-mix(in srgb, var(--destructive) 10%, transparent); + --alert-error-border: color-mix(in srgb, var(--destructive) 20%, transparent); + --alert-info-foreground: #1d4ed8; + --alert-info-foreground-muted: color-mix(in srgb, var(--alert-info-foreground) 80%, transparent); + --alert-info-background: color-mix(in srgb, #3b82f6 10%, transparent); + --alert-info-border: color-mix(in srgb, #3b82f6 20%, transparent); +} + +/* + * Dark variant. Overrides BRAND + SYSTEM (+ semantic foregrounds that differ). + * Shared aliases/ring re-resolve when BRAND changes; `--radius` and `--status-*` + * inherit from the light block. + */ +.dark { + color-scheme: dark; + + /* ── 1. BRAND (dark) ──────────────────────────────────────────────────── */ + --primary: rgba(229, 229, 229, 1); + --primary-foreground: rgba(23, 23, 23, 1); + --secondary: rgba(38, 38, 38, 1); + --secondary-foreground: rgba(250, 250, 250, 1); + --accent: rgb(48, 48, 48); + --accent-foreground: rgba(250, 250, 250, 1); + + /* ── 4. SYSTEM (dark) ─────────────────────────────────────────────────── */ + --background: rgba(10, 10, 10, 1); + --foreground: rgba(250, 250, 250, 1); + --card: rgba(23, 23, 23, 1); + --card-foreground: rgba(250, 250, 250, 1); + --popover: rgba(38, 38, 38, 1); + --popover-foreground: rgba(250, 250, 250, 1); + --muted: rgba(38, 38, 38, 1); + --muted-foreground: rgba(163, 163, 163, 1); + --border: rgba(255, 255, 255, 0.1); + --input: rgba(255, 255, 255, 0.15); + --sidebar: rgba(10, 10, 10, 1); + --sidebar-foreground: rgba(250, 250, 250, 1); + --sidebar-border: rgba(255, 255, 255, 0.1); + + /* ── 5. PALETTE (dark) ────────────────────────────────────────────────── */ + --destructive: rgba(248, 113, 113, 1); + --destructive-foreground: rgba(254, 242, 242, 1); + --chart-1: rgba(29, 78, 216, 1); + --chart-2: rgba(16, 185, 129, 1); + --chart-3: rgba(245, 158, 11, 1); + --chart-4: rgba(168, 85, 247, 1); + --chart-5: rgba(244, 63, 94, 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); + + /* ── 6. SEMANTIC (dark foreground adjustments) ──────────────────────── */ + --alert-success-foreground: #86efac; + --alert-success-foreground-muted: color-mix( + in srgb, + var(--alert-success-foreground) 85%, + transparent + ); + --alert-warning-foreground: #fde047; + --alert-warning-foreground-muted: color-mix( + in srgb, + var(--alert-warning-foreground) 85%, + transparent + ); + --alert-error-foreground: #fca5a5; + --alert-error-foreground-muted: color-mix( + in srgb, + var(--alert-error-foreground) 85%, + transparent + ); + --alert-info-foreground: #93c5fd; + --alert-info-foreground-muted: color-mix(in srgb, var(--alert-info-foreground) 85%, transparent); +} diff --git a/packages/core/src/components/alert.tsx b/packages/core/src/components/alert.tsx index 61f95257..dfba868a 100644 --- a/packages/core/src/components/alert.tsx +++ b/packages/core/src/components/alert.tsx @@ -16,14 +16,14 @@ const alertVariants = cva( variants: { variant: { neutral: - "astw:bg-secondary astw:text-secondary-foreground astw:border-border *:data-[slot=alert-description]:astw:text-muted-foreground", + "astw:bg-[color:var(--alert-neutral-background)] astw:text-[color:var(--alert-neutral-foreground)] astw:border-[color:var(--alert-neutral-border)] *:data-[slot=alert-description]:astw:text-[color:var(--alert-neutral-foreground-muted)]", success: - "astw:bg-green-500/10 astw:text-green-700 astw:border-green-500/20 dark:astw:text-green-400 *:data-[slot=alert-description]:astw:text-green-700/80 dark:*:data-[slot=alert-description]:astw:text-green-400/80", + "astw:bg-[color:var(--alert-success-background)] astw:text-[color:var(--alert-success-foreground)] astw:border-[color:var(--alert-success-border)] *:data-[slot=alert-description]:astw:text-[color:var(--alert-success-foreground-muted)]", warning: - "astw:bg-yellow-500/10 astw:text-yellow-700 astw:border-yellow-500/20 dark:astw:text-yellow-500 *:data-[slot=alert-description]:astw:text-yellow-700/80 dark:*:data-[slot=alert-description]:astw:text-yellow-500/80", + "astw:bg-[color:var(--alert-warning-background)] astw:text-[color:var(--alert-warning-foreground)] astw:border-[color:var(--alert-warning-border)] *:data-[slot=alert-description]:astw:text-[color:var(--alert-warning-foreground-muted)]", error: - "astw:bg-destructive/10 astw:text-destructive astw:border-destructive/20 *:data-[slot=alert-description]:astw:text-destructive/80", - info: "astw:bg-blue-500/10 astw:text-blue-700 astw:border-blue-500/20 dark:astw:text-blue-400 *:data-[slot=alert-description]:astw:text-blue-700/80 dark:*:data-[slot=alert-description]:astw:text-blue-400/80", + "astw:bg-[color:var(--alert-error-background)] astw:text-[color:var(--alert-error-foreground)] astw:border-[color:var(--alert-error-border)] *:data-[slot=alert-description]:astw:text-[color:var(--alert-error-foreground-muted)]", + info: "astw:bg-[color:var(--alert-info-background)] astw:text-[color:var(--alert-info-foreground)] astw:border-[color:var(--alert-info-border)] *:data-[slot=alert-description]:astw:text-[color:var(--alert-info-foreground-muted)]", }, }, defaultVariants: { diff --git a/packages/core/src/components/appearance-switcher.test.tsx b/packages/core/src/components/appearance-switcher.test.tsx new file mode 100644 index 00000000..477a5202 --- /dev/null +++ b/packages/core/src/components/appearance-switcher.test.tsx @@ -0,0 +1,150 @@ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import { COLOR_THEME_OPTIONS, ThemeProvider } from "@/contexts/theme-context"; +import { createAppShellWrapper } from "../../tests/test-utils"; +import { AppearanceSwitcher } from "./appearance-switcher"; + +/** happy-dom / Node can omit a full `localStorage`; ThemeProvider persists via it. */ +function installLocalStorageStub() { + const map = new Map(); + const ls = { + getItem: (k: string) => map.get(k) ?? null, + setItem: (k: string, v: string) => { + map.set(k, v); + }, + removeItem: (k: string) => { + map.delete(k); + }, + clear: () => map.clear(), + key: (i: number) => [...map.keys()][i] ?? null, + get length() { + return map.size; + }, + }; + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: ls, + }); + return map; +} + +function installMatchMediaStub(matches: boolean) { + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +let storageMap: Map; + +beforeAll(() => { + storageMap = installLocalStorageStub(); +}); + +beforeEach(() => { + storageMap.clear(); + installMatchMediaStub(false); + document.documentElement.removeAttribute("data-theme"); + document.documentElement.classList.remove("light", "dark"); +}); + +afterEach(() => { + cleanup(); +}); + +const AppShellWrapper = createAppShellWrapper("en"); + +describe("AppearanceSwitcher", () => { + it("opens a menu listing every mode option", async () => { + const user = userEvent.setup(); + + render( + + + + + , + ); + + await user.click(screen.getByRole("button", { name: "Appearance" })); + + await waitFor(() => { + expect(screen.getAllByRole("menuitemradio").length).toBe(COLOR_THEME_OPTIONS.length); + }); + + for (const opt of COLOR_THEME_OPTIONS) { + expect(screen.getByRole("menuitemradio", { name: opt.label })).toBeDefined(); + } + }); + + it("does not list color palettes (palette is a developer config)", async () => { + const user = userEvent.setup(); + + render( + + + + + , + ); + + await user.click(screen.getByRole("button", { name: "Appearance" })); + + await waitFor(() => { + expect(screen.getByRole("menu")).toBeDefined(); + }); + + expect(screen.queryByRole("menuitemradio", { name: "Cream" })).toBeNull(); + expect(screen.queryByRole("menuitemradio", { name: "Bloom" })).toBeNull(); + }); + + it("exposes the resolved mode on the trigger when system mode is selected", () => { + render( + + + + + , + ); + + const btn = screen.getByRole("button", { name: "Appearance" }); + expect(btn.getAttribute("title")).toMatch(/following system/i); + expect(btn.getAttribute("title")).toMatch(/currently light|currently dark/i); + }); + + it("applies the selected mode when a radio item is activated", async () => { + const user = userEvent.setup(); + + render( + + + + + , + ); + + await user.click(screen.getByRole("button", { name: "Appearance" })); + + await waitFor(() => { + expect(screen.getByRole("menu")).toBeDefined(); + }); + + await user.click(screen.getByRole("menuitemradio", { name: "Dark" })); + + await waitFor(() => { + expect(document.documentElement.classList.contains("dark")).toBe(true); + }); + expect(localStorage.getItem("appshell-ui-theme")).toBe("dark"); + }); +}); diff --git a/packages/core/src/components/appearance-switcher.tsx b/packages/core/src/components/appearance-switcher.tsx new file mode 100644 index 00000000..4301ba7c --- /dev/null +++ b/packages/core/src/components/appearance-switcher.tsx @@ -0,0 +1,130 @@ +import { Monitor, Moon, Palette, Sun } from "lucide-react"; + +import { Menu } from "@/components/menu"; +import { Button } from "@/components/button"; +import { cn } from "@/lib/utils"; +import { defineI18nLabels } from "@/hooks/i18n"; +import { useTheme, type ColorTheme, COLOR_THEME_OPTIONS } from "@/contexts/theme-context"; + +const appearanceSwitcherLabels = defineI18nLabels({ + en: { + appearance: "Appearance", + chooseAppearance: "Choose appearance", + followingSystem: (props: { resolved: string }) => + `Following system — currently ${props.resolved}`, + light: "Light", + dark: "Dark", + system: "System", + }, + ja: { + appearance: "外観", + chooseAppearance: "外観を選択", + followingSystem: (props: { resolved: string }) => `システムに従う — 現在${props.resolved}`, + light: "ライト", + dark: "ダーク", + system: "システム", + }, +}); +const useT = appearanceSwitcherLabels.useT; + +const COLOR_THEME_ICON: Record = { + light: Sun, + dark: Moon, + system: Monitor, +}; + +function isColorTheme(value: string): value is ColorTheme { + return COLOR_THEME_OPTIONS.some((o) => o.value === value); +} + +/** Shared radio-item chrome — used by both the mode 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 ColorThemeIcon({ colorTheme }: { colorTheme: ColorTheme }) { + const Icon = COLOR_THEME_ICON[colorTheme]; + return ( +
+ +
+ ); +} + +/** + * Appearance menu. The end-user controls **color theme** (light / dark / system); + * the color palette/brand is a developer configuration, so it is not shown here. + */ +function AppearanceSwitcher() { + const { theme, resolvedTheme, setTheme } = useTheme(); + const t = useT(); + + const triggerTitle = + theme === "system" + ? t("followingSystem", { resolved: t(resolvedTheme) }) + : t("chooseAppearance"); + + return ( + + + } + > + + + + + + {t("appearance")} + + { + if (typeof value === "string" && isColorTheme(value)) setTheme(value); + }} + > + {COLOR_THEME_OPTIONS.map((opt) => ( + + + {t(opt.value)} + + + + {t(opt.value)} + + + ))} + + + + + ); +} + +export { AppearanceSwitcher }; diff --git a/packages/core/src/components/appshell.tsx b/packages/core/src/components/appshell.tsx index bf4d8376..80759cbc 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 ColorTheme } 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,16 @@ type SharedAppShellProps = React.PropsWithChildren<{ * ``` */ searchSources?: readonly SearchSource[]; + + /** + * Initial color mode before any value is loaded from localStorage (`appshell-ui-theme`). + * This is the end-user accessibility preference; does not replace a stored preference. + * + * One of **`light`**, **`dark`**, or **`system`** (follows the OS). + * + * @default "system" + */ + defaultColorTheme?: ColorTheme; }>; /** @@ -320,7 +330,7 @@ export const AppShell = (props: AppShellProps) => { - + {props.children} diff --git a/packages/core/src/components/sidebar/sidebar-layout.tsx b/packages/core/src/components/sidebar/sidebar-layout.tsx index 30961c25..7b2c9704 100644 --- a/packages/core/src/components/sidebar/sidebar-layout.tsx +++ b/packages/core/src/components/sidebar/sidebar-layout.tsx @@ -1,8 +1,6 @@ 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 } from "@/contexts/theme-context"; +import { AppearanceSwitcher } from "@/components/appearance-switcher"; import { DefaultSidebar } from "./default-sidebar"; import { DynamicBreadcrumb } from "@/components/dynamic-breadcrumb"; @@ -68,10 +66,6 @@ const HidableSidebarTrigger = () => { export const SidebarLayout = (props: SidebarLayoutProps) => { const Children = props.children ? props.children({ Outlet: AppShellOutlet }) : null; - const themeContext = useTheme(); - const toggleTheme = () => { - themeContext.setTheme(themeContext.theme === "dark" ? "light" : "dark"); - }; return ( {
- +
diff --git a/packages/core/src/components/sonner.tsx b/packages/core/src/components/sonner.tsx index 543817b3..ca659c14 100644 --- a/packages/core/src/components/sonner.tsx +++ b/packages/core/src/components/sonner.tsx @@ -2,11 +2,11 @@ import { useTheme } from "@/contexts/theme-context"; import { Toaster as Sonner, ToasterProps } from "sonner"; const Toaster = ({ ...props }: ToasterProps) => { - const { theme } = useTheme(); + const { resolvedTheme } = useTheme(); return ( (); + 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; +} + +type MatchMediaListener = (e: MediaQueryListEvent) => void; +let matchMediaListeners: MatchMediaListener[] = []; + +/** `matchMedia` is not implemented in some test runtimes — stub to a controllable shape. */ +function installMatchMediaStub(matches: boolean) { + matchMediaListeners = []; + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: (_: string, fn: MatchMediaListener) => { + matchMediaListeners.push(fn); + }, + removeEventListener: (_: string, fn: MatchMediaListener) => { + matchMediaListeners = matchMediaListeners.filter((l) => l !== fn); + }, + dispatchEvent: vi.fn(), + })), + }); +} + +let storageMap: Map; + +beforeAll(() => { + storageMap = installLocalStorageStub(); +}); + +beforeEach(() => { + storageMap.clear(); + matchMediaListeners = []; + installMatchMediaStub(false); + document.documentElement.classList.remove("light", "dark"); +}); + +afterEach(() => { + cleanup(); +}); + +function Probe() { + const { theme, resolvedTheme } = useTheme(); + return ( +
+ {theme} + {resolvedTheme} +
+ ); +} + +describe("ThemeProvider — storage validation", () => { + it("falls back to defaultColorTheme for an unrecognized stored color theme", () => { + storageMap.set("appshell-ui-theme", "totally-not-a-mode"); + + const { getByTestId } = render( + + + , + ); + + expect(getByTestId("color-theme").textContent).toBe("dark"); + }); + + it("useTheme returns color theme values", () => { + storageMap.set("appshell-ui-theme", "dark"); + + const { getByTestId } = render( + + + , + ); + + expect(getByTestId("color-theme").textContent).toBe("dark"); + expect(getByTestId("resolvedTheme").textContent).toBe("dark"); + }); + + it("reads a valid stored color theme and applies it as the html class", async () => { + storageMap.set("appshell-ui-theme", "dark"); + + const { getByTestId } = render( + + + , + ); + + expect(getByTestId("color-theme").textContent).toBe("dark"); + await waitFor(() => { + expect(document.documentElement.classList.contains("dark")).toBe(true); + }); + }); +}); + +describe("ThemeProvider — system color theme resolution", () => { + it("resolves system → dark when prefers-color-scheme: dark matches", async () => { + installMatchMediaStub(true); + storageMap.set("appshell-ui-theme", "system"); + + const { getByTestId } = render( + + + , + ); + + expect(getByTestId("color-theme").textContent).toBe("system"); + expect(getByTestId("resolvedTheme").textContent).toBe("dark"); + await waitFor(() => { + expect(document.documentElement.classList.contains("dark")).toBe(true); + }); + }); + + it("resolves system → light when prefers-color-scheme: dark does not match", async () => { + installMatchMediaStub(false); + storageMap.set("appshell-ui-theme", "system"); + + const { getByTestId } = render( + + + , + ); + + expect(getByTestId("resolvedTheme").textContent).toBe("light"); + await waitFor(() => { + expect(document.documentElement.classList.contains("light")).toBe(true); + }); + }); + + it("reacts to OS color-scheme change while color theme is system", async () => { + installMatchMediaStub(false); + storageMap.set("appshell-ui-theme", "system"); + + const { getByTestId } = render( + + + , + ); + + expect(getByTestId("resolvedTheme").textContent).toBe("light"); + + // Simulate OS dark mode toggle + act(() => { + for (const listener of matchMediaListeners) { + listener({ matches: true } as MediaQueryListEvent); + } + }); + + expect(getByTestId("resolvedTheme").textContent).toBe("dark"); + await waitFor(() => { + expect(document.documentElement.classList.contains("dark")).toBe(true); + }); + }); +}); + +describe("provider guards", () => { + it("throws when useTheme is called outside ThemeProvider", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const TP = () => { + useTheme(); + return null; + }; + expect(() => render()).toThrow(/useTheme must be used within a ThemeProvider/); + spy.mockRestore(); + }); +}); diff --git a/packages/core/src/contexts/theme-context.tsx b/packages/core/src/contexts/theme-context.tsx index 77f62dc6..371d7b79 100644 --- a/packages/core/src/contexts/theme-context.tsx +++ b/packages/core/src/contexts/theme-context.tsx @@ -1,68 +1,138 @@ -import { createContext, useContext, useEffect, useMemo, useState } from "react"; +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; -type Theme = "dark" | "light" | "system"; +/** + * Color theme — the end-user accessibility preference. `system` follows the OS + * light/dark setting. Applied to `` as the `.light` / `.dark` class. + */ +export type ColorTheme = "light" | "dark" | "system"; + +/** Color theme after resolving `system` to a concrete value. */ +export type ResolvedColorTheme = "light" | "dark"; + +const ALL_COLOR_THEMES: readonly ColorTheme[] = ["light", "dark", "system"] as const; + +/** Switcher entries for the appearance (color theme) control. */ +export type ColorThemeOption = { + readonly value: ColorTheme; + readonly label: string; +}; + +export const COLOR_THEME_OPTIONS: readonly ColorThemeOption[] = [ + { value: "light", label: "Light" }, + { value: "dark", label: "Dark" }, + { value: "system", label: "System" }, +] as const; + +// This key was chosen in the first release and persisted to users' localStorage. +// Changing it would silently discard every existing user's preference — do not rename. +const COLOR_THEME_STORAGE_KEY = "appshell-ui-theme"; + +function readStored(key: string, allowList: readonly T[], fallback: T): T { + if (typeof window === "undefined") return fallback; + try { + const v = localStorage.getItem(key); + return v && (allowList as readonly string[]).includes(v) ? (v as T) : fallback; + } catch { + return fallback; + } +} type ThemeProviderProps = { children: React.ReactNode; - defaultTheme?: Theme; - storageKey: string; + /** Initial color theme (user preference). @default "system" */ + defaultColorTheme?: ColorTheme; }; type ThemeProviderState = { - theme: Theme; - resolvedTheme: Omit; - setTheme: (theme: Theme) => void; + colorTheme: ColorTheme; + resolvedColorTheme: ResolvedColorTheme; + setColorTheme: (colorTheme: ColorTheme) => void; }; -const initialState: ThemeProviderState = { - resolvedTheme: "light", - theme: "system", - setTheme: () => null, -}; +function resolveColorTheme(m: ColorTheme, dark: boolean): ResolvedColorTheme { + if (m !== "system") return m; + return dark ? "dark" : "light"; +} -const ThemeProviderContext = createContext(initialState); +function getSystemDark(): boolean { + return typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches; +} -export function ThemeProvider({ - children, - storageKey, - defaultTheme = "system", - ...props -}: ThemeProviderProps) { - const [theme, setTheme] = useState( - () => (localStorage.getItem(storageKey) as Theme) || defaultTheme, +const ThemeProviderContext = createContext(undefined); + +export function ThemeProvider({ children, defaultColorTheme = "system" }: ThemeProviderProps) { + const [colorTheme, setColorThemeState] = useState(() => + readStored(COLOR_THEME_STORAGE_KEY, ALL_COLOR_THEMES, defaultColorTheme), ); - const resolvedTheme = useMemo(() => { - if (theme !== "system") return theme; - return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; - }, [theme]); + const [resolvedColorTheme, setResolvedColorTheme] = useState(() => + resolveColorTheme( + readStored(COLOR_THEME_STORAGE_KEY, ALL_COLOR_THEMES, defaultColorTheme), + getSystemDark(), + ), + ); - useEffect(() => { + const applyColorTheme = useCallback((resolved: ResolvedColorTheme) => { const root = window.document.documentElement; root.classList.remove("light", "dark"); - root.classList.add(resolvedTheme); - }, [resolvedTheme]); - - const value = { - resolvedTheme, - theme, - setTheme: (newTheme: Theme) => { - localStorage.setItem(storageKey, newTheme); - setTheme(newTheme); + root.classList.add(resolved); + }, []); + + // Apply the resolved color theme to DOM and subscribe to OS color-scheme changes. + // Kept as a single effect for simplicity — the listener re-registration cost + // on OS theme change is negligible (microseconds) given it's a rare user action. + useEffect(() => { + applyColorTheme(resolvedColorTheme); + const mql = window.matchMedia("(prefers-color-scheme: dark)"); + const handler = (e: MediaQueryListEvent) => { + const next = resolveColorTheme(colorTheme, e.matches); + setResolvedColorTheme(next); + }; + mql.addEventListener("change", handler); + return () => mql.removeEventListener("change", handler); + }, [colorTheme, resolvedColorTheme, applyColorTheme]); + + const setColorTheme = useCallback( + (next: ColorTheme) => { + try { + localStorage.setItem(COLOR_THEME_STORAGE_KEY, next); + } catch { + /* storage full or forbidden */ + } + setColorThemeState(next); + const resolved = resolveColorTheme(next, getSystemDark()); + setResolvedColorTheme(resolved); + applyColorTheme(resolved); }, - }; + [applyColorTheme], + ); - return ( - - {children} - + const value = useMemo( + () => ({ + colorTheme, + resolvedColorTheme, + setColorTheme, + }), + [colorTheme, resolvedColorTheme, setColorTheme], ); + + return {children}; } +/** Color theme hook — returns the current color theme and a setter. */ export const useTheme = () => { const context = useContext(ThemeProviderContext); + if (context === undefined) { + throw new Error("useTheme must be used within a ThemeProvider"); + } - if (context === undefined) throw new Error("useTheme must be used within a ThemeProvider"); - - return context; + const { colorTheme, resolvedColorTheme, setColorTheme } = context; + return useMemo( + () => ({ + theme: colorTheme, + resolvedTheme: resolvedColorTheme, + setTheme: setColorTheme, + }), + [colorTheme, resolvedColorTheme, setColorTheme], + ); }; diff --git a/packages/core/src/globals.css b/packages/core/src/globals.css index f55ebe89..ddef2c57 100644 --- a/packages/core/src/globals.css +++ b/packages/core/src/globals.css @@ -34,6 +34,7 @@ body { @apply astw:font-sans astw:antialiased astw:bg-background astw:text-foreground; } + ::-webkit-scrollbar { @apply astw:w-2 astw:h-2 astw:bg-muted; } @@ -43,4 +44,35 @@ ::-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; + } +} + +@layer utilities { + /* + * 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 9da325fa..576fe5d0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -26,7 +26,8 @@ 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 ColorTheme, type ResolvedColorTheme } from "./contexts/theme-context"; +export { AppearanceSwitcher } from "./components/appearance-switcher"; export { type I18nLabels, defineI18nLabels } from "./hooks/i18n"; export { AuthProvider,