feat: implement 4 project type flows with unique AI experiences

- New multi-step CreateProjectFlow replaces 2-step modal with TypeSelector
  and 4 setup components (Fresh Idea, Chat Import, Code Import, Migrate)
- overview/page.tsx routes to unique main component per creationMode
- FreshIdeaMain: wraps AtlasChat with post-discovery decision banner
  (Generate PRD vs Plan MVP Test)
- ChatImportMain: 3-stage flow (intake → extracting → review) with
  editable insight buckets (decisions, ideas, questions, architecture, users)
- CodeImportMain: 4-stage flow (input → cloning → mapping → surfaces)
  with architecture map and surface selection
- MigrateMain: 5-stage flow with audit, review, planning, and migration
  plan doc with checkbox-tracked tasks and non-destructive warning banner
- New API routes: analyze-chats, analyze-repo, analysis-status,
  generate-migration-plan (all using Gemini)
- ProjectShell: accepts creationMode prop, filters/renames tabs per type
  (code-import hides PRD, migration hides PRD/Grow/Insights, renames Atlas tab)
- Right panel adapts content based on creationMode

Made-with: Cursor
This commit is contained in:
2026-03-06 12:48:28 -08:00
parent 24812df89b
commit ab100f2e76
19 changed files with 2696 additions and 403 deletions

View File

@@ -0,0 +1,84 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { SetupHeader, FieldLabel, TextInput, PrimaryButton, type SetupProps } from "./setup-shared";
export function ChatImportSetup({ workspace, onClose, onBack }: SetupProps) {
const router = useRouter();
const [name, setName] = useState("");
const [chatText, setChatText] = useState("");
const [loading, setLoading] = useState(false);
const canCreate = name.trim().length > 0 && chatText.trim().length > 20;
const handleCreate = async () => {
if (!canCreate) return;
setLoading(true);
try {
const res = await fetch("/api/projects/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectName: name.trim(),
projectType: "web-app",
slug: name.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
product: { name: name.trim() },
creationMode: "chat-import",
sourceData: { chatText: chatText.trim() },
}),
});
if (!res.ok) {
const err = await res.json();
toast.error(err.error || "Failed to create project");
return;
}
const data = await res.json();
onClose();
router.push(`/${workspace}/project/${data.projectId}/overview`);
} catch {
toast.error("Something went wrong");
} finally {
setLoading(false);
}
};
return (
<div style={{ padding: "32px 36px 36px" }}>
<SetupHeader
icon="⌁" label="Import Chats" tagline="You've been thinking"
accent="#2e5a4a" onBack={onBack} onClose={onClose}
/>
<FieldLabel>Project name</FieldLabel>
<TextInput
value={name}
onChange={setName}
placeholder="What are you building?"
autoFocus
/>
<FieldLabel>Paste your chat history</FieldLabel>
<textarea
value={chatText}
onChange={e => setChatText(e.target.value)}
placeholder={"Paste conversations from ChatGPT, Claude, Gemini, or any AI tool.\n\nAtlas will extract decisions, ideas, open questions, and architecture notes."}
rows={8}
style={{
width: "100%", padding: "12px 14px", marginBottom: 20,
borderRadius: 8, border: "1px solid #e0dcd4",
background: "#faf8f5", fontSize: "0.85rem", lineHeight: 1.55,
fontFamily: "Outfit, sans-serif", color: "#1a1a1a",
outline: "none", resize: "vertical", boxSizing: "border-box",
}}
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
/>
<PrimaryButton onClick={handleCreate} disabled={!canCreate} loading={loading}>
Extract & analyse
</PrimaryButton>
</div>
);
}

View File

