From a30c3373dc45db406e1508b68b49474ec825415f Mon Sep 17 00:00:00 2001 From: mawkone Date: Wed, 13 May 2026 20:50:02 -0700 Subject: [PATCH] feat(marketing): replace landing page with new site design and animations --- vibn-frontend/app/(justine)/page.tsx | 5 - vibn-frontend/app/(marketing)/page.tsx | 10 + vibn-frontend/app/api/chat/route.ts | 4 +- vibn-frontend/app/styles/new-site.css | 189 ++ .../marketing/components/new-site/index.tsx | 2345 +++++++++++++++++ 5 files changed, 2546 insertions(+), 7 deletions(-) delete mode 100644 vibn-frontend/app/(justine)/page.tsx create mode 100644 vibn-frontend/app/(marketing)/page.tsx create mode 100644 vibn-frontend/app/styles/new-site.css create mode 100644 vibn-frontend/marketing/components/new-site/index.tsx diff --git a/vibn-frontend/app/(justine)/page.tsx b/vibn-frontend/app/(justine)/page.tsx deleted file mode 100644 index cd77307b..00000000 --- a/vibn-frontend/app/(justine)/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { JustineHomePage } from "@/marketing/components/justine/JustineHomePage"; - -export default function LandingPage() { - return ; -} diff --git a/vibn-frontend/app/(marketing)/page.tsx b/vibn-frontend/app/(marketing)/page.tsx new file mode 100644 index 00000000..a7554aef --- /dev/null +++ b/vibn-frontend/app/(marketing)/page.tsx @@ -0,0 +1,10 @@ +import NewSite from "@/marketing/components/new-site"; +import "../styles/new-site.css"; + +export const metadata = { + title: "Vibn — Keep vibing. All the way to launch.", +}; + +export default function Page() { + return ; +} diff --git a/vibn-frontend/app/api/chat/route.ts b/vibn-frontend/app/api/chat/route.ts index 3bbb9550..bd5c2beb 100644 --- a/vibn-frontend/app/api/chat/route.ts +++ b/vibn-frontend/app/api/chat/route.ts @@ -213,9 +213,9 @@ Each project has a persistent \`vibn-dev\` container. Edit files via \`fs_*\` an **Build-me-X recipe:** \`devcontainer_ensure\` → \`shell_exec npx create-next-app@latest . --yes\` (or pick an OSS scaffold via \`github_search\`) → \`fs_edit\` / \`fs_write\` to customize → **wire Sentry (see below)** → \`dev_server_start { command: 'npm run dev', port: 3000 }\` and **share the previewUrl in your reply — that's the turn's stopping point**. When the user says "ship it", call \`ship { projectId, commitMsg }\` (commits to Gitea and triggers prod deploy in one shot). If a project is multi-service (frontend + API + worker), pick the user-facing service (usually the frontend) and start ITS dev server first, even if the others aren't done yet — a clickable shell beats a complete-but-invisible stack. -**Sentry is auto-provisioned per Vibn project.** When you scaffold a Next.js or Vite app, wire Sentry from day one so the user gets de-minified error capture + Session Replay on first deploy. The DSN (`NEXT_PUBLIC_SENTRY_DSN`) and shared org auth token (`SENTRY_AUTH_TOKEN`) are injected into the Coolify app's env automatically by `apps_create` — you don't set them. Get the project's Sentry slug from `projects_get { projectId }` (field: `sentry.slug`); pass it to `withSentryConfig({ org: "vibnai", project: "", ... })`. The reference recipe (instrumentation.ts, instrumentation-client.ts, app/global-error.tsx, next.config.ts wrapper, Dockerfile ARG declarations) is in `vibn-frontend/lib/scaffold/sentry-snippets.ts` — read it once via `fs_*` if you're unsure, then copy the snippets into the user's project verbatim. Skip Sentry for non-app projects (CLIs, library-only repos). +**Sentry is auto-provisioned per Vibn project.** When you scaffold a Next.js or Vite app, wire Sentry from day one so the user gets de-minified error capture + Session Replay on first deploy. The DSN (\`NEXT_PUBLIC_SENTRY_DSN\`) and shared org auth token (\`SENTRY_AUTH_TOKEN\`) are injected into the Coolify app's env automatically by \`apps_create\` — you don't set them. Get the project's Sentry slug from \`projects_get { projectId }\` (field: \`sentry.slug\`); pass it to \`withSentryConfig({ org: "vibnai", project: "", ... })\`. The reference recipe (instrumentation.ts, instrumentation-client.ts, app/global-error.tsx, next.config.ts wrapper, Dockerfile ARG declarations) is in \`vibn-frontend/lib/scaffold/sentry-snippets.ts\` — read it once via \`fs_*\` if you're unsure, then copy the snippets into the user's project verbatim. Skip Sentry for non-app projects (CLIs, library-only repos). -**Testing Auth & Protected Routes:** Do NOT attempt to verify signup flows or authenticated routes by making HTTP requests (e.g. `curl` or `http_fetch`) to the dev server yourself. The app is protected by NextAuth or similar session cookies which you do not have. Just write the code, start the dev server via `dev_server_start`, and provide the user the clickable `previewUrl` so they can test it themselves in their browser. If you hit a redirect/401, do NOT assume the server is broken and loop on restarting it. +**Testing Auth & Protected Routes:** Do NOT attempt to verify signup flows or authenticated routes by making HTTP requests (e.g. \`curl\` or \`http_fetch\`) to the dev server yourself. The app is protected by NextAuth or similar session cookies which you do not have. Just write the code, start the dev server via \`dev_server_start\`, and provide the user the clickable \`previewUrl\` so they can test it themselves in their browser. If you hit a redirect/401, do NOT assume the server is broken and loop on restarting it. **Rules:** - Stay under \`/workspace\`. \`fs_*\` enforce this; use \`shell_exec\` deliberately for system paths. diff --git a/vibn-frontend/app/styles/new-site.css b/vibn-frontend/app/styles/new-site.css new file mode 100644 index 00000000..bc974856 --- /dev/null +++ b/vibn-frontend/app/styles/new-site.css @@ -0,0 +1,189 @@ + + .new-site-wrapper { + --bg: oklch(0.155 0.008 60); + --bg-1: oklch(0.185 0.009 60); + --bg-2: oklch(0.225 0.010 60); + --hairline: oklch(0.32 0.010 60 / 0.55); + --hairline-2: oklch(0.40 0.012 60 / 0.35); + --fg: oklch(0.97 0.005 80); + --fg-dim: oklch(0.78 0.006 80); + --fg-mute: oklch(0.58 0.006 80); + --fg-faint: oklch(0.42 0.006 80); + + --accent: oklch(0.74 0.175 35); /* warm coral, default */ + --accent-soft: oklch(0.74 0.175 35 / 0.18); + --accent-glow: oklch(0.74 0.175 35 / 0.35); + --accent-fg: oklch(0.18 0.04 35); + + --ok: oklch(0.78 0.16 155); + + --r-sm: 8px; + --r-md: 12px; + --r-lg: 18px; + --r-xl: 28px; + + --maxw: 1240px; + --pad: clamp(20px, 4vw, 56px); + + --font-sans: "Geist", ui-sans-serif, system-ui, -apple-system, "Helvetica Neue", sans-serif; + --font-mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace; + } + + * { box-sizing: border-box; } + html, .new-site-wrapper { margin: 0; padding: 0; } + .new-site-wrapper { + background: var(--bg); + color: var(--fg); + font-family: var(--font-sans); + font-weight: 400; + line-height: 1.45; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + overflow-x: hidden; + } + .new-site-wrapper::before { + /* subtle grid */ + content: ""; + position: fixed; inset: 0; + background-image: + linear-gradient(to right, oklch(0.30 0.01 60 / 0.10) 1px, transparent 1px), + linear-gradient(to bottom, oklch(0.30 0.01 60 / 0.10) 1px, transparent 1px); + background-size: 56px 56px; + mask-image: radial-gradient(ellipse 90% 70% at 50% 30%, #000 30%, transparent 80%); + -webkit-mask-image: radial-gradient(ellipse 90% 70% at 50% 30%, #000 30%, transparent 80%); + pointer-events: none; + z-index: 0; + } + .new-site-wrapper::after { + /* film grain */ + content: ""; + position: fixed; inset: 0; + pointer-events: none; + z-index: 1; + opacity: 0.035; + mix-blend-mode: overlay; + background-image: url("data:image/svg+xml;utf8,"); + } + + /* base typography */ + h1, h2, h3, h4 { margin: 0; font-weight: 500; letter-spacing: -0.02em; line-height: 1.05; } + p { margin: 0; } + a { color: inherit; text-decoration: none; } + button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; } + ::selection { background: var(--accent); color: var(--accent-fg); } + + /* re-usable layout */ + .wrap { + position: relative; + width: 100%; + max-width: var(--maxw); + margin: 0 auto; + padding-inline: var(--pad); + z-index: 2; + } + + .mono { font-family: var(--font-mono); font-feature-settings: "ss01" on; } + .eyebrow { + display: inline-flex; align-items: center; gap: 8px; + font-family: var(--font-mono); + font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; + color: var(--fg-mute); + } + .eyebrow::before { + content: ""; width: 5px; height: 5px; border-radius: 50%; + background: var(--accent); box-shadow: 0 0 12px var(--accent-glow); + } + + /* primary button */ + .btn { + display: inline-flex; align-items: center; gap: 10px; + height: 46px; padding: 0 22px; + border-radius: 999px; + font-weight: 500; + transition: transform .12s ease, box-shadow .2s ease, background .2s ease; + white-space: nowrap; + } + .btn-primary { + background: var(--accent); + color: var(--accent-fg); + box-shadow: + 0 0 0 1px oklch(0.84 0.16 35 / 0.5) inset, + 0 10px 40px -10px var(--accent-glow), + 0 0 50px -8px var(--accent-glow); + } + .btn-primary:hover { transform: translateY(-1px); } + .btn-primary .arrow { transition: transform .15s ease; } + .btn-primary:hover .arrow { transform: translateX(3px); } + .btn-ghost { + color: var(--fg-dim); + border: 1px solid var(--hairline); + background: oklch(0.20 0.009 60 / 0.4); + backdrop-filter: blur(8px); + } + .btn-ghost:hover { color: var(--fg); border-color: var(--hairline-2); } + + /* gradient hairline card */ + .card { + position: relative; + background: linear-gradient(180deg, oklch(0.20 0.009 60 / 0.55), oklch(0.17 0.008 60 / 0.5)); + border-radius: var(--r-lg); + padding: 28px; + } + .card::before { + content: ""; position: absolute; inset: 0; border-radius: inherit; padding: 1px; + background: linear-gradient(180deg, var(--hairline-2), oklch(0.22 0.010 60 / 0.2)); + -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + pointer-events: none; + } + + /* hide ::-webkit-scrollbar globally where it'd jitter layout */ + .no-scrollbar::-webkit-scrollbar { display: none; } + + /* nav */ + .nav { + position: sticky; top: 0; + z-index: 50; + backdrop-filter: blur(12px) saturate(140%); + -webkit-backdrop-filter: blur(12px) saturate(140%); + background: oklch(0.155 0.008 60 / 0.55); + border-bottom: 1px solid oklch(0.30 0.01 60 / 0.0); + transition: border-color .2s; + } + .nav.scrolled { border-bottom-color: oklch(0.30 0.01 60 / 0.4); } + .nav-inner { + display: flex; align-items: center; justify-content: space-between; + height: 64px; + } + .logo { + display: inline-flex; align-items: center; gap: 9px; + font-weight: 600; font-size: 17px; letter-spacing: -0.02em; + } + .logo-mark { + width: 26px; height: 26px; border-radius: 50%; + background: linear-gradient(135deg, var(--accent) 0%, oklch(0.65 0.20 18) 100%); + box-shadow: 0 0 22px var(--accent-glow), inset 0 1px 0 oklch(1 0 0 / 0.25); + display: grid; place-items: center; + color: var(--accent-fg); + flex-shrink: 0; + } + .logo-mark svg { display: block; } + .logo-caret { animation: caret-blink 1.4s steps(2) infinite; } + @keyframes caret-blink { 50% { opacity: 0.25; } } + .nav-links { display: flex; gap: 28px; color: var(--fg-mute); font-size: 14px; } + .nav-links a:hover { color: var(--fg); } + .nav-cta { display: flex; gap: 10px; align-items: center; } + .nav-cta .btn { height: 36px; padding: 0 16px; font-size: 14px; } + @media (max-width: 760px) { .nav-links { display: none; } } + + /* section spacing */ + .section { position: relative; padding-block: clamp(80px, 11vh, 140px); } + .section-tight { padding-block: clamp(60px, 8vh, 100px); } + + /* responsive grid helper */ + .grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; } + @media (max-width: 880px) { .grid-3 { grid-template-columns: 1fr; } } + +.new-site-wrapper { min-height: 100vh; position: relative; overflow: hidden; } diff --git a/vibn-frontend/marketing/components/new-site/index.tsx b/vibn-frontend/marketing/components/new-site/index.tsx new file mode 100644 index 00000000..ff4b8518 --- /dev/null +++ b/vibn-frontend/marketing/components/new-site/index.tsx @@ -0,0 +1,2345 @@ +// 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 , 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 ( +//
+// Hello +// +// +// setTweak('fontSize', v)} /> +// setTweak('density', v)} /> +// +// setTweak('primaryColor', v)} /> +// setTweak('palette', v)} /> +// setTweak('dark', v)} /> +// +//
+// ); +// } +// +// ───────────────────────────────────────────────────────────────────────────── + +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,"); + 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 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 ( + <> + +
+
+ {title} + +
+
+ {children} + {hasDeckStage && railEnabled && !noDeckControls && ( + + + + )} +
+
+ + ); +} + +// ── Layout helpers ────────────────────────────────────────────────────────── + +function TweakSection({ label, children }) { + return ( + <> +
{label}
+ {children} + + ); +} + +function TweakRow({ label, value, children, inline = false }) { + return ( +
+
+ {label} + {value != null && {value}} +
+ {children} +
+ ); +} + +// ── Controls ──────────────────────────────────────────────────────────────── + +function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) { + return ( + + onChange(Number(e.target.value))} /> + + ); +} + +function TweakToggle({ label, value, onChange }) { + return ( +
+
{label}
+ +
+ ); +} + +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) { + // onChange(e.target.value)}> + {options.map((o) => { + const v = typeof o === 'object' ? o.value : o; + const l = typeof o === 'object' ? o.label : o; + return ; + })} + + + ); +} + +function TweakText({ label, value, placeholder, onChange }) { + return ( + + onChange(e.target.value)} /> + + ); +} + +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 ( +
+ {label} + onChange(clamp(Number(e.target.value)))} /> + {unit && {unit}} +
+ ); +} + +// 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 }) => ( + +); + +// 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 ( +
+
{label}
+ onChange(e.target.value)} /> +
+ ); + } + // Native 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 ( + +
+ {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 ( + + ); + })} +
+
+ ); +} + +function TweakButton({ label, onClick, secondary = false }) { + return ( + + ); +} + + + +// --- 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 ( + + + + ); +} + +function Logo({ size = 26 }) { + return ( + + + vibn + + ); +} + +function Arrow({ size = 14 }) { + return ( + + ); +} + +function Eyebrow({ children }) { + return
{children}
; +} + +// Soft radial glow blob for ambient backgrounds. Place absolutely positioned. +function Glow({ color = "var(--accent-glow)", size = 700, opacity = 1, style = {} }) { + return ( +