feat(settings): color theme picker (5 themes)

Adds a new "Appearance" tab in Settings with 5 dark color themes
(Violet, Emerald, Cobalt, Amber, Rose). Pick a theme → applied
immediately and persisted to localStorage. Boot script in the root
layout replays the pick before first paint so there's no
flash-of-default on subsequent loads.

Implementation, ordered the way it's read:

1. `globals.css` — 5 `html[data-theme="..."]` blocks extracted
   verbatim from the design handoff at project/Color Themes.html.
   `:root` mirrors Violet so SSR / first-paint renders correctly
   even before the boot script runs. Three new tokens
   (`--primary-hover`, `--primary-soft`, `--primary-soft-2`) added
   to all themes — used by tinted-primary surfaces.

2. `src/lib/themes.ts` — single source of truth for the catalog
   (id, name, tagline, swatch). Adding a theme = append a CSS
   block + append an entry here.

3. `src/hooks/use-theme.tsx` — ThemeProvider client component. Reads
   what the boot script already applied, syncs across tabs via the
   `storage` event, persists to localStorage. Falls back to a no-op
   setter when used outside the provider.

4. `src/app/layout.tsx` — inline `beforeInteractive` script that
   reads localStorage and sets `documentElement.dataset.theme`
   before React hydrates. Knowledge of valid theme IDs comes from
   the THEME_IDS constant so adding a theme doesn't silently break
   the boot path. Wraps the tree in ThemeProvider.

5. `src/components/settings/appearance-panel.tsx` — picker UI. 5
   cards, swatch + name + tagline + a 4-tone preview strip. Active
   card carries a primary-tinted ring + "Active" chip.

6. `src/app/(dashboard)/settings/page.tsx` — new "appearance" tab
   alongside profile/whatsapp/templates/tags.

7. Key-surface tokenization — replaced hard-coded `violet-*` with
   `primary` tokens on the high-visibility surfaces so the theme
   switch feels real: sidebar logo + active nav + unread dot +
   avatar fallback, header avatar, dashboard-shell loading
   spinner, Flows header icon + entry-node picker icon + entry
   badge + flash ring, Flows list page (template card hover,
   FlowCard icon), Flows editor + runs "back" links, Settings tabs
   active state.

NOT touched (intentional follow-ups):
- Per-node-type colors in flow-builder.tsx NODE_META — those are a
  deliberate per-type rainbow, not a brand accent.
- The inbox/contacts/pipelines surfaces — many `violet-*` usages
  there. Sweep is a contained follow-up; the user explicitly
  scoped this PR to "tokens + tab + key surfaces".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-05-22 16:48:40 +03:00
parent 39b03f1299
commit 54a969c56d
No known key found for this signature in database
13 changed files with 570 additions and 39 deletions

View File

