feat(dashboard): add multi-theme system and supporting improvements

Add complete multi-theme dashboard system including:
- 8 selectable themes with persistent per-browser preferences
- Theme bootstrap script to prevent FOUC on page load
- 12+ reusable chart components (area, bar, donut, radial gauge, etc)
- Custom checkbox UI component
- New settings page for appearance customization

Other changes:
- Optimize supplier map API payload with getMappableSuppliers helper
- Add aria-label attributes to back buttons on detail pages for better accessibility
- Update next.config.ts to include new local development IP 192.168.1.128
- Update site manifest with all new favicon and PWA assets
- Fix styling and layout spacing in rewrite quote modal
This commit is contained in:
SJCoetz 2026-06-26 17:59:40 +02:00
parent 588e9762c2
commit bb4233a9f2
50 changed files with 6747 additions and 870 deletions

View file

@ -15,7 +15,7 @@ A modern business CRM (customers, companies, suppliers, quotes, orders, driver c
## Prerequisites
- **Node.js >= 20.9.0** (enforced by `package.json` `engines`)
- **PocketBase** binary (the setup script connects to `http://127.0.0.1:8090`)
- **PocketBase** binary (the setup script connects to `http://127.0.0.1:8090`)but in my .env.local we have added the cloud url that its connecting to now at the moment
---
@ -51,6 +51,7 @@ Create `.env.local` (gitignored) with:
# Public — exposed to the browser
NEXT_PUBLIC_POCKETBASE_URL=http://127.0.0.1:8090
# Server-only — admin used by setup / migration scripts and agent API
PB_ADMIN_EMAIL=admin@example.com
PB_ADMIN_PASSWORD=your_password

View file

@ -0,0 +1,90 @@
# WFDASH — Multi-theme system
Ship 8 selectable themes (5 standard + 3 standout) with a warm/dark mode toggle.
Theme selection persists per browser. No FOUC on reload.
## File map
```
src/
├── lib/
│ ├── themes.ts # 8-theme registry (id, label, fonts, swatches)
│ └── use-theme-id.ts # Client hook to read/write active theme
├── styles/
│ └── themes.css # OKLCH token blocks for all 8 themes (warm + dark)
├── components/
│ ├── theme-provider.tsx # next-themes wrapper (manages warm/dark mode)
│ ├── theme-bootstrap.tsx # Inline <head> script — prevents FOUC
│ ├── theme-picker.tsx # Settings UI — mini preview cards
│ └── sidebar.tsx # +Settings nav item
└── app/
├── layout.tsx # +Inter, Space Grotesk, DM Sans, Playfair,
│ # Lato, Nunito, Outfit, JetBrains Mono via next/font
├── globals.css # Imports themes.css
└── (app)/settings/page.tsx # New Settings page with Appearance section
```
## The 8 themes
| # | ID | Category | Fonts | Radius | Notes |
|---|---|---|---|---|---|
| 1 | `crisp` | Standard | Inter | 10px | Default. Calm, professional. |
| 2 | `midnight` | Standard | Space Grotesk + DM Sans | 12px | Dark control-room. |
| 3 | `editorial` | Standard | Playfair Display + Lato | 6px | Warm cream + serif. |
| 4 | `bento` | Standard | Inter | 18px | Tinted tiles, big radius. |
| 5 | `field-ops` | Standard | Inter | 8px | Dense, navy sidebar, gold CTA. |
| 6 | `terminal` | **Standout** | JetBrains Mono | 0px | Green CRT, scanlines, mono. |
| 7 | `blueprint` | **Standout** | JetBrains Mono + Inter | 2px | White-on-blue, grid, technical. |
| 8 | `confetti` | **Standout** | Outfit + Nunito | 20px | Pastel, playful, oversized radius. |
## How it works
**Theme identity** = `data-theme="<id>"` on `<html>`
- Set by `theme-bootstrap.tsx` before hydration (prevents flash)
- Updated by `useThemeId()` hook when user picks a new theme
- Persisted to `localStorage["wfdash-theme"]`
**Mode (warm/dark)** = `.warm` or `.dark` class on `<html>`
- Managed by `next-themes` (already wired in `theme-provider.tsx`)
- Persisted to `localStorage["wfdash-mode"]`
- Independent from theme — you can mix any theme with any mode
**shadcn/ui adapts automatically** because all components read from CSS vars
(`--background`, `--primary`, `--accent`, `--radius`, etc.) that themes.css
overrides per theme.
## How to add a new theme
1. Add an entry to `THEMES` in `src/lib/themes.ts`
2. Add a `[data-theme="<id>"]` block in `src/styles/themes.css` (copy an existing
theme and adjust tokens). Add `.dark` variant if it works in dark mode.
3. Add `radiusMap[<id>]` in `theme-bootstrap.tsx`
4. Done — picker picks it up automatically
## How to ship a font swap
Each theme has `fontHeading` + `fontBody` pointing at a CSS var. Add the font to
`src/app/layout.tsx` via `next/font/google` and register its `.variable`. Then
add the binding in `src/styles/themes.css`:
```css
[data-theme="<id>"] {
--font-heading: var(--font-<your-font>);
--font-body: var(--font-<your-font>);
}
```
The body picks up `var(--font-body)` automatically; `font-heading-theme` class
applies heading font where you need it.
## Standout theme caveats
The 3 standout themes override default component styling:
- **Terminal** — sharp 0px radius, uppercase headings, mono everywhere, scanline body overlay
- **Blueprint** — sharp 2px radius, uppercase headings, grid overlay, transparent inputs
- **Confetti** — 20px radius (capped — shadcn's `--radius-4xl` calc clamps at ~1.6rem)
All three are still built on the brand palette (#4C96D7 / #F2AF58 / #E94E49) but
applied in different ways. They're tested against the standard shadcn components
but may render slightly differently from the 5 standard themes — by design.

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,7 @@ const nextConfig: NextConfig = {
// app is normally loaded on `localhost`, but we also load it on
// `127.0.0.1` (and on the LAN IP for phone testing). Listing both keeps
// the HMR client from hanging on the first load.
allowedDevOrigins: ["127.0.0.1", "localhost", "192.168.1.122"],
allowedDevOrigins: ["127.0.0.1", "localhost", "192.168.1.122", "192.168.1.128"],
};
export default nextConfig;

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 663 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
public/favicon-96x96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 685 B

View file

@ -8,6 +8,33 @@
"theme_color": "#E85D4C",
"orientation": "any",
"icons": [
{
"src": "/favicon-16x16.png",
"sizes": "16x16",
"type": "image/png"
},
{
"src": "/favicon-32x32.png",
"sizes": "32x32",
"type": "image/png"
},
{
"src": "/favicon-96x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon.svg",
"sizes": "any",

BIN
public/og-image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 732 KiB

BIN
public/og-square.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
public/twitter-image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 772 KiB

View file

@ -63,7 +63,7 @@ export default function CompanyDetailPage({ params }: { params: Promise<{ id: st
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.push("/companies")}>
<Button variant="ghost" size="icon" onClick={() => router.push("/companies")} aria-label="Back to companies">
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1">

View file

@ -91,7 +91,7 @@ export default function CustomerDetailPage({ params }: { params: Promise<{ id: s
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.push("/customers")}>
<Button variant="ghost" size="icon" onClick={() => router.push("/customers")} aria-label="Back to customers">
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1">

File diff suppressed because it is too large Load diff

View file

@ -223,7 +223,7 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.push("/orders")}>
<Button variant="ghost" size="icon" onClick={() => router.push("/orders")} aria-label="Back to orders">
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1">

View file

@ -18,6 +18,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { DeleteDialog } from "@/components/delete-dialog";
import { Checkbox } from "@/components/ui/checkbox";
import {
Table,
TableBody,
@ -147,7 +148,7 @@ function itemMarkupPercent(item: QuoteItem): number | null {
function markupBadgeClasses(pct: number | null): string {
if (pct === null) return "text-muted-foreground";
if (pct < 20) return "text-destructive font-semibold";
return "text-green-700 font-semibold dark:text-green-400";
return "text-primary font-semibold";
}
/**
@ -1304,7 +1305,7 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
<Button variant="ghost" size="icon" onClick={() => router.push("/quotes")} className="shrink-0">
<Button variant="ghost" size="icon" onClick={() => router.push("/quotes")} className="shrink-0" aria-label="Back to quotes">
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
@ -1325,7 +1326,7 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
<button
type="button"
onClick={() => setApproveOpen(true)}
className="inline-flex items-center gap-1.5 text-xs font-medium text-red-700 hover:text-red-800 dark:text-red-300 dark:hover:text-red-200 hover:underline"
className="inline-flex items-center gap-1.5 text-xs font-medium text-destructive hover:underline"
title={quote.email_error || "Last send failed"}
>
<AlertCircle className="h-3.5 w-3.5" />
@ -1333,7 +1334,7 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
</button>
)}
{quote.status === "sending" && (
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-blue-700 dark:text-blue-300">
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-primary">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Sending in progress
</span>
@ -1896,7 +1897,7 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
<p className="text-xs text-muted-foreground">Delivered</p>
<div className="flex items-center gap-1.5 mt-1">
{quote.email_delivered ? (
<Mail className="h-4 w-4 text-green-500" />
<Mail className="h-4 w-4 text-primary" />
) : (
<Mail className="h-4 w-4 text-muted-foreground" />
)}
@ -1907,7 +1908,7 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
<p className="text-xs text-muted-foreground">Opened</p>
<div className="flex items-center gap-1.5 mt-1">
{quote.email_opened ? (
<MailOpen className="h-4 w-4 text-green-500" />
<MailOpen className="h-4 w-4 text-primary" />
) : (
<MailOpen className="h-4 w-4 text-muted-foreground" />
)}
@ -2945,9 +2946,9 @@ function ApproveAndSendDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl w-[calc(100%-2rem)] max-h-[90vh] p-0 gap-0 overflow-hidden flex flex-col">
<DialogHeader className="px-6 py-4 border-b">
<DialogHeader className="px-6 py-4 border-b shrink-0 space-y-0">
<DialogTitle className="text-lg flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-600" />
<CheckCircle className="h-5 w-5 text-primary" />
Approve Quote #{quote.quote_number}
</DialogTitle>
</DialogHeader>
@ -2957,18 +2958,38 @@ function ApproveAndSendDialog({
{showFailureBanner && (
<div
data-testid="approve-failure-banner"
className="rounded-md border border-red-300 bg-red-50 dark:border-red-500/40 dark:bg-red-500/10 p-4 flex items-start gap-3"
className="rounded-md border p-4 flex items-start gap-3"
style={{
borderColor:
"color-mix(in oklch, var(--destructive) 30%, transparent)",
background:
"color-mix(in oklch, var(--destructive) 5%, transparent)",
color: "var(--destructive)",
}}
>
<AlertCircle className="h-5 w-5 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
<AlertCircle
className="h-5 w-5 shrink-0 mt-0.5"
style={{ color: "var(--destructive)" }}
/>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm text-red-900 dark:text-red-100">
Last send failed
</p>
<p className="text-xs text-red-700 dark:text-red-200 mt-1 whitespace-pre-wrap break-words">
<p className="font-medium text-sm">Last send failed</p>
<p
className="text-xs mt-1 whitespace-pre-wrap break-words"
style={{
color:
"color-mix(in oklch, var(--destructive) 80%, transparent)",
}}
>
{failureReason || "Unknown failure (no error message recorded)"}
</p>
{quote.email_failed_at && (
<p className="text-xs text-red-600/80 dark:text-red-300/80 mt-1">
<p
className="text-xs mt-1 opacity-75"
style={{
color:
"color-mix(in oklch, var(--destructive) 70%, transparent)",
}}
>
{new Date(quote.email_failed_at).toLocaleString()}
</p>
)}
@ -2978,7 +2999,11 @@ function ApproveAndSendDialog({
variant="outline"
onClick={handleRetry}
disabled={submitting || quote.status === "sending"}
className="shrink-0 border-red-300 hover:bg-red-100 dark:border-red-500/40 dark:hover:bg-red-500/20"
className="shrink-0"
style={{
borderColor:
"color-mix(in oklch, var(--destructive) 30%, transparent)",
}}
>
{submitting ? (
<RefreshCw className="mr-1.5 h-3.5 w-3.5 animate-spin" />
@ -2993,14 +3018,28 @@ function ApproveAndSendDialog({
{showSendingBanner && (
<div
data-testid="approve-sending-banner"
className="rounded-md border border-blue-300 bg-blue-50 dark:border-blue-500/40 dark:bg-blue-500/10 p-4 flex items-start gap-3"
className="rounded-md border p-4 flex items-start gap-3"
style={{
borderColor:
"color-mix(in oklch, var(--primary) 30%, transparent)",
background:
"color-mix(in oklch, var(--primary) 5%, transparent)",
color: "var(--primary)",
}}
>
<Loader2 className="h-5 w-5 text-blue-600 dark:text-blue-400 shrink-0 mt-0.5 animate-spin" />
<Loader2
className="h-5 w-5 shrink-0 mt-0.5 animate-spin"
style={{ color: "var(--primary)" }}
/>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm text-blue-900 dark:text-blue-100">
Sending email…
</p>
<p className="text-xs text-blue-700 dark:text-blue-200 mt-1">
<p className="font-medium text-sm">Sending email…</p>
<p
className="text-xs mt-1"
style={{
color:
"color-mix(in oklch, var(--primary) 80%, transparent)",
}}
>
{sendingElapsed
? `In progress for ${sendingElapsed}`
: "Just started"}
@ -3029,13 +3068,12 @@ function ApproveAndSendDialog({
{/* Send-email toggle (default ON) */}
<div className="rounded-md border p-4 flex items-start gap-3">
<input
<Checkbox
id="approve-send-email"
type="checkbox"
checked={sendEmail}
onChange={(e) => setSendEmail(e.target.checked)}
onCheckedChange={(v) => setSendEmail(v === true)}
disabled={submitting}
className="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary"
className="mt-1"
/>
<div className="flex-1 min-w-0">
<label htmlFor="approve-send-email" className="text-sm font-medium cursor-pointer">
@ -3048,16 +3086,15 @@ function ApproveAndSendDialog({
</div>
{sendEmail && (
<div className="space-y-4 pl-1 border-l-2 border-primary/20 ml-2 pl-4">
<div className="space-y-4 rounded-lg bg-muted/30 border p-4">
{/* Attach auth letters sub-toggle */}
<div className="flex items-start gap-2">
<input
<Checkbox
id="approve-attach-auth"
type="checkbox"
checked={attachAuthLetters}
onChange={(e) => setAttachAuthLetters(e.target.checked)}
onCheckedChange={(v) => setAttachAuthLetters(v === true)}
disabled={submitting}
className="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary"
className="mt-1"
/>
<div>
<label htmlFor="approve-attach-auth" className="text-sm font-medium cursor-pointer">
@ -3172,13 +3209,12 @@ function ApproveAndSendDialog({
return (
<li key={att.id}>
<label className={`flex items-center gap-2 px-3 py-2 rounded-md border bg-card hover:bg-accent/30 transition-colors cursor-pointer ${!isPdf && submitting ? "opacity-50" : ""}`}>
<input
type="checkbox"
<Checkbox
checked={included}
onChange={() => isPdf ? undefined : toggleAttachment(att.id)}
onCheckedChange={() => isPdf ? undefined : toggleAttachment(att.id)}
disabled={isPdf || submitting}
className="h-4 w-4 rounded border-border text-primary focus:ring-primary"
title={isPdf ? "The PDF is always attached" : "Toggle to include this file"}
aria-label={`Include ${att.filename}`}
/>
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex-1 min-w-0 text-sm truncate">{att.filename}</span>

View file

@ -392,7 +392,7 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
<div className="space-y-4 max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.push(`/quotes/${id}`)}>
<Button variant="ghost" size="icon" onClick={() => router.push(`/quotes/${id}`)} aria-label="Back to quote">
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1">

View file

@ -90,7 +90,7 @@ export default function ApprovalsPage() {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.push("/quotes")}>
<Button variant="ghost" size="icon" onClick={() => router.push("/quotes")} aria-label="Back to quotes">
<ArrowLeft className="h-4 w-4" />
</Button>
<div>

View file

@ -51,7 +51,7 @@ export default function ArchivedQuotesPage() {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.push("/quotes")}>
<Button variant="ghost" size="icon" onClick={() => router.push("/quotes")} aria-label="Back to quotes">
<ArrowLeft className="h-4 w-4" />
</Button>
<div>

View file

@ -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 (
<div className="space-y-6 max-w-5xl">
<div>
<h1 className="text-2xl font-semibold tracking-tight font-heading-theme">Settings</h1>
<p className="text-sm text-muted-foreground mt-1">
Manage your WFDASH preferences.
</p>
</div>
<Separator />
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Palette className="h-4 w-4 text-primary" />
<CardTitle>Appearance</CardTitle>
</div>
<CardDescription>
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.
</CardDescription>
</CardHeader>
<CardContent>
<ThemePicker />
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-primary" />
<CardTitle>About themes</CardTitle>
</div>
<CardDescription>
WFDASH ships <b>{standardCount}</b> standard themes + <b>{standoutCount}</b>{" "}
standout themes, all built on the same brand palette (#4C96D7 blue,
#F2AF58 gold, #E94E49 red).
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<div>
<div className="flex items-center gap-2 font-semibold text-foreground">
<Layers className="h-3.5 w-3.5" />
Standard themes
</div>
<p className="mt-1 leading-relaxed">
Tested against every shadcn/ui component — dialogs, popovers, dropdowns,
sonner toasts, tooltips, forms, tables all render cleanly. Safe defaults
for everyone on the team.
</p>
</div>
<div>
<div className="flex items-center gap-2 font-semibold text-foreground">
<Sparkles className="h-3.5 w-3.5" />
Standout themes
</div>
<p className="mt-1 leading-relaxed">
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.
</p>
</div>
</CardContent>
</Card>
</div>
);
}

View file

@ -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 {

View file

@ -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 (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
suppressHydrationWarning
>
<body className="min-h-full flex flex-col">
<ServiceWorkerRegistration />
<ThemeProvider>
<TooltipProvider>
<AuthProvider>
{children}
</AuthProvider>
</TooltipProvider>
<Toaster richColors />
</ThemeProvider>
</body>
</html>
);
}
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 <html> 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 (
<html
lang="en"
className={`${FONT_VARS} h-full antialiased`}
data-theme="crisp"
suppressHydrationWarning
>
<head>
{/* Apply persisted theme before hydration to prevent flash of wrong theme */}
<ThemeBootstrapScript />
</head>
<body className="min-h-full flex flex-col">
<ServiceWorkerRegistration />
<ThemeProvider>
<TooltipProvider>
<AuthProvider>
{children}
</AuthProvider>
</TooltipProvider>
<Toaster richColors />
</ThemeProvider>
</body>
</html>
);
}

View file

@ -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 (
<div
className="flex items-center justify-center text-sm text-muted-foreground"
style={{ height }}
>
No data yet.
</div>
);
}
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 (
<div>
<svg
viewBox={`0 0 ${w} ${h}`}
className="w-full"
style={{ height }}
preserveAspectRatio="none"
>
<defs>
{series.map((s, i) => (
<linearGradient
key={i}
id={`area-grad-${uid}-${i}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="0%" stopColor={s.color} stopOpacity={0.35} />
<stop offset="100%" stopColor={s.color} stopOpacity={0} />
</linearGradient>
))}
</defs>
{/* Horizontal grid lines */}
{[0, 0.25, 0.5, 0.75, 1].map((p, i) => (
<line
key={i}
x1={padX}
x2={padX + innerW}
y1={padTop + innerH * (1 - p)}
y2={padTop + innerH * (1 - p)}
stroke={withAlpha("#94A3B8", 8)}
strokeWidth={1}
strokeDasharray={p === 0 ? undefined : "3 3"}
/>
))}
{/* 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 (
<g key={si}>
<path
d={areaPath}
fill={`url(#area-grad-${uid}-${si})`}
/>
<path
d={linePath}
fill="none"
stroke={s.color}
strokeWidth={2.25}
strokeLinejoin="round"
strokeLinecap="round"
style={{ filter: `drop-shadow(0 0 6px ${withAlpha(s.color, 45)})` }}
/>
{pts.map((p, i) => (
<circle
key={i}
cx={p.x}
cy={p.y}
r={i === pts.length - 1 ? 3.5 : 0}
fill={s.color}
stroke="#0B1220"
strokeWidth={i === pts.length - 1 ? 1.5 : 0}
/>
))}
</g>
);
})}
{/* 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) => (
<text
key={i}
x={padX + i * step}
y={h - 8}
textAnchor={i === 0 ? "start" : i === labels.length - 1 ? "end" : "middle"}
style={{
fontSize: 10,
fill: "var(--muted-foreground)",
fontWeight: 500,
}}
>
{labels[i]}
</text>
));
})()}
</svg>
{showLegend && (
<div className="flex flex-wrap gap-3 mt-3 text-xs">
{series.map((s, i) => {
const last = s.data[s.data.length - 1] ?? 0;
return (
<div key={i} className="flex items-center gap-1.5">
<span
className="h-2 w-2 rounded-full"
style={{
background: s.color,
boxShadow: `0 0 6px ${withAlpha(s.color, 60)}`,
}}
/>
<span className="text-muted-foreground">{s.label}</span>
<span className="font-semibold tabular-nums">{last}</span>
</div>
);
})}
</div>
)}
</div>
);
}

View file

@ -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 (
<div
className="flex items-center justify-center text-sm text-muted-foreground"
style={{ height }}
>
No data yet.
</div>
);
}
const totals = data.map((d) => d.values.reduce((s, v) => s + v, 0));
const maxValue = Math.max(...totals, 1);
return (
<div>
<div className="flex items-end gap-1.5" style={{ height }}>
{data.map((d, i) => {
const total = totals[i];
const totalH = maxValue === 0 ? 0 : (total / maxValue) * height;
return (
<div
key={i}
className="flex-1 flex flex-col items-stretch justify-end group relative"
style={{ height: "100%" }}
>
{/* Stacked segments from bottom up */}
<div
className="w-full rounded-md overflow-hidden flex flex-col-reverse"
style={{ height: Math.max(totalH, 4) }}
title={`${d.label}: ${total}`}
>
{d.values.map((v, si) => {
const segH = total === 0 ? 0 : (v / total) * 100;
return (
<div
key={si}
style={{
height: `${segH}%`,
background: series[si].color,
boxShadow: `0 0 12px ${withAlpha(series[si].color, 35)}`,
}}
/>
);
})}
</div>
{/* Hover tooltip */}
{total > 0 && (
<div
className="absolute -top-7 left-1/2 -translate-x-1/2 text-[10px] font-semibold opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap px-1.5 py-0.5 rounded"
style={{
background: "rgba(11, 18, 32, 0.92)",
color: "#E6ECF5",
border: "1px solid rgba(76,150,215,0.2)",
}}
>
{total}
</div>
)}
</div>
);
})}
</div>
<div className="flex gap-1.5 mt-2">
{data.map((d, i) => (
<div
key={i}
className="flex-1 text-center text-[10px] font-medium text-muted-foreground truncate"
>
{d.label}
</div>
))}
</div>
</div>
);
}

View file

@ -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 (
<div
className="flex items-center justify-center text-sm text-muted-foreground"
style={{ height: size }}
>
No data yet.
</div>
);
}
// Each ring uses a fraction of its radius — share of total
const ringGap = (maxR - minR) / Math.max(rings.length, 1);
return (
<div className="flex flex-col sm:flex-row items-center gap-4">
<div className="relative shrink-0">
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
<defs>
{rings.map((_, i) => (
<filter
key={i}
id={`ring-glow-${gradId}-${i}`}
x="-20%"
y="-20%"
width="140%"
height="140%"
>
<feGaussianBlur stdDeviation="1.5" />
</filter>
))}
</defs>
{rings.map((r, i) => {
const radius = maxR - i * ringGap;
const fraction = total === 0 ? 0 : r.value / total;
const circumference = 2 * Math.PI * radius;
return (
<g key={i}>
{/* Background track */}
<circle
cx={cx}
cy={cy}
r={radius}
fill="none"
stroke={withAlpha("#94A3B8", 14)}
strokeWidth={stroke}
/>
{/* Active arc */}
<circle
cx={cx}
cy={cy}
r={radius}
fill="none"
stroke={r.color}
strokeWidth={stroke}
strokeDasharray={`${fraction * circumference} ${circumference}`}
transform={`rotate(-90 ${cx} ${cy})`}
strokeLinecap="round"
style={{
filter: `drop-shadow(0 0 6px ${withAlpha(r.color, 60)})`,
}}
/>
</g>
);
})}
{/* Center label */}
<text
x={cx}
y={centerSubLabel ? cy - 6 : cy + 6}
textAnchor="middle"
style={{
fontSize: size * 0.18,
fontFamily: "var(--font-jetbrains-mono), monospace",
fontWeight: 700,
fill: "var(--foreground)",
}}
>
{centerLabel}
</text>
{centerSubLabel && (
<text
x={cx}
y={cy + 14}
textAnchor="middle"
style={{
fontSize: size * 0.07,
letterSpacing: 1.4,
textTransform: "uppercase",
fill: "var(--muted-foreground)",
fontWeight: 600,
}}
>
{centerSubLabel}
</text>
)}
</svg>
</div>
{/* Legend */}
<div className="flex-1 w-full space-y-1.5 text-xs">
{rings.map((r, i) => {
const fraction = total === 0 ? 0 : r.value / total;
return (
<div key={i} className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<span
className="h-2.5 w-2.5 rounded-full shrink-0"
style={{
background: r.color,
boxShadow: `0 0 8px ${withAlpha(r.color, 50)}`,
}}
/>
<span className="truncate">{r.label}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="font-semibold tabular-nums">{r.value}</span>
<span className="text-muted-foreground tabular-nums w-9 text-right">
{Math.round(fraction * 100)}%
</span>
</div>
</div>
);
})}
</div>
</div>
);
}

View file

@ -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 (
<div
className={cn(
"relative rounded-xl border overflow-hidden",
padding,
className
)}
style={{
background: "var(--card)",
borderColor: withAlpha(accentColor ?? "#4C96D7", 18),
boxShadow:
"0 1px 0 color-mix(in oklch, var(--foreground) 4%, transparent) inset, 0 8px 24px rgba(0,0,0,0.06)",
}}
>
{accentColor && (
<div
className="absolute inset-x-0 top-0 h-0.5"
style={{
background: `linear-gradient(90deg, ${accentColor}, ${withAlpha(accentColor, 25)})`,
}}
/>
)}
{(title || action) && (
<div className="flex items-center justify-between gap-3 mb-3">
<div className="flex items-center gap-2 min-w-0">
{Icon && accentColor && (
<div
className="grid h-7 w-7 place-items-center rounded-md shrink-0"
style={{
background: withAlpha(accentColor, 14),
color: accentColor,
}}
>
<Icon className="h-3.5 w-3.5" style={{ color: accentColor }} />
</div>
)}
<div className="min-w-0">
{title && (
<div className="text-sm font-semibold truncate">{title}</div>
)}
{subtitle && (
<div className="text-[11px] text-muted-foreground truncate">
{subtitle}
</div>
)}
</div>
</div>
{action}
</div>
)}
{children}
</div>
);
}

View file

@ -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 (
<div
className="flex items-center justify-center text-sm text-muted-foreground"
style={{ height: size }}
>
No data yet.
</div>
);
}
// Build segments with running cumulative offset via reduce (lint-friendly)
const segments = buckets.reduce<
Array<DonutBucket & { dash: number; offset: number; fraction: number }>
>((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 (
<div className="flex flex-col sm:flex-row items-center gap-4">
<div className="relative shrink-0">
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
<defs>
{segments.map((s, i) => (
<filter
key={i}
id={`glow-${gradId}-${i}`}
x="-20%"
y="-20%"
width="140%"
height="140%"
>
<feGaussianBlur stdDeviation="2.5" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
))}
</defs>
{/* Background ring */}
<circle
cx={cx}
cy={cy}
r={r}
fill="none"
stroke={withAlpha("#94A3B8", 14)}
strokeWidth={stroke}
/>
{segments.map((s, i) => (
<circle
key={i}
cx={cx}
cy={cy}
r={r}
fill="none"
stroke={s.color}
strokeWidth={stroke}
strokeDasharray={`${s.dash} ${circumference}`}
strokeDashoffset={s.offset}
transform={`rotate(-90 ${cx} ${cy})`}
strokeLinecap="butt"
filter={`url(#glow-${gradId}-${i})`}
/>
))}
{/* Center label */}
<text
x={cx}
y={centerSubLabel ? cy - 6 : cy + 6}
textAnchor="middle"
style={{
fontSize: size * 0.16,
fontFamily: "var(--font-heading)",
fontWeight: 700,
fill: "var(--foreground)",
}}
>
{centerLabel}
</text>
{centerSubLabel && (
<text
x={cx}
y={cy + 14}
textAnchor="middle"
style={{
fontSize: size * 0.07,
letterSpacing: 1.4,
textTransform: "uppercase",
fill: "var(--muted-foreground)",
fontWeight: 600,
}}
>
{centerSubLabel}
</text>
)}
</svg>
</div>
{/* Legend */}
<div className="flex-1 w-full space-y-1.5 text-xs">
{segments.map((s, i) => (
<div key={i} className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<span
className="h-2.5 w-2.5 rounded-full shrink-0"
style={{
background: s.color,
boxShadow: `0 0 8px ${withAlpha(s.color, 50)}`,
}}
/>
<span className="truncate">{s.label}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="font-semibold tabular-nums">{s.value}</span>
<span className="text-muted-foreground tabular-nums w-9 text-right">
{Math.round(s.fraction * 100)}%
</span>
</div>
</div>
))}
</div>
</div>
);
}

View file

@ -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 (
<div className="py-6 text-center text-sm text-muted-foreground">
No data yet.
</div>
);
}
const max = maxValue ?? Math.max(...data.map((d) => d.value), 1);
return (
<div className="space-y-2.5">
{data.map((d, i) => {
const pct = (d.value / max) * 100;
const color = d.color ?? "#4C96D7";
return (
<div key={i} className="text-xs">
<div className="flex items-center justify-between gap-2 mb-1">
<span className="font-medium truncate">{d.label}</span>
<span className="font-bold tabular-nums shrink-0">
{d.value.toLocaleString()}
{unit}
</span>
</div>
<div
className="h-2 w-full rounded-full overflow-hidden"
style={{ background: withAlpha(color, 12) }}
>
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${pct}%`,
background: `linear-gradient(90deg, ${color}, ${withAlpha(color, 70)})`,
boxShadow: `0 0 8px ${withAlpha(color, 50)}`,
}}
/>
</div>
</div>
);
})}
</div>
);
}

View file

@ -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 (
<div
className="flex items-center justify-center text-sm text-muted-foreground"
style={{ height }}
>
No data yet.
</div>
);
}
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 (
<svg
viewBox={`0 0 ${w} ${height}`}
className="w-full"
style={{ height }}
preserveAspectRatio="none"
>
{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 (
<g key={si}>
{/* Glow layer */}
<path
d={linePath}
fill="none"
stroke={s.color}
strokeWidth={4}
strokeLinecap="round"
strokeLinejoin="round"
opacity={0.3}
style={{ filter: `blur(3px)` }}
/>
{/* Crisp line */}
<path
d={linePath}
fill="none"
stroke={s.color}
strokeWidth={2.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Dots: every data point */}
{pts.map((p, i) => (
<g key={i}>
<circle
cx={p.x}
cy={p.y}
r={4.5}
fill="#0B1220"
stroke={s.color}
strokeWidth={2}
style={{
filter: `drop-shadow(0 0 4px ${withAlpha(s.color, 60)})`,
}}
/>
<circle cx={p.x} cy={p.y} r={1.5} fill={s.color} />
</g>
))}
</g>
);
})}
{/* 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) => (
<text
key={i}
x={padX + i * step}
y={height - 6}
textAnchor={
i === 0 ? "start" : i === labels.length - 1 ? "end" : "middle"
}
style={{
fontSize: 10,
fill: "var(--muted-foreground)",
fontWeight: 500,
}}
>
{labels[i]}
</text>
));
})()}
</svg>
);
}

View file

@ -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}`;
}

View file

@ -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 (
<div>
<div className="flex items-center justify-between text-xs mb-1.5">
<span className="font-medium truncate">{label}</span>
<span className="text-muted-foreground tabular-nums shrink-0">
<span className="font-semibold text-foreground">{value}</span>
{unit ? ` ${unit}` : ""} · {pct}%
</span>
</div>
<div
className="h-2 w-full rounded-full overflow-hidden"
style={{ background: withAlpha(color, 12) }}
>
<div
className="h-full rounded-full"
style={{
width: `${pct}%`,
background: `linear-gradient(90deg, ${color}, ${withAlpha(color, 60)})`,
boxShadow: `0 0 8px ${withAlpha(color, 50)}`,
}}
/>
</div>
</div>
);
}

View file

@ -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(
<line
key={i}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke={withAlpha("#94A3B8", isMajor ? 50 : 25)}
strokeWidth={isMajor ? 1.5 : 1}
strokeLinecap="round"
/>
);
}
// 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 (
<div className="flex flex-col items-center gap-2">
<div className="relative">
<svg width={size} height={size * 0.72} viewBox={`0 0 ${size} ${size * 0.72}`}>
<defs>
<filter id={`gauge-glow-${gradId}`} x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="3" />
</filter>
</defs>
{tickElements}
{/* Background track */}
<path
d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none"
stroke={withAlpha("#94A3B8", 14)}
strokeWidth={stroke}
strokeLinecap="round"
/>
{/* Active arc */}
<path
d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none"
stroke={color}
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray={`${fraction * circumference} ${circumference}`}
style={{
filter: `drop-shadow(0 0 10px ${withAlpha(color, 60)})`,
}}
/>
{/* Cap dot */}
<circle
cx={capX}
cy={capY}
r={stroke * 0.55}
fill="#fff"
stroke={color}
strokeWidth={2}
style={{ filter: `drop-shadow(0 0 6px ${withAlpha(color, 70)})` }}
/>
{/* Center percentage */}
<text
x={cx}
y={cy - 4}
textAnchor="middle"
style={{
fontSize: size * 0.22,
fontFamily: "var(--font-jetbrains-mono), monospace",
fontWeight: 700,
fill: "var(--foreground)",
}}
>
{pct}%
</text>
{label && (
<text
x={cx}
y={cy + 18}
textAnchor="middle"
style={{
fontSize: size * 0.07,
letterSpacing: 1.4,
textTransform: "uppercase",
fill: "var(--muted-foreground)",
fontWeight: 600,
}}
>
{label}
</text>
)}
</svg>
</div>
{sublabel && (
<div className="text-xs text-muted-foreground -mt-1">{sublabel}</div>
)}
</div>
);
}

View file

@ -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 <div style={{ width, height }} aria-hidden />;
}
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 (
<svg width={width} height={height} className="overflow-visible shrink-0">
<defs>
<linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.45} />
<stop offset="100%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<polygon points={areaPoints} fill={`url(#${id})`} />
<polyline
points={points}
fill="none"
stroke={color}
strokeWidth={2}
strokeLinejoin="round"
strokeLinecap="round"
/>
<circle cx={lastX} cy={lastY} r={3.5} fill={color} />
<circle cx={lastX} cy={lastY} r={6} fill={color} fillOpacity={0.25} />
</svg>
);
}

View file

@ -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<keyof CustomThemeColors, string> = {
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<CustomThemeColors>(DEFAULT_CUSTOM_COLORS);
const [name, setName] = useState("My theme");
const [editingId, setEditingId] = useState<string | null>(null);
const [snapshot, setSnapshot] = useState<Record<string, string> | null>(null);
const [existingThemes, setExistingThemes] = useState<CustomTheme[]>([]);
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 <html> 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 (
<Dialog
open={open}
onOpenChange={(o) => (o ? onOpenChange(true) : handleCancel())}
>
<DialogContent
showCloseButton={false}
className="max-w-[calc(100%-1rem)] sm:max-w-xl md:max-w-3xl lg:max-w-4xl w-full p-0 gap-0 max-h-[min(90vh,800px)] flex flex-col overflow-hidden rounded-xl"
>
{/* ============ HEADER (sticky top) ============ */}
<div className="px-5 sm:px-6 py-4 border-b shrink-0">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="grid h-7 w-7 place-items-center rounded-md bg-primary/10 text-primary shrink-0">
<Sparkles className="h-3.5 w-3.5" />
</div>
<DialogTitle className="text-base font-semibold">
Customize theme
</DialogTitle>
</div>
<DialogDescription className="mt-1 text-xs">
Pick 7 colors. The rest is automatic.
</DialogDescription>
</div>
<button
type="button"
onClick={handleCancel}
aria-label="Close"
className="grid h-8 w-8 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors shrink-0 -mt-1 -mr-1"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* ============ BODY (scrollable middle) ============ */}
<div className="flex-1 overflow-y-auto">
<div className="grid grid-cols-1 md:grid-cols-[1fr_240px] min-h-0">
{/* EDITOR */}
<div className="p-5 sm:p-6 space-y-5 min-w-0">
{/* Presets — collapsed by default */}
<details
open={presetsOpen}
onToggle={(e) => setPresetsOpen((e.target as HTMLDetailsElement).open)}
className="group rounded-lg border bg-card/40"
>
<summary className="flex items-center justify-between cursor-pointer px-3 py-2 list-none select-none [&::-webkit-details-marker]:hidden">
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
Start from a preset
</span>
<ChevronDown
className={cn(
"h-3.5 w-3.5 text-muted-foreground transition-transform",
presetsOpen && "rotate-180"
)}
/>
</summary>
<div className="px-3 pb-3 pt-1">
<div className="flex flex-wrap gap-1.5">
{THEMES.map((t) => (
<button
key={t.id}
type="button"
onClick={() => 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}
</button>
))}
</div>
</div>
</details>
{/* Color pickers — 2-column grid at sm+ */}
<div>
<div className="flex items-center justify-between mb-3">
<div className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
Colors
</div>
{failedChecks.length > 0 && (
<div
className="inline-flex items-center gap-1 text-[11px] font-semibold"
style={{
color: "var(--destructive)",
}}
>
<AlertTriangle className="h-3 w-3" />
{failedChecks.length} issue{failedChecks.length === 1 ? "" : "s"}
</div>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1">
{COLOR_FIELDS.map((f) => (
<ColorRow
key={f.key}
fieldKey={f.key}
label={f.label}
value={colors[f.key]}
onChange={(hex) =>
setColors((c) => ({ ...c, [f.key]: hex }))
}
/>
))}
</div>
</div>
{/* Contrast warnings — compact inline */}
{failedChecks.length > 0 && (
<div
className="rounded-lg border p-3 space-y-2"
style={{
borderColor:
"color-mix(in oklch, var(--destructive) 30%, transparent)",
background:
"color-mix(in oklch, var(--destructive) 5%, transparent)",
}}
>
<div className="flex items-center justify-between gap-2">
<div
className="text-xs font-semibold"
style={{ color: "var(--destructive)" }}
>
Contrast below WCAG AA
</div>
<Button
type="button"
size="sm"
variant="outline"
onClick={handleAutoFixContrast}
className="h-7 gap-1.5 text-xs"
>
<Wand2 className="h-3 w-3" />
Auto-fix
</Button>
</div>
<ul
className="text-[11px] space-y-0.5"
style={{ color: "var(--destructive)" }}
>
{failedChecks.map((c) => (
<li
key={c.label}
className="flex items-center justify-between gap-2"
>
<span>{c.label}</span>
<span className="font-mono tabular-nums opacity-80">
{c.ratio.toFixed(1)}:1
</span>
</li>
))}
</ul>
</div>
)}
{/* Theme name */}
<div>
<label
htmlFor="theme-name"
className="text-xs font-semibold uppercase tracking-widest text-muted-foreground"
>
Theme name
</label>
<Input
id="theme-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My theme"
className="mt-1.5"
/>
</div>
</div>
{/* SIDE PANEL — sticky on md+ */}
<div
className="md:sticky md:top-0 md:self-start md:max-h-[calc(90vh-200px)] p-5 space-y-5 border-t md:border-t-0 md:border-l bg-muted/30"
>
{/* Live preview */}
<div>
<div className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-2">
Preview
</div>
<PreviewMock colors={colors} />
</div>
{/* Saved themes */}
<div>
<div className="flex items-center justify-between mb-2">
<div className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
Saved themes
</div>
<span className="text-[10px] text-muted-foreground tabular-nums">
{existingThemes.length}
</span>
</div>
<div className="space-y-1">
{existingThemes.length === 0 ? (
<div className="text-[11px] text-muted-foreground py-3 text-center border border-dashed rounded-md">
No saved themes yet
</div>
) : (
existingThemes.map((t) => (
<SavedThemeRow
key={t.id}
theme={t}
active={editingId === t.id}
onSelect={() => {
setColors(t.colors);
setName(t.name);
setEditingId(t.id);
}}
onDelete={() => handleDelete(t.id)}
/>
))
)}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setColors(DEFAULT_CUSTOM_COLORS);
setName("New theme");
setEditingId(null);
}}
className="w-full mt-2 gap-1.5 h-8 text-xs"
>
<Plus className="h-3.5 w-3.5" />
New
</Button>
</div>
</div>
</div>
</div>
{/* ============ FOOTER (sticky bottom) ============ */}
<div className="px-5 sm:px-6 py-3 border-t shrink-0 bg-muted/20 flex items-center justify-between gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleReset}
className="gap-1.5 text-muted-foreground"
>
<RotateCcw className="h-3.5 w-3.5" />
Reset
</Button>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleCancel}
>
Cancel
</Button>
<Button
type="button"
size="sm"
onClick={handleSave}
className="gap-1.5"
>
<Check className="h-3.5 w-3.5" />
{editingId ? "Update theme" : "Save theme"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
/* ============================================================================
* 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<string, string> | 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<string, string> = {};
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<HTMLInputElement>) => {
const next = e.target.value;
setText(next);
if (/^#[0-9A-Fa-f]{6}$/.test(next)) onChange(next);
};
const handleNativeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value);
};
return (
<div className="flex items-center gap-2.5 py-1">
<label
htmlFor={inputId}
title={FIELD_DESCRIPTIONS[fieldKey]}
className="relative h-8 w-8 shrink-0 rounded-md overflow-hidden border cursor-pointer shadow-sm ring-1 ring-black/5"
style={{ background: value }}
>
<input
id={inputId}
type="color"
value={value}
onChange={handleNativeChange}
className="absolute inset-0 opacity-0 cursor-pointer"
aria-label={`${label} color`}
/>
</label>
<span
className="text-sm font-medium flex-1 truncate"
title={FIELD_DESCRIPTIONS[fieldKey]}
>
{label}
</span>
<input
type="text"
value={text}
onChange={handleTextChange}
spellCheck={false}
aria-label={`${label} hex value`}
className="w-[88px] h-8 px-2 text-[11px] font-mono rounded-md border bg-background text-foreground tabular-nums"
/>
</div>
);
}
/* ============================================================================
* SavedThemeRow — compact saved-theme entry in the side panel
* ========================================================================== */
function SavedThemeRow({
theme,
active,
onSelect,
onDelete,
}: {
theme: CustomTheme;
active: boolean;
onSelect: () => void;
onDelete: () => void;
}) {
return (
<div
className={cn(
"group flex items-center gap-2 p-1.5 rounded-md border text-xs transition-colors",
active
? "border-primary bg-primary/10"
: "border-border hover:border-primary/40 hover:bg-background"
)}
>
<button
type="button"
onClick={onSelect}
className="flex items-center gap-2 flex-1 min-w-0 text-left"
>
<div className="flex gap-0.5 shrink-0">
<span
className="h-3 w-3 rounded-sm"
style={{ background: theme.colors.primary }}
/>
<span
className="h-3 w-3 rounded-sm"
style={{ background: theme.colors.accent }}
/>
<span
className="h-3 w-3 rounded-sm border"
style={{
background: theme.colors.background,
borderColor: "rgba(0,0,0,.08)",
}}
/>
</div>
<div className="font-medium truncate flex-1">{theme.name}</div>
</button>
<button
type="button"
onClick={onDelete}
aria-label={`Delete ${theme.name}`}
className="p-1 rounded text-muted-foreground opacity-0 group-hover:opacity-100 hover:bg-destructive/10 hover:text-destructive transition-all"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
);
}
/* ============================================================================
* 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 (
<div
className="rounded-lg overflow-hidden border text-xs shadow-sm"
style={{
...surfaceStyle,
borderColor: tokens["--border"],
}}
>
<div className="flex" style={{ height: 180 }}>
<div className="w-12 p-2 space-y-1.5 shrink-0" style={sidebarStyle}>
<div
className="h-4 w-4 rounded grid place-items-center text-[8px] font-bold mb-1.5"
style={primaryStyle}
>
W
</div>
{["Q", "C", "O"].map((_, i) => (
<div
key={i}
className="rounded"
style={{
background:
i === 0 ? tokens["--primary"] : "rgba(255,255,255,0.15)",
opacity: i === 0 ? 1 : 0.5,
height: i === 0 ? 5 : 4,
}}
/>
))}
</div>
<div className="flex-1 p-2 space-y-1.5 min-w-0">
<div className="flex items-center justify-between gap-1">
<div className="font-semibold text-[10px] truncate">Dashboard</div>
<div
className="px-1.5 py-0.5 rounded text-[8px] font-semibold shrink-0"
style={primaryStyle}
>
+ New
</div>
</div>
<div className="grid grid-cols-2 gap-1">
<div
className="p-1 rounded border"
style={{ borderColor: tokens["--border"] }}
>
<div className="text-[7px] opacity-60 uppercase tracking-wider">
Customers
</div>
<div className="text-[11px] font-bold leading-tight">248</div>
</div>
<div
className="p-1 rounded border"
style={{ borderColor: tokens["--border"] }}
>
<div className="text-[7px] opacity-60 uppercase tracking-wider">
Quotes
</div>
<div className="text-[11px] font-bold leading-tight">14</div>
</div>
</div>
<div className="flex gap-1 pt-0.5">
<div
className="px-1.5 py-0.5 rounded text-[8px] font-semibold"
style={primaryStyle}
>
Primary
</div>
<div
className="px-1.5 py-0.5 rounded text-[8px] font-semibold"
style={accentStyle}
>
Accent
</div>
</div>
</div>
</div>
</div>
);
}

View file

@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl w-[calc(100%-2rem)] max-h-[90vh] p-0 gap-0 flex flex-col">
<DialogHeader className="px-6 pt-5 pb-3">
<DialogContent className="max-w-2xl md:max-w-3xl lg:max-w-4xl w-[calc(100%-2rem)] max-h-[90vh] p-0 gap-0 flex flex-col">
<DialogHeader className="px-6 py-4 border-b shrink-0 space-y-1">
<DialogTitle className="flex items-center gap-2 text-base">
<Sparkles className="h-4 w-4 text-primary" />
Rewrite quote items
@ -360,11 +359,11 @@ export default function RewriteQuoteModal({
{!results ? (
// ───────────────────────────────────────── Setup state
<div className="flex-1 min-h-0 flex flex-col">
<div className="px-6 pb-3 space-y-3 shrink-0">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
<div className="px-6 py-3 shrink-0 border-b">
<div className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Transformations
</div>
<div className="grid grid-cols-2 gap-2">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-1.5">
{TRANSFORMATIONS.map((t) => (
<GlobalOptionToggle
key={t.key}
@ -378,8 +377,6 @@ export default function RewriteQuoteModal({
</div>
</div>
<Separator className="shrink-0" />
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-3">
<div
className="sticky top-0 z-20 -mx-6 px-6 py-2.5 mb-3 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80"
@ -389,7 +386,7 @@ export default function RewriteQuoteModal({
Items ({includedIds.size} of {items.length} included)
</div>
</div>
<ul className="space-y-2" data-testid="rewrite-item-list">
<ul className="space-y-1.5" data-testid="rewrite-item-list">
{items.map((item, i) => {
const included = includedIds.has(item.id);
const opts = perItemOptions[item.id] ?? globalOptions;
@ -397,7 +394,7 @@ export default function RewriteQuoteModal({
<li
key={item.id}
className={cn(
"rounded-md border p-3 space-y-2",
"rounded-md border bg-card p-2.5 space-y-1.5",
!included && "opacity-60"
)}
data-testid={`rewrite-item-${item.id}`}
@ -470,7 +467,7 @@ export default function RewriteQuoteModal({
size="sm"
onClick={callRewrite}
disabled={loading || includedIds.size === 0 || tickedCount === 0}
className="bg-amber-400 hover:bg-amber-500 text-amber-950 border-amber-500 shadow-sm font-semibold"
className="font-semibold shadow-sm"
>
{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}
>

View file

@ -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<CategoryId, string> = {
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<CategoryId, string> = {
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 (
<Link
href={item.href}
onClick={onClick}
style={inactiveStyle}
className={`group flex items-center gap-3 rounded-xl ${
indent ? "px-2.5 py-2 text-sm" : "px-3 py-2.5 text-sm"
} font-medium transition-all duration-150 ${
active
? "bg-primary text-primary-foreground shadow-sm"
: "hover:opacity-80"
}`}
>
<item.icon
className={`${indent ? "h-4 w-4" : "h-[18px] w-[18px]"} shrink-0`}
style={active ? { color: "var(--primary-foreground)" } : { color }}
/>
{item.label}
</Link>
);
}
/* ============================================================================
* 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 (
<div
className="rounded-xl border p-1.5"
style={{
background: withAlpha(color, open ? 10 : 6),
borderColor: withAlpha(color, open ? 28 : 18),
}}
>
<button
type="button"
onClick={onToggle}
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-xs font-semibold uppercase tracking-wide transition-colors ${
headerActive ? "" : "hover:opacity-80"
}`}
style={{
color: headerActive ? color : withAlpha(color, 78),
}}
>
<HeaderIcon
className="h-3.5 w-3.5"
style={{ color: headerActive ? color : color }}
/>
{CATEGORY_LABELS[category]}
<ChevronDown
className={`ml-auto h-3.5 w-3.5 transition-transform ${open ? "rotate-180" : ""}`}
style={{ color: withAlpha(color, 60) }}
/>
</button>
{open && (
<div className="mt-1 space-y-0.5">
{items.map((item) => {
const isActive =
item.href === "/quotes"
? pathname === "/quotes"
: pathname === item.href || pathname.startsWith(item.href + "/");
return (
<NavItem
key={item.href}
item={item}
active={isActive}
onClick={onLinkClick}
indent
/>
);
})}
</div>
)}
</div>
);
}
/* ============================================================================
* 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 (
<div className="flex flex-col h-full">
{/* Brand block */}
<div className="px-5 pt-6 pb-4">
<div className="flex items-center gap-3">
<div className="grid size-10 place-items-center rounded-xl bg-primary text-primary-foreground shadow-sm">
<Package className="h-5 w-5" />
</div>
<div>
<h1 className="text-lg font-semibold tracking-tight text-foreground">WFDASH</h1>
<p className="text-xs text-muted-foreground mt-0.5">Westcott Fasteners</p>
<h1 className="text-lg font-semibold tracking-tight text-foreground font-heading-theme">
WFDASH
</h1>
<p className="text-xs text-muted-foreground mt-0.5">
Westcott Fasteners
</p>
</div>
</div>
{/* Brand color stripe */}
<div className="mt-4 grid grid-cols-3 gap-1">
<span className="h-1.5 rounded-full bg-primary" />
<span className="h-1.5 rounded-full bg-destructive" />
<span className="h-1.5 rounded-full bg-accent" />
<span className="h-1.5 rounded-full" style={{ background: "#4C96D7" }} />
<span className="h-1.5 rounded-full" style={{ background: "#F2AF58" }} />
<span className="h-1.5 rounded-full" style={{ background: "#E94E49" }} />
</div>
</div>
<Separator className="mx-3" />
{/* Main nav */}
<ScrollArea className="flex-1 px-3 py-3">
<nav className="space-y-1">
<Link
href="/dashboard"
<nav className="space-y-1.5">
{/* Dashboard — flat top item, no accordion */}
<NavItem
item={dashboardItems[0]}
active={isDashboardActive}
onClick={onLinkClick}
className={`flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-all duration-150 ${
pathname === "/dashboard" || pathname.startsWith("/dashboard/")
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
}`}
>
<LayoutDashboard className="h-[18px] w-[18px] shrink-0" />
Dashboard
</Link>
/>
<div className="rounded-xl border border-sidebar-border/70 bg-sidebar-accent/45 p-1.5">
<button
type="button"
onClick={() => 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"
>
<Users className="h-3.5 w-3.5" />
Management
<ChevronDown className={`ml-auto h-3.5 w-3.5 transition-transform ${managementOpen ? "rotate-180" : ""}`} />
</button>
{managementOpen && (
<div className="mt-1 space-y-0.5">
{managementItems.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(item.href + "/");
return (
<Link
key={item.href}
href={item.href}
onClick={onLinkClick}
className={`flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors ${
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-background/70 hover:text-foreground"
}`}
>
<item.icon className="h-4 w-4 shrink-0" />
{item.label}
</Link>
);
})}
</div>
)}
{/* Management — accordion */}
<NavGroup
category="management"
open={managementOpen}
onToggle={() => setManagementOpen((o) => !o)}
headerActive={isManagementActive}
headerIcon={Users}
items={managementItems}
pathname={pathname}
onLinkClick={onLinkClick}
/>
{/* Quotes — accordion */}
<NavGroup
category="quotes"
open={quotesOpen}
onToggle={() => 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 */}
<OperationsList
active={isOperationsActive}
pathname={pathname}
onLinkClick={onLinkClick}
/>
</nav>
</ScrollArea>
<Separator className="mx-3" />
{/* Bottom block: settings (icon-only) + theme + avatar + logout */}
<div className="p-3 space-y-2">
{/* Settings — small icon-only button bottom-left */}
<Link
href="/settings"
onClick={onLinkClick}
aria-label="Settings"
title="Settings"
className="grid size-9 place-items-center rounded-xl text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<SettingsIcon className="h-[18px] w-[18px]" />
</Link>
{/* Theme toggle */}
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-3 text-muted-foreground hover:text-foreground h-9 px-2.5 rounded-xl"
onClick={() => setTheme(theme === "dark" ? "warm" : "dark")}
>
{theme === "dark" ? (
<>
<Sun className="h-[18px] w-[18px]" />
<span className="text-sm">Light Mode</span>
</>
) : (
<>
<Moon className="h-[18px] w-[18px]" />
<span className="text-sm">Dark Mode</span>
</>
)}
</Button>
{/* User */}
<div className="flex items-center gap-3 px-2.5 py-2">
<Avatar className="h-9 w-9 shrink-0 ring-2 ring-border/50">
<AvatarFallback className="text-xs font-medium bg-muted">
{user?.name?.charAt(0)?.toUpperCase() ||
user?.email?.charAt(0)?.toUpperCase() ||
"U"}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{user?.name || "User"}
</p>
<p className="text-xs text-muted-foreground truncate">
{user?.email}
</p>
</div>
</div>
<div className="rounded-xl border border-sidebar-border/70 bg-sidebar-accent/45 p-1.5">
<button
type="button"
onClick={() => 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"
}`}
>
<FileText className="h-3.5 w-3.5" />
Quotes
<ChevronDown className={`ml-auto h-3.5 w-3.5 transition-transform ${quotesOpen ? "rotate-180" : ""}`} />
</button>
{quotesOpen && (
<div className="mt-1 space-y-0.5">
{quoteItems.map((item) => {
const isActive = item.href === "/quotes"
? pathname === "/quotes"
: pathname === item.href || pathname.startsWith(item.href + "/");
return (
<Link
key={item.href}
href={item.href}
onClick={onLinkClick}
className={`flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors ${
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-background/70 hover:text-foreground"
}`}
>
<item.icon className="h-4 w-4 shrink-0" />
{item.label}
</Link>
);
})}
</div>
)}
</div>
{navItems.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(item.href + "/");
return (
<div key={item.href}>
<Link
href={item.href}
onClick={onLinkClick}
className={`flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-all duration-150 ${
isActive
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
}`}
>
<item.icon className="h-[18px] w-[18px] shrink-0" />
{item.label}
</Link>
</div>
);
})}
</nav>
</ScrollArea>
<Separator className="mx-3" />
<div className="p-3 space-y-2">
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-3 text-muted-foreground hover:text-foreground h-9 px-2.5 rounded-xl"
onClick={() => setTheme(theme === "dark" ? "warm" : "dark")}
>
{theme === "dark" ? (
<>
<Sun className="h-[18px] w-[18px]" />
<span className="text-sm">Light Mode</span>
</>
) : (
<>
<Moon className="h-[18px] w-[18px]" />
<span className="text-sm">Dark Mode</span>
</>
)}
</Button>
<div className="flex items-center gap-3 px-2.5 py-2">
<Avatar className="h-9 w-9 shrink-0 ring-2 ring-border/50">
<AvatarFallback className="text-xs font-medium bg-muted">
{user?.name?.charAt(0)?.toUpperCase() || user?.email?.charAt(0)?.toUpperCase() || "U"}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{user?.name || "User"}</p>
<p className="text-xs text-muted-foreground truncate">{user?.email}</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-3 text-destructive hover:text-destructive hover:bg-destructive/8 h-9 px-2.5 rounded-xl"
onClick={logout}
>
<LogOut className="h-[18px] w-[18px]" />
<span className="text-sm">Logout</span>
</Button>
</div>
</div>
);
}
export function Sidebar() {
const [open, setOpen] = useState(false);
return (
<>
<aside className="hidden lg:flex lg:w-60 lg:flex-col bg-sidebar border-r border-sidebar-border">
<NavContent />
</aside>
<div className="lg:hidden fixed top-0 left-0 right-0 z-40 flex items-center gap-3 border-b bg-background/95 backdrop-blur-sm px-4 h-14">
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger
render={<Button variant="ghost" size="icon" className="h-9 w-9" />}
>
<Menu className="h-5 w-5" />
</SheetTrigger>
<SheetContent side="left" className="w-60 p-0">
<SheetTitle className="sr-only">Navigation</SheetTitle>
<NavContent onLinkClick={() => setOpen(false)} />
</SheetContent>
</Sheet>
<h1 className="text-lg font-semibold">WFDASH</h1>
</div>
</>
);
{/* Logout */}
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-3 text-destructive hover:text-destructive hover:bg-destructive/8 h-9 px-2.5 rounded-xl"
onClick={logout}
>
<LogOut className="h-[18px] w-[18px]" />
<span className="text-sm">Logout</span>
</Button>
</div>
</div>
);
}
/* ============================================================================
* 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 (
<div
className="rounded-xl border p-1.5 space-y-0.5"
style={{
background: withAlpha(color, active ? 10 : 6),
borderColor: withAlpha(color, active ? 28 : 18),
}}
>
{/* Tiny header label so the category is identifiable */}
<div
className="px-2 py-1 text-xs font-semibold uppercase tracking-wide flex items-center gap-2"
style={{ color: withAlpha(color, 78) }}
>
<ShoppingCart className="h-3.5 w-3.5" style={{ color }} />
Operations
</div>
{operationsItems.map((item) => {
const isActive =
pathname === item.href || pathname.startsWith(item.href + "/");
return (
<NavItem
key={item.href}
item={item}
active={isActive}
onClick={onLinkClick}
indent
/>
);
})}
</div>
);
}
/* ============================================================================
* Sidebar — desktop + mobile (Sheet) wrappers
* ========================================================================== */
export function Sidebar() {
const [open, setOpen] = useState(false);
return (
<>
<aside className="hidden lg:flex lg:w-60 lg:flex-col bg-sidebar border-r border-sidebar-border">
<NavContent />
</aside>
<div className="lg:hidden fixed top-0 left-0 right-0 z-40 flex items-center gap-3 border-b bg-background/95 backdrop-blur-sm px-4 h-14">
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger
render={<Button variant="ghost" size="icon" className="h-9 w-9" />}
>
<Menu className="h-5 w-5" />
</SheetTrigger>
<SheetContent side="left" className="w-60 p-0">
<SheetTitle className="sr-only">Navigation</SheetTitle>
<NavContent onLinkClick={() => setOpen(false)} />
</SheetContent>
</Sheet>
<h1 className="text-lg font-semibold font-heading-theme">WFDASH</h1>
</div>
</>
);
}

View file

@ -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<string>();
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 = '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>';
@ -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<string, SupplierCluster>();
clusters.forEach((cluster) => cluster.suppliers.forEach((supplier) => clusterBySupplierId.set(supplier.id, cluster)));
const clusterBySupplierId = useMemo(() => {
const m = new Map<string, SupplierCluster>();
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 (
<div className="space-y-5">
@ -328,8 +387,8 @@ export function SupplierMap() {
</div>
</div>
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
<Card className={`overflow-hidden border-primary/10 ${isFullscreen ? "fixed inset-0 z-[45] rounded-none border-0" : "relative z-0"}`}>
<div className="grid items-start gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
<Card className={`overflow-hidden border-primary/10 py-0 ${isFullscreen ? "fixed inset-0 z-[45] rounded-none border-0" : "relative z-0"}`}>
<div className={`${isFullscreen ? "h-dvh min-h-0" : "h-[68vh] min-h-[520px]"} relative`}>
<div className="absolute right-3 top-3 z-[30] flex flex-col gap-2 sm:flex-row">
{editMode && (
@ -349,7 +408,11 @@ export function SupplierMap() {
</Button>
</div>
<MapContainer center={mapCenter} zoom={mapped.length ? 10 : 6} scrollWheelZoom className="h-full w-full">
<MapWatcher isFullscreen={isFullscreen} onZoomChange={setZoom} />
<MapWatcher
isFullscreen={isFullscreen}
onZoomChange={setZoom}
onViewportChange={setViewport}
/>
<TileLayer
key={isDarkMap ? "dark" : "light"}
attribution={isDarkMap ? DARK_TILE_ATTRIBUTION : LIGHT_TILE_ATTRIBUTION}
@ -386,7 +449,7 @@ export function SupplierMap() {
},
}}
>
{showLabels && (
{showLabels && labeledIds.has(supplier.id) && (
<Tooltip direction="top" offset={[0, -12]} permanent className="supplier-map-label">
{supplier.suppliername || "Unnamed supplier"}
</Tooltip>

View file

@ -0,0 +1,95 @@
/**
* Inline script that runs in <head> before hydration.
*
* Reads the persisted theme id + mode from localStorage and applies them
* to <html> 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 <script dangerouslySetInnerHTML={{ __html: code }} />;
}

View file

@ -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<CustomTheme[]>([]);
const [modalOpen, setModalOpen] = useState(false);
const [editingCustomId, setEditingCustomId] = useState<string | null>(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 (
<div className="space-y-6">
{/* Mode toggle */}
<div className="space-y-2">
<div className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
Mode
</div>
<div className="grid grid-cols-2 gap-2 max-w-sm">
<button
type="button"
onClick={() => 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"
)}
>
<Sun className="h-4 w-4" />
Light
</button>
<button
type="button"
onClick={() => 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"
)}
>
<Moon className="h-4 w-4" />
Dark
</button>
</div>
</div>
{/* Standard themes */}
<div className="space-y-3">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-muted-foreground">
Standard themes
<span className="text-[10px] font-normal text-muted-foreground/70 normal-case tracking-normal">
· ship cleanly with shadcn/ui
</span>
</div>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
{standardThemes.map((t) => (
<ThemeCard
key={t.id}
theme={t}
active={themeId === t.id}
onSelect={() => handleSelectPreset(t.id)}
/>
))}
</div>
</div>
{/* Standout themes */}
<div className="space-y-3">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-muted-foreground">
<Sparkles className="h-3 w-3" />
Standout themes
<span className="text-[10px] font-normal text-muted-foreground/70 normal-case tracking-normal">
· for users who want to stand out
</span>
</div>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
{standoutThemes.map((t) => (
<ThemeCard
key={t.id}
theme={t}
active={themeId === t.id}
onSelect={() => handleSelectPreset(t.id)}
/>
))}
</div>
</div>
{/* Custom themes */}
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-muted-foreground">
<Pencil className="h-3 w-3" />
Custom themes
<span className="text-[10px] font-normal text-muted-foreground/70 normal-case tracking-normal">
· your saved themes
</span>
</div>
<button
type="button"
onClick={() => {
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"
>
<Sparkles className="h-3 w-3" />
Customize
</button>
</div>
{customThemes.length === 0 ? (
<div className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">
No custom themes yet. Click <b className="text-foreground">Customize</b> to create
one from a preset or from scratch.
</div>
) : (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
{customThemes.map((t) => (
<button
key={t.id}
type="button"
onClick={() => 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"
)}
>
<div
className="relative w-full overflow-hidden rounded-lg border"
style={{
background: t.colors.background,
borderColor: "rgba(0,0,0,.08)",
aspectRatio: "16 / 9",
}}
>
{/* Mini preview */}
<div className="flex h-full">
<div
className="w-1/4 h-full p-1.5"
style={{ background: t.colors.sidebar }}
>
<div
className="h-3 w-3 rounded grid place-items-center text-[6px] font-bold"
style={{
background: t.colors.primary,
color: "#fff",
}}
>
W
</div>
</div>
<div className="flex-1 p-2 space-y-1.5">
<div
className="h-3 rounded"
style={{ background: t.colors.primary, opacity: 0.85, width: "55%" }}
/>
<div
className="h-2 rounded"
style={{
background: t.colors.foreground,
opacity: 0.18,
width: "90%",
}}
/>
<div
className="h-2 rounded"
style={{
background: t.colors.foreground,
opacity: 0.10,
width: "70%",
}}
/>
<div className="flex gap-1 mt-1.5">
<div
className="h-3 px-1.5 rounded text-[7px] font-bold flex items-center"
style={{
background: t.colors.primary,
color: "#fff",
}}
>
Go
</div>
<div
className="h-3 px-1.5 rounded text-[7px] font-bold flex items-center"
style={{
background: t.colors.accent,
color: "#fff",
}}
>
Acc
</div>
</div>
</div>
</div>
{themeId === CUSTOM_THEME_ID && (
<div className="absolute right-2 top-2 grid h-6 w-6 place-items-center rounded-full bg-primary text-primary-foreground shadow">
<Check className="h-3.5 w-3.5" />
</div>
)}
</div>
<div className="space-y-0.5">
<div className="text-sm font-semibold flex items-center gap-1.5">
{t.name}
<span className="inline-flex items-center rounded-full bg-primary/10 px-1.5 py-0.5 text-[8px] font-bold uppercase tracking-widest text-primary">
Custom
</span>
</div>
</div>
<span
role="button"
tabIndex={0}
onClick={(e) => {
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"
>
<Pencil className="h-3 w-3" />
</span>
</button>
))}
</div>
)}
</div>
<CustomThemeModal
open={modalOpen}
onOpenChange={(o) => {
setModalOpen(o);
if (!o) {
setEditingCustomId(null);
refreshCustomThemes();
}
}}
editingThemeId={editingCustomId}
onChange={refreshCustomThemes}
/>
</div>
);
}
function ThemeCard({
theme,
active,
onSelect,
}: {
theme: (typeof THEMES)[number];
active: boolean;
onSelect: () => void;
}) {
return (
<button
type="button"
onClick={onSelect}
className={cn(
"group relative flex flex-col gap-2 rounded-xl border-2 bg-card p-3 text-left transition-all hover:shadow-md",
active ? "border-primary shadow-sm" : "border-border hover:border-primary/40"
)}
>
{/* Mini preview */}
<div
className="relative w-full overflow-hidden rounded-lg border"
style={{
background: theme.swatches.surface,
borderColor: "rgba(0,0,0,.08)",
aspectRatio: "16 / 9",
}}
>
{/* Fake window chrome */}
<div
className="flex items-center gap-1 border-b px-2 py-1.5"
style={{ borderColor: "rgba(0,0,0,.08)" }}
>
<span className="h-1.5 w-1.5 rounded-full" style={{ background: "#FF5F57" }} />
<span className="h-1.5 w-1.5 rounded-full" style={{ background: "#FEBC2E" }} />
<span className="h-1.5 w-1.5 rounded-full" style={{ background: "#28C840" }} />
</div>
{/* Fake content */}
<div className="grid grid-cols-3 gap-1.5 p-2">
<div
className="col-span-1 h-12 rounded"
style={{ background: theme.swatches.primary, opacity: 0.92 }}
/>
<div
className="col-span-1 h-12 rounded"
style={{ background: theme.swatches.gold, opacity: 0.92 }}
/>
<div
className="col-span-1 h-12 rounded"
style={{ background: theme.swatches.red, opacity: 0.92 }}
/>
<div
className="col-span-2 h-6 rounded"
style={{
background: theme.swatches.text,
opacity: 0.12,
}}
/>
<div
className="col-span-1 h-6 rounded"
style={{ background: theme.swatches.primary, opacity: 0.7 }}
/>
<div
className="col-span-3 h-2 rounded"
style={{ background: theme.swatches.text, opacity: 0.18 }}
/>
<div
className="col-span-2 h-2 rounded"
style={{ background: theme.swatches.text, opacity: 0.10 }}
/>
</div>
{active && (
<div className="absolute right-2 top-2 grid h-6 w-6 place-items-center rounded-full bg-primary text-primary-foreground shadow">
<Check className="h-3.5 w-3.5" />
</div>
)}
</div>
{/* Label + tagline */}
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<div className="text-sm font-semibold">{theme.label}</div>
{theme.category === "standout" && (
<span className="inline-flex items-center gap-0.5 rounded-full bg-accent px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-widest text-accent-foreground">
<Sparkles className="h-2.5 w-2.5" />
Creative
</span>
)}
</div>
<div className="text-xs text-muted-foreground leading-snug">{theme.tagline}</div>
</div>
</button>
);
}

View file

@ -11,6 +11,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
themes={["warm", "dark"]}
enableSystem={false}
disableTransitionOnChange
storageKey="wfdash-mode"
>
{children}
</NextThemesProvider>

View file

@ -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 (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer group/checkbox relative shrink-0 grid place-items-center size-4 rounded-sm border border-input bg-background shadow-xs transition-colors outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 disabled:cursor-not-allowed disabled:opacity-50 data-checked:bg-primary data-checked:text-primary-foreground data-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-items-center text-current"
>
<Check
className="size-3 stroke-[3]"
strokeWidth={3}
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

416
src/lib/custom-themes.ts Normal file
View file

@ -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 <html>, 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<string, string> {
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 <html>
* ========================================================================== */
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;
}

View file

@ -55,6 +55,32 @@ export async function getAllSuppliers() {
return pb.collection("suppliers").getFullList<Supplier>({ 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<Supplier>({
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` —

139
src/lib/themes.ts Normal file
View file

@ -0,0 +1,139 @@
/**
* WFDASH theme registry.
*
* Each theme is identified by an `id` used in the `data-theme` attribute
* on <html>. Themes compose with the warm/dark mode toggle (handled by
* next-themes via the `class` attribute on <html>).
*
* 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 <html>. 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);
}

49
src/lib/use-theme-id.ts Normal file
View file

@ -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 <html> 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<string>(() => {
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 };
}

580
src/styles/themes.css Normal file
View file

@ -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="<id>"` on <html>; 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
);
}