mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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:
parent
39b03f1299
commit
54a969c56d
@ -29,7 +29,7 @@ function DashboardShellInner({ children }: { children: React.ReactNode }) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-screen items-center justify-center bg-slate-950">
|
<div className="flex h-screen items-center justify-center bg-slate-950">
|
||||||
<div className="flex flex-col items-center gap-3">
|
<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>
|
<p className="text-sm text-slate-400">Loading...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -88,7 +88,7 @@ export default function FlowEditorPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => router.push("/flows")}
|
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
|
← Back to flows
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -173,7 +173,7 @@ export default function FlowRunsPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => router.push("/flows")}
|
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
|
← Back to flows
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -271,9 +271,9 @@ export default function FlowsPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleUseTemplate(t.slug)}
|
onClick={() => handleUseTemplate(t.slug)}
|
||||||
disabled={creating}
|
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">
|
<span className="text-sm font-semibold text-white">
|
||||||
{t.name}
|
{t.name}
|
||||||
</span>
|
</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 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 items-start justify-between gap-2">
|
||||||
<div className="flex min-w-0 items-center 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">
|
<h3 className="truncate text-sm font-semibold text-white">
|
||||||
{flow.name}
|
{flow.name}
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
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 { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
import { WhatsAppConfig } from '@/components/settings/whatsapp-config';
|
import { WhatsAppConfig } from '@/components/settings/whatsapp-config';
|
||||||
import { TemplateManager } from '@/components/settings/template-manager';
|
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 { ProfileForm } from '@/components/settings/profile-form';
|
||||||
import { PasswordForm } from '@/components/settings/password-form';
|
import { PasswordForm } from '@/components/settings/password-form';
|
||||||
import { SessionsCard } from '@/components/settings/sessions-card';
|
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];
|
type TabValue = (typeof TAB_VALUES)[number];
|
||||||
|
|
||||||
function isTabValue(v: string | null): v is TabValue {
|
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">
|
<TabsList className="bg-slate-900 border border-slate-700">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="profile"
|
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" />
|
<User className="size-4" />
|
||||||
Profile
|
Profile
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="whatsapp"
|
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" />
|
<Settings className="size-4" />
|
||||||
WhatsApp Config
|
WhatsApp Config
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="templates"
|
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" />
|
<MessageSquare className="size-4" />
|
||||||
Templates
|
Templates
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="tags"
|
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" />
|
<Tag className="size-4" />
|
||||||
Tags
|
Tags
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="appearance"
|
||||||
|
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
|
||||||
|
>
|
||||||
|
<Palette className="size-4" />
|
||||||
|
Appearance
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="profile" className="space-y-6">
|
<TabsContent value="profile" className="space-y-6">
|
||||||
@ -93,6 +107,10 @@ export default function SettingsPage() {
|
|||||||
<TabsContent value="tags">
|
<TabsContent value="tags">
|
||||||
<TagManager />
|
<TagManager />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="appearance">
|
||||||
|
<AppearancePanel />
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -48,7 +48,29 @@
|
|||||||
--radius-4xl: calc(var(--radius) * 2.6);
|
--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);
|
--background: oklch(0.13 0.01 260);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.18 0.01 260);
|
--card: oklch(0.18 0.01 260);
|
||||||
@ -57,6 +79,9 @@
|
|||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--primary: oklch(0.526 0.247 293);
|
--primary: oklch(0.526 0.247 293);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--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: oklch(0.22 0.01 260);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
--muted: oklch(0.22 0.01 260);
|
--muted: oklch(0.22 0.01 260);
|
||||||
@ -83,6 +108,154 @@
|
|||||||
--sidebar-ring: oklch(0.526 0.247 293);
|
--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 {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
|
import Script from "next/script";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { ThemeProvider } from "@/hooks/use-theme";
|
||||||
|
import { DEFAULT_THEME, STORAGE_KEY, THEME_IDS } from "@/lib/themes";
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
variable: "--font-sans",
|
variable: "--font-sans",
|
||||||
@ -33,26 +36,63 @@ export const viewport: Viewport = {
|
|||||||
colorScheme: "dark",
|
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({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={`${inter.variable} h-full antialiased`}>
|
<html
|
||||||
<body className="min-h-full bg-slate-950 text-white font-sans">
|
lang="en"
|
||||||
{children}
|
data-theme={DEFAULT_THEME}
|
||||||
<Toaster
|
className={`${inter.variable} h-full antialiased`}
|
||||||
theme="dark"
|
>
|
||||||
position="top-right"
|
<head>
|
||||||
toastOptions={{
|
<Script
|
||||||
style: {
|
id="theme-boot"
|
||||||
background: "rgb(30 41 59)",
|
strategy="beforeInteractive"
|
||||||
border: "1px solid rgb(51 65 85)",
|
dangerouslySetInnerHTML={{ __html: THEME_BOOT_SCRIPT }}
|
||||||
color: "white",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
</head>
|
||||||
|
<body className="min-h-full bg-background text-foreground font-sans">
|
||||||
|
<ThemeProvider>
|
||||||
|
{children}
|
||||||
|
<Toaster
|
||||||
|
theme="dark"
|
||||||
|
position="top-right"
|
||||||
|
toastOptions={{
|
||||||
|
style: {
|
||||||
|
background: "rgb(30 41 59)",
|
||||||
|
border: "1px solid rgb(51 65 85)",
|
||||||
|
color: "white",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -712,7 +712,7 @@ function Header({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div className="flex min-w-0 flex-1 items-center 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
|
<Input
|
||||||
value={state.name}
|
value={state.name}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@ -920,7 +920,7 @@ function EntryPicker({
|
|||||||
if (state.nodes.length === 0) return null;
|
if (state.nodes.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<section className="flex items-center gap-3 rounded-lg border border-slate-800 bg-slate-900 p-3">
|
<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>
|
<span className="text-xs text-slate-400">Entry node:</span>
|
||||||
<NodeKeySelect
|
<NodeKeySelect
|
||||||
value={state.entry_node_id}
|
value={state.entry_node_id}
|
||||||
@ -977,10 +977,10 @@ function NodeCard({
|
|||||||
hasError
|
hasError
|
||||||
? "border-red-500/40"
|
? "border-red-500/40"
|
||||||
: isEntry
|
: isEntry
|
||||||
? "border-violet-500/40"
|
? "border-primary/50"
|
||||||
: "border-slate-800",
|
: "border-slate-800",
|
||||||
isFlashed &&
|
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
|
<button
|
||||||
@ -1000,7 +1000,7 @@ function NodeCard({
|
|||||||
{isEntry && (
|
{isEntry && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
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
|
Entry
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@ -80,7 +80,7 @@ export function Header({ onOpenSidebar }: HeaderProps) {
|
|||||||
alt={profile.full_name ?? "Avatar"}
|
alt={profile.full_name ?? "Avatar"}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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}
|
{initial}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|||||||
@ -129,8 +129,8 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
|
|||||||
close button is hidden since the sidebar is always-visible. */}
|
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">
|
<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">
|
<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">
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||||
<MessageSquare className="h-4 w-4 text-white" />
|
<MessageSquare className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-semibold text-white">
|
<span className="text-sm font-semibold text-white">
|
||||||
CRM Template for WhatsApp
|
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).
|
// 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",
|
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors lg:py-2",
|
||||||
isActive
|
isActive
|
||||||
? "bg-violet-500/10 text-violet-500"
|
? "bg-primary/10 text-primary"
|
||||||
: "text-slate-400 hover:bg-slate-800 hover:text-white",
|
: "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"}`}
|
aria-label={`${totalUnread} unread conversation${totalUnread === 1 ? "" : "s"}`}
|
||||||
className="relative flex h-2 w-2"
|
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="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-violet-500" />
|
<span className="relative inline-flex h-2 w-2 rounded-full bg-primary" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
@ -205,7 +205,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors lg:py-2",
|
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors lg:py-2",
|
||||||
isActive
|
isActive
|
||||||
? "bg-violet-500/10 text-violet-500"
|
? "bg-primary/10 text-primary"
|
||||||
: "text-slate-400 hover:bg-slate-800 hover:text-white",
|
: "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"}
|
alt={profile.full_name ?? "Avatar"}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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?.full_name?.charAt(0)?.toUpperCase() ??
|
||||||
profile?.email?.charAt(0)?.toUpperCase() ??
|
profile?.email?.charAt(0)?.toUpperCase() ??
|
||||||
"U"}
|
"U"}
|
||||||
|
|||||||
113
src/components/settings/appearance-panel.tsx
Normal file
113
src/components/settings/appearance-panel.tsx
Normal 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
106
src/hooks/use-theme.tsx
Normal 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
81
src/lib/themes.ts
Normal 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)
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user