feat(api): comprehensive QA hardening — security gates, chat improvements, beta scaffolds

Closes checklist items F-01..F-06, D-01..D-28, S-01..S-10, C-01..C-07,
B-01..B-07, R-01..R-02, O-03.

Security (28 deletions + 10 auth gates):
- Delete 28 unauthenticated debug/cursor/firebase/test routes
- Gate ai/chat, ai/conversation, context/summarize, work-completed with withTenantProject/withAuth
- Add HMAC-SHA256 signature verification to webhooks/coolify
- Switch all admin secret comparisons to timingSafeStringEq

Foundations (lib/server/*):
- api-handler.ts: withAuth, withTenantProject, withWorkspace, withAdminSecret, withRateLimit
- logger.ts: structured request-scoped logging with turnId
- audit-log.ts: writeAuditLog helper + audit_log table
- rate-limit.ts: Postgres sliding window rate limiter
- coolify-webhook.ts: verifyCoolifySignature
- timing-safe.ts: timingSafeStringEq

Chat hardening (chat/route.ts):
- MAX_TOOL_ROUNDS 15 → 8 (C-01)
- Loop detection: hard-break at 3 identical fingerprints (was 5) (C-02)
- Add 6-consecutive-tool-call hard-break (C-02)
- Mode: respond first, act second prompt block (C-03)
- SSE heartbeat every 25s via setInterval (C-04)
- Per-tool 45s timeout via Promise.race (C-05)
- turnId per-turn UUID for log correlation (C-06)
- Recovery fires when roundsSinceText >= 4 (C-07)
- SSE plan event on plan_task_add/edit (B-05)

Beta features:
- invites table + GET/POST /api/invites (P4.8)
- invites/[token] validate + redeem (P4.8)
- fs_project_dev_servers table + lib/server/dev-server-state.ts (P6.B1)
- fs_project_secrets table + CRUD routes (P6.D2)
- lib/integrations/brief-extract.ts (P3.7)

Documentation:
- app/api/ROUTES.md: full route map with auth + tenant
This commit is contained in:
2026-05-17 19:17:22 -07:00
parent 955aeed6ce
commit 6b8862ef2b
86 changed files with 6772 additions and 2817 deletions

View File

@@ -0,0 +1,45 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Vibn — Onboarding</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="icon" type="image/png" href="assets/logo-black.png" />
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="onboarding.css" />
<template id="__bundler_thumbnail" data-bg-color="#27201d">
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="200" fill="#27201d"/>
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#e9835d"/>
<stop offset="1" stop-color="#b53a25"/>
</linearGradient>
</defs>
<circle cx="100" cy="100" r="58" fill="url(#g)"/>
<g fill="#1a0f0a" stroke="#1a0f0a" stroke-width="2" stroke-linejoin="round">
<path d="M82 78 L94 78 L98 110 L102 78 L114 78 L102 130 Z"/>
<rect x="118" y="120" width="14" height="5" rx="1"/>
</g>
</svg>
</template>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="primitives.jsx"></script>
<script type="text/babel" src="onboarding-primitives.jsx"></script>
<script type="text/babel" src="onboarding-fork.jsx"></script>
<script type="text/babel" src="onboarding-entrepreneur.jsx"></script>
<script type="text/babel" src="onboarding-owner.jsx"></script>
<script type="text/babel" src="onboarding-consultant.jsx"></script>
<script type="text/babel" src="onboarding-build.jsx"></script>
<script type="text/babel" src="onboarding-app.jsx"></script>
</body>
</html>

28
new-site/Onboarding.html Normal file
View File

@@ -0,0 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Vibn — Onboarding</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="icon" type="image/png" href="assets/logo-black.png" />
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="onboarding.css" />
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="primitives.jsx"></script>
<script type="text/babel" src="onboarding-primitives.jsx"></script>
<script type="text/babel" src="onboarding-fork.jsx"></script>
<script type="text/babel" src="onboarding-entrepreneur.jsx"></script>
<script type="text/babel" src="onboarding-owner.jsx"></script>
<script type="text/babel" src="onboarding-consultant.jsx"></script>
<script type="text/babel" src="onboarding-build.jsx"></script>
<script type="text/babel" src="onboarding-app.jsx"></script>
</body>
</html>

181
new-site/onboarding-app.jsx Normal file
View File

@@ -0,0 +1,181 @@
// Root onboarding app — owns the route state and the answers dict.
// Routes: fork → <path> → build → ready. A floating debug navigator (toggle
// in the lower-right) lets reviewers jump between any screen without
// filling out the form.
function OnboardingApp() {
const initialName = React.useMemo(() => {
try { return localStorage.getItem("vibn:firstName") || ""; } catch { return ""; }
}, []);
const [stage, setStage] = React.useState("fork"); // fork | path | build | ready
const [path, setPath] = React.useState(null); // entrepreneur | owner | consultant
const [forkChoice, setForkChoice] = React.useState(null);
const [step, setStep] = React.useState(0);
const [data, setData] = React.useState({});
const [debugOpen, setDebugOpen] = React.useState(false);
const update = (patch) => setData((d) => ({ ...d, ...patch }));
// ── transitions ──────────────────────────────────────────────────────
const confirmFork = () => {
if (!forkChoice) return;
setPath(forkChoice);
setStep(0);
setStage("path");
};
const backToFork = () => { setStage("fork"); setStep(0); };
const completePath = () => setStage("build");
const openWorkspace = () => setStage("ready");
const close = () => { window.location.href = "index.html"; };
const openChat = () => { window.location.href = "index.html"; };
// ⌘↵ advances on whatever the current primary action is
React.useEffect(() => {
const handler = (e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
const btn = document.querySelector(".btn-primary:not([disabled])");
if (btn) btn.click();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
// ── render ───────────────────────────────────────────────────────────
let body;
if (stage === "fork") {
body = (
<ForkScreen
name={initialName}
value={forkChoice}
onChange={setForkChoice}
onClose={close}
onNext={confirmFork}
/>
);
} else if (stage === "path") {
const props = {
data, onUpdate: update,
onBack: backToFork,
onClose: close,
onComplete: completePath,
onJumpToStep: setStep,
step,
};
if (path === "entrepreneur") body = <EntrepreneurPath {...props} />;
else if (path === "owner") body = <OwnerPath {...props} />;
else body = <ConsultantPath {...props} />;
} else if (stage === "build") {
body = (
<BuildScreen
path={path} data={data}
onBack={() => setStage("path")}
onClose={close}
onOpen={openWorkspace}
/>
);
} else {
body = <ReadyScreen path={path} data={data} onClose={close} onOpenChat={openChat} />;
}
return (
<div className="app">
{body}
<DebugNav
open={debugOpen} setOpen={setDebugOpen}
stage={stage} path={path} step={step}
onJump={(s, p, idx) => {
if (s === "fork") setStage("fork");
else if (s === "build") { setPath(p); setStage("build"); }
else if (s === "ready") { setPath(p); setStage("ready"); }
else { setPath(p); setStep(idx); setStage("path"); }
}}
/>
</div>
);
}
// ── Debug navigator ──────────────────────────────────────────────────────
function DebugNav({ open, setOpen, stage, path, step, onJump }) {
const groups = [
{ title: "Start", rows: [
{ label: "01 · Fork", active: stage === "fork", go: () => onJump("fork") },
]},
{ title: "Entrepreneur", rows: [
{ label: "02 · Idea", active: stage === "path" && path === "entrepreneur" && step === 0, go: () => onJump("path", "entrepreneur", 0) },
{ label: "03 · Audience", active: stage === "path" && path === "entrepreneur" && step === 1, go: () => onJump("path", "entrepreneur", 1) },
{ label: "04 · Goal", active: stage === "path" && path === "entrepreneur" && step === 2, go: () => onJump("path", "entrepreneur", 2) },
{ label: "05 · Vibe", active: stage === "path" && path === "entrepreneur" && step === 3, go: () => onJump("path", "entrepreneur", 3) },
]},
{ title: "Owner", rows: [
{ label: "02 · Business", active: stage === "path" && path === "owner" && step === 0, go: () => onJump("path", "owner", 0) },
{ label: "03 · Stack", active: stage === "path" && path === "owner" && step === 1, go: () => onJump("path", "owner", 1) },
{ label: "04 · First fix", active: stage === "path" && path === "owner" && step === 2, go: () => onJump("path", "owner", 2) },
{ label: "05 · Scale", active: stage === "path" && path === "owner" && step === 3, go: () => onJump("path", "owner", 3) },
]},
{ title: "Consultant", rows: [
{ label: "02 · Client", active: stage === "path" && path === "consultant" && step === 0, go: () => onJump("path", "consultant", 0) },
{ label: "03 · Brief", active: stage === "path" && path === "consultant" && step === 1, go: () => onJump("path", "consultant", 1) },
{ label: "04 · Scope", active: stage === "path" && path === "consultant" && step === 2, go: () => onJump("path", "consultant", 2) },
{ label: "05 · Handoff", active: stage === "path" && path === "consultant" && step === 3, go: () => onJump("path", "consultant", 3) },
]},
{ title: "Finish", rows: [
{ label: "Build · entrepreneur", active: stage === "build" && path === "entrepreneur", go: () => onJump("build", "entrepreneur") },
{ label: "Build · owner", active: stage === "build" && path === "owner", go: () => onJump("build", "owner") },
{ label: "Build · consultant", active: stage === "build" && path === "consultant", go: () => onJump("build", "consultant") },
{ label: "Ready", active: stage === "ready", go: () => onJump("ready", path || "entrepreneur") },
]},
];
return (
<div className="debug">
{open && (
<div className="debug-panel">
{groups.map((g) => (
<React.Fragment key={g.title}>
<div style={{
fontFamily: "var(--font-mono)",
fontSize: 9.5,
color: "var(--fg-faint)",
letterSpacing: "0.14em",
textTransform: "uppercase",
padding: "8px 8px 4px",
}}>{g.title}</div>
{g.rows.map((r) => (
<button
key={r.label}
type="button"
className={"debug-row" + (r.active ? " active" : "")}
onClick={r.go}
>
{r.active && <b> </b>}{r.label}
</button>
))}
</React.Fragment>
))}
<button
type="button"
className="debug-row"
onClick={() => setOpen(false)}
style={{ marginTop: 8, justifyContent: "center", color: "var(--fg-mute)" }}
>
Close
</button>
</div>
)}
<button
type="button"
className="debug-toggle"
onClick={() => setOpen((o) => !o)}
title="Designer navigator"
>
<span style={{ color: "var(--accent)", marginRight: 6 }}></span>
{stage === "path" ? `${path} · step ${step + 1}` : stage}
</button>
</div>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<OnboardingApp />);

View File

@@ -0,0 +1,445 @@
// Build + Ready screens. The build screen shows the terminal stream + a live
// preview stencil; ready is a quiet confirmation page with the workspace URL.
// ── Per-path build plans ───────────────────────────────────────────────────
function buildPlanFor(path, data) {
const common = [
{ line: "vibn init — reading brief", ms: 600 },
{ line: "↳ provisioning workspace", ms: 700 },
{ line: "↳ wiring auth (email + Google)", ms: 800 },
{ line: "↳ minting database & seed schema", ms: 700 },
];
if (path === "entrepreneur") {
return [
...common,
{ line: `↳ generating landing page · vibe "${data.vibe || "warm"}"`, ms: 900 },
{ line: `↳ writing copy aimed at "${(data.audience || "your audience").slice(0, 40)}"`, ms: 800 },
{ line: "↳ wiring email capture + Stripe payment link", ms: 700 },
{ line: "↳ scaffolding admin: subscribers, sales, comments", ms: 800 },
{ line: `↳ tuning launch plan for goal: ${data.goal || "first_customer"}`, ms: 700 },
{ line: "↳ publishing preview → " + workspaceUrlFor(path, data), ms: 900 },
{ line: "ready.", ms: 400, ok: true },
];
}
if (path === "owner") {
return [
...common,
{ line: `↳ modelling ${data.biz || "small business"} for "${data.bizName || "your business"}"`, ms: 800 },
{ line: `↳ importing your stack (${(data.tools || []).length} tools)`, ms: 800 },
{ line: `↳ building module: ${labelFor(data.firstThing)}`, ms: 1000 },
{ line: "↳ generating customer + job records (10 sample)", ms: 700 },
{ line: "↳ scheduling daily ops view + weekly report", ms: 700 },
{ line: `↳ wiring savings tracker · est. $${(data.spend || 0)}/mo replaced`, ms: 800 },
{ line: "↳ publishing preview → " + workspaceUrlFor(path, data), ms: 900 },
{ line: "ready.", ms: 400, ok: true },
];
}
return [
...common,
{ line: `↳ branding workspace for "${data.clientName || "client"}"`, ms: 800 },
{ line: `↳ scaffolding scope (${(data.scope || []).length} modules)`, ms: 1000 },
...(data.scope || []).slice(0, 4).map((s) => ({ line: `${s}`, ms: 350 })),
{ line: "↳ generating handoff document + invoice template", ms: 700 },
{ line: `↳ setting deploy target: ${data.handoff || "subdomain"}`, ms: 700 },
{ line: "↳ publishing preview → " + workspaceUrlFor(path, data), ms: 900 },
{ line: "ready.", ms: 400, ok: true },
];
}
function labelFor(id) {
const map = {
booking: "Bookings & scheduling",
invoicing: "Quotes & invoices",
customers: "Customer portal",
inventory: "Inventory & orders",
team: "Team & dispatch",
marketing: "Marketing site",
};
return map[id] || "your first workflow";
}
function workspaceUrlFor(path, data) {
const seed =
(path === "owner" && data.bizName) ||
(path === "consultant" && data.clientName) ||
(path === "entrepreneur" && (data.audience || data.idea)) ||
"your-workspace";
const slug = String(seed).toLowerCase()
.replace(/[^a-z0-9\s-]/g, "")
.trim()
.split(/\s+/)
.slice(0, 3)
.join("-")
.slice(0, 28) || "your-workspace";
return `${slug}.vibn.app`;
}
const BUILD_BIZ_LABEL = {
service: "Trades / services",
retail: "Retail",
food: "Food & drink",
appointments: "Appointments",
events: "Events / hospitality",
other: "Small business",
};
// ── Build screen ───────────────────────────────────────────────────────────
function BuildScreen({ path, data, onBack, onClose, onOpen }) {
const plan = React.useMemo(() => buildPlanFor(path, data), [path, data]);
const [lineIdx, setLineIdx] = React.useState(0);
const [done, setDone] = React.useState(false);
const logRef = React.useRef(null);
React.useEffect(() => {
if (lineIdx >= plan.length) { setDone(true); return undefined; }
const t = setTimeout(() => setLineIdx(lineIdx + 1), plan[lineIdx].ms);
return () => clearTimeout(t);
}, [lineIdx, plan]);
React.useEffect(() => {
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
}, [lineIdx]);
const url = workspaceUrlFor(path, data);
const lane = LANE_LABELS[path];
const previewTitle =
path === "owner" ? (data.bizName || "Your business") :
path === "consultant" ? (data.clientName || "Your client") :
"Your launch page";
const previewSub =
path === "owner" ? `${BUILD_BIZ_LABEL[data.biz] || "Small business"} · ${labelFor(data.firstThing)}` :
path === "consultant" ? (data.industry || "Project") :
(data.audience || "An idea worth building").slice(0, 64);
const pct = done ? 1 : lineIdx / plan.length;
return (
<>
<WizardTop
onBack={onBack}
onClose={onClose}
lane={lane}
stepText={done ? "Done" : "Building"}
progress={pct}
/>
<main className="wiz-body" style={{ paddingTop: "clamp(28px, 5vh, 56px)" }}>
<div className="wiz-card xwide" style={{ gap: 18 }}>
<style>{`
.b-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr);
gap: 18px;
}
@media (max-width: 920px) { .b-grid { grid-template-columns: 1fr; } }
.b-pane {
border-radius: 12px;
border: 1px solid var(--hairline);
background: linear-gradient(180deg, oklch(0.16 0.008 60 / 0.92), oklch(0.14 0.008 60 / 0.92));
min-height: 420px;
display: flex; flex-direction: column;
overflow: hidden;
}
.b-pane-bar {
display: flex; align-items: center; gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid var(--hairline);
background: oklch(0.16 0.008 60 / 0.6);
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-mute);
letter-spacing: 0.04em;
}
.b-pane-bar .live {
margin-left: auto;
display: inline-flex; align-items: center; gap: 6px;
color: oklch(0.85 0.16 155);
font-size: 10.5px; letter-spacing: 0.08em; text-transform: uppercase;
}
.b-pane-bar .live::before {
content: ""; width: 5px; height: 5px; border-radius: 50%;
background: oklch(0.78 0.16 155);
box-shadow: 0 0 0 0 oklch(0.78 0.16 155 / 0.6);
animation: pulse 2s ease-out infinite;
}
.b-log {
flex: 1;
padding: 14px 16px;
font-family: var(--font-mono);
font-size: 12.5px;
line-height: 1.7;
color: var(--fg-dim);
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
.b-log .l-ok { color: oklch(0.85 0.16 155); font-weight: 500; }
.b-log .l-current { color: var(--fg); }
.b-log .l-done { color: var(--fg-mute); }
.b-log .l-done::before { content: "✓ "; color: var(--ok); margin-right: 2px; }
.b-log .l-current::before { content: "● "; color: var(--accent); margin-right: 2px;
animation: blink 1s steps(2) infinite; }
.b-cursor {
display: inline-block;
width: 7px; height: 13px; vertical-align: -2px;
background: var(--accent);
margin-left: 2px;
animation: blink 1s steps(2) infinite;
box-shadow: 0 0 12px var(--accent-glow);
}
.b-prev-bar {
padding: 10px 14px;
display: flex; gap: 10px; align-items: center;
border-bottom: 1px solid var(--hairline);
background: oklch(0.16 0.008 60 / 0.5);
}
.b-prev-url {
font-family: var(--font-mono);
font-size: 11.5px;
color: var(--fg-mute);
padding: 4px 12px;
background: oklch(0.13 0.008 60);
border: 1px solid var(--hairline);
border-radius: 999px;
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.b-prev-stage {
flex: 1;
display: flex; align-items: center; justify-content: center;
padding: 22px;
}
.b-stencil {
width: 100%;
display: flex; flex-direction: column; gap: 12px;
opacity: 0;
animation: stencil-in 0.7s ease-out forwards;
}
@keyframes stencil-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.b-stencil-block {
border-radius: 9px;
background: linear-gradient(135deg, oklch(0.22 0.011 60), oklch(0.18 0.009 60));
border: 1px solid var(--hairline);
position: relative; overflow: hidden;
}
.b-stencil-block::after {
content: "";
position: absolute; inset: 0;
background: linear-gradient(90deg, transparent, oklch(1 0 0 / 0.06), transparent);
transform: translateX(-100%);
animation: shimmer 2s ease-in-out infinite;
}
@keyframes shimmer { to { transform: translateX(100%); } }
@keyframes blink { 50% { opacity: 0.25; } }
@keyframes pulse {
0% { box-shadow: 0 0 0 0 oklch(0.78 0.16 155 / 0.6); }
70% { box-shadow: 0 0 0 8px oklch(0.78 0.16 155 / 0); }
100% { box-shadow: 0 0 0 0 oklch(0.78 0.16 155 / 0); }
}
.b-foot {
display: flex; align-items: center; justify-content: space-between;
gap: 14px;
padding-top: 6px;
}
.b-foot-status {
font-family: var(--font-mono);
font-size: 11px; letter-spacing: 0.06em;
color: var(--fg-faint);
}
.b-foot-status b { color: var(--accent); font-weight: 500; }
`}</style>
<WizardQ
title={done ? "Your workspace is live." : "Building your workspace…"}
sub={done
? "Open it to see what Vibn made. Every change from here happens in the chat."
: "Nothing for you to do. Vibn is scaffolding everything from your answers."}
/>
<div className="b-grid">
{/* terminal */}
<div className="b-pane">
<div className="b-pane-bar">
<span style={{ width: 9, height: 9, borderRadius: "50%", background: "oklch(0.65 0.18 25 / 0.7)" }} />
<span style={{ width: 9, height: 9, borderRadius: "50%", background: "oklch(0.78 0.13 80 / 0.7)" }} />
<span style={{ width: 9, height: 9, borderRadius: "50%", background: "oklch(0.72 0.16 145 / 0.7)" }} />
<span style={{ marginLeft: 6 }}>vibn build {url}</span>
{!done && <span className="live">live</span>}
</div>
<div className="b-log" ref={logRef}>
{plan.slice(0, lineIdx + 1).map((p, i) => {
const isCurrent = i === lineIdx && !done;
const isOk = p.ok && i <= lineIdx;
const cls = isOk ? "l-ok" : isCurrent ? "l-current" : "l-done";
return <div key={i} className={cls}>{p.line}</div>;
})}
{!done && <span className="b-cursor" aria-hidden="true" />}
</div>
</div>
{/* preview */}
<div className="b-pane">
<div className="b-prev-bar">
<span style={{ display: "flex", gap: 5 }}>
<span style={{ width: 9, height: 9, borderRadius: "50%", background: "oklch(0.30 0.010 60)" }}/>
<span style={{ width: 9, height: 9, borderRadius: "50%", background: "oklch(0.30 0.010 60)" }}/>
<span style={{ width: 9, height: 9, borderRadius: "50%", background: "oklch(0.30 0.010 60)" }}/>
</span>
<span className="b-prev-url">https://{url}</span>
</div>
<div className="b-prev-stage">
<div className="b-stencil">
<div style={{ fontSize: 20, fontWeight: 500, letterSpacing: "-0.02em", color: "var(--fg)", lineHeight: 1.15 }}>
{previewTitle}
</div>
<div style={{ fontSize: 13, color: "var(--fg-mute)", lineHeight: 1.45 }}>
{previewSub}
</div>
<div style={{ height: 4 }} />
<div className="b-stencil-block" style={{ height: 80 }} />
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
<div className="b-stencil-block" style={{ height: 60, animationDelay: "0.4s" }} />
<div className="b-stencil-block" style={{ height: 60, animationDelay: "0.8s" }} />
</div>
</div>
</div>
</div>
</div>
<div className="b-foot">
<span className="b-foot-status">
{done ? "build complete" : <>Building <b>{Math.min(lineIdx, plan.length)}/{plan.length}</b></>}
</span>
{done && (
<button type="button" className="btn btn-primary btn-wiz" onClick={onOpen}>
Open my workspace <Arrow size={13} />
</button>
)}
</div>
</div>
</main>
</>
);
}
// ── Ready screen ───────────────────────────────────────────────────────────
function ReadyScreen({ path, data, onClose, onOpenChat }) {
const url = workspaceUrlFor(path, data);
const summary = summaryFor(path, data);
return (
<>
<WizardTop
onClose={onClose}
lane={LANE_LABELS[path]}
stepText="Ready"
progress={1}
/>
<WizardBody>
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 8 }}>
<span
style={{
width: 32, height: 32, borderRadius: 8,
background: "linear-gradient(135deg, var(--accent), oklch(0.65 0.20 18))",
boxShadow: "0 0 18px var(--accent-glow)",
display: "grid", placeItems: "center",
color: "var(--accent-fg)", flexShrink: 0,
}}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m3 8.5 3.2 3.2L13 5"/>
</svg>
</span>
</div>
<WizardQ
title="You're in."
sub="Workspace provisioned, first build is online. Every change from here happens in the chat."
/>
<div
style={{
borderRadius: 10,
border: "1px solid var(--hairline)",
background: "oklch(0.18 0.009 60 / 0.6)",
overflow: "hidden",
}}
>
<div
style={{
padding: "14px 16px",
borderBottom: "1px solid var(--hairline)",
display: "flex", alignItems: "center", gap: 10,
}}
>
<span className="mono" style={{ fontSize: 10.5, color: "var(--fg-faint)", letterSpacing: "0.12em", textTransform: "uppercase" }}>
Workspace URL
</span>
<span className="mono" style={{ fontSize: 14, color: "var(--fg)", marginLeft: "auto" }}>
{url}
</span>
</div>
<div style={{ padding: "12px 16px 14px", display: "flex", flexDirection: "column", gap: 8 }}>
{summary.map((row, i) => (
<div key={i} style={{ display: "flex", gap: 14, alignItems: "baseline", fontSize: 13.5 }}>
<span
className="mono"
style={{
width: 86, flexShrink: 0,
color: "var(--fg-faint)",
fontSize: 10.5, letterSpacing: "0.08em", textTransform: "uppercase",
}}
>
{row.label}
</span>
<span style={{ color: "var(--fg-dim)" }}>{row.value}</span>
</div>
))}
</div>
</div>
<div className="wiz-foot">
<a href="index.html" className="wiz-skip">Back to home</a>
<button type="button" className="btn btn-primary btn-wiz" onClick={onOpenChat}>
Open the build chat <Arrow size={13} />
</button>
</div>
</WizardBody>
</>
);
}
function summaryFor(path, data) {
if (path === "owner") {
return [
{ label: "Business", value: `${data.bizName || "Untitled"} · ${BUILD_BIZ_LABEL[data.biz] || "Small business"}` },
{ label: "Replacing", value: `${(data.tools || []).length} tools · ~$${data.spend || 0}/mo` },
{ label: "First fix", value: labelFor(data.firstThing) },
{ label: "Team", value: `${data.team || 1} · ${(data.customers || 0).toLocaleString()} cust/mo` },
];
}
if (path === "consultant") {
return [
{ label: "Client", value: `${data.clientName || "Untitled"} · ${data.industry || ""}` },
{ label: "Scope", value: `${(data.scope || []).length} modules` },
{ label: "Brief", value: (data.brief || "").slice(0, 60) + ((data.brief || "").length > 60 ? "…" : "") },
{ label: "Handoff", value: data.handoff || "subdomain" },
];
}
return [
{ label: "Building", value: (data.idea || "").slice(0, 64) + ((data.idea || "").length > 64 ? "…" : "") },
{ label: "Audience", value: (data.audience || "").slice(0, 64) },
{ label: "Goal", value: data.goal || "first_customer" },
{ label: "Vibe", value: data.vibe || "warm" },
];
}
Object.assign(window, { BuildScreen, ReadyScreen });

