feat: flatten routes and merge marketing and onboarding directories

This commit is contained in:
2026-06-06 18:52:03 -07:00
parent 47417d13a0
commit 0480b306f1
139 changed files with 36409 additions and 229 deletions

3
.vibncode/settings.json Normal file
View File

@@ -0,0 +1,3 @@
{
"hooks": {}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"sections":{"app-navs":{"labels":{"sidebar":"01 · Sidebar w/ workspaces"}}}}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Atlas — Two-sided marketplace templates</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Inter+Tight:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,300;1,400&family=DM+Serif+Display:ital@0;1&display=swap" rel="stylesheet">
<link rel="stylesheet" href="vibn-ai-templates/tokens.css">
<link rel="stylesheet" href="vibn-marketplace/marketplace-tokens.css">
<style>
html, body { margin: 0; padding: 0; min-height: 100%; background: #f0eee9; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
</style>
<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="design-canvas.jsx"></script>
<!-- Vibn base library -->
<script type="text/babel" src="vibn-ai-templates/icons.jsx"></script>
<script type="text/babel" src="vibn-ai-templates/components.jsx"></script>
<script type="text/babel" src="vibn-ai-templates/shells.jsx"></script>
<!-- Marketplace extension -->
<script type="text/babel" src="vibn-marketplace/marketplace-components.jsx"></script>
<script type="text/babel" src="vibn-marketplace/marketplace-shells.jsx"></script>
<script type="text/babel" src="atlas-pages.jsx"></script>
<script type="text/babel">
const { DesignCanvas, DCSection, DCArtboard } = window;
const {
AtlasHome, AtlasSearch, AtlasListing, AtlasCheckout,
AtlasMessages, AtlasGuestDash, AtlasHostDash, AtlasNewListing,
} = window;
// Wrap each page in .theme-flux — modern dark-glass aesthetic
// with violet/fuchsia aurora backdrop. The marketplace components
// are theme-aware, so swapping the class to `theme-atlas` or any
// other theme re-skins the whole tree.
const Atlas = ({ children }) => (
<div className="theme-flux" style={{ width: "100%", height: "100%" }}>{children}</div>
);
const W = 1440, H = 900;
function App() {
return (
<DesignCanvas>
<DCSection
id="public"
title="Public-facing · discovery → booking"
subtitle="The guest's path from landing to confirmed booking."
>
<DCArtboard id="home" label="01 · Home / discovery" width={W} height={1500}><Atlas><AtlasHome/></Atlas></DCArtboard>
<DCArtboard id="search" label="02 · Search results + map" width={W} height={H}><Atlas><AtlasSearch/></Atlas></DCArtboard>
<DCArtboard id="listing" label="03 · Listing detail" width={W} height={2400}><Atlas><AtlasListing/></Atlas></DCArtboard>
<DCArtboard id="checkout" label="04 · Checkout" width={W} height={1100}><Atlas><AtlasCheckout/></Atlas></DCArtboard>
</DCSection>
<DCSection
id="guest"
title="Guest (demand-side) experience"
subtitle="Post-booking: managing trips and talking to hosts."
>
<DCArtboard id="g-trips" label="05 · Guest dashboard · trips" width={W} height={H}><Atlas><AtlasGuestDash/></Atlas></DCArtboard>
<DCArtboard id="messages" label="06 · Messages inbox" width={W} height={H}><Atlas><AtlasMessages/></Atlas></DCArtboard>
</DCSection>
<DCSection
id="host"
title="Host (supply-side) experience"
subtitle="Earnings, calendar and listing creation."
>
<DCArtboard id="h-today" label="07 · Host dashboard · today" width={W} height={H}><Atlas><AtlasHostDash/></Atlas></DCArtboard>
<DCArtboard id="h-new" label="08 · New listing · step 3 of 6" width={W} height={H}><Atlas><AtlasNewListing/></Atlas></DCArtboard>
</DCSection>
</DesignCanvas>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>

View File

@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Auth screens · 3 aesthetics × 3 flows</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #f0eee9; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
</style>
<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>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="design-canvas.jsx"></script>
<script type="text/babel" src="auth-style-a.jsx"></script>
<script type="text/babel" src="auth-style-b.jsx"></script>
<script type="text/babel" src="auth-style-c.jsx"></script>
<script type="text/babel">
const { DesignCanvas, DCSection, DCArtboard } = window;
const W = 1440, H = 900;
function App() {
return (
<DesignCanvas>
<DCSection
id="style-a"
title="A · Light minimal"
subtitle="Centered card on warm neutral. Pairs with the Sidebar nav style."
>
<DCArtboard id="a-signin" label="Sign in" width={W} height={H}><ASignIn/></DCArtboard>
<DCArtboard id="a-signup" label="Sign up" width={W} height={H}><ASignUp/></DCArtboard>
<DCArtboard id="a-onboarding" label="Onboarding · workspace" width={W} height={H}><AOnboarding/></DCArtboard>
</DCSection>
<DCSection
id="style-b"
title="B · Dark split-hero"
subtitle="Storytelling panel + form. Pairs with the Top horizontal / ⌘K nav."
>
<DCArtboard id="b-signin" label="Sign in" width={W} height={H}><BSignIn/></DCArtboard>
<DCArtboard id="b-signup" label="Sign up" width={W} height={H}><BSignUp/></DCArtboard>
<DCArtboard id="b-onboarding" label="Onboarding · personalise" width={W} height={H}><BOnboarding/></DCArtboard>
</DCSection>
<DCSection
id="style-c"
title="C · Glass aurora"
subtitle="Vibrant gradient + frosted card. Pairs with the Floating-pill marketing nav."
>
<DCArtboard id="c-signin" label="Sign in" width={W} height={H}><CSignIn/></DCArtboard>
<DCArtboard id="c-signup" label="Sign up" width={W} height={H}><CSignUp/></DCArtboard>
<DCArtboard id="c-onboarding" label="Onboarding · invite team" width={W} height={H}><COnboarding/></DCArtboard>
</DCSection>
</DesignCanvas>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>

View File

@@ -0,0 +1,167 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Vibn — Request an invite</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" />
<style>
: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.45;
-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% 50%, #000 30%, transparent 80%);
-webkit-mask-image: radial-gradient(ellipse 80% 80% at 50% 50%, #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); }
.wrap {
position: relative;
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding-inline: clamp(20px, 4vw, 56px);
z-index: 2;
}
.nav {
position: sticky; top: 0; z-index: 50;
backdrop-filter: blur(12px) saturate(140%);
background: oklch(0.155 0.008 60 / 0.55);
border-bottom: 1px solid transparent;
transition: border-color .2s;
}
.nav.scrolled { border-bottom-color: oklch(0.30 0.01 60 / 0.4); }
.nav-inner {
display: flex; align-items: center; justify-content: space-between;
height: 64px;
}
.logo {
display: inline-flex; align-items: center; gap: 9px;
font-weight: 600; font-size: 17px; letter-spacing: -0.02em;
}
.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; } }
.nav-back {
color: var(--fg-mute); font-size: 14px;
display: inline-flex; align-items: center; gap: 6px;
}
.nav-back:hover { color: var(--fg); }
.eyebrow {
display: inline-flex; align-items: center; gap: 8px;
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);
}
.mono { font-family: var(--font-mono); }
.btn {
display: inline-flex; align-items: center; justify-content: center;
gap: 10px;
height: 50px; padding: 0 22px;
border-radius: 999px;
font-weight: 500;
transition: transform .12s, box-shadow .2s, background .2s;
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 50px -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 {
color: var(--fg-dim);
border: 1px solid var(--hairline);
background: oklch(0.20 0.009 60 / 0.4);
backdrop-filter: blur(8px);
}
.btn-ghost:hover { color: var(--fg); border-color: var(--hairline-2); }
</style>
<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="beta.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Cadence CRM — Sidebar template package</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="vibn-ai-templates/tokens.css">
<style>
html, body { margin: 0; padding: 0; min-height: 100%; background: #f0eee9; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
</style>
<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="design-canvas.jsx"></script>
<script type="text/babel" src="vibn-ai-templates/icons.jsx"></script>
<script type="text/babel" src="vibn-ai-templates/components.jsx"></script>
<script type="text/babel" src="vibn-ai-templates/shells.jsx"></script>
<script type="text/babel" src="vibn-crm/crm-onboarding.jsx"></script>
<script type="text/babel" src="vibn-crm/crm-pages.jsx"></script>
<script type="text/babel">
const { DesignCanvas, DCSection, DCArtboard,
CRMSignUp, CRMSignIn,
CRMOnbWorkspace, CRMOnbAbout, CRMOnbImport, CRMOnbInvite,
CRMHome, CRMPeople, CRMRecord, CRMPipeline, CRMInbox, CRMReports, CRMSettings } = window;
// Everything renders in the light/minimal theme (the sidebar style).
const M = ({ children }) => (
<div className="theme-minimal" style={{ width: "100%", height: "100%" }}>{children}</div>
);
const W = 1440, H = 900;
function App() {
return (
<DesignCanvas>
<DCSection id="auth" title="Sign up & sign in"
subtitle="Full-screen, same minimal aesthetic as the app.">
<DCArtboard id="signup" label="Sign up" width={W} height={H}><M><CRMSignUp/></M></DCArtboard>
<DCArtboard id="signin" label="Sign in" width={W} height={H}><M><CRMSignIn/></M></DCArtboard>
</DCSection>
<DCSection id="onboarding" title="Onboarding · 4 steps"
subtitle="Workspace → about you → import → invite. Stepper-driven.">
<DCArtboard id="onb-1" label="01 · Name workspace" width={W} height={H}><M><CRMOnbWorkspace/></M></DCArtboard>
<DCArtboard id="onb-2" label="02 · About your team" width={W} height={H}><M><CRMOnbAbout/></M></DCArtboard>
<DCArtboard id="onb-3" label="03 · Import contacts" width={W} height={H}><M><CRMOnbImport/></M></DCArtboard>
<DCArtboard id="onb-4" label="04 · Invite team" width={W} height={H}><M><CRMOnbInvite/></M></DCArtboard>
</DCSection>
<DCSection id="app" title="In-app · Sidebar style"
subtitle="The far-left sidebar nav across every core CRM screen.">
<DCArtboard id="home" label="Home" width={W} height={H}><M><CRMHome/></M></DCArtboard>
<DCArtboard id="people" label="People · table" width={W} height={H}><M><CRMPeople/></M></DCArtboard>
<DCArtboard id="record" label="Company record" width={W} height={H}><M><CRMRecord/></M></DCArtboard>
<DCArtboard id="pipeline" label="Deals · pipeline" width={W} height={H}><M><CRMPipeline/></M></DCArtboard>
<DCArtboard id="inbox" label="Inbox" width={W} height={H}><M><CRMInbox/></M></DCArtboard>
<DCArtboard id="reports" label="Reports" width={W} height={H}><M><CRMReports/></M></DCArtboard>
<DCArtboard id="settings" label="Settings · members" width={W} height={H}><M><CRMSettings/></M></DCArtboard>
</DCSection>
</DesignCanvas>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>

View File

@@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Modern website design styles · 2026 sampler</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #f0eee9; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
</style>
<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>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&family=Space+Grotesk:wght@400;500;600;700;800&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="design-canvas.jsx"></script>
<script type="text/babel" src="styles.jsx"></script>
<script type="text/babel">
const { DesignCanvas, DCSection, DCArtboard, DCPostIt } = window;
// Each artboard is a 1280×800 desktop hero so the styles read as full
// landing pages, not crops. They're laid out in three thematic rows so
// the user can compare neighbours and skim the whole field at once.
const W = 1280, H = 800;
function App() {
return (
<DesignCanvas>
<DCSection
id="restrained"
title="The restrained school"
subtitle="Type-led, gridded, lots of white space — the editorial revival."
>
<DCArtboard id="editorial" label="01 · Editorial Swiss" width={W} height={H}><StyleEditorial /></DCArtboard>
<DCArtboard id="minimal" label="02 · Minimal mono" width={W} height={H}><StyleMinimal /></DCArtboard>
<DCArtboard id="organic" label="03 · Organic / warm serif" width={W} height={H}><StyleOrganic /></DCArtboard>
</DCSection>
<DCSection
id="product"
title="The product-led school"
subtitle="Dark UI, bento grids, frosted glass — what modern SaaS sites look like in 2026."
>
<DCArtboard id="bento" label="04 · Dark bento" width={W} height={H}><StyleBento /></DCArtboard>
<DCArtboard id="aurora" label="05 · Glass / Aurora" width={W} height={H}><StyleAurora /></DCArtboard>
<DCArtboard id="terminal" label="06 · Terminal mono" width={W} height={H}><StyleTerminal /></DCArtboard>
<DCArtboard id="cyber" label="07 · Cyber / neon grid" width={W} height={H}><StyleCyber /></DCArtboard>
</DCSection>
<DCSection
id="expressive"
title="The expressive school"
subtitle="Loud, opinionated, hand-feeling. Pushback against grid-perfect SaaS."
>
<DCArtboard id="brutalist" label="08 · Neo-brutalism" width={W} height={H}><StyleBrutalist /></DCArtboard>
<DCArtboard id="maximalist" label="09 · Maximalist Y2K" width={W} height={H}><StyleMaximalist /></DCArtboard>
<DCArtboard id="anti" label="10 · Anti-design" width={W} height={H}><StyleAntiDesign /></DCArtboard>
</DCSection>
</DesignCanvas>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>

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>

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>

View File

@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>4 modern SaaS nav layouts</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #f0eee9; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
</style>
<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>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="design-canvas.jsx"></script>
<script type="text/babel" src="nav-styles.jsx"></script>
<script type="text/babel">
const { DesignCanvas, DCSection, DCArtboard } = window;
const W = 1440, H = 900;
function App() {
return (
<DesignCanvas>
<DCSection
id="app-navs"
title="App navigation"
subtitle="In-product chrome for an authenticated workspace."
>
<DCArtboard id="sidebar" label="01 · Sidebar w/ workspaces" width={W} height={H}>
<NavSidebar />
</DCArtboard>
<DCArtboard id="rail" label="02 · Icon rail + secondary panel" width={W} height={H}>
<NavIconRail />
</DCArtboard>
<DCArtboard id="topbar" label="03 · Top horizontal + ⌘K bar" width={W} height={H}>
<NavTopHorizontal />
</DCArtboard>
</DCSection>
<DCSection
id="marketing-nav"
title="Marketing navigation"
subtitle="Public-facing homepage chrome."
>
<DCArtboard id="glasspill" label="04 · Floating glass pill" width={W} height={H}>
<NavFloatingGlass />
</DCArtboard>
</DCSection>
</DesignCanvas>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>

View File

@@ -0,0 +1,220 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>SaaS pages × 3 nav styles · Lattice</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #f0eee9; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
</style>
<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>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="design-canvas.jsx"></script>
<script type="text/babel" src="app-chrome.jsx"></script>
<script type="text/babel" src="page-customer.jsx"></script>
<script type="text/babel" src="page-dashboard.jsx"></script>
<script type="text/babel" src="page-admin.jsx"></script>
<script type="text/babel">
const { DesignCanvas, DCSection, DCArtboard,
SidebarChrome, RailChrome, RailItem, RailSectionHeader, TopbarChrome,
Icon, P } = window;
const W = 1440, H = 900;
// ── Secondary panel content per page, for the dark rail chrome ─────
// Companies list — relevant context next to a customer/company page
const CompaniesPanel = () => (
<>
<RailSectionHeader action={<Icon d={P.plus} size={12} />}>
Pinned
</RailSectionHeader>
{[
["NS", "Northstar Logistics", "Tier 1 · EMEA", "#f6c560", true],
["HC", "Halcyon", "Renewal Q3", "#a8c8e8"],
["KS", "Kestrel", "Pilot", "#c8e8a8"],
].map(([i, n, s, col, active]) => (
<RailItem key={i} active={active}
leading={<div style={{
width: 24, height: 24, borderRadius: 5, background: col,
display: "flex", alignItems: "center", justifyContent: "center",
color: "#3a2210", fontSize: 10, fontWeight: 700,
}}>{i}</div>}
label={n} sub={s} />
))}
<RailSectionHeader>All companies · 248</RailSectionHeader>
{[
["BF","Brooke Foods", "added 2 days", "#e8c8a8"],
["MV","Moss & Verra", "added 5 days", "#c8a8e8"],
["TD","Tide Co.", "added a week", "#a8e8c8"],
["VR","Verra Tech", "added a week", "#e8a87c"],
["LW","Lowell Works", "added 2 weeks", "#a8c8e8"],
["OK","Okra Studios", "added 3 weeks", "#e8a8c8"],
].map(([i, n, s, col]) => (
<RailItem key={i}
leading={<div style={{
width: 24, height: 24, borderRadius: 5, background: col,
display: "flex", alignItems: "center", justifyContent: "center",
color: "#3a2210", fontSize: 10, fontWeight: 700,
}}>{i}</div>}
label={n} sub={s} />
))}
</>
);
// Saved dashboards / reports — context for the dashboard page
const DashboardsPanel = () => (
<>
<RailSectionHeader action={<Icon d={P.plus} size={12} />}>
My dashboards
</RailSectionHeader>
{[
["Workspace overview", "default", true],
["Revenue · weekly", "shared by Theo"],
["Pipeline health", "auto-refresh 5m"],
["Team performance", "private"],
].map(([n, s, active]) => (
<RailItem key={n} active={active}
leading={<span style={{ color: "#9a9aa6", display: "flex" }}>
<Icon d={P.bar} size={14} />
</span>}
label={n} sub={s} />
))}
<RailSectionHeader>Shared with me</RailSectionHeader>
{[
["Q2 board review", "from Mira"],
["Marketing funnel", "from Devi"],
["Customer success", "from Sun"],
["Churn watch", "from Theo"],
].map(([n, s]) => (
<RailItem key={n}
leading={<span style={{ color: "#9a9aa6", display: "flex" }}>
<Icon d={P.bar} size={14} />
</span>}
label={n} sub={s} />
))}
</>
);
// Settings tree — context for the admin page
const SettingsPanel = () => (
<>
<RailSectionHeader>Workspace</RailSectionHeader>
{[
["General", P.settings],
["Members", P.people, true],
["Roles", P.check],
["Teams", P.people],
["Integrations", P.workflow],
["Billing", P.target],
["API & Webhooks", P.workflow],
["Audit log", P.doc],
].map(([n, ico, active]) => (
<RailItem key={n} active={active}
leading={<span style={{
color: active ? "#fff" : "#9a9aa6", display: "flex",
}}><Icon d={ico} size={14} /></span>}
label={n} />
))}
<RailSectionHeader>Personal</RailSectionHeader>
{[
["Profile", P.people],
["Notifications", P.bell],
["Sessions", P.target],
].map(([n, ico]) => (
<RailItem key={n}
leading={<span style={{ color: "#9a9aa6", display: "flex" }}>
<Icon d={ico} size={14} />
</span>}
label={n} />
))}
</>
);
// Tabs per page for the dark top bar
const customerTabs = ["Overview", "Activity", "People", "Notes", "Files"];
const dashboardTabs = ["Overview", "Reports", "Goals", "Anomalies", "Custom"];
const adminTabs = ["General", "Members", "Roles", "Integrations", "Billing", "API"];
function App() {
return (
<DesignCanvas>
<DCSection
id="customer"
title="Customer / company page"
subtitle="A CRM record — same content, three nav shells."
>
<DCArtboard id="cust-sidebar" label="Sidebar nav" width={W} height={H}>
<SidebarChrome active="companies"><CustomerBody theme="light"/></SidebarChrome>
</DCArtboard>
<DCArtboard id="cust-rail" label="Icon rail + secondary" width={W} height={H}>
<RailChrome active="companies" secondary={<CompaniesPanel/>}>
<CustomerBody theme="dark"/>
</RailChrome>
</DCArtboard>
<DCArtboard id="cust-topbar" label="Top horizontal + ⌘K" width={W} height={H}>
<TopbarChrome tabs={customerTabs} activeTab="Activity"
breadcrumb="northstar-logistics">
<CustomerBody theme="light"/>
</TopbarChrome>
</DCArtboard>
</DCSection>
<DCSection
id="dashboard"
title="Dashboard page"
subtitle="KPIs, time-series, funnel and activity."
>
<DCArtboard id="dash-sidebar" label="Sidebar nav" width={W} height={H}>
<SidebarChrome active="home"><DashboardBody theme="light"/></SidebarChrome>
</DCArtboard>
<DCArtboard id="dash-rail" label="Icon rail + secondary" width={W} height={H}>
<RailChrome active="home" secondary={<DashboardsPanel/>}>
<DashboardBody theme="dark"/>
</RailChrome>
</DCArtboard>
<DCArtboard id="dash-topbar" label="Top horizontal + ⌘K" width={W} height={H}>
<TopbarChrome tabs={dashboardTabs} activeTab="Overview"
breadcrumb="dashboard">
<DashboardBody theme="light"/>
</TopbarChrome>
</DCArtboard>
</DCSection>
<DCSection
id="admin"
title="Admin page"
subtitle="Workspace settings → Members table."
>
<DCArtboard id="admin-sidebar" label="Sidebar nav" width={W} height={H}>
<SidebarChrome active="settings"><AdminBody theme="light"/></SidebarChrome>
</DCArtboard>
<DCArtboard id="admin-rail" label="Icon rail + secondary" width={W} height={H}>
<RailChrome active="settings" secondary={<SettingsPanel/>}>
<AdminBody theme="dark"/>
</RailChrome>
</DCArtboard>
<DCArtboard id="admin-topbar" label="Top horizontal + ⌘K" width={W} height={H}>
<TopbarChrome tabs={adminTabs} activeTab="Members"
breadcrumb="settings">
<AdminBody theme="light"/>
</TopbarChrome>
</DCArtboard>
</DCSection>
</DesignCanvas>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>

View File

@@ -0,0 +1,38 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Vibn — Sign in</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="auth.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>
<template id="__bundler_thumbnail">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<rect width="200" height="200" fill="#27201d"/>
<circle cx="100" cy="100" r="56" fill="url(#g)"/>
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#ff6b47"/>
<stop offset="100%" stop-color="#c2410c"/>
</linearGradient>
</defs>
<g transform="translate(72 78)" fill="#1a0f0a" stroke="#1a0f0a" stroke-width="1.5" stroke-linejoin="round">
<path d="M0 0 L11 0 L14 24 L17 0 L28 0 L17 44 Z"/>
<rect x="33" y="36" width="18" height="7" rx="1"/>
</g>
</svg>
</template>
<div id="root"></div>
<script type="text/babel" src="auth-shared.jsx"></script>
<script type="text/babel" src="signin.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Vibn — Create your account</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="auth.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="auth-shared.jsx"></script>
<script type="text/babel" src="signup.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,687 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Vibn AI Templates — UI showcase</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=DM+Serif+Display:ital@0;1&display=swap" rel="stylesheet">
<link rel="stylesheet" href="vibn-ai-templates/tokens.css">
<style>
html, body { margin: 0; padding: 0; min-height: 100%; background: #f0eee9; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
</style>
<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="design-canvas.jsx"></script>
<script type="text/babel" src="vibn-ai-templates/icons.jsx"></script>
<script type="text/babel" src="vibn-ai-templates/components.jsx"></script>
<script type="text/babel" src="vibn-ai-templates/shells.jsx"></script>
<script type="text/babel">
const { DesignCanvas, DCSection, DCArtboard,
Button, IconButton, Field, Input, Textarea, Select, FieldGroup,
Checkbox, Radio, Switch, Card, CardHeader, Divider,
Badge, Avatar, AvatarStack, Tabs, Table, Modal, Banner, KBD, Spinner,
SidebarShell, TopbarShell, RailShell,
AuthCenteredShell, AuthSplitShell, AuthGlassShell,
Icon, icons, VibnMark } = window;
// ─── Section helpers ────────────────────────────────────────
const SubHeading = ({ children }) => (
<div style={{
fontSize: "var(--text-xs)", color: "var(--text-3)",
letterSpacing: "0.08em", textTransform: "uppercase",
fontWeight: 500, marginBottom: 10,
}}>{children}</div>
);
const ThemeFrame = ({ theme, children }) => (
// Themed wrapper — note: the artboard contents must be wrapped in
// a theme class so all CSS-var reads inside re-bind to that theme.
<div className={`theme-${theme}`} style={{ width: "100%", height: "100%" }}>
<div className="vibn-app" style={{ width: "100%", height: "100%", overflow: "auto" }}>
{children}
</div>
</div>
);
// ─── 1 · Foundations / token swatches ───────────────────────
const Foundations = ({ theme }) => (
<div style={{ padding: 32 }}>
<h1 style={{
margin: 0, fontFamily: "var(--font-display)",
fontSize: "var(--text-3xl)", letterSpacing: "-0.02em", fontWeight: 500,
}}>Theme · {theme}</h1>
<p style={{ color: "var(--text-2)", marginTop: 6, fontSize: "var(--text-md)" }}>
Same components, four CSS-variable themes. Tokens, type and surfaces.
</p>
<div style={{ marginTop: 28, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20 }}>
<Card>
<CardHeader title="Surface" subtitle="Page chrome + cards"/>
<div style={{ display: "grid", gridTemplateColumns: "repeat(5, 1fr)", gap: 10 }}>
{[
["--bg", "Page bg"],
["--surface", "Card"],
["--surface-2", "Card alt"],
["--surface-alt", "Sidebar"],
["--border", "Border"],
].map(([v, l]) => (
<div key={v}>
<div style={{
height: 56, borderRadius: "var(--radius)",
background: `var(${v})`, border: "1px solid var(--border)",
}}/>
<div style={{ fontSize: 11, color: "var(--text-3)", marginTop: 6 }}>{l}</div>
<div style={{ fontSize: 10, fontFamily: "var(--font-mono)", color: "var(--text-3)" }}>{v}</div>
</div>
))}
</div>
</Card>
<Card>
<CardHeader title="Accents & semantics"/>
<div style={{ display: "grid", gridTemplateColumns: "repeat(5, 1fr)", gap: 10 }}>
{[
["--accent", "Accent"],
["--accent-2", "Accent 2"],
["--success", "Success"],
["--warn", "Warn"],
["--danger", "Danger"],
].map(([v, l]) => (
<div key={v}>
<div style={{ height: 56, borderRadius: "var(--radius)", background: `var(${v})` }}/>
<div style={{ fontSize: 11, color: "var(--text-3)", marginTop: 6 }}>{l}</div>
<div style={{ fontSize: 10, fontFamily: "var(--font-mono)", color: "var(--text-3)" }}>{v}</div>
</div>
))}
</div>
</Card>
<Card>
<CardHeader title="Type scale"/>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{[
["Display · 38", { fontSize: 38, fontWeight: 500, fontFamily: "var(--font-display)", letterSpacing: "-0.02em" }],
["Heading · 22", { fontSize: 22, fontWeight: 600, letterSpacing: "-0.01em" }],
["Body · 13", { fontSize: 13 }],
["Caption · 11", { fontSize: 11, color: "var(--text-3)" }],
["Mono · 12", { fontFamily: "var(--font-mono)", fontSize: 12 }],
].map(([l, s], i) => (
<div key={i} style={{ ...s }}>{l} Modern SaaS, designed for everyone.</div>
))}
</div>
</Card>
<Card>
<CardHeader title="Radii, shadows, motion"/>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 12, marginBottom: 14 }}>
{["sm", "", "lg"].map(s => (
<div key={s} style={{
height: 50,
background: "var(--surface-2)",
border: "1px solid var(--border)",
borderRadius: `var(--radius${s ? `-${s}` : ""})`,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 11, color: "var(--text-3)",
}}>radius-{s || "default"}</div>
))}
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 12 }}>
{[
["shadow-sm", "var(--shadow-sm)"],
["shadow", "var(--shadow)"],
["shadow-lg", "var(--shadow-lg)"],
].map(([l, sh]) => (
<div key={l} style={{
height: 50, background: "var(--surface)",
border: "1px solid var(--border)", borderRadius: "var(--radius)",
boxShadow: sh, display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 11, color: "var(--text-3)",
}}>{l}</div>
))}
</div>
</Card>
</div>
</div>
);
// ─── 2 · Form atoms ─────────────────────────────────────────
const FormAtoms = () => {
const [tab, setTab] = React.useState("Account");
const [sw1, setSw1] = React.useState(true);
const [sw2, setSw2] = React.useState(false);
const [chk, setChk] = React.useState(true);
const [seg, setSeg] = React.useState("Week");
return (
<div style={{ padding: 32 }}>
<h1 style={{
margin: 0, fontFamily: "var(--font-display)", fontSize: "var(--text-2xl)",
fontWeight: 500, letterSpacing: "-0.02em",
}}>Forms & buttons</h1>
<div style={{ marginTop: 24, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20 }}>
<Card>
<CardHeader title="Buttons" subtitle="Variants, sizes, states"/>
<SubHeading>Variants</SubHeading>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 16 }}>
<Button>Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="destructive">Delete</Button>
<Button loading>Loading</Button>
<Button disabled>Disabled</Button>
</div>
<SubHeading>Sizes & icons</SubHeading>
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
<Button size="sm" leadingIcon={<Icon name="plus" size={12}/>}>New deal</Button>
<Button>Sign in <Icon name="arrow" size={13}/></Button>
<Button size="lg" variant="secondary">Get a demo</Button>
<IconButton name="bell" label="Notifications"/>
<IconButton name="settings" variant="secondary" label="Settings"/>
</div>
</Card>
<Card>
<CardHeader title="Fields" subtitle="Input, hint, error, password"/>
<Field label="Work email" hint="We'll send a 6-digit code.">
<Input value="mira@acme.io" leadingIcon={<Icon name="inbox" size={14}/>} autofocus/>
</Field>
<Field label="Password">
<Input type="password" value="••••••••••"
trailingIcon={<Icon name="eye" size={14}/>}/>
</Field>
<Field label="Workspace name" error="That name is taken.">
<Input value="lattice" invalid/>
</Field>
<Field label="Notes" optional>
<Textarea placeholder="Anything we should know?" rows={3}/>
</Field>
<Field label="Role">
<Select value="Admin" options={["Owner", "Admin", "Member", "Guest"]}/>
</Field>
</Card>
<Card>
<CardHeader title="Controls"/>
<SubHeading>Switches</SubHeading>
<div style={{ display: "flex", flexDirection: "column", gap: 14, marginBottom: 14 }}>
<Switch checked={sw1} onChange={setSw1}
label="Email me digests" hint="Weekly summary every Monday at 9am."/>
<Switch checked={sw2} onChange={setSw2}
label="Show beta features"/>
</div>
<SubHeading>Checkboxes & radios</SubHeading>
<div style={{ display: "flex", flexDirection: "column", gap: 10, marginBottom: 14 }}>
<Checkbox checked={chk} onChange={setChk} label="I agree to the Terms" hint="And the Privacy Policy."/>
<Checkbox checked indeterminate label="Select some items"/>
<div style={{ display: "flex", gap: 16 }}>
<Radio checked={true} label="Monthly"/>
<Radio checked={false} label="Yearly · save 20%"/>
</div>
</div>
<SubHeading>Segmented</SubHeading>
<FieldGroup options={["Day", "Week", "Month", "Quarter"]} value={seg} onChange={setSeg}/>
</Card>
<Card>
<CardHeader title="Tabs"/>
<SubHeading>Underline</SubHeading>
<Tabs items={[
{ label: "Account" }, { label: "Members", count: 8 },
{ label: "Billing" }, { label: "API" },
]} active={tab} onChange={setTab}/>
<div style={{ marginTop: 14 }}>
<SubHeading>Pill</SubHeading>
<Tabs variant="pill" items={[
{ label: "Day" }, { label: "Week" }, { label: "Month" }, { label: "Year" },
]} active="Week"/>
</div>
</Card>
</div>
</div>
);
};
// ─── 3 · Display atoms ──────────────────────────────────────
const DisplayAtoms = () => {
const [modalOpen, setModalOpen] = React.useState(false);
return (
<div style={{ padding: 32 }}>
<h1 style={{
margin: 0, fontFamily: "var(--font-display)", fontSize: "var(--text-2xl)",
fontWeight: 500, letterSpacing: "-0.02em",
}}>Display & feedback</h1>
<div style={{ marginTop: 24, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20 }}>
<Card>
<CardHeader title="Badges & avatars"/>
<SubHeading>Tones</SubHeading>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 16 }}>
<Badge>Neutral</Badge>
<Badge tone="accent" dot>Accent</Badge>
<Badge tone="success" dot>Active</Badge>
<Badge tone="warn" dot>Invited</Badge>
<Badge tone="danger" dot>Suspended</Badge>
<Badge tone="info">v4.2.1</Badge>
</div>
<SubHeading>Avatars</SubHeading>
<div style={{ display: "flex", gap: 14, alignItems: "center" }}>
<Avatar name="Mira Reyes" size={24}/>
<Avatar name="Theo Roux" size={32}/>
<Avatar name="Devi Patel" size={40} status="online"/>
<Avatar name="Sun Kim" size={48} status="busy"/>
<AvatarStack items={[
{name:"Mira Reyes"},{name:"Theo Roux"},{name:"Devi Patel"},
{name:"Sun Kim"},{name:"Ade Nwosu"},{name:"Linnea Berg"},
{name:"Jamal Frost"}
]}/>
</div>
</Card>
<Card>
<CardHeader title="Banners"/>
<Banner title="Workspace upgrade pending" tone="warn"
action={<Button size="sm" variant="secondary">Review</Button>}>
1 invitation hasn't been accepted yet — sent 3 days ago.
</Banner>
<div style={{ height: 10 }}/>
<Banner tone="success" title="Saved">Your changes were saved.</Banner>
<div style={{ height: 10 }}/>
<Banner tone="danger" title="Couldn't connect">Please check your network and try again.</Banner>
</Card>
<Card style={{ gridColumn: "span 2" }}>
<CardHeader title="Table" subtitle="Members of the workspace"
action={<Button size="sm" leadingIcon={<Icon name="plus" size={12}/>}>Invite</Button>}/>
<Table
columns={[
{ key: "name", label: "Name", render: r => (
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<Avatar name={r.name} size={26}/>
<div>
<div style={{ fontWeight: 500 }}>{r.name}</div>
<div style={{ fontSize: 11, color: "var(--text-3)" }}>{r.email}</div>
</div>
</div>
)},
{ key: "role", label: "Role", render: r => <Badge tone="accent">{r.role}</Badge> },
{ key: "status", label: "Status", render: r =>
<Badge dot tone={r.status === "Active" ? "success" :
r.status === "Invited" ? "warn" : "danger"}>{r.status}</Badge> },
{ key: "last", label: "Last active" },
{ key: "act", label: "", align: "right", width: 32,
render: () => <IconButton name="more" size="sm" label="More"/> },
]}
rows={[
{ id: 1, name: "Mira Reyes", email: "mira@vibn.co", role: "Owner", status: "Active", last: "now" },
{ id: 2, name: "Theo Roux", email: "theo@vibn.co", role: "Admin", status: "Active", last: "12 min" },
{ id: 3, name: "Devi Patel", email: "devi@vibn.co", role: "Admin", status: "Active", last: "1 hour" },
{ id: 4, name: "Linnea Berg", email: "linnea@vibn.co", role: "Member", status: "Invited", last: "" },
{ id: 5, name: "Elin Roos", email: "elin@vibn.co", role: "Member", status: "Suspended", last: "14 days" },
]}
selectable
selected={[1, 2]}
/>
</Card>
<Card style={{ gridColumn: "span 2" }}>
<CardHeader title="Modal"/>
<div style={{ display: "flex", gap: 10, alignItems: "center" }}>
<Button onClick={() => setModalOpen(true)}>Open modal</Button>
<span style={{ fontSize: 12, color: "var(--text-3)" }}>
Press <KBD>⌘ + Enter</KBD> to confirm
</span>
</div>
<Modal
open={modalOpen} onClose={() => setModalOpen(false)}
title="Delete workspace?"
description="This will permanently remove all data in Lattice Studio. This action cannot be undone."
footer={<>
<Button variant="secondary" onClick={() => setModalOpen(false)}>Cancel</Button>
<Button variant="destructive">Yes, delete it</Button>
</>}
>
<Field label="Type the workspace name to confirm">
<Input placeholder="lattice-studio"/>
</Field>
</Modal>
</Card>
</div>
</div>
);
};
// ─── 4 · In-product shells ──────────────────────────────────
const SidebarDemo = () => (
<SidebarShell
brand={{ name: "Lattice Studio" }}
sections={[
{ items: [
{ id: "home", label: "Home", icon: "home" },
{ id: "inbox", label: "Inbox", icon: "inbox", count: 12 },
{ id: "tasks", label: "Tasks", icon: "check", count: 3 },
]},
{ title: "Views", items: [
{ id: "co", label: "Companies", icon: "building", active: true },
{ id: "people", label: "People", icon: "people" },
{ id: "deals", label: "Opportunities", icon: "target" },
]},
{ title: "Tools", items: [
{ id: "i", label: "Insights", icon: "bar" },
{ id: "f", label: "Automations", icon: "workflow"},
{ id: "d", label: "Docs", icon: "doc" },
]},
{ title: "Admin", items: [
{ id: "s", label: "Settings", icon: "settings" },
]},
]}
user={{ name: "Mira Reyes", email: "mira@vibn.co" }}
>
<div style={{ padding: 28 }}>
<h1 style={{ margin: 0, fontSize: 26, fontWeight: 600 }}>Companies</h1>
<p style={{ color: "var(--text-2)", fontSize: 13, marginTop: 6 }}>
248 records · last sync 4 minutes ago
</p>
<div style={{ marginTop: 18 }}>
<Banner tone="info" title="Vibn 4.0 is live">
Workspace-wide rollout begins next Monday. Read the changelog →
</Banner>
</div>
</div>
</SidebarShell>
);
const TopbarDemo = () => {
const [tab, setTab] = React.useState("Activity");
return (
<TopbarShell
brand={{ name: "Lattice" }}
breadcrumb={[
{ avatar: "Mira Reyes", label: "mira-reyes" },
{ label: "northstar-logistics", badge: "Pro" },
]}
tabs={[
{ label: "Overview" }, { label: "Activity", count: 18 },
{ label: "People" }, { label: "Notes" }, { label: "Files" },
]}
activeTab={tab}
onTabChange={setTab}
user={{ name: "Mira Reyes" }}
>
<div style={{ padding: 32 }}>
<h1 style={{ margin: 0, fontSize: 28, fontWeight: 600, letterSpacing: "-0.02em" }}>Northstar Logistics</h1>
<div style={{ display: "flex", gap: 8, marginTop: 8 }}>
<Badge tone="success" dot>Customer</Badge>
<Badge tone="accent">Tier 1</Badge>
<Badge>EMEA</Badge>
</div>
<div style={{ marginTop: 24, display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 16 }}>
{[
{ l: "Pipeline", v: "146k", s: "+12k 30d"},
{ l: "Closed-won", v: "220k", s: "lifetime"},
{ l: "Health", v: "82", s: "stable"},
].map(k => (
<Card key={k.l} padding={18}>
<div style={{ fontSize: 11, color: "var(--text-3)", textTransform: "uppercase", letterSpacing: "0.06em" }}>{k.l}</div>
<div style={{ fontSize: 24, fontWeight: 600, marginTop: 6 }}>{k.v}</div>
<div style={{ fontSize: 11, color: "var(--text-2)", marginTop: 2 }}>{k.s}</div>
</Card>
))}
</div>
</div>
</TopbarShell>
);
};
const RailDemo = () => (
<RailShell
brand={{ name: "Vibn" }}
items={[
{ id: "home", icon: "home" },
{ id: "inbox", icon: "inbox", badge: 9 },
{ id: "co", icon: "building" },
{ id: "ppl", icon: "people" },
{ id: "deals", icon: "target", badge: 2 },
]}
activeRail="co"
secondaryTitle="Companies"
secondary={
<div>
{["Northstar Logistics", "Halcyon", "Kestrel", "Mossbank", "Verra", "Brooke Foods"].map((n, i) => (
<div key={n} style={{
padding: "8px 10px", borderRadius: "var(--radius-sm)", fontSize: 13,
background: i === 0 ? "var(--surface-alt)" : "transparent",
color: i === 0 ? "var(--text)" : "var(--text-2)",
display: "flex", alignItems: "center", gap: 10, cursor: "pointer",
}}>
<Avatar name={n} size={22}/>
<span style={{ flex: 1 }}>{n}</span>
{i === 0 && <Badge tone="success" dot>active</Badge>}
</div>
))}
</div>
}
user={{ name: "Mira Reyes" }}
>
<div style={{ padding: 32 }}>
<h1 style={{ margin: 0, fontSize: 24, fontWeight: 600 }}>Northstar Logistics</h1>
<p style={{ color: "var(--text-2)", fontSize: 13, marginTop: 6 }}>
Customer since Aug 2024 · 6 people · €146k pipeline
</p>
</div>
</RailShell>
);
// ─── 5 · Auth shells ────────────────────────────────────────
const SocialRow = () => (
<div style={{ display: "flex", gap: 8 }}>
<Button variant="secondary" full>Google</Button>
<Button variant="secondary" full>Microsoft</Button>
<Button variant="secondary" full>SSO</Button>
</div>
);
const AuthCenteredDemo = () => (
<AuthCenteredShell brand={{ name: "Lattice" }}>
<h1 style={{
margin: 0, fontFamily: "var(--font-display)",
fontSize: "var(--text-xl)", fontWeight: 600, letterSpacing: "-0.01em",
}}>Welcome back</h1>
<p style={{ fontSize: 13, color: "var(--text-2)", margin: "6px 0 22px" }}>
Sign in to your Lattice workspace.
</p>
<SocialRow/>
<Divider label="or with email"/>
<Field label="Email"><Input value="mira@lattice.co" autofocus/></Field>
<Field label="Password"><Input type="password" value=""
trailingIcon={<Icon name="eye" size={14}/>}/></Field>
<Button full>Sign in <Icon name="arrow" size={13}/></Button>
<div style={{ fontSize: 12, textAlign: "center", marginTop: 18, color: "var(--text-2)" }}>
New here? <span style={{ color: "var(--text)", fontWeight: 500 }}>Create an account</span>
</div>
</AuthCenteredShell>
);
const AuthSplitDemo = () => (
<AuthSplitShell
brand={{ name: "Lattice" }}
hero={{
badge: "Lattice 4.0 · agents that draft for you",
headline: "The workspace where good ideas compound.",
sub: "One luminous surface for docs, canvases, contacts and pipelines.",
quote: {
body: "Replaced three tools in our first week. Lattice is what every CRM should have been.",
author: "Devi Patel", role: "Head of Sales, Halcyon",
},
}}
>
<h1 style={{
margin: 0, fontFamily: "var(--font-display)", fontSize: 26,
fontWeight: 600, letterSpacing: "-0.02em",
}}>Sign in to Lattice</h1>
<p style={{ fontSize: 13, color: "var(--text-2)", margin: "6px 0 22px" }}>
Welcome back. Pick how you'd like to continue.
</p>
<SocialRow/>
<Divider label="or with email"/>
<Field label="Email"><Input value="mira@lattice.co" autofocus/></Field>
<Field label="Password"><Input type="password" value=""
trailingIcon={<Icon name="eye" size={14}/>}/></Field>
<Button full>Sign in <Icon name="arrow" size={13}/></Button>
<div style={{ marginTop: 18 }}>
<Banner tone="info" title="SAML / SSO for your company?"
action={<Button size="sm" variant="ghost">Use SSO →</Button>}>
Single sign-on is available on the Pro plan.
</Banner>
</div>
</AuthSplitShell>
);
const AuthGlassDemo = () => (
<AuthGlassShell brand={{ name: "Lattice" }} eyebrow="BETA · early access">
<h1 style={{
margin: 0, fontFamily: "var(--font-display)", fontSize: 32,
fontWeight: 500, letterSpacing: "-0.03em",
}}>Start your workspace.</h1>
<p style={{ fontSize: 14, color: "var(--text-2)", margin: "10px 0 22px" }}>
Free for 10 people. No card. 60 seconds to set up.
</p>
<SocialRow/>
<Divider label="or with email"/>
<Field label="Work email"><Input value="mira@lattice.co" autofocus/></Field>
<Field label="Password" hint="10+ chars · 1 number · 1 symbol">
<Input type="password" value="" trailingIcon={<Icon name="eye" size={14}/>}/>
</Field>
<Checkbox checked label="I agree to Vibn's Terms and Privacy Policy."
style={{ margin: "4px 0 16px" }}/>
<Button full>Create my workspace <Icon name="arrow" size={13}/></Button>
</AuthGlassShell>
);
// ─── App: design canvas with all 4 themes side-by-side ──────
const W = 1300, H = 800;
const themes = ["minimal", "dark", "glass", "editorial"];
function App() {
return (
<DesignCanvas>
<DCSection
id="foundations"
title="Foundations"
subtitle="The same token surfaces, in four themes. tokens.css is the source of truth."
>
{themes.map(t => (
<DCArtboard key={t} id={`f-${t}`} label={`${t}`} width={W} height={H}>
<ThemeFrame theme={t}><Foundations theme={t}/></ThemeFrame>
</DCArtboard>
))}
</DCSection>
<DCSection
id="forms"
title="Forms & buttons"
subtitle="Button variants, every field type, controls, tabs."
>
{themes.map(t => (
<DCArtboard key={t} id={`forms-${t}`} label={`${t}`} width={W} height={H}>
<ThemeFrame theme={t}><FormAtoms/></ThemeFrame>
</DCArtboard>
))}
</DCSection>
<DCSection
id="display"
title="Display & feedback"
subtitle="Badges, avatars, banners, table, modal."
>
{themes.map(t => (
<DCArtboard key={t} id={`display-${t}`} label={`${t}`} width={W} height={H + 100}>
<ThemeFrame theme={t}><DisplayAtoms/></ThemeFrame>
</DCArtboard>
))}
</DCSection>
<DCSection
id="shells-app"
title="In-product shells · Sidebar"
subtitle="One shell, four themes."
>
{themes.map(t => (
<DCArtboard key={t} id={`side-${t}`} label={`Sidebar · ${t}`} width={W} height={H}>
<ThemeFrame theme={t}><SidebarDemo/></ThemeFrame>
</DCArtboard>
))}
</DCSection>
<DCSection
id="shells-topbar"
title="In-product shells · Topbar"
subtitle="Breadcrumb + ⌘K + tabs."
>
{themes.map(t => (
<DCArtboard key={t} id={`top-${t}`} label={`Topbar · ${t}`} width={W} height={H}>
<ThemeFrame theme={t}><TopbarDemo/></ThemeFrame>
</DCArtboard>
))}
</DCSection>
<DCSection
id="shells-rail"
title="In-product shells · Rail"
subtitle="Icon rail + secondary panel."
>
{themes.map(t => (
<DCArtboard key={t} id={`rail-${t}`} label={`Rail · ${t}`} width={W} height={H}>
<ThemeFrame theme={t}><RailDemo/></ThemeFrame>
</DCArtboard>
))}
</DCSection>
<DCSection
id="auth-centered"
title="Auth shells · Centered card"
subtitle="Sign-in card on a soft background."
>
{themes.map(t => (
<DCArtboard key={t} id={`auth-c-${t}`} label={`Centered · ${t}`} width={W} height={H}>
<ThemeFrame theme={t}><AuthCenteredDemo/></ThemeFrame>
</DCArtboard>
))}
</DCSection>
<DCSection
id="auth-split"
title="Auth shells · Split hero"
subtitle="Storytelling panel on the left, form on the right."
>
{themes.map(t => (
<DCArtboard key={t} id={`auth-s-${t}`} label={`Split · ${t}`} width={W} height={H}>
<ThemeFrame theme={t}><AuthSplitDemo/></ThemeFrame>
</DCArtboard>
))}
</DCSection>
<DCSection
id="auth-glass"
title="Auth shells · Glass card"
subtitle="Frosted card on a vibrant backdrop."
>
{themes.map(t => (
<DCArtboard key={t} id={`auth-g-${t}`} label={`Glass · ${t}`} width={W} height={H}>
<ThemeFrame theme={t}><AuthGlassDemo/></ThemeFrame>
</DCArtboard>
))}
</DCSection>
</DesignCanvas>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>

View File

@@ -0,0 +1,440 @@
// ============================================================
// app-chrome.jsx — three reusable in-product nav shells.
// Each exposes `<children>` as the main content slot so page
// bodies (Customer/Dashboard/Admin) can be dropped into any
// nav style.
//
// All three share the invented brand "Lattice" + same workspace
// name + same user, so swapping the chrome reads as one product
// in three nav layouts.
// ============================================================
// ── Tiny stroke-icon helper ─────────────────────────────────
const Icon = ({ d, size = 16, sw = 1.6 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth={sw}
strokeLinecap="round" strokeLinejoin="round">{d}</svg>
);
// Common Tabler-style paths
const P = {
search: <><circle cx="11" cy="11" r="7"/><path d="m20 20-3-3"/></>,
bell: <><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10 21a2 2 0 0 0 4 0"/></>,
home: <><path d="m3 12 9-9 9 9"/><path d="M5 10v10h14V10"/></>,
inbox: <><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5 5h14l3 7v7H2v-7z"/></>,
building: <><path d="M3 21h18M5 21V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16"/><path d="M9 7h2M13 7h2M9 11h2M13 11h2M9 15h2M13 15h2"/></>,
people: <><circle cx="9" cy="8" r="4"/><path d="M3 21a6 6 0 0 1 12 0"/><circle cx="17" cy="6" r="3"/><path d="M21 17a4 4 0 0 0-6-3.5"/></>,
target: <><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1"/></>,
check: <><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></>,
bar: <><path d="M3 3v18h18"/><rect x="7" y="11" width="3" height="7"/><rect x="13" y="7" width="3" height="11"/></>,
workflow: <><rect x="3" y="3" width="6" height="6" rx="1"/><rect x="15" y="15" width="6" height="6" rx="1"/><path d="M9 6h6a3 3 0 0 1 3 3v6"/></>,
settings: <><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8L4.2 7a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3 1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8v0a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/><circle cx="12" cy="12" r="3"/></>,
plus: <path d="M12 5v14M5 12h14"/>,
chevron: <path d="m6 9 6 6 6-6"/>,
chevR: <path d="m9 6 6 6-6 6"/>,
doc: <><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6"/></>,
hash: <path d="M9 3l-2 18M17 3l-2 18M3 9h18M2 15h18"/>,
star: <path d="m12 3 2.6 6.2 6.7.5-5.1 4.4 1.6 6.6L12 17.3 6.2 20.7l1.6-6.6L2.7 9.7l6.7-.5z"/>,
spark: <path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l3 3M15 15l3 3M6 18l3-3M15 9l3-3"/>,
more: <><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></>,
dot: <circle cx="12" cy="12" r="3"/>,
};
const SANS = "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif";
// ── Brand mark, shared ───────────────────────────────────────
const LatticeMark = ({ size = 18 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<defs>
<linearGradient id={`lg${size}`} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#6e6cff"/>
<stop offset="100%" stopColor="#b15bff"/>
</linearGradient>
</defs>
<path d="M3 20 L12 4 L21 20 Z" fill={`url(#lg${size})`}/>
</svg>
);
// ============================================================
// SHELL 1 — Sidebar (Linear/Notion/Twenty school)
// ============================================================
const navItems = [
{ id: "home", label: "Home", icon: P.home },
{ id: "inbox", label: "Inbox", icon: P.inbox, count: "12" },
{ id: "tasks", label: "My tasks", icon: P.check, count: "3" },
{ id: "_views", section: "Views" },
{ id: "companies", label: "Companies", icon: P.building },
{ id: "people", label: "People", icon: P.people },
{ id: "deals", label: "Opportunities", icon: P.target },
{ id: "_tools", section: "Tools" },
{ id: "insights", label: "Insights", icon: P.bar },
{ id: "flows", label: "Automations", icon: P.workflow },
{ id: "docs", label: "Docs", icon: P.doc },
{ id: "_admin", section: "Admin" },
{ id: "settings", label: "Settings", icon: P.settings },
];
const SidebarChrome = ({ active = "home", children }) => {
const SideItem = ({ id, label, icon, count }) => {
const isActive = id === active;
return (
<div style={{
display: "flex", alignItems: "center", gap: 10,
padding: "6px 10px", borderRadius: 6, fontSize: 13,
color: isActive ? "#111" : "#5a5a5e",
background: isActive ? "#ffffff" : "transparent",
boxShadow: isActive ? "0 1px 0 #00000008, 0 0 0 1px #00000008" : "none",
fontWeight: isActive ? 500 : 400, cursor: "pointer",
}}>
<span style={{ color: isActive ? "#5e5cff" : "#8a8a90", display: "flex" }}>
<Icon d={icon} size={15} />
</span>
<span style={{ flex: 1 }}>{label}</span>
{count && <span style={{
fontSize: 11, color: "#8a8a90", fontVariantNumeric: "tabular-nums",
}}>{count}</span>}
</div>
);
};
return (
<div style={{
width: "100%", height: "100%", display: "grid",
gridTemplateColumns: "248px 1fr",
background: "#fcfcfb", fontFamily: SANS, color: "#111",
overflow: "hidden",
}}>
<aside style={{
background: "#f5f5f2", borderRight: "1px solid #e8e8e3",
display: "flex", flexDirection: "column",
}}>
<div style={{
padding: "12px 12px", display: "flex", alignItems: "center", gap: 10,
borderBottom: "1px solid #e8e8e3",
}}>
<div style={{
width: 26, height: 26, borderRadius: 6,
background: "linear-gradient(135deg, #6e6cff 0%, #b15bff 100%)",
display: "flex", alignItems: "center", justifyContent: "center",
color: "#fff", fontWeight: 700, fontSize: 13,
}}>L</div>
<div style={{ flex: 1, lineHeight: 1.2 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>Lattice Studio</div>
<div style={{ fontSize: 11, color: "#8a8a90" }}>Free · 4 members</div>
</div>
<span style={{ color: "#8a8a90", display: "flex" }}>
<Icon d={P.chevron} size={14} />
</span>
</div>
<div style={{ padding: "10px 12px" }}>
<div style={{
display: "flex", alignItems: "center", gap: 8, padding: "6px 10px",
background: "#fff", border: "1px solid #e8e8e3", borderRadius: 6,
fontSize: 12, color: "#8a8a90",
}}>
<Icon d={P.search} size={14} />
<span style={{ flex: 1 }}>Search</span>
<span style={{
fontSize: 10, padding: "1px 5px", border: "1px solid #e0e0d8",
borderRadius: 3, fontFamily: "monospace",
}}>K</span>
</div>
</div>
<nav style={{ padding: "4px 8px", flex: 1, overflowY: "auto" }}>
{navItems.map(item => item.section ? (
<div key={item.id} style={{
fontSize: 11, color: "#8a8a90", letterSpacing: "0.04em",
padding: "14px 10px 6px", textTransform: "uppercase",
fontWeight: 500,
}}>{item.section}</div>
) : (
<SideItem key={item.id} {...item} />
))}
</nav>
<div style={{
padding: "10px 12px", borderTop: "1px solid #e8e8e3",
display: "flex", alignItems: "center", gap: 10,
}}>
<div style={{
width: 24, height: 24, borderRadius: "50%", background: "#d4b8a8",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 11, fontWeight: 600, color: "#5a3e34",
}}>MR</div>
<div style={{ flex: 1, fontSize: 12 }}>
<div style={{ fontWeight: 500 }}>Mira Reyes</div>
<div style={{ color: "#8a8a90", fontSize: 11 }}>mira@lattice.co</div>
</div>
<span style={{ color: "#8a8a90", display: "flex" }}>
<Icon d={P.chevron} size={14} />
</span>
</div>
</aside>
<main style={{ overflow: "hidden", display: "flex", flexDirection: "column" }}>
{children}
</main>
</div>
);
};
// ============================================================
// SHELL 2 — Icon rail + secondary panel (Slack/Discord/mail school)
// ============================================================
const railItems = [
{ id: "home", icon: P.home, label: "Home" },
{ id: "inbox", icon: P.inbox, label: "Inbox", badge: "9" },
{ id: "companies", icon: P.building, label: "Companies" },
{ id: "people", icon: P.people, label: "People" },
{ id: "deals", icon: P.target, label: "Opportunities", badge: "2" },
{ id: "insights", icon: P.bar, label: "Insights" },
{ id: "settings", icon: P.settings, label: "Settings" },
];
// Secondary panel content per active rail item — wrapper passes
// in `secondary` so each page can ship its own.
const RailChrome = ({ active = "home", secondary, children }) => {
const activeItem = railItems.find(r => r.id === active) || railItems[0];
const RailIcon = ({ icon, isActive, badge }) => (
<div style={{
width: 40, height: 40, borderRadius: 10,
background: isActive ? "#5e5cff" : "transparent",
color: isActive ? "#fff" : "#9a9aa6",
display: "flex", alignItems: "center", justifyContent: "center",
cursor: "pointer", position: "relative",
}}>
<Icon d={icon} size={18} sw={2} />
{badge && (
<span style={{
position: "absolute", top: -2, right: -2, minWidth: 16, height: 16,
padding: "0 4px", background: "#ff4d5e", color: "#fff",
borderRadius: 8, fontSize: 10, fontWeight: 600,
display: "flex", alignItems: "center", justifyContent: "center",
border: "2px solid #08080c",
}}>{badge}</span>
)}
</div>
);
return (
<div style={{
width: "100%", height: "100%", display: "grid",
gridTemplateColumns: "72px 260px 1fr",
background: "#0f0f14", color: "#e8e8ee", fontFamily: SANS,
overflow: "hidden",
}}>
{/* Icon rail */}
<div style={{
background: "#08080c", borderRight: "1px solid #ffffff08",
display: "flex", flexDirection: "column", alignItems: "center",
padding: "12px 0", gap: 6,
}}>
<div style={{
width: 40, height: 40, borderRadius: 10,
background: "linear-gradient(135deg, #5e5cff 0%, #b15bff 100%)",
display: "flex", alignItems: "center", justifyContent: "center",
color: "#fff", fontWeight: 800, fontSize: 16, marginBottom: 6,
}}>L</div>
<div style={{ width: 24, height: 1, background: "#ffffff10", margin: "4px 0" }}></div>
{railItems.map(r => (
<RailIcon key={r.id} icon={r.icon} isActive={r.id === active} badge={r.badge} />
))}
<div style={{ flex: 1 }}></div>
<RailIcon icon={P.spark} />
<div style={{
width: 32, height: 32, borderRadius: "50%", marginTop: 4,
background: "#d4b8a8", display: "flex", alignItems: "center",
justifyContent: "center", fontSize: 12, fontWeight: 600, color: "#5a3e34",
border: "2px solid #08080c", boxShadow: "0 0 0 2px #5e5cff",
position: "relative",
}}>MR
<span style={{
position: "absolute", bottom: -2, right: -2, width: 11, height: 11,
background: "#22c55e", borderRadius: "50%", border: "2px solid #08080c",
}}></span>
</div>
</div>
{/* Secondary panel */}
<div style={{
background: "#13131a", borderRight: "1px solid #ffffff08",
display: "flex", flexDirection: "column", overflow: "hidden",
}}>
<div style={{
padding: "16px 16px 12px", borderBottom: "1px solid #ffffff08",
}}>
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
marginBottom: 12,
}}>
<span style={{ fontSize: 15, fontWeight: 600 }}>{activeItem.label}</span>
<span style={{ color: "#9a9aa6", display: "flex" }}>
<Icon d={P.more} size={16} />
</span>
</div>
<div style={{
display: "flex", alignItems: "center", gap: 8,
padding: "7px 10px", background: "#08080c",
borderRadius: 7, fontSize: 12, color: "#9a9aa6",
}}>
<Icon d={P.search} size={13} />
<span style={{ flex: 1 }}>Jump to</span>
<span style={{
fontSize: 10, padding: "1px 5px",
background: "#ffffff08", borderRadius: 3, fontFamily: "monospace",
}}>K</span>
</div>
</div>
<div style={{ padding: "10px 8px", flex: 1, overflowY: "auto" }}>
{secondary}
</div>
</div>
{/* Content */}
<main style={{ overflow: "hidden", display: "flex", flexDirection: "column" }}>
{children}
</main>
</div>
);
};
// Convenience list item for the rail's secondary panel (dark theme)
const RailItem = ({ leading, label, sub, trailing, active }) => (
<div style={{
display: "flex", alignItems: "center", gap: 10,
padding: "8px 10px", borderRadius: 6, fontSize: 13,
color: active ? "#fff" : "#dcdce4",
background: active ? "#ffffff14" : "transparent",
cursor: "pointer",
}}>
{leading}
<div style={{ flex: 1, lineHeight: 1.2, minWidth: 0 }}>
<div style={{
fontSize: 13, fontWeight: active ? 500 : 400,
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
}}>{label}</div>
{sub && <div style={{ fontSize: 11, color: "#7a7a85", marginTop: 1 }}>{sub}</div>}
</div>
{trailing}
</div>
);
const RailSectionHeader = ({ children, action }) => (
<div style={{
fontSize: 11, color: "#6a6a78", padding: "12px 10px 4px",
textTransform: "uppercase", letterSpacing: "0.04em", fontWeight: 500,
display: "flex", justifyContent: "space-between", alignItems: "center",
}}>
<span>{children}</span>
{action}
</div>
);
// ============================================================
// SHELL 3 — Top horizontal + ⌘K (Vercel/Stripe school)
// ============================================================
const TopbarChrome = ({ tabs, activeTab, breadcrumb, children }) => {
const TabItem = ({ label, isActive }) => (
<div style={{
padding: "16px 2px", margin: "0 12px", fontSize: 13, fontWeight: 500,
color: isActive ? "#fff" : "#9a9aa6", whiteSpace: "nowrap",
borderBottom: isActive ? "2px solid #fff" : "2px solid transparent",
cursor: "pointer", position: "relative", top: 1,
}}>{label}</div>
);
return (
<div style={{
width: "100%", height: "100%", background: "#fafaf9",
color: "#111", fontFamily: SANS, display: "flex", flexDirection: "column",
overflow: "hidden",
}}>
<header style={{ background: "#0a0a0a", color: "#fff" }}>
<div style={{
display: "flex", alignItems: "center", gap: 14,
padding: "12px 24px",
}}>
<div style={{
display: "flex", alignItems: "center", gap: 8, fontWeight: 600, fontSize: 14,
}}>
<LatticeMark size={20} />
Lattice
</div>
{breadcrumb && (
<>
<span style={{ color: "#3a3a3a" }}>/</span>
<div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13 }}>
<div style={{
width: 18, height: 18, borderRadius: "50%", background: "#e8a87c",
fontSize: 9, fontWeight: 700, color: "#5a3e34",
display: "flex", alignItems: "center", justifyContent: "center",
}}>MR</div>
<span>mira-reyes</span>
<span style={{ color: "#5a5a5e", display: "flex" }}><Icon d={P.chevron} size={12}/></span>
</div>
<span style={{ color: "#3a3a3a" }}>/</span>
<div style={{ fontSize: 13, display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ whiteSpace: "nowrap" }}>{breadcrumb}</span>
</div>
</>
)}
<div style={{ flex: 1 }}></div>
<div style={{
display: "flex", alignItems: "center", gap: 10,
padding: "6px 12px", borderRadius: 8,
background: "#1a1a1a", border: "1px solid #2a2a2a",
color: "#9a9aa6", fontSize: 12, minWidth: 280,
}}>
<Icon d={P.search} size={13} />
<span style={{ flex: 1 }}>Find or jump to anything</span>
<span style={{
fontSize: 10, padding: "1px 5px", background: "#2a2a2a",
borderRadius: 3, fontFamily: "monospace",
}}>K</span>
</div>
<button style={{
background: "transparent", border: "1px solid #2a2a2a",
color: "#fff", padding: "5px 12px", borderRadius: 6,
fontSize: 12, fontFamily: SANS, cursor: "pointer", whiteSpace: "nowrap",
}}>Feedback</button>
<span style={{ color: "#9a9aa6", display: "flex", cursor: "pointer", position: "relative" }}>
<Icon d={P.bell} size={16}/>
<span style={{
position: "absolute", top: -2, right: -2, width: 7, height: 7,
background: "#5e5cff", borderRadius: "50%",
}}></span>
</span>
<div style={{
width: 26, height: 26, borderRadius: "50%", background: "#d4b8a8",
fontSize: 11, fontWeight: 600, color: "#5a3e34",
display: "flex", alignItems: "center", justifyContent: "center",
cursor: "pointer",
}}>MR</div>
</div>
<div style={{
display: "flex", alignItems: "center",
padding: "0 16px", borderBottom: "1px solid #1a1a1a",
overflowX: "auto",
}}>
{(tabs || []).map(t => (
<TabItem key={t} label={t} isActive={t === activeTab} />
))}
</div>
</header>
<div style={{ flex: 1, overflow: "hidden" }}>{children}</div>
</div>
);
};
Object.assign(window, {
Icon, P, SANS, LatticeMark,
SidebarChrome, RailChrome, RailItem, RailSectionHeader, TopbarChrome,
});

View File

@@ -0,0 +1,230 @@
// App — composes the page. Includes the sticky nav, the success modal that
// appears when the user submits the hero prompt, and the Tweaks panel.
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"accent": ["#ff6b47", "#ffae9a", "#9c3a1f"],
"heroVariant": "owner",
"showStopMarker": true,
"showLivePill": false
}/*EDITMODE-END*/;
const ACCENT_PRESETS = {
coral: ["#ff6b47", "#ffae9a", "#9c3a1f"], // warm coral (default)
amber: ["#ffb347", "#ffd9a3", "#9c6e1f"], // soft amber
lime: ["#9ee649", "#d2f3a6", "#3f7a1c"], // electric lime
violet: ["#b07cff", "#dabfff", "#5a2fa3"], // violet
};
function applyAccent(arr) {
// arr[0] is the hero color we map to var(--accent); compute soft + glow + fg.
const hero = arr[0];
const soft = `${hero}24`; // 14% alpha
const glow = `${hero}59`; // 35% alpha
const root = document.documentElement;
root.style.setProperty("--accent", hero);
root.style.setProperty("--accent-soft", soft);
root.style.setProperty("--accent-glow", glow);
// Foreground on accent: derive a dark-on-accent for primary buttons.
root.style.setProperty("--accent-fg", "#1a0f0a");
}
function App() {
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
const [scrolled, setScrolled] = React.useState(false);
const [showLaunch, setShowLaunch] = React.useState(null);
React.useEffect(() => {
applyAccent(t.accent);
}, [t.accent]);
React.useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 8);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
const handleStart = (prompt) => {
setShowLaunch(prompt || "Build me a tool for my business.");
};
return (
<>
<Nav scrolled={scrolled} />
<main>
<Hero onStart={handleStart} variant={t.heroVariant} />
<Wall />
<CrossedOut />
<Stack />
<Journey />
<Audience />
<Mission />
<Closing />
</main>
<Footer />
{showLaunch !== null && (
<LaunchModal prompt={showLaunch} onClose={() => setShowLaunch(null)} />
)}
<TweaksPanel title="Tweaks">
<TweakSection label="Look">
<TweakColor
label="Accent"
value={t.accent}
options={[
ACCENT_PRESETS.coral,
ACCENT_PRESETS.amber,
ACCENT_PRESETS.lime,
ACCENT_PRESETS.violet,
]}
onChange={(v) => setTweak("accent", v)}
/>
</TweakSection>
<TweakSection label="Hero">
<TweakSelect
label="Headline"
value={t.heroVariant}
options={[
{ value: "owner", label: "Owner — 'Stop paying rent'" },
{ value: "promise", label: "Promise — 'Keep vibing'" },
{ value: "quote", label: "Quote — 'I built my product…'" },
]}
onChange={(v) => setTweak("heroVariant", v)}
/>
<TweakToggle
label="Live pill"
value={t.showLivePill}
onChange={(v) => setTweak("showLivePill", v)}
/>
</TweakSection>
<TweakSection label="Journey">
<TweakToggle
label="Show 'where others stop' marker"
value={t.showStopMarker}
onChange={(v) => setTweak("showStopMarker", v)}
/>
</TweakSection>
</TweaksPanel>
{/* Tweak-driven CSS overrides */}
<style>{`
${t.showLivePill ? "" : ".live-pill { display: none !important; }"}
${t.showStopMarker ? "" : ".stop-marker { display: none !important; }"}
`}</style>
</>
);
}
function Nav({ scrolled }) {
return (
<nav className={`nav${scrolled ? " scrolled" : ""}`}>
<div className="wrap nav-inner">
<Logo />
<div className="nav-links">
<a href="#how">How it works</a>
<a href="#">Templates</a>
<a href="#">Pricing</a>
<a href="#">Stories</a>
</div>
<div className="nav-cta">
<a href="Sign In.html" className="btn btn-ghost" style={{ display: "inline-flex" }}>Sign in</a>
<a href="Beta Signup.html" className="btn btn-primary">
Request invite <Arrow size={12} />
</a>
</div>
</div>
</nav>
);
}
// Modal that fires when the user submits the hero prompt. Reassures them their
// vibe will be honored — playfully sells the rest of the flow.
function LaunchModal({ prompt, onClose }) {
React.useEffect(() => {
const onKey = (e) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const [step, setStep] = React.useState(0);
React.useEffect(() => {
if (step >= 4) return undefined;
const t = setTimeout(() => setStep(step + 1), 700);
return () => clearTimeout(t);
}, [step]);
return (
<div className="modal-backdrop" onClick={onClose}>
<style>{`
.modal-backdrop {
position: fixed; inset: 0; z-index: 100;
background: oklch(0.10 0.005 60 / 0.7);
backdrop-filter: blur(8px);
display: grid; place-items: center;
padding: 24px;
animation: fadein .2s ease;
}
@keyframes fadein { from { opacity: 0; } }
.modal {
position: relative;
width: 100%; max-width: 540px;
background: linear-gradient(180deg, oklch(0.20 0.009 60), oklch(0.17 0.008 60));
border: 1px solid var(--hairline-2);
border-radius: 20px;
padding: 28px 28px 24px;
box-shadow: 0 30px 80px -20px oklch(0 0 0 / 0.6), 0 0 60px -20px var(--accent-glow);
}
.modal-close {
position: absolute; top: 14px; right: 14px;
width: 28px; height: 28px;
color: var(--fg-mute);
border-radius: 6px;
}
.modal-close:hover { color: var(--fg); background: oklch(0.25 0.01 60); }
.modal-eye { display: flex; align-items: center; gap: 10px; color: var(--accent); font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; }
.modal-eye i { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 12px var(--accent-glow); animation: pulse 2s ease-out infinite; }
.modal-title { margin-top: 12px; font-size: 24px; font-weight: 500; letter-spacing: -0.018em; line-height: 1.15; }
.modal-prompt { margin-top: 14px; padding: 12px 14px; border-radius: 10px; background: oklch(0.16 0.008 60); border: 1px solid var(--hairline); font-family: var(--font-mono); font-size: 13px; color: var(--fg-dim); line-height: 1.5; }
.modal-steps { margin-top: 18px; display: flex; flex-direction: column; gap: 10px; }
.modal-step { display: flex; align-items: center; gap: 12px; padding: 11px 14px; border-radius: 10px; background: oklch(0.165 0.008 60); border: 1px solid var(--hairline); font-size: 14px; color: var(--fg-dim); transition: all .25s; }
.modal-step.done { color: var(--fg); }
.modal-step.done .check { color: var(--ok); }
.modal-step .check { width: 18px; height: 18px; color: var(--fg-faint); flex-shrink: 0; }
.modal-step .spinner { width: 14px; height: 14px; border-radius: 50%; border: 2px solid oklch(0.30 0.01 60); border-top-color: var(--accent); animation: spin .9s linear infinite; flex-shrink: 0; }
@keyframes spin { to { transform: rotate(360deg); } }
.modal-foot { margin-top: 18px; text-align: center; font-family: var(--font-mono); font-size: 11px; color: var(--fg-faint); letter-spacing: 0.04em; }
`}</style>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<button type="button" className="modal-close" onClick={onClose}></button>
<div className="modal-eye"><i /> Vibn is on it</div>
<h3 className="modal-title">Keep vibing we've got the rest.</h3>
<div className="modal-prompt">"{prompt}"</div>
<div className="modal-steps">
{["Drafting the screens", "Setting up logins", "Saving your stuff", "Putting it online"].map((s, i) => (
<div key={s} className={`modal-step${i < step ? " done" : ""}`}>
{i < step ? (
<svg className="check" viewBox="0 0 20 20" fill="none">
<path d="M4 10.5 8 14.5 16 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
) : i === step ? (
<span className="spinner" />
) : (
<svg className="check" viewBox="0 0 20 20" fill="none">
<circle cx="10" cy="10" r="6" stroke="currentColor" strokeWidth="1.5" />
</svg>
)}
<span>{s}</span>
</div>
))}
</div>
<div className="modal-foot">No homework · No setup · No new tools to learn</div>
</div>
</div>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<App />);

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,190 @@
// Who it's for — three audience cards, each with a Reddit-style customer quote
// and Vibn's answer.
const AUDIENCE = [
{
label: "Small business owners",
icon: "shop",
headline: "Stop renting. Build the tool that actually fits.",
quote: "I'm paying $312/month for software that does 60% of what I need and zero of the rest.",
source: "u/coffeeshop_owner · r/smallbusiness",
answer: "Replace the whole stack with one tool that matches your workflow — bookings, customers, invoicing, all in one place. Owned by you.",
},
{
label: "Freelancers & local builders",
icon: "spark",
headline: "Become the craftsman who builds for businesses in your town.",
quote: "My client wants a quote tool. I can mock the frontend in a day. The backend? Two weeks I don't have.",
source: "u/agency_of_one · r/freelance",
answer: "Deliver the whole thing — login, data, hosting, polish — in the same chat where you built the screens. Bill for the system, not the hours.",
},
{
label: "Quiet entrepreneurs",
icon: "spark2",
headline: "Build a business without ever picking up the phone.",
quote: "I want to build my thing, ship my thing, and find my customers — without doing sales calls or talking to a developer.",
source: "u/asynchronous_human · r/indiehackers",
answer: "No deploys. No GitHub. No cold outreach. The thing you described is online, with logins, marketing on autopilot — ready for the right people to find it.",
},
];
function Audience() {
return (
<section className="section audience">
<style>{`
.audience-head { text-align: center; max-width: 820px; margin: 0 auto 56px; }
.audience-title {
font-size: clamp(36px, 4.8vw, 64px);
font-weight: 500; letter-spacing: -0.025em; line-height: 1.02;
text-wrap: balance;
}
.audience-sub {
margin-top: 20px;
color: var(--fg-mute);
font-size: 17px;
}
.audience-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18px;
}
@media (max-width: 1000px) { .audience-grid { grid-template-columns: 1fr; } }
.a-card {
position: relative;
padding: 28px 26px 26px;
border-radius: 18px;
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);
display: flex; flex-direction: column;
min-height: 380px;
overflow: hidden;
}
.a-card::after {
content: "";
position: absolute;
top: 0; left: 24px; right: 24px;
height: 1px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
opacity: .6;
}
.a-icon {
width: 40px; height: 40px;
border-radius: 10px;
display: grid; place-items: center;
background: oklch(0.22 0.011 60);
border: 1px solid var(--hairline);
color: var(--accent);
margin-bottom: 18px;
}
.a-label {
font-size: 19px; font-weight: 500;
letter-spacing: -0.015em;
color: var(--fg);
}
.a-headline {
margin: 8px 0 0;
color: var(--accent);
font-size: 15px;
line-height: 1.4;
letter-spacing: -0.005em;
font-weight: 500;
text-wrap: balance;
}
.a-quote {
margin: 18px 0 0;
padding: 16px 18px;
background: oklch(0.16 0.008 60 / 0.55);
border-left: 2px solid var(--accent);
border-radius: 4px 10px 10px 4px;
font-style: italic;
color: var(--fg-dim);
font-size: 14.5px;
line-height: 1.5;
position: relative;
}
.a-source {
margin-top: 8px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-faint);
letter-spacing: 0.02em;
}
.a-answer {
margin-top: auto;
padding-top: 22px;
font-size: 15px;
color: var(--fg);
line-height: 1.5;
display: flex; gap: 10px; align-items: flex-start;
}
.a-answer .label {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
padding: 3px 7px;
background: oklch(0.74 0.175 35 / 0.12);
border: 1px solid oklch(0.74 0.175 35 / 0.4);
border-radius: 4px;
margin-top: 1px;
flex-shrink: 0;
}
`}</style>
<div className="wrap">
<div className="audience-head">
<Eyebrow>Who Vibn is for</Eyebrow>
<h2 className="audience-title" style={{ marginTop: 18 }}>
People who have an idea not a stack.
</h2>
<p className="audience-sub">
Three people who feel the same thing different ways to fix it.
</p>
</div>
<div className="audience-grid">
{AUDIENCE.map((a) => (
<div className="a-card" key={a.label}>
<div className="a-icon"><AudienceIcon name={a.icon} /></div>
<div className="a-label">{a.label}</div>
<p className="a-headline">{a.headline}</p>
<div className="a-quote">
"{a.quote}"
<div className="a-source"> {a.source}</div>
</div>
<div className="a-answer">
<span className="label">Vibn</span>
<span>{a.answer}</span>
</div>
</div>
))}
</div>
</div>
</section>
);
}
function AudienceIcon({ name }) {
const p = { width: 20, height: 20, viewBox: "0 0 20 20", fill: "none",
stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round" };
if (name === "shop") return (
<svg {...p}><path d="M3.5 6.5h13l-1 9.5h-11l-1-9.5Z"/><path d="M7 6.5V5a3 3 0 0 1 6 0v1.5"/></svg>
);
if (name === "spark") return (
<svg {...p}><path d="M10 3v4M10 13v4M3 10h4M13 10h4M5.3 5.3l2.8 2.8M11.9 11.9l2.8 2.8M14.7 5.3l-2.8 2.8M8.1 11.9l-2.8 2.8"/></svg>
);
if (name === "spark2") return (
<svg {...p}><path d="M10 2.5v3M10 14.5v3M2.5 10h3M14.5 10h3"/><circle cx="10" cy="10" r="3"/></svg>
);
return null;
}
Object.assign(window, { Audience });

View File

@@ -0,0 +1,123 @@
// Shared auth-page primitives. Both Sign In and Sign Up use these.
function Logo({ size = 26 }) {
return (
<a href="index.html" className="logo">
<span className="logo-mark" style={{ width: size, height: size }}>
<svg viewBox="0 0 36 32" width="74%" height="74%" fill="currentColor" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" aria-hidden="true">
<path d="M4 5 L10 5 L12 18 L14 5 L20 5 L12 27 Z" />
<rect x="22.5" y="23" width="9.5" height="3.8" rx="0.7" className="logo-caret" />
</svg>
</span>
<span>vibn</span>
</a>
);
}
function TopBar({ rightLink }) {
return (
<header className="topbar">
<Logo />
{rightLink && (
<a href={rightLink.href} className="topbar-back">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M13 8H3M7 4 3 8l4 4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
{rightLink.label}
</a>
)}
</header>
);
}
function Glows() {
return (
<>
<div className="auth-glow" style={{
width: 700, height: 700,
top: -150, left: "50%", transform: "translateX(-50%)",
background: "radial-gradient(circle at center, oklch(0.74 0.175 35 / 0.22) 0%, transparent 62%)",
}} />
<div className="auth-glow" style={{
width: 500, height: 500,
bottom: -100, left: 0,
background: "radial-gradient(circle at center, oklch(0.45 0.10 35 / 0.20) 0%, transparent 62%)",
}} />
<div className="auth-glow" style={{
width: 450, height: 450,
top: "50%", right: -150,
background: "radial-gradient(circle at center, oklch(0.45 0.10 35 / 0.15) 0%, transparent 62%)",
}} />
</>
);
}
function Arrow({ size = 14 }) {
return (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
}
// Google "G" mark — inline SVG so we don't need to bundle an asset.
function GoogleIcon({ size = 16 }) {
return (
<svg width={size} height={size} viewBox="0 0 18 18" aria-hidden="true">
<path fill="#EA4335" d="M9 3.6c1.3 0 2.5.5 3.4 1.3l2.5-2.5C13.4 1 11.3.1 9 .1 5.5.1 2.4 2.1.9 5.1l2.9 2.3C4.5 5.2 6.6 3.6 9 3.6Z"/>
<path fill="#34A853" d="M17.6 9.2c0-.6-.1-1.2-.2-1.8H9v3.4h4.9c-.2 1.1-.9 2-1.9 2.6l2.9 2.3c1.7-1.6 2.7-3.9 2.7-6.5Z"/>
<path fill="#FBBC05" d="M3.8 10.7c-.2-.6-.3-1.1-.3-1.7s.1-1.2.3-1.7L.9 5C.3 6.2 0 7.5 0 9s.3 2.8.9 4l2.9-2.3Z"/>
<path fill="#4285F4" d="M9 17.9c2.4 0 4.4-.8 5.9-2.2l-2.9-2.3c-.8.5-1.8.9-3 .9-2.3 0-4.3-1.6-5-3.7L1.1 12.9C2.6 15.9 5.6 17.9 9 17.9Z"/>
</svg>
);
}
// Apple mark (filled apple silhouette)
function AppleIcon({ size = 16 }) {
return (
<svg width={size} height={size} viewBox="0 0 18 18" fill="currentColor" aria-hidden="true">
<path d="M14.7 13.1c-.4 1-1 1.9-1.7 2.5-.5.5-1.1.7-1.7.7-.6 0-1-.2-1.7-.5-.7-.3-1.3-.5-1.8-.5s-1.2.2-1.9.5c-.7.3-1.2.4-1.6.5-.6 0-1.2-.2-1.7-.7C2 14.8 1.4 13.6.9 12c-.5-1.7-.7-3.3-.6-4.7.1-1.6.7-2.9 1.7-3.9C2.8 2.7 3.8 2.2 5 2.2c.4 0 .9.1 1.5.4s1 .4 1.2.4c.2 0 .7-.1 1.4-.4.7-.3 1.3-.4 1.7-.4 1 .1 1.9.5 2.6 1.3-.9.6-1.4 1.5-1.4 2.6 0 .9.3 1.6 1 2.2.3.3.6.5 1 .6-.1.2-.2.5-.3.7Zm-3-12c0 .8-.3 1.6-.9 2.4-.7.9-1.6 1.5-2.6 1.4 0-.1 0-.2 0-.3 0-.8.3-1.6.9-2.3.3-.4.7-.7 1.1-.9.4-.2.9-.4 1.4-.4 0 .1 0 .1.1.1Z"/>
</svg>
);
}
function MailIcon({ size = 18 }) {
return (
<svg width={size} height={size} viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<rect x="2.5" y="4.5" width="15" height="11" rx="1.5"/>
<path d="M3.5 6 10 11l6.5-5"/>
</svg>
);
}
function TrustStrip({ items }) {
return (
<div className="auth-trust">
{items.map((item, i) => (
<React.Fragment key={i}>
{i > 0 && <span className="sep">·</span>}
<span>{item}</span>
</React.Fragment>
))}
</div>
);
}
// useResendTimer — manages a countdown for the "Resend in 30s" CTA after the
// magic-link confirmation state. Returns [remaining, restart].
function useResendTimer(initialSeconds = 30) {
const [left, setLeft] = React.useState(initialSeconds);
React.useEffect(() => {
if (left <= 0) return undefined;
const t = setTimeout(() => setLeft(left - 1), 1000);
return () => clearTimeout(t);
}, [left]);
const restart = () => setLeft(initialSeconds);
return [left, restart];
}
Object.assign(window, {
Logo, TopBar, Glows, Arrow,
GoogleIcon, AppleIcon, MailIcon,
TrustStrip, useResendTimer,
});

View File

@@ -0,0 +1,431 @@
// ============================================================
// auth-screens.jsx — Sign-in / Sign-up / Onboarding for the
// Lattice brand, in three aesthetic directions that match the
// three nav styles from the prior file:
//
// A · Light minimal ← Sidebar / Notion school
// B · Dark split-hero ← Topbar / Vercel school
// C · Glass aurora ← Floating-pill marketing school
//
// Each style ships all three screens. Shared <LatticeMark>,
// <SocialBtn>, <Field> components keep the family resemblance.
// ============================================================
// ── Shared atoms ────────────────────────────────────────────
// Branded "G" / "MS" social logos drawn inline as little glyphs
// so there are no missing-asset placeholders. They're recognizable
// without using actual brand marks.
const GoogleGlyph = () => (
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true">
<path fill="#4285F4" d="M21.6 12.2c0-.7-.1-1.4-.2-2H12v3.8h5.4c-.2 1.2-.9 2.2-2 2.9v2.4h3.2c1.9-1.7 3-4.3 3-7.1z"/>
<path fill="#34A853" d="M12 22c2.7 0 5-.9 6.6-2.4l-3.2-2.4c-.9.6-2 1-3.4 1-2.6 0-4.8-1.7-5.6-4.1H3.1v2.5C4.8 19.8 8.2 22 12 22z"/>
<path fill="#FBBC05" d="M6.4 14.1c-.2-.6-.3-1.3-.3-2s.1-1.4.3-2V7.6H3.1C2.4 9 2 10.4 2 12s.4 3 1.1 4.4l3.3-2.3z"/>
<path fill="#EA4335" d="M12 6c1.5 0 2.8.5 3.8 1.5l2.9-2.9C16.9 3.1 14.7 2 12 2 8.2 2 4.8 4.2 3.1 7.6l3.3 2.5C7.2 7.7 9.4 6 12 6z"/>
</svg>
);
const MicrosoftGlyph = () => (
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true">
<rect x="1" y="1" width="10" height="10" fill="#F25022"/>
<rect x="13" y="1" width="10" height="10" fill="#7FBA00"/>
<rect x="1" y="13" width="10" height="10" fill="#00A4EF"/>
<rect x="13" y="13" width="10" height="10" fill="#FFB900"/>
</svg>
);
const AppleGlyph = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M16.4 12.7c0-2.6 2.1-3.8 2.2-3.9-1.2-1.7-3-2-3.6-2-1.5-.2-3 .9-3.8.9-.8 0-2-.9-3.3-.9-1.7 0-3.3 1-4.2 2.6-1.8 3.1-.5 7.7 1.3 10.2.9 1.2 1.9 2.6 3.2 2.5 1.3-.1 1.8-.8 3.4-.8 1.6 0 2 .8 3.4.8 1.4 0 2.3-1.2 3.1-2.5.7-1 1.1-2 1.4-3-2.6-1-3.1-3.7-3.1-3.9zM13.5 5c.7-.9 1.2-2.1 1.1-3.4-1 .1-2.3.7-3 1.6-.7.8-1.3 2-1.1 3.2 1.2.1 2.3-.6 3-1.4z"/>
</svg>
);
const sansAuth = "'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif";
// Tiny stroke icon helper (re-defining locally so this file is standalone)
const Icn = ({ d, size = 16, sw = 1.6 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth={sw}
strokeLinecap="round" strokeLinejoin="round">{d}</svg>
);
const Pa = {
eye: <><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></>,
check: <path d="M5 12l5 5L20 7"/>,
arrow: <path d="M5 12h14M13 5l7 7-7 7"/>,
chevR: <path d="m9 6 6 6-6 6"/>,
chevL: <path d="m15 6-6 6 6 6"/>,
star: <path d="m12 3 2.6 6.2 6.7.5-5.1 4.4 1.6 6.6L12 17.3 6.2 20.7l1.6-6.6L2.7 9.7l6.7-.5z"/>,
spark: <path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l3 3M15 15l3 3M6 18l3-3M15 9l3-3"/>,
bolt: <path d="m13 2-9 13h7l-1 7 9-13h-7z"/>,
shield: <path d="M12 2 4 5v7c0 5 3.5 9 8 10 4.5-1 8-5 8-10V5z"/>,
briefcase: <><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M3 13h18"/></>,
};
// Brand mark (gradient triangle), shared
const Mark = ({ size = 20, mono }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
{mono ? (
<path d="M3 20 L12 4 L21 20 Z" fill="currentColor"/>
) : (
<>
<defs>
<linearGradient id={`mk${size}`} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#6e6cff"/>
<stop offset="100%" stopColor="#b15bff"/>
</linearGradient>
</defs>
<path d="M3 20 L12 4 L21 20 Z" fill={`url(#mk${size})`}/>
</>
)}
</svg>
);
// ============================================================
// STYLE A — LIGHT MINIMAL (Notion / Linear school)
// Centered card on warm neutral, no flourish, lots of air.
// ============================================================
const a = {
bg: "#f5f5f2", surface: "#ffffff",
border: "#e8e8e3", borderStrong: "#d8d8d2",
text: "#111", subtext: "#5a5a5e", muted: "#8a8a90",
accent: "#5e5cff", accentText: "#fff",
};
const fontA = sansAuth;
const AFieldLabel = ({ children, optional }) => (
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "baseline",
fontSize: 12, fontWeight: 500, color: a.text, marginBottom: 6,
}}>
<span>{children}</span>
{optional && <span style={{ color: a.muted, fontWeight: 400 }}>optional</span>}
</div>
);
const AField = ({ label, value, placeholder, hint, type = "text", icon, optional }) => (
<div style={{ marginBottom: 14 }}>
{label && <AFieldLabel optional={optional}>{label}</AFieldLabel>}
<div style={{
display: "flex", alignItems: "center", gap: 8,
padding: "10px 12px", borderRadius: 7,
background: "#fff", border: `1px solid ${a.border}`,
fontSize: 13, color: value ? a.text : a.muted,
boxShadow: "0 1px 0 #00000004",
}}>
{icon && <span style={{ color: a.muted, display: "flex" }}>{icon}</span>}
<span style={{ flex: 1 }}>{value || placeholder}</span>
{type === "password" && <span style={{ color: a.muted, display: "flex" }}>
<Icn d={Pa.eye} size={14} /></span>}
</div>
{hint && <div style={{ fontSize: 11, color: a.muted, marginTop: 5 }}>{hint}</div>}
</div>
);
const ASocial = ({ children, glyph }) => (
<button style={{
flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
padding: "10px 12px", borderRadius: 7, background: "#fff",
border: `1px solid ${a.border}`, color: a.text, fontSize: 13,
fontFamily: fontA, fontWeight: 500, cursor: "pointer",
}}>
{glyph}
<span>{children}</span>
</button>
);
const APrimary = ({ children, full = true }) => (
<button style={{
width: full ? "100%" : "auto",
padding: "11px 18px", borderRadius: 7,
background: "#111", color: "#fff", border: "none",
fontSize: 13, fontWeight: 500, fontFamily: fontA, cursor: "pointer",
display: "flex", alignItems: "center", justifyContent: "center", gap: 6,
}}>{children}</button>
);
const ACardShell = ({ children, foot }) => (
<div style={{
width: "100%", height: "100%", background: a.bg,
color: a.text, fontFamily: fontA,
display: "grid", gridTemplateRows: "auto 1fr auto",
}}>
{/* Top bar: brand on left, support on right */}
<header style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "20px 28px",
}}>
<div style={{ display: "flex", alignItems: "center", gap: 8, fontWeight: 600, fontSize: 14 }}>
<Mark size={20} />
Lattice
</div>
<div style={{ fontSize: 12, color: a.subtext, display: "flex", gap: 18 }}>
<span>Status</span>
<span>Docs</span>
<span>Sign in </span>
</div>
</header>
{/* Centered card */}
<main style={{ display: "flex", alignItems: "center", justifyContent: "center", padding: 24 }}>
<div style={{
width: 420, padding: "32px 36px", borderRadius: 12,
background: a.surface, border: `1px solid ${a.border}`,
boxShadow: "0 1px 2px #0000000a, 0 8px 32px -12px #0000000f",
}}>
{children}
</div>
</main>
{/* Footer band */}
<footer style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "16px 28px", fontSize: 11, color: a.muted,
}}>
<span>© 2026 Lattice Studio · Made in Copenhagen</span>
<div style={{ display: "flex", gap: 16 }}>
<span>Privacy</span><span>Terms</span><span>Security</span>
</div>
</footer>
</div>
);
const ASignIn = () => (
<ACardShell>
<h1 style={{ fontSize: 22, fontWeight: 600, margin: 0, letterSpacing: "-0.01em" }}>
Welcome back
</h1>
<p style={{ fontSize: 13, color: a.subtext, margin: "6px 0 22px" }}>
Sign in to your Lattice workspace.
</p>
<div style={{ display: "flex", gap: 8, marginBottom: 18 }}>
<ASocial glyph={<GoogleGlyph/>}>Google</ASocial>
<ASocial glyph={<MicrosoftGlyph/>}>Microsoft</ASocial>
<ASocial glyph={<span style={{ color: a.text, display: "flex" }}><AppleGlyph/></span>}>Apple</ASocial>
</div>
<div style={{
display: "flex", alignItems: "center", gap: 10,
fontSize: 11, color: a.muted, margin: "0 0 18px",
}}>
<div style={{ flex: 1, height: 1, background: a.border }}></div>
<span style={{ textTransform: "uppercase", letterSpacing: "0.08em" }}>or with email</span>
<div style={{ flex: 1, height: 1, background: a.border }}></div>
</div>
<AField label="Email" value="mira@lattice.co" />
<div style={{ marginBottom: 14 }}>
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "baseline",
fontSize: 12, fontWeight: 500, color: a.text, marginBottom: 6,
}}>
<span>Password</span>
<span style={{ color: a.accent, cursor: "pointer", fontWeight: 400 }}>Forgot?</span>
</div>
<div style={{
display: "flex", alignItems: "center", gap: 8,
padding: "10px 12px", borderRadius: 7, background: "#fff",
border: `1px solid ${a.border}`, fontSize: 13, color: a.text,
letterSpacing: "0.2em",
}}>
<span style={{ flex: 1 }}></span>
<span style={{ color: a.muted, display: "flex" }}><Icn d={Pa.eye} size={14}/></span>
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 18 }}>
<div style={{
width: 14, height: 14, borderRadius: 3, background: "#111",
display: "flex", alignItems: "center", justifyContent: "center",
color: "#fff",
}}><Icn d={Pa.check} size={10} sw={2.4}/></div>
<span style={{ fontSize: 12, color: a.subtext }}>Keep me signed in for 30 days</span>
</div>
<APrimary>Sign in </APrimary>
<div style={{ fontSize: 12, color: a.subtext, marginTop: 18, textAlign: "center" }}>
New here? <span style={{ color: a.text, fontWeight: 500, cursor: "pointer" }}>
Create an account
</span>
</div>
</ACardShell>
);
const ASignUp = () => (
<ACardShell>
<h1 style={{ fontSize: 22, fontWeight: 600, margin: 0, letterSpacing: "-0.01em" }}>
Create your workspace
</h1>
<p style={{ fontSize: 13, color: a.subtext, margin: "6px 0 22px" }}>
Free for up to 10 people. No card needed.
</p>
<div style={{ display: "flex", gap: 8, marginBottom: 18 }}>
<ASocial glyph={<GoogleGlyph/>}>Continue with Google</ASocial>
<ASocial glyph={<MicrosoftGlyph/>}>Microsoft</ASocial>
</div>
<div style={{
display: "flex", alignItems: "center", gap: 10,
fontSize: 11, color: a.muted, margin: "0 0 18px",
}}>
<div style={{ flex: 1, height: 1, background: a.border }}></div>
<span style={{ textTransform: "uppercase", letterSpacing: "0.08em" }}>or with email</span>
<div style={{ flex: 1, height: 1, background: a.border }}></div>
</div>
<AField label="Full name" placeholder="Mira Reyes" />
<AField label="Work email" value="mira@lattice.co"
hint="We'll send a 6-digit code to confirm." />
<AField label="Password" value="••••••••••" type="password"
hint="At least 10 characters, including a number." />
<div style={{ display: "flex", alignItems: "flex-start", gap: 8, margin: "4px 0 18px" }}>
<div style={{
width: 14, height: 14, borderRadius: 3, marginTop: 2,
background: "#fff", border: `1px solid ${a.borderStrong}`,
}}></div>
<span style={{ fontSize: 12, color: a.subtext, lineHeight: 1.5 }}>
I agree to Lattice's <span style={{ color: a.text, fontWeight: 500 }}>Terms</span> and{" "}
<span style={{ color: a.text, fontWeight: 500 }}>Privacy Policy</span>.
</span>
</div>
<APrimary>Create workspace →</APrimary>
<div style={{ fontSize: 12, color: a.subtext, marginTop: 18, textAlign: "center" }}>
Already have one? <span style={{ color: a.text, fontWeight: 500, cursor: "pointer" }}>
Sign in
</span>
</div>
</ACardShell>
);
const AOnboarding = () => {
const Step = ({ n, label, state }) => (
<div style={{ display: "flex", alignItems: "center", gap: 8, flex: 1, minWidth: 0 }}>
<div style={{
width: 22, height: 22, borderRadius: "50%",
background: state === "done" ? "#22c55e" : state === "active" ? "#111" : "transparent",
color: state === "todo" ? a.muted : "#fff",
border: state === "todo" ? `1px solid ${a.borderStrong}` : "none",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 11, fontWeight: 600, flexShrink: 0,
}}>{state === "done" ? <Icn d={Pa.check} size={12} sw={2.4} /> : n}</div>
<div style={{ fontSize: 12, color: state === "todo" ? a.muted : a.text,
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{label}</div>
</div>
);
const Tile = ({ title, sub, selected, icon }) => (
<div style={{
padding: 14, borderRadius: 8, cursor: "pointer", textAlign: "left",
border: selected ? `1.5px solid ${a.accent}` : `1px solid ${a.border}`,
background: selected ? "#f6f5ff" : "#fff",
boxShadow: selected ? `0 0 0 3px ${a.accent}1a` : "0 1px 0 #00000004",
}}>
<div style={{
width: 28, height: 28, borderRadius: 7, marginBottom: 10,
background: selected ? a.accent : "#f1f0eb",
color: selected ? "#fff" : a.subtext,
display: "flex", alignItems: "center", justifyContent: "center",
}}>{icon}</div>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 2 }}>{title}</div>
<div style={{ fontSize: 11, color: a.muted, lineHeight: 1.4 }}>{sub}</div>
</div>
);
return (
<div style={{
width: "100%", height: "100%", background: a.bg, color: a.text,
fontFamily: fontA, display: "grid", gridTemplateRows: "auto 1fr auto",
}}>
<header style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "20px 28px",
}}>
<div style={{ display: "flex", alignItems: "center", gap: 8, fontWeight: 600, fontSize: 14 }}>
<Mark size={20} /> Lattice
</div>
<div style={{ fontSize: 12, color: a.subtext }}>Step 2 of 4 · ⌘. to skip</div>
</header>
<main style={{
padding: "12px 28px 28px",
display: "flex", flexDirection: "column", alignItems: "center",
}}>
<div style={{
width: 640, padding: "30px 36px 36px", borderRadius: 14,
background: a.surface, border: `1px solid ${a.border}`,
boxShadow: "0 1px 2px #0000000a, 0 8px 32px -12px #0000000f",
}}>
{/* Stepper */}
<div style={{ display: "flex", alignItems: "center", gap: 4, marginBottom: 22 }}>
<Step n="1" label="Account" state="done" />
<div style={{ flex: 1, height: 1, background: a.border, margin: "0 6px" }}></div>
<Step n="2" label="Workspace" state="active" />
<div style={{ flex: 1, height: 1, background: a.border, margin: "0 6px" }}></div>
<Step n="3" label="Invite team" state="todo" />
<div style={{ flex: 1, height: 1, background: a.border, margin: "0 6px" }}></div>
<Step n="4" label="Import" state="todo" />
</div>
<h1 style={{ fontSize: 24, fontWeight: 600, margin: 0, letterSpacing: "-0.02em" }}>
Tell us about your work
</h1>
<p style={{ fontSize: 13, color: a.subtext, margin: "6px 0 22px" }}>
We'll tailor your workspace based on this. You can change it later.
</p>
<AField label="Workspace name" value="Lattice Studio"
hint="This is how your team will see it." />
<div style={{ fontSize: 12, fontWeight: 500, margin: "16px 0 8px" }}>
What do you do?
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 10 }}>
<Tile title="Sales & Revenue" sub="Pipeline, contacts, deals" selected icon={<Icn d={Pa.bolt} size={15}/>} />
<Tile title="Operations" sub="Vendors, ops, suppliers" icon={<Icn d={Pa.shield} size={15}/>} />
<Tile title="Product" sub="Customers, feedback, research" icon={<Icn d={Pa.spark} size={15}/>} />
<Tile title="Recruiting" sub="Candidates, pipeline" icon={<Icn d={Pa.briefcase} size={15}/>} />
<Tile title="Just exploring" sub="I'll figure it out" icon={<Icn d={Pa.star} size={15}/>} />
</div>
<div style={{ fontSize: 12, fontWeight: 500, margin: "20px 0 8px" }}>How big is your team?</div>
<div style={{ display: "flex", gap: 6 }}>
{["Just me", "210", "1150", "51200", "200+"].map((s, i) => (
<div key={s} style={{
flex: 1, padding: "9px 8px", textAlign: "center", borderRadius: 7,
fontSize: 12, fontWeight: 500, cursor: "pointer",
border: i === 1 ? `1.5px solid ${a.accent}` : `1px solid ${a.border}`,
background: i === 1 ? "#f6f5ff" : "#fff",
color: i === 1 ? a.accent : a.subtext,
}}>{s}</div>
))}
</div>
<div style={{
display: "flex", justifyContent: "space-between", marginTop: 26, alignItems: "center",
}}>
<button style={{
background: "transparent", border: "none", color: a.subtext,
fontSize: 13, fontFamily: fontA, cursor: "pointer", padding: 0,
}}> Back</button>
<APrimary full={false}>Continue <Icn d={Pa.arrow} size={13}/></APrimary>
</div>
</div>
</main>
<footer style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "16px 28px", fontSize: 11, color: a.muted,
}}>
<span>Press <code style={{
background: "#fff", padding: "1px 5px", borderRadius: 3,
border: `1px solid ${a.border}`, fontFamily: "monospace",
}}> + Enter</code> to continue</span>
<span>Need help? <span style={{ color: a.text, fontWeight: 500 }}>support@lattice.co</span></span>
</footer>
</div>
);
};
Object.assign(window, { ASignIn, ASignUp, AOnboarding });

View File

@@ -0,0 +1,548 @@
// ============================================================
// auth-style-b.jsx — Dark split-hero auth (Vercel / Stripe school).
// Two-column: marketing/storytelling on the left, form on the right.
// Inverts gracefully for onboarding (single dark surface, full width).
// ============================================================
const b = {
bg: "#0a0a0a", left: "#0f0f14",
surface: "#101015", surface2: "#16161d",
border: "#1f1f25", borderStrong: "#2a2a32",
text: "#fafafa", subtext: "#a8a8b0", muted: "#6a6a72",
accent: "#ffffff", accentText: "#0a0a0a",
brandA: "#5e5cff", brandB: "#b15bff",
};
const fontB = "'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif";
// Local icon helper (kept independent of other auth files)
const IcnB = ({ d, size = 16, sw = 1.6 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth={sw}
strokeLinecap="round" strokeLinejoin="round">{d}</svg>
);
const PB = {
check: <path d="M5 12l5 5L20 7"/>,
eye: <><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></>,
arrow: <path d="M5 12h14M13 5l7 7-7 7"/>,
bolt: <path d="m13 2-9 13h7l-1 7 9-13h-7z"/>,
spark: <path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l3 3M15 15l3 3M6 18l3-3M15 9l3-3"/>,
star: <path d="m12 3 2.6 6.2 6.7.5-5.1 4.4 1.6 6.6L12 17.3 6.2 20.7l1.6-6.6L2.7 9.7l6.7-.5z"/>,
};
const MarkB = ({ size = 22 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<defs>
<linearGradient id={`mkb${size}`} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#6e6cff"/>
<stop offset="100%" stopColor="#b15bff"/>
</linearGradient>
</defs>
<path d="M3 20 L12 4 L21 20 Z" fill={`url(#mkb${size})`}/>
</svg>
);
const BField = ({ label, value, placeholder, type, icon, hint, optional, autofocus }) => (
<div style={{ marginBottom: 14 }}>
{label && (
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "baseline",
fontSize: 12, fontWeight: 500, color: b.subtext, marginBottom: 6,
}}>
<span>{label}</span>
{optional && <span style={{ color: b.muted, fontWeight: 400 }}>optional</span>}
</div>
)}
<div style={{
display: "flex", alignItems: "center", gap: 8,
padding: "10px 12px", borderRadius: 8,
background: b.surface2,
border: `1px solid ${autofocus ? b.brandA : b.border}`,
boxShadow: autofocus ? `0 0 0 3px ${b.brandA}33` : "none",
fontSize: 13, color: value ? b.text : b.muted,
}}>
{icon && <span style={{ color: b.muted, display: "flex" }}>{icon}</span>}
<span style={{ flex: 1, letterSpacing: type === "password" ? "0.2em" : "0" }}>
{value || placeholder}
</span>
{type === "password" && <span style={{ color: b.muted, display: "flex" }}><IcnB d={PB.eye} size={14}/></span>}
</div>
{hint && <div style={{ fontSize: 11, color: b.muted, marginTop: 5 }}>{hint}</div>}
</div>
);
const BSocial = ({ children, glyph }) => (
<button style={{
flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
padding: "10px 14px", borderRadius: 8, background: b.surface2,
border: `1px solid ${b.border}`, color: b.text, fontSize: 13,
fontFamily: fontB, fontWeight: 500, cursor: "pointer",
}}>{glyph}<span>{children}</span></button>
);
const BPrimary = ({ children, full = true }) => (
<button style={{
width: full ? "100%" : "auto",
padding: "11px 18px", borderRadius: 8,
background: b.accent, color: b.accentText, border: "none",
fontSize: 13, fontWeight: 600, fontFamily: fontB, cursor: "pointer",
display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
}}>{children}</button>
);
// LEFT hero panel — short storytelling
const HeroPanel = ({ headline, sub, badge }) => (
<div style={{
background: b.left, color: b.text, padding: "32px 44px 36px",
display: "flex", flexDirection: "column", height: "100%",
position: "relative", overflow: "hidden",
borderRight: `1px solid ${b.border}`,
}}>
{/* Decorative grid + glow */}
<div style={{
position: "absolute", inset: 0, pointerEvents: "none", opacity: 0.5,
backgroundImage: `linear-gradient(${b.border} 1px, transparent 1px),
linear-gradient(90deg, ${b.border} 1px, transparent 1px)`,
backgroundSize: "40px 40px",
maskImage: "radial-gradient(circle at 50% 30%, #000 40%, transparent 80%)",
}}></div>
<div style={{
position: "absolute", top: -180, left: -120, width: 540, height: 540,
borderRadius: "50%",
background: `radial-gradient(circle, ${b.brandA}40, transparent 60%)`,
filter: "blur(60px)",
}}></div>
<div style={{
position: "absolute", bottom: -200, right: -120, width: 500, height: 500,
borderRadius: "50%",
background: `radial-gradient(circle, ${b.brandB}40, transparent 60%)`,
filter: "blur(60px)",
}}></div>
{/* Brand */}
<div style={{
position: "relative", display: "flex", alignItems: "center", gap: 10,
fontWeight: 600, fontSize: 16,
}}>
<MarkB size={22} />
Lattice
</div>
{/* Mid */}
<div style={{ position: "relative", marginTop: "auto" }}>
{badge && (
<div style={{
display: "inline-flex", alignItems: "center", gap: 8,
padding: "4px 12px 4px 4px", borderRadius: 999,
background: "#ffffff08", border: "1px solid #ffffff14",
fontSize: 11, color: b.subtext, marginBottom: 22,
}}>
<span style={{
padding: "2px 8px", background: b.brandA, color: "#fff",
borderRadius: 999, fontWeight: 600, fontSize: 10,
}}>NEW</span>
{badge}
</div>
)}
<h2 style={{
fontSize: 38, lineHeight: 1.05, margin: 0, letterSpacing: "-0.03em",
fontWeight: 500, textWrap: "balance", maxWidth: 360,
}}>{headline}</h2>
<p style={{ fontSize: 14, color: b.subtext, marginTop: 14, lineHeight: 1.5, maxWidth: 340 }}>
{sub}
</p>
{/* Trust row */}
<div style={{
marginTop: 32, paddingTop: 22, borderTop: `1px solid ${b.border}`,
}}>
<div style={{
fontSize: 11, color: b.muted, letterSpacing: "0.1em",
textTransform: "uppercase", fontWeight: 500, marginBottom: 12,
}}>Used by teams at</div>
<div style={{
display: "flex", gap: 22, alignItems: "center",
fontWeight: 600, fontSize: 15, color: b.subtext,
}}>
<span>Halcyon</span><span>·</span><span>Kestrel</span>
<span>·</span><span>Mossbank</span><span>·</span><span>Verra</span>
</div>
</div>
</div>
{/* Bottom quote */}
<div style={{
position: "relative", marginTop: 28, padding: "16px 18px",
borderRadius: 12, background: "#ffffff06",
border: `1px solid ${b.border}`,
}}>
<p style={{ fontSize: 13, color: b.text, margin: 0, lineHeight: 1.5 }}>
"Replaced three tools in our first week. Lattice is what every
CRM should have been."
</p>
<div style={{ marginTop: 12, display: "flex", alignItems: "center", gap: 10 }}>
<div style={{
width: 26, height: 26, borderRadius: "50%", background: "#a8c8e8",
fontSize: 11, fontWeight: 600, color: "#1a3a5e",
display: "flex", alignItems: "center", justifyContent: "center",
}}>DP</div>
<div style={{ fontSize: 12 }}>
<div style={{ fontWeight: 500 }}>Devi Patel</div>
<div style={{ color: b.muted, fontSize: 11 }}>Head of Sales, Halcyon</div>
</div>
</div>
</div>
</div>
);
// 2-col shell: hero on left, form on right
const BSplitShell = ({ hero, children }) => (
<div style={{
width: "100%", height: "100%", background: b.bg,
color: b.text, fontFamily: fontB,
display: "grid", gridTemplateColumns: "1fr 1fr",
}}>
{hero}
<div style={{
display: "flex", flexDirection: "column", padding: "32px 56px",
position: "relative",
}}>
<div style={{
display: "flex", justifyContent: "flex-end", fontSize: 13, color: b.subtext,
}}>
<span>Need help? <span style={{ color: b.text, fontWeight: 500 }}>support</span></span>
</div>
<div style={{
flex: 1, display: "flex", alignItems: "center", justifyContent: "center",
}}>
<div style={{ width: 380 }}>{children}</div>
</div>
<div style={{
display: "flex", gap: 18, fontSize: 11, color: b.muted, justifyContent: "flex-end",
}}>
<span>Privacy</span><span>Terms</span><span>Security</span>
<span>v4.2.1</span>
</div>
</div>
</div>
);
const BSignIn = () => (
<BSplitShell hero={
<HeroPanel
badge="Lattice 4.0 · agents that draft for you"
headline="The workspace where good ideas compound."
sub="One luminous surface for docs, canvases, contacts and pipelines. Built by people tired of switching tabs."
/>}>
<h1 style={{ fontSize: 26, fontWeight: 600, margin: 0, letterSpacing: "-0.02em" }}>
Sign in to Lattice
</h1>
<p style={{ fontSize: 13, color: b.subtext, margin: "6px 0 24px" }}>
Welcome back. Pick how you'd like to continue.
</p>
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 18 }}>
<BSocial glyph={<GoogleGlyph/>}>Continue with Google</BSocial>
<BSocial glyph={<MicrosoftGlyph/>}>Continue with Microsoft</BSocial>
<BSocial glyph={<span style={{ color: b.text, display: "flex" }}><AppleGlyph/></span>}>Continue with Apple</BSocial>
</div>
<div style={{
display: "flex", alignItems: "center", gap: 10,
fontSize: 11, color: b.muted, margin: "0 0 18px",
}}>
<div style={{ flex: 1, height: 1, background: b.border }}></div>
<span style={{ textTransform: "uppercase", letterSpacing: "0.08em" }}>or with email</span>
<div style={{ flex: 1, height: 1, background: b.border }}></div>
</div>
<BField label="Email" value="mira@lattice.co" autofocus />
<div style={{ marginBottom: 18 }}>
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "baseline",
fontSize: 12, fontWeight: 500, color: b.subtext, marginBottom: 6,
}}>
<span>Password</span>
<span style={{ color: b.text, cursor: "pointer", fontWeight: 500 }}>Forgot?</span>
</div>
<div style={{
display: "flex", alignItems: "center", gap: 8,
padding: "10px 12px", borderRadius: 8,
background: b.surface2, border: `1px solid ${b.border}`,
fontSize: 13, color: b.text, letterSpacing: "0.2em",
}}>
<span style={{ flex: 1 }}>••••••••••</span>
<span style={{ color: b.muted, display: "flex" }}><IcnB d={PB.eye} size={14}/></span>
</div>
</div>
<BPrimary>Sign in <IcnB d={PB.arrow} size={13}/></BPrimary>
<div style={{
marginTop: 22, padding: "10px 14px", borderRadius: 8,
background: b.surface2, border: `1px solid ${b.border}`,
fontSize: 12, color: b.subtext, display: "flex",
alignItems: "center", gap: 10,
}}>
<IcnB d={PB.bolt} size={14}/>
<span style={{ flex: 1 }}>SAML / SSO for your company?</span>
<span style={{ color: b.text, fontWeight: 500, cursor: "pointer" }}>Use SSO →</span>
</div>
<div style={{ fontSize: 12, color: b.subtext, marginTop: 22, textAlign: "center" }}>
New here? <span style={{ color: b.text, fontWeight: 500, cursor: "pointer" }}>
Create an account
</span>
</div>
</BSplitShell>
);
const BSignUp = () => (
<BSplitShell hero={
<HeroPanel
headline="Start a Lattice workspace in 30 seconds."
sub="Free for up to 10 people. No card required. SSO and SCIM on the Pro plan."
/>}>
<h1 style={{ fontSize: 26, fontWeight: 600, margin: 0, letterSpacing: "-0.02em" }}>
Create your account
</h1>
<p style={{ fontSize: 13, color: b.subtext, margin: "6px 0 24px" }}>
You'll set up your workspace in the next step.
</p>
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 18 }}>
<BSocial glyph={<GoogleGlyph/>}>Continue with Google</BSocial>
<BSocial glyph={<MicrosoftGlyph/>}>Continue with Microsoft</BSocial>
</div>
<div style={{
display: "flex", alignItems: "center", gap: 10,
fontSize: 11, color: b.muted, margin: "0 0 18px",
}}>
<div style={{ flex: 1, height: 1, background: b.border }}></div>
<span style={{ textTransform: "uppercase", letterSpacing: "0.08em" }}>or with email</span>
<div style={{ flex: 1, height: 1, background: b.border }}></div>
</div>
<BField label="Full name" value="Mira Reyes" autofocus />
<BField label="Work email" value="mira@lattice.co"
hint="We'll send a 6-digit code to confirm." />
<BField label="Password" value="••••••••••" type="password"
hint="At least 10 chars · 1 number · 1 symbol." />
<div style={{ display: "flex", alignItems: "flex-start", gap: 8, margin: "8px 0 18px" }}>
<div style={{
width: 14, height: 14, borderRadius: 3, marginTop: 2,
background: b.surface2, border: `1px solid ${b.borderStrong}`,
}}></div>
<span style={{ fontSize: 12, color: b.subtext, lineHeight: 1.5 }}>
I agree to the <span style={{ color: b.text, fontWeight: 500 }}>Terms</span> and{" "}
<span style={{ color: b.text, fontWeight: 500 }}>Privacy Policy</span>.
</span>
</div>
<BPrimary>Create account <IcnB d={PB.arrow} size={13}/></BPrimary>
<div style={{ fontSize: 12, color: b.subtext, marginTop: 22, textAlign: "center" }}>
Already have an account? <span style={{ color: b.text, fontWeight: 500, cursor: "pointer" }}>
Sign in
</span>
</div>
</BSplitShell>
);
const BOnboarding = () => {
// Full-bleed dark onboarding screen (workspace customization step)
const Step = ({ n, label, state }) => (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{
width: 22, height: 22, borderRadius: "50%",
background: state === "done" ? b.brandA : state === "active" ? b.text : "transparent",
color: state === "active" ? b.bg : state === "done" ? "#fff" : b.muted,
border: state === "todo" ? `1px solid ${b.borderStrong}` : "none",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 11, fontWeight: 600,
}}>{state === "done" ? <IcnB d={PB.check} size={12} sw={2.4}/> : n}</div>
<div style={{
fontSize: 12, color: state === "todo" ? b.muted : b.text, whiteSpace: "nowrap",
}}>{label}</div>
</div>
);
const ColorSwatch = ({ color, selected }) => (
<div style={{
width: 36, height: 36, borderRadius: 10, background: color, cursor: "pointer",
boxShadow: selected ? `0 0 0 2px ${b.bg}, 0 0 0 4px ${b.text}` : "none",
}}></div>
);
const Template = ({ title, sub, icon, selected, color }) => (
<div style={{
padding: 18, borderRadius: 12, cursor: "pointer", textAlign: "left",
border: selected ? `1.5px solid ${color}` : `1px solid ${b.border}`,
background: selected ? `${color}10` : b.surface,
position: "relative", overflow: "hidden",
}}>
<div style={{
width: 32, height: 32, borderRadius: 8, marginBottom: 12,
background: selected ? color : "#ffffff10",
color: selected ? "#fff" : b.subtext,
display: "flex", alignItems: "center", justifyContent: "center",
}}>{icon}</div>
<div style={{ fontSize: 14, fontWeight: 500, marginBottom: 4 }}>{title}</div>
<div style={{ fontSize: 12, color: b.muted, lineHeight: 1.4 }}>{sub}</div>
{selected && (
<div style={{
position: "absolute", top: 14, right: 14,
width: 18, height: 18, borderRadius: "50%", background: color,
color: "#fff", display: "flex", alignItems: "center", justifyContent: "center",
}}><IcnB d={PB.check} size={11} sw={2.4}/></div>
)}
</div>
);
return (
<div style={{
width: "100%", height: "100%", background: b.bg, color: b.text,
fontFamily: fontB, display: "grid", gridTemplateRows: "auto 1fr auto",
position: "relative", overflow: "hidden",
}}>
{/* Decorative aurora */}
<div style={{
position: "absolute", top: -200, right: -150, width: 600, height: 600,
borderRadius: "50%",
background: `radial-gradient(circle, ${b.brandA}33, transparent 60%)`,
filter: "blur(80px)", pointerEvents: "none",
}}></div>
{/* Top stepper bar */}
<header style={{
padding: "20px 56px", display: "flex", alignItems: "center", gap: 14,
borderBottom: `1px solid ${b.border}`, background: "#0a0a0d", position: "relative",
}}>
<MarkB size={22} />
<span style={{ fontWeight: 600, fontSize: 14 }}>Lattice</span>
<div style={{ width: 1, height: 18, background: b.border, margin: "0 12px" }}></div>
<div style={{ display: "flex", alignItems: "center", gap: 14, flex: 1 }}>
<Step n="1" label="Account" state="done" />
<div style={{ width: 32, height: 1, background: b.border }}></div>
<Step n="2" label="Workspace" state="done" />
<div style={{ width: 32, height: 1, background: b.border }}></div>
<Step n="3" label="Personalise" state="active" />
<div style={{ width: 32, height: 1, background: b.border }}></div>
<Step n="4" label="Invite" state="todo" />
<div style={{ width: 32, height: 1, background: b.border }}></div>
<Step n="5" label="Import" state="todo" />
</div>
<button style={{
background: "transparent", border: "none", color: b.subtext,
fontSize: 12, fontFamily: fontB, cursor: "pointer",
}}>Skip setup </button>
</header>
<main style={{
padding: "44px 64px 24px", position: "relative", overflowY: "auto",
display: "flex", flexDirection: "column", alignItems: "center",
}}>
<div style={{
fontSize: 11, color: b.muted, letterSpacing: "0.12em",
textTransform: "uppercase", fontWeight: 500, marginBottom: 12,
}}>Step 3 of 5 · Personalise</div>
<h1 style={{
fontSize: 40, fontWeight: 500, margin: 0, letterSpacing: "-0.03em",
textAlign: "center", textWrap: "balance",
}}>
Pick a template to get going.
</h1>
<p style={{
fontSize: 14, color: b.subtext, margin: "12px 0 36px",
textAlign: "center", maxWidth: 540, lineHeight: 1.5,
}}>
We'll pre-fill your workspace with the right objects, views and
fields. Everything is editable later.
</p>
{/* Template grid */}
<div style={{
display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 14,
width: "100%", maxWidth: 1080,
}}>
<Template title="Sales CRM" sub="Pipeline, contacts, deals, activity" icon={<IcnB d={PB.bolt} size={16}/>} selected color="#5e5cff" />
<Template title="Operations" sub="Vendors, suppliers, contracts" icon={<IcnB d={PB.spark} size={16}/>} color="#22c55e" />
<Template title="Recruiting" sub="Candidates, roles, interview loops" icon={<IcnB d={PB.star} size={16}/>} color="#f6c560" />
<Template title="Blank workspace" sub="Start from zero — I'll define my own objects" icon={<IcnB d={PB.spark} size={16}/>} color="#b15bff" />
</div>
{/* Theme + accent strip */}
<div style={{
marginTop: 32, padding: "20px 24px", borderRadius: 14,
background: b.surface, border: `1px solid ${b.border}`,
width: "100%", maxWidth: 1080,
display: "grid", gridTemplateColumns: "1fr 1fr", gap: 32,
}}>
<div>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 4 }}>Theme</div>
<div style={{ fontSize: 12, color: b.muted, marginBottom: 14 }}>
Light, dark, or follow the system.
</div>
<div style={{ display: "flex", gap: 8 }}>
{[
["Light", "#fafaf9", "#111"],
["Dark", "#0f0f14", "#fafafa"],
["System", "linear-gradient(135deg, #fafaf9 50%, #0f0f14 50%)", "#888"],
].map(([n, bg, ink], i) => (
<div key={n} style={{
flex: 1, padding: 4, borderRadius: 10, cursor: "pointer",
border: i === 1 ? `1.5px solid ${b.brandA}` : `1px solid ${b.border}`,
}}>
<div style={{
height: 56, borderRadius: 6, background: bg,
display: "flex", alignItems: "center", justifyContent: "center",
color: ink, fontSize: 11, fontWeight: 500,
}}>{n}</div>
</div>
))}
</div>
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 4 }}>Accent</div>
<div style={{ fontSize: 12, color: b.muted, marginBottom: 14 }}>
The color of your CTAs, links and focus rings.
</div>
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
<ColorSwatch color="#5e5cff" selected />
<ColorSwatch color="#22c55e" />
<ColorSwatch color="#f6c560" />
<ColorSwatch color="#ff5b6b" />
<ColorSwatch color="#b15bff" />
<ColorSwatch color="#06b6d4" />
<ColorSwatch color="#fafafa" />
</div>
</div>
</div>
</main>
<footer style={{
padding: "16px 56px", display: "flex", justifyContent: "space-between",
alignItems: "center", borderTop: `1px solid ${b.border}`,
background: "#0a0a0d", position: "relative",
}}>
<button style={{
background: "transparent", border: `1px solid ${b.border}`, color: b.text,
padding: "9px 16px", borderRadius: 8, fontSize: 13, fontFamily: fontB, cursor: "pointer",
}}>← Back</button>
<span style={{ fontSize: 12, color: b.muted }}>
Press <code style={{
background: b.surface2, padding: "1px 6px", borderRadius: 3,
border: `1px solid ${b.border}`, fontFamily: "monospace",
}}> + Enter</code> to continue
</span>
<BPrimary full={false}>Continue <IcnB d={PB.arrow} size={13}/></BPrimary>
</footer>
</div>
);
};
Object.assign(window, { BSignIn, BSignUp, BOnboarding });

View File

@@ -0,0 +1,535 @@
// ============================================================
// auth-style-c.jsx — Glass aurora auth (marketing-flavoured).
// Vibrant gradient background, frosted card, soft floating
// chrome. Onboarding becomes a kept-it-light, swipey wizard.
// ============================================================
const cc = {
bg: "#08081a", text: "#ffffff",
subtext: "rgba(255,255,255,0.7)", muted: "rgba(255,255,255,0.5)",
glass: "rgba(255,255,255,0.06)",
glassStrong: "rgba(255,255,255,0.1)",
glassBorder: "rgba(255,255,255,0.14)",
brandA: "#7a78ff", brandB: "#b15bff", brandC: "#00e5b3",
};
const fontC = "'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif";
const IcnC = ({ d, size = 16, sw = 1.6 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth={sw}
strokeLinecap="round" strokeLinejoin="round">{d}</svg>
);
const PC = {
check: <path d="M5 12l5 5L20 7"/>,
eye: <><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></>,
arrow: <path d="M5 12h14M13 5l7 7-7 7"/>,
spark: <path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l3 3M15 15l3 3M6 18l3-3M15 9l3-3"/>,
upload: <><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M17 8l-5-5-5 5M12 3v12"/></>,
pen: <><path d="m12 19 7-7 3 3-7 7-3-1z"/><path d="m18 13-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/><path d="M2 2l7.586 7.586"/><circle cx="11" cy="11" r="2"/></>,
bell: <><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10 21a2 2 0 0 0 4 0"/></>,
workflow: <><rect x="3" y="3" width="6" height="6" rx="1"/><rect x="15" y="15" width="6" height="6" rx="1"/><path d="M9 6h6a3 3 0 0 1 3 3v6"/></>,
};
const MarkC = ({ size = 22 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<defs>
<linearGradient id={`mkc${size}`} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#7a78ff"/>
<stop offset="100%" stopColor="#b15bff"/>
</linearGradient>
</defs>
<path d="M3 20 L12 4 L21 20 Z" fill={`url(#mkc${size})`}/>
</svg>
);
// Reusable aurora background — also used by onboarding
const AuroraBg = () => (
<>
<div style={{
position: "absolute", top: -250, left: -150, width: 700, height: 700,
borderRadius: "50%",
background: `radial-gradient(circle, ${cc.brandA} 0%, transparent 60%)`,
filter: "blur(100px)", opacity: 0.55, pointerEvents: "none",
}}></div>
<div style={{
position: "absolute", top: 100, right: -200, width: 600, height: 600,
borderRadius: "50%",
background: `radial-gradient(circle, ${cc.brandB} 0%, transparent 60%)`,
filter: "blur(100px)", opacity: 0.5, pointerEvents: "none",
}}></div>
<div style={{
position: "absolute", bottom: -200, left: "30%", width: 600, height: 600,
borderRadius: "50%",
background: `radial-gradient(circle, ${cc.brandC} 0%, transparent 60%)`,
filter: "blur(100px)", opacity: 0.35, pointerEvents: "none",
}}></div>
{/* Grain overlay */}
<div style={{
position: "absolute", inset: 0, pointerEvents: "none", opacity: 0.6,
backgroundImage: `radial-gradient(rgba(255,255,255,0.04) 1px, transparent 1px)`,
backgroundSize: "3px 3px",
}}></div>
</>
);
// Frosted glass top nav (the pill from the marketing nav, smaller)
const GlassTopNav = ({ right }) => (
<header style={{
position: "absolute", top: 22, left: "50%", transform: "translateX(-50%)",
zIndex: 10, width: "max-content", whiteSpace: "nowrap",
display: "flex", alignItems: "center", gap: 4,
padding: "8px 8px 8px 18px",
background: cc.glass, backdropFilter: "blur(24px)", WebkitBackdropFilter: "blur(24px)",
border: `1px solid ${cc.glassBorder}`, borderRadius: 999,
boxShadow: "0 18px 50px -20px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.1)",
}}>
<div style={{
display: "flex", alignItems: "center", gap: 8,
marginRight: 16, fontWeight: 600, fontSize: 14, color: "#fff",
}}>
<MarkC size={18} /> Lattice
</div>
{["Product", "Pricing", "Docs"].map(l => (
<button key={l} style={{
background: "transparent", border: "none", color: "#fff",
padding: "7px 12px", borderRadius: 999, fontSize: 13,
fontFamily: fontC, cursor: "pointer",
}}>{l}</button>
))}
<div style={{
width: 1, height: 18, background: cc.glassBorder, margin: "0 6px",
}}></div>
{right}
</header>
);
const CField = ({ label, value, placeholder, type, hint, optional, autofocus }) => (
<div style={{ marginBottom: 14 }}>
{label && (
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "baseline",
fontSize: 12, fontWeight: 500, color: cc.subtext, marginBottom: 6,
}}>
<span>{label}</span>
{optional && <span style={{ color: cc.muted, fontWeight: 400 }}>optional</span>}
</div>
)}
<div style={{
display: "flex", alignItems: "center", gap: 8,
padding: "11px 14px", borderRadius: 10,
background: cc.glass,
backdropFilter: "blur(16px)", WebkitBackdropFilter: "blur(16px)",
border: `1px solid ${autofocus ? cc.brandA : cc.glassBorder}`,
boxShadow: autofocus ? `0 0 0 3px ${cc.brandA}33` : "none",
fontSize: 13, color: value ? cc.text : cc.muted,
}}>
<span style={{ flex: 1, letterSpacing: type === "password" ? "0.2em" : "0" }}>
{value || placeholder}
</span>
{type === "password" && <span style={{ color: cc.muted, display: "flex" }}><IcnC d={PC.eye} size={14}/></span>}
</div>
{hint && <div style={{ fontSize: 11, color: cc.muted, marginTop: 5 }}>{hint}</div>}
</div>
);
const CSocial = ({ children, glyph }) => (
<button style={{
flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
padding: "10px 14px", borderRadius: 10,
background: cc.glass, backdropFilter: "blur(16px)", WebkitBackdropFilter: "blur(16px)",
border: `1px solid ${cc.glassBorder}`, color: cc.text, fontSize: 13,
fontFamily: fontC, fontWeight: 500, cursor: "pointer",
}}>{glyph}<span>{children}</span></button>
);
const CPrimary = ({ children, full = true }) => (
<button style={{
width: full ? "100%" : "auto", padding: "12px 22px", borderRadius: 999,
background: "#fff", color: "#08081a", border: "none",
fontSize: 13, fontWeight: 600, fontFamily: fontC, cursor: "pointer",
display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
}}>{children}</button>
);
const CSecondary = ({ children, full = false }) => (
<button style={{
width: full ? "100%" : "auto", padding: "12px 22px", borderRadius: 999,
background: cc.glass, color: cc.text,
backdropFilter: "blur(16px)", WebkitBackdropFilter: "blur(16px)",
border: `1px solid ${cc.glassBorder}`,
fontSize: 13, fontWeight: 500, fontFamily: fontC, cursor: "pointer",
display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
}}>{children}</button>
);
// Centered glass card shell — used by sign-in & sign-up
const CCardShell = ({ children, eyebrow }) => (
<div style={{
width: "100%", height: "100%", background: cc.bg, color: cc.text,
fontFamily: fontC, position: "relative", overflow: "hidden",
}}>
<AuroraBg/>
<GlassTopNav right={
<>
<button style={{
background: "transparent", border: "none", color: cc.text,
padding: "7px 12px", borderRadius: 999, fontSize: 13,
fontFamily: fontC, cursor: "pointer",
}}>Sign in</button>
<button style={{
background: "#fff", color: "#08081a", border: "none",
padding: "7px 14px", borderRadius: 999, fontSize: 13, fontWeight: 600,
fontFamily: fontC, cursor: "pointer",
}}>Get Lattice </button>
</>
} />
<main style={{
position: "relative", height: "100%",
display: "flex", alignItems: "center", justifyContent: "center",
padding: 24,
}}>
<div style={{
width: 460, padding: "32px 36px 36px", borderRadius: 22,
background: cc.glass, backdropFilter: "blur(28px)", WebkitBackdropFilter: "blur(28px)",
border: `1px solid ${cc.glassBorder}`,
boxShadow: `0 30px 80px -30px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.12)`,
}}>
{eyebrow && (
<div style={{
display: "inline-flex", alignItems: "center", gap: 8,
padding: "4px 12px 4px 4px", borderRadius: 999,
background: cc.glass, border: `1px solid ${cc.glassBorder}`,
fontSize: 11, color: cc.subtext, marginBottom: 16,
}}>
<span style={{
padding: "2px 8px", background: cc.brandA, color: "#fff",
borderRadius: 999, fontWeight: 600, fontSize: 10,
}}>BETA</span>
{eyebrow}
</div>
)}
{children}
</div>
</main>
{/* Footer dots */}
<div style={{
position: "absolute", bottom: 22, left: "50%", transform: "translateX(-50%)",
fontSize: 11, color: cc.muted, zIndex: 5,
display: "flex", gap: 18,
}}>
<span>Privacy</span><span>Terms</span><span>Security</span>
<span style={{ display: "inline-flex", alignItems: "center", gap: 5 }}>
<span style={{
width: 6, height: 6, borderRadius: "50%", background: cc.brandC,
boxShadow: `0 0 8px ${cc.brandC}`,
}}></span>
All systems normal
</span>
</div>
</div>
);
const CSignIn = () => (
<CCardShell>
<h1 style={{
fontSize: 32, fontWeight: 500, margin: 0, letterSpacing: "-0.03em",
}}>Welcome back.</h1>
<p style={{ fontSize: 14, color: cc.subtext, margin: "10px 0 26px" }}>
Sign in and pick up where you left off.
</p>
<div style={{ display: "flex", gap: 8, marginBottom: 18 }}>
<CSocial glyph={<GoogleGlyph/>}>Google</CSocial>
<CSocial glyph={<MicrosoftGlyph/>}>Microsoft</CSocial>
<CSocial glyph={<span style={{ color: cc.text, display: "flex" }}><AppleGlyph/></span>}>Apple</CSocial>
</div>
<div style={{
display: "flex", alignItems: "center", gap: 10,
fontSize: 11, color: cc.muted, margin: "0 0 18px",
}}>
<div style={{ flex: 1, height: 1, background: cc.glassBorder }}></div>
<span style={{ textTransform: "uppercase", letterSpacing: "0.08em" }}>or with email</span>
<div style={{ flex: 1, height: 1, background: cc.glassBorder }}></div>
</div>
<CField label="Email" value="mira@lattice.co" autofocus />
<div style={{ marginBottom: 18 }}>
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "baseline",
fontSize: 12, fontWeight: 500, color: cc.subtext, marginBottom: 6,
}}>
<span>Password</span>
<span style={{ color: cc.text, cursor: "pointer", fontWeight: 500 }}>Forgot?</span>
</div>
<div style={{
display: "flex", alignItems: "center", gap: 8,
padding: "11px 14px", borderRadius: 10,
background: cc.glass, backdropFilter: "blur(16px)",
border: `1px solid ${cc.glassBorder}`,
fontSize: 13, color: cc.text, letterSpacing: "0.2em",
}}>
<span style={{ flex: 1 }}></span>
<span style={{ color: cc.muted, display: "flex" }}><IcnC d={PC.eye} size={14}/></span>
</div>
</div>
<CPrimary>Sign in <IcnC d={PC.arrow} size={13}/></CPrimary>
<div style={{ fontSize: 12, color: cc.subtext, marginTop: 22, textAlign: "center" }}>
Don't have an account? <span style={{ color: cc.text, fontWeight: 500, cursor: "pointer" }}>
Sign up for free
</span>
</div>
</CCardShell>
);
const CSignUp = () => (
<CCardShell eyebrow="Lattice 4.0 · agents that draft for you">
<h1 style={{
fontSize: 32, fontWeight: 500, margin: 0, letterSpacing: "-0.03em",
}}>
Start your <span style={{
background: `linear-gradient(90deg, ${cc.brandB}, ${cc.brandA}, ${cc.brandC})`,
WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent",
fontStyle: "italic", fontWeight: 400,
}}>workspace</span>.
</h1>
<p style={{ fontSize: 14, color: cc.subtext, margin: "10px 0 22px" }}>
Free for 10 people. No card. 60 seconds to set up.
</p>
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
<CSocial glyph={<GoogleGlyph/>}>Google</CSocial>
<CSocial glyph={<MicrosoftGlyph/>}>Microsoft</CSocial>
</div>
<div style={{
display: "flex", alignItems: "center", gap: 10,
fontSize: 11, color: cc.muted, margin: "0 0 16px",
}}>
<div style={{ flex: 1, height: 1, background: cc.glassBorder }}></div>
<span style={{ textTransform: "uppercase", letterSpacing: "0.08em" }}>or with email</span>
<div style={{ flex: 1, height: 1, background: cc.glassBorder }}></div>
</div>
<CField label="Work email" value="mira@lattice.co" autofocus />
<CField label="Password" value="••••••••••" type="password"
hint="10+ chars · 1 number · 1 symbol — strong enough" />
<div style={{ display: "flex", alignItems: "flex-start", gap: 8, margin: "10px 0 18px" }}>
<div style={{
width: 16, height: 16, borderRadius: 4, marginTop: 1,
background: cc.brandA, color: "#fff",
display: "flex", alignItems: "center", justifyContent: "center",
}}><IcnC d={PC.check} size={11} sw={2.4}/></div>
<span style={{ fontSize: 12, color: cc.subtext, lineHeight: 1.5 }}>
I agree to Lattice's <span style={{ color: cc.text, fontWeight: 500 }}>Terms</span> and{" "}
<span style={{ color: cc.text, fontWeight: 500 }}>Privacy Policy</span>.
</span>
</div>
<CPrimary>Create my workspace <IcnC d={PC.arrow} size={13}/></CPrimary>
<div style={{ fontSize: 12, color: cc.subtext, marginTop: 22, textAlign: "center" }}>
Already on Lattice? <span style={{ color: cc.text, fontWeight: 500, cursor: "pointer" }}>
Sign in
</span>
</div>
</CCardShell>
);
const COnboarding = () => {
// Glass invite-teammates step — a single big card on aurora bg.
const ProgressDot = ({ state }) => (
<div style={{
width: state === "active" ? 26 : 10, height: 10,
borderRadius: 999,
background: state === "done" ? "#fff" :
state === "active" ? cc.brandA : cc.glassStrong,
transition: "all .2s",
}}></div>
);
const EmailRow = ({ email, role, status, color }) => (
<div style={{
display: "flex", alignItems: "center", gap: 12,
padding: "10px 12px", borderRadius: 10,
background: cc.glass,
backdropFilter: "blur(12px)",
border: `1px solid ${cc.glassBorder}`,
}}>
<div style={{
width: 28, height: 28, borderRadius: "50%", background: color,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 11, fontWeight: 600, color: "#3a2820",
}}>{email.slice(0, 2).toUpperCase()}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 13, color: cc.text, whiteSpace: "nowrap",
overflow: "hidden", textOverflow: "ellipsis",
}}>{email}</div>
</div>
<div style={{
display: "flex", alignItems: "center", gap: 6, padding: "3px 9px",
borderRadius: 6, background: cc.glassStrong, fontSize: 11, color: cc.text,
}}>{role} <IcnC d={<path d="m6 9 6 6 6-6"/>} size={11}/></div>
{status && <span style={{
fontSize: 11, padding: "2px 8px", borderRadius: 999,
background: status === "queued" ? `${cc.brandA}33` : "#22c55e33",
color: status === "queued" ? cc.brandA : "#7aff66",
}}>{status}</span>}
</div>
);
return (
<div style={{
width: "100%", height: "100%", background: cc.bg, color: cc.text,
fontFamily: fontC, position: "relative", overflow: "hidden",
}}>
<AuroraBg/>
{/* Brand top-left + skip top-right */}
<header style={{
position: "absolute", top: 22, left: 32, zIndex: 10,
display: "flex", alignItems: "center", gap: 8,
fontWeight: 600, fontSize: 14, color: cc.text,
}}>
<MarkC size={20} /> Lattice
</header>
<div style={{
position: "absolute", top: 26, right: 32, zIndex: 10,
fontSize: 12, color: cc.subtext,
}}>
Step 3 of 4 · <span style={{ color: cc.text, cursor: "pointer" }}>Skip</span>
</div>
<main style={{
position: "relative", height: "100%",
display: "flex", alignItems: "center", justifyContent: "center",
padding: 24,
}}>
<div style={{
width: 620, padding: "36px 40px 32px", borderRadius: 26,
background: cc.glass, backdropFilter: "blur(28px)", WebkitBackdropFilter: "blur(28px)",
border: `1px solid ${cc.glassBorder}`,
boxShadow: `0 40px 100px -30px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.12)`,
}}>
{/* Progress dots */}
<div style={{
display: "flex", alignItems: "center", justifyContent: "center",
gap: 8, marginBottom: 26,
}}>
<ProgressDot state="done" />
<ProgressDot state="done" />
<ProgressDot state="active" />
<ProgressDot state="todo" />
</div>
<h1 style={{
fontSize: 34, fontWeight: 500, margin: 0, letterSpacing: "-0.03em",
textAlign: "center", textWrap: "balance",
}}>
Lattice gets <em style={{
fontStyle: "italic", fontWeight: 400,
background: `linear-gradient(90deg, ${cc.brandB}, ${cc.brandC})`,
WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent",
}}>better</em> with your team.
</h1>
<p style={{
fontSize: 14, color: cc.subtext, margin: "12px 0 26px",
textAlign: "center", lineHeight: 1.5,
}}>
Invite the people you actually work with. You can always add more later.
</p>
{/* Invite input + role */}
<div style={{
display: "flex", gap: 8, marginBottom: 14,
}}>
<div style={{
flex: 1, display: "flex", alignItems: "center", gap: 8,
padding: "11px 14px", borderRadius: 12,
background: cc.glass,
backdropFilter: "blur(16px)",
border: `1px solid ${cc.brandA}`,
boxShadow: `0 0 0 3px ${cc.brandA}33`,
fontSize: 13, color: cc.subtext,
}}>
<span style={{ flex: 1 }}>name@company.com, separate with commas</span>
</div>
<div style={{
display: "flex", alignItems: "center", gap: 6,
padding: "11px 14px", borderRadius: 12, fontSize: 13, color: cc.text,
background: cc.glassStrong,
backdropFilter: "blur(16px)",
border: `1px solid ${cc.glassBorder}`,
cursor: "pointer", whiteSpace: "nowrap",
}}>
Member <IcnC d={<path d="m6 9 6 6 6-6"/>} size={12}/>
</div>
<button style={{
padding: "0 18px", borderRadius: 12, background: "#fff",
color: "#08081a", border: "none", fontFamily: fontC,
fontSize: 13, fontWeight: 600, cursor: "pointer",
}}>Send</button>
</div>
{/* Already queued list */}
<div style={{
fontSize: 11, color: cc.muted, letterSpacing: "0.08em",
textTransform: "uppercase", fontWeight: 500, margin: "12px 0 8px",
}}>To be invited · 3</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<EmailRow email="theo@lattice.co" role="Admin" status="queued" color="#c8e8a8" />
<EmailRow email="devi@lattice.co" role="Admin" status="queued" color="#a8c8e8" />
<EmailRow email="sun@lattice.co" role="Member" status="queued" color="#e8a87c" />
</div>
{/* Shareable link */}
<div style={{
marginTop: 18, padding: "12px 14px", borderRadius: 12,
background: cc.glass,
backdropFilter: "blur(16px)",
border: `1px dashed ${cc.glassBorder}`,
display: "flex", alignItems: "center", gap: 12,
}}>
<div style={{
width: 30, height: 30, borderRadius: 8,
background: cc.glassStrong,
display: "flex", alignItems: "center", justifyContent: "center",
color: cc.brandA,
}}><IcnC d={PC.workflow} size={14}/></div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>Or share an invite link</div>
<div style={{
fontSize: 12, color: cc.subtext, fontFamily: "monospace",
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
}}>lattice.app/join/mira-reyes-7f4ac</div>
</div>
<button style={{
padding: "6px 12px", borderRadius: 999, fontSize: 12,
fontFamily: fontC, background: cc.glassStrong,
border: `1px solid ${cc.glassBorder}`, color: cc.text, cursor: "pointer",
}}>Copy link</button>
</div>
{/* Footer buttons */}
<div style={{
display: "flex", justifyContent: "space-between", marginTop: 28, alignItems: "center",
}}>
<button style={{
background: "transparent", border: "none", color: cc.subtext,
fontSize: 13, fontFamily: fontC, cursor: "pointer", padding: 0,
}}>I'll do this later</button>
<CPrimary full={false}>Send invites & continue <IcnC d={PC.arrow} size={13}/></CPrimary>
</div>
</div>
</main>
</div>
);
};
Object.assign(window, { CSignIn, CSignUp, COnboarding });

View File

@@ -0,0 +1,379 @@
/* Shared auth styles — Sign In + Sign Up. Same tokens as the rest of the
site; declared inline here so each auth page is self-sufficient. */
: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.45;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overflow-x: hidden;
}
/* Ambient grid */
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 70% 70% at 50% 40%, #000 30%, transparent 80%);
-webkit-mask-image: radial-gradient(ellipse 70% 70% at 50% 40%, #000 30%, transparent 80%);
pointer-events: none;
z-index: 0;
}
/* Film grain */
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); }
/* Layout */
.page {
position: relative;
z-index: 2;
min-height: 100dvh;
display: flex; flex-direction: column;
}
.topbar {
position: relative; z-index: 5;
padding: 22px clamp(20px, 4vw, 48px);
display: flex; align-items: center; justify-content: space-between;
}
.topbar a:hover { color: var(--fg); }
.topbar-back {
color: var(--fg-mute);
font-size: 14px;
display: inline-flex; align-items: center; gap: 6px;
}
.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 */
.auth-main {
flex: 1;
display: grid; place-items: center;
padding: clamp(20px, 4vw, 40px);
position: relative;
}
/* Ambient glows */
.auth-glow {
position: absolute;
pointer-events: none;
filter: blur(20px);
z-index: 0;
}
/* Card */
.auth-card {
position: relative;
z-index: 2;
width: 100%; max-width: 440px;
padding: 36px clamp(24px, 4vw, 40px) 32px;
background: linear-gradient(180deg, oklch(0.20 0.009 60 / 0.85), oklch(0.17 0.008 60 / 0.85));
border: 1px solid var(--hairline);
border-radius: 22px;
backdrop-filter: blur(20px);
box-shadow:
0 30px 80px -20px oklch(0 0 0 / 0.7),
0 0 80px -30px var(--accent-glow);
}
.auth-card::before {
content: "";
position: absolute; left: 0; right: 0; top: 0; height: 1px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
opacity: .6;
}
/* Header */
.auth-eye {
display: inline-flex; align-items: center; gap: 8px;
font-family: var(--font-mono);
font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase;
color: var(--fg-mute);
}
.auth-eye::before {
content: ""; width: 5px; height: 5px; border-radius: 50%;
background: var(--accent); box-shadow: 0 0 12px var(--accent-glow);
}
.auth-title {
margin-top: 14px;
font-size: clamp(26px, 3.4vw, 34px);
font-weight: 500;
letter-spacing: -0.022em;
line-height: 1.1;
text-wrap: balance;
}
.auth-title em {
font-style: normal;
color: var(--accent);
text-shadow: 0 0 30px var(--accent-glow);
}
.auth-sub {
margin-top: 10px;
color: var(--fg-mute);
font-size: 14.5px;
line-height: 1.5;
text-wrap: balance;
}
/* Form */
.auth-form {
margin-top: 24px;
display: flex; flex-direction: column;
gap: 12px;
}
.auth-field {
display: flex; flex-direction: column;
gap: 6px;
}
.auth-label {
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--fg-mute);
padding-left: 4px;
}
.auth-input {
width: 100%;
padding: 13px 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;
}
.auth-input::placeholder { color: var(--fg-faint); }
.auth-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);
}
.auth-input.mono { font-family: var(--font-mono); letter-spacing: 0.08em; text-transform: uppercase; }
.auth-input.mono::placeholder { letter-spacing: 0.08em; }
/* Buttons */
.auth-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;
white-space: nowrap;
width: 100%;
}
.auth-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);
}
.auth-btn-primary:hover { transform: translateY(-1px); }
.auth-btn-primary[disabled] { opacity: .55; cursor: not-allowed; transform: none; }
.auth-btn-ghost {
background: oklch(0.20 0.009 60 / 0.6);
border: 1px solid var(--hairline);
color: var(--fg-dim);
}
.auth-btn-ghost:hover { color: var(--fg); border-color: var(--hairline-2); background: oklch(0.22 0.010 60 / 0.8); }
.auth-btn-ghost img, .auth-btn-ghost svg { flex-shrink: 0; }
/* Divider */
.auth-divider {
display: flex; align-items: center; gap: 14px;
margin: 6px 0 2px;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--fg-faint);
}
.auth-divider::before, .auth-divider::after {
content: ""; flex: 1; height: 1px; background: var(--hairline);
}
/* OAuth row */
.auth-oauth {
display: flex; flex-direction: column; gap: 10px;
}
/* Footer */
.auth-foot {
margin-top: 26px;
padding-top: 22px;
border-top: 1px solid var(--hairline);
text-align: center;
font-size: 14px;
color: var(--fg-mute);
}
.auth-foot a {
color: var(--accent);
font-weight: 500;
}
.auth-foot a:hover { text-decoration: underline; text-underline-offset: 3px; }
.auth-fine {
margin-top: 18px;
text-align: center;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.04em;
color: var(--fg-faint);
}
.auth-fine a { color: var(--fg-mute); text-decoration: underline; text-underline-offset: 3px; }
/* Spinner */
.auth-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;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Confirmed state */
.auth-success {
text-align: center;
}
.auth-success-badge {
display: inline-grid; place-items: center;
width: 56px; height: 56px;
border-radius: 50%;
margin-bottom: 16px;
color: var(--accent);
background: oklch(0.74 0.175 35 / 0.12);
border: 1px solid oklch(0.74 0.175 35 / 0.4);
box-shadow: 0 0 40px var(--accent-glow);
}
.auth-success .email-chip {
display: inline-block;
margin: 0 4px;
padding: 2px 9px;
border-radius: 6px;
background: oklch(0.74 0.175 35 / 0.12);
border: 1px solid oklch(0.74 0.175 35 / 0.3);
color: var(--accent);
font-family: var(--font-mono);
font-size: 13.5px;
font-weight: 500;
letter-spacing: 0;
text-transform: none;
white-space: nowrap;
}
.auth-tip {
margin-top: 22px;
padding: 14px 16px;
border-radius: 10px;
background: oklch(0.16 0.008 60 / 0.6);
border: 1px solid var(--hairline);
display: flex; gap: 12px;
text-align: left;
font-size: 13px; line-height: 1.5;
color: var(--fg-dim);
}
.auth-tip-icon {
flex-shrink: 0;
width: 22px; height: 22px; border-radius: 6px;
background: oklch(0.22 0.011 60);
color: var(--fg-mute);
display: grid; place-items: center;
}
.auth-tip a { color: var(--accent); }
.auth-tip a:hover { text-decoration: underline; text-underline-offset: 3px; }
/* Resend */
.auth-resend {
display: inline-flex; align-items: center; gap: 8px;
margin-top: 20px;
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.03em;
color: var(--fg-mute);
}
.auth-resend button {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 3px;
}
.auth-resend button:hover { color: oklch(0.78 0.16 35); }
.auth-resend button[disabled] {
color: var(--fg-faint);
text-decoration: none;
cursor: not-allowed;
}
/* Trust strip in footer area */
.auth-trust {
margin-top: 32px;
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);
}
.auth-trust .sep { color: var(--fg-faint); opacity: 0.5; }

View File

@@ -0,0 +1,809 @@
// Beta signup — invite request flow with submit/confirmed states.
function Arrow({ size = 14 }) {
return (
<svg className="arrow" width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function Glow({ color = "var(--accent-glow)", size = 700, opacity = 1, style = {} }) {
return (
<div aria-hidden="true" style={{
position: "absolute", width: size, height: size,
background: `radial-gradient(circle at center, ${color} 0%, transparent 62%)`,
filter: "blur(20px)", opacity, pointerEvents: "none", ...style,
}} />
);
}
const ROLES = [
{ value: "smb", label: "Small business owner", hint: "I run a shop, salon, studio, café…" },
{ value: "freelancer", label: "Freelancer / agency", hint: "I build tools for clients" },
{ value: "ideaperson", label: "I just have an idea", hint: "First-time builder, no code" },
];
const SOURCES = ["Reddit", "Twitter / X", "TikTok", "YouTube", "A friend", "Google", "Something else"];
const BENEFITS = [
{
icon: "lightning",
title: "First access",
body: "Skip the queue when public beta opens. You build before everyone else.",
},
{
icon: "gift",
title: "90 days of Pro, free",
body: "Full launch features — hosting, marketing, customer acquisition — on the house.",
},
{
icon: "chat",
title: "Direct line to the team",
body: "Private channel with the people building Vibn. Your feedback ships.",
},
];
function BetaApp() {
const [submitted, setSubmitted] = React.useState(false);
const [submitting, setSubmitting] = React.useState(false);
const [scrolled, setScrolled] = React.useState(false);
const [form, setForm] = React.useState({
email: "",
name: "",
build: "",
role: "smb",
source: "",
});
React.useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 8);
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
const update = (k, v) => setForm((f) => ({ ...f, [k]: v }));
const valid = /\S+@\S+\.\S+/.test(form.email) && form.build.trim().length > 4;
const handleSubmit = (e) => {
e.preventDefault();
if (!valid || submitting) return;
setSubmitting(true);
setTimeout(() => {
setSubmitting(false);
setSubmitted(true);
window.scrollTo({ top: 0, behavior: "smooth" });
}, 700);
};
// Stable "queue position" based on email — feels real, deterministic.
const queuePos = React.useMemo(() => {
let h = 7;
for (const c of form.email) h = (h * 31 + c.charCodeAt(0)) >>> 0;
return 2100 + (h % 900); // 2,100 2,999
}, [form.email]);
return (
<>
<BetaStyle />
<nav className={`nav${scrolled ? " scrolled" : ""}`}>
<div className="wrap nav-inner">
<a href="index.html" className="logo">
<span className="logo-mark">
<svg viewBox="0 0 36 32" width="74%" height="74%" fill="currentColor" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" aria-hidden="true">
<path d="M4 5 L10 5 L12 18 L14 5 L20 5 L12 27 Z" />
<rect x="22.5" y="23" width="9.5" height="3.8" rx="0.7" className="logo-caret" />
</svg>
</span>
<span>vibn</span>
</a>
<a href="index.html" className="nav-back">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
<path d="M13 8H3M7 4 3 8l4 4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
Back to home
</a>
</div>
</nav>
<main className="beta-main">
<Glow color="oklch(0.74 0.175 35 / 0.30)" size={1000}
style={{ top: "-280px", left: "50%", transform: "translateX(-50%)" }} />
<Glow color="oklch(0.45 0.10 35 / 0.20)" size={550}
style={{ top: "30%", left: "-180px" }} />
<Glow color="oklch(0.45 0.10 35 / 0.15)" size={500}
style={{ top: "20%", right: "-150px" }} />
<div className="wrap beta-wrap">
{submitted ? (
<Confirmed form={form} queuePos={queuePos} />
) : (
<>
<header className="beta-head">
<div className="eyebrow">Closed beta · invite-only</div>
<h1 className="beta-title">
Be one of the first to <em>vibe with Vibn</em>.
</h1>
<p className="beta-sub">
We're letting in <b>50 new builders a week</b>.
Tell us what you want to build — the most exciting ideas get the invite first.
</p>
</header>
<form className="beta-form" onSubmit={handleSubmit} noValidate>
<Field
label="01"
title="What's your email?"
hint="So we can send you the invite when it's your turn."
>
<input
type="email" required
className="f-input"
value={form.email}
onChange={(e) => update("email", e.target.value)}
placeholder="you@somewhere.com"
autoComplete="email"
/>
</Field>
<Field label="02" title="What should we call you?" hint="Optional, but nice to know.">
<input
type="text"
className="f-input"
value={form.name}
onChange={(e) => update("name", e.target.value)}
placeholder="First name or handle"
autoComplete="given-name"
/>
</Field>
<Field
label="03"
title="What's the first thing you want to build?"
hint="Free-form. The vibe matters more than the spec."
required
>
<div className="f-prompt">
<textarea
className="f-textarea"
value={form.build}
onChange={(e) => update("build", e.target.value)}
placeholder="A booking site for my dog grooming business with reminders, payments and a wait list"
rows={4}
/>
<div className="f-prompt-bar">
<span className="f-prompt-count">
{form.build.length > 0 ? `${form.build.length} chars` : "go wild"}
</span>
<span className="f-prompt-hint">
⌘ + Enter to submit the form
</span>
</div>
</div>
</Field>
<Field label="04" title="Which one are you?">
<div className="f-roles">
{ROLES.map((r) => (
<button
type="button" key={r.value}
className={`f-role${form.role === r.value ? " active" : ""}`}
onClick={() => update("role", r.value)}
>
<span className="f-role-label">{r.label}</span>
<span className="f-role-hint">{r.hint}</span>
<span className="f-role-check">
<svg width="12" height="12" viewBox="0 0 14 14" fill="none">
<path d="M3 7.2 5.8 10 11 4.2" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</span>
</button>
))}
</div>
</Field>
<Field label="05" title="How'd you hear about us?" hint="Optional. Helps us know what's working.">
<div className="f-chips">
{SOURCES.map((s) => (
<button
type="button" key={s}
className={`f-chip${form.source === s ? " active" : ""}`}
onClick={() => update("source", form.source === s ? "" : s)}
>{s}</button>
))}
</div>
</Field>
<div className="beta-submit">
<button
type="submit"
className="btn btn-primary beta-submit-btn"
disabled={!valid || submitting}
>
{submitting ? (
<>
<span className="spinner" /> Sending…
</>
) : (
<>Request my invite <Arrow /></>
)}
</button>
<p className="beta-fine mono">
No credit card · No spam, just one email when you're in · Unsubscribe anytime
</p>
</div>
</form>
</>
)}
{/* What you get — shown on both states */}
<section className="benefits">
<div className="benefits-head">
<div className="eyebrow">What you get on the inside</div>
</div>
<div className="benefits-grid">
{BENEFITS.map((b) => (
<div className="benefit" key={b.title}>
<div className="benefit-icon"><BenefitIcon name={b.icon} /></div>
<h3 className="benefit-title">{b.title}</h3>
<p className="benefit-body">{b.body}</p>
</div>
))}
</div>
</section>
</div>
</main>
<footer className="beta-footer">
<div className="wrap beta-footer-inner">
<span className="mono">🇨🇦 Built in Canada · Your data stays safe · No credit card to start</span>
<span className="mono">© 2026 Vibn Inc.</span>
</div>
</footer>
</>
);
}
function Field({ label, title, hint, required, children }) {
return (
<div className="field">
<div className="field-meta">
<span className="field-num mono">{label}{required && <em>*</em>}</span>
<div className="field-text">
<div className="field-title">{title}</div>
{hint && <div className="field-hint">{hint}</div>}
</div>
</div>
<div className="field-body">{children}</div>
</div>
);
}
function BenefitIcon({ name }) {
const p = { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none",
stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round" };
if (name === "lightning") return <svg {...p}><path d="M11 2 4 11h5l-1 7 7-9h-5l1-7Z"/></svg>;
if (name === "gift") return <svg {...p}><rect x="3" y="7.5" width="14" height="10"/><path d="M3 11h14M10 7.5V18M7 7.5a2 2 0 1 1 3-2.5 2 2 0 1 1 3 2.5"/></svg>;
if (name === "chat") return <svg {...p}><path d="M3.5 11.5a6 6 0 1 1 3.4 5.4L3 18l1.1-3.9a6 6 0 0 1-.6-2.6Z"/></svg>;
return null;
}
// ── Submitted state ─────────────────────────────────────────────────────────
function Confirmed({ form, queuePos }) {
const [copied, setCopied] = React.useState(false);
// Fake-but-stable referral code
const ref = React.useMemo(() => {
const seed = form.email || form.name || "anon";
let h = 5;
for (const c of seed) h = (h * 33 + c.charCodeAt(0)) >>> 0;
return "v-" + h.toString(36).slice(0, 6);
}, [form.email, form.name]);
const link = typeof window !== "undefined" ? `${window.location.origin}/join?ref=${ref}` : `vibn.app/join?ref=${ref}`;
const copyLink = () => {
try { navigator.clipboard.writeText(link); } catch (e) { /* noop */ }
setCopied(true);
setTimeout(() => setCopied(false), 1800);
};
// Compute a queue progress bar percentage — visual feedback only
const pct = Math.max(2, Math.min(98, 100 - (queuePos - 2100) / 9));
return (
<div className="confirmed">
<div className="confirmed-head">
<div className="confirmed-badge">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none">
<circle cx="16" cy="16" r="15" stroke="currentColor" strokeWidth="1.5" opacity=".25"/>
<path d="M10 16.5 14.5 21 22 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<div className="eyebrow">You're on the list</div>
<h1 className="beta-title" style={{ marginTop: 14 }}>
{form.name ? <>Welcome, <em>{form.name}</em>.</> : <>You're <em>in line</em>.</>}
</h1>
<p className="beta-sub">
We got your invite request — keep an eye on <b className="mono" style={{ fontWeight: 500 }}>{form.email}</b>.
</p>
</div>
<div className="queue-card">
<div className="queue-top">
<div>
<div className="queue-label mono">your spot in line</div>
<div className="queue-num">#{queuePos.toLocaleString()}</div>
</div>
<div className="queue-rate">
<div className="queue-rate-num">50<small>/wk</small></div>
<div className="queue-rate-lbl mono">letting in</div>
</div>
</div>
<div className="queue-bar">
<div className="queue-bar-fill" style={{ width: `${pct}%` }} />
<div className="queue-bar-marker" style={{ left: `${pct}%` }}>
<span>You</span>
</div>
</div>
<div className="queue-foot mono">
You should hear from us in ~<b>{Math.ceil((queuePos - 50) / 50)} weeks</b>. Don't want to wait?
</div>
</div>
<div className="refer">
<div className="refer-head">
<div className="eyebrow">Skip the line</div>
<h3 className="refer-title">Send 3 friends — jump to the front.</h3>
<p className="refer-sub">Each friend who joins via your link bumps you up 500 spots.</p>
</div>
<div className="refer-row">
<div className="refer-link mono">
<span className="refer-prefix">vibn.app/join?ref=</span>
<b>{ref}</b>
</div>
<button type="button" className="btn btn-ghost refer-copy" onClick={copyLink}>
{copied ? "Copied!" : "Copy link"}
</button>
</div>
<div className="refer-share">
<a className="share-btn" href="#"><ShareIcon name="x"/> Share on X</a>
<a className="share-btn" href="#"><ShareIcon name="reddit"/> Post to Reddit</a>
<a className="share-btn" href="#"><ShareIcon name="mail"/> Email a friend</a>
</div>
</div>
{form.build && (
<div className="build-echo">
<div className="eyebrow">What we'll help you build first</div>
<div className="build-echo-quote">"{form.build}"</div>
</div>
)}
</div>
);
}
function ShareIcon({ name }) {
const p = { width: 14, height: 14, viewBox: "0 0 16 16", fill: "currentColor" };
if (name === "x") return <svg {...p}><path d="M9.2 7 13.7 2h-1.4L8.6 6.3 5.6 2H2l4.7 6.8L2 14h1.4l4.1-4.7 3.3 4.7H14L9.2 7Z"/></svg>;
if (name === "reddit") return <svg {...p}><circle cx="8" cy="9" r="6"/></svg>;
if (name === "mail") return <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="2" y="3.5" width="12" height="9" rx="1.5"/><path d="m3 5 5 3.8L13 5"/></svg>;
return null;
}
// ── Styles ──────────────────────────────────────────────────────────────────
function BetaStyle() {
return <style>{`
.beta-main { position: relative; padding-block: clamp(60px, 9vh, 100px); overflow: hidden; }
.beta-wrap { position: relative; max-width: 760px; }
.beta-head { text-align: center; margin-bottom: 56px; }
.beta-title {
margin-top: 18px;
font-size: clamp(40px, 6.4vw, 80px);
font-weight: 500; letter-spacing: -0.03em; line-height: 1.0;
text-wrap: balance;
}
.beta-title em {
font-style: normal;
color: var(--accent);
text-shadow: 0 0 40px var(--accent-glow);
}
.beta-sub {
margin-top: 22px;
font-size: clamp(16px, 1.6vw, 19px);
color: var(--fg-dim);
max-width: 540px; margin-inline: auto;
text-wrap: balance;
}
.beta-sub b { color: var(--fg); font-weight: 500; }
/* Form */
.beta-form {
display: flex; flex-direction: column;
gap: 28px;
padding: 36px clamp(20px, 4vw, 44px) 32px;
background: linear-gradient(180deg, oklch(0.20 0.009 60 / 0.6), oklch(0.17 0.008 60 / 0.6));
border: 1px solid var(--hairline);
border-radius: 22px;
backdrop-filter: blur(20px);
position: relative;
box-shadow: 0 30px 80px -20px oklch(0 0 0 / 0.6);
}
.beta-form::before {
content: "";
position: absolute; left: 0; right: 0; top: 0; height: 1px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
opacity: .6;
}
.field { display: flex; flex-direction: column; gap: 12px; }
.field-meta {
display: flex; align-items: flex-start; gap: 14px;
}
.field-num {
font-size: 11px; letter-spacing: 0.1em;
color: var(--fg-faint);
padding: 4px 8px;
border: 1px solid var(--hairline);
border-radius: 6px;
flex-shrink: 0;
margin-top: 2px;
}
.field-num em {
font-style: normal;
color: var(--accent);
margin-left: 1px;
}
.field-text { flex: 1; }
.field-title {
font-size: 17px; font-weight: 500;
color: var(--fg);
letter-spacing: -0.01em;
}
.field-hint {
margin-top: 2px;
font-size: 13px; color: var(--fg-mute);
}
.field-body { padding-left: 0; }
/* Inputs */
.f-input, .f-textarea {
width: 100%; box-sizing: border-box;
padding: 14px 16px;
background: oklch(0.16 0.008 60 / 0.8);
border: 1px solid var(--hairline);
border-radius: 12px;
color: var(--fg);
font: 16px/1.5 var(--font-sans);
outline: none;
transition: border-color .15s, background .15s, box-shadow .15s;
}
.f-input::placeholder, .f-textarea::placeholder { color: var(--fg-faint); }
.f-input:focus, .f-textarea:focus, .f-prompt:focus-within {
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.15), 0 0 30px -10px var(--accent-glow);
}
.f-textarea { resize: vertical; min-height: 110px; }
.f-prompt {
border: 1px solid var(--hairline);
border-radius: 12px;
background: oklch(0.16 0.008 60 / 0.8);
overflow: hidden;
transition: border-color .15s, box-shadow .15s, background .15s;
}
.f-prompt .f-textarea {
border: 0; background: transparent; border-radius: 0;
padding: 14px 16px 10px;
}
.f-prompt .f-textarea:focus { box-shadow: none; }
.f-prompt-bar {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 14px 10px;
border-top: 1px solid var(--hairline);
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-faint);
letter-spacing: 0.02em;
}
.f-prompt-count { color: var(--accent); }
/* Role cards */
.f-roles {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
}
.f-role {
position: relative;
text-align: left;
padding: 14px 18px 14px 16px;
background: oklch(0.16 0.008 60 / 0.6);
border: 1px solid var(--hairline);
border-radius: 12px;
display: flex; flex-direction: column; gap: 2px;
transition: border-color .15s, background .15s;
}
.f-role:hover { border-color: var(--hairline-2); }
.f-role.active {
border-color: var(--accent);
background: oklch(0.20 0.04 35 / 0.4);
box-shadow: 0 0 0 3px oklch(0.74 0.175 35 / 0.1);
}
.f-role-label {
font-size: 15px; font-weight: 500;
color: var(--fg);
}
.f-role-hint {
font-size: 13px; color: var(--fg-mute);
}
.f-role-check {
position: absolute; top: 50%; right: 16px;
transform: translateY(-50%);
width: 20px; height: 20px; border-radius: 50%;
border: 1.5px solid var(--hairline-2);
display: grid; place-items: center;
color: var(--accent-fg);
background: transparent;
transition: all .15s;
}
.f-role.active .f-role-check {
background: var(--accent);
border-color: var(--accent);
}
.f-role-check svg { opacity: 0; transition: opacity .15s; }
.f-role.active .f-role-check svg { opacity: 1; }
/* Source chips */
.f-chips {
display: flex; flex-wrap: wrap; gap: 8px;
}
.f-chip {
padding: 8px 14px;
border-radius: 999px;
border: 1px solid var(--hairline);
background: oklch(0.16 0.008 60 / 0.6);
color: var(--fg-dim);
font-size: 13px;
transition: border-color .15s, color .15s, background .15s;
}
.f-chip:hover { color: var(--fg); border-color: var(--hairline-2); }
.f-chip.active {
border-color: var(--accent);
background: oklch(0.20 0.04 35 / 0.4);
color: var(--fg);
}
.beta-submit {
display: flex; flex-direction: column; align-items: center; gap: 14px;
margin-top: 8px;
}
.beta-submit-btn {
width: 100%; max-width: 320px;
height: 56px; font-size: 16px;
}
.beta-fine {
font-size: 11px; color: var(--fg-faint);
letter-spacing: 0.03em; text-align: center;
text-wrap: balance;
}
.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;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Confirmed state */
.confirmed { display: flex; flex-direction: column; gap: 28px; }
.confirmed-head { text-align: center; }
.confirmed-badge {
display: inline-grid; place-items: center;
width: 64px; height: 64px;
border-radius: 50%;
color: var(--ok);
background: oklch(0.78 0.16 155 / 0.1);
border: 1px solid oklch(0.78 0.16 155 / 0.4);
box-shadow: 0 0 40px oklch(0.78 0.16 155 / 0.3);
margin-bottom: 16px;
}
.queue-card {
padding: 28px;
background: linear-gradient(180deg, oklch(0.20 0.009 60 / 0.6), oklch(0.17 0.008 60 / 0.6));
border: 1px solid var(--hairline);
border-radius: 18px;
}
.queue-top {
display: flex; justify-content: space-between; align-items: flex-end;
gap: 14px;
margin-bottom: 24px;
}
.queue-label, .queue-rate-lbl {
font-size: 11px; letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--fg-faint);
}
.queue-num {
font-family: var(--font-mono);
font-size: clamp(48px, 7vw, 76px);
font-weight: 500;
letter-spacing: -0.04em;
line-height: 1;
color: var(--accent);
text-shadow: 0 0 40px var(--accent-glow);
margin-top: 8px;
}
.queue-rate { text-align: right; }
.queue-rate-num {
font-family: var(--font-mono);
font-size: 26px; font-weight: 500;
color: var(--fg);
line-height: 1;
}
.queue-rate-num small {
color: var(--fg-mute); font-size: 13px; font-weight: 400;
margin-left: 2px;
}
.queue-bar {
position: relative;
height: 6px;
border-radius: 999px;
background: oklch(0.22 0.01 60);
overflow: visible;
margin: 36px 0 24px;
}
.queue-bar-fill {
position: absolute; left: 0; top: 0; bottom: 0;
border-radius: 999px;
background: linear-gradient(90deg, oklch(0.65 0.15 35), var(--accent));
box-shadow: 0 0 12px var(--accent-glow);
transition: width .8s cubic-bezier(.4,.1,.2,1);
}
.queue-bar-marker {
position: absolute; top: 50%;
transform: translate(-50%, -50%);
width: 14px; height: 14px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 3px var(--bg), 0 0 18px var(--accent-glow);
}
.queue-bar-marker span {
position: absolute; bottom: 100%; left: 50%;
transform: translate(-50%, -8px);
font-family: var(--font-mono);
font-size: 11px; color: var(--accent);
letter-spacing: 0.04em;
}
.queue-foot {
font-size: 12px; color: var(--fg-mute);
letter-spacing: 0.02em;
}
.queue-foot b { color: var(--fg); font-weight: 500; }
/* Refer */
.refer {
padding: 28px;
background: linear-gradient(180deg, oklch(0.20 0.009 60 / 0.6), oklch(0.17 0.008 60 / 0.6));
border: 1px solid var(--hairline);
border-radius: 18px;
}
.refer-title {
margin-top: 12px;
font-size: 22px; font-weight: 500;
letter-spacing: -0.018em;
}
.refer-sub {
margin-top: 6px;
color: var(--fg-mute);
font-size: 14px;
}
.refer-row {
display: flex; gap: 8px; align-items: stretch;
margin-top: 18px;
}
.refer-link {
flex: 1;
padding: 12px 14px;
background: oklch(0.16 0.008 60);
border: 1px solid var(--hairline);
border-radius: 10px;
font-size: 13px;
letter-spacing: 0.01em;
color: var(--fg-dim);
display: flex; align-items: center;
overflow: hidden; text-overflow: ellipsis;
white-space: nowrap;
}
.refer-prefix { color: var(--fg-faint); }
.refer-link b { color: var(--accent); font-weight: 500; margin-left: 0; }
.refer-copy { height: auto; padding-inline: 18px; }
.refer-share {
display: flex; flex-wrap: wrap; gap: 8px;
margin-top: 14px;
}
.share-btn {
display: inline-flex; align-items: center; gap: 8px;
padding: 8px 14px;
border-radius: 999px;
border: 1px solid var(--hairline);
background: oklch(0.16 0.008 60 / 0.5);
color: var(--fg-dim);
font-size: 13px;
transition: border-color .15s, color .15s;
}
.share-btn:hover { color: var(--fg); border-color: var(--hairline-2); }
.build-echo {
padding: 24px 28px;
border: 1px dashed var(--hairline);
border-radius: 16px;
}
.build-echo-quote {
margin-top: 12px;
font-size: 18px;
color: var(--fg);
font-style: italic;
letter-spacing: -0.005em;
text-wrap: balance;
line-height: 1.4;
}
/* Benefits */
.benefits { margin-top: 64px; }
.benefits-head { text-align: center; margin-bottom: 26px; }
.benefits-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
}
@media (max-width: 720px) { .benefits-grid { grid-template-columns: 1fr; } }
.benefit {
padding: 24px;
background: linear-gradient(180deg, oklch(0.20 0.009 60 / 0.35), oklch(0.17 0.008 60 / 0.35));
border: 1px solid var(--hairline);
border-radius: 14px;
}
.benefit-icon {
width: 36px; height: 36px;
border-radius: 9px;
background: oklch(0.22 0.011 60);
border: 1px solid var(--hairline);
color: var(--accent);
display: grid; place-items: center;
margin-bottom: 14px;
}
.benefit-title {
font-size: 16px; font-weight: 500;
letter-spacing: -0.01em;
}
.benefit-body {
margin-top: 6px;
color: var(--fg-mute);
font-size: 13.5px;
line-height: 1.5;
}
.beta-footer {
padding: 24px 0;
border-top: 1px solid var(--hairline);
background: oklch(0.14 0.008 60);
}
.beta-footer-inner {
display: flex; justify-content: space-between; align-items: center;
gap: 16px; flex-wrap: wrap;
font-size: 11px;
color: var(--fg-faint);
letter-spacing: 0.03em;
}
`}</style>;
}
ReactDOM.createRoot(document.getElementById("root")).render(<BetaApp />);

View File

@@ -0,0 +1,150 @@
// Closing CTA + Footer.
function Closing() {
return (
<section className="section closing">
<style>{`
.closing {
padding-block: clamp(100px, 14vh, 180px);
position: relative; overflow: hidden;
text-align: center;
}
.closing-glow {
position: absolute; inset: 0;
pointer-events: none;
}
.closing-inner {
position: relative;
max-width: 900px; margin: 0 auto;
}
.closing-title {
font-size: clamp(40px, 6vw, 84px);
font-weight: 500; letter-spacing: -0.03em;
line-height: 1.02;
text-wrap: balance;
}
.closing-title .accent { color: var(--accent); }
.closing-title em {
font-style: normal;
background: linear-gradient(180deg, var(--accent), oklch(0.62 0.18 18));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.closing-sub {
margin-top: 28px;
font-size: clamp(17px, 1.6vw, 21px);
color: var(--fg-dim);
text-wrap: balance;
max-width: 640px; margin-inline: auto;
}
.closing-cta {
margin-top: 36px;
display: inline-flex; flex-direction: column; align-items: center; gap: 14px;
}
.closing-cta .btn { height: 56px; padding: 0 28px; font-size: 16px; }
.closing-cta .row {
display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
justify-content: center;
}
`}</style>
<Glow color="oklch(0.74 0.175 35 / 0.35)" size={1000}
style={{ top: "20%", left: "50%", transform: "translateX(-50%)" }} />
<Glow color="oklch(0.45 0.10 35 / 0.20)" size={600}
style={{ bottom: "-200px", left: "50%", transform: "translateX(-50%)" }} />
<div className="wrap closing-inner">
<h2 className="closing-title">
If you can <em>describe your business</em>,
<br/>you can <em>build the tool</em> that runs it.
</h2>
<p className="closing-sub">
Owned by you. No monthly rent. No homework.
<br/>All the way to customers.
</p>
<div className="closing-cta">
<div className="row">
<a href="Beta Signup.html" className="btn btn-primary">
Request invite <Arrow />
</a>
<a href="#how" className="btn btn-ghost">See how it works</a>
</div>
<TrustStrip items={["No credit card", "No homework", "No new tools to learn"]} />
</div>
</div>
</section>
);
}
function Footer() {
return (
<footer className="vibn-footer">
<style>{`
.vibn-footer {
position: relative;
padding: 40px 0 32px;
border-top: 1px solid var(--hairline);
background: oklch(0.14 0.008 60);
}
.vibn-footer-inner {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 32px;
flex-wrap: wrap;
}
.vibn-footer-trust {
display: flex; gap: 20px; align-items: center;
flex-wrap: wrap;
font-family: var(--font-mono);
font-size: 12px;
color: var(--fg-mute);
letter-spacing: 0.03em;
}
.vibn-footer-trust .item {
display: inline-flex; align-items: center; gap: 8px;
}
.vibn-footer-trust .sep { color: var(--fg-faint); }
.vibn-footer-bottom {
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid var(--hairline);
display: flex; justify-content: space-between; align-items: center;
gap: 16px;
flex-wrap: wrap;
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-faint);
letter-spacing: 0.04em;
}
.vibn-footer-links {
display: flex; gap: 18px;
}
.vibn-footer-links a:hover { color: var(--fg-dim); }
`}</style>
<div className="wrap">
<div className="vibn-footer-inner">
<Logo />
<div className="vibn-footer-trust">
<span className="item">🇨🇦 Built in Canada</span>
<span className="sep">·</span>
<span className="item">Your data stays safe</span>
<span className="sep">·</span>
<span className="item">No credit card to start</span>
</div>
</div>
<div className="vibn-footer-bottom">
<span>© 2026 Vibn Inc. · Made for makers, not engineers.</span>
<div className="vibn-footer-links">
<a href="#">Privacy</a>
<a href="#">Terms</a>
<a href="#">Status</a>
<a href="#">Changelog</a>
</div>
</div>
</div>
</footer>
);
}
Object.assign(window, { Closing, Footer });

View File

@@ -0,0 +1,134 @@
// Crossed-out list — technical terms struck through, ending in "Your AI handles
// all of it. You just keep building."
const CROSSED_TERMS = [
"Databases",
"Auth providers",
"GitHub",
"Hosting",
"API keys",
"Environment variables",
"Deployment",
"Backend code",
"Servers",
"DNS records",
"SSL certificates",
"CORS errors",
"Webhooks",
"Build pipelines",
"package.json",
"npm install",
];
function CrossedOut() {
return (
<section className="section crossed">
<style>{`
.crossed { padding-block: clamp(70px, 10vh, 130px); }
.crossed-head { text-align: center; max-width: 760px; margin: 0 auto 56px; }
.crossed-title {
font-size: clamp(36px, 4.8vw, 64px);
font-weight: 500; letter-spacing: -0.025em; line-height: 1.02;
text-wrap: balance;
}
.crossed-sub {
margin-top: 18px;
color: var(--fg-mute);
font-size: 17px;
}
.crossed-wall {
display: flex; flex-wrap: wrap; gap: 10px 14px;
justify-content: center;
max-width: 880px; margin: 0 auto;
}
.crossed-item {
position: relative;
font-family: var(--font-mono);
font-size: clamp(15px, 1.7vw, 22px);
font-weight: 400;
color: var(--fg-mute);
padding: 8px 14px;
border-radius: 8px;
background: oklch(0.20 0.009 60 / 0.45);
border: 1px solid var(--hairline);
letter-spacing: 0.005em;
overflow: hidden;
}
.crossed-item::after {
content: "";
position: absolute;
left: 8px; right: 8px;
top: 50%;
height: 2px;
transform: translateY(-50%) rotate(-1deg);
background: var(--accent);
box-shadow: 0 0 12px var(--accent-glow);
border-radius: 2px;
opacity: 0;
animation: strike 0.6s cubic-bezier(.7,.1,.3,1) forwards;
animation-delay: var(--delay, 0s);
}
.crossed-item .term {
opacity: 1;
animation: fade 0.4s ease forwards;
animation-delay: var(--delay, 0s);
display: inline-block;
}
@keyframes strike {
from { opacity: 0; transform: translateY(-50%) rotate(-1deg) scaleX(0); transform-origin: left center; }
to { opacity: 1; transform: translateY(-50%) rotate(-1deg) scaleX(1); transform-origin: left center; }
}
@keyframes fade {
to { opacity: 0.5; }
}
.crossed-closer {
margin-top: 56px;
text-align: center;
font-size: clamp(24px, 3vw, 36px);
font-weight: 500; letter-spacing: -0.02em;
line-height: 1.18;
text-wrap: balance;
max-width: 760px; margin-inline: auto; margin-top: 56px;
}
.crossed-closer .accent { color: var(--accent); }
.crossed-closer .sep {
display: block; width: 48px; height: 1px;
background: var(--hairline);
margin: 28px auto;
}
`}</style>
<div className="wrap">
<div className="crossed-head">
<Eyebrow>What you don't have to learn</Eyebrow>
<h2 className="crossed-title" style={{ marginTop: 18 }}>
All the stuff that made you give up last time.
</h2>
<p className="crossed-sub">Forget every word on this list.</p>
</div>
<div className="crossed-wall">
{CROSSED_TERMS.map((term, i) => (
<span
className="crossed-item"
key={term}
style={{ "--delay": `${0.12 + i * 0.06}s` }}
>
<span className="term">{term}</span>
</span>
))}
</div>
<p className="crossed-closer">
Your AI handles <span className="accent">all of it</span>.
<span className="sep" />
You just keep building.
</p>
</div>
</section>
);
}
Object.assign(window, { CrossedOut });

View File

@@ -0,0 +1,966 @@
// DesignCanvas.jsx — Figma-ish design canvas wrapper
// Warm gray grid bg + Sections + Artboards + PostIt notes.
// Artboards are reorderable (grip-drag), deletable, labels/titles are
// inline-editable, and any artboard can be opened in a fullscreen focus
// overlay (←/→/Esc). State persists to a .design-canvas.state.json sidecar
// via the host bridge. No assets, no deps.
//
// Usage:
// <DesignCanvas>
// <DCSection id="onboarding" title="Onboarding" subtitle="First-run variants">
// <DCArtboard id="a" label="A · Dusk" width={260} height={480}>…</DCArtboard>
// <DCArtboard id="b" label="B · Minimal" width={260} height={480}>…</DCArtboard>
// </DCSection>
// </DesignCanvas>
const DC = {
bg: '#f0eee9',
grid: 'rgba(0,0,0,0.06)',
label: 'rgba(60,50,40,0.7)',
title: 'rgba(40,30,20,0.85)',
subtitle: 'rgba(60,50,40,0.6)',
postitBg: '#fef4a8',
postitText: '#5a4a2a',
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
};
// One-time CSS injection (classes are dc-prefixed so they don't collide with
// the hosted design's own styles).
if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
const s = document.createElement('style');
s.id = 'dc-styles';
s.textContent = [
'.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}',
'.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}',
'[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}',
'[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}',
'[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}',
// isolation:isolate contains artboard content's z-indexes so a
// z-indexed child (sticky navbar etc.) can't paint over .dc-header or
// the .dc-menu popover that drops into the top of the card.
'.dc-card{isolation:isolate;transition:box-shadow .15s,transform .15s}',
'.dc-card *{scrollbar-width:none}',
'.dc-card *::-webkit-scrollbar{display:none}',
// Per-artboard header: grip + label on the left, delete/expand on the
// right. Single flex row; when the artboard's on-screen width is too
// narrow for both the label yields (ellipsis, then hidden entirely below
// ~4ch via the container query) and the buttons stay on the row.
'.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;',
' display:flex;align-items:center;container-type:inline-size}',
'.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}',
'.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}',
'.dc-grip:hover{background:rgba(0,0,0,.08)}',
'.dc-grip:active{cursor:grabbing}',
'.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;',
' display:flex;align-items:center;transition:background .12s;overflow:hidden}',
// Below ~4ch of label room: hide the label entirely, and drop the grip to
// hover-only (same reveal rule as .dc-btns) so a narrow header is clean
// until the card is moused.
'@container (max-width: 110px){',
' .dc-labeltext{display:none}',
' .dc-grip{opacity:0}',
' [data-dc-slot]:hover .dc-grip{opacity:1}',
'}',
'.dc-labeltext:hover{background:rgba(0,0,0,.05)}',
'.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}',
'.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}',
'.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}',
'[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-menu){opacity:1}',
'.dc-expand,.dc-kebab{width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;',
' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;',
' font:inherit;transition:background .12s,color .12s}',
'.dc-expand:hover,.dc-kebab:hover{background:rgba(0,0,0,.06);color:#2a251f}',
// Slot hosting an open menu floats above later siblings (which otherwise
// paint on top — same z-index:auto, later DOM order) so the popup isn't
// clipped by the next card.
'[data-dc-slot]:has(.dc-menu){z-index:10}',
'.dc-menu{position:absolute;top:100%;right:0;margin-top:4px;background:#fff;border-radius:8px;',
' box-shadow:0 8px 28px rgba(0,0,0,.18),0 0 0 1px rgba(0,0,0,.05);padding:4px;min-width:160px;z-index:10}',
'.dc-menu button{display:block;width:100%;padding:7px 10px;border:0;background:transparent;',
' border-radius:5px;font-family:inherit;font-size:13px;font-weight:500;line-height:1.2;',
' color:#29261b;cursor:pointer;text-align:left;transition:background .12s;white-space:nowrap}',
'.dc-menu button:hover{background:rgba(0,0,0,.05)}',
'.dc-menu hr{border:0;border-top:1px solid rgba(0,0,0,.08);margin:4px 2px}',
'.dc-menu .dc-danger{color:#c96442}',
'.dc-menu .dc-danger:hover{background:rgba(201,100,66,.1)}',
// Chrome (titles / labels / buttons) counter-scales against the viewport
// zoom so it stays a constant on-screen size. --dc-inv-zoom is set by
// DCViewport on every transform update and inherits to all descendants —
// any overlay inside the world (e.g. a TweaksPanel on an artboard) can use
// it the same way.
//
// The header uses transform:scale (out-of-flow, so layout impact doesn't
// matter) with its world-space width set to card-width / inv-zoom so that
// after counter-scaling its on-screen width exactly matches the card's —
// that's what lets the container query + text-overflow behave against the
// card's visible edge at every zoom level.
//
// The section head uses CSS zoom instead of transform so its layout box
// grows with the counter-scale, pushing the card row down — otherwise the
// constant-screen-size title would overflow into the (shrinking) world-
// space gap and overlap the artboard headers at low zoom.
'.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));',
' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left}',
'.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}',
].join('\n');
document.head.appendChild(s);
}
const DCCtx = React.createContext(null);
// Recursively unwrap React.Fragment so <>…</> grouping doesn't hide
// DCSection/DCArtboard children from the type-based walks below.
function dcFlatten(children) {
const out = [];
React.Children.forEach(children, (c) => {
if (c && c.type === React.Fragment) out.push(...dcFlatten(c.props.children));
else out.push(c);
});
return out;
}
// ─────────────────────────────────────────────────────────────
// DesignCanvas — stateful wrapper around the pan/zoom viewport.
// Owns runtime state (per-section order, renamed titles/labels, hidden
// artboards, focused artboard). Order/titles/labels/hidden persist to a
// .design-canvas.state.json
// sidecar next to the HTML. Reads go via plain fetch() so the saved
// arrangement is visible anywhere the HTML + sidecar are served together
// (omelette preview, direct link, downloaded zip). Writes go through the
// host's window.omelette bridge — editing requires the omelette runtime.
// Focus is ephemeral.
// ─────────────────────────────────────────────────────────────
const DC_STATE_FILE = '.design-canvas.state.json';
function DesignCanvas({ children, minScale, maxScale, style }) {
const [state, setState] = React.useState({ sections: {}, focus: null });
// Hold rendering until the sidecar read settles so the saved order/titles
// appear on first paint (no source-order flash). didRead gates writes until
// the read settles so the empty initial state can't clobber a slow read;
// skipNextWrite suppresses the one echo-write that would otherwise follow
// hydration.
const [ready, setReady] = React.useState(false);
const didRead = React.useRef(false);
const skipNextWrite = React.useRef(false);
React.useEffect(() => {
let off = false;
fetch('./' + DC_STATE_FILE)
.then((r) => (r.ok ? r.json() : null))
.then((saved) => {
if (off || !saved || !saved.sections) return;
skipNextWrite.current = true;
setState((s) => ({ ...s, sections: saved.sections }));
})
.catch(() => {})
.finally(() => { didRead.current = true; if (!off) setReady(true); });
const t = setTimeout(() => { if (!off) setReady(true); }, 150);
return () => { off = true; clearTimeout(t); };
}, []);
React.useEffect(() => {
if (!didRead.current) return;
if (skipNextWrite.current) { skipNextWrite.current = false; return; }
const t = setTimeout(() => {
window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {});
}, 250);
return () => clearTimeout(t);
}, [state.sections]);
// Build registries synchronously from children so FocusOverlay can read
// them in the same render. Fragments are flattened; wrapping in other
// elements still opts out of focus/reorder.
const registry = {}; // slotId -> { sectionId, artboard }
const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] }
const sectionOrder = [];
dcFlatten(children).forEach((sec) => {
if (!sec || sec.type !== DCSection) return;
const sid = sec.props.id ?? sec.props.title;
if (!sid) return;
sectionOrder.push(sid);
const persisted = state.sections[sid] || {};
const abs = [];
dcFlatten(sec.props.children).forEach((ab) => {
if (!ab || ab.type !== DCArtboard) return;
const aid = ab.props.id ?? ab.props.label;
if (aid) abs.push([aid, ab]);
});
// hidden is scoped to one source revision — when the agent regenerates
// (artboard-ID set changes), prior deletes don't apply to new content.
const srcKey = abs.map(([k]) => k).join('\x1f');
const hidden = persisted.srcKey === srcKey ? (persisted.hidden || []) : [];
const srcIds = [];
abs.forEach(([aid, ab]) => {
if (hidden.includes(aid)) return;
registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab };
srcIds.push(aid);
});
const kept = (persisted.order || []).filter((k) => srcIds.includes(k));
sectionMeta[sid] = {
title: persisted.title ?? sec.props.title,
subtitle: sec.props.subtitle,
slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))],
};
});
const api = React.useMemo(() => ({
state,
section: (id) => state.sections[id] || {},
patchSection: (id, p) => setState((s) => ({
...s,
sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } },
})),
setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })),
}), [state]);
// Esc exits focus; any outside pointerdown commits an in-progress rename.
React.useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); };
const onPd = (e) => {
const ae = document.activeElement;
if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur();
};
document.addEventListener('keydown', onKey);
document.addEventListener('pointerdown', onPd, true);
return () => {
document.removeEventListener('keydown', onKey);
document.removeEventListener('pointerdown', onPd, true);
};
}, [api]);
return (
<DCCtx.Provider value={api}>
<DCViewport minScale={minScale} maxScale={maxScale} style={style}>{ready && children}</DCViewport>
{state.focus && registry[state.focus] && (
<DCFocusOverlay entry={registry[state.focus]} sectionMeta={sectionMeta} sectionOrder={sectionOrder} />
)}
</DCCtx.Provider>
);
}
// ─────────────────────────────────────────────────────────────
// DCViewport — transform-based pan/zoom (internal)
//
// Input mapping (Figma-style):
// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events)
// • trackpad scroll → pan (two-finger)
// • mouse wheel → zoom (notched; distinguished from trackpad scroll)
// • middle-drag / primary-drag-on-bg → pan
//
// Transform state lives in a ref and is written straight to the DOM
// (translate3d + will-change) so wheel ticks don't go through React —
// keeps pans at 60fps on dense canvases.
// ─────────────────────────────────────────────────────────────
function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
const vpRef = React.useRef(null);
const worldRef = React.useRef(null);
const tf = React.useRef({ x: 0, y: 0, scale: 1 });
// Persist viewport across reloads so the user lands back where they were
// after an agent edit or browser refresh. The sandbox origin is already
// per-project; pathname keeps multiple canvas files in one project apart.
const tfKey = 'dc-viewport:' + location.pathname;
const saveT = React.useRef(0);
const lastPostedScale = React.useRef();
const apply = React.useCallback(() => {
const { x, y, scale } = tf.current;
const el = worldRef.current;
if (!el) return;
el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
// Exposed for zoom-invariant chrome (labels, buttons, TweaksPanel).
el.style.setProperty('--dc-inv-zoom', String(1 / scale));
// Keep the host toolbar's % readout in sync with the canvas scale. Pan
// ticks leave scale unchanged — skip the cross-frame post for those.
if (lastPostedScale.current !== scale) {
lastPostedScale.current = scale;
window.parent.postMessage({ type: '__dc_zoom', scale }, '*');
}
clearTimeout(saveT.current);
saveT.current = setTimeout(() => {
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
}, 200);
}, [tfKey]);
React.useLayoutEffect(() => {
const flush = () => {
clearTimeout(saveT.current);
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
};
try {
const s = JSON.parse(localStorage.getItem(tfKey) || 'null');
if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) {
tf.current = { x: s.x, y: s.y, scale: Math.min(maxScale, Math.max(minScale, s.scale)) };
apply();
}
} catch {}
// Flush on pagehide and unmount so a reload within the 200ms debounce
// window doesn't drop the last pan/zoom.
window.addEventListener('pagehide', flush);
return () => { window.removeEventListener('pagehide', flush); flush(); };
}, []);
React.useEffect(() => {
const vp = vpRef.current;
if (!vp) return;
const zoomAt = (cx, cy, factor) => {
const r = vp.getBoundingClientRect();
const px = cx - r.left, py = cy - r.top;
const t = tf.current;
const next = Math.min(maxScale, Math.max(minScale, t.scale * factor));
const k = next / t.scale;
// --dc-inv-zoom consumers (.dc-sectionhead's CSS zoom, each section's
// marginBottom) reflow on every scale change, vertically shifting the
// world layout — so a world point mathematically pinned under the cursor
// drifts as you zoom (content creeps up on zoom-in, down on zoom-out).
// Anchor the DOM element under the cursor instead: record its screen Y,
// apply the transform + --dc-inv-zoom, then cancel whatever vertical
// drift the reflow introduced so it stays put on screen.
let marker = null, markerY0 = 0;
if (k !== 1) {
const hit = document.elementFromPoint(cx, cy);
marker = hit && hit.closest ? hit.closest('[data-dc-slot],[data-dc-section]') : null;
if (marker) markerY0 = marker.getBoundingClientRect().top;
}
// keep the world point under the cursor fixed
t.x = px - (px - t.x) * k;
t.y = py - (py - t.y) * k;
t.scale = next;
apply();
if (marker) {
// A pure zoom around (cx, cy) maps screen Y → cy + (Y - cy) * k. Any
// departure after the --dc-inv-zoom reflow is the layout drift.
const drift = marker.getBoundingClientRect().top - (cy + (markerY0 - cy) * k);
if (Math.abs(drift) > 0.1) { t.y -= drift; apply(); }
}
};
// Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends
// line-mode deltas (Firefox) or large integer pixel deltas with no X
// component (Chrome/Safari, typically multiples of 100/120). Trackpad
// two-finger scroll sends small/fractional pixel deltas, often with
// non-zero deltaX. ctrlKey is set by the browser for trackpad pinch.
const isMouseWheel = (e) =>
e.deltaMode !== 0 ||
(e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);
const onWheel = (e) => {
e.preventDefault();
if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels
if ((e.ctrlKey || e.metaKey) && !isMouseWheel(e)) {
// trackpad pinch, or ctrl/cmd + smooth-scroll mouse. Notched
// wheels fall through to the fixed-step branch below.
zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01));
} else if (isMouseWheel(e)) {
// notched mouse wheel — fixed-ratio step per click
zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18));
} else {
// trackpad two-finger scroll — pan
tf.current.x -= e.deltaX;
tf.current.y -= e.deltaY;
apply();
}
};
// Safari sends native gesture* events for trackpad pinch with a smooth
// e.scale; preferring these over the ctrl+wheel fallback gives a much
// better feel there. No-ops on other browsers. Safari also fires
// ctrlKey wheel events during the same pinch — isGesturing makes
// onWheel drop those entirely so they neither zoom nor pan.
let gsBase = 1;
let isGesturing = false;
const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; };
const onGestureChange = (e) => {
e.preventDefault();
zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale);
};
const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; };
// Drag-pan: middle button anywhere, or primary button on canvas
// background (anything that isn't an artboard or an inline editor).
let drag = null;
const onPointerDown = (e) => {
const onBg = !e.target.closest('[data-dc-slot], .dc-editable');
if (!(e.button === 1 || (e.button === 0 && onBg))) return;
e.preventDefault();
vp.setPointerCapture(e.pointerId);
drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY };
vp.style.cursor = 'grabbing';
};
const onPointerMove = (e) => {
if (!drag || e.pointerId !== drag.id) return;
tf.current.x += e.clientX - drag.lx;
tf.current.y += e.clientY - drag.ly;
drag.lx = e.clientX; drag.ly = e.clientY;
apply();
};
const onPointerUp = (e) => {
if (!drag || e.pointerId !== drag.id) return;
vp.releasePointerCapture(e.pointerId);
drag = null;
vp.style.cursor = '';
};
// Host-driven zoom (toolbar % menu). Zooms around viewport centre so the
// visible midpoint stays fixed — matching the host's iframe-zoom feel.
const onHostMsg = (e) => {
const d = e.data;
if (d && d.type === '__dc_set_zoom' && typeof d.scale === 'number') {
const r = vp.getBoundingClientRect();
zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / tf.current.scale);
} else if (d && d.type === '__dc_probe') {
// Host's [readyGen] reset asks whether a canvas is present; it
// fires on the iframe's native 'load', which for canvases with
// images/fonts is after our mount-time announce, so re-announce.
// Clear the pan-tick guard so apply() re-posts the current scale
// even if it's unchanged — the host just reset dcScale to 1.
window.parent.postMessage({ type: '__dc_present' }, '*');
lastPostedScale.current = undefined;
apply();
}
};
window.addEventListener('message', onHostMsg);
// Announce canvas mode so the host toolbar proxies its % control here
// instead of scaling the iframe element (which would just shrink the
// viewport window of an infinite canvas). The apply() that follows emits
// the initial __dc_zoom so the toolbar % is correct before first pinch.
// lastPostedScale reset mirrors the __dc_probe handler: the layout
// effect's restore-path apply() may already have posted the restored
// scale (before __dc_present), so clear the guard to re-post it in order.
window.parent.postMessage({ type: '__dc_present' }, '*');
lastPostedScale.current = undefined;
apply();
vp.addEventListener('wheel', onWheel, { passive: false });
vp.addEventListener('gesturestart', onGestureStart, { passive: false });
vp.addEventListener('gesturechange', onGestureChange, { passive: false });
vp.addEventListener('gestureend', onGestureEnd, { passive: false });
vp.addEventListener('pointerdown', onPointerDown);
vp.addEventListener('pointermove', onPointerMove);
vp.addEventListener('pointerup', onPointerUp);
vp.addEventListener('pointercancel', onPointerUp);
return () => {
window.removeEventListener('message', onHostMsg);
vp.removeEventListener('wheel', onWheel);
vp.removeEventListener('gesturestart', onGestureStart);
vp.removeEventListener('gesturechange', onGestureChange);
vp.removeEventListener('gestureend', onGestureEnd);
vp.removeEventListener('pointerdown', onPointerDown);
vp.removeEventListener('pointermove', onPointerMove);
vp.removeEventListener('pointerup', onPointerUp);
vp.removeEventListener('pointercancel', onPointerUp);
};
}, [apply, minScale, maxScale]);
const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`;
return (
<div
ref={vpRef}
className="design-canvas"
style={{
height: '100vh', width: '100vw',
background: DC.bg,
overflow: 'hidden',
overscrollBehavior: 'none',
touchAction: 'none',
position: 'relative',
fontFamily: DC.font,
boxSizing: 'border-box',
...style,
}}
>
<div
ref={worldRef}
style={{
position: 'absolute', top: 0, left: 0,
transformOrigin: '0 0',
willChange: 'transform',
width: 'max-content', minWidth: '100%',
minHeight: '100%',
padding: '60px 0 80px',
}}
>
<div style={{ position: 'absolute', inset: -6000, backgroundImage: gridSvg, backgroundSize: '120px 120px', pointerEvents: 'none', zIndex: -1 }} />
{children}
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// DCSection — editable title + h-row of artboards in persisted order
// ─────────────────────────────────────────────────────────────
function DCSection({ id, title, subtitle, children, gap = 48 }) {
const ctx = React.useContext(DCCtx);
const sid = id ?? title;
const all = React.Children.toArray(dcFlatten(children));
const artboards = all.filter((c) => c && c.type === DCArtboard);
const rest = all.filter((c) => !(c && c.type === DCArtboard));
const sec = (ctx && sid && ctx.section(sid)) || {};
// Must match DesignCanvas's srcKey computation exactly (it filters falsy
// IDs), or onDelete persists a srcKey that DesignCanvas never recognizes.
const allIds = artboards.map((a) => a.props.id ?? a.props.label).filter(Boolean);
const srcKey = allIds.join('\x1f');
const hidden = sec.srcKey === srcKey ? (sec.hidden || []) : [];
const srcOrder = allIds.filter((k) => !hidden.includes(k));
const order = React.useMemo(() => {
const kept = (sec.order || []).filter((k) => srcOrder.includes(k));
return [...kept, ...srcOrder.filter((k) => !kept.includes(k))];
}, [sec.order, srcOrder.join('|')]);
const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a]));
// marginBottom counter-scales so the on-screen gap between sections stays
// constant — otherwise at low zoom the (world-space) gap collapses while
// the screen-constant sectionhead below it doesn't, and the title reads as
// belonging to the section above. paddingBottom below is just enough for
// the 24px artboard-header (abs-positioned above each card) plus ~8px, so
// the title sits tight against its own row at every zoom.
return (
<div data-dc-section={sid}
style={{ marginBottom: 'calc(80px * var(--dc-inv-zoom, 1))', position: 'relative' }}>
<div style={{ padding: '0 60px' }}>
<div className="dc-sectionhead" style={{ paddingBottom: 36 }}>
<DCEditable tag="div" value={sec.title ?? title}
onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
{subtitle && <div style={{ fontSize: 16, color: DC.subtitle }}>{subtitle}</div>}
</div>
</div>
<div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
{order.map((k) => (
<DCArtboardFrame key={k} sectionId={sid} artboard={byId[k]} order={order}
label={(sec.labels || {})[k] ?? byId[k].props.label}
onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })}
onDelete={() => ctx && ctx.patchSection(sid, (x) => ({
hidden: [...(x.srcKey === srcKey ? (x.hidden || []) : []), k],
srcKey,
}))}
onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} />
))}
</div>
{rest}
</div>
);
}
// DCArtboard — marker; rendered by DCArtboardFrame via DCSection.
function DCArtboard() { return null; }
// Per-artboard export (kind: 'png' | 'html'). Both paths share the same
// self-contained clone: computed styles baked in, @font-face / <img> /
// inline-style background-image urls inlined as data URIs. PNG wraps the
// clone in foreignObject→canvas at 3× the artboard's natural width×height
// (same pipeline the host uses for page captures); HTML wraps it in a
// minimal standalone document. Both are independent of viewport zoom.
async function dcExport(node, w, h, name, kind) {
try { await document.fonts.ready; } catch {}
const toDataURL = (url) => fetch(url).then((r) => r.blob()).then((b) => new Promise((res) => {
const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = () => res(url); fr.readAsDataURL(b);
})).catch(() => url);
// Collect @font-face rules. ss.cssRules throws SecurityError on
// cross-origin sheets (e.g. fonts.googleapis.com) — in that case fetch
// the CSS text directly (those endpoints send ACAO:*) and regex-extract
// the blocks. @import and @media/@supports are walked so nested
// @font-face rules aren't missed.
const fontRules = [], pending = [], seen = new Set();
const scrapeCss = (href) => {
if (seen.has(href)) return; seen.add(href);
pending.push(fetch(href).then((r) => r.text()).then((css) => {
for (const m of css.match(/@font-face\s*{[^}]*}/g) || []) fontRules.push({ css: m, base: href });
for (const m of css.matchAll(/@import\s+(?:url\()?['"]?([^'")\s;]+)/g))
scrapeCss(new URL(m[1], href).href);
}).catch(() => {}));
};
const walk = (rules, base) => {
for (const r of rules) {
if (r.type === CSSRule.FONT_FACE_RULE) fontRules.push({ css: r.cssText, base });
else if (r.type === CSSRule.IMPORT_RULE && r.styleSheet) {
const ibase = r.styleSheet.href || base;
try { walk(r.styleSheet.cssRules, ibase); } catch { scrapeCss(ibase); }
} else if (r.cssRules) walk(r.cssRules, base);
}
};
for (const ss of document.styleSheets) {
const base = ss.href || location.href;
try { walk(ss.cssRules, base); } catch { if (ss.href) scrapeCss(ss.href); }
}
while (pending.length) await pending.shift();
const fontCss = (await Promise.all(fontRules.map(async (rule) => {
let out = rule.css, m; const re = /url\((['"]?)([^'")]+)\1\)/g;
while ((m = re.exec(rule.css))) {
if (m[2].indexOf('data:') === 0) continue;
let abs; try { abs = new URL(m[2], rule.base).href; } catch { continue; }
out = out.split(m[0]).join('url("' + await toDataURL(abs) + '")');
}
return out;
}))).join('\n');
const cloneStyled = (src) => {
if (src.nodeType === 8 || (src.nodeType === 1 && src.tagName === 'SCRIPT')) return document.createTextNode('');
const dst = src.cloneNode(false);
if (src.nodeType === 1) {
const cs = getComputedStyle(src); let txt = '';
for (let i = 0; i < cs.length; i++) txt += cs[i] + ':' + cs.getPropertyValue(cs[i]) + ';';
dst.setAttribute('style', txt + 'animation:none;transition:none;');
if (src.tagName === 'CANVAS') try { const im = document.createElement('img'); im.src = src.toDataURL(); im.setAttribute('style', txt); return im; } catch {}
}
for (let c = src.firstChild; c; c = c.nextSibling) dst.appendChild(cloneStyled(c));
return dst;
};
const clone = cloneStyled(node);
clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
// Drop the card's own shadow/radius so the export is a flush w×h rect;
// the artboard's own background (if any) is already in the computed style.
clone.style.boxShadow = 'none'; clone.style.borderRadius = '0';
const jobs = [];
clone.querySelectorAll('img').forEach((el) => {
const s = el.getAttribute('src');
if (s && s.indexOf('data:') !== 0) jobs.push(toDataURL(el.src).then((d) => el.setAttribute('src', d)));
});
[clone, ...clone.querySelectorAll('*')].forEach((el) => {
const bg = el.style.backgroundImage; if (!bg) return;
let m; const re = /url\(["']?([^"')]+)["']?\)/g;
while ((m = re.exec(bg))) {
const tok = m[0], url = m[1];
if (url.indexOf('data:') === 0) continue;
jobs.push(toDataURL(url).then((d) => { el.style.backgroundImage = el.style.backgroundImage.split(tok).join('url("' + d + '")'); }));
}
});
await Promise.all(jobs);
const xml = new XMLSerializer().serializeToString(clone);
const save = (blob, ext) => {
if (!blob) return;
const a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.download = name + '.' + ext; a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
};
if (kind === 'html') {
const html = '<!doctype html><html><head><meta charset="utf-8"><title>' + name + '</title>' +
(fontCss ? '<style>' + fontCss + '</style>' : '') +
'</head><body style="margin:0">' + xml + '</body></html>';
return save(new Blob([html], { type: 'text/html' }), 'html');
}
// PNG: the SVG's own width/height must be the output resolution — an
// <img>-loaded SVG rasterizes at its intrinsic size, so sizing it at 1×
// and ctx.scale()-ing up would just upscale a 1× bitmap. viewBox maps the
// w×h foreignObject onto the px·w × px·h SVG canvas so the browser renders
// the HTML at full resolution.
const px = 3;
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + w * px + '" height="' + h * px +
'" viewBox="0 0 ' + w + ' ' + h + '"><foreignObject width="' + w + '" height="' + h + '">' +
(fontCss ? '<style><![CDATA[' + fontCss + ']]></style>' : '') + xml + '</foreignObject></svg>';
const img = new Image();
await new Promise((res, rej) => {
img.onload = res; img.onerror = () => rej(new Error('svg load failed'));
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
});
const cv = document.createElement('canvas');
cv.width = w * px; cv.height = h * px;
cv.getContext('2d').drawImage(img, 0, 0);
cv.toBlob((blob) => save(blob, 'png'), 'image/png');
}
function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus, onDelete }) {
const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props;
const id = rawId ?? rawLabel;
const ref = React.useRef(null);
const cardRef = React.useRef(null);
const menuRef = React.useRef(null);
const [menuOpen, setMenuOpen] = React.useState(false);
const [confirming, setConfirming] = React.useState(false);
// ⋯ menu: close on any outside pointerdown. Two-click delete lives inside
// the menu — first click arms the row, second commits; closing disarms.
React.useEffect(() => {
if (!menuOpen) { setConfirming(false); return; }
const off = (e) => { if (!menuRef.current || !menuRef.current.contains(e.target)) setMenuOpen(false); };
document.addEventListener('pointerdown', off, true);
return () => document.removeEventListener('pointerdown', off, true);
}, [menuOpen]);
const doExport = (kind) => {
setMenuOpen(false);
if (!cardRef.current) return;
const name = String(label || id || 'artboard').replace(/[^\w\s.-]+/g, '_');
dcExport(cardRef.current, width, height, name, kind)
.catch((e) => console.error('[design-canvas] export failed:', e));
};
// Live drag-reorder: dragged card sticks to cursor; siblings slide into
// their would-be slots in real time via transforms. DOM order only
// changes on drop.
const onGripDown = (e) => {
e.preventDefault(); e.stopPropagation();
const me = ref.current;
// translateX is applied in local (pre-scale) space but pointer deltas and
// getBoundingClientRect().left are screen-space — divide by the viewport's
// current scale so the dragged card tracks the cursor at any zoom level.
const scale = me.getBoundingClientRect().width / me.offsetWidth || 1;
const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`));
const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left }));
const slotXs = homes.map((h) => h.x);
const startIdx = order.indexOf(id);
const startX = e.clientX;
let liveOrder = order.slice();
me.classList.add('dc-dragging');
const layout = () => {
for (const h of homes) {
if (h.id === id) continue;
const slot = liveOrder.indexOf(h.id);
h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`;
}
};
const move = (ev) => {
const dx = ev.clientX - startX;
me.style.transform = `translateX(${dx / scale}px)`;
const cur = homes[startIdx].x + dx;
let nearest = 0, best = Infinity;
for (let i = 0; i < slotXs.length; i++) {
const d = Math.abs(slotXs[i] - cur);
if (d < best) { best = d; nearest = i; }
}
if (liveOrder.indexOf(id) !== nearest) {
liveOrder = order.filter((k) => k !== id);
liveOrder.splice(nearest, 0, id);
layout();
}
};
const up = () => {
document.removeEventListener('pointermove', move);
document.removeEventListener('pointerup', up);
const finalSlot = liveOrder.indexOf(id);
me.classList.remove('dc-dragging');
me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`;
// After the settle transition, kill transitions + clear transforms +
// commit the reorder in the same frame so there's no visual snap-back.
setTimeout(() => {
for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; }
if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder);
requestAnimationFrame(() => requestAnimationFrame(() => {
for (const h of homes) h.el.style.transition = '';
}));
}, 180);
};
document.addEventListener('pointermove', move);
document.addEventListener('pointerup', up);
};
return (
<div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
<div className="dc-header" data-noncommentable="" style={{ color: DC.label }} onPointerDown={(e) => e.stopPropagation()}>
<div className="dc-labelrow">
<div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
<svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
</div>
<div className="dc-labeltext" onClick={onFocus} title="Click to focus">
<DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
</div>
</div>
<div className="dc-btns">
<div ref={menuRef} style={{ position: 'relative' }}>
<button className="dc-kebab" title="More" onClick={() => setMenuOpen((o) => !o)}>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><circle cx="2.5" cy="6" r="1.1"/><circle cx="6" cy="6" r="1.1"/><circle cx="9.5" cy="6" r="1.1"/></svg>
</button>
{menuOpen && (
<div className="dc-menu" onPointerDown={(e) => e.stopPropagation()}>
<button onClick={() => doExport('png')}>Download PNG</button>
<button onClick={() => doExport('html')}>Download HTML</button>
<hr />
<button className="dc-danger"
onClick={() => { if (confirming) { setMenuOpen(false); onDelete(); } else setConfirming(true); }}>
{confirming ? 'Click again to delete' : 'Delete'}
</button>
</div>
)}
</div>
<button className="dc-expand" onClick={onFocus} title="Focus">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
</button>
</div>
</div>
<div ref={cardRef} className="dc-card"
style={{ borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06)', overflow: 'hidden', width, height, background: '#fff', ...style }}>
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>}
</div>
</div>
);
}
// Inline rename — commits on blur or Enter.
function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
const T = tag;
return (
<T className="dc-editable" contentEditable suppressContentEditableWarning
onClick={onClick}
onPointerDown={(e) => e.stopPropagation()}
onBlur={(e) => onChange && onChange(e.currentTarget.textContent)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
style={style}>{value}</T>
);
}
// ─────────────────────────────────────────────────────────────
// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across
// sections, Esc or backdrop click to exit.
// ─────────────────────────────────────────────────────────────
function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
const ctx = React.useContext(DCCtx);
const { sectionId, artboard } = entry;
const sec = ctx.section(sectionId);
const meta = sectionMeta[sectionId];
const peers = meta.slotIds;
const aid = artboard.props.id ?? artboard.props.label;
const idx = peers.indexOf(aid);
const secIdx = sectionOrder.indexOf(sectionId);
const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); };
const goSection = (d) => {
// Sections whose artboards are all deleted have slotIds:[] — step past
// them to the next non-empty section so ↑/↓ doesn't dead-end.
const n = sectionOrder.length;
for (let i = 1; i < n; i++) {
const ns = sectionOrder[(((secIdx + d * i) % n) + n) % n];
const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
if (first) { ctx.setFocus(`${ns}/${first}`); return; }
}
};
React.useEffect(() => {
const k = (e) => {
if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); }
if (e.key === 'ArrowRight') { e.preventDefault(); go(1); }
if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); }
if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); }
};
document.addEventListener('keydown', k);
return () => document.removeEventListener('keydown', k);
});
const { width = 260, height = 480, children } = artboard.props;
const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight });
React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []);
const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2));
const [ddOpen, setDd] = React.useState(false);
const Arrow = ({ dir, onClick }) => (
<button onClick={(e) => { e.stopPropagation(); onClick(); }}
style={{ position: 'absolute', top: '50%', [dir]: 28, transform: 'translateY(-50%)',
border: 'none', background: 'rgba(255,255,255,.08)', color: 'rgba(255,255,255,.9)',
width: 44, height: 44, borderRadius: 22, fontSize: 18, cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background .15s' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.18)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.08)')}>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d={dir === 'left' ? 'M11 3L5 9l6 6' : 'M7 3l6 6-6 6'} /></svg>
</button>
);
// Portal to body so position:fixed is the real viewport regardless of any
// transform on DesignCanvas's ancestors (including the canvas zoom itself).
return ReactDOM.createPortal(
<div onClick={() => ctx.setFocus(null)}
onWheel={(e) => e.preventDefault()}
style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)',
fontFamily: DC.font, color: '#fff' }}>
{/* top bar: section dropdown (left) · close (right) */}
<div onClick={(e) => e.stopPropagation()}
style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}>
<div style={{ position: 'relative' }}>
<button onClick={() => setDd((o) => !o)}
style={{ border: 'none', background: 'transparent', color: '#fff', cursor: 'pointer', padding: '6px 8px',
borderRadius: 6, textAlign: 'left', fontFamily: 'inherit' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: -0.3 }}>{meta.title}</span>
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" style={{ opacity: .7 }}><path d="M2 4l3.5 3.5L9 4"/></svg>
</span>
{meta.subtitle && <span style={{ display: 'block', fontSize: 13, opacity: .6, fontWeight: 400, marginTop: 2 }}>{meta.subtitle}</span>}
</button>
{ddOpen && (
<div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, background: '#2a251f', borderRadius: 8,
boxShadow: '0 8px 32px rgba(0,0,0,.4)', padding: 4, minWidth: 200, zIndex: 10 }}>
{sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => (
<button key={sid} onClick={() => { setDd(false); const f = sectionMeta[sid].slotIds[0]; if (f) ctx.setFocus(`${sid}/${f}`); }}
style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff',
padding: '8px 12px', borderRadius: 5, fontSize: 14, fontWeight: sid === sectionId ? 600 : 400, fontFamily: 'inherit' }}>
{sectionMeta[sid].title}
</button>
))}
</div>
)}
</div>
<div style={{ flex: 1 }} />
<button onClick={() => ctx.setFocus(null)}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.12)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
style={{ border: 'none', background: 'transparent', color: 'rgba(255,255,255,.7)', width: 32, height: 32,
borderRadius: 16, fontSize: 20, cursor: 'pointer', lineHeight: 1, transition: 'background .12s' }}>×</button>
</div>
{/* card centered, label + index below — only the card itself stops
propagation so any backdrop click (including the margins around
the card) exits focus */}
<div
style={{ position: 'absolute', top: 64, bottom: 56, left: 100, right: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
<div onClick={(e) => e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}>
<div style={{ width, height, transform: `scale(${scale})`, transformOrigin: 'top left', background: '#fff', borderRadius: 2, overflow: 'hidden',
boxShadow: '0 20px 80px rgba(0,0,0,.4)' }}>
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb' }}>{aid}</div>}
</div>
</div>
<div onClick={(e) => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}>
{(sec.labels || {})[aid] ?? artboard.props.label}
<span style={{ opacity: .5, marginLeft: 10, fontVariantNumeric: 'tabular-nums' }}>{idx + 1} / {peers.length}</span>
</div>
</div>
<Arrow dir="left" onClick={() => go(-1)} />
<Arrow dir="right" onClick={() => go(1)} />
{/* dots */}
<div onClick={(e) => e.stopPropagation()}
style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}>
{peers.map((p, i) => (
<button key={p} onClick={() => ctx.setFocus(`${sectionId}/${p}`)}
style={{ border: 'none', padding: 0, cursor: 'pointer', width: 6, height: 6, borderRadius: 3,
background: i === idx ? '#fff' : 'rgba(255,255,255,.3)' }} />
))}
</div>
</div>,
document.body,
);
}
// ─────────────────────────────────────────────────────────────
// Post-it — absolute-positioned sticky note
// ─────────────────────────────────────────────────────────────
function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) {
return (
<div style={{
position: 'absolute', top, left, right, bottom, width,
background: DC.postitBg, padding: '14px 16px',
fontFamily: '"Comic Sans MS", "Marker Felt", "Segoe Print", cursive',
fontSize: 14, lineHeight: 1.4, color: DC.postitText,
boxShadow: '0 2px 8px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)',
transform: `rotate(${rotate}deg)`,
zIndex: 5,
}}>{children}</div>
);
}
Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,412 @@
// Hero: the Reddit quote headline + prompt input.
// Visitors can type into the prompt; cycling placeholders, suggestion chips, submit handler logs to console.
// SMB-owner placeholders: each one frames "replace the stack" or "fit my biz",
// not "build a side project". The chips below match the same voice.
const HERO_PLACEHOLDERS = [
"A booking system for my barbershop — the way we actually book",
"One tool that replaces my POS, Square Appointments, and that spreadsheet",
"A customer portal for my plumbing business — quotes, jobs, invoices, one place",
"An inventory tool that fits my vintage shop, not Shopify's idea of one",
"A back-office system to replace QuickBooks plus six other things",
"A jobs + crews scheduler for my landscaping company, finally in one place",
];
const HERO_CHIPS = [
"📅 Booking system",
"🧾 Invoices + quotes",
"👥 Customer portal",
"📦 Inventory",
"🗂️ Back-office",
];
function Hero({ onStart, variant = "quote" }) {
const [text, setText] = React.useState("");
const [phIdx, setPhIdx] = React.useState(0);
const [phChars, setPhChars] = React.useState(0);
const [deleting, setDeleting] = React.useState(false);
const taRef = React.useRef(null);
// Type-on placeholder when textarea is empty.
React.useEffect(() => {
if (text.length > 0) return undefined;
const full = HERO_PLACEHOLDERS[phIdx];
const speed = deleting ? 18 : 38;
const t = setTimeout(() => {
if (!deleting) {
if (phChars < full.length) setPhChars(phChars + 1);
else setTimeout(() => setDeleting(true), 1700);
} else {
if (phChars > 0) setPhChars(phChars - 1);
else {
setDeleting(false);
setPhIdx((phIdx + 1) % HERO_PLACEHOLDERS.length);
}
}
}, speed);
return () => clearTimeout(t);
}, [text, phIdx, phChars, deleting]);
const placeholder = HERO_PLACEHOLDERS[phIdx].slice(0, phChars);
const submit = () => {
const value = text || HERO_PLACEHOLDERS[phIdx];
if (onStart) onStart(value);
};
const useChip = (chip) => {
const clean = chip.replace(/^[^\w]+/, "").trim();
setText(`Build me a ${clean.toLowerCase()} for my business.`);
if (taRef.current) taRef.current.focus();
};
return (
<header className="section hero">
<style>{`
.hero {
padding-top: clamp(60px, 9vh, 120px);
padding-bottom: clamp(60px, 10vh, 120px);
position: relative;
overflow: hidden;
}
.hero-inner {
position: relative;
display: flex; flex-direction: column; align-items: center;
text-align: center;
gap: 28px;
}
.hero-quote {
font-size: clamp(44px, 7.4vw, 104px);
font-weight: 500;
letter-spacing: -0.035em;
line-height: 0.98;
text-wrap: balance;
position: relative;
color: var(--fg);
}
.hero-quote .mark {
color: var(--accent);
font-family: "Geist", serif;
font-weight: 500;
line-height: 0;
vertical-align: -0.05em;
margin-inline: -0.08em;
text-shadow: 0 0 30px var(--accent-glow);
}
.hero-attribution {
font-family: var(--font-mono);
font-size: 12px;
color: var(--fg-faint);
letter-spacing: 0.04em;
margin-top: 6px;
display: inline-flex; align-items: center; gap: 8px;
}
.hero-attribution::before, .hero-attribution::after {
content: ""; width: 24px; height: 1px;
background: var(--hairline);
}
.hero-sub {
font-size: clamp(20px, 2.2vw, 28px);
color: var(--fg-dim);
letter-spacing: -0.01em;
text-wrap: balance;
max-width: 720px;
}
.hero-sub b {
color: var(--fg);
font-weight: 500;
}
/* Prompt input */
.prompt {
width: 100%;
max-width: 720px;
margin-top: 14px;
position: relative;
}
.prompt-frame {
position: relative;
border-radius: var(--r-xl);
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: calc(var(--r-xl) - 1px);
padding: 18px 18px 14px;
backdrop-filter: blur(20px);
}
.prompt textarea {
width: 100%;
min-height: 96px;
background: transparent;
border: 0;
color: var(--fg);
font: 17px/1.45 var(--font-sans);
resize: none;
outline: none;
padding: 6px 4px;
}
.prompt textarea::placeholder {
color: var(--fg-faint);
}
.prompt-typed {
/* simulated placeholder w/ blinking caret */
position: absolute;
top: 24px; left: 22px; right: 22px;
pointer-events: none;
color: var(--fg-faint);
font: 17px/1.45 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;
gap: 14px;
margin-top: 6px;
padding-top: 12px;
border-top: 1px solid var(--hairline);
}
.prompt-tools {
display: flex; gap: 6px; color: var(--fg-mute);
}
.prompt-tool {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 10px; border-radius: 999px;
font-size: 12px;
color: var(--fg-mute);
border: 1px solid transparent;
transition: border-color .15s, color .15s;
}
.prompt-tool:hover { color: var(--fg-dim); border-color: var(--hairline); }
.prompt-send {
display: inline-flex; align-items: center; gap: 8px;
height: 36px; padding: 0 14px 0 16px;
border-radius: 999px;
background: var(--accent);
color: var(--accent-fg);
font-weight: 500; font-size: 14px;
box-shadow: 0 0 0 1px oklch(0.84 0.16 35 / 0.5) inset, 0 8px 28px -8px var(--accent-glow);
transition: transform .12s;
}
.prompt-send:hover { transform: translateY(-1px); }
.chips {
display: flex; flex-wrap: wrap; gap: 8px; justify-content: center;
margin-top: 12px;
font-size: 13px;
}
.chip {
padding: 7px 14px;
border-radius: 999px;
border: 1px solid var(--hairline);
background: oklch(0.20 0.009 60 / 0.4);
color: var(--fg-dim);
font-family: var(--font-sans);
transition: border-color .15s, color .15s, transform .12s;
}
.chip:hover { border-color: var(--hairline-2); color: var(--fg); transform: translateY(-1px); }
.hero-cta {
display: flex; gap: 12px; align-items: center;
margin-top: 10px;
flex-wrap: wrap; justify-content: center;
}
.live-pill {
display: inline-flex; align-items: center; gap: 8px;
padding: 6px 12px; border-radius: 999px;
background: oklch(0.78 0.16 155 / 0.10);
border: 1px solid oklch(0.78 0.16 155 / 0.35);
color: oklch(0.85 0.14 155);
font-family: var(--font-mono);
font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase;
}
.live-pill .dot {
width: 6px; height: 6px; 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;
}
/* Rally line — typographic, not a pill. Reads like the opening of
a manifesto: small caps, mono, coral hairlines either side. */
.rally {
display: inline-flex; align-items: center; gap: 14px;
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--accent);
white-space: nowrap;
margin-bottom: -8px;
}
.rally::before, .rally::after {
content: "";
width: 36px; height: 1px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
box-shadow: 0 0 8px var(--accent-glow);
}
.rally b {
color: var(--fg);
font-weight: 500;
}
@media (max-width: 540px) {
.rally { font-size: 10.5px; letter-spacing: 0.18em; gap: 10px; }
.rally::before, .rally::after { width: 18px; }
}
@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); }
}
@media (max-width: 760px) {
.prompt textarea { min-height: 80px; }
.prompt-typed { top: 22px; left: 20px; right: 20px; font-size: 15px; }
.prompt textarea { font-size: 15px; }
.prompt-tools { display: none; }
}
`}</style>
{/* ambient glows behind hero */}
<Glow color="oklch(0.74 0.175 35 / 0.30)" size={900}
style={{ top: "-200px", left: "50%", transform: "translateX(-50%)" }} />
<Glow color="oklch(0.45 0.10 35 / 0.30)" size={600}
style={{ top: "20%", left: "-200px" }} />
<Glow color="oklch(0.45 0.10 35 / 0.20)" size={500}
style={{ top: "30%", right: "-150px" }} />
<div className="wrap hero-inner">
<span className="live-pill"><span className="dot" /> Live from minute one</span>
{variant === "owner" ? (
<>
<span className="rally">To every <b>small business owner</b></span>
<h1 className="hero-quote">
Stop renting <span className="mark">software</span>
<br/>that doesn't fit.
</h1>
<p className="hero-sub">
Look at your subscriptions. Ask if they're doing the job.
<br/>Vibn builds the <b>one</b> tool that fits how your business actually runs <b>built for you, owned by you.</b>
</p>
</>
) : variant === "promise" ? (
<>
<h1 className="hero-quote">
Keep <span className="mark">vibing</span>.
<br/>All the way to launch.
</h1>
<div className="hero-attribution mono">idea live marketed customers</div>
<p className="hero-sub">
<b>"I built my product, now what?"</b> Vibn is the answer.
<br/>Your AI handles the technical stuff, puts your idea online, and helps you find your first customers.
</p>
</>
) : (
<>
<h1 className="hero-quote">
<span className="mark" style={{ fontSize: "0.95em" }}>"</span>I built my product,
<br/>now what<span className="mark" style={{ fontSize: "0.95em" }}>?"</span>
</h1>
<div className="hero-attribution mono">posted 2 hours ago · r/smallbusiness</div>
<p className="hero-sub">
<b>Keep vibing.</b> All the way to launch.
<br/>Your AI handles the technical stuff, puts your idea online, and helps you find your first customers.
</p>
</>
)}
{/* Prompt */}
<div className="prompt">
<div className="prompt-frame">
<div className="prompt-inner">
<div style={{ position: "relative" }}>
<textarea
ref={taRef}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) submit(); }}
placeholder=""
aria-label="Describe what you want to build"
/>
{text.length === 0 && (
<div className="prompt-typed">{placeholder}</div>
)}
</div>
<div className="prompt-bar">
<div className="prompt-tools">
<button className="prompt-tool" type="button" title="Attach a screenshot">
<PromptIcon name="paperclip" /> Screenshot
</button>
<button className="prompt-tool" type="button" title="Voice prompt">
<PromptIcon name="mic" /> Voice
</button>
<button className="prompt-tool" type="button" title="Start from a template">
<PromptIcon name="grid" /> Templates
</button>
</div>
<button className="prompt-send" type="button" onClick={submit}>
Start building <Arrow size={13} />
</button>
</div>
</div>
</div>
{/* Suggestion chips */}
<div className="chips">
{HERO_CHIPS.map((c) => (
<button className="chip" type="button" key={c} onClick={() => useChip(c)}>{c}</button>
))}
</div>
</div>
<div className="hero-cta">
<button className="btn btn-primary" type="button" onClick={submit}>
Start building free <Arrow />
</button>
<a href="#how" className="btn btn-ghost">See how it works</a>
</div>
<TrustStrip items={["No credit card", "No homework", "No new tools to learn"]} />
</div>
</header>
);
}
function PromptIcon({ name }) {
const props = { width: 13, height: 13, viewBox: "0 0 16 16", fill: "none",
stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round" };
if (name === "paperclip") return (
<svg {...props}><path d="M11.5 6.5 6.6 11.4a2 2 0 1 1-2.8-2.8l5.4-5.4a3.5 3.5 0 1 1 5 5L8.6 13.7" /></svg>
);
if (name === "mic") return (
<svg {...props}><rect x="6" y="2" width="4" height="8" rx="2"/><path d="M3.5 8a4.5 4.5 0 0 0 9 0M8 13v2"/></svg>
);
if (name === "grid") return (
<svg {...props}><rect x="2.5" y="2.5" width="4.5" height="4.5"/><rect x="9" y="2.5" width="4.5" height="4.5"/><rect x="2.5" y="9" width="4.5" height="4.5"/><rect x="9" y="9" width="4.5" height="4.5"/></svg>
);
return null;
}
Object.assign(window, { Hero });

View File

@@ -0,0 +1,221 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Vibn — Keep vibing. All the way to launch.</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" />
<style>
: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); /* warm coral, default */
--accent-soft: oklch(0.74 0.175 35 / 0.18);
--accent-glow: oklch(0.74 0.175 35 / 0.35);
--accent-fg: oklch(0.18 0.04 35);
--ok: oklch(0.78 0.16 155);
--r-sm: 8px;
--r-md: 12px;
--r-lg: 18px;
--r-xl: 28px;
--maxw: 1240px;
--pad: clamp(20px, 4vw, 56px);
--font-sans: "Geist", ui-sans-serif, system-ui, -apple-system, "Helvetica Neue", sans-serif;
--font-mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--fg);
font-family: var(--font-sans);
font-weight: 400;
line-height: 1.45;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overflow-x: hidden;
}
body::before {
/* subtle grid */
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 90% 70% at 50% 30%, #000 30%, transparent 80%);
-webkit-mask-image: radial-gradient(ellipse 90% 70% at 50% 30%, #000 30%, transparent 80%);
pointer-events: none;
z-index: 0;
}
body::after {
/* film grain */
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>");
}
/* base typography */
h1, h2, h3, h4 { margin: 0; font-weight: 500; letter-spacing: -0.02em; line-height: 1.05; }
p { margin: 0; }
a { color: inherit; text-decoration: none; }
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
::selection { background: var(--accent); color: var(--accent-fg); }
/* re-usable layout */
.wrap {
position: relative;
width: 100%;
max-width: var(--maxw);
margin: 0 auto;
padding-inline: var(--pad);
z-index: 2;
}
.mono { font-family: var(--font-mono); font-feature-settings: "ss01" on; }
.eyebrow {
display: inline-flex; align-items: center; gap: 8px;
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);
}
/* primary button */
.btn {
display: inline-flex; align-items: center; gap: 10px;
height: 46px; padding: 0 22px;
border-radius: 999px;
font-weight: 500;
transition: transform .12s ease, box-shadow .2s ease, background .2s ease;
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 50px -8px var(--accent-glow);
}
.btn-primary:hover { transform: translateY(-1px); }
.btn-primary .arrow { transition: transform .15s ease; }
.btn-primary:hover .arrow { transform: translateX(3px); }
.btn-ghost {
color: var(--fg-dim);
border: 1px solid var(--hairline);
background: oklch(0.20 0.009 60 / 0.4);
backdrop-filter: blur(8px);
}
.btn-ghost:hover { color: var(--fg); border-color: var(--hairline-2); }
/* gradient hairline card */
.card {
position: relative;
background: linear-gradient(180deg, oklch(0.20 0.009 60 / 0.55), oklch(0.17 0.008 60 / 0.5));
border-radius: var(--r-lg);
padding: 28px;
}
.card::before {
content: ""; position: absolute; inset: 0; border-radius: inherit; padding: 1px;
background: linear-gradient(180deg, var(--hairline-2), oklch(0.22 0.010 60 / 0.2));
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
/* hide ::-webkit-scrollbar globally where it'd jitter layout */
.no-scrollbar::-webkit-scrollbar { display: none; }
/* nav */
.nav {
position: sticky; top: 0;
z-index: 50;
backdrop-filter: blur(12px) saturate(140%);
-webkit-backdrop-filter: blur(12px) saturate(140%);
background: oklch(0.155 0.008 60 / 0.55);
border-bottom: 1px solid oklch(0.30 0.01 60 / 0.0);
transition: border-color .2s;
}
.nav.scrolled { border-bottom-color: oklch(0.30 0.01 60 / 0.4); }
.nav-inner {
display: flex; align-items: center; justify-content: space-between;
height: 64px;
}
.logo {
display: inline-flex; align-items: center; gap: 9px;
font-weight: 600; font-size: 17px; letter-spacing: -0.02em;
}
.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; } }
.nav-links { display: flex; gap: 28px; color: var(--fg-mute); font-size: 14px; }
.nav-links a:hover { color: var(--fg); }
.nav-cta { display: flex; gap: 10px; align-items: center; }
.nav-cta .btn { height: 36px; padding: 0 16px; font-size: 14px; }
@media (max-width: 760px) { .nav-links { display: none; } }
/* section spacing */
.section { position: relative; padding-block: clamp(80px, 11vh, 140px); }
.section-tight { padding-block: clamp(60px, 8vh, 100px); }
/* responsive grid helper */
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
@media (max-width: 880px) { .grid-3 { grid-template-columns: 1fr; } }
</style>
<!-- React + Babel -->
<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="tweaks-panel.jsx"></script>
<script type="text/babel" src="primitives.jsx"></script>
<script type="text/babel" src="hero.jsx"></script>
<script type="text/babel" src="wall.jsx"></script>
<script type="text/babel" src="crossed.jsx"></script>
<script type="text/babel" src="stack.jsx"></script>
<script type="text/babel" src="journey.jsx"></script>
<script type="text/babel" src="audience.jsx"></script>
<script type="text/babel" src="mission.jsx"></script>
<script type="text/babel" src="closing.jsx"></script>
<script type="text/babel" src="app.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,333 @@
// The Journey — 4 steps from idea → first 100 customers. A visual marker shows
// where most tools stop. Each step shows a tiny "demo" snippet of what Vibn does.
const JOURNEY_STEPS = [
{
num: "01",
title: "You describe it.",
sub: "The AI builds it.",
body: "Talk to it like you'd talk to a friend who codes. It builds the screens, the buttons, the logic — whatever your idea needs.",
demo: "describe",
},
{
num: "02",
title: "It goes live.",
sub: "The AI puts it online.",
body: "Logins, saving your stuff, hosting — handled. You get a live link from minute one. Share it. Show your friends. It just works.",
demo: "live",
},
{
num: "03",
title: "It gets seen.",
sub: "The AI markets it.",
body: "Posts, emails, social — written, scheduled, and shipped on autopilot. The tone matches your brand because you trained it talking to your AI.",
demo: "seen",
},
{
num: "04",
title: "It gets customers.",
sub: "Your first 100.",
body: "Through our Google partnership, Vibn helps the right people find your product when they're searching for what you built.",
demo: "customers",
},
];
function Journey() {
return (
<section className="section journey" id="how">
<style>{`
.journey { padding-block: clamp(80px, 11vh, 140px); }
.journey-head { text-align: center; max-width: 820px; margin: 0 auto 64px; }
.journey-title {
font-size: clamp(36px, 4.8vw, 64px);
font-weight: 500; letter-spacing: -0.025em; line-height: 1.02;
text-wrap: balance;
}
.journey-title .accent { color: var(--accent); }
.journey-sub {
margin-top: 20px;
color: var(--fg-mute); font-size: 17px;
text-wrap: balance;
}
.journey-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
position: relative;
}
@media (max-width: 1080px) { .journey-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 640px) { .journey-grid { grid-template-columns: 1fr; } }
.step {
position: relative;
padding: 24px 24px 0;
border-radius: 16px;
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);
display: flex; flex-direction: column;
min-height: 380px;
overflow: hidden;
isolation: isolate;
}
.step::before {
/* Top accent line that varies by step */
content: "";
position: absolute; top: 0; left: 0; right: 0; height: 1px;
background: linear-gradient(90deg, transparent, var(--accent) 50%, transparent);
opacity: 0;
}
.step.active::before { opacity: .7; }
.step.stopped {
opacity: 0.46;
}
.step.stopped::after {
content: "";
position: absolute; inset: 0;
background: linear-gradient(180deg, transparent 40%, oklch(0.155 0.008 60 / 0.6));
pointer-events: none;
}
.step-num {
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-faint);
letter-spacing: 0.08em;
}
.step-title {
margin-top: 12px;
font-size: 22px; font-weight: 500;
letter-spacing: -0.018em;
}
.step-sub {
margin-top: 4px;
color: var(--accent);
font-size: 15px;
font-weight: 500;
}
.step.stopped .step-sub { color: var(--fg-mute); }
.step-body {
margin-top: 12px;
color: var(--fg-dim);
font-size: 14px;
line-height: 1.55;
}
.step-demo {
margin-top: auto;
margin-inline: -24px; margin-bottom: 0;
padding: 16px 18px;
border-top: 1px solid var(--hairline);
background: oklch(0.16 0.008 60 / 0.6);
font-family: var(--font-mono);
font-size: 12px; line-height: 1.55;
color: var(--fg-dim);
min-height: 116px;
display: flex; flex-direction: column;
gap: 7px;
}
/* Visual marker: where other tools stop */
.stop-marker {
position: absolute;
left: calc(50% - 8px);
top: 0; bottom: 0;
width: 16px;
display: flex; flex-direction: column; align-items: center;
pointer-events: none;
z-index: 2;
}
@media (max-width: 1080px) { .stop-marker { display: none; } }
.stop-marker .line {
flex: 1; width: 1px;
background: repeating-linear-gradient(180deg, var(--accent) 0 6px, transparent 6px 12px);
opacity: .7;
}
.stop-label {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
background: var(--bg);
padding: 6px 12px;
border-radius: 999px;
border: 1px solid oklch(0.74 0.175 35 / 0.5);
white-space: nowrap;
box-shadow: 0 0 24px var(--accent-glow);
transform: translateY(-1px);
}
/* Demo blocks */
.demo-row { display: flex; gap: 8px; align-items: flex-start; }
.demo-tag {
font-family: var(--font-mono); font-size: 10px;
padding: 1px 6px; border-radius: 4px;
color: var(--fg-faint);
background: oklch(0.22 0.01 60);
letter-spacing: 0.04em;
flex-shrink: 0;
margin-top: 1px;
}
.demo-tag.you { color: oklch(0.85 0.06 250); background: oklch(0.28 0.04 250); }
.demo-tag.ai { color: var(--accent); background: oklch(0.35 0.10 35 / 0.4); }
.demo-line { color: var(--fg-dim); }
.demo-ok { color: var(--ok); }
.demo-host {
display: inline-flex; align-items: center; gap: 6px;
color: var(--fg-dim);
}
.demo-host i {
width: 6px; height: 6px; border-radius: 50%;
background: var(--ok);
box-shadow: 0 0 6px oklch(0.78 0.16 155 / 0.6);
}
.demo-progress {
height: 4px; border-radius: 999px;
background: oklch(0.25 0.01 60);
overflow: hidden;
position: relative;
}
.demo-progress span {
position: absolute; inset: 0;
background: var(--accent);
width: 64%;
box-shadow: 0 0 8px var(--accent-glow);
}
.demo-customer {
display: flex; align-items: center; gap: 8px;
}
.demo-customer .av {
width: 16px; height: 16px; border-radius: 50%;
flex-shrink: 0;
}
.demo-num {
font-family: var(--font-mono); font-size: 22px;
color: var(--accent);
letter-spacing: -0.02em;
font-weight: 500;
}
.demo-num small {
color: var(--fg-mute); font-size: 11px;
font-weight: 400;
margin-left: 4px;
}
.journey-foot {
margin-top: 48px;
text-align: center;
color: var(--fg-mute);
font-size: 15px;
text-wrap: balance;
}
.journey-foot b {
color: var(--fg);
font-weight: 500;
}
`}</style>
<div className="wrap">
<div className="journey-head">
<Eyebrow>The journey</Eyebrow>
<h2 className="journey-title" style={{ marginTop: 18 }}>
From idea to first 100 customers.
<br/><span className="accent">In one chat.</span>
</h2>
<p className="journey-sub">
Other tools take you to step two and wave goodbye. Vibn keeps building with you.
</p>
</div>
<div className="journey-grid">
{/* "Where everyone else stops" marker, sits over the gap between cards 2 and 3 */}
<div className="stop-marker" style={{ left: "calc(50% - 1px)" }}>
<div className="line" />
<span className="stop-label"> Where every other tool stops</span>
<div className="line" />
</div>
{JOURNEY_STEPS.map((step, i) => (
<StepCard key={step.num} step={step} stopped={i >= 2} />
))}
</div>
<p className="journey-foot">
<b>One tool. One chat.</b> From "wouldn't it be cool if…" to <b>real customers paying you money.</b>
</p>
</div>
</section>
);
}
function StepCard({ step, stopped }) {
return (
<div className={`step${stopped ? "" : " active"}`}>
<div>
<div className="step-num">{step.num}</div>
<h3 className="step-title">{step.title}</h3>
<div className="step-sub">{step.sub}</div>
<p className="step-body">{step.body}</p>
</div>
<StepDemo demo={step.demo} />
</div>
);
}
function StepDemo({ demo }) {
if (demo === "describe") {
return (
<div className="step-demo">
<div className="demo-row"><span className="demo-tag you">YOU</span><span className="demo-line">build a booking site for my dog grooming biz</span></div>
<div className="demo-row"><span className="demo-tag ai">VIBN</span><span className="demo-line">on it designing screens</span></div>
<div className="demo-row" style={{ alignItems: "center" }}>
<span className="demo-tag ai">VIBN</span>
<span className="demo-ok"> booking flow ready</span>
</div>
</div>
);
}
if (demo === "live") {
return (
<div className="step-demo">
<div className="demo-row" style={{ alignItems: "center" }}>
<span className="demo-tag ai">VIBN</span>
<span className="demo-line">put it online</span>
</div>
<div className="demo-progress"><span /></div>
<div className="demo-row" style={{ alignItems: "center", marginTop: 2 }}>
<span className="demo-host"><i /> pawsandposh.vibn.app</span>
</div>
<div className="demo-row"><span className="demo-ok"> logins · saving · live</span></div>
</div>
);
}
if (demo === "seen") {
return (
<div className="step-demo">
<div className="demo-row"><span className="demo-tag ai">VIBN</span><span className="demo-line">draft a launch post for Instagram + email blast</span></div>
<div className="demo-row" style={{ color: "var(--fg-faint)" }}> scheduled for Tue 9:00 AM</div>
<div className="demo-row" style={{ color: "var(--fg-faint)" }}> scheduled for Thu 6:00 PM</div>
<div className="demo-row"><span className="demo-ok"> 3 channels on autopilot</span></div>
</div>
);
}
if (demo === "customers") {
return (
<div className="step-demo">
<div className="demo-row" style={{ alignItems: "center" }}>
<span className="demo-num">+47<small>this week</small></span>
</div>
<div className="demo-customer">
<span className="av" style={{ background: "oklch(0.55 0.14 35)" }} />
<span className="av" style={{ background: "oklch(0.55 0.14 260)" }} />
<span className="av" style={{ background: "oklch(0.55 0.14 155)" }} />
<span className="av" style={{ background: "oklch(0.55 0.14 80)" }} />
<span style={{ color: "var(--fg-mute)" }}>found you via Google</span>
</div>
<div className="demo-row"><span className="demo-ok"> tracking toward 100</span></div>
</div>
);
}
return null;
}
Object.assign(window, { Journey });

View File

@@ -0,0 +1,107 @@
// Mission whisper — three sentences, one CTA. Sits between Audience and
// Closing. Intentionally small, intentionally separate from the product pitch.
function Mission() {
return (
<section className="section mission">
<style>{`
.mission {
padding-block: clamp(70px, 10vh, 120px);
position: relative;
}
.mission-card {
position: relative;
max-width: 820px; margin: 0 auto;
padding: clamp(40px, 6vw, 64px) clamp(28px, 5vw, 56px);
border-radius: 24px;
background:
radial-gradient(ellipse 80% 60% at 50% 0%, oklch(0.74 0.175 35 / 0.10), transparent 70%),
linear-gradient(180deg, oklch(0.19 0.009 60 / 0.65), oklch(0.16 0.008 60 / 0.5));
border: 1px solid var(--hairline);
overflow: hidden;
text-align: center;
}
.mission-card::before {
content: "";
position: absolute; top: 0; left: 0; right: 0; height: 1px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
opacity: .7;
}
.mission-eye {
display: inline-flex; align-items: center; gap: 10px;
font-family: var(--font-mono);
font-size: 11px; letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--accent);
}
.mission-eye::before, .mission-eye::after {
content: ""; width: 24px; height: 1px;
background: oklch(0.74 0.175 35 / 0.4);
}
.mission-title {
margin-top: 18px;
font-size: clamp(28px, 3.6vw, 44px);
font-weight: 500;
letter-spacing: -0.025em;
line-height: 1.08;
text-wrap: balance;
}
.mission-title em {
font-style: normal;
background: linear-gradient(180deg, var(--accent), oklch(0.62 0.18 18));
-webkit-background-clip: text; background-clip: text;
color: transparent;
}
.mission-body {
margin-top: 22px;
font-size: clamp(15px, 1.55vw, 18px);
color: var(--fg-dim);
line-height: 1.6;
max-width: 580px; margin-inline: auto;
text-wrap: balance;
}
.mission-body b {
color: var(--fg);
font-weight: 500;
}
.mission-cta {
margin-top: 28px;
display: inline-flex; align-items: center; gap: 8px;
padding: 10px 16px;
border-radius: 999px;
font-size: 14px;
font-weight: 500;
color: var(--accent);
border: 1px solid oklch(0.74 0.175 35 / 0.4);
background: oklch(0.74 0.175 35 / 0.08);
transition: background .15s, transform .12s, border-color .15s;
}
.mission-cta:hover {
background: oklch(0.74 0.175 35 / 0.16);
border-color: oklch(0.74 0.175 35 / 0.6);
transform: translateY(-1px);
}
`}</style>
<div className="wrap">
<div className="mission-card">
<div className="mission-eye">Why Vibn exists</div>
<h2 className="mission-title">
This is bigger than software.
<br/>It's <em>the golden age of small business.</em>
</h2>
<p className="mission-body">
For twenty years, small business got the leftovers generic tools, monthly rent,
software built for someone else. <b>AI changes the math.</b> The custom system a business
needs to actually thrive is finally something they can have, own, and afford.
</p>
<a href="#" className="mission-cta">
Read our mission <Arrow size={12} />
</a>
</div>
</div>
</section>
);
}
Object.assign(window, { Mission });

View File

@@ -0,0 +1,753 @@
// ============================================================
// 4 modern SaaS nav layouts. Each artboard is a full 1440×900
// app/marketing chrome with the nav as the focal point and just
// enough body content to read context. Original brand "Lattice
// Studio" used throughout so the navs feel like one product
// family — the variable is the nav pattern itself.
// ============================================================
// Generic placeholder block
const NavImgSlot = ({ label, h = 200, tone = "light" }) => {
const p = tone === "dark"
? { bg: "#1a1a1f", stripe: "#222229", ink: "#7a7a85" }
: { bg: "#f3f3f0", stripe: "#e7e7e3", ink: "#7a7a72" };
return (
<div style={{
width: "100%", height: h, position: "relative",
backgroundImage: `repeating-linear-gradient(135deg, ${p.bg} 0 14px, ${p.stripe} 14px 15px)`,
display: "flex", alignItems: "center", justifyContent: "center",
}}>
<span style={{
fontFamily: "ui-monospace, 'SF Mono', Menlo, monospace",
fontSize: 10, letterSpacing: "0.1em", textTransform: "uppercase",
color: p.ink, padding: "3px 8px",
border: `1px solid ${p.ink}40`, background: `${p.bg}d0`,
}}>{label}</span>
</div>
);
};
// Tiny stroke icon helper — single line so it doesn't bloat the file
const I = ({ d, size = 16, sw = 1.6 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth={sw}
strokeLinecap="round" strokeLinejoin="round">{d}</svg>
);
// Common path sets, kept terse
const Paths = {
search: <><circle cx="11" cy="11" r="7"/><path d="m20 20-3-3"/></>,
bell: <><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10 21a2 2 0 0 0 4 0"/></>,
cmd: <path d="M9 3a3 3 0 0 0-3 3v3H3M15 3a3 3 0 0 1 3 3v3h3M9 21a3 3 0 0 1-3-3v-3H3M15 21a3 3 0 0 0 3-3v-3h3M9 9h6v6H9z"/>,
home: <><path d="m3 12 9-9 9 9"/><path d="M5 10v10h14V10"/></>,
inbox: <><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5 5h14l3 7v7H2v-7z"/></>,
people: <><circle cx="9" cy="8" r="4"/><path d="M3 21a6 6 0 0 1 12 0"/><circle cx="17" cy="6" r="3"/><path d="M21 17a4 4 0 0 0-6-3.5"/></>,
building: <><path d="M3 21h18M5 21V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16"/><path d="M9 7h2M13 7h2M9 11h2M13 11h2M9 15h2M13 15h2"/></>,
target: <><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1"/></>,
check: <><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></>,
workflow: <><rect x="3" y="3" width="6" height="6" rx="1"/><rect x="15" y="15" width="6" height="6" rx="1"/><path d="M9 6h6a3 3 0 0 1 3 3v6"/></>,
bar: <><path d="M3 3v18h18"/><rect x="7" y="11" width="3" height="7"/><rect x="13" y="7" width="3" height="11"/><rect x="19" y="14" width="0" height="4"/></>,
hash: <path d="M9 3l-2 18M17 3l-2 18M3 9h18M2 15h18"/>,
lock: <><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></>,
plus: <path d="M12 5v14M5 12h14"/>,
chevron: <path d="m6 9 6 6 6-6"/>,
arrow: <path d="M5 12h14M13 5l7 7-7 7"/>,
star: <path d="m12 3 2.6 6.2 6.7.5-5.1 4.4 1.6 6.6L12 17.3 6.2 20.7l1.6-6.6L2.7 9.7l6.7-.5z"/>,
doc: <><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6"/></>,
spark: <path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l3 3M15 15l3 3M6 18l3-3M15 9l3-3"/>,
};
const sansStack = "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif";
// ============================================================
// 1. SIDEBAR — workspace + sections + secondary
// (Linear / Notion / Twenty school)
// ============================================================
const NavSidebar = () => {
const ItemRow = ({ icon, label, count, active }) => (
<div style={{
display: "flex", alignItems: "center", gap: 10,
padding: "6px 10px", borderRadius: 6, fontSize: 13,
color: active ? "#111" : "#5a5a5e",
background: active ? "#ffffff" : "transparent",
boxShadow: active ? "0 1px 0 #00000008, 0 0 0 1px #00000008" : "none",
fontWeight: active ? 500 : 400, cursor: "pointer",
}}>
<span style={{ color: active ? "#5e5cff" : "#8a8a90", display: "flex" }}>
<I d={icon} size={15} />
</span>
<span style={{ flex: 1 }}>{label}</span>
{count && <span style={{
fontSize: 11, color: "#8a8a90", fontVariantNumeric: "tabular-nums",
}}>{count}</span>}
</div>
);
const SectionHeader = ({ label }) => (
<div style={{
fontSize: 11, color: "#8a8a90", letterSpacing: "0.04em",
padding: "16px 10px 6px", textTransform: "uppercase",
fontWeight: 500,
}}>{label}</div>
);
return (
<div style={{
width: "100%", height: "100%", display: "grid",
gridTemplateColumns: "248px 1fr",
background: "#fcfcfb", fontFamily: sansStack, color: "#111",
}}>
{/* SIDEBAR */}
<aside style={{
background: "#f5f5f2", borderRight: "1px solid #e8e8e3",
display: "flex", flexDirection: "column",
}}>
{/* Workspace switcher */}
<div style={{
padding: "12px 12px", display: "flex", alignItems: "center", gap: 10,
borderBottom: "1px solid #e8e8e3",
}}>
<div style={{
width: 26, height: 26, borderRadius: 6,
background: "linear-gradient(135deg, #6e6cff 0%, #b15bff 100%)",
display: "flex", alignItems: "center", justifyContent: "center",
color: "#fff", fontWeight: 700, fontSize: 13,
}}>L</div>
<div style={{ flex: 1, lineHeight: 1.2 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>Lattice Studio</div>
<div style={{ fontSize: 11, color: "#8a8a90" }}>Free · 4 members</div>
</div>
<span style={{ color: "#8a8a90", display: "flex" }}>
<I d={Paths.chevron} size={14} />
</span>
</div>
{/* Search */}
<div style={{ padding: "10px 12px" }}>
<div style={{
display: "flex", alignItems: "center", gap: 8, padding: "6px 10px",
background: "#fff", border: "1px solid #e8e8e3", borderRadius: 6,
fontSize: 12, color: "#8a8a90",
}}>
<I d={Paths.search} size={14} />
<span style={{ flex: 1 }}>Search</span>
<span style={{
fontSize: 10, padding: "1px 5px", border: "1px solid #e0e0d8",
borderRadius: 3, fontFamily: "monospace",
}}>K</span>
</div>
</div>
{/* Nav */}
<nav style={{ padding: "4px 8px", flex: 1, overflowY: "auto" }}>
<ItemRow icon={Paths.home} label="Home" />
<ItemRow icon={Paths.inbox} label="Inbox" count="12" />
<ItemRow icon={Paths.check} label="My tasks" count="3" />
<SectionHeader label="Workspaces" />
<ItemRow icon={Paths.hash} label="Marketing site" active />
<ItemRow icon={Paths.hash} label="Q2 launch" />
<ItemRow icon={Paths.hash} label="Brand refresh" />
<ItemRow icon={Paths.hash} label="Customer interviews" />
<SectionHeader label="Pinned" />
<ItemRow icon={Paths.doc} label="Roadmap · 2026" />
<ItemRow icon={Paths.doc} label="Design tokens" />
<ItemRow icon={Paths.doc} label="Onboarding flow" />
<SectionHeader label="Team" />
<ItemRow icon={Paths.people} label="People" />
<ItemRow icon={Paths.bar} label="Insights" />
<ItemRow icon={Paths.workflow} label="Automations" />
</nav>
{/* Footer */}
<div style={{
padding: "10px 12px", borderTop: "1px solid #e8e8e3",
display: "flex", alignItems: "center", gap: 10,
}}>
<div style={{
width: 24, height: 24, borderRadius: "50%", background: "#d4b8a8",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 11, fontWeight: 600, color: "#5a3e34",
}}>MR</div>
<div style={{ flex: 1, fontSize: 12 }}>
<div style={{ fontWeight: 500 }}>Mira Reyes</div>
<div style={{ color: "#8a8a90", fontSize: 11 }}>mira@lattice.co</div>
</div>
<span style={{ color: "#8a8a90", display: "flex" }}>
<I d={Paths.chevron} size={14} />
</span>
</div>
</aside>
{/* CONTENT */}
<main style={{ display: "flex", flexDirection: "column", overflow: "hidden" }}>
<div style={{
padding: "14px 28px", borderBottom: "1px solid #e8e8e3",
display: "flex", alignItems: "center", justifyContent: "space-between",
}}>
<div style={{ fontSize: 13, color: "#8a8a90" }}>
Workspaces / <span style={{ color: "#111" }}>Marketing site</span>
</div>
<div style={{ display: "flex", gap: 8 }}>
<button style={{
padding: "6px 12px", border: "1px solid #e0e0d8",
background: "#fff", borderRadius: 6, fontSize: 12, fontFamily: sansStack,
color: "#5a5a5e", cursor: "pointer",
}}>Share</button>
<button style={{
padding: "6px 12px", border: "none",
background: "#111", color: "#fff", borderRadius: 6, fontSize: 12,
fontFamily: sansStack, cursor: "pointer", display: "flex", alignItems: "center", gap: 6,
}}><I d={Paths.plus} size={12}/> New page</button>
</div>
</div>
<div style={{ padding: 36, flex: 1, overflow: "hidden" }}>
<h1 style={{
fontSize: 28, fontWeight: 600, letterSpacing: "-0.02em", margin: 0,
}}>Marketing site</h1>
<p style={{ color: "#5a5a5e", fontSize: 13, marginTop: 6 }}>
14 pages · last edited 4 minutes ago by Mira
</p>
<div style={{ marginTop: 28, display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16 }}>
{["Homepage", "Pricing", "Changelog"].map(t => (
<div key={t} style={{
background: "#fff", border: "1px solid #e8e8e3", borderRadius: 10,
padding: 16,
}}>
<NavImgSlot label={`${t} · preview`} h={110} />
<div style={{ fontSize: 13, fontWeight: 500, marginTop: 12 }}>{t}</div>
<div style={{ fontSize: 11, color: "#8a8a90", marginTop: 2 }}>
Edited 2h ago · Mira
</div>
</div>
))}
</div>
</div>
</main>
</div>
);
};
// ============================================================
// 2. ICON RAIL + CONTEXT PANEL — Slack / Discord / Mail school
// ============================================================
const NavIconRail = () => {
const RailIcon = ({ icon, active, badge, color }) => (
<div style={{
width: 40, height: 40, borderRadius: 10,
background: active ? color : "transparent",
color: active ? "#fff" : "#9a9aa6",
display: "flex", alignItems: "center", justifyContent: "center",
cursor: "pointer", position: "relative",
border: active ? "none" : "1px solid transparent",
}}>
<I d={icon} size={18} sw={2} />
{badge && (
<span style={{
position: "absolute", top: -2, right: -2, minWidth: 16, height: 16,
padding: "0 4px", background: "#ff4d5e", color: "#fff",
borderRadius: 8, fontSize: 10, fontWeight: 600,
display: "flex", alignItems: "center", justifyContent: "center",
border: "2px solid #0f0f14",
}}>{badge}</span>
)}
</div>
);
const ChannelItem = ({ name, active, unread, mention }) => (
<div style={{
display: "flex", alignItems: "center", gap: 8,
padding: "6px 10px", borderRadius: 6, fontSize: 13,
color: active ? "#fff" : (unread ? "#dcdce4" : "#9a9aa6"),
background: active ? "#ffffff14" : "transparent",
fontWeight: unread ? 500 : 400, cursor: "pointer",
}}>
<span style={{ opacity: 0.7 }}>#</span>
<span style={{ flex: 1 }}>{name}</span>
{mention && <span style={{
background: "#ff4d5e", color: "#fff", fontSize: 10, fontWeight: 600,
padding: "1px 6px", borderRadius: 8,
}}>{mention}</span>}
</div>
);
return (
<div style={{
width: "100%", height: "100%", display: "grid",
gridTemplateColumns: "72px 260px 1fr",
background: "#0f0f14", color: "#e8e8ee", fontFamily: sansStack,
}}>
{/* RAIL */}
<div style={{
background: "#08080c", borderRight: "1px solid #ffffff08",
display: "flex", flexDirection: "column", alignItems: "center",
padding: "12px 0", gap: 6,
}}>
<div style={{
width: 40, height: 40, borderRadius: 10,
background: "linear-gradient(135deg, #5e5cff 0%, #b15bff 100%)",
display: "flex", alignItems: "center", justifyContent: "center",
color: "#fff", fontWeight: 800, fontSize: 16, marginBottom: 6,
}}>L</div>
<div style={{ width: 24, height: 1, background: "#ffffff10", margin: "4px 0" }}></div>
<RailIcon icon={Paths.home} active color="#5e5cff" />
<RailIcon icon={Paths.inbox} badge="9" />
<RailIcon icon={Paths.people} />
<RailIcon icon={Paths.target} badge="2" />
<RailIcon icon={Paths.bar} />
<RailIcon icon={Paths.doc} />
<div style={{ flex: 1 }}></div>
<RailIcon icon={Paths.plus} />
<RailIcon icon={Paths.spark} />
<div style={{
width: 32, height: 32, borderRadius: "50%", marginTop: 4,
background: "#d4b8a8", display: "flex", alignItems: "center",
justifyContent: "center", fontSize: 12, fontWeight: 600, color: "#5a3e34",
border: "2px solid #08080c", boxShadow: "0 0 0 2px #5e5cff",
position: "relative",
}}>MR
<span style={{
position: "absolute", bottom: -2, right: -2, width: 12, height: 12,
background: "#22c55e", borderRadius: "50%", border: "2px solid #08080c",
}}></span>
</div>
</div>
{/* SECONDARY PANEL */}
<div style={{
background: "#13131a", borderRight: "1px solid #ffffff08",
display: "flex", flexDirection: "column",
}}>
<div style={{
padding: "16px 16px 12px",
borderBottom: "1px solid #ffffff08",
}}>
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
marginBottom: 12,
}}>
<span style={{ fontSize: 15, fontWeight: 600 }}>Lattice HQ</span>
<span style={{ color: "#9a9aa6", display: "flex" }}>
<I d={Paths.chevron} size={16} />
</span>
</div>
<div style={{
display: "flex", alignItems: "center", gap: 8,
padding: "7px 10px", background: "#08080c",
borderRadius: 7, fontSize: 12, color: "#9a9aa6",
}}>
<I d={Paths.search} size={13} />
<span style={{ flex: 1 }}>Jump to</span>
<span style={{
fontSize: 10, padding: "1px 5px",
background: "#ffffff08", borderRadius: 3, fontFamily: "monospace",
}}>K</span>
</div>
</div>
<div style={{ padding: "12px 8px", flex: 1, overflowY: "auto" }}>
<div style={{
fontSize: 11, color: "#6a6a78", padding: "8px 10px 4px",
textTransform: "uppercase", letterSpacing: "0.04em", fontWeight: 500,
display: "flex", justifyContent: "space-between",
}}>
<span>Channels</span>
<I d={Paths.plus} size={12} />
</div>
<ChannelItem name="general" />
<ChannelItem name="design-crits" unread mention="3" />
<ChannelItem name="launch-2026" active />
<ChannelItem name="random" />
<ChannelItem name="bugs" unread />
<div style={{
fontSize: 11, color: "#6a6a78", padding: "16px 10px 4px",
textTransform: "uppercase", letterSpacing: "0.04em", fontWeight: 500,
}}>Direct messages</div>
{[
["Sun Kim", "#e8a87c", true],
["Devi Patel", "#a8c8e8", false],
["Theo Roux", "#c8e8a8", false],
].map(([n, c, online], i) => (
<div key={i} style={{
display: "flex", alignItems: "center", gap: 10,
padding: "6px 10px", borderRadius: 6, fontSize: 13, color: "#dcdce4",
cursor: "pointer",
}}>
<div style={{ position: "relative" }}>
<div style={{ width: 22, height: 22, borderRadius: "50%", background: c }}></div>
{online && <span style={{
position: "absolute", bottom: -1, right: -1, width: 8, height: 8,
background: "#22c55e", borderRadius: "50%", border: "2px solid #13131a",
}}></span>}
</div>
<span style={{ flex: 1 }}>{n}</span>
</div>
))}
</div>
</div>
{/* CONTENT */}
<main style={{ display: "flex", flexDirection: "column" }}>
<div style={{
padding: "14px 24px", borderBottom: "1px solid #ffffff08",
display: "flex", alignItems: "center", gap: 12,
}}>
<span style={{ color: "#9a9aa6" }}>#</span>
<span style={{ fontSize: 15, fontWeight: 600 }}>launch-2026</span>
<span style={{ color: "#6a6a78", fontSize: 12 }}>· 18 members</span>
<div style={{ flex: 1 }}></div>
<span style={{ color: "#9a9aa6", display: "flex" }}><I d={Paths.bell} size={16}/></span>
<span style={{ color: "#9a9aa6", display: "flex" }}><I d={Paths.star} size={16}/></span>
</div>
<div style={{ padding: 28, flex: 1, color: "#9a9aa6", fontSize: 13 }}>
<NavImgSlot label="conversation thread" h={420} tone="dark" />
</div>
</main>
</div>
);
};
// ============================================================
// 3. TOP HORIZONTAL + COMMAND BAR — Vercel / Stripe / Linear web
// ============================================================
const NavTopHorizontal = () => {
const TabItem = ({ label, active }) => (
<div style={{
padding: "16px 2px", margin: "0 12px", fontSize: 13, fontWeight: 500,
color: active ? "#fff" : "#9a9aa6",
borderBottom: active ? "2px solid #fff" : "2px solid transparent",
cursor: "pointer", position: "relative", top: 1,
}}>{label}</div>
);
return (
<div style={{
width: "100%", height: "100%", background: "#fafaf9",
color: "#111", fontFamily: sansStack, display: "flex", flexDirection: "column",
}}>
{/* DARK TOP BAR */}
<header style={{ background: "#0a0a0a", color: "#fff" }}>
{/* Row 1: brand + workspace + global */}
<div style={{
display: "flex", alignItems: "center", gap: 14,
padding: "12px 24px",
}}>
{/* Brand */}
<div style={{
display: "flex", alignItems: "center", gap: 8, fontWeight: 600, fontSize: 14,
}}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M3 20 L12 4 L21 20 Z" fill="#fff"/>
</svg>
Lattice
</div>
<span style={{ color: "#3a3a3a" }}>/</span>
{/* Workspace */}
<div style={{
display: "flex", alignItems: "center", gap: 8, fontSize: 13,
}}>
<div style={{
width: 18, height: 18, borderRadius: "50%", background: "#e8a87c",
fontSize: 9, fontWeight: 700, color: "#5a3e34",
display: "flex", alignItems: "center", justifyContent: "center",
}}>MR</div>
<span>mira-reyes</span>
<span style={{ color: "#5a5a5e", display: "flex" }}><I d={Paths.chevron} size={12}/></span>
</div>
<span style={{ color: "#3a3a3a" }}>/</span>
{/* Project */}
<div style={{ fontSize: 13, display: "flex", alignItems: "center", gap: 6 }}>
<span>marketing-site</span>
<span style={{
fontSize: 10, padding: "1px 7px", borderRadius: 999,
background: "#1f1f1f", color: "#9a9aa6", border: "1px solid #2a2a2a",
}}>Hobby</span>
</div>
<div style={{ flex: 1 }}></div>
{/* Command bar — focal point */}
<div style={{
display: "flex", alignItems: "center", gap: 10,
padding: "6px 12px", borderRadius: 8,
background: "#1a1a1a", border: "1px solid #2a2a2a",
color: "#9a9aa6", fontSize: 12, minWidth: 320,
}}>
<I d={Paths.search} size={13} />
<span style={{ flex: 1 }}>Find or jump to anything</span>
<span style={{
fontSize: 10, padding: "1px 5px", background: "#2a2a2a",
borderRadius: 3, fontFamily: "monospace",
}}>K</span>
</div>
{/* Right icons */}
<button style={{
background: "transparent", border: "1px solid #2a2a2a",
color: "#fff", padding: "5px 12px", borderRadius: 6,
fontSize: 12, fontFamily: sansStack, cursor: "pointer",
}}>Feedback</button>
<span style={{ color: "#9a9aa6", display: "flex", cursor: "pointer" }}>
<I d={Paths.doc} size={16}/>
</span>
<span style={{ color: "#9a9aa6", display: "flex", cursor: "pointer", position: "relative" }}>
<I d={Paths.bell} size={16}/>
<span style={{
position: "absolute", top: -2, right: -2, width: 7, height: 7,
background: "#5e5cff", borderRadius: "50%",
}}></span>
</span>
<div style={{
width: 26, height: 26, borderRadius: "50%", background: "#d4b8a8",
fontSize: 11, fontWeight: 600, color: "#5a3e34",
display: "flex", alignItems: "center", justifyContent: "center",
cursor: "pointer",
}}>MR</div>
</div>
{/* Row 2: tabs */}
<div style={{
display: "flex", alignItems: "center",
padding: "0 16px", borderBottom: "1px solid #1a1a1a",
}}>
<TabItem label="Overview" active />
<TabItem label="Deployments" />
<TabItem label="Analytics" />
<TabItem label="Logs" />
<TabItem label="Storage" />
<TabItem label="Domains" />
<TabItem label="Settings" />
</div>
</header>
{/* CONTENT */}
<main style={{ flex: 1, padding: "32px 48px" }}>
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "flex-end",
marginBottom: 24,
}}>
<div>
<h1 style={{
fontSize: 32, fontWeight: 600, margin: 0, letterSpacing: "-0.02em",
}}>Overview</h1>
<p style={{ color: "#6a6a72", margin: "4px 0 0", fontSize: 13 }}>
Last deployment 14 minutes ago to <code style={{
background: "#f0efea", padding: "1px 5px", borderRadius: 3,
fontSize: 12,
}}>main</code>
</p>
</div>
<button style={{
background: "#111", color: "#fff", border: "none",
padding: "8px 16px", borderRadius: 6, fontSize: 13, fontWeight: 500,
fontFamily: sansStack, cursor: "pointer", display: "flex",
alignItems: "center", gap: 6,
}}><I d={Paths.plus} size={13}/> Deploy</button>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1.4fr 1fr", gap: 20 }}>
<div style={{
background: "#fff", border: "1px solid #ebebe6", borderRadius: 10,
padding: 20,
}}>
<div style={{ fontSize: 12, color: "#6a6a72", marginBottom: 12 }}>
Production · Last 24h
</div>
<NavImgSlot label="requests · time series" h={190} />
</div>
<div style={{
background: "#fff", border: "1px solid #ebebe6", borderRadius: 10,
padding: 20, display: "grid", gridTemplateRows: "1fr 1fr", gap: 14,
}}>
<div>
<div style={{ fontSize: 12, color: "#6a6a72" }}>Requests</div>
<div style={{ fontSize: 26, fontWeight: 600, marginTop: 4 }}>
284,012 <span style={{ color: "#22c55e", fontSize: 12 }}>+12%</span>
</div>
</div>
<div>
<div style={{ fontSize: 12, color: "#6a6a72" }}>Edge p99</div>
<div style={{ fontSize: 26, fontWeight: 600, marginTop: 4 }}>
47ms <span style={{ color: "#22c55e", fontSize: 12 }}>3ms</span>
</div>
</div>
</div>
</div>
</main>
</div>
);
};
// ============================================================
// 4. FLOATING GLASS NAV — marketing site / homepage pattern
// ============================================================
const NavFloatingGlass = () => (
<div style={{
width: "100%", height: "100%", color: "#fff",
background: "#08081a", fontFamily: sansStack,
position: "relative", overflow: "hidden",
}}>
{/* Soft aurora background */}
<div style={{
position: "absolute", top: -250, left: -150, width: 700, height: 700,
borderRadius: "50%",
background: "radial-gradient(circle, #5e5cff 0%, transparent 60%)",
filter: "blur(100px)", opacity: 0.5,
}}></div>
<div style={{
position: "absolute", top: 100, right: -200, width: 600, height: 600,
borderRadius: "50%",
background: "radial-gradient(circle, #b15bff 0%, transparent 60%)",
filter: "blur(100px)", opacity: 0.4,
}}></div>
<div style={{
position: "absolute", bottom: -200, left: "30%", width: 500, height: 500,
borderRadius: "50%",
background: "radial-gradient(circle, #00e5b3 0%, transparent 60%)",
filter: "blur(100px)", opacity: 0.3,
}}></div>
{/* Floating pill nav — the focal point */}
<header style={{
position: "absolute", top: 24, left: "50%",
transform: "translateX(-50%)", zIndex: 10,
width: "max-content", whiteSpace: "nowrap",
display: "flex", alignItems: "center", gap: 4,
padding: "8px 8px 8px 18px",
background: "rgba(255,255,255,0.06)",
backdropFilter: "blur(24px)",
WebkitBackdropFilter: "blur(24px)",
border: "1px solid rgba(255,255,255,0.12)",
borderRadius: 999,
boxShadow: "0 20px 50px -20px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.1)",
}}>
<div style={{
display: "flex", alignItems: "center", gap: 8,
marginRight: 18, fontWeight: 600, fontSize: 14,
}}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M3 20 L12 4 L21 20 Z" fill="url(#g)" />
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#5e5cff"/>
<stop offset="100%" stopColor="#b15bff"/>
</linearGradient>
</defs>
</svg>
Lattice
</div>
{["Product", "Solutions", "Customers", "Pricing", "Docs"].map((l, i) => (
<button key={l} style={{
background: i === 0 ? "rgba(255,255,255,0.1)" : "transparent",
border: "none", color: "#fff", whiteSpace: "nowrap",
padding: "8px 14px", borderRadius: 999,
fontSize: 13, fontFamily: sansStack, cursor: "pointer",
display: "flex", alignItems: "center", gap: 4,
}}>
{l}
{(i === 0 || i === 1) && (
<span style={{ opacity: 0.6, display: "flex" }}>
<I d={Paths.chevron} size={11} />
</span>
)}
</button>
))}
<div style={{ width: 1, height: 22, background: "rgba(255,255,255,0.12)", margin: "0 6px" }}></div>
<button style={{
background: "transparent", border: "none", color: "#fff",
padding: "8px 14px", borderRadius: 999, fontSize: 13,
fontFamily: sansStack, cursor: "pointer", whiteSpace: "nowrap",
}}>Sign in</button>
<button style={{
background: "#fff", color: "#08081a", border: "none",
padding: "8px 16px", borderRadius: 999, fontSize: 13, fontWeight: 600,
fontFamily: sansStack, cursor: "pointer", whiteSpace: "nowrap",
}}>Get started </button>
</header>
{/* Tiny status pill above nav */}
<div style={{
position: "absolute", top: -2, left: "50%",
transform: "translateX(-50%)",
padding: "4px 10px 4px 26px",
background: "rgba(255,255,255,0.04)",
backdropFilter: "blur(12px)",
border: "1px solid rgba(255,255,255,0.08)",
borderRadius: "0 0 10px 10px",
fontSize: 11, color: "#a8a8c0",
borderTop: "none",
}}>
All systems normal ·{" "}
<span style={{ color: "#7aff66" }}> 99.99% uptime</span>
</div>
{/* HERO */}
<main style={{
position: "relative", paddingTop: 180,
textAlign: "center", maxWidth: 880, margin: "0 auto",
padding: "180px 40px 0",
}}>
<div style={{
display: "inline-flex", alignItems: "center", gap: 8,
padding: "5px 14px 5px 5px", borderRadius: 999,
background: "rgba(255,255,255,0.06)",
border: "1px solid rgba(255,255,255,0.12)",
fontSize: 12, marginBottom: 28,
}}>
<span style={{
padding: "2px 8px", background: "#5e5cff", borderRadius: 999,
fontWeight: 600, fontSize: 10,
}}>NEW</span>
Lattice 4.0 agents that draft for you ·{" "}
<span style={{ opacity: 0.7 }}>read more </span>
</div>
<h1 style={{
fontSize: 76, lineHeight: 1, margin: 0, fontWeight: 500,
letterSpacing: "-0.04em", textWrap: "balance",
}}>
The workspace where{" "}
<span style={{
background: "linear-gradient(90deg, #b15bff, #5e5cff, #00e5b3)",
WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent",
fontStyle: "italic", fontWeight: 400,
}}>
good ideas
</span>{" "}
compound.
</h1>
<p style={{
fontSize: 17, lineHeight: 1.5, marginTop: 22, opacity: 0.7,
maxWidth: 540, marginLeft: "auto", marginRight: "auto",
}}>
Docs, canvases, and agents in one luminous surface.
Built by people who got tired of switching tabs.
</p>
<div style={{ display: "flex", gap: 12, justifyContent: "center", marginTop: 32 }}>
<button style={{
background: "#fff", color: "#08081a", border: "none",
padding: "14px 28px", borderRadius: 999, fontWeight: 600,
fontSize: 14, cursor: "pointer", fontFamily: sansStack,
whiteSpace: "nowrap",
}}>Start for free</button>
<button style={{
background: "rgba(255,255,255,0.08)", color: "#fff",
border: "1px solid rgba(255,255,255,0.16)",
backdropFilter: "blur(12px)",
padding: "14px 28px", borderRadius: 999, fontSize: 14,
cursor: "pointer", fontFamily: sansStack, whiteSpace: "nowrap",
}}> Watch the film · 2 min</button>
</div>
</main>
</div>
);
// Export to window
Object.assign(window, {
NavSidebar, NavIconRail, NavTopHorizontal, NavFloatingGlass,
});

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

@@ -1,11 +1,9 @@
import React, { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { WizardTop, WizardBody, WizardQ, WizardFooter, Label, LANE_LABELS, PresetGroup, Field } from "./onboarding-primitives";
// Consultant path 4 steps for freelancers building for a client.
const CONS_TOTAL = 4;
const CONS_STEP_NAMES = ["Client", "Brief", "Scope", "Handoff"];
export function ConsClient({ clientName, industry, contact, onChange }) {
function ConsClient({ clientName, industry, contact, onChange }) {
return (
<>
<WizardQ
@@ -238,7 +236,7 @@ function ConsHandoff({ data, onChange }) {
}
// Path wrapper
export function ConsultantPath({ data, onUpdate, onBack, onClose, onComplete, onJumpToStep, step }) {
function ConsultantPath({ data, onUpdate, onBack, onClose, onComplete, onJumpToStep, step }) {
const next = () => {
if (step < CONS_TOTAL - 1) onJumpToStep(step + 1);
else onComplete();
@@ -293,4 +291,4 @@ export function ConsultantPath({ data, onUpdate, onBack, onClose, onComplete, on
);
}
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,
});

View File

@@ -0,0 +1,300 @@
// ============================================================
// page-admin.jsx — Workspace settings, Members tab.
// Sub-nav (Workspace / Members / Roles / Integrations / Billing
// / API) + searchable member table + bulk actions + invite row.
// ============================================================
const AdminBody = ({ theme = "light", hideSubnav = false }) => {
const dark = theme === "dark";
const c = dark ? {
bg: "#0f0f14", panel: "#13131a", border: "#ffffff10",
text: "#e8e8ee", subtext: "#9a9aa6", muted: "#6a6a78",
rowAlt: "#ffffff04", input: "#08080c", accent: "#7a78ff",
chipBg: "#ffffff08", chipText: "#dcdce4", danger: "#ff4d5e",
} : {
bg: "#fafaf9", panel: "#ffffff", border: "#ebebe6",
text: "#111", subtext: "#5a5a5e", muted: "#8a8a90",
rowAlt: "#fafaf6", input: "#fff", accent: "#5e5cff",
chipBg: "#f1f0eb", chipText: "#3a3a3e", danger: "#dc2626",
};
const subnav = [
"General", "Members", "Roles", "Integrations", "Billing", "API & Webhooks", "Audit log",
];
const roleColors = {
Owner: "#b15bff",
Admin: "#5e5cff",
Member: "#22c55e",
Guest: "#9a9aa6",
};
const members = [
{ i: "MR", c: "#d4b8a8", n: "Mira Reyes", e: "mira@lattice.co", r: "Owner", s: "Active", last: "now", teams: ["Founding"] },
{ i: "TR", c: "#c8e8a8", n: "Theo Roux", e: "theo@lattice.co", r: "Admin", s: "Active", last: "12 min", teams: ["Engineering"] },
{ i: "DP", c: "#a8c8e8", n: "Devi Patel", e: "devi@lattice.co", r: "Admin", s: "Active", last: "1 hour", teams: ["Revenue"] },
{ i: "SK", c: "#e8a87c", n: "Sun Kim", e: "sun@lattice.co", r: "Member", s: "Active", last: "today", teams: ["Revenue", "Design"] },
{ i: "AN", c: "#e8c8a8", n: "Ade Nwosu", e: "ade@lattice.co", r: "Member", s: "Active", last: "yesterday", teams: ["Engineering"] },
{ i: "LB", c: "#c8a8e8", n: "Linnea Berg", e: "linnea@lattice.co", r: "Member", s: "Invited", last: "—", teams: [] },
{ i: "JF", c: "#a8e8c8", n: "Jamal Frost", e: "jamal@partner.co", r: "Guest", s: "Active", last: "3 days", teams: ["Revenue"] },
{ i: "ER", c: "#e8a8c8", n: "Elin Roos", e: "elin@lattice.co", r: "Member", s: "Suspended", last: "14 days", teams: ["Design"] },
];
const Badge = ({ color, children, dot }) => (
<span style={{
display: "inline-flex", alignItems: "center", gap: 5,
padding: "2px 8px", borderRadius: 999,
background: color ? `${color}1f` : c.chipBg,
color: color || c.chipText,
fontSize: 11, fontWeight: 500, whiteSpace: "nowrap",
}}>
{dot && <span style={{
width: 6, height: 6, borderRadius: "50%", background: color,
}}></span>}
{children}
</span>
);
const Avatar = ({ name, color, size = 28 }) => (
<div style={{
width: size, height: size, borderRadius: "50%", background: color,
fontSize: size * 0.4, fontWeight: 600, color: "#3a2820",
display: "flex", alignItems: "center", justifyContent: "center",
flexShrink: 0,
}}>{name}</div>
);
return (
<div style={{
height: "100%", background: c.bg, color: c.text, fontFamily: SANS,
display: "grid",
gridTemplateColumns: hideSubnav ? "1fr" : "220px 1fr",
overflow: "hidden",
}}>
{/* Settings sub-nav */}
{!hideSubnav && <aside style={{
borderRight: `1px solid ${c.border}`, padding: "20px 12px",
background: dark ? "#0a0a10" : "#f5f5f2",
display: "flex", flexDirection: "column",
}}>
<div style={{
fontSize: 11, color: c.muted, padding: "0 10px 8px",
letterSpacing: "0.06em", textTransform: "uppercase", fontWeight: 500,
}}>Settings</div>
{subnav.map((s, i) => (
<div key={s} style={{
padding: "7px 10px", borderRadius: 6, fontSize: 13, cursor: "pointer",
color: i === 1 ? c.text : c.subtext,
background: i === 1 ? (dark ? "#ffffff10" : "#fff") : "transparent",
boxShadow: i === 1 && !dark ? "0 1px 0 #00000008, 0 0 0 1px #00000008" : "none",
fontWeight: i === 1 ? 500 : 400,
marginBottom: 2,
}}>{s}</div>
))}
<div style={{
fontSize: 11, color: c.muted, padding: "16px 10px 8px",
letterSpacing: "0.06em", textTransform: "uppercase", fontWeight: 500,
}}>Personal</div>
{["Profile", "Notifications", "Sessions"].map(s => (
<div key={s} style={{
padding: "7px 10px", borderRadius: 6, fontSize: 13, cursor: "pointer",
color: c.subtext, marginBottom: 2,
}}>{s}</div>
))}
<div style={{ flex: 1 }}></div>
<div style={{
padding: "12px 12px", borderRadius: 8,
background: dark ? "#ffffff06" : "#fff",
border: `1px solid ${c.border}`,
}}>
<div style={{ fontSize: 12, fontWeight: 500, marginBottom: 4 }}>
Free workspace
</div>
<div style={{ fontSize: 11, color: c.muted, lineHeight: 1.4, marginBottom: 10 }}>
6 of 10 seats used. Upgrade for SSO, audit log retention, and SCIM.
</div>
<button style={{
width: "100%", padding: "7px 12px", borderRadius: 6,
background: dark ? "#fff" : "#111", color: dark ? "#111" : "#fff",
border: "none", fontSize: 12, fontFamily: SANS, fontWeight: 500,
cursor: "pointer",
}}>Upgrade to Pro </button>
</div>
</aside>}
{/* Main */}
<div style={{ display: "flex", flexDirection: "column", overflow: "hidden" }}>
{/* Page header */}
<div style={{
padding: "20px 28px 14px", borderBottom: `1px solid ${c.border}`,
}}>
<div style={{ fontSize: 12, color: c.muted, marginBottom: 6 }}>
Settings / Members
</div>
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "flex-end",
}}>
<div>
<h1 style={{ fontSize: 24, fontWeight: 600, margin: 0, letterSpacing: "-0.02em" }}>
Members
</h1>
<p style={{
fontSize: 13, color: c.subtext, margin: "6px 0 0", maxWidth: 540,
}}>
Manage who has access to <b>Lattice Studio</b>. Roles control
what each person can see and edit across the workspace.
</p>
</div>
<div style={{ display: "flex", gap: 8 }}>
<button style={{
padding: "8px 14px", borderRadius: 6, fontSize: 13, fontFamily: SANS,
background: c.panel, border: `1px solid ${c.border}`, color: c.text,
cursor: "pointer",
}}>Export CSV</button>
<button style={{
padding: "8px 14px", borderRadius: 6, fontSize: 13, fontFamily: SANS,
background: dark ? "#fff" : "#111", color: dark ? "#111" : "#fff",
border: "none", cursor: "pointer", fontWeight: 500,
display: "flex", alignItems: "center", gap: 6,
}}><Icon d={P.plus} size={13}/> Invite people</button>
</div>
</div>
</div>
{/* Filter / search row */}
<div style={{
padding: "12px 28px", borderBottom: `1px solid ${c.border}`,
display: "flex", alignItems: "center", gap: 10,
}}>
<div style={{
display: "flex", alignItems: "center", gap: 8, padding: "6px 10px",
background: c.input, border: `1px solid ${c.border}`, borderRadius: 6,
fontSize: 12, color: c.muted, width: 280,
}}>
<Icon d={P.search} size={13} />
<span style={{ flex: 1 }}>Search by name, email</span>
</div>
{[
{ l: "Role", v: "All" },
{ l: "Status", v: "Active + Invited" },
{ l: "Team", v: "Any" },
].map(f => (
<div key={f.l} style={{
display: "flex", alignItems: "center", gap: 6, padding: "6px 10px",
border: `1px dashed ${c.border}`, borderRadius: 6, fontSize: 12,
color: c.subtext, cursor: "pointer",
}}>
<span style={{ color: c.muted }}>{f.l}:</span>
<span style={{ color: c.text, fontWeight: 500 }}>{f.v}</span>
<Icon d={P.chevron} size={11} />
</div>
))}
<div style={{ flex: 1 }}></div>
<span style={{ fontSize: 12, color: c.muted }}>
<b style={{ color: c.text }}>8</b> members · 1 invited · 1 suspended
</span>
</div>
{/* Table */}
<div style={{ flex: 1, overflowY: "auto" }}>
<div style={{
display: "grid",
gridTemplateColumns: "28px 2fr 1fr 1fr 1.4fr 1fr 32px",
padding: "10px 28px", fontSize: 11, color: c.muted,
letterSpacing: "0.04em", textTransform: "uppercase", fontWeight: 500,
borderBottom: `1px solid ${c.border}`,
alignItems: "center", gap: 12,
}}>
<input type="checkbox" style={{ accentColor: c.accent }} readOnly />
<span>Name</span>
<span>Role</span>
<span>Status</span>
<span>Teams</span>
<span>Last active</span>
<span></span>
</div>
{members.map((m, i) => (
<div key={m.e} style={{
display: "grid",
gridTemplateColumns: "28px 2fr 1fr 1fr 1.4fr 1fr 32px",
padding: "10px 28px", fontSize: 13,
alignItems: "center", gap: 12,
borderBottom: `1px solid ${c.border}`,
background: i % 2 === 1 ? c.rowAlt : "transparent",
}}>
<input type="checkbox" style={{ accentColor: c.accent }} readOnly />
<div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
<Avatar name={m.i} color={m.c} />
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 500, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{m.n}</div>
<div style={{ fontSize: 11, color: c.muted, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{m.e}</div>
</div>
</div>
<div>
<div style={{
display: "inline-flex", alignItems: "center", gap: 6,
padding: "3px 9px", borderRadius: 5,
background: `${roleColors[m.r]}18`,
color: roleColors[m.r], fontSize: 12, fontWeight: 500,
cursor: "pointer",
}}>
{m.r}
<Icon d={P.chevron} size={11} />
</div>
</div>
<div>
{m.s === "Active" && <Badge color="#22c55e" dot>Active</Badge>}
{m.s === "Invited" && <Badge color="#f6c560" dot>Invited</Badge>}
{m.s === "Suspended" && <Badge color={c.danger} dot>Suspended</Badge>}
</div>
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
{m.teams.length === 0
? <span style={{ fontSize: 12, color: c.muted }}></span>
: m.teams.map(t => <Badge key={t}>{t}</Badge>)}
</div>
<div style={{ fontSize: 12, color: c.subtext }}>{m.last}</div>
<div style={{ color: c.muted, display: "flex", justifyContent: "flex-end", cursor: "pointer" }}>
<Icon d={P.more} size={16} />
</div>
</div>
))}
{/* Pending-invite footer band */}
<div style={{
margin: "18px 28px 28px", padding: "14px 16px", borderRadius: 10,
background: dark ? "#ffffff06" : "#fff8e6",
border: `1px solid ${dark ? "#ffffff14" : "#f3e0a4"}`,
display: "flex", alignItems: "center", gap: 14,
}}>
<div style={{
width: 32, height: 32, borderRadius: 8,
background: dark ? "#ffffff10" : "#f6c56020",
color: dark ? "#f6c560" : "#a87b1a",
display: "flex", alignItems: "center", justifyContent: "center",
}}><Icon d={P.bell} size={16} /></div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>
1 invitation is still pending
</div>
<div style={{ fontSize: 12, color: c.subtext, marginTop: 2 }}>
<b>linnea@lattice.co</b> hasn't accepted yet sent 3 days ago.
</div>
</div>
<button style={{
padding: "6px 12px", borderRadius: 6, fontSize: 12, fontFamily: SANS,
background: dark ? "#ffffff10" : "#fff",
border: `1px solid ${c.border}`, color: c.text, cursor: "pointer",
}}>Resend</button>
<button style={{
padding: "6px 12px", borderRadius: 6, fontSize: 12, fontFamily: SANS,
background: "transparent", border: "none", color: c.muted, cursor: "pointer",
}}>Revoke</button>
</div>
</div>
</div>
</div>
);
};
window.AdminBody = AdminBody;

View File

@@ -0,0 +1,318 @@
// ============================================================
// page-customer.jsx — CRM company record page.
// Header (logo + name + status + actions), 2-col layout:
// left — details panel (industry, owner, links, deals)
// right — tabbed work area (Overview / Activity / People / Notes)
// Pure content. Wrap in any *Chrome to compose.
// ============================================================
const CustomerBody = ({ theme = "light" }) => {
const dark = theme === "dark";
const c = dark ? {
bg: "#0f0f14", panel: "#13131a", border: "#ffffff10",
text: "#e8e8ee", subtext: "#9a9aa6", muted: "#6a6a78",
rowAlt: "#ffffff04", accent: "#7a78ff", ring: "#5e5cff",
chipBg: "#ffffff08", chipText: "#dcdce4",
inputBg: "#0a0a10",
} : {
bg: "#fcfcfb", panel: "#ffffff", border: "#e8e8e3",
text: "#111", subtext: "#5a5a5e", muted: "#8a8a90",
rowAlt: "#f9f9f6", accent: "#5e5cff", ring: "#5e5cff",
chipBg: "#f1f0eb", chipText: "#3a3a3e",
inputBg: "#fff",
};
const KV = ({ k, v }) => (
<div style={{
display: "grid", gridTemplateColumns: "110px 1fr", gap: 10,
padding: "8px 0", fontSize: 13, alignItems: "baseline",
}}>
<span style={{ color: c.muted }}>{k}</span>
<span style={{ color: c.text }}>{v}</span>
</div>
);
const Tag = ({ children, color }) => (
<span style={{
display: "inline-flex", alignItems: "center", gap: 5,
padding: "2px 8px", borderRadius: 999,
background: color ? `${color}1f` : c.chipBg,
color: color || c.chipText,
fontSize: 11, fontWeight: 500, whiteSpace: "nowrap",
}}>
{color && <span style={{
width: 6, height: 6, borderRadius: "50%", background: color,
}}></span>}
{children}
</span>
);
const Avatar = ({ name, color = "#d4b8a8", size = 24, ring }) => (
<div style={{
width: size, height: size, borderRadius: "50%", background: color,
fontSize: size * 0.4, fontWeight: 600, color: "#3a2820",
display: "flex", alignItems: "center", justifyContent: "center",
flexShrink: 0, boxShadow: ring ? `0 0 0 2px ${c.panel}, 0 0 0 3px ${ring}` : "none",
}}>{name}</div>
);
return (
<div style={{
display: "grid", gridTemplateRows: "auto 1fr", height: "100%",
background: c.bg, color: c.text, fontFamily: SANS, overflow: "hidden",
}}>
{/* Header */}
<div style={{
padding: "16px 28px", borderBottom: `1px solid ${c.border}`,
display: "flex", alignItems: "center", gap: 16,
}}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: "linear-gradient(135deg, #f6c560 0%, #e08c4a 100%)",
display: "flex", alignItems: "center", justifyContent: "center",
fontWeight: 700, fontSize: 18, color: "#3a2210",
}}>NS</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 3 }}>
<h1 style={{
fontSize: 22, fontWeight: 600, margin: 0, letterSpacing: "-0.01em",
}}>Northstar Logistics</h1>
<Tag color="#22c55e">Customer</Tag>
<Tag color="#5e5cff">Tier 1</Tag>
</div>
<div style={{
fontSize: 12, color: c.muted, display: "flex", gap: 14, alignItems: "center",
}}>
<span>northstarlogistics.com</span>
<span>·</span>
<span>Created Aug 2024</span>
<span>·</span>
<span>Last touched 2h ago</span>
</div>
</div>
<div style={{ display: "flex", gap: 8 }}>
<button style={{
padding: "7px 12px", borderRadius: 6, fontSize: 12, fontFamily: SANS,
background: dark ? "#ffffff08" : "#fff",
border: `1px solid ${c.border}`,
color: c.text, cursor: "pointer", display: "flex", alignItems: "center", gap: 6,
}}><Icon d={P.star} size={13}/> Star</button>
<button style={{
padding: "7px 12px", borderRadius: 6, fontSize: 12, fontFamily: SANS,
background: dark ? "#ffffff08" : "#fff",
border: `1px solid ${c.border}`,
color: c.text, cursor: "pointer",
}}>Share</button>
<button style={{
padding: "7px 14px", borderRadius: 6, fontSize: 12, fontFamily: SANS,
background: dark ? "#fff" : "#111",
color: dark ? "#111" : "#fff",
border: "none", cursor: "pointer", fontWeight: 500,
display: "flex", alignItems: "center", gap: 6,
}}><Icon d={P.plus} size={12}/> Log activity</button>
</div>
</div>
{/* Body */}
<div style={{
display: "grid", gridTemplateColumns: "320px 1fr", gap: 0,
overflow: "hidden",
}}>
{/* Details rail */}
<div style={{
padding: "20px 24px", borderRight: `1px solid ${c.border}`,
overflowY: "auto",
}}>
<div style={{
fontSize: 11, color: c.muted, letterSpacing: "0.06em",
textTransform: "uppercase", fontWeight: 500, marginBottom: 6,
}}>About</div>
<KV k="Industry" v="Freight & Logistics" />
<KV k="Employees" v="240 — 500" />
<KV k="HQ" v="Rotterdam, NL" />
<KV k="Founded" v="2011" />
<KV k="Owner" v={
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
<Avatar name="MR" size={18} /> Mira Reyes
</span>
} />
<KV k="Source" v="Referral · DH" />
<div style={{
fontSize: 11, color: c.muted, letterSpacing: "0.06em",
textTransform: "uppercase", fontWeight: 500, marginTop: 22, marginBottom: 8,
}}>Tags</div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
<Tag color="#e08c4a">Enterprise</Tag>
<Tag color="#22c55e">Renewal Q3</Tag>
<Tag color="#5e5cff">EMEA</Tag>
<Tag>Logistics</Tag>
</div>
<div style={{
fontSize: 11, color: c.muted, letterSpacing: "0.06em",
textTransform: "uppercase", fontWeight: 500, marginTop: 22, marginBottom: 8,
}}>Open opportunities</div>
{[
{ name: "Q3 — Carrier API", v: "€84,000", stage: "Negotiation", p: 70 },
{ name: "EU expansion", v: "€38,500", stage: "Proposal", p: 40 },
{ name: "Renewal · Pro", v: "€24,000", stage: "Discovery", p: 15 },
].map(d => (
<div key={d.name} style={{
padding: "10px 12px", borderRadius: 8,
background: c.panel, border: `1px solid ${c.border}`,
marginBottom: 8,
}}>
<div style={{
display: "flex", justifyContent: "space-between",
alignItems: "baseline", marginBottom: 6,
}}>
<span style={{ fontSize: 13, fontWeight: 500 }}>{d.name}</span>
<span style={{
fontSize: 12, color: c.subtext, fontVariantNumeric: "tabular-nums",
}}>{d.v}</span>
</div>
<div style={{
fontSize: 11, color: c.muted, display: "flex",
justifyContent: "space-between", marginBottom: 4,
}}>
<span>{d.stage}</span><span>{d.p}%</span>
</div>
<div style={{
height: 3, borderRadius: 2,
background: dark ? "#ffffff10" : "#eeeee9",
overflow: "hidden",
}}>
<div style={{
width: `${d.p}%`, height: "100%",
background: d.p > 60 ? "#22c55e" : d.p > 30 ? "#f6c560" : "#9a9aa6",
}}></div>
</div>
</div>
))}
</div>
{/* Tabs + work area */}
<div style={{ display: "flex", flexDirection: "column", overflow: "hidden" }}>
<div style={{
padding: "0 28px", borderBottom: `1px solid ${c.border}`,
display: "flex", gap: 0,
}}>
{["Overview", "Activity", "People", "Notes", "Files"].map((t, i) => (
<div key={t} style={{
padding: "14px 14px", fontSize: 13, fontWeight: 500,
color: i === 1 ? c.text : c.muted,
borderBottom: i === 1 ? `2px solid ${c.accent}` : "2px solid transparent",
cursor: "pointer", position: "relative", top: 1,
}}>{t}{t === "Activity" && " · 28"}</div>
))}
</div>
<div style={{ flex: 1, overflowY: "auto", padding: "24px 28px" }}>
{/* KPI row */}
<div style={{
display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 12,
marginBottom: 22,
}}>
{[
{ l: "Pipeline", v: "€146.5k", s: "+€12.4k 30d", up: true },
{ l: "Closed-won", v: "€220k", s: "lifetime" },
{ l: "Open deals", v: "3", s: "1 stalled", warn: true },
{ l: "Health", v: "82 / 100", s: "stable", up: true },
].map(k => (
<div key={k.l} style={{
padding: "14px 16px", borderRadius: 10,
background: c.panel, border: `1px solid ${c.border}`,
}}>
<div style={{ fontSize: 11, color: c.muted, marginBottom: 6 }}>{k.l}</div>
<div style={{
fontSize: 22, fontWeight: 600, letterSpacing: "-0.01em",
}}>{k.v}</div>
<div style={{
fontSize: 11, color: k.up ? "#22c55e" : k.warn ? "#f6c560" : c.muted,
marginTop: 2,
}}>{k.s}</div>
</div>
))}
</div>
{/* Activity timeline */}
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 12 }}>Activity</div>
<div style={{ position: "relative", paddingLeft: 22 }}>
<div style={{
position: "absolute", left: 9, top: 6, bottom: 6,
width: 1, background: c.border,
}}></div>
{[
{ dot: "#22c55e", t: "Deal moved to Negotiation",
who: "Mira Reyes", w: "Q3 — Carrier API · €84,000",
when: "2 hours ago" },
{ dot: "#5e5cff", t: "Email sent · proposal v4",
who: "Mira Reyes", w: "To: Sun Kim, Devi Patel — opened 6 times",
when: "Yesterday" },
{ dot: "#f6c560", t: "Call logged · 32 min",
who: "Theo Roux", w: "Walkthrough with their ops lead — promising",
when: "2 days ago" },
{ dot: "#9a9aa6", t: "Note added",
who: "Mira Reyes", w: "They want SSO and SCIM by Sept. — gating item.",
when: "4 days ago" },
].map((a, i) => (
<div key={i} style={{ marginBottom: 16, position: "relative" }}>
<span style={{
position: "absolute", left: -19, top: 4, width: 11, height: 11,
background: a.dot, borderRadius: "50%",
boxShadow: `0 0 0 3px ${c.bg}`,
}}></span>
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "baseline",
}}>
<span style={{ fontSize: 13, fontWeight: 500 }}>{a.t}</span>
<span style={{ fontSize: 11, color: c.muted }}>{a.when}</span>
</div>
<div style={{ fontSize: 12, color: c.subtext, marginTop: 2 }}>
{a.who} · {a.w}
</div>
</div>
))}
</div>
{/* People row */}
<div style={{
fontSize: 13, fontWeight: 600, marginTop: 12, marginBottom: 12,
display: "flex", justifyContent: "space-between", alignItems: "center",
}}>
<span>People at Northstar · 6</span>
<button style={{
background: "transparent", border: "none", color: c.accent,
fontSize: 12, fontFamily: SANS, cursor: "pointer",
}}>View all </button>
</div>
<div style={{
display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 10,
}}>
{[
{ i: "SK", n: "Sun Kim", r: "VP Operations", c: "#e8a87c" },
{ i: "DP", n: "Devi Patel", r: "Procurement", c: "#a8c8e8" },
{ i: "TR", n: "Theo Roux", r: "CFO", c: "#c8e8a8" },
].map(p => (
<div key={p.i} style={{
display: "flex", alignItems: "center", gap: 10,
padding: "10px 12px", borderRadius: 8,
background: c.panel, border: `1px solid ${c.border}`,
}}>
<Avatar name={p.i} color={p.c} size={32} />
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>{p.n}</div>
<div style={{ fontSize: 11, color: c.muted }}>{p.r}</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
};
window.CustomerBody = CustomerBody;

View File

@@ -0,0 +1,355 @@
// ============================================================
// page-dashboard.jsx — KPI strip + time-series chart +
// pipeline funnel + recent activity + team leaderboard.
// Theme-aware so it adapts to dark rail chrome.
// ============================================================
const DashboardBody = ({ theme = "light" }) => {
const dark = theme === "dark";
const c = dark ? {
bg: "#0f0f14", panel: "#13131a", border: "#ffffff10",
text: "#e8e8ee", subtext: "#9a9aa6", muted: "#6a6a78",
grid: "#ffffff08", accent: "#7a78ff", up: "#22c55e", down: "#ff4d5e",
} : {
bg: "#fafaf9", panel: "#ffffff", border: "#ebebe6",
text: "#111", subtext: "#5a5a5e", muted: "#8a8a90",
grid: "#eeeee9", accent: "#5e5cff", up: "#22c55e", down: "#ff4d5e",
};
// Synthetic but consistent daily series, weekday-shaped
const days = ["M","T","W","T","F","S","S","M","T","W","T","F","S","S"];
const series = [42,58,71,64,79,32,28, 51,68,82,75,90,38,33];
const max = Math.max(...series);
// Funnel data
const funnel = [
{ stage: "New", n: 184, v: "€2.1m" },
{ stage: "Qualified", n: 96, v: "€1.4m" },
{ stage: "Proposal", n: 42, v: "€780k" },
{ stage: "Negotiation", n: 19, v: "€420k" },
{ stage: "Closed-won", n: 11, v: "€286k" },
];
const fmax = funnel[0].n;
const Avatar = ({ name, color = "#d4b8a8", size = 22 }) => (
<div style={{
width: size, height: size, borderRadius: "50%", background: color,
fontSize: size * 0.42, fontWeight: 600, color: "#3a2820",
display: "flex", alignItems: "center", justifyContent: "center",
flexShrink: 0,
}}>{name}</div>
);
return (
<div style={{
height: "100%", background: c.bg, color: c.text, fontFamily: SANS,
display: "flex", flexDirection: "column", overflow: "hidden",
}}>
{/* Header */}
<div style={{
padding: "20px 28px 16px", borderBottom: `1px solid ${c.border}`,
display: "flex", alignItems: "flex-end", justifyContent: "space-between",
}}>
<div>
<div style={{
fontSize: 11, color: c.muted, letterSpacing: "0.06em",
textTransform: "uppercase", marginBottom: 4, fontWeight: 500,
}}>Workspace dashboard</div>
<h1 style={{
fontSize: 26, fontWeight: 600, margin: 0, letterSpacing: "-0.02em",
}}>Good afternoon, Mira</h1>
<div style={{ fontSize: 13, color: c.subtext, marginTop: 4 }}>
3 deals moved stage today · 12 unread in Inbox · 1 task overdue
</div>
</div>
<div style={{ display: "flex", gap: 8 }}>
<div style={{
display: "flex", alignItems: "center", padding: "6px 10px",
borderRadius: 6, background: c.panel, border: `1px solid ${c.border}`,
fontSize: 12, color: c.subtext, gap: 8,
}}>
<span style={{ fontWeight: 500, color: c.text }}>Last 14 days</span>
<Icon d={P.chevron} size={12} />
</div>
<button style={{
padding: "7px 12px", borderRadius: 6, fontSize: 12, fontFamily: SANS,
background: c.panel, border: `1px solid ${c.border}`, color: c.text,
cursor: "pointer",
}}>Export</button>
<button style={{
padding: "7px 14px", borderRadius: 6, fontSize: 12, fontFamily: SANS,
background: dark ? "#fff" : "#111", color: dark ? "#111" : "#fff",
border: "none", cursor: "pointer", fontWeight: 500,
display: "flex", alignItems: "center", gap: 6,
}}><Icon d={P.plus} size={12}/> New report</button>
</div>
</div>
<div style={{
flex: 1, overflowY: "auto", padding: "20px 28px 28px",
display: "flex", flexDirection: "column", gap: 20,
}}>
{/* KPI strip */}
<div style={{
display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 12,
}}>
{[
{ l: "Revenue · MTD", v: "€286,420", d: "+18.4%", up: true,
spark: [20,28,24,36,30,42,52,48,58,62,70,82] },
{ l: "Active deals", v: "168", d: "+12", up: true,
spark: [40,42,45,46,49,52,54,56,58,60,62,65] },
{ l: "Win rate · 30d", v: "34.2%", d: "1.1%", up: false,
spark: [60,58,55,52,54,50,48,45,46,42,38,36] },
{ l: "Pipeline ratio", v: "4.8×", d: "healthy", up: true,
spark: [50,48,52,55,53,58,56,60,62,65,63,68] },
].map(k => {
const sm = Math.max(...k.spark), sn = Math.min(...k.spark);
const pts = k.spark.map((v, i) => {
const x = (i / (k.spark.length - 1)) * 100;
const y = 30 - ((v - sn) / (sm - sn || 1)) * 26 - 2;
return `${x},${y}`;
}).join(" ");
return (
<div key={k.l} style={{
padding: "16px 18px", borderRadius: 10,
background: c.panel, border: `1px solid ${c.border}`,
}}>
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "baseline",
marginBottom: 8,
}}>
<span style={{ fontSize: 12, color: c.muted }}>{k.l}</span>
<span style={{
fontSize: 11, color: k.up ? c.up : c.down, fontWeight: 500,
}}>{k.d}</span>
</div>
<div style={{
fontSize: 26, fontWeight: 600, letterSpacing: "-0.02em",
marginBottom: 6, fontVariantNumeric: "tabular-nums",
}}>{k.v}</div>
<svg viewBox="0 0 100 30" style={{
width: "100%", height: 26, display: "block",
}} preserveAspectRatio="none">
<polyline points={pts} fill="none"
stroke={k.up ? c.up : c.down} strokeWidth="1.5"
vectorEffect="non-scaling-stroke" />
</svg>
</div>
);
})}
</div>
{/* Chart + funnel */}
<div style={{
display: "grid", gridTemplateColumns: "1.5fr 1fr", gap: 16,
}}>
{/* Time-series */}
<div style={{
padding: "18px 20px", borderRadius: 12,
background: c.panel, border: `1px solid ${c.border}`,
}}>
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
marginBottom: 14,
}}>
<div>
<div style={{ fontSize: 13, fontWeight: 600 }}>Revenue, daily</div>
<div style={{ fontSize: 11, color: c.muted, marginTop: 2 }}>
Bookings · GBP closed-won
</div>
</div>
<div style={{ display: "flex", gap: 4 }}>
{["Day", "Week", "Month"].map((t, i) => (
<span key={t} style={{
padding: "4px 10px", borderRadius: 5, fontSize: 11, fontWeight: 500,
background: i === 0 ? (dark ? "#ffffff10" : "#f1f0eb") : "transparent",
color: i === 0 ? c.text : c.muted, cursor: "pointer",
}}>{t}</span>
))}
</div>
</div>
<div style={{ position: "relative", height: 180 }}>
{/* Gridlines */}
{[0, 0.25, 0.5, 0.75, 1].map(p => (
<div key={p} style={{
position: "absolute", left: 0, right: 0,
bottom: `${p * 100}%`, height: 1, background: c.grid,
}}></div>
))}
{/* Bars */}
<div style={{
position: "absolute", inset: 0, display: "flex",
alignItems: "flex-end", gap: 6, paddingRight: 6,
}}>
{series.map((v, i) => (
<div key={i} style={{ flex: 1, position: "relative",
display: "flex", flexDirection: "column",
alignItems: "center", justifyContent: "flex-end",
height: "100%",
}}>
<div style={{
width: "100%", height: `${(v / max) * 100}%`,
background: i === 11
? `linear-gradient(180deg, ${c.accent}, ${dark ? "#3a38c0" : "#bfbeff"})`
: (dark ? "#ffffff14" : "#e8e7e0"),
borderRadius: 3,
}}></div>
</div>
))}
</div>
{/* Annotation */}
<div style={{
position: "absolute", right: 6, top: -6,
background: c.text, color: c.bg,
padding: "3px 8px", borderRadius: 4, fontSize: 11, fontWeight: 500,
}}>42k · today</div>
</div>
<div style={{
display: "flex", justifyContent: "space-between", marginTop: 6,
fontSize: 10, color: c.muted, fontFamily: "ui-monospace, monospace",
}}>
{days.map((d, i) => (
<span key={i} style={{ flex: 1, textAlign: "center" }}>{d}</span>
))}
</div>
</div>
{/* Funnel */}
<div style={{
padding: "18px 20px", borderRadius: 12,
background: c.panel, border: `1px solid ${c.border}`,
}}>
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "baseline",
marginBottom: 14,
}}>
<div style={{ fontSize: 13, fontWeight: 600 }}>Pipeline funnel</div>
<span style={{ fontSize: 11, color: c.muted }}>Q2 · 168 deals</span>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{funnel.map((f, i) => {
const w = (f.n / fmax) * 100;
const colors = ["#5e5cff", "#7a78ff", "#9b99ff", "#bcb9ff", "#22c55e"];
return (
<div key={f.stage} style={{ position: "relative" }}>
<div style={{
width: `${w}%`, height: 30, borderRadius: 5,
background: colors[i], display: "flex",
alignItems: "center", paddingLeft: 12, color: "#fff",
fontSize: 12, fontWeight: 500,
}}>{f.stage}</div>
<div style={{
position: "absolute", right: 0, top: 0, height: 30,
display: "flex", alignItems: "center", gap: 10,
fontSize: 12, color: c.muted,
}}>
<span style={{
fontFamily: "ui-monospace, monospace", color: c.text,
}}>{f.n}</span>
<span style={{ fontSize: 11 }}>{f.v}</span>
</div>
</div>
);
})}
</div>
</div>
</div>
{/* Activity + leaderboard */}
<div style={{
display: "grid", gridTemplateColumns: "1.5fr 1fr", gap: 16,
}}>
<div style={{
padding: "18px 20px", borderRadius: 12,
background: c.panel, border: `1px solid ${c.border}`,
}}>
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "baseline",
marginBottom: 14,
}}>
<div style={{ fontSize: 13, fontWeight: 600 }}>Recent activity</div>
<span style={{ fontSize: 11, color: c.accent, cursor: "pointer" }}>View all </span>
</div>
{[
{ who: "MR", c: "#d4b8a8", n: "Mira Reyes", v: "moved",
w: <><b>Q3 Carrier API</b> to <span style={{ color: "#22c55e" }}>Negotiation</span></>,
t: "2m ago" },
{ who: "TR", c: "#c8e8a8", n: "Theo Roux", v: "logged a call with",
w: <><b>Sun Kim · Northstar</b></>, t: "14m" },
{ who: "DP", c: "#a8c8e8", n: "Devi Patel", v: "closed",
w: <><b>Halcyon · Pro renewal</b> · 24,000</>, t: "1h" },
{ who: "MR", c: "#d4b8a8", n: "Mira Reyes", v: "created a deal",
w: <><b>Brooke Foods Q3 pilot</b></>, t: "2h" },
{ who: "SK", c: "#e8a87c", n: "Sun Kim", v: "added 4 contacts to",
w: <><b>Kestrel</b></>, t: "3h" },
].map((a, i) => (
<div key={i} style={{
display: "flex", alignItems: "center", gap: 10,
padding: "8px 0",
borderTop: i === 0 ? "none" : `1px solid ${c.border}`,
}}>
<Avatar name={a.who} color={a.c} size={26} />
<div style={{ flex: 1, fontSize: 13 }}>
<span style={{ fontWeight: 500 }}>{a.n}</span>
<span style={{ color: c.muted }}> {a.v} </span>
<span>{a.w}</span>
</div>
<span style={{ fontSize: 11, color: c.muted, whiteSpace: "nowrap" }}>{a.t}</span>
</div>
))}
</div>
<div style={{
padding: "18px 20px", borderRadius: 12,
background: c.panel, border: `1px solid ${c.border}`,
}}>
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "baseline",
marginBottom: 14,
}}>
<div style={{ fontSize: 13, fontWeight: 600 }}>Team · this month</div>
<span style={{ fontSize: 11, color: c.muted }}>By bookings</span>
</div>
{[
{ i: "MR", c: "#d4b8a8", n: "Mira Reyes", v: 124, d: "€124k", p: 100 },
{ i: "DP", c: "#a8c8e8", n: "Devi Patel", v: 86, d: "€86k", p: 70 },
{ i: "TR", c: "#c8e8a8", n: "Theo Roux", v: 62, d: "€62k", p: 50 },
{ i: "SK", c: "#e8a87c", n: "Sun Kim", v: 48, d: "€48k", p: 39 },
].map(t => (
<div key={t.i} style={{
display: "grid", gridTemplateColumns: "26px 1fr auto", gap: 10,
alignItems: "center", padding: "8px 0",
}}>
<Avatar name={t.i} color={t.c} size={26} />
<div style={{ minWidth: 0 }}>
<div style={{
display: "flex", justifyContent: "space-between",
fontSize: 12, marginBottom: 4,
}}>
<span style={{ fontWeight: 500 }}>{t.n}</span>
<span style={{
color: c.subtext, fontVariantNumeric: "tabular-nums",
}}>{t.d}</span>
</div>
<div style={{
height: 3, borderRadius: 2,
background: dark ? "#ffffff10" : "#eeeee9", overflow: "hidden",
}}>
<div style={{
width: `${t.p}%`, height: "100%",
background: `linear-gradient(90deg, ${c.accent}, ${dark ? "#9b99ff" : "#b15bff"})`,
}}></div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
};
window.DashboardBody = DashboardBody;

View File

@@ -0,0 +1,108 @@
// Small shared primitives: logo, arrow icon, ambient glow, eyebrow, trust strip.
// The "V_" mark — bold filled V + terminal-cursor underscore. Sized via the
// outer .logo-mark; the SVG fills it. `stroke-linejoin="round"` + a thin
// stroke on the filled paths softens the corners just enough.
function LogoMark({ size = 26, blink = true }) {
return (
<span className="logo-mark" style={{ width: size, height: size }}>
<svg
viewBox="0 0 36 32"
width="74%" height="74%"
fill="currentColor"
stroke="currentColor"
strokeWidth="1.2"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M4 5 L10 5 L12 18 L14 5 L20 5 L12 27 Z" />
<rect
x="22.5" y="23" width="9.5" height="3.8" rx="0.7"
className={blink ? "logo-caret" : ""}
/>
</svg>
</span>
);
}
function Logo({ size = 26 }) {
return (
<span className="logo">
<LogoMark size={size} />
<span>vibn</span>
</span>
);
}
function Arrow({ size = 14 }) {
return (
<svg className="arrow" width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function Eyebrow({ children }) {
return <div className="eyebrow">{children}</div>;
}
// Soft radial glow blob for ambient backgrounds. Place absolutely positioned.
function Glow({ color = "var(--accent-glow)", size = 700, opacity = 1, style = {} }) {
return (
<div
aria-hidden="true"
style={{
position: "absolute",
width: size,
height: size,
background: `radial-gradient(circle at center, ${color} 0%, transparent 62%)`,
filter: "blur(20px)",
opacity,
pointerEvents: "none",
...style,
}}
/>
);
}
function TrustStrip({ items }) {
return (
<div
className="mono"
style={{
display: "flex",
flexWrap: "wrap",
gap: "8px 18px",
fontSize: 12,
color: "var(--fg-mute)",
letterSpacing: "0.04em",
}}
>
{items.map((item, i) => (
<React.Fragment key={i}>
{i > 0 && <span style={{ color: "var(--fg-faint)" }}>·</span>}
<span>{item}</span>
</React.Fragment>
))}
</div>
);
}
// A subtle gradient hairline used inside cards & frames.
function Hairline({ vertical = false, style = {} }) {
return (
<div
aria-hidden="true"
style={{
background: vertical
? "linear-gradient(180deg, transparent, var(--hairline) 30%, var(--hairline) 70%, transparent)"
: "linear-gradient(90deg, transparent, var(--hairline) 30%, var(--hairline) 70%, transparent)",
height: vertical ? "100%" : 1,
width: vertical ? 1 : "100%",
...style,
}}
/>
);
}
Object.assign(window, { Logo, LogoMark, Arrow, Eyebrow, Glow, TrustStrip, Hairline });

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -0,0 +1,139 @@
// Sign In — magic-link primary, OAuth alternatives. Default action is
// "Send me a magic link" (no passwords — fits the "no homework" brand).
// On submit, transitions to a "Check your inbox" confirmation state.
function SignIn() {
const [email, setEmail] = React.useState("");
const [submitting, setSubmitting] = React.useState(false);
const [sent, setSent] = React.useState(false);
const valid = /\S+@\S+\.\S+/.test(email);
const handleSubmit = (e) => {
e.preventDefault();
if (!valid || submitting) return;
setSubmitting(true);
setTimeout(() => {
setSubmitting(false);
setSent(true);
}, 700);
};
return (
<div className="page">
<TopBar rightLink={{ href: "index.html", label: "Back to home" }} />
<main className="auth-main">
<Glows />
<div className="auth-card">
{sent ? (
<SentConfirmation email={email} onChangeEmail={() => setSent(false)} />
) : (
<>
<div className="auth-eye">Welcome back</div>
<h1 className="auth-title">
Sign in and <em>keep building</em>.
</h1>
<p className="auth-sub">
We'll email you a one-tap link. No passwords to remember, no homework.
</p>
<form className="auth-form" onSubmit={handleSubmit} noValidate>
<div className="auth-field">
<label className="auth-label" htmlFor="email">Email</label>
<input
id="email" type="email" autoComplete="email" required autoFocus
className="auth-input"
placeholder="you@somewhere.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<button type="submit" disabled={!valid || submitting}
className="auth-btn auth-btn-primary">
{submitting ? (
<><span className="auth-spinner" /> Sending…</>
) : (
<><MailIcon size={17} /> Send me a magic link</>
)}
</button>
</form>
<div className="auth-divider">or continue with</div>
<div className="auth-oauth">
<button type="button" className="auth-btn auth-btn-ghost">
<GoogleIcon /> Continue with Google
</button>
<button type="button" className="auth-btn auth-btn-ghost">
<AppleIcon /> Continue with Apple
</button>
</div>
<div className="auth-foot">
Don't have an invite yet? <a href="Beta Signup.html">Request one </a>
</div>
</>
)}
</div>
<TrustStrip items={["No passwords", "No homework", "🇨🇦 Built in Canada"]} />
</main>
</div>
);
}
// Confirmation: "Check your inbox at you@x.com" with a resend timer + the
// option to change email and try again.
function SentConfirmation({ email, onChangeEmail }) {
const [left, restart] = useResendTimer(30);
return (
<div className="auth-success">
<div className="auth-success-badge">
<MailIcon size={26} />
</div>
<div className="auth-eye">Check your inbox</div>
<h1 className="auth-title" style={{ marginTop: 10 }}>
Magic link <em>sent</em>.
</h1>
<p className="auth-sub">
We just sent a one-tap sign-in link to
<span className="email-chip">{email}</span>.
Tap it on this device to keep building.
</p>
<div className="auth-tip">
<span className="auth-tip-icon">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<circle cx="8" cy="8" r="6.5"/>
<path d="M8 5v4M8 11v.5"/>
</svg>
</span>
<span>
Can't find it? Check your <b style={{ color: "var(--fg)", fontWeight: 500 }}>spam folder</b> or wait a few seconds —
email is slower than Vibn.
</span>
</div>
<div className="auth-resend">
Didn't get it?{" "}
{left > 0 ? (
<button type="button" disabled>Resend in {left}s</button>
) : (
<button type="button" onClick={restart}>Send again</button>
)}
</div>
<div className="auth-foot" style={{ marginTop: 22 }}>
Wrong email? <button type="button" onClick={onChangeEmail}
style={{ color: "var(--accent)", fontWeight: 500 }}>
Use a different one
</button>
</div>
</div>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<SignIn />);

View File

@@ -0,0 +1,213 @@
// Sign Up — invite-code gated. User pastes/types their invite, gives email +
// optional name, hits "Create my workspace." Magic-link delivery on submit.
// OAuth is offered too but the invite is still required.
function SignUp() {
const [code, setCode] = React.useState("");
const [email, setEmail] = React.useState("");
const [name, setName] = React.useState("");
const [submitting, setSubmitting] = React.useState(false);
const [created, setCreated] = React.useState(false);
const [codeState, setCodeState] = React.useState("idle"); // idle | checking | ok | bad
// Validate the invite code shape (V-XXXXXX, case-insensitive) and pretend to
// verify with a debounce so the UI feels alive even with no backend.
React.useEffect(() => {
const c = code.trim().toLowerCase();
if (!c) { setCodeState("idle"); return undefined; }
const looksValid = /^v-?[a-z0-9]{4,8}$/.test(c);
if (!looksValid) { setCodeState("bad"); return undefined; }
setCodeState("checking");
const t = setTimeout(() => setCodeState("ok"), 600);
return () => clearTimeout(t);
}, [code]);
const emailValid = /\S+@\S+\.\S+/.test(email);
const valid = emailValid && codeState === "ok";
const handleSubmit = (e) => {
e.preventDefault();
if (!valid || submitting) return;
setSubmitting(true);
setTimeout(() => {
setSubmitting(false);
setCreated(true);
}, 800);
};
return (
<div className="page">
<TopBar rightLink={{ href: "index.html", label: "Back to home" }} />
<main className="auth-main">
<Glows />
<div className="auth-card">
{created ? (
<CreatedConfirmation email={email} name={name} />
) : (
<>
<div className="auth-eye">You're invited</div>
<h1 className="auth-title">
Create your <em>workspace</em>.
</h1>
<p className="auth-sub">
Paste your invite code and the email it came to. We'll have you building in seconds.
</p>
<form className="auth-form" onSubmit={handleSubmit} noValidate>
<div className="auth-field">
<label className="auth-label" htmlFor="code">Invite code</label>
<div style={{ position: "relative" }}>
<input
id="code" type="text" autoComplete="off"
required autoFocus
className="auth-input mono"
placeholder="V-XXXXXX"
value={code}
onChange={(e) => setCode(e.target.value)}
maxLength={12}
style={{ paddingRight: 44 }}
/>
<CodeStatus state={codeState} />
</div>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="email">Email</label>
<input
id="email" type="email" autoComplete="email" required
className="auth-input"
placeholder="you@somewhere.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="name">
What should we call you? <span style={{ color: "var(--fg-faint)", letterSpacing: 0, textTransform: "none" }}>(optional)</span>
</label>
<input
id="name" type="text" autoComplete="given-name"
className="auth-input"
placeholder="First name or handle"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<button type="submit" disabled={!valid || submitting}
className="auth-btn auth-btn-primary"
style={{ marginTop: 4 }}>
{submitting ? (
<><span className="auth-spinner" /> Creating your workspace</>
) : (
<>Create my workspace <Arrow size={13} /></>
)}
</button>
</form>
<div className="auth-divider">or continue with</div>
<div className="auth-oauth">
<button type="button" className="auth-btn auth-btn-ghost">
<GoogleIcon /> Continue with Google
</button>
<button type="button" className="auth-btn auth-btn-ghost">
<AppleIcon /> Continue with Apple
</button>
</div>
<p className="auth-fine">
By creating a workspace you agree to our <a href="#">Terms</a> and <a href="#">Privacy Policy</a>.
</p>
<div className="auth-foot">
Already have an account? <a href="Sign In.html">Sign in </a>
</div>
</>
)}
</div>
<TrustStrip items={["No credit card", "No homework", "🇨🇦 Built in Canada"]} />
</main>
</div>
);
}
function CodeStatus({ state }) {
const wrap = {
position: "absolute", right: 14, top: "50%", transform: "translateY(-50%)",
display: "flex", alignItems: "center", gap: 6,
fontFamily: "var(--font-mono)", fontSize: 11, letterSpacing: "0.06em",
textTransform: "uppercase",
pointerEvents: "none",
};
if (state === "idle") return null;
if (state === "checking") return (
<span style={{ ...wrap, color: "var(--fg-mute)" }}>
<span className="auth-spinner" style={{ width: 12, height: 12, borderTopColor: "var(--fg-mute)" }} />
</span>
);
if (state === "bad") return (
<span style={{ ...wrap, color: "oklch(0.65 0.18 25)" }}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<circle cx="8" cy="8" r="6.5"/><path d="M5.5 5.5l5 5M10.5 5.5l-5 5"/>
</svg>
</span>
);
if (state === "ok") return (
<span style={{ ...wrap, color: "var(--ok)" }}>
<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>
Valid
</span>
);
return null;
}
// Confirmation — we've sent the magic link AND provisioned a workspace.
// Small celebratory beat: "Welcome, <name>" if given, else "You're in."
function CreatedConfirmation({ email, name }) {
return (
<div className="auth-success">
<div className="auth-success-badge">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="14" cy="14" r="13" opacity="0.25"/>
<path d="M8 14.5 12.5 19 21 10"/>
</svg>
</div>
<div className="auth-eye">Workspace ready</div>
<h1 className="auth-title" style={{ marginTop: 10 }}>
{name ? <>Welcome, <em>{name}</em>.</> : <>You're <em>in</em>.</>}
</h1>
<p className="auth-sub">
We sent a sign-in link to <span className="email-chip">{email}</span>.
Tap it on this device to step inside your workspace.
</p>
<div className="auth-tip">
<span className="auth-tip-icon">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<path d="M3.5 12 8 3l4.5 9"/><path d="M5 9h6"/>
</svg>
</span>
<span>
While you're waiting on the email your workspace lives at{" "}
<b style={{ color: "var(--fg)", fontWeight: 500, fontFamily: "var(--font-mono)" }}>
{(name || email.split("@")[0] || "you").toLowerCase().replace(/[^a-z0-9-]/g, "")}.vibn.app
</b>
. We'll send you the keys.
</span>
</div>
<div className="auth-foot" style={{ marginTop: 24 }}>
Already opened the email? <a href="Sign In.html">Continue here </a>
</div>
</div>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<SignUp />);

View File

@@ -0,0 +1,343 @@
// Replace your stack — visualizes the SMB's fractured subscription landscape
// collapsing into one tool. Eight grayscale "rented" tiles + spaghetti
// connections, an arrow, then one bright "owned" tile.
const STACK_TOOLS = [
{ name: "Booking", price: "$29/mo", glyph: "B" },
{ name: "POS", price: "$79/mo", glyph: "P" },
{ name: "CRM", price: "$45/mo", glyph: "C" },
{ name: "Accounting", price: "$30/mo", glyph: "A" },
{ name: "Inventory", price: "$59/mo", glyph: "I" },
{ name: "Email", price: "$19/mo", glyph: "E" },
{ name: "Loyalty", price: "$25/mo", glyph: "L" },
{ name: "+ spreadsheet", price: "the one you trust", glyph: "+" },
];
function Stack() {
return (
<section className="section stack">
<style>{`
.stack { padding-block: clamp(80px, 11vh, 130px); }
.stack-head { text-align: center; max-width: 820px; margin: 0 auto 56px; }
.stack-title {
font-size: clamp(36px, 4.8vw, 64px);
font-weight: 500; letter-spacing: -0.025em; line-height: 1.02;
text-wrap: balance;
}
.stack-title .accent { color: var(--accent); }
.stack-sub {
margin-top: 20px;
color: var(--fg-mute); font-size: 17px;
line-height: 1.5;
text-wrap: balance;
max-width: 640px; margin-inline: auto;
}
.stack-stage {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: clamp(20px, 4vw, 56px);
align-items: center;
max-width: 1080px; margin: 0 auto;
}
@media (max-width: 880px) {
.stack-stage { grid-template-columns: 1fr; }
}
/* Left side: 8 rented tiles */
.rented-wrap { position: relative; }
.rented-label, .owned-label {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--fg-faint);
margin-bottom: 16px;
display: flex; align-items: center; gap: 10px;
}
.owned-label { color: var(--accent); }
.rented-label::after, .owned-label::after {
content: ""; flex: 1; height: 1px;
background: var(--hairline);
}
.rented-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
position: relative;
}
@media (max-width: 540px) {
.rented-grid { grid-template-columns: repeat(2, 1fr); }
}
.tool-tile {
position: relative;
padding: 14px 12px 12px;
background: oklch(0.18 0.006 60 / 0.7);
border: 1px solid var(--hairline);
border-radius: 10px;
filter: grayscale(1);
opacity: 0.85;
transition: opacity .3s;
}
.tool-tile:hover { opacity: 1; }
.tool-glyph {
width: 22px; height: 22px;
border-radius: 6px;
background: oklch(0.30 0.008 60);
color: var(--fg-mute);
font-family: var(--font-mono);
font-weight: 600;
font-size: 12px;
display: grid; place-items: center;
margin-bottom: 8px;
}
.tool-name {
font-size: 13px;
color: var(--fg-dim);
font-weight: 500;
letter-spacing: -0.01em;
}
.tool-price {
margin-top: 2px;
font-family: var(--font-mono);
font-size: 10.5px;
color: var(--fg-faint);
letter-spacing: 0.02em;
}
/* The chaos: faint connecting lines between tiles */
.rented-grid::before {
content: "";
position: absolute;
inset: -4px;
background:
radial-gradient(circle at 22% 28%, oklch(0.50 0.05 35 / 0.18), transparent 24%),
radial-gradient(circle at 78% 72%, oklch(0.50 0.05 60 / 0.15), transparent 26%);
pointer-events: none;
z-index: 0;
}
.rented-grid > .tool-tile { z-index: 1; }
/* Spaghetti lines SVG overlay */
.rented-spaghetti {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
}
.rented-spaghetti path {
fill: none;
stroke: oklch(0.45 0.04 35 / 0.4);
stroke-width: 1;
stroke-dasharray: 3 4;
}
/* Rented total at bottom */
.rented-total {
margin-top: 14px;
display: flex; justify-content: space-between; align-items: baseline;
padding: 10px 14px;
background: oklch(0.16 0.008 60 / 0.5);
border: 1px dashed var(--hairline);
border-radius: 8px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--fg-mute);
letter-spacing: 0.02em;
}
.rented-total b {
color: var(--fg-dim);
font-weight: 500;
font-size: 15px;
}
/* Middle arrow */
.stack-arrow {
display: flex; flex-direction: column;
align-items: center; justify-content: center;
color: var(--accent);
gap: 6px;
}
.stack-arrow svg {
filter: drop-shadow(0 0 12px var(--accent-glow));
}
.stack-arrow-label {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent);
}
@media (max-width: 880px) {
.stack-arrow { padding: 16px 0; transform: rotate(90deg); }
}
/* Right side: one tile */
.owned-tile {
position: relative;
padding: 28px 26px 26px;
background: linear-gradient(180deg, oklch(0.22 0.012 60 / 0.95), oklch(0.18 0.008 60 / 0.95));
border: 1px solid oklch(0.74 0.175 35 / 0.55);
border-radius: 18px;
box-shadow:
0 0 60px -10px var(--accent-glow),
0 30px 80px -20px oklch(0 0 0 / 0.6),
inset 0 1px 0 oklch(0.84 0.16 35 / 0.18);
overflow: hidden;
}
.owned-tile::before {
/* Top accent hairline */
content: "";
position: absolute; top: 0; left: 0; right: 0; height: 1px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
opacity: .9;
}
.owned-glyph {
width: 44px; height: 44px;
border-radius: 12px;
background: linear-gradient(135deg, var(--accent), oklch(0.65 0.20 18));
color: var(--accent-fg);
display: grid; place-items: center;
margin-bottom: 16px;
box-shadow: 0 0 24px var(--accent-glow), inset 0 1px 0 oklch(1 0 0 / 0.25);
}
.owned-glyph svg { display: block; }
.owned-title {
font-size: 22px; font-weight: 500;
letter-spacing: -0.018em;
color: var(--fg);
}
.owned-sub {
margin-top: 4px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--accent);
letter-spacing: 0.04em;
}
.owned-features {
list-style: none; padding: 0; margin: 18px 0 0;
display: flex; flex-direction: column; gap: 6px;
font-size: 13.5px;
color: var(--fg-dim);
}
.owned-features li {
display: flex; align-items: center; gap: 8px;
}
.owned-features li::before {
content: "✓";
color: var(--ok);
font-family: var(--font-mono);
font-weight: 600;
font-size: 12px;
width: 16px; flex-shrink: 0;
}
.owned-foot {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--hairline);
display: flex; justify-content: space-between; align-items: baseline;
font-family: var(--font-mono);
font-size: 11.5px;
color: var(--fg-mute);
letter-spacing: 0.02em;
}
.owned-foot b {
color: var(--accent);
font-weight: 500;
font-size: 14px;
}
.stack-foot {
margin-top: 48px;
text-align: center;
font-size: 17px;
color: var(--fg-dim);
text-wrap: balance;
max-width: 720px; margin-inline: auto;
}
.stack-foot b { color: var(--fg); font-weight: 500; }
`}</style>
<div className="wrap">
<div className="stack-head">
<Eyebrow>Replace your stack</Eyebrow>
<h2 className="stack-title" style={{ marginTop: 18 }}>
You're running your business on <span className="accent">eight tools</span>
<br/>that don't fit.
</h2>
<p className="stack-sub">
A booking app over here. Invoicing over there. A separate CRM.
A POS that doesn't quite know about either. An accounting add-on, a scheduler,
a loyalty platform, plus the spreadsheet you actually trust.
</p>
</div>
<div className="stack-stage">
{/* Left: rented mess */}
<div className="rented-wrap">
<div className="rented-label">What you rent today</div>
<div className="rented-grid">
<svg className="rented-spaghetti" viewBox="0 0 200 100" preserveAspectRatio="none" aria-hidden="true">
<path d="M20 25 C 60 60, 120 10, 180 50" />
<path d="M50 15 C 90 70, 130 80, 170 25" />
<path d="M30 75 C 80 30, 140 60, 180 80" />
<path d="M15 50 C 80 80, 120 20, 185 70" />
</svg>
{STACK_TOOLS.map((tool) => (
<div className="tool-tile" key={tool.name}>
<div className="tool-glyph">{tool.glyph}</div>
<div className="tool-name">{tool.name}</div>
<div className="tool-price">{tool.price}</div>
</div>
))}
</div>
<div className="rented-total">
<span>Monthly rent</span>
<b>$286+ / mo</b>
</div>
</div>
{/* Middle: arrow */}
<div className="stack-arrow">
<span className="stack-arrow-label">Replaced by</span>
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" aria-hidden="true">
<path d="M8 22h26M24 12l10 10-10 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
{/* Right: owned tile */}
<div className="owned-tile">
<div className="owned-label">What you own with Vibn</div>
<div className="owned-glyph">
<svg viewBox="0 0 36 32" width="60%" height="60%" fill="currentColor" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" aria-hidden="true">
<path d="M4 5 L10 5 L12 18 L14 5 L20 5 L12 27 Z" />
<rect x="22.5" y="23" width="9.5" height="3.8" rx="0.7" />
</svg>
</div>
<div className="owned-title">Your business, one tool.</div>
<div className="owned-sub">built for you · owned by you</div>
<ul className="owned-features">
<li>Bookings, customers, invoicing one place</li>
<li>Fits how your business actually runs</li>
<li>No new tools to learn. No homework.</li>
<li>You own the code. You own the data.</li>
</ul>
<div className="owned-foot">
<span>One tool</span>
<b>One price · No rent</b>
</div>
</div>
</div>
<p className="stack-foot">
Eight tools, none of them built for you, none of them talking to each other.
<br/><b>Vibn replaces the whole stack with one tool built for your business, owned by you.</b>
</p>
</div>
</section>
);
}
Object.assign(window, { Stack });

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,568 @@
// tweaks-panel.jsx
// Reusable Tweaks shell + form-control helpers.
//
// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
// individual prototypes don't re-roll it. Ships a consistent set of controls so you
// don't hand-draw <input type="range">, segmented radios, steppers, etc.
//
// Usage (in an HTML file that loads React + Babel):
//
// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
// "primaryColor": "#D97757",
// "palette": ["#D97757", "#29261b", "#f6f4ef"],
// "fontSize": 16,
// "density": "regular",
// "dark": false
// }/*EDITMODE-END*/;
//
// function App() {
// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
// return (
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
// Hello
// <TweaksPanel>
// <TweakSection label="Typography" />
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
// onChange={(v) => setTweak('fontSize', v)} />
// <TweakRadio label="Density" value={t.density}
// options={['compact', 'regular', 'comfy']}
// onChange={(v) => setTweak('density', v)} />
// <TweakSection label="Theme" />
// <TweakColor label="Primary" value={t.primaryColor}
// options={['#D97757', '#2A6FDB', '#1F8A5B', '#7A5AE0']}
// onChange={(v) => setTweak('primaryColor', v)} />
// <TweakColor label="Palette" value={t.palette}
// options={[['#D97757', '#29261b', '#f6f4ef'],
// ['#475569', '#0f172a', '#f1f5f9']]}
// onChange={(v) => setTweak('palette', v)} />
// <TweakToggle label="Dark mode" value={t.dark}
// onChange={(v) => setTweak('dark', v)} />
// </TweaksPanel>
// </div>
// );
// }
//
// ─────────────────────────────────────────────────────────────────────────────
const __TWEAKS_STYLE = `
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right;
background:rgba(250,249,247,.78);color:#29261b;
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
.twk-hd{display:flex;align-items:center;justify-content:space-between;
padding:10px 8px 10px 14px;cursor:move;user-select:none}
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
overflow-y:auto;overflow-x:hidden;min-height:0;
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
.twk-body::-webkit-scrollbar{width:8px}
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
border:2px solid transparent;background-clip:content-box}
.twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
border:2px solid transparent;background-clip:content-box}
.twk-row{display:flex;flex-direction:column;gap:5px}
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
color:rgba(41,38,27,.72)}
.twk-lbl>span:first-child{font-weight:500}
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
color:rgba(41,38,27,.45);padding:10px 0 0}
.twk-sect:first-child{padding-top:0}
.twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
select.twk-field{padding-right:22px;
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
background-repeat:no-repeat;background-position:right 8px center}
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
width:14px;height:14px;border-radius:50%;background:#fff;
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
background:rgba(0,0,0,.06);user-select:none}
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
.twk-seg.dragging .twk-seg-thumb{transition:none}
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
overflow-wrap:anywhere}
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
.twk-toggle[data-on="1"]{background:#34c759}
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
.twk-num{display:flex;align-items:center;box-sizing:border-box;min-width:0;height:26px;padding:0 0 0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
.twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
user-select:none;padding-right:8px}
.twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
outline:none;color:inherit;-moz-appearance:textfield}
.twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
-webkit-appearance:none;margin:0}
.twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
.twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
.twk-btn:hover{background:rgba(0,0,0,.88)}
.twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
.twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
background:transparent;flex-shrink:0}
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
.twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
.twk-chips{display:flex;gap:6px}
.twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px;
padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default;
box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06);
transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s}
.twk-chip:hover{transform:translateY(-1px);
box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)}
.twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85),
0 2px 6px rgba(0,0,0,.15)}
.twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%;
display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)}
.twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)}
.twk-chip>span>i:first-child{box-shadow:none}
.twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px;
filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}
`;
// ── useTweaks ───────────────────────────────────────────────────────────────
// Single source of truth for tweak values. setTweak persists via the host
// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
function useTweaks(defaults) {
const [values, setValues] = React.useState(defaults);
// Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
// useState-style call doesn't write a "[object Object]" key into the persisted
// JSON block.
const setTweak = React.useCallback((keyOrEdits, val) => {
const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
? keyOrEdits : { [keyOrEdits]: val };
setValues((prev) => ({ ...prev, ...edits }));
window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
// Same-window signal so in-page listeners (deck-stage rail thumbnails)
// can react — the parent message only reaches the host, not peers.
window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits }));
}, []);
return [values, setTweak];
}
// ── TweaksPanel ─────────────────────────────────────────────────────────────
// Floating shell. Registers the protocol listener BEFORE announcing
// availability — if the announce ran first, the host's activate could land
// before our handler exists and the toolbar toggle would silently no-op.
// The close button posts __edit_mode_dismissed so the host's toolbar toggle
// flips off in lockstep; the host echoes __deactivate_edit_mode back which
// is what actually hides the panel.
function TweaksPanel({ title = 'Tweaks', noDeckControls = false, children }) {
const [open, setOpen] = React.useState(false);
const dragRef = React.useRef(null);
// Auto-inject a rail toggle when a <deck-stage> is on the page. The
// toggle drives the deck's per-viewer _railVisible via window message;
// state is mirrored from the same localStorage key the deck reads so
// the control reflects reality across reloads. The mechanism is the
// message — authors who want custom placement can post it directly
// and pass noDeckControls to suppress this one.
const hasDeckStage = React.useMemo(
() => typeof document !== 'undefined' && !!document.querySelector('deck-stage'),
[],
);
// deck-stage enables its rail in connectedCallback, but this panel can
// mount before that element has upgraded. The initial read catches the
// common case; the listener covers mounting first. (Older deck-stage.js
// copies still wait for the host's __omelette_rail_enabled postMessage —
// same listener handles those.)
const [railEnabled, setRailEnabled] = React.useState(
() => hasDeckStage && !!document.querySelector('deck-stage')?._railEnabled,
);
React.useEffect(() => {
if (!hasDeckStage || railEnabled) return undefined;
const onMsg = (e) => {
if (e.data && e.data.type === '__omelette_rail_enabled') setRailEnabled(true);
};
window.addEventListener('message', onMsg);
return () => window.removeEventListener('message', onMsg);
}, [hasDeckStage, railEnabled]);
const [railVisible, setRailVisible] = React.useState(() => {
try { return localStorage.getItem('deck-stage.railVisible') !== '0'; } catch (e) { return true; }
});
const toggleRail = (on) => {
setRailVisible(on);
window.postMessage({ type: '__deck_rail_visible', on }, '*');
};
const offsetRef = React.useRef({ x: 16, y: 16 });
const PAD = 16;
const clampToViewport = React.useCallback(() => {
const panel = dragRef.current;
if (!panel) return;
const w = panel.offsetWidth, h = panel.offsetHeight;
const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
offsetRef.current = {
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
};
panel.style.right = offsetRef.current.x + 'px';
panel.style.bottom = offsetRef.current.y + 'px';
}, []);
React.useEffect(() => {
if (!open) return;
clampToViewport();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', clampToViewport);
return () => window.removeEventListener('resize', clampToViewport);
}
const ro = new ResizeObserver(clampToViewport);
ro.observe(document.documentElement);
return () => ro.disconnect();
}, [open, clampToViewport]);
React.useEffect(() => {
const onMsg = (e) => {
const t = e?.data?.type;
if (t === '__activate_edit_mode') setOpen(true);
else if (t === '__deactivate_edit_mode') setOpen(false);
};
window.addEventListener('message', onMsg);
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
return () => window.removeEventListener('message', onMsg);
}, []);
const dismiss = () => {
setOpen(false);
window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
};
const onDragStart = (e) => {
const panel = dragRef.current;
if (!panel) return;
const r = panel.getBoundingClientRect();
const sx = e.clientX, sy = e.clientY;
const startRight = window.innerWidth - r.right;
const startBottom = window.innerHeight - r.bottom;
const move = (ev) => {
offsetRef.current = {
x: startRight - (ev.clientX - sx),
y: startBottom - (ev.clientY - sy),
};
clampToViewport();
};
const up = () => {
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', up);
};
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
};
if (!open) return null;
return (
<>
<style>{__TWEAKS_STYLE}</style>
<div ref={dragRef} className="twk-panel" data-noncommentable=""
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
<div className="twk-hd" onMouseDown={onDragStart}>
<b>{title}</b>
<button className="twk-x" aria-label="Close tweaks"
onMouseDown={(e) => e.stopPropagation()}
onClick={dismiss}></button>
</div>
<div className="twk-body">
{children}
{hasDeckStage && railEnabled && !noDeckControls && (
<TweakSection label="Deck">
<TweakToggle label="Thumbnail rail" value={railVisible} onChange={toggleRail} />
</TweakSection>
)}
</div>
</div>
</>
);
}
// ── Layout helpers ──────────────────────────────────────────────────────────
function TweakSection({ label, children }) {
return (
<>
<div className="twk-sect">{label}</div>
{children}
</>
);
}
function TweakRow({ label, value, children, inline = false }) {
return (
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
<div className="twk-lbl">
<span>{label}</span>
{value != null && <span className="twk-val">{value}</span>}
</div>
{children}
</div>
);
}
// ── Controls ────────────────────────────────────────────────────────────────
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
return (
<TweakRow label={label} value={`${value}${unit}`}>
<input type="range" className="twk-slider" min={min} max={max} step={step}
value={value} onChange={(e) => onChange(Number(e.target.value))} />
</TweakRow>
);
}
function TweakToggle({ label, value, onChange }) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
role="switch" aria-checked={!!value}
onClick={() => onChange(!value)}><i /></button>
</div>
);
}
function TweakRadio({ label, value, options, onChange }) {
const trackRef = React.useRef(null);
const [dragging, setDragging] = React.useState(false);
// The active value is read by pointer-move handlers attached for the lifetime
// of a drag — ref it so a stale closure doesn't fire onChange for every move.
const valueRef = React.useRef(value);
valueRef.current = value;
// Segments wrap mid-word once per-segment width runs out. The track is
// ~248px (280 panel 28 body pad 4 seg pad), each button loses 12px
// to its own padding, and 11.5px system-ui averages ~6.3px/char — so 2
// options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall
// back to a dropdown rather than wrap.
const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length;
const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0);
const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0);
if (!fitsAsSegments) {
// <select> emits strings — map back to the original option value so the
// fallback stays type-preserving (numbers, booleans) like the segment path.
const resolve = (s) => {
const m = options.find((o) => String(typeof o === 'object' ? o.value : o) === s);
return m === undefined ? s : typeof m === 'object' ? m.value : m;
};
return <TweakSelect label={label} value={value} options={options}
onChange={(s) => onChange(resolve(s))} />;
}
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
const n = opts.length;
const segAt = (clientX) => {
const r = trackRef.current.getBoundingClientRect();
const inner = r.width - 4;
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
return opts[Math.max(0, Math.min(n - 1, i))].value;
};
const onPointerDown = (e) => {
setDragging(true);
const v0 = segAt(e.clientX);
if (v0 !== valueRef.current) onChange(v0);
const move = (ev) => {
if (!trackRef.current) return;
const v = segAt(ev.clientX);
if (v !== valueRef.current) onChange(v);
};
const up = () => {
setDragging(false);
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<TweakRow label={label}>
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
<div className="twk-seg-thumb"
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
width: `calc((100% - 4px) / ${n})` }} />
{opts.map((o) => (
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
{o.label}
</button>
))}
</div>
</TweakRow>
);
}
function TweakSelect({ label, value, options, onChange }) {
return (
<TweakRow label={label}>
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((o) => {
const v = typeof o === 'object' ? o.value : o;
const l = typeof o === 'object' ? o.label : o;
return <option key={v} value={v}>{l}</option>;
})}
</select>
</TweakRow>
);
}
function TweakText({ label, value, placeholder, onChange }) {
return (
<TweakRow label={label}>
<input className="twk-field" type="text" value={value} placeholder={placeholder}
onChange={(e) => onChange(e.target.value)} />
</TweakRow>
);
}
function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
const clamp = (n) => {
if (min != null && n < min) return min;
if (max != null && n > max) return max;
return n;
};
const startRef = React.useRef({ x: 0, val: 0 });
const onScrubStart = (e) => {
e.preventDefault();
startRef.current = { x: e.clientX, val: value };
const decimals = (String(step).split('.')[1] || '').length;
const move = (ev) => {
const dx = ev.clientX - startRef.current.x;
const raw = startRef.current.val + dx * step;
const snapped = Math.round(raw / step) * step;
onChange(clamp(Number(snapped.toFixed(decimals))));
};
const up = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<div className="twk-num">
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
<input type="number" value={value} min={min} max={max} step={step}
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
{unit && <span className="twk-num-unit">{unit}</span>}
</div>
);
}
// Relative-luminance contrast pick — checkmarks drawn over a swatch need to
// read on both #111 and #fafafa without per-option configuration. Hex input
// only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light".
function __twkIsLight(hex) {
const h = String(hex).replace('#', '');
const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0');
const n = parseInt(x.slice(0, 6), 16);
if (Number.isNaN(n)) return true;
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
return r * 299 + g * 587 + b * 114 > 148000;
}
const __TwkCheck = ({ light }) => (
<svg viewBox="0 0 14 14" aria-hidden="true">
<path d="M3 7.2 5.8 10 11 4.2" fill="none" strokeWidth="2.2"
strokeLinecap="round" strokeLinejoin="round"
stroke={light ? 'rgba(0,0,0,.78)' : '#fff'} />
</svg>
);
// TweakColor — curated color/palette picker. Each option is either a single
// hex string or an array of 1-5 hex strings; the card adapts — a lone color
// renders solid, a palette renders colors[0] as the hero (left ~2/3) with the
// rest stacked in a sharp column on the right. onChange emits the
// option in the shape it was passed (string stays string, array stays array).
// Without options it falls back to the native color input for back-compat.
function TweakColor({ label, value, options, onChange }) {
if (!options || !options.length) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<input type="color" className="twk-swatch" value={value}
onChange={(e) => onChange(e.target.value)} />
</div>
);
}
// Native <input type=color> emits lowercase hex per the HTML spec, so
// compare case-insensitively. String() guards JSON.stringify(undefined),
// which returns the primitive undefined (no .toLowerCase).
const key = (o) => String(JSON.stringify(o)).toLowerCase();
const cur = key(value);
return (
<TweakRow label={label}>
<div className="twk-chips" role="radiogroup">
{options.map((o, i) => {
const colors = Array.isArray(o) ? o : [o];
const [hero, ...rest] = colors;
const sup = rest.slice(0, 4);
const on = key(o) === cur;
return (
<button key={i} type="button" className="twk-chip" role="radio"
aria-checked={on} data-on={on ? '1' : '0'}
aria-label={colors.join(', ')} title={colors.join(' · ')}
style={{ background: hero }}
onClick={() => onChange(o)}>
{sup.length > 0 && (
<span>
{sup.map((c, j) => <i key={j} style={{ background: c }} />)}
</span>
)}
{on && <__TwkCheck light={__twkIsLight(hero)} />}
</button>
);
})}
</div>
</TweakRow>
);
}
function TweakButton({ label, onClick, secondary = false }) {
return (
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
onClick={onClick}>{label}</button>
);
}
Object.assign(window, {
useTweaks, TweaksPanel, TweakSection, TweakRow,
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
TweakText, TweakNumber, TweakColor, TweakButton,
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -0,0 +1,120 @@
# Vibn AI Templates
A small, themable starter kit for building modern SaaS UIs. Pure React + CSS variables — no build step, no dependencies. Designed to be copy-pasted into any project.
## What's in it
- **`tokens.css`** — Every color, radius, shadow, and type token, exposed as CSS custom properties. Four themes ship out of the box:
- `.theme-minimal` — soft warm light (Linear / Notion school)
- `.theme-dark` — black-and-white surface (Vercel / Stripe school)
- `.theme-glass` — aurora gradient + frosted glass
- `.theme-editorial` — paper, serif display, hairline rules
- **`icons.jsx`** — A small Tabler-style stroke icon set (`<Icon name="search"/>`) plus a `<VibnMark/>` brand glyph.
- **`components.jsx`** — Atoms + composites. Every visual property reads from a CSS variable:
- **Forms** · `Button`, `IconButton`, `Field`, `Input`, `Textarea`, `Select`, `Checkbox`, `Radio`, `Switch`, `FieldGroup`
- **Containers** · `Card`, `CardHeader`, `Divider`, `Modal`, `Banner`
- **Display** · `Badge` (tones: neutral / accent / success / warn / danger / info), `Avatar`, `AvatarStack`, `Tabs`, `Table`, `Spinner`, `KBD`
- **`shells.jsx`** — Page-level layouts:
- **In-product** · `SidebarShell`, `TopbarShell`, `RailShell`
- **Auth** · `AuthCenteredShell`, `AuthSplitShell`, `AuthGlassShell`
## How theming works
Tokens are CSS custom properties on `:root` (the default minimal theme). Each `.theme-*` class overrides a subset. Apply a theme by adding the class anywhere — usually on `<html>` or a top-level wrapper.
```html
<html class="theme-glass">
<!-- the whole page uses the glass theme -->
</html>
```
```html
<!-- or scope a theme to one region -->
<div class="theme-editorial">
<Card>… this card is editorial …</Card>
</div>
```
Themes can nest. Setting `theme-*` on a child element overrides only the tokens that theme defines; the rest inherit from the parent.
### Adding a fifth theme
Add a new class to `tokens.css` that overrides whichever tokens differ from `:root`:
```css
.theme-sunset {
--bg: #2b0d0e;
--surface: #3a1316;
--accent: #ff8a3a;
--accent-2: #f43f5e;
--text: #fef7ee;
--text-2: #f0c8b0;
--border: #4a1f23;
--button-bg: #ff8a3a;
--button-fg: #2b0d0e;
}
```
You don't need to redefine the whole token set — just the differences. Components don't change.
## Usage in plain HTML (no bundler)
```html
<link rel="stylesheet" href="vibn-ai-templates/tokens.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=DM+Serif+Display:ital@0;1&display=swap" rel="stylesheet">
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js"></script>
<script type="text/babel" src="vibn-ai-templates/icons.jsx"></script>
<script type="text/babel" src="vibn-ai-templates/components.jsx"></script>
<script type="text/babel" src="vibn-ai-templates/shells.jsx"></script>
<div id="root" class="theme-dark"></div>
<script type="text/babel">
function App() {
return (
<AuthCenteredShell brand={{ name: "Acme" }}>
<h1 style={{ margin: 0, fontSize: 22, fontWeight: 600 }}>Welcome back</h1>
<p style={{ color: "var(--text-2)", marginTop: 6 }}>Sign in to continue.</p>
<Field label="Email"><Input value="mira@acme.io"/></Field>
<Field label="Password"><Input type="password" value="••••••••••"/></Field>
<Button full>Sign in</Button>
</AuthCenteredShell>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
```
## Usage in a React codebase
Convert the three `.jsx` files from `Object.assign(window, …)` to named `export` statements. The components have no runtime dependencies beyond React.
```jsx
// In your app
import "vibn-ai-templates/tokens.css";
import { Button, Card, Input, Field, Tabs } from "vibn-ai-templates/components";
import { SidebarShell } from "vibn-ai-templates/shells";
// Pick a theme on your root
<html className="theme-dark">
```
## Conventions
- **Inline styles read from CSS vars** — `style={{ background: "var(--surface)" }}`. This is intentional: it lets the entire library reskin with one class swap, and avoids a CSS-in-JS dependency.
- **Components are presentational.** State (open/closed modals, active tabs, form values) lives in your app. Pass `active` + `onChange` to controlled components.
- **No external icon dependency.** `icons.jsx` ships a curated set. Add to it freely.
- **Avatars hash a color from the name** unless you pass `color="#…"`.
- **Tables and tabs are uncontrolled-friendly** — pass `rows`/`items`, omit selection props if you don't need them.
## Showcase
`Vibn UI Showcase.html` at the project root renders every component across every theme. Use it as the visual reference and as a starting point for new screens.
## Versioning
This is a starter — fork it. There's no semver, no changelog. Edit `tokens.css` to match your brand, prune what you don't use, extend what you do.

View File

@@ -0,0 +1,737 @@
// ============================================================
// vibn-ai-templates/components.jsx
// ------------------------------------------------------------
// The core component set. Every visual property is wired to a
// CSS variable from tokens.css — flipping `class="theme-glass"`
// (or any other theme class) reskins the whole library.
//
// Components export to `window` for use in script-tag HTML
// projects. In a real codebase, swap the bottom-of-file
// assignment for `export { … }`.
//
// Components included:
// Button, IconButton, Field, Input, Textarea, Select,
// Checkbox, Radio, Switch, Card, Badge, Tag, Avatar,
// AvatarStack, Tabs, Table, Modal, Banner, Divider,
// FieldGroup, KBD, Spinner.
// ============================================================
// ─── Helpers ─────────────────────────────────────────────────
const cx = (...names) => names.filter(Boolean).join(" ");
const noop = () => {};
// ─── Button ──────────────────────────────────────────────────
// variant: primary (default), secondary, ghost, destructive
// size: sm | md (default) | lg
// leadingIcon / trailingIcon: <Icon name="…"/>
// loading: disables and shows a spinner
const Button = ({
children, variant = "primary", size = "md", full = false,
leadingIcon, trailingIcon, loading, disabled, onClick = noop, style, type = "button",
...rest
}) => {
const sizing = {
sm: { padY: 6, padX: 12, font: "var(--text-sm)", iconSize: 13 },
md: { padY: 9, padX: 16, font: "var(--text-md)", iconSize: 15 },
lg: { padY: 12, padX: 22, font: "var(--text-lg)", iconSize: 16 },
}[size];
const variants = {
primary: {
background: "var(--button-bg)",
color: "var(--button-fg)",
border: "1px solid var(--button-border)",
},
secondary: {
background: "var(--button-secondary-bg)",
color: "var(--button-secondary-fg)",
border: "1px solid var(--button-secondary-border)",
},
ghost: {
background: "transparent",
color: "var(--button-ghost-fg)",
border: "1px solid transparent",
},
destructive: {
background: "var(--danger)",
color: "#ffffff",
border: "1px solid var(--danger)",
},
}[variant];
return (
<button
type={type}
disabled={disabled || loading}
onClick={onClick}
style={{
...variants,
padding: `${sizing.padY}px ${sizing.padX}px`,
borderRadius: "var(--button-radius)",
fontFamily: "var(--font-sans)",
fontSize: sizing.font,
fontWeight: "var(--weight-medium)",
lineHeight: 1.2,
cursor: disabled || loading ? "not-allowed" : "pointer",
opacity: disabled ? 0.5 : 1,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
width: full ? "100%" : "auto",
whiteSpace: "nowrap",
transition: "background var(--duration) var(--ease), transform var(--duration-fast) var(--ease)",
...style,
}}
{...rest}
>
{loading ? <Spinner size={sizing.iconSize}/> : leadingIcon}
<span>{children}</span>
{!loading && trailingIcon}
</button>
);
};
// ─── IconButton ──────────────────────────────────────────────
const IconButton = ({ icon, name, size = "md", variant = "ghost", onClick = noop, label, style }) => {
const dims = { sm: 28, md: 32, lg: 38 }[size];
const iconSize = { sm: 14, md: 16, lg: 18 }[size];
const variants = {
ghost: { background: "transparent", color: "var(--text-2)", border: "1px solid transparent" },
secondary: { background: "var(--button-secondary-bg)", color: "var(--button-secondary-fg)",
border: "1px solid var(--button-secondary-border)" },
}[variant];
return (
<button
aria-label={label}
onClick={onClick}
style={{
...variants,
width: dims, height: dims, borderRadius: "var(--radius-sm)",
display: "inline-flex", alignItems: "center", justifyContent: "center",
cursor: "pointer", padding: 0,
...style,
}}
>
{icon ?? <Icon name={name} size={iconSize} />}
</button>
);
};
// ─── Spinner ─────────────────────────────────────────────────
const Spinner = ({ size = 14, stroke = 2 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.2" strokeWidth={stroke} />
<path d="M12 3a9 9 0 0 1 9 9" stroke="currentColor" strokeWidth={stroke} strokeLinecap="round">
<animateTransform attributeName="transform" type="rotate"
from="0 12 12" to="360 12 12" dur="0.9s" repeatCount="indefinite"/>
</path>
</svg>
);
// ─── Field (wraps a labelled input with hint / error) ────────
const Field = ({ label, hint, error, optional, htmlFor, children, style }) => (
<div style={{ marginBottom: "var(--space-4)", ...style }}>
{label && (
<label htmlFor={htmlFor} style={{
display: "flex", justifyContent: "space-between", alignItems: "baseline",
fontSize: "var(--text-sm)", fontWeight: "var(--weight-medium)",
color: "var(--text)", marginBottom: 6,
}}>
<span>{label}</span>
{optional && <span style={{ color: "var(--text-3)", fontWeight: 400 }}>optional</span>}
</label>
)}
{children}
{(hint || error) && (
<div style={{
fontSize: "var(--text-xs)", marginTop: 5,
color: error ? "var(--danger)" : "var(--text-3)",
}}>{error || hint}</div>
)}
</div>
);
// ─── Input ───────────────────────────────────────────────────
// Bare input (use inside <Field>). leadingIcon / trailingIcon
// add an inner ornament. invalid red-rings the border.
const Input = ({
value, placeholder, type = "text", leadingIcon, trailingIcon,
invalid, disabled, autofocus, onChange = noop, id, style, ...rest
}) => (
<div style={{
display: "flex", alignItems: "center", gap: 8,
padding: "10px 12px",
borderRadius: "var(--field-radius)",
background: "var(--field-bg)",
border: `1px solid ${invalid ? "var(--danger)" : "var(--field-border)"}`,
boxShadow: autofocus ? "var(--shadow-focus)" : "var(--shadow-sm)",
fontSize: "var(--text-md)",
color: "var(--text)",
backdropFilter: "blur(var(--surface-blur))",
WebkitBackdropFilter: "blur(var(--surface-blur))",
opacity: disabled ? 0.5 : 1,
transition: "border-color var(--duration), box-shadow var(--duration)",
...style,
}}>
{leadingIcon && <span style={{ color: "var(--text-3)", display: "flex" }}>{leadingIcon}</span>}
<input
id={id}
type={type}
value={value}
placeholder={placeholder}
disabled={disabled}
onChange={(e) => onChange(e.target.value, e)}
style={{
flex: 1, minWidth: 0, border: "none", outline: "none", background: "transparent",
fontFamily: "inherit", fontSize: "inherit", color: "inherit",
padding: 0,
}}
{...rest}
/>
{trailingIcon && <span style={{ color: "var(--text-3)", display: "flex" }}>{trailingIcon}</span>}
</div>
);
// ─── Textarea ────────────────────────────────────────────────
const Textarea = ({ value, placeholder, rows = 4, onChange = noop, invalid, id, style, ...rest }) => (
<textarea
id={id}
value={value}
placeholder={placeholder}
rows={rows}
onChange={(e) => onChange(e.target.value, e)}
style={{
width: "100%", display: "block", padding: "10px 12px",
borderRadius: "var(--field-radius)",
background: "var(--field-bg)",
border: `1px solid ${invalid ? "var(--danger)" : "var(--field-border)"}`,
fontSize: "var(--text-md)", color: "var(--text)",
fontFamily: "var(--font-sans)", resize: "vertical",
outline: "none", boxShadow: "var(--shadow-sm)",
backdropFilter: "blur(var(--surface-blur))",
WebkitBackdropFilter: "blur(var(--surface-blur))",
...style,
}}
{...rest}
/>
);
// ─── Select (presentation only — clicks the menu open visually) ─
const Select = ({ value, placeholder, options = [], leadingIcon, style, ...rest }) => (
<div style={{
display: "flex", alignItems: "center", gap: 8,
padding: "10px 12px", borderRadius: "var(--field-radius)",
background: "var(--field-bg)", border: "1px solid var(--field-border)",
fontSize: "var(--text-md)", color: value ? "var(--text)" : "var(--text-3)",
cursor: "pointer", boxShadow: "var(--shadow-sm)",
backdropFilter: "blur(var(--surface-blur))",
WebkitBackdropFilter: "blur(var(--surface-blur))",
...style,
}} {...rest}>
{leadingIcon && <span style={{ color: "var(--text-3)", display: "flex" }}>{leadingIcon}</span>}
<span style={{ flex: 1 }}>{value || placeholder}</span>
<Icon name="chevDown" size={14} style={{ color: "var(--text-3)" }} />
</div>
);
// ─── Checkbox ────────────────────────────────────────────────
const Checkbox = ({ checked, indeterminate, disabled, label, hint, onChange = noop, style }) => (
<label style={{
display: "flex", alignItems: "flex-start", gap: 10, cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.5 : 1, ...style,
}}>
<span
role="checkbox"
aria-checked={indeterminate ? "mixed" : !!checked}
onClick={() => !disabled && onChange(!checked)}
style={{
width: 16, height: 16, borderRadius: 4, marginTop: 1, flexShrink: 0,
border: `1px solid ${checked || indeterminate ? "var(--accent)" : "var(--border-strong)"}`,
background: checked || indeterminate ? "var(--accent)" : "var(--surface)",
color: "var(--text-on-accent)",
display: "inline-flex", alignItems: "center", justifyContent: "center",
transition: "background var(--duration), border-color var(--duration)",
}}
>
{checked && !indeterminate && <Icon name="checkOnly" size={11} stroke={2.6}/>}
{indeterminate && <div style={{ width: 8, height: 2, background: "currentColor", borderRadius: 1 }}/>}
</span>
{(label || hint) && (
<span style={{ minWidth: 0 }}>
{label && <span style={{ fontSize: "var(--text-md)", color: "var(--text)" }}>{label}</span>}
{hint && <div style={{ fontSize: "var(--text-xs)", color: "var(--text-3)", marginTop: 2 }}>{hint}</div>}
</span>
)}
</label>
);
// ─── Radio ───────────────────────────────────────────────────
const Radio = ({ checked, disabled, label, hint, onChange = noop, style }) => (
<label style={{
display: "flex", alignItems: "flex-start", gap: 10, cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.5 : 1, ...style,
}}>
<span
role="radio"
aria-checked={!!checked}
onClick={() => !disabled && onChange(true)}
style={{
width: 16, height: 16, borderRadius: "50%", marginTop: 1, flexShrink: 0,
border: `1px solid ${checked ? "var(--accent)" : "var(--border-strong)"}`,
background: "var(--surface)",
position: "relative",
}}
>
{checked && <span style={{
position: "absolute", top: 3, left: 3, right: 3, bottom: 3,
background: "var(--accent)", borderRadius: "50%",
}}/>}
</span>
{(label || hint) && (
<span style={{ minWidth: 0 }}>
{label && <span style={{ fontSize: "var(--text-md)", color: "var(--text)" }}>{label}</span>}
{hint && <div style={{ fontSize: "var(--text-xs)", color: "var(--text-3)", marginTop: 2 }}>{hint}</div>}
</span>
)}
</label>
);
// ─── Switch ──────────────────────────────────────────────────
const Switch = ({ checked, disabled, onChange = noop, label, hint, style }) => (
<label style={{
display: "flex", alignItems: "center", gap: 12,
cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.5 : 1, ...style,
}}>
<span
role="switch"
aria-checked={!!checked}
onClick={() => !disabled && onChange(!checked)}
style={{
width: 34, height: 20, borderRadius: 999,
background: checked ? "var(--accent)" : "var(--surface-alt)",
border: `1px solid ${checked ? "var(--accent)" : "var(--border-strong)"}`,
position: "relative", flexShrink: 0,
transition: "background var(--duration), border-color var(--duration)",
}}
>
<span style={{
position: "absolute", top: 1, left: checked ? 15 : 1,
width: 16, height: 16, borderRadius: "50%",
background: checked ? "var(--text-on-accent)" : "var(--surface)",
boxShadow: "0 1px 3px rgba(0,0,0,0.2)",
transition: "left var(--duration) var(--ease)",
}}/>
</span>
{(label || hint) && (
<span style={{ flex: 1, minWidth: 0 }}>
{label && <div style={{ fontSize: "var(--text-md)", color: "var(--text)" }}>{label}</div>}
{hint && <div style={{ fontSize: "var(--text-xs)", color: "var(--text-3)", marginTop: 2 }}>{hint}</div>}
</span>
)}
</label>
);
// ─── Card / Surface ──────────────────────────────────────────
// Card paints a `surface` background with border + shadow.
// Use `variant="raised"` for shadow-lg, "flat" for no shadow.
const Card = ({ children, variant = "default", padding = 20, style, ...rest }) => {
const shadows = {
default: "var(--shadow-sm)",
raised: "var(--shadow)",
floating:"var(--shadow-lg)",
flat: "none",
};
return (
<div style={{
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: "var(--card-radius)",
padding,
boxShadow: shadows[variant] || shadows.default,
backdropFilter: "blur(var(--surface-blur))",
WebkitBackdropFilter: "blur(var(--surface-blur))",
color: "var(--text)",
...style,
}} {...rest}>{children}</div>
);
};
const CardHeader = ({ title, subtitle, action, style }) => (
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "flex-start",
marginBottom: "var(--space-4)", gap: 16, ...style,
}}>
<div style={{ minWidth: 0 }}>
{title && <div style={{
fontSize: "var(--text-lg)", fontWeight: "var(--weight-semibold)",
color: "var(--text)", letterSpacing: "-0.01em",
}}>{title}</div>}
{subtitle && <div style={{
fontSize: "var(--text-sm)", color: "var(--text-2)", marginTop: 2,
}}>{subtitle}</div>}
</div>
{action}
</div>
);
// ─── Badge / Tag ─────────────────────────────────────────────
// tone: neutral | accent | success | warn | danger | info
const Badge = ({ children, tone = "neutral", dot, leadingIcon, style }) => {
const palette = {
neutral: { bg: "var(--surface-alt)", fg: "var(--text-2)", dotColor: "var(--text-3)" },
accent: { bg: "var(--accent-soft)", fg: "var(--accent)", dotColor: "var(--accent)" },
success: { bg: "var(--success-soft)", fg: "var(--success)", dotColor: "var(--success)" },
warn: { bg: "var(--warn-soft)", fg: "var(--warn)", dotColor: "var(--warn)" },
danger: { bg: "var(--danger-soft)", fg: "var(--danger)", dotColor: "var(--danger)" },
info: { bg: "var(--accent-soft)", fg: "var(--accent)", dotColor: "var(--accent)" },
}[tone] || {};
return (
<span style={{
display: "inline-flex", alignItems: "center", gap: 6,
padding: "2px 8px", borderRadius: "var(--radius-pill)",
background: palette.bg, color: palette.fg,
fontSize: "var(--text-xs)", fontWeight: "var(--weight-medium)",
whiteSpace: "nowrap", lineHeight: 1.4,
...style,
}}>
{dot && <span style={{ width: 6, height: 6, borderRadius: "50%", background: palette.dotColor }}/>}
{leadingIcon}
{children}
</span>
);
};
const Tag = Badge; // alias
// ─── Avatar ──────────────────────────────────────────────────
const avatarPalette = ["#d4b8a8", "#e8a87c", "#c8e8a8", "#a8c8e8", "#c8a8e8", "#e8c8a8", "#a8e8c8", "#e8a8c8"];
const hashName = (s = "") => {
let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
return avatarPalette[Math.abs(h) % avatarPalette.length];
};
const Avatar = ({ name = "?", src, size = 32, status, color, ring, style }) => {
const initials = name.split(/\s+/).filter(Boolean).map(w => w[0]).slice(0, 2).join("").toUpperCase();
return (
<span style={{
position: "relative", display: "inline-flex", flexShrink: 0,
width: size, height: size, borderRadius: "50%",
background: color || hashName(name), color: "#3a2820",
alignItems: "center", justifyContent: "center",
fontSize: Math.round(size * 0.4), fontWeight: 600,
boxShadow: ring ? `0 0 0 2px var(--surface), 0 0 0 ${2 + ring}px var(--accent)` : "none",
overflow: "hidden", ...style,
}}>
{src
? <img src={src} alt={name} style={{ width: "100%", height: "100%", objectFit: "cover" }}/>
: initials}
{status && <span style={{
position: "absolute", bottom: 0, right: 0,
width: Math.max(8, size * 0.28), height: Math.max(8, size * 0.28),
borderRadius: "50%", border: "2px solid var(--surface)",
background: status === "online" ? "var(--success)" :
status === "busy" ? "var(--danger)" : "var(--text-3)",
}}/>}
</span>
);
};
const AvatarStack = ({ items = [], size = 28, max = 4 }) => {
const shown = items.slice(0, max);
const remaining = items.length - shown.length;
return (
<div style={{ display: "inline-flex" }}>
{shown.map((p, i) => (
<Avatar key={i} name={p.name} src={p.src} color={p.color} size={size}
style={{ marginLeft: i ? -size * 0.32 : 0, boxShadow: "0 0 0 2px var(--surface)" }}/>
))}
{remaining > 0 && (
<span style={{
width: size, height: size, borderRadius: "50%",
background: "var(--surface-alt)", color: "var(--text-2)",
display: "inline-flex", alignItems: "center", justifyContent: "center",
fontSize: Math.round(size * 0.4), fontWeight: 600,
marginLeft: -size * 0.32, boxShadow: "0 0 0 2px var(--surface)",
}}>+{remaining}</span>
)}
</div>
);
};
// ─── Tabs ────────────────────────────────────────────────────
// Controlled: pass `active` (label of active tab) + `onChange`.
const Tabs = ({ items = [], active, onChange = noop, style, variant = "underline" }) => {
return (
<div style={{
display: "flex", gap: 4, borderBottom: variant === "underline" ? "1px solid var(--border)" : "none",
...style,
}}>
{items.map(t => {
const isActive = t.label === active || t.id === active;
if (variant === "pill") {
return (
<button key={t.id || t.label}
onClick={() => onChange(t.id || t.label)}
style={{
padding: "6px 12px", borderRadius: "var(--radius-pill)",
fontFamily: "var(--font-sans)", fontSize: "var(--text-sm)",
background: isActive ? "var(--accent)" : "transparent",
color: isActive ? "var(--text-on-accent)" : "var(--text-2)",
border: "none", cursor: "pointer", fontWeight: 500,
display: "inline-flex", alignItems: "center", gap: 6,
}}>
{t.icon}{t.label}
{t.count != null && (
<span style={{
fontSize: 10, padding: "1px 6px", borderRadius: 999,
background: isActive ? "rgba(255,255,255,0.18)" : "var(--surface-alt)",
color: "inherit",
}}>{t.count}</span>
)}
</button>
);
}
return (
<button key={t.id || t.label}
onClick={() => onChange(t.id || t.label)}
style={{
padding: "10px 4px", margin: "0 12px 0 0",
fontFamily: "var(--font-sans)", fontSize: "var(--text-md)",
fontWeight: "var(--weight-medium)",
background: "transparent", border: "none", cursor: "pointer",
color: isActive ? "var(--text)" : "var(--text-2)",
borderBottom: isActive ? "2px solid var(--accent)" : "2px solid transparent",
position: "relative", top: 1,
display: "inline-flex", alignItems: "center", gap: 6, whiteSpace: "nowrap",
}}>
{t.icon}{t.label}
{t.count != null && (
<span style={{
fontSize: 10, padding: "1px 6px", borderRadius: 999,
background: isActive ? "var(--accent-soft)" : "var(--surface-alt)",
color: isActive ? "var(--accent)" : "var(--text-3)",
}}>{t.count}</span>
)}
</button>
);
})}
</div>
);
};
// ─── Table ───────────────────────────────────────────────────
// columns: [{ key, label, width?, align?, render? }]
// rows: [{ id, [key]: value, … }]
const Table = ({ columns = [], rows = [], selectable, selected = [], onSelectionChange = noop, density = "comfortable" }) => {
const padY = density === "compact" ? 8 : 12;
const allChecked = rows.length > 0 && selected.length === rows.length;
const someChecked = selected.length > 0 && !allChecked;
const toggleAll = () => onSelectionChange(allChecked ? [] : rows.map(r => r.id));
const toggleOne = (id) => onSelectionChange(
selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id]
);
const headerCell = {
padding: `10px 12px`, fontSize: "var(--text-xs)",
color: "var(--text-3)", fontWeight: "var(--weight-medium)",
textTransform: "uppercase", letterSpacing: "0.04em", textAlign: "left",
borderBottom: "1px solid var(--border)", background: "var(--surface)",
};
const bodyCell = {
padding: `${padY}px 12px`, fontSize: "var(--text-md)",
color: "var(--text)", borderBottom: "1px solid var(--divider)", verticalAlign: "middle",
};
return (
<div style={{
background: "var(--surface)", border: "1px solid var(--border)",
borderRadius: "var(--card-radius)", overflow: "hidden",
backdropFilter: "blur(var(--surface-blur))",
WebkitBackdropFilter: "blur(var(--surface-blur))",
}}>
<table style={{ width: "100%", borderCollapse: "collapse", fontFamily: "var(--font-sans)" }}>
<thead>
<tr>
{selectable && (
<th style={{ ...headerCell, width: 36, paddingRight: 0 }}>
<Checkbox checked={allChecked} indeterminate={someChecked} onChange={toggleAll}/>
</th>
)}
{columns.map(c => (
<th key={c.key} style={{ ...headerCell, width: c.width, textAlign: c.align || "left" }}>
{c.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={r.id ?? i}>
{selectable && (
<td style={{ ...bodyCell, paddingRight: 0 }}>
<Checkbox checked={selected.includes(r.id)} onChange={() => toggleOne(r.id)}/>
</td>
)}
{columns.map(c => (
<td key={c.key} style={{ ...bodyCell, textAlign: c.align || "left" }}>
{c.render ? c.render(r) : r[c.key]}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
};
// ─── Modal (presentational — wrap your own state) ────────────
const Modal = ({ open, onClose = noop, title, description, footer, children, width = 480 }) => {
if (!open) return null;
return (
<div
onClick={onClose}
style={{
position: "fixed", inset: 0, zIndex: 100,
background: "rgba(0,0,0,0.5)",
display: "flex", alignItems: "center", justifyContent: "center", padding: 24,
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width, maxWidth: "100%", maxHeight: "85vh", overflow: "auto",
background: "var(--surface)", color: "var(--text)",
border: "1px solid var(--border)", borderRadius: "var(--modal-radius)",
boxShadow: "var(--shadow-modal)",
backdropFilter: "blur(var(--surface-blur))",
WebkitBackdropFilter: "blur(var(--surface-blur))",
}}
>
<div style={{
padding: "var(--space-6)", display: "flex",
justifyContent: "space-between", alignItems: "flex-start", gap: 16,
}}>
<div style={{ minWidth: 0 }}>
{title && <h2 style={{
margin: 0, fontSize: "var(--text-xl)", fontWeight: "var(--weight-semibold)",
letterSpacing: "-0.01em", fontFamily: "var(--font-display)",
}}>{title}</h2>}
{description && <p style={{
margin: "6px 0 0", fontSize: "var(--text-md)", color: "var(--text-2)",
}}>{description}</p>}
</div>
<IconButton name="x" size="sm" onClick={onClose} label="Close"/>
</div>
{children && <div style={{ padding: "0 var(--space-6) var(--space-6)" }}>{children}</div>}
{footer && (
<div style={{
padding: "var(--space-4) var(--space-6)",
borderTop: "1px solid var(--divider)",
display: "flex", justifyContent: "flex-end", gap: 8,
background: "var(--surface-2)",
}}>{footer}</div>
)}
</div>
</div>
);
};
// ─── Banner / Alert ──────────────────────────────────────────
const Banner = ({ tone = "info", title, children, action, onDismiss, icon, style }) => {
const palette = {
info: { bg: "var(--accent-soft)", fg: "var(--accent)", iconName: "info" },
success: { bg: "var(--success-soft)", fg: "var(--success)", iconName: "checkOnly" },
warn: { bg: "var(--warn-soft)", fg: "var(--warn)", iconName: "alert" },
danger: { bg: "var(--danger-soft)", fg: "var(--danger)", iconName: "alert" },
}[tone];
return (
<div style={{
display: "flex", gap: 12, padding: "12px 16px",
borderRadius: "var(--radius)",
background: palette.bg, color: "var(--text)",
border: `1px solid ${palette.fg}33`,
alignItems: "flex-start",
...style,
}}>
<span style={{ color: palette.fg, display: "flex", marginTop: 1 }}>
{icon ?? <Icon name={palette.iconName} size={16} stroke={2}/>}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
{title && <div style={{
fontSize: "var(--text-md)", fontWeight: "var(--weight-semibold)",
color: "var(--text)",
}}>{title}</div>}
{children && <div style={{
fontSize: "var(--text-sm)", color: "var(--text-2)", marginTop: title ? 2 : 0,
}}>{children}</div>}
</div>
{action}
{onDismiss && <IconButton name="x" size="sm" onClick={onDismiss} label="Dismiss"/>}
</div>
);
};
// ─── Divider ─────────────────────────────────────────────────
const Divider = ({ label, vertical, style }) => {
if (vertical) return <span style={{ width: 1, alignSelf: "stretch", background: "var(--border)", ...style }}/>;
if (!label) return <hr style={{ border: "none", borderTop: "1px solid var(--border)", margin: "var(--space-4) 0", ...style }}/>;
return (
<div style={{
display: "flex", alignItems: "center", gap: 12,
fontSize: "var(--text-xs)", color: "var(--text-3)",
letterSpacing: "0.08em", textTransform: "uppercase",
margin: "var(--space-4) 0", ...style,
}}>
<div style={{ flex: 1, height: 1, background: "var(--border)" }}/>
<span>{label}</span>
<div style={{ flex: 1, height: 1, background: "var(--border)" }}/>
</div>
);
};
// ─── FieldGroup — horizontal segmented control ───────────────
const FieldGroup = ({ options = [], value, onChange = noop, style }) => (
<div style={{
display: "inline-flex", padding: 3, gap: 2,
background: "var(--surface-alt)", border: "1px solid var(--border)",
borderRadius: "var(--radius)", ...style,
}}>
{options.map(o => {
const v = typeof o === "string" ? o : o.value;
const label = typeof o === "string" ? o : o.label;
const sel = v === value;
return (
<button key={v} onClick={() => onChange(v)} style={{
padding: "5px 12px", borderRadius: "calc(var(--radius) - 3px)",
fontFamily: "var(--font-sans)", fontSize: "var(--text-sm)", whiteSpace: "nowrap",
background: sel ? "var(--surface)" : "transparent",
color: sel ? "var(--text)" : "var(--text-2)",
border: "none", cursor: "pointer",
boxShadow: sel ? "var(--shadow-sm)" : "none",
fontWeight: sel ? 500 : 400,
}}>{label}</button>
);
})}
</div>
);
// ─── KBD ─────────────────────────────────────────────────────
const KBD = ({ children, style }) => (
<kbd style={{
fontFamily: "var(--font-mono)", fontSize: "var(--text-xs)",
padding: "1px 6px", borderRadius: 4,
background: "var(--surface-alt)", color: "var(--text-2)",
border: "1px solid var(--border)",
...style,
}}>{children}</kbd>
);
// ─── Exports ─────────────────────────────────────────────────
Object.assign(window, {
Button, IconButton, Spinner,
Field, Input, Textarea, Select, FieldGroup,
Checkbox, Radio, Switch,
Card, CardHeader, Divider,
Badge, Tag, Avatar, AvatarStack,
Tabs, Table, Modal, Banner, KBD,
});

View File

@@ -0,0 +1,89 @@
// ============================================================
// vibn-ai-templates/icons.jsx
// ------------------------------------------------------------
// A tiny Tabler-style stroke-icon helper + a curated set of
// paths used by the components. All inherit `currentColor` so
// they re-tint to whatever the parent's CSS color is.
//
// Usage:
// <Icon name="search" />
// <Icon path={icons.bell} size={20} stroke={2} />
// ============================================================
const icons = {
// Navigation / surface
home: <><path d="m3 12 9-9 9 9"/><path d="M5 10v10h14V10"/></>,
inbox: <><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5 5h14l3 7v7H2v-7z"/></>,
search: <><circle cx="11" cy="11" r="7"/><path d="m20 20-3-3"/></>,
bell: <><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10 21a2 2 0 0 0 4 0"/></>,
settings:<><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8L4.2 7a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3 1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8 1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></>,
// Objects
people: <><circle cx="9" cy="8" r="4"/><path d="M3 21a6 6 0 0 1 12 0"/><circle cx="17" cy="6" r="3"/><path d="M21 17a4 4 0 0 0-6-3.5"/></>,
building: <><path d="M3 21h18M5 21V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16"/><path d="M9 7h2M13 7h2M9 11h2M13 11h2M9 15h2M13 15h2"/></>,
target: <><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1"/></>,
doc: <><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6"/></>,
check: <><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></>,
bar: <><path d="M3 3v18h18"/><rect x="7" y="11" width="3" height="7"/><rect x="13" y="7" width="3" height="11"/></>,
workflow: <><rect x="3" y="3" width="6" height="6" rx="1"/><rect x="15" y="15" width="6" height="6" rx="1"/><path d="M9 6h6a3 3 0 0 1 3 3v6"/></>,
// Actions
plus: <path d="M12 5v14M5 12h14"/>,
x: <path d="M6 6l12 12M18 6L6 18"/>,
more: <><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></>,
chevDown:<path d="m6 9 6 6 6-6"/>,
chevUp: <path d="m6 15 6-6 6 6"/>,
chevLeft:<path d="m15 6-6 6 6 6"/>,
chevRight:<path d="m9 6 6 6-6 6"/>,
arrow: <path d="M5 12h14M13 5l7 7-7 7"/>,
arrowUp: <path d="M12 19V5M5 12l7-7 7 7"/>,
arrowDown:<path d="M12 5v14M5 12l7 7 7-7"/>,
// Status
checkOnly: <path d="M5 12l5 5L20 7"/>,
alert: <><path d="M12 9v4M12 17v.01"/><path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.7 3.86a2 2 0 0 0-3.4 0z"/></>,
info: <><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></>,
eye: <><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></>,
eyeOff: <><path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 11 7 11 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.526 13.526 0 0 0 1 12s4 7 11 7a9.74 9.74 0 0 0 5.39-1.61"/><path d="M2 2l20 20"/></>,
star: <path d="m12 3 2.6 6.2 6.7.5-5.1 4.4 1.6 6.6L12 17.3 6.2 20.7l1.6-6.6L2.7 9.7l6.7-.5z"/>,
spark: <path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l3 3M15 15l3 3M6 18l3-3M15 9l3-3"/>,
bolt: <path d="m13 2-9 13h7l-1 7 9-13h-7z"/>,
shield: <path d="M12 2 4 5v7c0 5 3.5 9 8 10 4.5-1 8-5 8-10V5z"/>,
// Misc
briefcase: <><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M3 13h18"/></>,
link: <><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></>,
copy: <><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></>,
};
const Icon = ({ name, path, size = 16, stroke = 1.6, style, className }) => (
<svg
width={size} height={size} viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth={stroke}
strokeLinecap="round" strokeLinejoin="round"
style={{ display: "inline-block", verticalAlign: "middle", flexShrink: 0, ...style }}
className={className}
aria-hidden="true"
>
{path ?? icons[name]}
</svg>
);
// Tiny brand mark — a gradient triangle, the same as we've been
// using everywhere. Exported here so consumers don't redraw it.
const VibnMark = ({ size = 22 }) => {
const id = `vmk_${size}`;
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<defs>
<linearGradient id={id} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#6e6cff"/>
<stop offset="100%" stopColor="#b15bff"/>
</linearGradient>
</defs>
<path d="M3 20 L12 4 L21 20 Z" fill={`url(#${id})`}/>
</svg>
);
};
Object.assign(window, { Icon, icons, VibnMark });

View File

@@ -0,0 +1,399 @@
// ============================================================
// vibn-ai-templates/shells.jsx
// ------------------------------------------------------------
// Layout shells — both in-product navs (Sidebar / Rail /
// Topbar) and auth scaffolds (CenteredCard / SplitHero / Glass).
//
// These are containers. Wrap your page in any shell and the
// shell handles brand, search, nav, footer. Compose with the
// components from components.jsx.
// ============================================================
// ── SidebarShell ─────────────────────────────────────────────
// Props:
// brand: { name, mark? }
// sections: [{ title?, items: [{ id, label, icon, count, active }] }]
// user: { name, email, color? }
// children: main pane
const SidebarShell = ({ brand = { name: "Vibn" }, sections = [], user, search = "Search…", children, width = 248 }) => {
return (
<div className="vibn-app" style={{
width: "100%", height: "100%",
display: "grid", gridTemplateColumns: `${width}px 1fr`,
overflow: "hidden",
}}>
<aside style={{
background: "var(--surface-alt)",
borderRight: "1px solid var(--border)",
display: "flex", flexDirection: "column",
}}>
{/* Brand row */}
<div style={{
padding: "12px 14px", display: "flex", alignItems: "center", gap: 10,
borderBottom: "1px solid var(--border)",
}}>
{brand.mark || <VibnMark size={22}/>}
<div style={{ flex: 1, fontSize: "var(--text-md)", fontWeight: "var(--weight-semibold)" }}>
{brand.name}
</div>
<Icon name="chevDown" size={14} style={{ color: "var(--text-3)" }}/>
</div>
{/* Search */}
<div style={{ padding: 12 }}>
<Input
placeholder={search}
leadingIcon={<Icon name="search" size={14}/>}
trailingIcon={<KBD>K</KBD>}
style={{ padding: "6px 10px" }}
/>
</div>
{/* Nav */}
<nav style={{ padding: "4px 8px", flex: 1, overflowY: "auto" }}>
{sections.map((s, i) => (
<div key={s.title || `s${i}`}>
{s.title && <div style={{
fontSize: "var(--text-xs)", color: "var(--text-3)",
padding: "14px 10px 6px", textTransform: "uppercase",
letterSpacing: "0.04em", fontWeight: 500,
}}>{s.title}</div>}
{s.items.map(it => (
<div key={it.id || it.label} style={{
display: "flex", alignItems: "center", gap: 10,
padding: "6px 10px", borderRadius: "var(--radius-sm)",
fontSize: "var(--text-md)", cursor: "pointer", marginBottom: 1,
color: it.active ? "var(--text)" : "var(--text-2)",
fontWeight: it.active ? 500 : 400,
background: it.active ? "var(--surface)" : "transparent",
boxShadow: it.active ? "var(--shadow-sm)" : "none",
}}>
<span style={{ color: it.active ? "var(--accent)" : "var(--text-3)", display: "flex" }}>
{typeof it.icon === "string"
? <Icon name={it.icon} size={15}/>
: it.icon}
</span>
<span style={{ flex: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{it.label}</span>
{it.count != null && <span style={{
fontSize: "var(--text-xs)", color: "var(--text-3)",
fontVariantNumeric: "tabular-nums",
}}>{it.count}</span>}
</div>
))}
</div>
))}
</nav>
{/* User */}
{user && (
<div style={{
padding: 12, borderTop: "1px solid var(--border)",
display: "flex", alignItems: "center", gap: 10,
}}>
<Avatar name={user.name} color={user.color} size={26}/>
<div style={{ flex: 1, minWidth: 0, lineHeight: 1.2 }}>
<div style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>{user.name}</div>
{user.email && <div style={{ fontSize: "var(--text-xs)", color: "var(--text-3)" }}>{user.email}</div>}
</div>
<Icon name="chevDown" size={14} style={{ color: "var(--text-3)" }}/>
</div>
)}
</aside>
<main style={{ display: "flex", flexDirection: "column", overflow: "hidden", background: "var(--bg)" }}>
{children}
</main>
</div>
);
};
// ── TopbarShell ──────────────────────────────────────────────
// Dark top with breadcrumb + ⌘K + avatar; tabs strip below.
const TopbarShell = ({ brand = { name: "Vibn" }, breadcrumb, tabs = [], activeTab,
onTabChange = () => {}, user, children, search = "Find or jump to anything…" }) => {
return (
<div className="vibn-app" style={{
width: "100%", height: "100%", display: "flex", flexDirection: "column",
overflow: "hidden",
}}>
<header style={{
background: "var(--surface-alt)", color: "var(--text)",
borderBottom: "1px solid var(--border)",
}}>
<div style={{
display: "flex", alignItems: "center", gap: 14, padding: "12px 24px",
}}>
<div style={{ display: "flex", alignItems: "center", gap: 8, fontWeight: "var(--weight-semibold)", fontSize: "var(--text-lg)" }}>
{brand.mark || <VibnMark size={20}/>}
{brand.name}
</div>
{breadcrumb && (
<>
<span style={{ color: "var(--text-3)" }}>/</span>
{breadcrumb.map((b, i) => (
<React.Fragment key={i}>
{i > 0 && <span style={{ color: "var(--text-3)" }}>/</span>}
<span style={{ fontSize: "var(--text-md)", display: "flex", alignItems: "center", gap: 8 }}>
{b.avatar && <Avatar name={b.avatar} size={18}/>}
<span>{b.label}</span>
{b.badge && <Badge tone="neutral">{b.badge}</Badge>}
</span>
</React.Fragment>
))}
</>
)}
<div style={{ flex: 1 }}/>
<div style={{ minWidth: 280 }}>
<Input placeholder={search}
leadingIcon={<Icon name="search" size={13}/>}
trailingIcon={<KBD>K</KBD>}
style={{ padding: "6px 12px" }} />
</div>
<Button variant="secondary" size="sm">Feedback</Button>
<IconButton name="bell" size="md"/>
{user && <Avatar name={user.name} color={user.color} size={28}/>}
</div>
{tabs.length > 0 && (
<div style={{ padding: "0 16px" }}>
<Tabs items={tabs} active={activeTab} onChange={onTabChange}/>
</div>
)}
</header>
<main style={{ flex: 1, overflow: "hidden", background: "var(--bg)" }}>{children}</main>
</div>
);
};
// ── RailShell ────────────────────────────────────────────────
// Icon rail + secondary panel + content.
const RailShell = ({ brand, items = [], activeRail, onRailChange = () => {},
secondary, secondaryTitle, user, children }) => {
return (
<div className="vibn-app" style={{
width: "100%", height: "100%",
display: "grid", gridTemplateColumns: "64px 240px 1fr", overflow: "hidden",
}}>
{/* Rail */}
<div style={{
background: "var(--surface-alt)", borderRight: "1px solid var(--border)",
padding: "10px 0", display: "flex", flexDirection: "column",
alignItems: "center", gap: 4,
}}>
<div style={{ padding: "0 10px 6px" }}>
{brand?.mark || <VibnMark size={22}/>}
</div>
<Divider />
{items.map(it => {
const sel = (it.id || it.label) === activeRail;
return (
<button key={it.id || it.label}
onClick={() => onRailChange(it.id || it.label)}
aria-label={it.label}
style={{
width: 40, height: 40, borderRadius: "var(--radius)",
background: sel ? "var(--accent)" : "transparent",
color: sel ? "var(--text-on-accent)" : "var(--text-2)",
border: "none", cursor: "pointer", position: "relative",
}}>
{typeof it.icon === "string" ? <Icon name={it.icon} size={18} stroke={2}/> : it.icon}
{it.badge && <span style={{
position: "absolute", top: 2, right: 2, minWidth: 16, height: 16,
padding: "0 4px", borderRadius: 8,
background: "var(--danger)", color: "#fff",
fontSize: 10, fontWeight: 600,
display: "flex", alignItems: "center", justifyContent: "center",
border: "2px solid var(--surface-alt)",
}}>{it.badge}</span>}
</button>
);
})}
<div style={{ flex: 1 }}/>
{user && <Avatar name={user.name} color={user.color} size={30} ring={1}/>}
</div>
{/* Secondary */}
<div style={{
background: "var(--surface-2)", borderRight: "1px solid var(--border)",
display: "flex", flexDirection: "column", overflow: "hidden",
}}>
{secondaryTitle && (
<div style={{
padding: "16px 14px 10px", borderBottom: "1px solid var(--border)",
}}>
<div style={{
fontSize: "var(--text-lg)", fontWeight: "var(--weight-semibold)",
marginBottom: 10,
}}>{secondaryTitle}</div>
<Input
placeholder="Jump to…"
leadingIcon={<Icon name="search" size={13}/>}
trailingIcon={<KBD>K</KBD>}
style={{ padding: "6px 10px" }}
/>
</div>
)}
<div style={{ padding: 10, flex: 1, overflowY: "auto" }}>{secondary}</div>
</div>
<main style={{ overflow: "hidden", background: "var(--bg)" }}>{children}</main>
</div>
);
};
// ── AuthCenteredShell ────────────────────────────────────────
// A single centered Card on a soft background, with brand top
// and small footer links. Good for sign-in / sign-up.
const AuthCenteredShell = ({ brand = { name: "Vibn" }, footerLinks = ["Privacy", "Terms", "Security"], cardWidth = 420, children }) => (
<div className="vibn-app" style={{
width: "100%", height: "100%", display: "grid",
gridTemplateRows: "auto 1fr auto", overflow: "hidden",
}}>
<header style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "20px 28px",
}}>
<div style={{ display: "flex", alignItems: "center", gap: 8, fontWeight: 600 }}>
{brand.mark || <VibnMark size={20}/>}
{brand.name}
</div>
<div style={{ fontSize: "var(--text-sm)", color: "var(--text-2)", display: "flex", gap: 18 }}>
<span>Status</span><span>Docs</span><span style={{ color: "var(--text)", fontWeight: 500 }}>Sign in </span>
</div>
</header>
<main style={{ display: "flex", alignItems: "center", justifyContent: "center", padding: 24 }}>
<Card variant="raised" padding={32} style={{ width: cardWidth }}>{children}</Card>
</main>
<footer style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "16px 28px", fontSize: "var(--text-xs)", color: "var(--text-3)",
}}>
<span>© 2026 {brand.name}</span>
<div style={{ display: "flex", gap: 16 }}>{footerLinks.map(l => <span key={l}>{l}</span>)}</div>
</footer>
</div>
);
// ── AuthSplitShell ───────────────────────────────────────────
// Left storytelling panel, right form. Big SaaS / Vercel feel.
const AuthSplitShell = ({ brand = { name: "Vibn" }, hero = {}, children }) => (
<div className="vibn-app" style={{
width: "100%", height: "100%", display: "grid",
gridTemplateColumns: "1fr 1fr", overflow: "hidden",
}}>
<div style={{
padding: "32px 44px", borderRight: "1px solid var(--border)",
display: "flex", flexDirection: "column",
background: "var(--surface-alt)", position: "relative", overflow: "hidden",
}}>
{/* Decorative wash, picks up theme accent */}
<div style={{
position: "absolute", top: -140, left: -120, width: 540, height: 540,
borderRadius: "50%",
background: "radial-gradient(circle, color-mix(in srgb, var(--accent-2) 40%, transparent), transparent 60%)",
filter: "blur(60px)", pointerEvents: "none",
}}/>
<div style={{
position: "absolute", bottom: -180, right: -120, width: 480, height: 480,
borderRadius: "50%",
background: "radial-gradient(circle, color-mix(in srgb, var(--accent) 30%, transparent), transparent 60%)",
filter: "blur(60px)", pointerEvents: "none",
}}/>
<div style={{ position: "relative", display: "flex", alignItems: "center", gap: 10, fontWeight: 600 }}>
{brand.mark || <VibnMark size={22}/>}
{brand.name}
</div>
<div style={{ position: "relative", marginTop: "auto" }}>
{hero.badge && (
<Badge tone="accent" style={{ marginBottom: 22 }}>{hero.badge}</Badge>
)}
{hero.headline && <h2 style={{
fontFamily: "var(--font-display)", fontSize: "var(--text-3xl)",
lineHeight: 1.05, margin: 0, letterSpacing: "-0.02em",
fontWeight: 500, textWrap: "balance", maxWidth: 360,
}}>{hero.headline}</h2>}
{hero.sub && <p style={{
fontSize: "var(--text-md)", color: "var(--text-2)",
marginTop: 14, lineHeight: 1.5, maxWidth: 340,
}}>{hero.sub}</p>}
{hero.quote && (
<div style={{
position: "relative", marginTop: 28, padding: 18,
borderRadius: "var(--card-radius)", background: "var(--surface)",
border: "1px solid var(--border)",
}}>
<p style={{ fontSize: "var(--text-md)", margin: 0, lineHeight: 1.5, color: "var(--text)" }}>
"{hero.quote.body}"
</p>
<div style={{ marginTop: 12, display: "flex", alignItems: "center", gap: 10 }}>
<Avatar name={hero.quote.author} size={26}/>
<div style={{ fontSize: "var(--text-xs)" }}>
<div style={{ fontWeight: 500 }}>{hero.quote.author}</div>
<div style={{ color: "var(--text-3)" }}>{hero.quote.role}</div>
</div>
</div>
</div>
)}
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", padding: "32px 56px" }}>
<div style={{ display: "flex", justifyContent: "flex-end", fontSize: "var(--text-sm)", color: "var(--text-2)" }}>
Need help? <span style={{ color: "var(--text)", fontWeight: 500, marginLeft: 4 }}>support</span>
</div>
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}>
<div style={{ width: 380 }}>{children}</div>
</div>
<div style={{ display: "flex", gap: 18, fontSize: "var(--text-xs)", color: "var(--text-3)", justifyContent: "flex-end" }}>
<span>Privacy</span><span>Terms</span><span>Security</span><span>v4.2.1</span>
</div>
</div>
</div>
);
// ── AuthGlassShell ───────────────────────────────────────────
// Aurora background + frosted card. Marketing-leaning.
const AuthGlassShell = ({ brand = { name: "Vibn" }, eyebrow, cardWidth = 460, children }) => (
<div className="vibn-app" style={{
width: "100%", height: "100%", position: "relative", overflow: "hidden",
}}>
{/* Top bar (a thin frosted pill — works in any theme thanks to surface vars) */}
<header style={{
position: "absolute", top: 22, left: "50%", transform: "translateX(-50%)",
zIndex: 10, width: "max-content",
display: "flex", alignItems: "center", gap: 4,
padding: "8px 8px 8px 18px", borderRadius: "var(--radius-pill)",
background: "var(--surface)",
border: "1px solid var(--border)",
backdropFilter: "blur(var(--surface-blur))",
WebkitBackdropFilter: "blur(var(--surface-blur))",
boxShadow: "var(--shadow-lg)",
}}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginRight: 16, fontWeight: 600 }}>
{brand.mark || <VibnMark size={18}/>}
{brand.name}
</div>
{["Product", "Pricing", "Docs"].map(l => (
<Button key={l} variant="ghost" size="sm">{l}</Button>
))}
<Divider vertical style={{ margin: "0 6px" }}/>
<Button variant="ghost" size="sm">Sign in</Button>
<Button size="sm">Get started </Button>
</header>
<main style={{
position: "relative", height: "100%",
display: "flex", alignItems: "center", justifyContent: "center", padding: 24,
}}>
<Card variant="floating" padding={36} style={{ width: cardWidth, borderRadius: "var(--radius-xl)" }}>
{eyebrow && <Badge tone="accent" style={{ marginBottom: 16 }}>{eyebrow}</Badge>}
{children}
</Card>
</main>
</div>
);
// ─── Exports ─────────────────────────────────────────────────
Object.assign(window, {
SidebarShell, TopbarShell, RailShell,
AuthCenteredShell, AuthSplitShell, AuthGlassShell,
});

View File

@@ -0,0 +1,325 @@
/* ============================================================
vibn-ai-templates · tokens.css
------------------------------------------------------------
The whole library is themed through CSS custom properties.
The :root block holds the DEFAULT theme (minimal). Each
.theme-* class below overrides a subset to flip aesthetics.
------------------------------------------------------------
To use:
<html class="theme-glass"> → glass theme app-wide
<div class="theme-editorial">…</div> → scope to one block
============================================================ */
:root {
/* ── Typography ─────────────────────────────────────────── */
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--font-display: var(--font-sans); /* themes may override */
--font-mono: ui-monospace, 'JetBrains Mono', 'SF Mono', Menlo, monospace;
--text-xs: 11px;
--text-sm: 12px;
--text-md: 13px;
--text-lg: 16px;
--text-xl: 20px;
--text-2xl: 28px;
--text-3xl: 38px;
--weight-regular: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-bold: 700;
/* ── Spacing (4 px base) ────────────────────────────────── */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
/* ── Radii ──────────────────────────────────────────────── */
--radius-xs: 3px;
--radius-sm: 5px;
--radius: 8px;
--radius-lg: 14px;
--radius-xl: 22px;
--radius-pill: 999px;
--button-radius: var(--radius);
--field-radius: 7px;
--card-radius: 12px;
--modal-radius: 16px;
/* ── Colors · MINIMAL (default light theme) ────────────── */
--bg: #f5f5f2;
--surface: #ffffff;
--surface-2: #fafaf8;
--surface-alt: #f1f0eb; /* sidebar / muted regions */
--border: #e8e8e3;
--border-strong:#d8d8d2;
--divider: #ededea;
--text: #111111;
--text-2: #5a5a5e;
--text-3: #8a8a90;
--text-on-accent: #ffffff;
--accent: #5e5cff;
--accent-2: #b15bff;
--accent-soft: #eeedff;
--accent-ring: rgba(94,92,255,0.22);
--success: #16a34a;
--success-soft: #dcfce7;
--warn: #d97706;
--warn-soft: #fef3c7;
--danger: #dc2626;
--danger-soft: #fee2e2;
/* ── Surfaces & effects ────────────────────────────────── */
--surface-blur: 0px; /* glass theme overrides */
--backdrop: transparent; /* glass theme overrides */
--grain: none; /* maximalist themes can use */
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
--shadow: 0 4px 12px -4px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04);
--shadow-lg: 0 24px 48px -20px rgba(0,0,0,0.16), 0 2px 8px rgba(0,0,0,0.05);
--shadow-modal: 0 40px 80px -20px rgba(0,0,0,0.28), 0 2px 8px rgba(0,0,0,0.08);
--shadow-focus: 0 0 0 3px var(--accent-ring);
/* ── Buttons ───────────────────────────────────────────── */
--button-bg: #111111;
--button-fg: #ffffff;
--button-border: #111111;
--button-hover: #2a2a2a;
--button-press: #000000;
--button-secondary-bg: #ffffff;
--button-secondary-fg: #111111;
--button-secondary-border: var(--border);
--button-secondary-hover: #f6f5f0;
--button-ghost-fg: var(--text);
--button-ghost-hover: #00000008;
/* ── Inputs ────────────────────────────────────────────── */
--field-bg: #ffffff;
--field-border: var(--border);
--field-text: var(--text);
--field-placeholder: var(--text-3);
--field-focus-ring: var(--shadow-focus);
/* ── Animation ─────────────────────────────────────────── */
--duration-fast: 120ms;
--duration: 180ms;
--duration-slow: 260ms;
--ease: cubic-bezier(0.2, 0.7, 0.3, 1);
}
/* ============================================================
THEME: minimal (default, same as :root)
The class exists so consumers can name-toggle.
============================================================ */
.theme-minimal {}
/* ============================================================
THEME: dark — Vercel / Stripe / Linear web school
============================================================ */
.theme-dark {
--bg: #0a0a0a;
--surface: #101015;
--surface-2: #16161d;
--surface-alt: #0a0a0d;
--border: #1f1f25;
--border-strong:#2a2a32;
--divider: #1a1a20;
--text: #fafafa;
--text-2: #a8a8b0;
--text-3: #6a6a72;
--text-on-accent: #0a0a0a;
--accent: #ffffff;
--accent-2: #b15bff;
--accent-soft: rgba(255,255,255,0.08);
--accent-ring: rgba(255,255,255,0.24);
--success: #4ade80;
--success-soft: rgba(74,222,128,0.14);
--warn: #f59e0b;
--warn-soft: rgba(245,158,11,0.14);
--danger: #ff4d5e;
--danger-soft: rgba(255,77,94,0.16);
--shadow-sm: 0 1px 2px rgba(0,0,0,0.5);
--shadow: 0 4px 12px rgba(0,0,0,0.4);
--shadow-lg: 0 24px 60px -20px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.04);
--shadow-modal: 0 40px 100px -20px rgba(0,0,0,0.8);
--button-bg: #ffffff;
--button-fg: #0a0a0a;
--button-border: #ffffff;
--button-hover: #e8e8e8;
--button-press: #d4d4d4;
--button-secondary-bg: #16161d;
--button-secondary-fg: #fafafa;
--button-secondary-border: var(--border);
--button-secondary-hover: #1f1f28;
--button-ghost-fg: var(--text);
--button-ghost-hover: rgba(255,255,255,0.05);
--field-bg: #16161d;
--field-border: var(--border);
--field-placeholder: var(--text-3);
}
/* ============================================================
THEME: glass — vibrant aurora bg + frosted surfaces
============================================================ */
.theme-glass {
--bg: #08081a;
--surface: rgba(255,255,255,0.06);
--surface-2: rgba(255,255,255,0.10);
--surface-alt: rgba(255,255,255,0.04);
--border: rgba(255,255,255,0.14);
--border-strong:rgba(255,255,255,0.22);
--divider: rgba(255,255,255,0.08);
--text: #ffffff;
--text-2: rgba(255,255,255,0.70);
--text-3: rgba(255,255,255,0.50);
--text-on-accent: #08081a;
--accent: #ffffff;
--accent-2: #b15bff;
--accent-soft: rgba(255,255,255,0.12);
--accent-ring: rgba(122,120,255,0.40);
--success: #7aff66;
--success-soft: rgba(122,255,102,0.14);
--warn: #ffce5b;
--warn-soft: rgba(255,206,91,0.14);
--danger: #ff5b6b;
--danger-soft: rgba(255,91,107,0.14);
--button-radius: var(--radius-pill);
--field-radius: 10px;
--card-radius: 22px;
--modal-radius: 22px;
--surface-blur: 20px;
--backdrop: radial-gradient(60% 50% at 20% 20%, rgba(122,120,255,0.55), transparent 60%),
radial-gradient(50% 50% at 80% 30%, rgba(177,91,255,0.50), transparent 60%),
radial-gradient(70% 60% at 50% 100%, rgba(0,229,179,0.35), transparent 60%),
#08081a;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.4);
--shadow: 0 10px 40px -10px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.10);
--shadow-lg: 0 30px 80px -30px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.12);
--shadow-modal: 0 40px 100px -30px rgba(0,0,0,0.7), inset 0 1px 0 rgba(255,255,255,0.14);
--button-bg: #ffffff;
--button-fg: #08081a;
--button-border: transparent;
--button-hover: rgba(255,255,255,0.92);
--button-press: rgba(255,255,255,0.84);
--button-secondary-bg: rgba(255,255,255,0.08);
--button-secondary-fg: #ffffff;
--button-secondary-border: var(--border);
--button-secondary-hover: rgba(255,255,255,0.14);
--button-ghost-fg: #ffffff;
--button-ghost-hover: rgba(255,255,255,0.06);
--field-bg: rgba(255,255,255,0.06);
--field-border: var(--border);
--field-placeholder: var(--text-3);
}
/* ============================================================
THEME: editorial — warm paper, serif display, hairline rules
============================================================ */
.theme-editorial {
--font-display: 'DM Serif Display', 'Times New Roman', Times, serif;
--bg: #f3eee2;
--surface: #fbf8f0;
--surface-2: #f7f2e6;
--surface-alt: #ece6d6;
--border: #d8d0bc;
--border-strong:#b8a988;
--divider: #e2d9c4;
--text: #1c1a14;
--text-2: #5a5044;
--text-3: #8a7d6a;
--text-on-accent: #fbf8f0;
--accent: #1c1a14;
--accent-2: #b85c28; /* terracotta */
--accent-soft: #e8e1cd;
--accent-ring: rgba(28,26,20,0.18);
--success: #3f7a3a;
--success-soft: #dde9d4;
--warn: #a86b14;
--warn-soft: #f3e7c4;
--danger: #a32a1e;
--danger-soft: #f1d6cf;
--button-radius: 3px;
--field-radius: 3px;
--card-radius: 4px;
--modal-radius: 4px;
--shadow-sm: 0 1px 0 rgba(28,26,20,0.06);
--shadow: 0 1px 0 rgba(28,26,20,0.06), 0 6px 24px -12px rgba(28,26,20,0.12);
--shadow-lg: 0 14px 36px -16px rgba(28,26,20,0.18), 0 1px 0 rgba(28,26,20,0.06);
--shadow-modal: 0 30px 60px -20px rgba(28,26,20,0.28);
--button-bg: #1c1a14;
--button-fg: #fbf8f0;
--button-border: #1c1a14;
--button-hover: #2f2a20;
--button-press: #000000;
--button-secondary-bg: transparent;
--button-secondary-fg: #1c1a14;
--button-secondary-border: #1c1a14; /* thick rule */
--button-secondary-hover: rgba(28,26,20,0.06);
--button-ghost-fg: var(--text);
--button-ghost-hover: rgba(28,26,20,0.06);
--field-bg: #fbf8f0;
--field-border: #1c1a14; /* hairline rule */
--field-placeholder: var(--text-3);
}
/* ============================================================
Body backdrop helper — paint --backdrop on the page root.
Glass theme uses this to show the aurora wash.
============================================================ */
.vibn-app {
font-family: var(--font-sans);
color: var(--text);
background: var(--bg);
min-height: 100%;
position: relative;
}
.vibn-app::before {
content: "";
position: absolute; inset: 0;
background: var(--backdrop);
z-index: 0; pointer-events: none;
}
.vibn-app > * { position: relative; z-index: 1; }

View File

@@ -0,0 +1,5 @@
node_modules
dist
.DS_Store
.env*.local
*.log

View File

@@ -0,0 +1,123 @@
# Vibn — Marketing Site
Production Vite + React 18 + Tailwind CSS implementation of the Vibn marketing
homepage and beta-invite signup page.
## Stack
- **Vite 6** — dev server, HMR, multi-page build
- **React 18** — function components + hooks, no router (two static entries)
- **Tailwind CSS 3** — utility classes + a small `@layer components` block for
things that don't compress cleanly to utilities (gradients, pseudo-elements,
custom shadows)
- **No CSS-in-JS, no UI library** — design tokens live as CSS custom properties
so a tweak panel or theme switcher can runtime-swap the accent palette
- **Geist + Geist Mono** — loaded from Google Fonts in each entry HTML
## Getting started
```bash
npm install
npm run dev # http://localhost:5173 (homepage)
# http://localhost:5173/beta.html (signup)
npm run build # production build → dist/
npm run preview # serve the built bundle
```
## Project layout
```
vibn-app/
├── index.html ← homepage entry
├── beta.html ← beta-signup entry
├── vite.config.js ← multi-page input config
├── tailwind.config.js ← design tokens + named animations
├── postcss.config.js
├── public/
│ └── logo-black.png ← favicon (V_ mark on black)
└── src/
├── main.jsx ← mounts <App />
├── beta-main.jsx ← mounts <BetaApp />
├── styles.css ← Tailwind + tokens + keyframes + .btn / .card / .logo-mark
├── App.jsx ← homepage composition
├── BetaApp.jsx ← signup-page composition
├── lib/
│ └── primitives.jsx ← Logo, LogoMark, Arrow, Eyebrow, Glow, TrustStrip
└── components/
├── Nav.jsx
├── Hero.jsx ← Reddit-quote / promise variants + prompt input
├── Wall.jsx ← faux chat-window "homework wall" scene
├── CrossedOut.jsx ← animated strike-through term wall
├── Journey.jsx ← 4-step path + "where others stop" marker
├── Audience.jsx ← 3 audience cards w/ Reddit-style quotes
├── Closing.jsx
├── Footer.jsx
├── LaunchModal.jsx ← hero prompt-submit modal
└── beta/
├── BetaForm.jsx ← 5-step form
├── Confirmed.jsx ← submitted state w/ queue card + referral
└── Benefits.jsx ← "what you get inside" trio
```
## Design tokens
All colors are exposed as CSS custom properties (see `src/styles.css`,
`:root` block) and aliased in `tailwind.config.js → theme.extend.colors`:
| Tailwind class | CSS var | Default |
|---|---|---|
| `bg-bg` | `--c-bg` | `oklch(0.155 0.008 60)` |
| `bg-bg-1` | `--c-bg-1` | `oklch(0.185 0.009 60)` |
| `text-fg` | `--c-fg` | `oklch(0.97 0.005 80)` |
| `text-fg-dim` | `--c-fg-dim` | `oklch(0.78 0.006 80)` |
| `text-fg-mute` | `--c-fg-mute` | `oklch(0.58 0.006 80)` |
| `text-fg-faint` | `--c-fg-faint` | `oklch(0.42 0.006 80)` |
| `text-accent` | `--c-accent` | `oklch(0.74 0.175 35)` (coral) |
| `bg-accent` | `--c-accent` | (same) |
| `border-hairline` | `--c-hairline` | `oklch(0.32 0.010 60 / 0.55)` |
| `text-ok` | `--c-ok` | `oklch(0.78 0.16 155)` |
To re-theme at runtime, set the variables on `:root` from JS:
```js
document.documentElement.style.setProperty("--c-accent", "#9ee649");
document.documentElement.style.setProperty("--c-accent-glow", "#9ee64959");
document.documentElement.style.setProperty("--c-accent-fg", "#0f1408");
```
## Type scale
Fluid `clamp()` sizes are used inline:
- Hero headline: `clamp(44px, 7.4vw, 104px)`
- Section H2: `clamp(36px, 4.8vw, 64px)`
- Body / sub: `clamp(1617px, 1.62.2vw, 1928px)`
Geist is the body face; Geist Mono is reserved for tags, eyebrows, code, and
"trust strip" details.
## Animations
Keyframes are defined once in `styles.css`; some are also registered as named
Tailwind utilities (`animate-caret-blink`, `animate-pulse-ok`, etc.) for
ergonomics. Anything not in the config uses an inline `style={{ animation: ... }}`
with the keyframe name.
## Notes & next steps
- **No router.** Homepage links to `/beta.html` directly. Drop in
`react-router-dom` if you need client-side navigation between more pages.
- **Form submission is a stub.** `BetaForm` just waits 700 ms and toggles to
the confirmed state. Wire to your real endpoint (e.g. `fetch("/api/invite", …)`).
- **Queue position is deterministic on email.** Replace with the real one from
your backend.
- **The hero "Live from minute one" pill is currently not rendered** in this
port (the marketing prototype defaults it off). Re-add by uncommenting the
pill in `Hero.jsx` if you want it back.
- **The Tweaks panel from the prototype is not ported.** Tweaks are a design-time
tool; runtime theming is enough via the CSS-var token system.
## Browser support
Uses `oklch()`, `text-wrap: balance`, and `backdrop-filter`. Safari 15.4+,
Chrome 111+, Firefox 113+.

View File

@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/logo-black.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<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"
/>
<title>Vibn — Request an invite</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/beta-main.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/logo-black.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<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"
/>
<title>Vibn — Keep vibing. All the way to launch.</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,23 @@
{
"name": "vibn-app",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"vite": "^6.0.7"
},
"packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8"
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -0,0 +1,40 @@
import { useEffect, useState } from "react";
import Nav from "./components/Nav.jsx";
import Hero from "./components/Hero.jsx";
import Wall from "./components/Wall.jsx";
import CrossedOut from "./components/CrossedOut.jsx";
import Journey from "./components/Journey.jsx";
import Audience from "./components/Audience.jsx";
import Closing from "./components/Closing.jsx";
import Footer from "./components/Footer.jsx";
import LaunchModal from "./components/LaunchModal.jsx";
export default function App() {
const [scrolled, setScrolled] = useState(false);
const [launchPrompt, setLaunchPrompt] = useState(null);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 8);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
<>
<Nav scrolled={scrolled} />
<main>
<Hero onStart={(p) => setLaunchPrompt(p || "Build me a tool for my business.")} />
<Wall />
<CrossedOut />
<Journey />
<Audience />
<Closing />
</main>
<Footer />
{launchPrompt !== null && (
<LaunchModal prompt={launchPrompt} onClose={() => setLaunchPrompt(null)} />
)}
</>
);
}

View File

@@ -0,0 +1,91 @@
import { useEffect, useMemo, useState } from "react";
import { Logo, Arrow, Eyebrow, Glow } from "./lib/primitives.jsx";
import BetaForm from "./components/beta/BetaForm.jsx";
import Confirmed from "./components/beta/Confirmed.jsx";
import Benefits from "./components/beta/Benefits.jsx";
export default function BetaApp() {
const [scrolled, setScrolled] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState({
email: "", name: "", build: "", role: "smb", source: "",
});
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 8);
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
// Stable queue position — deterministic on email so the number doesn't jump
const queuePos = useMemo(() => {
let h = 7;
for (const c of form.email) h = (h * 31 + c.charCodeAt(0)) >>> 0;
return 2100 + (h % 900);
}, [form.email]);
const handleSubmit = () => {
setSubmitting(true);
setTimeout(() => {
setSubmitting(false);
setSubmitted(true);
window.scrollTo({ top: 0, behavior: "smooth" });
}, 700);
};
return (
<>
<nav className={`sticky top-0 z-50 backdrop-blur-md bg-[oklch(0.155_0.008_60/0.55)] transition-colors ${scrolled ? "border-b border-hairline" : "border-b border-transparent"}`}>
<div className="wrap flex items-center justify-between h-16">
<Logo href="/" />
<a href="/" className="text-fg-mute text-[14px] inline-flex items-center gap-1.5 hover:text-fg">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
<path d="M13 8H3M7 4 3 8l4 4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
Back to home
</a>
</div>
</nav>
<main className="relative overflow-hidden py-[clamp(60px,9vh,100px)]">
<Glow color="oklch(0.74 0.175 35 / 0.30)" size={1000}
style={{ top: -280, left: "50%", transform: "translateX(-50%)" }} />
<Glow color="oklch(0.45 0.10 35 / 0.20)" size={550} style={{ top: "30%", left: -180 }} />
<Glow color="oklch(0.45 0.10 35 / 0.15)" size={500} style={{ top: "20%", right: -150 }} />
<div className="wrap relative max-w-[760px]">
{submitted ? (
<Confirmed form={form} queuePos={queuePos} />
) : (
<>
<header className="text-center mb-14">
<Eyebrow>Closed beta · invite-only</Eyebrow>
<h1 className="mt-[18px] font-medium tracking-[-0.03em] leading-none text-[clamp(40px,6.4vw,80px)] text-balance">
Be one of the first to{" "}
<em className="not-italic text-accent" style={{ textShadow: "0 0 40px var(--c-accent-glow)" }}>
vibe with Vibn
</em>.
</h1>
<p className="mt-6 text-[clamp(16px,1.6vw,19px)] text-fg-dim max-w-[540px] mx-auto text-balance">
We're letting in <b className="text-fg font-medium">50 new builders a week</b>.
Tell us what you want to build the most exciting ideas get the invite first.
</p>
</header>
<BetaForm form={form} setForm={setForm} submitting={submitting} onSubmit={handleSubmit} />
</>
)}
<Benefits />
</div>
</main>
<footer className="py-6 border-t border-hairline" style={{ background: "oklch(0.14 0.008 60)" }}>
<div className="wrap flex justify-between items-center gap-4 flex-wrap font-mono text-[11px] text-fg-faint tracking-[0.03em]">
<span>🇨🇦 Built in Canada · Your data stays safe · No credit card to start</span>
<span>© 2026 Vibn Inc.</span>
</div>
</footer>
</>
);
}

View File

@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import BetaApp from "./BetaApp.jsx";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<BetaApp />
</React.StrictMode>
);

View File

@@ -0,0 +1,82 @@
import { Eyebrow } from "../lib/primitives.jsx";
const AUDIENCE = [
{
label: "Small business owners", icon: "shop",
quote: "I'm paying $312/month for software that does 60% of what I need and zero of the rest.",
source: "u/coffeeshop_owner · r/smallbusiness",
answer: "Build the tool that actually fits your shop — exactly your workflow, no monthly fee bleed.",
},
{
label: "Freelancers building for clients", icon: "spark",
quote: "My client wants a quote tool. I can mock the frontend in a day. The backend? Two weeks I don't have.",
source: "u/agency_of_one · r/freelance",
answer: "Deliver the whole thing — login, data, hosting — in the same chat where you built the screens.",
},
{
label: "Anyone with an idea", icon: "spark2",
quote: "I built the homepage in an afternoon. Then the AI told me to 'just deploy it' and I cried.",
source: "u/first_time_builder · r/sideproject",
answer: "No deploys. No GitHub. No fear. The thing you described is online, with logins, ready for users.",
},
];
export default function Audience() {
return (
<section className="relative py-[clamp(80px,11vh,140px)]">
<div className="wrap">
<div className="text-center max-w-[820px] mx-auto mb-14">
<Eyebrow>Who Vibn is for</Eyebrow>
<h2 className="mt-[18px] font-medium text-[clamp(36px,4.8vw,64px)] tracking-[-0.025em] leading-[1.02] text-balance">
People who have an idea not a stack.
</h2>
<p className="mt-5 text-fg-mute text-[17px]">If you've ever felt this, Vibn was built for you.</p>
</div>
<div className="grid gap-[18px] grid-cols-1 lg:grid-cols-3">
{AUDIENCE.map((a) => <ACard key={a.label} a={a} />)}
</div>
</div>
</section>
);
}
function ACard({ a }) {
return (
<div className="relative flex flex-col min-h-[380px] p-7 pb-[26px] rounded-[18px] border border-hairline overflow-hidden"
style={{ background: "linear-gradient(180deg, oklch(0.20 0.009 60 / 0.55), oklch(0.17 0.008 60 / 0.55))" }}>
<span aria-hidden="true" className="absolute top-0 left-6 right-6 h-px opacity-60"
style={{ background: "linear-gradient(90deg, transparent, var(--c-accent), transparent)" }} />
<div className="w-10 h-10 rounded-[10px] grid place-items-center border border-hairline mb-[18px] text-accent"
style={{ background: "oklch(0.22 0.011 60)" }}>
<Icon name={a.icon} />
</div>
<div className="text-[19px] font-medium tracking-[-0.015em] text-fg">{a.label}</div>
<div className="mt-[18px] py-4 px-[18px] italic text-fg-dim text-[14.5px] leading-[1.5] rounded-[4px_10px_10px_4px] border-l-2"
style={{ background: "oklch(0.16 0.008 60 / 0.55)", borderLeftColor: "var(--c-accent)" }}>
"{a.quote}"
<div className="mt-2 not-italic font-mono text-[11px] text-fg-faint tracking-[0.02em]"> {a.source}</div>
</div>
<div className="mt-auto pt-[22px] flex gap-2.5 items-start text-[15px] text-fg leading-[1.5]">
<span className="font-mono text-[10px] uppercase tracking-[0.12em] text-accent px-[7px] py-[3px] rounded shrink-0 border mt-px"
style={{ background: "oklch(0.74 0.175 35 / 0.12)", borderColor: "oklch(0.74 0.175 35 / 0.4)" }}>
Vibn
</span>
<span>{a.answer}</span>
</div>
</div>
);
}
function Icon({ name }) {
const p = {
width: 20, height: 20, viewBox: "0 0 20 20", fill: "none",
stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round",
};
if (name === "shop") return <svg {...p}><path d="M3.5 6.5h13l-1 9.5h-11l-1-9.5Z"/><path d="M7 6.5V5a3 3 0 0 1 6 0v1.5"/></svg>;
if (name === "spark") return <svg {...p}><path d="M10 3v4M10 13v4M3 10h4M13 10h4M5.3 5.3l2.8 2.8M11.9 11.9l2.8 2.8M14.7 5.3l-2.8 2.8M8.1 11.9l-2.8 2.8"/></svg>;
if (name === "spark2") return <svg {...p}><path d="M10 2.5v3M10 14.5v3M2.5 10h3M14.5 10h3"/><circle cx="10" cy="10" r="3"/></svg>;
return null;
}

View File

@@ -0,0 +1,34 @@
import { Arrow, Glow, TrustStrip } from "../lib/primitives.jsx";
export default function Closing() {
return (
<section className="relative overflow-hidden text-center py-[clamp(100px,14vh,180px)]">
<Glow color="oklch(0.74 0.175 35 / 0.35)" size={1000}
style={{ top: "20%", left: "50%", transform: "translateX(-50%)" }} />
<Glow color="oklch(0.45 0.10 35 / 0.20)" size={600}
style={{ bottom: -200, left: "50%", transform: "translateX(-50%)" }} />
<div className="wrap max-w-[900px] mx-auto relative">
<h2 className="font-medium tracking-[-0.03em] leading-[1.02] text-balance text-[clamp(40px,6vw,84px)]">
If you can <em className="not-italic bg-clip-text text-transparent"
style={{ backgroundImage: "linear-gradient(180deg, var(--c-accent), oklch(0.62 0.18 18))" }}>describe</em> it,
<br />you can <em className="not-italic bg-clip-text text-transparent"
style={{ backgroundImage: "linear-gradient(180deg, var(--c-accent), oklch(0.62 0.18 18))" }}>build</em> it.
</h2>
<p className="mt-7 text-fg-dim text-balance max-w-[640px] mx-auto text-[clamp(17px,1.6vw,21px)]">
And you can keep building it all the way to customers.
<br />No new tools. No homework. No going back to the wall.
</p>
<div className="mt-9 inline-flex flex-col items-center gap-3.5">
<div className="flex gap-3 items-center flex-wrap justify-center">
<a href="/beta.html" className="btn btn-primary h-14 px-7 text-base">
Request invite <Arrow />
</a>
<a href="#how" className="btn btn-ghost">See how it works</a>
</div>
<TrustStrip items={["No credit card", "No homework", "No new tools to learn"]} />
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,57 @@
import { Eyebrow } from "../lib/primitives.jsx";
const CROSSED_TERMS = [
"Databases", "Auth providers", "GitHub", "Hosting",
"API keys", "Environment variables", "Deployment", "Backend code",
"Servers", "DNS records", "SSL certificates", "CORS errors",
"Webhooks", "Build pipelines", "package.json", "npm install",
];
export default function CrossedOut() {
return (
<section className="relative py-[clamp(70px,10vh,130px)]">
<div className="wrap">
<div className="text-center max-w-[760px] mx-auto mb-14">
<Eyebrow>What you don't have to learn</Eyebrow>
<h2 className="mt-[18px] font-medium text-[clamp(36px,4.8vw,64px)] tracking-[-0.025em] leading-[1.02] text-balance">
All the stuff that made you give up last time.
</h2>
<p className="mt-[18px] text-fg-mute text-[17px]">Forget every word on this list.</p>
</div>
<div className="flex flex-wrap justify-center gap-x-3.5 gap-y-2.5 max-w-[880px] mx-auto">
{CROSSED_TERMS.map((term, i) => (
<Term key={term} term={term} delay={0.12 + i * 0.06} />
))}
</div>
<p className="mt-14 mx-auto max-w-[760px] text-center font-medium tracking-[-0.02em] leading-[1.18] text-balance text-[clamp(24px,3vw,36px)]">
Your AI handles <span className="text-accent">all of it</span>.
<span className="block w-12 h-px bg-hairline mx-auto my-7" />
You just keep building.
</p>
</div>
</section>
);
}
function Term({ term, delay }) {
return (
<span className="relative overflow-hidden px-3.5 py-2 rounded-lg border border-hairline font-mono tracking-[0.005em] text-[clamp(15px,1.7vw,22px)] text-fg-mute"
style={{ background: "oklch(0.20 0.009 60 / 0.45)" }}>
<span
className="inline-block opacity-100"
style={{ animation: `fade-half 0.4s ease ${delay}s forwards` }}
>{term}</span>
<span
aria-hidden="true"
className="absolute left-2 right-2 top-1/2 h-0.5 rounded-sm opacity-0"
style={{
background: "var(--c-accent)",
boxShadow: "0 0 12px var(--c-accent-glow)",
animation: `strike 0.6s cubic-bezier(.7,.1,.3,1) ${delay}s forwards`,
}}
/>
</span>
);
}

View File

@@ -0,0 +1,29 @@
import { Logo } from "../lib/primitives.jsx";
export default function Footer() {
return (
<footer className="relative pt-10 pb-8 border-t border-hairline" style={{ background: "oklch(0.14 0.008 60)" }}>
<div className="wrap">
<div className="flex items-start justify-between gap-8 flex-wrap">
<Logo />
<div className="flex flex-wrap items-center gap-5 font-mono text-[12px] text-fg-mute tracking-[0.03em]">
<span>🇨🇦 Built in Canada</span>
<span className="text-fg-faint">·</span>
<span>Your data stays safe</span>
<span className="text-fg-faint">·</span>
<span>No credit card to start</span>
</div>
</div>
<div className="mt-6 pt-5 border-t border-hairline flex justify-between items-center gap-4 flex-wrap font-mono text-[11px] text-fg-faint tracking-[0.04em]">
<span>© 2026 Vibn Inc. · Made for makers, not engineers.</span>
<div className="flex gap-[18px]">
<a href="#" className="hover:text-fg-dim">Privacy</a>
<a href="#" className="hover:text-fg-dim">Terms</a>
<a href="#" className="hover:text-fg-dim">Status</a>
<a href="#" className="hover:text-fg-dim">Changelog</a>
</div>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,210 @@
import { useEffect, useRef, useState } from "react";
import { Arrow, TrustStrip, Glow } from "../lib/primitives.jsx";
const HERO_PLACEHOLDERS = [
"A booking site for my dog grooming business…",
"An invoice tracker for my freelance clients…",
"A members-only recipe site for my supper club…",
"A custom CRM for our 3-person real estate team…",
"A tip calculator app for our restaurant staff…",
"A waitlist site for my new ceramics studio…",
];
const HERO_CHIPS = [
"📋 Client intake form",
"📅 Booking site",
"🧾 Invoice tracker",
"🛒 Online store",
"📰 Email newsletter",
];
export default function Hero({ onStart, variant = "promise" }) {
const [text, setText] = useState("");
const [phIdx, setPhIdx] = useState(0);
const [phChars, setPhChars] = useState(0);
const [deleting, setDeleting] = useState(false);
const taRef = useRef(null);
// Type-on placeholder while empty
useEffect(() => {
if (text.length > 0) return undefined;
const full = HERO_PLACEHOLDERS[phIdx];
const speed = deleting ? 18 : 38;
const t = setTimeout(() => {
if (!deleting) {
if (phChars < full.length) setPhChars(phChars + 1);
else setTimeout(() => setDeleting(true), 1700);
} else {
if (phChars > 0) setPhChars(phChars - 1);
else { setDeleting(false); setPhIdx((phIdx + 1) % HERO_PLACEHOLDERS.length); }
}
}, speed);
return () => clearTimeout(t);
}, [text, phIdx, phChars, deleting]);
const placeholder = HERO_PLACEHOLDERS[phIdx].slice(0, phChars);
const submit = () => {
const value = text || HERO_PLACEHOLDERS[phIdx];
onStart?.(value);
};
const useChip = (chip) => {
const clean = chip.replace(/^[^\w]+/, "").trim();
setText(`Build me ${clean.toLowerCase()} for my business.`);
taRef.current?.focus();
};
return (
<header className="relative overflow-hidden pt-[clamp(60px,9vh,120px)] pb-[clamp(60px,10vh,120px)]">
{/* Ambient glows */}
<Glow color="oklch(0.74 0.175 35 / 0.30)" size={900}
style={{ top: -200, left: "50%", transform: "translateX(-50%)" }} />
<Glow color="oklch(0.45 0.10 35 / 0.30)" size={600} style={{ top: "20%", left: -200 }} />
<Glow color="oklch(0.45 0.10 35 / 0.20)" size={500} style={{ top: "30%", right: -150 }} />
<div className="wrap relative flex flex-col items-center text-center gap-7">
{variant === "promise" ? (
<>
<h1 className="font-medium leading-[0.98] tracking-[-0.035em] text-[clamp(44px,7.4vw,104px)] text-balance">
Keep <span className="text-accent" style={{ textShadow: "0 0 30px var(--c-accent-glow)" }}>vibing</span>.
<br />All the way to launch.
</h1>
<div className="font-mono text-[12px] text-fg-faint tracking-[0.04em] inline-flex items-center gap-2 -mt-2">
<span className="w-6 h-px bg-hairline" />
idea live marketed customers
<span className="w-6 h-px bg-hairline" />
</div>
<p className="text-[clamp(20px,2.2vw,28px)] text-fg-dim tracking-[-0.01em] max-w-[720px] text-balance">
<b className="text-fg font-medium">"I built my product, now what?"</b> Vibn is the answer.
<br />Your AI handles the technical stuff, puts your idea online, and helps you find your first customers.
</p>
</>
) : (
<>
<h1 className="font-medium leading-[0.98] tracking-[-0.035em] text-[clamp(44px,7.4vw,104px)] text-balance">
<span className="text-accent" style={{ textShadow: "0 0 30px var(--c-accent-glow)" }}></span>I built my product,
<br />now what<span className="text-accent" style={{ textShadow: "0 0 30px var(--c-accent-glow)" }}>?</span>
</h1>
<div className="font-mono text-[12px] text-fg-faint tracking-[0.04em] inline-flex items-center gap-2 -mt-2">
<span className="w-6 h-px bg-hairline" />
posted 2 hours ago · r/SideProject
<span className="w-6 h-px bg-hairline" />
</div>
<p className="text-[clamp(20px,2.2vw,28px)] text-fg-dim tracking-[-0.01em] max-w-[720px] text-balance">
<b className="text-fg font-medium">Keep vibing.</b> All the way to launch.
<br />Your AI handles the technical stuff, puts your idea online, and helps you find your first customers.
</p>
</>
)}
{/* Prompt */}
<PromptInput
text={text} setText={setText}
placeholder={placeholder}
taRef={taRef}
onSubmit={submit}
/>
<div className="flex flex-wrap gap-2 justify-center mt-3 text-[13px]">
{HERO_CHIPS.map((c) => (
<button key={c} type="button"
onClick={() => useChip(c)}
className="px-3.5 py-[7px] rounded-full border border-hairline bg-[oklch(0.20_0.009_60/0.4)] text-fg-dim transition-all hover:border-hairline-2 hover:text-fg hover:-translate-y-px"
>
{c}
</button>
))}
</div>
<div className="flex gap-3 items-center mt-2.5 flex-wrap justify-center">
<button type="button" onClick={submit} className="btn btn-primary">
Start building free <Arrow />
</button>
<a href="#how" className="btn btn-ghost">See how it works</a>
</div>
<TrustStrip items={["No credit card", "No homework", "No new tools to learn"]} />
</div>
</header>
);
}
function PromptInput({ text, setText, placeholder, taRef, onSubmit }) {
return (
<div className="w-full max-w-[720px] relative mt-3.5">
<div className="relative rounded-3xl p-px"
style={{
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))",
boxShadow: "0 30px 80px -20px oklch(0 0 0 / 0.6), 0 0 80px -20px var(--c-accent-glow)",
}}
>
<div className="rounded-[27px] px-[18px] pt-[18px] pb-3.5 backdrop-blur-xl"
style={{ background: "linear-gradient(180deg, oklch(0.19 0.009 60 / 0.92), oklch(0.17 0.008 60 / 0.92))" }}
>
<div className="relative">
<textarea
ref={taRef}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) onSubmit(); }}
className="w-full min-h-[96px] bg-transparent border-0 outline-none resize-none text-fg text-[17px] leading-[1.45] py-1.5 px-1 placeholder:text-fg-faint"
aria-label="Describe what you want to build"
placeholder=""
/>
{text.length === 0 && (
<div className="absolute top-[22px] left-[6px] right-[6px] pointer-events-none text-fg-faint text-[17px] leading-[1.45] text-left">
{placeholder}
<span className="inline-block w-2 h-[18px] align-[-3px] ml-0.5 animate-[blink_1s_steps(2)_infinite]"
style={{ background: "var(--c-accent)", boxShadow: "0 0 12px var(--c-accent-glow)" }} />
</div>
)}
</div>
<div className="flex items-center justify-between gap-3.5 mt-1.5 pt-3 border-t border-hairline">
<div className="hidden sm:flex gap-1.5 text-fg-mute">
<PromptTool icon="paperclip" label="Screenshot" />
<PromptTool icon="mic" label="Voice" />
<PromptTool icon="grid" label="Templates" />
</div>
<button
type="button" onClick={onSubmit}
className="inline-flex items-center gap-2 h-9 px-3.5 pr-3.5 rounded-full font-medium text-sm transition-transform hover:-translate-y-px"
style={{
background: "var(--c-accent)", color: "var(--c-accent-fg)",
boxShadow: "0 0 0 1px oklch(0.84 0.16 35 / 0.5) inset, 0 8px 28px -8px var(--c-accent-glow)",
}}
>
Start building <Arrow size={13} />
</button>
</div>
</div>
</div>
</div>
);
}
function PromptTool({ icon, label }) {
return (
<button type="button" title={label}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-full text-[12px] text-fg-mute border border-transparent transition-colors hover:border-hairline hover:text-fg-dim">
<PromptIcon name={icon} />
{label}
</button>
);
}
function PromptIcon({ name }) {
const props = {
width: 13, height: 13, viewBox: "0 0 16 16", fill: "none",
stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round",
};
if (name === "paperclip") return (
<svg {...props}><path d="M11.5 6.5 6.6 11.4a2 2 0 1 1-2.8-2.8l5.4-5.4a3.5 3.5 0 1 1 5 5L8.6 13.7"/></svg>
);
if (name === "mic") return (
<svg {...props}><rect x="6" y="2" width="4" height="8" rx="2"/><path d="M3.5 8a4.5 4.5 0 0 0 9 0M8 13v2"/></svg>
);
if (name === "grid") return (
<svg {...props}><rect x="2.5" y="2.5" width="4.5" height="4.5"/><rect x="9" y="2.5" width="4.5" height="4.5"/><rect x="2.5" y="9" width="4.5" height="4.5"/><rect x="9" y="9" width="4.5" height="4.5"/></svg>
);
return null;
}

View File

@@ -0,0 +1,165 @@
import { Eyebrow } from "../lib/primitives.jsx";
const JOURNEY_STEPS = [
{
num: "01", title: "You describe it.", sub: "The AI builds it.",
body: "Talk to it like you'd talk to a friend who codes. It builds the screens, the buttons, the logic — whatever your idea needs.",
demo: "describe",
},
{
num: "02", title: "It goes live.", sub: "The AI puts it online.",
body: "Logins, saving your stuff, hosting — handled. You get a live link from minute one. Share it. Show your friends. It just works.",
demo: "live",
},
{
num: "03", title: "It gets seen.", sub: "The AI markets it.",
body: "Posts, emails, social — written, scheduled, and shipped on autopilot. The tone matches your brand because you trained it talking to your AI.",
demo: "seen",
},
{
num: "04", title: "It gets customers.", sub: "Your first 100.",
body: "Through our Google partnership, Vibn helps the right people find your product when they're searching for what you built.",
demo: "customers",
},
];
export default function Journey() {
return (
<section id="how" className="relative py-[clamp(80px,11vh,140px)]">
<div className="wrap">
<div className="text-center max-w-[820px] mx-auto mb-16">
<Eyebrow>The journey</Eyebrow>
<h2 className="mt-[18px] font-medium text-[clamp(36px,4.8vw,64px)] tracking-[-0.025em] leading-[1.02] text-balance">
From idea to first 100 customers.
<br /><span className="text-accent">In one chat.</span>
</h2>
<p className="mt-5 text-fg-mute text-[17px] text-balance">
Other tools take you to step two and wave goodbye. Vibn keeps building with you.
</p>
</div>
<div className="relative grid gap-4 grid-cols-1 sm:grid-cols-2 xl:grid-cols-4">
{/* "Where everyone else stops" — only meaningful on the 4-col layout */}
<div className="hidden xl:flex absolute inset-y-0 flex-col items-center pointer-events-none z-[2]"
style={{ left: "calc(50% - 1px)", width: 16 }}>
<div className="flex-1 w-px" style={{
background: "repeating-linear-gradient(180deg, var(--c-accent) 0 6px, transparent 6px 12px)",
opacity: .7,
}}/>
<span className="font-mono text-[10px] uppercase tracking-[0.12em] text-accent bg-bg px-3 py-1.5 rounded-full border whitespace-nowrap"
style={{
borderColor: "oklch(0.74 0.175 35 / 0.5)",
boxShadow: "0 0 24px var(--c-accent-glow)",
transform: "translateY(-1px)",
}}>
Where every other tool stops
</span>
<div className="flex-1 w-px" style={{
background: "repeating-linear-gradient(180deg, var(--c-accent) 0 6px, transparent 6px 12px)",
opacity: .7,
}}/>
</div>
{JOURNEY_STEPS.map((s, i) => (
<StepCard key={s.num} step={s} stopped={i >= 2} />
))}
</div>
<p className="mt-12 text-center text-fg-mute text-[15px] text-balance">
<b className="text-fg font-medium">One tool. One chat.</b> From "wouldn't it be cool if…" to{" "}
<b className="text-fg font-medium">real customers paying you money.</b>
</p>
</div>
</section>
);
}
function StepCard({ step, stopped }) {
return (
<div className={[
"relative flex flex-col rounded-2xl border border-hairline overflow-hidden isolate min-h-[380px] pt-6 px-6",
stopped ? "opacity-[0.46]" : "",
].join(" ")}
style={{ background: "linear-gradient(180deg, oklch(0.20 0.009 60 / 0.55), oklch(0.17 0.008 60 / 0.55))" }}
>
{!stopped && (
<span className="absolute top-0 left-0 right-0 h-px opacity-70"
style={{ background: "linear-gradient(90deg, transparent, var(--c-accent) 50%, transparent)" }} />
)}
{stopped && (
<span aria-hidden="true" className="absolute inset-0 pointer-events-none"
style={{ background: "linear-gradient(180deg, transparent 40%, oklch(0.155 0.008 60 / 0.6))" }} />
)}
<div className="font-mono text-[11px] text-fg-faint tracking-[0.08em]">{step.num}</div>
<h3 className="mt-3 text-[22px] font-medium tracking-[-0.018em]">{step.title}</h3>
<div className={`mt-1 text-[15px] font-medium ${stopped ? "text-fg-mute" : "text-accent"}`}>{step.sub}</div>
<p className="mt-3 text-fg-dim text-[14px] leading-[1.55]">{step.body}</p>
<StepDemo demo={step.demo} />
</div>
);
}
function StepDemo({ demo }) {
const wrapStyle = "mt-auto -mx-6 px-[18px] py-4 border-t border-hairline font-mono text-[12px] leading-[1.55] text-fg-dim flex flex-col gap-[7px] min-h-[116px]";
const wrapBg = { background: "oklch(0.16 0.008 60 / 0.6)" };
if (demo === "describe") return (
<div className={wrapStyle} style={wrapBg}>
<DemoRow tag="YOU" tagKind="you">build a booking site for my dog grooming biz</DemoRow>
<DemoRow tag="VIBN" tagKind="ai">on it designing screens</DemoRow>
<DemoRow tag="VIBN" tagKind="ai" align="center"><span className="text-ok"> booking flow ready</span></DemoRow>
</div>
);
if (demo === "live") return (
<div className={wrapStyle} style={wrapBg}>
<DemoRow tag="VIBN" tagKind="ai" align="center">put it online</DemoRow>
<div className="h-1 rounded-full overflow-hidden relative" style={{ background: "oklch(0.25 0.01 60)" }}>
<span className="absolute inset-0 w-[64%]" style={{ background: "var(--c-accent)", boxShadow: "0 0 8px var(--c-accent-glow)" }} />
</div>
<div className="flex items-center gap-1.5 mt-0.5 text-fg-dim">
<i className="w-1.5 h-1.5 rounded-full" style={{ background: "var(--c-ok)", boxShadow: "0 0 6px oklch(0.78 0.16 155 / 0.6)" }} />
pawsandposh.vibn.app
</div>
<div><span className="text-ok"> logins · saving · live</span></div>
</div>
);
if (demo === "seen") return (
<div className={wrapStyle} style={wrapBg}>
<DemoRow tag="VIBN" tagKind="ai">draft a launch post for Instagram + email blast</DemoRow>
<div className="text-fg-faint"> scheduled for Tue 9:00 AM</div>
<div className="text-fg-faint"> scheduled for Thu 6:00 PM</div>
<div><span className="text-ok"> 3 channels on autopilot</span></div>
</div>
);
if (demo === "customers") return (
<div className={wrapStyle} style={wrapBg}>
<div className="flex items-center">
<span className="font-mono text-[22px] font-medium text-accent tracking-[-0.02em]">
+47<small className="text-fg-mute text-[11px] font-normal ml-1">this week</small>
</span>
</div>
<div className="flex items-center gap-2">
{["35", "260", "155", "80"].map((h) => (
<span key={h} className="w-4 h-4 rounded-full shrink-0" style={{ background: `oklch(0.55 0.14 ${h})` }} />
))}
<span className="text-fg-mute">found you via Google</span>
</div>
<div><span className="text-ok"> tracking toward 100</span></div>
</div>
);
return null;
}
function DemoRow({ tag, tagKind, align, children }) {
const styles = tagKind === "you"
? { color: "oklch(0.85 0.06 250)", background: "oklch(0.28 0.04 250)" }
: tagKind === "ai"
? { color: "var(--c-accent)", background: "oklch(0.35 0.10 35 / 0.4)" }
: { color: "var(--c-fg-faint)", background: "oklch(0.22 0.01 60)" };
return (
<div className={`flex gap-2 ${align === "center" ? "items-center" : "items-start"}`}>
<span className="font-mono text-[10px] px-1.5 py-px rounded shrink-0 tracking-[0.04em] mt-px" style={styles}>{tag}</span>
<span className="text-fg-dim">{children}</span>
</div>
);
}

View File

@@ -0,0 +1,90 @@
import { useEffect, useState } from "react";
const STEPS = [
"Drafting the screens",
"Setting up logins",
"Saving your stuff",
"Putting it online",
];
export default function LaunchModal({ prompt, onClose }) {
const [step, setStep] = useState(0);
useEffect(() => {
const onKey = (e) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
useEffect(() => {
if (step >= STEPS.length) return undefined;
const t = setTimeout(() => setStep(step + 1), 700);
return () => clearTimeout(t);
}, [step]);
return (
<div onClick={onClose}
className="fixed inset-0 z-[100] grid place-items-center p-6 backdrop-blur-md"
style={{ background: "oklch(0.10 0.005 60 / 0.7)", animation: "fadein .2s ease" }}>
<div onClick={(e) => e.stopPropagation()}
className="relative w-full max-w-[540px] rounded-[20px] p-7 pb-6 border border-hairline-2"
style={{
background: "linear-gradient(180deg, oklch(0.20 0.009 60), oklch(0.17 0.008 60))",
boxShadow: "0 30px 80px -20px oklch(0 0 0 / 0.6), 0 0 60px -20px var(--c-accent-glow)",
}}>
<button type="button" onClick={onClose}
className="absolute top-3.5 right-3.5 w-7 h-7 rounded-md text-fg-mute hover:text-fg hover:bg-[oklch(0.25_0.01_60)]"
aria-label="Close"></button>
<div className="flex items-center gap-2.5 text-accent font-mono text-[11px] uppercase tracking-[0.1em]">
<i className="w-1.5 h-1.5 rounded-full animate-pulse-ok"
style={{ background: "var(--c-accent)", boxShadow: "0 0 12px var(--c-accent-glow)" }} />
Vibn is on it
</div>
<h3 className="mt-3 text-[24px] font-medium tracking-[-0.018em] leading-[1.15]">Keep vibing we've got the rest.</h3>
<div className="mt-3.5 p-3.5 rounded-[10px] border border-hairline font-mono text-[13px] text-fg-dim leading-[1.5]"
style={{ background: "oklch(0.16 0.008 60)" }}>
"{prompt}"
</div>
<div className="mt-[18px] flex flex-col gap-2.5">
{STEPS.map((s, i) => (
<Step key={s} label={s} state={i < step ? "done" : i === step ? "active" : "pending"} />
))}
</div>
<div className="mt-[18px] text-center font-mono text-[11px] text-fg-faint tracking-[0.04em]">
No homework · No setup · No new tools to learn
</div>
</div>
</div>
);
}
function Step({ label, state }) {
return (
<div className={[
"flex items-center gap-3 px-3.5 py-[11px] rounded-[10px] border border-hairline text-[14px] transition-colors",
state === "done" ? "text-fg" : "text-fg-dim",
].join(" ")} style={{ background: "oklch(0.165 0.008 60)" }}>
{state === "done" ? (
<svg className="w-[18px] h-[18px] text-ok shrink-0" viewBox="0 0 20 20" fill="none">
<path d="M4 10.5 8 14.5 16 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
) : state === "active" ? (
<span className="w-3.5 h-3.5 rounded-full border-2 shrink-0"
style={{
borderColor: "oklch(0.30 0.01 60)",
borderTopColor: "var(--c-accent)",
animation: "spin .9s linear infinite",
}} />
) : (
<svg className="w-[18px] h-[18px] text-fg-faint shrink-0" viewBox="0 0 20 20" fill="none">
<circle cx="10" cy="10" r="6" stroke="currentColor" strokeWidth="1.5"/>
</svg>
)}
<span>{label}</span>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { Logo, Arrow } from "../lib/primitives.jsx";
export default function Nav({ scrolled }) {
return (
<nav
className={[
"sticky top-0 z-50 backdrop-blur-md transition-colors",
"bg-[oklch(0.155_0.008_60/0.55)]",
scrolled ? "border-b border-hairline" : "border-b border-transparent",
].join(" ")}
>
<div className="wrap flex items-center justify-between h-16">
<Logo href="/" />
<div className="hidden md:flex gap-7 text-fg-mute text-[14px]">
<a href="#how" className="hover:text-fg">How it works</a>
<a href="#" className="hover:text-fg">Templates</a>
<a href="#" className="hover:text-fg">Pricing</a>
<a href="#" className="hover:text-fg">Stories</a>
</div>
<div className="flex items-center gap-2.5">
<a href="#" className="btn btn-ghost h-9 px-4 text-sm hidden sm:inline-flex">Sign in</a>
<a href="/beta.html" className="btn btn-primary h-9 px-4 text-sm">
Request invite <Arrow size={12} />
</a>
</div>
</div>
</nav>
);
}

View File

@@ -0,0 +1,140 @@
import { Eyebrow } from "../lib/primitives.jsx";
export default function Wall() {
return (
<section id="the-wall" className="relative py-[clamp(60px,9vh,110px)]">
<div className="wrap">
<div className="text-center max-w-[760px] mx-auto mb-14">
<Eyebrow>The wall</Eyebrow>
<h2 className="mt-[18px] font-medium text-[clamp(36px,4.8vw,64px)] tracking-[-0.025em] leading-[1.02] text-balance">
Every other tool stops{" "}
<em className="not-italic text-accent" style={{ textShadow: "0 0 30px var(--c-accent-glow)" }}>
right here
</em>.
</h2>
<p className="mt-5 text-fg-mute text-[17px] text-balance">
You built it. It works on your laptop. Then the chat hands you a list.
</p>
</div>
{/* Faux app window */}
<div className="relative max-w-[880px] mx-auto rounded-2xl border border-hairline overflow-hidden backdrop-blur-md"
style={{ background: "oklch(0.165 0.008 60 / 0.85)", boxShadow: "0 30px 80px -20px oklch(0 0 0 / 0.6)" }}>
<WindowBar />
<div className="p-6 flex flex-col gap-4">
<Msg who="user" name="You · just now" body="okay it works!! how do i put this online so my customers can use it?" />
<Msg who="ai" name="Generic AI · just now" body={<HomeworkBody />} />
<Msg who="user" name="You · now" body={<Typing />} muted />
</div>
</div>
{/* Punchline */}
<div className="mt-14 text-center">
<div className="w-px h-14 mx-auto mb-7"
style={{ background: "linear-gradient(180deg, transparent, var(--c-hairline), transparent)" }} />
<p className="font-medium tracking-[-0.022em] leading-[1.2] text-balance text-fg-mute text-[clamp(28px,3.4vw,42px)]">
And just like that <em className="italic text-fg">the vibe is gone.</em>
</p>
</div>
</div>
</section>
);
}
function WindowBar() {
return (
<div className="flex items-center gap-3.5 px-3.5 py-[11px] border-b border-hairline font-mono text-[12px] text-fg-mute"
style={{ background: "oklch(0.20 0.009 60 / 0.85)" }}>
<div className="flex gap-[7px]">
<i className="w-[11px] h-[11px] rounded-full" style={{ background: "oklch(0.40 0.01 60)" }} />
<i className="w-[11px] h-[11px] rounded-full" style={{ background: "oklch(0.40 0.01 60)" }} />
<i className="w-[11px] h-[11px] rounded-full" style={{ background: "oklch(0.40 0.01 60)" }} />
</div>
<span className="ml-2 text-fg-faint tracking-[0.02em]">untitled-project · main</span>
<span className="ml-auto px-2 py-0.5 rounded text-fg-faint text-[11px]" style={{ background: "oklch(0.25 0.01 60)" }}>
generic ai coder · chat
</span>
</div>
);
}
function Msg({ who, name, body, muted }) {
return (
<div className="flex gap-3 items-start text-[14.5px] leading-[1.55]">
<div className={[
"w-[26px] h-[26px] rounded-[7px] grid place-items-center font-mono text-[11px] font-semibold shrink-0",
who === "user" ? "" : "",
].join(" ")}
style={who === "user"
? { background: "oklch(0.28 0.01 60)", color: "var(--c-fg-dim)" }
: { background: "oklch(0.30 0.02 250)", color: "oklch(0.85 0.06 250)" }
}>
{who === "user" ? "YOU" : "AI"}
</div>
<div className="flex-1 min-w-0">
<div className="font-mono text-[11px] text-fg-faint tracking-[0.04em] uppercase mb-0.5">{name}</div>
<div className={who === "user" && !muted ? "text-fg" : "text-fg-dim"}>{body}</div>
</div>
</div>
);
}
function HomeworkBody() {
const items = [
["Sign up for Supabase", "and create a project for your database."],
["Configure authentication", "with Supabase Auth or Clerk — pick one."],
["Create a GitHub repo", ", commit your code, and push it."],
["Deploy to Vercel", ": connect repo, configure framework preset."],
["Add environment variables", "for your API keys and DB url in the Vercel dashboard."],
["Set up DNS", "for your custom domain and verify nameservers with your registrar."],
["Configure SSL / TLS certificates", "for HTTPS (or use Vercel's automatic provisioning)."],
["Set up Stripe", "if you want to take payments, and configure webhooks."],
];
// Per-row fade values for "overload" feeling
const opacities = [1, 1, 1, 0.82, 0.65, 0.48, 0.34, 0.22];
const blurs = [0, 0, 0, 0, 0, 0.2, 0.4, 0.7];
return (
<>
<p className="text-fg-dim">Great job 🎉 Your app is running locally. To take it live, you'll need to set a few things up first:</p>
<ol className="list-none p-0 mt-3 flex flex-col gap-2">
{items.map(([title, rest], i) => (
<li key={i}
className="flex items-start gap-3 px-3.5 py-3 rounded-[10px] border border-hairline text-fg-dim text-[13.5px] transition-opacity"
style={{
background: "oklch(0.20 0.009 60)",
opacity: opacities[i],
filter: blurs[i] ? `blur(${blurs[i]}px)` : "none",
}}
>
<span className="font-mono text-[11px] text-fg-faint px-1.5 py-px rounded shrink-0"
style={{ background: "oklch(0.16 0.008 60)" }}>
{String(i + 1).padStart(2, "0")}
</span>
<span>
<b className="text-fg font-medium">{title}</b> {rest}
</span>
<span className="ml-auto font-mono text-[11px] text-fg-faint px-[7px] py-px border border-hairline rounded shrink-0">
external
</span>
</li>
))}
</ol>
<div className="-mt-2.5 pt-[30px] font-mono text-[11px] text-fg-faint text-center"
style={{ background: "linear-gradient(180deg, transparent, oklch(0.165 0.008 60 / 0.85))" }}>
23 more steps
</div>
</>
);
}
function Typing() {
return (
<span className="inline-flex gap-[3px] items-center py-1 text-fg-mute">
{[0, 1, 2].map((i) => (
<i key={i}
className="w-[5px] h-[5px] rounded-full bg-fg-mute"
style={{ animation: `bounce-dot 1.2s ${i * 0.15}s infinite ease-in-out` }} />
))}
</span>
);
}

View File

@@ -0,0 +1,37 @@
import { Eyebrow } from "../../lib/primitives.jsx";
const BENEFITS = [
{ icon: "lightning", title: "First access", body: "Skip the queue when public beta opens. You build before everyone else." },
{ icon: "gift", title: "90 days of Pro, free", body: "Full launch features — hosting, marketing, customer acquisition — on the house." },
{ icon: "chat", title: "Direct line to the team", body: "Private channel with the people building Vibn. Your feedback ships." },
];
export default function Benefits() {
return (
<section className="mt-16">
<div className="text-center mb-7"><Eyebrow>What you get on the inside</Eyebrow></div>
<div className="grid gap-3.5 grid-cols-1 md:grid-cols-3">
{BENEFITS.map((b) => (
<div key={b.title} className="p-6 rounded-[14px] border border-hairline"
style={{ background: "linear-gradient(180deg, oklch(0.20 0.009 60 / 0.35), oklch(0.17 0.008 60 / 0.35))" }}>
<div className="w-9 h-9 rounded-[9px] grid place-items-center border border-hairline text-accent mb-3.5"
style={{ background: "oklch(0.22 0.011 60)" }}>
<Icon name={b.icon} />
</div>
<h3 className="text-[16px] font-medium tracking-[-0.01em]">{b.title}</h3>
<p className="mt-1.5 text-fg-mute text-[13.5px] leading-[1.5]">{b.body}</p>
</div>
))}
</div>
</section>
);
}
function Icon({ name }) {
const p = { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none",
stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round" };
if (name === "lightning") return <svg {...p}><path d="M11 2 4 11h5l-1 7 7-9h-5l1-7Z"/></svg>;
if (name === "gift") return <svg {...p}><rect x="3" y="7.5" width="14" height="10"/><path d="M3 11h14M10 7.5V18M7 7.5a2 2 0 1 1 3-2.5 2 2 0 1 1 3 2.5"/></svg>;
if (name === "chat") return <svg {...p}><path d="M3.5 11.5a6 6 0 1 1 3.4 5.4L3 18l1.1-3.9a6 6 0 0 1-.6-2.6Z"/></svg>;
return null;
}

View File

@@ -0,0 +1,172 @@
import { Arrow } from "../../lib/primitives.jsx";
const ROLES = [
{ value: "smb", label: "Small business owner", hint: "I run a shop, salon, studio, café…" },
{ value: "freelancer", label: "Freelancer / agency", hint: "I build tools for clients" },
{ value: "ideaperson", label: "I just have an idea", hint: "First-time builder, no code" },
];
const SOURCES = ["Reddit", "Twitter / X", "TikTok", "YouTube", "A friend", "Google", "Something else"];
export default function BetaForm({ form, setForm, submitting, onSubmit }) {
const update = (k, v) => setForm((f) => ({ ...f, [k]: v }));
const valid = /\S+@\S+\.\S+/.test(form.email) && form.build.trim().length > 4;
const handle = (e) => {
e.preventDefault();
if (!valid || submitting) return;
onSubmit();
};
return (
<form onSubmit={handle} noValidate
className="relative flex flex-col gap-7 py-9 px-5 sm:px-11 rounded-[22px] border border-hairline backdrop-blur-xl"
style={{
background: "linear-gradient(180deg, oklch(0.20 0.009 60 / 0.6), oklch(0.17 0.008 60 / 0.6))",
boxShadow: "0 30px 80px -20px oklch(0 0 0 / 0.6)",
}}>
<span aria-hidden="true" className="absolute top-0 left-0 right-0 h-px opacity-60"
style={{ background: "linear-gradient(90deg, transparent, var(--c-accent), transparent)" }} />
<Field num="01" title="What's your email?" hint="So we can send you the invite when it's your turn.">
<input
type="email" required
className="f-input" placeholder="you@somewhere.com" autoComplete="email"
value={form.email} onChange={(e) => update("email", e.target.value)}
/>
</Field>
<Field num="02" title="What should we call you?" hint="Optional, but nice to know.">
<input
type="text" className="f-input" placeholder="First name or handle" autoComplete="given-name"
value={form.name} onChange={(e) => update("name", e.target.value)}
/>
</Field>
<Field num="03" title="What's the first thing you want to build?"
hint="Free-form. The vibe matters more than the spec." required>
<div className="rounded-xl border border-hairline overflow-hidden transition-all"
style={{ background: "oklch(0.16 0.008 60 / 0.8)" }}>
<textarea
rows={4}
placeholder="A booking site for my dog grooming business with reminders, payments and a wait list…"
className="w-full border-0 bg-transparent text-fg text-[16px] leading-[1.5] py-3.5 px-4 pb-2.5 outline-none resize-y min-h-[110px] placeholder:text-fg-faint"
value={form.build} onChange={(e) => update("build", e.target.value)}
/>
<div className="flex items-center justify-between px-3.5 py-2.5 border-t border-hairline font-mono text-[11px] text-fg-faint tracking-[0.02em]">
<span className="text-accent">{form.build.length > 0 ? `${form.build.length} chars` : "go wild"}</span>
<span> + Enter to submit the form</span>
</div>
</div>
</Field>
<Field num="04" title="Which one are you?">
<div className="grid grid-cols-1 gap-2">
{ROLES.map((r) => (
<button key={r.value} type="button"
className={[
"relative text-left py-3.5 pr-12 pl-4 rounded-xl border flex flex-col gap-0.5 transition-all",
form.role === r.value
? "border-accent bg-[oklch(0.20_0.04_35/0.4)] shadow-[0_0_0_3px_oklch(0.74_0.175_35/0.1)]"
: "border-hairline hover:border-hairline-2",
].join(" ")}
style={{ background: form.role !== r.value ? "oklch(0.16 0.008 60 / 0.6)" : undefined }}
onClick={() => update("role", r.value)}
>
<span className="text-[15px] font-medium text-fg">{r.label}</span>
<span className="text-[13px] text-fg-mute">{r.hint}</span>
<span className={[
"absolute top-1/2 right-4 -translate-y-1/2 w-5 h-5 rounded-full grid place-items-center transition-all",
form.role === r.value
? "bg-accent border-accent text-accent-fg"
: "border-[1.5px] border-hairline-2 bg-transparent",
].join(" ")}>
<svg width="12" height="12" viewBox="0 0 14 14" fill="none"
className={form.role === r.value ? "opacity-100" : "opacity-0"}>
<path d="M3 7.2 5.8 10 11 4.2" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</span>
</button>
))}
</div>
</Field>
<Field num="05" title="How'd you hear about us?" hint="Optional. Helps us know what's working.">
<div className="flex flex-wrap gap-2">
{SOURCES.map((s) => (
<button key={s} type="button"
onClick={() => update("source", form.source === s ? "" : s)}
className={[
"px-3.5 py-2 rounded-full border text-[13px] transition-colors",
form.source === s
? "border-accent bg-[oklch(0.20_0.04_35/0.4)] text-fg"
: "border-hairline bg-[oklch(0.16_0.008_60/0.6)] text-fg-dim hover:border-hairline-2 hover:text-fg",
].join(" ")}
>{s}</button>
))}
</div>
</Field>
<div className="flex flex-col items-center gap-3.5 mt-2">
<button type="submit" disabled={!valid || submitting}
className="btn btn-primary w-full max-w-[320px] h-14 text-base">
{submitting ? (<><Spinner /> Sending</>) : (<>Request my invite <Arrow /></>)}
</button>
<p className="font-mono text-[11px] text-fg-faint tracking-[0.03em] text-center text-balance">
No credit card · No spam, just one email when you're in · Unsubscribe anytime
</p>
</div>
{/* Component-level CSS for the field inputs (Tailwind doesn't cover the
custom focus glow shadow cleanly). */}
<style>{`
.f-input {
width: 100%; box-sizing: border-box;
padding: 14px 16px;
background: oklch(0.16 0.008 60 / 0.8);
border: 1px solid var(--c-hairline);
border-radius: 12px;
color: var(--c-fg);
font: 16px/1.5 'Geist', system-ui, sans-serif;
outline: none;
transition: border-color .15s, background .15s, box-shadow .15s;
}
.f-input::placeholder { color: var(--c-fg-faint); }
.f-input:focus,
form > div > div:focus-within {
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.15), 0 0 30px -10px var(--c-accent-glow);
}
`}</style>
</form>
);
}
function Field({ num, title, hint, required, children }) {
return (
<div className="flex flex-col gap-3">
<div className="flex items-start gap-3.5">
<span className="font-mono text-[11px] tracking-[0.1em] text-fg-faint px-2 py-1 border border-hairline rounded-md shrink-0 mt-0.5">
{num}{required && <em className="not-italic text-accent ml-px">*</em>}
</span>
<div className="flex-1">
<div className="text-[17px] font-medium text-fg tracking-[-0.01em]">{title}</div>
{hint && <div className="mt-0.5 text-[13px] text-fg-mute">{hint}</div>}
</div>
</div>
<div>{children}</div>
</div>
);
}
function Spinner() {
return (
<span className="w-4 h-4 rounded-full border-2 inline-block"
style={{
borderColor: "oklch(0 0 0 / 0.2)",
borderTopColor: "var(--c-accent-fg)",
animation: "spin .9s linear infinite",
}} />
);
}

View File

@@ -0,0 +1,150 @@
import { useMemo, useState } from "react";
import { Eyebrow } from "../../lib/primitives.jsx";
export default function Confirmed({ form, queuePos }) {
const [copied, setCopied] = useState(false);
const ref = useMemo(() => {
const seed = form.email || form.name || "anon";
let h = 5;
for (const c of seed) h = (h * 33 + c.charCodeAt(0)) >>> 0;
return "v-" + h.toString(36).slice(0, 6);
}, [form.email, form.name]);
const link = typeof window !== "undefined"
? `${window.location.origin}/join?ref=${ref}`
: `vibn.app/join?ref=${ref}`;
const copyLink = () => {
try { navigator.clipboard.writeText(link); } catch (e) { /* noop */ }
setCopied(true);
setTimeout(() => setCopied(false), 1800);
};
const pct = Math.max(2, Math.min(98, 100 - (queuePos - 2100) / 9));
return (
<div className="flex flex-col gap-7">
<div className="text-center">
<div className="inline-grid place-items-center w-16 h-16 rounded-full mb-4 text-ok"
style={{
background: "oklch(0.78 0.16 155 / 0.1)",
border: "1px solid oklch(0.78 0.16 155 / 0.4)",
boxShadow: "0 0 40px oklch(0.78 0.16 155 / 0.3)",
}}>
<svg width="32" height="32" viewBox="0 0 32 32" fill="none">
<circle cx="16" cy="16" r="15" stroke="currentColor" strokeWidth="1.5" opacity=".25"/>
<path d="M10 16.5 14.5 21 22 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<Eyebrow>You're on the list</Eyebrow>
<h1 className="mt-3.5 font-medium tracking-[-0.03em] leading-none text-[clamp(40px,6.4vw,80px)] text-balance">
{form.name
? <>Welcome, <em className="not-italic text-accent" style={{ textShadow: "0 0 40px var(--c-accent-glow)" }}>{form.name}</em>.</>
: <>You're <em className="not-italic text-accent" style={{ textShadow: "0 0 40px var(--c-accent-glow)" }}>in line</em>.</>}
</h1>
<p className="mt-5 text-[clamp(16px,1.6vw,19px)] text-fg-dim text-balance">
We got your invite request keep an eye on{" "}
<b className="font-mono font-medium text-fg">{form.email}</b>.
</p>
</div>
{/* Queue card */}
<div className="p-7 rounded-[18px] border border-hairline"
style={{ background: "linear-gradient(180deg, oklch(0.20 0.009 60 / 0.6), oklch(0.17 0.008 60 / 0.6))" }}>
<div className="flex justify-between items-end gap-3.5 mb-6">
<div>
<div className="font-mono text-[11px] uppercase tracking-[0.1em] text-fg-faint">your spot in line</div>
<div className="mt-2 font-mono font-medium text-accent tracking-[-0.04em] leading-none text-[clamp(48px,7vw,76px)]"
style={{ textShadow: "0 0 40px var(--c-accent-glow)" }}>
#{queuePos.toLocaleString()}
</div>
</div>
<div className="text-right">
<div className="font-mono text-[26px] font-medium text-fg leading-none">
50<small className="text-fg-mute text-[13px] font-normal ml-0.5">/wk</small>
</div>
<div className="font-mono text-[11px] uppercase tracking-[0.1em] text-fg-faint">letting in</div>
</div>
</div>
<div className="relative h-1.5 rounded-full mb-6 mt-9" style={{ background: "oklch(0.22 0.01 60)" }}>
<div className="absolute left-0 top-0 bottom-0 rounded-full transition-[width] duration-700"
style={{
width: `${pct}%`,
background: "linear-gradient(90deg, oklch(0.65 0.15 35), var(--c-accent))",
boxShadow: "0 0 12px var(--c-accent-glow)",
}} />
<div className="absolute top-1/2 -translate-y-1/2 w-3.5 h-3.5 rounded-full"
style={{
left: `${pct}%`,
transform: "translate(-50%, -50%)",
background: "var(--c-accent)",
boxShadow: "0 0 0 3px var(--c-bg), 0 0 18px var(--c-accent-glow)",
}}>
<span className="absolute bottom-full left-1/2 -translate-x-1/2 -translate-y-2 font-mono text-[11px] tracking-[0.04em] text-accent whitespace-nowrap">
You
</span>
</div>
</div>
<div className="font-mono text-[12px] text-fg-mute tracking-[0.02em]">
You should hear from us in ~<b className="text-fg font-medium">{Math.ceil((queuePos - 50) / 50)} weeks</b>. Don't want to wait?
</div>
</div>
{/* Refer card */}
<div className="p-7 rounded-[18px] border border-hairline"
style={{ background: "linear-gradient(180deg, oklch(0.20 0.009 60 / 0.6), oklch(0.17 0.008 60 / 0.6))" }}>
<Eyebrow>Skip the line</Eyebrow>
<h3 className="mt-3 text-[22px] font-medium tracking-[-0.018em]">Send 3 friends — jump to the front.</h3>
<p className="mt-1.5 text-fg-mute text-[14px]">Each friend who joins via your link bumps you up 500 spots.</p>
<div className="mt-4 flex gap-2 items-stretch">
<div className="flex-1 px-3.5 py-3 rounded-[10px] border border-hairline text-[13px] tracking-[0.01em] text-fg-dim flex items-center overflow-hidden whitespace-nowrap"
style={{ background: "oklch(0.16 0.008 60)" }}>
<span className="font-mono">
<span className="text-fg-faint">vibn.app/join?ref=</span>
<b className="text-accent font-medium">{ref}</b>
</span>
</div>
<button type="button" onClick={copyLink} className="btn btn-ghost h-auto px-[18px]">
{copied ? "Copied!" : "Copy link"}
</button>
</div>
<div className="mt-3.5 flex flex-wrap gap-2">
{[["x", "Share on X"], ["reddit", "Post to Reddit"], ["mail", "Email a friend"]].map(([k, label]) => (
<a key={k} href="#"
className="inline-flex items-center gap-2 px-3.5 py-2 rounded-full border border-hairline text-[13px] text-fg-dim transition-colors hover:text-fg hover:border-hairline-2"
style={{ background: "oklch(0.16 0.008 60 / 0.5)" }}>
<ShareIcon name={k} /> {label}
</a>
))}
</div>
</div>
{form.build && (
<div className="p-6 px-7 rounded-2xl border border-dashed border-hairline">
<Eyebrow>What we'll help you build first</Eyebrow>
<div className="mt-3 italic text-fg text-[18px] tracking-[-0.005em] leading-[1.4] text-balance">
"{form.build}"
</div>
</div>
)}
</div>
);
}
function ShareIcon({ name }) {
const p = { width: 14, height: 14, viewBox: "0 0 16 16", fill: "currentColor" };
if (name === "x") return <svg {...p}><path d="M9.2 7 13.7 2h-1.4L8.6 6.3 5.6 2H2l4.7 6.8L2 14h1.4l4.1-4.7 3.3 4.7H14L9.2 7Z"/></svg>;
if (name === "reddit") return <svg {...p}><circle cx="8" cy="9" r="6"/></svg>;
if (name === "mail") return (
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="2" y="3.5" width="12" height="9" rx="1.5"/>
<path d="m3 5 5 3.8L13 5"/>
</svg>
);
return null;
}

View File

@@ -0,0 +1,73 @@
// Shared primitives — used across homepage and beta page.
export function LogoMark({ size = 26, blink = true }) {
return (
<span className="logo-mark" style={{ width: size, height: size }}>
<svg
viewBox="0 0 36 32"
width="74%" height="74%"
fill="currentColor"
stroke="currentColor"
strokeWidth="1.2"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M4 5 L10 5 L12 18 L14 5 L20 5 L12 27 Z" />
<rect
x="22.5" y="23" width="9.5" height="3.8" rx="0.7"
className={blink ? "logo-caret" : ""}
/>
</svg>
</span>
);
}
export function Logo({ size = 26, href = "/" }) {
return (
<a href={href} className="inline-flex items-center gap-[9px] font-semibold text-[17px] tracking-[-0.02em]">
<LogoMark size={size} />
<span>vibn</span>
</a>
);
}
export function Arrow({ size = 14 }) {
return (
<svg className="arrow" width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
}
export function Eyebrow({ children }) {
return <div className="eyebrow">{children}</div>;
}
export function Glow({ color = "var(--c-accent-glow)", size = 700, opacity = 1, style = {} }) {
return (
<div
aria-hidden="true"
className="absolute pointer-events-none"
style={{
width: size, height: size,
background: `radial-gradient(circle at center, ${color} 0%, transparent 62%)`,
filter: "blur(20px)",
opacity,
...style,
}}
/>
);
}
export function TrustStrip({ items }) {
return (
<div className="font-mono flex flex-wrap gap-x-[18px] gap-y-2 text-[12px] text-fg-mute tracking-[0.04em]">
{items.map((item, i) => (
<span key={i} className="contents">
{i > 0 && <span className="text-fg-faint">·</span>}
<span>{item}</span>
</span>
))}
</div>
);
}

View File

@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,180 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ─────────────────────────────────────────────────────────────────────────
* Design tokens — exposed as CSS variables so the Tweaks panel can
* runtime-swap the accent palette. Tailwind's color names alias these
* (see tailwind.config.js → theme.extend.colors).
* ──────────────────────────────────────────────────────────────────────── */
@layer base {
:root {
--c-bg: oklch(0.155 0.008 60);
--c-bg-1: oklch(0.185 0.009 60);
--c-bg-2: oklch(0.225 0.010 60);
--c-hairline: oklch(0.32 0.010 60 / 0.55);
--c-hairline-2:oklch(0.40 0.012 60 / 0.35);
--c-fg: oklch(0.97 0.005 80);
--c-fg-dim: oklch(0.78 0.006 80);
--c-fg-mute: oklch(0.58 0.006 80);
--c-fg-faint: oklch(0.42 0.006 80);
/* Accent (coral) — overridden by Tweaks panel via .style.setProperty */
--c-accent: oklch(0.74 0.175 35);
--c-accent-soft: oklch(0.74 0.175 35 / 0.18);
--c-accent-glow: oklch(0.74 0.175 35 / 0.35);
--c-accent-fg: #1a0f0a;
--c-ok: oklch(0.78 0.16 155);
}
html, body { margin: 0; padding: 0; }
body {
background: var(--c-bg);
color: var(--c-fg);
font-family: theme('fontFamily.sans');
line-height: 1.45;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overflow-x: hidden;
}
/* Ambient grid */
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 90% 70% at 50% 30%, #000 30%, transparent 80%);
-webkit-mask-image: radial-gradient(ellipse 90% 70% at 50% 30%, #000 30%, transparent 80%);
pointer-events: none;
z-index: 0;
}
/* Film grain */
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>");
}
h1, h2, h3, h4 { margin: 0; font-weight: 500; letter-spacing: -0.02em; line-height: 1.05; }
p { margin: 0; }
::selection { background: var(--c-accent); color: var(--c-accent-fg); }
}
/* ─────────────────────────────────────────────────────────────────────────
* Components — composed Tailwind classes for repeated patterns.
* ──────────────────────────────────────────────────────────────────────── */
@layer components {
.wrap {
@apply relative w-full max-w-[1240px] mx-auto px-5 sm:px-10 lg:px-14 z-[2];
}
/* Buttons */
.btn {
@apply inline-flex items-center gap-2.5 h-[46px] px-[22px] rounded-full font-medium whitespace-nowrap transition-transform duration-100;
}
.btn-primary {
background: var(--c-accent);
color: var(--c-accent-fg);
box-shadow:
0 0 0 1px oklch(0.84 0.16 35 / 0.5) inset,
0 10px 40px -10px var(--c-accent-glow),
0 0 50px -8px var(--c-accent-glow);
}
.btn-primary:hover { transform: translateY(-1px); }
.btn-primary[disabled] { opacity: 0.55; cursor: not-allowed; transform: none; }
.btn-primary .arrow { transition: transform .15s ease; }
.btn-primary:hover .arrow { transform: translateX(3px); }
.btn-ghost {
@apply text-fg-dim;
border: 1px solid var(--c-hairline);
background: oklch(0.20 0.009 60 / 0.4);
backdrop-filter: blur(8px);
}
.btn-ghost:hover { color: var(--c-fg); border-color: var(--c-hairline-2); }
/* Logo mark — coral circle that hosts the V_ glyph */
.logo-mark {
@apply inline-grid place-items-center rounded-full shrink-0;
width: 26px; height: 26px;
background: linear-gradient(135deg, var(--c-accent) 0%, oklch(0.65 0.20 18) 100%);
box-shadow: 0 0 22px var(--c-accent-glow), inset 0 1px 0 oklch(1 0 0 / 0.25);
color: var(--c-accent-fg);
}
.logo-mark svg { display: block; }
.logo-caret { animation: caret-blink 1.4s steps(2) infinite; }
/* Eyebrow */
.eyebrow {
@apply inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.14em] text-fg-mute;
}
.eyebrow::before {
content: "";
width: 5px; height: 5px; border-radius: 9999px;
background: var(--c-accent);
box-shadow: 0 0 12px var(--c-accent-glow);
}
/* Gradient hairline card */
.card {
@apply relative rounded-xl p-7;
background: linear-gradient(180deg, oklch(0.20 0.009 60 / 0.55), oklch(0.17 0.008 60 / 0.5));
}
.card::before {
content: ""; position: absolute; inset: 0; border-radius: inherit; padding: 1px;
background: linear-gradient(180deg, var(--c-hairline-2), oklch(0.22 0.010 60 / 0.2));
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
/* Surfaces used in multiple sections */
.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(--c-hairline);
}
}
/* ─────────────────────────────────────────────────────────────────────────
* Keyframes that Tailwind's default animation set doesn't cover.
* (See tailwind.config.js for the named utilities that hit these.)
* ──────────────────────────────────────────────────────────────────────── */
@keyframes blink {
50% { opacity: 0; }
}
@keyframes caret-blink {
50% { opacity: 0.25; }
}
@keyframes pulse-ok {
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); }
}
@keyframes bounce-dot {
0%, 80%, 100% { transform: translateY(0); opacity: .5; }
40% { transform: translateY(-3px); opacity: 1; }
}
@keyframes strike {
from { opacity: 0; transform: translateY(-50%) rotate(-1deg) scaleX(0); transform-origin: left center; }
to { opacity: 1; transform: translateY(-50%) rotate(-1deg) scaleX(1); transform-origin: left center; }
}
@keyframes fade-half {
to { opacity: 0.5; }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes fadein {
from { opacity: 0; }
}

View File

@@ -0,0 +1,60 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./beta.html", "./src/**/*.{js,jsx,ts,tsx}"],
theme: {
extend: {
colors: {
// Tokens are wired to CSS variables so the Tweaks panel (or any
// runtime themer) can swap the accent palette without rebuilding.
bg: "var(--c-bg)",
"bg-1": "var(--c-bg-1)",
"bg-2": "var(--c-bg-2)",
fg: "var(--c-fg)",
"fg-dim": "var(--c-fg-dim)",
"fg-mute": "var(--c-fg-mute)",
"fg-faint":"var(--c-fg-faint)",
hairline: "var(--c-hairline)",
"hairline-2": "var(--c-hairline-2)",
accent: {
DEFAULT: "var(--c-accent)",
soft: "var(--c-accent-soft)",
glow: "var(--c-accent-glow)",
fg: "var(--c-accent-fg)",
},
ok: "var(--c-ok)",
},
fontFamily: {
sans: ['Geist', 'ui-sans-serif', 'system-ui', '-apple-system', 'sans-serif'],
mono: ['"Geist Mono"', 'ui-monospace', '"SF Mono"', 'Menlo', 'monospace'],
},
borderRadius: {
xl: "18px",
"2xl": "22px",
"3xl": "28px",
},
boxShadow: {
"accent-glow": "0 10px 40px -10px var(--c-accent-glow), 0 0 50px -8px var(--c-accent-glow)",
"card": "0 30px 80px -20px oklch(0 0 0 / 0.6)",
},
keyframes: {
"caret-blink": { "50%": { opacity: "0.25" } },
"pulse-ok": {
"0%": { boxShadow: "0 0 0 0 oklch(0.78 0.16 155 / 0.6)" },
"70%": { boxShadow: "0 0 0 8px oklch(0.78 0.16 155 / 0)" },
"100%": { boxShadow: "0 0 0 0 oklch(0.78 0.16 155 / 0)" },
},
spin: { to: { transform: "rotate(360deg)" } },
strike: {
from: { opacity: "0", transform: "translateY(-50%) rotate(-1deg) scaleX(0)" },
to: { opacity: "1", transform: "translateY(-50%) rotate(-1deg) scaleX(1)" },
},
},
animation: {
"caret-blink": "caret-blink 1.4s steps(2) infinite",
"pulse-ok": "pulse-ok 2s ease-out infinite",
spin: "spin .9s linear infinite",
},
},
},
plugins: [],
};

View File

@@ -0,0 +1,17 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { resolve } from "node:path";
// Multi-page setup so the marketing site and beta signup stay as separate
// static entry points — same as a typical landing/marketing app build.
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
input: {
main: resolve(__dirname, "index.html"),
beta: resolve(__dirname, "beta.html"),
},
},
},
});

View File

@@ -0,0 +1,72 @@
# Cadence CRM — Sidebar template package
A complete CRM screen set in the **far-left Sidebar** navigation style, built on the **vibn-ai-templates** component library in the light/minimal theme.
Worked example brand: **Cadence** (the product) / **Northwind** (the example workspace).
## Screens (13)
**Auth + onboarding** (full-screen, no sidebar yet — standard pattern)
| Screen | File |
|---|---|
| Sign up | `crm-onboarding.jsx``CRMSignUp` |
| Sign in | `crm-onboarding.jsx``CRMSignIn` |
| Onboarding 1 · Name workspace | `CRMOnbWorkspace` |
| Onboarding 2 · About your team | `CRMOnbAbout` |
| Onboarding 3 · Import contacts | `CRMOnbImport` |
| Onboarding 4 · Invite team | `CRMOnbInvite` |
**In-app** (every screen rendered inside `SidebarShell`)
| Screen | File |
|---|---|
| Home / dashboard | `crm-pages.jsx``CRMHome` |
| People (contacts table) | `CRMPeople` |
| Company record (detail) | `CRMRecord` |
| Deals (pipeline kanban) | `CRMPipeline` |
| Inbox (threads + conversation) | `CRMInbox` |
| Reports (charts + leaderboard) | `CRMReports` |
| Settings → Members (admin) | `CRMSettings` |
## The sidebar pattern
Every in-app screen wraps its body in `CRMShell`, which configures the kit's `SidebarShell`:
```jsx
const CRMShell = ({ active, children }) => (
<SidebarShell
brand={{ name: "Northwind", mark: <CadenceMark/> }}
sections={crmSections(active)} // sets the active nav item
user={{ name: "Mira Reyes", email: "mira@northwind.io" }}
search="Search or jump to…"
>
{children}
</SidebarShell>
);
```
The nav is defined once in `crmSections(active)` — three groups: top-level (Home / Inbox / Tasks), **Records** (Companies / People / Deals), **Workspace** (Reports / Automations / Settings). Pass the active id and the matching item highlights.
## Dependencies & load order
This package depends on `vibn-ai-templates`. Load order matters:
```html
<link rel="stylesheet" href="vibn-ai-templates/tokens.css">
<script type="text/babel" src="vibn-ai-templates/icons.jsx"></script>
<script type="text/babel" src="vibn-ai-templates/components.jsx"></script>
<script type="text/babel" src="vibn-ai-templates/shells.jsx"></script>
<script type="text/babel" src="vibn-crm/crm-onboarding.jsx"></script>
<script type="text/babel" src="vibn-crm/crm-pages.jsx"></script>
```
Wrap the app in `class="theme-minimal"` (the default light theme). Because the whole kit is token-driven, you can re-skin the entire CRM to dark by swapping that one class to `theme-dark` — no page edits.
## Showcase
`Cadence CRM Templates.html` lays all 13 screens out on a design canvas in three sections (auth, onboarding, in-app). Drag artboards to reorder, or open any one fullscreen (←/→/Esc).
## What's template vs. reusable
- **Reusable** — everything in `vibn-ai-templates` (Button, Table, Card, Badge, Avatar, Tabs, Input, SidebarShell, …).
- **Template** — `crm-pages.jsx` and `crm-onboarding.jsx` are *compositions*. They show how to assemble the kit into real CRM screens. Copy them as starting points and swap in your data.

View File

@@ -0,0 +1,344 @@
// ============================================================
// crm-onboarding.jsx — Cadence CRM · auth + onboarding
// ------------------------------------------------------------
// Full-screen flows that precede the app. Same minimal/light
// aesthetic as the Sidebar app shell. Built on vibn-ai-templates
// components (Button, Field, Input, Card, Badge, Avatar, Icon…).
// ============================================================
// Cadence brand mark — concentric "pulse" rings (a CRM cadence)
const CadenceMark = ({ size = 22 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect x="1" y="1" width="22" height="22" rx="7" fill="#5e5cff"/>
<path d="M5 13.5 H8.5 L10.2 8 L13 16 L14.8 11 L16.4 13.5 H19"
stroke="#fff" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
const onbFont = "'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif";
// ── Shared scaffold: brand top bar + footer, centered slot ───
const OnbScaffold = ({ children, right, maxWidth = 460 }) => (
<div className="vibn-app" style={{
width: "100%", height: "100%", display: "grid",
gridTemplateRows: "auto 1fr auto", fontFamily: onbFont,
background: "var(--bg)", overflow: "hidden",
}}>
<header style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "20px 28px",
}}>
<div style={{ display: "flex", alignItems: "center", gap: 9, fontWeight: 600, fontSize: 15 }}>
<CadenceMark size={22}/> Cadence
</div>
<div style={{ fontSize: 13, color: "var(--text-2)" }}>{right}</div>
</header>
<main style={{ display: "flex", alignItems: "center", justifyContent: "center", padding: 24, overflowY: "auto" }}>
<div style={{ width: maxWidth, maxWidth: "100%" }}>{children}</div>
</main>
<footer style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "16px 28px", fontSize: 11, color: "var(--text-3)",
}}>
<span>© 2026 Cadence CRM</span>
<div style={{ display: "flex", gap: 16 }}><span>Privacy</span><span>Terms</span><span>Security</span></div>
</footer>
</div>
);
const SocialButtons = ({ stacked }) => (
<div style={{ display: stacked ? "flex" : "flex", flexDirection: stacked ? "column" : "row", gap: 8 }}>
<Button variant="secondary" full>Continue with Google</Button>
<Button variant="secondary" full>Continue with Microsoft</Button>
</div>
);
// ── 1 · SIGN UP ──────────────────────────────────────────────
const CRMSignUp = () => (
<OnbScaffold right={<>Have an account? <b style={{ color: "var(--text)" }}>Sign in</b></>}>
<Card variant="raised" padding={34}>
<h1 style={{ margin: 0, fontSize: 24, fontWeight: 600, letterSpacing: "-0.02em" }}>
Create your Cadence account
</h1>
<p style={{ fontSize: 13, color: "var(--text-2)", margin: "8px 0 24px" }}>
Free for your whole team for 14 days. No card required.
</p>
<SocialButtons/>
<Divider label="or"/>
<Field label="Work email"><Input value="mira@northwind.io" autofocus/></Field>
<Field label="Full name"><Input placeholder="Mira Reyes"/></Field>
<Field label="Password" hint="At least 10 characters with a number.">
<Input type="password" value="••••••••••" trailingIcon={<Icon name="eye" size={14}/>}/>
</Field>
<Checkbox checked label="I agree to the Terms and Privacy Policy." style={{ margin: "2px 0 18px" }}/>
<Button full size="lg">Create account <Icon name="arrow" size={14}/></Button>
</Card>
</OnbScaffold>
);
// ── 2 · SIGN IN ──────────────────────────────────────────────
const CRMSignIn = () => (
<OnbScaffold right={<>New here? <b style={{ color: "var(--text)" }}>Create an account</b></>}>
<Card variant="raised" padding={34}>
<h1 style={{ margin: 0, fontSize: 24, fontWeight: 600, letterSpacing: "-0.02em" }}>Welcome back</h1>
<p style={{ fontSize: 13, color: "var(--text-2)", margin: "8px 0 24px" }}>
Sign in to the Northwind workspace.
</p>
<SocialButtons/>
<Divider label="or"/>
<Field label="Work email"><Input value="mira@northwind.io" autofocus/></Field>
<div style={{ marginBottom: 16 }}>
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "baseline",
fontSize: 12, fontWeight: 500, marginBottom: 6,
}}>
<span>Password</span>
<span style={{ color: "var(--accent)", cursor: "pointer", fontWeight: 400 }}>Forgot?</span>
</div>
<Input type="password" value="••••••••••" trailingIcon={<Icon name="eye" size={14}/>}/>
</div>
<Checkbox checked label="Keep me signed in for 30 days" style={{ marginBottom: 18 }}/>
<Button full size="lg">Sign in <Icon name="arrow" size={14}/></Button>
<div style={{
marginTop: 18, padding: "10px 14px", borderRadius: "var(--radius)",
background: "var(--surface-2)", border: "1px solid var(--border)",
fontSize: 12, color: "var(--text-2)", display: "flex", alignItems: "center", gap: 10,
}}>
<Icon name="shield" size={14} style={{ color: "var(--accent)" }}/>
<span style={{ flex: 1 }}>Your company uses SSO?</span>
<span style={{ color: "var(--text)", fontWeight: 500, cursor: "pointer" }}>Use SSO </span>
</div>
</Card>
</OnbScaffold>
);
// ── Stepper used across onboarding steps ─────────────────────
const Stepper = ({ step }) => {
const steps = ["Workspace", "About you", "Import", "Invite"];
return (
<div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 24 }}>
{steps.map((s, i) => {
const state = i < step ? "done" : i === step ? "active" : "todo";
return (
<React.Fragment key={s}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{
width: 22, height: 22, borderRadius: "50%",
background: state === "done" ? "var(--success)" : state === "active" ? "var(--text)" : "transparent",
color: state === "todo" ? "var(--text-3)" : "#fff",
border: state === "todo" ? "1px solid var(--border-strong)" : "none",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 11, fontWeight: 600, flexShrink: 0,
}}>{state === "done" ? <Icon name="checkOnly" size={12} stroke={2.6}/> : i + 1}</div>
<span style={{
fontSize: 12, color: state === "todo" ? "var(--text-3)" : "var(--text)",
fontWeight: state === "active" ? 600 : 400, whiteSpace: "nowrap",
}}>{s}</span>
</div>
{i < steps.length - 1 && <div style={{ flex: 1, height: 1, background: "var(--border)", margin: "0 4px", minWidth: 16 }}/>}
</React.Fragment>
);
})}
</div>
);
};
const Tile = ({ icon, title, sub, selected }) => (
<div style={{
padding: 16, borderRadius: "var(--radius)", cursor: "pointer", textAlign: "left",
border: selected ? "1.5px solid var(--accent)" : "1px solid var(--border)",
background: selected ? "var(--accent-soft)" : "var(--surface)",
boxShadow: selected ? "0 0 0 3px var(--accent-ring)" : "var(--shadow-sm)",
position: "relative",
}}>
<div style={{
width: 32, height: 32, borderRadius: 9, marginBottom: 12,
background: selected ? "var(--accent)" : "var(--surface-alt)",
color: selected ? "#fff" : "var(--text-2)",
display: "flex", alignItems: "center", justifyContent: "center",
}}><Icon name={icon} size={16}/></div>
<div style={{ fontSize: 13, fontWeight: 600 }}>{title}</div>
<div style={{ fontSize: 12, color: "var(--text-3)", marginTop: 2, lineHeight: 1.4 }}>{sub}</div>
{selected && <div style={{
position: "absolute", top: 14, right: 14, width: 18, height: 18, borderRadius: "50%",
background: "var(--accent)", color: "#fff",
display: "flex", alignItems: "center", justifyContent: "center",
}}><Icon name="checkOnly" size={11} stroke={2.6}/></div>}
</div>
);
// ── 3 · ONBOARDING · step 1 — create workspace ───────────────
const CRMOnbWorkspace = () => (
<OnbScaffold right="Step 1 of 4" maxWidth={560}>
<Card variant="raised" padding={36}>
<Stepper step={0}/>
<h1 style={{ margin: 0, fontSize: 26, fontWeight: 600, letterSpacing: "-0.02em" }}>
Name your workspace
</h1>
<p style={{ fontSize: 13, color: "var(--text-2)", margin: "8px 0 24px" }}>
This is where your team's contacts, companies and deals will live.
</p>
<div style={{ display: "flex", gap: 16, alignItems: "flex-start", marginBottom: 18 }}>
<div style={{
width: 64, height: 64, borderRadius: 14, flexShrink: 0,
background: "var(--surface-alt)", border: "1.5px dashed var(--border-strong)",
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
color: "var(--text-3)", cursor: "pointer", gap: 2,
}}>
<Icon name="plus" size={18}/>
<span style={{ fontSize: 9 }}>Logo</span>
</div>
<div style={{ flex: 1 }}>
<Field label="Workspace name" style={{ marginBottom: 12 }}>
<Input value="Northwind"/>
</Field>
<Field label="Workspace URL" hint="You can change this later." style={{ marginBottom: 0 }}>
<Input value="northwind" leadingIcon={<span style={{ fontSize: 12, color: "var(--text-3)" }}>cadence.app/</span>}/>
</Field>
</div>
</div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 26 }}>
<span style={{ fontSize: 12, color: "var(--text-3)" }}>You can invite teammates in a moment.</span>
<Button size="lg">Continue <Icon name="arrow" size={14}/></Button>
</div>
</Card>
</OnbScaffold>
);
// ── 4 · ONBOARDING · step 2 — about you ──────────────────────
const CRMOnbAbout = () => (
<OnbScaffold right="Step 2 of 4" maxWidth={620}>
<Card variant="raised" padding={36}>
<Stepper step={1}/>
<h1 style={{ margin: 0, fontSize: 26, fontWeight: 600, letterSpacing: "-0.02em" }}>
Tell us about your team
</h1>
<p style={{ fontSize: 13, color: "var(--text-2)", margin: "8px 0 22px" }}>
We'll tailor your pipeline stages and views to match.
</p>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 10 }}>What will you use Cadence for?</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 10, marginBottom: 24 }}>
<Tile icon="target" title="Sales pipeline" sub="Track deals to close" selected/>
<Tile icon="people" title="Relationships" sub="Nurture contacts"/>
<Tile icon="briefcase" title="Recruiting" sub="Manage candidates"/>
<Tile icon="bolt" title="Fundraising" sub="Investors & rounds"/>
<Tile icon="inbox" title="Support" sub="Customer success"/>
<Tile icon="spark" title="Something else" sub="I'll set it up"/>
</div>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 10 }}>How big is your team?</div>
<FieldGroup options={["Just me", "210", "1150", "51200", "200+"]} value="210"/>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 28 }}>
<Button variant="ghost"> Back</Button>
<Button size="lg">Continue <Icon name="arrow" size={14}/></Button>
</div>
</Card>
</OnbScaffold>
);
// ── 5 · ONBOARDING · step 3 — import contacts ────────────────
const CRMOnbImport = () => {
const Source = ({ icon, title, sub, badge }) => (
<div style={{
display: "flex", alignItems: "center", gap: 14, padding: 16,
borderRadius: "var(--radius)", border: "1px solid var(--border)",
background: "var(--surface)", cursor: "pointer", boxShadow: "var(--shadow-sm)",
}}>
<div style={{
width: 40, height: 40, borderRadius: 10, flexShrink: 0,
background: "var(--surface-alt)", color: "var(--text-2)",
display: "flex", alignItems: "center", justifyContent: "center",
}}><Icon name={icon} size={18}/></div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600, display: "flex", alignItems: "center", gap: 8 }}>
{title}{badge && <Badge tone="accent">{badge}</Badge>}
</div>
<div style={{ fontSize: 12, color: "var(--text-3)", marginTop: 2 }}>{sub}</div>
</div>
<Icon name="chevRight" size={16} style={{ color: "var(--text-3)" }}/>
</div>
);
return (
<OnbScaffold right="Step 3 of 4" maxWidth={560}>
<Card variant="raised" padding={36}>
<Stepper step={2}/>
<h1 style={{ margin: 0, fontSize: 26, fontWeight: 600, letterSpacing: "-0.02em" }}>
Bring your contacts in
</h1>
<p style={{ fontSize: 13, color: "var(--text-2)", margin: "8px 0 22px" }}>
Cadence dedupes and enriches automatically. Nothing is shared.
</p>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<Source icon="people" title="Google Contacts" sub="Sync 2,400 contacts" badge="Recommended"/>
<Source icon="inbox" title="Connect a mailbox" sub="Build contacts from your sent mail"/>
<Source icon="doc" title="Upload a CSV" sub="Map columns to fields"/>
</div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 26 }}>
<Button variant="ghost">Skip for now</Button>
<Button size="lg">Continue <Icon name="arrow" size={14}/></Button>
</div>
</Card>
</OnbScaffold>
);
};
// ── 6 · ONBOARDING · step 4 — invite team ────────────────────
const CRMOnbInvite = () => {
const Row = ({ email, role, color }) => (
<div style={{
display: "flex", alignItems: "center", gap: 12, padding: "10px 12px",
borderRadius: "var(--radius)", background: "var(--surface-2)", border: "1px solid var(--border)",
}}>
<Avatar name={email} color={color} size={28}/>
<span style={{ flex: 1, fontSize: 13, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{email}</span>
<Select value={role} style={{ padding: "5px 10px" }}/>
<Badge tone="warn" dot>Pending</Badge>
</div>
);
return (
<OnbScaffold right="Step 4 of 4" maxWidth={560}>
<Card variant="raised" padding={36}>
<Stepper step={3}/>
<h1 style={{ margin: 0, fontSize: 26, fontWeight: 600, letterSpacing: "-0.02em" }}>
Invite your team
</h1>
<p style={{ fontSize: 13, color: "var(--text-2)", margin: "8px 0 22px" }}>
Cadence is better with the people you sell with.
</p>
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
<Input placeholder="name@northwind.io, separate with commas" style={{ flex: 1 }} autofocus/>
<Button>Send invites</Button>
</div>
<div style={{ fontSize: 11, color: "var(--text-3)", textTransform: "uppercase", letterSpacing: "0.06em", fontWeight: 500, marginBottom: 8 }}>
To be invited · 3
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<Row email="theo@northwind.io" role="Admin" color="#c8e8a8"/>
<Row email="devi@northwind.io" role="Member" color="#a8c8e8"/>
<Row email="sun@northwind.io" role="Member" color="#e8a87c"/>
</div>
<div style={{
marginTop: 18, padding: "12px 14px", borderRadius: "var(--radius)",
border: "1px dashed var(--border-strong)", display: "flex", alignItems: "center", gap: 12,
}}>
<Icon name="link" size={16} style={{ color: "var(--accent)" }}/>
<span style={{ flex: 1, fontSize: 13, fontFamily: "var(--font-mono)", color: "var(--text-2)" }}>cadence.app/join/northwind-7f4a</span>
<Button variant="secondary" size="sm">Copy link</Button>
</div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 26 }}>
<Button variant="ghost">I'll do this later</Button>
<Button size="lg">Enter Cadence <Icon name="arrow" size={14}/></Button>
</div>
</Card>
</OnbScaffold>
);
};
Object.assign(window, {
CadenceMark, CRMSignUp, CRMSignIn,
CRMOnbWorkspace, CRMOnbAbout, CRMOnbImport, CRMOnbInvite,
});

View File

@@ -0,0 +1,688 @@
// ============================================================
// crm-pages.jsx — Cadence CRM · in-app screens
// ------------------------------------------------------------
// Every page renders INSIDE SidebarShell (far-left sidebar).
// Built on vibn-ai-templates components. Light/minimal theme.
//
// Pages: CRMHome, CRMPeople, CRMRecord, CRMPipeline,
// CRMInbox, CRMReports, CRMSettings.
// ============================================================
const CRM_USER = { name: "Mira Reyes", email: "mira@northwind.io", color: "#d4b8a8" };
// Sidebar config — pass the active id, get the sections array.
const crmSections = (active) => [
{ items: [
{ id: "home", label: "Home", icon: "home", active: active === "home" },
{ id: "inbox", label: "Inbox", icon: "inbox", count: 8, active: active === "inbox" },
{ id: "tasks", label: "My tasks", icon: "check", count: 3, active: active === "tasks" },
]},
{ title: "Records", items: [
{ id: "companies", label: "Companies", icon: "building", active: active === "companies" },
{ id: "people", label: "People", icon: "people", active: active === "people" },
{ id: "deals", label: "Deals", icon: "target", count: 12, active: active === "deals" },
]},
{ title: "Workspace", items: [
{ id: "reports", label: "Reports", icon: "bar", active: active === "reports" },
{ id: "automations", label: "Automations", icon: "workflow", active: active === "automations" },
{ id: "settings", label: "Settings", icon: "settings", active: active === "settings" },
]},
];
// Wrap a page body in the shell with the right nav item active
const CRMShell = ({ active, children }) => (
<SidebarShell brand={{ name: "Northwind", mark: <CadenceMark size={22}/> }}
sections={crmSections(active)} user={CRM_USER} search="Search or jump to…">
{children}
</SidebarShell>
);
// Reusable page header bar (title + actions row)
const PageBar = ({ title, sub, breadcrumb, actions }) => (
<div style={{
padding: "16px 28px", borderBottom: "1px solid var(--divider)",
background: "var(--surface)", display: "flex",
justifyContent: "space-between", alignItems: "center", gap: 16,
}}>
<div style={{ minWidth: 0 }}>
{breadcrumb && <div style={{ fontSize: 12, color: "var(--text-3)", marginBottom: 3 }}>{breadcrumb}</div>}
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 600, letterSpacing: "-0.01em" }}>{title}</h1>
{sub && <div style={{ fontSize: 13, color: "var(--text-2)", marginTop: 2 }}>{sub}</div>}
</div>
{actions && <div style={{ display: "flex", gap: 8, flexShrink: 0 }}>{actions}</div>}
</div>
);
const Scroll = ({ children, pad = 28 }) => (
<div style={{ flex: 1, overflowY: "auto", padding: pad }}>{children}</div>
);
// ── small stat tile ──────────────────────────────────────────
const Stat = ({ label, value, delta, up, spark }) => (
<Card padding={18}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
<span style={{ fontSize: 12, color: "var(--text-2)" }}>{label}</span>
{delta && <span style={{ fontSize: 11, fontWeight: 600, color: up ? "var(--success)" : "var(--danger)" }}>
{up ? "↑" : "↓"} {delta}</span>}
</div>
<div style={{ fontSize: 28, fontWeight: 600, letterSpacing: "-0.02em", marginTop: 8 }}>{value}</div>
{spark && (
<svg viewBox="0 0 100 28" preserveAspectRatio="none" style={{ width: "100%", height: 24, marginTop: 6, display: "block" }}>
<polyline points={spark} fill="none" stroke={up ? "var(--success)" : "var(--danger)"} strokeWidth="1.5" vectorEffect="non-scaling-stroke"/>
</svg>
)}
</Card>
);
const sparkUp = "0,24 12,20 24,22 36,15 48,17 60,10 72,12 84,6 100,2";
const sparkDn = "0,6 14,8 28,7 42,12 56,11 70,16 84,15 100,20";
// ============================================================
// 1 · HOME
// ============================================================
const CRMHome = () => (
<CRMShell active="home">
<PageBar title="Good morning, Mira"
sub="3 deals need a nudge today · 8 unread in Inbox · 1 task overdue"
actions={<>
<Button variant="secondary" leadingIcon={<Icon name="bell" size={13}/>}>Notifications</Button>
<Button leadingIcon={<Icon name="plus" size={13}/>}>New deal</Button>
</>}/>
<Scroll>
{/* KPI row */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 14, marginBottom: 20 }}>
<Stat label="Open pipeline" value="$1.24M" delta="12%" up spark={sparkUp}/>
<Stat label="Won · this month" value="$284K" delta="8%" up spark={sparkUp}/>
<Stat label="Win rate · 90d" value="31%" delta="2pt" up spark={sparkUp}/>
<Stat label="Avg. response" value="2.4h" delta="0.6h" up={false} spark={sparkDn}/>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1.5fr 1fr", gap: 20 }}>
{/* Pipeline by stage */}
<Card padding={0}>
<CardHeader title="Pipeline by stage" subtitle="Q2 · 12 active deals"
style={{ padding: "16px 20px", margin: 0, borderBottom: "1px solid var(--divider)" }}
action={<Button variant="ghost" size="sm">View board </Button>}/>
<div style={{ padding: "12px 20px 18px" }}>
{[
["Lead", 5, "$420K", 100],
["Qualified", 3, "$365K", 78],
["Proposal", 2, "$280K", 60],
["Negotiation", 1, "$148K", 32],
["Won", 1, "$96K", 22],
].map(([name, n, val, w]) => (
<div key={name} style={{ padding: "8px 0" }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 6, fontSize: 13 }}>
<span>{name}</span>
<span style={{ color: "var(--text-2)" }}>{n} deals · <b style={{ color: "var(--text)" }}>{val}</b></span>
</div>
<div style={{ height: 8, borderRadius: 4, background: "var(--surface-alt)", overflow: "hidden" }}>
<div style={{ width: `${w}%`, height: "100%", background: "linear-gradient(90deg, var(--accent), color-mix(in srgb, var(--accent) 55%, transparent))", borderRadius: 4 }}/>
</div>
</div>
))}
</div>
</Card>
{/* Today's tasks */}
<Card padding={0}>
<CardHeader title="Today" subtitle="3 tasks"
style={{ padding: "16px 20px", margin: 0, borderBottom: "1px solid var(--divider)" }}
action={<Button variant="ghost" size="sm">All tasks</Button>}/>
<div style={{ padding: "6px 12px 12px" }}>
{[
["Follow up with Acme Robotics", "Overdue · 2d", true, "danger"],
["Send proposal to Halcyon", "Due 2:00pm", false, "warn"],
["Call Sun at Northstar", "Due 4:30pm", false, "neutral"],
["Prep QBR deck for Kestrel", "Tomorrow", false, "neutral"],
].map(([t, when, overdue, tone], i) => (
<div key={i} style={{ display: "flex", alignItems: "center", gap: 10, padding: "9px 8px", borderBottom: i < 3 ? "1px solid var(--divider)" : "none" }}>
<Checkbox checked={false}/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>{t}</div>
<div style={{ fontSize: 11, color: overdue ? "var(--danger)" : "var(--text-3)", marginTop: 1 }}>{when}</div>
</div>
</div>
))}
</div>
</Card>
</div>
{/* Recent activity */}
<Card padding={0} style={{ marginTop: 20 }}>
<CardHeader title="Recent activity"
style={{ padding: "16px 20px", margin: 0, borderBottom: "1px solid var(--divider)" }}
action={<Tabs variant="pill" items={[{ label: "All" }, { label: "Deals" }, { label: "Emails" }]} active="All"/>}/>
<div style={{ padding: "8px 20px 16px" }}>
{[
["MR", "#d4b8a8", "Mira Reyes", "moved", "Acme — Renewal '26 to Negotiation", "12m"],
["TR", "#c8e8a8", "Theo Roux", "logged a call with", "Sun Kim · Northstar", "1h"],
["DP", "#a8c8e8", "Devi Patel", "won", "Halcyon · Pro renewal · $24K", "3h"],
["SK", "#e8a87c", "Sun Ortiz", "added 4 contacts to", "Kestrel", "Yesterday"],
].map(([i, c, who, verb, obj, t], idx) => (
<div key={idx} style={{ display: "flex", alignItems: "center", gap: 12, padding: "10px 0", borderTop: idx ? "1px solid var(--divider)" : "none" }}>
<Avatar name={who} color={c} size={28}/>
<div style={{ flex: 1, fontSize: 13 }}>
<b style={{ fontWeight: 600 }}>{who}</b>
<span style={{ color: "var(--text-2)" }}> {verb} </span>
<b style={{ fontWeight: 500 }}>{obj}</b>
</div>
<span style={{ fontSize: 11, color: "var(--text-3)" }}>{t}</span>
</div>
))}
</div>
</Card>
</Scroll>
</CRMShell>
);
// ============================================================
// 2 · PEOPLE (contacts table)
// ============================================================
const CRMPeople = () => {
const people = [
{ id: 1, name: "Iris Tanaka", color: "#e8a87c", title: "Head of Engineering", company: "Acme Robotics", stage: "Customer", owner: "MR", last: "2h" },
{ id: 2, name: "Daniel Owusu", color: "#a8c8e8", title: "VP Product", company: "Acme Robotics", stage: "Customer", owner: "MR", last: "Yesterday" },
{ id: 3, name: "Sun Kim", color: "#c8e8a8", title: "VP Operations", company: "Northstar", stage: "Lead", owner: "TR", last: "3d" },
{ id: 4, name: "Priya Nair", color: "#c8a8e8", title: "COO", company: "Halcyon", stage: "Prospect", owner: "DP", last: "1w" },
{ id: 5, name: "Marco Lindqvist", color: "#e8c8a8", title: "Procurement", company: "Kestrel", stage: "Customer", owner: "MR", last: "4d" },
{ id: 6, name: "Naila Choudhury", color: "#a8e8c8", title: "CFO", company: "Mossbank", stage: "Lead", owner: "TR", last: "2w" },
{ id: 7, name: "Henri Lamarck", color: "#e8a8c8", title: "Founder", company: "Verra", stage: "Prospect", owner: "DP", last: "5d" },
{ id: 8, name: "Emi Hara", color: "#d4b8a8", title: "Head of Sales", company: "Tide Co.", stage: "Customer", owner: "MR", last: "1d" },
];
const stageTone = { Customer: "success", Lead: "accent", Prospect: "warn" };
return (
<CRMShell active="people">
<PageBar title="People" sub="2,418 contacts"
actions={<>
<Button variant="secondary" leadingIcon={<Icon name="doc" size={13}/>}>Import</Button>
<Button leadingIcon={<Icon name="plus" size={13}/>}>New contact</Button>
</>}/>
{/* Filter row */}
<div style={{ padding: "12px 28px", borderBottom: "1px solid var(--divider)", display: "flex", alignItems: "center", gap: 10, background: "var(--surface)" }}>
<Input placeholder="Search people" leadingIcon={<Icon name="search" size={13}/>} style={{ width: 260, padding: "6px 10px" }}/>
{["Stage", "Owner", "Company", "Last activity"].map(f => (
<div key={f} style={{
display: "flex", alignItems: "center", gap: 6, padding: "6px 10px",
border: "1px dashed var(--border)", borderRadius: "var(--radius-sm)",
fontSize: 12, color: "var(--text-2)", cursor: "pointer",
}}>{f}<Icon name="chevDown" size={11}/></div>
))}
<div style={{ flex: 1 }}/>
<FieldGroup options={["Table", "Board"]} value="Table"/>
</div>
<Scroll pad={20}>
<Table
selectable selected={[1, 5]}
columns={[
{ key: "name", label: "Name", render: r => (
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<Avatar name={r.name} color={r.color} size={28}/>
<div>
<div style={{ fontWeight: 500 }}>{r.name}</div>
<div style={{ fontSize: 11, color: "var(--text-3)" }}>{r.title}</div>
</div>
</div>
)},
{ key: "company", label: "Company", render: r => (
<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
<span style={{ width: 20, height: 20, borderRadius: 5, background: "var(--surface-alt)", display: "inline-flex", alignItems: "center", justifyContent: "center", fontSize: 10, fontWeight: 700, color: "var(--text-2)" }}>{r.company[0]}</span>
{r.company}
</span>
)},
{ key: "stage", label: "Stage", render: r => <Badge tone={stageTone[r.stage]} dot>{r.stage}</Badge> },
{ key: "owner", label: "Owner", render: r => <Avatar name={r.owner} size={24}/> },
{ key: "last", label: "Last activity" },
{ key: "act", label: "", align: "right", width: 40, render: () => <IconButton name="more" size="sm" label="More"/> },
]}
rows={people}
/>
</Scroll>
</CRMShell>
);
};
// ============================================================
// 3 · COMPANY RECORD (detail)
// ============================================================
const CRMRecord = () => (
<CRMShell active="companies">
<PageBar breadcrumb="Companies Acme Robotics" title="Acme Robotics"
actions={<>
<Button variant="secondary" leadingIcon={<Icon name="star" size={13}/>}>Follow</Button>
<Button variant="secondary">Share</Button>
<Button leadingIcon={<Icon name="plus" size={13}/>}>Log activity</Button>
</>}/>
<div style={{ display: "grid", gridTemplateColumns: "320px 1fr", flex: 1, overflow: "hidden" }}>
{/* Details rail */}
<div style={{ borderRight: "1px solid var(--divider)", overflowY: "auto", padding: "20px 22px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 14, marginBottom: 18 }}>
<div style={{ width: 52, height: 52, borderRadius: 12, background: "linear-gradient(135deg, #ff8a3a, #f43f5e)", color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 22, fontWeight: 700 }}>A</div>
<div>
<div style={{ display: "flex", gap: 6 }}>
<Badge tone="success" dot>Customer</Badge>
<Badge tone="accent">Tier 1</Badge>
</div>
<div style={{ fontSize: 12, color: "var(--text-3)", marginTop: 6 }}>acme-robotics.io</div>
</div>
</div>
{[
["Industry", "Industrial automation"],
["Employees", "210"],
["Owner", "Mira Reyes"],
["Renewal", "Sept 1, 2026"],
["ARR", "$148,000"],
["Health", "Strong"],
].map(([k, v]) => (
<div key={k} style={{ display: "grid", gridTemplateColumns: "100px 1fr", gap: 10, padding: "8px 0", fontSize: 13, borderBottom: "1px solid var(--divider)" }}>
<span style={{ color: "var(--text-3)" }}>{k}</span>
<span style={{ fontWeight: 500 }}>{v}</span>
</div>
))}
<div style={{ fontSize: 11, color: "var(--text-3)", textTransform: "uppercase", letterSpacing: "0.06em", fontWeight: 500, margin: "18px 0 8px" }}>Open deals · 3</div>
{[
["Renewal '26", "$148K", "Negotiation", 70],
["Vision platform", "$62K", "Discovery", 30],
["Edge SDK pilot", "$24K", "Proposal", 45],
].map(([n, v, s, p]) => (
<div key={n} style={{ padding: "10px 12px", borderRadius: "var(--radius)", background: "var(--surface)", border: "1px solid var(--border)", marginBottom: 8 }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 8, fontSize: 13, marginBottom: 6 }}>
<span style={{ fontWeight: 500, minWidth: 0, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{n}</span>
<span style={{ color: "var(--text-2)", flexShrink: 0 }}>{v}</span>
</div>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 11, color: "var(--text-3)", marginBottom: 4 }}>
<span>{s}</span><span>{p}%</span>
</div>
<div style={{ height: 3, borderRadius: 2, background: "var(--surface-alt)", overflow: "hidden" }}>
<div style={{ width: `${p}%`, height: "100%", background: p > 60 ? "var(--success)" : "var(--accent)" }}/>
</div>
</div>
))}
</div>
{/* Main: tabs + activity */}
<div style={{ display: "flex", flexDirection: "column", overflow: "hidden" }}>
<div style={{ padding: "0 28px", borderBottom: "1px solid var(--divider)", background: "var(--surface)" }}>
<Tabs items={[{ label: "Activity", count: 28 }, { label: "Notes", count: 7 }, { label: "People", count: 4 }, { label: "Files" }, { label: "Emails" }]} active="Activity"/>
</div>
<Scroll>
{/* KPIs */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 12, marginBottom: 22 }}>
{[["Pipeline", "$234K", "+$12K 30d"], ["Lifetime", "$420K", "won"], ["Open deals", "3", "1 stalled"], ["Health", "82/100", "stable"]].map(([l, v, s]) => (
<Card key={l} padding={14}>
<div style={{ fontSize: 11, color: "var(--text-3)" }}>{l}</div>
<div style={{ fontSize: 20, fontWeight: 600, marginTop: 4 }}>{v}</div>
<div style={{ fontSize: 11, color: "var(--text-2)", marginTop: 2 }}>{s}</div>
</Card>
))}
</div>
{/* Composer */}
<div style={{ display: "flex", gap: 10, padding: 12, borderRadius: "var(--radius)", border: "1px solid var(--border)", background: "var(--surface)", marginBottom: 20 }}>
<Avatar name="Mira Reyes" color="#d4b8a8" size={28}/>
<input placeholder="Log a note, call, or email…" style={{ flex: 1, border: "none", outline: "none", background: "transparent", fontSize: 13, fontFamily: "var(--font-sans)", color: "var(--text)" }}/>
<Button size="sm">Log</Button>
</div>
{/* Timeline */}
<div style={{ position: "relative", paddingLeft: 22 }}>
<div style={{ position: "absolute", left: 9, top: 6, bottom: 6, width: 1, background: "var(--border)" }}/>
{[
["var(--success)", "Deal moved to Negotiation", "Mira · Renewal '26 · $148,000", "2h ago"],
["var(--accent)", "Email sent · proposal v4", "To Iris, Daniel — opened 6 times", "Yesterday"],
["var(--warn)", "Call logged · 32 min", "Theo — walkthrough with ops lead", "2d ago"],
["var(--text-3)", "Note added", "They need SSO + SCIM by Sept — gating item", "4d ago"],
].map(([dot, t, sub, when], i) => (
<div key={i} style={{ position: "relative", marginBottom: 18 }}>
<span style={{ position: "absolute", left: -19, top: 3, width: 11, height: 11, borderRadius: "50%", background: dot, boxShadow: "0 0 0 3px var(--bg)" }}/>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={{ fontSize: 13, fontWeight: 500 }}>{t}</span>
<span style={{ fontSize: 11, color: "var(--text-3)" }}>{when}</span>
</div>
<div style={{ fontSize: 12, color: "var(--text-2)", marginTop: 2 }}>{sub}</div>
</div>
))}
</div>
</Scroll>
</div>
</div>
</CRMShell>
);
// ============================================================
// 4 · PIPELINE (deals kanban)
// ============================================================
const CRMPipeline = () => {
const columns = [
{ name: "Lead", total: "$420K", tone: "var(--text-3)", deals: [
{ co: "Mossbank", title: "New logo · Platform", val: "$120K", owner: "TR", color: "#a8e8c8", days: 3, tags: ["Inbound"] },
{ co: "Verra", title: "Vision pilot", val: "$84K", owner: "DP", color: "#e8a8c8", days: 8 },
{ co: "Tide Co.", title: "Expansion", val: "$96K", owner: "MR", color: "#d4b8a8", days: 1, tags: ["Warm"] },
]},
{ name: "Qualified", total: "$365K", tone: "var(--accent)", deals: [
{ co: "Northstar", title: "Carrier API", val: "$148K", owner: "MR", color: "#c8e8a8", days: 5, tags: ["Champion"] },
{ co: "Kestrel", title: "Renewal + seats", val: "$120K", owner: "TR", color: "#a8c8e8", days: 12 },
]},
{ name: "Proposal", total: "$280K", tone: "var(--warn)", deals: [
{ co: "Halcyon", title: "Pro renewal", val: "$180K", owner: "DP", color: "#c8a8e8", days: 2, tags: ["Sent"] },
{ co: "Acme", title: "Edge SDK pilot", val: "$24K", owner: "MR", color: "#e8c8a8", days: 6 },
]},
{ name: "Negotiation", total: "$148K", tone: "#f59e0b", deals: [
{ co: "Acme Robotics", title: "Renewal '26", val: "$148K", owner: "MR", color: "#e8a87c", days: 1, tags: ["Hot", "Closing"] },
]},
{ name: "Won", total: "$96K", tone: "var(--success)", deals: [
{ co: "Lowell Works", title: "Annual plan", val: "$96K", owner: "TR", color: "#a8c8e8", days: 0, tags: ["Closed"] },
]},
];
return (
<CRMShell active="deals">
<PageBar title="Deals" sub="12 active · $1.24M open pipeline"
actions={<>
<Button variant="secondary" leadingIcon={<Icon name="bar" size={13}/>}>Forecast</Button>
<Button leadingIcon={<Icon name="plus" size={13}/>}>New deal</Button>
</>}/>
<div style={{ padding: "12px 28px", borderBottom: "1px solid var(--divider)", display: "flex", alignItems: "center", gap: 10, background: "var(--surface)" }}>
<Input placeholder="Search deals" leadingIcon={<Icon name="search" size={13}/>} style={{ width: 240, padding: "6px 10px" }}/>
{["Owner", "Close date", "Value"].map(f => (
<div key={f} style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 10px", border: "1px dashed var(--border)", borderRadius: "var(--radius-sm)", fontSize: 12, color: "var(--text-2)", cursor: "pointer" }}>{f}<Icon name="chevDown" size={11}/></div>
))}
</div>
<div style={{ flex: 1, overflow: "auto", padding: 20, background: "var(--bg)" }}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(5, minmax(240px, 1fr))", gap: 14, height: "100%", minWidth: "max-content" }}>
{columns.map(col => (
<div key={col.name} style={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "0 4px 10px" }}>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: col.tone }}/>
<span style={{ fontSize: 13, fontWeight: 600 }}>{col.name}</span>
<span style={{ fontSize: 12, color: "var(--text-3)" }}>{col.deals.length}</span>
<span style={{ flex: 1 }}/>
<span style={{ fontSize: 12, color: "var(--text-2)", fontVariantNumeric: "tabular-nums" }}>{col.total}</span>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 10, flex: 1, overflowY: "auto", paddingBottom: 8 }}>
{col.deals.map((d, i) => (
<div key={i} style={{ padding: 14, borderRadius: "var(--radius)", background: "var(--surface)", border: "1px solid var(--border)", boxShadow: "var(--shadow-sm)", cursor: "grab" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }}>
<span style={{ width: 22, height: 22, borderRadius: 6, background: "var(--surface-alt)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 10, fontWeight: 700, color: "var(--text-2)" }}>{d.co[0]}</span>
<span style={{ fontSize: 12, color: "var(--text-2)", flex: 1, minWidth: 0, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{d.co}</span>
<IconButton name="more" size="sm" label="More"/>
</div>
<div style={{ fontSize: 14, fontWeight: 600, letterSpacing: "-0.01em" }}>{d.title}</div>
<div style={{ fontSize: 18, fontWeight: 600, marginTop: 6, fontVariantNumeric: "tabular-nums" }}>{d.val}</div>
{d.tags && <div style={{ display: "flex", gap: 6, marginTop: 10, flexWrap: "wrap" }}>
{d.tags.map(t => <Badge key={t} tone={t === "Hot" || t === "Closing" ? "danger" : t === "Closed" ? "success" : "neutral"}>{t}</Badge>)}
</div>}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginTop: 12, paddingTop: 10, borderTop: "1px solid var(--divider)" }}>
<Avatar name={d.owner} color={d.color} size={22}/>
<span style={{ fontSize: 11, color: d.days <= 1 ? "var(--danger)" : "var(--text-3)" }}>
{d.days === 0 ? "Closed" : d.days === 1 ? "Closes today" : `${d.days}d to close`}
</span>
</div>
</div>
))}
<button style={{ padding: "8px", borderRadius: "var(--radius)", border: "1px dashed var(--border)", background: "transparent", color: "var(--text-3)", fontSize: 12, fontFamily: "var(--font-sans)", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: 6 }}>
<Icon name="plus" size={12}/> Add deal
</button>
</div>
</div>
))}
</div>
</div>
</CRMShell>
);
};
// ============================================================
// 5 · INBOX
// ============================================================
const CRMInbox = () => {
const threads = [
["IT", "#e8a87c", "Iris Tanaka", "Acme Robotics", "Re: Renewal terms — forwarding to Marco to start paper.", "10:42", 1, true],
["SK", "#c8e8a8", "Sun Kim", "Northstar", "Could we move the demo to Thursday?", "9:18", 1, false],
["DP", "#a8c8e8", "Devi Patel", "Internal", "Closed Halcyon! 🎉 Logging it now.", "Tue", 0, false],
["PN", "#c8a8e8", "Priya Nair", "Halcyon", "Thanks for the deck — a few questions inside.", "Mon", 0, false],
["ML", "#e8c8a8", "Marco Lindqvist", "Kestrel", "Procurement needs the security packet.", "May 28", 0, false],
];
return (
<CRMShell active="inbox">
<div style={{ display: "grid", gridTemplateColumns: "340px 1fr", flex: 1, overflow: "hidden" }}>
{/* List */}
<div style={{ borderRight: "1px solid var(--divider)", display: "flex", flexDirection: "column", overflow: "hidden" }}>
<div style={{ padding: "16px 18px 10px" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>Inbox</h1>
<Button size="sm" leadingIcon={<Icon name="plus" size={12}/>}>Compose</Button>
</div>
<Input placeholder="Search messages" leadingIcon={<Icon name="search" size={13}/>}/>
</div>
<div style={{ padding: "0 12px 6px", display: "flex", gap: 6 }}>
{["All", "Unread", "Assigned", "Deals"].map((t, i) => (
<span key={t} style={{ padding: "5px 10px", borderRadius: 999, fontSize: 12, fontWeight: 500, cursor: "pointer", background: i === 0 ? "var(--text)" : "transparent", color: i === 0 ? "var(--bg)" : "var(--text-2)" }}>{t}</span>
))}
</div>
<div style={{ flex: 1, overflowY: "auto", padding: "8px 8px" }}>
{threads.map((th, i) => (
<div key={i} style={{ display: "flex", gap: 12, padding: 12, borderRadius: "var(--radius)", cursor: "pointer", marginBottom: 2, background: th[7] ? "var(--surface)" : "transparent", boxShadow: th[7] ? "var(--shadow-sm)" : "none" }}>
<Avatar name={th[2]} color={th[1]} size={38}/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 6 }}>
<span style={{ fontSize: 13, fontWeight: th[6] ? 700 : 500, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{th[2]}</span>
<span style={{ fontSize: 11, color: "var(--text-3)", flexShrink: 0 }}>{th[5]}</span>
</div>
<div style={{ fontSize: 11, color: "var(--accent)", fontWeight: 500, marginTop: 1 }}>{th[3]}</div>
<div style={{ fontSize: 12, color: th[6] ? "var(--text)" : "var(--text-2)", marginTop: 2, display: "-webkit-box", WebkitLineClamp: 1, WebkitBoxOrient: "vertical", overflow: "hidden" }}>{th[4]}</div>
</div>
{th[6] > 0 && <span style={{ width: 8, height: 8, borderRadius: "50%", background: "var(--accent)", alignSelf: "center", flexShrink: 0 }}/>}
</div>
))}
</div>
</div>
{/* Conversation */}
<div style={{ display: "flex", flexDirection: "column", overflow: "hidden" }}>
<div style={{ padding: "14px 24px", borderBottom: "1px solid var(--divider)", display: "flex", alignItems: "center", gap: 14, background: "var(--surface)" }}>
<Avatar name="Iris Tanaka" color="#e8a87c" size={40}/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 15, fontWeight: 600 }}>Iris Tanaka</div>
<div style={{ fontSize: 12, color: "var(--text-3)" }}>iris@acme.io · Head of Engineering</div>
</div>
<Button variant="secondary" size="sm">Open record</Button>
<IconButton name="more" variant="secondary" label="More"/>
</div>
{/* Linked deal */}
<div style={{ padding: "12px 24px 0" }}>
<div style={{ display: "flex", gap: 12, padding: 12, borderRadius: "var(--radius)", background: "var(--surface-2)", border: "1px solid var(--border)", alignItems: "center" }}>
<span style={{ width: 36, height: 36, borderRadius: 8, background: "linear-gradient(135deg, #ff8a3a, #f43f5e)", color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 700 }}>A</span>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>Acme Renewal '26</div>
<div style={{ fontSize: 11, color: "var(--text-3)" }}>Negotiation · $148,000 · closes Jun 12</div>
</div>
<Badge tone="warn" dot>Closing soon</Badge>
</div>
</div>
{/* Messages */}
<div style={{ flex: 1, overflowY: "auto", padding: "20px 24px" }}>
<div style={{ textAlign: "center", fontSize: 11, color: "var(--text-3)", margin: "0 0 16px" }}>Today</div>
{[
[false, "Hi Mira — the team reviewed the renewal terms and they look great. I'm forwarding to Marco in procurement to start the paperwork.", "10:14"],
[true, "That's wonderful news, Iris. I'll send Marco the order form today. Anything he needs from our side to move quickly?", "10:28"],
[false, "Just the security packet (SOC 2 + the SSO/SCIM details). If you can get that over, we should be able to close by the 12th.", "10:42"],
].map(([mine, body, time], i) => (
<div key={i} style={{ display: "flex", flexDirection: mine ? "row-reverse" : "row", gap: 10, alignItems: "flex-end", marginBottom: 12 }}>
{!mine && <Avatar name="Iris Tanaka" color="#e8a87c" size={28}/>}
<div style={{ maxWidth: "62%" }}>
<div style={{ padding: "10px 14px", borderRadius: 16, background: mine ? "var(--text)" : "var(--surface-2)", color: mine ? "var(--bg)" : "var(--text)", fontSize: 13, lineHeight: 1.45, borderBottomRightRadius: mine ? 4 : 16, borderBottomLeftRadius: mine ? 16 : 4, boxShadow: "var(--shadow-sm)" }}>{body}</div>
<div style={{ fontSize: 11, color: "var(--text-3)", marginTop: 4, textAlign: mine ? "right" : "left" }}>{time}</div>
</div>
</div>
))}
</div>
{/* Composer */}
<div style={{ padding: 16, borderTop: "1px solid var(--divider)" }}>
<div style={{ display: "flex", alignItems: "flex-end", gap: 10, padding: 8, borderRadius: 14, background: "var(--surface)", border: "1px solid var(--border)" }}>
<IconButton name="plus" size="sm" variant="ghost" label="Attach"/>
<textarea placeholder="Reply to Iris" rows="1" style={{ flex: 1, border: "none", outline: "none", background: "transparent", fontFamily: "var(--font-sans)", fontSize: 13, color: "var(--text)", resize: "none", padding: "6px 0" }}/>
<Button size="sm">Send</Button>
</div>
</div>
</div>
</div>
</CRMShell>
);
};
// ============================================================
// 6 · REPORTS
// ============================================================
const CRMReports = () => {
const months = ["Dec","Jan","Feb","Mar","Apr","May"];
const won = [180, 220, 195, 260, 240, 284];
const goal = 250;
const maxV = 320;
return (
<CRMShell active="reports">
<PageBar title="Reports" sub="Sales performance · last 6 months"
actions={<>
<Select value="Last 6 months"/>
<Button variant="secondary">Export</Button>
<Button leadingIcon={<Icon name="plus" size={13}/>}>New report</Button>
</>}/>
<Scroll>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 14, marginBottom: 20 }}>
<Stat label="Bookings · MTD" value="$284K" delta="18%" up spark={sparkUp}/>
<Stat label="New pipeline" value="$612K" delta="9%" up spark={sparkUp}/>
<Stat label="Avg deal size" value="$84K" delta="2%" up={false} spark={sparkDn}/>
<Stat label="Sales cycle" value="34d" delta="3d" up spark={sparkUp}/>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1.5fr 1fr", gap: 20 }}>
{/* Bookings vs goal */}
<Card padding={24}>
<CardHeader title="Bookings vs goal" subtitle="Closed-won by month"
action={<Tabs variant="pill" items={[{ label: "Monthly" }, { label: "Quarterly" }]} active="Monthly"/>}/>
<div style={{ position: "relative", height: 220, marginTop: 8 }}>
{/* goal line */}
<div style={{ position: "absolute", left: 0, right: 0, bottom: `${(goal / maxV) * 100}%`, borderTop: "2px dashed var(--accent)", zIndex: 1 }}>
<span style={{ position: "absolute", right: 0, top: -18, fontSize: 11, color: "var(--accent)", fontWeight: 600, background: "var(--surface)", padding: "0 4px" }}>Goal ${goal}K</span>
</div>
<div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "flex-end", gap: 18, paddingRight: 4 }}>
{won.map((v, i) => (
<div key={i} style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "flex-end", height: "100%", gap: 8 }}>
<div style={{ width: "100%", maxWidth: 44, height: `${(v / maxV) * 100}%`, borderRadius: "6px 6px 0 0", background: v >= goal ? "linear-gradient(180deg, var(--success), color-mix(in srgb, var(--success) 50%, transparent))" : "linear-gradient(180deg, var(--accent), color-mix(in srgb, var(--accent) 45%, transparent))" }}/>
<span style={{ fontSize: 11, color: "var(--text-3)" }}>{months[i]}</span>
</div>
))}
</div>
</div>
</Card>
{/* Leaderboard */}
<Card padding={0}>
<CardHeader title="Team leaderboard" subtitle="This month"
style={{ padding: "16px 20px", margin: 0, borderBottom: "1px solid var(--divider)" }}/>
<div style={{ padding: "6px 16px 12px" }}>
{[
["MR", "#d4b8a8", "Mira Reyes", "$124K", 100],
["DP", "#a8c8e8", "Devi Patel", "$86K", 70],
["TR", "#c8e8a8", "Theo Roux", "$62K", 50],
["SK", "#e8a87c", "Sun Ortiz", "$48K", 39],
].map(([i, c, n, v, p], idx) => (
<div key={idx} style={{ display: "grid", gridTemplateColumns: "20px 28px 1fr auto", gap: 10, alignItems: "center", padding: "9px 0", borderBottom: idx < 3 ? "1px solid var(--divider)" : "none" }}>
<span style={{ fontSize: 12, color: "var(--text-3)" }}>#{idx + 1}</span>
<Avatar name={n} color={c} size={28}/>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>{n}</div>
<div style={{ height: 3, borderRadius: 2, background: "var(--surface-alt)", overflow: "hidden", marginTop: 4 }}>
<div style={{ width: `${p}%`, height: "100%", background: "var(--accent)" }}/>
</div>
</div>
<span style={{ fontSize: 13, fontWeight: 600 }}>{v}</span>
</div>
))}
</div>
</Card>
</div>
</Scroll>
</CRMShell>
);
};
// ============================================================
// 7 · SETTINGS · members (admin)
// ============================================================
const CRMSettings = () => {
const members = [
{ id: 1, name: "Mira Reyes", email: "mira@northwind.io", color: "#d4b8a8", role: "Owner", status: "Active", last: "now" },
{ id: 2, name: "Theo Roux", email: "theo@northwind.io", color: "#c8e8a8", role: "Admin", status: "Active", last: "12 min" },
{ id: 3, name: "Devi Patel", email: "devi@northwind.io", color: "#a8c8e8", role: "Admin", status: "Active", last: "1 hour" },
{ id: 4, name: "Sun Ortiz", email: "sun@northwind.io", color: "#e8a87c", role: "Member", status: "Active", last: "today" },
{ id: 5, name: "Linnea Berg", email: "linnea@northwind.io", color: "#c8a8e8", role: "Member", status: "Invited", last: "" },
{ id: 6, name: "Jamal Frost", email: "jamal@partner.co", color: "#a8e8c8", role: "Guest", status: "Active", last: "3 days" },
];
const roleTone = { Owner: "accent", Admin: "info", Member: "success", Guest: "neutral" };
return (
<CRMShell active="settings">
<div style={{ display: "grid", gridTemplateColumns: "200px 1fr", flex: 1, overflow: "hidden" }}>
{/* Settings subnav */}
<div style={{ borderRight: "1px solid var(--divider)", padding: "18px 12px", overflowY: "auto" }}>
<div style={{ fontSize: 11, color: "var(--text-3)", textTransform: "uppercase", letterSpacing: "0.06em", fontWeight: 500, padding: "0 10px 8px" }}>Workspace</div>
{["General", "Members", "Roles", "Pipeline stages", "Integrations", "Billing", "API"].map((s, i) => (
<div key={s} style={{ padding: "7px 10px", borderRadius: "var(--radius-sm)", fontSize: 13, cursor: "pointer", marginBottom: 2, color: i === 1 ? "var(--text)" : "var(--text-2)", fontWeight: i === 1 ? 500 : 400, background: i === 1 ? "var(--surface)" : "transparent", boxShadow: i === 1 ? "var(--shadow-sm)" : "none" }}>{s}</div>
))}
<div style={{ fontSize: 11, color: "var(--text-3)", textTransform: "uppercase", letterSpacing: "0.06em", fontWeight: 500, padding: "16px 10px 8px" }}>Personal</div>
{["Profile", "Notifications", "Sessions"].map(s => (
<div key={s} style={{ padding: "7px 10px", borderRadius: "var(--radius-sm)", fontSize: 13, color: "var(--text-2)", cursor: "pointer", marginBottom: 2 }}>{s}</div>
))}
</div>
{/* Members panel */}
<div style={{ display: "flex", flexDirection: "column", overflow: "hidden" }}>
<PageBar breadcrumb="Settings" title="Members"
sub="Manage who can access the Northwind workspace."
actions={<>
<Button variant="secondary">Export CSV</Button>
<Button leadingIcon={<Icon name="plus" size={13}/>}>Invite people</Button>
</>}/>
<div style={{ padding: "12px 28px", borderBottom: "1px solid var(--divider)", display: "flex", alignItems: "center", gap: 10, background: "var(--surface)" }}>
<Input placeholder="Search members" leadingIcon={<Icon name="search" size={13}/>} style={{ width: 260, padding: "6px 10px" }}/>
<div style={{ flex: 1 }}/>
<span style={{ fontSize: 12, color: "var(--text-2)" }}><b style={{ color: "var(--text)" }}>6</b> members · 1 invited</span>
</div>
<Scroll pad={20}>
<Table
columns={[
{ key: "name", label: "Name", render: r => (
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<Avatar name={r.name} color={r.color} size={28}/>
<div>
<div style={{ fontWeight: 500 }}>{r.name}</div>
<div style={{ fontSize: 11, color: "var(--text-3)" }}>{r.email}</div>
</div>
</div>
)},
{ key: "role", label: "Role", render: r => (
<span style={{ display: "inline-flex", alignItems: "center", gap: 6, padding: "3px 9px", borderRadius: "var(--radius-sm)", background: "var(--accent-soft)", color: "var(--accent)", fontSize: 12, fontWeight: 500, cursor: "pointer" }}>
{r.role}<Icon name="chevDown" size={11}/>
</span>
)},
{ key: "status", label: "Status", render: r => (
<Badge dot tone={r.status === "Active" ? "success" : "warn"}>{r.status}</Badge>
)},
{ key: "last", label: "Last active" },
{ key: "act", label: "", align: "right", width: 40, render: () => <IconButton name="more" size="sm" label="More"/> },
]}
rows={members}
/>
<div style={{ marginTop: 18 }}>
<Banner tone="warn" title="1 invitation pending"
action={<Button size="sm" variant="secondary">Resend</Button>}>
linnea@northwind.io hasn't accepted yet sent 3 days ago.
</Banner>
</div>
</Scroll>
</div>
</div>
</CRMShell>
);
};
Object.assign(window, {
CRMHome, CRMPeople, CRMRecord, CRMPipeline, CRMInbox, CRMReports, CRMSettings,
});

View File

@@ -0,0 +1,65 @@
# Vibn Marketplace
A small extension to **vibn-ai-templates** for building two-sided marketplaces. Ships with a fully composed example brand — Atlas — and a set of marketplace-specific components.
## What's in it
- **`marketplace-tokens.css`** — A new `theme-atlas` (warm-editorial, Airbnb school) plus marketplace-specific tokens (`--listing-radius`, `--rating`, `--map-pin`, etc.). Stacks on top of the base `tokens.css`.
- **`marketplace-components.jsx`** — Components you need for marketplace UX, none of which exist in the base library:
- **Listings** · `ListingCard`, `ListingCardHorizontal`, `PhotoGallery`, `PhotoSlot`
- **Reviews & trust** · `RatingStars`, `ReviewCard`, `RatingsSummary`, `TrustBadge` (verified, superhost, instant, top-rated, new)
- **Pricing** · `PriceTag`, `PriceBreakdown` (receipt with discounts)
- **Host/profile** · `HostHeader` with KPI strip
- **Messaging** · `MessageBubble`
- **Search & discovery** · `SearchBar` (destination + dates + guests), `CategoryRail`, `FilterChips`, `AmenityChip`
- **Map & calendar** · `MiniMap` (stylized SVG with price pins), `CalendarMonth`, `AvailabilityHeatmap`
- **Dashboard** · `StatTile`
- **`marketplace-shells.jsx`** — Two layout shells:
- `MarketplaceTopShell` — public-facing top nav with inline search, host-mode toggle, big footer
- `MarketplaceDashboardShell` — sidebar with Guest/Host segmented toggle (the canonical two-sided marketplace pattern)
## Architecture
Marketplace components depend on the base library (`vibn-ai-templates`). Load order:
```html
<link rel="stylesheet" href="vibn-ai-templates/tokens.css">
<link rel="stylesheet" href="vibn-marketplace/marketplace-tokens.css">
<script type="text/babel" src="vibn-ai-templates/icons.jsx"></script>
<script type="text/babel" src="vibn-ai-templates/components.jsx"></script>
<script type="text/babel" src="vibn-ai-templates/shells.jsx"></script>
<script type="text/babel" src="vibn-marketplace/marketplace-components.jsx"></script>
<script type="text/babel" src="vibn-marketplace/marketplace-shells.jsx"></script>
```
Wrap your app in `class="theme-atlas"` and you're done.
## Atlas — the worked example
`Atlas Marketplace Templates.html` is a full 8-page sample marketplace using only these primitives. Pages cover the full lifecycle:
| # | Page | What it demonstrates |
|---|---|---|
| 01 | Home / discovery | Hero, inline search bar, category rail, featured grid, cities, how-it-works |
| 02 | Search + map | Split list/map layout, filter chips, sort, horizontal listing cards |
| 03 | Listing detail | Photo gallery, summary, amenities, dual-month calendar, ratings summary, reviews, host header, sticky booking card with price breakdown |
| 04 | Checkout | Payment, message-to-host, ground rules, sticky summary |
| 05 | Guest dashboard | Hero upcoming-trip card, past trips, wishlist |
| 06 | Messages | Inbox + thread + trip context card + composer |
| 07 | Host dashboard | KPI strip, earnings bar chart, upcoming check-ins, availability heatmap, recent reviews |
| 08 | New listing wizard | Step 3/6 — photos, title, description with live preview card |
## What's still hand-written
`atlas-pages.jsx` is page-specific composition — these are *templates*, not reusable components. They show how to assemble the kit. Use them as starting points and rip out anything you don't need.
## Marketplace adaptation
To pivot the kit to a different marketplace type:
- **Services (Upwork/Fiverr)** — Swap `PhotoGallery` for a portfolio grid, `PriceTag` for hourly-rate ranges, `CalendarMonth` for availability slots. The `HostHeader` and `MessageBubble` work as-is.
- **Goods (Etsy)** — Add a quantity/variant selector. The listing card is already photo-led; just adjust subtitle to surface seller name and shipping.
- **On-demand (delivery, rides)** — Drop the calendar; replace with a live map. `TrustBadge` already has variants you can rename. The dashboard shell is built for two-sided role switching.
The CSS variable approach means you can also restyle the marketplace to feel utilitarian (e.g. swap `theme-atlas` for `theme-minimal` or `theme-dark`) without changing a single component.

View File

@@ -0,0 +1,870 @@
// ============================================================
// vibn-marketplace · marketplace-components.jsx
// ------------------------------------------------------------
// Components specific to two-sided marketplaces. Built on top
// of vibn-ai-templates/components.jsx — load that first so
// Icon, Button, Card, Badge, Avatar etc. are available on the
// global scope.
//
// All visual properties read from CSS variables so any theme
// (atlas / minimal / dark / glass / editorial) restyles them.
//
// Components:
// PhotoSlot, ListingCard, ListingCardHorizontal,
// RatingStars, ReviewCard, RatingsSummary,
// PriceTag, PriceBreakdown,
// HostHeader, MessageBubble, MessageThread,
// TrustBadge, AmenityChip, FilterChips,
// SearchBar, MiniMap, CalendarMonth, AvailabilityHeatmap,
// CategoryRail, PhotoGallery, StatTile.
// ============================================================
// ── PhotoSlot ────────────────────────────────────────────────
// Placeholder image — stripe pattern with a label. Used wherever
// the user is expected to plug in a real photo.
const PhotoSlot = ({ label = "Photo", aspect = "4/3", tone = "warm", style, children }) => {
const palettes = {
warm: ["#efe2cc", "#e3d2b3"],
sage: ["#e0e8d4", "#cfdcb9"],
blush: ["#f1dcd2", "#e6c5b8"],
ocean: ["#d6e0e8", "#bccada"],
night: ["#1f2530", "#2a3140"],
sand: ["#ece4d3", "#dccfb3"],
};
const [a, b] = palettes[tone] || palettes.warm;
const isDark = tone === "night";
return (
<div style={{
width: "100%", aspectRatio: aspect, position: "relative",
overflow: "hidden", borderRadius: "inherit",
background: a,
backgroundImage: `repeating-linear-gradient(135deg, ${a} 0 14px, ${b} 14px 15px)`,
...style,
}}>
{label && (
<span style={{
position: "absolute", left: 10, bottom: 10,
fontFamily: "var(--font-mono)", fontSize: 10,
letterSpacing: "0.08em", textTransform: "uppercase",
padding: "3px 8px",
color: isDark ? "rgba(255,255,255,0.7)" : "rgba(26,22,18,0.55)",
background: isDark ? "rgba(0,0,0,0.4)" : "rgba(255,255,255,0.7)",
border: `1px solid ${isDark ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.06)"}`,
backdropFilter: "blur(4px)",
}}>{label}</span>
)}
{children}
</div>
);
};
// ── RatingStars ─────────────────────────────────────────────
// `value` 05 (fractional ok). `size` and `gap` adjust scale.
const RatingStars = ({ value = 4.5, max = 5, size = 14, showValue = true, style }) => {
const stars = [];
for (let i = 0; i < max; i++) {
const fill = Math.max(0, Math.min(1, value - i));
stars.push(
<span key={i} style={{
position: "relative", display: "inline-block", width: size, height: size,
}}>
{/* Empty */}
<svg width={size} height={size} viewBox="0 0 24 24" fill="var(--rating-empty)"
style={{ position: "absolute", inset: 0 }} aria-hidden="true">
<path d="m12 3 2.6 6.2 6.7.5-5.1 4.4 1.6 6.6L12 17.3 6.2 20.7l1.6-6.6L2.7 9.7l6.7-.5z"/>
</svg>
{/* Filled overlay clipped to fill */}
{fill > 0 && (
<span style={{
position: "absolute", inset: 0, width: `${fill * 100}%`,
overflow: "hidden",
}}>
<svg width={size} height={size} viewBox="0 0 24 24" fill="var(--rating)" aria-hidden="true">
<path d="m12 3 2.6 6.2 6.7.5-5.1 4.4 1.6 6.6L12 17.3 6.2 20.7l1.6-6.6L2.7 9.7l6.7-.5z"/>
</svg>
</span>
)}
</span>
);
}
return (
<span style={{
display: "inline-flex", alignItems: "center", gap: 6, ...style,
}}>
<span style={{ display: "inline-flex", gap: 1 }}>{stars}</span>
{showValue && <span style={{
fontSize: "var(--text-sm)", color: "var(--text)",
fontWeight: "var(--weight-medium)", fontVariantNumeric: "tabular-nums",
}}>{value.toFixed(2)}</span>}
</span>
);
};
// ── TrustBadge ───────────────────────────────────────────────
const TrustBadge = ({ kind = "verified", label, style }) => {
const kinds = {
verified: { icon: "shield", txt: "Verified host", tone: "info" },
superhost: { icon: "star", txt: "Superhost", tone: "warn" },
"top-rated": { icon: "star", txt: "Top rated", tone: "accent" },
instant: { icon: "bolt", txt: "Instant book", tone: "success"},
new: { icon: "spark", txt: "New on Atlas", tone: "info" },
};
const k = kinds[kind] || kinds.verified;
return (
<Badge tone={k.tone} leadingIcon={<Icon name={k.icon} size={11} stroke={2}/>} style={style}>
{label || k.txt}
</Badge>
);
};
// ── PriceTag ─────────────────────────────────────────────────
// Inline pricing display. `period` like "/ night", "/ project".
// `original` shows a strikethrough above for discounts.
const PriceTag = ({ amount, currency = "$", period, original, size = "md", emphasis = true, style }) => {
const sizing = {
sm: { num: 14, lbl: 11 },
md: { num: 20, lbl: 12 },
lg: { num: 28, lbl: 13 },
xl: { num: 36, lbl: 14 },
}[size];
return (
<span style={{
display: "inline-flex", alignItems: "baseline", gap: 6,
color: emphasis ? "var(--text)" : "var(--text-2)",
...style,
}}>
{original && (
<span style={{
fontSize: sizing.lbl, color: "var(--text-3)",
textDecoration: "line-through", marginRight: 2,
}}>{currency}{original}</span>
)}
<span style={{
fontSize: sizing.num, fontWeight: "var(--weight-semibold)",
letterSpacing: "-0.01em", fontVariantNumeric: "tabular-nums",
}}>{currency}{amount}</span>
{period && <span style={{ fontSize: sizing.lbl, color: "var(--text-2)" }}>{period}</span>}
</span>
);
};
// ── ListingCard (vertical) ───────────────────────────────────
// Standard photo-on-top card.
// listing.photo — { label, tone } (placeholder hints)
// listing.title — string
// listing.subtitle— string (location, host, dates etc.)
// listing.price — { amount, period }
// listing.rating — number
// listing.reviews — number
// listing.badges — array of TrustBadge kinds
// listing.tags — short strings for top-left chips
const ListingCard = ({ listing = {}, onClick, style }) => {
const { photo = {}, title, subtitle, price = {}, rating, reviews, badges = [], tags = [], favorite } = listing;
return (
<div onClick={onClick} style={{
cursor: onClick ? "pointer" : "default",
display: "flex", flexDirection: "column", gap: 12,
...style,
}}>
<div style={{
position: "relative", borderRadius: "var(--listing-radius)",
overflow: "hidden", boxShadow: "var(--listing-shadow)",
}}>
<PhotoSlot label={photo.label || title} tone={photo.tone} aspect="4/3"/>
{/* Top-left tags */}
{tags.length > 0 && (
<div style={{
position: "absolute", top: 12, left: 12,
display: "flex", gap: 6, flexWrap: "wrap",
}}>
{tags.map(t => (
<span key={t} style={{
padding: "3px 9px", borderRadius: 999,
background: "rgba(255,255,255,0.9)",
color: "var(--text)", fontSize: 11,
fontWeight: "var(--weight-medium)",
backdropFilter: "blur(6px)",
}}>{t}</span>
))}
</div>
)}
{/* Favorite */}
<button aria-label="Favorite" style={{
position: "absolute", top: 10, right: 10,
width: 32, height: 32, borderRadius: "50%",
background: "rgba(255,255,255,0.95)", border: "none",
color: favorite ? "var(--accent)" : "var(--text)",
cursor: "pointer", display: "flex",
alignItems: "center", justifyContent: "center",
backdropFilter: "blur(6px)",
}}>
<svg width="16" height="16" viewBox="0 0 24 24" fill={favorite ? "currentColor" : "none"}
stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
</svg>
</button>
</div>
<div>
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 10,
}}>
<h3 style={{
margin: 0, fontSize: "var(--text-md)",
fontWeight: "var(--weight-semibold)",
color: "var(--text)", letterSpacing: "-0.01em",
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
flex: 1, minWidth: 0,
}}>{title}</h3>
{rating != null && (
<span style={{
display: "inline-flex", alignItems: "center", gap: 4,
fontSize: "var(--text-sm)", color: "var(--text)",
fontWeight: "var(--weight-medium)",
}}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="var(--rating)" aria-hidden="true">
<path d="m12 3 2.6 6.2 6.7.5-5.1 4.4 1.6 6.6L12 17.3 6.2 20.7l1.6-6.6L2.7 9.7l6.7-.5z"/>
</svg>
{rating.toFixed(2)}
</span>
)}
</div>
{subtitle && (
<div style={{
fontSize: "var(--text-sm)", color: "var(--text-2)", marginTop: 2,
}}>{subtitle}</div>
)}
{reviews != null && (
<div style={{ fontSize: "var(--text-sm)", color: "var(--text-3)", marginTop: 2 }}>
{reviews} reviews
</div>
)}
{badges.length > 0 && (
<div style={{ display: "flex", gap: 6, marginTop: 8, flexWrap: "wrap" }}>
{badges.map(b => <TrustBadge key={b} kind={b}/>)}
</div>
)}
{price.amount != null && (
<div style={{ marginTop: 10 }}>
<PriceTag {...price} size="md"/>
</div>
)}
</div>
</div>
);
};
// ── ListingCardHorizontal — for search results lists ─────────
const ListingCardHorizontal = ({ listing = {}, onClick, style }) => {
const { photo = {}, title, subtitle, price = {}, rating, reviews, badges = [], description, amenities = [] } = listing;
return (
<div onClick={onClick} style={{
display: "grid", gridTemplateColumns: "320px 1fr",
gap: 20, padding: 16, borderRadius: "var(--card-radius)",
background: "var(--surface)", border: "1px solid var(--border)",
cursor: onClick ? "pointer" : "default",
...style,
}}>
<div style={{
borderRadius: "var(--listing-radius)", overflow: "hidden",
boxShadow: "var(--listing-shadow)", alignSelf: "stretch",
}}>
<PhotoSlot label={photo.label || title} tone={photo.tone} aspect="4/3"/>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8, minWidth: 0 }}>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{badges.map(b => <TrustBadge key={b} kind={b}/>)}
</div>
<h3 style={{
margin: 0, fontSize: "var(--text-lg)",
fontWeight: "var(--weight-semibold)", letterSpacing: "-0.01em",
color: "var(--text)",
}}>{title}</h3>
{subtitle && (
<div style={{ fontSize: "var(--text-sm)", color: "var(--text-2)" }}>{subtitle}</div>
)}
{description && (
<p style={{
margin: "4px 0 0", fontSize: "var(--text-sm)",
color: "var(--text-2)", lineHeight: 1.5,
display: "-webkit-box", WebkitLineClamp: 2,
WebkitBoxOrient: "vertical", overflow: "hidden",
}}>{description}</p>
)}
{amenities.length > 0 && (
<div style={{ display: "flex", gap: 6, marginTop: 4, flexWrap: "wrap" }}>
{amenities.slice(0, 4).map(a => <AmenityChip key={a} label={a}/>)}
</div>
)}
<div style={{
marginTop: "auto", paddingTop: 12,
display: "flex", justifyContent: "space-between", alignItems: "flex-end",
}}>
<div>
{rating != null && <RatingStars value={rating} size={12}/>}
{reviews != null && <div style={{
fontSize: "var(--text-xs)", color: "var(--text-3)", marginTop: 4,
}}>{reviews} reviews</div>}
</div>
{price.amount != null && <PriceTag {...price} size="md"/>}
</div>
</div>
</div>
);
};
// ── AmenityChip ──────────────────────────────────────────────
const AmenityChip = ({ label, icon, style }) => (
<span style={{
display: "inline-flex", alignItems: "center", gap: 6,
padding: "4px 10px", borderRadius: 999,
background: "var(--surface-2)", color: "var(--text-2)",
border: "1px solid var(--border)",
fontSize: "var(--text-xs)", fontWeight: "var(--weight-medium)",
...style,
}}>
{icon && <Icon name={icon} size={11}/>}
{label}
</span>
);
// ── ReviewCard ───────────────────────────────────────────────
const ReviewCard = ({ author, avatarColor, rating, date, body, location, style }) => (
<div style={{
padding: 0, ...style,
}}>
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 10 }}>
<Avatar name={author} color={avatarColor} size={40}/>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{
fontSize: "var(--text-md)", fontWeight: "var(--weight-semibold)",
color: "var(--text)",
}}>{author}</div>
<div style={{ fontSize: "var(--text-xs)", color: "var(--text-3)" }}>
{location && <>{location} · </>}{date}
</div>
</div>
</div>
{rating != null && (
<div style={{ marginBottom: 8 }}>
<RatingStars value={rating} size={12} showValue={false}/>
</div>
)}
<p style={{
margin: 0, fontSize: "var(--text-md)", color: "var(--text)",
lineHeight: 1.55,
}}>{body}</p>
</div>
);
// ── RatingsSummary ──────────────────────────────────────────
// Header block for review section: big number + category bars.
const RatingsSummary = ({ value = 4.92, total = 184, categories = [], style }) => (
<div style={{ ...style }}>
<div style={{ display: "flex", alignItems: "baseline", gap: 14, marginBottom: 16 }}>
<span style={{
fontFamily: "var(--font-display)", fontSize: 44,
fontWeight: "var(--weight-medium)", letterSpacing: "-0.02em",
color: "var(--text)", lineHeight: 1,
}}> {value.toFixed(2)}</span>
<span style={{ fontSize: "var(--text-md)", color: "var(--text-2)" }}>
· {total} reviews
</span>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: "10px 28px" }}>
{categories.map(c => (
<div key={c.label} style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
gap: 14, padding: "6px 0", borderBottom: "1px solid var(--divider)",
}}>
<span style={{ fontSize: "var(--text-sm)", color: "var(--text)" }}>{c.label}</span>
<span style={{ display: "inline-flex", alignItems: "center", gap: 10 }}>
<span style={{
width: 120, height: 4, borderRadius: 2,
background: "var(--surface-alt)", overflow: "hidden",
}}>
<span style={{
display: "block", height: "100%", width: `${(c.value / 5) * 100}%`,
background: "var(--text)",
}}/>
</span>
<span style={{
fontSize: "var(--text-sm)", color: "var(--text-2)",
fontVariantNumeric: "tabular-nums",
}}>{c.value.toFixed(1)}</span>
</span>
</div>
))}
</div>
</div>
);
// ── PriceBreakdown ───────────────────────────────────────────
// Receipt-style line items + total.
// items: [{ label, amount, note?, strike? }]
// total: number
// currency
const PriceBreakdown = ({ items = [], total, currency = "$", style }) => (
<div style={{ ...style }}>
{items.map((it, i) => (
<div key={i} style={{
display: "flex", justifyContent: "space-between", alignItems: "baseline",
padding: "8px 0", fontSize: "var(--text-md)",
color: "var(--text-2)",
}}>
<span style={{
textDecoration: it.note === "discount" ? "none" : "underline",
textDecorationStyle: "dotted", textUnderlineOffset: 2,
}}>{it.label}</span>
<span style={{
color: it.note === "discount" ? "var(--success)" : "var(--text)",
fontVariantNumeric: "tabular-nums",
}}>
{it.note === "discount" ? "" : ""}{currency}{it.amount}
</span>
</div>
))}
<div style={{
display: "flex", justifyContent: "space-between",
paddingTop: 14, marginTop: 6,
borderTop: "1px solid var(--border-strong)",
fontSize: "var(--text-lg)", fontWeight: "var(--weight-semibold)",
color: "var(--text)",
}}>
<span>Total</span>
<span style={{ fontVariantNumeric: "tabular-nums" }}>{currency}{total}</span>
</div>
</div>
);
// ── HostHeader ───────────────────────────────────────────────
const HostHeader = ({ name, avatar, color, joined, location, languages = [], blurb, kpis = [], style }) => (
<div style={{
display: "grid", gridTemplateColumns: "auto 1fr", gap: 20,
...style,
}}>
<div style={{
display: "flex", flexDirection: "column", alignItems: "center",
gap: 12, padding: 22,
background: "var(--surface)", border: "1px solid var(--border)",
borderRadius: "var(--card-radius)", boxShadow: "var(--shadow-sm)",
minWidth: 200,
}}>
<Avatar name={name} color={color} size={80}/>
<div style={{ textAlign: "center" }}>
<div style={{
fontFamily: "var(--font-display)", fontSize: "var(--text-xl)",
fontWeight: "var(--weight-semibold)", color: "var(--text)",
}}>{name}</div>
<div style={{ fontSize: "var(--text-sm)", color: "var(--text-3)", marginTop: 2 }}>Host</div>
</div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", justifyContent: "center" }}>
<TrustBadge kind="superhost"/>
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", justifyContent: "center", minWidth: 0 }}>
<div style={{
display: "grid", gridTemplateColumns: `repeat(${kpis.length}, auto)`,
gap: 26, marginBottom: 14,
}}>
{kpis.map(k => (
<div key={k.label}>
<div style={{
fontSize: "var(--text-xl)", fontWeight: "var(--weight-semibold)",
color: "var(--text)", fontFamily: "var(--font-display)",
}}>{k.value}</div>
<div style={{ fontSize: "var(--text-xs)", color: "var(--text-3)",
textTransform: "uppercase", letterSpacing: "0.06em", marginTop: 2,
}}>{k.label}</div>
</div>
))}
</div>
{(joined || location) && (
<div style={{ fontSize: "var(--text-sm)", color: "var(--text-2)", marginBottom: 6 }}>
{location && <>Lives in {location}{joined ? " · " : ""}</>}
{joined && <>Hosting since {joined}</>}
</div>
)}
{languages.length > 0 && (
<div style={{ fontSize: "var(--text-sm)", color: "var(--text-2)", marginBottom: 10 }}>
Speaks {languages.join(", ")}
</div>
)}
{blurb && (
<p style={{
margin: 0, fontSize: "var(--text-md)", color: "var(--text)",
lineHeight: 1.55, maxWidth: 580,
}}>{blurb}</p>
)}
</div>
</div>
);
// ── MessageBubble ────────────────────────────────────────────
const MessageBubble = ({ mine, name, body, time, avatar, color, attachment, style }) => (
<div style={{
display: "flex", flexDirection: mine ? "row-reverse" : "row",
gap: 10, alignItems: "flex-end", marginBottom: 12, ...style,
}}>
{!mine && <Avatar name={name || "?"} color={color} size={28}/>}
<div style={{ maxWidth: "62%" }}>
<div style={{
padding: "10px 14px", borderRadius: 16,
background: mine ? "var(--text)" : "var(--surface-2)",
color: mine ? "var(--bg)" : "var(--text)",
fontSize: "var(--text-md)", lineHeight: 1.45,
borderBottomRightRadius: mine ? 4 : 16,
borderBottomLeftRadius: mine ? 16 : 4,
boxShadow: "var(--shadow-sm)",
}}>{body}</div>
{attachment && (
<div style={{
marginTop: 6, padding: "8px 12px",
background: "var(--surface)", border: "1px solid var(--border)",
borderRadius: 12, fontSize: "var(--text-sm)", color: "var(--text)",
display: "inline-flex", alignItems: "center", gap: 8,
}}>
<Icon name="doc" size={14}/>
{attachment}
</div>
)}
{time && <div style={{
fontSize: "var(--text-xs)", color: "var(--text-3)",
marginTop: 4, textAlign: mine ? "right" : "left",
}}>{time}</div>}
</div>
</div>
);
// ── SearchBar — destination + dates + guests ────────────────
const SearchBar = ({ destination, dates, guests, compact, onSearch, style }) => {
const segPad = compact ? "10px 18px" : "14px 22px";
const Segment = ({ label, value, placeholder, last }) => (
<div style={{
flex: 1, padding: segPad, minWidth: 0,
borderRight: last ? "none" : "1px solid var(--border)",
cursor: "pointer",
}}>
<div style={{
fontSize: "var(--text-xs)", fontWeight: "var(--weight-semibold)",
color: "var(--text)", letterSpacing: "0.02em",
}}>{label}</div>
<div style={{
fontSize: compact ? "var(--text-sm)" : "var(--text-md)",
color: value ? "var(--text)" : "var(--text-3)",
marginTop: 2, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
}}>{value || placeholder}</div>
</div>
);
return (
<div style={{
display: "flex", alignItems: "center",
background: "var(--surface)", border: "1px solid var(--border)",
borderRadius: 999, boxShadow: "var(--shadow)",
padding: 4, ...style,
}}>
<Segment label="Where" value={destination} placeholder="Search destinations"/>
<Segment label="Check in" value={dates?.in} placeholder="Add dates"/>
<Segment label="Check out" value={dates?.out} placeholder="Add dates"/>
<Segment label="Who" value={guests} placeholder="Add guests" last/>
<button onClick={onSearch} aria-label="Search" style={{
width: compact ? 38 : 50, height: compact ? 38 : 50,
borderRadius: 999, border: "none",
background: "var(--accent)", color: "var(--text-on-accent)",
cursor: "pointer", display: "flex", alignItems: "center",
justifyContent: "center", marginLeft: 6,
}}>
<Icon name="search" size={compact ? 15 : 18} stroke={2.2}/>
</button>
</div>
);
};
// ── CategoryRail — horizontal scrolling category chips ───────
const CategoryRail = ({ categories = [], active, onChange = () => {}, style }) => (
<div style={{
display: "flex", gap: 28, overflowX: "auto", padding: "10px 0",
...style,
}}>
{categories.map(c => {
const sel = c.id === active || c.label === active;
return (
<button key={c.id || c.label} onClick={() => onChange(c.id || c.label)} style={{
display: "flex", flexDirection: "column", alignItems: "center",
gap: 6, padding: "4px 2px", background: "transparent",
border: "none", cursor: "pointer", color: sel ? "var(--text)" : "var(--text-2)",
borderBottom: sel ? "2px solid var(--text)" : "2px solid transparent",
fontFamily: "var(--font-sans)", flexShrink: 0,
opacity: sel ? 1 : 0.7,
}}>
<span style={{ fontSize: 22, lineHeight: 1 }}>{c.emoji || "○"}</span>
<span style={{ fontSize: "var(--text-xs)", fontWeight: 500, whiteSpace: "nowrap" }}>{c.label}</span>
</button>
);
})}
</div>
);
// ── FilterChips — horizontal chip strip ──────────────────────
const FilterChips = ({ filters = [], onClear, style }) => (
<div style={{
display: "flex", gap: 8, flexWrap: "wrap",
alignItems: "center", ...style,
}}>
{filters.map((f, i) => (
<span key={i} style={{
display: "inline-flex", alignItems: "center", gap: 6,
padding: "6px 12px", borderRadius: 999,
background: f.active ? "var(--text)" : "var(--surface)",
color: f.active ? "var(--bg)" : "var(--text)",
border: `1px solid ${f.active ? "var(--text)" : "var(--border)"}`,
fontSize: "var(--text-sm)", fontWeight: "var(--weight-medium)",
cursor: "pointer",
}}>
{f.label}
{f.count != null && <span style={{
fontSize: 10, padding: "1px 6px", borderRadius: 999,
background: f.active ? "rgba(255,255,255,0.18)" : "var(--surface-alt)",
color: "inherit",
}}>{f.count}</span>}
</span>
))}
{onClear && (
<button onClick={onClear} style={{
marginLeft: 4, padding: "6px 12px", borderRadius: 999,
background: "transparent", border: "none", cursor: "pointer",
fontSize: "var(--text-sm)", color: "var(--text-2)",
textDecoration: "underline",
}}>Clear all</button>
)}
</div>
);
// ── MiniMap — schematic map with pins ────────────────────────
// Not a real map — a stylized SVG you can drop in for layout.
// Pins: [{ x: 0-100, y: 0-100, price, active }]
const MiniMap = ({ pins = [], style }) => (
<div style={{
position: "relative", width: "100%", height: "100%",
borderRadius: "var(--card-radius)", overflow: "hidden",
background: "var(--surface-2)", border: "1px solid var(--border)",
...style,
}}>
{/* Map illustration via SVG */}
<svg width="100%" height="100%" viewBox="0 0 600 600" preserveAspectRatio="xMidYMid slice"
style={{ position: "absolute", inset: 0 }}>
<defs>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="var(--border)" strokeWidth="0.5"/>
</pattern>
</defs>
<rect width="600" height="600" fill="var(--surface-2)"/>
<rect width="600" height="600" fill="url(#grid)" opacity="0.6"/>
{/* Faux water */}
<path d="M0,380 Q100,360 220,400 T440,420 T600,400 L600,600 L0,600 Z"
fill="var(--accent-soft)" opacity="0.7"/>
{/* Faux roads */}
<path d="M0,200 Q200,210 380,180 T600,220"
fill="none" stroke="var(--text-3)" strokeWidth="2" strokeDasharray="6 4" opacity="0.3"/>
<path d="M280,0 L260,600" fill="none" stroke="var(--text-3)"
strokeWidth="2" strokeDasharray="6 4" opacity="0.3"/>
{/* Faux blocks */}
{[[80,80,90,70],[200,60,80,100],[340,90,110,80],[470,70,80,90],
[60,250,80,90],[180,260,110,80],[330,260,90,70],[460,260,80,90]].map(([x,y,w,h], i) => (
<rect key={i} x={x} y={y} width={w} height={h} rx="4"
fill="var(--surface)" stroke="var(--border)" strokeWidth="1"/>
))}
</svg>
{/* Pins */}
{pins.map((p, i) => (
<div key={i} style={{
position: "absolute", left: `${p.x}%`, top: `${p.y}%`,
transform: "translate(-50%, -100%)",
padding: "4px 10px", borderRadius: 999,
background: p.active ? "var(--text)" : "var(--surface)",
color: p.active ? "var(--bg)" : "var(--text)",
border: `1.5px solid ${p.active ? "var(--text)" : "var(--border-strong)"}`,
fontSize: "var(--text-xs)",
fontWeight: "var(--weight-semibold)",
fontVariantNumeric: "tabular-nums",
boxShadow: "var(--shadow)", cursor: "pointer", whiteSpace: "nowrap",
zIndex: p.active ? 2 : 1,
}}>${p.price}</div>
))}
{/* Map controls */}
<div style={{
position: "absolute", right: 12, top: 12,
display: "flex", flexDirection: "column", gap: 0,
background: "var(--surface)", borderRadius: "var(--radius)",
border: "1px solid var(--border)", boxShadow: "var(--shadow)",
}}>
<button style={{
width: 32, height: 32, border: "none", background: "transparent",
borderBottom: "1px solid var(--border)", cursor: "pointer",
color: "var(--text)", fontSize: 16,
}}>+</button>
<button style={{
width: 32, height: 32, border: "none", background: "transparent",
cursor: "pointer", color: "var(--text)", fontSize: 16,
}}></button>
</div>
</div>
);
// ── CalendarMonth — single-month booking calendar ────────────
// `start` and `end` are 131 day numbers within the displayed
// month. Tinted range between them, range endpoints highlighted.
const CalendarMonth = ({ month = "August 2026", firstWeekday = 6, daysInMonth = 31, start, end, today, blocked = [], style }) => {
const days = [];
// Blank cells before day 1
for (let i = 0; i < firstWeekday; i++) days.push(null);
for (let d = 1; d <= daysInMonth; d++) days.push(d);
const inRange = (d) => start != null && end != null && d > start && d < end;
const isEnd = (d) => d === start || d === end;
const isBlocked = (d) => blocked.includes(d);
return (
<div style={{ ...style }}>
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
marginBottom: 12,
}}>
<IconButton name="chevLeft" size="sm" label="Previous month"/>
<span style={{
fontFamily: "var(--font-display)", fontSize: "var(--text-lg)",
fontWeight: "var(--weight-semibold)", color: "var(--text)",
}}>{month}</span>
<IconButton name="chevRight" size="sm" label="Next month"/>
</div>
<div style={{
display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: 4,
fontSize: "var(--text-xs)", color: "var(--text-3)",
textAlign: "center", marginBottom: 6,
textTransform: "uppercase", letterSpacing: "0.08em",
}}>
{["S","M","T","W","T","F","S"].map((d, i) => <span key={i}>{d}</span>)}
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: 2 }}>
{days.map((d, i) => {
if (d == null) return <span key={i}/>;
const blocked = isBlocked(d);
const range = inRange(d);
const end = isEnd(d);
return (
<button key={i} disabled={blocked} style={{
aspectRatio: "1", border: "none", cursor: blocked ? "not-allowed" : "pointer",
borderRadius: end ? "50%" : 6,
background: end ? "var(--text)" : range ? "var(--surface-alt)" : "transparent",
color: end ? "var(--bg)" : blocked ? "var(--text-3)" : "var(--text)",
fontSize: "var(--text-sm)",
fontWeight: end ? 600 : 400,
textDecoration: blocked ? "line-through" : "none",
opacity: blocked ? 0.5 : 1,
outline: d === today ? "1px solid var(--accent)" : "none",
fontFamily: "var(--font-sans)",
}}>{d}</button>
);
})}
</div>
</div>
);
};
// ── AvailabilityHeatmap — for host calendar ──────────────────
// 4-week strip showing booked vs free vs blocked nights.
// weeks: array of 7-element arrays of "open"|"booked"|"blocked"
const AvailabilityHeatmap = ({ weeks = [], style }) => {
const colors = {
open: { bg: "var(--success-soft)", dot: "var(--success)" },
booked: { bg: "var(--accent-soft)", dot: "var(--accent)" },
blocked: { bg: "var(--surface-alt)", dot: "var(--text-3)" },
};
return (
<div style={{ display: "flex", flexDirection: "column", gap: 4, ...style }}>
{weeks.map((w, i) => (
<div key={i} style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: 4 }}>
{w.map((cell, j) => (
<div key={j} style={{
aspectRatio: "1", borderRadius: 4,
background: colors[cell]?.bg || "transparent",
border: `1px solid ${colors[cell]?.dot || "transparent"}33`,
}}/>
))}
</div>
))}
</div>
);
};
// ── PhotoGallery — main + 4 thumbs grid ──────────────────────
const PhotoGallery = ({ photos = [], style }) => {
// photos: [{ label, tone }] up to 5
const [main, ...rest] = photos;
return (
<div style={{
display: "grid",
gridTemplateColumns: "2fr 1fr 1fr",
gridTemplateRows: "1fr 1fr",
gap: 8, borderRadius: "var(--card-radius)", overflow: "hidden",
height: 460, ...style,
}}>
<div style={{ gridColumn: "1 / 2", gridRow: "1 / 3" }}>
<PhotoSlot label={main?.label || "Photo 1"} tone={main?.tone} aspect="auto" style={{ height: "100%", aspectRatio: "auto" }}/>
</div>
{rest.slice(0, 4).map((p, i) => (
<PhotoSlot key={i} label={p.label || `Photo ${i + 2}`} tone={p.tone}
aspect="auto" style={{ height: "100%", aspectRatio: "auto" }}/>
))}
</div>
);
};
// ── StatTile — small dashboard stat ──────────────────────────
const StatTile = ({ label, value, sub, trend, icon, style }) => (
<Card padding={20} style={style}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<span style={{
fontSize: "var(--text-xs)", color: "var(--text-3)",
textTransform: "uppercase", letterSpacing: "0.06em",
fontWeight: "var(--weight-medium)",
}}>{label}</span>
{icon && (
<span style={{
width: 28, height: 28, borderRadius: 8,
background: "var(--accent-soft)", color: "var(--accent)",
display: "flex", alignItems: "center", justifyContent: "center",
}}>
<Icon name={icon} size={14}/>
</span>
)}
</div>
<div style={{
fontFamily: "var(--font-display)",
fontSize: 28, fontWeight: "var(--weight-semibold)",
letterSpacing: "-0.02em", color: "var(--text)",
marginTop: 10,
}}>{value}</div>
{(sub || trend) && (
<div style={{ fontSize: "var(--text-xs)", color: "var(--text-3)", marginTop: 4 }}>
{trend != null && (
<span style={{ color: trend >= 0 ? "var(--success)" : "var(--danger)", marginRight: 4 }}>
{trend >= 0 ? "↑" : "↓"} {Math.abs(trend)}%
</span>
)}
{sub}
</div>
)}
</Card>
);
// ─── Exports ─────────────────────────────────────────────────
Object.assign(window, {
PhotoSlot, ListingCard, ListingCardHorizontal,
RatingStars, ReviewCard, RatingsSummary,
PriceTag, PriceBreakdown,
HostHeader, MessageBubble,
TrustBadge, AmenityChip, FilterChips,
SearchBar, MiniMap, CalendarMonth, AvailabilityHeatmap,
CategoryRail, PhotoGallery, StatTile,
});

View File

@@ -0,0 +1,280 @@
// ============================================================
// vibn-marketplace · marketplace-shells.jsx
// ------------------------------------------------------------
// Layout shells for marketplace pages.
//
// MarketplaceTopShell — public, transparent-on-scroll top
// nav with brand left, links center, host-mode toggle and
// avatar right. Compact variant shrinks the SearchBar.
//
// DashboardShell — left sidebar with side switch (Guest /
// Host modes), workspace, nav sections.
// ============================================================
// ── MarketplaceTopShell ──────────────────────────────────────
// Props:
// brand { name, mark }
// showSearch bool — inline SearchBar in the header
// searchProps { destination, dates, guests }
// user { name, color, role }
// onHostMode fn — called when user clicks "Switch to hosting"
// compact bool — slimmer header (e.g. for non-home pages)
const MarketplaceTopShell = ({
brand = { name: "Atlas" },
showSearch, searchProps = {},
user, onHostMode = () => {},
compact, children,
footer = true,
}) => (
<div className="vibn-app" style={{
width: "100%", minHeight: "100%", display: "flex",
flexDirection: "column", background: "var(--bg)",
fontFamily: "var(--font-sans)", color: "var(--text)",
}}>
<header style={{
padding: compact ? "14px 32px" : "20px 40px",
borderBottom: "1px solid var(--border)",
background: "var(--surface)", position: "sticky", top: 0, zIndex: 5,
}}>
<div style={{
display: "grid",
gridTemplateColumns: showSearch ? "auto 1fr auto" : "auto 1fr auto",
alignItems: "center", gap: 24,
}}>
{/* Brand */}
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
{brand.mark || <AtlasMark size={compact ? 22 : 26}/>}
<span style={{
fontFamily: "var(--font-display)",
fontSize: compact ? 18 : 22,
fontWeight: "var(--weight-semibold)",
letterSpacing: "-0.01em",
color: "var(--accent)",
}}>{brand.name}</span>
</div>
{/* Center: search OR links */}
{showSearch ? (
<div style={{ maxWidth: 720, width: "100%", justifySelf: "center" }}>
<SearchBar compact={compact} {...searchProps}/>
</div>
) : (
<nav style={{
display: "flex", justifyContent: "center", gap: 28,
fontSize: "var(--text-sm)",
}}>
<span style={{ color: "var(--text)", fontWeight: 500, cursor: "pointer" }}>Stays</span>
<span style={{ color: "var(--text-2)", cursor: "pointer" }}>Experiences</span>
<span style={{ color: "var(--text-2)", cursor: "pointer" }}>Guides</span>
<span style={{ color: "var(--text-2)", cursor: "pointer" }}>Gift cards</span>
</nav>
)}
{/* Right */}
<div style={{ display: "flex", alignItems: "center", gap: 8, justifySelf: "end" }}>
<button onClick={onHostMode} style={{
background: "transparent", border: "none", padding: "8px 14px",
borderRadius: 999, fontSize: "var(--text-sm)",
fontWeight: "var(--weight-medium)", color: "var(--text)",
fontFamily: "var(--font-sans)", cursor: "pointer",
whiteSpace: "nowrap",
}}>Become a host</button>
<IconButton name="more" size="md" variant="secondary"/>
{user && (
<div style={{
display: "flex", alignItems: "center", gap: 8,
padding: "4px 4px 4px 14px", borderRadius: 999,
border: "1px solid var(--border)",
background: "var(--surface)",
boxShadow: "var(--shadow-sm)",
}}>
<Icon name="more" size={14}/>
<Avatar name={user.name} color={user.color} size={28}/>
</div>
)}
</div>
</div>
</header>
<main style={{ flex: 1, minWidth: 0 }}>{children}</main>
{footer && (
<footer style={{
padding: "28px 40px", borderTop: "1px solid var(--border)",
background: "var(--surface-2)",
display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 32,
}}>
{[
["Support", ["Help centre","Cancellation options","Safety information","Report a concern"]],
["Community",["Host an event","Atlas.org","Community forum","Refer a host"]],
["Hosting", ["Become a host","Hosting resources","Community forum","Responsible hosting"]],
["Atlas", ["Newsroom","Investors","Careers","Press centre"]],
].map(([title, items]) => (
<div key={title}>
<div style={{
fontSize: "var(--text-sm)", fontWeight: "var(--weight-semibold)",
color: "var(--text)", marginBottom: 12,
}}>{title}</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{items.map(it => (
<span key={it} style={{
fontSize: "var(--text-sm)", color: "var(--text-2)", cursor: "pointer",
}}>{it}</span>
))}
</div>
</div>
))}
</footer>
)}
</div>
);
// Brand mark for the Atlas marketplace — a stylized compass-ish glyph
const AtlasMark = ({ size = 26 }) => (
<svg width={size} height={size} viewBox="0 0 32 32" fill="none" aria-hidden="true">
<path d="M16 2 L19 13 L30 16 L19 19 L16 30 L13 19 L2 16 L13 13 Z"
fill="var(--accent)"/>
<circle cx="16" cy="16" r="2.5" fill="var(--bg)"/>
</svg>
);
// ── MarketplaceDashboardShell ────────────────────────────────
// Side nav for the demand (guest) and supply (host) dashboards.
// role "guest" | "host"
// active id of current nav item
// onRoleSwitch fn
const MarketplaceDashboardShell = ({
brand = { name: "Atlas" },
role = "guest",
active,
onRoleSwitch = () => {},
user,
children,
}) => {
const guestNav = [
{ id: "trips", label: "My trips", icon: "briefcase" },
{ id: "saved", label: "Saved", icon: "star" },
{ id: "inbox", label: "Messages", icon: "inbox", count: 3 },
{ id: "_account", section: "Account" },
{ id: "profile", label: "Profile", icon: "people" },
{ id: "payment", label: "Payments", icon: "doc" },
{ id: "settings", label: "Settings", icon: "settings" },
];
const hostNav = [
{ id: "today", label: "Today", icon: "home" },
{ id: "calendar", label: "Calendar", icon: "check" },
{ id: "listings", label: "Listings", icon: "building", count: 3 },
{ id: "earnings", label: "Earnings", icon: "bar" },
{ id: "inbox", label: "Inbox", icon: "inbox", count: 5 },
{ id: "_growth", section: "Growth" },
{ id: "insights", label: "Insights", icon: "spark" },
{ id: "reviews", label: "Reviews", icon: "star" },
{ id: "_account", section: "Account" },
{ id: "profile", label: "Profile", icon: "people" },
{ id: "payouts", label: "Payouts", icon: "doc" },
{ id: "settings", label: "Settings", icon: "settings" },
];
const nav = role === "host" ? hostNav : guestNav;
return (
<div className="vibn-app" style={{
width: "100%", height: "100%",
display: "grid", gridTemplateColumns: "260px 1fr",
overflow: "hidden",
}}>
<aside style={{
background: "var(--surface-2)",
borderRight: "1px solid var(--border)",
display: "flex", flexDirection: "column",
}}>
{/* Brand */}
<div style={{
padding: "16px 18px", display: "flex", alignItems: "center", gap: 10,
borderBottom: "1px solid var(--border)",
}}>
<AtlasMark size={22}/>
<span style={{
fontFamily: "var(--font-display)", fontSize: 20,
fontWeight: "var(--weight-semibold)", color: "var(--accent)",
}}>{brand.name}</span>
</div>
{/* Role switch — segmented */}
<div style={{ padding: 16 }}>
<div style={{
display: "flex", padding: 3,
background: "var(--surface-alt)", borderRadius: 999,
}}>
{["guest", "host"].map(r => (
<button key={r} onClick={() => onRoleSwitch(r)} style={{
flex: 1, padding: "8px 12px", borderRadius: 999,
background: r === role ? "var(--surface)" : "transparent",
color: r === role ? "var(--text)" : "var(--text-2)",
border: "none", cursor: "pointer", fontFamily: "var(--font-sans)",
fontSize: "var(--text-sm)", fontWeight: 500,
boxShadow: r === role ? "var(--shadow-sm)" : "none",
textTransform: "capitalize",
}}>{r === "guest" ? "Travelling" : "Hosting"}</button>
))}
</div>
</div>
{/* Nav */}
<nav style={{ padding: "0 10px", flex: 1, overflowY: "auto" }}>
{nav.map(it => it.section ? (
<div key={it.id} style={{
fontSize: "var(--text-xs)", color: "var(--text-3)",
padding: "16px 10px 6px", textTransform: "uppercase",
letterSpacing: "0.06em", fontWeight: 500,
}}>{it.section}</div>
) : (
<div key={it.id} style={{
display: "flex", alignItems: "center", gap: 10,
padding: "8px 12px", borderRadius: "var(--radius-sm)",
fontSize: "var(--text-md)", marginBottom: 1, cursor: "pointer",
color: it.id === active ? "var(--text)" : "var(--text-2)",
fontWeight: it.id === active ? 500 : 400,
background: it.id === active ? "var(--surface)" : "transparent",
boxShadow: it.id === active ? "var(--shadow-sm)" : "none",
}}>
<span style={{ color: it.id === active ? "var(--accent)" : "var(--text-3)", display: "flex" }}>
<Icon name={it.icon} size={15}/>
</span>
<span style={{ flex: 1 }}>{it.label}</span>
{it.count != null && <span style={{
fontSize: "var(--text-xs)", color: it.id === active ? "var(--accent)" : "var(--text-3)",
fontWeight: 500,
}}>{it.count}</span>}
</div>
))}
</nav>
{/* Help footer */}
<div style={{
padding: 14, borderTop: "1px solid var(--border)",
display: "flex", alignItems: "center", gap: 12,
}}>
<div style={{
width: 36, height: 36, borderRadius: 10,
background: "var(--accent-soft)", color: "var(--accent)",
display: "flex", alignItems: "center", justifyContent: "center",
}}>
<Icon name="info" size={16}/>
</div>
<div style={{ flex: 1, minWidth: 0, lineHeight: 1.3 }}>
<div style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Need help?</div>
<div style={{ fontSize: "var(--text-xs)", color: "var(--text-3)" }}>24/7 support · all channels</div>
</div>
</div>
</aside>
<main style={{ overflow: "hidden", display: "flex", flexDirection: "column", background: "var(--bg)" }}>
{children}
</main>
</div>
);
};
Object.assign(window, {
MarketplaceTopShell, MarketplaceDashboardShell, AtlasMark,
});

Some files were not shown because too many files have changed in this diff Show More