@@ -0,0 +1,100 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { SetupHeader, FieldLabel, TextInput, PrimaryButton, type SetupProps } from "./setup-shared";
export function CodeImportSetup({ workspace, onClose, onBack }: SetupProps) {
const router = useRouter();
const [name, setName] = useState("");
const [repoUrl, setRepoUrl] = useState("");
const [pat, setPat] = useState("");
const [loading, setLoading] = useState(false);
const isValidUrl = repoUrl.trim().startsWith("http");
const canCreate = name.trim().length > 0 && isValidUrl;
const handleCreate = async () => {
if (!canCreate) return;
setLoading(true);
try {
const res = await fetch("/api/projects/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectName: name.trim(),
projectType: "web-app",
slug: name.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
product: { name: name.trim() },
creationMode: "code-import",
sourceData: { repoUrl: repoUrl.trim(), pat: pat.trim() || undefined },
}),
});
if (!res.ok) {
const err = await res.json();
toast.error(err.error || "Failed to create project");
return;
}
const data = await res.json();
onClose();
router.push(`/${workspace}/project/${data.projectId}/overview`);
} catch {
toast.error("Something went wrong");
} finally {
setLoading(false);
}
};
return (
<div style={{ padding: "32px 36px 36px" }}>
<SetupHeader
icon="⌘" label="Import Code" tagline="Already have a repo"
accent="#1a3a5c" onBack={onBack} onClose={onClose}
/>
<FieldLabel>Project name</FieldLabel>
<TextInput
value={name}
onChange={setName}
placeholder="What is this project called?"
autoFocus
/>
<FieldLabel>Repository URL</FieldLabel>
<TextInput
value={repoUrl}
onChange={setRepoUrl}
placeholder="https://github.com/yourorg/your-repo"
/>
<FieldLabel>
Personal Access Token{" "}
<span style={{ color: "#b5b0a6", fontWeight: 400 }}>(required for private repos)</span>
</FieldLabel>
<input
type="password"
value={pat}
onChange={e => setPat(e.target.value)}
placeholder="ghp_… or similar"
style={{
width: "100%", padding: "11px 14px", marginBottom: 20,
borderRadius: 8, border: "1px solid #e0dcd4",
background: "#faf8f5", fontSize: "0.9rem",
fontFamily: "Outfit, sans-serif", color: "#1a1a1a",
outline: "none", boxSizing: "border-box",
}}
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
/>
<div style={{ fontSize: "0.75rem", color: "#a09a90", marginBottom: 20, lineHeight: 1.5, padding: "12px 14px", background: "#faf8f5", borderRadius: 8, border: "1px solid #f0ece4" }}>
Atlas will clone your repo, read key files, and build a full architecture map tech stack, routes, database, auth, and third-party integrations. Tokens are used only for cloning and are not stored.
</div>
<PrimaryButton onClick={handleCreate} disabled={!canCreate} loading={loading}>
Import & map
</PrimaryButton>
</div>
);
}

View File

@@ -0,0 +1,106 @@
"use client";
import { useState, useEffect } from "react";
import { createPortal } from "react-dom";
import { TypeSelector } from "./TypeSelector";
import { FreshIdeaSetup } from "./FreshIdeaSetup";
import { ChatImportSetup } from "./ChatImportSetup";
import { CodeImportSetup } from "./CodeImportSetup";
import { MigrateSetup } from "./MigrateSetup";
export type CreationMode = "fresh" | "chat-import" | "code-import" | "migration";
interface CreateProjectFlowProps {
open: boolean;
onOpenChange: (open: boolean) => void;
workspace: string;
}
type Step = "select-type" | "setup";
export function CreateProjectFlow({ open, onOpenChange, workspace }: CreateProjectFlowProps) {
const [step, setStep] = useState<Step>("select-type");
const [mode, setMode] = useState<CreationMode | null>(null);
useEffect(() => {
if (open) {
setStep("select-type");
setMode(null);
}
}, [open]);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onOpenChange(false); };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onOpenChange]);
if (!open) return null;
const handleSelectType = (selected: CreationMode) => {
setMode(selected);
setStep("setup");
};
const handleBack = () => {
setStep("select-type");
setMode(null);
};
const setupProps = { workspace, onClose: () => onOpenChange(false), onBack: handleBack };
return createPortal(
<>
<style>{`
@keyframes vibn-fadeIn { from { opacity:0; } to { opacity:1; } }
@keyframes vibn-slideUp { from { opacity:0; transform:translateY(14px); } to { opacity:1; transform:translateY(0); } }
@keyframes vibn-spin { to { transform:rotate(360deg); } }
`}</style>
{/* Backdrop */}
<div
onClick={() => onOpenChange(false)}
style={{
position: "fixed", inset: 0, zIndex: 50,
background: "rgba(26,26,26,0.38)",
animation: "vibn-fadeIn 0.15s ease",
}}
/>
{/* Modal container */}
<div style={{
position: "fixed", inset: 0, zIndex: 51,
display: "flex", alignItems: "center", justifyContent: "center",
padding: 24, pointerEvents: "none",
}}>
<div
onClick={e => e.stopPropagation()}
style={{
background: "#fff", borderRadius: 16,
boxShadow: "0 12px 48px rgba(26,26,26,0.16)",
width: "100%",
maxWidth: step === "select-type" ? 620 : 520,
fontFamily: "Outfit, sans-serif",
pointerEvents: "all",
animation: "vibn-slideUp 0.18s cubic-bezier(0.4,0,0.2,1)",
transition: "max-width 0.2s ease",
overflow: "hidden",
}}
>
{step === "select-type" && (
<TypeSelector
onSelect={handleSelectType}
onClose={() => onOpenChange(false)}
/>
)}
{step === "setup" && mode === "fresh" && <FreshIdeaSetup {...setupProps} />}
{step === "setup" && mode === "chat-import" && <ChatImportSetup {...setupProps} />}
{step === "setup" && mode === "code-import" && <CodeImportSetup {...setupProps} />}
{step === "setup" && mode === "migration" && <MigrateSetup {...setupProps} />}
</div>
</div>
</>,
document.body
);
}