View File

@@ -0,0 +1,294 @@
// Consultant path — 4 steps for freelancers building for a client.
const CONS_TOTAL = 4;
const CONS_STEP_NAMES = ["Client", "Brief", "Scope", "Handoff"];
function ConsClient({ clientName, industry, contact, onChange }) {
return (
<>
<WizardQ
title="Who are you building for?"
sub="Used to brand the preview and the handoff doc."
/>
<Field label="Client / company">
<input
className="wiz-input"
placeholder="Rivera & Co Roofing"
value={clientName}
onChange={(e) => onChange({ clientName: e.target.value })}
autoFocus
/>
</Field>
<Field label="What they do">
<input
className="wiz-input"
placeholder="Residential roofing, mostly insurance jobs"
value={industry}
onChange={(e) => onChange({ industry: e.target.value })}
/>
</Field>
<Field label="Your point of contact" optional hint="So Vibn can address them in the handoff.">
<input
className="wiz-input"
placeholder="Marisol Rivera, Owner"
value={contact}
onChange={(e) => onChange({ contact: e.target.value })}
/>
</Field>
</>
);
}
const BRIEF_TEMPLATES = [
{ id: "quote_tool", label: "Quote tool",
body: "Customers request a quote with a few photos and a project description. The team reviews, sends a polished PDF, customer signs and pays a deposit online." },
{ id: "booking", label: "Booking system",
body: "Customers see real availability, book a service window, and get reminders. The team has a daily view of jobs with addresses and contact info." },
{ id: "portal", label: "Customer portal",
body: "Logged-in customers see past jobs, invoices, documents, and can message the business. The business sees a single page per customer." },
{ id: "internal", label: "Internal ops",
body: "Replace the spreadsheets the team is currently using. CRUD on jobs/customers, simple reports, role-based access, export to accounting." },
];
function ConsBrief({ brief, onChange }) {
return (
<>
<WizardQ
title="What did they ask for?"
sub="Paste the brief, or start from a template and edit."
/>
<Field label="Start from a template" optional>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{BRIEF_TEMPLATES.map((t) => (
<button
key={t.id}
type="button"
className="preset-chip"
style={{ padding: "8px 14px", fontSize: 13 }}
onClick={() => onChange(t.body)}
>
{t.label}
</button>
))}
</div>
</Field>
<Field label="Brief">
<textarea
className="wiz-input"
style={{ minHeight: 160 }}
placeholder="The client wants…"
value={brief}
onChange={(e) => onChange(e.target.value)}
/>
</Field>
</>
);
}
const SCOPE_GROUPS = [
{ label: "Foundations", items: ["Auth & accounts", "Roles & permissions", "Database & admin", "Hosting & domain"] },
{ label: "Customer-facing", items: ["Marketing site", "Booking / scheduling", "Quote requests", "Customer portal", "Self-serve forms"] },
{ label: "Money", items: ["Stripe / payments", "Invoices & receipts", "Subscriptions", "Refunds & disputes"] },
{ label: "Ops", items: ["Dashboards & reports", "CSV import / export", "Email + SMS notifs", "Audit log"] },
];
function ConsScope({ scope, onChange }) {
const toggle = (item) => {
if (scope.includes(item)) onChange(scope.filter((x) => x !== item));
else onChange([...scope, item]);
};
return (
<>
<WizardQ
title="What's in scope?"
sub="Tick what you've signed up to deliver. The rest you can add later — billable."
/>
<div
style={{
display: "grid",
gap: 12,
gridTemplateColumns: "repeat(2, 1fr)",
}}
>
{SCOPE_GROUPS.map((g) => (
<div
key={g.label}
style={{
padding: "14px 14px 10px",
borderRadius: 10,
border: "1px solid var(--hairline)",
background: "oklch(0.18 0.009 60 / 0.6)",
}}
>
<div
className="mono"
style={{
fontSize: 10.5, letterSpacing: "0.12em", textTransform: "uppercase",
color: "var(--fg-mute)", marginBottom: 10,
}}
>
{g.label}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{g.items.map((it) => {
const active = scope.includes(it);
return (
<button
key={it}
type="button"
onClick={() => toggle(it)}
style={{
display: "flex", alignItems: "center", gap: 10,
textAlign: "left",
padding: "6px 4px",
color: active ? "var(--fg)" : "var(--fg-dim)",
borderRadius: 6,
fontSize: 13.5,
}}
>
<span
style={{
width: 16, height: 16,
flexShrink: 0,
borderRadius: 4,
border: `1px solid ${active ? "var(--accent)" : "var(--hairline-2)"}`,
background: active ? "var(--accent)" : "transparent",
color: "var(--accent-fg)",
display: "grid", placeItems: "center",
}}
>
{active && (
<svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor"
strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m3 8.5 3.2 3.2L13 5"/>
</svg>
)}
</span>
{it}
</button>
);
})}
</div>
</div>
))}
</div>
</>
);
}
function ConsHandoff({ data, onChange }) {
return (
<>
<WizardQ
title="And finally — delivery."
sub="Where it lives, how you bill. Change later from settings."
/>
<Field label="Brand colors" optional hint="Hex, or just describe it. Paste a link, drop a screenshot — Vibn will figure it out.">
<input
className="wiz-input"
placeholder="#1B4D3E and #F2E2C4 — matches their truck wraps"
value={data.brand || ""}
onChange={(e) => onChange({ brand: e.target.value })}
/>
</Field>
<Field label="Where should it live?">
<PresetGroup
options={[
{ id: "subdomain", label: "Vibn subdomain", desc: "client-name.vibn.app — fastest." },
{ id: "custom", label: "Their custom domain", desc: "We'll walk you through DNS." },
{ id: "transfer", label: "Transfer ownership", desc: "They own it. You stay billable as editor." },
]}
value={data.handoff}
onChange={(v) => onChange({ handoff: v })}
columns={1}
/>
</Field>
<Field label="Your hourly rate" optional hint="Used on the handoff doc & invoice template.">
<div style={{ position: "relative" }}>
<span
className="mono"
style={{
position: "absolute", left: 14, top: "50%", transform: "translateY(-50%)",
color: "var(--fg-faint)", fontSize: 14.5, pointerEvents: "none",
}}
>$</span>
<input
className="wiz-input"
type="number"
min="0"
placeholder="120"
value={data.rate || ""}
onChange={(e) => onChange({ rate: e.target.value })}
style={{ paddingLeft: 26, paddingRight: 58 }}
/>
<span
className="mono"
style={{
position: "absolute", right: 14, top: "50%", transform: "translateY(-50%)",
color: "var(--fg-faint)", fontSize: 11.5, letterSpacing: "0.04em",
pointerEvents: "none",
}}
>/ hour</span>
</div>
</Field>
</>
);
}
// ── Path wrapper ───────────────────────────────────────────────────────────
function ConsultantPath({ data, onUpdate, onBack, onClose, onComplete, onJumpToStep, step }) {
const next = () => {
if (step < CONS_TOTAL - 1) onJumpToStep(step + 1);
else onComplete();
};
const back = () => {
if (step === 0) onBack();
else onJumpToStep(step - 1);
};
let body, canNext;
if (step === 0) {
body = (
<ConsClient
clientName={data.clientName || ""}
industry={data.industry || ""}
contact={data.contact || ""}
onChange={onUpdate}
/>
);
canNext = (data.clientName || "").trim().length >= 2 && (data.industry || "").trim().length >= 3;
} else if (step === 1) {
body = <ConsBrief brief={data.brief || ""} onChange={(v) => onUpdate({ brief: v })} />;
canNext = (data.brief || "").trim().length >= 30;
} else if (step === 2) {
body = <ConsScope scope={data.scope || []} onChange={(v) => onUpdate({ scope: v })} />;
canNext = (data.scope || []).length >= 2;
} else {
body = <ConsHandoff data={data} onChange={onUpdate} />;
canNext = !!data.handoff;
}
return (
<>
<WizardTop
onBack={back}
onClose={onClose}
lane={LANE_LABELS.consultant}
stepText={CONS_STEP_NAMES[step]}
current={step + 2}
total={5}
/>
<WizardBody width={step === 2 ? "wide" : null}>
{body}
<WizardFooter
onNext={next}
canNext={canNext}
nextLabel={step === CONS_TOTAL - 1 ? "Spin up project →" : "Continue"}
hint={canNext ? "⌘↵" : null}
/>
</WizardBody>
</>
);
}
Object.assign(window, { ConsultantPath, CONS_TOTAL });

