2698 lines
89 KiB
TypeScript
2698 lines
89 KiB
TypeScript
// 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.
|
||
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) => (
|
||
<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-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;
|
||
}
|
||
|
||
// --- 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: 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>
|
||
);
|
||
}
|
||
|
||
// --- 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>
|
||
);
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
// --- 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>
|
||
</>
|
||
);
|
||
}
|
||
|
||
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="/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>
|
||
);
|
||
}
|