@ -29,7 +29,7 @@ function DashboardShellInner({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-screen items-center justify-center bg-slate-950">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-violet-500 border-t-transparent" />
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<p className="text-sm text-slate-400">Loading...</p>
</div>
</div>

View File

@ -88,7 +88,7 @@ export default function FlowEditorPage() {
<button
type="button"
onClick={() => router.push("/flows")}
className="text-sm text-violet-400 hover:text-violet-300"
className="text-sm text-primary hover:opacity-80"
>
← Back to flows
</button>

View File

@ -173,7 +173,7 @@ export default function FlowRunsPage() {
<button
type="button"
onClick={() => router.push("/flows")}
className="text-sm text-violet-400 hover:text-violet-300"
className="text-sm text-primary hover:opacity-80"
>
← Back to flows
</button>

View File

@ -271,9 +271,9 @@ export default function FlowsPage() {
type="button"
onClick={() => handleUseTemplate(t.slug)}
disabled={creating}
className="flex flex-col gap-2.5 rounded-lg border border-slate-800 bg-slate-950 p-4 text-left transition-colors hover:border-violet-500/40 hover:bg-slate-800 disabled:opacity-50"
className="flex flex-col gap-2.5 rounded-lg border border-slate-800 bg-slate-950 p-4 text-left transition-colors hover:border-primary/40 hover:bg-slate-800 disabled:opacity-50"
>
<Icon className="h-5 w-5 text-violet-400" />
<Icon className="h-5 w-5 text-primary" />
<span className="text-sm font-semibold text-white">
{t.name}
</span>
@ -366,7 +366,7 @@ function FlowCard({
<div className="flex flex-col rounded-lg border border-slate-800 bg-slate-900 p-4 transition-colors hover:border-slate-700">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<Workflow className="h-4 w-4 shrink-0 text-violet-400" />
<Workflow className="h-4 w-4 shrink-0 text-primary" />
<h3 className="truncate text-sm font-semibold text-white">
{flow.name}
</h3>

View File

@ -1,7 +1,7 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { Settings, MessageSquare, Tag, User } from 'lucide-react';
import { Settings, MessageSquare, Tag, User, Palette } from 'lucide-react';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { WhatsAppConfig } from '@/components/settings/whatsapp-config';
import { TemplateManager } from '@/components/settings/template-manager';
@ -9,8 +9,15 @@ import { TagManager } from '@/components/settings/tag-manager';
import { ProfileForm } from '@/components/settings/profile-form';
import { PasswordForm } from '@/components/settings/password-form';
import { SessionsCard } from '@/components/settings/sessions-card';
import { AppearancePanel } from '@/components/settings/appearance-panel';
const TAB_VALUES = ['profile', 'whatsapp', 'templates', 'tags'] as const;
const TAB_VALUES = [
'profile',
'whatsapp',
'templates',
'tags',
'appearance',
] as const;
type TabValue = (typeof TAB_VALUES)[number];
function isTabValue(v: string | null): v is TabValue {
@ -48,32 +55,39 @@ export default function SettingsPage() {
<TabsList className="bg-slate-900 border border-slate-700">
<TabsTrigger
value="profile"
className="data-active:bg-slate-800 data-active:text-violet-400 text-slate-400"
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
>
<User className="size-4" />
Profile
</TabsTrigger>
<TabsTrigger
value="whatsapp"
className="data-active:bg-slate-800 data-active:text-violet-400 text-slate-400"
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
>
<Settings className="size-4" />
WhatsApp Config
</TabsTrigger>
<TabsTrigger
value="templates"
className="data-active:bg-slate-800 data-active:text-violet-400 text-slate-400"
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
>
<MessageSquare className="size-4" />
Templates
</TabsTrigger>
<TabsTrigger
value="tags"
className="data-active:bg-slate-800 data-active:text-violet-400 text-slate-400"
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
>
<Tag className="size-4" />
Tags
</TabsTrigger>
<TabsTrigger
value="appearance"
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
>
<Palette className="size-4" />
Appearance
</TabsTrigger>
</TabsList>
<TabsContent value="profile" className="space-y-6">
@ -93,6 +107,10 @@ export default function SettingsPage() {
<TabsContent value="tags">
<TagManager />
</TabsContent>
<TabsContent value="appearance">
<AppearancePanel />
</TabsContent>
</Tabs>
</div>
);

View File

@ -48,7 +48,29 @@
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
/* ============================================================
* COLOR THEMES
*
* 5 dark themes sharing structure, swapping only the primary
* accent (and a tiny hue rotation on the slate base). Switched
* at runtime by setting `document.documentElement.dataset.theme`
* — see ThemeProvider + the inline boot script in layout.tsx.
*
* `:root` mirrors Violet so default visitors render correctly
* before JS runs. `[data-theme="violet"]` is included for the
* explicit-select path (settings UI sets it after a pick).
*
* The three `--primary-*` extras (hover / soft / soft-2) are
* new — used for hovered primary buttons and tinted-primary
* surfaces (sidebar active pill, validation badges). Components
* that don't use them remain backward-compatible.
*
* Token shapes come straight from the design handoff at
* project/Color Themes.html — change one, change the other.
* ============================================================ */
:root,
html[data-theme="violet"] {
--background: oklch(0.13 0.01 260);
--foreground: oklch(0.985 0 0);
--card: oklch(0.18 0.01 260);
@ -57,6 +79,9 @@
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.526 0.247 293);
--primary-foreground: oklch(0.985 0 0);
--primary-hover: oklch(0.6 0.22 293);
--primary-soft: oklch(0.526 0.247 293 / 0.12);
--primary-soft-2: oklch(0.526 0.247 293 / 0.2);
--secondary: oklch(0.22 0.01 260);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.22 0.01 260);
@ -83,6 +108,154 @@
--sidebar-ring: oklch(0.526 0.247 293);
}
html[data-theme="emerald"] {
--background: oklch(0.135 0.01 200);
--foreground: oklch(0.985 0 0);
--card: oklch(0.185 0.01 200);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.185 0.01 200);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.62 0.16 162);
--primary-foreground: oklch(0.16 0.02 162);
--primary-hover: oklch(0.68 0.15 162);
--primary-soft: oklch(0.62 0.16 162 / 0.12);
--primary-soft-2: oklch(0.62 0.16 162 / 0.22);
--secondary: oklch(0.225 0.01 200);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.225 0.01 200);
--muted-foreground: oklch(0.65 0.01 200);
--accent: oklch(0.225 0.01 200);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.285 0.01 200);
--input: oklch(0.285 0.01 200);
--ring: oklch(0.62 0.16 162);
--chart-1: oklch(0.62 0.16 162);
--chart-2: oklch(0.7 0.14 195);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.165 0.01 200);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.62 0.16 162);
--sidebar-primary-foreground: oklch(0.16 0.02 162);
--sidebar-accent: oklch(0.225 0.01 200);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.285 0.01 200);
--sidebar-ring: oklch(0.62 0.16 162);
}
html[data-theme="cobalt"] {
--background: oklch(0.13 0.015 252);
--foreground: oklch(0.985 0 0);
--card: oklch(0.18 0.015 252);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.18 0.015 252);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.585 0.2 254);
--primary-foreground: oklch(0.985 0 0);
--primary-hover: oklch(0.66 0.18 254);
--primary-soft: oklch(0.585 0.2 254 / 0.12);
--primary-soft-2: oklch(0.585 0.2 254 / 0.22);
--secondary: oklch(0.22 0.015 252);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.22 0.015 252);
--muted-foreground: oklch(0.65 0.012 252);
--accent: oklch(0.22 0.015 252);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.28 0.015 252);
--input: oklch(0.28 0.015 252);
--ring: oklch(0.585 0.2 254);
--chart-1: oklch(0.585 0.2 254);
--chart-2: oklch(0.7 0.15 220);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.16 0.015 252);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.585 0.2 254);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.22 0.015 252);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.28 0.015 252);
--sidebar-ring: oklch(0.585 0.2 254);
}
html[data-theme="amber"] {
--background: oklch(0.135 0.012 60);
--foreground: oklch(0.985 0 0);
--card: oklch(0.185 0.012 60);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.185 0.012 60);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.745 0.16 65);
--primary-foreground: oklch(0.18 0.03 65);
--primary-hover: oklch(0.8 0.15 65);
--primary-soft: oklch(0.745 0.16 65 / 0.12);
--primary-soft-2: oklch(0.745 0.16 65 / 0.22);
--secondary: oklch(0.225 0.012 60);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.225 0.012 60);
--muted-foreground: oklch(0.66 0.01 60);
--accent: oklch(0.225 0.012 60);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.285 0.012 60);
--input: oklch(0.285 0.012 60);
--ring: oklch(0.745 0.16 65);
--chart-1: oklch(0.745 0.16 65);
--chart-2: oklch(0.7 0.15 35);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.165 0.012 60);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.745 0.16 65);
--sidebar-primary-foreground: oklch(0.18 0.03 65);
--sidebar-accent: oklch(0.225 0.012 60);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.285 0.012 60);
--sidebar-ring: oklch(0.745 0.16 65);
}
html[data-theme="rose"] {
--background: oklch(0.13 0.012 15);
--foreground: oklch(0.985 0 0);
--card: oklch(0.18 0.012 15);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.18 0.012 15);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.645 0.22 16);
--primary-foreground: oklch(0.985 0 0);
--primary-hover: oklch(0.71 0.2 16);
--primary-soft: oklch(0.645 0.22 16 / 0.12);
--primary-soft-2: oklch(0.645 0.22 16 / 0.22);
--secondary: oklch(0.22 0.012 15);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.22 0.012 15);
--muted-foreground: oklch(0.65 0.01 15);
--accent: oklch(0.22 0.012 15);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.28 0.012 15);
--input: oklch(0.28 0.012 15);
--ring: oklch(0.645 0.22 16);
--chart-1: oklch(0.645 0.22 16);
--chart-2: oklch(0.7 0.18 340);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.16 0.012 15);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.645 0.22 16);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.22 0.012 15);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.28 0.012 15);
--sidebar-ring: oklch(0.645 0.22 16);
}
@layer base {
* {
@apply border-border outline-ring/50;

View File

@ -1,7 +1,10 @@
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import Script from "next/script";
import { Toaster } from "sonner";
import "./globals.css";
import { ThemeProvider } from "@/hooks/use-theme";
import { DEFAULT_THEME, STORAGE_KEY, THEME_IDS } from "@/lib/themes";
const inter = Inter({
variable: "--font-sans",
@ -33,14 +36,50 @@ export const viewport: Viewport = {
colorScheme: "dark",
};
// Inline boot script — runs before React hydrates so the user's
// chosen theme is on the <html> element before first paint. Without
// this every page load flashes the default Violet for a frame before
// the React tree mounts and applies the picked theme.
//
// Kept dependency-free (no imports, no JSX) — must be a string the
// browser can run as a single <script>. Knowledge of valid theme IDs
// is sourced from the THEME_IDS constant so adding a theme doesn't
// silently break the boot path.
const THEME_BOOT_SCRIPT = `
(function(){
try {
var STORAGE_KEY = ${JSON.stringify(STORAGE_KEY)};
var DEFAULT = ${JSON.stringify(DEFAULT_THEME)};
var ALLOWED = ${JSON.stringify(THEME_IDS)};
var saved = localStorage.getItem(STORAGE_KEY);
var theme = ALLOWED.indexOf(saved) !== -1 ? saved : DEFAULT;
document.documentElement.dataset.theme = theme;
} catch (_e) {
document.documentElement.dataset.theme = ${JSON.stringify(DEFAULT_THEME)};
}
})();
`;
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className={`${inter.variable} h-full antialiased`}>
<body className="min-h-full bg-slate-950 text-white font-sans">
<html
lang="en"
data-theme={DEFAULT_THEME}
className={`${inter.variable} h-full antialiased`}
>
<head>
<Script
id="theme-boot"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{ __html: THEME_BOOT_SCRIPT }}
/>
</head>
<body className="min-h-full bg-background text-foreground font-sans">
<ThemeProvider>
{children}
<Toaster
theme="dark"
@ -53,6 +92,7 @@ export default function RootLayout({
},
}}
/>
</ThemeProvider>
</body>
</html>
);

View File

@ -712,7 +712,7 @@ function Header({
</div>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-3">
<Workflow className="h-5 w-5 shrink-0 text-violet-400" />
<Workflow className="h-5 w-5 shrink-0 text-primary" />
<Input
value={state.name}
onChange={(e) =>
@ -920,7 +920,7 @@ function EntryPicker({
if (state.nodes.length === 0) return null;
return (
<section className="flex items-center gap-3 rounded-lg border border-slate-800 bg-slate-900 p-3">
<CornerDownRight className="h-4 w-4 shrink-0 text-violet-400" />
<CornerDownRight className="h-4 w-4 shrink-0 text-primary" />
<span className="text-xs text-slate-400">Entry node:</span>
<NodeKeySelect
value={state.entry_node_id}
@ -977,10 +977,10 @@ function NodeCard({
hasError
? "border-red-500/40"
: isEntry
? "border-violet-500/40"
? "border-primary/50"
: "border-slate-800",
isFlashed &&
"ring-2 ring-violet-400 ring-offset-2 ring-offset-slate-950",
"ring-2 ring-primary ring-offset-2 ring-offset-slate-950",
)}
>
<button
@ -1000,7 +1000,7 @@ function NodeCard({
{isEntry && (
<Badge
variant="outline"
className="border-violet-500/40 bg-violet-500/10 text-[10px] text-violet-300"
className="border-primary/40 bg-primary/10 text-[10px] text-primary"
>
Entry
</Badge>

View File

@ -80,7 +80,7 @@ export function Header({ onOpenSidebar }: HeaderProps) {
alt={profile.full_name ?? "Avatar"}
/>
) : null}
<AvatarFallback className="bg-violet-500/10 text-sm font-medium text-violet-500">
<AvatarFallback className="bg-primary/10 text-sm font-medium text-primary">
{initial}
</AvatarFallback>
</Avatar>

View File

@ -129,8 +129,8 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
close button is hidden since the sidebar is always-visible. */}
<div className="flex h-14 shrink-0 items-center justify-between gap-2 border-b border-slate-800 px-4">
<Link href="/dashboard" className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-500">
<MessageSquare className="h-4 w-4 text-white" />
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<MessageSquare className="h-4 w-4" />
</div>
<span className="text-sm font-semibold text-white">
CRM Template for WhatsApp
@ -172,7 +172,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
// Taller on mobile so fingers can hit the row reliably (≥44px).
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors lg:py-2",
isActive
? "bg-violet-500/10 text-violet-500"
? "bg-primary/10 text-primary"
: "text-slate-400 hover:bg-slate-800 hover:text-white",
)}
>
@ -183,8 +183,8 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
aria-label={`${totalUnread} unread conversation${totalUnread === 1 ? "" : "s"}`}
className="relative flex h-2 w-2"
>
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-violet-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-violet-500" />
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-primary" />
</span>
)}
</Link>
@ -205,7 +205,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors lg:py-2",
isActive
? "bg-violet-500/10 text-violet-500"
? "bg-primary/10 text-primary"
: "text-slate-400 hover:bg-slate-800 hover:text-white",
)}
>
@ -229,7 +229,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
alt={profile.full_name ?? "Avatar"}
/>
) : null}
<AvatarFallback className="bg-violet-500/10 text-sm font-medium text-violet-500">
<AvatarFallback className="bg-primary/10 text-sm font-medium text-primary">
{profile?.full_name?.charAt(0)?.toUpperCase() ??
profile?.email?.charAt(0)?.toUpperCase() ??
"U"}

View File

@ -0,0 +1,113 @@
"use client";
import { Check } from "lucide-react";
import { useTheme } from "@/hooks/use-theme";
import { THEMES, type ThemeId } from "@/lib/themes";
import { cn } from "@/lib/utils";
/**
* Appearance panel — color-theme picker.
*
* Click a card → applies + persists immediately. No save button:
* the whole change is a single CSS-variable swap on <html>, there's
* nothing to roll back. The active card carries a check chip + a
* primary-tinted border so the current pick is obvious.
*
* Persistence: localStorage only (device-scoped). The boot script in
* layout.tsx replays the choice before first paint on subsequent
* loads.
*/
export function AppearancePanel() {
const { theme, setTheme } = useTheme();
return (
<section className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-white">Color theme</h2>
<p className="mt-1 text-sm text-slate-400">
Pick the accent color used across the app. All themes stay
dark — only the primary color (buttons, active nav, badges)
changes. Saved to this device.
</p>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{THEMES.map((t) => (
<ThemeCard
key={t.id}
id={t.id}
name={t.name}
tagline={t.tagline}
swatch={t.swatch}
isActive={t.id === theme}
onPick={() => setTheme(t.id)}
/>
))}
</div>
</section>
);
}
function ThemeCard({
id,
name,
tagline,
swatch,
isActive,
onPick,
}: {
id: ThemeId;
name: string;
tagline: string;
swatch: string;
isActive: boolean;
onPick: () => void;
}) {
return (
<button
type="button"
onClick={onPick}
aria-pressed={isActive}
aria-label={`Use ${name} theme`}
className={cn(
"flex flex-col gap-3 rounded-lg border bg-card p-4 text-left transition-colors",
isActive
? "border-primary/60 ring-2 ring-primary/40"
: "border-slate-800 hover:border-slate-700 hover:bg-slate-800/40",
)}
>
<div className="flex items-center justify-between">
<span
aria-hidden
className="h-8 w-8 shrink-0 rounded-full"
style={{
background: swatch,
boxShadow: "inset 0 0 0 1px oklch(1 0 0 / 0.15)",
}}
/>
{isActive && (
<span className="inline-flex items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-[11px] font-medium text-primary">
<Check className="h-3 w-3" />
Active
</span>
)}
</div>
<div>
<div className="text-sm font-semibold text-white">{name}</div>
<div className="mt-1 text-xs leading-relaxed text-slate-400">
{tagline}
</div>
</div>
<div
className="mt-1 flex h-2 overflow-hidden rounded-full"
aria-hidden
>
<span className="flex-1" style={{ background: swatch }} />
<span className="w-3 bg-slate-700" />
<span className="w-3 bg-slate-800" />
<span className="w-3 bg-slate-900" />
</div>
<span className="sr-only">Theme id: {id}</span>
</button>
);
}

106
src/hooks/use-theme.tsx Normal file
View File

@ -0,0 +1,106 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
import {
DEFAULT_THEME,
STORAGE_KEY,
isThemeId,
type ThemeId,
} from "@/lib/themes";
/**
* ThemeProvider — wraps the whole app, owns the active theme state.
*
* The boot script in `src/app/layout.tsx` has already applied
* `document.documentElement.dataset.theme` before React hydrates, so
* by the time this Provider mounts the page is already painted in
* the right colors. We just have to read what's there and keep it
* in sync going forward.
*
* Persistence is localStorage only (device-scoped). A future
* follow-up could mirror to `profiles.preferences` for cross-device
* sync, but a per-device choice is also defensible — your phone may
* deserve a different theme than your laptop.
*/
interface ThemeContextValue {
theme: ThemeId;
setTheme: (next: ThemeId) => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
function readInitialTheme(): ThemeId {
if (typeof window === "undefined") return DEFAULT_THEME;
// Whatever the boot script applied is the truth. Fall back to
// localStorage / default if for some reason the attribute is missing
// (e.g. someone bypassed the boot script in a custom layout).
const fromAttr = document.documentElement.dataset.theme;
if (isThemeId(fromAttr)) return fromAttr;
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (isThemeId(stored)) return stored;
} catch {
// localStorage can throw in private-browsing / sandboxed contexts.
}
return DEFAULT_THEME;
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<ThemeId>(readInitialTheme);
const setTheme = useCallback((next: ThemeId) => {
setThemeState(next);
if (typeof document !== "undefined") {
document.documentElement.dataset.theme = next;
}
try {
localStorage.setItem(STORAGE_KEY, next);
} catch {
// Same private-browsing edge case as above; the in-memory state
// still updates so the current tab works for the session.
}
}, []);
// Sync from other tabs — if you change your theme in tab A, tab B
// catches up without a refresh.
useEffect(() => {
function onStorage(e: StorageEvent) {
if (e.key !== STORAGE_KEY) return;
if (isThemeId(e.newValue) && e.newValue !== theme) {
setThemeState(e.newValue);
document.documentElement.dataset.theme = e.newValue;
}
}
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) {
// Fallback for components rendered outside the provider — return a
// no-op setter so callers don't crash. The boot script still
// applied the right CSS attribute, so visually the page is fine.
return {
theme: DEFAULT_THEME,
setTheme: () => {},
};
}
return ctx;
}

81
src/lib/themes.ts Normal file
View File

@ -0,0 +1,81 @@
/**
* Single source of truth for the color-theme catalog.
*
* The CSS variables themselves live in `src/app/globals.css` under
* `html[data-theme="..."]` blocks — that file is the one we paste
* theme tokens into. This module only carries the metadata the UI
* (settings picker, no-flash boot script) needs.
*
* Adding a new theme is a two-step change:
* 1. Append the new `html[data-theme="<id>"]` block in globals.css
* with every token from an existing theme (use violet as the
* shape reference).
* 2. Add an entry below. The order here drives the picker grid.
*/
export const THEME_IDS = [
"violet",
"emerald",
"cobalt",
"amber",
"rose",
] as const;
export type ThemeId = (typeof THEME_IDS)[number];
export const DEFAULT_THEME: ThemeId = "violet";
export const STORAGE_KEY = "wacrm.theme";
export interface ThemeMeta {
id: ThemeId;
name: string;
tagline: string;
/**
* Static swatch color for the picker chip. Hard-coded so the boot
* script / picker cards don't need a getComputedStyle round trip
* before the page settles. Must mirror `--primary` of the same
* theme in globals.css.
*/
swatch: string;
}
export const THEMES: ReadonlyArray<ThemeMeta> = [
{
id: "violet",
name: "Violet",
tagline: "The default — confident, slightly playful.",
swatch: "oklch(0.526 0.247 293)",
},
{
id: "emerald",
name: "Emerald",
tagline: "Growth-coded, nods at messaging without copying WhatsApp green.",
swatch: "oklch(0.62 0.16 162)",
},
{
id: "cobalt",
name: "Cobalt",
tagline: "Clean B2B-SaaS blue — calm and product-y.",
swatch: "oklch(0.585 0.2 254)",
},
{
id: "amber",
name: "Amber",
tagline: "Warm and friendly — feels good for SMB teams.",
swatch: "oklch(0.745 0.16 65)",
},
{
id: "rose",
name: "Rose",
tagline: "Bold and modern — D2C, creator-economy, lifestyle.",
swatch: "oklch(0.645 0.22 16)",
},
];
export function isThemeId(value: unknown): value is ThemeId {
return (
typeof value === "string" &&
(THEME_IDS as ReadonlyArray<string>).includes(value)
);
}