View File

@@ -0,0 +1,274 @@
// Entrepreneur path — 4 steps. Each step is a focused question.
const ENTREP_TOTAL = 4;
const ENTREP_STEP_NAMES = ["Idea", "Audience", "Goal", "Look"];
const IDEA_PROMPTS = [
"A community for indie game devs to swap playtesters, with weekly demo nights",
"An AI tool that turns my handwritten recipe notes into a clean cookbook for my family",
"A waitlist + scheduler for my pottery studio — small classes, six people max",
"A subscription box service for cold-brew enthusiasts, with monthly tasting cards",
"A simple tool that turns my Strava data into framed art prints I can sell",
];
function EntrepIdea({ value, onChange }) {
const [phIdx, setPhIdx] = React.useState(0);
const [phChars, setPhChars] = React.useState(0);
const [deleting, setDeleting] = React.useState(false);
React.useEffect(() => {
if (value.length > 0) return undefined;
const full = IDEA_PROMPTS[phIdx];
const speed = deleting ? 18 : 38;
const t = setTimeout(() => {
if (!deleting) {
if (phChars < full.length) setPhChars(phChars + 1);
else setTimeout(() => setDeleting(true), 1500);
} else {
if (phChars > 0) setPhChars(phChars - 1);
else { setDeleting(false); setPhIdx((phIdx + 1) % IDEA_PROMPTS.length); }
}
}, speed);
return () => clearTimeout(t);
}, [value, phIdx, phChars, deleting]);
return (
<>
<WizardQ
title="What are you building?"
sub="One paragraph is enough. Talk like you would to a friend."
/>
<div style={{ position: "relative" }}>
<textarea
className="wiz-input"
style={{ minHeight: 140, fontSize: 15 }}
value={value}
onChange={(e) => onChange(e.target.value)}
autoFocus
aria-label="Describe your idea"
/>
{value.length === 0 && (
<div
style={{
position: "absolute", top: 12, left: 14, right: 14,
pointerEvents: "none",
color: "var(--fg-faint)",
font: "14.5px/1.5 var(--font-sans)",
}}
>
{IDEA_PROMPTS[phIdx].slice(0, phChars)}
<span
style={{
display: "inline-block",
width: 7, height: 14, verticalAlign: "-2px",
background: "var(--accent)", marginLeft: 1,
animation: "blink 1s steps(2) infinite",
boxShadow: "0 0 10px var(--accent-glow)",
}}
/>
</div>
)}
</div>
<div
className="mono"
style={{ fontSize: 11, color: "var(--fg-faint)", letterSpacing: "0.06em", marginTop: -16 }}
>
{value.length} chars · be specific where it matters
</div>
</>
);
}
const AUDIENCE_PRESETS = [
"Me and people like me",
"A small community I'm part of",
"Local people in my city",
"Anyone searching for this",
"Other small businesses",
"Hobbyists in a niche I love",
];
function EntrepAudience({ value, onChange }) {
const isPreset = AUDIENCE_PRESETS.includes(value);
return (
<>
<WizardQ
title="Who is it for?"
sub="The clearer your audience, the better the copy Vibn writes for it."
/>
<ChipGroup
options={AUDIENCE_PRESETS}
values={value ? [value] : []}
onChange={(arr) => onChange(arr[arr.length - 1] || "")}
/>
<Field label="Or describe them in your own words" optional>
<input
className="wiz-input"
placeholder="e.g. dog owners in Brooklyn who walk before work"
value={!isPreset ? value : ""}
onChange={(e) => onChange(e.target.value)}
/>
</Field>
</>
);
}
const GOALS = [
{ id: "first_customer", icon: "🎯", label: "First real customer",
desc: "Someone I don't know pays me. Even once." },
{ id: "ten_users", icon: "👥", label: "Ten weekly users",
desc: "A signal the thing actually does something useful." },
{ id: "mrr_1k", icon: "📈", label: "$1k MRR",
desc: "Enough to take it seriously." },
{ id: "side_quit", icon: "🚪", label: "Replace my day job",
desc: "The long road. Make this the main thing." },
{ id: "audience", icon: "📣", label: "Build a tiny audience",
desc: "200 emails, a community, something I can talk to." },
{ id: "ship_it", icon: "🚀", label: "Just ship it",
desc: "I want the thing to exist." },
];
function EntrepGoal({ value, onChange }) {
return (
<>
<WizardQ
title="What does “working” look like?"
sub="Helps Vibn decide what to build first — a landing page that converts, or a tool that retains."
/>
<PresetGroup
options={GOALS.map((g) => ({
id: g.id, label: g.label, desc: g.desc,
icon: <span style={{ fontSize: 14 }}>{g.icon}</span>,
}))}
value={value}
onChange={onChange}
columns={2}
/>
</>
);
}
const VIBES = [
{ id: "warm", name: "Warm coral", swatch: "linear-gradient(135deg, #E27855, #B33B2A)",
desc: "Confident, hand-built, warm." },
{ id: "ink", name: "Ink & paper", swatch: "linear-gradient(135deg, #1d1d1d, #4a4a4a)",
desc: "Editorial, serif, quiet." },
{ id: "sage", name: "Sage matte", swatch: "linear-gradient(135deg, #7BA890, #3F6B57)",
desc: "Calm, modern, slightly herbal." },
{ id: "neon", name: "Neon arcade", swatch: "linear-gradient(135deg, #5B6CFF, #FF3DDB)",
desc: "Loud, fun, late-night." },
{ id: "cream", name: "Cream linen", swatch: "linear-gradient(135deg, #F2E7D5, #C9A977)",
desc: "Cozy and beige." },
{ id: "later", name: "Decide later", swatch: "repeating-linear-gradient(45deg, oklch(0.30 0.010 60), oklch(0.30 0.010 60) 6px, oklch(0.22 0.010 60) 6px, oklch(0.22 0.010 60) 12px)",
desc: "Vibn picks one that fits." },
];
function EntrepVibe({ value, onChange }) {
return (
<>
<WizardQ
title="Pick a starting vibe."
sub="Every color and font is a tweak away once the site is live."
/>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 10,
}}
>
{VIBES.map((v) => {
const active = value === v.id;
return (
<button
key={v.id}
type="button"
onClick={() => onChange(v.id)}
style={{
padding: "10px 10px 10px",
borderRadius: 11,
border: `1px solid ${active ? "var(--accent)" : "var(--hairline)"}`,
background: active ? "oklch(0.20 0.04 35 / 0.4)" : "oklch(0.18 0.009 60 / 0.6)",
boxShadow: active ? "0 0 0 3px oklch(0.74 0.175 35 / 0.1)" : "none",
textAlign: "left",
color: "var(--fg)",
display: "flex", flexDirection: "column", gap: 8,
transition: "border-color .15s, background .15s",
}}
>
<span
style={{
height: 52, borderRadius: 7,
background: v.swatch,
border: "1px solid oklch(1 0 0 / 0.08)",
boxShadow: "inset 0 1px 0 oklch(1 0 0 / 0.18)",
}}
/>
<span style={{ fontSize: 13, fontWeight: 500, letterSpacing: "-0.005em" }}>
{v.name}
</span>
<span style={{ fontSize: 11.5, color: "var(--fg-mute)", lineHeight: 1.4 }}>
{v.desc}
</span>
</button>
);
})}
</div>
</>
);
}
// ── Path wrapper ───────────────────────────────────────────────────────────
function EntrepreneurPath({ data, onUpdate, onBack, onClose, onComplete, onJumpToStep, step }) {
const next = () => {
if (step < ENTREP_TOTAL - 1) onJumpToStep(step + 1);
else onComplete();
};
const back = () => {
if (step === 0) onBack();
else onJumpToStep(step - 1);
};
let body, canNext, onSkip = null;
if (step === 0) {
body = <EntrepIdea value={data.idea || ""} onChange={(v) => onUpdate({ idea: v })} />;
canNext = (data.idea || "").trim().length >= 8;
} else if (step === 1) {
body = <EntrepAudience value={data.audience || ""} onChange={(v) => onUpdate({ audience: v })} />;
canNext = (data.audience || "").trim().length >= 3;
} else if (step === 2) {
body = <EntrepGoal value={data.goal} onChange={(v) => onUpdate({ goal: v })} />;
canNext = !!data.goal;
} else {
body = <EntrepVibe value={data.vibe} onChange={(v) => onUpdate({ vibe: v })} />;
canNext = !!data.vibe;
onSkip = () => { onUpdate({ vibe: "later" }); next(); };
}
// 5 total: fork(1) + 4 path steps
return (
<>
<WizardTop
onBack={back}
onClose={onClose}
lane={LANE_LABELS.entrepreneur}
stepText={ENTREP_STEP_NAMES[step]}
current={step + 2}
total={5}
/>
<WizardBody width={step === 2 || step === 3 ? "wide" : null}>
{body}
<WizardFooter
onNext={next}
canNext={canNext}
nextLabel={step === ENTREP_TOTAL - 1 ? "Build →" : "Continue"}
hint={canNext ? "⌘↵" : null}
onSkip={onSkip}
skipLabel="Pick for me"
/>
</WizardBody>
</>
);
}
Object.assign(window, { EntrepreneurPath, ENTREP_TOTAL });

