Files
vibn-frontend/app/[workspace]/project/[projectId]/design/page.tsx
Mark Henderson a980354da6 Replace flat library buttons with capability cards on design page
Each library option now shows: best-for summary, 3 key highlights,
capability tags, Templates badge, and Dark-first badge. All surface
themes updated with richer metadata. Marketing surface updated with
full highlights for DaisyUI/HeroUI/Aceternity/Tailwind.

Made-with: Cursor
2026-03-05 20:01:31 -08:00

768 lines
35 KiB
TypeScript

"use client";
import { use, useState, useEffect } from "react";
import { toast } from "sonner";
import { SCAFFOLD_REGISTRY, THEME_REGISTRY, type ThemeColor } from "@/components/design-scaffolds";
// ---------------------------------------------------------------------------
// Surface definitions
// ---------------------------------------------------------------------------
interface Surface {
id: string;
name: string;
description: string;
icon: string;
themes: Theme[];
}
interface Theme {
id: string;
name: string;
description: string;
tags: string[];
url: string;
// Richer metadata shown in the capability card picker
highlights?: string[]; // 3 key things this library does well
hasTemplates?: boolean; // pre-built page templates available
bestFor?: string; // one-line "best for" summary
darkFirst?: boolean; // dark-mode-first aesthetic
}
const ALL_SURFACES: Surface[] = [
{
id: "web-app",
name: "Web App",
icon: "⬡",
description: "The core product users log into — dashboards, features, settings",
themes: [
{
id: "shadcn", name: "shadcn/ui", url: "https://ui.shadcn.com",
description: "Copy-paste components on Radix primitives. You own the code.",
tags: ["Tailwind", "Radix", "Copy-paste"],
bestFor: "Most SaaS apps — you control every pixel",
highlights: ["Code lives in your repo, not node_modules", "Radix primitives for full accessibility", "New York & Default variants built in"],
hasTemplates: true,
},
{
id: "mantine", name: "Mantine", url: "https://mantine.dev",
description: "100+ fully featured components including charts, forms, date pickers.",
tags: ["React", "Charts", "Forms", "Hooks"],
bestFor: "Data-heavy apps that need charts and complex forms",
highlights: ["Built-in charts, tables, and data grid", "60+ hooks (useForm, useLocalStorage, etc.)", "Date/time pickers, rich text editor included"],
},
{
id: "hero-ui", name: "HeroUI", url: "https://heroui.com",
description: "Smooth animations, blur effects, and dark mode out of the box.",
tags: ["Tailwind", "Framer Motion", "Dark mode"],
bestFor: "Modern SaaS with polished animations",
highlights: ["Blur backdrop navigation built in", "Framer Motion on every interaction", "Dark mode first, light mode included"],
hasTemplates: true,
},
{
id: "tremor", name: "Tremor", url: "https://tremor.so",
description: "Purpose-built dashboard components — charts, KPIs, tables.",
tags: ["Charts", "Dashboard", "Analytics"],
bestFor: "Analytics products and internal dashboards",
highlights: ["Chart library built in (area, bar, donut)", "KPI cards, progress bars, trackers", "Area chart with tooltip out of the box"],
hasTemplates: true,
},
],
},
{
id: "marketing",
name: "Marketing Site",
icon: "◎",
description: "Public-facing landing page, blog, pricing — brand expression and conversion",
themes: [
{
id: "daisy-ui", name: "DaisyUI", url: "https://daisyui.com",
description: "Tailwind plugin with 48 built-in themes — swap the entire look with one word.",
tags: ["Tailwind", "48 Themes", "Plugin"],
bestFor: "Teams that want theme variety without custom CSS",
highlights: ["48 built-in themes (dark, cupcake, synthwave, cyberpunk…)", "One-line theme switching: data-theme=\"synthwave\"", "Hero, Features, Pricing, CTA, Footer all included"],
hasTemplates: true,
},
{
id: "hero-ui", name: "HeroUI", url: "https://heroui.com",
description: "Gradient buttons, animated badges, blur nav — high-end SaaS look.",
tags: ["Tailwind", "Framer Motion", "Gradients"],
bestFor: "Modern SaaS landing page with a premium feel",
highlights: ["Gradient & glassmorphism components", "Animated feature cards with hover depth", "Navbar with blur-backdrop effect"],
hasTemplates: true,
},
{
id: "aceternity", name: "Aceternity UI", url: "https://ui.aceternity.com",
description: "Signature background effects — beams, meteors, sparkles, wavy gradients.",
tags: ["Framer Motion", "Background FX", "Dark-first"],
bestFor: "Premium, visually striking launch pages",
highlights: ["Background Beams, Meteors, Sparkles effects", "Infinite moving cards, Lamp effect, Wavy BG", "Text generate & typewriter animations"],
darkFirst: true,
},
{
id: "tailwind-only", name: "Tailwind only", url: "https://tailwindcss.com",
description: "Zero component library. Full creative control — design exactly what you want.",
tags: ["Custom", "Zero constraints", "Typography-first"],
bestFor: "Strong design opinions, no library overhead",
highlights: ["No component library constraints", "Typography-first editorial layouts", "Fastest builds, smallest bundle"],
},
],
},
{
id: "admin",
name: "Admin Panel",
icon: "◫",
description: "Internal tool for managing your business — users, support, billing, analytics",
themes: [
{
id: "mantine", name: "Mantine", url: "https://mantine.dev",
description: "Comprehensive component set built for admin — tables, forms, charts.",
tags: ["Tables", "Forms", "Charts", "Date picker"],
bestFor: "Feature-rich internal tools",
highlights: ["Data grid with sorting, filtering, pagination", "Complex form handling with validation", "Stats, charts, and KPI components"],
},
{
id: "shadcn", name: "shadcn/ui", url: "https://ui.shadcn.com",
description: "Clean components that match your main app if it uses shadcn.",
tags: ["Tailwind", "Consistent", "Copy-paste"],
bestFor: "Keeping admin and app visually consistent",
highlights: ["Matches the web app if using shadcn", "Data table with react-table built in", "Command palette, sheets, dialogs"],
},
{
id: "tremor", name: "Tremor", url: "https://tremor.so",
description: "Analytics-first admin — built for KPI dashboards and data tables.",
tags: ["Analytics", "Charts", "KPIs"],
bestFor: "Analytics-heavy operations dashboards",
highlights: ["KPI cards, area charts, bar lists built in", "Data table with search + filter", "Progress bars, badges, trackers"],
hasTemplates: true,
},
],
},
{
id: "mobile",
name: "Mobile App",
icon: "▢",
description: "iOS and Android companion app — touch-first, native feel",
themes: [
{
id: "nativewind", name: "NativeWind", url: "https://nativewind.dev",
description: "Use Tailwind classes in React Native. Consistent web + mobile styling.",
tags: ["Tailwind", "React Native", "Expo"],
bestFor: "Teams already using Tailwind on web",
highlights: ["Same Tailwind classes on web and mobile", "Expo compatible, dark mode support", "Platform-specific variants built in"],
},
{
id: "gluestack", name: "Gluestack UI", url: "https://gluestack.io",
description: "Universal component library for React Native with accessibility baked in.",
tags: ["Universal", "Accessible", "Expo"],
bestFor: "Comprehensive native component coverage",
highlights: ["50+ components (ActionSheet, Toast, FAB…)", "ARIA accessible out of the box", "Works on iOS, Android, and web"],
},
],
},
{
id: "email",
name: "Email",
icon: "✉",
description: "Transactional and marketing emails — welcome, billing, notifications",
themes: [
{
id: "react-email", name: "React Email", url: "https://react.email",
description: "Build beautiful emails with React. Test across 90+ email clients.",
tags: ["React", "Resend", "Cross-client", "Preview"],
bestFor: "Teams wanting React-based email workflows",
highlights: ["Live preview across Gmail, Outlook, Apple Mail", "Works with Resend, SendGrid, Postmark", "50+ pre-built email components"],
hasTemplates: true,
},
],
},
{
id: "docs",
name: "Docs / Content",
icon: "☰",
description: "Documentation, knowledge base, or blog for your product",
themes: [
{
id: "nextra", name: "Nextra", url: "https://nextra.site",
description: "Next.js-based docs with built-in search, MDX, and versioning.",
tags: ["Next.js", "MDX", "Search", "Dark mode"],
bestFor: "Developer-facing documentation",
highlights: ["Full-text search out of the box (Pagefind)", "MDX — React components inside Markdown", "Auto-generated sidebar from file structure"],
hasTemplates: true,
},
{
id: "shadcn", name: "shadcn/ui + custom", url: "https://ui.shadcn.com",
description: "Fully custom docs site that matches your product exactly.",
tags: ["Custom", "Tailwind", "Flexible"],
bestFor: "Brand-consistent docs that stand out",
highlights: ["Matches your app's exact design language", "Full control over layout and navigation", "Blog, changelog, and API ref in one"],
},
],
},
];
// ---------------------------------------------------------------------------
// Surface section
// ---------------------------------------------------------------------------
function SurfaceSection({
surface,
selectedThemeId,
lockedThemeId,
onSelect,
onLock,
onUnlock,
saving,
}: {
surface: Surface;
selectedThemeId: string | null;
lockedThemeId: string | null;
onSelect: (themeId: string) => void;
onLock: () => void;
onUnlock: () => void;
saving: boolean;
}) {
const previewId = lockedThemeId ?? selectedThemeId ?? surface.themes[0]?.id ?? null;
const activeTheme = surface.themes.find(t => t.id === previewId);
const ScaffoldComponent = previewId ? SCAFFOLD_REGISTRY[surface.id]?.[previewId] : null;
const availableColorThemes: ThemeColor[] = previewId
? (THEME_REGISTRY[surface.id]?.[previewId] ?? [])
: [];
const [selectedColorTheme, setSelectedColorTheme] = useState<ThemeColor | null>(null);
const activeColorTheme = selectedColorTheme ?? availableColorThemes[0] ?? null;
const isLocked = !!lockedThemeId;
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%", gap: 0 }}>
{/* Browser chrome + scaffold */}
<div style={{
flex: 1, borderRadius: 10, overflow: "hidden",
border: "1px solid #e8e4dc",
boxShadow: "0 1px 4px #1a1a1a06",
display: "flex", flexDirection: "column",
minHeight: 460,
}}>
{/* Chrome bar */}
<div style={{
display: "flex", alignItems: "center", gap: 6,
padding: "0 14px", height: 34, flexShrink: 0,
borderBottom: "1px solid #e8e4dc", background: "#faf8f5",
}}>
{["#d0ccc4", "#d0ccc4", "#d0ccc4"].map((c, i) => (
<div key={i} style={{ width: 9, height: 9, borderRadius: "50%", background: c }} />
))}
<div style={{
marginLeft: 12, flex: 1, maxWidth: 280, height: 20, borderRadius: 5,
background: "#f0ece4", display: "flex", alignItems: "center", padding: "0 10px",
}}>
<span style={{ fontSize: "0.65rem", color: "#b5b0a6", fontFamily: "IBM Plex Mono", overflow: "hidden", whiteSpace: "nowrap", textOverflow: "ellipsis" }}>
{activeTheme ? `/${surface.id} · ${activeTheme.name}${activeColorTheme ? ` · ${activeColorTheme.label}` : ""}` : ""}
</span>
</div>
</div>
{/* Scaffold */}
<div style={{ flex: 1, overflow: "hidden" }}>
{ScaffoldComponent
? <ScaffoldComponent themeColor={activeColorTheme ?? undefined} />
: (
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: "#b5b0a6", fontSize: "0.82rem", fontFamily: "Outfit" }}>
Select a library below to preview
</div>
)
}
</div>
</div>
{/* Controls below render */}
<div style={{ flexShrink: 0, paddingTop: 18, display: "flex", flexDirection: "column", gap: 14 }}>
{/* Library capability cards */}
<div style={{ display: "grid", gridTemplateColumns: `repeat(${Math.min(surface.themes.length, 4)}, 1fr)`, gap: 8 }}>
{surface.themes.map(theme => {
const isActive = theme.id === previewId;
const isThisLocked = theme.id === lockedThemeId;
const dimmed = isLocked && !isThisLocked;
return (
<button
key={theme.id}
onClick={() => { if (!isLocked) { onSelect(theme.id); setSelectedColorTheme(null); } }}
disabled={dimmed}
style={{
display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 0,
padding: "12px 14px", borderRadius: 9, textAlign: "left",
border: `1px solid ${isActive ? "#1a1a1a" : "#e8e4dc"}`,
background: isActive ? "#1a1a1a" : "#fff",
boxShadow: isActive ? "0 0 0 1px #1a1a1a" : "none",
cursor: dimmed ? "not-allowed" : "pointer",
opacity: dimmed ? 0.35 : 1,
transition: "all 0.12s",
position: "relative",
fontFamily: "Outfit, sans-serif",
}}
onMouseEnter={e => { if (!isLocked && !isActive) { (e.currentTarget as HTMLElement).style.borderColor = "#c5c0b8"; } }}
onMouseLeave={e => { if (!isLocked && !isActive) { (e.currentTarget as HTMLElement).style.borderColor = "#e8e4dc"; } }}
>
{/* Top row: name + badges */}
<div style={{ display: "flex", alignItems: "center", gap: 6, width: "100%", marginBottom: 8 }}>
{isThisLocked && <span style={{ fontSize: "0.6rem" }}>🔒</span>}
<span style={{ fontSize: "0.8rem", fontWeight: 700, color: isActive ? "#fff" : "#1a1a1a", flex: 1 }}>
{theme.name}
</span>
{theme.hasTemplates && (
<span style={{
fontSize: "0.56rem", fontWeight: 700, padding: "1px 5px", borderRadius: 3,
background: isActive ? "rgba(255,255,255,0.15)" : "#f0ece4",
color: isActive ? "rgba(255,255,255,0.8)" : "#8a8478",
letterSpacing: "0.04em", textTransform: "uppercase", flexShrink: 0,
}}>Templates</span>
)}
{theme.darkFirst && (
<span style={{
fontSize: "0.56rem", fontWeight: 700, padding: "1px 5px", borderRadius: 3,
background: isActive ? "rgba(255,255,255,0.15)" : "#1a1a1a",
color: isActive ? "rgba(255,255,255,0.8)" : "#fff",
letterSpacing: "0.04em", textTransform: "uppercase", flexShrink: 0,
}}>Dark</span>
)}
</div>
{/* Best for */}
{theme.bestFor && (
<p style={{ fontSize: "0.68rem", color: isActive ? "rgba(255,255,255,0.55)" : "#a09a90", marginBottom: 8, lineHeight: 1.4 }}>
Best for: {theme.bestFor}
</p>
)}
{/* Highlights */}
{theme.highlights && theme.highlights.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: 4, marginBottom: 10, width: "100%" }}>
{theme.highlights.map((h, i) => (
<div key={i} style={{ display: "flex", alignItems: "flex-start", gap: 6 }}>
<span style={{ fontSize: "0.6rem", color: isActive ? "rgba(255,255,255,0.4)" : "#c5c0b8", marginTop: 2, flexShrink: 0 }}></span>
<span style={{ fontSize: "0.7rem", color: isActive ? "rgba(255,255,255,0.7)" : "#4a4640", lineHeight: 1.4 }}>{h}</span>
</div>
))}
</div>
)}
{/* Tags */}
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
{theme.tags.map(t => (
<span key={t} style={{
fontSize: "0.6rem", fontWeight: 600, padding: "2px 6px", borderRadius: 4,
background: isActive ? "rgba(255,255,255,0.1)" : "#f6f4f0",
color: isActive ? "rgba(255,255,255,0.6)" : "#8a8478",
}}>{t}</span>
))}
</div>
{/* Active indicator */}
{isActive && !isThisLocked && (
<div style={{ position: "absolute", top: 10, right: 12, fontSize: "0.7rem", color: "rgba(255,255,255,0.5)" }}></div>
)}
</button>
);
})}
</div>
{/* Color swatches — shown below the cards when available */}
{availableColorThemes.length > 0 && (
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span style={{ fontSize: "0.68rem", fontWeight: 600, color: "#a09a90", textTransform: "uppercase", letterSpacing: "0.08em", fontFamily: "Outfit" }}>Colour</span>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", alignItems: "center" }}>
{availableColorThemes.map(ct => (
<button
key={ct.id}
title={ct.label}
onClick={() => !isLocked && setSelectedColorTheme(ct)}
disabled={isLocked}
style={{
width: 20, height: 20, borderRadius: "50%", border: "none", cursor: isLocked ? "not-allowed" : "pointer",
background: ct.bg ? `linear-gradient(135deg, ${ct.bg} 50%, ${ct.primary} 50%)` : ct.primary,
outline: activeColorTheme?.id === ct.id ? `2px solid ${ct.primary}` : "none",
outlineOffset: 2, transition: "transform 0.12s",
opacity: isLocked ? 0.4 : 1,
}}
onMouseEnter={e => { if (!isLocked) (e.currentTarget as HTMLElement).style.transform = "scale(1.15)"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.transform = "scale(1)"; }}
/>
))}
{activeColorTheme && (
<span style={{ fontSize: "0.72rem", color: "#a09a90", fontFamily: "Outfit" }}>{activeColorTheme.label}</span>
)}
</div>
</div>
)}
{/* Docs link + lock action bar */}
<div style={{ display: "flex", alignItems: "center", gap: 12, paddingTop: 4, borderTop: "1px solid #f0ece4" }}>
{activeTheme && (
<>
<a
href={activeTheme.url}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: "0.75rem", color: "#a09a90", textDecoration: "none", fontFamily: "Outfit" }}
onMouseEnter={e => (e.currentTarget.style.color = "#1a1a1a")}
onMouseLeave={e => (e.currentTarget.style.color = "#a09a90")}
>
{activeTheme.name} docs
</a>
<div style={{ flex: 1 }} />
</>
)}
{isLocked ? (
<button
onClick={onUnlock}
style={{
padding: "7px 14px", borderRadius: 7, border: "1px solid #e0dcd4",
background: "#fff", color: "#1a1a1a", fontSize: "0.76rem", fontWeight: 600,
fontFamily: "Outfit", cursor: "pointer", flexShrink: 0, transition: "opacity 0.15s",
}}
onMouseEnter={e => (e.currentTarget.style.opacity = "0.7")}
onMouseLeave={e => (e.currentTarget.style.opacity = "1")}
>
Change
</button>
) : (
<button
onClick={onLock}
disabled={!selectedThemeId || saving}
style={{
padding: "7px 14px", borderRadius: 7, border: "1px solid #1a1a1a",
background: !selectedThemeId || saving ? "#e0dcd4" : "#1a1a1a",
color: !selectedThemeId || saving ? "#b5b0a6" : "#fff",
fontSize: "0.76rem", fontWeight: 600, fontFamily: "Outfit",
cursor: !selectedThemeId || saving ? "not-allowed" : "pointer",
flexShrink: 0, transition: "opacity 0.15s",
}}
onMouseEnter={e => { if (selectedThemeId && !saving) (e.currentTarget.style.opacity = "0.8"); }}
onMouseLeave={e => { (e.currentTarget.style.opacity = "1"); }}
>
{saving ? "Saving…" : "🔒 Lock in"}
</button>
)}
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Surface ID fuzzy-match helper — maps AI-generated labels to our surface IDs
// ---------------------------------------------------------------------------
function matchSurfaceId(label: string): string | null {
const l = label.toLowerCase();
if (l.includes("web app") || l.includes("webapp") || l.includes("saas") || l.includes("dashboard") || l.includes("pwa")) return "web-app";
if (l.includes("marketing") || l.includes("landing") || l.includes("site") || l.includes("homepage")) return "marketing";
if (l.includes("admin") || l.includes("internal") || l.includes("back office") || l.includes("backoffice")) return "admin";
if (l.includes("mobile") || l.includes("ios") || l.includes("android") || l.includes("native")) return "mobile";
if (l.includes("email") || l.includes("notification")) return "email";
if (l.includes("doc") || l.includes("content") || l.includes("blog") || l.includes("knowledge")) return "docs";
return null;
}
// ---------------------------------------------------------------------------
// Phase 1 — Surface picker
// ---------------------------------------------------------------------------
function SurfacePicker({
onConfirm,
saving,
aiSuggested,
}: {
onConfirm: (ids: string[]) => void;
saving: boolean;
aiSuggested: string[];
}) {
const [selected, setSelected] = useState<Set<string>>(new Set(aiSuggested));
const toggle = (id: string) => {
setSelected(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
return (
<div style={{ padding: "28px 32px", fontFamily: "Outfit, sans-serif" }}>
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 4 }}>
Design surfaces
</h3>
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: aiSuggested.length > 0 ? 10 : 24 }}>
Which surfaces does your product need?
</p>
{aiSuggested.length > 0 && (
<div style={{
display: "flex", alignItems: "center", gap: 8, marginBottom: 20,
padding: "10px 14px", background: "#f6f4f0", borderRadius: 8,
border: "1px solid #e8e4dc",
}}>
<span style={{ fontSize: "0.8rem" }}></span>
<span style={{ fontSize: "0.76rem", color: "#4a4640", lineHeight: 1.5 }}>
Based on your PRD, the AI pre-selected the surfaces your product needs. Adjust if needed.
</span>
</div>
)}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))", gap: 8, marginBottom: 24 }}>
{ALL_SURFACES.map(surface => {
const isSelected = selected.has(surface.id);
const isAiPick = aiSuggested.includes(surface.id);
return (
<button
key={surface.id}
onClick={() => toggle(surface.id)}
style={{
display: "flex", alignItems: "flex-start", gap: 14,
padding: "16px 18px", borderRadius: 10, textAlign: "left",
border: `1px solid ${isSelected ? "#1a1a1a" : "#e8e4dc"}`,
background: isSelected ? "#1a1a1a08" : "#fff",
boxShadow: isSelected ? "0 0 0 1px #1a1a1a" : "0 1px 2px #1a1a1a05",
cursor: "pointer", transition: "all 0.12s", fontFamily: "Outfit",
position: "relative",
}}
onMouseEnter={e => { if (!isSelected) (e.currentTarget.style.borderColor = "#d0ccc4"); }}
onMouseLeave={e => { if (!isSelected) (e.currentTarget.style.borderColor = "#e8e4dc"); }}
>
{isAiPick && !isSelected && (
<div style={{
position: "absolute", top: 8, right: 8,
fontSize: "0.58rem", color: "#9a7b3a", background: "#d4a04a15",
border: "1px solid #d4a04a30", padding: "1px 5px", borderRadius: 3,
fontWeight: 600, letterSpacing: "0.05em",
}}>AI</div>
)}
<div style={{
width: 34, height: 34, borderRadius: 8, flexShrink: 0, marginTop: 1,
background: isSelected ? "#1a1a1a" : "#f6f4f0",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "1rem", color: isSelected ? "#fff" : "#8a8478",
}}>
{surface.icon}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: "0.84rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 3 }}>{surface.name}</div>
<div style={{ fontSize: "0.74rem", color: "#8a8478", lineHeight: 1.5 }}>{surface.description}</div>
</div>
{isSelected && <span style={{ flexShrink: 0, color: "#1a1a1a", fontSize: "0.85rem", marginTop: 2 }}></span>}
</button>
);
})}
</div>
<button
onClick={() => onConfirm(Array.from(selected))}
disabled={selected.size === 0 || saving}
style={{
padding: "9px 20px", borderRadius: 7, border: "none",
background: selected.size === 0 || saving ? "#e0dcd4" : "#1a1a1a",
color: selected.size === 0 || saving ? "#b5b0a6" : "#fff",
fontSize: "0.82rem", fontWeight: 600, fontFamily: "Outfit",
cursor: selected.size === 0 || saving ? "not-allowed" : "pointer",
transition: "opacity 0.15s",
}}
onMouseEnter={e => { if (selected.size > 0 && !saving) (e.currentTarget.style.opacity = "0.8"); }}
onMouseLeave={e => { (e.currentTarget.style.opacity = "1"); }}
>
{saving ? "Saving…" : `Confirm surfaces (${selected.size})`}
</button>
{selected.size === 0 && (
<p style={{ display: "inline-block", marginLeft: 12, fontSize: "0.74rem", color: "#b5b0a6", fontFamily: "Outfit" }}>
Select at least one surface to continue
</p>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default function DesignPage({ params }: { params: Promise<{ workspace: string; projectId: string }> }) {
const { projectId } = use(params);
const [surfaces, setSurfaces] = useState<string[]>([]);
const [surfaceThemes, setSurfaceThemes] = useState<Record<string, string>>({});
const [selectedThemes, setSelectedThemes] = useState<Record<string, string>>({});
const [activeSurfaceId, setActiveSurfaceId] = useState<string | null>(null);
const [savingLock, setSavingLock] = useState<string | null>(null);
const [savingSurfaces, setSavingSurfaces] = useState(false);
const [loading, setLoading] = useState(true);
const [aiSuggestedSurfaces, setAiSuggestedSurfaces] = useState<string[]>([]);
useEffect(() => {
// Load saved design surfaces
const designP = fetch(`/api/projects/${projectId}/design-surfaces`)
.then(r => r.json())
.then(d => {
const loaded = (d.surfaces ?? []) as string[];
setSurfaces(loaded);
setSurfaceThemes(d.surfaceThemes ?? {});
setSelectedThemes(d.surfaceThemes ?? {});
if (loaded.length > 0) setActiveSurfaceId(loaded[0]);
return loaded;
});
// Load architecture to get AI-suggested surfaces
const archP = fetch(`/api/projects/${projectId}/architecture`)
.then(r => r.json())
.then(d => {
const arch = d.architecture;
if (arch?.designSurfaces && Array.isArray(arch.designSurfaces)) {
const matched = (arch.designSurfaces as string[])
.map(matchSurfaceId)
.filter((id): id is string => id !== null);
setAiSuggestedSurfaces([...new Set(matched)]);
}
})
.catch(() => {});
Promise.all([designP, archP])
.catch(() => toast.error("Failed to load design data"))
.finally(() => setLoading(false));
}, [projectId]);
const handleConfirmSurfaces = async (ids: string[]) => {
setSavingSurfaces(true);
try {
const res = await fetch(`/api/projects/${projectId}/design-surfaces`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ surfaces: ids }),
});
if (!res.ok) throw new Error();
setSurfaces(ids);
setActiveSurfaceId(ids[0] ?? null);
toast.success("Surfaces saved");
} catch {
toast.error("Failed to save surfaces");
} finally {
setSavingSurfaces(false);
}
};
const handleLock = async (surfaceId: string) => {
const themeId = selectedThemes[surfaceId];
if (!themeId) return;
setSavingLock(surfaceId);
try {
const res = await fetch(`/api/projects/${projectId}/design-surfaces`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ surface: surfaceId, theme: themeId }),
});
if (!res.ok) throw new Error();
setSurfaceThemes(prev => ({ ...prev, [surfaceId]: themeId }));
const surface = ALL_SURFACES.find(s => s.id === surfaceId);
const theme = surface?.themes.find(t => t.id === themeId);
toast.success(`${surface?.name}${theme?.name} locked in`);
} catch {
toast.error("Failed to lock in theme");
} finally {
setSavingLock(null);
}
};
const handleUnlock = (surfaceId: string) => {
setSurfaceThemes(prev => { const next = { ...prev }; delete next[surfaceId]; return next; });
};
if (loading) {
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "Outfit" }}>
<div style={{ width: 18, height: 18, borderRadius: "50%", border: "2px solid #e8e4dc", borderTopColor: "#1a1a1a", animation: "spin 0.8s linear infinite" }} />
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
);
}
if (surfaces.length === 0) {
return <SurfacePicker onConfirm={handleConfirmSurfaces} saving={savingSurfaces} aiSuggested={aiSuggestedSurfaces} />;
}
const activeSurfaces = ALL_SURFACES.filter(s => surfaces.includes(s.id));
const currentSurface = activeSurfaces.find(s => s.id === activeSurfaceId) ?? activeSurfaces[0];
const lockedCount = Object.keys(surfaceThemes).length;
return (
<div style={{ display: "flex", height: "100%", fontFamily: "Outfit, sans-serif" }}>
{/* Left nav */}
<div style={{ width: 180, flexShrink: 0, borderRight: "1px solid #e8e4dc", display: "flex", flexDirection: "column", background: "#fff" }}>
<div style={{ padding: "16px 18px 10px", borderBottom: "1px solid #f0ece4" }}>
<div style={{ fontSize: "0.6rem", fontWeight: 600, color: "#a09a90", letterSpacing: "0.1em", textTransform: "uppercase" }}>Surfaces</div>
</div>
<nav style={{ flex: 1, padding: "6px 8px", overflow: "auto" }}>
{activeSurfaces.map(surface => {
const isActive = surface.id === currentSurface?.id;
const isLocked = !!surfaceThemes[surface.id];
return (
<button
key={surface.id}
onClick={() => setActiveSurfaceId(surface.id)}
style={{
display: "flex", alignItems: "center", gap: 8, width: "100%",
padding: "8px 10px", borderRadius: 6, border: "none", textAlign: "left",
background: isActive ? "#f6f4f0" : "transparent",
color: isActive ? "#1a1a1a" : "#6b6560",
fontSize: "0.8rem", fontWeight: isActive ? 600 : 450,
cursor: "pointer", transition: "all 0.12s", position: "relative",
fontFamily: "Outfit",
}}
onMouseEnter={e => { if (!isActive) (e.currentTarget.style.background = "#f6f4f0"); }}
onMouseLeave={e => { if (!isActive) (e.currentTarget.style.background = "transparent"); }}
>
{isActive && (
<div style={{ position: "absolute", left: 0, top: "50%", transform: "translateY(-50%)", width: 2, height: 16, background: "#1a1a1a", borderRadius: "0 2px 2px 0" }} />
)}
<span style={{ fontSize: "0.9rem", opacity: 0.5, flexShrink: 0 }}>{surface.icon}</span>
<span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{surface.name}</span>
{isLocked && <span style={{ fontSize: "0.6rem", flexShrink: 0, opacity: 0.5 }}>🔒</span>}
</button>
);
})}
</nav>
<div style={{ padding: "12px 18px", borderTop: "1px solid #f0ece4" }}>
{lockedCount === activeSurfaces.length && lockedCount > 0 && (
<p style={{ fontSize: "0.68rem", color: "#2e7d32", fontFamily: "Outfit", marginBottom: 6 }}> All locked</p>
)}
<button
onClick={() => setSurfaces([])}
style={{ fontSize: "0.72rem", color: "#a09a90", background: "none", border: "none", cursor: "pointer", fontFamily: "Outfit", padding: 0 }}
onMouseEnter={e => (e.currentTarget.style.color = "#1a1a1a")}
onMouseLeave={e => (e.currentTarget.style.color = "#a09a90")}
>
+ Edit surfaces
</button>
</div>
</div>
{/* Main content */}
<div style={{ flex: 1, overflow: "auto", padding: "24px 28px" }}>
{currentSurface && (
<SurfaceSection
surface={currentSurface}
selectedThemeId={selectedThemes[currentSurface.id] ?? null}
lockedThemeId={surfaceThemes[currentSurface.id] ?? null}
onSelect={themeId => setSelectedThemes(prev => ({ ...prev, [currentSurface.id]: themeId }))}
onLock={() => handleLock(currentSurface.id)}
onUnlock={() => handleUnlock(currentSurface.id)}
saving={savingLock === currentSurface.id}
/>
)}
</div>
</div>
);
}