feat: flatten routes and merge marketing and onboarding directories
3
.vibncode/settings.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"hooks": {}
|
||||
}
|
||||
1998
compiled_system_prompts_audit.json
Normal file
1
design-templates/VIBN (2)/.design-canvas.state.json
Normal file
@@ -0,0 +1 @@
|
||||
{"sections":{"app-navs":{"labels":{"sidebar":"01 · Sidebar w/ workspaces"}}}}
|
||||
BIN
design-templates/VIBN (2)/.thumbnail
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
86
design-templates/VIBN (2)/Atlas Marketplace Templates.html
Normal 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>
|
||||
67
design-templates/VIBN (2)/Auth Screens by Style.html
Normal 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>
|
||||
167
design-templates/VIBN (2)/Beta Signup.html
Normal 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>
|
||||
73
design-templates/VIBN (2)/Cadence CRM Templates.html
Normal 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>
|
||||
71
design-templates/VIBN (2)/Modern Website Styles.html
Normal 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>
|
||||
45
design-templates/VIBN (2)/Onboarding.bundle.html
Normal file
@@ -0,0 +1,45 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Vibn — Onboarding</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="icon" type="image/png" href="assets/logo-black.png" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="onboarding.css" />
|
||||
|
||||
<template id="__bundler_thumbnail" data-bg-color="#27201d">
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="200" height="200" fill="#27201d"/>
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#e9835d"/>
|
||||
<stop offset="1" stop-color="#b53a25"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="100" cy="100" r="58" fill="url(#g)"/>
|
||||
<g fill="#1a0f0a" stroke="#1a0f0a" stroke-width="2" stroke-linejoin="round">
|
||||
<path d="M82 78 L94 78 L98 110 L102 78 L114 78 L102 130 Z"/>
|
||||
<rect x="118" y="120" width="14" height="5" rx="1"/>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="text/babel" src="primitives.jsx"></script>
|
||||
<script type="text/babel" src="onboarding-primitives.jsx"></script>
|
||||
<script type="text/babel" src="onboarding-fork.jsx"></script>
|
||||
<script type="text/babel" src="onboarding-entrepreneur.jsx"></script>
|
||||
<script type="text/babel" src="onboarding-owner.jsx"></script>
|
||||
<script type="text/babel" src="onboarding-consultant.jsx"></script>
|
||||
<script type="text/babel" src="onboarding-build.jsx"></script>
|
||||
<script type="text/babel" src="onboarding-app.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
28
design-templates/VIBN (2)/Onboarding.html
Normal 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>
|
||||
61
design-templates/VIBN (2)/SaaS Nav Layouts.html
Normal 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>
|
||||
220
design-templates/VIBN (2)/SaaS Pages by Nav Style.html
Normal 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>
|
||||
38
design-templates/VIBN (2)/Sign In.html
Normal 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>
|
||||
22
design-templates/VIBN (2)/Sign Up.html
Normal 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>
|
||||
687
design-templates/VIBN (2)/Vibn UI Showcase.html
Normal 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>
|
||||
440
design-templates/VIBN (2)/app-chrome.jsx
Normal 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,
|
||||
});
|
||||
230
design-templates/VIBN (2)/app.jsx
Normal 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 />);
|
||||
BIN
design-templates/VIBN (2)/assets/logo-black.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
1314
design-templates/VIBN (2)/atlas-pages.jsx
Normal file
190
design-templates/VIBN (2)/audience.jsx
Normal 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 });
|
||||
123
design-templates/VIBN (2)/auth-shared.jsx
Normal 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,
|
||||
});
|
||||
431
design-templates/VIBN (2)/auth-style-a.jsx
Normal 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", "2–10", "11–50", "51–200", "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 });
|
||||
548
design-templates/VIBN (2)/auth-style-b.jsx
Normal 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 });
|
||||
535
design-templates/VIBN (2)/auth-style-c.jsx
Normal 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 });
|
||||
379
design-templates/VIBN (2)/auth.css
Normal 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; }
|
||||
809
design-templates/VIBN (2)/beta.jsx
Normal 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 />);
|
||||
150
design-templates/VIBN (2)/closing.jsx
Normal 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 });
|
||||
134
design-templates/VIBN (2)/crossed.jsx
Normal 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 });
|
||||
966
design-templates/VIBN (2)/design-canvas.jsx
Normal 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 });
|
||||
|
||||
189
design-templates/VIBN (2)/exports/vibn-onboarding.html
Normal file
189
design-templates/VIBN (2)/exports/vibn-signin.html
Normal file
412
design-templates/VIBN (2)/hero.jsx
Normal 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 });
|
||||
221
design-templates/VIBN (2)/index.html
Normal 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>
|
||||
333
design-templates/VIBN (2)/journey.jsx
Normal 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 });
|
||||
107
design-templates/VIBN (2)/mission.jsx
Normal 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 });
|
||||
753
design-templates/VIBN (2)/nav-styles.jsx
Normal 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,
|
||||
});
|
||||
181
design-templates/VIBN (2)/onboarding-app.jsx
Normal 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 />);
|
||||
445
design-templates/VIBN (2)/onboarding-build.jsx
Normal 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 });
|
||||
@@ -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 });
|
||||
274
design-templates/VIBN (2)/onboarding-entrepreneur.jsx
Normal 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 });
|
||||
134
design-templates/VIBN (2)/onboarding-fork.jsx
Normal 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 });
|
||||
262
design-templates/VIBN (2)/onboarding-owner.jsx
Normal 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: "1–3 years" },
|
||||
{ id: "3_10", label: "3–10 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 });
|
||||
333
design-templates/VIBN (2)/onboarding-primitives.jsx
Normal 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,
|
||||
});
|
||||
300
design-templates/VIBN (2)/page-admin.jsx
Normal 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;
|
||||
318
design-templates/VIBN (2)/page-customer.jsx
Normal 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;
|
||||
355
design-templates/VIBN (2)/page-dashboard.jsx
Normal 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;
|
||||
108
design-templates/VIBN (2)/primitives.jsx
Normal 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 });
|
||||
BIN
design-templates/VIBN (2)/screenshots/hero-current.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
design-templates/VIBN (2)/screenshots/hero-v2.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
design-templates/VIBN (2)/screenshots/hero-v2b.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
design-templates/VIBN (2)/screenshots/logo-check.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
design-templates/VIBN (2)/screenshots/logo-nav.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
design-templates/VIBN (2)/screenshots/signin-bundled.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
139
design-templates/VIBN (2)/signin.jsx
Normal 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 />);
|
||||
213
design-templates/VIBN (2)/signup.jsx
Normal 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 />);
|
||||
343
design-templates/VIBN (2)/stack.jsx
Normal 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 });
|
||||
1104
design-templates/VIBN (2)/styles.jsx
Normal file
568
design-templates/VIBN (2)/tweaks-panel.jsx
Normal 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,
|
||||
});
|
||||
BIN
design-templates/VIBN (2)/uploads/vibn-black-circle-logo.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
120
design-templates/VIBN (2)/vibn-ai-templates/README.md
Normal 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.
|
||||
737
design-templates/VIBN (2)/vibn-ai-templates/components.jsx
Normal 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,
|
||||
});
|
||||
89
design-templates/VIBN (2)/vibn-ai-templates/icons.jsx
Normal 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 });
|
||||
399
design-templates/VIBN (2)/vibn-ai-templates/shells.jsx
Normal 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,
|
||||
});
|
||||
325
design-templates/VIBN (2)/vibn-ai-templates/tokens.css
Normal 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; }
|
||||
5
design-templates/VIBN (2)/vibn-app/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
.env*.local
|
||||
*.log
|
||||
123
design-templates/VIBN (2)/vibn-app/README.md
Normal 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(16–17px, 1.6–2.2vw, 19–28px)`
|
||||
|
||||
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+.
|
||||
19
design-templates/VIBN (2)/vibn-app/beta.html
Normal 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>
|
||||
19
design-templates/VIBN (2)/vibn-app/index.html
Normal 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>
|
||||
23
design-templates/VIBN (2)/vibn-app/package.json
Normal 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"
|
||||
}
|
||||
1635
design-templates/VIBN (2)/vibn-app/pnpm-lock.yaml
generated
Normal file
6
design-templates/VIBN (2)/vibn-app/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
BIN
design-templates/VIBN (2)/vibn-app/public/logo-black.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
40
design-templates/VIBN (2)/vibn-app/src/App.jsx
Normal 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)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
91
design-templates/VIBN (2)/vibn-app/src/BetaApp.jsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
10
design-templates/VIBN (2)/vibn-app/src/beta-main.jsx
Normal 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>
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
29
design-templates/VIBN (2)/vibn-app/src/components/Footer.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
210
design-templates/VIBN (2)/vibn-app/src/components/Hero.jsx
Normal 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;
|
||||
}
|
||||
165
design-templates/VIBN (2)/vibn-app/src/components/Journey.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
29
design-templates/VIBN (2)/vibn-app/src/components/Nav.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
140
design-templates/VIBN (2)/vibn-app/src/components/Wall.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
}} />
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
73
design-templates/VIBN (2)/vibn-app/src/lib/primitives.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
design-templates/VIBN (2)/vibn-app/src/main.jsx
Normal 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>
|
||||
);
|
||||
180
design-templates/VIBN (2)/vibn-app/src/styles.css
Normal 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; }
|
||||
}
|
||||
60
design-templates/VIBN (2)/vibn-app/tailwind.config.js
Normal 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: [],
|
||||
};
|
||||
17
design-templates/VIBN (2)/vibn-app/vite.config.js
Normal 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"),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
72
design-templates/VIBN (2)/vibn-crm/README.md
Normal 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.
|
||||
344
design-templates/VIBN (2)/vibn-crm/crm-onboarding.jsx
Normal 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", "2–10", "11–50", "51–200", "200+"]} value="2–10"/>
|
||||
|
||||
<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,
|
||||
});
|
||||
688
design-templates/VIBN (2)/vibn-crm/crm-pages.jsx
Normal 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,
|
||||
});
|
||||
65
design-templates/VIBN (2)/vibn-marketplace/README.md
Normal 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.
|
||||
@@ -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` 0–5 (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 1–31 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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||