View File

@@ -0,0 +1,91 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { SetupHeader, FieldLabel, TextInput, PrimaryButton, type SetupProps } from "./setup-shared";
export function FreshIdeaSetup({ workspace, onClose, onBack }: SetupProps) {
const router = useRouter();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [loading, setLoading] = useState(false);
const nameRef = useRef<HTMLInputElement>(null);
const canCreate = name.trim().length > 0;
const handleCreate = async () => {
if (!canCreate) return;
setLoading(true);
try {
const res = await fetch("/api/projects/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectName: name.trim(),
projectType: "web-app",
slug: name.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
product: { name: name.trim() },
creationMode: "fresh",
sourceData: { description: description.trim() },
}),
});
if (!res.ok) {
const err = await res.json();
toast.error(err.error || "Failed to create project");
return;
}
const data = await res.json();
onClose();
router.push(`/${workspace}/project/${data.projectId}/overview`);
} catch {
toast.error("Something went wrong");
} finally {
setLoading(false);
}
};
return (
<div style={{ padding: "32px 36px 36px" }}>
<SetupHeader
icon="✦" label="Fresh Idea" tagline="Start from scratch"
accent="#4a3728" onBack={onBack} onClose={onClose}
/>
<FieldLabel>Project name</FieldLabel>
<TextInput
value={name}
onChange={setName}
placeholder="e.g. Foxglove, Meridian, OpsAI…"
onKeyDown={e => { if (e.key === "Enter" && canCreate) handleCreate(); }}
inputRef={nameRef}
autoFocus
/>
<FieldLabel>One-line description <span style={{ color: "#b5b0a6", fontWeight: 400 }}>(optional)</span></FieldLabel>
<input
type="text"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="A short description to kick off the conversation"
style={{
width: "100%", padding: "11px 14px", marginBottom: 20,
borderRadius: 8, border: "1px solid #e0dcd4",
background: "#faf8f5", fontSize: "0.9rem",
fontFamily: "Outfit, sans-serif", color: "#1a1a1a",
outline: "none", boxSizing: "border-box",
}}
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
/>
<div style={{ fontSize: "0.75rem", color: "#a09a90", marginBottom: 20, lineHeight: 1.5, padding: "12px 14px", background: "#faf8f5", borderRadius: 8, border: "1px solid #f0ece4" }}>
Atlas will guide you through 6 discovery phases Big Picture, Users, Features, Business Model, Screens, and Risks building your product plan as you go.
</div>
<PrimaryButton onClick={handleCreate} disabled={!canCreate} loading={loading}>
Start with Atlas
</PrimaryButton>
</div>
);
}

View File

