This repository has been archived on 2026-06-07. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
master-ai/vibn-frontend/marketing/components/new-site/index.tsx

2704 lines
90 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Auto-ported from new-site
"use client";
import React, { useState, useEffect, useRef, Fragment } from "react";
// --- tweaks-panel.jsx ---
// 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] = 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] = useState(false);
const dragRef = 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] = useState(
() => hasDeckStage && !!document.querySelector("deck-stage")?._railEnabled,
);
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] = 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 = 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";
}, []);
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]);
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 = useRef(null);
const [dragging, setDragging] = 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 = 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 = 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>
);
}
// --- primitives.jsx ---
// 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.
export function LogoMark({
size = 26,
blink = true,
}: {
size?: number;
blink?: boolean;
}) {
return (
<span className="logo-mark" style={{ width: size, height: size }}>
<svg
viewBox="0 0 36 32"
width="74%"
height="74%"
fill="currentColor"
stroke="currentColor"
strokeWidth="1.2"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M4 5 L10 5 L12 18 L14 5 L20 5 L12 27 Z" />
<rect
x="22.5"
y="23"
width="9.5"
height="3.8"
rx="0.7"
className={blink ? "logo-caret" : ""}
/>
</svg>
</span>
);
}
export function Logo({ size = 26 }: { size?: number }) {
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) => (
<Fragment key={i}>
{i > 0 && <span style={{ color: "var(--fg-faint)" }}>·</span>}
<span>{item}</span>
</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,
}}
/>
);
}
// --- hero.jsx ---
// 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] = useState("");
const [phIdx, setPhIdx] = useState(0);
const [phChars, setPhChars] = useState(0);
const [deleting, setDeleting] = useState(false);
const taRef = useRef(null);
// Type-on placeholder when textarea is empty.
useEffect(() => {
if (text.length > 0) return undefined;
const full = HERO_PLACEHOLDERS[phIdx];
const speed = deleting ? 18 : 38;
const t = setTimeout(() => {
if (!deleting) {
if (phChars < full.length) setPhChars(phChars + 1);
else setTimeout(() => setDeleting(true), 1700);
} else {
if (phChars > 0) setPhChars(phChars - 1);
else {
setDeleting(false);
setPhIdx((phIdx + 1) % HERO_PLACEHOLDERS.length);
}
}
}, speed);
return () => clearTimeout(t);
}, [text, phIdx, phChars, deleting]);
const placeholder = HERO_PLACEHOLDERS[phIdx].slice(0, phChars);
const submit = () => {
const value = text || HERO_PLACEHOLDERS[phIdx];
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-dim);
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: 16px/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: 16px/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">
Every small business deserves their{" "}
<span className="mark">perfect tool</span>.
</h1>
<div className="hero-attribution mono">
idea live marketed customers
</div>
<p className="hero-sub">
Vibn is the platform for people building them.
</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;
}
// --- wall.jsx ---
// 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: 14px;
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 }}>
Keep vibing. <em>All the way to launch</em>.
</h2>
<p className="wall-sub">
Your AI handles the technical stuff, puts your idea online, and
helps you find customers. No extra tools. No headaches.
</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>
);
}
// --- crossed.jsx ---
// 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>
);
}
// --- journey.jsx ---
// 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;
}
// --- audience.jsx ---
// 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;
}
// --- closing.jsx ---
// 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>
);
}
export 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">
<a href="/" style={{ display: "inline-flex" }}>
<Logo />
</a>
<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>
);
}
// --- app.jsx ---
// 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] = useState(false);
const [showLaunch, setShowLaunch] = useState(null);
useEffect(() => {
applyAccent(t.accent);
}, [t.accent]);
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>
</>
);
}
export function Nav({ scrolled = false }: { scrolled?: boolean }) {
return (
<nav className={`nav${scrolled ? " scrolled" : ""}`}>
<div className="wrap nav-inner">
<a href="/" style={{ display: "inline-flex" }}>
<Logo />
</a>
<div className="nav-links">
<a href="/#how">How it works</a>
<a href="/mission">Our Mission</a>
<a href="/#templates">Templates</a>
<a href="/#pricing">Pricing</a>
</div>
<div className="nav-cta">
<a
href="/auth"
className="btn btn-ghost"
style={{ display: "inline-flex" }}
>
Sign in
</a>
<a href="/auth?new=1" 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 }) {
useEffect(() => {
const onKey = (e) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const [step, setStep] = useState(0);
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>
);
}
export default function NewSite() {
return (
<div
className="new-site-wrapper"
style={{ display: "flex", flexDirection: "column" }}
>
<App />
</div>
);
}