View File

@@ -0,0 +1,134 @@
// Step 1: the only branching question — "which describes you?"
// Quiet radio-style cards. No quotes, no marketing, no glow theatrics.
const FORKS = [
{
id: "entrepreneur",
label: "I'm building my own thing",
hint: "Idea → live → first customer. You're the founder.",
icon: (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor"
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="9" cy="9" r="3"/>
<path d="M9 2.5v2M9 13.5v2M2.5 9h2M13.5 9h2"/>
</svg>
),
},
{
id: "owner",
label: "I run a business",
hint: "Replace the stack of tools you currently rent.",
icon: (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor"
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M3 6h12l-1 9H4L3 6Z"/>
<path d="M6 6V4.5a3 3 0 0 1 6 0V6"/>
</svg>
),
},
{
id: "consultant",
label: "I build for clients",
hint: "A workspace per client. Bill for the system, not the hours.",
icon: (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor"
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M2.5 15 9 3l6.5 12"/>
<path d="M5.5 12h7"/>
</svg>
),
},
];
function ForkScreen({ name, value, onChange, onClose, onNext }) {
return (
<>
<WizardTop
onBack={null}
onClose={onClose}
stepText="Pick your lane"
current={1}
total={5}
/>
<WizardBody>
<WizardQ
title={name ? `Welcome, ${name}. Which sounds like you?` : "Which one sounds like you?"}
sub="Vibn asks different questions on the next screens depending on the answer. You can change this later."
/>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{FORKS.map((f) => {
const active = value === f.id;
return (
<button
key={f.id}
type="button"
onClick={() => onChange(f.id)}
onDoubleClick={() => { onChange(f.id); onNext(); }}
style={{
display: "flex", alignItems: "center", gap: 14,
padding: "14px 16px",
borderRadius: 12,
border: `1px solid ${active ? "var(--accent)" : "var(--hairline)"}`,
background: active ? "oklch(0.20 0.04 35 / 0.4)" : "oklch(0.18 0.009 60 / 0.6)",
boxShadow: active ? "0 0 0 3px oklch(0.74 0.175 35 / 0.1)" : "none",
textAlign: "left",
color: "var(--fg)",
transition: "border-color .15s, background .15s",
cursor: "pointer",
}}
>
<span style={{
width: 36, height: 36, flexShrink: 0,
borderRadius: 9,
background: active ? "oklch(0.74 0.175 35 / 0.18)" : "oklch(0.22 0.011 60)",
border: "1px solid var(--hairline)",
color: active ? "var(--accent)" : "var(--fg-mute)",
display: "grid", placeItems: "center",
}}>
{f.icon}
</span>
<span style={{ display: "flex", flexDirection: "column", gap: 2, flex: 1 }}>
<span style={{ fontSize: 15, fontWeight: 500, letterSpacing: "-0.008em" }}>
{f.label}
</span>
<span style={{ fontSize: 13, color: "var(--fg-mute)", lineHeight: 1.4 }}>
{f.hint}
</span>
</span>
<span
style={{
width: 18, height: 18, flexShrink: 0,
borderRadius: "50%",
border: `1.5px solid ${active ? "var(--accent)" : "var(--hairline-2)"}`,
background: active ? "var(--accent)" : "transparent",
display: "grid", placeItems: "center",
color: "var(--accent-fg)",
transition: "border-color .15s, background .15s",
}}
>
{active && (
<svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor"
strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m3 8.5 3.2 3.2L13 5"/>
</svg>
)}
</span>
</button>
);
})}
</div>
<WizardFooter
canNext={!!value}
onNext={onNext}
nextLabel="Continue"
hint={value ? "Press ⌘↵" : null}
/>
</WizardBody>
</>
);
}
Object.assign(window, { ForkScreen });