@@ -0,0 +1,159 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { SetupHeader, FieldLabel, TextInput, PrimaryButton, type SetupProps } from "./setup-shared";
const HOSTING_OPTIONS = [
{ value: "", label: "Select hosting provider" },
{ value: "vercel", label: "Vercel" },
{ value: "aws", label: "AWS (EC2 / ECS / Elastic Beanstalk)" },
{ value: "heroku", label: "Heroku" },
{ value: "digitalocean", label: "DigitalOcean (Droplet / App Platform)" },
{ value: "gcp", label: "Google Cloud Platform" },
{ value: "azure", label: "Microsoft Azure" },
{ value: "railway", label: "Railway" },
{ value: "render", label: "Render" },
{ value: "netlify", label: "Netlify" },
{ value: "self-hosted", label: "Self-hosted / VPS" },
{ value: "other", label: "Other" },
];
export function MigrateSetup({ workspace, onClose, onBack }: SetupProps) {
const router = useRouter();
const [name, setName] = useState("");
const [repoUrl, setRepoUrl] = useState("");
const [liveUrl, setLiveUrl] = useState("");
const [hosting, setHosting] = useState("");
const [pat, setPat] = useState("");
const [loading, setLoading] = useState(false);
const isValidRepo = repoUrl.trim().startsWith("http");
const isValidLive = liveUrl.trim().startsWith("http");
const canCreate = name.trim().length > 0 && (isValidRepo || isValidLive);
const handleCreate = async () => {
if (!canCreate) return;
setLoading(true);
try {
const res = await fetch("/api/projects/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectName: name.trim(),
projectType: "web-app",
slug: name.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
product: { name: name.trim() },
creationMode: "migration",
sourceData: {
repoUrl: repoUrl.trim() || undefined,
liveUrl: liveUrl.trim() || undefined,
hosting: hosting || undefined,
pat: pat.trim() || undefined,
},
}),
});
if (!res.ok) {
const err = await res.json();
toast.error(err.error || "Failed to create project");
return;
}
const data = await res.json();
onClose();
router.push(`/${workspace}/project/${data.projectId}/overview`);
} catch {
toast.error("Something went wrong");
} finally {
setLoading(false);
}
};
return (
<div style={{ padding: "32px 36px 36px" }}>
<SetupHeader
icon="⇢" label="Migrate Product" tagline="Move an existing product"
accent="#4a2a5a" onBack={onBack} onClose={onClose}
/>
<FieldLabel>Product name</FieldLabel>
<TextInput
value={name}
onChange={setName}
placeholder="What is this product called?"
autoFocus
/>
<FieldLabel>
Repository URL{" "}
<span style={{ color: "#b5b0a6", fontWeight: 400 }}>(recommended)</span>
</FieldLabel>
<TextInput
value={repoUrl}
onChange={setRepoUrl}
placeholder="https://github.com/yourorg/your-repo"
/>
<FieldLabel>
Live URL{" "}
<span style={{ color: "#b5b0a6", fontWeight: 400 }}>(optional)</span>
</FieldLabel>
<TextInput
value={liveUrl}
onChange={setLiveUrl}
placeholder="https://yourproduct.com"
/>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12, marginBottom: 4 }}>
<div>
<FieldLabel>Hosting provider</FieldLabel>
<select
value={hosting}
onChange={e => setHosting(e.target.value)}
style={{
width: "100%", padding: "11px 14px", marginBottom: 16,
borderRadius: 8, border: "1px solid #e0dcd4",
background: "#faf8f5", fontSize: "0.88rem",
fontFamily: "Outfit, sans-serif", color: hosting ? "#1a1a1a" : "#a09a90",
outline: "none", boxSizing: "border-box", appearance: "none",
backgroundImage: `url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23a09a90' strokeWidth='1.5' strokeLinecap='round' strokeLinejoin='round'/%3E%3C/svg%3E")`,
backgroundRepeat: "no-repeat", backgroundPosition: "right 12px center",
}}
>
{HOSTING_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
<div>
<FieldLabel>
PAT{" "}<span style={{ color: "#b5b0a6", fontWeight: 400 }}>(private repos)</span>
</FieldLabel>
<input
type="password"
value={pat}
onChange={e => setPat(e.target.value)}
placeholder="ghp_…"
style={{
width: "100%", padding: "11px 14px", marginBottom: 16,
borderRadius: 8, border: "1px solid #e0dcd4",
background: "#faf8f5", fontSize: "0.9rem",
fontFamily: "Outfit, sans-serif", color: "#1a1a1a",
outline: "none", boxSizing: "border-box",
}}
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
/>
</div>
</div>
<div style={{ fontSize: "0.75rem", color: "#a09a90", marginBottom: 20, lineHeight: 1.5, padding: "12px 14px", background: "#faf8f5", borderRadius: 8, border: "1px solid #f0ece4" }}>
<strong style={{ color: "#4a2a5a" }}>Non-destructive.</strong> Atlas builds a full audit and migration plan. Your existing product stays live throughout the entire migration process.
</div>
<PrimaryButton onClick={handleCreate} disabled={!canCreate} loading={loading}>
Start migration plan
</PrimaryButton>
</div>
);
}

