diff --git a/.changeset/tailor-theme-palettes.md b/.changeset/tailor-theme-palettes.md new file mode 100644 index 00000000..68489005 --- /dev/null +++ b/.changeset/tailor-theme-palettes.md @@ -0,0 +1,74 @@ +--- +"@tailor-platform/app-shell": minor +--- + +Introduce the **Tailor** brand appearance: two new color palettes, an independent font axis, a ready-made `ThemeSwitcher`, and a pre-paint helper to avoid FOUC. + +### Color palettes — `cream`, `bloom` + +`useTheme` / `ThemeProvider` now accept `light`, `dark`, `cream`, `bloom`, and `system`. The document root sets `data-theme` to the resolved palette and keeps `class="light"` or `class="dark"` for Tailwind's `dark:` variant. `cream` and `bloom` paint a fixed vertical shell gradient on `` (light tint at top → white at bottom) and use squircle corners where the browser supports `corner-shape`. + +`bloom` is the new default when no `defaultTheme` is provided. Existing apps with `tailor-light` / `tailor-bloom` / `tailor-dark` stored in `localStorage` are migrated on read to `cream` / `bloom` / `dark`. + +### Independent font axis — `geist`, `inter` + +A second appearance axis, separate from the color theme. Applied to `` as `data-font`, persisted to `localStorage` under `appshell-ui-font`. Set via `useFont` (`setFont("inter")`) or `AppShell`'s `defaultFont` prop (default `"geist"`). + +AppShell no longer fetches fonts at runtime. Pick a loading strategy: + +```css +/* (a) zero-config: bundled Geist + Inter variable fonts */ +@import "@tailor-platform/app-shell/styles"; +@import "@tailor-platform/app-shell/fonts"; +``` + +```tsx +// (b) next/font/google — the new family fallback chain ("Geist Variable", "Geist Sans", …) +// catches Next's registered family automatically +import { Geist, Inter } from "next/font/google"; +``` + +### `ThemeSwitcher` + `SidebarLayout.themeSwitcher` + +New `ThemeSwitcher` (exported from `@tailor-platform/app-shell` and `@tailor-platform/app-shell/sidebar`) provides a two-axis appearance menu (color grid + font grid). `SidebarLayout` now mounts it in the header by default, replacing the old `SunIcon` light/dark toggle. Override or hide via the new `themeSwitcher` prop: + +```tsx + {/* hide */} +}> {/* replace */} +``` + +### Pre-paint script — `getInitialAppearanceScript()` + +`ThemeProvider` writes `data-theme` / `data-font` from a post-mount effect. To avoid FOUC and React hydration warnings on SSR'd apps, inline the new helper in ``: + +```tsx +// app/layout.tsx +import { getInitialAppearanceScript } from "@tailor-platform/app-shell"; + + + + + + {children} + ); } ``` -## Theme Persistence +The script reads **`localStorage`**, runs the same legacy-id migration as the provider, resolves **`system`** via **`matchMedia`**, and sets **`data-theme`**, **`data-font`**, and **`class="light"|"dark"`** on **``**. Safe to call without arguments; pass overrides for non-default storage keys / defaults. + +```ts +getInitialAppearanceScript({ + storageKey: "my-app-theme", + fontStorageKey: "my-app-font", + defaultTheme: "cream", + defaultFont: "inter", +}); +``` -Theme preference is automatically saved to localStorage and restored on page load. +For Vite / static HTML apps, paste the returned string into a **` + {children} ); 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/examples/vite-app/src/index.css b/examples/vite-app/src/index.css index c280a9f7..1df94d6e 100644 --- a/examples/vite-app/src/index.css +++ b/examples/vite-app/src/index.css @@ -1,5 +1,10 @@ +/* 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. + */ @import "tailwindcss"; @import "@tailor-platform/app-shell/styles"; +@import "@tailor-platform/app-shell/fonts"; html, body { 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..6486fc22 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..7013deba 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..4aa5dc49 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,9 +16,9 @@ exports[`Button > snapshots > large size 1`] = `""`; -exports[`Button > snapshots > outline variant 1`] = `""`; +exports[`Button > snapshots > outline 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/__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..0e82ba44 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__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__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..ecb9457e 100644 --- a/packages/core/__snapshots__/src__components__select.test.tsx.snap +++ b/packages/core/__snapshots__/src__components__select.test.tsx.snap @@ -1,11 +1,11 @@ // 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/__snapshots__/src__components__table.test.tsx.snap b/packages/core/__snapshots__/src__components__table.test.tsx.snap index ba7e659e..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
"`; diff --git a/packages/core/package.json b/packages/core/package.json index c21ce67b..bcfdc7af 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -28,10 +28,15 @@ "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" }, + "./initial-appearance": { + "types": "./dist/initial-appearance.d.ts", + "default": "./dist/initial-appearance.js" + }, "./vite-plugin": { "types": "./dist/vite-plugin.d.ts", "default": "./dist/vite-plugin.js" @@ -49,6 +54,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/assets/theme.css b/packages/core/src/assets/theme.css index a594613b..4bf9563c 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 { @@ -62,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); @@ -70,6 +82,126 @@ --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="cream"] { + color-scheme: light; + /* Off-white app shell; white cards for elevation */ + --background: rgba(248, 243, 228, 1); + /* 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); + --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; near-black text (match bloom; drop the brand dark-green pairing). */ + --secondary: rgba(226, 212, 254, 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(16, 18, 43, 1); + /* Destructive matches default light theme so the brand red stays consistent across light/cream/bloom. */ + --destructive: rgba(220, 38, 38, 1); + --destructive-foreground: rgba(254, 242, 242, 1); + --border: rgba(0, 0, 0, 0.08); + --input: rgba(0, 0, 0, 0.08); + --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(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); + --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); +} + +/** + * Bloom — light lavender app shell; cream secondary/accent surfaces (inverse of Tailor light). + */ +html[data-theme="bloom"] { + color-scheme: light; + --background: rgba(239, 232, 255, 1); + /* 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); + --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); + /* Match light theme — neutral pale grey reads as a soft pill on the lavender shell. */ + --secondary: rgba(245, 245, 245, 1); + --secondary-foreground: rgba(23, 23, 23, 1); + /* Row hovers / `bg-muted`: subtle neutral lift (same alpha family as `--border`). */ + --muted: rgba(0, 0, 0, 0.08); + --muted-foreground: rgba(16, 18, 43, 0.72); + --accent: rgba(248, 243, 228, 1); + --accent-foreground: rgba(16, 18, 43, 1); + /* 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); + --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); } @theme inline { @@ -110,10 +242,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..c9c26c69 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, 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"; @@ -177,6 +177,25 @@ 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. + * + * 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 "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; }>; /** @@ -320,7 +339,12 @@ export const AppShell = (props: AppShellProps) => { - + {props.children} 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({ {children} - + ); diff --git a/packages/core/src/components/select.tsx b/packages/core/src/components/select.tsx index c47a94a1..92b68aba 100644 --- a/packages/core/src/components/select.tsx +++ b/packages/core/src/components/select.tsx @@ -37,7 +37,7 @@ function SelectTrigger({ ; 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/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-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 30961c25..b80146e5 100644 --- a/packages/core/src/components/sidebar/sidebar-layout.tsx +++ b/packages/core/src/components/sidebar/sidebar-layout.tsx @@ -1,12 +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 } from "@/contexts/theme-context"; +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. * @@ -23,7 +31,7 @@ export type SidebarLayoutProps = { *
* ``` */ - children?: (props: { Outlet: () => React.ReactNode }) => React.ReactNode; + children?: (props: { Outlet: () => ReactNode }) => ReactNode; /** * Custom sidebar content. @@ -68,10 +76,7 @@ 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"); - }; + 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 and font option", async () => { + const user = userEvent.setup(); + + render( + + + , + ); + + await user.click(screen.getByRole("button", { name: "Appearance" })); + + await waitFor(() => { + expect(screen.getAllByRole("menuitemradio").length).toBe( + THEME_OPTIONS.length + FONT_OPTIONS.length, + ); + }); + + for (const opt of THEME_OPTIONS) { + expect(screen.getByRole("menuitemradio", { name: opt.label })).toBeDefined(); + } + for (const opt of FONT_OPTIONS) { + expect(screen.getByRole("menuitemradio", { name: opt.label })).toBeDefined(); + } + }); + + it("exposes resolved palette on the trigger when system mode is selected", () => { + render( + + + , + ); + + const btn = screen.getByRole("button", { name: "Appearance" }); + expect(btn.getAttribute("title")).toMatch(/following system/i); + expect(btn.getAttribute("title")).toMatch(/currently light|currently dark/i); + }); + + it("applies selected palette when a radio item is activated", async () => { + const user = userEvent.setup(); + + render( + + + , + ); + + await user.click(screen.getByRole("button", { name: "Appearance" })); + + await waitFor(() => { + expect(screen.getByRole("menu")).toBeDefined(); + }); + + await user.click(screen.getByRole("menuitemradio", { name: "Bloom" })); + + await waitFor(() => { + expect(document.documentElement.dataset.theme).toBe("bloom"); + }); + expect(localStorage.getItem(storageKey)).toBe("bloom"); + }); + + it("applies selected font when a font radio item is activated", async () => { + const user = userEvent.setup(); + + render( + + + , + ); + + await user.click(screen.getByRole("button", { name: "Appearance" })); + + await waitFor(() => { + expect(screen.getByRole("menu")).toBeDefined(); + }); + + await user.click(screen.getByRole("menuitemradio", { name: "Inter" })); + + await waitFor(() => { + expect(document.documentElement.dataset.font).toBe("inter"); + }); + expect(localStorage.getItem(fontStorageKey)).toBe("inter"); + }); +}); diff --git a/packages/core/src/components/theme-switcher.tsx b/packages/core/src/components/theme-switcher.tsx new file mode 100644 index 00000000..c0103d15 --- /dev/null +++ b/packages/core/src/components/theme-switcher.tsx @@ -0,0 +1,190 @@ +import { Palette } from "lucide-react"; + +import { Menu } from "@/components/menu"; +import { Button } from "@/components/button"; +import { cn } from "@/lib/utils"; +import { + useTheme, + useFont, + type ResolvedTheme, + type Theme, + type Font, + THEME_OPTIONS, + FONT_OPTIONS, +} from "@/contexts/theme-context"; + +const RESOLVED_THEME_SHORT: Record = { + light: "Light", + dark: "Dark", + cream: "Cream", + bloom: "Bloom", +}; + +/** + * Decorative dual swatches — approximates each palette pair (accent + neutral) for the picker grid. + * Kept as static hex previews so the swatches render even before a theme stylesheet has loaded; + * keep these in sync with the palette tokens in `assets/theme.css`. + */ +const THEME_PREVIEW: Record = { + light: { a: "#ffffff", b: "#d4d4d8" }, + dark: { a: "#3f3f46", b: "#d4d4d8" }, + cream: { a: "#f8f3e4", b: "#e2d4fe" }, + bloom: { a: "#535ae8", b: "#f8f3e4" }, + system: { a: "#52525b", b: "#7c73e6" }, +}; + +/** Font preview — `font-family` for the "Aa" sample so users see the face before selecting. + * Mirrors the chain in `globals.css` (variable build first, then static family fallback). */ +const FONT_PREVIEW: Record = { + geist: '"Geist Variable", "Geist Sans", ui-sans-serif, system-ui, sans-serif', + inter: '"Inter Variable", "Inter", ui-sans-serif, system-ui, sans-serif', +}; + +function isTheme(value: string): value is Theme { + return THEME_OPTIONS.some((o) => o.value === value); +} + +function isFont(value: string): value is Font { + return FONT_OPTIONS.some((o) => o.value === value); +} + +/** Shared radio-item chrome — used by both the color and font grids. */ +function radioItemClasses(active: boolean) { + return cn( + "astw:relative astw:flex astw:h-auto astw:w-full astw:cursor-default astw:select-none astw:flex-col astw:items-center astw:justify-center astw:gap-1.5 astw:rounded-xl astw:border-0 astw:bg-transparent astw:px-2 astw:py-2 astw:text-center astw:text-xs astw:font-medium astw:leading-tight astw:outline-hidden", + "astw:data-highlighted:bg-muted/80 astw:data-highlighted:text-foreground", + "astw:data-disabled:pointer-events-none astw:data-disabled:opacity-50", + "[&_[data-slot=menu-radio-item-indicator]]:astw:hidden", + active && + "astw:bg-primary/12 astw:ring-1 astw:ring-primary/25 astw:data-highlighted:bg-primary/[0.14]", + ); +} + +function ThemePreviewSwatches({ themeId }: { themeId: Theme }) { + const { a, b } = THEME_PREVIEW[themeId]; + return ( +
+ + +
+ ); +} + +function FontPreview({ fontId }: { fontId: Font }) { + return ( +
+ + Aa + +
+ ); +} + +/** + * Appearance menu: two independent axes — color palette (top) and font (bottom). + * Bound to stored `theme` and `font`; **System** stays explicit on the color axis. + */ +function ThemeSwitcher() { + const { theme, resolvedTheme, setTheme } = useTheme(); + const { font, setFont } = useFont(); + + const fontLabel = FONT_OPTIONS.find((o) => o.value === font)?.label ?? font; + const triggerTitle = + theme === "system" + ? `Following system — currently ${RESOLVED_THEME_SHORT[resolvedTheme]} · ${fontLabel}` + : "Choose appearance — color + font"; + + return ( + + + } + > + + + + + Colors + { + if (typeof value === "string" && isTheme(value)) setTheme(value); + }} + > + {THEME_OPTIONS.map((opt) => ( + + + {opt.label} + + + + {opt.label} + + + ))} + + + + + Font + { + if (typeof value === "string" && isFont(value)) setFont(value); + }} + > + {FONT_OPTIONS.map((opt) => ( + + + {opt.label} + + + + {opt.label} + + + ))} + + + + + ); +} + +export { ThemeSwitcher }; diff --git a/packages/core/src/contexts/theme-context.test.tsx b/packages/core/src/contexts/theme-context.test.tsx new file mode 100644 index 00000000..9d5c0af4 --- /dev/null +++ b/packages/core/src/contexts/theme-context.test.tsx @@ -0,0 +1,174 @@ +import { cleanup, render, waitFor } from "@testing-library/react"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ThemeProvider, useTheme, useFont } from "./theme-context"; + +const storageKey = "theme-context-test-theme"; +const fontStorageKey = "theme-context-test-font"; + +/** happy-dom / Node can omit a full `localStorage`; ThemeProvider persists via it. */ +function installLocalStorageStub() { + const map = new Map(); + const ls = { + getItem: (k: string) => map.get(k) ?? null, + setItem: (k: string, v: string) => { + map.set(k, v); + }, + removeItem: (k: string) => { + map.delete(k); + }, + clear: () => map.clear(), + key: (i: number) => [...map.keys()][i] ?? null, + get length() { + return map.size; + }, + }; + Object.defineProperty(globalThis, "localStorage", { configurable: true, value: ls }); + return map; +} + +/** `matchMedia` is not implemented in some test runtimes — stub to a controllable shape. */ +function installMatchMediaStub(matches: boolean) { + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +let storageMap: Map; + +beforeAll(() => { + storageMap = installLocalStorageStub(); +}); + +beforeEach(() => { + storageMap.clear(); + document.documentElement.removeAttribute("data-theme"); + document.documentElement.removeAttribute("data-font"); + document.documentElement.classList.remove("light", "dark"); +}); + +afterEach(() => { + cleanup(); +}); + +function ThemeProbe() { + const { theme, resolvedTheme } = useTheme(); + return ( +
+ {theme} + {resolvedTheme} +
+ ); +} + +function FontProbe() { + const { font } = useFont(); + return {font}; +} + +describe("ThemeProvider — legacy id migration", () => { + it.each([ + ["tailor-light", "cream"], + ["tailor-bloom", "bloom"], + ["tailor-dark", "dark"], + ])("maps stored %s → %s on first render", async (stored, expected) => { + storageMap.set(storageKey, stored); + + const { getByTestId } = render( + + + , + ); + + expect(getByTestId("theme").textContent).toBe(expected); + await waitFor(() => { + expect(document.documentElement.dataset.theme).toBe(expected); + }); + }); + + it("falls back to defaultTheme for an unrecognized stored value", () => { + storageMap.set(storageKey, "totally-not-a-theme"); + + const { getByTestId } = render( + + + , + ); + + expect(getByTestId("theme").textContent).toBe("light"); + }); + + it("falls back to defaultFont for an unrecognized stored font", () => { + storageMap.set(fontStorageKey, "wingdings"); + + const { getByTestId } = render( + + + , + ); + + expect(getByTestId("font").textContent).toBe("inter"); + }); +}); + +describe("ThemeProvider — system resolution", () => { + it("resolves system → dark when prefers-color-scheme: dark matches", async () => { + installMatchMediaStub(true); + storageMap.set(storageKey, "system"); + + const { getByTestId } = render( + + + , + ); + + expect(getByTestId("theme").textContent).toBe("system"); + expect(getByTestId("resolved").textContent).toBe("dark"); + await waitFor(() => { + expect(document.documentElement.dataset.theme).toBe("dark"); + expect(document.documentElement.classList.contains("dark")).toBe(true); + }); + }); + + it("resolves system → light when prefers-color-scheme: dark does not match", async () => { + installMatchMediaStub(false); + storageMap.set(storageKey, "system"); + + const { getByTestId } = render( + + + , + ); + + expect(getByTestId("resolved").textContent).toBe("light"); + await waitFor(() => { + expect(document.documentElement.dataset.theme).toBe("light"); + expect(document.documentElement.classList.contains("light")).toBe(true); + }); + }); +}); + +describe("useTheme / useFont — provider guard", () => { + it("throws when useTheme is called outside ThemeProvider", () => { + // Silence React's expected error log for this assertion. + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(() => render()).toThrow(/useTheme must be used within a ThemeProvider/); + spy.mockRestore(); + }); + + it("throws when useFont is called outside ThemeProvider", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(() => render()).toThrow(/useFont must be used within a ThemeProvider/); + spy.mockRestore(); + }); +}); diff --git a/packages/core/src/contexts/theme-context.tsx b/packages/core/src/contexts/theme-context.tsx index 77f62dc6..82237b63 100644 --- a/packages/core/src/contexts/theme-context.tsx +++ b/packages/core/src/contexts/theme-context.tsx @@ -1,68 +1,171 @@ -import { createContext, useContext, useEffect, useMemo, useState } from "react"; +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; -type Theme = "dark" | "light" | "system"; +/** User-selectable theme. `system` follows OS light/dark (default palettes only — not cream/bloom). */ +export type Theme = "light" | "dark" | "cream" | "bloom" | "system"; + +/** Resolved paint after applying `system`. */ +export type ResolvedTheme = "light" | "dark" | "cream" | "bloom"; + +const ALL_THEMES: readonly Theme[] = ["light", "dark", "cream", "bloom", "system"] as const; + +/** Dropdown / switcher entries: order matches selectable themes; labels are user-facing. */ +export type ThemeOption = { readonly value: Theme; readonly label: string }; + +export const THEME_OPTIONS: readonly ThemeOption[] = [ + { value: "bloom", label: "Bloom" }, + { value: "light", label: "Light" }, + { value: "dark", label: "Dark" }, + { value: "cream", label: "Cream" }, + { value: "system", label: "System" }, +] as const; + +/** Font axis — independent of color theme. Applied to `` as `data-font`. */ +export type Font = "geist" | "inter"; + +const ALL_FONTS: readonly Font[] = ["geist", "inter"] as const; + +export type FontOption = { readonly value: Font; readonly label: string }; + +export const FONT_OPTIONS: readonly FontOption[] = [ + { value: "geist", label: "Geist" }, + { value: "inter", label: "Inter" }, +] as const; + +/** Migrate stored values from legacy `tailor-*` ids before the public rename. */ +const LEGACY_THEME_IDS: Partial> = { + "tailor-light": "cream", + "tailor-bloom": "bloom", + "tailor-dark": "dark", +}; + +function parseStoredTheme(value: string | null, fallback: Theme): Theme { + if (!value) return fallback; + const legacy = LEGACY_THEME_IDS[value]; + if (legacy) return legacy; + if ((ALL_THEMES as readonly string[]).includes(value)) return value as Theme; + return fallback; +} + +function parseStoredFont(value: string | null, fallback: Font): Font { + if (!value) return fallback; + if ((ALL_FONTS as readonly string[]).includes(value)) return value as Font; + return fallback; +} + +function readStored( + storageKey: string, + fallback: T, + parse: (value: string | null, fallback: T) => T, +): T { + if (typeof window === "undefined") return fallback; + const ls = window.localStorage; + const getItem = ls && typeof ls.getItem === "function" ? ls.getItem.bind(ls) : null; + if (!getItem) return fallback; + try { + return parse(getItem(storageKey), fallback); + } catch { + return fallback; + } +} + +function writeStored(storageKey: string, value: T) { + if (typeof window === "undefined") return; + const ls = window.localStorage; + if (!ls || typeof ls.setItem !== "function") return; + try { + ls.setItem(storageKey, value); + } catch { + /* storage full or forbidden */ + } +} type ThemeProviderProps = { children: React.ReactNode; defaultTheme?: Theme; + defaultFont?: Font; storageKey: string; + fontStorageKey: string; }; type ThemeProviderState = { theme: Theme; - resolvedTheme: Omit; + resolvedTheme: ResolvedTheme; setTheme: (theme: Theme) => void; + font: Font; + setFont: (font: Font) => void; }; -const initialState: ThemeProviderState = { - resolvedTheme: "light", - theme: "system", - setTheme: () => null, -}; +const ThemeProviderContext = createContext(undefined); -const ThemeProviderContext = createContext(initialState); +function resolveTheme(theme: Theme): ResolvedTheme { + if (theme !== "system") return theme; + if (typeof window === "undefined") return "light"; + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +} export function ThemeProvider({ children, storageKey, - defaultTheme = "system", - ...props + fontStorageKey, + defaultTheme = "bloom", + defaultFont = "geist", }: ThemeProviderProps) { - const [theme, setTheme] = useState( - () => (localStorage.getItem(storageKey) as Theme) || defaultTheme, + const [theme, setThemeState] = useState(() => + readStored(storageKey, defaultTheme, parseStoredTheme), + ); + const [font, setFontState] = useState(() => + readStored(fontStorageKey, defaultFont, parseStoredFont), ); - const resolvedTheme = useMemo(() => { - if (theme !== "system") return theme; - return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; - }, [theme]); + const resolvedTheme = useMemo(() => resolveTheme(theme), [theme]); useEffect(() => { const root = window.document.documentElement; root.classList.remove("light", "dark"); - root.classList.add(resolvedTheme); + root.classList.add(resolvedTheme === "dark" ? "dark" : "light"); + root.dataset.theme = resolvedTheme; }, [resolvedTheme]); - const value = { - resolvedTheme, - theme, - setTheme: (newTheme: Theme) => { - localStorage.setItem(storageKey, newTheme); - setTheme(newTheme); + useEffect(() => { + window.document.documentElement.dataset.font = font; + }, [font]); + + const setTheme = useCallback( + (newTheme: Theme) => { + writeStored(storageKey, newTheme); + setThemeState(newTheme); + }, + [storageKey], + ); + + const setFont = useCallback( + (newFont: Font) => { + writeStored(fontStorageKey, newFont); + setFontState(newFont); }, - }; + [fontStorageKey], + ); - return ( - - {children} - + const value = useMemo( + () => ({ theme, resolvedTheme, setTheme, font, setFont }), + [theme, resolvedTheme, setTheme, font, setFont], ); + + return {children}; } export const useTheme = () => { const context = useContext(ThemeProviderContext); - if (context === undefined) throw new Error("useTheme must be used within a ThemeProvider"); - return context; + const { theme, resolvedTheme, setTheme } = context; + return useMemo(() => ({ theme, resolvedTheme, setTheme }), [theme, resolvedTheme, setTheme]); +}; + +export const useFont = () => { + const context = useContext(ThemeProviderContext); + if (context === undefined) throw new Error("useFont must be used within a ThemeProvider"); + + const { font, setFont } = context; + return useMemo(() => ({ font, setFont }), [font, setFont]); }; diff --git a/packages/core/src/globals.css b/packages/core/src/globals.css index f55ebe89..17da3132 100644 --- a/packages/core/src/globals.css +++ b/packages/core/src/globals.css @@ -34,6 +34,65 @@ body { @apply astw:font-sans astw:antialiased astw:bg-background astw:text-foreground; } + + /* + * Cream / Bloom — vertical shell gradient (top = light tint via --shell-gradient-start → bottom = white via --shell-gradient-end). + * Paint on `html` (fixed); shell chrome uses transparent bg so `bg-background` on buttons/cards stays solid. + */ + :is(html[data-theme="cream"], html[data-theme="bloom"]) { + min-height: 100vh; + min-height: 100dvh; + background-color: var(--background); + background-image: linear-gradient( + to bottom, + var(--shell-gradient-start) 0%, + color-mix(in srgb, var(--background) 45%, white) 20%, + color-mix(in srgb, var(--background) 30%, white) 40%, + color-mix(in srgb, var(--background) 15%, white) 55%, + color-mix(in srgb, var(--background) 6%, white) 65%, + var(--shell-gradient-end) 70%, + var(--shell-gradient-end) 100% + ); + background-attachment: fixed; + background-repeat: no-repeat; + background-size: 100% 100%; + } + + /* + * Font axis — applied independently of color theme via `data-font` on ``. + * + * Family-name chain prefers the variable build (loaded by + * `@tailor-platform/app-shell/fonts`), then the conventional static family + * name (`Geist Sans` / `Inter` — what `next/font/google` and most CDNs + * register), then the system stack. Consumers who load their own font under + * either name automatically participate; no font loads when nothing matches. + */ + html[data-font="geist"] body, + html[data-font="geist"] :where(h1, h2, h3, h4, h5, h6) { + font-family: "Geist Variable", "Geist Sans", ui-sans-serif, system-ui, sans-serif; + } + + html[data-font="inter"] body, + html[data-font="inter"] :where(h1, h2, h3, h4, h5, h6) { + font-family: "Inter Variable", "Inter", ui-sans-serif, system-ui, sans-serif; + } + + /* Tighter heading rhythm on cream/bloom — matches the showcase palettes' display feel. */ + :is(html[data-theme="cream"], html[data-theme="bloom"]) :where(h1, h2, h3, h4, h5, h6) { + letter-spacing: -0.03em; + line-height: 1.2; + } + + /* Apple-style squircle corners on cream/bloom where engine supports corner-shape (Chromium). */ + @supports (corner-shape: squircle) { + :is(html[data-theme="cream"], html[data-theme="bloom"]) * { + corner-shape: squircle; + } + /* Round radii (.rounded-full) stay circular squircles, not elongated “capsules” */ + :is(html[data-theme="cream"], html[data-theme="bloom"]) [class*="rounded-full"] { + corner-shape: round; + } + } ::-webkit-scrollbar { @apply astw:w-2 astw:h-2 astw:bg-muted; } @@ -43,4 +102,77 @@ ::-webkit-scrollbar-corner { @apply astw:bg-muted-foreground; } + + /* + * WebKit / Firefox autofill: override system yellow so fills follow design tokens + * (typically white card surfaces in cream / bloom; --card resolves per theme). + */ + input:is(:-webkit-autofill, :autofill), + textarea:is(:-webkit-autofill, :autofill), + select:is(:-webkit-autofill, :autofill) { + -webkit-box-shadow: 0 0 0 1000px var(--card) inset !important; + box-shadow: 0 0 0 1000px var(--card) inset !important; + caret-color: var(--foreground); + transition: background-color 9999s ease-out 0s; + } +} + +/* + * Theme-specific overrides that must beat Tailwind utility classes (which sit in `@layer utilities`). + * Keep this block in `@layer utilities` so it loses to no Tailwind utility in earlier layers and wins + * on selector specificity (no `!important` needed). + */ +@layer utilities { + /* + * Cream / Bloom: body and sidebar chrome stay transparent so the html-level shell gradient shows + * through. Wins over `body { bg-background }` (base) and the Tailwind `bg-sidebar*` utilities on + * sidebar slots via the `:is(html[data-theme=...])` prefix raising specificity. + */ + :is(html[data-theme="cream"], html[data-theme="bloom"]) body { + background-color: transparent; + background-image: none; + } + + :is(html[data-theme="cream"], html[data-theme="bloom"]) [data-slot="sidebar-wrapper"], + :is(html[data-theme="cream"], html[data-theme="bloom"]) [data-slot="sidebar-inner"], + :is(html[data-theme="cream"], html[data-theme="bloom"]) main[data-slot="sidebar-inset"], + :is(html[data-theme="cream"], html[data-theme="bloom"]) + [data-slot="sidebar"][class*="bg-sidebar"]:not([data-mobile="true"]):not( + [data-icon-mode="true"] + ) { + background-color: transparent; + } + + /* Outline button on cream/bloom: transparent fill so the shell gradient shows through; keep border. */ + :is(html[data-theme="cream"], html[data-theme="bloom"]) + [data-slot="button"][class*="astw:bg-background"][class*="astw:border"] { + background-color: transparent; + transition: + background-color 0.12s ease, + color 0.12s ease; + } + + /* Hover state — restore the accent fill that the Tailwind `hover:bg-accent` utility would have provided + * if our transparent rule above weren't winning on specificity. */ + :is(html[data-theme="cream"], html[data-theme="bloom"]) + [data-slot="button"][class*="astw:bg-background"][class*="astw:border"]:hover:not(:disabled) { + background-color: var(--accent); + color: var(--accent-foreground); + } + + /* + * Sidebar active item — hairline border (~0.5px @ 30% black). Wrapper components in + * `components/sidebar/*` apply the active state via direct `bg-sidebar-accent` classes + * (not the `data-active` attribute), so target the class signature. Inset box-shadow + * (not outline) because Tailwind's `outline-hidden` utility sets a transparent 2px + * outline that would override us. Lives in `@layer utilities` to win the cascade. + */ + :is( + [data-slot="sidebar-menu-button"], + [data-slot="sidebar-menu-sub-button"] + )[class~="astw:bg-sidebar-accent"] { + outline: 0.5px solid var(--border); + outline-offset: -0.5px; + box-shadow: var(--semantic-shadow-xs); + } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index daef45a1..e07a1ed5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -24,7 +24,19 @@ export { export { WithGuard, type WithGuardProps } from "./components/with-guard"; export { useAppShell, useAppShellConfig, useAppShellData } from "./contexts/appshell-context"; -export { useTheme } from "./contexts/theme-context"; +export { + useTheme, + useFont, + THEME_OPTIONS, + FONT_OPTIONS, + type ResolvedTheme, + type Theme, + type ThemeOption, + type Font, + type FontOption, +} from "./contexts/theme-context"; +export { ThemeSwitcher } from "./components/theme-switcher"; +export { getInitialAppearanceScript } from "./lib/initial-appearance"; export { type I18nLabels, defineI18nLabels } from "./hooks/i18n"; export { AuthProvider, diff --git a/packages/core/src/lib/initial-appearance.test.ts b/packages/core/src/lib/initial-appearance.test.ts new file mode 100644 index 00000000..c3ac32c7 --- /dev/null +++ b/packages/core/src/lib/initial-appearance.test.ts @@ -0,0 +1,120 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import { getInitialAppearanceScript } from "./initial-appearance"; + +function installLocalStorageStub() { + const map = new Map(); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: (k: string) => map.get(k) ?? null, + setItem: (k: string, v: string) => { + map.set(k, v); + }, + removeItem: (k: string) => map.delete(k), + clear: () => map.clear(), + key: (i: number) => [...map.keys()][i] ?? null, + get length() { + return map.size; + }, + }, + }); + return map; +} + +function installMatchMediaStub(matches: boolean) { + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: vi.fn().mockReturnValue({ matches }), + }); +} + +let storage: Map; + +beforeAll(() => { + storage = installLocalStorageStub(); +}); + +beforeEach(() => { + storage.clear(); + document.documentElement.removeAttribute("data-theme"); + document.documentElement.removeAttribute("data-font"); + document.documentElement.classList.remove("light", "dark"); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +/** Run the IIFE source as if a `