fix(ai): strip deepseek xml tags from chat history & secure git tools

This commit addresses the issue where DeepSeek's raw XML markup (like <tool_calls> and <think>) was leaking into chat history, causing hallucinations in subsequent turns. It also patches a vulnerability in the git commit tool where arbitrary shell injection was possible.

Additionally, it includes UX copy and color contrast adjustments for the marketing homepage breadcrumbs.
This commit is contained in:
2026-05-14 11:34:42 -07:00
parent 5968b98aa7
commit c51c3c21b3
22 changed files with 4559 additions and 667 deletions

36
check_coolify_logs.js Normal file
View File

@@ -0,0 +1,36 @@
const DFS_LOGIN = process.env.COOLIFY_API_TOKEN;
async function checkDeployment() {
const url = `${process.env.COOLIFY_URL}/api/v1/deployments?resource_uuid=y4cscsc8s08c8808go0448s0&per_page=1`;
console.log(`Pinging Coolify API at ${url}...`);
const response = await fetch(url, {
headers: {
"Authorization": `Bearer ${DFS_LOGIN}`
}
});
const data = await response.json();
if (data && data.length > 0) {
const deploy = data[0];
console.log(`\nDeployment UUID: ${deploy.deployment_uuid}`);
console.log(`Status: ${deploy.status}`);
console.log(`Created At: ${deploy.created_at}`);
// Parse the logs array to see what it's currently doing
try {
const logs = JSON.parse(deploy.logs);
if (logs && logs.length > 0) {
console.log("\nLast 5 log lines from the build container:");
logs.slice(-5).forEach(log => {
console.log(`[${log.timestamp}] ${log.type}: ${log.output.substring(0, 100)}`);
});
}
} catch(e) {
console.log("Could not parse logs array.");
}
} else {
console.log("No deployments found.");
}
}
checkDeployment().catch(console.error);

View File

@@ -0,0 +1,47 @@
Category,Tier,Canada,United States,Total Market Size
"day_care_center",1,8875,49542,58417
"martial_arts_school",2,2763,22131,24894
"dance_school",2,2785,18951,21736
"after_school_program",1,1765,18992,20757
"community_center",2,2510,12556,15066
"summer_camp",1,1301,12109,13410
"music_school",2,2381,11015,13396
"recreation_center",1,1672,8824,10496
"camp",1,1133,7801,8934
"sports_complex",2,1160,7013,8173
"youth_organization",1,576,7351,7927
"art_school",2,857,5538,6395
"gymnastics_center",2,323,4655,4978
"boxing_gym",2,519,4171,4690
"equestrian_facility",2,666,3625,4291
"children_amusement_center",1,262,3271,3533
"swimming_school",2,385,2531,2916
"ballet_school",2,231,2290,2521
"adventure_sports_center",2,448,2000,2448
"tennis_club",2,264,2043,2307
"boot_camp",1,206,1971,2177
"athletic_club",2,213,1959,2172
"drum_school",2,147,1593,1740
"baseball_club",2,105,1596,1701
"boat_club",2,97,1477,1574
"basketball_club",2,200,1349,1549
"childrens_club",1,185,1211,1396
"drama_school",2,268,1100,1368
"baby_swimming_school",2,159,1202,1361
"archery_range",2,76,844,920
"surf_school",2,62,755,817
"sailing_school",2,121,583,704
"aquatic_center",2,93,506,599
"cooking_school",2,84,479,563
"drawing_lessons",2,86,379,465
"equestrian_club",2,73,380,453
"aikido_school",2,44,348,392
"bicycle_club",2,64,318,382
"archery_club",2,57,254,311
"chess_club",2,53,218,271
"childrens_farm",1,61,191,252
"canoe_and_kayak_club",2,64,157,221
"badminton_club",2,97,82,179
"english_language_camp",1,31,101,132
"capoeira_school",2,11,92,103
"riding_school",2,0,0,0
1 Category Tier Canada United States Total Market Size
2 day_care_center 1 8875 49542 58417
3 martial_arts_school 2 2763 22131 24894
4 dance_school 2 2785 18951 21736
5 after_school_program 1 1765 18992 20757
6 community_center 2 2510 12556 15066
7 summer_camp 1 1301 12109 13410
8 music_school 2 2381 11015 13396
9 recreation_center 1 1672 8824 10496
10 camp 1 1133 7801 8934
11 sports_complex 2 1160 7013 8173
12 youth_organization 1 576 7351 7927
13 art_school 2 857 5538 6395
14 gymnastics_center 2 323 4655 4978
15 boxing_gym 2 519 4171 4690
16 equestrian_facility 2 666 3625 4291
17 children_amusement_center 1 262 3271 3533
18 swimming_school 2 385 2531 2916
19 ballet_school 2 231 2290 2521
20 adventure_sports_center 2 448 2000 2448
21 tennis_club 2 264 2043 2307
22 boot_camp 1 206 1971 2177
23 athletic_club 2 213 1959 2172
24 drum_school 2 147 1593 1740
25 baseball_club 2 105 1596 1701
26 boat_club 2 97 1477 1574
27 basketball_club 2 200 1349 1549
28 childrens_club 1 185 1211 1396
29 drama_school 2 268 1100 1368
30 baby_swimming_school 2 159 1202 1361
31 archery_range 2 76 844 920
32 surf_school 2 62 755 817
33 sailing_school 2 121 583 704
34 aquatic_center 2 93 506 599
35 cooking_school 2 84 479 563
36 drawing_lessons 2 86 379 465
37 equestrian_club 2 73 380 453
38 aikido_school 2 44 348 392
39 bicycle_club 2 64 318 382
40 archery_club 2 57 254 311
41 chess_club 2 53 218 271
42 childrens_farm 1 61 191 252
43 canoe_and_kayak_club 2 64 157 221
44 badminton_club 2 97 82 179
45 english_language_camp 1 31 101 132
46 capoeira_school 2 11 92 103
47 riding_school 2 0 0 0

167
new-site/Beta Signup.html Normal file
View File

@@ -0,0 +1,167 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Vibn — Request an invite</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="icon" type="image/png" href="assets/logo-black.png" />
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet" />
<style>
:root {
--bg: oklch(0.155 0.008 60);
--bg-1: oklch(0.185 0.009 60);
--bg-2: oklch(0.225 0.010 60);
--hairline: oklch(0.32 0.010 60 / 0.55);
--hairline-2: oklch(0.40 0.012 60 / 0.35);
--fg: oklch(0.97 0.005 80);
--fg-dim: oklch(0.78 0.006 80);
--fg-mute: oklch(0.58 0.006 80);
--fg-faint: oklch(0.42 0.006 80);
--accent: oklch(0.74 0.175 35);
--accent-soft: oklch(0.74 0.175 35 / 0.18);
--accent-glow: oklch(0.74 0.175 35 / 0.35);
--accent-fg: #1a0f0a;
--ok: oklch(0.78 0.16 155);
--font-sans: "Geist", ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; min-height: 100%; }
body {
background: var(--bg);
color: var(--fg);
font-family: var(--font-sans);
line-height: 1.45;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overflow-x: hidden;
}
body::before {
content: "";
position: fixed; inset: 0;
background-image:
linear-gradient(to right, oklch(0.30 0.01 60 / 0.10) 1px, transparent 1px),
linear-gradient(to bottom, oklch(0.30 0.01 60 / 0.10) 1px, transparent 1px);
background-size: 56px 56px;
mask-image: radial-gradient(ellipse 80% 80% at 50% 50%, #000 30%, transparent 80%);
-webkit-mask-image: radial-gradient(ellipse 80% 80% at 50% 50%, #000 30%, transparent 80%);
pointer-events: none;
z-index: 0;
}
body::after {
content: "";
position: fixed; inset: 0;
pointer-events: none;
z-index: 1;
opacity: 0.035;
mix-blend-mode: overlay;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%25' height='100%25' filter='url(%23n)' opacity='0.85'/></svg>");
}
a { color: inherit; text-decoration: none; }
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
h1, h2, h3 { margin: 0; font-weight: 500; letter-spacing: -0.02em; line-height: 1.05; }
p { margin: 0; }
::selection { background: var(--accent); color: var(--accent-fg); }
.wrap {
position: relative;
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding-inline: clamp(20px, 4vw, 56px);
z-index: 2;
}
.nav {
position: sticky; top: 0; z-index: 50;
backdrop-filter: blur(12px) saturate(140%);
background: oklch(0.155 0.008 60 / 0.55);
border-bottom: 1px solid transparent;
transition: border-color .2s;
}
.nav.scrolled { border-bottom-color: oklch(0.30 0.01 60 / 0.4); }
.nav-inner {
display: flex; align-items: center; justify-content: space-between;
height: 64px;
}
.logo {
display: inline-flex; align-items: center; gap: 9px;
font-weight: 600; font-size: 17px; letter-spacing: -0.02em;
}
.logo-mark {
width: 26px; height: 26px; border-radius: 50%;
background: linear-gradient(135deg, var(--accent) 0%, oklch(0.65 0.20 18) 100%);
box-shadow: 0 0 22px var(--accent-glow), inset 0 1px 0 oklch(1 0 0 / 0.25);
display: grid; place-items: center;
color: var(--accent-fg);
flex-shrink: 0;
}
.logo-mark svg { display: block; }
.logo-caret { animation: caret-blink 1.4s steps(2) infinite; }
@keyframes caret-blink { 50% { opacity: 0.25; } }
.nav-back {
color: var(--fg-mute); font-size: 14px;
display: inline-flex; align-items: center; gap: 6px;
}
.nav-back:hover { color: var(--fg); }
.eyebrow {
display: inline-flex; align-items: center; gap: 8px;
font-family: var(--font-mono);
font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase;
color: var(--fg-mute);
}
.eyebrow::before {
content: ""; width: 5px; height: 5px; border-radius: 50%;
background: var(--accent); box-shadow: 0 0 12px var(--accent-glow);
}
.mono { font-family: var(--font-mono); }
.btn {
display: inline-flex; align-items: center; justify-content: center;
gap: 10px;
height: 50px; padding: 0 22px;
border-radius: 999px;
font-weight: 500;
transition: transform .12s, box-shadow .2s, background .2s;
white-space: nowrap;
}
.btn-primary {
background: var(--accent);
color: var(--accent-fg);
box-shadow:
0 0 0 1px oklch(0.84 0.16 35 / 0.5) inset,
0 10px 40px -10px var(--accent-glow),
0 0 50px -8px var(--accent-glow);
}
.btn-primary:hover { transform: translateY(-1px); }
.btn-primary[disabled] { opacity: .55; cursor: not-allowed; transform: none; }
.btn-primary .arrow { transition: transform .15s; }
.btn-primary:hover .arrow { transform: translateX(3px); }
.btn-ghost {
color: var(--fg-dim);
border: 1px solid var(--hairline);
background: oklch(0.20 0.009 60 / 0.4);
backdrop-filter: blur(8px);
}
.btn-ghost:hover { color: var(--fg); border-color: var(--hairline-2); }
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="beta.jsx"></script>
</body>
</html>

227
new-site/app.jsx Normal file
View File

@@ -0,0 +1,227 @@
// 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": "promise",
"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 />
<Journey />
<Audience />
<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">
<TweakRadio
label="Headline"
value={t.heroVariant}
options={[
{ value: "quote", label: "Reddit quote" },
{ value: "promise", label: "The promise" },
]}
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="#" 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 />);

177
new-site/audience.jsx Normal file
View File

@@ -0,0 +1,177 @@
// 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",
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.",
},
];
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-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">
If you've ever felt this, Vibn was built for you.
</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>
<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 });