View File

@@ -0,0 +1,145 @@
"use client";
import type { CreationMode } from "./CreateProjectFlow";
interface TypeSelectorProps {
onSelect: (mode: CreationMode) => void;
onClose: () => void;
}
const FLOW_TYPES: {
id: CreationMode;
icon: string;
label: string;
tagline: string;
desc: string;
accent: string;
}[] = [
{
id: "fresh",
icon: "✦",
label: "Fresh Idea",
tagline: "Start from scratch",
desc: "Talk through your idea with Atlas. We'll explore it together and shape it into a full product plan.",
accent: "#4a3728",
},
{
id: "chat-import",
icon: "⌁",
label: "Import Chats",
tagline: "You've been thinking",
desc: "Paste conversations from ChatGPT or Claude. Atlas extracts your decisions, ideas, and open questions.",
accent: "#2e5a4a",
},
{
id: "code-import",
icon: "⌘",
label: "Import Code",
tagline: "Already have a repo",
desc: "Point Atlas at your GitHub or Bitbucket repo. We'll map your stack and show what's missing.",
accent: "#1a3a5c",
},
{
id: "migration",
icon: "⇢",
label: "Migrate Product",
tagline: "Move an existing product",
desc: "Bring your live product into the VIBN infrastructure. Atlas builds a safe, phased migration plan.",
accent: "#4a2a5a",
},
];
export function TypeSelector({ onSelect, onClose }: TypeSelectorProps) {
return (
<div style={{ padding: "32px 36px 36px" }}>
{/* Header */}
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 28 }}>
<div>
<h2 style={{
fontFamily: "Newsreader, serif", fontSize: "1.4rem", fontWeight: 400,
color: "#1a1a1a", margin: 0, marginBottom: 4,
}}>
Start a new project
</h2>
<p style={{ fontSize: "0.78rem", color: "#a09a90", margin: 0 }}>
How would you like to begin?
</p>
</div>
<button
onClick={onClose}
style={{
background: "none", border: "none", cursor: "pointer",
color: "#b5b0a6", fontSize: "1.2rem", lineHeight: 1,
padding: "2px 5px", borderRadius: 4,
}}
onMouseEnter={e => (e.currentTarget.style.color = "#6b6560")}
onMouseLeave={e => (e.currentTarget.style.color = "#b5b0a6")}
>
×
</button>
</div>
{/* Type cards */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
{FLOW_TYPES.map(type => (
<button
key={type.id}
onClick={() => onSelect(type.id)}
style={{
display: "flex", flexDirection: "column", alignItems: "flex-start",
gap: 0, padding: "20px", borderRadius: 12, textAlign: "left",
border: "1px solid #e8e4dc",
background: "#faf8f5",
cursor: "pointer",
transition: "all 0.14s",
fontFamily: "Outfit, sans-serif",
position: "relative",
overflow: "hidden",
}}
onMouseEnter={e => {
e.currentTarget.style.borderColor = "#d0ccc4";
e.currentTarget.style.background = "#fff";
e.currentTarget.style.boxShadow = "0 2px 12px rgba(26,26,26,0.07)";
}}
onMouseLeave={e => {
e.currentTarget.style.borderColor = "#e8e4dc";
e.currentTarget.style.background = "#faf8f5";
e.currentTarget.style.boxShadow = "none";
}}
>
{/* Icon */}
<div style={{
width: 36, height: 36, borderRadius: 9, marginBottom: 14,
background: `${type.accent}10`,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "1.1rem", color: type.accent,
}}>
{type.icon}
</div>
{/* Label + tagline */}
<div style={{ fontSize: "0.88rem", fontWeight: 700, color: "#1a1a1a", marginBottom: 2 }}>
{type.label}
</div>
<div style={{ fontSize: "0.68rem", fontWeight: 600, color: type.accent, letterSpacing: "0.03em", marginBottom: 8, textTransform: "uppercase" }}>
{type.tagline}
</div>
{/* Description */}
<div style={{ fontSize: "0.75rem", color: "#8a8478", lineHeight: 1.5 }}>
{type.desc}
</div>
{/* Arrow */}
<div style={{
position: "absolute", right: 16, bottom: 16,
fontSize: "0.85rem", color: "#c5c0b8",
}}>
</div>
</button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,153 @@
"use client";
import { ReactNode, CSSProperties } from "react";
export interface SetupProps {
workspace: string;
onClose: () => void;
onBack: () => void;
}
// Shared modal header
export function SetupHeader({
icon,
label,
tagline,
accent,
onBack,
onClose,
}: {
icon: string;
label: string;
tagline: string;
accent: string;
onBack: () => void;
onClose: () => void;
}) {
return (
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 28 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<button
onClick={onBack}
style={{
background: "none", border: "none", cursor: "pointer",
color: "#b5b0a6", fontSize: "1rem", padding: "3px 5px",
borderRadius: 4, lineHeight: 1, flexShrink: 0,
}}
onMouseEnter={e => (e.currentTarget.style.color = "#1a1a1a")}
onMouseLeave={e => (e.currentTarget.style.color = "#b5b0a6")}
>
</button>
<div>
<h2 style={{
fontFamily: "Newsreader, serif", fontSize: "1.3rem", fontWeight: 400,
color: "#1a1a1a", margin: 0, marginBottom: 3,
}}>
{label}
</h2>
<p style={{ fontSize: "0.72rem", fontWeight: 600, color: accent, textTransform: "uppercase", letterSpacing: "0.04em", margin: 0 }}>
{tagline}
</p>
</div>
</div>
<button
onClick={onClose}
style={{
background: "none", border: "none", cursor: "pointer",
color: "#b5b0a6", fontSize: "1.2rem", lineHeight: 1,
padding: "2px 5px", borderRadius: 4, flexShrink: 0,
}}
onMouseEnter={e => (e.currentTarget.style.color = "#6b6560")}
onMouseLeave={e => (e.currentTarget.style.color = "#b5b0a6")}
>
×
</button>
</div>
);
}
export function FieldLabel({ children }: { children: ReactNode }) {
return (
<label style={{ display: "block", fontSize: "0.72rem", fontWeight: 600, color: "#6b6560", marginBottom: 6, letterSpacing: "0.02em" }}>
{children}
</label>
);
}
export function TextInput({
value,
onChange,
placeholder,
onKeyDown,
autoFocus,
inputRef,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
autoFocus?: boolean;
inputRef?: React.RefObject<HTMLInputElement>;
}) {
const base: CSSProperties = {
width: "100%", padding: "11px 14px", marginBottom: 16,
borderRadius: 8, border: "1px solid #e0dcd4",
background: "#faf8f5", fontSize: "0.9rem",
fontFamily: "Outfit, sans-serif", color: "#1a1a1a",
outline: "none", boxSizing: "border-box",
};
return (
<input
ref={inputRef}
type="text"
value={value}
onChange={e => onChange(e.target.value)}
onKeyDown={onKeyDown}
placeholder={placeholder}
autoFocus={autoFocus}
style={base}
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
/>
);
}
export function PrimaryButton({
onClick,
disabled,
loading,
children,
}: {
onClick: () => void;
disabled?: boolean;
loading?: boolean;
children: ReactNode;
}) {
const active = !disabled && !loading;
return (
<button
onClick={onClick}
disabled={!active}
style={{
width: "100%", padding: "12px",
borderRadius: 8, border: "none",
background: active ? "#1a1a1a" : "#e0dcd4",
color: active ? "#fff" : "#b5b0a6",
fontSize: "0.88rem", fontWeight: 600,
fontFamily: "Outfit, sans-serif",
cursor: active ? "pointer" : "not-allowed",
display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
}}
onMouseEnter={e => { if (active) e.currentTarget.style.opacity = "0.85"; }}
onMouseLeave={e => { e.currentTarget.style.opacity = "1"; }}
>
{loading ? (
<>
<span style={{ width: 14, height: 14, borderRadius: "50%", border: "2px solid #fff4", borderTopColor: "#fff", animation: "vibn-spin 0.7s linear infinite", display: "inline-block" }} />
Creating
</>
) : children}
</button>
);
}