View File

@@ -0,0 +1,262 @@
// Owner path — 4 steps for small-business owners replacing their stack.
const OWNER_TOTAL = 4;
const OWNER_STEP_NAMES = ["Business", "Stack", "First fix", "Scale"];
const BIZ_KINDS = [
{ id: "service", icon: "🛠", label: "Trades / home services", desc: "Plumbing, HVAC, landscaping, cleaning" },
{ id: "retail", icon: "🛍", label: "Retail / shop", desc: "Vintage, boutique, market, online" },
{ id: "food", icon: "🥐", label: "Food & drink", desc: "Café, bakery, food truck, catering" },
{ id: "appointments", icon: "💈", label: "Appointment-based", desc: "Salon, studio, clinic, tutoring" },
{ id: "events", icon: "🎟", label: "Events / hospitality", desc: "Venue, rental, planning" },
{ id: "other", icon: "✦", label: "Something else", desc: "We'll learn from your answers" },
];
function OwnerBiz({ value, name, onChange, onNameChange }) {
return (
<>
<WizardQ
title="What does your business do?"
sub="Roughly. We tailor the next screens to match."
/>
<PresetGroup
options={BIZ_KINDS.map((b) => ({
id: b.id, label: b.label, desc: b.desc,
icon: <span style={{ fontSize: 14 }}>{b.icon}</span>,
}))}
value={value}
onChange={onChange}
columns={2}
/>
<Field label="Business name">
<input
className="wiz-input"
placeholder="Sunrise Plumbing, Pearl Lane Bakery…"
value={name}
onChange={(e) => onNameChange(e.target.value)}
/>
</Field>
</>
);
}
const STACK_TOOLS = [
"Square / POS",
"Stripe",
"Calendly",
"Acuity",
"Shopify",
"QuickBooks",
"Mailchimp",
"Instagram",
"Google Sheets",
"Notion / Airtable",
"Wix / Squarespace",
"WhatsApp / Slack",
"A printed binder",
"Head + notepad",
];
function OwnerStack({ tools, spend, onToolsChange, onSpendChange }) {
return (
<>
<WizardQ
title="What are you renting right now?"
sub="Tap everything you pay for. Approximate is fine."
/>
<Field label="Tools & subscriptions">
<ChipGroup
options={STACK_TOOLS}
values={tools || []}
onChange={onToolsChange}
allowOther
/>
</Field>
<Field label="About how much per month?" hint="Across all your software, ballpark.">
<Slider
min={0} max={1500} step={25}
value={spend ?? 250}
onChange={onSpendChange}
format={(v) => v === 0 ? "$0" : v === 1500 ? "$1.5k+" : `$${v}`}
/>
</Field>
{(tools || []).length > 0 && (
<div
style={{
padding: "12px 14px",
borderRadius: 10,
border: "1px solid var(--hairline)",
background: "oklch(0.18 0.009 60 / 0.6)",
fontSize: 13.5,
lineHeight: 1.5,
color: "var(--fg-dim)",
display: "flex", gap: 12, alignItems: "flex-start",
}}
>
<span style={{
width: 6, height: 6, borderRadius: "50%",
background: "var(--accent)", boxShadow: "0 0 10px var(--accent-glow)",
marginTop: 7, flexShrink: 0,
}} />
<span>
<b style={{ color: "var(--fg)", fontWeight: 500 }}>{tools.length} tool{tools.length === 1 ? "" : "s"}</b>
{spend ? <> · ~<b style={{ color: "var(--fg)", fontWeight: 500 }}>${spend}/mo</b></> : null}.
Replaced by one workspace, owned by you.
</span>
</div>
)}
</>
);
}
const OWNER_FIRST_THINGS = [
{ id: "booking", icon: "📅", label: "Bookings & scheduling", desc: "Customers book themselves." },
{ id: "invoicing", icon: "🧾", label: "Quotes, invoices, payments", desc: "Send a quote, get paid, no chasing." },
{ id: "customers", icon: "👥", label: "Customer history & portal", desc: "One place per customer." },
{ id: "inventory", icon: "📦", label: "Inventory & orders", desc: "Track stock, sales, suppliers." },
{ id: "team", icon: "🪪", label: "Team & job dispatch", desc: "Assign jobs, log hours." },
{ id: "marketing", icon: "📣", label: "Website + email + reviews", desc: "A site that converts, list that follows up." },
];
function OwnerFirstThing({ value, onChange }) {
return (
<>
<WizardQ
title="What's burning first?"
sub="The one workflow you wish was already replaced. Vibn builds it on day one."
/>
<PresetGroup
options={OWNER_FIRST_THINGS.map((f) => ({
id: f.id, label: f.label, desc: f.desc,
icon: <span style={{ fontSize: 14 }}>{f.icon}</span>,
}))}
value={value}
onChange={onChange}
columns={2}
/>
</>
);
}
const OWNER_HOW_LONG = [
{ id: "starting", label: "Just starting" },
{ id: "1_3", label: "13 years" },
{ id: "3_10", label: "310 years" },
{ id: "10_plus", label: "10+ years" },
];
function OwnerScale({ customers, team, howLong, onCustomers, onTeam, onHowLong }) {
return (
<>
<WizardQ
title="A little about scale."
sub="Sensible defaults — table view vs. cards, daily vs. monthly reports."
/>
<Field label="Customers per month">
<Slider
min={0} max={2000} step={10}
value={customers ?? 50}
onChange={onCustomers}
format={(v) => v === 0 ? "0" : v >= 2000 ? "2k+" : v.toLocaleString()}
/>
</Field>
<Field label="Team size (incl. you)">
<Slider
min={1} max={50} step={1}
value={team ?? 1}
onChange={onTeam}
format={(v) => v >= 50 ? "50+" : `${v}`}
/>
</Field>
<Field label="How long have you been at this?">
<div className="chips">
{OWNER_HOW_LONG.map((h) => (
<button
key={h.id}
type="button"
className={"chip" + (howLong === h.id ? " active" : "")}
onClick={() => onHowLong(h.id)}
>
{h.label}
</button>
))}
</div>
</Field>
</>
);
}
// ── Path wrapper ───────────────────────────────────────────────────────────
function OwnerPath({ data, onUpdate, onBack, onClose, onComplete, onJumpToStep, step }) {
const next = () => {
if (step < OWNER_TOTAL - 1) onJumpToStep(step + 1);
else onComplete();
};
const back = () => {
if (step === 0) onBack();
else onJumpToStep(step - 1);
};
let body, canNext;
if (step === 0) {
body = (
<OwnerBiz
value={data.biz}
name={data.bizName || ""}
onChange={(v) => onUpdate({ biz: v })}
onNameChange={(v) => onUpdate({ bizName: v })}
/>
);
canNext = !!data.biz && (data.bizName || "").trim().length >= 2;
} else if (step === 1) {
body = (
<OwnerStack
tools={data.tools || []}
spend={data.spend}
onToolsChange={(v) => onUpdate({ tools: v })}
onSpendChange={(v) => onUpdate({ spend: v })}
/>
);
canNext = (data.tools || []).length >= 1;
} else if (step === 2) {
body = <OwnerFirstThing value={data.firstThing} onChange={(v) => onUpdate({ firstThing: v })} />;
canNext = !!data.firstThing;
} else {
body = (
<OwnerScale
customers={data.customers}
team={data.team}
howLong={data.howLong}
onCustomers={(v) => onUpdate({ customers: v })}
onTeam={(v) => onUpdate({ team: v })}
onHowLong={(v) => onUpdate({ howLong: v })}
/>
);
canNext = !!data.howLong;
}
return (
<>
<WizardTop
onBack={back}
onClose={onClose}
lane={LANE_LABELS.owner}
stepText={OWNER_STEP_NAMES[step]}
current={step + 2}
total={5}
/>
<WizardBody width={step === 0 || step === 2 ? "wide" : null}>
{body}
<WizardFooter
onNext={next}
canNext={canNext}
nextLabel={step === OWNER_TOTAL - 1 ? "Build my workspace →" : "Continue"}
hint={canNext ? "⌘↵" : null}
/>
</WizardBody>
</>
);
}
Object.assign(window, { OwnerPath, OWNER_TOTAL });

