diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx
new file mode 100644
index 0000000..f4df3e9
--- /dev/null
+++ b/src/app/(app)/settings/page.tsx
@@ -0,0 +1,81 @@
+"use client";
+
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Separator } from "@/components/ui/separator";
+import { ThemePicker } from "@/components/theme-picker";
+import { THEMES } from "@/lib/themes";
+import { Palette, Layers, Sparkles } from "lucide-react";
+
+export default function SettingsPage() {
+ const standardCount = THEMES.filter((t) => t.category === "standard").length;
+ const standoutCount = THEMES.filter((t) => t.category === "standout").length;
+
+ return (
+
+
+
Settings
+
+ Manage your WFDASH preferences.
+
+
+
+
+
+
+
+
+
+ Pick how WFDASH looks for you. Theme defines colors, fonts, and shape
+ language. Mode switches the light/dark surface treatment. Both choices
+ persist per browser.
+
+
+
+
+
+
+
+
+
+
+
+ About themes
+
+
+ WFDASH ships {standardCount} standard themes + {standoutCount} {" "}
+ standout themes, all built on the same brand palette (#4C96D7 blue,
+ #F2AF58 gold, #E94E49 red).
+
+
+
+
+
+
+ Standard themes
+
+
+ Tested against every shadcn/ui component — dialogs, popovers, dropdowns,
+ sonner toasts, tooltips, forms, tables all render cleanly. Safe defaults
+ for everyone on the team.
+
+
+
+
+
+ Standout themes
+
+
+ Creative options for users who want their CRM to feel different. Some
+ overrides on default component styling (sharp corners, monospace,
+ uppercase headings) so a few components render slightly differently
+ from the standard set.
+
+
+
+
+
+ );
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index 4174a30..931b2a5 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -1,6 +1,7 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
+@import "../styles/themes.css";
@custom-variant dark (&:is(.dark *));
@custom-variant warm (&:is(.warm *));
@@ -49,39 +50,21 @@
--radius-4xl: calc(var(--radius) * 2.6);
}
-:root {
- --background: oklch(0.985 0.01 245);
- --foreground: oklch(0.22 0.045 245);
- --card: oklch(1 0 0);
- --card-foreground: oklch(0.22 0.045 245);
- --popover: oklch(1 0 0);
- --popover-foreground: oklch(0.22 0.045 245);
- --primary: oklch(0.47 0.16 245);
- --primary-foreground: oklch(1 0 0);
- --secondary: oklch(0.95 0.025 245);
- --secondary-foreground: oklch(0.24 0.08 245);
- --muted: oklch(0.95 0.012 245);
- --muted-foreground: oklch(0.48 0.035 245);
- --accent: oklch(0.88 0.13 82);
- --accent-foreground: oklch(0.24 0.08 245);
- --destructive: oklch(0.60 0.21 25);
- --border: oklch(0.88 0.018 245);
- --input: oklch(0.90 0.018 245);
- --ring: oklch(0.47 0.16 245);
- --chart-1: oklch(0.47 0.16 245);
- --chart-2: oklch(0.66 0.20 32);
- --chart-3: oklch(0.82 0.15 82);
- --chart-4: oklch(0.58 0.15 205);
- --chart-5: oklch(0.55 0.16 25);
- --radius: 0.875rem;
- --sidebar: oklch(0.97 0.015 245);
- --sidebar-foreground: oklch(0.22 0.045 245);
- --sidebar-primary: oklch(0.47 0.16 245);
- --sidebar-primary-foreground: oklch(1 0 0);
- --sidebar-accent: oklch(0.93 0.04 245);
- --sidebar-accent-foreground: oklch(0.22 0.045 245);
- --sidebar-border: oklch(0.86 0.025 245);
- --sidebar-ring: oklch(0.47 0.16 245);
+/* Default theme tokens live in src/styles/themes.css under [data-theme="crisp"].
+ Keep :root as a safety fallback for the brief moment before the inline theme
+ script runs (prevents FOUC). */
+:root {
+ --background: oklch(0.985 0.01 240);
+ --foreground: oklch(0.20 0.045 240);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.20 0.045 240);
+ --primary: oklch(0.62 0.13 240);
+ --primary-foreground: oklch(1 0 0);
+ --accent: oklch(0.81 0.14 75);
+ --destructive: oklch(0.62 0.21 25);
+ --border: oklch(0.90 0.018 240);
+ --ring: oklch(0.62 0.13 240);
+ --radius: 0.625rem;
}
.warm {
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index d43eaae..db87499 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,63 +1,167 @@
import type { Metadata, Viewport } from "next";
-import { Geist, Geist_Mono } from "next/font/google";
+import {
+ Geist,
+ Geist_Mono,
+ Inter,
+ Space_Grotesk,
+ DM_Sans,
+ Playfair_Display,
+ Lato,
+ Nunito,
+ Outfit,
+ JetBrains_Mono,
+} from "next/font/google";
import "leaflet/dist/leaflet.css";
import "./globals.css";
-import { ThemeProvider } from "@/components/theme-provider";
-import { AuthProvider } from "@/components/auth-provider";
-import { Toaster } from "@/components/ui/sonner";
-import { TooltipProvider } from "@/components/ui/tooltip";
-import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
-
-const geistSans = Geist({
- variable: "--font-geist-sans",
- subsets: ["latin"],
-});
-
-const geistMono = Geist_Mono({
- variable: "--font-geist-mono",
- subsets: ["latin"],
-});
-
-export const viewport: Viewport = {
- width: "device-width",
- initialScale: 1,
- viewportFit: "cover",
- themeColor: "#E85D4C",
-};
-
-export const metadata: Metadata = {
- title: "WFDASH - Business CRM",
- description: "Business CRM for managing customers, companies, suppliers, quotes, and orders",
- manifest: "/manifest.json",
- appleWebApp: {
- capable: true,
- statusBarStyle: "default",
- title: "WFDASH",
- },
- icons: {
- icon: "/icons/icon.svg",
- apple: "/icons/icon.svg",
- },
-};
-
-export default function RootLayout({ children }: { children: React.ReactNode }) {
- return (
-
-
-
-
-
-
- {children}
-
-
-
-
-
-
- );
-}
+import { ThemeProvider } from "@/components/theme-provider";
+import { AuthProvider } from "@/components/auth-provider";
+import { Toaster } from "@/components/ui/sonner";
+import { TooltipProvider } from "@/components/ui/tooltip";
+import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
+import { ThemeBootstrapScript } from "@/components/theme-bootstrap";
+
+// Geist (default app fonts)
+const geistSans = Geist({
+ variable: "--font-geist-sans",
+ subsets: ["latin"],
+});
+const geistMono = Geist_Mono({
+ variable: "--font-geist-mono",
+ subsets: ["latin"],
+});
+
+// Theme-bound fonts (registered as CSS vars for the theme picker to swap)
+const inter = Inter({
+ variable: "--font-inter",
+ subsets: ["latin"],
+ display: "swap",
+});
+const spaceGrotesk = Space_Grotesk({
+ variable: "--font-space-grotesk",
+ subsets: ["latin"],
+ display: "swap",
+});
+const dmSans = DM_Sans({
+ variable: "--font-dm-sans",
+ subsets: ["latin"],
+ display: "swap",
+});
+const playfair = Playfair_Display({
+ variable: "--font-playfair",
+ subsets: ["latin"],
+ display: "swap",
+});
+const lato = Lato({
+ variable: "--font-lato",
+ subsets: ["latin"],
+ weight: ["400", "700"],
+ display: "swap",
+});
+const nunito = Nunito({
+ variable: "--font-nunito",
+ subsets: ["latin"],
+ display: "swap",
+});
+const outfit = Outfit({
+ variable: "--font-outfit",
+ subsets: ["latin"],
+ display: "swap",
+});
+const jetbrainsMono = JetBrains_Mono({
+ variable: "--font-jetbrains-mono",
+ subsets: ["latin"],
+ display: "swap",
+});
+
+export const viewport: Viewport = {
+ width: "device-width",
+ initialScale: 1,
+ viewportFit: "cover",
+ themeColor: "#4C96D7",
+};
+
+export const metadata: Metadata = {
+ title: "WFDASH - Business CRM",
+ description: "Business CRM for managing customers, companies, suppliers, quotes, and orders",
+ manifest: "/manifest.json",
+ applicationName: "WFDASH",
+ authors: [{ name: "WFDASH" }],
+ keywords: ["CRM", "Business", "Quotes", "Orders", "Customers", "Suppliers"],
+ appleWebApp: {
+ capable: true,
+ statusBarStyle: "default",
+ title: "WFDASH",
+ },
+ icons: {
+ icon: [
+ { url: "/favicon.ico", sizes: "any" },
+ { url: "/favicon-16x16.png", sizes: "16x16", type: "image/png" },
+ { url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" },
+ { url: "/favicon-96x96.png", sizes: "96x96", type: "image/png" },
+ { url: "/android-chrome-192x192.png", sizes: "192x192", type: "image/png" },
+ { url: "/android-chrome-512x512.png", sizes: "512x512", type: "image/png" },
+ ],
+ apple: "/apple-touch-icon.png",
+ },
+ openGraph: {
+ type: "website",
+ title: "WFDASH - Business CRM",
+ description: "Business CRM for managing customers, companies, suppliers, quotes, and orders",
+ siteName: "WFDASH",
+ images: [
+ {
+ url: "/og-image.png",
+ width: 1200,
+ height: 630,
+ alt: "WFDASH - Business CRM",
+ },
+ ],
+ },
+ twitter: {
+ card: "summary_large_image",
+ title: "WFDASH - Business CRM",
+ description: "Business CRM for managing customers, companies, suppliers, quotes, and orders",
+ images: ["/twitter-image.png"],
+ },
+};
+
+// All theme-bound CSS vars must be present on so themes can swap them
+const FONT_VARS = [
+ geistSans.variable,
+ geistMono.variable,
+ inter.variable,
+ spaceGrotesk.variable,
+ dmSans.variable,
+ playfair.variable,
+ lato.variable,
+ nunito.variable,
+ outfit.variable,
+ jetbrainsMono.variable,
+].join(" ");
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+ {/* Apply persisted theme before hydration to prevent flash of wrong theme */}
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/charts/area-chart.tsx b/src/components/charts/area-chart.tsx
new file mode 100644
index 0000000..ab1a3a0
--- /dev/null
+++ b/src/components/charts/area-chart.tsx
@@ -0,0 +1,173 @@
+"use client";
+
+import { useId } from "react";
+import { withAlpha } from "./palette";
+
+export type AreaSeries = {
+ label: string;
+ color: string;
+ data: number[];
+};
+
+export type AreaChartProps = {
+ labels: string[];
+ series: AreaSeries[];
+ height?: number;
+ /** Show legend below the chart */
+ showLegend?: boolean;
+};
+
+/**
+ * Multi-series area chart with gradient fills under each line.
+ * Pure SVG — no external deps.
+ */
+export function AreaChart({
+ labels,
+ series,
+ height = 220,
+ showLegend = true,
+}: AreaChartProps) {
+ const uid = useId();
+ if (series.length === 0 || labels.length === 0) {
+ return (
+
+ No data yet.
+
+ );
+ }
+ const w = 800;
+ const h = height;
+ const padX = 8;
+ const padTop = 12;
+ const padBottom = 28;
+ const innerW = w - padX * 2;
+ const innerH = h - padTop - padBottom;
+ const allValues = series.flatMap((s) => s.data);
+ const maxValue = Math.max(...allValues, 1);
+ const step = labels.length > 1 ? innerW / (labels.length - 1) : innerW;
+
+ return (
+
+
+
+ {series.map((s, i) => (
+
+
+
+
+ ))}
+
+ {/* Horizontal grid lines */}
+ {[0, 0.25, 0.5, 0.75, 1].map((p, i) => (
+
+ ))}
+ {/* Series lines + area fills */}
+ {series.map((s, si) => {
+ const pts = s.data.map((v, i) => {
+ const x = padX + i * step;
+ const y = padTop + innerH - (v / maxValue) * innerH;
+ return { x, y, v, label: labels[i] };
+ });
+ const linePath = pts
+ .map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(2)},${p.y.toFixed(2)}`)
+ .join(" ");
+ const areaPath = `${linePath} L${pts[pts.length - 1].x.toFixed(2)},${(padTop + innerH).toFixed(2)} L${pts[0].x.toFixed(2)},${(padTop + innerH).toFixed(2)} Z`;
+ return (
+
+
+
+ {pts.map((p, i) => (
+
+ ))}
+
+ );
+ })}
+ {/* X-axis labels (first / mid / last) */}
+ {(() => {
+ const positions =
+ labels.length <= 3
+ ? labels.map((_, i) => i)
+ : [0, Math.floor(labels.length / 2), labels.length - 1];
+ return positions.map((i) => (
+
+ {labels[i]}
+
+ ));
+ })()}
+
+ {showLegend && (
+
+ {series.map((s, i) => {
+ const last = s.data[s.data.length - 1] ?? 0;
+ return (
+
+
+ {s.label}
+ {last}
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/src/components/charts/bar-chart.tsx b/src/components/charts/bar-chart.tsx
new file mode 100644
index 0000000..f9785f6
--- /dev/null
+++ b/src/components/charts/bar-chart.tsx
@@ -0,0 +1,104 @@
+"use client";
+
+import { withAlpha } from "./palette";
+
+/**
+ * BarChart — vertical bars, optional series stacking.
+ * Single-series: bars get gradient of the color.
+ * Multi-series: bars are stacked, each segment in its own color.
+ */
+export type BarSeries = {
+ label: string;
+ color: string;
+};
+
+export type BarDatum = {
+ /** X-axis label (e.g., "Mon", "W22") */
+ label: string;
+ /** Numeric values, one per series (in order). Empty array = empty bucket. */
+ values: number[];
+};
+
+export function BarChart({
+ data,
+ series,
+ height = 180,
+}: {
+ data: BarDatum[];
+ series: BarSeries[];
+ height?: number;
+}) {
+ if (data.length === 0 || series.length === 0) {
+ return (
+
+ No data yet.
+
+ );
+ }
+ const totals = data.map((d) => d.values.reduce((s, v) => s + v, 0));
+ const maxValue = Math.max(...totals, 1);
+ return (
+
+
+ {data.map((d, i) => {
+ const total = totals[i];
+ const totalH = maxValue === 0 ? 0 : (total / maxValue) * height;
+ return (
+
+ {/* Stacked segments from bottom up */}
+
+ {d.values.map((v, si) => {
+ const segH = total === 0 ? 0 : (v / total) * 100;
+ return (
+
+ );
+ })}
+
+ {/* Hover tooltip */}
+ {total > 0 && (
+
+ {total}
+
+ )}
+
+ );
+ })}
+
+
+ {data.map((d, i) => (
+
+ {d.label}
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/charts/concentric-rings.tsx b/src/components/charts/concentric-rings.tsx
new file mode 100644
index 0000000..0127c86
--- /dev/null
+++ b/src/components/charts/concentric-rings.tsx
@@ -0,0 +1,162 @@
+"use client";
+
+import { useId } from "react";
+import { withAlpha } from "./palette";
+
+export type RingDatum = {
+ label: string;
+ value: number;
+ color: string;
+};
+
+/**
+ * ConcentricRings — multiple nested donut rings, each showing a different metric.
+ * Inner-most ring = smallest, outer-most = largest.
+ * Center shows total or primary value.
+ */
+export function ConcentricRings({
+ rings,
+ total,
+ centerLabel,
+ centerSubLabel,
+ size = 180,
+}: {
+ rings: RingDatum[];
+ total: number;
+ centerLabel: string;
+ centerSubLabel?: string;
+ size?: number;
+}) {
+ const cx = size / 2;
+ const cy = size / 2;
+ const minR = size * 0.18;
+ const maxR = size * 0.42;
+ const stroke = size * 0.07;
+ const gradId = useId();
+
+ if (total === 0 || rings.length === 0) {
+ return (
+
+ No data yet.
+
+ );
+ }
+
+ // Each ring uses a fraction of its radius — share of total
+ const ringGap = (maxR - minR) / Math.max(rings.length, 1);
+
+ return (
+
+
+
+
+ {rings.map((_, i) => (
+
+
+
+ ))}
+
+ {rings.map((r, i) => {
+ const radius = maxR - i * ringGap;
+ const fraction = total === 0 ? 0 : r.value / total;
+ const circumference = 2 * Math.PI * radius;
+ return (
+
+ {/* Background track */}
+
+ {/* Active arc */}
+
+
+ );
+ })}
+ {/* Center label */}
+
+ {centerLabel}
+
+ {centerSubLabel && (
+
+ {centerSubLabel}
+
+ )}
+
+
+ {/* Legend */}
+
+ {rings.map((r, i) => {
+ const fraction = total === 0 ? 0 : r.value / total;
+ return (
+
+
+
+ {r.label}
+
+
+ {r.value}
+
+ {Math.round(fraction * 100)}%
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/components/charts/dashboard-card.tsx b/src/components/charts/dashboard-card.tsx
new file mode 100644
index 0000000..8baae18
--- /dev/null
+++ b/src/components/charts/dashboard-card.tsx
@@ -0,0 +1,85 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { cn } from "@/lib/utils";
+import { withAlpha } from "./palette";
+
+/**
+ * DashboardCard — frosted-glass frame used everywhere on the dashboard.
+ * Adapts to active theme via CSS vars for surface/border; uses a subtle
+ * colored top accent strip passed via `accentColor` for category coding.
+ */
+export function DashboardCard({
+ title,
+ subtitle,
+ icon: Icon,
+ accentColor,
+ action,
+ children,
+ className,
+ padding = "p-5",
+}: {
+ title?: string;
+ subtitle?: string;
+ icon?: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
+ /** Thin colored strip at the top edge — uses the expanded chart palette */
+ accentColor?: string;
+ action?: ReactNode;
+ children: ReactNode;
+ className?: string;
+ padding?: string;
+}) {
+ return (
+
+ {accentColor && (
+
+ )}
+ {(title || action) && (
+
+
+ {Icon && accentColor && (
+
+
+
+ )}
+
+ {title && (
+
{title}
+ )}
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+
+ {action}
+
+ )}
+ {children}
+
+ );
+}
diff --git a/src/components/charts/donut-chart.tsx b/src/components/charts/donut-chart.tsx
new file mode 100644
index 0000000..e8e0fe8
--- /dev/null
+++ b/src/components/charts/donut-chart.tsx
@@ -0,0 +1,162 @@
+"use client";
+
+import { useId } from "react";
+import { withAlpha } from "./palette";
+
+export type DonutBucket = {
+ label: string;
+ value: number;
+ color: string;
+};
+
+export function DonutChart({
+ buckets,
+ total,
+ centerLabel,
+ centerSubLabel,
+ size = 180,
+}: {
+ buckets: DonutBucket[];
+ total: number;
+ centerLabel: string;
+ centerSubLabel?: string;
+ size?: number;
+}) {
+ const cx = size / 2;
+ const cy = size / 2;
+ const r = size * 0.36;
+ const stroke = size * 0.13;
+ const circumference = 2 * Math.PI * r;
+ const gradId = useId();
+
+ if (total === 0 || buckets.length === 0) {
+ return (
+
+ No data yet.
+
+ );
+ }
+
+ // Build segments with running cumulative offset via reduce (lint-friendly)
+ const segments = buckets.reduce<
+ Array
+ >((acc, b) => {
+ const priorTotal = acc.reduce((s, x) => s + x.fraction, 0);
+ const fraction = b.value / total;
+ acc.push({
+ ...b,
+ dash: fraction * circumference,
+ offset: -priorTotal * circumference,
+ fraction,
+ });
+ return acc;
+ }, []);
+
+ return (
+
+
+
+
+ {segments.map((s, i) => (
+
+
+
+
+
+
+
+ ))}
+
+ {/* Background ring */}
+
+ {segments.map((s, i) => (
+
+ ))}
+ {/* Center label */}
+
+ {centerLabel}
+
+ {centerSubLabel && (
+
+ {centerSubLabel}
+
+ )}
+
+
+ {/* Legend */}
+
+ {segments.map((s, i) => (
+
+
+
+ {s.label}
+
+
+ {s.value}
+
+ {Math.round(s.fraction * 100)}%
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/charts/horizontal-bars.tsx b/src/components/charts/horizontal-bars.tsx
new file mode 100644
index 0000000..8549f22
--- /dev/null
+++ b/src/components/charts/horizontal-bars.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+import { withAlpha } from "./palette";
+
+export type HBarDatum = {
+ label: string;
+ value: number;
+ color?: string;
+};
+
+/**
+ * HorizontalBar — for "top customers", ranked lists, etc.
+ * Each row is a full-width rounded bar with a label, value, and percentage fill.
+ */
+export function HorizontalBars({
+ data,
+ unit = "",
+ maxValue,
+}: {
+ data: HBarDatum[];
+ unit?: string;
+ /** Optional explicit max; otherwise computed from data */
+ maxValue?: number;
+}) {
+ if (data.length === 0) {
+ return (
+
+ No data yet.
+
+ );
+ }
+ const max = maxValue ?? Math.max(...data.map((d) => d.value), 1);
+ return (
+
+ {data.map((d, i) => {
+ const pct = (d.value / max) * 100;
+ const color = d.color ?? "#4C96D7";
+ return (
+
+
+ {d.label}
+
+ {d.value.toLocaleString()}
+ {unit}
+
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/charts/multi-line-chart.tsx b/src/components/charts/multi-line-chart.tsx
new file mode 100644
index 0000000..334ea40
--- /dev/null
+++ b/src/components/charts/multi-line-chart.tsx
@@ -0,0 +1,135 @@
+"use client";
+
+import { withAlpha } from "./palette";
+
+export type LineSeries = {
+ label: string;
+ color: string;
+ data: number[];
+};
+
+/**
+ * MultiLineChart — pure line chart with circular dots at endpoints.
+ * No area fill — just crisp glowing lines + dots.
+ * Like the multi-peak chart in the reference with circular endpoints.
+ */
+export function MultiLineChart({
+ labels,
+ series,
+ height = 180,
+}: {
+ labels: string[];
+ series: LineSeries[];
+ height?: number;
+}) {
+ if (series.length === 0 || labels.length === 0) {
+ return (
+
+ No data yet.
+
+ );
+ }
+ const w = 600;
+ const padX = 8;
+ const padTop = 8;
+ const padBottom = 24;
+ const innerW = w - padX * 2;
+ const innerH = height - padTop - padBottom;
+ const allValues = series.flatMap((s) => s.data);
+ const maxValue = Math.max(...allValues, 1);
+ const step = labels.length > 1 ? innerW / (labels.length - 1) : innerW;
+
+ return (
+
+ {series.map((s, si) => {
+ const pts = s.data.map((v, i) => {
+ const x = padX + i * step;
+ const y = padTop + innerH - (v / maxValue) * innerH;
+ return { x, y };
+ });
+ // Smooth curve using Catmull-Rom -> Bezier conversion (simplified)
+ const linePath = pts
+ .map((p, i) => {
+ if (i === 0) return `M${p.x.toFixed(2)},${p.y.toFixed(2)}`;
+ const prev = pts[i - 1];
+ const cpx = (prev.x + p.x) / 2;
+ return `Q${cpx.toFixed(2)},${prev.y.toFixed(2)} ${cpx.toFixed(2)},${((prev.y + p.y) / 2).toFixed(2)} T${p.x.toFixed(2)},${p.y.toFixed(2)}`;
+ })
+ .join(" ");
+ return (
+
+ {/* Glow layer */}
+
+ {/* Crisp line */}
+
+ {/* Dots: every data point */}
+ {pts.map((p, i) => (
+
+
+
+
+ ))}
+
+ );
+ })}
+ {/* X-axis labels (first / mid / last) */}
+ {(() => {
+ const positions =
+ labels.length <= 3
+ ? labels.map((_, i) => i)
+ : [0, Math.floor(labels.length / 2), labels.length - 1];
+ return positions.map((i) => (
+
+ {labels[i]}
+
+ ));
+ })()}
+
+ );
+}
diff --git a/src/components/charts/palette.ts b/src/components/charts/palette.ts
new file mode 100644
index 0000000..fa6092d
--- /dev/null
+++ b/src/components/charts/palette.ts
@@ -0,0 +1,42 @@
+"use client";
+
+/**
+ * Brand + dashboard chart color palette.
+ *
+ * Brand colors (locked, used everywhere):
+ * blue #4C96D7
+ * gold #F2AF58
+ * red #E94E49
+ *
+ * Expanded data palette (dashboard charts only):
+ * cyan / pink / orange / green / purple
+ *
+ * The expanded colors are intentionally brighter + more saturated so they
+ * read well on dark surfaces and pop in chart contexts. They are NOT used
+ * outside the dashboard — sidebar buttons, brand surfaces, theme tokens
+ * stay locked to the 3 brand colors.
+ */
+export const BRAND = {
+ blue: "#4C96D7",
+ gold: "#F2AF58",
+ red: "#E94E49",
+} as const;
+
+export const CHART = {
+ ...BRAND,
+ cyan: "#22D3EE",
+ pink: "#EC4899",
+ orange: "#F97316",
+ yellow: "#FBBF24",
+ green: "#10B981",
+ purple: "#A78BFA",
+ muted: "#94A3B8",
+} as const;
+
+/** 8-digit hex with alpha (browser-portable, no color-mix needed) */
+export function withAlpha(hex: string, alphaPercent: number): string {
+ const a = Math.round((alphaPercent / 100) * 255)
+ .toString(16)
+ .padStart(2, "0");
+ return `${hex}${a}`;
+}
diff --git a/src/components/charts/progress-bar.tsx b/src/components/charts/progress-bar.tsx
new file mode 100644
index 0000000..95af1ac
--- /dev/null
+++ b/src/components/charts/progress-bar.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import { withAlpha } from "./palette";
+
+/**
+ * ProgressBar — vertical or horizontal bar showing a percentage fill.
+ * Used for compact status widgets, e.g. "Approval Pending: 62%".
+ */
+export function ProgressBar({
+ label,
+ value,
+ total,
+ color,
+ unit = "",
+}: {
+ label: string;
+ value: number;
+ total: number;
+ color: string;
+ unit?: string;
+}) {
+ const pct = total === 0 ? 0 : Math.round((value / total) * 100);
+ return (
+
+
+ {label}
+
+ {value}
+ {unit ? ` ${unit}` : ""} · {pct}%
+
+
+
+
+ );
+}
diff --git a/src/components/charts/radial-gauge.tsx b/src/components/charts/radial-gauge.tsx
new file mode 100644
index 0000000..e4ba67d
--- /dev/null
+++ b/src/components/charts/radial-gauge.tsx
@@ -0,0 +1,149 @@
+"use client";
+
+import { useId } from "react";
+import { withAlpha } from "./palette";
+
+/**
+ * RadialGauge — half-donut (180°) with tick marks around the arc and a
+ * central percentage. Like a speedometer.
+ *
+ * Reference: matches the "DASHBOARD ELEMENT" gauge in the design kit
+ * with radial tick marks.
+ */
+export function RadialGauge({
+ value,
+ total,
+ color,
+ label,
+ sublabel,
+ size = 180,
+ ticks = 24,
+}: {
+ value: number;
+ total: number;
+ color: string;
+ label?: string;
+ sublabel?: string;
+ size?: number;
+ ticks?: number;
+}) {
+ const cx = size / 2;
+ const cy = size * 0.62;
+ const r = size * 0.36;
+ const stroke = size * 0.1;
+ const circumference = Math.PI * r; // half-circle
+ const fraction = total === 0 ? 0 : Math.min(1, value / total);
+ const gradId = useId();
+
+ // Tick marks around the half-circle
+ const tickElements: React.ReactNode[] = [];
+ for (let i = 0; i <= ticks; i++) {
+ const angle = -180 + (i / ticks) * 180; // -180° to 0°
+ const rad = (angle * Math.PI) / 180;
+ const outerR = r + stroke / 2 + 4;
+ const innerR = r + stroke / 2 + 1;
+ const isMajor = i % 4 === 0;
+ const x1 = cx + Math.cos(rad) * outerR;
+ const y1 = cy + Math.sin(rad) * outerR;
+ const x2 = cx + Math.cos(rad) * innerR;
+ const y2 = cy + Math.sin(rad) * innerR;
+ tickElements.push(
+
+ );
+ }
+
+ // Cap dot at the end of the value arc
+ const valueAngle = -180 + fraction * 180;
+ const valueRad = (valueAngle * Math.PI) / 180;
+ const capX = cx + Math.cos(valueRad) * r;
+ const capY = cy + Math.sin(valueRad) * r;
+
+ const pct = Math.round(fraction * 100);
+
+ return (
+
+
+
+
+
+
+
+
+ {tickElements}
+ {/* Background track */}
+
+ {/* Active arc */}
+
+ {/* Cap dot */}
+
+ {/* Center percentage */}
+
+ {pct}%
+
+ {label && (
+
+ {label}
+
+ )}
+
+
+ {sublabel && (
+
{sublabel}
+ )}
+
+ );
+}
diff --git a/src/components/charts/sparkline.tsx b/src/components/charts/sparkline.tsx
new file mode 100644
index 0000000..b9e133e
--- /dev/null
+++ b/src/components/charts/sparkline.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+/**
+ * Sparkline — tiny inline-SVG line chart for KPI cards.
+ * Includes gradient fill below the line + endpoint dot.
+ */
+export function Sparkline({
+ values,
+ color,
+ width = 96,
+ height = 36,
+}: {
+ values: number[];
+ color: string;
+ width?: number;
+ height?: number;
+}) {
+ if (values.length === 0) {
+ return
;
+ }
+ const max = Math.max(...values, 1);
+ const min = Math.min(...values, 0);
+ const range = max - min || 1;
+ const step = values.length > 1 ? width / (values.length - 1) : width;
+ const points = values
+ .map((v, i) => {
+ const x = i * step;
+ const y = height - ((v - min) / range) * height;
+ return `${x.toFixed(2)},${y.toFixed(2)}`;
+ })
+ .join(" ");
+ const areaPoints = `0,${height} ${points} ${width},${height}`;
+ const id = `spark-${color.replace("#", "")}-${Math.round(width)}`;
+ const last = values[values.length - 1];
+ const lastX = (values.length - 1) * step;
+ const lastY = height - ((last - min) / range) * height;
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/custom-theme-modal.tsx b/src/components/custom-theme-modal.tsx
new file mode 100644
index 0000000..b4674da
--- /dev/null
+++ b/src/components/custom-theme-modal.tsx
@@ -0,0 +1,777 @@
+"use client";
+
+import { useState, useEffect, useCallback, useId } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+ Check,
+ AlertTriangle,
+ Wand2,
+ RotateCcw,
+ Trash2,
+ Plus,
+ Sparkles,
+ ChevronDown,
+} from "lucide-react";
+import { cn } from "@/lib/utils";
+import {
+ DEFAULT_CUSTOM_COLORS,
+ autoFixForeground,
+ deriveTokens,
+ getContrastChecks,
+ hexToRgb,
+ loadCustomThemes,
+ newCustomThemeId,
+ presetToColors,
+ saveActiveCustomThemeId,
+ saveCustomThemes,
+ type CustomTheme,
+ type CustomThemeColors,
+} from "@/lib/custom-themes";
+import { THEMES } from "@/lib/themes";
+
+/* ============================================================================
+ * Color field metadata
+ * ========================================================================== */
+const COLOR_FIELDS: {
+ key: keyof CustomThemeColors;
+ label: string;
+}[] = [
+ { key: "primary", label: "Primary" },
+ { key: "accent", label: "Accent" },
+ { key: "destructive", label: "Destructive" },
+ { key: "background", label: "Background" },
+ { key: "sidebar", label: "Sidebar" },
+ { key: "foreground", label: "Foreground" },
+ { key: "border", label: "Border" },
+];
+
+const FIELD_DESCRIPTIONS: Record = {
+ primary: "Buttons, links, active nav",
+ accent: "Highlights, revenue, gold CTAs",
+ destructive: "Errors, over-budget, delete",
+ background: "Main page surface",
+ sidebar: "Left navigation background",
+ foreground: "Body text",
+ border: "Dividers, card outlines",
+};
+
+/* ============================================================================
+ * Modal — sticky header + scrollable body + sticky footer
+ *
+ * Layout:
+ * ┌─ Header (sticky top) ─────────────────────────────┐
+ * ├─ Body (scrollable) ────────┬─ Side panel ─────────┤
+ * │ Editor │ Preview │
+ * │ • Preset chips │ Saved themes │
+ * │ • Color grid (2 cols) │ │
+ * │ • Contrast warnings │ │
+ * │ • Name input │ │
+ * ├─ Footer (sticky bottom) ──────────────────────────┤
+ * │ [Reset] [Cancel] [Save theme] │
+ * └────────────────────────────────────────────────────┘
+ * ========================================================================== */
+export function CustomThemeModal({
+ open,
+ onOpenChange,
+ editingThemeId,
+ onChange,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ editingThemeId?: string | null;
+ onChange?: () => void;
+}) {
+ const [colors, setColors] = useState(DEFAULT_CUSTOM_COLORS);
+ const [name, setName] = useState("My theme");
+ const [editingId, setEditingId] = useState(null);
+ const [snapshot, setSnapshot] = useState | null>(null);
+ const [existingThemes, setExistingThemes] = useState([]);
+ const [presetsOpen, setPresetsOpen] = useState(false);
+
+ /* ---- Lifecycle: load existing + snapshot on open ---- */
+ useEffect(() => {
+ if (!open) return;
+ const list = loadCustomThemes();
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setExistingThemes(list);
+
+ const snapshotTokens = deriveTokensActive();
+ setSnapshot(snapshotTokens);
+
+ if (editingThemeId) {
+ const t = list.find((x) => x.id === editingThemeId);
+ if (t) {
+ setColors(t.colors);
+ setName(t.name);
+ setEditingId(t.id);
+ return;
+ }
+ }
+ const activeId = localStorage.getItem("wfdash-active-custom-id");
+ if (activeId) {
+ const active = list.find((x) => x.id === activeId);
+ if (active) {
+ setColors(active.colors);
+ setName(active.name);
+ setEditingId(active.id);
+ return;
+ }
+ }
+ setColors(DEFAULT_CUSTOM_COLORS);
+ setName("My theme");
+ setEditingId(null);
+ }, [open, editingThemeId]);
+
+ /* ---- Live preview: apply tokens to while editing ---- */
+ useEffect(() => {
+ if (!open) return;
+ const tokens = deriveTokens(colors);
+ const root = document.documentElement;
+ for (const [key, value] of Object.entries(tokens)) {
+ root.style.setProperty(key, value);
+ }
+ root.setAttribute("data-theme", "custom");
+ }, [colors, open]);
+
+ const handleCancel = useCallback(() => {
+ const root = document.documentElement;
+ if (snapshot) {
+ for (const [key, value] of Object.entries(snapshot)) {
+ root.style.setProperty(key, value);
+ }
+ } else {
+ const keys = Object.keys(deriveTokens(DEFAULT_CUSTOM_COLORS));
+ for (const k of keys) root.style.removeProperty(k);
+ root.setAttribute("data-theme", "crisp");
+ }
+ onOpenChange(false);
+ }, [snapshot, onOpenChange]);
+
+ const handleReset = () => {
+ setColors(DEFAULT_CUSTOM_COLORS);
+ setName("My theme");
+ setEditingId(null);
+ };
+
+ const handleSave = () => {
+ const id = editingId ?? newCustomThemeId();
+ const theme: CustomTheme = {
+ id,
+ name: name.trim() || "Untitled theme",
+ colors,
+ createdAt: new Date().toISOString(),
+ };
+ const next = [...existingThemes.filter((t) => t.id !== id), theme];
+ saveCustomThemes(next);
+ saveActiveCustomThemeId(id);
+ setExistingThemes(next);
+ setEditingId(id);
+ onChange?.();
+ onOpenChange(false);
+ };
+
+ const handleDelete = (id: string) => {
+ const next = existingThemes.filter((t) => t.id !== id);
+ saveCustomThemes(next);
+ setExistingThemes(next);
+ if (id === editingId) {
+ setColors(DEFAULT_CUSTOM_COLORS);
+ setName("My theme");
+ setEditingId(null);
+ }
+ onChange?.();
+ };
+
+ const handleLoadFromPreset = (presetId: string) => {
+ const presetColors = presetToColors(presetId);
+ if (presetColors) {
+ setColors(presetColors);
+ setPresetsOpen(false);
+ }
+ };
+
+ const handleAutoFixContrast = () => {
+ const checks = getContrastChecks(colors);
+ const fixed = { ...colors };
+ for (const c of checks) {
+ if (!c.passes) {
+ if (c.label.startsWith("Body text")) {
+ fixed.foreground = autoFixForeground(colors.background);
+ } else if (c.label.startsWith("Sidebar")) {
+ fixed.sidebar = deriveBetterSidebar(fixed.sidebar);
+ } else if (c.label.startsWith("Primary")) {
+ fixed.primary = deriveBetterContrast(colors.primary);
+ } else if (c.label.startsWith("Accent")) {
+ fixed.accent = deriveBetterContrast(colors.accent);
+ }
+ }
+ }
+ setColors(fixed);
+ };
+
+ const contrastChecks = getContrastChecks(colors);
+ const failedChecks = contrastChecks.filter((c) => !c.passes);
+
+ return (
+ (o ? onOpenChange(true) : handleCancel())}
+ >
+
+ {/* ============ HEADER (sticky top) ============ */}
+
+
+
+
+
+
+
+
+ Customize theme
+
+
+
+ Pick 7 colors. The rest is automatic.
+
+
+
+
+
+
+
+
+
+
+ {/* ============ BODY (scrollable middle) ============ */}
+
+
+ {/* EDITOR */}
+
+ {/* Presets — collapsed by default */}
+
setPresetsOpen((e.target as HTMLDetailsElement).open)}
+ className="group rounded-lg border bg-card/40"
+ >
+
+
+ Start from a preset
+
+
+
+
+
+ {THEMES.map((t) => (
+ handleLoadFromPreset(t.id)}
+ className="px-2 py-1 rounded-md border text-[11px] font-medium hover:border-primary/40 hover:bg-accent transition-colors"
+ >
+ {t.label}
+
+ ))}
+
+
+
+
+ {/* Color pickers — 2-column grid at sm+ */}
+
+
+
+ Colors
+
+ {failedChecks.length > 0 && (
+
+
+ {failedChecks.length} issue{failedChecks.length === 1 ? "" : "s"}
+
+ )}
+
+
+ {COLOR_FIELDS.map((f) => (
+
+ setColors((c) => ({ ...c, [f.key]: hex }))
+ }
+ />
+ ))}
+
+
+
+ {/* Contrast warnings — compact inline */}
+ {failedChecks.length > 0 && (
+
+
+
+ Contrast below WCAG AA
+
+
+
+ Auto-fix
+
+
+
+ {failedChecks.map((c) => (
+
+ {c.label}
+
+ {c.ratio.toFixed(1)}:1
+
+
+ ))}
+
+
+ )}
+
+ {/* Theme name */}
+
+
+ Theme name
+
+ setName(e.target.value)}
+ placeholder="My theme"
+ className="mt-1.5"
+ />
+
+
+
+ {/* SIDE PANEL — sticky on md+ */}
+
+ {/* Live preview */}
+
+
+ {/* Saved themes */}
+
+
+
+ Saved themes
+
+
+ {existingThemes.length}
+
+
+
+ {existingThemes.length === 0 ? (
+
+ No saved themes yet
+
+ ) : (
+ existingThemes.map((t) => (
+
{
+ setColors(t.colors);
+ setName(t.name);
+ setEditingId(t.id);
+ }}
+ onDelete={() => handleDelete(t.id)}
+ />
+ ))
+ )}
+
+
{
+ setColors(DEFAULT_CUSTOM_COLORS);
+ setName("New theme");
+ setEditingId(null);
+ }}
+ className="w-full mt-2 gap-1.5 h-8 text-xs"
+ >
+
+ New
+
+
+
+
+
+
+ {/* ============ FOOTER (sticky bottom) ============ */}
+
+
+
+ Reset
+
+
+
+ Cancel
+
+
+
+ {editingId ? "Update theme" : "Save theme"}
+
+
+
+
+
+ );
+}
+
+/* ============================================================================
+ * Helpers — auto-fix math
+ * ========================================================================== */
+function deriveBetterSidebar(current: string): string {
+ const rgb = hexToRgb(current);
+ if (!rgb) return current;
+ const factor = 0.7;
+ const r = Math.round(rgb[0] * factor);
+ const g = Math.round(rgb[1] * factor);
+ const b = Math.round(rgb[2] * factor);
+ const toHex = (n: number) => n.toString(16).padStart(2, "0");
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
+}
+
+function deriveBetterContrast(current: string): string {
+ const rgb = hexToRgb(current);
+ if (!rgb) return current;
+ const lum =
+ (0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) / 255;
+ const target = 0.45;
+ const factor =
+ lum > 0.5 ? target / lum : Math.min(1, (1 - lum) / (1 - target));
+ const safeFactor = Math.max(0.5, Math.min(0.95, factor));
+ const r = Math.round(rgb[0] * safeFactor);
+ const g = Math.round(rgb[1] * safeFactor);
+ const b = Math.round(rgb[2] * safeFactor);
+ const toHex = (n: number) => n.toString(16).padStart(2, "0");
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
+}
+
+function deriveTokensActive(): Record | null {
+ if (typeof window === "undefined") return null;
+ const root = document.documentElement;
+ const inlineStyle = root.style;
+ const keys = Object.keys(deriveTokens(DEFAULT_CUSTOM_COLORS));
+ const out: Record = {};
+ let any = false;
+ for (const k of keys) {
+ const v = inlineStyle.getPropertyValue(k);
+ if (v) {
+ out[k] = v;
+ any = true;
+ }
+ }
+ return any ? out : null;
+}
+
+/* ============================================================================
+ * ColorRow — compact 1-line: swatch + label + hex input
+ * ========================================================================== */
+function ColorRow({
+ fieldKey,
+ label,
+ value,
+ onChange,
+}: {
+ fieldKey: keyof CustomThemeColors;
+ label: string;
+ value: string;
+ onChange: (hex: string) => void;
+}) {
+ const reactId = useId();
+ const inputId = `${reactId}-${fieldKey}`;
+ const [text, setText] = useState(value);
+
+ useEffect(() => {
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setText(value);
+ }, [value]);
+
+ const handleTextChange = (e: React.ChangeEvent) => {
+ const next = e.target.value;
+ setText(next);
+ if (/^#[0-9A-Fa-f]{6}$/.test(next)) onChange(next);
+ };
+
+ const handleNativeChange = (e: React.ChangeEvent) => {
+ onChange(e.target.value);
+ };
+
+ return (
+
+
+
+
+
+ {label}
+
+
+
+ );
+}
+
+/* ============================================================================
+ * SavedThemeRow — compact saved-theme entry in the side panel
+ * ========================================================================== */
+function SavedThemeRow({
+ theme,
+ active,
+ onSelect,
+ onDelete,
+}: {
+ theme: CustomTheme;
+ active: boolean;
+ onSelect: () => void;
+ onDelete: () => void;
+}) {
+ return (
+
+
+
+
+
+
+
+ {theme.name}
+
+
+
+
+
+ );
+}
+
+/* ============================================================================
+ * PreviewMock — compact WFDASH mock using current colors
+ * ========================================================================== */
+function PreviewMock({ colors }: { colors: CustomThemeColors }) {
+ const tokens = deriveTokens(colors);
+ const surfaceStyle = {
+ background: tokens["--background"],
+ color: tokens["--foreground"],
+ borderColor: tokens["--border"],
+ };
+ const sidebarStyle = {
+ background: tokens["--sidebar"],
+ color: tokens["--sidebar-foreground"],
+ };
+ const primaryStyle = {
+ background: tokens["--primary"],
+ color: tokens["--primary-foreground"],
+ };
+ const accentStyle = {
+ background: tokens["--accent"],
+ color: tokens["--accent-foreground"],
+ };
+ return (
+
+
+
+
+ W
+
+ {["Q", "C", "O"].map((_, i) => (
+
+ ))}
+
+
+
+
Dashboard
+
+ + New
+
+
+
+
+
+ Primary
+
+
+ Accent
+
+
+
+
+
+ );
+}
diff --git a/src/components/rewrite-quote-modal.tsx b/src/components/rewrite-quote-modal.tsx
index 85c0a51..83ab26b 100644
--- a/src/components/rewrite-quote-modal.tsx
+++ b/src/components/rewrite-quote-modal.tsx
@@ -32,7 +32,6 @@ import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
-import { Separator } from "@/components/ui/separator";
import {
Loader2,
Sparkles,
@@ -343,8 +342,8 @@ export default function RewriteQuoteModal({
return (
-
-
+
+
Rewrite quote items
@@ -360,11 +359,11 @@ export default function RewriteQuoteModal({
{!results ? (
// ───────────────────────────────────────── Setup state
-
-
+
+
Transformations
-
+
{TRANSFORMATIONS.map((t) => (
-
-
-
+
{items.map((item, i) => {
const included = includedIds.has(item.id);
const opts = perItemOptions[item.id] ?? globalOptions;
@@ -397,7 +394,7 @@ export default function RewriteQuoteModal({
{loading ? (
<>
@@ -602,8 +599,8 @@ function GlobalOptionToggle({
className={cn(
"flex items-start gap-2.5 rounded-md border px-3 py-2 cursor-pointer transition-colors",
checked
- ? "bg-amber-50 border-amber-300 hover:bg-amber-100 dark:bg-amber-950/30 dark:border-amber-700/60 dark:hover:bg-amber-950/50"
- : "bg-background hover:bg-accent/30"
+ ? "bg-primary/8 border-primary/40 hover:bg-primary/12"
+ : "bg-card hover:bg-accent/40 border-border"
)}
data-testid={testId}
>
diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx
index 404239a..e92da3d 100644
--- a/src/components/sidebar.tsx
+++ b/src/components/sidebar.tsx
@@ -1,15 +1,16 @@
-"use client";
-
-import Link from "next/link";
-import { usePathname } from "next/navigation";
-import { useAuth } from "./auth-provider";
-import { useTheme } from "next-themes";
-import {
- LayoutDashboard,
- Users,
- Building2,
- Truck,
- FileText,
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { useAuth } from "./auth-provider";
+import { useTheme } from "next-themes";
+import type { LucideIcon } from "lucide-react";
+import {
+ LayoutDashboard,
+ Users,
+ Building2,
+ Truck,
+ FileText,
ShoppingCart,
LogOut,
Sun,
@@ -22,245 +23,442 @@ import {
Wrench,
ChevronDown,
MapPinned,
+ Settings as SettingsIcon,
} from "lucide-react";
-import { Button } from "@/components/ui/button";
-import { Avatar, AvatarFallback } from "@/components/ui/avatar";
-import { Separator } from "@/components/ui/separator";
-import { ScrollArea } from "@/components/ui/scroll-area";
-import { Sheet, SheetContent, SheetTrigger, SheetTitle } from "@/components/ui/sheet";
-import { useEffect, useState } from "react";
-
-const navItems = [
- { href: "/orders", label: "Orders", icon: ShoppingCart },
- { href: "/collection-pool", label: "Collection Pool", icon: Package },
- { href: "/delivery-notes", label: "Delivery Notes", icon: ClipboardList },
- { href: "/repairs", label: "Repairs", icon: Wrench },
+import { Button } from "@/components/ui/button";
+import { Avatar, AvatarFallback } from "@/components/ui/avatar";
+import { Separator } from "@/components/ui/separator";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Sheet, SheetContent, SheetTrigger, SheetTitle } from "@/components/ui/sheet";
+import { useEffect, useState, type ReactNode } from "react";
+
+/* ============================================================================
+ * CATEGORY SYSTEM
+ *
+ * Sidebar nav is grouped into 4 categories, each with a fixed brand color
+ * so users can scan by color. Colors are intentionally hard-coded — they
+ * stay the same regardless of which theme is active, so the sidebar reads
+ * consistently.
+ *
+ * dashboard → blue (brand)
+ * management → blue (brand)
+ * quotes → gold (brand)
+ * operations → red (brand)
+ * ========================================================================== */
+type CategoryId = "dashboard" | "management" | "quotes" | "operations";
+
+const CATEGORY_COLORS: Record = {
+ dashboard: "#4C96D7",
+ management: "#4C96D7",
+ quotes: "#F2AF58",
+ operations: "#E94E49",
+};
+
+// Helper — produce 8-digit hex with alpha (browser-portable, no color-mix needed)
+function withAlpha(hex: string, alphaPercent: number): string {
+ const a = Math.round((alphaPercent / 100) * 255)
+ .toString(16)
+ .padStart(2, "0");
+ return `${hex}${a}`;
+}
+
+type NavEntry = {
+ href: string;
+ label: string;
+ icon: LucideIcon;
+ category: CategoryId;
+};
+
+const dashboardItems: NavEntry[] = [
+ { href: "/dashboard", label: "Dashboard", icon: LayoutDashboard, category: "dashboard" },
];
-const managementItems = [
- { href: "/customers", label: "Customers", icon: Users },
- { href: "/companies", label: "Companies", icon: Building2 },
- { href: "/suppliers", label: "Suppliers", icon: Truck },
- { href: "/supplier-map", label: "Supplier Map", icon: MapPinned },
+const managementItems: NavEntry[] = [
+ { href: "/customers", label: "Customers", icon: Users, category: "management" },
+ { href: "/companies", label: "Companies", icon: Building2, category: "management" },
+ { href: "/suppliers", label: "Suppliers", icon: Truck, category: "management" },
+ { href: "/supplier-map", label: "Supplier Map", icon: MapPinned, category: "management" },
];
-const quoteItems = [
- { href: "/quotes", label: "All Quotes", icon: FileText },
- { href: "/quotes/approvals", label: "Pending Approvals", icon: CheckCircle },
- { href: "/quotes/archived", label: "Archived", icon: Archive },
+const quoteItems: NavEntry[] = [
+ { href: "/quotes", label: "All Quotes", icon: FileText, category: "quotes" },
+ { href: "/quotes/approvals", label: "Pending Approvals", icon: CheckCircle, category: "quotes" },
+ { href: "/quotes/archived", label: "Archived", icon: Archive, category: "quotes" },
];
-
-function NavContent({ onLinkClick }: { onLinkClick?: () => void }) {
+
+const operationsItems: NavEntry[] = [
+ { href: "/orders", label: "Orders", icon: ShoppingCart, category: "operations" },
+ { href: "/collection-pool", label: "Collection Pool", icon: Package, category: "operations" },
+ { href: "/delivery-notes", label: "Delivery Notes", icon: ClipboardList, category: "operations" },
+ { href: "/repairs", label: "Repairs", icon: Wrench, category: "operations" },
+];
+
+const CATEGORY_LABELS: Record = {
+ dashboard: "Dashboard",
+ management: "Management",
+ quotes: "Quotes",
+ operations: "Operations",
+};
+
+/* ============================================================================
+ * NavItem — single nav link with category-colored icon
+ * ========================================================================== */
+function NavItem({
+ item,
+ active,
+ onClick,
+ indent = false,
+}: {
+ item: NavEntry;
+ active: boolean;
+ onClick?: () => void;
+ indent?: boolean;
+}) {
+ const color = CATEGORY_COLORS[item.category];
+ // Inactive: subtle category tint. Active: white-on-primary from theme.
+ const inactiveStyle = active
+ ? undefined
+ : { color: withAlpha(color, 78) };
+ return (
+
+
+ {item.label}
+
+ );
+}
+
+/* ============================================================================
+ * NavGroup — accordion wrapper tinted with category color
+ * ========================================================================== */
+function NavGroup({
+ category,
+ open,
+ onToggle,
+ headerActive,
+ headerIcon,
+ items,
+ pathname,
+ onLinkClick,
+}: {
+ category: CategoryId;
+ open: boolean;
+ onToggle: () => void;
+ headerActive: boolean;
+ headerIcon: LucideIcon;
+ items: NavEntry[];
+ pathname: string;
+ onLinkClick?: () => void;
+}) {
+ const color = CATEGORY_COLORS[category];
+ const HeaderIcon = headerIcon;
+ return (
+
+
+
+ {CATEGORY_LABELS[category]}
+
+
+ {open && (
+
+ {items.map((item) => {
+ const isActive =
+ item.href === "/quotes"
+ ? pathname === "/quotes"
+ : pathname === item.href || pathname.startsWith(item.href + "/");
+ return (
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
+/* ============================================================================
+ * NavContent — the actual sidebar body
+ * ========================================================================== */
+function NavContent({ onLinkClick }: { onLinkClick?: () => void }) {
const pathname = usePathname();
const { user, logout } = useAuth();
const { theme, setTheme } = useTheme();
- const isManagementRoute = managementItems.some((item) => pathname === item.href || pathname.startsWith(item.href + "/"));
- const isQuotesRoute = pathname === "/quotes" || pathname.startsWith("/quotes/");
- const [managementOpen, setManagementOpen] = useState(isManagementRoute);
- const [quotesOpen, setQuotesOpen] = useState(isQuotesRoute);
+ const isManagementActive = managementItems.some(
+ (item) => pathname === item.href || pathname.startsWith(item.href + "/")
+ );
+ const isQuotesActive =
+ pathname === "/quotes" || pathname.startsWith("/quotes/");
+ const isOperationsActive = operationsItems.some(
+ (item) => pathname === item.href || pathname.startsWith(item.href + "/")
+ );
+
+ const [managementOpen, setManagementOpen] = useState(isManagementActive);
+ const [quotesOpen, setQuotesOpen] = useState(isQuotesActive);
+
+ // Auto-open accordion when route inside it becomes active
useEffect(() => {
- if (isManagementRoute) setManagementOpen(true);
- if (isQuotesRoute) setQuotesOpen(true);
- }, [isManagementRoute, isQuotesRoute]);
-
+ if (isManagementActive) setManagementOpen(true);
+ if (isQuotesActive) setQuotesOpen(true);
+ }, [isManagementActive, isQuotesActive]);
+
+ const isDashboardActive =
+ pathname === "/dashboard" || pathname.startsWith("/dashboard/");
+
return (
+ {/* Brand block */}
-
WFDASH
-
Westcott Fasteners
+
+ WFDASH
+
+
+ Westcott Fasteners
+
+ {/* Brand color stripe */}
-
-
-
+
+
+
+
+ {/* Main nav */}
-
-
+ {/* Dashboard — flat top item, no accordion */}
+
-
- Dashboard
-
+ />
-
-
setManagementOpen((open) => !open)}
- className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground transition-colors hover:bg-background/70 hover:text-foreground"
- >
-
- Management
-
-
- {managementOpen && (
-
- {managementItems.map((item) => {
- const isActive = pathname === item.href || pathname.startsWith(item.href + "/");
- return (
-
-
- {item.label}
-
- );
- })}
-
- )}
+ {/* Management — accordion */}
+
setManagementOpen((o) => !o)}
+ headerActive={isManagementActive}
+ headerIcon={Users}
+ items={managementItems}
+ pathname={pathname}
+ onLinkClick={onLinkClick}
+ />
+
+ {/* Quotes — accordion */}
+ setQuotesOpen((o) => !o)}
+ headerActive={isQuotesActive}
+ headerIcon={FileText}
+ items={quoteItems}
+ pathname={pathname}
+ onLinkClick={onLinkClick}
+ />
+
+ {/* Operations — flat items, but with a tiny colored gutter on the left */}
+
+
+
+
+
+
+ {/* Bottom block: settings (icon-only) + theme + avatar + logout */}
+
+ {/* Settings — small icon-only button bottom-left */}
+
+
+
+
+ {/* Theme toggle */}
+
setTheme(theme === "dark" ? "warm" : "dark")}
+ >
+ {theme === "dark" ? (
+ <>
+
+ Light Mode
+ >
+ ) : (
+ <>
+
+ Dark Mode
+ >
+ )}
+
+
+ {/* User */}
+
+
+
+ {user?.name?.charAt(0)?.toUpperCase() ||
+ user?.email?.charAt(0)?.toUpperCase() ||
+ "U"}
+
+
+
+
+ {user?.name || "User"}
+
+
+ {user?.email}
+
+
-
-
setQuotesOpen((open) => !open)}
- className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-xs font-semibold uppercase tracking-wide transition-colors ${
- isQuotesRoute
- ? "text-primary"
- : "text-muted-foreground hover:bg-background/70 hover:text-foreground"
- }`}
- >
-
- Quotes
-
-
- {quotesOpen && (
-
- {quoteItems.map((item) => {
- const isActive = item.href === "/quotes"
- ? pathname === "/quotes"
- : pathname === item.href || pathname.startsWith(item.href + "/");
- return (
-
-
- {item.label}
-
- );
- })}
-
- )}
-
-
- {navItems.map((item) => {
- const isActive = pathname === item.href || pathname.startsWith(item.href + "/");
- return (
-
-
-
- {item.label}
-
-
- );
- })}
-
-
-
-
-
setTheme(theme === "dark" ? "warm" : "dark")}
- >
- {theme === "dark" ? (
- <>
-
- Light Mode
- >
- ) : (
- <>
-
- Dark Mode
- >
- )}
-
-
-
-
- {user?.name?.charAt(0)?.toUpperCase() || user?.email?.charAt(0)?.toUpperCase() || "U"}
-
-
-
-
{user?.name || "User"}
-
{user?.email}
-
-
-
-
- Logout
-
-
-
- );
-}
-
-export function Sidebar() {
- const [open, setOpen] = useState(false);
-
- return (
- <>
-
-
-
-
- }
- >
-
-
-
- Navigation
- setOpen(false)} />
-
-
-
WFDASH
-
- >
- );
+ {/* Logout */}
+
+
+ Logout
+
+
+
+ );
+}
+
+/* ============================================================================
+ * OperationsList — flat items with a 4px red left gutter
+ *
+ * Operations is the only flat category. To make it visually consistent with
+ * the accordion groups above, we wrap it in a thin red gutter that hints
+ * at the category color without painting a full background.
+ * ========================================================================== */
+function OperationsList({
+ active,
+ pathname,
+ onLinkClick,
+}: {
+ active: boolean;
+ pathname: string;
+ onLinkClick?: () => void;
+}): ReactNode {
+ const color = CATEGORY_COLORS.operations;
+ return (
+
+ {/* Tiny header label so the category is identifiable */}
+
+
+ Operations
+
+ {operationsItems.map((item) => {
+ const isActive =
+ pathname === item.href || pathname.startsWith(item.href + "/");
+ return (
+
+ );
+ })}
+
+ );
+}
+
+/* ============================================================================
+ * Sidebar — desktop + mobile (Sheet) wrappers
+ * ========================================================================== */
+export function Sidebar() {
+ const [open, setOpen] = useState(false);
+
+ return (
+ <>
+
+
+
+
+ }
+ >
+
+
+
+ Navigation
+ setOpen(false)} />
+
+
+
WFDASH
+
+ >
+ );
}
diff --git a/src/components/supplier-map.tsx b/src/components/supplier-map.tsx
index 8021e52..a35aec3 100644
--- a/src/components/supplier-map.tsx
+++ b/src/components/supplier-map.tsx
@@ -10,7 +10,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Maximize2, Mail, MapPin, Minimize2, Move, Phone, RefreshCw, Search, Tag } from "lucide-react";
import { toast } from "sonner";
-import { getAllSuppliers } from "@/lib/pb-helpers";
+import { getMappableSuppliers } from "@/lib/pb-helpers";
import getPocketBase from "@/lib/pocketbase";
import type { Supplier } from "@/lib/types";
@@ -30,7 +30,15 @@ const DEFAULT_CENTER: [number, number] = [-26.2041, 28.0473];
const CLUSTER_DISTANCE_KM = 8;
const CLUSTER_ZOOM_BREAKPOINT = 12;
const LABEL_ZOOM_BREAKPOINT = 12;
+/**
+ * Max number of permanent supplier-name labels visible on the map at
+ * once. Picked by distance to viewport centre — keeps the DOM light
+ * when zoomed in over a dense cluster of pins.
+ */
+const LABEL_LIMIT = 24;
const CLUSTER_COLORS = ["#1B76BB", "#E64B4B", "#F4B842", "#169B62", "#7C3AED", "#EA580C", "#0F766E"];
+/** Stable empty Set so consumers can `has()` without re-creating it. */
+const EMPTY_LABELED_IDS = new Set();
const LIGHT_TILE_URL = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
const DARK_TILE_URL = "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png";
const LIGHT_TILE_ATTRIBUTION = '© OpenStreetMap ';
@@ -125,7 +133,15 @@ function buildClusters(points: SupplierPoint[], distanceKmLimit = CLUSTER_DISTAN
return clusters;
}
-function MapWatcher({ isFullscreen, onZoomChange }: { isFullscreen: boolean; onZoomChange: (zoom: number) => void }) {
+function MapWatcher({
+ isFullscreen,
+ onZoomChange,
+ onViewportChange,
+}: {
+ isFullscreen: boolean;
+ onZoomChange: (zoom: number) => void;
+ onViewportChange: (viewport: { center: [number, number]; zoom: number }) => void;
+}) {
const map = useMap();
useEffect(() => {
@@ -133,7 +149,18 @@ function MapWatcher({ isFullscreen, onZoomChange }: { isFullscreen: boolean; onZ
}, [isFullscreen, map]);
useMapEvents({
- zoomend: (event) => onZoomChange(event.target.getZoom()),
+ zoomend: (event) => {
+ const target = event.target;
+ const zoom = target.getZoom();
+ const center = target.getCenter();
+ onZoomChange(zoom);
+ onViewportChange({ center: [center.lat, center.lng], zoom });
+ },
+ moveend: (event) => {
+ const target = event.target;
+ const center = target.getCenter();
+ onViewportChange({ center: [center.lat, center.lng], zoom: target.getZoom() });
+ },
});
return null;
@@ -191,6 +218,7 @@ export function SupplierMap() {
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [zoom, setZoom] = useState(10);
+ const [viewport, setViewport] = useState<{ center: [number, number]; zoom: number } | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [editMode, setEditMode] = useState(false);
const isDarkMap = resolvedTheme === "dark";
@@ -198,7 +226,7 @@ export function SupplierMap() {
const fetchSuppliers = async () => {
setLoading(true);
try {
- setSuppliers(await getAllSuppliers());
+ setSuppliers(await getMappableSuppliers());
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to load suppliers");
} finally {
@@ -258,6 +286,11 @@ export function SupplierMap() {
}, [search, suppliers]);
const clusterDistanceKm = clusterDistanceForZoom(zoom);
+ // `clusterDistanceKm` only changes when zoom crosses one of
+ // {7, 9, 11} (see clusterDistanceForZoom). Dep'ing on the bucket
+ // value — not on zoom — keeps `buildClusters` from re-running on
+ // every wheel tick. If you change the cluster distance function,
+ // keep this bucketed.
const clusters = useMemo(() => buildClusters(mapped, clusterDistanceKm), [clusterDistanceKm, mapped]);
const showIndividualSuppliers = zoom >= CLUSTER_ZOOM_BREAKPOINT;
const showLabels = zoom >= LABEL_ZOOM_BREAKPOINT;
@@ -268,8 +301,34 @@ export function SupplierMap() {
] as [number, number])
: DEFAULT_CENTER;
- const clusterBySupplierId = new Map();
- clusters.forEach((cluster) => cluster.suppliers.forEach((supplier) => clusterBySupplierId.set(supplier.id, cluster)));
+ const clusterBySupplierId = useMemo(() => {
+ const m = new Map();
+ for (const cluster of clusters) {
+ for (const supplier of cluster.suppliers) {
+ m.set(supplier.id, cluster);
+ }
+ }
+ return m;
+ }, [clusters]);
+
+ /**
+ * IDs of the suppliers that should render a permanent name label
+ * at the current zoom. Top LABEL_LIMIT by squared distance to the
+ * viewport centre — cheap, no sqrt, and "centre of viewport" is a
+ * good enough proxy for "what the user is actually looking at".
+ */
+ const labeledIds = useMemo(() => {
+ if (!showLabels || !viewport) return EMPTY_LABELED_IDS;
+ const [cLat, cLng] = viewport.center;
+ const ranked = mapped
+ .map((supplier) => ({
+ id: supplier.id,
+ d: (supplier.lat - cLat) ** 2 + (supplier.lng - cLng) ** 2,
+ }))
+ .sort((a, b) => a.d - b.d)
+ .slice(0, LABEL_LIMIT);
+ return new Set(ranked.map((entry) => entry.id));
+ }, [mapped, showLabels, viewport]);
return (
@@ -328,8 +387,8 @@ export function SupplierMap() {
-
-
+
+
{editMode && (
@@ -349,7 +408,11 @@ export function SupplierMap() {
-
+
- {showLabels && (
+ {showLabels && labeledIds.has(supplier.id) && (
{supplier.suppliername || "Unnamed supplier"}
diff --git a/src/components/theme-bootstrap.tsx b/src/components/theme-bootstrap.tsx
new file mode 100644
index 0000000..f1d2374
--- /dev/null
+++ b/src/components/theme-bootstrap.tsx
@@ -0,0 +1,95 @@
+/**
+ * Inline script that runs in before hydration.
+ *
+ * Reads the persisted theme id + mode from localStorage and applies them
+ * to so the user never sees a flash of the default theme.
+ *
+ * MUST stay dependency-free — it's bundled as a string and inlined.
+ */
+export function ThemeBootstrapScript() {
+ const code = `
+ (function() {
+ try {
+ var themeId = localStorage.getItem('wfdash-theme') || 'crisp';
+ var mode = localStorage.getItem('wfdash-mode') || 'warm';
+ var root = document.documentElement;
+ root.setAttribute('data-theme', themeId);
+ root.classList.remove('warm', 'dark');
+ root.classList.add(mode);
+ var radiusMap = {
+ 'crisp': '0.625rem',
+ 'midnight': '0.75rem',
+ 'editorial': '0.375rem',
+ 'bento': '1.125rem',
+ 'field-ops': '0.5rem',
+ 'terminal': '0',
+ 'blueprint': '0.125rem',
+ 'confetti': '1.25rem'
+ };
+ if (radiusMap[themeId]) root.style.setProperty('--radius', radiusMap[themeId]);
+
+ // If the active theme is a custom theme, apply its inline token overrides
+ // so the page renders correctly before React hydrates.
+ var activeCustomId = localStorage.getItem('wfdash-active-custom-id');
+ if (activeCustomId) {
+ try {
+ var raw = localStorage.getItem('wfdash-custom-themes');
+ if (raw) {
+ var list = JSON.parse(raw);
+ var found = null;
+ for (var i = 0; i < list.length; i++) {
+ if (list[i].id === activeCustomId) { found = list[i]; break; }
+ }
+ if (found && found.colors) {
+ var c = found.colors;
+ // Direct mappings
+ root.style.setProperty('--background', c.background);
+ root.style.setProperty('--foreground', c.foreground);
+ root.style.setProperty('--primary', c.primary);
+ root.style.setProperty('--accent', c.accent);
+ root.style.setProperty('--destructive', c.destructive);
+ root.style.setProperty('--sidebar', c.sidebar);
+ root.style.setProperty('--border', c.border);
+ // Contrast foregrounds
+ function lum(hex){
+ var r = parseInt(hex.slice(1,3),16)/255;
+ var g = parseInt(hex.slice(3,5),16)/255;
+ var b = parseInt(hex.slice(5,7),16)/255;
+ var f = function(v){return v<=0.03928 ? v/12.92 : Math.pow((v+0.055)/1.055,2.4);};
+ return 0.2126*f(r)+0.7152*f(g)+0.0722*f(b);
+ }
+ function fgFor(bg){ return lum(bg) > 0.55 ? '#0F172A' : '#FFFFFF'; }
+ root.style.setProperty('--primary-foreground', fgFor(c.primary));
+ root.style.setProperty('--accent-foreground', fgFor(c.accent));
+ root.style.setProperty('--destructive-foreground', fgFor(c.destructive));
+ root.style.setProperty('--sidebar-foreground', fgFor(c.sidebar));
+ // Derived
+ function hexA(hex,a){return hex+(Math.round(a*2.55).toString(16).padStart(2,'0'));}
+ function mix(a,b,p){var ar=parseInt(a.slice(1,3),16),ag=parseInt(a.slice(3,5),16),ab=parseInt(a.slice(5,7),16),br=parseInt(b.slice(1,3),16),bg=parseInt(b.slice(3,5),16),bb=parseInt(b.slice(5,7),16);var k=p/100;var r=Math.round(ar*k+br*(1-k)),g=Math.round(ag*k+bg*(1-k)),bb2=Math.round(ab*k+bb*(1-k));var h=function(n){return n.toString(16).padStart(2,'0');};return '#'+h(r)+h(g)+h(bb2);}
+ root.style.setProperty('--card', mix('#FFFFFF', c.background, 2));
+ root.style.setProperty('--popover', mix('#FFFFFF', c.background, 2));
+ root.style.setProperty('--muted', mix('#000000', c.background, 4));
+ root.style.setProperty('--secondary', mix('#000000', c.background, 3));
+ root.style.setProperty('--card-foreground', c.foreground);
+ root.style.setProperty('--popover-foreground', c.foreground);
+ root.style.setProperty('--secondary-foreground', c.foreground);
+ root.style.setProperty('--muted-foreground', mix('#94A3B8', c.foreground, 60));
+ root.style.setProperty('--input', c.border);
+ root.style.setProperty('--ring', c.primary);
+ root.style.setProperty('--sidebar-primary', c.primary);
+ root.style.setProperty('--sidebar-primary-foreground', fgFor(c.primary));
+ root.style.setProperty('--sidebar-accent', mix('#FFFFFF', c.sidebar, 8));
+ root.style.setProperty('--sidebar-accent-foreground', fgFor(c.sidebar));
+ root.style.setProperty('--sidebar-border', mix('#FFFFFF', c.sidebar, 12));
+ root.style.setProperty('--sidebar-ring', c.primary);
+ }
+ }
+ } catch(e2) { /* ignore */ }
+ }
+ } catch (e) {
+ // localStorage may be blocked — fall back to defaults silently
+ }
+ })();
+ `;
+ return ;
+}
diff --git a/src/components/theme-picker.tsx b/src/components/theme-picker.tsx
new file mode 100644
index 0000000..436e38a
--- /dev/null
+++ b/src/components/theme-picker.tsx
@@ -0,0 +1,395 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useTheme } from "next-themes";
+import { THEMES } from "@/lib/themes";
+import { useThemeId } from "@/lib/use-theme-id";
+import {
+ applyCustomThemeToDocument,
+ CUSTOM_THEME_ID,
+ loadCustomThemes,
+ saveActiveCustomThemeId,
+ type CustomTheme,
+} from "@/lib/custom-themes";
+import { CustomThemeModal } from "@/components/custom-theme-modal";
+import { Check, Moon, Sun, Sparkles, Pencil } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+/**
+ * ThemePicker — settings UI for picking visual theme + warm/dark mode.
+ *
+ * - Mode toggle (light/dark) — independent of theme
+ * - 8 preset themes
+ * - Custom themes (user-created) listed below
+ * - "Customize" button opens CustomThemeModal
+ */
+export function ThemePicker() {
+ const { theme: mode, setTheme: setMode } = useTheme();
+ const { themeId, setThemeId } = useThemeId();
+ const [customThemes, setCustomThemes] = useState([]);
+ const [modalOpen, setModalOpen] = useState(false);
+ const [editingCustomId, setEditingCustomId] = useState(null);
+
+ const refreshCustomThemes = () => {
+ setCustomThemes(loadCustomThemes());
+ };
+
+ useEffect(() => {
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ refreshCustomThemes();
+ }, []);
+
+ const standardThemes = THEMES.filter((t) => t.category === "standard");
+ const standoutThemes = THEMES.filter((t) => t.category === "standout");
+
+ const handleSelectCustom = (id: string) => {
+ const theme = customThemes.find((t) => t.id === id);
+ if (!theme) return;
+ applyCustomThemeToDocument(theme);
+ saveActiveCustomThemeId(id);
+ setThemeId(CUSTOM_THEME_ID);
+ refreshCustomThemes();
+ };
+
+ const handleSelectPreset = (id: string) => {
+ // Switch away from any custom theme
+ saveActiveCustomThemeId(null);
+ // Remove inline custom-theme overrides
+ applyCustomThemeToDocument(null);
+ setThemeId(id);
+ };
+
+ return (
+
+ {/* Mode toggle */}
+
+
+ Mode
+
+
+ setMode("warm")}
+ className={cn(
+ "flex items-center justify-center gap-2 rounded-lg border px-4 py-2.5 text-sm font-medium transition-colors",
+ mode === "warm"
+ ? "border-primary bg-primary text-primary-foreground"
+ : "border-border bg-card hover:bg-accent"
+ )}
+ >
+
+ Light
+
+ setMode("dark")}
+ className={cn(
+ "flex items-center justify-center gap-2 rounded-lg border px-4 py-2.5 text-sm font-medium transition-colors",
+ mode === "dark"
+ ? "border-primary bg-primary text-primary-foreground"
+ : "border-border bg-card hover:bg-accent"
+ )}
+ >
+
+ Dark
+
+
+
+
+ {/* Standard themes */}
+
+
+ Standard themes
+
+ · ship cleanly with shadcn/ui
+
+
+
+ {standardThemes.map((t) => (
+ handleSelectPreset(t.id)}
+ />
+ ))}
+
+
+
+ {/* Standout themes */}
+
+
+
+ Standout themes
+
+ · for users who want to stand out
+
+
+
+ {standoutThemes.map((t) => (
+ handleSelectPreset(t.id)}
+ />
+ ))}
+
+
+
+ {/* Custom themes */}
+
+
+
+
+ Custom themes
+
+ · your saved themes
+
+
+
{
+ setEditingCustomId(null);
+ setModalOpen(true);
+ }}
+ className="inline-flex items-center gap-1.5 rounded-md border border-primary/30 bg-primary/8 px-2.5 py-1.5 text-xs font-semibold text-primary hover:bg-primary/15 transition-colors"
+ >
+
+ Customize
+
+
+
+ {customThemes.length === 0 ? (
+
+ No custom themes yet. Click Customize to create
+ one from a preset or from scratch.
+
+ ) : (
+
+ {customThemes.map((t) => (
+
handleSelectCustom(t.id)}
+ className={cn(
+ "group relative flex flex-col gap-2 rounded-xl border-2 bg-card p-3 text-left transition-all hover:shadow-md",
+ themeId === CUSTOM_THEME_ID
+ ? "border-primary shadow-sm"
+ : "border-border hover:border-primary/40"
+ )}
+ >
+
+ {/* Mini preview */}
+
+ {themeId === CUSTOM_THEME_ID && (
+
+
+
+ )}
+
+
+
+ {t.name}
+
+ Custom
+
+
+
+ {
+ e.stopPropagation();
+ setEditingCustomId(t.id);
+ setModalOpen(true);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.stopPropagation();
+ setEditingCustomId(t.id);
+ setModalOpen(true);
+ }
+ }}
+ className="absolute top-2 right-2 grid h-7 w-7 place-items-center rounded-md bg-background/90 border border-border opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer hover:bg-accent"
+ >
+
+
+
+ ))}
+
+ )}
+
+
+
{
+ setModalOpen(o);
+ if (!o) {
+ setEditingCustomId(null);
+ refreshCustomThemes();
+ }
+ }}
+ editingThemeId={editingCustomId}
+ onChange={refreshCustomThemes}
+ />
+
+ );
+}
+
+function ThemeCard({
+ theme,
+ active,
+ onSelect,
+}: {
+ theme: (typeof THEMES)[number];
+ active: boolean;
+ onSelect: () => void;
+}) {
+ return (
+
+ {/* Mini preview */}
+
+ {/* Fake window chrome */}
+
+
+
+
+
+ {/* Fake content */}
+
+ {active && (
+
+
+
+ )}
+
+ {/* Label + tagline */}
+
+
+
{theme.label}
+ {theme.category === "standout" && (
+
+
+ Creative
+
+ )}
+
+
{theme.tagline}
+
+
+ );
+}
diff --git a/src/components/theme-provider.tsx b/src/components/theme-provider.tsx
index 4a113bb..c5dfd95 100644
--- a/src/components/theme-provider.tsx
+++ b/src/components/theme-provider.tsx
@@ -11,6 +11,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
themes={["warm", "dark"]}
enableSystem={false}
disableTransitionOnChange
+ storageKey="wfdash-mode"
>
{children}
diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx
new file mode 100644
index 0000000..c46cd64
--- /dev/null
+++ b/src/components/ui/checkbox.tsx
@@ -0,0 +1,34 @@
+"use client"
+
+import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
+import { Check } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function Checkbox({
+ className,
+ ...props
+}: CheckboxPrimitive.Root.Props) {
+ return (
+
+
+
+
+
+ )
+}
+
+export { Checkbox }
diff --git a/src/lib/custom-themes.ts b/src/lib/custom-themes.ts
new file mode 100644
index 0000000..268b04b
--- /dev/null
+++ b/src/lib/custom-themes.ts
@@ -0,0 +1,416 @@
+/**
+ * Custom theme system — user-defined color themes stored in localStorage.
+ *
+ * The user picks 7 colors (primary, accent, destructive, background, sidebar,
+ * foreground, border). Everything else (~25 derived tokens) is computed
+ * automatically using simple luminance + tint math.
+ *
+ * When a custom theme is active, all CSS custom properties are written as
+ * inline style on , which overrides the preset theme selectors.
+ * `data-theme="custom"` is set so other code can branch on it.
+ */
+
+export type CustomThemeColors = {
+ primary: string;
+ accent: string;
+ destructive: string;
+ background: string;
+ sidebar: string;
+ foreground: string;
+ border: string;
+};
+
+export type CustomTheme = {
+ id: string;
+ name: string;
+ colors: CustomThemeColors;
+ createdAt: string;
+};
+
+/* ============================================================================
+ * Defaults — start from the Crisp theme's tokens
+ * ========================================================================== */
+export const DEFAULT_CUSTOM_COLORS: CustomThemeColors = {
+ primary: "#4C96D7",
+ accent: "#F2AF58",
+ destructive: "#E94E49",
+ background: "#F8FAFC",
+ sidebar: "#0F2E4C",
+ foreground: "#0F172A",
+ border: "#E2E8F0",
+};
+
+export const CUSTOM_THEME_ID = "custom";
+export const STORAGE_KEY_CUSTOM_THEMES = "wfdash-custom-themes";
+export const STORAGE_KEY_ACTIVE_CUSTOM_ID = "wfdash-active-custom-id";
+
+/* ============================================================================
+ * Storage helpers
+ * ========================================================================== */
+export function loadCustomThemes(): CustomTheme[] {
+ if (typeof window === "undefined") return [];
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY_CUSTOM_THEMES);
+ if (!raw) return [];
+ const parsed = JSON.parse(raw);
+ if (!Array.isArray(parsed)) return [];
+ return parsed as CustomTheme[];
+ } catch {
+ return [];
+ }
+}
+
+export function saveCustomThemes(themes: CustomTheme[]): void {
+ if (typeof window === "undefined") return;
+ try {
+ localStorage.setItem(STORAGE_KEY_CUSTOM_THEMES, JSON.stringify(themes));
+ } catch {
+ /* ignore */
+ }
+}
+
+export function loadActiveCustomThemeId(): string | null {
+ if (typeof window === "undefined") return null;
+ try {
+ return localStorage.getItem(STORAGE_KEY_ACTIVE_CUSTOM_ID);
+ } catch {
+ return null;
+ }
+}
+
+export function saveActiveCustomThemeId(id: string | null): void {
+ if (typeof window === "undefined") return;
+ try {
+ if (id) localStorage.setItem(STORAGE_KEY_ACTIVE_CUSTOM_ID, id);
+ else localStorage.removeItem(STORAGE_KEY_ACTIVE_CUSTOM_ID);
+ } catch {
+ /* ignore */
+ }
+}
+
+/* ============================================================================
+ * Color math — WCAG luminance + simple tint/shade helpers
+ * ========================================================================== */
+export function hexToRgb(hex: string): [number, number, number] | null {
+ const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex.trim());
+ if (!m) return null;
+ return [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)];
+}
+
+function relativeLuminance(r: number, g: number, b: number): number {
+ const channel = (c: number) => {
+ const v = c / 255;
+ return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
+ };
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
+}
+
+/** WCAG contrast ratio between two hex colors */
+export function contrastRatio(hex1: string, hex2: string): number {
+ const a = hexToRgb(hex1);
+ const b = hexToRgb(hex2);
+ if (!a || !b) return 1;
+ const l1 = relativeLuminance(...a);
+ const l2 = relativeLuminance(...b);
+ const lighter = Math.max(l1, l2);
+ const darker = Math.min(l1, l2);
+ return (lighter + 0.05) / (darker + 0.05);
+}
+
+/** Pick white or black for best contrast on a given background */
+export function bestContrastFg(bgHex: string): string {
+ const bg = hexToRgb(bgHex);
+ if (!bg) return "#ffffff";
+ const lum = relativeLuminance(...bg);
+ return lum > 0.55 ? "#0F172A" : "#FFFFFF";
+}
+
+/** Produce 8-digit hex with alpha (for "soft" tints used in chips/badges) */
+export function withAlpha(hex: string, alphaPercent: number): string {
+ const a = Math.round((alphaPercent / 100) * 255)
+ .toString(16)
+ .padStart(2, "0");
+ // Normalize to #RRGGBB
+ const cleaned = hex.replace("#", "");
+ if (cleaned.length === 3) {
+ const full =
+ cleaned[0] + cleaned[0] + cleaned[1] + cleaned[1] + cleaned[2] + cleaned[2];
+ return `#${full}${a}`;
+ }
+ return `#${cleaned}${a}`;
+}
+
+/**
+ * Mix two hex colors at a percentage. `mix(a, b, 30)` = 30% a, 70% b.
+ * Returns #RRGGBB.
+ */
+export function mix(a: string, b: string, aPercent: number): string {
+ const ra = hexToRgb(a);
+ const rb = hexToRgb(b);
+ if (!ra || !rb) return a;
+ const p = aPercent / 100;
+ const r = Math.round(ra[0] * p + rb[0] * (1 - p));
+ const g = Math.round(ra[1] * p + rb[1] * (1 - p));
+ const bl = Math.round(ra[2] * p + rb[2] * (1 - p));
+ const toHex = (n: number) => n.toString(16).padStart(2, "0");
+ return `#${toHex(r)}${toHex(g)}${toHex(bl)}`;
+}
+
+/* ============================================================================
+ * Full token derivation
+ *
+ * Given the 7 user-picked colors, compute the full token set (~25 vars)
+ * that shadcn/ui + the theme system expects. All in OKLCH-friendly hex.
+ * ========================================================================== */
+export function deriveTokens(colors: CustomThemeColors): Record {
+ const { primary, accent, destructive, background, sidebar, foreground, border } = colors;
+
+ // Foreground on each colored surface
+ const primaryFg = bestContrastFg(primary);
+ const accentFg = bestContrastFg(accent);
+ const destructiveFg = bestContrastFg(destructive);
+ const sidebarFg = bestContrastFg(sidebar);
+
+ // Slight tints derived from background for card / popover / muted
+ const card = mix("#FFFFFF", background, 2); // barely lighter than bg
+ const muted = mix("#000000", background, 4); // barely darker
+ const mutedFg = mix("#94A3B8", foreground, 60);
+ const secondary = mix("#000000", background, 3);
+
+ // Sidebar accents — slightly tinted sidebar surface for hover/active states
+ const sidebarAccent = mix("#FFFFFF", sidebar, 8);
+ const sidebarBorder = mix("#FFFFFF", sidebar, 12);
+
+ return {
+ "--background": background,
+ "--foreground": foreground,
+ "--card": card,
+ "--card-foreground": foreground,
+ "--popover": card,
+ "--popover-foreground": foreground,
+ "--primary": primary,
+ "--primary-foreground": primaryFg,
+ "--secondary": secondary,
+ "--secondary-foreground": foreground,
+ "--muted": muted,
+ "--muted-foreground": mutedFg,
+ "--accent": accent,
+ "--accent-foreground": accentFg,
+ "--destructive": destructive,
+ "--destructive-foreground": destructiveFg,
+ "--border": border,
+ "--input": border,
+ "--ring": primary,
+ "--sidebar": sidebar,
+ "--sidebar-foreground": sidebarFg,
+ "--sidebar-primary": primary,
+ "--sidebar-primary-foreground": primaryFg,
+ "--sidebar-accent": sidebarAccent,
+ "--sidebar-accent-foreground": sidebarFg,
+ "--sidebar-border": sidebarBorder,
+ "--sidebar-ring": primary,
+ };
+}
+
+/* ============================================================================
+ * Apply custom theme to
+ * ========================================================================== */
+export function applyCustomThemeToDocument(theme: CustomTheme | null): void {
+ if (typeof document === "undefined") return;
+ const root = document.documentElement;
+ if (!theme) {
+ root.removeAttribute("data-theme");
+ root.style.removeProperty("data-theme");
+ // Clear all --xxx props we previously set
+ const keys = Object.keys(deriveTokens(DEFAULT_CUSTOM_COLORS));
+ for (const k of keys) root.style.removeProperty(k);
+ // Restore radius
+ root.style.removeProperty("--radius");
+ return;
+ }
+ root.setAttribute("data-theme", "custom");
+ const tokens = deriveTokens(theme.colors);
+ for (const [key, value] of Object.entries(tokens)) {
+ root.style.setProperty(key, value);
+ }
+ root.style.setProperty("--radius", "0.625rem");
+}
+
+/* ============================================================================
+ * Load + apply persisted custom theme (called from bootstrap script)
+ * ========================================================================== */
+export function bootstrapCustomTheme(): CustomTheme | null {
+ if (typeof window === "undefined") return null;
+ const id = loadActiveCustomThemeId();
+ if (!id) return null;
+ const themes = loadCustomThemes();
+ const theme = themes.find((t) => t.id === id) ?? null;
+ if (theme) applyCustomThemeToDocument(theme);
+ return theme;
+}
+
+/* ============================================================================
+ * Helpers for the modal
+ * ========================================================================== */
+export function newCustomThemeId(): string {
+ return `custom_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
+}
+
+export function presetToColors(themeId: string): CustomThemeColors | null {
+ switch (themeId) {
+ case "crisp":
+ return {
+ primary: "#4C96D7",
+ accent: "#F2AF58",
+ destructive: "#E94E49",
+ background: "#F8FAFC",
+ sidebar: "#0F2E4C",
+ foreground: "#0F172A",
+ border: "#E2E8F0",
+ };
+ case "midnight":
+ return {
+ primary: "#4C96D7",
+ accent: "#F2AF58",
+ destructive: "#E94E49",
+ background: "#0B1220",
+ sidebar: "#05080F",
+ foreground: "#E6ECF5",
+ border: "#1F2B47",
+ };
+ case "editorial":
+ return {
+ primary: "#1E5A99",
+ accent: "#B8842C",
+ destructive: "#C0392B",
+ background: "#FBF8F3",
+ sidebar: "#0F2E4C",
+ foreground: "#0F2E4C",
+ border: "#E8DFCB",
+ };
+ case "bento":
+ return {
+ primary: "#4C96D7",
+ accent: "#F2AF58",
+ destructive: "#E94E49",
+ background: "#F4F6FB",
+ sidebar: "#0F2E4C",
+ foreground: "#0F172A",
+ border: "#E2E8F0",
+ };
+ case "field-ops":
+ return {
+ primary: "#0F2E4C",
+ accent: "#F2AF58",
+ destructive: "#E94E49",
+ background: "#F8FAFC",
+ sidebar: "#0F2E4C",
+ foreground: "#0F172A",
+ border: "#E2E8F0",
+ };
+ case "terminal":
+ return {
+ primary: "#7EE787",
+ accent: "#F2AF58",
+ destructive: "#FF7B72",
+ background: "#0D1117",
+ sidebar: "#010409",
+ foreground: "#C9D1D9",
+ border: "#30363D",
+ };
+ case "blueprint":
+ return {
+ primary: "#FFFFFF",
+ accent: "#F2AF58",
+ destructive: "#FF6B6B",
+ background: "#0B3B8C",
+ sidebar: "#082D6B",
+ foreground: "#FFFFFF",
+ border: "#1F5BB8",
+ };
+ case "confetti":
+ return {
+ primary: "#7AB6E5",
+ accent: "#F8D89A",
+ destructive: "#F0A19D",
+ background: "#FFF7F0",
+ sidebar: "#F8D89A",
+ foreground: "#2A3958",
+ border: "#F0DCC2",
+ };
+ default:
+ return null;
+ }
+}
+
+/* ============================================================================
+ * Contrast warnings
+ * ========================================================================== */
+export type ContrastCheck = {
+ label: string;
+ fg: string;
+ bg: string;
+ ratio: number;
+ /** WCAG AA minimum for normal text is 4.5:1 */
+ passes: boolean;
+};
+
+export function getContrastChecks(colors: CustomThemeColors): ContrastCheck[] {
+ const checks: ContrastCheck[] = [
+ {
+ label: "Body text on background",
+ fg: colors.foreground,
+ bg: colors.background,
+ ratio: contrastRatio(colors.foreground, colors.background),
+ passes: false,
+ },
+ {
+ label: "Primary button text",
+ fg: bestContrastFg(colors.primary),
+ bg: colors.primary,
+ ratio: contrastRatio(bestContrastFg(colors.primary), colors.primary),
+ passes: false,
+ },
+ {
+ label: "Sidebar text on sidebar",
+ fg: bestContrastFg(colors.sidebar),
+ bg: colors.sidebar,
+ ratio: contrastRatio(bestContrastFg(colors.sidebar), colors.sidebar),
+ passes: false,
+ },
+ {
+ label: "Accent button text",
+ fg: bestContrastFg(colors.accent),
+ bg: colors.accent,
+ ratio: contrastRatio(bestContrastFg(colors.accent), colors.accent),
+ passes: false,
+ },
+ ];
+ for (const c of checks) c.passes = c.ratio >= 4.5;
+ return checks;
+}
+
+/** Suggest an auto-fixed foreground for a given background */
+export function autoFixForeground(bgHex: string): string {
+ // Walk lightness up/down to find a foreground that hits 4.5:1
+ const bg = hexToRgb(bgHex);
+ if (!bg) return "#FFFFFF";
+ const candidates = [
+ "#FFFFFF",
+ "#0F172A",
+ "#F8FAFC",
+ "#E2E8F0",
+ "#1E293B",
+ "#94A3B8",
+ ];
+ let best = candidates[0];
+ let bestRatio = 0;
+ for (const c of candidates) {
+ const r = contrastRatio(bgHex, c);
+ if (r > bestRatio) {
+ bestRatio = r;
+ best = c;
+ }
+ }
+ return best;
+}
diff --git a/src/lib/pb-helpers.ts b/src/lib/pb-helpers.ts
index 294a731..b3532b6 100644
--- a/src/lib/pb-helpers.ts
+++ b/src/lib/pb-helpers.ts
@@ -55,6 +55,32 @@ export async function getAllSuppliers() {
return pb.collection("suppliers").getFullList({ sort: "-created" });
}
+/**
+ * Fields the supplier-map actually renders. Kept narrow so a 5k-row
+ * supplier table doesn't ship `closingtime` and the rest of the blob
+ * over the wire just to show pins on a Leaflet map.
+ */
+const SUPPLIER_MAP_FIELDS =
+ "id,suppliername,supplieraddress,suppliernumber,email,supply_tags,coordinates";
+
+/**
+ * Suppliers that can actually be placed on the map — only rows with
+ * non-empty `coordinates`, and only the fields the map renders.
+ *
+ * Distinct from `getAllSuppliers` (which the quote/order forms still
+ * want full records from). Introduced when the supplier-map was
+ * choking on zoom-in with a few thousand suppliers: shipping the
+ * whole table was the biggest single contributor.
+ */
+export async function getMappableSuppliers() {
+ const pb = getPocketBase();
+ return pb.collection("suppliers").getFullList({
+ sort: "-created",
+ filter: 'coordinates != ""',
+ fields: SUPPLIER_MAP_FIELDS,
+ });
+}
+
/**
* Quote queries reference `is_template`, `quote_date`, `validity_days`,
* `follow_up_due`, `template_source_id`, `generated_from_template_id` —
diff --git a/src/lib/themes.ts b/src/lib/themes.ts
new file mode 100644
index 0000000..ce05fa9
--- /dev/null
+++ b/src/lib/themes.ts
@@ -0,0 +1,139 @@
+/**
+ * WFDASH theme registry.
+ *
+ * Each theme is identified by an `id` used in the `data-theme` attribute
+ * on . Themes compose with the warm/dark mode toggle (handled by
+ * next-themes via the `class` attribute on ).
+ *
+ * The 5 "safe" themes work cleanly with shadcn/ui out of the box.
+ * The 3 "standout" themes are intentionally creative — they may need
+ * extra component overrides for dialogs/popovers/tooltips.
+ */
+
+export type ThemeCategory = "standard" | "standout";
+
+export type ThemeDefinition = {
+ id: string;
+ label: string;
+ category: ThemeCategory;
+ tagline: string;
+ /** Fonts are CSS variable names registered via next/font in layout.tsx */
+ fontHeading: string;
+ fontBody: string;
+ /** Tailwind radius token used for the `rounded-*` scale */
+ radius: string;
+ /** Preview swatches for the settings picker */
+ swatches: { primary: string; gold: string; red: string; surface: string; text: string };
+};
+
+export const THEMES: ThemeDefinition[] = [
+ // ============================================================
+ // 5 SAFE / STANDARD THEMES
+ // ============================================================
+ {
+ id: "crisp",
+ label: "Crisp Minimal",
+ category: "standard",
+ tagline: "Clean, calm, professional. Easiest to ship.",
+ fontHeading: "var(--font-inter)",
+ fontBody: "var(--font-inter)",
+ radius: "0.625rem",
+ swatches: { primary: "#4C96D7", gold: "#F2AF58", red: "#E94E49", surface: "#FFFFFF", text: "#0F172A" },
+ },
+ {
+ id: "midnight",
+ label: "Midnight Sapphire",
+ category: "standard",
+ tagline: "Dark by default. Control-room vibes, low eye-strain.",
+ fontHeading: "var(--font-space-grotesk)",
+ fontBody: "var(--font-dm-sans)",
+ radius: "0.75rem",
+ swatches: { primary: "#4C96D7", gold: "#F2AF58", red: "#E94E49", surface: "#0B1220", text: "#E6ECF5" },
+ },
+ {
+ id: "editorial",
+ label: "Editorial Premium",
+ category: "standard",
+ tagline: "Warm cream + serif headings. Financial quarterly feel.",
+ fontHeading: "var(--font-playfair)",
+ fontBody: "var(--font-lato)",
+ radius: "0.375rem",
+ swatches: { primary: "#1E5A99", gold: "#B8842C", red: "#C0392B", surface: "#FBF8F3", text: "#0F2E4C" },
+ },
+ {
+ id: "bento",
+ label: "Bento Modular",
+ category: "standard",
+ tagline: "Tinted tiles, big radius. KPI-heavy dashboards pop.",
+ fontHeading: "var(--font-inter)",
+ fontBody: "var(--font-inter)",
+ radius: "1.125rem",
+ swatches: { primary: "#4C96D7", gold: "#F2AF58", red: "#E94E49", surface: "#F4F6FB", text: "#0F172A" },
+ },
+ {
+ id: "field-ops",
+ label: "Field Ops",
+ category: "standard",
+ tagline: "Dense, glove-friendly. Tablet in the yard.",
+ fontHeading: "var(--font-inter)",
+ fontBody: "var(--font-inter)",
+ radius: "0.5rem",
+ swatches: { primary: "#0F2E4C", gold: "#F2AF58", red: "#E94E49", surface: "#F8FAFC", text: "#0F172A" },
+ },
+
+ // ============================================================
+ // 3 STANDOUT / CREATIVE THEMES
+ // ============================================================
+ {
+ id: "terminal",
+ label: "Terminal",
+ category: "standout",
+ tagline: "Green CRT. Monospace. For the spreadsheet crowd.",
+ fontHeading: "var(--font-jetbrains-mono)",
+ fontBody: "var(--font-jetbrains-mono)",
+ radius: "0",
+ swatches: { primary: "#7EE787", gold: "#F2AF58", red: "#FF7B72", surface: "#0D1117", text: "#C9D1D9" },
+ },
+ {
+ id: "blueprint",
+ label: "Blueprint",
+ category: "standout",
+ tagline: "Engineering blueprint. White lines on technical blue.",
+ fontHeading: "var(--font-jetbrains-mono)",
+ fontBody: "var(--font-inter)",
+ radius: "0.125rem",
+ swatches: { primary: "#FFFFFF", gold: "#F2AF58", red: "#FF6B6B", surface: "#0B3B8C", text: "#FFFFFF" },
+ },
+ {
+ id: "confetti",
+ label: "Confetti",
+ category: "standout",
+ tagline: "Playful pastels. Oversized radius. Friendly for everyone.",
+ fontHeading: "var(--font-outfit)",
+ fontBody: "var(--font-nunito)",
+ radius: "1.25rem",
+ swatches: { primary: "#7AB6E5", gold: "#F8D89A", red: "#F0A19D", surface: "#FFF7F0", text: "#2A3958" },
+ },
+];
+
+export const DEFAULT_THEME_ID = "crisp";
+
+export function getThemeById(id: string | null | undefined): ThemeDefinition {
+ return THEMES.find((t) => t.id === id) ?? THEMES[0];
+}
+
+export const STORAGE_KEY_THEME = "wfdash-theme";
+
+/** Apply theme + mode to . Called before hydration to avoid FOUC. */
+export function applyThemeToDocument(themeId: string, mode: "warm" | "dark"): void {
+ if (typeof document === "undefined") return;
+ const root = document.documentElement;
+ root.setAttribute("data-theme", themeId);
+ root.classList.remove("warm", "dark");
+ root.classList.add(mode);
+ // Map theme semantic CSS vars onto the html root so shadcn picks them up
+ const t = getThemeById(themeId);
+ root.style.setProperty("--font-heading", t.fontHeading);
+ root.style.setProperty("--font-body", t.fontBody);
+ root.style.setProperty("--radius", t.radius);
+}
diff --git a/src/lib/use-theme-id.ts b/src/lib/use-theme-id.ts
new file mode 100644
index 0000000..24db223
--- /dev/null
+++ b/src/lib/use-theme-id.ts
@@ -0,0 +1,49 @@
+"use client";
+
+import { useCallback, useState } from "react";
+import {
+ STORAGE_KEY_THEME,
+ DEFAULT_THEME_ID,
+ getThemeById,
+} from "@/lib/themes";
+
+/**
+ * useThemeId — reads / writes the active visual theme id.
+ *
+ * The theme id is applied to via the `data-theme` attribute
+ * and the matching OKLCH tokens in src/styles/themes.css do the rest.
+ *
+ * Persists to localStorage so the bootstrap script can re-apply
+ * before hydration on the next visit (prevents FOUC).
+ *
+ * Initial state reads localStorage lazily so we don't trigger a cascading
+ * render — the bootstrap script has already set `data-theme` before React
+ * hydrates, so the first render matches.
+ */
+export function useThemeId() {
+ const [themeId, setThemeIdState] = useState(() => {
+ if (typeof window === "undefined") return DEFAULT_THEME_ID;
+ try {
+ return localStorage.getItem(STORAGE_KEY_THEME) ?? DEFAULT_THEME_ID;
+ } catch {
+ return DEFAULT_THEME_ID;
+ }
+ });
+
+ const setThemeId = useCallback((id: string) => {
+ setThemeIdState(id);
+ try {
+ localStorage.setItem(STORAGE_KEY_THEME, id);
+ } catch {
+ /* ignore */
+ }
+ if (typeof document !== "undefined") {
+ const root = document.documentElement;
+ root.setAttribute("data-theme", id);
+ const def = getThemeById(id);
+ root.style.setProperty("--radius", def.radius);
+ }
+ }, []);
+
+ return { themeId, setThemeId };
+}
diff --git a/src/styles/themes.css b/src/styles/themes.css
new file mode 100644
index 0000000..beda583
--- /dev/null
+++ b/src/styles/themes.css
@@ -0,0 +1,580 @@
+/* ============================================================================
+ * WFDASH — Multi-theme tokens
+ *
+ * 5 standard + 3 standout themes, each with a light (default) + dark variant.
+ * Apply by setting `data-theme=""` on ; mode is controlled separately
+ * via the `.dark` or `.warm` class (managed by next-themes).
+ *
+ * Brand palette (locked):
+ * primary #4C96D7 oklch(0.62 0.13 240)
+ * gold #F2AF58 oklch(0.81 0.14 75)
+ * red #E94E49 oklch(0.62 0.21 25)
+ * ============================================================================
+ */
+
+/* ============================================================================
+ * 01 · CRISP MINIMAL — default safe theme
+ * ============================================================================ */
+html[data-theme="crisp"] {
+ --background: oklch(0.985 0.01 240);
+ --foreground: oklch(0.20 0.045 240);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.20 0.045 240);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.20 0.045 240);
+ --primary: oklch(0.62 0.13 240);
+ --primary-foreground: oklch(1 0 0);
+ --secondary: oklch(0.96 0.018 240);
+ --secondary-foreground: oklch(0.24 0.08 240);
+ --muted: oklch(0.96 0.012 240);
+ --muted-foreground: oklch(0.48 0.035 240);
+ --accent: oklch(0.81 0.14 75);
+ --accent-foreground: oklch(0.28 0.08 240);
+ --destructive: oklch(0.62 0.21 25);
+ --destructive-foreground: oklch(1 0 0);
+ --border: oklch(0.90 0.018 240);
+ --input: oklch(0.92 0.018 240);
+ --ring: oklch(0.62 0.13 240);
+ --sidebar: oklch(0.97 0.015 240);
+ --sidebar-foreground: oklch(0.20 0.045 240);
+ --sidebar-primary: oklch(0.62 0.13 240);
+ --sidebar-primary-foreground: oklch(1 0 0);
+ --sidebar-accent: oklch(0.94 0.04 240);
+ --sidebar-accent-foreground: oklch(0.20 0.045 240);
+ --sidebar-border: oklch(0.88 0.025 240);
+ --sidebar-ring: oklch(0.62 0.13 240);
+}
+html[data-theme="crisp"].dark {
+ --background: oklch(0.15 0.035 240);
+ --foreground: oklch(0.96 0.01 240);
+ --card: oklch(0.18 0.035 240);
+ --card-foreground: oklch(0.96 0.01 240);
+ --popover: oklch(0.18 0.035 240);
+ --popover-foreground: oklch(0.96 0.01 240);
+ --primary: oklch(0.72 0.13 240);
+ --primary-foreground: oklch(0.12 0.03 240);
+ --secondary: oklch(0.24 0.045 240);
+ --secondary-foreground: oklch(0.96 0.01 240);
+ --muted: oklch(0.23 0.035 240);
+ --muted-foreground: oklch(0.66 0.035 240);
+ --accent: oklch(0.78 0.14 75);
+ --accent-foreground: oklch(0.15 0.035 240);
+ --destructive: oklch(0.68 0.21 25);
+ --destructive-foreground: oklch(1 0 0);
+ --border: oklch(0.30 0.04 240);
+ --input: oklch(0.30 0.04 240);
+ --ring: oklch(0.72 0.13 240);
+ --sidebar: oklch(0.16 0.04 240);
+ --sidebar-foreground: oklch(0.96 0.01 240);
+ --sidebar-primary: oklch(0.72 0.13 240);
+ --sidebar-primary-foreground: oklch(0.12 0.03 240);
+ --sidebar-accent: oklch(0.22 0.045 240);
+ --sidebar-accent-foreground: oklch(0.96 0.01 240);
+ --sidebar-border: oklch(0.30 0.04 240);
+ --sidebar-ring: oklch(0.72 0.13 240);
+}
+
+/* ============================================================================
+ * 02 · MIDNIGHT SAPPHIRE — dark by default, electric blue accents
+ * ============================================================================ */
+html[data-theme="midnight"] {
+ --background: oklch(0.14 0.035 240);
+ --foreground: oklch(0.93 0.01 240);
+ --card: oklch(0.18 0.04 240);
+ --card-foreground: oklch(0.93 0.01 240);
+ --popover: oklch(0.18 0.04 240);
+ --popover-foreground: oklch(0.93 0.01 240);
+ --primary: oklch(0.68 0.16 240);
+ --primary-foreground: oklch(0.10 0.03 240);
+ --secondary: oklch(0.22 0.05 240);
+ --secondary-foreground: oklch(0.93 0.01 240);
+ --muted: oklch(0.22 0.04 240);
+ --muted-foreground: oklch(0.66 0.04 240);
+ --accent: oklch(0.81 0.14 75);
+ --accent-foreground: oklch(0.12 0.04 240);
+ --destructive: oklch(0.66 0.22 25);
+ --destructive-foreground: oklch(1 0 0);
+ --border: oklch(0.26 0.05 240);
+ --input: oklch(0.24 0.05 240);
+ --ring: oklch(0.68 0.16 240);
+ --sidebar: oklch(0.11 0.03 240);
+ --sidebar-foreground: oklch(0.93 0.01 240);
+ --sidebar-primary: oklch(0.68 0.16 240);
+ --sidebar-primary-foreground: oklch(0.10 0.03 240);
+ --sidebar-accent: oklch(0.18 0.045 240);
+ --sidebar-accent-foreground: oklch(0.93 0.01 240);
+ --sidebar-border: oklch(0.22 0.05 240);
+ --sidebar-ring: oklch(0.68 0.16 240);
+}
+html[data-theme="midnight"].dark {
+ /* Midnight stays dark — just slightly deeper */
+ --background: oklch(0.10 0.025 240);
+ --foreground: oklch(0.96 0.008 240);
+ --card: oklch(0.13 0.03 240);
+ --border: oklch(0.22 0.04 240);
+ --sidebar: oklch(0.08 0.025 240);
+}
+
+/* ============================================================================
+ * 03 · EDITORIAL PREMIUM — warm cream, serif headings, deep navy
+ * ============================================================================ */
+html[data-theme="editorial"] {
+ --background: oklch(0.975 0.018 80);
+ --foreground: oklch(0.22 0.05 240);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.22 0.05 240);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.22 0.05 240);
+ --primary: oklch(0.42 0.13 240); /* deeper blue for serif contrast */
+ --primary-foreground: oklch(0.99 0.005 80);
+ --secondary: oklch(0.95 0.025 80);
+ --secondary-foreground: oklch(0.24 0.07 240);
+ --muted: oklch(0.94 0.022 80);
+ --muted-foreground: oklch(0.45 0.04 240);
+ --accent: oklch(0.62 0.13 75); /* burnt gold, not bright */
+ --accent-foreground: oklch(1 0 0);
+ --destructive: oklch(0.50 0.20 25);
+ --destructive-foreground: oklch(1 0 0);
+ --border: oklch(0.88 0.03 80);
+ --input: oklch(0.91 0.03 80);
+ --ring: oklch(0.42 0.13 240);
+ --sidebar: oklch(0.95 0.022 80);
+ --sidebar-foreground: oklch(0.22 0.05 240);
+ --sidebar-primary: oklch(0.42 0.13 240);
+ --sidebar-primary-foreground: oklch(1 0 0);
+ --sidebar-accent: oklch(0.92 0.04 80);
+ --sidebar-accent-foreground: oklch(0.22 0.05 240);
+ --sidebar-border: oklch(0.86 0.035 80);
+ --sidebar-ring: oklch(0.42 0.13 240);
+}
+html[data-theme="editorial"].dark {
+ --background: oklch(0.16 0.025 240);
+ --foreground: oklch(0.94 0.01 80);
+ --card: oklch(0.19 0.03 240);
+ --card-foreground: oklch(0.94 0.01 80);
+ --popover: oklch(0.19 0.03 240);
+ --popover-foreground: oklch(0.94 0.01 80);
+ --primary: oklch(0.70 0.13 240);
+ --primary-foreground: oklch(0.10 0.03 240);
+ --secondary: oklch(0.25 0.04 240);
+ --secondary-foreground: oklch(0.94 0.01 80);
+ --muted: oklch(0.24 0.035 240);
+ --muted-foreground: oklch(0.68 0.035 240);
+ --accent: oklch(0.76 0.14 75);
+ --accent-foreground: oklch(0.10 0.03 240);
+ --destructive: oklch(0.66 0.20 25);
+ --destructive-foreground: oklch(1 0 0);
+ --border: oklch(0.30 0.04 240);
+ --input: oklch(0.30 0.04 240);
+ --ring: oklch(0.70 0.13 240);
+ --sidebar: oklch(0.14 0.025 240);
+ --sidebar-foreground: oklch(0.94 0.01 80);
+ --sidebar-primary: oklch(0.70 0.13 240);
+ --sidebar-primary-foreground: oklch(0.10 0.03 240);
+ --sidebar-accent: oklch(0.22 0.04 240);
+ --sidebar-accent-foreground: oklch(0.94 0.01 80);
+ --sidebar-border: oklch(0.30 0.04 240);
+ --sidebar-ring: oklch(0.70 0.13 240);
+}
+
+/* ============================================================================
+ * 04 · BENTO MODULAR — cool tinted base, big radius
+ * ============================================================================ */
+html[data-theme="bento"] {
+ --background: oklch(0.965 0.018 240);
+ --foreground: oklch(0.18 0.045 240);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.18 0.045 240);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.18 0.045 240);
+ --primary: oklch(0.62 0.13 240);
+ --primary-foreground: oklch(1 0 0);
+ --secondary: oklch(0.93 0.035 240);
+ --secondary-foreground: oklch(0.24 0.08 240);
+ --muted: oklch(0.94 0.025 240);
+ --muted-foreground: oklch(0.48 0.035 240);
+ --accent: oklch(0.81 0.14 75);
+ --accent-foreground: oklch(0.24 0.08 240);
+ --destructive: oklch(0.62 0.21 25);
+ --destructive-foreground: oklch(1 0 0);
+ --border: oklch(0.88 0.025 240);
+ --input: oklch(0.92 0.025 240);
+ --ring: oklch(0.62 0.13 240);
+ --sidebar: oklch(0.95 0.028 240);
+ --sidebar-foreground: oklch(0.18 0.045 240);
+ --sidebar-primary: oklch(0.62 0.13 240);
+ --sidebar-primary-foreground: oklch(1 0 0);
+ --sidebar-accent: oklch(0.91 0.05 240);
+ --sidebar-accent-foreground: oklch(0.18 0.045 240);
+ --sidebar-border: oklch(0.86 0.03 240);
+ --sidebar-ring: oklch(0.62 0.13 240);
+}
+html[data-theme="bento"].dark {
+ --background: oklch(0.14 0.035 240);
+ --foreground: oklch(0.95 0.01 240);
+ --card: oklch(0.17 0.04 240);
+ --card-foreground: oklch(0.95 0.01 240);
+ --popover: oklch(0.17 0.04 240);
+ --popover-foreground: oklch(0.95 0.01 240);
+ --primary: oklch(0.70 0.13 240);
+ --primary-foreground: oklch(0.10 0.03 240);
+ --secondary: oklch(0.22 0.05 240);
+ --secondary-foreground: oklch(0.95 0.01 240);
+ --muted: oklch(0.22 0.04 240);
+ --muted-foreground: oklch(0.66 0.035 240);
+ --accent: oklch(0.78 0.14 75);
+ --accent-foreground: oklch(0.10 0.03 240);
+ --destructive: oklch(0.66 0.20 25);
+ --destructive-foreground: oklch(1 0 0);
+ --border: oklch(0.28 0.04 240);
+ --input: oklch(0.28 0.04 240);
+ --ring: oklch(0.70 0.13 240);
+ --sidebar: oklch(0.13 0.035 240);
+ --sidebar-foreground: oklch(0.95 0.01 240);
+ --sidebar-primary: oklch(0.70 0.13 240);
+ --sidebar-primary-foreground: oklch(0.10 0.03 240);
+ --sidebar-accent: oklch(0.20 0.045 240);
+ --sidebar-accent-foreground: oklch(0.95 0.01 240);
+ --sidebar-border: oklch(0.28 0.04 240);
+ --sidebar-ring: oklch(0.70 0.13 240);
+}
+
+/* ============================================================================
+ * 05 · FIELD OPS — deep navy sidebar + gold CTAs, dense
+ * ============================================================================ */
+html[data-theme="field-ops"] {
+ --background: oklch(0.98 0.008 240);
+ --foreground: oklch(0.18 0.04 240);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.18 0.04 240);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.18 0.04 240);
+ --primary: oklch(0.28 0.07 240); /* deep navy */
+ --primary-foreground: oklch(1 0 0);
+ --secondary: oklch(0.95 0.012 240);
+ --secondary-foreground: oklch(0.20 0.06 240);
+ --muted: oklch(0.95 0.012 240);
+ --muted-foreground: oklch(0.48 0.03 240);
+ --accent: oklch(0.81 0.14 75);
+ --accent-foreground: oklch(0.18 0.05 240);
+ --destructive: oklch(0.62 0.21 25);
+ --destructive-foreground: oklch(1 0 0);
+ --border: oklch(0.90 0.015 240);
+ --input: oklch(0.92 0.015 240);
+ --ring: oklch(0.28 0.07 240);
+ --sidebar: oklch(0.22 0.06 240); /* deep navy sidebar */
+ --sidebar-foreground: oklch(0.96 0.008 240);
+ --sidebar-primary: oklch(0.81 0.14 75); /* gold as primary in sidebar */
+ --sidebar-primary-foreground: oklch(0.18 0.05 240);
+ --sidebar-accent: oklch(0.28 0.07 240);
+ --sidebar-accent-foreground: oklch(0.96 0.008 240);
+ --sidebar-border: oklch(0.30 0.06 240);
+ --sidebar-ring: oklch(0.81 0.14 75);
+}
+html[data-theme="field-ops"].dark {
+ --background: oklch(0.13 0.025 240);
+ --foreground: oklch(0.95 0.008 240);
+ --card: oklch(0.16 0.03 240);
+ --card-foreground: oklch(0.95 0.008 240);
+ --popover: oklch(0.16 0.03 240);
+ --popover-foreground: oklch(0.95 0.008 240);
+ --primary: oklch(0.20 0.06 240);
+ --primary-foreground: oklch(0.95 0.008 240);
+ --secondary: oklch(0.22 0.04 240);
+ --secondary-foreground: oklch(0.95 0.008 240);
+ --muted: oklch(0.22 0.04 240);
+ --muted-foreground: oklch(0.66 0.03 240);
+ --accent: oklch(0.78 0.14 75);
+ --accent-foreground: oklch(0.13 0.025 240);
+ --destructive: oklch(0.66 0.20 25);
+ --destructive-foreground: oklch(1 0 0);
+ --border: oklch(0.28 0.04 240);
+ --input: oklch(0.28 0.04 240);
+ --ring: oklch(0.78 0.14 75);
+ --sidebar: oklch(0.10 0.04 240);
+ --sidebar-foreground: oklch(0.95 0.008 240);
+ --sidebar-primary: oklch(0.78 0.14 75);
+ --sidebar-primary-foreground: oklch(0.10 0.04 240);
+ --sidebar-accent: oklch(0.18 0.05 240);
+ --sidebar-accent-foreground: oklch(0.95 0.008 240);
+ --sidebar-border: oklch(0.24 0.05 240);
+ --sidebar-ring: oklch(0.78 0.14 75);
+}
+
+/* ============================================================================
+ * 06 · TERMINAL — green CRT, monospace, 0px radius
+ * ============================================================================ */
+html[data-theme="terminal"] {
+ --background: oklch(0.16 0.018 145);
+ --foreground: oklch(0.92 0.04 145);
+ --card: oklch(0.13 0.02 145);
+ --card-foreground: oklch(0.92 0.04 145);
+ --popover: oklch(0.13 0.02 145);
+ --popover-foreground: oklch(0.92 0.04 145);
+ --primary: oklch(0.84 0.18 145); /* terminal green */
+ --primary-foreground: oklch(0.10 0.02 145);
+ --secondary: oklch(0.20 0.025 145);
+ --secondary-foreground: oklch(0.92 0.04 145);
+ --muted: oklch(0.20 0.022 145);
+ --muted-foreground: oklch(0.66 0.06 145);
+ --accent: oklch(0.81 0.14 75);
+ --accent-foreground: oklch(0.10 0.02 145);
+ --destructive: oklch(0.66 0.22 25);
+ --destructive-foreground: oklch(1 0 0);
+ --border: oklch(0.30 0.05 145);
+ --input: oklch(0.22 0.04 145);
+ --ring: oklch(0.84 0.18 145);
+ --sidebar: oklch(0.10 0.02 145);
+ --sidebar-foreground: oklch(0.92 0.04 145);
+ --sidebar-primary: oklch(0.84 0.18 145);
+ --sidebar-primary-foreground: oklch(0.10 0.02 145);
+ --sidebar-accent: oklch(0.16 0.025 145);
+ --sidebar-accent-foreground: oklch(0.92 0.04 145);
+ --sidebar-border: oklch(0.26 0.05 145);
+ --sidebar-ring: oklch(0.84 0.18 145);
+}
+html[data-theme="terminal"].dark {
+ --background: oklch(0.12 0.015 145);
+ --foreground: oklch(0.94 0.04 145);
+ --border: oklch(0.26 0.04 145);
+ --card: oklch(0.10 0.018 145);
+ --sidebar: oklch(0.07 0.012 145);
+}
+
+/* Terminal-specific styling — ASCII borders, mono everywhere */
+[data-theme="terminal"] body {
+ font-family: var(--font-body);
+ background-image:
+ repeating-linear-gradient(0deg, transparent, transparent 2px, color-mix(in oklch, var(--primary) 4%, transparent) 2px, color-mix(in oklch, var(--primary) 4%, transparent) 3px);
+}
+[data-theme="terminal"] button,
+[data-theme="terminal"] [role="button"] {
+ border-radius: 0 !important;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ font-weight: 700;
+}
+[data-theme="terminal"] input,
+[data-theme="terminal"] select,
+[data-theme="terminal"] textarea {
+ border-radius: 0 !important;
+ font-family: var(--font-body);
+}
+[data-theme="terminal"] h1,
+[data-theme="terminal"] h2,
+[data-theme="terminal"] h3,
+[data-theme="terminal"] h4 {
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+
+/* ============================================================================
+ * 07 · BLUEPRINT — engineering blueprint, white on technical blue
+ * ============================================================================ */
+html[data-theme="blueprint"] {
+ --background: oklch(0.30 0.13 255); /* deep blueprint blue */
+ --foreground: oklch(0.98 0.005 255);
+ --card: oklch(0.28 0.13 255);
+ --card-foreground: oklch(0.98 0.005 255);
+ --popover: oklch(0.28 0.13 255);
+ --popover-foreground: oklch(0.98 0.005 255);
+ --primary: oklch(0.98 0.005 255); /* white as primary */
+ --primary-foreground: oklch(0.28 0.13 255);
+ --secondary: oklch(0.36 0.13 255);
+ --secondary-foreground: oklch(0.98 0.005 255);
+ --muted: oklch(0.36 0.13 255);
+ --muted-foreground: oklch(0.82 0.04 255);
+ --accent: oklch(0.81 0.14 75); /* gold as accent */
+ --accent-foreground: oklch(0.20 0.06 255);
+ --destructive: oklch(0.68 0.22 25);
+ --destructive-foreground: oklch(0.98 0.005 255);
+ --border: oklch(0.55 0.10 255);
+ --input: oklch(0.40 0.12 255);
+ --ring: oklch(0.98 0.005 255);
+ --sidebar: oklch(0.24 0.13 255);
+ --sidebar-foreground: oklch(0.98 0.005 255);
+ --sidebar-primary: oklch(0.98 0.005 255);
+ --sidebar-primary-foreground: oklch(0.24 0.13 255);
+ --sidebar-accent: oklch(0.32 0.13 255);
+ --sidebar-accent-foreground: oklch(0.98 0.005 255);
+ --sidebar-border: oklch(0.50 0.10 255);
+ --sidebar-ring: oklch(0.98 0.005 255);
+}
+html[data-theme="blueprint"].dark {
+ --background: oklch(0.22 0.13 255);
+ --card: oklch(0.20 0.13 255);
+ --sidebar: oklch(0.18 0.13 255);
+}
+
+/* Blueprint-specific — grid lines, white text, technical feel */
+[data-theme="blueprint"] body {
+ background-image:
+ linear-gradient(to right, color-mix(in oklch, var(--foreground) 6%, transparent) 1px, transparent 1px),
+ linear-gradient(to bottom, color-mix(in oklch, var(--foreground) 6%, transparent) 1px, transparent 1px);
+ background-size: 24px 24px;
+ font-family: var(--font-body);
+}
+[data-theme="blueprint"] button,
+[data-theme="blueprint"] [role="button"] {
+ border: 1px solid var(--foreground) !important;
+ border-radius: 0 !important;
+ font-family: var(--font-body);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+[data-theme="blueprint"] input,
+[data-theme="blueprint"] select,
+[data-theme="blueprint"] textarea {
+ border: 1px solid var(--foreground) !important;
+ border-radius: 0 !important;
+ font-family: var(--font-body);
+ background: transparent;
+}
+[data-theme="blueprint"] h1,
+[data-theme="blueprint"] h2,
+[data-theme="blueprint"] h3 {
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ font-family: var(--font-heading);
+}
+
+/* ============================================================================
+ * 08 · CONFETTI — playful pastels, oversized radius
+ * ============================================================================ */
+html[data-theme="confetti"] {
+ --background: oklch(0.985 0.025 60);
+ --foreground: oklch(0.25 0.05 260);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.25 0.05 260);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.25 0.05 260);
+ --primary: oklch(0.74 0.11 230); /* soft blue */
+ --primary-foreground: oklch(1 0 0);
+ --secondary: oklch(0.94 0.04 60);
+ --secondary-foreground: oklch(0.30 0.06 260);
+ --muted: oklch(0.95 0.03 60);
+ --muted-foreground: oklch(0.52 0.04 260);
+ --accent: oklch(0.86 0.10 75); /* pale gold */
+ --accent-foreground: oklch(0.30 0.08 260);
+ --destructive: oklch(0.72 0.14 25); /* soft coral */
+ --destructive-foreground: oklch(1 0 0);
+ --border: oklch(0.92 0.03 60);
+ --input: oklch(0.94 0.03 60);
+ --ring: oklch(0.74 0.11 230);
+ --sidebar: oklch(0.96 0.04 60);
+ --sidebar-foreground: oklch(0.25 0.05 260);
+ --sidebar-primary: oklch(0.74 0.11 230);
+ --sidebar-primary-foreground: oklch(1 0 0);
+ --sidebar-accent: oklch(0.93 0.05 60);
+ --sidebar-accent-foreground: oklch(0.25 0.05 260);
+ --sidebar-border: oklch(0.90 0.04 60);
+ --sidebar-ring: oklch(0.74 0.11 230);
+}
+html[data-theme="confetti"].dark {
+ --background: oklch(0.20 0.04 260);
+ --foreground: oklch(0.95 0.025 60);
+ --card: oklch(0.23 0.05 260);
+ --card-foreground: oklch(0.95 0.025 60);
+ --popover: oklch(0.23 0.05 260);
+ --popover-foreground: oklch(0.95 0.025 60);
+ --primary: oklch(0.76 0.12 230);
+ --primary-foreground: oklch(0.15 0.03 260);
+ --secondary: oklch(0.28 0.05 260);
+ --secondary-foreground: oklch(0.95 0.025 60);
+ --muted: oklch(0.28 0.05 260);
+ --muted-foreground: oklch(0.72 0.04 260);
+ --accent: oklch(0.82 0.12 75);
+ --accent-foreground: oklch(0.15 0.03 260);
+ --destructive: oklch(0.74 0.15 25);
+ --destructive-foreground: oklch(1 0 0);
+ --border: oklch(0.34 0.05 260);
+ --input: oklch(0.34 0.05 260);
+ --ring: oklch(0.76 0.12 230);
+ --sidebar: oklch(0.22 0.05 260);
+ --sidebar-foreground: oklch(0.95 0.025 60);
+ --sidebar-primary: oklch(0.76 0.12 230);
+ --sidebar-primary-foreground: oklch(0.15 0.03 260);
+ --sidebar-accent: oklch(0.26 0.05 260);
+ --sidebar-accent-foreground: oklch(0.95 0.025 60);
+ --sidebar-border: oklch(0.34 0.05 260);
+ --sidebar-ring: oklch(0.76 0.12 230);
+}
+
+/* ============================================================================
+ * Theme-aware font bindings
+ * (Tailwind 4 reads these via --font-heading / --font-body classes below)
+ * ============================================================================ */
+html[data-theme="crisp"],
+html[data-theme="bento"],
+html[data-theme="field-ops"] {
+ --font-heading: var(--font-inter);
+ --font-body: var(--font-inter);
+}
+html[data-theme="midnight"] {
+ --font-heading: var(--font-space-grotesk);
+ --font-body: var(--font-dm-sans);
+}
+html[data-theme="editorial"] {
+ --font-heading: var(--font-playfair);
+ --font-body: var(--font-lato);
+}
+html[data-theme="terminal"] {
+ --font-heading: var(--font-jetbrains-mono);
+ --font-body: var(--font-jetbrains-mono);
+}
+html[data-theme="blueprint"] {
+ --font-heading: var(--font-jetbrains-mono);
+ --font-body: var(--font-inter);
+}
+html[data-theme="confetti"] {
+ --font-heading: var(--font-outfit);
+ --font-body: var(--font-nunito);
+}
+
+/* ============================================================================
+ * Helper classes that read theme tokens
+ * ============================================================================ */
+.font-heading-theme { font-family: var(--font-heading); }
+.font-body-theme { font-family: var(--font-body); }
+
+/* Body font follows theme */
+body {
+ font-family: var(--font-body);
+}
+h1, h2, h3, h4, h5, h6 {
+ font-family: var(--font-heading);
+ letter-spacing: -0.01em;
+}
+
+/* Body keeps the brand gradient wash by default; theme overrides go below */
+body {
+ background-image:
+ linear-gradient(135deg, color-mix(in oklch, var(--primary) 8%, transparent), transparent 34%),
+ linear-gradient(315deg, color-mix(in oklch, var(--accent) 10%, transparent), transparent 32%);
+ background-attachment: fixed;
+}
+
+/* Disable gradient wash on standout themes — they're expressive enough */
+[data-theme="terminal"] body,
+[data-theme="blueprint"] body {
+ background-image: none;
+}
+
+/* Blueprint gets grid lines instead */
+[data-theme="blueprint"] body {
+ background-image:
+ linear-gradient(to right, color-mix(in oklch, var(--foreground) 5%, transparent) 1px, transparent 1px),
+ linear-gradient(to bottom, color-mix(in oklch, var(--foreground) 5%, transparent) 1px, transparent 1px);
+ background-size: 24px 24px;
+}
+
+/* Terminal gets scanlines */
+[data-theme="terminal"] body {
+ background-image: repeating-linear-gradient(
+ 0deg,
+ transparent,
+ transparent 2px,
+ color-mix(in oklch, var(--primary) 4%, transparent) 2px,
+ color-mix(in oklch, var(--primary) 4%, transparent) 3px
+ );
+}