From 54a969c56d703485a9db4e553d78fbd50fc0aaf6 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Fri, 22 May 2026 16:48:40 +0300 Subject: [PATCH] feat(settings): color theme picker (5 themes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/app/(dashboard)/dashboard-shell.tsx | 2 +- src/app/(dashboard)/flows/[id]/page.tsx | 2 +- src/app/(dashboard)/flows/[id]/runs/page.tsx | 2 +- src/app/(dashboard)/flows/page.tsx | 6 +- src/app/(dashboard)/settings/page.tsx | 30 +++- src/app/globals.css | 175 ++++++++++++++++++- src/app/layout.tsx | 66 +++++-- src/components/flows/flow-builder.tsx | 10 +- src/components/layout/header.tsx | 2 +- src/components/layout/sidebar.tsx | 14 +- src/components/settings/appearance-panel.tsx | 113 ++++++++++++ src/hooks/use-theme.tsx | 106 +++++++++++ src/lib/themes.ts | 81 +++++++++ 13 files changed, 570 insertions(+), 39 deletions(-) create mode 100644 src/components/settings/appearance-panel.tsx create mode 100644 src/hooks/use-theme.tsx create mode 100644 src/lib/themes.ts diff --git a/src/app/(dashboard)/dashboard-shell.tsx b/src/app/(dashboard)/dashboard-shell.tsx index 5606f99..0ad55b0 100644 --- a/src/app/(dashboard)/dashboard-shell.tsx +++ b/src/app/(dashboard)/dashboard-shell.tsx @@ -29,7 +29,7 @@ function DashboardShellInner({ children }: { children: React.ReactNode }) { return (
-
+

Loading...

diff --git a/src/app/(dashboard)/flows/[id]/page.tsx b/src/app/(dashboard)/flows/[id]/page.tsx index 2d25bee..3a8de76 100644 --- a/src/app/(dashboard)/flows/[id]/page.tsx +++ b/src/app/(dashboard)/flows/[id]/page.tsx @@ -88,7 +88,7 @@ export default function FlowEditorPage() { diff --git a/src/app/(dashboard)/flows/[id]/runs/page.tsx b/src/app/(dashboard)/flows/[id]/runs/page.tsx index da6dc2b..78d7ced 100644 --- a/src/app/(dashboard)/flows/[id]/runs/page.tsx +++ b/src/app/(dashboard)/flows/[id]/runs/page.tsx @@ -173,7 +173,7 @@ export default function FlowRunsPage() { diff --git a/src/app/(dashboard)/flows/page.tsx b/src/app/(dashboard)/flows/page.tsx index e037bd5..0476901 100644 --- a/src/app/(dashboard)/flows/page.tsx +++ b/src/app/(dashboard)/flows/page.tsx @@ -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" > - + {t.name} @@ -366,7 +366,7 @@ function FlowCard({
- +

{flow.name}

diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 0dc8d5d..4c29725 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -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() { Profile WhatsApp Config Templates Tags + + + Appearance + @@ -93,6 +107,10 @@ export default function SettingsPage() { + + + +
); diff --git a/src/app/globals.css b/src/app/globals.css index 25b8e6f..0212fe0 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9e3207d..0956aa5 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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,26 +36,63 @@ export const viewport: Viewport = { colorScheme: "dark", }; +// Inline boot script — runs before React hydrates so the user's +// chosen theme is on the 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