View File

@@ -0,0 +1,333 @@
// Shared building blocks for the onboarding flow.
// All <style> belongs in onboarding.css; this file is JSX only.
// ── Wizard top bar ─────────────────────────────────────────────────────────
// Sticky, thin. Holds: back arrow · vibn mark · centered step label · close.
// A 2px progress bar runs along its bottom edge.
function WizardTop({
onBack, onClose,
lane, // "Solo / quiet entrepreneur" etc.
stepText, // "Idea" or "Pick your lane"
current, total, // 1-indexed
progress, // 0..1 (optional override)
}) {
const pct = typeof progress === "number"
? Math.max(0, Math.min(1, progress))
: (typeof current === "number" && typeof total === "number"
? Math.max(0, Math.min(1, current / total))
: 0);
return (
<header className="wiz-top">
<div className="wiz-top-row">
<button
type="button"
className="wiz-iconbtn"
onClick={onBack}
disabled={!onBack}
aria-label="Back"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor"
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M13 8H3M7 4 3 8l4 4"/>
</svg>
</button>
<a href="index.html" className="wiz-logo" aria-label="vibn — home">
<LogoMark size={22} />
<span>vibn</span>
</a>
<div className="wiz-step">
{lane && <span className="lane">{lane}</span>}
{lane && stepText && <span className="dot" />}
{stepText && (
<span>
{typeof current === "number" && typeof total === "number" && (
<>
<b>{current}</b> <span style={{ opacity: 0.6 }}>/ {total}</span>{" · "}
</>
)}
{stepText}
</span>
)}
</div>
<button
type="button"
className="wiz-iconbtn"
onClick={onClose}
aria-label="Save & exit"
title="Save & exit"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor"
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m4 4 8 8M12 4l-8 8"/>
</svg>
</button>
</div>
<div className="wiz-progress">
<div className="wiz-progress-fill" style={{ width: `${pct * 100}%` }} />
</div>
</header>
);
}
// ── Wizard body wrapper ────────────────────────────────────────────────────
function WizardBody({ children, width }) {
const cls = "wiz-card" + (width === "wide" ? " wide" : width === "xwide" ? " xwide" : "");
return (
<main className="wiz-body">
<div className={cls}>{children}</div>
</main>
);
}
// ── Question heading ───────────────────────────────────────────────────────
function WizardQ({ title, sub }) {
return (
<div className="wiz-q">
<h2>{title}</h2>
{sub && <p>{sub}</p>}
</div>
);
}
// ── Footer (back / hint / continue) ────────────────────────────────────────
function WizardFooter({
onBack, onNext, canNext = true,
nextLabel = "Continue",
hint,
onSkip, skipLabel = "Skip",
}) {
return (
<div className="wiz-foot">
<div className="wiz-foot-left">
{onSkip && (
<button type="button" className="wiz-skip" onClick={onSkip}>
{skipLabel}
</button>
)}
</div>
<div className="wiz-foot-right">
{hint && <span className="wiz-hint">{hint}</span>}
<button
type="button"
className="btn btn-primary btn-wiz"
disabled={!canNext}
onClick={() => canNext && onNext && onNext()}
>
{nextLabel} <Arrow size={13} />
</button>
</div>
</div>
);
}
// ── Field wrappers (wizard variants) ───────────────────────────────────────
function Field({ label, hint, children, optional }) {
return (
<label className="wiz-field">
{label && (
<span className="wiz-field-label">
{label}
{optional && (
<span style={{ color: "var(--fg-faint)", fontWeight: 400, marginLeft: 8, fontSize: 12 }}>
optional
</span>
)}
</span>
)}
{children}
{hint && <span className="wiz-field-hint">{hint}</span>}
</label>
);
}
// ── Chip group (multi-select) ──────────────────────────────────────────────
function ChipGroup({ options, values, onChange, allowOther = false }) {
const [other, setOther] = React.useState("");
const customs = (values || []).filter((v) => !options.includes(v));
const toggle = (v) => {
if (!onChange) return;
if (values.includes(v)) onChange(values.filter((x) => x !== v));
else onChange([...values, v]);
};
return (
<div>
<div className="chips">
{options.map((opt) => (
<button
type="button" key={opt}
className={"chip" + (values.includes(opt) ? " active" : "")}
onClick={() => toggle(opt)}
>
{opt}
</button>
))}
{customs.map((c) => (
<button
type="button" key={c}
className="chip active"
onClick={() => toggle(c)}
title="Click to remove"
>
{c} <span style={{ marginLeft: 4, opacity: 0.6 }}>×</span>
</button>
))}
</div>
{allowOther && (
<form
onSubmit={(e) => {
e.preventDefault();
const v = other.trim();
if (v && !values.includes(v)) onChange([...values, v]);
setOther("");
}}
style={{ marginTop: 10, display: "flex", gap: 8 }}
>
<input
type="text"
className="wiz-input"
placeholder="Add your own…"
value={other}
onChange={(e) => setOther(e.target.value)}
style={{ flex: 1 }}
/>
<button
type="submit"
className="btn btn-ghost"
style={{ height: 42, padding: "0 14px", fontSize: 13, borderRadius: 10 }}
disabled={!other.trim()}
>
Add
</button>
</form>
)}
</div>
);
}
// ── Preset group (single-select cards) ─────────────────────────────────────
function PresetGroup({ options, value, onChange, columns = 1 }) {
return (
<div
style={{
display: "grid",
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
gap: 8,
width: "100%",
}}
>
{options.map((opt) => {
const active = value === opt.id;
return (
<button
key={opt.id}
type="button"
onClick={() => onChange(opt.id)}
style={{
textAlign: "left",
padding: "12px 14px",
borderRadius: 10,
border: `1px solid ${active ? "var(--accent)" : "var(--hairline)"}`,
background: active ? "oklch(0.20 0.04 35 / 0.4)" : "oklch(0.18 0.009 60 / 0.6)",
boxShadow: active ? "0 0 0 3px oklch(0.74 0.175 35 / 0.1)" : "none",
transition: "border-color .15s, background .15s",
color: "var(--fg)",
display: "flex", alignItems: "flex-start", gap: 12,
}}
>
{opt.icon && (
<span style={{
width: 28, height: 28, flexShrink: 0,
borderRadius: 8,
background: active ? "oklch(0.74 0.175 35 / 0.18)" : "oklch(0.22 0.011 60)",
border: "1px solid var(--hairline)",
color: active ? "var(--accent)" : "var(--fg-mute)",
display: "grid", placeItems: "center",
fontSize: 14,
marginTop: 1,
}}>
{opt.icon}
</span>
)}
<span style={{ display: "flex", flexDirection: "column", gap: 2, flex: 1, minWidth: 0 }}>
<span style={{ fontSize: 14, fontWeight: 500, letterSpacing: "-0.005em" }}>
{opt.label}
</span>
{opt.desc && (
<span style={{ fontSize: 12.5, color: "var(--fg-mute)", lineHeight: 1.45 }}>
{opt.desc}
</span>
)}
</span>
{active && (
<span style={{
width: 16, height: 16, borderRadius: "50%",
background: "var(--accent)",
display: "grid", placeItems: "center",
color: "var(--accent-fg)",
flexShrink: 0,
marginTop: 6,
}}>
<svg width="9" height="9" viewBox="0 0 16 16" fill="none" stroke="currentColor"
strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m3 8.5 3.2 3.2L13 5"/>
</svg>
</span>
)}
</button>
);
})}
</div>
);
}
// ── Slider ─────────────────────────────────────────────────────────────────
function Slider({ min, max, step = 1, value, onChange, format }) {
return (
<div style={{ width: "100%" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
<span className="mono" style={{ fontSize: 11, color: "var(--fg-faint)", letterSpacing: "0.04em" }}>
{format ? format(min) : min}
</span>
<span
className="mono"
style={{
fontSize: 18,
color: "var(--fg)",
letterSpacing: "-0.01em",
fontWeight: 500,
}}
>
{format ? format(value) : value}
</span>
<span className="mono" style={{ fontSize: 11, color: "var(--fg-faint)", letterSpacing: "0.04em" }}>
{format ? format(max) : max}
</span>
</div>
<input
type="range"
min={min} max={max} step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
style={{ width: "100%", marginTop: 6, accentColor: "var(--accent)" }}
/>
</div>
);
}
// Lane labels — used by WizardTop and elsewhere.
const LANE_LABELS = {
entrepreneur: "Solo entrepreneur",
owner: "Small business owner",
consultant: "Building for clients",
};
Object.assign(window, {
WizardTop, WizardBody, WizardQ, WizardFooter,
Field, ChipGroup, PresetGroup, Slider,
LANE_LABELS,
});

677
new-site/onboarding.css Normal file
View File

@@ -0,0 +1,677 @@
/* Onboarding shared styles — same tokens as the rest of the site. */
:root {
--bg: oklch(0.155 0.008 60);
--bg-1: oklch(0.185 0.009 60);
--bg-2: oklch(0.225 0.010 60);
--hairline: oklch(0.32 0.010 60 / 0.55);
--hairline-2: oklch(0.40 0.012 60 / 0.35);
--fg: oklch(0.97 0.005 80);
--fg-dim: oklch(0.78 0.006 80);
--fg-mute: oklch(0.58 0.006 80);
--fg-faint: oklch(0.42 0.006 80);
--accent: oklch(0.74 0.175 35);
--accent-soft: oklch(0.74 0.175 35 / 0.18);
--accent-glow: oklch(0.74 0.175 35 / 0.35);
--accent-fg: #1a0f0a;
--ok: oklch(0.78 0.16 155);
--font-sans: "Geist", ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; min-height: 100%; }
body {
background: var(--bg);
color: var(--fg);
font-family: var(--font-sans);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overflow-x: hidden;
}
body::before {
content: "";
position: fixed; inset: 0;
background-image:
linear-gradient(to right, oklch(0.30 0.01 60 / 0.10) 1px, transparent 1px),
linear-gradient(to bottom, oklch(0.30 0.01 60 / 0.10) 1px, transparent 1px);
background-size: 56px 56px;
mask-image: radial-gradient(ellipse 80% 80% at 50% 40%, #000 30%, transparent 80%);
-webkit-mask-image: radial-gradient(ellipse 80% 80% at 50% 40%, #000 30%, transparent 80%);
pointer-events: none;
z-index: 0;
}
body::after {
content: "";
position: fixed; inset: 0;
pointer-events: none;
z-index: 1;
opacity: 0.035;
mix-blend-mode: overlay;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%25' height='100%25' filter='url(%23n)' opacity='0.85'/></svg>");
}
a { color: inherit; text-decoration: none; }
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
h1, h2, h3 { margin: 0; font-weight: 500; letter-spacing: -0.02em; line-height: 1.05; }
p { margin: 0; }
::selection { background: var(--accent); color: var(--accent-fg); }
.mono { font-family: var(--font-mono); }
/* App shell */
.app {
position: relative;
z-index: 2;
min-height: 100dvh;
display: flex; flex-direction: column;
}
.app-bar {
position: relative; z-index: 5;
padding: 20px clamp(20px, 4vw, 48px);
display: flex; align-items: center; justify-content: space-between;
border-bottom: 1px solid transparent;
}
.app-bar-left { display: flex; align-items: center; gap: 24px; }
.app-step {
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-faint);
letter-spacing: 0.12em;
text-transform: uppercase;
display: inline-flex; align-items: center; gap: 8px;
}
.app-step::before {
content: "";
width: 5px; height: 5px; border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 12px var(--accent-glow);
}
.app-bar-right {
display: flex; gap: 18px; align-items: center;
}
.app-bar-right a, .app-bar-right button {
font-size: 13px; color: var(--fg-mute);
}
.app-bar-right a:hover, .app-bar-right button:hover { color: var(--fg); }
/* Logo */
.logo {
display: inline-flex; align-items: center; gap: 9px;
font-weight: 600; font-size: 17px; letter-spacing: -0.02em;
color: var(--fg);
}
.logo-mark {
width: 26px; height: 26px; border-radius: 50%;
background: linear-gradient(135deg, var(--accent) 0%, oklch(0.65 0.20 18) 100%);
box-shadow: 0 0 22px var(--accent-glow), inset 0 1px 0 oklch(1 0 0 / 0.25);
display: grid; place-items: center;
color: var(--accent-fg);
flex-shrink: 0;
}
.logo-mark svg { display: block; }
.logo-caret { animation: caret-blink 1.4s steps(2) infinite; }
@keyframes caret-blink { 50% { opacity: 0.25; } }
/* Main */
.screen {
flex: 1;
position: relative;
padding: clamp(40px, 7vh, 80px) clamp(20px, 4vw, 48px) clamp(40px, 6vh, 60px);
display: flex; flex-direction: column;
align-items: center;
text-align: center;
}
.screen-wide {
align-items: stretch;
text-align: left;
}
.screen-content {
position: relative; z-index: 2;
width: 100%;
max-width: 720px;
display: flex; flex-direction: column;
align-items: center; text-align: center;
}
.screen-content-wide {
max-width: 1100px;
align-items: stretch; text-align: left;
}
/* Ambient glows */
.glow {
position: absolute;
pointer-events: none;
filter: blur(20px);
z-index: 0;
}
/* Typography */
.eyebrow {
display: inline-flex; align-items: center; gap: 10px;
font-family: var(--font-mono);
font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase;
color: var(--fg-mute);
}
.eyebrow::before {
content: ""; width: 5px; height: 5px; border-radius: 50%;
background: var(--accent); box-shadow: 0 0 12px var(--accent-glow);
}
.eyebrow-accent { color: var(--accent); }
.h1 {
margin-top: 20px;
font-size: clamp(36px, 5.4vw, 64px);
font-weight: 500; letter-spacing: -0.03em; line-height: 1.04;
text-wrap: balance;
}
.h1 em {
font-style: normal;
color: var(--accent);
text-shadow: 0 0 30px var(--accent-glow);
}
.sub {
margin-top: 18px;
font-size: clamp(15px, 1.55vw, 18px);
color: var(--fg-mute);
line-height: 1.55;
text-wrap: balance;
max-width: 540px;
}
.sub b { color: var(--fg); font-weight: 500; }
.tagline {
display: inline-flex; align-items: center; gap: 14px;
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.06em;
color: var(--fg-faint);
margin-bottom: 8px;
}
.tagline::before, .tagline::after {
content: ""; width: 28px; height: 1px;
background: linear-gradient(90deg, transparent, var(--hairline), transparent);
}
/* Buttons */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 10px;
height: 50px; padding: 0 22px;
border-radius: 999px;
font-weight: 500;
font-size: 15px;
transition: transform .12s, box-shadow .2s, background .2s, border-color .15s;
white-space: nowrap;
}
.btn-primary {
background: var(--accent);
color: var(--accent-fg);
box-shadow:
0 0 0 1px oklch(0.84 0.16 35 / 0.5) inset,
0 10px 40px -10px var(--accent-glow),
0 0 40px -8px var(--accent-glow);
}
.btn-primary:hover { transform: translateY(-1px); }
.btn-primary[disabled] { opacity: .55; cursor: not-allowed; transform: none; }
.btn-primary .arrow { transition: transform .15s; }
.btn-primary:hover .arrow { transform: translateX(3px); }
.btn-ghost {
background: oklch(0.20 0.009 60 / 0.6);
border: 1px solid var(--hairline);
color: var(--fg-dim);
}
.btn-ghost:hover { color: var(--fg); border-color: var(--hairline-2); background: oklch(0.22 0.010 60 / 0.8); }
.link-quiet {
font-size: 13px;
color: var(--fg-mute);
display: inline-flex; align-items: center; gap: 6px;
border-bottom: 1px dashed var(--hairline);
padding-bottom: 2px;
}
.link-quiet:hover { color: var(--fg); border-color: var(--accent); }
/* Or divider */
.or-divider {
display: flex; align-items: center; gap: 14px;
margin: 28px 0 18px;
width: 100%; max-width: 360px;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--fg-faint);
}
.or-divider::before, .or-divider::after {
content: ""; flex: 1; height: 1px; background: var(--hairline);
}
/* Form */
.field {
width: 100%;
display: flex; flex-direction: column; gap: 8px;
margin-top: 24px;
text-align: left;
}
.field-label {
font-size: 15px;
font-weight: 500;
color: var(--fg);
letter-spacing: -0.005em;
}
.field-hint {
font-size: 13px;
color: var(--fg-mute);
line-height: 1.5;
}
.input {
width: 100%;
padding: 14px 16px;
background: oklch(0.16 0.008 60 / 0.8);
border: 1px solid var(--hairline);
border-radius: 12px;
color: var(--fg);
font: 15px/1.5 var(--font-sans);
outline: none;
transition: border-color .15s, background .15s, box-shadow .15s;
resize: vertical;
}
.input::placeholder { color: var(--fg-faint); }
.input:focus {
border-color: oklch(0.74 0.175 35 / 0.65);
background: oklch(0.18 0.009 60 / 0.95);
box-shadow: 0 0 0 3px oklch(0.74 0.175 35 / 0.12), 0 0 30px -10px var(--accent-glow);
}
.input-textarea { min-height: 110px; resize: vertical; }
.input-large { padding: 20px 22px; font-size: 17px; border-radius: 16px; }
/* Hero prompt input */
.prompt {
width: 100%;
position: relative;
margin-top: 24px;
}
.prompt-frame {
position: relative;
border-radius: 22px;
padding: 1px;
background: linear-gradient(180deg,
oklch(0.50 0.06 35 / 0.6),
oklch(0.30 0.012 60 / 0.4) 40%,
oklch(0.25 0.012 60 / 0.4));
box-shadow:
0 30px 80px -20px oklch(0 0 0 / 0.6),
0 0 80px -20px var(--accent-glow);
}
.prompt-inner {
background: linear-gradient(180deg, oklch(0.19 0.009 60 / 0.92), oklch(0.17 0.008 60 / 0.92));
border-radius: 21px;
padding: 18px 20px 14px;
backdrop-filter: blur(20px);
display: flex; flex-direction: column;
gap: 12px;
}
.prompt-inner textarea {
width: 100%;
min-height: 92px;
background: transparent;
border: 0;
color: var(--fg);
font: 17px/1.5 var(--font-sans);
resize: none;
outline: none;
padding: 4px;
}
.prompt-typed {
position: absolute;
top: 22px; left: 24px; right: 24px;
pointer-events: none;
color: var(--fg-faint);
font: 17px/1.5 var(--font-sans);
text-align: left;
}
.prompt-typed::after {
content: "";
display: inline-block;
width: 8px; height: 18px;
background: var(--accent);
vertical-align: -3px;
margin-left: 2px;
animation: blink 1s steps(2) infinite;
box-shadow: 0 0 12px var(--accent-glow);
}
@keyframes blink { 50% { opacity: 0; } }
.prompt-bar {
display: flex; align-items: center; justify-content: space-between;
padding-top: 8px;
border-top: 1px solid var(--hairline);
}
.prompt-hint {
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-faint);
letter-spacing: 0.02em;
}
/* Chip / option grid */
.chips {
display: flex; flex-wrap: wrap; gap: 8px;
}
.chip {
padding: 9px 14px;
border-radius: 999px;
border: 1px solid var(--hairline);
background: oklch(0.20 0.009 60 / 0.5);
color: var(--fg-dim);
font-size: 13.5px;
transition: border-color .15s, color .15s, background .15s, transform .12s;
}
.chip:hover { border-color: var(--hairline-2); color: var(--fg); transform: translateY(-1px); }
.chip.active {
border-color: var(--accent);
background: oklch(0.20 0.04 35 / 0.4);
color: var(--fg);
}
/* Preset chips */
.preset-row {
display: flex; gap: 8px; flex-wrap: wrap;
margin-top: 4px;
}
.preset-chip {
padding: 11px 18px;
border-radius: 12px;
border: 1px solid var(--hairline);
background: oklch(0.18 0.009 60 / 0.6);
color: var(--fg-dim);
font: 500 14.5px var(--font-mono);
letter-spacing: -0.005em;
transition: all .15s;
}
.preset-chip:hover { border-color: var(--hairline-2); color: var(--fg); }
.preset-chip.active {
border-color: var(--accent);
background: oklch(0.20 0.04 35 / 0.4);
color: var(--fg);
box-shadow: 0 0 0 3px oklch(0.74 0.175 35 / 0.1);
}
/* Trust strip */
.trust {
margin-top: 36px;
display: flex; gap: 14px; justify-content: center; align-items: center;
flex-wrap: wrap;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.03em;
color: var(--fg-faint);
}
.trust .sep { opacity: 0.5; }
/* CTA row */
.cta-row {
margin-top: 36px;
display: flex; gap: 14px; align-items: center; flex-wrap: wrap;
justify-content: center;
}
/* Spinner */
.spinner {
width: 16px; height: 16px; border-radius: 50%;
border: 2px solid oklch(0 0 0 / 0.2);
border-top-color: var(--accent-fg);
animation: spin .9s linear infinite;
display: inline-block;
}
.spinner-line {
width: 12px; height: 12px;
border-color: var(--hairline);
border-top-color: var(--accent);
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Surface card */
.surface {
background: linear-gradient(180deg, oklch(0.20 0.009 60 / 0.55), oklch(0.17 0.008 60 / 0.55));
border: 1px solid var(--hairline);
border-radius: 18px;
}
/* ── Wizard chrome ───────────────────────────────────────────────────── */
/* The persistent top strip with progress bar + back + step text + close. */
.wiz-top {
position: sticky; top: 0; z-index: 50;
background: oklch(0.155 0.008 60 / 0.85);
backdrop-filter: blur(14px) saturate(140%);
-webkit-backdrop-filter: blur(14px) saturate(140%);
border-bottom: 1px solid var(--hairline);
}
.wiz-top-row {
height: 54px;
padding: 0 clamp(16px, 3vw, 28px);
display: flex; align-items: center; gap: 14px;
}
.wiz-iconbtn {
width: 32px; height: 32px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 8px;
color: var(--fg-mute);
border: 1px solid transparent;
transition: color .15s, border-color .15s, background .15s;
flex-shrink: 0;
}
.wiz-iconbtn:hover {
color: var(--fg);
background: oklch(0.20 0.009 60 / 0.6);
border-color: var(--hairline);
}
.wiz-iconbtn[disabled] { opacity: 0; pointer-events: none; }
.wiz-logo {
display: inline-flex; align-items: center; gap: 8px;
font-weight: 500; font-size: 14px; letter-spacing: -0.01em;
color: var(--fg);
flex-shrink: 0;
}
.wiz-logo .logo-mark { width: 22px; height: 22px; }
.wiz-step {
flex: 1;
display: flex; align-items: center; gap: 10px;
min-width: 0;
justify-content: center;
font-family: var(--font-mono);
font-size: 11.5px;
color: var(--fg-mute);
letter-spacing: 0.04em;
overflow: hidden;
}
.wiz-step b { color: var(--fg); font-weight: 500; }
.wiz-step .dot {
width: 4px; height: 4px; border-radius: 50%;
background: var(--fg-faint);
flex-shrink: 0;
}
.wiz-step .lane {
color: var(--accent);
letter-spacing: 0.08em;
text-transform: uppercase;
font-size: 10.5px;
display: inline-flex; align-items: center; gap: 6px;
}
.wiz-step .lane::before {
content: ""; width: 5px; height: 5px; border-radius: 50%;
background: var(--accent); box-shadow: 0 0 10px var(--accent-glow);
}
.wiz-progress {
position: relative;
height: 2px;
background: oklch(0.30 0.010 60 / 0.35);
}
.wiz-progress-fill {
position: absolute; left: 0; top: 0; bottom: 0;
background: var(--accent);
box-shadow: 0 0 14px var(--accent-glow);
transition: width .35s cubic-bezier(.4,0,.2,1);
}
@media (max-width: 640px) {
.wiz-step .lane { display: none; }
.wiz-step .dot:first-of-type { display: none; }
}
/* ── Wizard body ─────────────────────────────────────────────────────── */
.wiz-body {
flex: 1;
position: relative;
padding: clamp(40px, 7vh, 88px) clamp(20px, 4vw, 32px) clamp(40px, 6vh, 64px);
display: flex; flex-direction: column;
align-items: center;
}
.wiz-card {
width: 100%;
max-width: 520px;
display: flex; flex-direction: column;
gap: 28px;
}
.wiz-card.wide { max-width: 760px; }
.wiz-card.xwide { max-width: 1040px; }
/* Question heading — quiet, one line, no em accents */
.wiz-q { display: flex; flex-direction: column; gap: 10px; }
.wiz-q h2 {
font-size: clamp(22px, 2.4vw, 28px);
font-weight: 500;
letter-spacing: -0.018em;
line-height: 1.22;
color: var(--fg);
text-wrap: balance;
}
.wiz-q p {
font-size: 14.5px;
color: var(--fg-mute);
line-height: 1.55;
max-width: 460px;
}
/* Footer with back/continue */
.wiz-foot {
display: flex; align-items: center; justify-content: space-between;
gap: 14px;
margin-top: 8px;
}
.wiz-foot-left {
display: flex; align-items: center; gap: 10px;
font-size: 13px;
color: var(--fg-mute);
}
.wiz-foot-right {
display: flex; align-items: center; gap: 12px;
}
.wiz-hint {
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-faint);
letter-spacing: 0.06em;
}
.wiz-skip {
font-size: 13.5px;
color: var(--fg-mute);
padding: 8px 12px;
border-radius: 8px;
}
.wiz-skip:hover { color: var(--fg); background: oklch(0.20 0.009 60 / 0.5); }
.btn-wiz {
height: 42px;
padding: 0 18px;
font-size: 14px;
border-radius: 10px;
}
/* Fields tightened up for wizard context */
.wiz-field {
display: flex; flex-direction: column; gap: 8px;
}
.wiz-field-label {
font-size: 13.5px;
font-weight: 500;
color: var(--fg-dim);
letter-spacing: -0.005em;
}
.wiz-field-hint {
font-size: 12.5px;
color: var(--fg-mute);
line-height: 1.5;
}
.wiz-input {
width: 100%;
padding: 12px 14px;
background: oklch(0.16 0.008 60 / 0.8);
border: 1px solid var(--hairline);
border-radius: 10px;
color: var(--fg);
font: 14.5px/1.5 var(--font-sans);
outline: none;
transition: border-color .15s, background .15s, box-shadow .15s;
}
.wiz-input::placeholder { color: var(--fg-faint); }
.wiz-input:focus {
border-color: oklch(0.74 0.175 35 / 0.6);
background: oklch(0.18 0.009 60 / 0.95);
box-shadow: 0 0 0 3px oklch(0.74 0.175 35 / 0.12);
}
textarea.wiz-input { min-height: 96px; resize: vertical; }
/* Debug navigator panel */
.debug {
position: fixed; bottom: 16px; right: 16px;
z-index: 1000;
font-family: var(--font-mono);
font-size: 11px;
display: flex; flex-direction: column; gap: 6px;
align-items: flex-end;
}
.debug-toggle {
padding: 8px 12px;
border-radius: 999px;
background: oklch(0.18 0.009 60 / 0.85);
border: 1px solid var(--hairline);
color: var(--fg-mute);
letter-spacing: 0.06em;
text-transform: uppercase;
backdrop-filter: blur(12px);
}
.debug-toggle:hover { color: var(--fg); border-color: var(--hairline-2); }
.debug-panel {
width: 240px;
padding: 12px;
background: oklch(0.16 0.008 60 / 0.95);
border: 1px solid var(--hairline);
border-radius: 12px;
backdrop-filter: blur(20px);
display: flex; flex-direction: column; gap: 4px;
max-height: 60vh; overflow-y: auto;
}
.debug-row {
display: flex; align-items: center; gap: 8px;
padding: 6px 8px;
border-radius: 6px;
color: var(--fg-mute);
cursor: pointer;
letter-spacing: 0.04em;
text-transform: uppercase;
font-size: 10px;
}
.debug-row:hover { background: oklch(0.20 0.009 60); color: var(--fg-dim); }
.debug-row.active {
background: oklch(0.74 0.175 35 / 0.18);
color: var(--accent);
}
.debug-row b { color: inherit; font-weight: 600; }

189
new-site/vibn-signin.html Normal file

File diff suppressed because one or more lines are too long