809
new-site/beta.jsx Normal file
View File

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

150
new-site/closing.jsx Normal file
View File

@@ -0,0 +1,150 @@
// Closing CTA + Footer.
function Closing() {
return (
<section className="section closing">
<style>{`
.closing {
padding-block: clamp(100px, 14vh, 180px);
position: relative; overflow: hidden;
text-align: center;
}
.closing-glow {
position: absolute; inset: 0;
pointer-events: none;
}
.closing-inner {
position: relative;
max-width: 900px; margin: 0 auto;
}
.closing-title {
font-size: clamp(40px, 6vw, 84px);
font-weight: 500; letter-spacing: -0.03em;
line-height: 1.02;
text-wrap: balance;
}
.closing-title .accent { color: var(--accent); }
.closing-title em {
font-style: normal;
background: linear-gradient(180deg, var(--accent), oklch(0.62 0.18 18));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.closing-sub {
margin-top: 28px;
font-size: clamp(17px, 1.6vw, 21px);
color: var(--fg-dim);
text-wrap: balance;
max-width: 640px; margin-inline: auto;
}
.closing-cta {
margin-top: 36px;
display: inline-flex; flex-direction: column; align-items: center; gap: 14px;
}
.closing-cta .btn { height: 56px; padding: 0 28px; font-size: 16px; }
.closing-cta .row {
display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
justify-content: center;
}
`}</style>
<Glow color="oklch(0.74 0.175 35 / 0.35)" size={1000}
style={{ top: "20%", left: "50%", transform: "translateX(-50%)" }} />
<Glow color="oklch(0.45 0.10 35 / 0.20)" size={600}
style={{ bottom: "-200px", left: "50%", transform: "translateX(-50%)" }} />
<div className="wrap closing-inner">
<h2 className="closing-title">
If you can <em>describe</em> it,
<br/>you can <em>build</em> it.
</h2>
<p className="closing-sub">
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="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
new-site/crossed.jsx Normal file
View File

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

370
new-site/hero.jsx Normal file
View File

@@ -0,0 +1,370 @@
// Hero: the Reddit quote headline + prompt input.
// Visitors can type into the prompt; cycling placeholders, suggestion chips, submit handler logs to console.
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",
];
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 ${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;
}
@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 === "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/SideProject</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 });

219
new-site/index.html Normal file
View File

@@ -0,0 +1,219 @@
<!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="journey.jsx"></script>
<script type="text/babel" src="audience.jsx"></script>
<script type="text/babel" src="closing.jsx"></script>
<script type="text/babel" src="app.jsx"></script>
</body>
</html>

333
new-site/journey.jsx Normal file
View File

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

108
new-site/primitives.jsx Normal file
View File

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

568
new-site/tweaks-panel.jsx Normal file
View File

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

251
new-site/wall.jsx Normal file
View File

@@ -0,0 +1,251 @@
// The Wall — recreates the moment the vibe dies. Faux chat from a "generic" AI
// coding tool that hands back a homework list. Ends on the punchline.
function Wall() {
return (
<section className="section wall" id="the-wall">
<style>{`
.wall { padding-block: clamp(60px, 9vh, 110px); }
.wall-head { text-align: center; max-width: 760px; margin: 0 auto 56px; }
.wall-title {
font-size: clamp(36px, 4.8vw, 64px);
font-weight: 500; letter-spacing: -0.025em; line-height: 1.02;
text-wrap: balance;
}
.wall-title em {
font-style: normal;
color: var(--accent);
text-shadow: 0 0 30px var(--accent-glow);
}
.wall-sub {
margin-top: 20px;
color: var(--fg-mute);
font-size: 17px;
text-wrap: balance;
}
/* Faux app window */
.window {
max-width: 880px; margin: 0 auto;
position: relative;
border-radius: 16px;
background: oklch(0.165 0.008 60 / 0.85);
border: 1px solid var(--hairline);
box-shadow: 0 30px 80px -20px oklch(0 0 0 / 0.6);
overflow: hidden;
backdrop-filter: blur(10px);
}
.window-bar {
display: flex; align-items: center; gap: 14px;
padding: 11px 14px;
background: oklch(0.20 0.009 60 / 0.85);
border-bottom: 1px solid var(--hairline);
font-family: var(--font-mono);
font-size: 12px;
color: var(--fg-mute);
}
.traffic { display: flex; gap: 7px; }
.traffic i {
width: 11px; height: 11px; border-radius: 50%;
background: oklch(0.40 0.01 60);
}
.window-name {
margin-left: 8px; color: var(--fg-faint);
letter-spacing: 0.02em;
}
.window-tag {
margin-left: auto;
padding: 2px 8px; border-radius: 4px;
background: oklch(0.25 0.01 60); color: var(--fg-faint);
font-size: 11px;
}
.chat { padding: 24px; display: flex; flex-direction: column; gap: 16px; }
.msg {
display: flex; gap: 12px; align-items: flex-start;
font-size: 14.5px; line-height: 1.55;
}
.avatar {
width: 26px; height: 26px; border-radius: 7px;
display: grid; place-items: center;
font-family: var(--font-mono); font-size: 11px; font-weight: 600;
flex-shrink: 0;
}
.avatar.user { background: oklch(0.28 0.01 60); color: var(--fg-dim); }
.avatar.ai { background: oklch(0.30 0.02 250); color: oklch(0.85 0.06 250); }
.msg-body { flex: 1; min-width: 0; }
.msg-name {
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-faint);
letter-spacing: 0.04em;
margin-bottom: 3px;
text-transform: uppercase;
}
.msg p { margin: 0; color: var(--fg-dim); }
.msg.user .msg-body p { color: var(--fg); }
.homework-intro { color: var(--fg-dim); }
.homework-list {
list-style: none; padding: 0; margin: 12px 0 0;
display: flex; flex-direction: column; gap: 8px;
counter-reset: hw;
}
.homework-list li {
counter-increment: hw;
display: flex; gap: 12px; align-items: flex-start;
padding: 12px 14px;
background: oklch(0.20 0.009 60);
border: 1px solid var(--hairline);
border-radius: 10px;
color: var(--fg-dim);
font-size: 13.5px;
transition: opacity .3s, filter .3s;
}
.homework-list li::before {
content: counter(hw, decimal-leading-zero);
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-faint);
padding: 1px 6px;
background: oklch(0.16 0.008 60);
border-radius: 4px;
flex-shrink: 0;
}
.homework-list li .ext {
margin-left: auto;
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-faint);
padding: 1px 7px;
border: 1px solid var(--hairline);
border-radius: 4px;
flex-shrink: 0;
}
.homework-list li b { color: var(--fg); font-weight: 500; }
/* desaturate the bottom of the list to convey overload */
.homework-list li:nth-child(4) { opacity: .82; }
.homework-list li:nth-child(5) { opacity: .65; }
.homework-list li:nth-child(6) { opacity: .48; filter: blur(.2px); }
.homework-list li:nth-child(7) { opacity: .34; filter: blur(.4px); }
.homework-list li:nth-child(8) { opacity: .22; filter: blur(.7px); }
.homework-fade {
margin-top: -10px; padding-top: 30px;
background: linear-gradient(180deg, transparent, oklch(0.165 0.008 60 / 0.85));
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-faint);
text-align: center;
}
.typing {
display: inline-flex; gap: 3px; align-items: center;
padding: 4px 0;
color: var(--fg-mute);
}
.typing i {
width: 5px; height: 5px; border-radius: 50%; background: var(--fg-mute);
animation: bounce 1.2s infinite ease-in-out;
}
.typing i:nth-child(2) { animation-delay: .15s; }
.typing i:nth-child(3) { animation-delay: .3s; }
@keyframes bounce {
0%, 80%, 100% { transform: translateY(0); opacity: .5; }
40% { transform: translateY(-3px); opacity: 1; }
}
/* The punchline beat */
.punchline {
margin-top: 56px;
text-align: center;
}
.punchline-text {
font-size: clamp(28px, 3.4vw, 42px);
font-weight: 500; letter-spacing: -0.022em;
color: var(--fg-mute);
line-height: 1.2; text-wrap: balance;
}
.punchline-text em {
font-style: italic;
color: var(--fg);
}
.punchline-divider {
width: 1px; height: 56px;
background: linear-gradient(180deg, transparent, var(--hairline), transparent);
margin: 0 auto 28px;
}
`}</style>
<div className="wrap">
<div className="wall-head">
<Eyebrow>The wall</Eyebrow>
<h2 className="wall-title" style={{ marginTop: 18 }}>
Every other tool stops <em>right here</em>.
</h2>
<p className="wall-sub">
You built it. It works on your laptop. Then the chat hands you a list.
</p>
</div>
<div className="window">
<div className="window-bar">
<div className="traffic"><i/><i/><i/></div>
<span className="window-name">untitled-project · main</span>
<span className="window-tag">generic ai coder · chat</span>
</div>
<div className="chat">
<div className="msg user">
<div className="avatar user">YOU</div>
<div className="msg-body">
<div className="msg-name">You · just now</div>
<p>okay it works!! how do i put this online so my customers can use it?</p>
</div>
</div>
<div className="msg ai">
<div className="avatar ai">AI</div>
<div className="msg-body">
<div className="msg-name">Generic AI · just now</div>
<p className="homework-intro">
Great job 🎉 Your app is running locally. To take it live, you'll need to set a few things up first:
</p>
<ol className="homework-list">
<li><b>Sign up for Supabase</b> and create a project for your database.<span className="ext">↗ external</span></li>
<li><b>Configure authentication</b> with Supabase Auth or Clerk — pick one.<span className="ext">↗ external</span></li>
<li><b>Create a GitHub repo</b>, commit your code, and push it.<span className="ext">↗ external</span></li>
<li><b>Deploy to Vercel</b>: connect repo, configure framework preset.<span className="ext">↗ external</span></li>
<li><b>Add environment variables</b> for your API keys and DB url in the Vercel dashboard.<span className="ext">↗ external</span></li>
<li><b>Set up DNS</b> for your custom domain and verify nameservers with your registrar.<span className="ext">↗ external</span></li>
<li><b>Configure SSL / TLS certificates</b> for HTTPS (or use Vercel's automatic provisioning).<span className="ext"> external</span></li>
<li><b>Set up Stripe</b> if you want to take payments, and configure webhooks.<span className="ext"> external</span></li>
</ol>
<div className="homework-fade"> 23 more steps</div>
</div>
</div>
<div className="msg user">
<div className="avatar user">YOU</div>
<div className="msg-body">
<div className="msg-name">You · now</div>
<p style={{ color: "var(--fg-mute)" }}>
<span className="typing"><i/><i/><i/></span>
</p>
</div>
</div>
</div>
</div>
<div className="punchline">
<div className="punchline-divider"></div>
<p className="punchline-text">
And just like that <em>the vibe is gone.</em>
</p>
</div>
</div>
</section>
);
}
Object.assign(window, { Wall });

17
patch_stripe_keys.js Normal file
View File

@@ -0,0 +1,17 @@
const fs = require('fs');
const envPath = 'vibn-frontend/.env.local';
let content = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8') : '';
if (!content.includes('STRIPE_CLIENT_ID')) {
content += `\nSTRIPE_CLIENT_ID=ca_UTuWw2qE8wFLNlWOL7T1v0H5GdB6BtDw\n`;
fs.writeFileSync(envPath, content);
console.log("✅ Successfully added STRIPE_CLIENT_ID to .env.local!");
} else {
// Replace it just in case
content = content.replace(/STRIPE_CLIENT_ID=.*/, 'STRIPE_CLIENT_ID=ca_UTuWw2qE8wFLNlWOL7T1v0H5GdB6BtDw');
fs.writeFileSync(envPath, content);
console.log("✅ Successfully updated STRIPE_CLIENT_ID in .env.local!");
}

View File

@@ -12,189 +12,243 @@
* - Calls vibn-frontend's PATCH /api/projects/[id]/agent/sessions/[sid]
*/
import { execSync } from 'child_process';
import { createLLM, toOAITools, LLMMessage } from './llm';
import { AgentConfig } from './agents';
import { executeTool, ToolContext } from './tools';
import { resolvePrompt } from './prompts/loader';
import { ingestSessionEvents } from './vibn-events-ingest';
import { execSync } from "child_process";
import { createLLM, toOAITools, LLMMessage } from "./llm";
import { AgentConfig } from "./agents";
import { executeTool, ToolContext } from "./tools";
import { resolvePrompt } from "./prompts/loader";
import { ingestSessionEvents } from "./vibn-events-ingest";
const MAX_TURNS = 60;
export interface OutputLine {
ts: string;
type: 'step' | 'stdout' | 'stderr' | 'info' | 'error' | 'done';
text: string;
ts: string;
type: "step" | "stdout" | "stderr" | "info" | "error" | "done";
text: string;
}
export interface SessionRunOptions {
sessionId: string;
projectId: string;
vibnApiUrl: string; // e.g. https://vibnai.com
appPath: string; // relative path within repo, e.g. "apps/admin"
repoRoot?: string; // absolute path to the git repo root (for auto-commit)
isStopped: () => boolean;
// Auto-approve: commit + push + deploy without user confirmation
autoApprove?: boolean;
giteaRepo?: string; // e.g. "mark/sportsy"
coolifyAppUuid?: string;
coolifyApiUrl?: string;
coolifyApiToken?: string;
sessionId: string;
projectId: string;
vibnApiUrl: string; // e.g. https://vibnai.com
appPath: string; // relative path within repo, e.g. "apps/admin"
repoRoot?: string; // absolute path to the git repo root (for auto-commit)
isStopped: () => boolean;
// Auto-approve: commit + push + deploy without user confirmation
autoApprove?: boolean;
giteaRepo?: string; // e.g. "mark/sportsy"
coolifyAppUuid?: string;
coolifyApiUrl?: string;
coolifyApiToken?: string;
}
// ── VIBN DB bridge ────────────────────────────────────────────────────────────
async function patchSession(
opts: SessionRunOptions,
payload: {
status?: string;
outputLine?: OutputLine;
changedFile?: { path: string; status: string };
error?: string;
}
opts: SessionRunOptions,
payload: {
status?: string;
outputLine?: OutputLine;
changedFile?: { path: string; status: string };
error?: string;
},
): Promise<void> {
const url = `${opts.vibnApiUrl}/api/projects/${opts.projectId}/agent/sessions/${opts.sessionId}`;
try {
await fetch(url, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'x-agent-runner-secret': process.env.AGENT_RUNNER_SECRET ?? '' },
body: JSON.stringify(payload),
});
} catch (err) {
// Log but don't crash — output will be lost for this line but loop continues
console.warn('[session-runner] PATCH failed:', err instanceof Error ? err.message : err);
}
const url = `${opts.vibnApiUrl}/api/projects/${opts.projectId}/agent/sessions/${opts.sessionId}`;
try {
await fetch(url, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"x-agent-runner-secret": process.env.AGENT_RUNNER_SECRET ?? "",
},
body: JSON.stringify(payload),
});
} catch (err) {
// Log but don't crash — output will be lost for this line but loop continues
console.warn(
"[session-runner] PATCH failed:",
err instanceof Error ? err.message : err,
);
}
}
function now(): string {
return new Date().toISOString();
return new Date().toISOString();
}
// ── File change tracking ──────────────────────────────────────────────────────
const FILE_WRITE_TOOLS = new Set(['write_file', 'replace_in_file', 'create_file']);
const FILE_WRITE_TOOLS = new Set([
"write_file",
"replace_in_file",
"create_file",
]);
function extractChangedFile(
toolName: string,
args: Record<string, unknown>,
workspaceRoot: string,
appPath: string
toolName: string,
args: Record<string, unknown>,
workspaceRoot: string,
appPath: string,
): { path: string; status: string } | null {
if (!FILE_WRITE_TOOLS.has(toolName)) return null;
const rawPath = String(args.path ?? args.file_path ?? '');
if (!rawPath) return null;
if (!FILE_WRITE_TOOLS.has(toolName)) return null;
const rawPath = String(args.path ?? args.file_path ?? "");
if (!rawPath) return null;
// Make path relative to appPath for display
const fullPrefix = `${workspaceRoot}/${appPath}/`;
const appPrefix = `${appPath}/`;
let displayPath = rawPath
.replace(fullPrefix, '')
.replace(appPrefix, '');
// Make path relative to appPath for display
const fullPrefix = `${workspaceRoot}/${appPath}/`;
const appPrefix = `${appPath}/`;
let displayPath = rawPath.replace(fullPrefix, "").replace(appPrefix, "");
const fileStatus = toolName === 'write_file' ? 'added' : 'modified';
return { path: displayPath, status: fileStatus };
const fileStatus = toolName === "write_file" ? "added" : "modified";
return { path: displayPath, status: fileStatus };
}
// ── Auto-commit helper ────────────────────────────────────────────────────────
async function autoCommitAndDeploy(
opts: SessionRunOptions,
task: string,
emit: (line: OutputLine) => Promise<void>
opts: SessionRunOptions,
task: string,
emit: (line: OutputLine) => Promise<void>,
): Promise<void> {
const repoRoot = opts.repoRoot;
if (!repoRoot || !opts.giteaRepo) {
await emit({ ts: now(), type: 'info', text: 'Auto-approve skipped — no repo root available.' });
return;
}
const repoRoot = opts.repoRoot;
if (!repoRoot || !opts.giteaRepo) {
await emit({
ts: now(),
type: "info",
text: "Auto-approve skipped — no repo root available.",
});
return;
}
const gitOpts = { cwd: repoRoot, stdio: 'pipe' as const };
const giteaApiUrl = process.env.GITEA_API_URL || '';
const giteaUsername = process.env.GITEA_USERNAME || 'agent';
const giteaToken = process.env.GITEA_API_TOKEN || '';
const gitOpts = { cwd: repoRoot, stdio: "pipe" as const };
const giteaApiUrl = process.env.GITEA_API_URL || "";
const giteaUsername = process.env.GITEA_USERNAME || "agent";
const giteaToken = process.env.GITEA_API_TOKEN || "";
try {
try {
try {
execSync('git config user.email "agent@vibnai.com"', gitOpts);
execSync('git config user.name "VIBN Agent"', gitOpts);
} catch { /* already set */ }
execSync('git add -A', gitOpts);
const status = execSync('git status --porcelain', gitOpts).toString().trim();
if (!status) {
await emit({ ts: now(), type: 'info', text: '✓ No file changes to commit.' });
await patchSession(opts, { status: 'approved' });
return;
}
const commitMsg = `agent: ${task.slice(0, 72)}`;
execSync(`git commit -m ${JSON.stringify(commitMsg)}`, gitOpts);
await emit({ ts: now(), type: 'info', text: `✓ Committed: "${commitMsg}"` });
const authedUrl = `${giteaApiUrl}/${opts.giteaRepo}.git`
.replace('https://', `https://${giteaUsername}:${giteaToken}@`);
execSync(`git push "${authedUrl}" HEAD:main`, gitOpts);
await emit({ ts: now(), type: 'info', text: '✓ Pushed to Gitea.' });
// Optional Coolify deploy
let deployed = false;
if (opts.coolifyApiUrl && opts.coolifyApiToken && opts.coolifyAppUuid) {
try {
const deployRes = await fetch(
`${opts.coolifyApiUrl}/api/v1/applications/${opts.coolifyAppUuid}/start`,
{ method: 'POST', headers: { Authorization: `Bearer ${opts.coolifyApiToken}` } }
);
deployed = deployRes.ok;
if (deployed) await emit({ ts: now(), type: 'info', text: '✓ Deployment triggered.' });
} catch { /* best-effort */ }
}
await patchSession(opts, {
status: 'approved',
outputLine: {
ts: now(), type: 'done',
text: `✓ Auto-committed & ${deployed ? 'deployed' : 'pushed'}. No approval needed.`,
},
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
await emit({ ts: now(), type: 'error', text: `Auto-commit failed: ${msg}` });
// Fall back to done so user can manually approve
await patchSession(opts, { status: 'done' });
execSync('git config user.email "agent@vibnai.com"', gitOpts);
execSync('git config user.name "VIBN Agent"', gitOpts);
} catch {
/* already set */
}
execSync("git add -A", gitOpts);
const status = execSync("git status --porcelain", gitOpts)
.toString()
.trim();
if (!status) {
await emit({
ts: now(),
type: "info",
text: "✓ No file changes to commit.",
});
await patchSession(opts, { status: "approved" });
return;
}
const commitMsg = `agent: ${task.slice(0, 72)}`;
const msgFile = require("path").join(
opts.workspaceRoot,
".git",
"COMMIT_EDITMSG",
);
require("fs").writeFileSync(msgFile, commitMsg, "utf8");
execSync("git commit -F .git/COMMIT_EDITMSG", gitOpts);
try {
require("fs").unlinkSync(msgFile);
} catch {}
await emit({
ts: now(),
type: "info",
text: `✓ Committed: "${commitMsg}"`,
});
const authedUrl = `${giteaApiUrl}/${opts.giteaRepo}.git`.replace(
"https://",
`https://${giteaUsername}:${giteaToken}@`,
);
execSync(`git push "${authedUrl}" HEAD:main`, gitOpts);
await emit({ ts: now(), type: "info", text: "✓ Pushed to Gitea." });
// Optional Coolify deploy
let deployed = false;
if (opts.coolifyApiUrl && opts.coolifyApiToken && opts.coolifyAppUuid) {
try {
const deployRes = await fetch(
`${opts.coolifyApiUrl}/api/v1/applications/${opts.coolifyAppUuid}/start`,
{
method: "POST",
headers: { Authorization: `Bearer ${opts.coolifyApiToken}` },
},
);
deployed = deployRes.ok;
if (deployed)
await emit({
ts: now(),
type: "info",
text: "✓ Deployment triggered.",
});
} catch {
/* best-effort */
}
}
await patchSession(opts, {
status: "approved",
outputLine: {
ts: now(),
type: "done",
text: `✓ Auto-committed & ${deployed ? "deployed" : "pushed"}. No approval needed.`,
},
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
await emit({
ts: now(),
type: "error",
text: `Auto-commit failed: ${msg}`,
});
// Fall back to done so user can manually approve
await patchSession(opts, { status: "done" });
}
}
// ── Main streaming execution loop ─────────────────────────────────────────────
export async function runSessionAgent(
config: AgentConfig,
task: string,
ctx: ToolContext,
opts: SessionRunOptions
config: AgentConfig,
task: string,
ctx: ToolContext,
opts: SessionRunOptions,
): Promise<void> {
const llm = createLLM(config.model, { temperature: 0.2 });
const oaiTools = toOAITools(config.tools);
const llm = createLLM(config.model, { temperature: 0.2 });
const oaiTools = toOAITools(config.tools);
const emit = async (line: OutputLine) => {
console.log(`[session ${opts.sessionId}] ${line.type}: ${line.text}`);
await Promise.all([
patchSession(opts, { outputLine: line }),
ingestSessionEvents(opts.vibnApiUrl, opts.projectId, opts.sessionId, [
{
type: `output.${line.type}`,
payload: { text: line.text },
ts: line.ts,
},
]),
]);
};
const emit = async (line: OutputLine) => {
console.log(`[session ${opts.sessionId}] ${line.type}: ${line.text}`);
await Promise.all([
patchSession(opts, { outputLine: line }),
ingestSessionEvents(opts.vibnApiUrl, opts.projectId, opts.sessionId, [
{
type: `output.${line.type}`,
payload: { text: line.text },
ts: line.ts,
},
]),
]);
};
await emit({ ts: now(), type: 'info', text: `Agent starting (${llm.modelId}) — working in ${opts.appPath}` });
await emit({
ts: now(),
type: "info",
text: `Agent starting (${llm.modelId}) — working in ${opts.appPath}`,
});
// Scope the system prompt to the specific app within the monorepo
const basePrompt = resolvePrompt(config.promptId);
const scopedPrompt = `${basePrompt}
// Scope the system prompt to the specific app within the monorepo
const basePrompt = resolvePrompt(config.promptId);
const scopedPrompt = `${basePrompt}
## Active context
You are working inside the monorepo directory: ${opts.appPath}
@@ -203,135 +257,166 @@ When running commands, always cd into ${opts.appPath} first unless already there
Do NOT run git commit or git push — the platform handles committing after you finish.
`;
const history: LLMMessage[] = [
{ role: 'user', content: task }
const history: LLMMessage[] = [{ role: "user", content: task }];
let turn = 0;
let finalText = "";
const trackedFiles = new Map<string, string>(); // path → status
while (turn < MAX_TURNS) {
// Check for stop signal between turns
if (opts.isStopped()) {
await emit({ ts: now(), type: "info", text: "Stopped by user." });
await patchSession(opts, { status: "stopped" });
return;
}
turn++;
await emit({ ts: now(), type: "info", text: `Turn ${turn} — thinking…` });
const messages: LLMMessage[] = [
{ role: "system", content: scopedPrompt },
...history,
];
let turn = 0;
let finalText = '';
const trackedFiles = new Map<string, string>(); // path → status
while (turn < MAX_TURNS) {
// Check for stop signal between turns
if (opts.isStopped()) {
await emit({ ts: now(), type: 'info', text: 'Stopped by user.' });
await patchSession(opts, { status: 'stopped' });
return;
}
turn++;
await emit({ ts: now(), type: 'info', text: `Turn ${turn} — thinking…` });
const messages: LLMMessage[] = [
{ role: 'system', content: scopedPrompt },
...history
];
let response: Awaited<ReturnType<typeof llm.chat>>;
try {
response = await llm.chat(messages, oaiTools, 8192);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
await emit({ ts: now(), type: 'error', text: `LLM error: ${msg}` });
await patchSession(opts, { status: 'failed', error: msg });
return;
}
const assistantMsg: LLMMessage = {
role: 'assistant',
content: response.content,
tool_calls: response.tool_calls.length > 0 ? response.tool_calls : undefined
};
history.push(assistantMsg);
// Agent finished — no more tool calls
if (response.tool_calls.length === 0) {
finalText = response.content ?? 'Task complete.';
break;
}
// Execute each tool call
for (const tc of response.tool_calls) {
if (opts.isStopped()) break;
const fnName = tc.function.name;
let fnArgs: Record<string, unknown> = {};
try { fnArgs = JSON.parse(tc.function.arguments || '{}'); } catch { /* bad JSON */ }
// Human-readable step label
const stepLabel = buildStepLabel(fnName, fnArgs);
await emit({ ts: now(), type: 'step', text: stepLabel });
let result: unknown;
try {
result = await executeTool(fnName, fnArgs, ctx);
} catch (err) {
result = { error: err instanceof Error ? err.message : String(err) };
}
// Stream stdout/stderr if present
if (result && typeof result === 'object') {
const r = result as Record<string, unknown>;
if (r.stdout && String(r.stdout).trim()) {
for (const line of String(r.stdout).split('\n').filter(Boolean).slice(0, 40)) {
await emit({ ts: now(), type: 'stdout', text: line });
}
}
if (r.stderr && String(r.stderr).trim()) {
for (const line of String(r.stderr).split('\n').filter(Boolean).slice(0, 20)) {
await emit({ ts: now(), type: 'stderr', text: line });
}
}
if (r.error) {
await emit({ ts: now(), type: 'error', text: String(r.error) });
}
}
// Track file changes
const changed = extractChangedFile(fnName, fnArgs, ctx.workspaceRoot, opts.appPath);
if (changed && !trackedFiles.has(changed.path)) {
trackedFiles.set(changed.path, changed.status);
await patchSession(opts, { changedFile: changed });
await emit({ ts: now(), type: 'info', text: `${changed.status === 'added' ? '+ Created' : '~ Modified'} ${changed.path}` });
}
history.push({
role: 'tool',
tool_call_id: tc.id,
name: fnName,
content: typeof result === 'string' ? result : JSON.stringify(result)
});
}
let response: Awaited<ReturnType<typeof llm.chat>>;
try {
response = await llm.chat(messages, oaiTools, 8192);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
await emit({ ts: now(), type: "error", text: `LLM error: ${msg}` });
await patchSession(opts, { status: "failed", error: msg });
return;
}
if (turn >= MAX_TURNS && !finalText) {
finalText = `Hit the ${MAX_TURNS}-turn limit. Stopping.`;
const assistantMsg: LLMMessage = {
role: "assistant",
content: response.content,
tool_calls:
response.tool_calls.length > 0 ? response.tool_calls : undefined,
};
history.push(assistantMsg);
// Agent finished — no more tool calls
if (response.tool_calls.length === 0) {
finalText = response.content ?? "Task complete.";
break;
}
await emit({ ts: now(), type: 'done', text: finalText });
// Execute each tool call
for (const tc of response.tool_calls) {
if (opts.isStopped()) break;
if (opts.autoApprove) {
await autoCommitAndDeploy(opts, task, emit);
} else {
await patchSession(opts, {
status: 'done',
outputLine: { ts: now(), type: 'done', text: '✓ Complete — review changes and approve to commit.' },
const fnName = tc.function.name;
let fnArgs: Record<string, unknown> = {};
try {
fnArgs = JSON.parse(tc.function.arguments || "{}");
} catch {
/* bad JSON */
}
// Human-readable step label
const stepLabel = buildStepLabel(fnName, fnArgs);
await emit({ ts: now(), type: "step", text: stepLabel });
let result: unknown;
try {
result = await executeTool(fnName, fnArgs, ctx);
} catch (err) {
result = { error: err instanceof Error ? err.message : String(err) };
}
// Stream stdout/stderr if present
if (result && typeof result === "object") {
const r = result as Record<string, unknown>;
if (r.stdout && String(r.stdout).trim()) {
for (const line of String(r.stdout)
.split("\n")
.filter(Boolean)
.slice(0, 40)) {
await emit({ ts: now(), type: "stdout", text: line });
}
}
if (r.stderr && String(r.stderr).trim()) {
for (const line of String(r.stderr)
.split("\n")
.filter(Boolean)
.slice(0, 20)) {
await emit({ ts: now(), type: "stderr", text: line });
}
}
if (r.error) {
await emit({ ts: now(), type: "error", text: String(r.error) });
}
}
// Track file changes
const changed = extractChangedFile(
fnName,
fnArgs,
ctx.workspaceRoot,
opts.appPath,
);
if (changed && !trackedFiles.has(changed.path)) {
trackedFiles.set(changed.path, changed.status);
await patchSession(opts, { changedFile: changed });
await emit({
ts: now(),
type: "info",
text: `${changed.status === "added" ? "+ Created" : "~ Modified"} ${changed.path}`,
});
}
history.push({
role: "tool",
tool_call_id: tc.id,
name: fnName,
content: typeof result === "string" ? result : JSON.stringify(result),
});
}
}
if (turn >= MAX_TURNS && !finalText) {
finalText = `Hit the ${MAX_TURNS}-turn limit. Stopping.`;
}
await emit({ ts: now(), type: "done", text: finalText });
if (opts.autoApprove) {
await autoCommitAndDeploy(opts, task, emit);
} else {
await patchSession(opts, {
status: "done",
outputLine: {
ts: now(),
type: "done",
text: "✓ Complete — review changes and approve to commit.",
},
});
}
}
// ── Step label helpers ────────────────────────────────────────────────────────
function buildStepLabel(tool: string, args: Record<string, unknown>): string {
switch (tool) {
case 'read_file': return `Read ${args.path ?? args.file_path}`;
case 'write_file': return `Write ${args.path ?? args.file_path}`;
case 'replace_in_file': return `Edit ${args.path ?? args.file_path}`;
case 'list_directory': return `List ${args.path ?? '.'}`;
case 'find_files': return `Find files: ${args.pattern}`;
case 'search_code': return `Search: ${args.query}`;
case 'execute_command': return `Run: ${String(args.command ?? '').slice(0, 80)}`;
case 'git_commit_and_push': return `Git commit: "${args.message}"`;
default: return `${tool}(${JSON.stringify(args).slice(0, 60)})`;
}
switch (tool) {
case "read_file":
return `Read ${args.path ?? args.file_path}`;
case "write_file":
return `Write ${args.path ?? args.file_path}`;
case "replace_in_file":
return `Edit ${args.path ?? args.file_path}`;
case "list_directory":
return `List ${args.path ?? "."}`;
case "find_files":
return `Find files: ${args.pattern}`;
case "search_code":
return `Search: ${args.query}`;
case "execute_command":
return `Run: ${String(args.command ?? "").slice(0, 80)}`;
case "git_commit_and_push":
return `Git commit: "${args.message}"`;
default:
return `${tool}(${JSON.stringify(args).slice(0, 60)})`;
}
}

View File

@@ -1,7 +1,7 @@
import { GoogleAuth } from 'google-auth-library';
import { GoogleGenAI } from '@google/genai';
import AnthropicVertex from '@anthropic-ai/vertex-sdk';
import { v4 as uuidv4 } from 'uuid';
import { GoogleAuth } from "google-auth-library";
import { GoogleGenAI } from "@google/genai";
import AnthropicVertex from "@anthropic-ai/vertex-sdk";
import { v4 as uuidv4 } from "uuid";
// =============================================================================
// Unified LLM client — OpenAI-compatible message format throughout
@@ -22,46 +22,64 @@ import { v4 as uuidv4 } from 'uuid';
// ---------------------------------------------------------------------------
export interface LLMMessage {
role: 'system' | 'user' | 'assistant' | 'tool';
content: string | null;
tool_calls?: LLMToolCall[];
tool_call_id?: string; // set on role=tool messages
name?: string; // function name on role=tool messages
role: "system" | "user" | "assistant" | "tool";
content: string | null;
tool_calls?: LLMToolCall[];
tool_call_id?: string; // set on role=tool messages
name?: string; // function name on role=tool messages
}
export interface LLMToolCall {
id: string;
type: 'function';
function: {
name: string;
arguments: string; // JSON-encoded string
};
id: string;
type: "function";
function: {
name: string;
arguments: string; // JSON-encoded string
};
}
export interface LLMTool {
type: 'function';
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
type: "function";
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
}
export interface LLMResponse {
content: string | null;
reasoning: string | null; // GLM-5 chain-of-thought
tool_calls: LLMToolCall[];
finish_reason: string;
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
content: string | null;
reasoning: string | null; // GLM-5 chain-of-thought
tool_calls: LLMToolCall[];
finish_reason: string;
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}
/**
* Strips DeepSeek-specific XML tags like <tool_calls> and <think> from content
* so it doesn't leak into the model's history and cause subsequent hallucinations.
*/
function stripModelMarkup(text: string | null | undefined): string | null {
if (!text) return null;
return (
text
.replace(/<tool_calls>[\s\S]*?<\/tool_calls>/g, "")
.replace(/<think>[\s\S]*?<\/think>/g, "")
.trim() || null
);
}
export interface LLMClient {
modelId: string;
chat(messages: LLMMessage[], tools?: LLMTool[], maxTokens?: number): Promise<LLMResponse>;
modelId: string;
chat(
messages: LLMMessage[],
tools?: LLMTool[],
maxTokens?: number,
): Promise<LLMResponse>;
}
// ---------------------------------------------------------------------------
@@ -69,7 +87,7 @@ export interface LLMClient {
// Used for: zai-org/glm-5-maas, anthropic/claude-sonnet-4-6, etc.
// ---------------------------------------------------------------------------
let _cachedToken = '';
let _cachedToken = "";
let _tokenExpiry = 0;
// Build GoogleAuth with explicit service account credentials when available.
@@ -77,113 +95,131 @@ let _tokenExpiry = 0;
// an env var since it contains no newlines or special shell characters.
// Falls back to the GCP metadata server (works on VMs with correct scopes).
function buildGoogleAuth(): GoogleAuth {
const b64Key = process.env.GCP_SA_KEY_BASE64;
if (b64Key) {
try {
const jsonStr = Buffer.from(b64Key, 'base64').toString('utf8');
const credentials = JSON.parse(jsonStr);
return new GoogleAuth({ credentials, scopes: ['https://www.googleapis.com/auth/cloud-platform'] });
} catch {
console.warn('[llm] GCP_SA_KEY_BASE64 is set but failed to decode/parse — falling back to metadata server');
}
const b64Key = process.env.GCP_SA_KEY_BASE64;
if (b64Key) {
try {
const jsonStr = Buffer.from(b64Key, "base64").toString("utf8");
const credentials = JSON.parse(jsonStr);
return new GoogleAuth({
credentials,
scopes: ["https://www.googleapis.com/auth/cloud-platform"],
});
} catch {
console.warn(
"[llm] GCP_SA_KEY_BASE64 is set but failed to decode/parse — falling back to metadata server",
);
}
return new GoogleAuth({ scopes: ['https://www.googleapis.com/auth/cloud-platform'] });
}
return new GoogleAuth({
scopes: ["https://www.googleapis.com/auth/cloud-platform"],
});
}
const _googleAuth = buildGoogleAuth();
async function getVertexToken(): Promise<string> {
const now = Date.now();
if (_cachedToken && now < _tokenExpiry) return _cachedToken;
const client = await _googleAuth.getClient();
const tokenResponse = await client.getAccessToken();
_cachedToken = tokenResponse.token!;
_tokenExpiry = now + 55 * 60 * 1000; // tokens last 1hr, refresh at 55min
return _cachedToken;
const now = Date.now();
if (_cachedToken && now < _tokenExpiry) return _cachedToken;
const client = await _googleAuth.getClient();
const tokenResponse = await client.getAccessToken();
_cachedToken = tokenResponse.token!;
_tokenExpiry = now + 55 * 60 * 1000; // tokens last 1hr, refresh at 55min
return _cachedToken;
}
export class VertexOpenAIClient implements LLMClient {
modelId: string;
private projectId: string;
private region: string;
private temperature: number;
modelId: string;
private projectId: string;
private region: string;
private temperature: number;
constructor(modelId: string, opts?: { projectId?: string; region?: string; temperature?: number }) {
this.modelId = modelId;
this.projectId = opts?.projectId ?? process.env.GCP_PROJECT_ID ?? 'master-ai-484822';
this.region = opts?.region ?? 'global';
this.temperature = opts?.temperature ?? 0.3;
constructor(
modelId: string,
opts?: { projectId?: string; region?: string; temperature?: number },
) {
this.modelId = modelId;
this.projectId =
opts?.projectId ?? process.env.GCP_PROJECT_ID ?? "master-ai-484822";
this.region = opts?.region ?? "global";
this.temperature = opts?.temperature ?? 0.3;
}
async chat(
messages: LLMMessage[],
tools?: LLMTool[],
maxTokens = 4096,
): Promise<LLMResponse> {
const base =
this.region === "global"
? "https://aiplatform.googleapis.com"
: `https://${this.region}-aiplatform.googleapis.com`;
const url = `${base}/v1/projects/${this.projectId}/locations/${this.region}/endpoints/openapi/chat/completions`;
const body: Record<string, unknown> = {
model: this.modelId,
messages,
max_tokens: maxTokens,
temperature: this.temperature,
stream: false,
};
if (tools && tools.length > 0) {
body.tools = tools;
body.tool_choice = "auto";
}
async chat(messages: LLMMessage[], tools?: LLMTool[], maxTokens = 4096): Promise<LLMResponse> {
const base = this.region === 'global'
? 'https://aiplatform.googleapis.com'
: `https://${this.region}-aiplatform.googleapis.com`;
const url = `${base}/v1/projects/${this.projectId}/locations/${this.region}/endpoints/openapi/chat/completions`;
// Retry with exponential backoff on 429 / 503 (rate limit / overload)
const MAX_RETRIES = 4;
const RETRY_STATUSES = new Set([429, 503]);
const body: Record<string, unknown> = {
model: this.modelId,
messages,
max_tokens: maxTokens,
temperature: this.temperature,
stream: false
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
const token = await getVertexToken();
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (res.ok) {
const data = (await res.json()) as any;
const choice = data.choices?.[0];
const message = choice?.message ?? {};
return {
content: stripModelMarkup(message.content),
reasoning: stripModelMarkup(message.reasoning_content),
tool_calls: message.tool_calls ?? [],
finish_reason: choice?.finish_reason ?? "stop",
usage: data.usage,
};
}
if (tools && tools.length > 0) {
body.tools = tools;
body.tool_choice = 'auto';
}
const errText = await res.text();
// Retry with exponential backoff on 429 / 503 (rate limit / overload)
const MAX_RETRIES = 4;
const RETRY_STATUSES = new Set([429, 503]);
// Force token refresh on 401
if (res.status === 401) _tokenExpiry = 0;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
const token = await getVertexToken();
const res = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (RETRY_STATUSES.has(res.status) && attempt < MAX_RETRIES) {
// Check for Retry-After header, otherwise use exponential backoff
const retryAfter = res.headers.get("retry-after");
const waitMs = retryAfter
? Math.min(parseInt(retryAfter, 10) * 1000, 60_000)
: Math.min(2 ** attempt * 2000 + Math.random() * 500, 30_000);
console.warn(
`[llm] Vertex ${res.status} on attempt ${attempt + 1}/${MAX_RETRIES + 1} — retrying in ${Math.round(waitMs / 1000)}s`,
);
await new Promise((r) => setTimeout(r, waitMs));
continue;
}
if (res.ok) {
const data = await res.json() as any;
const choice = data.choices?.[0];
const message = choice?.message ?? {};
return {
content: message.content ?? null,
reasoning: message.reasoning_content ?? null,
tool_calls: message.tool_calls ?? [],
finish_reason: choice?.finish_reason ?? 'stop',
usage: data.usage
};
}
const errText = await res.text();
// Force token refresh on 401
if (res.status === 401) _tokenExpiry = 0;
if (RETRY_STATUSES.has(res.status) && attempt < MAX_RETRIES) {
// Check for Retry-After header, otherwise use exponential backoff
const retryAfter = res.headers.get('retry-after');
const waitMs = retryAfter
? Math.min(parseInt(retryAfter, 10) * 1000, 60_000)
: Math.min(2 ** attempt * 2000 + Math.random() * 500, 30_000);
console.warn(`[llm] Vertex ${res.status} on attempt ${attempt + 1}/${MAX_RETRIES + 1} — retrying in ${Math.round(waitMs / 1000)}s`);
await new Promise(r => setTimeout(r, waitMs));
continue;
}
throw new Error(`Vertex API ${res.status}: ${errText.slice(0, 400)}`);
}
// TypeScript requires an explicit throw after the loop (unreachable in practice)
throw new Error('Vertex API: exceeded max retries');
throw new Error(`Vertex API ${res.status}: ${errText.slice(0, 400)}`);
}
// TypeScript requires an explicit throw after the loop (unreachable in practice)
throw new Error("Vertex API: exceeded max retries");
}
}
// ---------------------------------------------------------------------------
@@ -193,99 +229,116 @@ export class VertexOpenAIClient implements LLMClient {
// ---------------------------------------------------------------------------
export class GeminiClient implements LLMClient {
modelId: string;
private temperature: number;
modelId: string;
private temperature: number;
constructor(modelId = 'gemini-2.5-flash', opts?: { temperature?: number }) {
this.modelId = modelId;
this.temperature = opts?.temperature ?? 0.2;
}
constructor(modelId = "gemini-2.5-flash", opts?: { temperature?: number }) {
this.modelId = modelId;
this.temperature = opts?.temperature ?? 0.2;
}
async chat(messages: LLMMessage[], tools?: LLMTool[], maxTokens = 8192): Promise<LLMResponse> {
const apiKey = process.env.GOOGLE_API_KEY;
if (!apiKey) throw new Error('GOOGLE_API_KEY not set');
async chat(
messages: LLMMessage[],
tools?: LLMTool[],
maxTokens = 8192,
): Promise<LLMResponse> {
const apiKey = process.env.GOOGLE_API_KEY;
if (!apiKey) throw new Error("GOOGLE_API_KEY not set");
const genai = new GoogleGenAI({ apiKey });
const genai = new GoogleGenAI({ apiKey });
const systemMsg = messages.find(m => m.role === 'system');
const nonSystem = messages.filter(m => m.role !== 'system');
const systemMsg = messages.find((m) => m.role === "system");
const nonSystem = messages.filter((m) => m.role !== "system");
const functionDeclarations = (tools ?? []).map(t => ({
name: t.function.name,
description: t.function.description,
parameters: t.function.parameters as any
}));
const functionDeclarations = (tools ?? []).map((t) => ({
name: t.function.name,
description: t.function.description,
parameters: t.function.parameters as any,
}));
const response = await genai.models.generateContent({
model: this.modelId,
contents: toGeminiContents(nonSystem),
config: {
systemInstruction: systemMsg?.content ?? undefined,
tools: functionDeclarations.length > 0 ? [{ functionDeclarations }] : undefined,
temperature: this.temperature,
maxOutputTokens: maxTokens
}
});
const response = await genai.models.generateContent({
model: this.modelId,
contents: toGeminiContents(nonSystem),
config: {
systemInstruction: systemMsg?.content ?? undefined,
tools:
functionDeclarations.length > 0
? [{ functionDeclarations }]
: undefined,
temperature: this.temperature,
maxOutputTokens: maxTokens,
},
});
const candidate = response.candidates?.[0];
if (!candidate) throw new Error('No response from Gemini');
const candidate = response.candidates?.[0];
if (!candidate) throw new Error("No response from Gemini");
const parts = candidate.content?.parts ?? [];
const textContent = parts.filter(p => p.text).map(p => p.text).join('') || null;
const fnCalls = parts.filter(p => p.functionCall);
const parts = candidate.content?.parts ?? [];
const textContent =
parts
.filter((p) => p.text)
.map((p) => p.text)
.join("") || null;
const fnCalls = parts.filter((p) => p.functionCall);
const tool_calls: LLMToolCall[] = fnCalls.map(p => ({
id: `call_${uuidv4().replace(/-/g, '').slice(0, 12)}`,
type: 'function' as const,
function: {
name: p.functionCall!.name ?? '',
arguments: JSON.stringify(p.functionCall!.args ?? {})
}
}));
const tool_calls: LLMToolCall[] = fnCalls.map((p) => ({
id: `call_${uuidv4().replace(/-/g, "").slice(0, 12)}`,
type: "function" as const,
function: {
name: p.functionCall!.name ?? "",
arguments: JSON.stringify(p.functionCall!.args ?? {}),
},
}));
return {
content: textContent,
reasoning: null,
tool_calls,
finish_reason: fnCalls.length > 0 ? 'tool_calls' : 'stop'
};
}
return {
content: stripModelMarkup(textContent),
reasoning: null,
tool_calls,
finish_reason: fnCalls.length > 0 ? "tool_calls" : "stop",
};
}
}
/** Convert OpenAI message format → Gemini Content[] format */
function toGeminiContents(messages: LLMMessage[]): any[] {
const contents: any[] = [];
for (const msg of messages) {
if (msg.role === 'assistant') {
const parts: any[] = [];
if (msg.content) parts.push({ text: msg.content });
for (const tc of msg.tool_calls ?? []) {
parts.push({
functionCall: {
name: tc.function.name,
args: JSON.parse(tc.function.arguments || '{}')
}
});
}
contents.push({ role: 'model', parts });
} else if (msg.role === 'tool') {
// Parse content back — could be JSON or plain text
let resultValue: unknown = msg.content;
try { resultValue = JSON.parse(msg.content ?? 'null'); } catch { /* keep as string */ }
contents.push({
role: 'user',
parts: [{
functionResponse: {
name: msg.name ?? 'tool',
response: { result: resultValue }
}
}]
});
} else {
contents.push({ role: 'user', parts: [{ text: msg.content ?? '' }] });
}
const contents: any[] = [];
for (const msg of messages) {
if (msg.role === "assistant") {
const parts: any[] = [];
if (msg.content) parts.push({ text: msg.content });
for (const tc of msg.tool_calls ?? []) {
parts.push({
functionCall: {
name: tc.function.name,
args: JSON.parse(tc.function.arguments || "{}"),
},
});
}
contents.push({ role: "model", parts });
} else if (msg.role === "tool") {
// Parse content back — could be JSON or plain text
let resultValue: unknown = msg.content;
try {
resultValue = JSON.parse(msg.content ?? "null");
} catch {
/* keep as string */
}
contents.push({
role: "user",
parts: [
{
functionResponse: {
name: msg.name ?? "tool",
response: { result: resultValue },
},
},
],
});
} else {
contents.push({ role: "user", parts: [{ text: msg.content ?? "" }] });
}
return contents;
}
return contents;
}
// ---------------------------------------------------------------------------
@@ -295,147 +348,196 @@ function toGeminiContents(messages: LLMMessage[]): any[] {
// ---------------------------------------------------------------------------
export class AnthropicVertexClient implements LLMClient {
modelId: string;
private projectId: string;
private region: string;
modelId: string;
private projectId: string;
private region: string;
constructor(modelId: string, opts?: { projectId?: string; region?: string }) {
// Strip the "anthropic/" prefix if present — the SDK uses bare model names
this.modelId = modelId.startsWith('anthropic/') ? modelId.slice(10) : modelId;
this.projectId = opts?.projectId ?? process.env.GCP_PROJECT_ID ?? 'master-ai-484822';
this.region = opts?.region ?? process.env.CLAUDE_REGION ?? 'us-east5';
constructor(modelId: string, opts?: { projectId?: string; region?: string }) {
// Strip the "anthropic/" prefix if present — the SDK uses bare model names
this.modelId = modelId.startsWith("anthropic/")
? modelId.slice(10)
: modelId;
this.projectId =
opts?.projectId ?? process.env.GCP_PROJECT_ID ?? "master-ai-484822";
this.region = opts?.region ?? process.env.CLAUDE_REGION ?? "us-east5";
}
private buildClient(): AnthropicVertex {
const b64Key = process.env.GCP_SA_KEY_BASE64;
if (b64Key) {
try {
const jsonStr = Buffer.from(b64Key, "base64").toString("utf8");
const credentials = JSON.parse(jsonStr);
return new AnthropicVertex({
projectId: this.projectId,
region: this.region,
googleAuth: new GoogleAuth({
credentials,
scopes: ["https://www.googleapis.com/auth/cloud-platform"],
}) as any,
});
} catch {
console.warn(
"[llm] AnthropicVertex: SA key decode failed, falling back to metadata server",
);
}
}
return new AnthropicVertex({
projectId: this.projectId,
region: this.region,
});
}
private buildClient(): AnthropicVertex {
const b64Key = process.env.GCP_SA_KEY_BASE64;
if (b64Key) {
try {
const jsonStr = Buffer.from(b64Key, 'base64').toString('utf8');
const credentials = JSON.parse(jsonStr);
return new AnthropicVertex({
projectId: this.projectId,
region: this.region,
googleAuth: new GoogleAuth({ credentials, scopes: ['https://www.googleapis.com/auth/cloud-platform'] }) as any,
});
} catch {
console.warn('[llm] AnthropicVertex: SA key decode failed, falling back to metadata server');
}
async chat(
messages: LLMMessage[],
tools?: LLMTool[],
maxTokens = 8192,
): Promise<LLMResponse> {
const client = this.buildClient();
const system =
messages.find((m) => m.role === "system")?.content ?? undefined;
const nonSystem = messages.filter((m) => m.role !== "system");
// Convert OpenAI message format → Anthropic format
const anthropicMessages: any[] = nonSystem.map((m) => {
if (m.role === "assistant") {
const parts: any[] = [];
if (m.content) parts.push({ type: "text", text: m.content });
for (const tc of m.tool_calls ?? []) {
parts.push({
type: "tool_use",
id: tc.id,
name: tc.function.name,
input: JSON.parse(tc.function.arguments || "{}"),
});
}
return new AnthropicVertex({ projectId: this.projectId, region: this.region });
}
return {
role: "assistant",
content:
parts.length === 1 && parts[0].type === "text"
? parts[0].text
: parts,
};
}
if (m.role === "tool") {
return {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: m.tool_call_id,
content: m.content ?? "",
},
],
};
}
return { role: "user", content: m.content ?? "" };
});
async chat(messages: LLMMessage[], tools?: LLMTool[], maxTokens = 8192): Promise<LLMResponse> {
const client = this.buildClient();
const anthropicTools = (tools ?? []).map((t) => ({
name: t.function.name,
description: t.function.description,
input_schema: t.function.parameters,
}));
const system = messages.find(m => m.role === 'system')?.content ?? undefined;
const nonSystem = messages.filter(m => m.role !== 'system');
const MAX_RETRIES = 4;
const RETRY_STATUSES = new Set([429, 503]);
// Convert OpenAI message format → Anthropic format
const anthropicMessages: any[] = nonSystem.map(m => {
if (m.role === 'assistant') {
const parts: any[] = [];
if (m.content) parts.push({ type: 'text', text: m.content });
for (const tc of m.tool_calls ?? []) {
parts.push({
type: 'tool_use',
id: tc.id,
name: tc.function.name,
input: JSON.parse(tc.function.arguments || '{}'),
});
}
return { role: 'assistant', content: parts.length === 1 && parts[0].type === 'text' ? parts[0].text : parts };
}
if (m.role === 'tool') {
return {
role: 'user',
content: [{ type: 'tool_result', tool_use_id: m.tool_call_id, content: m.content ?? '' }],
};
}
return { role: 'user', content: m.content ?? '' };
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
const response = await (client.messages.create as Function)({
model: this.modelId,
max_tokens: maxTokens,
system: system ?? undefined,
messages: anthropicMessages,
tools: anthropicTools.length > 0 ? anthropicTools : undefined,
});
const anthropicTools = (tools ?? []).map(t => ({
name: t.function.name,
description: t.function.description,
input_schema: t.function.parameters,
}));
const textContent =
response.content
.filter((b: any) => b.type === "text")
.map((b: any) => b.text)
.join("") || null;
const MAX_RETRIES = 4;
const RETRY_STATUSES = new Set([429, 503]);
const tool_calls: LLMToolCall[] = response.content
.filter((b: any) => b.type === "tool_use")
.map((b: any) => ({
id: b.id,
type: "function" as const,
function: {
name: b.name,
arguments: JSON.stringify(b.input ?? {}),
},
}));
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
const response = await (client.messages.create as Function)({
model: this.modelId,
max_tokens: maxTokens,
system: system ?? undefined,
messages: anthropicMessages,
tools: anthropicTools.length > 0 ? anthropicTools : undefined,
});
const textContent = response.content
.filter((b: any) => b.type === 'text')
.map((b: any) => b.text)
.join('') || null;
const tool_calls: LLMToolCall[] = response.content
.filter((b: any) => b.type === 'tool_use')
.map((b: any) => ({
id: b.id,
type: 'function' as const,
function: { name: b.name, arguments: JSON.stringify(b.input ?? {}) },
}));
return {
content: textContent,
reasoning: null,
tool_calls,
finish_reason: response.stop_reason === 'tool_use' ? 'tool_calls' : 'stop',
usage: response.usage
? { prompt_tokens: response.usage.input_tokens, completion_tokens: response.usage.output_tokens, total_tokens: response.usage.input_tokens + response.usage.output_tokens }
: undefined,
};
} catch (err: any) {
const status = err?.status ?? err?.statusCode ?? 0;
if (RETRY_STATUSES.has(status) && attempt < MAX_RETRIES) {
const waitMs = Math.min(2 ** attempt * 2000 + Math.random() * 500, 30_000);
console.warn(`[llm] Anthropic Vertex ${status} on attempt ${attempt + 1}/${MAX_RETRIES + 1} — retrying in ${Math.round(waitMs / 1000)}s`);
await new Promise(r => setTimeout(r, waitMs));
continue;
}
throw new Error(`Anthropic Vertex error: ${err?.message ?? String(err)}`);
}
return {
content: stripModelMarkup(textContent),
reasoning: null,
tool_calls,
finish_reason:
response.stop_reason === "tool_use" ? "tool_calls" : "stop",
usage: response.usage
? {
prompt_tokens: response.usage.input_tokens,
completion_tokens: response.usage.output_tokens,
total_tokens:
response.usage.input_tokens + response.usage.output_tokens,
}
: undefined,
};
} catch (err: any) {
const status = err?.status ?? err?.statusCode ?? 0;
if (RETRY_STATUSES.has(status) && attempt < MAX_RETRIES) {
const waitMs = Math.min(
2 ** attempt * 2000 + Math.random() * 500,
30_000,
);
console.warn(
`[llm] Anthropic Vertex ${status} on attempt ${attempt + 1}/${MAX_RETRIES + 1} — retrying in ${Math.round(waitMs / 1000)}s`,
);
await new Promise((r) => setTimeout(r, waitMs));
continue;
}
throw new Error('Anthropic Vertex: exceeded max retries');
throw new Error(
`Anthropic Vertex error: ${err?.message ?? String(err)}`,
);
}
}
throw new Error("Anthropic Vertex: exceeded max retries");
}
}
// ---------------------------------------------------------------------------
// Factory — createLLM(modelId | tier)
// ---------------------------------------------------------------------------
export type ModelTier = 'A' | 'B' | 'C';
export type ModelTier = "A" | "B" | "C";
const TIER_MODELS: Record<ModelTier, string> = {
A: process.env.TIER_A_MODEL ?? 'gemini-2.5-flash',
B: process.env.TIER_B_MODEL ?? 'claude-sonnet-4-6',
C: process.env.TIER_C_MODEL ?? 'claude-sonnet-4-6'
A: process.env.TIER_A_MODEL ?? "gemini-2.5-flash",
B: process.env.TIER_B_MODEL ?? "claude-sonnet-4-6",
C: process.env.TIER_C_MODEL ?? "claude-sonnet-4-6",
};
export function createLLM(modelOrTier: string | ModelTier, opts?: { temperature?: number }): LLMClient {
const modelId = (modelOrTier === 'A' || modelOrTier === 'B' || modelOrTier === 'C')
? TIER_MODELS[modelOrTier]
: modelOrTier;
export function createLLM(
modelOrTier: string | ModelTier,
opts?: { temperature?: number },
): LLMClient {
const modelId =
modelOrTier === "A" || modelOrTier === "B" || modelOrTier === "C"
? TIER_MODELS[modelOrTier]
: modelOrTier;
if (modelId.startsWith('gemini-')) {
return new GeminiClient(modelId, opts);
}
if (modelId.startsWith("gemini-")) {
return new GeminiClient(modelId, opts);
}
if (modelId.startsWith('anthropic/') || modelId.startsWith('claude-')) {
return new AnthropicVertexClient(modelId);
}
if (modelId.startsWith("anthropic/") || modelId.startsWith("claude-")) {
return new AnthropicVertexClient(modelId);
}
return new VertexOpenAIClient(modelId, { temperature: opts?.temperature });
return new VertexOpenAIClient(modelId, { temperature: opts?.temperature });
}
// ---------------------------------------------------------------------------
@@ -443,14 +545,18 @@ export function createLLM(modelOrTier: string | ModelTier, opts?: { temperature?
// ---------------------------------------------------------------------------
export function toOAITools(
tools: Array<{ name: string; description: string; parameters: Record<string, unknown> }>
tools: Array<{
name: string;
description: string;
parameters: Record<string, unknown>;
}>,
): LLMTool[] {
return tools.map(t => ({
type: 'function',
function: {
name: t.name,
description: t.description,
parameters: t.parameters
}
}));
return tools.map((t) => ({
type: "function",
function: {
name: t.name,
description: t.description,
parameters: t.parameters,
},
}));
}

View File

@@ -3,66 +3,100 @@
// Requires a GitPushConfig with Gitea credentials for authenticated push.
// =============================================================================
import * as cp from 'child_process';
import * as util from 'util';
import { PROTECTED_GITEA_REPOS } from './security';
import * as cp from "child_process";
import * as util from "util";
import { PROTECTED_GITEA_REPOS } from "./security";
const execAsync = util.promisify(cp.exec);
import fs from "fs";
import path from "path";
export interface GitPushConfig {
apiUrl: string;
apiToken: string;
username: string;
apiUrl: string;
apiToken: string;
username: string;
}
export async function gitCommitAndPush(
workspaceRoot: string,
message: string,
cfg: GitPushConfig
workspaceRoot: string,
message: string,
cfg: GitPushConfig,
): Promise<unknown> {
const cwd = workspaceRoot;
const { apiUrl, apiToken, username } = cfg;
const cwd = workspaceRoot;
const { apiUrl, apiToken, username } = cfg;
try {
// Check remote URL before committing — block pushes to protected repos
let remoteCheck = "";
try {
// Check remote URL before committing — block pushes to protected repos
let remoteCheck = '';
try {
remoteCheck = (await execAsync('git remote get-url origin', { cwd })).stdout.trim();
} catch { /* no remote yet */ }
for (const protectedRepo of PROTECTED_GITEA_REPOS) {
const repoPath = protectedRepo.replace('mark/', '');
if (remoteCheck.includes(`/${repoPath}`) || remoteCheck.includes(`/${repoPath}.git`)) {
return {
error: `SECURITY: This workspace is linked to a protected Vibn platform repo (${protectedRepo}). ` +
`Agents cannot push to platform repos. Only user project repos are writable.`,
};
}
}
await execAsync('git add -A', { cwd });
await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd });
// Strip any existing credentials from remote URL and re-inject cleanly
let remoteUrl = '';
try {
remoteUrl = (await execAsync('git remote get-url origin', { cwd })).stdout.trim();
} catch { /* no remote */ }
const cleanUrl = remoteUrl.replace(/https:\/\/[^@]+@/, 'https://');
const baseUrl = cleanUrl || apiUrl;
const authedUrl = baseUrl.replace('https://', `https://${username}:${apiToken}@`);
await execAsync(`git remote set-url origin "${authedUrl}"`, { cwd }).catch(async () => {
await execAsync(`git remote add origin "${authedUrl}"`, { cwd });
});
const branch = (await execAsync('git rev-parse --abbrev-ref HEAD', { cwd })).stdout.trim();
await execAsync(`git push -u origin "${branch}"`, { cwd, timeout: 60_000 });
return { success: true, message, branch };
} catch (err: any) {
const cleaned = (err.message || '').replace(new RegExp(apiToken, 'g'), '***');
return { error: `Git operation failed: ${cleaned}` };
remoteCheck = (
await execAsync("git remote get-url origin", { cwd })
).stdout.trim();
} catch {
/* no remote yet */
}
for (const protectedRepo of PROTECTED_GITEA_REPOS) {
const repoPath = protectedRepo.replace("mark/", "");
if (
remoteCheck.includes(`/${repoPath}`) ||
remoteCheck.includes(`/${repoPath}.git`)
) {
return {
error:
`SECURITY: This workspace is linked to a protected Vibn platform repo (${protectedRepo}). ` +
`Agents cannot push to platform repos. Only user project repos are writable.`,
};
}
}
// Write commit message to a temporary file to avoid shell injection
const msgFile = path.join(cwd, ".git", "COMMIT_EDITMSG");
fs.writeFileSync(msgFile, message, "utf8");
await execAsync("git add -A", { cwd });
await execAsync("git commit -F .git/COMMIT_EDITMSG", { cwd });
try {
fs.unlinkSync(msgFile);
} catch {
/* ignore */
}
// Strip any existing credentials from remote URL and re-inject cleanly
let remoteUrl = "";
try {
remoteUrl = (
await execAsync("git remote get-url origin", { cwd })
).stdout.trim();
} catch {
/* no remote */
}
const cleanUrl = remoteUrl.replace(/https:\/\/[^@]+@/, "https://");
const baseUrl = cleanUrl || apiUrl;
const authedUrl = baseUrl.replace(
"https://",
`https://${username}:${apiToken}@`,
);
await execAsync(`git remote set-url origin "${authedUrl}"`, { cwd }).catch(
async () => {
await execAsync(`git remote add origin "${authedUrl}"`, { cwd });
},
);
const branch = (
await execAsync("git rev-parse --abbrev-ref HEAD", { cwd })
).stdout.trim();
await execAsync(`git push -u origin "${branch}"`, { cwd, timeout: 60_000 });
return { success: true, message, branch };
} catch (err: any) {
const cleaned = (err.message || "").replace(
new RegExp(apiToken, "g"),
"***",
);
return { error: `Git operation failed: ${cleaned}` };
}
}

View File

@@ -316,7 +316,13 @@ export async function POST(request: Request) {
const history: ChatMessage[] = rows.reverse().map((r: any) => {
const msg = r.data;
if (msg.role === "assistant" && msg.toolCalls?.length) {
return { ...msg, toolCalls: undefined };
msg.toolCalls = undefined;
}
if (typeof msg.content === "string") {
msg.content = msg.content
.replace(/<tool_calls>[\s\S]*?<\/tool_calls>/g, "")
.replace(/<think>[\s\S]*?<\/think>/g, "")
.trim();
}
return msg;
});

View File

@@ -1,13 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { authSession } from "@/lib/auth/session-server";
import { query } from "@/lib/db-postgres";
import {
augmentAtlasMessage,
parseContextRefs,
} from "@/lib/chat-context-refs";
import { augmentAtlasMessage, parseContextRefs } from "@/lib/chat-context-refs";
import { formatCreationKickoffForPrompt } from "@/lib/server/creation-kickoff-prompt";
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
const AGENT_RUNNER_URL =
process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
const ALLOWED_SCOPES = new Set(["overview", "build"]);
@@ -16,8 +14,13 @@ function normalizeScope(raw: string | null | undefined): "overview" | "build" {
return ALLOWED_SCOPES.has(s) ? (s as "overview" | "build") : "overview";
}
function runnerSessionId(projectId: string, scope: "overview" | "build"): string {
return scope === "overview" ? `atlas_${projectId}` : `atlas_${projectId}__build`;
function runnerSessionId(
projectId: string,
scope: "overview" | "build",
): string {
return scope === "overview"
? `atlas_${projectId}`
: `atlas_${projectId}__build`;
}
// ---------------------------------------------------------------------------
@@ -53,12 +56,15 @@ async function ensureLegacyConversationsTable() {
legacyTableChecked = true;
}
async function loadAtlasHistory(projectId: string, scope: "overview" | "build"): Promise<any[]> {
async function loadAtlasHistory(
projectId: string,
scope: "overview" | "build",
): Promise<any[]> {
try {
await ensureThreadsTable();
const rows = await query<{ messages: any[] }>(
`SELECT messages FROM atlas_chat_threads WHERE project_id = $1 AND scope = $2`,
[projectId, scope]
[projectId, scope],
);
if (rows.length > 0) {
const fromThreads = rows[0]?.messages;
@@ -68,7 +74,7 @@ async function loadAtlasHistory(projectId: string, scope: "overview" | "build"):
await ensureLegacyConversationsTable();
const leg = await query<{ messages: any[] }>(
`SELECT messages FROM atlas_conversations WHERE project_id = $1`,
[projectId]
[projectId],
);
const legacyMsgs = leg[0]?.messages ?? [];
if (Array.isArray(legacyMsgs) && legacyMsgs.length > 0) {
@@ -82,7 +88,11 @@ async function loadAtlasHistory(projectId: string, scope: "overview" | "build"):
}
}
async function saveAtlasHistory(projectId: string, scope: "overview" | "build", messages: any[]): Promise<void> {
async function saveAtlasHistory(
projectId: string,
scope: "overview" | "build",
messages: any[],
): Promise<void> {
try {
await ensureThreadsTable();
await query(
@@ -90,7 +100,7 @@ async function saveAtlasHistory(projectId: string, scope: "overview" | "build",
VALUES ($1, $2, $3::jsonb, NOW())
ON CONFLICT (project_id, scope) DO UPDATE
SET messages = $3::jsonb, updated_at = NOW()`,
[projectId, scope, JSON.stringify(messages)]
[projectId, scope, JSON.stringify(messages)],
);
} catch (e) {
console.error("[atlas-chat] Failed to save history:", e);
@@ -104,7 +114,7 @@ async function savePrd(projectId: string, prdContent: string): Promise<void> {
SET data = data || jsonb_build_object('prd', $2::text, 'stage', 'architecture'),
updated_at = NOW()
WHERE id = $1`,
[projectId, prdContent]
[projectId, prdContent],
);
console.log(`[atlas-chat] PRD saved for project ${projectId}`);
} catch (e) {
@@ -113,9 +123,14 @@ async function savePrd(projectId: string, prdContent: string): Promise<void> {
}
/** Replace the latest user message content so DB/UI never show the internal ref prefix. */
function scrubLastUserMessageContent(history: unknown[], cleanText: string): unknown[] {
function scrubLastUserMessageContent(
history: unknown[],
cleanText: string,
): unknown[] {
if (!Array.isArray(history) || history.length === 0) return history;
const h = history.map(m => (m && typeof m === "object" ? { ...(m as object) } : m));
const h = history.map((m) =>
m && typeof m === "object" ? { ...(m as object) } : m,
);
for (let i = h.length - 1; i >= 0; i--) {
const m = h[i] as { role?: string; content?: string };
if (m?.role === "user" && typeof m.content === "string") {
@@ -132,7 +147,7 @@ function scrubLastUserMessageContent(history: unknown[], cleanText: string): unk
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
{ params }: { params: Promise<{ projectId: string }> },
) {
const session = await authSession();
if (!session?.user?.email) {
@@ -146,7 +161,10 @@ export async function GET(
// Filter to only user/assistant messages (no system prompts) for display
const messages = history
.filter((m: any) => m.role === "user" || m.role === "assistant")
.map((m: any) => ({ role: m.role as "user" | "assistant", content: m.content as string }));
.map((m: any) => ({
role: m.role as "user" | "assistant",
content: m.content as string,
}));
return NextResponse.json({ messages });
}
@@ -157,7 +175,7 @@ export async function GET(
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
{ params }: { params: Promise<{ projectId: string }> },
) {
const session = await authSession();
if (!session?.user?.email) {
@@ -180,9 +198,19 @@ export async function POST(
// Strip tool_call / tool_response messages — replaying them across sessions
// causes Gemini to reject the request with a turn-ordering error.
const rawHistory = await loadAtlasHistory(projectId, scope);
const history = rawHistory.filter((m: any) =>
(m.role === "user" || m.role === "assistant") && m.content
);
const history = rawHistory
.filter(
(m: any) => (m.role === "user" || m.role === "assistant") && m.content,
)
.map((m: any) => {
if (typeof m.content === "string") {
m.content = m.content
.replace(/<tool_calls>[\s\S]*?<\/tool_calls>/g, "")
.replace(/<think>[\s\S]*?<\/think>/g, "")
.trim();
}
return m;
});
// __init__ is a special internal trigger used only when there is no existing history.
// If history already exists, ignore the init request (conversation already started).
@@ -197,11 +225,13 @@ export async function POST(
try {
const rows = await query<{ data: Record<string, unknown> }>(
`SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`,
[projectId]
[projectId],
);
const kb =
rows[0]?.data != null
? formatCreationKickoffForPrompt(rows[0].data as Record<string, unknown>)
? formatCreationKickoffForPrompt(
rows[0].data as Record<string, unknown>,
)
: null;
if (kb) {
kickoffPrefix = `[Project kickoff from creation wizard]\n${kb}\n\n`;
@@ -236,7 +266,7 @@ export async function POST(
console.error("[atlas-chat] Agent runner error:", text);
return NextResponse.json(
{ error: "Vibn is unavailable. Please try again." },
{ status: 502 }
{ status: 502 },
);
}
@@ -265,7 +295,7 @@ export async function POST(
console.error("[atlas-chat] Error:", err);
return NextResponse.json(
{ error: "Request timed out or failed. Please try again." },
{ status: 500 }
{ status: 500 },
);
}
}
@@ -276,7 +306,7 @@ export async function POST(
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
{ params }: { params: Promise<{ projectId: string }> },
) {
const session = await authSession();
if (!session?.user?.email) {
@@ -288,21 +318,32 @@ export async function DELETE(
const sessionId = runnerSessionId(projectId, scope);
try {
await fetch(`${AGENT_RUNNER_URL}/atlas/sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE" });
} catch { /* runner may be down */ }
await fetch(
`${AGENT_RUNNER_URL}/atlas/sessions/${encodeURIComponent(sessionId)}`,
{ method: "DELETE" },
);
} catch {
/* runner may be down */
}
try {
await ensureThreadsTable();
await query(
`DELETE FROM atlas_chat_threads WHERE project_id = $1 AND scope = $2`,
[projectId, scope]
[projectId, scope],
);
} catch { /* table may not exist yet */ }
} catch {
/* table may not exist yet */
}
if (scope === "overview") {
try {
await query(`DELETE FROM atlas_conversations WHERE project_id = $1`, [projectId]);
} catch { /* legacy */ }
await query(`DELETE FROM atlas_conversations WHERE project_id = $1`, [
projectId,
]);
} catch {
/* legacy */
}
}
return NextResponse.json({ cleared: true });

View File

@@ -210,11 +210,18 @@ function parseAssistantMessage(message: Record<string, unknown> | undefined): {
: typeof (message as { reasoning?: string })?.reasoning === "string"
? (message as { reasoning: string }).reasoning
: "";
const stripTags = (s: string) =>
s
.replace(/<tool_calls>[\s\S]*?<\/tool_calls>/g, "")
.replace(/<think>[\s\S]*?<\/think>/g, "")
.trim();
// DeepSeek separates thinking from speaking — during tool loops it
// often puts everything in reasoning_content and leaves content empty.
// When that happens, surface the reasoning as the user-visible text
// so the user isn't staring at silent tool pills.
const text = rawText || thoughts;
const text = stripTags(rawText || thoughts);
const toolCalls: ToolCall[] = [];
const rawCalls = message?.tool_calls;
if (Array.isArray(rawCalls)) {

View File

@@ -923,7 +923,7 @@ function Hero({ onStart, variant = "quote" }) {
.hero-attribution {
font-family: var(--font-mono);
font-size: 12px;
color: var(--fg-faint);
color: var(--fg-mute);
letter-spacing: 0.04em;
margin-top: 6px;
display: inline-flex; align-items: center; gap: 8px;
@@ -1120,10 +1120,10 @@ function Hero({ onStart, variant = "quote" }) {
idea live marketed customers
</div>
<p className="hero-sub">
<b>"I built my product, now what?"</b> Vibn is the answer.
<b>Build it. Go Live. Find Users.</b>
<br />
Your AI handles the technical stuff, puts your idea online, and
helps you find your first customers.
helps you find customers. No extra tools. No headaches.
</p>
</>
) : (