Compare commits

...

340 Commits

Author SHA1 Message Date
f41f309c3f Stop falsely labeling log-reading tools as failed when they read stack traces 2026-06-15 14:15:17 -07:00
d67d8e2052 Fix MCP action routing for workspace_db_query 2026-06-15 13:55:21 -07:00
9b19befa0a Fix duplicate query import in MCP route 2026-06-15 13:52:25 -07:00
c499044783 Add workspace_db_query MCP tool for reading telemetry 2026-06-15 13:49:43 -07:00
d2d487eb97 Fix ArrowDown import 2026-06-15 13:41:44 -07:00
ba00e143cc Fix Tool pill styling syntax issue 2026-06-15 13:22:06 -07:00
44385514cd Add blinking cursor to active streaming text 2026-06-15 13:12:08 -07:00
6cf4dc494f Remove Thinking container, render thoughts as normal chat text 2026-06-15 13:02:44 -07:00
9504d5f4a9 Remove floating Action Status Bar from chat UI 2026-06-15 12:56:38 -07:00
962114d1d3 Allow agent to run in background after browser refresh 2026-06-15 12:47:32 -07:00
041aef63e2 Fix syntax error 2026-06-15 12:42:05 -07:00
3f40e5f86e Beautify chat UI: tool pills, code blocks, modern links 2026-06-15 12:35:50 -07:00
812ee4bfec Polish thinking streaming UI and ordering 2026-06-15 12:23:45 -07:00
86da778721 Implement accumulate-then-act streaming for thinking models 2026-06-15 12:13:46 -07:00
a0e2364481 Store thinking events in message timeline 2026-06-15 11:50:35 -07:00
dfb79f3acd Enable thinking stream in frontend lib/ai/gemini-chat.ts 2026-06-15 11:46:20 -07:00
e58972d594 Enable Gemini thinking stream 2026-06-15 11:37:29 -07:00
b3ec779058 fix(logs): resolve missing deployment uuid in anatomy payload to fix build logs display 2026-06-14 15:11:10 -07:00
ec68e78725 fix(logs): include missing deployment UUIDs in anatomy output so frontend can reliably fetch build logs 2026-06-14 15:04:25 -07:00
7f170c8079 fix(logs): add immediate loading state clearing and append timestamps to database log tails 2026-06-14 15:03:20 -07:00
26b4c53633 feat(logs): reverse log order so the newest logs always display at the top 2026-06-14 15:00:03 -07:00
e0354d969e feat(logs): add Database logs and Build logs to the Server Logs dashboard page 2026-06-14 14:58:02 -07:00
f8cc4b32b0 feat(logs): add Dev Previews to server logs page 2026-06-14 14:52:16 -07:00
d3b3bc11d9 fix(logs): remove redundant top page header to maximize vertical terminal space 2026-06-14 14:48:51 -07:00
6578a45332 fix(dashboard): allow style overrides on primary and secondary buttons 2026-06-14 14:44:58 -07:00
6e0620ea7d fix(dashboard): align server logs page to workspace layout and flatten database link in sidebar 2026-06-14 14:42:46 -07:00
54cb481f53 fix(databases): align database table list text spacing and icon sizes with codebase tree 2026-06-14 14:39:09 -07:00
2cdbadec8c fix(databases): execute exact row count fallback for empty tables and tighten table spacing 2026-06-14 14:36:19 -07:00
1668cf1fb4 fix(databases): properly display row count pills for un-analyzed empty postgres tables 2026-06-14 14:32:29 -07:00
0894f1093d fix(databases): ensure row count pill renders safely 2026-06-14 14:26:18 -07:00
0f2476b863 fix(databases): format row counts as pills on the right side of the tree 2026-06-14 14:23:55 -07:00
8564442e67 fix(databases): remove dropdown chevron from database header 2026-06-14 14:21:54 -07:00
2b4f0d9a5f fix(databases): align database header with table row list in sidebar tree 2026-06-14 14:20:12 -07:00
606af52b8e fix(databases): move table metadata to the bottom of the table viewer 2026-06-14 14:17:56 -07:00
103ad8c81f fix(databases): apply Flowbite colors to data table viewer 2026-06-14 14:15:15 -07:00
c5894775f8 fix(dashboard): align table tree styling with codebase tree and flatten public schemas 2026-06-14 14:13:16 -07:00
c004be3b12 fix(databases): align database tree styles with codebase tree 2026-06-14 14:11:14 -07:00
7a8d13d7e2 fix(dashboard): fix double borders and corner masking issue on databases page 2026-06-14 14:07:46 -07:00
9bca5d9850 fix(dashboard): remove top PageHeader from Databases page to maximize vertical space 2026-06-14 14:05:32 -07:00
a55a226ed0 fix(dashboard): apply workspace layout sizing and independent scroll to databases page 2026-06-14 14:02:57 -07:00
968d3477c2 fix(codebase): fix background color cutting off outer card border radius in file tree header 2026-06-14 13:58:40 -07:00
6057fb91c1 fix(codebase): perfectly align left file tree header and right preview header 2026-06-14 13:56:33 -07:00
a695f74132 fix(codebase): clean up initial loading state UI 2026-06-14 13:53:29 -07:00
ecd51a3987 fix(codebase): add top bar header frame to file viewer loading states 2026-06-14 13:50:07 -07:00
93e08e5c8e feat(codebase): auto-select the best entry point file on load so preview isn't empty 2026-06-14 13:47:08 -07:00
ccba3d42d2 feat(codebase): smart auto-expand logic for Next.js and React project structures 2026-06-14 13:45:03 -07:00
dc9347c01c fix(codebase): freeze header name on scroll and remove count pill 2026-06-14 13:42:09 -07:00
e73efc24ba fix(codebase): fix left column padding and justification to align header with file tree 2026-06-14 13:41:07 -07:00
44bed63c28 fix(codebase): constrain right column height to prevent window scrolling 2026-06-14 13:37:20 -07:00
c649cb06c9 fix(preview): align background color and padding with new dashboard theme 2026-06-14 13:35:06 -07:00
86ebf542b9 fix(codebase): constrain height correctly to flex container 2026-06-14 13:34:20 -07:00
e06ad16aab fix(codebase): optimize layout paddings for code editor feel 2026-06-14 13:32:25 -07:00
3a884fe28d fix(codebase): constrain layout to 100vh so left and right panes scroll internally instead of the window 2026-06-14 13:29:21 -07:00
db7814782c fix(dashboard): align rail group titles, headers, and count pills with reference design 2026-06-14 13:27:42 -07:00
c56a39d763 fix(codebase): optimize layout for editor view
- Removes top PageHeader to maximize vertical space.

- Sets layout padding tighter.

- Dynamically replaces 'Code files' header with the repository name (if single repo).

- Hides redundant nested tile header when there's only one codebase.
2026-06-14 13:25:35 -07:00
9766990e70 fix(codebase): connect the code tree container and preview container like in reference image 2026-06-14 13:23:33 -07:00
824fe0c6f1 fix(codebase): remove subtitle from Codebase page header 2026-06-14 13:22:27 -07:00
d738842069 fix(codebase): remove legacy single-codebase hint description from api endpoints 2026-06-14 13:20:49 -07:00
adc60690c8 fix(codebase): remove duplicate THEME definition causing build failure 2026-06-14 13:19:21 -07:00
01d9c07e24 fix(codebase): migrate codebase layout wrappers to dashboard-ui cards 2026-06-14 13:18:14 -07:00
1895c8f947 perf(codebase): implement in-memory cache for file tree to persist state across tab navigations 2026-06-14 13:13:50 -07:00
3774a1771b fix(codebase): adjust code viewer spacing, font size, and line number colors to match design reference 2026-06-14 13:11:12 -07:00
5abfe19bed fix(dashboard): use sans-serif for headings globally to match design kit 2026-06-14 13:07:40 -07:00
d41a2619b1 feat(codebase): add image and SVG preview support to the file viewer 2026-06-14 13:05:07 -07:00
448968119e fix(codebase): flip code viewer syntax highlighting to oneLight theme 2026-06-14 13:03:04 -07:00
759ad99cd8 feat(codebase): add syntax highlighting via react-syntax-highlighter with vs-dark-plus theme to the file viewer 2026-06-14 13:02:21 -07:00
249d88f405 feat(codebase): add file-type specific subtle colored icons to the file tree 2026-06-14 12:59:07 -07:00
bb5d879a0d feat(dashboard): add FileTree component and auto-expand top level directories 2026-06-14 12:53:27 -07:00
52f8c26ace fix(dashboard): align heading styles across legacy pages to match dashboard-ui 2026-06-14 12:52:53 -07:00
77b24f1a6b fix(logs): resolve object-child crash and align terminal colors to light theme 2026-06-14 12:46:26 -07:00
4c0754de33 fix(logs): fix terminal layout 2026-06-14 12:44:19 -07:00
7e67e480bb fix(logs): fix terminal colors and payload rendering 2026-06-14 12:43:09 -07:00
95f54260c1 feat(dashboard): integrate MagicUI Terminal and BorderBeam 2026-06-14 12:41:11 -07:00
869098af1e feat(codebase): auto-expand top-level directories in file tree 2026-06-14 12:34:53 -07:00
691fc73ed1 fix(overview): add missing StatusDot import 2026-06-14 12:25:47 -07:00
ebc84dbdc5 fix(overview): restore missing useAnatomy import 2026-06-13 11:39:56 -07:00
f19155ed44 fix(dashboard): add missing dashboard-ui component kit 2026-06-13 11:31:44 -07:00
8f5853e684 fix(dashboard): apply PageHeaders to correspond with new sidebar names 2026-06-13 11:30:30 -07:00
8a4aad3707 fix(dashboard): rename AI Blueprint to Plan Docs 2026-06-13 11:18:52 -07:00
0bc9e288f1 feat(dashboard): reword and reorder sidebar for non-technical founders
Rewords dev-jargon into approachable business concepts and reorders them by priority (Product > Data/Infra > Ops).
- Plan & Specs -> AI Blueprint
- Code -> Codebase
- Auth / Users -> Users
- Data -> Databases
- Storage -> File Storage
- Services -> Live Servers
- Domains -> Custom Domains
- Logs -> Server Logs
2026-06-13 11:17:36 -07:00
9092b9e549 fix(dashboard): remove Analytics, Marketing, Security, Integrations + fix build
- Deleted unused/stub routes: Security, Analytics, Marketing (+SEO/Social), Integrations
- Removed these routes from the Dashboard Sidebar menu
- Fixed Next.js build errors caused by duplicate component declarations (SectionHeader, KvRow) in overview, hosting, services, and infrastructure by relying fully on the unified dashboard-ui kit.
2026-06-13 11:13:37 -07:00
eb198e2d4d ship: project dashboard pages + sidebar/chat overhaul + log tooling
Ships accumulated WIP that was sitting uncommitted:
- New (home) dashboard route pages: overview, code, data/tables, hosting,
  infrastructure, services, domains, integrations, agents, analytics, api,
  automations, billing, logs, market, marketing(+seo/social), product, security,
  storage, users, settings(app/auth).
- dashboard-sidebar, project-icon-rail, chat-panel updates; mcp + anatomy route
  changes; package.json/lock dependency bumps.
- Coolify log tooling (scripts/fetch-app-logs.mjs + fetch-app-logs-ssh.mjs) and
  ai-new-thread.md "Fetching Production Logs" section.

Excludes throwaway debug scripts and telemetry audit dumps (the latter contain
live credentials and must not be committed).
2026-06-12 18:09:09 -07:00
0f212c750b fix(preview): stop refresh-flicker false-restarts + harden dev container & agent
- isDevServerListening: key off curl EXIT CODE not response time. The 2s
  max-time treated a busy/compiling-but-listening dev server as DEAD, so ensure
  restarted a healthy server on every refresh -> cold compile -> the
  502/no-CSS/broken-images/perfect flicker. Now dead only when BOTH localhost
  and 0.0.0.0 refuse the connection (curl exit 7).
- ensure route: liveness probe is fail-safe (try/catch) -> never 500s or
  needlessly restarts on a probe error; trusts the DB flag instead.
- dev container: reconcile dead orphan containers before resume/start so a
  leftover name no longer triggers 'container name already in use' -> Traefik
  gateway timeout.
- dev container: inject AUTH_SECRET / NEXTAUTH_SECRET / AUTH_TRUST_HOST so
  scaffolded NextAuth apps stop throwing [auth][error] MissingSecret in preview.
- chat prompt: don't bounce a healthy dev server; only claim actions a tool
  actually performed (no hallucinated DB deletes); NextAuth previews pre-wired.
- intent budgets: route 'not appearing/showing/missing' to diagnose; bump
  status_check 12->16, diagnose 15->22 so investigations don't hit the cap.
2026-06-12 18:05:16 -07:00
514f11e80d fix(preview): self-healing dev server so the preview always loads cleanly
The dominant production failure was a dead dev-server process behind a
'running' DB flag (idle-stop / OOM / crash / host restart), which the UI
trusted and embedded -> permanent 502 until a manual restart.

- dev-container.ts: add isDevServerListening() fast liveness probe; stop the
  container entrypoint from auto-running 'npx next dev --webpack' (it competed
  with the managed server, forced the wrong bundler/cwd, and doubled memory);
  drop the fake state='running' seed row; bump dev container memory 1g -> 2g.
- ensure route: verify a 'running' row is ACTUALLY listening and resurrect it
  if dead, instead of trusting the flag; never bounce a healthy server.
- preview page: call ensure on every mount and on refresh (verify + heal),
  force an immediate anatomy refetch on (re)start so a dead frame swaps to
  'warming up' without the 5s lag.

Backstopped by the partial unique index + startDevServer idempotency, so heals
can never duplicate or thrash a server.
2026-06-12 17:30:27 -07:00
0f90ef6f5c Fix preview pipeline: dedup duplicate previews, race-safe dev server start, honest readiness
- Add partial unique index on (project_id, port) for active dev servers so the
  SELECT-then-INSERT race can no longer create duplicate 'Port 3000' rows.
- Make startDevServer race-safe: on unique violation, adopt the winning row
  instead of duplicating.
- ensure route no longer marks a server 'running' before it binds the port;
  the readiness probe flips starting->running only after the port answers.
  Kills the '502 -> broken CSS -> works' refresh loop.
- Deduplicate previews per-port in sortDevPreviewsFrontendFirst as a defensive
  backstop for the dropdown.
- Revert iframe _refresh query-param hack (was forcing cold recompiles).
2026-06-12 16:57:06 -07:00
87acebfab3 fix(preview): resolve ReferenceError on refreshKey initialization order 2026-06-12 16:50:34 -07:00
aa780492fd fix(preview): add 60-second grace period before marking freshly booted servers as zombies to prevent aggressive 502 teardowns 2026-06-12 16:49:31 -07:00
95253c7707 fix(preview): resolve ReferenceError on isForceStarting initialization order 2026-06-12 16:46:35 -07:00
de950b1fb0 Make dev_server_start idempotent to prevent HMR breakage and 502s 2026-06-12 16:42:26 -07:00
76c0241bd1 Save frontend state (layout, sidebar, chat panel, preview refresh fix) before rollback 2026-06-12 16:35:45 -07:00
c3fdc170d1 fix(preview): sync fs_dev_servers state with container suspension to properly handle idle wakeup 2026-06-12 16:08:03 -07:00
68c8d398e3 fix(preview): permanently restore resilient zombie-killer ping to auto-restart suspended containers 2026-06-12 16:03:00 -07:00
191fb10b4b fix(preview): remove zombie killer ping to allow container auto-wake via Traefik 2026-06-12 16:00:47 -07:00
28441e75f2 fix(overview): restore lost Dev Previews and Live endpoints lists that were accidentally overwritten during Dashboard migration 2026-06-12 15:57:14 -07:00
9b56cf362b fix(preview): remove brittle dev server readiness probes; trust that the server will eventually boot 2026-06-12 15:36:35 -07:00
c565a9f6ed fix(preview): properly restore zombie-killer ping with graceful fallback logic 2026-06-12 15:33:30 -07:00
4375fbcb22 fix(preview): resolve 'command not found' by prefixing next dev fallback and prompt recipes with npx 2026-06-12 15:29:01 -07:00
b2bb1bc1e9 fix(preview): remove zombie killer ping to allow container auto-wake via Traefik 2026-06-12 15:24:59 -07:00
07fb3377ad fix(preview): restore resilient zombie-killer logic to auto-restart suspended previews 2026-06-12 15:18:34 -07:00
c5454347f9 fix(preview): remove zombie killer ping to allow container auto-wake via Traefik 2026-06-12 15:12:14 -07:00
27a1f308d0 fix(preview): add 60-second grace period before zombie killer murders booting dev servers throwing 504s 2026-06-12 15:05:24 -07:00
5a7e1abcc7 design(preview): restore path input box with datalist dropdown for free-text navigation 2026-06-12 15:03:16 -07:00
960232e525 design(preview): match toggle button sizes to device toggles 2026-06-12 15:00:40 -07:00
6687b79bfd design(chat): replace internal expand button with floating edge toggle; add ChevronLeft 2026-06-12 13:01:36 -07:00
2b569bd55f fix(dashboard): re-add missing Users icon import for sidebar 2026-06-12 12:55:54 -07:00
9abc957260 design(dashboard): reorder sidebar navigation items per requested priority 2026-06-12 12:54:39 -07:00
f6f7867d77 design(chat): fix z-index stacking to ensure collapse button floats above dashboard pane 2026-06-12 12:51:47 -07:00
5b26dbf80d design(chat): fix clipping of collapse button by adjusting overflow on structural chat container 2026-06-12 12:47:43 -07:00
81994d4b6c design(dashboard): remove unused routes, rename existing routes to match Base44 menu structure 2026-06-12 12:42:19 -07:00
1532cb6111 design(dashboard): rebuild sidebar to match Base44 navigation hierarchy and aesthetic 2026-06-12 12:34:39 -07:00
69e8086018 fix(preview): resolve ReferenceError by hoisting isForceStarting state declaration 2026-06-12 12:28:22 -07:00
2a2962a098 design(preview): replace path input with strict dropdown menu 2026-06-12 12:22:50 -07:00
cd26dd807f feat(preview): add server/port dropdown to address bar 2026-06-12 12:20:48 -07:00
5ed10c4077 feat(preview): make address bar interactive for routing inside iframe 2026-06-12 11:57:36 -07:00
c1a43d18a6 feat(preview): add tablet device mode with scaled frame styling 2026-06-12 11:54:47 -07:00
2e8d9ddecc design(preview): organize address bar tools, move refresh button to far left 2026-06-12 11:50:54 -07:00
a52557f35b design(preview): consolidate desktop/mobile toggles inside the URL bar for a cleaner layout 2026-06-12 11:48:33 -07:00
f510848173 design(preview): move refresh button inside the address bar for a cleaner layout 2026-06-12 11:46:14 -07:00
308d7cd5e1 design(preview): remove text labels from device toggles to match reference 2026-06-12 11:45:09 -07:00
c337655dde design(preview): match header layout order to Base44 reference (URL bar on left, device toggles on right) 2026-06-12 11:43:25 -07:00
730154c2f9 fix(hosting): restore missing CSS properties for hosting tab layout 2026-06-12 11:39:49 -07:00
3833ba5dd2 fix(preview): do not murder dev servers that take longer than 2 seconds to compile webpack 2026-06-12 11:36:34 -07:00
2e66ea087b fix(preview): resolve SQL column error on dev server force-start 2026-06-12 11:31:15 -07:00
576446e36a design(preview): move preview/dashboard toggle to the left side of the header 2026-06-12 11:25:52 -07:00
dd85b0b8f8 design(chat): remove hardcoded suggestion chips from input box 2026-06-12 11:23:07 -07:00
41a1f66b2d design(preview): match toggle button sizes to device toggles 2026-06-12 11:20:51 -07:00
32aaf9b6ab design(preview): align Preview and Dashboard buttons next to Publish button on the right 2026-06-12 11:16:51 -07:00
7305c2a57c design(preview): replace mock visual tools with wide address bar showing active dev URL 2026-06-12 11:12:09 -07:00
9b13320253 design(chat): remove un-wired dictation and select-mode buttons from composer to match Base44 simplicity 2026-06-12 11:05:01 -07:00
55646f668e design(preview): replace mock visual tools with wide address bar showing active dev URL 2026-06-12 11:00:47 -07:00
0fdb47c310 fix(chat): define missing suggestionChipStyle constant 2026-06-12 10:57:23 -07:00
548420c4f5 fix(preview): remove duplicate Palette import in project icon rail 2026-06-12 10:54:52 -07:00
2ee68c7ac2 feat(preview): add interactive address bar and visual editing tools to preview header 2026-06-12 10:52:38 -07:00
2a7e87c790 fix(preview): resolve TypeError on forced dev server start when no history exists 2026-06-12 10:47:36 -07:00
e240481ba6 design(chat): hide verbose command args for dev server and shell tools to keep UI clean 2026-06-11 17:14:15 -07:00
180c55ee89 copy(preview): change button text from Start Dev Server to Start Preview 2026-06-11 17:10:10 -07:00
08fbe8405b feat(preview): add 1-click start dev server button to empty state 2026-06-11 17:07:17 -07:00
7337e2c5b0 fix(preview): re-trigger dev server ensure check when user clicks refresh button 2026-06-11 12:07:39 -07:00
2036df6c2b fix(preview): zombie process cleanup on anatomy load 2026-06-11 12:05:48 -07:00
2bff945dd8 design(chat): simplify tool labels to plain English 2026-06-11 11:52:36 -07:00
180f0bdc0a design(chat): replace heavy phase dividers with sticky active-status bar; flatten tool groups to match Base44 aesthetic 2026-06-11 11:40:16 -07:00
371ae37cc2 feat(preview): support multiple preview ports with styled picker 2026-06-11 11:27:44 -07:00
bcf47b5c6c fix(ai): enforce port 3000 exclusively for visual previews to match UI constraints 2026-06-11 11:23:01 -07:00
ca0ae32a21 fix(preview): ignore stale ghost dev servers in auto-restarter; cap elapsed timer 2026-06-11 11:20:19 -07:00
d165ab9de1 feat(preview): auto-restart dev server on session open; WarmingUpState with elapsed timer
- New POST /api/projects/[id]/dev-server/ensure: checks if dev server is running,
  queries last known config from fs_dev_servers, fires startDevServer +
  probeDevServerReadiness in background, returns immediately
- Preview pane calls ensure on mount when anatomy is loaded but no server is running
- Distinguishes state='running' (show iframe) from state='starting' (show warming-up UI)
- WarmingUpState: indigo spinner, elapsed timer, 'View last deployed version' link if available
- ensureCalledRef prevents duplicate calls per mount
- The 5s anatomy poll handles the starting→running transition automatically
2026-06-11 11:05:58 -07:00
7a69f47608 docs(ai-new-thread): clarify master-ai is local-only; production runs from Gitea+Coolify 2026-06-10 21:45:28 -07:00
cca2211b33 docs(deploy): clarify correct push command for Coolify remotes; warn against subtree push 2026-06-10 21:44:06 -07:00
82a41f7e95 fix(stop+stability): stop button interrupts live generation; classifier, prompt + preview pane improvements
Stop button fix:
- Plumb AbortSignal end-to-end: callVibnChat → Gemini SDK (config.abortSignal) / OpenAI fetch → executeMcpTool (/api/mcp fetch)
- Treat abort as clean user stop (not fatal error); partial reply persisted with '(stopped by user)'

Classifier fix:
- Add timeout/gateway/5xx/connection-error vocabulary to diagnose intent
- Prevents 'I get a gateway timeout' from falling through to feature_build (40 rounds) and looping

Prompt / agent behaviour:
- Render verification is now scope-aware: small edits stop at green healthCheck; no browser_console/curl audit on healthy server
- Sanitize stale '### Phase Checkpoint' walls from loaded history so old threads stop biasing new turns
- Next.js dev command updated to --no-turbopack for container stability (per-route lazy compile caused cold-start 503s)
- New public page prompt: agent checks middleware allowlist in the same turn
- Scope discipline and QA-tool gating carried forward from prior session

Code cleanup:
- Remove duplicate AgentPhase declaration (TS2440)
- Remove dead checkpoint emit branch and orphan 'checkpoint' phase value
- Remove unused MAX_TOOL_ROUNDS constant

Preview pane (build status):
- 4-state machine: initial-load / building (with elapsed timer) / build-failed / not-running
- pollMs 0 → 5 000ms so dev-server recovery and build completion auto-update without refresh
- anatomy route + use-anatomy type: inFlightBuild gains createdAt for elapsed timer
2026-06-10 21:40:48 -07:00
39cb9194a5 feat(verification): acceptance-check layer + executor fix-loop; hide phase-checkpoint walls; guaranteed turn-end summary. Verification gated behind VIBN_VERIFICATION_ENABLED. 2026-06-10 19:43:36 -07:00
46291becd3 ux(chat): remove the reasoning/suggestion bubble and the 'executing tools & planning next steps' status line 2026-06-10 17:52:38 -07:00
e9d597de03 fix(governor): classify multi-word greetings ('hey there!', 'good morning') and short verb-less messages as conversational so they don't trigger the agent loop 2026-06-10 17:50:31 -07:00
a87faa2353 ux(chat): clean tool-pill results (no raw JSON, Failed/exit verbs); structured build-health status instead of 'didn't reach a clean stopping point'; label active toolbar mode 2026-06-10 17:44:19 -07:00
6fe774719a fix(governor): stop misclassifying 'okay <request>' and investigative questions as conversational; raise status_check/diagnose tool budgets (fixes round_cap cut-offs) 2026-06-10 17:26:41 -07:00
a4fe96496a feat(telemetry): emit per-turn governor summary (stop_reason, rounds, tool_results) for orchestration diagnostics 2026-06-10 17:07:43 -07:00
db18168537 feat(telemetry): capture agent-runner model turns via central telemetry service 2026-06-10 16:43:14 -07:00
caab38f950 fix(telemetry): restore knowledge_chunks schema; move agent_telemetry DDL to its own file 2026-06-10 16:23:10 -07:00
4f76b0f3b7 feat: decoupled training telemetry microservice 2026-06-10 15:11:26 -07:00
3c0a6860fc design(chat): align composer action buttons to the right 2026-06-10 14:15:14 -07:00
6a2027b0d4 design(chat): add paperclip button to chat input and auto-resize textarea 2026-06-10 14:13:31 -07:00
ef539d34a7 chore(telemetry): correct Path Confusion loop breaker implementation 2026-06-10 12:16:42 -07:00
e6721a0b72 chore(telemetry): resolve loop crash caused by Temporal Dead Zone hoisting and fix conversational budget mapping 2026-06-10 12:04:13 -07:00
8eaa20106a feat(orchestration): implement state-based loop governor with forced checkpoints, phase events, and robust tool signatures 2026-06-10 11:55:17 -07:00
4550df6c1a perf(telemetry): stop migration hook from endlessly rsyncing large node_modules by guarding with marker file 2026-06-10 11:46:50 -07:00
a01f3331df chore(telemetry): implement robust path-confusion stall guard, persist verify signatures correctly, and redact secrets from telemetry logs 2026-06-10 11:38:49 -07:00
019211ecce chore(telemetry): loosen error normalization to preserve status codes and line numbers for accurate verification signatures 2026-06-10 11:12:29 -07:00
d433da56f9 chore(telemetry): resolve universal path normalizer logic and path-confusion tracking 2026-06-10 11:08:42 -07:00
662caf230a design(chat): implement glass-box phase tracker and checkpoint rendering in timeline 2026-06-09 19:01:44 -07:00
ca47d0643d feat(telemetry): implement phase-based execution loop and adaptive tool budgets 2026-06-09 18:58:12 -07:00
d4c10db58e chore(telemetry): use safer rsync for container path migration hook 2026-06-09 16:28:51 -07:00
7ddcbfe32d chore(telemetry): add path-confusion loop breaker and strict blank-preview diagnostic protocol 2026-06-09 16:27:09 -07:00
472e30e9bc chore(telemetry): replace fragile regex path normalization with bulletproof path.posix.resolve 2026-06-09 16:25:51 -07:00
a2298be5ca chore(telemetry): remove hardcoded legacy getacquired slug from universal path normalizer 2026-06-09 16:21:12 -07:00
137d5975e1 chore(telemetry): implement universal path normalizer and omni-reaper to prevent preview sprawl 2026-06-09 16:11:31 -07:00
7a9c2575f0 chore(telemetry): add path-confusion loop breaker and strict blank-preview diagnostic protocol 2026-06-09 16:10:45 -07:00
dd510fe81f chore(telemetry): verify signature comment and cleanup 2026-06-09 15:35:46 -07:00
ef4a06a57c ux(chat): tune silent-loop status nudge threshold to 6 rounds and strip leaked tool trace payloads from UI 2026-06-09 15:02:34 -07:00
a036f2f28f chore(telemetry): align shell_exec and dev_server_start cwd to flattened workspace root 2026-06-09 14:59:42 -07:00
8c73f72680 chore(telemetry): jack up MAX_TOOL_ROUNDS to 150 for ultimate custom app-building runway 2026-06-09 14:30:36 -07:00
f1d0c9e0b5 chore(telemetry): jack up MAX_TOOL_ROUNDS to 150 for ultimate custom app-building runway 2026-06-09 14:18:44 -07:00
ad7d0face8 chore(telemetry): raise MAX_TOOL_ROUNDS to 60 for complete engineering runway 2026-06-09 14:16:31 -07:00
b43dddac4e chore(telemetry): add container-level self-healing repository folder migration hook 2026-06-09 13:52:14 -07:00
1284078799 chore(telemetry): align attached-file reader to flattened project root path 2026-06-09 13:38:32 -07:00
de1209afe4 chore(telemetry): refactor stall detector to track real state progress and persist non-null verify signatures across edit rounds 2026-06-09 13:36:30 -07:00
6ec312f716 chore(telemetry): flatten the project slug layer and remove cd path instructions from system prompt 2026-06-09 13:28:57 -07:00
b5b18ccd32 design(chat): replace raw mcp tool logs with active-progressive visual badges 2026-06-09 13:06:31 -07:00
98cb278cbc ux(mcp): resolve tool-pill red X contradiction inside Playwright browser crawler 2026-06-09 12:42:53 -07:00
3679ccf913 chore(telemetry): optimize state-based loop stall detector by tracking tool input signatures and clean up unused helper functions 2026-06-09 12:23:20 -07:00
7b6cac5462 chore(telemetry): implement state-based loop governor, 180s tool timeout, visual-qa path fix, and fs_write diff-guard 2026-06-09 12:05:15 -07:00
c442921ccb chore(telemetry): add bulletproof mcp_token sanitization and read-only mode fallback in chat route 2026-06-09 10:47:32 -07:00
48c959402c design(onboarding): add premium springy card animations, breathing focus inputs, and volumetric radial glow 2026-06-08 16:25:30 -07:00
492404cd14 chore(telemetry): fix agent loops, name mangling, dev server leaks, CWD alignment, and add daily session auditor 2026-06-08 16:09:58 -07:00
f670fee691 chore: deploy final layout and routing polish to live 2026-06-08 14:46:21 -07:00
f9ae97d31c fix(auth): prevent flash of login screen by maintaining loading state while resolving post-auth dashboard routing 2026-06-08 14:42:26 -07:00
2312784b96 feat(agency): append user profile identity badge and sign-out footer to the agency side navigation 2026-06-08 14:37:58 -07:00
1ee3a2e28e design: streamline agency growth nav items to focus strictly on lead pipeline and contracts 2026-06-08 14:24:53 -07:00
d5467bf236 design: flatten agency client menu structure, moving granular client controls to dedicated client pages 2026-06-08 14:23:16 -07:00
f1b2c7147a feat(agency): redefine agency sidebar navigation to match Bonsai OS patterns with Client Management, nested Delivery Hubs, and Finance & Ops 2026-06-08 14:22:10 -07:00
00539b90e4 feat(agency): refactor agency dashboard into a Next.js App Router layout with dynamic route-based views for individual clients 2026-06-08 14:17:10 -07:00
f8c73f27de feat(agency): refactor agency sidebar to make clients selectable top-level items that render a dedicated client-management view instead of using inline collapsible accordions 2026-06-08 14:13:17 -07:00
00f269822b feat(agency): restore icons and dynamic nested client menus with expand/collapse states 2026-06-08 14:08:03 -07:00
bc6ccce9e0 chore(agency): remove temporary routing console debug logs 2026-06-08 13:39:13 -07:00
697eaad2d7 fix(agency): permanently resolve double-sidebar race condition by cleanly redirecting agency workspaces from /projects to a dedicated /agency route 2026-06-08 13:31:11 -07:00
03bcbfd1c5 fix(agency): resolve unused imports and wire workspace slug dynamically into Agency Command Center sidebar 2026-06-08 13:25:45 -07:00
fa9a8840f7 fix(agency): bypass standard maker sidebar inside projects layout for agency workspaces 2026-06-08 13:24:11 -07:00
c35b63d797 fix(agency): add force-dynamic to workspace api route to prevent aggressive next.js cache 2026-06-08 13:23:07 -07:00
0d5ec04f5c fix(agency): force no-store cache on workspace metadata fetch to prevent stale routing 2026-06-08 13:18:43 -07:00
63b16e76bb feat(agency): implement dynamic day-0 routing to seamlessly send agency users to the Command Center instead of the Maker projects grid 2026-06-08 13:13:51 -07:00
9323a92eff feat(agency): build high-fidelity Agency Command Center dashboard matching Cadence CRM aesthetics with Lead Generation Engine interface 2026-06-08 13:12:15 -07:00
ad4872d31c design: implement multi-file selection support for research and existing-project onboarding uploaders 2026-06-08 12:49:03 -07:00
f72d27790a design: implement dynamic file-ingestion and PRD checklists for research and existing-project stages 2026-06-08 12:33:43 -07:00
cca53538ed design: remove client-profile step from agency flow, streamlining it to Brief, Scope, and Handoff 2026-06-08 12:28:17 -07:00
a1fd81c8bb design: update status-option card descriptions for research and existing-project stages 2026-06-08 12:24:51 -07:00
1756778a54 design: integrate Name workspace step into the path, pre-seed name from description, and remove redundant vibe and choice stages 2026-06-08 12:23:14 -07:00
00243cbc73 design: integrate 4-step onboarding flow (Type, Idea, Status, Look) 2026-06-08 12:20:18 -07:00
b8da6937f7 design: replace onboarding audience step with starting-point progress status cards 2026-06-08 12:18:33 -07:00
2b3aed7f27 design: strip down onboarding path to 3 high-speed steps (Idea, Audience, Look) 2026-06-08 12:13:50 -07:00
2bfcf605d6 design: implement blurred dashboard overlays and 5-item symmetric 2x2 grid with full-width I'm not sure tile at the bottom 2026-06-08 12:12:00 -07:00
d4dfb163f3 design: implement symmetrical 2x2 design layout grid with light/dark vertical sidebars and light/dark top horizontals 2026-06-08 12:08:48 -07:00
db26a51ea3 design: double card sizes to 300px and lay out in a 2x2 grid 2026-06-08 12:01:14 -07:00
42fa2594e3 design: scale up layout preview card height from 104px to 170px for dramatic legibility and impact 2026-06-08 11:57:10 -07:00
9640e138f3 design: implement pixel-perfect high-fidelity vector mockup replicas of your actual CRM screens inside the onboarding layout selector 2026-06-08 11:56:14 -07:00
83612f29c4 design: implement 3-column horizontal side-by-side layout picker with minimal cards (illustrations only, no description/radio text) 2026-06-08 11:47:01 -07:00
ebec667d62 design: align layout-mockup previews with 100% precision to your actual Lattice CRM templates 2026-06-08 11:44:41 -07:00
7d4d034e2a design: align layout-mockup previews precisely with your high-fidelity SaaS templates 2026-06-08 11:37:33 -07:00
188be0ee6b design: implement beautiful CSS-animated miniature mockup layout previews inside design-style onboarding cards 2026-06-08 11:30:38 -07:00
abfe98bdce design: swap order of onboarding steps so design-style selection happens before idea brain-dump 2026-06-08 11:24:09 -07:00
32124555ab design: add dynamic design-style layout selector for saas, marketplace, and websites 2026-06-08 11:23:01 -07:00
fa2e08ea42 design: make I'm not sure a first-class full-width card tile inside the product-type selection grid 2026-06-08 11:10:23 -07:00
f142128f54 design: add I'm not sure skip button to product archetype onboarding page 2026-06-08 11:08:35 -07:00
b98fb2eba8 design: prevent preset-group tiles from resizing on selection by maintaining a static radio-check circle 2026-06-08 11:07:10 -07:00
dcbab51f9e design: swap order of onboarding steps so category selection happens before idea brain-dump 2026-06-08 11:04:03 -07:00
cc429830ba design: change onboarding front door option from Personal to Solo or Team 2026-06-08 11:02:27 -07:00
71deea7980 design: remove emoji icons from product archetype onboarding tiles 2026-06-08 11:01:54 -07:00
a0ae6ed82e design: replace redundant onboarding audience step with 6 product archetype tiles 2026-06-08 11:00:37 -07:00
1accdc6912 design: restore minimum character count to 8 on entrepreneur onboarding step 0 2026-06-08 10:54:58 -07:00
a5d8b9471c design: increase required minimum character count to 80 on entrepreneur onboarding step 0 2026-06-08 10:53:37 -07:00
a6d0688c94 design: increase entrepreneur onboarding textarea to 200px and add reassuring subheader text, resolve unused label import error 2026-06-08 10:52:13 -07:00
0d8e982f81 design: remove trust bullet strip from signup, make 🇨🇦 Built in Canada single larger centered footer below card 2026-06-08 10:47:27 -07:00
d7187abedc feat(auth): make Name field required on signup 2026-06-08 10:45:26 -07:00
0c56ed4cc5 design: hide live accent switcher from landing page (commented out) 2026-06-06 21:18:33 -07:00
dd44963225 design: set default site theme to crimson red (hue 25, vivid 1.0) 2026-06-06 21:17:19 -07:00
0e674e6715 design: reduce border-beam size to 90px to prevent corner-overlap shudder on short vertical runs 2026-06-06 21:16:30 -07:00
fb47b859ab design: optimize border-beam with layout containment and hardcoded path radius to eliminate corner stutter 2026-06-06 21:15:08 -07:00
c11fa15828 design: add hardware acceleration offsets to border-beam to prevent corner jitter 2026-06-06 21:12:45 -07:00
bb5645c5d0 design: apply silver gradient to .audience-title section header 2026-06-06 21:11:41 -07:00
eaea0dd027 design: apply silver gradient to section headers + nav links, add dev-only live accent switcher, slow border-beam to 11s 2026-06-06 21:10:42 -07:00
2714f8cdf3 feat(frontend): email+password auth, /signin + /signup pages, marketing consolidation, onboarding workspace naming + full data persistence 2026-06-06 20:28:38 -07:00
b33a85c8dc feat: change 'Request invite' to 'Sign up' on the marketing homepage 2026-06-06 19:00:54 -07:00
0480b306f1 feat: flatten routes and merge marketing and onboarding directories 2026-06-06 18:52:03 -07:00
47417d13a0 feat: support root-level _marketing and _onboarding directories (T12) 2026-06-06 18:32:57 -07:00
1ed76c99b8 chore: remove deprecated modular marketing components (Group 3) 2026-06-06 18:27:17 -07:00
135fc2d1e6 feat: enable marketing site registration and launch-prompt preservation (T12) 2026-06-06 18:16:19 -07:00
d1cb116e30 merge: resolve conflicts and integrate coolify_gitea/main with local onboarding changes 2026-06-06 18:02:41 -07:00
4d40496739 feat: complete live-verified GTM onboarding flow & places autocomplete search proxies 2026-06-06 17:53:13 -07:00
febcbf6d2e fix(frontend): update TimelineToolGroup to visually propagate error status with red color and X icon 2026-06-04 12:37:44 -07:00
0439a8dafd feat(frontend): instruct Web Vibe agent to proactively check browser console for HMR syntax errors 2026-06-04 12:29:53 -07:00
6813869d10 fix(frontend): add missing React import for React.memo in chat-panel 2026-06-04 12:00:13 -07:00
486d4449b2 fix(frontend): correctly resolve token object from revealWorkspaceApiKey to prevent unauthorized access 2026-06-04 11:45:42 -07:00
9def97c3a5 fix(frontend): add X-Accel-Buffering to sse stream and optimize textarea input height resize 2026-06-04 11:16:21 -07:00
48ab562577 fix(frontend): remove infinite localStorage caching of mcp_token to prevent unauthorized lockout 2026-06-04 11:01:03 -07:00
9523a8f482 fix(frontend): double-escape Python newline strings in toolFsEdit and finalize doubled-path fixes 2026-06-03 16:43:34 -07:00
f40dbdfb99 fix(frontend): strip duplicate subdirectory prefix in normalizeFsPath to resolve doubled-path bug 2026-06-03 16:42:01 -07:00
6a6fbd87a7 fix(frontend): implement robust POSIX pipe for fs_edit and robust parameter defaulting in fs_glob 2026-06-03 16:41:00 -07:00
a7d4ccfc88 build(runner): compile structured gemini API request/response logging 2026-06-03 16:11:24 -07:00
a1650abe06 feat(frontend): add structured gemini API request/response logging 2026-06-03 16:10:48 -07:00
9d3aef33e8 feat(runner): add structured gemini API request/response logging 2026-06-03 16:10:36 -07:00
1219c9d00f fix(frontend): default empty path/cwd to root dot in fs_glob and fs_tree 2026-06-03 15:52:56 -07:00
6f16401849 feat(frontend): automatically trigger prisma generate on schema.prisma write 2026-06-03 15:48:25 -07:00
59e5b4d4a9 feat(runner): inject active dev server status in system prompt 2026-06-03 15:48:02 -07:00
3c855461b6 fix(frontend): replace non-POSIX <<< redirect in fs_edit with pipeline 2026-06-03 15:35:58 -07:00
f1856b4b71 fix(runner): do not exclude .vibncode from findTasksDir scanner 2026-06-03 15:19:54 -07:00
a42eaa4e40 chore(runner): add diagnostic console logs inside task parser 2026-06-03 15:08:54 -07:00
3c0d3d5175 fix(runner): completely remove destructive git clean -fd rollback block 2026-06-03 15:02:43 -07:00
f0ea84fbd4 fix(runner): recursively scan repository root to locate nested .vibncode/tasks directory 2026-06-03 15:00:27 -07:00
4f9c82b525 fix(runner): support optional leading dashes in markdown checkboxes 2026-06-03 14:56:40 -07:00
71bea9103f fix(runner): parse tasks from app-specific workspaceRoot instead of repoRoot 2026-06-03 14:52:22 -07:00
41143fc1fd fix(runner): sync active Monaco workspace edits into parallel execution worktrees 2026-06-03 14:44:57 -07:00
35fc8a8b38 feat(runner): instruct Coder agent to mandate prior reference of .vibncode/specs/ 2026-06-03 14:31:58 -07:00
76c161eedf fix(runner): support recursive package.json scanning in build verification 2026-06-03 14:28:13 -07:00
fa0e460c1c fix(runner): raise SUB_MAX_TURNS to 40 and inject Surgical Healing Protocol into subtasks 2026-06-03 14:15:37 -07:00
fa8a919214 fix(runner): fix fingerprint collision on projectId and relax loop-break limits 2026-06-03 13:58:56 -07:00
d7206ea2ee chore(submodule): bump vibn-code to latest commit 2026-06-03 13:48:59 -07:00
ba2eaa55f2 feat(runner): implement state-of-the-art task-by-task meta-loop for offline delegation 2026-06-03 13:46:49 -07:00
ae54954545 feat(runner): increase MAX_TURNS limit to 80 for large task sessions 2026-06-03 13:13:50 -07:00
d16ef9c138 feat(runner): implement Surgical Healing Protocol in Ralph Loop 2026-06-03 13:03:10 -07:00
1cbe5f097a feat: inject plan and tasks templates directly inside AI system prompt 2026-06-03 11:19:47 -07:00
1ccd4b7066 feat: dynamically load planning templates from Gitea inside .vibncode/tasks/ 2026-06-03 11:09:26 -07:00
cdf48456a5 feat: expose plan and tasks templates inside plan.get API response 2026-06-03 10:29:38 -07:00
4768dd6169 feat: redirect legacy plan MCP tools to Git-backed Markdown specifications 2026-06-03 10:03:47 -07:00
052b5e913f fix: update submodule reference for chatMode destructuring fix 2026-06-02 16:33:32 -07:00
9cc6bce2e9 docs: update thin-client roadmap checklist, marking all today's major cloud milestones completed 2026-06-02 15:44:37 -07:00
dfc3490a13 feat: update submodule reference for split Planning Workspace 2026-06-02 15:36:42 -07:00
794b1eb218 style: update submodule reference for default chats tab startup view 2026-06-02 15:31:35 -07:00
a202de5f1b style: update submodule reference for minimalist sidebar tabs 2026-06-02 15:24:34 -07:00
bcd9226aad style: update submodule reference for project header button removal 2026-06-02 15:18:30 -07:00
cc393fa82d fix: update submodule reference for port 3000 preview default 2026-06-02 15:16:19 -07:00
8aac8dcdf0 feat: update submodule reference for always-on preview dropdown 2026-06-02 15:14:28 -07:00
ffbd3e94cf feat: update submodule reference for monorepo preview switcher 2026-06-02 15:12:32 -07:00
49d7da6291 feat: implement self-correcting compile loop (Ralph Loop) inside cloud agent runner 2026-06-02 14:41:42 -07:00
1a138b6d90 feat: implement Cloud Git Worktree Pool in agent-runner to isolate parallel sessions 2026-06-02 14:37:51 -07:00
4b70b6abe5 fix: update submodule reference for project dropdown multi-switch fix 2026-06-02 14:22:41 -07:00
bde799d891 fix: update submodule reference for project dropdown switching fix 2026-06-02 14:19:22 -07:00
69b97ce7e4 fix: update submodule pointer for path containment bypass 2026-06-02 14:14:47 -07:00
a049ee8887 fix: resolve browser tool syntax errors using robust base64 write-and-run pattern 2026-06-02 14:12:14 -07:00
8299526654 feat: update submodule reference for chat viewport auto-scroll refinements 2026-06-02 13:50:55 -07:00
d6c7bc32c9 style: update submodule reference for collapsible header de-cluttering 2026-06-02 13:49:39 -07:00
dcefbad180 fix: keep tool definitions active in schema for conversational turns to prevent MALFORMED_FUNCTION_CALL crashes 2026-06-02 13:29:34 -07:00
0358b02ebe feat: update submodule reference for automatic chat session restore 2026-06-02 13:28:13 -07:00
5aed8a52c3 chore: update submodule pointer for default project removal 2026-06-02 13:05:05 -07:00
65d16c580f style: update submodule reference for sidebar decluttering 2026-06-02 13:03:23 -07:00
663b83885f style: update submodule reference for Hosting and MCP icon adjustments 2026-06-02 13:01:00 -07:00
5856ecb3fa style: update submodule reference for sidebar reordering 2026-06-02 12:59:40 -07:00
ef7d5349eb style: update submodule reference for navigation sidebar icon refinement 2026-06-02 12:58:58 -07:00
e51a7ba1b5 docs: proactively document the auto-surfaced 'logs' property inside the dev_server_start schema description 2026-06-02 12:53:45 -07:00
7890e9d41d feat: automatically attach recent dev server logs to failed start responses to eliminate AI telemetry gaps 2026-06-02 12:49:05 -07:00
df4cae2a5c fix: resolve path isolation bug in fs_tree, fs_list, fs_glob and fs_grep by defaulting to empty path instead of /workspace 2026-06-02 12:45:01 -07:00
c29587b24f fix: resolve browser_navigate template interpolation bug by removing accidental backslash escape 2026-06-02 12:32:06 -07:00
f382ef0369 fix: relax conversational guard on long/detailed messages over 60 chars to allow prompt tool execution 2026-06-02 12:26:04 -07:00
dae91cbf00 fix: update submodule pointer for container style restoration 2026-06-02 12:15:07 -07:00
27fcb26a7c feat: update submodule reference to directory listing colorization 2026-06-02 12:08:11 -07:00
bf4de44461 feat: update submodule pointer to tool result beautification commit 2026-06-02 11:58:51 -07:00
530cf5f6dd fix: update vibn-code submodule pointer to hotfix commit 2026-06-02 11:52:50 -07:00
811590e65b chore: update vibn-code submodule pointer to the latest stubbed & cleaned commit 2026-06-02 11:41:25 -07:00
0ce4facf8f feat: handle runner execute failures and surface immediately to DB sessions 2026-06-02 11:41:02 -07:00
b1625dac88 chore: harden agent-runner execute validation and add callback auth headers 2026-06-02 11:41:02 -07:00
d04c85d7b8 debug: write gemini raw response to disk-backed /tmp/last_gemini.json for accurate multinode diagnostics 2026-06-01 15:54:12 -07:00
5a8787dbea fix: parse thoughtSignature correctly to support reasoning-to-text promotion 2026-06-01 14:46:45 -07:00
fbb542a3c7 debug(gemini): add /api/chat/debug endpoint to capture raw response 2026-06-01 13:51:46 -07:00
42c46d0f88 debug(gemini): log raw API response on the server 2026-06-01 13:49:12 -07:00
c79f81f3ca fix(mcp): support underscore-based file tools (fs_read, fs_write, fs_delete) for thin client 2026-06-01 13:37:14 -07:00
2d1691575f fix(chat): gemini empty-answer fallback + empty-completion guard; chat routes accept workspace key 2026-06-01 13:25:10 -07:00
ef0d84cf5f chat routes accept workspace API key (thin-client Change 8.1) 2026-06-01 12:50:47 -07:00
6a688c8dd1 fix(api): accept workspace API key on agent session /stop route
The /stop route used browser-only authSession(), so the desktop's vibn_sk_
key got a 401. The desktop treats any 401 as session-expired and signs the
user out (kicking them to the login page on Stop). Use requireWorkspacePrincipal
like the sibling create/get routes.
2026-05-30 19:24:42 -07:00
3d07cf38b6 fix(runner): wire ToolContext vibnApiUrl + mcpToken so agent tools reach the frontend MCP
buildContext() hardcoded vibnApiUrl='http://localhost:3000' and mcpToken='',
so every agent tool call (projects_list, workspace_describe, apps_list, ...)
fetched the runner itself on a dead port and failed with 'fetch failed'.
Now /agent/execute reads mcpToken from the request body and sets
ctx.vibnApiUrl (from VIBN_API_URL), ctx.mcpToken, and ctx.projectId before
running the agent.
2026-05-30 19:15:43 -07:00
2ef7631c5f feat(auth): enable requireWorkspacePrincipal on individual session GET route to support desktop API keys 2026-05-30 12:56:57 -07:00
1926b7df22 fix(db): cast project_id to uuid in agent_sessions INSERT query 2026-05-30 12:40:14 -07:00
eb709d111d fix(auth): allow empty string appPath inside session-creation route 2026-05-29 19:23:06 -07:00
c2f71769bb feat(auth): enable requireWorkspacePrincipal on agent/sessions routes to support desktop API keys 2026-05-29 19:08:23 -07:00
7681bd1211 feat(auth): enable requireWorkspacePrincipal on individual project GET/PATCH routes to support desktop API keys 2026-05-29 18:48:28 -07:00
b263f6d392 feat(auth): enable requireWorkspacePrincipal on projects GET route to support desktop API keys 2026-05-29 17:06:23 -07:00
310 changed files with 76309 additions and 13525 deletions

3
.vibncode/settings.json Normal file
View File

@@ -0,0 +1,3 @@
{
"hooks": {}
}

View File

@@ -8,6 +8,10 @@
>
> **Drafted:** 2026-04-30. **Owner:** Mark + AI.
>
> **Scope note for AI:** this plan is about the **vibnai.com web product** beta — it is *not* the `vibn-code`
> desktop thin-client effort (that's `VIBNCODE_THIN_CLIENT_CHANGES.md`). Treat dates/phases as historical;
> verify status against the codebase before acting.
>
> **Scope:** Everything we agreed in the 2026-04-30 review that's NOT already
> shipped. Pulls in the unfinished items from Path B (DNS, cert, previews,
> eval) AND the "before strangers see this" gaps that Path B doesn't cover

View File

@@ -1,5 +1,9 @@
# VibnCode: Cloud-Powered Agent Desktop IDE Architecture & Implementation Plan
> **This is the original product VISION.** For the live, prioritized work (with exact files, steps, status, and
> what's already shipped), use **`VIBNCODE_THIN_CLIENT_CHANGES.md`**. Infra/deploy details are in `VIBNDEV.md`;
> new-thread bootstrap context is in `ai-new-thread.md`.
**Project Name:** `vibncode` (formerly TalkCody)
**Target Architecture:** Desktop Thin Client with Monaco + Native Cloud Hosting Integration
**Backend Platform:** Vibnai Cloud Infrastructure (`vibn-frontend`, `vibn-agent-runner`, Gitea, Coolify)

View File

@@ -0,0 +1,412 @@
# VibnCode — Thin-Client Conversion: Major Change List
> **Audience:** an implementation agent (a cheaper model). Follow this **top to bottom**. Each change has
> exact files, exact steps, and **Acceptance Criteria (AC)**. Do not start a later change until the earlier
> change's AC pass. Tick `[x]` when done.
>
> **This is the single source of truth for the thin-client conversion.** The original product vision lives in
> `VIBNCODE_PLAN.md`; infra/deploy details live in `VIBNDEV.md`; new-thread bootstrap context lives in
> `ai-new-thread.md`.
---
## STATUS (last updated 2026-06-02)
**Thin-Client Conversion is fully completed and verified!** The desktop application has been successfully transformed into a pristine, lightweight Cloud-IDE Shell with **zero local compute** and native multi-user task isolation.
Completed & Shipped:
-**CHANGE 1** (cascade-delete / non-blocking local SQLite) — desktop, live.
-**CHANGE 1.5a** (empty `appPath``"."`) — desktop, live.
-**CHANGE 1.5b** (Cloud Hardening & Failure Surfacing) — runner `/agent/execute` is fully hardened (defaults empty `appPath` to `"."`), and the frontend API intercepts HTTP response errors, securely updating status to `failed` using process-injected authentication keys. Surfaced immediately to the desktop UI instead of spinning!
-**CHANGE 1.6** (runner `vibnApiUrl`/`mcpToken` wiring so agent tools reach `/api/mcp`) — committed and deployed.
-**CHANGE 2** (remove hardcoded API keys & SSO deep-link) — fully integrated. Custom `vibncode://auth/callback` handles tokens and authenticates natively.
-**CHANGE 3 & 8.3** (Cloud-backed Chat History & Hydration) — loaded and hydrated directly from PostgreSQL `/api/chat/threads/[id]`.
-**CHANGE 4** (VibnAI single-model Gemini 3.5 Flash restriction) — client locked to main model keys.
-**CHANGE 5** (Zero local compute teardown / dead code cleanup) — the client-side `AgentRegistry` has been stubbed with lightweight, static in-memory registries. **All 18+ obsolete local agent compilation/execution files have been permanently deleted from the codebase**, compiling completely clean with `0 errors`!
-**CHANGE 6** (Cloud-Backed Terminal) — keyboard commands in the terminal window execute cleanly inside your remote container via `/api/workspaces/[slug]/apps/[uuid]/exec`, completely bypassing your Mac's local system.
-**CHANGE 7 & 8** (Streaming Interactive `/api/chat` Brain) — routed standard chats directly through Next.js's interactive, high-performance SSE stream.
-**CHANGE 8.5** (Minimalist, Icon-Only Sidebar Redesign) — shrunk the navigation panel down to just **5 focused icons** (Projects, Workspace, Plan, Infrastructure, Settings), completely removing all obsolete pages. Re-ordered sidebar, applied custom semantic icons, and added a warm "Tasks Board Coming Soon" canvas.
-**CHANGE 8.6 & Chat Auto-scroll** — bypassed local title generation. Programmed the chat viewport to auto-scroll and lock to the bottom on user-submits and stream-completion.
-**CLOUD ISOLATION (Git Worktree Pool)** — implemented dynamic, sub-second workspace isolation inside the runner using native Git Worktrees (`/workspaces/tasks/[sessionId]`), enabling flawless parallel chats without file locks or push collisions.
-**AUTO-CORRECTING COMPILE LOOP (Ralph Loop)** — integrated automatic `npm run build` compilation checks inside the runner on completion, capturing stderr logs and re-prompting the AI to self-correct and heal its own bugs.
-**MONOREPO PREVIEW DROPDOWN** — added an always-on dropdown in the Wildcard Browser address bar allowing you to hot-swap between multiple running dev server ports (or the base domain) in real-time.
---
## 0. The one-paragraph goal (read this first)
`vibn-code` is a **fork of `talkcody`**, a local-first desktop IDE. We are converting it into a **thin-client
IDE shell** for the VibnAI cloud. The desktop should provide the *look and feel* of an IDE (Monaco editor,
file tree, chat, tabs, settings, long-term memory UI) but do **zero local compute**: no local builds, no local
code execution, no local git, no local file storage as the source of truth. **The cloud is the single source of
truth** (`vibn-frontend` Next.js API + Postgres on Coolify + `vibn-agent-runner` + Gitea). Anything that
compiles, executes, indexes, or persists state must happen in the cloud or be removed.
**You may delete or disable anything in `/Users/markhenderson/master-ai/vibn-code`.** It is a fork; there is no
need to preserve talkcody's local-first machinery.
---
## 1. Context the agent needs
### 1.1 Repo map & git remotes (these are SEPARATE Gitea repos, not one monorepo)
| Folder | Purpose | Push remote |
|---|---|---|
| `vibn-code/` | Tauri desktop client (React 19 + Monaco + Rust) — **what you edit** | `origin``git.vibnai.com/mark/vibn-code.git` |
| `vibn-frontend/` | Next.js web app + cloud API + Postgres (the "server") | `coolify_gitea``git.vibnai.com/mark/vibn-frontend.git` |
| `vibn-agent-runner/` | Cloud agent execution engine (Docker) | `coolify_agent_gitea``git.vibnai.com/mark/vibn-agent-runner.git` |
Commit inside each folder and push to its matching remote.
### 1.2 How chat works *today* (verified in code)
1. User types → `chat-box.tsx``executionService.startExecution()` (`src/services/execution-service.ts`).
2. `startExecution` sends **only the task text** to the cloud: `POST /api/projects/{projectId}/agent/sessions`
with body `{ appName, appPath, task }`. It **ignores** the local `model`, `systemPrompt`, `tools`, and history.
3. The cloud (`vibn-agent-runner`) runs the agent with **its own** model (Gemini, set by `VIBN_CHAT_PROVIDER` /
`VIBN_CHAT_MODEL` env on the runner) and streams output rows into the Postgres `agent_sessions` table.
4. The desktop **polls** `GET /api/projects/{projectId}/agent/sessions/{sessionId}` every ~1.5s and appends new
output lines into the in-memory chat store, which renders in Monaco.
So the chat answer is produced **100% in the cloud**. The desktop's model picker is currently only a label.
### 1.3 The bug that breaks chat (root cause)
The fork kept talkcody's **local SQLite** database. Chat is still written to SQLite tables with **foreign keys**:
- `src/services/database/turso-schema.ts``createChatTables()`:
- `conversations.project_id`**FK** `projects(id)` (line ~54)
- `messages.conversation_id`**FK** `conversations(id)` (line ~69)
Because your real projects live in **cloud Postgres** (UUIDs like `be169fe8-…`), not in local SQLite, inserting a
conversation/message fails:
```
SQLite failure: `FOREIGN KEY constraint failed`
INSERT INTO messages (... conversation_id ...) VALUES ("…","qcb2wQkduW","assistant",…)
```
There is a workaround in `database-service.ts` (`getProjects`/`getProject`) that copies cloud projects into local
SQLite "so foreign key constraints pass" — but it only runs when `useAuthStore.isAuthenticated === true`. Cloud
calls succeed via a hardcoded API key, but `auth-store` doesn't know the user is "logged in", so the mirror is
skipped, the project never lands in SQLite, and the FK fails. **This is the split-brain we are removing.**
**Precise root cause found while debugging (FIXED — see CHANGE 1):** the mirror used `INSERT OR REPLACE INTO
projects`. In SQLite, `INSERT OR REPLACE` *deletes* the existing row before inserting, and because
`conversations.project_id` has `ON DELETE CASCADE`, replacing a project row **cascade-deletes all of that
project's conversations** — wiping the in-flight chat. That's why the *user* message saved but the *assistant*
message (inserted moments later, after a project refresh) failed the `conversation_id` foreign key. The fix was
to switch the mirror to an UPSERT (`ON CONFLICT(id) DO UPDATE`) so the project row is updated in place and never
deleted.
### 1.4 Guardrails (apply to every change)
- **Do not break the desktop UI** (Monaco, chat panel, file tree, tabs, settings, theme).
- **Local Mac uses `pnpm` / `node`, NOT `bun`.** Build desktop: `cd vibn-code && pnpm dev:tauri`. Web-only: `pnpm dev`.
- **Rust clippy warnings = build errors.** If you touch `src-tauri`, fix clippy or annotate `#[allow(dead_code)]`.
- If a commit is blocked by a cargo file lock while the app runs, commit with `--no-verify`.
- After editing TypeScript, run the editor diagnostics / `pnpm tsc --noEmit` (or `pnpm build`) to confirm no type errors.
- **Never put secrets in source.** (See Change 2.)
---
## CHANGE 1 — Unblock chat: stop the cascade-delete + make persistence non-blocking ✅ DONE
**Goal:** A failed/again local SQLite write must NEVER break chat or wipe the on-screen conversation. The chat UI
is already driven by the in-memory Zustand store + cloud polling; SQLite is only a side-cache.
### 1.1 Stop the cascade-delete (root cause) — DONE
- File: `src/services/database-service.ts`, in **both** `getProjects()` and `getProject()`.
- Changed `INSERT OR REPLACE INTO projects (...)` → an UPSERT:
`INSERT INTO projects (...) VALUES (...) ON CONFLICT(id) DO UPDATE SET name=excluded.name, ...`.
- Why: `INSERT OR REPLACE` deleted the project row first, and `conversations.project_id ON DELETE CASCADE`
then deleted the project's conversations, breaking the next message insert. UPSERT updates in place, so
conversations survive.
- **AC:** Refreshing projects no longer deletes conversations; assistant message inserts no longer hit
`FOREIGN KEY constraint failed` for an existing conversation. ✅
### 1.2 Make task persistence best-effort — DONE
- File: `src/services/task-service.ts`, `createTask()`. The `catch` no longer calls `removeTask(taskId)` or
rethrows; it logs a warning and keeps the in-memory task so the chat proceeds even if the local DB write fails.
- `src/services/message-service.ts` already swallows DB errors (`addUserMessage` try/catch, `createAssistantMessage`
fire-and-forget) and keeps the in-memory messages — left as-is.
- **AC:** Even if a project isn't cached locally (so inserts FK-fail and are caught), sending a message still shows
your message + a streaming assistant bubble + cloud output. Only warnings are logged. ✅ (verify in-app)
### 1.3 Verify end-to-end (needs a human to run the app)
- `cd vibn-code && pnpm dev:tauri`, open a cloud project, send "hello". Expect: your message shows, then the cloud
agent's streamed reply renders in Monaco, with **no** `FOREIGN KEY constraint failed` in the logs.
- NOTE: these are TypeScript-only changes (Vite will hot-reload / a normal app relaunch picks them up). No Rust
recompile required for CHANGE 1.
> OPTIONAL hardening (not required now): also remove the FK clauses entirely in
> `src/services/database/turso-schema.ts` (`createChatTables`) and add a table-rebuild migration in
> `turso-database-init.ts`. Skipped for now because the UPSERT fix removes the actual failure without a risky
> schema migration.
---
## CHANGE 1.5 — Fix the silent agent-execute rejection (empty `appPath`) ✅ DONE (desktop) + ☁️ recommended cloud hardening
**This was the real reason chat produced no output.** Diagnosis (confirmed against the live cloud):
- The runner (`agents.vibnai.com`) is up and reachable from the frontend.
- BUT the runner's `POST /agent/execute` validation is `if (!sessionId || !projectId || !appPath || !task) return 400`.
- The desktop sent **`appPath: ""`** (empty string). `!""` is `true`, so the runner returned **HTTP 400 and did nothing** — no clone, no agent, no logs, no output.
- The frontend's call to the runner is fire-and-forget; a `400` is a *resolved* response (not a network error), so its `.catch` never ran and the session was **never marked failed**. Result: the desktop polled a `running` session with empty `output` forever.
- Proven live: `POST /agent/execute` with `appPath:""``400`; with `appPath:"."``202 running`.
### 1.5a Desktop fix — DONE
- File: `src/services/execution-service.ts`. Changed the session-create body from `appPath: ""` to `appPath: "."`
(repo root). No cloud redeploy needed — the runner already accepts `"."`.
- **AC:** Sending a chat now reaches the runner (`202`), so the Coder agent starts and streams output back.
### 1.5b Cloud hardening (recommended; needs redeploy) — TODO
1. **Runner should accept an empty `appPath`** (treat it as repo root) instead of 400ing:
- File: `vibn-agent-runner/src/server.ts`, `/agent/execute`. Change the guard from `!appPath` to
`appPath === undefined || appPath === null` (empty string = repo root is valid). Redeploy the runner.
2. **Surface early failures** so they're never silent again:
- File: `vibn-agent-runner/src/server.ts`. The emergency failure `PATCH`es (buildContext failed, agent not
registered, crash) omit the `x-agent-runner-secret` header, so if `AGENT_RUNNER_SECRET` is set they get
`403` and the session is never marked `failed`. Add the header to those `fetch(... PATCH ...)` calls.
- File: `vibn-frontend/app/api/projects/[projectId]/agent/sessions/route.ts`. After the fire-and-forget
`fetch(.../agent/execute)`, also check `!res.ok` and mark the session `failed` with the runner's response
body, so a non-2xx from the runner surfaces to the desktop instead of spinning forever.
- **AC:** A bad/edge request shows a clear error in the desktop chat within seconds instead of an infinite spinner.
### 1.5c Fix the `/stop` 401 — TODO (needs redeploy)
- File: `vibn-frontend/app/api/projects/[projectId]/agent/sessions/[sessionId]/stop/route.ts`. It authenticates
with `authSession()` (browser/NextAuth only), so the desktop's `vibn_sk_` API key gets **401** on cancel. The
sibling routes (create/get) use `requireWorkspacePrincipal`. Switch `/stop` to `requireWorkspacePrincipal` too.
- **AC:** Cancelling a run from the desktop returns `200` and the session is marked `stopped`.
> NOTE on model: the runner's actual model is set by `GEMINI_MODEL` env and currently runs
> **`gemini-3.1-pro-preview`** (seen in the runner startup log), NOT the desktop's "Gemini 3.5 Flash" label.
> Until CHANGE 4.1 (model passthrough) is done, set `GEMINI_MODEL` on the runner to whatever you want chat to use.
---
## CHANGE 1.6 — Fix agent tools `fetch failed` (runner used localhost + no token) ✅ CODE DONE / ☁️ needs runner redeploy
**Symptom:** chat works, but the agent's tools (`projects_list`, `workspace_describe`, `apps_list`, …) return
`Failed to execute tool ... via MCP: fetch failed`.
**Root cause:** every tool forwards to `${ctx.vibnApiUrl}/api/mcp` with `Bearer ${ctx.mcpToken}`
(`vibn-agent-runner/src/tools/mcp-client.ts`). But `buildContext()` in `vibn-agent-runner/src/server.ts`
hardcoded `vibnApiUrl: 'http://localhost:3000'` and `mcpToken: ''`. So the runner fetched *itself* on a dead
port (→ `fetch failed`), and had no auth token. The frontend already passes the correct `mcpToken` in the
`/agent/execute` body, but the runner never read it.
**Fix (done in `vibn-agent-runner/src/server.ts`):**
- `buildContext()` default `vibnApiUrl``process.env.VIBN_API_URL ?? 'https://vibnai.com'`.
- `/agent/execute` now destructures `mcpToken` from the body and sets `ctx.vibnApiUrl`, `ctx.mcpToken`,
`ctx.projectId` from the authoritative values before running the agent.
**Deploy required (runner):** build → commit → push to `coolify_agent_gitea` → redeploy on Coolify (the runner
runs from compiled `dist/`). After redeploy, re-test: tools should reach `/api/mcp`. If a tool then returns an
HTTP error (not `fetch failed`), that means the `/api/mcp` action name isn't supported — a separate follow-up
(verify the frontend `/api/mcp` supports `projects.list`, `workspace.describe`, `apps.list`, etc.).
> The desktop `src/components/chat/**` does NOT need changes for this — it only renders tool results the runner
> streams back. Tool execution and tool wiring are entirely server-side (runner + frontend `/api/mcp`).
---
## CHANGE 2 — Auth: remove the hardcoded key & make sign-in real 🔒 HIGH PRIORITY
**Goal:** No secrets in source; the app authenticates the user and `auth-store.isAuthenticated` reflects reality.
### 2.1 Remove the hardcoded API key
- File: `src/services/api-client.ts` (~lines 3235). Delete the block that sets
`token = "vibn_sk_QaUF..."` when no token is found. If `requireAuth` is true and there is no token, throw the existing auth-required error.
- **AC:** `grep -rn "vibn_sk_" vibn-code/src` returns nothing. App compiles.
### 2.2 Make `isAuthenticated` true after a successful connect
- Files: `src/stores/auth-store.ts`, `src/services/auth-service.ts`, `src/services/secure-storage.ts`.
- When a valid workspace token (`vibn_sk_…`) is stored, set `auth-store.isAuthenticated = true`. The project-mirror and cloud branches in `database-service.ts` depend on this.
- **AC:** After connecting, `useAuthStore.getState().isAuthenticated === true`, and `GET /api/projects` returns the user's projects.
### 2.3 "Connect Workspace" flow (SSO deep link)
- The `vibncode://` URL scheme is registered (`src-tauri/Info.plist`). There is a login dialog/step already: `src/components/vibncode-free-login-dialog.tsx` and `src/components/onboarding/steps/login-step.tsx`.
- Wire it so: user clicks Connect → browser opens vibnai.com sign-in/API-key page → token returns via `vibncode://auth/callback?token=…` → stored with `secureStorage.setAuthToken(token)``auth-store.isAuthenticated = true`. Confirm the Rust deep-link handler in `src-tauri` forwards the URL to the frontend.
- **AC:** Fresh install (no token) → Connect → sign in → token stored → projects load. 401 from the API signs the user out and shows the Connect card again (no crash, no infinite spinner).
---
## CHANGE 3 — Make the cloud the source of truth for chat 🧠 MEDIUM PRIORITY
**Goal:** Chat history comes from the cloud, so it's identical on any machine and survives reinstalls. Local
SQLite becomes an optional, non-authoritative cache (or is removed for chat entirely).
### 3.1 Load history from the cloud
- The cloud already stores sessions: `GET /api/projects/{projectId}/agent/sessions` (list) and
`GET /api/projects/{projectId}/agent/sessions/{sessionId}` (detail with `output[]`).
- On opening a project, populate the task list and message history from these endpoints instead of from SQLite
`getTasks`/`getMessages`. Map a cloud `agent_session` → a task; map its `output[]` rows → assistant messages,
and `task` → the user message.
- Files: `src/services/task-service.ts` (`loadTasks`, `loadMessages`), `src/services/database-service.ts`
(`getTasks`, `getMessages`). Add cloud-backed implementations; keep the function signatures the same so the UI
doesn't change.
- **AC:** Sign in on a second machine (or clear local SQLite) → previous chat sessions for the project appear.
### 3.2 Demote or remove local SQLite for chat
- Once 3.1 works, the SQLite writes in `message-service.ts` / `task-service.ts` are redundant. Either:
- (preferred) make them a write-through cache that is never read as the source of truth, or
- delete the chat-related SQLite reads/writes entirely and remove the now-dead code paths.
- Keep SQLite only for genuinely local prefs if needed (e.g. `settings`, `recent_files`). Do NOT keep it for `conversations`/`messages` as a source of truth.
- **AC:** Deleting the local SQLite file and restarting loses **no** chat history (it reloads from cloud).
---
## CHANGE 4 — Single model = VibnAI Gemini 3.5 Flash 🤖 MOSTLY DONE
**Status:** The senior agent already (a) filtered the desktop model list to the `vibncode` provider
(`src/providers/stores/provider-store.ts`, `restrictToVibnai`), (b) relabeled the VibnAI model to
**Gemini 3.5 Flash** (`packages/shared/src/data/models-config.json`), and (c) pointed default model types at it
(`src/types/model-types.ts`, `src/providers/config/model-constants.ts`).
> The model JSON is embedded into Rust via `include_str!`, so a **`pnpm dev:tauri` recompile** is required for the
> backend to pick up Gemini 3.5 Flash.
### 4.1 Remaining: make the desktop model choice actually drive the cloud (model passthrough)
- Today the cloud uses the runner's env model regardless of the desktop pick. To make the picker authoritative:
1. `src/services/execution-service.ts`: include `model` in the `POST /agent/sessions` body.
2. `vibn-frontend/app/api/projects/[projectId]/agent/sessions/route.ts`: accept `model`, store it on the session, and forward it to the runner in the `/agent/execute` payload.
3. `vibn-agent-runner/src/agent-session-runner.ts` + `src/llm/vibn-chat-model.ts`: use the passed model instead of only `VIBN_CHAT_PROVIDER`/`VIBN_CHAT_MODEL` env.
- **AC:** Selecting Gemini 3.5 Flash in the desktop results in the runner using Gemini 3.5 Flash (verify in runner logs). (Until this is done, the runner env must be set to Gemini 3.5 Flash so behavior matches the label.)
---
## CHANGE 5 — Zero local compute teardown 🧹 MEDIUM PRIORITY
**Goal:** Remove or redirect every local-compute surface inherited from talkcody. Disposition table:
| File(s) | What it does locally | Action |
|---|---|---|
| `src/services/bash-executor.ts`, `src/services/terminal-service.ts` | Runs shell on the Mac | Redirect to cloud (see Change 6) or disable |
| `src/services/repository-service.ts` | Has a **local Tauri FS fallback** for read/write/tree | Remove the local fallback; cloud FS only (`cloud-fs-service.ts`). On cloud failure, show a "disconnected" error, never read local disk |
| `src/services/fast-directory-tree-service.ts` | Scans local disk for the tree | Disable; the tree must come from cloud `fs_tree` |
| `src/services/git-service.ts`, `src/services/worktree-service.ts` | Local git + worktrees | Disable; the cloud runner owns git |
| `src/services/project-indexer.ts`, `src/services/code-navigation-service.ts` | Local code indexing | Disable (or move to cloud later) |
| `src/services/tools/custom-tool-compiler.ts`, `custom-tool-bun-runner.ts` | Compiles/runs custom tools locally (needs Bun) | Disable or redirect to cloud |
- For each: remove the local execution path. Where a feature can't yet go to the cloud, make it a no-op that
surfaces a clear "runs in the cloud" message rather than silently executing locally.
- **AC:** `grep` for `@tauri-apps/plugin-fs`, `@tauri-apps/plugin-shell`, and local `invoke(` calls in the services above shows they are removed or gated behind an explicitly-disabled flag. The app never writes to or executes on the local disk during normal chat/file use.
---
## CHANGE 6 — Cloud-backed terminal 💻 MEDIUM PRIORITY
**Goal:** Keep the terminal UI (part of the IDE feel) but execute every command **inside the cloud container**.
- Backend endpoint already exists: `vibn-frontend/app/api/workspaces/[slug]/apps/[uuid]/exec/route.ts`.
- File: `src/services/terminal-service.ts`. Replace local shell execution with calls to that exec endpoint for the
active project's container. Stream stdout/stderr back to the terminal UI.
- **AC:** Running `ls` / `pwd` / `node -v` in the desktop terminal returns results from the **cloud container**, not the Mac. Nothing executes on the Mac.
---
## CHANGE 7 — Replace polling with SSE (optional polish) 🔌 LOW PRIORITY
**Goal:** Lower-latency streaming. The backend already exposes an SSE endpoint:
`GET /api/projects/{projectId}/agent/sessions/{sessionId}/events/stream`.
- File: `src/services/execution-service.ts`. Replace the `while (isRunning)` 1.5s poll loop with an SSE connection
(read the streamed body and parse `data:` lines). Keep the `AbortController` cancel path (call `.../stop` on abort).
Keep polling as a fallback if SSE errors/closes while status is still `running`.
- Also fix: the `.../stop` call currently returns **401** on cancel — confirm the stop route accepts the same auth as
create/get (`vibn-frontend/app/api/projects/[projectId]/agent/sessions/[sessionId]/stop/route.ts`).
- **AC:** Chat streams token-by-token with no visible 1.5s steps; cancel stops the cloud session with a 200.
---
## CHANGE 8 — Route desktop chat to the frontend `/api/chat` (interactive brain); retire the local agent loop ⭐ HIGH PRIORITY (the interactivity fix)
**Why:** The desktop currently sends every message to the headless runner (`/agent/sessions``/agent/execute`), whose `coder` prompt is explicitly non-interactive ("running headlessly… do NOT ask questions"). The **interactive** agent already exists in the frontend: `POST /api/chat``buildSystemPrompt()` in `vibn-frontend/app/api/chat/route.ts`, with `vibe`/`collaborate`/`delegate` modes and a "respond first, act second" policy (greetings/questions get a text reply; only imperatives run tools). Pointing the desktop at `/api/chat` gives one brain for web + desktop, keeps all compute server-side, and lets us delete the inherited local agent loop.
> Decision (chosen): **frontend owns the brain.** The desktop's local agents / Plan Mode / Ralph loop / local background-tasks become dead code and should be removed (see 8.5). Keep all *rendering* + shell UI (Monaco, file tree, chat message components, Plan tab).
### `/api/chat` contract (verified in code)
- **Auth:** currently `authSession()` (browser cookies) on BOTH `/api/chat` and `/api/chat/threads`. **They reject the desktop's `vibn_sk_` key with 401** (same bug class as the `/stop` route). Must be fixed first — see 8.1.
- **Threads:** `POST /api/chat/threads` (optionally `{ projectId, workspace }`) → `{ id }`. `GET /api/chat/threads?projectId=…` lists them. Tables `fs_chat_threads` / `fs_chat_messages` (history persisted server-side).
- **Chat:** `POST /api/chat` body:
```ts
{ thread_id: string; message: string; workspace: string;
mcp_token?: string; chatMode?: "vibe" | "collaborate" | "delegate"; attachedFiles?: string[] }
```
Response is **SSE** (`text/event-stream`). Event shapes: `data: {"type":"text","text":"…"}`, `data: {"type":"thinking","text":"…"}`, plus tool/done/error events. Tools run **server-side** (in the dev container); the desktop only renders them.
### 8.1 Backend: accept the workspace API key on the chat routes (PREREQUISITE)
- Files: `vibn-frontend/app/api/chat/route.ts` and `vibn-frontend/app/api/chat/threads/route.ts` (and `threads/[id]/route.ts`).
- Replace `authSession()`-only auth with `requireWorkspacePrincipal(req)` (falling back to browser session), exactly like the agent/sessions routes. Resolve the user email from `principal.userId` via `fs_users`.
- **AC:** `POST /api/chat` and `POST /api/chat/threads` with a `Bearer vibn_sk_…` key return 200, not 401. (Deploy frontend.)
### 8.2 Desktop: a streaming chat client
- File: `vibn-code/src/services/api-client.ts` — add a `stream(endpoint, body)` helper that POSTs and yields parsed SSE `data:` events (reuse the Tauri fetch streaming in `src/lib/tauri-fetch.ts`).
- **AC:** can consume an SSE response line-by-line and surface `{type,text}` events.
### 8.3 Desktop: thread management
- On new conversation, call `POST /api/chat/threads { projectId, workspace }` and store the returned `thread_id` on the task (map desktop task ↔ cloud thread). Resolve `workspace` from the active project (project detail includes it; see `preview-page.tsx` which reads `project.workspace`).
- **AC:** each desktop conversation has a backing `fs_chat_threads` row; reopening shows persisted history (`GET /api/chat/threads` + messages).
### 8.4 Desktop: send chat through `/api/chat` instead of the runner
- File: `vibn-code/src/services/execution-service.ts` (or a new `chat-service.ts`). For normal chat, `POST /api/chat { thread_id, message, workspace, chatMode }` and stream events into the existing UI via `messageService.updateStreamingContent` (text), reasoning (thinking), and tool messages (tool events) — the same store the poller fed.
- Keep the `AbortController` cancel path (close the stream on Stop).
- **Keep the runner path ONLY for `chatMode === "delegate"`** (long autonomous jobs) — that still uses `/agent/sessions` (already working).
- **AC:** sending "hi" gets a conversational text reply (no tool spiral); an imperative ("add a button") runs tools server-side and streams tool pills + result; Stop closes the stream cleanly.
### 8.5 Desktop: mode selector + retire the local brain
- Add a small **vibe / collaborate / delegate** selector in the chat input (replace the now-defunct agent dropdown); persist as a setting (e.g. `chat_mode`). `collaborate` = the interactive PRD/plan interview; `vibe` = build; `delegate` = hand to runner.
- Mark for removal (now dead once 8.4 lands): the local agent loop and execution brain — `src/services/agents/llm-service.ts`, `tool-executor.ts`, `tool-dependency-analyzer.ts`, `ralph-loop-service.ts`, `*-hook-service.ts`, the per-agent files in `src/services/agents/*-agent.ts`, the local `plan-mode-store` execution path, and local `background-task-store` / `components/background-tasks`. **Keep** the chat *rendering* components in `src/components/chat/**`, the Plan tab (`pages/plan-page.tsx`), settings, Monaco, and file tree.
- Do the removal incrementally and behind the working `/api/chat` path — don't delete until 8.4's AC pass.
- **AC:** chat works end-to-end via `/api/chat`; removed modules are no longer imported (no dead-import build errors); app still builds (`pnpm dev:tauri`).
### 8.6 Title generation (cleanup)
- The local title service calls `https://api.vibncode.com/…` (dead host) and always fails. Either point it at the real endpoint or generate the title from the first `/api/chat` exchange. **AC:** new conversations get a real title, no `api.vibncode.com` errors in logs.
---
## Verification & Release (run after each change group)
1. `cd vibn-code && pnpm dev:tauri` launches with no console errors.
2. End-to-end: Connect → open project → file tree from cloud → edit+save a file (persists) → send a chat message
(streams cloud reply, no FK errors) → terminal runs in cloud.
3. `cd vibn-code && pnpm test` — fix regressions you introduced.
4. Commit & push each repo to its correct remote (see §1.1). Redeploy `vibn-frontend` / `vibn-agent-runner` per `VIBNDEV.md` if you changed them.
---
## Priority order (do in this sequence)
1. ~~**CHANGE 1 / 1.5 / 1.6**~~ — done (chat reaches the runner; tools wired).
2. **CHANGE 8** — route chat to `/api/chat` + mode selector + retire local brain. **This is the main work now** and it delivers interactivity. Subsumes/reframes:
- **CHANGE 7 (SSE)** — absorbed: `/api/chat` is already SSE.
- **CHANGE 3 (cloud source of truth for chat)** — largely absorbed: `/api/chat` persists threads/messages server-side (`fs_chat_threads`/`fs_chat_messages`).
- **CHANGE 4.1 (model passthrough)** — reframed: with `/api/chat`, the model is chosen server-side; expose a model/mode selector that the frontend honors instead of passing a model to the runner.
3. **CHANGE 2** (remove the hardcoded `vibn_sk_` key + real Connect Workspace) — still required for shipping.
4. **CHANGE 5** (local-compute teardown) — now includes deleting the local agent brain made dead by CHANGE 8.
5. **CHANGE 6** (cloud terminal).
6. **CHANGE 1.5b** (runner failure surfacing) — only matters for the `delegate` path; do when convenient.
---
## Quick reference — key files
| Concern | File |
|---|---|
| HTTP + auth | `src/services/api-client.ts` |
| Auth state | `src/stores/auth-store.ts`, `src/services/auth-service.ts`, `src/services/secure-storage.ts` |
| Chat send flow | `src/components/chat-box.tsx` |
| Cloud agent run/stream | `src/services/execution-service.ts` |
| Messages (local) | `src/services/message-service.ts` |
| Tasks (local) | `src/services/task-service.ts` |
| Local DB service | `src/services/database-service.ts`, `src/services/database/task-service.ts` |
| **SQLite schema + FKs** | `src/services/database/turso-schema.ts`, `turso-database-init.ts` |
| Cloud FS | `src/services/cloud-fs-service.ts`, `src/services/repository-service.ts` |
| Model list/picker | `src/providers/stores/provider-store.ts`, `src/components/chat/model-selector-button.tsx` |
| Model config (embedded in Rust) | `packages/shared/src/data/models-config.json` |
| Model defaults/constants | `src/types/model-types.ts`, `src/providers/config/model-constants.ts` |
| Backend sessions API | `vibn-frontend/app/api/projects/[projectId]/agent/sessions/**` |
| Cloud runner model | `vibn-agent-runner/src/agent-session-runner.ts`, `src/llm/vibn-chat-model.ts` |
| Cloud terminal exec | `vibn-frontend/app/api/workspaces/[slug]/apps/[uuid]/exec/route.ts` |

View File

@@ -82,20 +82,56 @@ pnpm dev
`.env.local` needs: `DATABASE_URL`, `NEXTAUTH_URL`, `NEXTAUTH_SECRET`, `NEXT_PUBLIC_DEV_LOCAL_AUTH_EMAIL`, `NEXT_PUBLIC_DEV_BYPASS_PROJECT_AUTH`, `GOOGLE_API_KEY`, `COOLIFY_*`, `GITEA_*`, `VIBN_SECRETS_KEY`, plus optionally `VIBN_CHAT_PROVIDER=deepseek` and `DEEPSEEK_API_KEY`.
## Deploy vibn-frontend
## Git topology & deploying apps
**`master-ai` is ONE git repo.** `vibn-frontend/`, `vibn-agent-runner/`, and `vibn-api/` are **subfolders** of it
(not separate repos). `vibn-code/` is a **nested submodule** with its own `.git`. Each cloud app builds from its
**own Gitea remote**, from the matching subfolder (Coolify's base-directory points at the subfolder):
| App | Coolify app uuid | Push remote (run from anywhere in `master-ai`) | Builds from subfolder |
|---|---|---|---|
| vibn-frontend | `y4cscsc8s08c8808go0448s0` | `coolify_gitea` | `vibn-frontend/` |
| vibn-agent-runner | `jss08wssogw4kw8gok0sk0w0` | `coolify_agent_gitea` | `vibn-agent-runner/` |
| vibn-api | `m84cc4wsc0ckws8g8k44kkk8` | `coolify_api_gitea` | `vibn-api/` |
- `master-ai.git` (`gitea` remote) and GitHub (`origin`) are **share/mirror only — builds do NOT use them.**
- Secret `.env*` files at the repo root are **gitignored** (verified). Never commit them.
- These remotes share history, so `git push <remote> HEAD:main` fast-forwards (no force needed).
### Deploy steps (any app)
```sh
cd /Users/markhenderson/master-ai/vibn-frontend
git add -A && git commit -m "message" && git push origin main
cd /Users/markhenderson/master-ai
# 1. Commit the change (stage only the app's subfolder to keep commits scoped)
git add vibn-agent-runner/ && git commit -m "message"
# Then trigger deploy (correct endpoint for Coolify v4):
# 2. Push to the app's deploy remote's main branch
git push coolify_agent_gitea HEAD:main # runner
# git push coolify_gitea HEAD:main # frontend
# 3. Trigger the Coolify deploy (correct endpoint for Coolify v4)
source /Users/markhenderson/master-ai/.coolify.env
curl -s -X POST \
-H "Authorization: Bearer $COOLIFY_API_TOKEN" \
"$COOLIFY_URL/api/v1/deploy?uuid=y4cscsc8s08c8808go0448s0"
curl -s -X POST -H "Authorization: Bearer $COOLIFY_API_TOKEN" \
"$COOLIFY_URL/api/v1/deploy?uuid=jss08wssogw4kw8gok0sk0w0" # runner uuid; use the frontend uuid for the frontend
```
**Note:** `/api/v1/applications/{uuid}/start` or `/deploy` returns 404 on Coolify v4. The correct deploy path is `/api/v1/deploy?uuid=...`. Add `&force=true` to force a full rebuild.
**Notes:**
- `/api/v1/applications/{uuid}/start` or `/deploy` returns 404 on Coolify v4. The correct deploy path is `/api/v1/deploy?uuid=...`. Add `&force=true` to force a full rebuild.
- The runner builds from `vibn-agent-runner/Dockerfile`, which runs `npm run build` (tsc) on `src/` — you do **not** need to hand-build `dist/` for the deploy (but keeping `dist/` in sync is tidy).
## The agent runner (chat backend)
`vibn-agent-runner` (FQDN `https://agents.vibnai.com`, port 3333) is what actually answers desktop/web chat:
- Frontend `POST /api/projects/:id/agent/sessions` inserts an `agent_sessions` row and fire-and-forgets
`POST {AGENT_RUNNER_URL}/agent/execute` to the runner. The runner clones the project's Gitea repo, runs the
**Coder** agent, and `PATCH`es output/status back to the session row (auth via `x-agent-runner-secret`).
- The desktop/web then polls `GET /api/projects/:id/agent/sessions/:sid` for streamed output.
- **Model:** set by the runner env `GEMINI_MODEL` (currently `gemini-3.1-pro-preview`). The desktop model picker
is cosmetic until model-passthrough is wired.
- Health check: `curl https://agents.vibnai.com/health``{"status":"ok"}`.
- The happy path of `/agent/execute` has **no logging** — only failures log. To inspect:
`gcloud compute ssh coolify-server-mtl --zone=northamerica-northeast1-a --project=master-ai-484822 --command="sudo docker logs --tail 100 jss08wssogw4kw8gok0sk0w0-<suffix>"` (find the exact container name with `docker ps`).
## Coolify API Reference

249
VIBN_HANDOFF_TICKETS.md Normal file
View File

@@ -0,0 +1,249 @@
# Vibn — Backend Handoff Tickets (for Sonnet)
> Companion to `VIBN_PRODUCT_BLUEPRINT.md`. These are the **backend / plumbing**
> tasks to hand to a cheaper model (latest Sonnet, standard reasoning; use high
> reasoning only on T2/T3/T6, which Opus should also review).
>
> Rule of the seam: the frontend is already built against typed contracts +
> mock data. **Implement endpoints to the exact shapes; do not change the
> contract types or the frontend components.** When an endpoint is live, swap the
> mock call for a fetch — that's the only frontend edit allowed.
>
> Disjoint write scope: these tickets touch `app/api/**`, `lib/**`, the agent
> runner, and the prompt files — NOT the onboarding `.tsx` UI (except the one
> documented mock→fetch swap in T11).
---
## Milestone 0 — Foundation (do first; nothing is safe until these land)
### T1 — One task ledger: markdown everywhere · ⚠️ Opus review
**Problem:** three prompts disagree on task tracking (route.ts says `plan_*` are
retired; the agent-runner `coder.ts` says call `plan_task_complete`; the session
runner toggles markdown). This causes the "loops on task 1" bug.
**Do:** make `.vibncode/specs/*.md` markdown checkboxes (`- [ ]` / `- [x]`) the
single source of truth in all three. Retire the DB `plan_*` tools (or make them
thin markdown writers). Ensure `.vibncode/` is committed and never removed by
`git clean -fd`.
**Files:** `vibn-frontend/app/api/chat/route.ts`, `vibn-agent-runner/src/prompts/coder.ts`, `vibn-agent-runner/src/agent-session-runner.ts`.
**Accept:** a delegated run that completes a task flips the markdown checkbox AND
the desktop Interactive Backlog reflects it; no prompt references `plan_*`.
### T2 — Extract BASE + MODE prompt modules · ⚠️ Opus review
**Do:** factor the shared prompt into `BASE` (identity, voice, spine/task-ledger
contract, infra model, hard rules, untrusted-content rule, project state) +
three `MODE` deltas (Collab / Build / Grow). Both `route.ts` and the agent-runner
import the same modules. See blueprint §5.3.
**Accept:** one source of truth for BASE/MODE; route + runner import it; Architect
("Collab") no longer contains the code/deploy body.
### T3 — MODE_TOOLS map + enforced gating · ⚠️ Opus review
**Do:** one `MODE_TOOLS: Record<"collab"|"build"|"grow", ToolName[]>` (blueprint
§5.2). Filter exposed tool schemas per mode in the prompt builder AND reject
out-of-mode calls in the dispatcher. Apply in route + runner.
**Accept:** Collab cannot call `ship`/`shell_exec`/`apps_create` (not in schema +
dispatcher rejects); `market_research_run` only callable in Collab; tool count
per turn drops to ~2030.
### T4 — Phantom-tool + template-literal fixes (mechanical)
**Do:** in `route.ts`: `apps_envs_set``apps_envs_upsert`; `apps_containers_list`
`apps_containers_ps`; remove `plan_decision_log` (doesn't exist); un-escape the
`\${activeProject.slug}` at ~L306 so it interpolates.
**Accept:** every tool name in prose exists in the registry; no literal
`${activeProject.slug}` in the compiled prompt.
### T5 — De-contaminate hardcoded specs
**Do:** the 10-file spec manifest in `route.ts` (~L346357, COPPA/Missinglettr/
Dracula) ships to every user. Derive it from the active project, or replace with a
generic "read whatever exists in `.vibncode/specs/`."
**Accept:** a fresh project's prompt contains no GetAcquired-specific spec names.
### T6 — Metering ledger foundation · ⚠️ Opus review
**Do:** a per-event usage ledger `{ workspaceId, clientId, projectId, costType,
quantity, rawCost, ts }` (blueprint §8.2). Emit an event from every cost-incurring
tool (AI tokens, deploys, domains, market research, media, Missinglettr). Build on
`lib/quotas.ts`.
**Accept:** every billable action writes a ledger row tagged by project; a query
can total raw cost per client per period. (Invoicing UI is T16 — not now.)
---
## Milestone 1 — Onboarding + dashboard endpoints (implement to the contracts)
> Contracts: `vibn-frontend/app/(onboarding)/onboarding/onboarding-agency-types.ts`.
> Mock to replace: `…/onboarding-agency-mock.ts`.
> Flow reminder: onboarding ends at **ideal customer → dashboard**; the targeting
> recommendation lives in the **dashboard** (T8), not onboarding. Steps are:
> Identity (T7b) → Presence → Ideal Customer (T7) → POST (T9) → Dashboard (T10).
### T7 — POST /api/agency/analyze-expertise `{ text }`
**Returns:** `{ tools: string[] }`. **Do:** an LLM call that maps the consultant's
free-text ideal customer / problem description to canonical tool-category
labels that match `smb_to_software_mapping` (so they join in T8). The FE mock is
`extractTools()` (keyword stub) — replace with the LLM, keep the output shape.
**Accept:** "I want to help dentists automate booking" -> `["Appointment Scheduling
Software"]` (or better); labels exist in the mapping. Matches them with potential customers in their area, and drives brand awareness.
### T7b — GET /api/agency/cities?q= (city autocomplete)
**Returns:** `CityRef[]` (global). **Do:** proxy **Places API (New) Autocomplete**
(`places:autocomplete`, restrict to localities/cities) for predictions, then
**Place Details (New)** to resolve each into `CityRef` (name = locality,
region = admin area level 1 short name, country + `countryCode` = ISO alpha-2,
lat/lng = location). Key stays server-side. The frontend `CityLookup` already
calls this and falls back to the seed list when absent.
**Accept:** typing "Vic" returns Victoria BC *and* global matches (not a fixed list).
### T7c — POST /api/agency/places/search `{ name: string, city: CityRef }`
**Returns:** `PlaceMatch[]` (top 3 matching businesses).
**Do (Three-Stage AI Category Resolver):**
1. **Stage 1: Google Places Lookup (Physical Context):**
- Query **Places API (New) Text Search** using `GOOGLE_PLACES_API_KEY` with `${name} ${city.name} ${city.region}` to find matching business entities.
- Extract: `id`, `displayName`, `formattedAddress`, `primaryType` (e.g., `"health"`), and `types`.
2. **Stage 2: DataForSEO Website Lookup (Digital Context):**
- If the business has a website, query **DataForSEO's OnPage/Database APIs** (or scrape the URL, falls back to raw query if offline) to retrieve the website's meta-title, description, and domain tags.
3. **Stage 3: AI Assessment (The Reasoning Bridge):**
- Feed both Stage 1 (Google Places categories/types) and Stage 2 (website title, description, domain tags) into a **Gemini LLM call** (`gemini-3.5-flash`).
- **Prompt:**
```
Google Places details:
- Name: {{displayName}}
- Primary Type: {{primaryType}}
- Types: {{types}}
Website Details:
- Title: {{scrapedTitle}}
- Description: {{scrapedDescription}}
Based on the above, select the single most relevant category ID (gcid) for this business from our canonical mapping list. Return only the raw GCID string (e.g., "gcid:dental_hygienist" or "gcid:plumber").
```
- Map the returned GCID to one of our 5 baseline categories: `"service"` (trades/FSM), `"appointments"` (scheduling), `"food"` (dining), `"retail"` (pos/shop), or `"events"`.
- Retrieve the matched GCID's exact `"softwareNeeds"` list from `smb_to_software_mapping_final.json` and return it in `presetTools`.
**Accept:** looking up "Wheely Clean" (which Google labels `"health"`) correctly maps to `"gcid:dental_hygienist"` via AI assessment (by reading their teeth whitening website title), loading their actual dental scheduling, billing, and EHR/EMR custom blocks on Step 2! Shows loading spinner during execution.
### T8 — POST /api/agency/targets `{ city: CityRef, tools: string[] }` (powers the dashboard)
**Returns:** `TerritoryOpportunity[]`, sorted by `opportunityScore` desc.
**Do:** intersect `tools` with each SMB type's `softwareNeeds`
(`smb_to_software_mapping_final.json`) to pick candidate niches; for each, get
**business counts via the Places Aggregate API** (`:computeInsights`,
`INSIGHT_COUNT`) filtered by the niche's Places `type` (mapped from `gcid`)
within the city's area (circle around `city.lat/lng`). `opportunityScore` =
demand × weak/no-software gap × low Vibn saturation, biased by tool-fit. Treat
"Reporting / Dashboard Software" as a **universal** need. Mirror `mockTargets`;
set `matchedTools` per result.
**Accept:** real per-city counts for any city worldwide; `vibnClaimedCount` from
our DB; honest numbers only (no fabricated scarcity — blueprint honesty guardrail).
### T9 — POST /api/agency `AgencyOnboardingResult` `{ profile, expertise, tools }`
**Returns:** `{ workspaceSlug }`.
**Do (DB Storage Spec):**
1. **Workspace row:** Insert a new row into `fs_workspaces`.
- `name` = `profile.name`
- `slug` = derived slug from `profile.name` (idempotently deduplicated)
- Store all metadata inside a structured `agency_onboarding` JSONB field in `fs_workspaces.data` (or related column):
```json
{
"city": {
"id": "victoria-bc",
"name": "Victoria",
"region": "BC",
"country": "Canada",
"countryCode": "CA",
"lat": 48.4284,
"lng": -123.3656
},
"hasWebsite": true,
"websiteUrl": "yoursite.com",
"hasSocials": true,
"hasBlog": false,
"hasCustomDomain": false,
"hasExistingClients": false,
"expertise": "I want to help dentists automate booking",
"tools": ["Appointment Scheduling Software"]
}
```
2. **Workspace member row:** Link the signed-in NextAuth user (`userId`) as the `'owner'` of this workspace in `fs_workspace_members`.
3. **Provisioning:** Trigger the standard workspace provision pipeline (Gitea org, Coolify project boundary via `lib/workspaces.ts`) asynchronously so the tenant stands up.
**Accept:** round-trips the posted result; a new row is created in `fs_workspaces` and `fs_workspace_members`; metadata is saved perfectly in JSONB; and the workspace slug is returned.
*(No pitch, no claimed territory — those were removed from onboarding.)*
### T10 — The dashboard (the screen they land on) · ⚠️ Opus may build
**Do:** the agency dashboard at `/[workspace]` (light paper/ink theme). On load,
call T7 (analyze-expertise, or use stored `tools`) + T8 (targets for their city)
and render the **recommended local businesses to target** (the gold-rush list
with businesses / weak-software / claimed + matched-tools chips). Plus clients/
prospects, projects, retainer MRR. Claiming a target creates a prospect.
**Accept:** lands from onboarding seeded with their ideal-customer description; shows real
recommendations; claim → prospect. *(FE craft — likely an Opus task; reuses
`extractTools` + `mockTargets` until T7/T8 are live.)*
### T11 — Wire onboarding + dashboard to the endpoints
**Do:** `CityLookup` already calls `GET /api/agency/cities` (T7b) with a seed
fallback — just stand up the route. Implement `finishAgency` in `page.tsx` to
POST T9 and route to `/[workspace]`. In the dashboard, swap `extractTools`/
`mockTargets` for T7/T8. No styling changes to onboarding.
**Accept:** the flow runs end-to-end on real data; onboarding behavior unchanged.
### T12 — Preserve homepage intent through auth
**Do:** if the homepage hero captures input, persist it across Google OAuth
(localStorage/draft, like `vibn:firstName`) so onboarding resumes seeded.
**Accept:** typing on the homepage → sign in → onboarding has the value.
---
## Milestone 2 — Design-first delivery (the custom tool)
### T13 — Ingest the 4 design-kit families
**Do:** register `vibn-ai-templates`, `vibn-app`, `vibn-crm`, `vibn-marketplace`
(in `design-templates/VIBN (2)/`) into the design-kit registry: one kit per family,
themes as overrides; add `DESIGN.md` + `tokens.css` (+ `SKILL.md`) per the existing
`lib/scaffold/open-design/design-systems/<id>/` structure.
**Accept:** `get_design_template` returns each; they appear on the Design tab.
### T14 — Build recipe: scaffold-from-kit first
**Do:** rewrite the Build mode recipe so building a client's **custom tool**
starts from a kit (fork into the client repo) + token reskin, instead of
`create-next-app`. The tool is scoped from the consultant's expertise + the
client's `softwareNeeds`; SMB domain → family; client brand → accent.
**Accept:** a build starts from a polished themed template, not an empty Next app.
### T17 — Onboarding hardening note (low priority)
**Do:** `page.tsx` has pre-existing unused imports (`useState`/`useEffect`/
`useMemo` on line 3) flagged as warnings — not from the agency work. Clean up if
touching the file.
**Accept:** no behavior change.
---
## Milestone 3+ — Grow & billing (later)
### T15 — `missinglettr_*` tool wrapper
**Do:** wrap the Missinglettr API (`workspaces.create`, `posts.create`, analytics).
Grow mode only. **Accept:** can schedule a multi-platform post; metered (T6).
### T16 — Stripe retainers + invoicing
**Do:** on the metering ledger, roll up cost → apply pricing (retainer / cost-plus /
fixed) → Stripe one-off invoice + recurring subscription for retainers.
**Accept:** an agency can invoice a client and start a monthly retainer.
### T18 — Google Business Profile (GMB) OAuth & Token storage
**Do:** add `https://www.googleapis.com/auth/business.manage` to the NextAuth Google Provider config.
Upon user sign-in, save the authorized OAuth `access_token` and `refresh_token` in `fs_users.data`.
On the backend, write a helper to list GMB locations for the authorized user and support posting Google Local Business posts.
This is the core engine for automated GBP posting and review management in Grow mode.
**Accept:** signing in with Google requests the GMB permission; tokens are securely saved in `fs_users.data` and are queryable by the server.
### T19 — DataForSEO OnPage API Website Auditor
**Do:** implement a backend helper to post and retrieve data from **DataForSEO's OnPage API** (`/v3/on_page/task_post` -> `/v3/on_page/summary`).
Extract domain-wide metrics: `domain_info.cms` (to auto-detect what builder they are renting), `domain_info.ssl_info`, `page_metrics.broken_links`, and favicon availability.
**⚠️ Hard constraint:** DataForSEO's OnPage crawler strictly requires the target URL to include the protocol (e.g., must be `"https://allardcontractorsltd.com"` or `"http://..."`, NOT a bare domain). Ensure the server-side payload prepends `"https://"` automatically when creating the crawler task.
Expose this audit dataset to the dashboard so consultants can auto-generate SEO health audits for their prospects.
**Accept:** posting a scan request triggers the DataForSEO crawl; returns unified CMS, SSL, and link metrics; tags them to the client's project row.
---
## Notes for the implementer
- Don't touch the onboarding `.tsx` files except T11's documented swap.
- Keep `onboarding-agency-types.ts` as the contract; if a shape must change, change
it there and flag it (the UI depends on it).
- Honesty guardrail (T8/T9): never show fabricated market/scarcity numbers.
- Flag T1/T2/T3/T6 for an Opus review pass before merge.

View File

@@ -0,0 +1,43 @@
# VIBN Agent Orchestration Loop & State Governor
This document outlines the Phase-Based Execution Loop architecture that governs all autonomous agent runs in the Vibn workspace.
## 1. Adaptive Tool Budgets (Intent Classification)
The global `MAX_TOOL_ROUNDS = 150` is a necessary safety net, but allowing a simple "why is the preview blank?" query to run 150 tools is a UX failure.
When a user prompt is received, we classify its intent and assign a strict tool budget:
* **`conversational`** (Budget: 0) — Greetings, affirmations.
* **`status_check`** (Budget: 2) — "What is running?", "Show me the logs."
* **`diagnose`** (Budget: 8) — "Why is the preview blank?", "The build failed."
* **`small_fix`** (Budget: 15) — "Change the header color", "Fix the typo."
* **`feature_build`** (Budget: 40) — "Add a pricing page", "Wire up Stripe."
* **`autonomous`** (Budget: 150) — "Build this entire app from scratch", "Keep going."
## 2. Phase-Based Execution State Machine
An agent turn no longer has access to all tools at all times. It transitions through a strict state machine:
1. **`recon`**: Gathering context. Only non-mutating tools allowed (`fs_read`, `dev_server_logs`, `browser_console`).
2. **`checkpoint`**: A mandatory pause where the agent must state its findings, goal, and proposed action *before* it is granted write access.
3. **`execute`**: Mutating tools unlocked (`fs_edit`, `shell_exec`, `dev_server_start`).
4. **`verify`**: Post-mutation testing. The agent must successfully run a compilation check or visual QA before claiming success.
5. **`final`**: Synthesis and user response.
## 3. Tool Classification & Filtering
Tools in `lib/ai/vibn-tools.ts` are heavily categorized:
* **Read-Only**: `fs_read`, `fs_list`, `fs_grep`, `dev_server_list`, `dev_server_logs`, `projects_get`
* **Mutating**: `fs_write`, `fs_edit`, `fs_delete`, `shell_exec`
* **Verification**: `browser_console`, `request_visual_qa`
If an agent in the `recon` phase attempts a mutating tool, the loop intercepts the call, blocks execution, and injects a recovery prompt demanding a Checkpoint first.
## 4. Forced Verification Gates
Before the loop can naturally terminate and present the "Done" state to the user, the governor checks:
* Did the agent mutate files (`fs_write`, `fs_edit`)?
* If yes, did the agent run `browser_console` or `dev_server_start` after the last edit?
* If no, the final response is rejected and a system prompt forces the agent to verify the build before concluding.
## 5. UI Event Telemetry
The backend streams rich SSE events to the frontend Chat Panel:
* `data: {"type": "phase", "phase": "recon", "label": "Investigating Codebase"}`
* `data: {"type": "checkpoint", "goal": "...", "findings": "..."}`
* `data: {"type": "budget", "used": 5, "limit": 15}`
This replaces the "silent black box" with an engaging, highly transparent glass-box UI.

433
VIBN_PRODUCT_BLUEPRINT.md Normal file
View File

@@ -0,0 +1,433 @@
# Vibn — Product Blueprint & Go-to-Market Architecture
> Status: Draft v3 · Owner: Mark · Last updated: 2026-06-04
> v3 sharpens the wedge to **custom tools** for local businesses (not
> websites/marketing), makes onboarding **expertise-first → dashboard** (no pitch
> generator), and moves the targeting/"gold rush" recommendation into the
> dashboard. v2 (services+margins+pitch onboarding) is superseded; v1 (founder/
> build-first) before it.
>
> Implemented so far (FE, against mocks + typed contracts): the contractor
> onboarding flow — `app/(onboarding)/onboarding/onboarding-agency*.{tsx,ts}` +
> the front-door fork in `page.tsx`. Backend + dashboard pending (handoff doc).
---
## 1. Positioning
**Vibn is the operating system for a new breed of local-business consultant — it
helps them find local SMBs, build them the *custom tools* they actually need
(without writing code), and bill for it profitably — so those businesses stop
paying for a stack of expensive SaaS apps that don't talk to each other.**
The wedge is **custom tools, not websites/marketing.** Every local business is
overpaying for generic SaaS that half-fits; the consultant builds one tool that
fits their workflow exactly.
Think **Harvest for AI vibe coding**: the place a consultant runs the whole
client business — find, build, host, invoice.
Two audiences, one engine — but a clear hero:
- **PRIMARY · The new consultant / freelancer / small studio.** Often not a deep
engineer (a marketer, designer, or hustler starting a "websites + marketing for
local business" practice). Vibn is their unfair advantage. **They are the buyer.**
- **SECONDARY · The SMB owner doing it themselves.** Same engine, no markup. Served
by self-serve, not chased.
- **De-emphasized:** startup founders. That lane (Lovable, v0, Bolt, Replit) is a
bloodbath and is *not* where our infrastructure points.
### Why this wedge (the infra already leans here)
- `market_categories_suggest` returns **Google Business Profile** categories — a
*local business* construct.
- `market_research_run` pulls local **business leads, TAM, competitors** (DataForSEO).
- Missinglettr lists **Google Business** among its 12 platforms — local social + GBP.
- The "owner" persona pitch is *"replace the stack of tools you rent"* — SMB ops.
- The Cadence CRM template = contacts + scheduling; domains + Stripe = every SMB.
None of this was built for startups. It was built for **local SMBs and the people
who serve them.** This is a focus, not a pivot.
---
## 2. The two front doors
Opposite motivations require opposite openings:
| Door | Who | First question they're asking | Opening |
|---|---|---|---|
| **"Personal"** | SMB owner / self-builder | *"Can I see my thing built?"* | **Build-first** — straight to a live themed preview (§6) |
| **"Agency"** (HERO) | New consultant | *"Can I do this? What do I build? Who needs it? How do I get a client?"* | **Contractor-first** — set up the agency, state your expertise, land in a dashboard of local targets (§4) |
The homepage leads with the consultant promise and routes self-builders to the
simpler path — it does **not** treat them as equals.
> Reversal from v1: "get to a live preview ASAP" is right for a self-builder and
> *wrong* for a consultant. A consultant is evaluating a **business**, not a build
> tool. Lead with the contractor parts; building happens later, per client.
---
## 3. The lifecycle = one client engagement
For the consultant, the product lifecycle is the shape of **a single client
engagement**, run once per SMB client:
```
DISCOVER → BUILD → REFINE → GROW
the pitch deliver iterate the retainer
(win it) the site with client (recurring $)
```
It's driven by two orthogonal axes (kept distinct in code — today they're conflated):
| Axis | Controls | Decided |
|---|---|---|
| **Engagement stage** | The *path* (research/pitch vs build vs grow) | Per client, by where the deal is |
| **SMB domain** (trades, salon, dental, food, fitness…) | The *look* — template family + theme | Inferred from the business type |
A consultant runs many clients at different stages simultaneously.
---
## 4. Contractor-first onboarding — "Set up your AI agency"
Onboarding is short and has one job: learn **who the consultant is** and **what
they love building**. The moment we know their sweet spot, we drop them into
their **dashboard** — where the local-business recommendations live as an
ongoing feature (not a one-shot screen). No pitch generator, no terminal
targeting screen in onboarding. *(Implemented: `app/(onboarding)/onboarding/onboarding-agency.tsx`.)*
```mermaid
flowchart TD
A["1 · Your agency<br/>name · city (Places lookup)"] --> B["2 · Your presence<br/>what does your agency have today? (checklist)"]
B --> C["3 · Your ideal customer (free text)<br/>'who / what problem do you want to solve?'"]
C --> D["Open my dashboard →"]
D --> E["DASHBOARD<br/>AI reads description → recommends local businesses to target"]
```
- **Agency** option details: "I want to do billable AI work for others" (VIBN helps you find local businesses that you can build custom solutions for).
- **Personal** option details: "I want to build my own ideas" (Go from idea to market, and beyond).
### Step contents
1. **Your agency** — name, **city** (global Places-API lookup, §6.x).
2. **Your presence** — "What does your agency have today?" Checklist of assets
(Website, social media accounts, blog, custom domain, existing billing).
Light profiling to customize their dashboard experience.
3. **Your ideal customer** *(the heart of it)* — a **free-text** box: *"Is there a
certain type of business or business problem you are passionate about?"*
(e.g. "I want to help dentists automate patient booking"). If they are undecided,
clicking **"I'm not sure right now"** bypasses the step with a neutral default
("help any local business automate workflows"). This replaces the old examples list.
Vibn will help them match with potential customers in their area, and drive awareness of their brand.
4. **→ Dashboard.** CTA "Open my dashboard →" finishes onboarding with
`{ profile, expertise, tools }` (AI-extracted tool categories, where `expertise`
holds the ideal-customer string).
### The Local Business Category Lookup & Mapping Pipeline (onboarding)
This is the core "Business Identity & Needs" pipeline run on Step 1 of the self-builder flow, designed to bypass Google's messy administrative category labels:
1. **Step 1: City Geocoding & Radius Setup:**
- The user selects their city in Step 1. The frontend retrieves their structured `CityRef` (holding `lat`/`lng` coordinates from Google Places) and sets a default radial search geofence of **50km**.
2. **Step 2: Geofenced DataForSEO Business Search:**
- The backend takes the business name (or URL) and queries the **DataForSEO Business Listings Search API** (`/v3/business_data/business_listings/search/live`) using geofenced `"location_coordinate"` radial search:
`"location_coordinate": "{{lat}},{{lng}},50"`
- This bypasses Google's strict SAB restrictions, pulling down the full business records (including mobile businesses with hidden addresses like "Wheely Clean Mobile Dental").
- The server extracts GMB's main `"category"` and `"additional_categories"` arrays.
- It joins them to our `smb_to_software_mapping_final.json` dataset (the 4,006-item database) to fetch their exact, customized software tool requirements.
3. **Step 3: Unpacked Category Card Selection ("Which best describes your business?"):**
- The frontend receives the matched business, and automatically unpacks **both its primary category and all discovered GMB alternative categories** into individual clickable cards.
- The screen displays: **"Which best describes your business?"**
- Selecting any card (e.g. *Dental hygienist* or *Teeth whitening service*) instantly loads that specific subcategory's custom presets and advances to Step 2!
- *Fallback:* If DataForSEO or geocoding fails (e.g. offline dev), it gracefully triggers Google Places Text Search (New) + Gemini 2.5 Flash as an automated fallback reasoning bridge.
### The targeting engine (lives in the dashboard)
### The dashboard (the home screen they land on)
- **Recommended targets** — the AI's local-business recommendations (above),
refreshable; claim one to start a client/prospect.
- **Clients / prospects** — each SMB; status (prospect → won → live → retainer).
- **Projects** — per client (a custom tool build + hosting/support retainer).
- **Revenue & margin** — what each client costs me vs. what I bill; retainer MRR
(illustrative until metering lands, then live).
- Building a client's tool is entered **from a client**, not the dashboard root.
- *(To build next — reuses `extractTools` + `mockTargets`; light paper/ink theme.)*
---
## 5. Modes — capability surfaces with enforced tool gating
Three modes (capability surfaces, not vibes). **Refine is not a mode** — it's Build
against an already-live project.
| Mode | Engagement role | Stop condition |
|---|---|---|
| **Collab** | Discover — research + pitch + spec | PRD + decisions + backlog (the spine) |
| **Build** | Deliver the site + refine | A clickable preview / shipped `fqdn` |
| **Grow** | The retainer — distribute + monitor | Scheduled content + live analytics |
### 5.1 Tool gating is enforced, not described
Today "DO NOT WRITE CODE" is a prompt *request* while `fs_write`/`ship`/`shell_exec`
stay in the tool list → the constraint is soft and the prompt re-teaches the
forbidden workflow. Fix: one `MODE_TOOLS: Record<Mode, ToolName[]>` map, read by:
1. **Prompt builder** — filters exposed tool schemas per mode (model can't see what
it can't call; also cuts ~88 schemas → ~2030 = token/latency win).
2. **Dispatcher** — rejects out-of-mode calls (guards hallucinated names).
Applied in **both** `vibn-frontend/app/api/chat/route.ts` and the agent-runner.
### 5.2 Allowlist sketch
| Capability | Collab | Build | Grow |
|---|---|---|---|
| Reads (`projects_get`, `apps_*` reads, `get_design_template`) | ✅ | ✅ | ✅ |
| Research (`market_*` 💲, `github_*`, `http_fetch`) | ✅ | ❌ | ✅ (seo/insights) |
| Spine docs — `fs_*` **scoped to `.vibncode/specs/`** | ✅ | ✅ (+ repo) | ✅ (blog/SEO) |
| Design kit / `apps_templates_scaffold` | propose | ✅ full | theme marketing |
| Engineering (`shell_exec`, `dev_server_*`, `apps_create`, `ship`, `databases_*`) | ❌ | ✅ | `apps_create { repo }` only |
| Distribution (`missinglettr_*`, `generate_media`) | ❌ | ❌ | ✅ |
| Destructive (`*_delete`, `apps_volumes_wipe`) | ❌ | ⚠️ confirm | ❌ |
Gating gives each guardrail a home: **money gate** in Collab, **destructive-confirm**
in Build, **untrusted-content rule** in BASE (Collab + Grow read the open web).
### 5.3 Prompt composition
```
BASE identity · voice · spine/task-ledger contract · infra model · hard rules ·
untrusted-content rule · project + client/agency state
+
MODE { Collab | Build | Grow } — behavior + stop condition + protocols
+
CONTEXT design kit · decisions/backlog · stage seed · SMB-domain template guidance
```
BASE + MODE must be **shared modules imported by both** the chat route and the
agent-runner (today there are three drifted copies — root of the "loops on task 1"
bug).
### 5.4 Visibility
Modes **auto-select** by stage + project state. The toggle remains a power-user
override. Nobody picks a mode manually.
---
## 6. Build-first door & design-first delivery
The build flow is no longer the front door — but it's still how work gets
*delivered* (and how a self-builder enters). It must be **design-first, not
code-first.**
- **Stop** scaffolding from `create-next-app` + hand-building UI (slow, generic,
the source of visual-QA loops).
- **Start** from a polished, CSS-variable-themed template family, reskin to the
SMB's brand via the design-kit token system, then populate content.
### 6.1 Template families (assets in `design-templates/VIBN (2)/`)
| Family | Use | SMB domain |
|---|---|---|
| `vibn-ai-templates` | Shared base library (components + 4 themes) | foundation |
| `vibn-app` | Marketing / landing / lead capture + payments | most local SMB sites |
| `vibn-crm` (Cadence) | Ops: contacts, scheduling, dashboard | trades, salons, clinics |
| `vibn-marketplace` (Atlas) | Listings / booking / two-sided | directories, multi-vendor |
Themes (`minimal` / `dark` / `glass` / `editorial`) + accent come from the SMB's
brand. **Demos must look visibly local-SMB** (a plumber, a salon, a dental office),
never a generic SaaS dashboard.
### 6.2 Wired vs. to-build
- ✅ Design-kit registry, Design tab, token injection, `apps_templates_scaffold`, `get_design_template`.
- ❌ The four families ingested as registered kits (`DESIGN.md` + `tokens.css` + `SKILL.md`).
- ❌ Build recipe rewritten to "scaffold-from-kit first."
- ❌ Build entered per client from the console; SMB brand → kit selection.
- ❌ Real session streamed to a real themed preview (today: `setTimeout` animation + fake URL, answers discarded).
### 6.3 Fork, don't depend
Fork the kit into the client's repo (the READMEs say "fork it") — self-contained,
fully editable.
---
## 7. Grow — the retainer (Missinglettr)
Grow is the consultant's **recurring revenue.** Once a client's site is live, the
consultant runs their marketing as a monthly retainer.
- **Missinglettr API** = one API to post/schedule across 12 platforms (incl. Google
Business) with shortening, analytics, webhooks. The engine of Grow.
- **My Business Business Information API (GMB)** = used specifically via OAuth 2.0 to manage verified locations, publish Google Local Business posts, and retrieve and reply to reviews (reputation management). Combined with Missinglettr, it forms the core Grow suite.
- **DataForSEO OnPage API Website Auditor** = crawlers that fetch a client's website and return full on-page diagnostics: `cms` auto-detection (Wix, WordPress, Squarespace), SSL status, mobile responsiveness, and broken link counts.
- Capabilities: AI-generated social + blog + **local SEO pages**, styled to match
the client's design kit, scheduled via Missinglettr; reviews; analytics reporting.
- Existing blocks: `market_seo_analyze`, `generate_media`, `project_recent_errors`
(monitoring), and `vibn-attribution-package` (UTM → first-touch attribution).
- To build: `missinglettr_*` tool wrapper, content/SEO generation, analytics loop,
and a **client-facing monthly report** (the retainer's visible value).
---
## 8. Agency / billing layer — "Harvest for AI vibe coding" (CORE, not a fast-follow)
For the consultant ICP, **getting paid is half the value prop.** It cannot be an
afterthought. The most important primitive is the **monthly retainer** (the Grow
fee = the consultant's MRR), not just the one-off project invoice.
### 8.1 Cost sources (all metered, tagged by client/project)
| Cost type | Source |
|---|---|
| AI usage (tokens) | `lib/ai/llm-client.ts` |
| Compute / infra (apps, dev containers, DBs) | Coolify / `lib/dev-container.ts` |
| Domains | `domains_register` / `lib/opensrs.ts` |
| Market research 💲 | `market_research_run` (DataForSEO) |
| Distribution | Missinglettr (usage / subscription) |
| Media | `generate_media` |
### 8.2 Components
1. **Metering ledger** — `{ workspaceId, clientId, projectId, costType, quantity,
rawCost, ts }`. Seeded by `lib/quotas.ts` + telemetry. Every cost-incurring tool
emits an event.
2. **Client ↔ project mapping** — agency workspace holds many client projects.
3. **Pricing engine** — per client: **retainer** (recurring), cost-plus markup %,
or fixed project price. Roll up raw cost → apply pricing → billable.
4. **Invoicing & retainers** — Stripe (in stack): one-off invoices **and recurring
subscriptions** for retainers; optional client statement/portal.
5. **In-agent cost transparency** — AI surfaces estimated cost *before* spending
(the money-gate guardrail as a platform concept); every spend is attributable.
### 8.3 Consequence
Accurate per-client cost accounting is needed regardless of when invoicing UI ships,
so **the metering ledger is launch-foundation work** — retrofitting attribution is
painful. Onboarding's "your margins" can be *illustrative* until metering is live,
provided the numbers are honestly labeled.
---
## 9. The spine — single source of truth
**`.vibncode/specs/*.md` (markdown) is the law.** Retire DB `plan_*` tools (or make
them thin markdown writers). The desktop Interactive Backlog already reads markdown;
the session runner already toggles checkboxes; it's git-tracked; it's the artifact
the user sees and edits.
- Task state = `- [ ]` / `- [x]`; all brains obey this.
- `.vibncode/` **must be committed and never removable by `git clean -fd`** (the
earlier unattended-loop bug).
- The **design kit travels in the spine** — styling source of truth from Build → Grow.
- The spine carries the engagement: Discover writes the spec/pitch, Build consumes
it + adds the kit, Grow reads product + kit to generate matching marketing.
---
## 10. Hardening (from the prompt/tool audit)
- **Phantom tools:** `apps_envs_set`→`apps_envs_upsert`; `apps_containers_list`→
`apps_containers_ps`; `plan_decision_log` (Architect; doesn't exist) → remove.
- **Template-literal leak:** `route.ts` ~L306 escaped `\${activeProject.slug}` →
un-escape so it interpolates.
- **Task-tracking civil war:** route vs shared-body vs runner disagree → markdown
checkboxes everywhere (§9).
- **Hardcoded project specs:** route ships one project's private spec list
(COPPA / Missinglettr / Dracula) to every user → generalize/derive.
- **Architect contradiction:** solved by mode gating (§5) + composition (§5.3).
- **Sentry snippet path** unreachable from dev container → inline / ship in scaffold.
- **`request_visual_qa` "Always"** → "for UI work."
- **Infra clarity:** add infra model + first-deploy recipe (resolve `ship` "if
linked" + `ship` vs `apps_create { repo }`).
---
## 11. Go-to-market sequencing
| Milestone | Scope | Why |
|---|---|---|
| **0 · Foundation** | Spine = markdown everywhere; BASE+MODE shared modules; `MODE_TOOLS` gating; phantom/leak fixes; de-contaminate specs; **metering ledger** | Nothing safe until brains agree on the ledger; metering before any spend |
| **1 · Contractor onboarding (launch front door)** | "Start an AI agency" flow: opportunity → identity (Places city lookup) → presence → **free-text expertise** → dashboard. ✅ FE built against mocks | What the hero ICP evaluates first; short and contained |
| **2 · The dashboard + targeting** | Land them in the dashboard; AI extracts tools from expertise (`analyze-expertise`) → recommends local businesses to target (`targets` via Places Aggregate); claim → prospect | The "gold rush" payoff + ongoing home |
| **3 · Deliver (design-first build)** | Ingest 4 kits → kit-first build recipe → build the client's **custom tool** per client → real preview | Turns a claimed target into a delivered tool |
| **4 · Grow retainer + Missinglettr** | `missinglettr_*`, content/local-SEO, analytics loop, client monthly report | Recurring-revenue hook |
| **5 · Full billing** | Retainers + one-off invoicing (Stripe) + client statements on the metering ledger | Completes "bill for it" |
**Launch line:** Milestones 0 + 1 + 2 (onboarding → dashboard with real
recommendations) + a slice of 3 (one custom tool built end-to-end). Grow/billing
shown as the model, delivered next.
**Build status:** onboarding FE (steps 14) is built and compiles clean against
mock data + typed contracts. Backend endpoints + the dashboard are next (see
`VIBN_HANDOFF_TICKETS.md`).
---
## 12. The journeys (consultant serving a local SMB)
### Sofia — the new consultant (the hero path)
Sofia wants to start a side-business building custom tools for local businesses.
She signs up, selects the "Agency" option, names it, sets her city (a global Places lookup),
and declares what assets she has today. Then the
one question that matters: *"Is there a certain type of business or business problem you are passionate about?"* She types
"I want to help local dentists automate patient booking" and clicks **Open my dashboard**. She's
in. Her dashboard already shows local businesses that fit — salons, gyms, auto
shops, dentists — each flagged as stuck on disconnected SaaS and ripe for one
custom tool. She hasn't done any work and already has a target list.
### Joe's Plumbing — the client gets a custom tool (Build)
Sofia claims "plumbers," picks Joe as a client, and builds him a custom
scheduling + invoicing + reporting tool — one app around his workflow, replacing
the three half-fitting SaaS subscriptions he was paying for. Design-first from a
kit, in his colors, live in a day instead of weeks.
### The SMB owner — self-serve (secondary door)
An owner who finds Vibn directly selects the "Personal" option, enters their business name, city, and optional website, gets their business type auto-analyzed by the AI, and goes straight to building:
### The retainer — recurring revenue (Grow)
With Joe live, Sofia adds a marketing retainer. Vibn generates his Google Business
posts, a couple of local-SEO pages, and social content in his brand voice, schedules
it across platforms via Missinglettr, and produces a monthly report Sofia forwards to
Joe. Joe sees leads; Sofia bills a recurring fee.
### Getting paid (Billing)
Every cost on Joe's projects — AI, hosting, his domain, the market research, the
social scheduling — is metered and attributed. Sofia applies her markup, and Vibn
turns the build into a one-off invoice and the marketing into a recurring Stripe
subscription. She's not just delivering work; she's running a business with real margin.
### The SMB owner — self-serve (secondary door)
An owner who finds Vibn directly skips the agency setup and goes build-first:
"a booking tool for my salon" → a quick look confirm → watches it build → lands
in the chat refining it. Same engine, no markup, no dashboard.
---
## 13. Open decisions
1. ~~**Front-door fork**~~ — **DECIDED:** explicit "Agency" vs "Personal" (neutral presentation, no pre-selection bias).
2. **Targeting spend** — the dashboard's recommendations need per-city counts
(Places Aggregate API) and may touch paid `market_research_run`. Use a
free/cached read for the first dashboard view; gate any paid run on consent.
3. **`MODE_TOOLS`** as the single source of truth for prompt + dispatcher. (Lean: yes.)
4. **Collab write scope** path-scoped to `.vibncode/`. (Lean: yes.)
5. **Kit ingestion** — one kit per family, themes as overrides. (Lean: yes.)
6. **Pricing model first** — retainer vs cost-plus vs fixed: which to ship first.
(Lean: retainer + simple markup.)
7. **Launch SMB vertical** — which single domain to polish end-to-end first
(trades? salon? dental?).
8. ~~**Core brand color**~~ — **DECIDED** (see §14).
---
## 14. Design system (aligned — build everything to this)
The canonical visual spec. All net-new frontend is built to it; the cheaper model
inherits it too.
- **Foundation: warm paper-and-ink** (the differentiator). Product is light
(`--vibn-paper` bg, `--vibn-ink` text, the warm-neutral ramp); marketing may be
dramatically dark.
- **One brand color: matured clay-coral**, threaded through *both* marketing and
product, used **sparingly** (primary action, active state, brand mark, progress,
the single "next thing"). No second chromatic color.
- Tokens in `app/globals.css`: `--vibn-coral` `oklch(0.68 0.16 35)` (actions),
`--vibn-coral-hover`, `--vibn-coral-glow` (the brighter `0.74 0.175 35` for
glow/focus), `--vibn-coral-soft`, `--vibn-coral-fg`.
- Added **additively** — existing `--primary`/`--accent` not rewired blind;
new surfaces use the coral tokens directly and get visual QA.
- **Hard rule:** Vibn chrome accent (coral) ≠ a client app's accent. The product
chrome is coral; each *built client site* uses its own design-kit accent.
- **Two contexts:** onboarding wizard = dark + coral (existing primitives in
`app/(onboarding)/onboarding/onboarding-primitives.tsx`); product/console =
light paper/ink + coral.
- **Client builds** come from the 4 design-kit families (base + app/crm/
marketplace), CSS-var themed (minimal/dark/glass/editorial), forked into the
client repo. Demos must look visibly local-SMB.
- **Type:** product = Inter (`--font-inter`); editorial moments may use a serif
display per the kit.

View File

@@ -39,9 +39,11 @@ graph TD
## 2. Directory Structure & Individual Git Repositories
Your local directory `master-ai` is a **unified workspace** housing folders that map directly to **individual, independent repositories on Gitea** (`https://git.vibnai.com/mark`).
> **`master-ai` is a LOCAL development workspace on Mark's Mac. It does not exist in production and is never accessed by any running cloud service.** Production runs entirely from the individual Gitea repositories → Coolify builds → running containers on `34.19.250.135`. Once a change is pushed to the matching Gitea remote, `master-ai` is completely out of the picture.
DO NOT treat `master-ai` as a single monorepo on Gitea. You must push changes inside specific directories to their matching Gitea remote targets.
The local `master-ai` directory houses folders that each map to an **independent Gitea repository**. The `master-ai` git repo itself is just a convenience — a single place to commit and track changes across all sub-projects before pushing each one to its own Gitea remote.
DO NOT treat `master-ai` as a single monorepo on Gitea — it is not deployed as one. You must push changes inside specific directories to their matching Gitea remote targets.
```
/Users/markhenderson/master-ai/ <-- Local Parent Directory
@@ -51,16 +53,55 @@ DO NOT treat `master-ai` as a single monorepo on Gitea. You must push changes in
│ Remote 'coolify_agent_gitea' -> https://git.vibnai.com/mark/vibn-agent-runner.git
├── vibn-frontend/ <-- Subfolder of master-ai. Pushes via:
│ Remote 'coolify_gitea' -> https://git.vibnai.com/mark/vibn-frontend.git
── vibn-api/ <-- Subfolder of master-ai. Pushes via:
Remote 'coolify_api_gitea' -> https://git.vibnai.com/mark/vibn-api.git
── vibn-api/ <-- Subfolder of master-ai. Pushes via:
Remote 'coolify_api_gitea' -> https://git.vibnai.com/mark/vibn-api.git
└── vibn-telemetry-service/ <-- Subfolder of master-ai (Training Data Microservice). Pushes via:
Remote 'coolify_telemetry_gitea' -> https://git.vibnai.com/mark/vibn-telemetry-service.git
```
### Git Remotes Reference (Configured in `/Users/markhenderson/master-ai`):
* `coolify_agent_gitea` : `https://git.vibnai.com/mark/vibn-agent-runner.git`
* `coolify_gitea` : `https://git.vibnai.com/mark/vibn-frontend.git`
* `coolify_api_gitea` : `https://git.vibnai.com/mark/vibn-api.git`
* `gitea` : `https://git.vibnai.com/mark/master-ai.git`
* `origin` : `https://github.com/MawkOne/master-ai.git`
### Git Remotes Reference (local Mac remotes — these exist only on Mark's machine):
These are git remotes configured in the local `master-ai` repo. They are the **one-way bridge** between local development and production. Production Coolify services pull directly from the Gitea URLs; they have no knowledge of `master-ai`.
| Remote | Gitea URL | What it deploys |
|---|---|---|
| `coolify_gitea` | `https://git.vibnai.com/mark/vibn-frontend.git` | vibn-frontend (Next.js platform) |
| `coolify_agent_gitea` | `https://git.vibnai.com/mark/vibn-agent-runner.git` | vibn-agent-runner |
| `coolify_api_gitea` | `https://git.vibnai.com/mark/vibn-api.git` | vibn-api |
| `coolify_telemetry_gitea` | `https://git.vibnai.com/mark/vibn-telemetry-service.git` | vibn-telemetry-service |
| `gitea` | `https://git.vibnai.com/mark/master-ai.git` | *(share-only — coworker local setup; **builds do NOT use this**)* |
| `origin` | `https://github.com/MawkOne/master-ai.git` | *(GitHub mirror only — not used by Coolify)* |
**The full deploy lifecycle:**
```
Local Mac (master-ai) → git push <remote> HEAD:main → Gitea repo → Coolify build → Production
↑ ↑
master-ai ends here Production begins here
```
1. Make changes in `master-ai/vibn-frontend/` (or whichever subfolder).
2. `git commit` in `master-ai`.
3. `git push coolify_gitea HEAD:main` (or relevant remote) — **this is the complete hand-off**.
4. Coolify detects the push, builds a Docker image from the Gitea repo, and deploys it.
5. `master-ai` is no longer involved. Production runs entirely from the Gitea repo + Coolify.
`vibn-code` is a nested submodule with its own `.git` — commit & push it via its own `origin`.
Secret `.env*` files at the repo root are gitignored — never commit them.
**⚠️ NEVER use `git subtree push` for these remotes.** Coolify is configured with `vibn-frontend` as its **base directory**, so it expects the full `master-ai` repo structure at the Gitea root and resolves the Dockerfile at `vibn-frontend/Dockerfile`. A subtree push flattens the repo to just the subfolder contents, making `vibn-frontend/` disappear and breaking the build with `open Dockerfile: no such file or directory`. Always use:
```bash
git push <remote> HEAD:main # normal
git push <remote> HEAD:main --force # if remote has diverged
```
### Deploying the Telemetry Service manually via Coolify UI:
Because Coolify's API strictly blocks the programmatic creation of GitHub/Gitea Apps, the Telemetry service must be linked manually once:
1. Open [Coolify Dashboard -> vibn-infrastructure -> production](https://coolify.vibnai.com/project/f4owwggokksgw0ogo0844os0/environment/foskksoccksk0kc4g8sk88ok)
2. Click **+ Add -> Application -> Private Repository (with Gitea)**.
3. Select `vibn-telemetry-service` and branch `main`.
4. Set Build Pack to `Dockerfile` and Ports Exposes to `4000`.
5. Under Environment Variables, add `DATABASE_URL=postgresql://<user>:<password>@<host>/<database>`
6. Deploy it, then add `TELEMETRY_SERVICE_URL=http://<the-new-coolify-url>:4000` to the `vibn-frontend` environments.
---
@@ -123,18 +164,97 @@ VibnCode overrides local OS actions to communicate with your cloud containers (o
```bash
git commit -m "commit message" --no-verify
```
3. **Push to Individual remotes**:
Always commit inside the specific project folder, and push to the matching Gitea remote (e.g., `git push coolify_agent_gitea branch-name` for `vibn-agent-runner`).
3. **Push to Individual remotes (the ONLY way changes reach production)**:
Commit in `master-ai`, then push the relevant subfolder's remote. Production never reads from `master-ai` directly — the push to Gitea is the complete hand-off.
```bash
git push coolify_gitea HEAD:main # deploy vibn-frontend
git push coolify_agent_gitea HEAD:main # deploy vibn-agent-runner
git push coolify_api_gitea HEAD:main # deploy vibn-api
git push coolify_telemetry_gitea HEAD:main # deploy vibn-telemetry-service
```
---
## 6. Where We Left Off (As of May 28, 2026)
## 6. Where We Left Off (As of May 31, 2026)
* **Deep-Link Protocol Scheme Resolved**:
Fixed `src-tauri/Info.plist` which was still configured with `com.talkcody` / `talkcody`. macOS Launch Services now correctly maps `vibncode://` deep links directly to the local dev app.
* **Rust Compiling Errors Resolved**:
Patched cargo clippy errors in `dashscope.rs`, `openai_responses_protocol.rs`, and `openai_responses_ws.rs` (collapsed match statements and annotated unused structs).
* **Repositories Synchronized**:
Merged, committed, and pushed all updated code:
* `vibn-code` pushed to Gitea `origin main`.
* `vibn-agent-runner` and `vibn-frontend` modifications pushed to `coolify_agent_gitea` and `coolify_gitea` on branch `frontend-deploy-13`.
**Read `VIBNCODE_THIN_CLIENT_CHANGES.md` first** — it is the live, prioritized change list with exact files,
steps, and acceptance criteria for the thin-client conversion, plus a STATUS section of what's done.
**Chat works end-to-end.** A desktop message → `POST /api/projects/:id/agent/sessions` → cloud runner executes
the Coder agent (Gemini) → output polled back into the Monaco chat. Recent fixes that got it there:
* **Local SQLite was wiping chats (fixed):** `database-service.ts` used `INSERT OR REPLACE INTO projects`, which
(via `ON DELETE CASCADE`) deleted the active conversation mid-run. Switched to UPSERT; made `task-service`
persistence non-blocking. The cloud is the source of truth; local SQLite is just a cache.
* **Empty `appPath` broke every run (fixed):** the desktop sent `appPath: ""`; the runner's `/agent/execute`
rejects falsy `appPath` with HTTP 400 and does nothing (no logs). Desktop now sends `appPath: "."`.
* **Agent tools `fetch failed` (fixed, pushed):** the runner's `buildContext()` hardcoded
`vibnApiUrl: 'http://localhost:3000'` and an empty `mcpToken`, so tool calls fetched a dead port. Now
`/agent/execute` reads `mcpToken` from the body and sets `ctx.vibnApiUrl` (from `VIBN_API_URL`) + `mcpToken`.
Pushed to `coolify_agent_gitea/main` — confirm the runner redeploy.
* **Single model:** desktop model picker restricted to the VibnAI model, relabeled "Gemini 3.5 Flash". The
runner's real model is set by `GEMINI_MODEL` env (currently `gemini-3.1-pro-preview`); the desktop label is
cosmetic until model-passthrough is wired (CHANGE 4.1 in the change doc).
**Known open items (in the change doc):** the desktop still has a hardcoded `vibn_sk_` API key to remove;
`/agent/sessions/:id/stop` returns 401 to the desktop (uses browser-session auth, not the workspace key); runner
early-failures are silently swallowed (failure PATCHes omit the `x-agent-runner-secret` header).
**Earlier (still true):** `vibncode://` deep link scheme is registered in `src-tauri/Info.plist`; Rust clippy is
treated as errors on commit.
---
## 7. Fetching Production Logs (Coolify apps)
The Coolify dashboard (`https://coolify.vibnai.com/...`) is login-walled, so to read an app's logs
programmatically use one of the two paths below. Both read credentials from `vibn-frontend/.env.local`
(`COOLIFY_URL`, `COOLIFY_API_TOKEN`, and `COOLIFY_SSH_HOST` / `COOLIFY_SSH_PORT` / `COOLIFY_SSH_USER` /
`COOLIFY_SSH_PRIVATE_KEY_B64`).
**The `<appUuid>` is the last path segment of the Coolify app URL:**
`.../application/y4cscsc8s08c8808go0448s0` -> appUuid = `y4cscsc8s08c8808go0448s0`.
| App | appUuid | Build pack | Notes |
|---|---|---|---|
| `vibn-frontend` | `y4cscsc8s08c8808go0448s0` | dockerfile | Next.js, port 3000, fqdn vibnai.com |
| `vibn-telemetry` | `hou4vy5mtyg5mrx3w4nl2lxv` | dockerfile | port 4000; usage data lives in its **DB**, not stdout |
### Method A - Coolify REST API (simplest)
`GET {COOLIFY_URL}/api/v1/applications/{uuid}/logs?lines=N` with `Authorization: Bearer {COOLIFY_API_TOKEN}`.
Returns `{ logs: "..." }`. Works for dockerfile / nixpacks / static apps; returns **empty for `dockercompose`**
(Coolify can't pick which service to tail). Helper script:
```bash
cd vibn-frontend
node scripts/fetch-app-logs.mjs <appUuid> [lines] # reads .env.local itself
```
### Method B - SSH + `docker logs` (full history, timestamps, date filter)
Use when the REST endpoint returns little/nothing (compose apps, or quiet services). Connects to the host with
the `ssh2` lib and runs `docker logs` against the app's container(s). Coolify names containers
`{appUuid}-{hash}`; a zero-downtime deploy briefly leaves TWO containers (old draining + new). Helper script:
```bash
cd vibn-frontend
# everything since the start of a UTC day (note: logs are UTC):
node --env-file=.env.local scripts/fetch-app-logs-ssh.mjs <appUuid> 2026-06-12
# last 500 lines, no date filter:
node --env-file=.env.local scripts/fetch-app-logs-ssh.mjs <appUuid> "" 500
# target a specific container during a rollout (substring match):
node --env-file=.env.local scripts/fetch-app-logs-ssh.mjs <appUuid>-003647723804 2026-06-12
```
Under the hood (reusable one-off): `runOnCoolifyHost()` in `lib/coolify-ssh.ts`, or for the compose-aware
unified fetcher, `getApplicationRuntimeLogs()` in `lib/coolify-logs.ts` (API first, SSH `docker logs` fallback).
List an app's containers: `docker ps -a --filter name=<appUuid> --format '{{.Names}}\t{{.Status}}'`.
### Important caveat - container logs are NOT "usage logs"
Both the Next.js frontend (production server) and the telemetry service only emit **startup lines + explicit
`console.error`** to stdout - they do NOT log per-request activity. So `docker logs` is the right tool for
**deploy health and crashes/errors**, but for actual product **usage** you must query the data store:
- **Telemetry / usage** is written to Postgres by `vibn-telemetry-service` (its own `DATABASE_URL`). The existing
extractors (`vibn-frontend/scripts/extract-live-telemetry.ts`, `extract-ui-telemetry.ts`) pull telemetry by
querying `fs_chat_threads` / `fs_chat_messages` via `DATABASE_URL` - copy their pattern for date-ranged usage.
- **Runtime errors** at scale are captured by **Sentry** (auto-provisioned per project), not container stdout.
### Verifying a deploy landed
`GET /api/v1/applications/{uuid}` returns `status` (`running:healthy` when good). On a fresh deploy the new
container shows `Up About a minute (healthy)` and the previous one disappears once draining completes.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"sections":{"app-navs":{"labels":{"sidebar":"01 · Sidebar w/ workspaces"}}}}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Atlas — Two-sided marketplace templates</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Inter+Tight:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,300;1,400&family=DM+Serif+Display:ital@0;1&display=swap" rel="stylesheet">
<link rel="stylesheet" href="vibn-ai-templates/tokens.css">
<link rel="stylesheet" href="vibn-marketplace/marketplace-tokens.css">
<style>
html, body { margin: 0; padding: 0; min-height: 100%; background: #f0eee9; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="design-canvas.jsx"></script>
<!-- Vibn base library -->
<script type="text/babel" src="vibn-ai-templates/icons.jsx"></script>
<script type="text/babel" src="vibn-ai-templates/components.jsx"></script>
<script type="text/babel" src="vibn-ai-templates/shells.jsx"></script>
<!-- Marketplace extension -->
<script type="text/babel" src="vibn-marketplace/marketplace-components.jsx"></script>
<script type="text/babel" src="vibn-marketplace/marketplace-shells.jsx"></script>
<script type="text/babel" src="atlas-pages.jsx"></script>
<script type="text/babel">
const { DesignCanvas, DCSection, DCArtboard } = window;
const {
AtlasHome, AtlasSearch, AtlasListing, AtlasCheckout,
AtlasMessages, AtlasGuestDash, AtlasHostDash, AtlasNewListing,
} = window;
// Wrap each page in .theme-flux — modern dark-glass aesthetic
// with violet/fuchsia aurora backdrop. The marketplace components
// are theme-aware, so swapping the class to `theme-atlas` or any
// other theme re-skins the whole tree.
const Atlas = ({ children }) => (
<div className="theme-flux" style={{ width: "100%", height: "100%" }}>{children}</div>
);
const W = 1440, H = 900;
function App() {
return (
<DesignCanvas>
<DCSection
id="public"
title="Public-facing · discovery → booking"
subtitle="The guest's path from landing to confirmed booking."
>
<DCArtboard id="home" label="01 · Home / discovery" width={W} height={1500}><Atlas><AtlasHome/></Atlas></DCArtboard>
<DCArtboard id="search" label="02 · Search results + map" width={W} height={H}><Atlas><AtlasSearch/></Atlas></DCArtboard>
<DCArtboard id="listing" label="03 · Listing detail" width={W} height={2400}><Atlas><AtlasListing/></Atlas></DCArtboard>
<DCArtboard id="checkout" label="04 · Checkout" width={W} height={1100}><Atlas><AtlasCheckout/></Atlas></DCArtboard>
</DCSection>
<DCSection
id="guest"
title="Guest (demand-side) experience"
subtitle="Post-booking: managing trips and talking to hosts."
>
<DCArtboard id="g-trips" label="05 · Guest dashboard · trips" width={W} height={H}><Atlas><AtlasGuestDash/></Atlas></DCArtboard>
<DCArtboard id="messages" label="06 · Messages inbox" width={W} height={H}><Atlas><AtlasMessages/></Atlas></DCArtboard>
</DCSection>
<DCSection
id="host"
title="Host (supply-side) experience"
subtitle="Earnings, calendar and listing creation."
>
<DCArtboard id="h-today" label="07 · Host dashboard · today" width={W} height={H}><Atlas><AtlasHostDash/></Atlas></DCArtboard>
<DCArtboard id="h-new" label="08 · New listing · step 3 of 6" width={W} height={H}><Atlas><AtlasNewListing/></Atlas></DCArtboard>
</DCSection>
</DesignCanvas>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>

View File

@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Auth screens · 3 aesthetics × 3 flows</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #f0eee9; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="design-canvas.jsx"></script>
<script type="text/babel" src="auth-style-a.jsx"></script>
<script type="text/babel" src="auth-style-b.jsx"></script>
<script type="text/babel" src="auth-style-c.jsx"></script>
<script type="text/babel">
const { DesignCanvas, DCSection, DCArtboard } = window;
const W = 1440, H = 900;
function App() {
return (
<DesignCanvas>
<DCSection
id="style-a"
title="A · Light minimal"
subtitle="Centered card on warm neutral. Pairs with the Sidebar nav style."
>
<DCArtboard id="a-signin" label="Sign in" width={W} height={H}><ASignIn/></DCArtboard>
<DCArtboard id="a-signup" label="Sign up" width={W} height={H}><ASignUp/></DCArtboard>
<DCArtboard id="a-onboarding" label="Onboarding · workspace" width={W} height={H}><AOnboarding/></DCArtboard>
</DCSection>
<DCSection
id="style-b"
title="B · Dark split-hero"
subtitle="Storytelling panel + form. Pairs with the Top horizontal / ⌘K nav."
>
<DCArtboard id="b-signin" label="Sign in" width={W} height={H}><BSignIn/></DCArtboard>
<DCArtboard id="b-signup" label="Sign up" width={W} height={H}><BSignUp/></DCArtboard>
<DCArtboard id="b-onboarding" label="Onboarding · personalise" width={W} height={H}><BOnboarding/></DCArtboard>
</DCSection>
<DCSection
id="style-c"
title="C · Glass aurora"
subtitle="Vibrant gradient + frosted card. Pairs with the Floating-pill marketing nav."
>
<DCArtboard id="c-signin" label="Sign in" width={W} height={H}><CSignIn/></DCArtboard>
<DCArtboard id="c-signup" label="Sign up" width={W} height={H}><CSignUp/></DCArtboard>
<DCArtboard id="c-onboarding" label="Onboarding · invite team" width={W} height={H}><COnboarding/></DCArtboard>
</DCSection>
</DesignCanvas>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>

View File

@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Cadence CRM — Sidebar template package</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="vibn-ai-templates/tokens.css">
<style>
html, body { margin: 0; padding: 0; min-height: 100%; background: #f0eee9; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="design-canvas.jsx"></script>
<script type="text/babel" src="vibn-ai-templates/icons.jsx"></script>
<script type="text/babel" src="vibn-ai-templates/components.jsx"></script>
<script type="text/babel" src="vibn-ai-templates/shells.jsx"></script>
<script type="text/babel" src="vibn-crm/crm-onboarding.jsx"></script>
<script type="text/babel" src="vibn-crm/crm-pages.jsx"></script>
<script type="text/babel">
const { DesignCanvas, DCSection, DCArtboard,
CRMSignUp, CRMSignIn,
CRMOnbWorkspace, CRMOnbAbout, CRMOnbImport, CRMOnbInvite,
CRMHome, CRMPeople, CRMRecord, CRMPipeline, CRMInbox, CRMReports, CRMSettings } = window;
// Everything renders in the light/minimal theme (the sidebar style).
const M = ({ children }) => (
<div className="theme-minimal" style={{ width: "100%", height: "100%" }}>{children}</div>
);
const W = 1440, H = 900;
function App() {
return (
<DesignCanvas>
<DCSection id="auth" title="Sign up & sign in"
subtitle="Full-screen, same minimal aesthetic as the app.">
<DCArtboard id="signup" label="Sign up" width={W} height={H}><M><CRMSignUp/></M></DCArtboard>
<DCArtboard id="signin" label="Sign in" width={W} height={H}><M><CRMSignIn/></M></DCArtboard>
</DCSection>
<DCSection id="onboarding" title="Onboarding · 4 steps"
subtitle="Workspace → about you → import → invite. Stepper-driven.">
<DCArtboard id="onb-1" label="01 · Name workspace" width={W} height={H}><M><CRMOnbWorkspace/></M></DCArtboard>
<DCArtboard id="onb-2" label="02 · About your team" width={W} height={H}><M><CRMOnbAbout/></M></DCArtboard>
<DCArtboard id="onb-3" label="03 · Import contacts" width={W} height={H}><M><CRMOnbImport/></M></DCArtboard>
<DCArtboard id="onb-4" label="04 · Invite team" width={W} height={H}><M><CRMOnbInvite/></M></DCArtboard>
</DCSection>
<DCSection id="app" title="In-app · Sidebar style"
subtitle="The far-left sidebar nav across every core CRM screen.">
<DCArtboard id="home" label="Home" width={W} height={H}><M><CRMHome/></M></DCArtboard>
<DCArtboard id="people" label="People · table" width={W} height={H}><M><CRMPeople/></M></DCArtboard>
<DCArtboard id="record" label="Company record" width={W} height={H}><M><CRMRecord/></M></DCArtboard>
<DCArtboard id="pipeline" label="Deals · pipeline" width={W} height={H}><M><CRMPipeline/></M></DCArtboard>
<DCArtboard id="inbox" label="Inbox" width={W} height={H}><M><CRMInbox/></M></DCArtboard>
<DCArtboard id="reports" label="Reports" width={W} height={H}><M><CRMReports/></M></DCArtboard>
<DCArtboard id="settings" label="Settings · members" width={W} height={H}><M><CRMSettings/></M></DCArtboard>
</DCSection>
</DesignCanvas>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>

View File

@@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Modern website design styles · 2026 sampler</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #f0eee9; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&family=Space+Grotesk:wght@400;500;600;700;800&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="design-canvas.jsx"></script>
<script type="text/babel" src="styles.jsx"></script>
<script type="text/babel">
const { DesignCanvas, DCSection, DCArtboard, DCPostIt } = window;
// Each artboard is a 1280×800 desktop hero so the styles read as full
// landing pages, not crops. They're laid out in three thematic rows so
// the user can compare neighbours and skim the whole field at once.
const W = 1280, H = 800;
function App() {
return (
<DesignCanvas>
<DCSection
id="restrained"
title="The restrained school"
subtitle="Type-led, gridded, lots of white space — the editorial revival."
>
<DCArtboard id="editorial" label="01 · Editorial Swiss" width={W} height={H}><StyleEditorial /></DCArtboard>
<DCArtboard id="minimal" label="02 · Minimal mono" width={W} height={H}><StyleMinimal /></DCArtboard>
<DCArtboard id="organic" label="03 · Organic / warm serif" width={W} height={H}><StyleOrganic /></DCArtboard>
</DCSection>
<DCSection
id="product"
title="The product-led school"
subtitle="Dark UI, bento grids, frosted glass — what modern SaaS sites look like in 2026."
>
<DCArtboard id="bento" label="04 · Dark bento" width={W} height={H}><StyleBento /></DCArtboard>
<DCArtboard id="aurora" label="05 · Glass / Aurora" width={W} height={H}><StyleAurora /></DCArtboard>
<DCArtboard id="terminal" label="06 · Terminal mono" width={W} height={H}><StyleTerminal /></DCArtboard>
<DCArtboard id="cyber" label="07 · Cyber / neon grid" width={W} height={H}><StyleCyber /></DCArtboard>
</DCSection>
<DCSection
id="expressive"
title="The expressive school"
subtitle="Loud, opinionated, hand-feeling. Pushback against grid-perfect SaaS."
>
<DCArtboard id="brutalist" label="08 · Neo-brutalism" width={W} height={H}><StyleBrutalist /></DCArtboard>
<DCArtboard id="maximalist" label="09 · Maximalist Y2K" width={W} height={H}><StyleMaximalist /></DCArtboard>
<DCArtboard id="anti" label="10 · Anti-design" width={W} height={H}><StyleAntiDesign /></DCArtboard>
</DCSection>
</DesignCanvas>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>

View File

@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>4 modern SaaS nav layouts</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #f0eee9; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="design-canvas.jsx"></script>
<script type="text/babel" src="nav-styles.jsx"></script>
<script type="text/babel">
const { DesignCanvas, DCSection, DCArtboard } = window;
const W = 1440, H = 900;
function App() {
return (
<DesignCanvas>
<DCSection
id="app-navs"
title="App navigation"
subtitle="In-product chrome for an authenticated workspace."
>
<DCArtboard id="sidebar" label="01 · Sidebar w/ workspaces" width={W} height={H}>
<NavSidebar />
</DCArtboard>
<DCArtboard id="rail" label="02 · Icon rail + secondary panel" width={W} height={H}>
<NavIconRail />
</DCArtboard>
<DCArtboard id="topbar" label="03 · Top horizontal + ⌘K bar" width={W} height={H}>
<NavTopHorizontal />
</DCArtboard>
</DCSection>
<DCSection
id="marketing-nav"
title="Marketing navigation"
subtitle="Public-facing homepage chrome."
>
<DCArtboard id="glasspill" label="04 · Floating glass pill" width={W} height={H}>
<NavFloatingGlass />
</DCArtboard>
</DCSection>
</DesignCanvas>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>

View File

@@ -0,0 +1,220 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>SaaS pages × 3 nav styles · Lattice</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #f0eee9; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="design-canvas.jsx"></script>
<script type="text/babel" src="app-chrome.jsx"></script>
<script type="text/babel" src="page-customer.jsx"></script>
<script type="text/babel" src="page-dashboard.jsx"></script>
<script type="text/babel" src="page-admin.jsx"></script>
<script type="text/babel">
const { DesignCanvas, DCSection, DCArtboard,
SidebarChrome, RailChrome, RailItem, RailSectionHeader, TopbarChrome,
Icon, P } = window;
const W = 1440, H = 900;
// ── Secondary panel content per page, for the dark rail chrome ─────
// Companies list — relevant context next to a customer/company page
const CompaniesPanel = () => (
<>
<RailSectionHeader action={<Icon d={P.plus} size={12} />}>
Pinned
</RailSectionHeader>
{[
["NS", "Northstar Logistics", "Tier 1 · EMEA", "#f6c560", true],
["HC", "Halcyon", "Renewal Q3", "#a8c8e8"],
["KS", "Kestrel", "Pilot", "#c8e8a8"],
].map(([i, n, s, col, active]) => (
<RailItem key={i} active={active}
leading={<div style={{
width: 24, height: 24, borderRadius: 5, background: col,
display: "flex", alignItems: "center", justifyContent: "center",
color: "#3a2210", fontSize: 10, fontWeight: 700,
}}>{i}</div>}
label={n} sub={s} />
))}
<RailSectionHeader>All companies · 248</RailSectionHeader>
{[
["BF","Brooke Foods", "added 2 days", "#e8c8a8"],
["MV","Moss & Verra", "added 5 days", "#c8a8e8"],
["TD","Tide Co.", "added a week", "#a8e8c8"],
["VR","Verra Tech", "added a week", "#e8a87c"],
["LW","Lowell Works", "added 2 weeks", "#a8c8e8"],
["OK","Okra Studios", "added 3 weeks", "#e8a8c8"],
].map(([i, n, s, col]) => (
<RailItem key={i}
leading={<div style={{
width: 24, height: 24, borderRadius: 5, background: col,
display: "flex", alignItems: "center", justifyContent: "center",
color: "#3a2210", fontSize: 10, fontWeight: 700,
}}>{i}</div>}
label={n} sub={s} />
))}
</>
);
// Saved dashboards / reports — context for the dashboard page
const DashboardsPanel = () => (
<>
<RailSectionHeader action={<Icon d={P.plus} size={12} />}>
My dashboards
</RailSectionHeader>
{[
["Workspace overview", "default", true],
["Revenue · weekly", "shared by Theo"],
["Pipeline health", "auto-refresh 5m"],
["Team performance", "private"],
].map(([n, s, active]) => (
<RailItem key={n} active={active}
leading={<span style={{ color: "#9a9aa6", display: "flex" }}>
<Icon d={P.bar} size={14} />
</span>}
label={n} sub={s} />
))}
<RailSectionHeader>Shared with me</RailSectionHeader>
{[
["Q2 board review", "from Mira"],
["Marketing funnel", "from Devi"],
["Customer success", "from Sun"],
["Churn watch", "from Theo"],
].map(([n, s]) => (
<RailItem key={n}
leading={<span style={{ color: "#9a9aa6", display: "flex" }}>
<Icon d={P.bar} size={14} />
</span>}
label={n} sub={s} />
))}
</>
);
// Settings tree — context for the admin page
const SettingsPanel = () => (
<>
<RailSectionHeader>Workspace</RailSectionHeader>
{[
["General", P.settings],
["Members", P.people, true],
["Roles", P.check],
["Teams", P.people],
["Integrations", P.workflow],
["Billing", P.target],
["API & Webhooks", P.workflow],
["Audit log", P.doc],
].map(([n, ico, active]) => (
<RailItem key={n} active={active}
leading={<span style={{
color: active ? "#fff" : "#9a9aa6", display: "flex",
}}><Icon d={ico} size={14} /></span>}
label={n} />
))}
<RailSectionHeader>Personal</RailSectionHeader>
{[
["Profile", P.people],
["Notifications", P.bell],
["Sessions", P.target],
].map(([n, ico]) => (
<RailItem key={n}
leading={<span style={{ color: "#9a9aa6", display: "flex" }}>
<Icon d={ico} size={14} />
</span>}
label={n} />
))}
</>
);
// Tabs per page for the dark top bar
const customerTabs = ["Overview", "Activity", "People", "Notes", "Files"];
const dashboardTabs = ["Overview", "Reports", "Goals", "Anomalies", "Custom"];
const adminTabs = ["General", "Members", "Roles", "Integrations", "Billing", "API"];
function App() {
return (
<DesignCanvas>
<DCSection
id="customer"
title="Customer / company page"
subtitle="A CRM record — same content, three nav shells."
>
<DCArtboard id="cust-sidebar" label="Sidebar nav" width={W} height={H}>
<SidebarChrome active="companies"><CustomerBody theme="light"/></SidebarChrome>
</DCArtboard>
<DCArtboard id="cust-rail" label="Icon rail + secondary" width={W} height={H}>
<RailChrome active="companies" secondary={<CompaniesPanel/>}>
<CustomerBody theme="dark"/>
</RailChrome>
</DCArtboard>
<DCArtboard id="cust-topbar" label="Top horizontal + ⌘K" width={W} height={H}>
<TopbarChrome tabs={customerTabs} activeTab="Activity"
breadcrumb="northstar-logistics">
<CustomerBody theme="light"/>
</TopbarChrome>
</DCArtboard>
</DCSection>
<DCSection
id="dashboard"
title="Dashboard page"
subtitle="KPIs, time-series, funnel and activity."
>
<DCArtboard id="dash-sidebar" label="Sidebar nav" width={W} height={H}>
<SidebarChrome active="home"><DashboardBody theme="light"/></SidebarChrome>
</DCArtboard>
<DCArtboard id="dash-rail" label="Icon rail + secondary" width={W} height={H}>
<RailChrome active="home" secondary={<DashboardsPanel/>}>
<DashboardBody theme="dark"/>
</RailChrome>
</DCArtboard>
<DCArtboard id="dash-topbar" label="Top horizontal + ⌘K" width={W} height={H}>
<TopbarChrome tabs={dashboardTabs} activeTab="Overview"
breadcrumb="dashboard">
<DashboardBody theme="light"/>
</TopbarChrome>
</DCArtboard>
</DCSection>
<DCSection
id="admin"
title="Admin page"
subtitle="Workspace settings → Members table."
>
<DCArtboard id="admin-sidebar" label="Sidebar nav" width={W} height={H}>
<SidebarChrome active="settings"><AdminBody theme="light"/></SidebarChrome>
</DCArtboard>
<DCArtboard id="admin-rail" label="Icon rail + secondary" width={W} height={H}>
<RailChrome active="settings" secondary={<SettingsPanel/>}>
<AdminBody theme="dark"/>
</RailChrome>
</DCArtboard>
<DCArtboard id="admin-topbar" label="Top horizontal + ⌘K" width={W} height={H}>
<TopbarChrome tabs={adminTabs} activeTab="Members"
breadcrumb="settings">
<AdminBody theme="light"/>
</TopbarChrome>
</DCArtboard>
</DCSection>
</DesignCanvas>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>

View File

@@ -0,0 +1,38 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Vibn — Sign in</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="icon" type="image/png" href="assets/logo-black.png" />
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="auth.css" />
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<template id="__bundler_thumbnail">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<rect width="200" height="200" fill="#27201d"/>
<circle cx="100" cy="100" r="56" fill="url(#g)"/>
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#ff6b47"/>
<stop offset="100%" stop-color="#c2410c"/>
</linearGradient>
</defs>
<g transform="translate(72 78)" fill="#1a0f0a" stroke="#1a0f0a" stroke-width="1.5" stroke-linejoin="round">
<path d="M0 0 L11 0 L14 24 L17 0 L28 0 L17 44 Z"/>
<rect x="33" y="36" width="18" height="7" rx="1"/>
</g>
</svg>
</template>
<div id="root"></div>
<script type="text/babel" src="auth-shared.jsx"></script>
<script type="text/babel" src="signin.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Vibn — Create your account</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="icon" type="image/png" href="assets/logo-black.png" />
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="auth.css" />
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="auth-shared.jsx"></script>
<script type="text/babel" src="signup.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,687 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Vibn AI Templates — UI showcase</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=DM+Serif+Display:ital@0;1&display=swap" rel="stylesheet">
<link rel="stylesheet" href="vibn-ai-templates/tokens.css">
<style>
html, body { margin: 0; padding: 0; min-height: 100%; background: #f0eee9; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="design-canvas.jsx"></script>
<script type="text/babel" src="vibn-ai-templates/icons.jsx"></script>
<script type="text/babel" src="vibn-ai-templates/components.jsx"></script>
<script type="text/babel" src="vibn-ai-templates/shells.jsx"></script>
<script type="text/babel">
const { DesignCanvas, DCSection, DCArtboard,
Button, IconButton, Field, Input, Textarea, Select, FieldGroup,
Checkbox, Radio, Switch, Card, CardHeader, Divider,
Badge, Avatar, AvatarStack, Tabs, Table, Modal, Banner, KBD, Spinner,
SidebarShell, TopbarShell, RailShell,
AuthCenteredShell, AuthSplitShell, AuthGlassShell,
Icon, icons, VibnMark } = window;
// ─── Section helpers ────────────────────────────────────────
const SubHeading = ({ children }) => (
<div style={{
fontSize: "var(--text-xs)", color: "var(--text-3)",
letterSpacing: "0.08em", textTransform: "uppercase",
fontWeight: 500, marginBottom: 10,
}}>{children}</div>
);
const ThemeFrame = ({ theme, children }) => (
// Themed wrapper — note: the artboard contents must be wrapped in
// a theme class so all CSS-var reads inside re-bind to that theme.
<div className={`theme-${theme}`} style={{ width: "100%", height: "100%" }}>
<div className="vibn-app" style={{ width: "100%", height: "100%", overflow: "auto" }}>
{children}
</div>
</div>
);
// ─── 1 · Foundations / token swatches ───────────────────────
const Foundations = ({ theme }) => (
<div style={{ padding: 32 }}>
<h1 style={{
margin: 0, fontFamily: "var(--font-display)",
fontSize: "var(--text-3xl)", letterSpacing: "-0.02em", fontWeight: 500,
}}>Theme · {theme}</h1>
<p style={{ color: "var(--text-2)", marginTop: 6, fontSize: "var(--text-md)" }}>
Same components, four CSS-variable themes. Tokens, type and surfaces.
</p>
<div style={{ marginTop: 28, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20 }}>
<Card>
<CardHeader title="Surface" subtitle="Page chrome + cards"/>
<div style={{ display: "grid", gridTemplateColumns: "repeat(5, 1fr)", gap: 10 }}>
{[
["--bg", "Page bg"],
["--surface", "Card"],
["--surface-2", "Card alt"],
["--surface-alt", "Sidebar"],
["--border", "Border"],
].map(([v, l]) => (
<div key={v}>
<div style={{
height: 56, borderRadius: "var(--radius)",
background: `var(${v})`, border: "1px solid var(--border)",
}}/>
<div style={{ fontSize: 11, color: "var(--text-3)", marginTop: 6 }}>{l}</div>
<div style={{ fontSize: 10, fontFamily: "var(--font-mono)", color: "var(--text-3)" }}>{v}</div>
</div>
))}
</div>
</Card>
<Card>
<CardHeader title="Accents & semantics"/>
<div style={{ display: "grid", gridTemplateColumns: "repeat(5, 1fr)", gap: 10 }}>
{[
["--accent", "Accent"],
["--accent-2", "Accent 2"],
["--success", "Success"],
["--warn", "Warn"],
["--danger", "Danger"],
].map(([v, l]) => (
<div key={v}>
<div style={{ height: 56, borderRadius: "var(--radius)", background: `var(${v})` }}/>
<div style={{ fontSize: 11, color: "var(--text-3)", marginTop: 6 }}>{l}</div>
<div style={{ fontSize: 10, fontFamily: "var(--font-mono)", color: "var(--text-3)" }}>{v}</div>
</div>
))}
</div>
</Card>
<Card>
<CardHeader title="Type scale"/>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{[
["Display · 38", { fontSize: 38, fontWeight: 500, fontFamily: "var(--font-display)", letterSpacing: "-0.02em" }],
["Heading · 22", { fontSize: 22, fontWeight: 600, letterSpacing: "-0.01em" }],
["Body · 13", { fontSize: 13 }],
["Caption · 11", { fontSize: 11, color: "var(--text-3)" }],
["Mono · 12", { fontFamily: "var(--font-mono)", fontSize: 12 }],
].map(([l, s], i) => (
<div key={i} style={{ ...s }}>{l} Modern SaaS, designed for everyone.</div>
))}
</div>
</Card>
<Card>
<CardHeader title="Radii, shadows, motion"/>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 12, marginBottom: 14 }}>
{["sm", "", "lg"].map(s => (
<div key={s} style={{
height: 50,
background: "var(--surface-2)",
border: "1px solid var(--border)",
borderRadius: `var(--radius${s ? `-${s}` : ""})`,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 11, color: "var(--text-3)",
}}>radius-{s || "default"}</div>
))}
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 12 }}>
{[
["shadow-sm", "var(--shadow-sm)"],
["shadow", "var(--shadow)"],
["shadow-lg", "var(--shadow-lg)"],
].map(([l, sh]) => (
<div key={l} style={{
height: 50, background: "var(--surface)",
border: "1px solid var(--border)", borderRadius: "var(--radius)",
boxShadow: sh, display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 11, color: "var(--text-3)",
}}>{l}</div>
))}
</div>
</Card>
</div>
</div>
);
// ─── 2 · Form atoms ─────────────────────────────────────────
const FormAtoms = () => {
const [tab, setTab] = React.useState("Account");
const [sw1, setSw1] = React.useState(true);
const [sw2, setSw2] = React.useState(false);
const [chk, setChk] = React.useState(true);
const [seg, setSeg] = React.useState("Week");
return (
<div style={{ padding: 32 }}>
<h1 style={{
margin: 0, fontFamily: "var(--font-display)", fontSize: "var(--text-2xl)",
fontWeight: 500, letterSpacing: "-0.02em",
}}>Forms & buttons</h1>
<div style={{ marginTop: 24, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20 }}>
<Card>
<CardHeader title="Buttons" subtitle="Variants, sizes, states"/>
<SubHeading>Variants</SubHeading>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 16 }}>
<Button>Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="destructive">Delete</Button>
<Button loading>Loading</Button>
<Button disabled>Disabled</Button>
</div>
<SubHeading>Sizes & icons</SubHeading>
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
<Button size="sm" leadingIcon={<Icon name="plus" size={12}/>}>New deal</Button>
<Button>Sign in <Icon name="arrow" size={13}/></Button>
<Button size="lg" variant="secondary">Get a demo</Button>
<IconButton name="bell" label="Notifications"/>
<IconButton name="settings" variant="secondary" label="Settings"/>
</div>
</Card>
<Card>
<CardHeader title="Fields" subtitle="Input, hint, error, password"/>
<Field label="Work email" hint="We'll send a 6-digit code.">
<Input value="mira@acme.io" leadingIcon={<Icon name="inbox" size={14}/>} autofocus/>
</Field>
<Field label="Password">
<Input type="password" value="••••••••••"
trailingIcon={<Icon name="eye" size={14}/>}/>
</Field>
<Field label="Workspace name" error="That name is taken.">
<Input value="lattice" invalid/>
</Field>
<Field label="Notes" optional>
<Textarea placeholder="Anything we should know?" rows={3}/>
</Field>
<Field label="Role">
<Select value="Admin" options={["Owner", "Admin", "Member", "Guest"]}/>
</Field>
</Card>
<Card>
<CardHeader title="Controls"/>
<SubHeading>Switches</SubHeading>
<div style={{ display: "flex", flexDirection: "column", gap: 14, marginBottom: 14 }}>
<Switch checked={sw1} onChange={setSw1}
label="Email me digests" hint="Weekly summary every Monday at 9am."/>
<Switch checked={sw2} onChange={setSw2}
label="Show beta features"/>
</div>
<SubHeading>Checkboxes & radios</SubHeading>
<div style={{ display: "flex", flexDirection: "column", gap: 10, marginBottom: 14 }}>
<Checkbox checked={chk} onChange={setChk} label="I agree to the Terms" hint="And the Privacy Policy."/>
<Checkbox checked indeterminate label="Select some items"/>
<div style={{ display: "flex", gap: 16 }}>
<Radio checked={true} label="Monthly"/>
<Radio checked={false} label="Yearly · save 20%"/>
</div>
</div>
<SubHeading>Segmented</SubHeading>
<FieldGroup options={["Day", "Week", "Month", "Quarter"]} value={seg} onChange={setSeg}/>
</Card>
<Card>
<CardHeader title="Tabs"/>
<SubHeading>Underline</SubHeading>
<Tabs items={[
{ label: "Account" }, { label: "Members", count: 8 },
{ label: "Billing" }, { label: "API" },
]} active={tab} onChange={setTab}/>
<div style={{ marginTop: 14 }}>
<SubHeading>Pill</SubHeading>
<Tabs variant="pill" items={[
{ label: "Day" }, { label: "Week" }, { label: "Month" }, { label: "Year" },
]} active="Week"/>
</div>
</Card>
</div>
</div>
);
};
// ─── 3 · Display atoms ──────────────────────────────────────
const DisplayAtoms = () => {
const [modalOpen, setModalOpen] = React.useState(false);
return (
<div style={{ padding: 32 }}>
<h1 style={{
margin: 0, fontFamily: "var(--font-display)", fontSize: "var(--text-2xl)",
fontWeight: 500, letterSpacing: "-0.02em",
}}>Display & feedback</h1>
<div style={{ marginTop: 24, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20 }}>
<Card>
<CardHeader title="Badges & avatars"/>
<SubHeading>Tones</SubHeading>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 16 }}>
<Badge>Neutral</Badge>
<Badge tone="accent" dot>Accent</Badge>
<Badge tone="success" dot>Active</Badge>
<Badge tone="warn" dot>Invited</Badge>
<Badge tone="danger" dot>Suspended</Badge>
<Badge tone="info">v4.2.1</Badge>
</div>
<SubHeading>Avatars</SubHeading>
<div style={{ display: "flex", gap: 14, alignItems: "center" }}>
<Avatar name="Mira Reyes" size={24}/>
<Avatar name="Theo Roux" size={32}/>
<Avatar name="Devi Patel" size={40} status="online"/>
<Avatar name="Sun Kim" size={48} status="busy"/>
<AvatarStack items={[
{name:"Mira Reyes"},{name:"Theo Roux"},{name:"Devi Patel"},
{name:"Sun Kim"},{name:"Ade Nwosu"},{name:"Linnea Berg"},
{name:"Jamal Frost"}
]}/>
</div>
</Card>
<Card>
<CardHeader title="Banners"/>
<Banner title="Workspace upgrade pending" tone="warn"
action={<Button size="sm" variant="secondary">Review</Button>}>
1 invitation hasn't been accepted yet — sent 3 days ago.
</Banner>
<div style={{ height: 10 }}/>
<Banner tone="success" title="Saved">Your changes were saved.</Banner>
<div style={{ height: 10 }}/>
<Banner tone="danger" title="Couldn't connect">Please check your network and try again.</Banner>
</Card>
<Card style={{ gridColumn: "span 2" }}>
<CardHeader title="Table" subtitle="Members of the workspace"
action={<Button size="sm" leadingIcon={<Icon name="plus" size={12}/>}>Invite</Button>}/>
<Table
columns={[
{ key: "name", label: "Name", render: r => (
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<Avatar name={r.name} size={26}/>
<div>
<div style={{ fontWeight: 500 }}>{r.name}</div>
<div style={{ fontSize: 11, color: "var(--text-3)" }}>{r.email}</div>
</div>
</div>
)},
{ key: "role", label: "Role", render: r => <Badge tone="accent">{r.role}</Badge> },
{ key: "status", label: "Status", render: r =>
<Badge dot tone={r.status === "Active" ? "success" :
r.status === "Invited" ? "warn" : "danger"}>{r.status}</Badge> },
{ key: "last", label: "Last active" },
{ key: "act", label: "", align: "right", width: 32,
render: () => <IconButton name="more" size="sm" label="More"/> },
]}
rows={[
{ id: 1, name: "Mira Reyes", email: "mira@vibn.co", role: "Owner", status: "Active", last: "now" },
{ id: 2, name: "Theo Roux", email: "theo@vibn.co", role: "Admin", status: "Active", last: "12 min" },
{ id: 3, name: "Devi Patel", email: "devi@vibn.co", role: "Admin", status: "Active", last: "1 hour" },
{ id: 4, name: "Linnea Berg", email: "linnea@vibn.co", role: "Member", status: "Invited", last: "" },
{ id: 5, name: "Elin Roos", email: "elin@vibn.co", role: "Member", status: "Suspended", last: "14 days" },
]}
selectable
selected={[1, 2]}
/>
</Card>
<Card style={{ gridColumn: "span 2" }}>
<CardHeader title="Modal"/>
<div style={{ display: "flex", gap: 10, alignItems: "center" }}>
<Button onClick={() => setModalOpen(true)}>Open modal</Button>
<span style={{ fontSize: 12, color: "var(--text-3)" }}>
Press <KBD>⌘ + Enter</KBD> to confirm
</span>
</div>
<Modal
open={modalOpen} onClose={() => setModalOpen(false)}
title="Delete workspace?"
description="This will permanently remove all data in Lattice Studio. This action cannot be undone."
footer={<>
<Button variant="secondary" onClick={() => setModalOpen(false)}>Cancel</Button>
<Button variant="destructive">Yes, delete it</Button>
</>}
>
<Field label="Type the workspace name to confirm">
<Input placeholder="lattice-studio"/>
</Field>
</Modal>
</Card>
</div>
</div>
);
};
// ─── 4 · In-product shells ──────────────────────────────────
const SidebarDemo = () => (
<SidebarShell
brand={{ name: "Lattice Studio" }}
sections={[
{ items: [
{ id: "home", label: "Home", icon: "home" },
{ id: "inbox", label: "Inbox", icon: "inbox", count: 12 },
{ id: "tasks", label: "Tasks", icon: "check", count: 3 },
]},
{ title: "Views", items: [
{ id: "co", label: "Companies", icon: "building", active: true },
{ id: "people", label: "People", icon: "people" },
{ id: "deals", label: "Opportunities", icon: "target" },
]},
{ title: "Tools", items: [
{ id: "i", label: "Insights", icon: "bar" },
{ id: "f", label: "Automations", icon: "workflow"},
{ id: "d", label: "Docs", icon: "doc" },
]},
{ title: "Admin", items: [
{ id: "s", label: "Settings", icon: "settings" },
]},
]}
user={{ name: "Mira Reyes", email: "mira@vibn.co" }}
>
<div style={{ padding: 28 }}>
<h1 style={{ margin: 0, fontSize: 26, fontWeight: 600 }}>Companies</h1>
<p style={{ color: "var(--text-2)", fontSize: 13, marginTop: 6 }}>
248 records · last sync 4 minutes ago
</p>
<div style={{ marginTop: 18 }}>
<Banner tone="info" title="Vibn 4.0 is live">
Workspace-wide rollout begins next Monday. Read the changelog →
</Banner>
</div>
</div>
</SidebarShell>
);
const TopbarDemo = () => {
const [tab, setTab] = React.useState("Activity");
return (
<TopbarShell
brand={{ name: "Lattice" }}
breadcrumb={[
{ avatar: "Mira Reyes", label: "mira-reyes" },
{ label: "northstar-logistics", badge: "Pro" },
]}
tabs={[
{ label: "Overview" }, { label: "Activity", count: 18 },
{ label: "People" }, { label: "Notes" }, { label: "Files" },
]}
activeTab={tab}
onTabChange={setTab}
user={{ name: "Mira Reyes" }}
>
<div style={{ padding: 32 }}>
<h1 style={{ margin: 0, fontSize: 28, fontWeight: 600, letterSpacing: "-0.02em" }}>Northstar Logistics</h1>
<div style={{ display: "flex", gap: 8, marginTop: 8 }}>
<Badge tone="success" dot>Customer</Badge>
<Badge tone="accent">Tier 1</Badge>
<Badge>EMEA</Badge>
</div>
<div style={{ marginTop: 24, display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 16 }}>
{[
{ l: "Pipeline", v: "146k", s: "+12k 30d"},
{ l: "Closed-won", v: "220k", s: "lifetime"},
{ l: "Health", v: "82", s: "stable"},
].map(k => (
<Card key={k.l} padding={18}>
<div style={{ fontSize: 11, color: "var(--text-3)", textTransform: "uppercase", letterSpacing: "0.06em" }}>{k.l}</div>
<div style={{ fontSize: 24, fontWeight: 600, marginTop: 6 }}>{k.v}</div>
<div style={{ fontSize: 11, color: "var(--text-2)", marginTop: 2 }}>{k.s}</div>
</Card>
))}
</div>
</div>
</TopbarShell>
);
};
const RailDemo = () => (
<RailShell
brand={{ name: "Vibn" }}
items={[
{ id: "home", icon: "home" },
{ id: "inbox", icon: "inbox", badge: 9 },
{ id: "co", icon: "building" },
{ id: "ppl", icon: "people" },
{ id: "deals", icon: "target", badge: 2 },
]}
activeRail="co"
secondaryTitle="Companies"
secondary={
<div>
{["Northstar Logistics", "Halcyon", "Kestrel", "Mossbank", "Verra", "Brooke Foods"].map((n, i) => (
<div key={n} style={{
padding: "8px 10px", borderRadius: "var(--radius-sm)", fontSize: 13,
background: i === 0 ? "var(--surface-alt)" : "transparent",
color: i === 0 ? "var(--text)" : "var(--text-2)",
display: "flex", alignItems: "center", gap: 10, cursor: "pointer",
}}>
<Avatar name={n} size={22}/>
<span style={{ flex: 1 }}>{n}</span>
{i === 0 && <Badge tone="success" dot>active</Badge>}
</div>
))}
</div>
}
user={{ name: "Mira Reyes" }}
>
<div style={{ padding: 32 }}>
<h1 style={{ margin: 0, fontSize: 24, fontWeight: 600 }}>Northstar Logistics</h1>
<p style={{ color: "var(--text-2)", fontSize: 13, marginTop: 6 }}>
Customer since Aug 2024 · 6 people · €146k pipeline
</p>
</div>
</RailShell>
);
// ─── 5 · Auth shells ────────────────────────────────────────
const SocialRow = () => (
<div style={{ display: "flex", gap: 8 }}>
<Button variant="secondary" full>Google</Button>
<Button variant="secondary" full>Microsoft</Button>
<Button variant="secondary" full>SSO</Button>
</div>
);
const AuthCenteredDemo = () => (
<AuthCenteredShell brand={{ name: "Lattice" }}>
<h1 style={{
margin: 0, fontFamily: "var(--font-display)",
fontSize: "var(--text-xl)", fontWeight: 600, letterSpacing: "-0.01em",
}}>Welcome back</h1>
<p style={{ fontSize: 13, color: "var(--text-2)", margin: "6px 0 22px" }}>
Sign in to your Lattice workspace.
</p>
<SocialRow/>
<Divider label="or with email"/>
<Field label="Email"><Input value="mira@lattice.co" autofocus/></Field>
<Field label="Password"><Input type="password" value=""
trailingIcon={<Icon name="eye" size={14}/>}/></Field>
<Button full>Sign in <Icon name="arrow" size={13}/></Button>
<div style={{ fontSize: 12, textAlign: "center", marginTop: 18, color: "var(--text-2)" }}>
New here? <span style={{ color: "var(--text)", fontWeight: 500 }}>Create an account</span>
</div>
</AuthCenteredShell>
);
const AuthSplitDemo = () => (
<AuthSplitShell
brand={{ name: "Lattice" }}
hero={{
badge: "Lattice 4.0 · agents that draft for you",
headline: "The workspace where good ideas compound.",
sub: "One luminous surface for docs, canvases, contacts and pipelines.",
quote: {
body: "Replaced three tools in our first week. Lattice is what every CRM should have been.",
author: "Devi Patel", role: "Head of Sales, Halcyon",
},
}}
>
<h1 style={{
margin: 0, fontFamily: "var(--font-display)", fontSize: 26,
fontWeight: 600, letterSpacing: "-0.02em",
}}>Sign in to Lattice</h1>
<p style={{ fontSize: 13, color: "var(--text-2)", margin: "6px 0 22px" }}>
Welcome back. Pick how you'd like to continue.
</p>
<SocialRow/>
<Divider label="or with email"/>
<Field label="Email"><Input value="mira@lattice.co" autofocus/></Field>
<Field label="Password"><Input type="password" value=""
trailingIcon={<Icon name="eye" size={14}/>}/></Field>
<Button full>Sign in <Icon name="arrow" size={13}/></Button>
<div style={{ marginTop: 18 }}>
<Banner tone="info" title="SAML / SSO for your company?"
action={<Button size="sm" variant="ghost">Use SSO →</Button>}>
Single sign-on is available on the Pro plan.
</Banner>
</div>
</AuthSplitShell>
);
const AuthGlassDemo = () => (
<AuthGlassShell brand={{ name: "Lattice" }} eyebrow="BETA · early access">
<h1 style={{
margin: 0, fontFamily: "var(--font-display)", fontSize: 32,
fontWeight: 500, letterSpacing: "-0.03em",
}}>Start your workspace.</h1>
<p style={{ fontSize: 14, color: "var(--text-2)", margin: "10px 0 22px" }}>
Free for 10 people. No card. 60 seconds to set up.
</p>
<SocialRow/>
<Divider label="or with email"/>
<Field label="Work email"><Input value="mira@lattice.co" autofocus/></Field>
<Field label="Password" hint="10+ chars · 1 number · 1 symbol">
<Input type="password" value="" trailingIcon={<Icon name="eye" size={14}/>}/>
</Field>
<Checkbox checked label="I agree to Vibn's Terms and Privacy Policy."
style={{ margin: "4px 0 16px" }}/>
<Button full>Create my workspace <Icon name="arrow" size={13}/></Button>
</AuthGlassShell>
);
// ─── App: design canvas with all 4 themes side-by-side ──────
const W = 1300, H = 800;
const themes = ["minimal", "dark", "glass", "editorial"];
function App() {
return (
<DesignCanvas>
<DCSection
id="foundations"
title="Foundations"
subtitle="The same token surfaces, in four themes. tokens.css is the source of truth."
>
{themes.map(t => (
<DCArtboard key={t} id={`f-${t}`} label={`${t}`} width={W} height={H}>
<ThemeFrame theme={t}><Foundations theme={t}/></ThemeFrame>
</DCArtboard>
))}
</DCSection>
<DCSection
id="forms"
title="Forms & buttons"
subtitle="Button variants, every field type, controls, tabs."
>
{themes.map(t => (
<DCArtboard key={t} id={`forms-${t}`} label={`${t}`} width={W} height={H}>
<ThemeFrame theme={t}><FormAtoms/></ThemeFrame>
</DCArtboard>
))}
</DCSection>
<DCSection
id="display"
title="Display & feedback"
subtitle="Badges, avatars, banners, table, modal."
>
{themes.map(t => (
<DCArtboard key={t} id={`display-${t}`} label={`${t}`} width={W} height={H + 100}>
<ThemeFrame theme={t}><DisplayAtoms/></ThemeFrame>
</DCArtboard>
))}
</DCSection>
<DCSection
id="shells-app"
title="In-product shells · Sidebar"
subtitle="One shell, four themes."
>
{themes.map(t => (
<DCArtboard key={t} id={`side-${t}`} label={`Sidebar · ${t}`} width={W} height={H}>
<ThemeFrame theme={t}><SidebarDemo/></ThemeFrame>
</DCArtboard>
))}
</DCSection>
<DCSection
id="shells-topbar"
title="In-product shells · Topbar"
subtitle="Breadcrumb + ⌘K + tabs."
>
{themes.map(t => (
<DCArtboard key={t} id={`top-${t}`} label={`Topbar · ${t}`} width={W} height={H}>
<ThemeFrame theme={t}><TopbarDemo/></ThemeFrame>
</DCArtboard>
))}
</DCSection>
<DCSection
id="shells-rail"
title="In-product shells · Rail"
subtitle="Icon rail + secondary panel."
>
{themes.map(t => (
<DCArtboard key={t} id={`rail-${t}`} label={`Rail · ${t}`} width={W} height={H}>
<ThemeFrame theme={t}><RailDemo/></ThemeFrame>
</DCArtboard>
))}
</DCSection>
<DCSection
id="auth-centered"
title="Auth shells · Centered card"
subtitle="Sign-in card on a soft background."
>
{themes.map(t => (
<DCArtboard key={t} id={`auth-c-${t}`} label={`Centered · ${t}`} width={W} height={H}>
<ThemeFrame theme={t}><AuthCenteredDemo/></ThemeFrame>
</DCArtboard>
))}
</DCSection>
<DCSection
id="auth-split"
title="Auth shells · Split hero"
subtitle="Storytelling panel on the left, form on the right."
>
{themes.map(t => (
<DCArtboard key={t} id={`auth-s-${t}`} label={`Split · ${t}`} width={W} height={H}>
<ThemeFrame theme={t}><AuthSplitDemo/></ThemeFrame>
</DCArtboard>
))}
</DCSection>
<DCSection
id="auth-glass"
title="Auth shells · Glass card"
subtitle="Frosted card on a vibrant backdrop."
>
{themes.map(t => (
<DCArtboard key={t} id={`auth-g-${t}`} label={`Glass · ${t}`} width={W} height={H}>
<ThemeFrame theme={t}><AuthGlassDemo/></ThemeFrame>
</DCArtboard>
))}
</DCSection>
</DesignCanvas>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>

View File

@@ -0,0 +1,440 @@
// ============================================================
// app-chrome.jsx — three reusable in-product nav shells.
// Each exposes `<children>` as the main content slot so page
// bodies (Customer/Dashboard/Admin) can be dropped into any
// nav style.
//
// All three share the invented brand "Lattice" + same workspace
// name + same user, so swapping the chrome reads as one product
// in three nav layouts.
// ============================================================
// ── Tiny stroke-icon helper ─────────────────────────────────
const Icon = ({ d, size = 16, sw = 1.6 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth={sw}
strokeLinecap="round" strokeLinejoin="round">{d}</svg>
);
// Common Tabler-style paths
const P = {
search: <><circle cx="11" cy="11" r="7"/><path d="m20 20-3-3"/></>,
bell: <><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10 21a2 2 0 0 0 4 0"/></>,
home: <><path d="m3 12 9-9 9 9"/><path d="M5 10v10h14V10"/></>,
inbox: <><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5 5h14l3 7v7H2v-7z"/></>,
building: <><path d="M3 21h18M5 21V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16"/><path d="M9 7h2M13 7h2M9 11h2M13 11h2M9 15h2M13 15h2"/></>,
people: <><circle cx="9" cy="8" r="4"/><path d="M3 21a6 6 0 0 1 12 0"/><circle cx="17" cy="6" r="3"/><path d="M21 17a4 4 0 0 0-6-3.5"/></>,
target: <><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1"/></>,
check: <><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></>,
bar: <><path d="M3 3v18h18"/><rect x="7" y="11" width="3" height="7"/><rect x="13" y="7" width="3" height="11"/></>,
workflow: <><rect x="3" y="3" width="6" height="6" rx="1"/><rect x="15" y="15" width="6" height="6" rx="1"/><path d="M9 6h6a3 3 0 0 1 3 3v6"/></>,
settings: <><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8L4.2 7a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3 1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8v0a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/><circle cx="12" cy="12" r="3"/></>,
plus: <path d="M12 5v14M5 12h14"/>,
chevron: <path d="m6 9 6 6 6-6"/>,
chevR: <path d="m9 6 6 6-6 6"/>,
doc: <><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6"/></>,
hash: <path d="M9 3l-2 18M17 3l-2 18M3 9h18M2 15h18"/>,
star: <path d="m12 3 2.6 6.2 6.7.5-5.1 4.4 1.6 6.6L12 17.3 6.2 20.7l1.6-6.6L2.7 9.7l6.7-.5z"/>,
spark: <path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l3 3M15 15l3 3M6 18l3-3M15 9l3-3"/>,
more: <><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></>,
dot: <circle cx="12" cy="12" r="3"/>,
};
const SANS = "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif";
// ── Brand mark, shared ───────────────────────────────────────
const LatticeMark = ({ size = 18 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<defs>
<linearGradient id={`lg${size}`} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#6e6cff"/>
<stop offset="100%" stopColor="#b15bff"/>
</linearGradient>
</defs>
<path d="M3 20 L12 4 L21 20 Z" fill={`url(#lg${size})`}/>
</svg>
);
// ============================================================
// SHELL 1 — Sidebar (Linear/Notion/Twenty school)
// ============================================================
const navItems = [
{ id: "home", label: "Home", icon: P.home },
{ id: "inbox", label: "Inbox", icon: P.inbox, count: "12" },
{ id: "tasks", label: "My tasks", icon: P.check, count: "3" },
{ id: "_views", section: "Views" },
{ id: "companies", label: "Companies", icon: P.building },
{ id: "people", label: "People", icon: P.people },
{ id: "deals", label: "Opportunities", icon: P.target },
{ id: "_tools", section: "Tools" },
{ id: "insights", label: "Insights", icon: P.bar },
{ id: "flows", label: "Automations", icon: P.workflow },
{ id: "docs", label: "Docs", icon: P.doc },
{ id: "_admin", section: "Admin" },
{ id: "settings", label: "Settings", icon: P.settings },
];
const SidebarChrome = ({ active = "home", children }) => {
const SideItem = ({ id, label, icon, count }) => {
const isActive = id === active;
return (
<div style={{
display: "flex", alignItems: "center", gap: 10,
padding: "6px 10px", borderRadius: 6, fontSize: 13,
color: isActive ? "#111" : "#5a5a5e",
background: isActive ? "#ffffff" : "transparent",
boxShadow: isActive ? "0 1px 0 #00000008, 0 0 0 1px #00000008" : "none",
fontWeight: isActive ? 500 : 400, cursor: "pointer",
}}>
<span style={{ color: isActive ? "#5e5cff" : "#8a8a90", display: "flex" }}>
<Icon d={icon} size={15} />
</span>
<span style={{ flex: 1 }}>{label}</span>
{count && <span style={{
fontSize: 11, color: "#8a8a90", fontVariantNumeric: "tabular-nums",
}}>{count}</span>}
</div>
);
};
return (
<div style={{
width: "100%", height: "100%", display: "grid",
gridTemplateColumns: "248px 1fr",
background: "#fcfcfb", fontFamily: SANS, color: "#111",
overflow: "hidden",
}}>
<aside style={{
background: "#f5f5f2", borderRight: "1px solid #e8e8e3",
display: "flex", flexDirection: "column",
}}>
<div style={{
padding: "12px 12px", display: "flex", alignItems: "center", gap: 10,
borderBottom: "1px solid #e8e8e3",
}}>
<div style={{
width: 26, height: 26, borderRadius: 6,
background: "linear-gradient(135deg, #6e6cff 0%, #b15bff 100%)",
display: "flex", alignItems: "center", justifyContent: "center",
color: "#fff", fontWeight: 700, fontSize: 13,
}}>L</div>
<div style={{ flex: 1, lineHeight: 1.2 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>Lattice Studio</div>
<div style={{ fontSize: 11, color: "#8a8a90" }}>Free · 4 members</div>
</div>
<span style={{ color: "#8a8a90", display: "flex" }}>
<Icon d={P.chevron} size={14} />
</span>
</div>
<div style={{ padding: "10px 12px" }}>
<div style={{
display: "flex", alignItems: "center", gap: 8, padding: "6px 10px",
background: "#fff", border: "1px solid #e8e8e3", borderRadius: 6,
fontSize: 12, color: "#8a8a90",
}}>
<Icon d={P.search} size={14} />
<span style={{ flex: 1 }}>Search</span>
<span style={{
fontSize: 10, padding: "1px 5px", border: "1px solid #e0e0d8",
borderRadius: 3, fontFamily: "monospace",
}}>K</span>
</div>
</div>
<nav style={{ padding: "4px 8px", flex: 1, overflowY: "auto" }}>
{navItems.map(item => item.section ? (
<div key={item.id} style={{
fontSize: 11, color: "#8a8a90", letterSpacing: "0.04em",
padding: "14px 10px 6px", textTransform: "uppercase",
fontWeight: 500,
}}>{item.section}</div>
) : (
<SideItem key={item.id} {...item} />
))}
</nav>
<div style={{
padding: "10px 12px", borderTop: "1px solid #e8e8e3",
display: "flex", alignItems: "center", gap: 10,
}}>
<div style={{
width: 24, height: 24, borderRadius: "50%", background: "#d4b8a8",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 11, fontWeight: 600, color: "#5a3e34",
}}>MR</div>
<div style={{ flex: 1, fontSize: 12 }}>
<div style={{ fontWeight: 500 }}>Mira Reyes</div>
<div style={{ color: "#8a8a90", fontSize: 11 }}>mira@lattice.co</div>
</div>
<span style={{ color: "#8a8a90", display: "flex" }}>
<Icon d={P.chevron} size={14} />
</span>
</div>
</aside>
<main style={{ overflow: "hidden", display: "flex", flexDirection: "column" }}>
{children}
</main>
</div>
);
};
// ============================================================
// SHELL 2 — Icon rail + secondary panel (Slack/Discord/mail school)
// ============================================================
const railItems = [
{ id: "home", icon: P.home, label: "Home" },
{ id: "inbox", icon: P.inbox, label: "Inbox", badge: "9" },
{ id: "companies", icon: P.building, label: "Companies" },
{ id: "people", icon: P.people, label: "People" },
{ id: "deals", icon: P.target, label: "Opportunities", badge: "2" },
{ id: "insights", icon: P.bar, label: "Insights" },
{ id: "settings", icon: P.settings, label: "Settings" },
];
// Secondary panel content per active rail item — wrapper passes
// in `secondary` so each page can ship its own.
const RailChrome = ({ active = "home", secondary, children }) => {
const activeItem = railItems.find(r => r.id === active) || railItems[0];
const RailIcon = ({ icon, isActive, badge }) => (
<div style={{
width: 40, height: 40, borderRadius: 10,
background: isActive ? "#5e5cff" : "transparent",
color: isActive ? "#fff" : "#9a9aa6",
display: "flex", alignItems: "center", justifyContent: "center",
cursor: "pointer", position: "relative",
}}>
<Icon d={icon} size={18} sw={2} />
{badge && (
<span style={{
position: "absolute", top: -2, right: -2, minWidth: 16, height: 16,
padding: "0 4px", background: "#ff4d5e", color: "#fff",
borderRadius: 8, fontSize: 10, fontWeight: 600,
display: "flex", alignItems: "center", justifyContent: "center",
border: "2px solid #08080c",
}}>{badge}</span>
)}
</div>
);
return (
<div style={{
width: "100%", height: "100%", display: "grid",
gridTemplateColumns: "72px 260px 1fr",
background: "#0f0f14", color: "#e8e8ee", fontFamily: SANS,
overflow: "hidden",
}}>
{/* Icon rail */}
<div style={{
background: "#08080c", borderRight: "1px solid #ffffff08",
display: "flex", flexDirection: "column", alignItems: "center",
padding: "12px 0", gap: 6,
}}>
<div style={{
width: 40, height: 40, borderRadius: 10,
background: "linear-gradient(135deg, #5e5cff 0%, #b15bff 100%)",
display: "flex", alignItems: "center", justifyContent: "center",
color: "#fff", fontWeight: 800, fontSize: 16, marginBottom: 6,
}}>L</div>
<div style={{ width: 24, height: 1, background: "#ffffff10", margin: "4px 0" }}></div>
{railItems.map(r => (
<RailIcon key={r.id} icon={r.icon} isActive={r.id === active} badge={r.badge} />
))}
<div style={{ flex: 1 }}></div>
<RailIcon icon={P.spark} />
<div style={{
width: 32, height: 32, borderRadius: "50%", marginTop: 4,
background: "#d4b8a8", display: "flex", alignItems: "center",
justifyContent: "center", fontSize: 12, fontWeight: 600, color: "#5a3e34",
border: "2px solid #08080c", boxShadow: "0 0 0 2px #5e5cff",
position: "relative",
}}>MR
<span style={{
position: "absolute", bottom: -2, right: -2, width: 11, height: 11,
background: "#22c55e", borderRadius: "50%", border: "2px solid #08080c",
}}></span>
</div>
</div>
{/* Secondary panel */}
<div style={{
background: "#13131a", borderRight: "1px solid #ffffff08",
display: "flex", flexDirection: "column", overflow: "hidden",
}}>
<div style={{
padding: "16px 16px 12px", borderBottom: "1px solid #ffffff08",
}}>
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
marginBottom: 12,
}}>
<span style={{ fontSize: 15, fontWeight: 600 }}>{activeItem.label}</span>
<span style={{ color: "#9a9aa6", display: "flex" }}>
<Icon d={P.more} size={16} />
</span>
</div>
<div style={{
display: "flex", alignItems: "center", gap: 8,
padding: "7px 10px", background: "#08080c",
borderRadius: 7, fontSize: 12, color: "#9a9aa6",
}}>
<Icon d={P.search} size={13} />
<span style={{ flex: 1 }}>Jump to</span>
<span style={{
fontSize: 10, padding: "1px 5px",
background: "#ffffff08", borderRadius: 3, fontFamily: "monospace",
}}>K</span>
</div>
</div>
<div style={{ padding: "10px 8px", flex: 1, overflowY: "auto" }}>
{secondary}
</div>
</div>
{/* Content */}
<main style={{ overflow: "hidden", display: "flex", flexDirection: "column" }}>
{children}
</main>
</div>
);
};
// Convenience list item for the rail's secondary panel (dark theme)
const RailItem = ({ leading, label, sub, trailing, active }) => (
<div style={{
display: "flex", alignItems: "center", gap: 10,
padding: "8px 10px", borderRadius: 6, fontSize: 13,
color: active ? "#fff" : "#dcdce4",
background: active ? "#ffffff14" : "transparent",
cursor: "pointer",
}}>
{leading}
<div style={{ flex: 1, lineHeight: 1.2, minWidth: 0 }}>
<div style={{
fontSize: 13, fontWeight: active ? 500 : 400,
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
}}>{label}</div>
{sub && <div style={{ fontSize: 11, color: "#7a7a85", marginTop: 1 }}>{sub}</div>}
</div>
{trailing}
</div>
);
const RailSectionHeader = ({ children, action }) => (
<div style={{
fontSize: 11, color: "#6a6a78", padding: "12px 10px 4px",
textTransform: "uppercase", letterSpacing: "0.04em", fontWeight: 500,
display: "flex", justifyContent: "space-between", alignItems: "center",
}}>
<span>{children}</span>
{action}
</div>
);
// ============================================================
// SHELL 3 — Top horizontal + ⌘K (Vercel/Stripe school)
// ============================================================
const TopbarChrome = ({ tabs, activeTab, breadcrumb, children }) => {
const TabItem = ({ label, isActive }) => (
<div style={{
padding: "16px 2px", margin: "0 12px", fontSize: 13, fontWeight: 500,
color: isActive ? "#fff" : "#9a9aa6", whiteSpace: "nowrap",
borderBottom: isActive ? "2px solid #fff" : "2px solid transparent",
cursor: "pointer", position: "relative", top: 1,
}}>{label}</div>
);
return (
<div style={{
width: "100%", height: "100%", background: "#fafaf9",
color: "#111", fontFamily: SANS, display: "flex", flexDirection: "column",
overflow: "hidden",
}}>
<header style={{ background: "#0a0a0a", color: "#fff" }}>
<div style={{
display: "flex", alignItems: "center", gap: 14,
padding: "12px 24px",
}}>
<div style={{
display: "flex", alignItems: "center", gap: 8, fontWeight: 600, fontSize: 14,
}}>
<LatticeMark size={20} />
Lattice
</div>
{breadcrumb && (
<>
<span style={{ color: "#3a3a3a" }}>/</span>
<div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13 }}>
<div style={{
width: 18, height: 18, borderRadius: "50%", background: "#e8a87c",
fontSize: 9, fontWeight: 700, color: "#5a3e34",
display: "flex", alignItems: "center", justifyContent: "center",
}}>MR</div>
<span>mira-reyes</span>
<span style={{ color: "#5a5a5e", display: "flex" }}><Icon d={P.chevron} size={12}/></span>
</div>
<span style={{ color: "#3a3a3a" }}>/</span>
<div style={{ fontSize: 13, display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ whiteSpace: "nowrap" }}>{breadcrumb}</span>
</div>
</>
)}
<div style={{ flex: 1 }}></div>
<div style={{
display: "flex", alignItems: "center", gap: 10,
padding: "6px 12px", borderRadius: 8,
background: "#1a1a1a", border: "1px solid #2a2a2a",
color: "#9a9aa6", fontSize: 12, minWidth: 280,
}}>
<Icon d={P.search} size={13} />
<span style={{ flex: 1 }}>Find or jump to anything</span>
<span style={{
fontSize: 10, padding: "1px 5px", background: "#2a2a2a",
borderRadius: 3, fontFamily: "monospace",
}}>K</span>
</div>
<button style={{
background: "transparent", border: "1px solid #2a2a2a",
color: "#fff", padding: "5px 12px", borderRadius: 6,
fontSize: 12, fontFamily: SANS, cursor: "pointer", whiteSpace: "nowrap",
}}>Feedback</button>
<span style={{ color: "#9a9aa6", display: "flex", cursor: "pointer", position: "relative" }}>
<Icon d={P.bell} size={16}/>
<span style={{
position: "absolute", top: -2, right: -2, width: 7, height: 7,
background: "#5e5cff", borderRadius: "50%",
}}></span>
</span>
<div style={{
width: 26, height: 26, borderRadius: "50%", background: "#d4b8a8",
fontSize: 11, fontWeight: 600, color: "#5a3e34",
display: "flex", alignItems: "center", justifyContent: "center",
cursor: "pointer",
}}>MR</div>
</div>
<div style={{
display: "flex", alignItems: "center",
padding: "0 16px", borderBottom: "1px solid #1a1a1a",
overflowX: "auto",
}}>
{(tabs || []).map(t => (
<TabItem key={t} label={t} isActive={t === activeTab} />
))}
</div>
</header>
<div style={{ flex: 1, overflow: "hidden" }}>{children}</div>
</div>
);
};
Object.assign(window, {
Icon, P, SANS, LatticeMark,
SidebarChrome, RailChrome, RailItem, RailSectionHeader, TopbarChrome,
});

View File

@@ -3,7 +3,7 @@
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"accent": ["#ff6b47", "#ffae9a", "#9c3a1f"],
"heroVariant": "promise",
"heroVariant": "owner",
"showStopMarker": true,
"showLivePill": false
}/*EDITMODE-END*/;
@@ -55,8 +55,10 @@ function App() {
<Hero onStart={handleStart} variant={t.heroVariant} />
<Wall />
<CrossedOut />
<Stack />
<Journey />
<Audience />
<Mission />
<Closing />
</main>
<Footer />
@@ -80,12 +82,13 @@ function App() {
/>
</TweakSection>
<TweakSection label="Hero">
<TweakRadio
<TweakSelect
label="Headline"
value={t.heroVariant}
options={[
{ value: "quote", label: "Reddit quote" },
{ value: "promise", label: "The promise" },
{ value: "owner", label: "Owner — 'Stop paying rent'" },
{ value: "promise", label: "Promise — 'Keep vibing'" },
{ value: "quote", label: "Quote — 'I built my product…'" },
]}
onChange={(v) => setTweak("heroVariant", v)}
/>
@@ -125,7 +128,7 @@ function Nav({ scrolled }) {
<a href="#">Stories</a>
</div>
<div className="nav-cta">
<a href="#" className="btn btn-ghost" style={{ display: "inline-flex" }}>Sign in</a>
<a href="Sign In.html" className="btn btn-ghost" style={{ display: "inline-flex" }}>Sign in</a>
<a href="Beta Signup.html" className="btn btn-primary">
Request invite <Arrow size={12} />
</a>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

File diff suppressed because it is too large Load Diff

View File

@@ -5,23 +5,26 @@ const AUDIENCE = [
{
label: "Small business owners",
icon: "shop",
headline: "Stop renting. Build the tool that actually fits.",
quote: "I'm paying $312/month for software that does 60% of what I need and zero of the rest.",
source: "u/coffeeshop_owner · r/smallbusiness",
answer: "Build the tool that actually fits your shop — exactly your workflow, no monthly fee bleed.",
answer: "Replace the whole stack with one tool that matches your workflow — bookings, customers, invoicing, all in one place. Owned by you.",
},
{
label: "Freelancers building for clients",
label: "Freelancers & local builders",
icon: "spark",
headline: "Become the craftsman who builds for businesses in your town.",
quote: "My client wants a quote tool. I can mock the frontend in a day. The backend? Two weeks I don't have.",
source: "u/agency_of_one · r/freelance",
answer: "Deliver the whole thing — login, data, hosting — in the same chat where you built the screens.",
answer: "Deliver the whole thing — login, data, hosting, polish — in the same chat where you built the screens. Bill for the system, not the hours.",
},
{
label: "Anyone with an idea",
label: "Quiet entrepreneurs",
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.",
headline: "Build a business without ever picking up the phone.",
quote: "I want to build my thing, ship my thing, and find my customers — without doing sales calls or talking to a developer.",
source: "u/asynchronous_human · r/indiehackers",
answer: "No deploys. No GitHub. No cold outreach. The thing you described is online, with logins, marketing on autopilot — ready for the right people to find it.",
},
];
@@ -81,6 +84,15 @@ function Audience() {
letter-spacing: -0.015em;
color: var(--fg);
}
.a-headline {
margin: 8px 0 0;
color: var(--accent);
font-size: 15px;
line-height: 1.4;
letter-spacing: -0.005em;
font-weight: 500;
text-wrap: balance;
}
.a-quote {
margin: 18px 0 0;
@@ -132,7 +144,7 @@ function Audience() {
People who have an idea not a stack.
</h2>
<p className="audience-sub">
If you've ever felt this, Vibn was built for you.
Three people who feel the same thing different ways to fix it.
</p>
</div>
@@ -141,6 +153,7 @@ function Audience() {
<div className="a-card" key={a.label}>
<div className="a-icon"><AudienceIcon name={a.icon} /></div>
<div className="a-label">{a.label}</div>
<p className="a-headline">{a.headline}</p>
<div className="a-quote">
"{a.quote}"

View File

@@ -0,0 +1,123 @@
// Shared auth-page primitives. Both Sign In and Sign Up use these.
function Logo({ size = 26 }) {
return (
<a href="index.html" className="logo">
<span className="logo-mark" style={{ width: size, height: size }}>
<svg viewBox="0 0 36 32" width="74%" height="74%" fill="currentColor" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" aria-hidden="true">
<path d="M4 5 L10 5 L12 18 L14 5 L20 5 L12 27 Z" />
<rect x="22.5" y="23" width="9.5" height="3.8" rx="0.7" className="logo-caret" />
</svg>
</span>
<span>vibn</span>
</a>
);
}
function TopBar({ rightLink }) {
return (
<header className="topbar">
<Logo />
{rightLink && (
<a href={rightLink.href} className="topbar-back">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M13 8H3M7 4 3 8l4 4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
{rightLink.label}
</a>
)}
</header>
);
}
function Glows() {
return (
<>
<div className="auth-glow" style={{
width: 700, height: 700,
top: -150, left: "50%", transform: "translateX(-50%)",
background: "radial-gradient(circle at center, oklch(0.74 0.175 35 / 0.22) 0%, transparent 62%)",
}} />
<div className="auth-glow" style={{
width: 500, height: 500,
bottom: -100, left: 0,
background: "radial-gradient(circle at center, oklch(0.45 0.10 35 / 0.20) 0%, transparent 62%)",
}} />
<div className="auth-glow" style={{
width: 450, height: 450,
top: "50%", right: -150,
background: "radial-gradient(circle at center, oklch(0.45 0.10 35 / 0.15) 0%, transparent 62%)",
}} />
</>
);
}
function Arrow({ size = 14 }) {
return (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
}
// Google "G" mark — inline SVG so we don't need to bundle an asset.
function GoogleIcon({ size = 16 }) {
return (
<svg width={size} height={size} viewBox="0 0 18 18" aria-hidden="true">
<path fill="#EA4335" d="M9 3.6c1.3 0 2.5.5 3.4 1.3l2.5-2.5C13.4 1 11.3.1 9 .1 5.5.1 2.4 2.1.9 5.1l2.9 2.3C4.5 5.2 6.6 3.6 9 3.6Z"/>
<path fill="#34A853" d="M17.6 9.2c0-.6-.1-1.2-.2-1.8H9v3.4h4.9c-.2 1.1-.9 2-1.9 2.6l2.9 2.3c1.7-1.6 2.7-3.9 2.7-6.5Z"/>
<path fill="#FBBC05" d="M3.8 10.7c-.2-.6-.3-1.1-.3-1.7s.1-1.2.3-1.7L.9 5C.3 6.2 0 7.5 0 9s.3 2.8.9 4l2.9-2.3Z"/>
<path fill="#4285F4" d="M9 17.9c2.4 0 4.4-.8 5.9-2.2l-2.9-2.3c-.8.5-1.8.9-3 .9-2.3 0-4.3-1.6-5-3.7L1.1 12.9C2.6 15.9 5.6 17.9 9 17.9Z"/>
</svg>
);
}
// Apple mark (filled apple silhouette)
function AppleIcon({ size = 16 }) {
return (
<svg width={size} height={size} viewBox="0 0 18 18" fill="currentColor" aria-hidden="true">
<path d="M14.7 13.1c-.4 1-1 1.9-1.7 2.5-.5.5-1.1.7-1.7.7-.6 0-1-.2-1.7-.5-.7-.3-1.3-.5-1.8-.5s-1.2.2-1.9.5c-.7.3-1.2.4-1.6.5-.6 0-1.2-.2-1.7-.7C2 14.8 1.4 13.6.9 12c-.5-1.7-.7-3.3-.6-4.7.1-1.6.7-2.9 1.7-3.9C2.8 2.7 3.8 2.2 5 2.2c.4 0 .9.1 1.5.4s1 .4 1.2.4c.2 0 .7-.1 1.4-.4.7-.3 1.3-.4 1.7-.4 1 .1 1.9.5 2.6 1.3-.9.6-1.4 1.5-1.4 2.6 0 .9.3 1.6 1 2.2.3.3.6.5 1 .6-.1.2-.2.5-.3.7Zm-3-12c0 .8-.3 1.6-.9 2.4-.7.9-1.6 1.5-2.6 1.4 0-.1 0-.2 0-.3 0-.8.3-1.6.9-2.3.3-.4.7-.7 1.1-.9.4-.2.9-.4 1.4-.4 0 .1 0 .1.1.1Z"/>
</svg>
);
}
function MailIcon({ size = 18 }) {
return (
<svg width={size} height={size} viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<rect x="2.5" y="4.5" width="15" height="11" rx="1.5"/>
<path d="M3.5 6 10 11l6.5-5"/>
</svg>
);
}
function TrustStrip({ items }) {
return (
<div className="auth-trust">
{items.map((item, i) => (
<React.Fragment key={i}>
{i > 0 && <span className="sep">·</span>}
<span>{item}</span>
</React.Fragment>
))}
</div>
);
}
// useResendTimer — manages a countdown for the "Resend in 30s" CTA after the
// magic-link confirmation state. Returns [remaining, restart].
function useResendTimer(initialSeconds = 30) {
const [left, setLeft] = React.useState(initialSeconds);
React.useEffect(() => {
if (left <= 0) return undefined;
const t = setTimeout(() => setLeft(left - 1), 1000);
return () => clearTimeout(t);
}, [left]);
const restart = () => setLeft(initialSeconds);
return [left, restart];
}
Object.assign(window, {
Logo, TopBar, Glows, Arrow,
GoogleIcon, AppleIcon, MailIcon,
TrustStrip, useResendTimer,
});

View File

@@ -0,0 +1,431 @@
// ============================================================
// auth-screens.jsx — Sign-in / Sign-up / Onboarding for the
// Lattice brand, in three aesthetic directions that match the
// three nav styles from the prior file:
//
// A · Light minimal ← Sidebar / Notion school
// B · Dark split-hero ← Topbar / Vercel school
// C · Glass aurora ← Floating-pill marketing school
//
// Each style ships all three screens. Shared <LatticeMark>,
// <SocialBtn>, <Field> components keep the family resemblance.
// ============================================================
// ── Shared atoms ────────────────────────────────────────────
// Branded "G" / "MS" social logos drawn inline as little glyphs
// so there are no missing-asset placeholders. They're recognizable
// without using actual brand marks.
const GoogleGlyph = () => (
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true">
<path fill="#4285F4" d="M21.6 12.2c0-.7-.1-1.4-.2-2H12v3.8h5.4c-.2 1.2-.9 2.2-2 2.9v2.4h3.2c1.9-1.7 3-4.3 3-7.1z"/>
<path fill="#34A853" d="M12 22c2.7 0 5-.9 6.6-2.4l-3.2-2.4c-.9.6-2 1-3.4 1-2.6 0-4.8-1.7-5.6-4.1H3.1v2.5C4.8 19.8 8.2 22 12 22z"/>
<path fill="#FBBC05" d="M6.4 14.1c-.2-.6-.3-1.3-.3-2s.1-1.4.3-2V7.6H3.1C2.4 9 2 10.4 2 12s.4 3 1.1 4.4l3.3-2.3z"/>
<path fill="#EA4335" d="M12 6c1.5 0 2.8.5 3.8 1.5l2.9-2.9C16.9 3.1 14.7 2 12 2 8.2 2 4.8 4.2 3.1 7.6l3.3 2.5C7.2 7.7 9.4 6 12 6z"/>
</svg>
);
const MicrosoftGlyph = () => (
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true">
<rect x="1" y="1" width="10" height="10" fill="#F25022"/>
<rect x="13" y="1" width="10" height="10" fill="#7FBA00"/>
<rect x="1" y="13" width="10" height="10" fill="#00A4EF"/>
<rect x="13" y="13" width="10" height="10" fill="#FFB900"/>
</svg>
);
const AppleGlyph = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M16.4 12.7c0-2.6 2.1-3.8 2.2-3.9-1.2-1.7-3-2-3.6-2-1.5-.2-3 .9-3.8.9-.8 0-2-.9-3.3-.9-1.7 0-3.3 1-4.2 2.6-1.8 3.1-.5 7.7 1.3 10.2.9 1.2 1.9 2.6 3.2 2.5 1.3-.1 1.8-.8 3.4-.8 1.6 0 2 .8 3.4.8 1.4 0 2.3-1.2 3.1-2.5.7-1 1.1-2 1.4-3-2.6-1-3.1-3.7-3.1-3.9zM13.5 5c.7-.9 1.2-2.1 1.1-3.4-1 .1-2.3.7-3 1.6-.7.8-1.3 2-1.1 3.2 1.2.1 2.3-.6 3-1.4z"/>
</svg>
);
const sansAuth = "'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif";
// Tiny stroke icon helper (re-defining locally so this file is standalone)
const Icn = ({ d, size = 16, sw = 1.6 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth={sw}
strokeLinecap="round" strokeLinejoin="round">{d}</svg>
);
const Pa = {
eye: <><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></>,
check: <path d="M5 12l5 5L20 7"/>,
arrow: <path d="M5 12h14M13 5l7 7-7 7"/>,
chevR: <path d="m9 6 6 6-6 6"/>,
chevL: <path d="m15 6-6 6 6 6"/>,
star: <path d="m12 3 2.6 6.2 6.7.5-5.1 4.4 1.6 6.6L12 17.3 6.2 20.7l1.6-6.6L2.7 9.7l6.7-.5z"/>,
spark: <path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l3 3M15 15l3 3M6 18l3-3M15 9l3-3"/>,
bolt: <path d="m13 2-9 13h7l-1 7 9-13h-7z"/>,
shield: <path d="M12 2 4 5v7c0 5 3.5 9 8 10 4.5-1 8-5 8-10V5z"/>,
briefcase: <><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M3 13h18"/></>,
};
// Brand mark (gradient triangle), shared
const Mark = ({ size = 20, mono }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
{mono ? (
<path d="M3 20 L12 4 L21 20 Z" fill="currentColor"/>
) : (
<>
<defs>
<linearGradient id={`mk${size}`} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#6e6cff"/>
<stop offset="100%" stopColor="#b15bff"/>
</linearGradient>
</defs>
<path d="M3 20 L12 4 L21 20 Z" fill={`url(#mk${size})`}/>
</>
)}
</svg>
);
// ============================================================
// STYLE A — LIGHT MINIMAL (Notion / Linear school)
// Centered card on warm neutral, no flourish, lots of air.
// ============================================================
const a = {
bg: "#f5f5f2", surface: "#ffffff",
border: "#e8e8e3", borderStrong: "#d8d8d2",
text: "#111", subtext: "#5a5a5e", muted: "#8a8a90",
accent: "#5e5cff", accentText: "#fff",
};
const fontA = sansAuth;
const AFieldLabel = ({ children, optional }) => (
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "baseline",
fontSize: 12, fontWeight: 500, color: a.text, marginBottom: 6,
}}>
<span>{children}</span>
{optional && <span style={{ color: a.muted, fontWeight: 400 }}>optional</span>}
</div>
);
const AField = ({ label, value, placeholder, hint, type = "text", icon, optional }) => (
<div style={{ marginBottom: 14 }}>
{label && <AFieldLabel optional={optional}>{label}</AFieldLabel>}
<div style={{
display: "flex", alignItems: "center", gap: 8,
padding: "10px 12px", borderRadius: 7,
background: "#fff", border: `1px solid ${a.border}`,
fontSize: 13, color: value ? a.text : a.muted,
boxShadow: "0 1px 0 #00000004",
}}>
{icon && <span style={{ color: a.muted, display: "flex" }}>{icon}</span>}
<span style={{ flex: 1 }}>{value || placeholder}</span>
{type === "password" && <span style={{ color: a.muted, display: "flex" }}>
<Icn d={Pa.eye} size={14} /></span>}
</div>
{hint && <div style={{ fontSize: 11, color: a.muted, marginTop: 5 }}>{hint}</div>}
</div>
);
const ASocial = ({ children, glyph }) => (
<button style={{
flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
padding: "10px 12px", borderRadius: 7, background: "#fff",
border: `1px solid ${a.border}`, color: a.text, fontSize: 13,
fontFamily: fontA, fontWeight: 500, cursor: "pointer",
}}>
{glyph}
<span>{children}</span>
</button>
);
const APrimary = ({ children, full = true }) => (
<button style={{
width: full ? "100%" : "auto",
padding: "11px 18px", borderRadius: 7,
background: "#111", color: "#fff", border: "none",
fontSize: 13, fontWeight: 500, fontFamily: fontA, cursor: "pointer",
display: "flex", alignItems: "center", justifyContent: "center", gap: 6,
}}>{children}</button>
);
const ACardShell = ({ children, foot }) => (
<div style={{
width: "100%", height: "100%", background: a.bg,
color: a.text, fontFamily: fontA,
display: "grid", gridTemplateRows: "auto 1fr auto",
}}>
{/* Top bar: brand on left, support on right */}
<header style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "20px 28px",
}}>
<div style={{ display: "flex", alignItems: "center", gap: 8, fontWeight: 600, fontSize: 14 }}>
<Mark size={20} />
Lattice
</div>
<div style={{ fontSize: 12, color: a.subtext, display: "flex", gap: 18 }}>
<span>Status</span>
<span>Docs</span>
<span>Sign in </span>
</div>
</header>
{/* Centered card */}
<main style={{ display: "flex", alignItems: "center", justifyContent: "center", padding: 24 }}>
<div style={{
width: 420, padding: "32px 36px", borderRadius: 12,
background: a.surface, border: `1px solid ${a.border}`,
boxShadow: "0 1px 2px #0000000a, 0 8px 32px -12px #0000000f",
}}>
{children}
</div>
</main>
{/* Footer band */}
<footer style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "16px 28px", fontSize: 11, color: a.muted,
}}>
<span>© 2026 Lattice Studio · Made in Copenhagen</span>
<div style={{ display: "flex", gap: 16 }}>
<span>Privacy</span><span>Terms</span><span>Security</span>
</div>
</footer>
</div>
);
const ASignIn = () => (
<ACardShell>
<h1 style={{ fontSize: 22, fontWeight: 600, margin: 0, letterSpacing: "-0.01em" }}>
Welcome back
</h1>
<p style={{ fontSize: 13, color: a.subtext, margin: "6px 0 22px" }}>
Sign in to your Lattice workspace.
</p>
<div style={{ display: "flex", gap: 8, marginBottom: 18 }}>
<ASocial glyph={<GoogleGlyph/>}>Google</ASocial>
<ASocial glyph={<MicrosoftGlyph/>}>Microsoft</ASocial>
<ASocial glyph={<span style={{ color: a.text, display: "flex" }}><AppleGlyph/></span>}>Apple</ASocial>
</div>
<div style={{
display: "flex", alignItems: "center", gap: 10,
fontSize: 11, color: a.muted, margin: "0 0 18px",
}}>
<div style={{ flex: 1, height: 1, background: a.border }}></div>
<span style={{ textTransform: "uppercase", letterSpacing: "0.08em" }}>or with email</span>
<div style={{ flex: 1, height: 1, background: a.border }}></div>
</div>
<AField label="Email" value="mira@lattice.co" />
<div style={{ marginBottom: 14 }}>
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "baseline",
fontSize: 12, fontWeight: 500, color: a.text, marginBottom: 6,
}}>
<span>Password</span>
<span style={{ color: a.accent, cursor: "pointer", fontWeight: 400 }}>Forgot?</span>
</div>
<div style={{
display: "flex", alignItems: "center", gap: 8,
padding: "10px 12px", borderRadius: 7, background: "#fff",
border: `1px solid ${a.border}`, fontSize: 13, color: a.text,
letterSpacing: "0.2em",
}}>
<span style={{ flex: 1 }}></span>
<span style={{ color: a.muted, display: "flex" }}><Icn d={Pa.eye} size={14}/></span>
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 18 }}>
<div style={{
width: 14, height: 14, borderRadius: 3, background: "#111",
display: "flex", alignItems: "center", justifyContent: "center",
color: "#fff",
}}><Icn d={Pa.check} size={10} sw={2.4}/></div>
<span style={{ fontSize: 12, color: a.subtext }}>Keep me signed in for 30 days</span>
</div>
<APrimary>Sign in </APrimary>
<div style={{ fontSize: 12, color: a.subtext, marginTop: 18, textAlign: "center" }}>
New here? <span style={{ color: a.text, fontWeight: 500, cursor: "pointer" }}>
Create an account
</span>
</div>
</ACardShell>
);
const ASignUp = () => (
<ACardShell>
<h1 style={{ fontSize: 22, fontWeight: 600, margin: 0, letterSpacing: "-0.01em" }}>
Create your workspace
</h1>
<p style={{ fontSize: 13, color: a.subtext, margin: "6px 0 22px" }}>
Free for up to 10 people. No card needed.
</p>
<div style={{ display: "flex", gap: 8, marginBottom: 18 }}>
<ASocial glyph={<GoogleGlyph/>}>Continue with Google</ASocial>
<ASocial glyph={<MicrosoftGlyph/>}>Microsoft</ASocial>
</div>
<div style={{
display: "flex", alignItems: "center", gap: 10,
fontSize: 11, color: a.muted, margin: "0 0 18px",
}}>
<div style={{ flex: 1, height: 1, background: a.border }}></div>
<span style={{ textTransform: "uppercase", letterSpacing: "0.08em" }}>or with email</span>
<div style={{ flex: 1, height: 1, background: a.border }}></div>
</div>
<AField label="Full name" placeholder="Mira Reyes" />
<AField label="Work email" value="mira@lattice.co"
hint="We'll send a 6-digit code to confirm." />
<AField label="Password" value="••••••••••" type="password"
hint="At least 10 characters, including a number." />
<div style={{ display: "flex", alignItems: "flex-start", gap: 8, margin: "4px 0 18px" }}>
<div style={{
width: 14, height: 14, borderRadius: 3, marginTop: 2,
background: "#fff", border: `1px solid ${a.borderStrong}`,
}}></div>
<span style={{ fontSize: 12, color: a.subtext, lineHeight: 1.5 }}>
I agree to Lattice's <span style={{ color: a.text, fontWeight: 500 }}>Terms</span> and{" "}
<span style={{ color: a.text, fontWeight: 500 }}>Privacy Policy</span>.
</span>
</div>
<APrimary>Create workspace →</APrimary>
<div style={{ fontSize: 12, color: a.subtext, marginTop: 18, textAlign: "center" }}>
Already have one? <span style={{ color: a.text, fontWeight: 500, cursor: "pointer" }}>
Sign in
</span>
</div>
</ACardShell>
);
const AOnboarding = () => {
const Step = ({ n, label, state }) => (
<div style={{ display: "flex", alignItems: "center", gap: 8, flex: 1, minWidth: 0 }}>
<div style={{
width: 22, height: 22, borderRadius: "50%",
background: state === "done" ? "#22c55e" : state === "active" ? "#111" : "transparent",
color: state === "todo" ? a.muted : "#fff",
border: state === "todo" ? `1px solid ${a.borderStrong}` : "none",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 11, fontWeight: 600, flexShrink: 0,
}}>{state === "done" ? <Icn d={Pa.check} size={12} sw={2.4} /> : n}</div>
<div style={{ fontSize: 12, color: state === "todo" ? a.muted : a.text,
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{label}</div>
</div>
);
const Tile = ({ title, sub, selected, icon }) => (
<div style={{
padding: 14, borderRadius: 8, cursor: "pointer", textAlign: "left",
border: selected ? `1.5px solid ${a.accent}` : `1px solid ${a.border}`,
background: selected ? "#f6f5ff" : "#fff",
boxShadow: selected ? `0 0 0 3px ${a.accent}1a` : "0 1px 0 #00000004",
}}>
<div style={{
width: 28, height: 28, borderRadius: 7, marginBottom: 10,
background: selected ? a.accent : "#f1f0eb",
color: selected ? "#fff" : a.subtext,
display: "flex", alignItems: "center", justifyContent: "center",
}}>{icon}</div>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 2 }}>{title}</div>
<div style={{ fontSize: 11, color: a.muted, lineHeight: 1.4 }}>{sub}</div>
</div>
);
return (
<div style={{
width: "100%", height: "100%", background: a.bg, color: a.text,
fontFamily: fontA, display: "grid", gridTemplateRows: "auto 1fr auto",
}}>
<header style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "20px 28px",
}}>
<div style={{ display: "flex", alignItems: "center", gap: 8, fontWeight: 600, fontSize: 14 }}>
<Mark size={20} /> Lattice
</div>
<div style={{ fontSize: 12, color: a.subtext }}>Step 2 of 4 · ⌘. to skip</div>
</header>
<main style={{
padding: "12px 28px 28px",
display: "flex", flexDirection: "column", alignItems: "center",
}}>
<div style={{
width: 640, padding: "30px 36px 36px", borderRadius: 14,
background: a.surface, border: `1px solid ${a.border}`,
boxShadow: "0 1px 2px #0000000a, 0 8px 32px -12px #0000000f",
}}>
{/* Stepper */}
<div style={{ display: "flex", alignItems: "center", gap: 4, marginBottom: 22 }}>
<Step n="1" label="Account" state="done" />
<div style={{ flex: 1, height: 1, background: a.border, margin: "0 6px" }}></div>
<Step n="2" label="Workspace" state="active" />
<div style={{ flex: 1, height: 1, background: a.border, margin: "0 6px" }}></div>
<Step n="3" label="Invite team" state="todo" />
<div style={{ flex: 1, height: 1, background: a.border, margin: "0 6px" }}></div>
<Step n="4" label="Import" state="todo" />
</div>
<h1 style={{ fontSize: 24, fontWeight: 600, margin: 0, letterSpacing: "-0.02em" }}>
Tell us about your work
</h1>
<p style={{ fontSize: 13, color: a.subtext, margin: "6px 0 22px" }}>
We'll tailor your workspace based on this. You can change it later.
</p>
<AField label="Workspace name" value="Lattice Studio"
hint="This is how your team will see it." />
<div style={{ fontSize: 12, fontWeight: 500, margin: "16px 0 8px" }}>
What do you do?
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 10 }}>
<Tile title="Sales & Revenue" sub="Pipeline, contacts, deals" selected icon={<Icn d={Pa.bolt} size={15}/>} />
<Tile title="Operations" sub="Vendors, ops, suppliers" icon={<Icn d={Pa.shield} size={15}/>} />
<Tile title="Product" sub="Customers, feedback, research" icon={<Icn d={Pa.spark} size={15}/>} />
<Tile title="Recruiting" sub="Candidates, pipeline" icon={<Icn d={Pa.briefcase} size={15}/>} />
<Tile title="Just exploring" sub="I'll figure it out" icon={<Icn d={Pa.star} size={15}/>} />
</div>
<div style={{ fontSize: 12, fontWeight: 500, margin: "20px 0 8px" }}>How big is your team?</div>
<div style={{ display: "flex", gap: 6 }}>
{["Just me", "210", "1150", "51200", "200+"].map((s, i) => (
<div key={s} style={{
flex: 1, padding: "9px 8px", textAlign: "center", borderRadius: 7,
fontSize: 12, fontWeight: 500, cursor: "pointer",
border: i === 1 ? `1.5px solid ${a.accent}` : `1px solid ${a.border}`,
background: i === 1 ? "#f6f5ff" : "#fff",
color: i === 1 ? a.accent : a.subtext,
}}>{s}</div>
))}
</div>
<div style={{
display: "flex", justifyContent: "space-between", marginTop: 26, alignItems: "center",
}}>
<button style={{
background: "transparent", border: "none", color: a.subtext,
fontSize: 13, fontFamily: fontA, cursor: "pointer", padding: 0,
}}> Back</button>
<APrimary full={false}>Continue <Icn d={Pa.arrow} size={13}/></APrimary>
</div>
</div>
</main>
<footer style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "16px 28px", fontSize: 11, color: a.muted,
}}>
<span>Press <code style={{
background: "#fff", padding: "1px 5px", borderRadius: 3,
border: `1px solid ${a.border}`, fontFamily: "monospace",
}}> + Enter</code> to continue</span>
<span>Need help? <span style={{ color: a.text, fontWeight: 500 }}>support@lattice.co</span></span>
</footer>
</div>
);
};
Object.assign(window, { ASignIn, ASignUp, AOnboarding });

View File

@@ -0,0 +1,548 @@
// ============================================================
// auth-style-b.jsx — Dark split-hero auth (Vercel / Stripe school).
// Two-column: marketing/storytelling on the left, form on the right.
// Inverts gracefully for onboarding (single dark surface, full width).
// ============================================================
const b = {
bg: "#0a0a0a", left: "#0f0f14",
surface: "#101015", surface2: "#16161d",
border: "#1f1f25", borderStrong: "#2a2a32",
text: "#fafafa", subtext: "#a8a8b0", muted: "#6a6a72",
accent: "#ffffff", accentText: "#0a0a0a",
brandA: "#5e5cff", brandB: "#b15bff",
};
const fontB = "'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif";
// Local icon helper (kept independent of other auth files)
const IcnB = ({ d, size = 16, sw = 1.6 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth={sw}
strokeLinecap="round" strokeLinejoin="round">{d}</svg>
);
const PB = {
check: <path d="M5 12l5 5L20 7"/>,
eye: <><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></>,
arrow: <path d="M5 12h14M13 5l7 7-7 7"/>,
bolt: <path d="m13 2-9 13h7l-1 7 9-13h-7z"/>,
spark: <path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l3 3M15 15l3 3M6 18l3-3M15 9l3-3"/>,
star: <path d="m12 3 2.6 6.2 6.7.5-5.1 4.4 1.6 6.6L12 17.3 6.2 20.7l1.6-6.6L2.7 9.7l6.7-.5z"/>,
};
const MarkB = ({ size = 22 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<defs>
<linearGradient id={`mkb${size}`} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#6e6cff"/>
<stop offset="100%" stopColor="#b15bff"/>
</linearGradient>
</defs>
<path d="M3 20 L12 4 L21 20 Z" fill={`url(#mkb${size})`}/>
</svg>
);
const BField = ({ label, value, placeholder, type, icon, hint, optional, autofocus }) => (
<div style={{ marginBottom: 14 }}>
{label && (
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "baseline",
fontSize: 12, fontWeight: 500, color: b.subtext, marginBottom: 6,
}}>
<span>{label}</span>
{optional && <span style={{ color: b.muted, fontWeight: 400 }}>optional</span>}
</div>
)}
<div style={{
display: "flex", alignItems: "center", gap: 8,
padding: "10px 12px", borderRadius: 8,
background: b.surface2,
border: `1px solid ${autofocus ? b.brandA : b.border}`,
boxShadow: autofocus ? `0 0 0 3px ${b.brandA}33` : "none",
fontSize: 13, color: value ? b.text : b.muted,
}}>
{icon && <span style={{ color: b.muted, display: "flex" }}>{icon}</span>}
<span style={{ flex: 1, letterSpacing: type === "password" ? "0.2em" : "0" }}>
{value || placeholder}
</span>
{type === "password" && <span style={{ color: b.muted, display: "flex" }}><IcnB d={PB.eye} size={14}/></span>}
</div>
{hint && <div style={{ fontSize: 11, color: b.muted, marginTop: 5 }}>{hint}</div>}
</div>
);
const BSocial = ({ children, glyph }) => (
<button style={{
flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
padding: "10px 14px", borderRadius: 8, background: b.surface2,
border: `1px solid ${b.border}`, color: b.text, fontSize: 13,
fontFamily: fontB, fontWeight: 500, cursor: "pointer",
}}>{glyph}<span>{children}</span></button>
);
const BPrimary = ({ children, full = true }) => (
<button style={{
width: full ? "100%" : "auto",
padding: "11px 18px", borderRadius: 8,
background: b.accent, color: b.accentText, border: "none",
fontSize: 13, fontWeight: 600, fontFamily: fontB, cursor: "pointer",
display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
}}>{children}</button>
);
// LEFT hero panel — short storytelling
const HeroPanel = ({ headline, sub, badge }) => (
<div style={{
background: b.left, color: b.text, padding: "32px 44px 36px",
display: "flex", flexDirection: "column", height: "100%",
position: "relative", overflow: "hidden",
borderRight: `1px solid ${b.border}`,
}}>
{/* Decorative grid + glow */}
<div style={{
position: "absolute", inset: 0, pointerEvents: "none", opacity: 0.5,
backgroundImage: `linear-gradient(${b.border} 1px, transparent 1px),
linear-gradient(90deg, ${b.border} 1px, transparent 1px)`,
backgroundSize: "40px 40px",
maskImage: "radial-gradient(circle at 50% 30%, #000 40%, transparent 80%)",
}}></div>
<div style={{
position: "absolute", top: -180, left: -120, width: 540, height: 540,
borderRadius: "50%",
background: `radial-gradient(circle, ${b.brandA}40, transparent 60%)`,
filter: "blur(60px)",
}}></div>
<div style={{
position: "absolute", bottom: -200, right: -120, width: 500, height: 500,
borderRadius: "50%",
background: `radial-gradient(circle, ${b.brandB}40, transparent 60%)`,
filter: "blur(60px)",
}}></div>
{/* Brand */}
<div style={{
position: "relative", display: "flex", alignItems: "center", gap: 10,
fontWeight: 600, fontSize: 16,
}}>
<MarkB size={22} />
Lattice
</div>
{/* Mid */}
<div style={{ position: "relative", marginTop: "auto" }}>
{badge && (
<div style={{
display: "inline-flex", alignItems: "center", gap: 8,
padding: "4px 12px 4px 4px", borderRadius: 999,
background: "#ffffff08", border: "1px solid #ffffff14",
fontSize: 11, color: b.subtext, marginBottom: 22,
}}>
<span style={{
padding: "2px 8px", background: b.brandA, color: "#fff",
borderRadius: 999, fontWeight: 600, fontSize: 10,
}}>NEW</span>
{badge}
</div>
)}
<h2 style={{
fontSize: 38, lineHeight: 1.05, margin: 0, letterSpacing: "-0.03em",
fontWeight: 500, textWrap: "balance", maxWidth: 360,
}}>{headline}</h2>
<p style={{ fontSize: 14, color: b.subtext, marginTop: 14, lineHeight: 1.5, maxWidth: 340 }}>
{sub}
</p>
{/* Trust row */}
<div style={{
marginTop: 32, paddingTop: 22, borderTop: `1px solid ${b.border}`,
}}>
<div style={{
fontSize: 11, color: b.muted, letterSpacing: "0.1em",
textTransform: "uppercase", fontWeight: 500, marginBottom: 12,
}}>Used by teams at</div>
<div style={{
display: "flex", gap: 22, alignItems: "center",
fontWeight: 600, fontSize: 15, color: b.subtext,
}}>
<span>Halcyon</span><span>·</span><span>Kestrel</span>
<span>·</span><span>Mossbank</span><span>·</span><span>Verra</span>
</div>
</div>
</div>
{/* Bottom quote */}
<div style={{
position: "relative", marginTop: 28, padding: "16px 18px",
borderRadius: 12, background: "#ffffff06",
border: `1px solid ${b.border}`,
}}>
<p style={{ fontSize: 13, color: b.text, margin: 0, lineHeight: 1.5 }}>
"Replaced three tools in our first week. Lattice is what every
CRM should have been."
</p>
<div style={{ marginTop: 12, display: "flex", alignItems: "center", gap: 10 }}>
<div style={{
width: 26, height: 26, borderRadius: "50%", background: "#a8c8e8",
fontSize: 11, fontWeight: 600, color: "#1a3a5e",
display: "flex", alignItems: "center", justifyContent: "center",
}}>DP</div>
<div style={{ fontSize: 12 }}>
<div style={{ fontWeight: 500 }}>Devi Patel</div>
<div style={{ color: b.muted, fontSize: 11 }}>Head of Sales, Halcyon</div>
</div>
</div>
</div>
</div>
);
// 2-col shell: hero on left, form on right
const BSplitShell = ({ hero, children }) => (
<div style={{
width: "100%", height: "100%", background: b.bg,
color: b.text, fontFamily: fontB,
display: "grid", gridTemplateColumns: "1fr 1fr",
}}>
{hero}
<div style={{
display: "flex", flexDirection: "column", padding: "32px 56px",
position: "relative",
}}>
<div style={{
display: "flex", justifyContent: "flex-end", fontSize: 13, color: b.subtext,
}}>
<span>Need help? <span style={{ color: b.text, fontWeight: 500 }}>support</span></span>
</div>
<div style={{
flex: 1, display: "flex", alignItems: "center", justifyContent: "center",
}}>
<div style={{ width: 380 }}>{children}</div>
</div>
<div style={{
display: "flex", gap: 18, fontSize: 11, color: b.muted, justifyContent: "flex-end",
}}>
<span>Privacy</span><span>Terms</span><span>Security</span>
<span>v4.2.1</span>
</div>
</div>
</div>
);
const BSignIn = () => (
<BSplitShell hero={
<HeroPanel
badge="Lattice 4.0 · agents that draft for you"
headline="The workspace where good ideas compound."
sub="One luminous surface for docs, canvases, contacts and pipelines. Built by people tired of switching tabs."
/>}>
<h1 style={{ fontSize: 26, fontWeight: 600, margin: 0, letterSpacing: "-0.02em" }}>
Sign in to Lattice
</h1>
<p style={{ fontSize: 13, color: b.subtext, margin: "6px 0 24px" }}>
Welcome back. Pick how you'd like to continue.
</p>
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 18 }}>
<BSocial glyph={<GoogleGlyph/>}>Continue with Google</BSocial>
<BSocial glyph={<MicrosoftGlyph/>}>Continue with Microsoft</BSocial>
<BSocial glyph={<span style={{ color: b.text, display: "flex" }}><AppleGlyph/></span>}>Continue with Apple</BSocial>
</div>
<div style={{
display: "flex", alignItems: "center", gap: 10,
fontSize: 11, color: b.muted, margin: "0 0 18px",
}}>
<div style={{ flex: 1, height: 1, background: b.border }}></div>
<span style={{ textTransform: "uppercase", letterSpacing: "0.08em" }}>or with email</span>
<div style={{ flex: 1, height: 1, background: b.border }}></div>
</div>
<BField label="Email" value="mira@lattice.co" autofocus />
<div style={{ marginBottom: 18 }}>
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "baseline",
fontSize: 12, fontWeight: 500, color: b.subtext, marginBottom: 6,
}}>
<span>Password</span>
<span style={{ color: b.text, cursor: "pointer", fontWeight: 500 }}>Forgot?</span>
</div>
<div style={{
display: "flex", alignItems: "center", gap: 8,
padding: "10px 12px", borderRadius: 8,
background: b.surface2, border: `1px solid ${b.border}`,
fontSize: 13, color: b.text, letterSpacing: "0.2em",
}}>
<span style={{ flex: 1 }}>••••••••••</span>
<span style={{ color: b.muted, display: "flex" }}><IcnB d={PB.eye} size={14}/></span>
</div>
</div>
<BPrimary>Sign in <IcnB d={PB.arrow} size={13}/></BPrimary>
<div style={{
marginTop: 22, padding: "10px 14px", borderRadius: 8,
background: b.surface2, border: `1px solid ${b.border}`,
fontSize: 12, color: b.subtext, display: "flex",
alignItems: "center", gap: 10,
}}>
<IcnB d={PB.bolt} size={14}/>
<span style={{ flex: 1 }}>SAML / SSO for your company?</span>
<span style={{ color: b.text, fontWeight: 500, cursor: "pointer" }}>Use SSO →</span>
</div>
<div style={{ fontSize: 12, color: b.subtext, marginTop: 22, textAlign: "center" }}>
New here? <span style={{ color: b.text, fontWeight: 500, cursor: "pointer" }}>
Create an account
</span>
</div>
</BSplitShell>
);
const BSignUp = () => (
<BSplitShell hero={
<HeroPanel
headline="Start a Lattice workspace in 30 seconds."
sub="Free for up to 10 people. No card required. SSO and SCIM on the Pro plan."
/>}>
<h1 style={{ fontSize: 26, fontWeight: 600, margin: 0, letterSpacing: "-0.02em" }}>
Create your account
</h1>
<p style={{ fontSize: 13, color: b.subtext, margin: "6px 0 24px" }}>
You'll set up your workspace in the next step.
</p>
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 18 }}>
<BSocial glyph={<GoogleGlyph/>}>Continue with Google</BSocial>
<BSocial glyph={<MicrosoftGlyph/>}>Continue with Microsoft</BSocial>
</div>
<div style={{
display: "flex", alignItems: "center", gap: 10,
fontSize: 11, color: b.muted, margin: "0 0 18px",
}}>
<div style={{ flex: 1, height: 1, background: b.border }}></div>
<span style={{ textTransform: "uppercase", letterSpacing: "0.08em" }}>or with email</span>
<div style={{ flex: 1, height: 1, background: b.border }}></div>
</div>
<BField label="Full name" value="Mira Reyes" autofocus />
<BField label="Work email" value="mira@lattice.co"
hint="We'll send a 6-digit code to confirm." />
<BField label="Password" value="••••••••••" type="password"
hint="At least 10 chars · 1 number · 1 symbol." />
<div style={{ display: "flex", alignItems: "flex-start", gap: 8, margin: "8px 0 18px" }}>
<div style={{
width: 14, height: 14, borderRadius: 3, marginTop: 2,
background: b.surface2, border: `1px solid ${b.borderStrong}`,
}}></div>
<span style={{ fontSize: 12, color: b.subtext, lineHeight: 1.5 }}>
I agree to the <span style={{ color: b.text, fontWeight: 500 }}>Terms</span> and{" "}
<span style={{ color: b.text, fontWeight: 500 }}>Privacy Policy</span>.
</span>
</div>
<BPrimary>Create account <IcnB d={PB.arrow} size={13}/></BPrimary>
<div style={{ fontSize: 12, color: b.subtext, marginTop: 22, textAlign: "center" }}>
Already have an account? <span style={{ color: b.text, fontWeight: 500, cursor: "pointer" }}>
Sign in
</span>
</div>
</BSplitShell>
);
const BOnboarding = () => {
// Full-bleed dark onboarding screen (workspace customization step)
const Step = ({ n, label, state }) => (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{
width: 22, height: 22, borderRadius: "50%",
background: state === "done" ? b.brandA : state === "active" ? b.text : "transparent",
color: state === "active" ? b.bg : state === "done" ? "#fff" : b.muted,
border: state === "todo" ? `1px solid ${b.borderStrong}` : "none",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 11, fontWeight: 600,
}}>{state === "done" ? <IcnB d={PB.check} size={12} sw={2.4}/> : n}</div>
<div style={{
fontSize: 12, color: state === "todo" ? b.muted : b.text, whiteSpace: "nowrap",
}}>{label}</div>
</div>
);
const ColorSwatch = ({ color, selected }) => (
<div style={{
width: 36, height: 36, borderRadius: 10, background: color, cursor: "pointer",
boxShadow: selected ? `0 0 0 2px ${b.bg}, 0 0 0 4px ${b.text}` : "none",
}}></div>
);
const Template = ({ title, sub, icon, selected, color }) => (
<div style={{
padding: 18, borderRadius: 12, cursor: "pointer", textAlign: "left",
border: selected ? `1.5px solid ${color}` : `1px solid ${b.border}`,
background: selected ? `${color}10` : b.surface,
position: "relative", overflow: "hidden",
}}>
<div style={{
width: 32, height: 32, borderRadius: 8, marginBottom: 12,
background: selected ? color : "#ffffff10",
color: selected ? "#fff" : b.subtext,
display: "flex", alignItems: "center", justifyContent: "center",
}}>{icon}</div>
<div style={{ fontSize: 14, fontWeight: 500, marginBottom: 4 }}>{title}</div>
<div style={{ fontSize: 12, color: b.muted, lineHeight: 1.4 }}>{sub}</div>
{selected && (
<div style={{
position: "absolute", top: 14, right: 14,
width: 18, height: 18, borderRadius: "50%", background: color,
color: "#fff", display: "flex", alignItems: "center", justifyContent: "center",
}}><IcnB d={PB.check} size={11} sw={2.4}/></div>
)}
</div>
);
return (
<div style={{
width: "100%", height: "100%", background: b.bg, color: b.text,
fontFamily: fontB, display: "grid", gridTemplateRows: "auto 1fr auto",
position: "relative", overflow: "hidden",
}}>
{/* Decorative aurora */}
<div style={{
position: "absolute", top: -200, right: -150, width: 600, height: 600,
borderRadius: "50%",
background: `radial-gradient(circle, ${b.brandA}33, transparent 60%)`,
filter: "blur(80px)", pointerEvents: "none",
}}></div>
{/* Top stepper bar */}
<header style={{
padding: "20px 56px", display: "flex", alignItems: "center", gap: 14,
borderBottom: `1px solid ${b.border}`, background: "#0a0a0d", position: "relative",
}}>
<MarkB size={22} />
<span style={{ fontWeight: 600, fontSize: 14 }}>Lattice</span>
<div style={{ width: 1, height: 18, background: b.border, margin: "0 12px" }}></div>
<div style={{ display: "flex", alignItems: "center", gap: 14, flex: 1 }}>
<Step n="1" label="Account" state="done" />
<div style={{ width: 32, height: 1, background: b.border }}></div>
<Step n="2" label="Workspace" state="done" />
<div style={{ width: 32, height: 1, background: b.border }}></div>
<Step n="3" label="Personalise" state="active" />
<div style={{ width: 32, height: 1, background: b.border }}></div>
<Step n="4" label="Invite" state="todo" />
<div style={{ width: 32, height: 1, background: b.border }}></div>
<Step n="5" label="Import" state="todo" />
</div>
<button style={{
background: "transparent", border: "none", color: b.subtext,
fontSize: 12, fontFamily: fontB, cursor: "pointer",
}}>Skip setup </button>
</header>
<main style={{
padding: "44px 64px 24px", position: "relative", overflowY: "auto",
display: "flex", flexDirection: "column", alignItems: "center",
}}>
<div style={{
fontSize: 11, color: b.muted, letterSpacing: "0.12em",
textTransform: "uppercase", fontWeight: 500, marginBottom: 12,
}}>Step 3 of 5 · Personalise</div>
<h1 style={{
fontSize: 40, fontWeight: 500, margin: 0, letterSpacing: "-0.03em",
textAlign: "center", textWrap: "balance",
}}>
Pick a template to get going.
</h1>
<p style={{
fontSize: 14, color: b.subtext, margin: "12px 0 36px",
textAlign: "center", maxWidth: 540, lineHeight: 1.5,
}}>
We'll pre-fill your workspace with the right objects, views and
fields. Everything is editable later.
</p>
{/* Template grid */}
<div style={{
display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 14,
width: "100%", maxWidth: 1080,
}}>
<Template title="Sales CRM" sub="Pipeline, contacts, deals, activity" icon={<IcnB d={PB.bolt} size={16}/>} selected color="#5e5cff" />
<Template title="Operations" sub="Vendors, suppliers, contracts" icon={<IcnB d={PB.spark} size={16}/>} color="#22c55e" />
<Template title="Recruiting" sub="Candidates, roles, interview loops" icon={<IcnB d={PB.star} size={16}/>} color="#f6c560" />
<Template title="Blank workspace" sub="Start from zero — I'll define my own objects" icon={<IcnB d={PB.spark} size={16}/>} color="#b15bff" />
</div>
{/* Theme + accent strip */}
<div style={{
marginTop: 32, padding: "20px 24px", borderRadius: 14,
background: b.surface, border: `1px solid ${b.border}`,
width: "100%", maxWidth: 1080,
display: "grid", gridTemplateColumns: "1fr 1fr", gap: 32,
}}>
<div>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 4 }}>Theme</div>
<div style={{ fontSize: 12, color: b.muted, marginBottom: 14 }}>
Light, dark, or follow the system.
</div>
<div style={{ display: "flex", gap: 8 }}>
{[
["Light", "#fafaf9", "#111"],
["Dark", "#0f0f14", "#fafafa"],
["System", "linear-gradient(135deg, #fafaf9 50%, #0f0f14 50%)", "#888"],
].map(([n, bg, ink], i) => (
<div key={n} style={{
flex: 1, padding: 4, borderRadius: 10, cursor: "pointer",
border: i === 1 ? `1.5px solid ${b.brandA}` : `1px solid ${b.border}`,
}}>
<div style={{
height: 56, borderRadius: 6, background: bg,
display: "flex", alignItems: "center", justifyContent: "center",
color: ink, fontSize: 11, fontWeight: 500,
}}>{n}</div>
</div>
))}
</div>
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 4 }}>Accent</div>
<div style={{ fontSize: 12, color: b.muted, marginBottom: 14 }}>
The color of your CTAs, links and focus rings.
</div>
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
<ColorSwatch color="#5e5cff" selected />
<ColorSwatch color="#22c55e" />
<ColorSwatch color="#f6c560" />
<ColorSwatch color="#ff5b6b" />
<ColorSwatch color="#b15bff" />
<ColorSwatch color="#06b6d4" />
<ColorSwatch color="#fafafa" />
</div>
</div>
</div>
</main>
<footer style={{
padding: "16px 56px", display: "flex", justifyContent: "space-between",
alignItems: "center", borderTop: `1px solid ${b.border}`,
background: "#0a0a0d", position: "relative",
}}>
<button style={{
background: "transparent", border: `1px solid ${b.border}`, color: b.text,
padding: "9px 16px", borderRadius: 8, fontSize: 13, fontFamily: fontB, cursor: "pointer",
}}>← Back</button>
<span style={{ fontSize: 12, color: b.muted }}>
Press <code style={{
background: b.surface2, padding: "1px 6px", borderRadius: 3,
border: `1px solid ${b.border}`, fontFamily: "monospace",
}}> + Enter</code> to continue
</span>
<BPrimary full={false}>Continue <IcnB d={PB.arrow} size={13}/></BPrimary>
</footer>
</div>
);
};
Object.assign(window, { BSignIn, BSignUp, BOnboarding });

View File

@@ -0,0 +1,535 @@
// ============================================================
// auth-style-c.jsx — Glass aurora auth (marketing-flavoured).
// Vibrant gradient background, frosted card, soft floating
// chrome. Onboarding becomes a kept-it-light, swipey wizard.
// ============================================================
const cc = {
bg: "#08081a", text: "#ffffff",
subtext: "rgba(255,255,255,0.7)", muted: "rgba(255,255,255,0.5)",
glass: "rgba(255,255,255,0.06)",
glassStrong: "rgba(255,255,255,0.1)",
glassBorder: "rgba(255,255,255,0.14)",
brandA: "#7a78ff", brandB: "#b15bff", brandC: "#00e5b3",
};
const fontC = "'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif";
const IcnC = ({ d, size = 16, sw = 1.6 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth={sw}
strokeLinecap="round" strokeLinejoin="round">{d}</svg>
);
const PC = {
check: <path d="M5 12l5 5L20 7"/>,
eye: <><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></>,
arrow: <path d="M5 12h14M13 5l7 7-7 7"/>,
spark: <path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l3 3M15 15l3 3M6 18l3-3M15 9l3-3"/>,
upload: <><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M17 8l-5-5-5 5M12 3v12"/></>,
pen: <><path d="m12 19 7-7 3 3-7 7-3-1z"/><path d="m18 13-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/><path d="M2 2l7.586 7.586"/><circle cx="11" cy="11" r="2"/></>,
bell: <><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10 21a2 2 0 0 0 4 0"/></>,
workflow: <><rect x="3" y="3" width="6" height="6" rx="1"/><rect x="15" y="15" width="6" height="6" rx="1"/><path d="M9 6h6a3 3 0 0 1 3 3v6"/></>,
};
const MarkC = ({ size = 22 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<defs>
<linearGradient id={`mkc${size}`} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#7a78ff"/>
<stop offset="100%" stopColor="#b15bff"/>
</linearGradient>
</defs>
<path d="M3 20 L12 4 L21 20 Z" fill={`url(#mkc${size})`}/>
</svg>
);
// Reusable aurora background — also used by onboarding
const AuroraBg = () => (
<>
<div style={{
position: "absolute", top: -250, left: -150, width: 700, height: 700,
borderRadius: "50%",
background: `radial-gradient(circle, ${cc.brandA} 0%, transparent 60%)`,
filter: "blur(100px)", opacity: 0.55, pointerEvents: "none",
}}></div>
<div style={{
position: "absolute", top: 100, right: -200, width: 600, height: 600,
borderRadius: "50%",
background: `radial-gradient(circle, ${cc.brandB} 0%, transparent 60%)`,
filter: "blur(100px)", opacity: 0.5, pointerEvents: "none",
}}></div>
<div style={{
position: "absolute", bottom: -200, left: "30%", width: 600, height: 600,
borderRadius: "50%",
background: `radial-gradient(circle, ${cc.brandC} 0%, transparent 60%)`,
filter: "blur(100px)", opacity: 0.35, pointerEvents: "none",
}}></div>
{/* Grain overlay */}
<div style={{
position: "absolute", inset: 0, pointerEvents: "none", opacity: 0.6,
backgroundImage: `radial-gradient(rgba(255,255,255,0.04) 1px, transparent 1px)`,
backgroundSize: "3px 3px",
}}></div>
</>
);
// Frosted glass top nav (the pill from the marketing nav, smaller)
const GlassTopNav = ({ right }) => (
<header style={{
position: "absolute", top: 22, left: "50%", transform: "translateX(-50%)",
zIndex: 10, width: "max-content", whiteSpace: "nowrap",
display: "flex", alignItems: "center", gap: 4,
padding: "8px 8px 8px 18px",
background: cc.glass, backdropFilter: "blur(24px)", WebkitBackdropFilter: "blur(24px)",
border: `1px solid ${cc.glassBorder}`, borderRadius: 999,
boxShadow: "0 18px 50px -20px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.1)",
}}>
<div style={{
display: "flex", alignItems: "center", gap: 8,
marginRight: 16, fontWeight: 600, fontSize: 14, color: "#fff",
}}>
<MarkC size={18} /> Lattice
</div>
{["Product", "Pricing", "Docs"].map(l => (
<button key={l} style={{
background: "transparent", border: "none", color: "#fff",
padding: "7px 12px", borderRadius: 999, fontSize: 13,
fontFamily: fontC, cursor: "pointer",
}}>{l}</button>
))}
<div style={{
width: 1, height: 18, background: cc.glassBorder, margin: "0 6px",
}}></div>
{right}
</header>
);
const CField = ({ label, value, placeholder, type, hint, optional, autofocus }) => (
<div style={{ marginBottom: 14 }}>
{label && (
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "baseline",
fontSize: 12, fontWeight: 500, color: cc.subtext, marginBottom: 6,
}}>
<span>{label}</span>
{optional && <span style={{ color: cc.muted, fontWeight: 400 }}>optional</span>}
</div>
)}
<div style={{
display: "flex", alignItems: "center", gap: 8,
padding: "11px 14px", borderRadius: 10,
background: cc.glass,
backdropFilter: "blur(16px)", WebkitBackdropFilter: "blur(16px)",
border: `1px solid ${autofocus ? cc.brandA : cc.glassBorder}`,
boxShadow: autofocus ? `0 0 0 3px ${cc.brandA}33` : "none",
fontSize: 13, color: value ? cc.text : cc.muted,
}}>
<span style={{ flex: 1, letterSpacing: type === "password" ? "0.2em" : "0" }}>
{value || placeholder}
</span>
{type === "password" && <span style={{ color: cc.muted, display: "flex" }}><IcnC d={PC.eye} size={14}/></span>}
</div>
{hint && <div style={{ fontSize: 11, color: cc.muted, marginTop: 5 }}>{hint}</div>}
</div>
);
const CSocial = ({ children, glyph }) => (
<button style={{
flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
padding: "10px 14px", borderRadius: 10,
background: cc.glass, backdropFilter: "blur(16px)", WebkitBackdropFilter: "blur(16px)",
border: `1px solid ${cc.glassBorder}`, color: cc.text, fontSize: 13,
fontFamily: fontC, fontWeight: 500, cursor: "pointer",
}}>{glyph}<span>{children}</span></button>
);
const CPrimary = ({ children, full = true }) => (
<button style={{
width: full ? "100%" : "auto", padding: "12px 22px", borderRadius: 999,
background: "#fff", color: "#08081a", border: "none",
fontSize: 13, fontWeight: 600, fontFamily: fontC, cursor: "pointer",
display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
}}>{children}</button>
);
const CSecondary = ({ children, full = false }) => (
<button style={{
width: full ? "100%" : "auto", padding: "12px 22px", borderRadius: 999,
background: cc.glass, color: cc.text,
backdropFilter: "blur(16px)", WebkitBackdropFilter: "blur(16px)",
border: `1px solid ${cc.glassBorder}`,
fontSize: 13, fontWeight: 500, fontFamily: fontC, cursor: "pointer",
display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
}}>{children}</button>
);
// Centered glass card shell — used by sign-in & sign-up
const CCardShell = ({ children, eyebrow }) => (
<div style={{
width: "100%", height: "100%", background: cc.bg, color: cc.text,
fontFamily: fontC, position: "relative", overflow: "hidden",
}}>
<AuroraBg/>
<GlassTopNav right={
<>
<button style={{
background: "transparent", border: "none", color: cc.text,
padding: "7px 12px", borderRadius: 999, fontSize: 13,
fontFamily: fontC, cursor: "pointer",
}}>Sign in</button>
<button style={{
background: "#fff", color: "#08081a", border: "none",
padding: "7px 14px", borderRadius: 999, fontSize: 13, fontWeight: 600,
fontFamily: fontC, cursor: "pointer",
}}>Get Lattice </button>
</>
} />
<main style={{
position: "relative", height: "100%",
display: "flex", alignItems: "center", justifyContent: "center",
padding: 24,
}}>
<div style={{
width: 460, padding: "32px 36px 36px", borderRadius: 22,
background: cc.glass, backdropFilter: "blur(28px)", WebkitBackdropFilter: "blur(28px)",
border: `1px solid ${cc.glassBorder}`,
boxShadow: `0 30px 80px -30px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.12)`,
}}>
{eyebrow && (
<div style={{
display: "inline-flex", alignItems: "center", gap: 8,
padding: "4px 12px 4px 4px", borderRadius: 999,
background: cc.glass, border: `1px solid ${cc.glassBorder}`,
fontSize: 11, color: cc.subtext, marginBottom: 16,
}}>
<span style={{
padding: "2px 8px", background: cc.brandA, color: "#fff",
borderRadius: 999, fontWeight: 600, fontSize: 10,
}}>BETA</span>
{eyebrow}
</div>
)}
{children}
</div>
</main>
{/* Footer dots */}
<div style={{
position: "absolute", bottom: 22, left: "50%", transform: "translateX(-50%)",
fontSize: 11, color: cc.muted, zIndex: 5,
display: "flex", gap: 18,
}}>
<span>Privacy</span><span>Terms</span><span>Security</span>
<span style={{ display: "inline-flex", alignItems: "center", gap: 5 }}>
<span style={{
width: 6, height: 6, borderRadius: "50%", background: cc.brandC,
boxShadow: `0 0 8px ${cc.brandC}`,
}}></span>
All systems normal
</span>
</div>
</div>
);
const CSignIn = () => (
<CCardShell>
<h1 style={{
fontSize: 32, fontWeight: 500, margin: 0, letterSpacing: "-0.03em",
}}>Welcome back.</h1>
<p style={{ fontSize: 14, color: cc.subtext, margin: "10px 0 26px" }}>
Sign in and pick up where you left off.
</p>
<div style={{ display: "flex", gap: 8, marginBottom: 18 }}>
<CSocial glyph={<GoogleGlyph/>}>Google</CSocial>
<CSocial glyph={<MicrosoftGlyph/>}>Microsoft</CSocial>
<CSocial glyph={<span style={{ color: cc.text, display: "flex" }}><AppleGlyph/></span>}>Apple</CSocial>
</div>
<div style={{
display: "flex", alignItems: "center", gap: 10,
fontSize: 11, color: cc.muted, margin: "0 0 18px",
}}>
<div style={{ flex: 1, height: 1, background: cc.glassBorder }}></div>
<span style={{ textTransform: "uppercase", letterSpacing: "0.08em" }}>or with email</span>
<div style={{ flex: 1, height: 1, background: cc.glassBorder }}></div>
</div>
<CField label="Email" value="mira@lattice.co" autofocus />
<div style={{ marginBottom: 18 }}>
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "baseline",
fontSize: 12, fontWeight: 500, color: cc.subtext, marginBottom: 6,
}}>
<span>Password</span>
<span style={{ color: cc.text, cursor: "pointer", fontWeight: 500 }}>Forgot?</span>
</div>
<div style={{
display: "flex", alignItems: "center", gap: 8,
padding: "11px 14px", borderRadius: 10,
background: cc.glass, backdropFilter: "blur(16px)",
border: `1px solid ${cc.glassBorder}`,
fontSize: 13, color: cc.text, letterSpacing: "0.2em",
}}>
<span style={{ flex: 1 }}></span>
<span style={{ color: cc.muted, display: "flex" }}><IcnC d={PC.eye} size={14}/></span>
</div>
</div>
<CPrimary>Sign in <IcnC d={PC.arrow} size={13}/></CPrimary>
<div style={{ fontSize: 12, color: cc.subtext, marginTop: 22, textAlign: "center" }}>
Don't have an account? <span style={{ color: cc.text, fontWeight: 500, cursor: "pointer" }}>
Sign up for free
</span>
</div>
</CCardShell>
);
const CSignUp = () => (
<CCardShell eyebrow="Lattice 4.0 · agents that draft for you">
<h1 style={{
fontSize: 32, fontWeight: 500, margin: 0, letterSpacing: "-0.03em",
}}>
Start your <span style={{
background: `linear-gradient(90deg, ${cc.brandB}, ${cc.brandA}, ${cc.brandC})`,
WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent",
fontStyle: "italic", fontWeight: 400,
}}>workspace</span>.
</h1>
<p style={{ fontSize: 14, color: cc.subtext, margin: "10px 0 22px" }}>
Free for 10 people. No card. 60 seconds to set up.
</p>
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
<CSocial glyph={<GoogleGlyph/>}>Google</CSocial>
<CSocial glyph={<MicrosoftGlyph/>}>Microsoft</CSocial>
</div>
<div style={{
display: "flex", alignItems: "center", gap: 10,
fontSize: 11, color: cc.muted, margin: "0 0 16px",
}}>
<div style={{ flex: 1, height: 1, background: cc.glassBorder }}></div>
<span style={{ textTransform: "uppercase", letterSpacing: "0.08em" }}>or with email</span>
<div style={{ flex: 1, height: 1, background: cc.glassBorder }}></div>
</div>
<CField label="Work email" value="mira@lattice.co" autofocus />
<CField label="Password" value="••••••••••" type="password"
hint="10+ chars · 1 number · 1 symbol — strong enough" />
<div style={{ display: "flex", alignItems: "flex-start", gap: 8, margin: "10px 0 18px" }}>
<div style={{
width: 16, height: 16, borderRadius: 4, marginTop: 1,
background: cc.brandA, color: "#fff",
display: "flex", alignItems: "center", justifyContent: "center",
}}><IcnC d={PC.check} size={11} sw={2.4}/></div>
<span style={{ fontSize: 12, color: cc.subtext, lineHeight: 1.5 }}>
I agree to Lattice's <span style={{ color: cc.text, fontWeight: 500 }}>Terms</span> and{" "}
<span style={{ color: cc.text, fontWeight: 500 }}>Privacy Policy</span>.
</span>
</div>
<CPrimary>Create my workspace <IcnC d={PC.arrow} size={13}/></CPrimary>
<div style={{ fontSize: 12, color: cc.subtext, marginTop: 22, textAlign: "center" }}>
Already on Lattice? <span style={{ color: cc.text, fontWeight: 500, cursor: "pointer" }}>
Sign in
</span>
</div>
</CCardShell>
);
const COnboarding = () => {
// Glass invite-teammates step — a single big card on aurora bg.
const ProgressDot = ({ state }) => (
<div style={{
width: state === "active" ? 26 : 10, height: 10,
borderRadius: 999,
background: state === "done" ? "#fff" :
state === "active" ? cc.brandA : cc.glassStrong,
transition: "all .2s",
}}></div>
);
const EmailRow = ({ email, role, status, color }) => (
<div style={{
display: "flex", alignItems: "center", gap: 12,
padding: "10px 12px", borderRadius: 10,
background: cc.glass,
backdropFilter: "blur(12px)",
border: `1px solid ${cc.glassBorder}`,
}}>
<div style={{
width: 28, height: 28, borderRadius: "50%", background: color,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 11, fontWeight: 600, color: "#3a2820",
}}>{email.slice(0, 2).toUpperCase()}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 13, color: cc.text, whiteSpace: "nowrap",
overflow: "hidden", textOverflow: "ellipsis",
}}>{email}</div>
</div>
<div style={{
display: "flex", alignItems: "center", gap: 6, padding: "3px 9px",
borderRadius: 6, background: cc.glassStrong, fontSize: 11, color: cc.text,
}}>{role} <IcnC d={<path d="m6 9 6 6 6-6"/>} size={11}/></div>
{status && <span style={{
fontSize: 11, padding: "2px 8px", borderRadius: 999,
background: status === "queued" ? `${cc.brandA}33` : "#22c55e33",
color: status === "queued" ? cc.brandA : "#7aff66",
}}>{status}</span>}
</div>
);
return (
<div style={{
width: "100%", height: "100%", background: cc.bg, color: cc.text,
fontFamily: fontC, position: "relative", overflow: "hidden",
}}>
<AuroraBg/>
{/* Brand top-left + skip top-right */}
<header style={{
position: "absolute", top: 22, left: 32, zIndex: 10,
display: "flex", alignItems: "center", gap: 8,
fontWeight: 600, fontSize: 14, color: cc.text,
}}>
<MarkC size={20} /> Lattice
</header>
<div style={{
position: "absolute", top: 26, right: 32, zIndex: 10,
fontSize: 12, color: cc.subtext,
}}>
Step 3 of 4 · <span style={{ color: cc.text, cursor: "pointer" }}>Skip</span>
</div>
<main style={{
position: "relative", height: "100%",
display: "flex", alignItems: "center", justifyContent: "center",
padding: 24,
}}>
<div style={{
width: 620, padding: "36px 40px 32px", borderRadius: 26,
background: cc.glass, backdropFilter: "blur(28px)", WebkitBackdropFilter: "blur(28px)",
border: `1px solid ${cc.glassBorder}`,
boxShadow: `0 40px 100px -30px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.12)`,
}}>
{/* Progress dots */}
<div style={{
display: "flex", alignItems: "center", justifyContent: "center",
gap: 8, marginBottom: 26,
}}>
<ProgressDot state="done" />
<ProgressDot state="done" />
<ProgressDot state="active" />
<ProgressDot state="todo" />
</div>
<h1 style={{
fontSize: 34, fontWeight: 500, margin: 0, letterSpacing: "-0.03em",
textAlign: "center", textWrap: "balance",
}}>
Lattice gets <em style={{
fontStyle: "italic", fontWeight: 400,
background: `linear-gradient(90deg, ${cc.brandB}, ${cc.brandC})`,
WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent",
}}>better</em> with your team.
</h1>
<p style={{
fontSize: 14, color: cc.subtext, margin: "12px 0 26px",
textAlign: "center", lineHeight: 1.5,
}}>
Invite the people you actually work with. You can always add more later.
</p>
{/* Invite input + role */}
<div style={{
display: "flex", gap: 8, marginBottom: 14,
}}>
<div style={{
flex: 1, display: "flex", alignItems: "center", gap: 8,
padding: "11px 14px", borderRadius: 12,
background: cc.glass,
backdropFilter: "blur(16px)",
border: `1px solid ${cc.brandA}`,
boxShadow: `0 0 0 3px ${cc.brandA}33`,
fontSize: 13, color: cc.subtext,
}}>
<span style={{ flex: 1 }}>name@company.com, separate with commas</span>
</div>
<div style={{
display: "flex", alignItems: "center", gap: 6,
padding: "11px 14px", borderRadius: 12, fontSize: 13, color: cc.text,
background: cc.glassStrong,
backdropFilter: "blur(16px)",
border: `1px solid ${cc.glassBorder}`,
cursor: "pointer", whiteSpace: "nowrap",
}}>
Member <IcnC d={<path d="m6 9 6 6 6-6"/>} size={12}/>
</div>
<button style={{
padding: "0 18px", borderRadius: 12, background: "#fff",
color: "#08081a", border: "none", fontFamily: fontC,
fontSize: 13, fontWeight: 600, cursor: "pointer",
}}>Send</button>
</div>
{/* Already queued list */}
<div style={{
fontSize: 11, color: cc.muted, letterSpacing: "0.08em",
textTransform: "uppercase", fontWeight: 500, margin: "12px 0 8px",
}}>To be invited · 3</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<EmailRow email="theo@lattice.co" role="Admin" status="queued" color="#c8e8a8" />
<EmailRow email="devi@lattice.co" role="Admin" status="queued" color="#a8c8e8" />
<EmailRow email="sun@lattice.co" role="Member" status="queued" color="#e8a87c" />
</div>
{/* Shareable link */}
<div style={{
marginTop: 18, padding: "12px 14px", borderRadius: 12,
background: cc.glass,
backdropFilter: "blur(16px)",
border: `1px dashed ${cc.glassBorder}`,
display: "flex", alignItems: "center", gap: 12,
}}>
<div style={{
width: 30, height: 30, borderRadius: 8,
background: cc.glassStrong,
display: "flex", alignItems: "center", justifyContent: "center",
color: cc.brandA,
}}><IcnC d={PC.workflow} size={14}/></div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>Or share an invite link</div>
<div style={{
fontSize: 12, color: cc.subtext, fontFamily: "monospace",
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
}}>lattice.app/join/mira-reyes-7f4ac</div>
</div>
<button style={{
padding: "6px 12px", borderRadius: 999, fontSize: 12,
fontFamily: fontC, background: cc.glassStrong,
border: `1px solid ${cc.glassBorder}`, color: cc.text, cursor: "pointer",
}}>Copy link</button>
</div>
{/* Footer buttons */}
<div style={{
display: "flex", justifyContent: "space-between", marginTop: 28, alignItems: "center",
}}>
<button style={{
background: "transparent", border: "none", color: cc.subtext,
fontSize: 13, fontFamily: fontC, cursor: "pointer", padding: 0,
}}>I'll do this later</button>
<CPrimary full={false}>Send invites & continue <IcnC d={PC.arrow} size={13}/></CPrimary>
</div>
</div>
</main>
</div>
);
};
Object.assign(window, { CSignIn, CSignUp, COnboarding });

View File

@@ -0,0 +1,379 @@
/* Shared auth styles — Sign In + Sign Up. Same tokens as the rest of the
site; declared inline here so each auth page is self-sufficient. */
:root {
--bg: oklch(0.155 0.008 60);
--bg-1: oklch(0.185 0.009 60);
--bg-2: oklch(0.225 0.010 60);
--hairline: oklch(0.32 0.010 60 / 0.55);
--hairline-2: oklch(0.40 0.012 60 / 0.35);
--fg: oklch(0.97 0.005 80);
--fg-dim: oklch(0.78 0.006 80);
--fg-mute: oklch(0.58 0.006 80);
--fg-faint: oklch(0.42 0.006 80);
--accent: oklch(0.74 0.175 35);
--accent-soft: oklch(0.74 0.175 35 / 0.18);
--accent-glow: oklch(0.74 0.175 35 / 0.35);
--accent-fg: #1a0f0a;
--ok: oklch(0.78 0.16 155);
--font-sans: "Geist", ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; min-height: 100%; }
body {
background: var(--bg);
color: var(--fg);
font-family: var(--font-sans);
line-height: 1.45;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overflow-x: hidden;
}
/* Ambient grid */
body::before {
content: "";
position: fixed; inset: 0;
background-image:
linear-gradient(to right, oklch(0.30 0.01 60 / 0.10) 1px, transparent 1px),
linear-gradient(to bottom, oklch(0.30 0.01 60 / 0.10) 1px, transparent 1px);
background-size: 56px 56px;
mask-image: radial-gradient(ellipse 70% 70% at 50% 40%, #000 30%, transparent 80%);
-webkit-mask-image: radial-gradient(ellipse 70% 70% at 50% 40%, #000 30%, transparent 80%);
pointer-events: none;
z-index: 0;
}
/* Film grain */
body::after {
content: "";
position: fixed; inset: 0;
pointer-events: none;
z-index: 1;
opacity: 0.035;
mix-blend-mode: overlay;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%25' height='100%25' filter='url(%23n)' opacity='0.85'/></svg>");
}
a { color: inherit; text-decoration: none; }
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
h1, h2, h3 { margin: 0; font-weight: 500; letter-spacing: -0.02em; line-height: 1.05; }
p { margin: 0; }
::selection { background: var(--accent); color: var(--accent-fg); }
/* Layout */
.page {
position: relative;
z-index: 2;
min-height: 100dvh;
display: flex; flex-direction: column;
}
.topbar {
position: relative; z-index: 5;
padding: 22px clamp(20px, 4vw, 48px);
display: flex; align-items: center; justify-content: space-between;
}
.topbar a:hover { color: var(--fg); }
.topbar-back {
color: var(--fg-mute);
font-size: 14px;
display: inline-flex; align-items: center; gap: 6px;
}
.logo {
display: inline-flex; align-items: center; gap: 9px;
font-weight: 600; font-size: 17px; letter-spacing: -0.02em;
color: var(--fg);
}
.logo-mark {
width: 26px; height: 26px; border-radius: 50%;
background: linear-gradient(135deg, var(--accent) 0%, oklch(0.65 0.20 18) 100%);
box-shadow: 0 0 22px var(--accent-glow), inset 0 1px 0 oklch(1 0 0 / 0.25);
display: grid; place-items: center;
color: var(--accent-fg);
flex-shrink: 0;
}
.logo-mark svg { display: block; }
.logo-caret { animation: caret-blink 1.4s steps(2) infinite; }
@keyframes caret-blink { 50% { opacity: 0.25; } }
/* Main */
.auth-main {
flex: 1;
display: grid; place-items: center;
padding: clamp(20px, 4vw, 40px);
position: relative;
}
/* Ambient glows */
.auth-glow {
position: absolute;
pointer-events: none;
filter: blur(20px);
z-index: 0;
}
/* Card */
.auth-card {
position: relative;
z-index: 2;
width: 100%; max-width: 440px;
padding: 36px clamp(24px, 4vw, 40px) 32px;
background: linear-gradient(180deg, oklch(0.20 0.009 60 / 0.85), oklch(0.17 0.008 60 / 0.85));
border: 1px solid var(--hairline);
border-radius: 22px;
backdrop-filter: blur(20px);
box-shadow:
0 30px 80px -20px oklch(0 0 0 / 0.7),
0 0 80px -30px var(--accent-glow);
}
.auth-card::before {
content: "";
position: absolute; left: 0; right: 0; top: 0; height: 1px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
opacity: .6;
}
/* Header */
.auth-eye {
display: inline-flex; align-items: center; gap: 8px;
font-family: var(--font-mono);
font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase;
color: var(--fg-mute);
}
.auth-eye::before {
content: ""; width: 5px; height: 5px; border-radius: 50%;
background: var(--accent); box-shadow: 0 0 12px var(--accent-glow);
}
.auth-title {
margin-top: 14px;
font-size: clamp(26px, 3.4vw, 34px);
font-weight: 500;
letter-spacing: -0.022em;
line-height: 1.1;
text-wrap: balance;
}
.auth-title em {
font-style: normal;
color: var(--accent);
text-shadow: 0 0 30px var(--accent-glow);
}
.auth-sub {
margin-top: 10px;
color: var(--fg-mute);
font-size: 14.5px;
line-height: 1.5;
text-wrap: balance;
}
/* Form */
.auth-form {
margin-top: 24px;
display: flex; flex-direction: column;
gap: 12px;
}
.auth-field {
display: flex; flex-direction: column;
gap: 6px;
}
.auth-label {
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--fg-mute);
padding-left: 4px;
}
.auth-input {
width: 100%;
padding: 13px 16px;
background: oklch(0.16 0.008 60 / 0.8);
border: 1px solid var(--hairline);
border-radius: 12px;
color: var(--fg);
font: 15px/1.5 var(--font-sans);
outline: none;
transition: border-color .15s, background .15s, box-shadow .15s;
}
.auth-input::placeholder { color: var(--fg-faint); }
.auth-input:focus {
border-color: oklch(0.74 0.175 35 / 0.65);
background: oklch(0.18 0.009 60 / 0.95);
box-shadow: 0 0 0 3px oklch(0.74 0.175 35 / 0.12), 0 0 30px -10px var(--accent-glow);
}
.auth-input.mono { font-family: var(--font-mono); letter-spacing: 0.08em; text-transform: uppercase; }
.auth-input.mono::placeholder { letter-spacing: 0.08em; }
/* Buttons */
.auth-btn {
display: inline-flex; align-items: center; justify-content: center; gap: 10px;
height: 50px; padding: 0 22px;
border-radius: 999px;
font-weight: 500;
font-size: 15px;
transition: transform .12s, box-shadow .2s, background .2s;
white-space: nowrap;
width: 100%;
}
.auth-btn-primary {
background: var(--accent);
color: var(--accent-fg);
box-shadow:
0 0 0 1px oklch(0.84 0.16 35 / 0.5) inset,
0 10px 40px -10px var(--accent-glow),
0 0 40px -8px var(--accent-glow);
}
.auth-btn-primary:hover { transform: translateY(-1px); }
.auth-btn-primary[disabled] { opacity: .55; cursor: not-allowed; transform: none; }
.auth-btn-ghost {
background: oklch(0.20 0.009 60 / 0.6);
border: 1px solid var(--hairline);
color: var(--fg-dim);
}
.auth-btn-ghost:hover { color: var(--fg); border-color: var(--hairline-2); background: oklch(0.22 0.010 60 / 0.8); }
.auth-btn-ghost img, .auth-btn-ghost svg { flex-shrink: 0; }
/* Divider */
.auth-divider {
display: flex; align-items: center; gap: 14px;
margin: 6px 0 2px;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--fg-faint);
}
.auth-divider::before, .auth-divider::after {
content: ""; flex: 1; height: 1px; background: var(--hairline);
}
/* OAuth row */
.auth-oauth {
display: flex; flex-direction: column; gap: 10px;
}
/* Footer */
.auth-foot {
margin-top: 26px;
padding-top: 22px;
border-top: 1px solid var(--hairline);
text-align: center;
font-size: 14px;
color: var(--fg-mute);
}
.auth-foot a {
color: var(--accent);
font-weight: 500;
}
.auth-foot a:hover { text-decoration: underline; text-underline-offset: 3px; }
.auth-fine {
margin-top: 18px;
text-align: center;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.04em;
color: var(--fg-faint);
}
.auth-fine a { color: var(--fg-mute); text-decoration: underline; text-underline-offset: 3px; }
/* Spinner */
.auth-spinner {
width: 16px; height: 16px; border-radius: 50%;
border: 2px solid oklch(0 0 0 / 0.2);
border-top-color: var(--accent-fg);
animation: spin .9s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Confirmed state */
.auth-success {
text-align: center;
}
.auth-success-badge {
display: inline-grid; place-items: center;
width: 56px; height: 56px;
border-radius: 50%;
margin-bottom: 16px;
color: var(--accent);
background: oklch(0.74 0.175 35 / 0.12);
border: 1px solid oklch(0.74 0.175 35 / 0.4);
box-shadow: 0 0 40px var(--accent-glow);
}
.auth-success .email-chip {
display: inline-block;
margin: 0 4px;
padding: 2px 9px;
border-radius: 6px;
background: oklch(0.74 0.175 35 / 0.12);
border: 1px solid oklch(0.74 0.175 35 / 0.3);
color: var(--accent);
font-family: var(--font-mono);
font-size: 13.5px;
font-weight: 500;
letter-spacing: 0;
text-transform: none;
white-space: nowrap;
}
.auth-tip {
margin-top: 22px;
padding: 14px 16px;
border-radius: 10px;
background: oklch(0.16 0.008 60 / 0.6);
border: 1px solid var(--hairline);
display: flex; gap: 12px;
text-align: left;
font-size: 13px; line-height: 1.5;
color: var(--fg-dim);
}
.auth-tip-icon {
flex-shrink: 0;
width: 22px; height: 22px; border-radius: 6px;
background: oklch(0.22 0.011 60);
color: var(--fg-mute);
display: grid; place-items: center;
}
.auth-tip a { color: var(--accent); }
.auth-tip a:hover { text-decoration: underline; text-underline-offset: 3px; }
/* Resend */
.auth-resend {
display: inline-flex; align-items: center; gap: 8px;
margin-top: 20px;
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.03em;
color: var(--fg-mute);
}
.auth-resend button {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 3px;
}
.auth-resend button:hover { color: oklch(0.78 0.16 35); }
.auth-resend button[disabled] {
color: var(--fg-faint);
text-decoration: none;
cursor: not-allowed;
}
/* Trust strip in footer area */
.auth-trust {
margin-top: 32px;
display: flex; gap: 14px; justify-content: center; align-items: center;
flex-wrap: wrap;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.03em;
color: var(--fg-faint);
}
.auth-trust .sep { color: var(--fg-faint); opacity: 0.5; }

View File

@@ -56,12 +56,12 @@ function Closing() {
<div className="wrap closing-inner">
<h2 className="closing-title">
If you can <em>describe</em> it,
<br/>you can <em>build</em> it.
If you can <em>describe your business</em>,
<br/>you can <em>build the tool</em> that runs 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.
Owned by you. No monthly rent. No homework.
<br/>All the way to customers.
</p>
<div className="closing-cta">

View File

@@ -0,0 +1,966 @@
// DesignCanvas.jsx — Figma-ish design canvas wrapper
// Warm gray grid bg + Sections + Artboards + PostIt notes.
// Artboards are reorderable (grip-drag), deletable, labels/titles are
// inline-editable, and any artboard can be opened in a fullscreen focus
// overlay (←/→/Esc). State persists to a .design-canvas.state.json sidecar
// via the host bridge. No assets, no deps.
//
// Usage:
// <DesignCanvas>
// <DCSection id="onboarding" title="Onboarding" subtitle="First-run variants">
// <DCArtboard id="a" label="A · Dusk" width={260} height={480}>…</DCArtboard>
// <DCArtboard id="b" label="B · Minimal" width={260} height={480}>…</DCArtboard>
// </DCSection>
// </DesignCanvas>
const DC = {
bg: '#f0eee9',
grid: 'rgba(0,0,0,0.06)',
label: 'rgba(60,50,40,0.7)',
title: 'rgba(40,30,20,0.85)',
subtitle: 'rgba(60,50,40,0.6)',
postitBg: '#fef4a8',
postitText: '#5a4a2a',
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
};
// One-time CSS injection (classes are dc-prefixed so they don't collide with
// the hosted design's own styles).
if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
const s = document.createElement('style');
s.id = 'dc-styles';
s.textContent = [
'.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}',
'.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}',
'[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}',
'[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}',
'[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}',
// isolation:isolate contains artboard content's z-indexes so a
// z-indexed child (sticky navbar etc.) can't paint over .dc-header or
// the .dc-menu popover that drops into the top of the card.
'.dc-card{isolation:isolate;transition:box-shadow .15s,transform .15s}',
'.dc-card *{scrollbar-width:none}',
'.dc-card *::-webkit-scrollbar{display:none}',
// Per-artboard header: grip + label on the left, delete/expand on the
// right. Single flex row; when the artboard's on-screen width is too
// narrow for both the label yields (ellipsis, then hidden entirely below
// ~4ch via the container query) and the buttons stay on the row.
'.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;',
' display:flex;align-items:center;container-type:inline-size}',
'.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}',
'.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}',
'.dc-grip:hover{background:rgba(0,0,0,.08)}',
'.dc-grip:active{cursor:grabbing}',
'.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;',
' display:flex;align-items:center;transition:background .12s;overflow:hidden}',
// Below ~4ch of label room: hide the label entirely, and drop the grip to
// hover-only (same reveal rule as .dc-btns) so a narrow header is clean
// until the card is moused.
'@container (max-width: 110px){',
' .dc-labeltext{display:none}',
' .dc-grip{opacity:0}',
' [data-dc-slot]:hover .dc-grip{opacity:1}',
'}',
'.dc-labeltext:hover{background:rgba(0,0,0,.05)}',
'.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}',
'.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}',
'.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}',
'[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-menu){opacity:1}',
'.dc-expand,.dc-kebab{width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;',
' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;',
' font:inherit;transition:background .12s,color .12s}',
'.dc-expand:hover,.dc-kebab:hover{background:rgba(0,0,0,.06);color:#2a251f}',
// Slot hosting an open menu floats above later siblings (which otherwise
// paint on top — same z-index:auto, later DOM order) so the popup isn't
// clipped by the next card.
'[data-dc-slot]:has(.dc-menu){z-index:10}',
'.dc-menu{position:absolute;top:100%;right:0;margin-top:4px;background:#fff;border-radius:8px;',
' box-shadow:0 8px 28px rgba(0,0,0,.18),0 0 0 1px rgba(0,0,0,.05);padding:4px;min-width:160px;z-index:10}',
'.dc-menu button{display:block;width:100%;padding:7px 10px;border:0;background:transparent;',
' border-radius:5px;font-family:inherit;font-size:13px;font-weight:500;line-height:1.2;',
' color:#29261b;cursor:pointer;text-align:left;transition:background .12s;white-space:nowrap}',
'.dc-menu button:hover{background:rgba(0,0,0,.05)}',
'.dc-menu hr{border:0;border-top:1px solid rgba(0,0,0,.08);margin:4px 2px}',
'.dc-menu .dc-danger{color:#c96442}',
'.dc-menu .dc-danger:hover{background:rgba(201,100,66,.1)}',
// Chrome (titles / labels / buttons) counter-scales against the viewport
// zoom so it stays a constant on-screen size. --dc-inv-zoom is set by
// DCViewport on every transform update and inherits to all descendants —
// any overlay inside the world (e.g. a TweaksPanel on an artboard) can use
// it the same way.
//
// The header uses transform:scale (out-of-flow, so layout impact doesn't
// matter) with its world-space width set to card-width / inv-zoom so that
// after counter-scaling its on-screen width exactly matches the card's —
// that's what lets the container query + text-overflow behave against the
// card's visible edge at every zoom level.
//
// The section head uses CSS zoom instead of transform so its layout box
// grows with the counter-scale, pushing the card row down — otherwise the
// constant-screen-size title would overflow into the (shrinking) world-
// space gap and overlap the artboard headers at low zoom.
'.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));',
' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left}',
'.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}',
].join('\n');
document.head.appendChild(s);
}
const DCCtx = React.createContext(null);
// Recursively unwrap React.Fragment so <>…</> grouping doesn't hide
// DCSection/DCArtboard children from the type-based walks below.
function dcFlatten(children) {
const out = [];
React.Children.forEach(children, (c) => {
if (c && c.type === React.Fragment) out.push(...dcFlatten(c.props.children));
else out.push(c);
});
return out;
}
// ─────────────────────────────────────────────────────────────
// DesignCanvas — stateful wrapper around the pan/zoom viewport.
// Owns runtime state (per-section order, renamed titles/labels, hidden
// artboards, focused artboard). Order/titles/labels/hidden persist to a
// .design-canvas.state.json
// sidecar next to the HTML. Reads go via plain fetch() so the saved
// arrangement is visible anywhere the HTML + sidecar are served together
// (omelette preview, direct link, downloaded zip). Writes go through the
// host's window.omelette bridge — editing requires the omelette runtime.
// Focus is ephemeral.
// ─────────────────────────────────────────────────────────────
const DC_STATE_FILE = '.design-canvas.state.json';
function DesignCanvas({ children, minScale, maxScale, style }) {
const [state, setState] = React.useState({ sections: {}, focus: null });
// Hold rendering until the sidecar read settles so the saved order/titles
// appear on first paint (no source-order flash). didRead gates writes until
// the read settles so the empty initial state can't clobber a slow read;
// skipNextWrite suppresses the one echo-write that would otherwise follow
// hydration.
const [ready, setReady] = React.useState(false);
const didRead = React.useRef(false);
const skipNextWrite = React.useRef(false);
React.useEffect(() => {
let off = false;
fetch('./' + DC_STATE_FILE)
.then((r) => (r.ok ? r.json() : null))
.then((saved) => {
if (off || !saved || !saved.sections) return;
skipNextWrite.current = true;
setState((s) => ({ ...s, sections: saved.sections }));
})
.catch(() => {})
.finally(() => { didRead.current = true; if (!off) setReady(true); });
const t = setTimeout(() => { if (!off) setReady(true); }, 150);
return () => { off = true; clearTimeout(t); };
}, []);
React.useEffect(() => {
if (!didRead.current) return;
if (skipNextWrite.current) { skipNextWrite.current = false; return; }
const t = setTimeout(() => {
window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {});
}, 250);
return () => clearTimeout(t);
}, [state.sections]);
// Build registries synchronously from children so FocusOverlay can read
// them in the same render. Fragments are flattened; wrapping in other
// elements still opts out of focus/reorder.
const registry = {}; // slotId -> { sectionId, artboard }
const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] }
const sectionOrder = [];
dcFlatten(children).forEach((sec) => {
if (!sec || sec.type !== DCSection) return;
const sid = sec.props.id ?? sec.props.title;
if (!sid) return;
sectionOrder.push(sid);
const persisted = state.sections[sid] || {};
const abs = [];
dcFlatten(sec.props.children).forEach((ab) => {
if (!ab || ab.type !== DCArtboard) return;
const aid = ab.props.id ?? ab.props.label;
if (aid) abs.push([aid, ab]);
});
// hidden is scoped to one source revision — when the agent regenerates
// (artboard-ID set changes), prior deletes don't apply to new content.
const srcKey = abs.map(([k]) => k).join('\x1f');
const hidden = persisted.srcKey === srcKey ? (persisted.hidden || []) : [];
const srcIds = [];
abs.forEach(([aid, ab]) => {
if (hidden.includes(aid)) return;
registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab };
srcIds.push(aid);
});
const kept = (persisted.order || []).filter((k) => srcIds.includes(k));
sectionMeta[sid] = {
title: persisted.title ?? sec.props.title,
subtitle: sec.props.subtitle,
slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))],
};
});
const api = React.useMemo(() => ({
state,
section: (id) => state.sections[id] || {},
patchSection: (id, p) => setState((s) => ({
...s,
sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } },
})),
setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })),
}), [state]);
// Esc exits focus; any outside pointerdown commits an in-progress rename.
React.useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); };
const onPd = (e) => {
const ae = document.activeElement;
if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur();
};
document.addEventListener('keydown', onKey);
document.addEventListener('pointerdown', onPd, true);
return () => {
document.removeEventListener('keydown', onKey);
document.removeEventListener('pointerdown', onPd, true);
};
}, [api]);
return (
<DCCtx.Provider value={api}>
<DCViewport minScale={minScale} maxScale={maxScale} style={style}>{ready && children}</DCViewport>
{state.focus && registry[state.focus] && (
<DCFocusOverlay entry={registry[state.focus]} sectionMeta={sectionMeta} sectionOrder={sectionOrder} />
)}
</DCCtx.Provider>
);
}
// ─────────────────────────────────────────────────────────────
// DCViewport — transform-based pan/zoom (internal)
//
// Input mapping (Figma-style):
// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events)
// • trackpad scroll → pan (two-finger)
// • mouse wheel → zoom (notched; distinguished from trackpad scroll)
// • middle-drag / primary-drag-on-bg → pan
//
// Transform state lives in a ref and is written straight to the DOM
// (translate3d + will-change) so wheel ticks don't go through React —
// keeps pans at 60fps on dense canvases.
// ─────────────────────────────────────────────────────────────
function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
const vpRef = React.useRef(null);
const worldRef = React.useRef(null);
const tf = React.useRef({ x: 0, y: 0, scale: 1 });
// Persist viewport across reloads so the user lands back where they were
// after an agent edit or browser refresh. The sandbox origin is already
// per-project; pathname keeps multiple canvas files in one project apart.
const tfKey = 'dc-viewport:' + location.pathname;
const saveT = React.useRef(0);
const lastPostedScale = React.useRef();
const apply = React.useCallback(() => {
const { x, y, scale } = tf.current;
const el = worldRef.current;
if (!el) return;
el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
// Exposed for zoom-invariant chrome (labels, buttons, TweaksPanel).
el.style.setProperty('--dc-inv-zoom', String(1 / scale));
// Keep the host toolbar's % readout in sync with the canvas scale. Pan
// ticks leave scale unchanged — skip the cross-frame post for those.
if (lastPostedScale.current !== scale) {
lastPostedScale.current = scale;
window.parent.postMessage({ type: '__dc_zoom', scale }, '*');
}
clearTimeout(saveT.current);
saveT.current = setTimeout(() => {
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
}, 200);
}, [tfKey]);
React.useLayoutEffect(() => {
const flush = () => {
clearTimeout(saveT.current);
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
};
try {
const s = JSON.parse(localStorage.getItem(tfKey) || 'null');
if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) {
tf.current = { x: s.x, y: s.y, scale: Math.min(maxScale, Math.max(minScale, s.scale)) };
apply();
}
} catch {}
// Flush on pagehide and unmount so a reload within the 200ms debounce
// window doesn't drop the last pan/zoom.
window.addEventListener('pagehide', flush);
return () => { window.removeEventListener('pagehide', flush); flush(); };
}, []);
React.useEffect(() => {
const vp = vpRef.current;
if (!vp) return;
const zoomAt = (cx, cy, factor) => {
const r = vp.getBoundingClientRect();
const px = cx - r.left, py = cy - r.top;
const t = tf.current;
const next = Math.min(maxScale, Math.max(minScale, t.scale * factor));
const k = next / t.scale;
// --dc-inv-zoom consumers (.dc-sectionhead's CSS zoom, each section's
// marginBottom) reflow on every scale change, vertically shifting the
// world layout — so a world point mathematically pinned under the cursor
// drifts as you zoom (content creeps up on zoom-in, down on zoom-out).
// Anchor the DOM element under the cursor instead: record its screen Y,
// apply the transform + --dc-inv-zoom, then cancel whatever vertical
// drift the reflow introduced so it stays put on screen.
let marker = null, markerY0 = 0;
if (k !== 1) {
const hit = document.elementFromPoint(cx, cy);
marker = hit && hit.closest ? hit.closest('[data-dc-slot],[data-dc-section]') : null;
if (marker) markerY0 = marker.getBoundingClientRect().top;
}
// keep the world point under the cursor fixed
t.x = px - (px - t.x) * k;
t.y = py - (py - t.y) * k;
t.scale = next;
apply();
if (marker) {
// A pure zoom around (cx, cy) maps screen Y → cy + (Y - cy) * k. Any
// departure after the --dc-inv-zoom reflow is the layout drift.
const drift = marker.getBoundingClientRect().top - (cy + (markerY0 - cy) * k);
if (Math.abs(drift) > 0.1) { t.y -= drift; apply(); }
}
};
// Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends
// line-mode deltas (Firefox) or large integer pixel deltas with no X
// component (Chrome/Safari, typically multiples of 100/120). Trackpad
// two-finger scroll sends small/fractional pixel deltas, often with
// non-zero deltaX. ctrlKey is set by the browser for trackpad pinch.
const isMouseWheel = (e) =>
e.deltaMode !== 0 ||
(e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);
const onWheel = (e) => {
e.preventDefault();
if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels
if ((e.ctrlKey || e.metaKey) && !isMouseWheel(e)) {
// trackpad pinch, or ctrl/cmd + smooth-scroll mouse. Notched
// wheels fall through to the fixed-step branch below.
zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01));
} else if (isMouseWheel(e)) {
// notched mouse wheel — fixed-ratio step per click
zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18));
} else {
// trackpad two-finger scroll — pan
tf.current.x -= e.deltaX;
tf.current.y -= e.deltaY;
apply();
}
};
// Safari sends native gesture* events for trackpad pinch with a smooth
// e.scale; preferring these over the ctrl+wheel fallback gives a much
// better feel there. No-ops on other browsers. Safari also fires
// ctrlKey wheel events during the same pinch — isGesturing makes
// onWheel drop those entirely so they neither zoom nor pan.
let gsBase = 1;
let isGesturing = false;
const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; };
const onGestureChange = (e) => {
e.preventDefault();
zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale);
};
const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; };
// Drag-pan: middle button anywhere, or primary button on canvas
// background (anything that isn't an artboard or an inline editor).
let drag = null;
const onPointerDown = (e) => {
const onBg = !e.target.closest('[data-dc-slot], .dc-editable');
if (!(e.button === 1 || (e.button === 0 && onBg))) return;
e.preventDefault();
vp.setPointerCapture(e.pointerId);
drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY };
vp.style.cursor = 'grabbing';
};
const onPointerMove = (e) => {
if (!drag || e.pointerId !== drag.id) return;
tf.current.x += e.clientX - drag.lx;
tf.current.y += e.clientY - drag.ly;
drag.lx = e.clientX; drag.ly = e.clientY;
apply();
};
const onPointerUp = (e) => {
if (!drag || e.pointerId !== drag.id) return;
vp.releasePointerCapture(e.pointerId);
drag = null;
vp.style.cursor = '';
};
// Host-driven zoom (toolbar % menu). Zooms around viewport centre so the
// visible midpoint stays fixed — matching the host's iframe-zoom feel.
const onHostMsg = (e) => {
const d = e.data;
if (d && d.type === '__dc_set_zoom' && typeof d.scale === 'number') {
const r = vp.getBoundingClientRect();
zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / tf.current.scale);
} else if (d && d.type === '__dc_probe') {
// Host's [readyGen] reset asks whether a canvas is present; it
// fires on the iframe's native 'load', which for canvases with
// images/fonts is after our mount-time announce, so re-announce.
// Clear the pan-tick guard so apply() re-posts the current scale
// even if it's unchanged — the host just reset dcScale to 1.
window.parent.postMessage({ type: '__dc_present' }, '*');
lastPostedScale.current = undefined;
apply();
}
};
window.addEventListener('message', onHostMsg);
// Announce canvas mode so the host toolbar proxies its % control here
// instead of scaling the iframe element (which would just shrink the
// viewport window of an infinite canvas). The apply() that follows emits
// the initial __dc_zoom so the toolbar % is correct before first pinch.
// lastPostedScale reset mirrors the __dc_probe handler: the layout
// effect's restore-path apply() may already have posted the restored
// scale (before __dc_present), so clear the guard to re-post it in order.
window.parent.postMessage({ type: '__dc_present' }, '*');
lastPostedScale.current = undefined;
apply();
vp.addEventListener('wheel', onWheel, { passive: false });
vp.addEventListener('gesturestart', onGestureStart, { passive: false });
vp.addEventListener('gesturechange', onGestureChange, { passive: false });
vp.addEventListener('gestureend', onGestureEnd, { passive: false });
vp.addEventListener('pointerdown', onPointerDown);
vp.addEventListener('pointermove', onPointerMove);
vp.addEventListener('pointerup', onPointerUp);
vp.addEventListener('pointercancel', onPointerUp);
return () => {
window.removeEventListener('message', onHostMsg);
vp.removeEventListener('wheel', onWheel);
vp.removeEventListener('gesturestart', onGestureStart);
vp.removeEventListener('gesturechange', onGestureChange);
vp.removeEventListener('gestureend', onGestureEnd);
vp.removeEventListener('pointerdown', onPointerDown);
vp.removeEventListener('pointermove', onPointerMove);
vp.removeEventListener('pointerup', onPointerUp);
vp.removeEventListener('pointercancel', onPointerUp);
};
}, [apply, minScale, maxScale]);
const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`;
return (
<div
ref={vpRef}
className="design-canvas"
style={{
height: '100vh', width: '100vw',
background: DC.bg,
overflow: 'hidden',
overscrollBehavior: 'none',
touchAction: 'none',
position: 'relative',
fontFamily: DC.font,
boxSizing: 'border-box',
...style,
}}
>
<div
ref={worldRef}
style={{
position: 'absolute', top: 0, left: 0,
transformOrigin: '0 0',
willChange: 'transform',
width: 'max-content', minWidth: '100%',
minHeight: '100%',
padding: '60px 0 80px',
}}
>
<div style={{ position: 'absolute', inset: -6000, backgroundImage: gridSvg, backgroundSize: '120px 120px', pointerEvents: 'none', zIndex: -1 }} />
{children}
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// DCSection — editable title + h-row of artboards in persisted order
// ─────────────────────────────────────────────────────────────
function DCSection({ id, title, subtitle, children, gap = 48 }) {
const ctx = React.useContext(DCCtx);
const sid = id ?? title;
const all = React.Children.toArray(dcFlatten(children));
const artboards = all.filter((c) => c && c.type === DCArtboard);
const rest = all.filter((c) => !(c && c.type === DCArtboard));
const sec = (ctx && sid && ctx.section(sid)) || {};
// Must match DesignCanvas's srcKey computation exactly (it filters falsy
// IDs), or onDelete persists a srcKey that DesignCanvas never recognizes.
const allIds = artboards.map((a) => a.props.id ?? a.props.label).filter(Boolean);
const srcKey = allIds.join('\x1f');
const hidden = sec.srcKey === srcKey ? (sec.hidden || []) : [];
const srcOrder = allIds.filter((k) => !hidden.includes(k));
const order = React.useMemo(() => {
const kept = (sec.order || []).filter((k) => srcOrder.includes(k));
return [...kept, ...srcOrder.filter((k) => !kept.includes(k))];
}, [sec.order, srcOrder.join('|')]);
const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a]));
// marginBottom counter-scales so the on-screen gap between sections stays
// constant — otherwise at low zoom the (world-space) gap collapses while
// the screen-constant sectionhead below it doesn't, and the title reads as
// belonging to the section above. paddingBottom below is just enough for
// the 24px artboard-header (abs-positioned above each card) plus ~8px, so
// the title sits tight against its own row at every zoom.
return (
<div data-dc-section={sid}
style={{ marginBottom: 'calc(80px * var(--dc-inv-zoom, 1))', position: 'relative' }}>
<div style={{ padding: '0 60px' }}>
<div className="dc-sectionhead" style={{ paddingBottom: 36 }}>
<DCEditable tag="div" value={sec.title ?? title}
onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
{subtitle && <div style={{ fontSize: 16, color: DC.subtitle }}>{subtitle}</div>}
</div>
</div>
<div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
{order.map((k) => (
<DCArtboardFrame key={k} sectionId={sid} artboard={byId[k]} order={order}
label={(sec.labels || {})[k] ?? byId[k].props.label}
onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })}
onDelete={() => ctx && ctx.patchSection(sid, (x) => ({
hidden: [...(x.srcKey === srcKey ? (x.hidden || []) : []), k],
srcKey,
}))}
onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} />
))}
</div>
{rest}
</div>
);
}
// DCArtboard — marker; rendered by DCArtboardFrame via DCSection.
function DCArtboard() { return null; }
// Per-artboard export (kind: 'png' | 'html'). Both paths share the same
// self-contained clone: computed styles baked in, @font-face / <img> /
// inline-style background-image urls inlined as data URIs. PNG wraps the
// clone in foreignObject→canvas at 3× the artboard's natural width×height
// (same pipeline the host uses for page captures); HTML wraps it in a
// minimal standalone document. Both are independent of viewport zoom.
async function dcExport(node, w, h, name, kind) {
try { await document.fonts.ready; } catch {}
const toDataURL = (url) => fetch(url).then((r) => r.blob()).then((b) => new Promise((res) => {
const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = () => res(url); fr.readAsDataURL(b);
})).catch(() => url);
// Collect @font-face rules. ss.cssRules throws SecurityError on
// cross-origin sheets (e.g. fonts.googleapis.com) — in that case fetch
// the CSS text directly (those endpoints send ACAO:*) and regex-extract
// the blocks. @import and @media/@supports are walked so nested
// @font-face rules aren't missed.
const fontRules = [], pending = [], seen = new Set();
const scrapeCss = (href) => {
if (seen.has(href)) return; seen.add(href);
pending.push(fetch(href).then((r) => r.text()).then((css) => {
for (const m of css.match(/@font-face\s*{[^}]*}/g) || []) fontRules.push({ css: m, base: href });
for (const m of css.matchAll(/@import\s+(?:url\()?['"]?([^'")\s;]+)/g))
scrapeCss(new URL(m[1], href).href);
}).catch(() => {}));
};
const walk = (rules, base) => {
for (const r of rules) {
if (r.type === CSSRule.FONT_FACE_RULE) fontRules.push({ css: r.cssText, base });
else if (r.type === CSSRule.IMPORT_RULE && r.styleSheet) {
const ibase = r.styleSheet.href || base;
try { walk(r.styleSheet.cssRules, ibase); } catch { scrapeCss(ibase); }
} else if (r.cssRules) walk(r.cssRules, base);
}
};
for (const ss of document.styleSheets) {
const base = ss.href || location.href;
try { walk(ss.cssRules, base); } catch { if (ss.href) scrapeCss(ss.href); }
}
while (pending.length) await pending.shift();
const fontCss = (await Promise.all(fontRules.map(async (rule) => {
let out = rule.css, m; const re = /url\((['"]?)([^'")]+)\1\)/g;
while ((m = re.exec(rule.css))) {
if (m[2].indexOf('data:') === 0) continue;
let abs; try { abs = new URL(m[2], rule.base).href; } catch { continue; }
out = out.split(m[0]).join('url("' + await toDataURL(abs) + '")');
}
return out;
}))).join('\n');
const cloneStyled = (src) => {
if (src.nodeType === 8 || (src.nodeType === 1 && src.tagName === 'SCRIPT')) return document.createTextNode('');
const dst = src.cloneNode(false);
if (src.nodeType === 1) {
const cs = getComputedStyle(src); let txt = '';
for (let i = 0; i < cs.length; i++) txt += cs[i] + ':' + cs.getPropertyValue(cs[i]) + ';';
dst.setAttribute('style', txt + 'animation:none;transition:none;');
if (src.tagName === 'CANVAS') try { const im = document.createElement('img'); im.src = src.toDataURL(); im.setAttribute('style', txt); return im; } catch {}
}
for (let c = src.firstChild; c; c = c.nextSibling) dst.appendChild(cloneStyled(c));
return dst;
};
const clone = cloneStyled(node);
clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
// Drop the card's own shadow/radius so the export is a flush w×h rect;
// the artboard's own background (if any) is already in the computed style.
clone.style.boxShadow = 'none'; clone.style.borderRadius = '0';
const jobs = [];
clone.querySelectorAll('img').forEach((el) => {
const s = el.getAttribute('src');
if (s && s.indexOf('data:') !== 0) jobs.push(toDataURL(el.src).then((d) => el.setAttribute('src', d)));
});
[clone, ...clone.querySelectorAll('*')].forEach((el) => {
const bg = el.style.backgroundImage; if (!bg) return;
let m; const re = /url\(["']?([^"')]+)["']?\)/g;
while ((m = re.exec(bg))) {
const tok = m[0], url = m[1];
if (url.indexOf('data:') === 0) continue;
jobs.push(toDataURL(url).then((d) => { el.style.backgroundImage = el.style.backgroundImage.split(tok).join('url("' + d + '")'); }));
}
});
await Promise.all(jobs);
const xml = new XMLSerializer().serializeToString(clone);
const save = (blob, ext) => {
if (!blob) return;
const a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.download = name + '.' + ext; a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
};
if (kind === 'html') {
const html = '<!doctype html><html><head><meta charset="utf-8"><title>' + name + '</title>' +
(fontCss ? '<style>' + fontCss + '</style>' : '') +
'</head><body style="margin:0">' + xml + '</body></html>';
return save(new Blob([html], { type: 'text/html' }), 'html');
}
// PNG: the SVG's own width/height must be the output resolution — an
// <img>-loaded SVG rasterizes at its intrinsic size, so sizing it at 1×
// and ctx.scale()-ing up would just upscale a 1× bitmap. viewBox maps the
// w×h foreignObject onto the px·w × px·h SVG canvas so the browser renders
// the HTML at full resolution.
const px = 3;
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + w * px + '" height="' + h * px +
'" viewBox="0 0 ' + w + ' ' + h + '"><foreignObject width="' + w + '" height="' + h + '">' +
(fontCss ? '<style><![CDATA[' + fontCss + ']]></style>' : '') + xml + '</foreignObject></svg>';
const img = new Image();
await new Promise((res, rej) => {
img.onload = res; img.onerror = () => rej(new Error('svg load failed'));
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
});
const cv = document.createElement('canvas');
cv.width = w * px; cv.height = h * px;
cv.getContext('2d').drawImage(img, 0, 0);
cv.toBlob((blob) => save(blob, 'png'), 'image/png');
}
function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus, onDelete }) {
const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props;
const id = rawId ?? rawLabel;
const ref = React.useRef(null);
const cardRef = React.useRef(null);
const menuRef = React.useRef(null);
const [menuOpen, setMenuOpen] = React.useState(false);
const [confirming, setConfirming] = React.useState(false);
// ⋯ menu: close on any outside pointerdown. Two-click delete lives inside
// the menu — first click arms the row, second commits; closing disarms.
React.useEffect(() => {
if (!menuOpen) { setConfirming(false); return; }
const off = (e) => { if (!menuRef.current || !menuRef.current.contains(e.target)) setMenuOpen(false); };
document.addEventListener('pointerdown', off, true);
return () => document.removeEventListener('pointerdown', off, true);
}, [menuOpen]);
const doExport = (kind) => {
setMenuOpen(false);
if (!cardRef.current) return;
const name = String(label || id || 'artboard').replace(/[^\w\s.-]+/g, '_');
dcExport(cardRef.current, width, height, name, kind)
.catch((e) => console.error('[design-canvas] export failed:', e));
};
// Live drag-reorder: dragged card sticks to cursor; siblings slide into
// their would-be slots in real time via transforms. DOM order only
// changes on drop.
const onGripDown = (e) => {
e.preventDefault(); e.stopPropagation();
const me = ref.current;
// translateX is applied in local (pre-scale) space but pointer deltas and
// getBoundingClientRect().left are screen-space — divide by the viewport's
// current scale so the dragged card tracks the cursor at any zoom level.
const scale = me.getBoundingClientRect().width / me.offsetWidth || 1;
const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`));
const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left }));
const slotXs = homes.map((h) => h.x);
const startIdx = order.indexOf(id);
const startX = e.clientX;
let liveOrder = order.slice();
me.classList.add('dc-dragging');
const layout = () => {
for (const h of homes) {
if (h.id === id) continue;
const slot = liveOrder.indexOf(h.id);
h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`;
}
};
const move = (ev) => {
const dx = ev.clientX - startX;
me.style.transform = `translateX(${dx / scale}px)`;
const cur = homes[startIdx].x + dx;
let nearest = 0, best = Infinity;
for (let i = 0; i < slotXs.length; i++) {
const d = Math.abs(slotXs[i] - cur);
if (d < best) { best = d; nearest = i; }
}
if (liveOrder.indexOf(id) !== nearest) {
liveOrder = order.filter((k) => k !== id);
liveOrder.splice(nearest, 0, id);
layout();
}
};
const up = () => {
document.removeEventListener('pointermove', move);
document.removeEventListener('pointerup', up);
const finalSlot = liveOrder.indexOf(id);
me.classList.remove('dc-dragging');
me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`;
// After the settle transition, kill transitions + clear transforms +
// commit the reorder in the same frame so there's no visual snap-back.
setTimeout(() => {
for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; }
if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder);
requestAnimationFrame(() => requestAnimationFrame(() => {
for (const h of homes) h.el.style.transition = '';
}));
}, 180);
};
document.addEventListener('pointermove', move);
document.addEventListener('pointerup', up);
};
return (
<div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
<div className="dc-header" data-noncommentable="" style={{ color: DC.label }} onPointerDown={(e) => e.stopPropagation()}>
<div className="dc-labelrow">
<div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
<svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
</div>
<div className="dc-labeltext" onClick={onFocus} title="Click to focus">
<DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
</div>
</div>
<div className="dc-btns">
<div ref={menuRef} style={{ position: 'relative' }}>
<button className="dc-kebab" title="More" onClick={() => setMenuOpen((o) => !o)}>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><circle cx="2.5" cy="6" r="1.1"/><circle cx="6" cy="6" r="1.1"/><circle cx="9.5" cy="6" r="1.1"/></svg>
</button>
{menuOpen && (
<div className="dc-menu" onPointerDown={(e) => e.stopPropagation()}>
<button onClick={() => doExport('png')}>Download PNG</button>
<button onClick={() => doExport('html')}>Download HTML</button>
<hr />
<button className="dc-danger"
onClick={() => { if (confirming) { setMenuOpen(false); onDelete(); } else setConfirming(true); }}>
{confirming ? 'Click again to delete' : 'Delete'}
</button>
</div>
)}
</div>
<button className="dc-expand" onClick={onFocus} title="Focus">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
</button>
</div>
</div>
<div ref={cardRef} className="dc-card"
style={{ borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06)', overflow: 'hidden', width, height, background: '#fff', ...style }}>
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>}
</div>
</div>
);
}
// Inline rename — commits on blur or Enter.
function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
const T = tag;
return (
<T className="dc-editable" contentEditable suppressContentEditableWarning
onClick={onClick}
onPointerDown={(e) => e.stopPropagation()}
onBlur={(e) => onChange && onChange(e.currentTarget.textContent)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
style={style}>{value}</T>
);
}
// ─────────────────────────────────────────────────────────────
// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across
// sections, Esc or backdrop click to exit.
// ─────────────────────────────────────────────────────────────
function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
const ctx = React.useContext(DCCtx);
const { sectionId, artboard } = entry;
const sec = ctx.section(sectionId);
const meta = sectionMeta[sectionId];
const peers = meta.slotIds;
const aid = artboard.props.id ?? artboard.props.label;
const idx = peers.indexOf(aid);
const secIdx = sectionOrder.indexOf(sectionId);
const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); };
const goSection = (d) => {
// Sections whose artboards are all deleted have slotIds:[] — step past
// them to the next non-empty section so ↑/↓ doesn't dead-end.
const n = sectionOrder.length;
for (let i = 1; i < n; i++) {
const ns = sectionOrder[(((secIdx + d * i) % n) + n) % n];
const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
if (first) { ctx.setFocus(`${ns}/${first}`); return; }
}
};
React.useEffect(() => {
const k = (e) => {
if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); }
if (e.key === 'ArrowRight') { e.preventDefault(); go(1); }
if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); }
if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); }
};
document.addEventListener('keydown', k);
return () => document.removeEventListener('keydown', k);
});
const { width = 260, height = 480, children } = artboard.props;
const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight });
React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []);
const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2));
const [ddOpen, setDd] = React.useState(false);
const Arrow = ({ dir, onClick }) => (
<button onClick={(e) => { e.stopPropagation(); onClick(); }}
style={{ position: 'absolute', top: '50%', [dir]: 28, transform: 'translateY(-50%)',
border: 'none', background: 'rgba(255,255,255,.08)', color: 'rgba(255,255,255,.9)',
width: 44, height: 44, borderRadius: 22, fontSize: 18, cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background .15s' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.18)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.08)')}>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d={dir === 'left' ? 'M11 3L5 9l6 6' : 'M7 3l6 6-6 6'} /></svg>
</button>
);
// Portal to body so position:fixed is the real viewport regardless of any
// transform on DesignCanvas's ancestors (including the canvas zoom itself).
return ReactDOM.createPortal(
<div onClick={() => ctx.setFocus(null)}
onWheel={(e) => e.preventDefault()}
style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)',
fontFamily: DC.font, color: '#fff' }}>
{/* top bar: section dropdown (left) · close (right) */}
<div onClick={(e) => e.stopPropagation()}
style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}>
<div style={{ position: 'relative' }}>
<button onClick={() => setDd((o) => !o)}
style={{ border: 'none', background: 'transparent', color: '#fff', cursor: 'pointer', padding: '6px 8px',
borderRadius: 6, textAlign: 'left', fontFamily: 'inherit' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: -0.3 }}>{meta.title}</span>
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" style={{ opacity: .7 }}><path d="M2 4l3.5 3.5L9 4"/></svg>
</span>
{meta.subtitle && <span style={{ display: 'block', fontSize: 13, opacity: .6, fontWeight: 400, marginTop: 2 }}>{meta.subtitle}</span>}
</button>
{ddOpen && (
<div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, background: '#2a251f', borderRadius: 8,
boxShadow: '0 8px 32px rgba(0,0,0,.4)', padding: 4, minWidth: 200, zIndex: 10 }}>
{sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => (
<button key={sid} onClick={() => { setDd(false); const f = sectionMeta[sid].slotIds[0]; if (f) ctx.setFocus(`${sid}/${f}`); }}
style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff',
padding: '8px 12px', borderRadius: 5, fontSize: 14, fontWeight: sid === sectionId ? 600 : 400, fontFamily: 'inherit' }}>
{sectionMeta[sid].title}
</button>
))}
</div>
)}
</div>
<div style={{ flex: 1 }} />
<button onClick={() => ctx.setFocus(null)}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.12)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
style={{ border: 'none', background: 'transparent', color: 'rgba(255,255,255,.7)', width: 32, height: 32,
borderRadius: 16, fontSize: 20, cursor: 'pointer', lineHeight: 1, transition: 'background .12s' }}>×</button>
</div>
{/* card centered, label + index below — only the card itself stops
propagation so any backdrop click (including the margins around
the card) exits focus */}
<div
style={{ position: 'absolute', top: 64, bottom: 56, left: 100, right: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
<div onClick={(e) => e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}>
<div style={{ width, height, transform: `scale(${scale})`, transformOrigin: 'top left', background: '#fff', borderRadius: 2, overflow: 'hidden',
boxShadow: '0 20px 80px rgba(0,0,0,.4)' }}>
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb' }}>{aid}</div>}
</div>
</div>
<div onClick={(e) => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}>
{(sec.labels || {})[aid] ?? artboard.props.label}
<span style={{ opacity: .5, marginLeft: 10, fontVariantNumeric: 'tabular-nums' }}>{idx + 1} / {peers.length}</span>
</div>
</div>
<Arrow dir="left" onClick={() => go(-1)} />
<Arrow dir="right" onClick={() => go(1)} />
{/* dots */}
<div onClick={(e) => e.stopPropagation()}
style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}>
{peers.map((p, i) => (
<button key={p} onClick={() => ctx.setFocus(`${sectionId}/${p}`)}
style={{ border: 'none', padding: 0, cursor: 'pointer', width: 6, height: 6, borderRadius: 3,
background: i === idx ? '#fff' : 'rgba(255,255,255,.3)' }} />
))}
</div>
</div>,
document.body,
);
}
// ─────────────────────────────────────────────────────────────
// Post-it — absolute-positioned sticky note
// ─────────────────────────────────────────────────────────────
function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) {
return (
<div style={{
position: 'absolute', top, left, right, bottom, width,
background: DC.postitBg, padding: '14px 16px',
fontFamily: '"Comic Sans MS", "Marker Felt", "Segoe Print", cursive',
fontSize: 14, lineHeight: 1.4, color: DC.postitText,
boxShadow: '0 2px 8px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)',
transform: `rotate(${rotate}deg)`,
zIndex: 5,
}}>{children}</div>
);
}
Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });

File diff suppressed because one or more lines are too long

View File

@@ -1,21 +1,23 @@
// Hero: the Reddit quote headline + prompt input.
// Visitors can type into the prompt; cycling placeholders, suggestion chips, submit handler logs to console.
// SMB-owner placeholders: each one frames "replace the stack" or "fit my biz",
// not "build a side project". The chips below match the same voice.
const HERO_PLACEHOLDERS = [
"A booking 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…",
"A booking system for my barbershop — the way we actually book",
"One tool that replaces my POS, Square Appointments, and that spreadsheet",
"A customer portal for my plumbing business — quotes, jobs, invoices, one place",
"An inventory tool that fits my vintage shop, not Shopify's idea of one",
"A back-office system to replace QuickBooks plus six other things",
"A jobs + crews scheduler for my landscaping company, finally in one place",
];
const HERO_CHIPS = [
"📋 Client intake form",
"📅 Booking site",
"🧾 Invoice tracker",
"🛒 Online store",
"📰 Email newsletter",
"📅 Booking system",
"🧾 Invoices + quotes",
"👥 Customer portal",
"📦 Inventory",
"🗂️ Back-office",
];
function Hero({ onStart, variant = "quote" }) {
@@ -54,7 +56,7 @@ function Hero({ onStart, variant = "quote" }) {
const useChip = (chip) => {
const clean = chip.replace(/^[^\w]+/, "").trim();
setText(`Build me ${clean.toLowerCase()} for my business.`);
setText(`Build me a ${clean.toLowerCase()} for my business.`);
if (taRef.current) taRef.current.focus();
};
@@ -244,6 +246,34 @@ function Hero({ onStart, variant = "quote" }) {
box-shadow: 0 0 0 0 oklch(0.78 0.16 155 / 0.6);
animation: pulse 2s ease-out infinite;
}
/* Rally line typographic, not a pill. Reads like the opening of
a manifesto: small caps, mono, coral hairlines either side. */
.rally {
display: inline-flex; align-items: center; gap: 14px;
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--accent);
white-space: nowrap;
margin-bottom: -8px;
}
.rally::before, .rally::after {
content: "";
width: 36px; height: 1px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
box-shadow: 0 0 8px var(--accent-glow);
}
.rally b {
color: var(--fg);
font-weight: 500;
}
@media (max-width: 540px) {
.rally { font-size: 10.5px; letter-spacing: 0.18em; gap: 10px; }
.rally::before, .rally::after { width: 18px; }
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 oklch(0.78 0.16 155 / 0.6); }
70% { box-shadow: 0 0 0 8px oklch(0.78 0.16 155 / 0); }
@@ -269,7 +299,19 @@ function Hero({ onStart, variant = "quote" }) {
<div className="wrap hero-inner">
<span className="live-pill"><span className="dot" /> Live from minute one</span>
{variant === "promise" ? (
{variant === "owner" ? (
<>
<span className="rally">To every <b>small business owner</b></span>
<h1 className="hero-quote">
Stop renting <span className="mark">software</span>
<br/>that doesn't fit.
</h1>
<p className="hero-sub">
Look at your subscriptions. Ask if they're doing the job.
<br/>Vibn builds the <b>one</b> tool that fits how your business actually runs <b>built for you, owned by you.</b>
</p>
</>
) : variant === "promise" ? (
<>
<h1 className="hero-quote">
Keep <span className="mark">vibing</span>.
@@ -287,7 +329,7 @@ function Hero({ onStart, variant = "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>
<div className="hero-attribution mono">posted 2 hours ago · r/smallbusiness</div>
<p className="hero-sub">
<b>Keep vibing.</b> All the way to launch.
<br/>Your AI handles the technical stuff, puts your idea online, and helps you find your first customers.

View File

@@ -211,8 +211,10 @@
<script type="text/babel" src="hero.jsx"></script>
<script type="text/babel" src="wall.jsx"></script>
<script type="text/babel" src="crossed.jsx"></script>
<script type="text/babel" src="stack.jsx"></script>
<script type="text/babel" src="journey.jsx"></script>
<script type="text/babel" src="audience.jsx"></script>
<script type="text/babel" src="mission.jsx"></script>
<script type="text/babel" src="closing.jsx"></script>
<script type="text/babel" src="app.jsx"></script>
</body>

View File

@@ -0,0 +1,107 @@
// Mission whisper — three sentences, one CTA. Sits between Audience and
// Closing. Intentionally small, intentionally separate from the product pitch.
function Mission() {
return (
<section className="section mission">
<style>{`
.mission {
padding-block: clamp(70px, 10vh, 120px);
position: relative;
}
.mission-card {
position: relative;
max-width: 820px; margin: 0 auto;
padding: clamp(40px, 6vw, 64px) clamp(28px, 5vw, 56px);
border-radius: 24px;
background:
radial-gradient(ellipse 80% 60% at 50% 0%, oklch(0.74 0.175 35 / 0.10), transparent 70%),
linear-gradient(180deg, oklch(0.19 0.009 60 / 0.65), oklch(0.16 0.008 60 / 0.5));
border: 1px solid var(--hairline);
overflow: hidden;
text-align: center;
}
.mission-card::before {
content: "";
position: absolute; top: 0; left: 0; right: 0; height: 1px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
opacity: .7;
}
.mission-eye {
display: inline-flex; align-items: center; gap: 10px;
font-family: var(--font-mono);
font-size: 11px; letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--accent);
}
.mission-eye::before, .mission-eye::after {
content: ""; width: 24px; height: 1px;
background: oklch(0.74 0.175 35 / 0.4);
}
.mission-title {
margin-top: 18px;
font-size: clamp(28px, 3.6vw, 44px);
font-weight: 500;
letter-spacing: -0.025em;
line-height: 1.08;
text-wrap: balance;
}
.mission-title em {
font-style: normal;
background: linear-gradient(180deg, var(--accent), oklch(0.62 0.18 18));
-webkit-background-clip: text; background-clip: text;
color: transparent;
}
.mission-body {
margin-top: 22px;
font-size: clamp(15px, 1.55vw, 18px);
color: var(--fg-dim);
line-height: 1.6;
max-width: 580px; margin-inline: auto;
text-wrap: balance;
}
.mission-body b {
color: var(--fg);
font-weight: 500;
}
.mission-cta {
margin-top: 28px;
display: inline-flex; align-items: center; gap: 8px;
padding: 10px 16px;
border-radius: 999px;
font-size: 14px;
font-weight: 500;
color: var(--accent);
border: 1px solid oklch(0.74 0.175 35 / 0.4);
background: oklch(0.74 0.175 35 / 0.08);
transition: background .15s, transform .12s, border-color .15s;
}
.mission-cta:hover {
background: oklch(0.74 0.175 35 / 0.16);
border-color: oklch(0.74 0.175 35 / 0.6);
transform: translateY(-1px);
}
`}</style>
<div className="wrap">
<div className="mission-card">
<div className="mission-eye">Why Vibn exists</div>
<h2 className="mission-title">
This is bigger than software.
<br/>It's <em>the golden age of small business.</em>
</h2>
<p className="mission-body">
For twenty years, small business got the leftovers generic tools, monthly rent,
software built for someone else. <b>AI changes the math.</b> The custom system a business
needs to actually thrive is finally something they can have, own, and afford.
</p>
<a href="#" className="mission-cta">
Read our mission <Arrow size={12} />
</a>
</div>
</div>
</section>
);
}
Object.assign(window, { Mission });

View File

@@ -0,0 +1,753 @@
// ============================================================
// 4 modern SaaS nav layouts. Each artboard is a full 1440×900
// app/marketing chrome with the nav as the focal point and just
// enough body content to read context. Original brand "Lattice
// Studio" used throughout so the navs feel like one product
// family — the variable is the nav pattern itself.
// ============================================================
// Generic placeholder block
const NavImgSlot = ({ label, h = 200, tone = "light" }) => {
const p = tone === "dark"
? { bg: "#1a1a1f", stripe: "#222229", ink: "#7a7a85" }
: { bg: "#f3f3f0", stripe: "#e7e7e3", ink: "#7a7a72" };
return (
<div style={{
width: "100%", height: h, position: "relative",
backgroundImage: `repeating-linear-gradient(135deg, ${p.bg} 0 14px, ${p.stripe} 14px 15px)`,
display: "flex", alignItems: "center", justifyContent: "center",
}}>
<span style={{
fontFamily: "ui-monospace, 'SF Mono', Menlo, monospace",
fontSize: 10, letterSpacing: "0.1em", textTransform: "uppercase",
color: p.ink, padding: "3px 8px",
border: `1px solid ${p.ink}40`, background: `${p.bg}d0`,
}}>{label}</span>
</div>
);
};
// Tiny stroke icon helper — single line so it doesn't bloat the file
const I = ({ d, size = 16, sw = 1.6 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth={sw}
strokeLinecap="round" strokeLinejoin="round">{d}</svg>
);
// Common path sets, kept terse
const Paths = {
search: <><circle cx="11" cy="11" r="7"/><path d="m20 20-3-3"/></>,
bell: <><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10 21a2 2 0 0 0 4 0"/></>,
cmd: <path d="M9 3a3 3 0 0 0-3 3v3H3M15 3a3 3 0 0 1 3 3v3h3M9 21a3 3 0 0 1-3-3v-3H3M15 21a3 3 0 0 0 3-3v-3h3M9 9h6v6H9z"/>,
home: <><path d="m3 12 9-9 9 9"/><path d="M5 10v10h14V10"/></>,
inbox: <><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5 5h14l3 7v7H2v-7z"/></>,
people: <><circle cx="9" cy="8" r="4"/><path d="M3 21a6 6 0 0 1 12 0"/><circle cx="17" cy="6" r="3"/><path d="M21 17a4 4 0 0 0-6-3.5"/></>,
building: <><path d="M3 21h18M5 21V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16"/><path d="M9 7h2M13 7h2M9 11h2M13 11h2M9 15h2M13 15h2"/></>,
target: <><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1"/></>,
check: <><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></>,
workflow: <><rect x="3" y="3" width="6" height="6" rx="1"/><rect x="15" y="15" width="6" height="6" rx="1"/><path d="M9 6h6a3 3 0 0 1 3 3v6"/></>,
bar: <><path d="M3 3v18h18"/><rect x="7" y="11" width="3" height="7"/><rect x="13" y="7" width="3" height="11"/><rect x="19" y="14" width="0" height="4"/></>,
hash: <path d="M9 3l-2 18M17 3l-2 18M3 9h18M2 15h18"/>,
lock: <><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></>,
plus: <path d="M12 5v14M5 12h14"/>,
chevron: <path d="m6 9 6 6 6-6"/>,
arrow: <path d="M5 12h14M13 5l7 7-7 7"/>,
star: <path d="m12 3 2.6 6.2 6.7.5-5.1 4.4 1.6 6.6L12 17.3 6.2 20.7l1.6-6.6L2.7 9.7l6.7-.5z"/>,
doc: <><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6"/></>,
spark: <path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l3 3M15 15l3 3M6 18l3-3M15 9l3-3"/>,
};
const sansStack = "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif";
// ============================================================
// 1. SIDEBAR — workspace + sections + secondary
// (Linear / Notion / Twenty school)
// ============================================================
const NavSidebar = () => {
const ItemRow = ({ icon, label, count, active }) => (
<div style={{
display: "flex", alignItems: "center", gap: 10,
padding: "6px 10px", borderRadius: 6, fontSize: 13,
color: active ? "#111" : "#5a5a5e",
background: active ? "#ffffff" : "transparent",
boxShadow: active ? "0 1px 0 #00000008, 0 0 0 1px #00000008" : "none",
fontWeight: active ? 500 : 400, cursor: "pointer",
}}>
<span style={{ color: active ? "#5e5cff" : "#8a8a90", display: "flex" }}>
<I d={icon} size={15} />
</span>
<span style={{ flex: 1 }}>{label}</span>
{count && <span style={{
fontSize: 11, color: "#8a8a90", fontVariantNumeric: "tabular-nums",
}}>{count}</span>}
</div>
);
const SectionHeader = ({ label }) => (
<div style={{
fontSize: 11, color: "#8a8a90", letterSpacing: "0.04em",
padding: "16px 10px 6px", textTransform: "uppercase",
fontWeight: 500,
}}>{label}</div>
);
return (
<div style={{
width: "100%", height: "100%", display: "grid",
gridTemplateColumns: "248px 1fr",
background: "#fcfcfb", fontFamily: sansStack, color: "#111",
}}>
{/* SIDEBAR */}
<aside style={{
background: "#f5f5f2", borderRight: "1px solid #e8e8e3",
display: "flex", flexDirection: "column",
}}>
{/* Workspace switcher */}
<div style={{
padding: "12px 12px", display: "flex", alignItems: "center", gap: 10,
borderBottom: "1px solid #e8e8e3",
}}>
<div style={{
width: 26, height: 26, borderRadius: 6,
background: "linear-gradient(135deg, #6e6cff 0%, #b15bff 100%)",
display: "flex", alignItems: "center", justifyContent: "center",
color: "#fff", fontWeight: 700, fontSize: 13,
}}>L</div>
<div style={{ flex: 1, lineHeight: 1.2 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>Lattice Studio</div>
<div style={{ fontSize: 11, color: "#8a8a90" }}>Free · 4 members</div>
</div>
<span style={{ color: "#8a8a90", display: "flex" }}>
<I d={Paths.chevron} size={14} />
</span>
</div>
{/* Search */}
<div style={{ padding: "10px 12px" }}>
<div style={{
display: "flex", alignItems: "center", gap: 8, padding: "6px 10px",
background: "#fff", border: "1px solid #e8e8e3", borderRadius: 6,
fontSize: 12, color: "#8a8a90",
}}>
<I d={Paths.search} size={14} />
<span style={{ flex: 1 }}>Search</span>
<span style={{
fontSize: 10, padding: "1px 5px", border: "1px solid #e0e0d8",
borderRadius: 3, fontFamily: "monospace",
}}>K</span>
</div>
</div>
{/* Nav */}
<nav style={{ padding: "4px 8px", flex: 1, overflowY: "auto" }}>
<ItemRow icon={Paths.home} label="Home" />
<ItemRow icon={Paths.inbox} label="Inbox" count="12" />
<ItemRow icon={Paths.check} label="My tasks" count="3" />
<SectionHeader label="Workspaces" />
<ItemRow icon={Paths.hash} label="Marketing site" active />
<ItemRow icon={Paths.hash} label="Q2 launch" />
<ItemRow icon={Paths.hash} label="Brand refresh" />
<ItemRow icon={Paths.hash} label="Customer interviews" />
<SectionHeader label="Pinned" />
<ItemRow icon={Paths.doc} label="Roadmap · 2026" />
<ItemRow icon={Paths.doc} label="Design tokens" />
<ItemRow icon={Paths.doc} label="Onboarding flow" />
<SectionHeader label="Team" />
<ItemRow icon={Paths.people} label="People" />
<ItemRow icon={Paths.bar} label="Insights" />
<ItemRow icon={Paths.workflow} label="Automations" />
</nav>
{/* Footer */}
<div style={{
padding: "10px 12px", borderTop: "1px solid #e8e8e3",
display: "flex", alignItems: "center", gap: 10,
}}>
<div style={{
width: 24, height: 24, borderRadius: "50%", background: "#d4b8a8",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 11, fontWeight: 600, color: "#5a3e34",
}}>MR</div>
<div style={{ flex: 1, fontSize: 12 }}>
<div style={{ fontWeight: 500 }}>Mira Reyes</div>
<div style={{ color: "#8a8a90", fontSize: 11 }}>mira@lattice.co</div>
</div>
<span style={{ color: "#8a8a90", display: "flex" }}>
<I d={Paths.chevron} size={14} />
</span>
</div>
</aside>
{/* CONTENT */}
<main style={{ display: "flex", flexDirection: "column", overflow: "hidden" }}>
<div style={{
padding: "14px 28px", borderBottom: "1px solid #e8e8e3",
display: "flex", alignItems: "center", justifyContent: "space-between",
}}>
<div style={{ fontSize: 13, color: "#8a8a90" }}>
Workspaces / <span style={{ color: "#111" }}>Marketing site</span>
</div>
<div style={{ display: "flex", gap: 8 }}>
<button style={{
padding: "6px 12px", border: "1px solid #e0e0d8",
background: "#fff", borderRadius: 6, fontSize: 12, fontFamily: sansStack,
color: "#5a5a5e", cursor: "pointer",
}}>Share</button>
<button style={{
padding: "6px 12px", border: "none",
background: "#111", color: "#fff", borderRadius: 6, fontSize: 12,
fontFamily: sansStack, cursor: "pointer", display: "flex", alignItems: "center", gap: 6,
}}><I d={Paths.plus} size={12}/> New page</button>
</div>
</div>
<div style={{ padding: 36, flex: 1, overflow: "hidden" }}>
<h1 style={{
fontSize: 28, fontWeight: 600, letterSpacing: "-0.02em", margin: 0,
}}>Marketing site</h1>
<p style={{ color: "#5a5a5e", fontSize: 13, marginTop: 6 }}>
14 pages · last edited 4 minutes ago by Mira
</p>
<div style={{ marginTop: 28, display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16 }}>
{["Homepage", "Pricing", "Changelog"].map(t => (
<div key={t} style={{
background: "#fff", border: "1px solid #e8e8e3", borderRadius: 10,
padding: 16,
}}>
<NavImgSlot label={`${t} · preview`} h={110} />
<div style={{ fontSize: 13, fontWeight: 500, marginTop: 12 }}>{t}</div>
<div style={{ fontSize: 11, color: "#8a8a90", marginTop: 2 }}>
Edited 2h ago · Mira
</div>
</div>
))}
</div>
</div>
</main>
</div>
);
};
// ============================================================
// 2. ICON RAIL + CONTEXT PANEL — Slack / Discord / Mail school
// ============================================================
const NavIconRail = () => {
const RailIcon = ({ icon, active, badge, color }) => (
<div style={{
width: 40, height: 40, borderRadius: 10,
background: active ? color : "transparent",
color: active ? "#fff" : "#9a9aa6",
display: "flex", alignItems: "center", justifyContent: "center",
cursor: "pointer", position: "relative",
border: active ? "none" : "1px solid transparent",
}}>
<I d={icon} size={18} sw={2} />
{badge && (
<span style={{
position: "absolute", top: -2, right: -2, minWidth: 16, height: 16,
padding: "0 4px", background: "#ff4d5e", color: "#fff",
borderRadius: 8, fontSize: 10, fontWeight: 600,
display: "flex", alignItems: "center", justifyContent: "center",
border: "2px solid #0f0f14",
}}>{badge}</span>
)}
</div>
);
const ChannelItem = ({ name, active, unread, mention }) => (
<div style={{
display: "flex", alignItems: "center", gap: 8,
padding: "6px 10px", borderRadius: 6, fontSize: 13,
color: active ? "#fff" : (unread ? "#dcdce4" : "#9a9aa6"),
background: active ? "#ffffff14" : "transparent",
fontWeight: unread ? 500 : 400, cursor: "pointer",
}}>
<span style={{ opacity: 0.7 }}>#</span>
<span style={{ flex: 1 }}>{name}</span>
{mention && <span style={{
background: "#ff4d5e", color: "#fff", fontSize: 10, fontWeight: 600,
padding: "1px 6px", borderRadius: 8,
}}>{mention}</span>}
</div>
);
return (
<div style={{
width: "100%", height: "100%", display: "grid",
gridTemplateColumns: "72px 260px 1fr",
background: "#0f0f14", color: "#e8e8ee", fontFamily: sansStack,
}}>
{/* RAIL */}
<div style={{
background: "#08080c", borderRight: "1px solid #ffffff08",
display: "flex", flexDirection: "column", alignItems: "center",
padding: "12px 0", gap: 6,
}}>
<div style={{
width: 40, height: 40, borderRadius: 10,
background: "linear-gradient(135deg, #5e5cff 0%, #b15bff 100%)",
display: "flex", alignItems: "center", justifyContent: "center",
color: "#fff", fontWeight: 800, fontSize: 16, marginBottom: 6,
}}>L</div>
<div style={{ width: 24, height: 1, background: "#ffffff10", margin: "4px 0" }}></div>
<RailIcon icon={Paths.home} active color="#5e5cff" />
<RailIcon icon={Paths.inbox} badge="9" />
<RailIcon icon={Paths.people} />
<RailIcon icon={Paths.target} badge="2" />
<RailIcon icon={Paths.bar} />
<RailIcon icon={Paths.doc} />
<div style={{ flex: 1 }}></div>
<RailIcon icon={Paths.plus} />
<RailIcon icon={Paths.spark} />
<div style={{
width: 32, height: 32, borderRadius: "50%", marginTop: 4,
background: "#d4b8a8", display: "flex", alignItems: "center",
justifyContent: "center", fontSize: 12, fontWeight: 600, color: "#5a3e34",
border: "2px solid #08080c", boxShadow: "0 0 0 2px #5e5cff",
position: "relative",
}}>MR
<span style={{
position: "absolute", bottom: -2, right: -2, width: 12, height: 12,
background: "#22c55e", borderRadius: "50%", border: "2px solid #08080c",
}}></span>
</div>
</div>
{/* SECONDARY PANEL */}
<div style={{
background: "#13131a", borderRight: "1px solid #ffffff08",
display: "flex", flexDirection: "column",
}}>
<div style={{
padding: "16px 16px 12px",
borderBottom: "1px solid #ffffff08",
}}>
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
marginBottom: 12,
}}>
<span style={{ fontSize: 15, fontWeight: 600 }}>Lattice HQ</span>
<span style={{ color: "#9a9aa6", display: "flex" }}>
<I d={Paths.chevron} size={16} />
</span>
</div>
<div style={{
display: "flex", alignItems: "center", gap: 8,
padding: "7px 10px", background: "#08080c",
borderRadius: 7, fontSize: 12, color: "#9a9aa6",
}}>
<I d={Paths.search} size={13} />
<span style={{ flex: 1 }}>Jump to</span>
<span style={{
fontSize: 10, padding: "1px 5px",
background: "#ffffff08", borderRadius: 3, fontFamily: "monospace",
}}>K</span>
</div>
</div>
<div style={{ padding: "12px 8px", flex: 1, overflowY: "auto" }}>
<div style={{
fontSize: 11, color: "#6a6a78", padding: "8px 10px 4px",
textTransform: "uppercase", letterSpacing: "0.04em", fontWeight: 500,
display: "flex", justifyContent: "space-between",
}}>
<span>Channels</span>
<I d={Paths.plus} size={12} />
</div>
<ChannelItem name="general" />
<ChannelItem name="design-crits" unread mention="3" />
<ChannelItem name="launch-2026" active />
<ChannelItem name="random" />
<ChannelItem name="bugs" unread />
<div style={{
fontSize: 11, color: "#6a6a78", padding: "16px 10px 4px",
textTransform: "uppercase", letterSpacing: "0.04em", fontWeight: 500,
}}>Direct messages</div>
{[
["Sun Kim", "#e8a87c", true],
["Devi Patel", "#a8c8e8", false],
["Theo Roux", "#c8e8a8", false],
].map(([n, c, online], i) => (
<div key={i} style={{
display: "flex", alignItems: "center", gap: 10,
padding: "6px 10px", borderRadius: 6, fontSize: 13, color: "#dcdce4",
cursor: "pointer",
}}>
<div style={{ position: "relative" }}>
<div style={{ width: 22, height: 22, borderRadius: "50%", background: c }}></div>
{online && <span style={{
position: "absolute", bottom: -1, right: -1, width: 8, height: 8,
background: "#22c55e", borderRadius: "50%", border: "2px solid #13131a",
}}></span>}
</div>
<span style={{ flex: 1 }}>{n}</span>
</div>
))}
</div>
</div>
{/* CONTENT */}
<main style={{ display: "flex", flexDirection: "column" }}>
<div style={{
padding: "14px 24px", borderBottom: "1px solid #ffffff08",
display: "flex", alignItems: "center", gap: 12,
}}>
<span style={{ color: "#9a9aa6" }}>#</span>
<span style={{ fontSize: 15, fontWeight: 600 }}>launch-2026</span>
<span style={{ color: "#6a6a78", fontSize: 12 }}>· 18 members</span>
<div style={{ flex: 1 }}></div>
<span style={{ color: "#9a9aa6", display: "flex" }}><I d={Paths.bell} size={16}/></span>
<span style={{ color: "#9a9aa6", display: "flex" }}><I d={Paths.star} size={16}/></span>
</div>
<div style={{ padding: 28, flex: 1, color: "#9a9aa6", fontSize: 13 }}>
<NavImgSlot label="conversation thread" h={420} tone="dark" />
</div>
</main>
</div>
);
};
// ============================================================
// 3. TOP HORIZONTAL + COMMAND BAR — Vercel / Stripe / Linear web
// ============================================================
const NavTopHorizontal = () => {
const TabItem = ({ label, active }) => (
<div style={{
padding: "16px 2px", margin: "0 12px", fontSize: 13, fontWeight: 500,
color: active ? "#fff" : "#9a9aa6",
borderBottom: active ? "2px solid #fff" : "2px solid transparent",
cursor: "pointer", position: "relative", top: 1,
}}>{label}</div>
);
return (
<div style={{
width: "100%", height: "100%", background: "#fafaf9",
color: "#111", fontFamily: sansStack, display: "flex", flexDirection: "column",
}}>
{/* DARK TOP BAR */}
<header style={{ background: "#0a0a0a", color: "#fff" }}>
{/* Row 1: brand + workspace + global */}
<div style={{
display: "flex", alignItems: "center", gap: 14,
padding: "12px 24px",
}}>
{/* Brand */}
<div style={{
display: "flex", alignItems: "center", gap: 8, fontWeight: 600, fontSize: 14,
}}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M3 20 L12 4 L21 20 Z" fill="#fff"/>
</svg>
Lattice
</div>
<span style={{ color: "#3a3a3a" }}>/</span>
{/* Workspace */}
<div style={{
display: "flex", alignItems: "center", gap: 8, fontSize: 13,
}}>
<div style={{
width: 18, height: 18, borderRadius: "50%", background: "#e8a87c",
fontSize: 9, fontWeight: 700, color: "#5a3e34",
display: "flex", alignItems: "center", justifyContent: "center",
}}>MR</div>
<span>mira-reyes</span>
<span style={{ color: "#5a5a5e", display: "flex" }}><I d={Paths.chevron} size={12}/></span>
</div>
<span style={{ color: "#3a3a3a" }}>/</span>
{/* Project */}
<div style={{ fontSize: 13, display: "flex", alignItems: "center", gap: 6 }}>
<span>marketing-site</span>
<span style={{
fontSize: 10, padding: "1px 7px", borderRadius: 999,
background: "#1f1f1f", color: "#9a9aa6", border: "1px solid #2a2a2a",
}}>Hobby</span>
</div>
<div style={{ flex: 1 }}></div>
{/* Command bar — focal point */}
<div style={{
display: "flex", alignItems: "center", gap: 10,
padding: "6px 12px", borderRadius: 8,
background: "#1a1a1a", border: "1px solid #2a2a2a",
color: "#9a9aa6", fontSize: 12, minWidth: 320,
}}>
<I d={Paths.search} size={13} />
<span style={{ flex: 1 }}>Find or jump to anything</span>
<span style={{
fontSize: 10, padding: "1px 5px", background: "#2a2a2a",
borderRadius: 3, fontFamily: "monospace",
}}>K</span>
</div>
{/* Right icons */}
<button style={{
background: "transparent", border: "1px solid #2a2a2a",
color: "#fff", padding: "5px 12px", borderRadius: 6,
fontSize: 12, fontFamily: sansStack, cursor: "pointer",
}}>Feedback</button>
<span style={{ color: "#9a9aa6", display: "flex", cursor: "pointer" }}>
<I d={Paths.doc} size={16}/>
</span>
<span style={{ color: "#9a9aa6", display: "flex", cursor: "pointer", position: "relative" }}>
<I d={Paths.bell} size={16}/>
<span style={{
position: "absolute", top: -2, right: -2, width: 7, height: 7,
background: "#5e5cff", borderRadius: "50%",
}}></span>
</span>
<div style={{
width: 26, height: 26, borderRadius: "50%", background: "#d4b8a8",
fontSize: 11, fontWeight: 600, color: "#5a3e34",
display: "flex", alignItems: "center", justifyContent: "center",
cursor: "pointer",
}}>MR</div>
</div>
{/* Row 2: tabs */}
<div style={{
display: "flex", alignItems: "center",
padding: "0 16px", borderBottom: "1px solid #1a1a1a",
}}>
<TabItem label="Overview" active />
<TabItem label="Deployments" />
<TabItem label="Analytics" />
<TabItem label="Logs" />
<TabItem label="Storage" />
<TabItem label="Domains" />
<TabItem label="Settings" />
</div>
</header>
{/* CONTENT */}
<main style={{ flex: 1, padding: "32px 48px" }}>
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "flex-end",
marginBottom: 24,
}}>
<div>
<h1 style={{
fontSize: 32, fontWeight: 600, margin: 0, letterSpacing: "-0.02em",
}}>Overview</h1>
<p style={{ color: "#6a6a72", margin: "4px 0 0", fontSize: 13 }}>
Last deployment 14 minutes ago to <code style={{
background: "#f0efea", padding: "1px 5px", borderRadius: 3,
fontSize: 12,
}}>main</code>
</p>
</div>
<button style={{
background: "#111", color: "#fff", border: "none",
padding: "8px 16px", borderRadius: 6, fontSize: 13, fontWeight: 500,
fontFamily: sansStack, cursor: "pointer", display: "flex",
alignItems: "center", gap: 6,
}}><I d={Paths.plus} size={13}/> Deploy</button>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1.4fr 1fr", gap: 20 }}>
<div style={{
background: "#fff", border: "1px solid #ebebe6", borderRadius: 10,
padding: 20,
}}>
<div style={{ fontSize: 12, color: "#6a6a72", marginBottom: 12 }}>
Production · Last 24h
</div>
<NavImgSlot label="requests · time series" h={190} />
</div>
<div style={{
background: "#fff", border: "1px solid #ebebe6", borderRadius: 10,
padding: 20, display: "grid", gridTemplateRows: "1fr 1fr", gap: 14,
}}>
<div>
<div style={{ fontSize: 12, color: "#6a6a72" }}>Requests</div>
<div style={{ fontSize: 26, fontWeight: 600, marginTop: 4 }}>
284,012 <span style={{ color: "#22c55e", fontSize: 12 }}>+12%</span>
</div>
</div>
<div>
<div style={{ fontSize: 12, color: "#6a6a72" }}>Edge p99</div>
<div style={{ fontSize: 26, fontWeight: 600, marginTop: 4 }}>
47ms <span style={{ color: "#22c55e", fontSize: 12 }}>3ms</span>
</div>
</div>
</div>
</div>
</main>
</div>
);
};
// ============================================================
// 4. FLOATING GLASS NAV — marketing site / homepage pattern
// ============================================================
const NavFloatingGlass = () => (
<div style={{
width: "100%", height: "100%", color: "#fff",
background: "#08081a", fontFamily: sansStack,
position: "relative", overflow: "hidden",
}}>
{/* Soft aurora background */}
<div style={{
position: "absolute", top: -250, left: -150, width: 700, height: 700,
borderRadius: "50%",
background: "radial-gradient(circle, #5e5cff 0%, transparent 60%)",
filter: "blur(100px)", opacity: 0.5,
}}></div>
<div style={{
position: "absolute", top: 100, right: -200, width: 600, height: 600,
borderRadius: "50%",
background: "radial-gradient(circle, #b15bff 0%, transparent 60%)",
filter: "blur(100px)", opacity: 0.4,
}}></div>
<div style={{
position: "absolute", bottom: -200, left: "30%", width: 500, height: 500,
borderRadius: "50%",
background: "radial-gradient(circle, #00e5b3 0%, transparent 60%)",
filter: "blur(100px)", opacity: 0.3,
}}></div>
{/* Floating pill nav — the focal point */}
<header style={{
position: "absolute", top: 24, left: "50%",
transform: "translateX(-50%)", zIndex: 10,
width: "max-content", whiteSpace: "nowrap",
display: "flex", alignItems: "center", gap: 4,
padding: "8px 8px 8px 18px",
background: "rgba(255,255,255,0.06)",
backdropFilter: "blur(24px)",
WebkitBackdropFilter: "blur(24px)",
border: "1px solid rgba(255,255,255,0.12)",
borderRadius: 999,
boxShadow: "0 20px 50px -20px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.1)",
}}>
<div style={{
display: "flex", alignItems: "center", gap: 8,
marginRight: 18, fontWeight: 600, fontSize: 14,
}}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M3 20 L12 4 L21 20 Z" fill="url(#g)" />
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#5e5cff"/>
<stop offset="100%" stopColor="#b15bff"/>
</linearGradient>
</defs>
</svg>
Lattice
</div>
{["Product", "Solutions", "Customers", "Pricing", "Docs"].map((l, i) => (
<button key={l} style={{
background: i === 0 ? "rgba(255,255,255,0.1)" : "transparent",
border: "none", color: "#fff", whiteSpace: "nowrap",
padding: "8px 14px", borderRadius: 999,
fontSize: 13, fontFamily: sansStack, cursor: "pointer",
display: "flex", alignItems: "center", gap: 4,
}}>
{l}
{(i === 0 || i === 1) && (
<span style={{ opacity: 0.6, display: "flex" }}>
<I d={Paths.chevron} size={11} />
</span>
)}
</button>
))}
<div style={{ width: 1, height: 22, background: "rgba(255,255,255,0.12)", margin: "0 6px" }}></div>
<button style={{
background: "transparent", border: "none", color: "#fff",
padding: "8px 14px", borderRadius: 999, fontSize: 13,
fontFamily: sansStack, cursor: "pointer", whiteSpace: "nowrap",
}}>Sign in</button>
<button style={{
background: "#fff", color: "#08081a", border: "none",
padding: "8px 16px", borderRadius: 999, fontSize: 13, fontWeight: 600,
fontFamily: sansStack, cursor: "pointer", whiteSpace: "nowrap",
}}>Get started </button>
</header>
{/* Tiny status pill above nav */}
<div style={{
position: "absolute", top: -2, left: "50%",
transform: "translateX(-50%)",
padding: "4px 10px 4px 26px",
background: "rgba(255,255,255,0.04)",
backdropFilter: "blur(12px)",
border: "1px solid rgba(255,255,255,0.08)",
borderRadius: "0 0 10px 10px",
fontSize: 11, color: "#a8a8c0",
borderTop: "none",
}}>
All systems normal ·{" "}
<span style={{ color: "#7aff66" }}> 99.99% uptime</span>
</div>
{/* HERO */}
<main style={{
position: "relative", paddingTop: 180,
textAlign: "center", maxWidth: 880, margin: "0 auto",
padding: "180px 40px 0",
}}>
<div style={{
display: "inline-flex", alignItems: "center", gap: 8,
padding: "5px 14px 5px 5px", borderRadius: 999,
background: "rgba(255,255,255,0.06)",
border: "1px solid rgba(255,255,255,0.12)",
fontSize: 12, marginBottom: 28,
}}>
<span style={{
padding: "2px 8px", background: "#5e5cff", borderRadius: 999,
fontWeight: 600, fontSize: 10,
}}>NEW</span>
Lattice 4.0 agents that draft for you ·{" "}
<span style={{ opacity: 0.7 }}>read more </span>
</div>
<h1 style={{
fontSize: 76, lineHeight: 1, margin: 0, fontWeight: 500,
letterSpacing: "-0.04em", textWrap: "balance",
}}>
The workspace where{" "}
<span style={{
background: "linear-gradient(90deg, #b15bff, #5e5cff, #00e5b3)",
WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent",
fontStyle: "italic", fontWeight: 400,
}}>
good ideas
</span>{" "}
compound.
</h1>
<p style={{
fontSize: 17, lineHeight: 1.5, marginTop: 22, opacity: 0.7,
maxWidth: 540, marginLeft: "auto", marginRight: "auto",
}}>
Docs, canvases, and agents in one luminous surface.
Built by people who got tired of switching tabs.
</p>
<div style={{ display: "flex", gap: 12, justifyContent: "center", marginTop: 32 }}>
<button style={{
background: "#fff", color: "#08081a", border: "none",
padding: "14px 28px", borderRadius: 999, fontWeight: 600,
fontSize: 14, cursor: "pointer", fontFamily: sansStack,
whiteSpace: "nowrap",
}}>Start for free</button>
<button style={{
background: "rgba(255,255,255,0.08)", color: "#fff",
border: "1px solid rgba(255,255,255,0.16)",
backdropFilter: "blur(12px)",
padding: "14px 28px", borderRadius: 999, fontSize: 14,
cursor: "pointer", fontFamily: sansStack, whiteSpace: "nowrap",
}}> Watch the film · 2 min</button>
</div>
</main>
</div>
);
// Export to window
Object.assign(window, {
NavSidebar, NavIconRail, NavTopHorizontal, NavFloatingGlass,
});

View File

@@ -0,0 +1,300 @@
// ============================================================
// page-admin.jsx — Workspace settings, Members tab.
// Sub-nav (Workspace / Members / Roles / Integrations / Billing
// / API) + searchable member table + bulk actions + invite row.
// ============================================================
const AdminBody = ({ theme = "light", hideSubnav = false }) => {
const dark = theme === "dark";
const c = dark ? {
bg: "#0f0f14", panel: "#13131a", border: "#ffffff10",
text: "#e8e8ee", subtext: "#9a9aa6", muted: "#6a6a78",
rowAlt: "#ffffff04", input: "#08080c", accent: "#7a78ff",
chipBg: "#ffffff08", chipText: "#dcdce4", danger: "#ff4d5e",
} : {
bg: "#fafaf9", panel: "#ffffff", border: "#ebebe6",
text: "#111", subtext: "#5a5a5e", muted: "#8a8a90",
rowAlt: "#fafaf6", input: "#fff", accent: "#5e5cff",
chipBg: "#f1f0eb", chipText: "#3a3a3e", danger: "#dc2626",
};
const subnav = [
"General", "Members", "Roles", "Integrations", "Billing", "API & Webhooks", "Audit log",
];
const roleColors = {
Owner: "#b15bff",
Admin: "#5e5cff",
Member: "#22c55e",
Guest: "#9a9aa6",
};
const members = [
{ i: "MR", c: "#d4b8a8", n: "Mira Reyes", e: "mira@lattice.co", r: "Owner", s: "Active", last: "now", teams: ["Founding"] },
{ i: "TR", c: "#c8e8a8", n: "Theo Roux", e: "theo@lattice.co", r: "Admin", s: "Active", last: "12 min", teams: ["Engineering"] },
{ i: "DP", c: "#a8c8e8", n: "Devi Patel", e: "devi@lattice.co", r: "Admin", s: "Active", last: "1 hour", teams: ["Revenue"] },
{ i: "SK", c: "#e8a87c", n: "Sun Kim", e: "sun@lattice.co", r: "Member", s: "Active", last: "today", teams: ["Revenue", "Design"] },
{ i: "AN", c: "#e8c8a8", n: "Ade Nwosu", e: "ade@lattice.co", r: "Member", s: "Active", last: "yesterday", teams: ["Engineering"] },
{ i: "LB", c: "#c8a8e8", n: "Linnea Berg", e: "linnea@lattice.co", r: "Member", s: "Invited", last: "—", teams: [] },
{ i: "JF", c: "#a8e8c8", n: "Jamal Frost", e: "jamal@partner.co", r: "Guest", s: "Active", last: "3 days", teams: ["Revenue"] },
{ i: "ER", c: "#e8a8c8", n: "Elin Roos", e: "elin@lattice.co", r: "Member", s: "Suspended", last: "14 days", teams: ["Design"] },
];
const Badge = ({ color, children, dot }) => (
<span style={{
display: "inline-flex", alignItems: "center", gap: 5,
padding: "2px 8px", borderRadius: 999,
background: color ? `${color}1f` : c.chipBg,
color: color || c.chipText,
fontSize: 11, fontWeight: 500, whiteSpace: "nowrap",
}}>
{dot && <span style={{
width: 6, height: 6, borderRadius: "50%", background: color,
}}></span>}
{children}
</span>
);
const Avatar = ({ name, color, size = 28 }) => (
<div style={{
width: size, height: size, borderRadius: "50%", background: color,
fontSize: size * 0.4, fontWeight: 600, color: "#3a2820",
display: "flex", alignItems: "center", justifyContent: "center",
flexShrink: 0,
}}>{name}</div>
);
return (
<div style={{
height: "100%", background: c.bg, color: c.text, fontFamily: SANS,
display: "grid",
gridTemplateColumns: hideSubnav ? "1fr" : "220px 1fr",
overflow: "hidden",
}}>
{/* Settings sub-nav */}
{!hideSubnav && <aside style={{
borderRight: `1px solid ${c.border}`, padding: "20px 12px",
background: dark ? "#0a0a10" : "#f5f5f2",
display: "flex", flexDirection: "column",
}}>
<div style={{
fontSize: 11, color: c.muted, padding: "0 10px 8px",
letterSpacing: "0.06em", textTransform: "uppercase", fontWeight: 500,
}}>Settings</div>
{subnav.map((s, i) => (
<div key={s} style={{
padding: "7px 10px", borderRadius: 6, fontSize: 13, cursor: "pointer",
color: i === 1 ? c.text : c.subtext,
background: i === 1 ? (dark ? "#ffffff10" : "#fff") : "transparent",
boxShadow: i === 1 && !dark ? "0 1px 0 #00000008, 0 0 0 1px #00000008" : "none",
fontWeight: i === 1 ? 500 : 400,
marginBottom: 2,
}}>{s}</div>
))}
<div style={{
fontSize: 11, color: c.muted, padding: "16px 10px 8px",
letterSpacing: "0.06em", textTransform: "uppercase", fontWeight: 500,
}}>Personal</div>
{["Profile", "Notifications", "Sessions"].map(s => (
<div key={s} style={{
padding: "7px 10px", borderRadius: 6, fontSize: 13, cursor: "pointer",
color: c.subtext, marginBottom: 2,
}}>{s}</div>
))}
<div style={{ flex: 1 }}></div>
<div style={{
padding: "12px 12px", borderRadius: 8,
background: dark ? "#ffffff06" : "#fff",
border: `1px solid ${c.border}`,
}}>
<div style={{ fontSize: 12, fontWeight: 500, marginBottom: 4 }}>
Free workspace
</div>
<div style={{ fontSize: 11, color: c.muted, lineHeight: 1.4, marginBottom: 10 }}>
6 of 10 seats used. Upgrade for SSO, audit log retention, and SCIM.
</div>
<button style={{
width: "100%", padding: "7px 12px", borderRadius: 6,
background: dark ? "#fff" : "#111", color: dark ? "#111" : "#fff",
border: "none", fontSize: 12, fontFamily: SANS, fontWeight: 500,
cursor: "pointer",
}}>Upgrade to Pro </button>
</div>
</aside>}
{/* Main */}
<div style={{ display: "flex", flexDirection: "column", overflow: "hidden" }}>
{/* Page header */}
<div style={{
padding: "20px 28px 14px", borderBottom: `1px solid ${c.border}`,
}}>
<div style={{ fontSize: 12, color: c.muted, marginBottom: 6 }}>
Settings / Members
</div>
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "flex-end",
}}>
<div>
<h1 style={{ fontSize: 24, fontWeight: 600, margin: 0, letterSpacing: "-0.02em" }}>
Members
</h1>
<p style={{
fontSize: 13, color: c.subtext, margin: "6px 0 0", maxWidth: 540,
}}>
Manage who has access to <b>Lattice Studio</b>. Roles control
what each person can see and edit across the workspace.
</p>
</div>
<div style={{ display: "flex", gap: 8 }}>
<button style={{
padding: "8px 14px", borderRadius: 6, fontSize: 13, fontFamily: SANS,
background: c.panel, border: `1px solid ${c.border}`, color: c.text,
cursor: "pointer",
}}>Export CSV</button>
<button style={{
padding: "8px 14px", borderRadius: 6, fontSize: 13, fontFamily: SANS,
background: dark ? "#fff" : "#111", color: dark ? "#111" : "#fff",
border: "none", cursor: "pointer", fontWeight: 500,
display: "flex", alignItems: "center", gap: 6,
}}><Icon d={P.plus} size={13}/> Invite people</button>
</div>
</div>
</div>
{/* Filter / search row */}
<div style={{
padding: "12px 28px", borderBottom: `1px solid ${c.border}`,
display: "flex", alignItems: "center", gap: 10,
}}>
<div style={{
display: "flex", alignItems: "center", gap: 8, padding: "6px 10px",
background: c.input, border: `1px solid ${c.border}`, borderRadius: 6,
fontSize: 12, color: c.muted, width: 280,
}}>
<Icon d={P.search} size={13} />
<span style={{ flex: 1 }}>Search by name, email</span>
</div>
{[
{ l: "Role", v: "All" },
{ l: "Status", v: "Active + Invited" },
{ l: "Team", v: "Any" },
].map(f => (
<div key={f.l} style={{
display: "flex", alignItems: "center", gap: 6, padding: "6px 10px",
border: `1px dashed ${c.border}`, borderRadius: 6, fontSize: 12,
color: c.subtext, cursor: "pointer",
}}>
<span style={{ color: c.muted }}>{f.l}:</span>
<span style={{ color: c.text, fontWeight: 500 }}>{f.v}</span>
<Icon d={P.chevron} size={11} />
</div>
))}
<div style={{ flex: 1 }}></div>
<span style={{ fontSize: 12, color: c.muted }}>
<b style={{ color: c.text }}>8</b> members · 1 invited · 1 suspended
</span>
</div>
{/* Table */}
<div style={{ flex: 1, overflowY: "auto" }}>
<div style={{
display: "grid",
gridTemplateColumns: "28px 2fr 1fr 1fr 1.4fr 1fr 32px",
padding: "10px 28px", fontSize: 11, color: c.muted,
letterSpacing: "0.04em", textTransform: "uppercase", fontWeight: 500,
borderBottom: `1px solid ${c.border}`,
alignItems: "center", gap: 12,
}}>
<input type="checkbox" style={{ accentColor: c.accent }} readOnly />
<span>Name</span>
<span>Role</span>
<span>Status</span>
<span>Teams</span>
<span>Last active</span>
<span></span>
</div>
{members.map((m, i) => (
<div key={m.e} style={{
display: "grid",
gridTemplateColumns: "28px 2fr 1fr 1fr 1.4fr 1fr 32px",
padding: "10px 28px", fontSize: 13,
alignItems: "center", gap: 12,
borderBottom: `1px solid ${c.border}`,
background: i % 2 === 1 ? c.rowAlt : "transparent",
}}>
<input type="checkbox" style={{ accentColor: c.accent }} readOnly />
<div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
<Avatar name={m.i} color={m.c} />
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 500, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{m.n}</div>
<div style={{ fontSize: 11, color: c.muted, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{m.e}</div>
</div>
</div>
<div>
<div style={{
display: "inline-flex", alignItems: "center", gap: 6,
padding: "3px 9px", borderRadius: 5,
background: `${roleColors[m.r]}18`,
color: roleColors[m.r], fontSize: 12, fontWeight: 500,
cursor: "pointer",
}}>
{m.r}
<Icon d={P.chevron} size={11} />
</div>
</div>
<div>
{m.s === "Active" && <Badge color="#22c55e" dot>Active</Badge>}
{m.s === "Invited" && <Badge color="#f6c560" dot>Invited</Badge>}
{m.s === "Suspended" && <Badge color={c.danger} dot>Suspended</Badge>}
</div>
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
{m.teams.length === 0
? <span style={{ fontSize: 12, color: c.muted }}></span>
: m.teams.map(t => <Badge key={t}>{t}</Badge>)}
</div>
<div style={{ fontSize: 12, color: c.subtext }}>{m.last}</div>
<div style={{ color: c.muted, display: "flex", justifyContent: "flex-end", cursor: "pointer" }}>
<Icon d={P.more} size={16} />
</div>
</div>
))}
{/* Pending-invite footer band */}
<div style={{
margin: "18px 28px 28px", padding: "14px 16px", borderRadius: 10,
background: dark ? "#ffffff06" : "#fff8e6",
border: `1px solid ${dark ? "#ffffff14" : "#f3e0a4"}`,
display: "flex", alignItems: "center", gap: 14,
}}>
<div style={{
width: 32, height: 32, borderRadius: 8,
background: dark ? "#ffffff10" : "#f6c56020",
color: dark ? "#f6c560" : "#a87b1a",
display: "flex", alignItems: "center", justifyContent: "center",
}}><Icon d={P.bell} size={16} /></div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>
1 invitation is still pending
</div>
<div style={{ fontSize: 12, color: c.subtext, marginTop: 2 }}>
<b>linnea@lattice.co</b> hasn't accepted yet sent 3 days ago.
</div>
</div>
<button style={{
padding: "6px 12px", borderRadius: 6, fontSize: 12, fontFamily: SANS,
background: dark ? "#ffffff10" : "#fff",
border: `1px solid ${c.border}`, color: c.text, cursor: "pointer",
}}>Resend</button>
<button style={{
padding: "6px 12px", borderRadius: 6, fontSize: 12, fontFamily: SANS,
background: "transparent", border: "none", color: c.muted, cursor: "pointer",
}}>Revoke</button>
</div>
</div>
</div>
</div>
);
};
window.AdminBody = AdminBody;

View File

@@ -0,0 +1,318 @@
// ============================================================
// page-customer.jsx — CRM company record page.
// Header (logo + name + status + actions), 2-col layout:
// left — details panel (industry, owner, links, deals)
// right — tabbed work area (Overview / Activity / People / Notes)
// Pure content. Wrap in any *Chrome to compose.
// ============================================================
const CustomerBody = ({ theme = "light" }) => {
const dark = theme === "dark";
const c = dark ? {
bg: "#0f0f14", panel: "#13131a", border: "#ffffff10",
text: "#e8e8ee", subtext: "#9a9aa6", muted: "#6a6a78",
rowAlt: "#ffffff04", accent: "#7a78ff", ring: "#5e5cff",
chipBg: "#ffffff08", chipText: "#dcdce4",
inputBg: "#0a0a10",
} : {
bg: "#fcfcfb", panel: "#ffffff", border: "#e8e8e3",
text: "#111", subtext: "#5a5a5e", muted: "#8a8a90",
rowAlt: "#f9f9f6", accent: "#5e5cff", ring: "#5e5cff",
chipBg: "#f1f0eb", chipText: "#3a3a3e",
inputBg: "#fff",
};
const KV = ({ k, v }) => (
<div style={{
display: "grid", gridTemplateColumns: "110px 1fr", gap: 10,
padding: "8px 0", fontSize: 13, alignItems: "baseline",
}}>
<span style={{ color: c.muted }}>{k}</span>
<span style={{ color: c.text }}>{v}</span>
</div>
);
const Tag = ({ children, color }) => (
<span style={{
display: "inline-flex", alignItems: "center", gap: 5,
padding: "2px 8px", borderRadius: 999,
background: color ? `${color}1f` : c.chipBg,
color: color || c.chipText,
fontSize: 11, fontWeight: 500, whiteSpace: "nowrap",
}}>
{color && <span style={{
width: 6, height: 6, borderRadius: "50%", background: color,
}}></span>}
{children}
</span>
);
const Avatar = ({ name, color = "#d4b8a8", size = 24, ring }) => (
<div style={{
width: size, height: size, borderRadius: "50%", background: color,
fontSize: size * 0.4, fontWeight: 600, color: "#3a2820",
display: "flex", alignItems: "center", justifyContent: "center",
flexShrink: 0, boxShadow: ring ? `0 0 0 2px ${c.panel}, 0 0 0 3px ${ring}` : "none",
}}>{name}</div>
);
return (
<div style={{
display: "grid", gridTemplateRows: "auto 1fr", height: "100%",
background: c.bg, color: c.text, fontFamily: SANS, overflow: "hidden",
}}>
{/* Header */}
<div style={{
padding: "16px 28px", borderBottom: `1px solid ${c.border}`,
display: "flex", alignItems: "center", gap: 16,
}}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: "linear-gradient(135deg, #f6c560 0%, #e08c4a 100%)",
display: "flex", alignItems: "center", justifyContent: "center",
fontWeight: 700, fontSize: 18, color: "#3a2210",
}}>NS</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 3 }}>
<h1 style={{
fontSize: 22, fontWeight: 600, margin: 0, letterSpacing: "-0.01em",
}}>Northstar Logistics</h1>
<Tag color="#22c55e">Customer</Tag>
<Tag color="#5e5cff">Tier 1</Tag>
</div>
<div style={{
fontSize: 12, color: c.muted, display: "flex", gap: 14, alignItems: "center",
}}>
<span>northstarlogistics.com</span>
<span>·</span>
<span>Created Aug 2024</span>
<span>·</span>
<span>Last touched 2h ago</span>
</div>
</div>
<div style={{ display: "flex", gap: 8 }}>
<button style={{
padding: "7px 12px", borderRadius: 6, fontSize: 12, fontFamily: SANS,
background: dark ? "#ffffff08" : "#fff",
border: `1px solid ${c.border}`,
color: c.text, cursor: "pointer", display: "flex", alignItems: "center", gap: 6,
}}><Icon d={P.star} size={13}/> Star</button>
<button style={{
padding: "7px 12px", borderRadius: 6, fontSize: 12, fontFamily: SANS,
background: dark ? "#ffffff08" : "#fff",
border: `1px solid ${c.border}`,
color: c.text, cursor: "pointer",
}}>Share</button>
<button style={{
padding: "7px 14px", borderRadius: 6, fontSize: 12, fontFamily: SANS,
background: dark ? "#fff" : "#111",
color: dark ? "#111" : "#fff",
border: "none", cursor: "pointer", fontWeight: 500,
display: "flex", alignItems: "center", gap: 6,
}}><Icon d={P.plus} size={12}/> Log activity</button>
</div>
</div>
{/* Body */}
<div style={{
display: "grid", gridTemplateColumns: "320px 1fr", gap: 0,
overflow: "hidden",
}}>
{/* Details rail */}
<div style={{
padding: "20px 24px", borderRight: `1px solid ${c.border}`,
overflowY: "auto",
}}>
<div style={{
fontSize: 11, color: c.muted, letterSpacing: "0.06em",
textTransform: "uppercase", fontWeight: 500, marginBottom: 6,
}}>About</div>
<KV k="Industry" v="Freight & Logistics" />
<KV k="Employees" v="240 — 500" />
<KV k="HQ" v="Rotterdam, NL" />
<KV k="Founded" v="2011" />
<KV k="Owner" v={
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
<Avatar name="MR" size={18} /> Mira Reyes
</span>
} />
<KV k="Source" v="Referral · DH" />
<div style={{
fontSize: 11, color: c.muted, letterSpacing: "0.06em",
textTransform: "uppercase", fontWeight: 500, marginTop: 22, marginBottom: 8,
}}>Tags</div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
<Tag color="#e08c4a">Enterprise</Tag>
<Tag color="#22c55e">Renewal Q3</Tag>
<Tag color="#5e5cff">EMEA</Tag>
<Tag>Logistics</Tag>
</div>
<div style={{
fontSize: 11, color: c.muted, letterSpacing: "0.06em",
textTransform: "uppercase", fontWeight: 500, marginTop: 22, marginBottom: 8,
}}>Open opportunities</div>
{[
{ name: "Q3 — Carrier API", v: "€84,000", stage: "Negotiation", p: 70 },
{ name: "EU expansion", v: "€38,500", stage: "Proposal", p: 40 },
{ name: "Renewal · Pro", v: "€24,000", stage: "Discovery", p: 15 },
].map(d => (
<div key={d.name} style={{
padding: "10px 12px", borderRadius: 8,
background: c.panel, border: `1px solid ${c.border}`,
marginBottom: 8,
}}>
<div style={{
display: "flex", justifyContent: "space-between",
alignItems: "baseline", marginBottom: 6,
}}>
<span style={{ fontSize: 13, fontWeight: 500 }}>{d.name}</span>
<span style={{
fontSize: 12, color: c.subtext, fontVariantNumeric: "tabular-nums",
}}>{d.v}</span>
</div>
<div style={{
fontSize: 11, color: c.muted, display: "flex",
justifyContent: "space-between", marginBottom: 4,
}}>
<span>{d.stage}</span><span>{d.p}%</span>
</div>
<div style={{
height: 3, borderRadius: 2,
background: dark ? "#ffffff10" : "#eeeee9",
overflow: "hidden",
}}>
<div style={{
width: `${d.p}%`, height: "100%",
background: d.p > 60 ? "#22c55e" : d.p > 30 ? "#f6c560" : "#9a9aa6",
}}></div>
</div>
</div>
))}
</div>
{/* Tabs + work area */}
<div style={{ display: "flex", flexDirection: "column", overflow: "hidden" }}>
<div style={{
padding: "0 28px", borderBottom: `1px solid ${c.border}`,
display: "flex", gap: 0,
}}>
{["Overview", "Activity", "People", "Notes", "Files"].map((t, i) => (
<div key={t} style={{
padding: "14px 14px", fontSize: 13, fontWeight: 500,
color: i === 1 ? c.text : c.muted,
borderBottom: i === 1 ? `2px solid ${c.accent}` : "2px solid transparent",
cursor: "pointer", position: "relative", top: 1,
}}>{t}{t === "Activity" && " · 28"}</div>
))}
</div>
<div style={{ flex: 1, overflowY: "auto", padding: "24px 28px" }}>
{/* KPI row */}
<div style={{
display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 12,
marginBottom: 22,
}}>
{[
{ l: "Pipeline", v: "€146.5k", s: "+€12.4k 30d", up: true },
{ l: "Closed-won", v: "€220k", s: "lifetime" },
{ l: "Open deals", v: "3", s: "1 stalled", warn: true },
{ l: "Health", v: "82 / 100", s: "stable", up: true },
].map(k => (
<div key={k.l} style={{
padding: "14px 16px", borderRadius: 10,
background: c.panel, border: `1px solid ${c.border}`,
}}>
<div style={{ fontSize: 11, color: c.muted, marginBottom: 6 }}>{k.l}</div>
<div style={{
fontSize: 22, fontWeight: 600, letterSpacing: "-0.01em",
}}>{k.v}</div>
<div style={{
fontSize: 11, color: k.up ? "#22c55e" : k.warn ? "#f6c560" : c.muted,
marginTop: 2,
}}>{k.s}</div>
</div>
))}
</div>
{/* Activity timeline */}
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 12 }}>Activity</div>
<div style={{ position: "relative", paddingLeft: 22 }}>
<div style={{
position: "absolute", left: 9, top: 6, bottom: 6,
width: 1, background: c.border,
}}></div>
{[
{ dot: "#22c55e", t: "Deal moved to Negotiation",
who: "Mira Reyes", w: "Q3 — Carrier API · €84,000",
when: "2 hours ago" },
{ dot: "#5e5cff", t: "Email sent · proposal v4",
who: "Mira Reyes", w: "To: Sun Kim, Devi Patel — opened 6 times",
when: "Yesterday" },
{ dot: "#f6c560", t: "Call logged · 32 min",
who: "Theo Roux", w: "Walkthrough with their ops lead — promising",
when: "2 days ago" },
{ dot: "#9a9aa6", t: "Note added",
who: "Mira Reyes", w: "They want SSO and SCIM by Sept. — gating item.",
when: "4 days ago" },
].map((a, i) => (
<div key={i} style={{ marginBottom: 16, position: "relative" }}>
<span style={{
position: "absolute", left: -19, top: 4, width: 11, height: 11,
background: a.dot, borderRadius: "50%",
boxShadow: `0 0 0 3px ${c.bg}`,
}}></span>
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "baseline",
}}>
<span style={{ fontSize: 13, fontWeight: 500 }}>{a.t}</span>
<span style={{ fontSize: 11, color: c.muted }}>{a.when}</span>
</div>
<div style={{ fontSize: 12, color: c.subtext, marginTop: 2 }}>
{a.who} · {a.w}
</div>
</div>
))}
</div>
{/* People row */}
<div style={{
fontSize: 13, fontWeight: 600, marginTop: 12, marginBottom: 12,
display: "flex", justifyContent: "space-between", alignItems: "center",
}}>
<span>People at Northstar · 6</span>
<button style={{
background: "transparent", border: "none", color: c.accent,
fontSize: 12, fontFamily: SANS, cursor: "pointer",
}}>View all </button>
</div>
<div style={{
display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 10,
}}>
{[
{ i: "SK", n: "Sun Kim", r: "VP Operations", c: "#e8a87c" },
{ i: "DP", n: "Devi Patel", r: "Procurement", c: "#a8c8e8" },
{ i: "TR", n: "Theo Roux", r: "CFO", c: "#c8e8a8" },
].map(p => (
<div key={p.i} style={{
display: "flex", alignItems: "center", gap: 10,
padding: "10px 12px", borderRadius: 8,
background: c.panel, border: `1px solid ${c.border}`,
}}>
<Avatar name={p.i} color={p.c} size={32} />
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>{p.n}</div>
<div style={{ fontSize: 11, color: c.muted }}>{p.r}</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
};
window.CustomerBody = CustomerBody;

View File

@@ -0,0 +1,355 @@
// ============================================================
// page-dashboard.jsx — KPI strip + time-series chart +
// pipeline funnel + recent activity + team leaderboard.
// Theme-aware so it adapts to dark rail chrome.
// ============================================================
const DashboardBody = ({ theme = "light" }) => {
const dark = theme === "dark";
const c = dark ? {
bg: "#0f0f14", panel: "#13131a", border: "#ffffff10",
text: "#e8e8ee", subtext: "#9a9aa6", muted: "#6a6a78",
grid: "#ffffff08", accent: "#7a78ff", up: "#22c55e", down: "#ff4d5e",
} : {
bg: "#fafaf9", panel: "#ffffff", border: "#ebebe6",
text: "#111", subtext: "#5a5a5e", muted: "#8a8a90",
grid: "#eeeee9", accent: "#5e5cff", up: "#22c55e", down: "#ff4d5e",
};
// Synthetic but consistent daily series, weekday-shaped
const days = ["M","T","W","T","F","S","S","M","T","W","T","F","S","S"];
const series = [42,58,71,64,79,32,28, 51,68,82,75,90,38,33];
const max = Math.max(...series);
// Funnel data
const funnel = [
{ stage: "New", n: 184, v: "€2.1m" },
{ stage: "Qualified", n: 96, v: "€1.4m" },
{ stage: "Proposal", n: 42, v: "€780k" },
{ stage: "Negotiation", n: 19, v: "€420k" },
{ stage: "Closed-won", n: 11, v: "€286k" },
];
const fmax = funnel[0].n;
const Avatar = ({ name, color = "#d4b8a8", size = 22 }) => (
<div style={{
width: size, height: size, borderRadius: "50%", background: color,
fontSize: size * 0.42, fontWeight: 600, color: "#3a2820",
display: "flex", alignItems: "center", justifyContent: "center",
flexShrink: 0,
}}>{name}</div>
);
return (
<div style={{
height: "100%", background: c.bg, color: c.text, fontFamily: SANS,
display: "flex", flexDirection: "column", overflow: "hidden",
}}>
{/* Header */}
<div style={{
padding: "20px 28px 16px", borderBottom: `1px solid ${c.border}`,
display: "flex", alignItems: "flex-end", justifyContent: "space-between",
}}>
<div>
<div style={{
fontSize: 11, color: c.muted, letterSpacing: "0.06em",
textTransform: "uppercase", marginBottom: 4, fontWeight: 500,
}}>Workspace dashboard</div>
<h1 style={{
fontSize: 26, fontWeight: 600, margin: 0, letterSpacing: "-0.02em",
}}>Good afternoon, Mira</h1>
<div style={{ fontSize: 13, color: c.subtext, marginTop: 4 }}>
3 deals moved stage today · 12 unread in Inbox · 1 task overdue
</div>
</div>
<div style={{ display: "flex", gap: 8 }}>
<div style={{
display: "flex", alignItems: "center", padding: "6px 10px",
borderRadius: 6, background: c.panel, border: `1px solid ${c.border}`,
fontSize: 12, color: c.subtext, gap: 8,
}}>
<span style={{ fontWeight: 500, color: c.text }}>Last 14 days</span>
<Icon d={P.chevron} size={12} />
</div>
<button style={{
padding: "7px 12px", borderRadius: 6, fontSize: 12, fontFamily: SANS,
background: c.panel, border: `1px solid ${c.border}`, color: c.text,
cursor: "pointer",
}}>Export</button>
<button style={{
padding: "7px 14px", borderRadius: 6, fontSize: 12, fontFamily: SANS,
background: dark ? "#fff" : "#111", color: dark ? "#111" : "#fff",
border: "none", cursor: "pointer", fontWeight: 500,
display: "flex", alignItems: "center", gap: 6,
}}><Icon d={P.plus} size={12}/> New report</button>
</div>
</div>
<div style={{
flex: 1, overflowY: "auto", padding: "20px 28px 28px",
display: "flex", flexDirection: "column", gap: 20,
}}>
{/* KPI strip */}
<div style={{
display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 12,
}}>
{[
{ l: "Revenue · MTD", v: "€286,420", d: "+18.4%", up: true,
spark: [20,28,24,36,30,42,52,48,58,62,70,82] },
{ l: "Active deals", v: "168", d: "+12", up: true,
spark: [40,42,45,46,49,52,54,56,58,60,62,65] },
{ l: "Win rate · 30d", v: "34.2%", d: "1.1%", up: false,
spark: [60,58,55,52,54,50,48,45,46,42,38,36] },
{ l: "Pipeline ratio", v: "4.8×", d: "healthy", up: true,
spark: [50,48,52,55,53,58,56,60,62,65,63,68] },
].map(k => {
const sm = Math.max(...k.spark), sn = Math.min(...k.spark);
const pts = k.spark.map((v, i) => {
const x = (i / (k.spark.length - 1)) * 100;
const y = 30 - ((v - sn) / (sm - sn || 1)) * 26 - 2;
return `${x},${y}`;
}).join(" ");
return (
<div key={k.l} style={{
padding: "16px 18px", borderRadius: 10,
background: c.panel, border: `1px solid ${c.border}`,
}}>
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "baseline",
marginBottom: 8,
}}>
<span style={{ fontSize: 12, color: c.muted }}>{k.l}</span>
<span style={{
fontSize: 11, color: k.up ? c.up : c.down, fontWeight: 500,
}}>{k.d}</span>
</div>
<div style={{
fontSize: 26, fontWeight: 600, letterSpacing: "-0.02em",
marginBottom: 6, fontVariantNumeric: "tabular-nums",
}}>{k.v}</div>
<svg viewBox="0 0 100 30" style={{
width: "100%", height: 26, display: "block",
}} preserveAspectRatio="none">
<polyline points={pts} fill="none"
stroke={k.up ? c.up : c.down} strokeWidth="1.5"
vectorEffect="non-scaling-stroke" />
</svg>
</div>
);
})}
</div>
{/* Chart + funnel */}
<div style={{
display: "grid", gridTemplateColumns: "1.5fr 1fr", gap: 16,
}}>
{/* Time-series */}
<div style={{
padding: "18px 20px", borderRadius: 12,
background: c.panel, border: `1px solid ${c.border}`,
}}>
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
marginBottom: 14,
}}>
<div>
<div style={{ fontSize: 13, fontWeight: 600 }}>Revenue, daily</div>
<div style={{ fontSize: 11, color: c.muted, marginTop: 2 }}>
Bookings · GBP closed-won
</div>
</div>
<div style={{ display: "flex", gap: 4 }}>
{["Day", "Week", "Month"].map((t, i) => (
<span key={t} style={{
padding: "4px 10px", borderRadius: 5, fontSize: 11, fontWeight: 500,
background: i === 0 ? (dark ? "#ffffff10" : "#f1f0eb") : "transparent",
color: i === 0 ? c.text : c.muted, cursor: "pointer",
}}>{t}</span>
))}
</div>
</div>
<div style={{ position: "relative", height: 180 }}>
{/* Gridlines */}
{[0, 0.25, 0.5, 0.75, 1].map(p => (
<div key={p} style={{
position: "absolute", left: 0, right: 0,
bottom: `${p * 100}%`, height: 1, background: c.grid,
}}></div>
))}
{/* Bars */}
<div style={{
position: "absolute", inset: 0, display: "flex",
alignItems: "flex-end", gap: 6, paddingRight: 6,
}}>
{series.map((v, i) => (
<div key={i} style={{ flex: 1, position: "relative",
display: "flex", flexDirection: "column",
alignItems: "center", justifyContent: "flex-end",
height: "100%",
}}>
<div style={{
width: "100%", height: `${(v / max) * 100}%`,
background: i === 11
? `linear-gradient(180deg, ${c.accent}, ${dark ? "#3a38c0" : "#bfbeff"})`
: (dark ? "#ffffff14" : "#e8e7e0"),
borderRadius: 3,
}}></div>
</div>
))}
</div>
{/* Annotation */}
<div style={{
position: "absolute", right: 6, top: -6,
background: c.text, color: c.bg,
padding: "3px 8px", borderRadius: 4, fontSize: 11, fontWeight: 500,
}}>42k · today</div>
</div>
<div style={{
display: "flex", justifyContent: "space-between", marginTop: 6,
fontSize: 10, color: c.muted, fontFamily: "ui-monospace, monospace",
}}>
{days.map((d, i) => (
<span key={i} style={{ flex: 1, textAlign: "center" }}>{d}</span>
))}
</div>
</div>
{/* Funnel */}
<div style={{
padding: "18px 20px", borderRadius: 12,
background: c.panel, border: `1px solid ${c.border}`,
}}>
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "baseline",
marginBottom: 14,
}}>
<div style={{ fontSize: 13, fontWeight: 600 }}>Pipeline funnel</div>
<span style={{ fontSize: 11, color: c.muted }}>Q2 · 168 deals</span>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{funnel.map((f, i) => {
const w = (f.n / fmax) * 100;
const colors = ["#5e5cff", "#7a78ff", "#9b99ff", "#bcb9ff", "#22c55e"];
return (
<div key={f.stage} style={{ position: "relative" }}>
<div style={{
width: `${w}%`, height: 30, borderRadius: 5,
background: colors[i], display: "flex",
alignItems: "center", paddingLeft: 12, color: "#fff",
fontSize: 12, fontWeight: 500,
}}>{f.stage}</div>
<div style={{
position: "absolute", right: 0, top: 0, height: 30,
display: "flex", alignItems: "center", gap: 10,
fontSize: 12, color: c.muted,
}}>
<span style={{
fontFamily: "ui-monospace, monospace", color: c.text,
}}>{f.n}</span>
<span style={{ fontSize: 11 }}>{f.v}</span>
</div>
</div>
);
})}
</div>
</div>
</div>
{/* Activity + leaderboard */}
<div style={{
display: "grid", gridTemplateColumns: "1.5fr 1fr", gap: 16,
}}>
<div style={{
padding: "18px 20px", borderRadius: 12,
background: c.panel, border: `1px solid ${c.border}`,
}}>
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "baseline",
marginBottom: 14,
}}>
<div style={{ fontSize: 13, fontWeight: 600 }}>Recent activity</div>
<span style={{ fontSize: 11, color: c.accent, cursor: "pointer" }}>View all </span>
</div>
{[
{ who: "MR", c: "#d4b8a8", n: "Mira Reyes", v: "moved",
w: <><b>Q3 Carrier API</b> to <span style={{ color: "#22c55e" }}>Negotiation</span></>,
t: "2m ago" },
{ who: "TR", c: "#c8e8a8", n: "Theo Roux", v: "logged a call with",
w: <><b>Sun Kim · Northstar</b></>, t: "14m" },
{ who: "DP", c: "#a8c8e8", n: "Devi Patel", v: "closed",
w: <><b>Halcyon · Pro renewal</b> · 24,000</>, t: "1h" },
{ who: "MR", c: "#d4b8a8", n: "Mira Reyes", v: "created a deal",
w: <><b>Brooke Foods Q3 pilot</b></>, t: "2h" },
{ who: "SK", c: "#e8a87c", n: "Sun Kim", v: "added 4 contacts to",
w: <><b>Kestrel</b></>, t: "3h" },
].map((a, i) => (
<div key={i} style={{
display: "flex", alignItems: "center", gap: 10,
padding: "8px 0",
borderTop: i === 0 ? "none" : `1px solid ${c.border}`,
}}>
<Avatar name={a.who} color={a.c} size={26} />
<div style={{ flex: 1, fontSize: 13 }}>
<span style={{ fontWeight: 500 }}>{a.n}</span>
<span style={{ color: c.muted }}> {a.v} </span>
<span>{a.w}</span>
</div>
<span style={{ fontSize: 11, color: c.muted, whiteSpace: "nowrap" }}>{a.t}</span>
</div>
))}
</div>
<div style={{
padding: "18px 20px", borderRadius: 12,
background: c.panel, border: `1px solid ${c.border}`,
}}>
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "baseline",
marginBottom: 14,
}}>
<div style={{ fontSize: 13, fontWeight: 600 }}>Team · this month</div>
<span style={{ fontSize: 11, color: c.muted }}>By bookings</span>
</div>
{[
{ i: "MR", c: "#d4b8a8", n: "Mira Reyes", v: 124, d: "€124k", p: 100 },
{ i: "DP", c: "#a8c8e8", n: "Devi Patel", v: 86, d: "€86k", p: 70 },
{ i: "TR", c: "#c8e8a8", n: "Theo Roux", v: 62, d: "€62k", p: 50 },
{ i: "SK", c: "#e8a87c", n: "Sun Kim", v: 48, d: "€48k", p: 39 },
].map(t => (
<div key={t.i} style={{
display: "grid", gridTemplateColumns: "26px 1fr auto", gap: 10,
alignItems: "center", padding: "8px 0",
}}>
<Avatar name={t.i} color={t.c} size={26} />
<div style={{ minWidth: 0 }}>
<div style={{
display: "flex", justifyContent: "space-between",
fontSize: 12, marginBottom: 4,
}}>
<span style={{ fontWeight: 500 }}>{t.n}</span>
<span style={{
color: c.subtext, fontVariantNumeric: "tabular-nums",
}}>{t.d}</span>
</div>
<div style={{
height: 3, borderRadius: 2,
background: dark ? "#ffffff10" : "#eeeee9", overflow: "hidden",
}}>
<div style={{
width: `${t.p}%`, height: "100%",
background: `linear-gradient(90deg, ${c.accent}, ${dark ? "#9b99ff" : "#b15bff"})`,
}}></div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
};
window.DashboardBody = DashboardBody;

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -0,0 +1,139 @@
// Sign In — magic-link primary, OAuth alternatives. Default action is
// "Send me a magic link" (no passwords — fits the "no homework" brand).
// On submit, transitions to a "Check your inbox" confirmation state.
function SignIn() {
const [email, setEmail] = React.useState("");
const [submitting, setSubmitting] = React.useState(false);
const [sent, setSent] = React.useState(false);
const valid = /\S+@\S+\.\S+/.test(email);
const handleSubmit = (e) => {
e.preventDefault();
if (!valid || submitting) return;
setSubmitting(true);
setTimeout(() => {
setSubmitting(false);
setSent(true);
}, 700);
};
return (
<div className="page">
<TopBar rightLink={{ href: "index.html", label: "Back to home" }} />
<main className="auth-main">
<Glows />
<div className="auth-card">
{sent ? (
<SentConfirmation email={email} onChangeEmail={() => setSent(false)} />
) : (
<>
<div className="auth-eye">Welcome back</div>
<h1 className="auth-title">
Sign in and <em>keep building</em>.
</h1>
<p className="auth-sub">
We'll email you a one-tap link. No passwords to remember, no homework.
</p>
<form className="auth-form" onSubmit={handleSubmit} noValidate>
<div className="auth-field">
<label className="auth-label" htmlFor="email">Email</label>
<input
id="email" type="email" autoComplete="email" required autoFocus
className="auth-input"
placeholder="you@somewhere.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<button type="submit" disabled={!valid || submitting}
className="auth-btn auth-btn-primary">
{submitting ? (
<><span className="auth-spinner" /> Sending…</>
) : (
<><MailIcon size={17} /> Send me a magic link</>
)}
</button>
</form>
<div className="auth-divider">or continue with</div>
<div className="auth-oauth">
<button type="button" className="auth-btn auth-btn-ghost">
<GoogleIcon /> Continue with Google
</button>
<button type="button" className="auth-btn auth-btn-ghost">
<AppleIcon /> Continue with Apple
</button>
</div>
<div className="auth-foot">
Don't have an invite yet? <a href="Beta Signup.html">Request one </a>
</div>
</>
)}
</div>
<TrustStrip items={["No passwords", "No homework", "🇨🇦 Built in Canada"]} />
</main>
</div>
);
}
// Confirmation: "Check your inbox at you@x.com" with a resend timer + the
// option to change email and try again.
function SentConfirmation({ email, onChangeEmail }) {
const [left, restart] = useResendTimer(30);
return (
<div className="auth-success">
<div className="auth-success-badge">
<MailIcon size={26} />
</div>
<div className="auth-eye">Check your inbox</div>
<h1 className="auth-title" style={{ marginTop: 10 }}>
Magic link <em>sent</em>.
</h1>
<p className="auth-sub">
We just sent a one-tap sign-in link to
<span className="email-chip">{email}</span>.
Tap it on this device to keep building.
</p>
<div className="auth-tip">
<span className="auth-tip-icon">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<circle cx="8" cy="8" r="6.5"/>
<path d="M8 5v4M8 11v.5"/>
</svg>
</span>
<span>
Can't find it? Check your <b style={{ color: "var(--fg)", fontWeight: 500 }}>spam folder</b> or wait a few seconds —
email is slower than Vibn.
</span>
</div>
<div className="auth-resend">
Didn't get it?{" "}
{left > 0 ? (
<button type="button" disabled>Resend in {left}s</button>
) : (
<button type="button" onClick={restart}>Send again</button>
)}
</div>
<div className="auth-foot" style={{ marginTop: 22 }}>
Wrong email? <button type="button" onClick={onChangeEmail}
style={{ color: "var(--accent)", fontWeight: 500 }}>
Use a different one
</button>
</div>
</div>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<SignIn />);

View File

@@ -0,0 +1,213 @@
// Sign Up — invite-code gated. User pastes/types their invite, gives email +
// optional name, hits "Create my workspace." Magic-link delivery on submit.
// OAuth is offered too but the invite is still required.
function SignUp() {
const [code, setCode] = React.useState("");
const [email, setEmail] = React.useState("");
const [name, setName] = React.useState("");
const [submitting, setSubmitting] = React.useState(false);
const [created, setCreated] = React.useState(false);
const [codeState, setCodeState] = React.useState("idle"); // idle | checking | ok | bad
// Validate the invite code shape (V-XXXXXX, case-insensitive) and pretend to
// verify with a debounce so the UI feels alive even with no backend.
React.useEffect(() => {
const c = code.trim().toLowerCase();
if (!c) { setCodeState("idle"); return undefined; }
const looksValid = /^v-?[a-z0-9]{4,8}$/.test(c);
if (!looksValid) { setCodeState("bad"); return undefined; }
setCodeState("checking");
const t = setTimeout(() => setCodeState("ok"), 600);
return () => clearTimeout(t);
}, [code]);
const emailValid = /\S+@\S+\.\S+/.test(email);
const valid = emailValid && codeState === "ok";
const handleSubmit = (e) => {
e.preventDefault();
if (!valid || submitting) return;
setSubmitting(true);
setTimeout(() => {
setSubmitting(false);
setCreated(true);
}, 800);
};
return (
<div className="page">
<TopBar rightLink={{ href: "index.html", label: "Back to home" }} />
<main className="auth-main">
<Glows />
<div className="auth-card">
{created ? (
<CreatedConfirmation email={email} name={name} />
) : (
<>
<div className="auth-eye">You're invited</div>
<h1 className="auth-title">
Create your <em>workspace</em>.
</h1>
<p className="auth-sub">
Paste your invite code and the email it came to. We'll have you building in seconds.
</p>
<form className="auth-form" onSubmit={handleSubmit} noValidate>
<div className="auth-field">
<label className="auth-label" htmlFor="code">Invite code</label>
<div style={{ position: "relative" }}>
<input
id="code" type="text" autoComplete="off"
required autoFocus
className="auth-input mono"
placeholder="V-XXXXXX"
value={code}
onChange={(e) => setCode(e.target.value)}
maxLength={12}
style={{ paddingRight: 44 }}
/>
<CodeStatus state={codeState} />
</div>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="email">Email</label>
<input
id="email" type="email" autoComplete="email" required
className="auth-input"
placeholder="you@somewhere.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="name">
What should we call you? <span style={{ color: "var(--fg-faint)", letterSpacing: 0, textTransform: "none" }}>(optional)</span>
</label>
<input
id="name" type="text" autoComplete="given-name"
className="auth-input"
placeholder="First name or handle"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<button type="submit" disabled={!valid || submitting}
className="auth-btn auth-btn-primary"
style={{ marginTop: 4 }}>
{submitting ? (
<><span className="auth-spinner" /> Creating your workspace</>
) : (
<>Create my workspace <Arrow size={13} /></>
)}
</button>
</form>
<div className="auth-divider">or continue with</div>
<div className="auth-oauth">
<button type="button" className="auth-btn auth-btn-ghost">
<GoogleIcon /> Continue with Google
</button>
<button type="button" className="auth-btn auth-btn-ghost">
<AppleIcon /> Continue with Apple
</button>
</div>
<p className="auth-fine">
By creating a workspace you agree to our <a href="#">Terms</a> and <a href="#">Privacy Policy</a>.
</p>
<div className="auth-foot">
Already have an account? <a href="Sign In.html">Sign in </a>
</div>
</>
)}
</div>
<TrustStrip items={["No credit card", "No homework", "🇨🇦 Built in Canada"]} />
</main>
</div>
);
}
function CodeStatus({ state }) {
const wrap = {
position: "absolute", right: 14, top: "50%", transform: "translateY(-50%)",
display: "flex", alignItems: "center", gap: 6,
fontFamily: "var(--font-mono)", fontSize: 11, letterSpacing: "0.06em",
textTransform: "uppercase",
pointerEvents: "none",
};
if (state === "idle") return null;
if (state === "checking") return (
<span style={{ ...wrap, color: "var(--fg-mute)" }}>
<span className="auth-spinner" style={{ width: 12, height: 12, borderTopColor: "var(--fg-mute)" }} />
</span>
);
if (state === "bad") return (
<span style={{ ...wrap, color: "oklch(0.65 0.18 25)" }}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<circle cx="8" cy="8" r="6.5"/><path d="M5.5 5.5l5 5M10.5 5.5l-5 5"/>
</svg>
</span>
);
if (state === "ok") return (
<span style={{ ...wrap, color: "var(--ok)" }}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m3 8.5 3.2 3.2L13 5"/>
</svg>
Valid
</span>
);
return null;
}
// Confirmation — we've sent the magic link AND provisioned a workspace.
// Small celebratory beat: "Welcome, <name>" if given, else "You're in."
function CreatedConfirmation({ email, name }) {
return (
<div className="auth-success">
<div className="auth-success-badge">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="14" cy="14" r="13" opacity="0.25"/>
<path d="M8 14.5 12.5 19 21 10"/>
</svg>
</div>
<div className="auth-eye">Workspace ready</div>
<h1 className="auth-title" style={{ marginTop: 10 }}>
{name ? <>Welcome, <em>{name}</em>.</> : <>You're <em>in</em>.</>}
</h1>
<p className="auth-sub">
We sent a sign-in link to <span className="email-chip">{email}</span>.
Tap it on this device to step inside your workspace.
</p>
<div className="auth-tip">
<span className="auth-tip-icon">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<path d="M3.5 12 8 3l4.5 9"/><path d="M5 9h6"/>
</svg>
</span>
<span>
While you're waiting on the email your workspace lives at{" "}
<b style={{ color: "var(--fg)", fontWeight: 500, fontFamily: "var(--font-mono)" }}>
{(name || email.split("@")[0] || "you").toLowerCase().replace(/[^a-z0-9-]/g, "")}.vibn.app
</b>
. We'll send you the keys.
</span>
</div>
<div className="auth-foot" style={{ marginTop: 24 }}>
Already opened the email? <a href="Sign In.html">Continue here </a>
</div>
</div>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<SignUp />);

View File

@@ -0,0 +1,343 @@
// Replace your stack — visualizes the SMB's fractured subscription landscape
// collapsing into one tool. Eight grayscale "rented" tiles + spaghetti
// connections, an arrow, then one bright "owned" tile.
const STACK_TOOLS = [
{ name: "Booking", price: "$29/mo", glyph: "B" },
{ name: "POS", price: "$79/mo", glyph: "P" },
{ name: "CRM", price: "$45/mo", glyph: "C" },
{ name: "Accounting", price: "$30/mo", glyph: "A" },
{ name: "Inventory", price: "$59/mo", glyph: "I" },
{ name: "Email", price: "$19/mo", glyph: "E" },
{ name: "Loyalty", price: "$25/mo", glyph: "L" },
{ name: "+ spreadsheet", price: "the one you trust", glyph: "+" },
];
function Stack() {
return (
<section className="section stack">
<style>{`
.stack { padding-block: clamp(80px, 11vh, 130px); }
.stack-head { text-align: center; max-width: 820px; margin: 0 auto 56px; }
.stack-title {
font-size: clamp(36px, 4.8vw, 64px);
font-weight: 500; letter-spacing: -0.025em; line-height: 1.02;
text-wrap: balance;
}
.stack-title .accent { color: var(--accent); }
.stack-sub {
margin-top: 20px;
color: var(--fg-mute); font-size: 17px;
line-height: 1.5;
text-wrap: balance;
max-width: 640px; margin-inline: auto;
}
.stack-stage {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: clamp(20px, 4vw, 56px);
align-items: center;
max-width: 1080px; margin: 0 auto;
}
@media (max-width: 880px) {
.stack-stage { grid-template-columns: 1fr; }
}
/* Left side: 8 rented tiles */
.rented-wrap { position: relative; }
.rented-label, .owned-label {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--fg-faint);
margin-bottom: 16px;
display: flex; align-items: center; gap: 10px;
}
.owned-label { color: var(--accent); }
.rented-label::after, .owned-label::after {
content: ""; flex: 1; height: 1px;
background: var(--hairline);
}
.rented-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
position: relative;
}
@media (max-width: 540px) {
.rented-grid { grid-template-columns: repeat(2, 1fr); }
}
.tool-tile {
position: relative;
padding: 14px 12px 12px;
background: oklch(0.18 0.006 60 / 0.7);
border: 1px solid var(--hairline);
border-radius: 10px;
filter: grayscale(1);
opacity: 0.85;
transition: opacity .3s;
}
.tool-tile:hover { opacity: 1; }
.tool-glyph {
width: 22px; height: 22px;
border-radius: 6px;
background: oklch(0.30 0.008 60);
color: var(--fg-mute);
font-family: var(--font-mono);
font-weight: 600;
font-size: 12px;
display: grid; place-items: center;
margin-bottom: 8px;
}
.tool-name {
font-size: 13px;
color: var(--fg-dim);
font-weight: 500;
letter-spacing: -0.01em;
}
.tool-price {
margin-top: 2px;
font-family: var(--font-mono);
font-size: 10.5px;
color: var(--fg-faint);
letter-spacing: 0.02em;
}
/* The chaos: faint connecting lines between tiles */
.rented-grid::before {
content: "";
position: absolute;
inset: -4px;
background:
radial-gradient(circle at 22% 28%, oklch(0.50 0.05 35 / 0.18), transparent 24%),
radial-gradient(circle at 78% 72%, oklch(0.50 0.05 60 / 0.15), transparent 26%);
pointer-events: none;
z-index: 0;
}
.rented-grid > .tool-tile { z-index: 1; }
/* Spaghetti lines SVG overlay */
.rented-spaghetti {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
}
.rented-spaghetti path {
fill: none;
stroke: oklch(0.45 0.04 35 / 0.4);
stroke-width: 1;
stroke-dasharray: 3 4;
}
/* Rented total at bottom */
.rented-total {
margin-top: 14px;
display: flex; justify-content: space-between; align-items: baseline;
padding: 10px 14px;
background: oklch(0.16 0.008 60 / 0.5);
border: 1px dashed var(--hairline);
border-radius: 8px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--fg-mute);
letter-spacing: 0.02em;
}
.rented-total b {
color: var(--fg-dim);
font-weight: 500;
font-size: 15px;
}
/* Middle arrow */
.stack-arrow {
display: flex; flex-direction: column;
align-items: center; justify-content: center;
color: var(--accent);
gap: 6px;
}
.stack-arrow svg {
filter: drop-shadow(0 0 12px var(--accent-glow));
}
.stack-arrow-label {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent);
}
@media (max-width: 880px) {
.stack-arrow { padding: 16px 0; transform: rotate(90deg); }
}
/* Right side: one tile */
.owned-tile {
position: relative;
padding: 28px 26px 26px;
background: linear-gradient(180deg, oklch(0.22 0.012 60 / 0.95), oklch(0.18 0.008 60 / 0.95));
border: 1px solid oklch(0.74 0.175 35 / 0.55);
border-radius: 18px;
box-shadow:
0 0 60px -10px var(--accent-glow),
0 30px 80px -20px oklch(0 0 0 / 0.6),
inset 0 1px 0 oklch(0.84 0.16 35 / 0.18);
overflow: hidden;
}
.owned-tile::before {
/* Top accent hairline */
content: "";
position: absolute; top: 0; left: 0; right: 0; height: 1px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
opacity: .9;
}
.owned-glyph {
width: 44px; height: 44px;
border-radius: 12px;
background: linear-gradient(135deg, var(--accent), oklch(0.65 0.20 18));
color: var(--accent-fg);
display: grid; place-items: center;
margin-bottom: 16px;
box-shadow: 0 0 24px var(--accent-glow), inset 0 1px 0 oklch(1 0 0 / 0.25);
}
.owned-glyph svg { display: block; }
.owned-title {
font-size: 22px; font-weight: 500;
letter-spacing: -0.018em;
color: var(--fg);
}
.owned-sub {
margin-top: 4px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--accent);
letter-spacing: 0.04em;
}
.owned-features {
list-style: none; padding: 0; margin: 18px 0 0;
display: flex; flex-direction: column; gap: 6px;
font-size: 13.5px;
color: var(--fg-dim);
}
.owned-features li {
display: flex; align-items: center; gap: 8px;
}
.owned-features li::before {
content: "✓";
color: var(--ok);
font-family: var(--font-mono);
font-weight: 600;
font-size: 12px;
width: 16px; flex-shrink: 0;
}
.owned-foot {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--hairline);
display: flex; justify-content: space-between; align-items: baseline;
font-family: var(--font-mono);
font-size: 11.5px;
color: var(--fg-mute);
letter-spacing: 0.02em;
}
.owned-foot b {
color: var(--accent);
font-weight: 500;
font-size: 14px;
}
.stack-foot {
margin-top: 48px;
text-align: center;
font-size: 17px;
color: var(--fg-dim);
text-wrap: balance;
max-width: 720px; margin-inline: auto;
}
.stack-foot b { color: var(--fg); font-weight: 500; }
`}</style>
<div className="wrap">
<div className="stack-head">
<Eyebrow>Replace your stack</Eyebrow>
<h2 className="stack-title" style={{ marginTop: 18 }}>
You're running your business on <span className="accent">eight tools</span>
<br/>that don't fit.
</h2>
<p className="stack-sub">
A booking app over here. Invoicing over there. A separate CRM.
A POS that doesn't quite know about either. An accounting add-on, a scheduler,
a loyalty platform, plus the spreadsheet you actually trust.
</p>
</div>
<div className="stack-stage">
{/* Left: rented mess */}
<div className="rented-wrap">
<div className="rented-label">What you rent today</div>
<div className="rented-grid">
<svg className="rented-spaghetti" viewBox="0 0 200 100" preserveAspectRatio="none" aria-hidden="true">
<path d="M20 25 C 60 60, 120 10, 180 50" />
<path d="M50 15 C 90 70, 130 80, 170 25" />
<path d="M30 75 C 80 30, 140 60, 180 80" />
<path d="M15 50 C 80 80, 120 20, 185 70" />
</svg>
{STACK_TOOLS.map((tool) => (
<div className="tool-tile" key={tool.name}>
<div className="tool-glyph">{tool.glyph}</div>
<div className="tool-name">{tool.name}</div>
<div className="tool-price">{tool.price}</div>
</div>
))}
</div>
<div className="rented-total">
<span>Monthly rent</span>
<b>$286+ / mo</b>
</div>
</div>
{/* Middle: arrow */}
<div className="stack-arrow">
<span className="stack-arrow-label">Replaced by</span>
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" aria-hidden="true">
<path d="M8 22h26M24 12l10 10-10 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
{/* Right: owned tile */}
<div className="owned-tile">
<div className="owned-label">What you own with Vibn</div>
<div className="owned-glyph">
<svg viewBox="0 0 36 32" width="60%" height="60%" fill="currentColor" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" aria-hidden="true">
<path d="M4 5 L10 5 L12 18 L14 5 L20 5 L12 27 Z" />
<rect x="22.5" y="23" width="9.5" height="3.8" rx="0.7" />
</svg>
</div>
<div className="owned-title">Your business, one tool.</div>
<div className="owned-sub">built for you · owned by you</div>
<ul className="owned-features">
<li>Bookings, customers, invoicing one place</li>
<li>Fits how your business actually runs</li>
<li>No new tools to learn. No homework.</li>
<li>You own the code. You own the data.</li>
</ul>
<div className="owned-foot">
<span>One tool</span>
<b>One price · No rent</b>
</div>
</div>
</div>
<p className="stack-foot">
Eight tools, none of them built for you, none of them talking to each other.
<br/><b>Vibn replaces the whole stack with one tool built for your business, owned by you.</b>
</p>
</div>
</section>
);
}
Object.assign(window, { Stack });

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -0,0 +1,120 @@
# Vibn AI Templates
A small, themable starter kit for building modern SaaS UIs. Pure React + CSS variables — no build step, no dependencies. Designed to be copy-pasted into any project.
## What's in it
- **`tokens.css`** — Every color, radius, shadow, and type token, exposed as CSS custom properties. Four themes ship out of the box:
- `.theme-minimal` — soft warm light (Linear / Notion school)
- `.theme-dark` — black-and-white surface (Vercel / Stripe school)
- `.theme-glass` — aurora gradient + frosted glass
- `.theme-editorial` — paper, serif display, hairline rules
- **`icons.jsx`** — A small Tabler-style stroke icon set (`<Icon name="search"/>`) plus a `<VibnMark/>` brand glyph.
- **`components.jsx`** — Atoms + composites. Every visual property reads from a CSS variable:
- **Forms** · `Button`, `IconButton`, `Field`, `Input`, `Textarea`, `Select`, `Checkbox`, `Radio`, `Switch`, `FieldGroup`
- **Containers** · `Card`, `CardHeader`, `Divider`, `Modal`, `Banner`
- **Display** · `Badge` (tones: neutral / accent / success / warn / danger / info), `Avatar`, `AvatarStack`, `Tabs`, `Table`, `Spinner`, `KBD`
- **`shells.jsx`** — Page-level layouts:
- **In-product** · `SidebarShell`, `TopbarShell`, `RailShell`
- **Auth** · `AuthCenteredShell`, `AuthSplitShell`, `AuthGlassShell`
## How theming works
Tokens are CSS custom properties on `:root` (the default minimal theme). Each `.theme-*` class overrides a subset. Apply a theme by adding the class anywhere — usually on `<html>` or a top-level wrapper.
```html
<html class="theme-glass">
<!-- the whole page uses the glass theme -->
</html>
```
```html
<!-- or scope a theme to one region -->
<div class="theme-editorial">
<Card>… this card is editorial …</Card>
</div>
```
Themes can nest. Setting `theme-*` on a child element overrides only the tokens that theme defines; the rest inherit from the parent.
### Adding a fifth theme
Add a new class to `tokens.css` that overrides whichever tokens differ from `:root`:
```css
.theme-sunset {
--bg: #2b0d0e;
--surface: #3a1316;
--accent: #ff8a3a;
--accent-2: #f43f5e;
--text: #fef7ee;
--text-2: #f0c8b0;
--border: #4a1f23;
--button-bg: #ff8a3a;
--button-fg: #2b0d0e;
}
```
You don't need to redefine the whole token set — just the differences. Components don't change.
## Usage in plain HTML (no bundler)
```html
<link rel="stylesheet" href="vibn-ai-templates/tokens.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=DM+Serif+Display:ital@0;1&display=swap" rel="stylesheet">
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js"></script>
<script type="text/babel" src="vibn-ai-templates/icons.jsx"></script>
<script type="text/babel" src="vibn-ai-templates/components.jsx"></script>
<script type="text/babel" src="vibn-ai-templates/shells.jsx"></script>
<div id="root" class="theme-dark"></div>
<script type="text/babel">
function App() {
return (
<AuthCenteredShell brand={{ name: "Acme" }}>
<h1 style={{ margin: 0, fontSize: 22, fontWeight: 600 }}>Welcome back</h1>
<p style={{ color: "var(--text-2)", marginTop: 6 }}>Sign in to continue.</p>
<Field label="Email"><Input value="mira@acme.io"/></Field>
<Field label="Password"><Input type="password" value="••••••••••"/></Field>
<Button full>Sign in</Button>
</AuthCenteredShell>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
```
## Usage in a React codebase
Convert the three `.jsx` files from `Object.assign(window, …)` to named `export` statements. The components have no runtime dependencies beyond React.
```jsx
// In your app
import "vibn-ai-templates/tokens.css";
import { Button, Card, Input, Field, Tabs } from "vibn-ai-templates/components";
import { SidebarShell } from "vibn-ai-templates/shells";
// Pick a theme on your root
<html className="theme-dark">
```
## Conventions
- **Inline styles read from CSS vars** — `style={{ background: "var(--surface)" }}`. This is intentional: it lets the entire library reskin with one class swap, and avoids a CSS-in-JS dependency.
- **Components are presentational.** State (open/closed modals, active tabs, form values) lives in your app. Pass `active` + `onChange` to controlled components.
- **No external icon dependency.** `icons.jsx` ships a curated set. Add to it freely.
- **Avatars hash a color from the name** unless you pass `color="#…"`.
- **Tables and tabs are uncontrolled-friendly** — pass `rows`/`items`, omit selection props if you don't need them.
## Showcase
`Vibn UI Showcase.html` at the project root renders every component across every theme. Use it as the visual reference and as a starting point for new screens.
## Versioning
This is a starter — fork it. There's no semver, no changelog. Edit `tokens.css` to match your brand, prune what you don't use, extend what you do.

View File

@@ -0,0 +1,737 @@
// ============================================================
// vibn-ai-templates/components.jsx
// ------------------------------------------------------------
// The core component set. Every visual property is wired to a
// CSS variable from tokens.css — flipping `class="theme-glass"`
// (or any other theme class) reskins the whole library.
//
// Components export to `window` for use in script-tag HTML
// projects. In a real codebase, swap the bottom-of-file
// assignment for `export { … }`.
//
// Components included:
// Button, IconButton, Field, Input, Textarea, Select,
// Checkbox, Radio, Switch, Card, Badge, Tag, Avatar,
// AvatarStack, Tabs, Table, Modal, Banner, Divider,
// FieldGroup, KBD, Spinner.
// ============================================================
// ─── Helpers ─────────────────────────────────────────────────
const cx = (...names) => names.filter(Boolean).join(" ");
const noop = () => {};
// ─── Button ──────────────────────────────────────────────────
// variant: primary (default), secondary, ghost, destructive
// size: sm | md (default) | lg
// leadingIcon / trailingIcon: <Icon name="…"/>
// loading: disables and shows a spinner
const Button = ({
children, variant = "primary", size = "md", full = false,
leadingIcon, trailingIcon, loading, disabled, onClick = noop, style, type = "button",
...rest
}) => {
const sizing = {
sm: { padY: 6, padX: 12, font: "var(--text-sm)", iconSize: 13 },
md: { padY: 9, padX: 16, font: "var(--text-md)", iconSize: 15 },
lg: { padY: 12, padX: 22, font: "var(--text-lg)", iconSize: 16 },
}[size];
const variants = {
primary: {
background: "var(--button-bg)",
color: "var(--button-fg)",
border: "1px solid var(--button-border)",
},
secondary: {
background: "var(--button-secondary-bg)",
color: "var(--button-secondary-fg)",
border: "1px solid var(--button-secondary-border)",
},
ghost: {
background: "transparent",
color: "var(--button-ghost-fg)",
border: "1px solid transparent",
},
destructive: {
background: "var(--danger)",
color: "#ffffff",
border: "1px solid var(--danger)",
},
}[variant];
return (
<button
type={type}
disabled={disabled || loading}
onClick={onClick}
style={{
...variants,
padding: `${sizing.padY}px ${sizing.padX}px`,
borderRadius: "var(--button-radius)",
fontFamily: "var(--font-sans)",
fontSize: sizing.font,
fontWeight: "var(--weight-medium)",
lineHeight: 1.2,
cursor: disabled || loading ? "not-allowed" : "pointer",
opacity: disabled ? 0.5 : 1,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
width: full ? "100%" : "auto",
whiteSpace: "nowrap",
transition: "background var(--duration) var(--ease), transform var(--duration-fast) var(--ease)",
...style,
}}
{...rest}
>
{loading ? <Spinner size={sizing.iconSize}/> : leadingIcon}
<span>{children}</span>
{!loading && trailingIcon}
</button>
);
};
// ─── IconButton ──────────────────────────────────────────────
const IconButton = ({ icon, name, size = "md", variant = "ghost", onClick = noop, label, style }) => {
const dims = { sm: 28, md: 32, lg: 38 }[size];
const iconSize = { sm: 14, md: 16, lg: 18 }[size];
const variants = {
ghost: { background: "transparent", color: "var(--text-2)", border: "1px solid transparent" },
secondary: { background: "var(--button-secondary-bg)", color: "var(--button-secondary-fg)",
border: "1px solid var(--button-secondary-border)" },
}[variant];
return (
<button
aria-label={label}
onClick={onClick}
style={{
...variants,
width: dims, height: dims, borderRadius: "var(--radius-sm)",
display: "inline-flex", alignItems: "center", justifyContent: "center",
cursor: "pointer", padding: 0,
...style,
}}
>
{icon ?? <Icon name={name} size={iconSize} />}
</button>
);
};
// ─── Spinner ─────────────────────────────────────────────────
const Spinner = ({ size = 14, stroke = 2 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.2" strokeWidth={stroke} />
<path d="M12 3a9 9 0 0 1 9 9" stroke="currentColor" strokeWidth={stroke} strokeLinecap="round">
<animateTransform attributeName="transform" type="rotate"
from="0 12 12" to="360 12 12" dur="0.9s" repeatCount="indefinite"/>
</path>
</svg>
);
// ─── Field (wraps a labelled input with hint / error) ────────
const Field = ({ label, hint, error, optional, htmlFor, children, style }) => (
<div style={{ marginBottom: "var(--space-4)", ...style }}>
{label && (
<label htmlFor={htmlFor} style={{
display: "flex", justifyContent: "space-between", alignItems: "baseline",
fontSize: "var(--text-sm)", fontWeight: "var(--weight-medium)",
color: "var(--text)", marginBottom: 6,
}}>
<span>{label}</span>
{optional && <span style={{ color: "var(--text-3)", fontWeight: 400 }}>optional</span>}
</label>
)}
{children}
{(hint || error) && (
<div style={{
fontSize: "var(--text-xs)", marginTop: 5,
color: error ? "var(--danger)" : "var(--text-3)",
}}>{error || hint}</div>
)}
</div>
);
// ─── Input ───────────────────────────────────────────────────
// Bare input (use inside <Field>). leadingIcon / trailingIcon
// add an inner ornament. invalid red-rings the border.
const Input = ({
value, placeholder, type = "text", leadingIcon, trailingIcon,
invalid, disabled, autofocus, onChange = noop, id, style, ...rest
}) => (
<div style={{
display: "flex", alignItems: "center", gap: 8,
padding: "10px 12px",
borderRadius: "var(--field-radius)",
background: "var(--field-bg)",
border: `1px solid ${invalid ? "var(--danger)" : "var(--field-border)"}`,
boxShadow: autofocus ? "var(--shadow-focus)" : "var(--shadow-sm)",
fontSize: "var(--text-md)",
color: "var(--text)",
backdropFilter: "blur(var(--surface-blur))",
WebkitBackdropFilter: "blur(var(--surface-blur))",
opacity: disabled ? 0.5 : 1,
transition: "border-color var(--duration), box-shadow var(--duration)",
...style,
}}>
{leadingIcon && <span style={{ color: "var(--text-3)", display: "flex" }}>{leadingIcon}</span>}
<input
id={id}
type={type}
value={value}
placeholder={placeholder}
disabled={disabled}
onChange={(e) => onChange(e.target.value, e)}
style={{
flex: 1, minWidth: 0, border: "none", outline: "none", background: "transparent",
fontFamily: "inherit", fontSize: "inherit", color: "inherit",
padding: 0,
}}
{...rest}
/>
{trailingIcon && <span style={{ color: "var(--text-3)", display: "flex" }}>{trailingIcon}</span>}
</div>
);
// ─── Textarea ────────────────────────────────────────────────
const Textarea = ({ value, placeholder, rows = 4, onChange = noop, invalid, id, style, ...rest }) => (
<textarea
id={id}
value={value}
placeholder={placeholder}
rows={rows}
onChange={(e) => onChange(e.target.value, e)}
style={{
width: "100%", display: "block", padding: "10px 12px",
borderRadius: "var(--field-radius)",
background: "var(--field-bg)",
border: `1px solid ${invalid ? "var(--danger)" : "var(--field-border)"}`,
fontSize: "var(--text-md)", color: "var(--text)",
fontFamily: "var(--font-sans)", resize: "vertical",
outline: "none", boxShadow: "var(--shadow-sm)",
backdropFilter: "blur(var(--surface-blur))",
WebkitBackdropFilter: "blur(var(--surface-blur))",
...style,
}}
{...rest}
/>
);
// ─── Select (presentation only — clicks the menu open visually) ─
const Select = ({ value, placeholder, options = [], leadingIcon, style, ...rest }) => (
<div style={{
display: "flex", alignItems: "center", gap: 8,
padding: "10px 12px", borderRadius: "var(--field-radius)",
background: "var(--field-bg)", border: "1px solid var(--field-border)",
fontSize: "var(--text-md)", color: value ? "var(--text)" : "var(--text-3)",
cursor: "pointer", boxShadow: "var(--shadow-sm)",
backdropFilter: "blur(var(--surface-blur))",
WebkitBackdropFilter: "blur(var(--surface-blur))",
...style,
}} {...rest}>
{leadingIcon && <span style={{ color: "var(--text-3)", display: "flex" }}>{leadingIcon}</span>}
<span style={{ flex: 1 }}>{value || placeholder}</span>
<Icon name="chevDown" size={14} style={{ color: "var(--text-3)" }} />
</div>
);
// ─── Checkbox ────────────────────────────────────────────────
const Checkbox = ({ checked, indeterminate, disabled, label, hint, onChange = noop, style }) => (
<label style={{
display: "flex", alignItems: "flex-start", gap: 10, cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.5 : 1, ...style,
}}>
<span
role="checkbox"
aria-checked={indeterminate ? "mixed" : !!checked}
onClick={() => !disabled && onChange(!checked)}
style={{
width: 16, height: 16, borderRadius: 4, marginTop: 1, flexShrink: 0,
border: `1px solid ${checked || indeterminate ? "var(--accent)" : "var(--border-strong)"}`,
background: checked || indeterminate ? "var(--accent)" : "var(--surface)",
color: "var(--text-on-accent)",
display: "inline-flex", alignItems: "center", justifyContent: "center",
transition: "background var(--duration), border-color var(--duration)",
}}
>
{checked && !indeterminate && <Icon name="checkOnly" size={11} stroke={2.6}/>}
{indeterminate && <div style={{ width: 8, height: 2, background: "currentColor", borderRadius: 1 }}/>}
</span>
{(label || hint) && (
<span style={{ minWidth: 0 }}>
{label && <span style={{ fontSize: "var(--text-md)", color: "var(--text)" }}>{label}</span>}
{hint && <div style={{ fontSize: "var(--text-xs)", color: "var(--text-3)", marginTop: 2 }}>{hint}</div>}
</span>
)}
</label>
);
// ─── Radio ───────────────────────────────────────────────────
const Radio = ({ checked, disabled, label, hint, onChange = noop, style }) => (
<label style={{
display: "flex", alignItems: "flex-start", gap: 10, cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.5 : 1, ...style,
}}>
<span
role="radio"
aria-checked={!!checked}
onClick={() => !disabled && onChange(true)}
style={{
width: 16, height: 16, borderRadius: "50%", marginTop: 1, flexShrink: 0,
border: `1px solid ${checked ? "var(--accent)" : "var(--border-strong)"}`,
background: "var(--surface)",
position: "relative",
}}
>
{checked && <span style={{
position: "absolute", top: 3, left: 3, right: 3, bottom: 3,
background: "var(--accent)", borderRadius: "50%",
}}/>}
</span>
{(label || hint) && (
<span style={{ minWidth: 0 }}>
{label && <span style={{ fontSize: "var(--text-md)", color: "var(--text)" }}>{label}</span>}
{hint && <div style={{ fontSize: "var(--text-xs)", color: "var(--text-3)", marginTop: 2 }}>{hint}</div>}
</span>
)}
</label>
);
// ─── Switch ──────────────────────────────────────────────────
const Switch = ({ checked, disabled, onChange = noop, label, hint, style }) => (
<label style={{
display: "flex", alignItems: "center", gap: 12,
cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.5 : 1, ...style,
}}>
<span
role="switch"
aria-checked={!!checked}
onClick={() => !disabled && onChange(!checked)}
style={{
width: 34, height: 20, borderRadius: 999,
background: checked ? "var(--accent)" : "var(--surface-alt)",
border: `1px solid ${checked ? "var(--accent)" : "var(--border-strong)"}`,
position: "relative", flexShrink: 0,
transition: "background var(--duration), border-color var(--duration)",
}}
>
<span style={{
position: "absolute", top: 1, left: checked ? 15 : 1,
width: 16, height: 16, borderRadius: "50%",
background: checked ? "var(--text-on-accent)" : "var(--surface)",
boxShadow: "0 1px 3px rgba(0,0,0,0.2)",
transition: "left var(--duration) var(--ease)",
}}/>
</span>
{(label || hint) && (
<span style={{ flex: 1, minWidth: 0 }}>
{label && <div style={{ fontSize: "var(--text-md)", color: "var(--text)" }}>{label}</div>}
{hint && <div style={{ fontSize: "var(--text-xs)", color: "var(--text-3)", marginTop: 2 }}>{hint}</div>}
</span>
)}
</label>
);
// ─── Card / Surface ──────────────────────────────────────────
// Card paints a `surface` background with border + shadow.
// Use `variant="raised"` for shadow-lg, "flat" for no shadow.
const Card = ({ children, variant = "default", padding = 20, style, ...rest }) => {
const shadows = {
default: "var(--shadow-sm)",
raised: "var(--shadow)",
floating:"var(--shadow-lg)",
flat: "none",
};
return (
<div style={{
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: "var(--card-radius)",
padding,
boxShadow: shadows[variant] || shadows.default,
backdropFilter: "blur(var(--surface-blur))",
WebkitBackdropFilter: "blur(var(--surface-blur))",
color: "var(--text)",
...style,
}} {...rest}>{children}</div>
);
};
const CardHeader = ({ title, subtitle, action, style }) => (
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "flex-start",
marginBottom: "var(--space-4)", gap: 16, ...style,
}}>
<div style={{ minWidth: 0 }}>
{title && <div style={{
fontSize: "var(--text-lg)", fontWeight: "var(--weight-semibold)",
color: "var(--text)", letterSpacing: "-0.01em",
}}>{title}</div>}
{subtitle && <div style={{
fontSize: "var(--text-sm)", color: "var(--text-2)", marginTop: 2,
}}>{subtitle}</div>}
</div>
{action}
</div>
);
// ─── Badge / Tag ─────────────────────────────────────────────
// tone: neutral | accent | success | warn | danger | info
const Badge = ({ children, tone = "neutral", dot, leadingIcon, style }) => {
const palette = {
neutral: { bg: "var(--surface-alt)", fg: "var(--text-2)", dotColor: "var(--text-3)" },
accent: { bg: "var(--accent-soft)", fg: "var(--accent)", dotColor: "var(--accent)" },
success: { bg: "var(--success-soft)", fg: "var(--success)", dotColor: "var(--success)" },
warn: { bg: "var(--warn-soft)", fg: "var(--warn)", dotColor: "var(--warn)" },
danger: { bg: "var(--danger-soft)", fg: "var(--danger)", dotColor: "var(--danger)" },
info: { bg: "var(--accent-soft)", fg: "var(--accent)", dotColor: "var(--accent)" },
}[tone] || {};
return (
<span style={{
display: "inline-flex", alignItems: "center", gap: 6,
padding: "2px 8px", borderRadius: "var(--radius-pill)",
background: palette.bg, color: palette.fg,
fontSize: "var(--text-xs)", fontWeight: "var(--weight-medium)",
whiteSpace: "nowrap", lineHeight: 1.4,
...style,
}}>
{dot && <span style={{ width: 6, height: 6, borderRadius: "50%", background: palette.dotColor }}/>}
{leadingIcon}
{children}
</span>
);
};
const Tag = Badge; // alias
// ─── Avatar ──────────────────────────────────────────────────
const avatarPalette = ["#d4b8a8", "#e8a87c", "#c8e8a8", "#a8c8e8", "#c8a8e8", "#e8c8a8", "#a8e8c8", "#e8a8c8"];
const hashName = (s = "") => {
let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
return avatarPalette[Math.abs(h) % avatarPalette.length];
};
const Avatar = ({ name = "?", src, size = 32, status, color, ring, style }) => {
const initials = name.split(/\s+/).filter(Boolean).map(w => w[0]).slice(0, 2).join("").toUpperCase();
return (
<span style={{
position: "relative", display: "inline-flex", flexShrink: 0,
width: size, height: size, borderRadius: "50%",
background: color || hashName(name), color: "#3a2820",
alignItems: "center", justifyContent: "center",
fontSize: Math.round(size * 0.4), fontWeight: 600,
boxShadow: ring ? `0 0 0 2px var(--surface), 0 0 0 ${2 + ring}px var(--accent)` : "none",
overflow: "hidden", ...style,
}}>
{src
? <img src={src} alt={name} style={{ width: "100%", height: "100%", objectFit: "cover" }}/>
: initials}
{status && <span style={{
position: "absolute", bottom: 0, right: 0,
width: Math.max(8, size * 0.28), height: Math.max(8, size * 0.28),
borderRadius: "50%", border: "2px solid var(--surface)",
background: status === "online" ? "var(--success)" :
status === "busy" ? "var(--danger)" : "var(--text-3)",
}}/>}
</span>
);
};
const AvatarStack = ({ items = [], size = 28, max = 4 }) => {
const shown = items.slice(0, max);
const remaining = items.length - shown.length;
return (
<div style={{ display: "inline-flex" }}>
{shown.map((p, i) => (
<Avatar key={i} name={p.name} src={p.src} color={p.color} size={size}
style={{ marginLeft: i ? -size * 0.32 : 0, boxShadow: "0 0 0 2px var(--surface)" }}/>
))}
{remaining > 0 && (
<span style={{
width: size, height: size, borderRadius: "50%",
background: "var(--surface-alt)", color: "var(--text-2)",
display: "inline-flex", alignItems: "center", justifyContent: "center",
fontSize: Math.round(size * 0.4), fontWeight: 600,
marginLeft: -size * 0.32, boxShadow: "0 0 0 2px var(--surface)",
}}>+{remaining}</span>
)}
</div>
);
};
// ─── Tabs ────────────────────────────────────────────────────
// Controlled: pass `active` (label of active tab) + `onChange`.
const Tabs = ({ items = [], active, onChange = noop, style, variant = "underline" }) => {
return (
<div style={{
display: "flex", gap: 4, borderBottom: variant === "underline" ? "1px solid var(--border)" : "none",
...style,
}}>
{items.map(t => {
const isActive = t.label === active || t.id === active;
if (variant === "pill") {
return (
<button key={t.id || t.label}
onClick={() => onChange(t.id || t.label)}
style={{
padding: "6px 12px", borderRadius: "var(--radius-pill)",
fontFamily: "var(--font-sans)", fontSize: "var(--text-sm)",
background: isActive ? "var(--accent)" : "transparent",
color: isActive ? "var(--text-on-accent)" : "var(--text-2)",
border: "none", cursor: "pointer", fontWeight: 500,
display: "inline-flex", alignItems: "center", gap: 6,
}}>
{t.icon}{t.label}
{t.count != null && (
<span style={{
fontSize: 10, padding: "1px 6px", borderRadius: 999,
background: isActive ? "rgba(255,255,255,0.18)" : "var(--surface-alt)",
color: "inherit",
}}>{t.count}</span>
)}
</button>
);
}
return (
<button key={t.id || t.label}
onClick={() => onChange(t.id || t.label)}
style={{
padding: "10px 4px", margin: "0 12px 0 0",
fontFamily: "var(--font-sans)", fontSize: "var(--text-md)",
fontWeight: "var(--weight-medium)",
background: "transparent", border: "none", cursor: "pointer",
color: isActive ? "var(--text)" : "var(--text-2)",
borderBottom: isActive ? "2px solid var(--accent)" : "2px solid transparent",
position: "relative", top: 1,
display: "inline-flex", alignItems: "center", gap: 6, whiteSpace: "nowrap",
}}>
{t.icon}{t.label}
{t.count != null && (
<span style={{
fontSize: 10, padding: "1px 6px", borderRadius: 999,
background: isActive ? "var(--accent-soft)" : "var(--surface-alt)",
color: isActive ? "var(--accent)" : "var(--text-3)",
}}>{t.count}</span>
)}
</button>
);
})}
</div>
);
};
// ─── Table ───────────────────────────────────────────────────
// columns: [{ key, label, width?, align?, render? }]
// rows: [{ id, [key]: value, … }]
const Table = ({ columns = [], rows = [], selectable, selected = [], onSelectionChange = noop, density = "comfortable" }) => {
const padY = density === "compact" ? 8 : 12;
const allChecked = rows.length > 0 && selected.length === rows.length;
const someChecked = selected.length > 0 && !allChecked;
const toggleAll = () => onSelectionChange(allChecked ? [] : rows.map(r => r.id));
const toggleOne = (id) => onSelectionChange(
selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id]
);
const headerCell = {
padding: `10px 12px`, fontSize: "var(--text-xs)",
color: "var(--text-3)", fontWeight: "var(--weight-medium)",
textTransform: "uppercase", letterSpacing: "0.04em", textAlign: "left",
borderBottom: "1px solid var(--border)", background: "var(--surface)",
};
const bodyCell = {
padding: `${padY}px 12px`, fontSize: "var(--text-md)",
color: "var(--text)", borderBottom: "1px solid var(--divider)", verticalAlign: "middle",
};
return (
<div style={{
background: "var(--surface)", border: "1px solid var(--border)",
borderRadius: "var(--card-radius)", overflow: "hidden",
backdropFilter: "blur(var(--surface-blur))",
WebkitBackdropFilter: "blur(var(--surface-blur))",
}}>
<table style={{ width: "100%", borderCollapse: "collapse", fontFamily: "var(--font-sans)" }}>
<thead>
<tr>
{selectable && (
<th style={{ ...headerCell, width: 36, paddingRight: 0 }}>
<Checkbox checked={allChecked} indeterminate={someChecked} onChange={toggleAll}/>
</th>
)}
{columns.map(c => (
<th key={c.key} style={{ ...headerCell, width: c.width, textAlign: c.align || "left" }}>
{c.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={r.id ?? i}>
{selectable && (
<td style={{ ...bodyCell, paddingRight: 0 }}>
<Checkbox checked={selected.includes(r.id)} onChange={() => toggleOne(r.id)}/>
</td>
)}
{columns.map(c => (
<td key={c.key} style={{ ...bodyCell, textAlign: c.align || "left" }}>
{c.render ? c.render(r) : r[c.key]}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
};
// ─── Modal (presentational — wrap your own state) ────────────
const Modal = ({ open, onClose = noop, title, description, footer, children, width = 480 }) => {
if (!open) return null;
return (
<div
onClick={onClose}
style={{
position: "fixed", inset: 0, zIndex: 100,
background: "rgba(0,0,0,0.5)",
display: "flex", alignItems: "center", justifyContent: "center", padding: 24,
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width, maxWidth: "100%", maxHeight: "85vh", overflow: "auto",
background: "var(--surface)", color: "var(--text)",
border: "1px solid var(--border)", borderRadius: "var(--modal-radius)",
boxShadow: "var(--shadow-modal)",
backdropFilter: "blur(var(--surface-blur))",
WebkitBackdropFilter: "blur(var(--surface-blur))",
}}
>
<div style={{
padding: "var(--space-6)", display: "flex",
justifyContent: "space-between", alignItems: "flex-start", gap: 16,
}}>
<div style={{ minWidth: 0 }}>
{title && <h2 style={{
margin: 0, fontSize: "var(--text-xl)", fontWeight: "var(--weight-semibold)",
letterSpacing: "-0.01em", fontFamily: "var(--font-display)",
}}>{title}</h2>}
{description && <p style={{
margin: "6px 0 0", fontSize: "var(--text-md)", color: "var(--text-2)",
}}>{description}</p>}
</div>
<IconButton name="x" size="sm" onClick={onClose} label="Close"/>
</div>
{children && <div style={{ padding: "0 var(--space-6) var(--space-6)" }}>{children}</div>}
{footer && (
<div style={{
padding: "var(--space-4) var(--space-6)",
borderTop: "1px solid var(--divider)",
display: "flex", justifyContent: "flex-end", gap: 8,
background: "var(--surface-2)",
}}>{footer}</div>
)}
</div>
</div>
);
};
// ─── Banner / Alert ──────────────────────────────────────────
const Banner = ({ tone = "info", title, children, action, onDismiss, icon, style }) => {
const palette = {
info: { bg: "var(--accent-soft)", fg: "var(--accent)", iconName: "info" },
success: { bg: "var(--success-soft)", fg: "var(--success)", iconName: "checkOnly" },
warn: { bg: "var(--warn-soft)", fg: "var(--warn)", iconName: "alert" },
danger: { bg: "var(--danger-soft)", fg: "var(--danger)", iconName: "alert" },
}[tone];
return (
<div style={{
display: "flex", gap: 12, padding: "12px 16px",
borderRadius: "var(--radius)",
background: palette.bg, color: "var(--text)",
border: `1px solid ${palette.fg}33`,
alignItems: "flex-start",
...style,
}}>
<span style={{ color: palette.fg, display: "flex", marginTop: 1 }}>
{icon ?? <Icon name={palette.iconName} size={16} stroke={2}/>}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
{title && <div style={{
fontSize: "var(--text-md)", fontWeight: "var(--weight-semibold)",
color: "var(--text)",
}}>{title}</div>}
{children && <div style={{
fontSize: "var(--text-sm)", color: "var(--text-2)", marginTop: title ? 2 : 0,
}}>{children}</div>}
</div>
{action}
{onDismiss && <IconButton name="x" size="sm" onClick={onDismiss} label="Dismiss"/>}
</div>
);
};
// ─── Divider ─────────────────────────────────────────────────
const Divider = ({ label, vertical, style }) => {
if (vertical) return <span style={{ width: 1, alignSelf: "stretch", background: "var(--border)", ...style }}/>;
if (!label) return <hr style={{ border: "none", borderTop: "1px solid var(--border)", margin: "var(--space-4) 0", ...style }}/>;
return (
<div style={{
display: "flex", alignItems: "center", gap: 12,
fontSize: "var(--text-xs)", color: "var(--text-3)",
letterSpacing: "0.08em", textTransform: "uppercase",
margin: "var(--space-4) 0", ...style,
}}>
<div style={{ flex: 1, height: 1, background: "var(--border)" }}/>
<span>{label}</span>
<div style={{ flex: 1, height: 1, background: "var(--border)" }}/>
</div>
);
};
// ─── FieldGroup — horizontal segmented control ───────────────
const FieldGroup = ({ options = [], value, onChange = noop, style }) => (
<div style={{
display: "inline-flex", padding: 3, gap: 2,
background: "var(--surface-alt)", border: "1px solid var(--border)",
borderRadius: "var(--radius)", ...style,
}}>
{options.map(o => {
const v = typeof o === "string" ? o : o.value;
const label = typeof o === "string" ? o : o.label;
const sel = v === value;
return (
<button key={v} onClick={() => onChange(v)} style={{
padding: "5px 12px", borderRadius: "calc(var(--radius) - 3px)",
fontFamily: "var(--font-sans)", fontSize: "var(--text-sm)", whiteSpace: "nowrap",
background: sel ? "var(--surface)" : "transparent",
color: sel ? "var(--text)" : "var(--text-2)",
border: "none", cursor: "pointer",
boxShadow: sel ? "var(--shadow-sm)" : "none",
fontWeight: sel ? 500 : 400,
}}>{label}</button>
);
})}
</div>
);
// ─── KBD ─────────────────────────────────────────────────────
const KBD = ({ children, style }) => (
<kbd style={{
fontFamily: "var(--font-mono)", fontSize: "var(--text-xs)",
padding: "1px 6px", borderRadius: 4,
background: "var(--surface-alt)", color: "var(--text-2)",
border: "1px solid var(--border)",
...style,
}}>{children}</kbd>
);
// ─── Exports ─────────────────────────────────────────────────
Object.assign(window, {
Button, IconButton, Spinner,
Field, Input, Textarea, Select, FieldGroup,
Checkbox, Radio, Switch,
Card, CardHeader, Divider,
Badge, Tag, Avatar, AvatarStack,
Tabs, Table, Modal, Banner, KBD,
});

View File

@@ -0,0 +1,89 @@
// ============================================================
// vibn-ai-templates/icons.jsx
// ------------------------------------------------------------
// A tiny Tabler-style stroke-icon helper + a curated set of
// paths used by the components. All inherit `currentColor` so
// they re-tint to whatever the parent's CSS color is.
//
// Usage:
// <Icon name="search" />
// <Icon path={icons.bell} size={20} stroke={2} />
// ============================================================
const icons = {
// Navigation / surface
home: <><path d="m3 12 9-9 9 9"/><path d="M5 10v10h14V10"/></>,
inbox: <><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5 5h14l3 7v7H2v-7z"/></>,
search: <><circle cx="11" cy="11" r="7"/><path d="m20 20-3-3"/></>,
bell: <><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10 21a2 2 0 0 0 4 0"/></>,
settings:<><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8L4.2 7a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3 1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8 1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></>,
// Objects
people: <><circle cx="9" cy="8" r="4"/><path d="M3 21a6 6 0 0 1 12 0"/><circle cx="17" cy="6" r="3"/><path d="M21 17a4 4 0 0 0-6-3.5"/></>,
building: <><path d="M3 21h18M5 21V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16"/><path d="M9 7h2M13 7h2M9 11h2M13 11h2M9 15h2M13 15h2"/></>,
target: <><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1"/></>,
doc: <><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6"/></>,
check: <><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></>,
bar: <><path d="M3 3v18h18"/><rect x="7" y="11" width="3" height="7"/><rect x="13" y="7" width="3" height="11"/></>,
workflow: <><rect x="3" y="3" width="6" height="6" rx="1"/><rect x="15" y="15" width="6" height="6" rx="1"/><path d="M9 6h6a3 3 0 0 1 3 3v6"/></>,
// Actions
plus: <path d="M12 5v14M5 12h14"/>,
x: <path d="M6 6l12 12M18 6L6 18"/>,
more: <><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></>,
chevDown:<path d="m6 9 6 6 6-6"/>,
chevUp: <path d="m6 15 6-6 6 6"/>,
chevLeft:<path d="m15 6-6 6 6 6"/>,
chevRight:<path d="m9 6 6 6-6 6"/>,
arrow: <path d="M5 12h14M13 5l7 7-7 7"/>,
arrowUp: <path d="M12 19V5M5 12l7-7 7 7"/>,
arrowDown:<path d="M12 5v14M5 12l7 7 7-7"/>,
// Status
checkOnly: <path d="M5 12l5 5L20 7"/>,
alert: <><path d="M12 9v4M12 17v.01"/><path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.7 3.86a2 2 0 0 0-3.4 0z"/></>,
info: <><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></>,
eye: <><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></>,
eyeOff: <><path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 11 7 11 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.526 13.526 0 0 0 1 12s4 7 11 7a9.74 9.74 0 0 0 5.39-1.61"/><path d="M2 2l20 20"/></>,
star: <path d="m12 3 2.6 6.2 6.7.5-5.1 4.4 1.6 6.6L12 17.3 6.2 20.7l1.6-6.6L2.7 9.7l6.7-.5z"/>,
spark: <path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l3 3M15 15l3 3M6 18l3-3M15 9l3-3"/>,
bolt: <path d="m13 2-9 13h7l-1 7 9-13h-7z"/>,
shield: <path d="M12 2 4 5v7c0 5 3.5 9 8 10 4.5-1 8-5 8-10V5z"/>,
// Misc
briefcase: <><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M3 13h18"/></>,
link: <><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></>,
copy: <><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></>,
};
const Icon = ({ name, path, size = 16, stroke = 1.6, style, className }) => (
<svg
width={size} height={size} viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth={stroke}
strokeLinecap="round" strokeLinejoin="round"
style={{ display: "inline-block", verticalAlign: "middle", flexShrink: 0, ...style }}
className={className}
aria-hidden="true"
>
{path ?? icons[name]}
</svg>
);
// Tiny brand mark — a gradient triangle, the same as we've been
// using everywhere. Exported here so consumers don't redraw it.
const VibnMark = ({ size = 22 }) => {
const id = `vmk_${size}`;
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<defs>
<linearGradient id={id} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#6e6cff"/>
<stop offset="100%" stopColor="#b15bff"/>
</linearGradient>
</defs>
<path d="M3 20 L12 4 L21 20 Z" fill={`url(#${id})`}/>
</svg>
);
};
Object.assign(window, { Icon, icons, VibnMark });

View File

@@ -0,0 +1,399 @@
// ============================================================
// vibn-ai-templates/shells.jsx
// ------------------------------------------------------------
// Layout shells — both in-product navs (Sidebar / Rail /
// Topbar) and auth scaffolds (CenteredCard / SplitHero / Glass).
//
// These are containers. Wrap your page in any shell and the
// shell handles brand, search, nav, footer. Compose with the
// components from components.jsx.
// ============================================================
// ── SidebarShell ─────────────────────────────────────────────
// Props:
// brand: { name, mark? }
// sections: [{ title?, items: [{ id, label, icon, count, active }] }]
// user: { name, email, color? }
// children: main pane
const SidebarShell = ({ brand = { name: "Vibn" }, sections = [], user, search = "Search…", children, width = 248 }) => {
return (
<div className="vibn-app" style={{
width: "100%", height: "100%",
display: "grid", gridTemplateColumns: `${width}px 1fr`,
overflow: "hidden",
}}>
<aside style={{
background: "var(--surface-alt)",
borderRight: "1px solid var(--border)",
display: "flex", flexDirection: "column",
}}>
{/* Brand row */}
<div style={{
padding: "12px 14px", display: "flex", alignItems: "center", gap: 10,
borderBottom: "1px solid var(--border)",
}}>
{brand.mark || <VibnMark size={22}/>}
<div style={{ flex: 1, fontSize: "var(--text-md)", fontWeight: "var(--weight-semibold)" }}>
{brand.name}
</div>
<Icon name="chevDown" size={14} style={{ color: "var(--text-3)" }}/>
</div>
{/* Search */}
<div style={{ padding: 12 }}>
<Input
placeholder={search}
leadingIcon={<Icon name="search" size={14}/>}
trailingIcon={<KBD>K</KBD>}
style={{ padding: "6px 10px" }}
/>
</div>
{/* Nav */}
<nav style={{ padding: "4px 8px", flex: 1, overflowY: "auto" }}>
{sections.map((s, i) => (
<div key={s.title || `s${i}`}>
{s.title && <div style={{
fontSize: "var(--text-xs)", color: "var(--text-3)",
padding: "14px 10px 6px", textTransform: "uppercase",
letterSpacing: "0.04em", fontWeight: 500,
}}>{s.title}</div>}
{s.items.map(it => (
<div key={it.id || it.label} style={{
display: "flex", alignItems: "center", gap: 10,
padding: "6px 10px", borderRadius: "var(--radius-sm)",
fontSize: "var(--text-md)", cursor: "pointer", marginBottom: 1,
color: it.active ? "var(--text)" : "var(--text-2)",
fontWeight: it.active ? 500 : 400,
background: it.active ? "var(--surface)" : "transparent",
boxShadow: it.active ? "var(--shadow-sm)" : "none",
}}>
<span style={{ color: it.active ? "var(--accent)" : "var(--text-3)", display: "flex" }}>
{typeof it.icon === "string"
? <Icon name={it.icon} size={15}/>
: it.icon}
</span>
<span style={{ flex: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{it.label}</span>
{it.count != null && <span style={{
fontSize: "var(--text-xs)", color: "var(--text-3)",
fontVariantNumeric: "tabular-nums",
}}>{it.count}</span>}
</div>
))}
</div>
))}
</nav>
{/* User */}
{user && (
<div style={{
padding: 12, borderTop: "1px solid var(--border)",
display: "flex", alignItems: "center", gap: 10,
}}>
<Avatar name={user.name} color={user.color} size={26}/>
<div style={{ flex: 1, minWidth: 0, lineHeight: 1.2 }}>
<div style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>{user.name}</div>
{user.email && <div style={{ fontSize: "var(--text-xs)", color: "var(--text-3)" }}>{user.email}</div>}
</div>
<Icon name="chevDown" size={14} style={{ color: "var(--text-3)" }}/>
</div>
)}
</aside>
<main style={{ display: "flex", flexDirection: "column", overflow: "hidden", background: "var(--bg)" }}>
{children}
</main>
</div>
);
};
// ── TopbarShell ──────────────────────────────────────────────
// Dark top with breadcrumb + ⌘K + avatar; tabs strip below.
const TopbarShell = ({ brand = { name: "Vibn" }, breadcrumb, tabs = [], activeTab,
onTabChange = () => {}, user, children, search = "Find or jump to anything…" }) => {
return (
<div className="vibn-app" style={{
width: "100%", height: "100%", display: "flex", flexDirection: "column",
overflow: "hidden",
}}>
<header style={{
background: "var(--surface-alt)", color: "var(--text)",
borderBottom: "1px solid var(--border)",
}}>
<div style={{
display: "flex", alignItems: "center", gap: 14, padding: "12px 24px",
}}>
<div style={{ display: "flex", alignItems: "center", gap: 8, fontWeight: "var(--weight-semibold)", fontSize: "var(--text-lg)" }}>
{brand.mark || <VibnMark size={20}/>}
{brand.name}
</div>
{breadcrumb && (
<>
<span style={{ color: "var(--text-3)" }}>/</span>
{breadcrumb.map((b, i) => (
<React.Fragment key={i}>
{i > 0 && <span style={{ color: "var(--text-3)" }}>/</span>}
<span style={{ fontSize: "var(--text-md)", display: "flex", alignItems: "center", gap: 8 }}>
{b.avatar && <Avatar name={b.avatar} size={18}/>}
<span>{b.label}</span>
{b.badge && <Badge tone="neutral">{b.badge}</Badge>}
</span>
</React.Fragment>
))}
</>
)}
<div style={{ flex: 1 }}/>
<div style={{ minWidth: 280 }}>
<Input placeholder={search}
leadingIcon={<Icon name="search" size={13}/>}
trailingIcon={<KBD>K</KBD>}
style={{ padding: "6px 12px" }} />
</div>
<Button variant="secondary" size="sm">Feedback</Button>
<IconButton name="bell" size="md"/>
{user && <Avatar name={user.name} color={user.color} size={28}/>}
</div>
{tabs.length > 0 && (
<div style={{ padding: "0 16px" }}>
<Tabs items={tabs} active={activeTab} onChange={onTabChange}/>
</div>
)}
</header>
<main style={{ flex: 1, overflow: "hidden", background: "var(--bg)" }}>{children}</main>
</div>
);
};
// ── RailShell ────────────────────────────────────────────────
// Icon rail + secondary panel + content.
const RailShell = ({ brand, items = [], activeRail, onRailChange = () => {},
secondary, secondaryTitle, user, children }) => {
return (
<div className="vibn-app" style={{
width: "100%", height: "100%",
display: "grid", gridTemplateColumns: "64px 240px 1fr", overflow: "hidden",
}}>
{/* Rail */}
<div style={{
background: "var(--surface-alt)", borderRight: "1px solid var(--border)",
padding: "10px 0", display: "flex", flexDirection: "column",
alignItems: "center", gap: 4,
}}>
<div style={{ padding: "0 10px 6px" }}>
{brand?.mark || <VibnMark size={22}/>}
</div>
<Divider />
{items.map(it => {
const sel = (it.id || it.label) === activeRail;
return (
<button key={it.id || it.label}
onClick={() => onRailChange(it.id || it.label)}
aria-label={it.label}
style={{
width: 40, height: 40, borderRadius: "var(--radius)",
background: sel ? "var(--accent)" : "transparent",
color: sel ? "var(--text-on-accent)" : "var(--text-2)",
border: "none", cursor: "pointer", position: "relative",
}}>
{typeof it.icon === "string" ? <Icon name={it.icon} size={18} stroke={2}/> : it.icon}
{it.badge && <span style={{
position: "absolute", top: 2, right: 2, minWidth: 16, height: 16,
padding: "0 4px", borderRadius: 8,
background: "var(--danger)", color: "#fff",
fontSize: 10, fontWeight: 600,
display: "flex", alignItems: "center", justifyContent: "center",
border: "2px solid var(--surface-alt)",
}}>{it.badge}</span>}
</button>
);
})}
<div style={{ flex: 1 }}/>
{user && <Avatar name={user.name} color={user.color} size={30} ring={1}/>}
</div>
{/* Secondary */}
<div style={{
background: "var(--surface-2)", borderRight: "1px solid var(--border)",
display: "flex", flexDirection: "column", overflow: "hidden",
}}>
{secondaryTitle && (
<div style={{
padding: "16px 14px 10px", borderBottom: "1px solid var(--border)",
}}>
<div style={{
fontSize: "var(--text-lg)", fontWeight: "var(--weight-semibold)",
marginBottom: 10,
}}>{secondaryTitle}</div>
<Input
placeholder="Jump to…"
leadingIcon={<Icon name="search" size={13}/>}
trailingIcon={<KBD>K</KBD>}
style={{ padding: "6px 10px" }}
/>
</div>
)}
<div style={{ padding: 10, flex: 1, overflowY: "auto" }}>{secondary}</div>
</div>
<main style={{ overflow: "hidden", background: "var(--bg)" }}>{children}</main>
</div>
);
};
// ── AuthCenteredShell ────────────────────────────────────────
// A single centered Card on a soft background, with brand top
// and small footer links. Good for sign-in / sign-up.
const AuthCenteredShell = ({ brand = { name: "Vibn" }, footerLinks = ["Privacy", "Terms", "Security"], cardWidth = 420, children }) => (
<div className="vibn-app" style={{
width: "100%", height: "100%", display: "grid",
gridTemplateRows: "auto 1fr auto", overflow: "hidden",
}}>
<header style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "20px 28px",
}}>
<div style={{ display: "flex", alignItems: "center", gap: 8, fontWeight: 600 }}>
{brand.mark || <VibnMark size={20}/>}
{brand.name}
</div>
<div style={{ fontSize: "var(--text-sm)", color: "var(--text-2)", display: "flex", gap: 18 }}>
<span>Status</span><span>Docs</span><span style={{ color: "var(--text)", fontWeight: 500 }}>Sign in </span>
</div>
</header>
<main style={{ display: "flex", alignItems: "center", justifyContent: "center", padding: 24 }}>
<Card variant="raised" padding={32} style={{ width: cardWidth }}>{children}</Card>
</main>
<footer style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "16px 28px", fontSize: "var(--text-xs)", color: "var(--text-3)",
}}>
<span>© 2026 {brand.name}</span>
<div style={{ display: "flex", gap: 16 }}>{footerLinks.map(l => <span key={l}>{l}</span>)}</div>
</footer>
</div>
);
// ── AuthSplitShell ───────────────────────────────────────────
// Left storytelling panel, right form. Big SaaS / Vercel feel.
const AuthSplitShell = ({ brand = { name: "Vibn" }, hero = {}, children }) => (
<div className="vibn-app" style={{
width: "100%", height: "100%", display: "grid",
gridTemplateColumns: "1fr 1fr", overflow: "hidden",
}}>
<div style={{
padding: "32px 44px", borderRight: "1px solid var(--border)",
display: "flex", flexDirection: "column",
background: "var(--surface-alt)", position: "relative", overflow: "hidden",
}}>
{/* Decorative wash, picks up theme accent */}
<div style={{
position: "absolute", top: -140, left: -120, width: 540, height: 540,
borderRadius: "50%",
background: "radial-gradient(circle, color-mix(in srgb, var(--accent-2) 40%, transparent), transparent 60%)",
filter: "blur(60px)", pointerEvents: "none",
}}/>
<div style={{
position: "absolute", bottom: -180, right: -120, width: 480, height: 480,
borderRadius: "50%",
background: "radial-gradient(circle, color-mix(in srgb, var(--accent) 30%, transparent), transparent 60%)",
filter: "blur(60px)", pointerEvents: "none",
}}/>
<div style={{ position: "relative", display: "flex", alignItems: "center", gap: 10, fontWeight: 600 }}>
{brand.mark || <VibnMark size={22}/>}
{brand.name}
</div>
<div style={{ position: "relative", marginTop: "auto" }}>
{hero.badge && (
<Badge tone="accent" style={{ marginBottom: 22 }}>{hero.badge}</Badge>
)}
{hero.headline && <h2 style={{
fontFamily: "var(--font-display)", fontSize: "var(--text-3xl)",
lineHeight: 1.05, margin: 0, letterSpacing: "-0.02em",
fontWeight: 500, textWrap: "balance", maxWidth: 360,
}}>{hero.headline}</h2>}
{hero.sub && <p style={{
fontSize: "var(--text-md)", color: "var(--text-2)",
marginTop: 14, lineHeight: 1.5, maxWidth: 340,
}}>{hero.sub}</p>}
{hero.quote && (
<div style={{
position: "relative", marginTop: 28, padding: 18,
borderRadius: "var(--card-radius)", background: "var(--surface)",
border: "1px solid var(--border)",
}}>
<p style={{ fontSize: "var(--text-md)", margin: 0, lineHeight: 1.5, color: "var(--text)" }}>
"{hero.quote.body}"
</p>
<div style={{ marginTop: 12, display: "flex", alignItems: "center", gap: 10 }}>
<Avatar name={hero.quote.author} size={26}/>
<div style={{ fontSize: "var(--text-xs)" }}>
<div style={{ fontWeight: 500 }}>{hero.quote.author}</div>
<div style={{ color: "var(--text-3)" }}>{hero.quote.role}</div>
</div>
</div>
</div>
)}
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", padding: "32px 56px" }}>
<div style={{ display: "flex", justifyContent: "flex-end", fontSize: "var(--text-sm)", color: "var(--text-2)" }}>
Need help? <span style={{ color: "var(--text)", fontWeight: 500, marginLeft: 4 }}>support</span>
</div>
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}>
<div style={{ width: 380 }}>{children}</div>
</div>
<div style={{ display: "flex", gap: 18, fontSize: "var(--text-xs)", color: "var(--text-3)", justifyContent: "flex-end" }}>
<span>Privacy</span><span>Terms</span><span>Security</span><span>v4.2.1</span>
</div>
</div>
</div>
);
// ── AuthGlassShell ───────────────────────────────────────────
// Aurora background + frosted card. Marketing-leaning.
const AuthGlassShell = ({ brand = { name: "Vibn" }, eyebrow, cardWidth = 460, children }) => (
<div className="vibn-app" style={{
width: "100%", height: "100%", position: "relative", overflow: "hidden",
}}>
{/* Top bar (a thin frosted pill — works in any theme thanks to surface vars) */}
<header style={{
position: "absolute", top: 22, left: "50%", transform: "translateX(-50%)",
zIndex: 10, width: "max-content",
display: "flex", alignItems: "center", gap: 4,
padding: "8px 8px 8px 18px", borderRadius: "var(--radius-pill)",
background: "var(--surface)",
border: "1px solid var(--border)",
backdropFilter: "blur(var(--surface-blur))",
WebkitBackdropFilter: "blur(var(--surface-blur))",
boxShadow: "var(--shadow-lg)",
}}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginRight: 16, fontWeight: 600 }}>
{brand.mark || <VibnMark size={18}/>}
{brand.name}
</div>
{["Product", "Pricing", "Docs"].map(l => (
<Button key={l} variant="ghost" size="sm">{l}</Button>
))}
<Divider vertical style={{ margin: "0 6px" }}/>
<Button variant="ghost" size="sm">Sign in</Button>
<Button size="sm">Get started </Button>
</header>
<main style={{
position: "relative", height: "100%",
display: "flex", alignItems: "center", justifyContent: "center", padding: 24,
}}>
<Card variant="floating" padding={36} style={{ width: cardWidth, borderRadius: "var(--radius-xl)" }}>
{eyebrow && <Badge tone="accent" style={{ marginBottom: 16 }}>{eyebrow}</Badge>}
{children}
</Card>
</main>
</div>
);
// ─── Exports ─────────────────────────────────────────────────
Object.assign(window, {
SidebarShell, TopbarShell, RailShell,
AuthCenteredShell, AuthSplitShell, AuthGlassShell,
});

View File

@@ -0,0 +1,325 @@
/* ============================================================
vibn-ai-templates · tokens.css
------------------------------------------------------------
The whole library is themed through CSS custom properties.
The :root block holds the DEFAULT theme (minimal). Each
.theme-* class below overrides a subset to flip aesthetics.
------------------------------------------------------------
To use:
<html class="theme-glass"> → glass theme app-wide
<div class="theme-editorial">…</div> → scope to one block
============================================================ */
:root {
/* ── Typography ─────────────────────────────────────────── */
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--font-display: var(--font-sans); /* themes may override */
--font-mono: ui-monospace, 'JetBrains Mono', 'SF Mono', Menlo, monospace;
--text-xs: 11px;
--text-sm: 12px;
--text-md: 13px;
--text-lg: 16px;
--text-xl: 20px;
--text-2xl: 28px;
--text-3xl: 38px;
--weight-regular: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-bold: 700;
/* ── Spacing (4 px base) ────────────────────────────────── */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
/* ── Radii ──────────────────────────────────────────────── */
--radius-xs: 3px;
--radius-sm: 5px;
--radius: 8px;
--radius-lg: 14px;
--radius-xl: 22px;
--radius-pill: 999px;
--button-radius: var(--radius);
--field-radius: 7px;
--card-radius: 12px;
--modal-radius: 16px;
/* ── Colors · MINIMAL (default light theme) ────────────── */
--bg: #f5f5f2;
--surface: #ffffff;
--surface-2: #fafaf8;
--surface-alt: #f1f0eb; /* sidebar / muted regions */
--border: #e8e8e3;
--border-strong:#d8d8d2;
--divider: #ededea;
--text: #111111;
--text-2: #5a5a5e;
--text-3: #8a8a90;
--text-on-accent: #ffffff;
--accent: #5e5cff;
--accent-2: #b15bff;
--accent-soft: #eeedff;
--accent-ring: rgba(94,92,255,0.22);
--success: #16a34a;
--success-soft: #dcfce7;
--warn: #d97706;
--warn-soft: #fef3c7;
--danger: #dc2626;
--danger-soft: #fee2e2;
/* ── Surfaces & effects ────────────────────────────────── */
--surface-blur: 0px; /* glass theme overrides */
--backdrop: transparent; /* glass theme overrides */
--grain: none; /* maximalist themes can use */
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
--shadow: 0 4px 12px -4px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04);
--shadow-lg: 0 24px 48px -20px rgba(0,0,0,0.16), 0 2px 8px rgba(0,0,0,0.05);
--shadow-modal: 0 40px 80px -20px rgba(0,0,0,0.28), 0 2px 8px rgba(0,0,0,0.08);
--shadow-focus: 0 0 0 3px var(--accent-ring);
/* ── Buttons ───────────────────────────────────────────── */
--button-bg: #111111;
--button-fg: #ffffff;
--button-border: #111111;
--button-hover: #2a2a2a;
--button-press: #000000;
--button-secondary-bg: #ffffff;
--button-secondary-fg: #111111;
--button-secondary-border: var(--border);
--button-secondary-hover: #f6f5f0;
--button-ghost-fg: var(--text);
--button-ghost-hover: #00000008;
/* ── Inputs ────────────────────────────────────────────── */
--field-bg: #ffffff;
--field-border: var(--border);
--field-text: var(--text);
--field-placeholder: var(--text-3);
--field-focus-ring: var(--shadow-focus);
/* ── Animation ─────────────────────────────────────────── */
--duration-fast: 120ms;
--duration: 180ms;
--duration-slow: 260ms;
--ease: cubic-bezier(0.2, 0.7, 0.3, 1);
}
/* ============================================================
THEME: minimal (default, same as :root)
The class exists so consumers can name-toggle.
============================================================ */
.theme-minimal {}
/* ============================================================
THEME: dark — Vercel / Stripe / Linear web school
============================================================ */
.theme-dark {
--bg: #0a0a0a;
--surface: #101015;
--surface-2: #16161d;
--surface-alt: #0a0a0d;
--border: #1f1f25;
--border-strong:#2a2a32;
--divider: #1a1a20;
--text: #fafafa;
--text-2: #a8a8b0;
--text-3: #6a6a72;
--text-on-accent: #0a0a0a;
--accent: #ffffff;
--accent-2: #b15bff;
--accent-soft: rgba(255,255,255,0.08);
--accent-ring: rgba(255,255,255,0.24);
--success: #4ade80;
--success-soft: rgba(74,222,128,0.14);
--warn: #f59e0b;
--warn-soft: rgba(245,158,11,0.14);
--danger: #ff4d5e;
--danger-soft: rgba(255,77,94,0.16);
--shadow-sm: 0 1px 2px rgba(0,0,0,0.5);
--shadow: 0 4px 12px rgba(0,0,0,0.4);
--shadow-lg: 0 24px 60px -20px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.04);
--shadow-modal: 0 40px 100px -20px rgba(0,0,0,0.8);
--button-bg: #ffffff;
--button-fg: #0a0a0a;
--button-border: #ffffff;
--button-hover: #e8e8e8;
--button-press: #d4d4d4;
--button-secondary-bg: #16161d;
--button-secondary-fg: #fafafa;
--button-secondary-border: var(--border);
--button-secondary-hover: #1f1f28;
--button-ghost-fg: var(--text);
--button-ghost-hover: rgba(255,255,255,0.05);
--field-bg: #16161d;
--field-border: var(--border);
--field-placeholder: var(--text-3);
}
/* ============================================================
THEME: glass — vibrant aurora bg + frosted surfaces
============================================================ */
.theme-glass {
--bg: #08081a;
--surface: rgba(255,255,255,0.06);
--surface-2: rgba(255,255,255,0.10);
--surface-alt: rgba(255,255,255,0.04);
--border: rgba(255,255,255,0.14);
--border-strong:rgba(255,255,255,0.22);
--divider: rgba(255,255,255,0.08);
--text: #ffffff;
--text-2: rgba(255,255,255,0.70);
--text-3: rgba(255,255,255,0.50);
--text-on-accent: #08081a;
--accent: #ffffff;
--accent-2: #b15bff;
--accent-soft: rgba(255,255,255,0.12);
--accent-ring: rgba(122,120,255,0.40);
--success: #7aff66;
--success-soft: rgba(122,255,102,0.14);
--warn: #ffce5b;
--warn-soft: rgba(255,206,91,0.14);
--danger: #ff5b6b;
--danger-soft: rgba(255,91,107,0.14);
--button-radius: var(--radius-pill);
--field-radius: 10px;
--card-radius: 22px;
--modal-radius: 22px;
--surface-blur: 20px;
--backdrop: radial-gradient(60% 50% at 20% 20%, rgba(122,120,255,0.55), transparent 60%),
radial-gradient(50% 50% at 80% 30%, rgba(177,91,255,0.50), transparent 60%),
radial-gradient(70% 60% at 50% 100%, rgba(0,229,179,0.35), transparent 60%),
#08081a;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.4);
--shadow: 0 10px 40px -10px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.10);
--shadow-lg: 0 30px 80px -30px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.12);
--shadow-modal: 0 40px 100px -30px rgba(0,0,0,0.7), inset 0 1px 0 rgba(255,255,255,0.14);
--button-bg: #ffffff;
--button-fg: #08081a;
--button-border: transparent;
--button-hover: rgba(255,255,255,0.92);
--button-press: rgba(255,255,255,0.84);
--button-secondary-bg: rgba(255,255,255,0.08);
--button-secondary-fg: #ffffff;
--button-secondary-border: var(--border);
--button-secondary-hover: rgba(255,255,255,0.14);
--button-ghost-fg: #ffffff;
--button-ghost-hover: rgba(255,255,255,0.06);
--field-bg: rgba(255,255,255,0.06);
--field-border: var(--border);
--field-placeholder: var(--text-3);
}
/* ============================================================
THEME: editorial — warm paper, serif display, hairline rules
============================================================ */
.theme-editorial {
--font-display: 'DM Serif Display', 'Times New Roman', Times, serif;
--bg: #f3eee2;
--surface: #fbf8f0;
--surface-2: #f7f2e6;
--surface-alt: #ece6d6;
--border: #d8d0bc;
--border-strong:#b8a988;
--divider: #e2d9c4;
--text: #1c1a14;
--text-2: #5a5044;
--text-3: #8a7d6a;
--text-on-accent: #fbf8f0;
--accent: #1c1a14;
--accent-2: #b85c28; /* terracotta */
--accent-soft: #e8e1cd;
--accent-ring: rgba(28,26,20,0.18);
--success: #3f7a3a;
--success-soft: #dde9d4;
--warn: #a86b14;
--warn-soft: #f3e7c4;
--danger: #a32a1e;
--danger-soft: #f1d6cf;
--button-radius: 3px;
--field-radius: 3px;
--card-radius: 4px;
--modal-radius: 4px;
--shadow-sm: 0 1px 0 rgba(28,26,20,0.06);
--shadow: 0 1px 0 rgba(28,26,20,0.06), 0 6px 24px -12px rgba(28,26,20,0.12);
--shadow-lg: 0 14px 36px -16px rgba(28,26,20,0.18), 0 1px 0 rgba(28,26,20,0.06);
--shadow-modal: 0 30px 60px -20px rgba(28,26,20,0.28);
--button-bg: #1c1a14;
--button-fg: #fbf8f0;
--button-border: #1c1a14;
--button-hover: #2f2a20;
--button-press: #000000;
--button-secondary-bg: transparent;
--button-secondary-fg: #1c1a14;
--button-secondary-border: #1c1a14; /* thick rule */
--button-secondary-hover: rgba(28,26,20,0.06);
--button-ghost-fg: var(--text);
--button-ghost-hover: rgba(28,26,20,0.06);
--field-bg: #fbf8f0;
--field-border: #1c1a14; /* hairline rule */
--field-placeholder: var(--text-3);
}
/* ============================================================
Body backdrop helper — paint --backdrop on the page root.
Glass theme uses this to show the aurora wash.
============================================================ */
.vibn-app {
font-family: var(--font-sans);
color: var(--text);
background: var(--bg);
min-height: 100%;
position: relative;
}
.vibn-app::before {
content: "";
position: absolute; inset: 0;
background: var(--backdrop);
z-index: 0; pointer-events: none;
}
.vibn-app > * { position: relative; z-index: 1; }

View File

@@ -0,0 +1,5 @@
node_modules
dist
.DS_Store
.env*.local
*.log

View File

@@ -0,0 +1,123 @@
# Vibn — Marketing Site
Production Vite + React 18 + Tailwind CSS implementation of the Vibn marketing
homepage and beta-invite signup page.
## Stack
- **Vite 6** — dev server, HMR, multi-page build
- **React 18** — function components + hooks, no router (two static entries)
- **Tailwind CSS 3** — utility classes + a small `@layer components` block for
things that don't compress cleanly to utilities (gradients, pseudo-elements,
custom shadows)
- **No CSS-in-JS, no UI library** — design tokens live as CSS custom properties
so a tweak panel or theme switcher can runtime-swap the accent palette
- **Geist + Geist Mono** — loaded from Google Fonts in each entry HTML
## Getting started
```bash
npm install
npm run dev # http://localhost:5173 (homepage)
# http://localhost:5173/beta.html (signup)
npm run build # production build → dist/
npm run preview # serve the built bundle
```
## Project layout
```
vibn-app/
├── index.html ← homepage entry
├── beta.html ← beta-signup entry
├── vite.config.js ← multi-page input config
├── tailwind.config.js ← design tokens + named animations
├── postcss.config.js
├── public/
│ └── logo-black.png ← favicon (V_ mark on black)
└── src/
├── main.jsx ← mounts <App />
├── beta-main.jsx ← mounts <BetaApp />
├── styles.css ← Tailwind + tokens + keyframes + .btn / .card / .logo-mark
├── App.jsx ← homepage composition
├── BetaApp.jsx ← signup-page composition
├── lib/
│ └── primitives.jsx ← Logo, LogoMark, Arrow, Eyebrow, Glow, TrustStrip
└── components/
├── Nav.jsx
├── Hero.jsx ← Reddit-quote / promise variants + prompt input
├── Wall.jsx ← faux chat-window "homework wall" scene
├── CrossedOut.jsx ← animated strike-through term wall
├── Journey.jsx ← 4-step path + "where others stop" marker
├── Audience.jsx ← 3 audience cards w/ Reddit-style quotes
├── Closing.jsx
├── Footer.jsx
├── LaunchModal.jsx ← hero prompt-submit modal
└── beta/
├── BetaForm.jsx ← 5-step form
├── Confirmed.jsx ← submitted state w/ queue card + referral
└── Benefits.jsx ← "what you get inside" trio
```
## Design tokens
All colors are exposed as CSS custom properties (see `src/styles.css`,
`:root` block) and aliased in `tailwind.config.js → theme.extend.colors`:
| Tailwind class | CSS var | Default |
|---|---|---|
| `bg-bg` | `--c-bg` | `oklch(0.155 0.008 60)` |
| `bg-bg-1` | `--c-bg-1` | `oklch(0.185 0.009 60)` |
| `text-fg` | `--c-fg` | `oklch(0.97 0.005 80)` |
| `text-fg-dim` | `--c-fg-dim` | `oklch(0.78 0.006 80)` |
| `text-fg-mute` | `--c-fg-mute` | `oklch(0.58 0.006 80)` |
| `text-fg-faint` | `--c-fg-faint` | `oklch(0.42 0.006 80)` |
| `text-accent` | `--c-accent` | `oklch(0.74 0.175 35)` (coral) |
| `bg-accent` | `--c-accent` | (same) |
| `border-hairline` | `--c-hairline` | `oklch(0.32 0.010 60 / 0.55)` |
| `text-ok` | `--c-ok` | `oklch(0.78 0.16 155)` |
To re-theme at runtime, set the variables on `:root` from JS:
```js
document.documentElement.style.setProperty("--c-accent", "#9ee649");
document.documentElement.style.setProperty("--c-accent-glow", "#9ee64959");
document.documentElement.style.setProperty("--c-accent-fg", "#0f1408");
```
## Type scale
Fluid `clamp()` sizes are used inline:
- Hero headline: `clamp(44px, 7.4vw, 104px)`
- Section H2: `clamp(36px, 4.8vw, 64px)`
- Body / sub: `clamp(1617px, 1.62.2vw, 1928px)`
Geist is the body face; Geist Mono is reserved for tags, eyebrows, code, and
"trust strip" details.
## Animations
Keyframes are defined once in `styles.css`; some are also registered as named
Tailwind utilities (`animate-caret-blink`, `animate-pulse-ok`, etc.) for
ergonomics. Anything not in the config uses an inline `style={{ animation: ... }}`
with the keyframe name.
## Notes & next steps
- **No router.** Homepage links to `/beta.html` directly. Drop in
`react-router-dom` if you need client-side navigation between more pages.
- **Form submission is a stub.** `BetaForm` just waits 700 ms and toggles to
the confirmed state. Wire to your real endpoint (e.g. `fetch("/api/invite", …)`).
- **Queue position is deterministic on email.** Replace with the real one from
your backend.
- **The hero "Live from minute one" pill is currently not rendered** in this
port (the marketing prototype defaults it off). Re-add by uncommenting the
pill in `Hero.jsx` if you want it back.
- **The Tweaks panel from the prototype is not ported.** Tweaks are a design-time
tool; runtime theming is enough via the CSS-var token system.
## Browser support
Uses `oklch()`, `text-wrap: balance`, and `backdrop-filter`. Safari 15.4+,
Chrome 111+, Firefox 113+.

View File

@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/logo-black.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<title>Vibn — Request an invite</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/beta-main.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/logo-black.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<title>Vibn — Keep vibing. All the way to launch.</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,23 @@
{
"name": "vibn-app",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"vite": "^6.0.7"
},
"packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8"
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -0,0 +1,40 @@
import { useEffect, useState } from "react";
import Nav from "./components/Nav.jsx";
import Hero from "./components/Hero.jsx";
import Wall from "./components/Wall.jsx";
import CrossedOut from "./components/CrossedOut.jsx";
import Journey from "./components/Journey.jsx";
import Audience from "./components/Audience.jsx";
import Closing from "./components/Closing.jsx";
import Footer from "./components/Footer.jsx";
import LaunchModal from "./components/LaunchModal.jsx";
export default function App() {
const [scrolled, setScrolled] = useState(false);
const [launchPrompt, setLaunchPrompt] = useState(null);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 8);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
<>
<Nav scrolled={scrolled} />
<main>
<Hero onStart={(p) => setLaunchPrompt(p || "Build me a tool for my business.")} />
<Wall />
<CrossedOut />
<Journey />
<Audience />
<Closing />
</main>
<Footer />
{launchPrompt !== null && (
<LaunchModal prompt={launchPrompt} onClose={() => setLaunchPrompt(null)} />
)}
</>
);
}

View File

@@ -0,0 +1,91 @@
import { useEffect, useMemo, useState } from "react";
import { Logo, Arrow, Eyebrow, Glow } from "./lib/primitives.jsx";
import BetaForm from "./components/beta/BetaForm.jsx";
import Confirmed from "./components/beta/Confirmed.jsx";
import Benefits from "./components/beta/Benefits.jsx";
export default function BetaApp() {
const [scrolled, setScrolled] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState({
email: "", name: "", build: "", role: "smb", source: "",
});
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 8);
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
// Stable queue position — deterministic on email so the number doesn't jump
const queuePos = useMemo(() => {
let h = 7;
for (const c of form.email) h = (h * 31 + c.charCodeAt(0)) >>> 0;
return 2100 + (h % 900);
}, [form.email]);
const handleSubmit = () => {
setSubmitting(true);
setTimeout(() => {
setSubmitting(false);
setSubmitted(true);
window.scrollTo({ top: 0, behavior: "smooth" });
}, 700);
};
return (
<>
<nav className={`sticky top-0 z-50 backdrop-blur-md bg-[oklch(0.155_0.008_60/0.55)] transition-colors ${scrolled ? "border-b border-hairline" : "border-b border-transparent"}`}>
<div className="wrap flex items-center justify-between h-16">
<Logo href="/" />
<a href="/" className="text-fg-mute text-[14px] inline-flex items-center gap-1.5 hover:text-fg">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
<path d="M13 8H3M7 4 3 8l4 4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
Back to home
</a>
</div>
</nav>
<main className="relative overflow-hidden py-[clamp(60px,9vh,100px)]">
<Glow color="oklch(0.74 0.175 35 / 0.30)" size={1000}
style={{ top: -280, left: "50%", transform: "translateX(-50%)" }} />
<Glow color="oklch(0.45 0.10 35 / 0.20)" size={550} style={{ top: "30%", left: -180 }} />
<Glow color="oklch(0.45 0.10 35 / 0.15)" size={500} style={{ top: "20%", right: -150 }} />
<div className="wrap relative max-w-[760px]">
{submitted ? (
<Confirmed form={form} queuePos={queuePos} />
) : (
<>
<header className="text-center mb-14">
<Eyebrow>Closed beta · invite-only</Eyebrow>
<h1 className="mt-[18px] font-medium tracking-[-0.03em] leading-none text-[clamp(40px,6.4vw,80px)] text-balance">
Be one of the first to{" "}
<em className="not-italic text-accent" style={{ textShadow: "0 0 40px var(--c-accent-glow)" }}>
vibe with Vibn
</em>.
</h1>
<p className="mt-6 text-[clamp(16px,1.6vw,19px)] text-fg-dim max-w-[540px] mx-auto text-balance">
We're letting in <b className="text-fg font-medium">50 new builders a week</b>.
Tell us what you want to build the most exciting ideas get the invite first.
</p>
</header>
<BetaForm form={form} setForm={setForm} submitting={submitting} onSubmit={handleSubmit} />
</>
)}
<Benefits />
</div>
</main>
<footer className="py-6 border-t border-hairline" style={{ background: "oklch(0.14 0.008 60)" }}>
<div className="wrap flex justify-between items-center gap-4 flex-wrap font-mono text-[11px] text-fg-faint tracking-[0.03em]">
<span>🇨🇦 Built in Canada · Your data stays safe · No credit card to start</span>
<span>© 2026 Vibn Inc.</span>
</div>
</footer>
</>
);
}

View File

@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import BetaApp from "./BetaApp.jsx";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<BetaApp />
</React.StrictMode>
);

View File

@@ -0,0 +1,82 @@
import { Eyebrow } from "../lib/primitives.jsx";
const AUDIENCE = [
{
label: "Small business owners", icon: "shop",
quote: "I'm paying $312/month for software that does 60% of what I need and zero of the rest.",
source: "u/coffeeshop_owner · r/smallbusiness",
answer: "Build the tool that actually fits your shop — exactly your workflow, no monthly fee bleed.",
},
{
label: "Freelancers building for clients", icon: "spark",
quote: "My client wants a quote tool. I can mock the frontend in a day. The backend? Two weeks I don't have.",
source: "u/agency_of_one · r/freelance",
answer: "Deliver the whole thing — login, data, hosting — in the same chat where you built the screens.",
},
{
label: "Anyone with an idea", icon: "spark2",
quote: "I built the homepage in an afternoon. Then the AI told me to 'just deploy it' and I cried.",
source: "u/first_time_builder · r/sideproject",
answer: "No deploys. No GitHub. No fear. The thing you described is online, with logins, ready for users.",
},
];
export default function Audience() {
return (
<section className="relative py-[clamp(80px,11vh,140px)]">
<div className="wrap">
<div className="text-center max-w-[820px] mx-auto mb-14">
<Eyebrow>Who Vibn is for</Eyebrow>
<h2 className="mt-[18px] font-medium text-[clamp(36px,4.8vw,64px)] tracking-[-0.025em] leading-[1.02] text-balance">
People who have an idea not a stack.
</h2>
<p className="mt-5 text-fg-mute text-[17px]">If you've ever felt this, Vibn was built for you.</p>
</div>
<div className="grid gap-[18px] grid-cols-1 lg:grid-cols-3">
{AUDIENCE.map((a) => <ACard key={a.label} a={a} />)}
</div>
</div>
</section>
);
}
function ACard({ a }) {
return (
<div className="relative flex flex-col min-h-[380px] p-7 pb-[26px] rounded-[18px] border border-hairline overflow-hidden"
style={{ background: "linear-gradient(180deg, oklch(0.20 0.009 60 / 0.55), oklch(0.17 0.008 60 / 0.55))" }}>
<span aria-hidden="true" className="absolute top-0 left-6 right-6 h-px opacity-60"
style={{ background: "linear-gradient(90deg, transparent, var(--c-accent), transparent)" }} />
<div className="w-10 h-10 rounded-[10px] grid place-items-center border border-hairline mb-[18px] text-accent"
style={{ background: "oklch(0.22 0.011 60)" }}>
<Icon name={a.icon} />
</div>
<div className="text-[19px] font-medium tracking-[-0.015em] text-fg">{a.label}</div>
<div className="mt-[18px] py-4 px-[18px] italic text-fg-dim text-[14.5px] leading-[1.5] rounded-[4px_10px_10px_4px] border-l-2"
style={{ background: "oklch(0.16 0.008 60 / 0.55)", borderLeftColor: "var(--c-accent)" }}>
"{a.quote}"
<div className="mt-2 not-italic font-mono text-[11px] text-fg-faint tracking-[0.02em]"> {a.source}</div>
</div>
<div className="mt-auto pt-[22px] flex gap-2.5 items-start text-[15px] text-fg leading-[1.5]">
<span className="font-mono text-[10px] uppercase tracking-[0.12em] text-accent px-[7px] py-[3px] rounded shrink-0 border mt-px"
style={{ background: "oklch(0.74 0.175 35 / 0.12)", borderColor: "oklch(0.74 0.175 35 / 0.4)" }}>
Vibn
</span>
<span>{a.answer}</span>
</div>
</div>
);
}
function Icon({ name }) {
const p = {
width: 20, height: 20, viewBox: "0 0 20 20", fill: "none",
stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round",
};
if (name === "shop") return <svg {...p}><path d="M3.5 6.5h13l-1 9.5h-11l-1-9.5Z"/><path d="M7 6.5V5a3 3 0 0 1 6 0v1.5"/></svg>;
if (name === "spark") return <svg {...p}><path d="M10 3v4M10 13v4M3 10h4M13 10h4M5.3 5.3l2.8 2.8M11.9 11.9l2.8 2.8M14.7 5.3l-2.8 2.8M8.1 11.9l-2.8 2.8"/></svg>;
if (name === "spark2") return <svg {...p}><path d="M10 2.5v3M10 14.5v3M2.5 10h3M14.5 10h3"/><circle cx="10" cy="10" r="3"/></svg>;
return null;
}

View File

@@ -0,0 +1,34 @@
import { Arrow, Glow, TrustStrip } from "../lib/primitives.jsx";
export default function Closing() {
return (
<section className="relative overflow-hidden text-center py-[clamp(100px,14vh,180px)]">
<Glow color="oklch(0.74 0.175 35 / 0.35)" size={1000}
style={{ top: "20%", left: "50%", transform: "translateX(-50%)" }} />
<Glow color="oklch(0.45 0.10 35 / 0.20)" size={600}
style={{ bottom: -200, left: "50%", transform: "translateX(-50%)" }} />
<div className="wrap max-w-[900px] mx-auto relative">
<h2 className="font-medium tracking-[-0.03em] leading-[1.02] text-balance text-[clamp(40px,6vw,84px)]">
If you can <em className="not-italic bg-clip-text text-transparent"
style={{ backgroundImage: "linear-gradient(180deg, var(--c-accent), oklch(0.62 0.18 18))" }}>describe</em> it,
<br />you can <em className="not-italic bg-clip-text text-transparent"
style={{ backgroundImage: "linear-gradient(180deg, var(--c-accent), oklch(0.62 0.18 18))" }}>build</em> it.
</h2>
<p className="mt-7 text-fg-dim text-balance max-w-[640px] mx-auto text-[clamp(17px,1.6vw,21px)]">
And you can keep building it all the way to customers.
<br />No new tools. No homework. No going back to the wall.
</p>
<div className="mt-9 inline-flex flex-col items-center gap-3.5">
<div className="flex gap-3 items-center flex-wrap justify-center">
<a href="/beta.html" className="btn btn-primary h-14 px-7 text-base">
Request invite <Arrow />
</a>
<a href="#how" className="btn btn-ghost">See how it works</a>
</div>
<TrustStrip items={["No credit card", "No homework", "No new tools to learn"]} />
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,57 @@
import { Eyebrow } from "../lib/primitives.jsx";
const CROSSED_TERMS = [
"Databases", "Auth providers", "GitHub", "Hosting",
"API keys", "Environment variables", "Deployment", "Backend code",
"Servers", "DNS records", "SSL certificates", "CORS errors",
"Webhooks", "Build pipelines", "package.json", "npm install",
];
export default function CrossedOut() {
return (
<section className="relative py-[clamp(70px,10vh,130px)]">
<div className="wrap">
<div className="text-center max-w-[760px] mx-auto mb-14">
<Eyebrow>What you don't have to learn</Eyebrow>
<h2 className="mt-[18px] font-medium text-[clamp(36px,4.8vw,64px)] tracking-[-0.025em] leading-[1.02] text-balance">
All the stuff that made you give up last time.
</h2>
<p className="mt-[18px] text-fg-mute text-[17px]">Forget every word on this list.</p>
</div>
<div className="flex flex-wrap justify-center gap-x-3.5 gap-y-2.5 max-w-[880px] mx-auto">
{CROSSED_TERMS.map((term, i) => (
<Term key={term} term={term} delay={0.12 + i * 0.06} />
))}
</div>
<p className="mt-14 mx-auto max-w-[760px] text-center font-medium tracking-[-0.02em] leading-[1.18] text-balance text-[clamp(24px,3vw,36px)]">
Your AI handles <span className="text-accent">all of it</span>.
<span className="block w-12 h-px bg-hairline mx-auto my-7" />
You just keep building.
</p>
</div>
</section>
);
}
function Term({ term, delay }) {
return (
<span className="relative overflow-hidden px-3.5 py-2 rounded-lg border border-hairline font-mono tracking-[0.005em] text-[clamp(15px,1.7vw,22px)] text-fg-mute"
style={{ background: "oklch(0.20 0.009 60 / 0.45)" }}>
<span
className="inline-block opacity-100"
style={{ animation: `fade-half 0.4s ease ${delay}s forwards` }}
>{term}</span>
<span
aria-hidden="true"
className="absolute left-2 right-2 top-1/2 h-0.5 rounded-sm opacity-0"
style={{
background: "var(--c-accent)",
boxShadow: "0 0 12px var(--c-accent-glow)",
animation: `strike 0.6s cubic-bezier(.7,.1,.3,1) ${delay}s forwards`,
}}
/>
</span>
);
}

View File

@@ -0,0 +1,29 @@
import { Logo } from "../lib/primitives.jsx";
export default function Footer() {
return (
<footer className="relative pt-10 pb-8 border-t border-hairline" style={{ background: "oklch(0.14 0.008 60)" }}>
<div className="wrap">
<div className="flex items-start justify-between gap-8 flex-wrap">
<Logo />
<div className="flex flex-wrap items-center gap-5 font-mono text-[12px] text-fg-mute tracking-[0.03em]">
<span>🇨🇦 Built in Canada</span>
<span className="text-fg-faint">·</span>
<span>Your data stays safe</span>
<span className="text-fg-faint">·</span>
<span>No credit card to start</span>
</div>
</div>
<div className="mt-6 pt-5 border-t border-hairline flex justify-between items-center gap-4 flex-wrap font-mono text-[11px] text-fg-faint tracking-[0.04em]">
<span>© 2026 Vibn Inc. · Made for makers, not engineers.</span>
<div className="flex gap-[18px]">
<a href="#" className="hover:text-fg-dim">Privacy</a>
<a href="#" className="hover:text-fg-dim">Terms</a>
<a href="#" className="hover:text-fg-dim">Status</a>
<a href="#" className="hover:text-fg-dim">Changelog</a>
</div>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,210 @@
import { useEffect, useRef, useState } from "react";
import { Arrow, TrustStrip, Glow } from "../lib/primitives.jsx";
const HERO_PLACEHOLDERS = [
"A booking site for my dog grooming business…",
"An invoice tracker for my freelance clients…",
"A members-only recipe site for my supper club…",
"A custom CRM for our 3-person real estate team…",
"A tip calculator app for our restaurant staff…",
"A waitlist site for my new ceramics studio…",
];
const HERO_CHIPS = [
"📋 Client intake form",
"📅 Booking site",
"🧾 Invoice tracker",
"🛒 Online store",
"📰 Email newsletter",
];
export default function Hero({ onStart, variant = "promise" }) {
const [text, setText] = useState("");
const [phIdx, setPhIdx] = useState(0);
const [phChars, setPhChars] = useState(0);
const [deleting, setDeleting] = useState(false);
const taRef = useRef(null);
// Type-on placeholder while empty
useEffect(() => {
if (text.length > 0) return undefined;
const full = HERO_PLACEHOLDERS[phIdx];
const speed = deleting ? 18 : 38;
const t = setTimeout(() => {
if (!deleting) {
if (phChars < full.length) setPhChars(phChars + 1);
else setTimeout(() => setDeleting(true), 1700);
} else {
if (phChars > 0) setPhChars(phChars - 1);
else { setDeleting(false); setPhIdx((phIdx + 1) % HERO_PLACEHOLDERS.length); }
}
}, speed);
return () => clearTimeout(t);
}, [text, phIdx, phChars, deleting]);
const placeholder = HERO_PLACEHOLDERS[phIdx].slice(0, phChars);
const submit = () => {
const value = text || HERO_PLACEHOLDERS[phIdx];
onStart?.(value);
};
const useChip = (chip) => {
const clean = chip.replace(/^[^\w]+/, "").trim();
setText(`Build me ${clean.toLowerCase()} for my business.`);
taRef.current?.focus();
};
return (
<header className="relative overflow-hidden pt-[clamp(60px,9vh,120px)] pb-[clamp(60px,10vh,120px)]">
{/* Ambient glows */}
<Glow color="oklch(0.74 0.175 35 / 0.30)" size={900}
style={{ top: -200, left: "50%", transform: "translateX(-50%)" }} />
<Glow color="oklch(0.45 0.10 35 / 0.30)" size={600} style={{ top: "20%", left: -200 }} />
<Glow color="oklch(0.45 0.10 35 / 0.20)" size={500} style={{ top: "30%", right: -150 }} />
<div className="wrap relative flex flex-col items-center text-center gap-7">
{variant === "promise" ? (
<>
<h1 className="font-medium leading-[0.98] tracking-[-0.035em] text-[clamp(44px,7.4vw,104px)] text-balance">
Keep <span className="text-accent" style={{ textShadow: "0 0 30px var(--c-accent-glow)" }}>vibing</span>.
<br />All the way to launch.
</h1>
<div className="font-mono text-[12px] text-fg-faint tracking-[0.04em] inline-flex items-center gap-2 -mt-2">
<span className="w-6 h-px bg-hairline" />
idea live marketed customers
<span className="w-6 h-px bg-hairline" />
</div>
<p className="text-[clamp(20px,2.2vw,28px)] text-fg-dim tracking-[-0.01em] max-w-[720px] text-balance">
<b className="text-fg font-medium">"I built my product, now what?"</b> Vibn is the answer.
<br />Your AI handles the technical stuff, puts your idea online, and helps you find your first customers.
</p>
</>
) : (
<>
<h1 className="font-medium leading-[0.98] tracking-[-0.035em] text-[clamp(44px,7.4vw,104px)] text-balance">
<span className="text-accent" style={{ textShadow: "0 0 30px var(--c-accent-glow)" }}></span>I built my product,
<br />now what<span className="text-accent" style={{ textShadow: "0 0 30px var(--c-accent-glow)" }}>?</span>
</h1>
<div className="font-mono text-[12px] text-fg-faint tracking-[0.04em] inline-flex items-center gap-2 -mt-2">
<span className="w-6 h-px bg-hairline" />
posted 2 hours ago · r/SideProject
<span className="w-6 h-px bg-hairline" />
</div>
<p className="text-[clamp(20px,2.2vw,28px)] text-fg-dim tracking-[-0.01em] max-w-[720px] text-balance">
<b className="text-fg font-medium">Keep vibing.</b> All the way to launch.
<br />Your AI handles the technical stuff, puts your idea online, and helps you find your first customers.
</p>
</>
)}
{/* Prompt */}
<PromptInput
text={text} setText={setText}
placeholder={placeholder}
taRef={taRef}
onSubmit={submit}
/>
<div className="flex flex-wrap gap-2 justify-center mt-3 text-[13px]">
{HERO_CHIPS.map((c) => (
<button key={c} type="button"
onClick={() => useChip(c)}
className="px-3.5 py-[7px] rounded-full border border-hairline bg-[oklch(0.20_0.009_60/0.4)] text-fg-dim transition-all hover:border-hairline-2 hover:text-fg hover:-translate-y-px"
>
{c}
</button>
))}
</div>
<div className="flex gap-3 items-center mt-2.5 flex-wrap justify-center">
<button type="button" onClick={submit} className="btn btn-primary">
Start building free <Arrow />
</button>
<a href="#how" className="btn btn-ghost">See how it works</a>
</div>
<TrustStrip items={["No credit card", "No homework", "No new tools to learn"]} />
</div>
</header>
);
}
function PromptInput({ text, setText, placeholder, taRef, onSubmit }) {
return (
<div className="w-full max-w-[720px] relative mt-3.5">
<div className="relative rounded-3xl p-px"
style={{
background: "linear-gradient(180deg, oklch(0.50 0.06 35 / 0.6), oklch(0.30 0.012 60 / 0.4) 40%, oklch(0.25 0.012 60 / 0.4))",
boxShadow: "0 30px 80px -20px oklch(0 0 0 / 0.6), 0 0 80px -20px var(--c-accent-glow)",
}}
>
<div className="rounded-[27px] px-[18px] pt-[18px] pb-3.5 backdrop-blur-xl"
style={{ background: "linear-gradient(180deg, oklch(0.19 0.009 60 / 0.92), oklch(0.17 0.008 60 / 0.92))" }}
>
<div className="relative">
<textarea
ref={taRef}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) onSubmit(); }}
className="w-full min-h-[96px] bg-transparent border-0 outline-none resize-none text-fg text-[17px] leading-[1.45] py-1.5 px-1 placeholder:text-fg-faint"
aria-label="Describe what you want to build"
placeholder=""
/>
{text.length === 0 && (
<div className="absolute top-[22px] left-[6px] right-[6px] pointer-events-none text-fg-faint text-[17px] leading-[1.45] text-left">
{placeholder}
<span className="inline-block w-2 h-[18px] align-[-3px] ml-0.5 animate-[blink_1s_steps(2)_infinite]"
style={{ background: "var(--c-accent)", boxShadow: "0 0 12px var(--c-accent-glow)" }} />
</div>
)}
</div>
<div className="flex items-center justify-between gap-3.5 mt-1.5 pt-3 border-t border-hairline">
<div className="hidden sm:flex gap-1.5 text-fg-mute">
<PromptTool icon="paperclip" label="Screenshot" />
<PromptTool icon="mic" label="Voice" />
<PromptTool icon="grid" label="Templates" />
</div>
<button
type="button" onClick={onSubmit}
className="inline-flex items-center gap-2 h-9 px-3.5 pr-3.5 rounded-full font-medium text-sm transition-transform hover:-translate-y-px"
style={{
background: "var(--c-accent)", color: "var(--c-accent-fg)",
boxShadow: "0 0 0 1px oklch(0.84 0.16 35 / 0.5) inset, 0 8px 28px -8px var(--c-accent-glow)",
}}
>
Start building <Arrow size={13} />
</button>
</div>
</div>
</div>
</div>
);
}
function PromptTool({ icon, label }) {
return (
<button type="button" title={label}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-full text-[12px] text-fg-mute border border-transparent transition-colors hover:border-hairline hover:text-fg-dim">
<PromptIcon name={icon} />
{label}
</button>
);
}
function PromptIcon({ name }) {
const props = {
width: 13, height: 13, viewBox: "0 0 16 16", fill: "none",
stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round",
};
if (name === "paperclip") return (
<svg {...props}><path d="M11.5 6.5 6.6 11.4a2 2 0 1 1-2.8-2.8l5.4-5.4a3.5 3.5 0 1 1 5 5L8.6 13.7"/></svg>
);
if (name === "mic") return (
<svg {...props}><rect x="6" y="2" width="4" height="8" rx="2"/><path d="M3.5 8a4.5 4.5 0 0 0 9 0M8 13v2"/></svg>
);
if (name === "grid") return (
<svg {...props}><rect x="2.5" y="2.5" width="4.5" height="4.5"/><rect x="9" y="2.5" width="4.5" height="4.5"/><rect x="2.5" y="9" width="4.5" height="4.5"/><rect x="9" y="9" width="4.5" height="4.5"/></svg>
);
return null;
}

View File

@@ -0,0 +1,165 @@
import { Eyebrow } from "../lib/primitives.jsx";
const JOURNEY_STEPS = [
{
num: "01", title: "You describe it.", sub: "The AI builds it.",
body: "Talk to it like you'd talk to a friend who codes. It builds the screens, the buttons, the logic — whatever your idea needs.",
demo: "describe",
},
{
num: "02", title: "It goes live.", sub: "The AI puts it online.",
body: "Logins, saving your stuff, hosting — handled. You get a live link from minute one. Share it. Show your friends. It just works.",
demo: "live",
},
{
num: "03", title: "It gets seen.", sub: "The AI markets it.",
body: "Posts, emails, social — written, scheduled, and shipped on autopilot. The tone matches your brand because you trained it talking to your AI.",
demo: "seen",
},
{
num: "04", title: "It gets customers.", sub: "Your first 100.",
body: "Through our Google partnership, Vibn helps the right people find your product when they're searching for what you built.",
demo: "customers",
},
];
export default function Journey() {
return (
<section id="how" className="relative py-[clamp(80px,11vh,140px)]">
<div className="wrap">
<div className="text-center max-w-[820px] mx-auto mb-16">
<Eyebrow>The journey</Eyebrow>
<h2 className="mt-[18px] font-medium text-[clamp(36px,4.8vw,64px)] tracking-[-0.025em] leading-[1.02] text-balance">
From idea to first 100 customers.
<br /><span className="text-accent">In one chat.</span>
</h2>
<p className="mt-5 text-fg-mute text-[17px] text-balance">
Other tools take you to step two and wave goodbye. Vibn keeps building with you.
</p>
</div>
<div className="relative grid gap-4 grid-cols-1 sm:grid-cols-2 xl:grid-cols-4">
{/* "Where everyone else stops" — only meaningful on the 4-col layout */}
<div className="hidden xl:flex absolute inset-y-0 flex-col items-center pointer-events-none z-[2]"
style={{ left: "calc(50% - 1px)", width: 16 }}>
<div className="flex-1 w-px" style={{
background: "repeating-linear-gradient(180deg, var(--c-accent) 0 6px, transparent 6px 12px)",
opacity: .7,
}}/>
<span className="font-mono text-[10px] uppercase tracking-[0.12em] text-accent bg-bg px-3 py-1.5 rounded-full border whitespace-nowrap"
style={{
borderColor: "oklch(0.74 0.175 35 / 0.5)",
boxShadow: "0 0 24px var(--c-accent-glow)",
transform: "translateY(-1px)",
}}>
Where every other tool stops
</span>
<div className="flex-1 w-px" style={{
background: "repeating-linear-gradient(180deg, var(--c-accent) 0 6px, transparent 6px 12px)",
opacity: .7,
}}/>
</div>
{JOURNEY_STEPS.map((s, i) => (
<StepCard key={s.num} step={s} stopped={i >= 2} />
))}
</div>
<p className="mt-12 text-center text-fg-mute text-[15px] text-balance">
<b className="text-fg font-medium">One tool. One chat.</b> From "wouldn't it be cool if…" to{" "}
<b className="text-fg font-medium">real customers paying you money.</b>
</p>
</div>
</section>
);
}
function StepCard({ step, stopped }) {
return (
<div className={[
"relative flex flex-col rounded-2xl border border-hairline overflow-hidden isolate min-h-[380px] pt-6 px-6",
stopped ? "opacity-[0.46]" : "",
].join(" ")}
style={{ background: "linear-gradient(180deg, oklch(0.20 0.009 60 / 0.55), oklch(0.17 0.008 60 / 0.55))" }}
>
{!stopped && (
<span className="absolute top-0 left-0 right-0 h-px opacity-70"
style={{ background: "linear-gradient(90deg, transparent, var(--c-accent) 50%, transparent)" }} />
)}
{stopped && (
<span aria-hidden="true" className="absolute inset-0 pointer-events-none"
style={{ background: "linear-gradient(180deg, transparent 40%, oklch(0.155 0.008 60 / 0.6))" }} />
)}
<div className="font-mono text-[11px] text-fg-faint tracking-[0.08em]">{step.num}</div>
<h3 className="mt-3 text-[22px] font-medium tracking-[-0.018em]">{step.title}</h3>
<div className={`mt-1 text-[15px] font-medium ${stopped ? "text-fg-mute" : "text-accent"}`}>{step.sub}</div>
<p className="mt-3 text-fg-dim text-[14px] leading-[1.55]">{step.body}</p>
<StepDemo demo={step.demo} />
</div>
);
}
function StepDemo({ demo }) {
const wrapStyle = "mt-auto -mx-6 px-[18px] py-4 border-t border-hairline font-mono text-[12px] leading-[1.55] text-fg-dim flex flex-col gap-[7px] min-h-[116px]";
const wrapBg = { background: "oklch(0.16 0.008 60 / 0.6)" };
if (demo === "describe") return (
<div className={wrapStyle} style={wrapBg}>
<DemoRow tag="YOU" tagKind="you">build a booking site for my dog grooming biz</DemoRow>
<DemoRow tag="VIBN" tagKind="ai">on it designing screens</DemoRow>
<DemoRow tag="VIBN" tagKind="ai" align="center"><span className="text-ok"> booking flow ready</span></DemoRow>
</div>
);
if (demo === "live") return (
<div className={wrapStyle} style={wrapBg}>
<DemoRow tag="VIBN" tagKind="ai" align="center">put it online</DemoRow>
<div className="h-1 rounded-full overflow-hidden relative" style={{ background: "oklch(0.25 0.01 60)" }}>
<span className="absolute inset-0 w-[64%]" style={{ background: "var(--c-accent)", boxShadow: "0 0 8px var(--c-accent-glow)" }} />
</div>
<div className="flex items-center gap-1.5 mt-0.5 text-fg-dim">
<i className="w-1.5 h-1.5 rounded-full" style={{ background: "var(--c-ok)", boxShadow: "0 0 6px oklch(0.78 0.16 155 / 0.6)" }} />
pawsandposh.vibn.app
</div>
<div><span className="text-ok"> logins · saving · live</span></div>
</div>
);
if (demo === "seen") return (
<div className={wrapStyle} style={wrapBg}>
<DemoRow tag="VIBN" tagKind="ai">draft a launch post for Instagram + email blast</DemoRow>
<div className="text-fg-faint"> scheduled for Tue 9:00 AM</div>
<div className="text-fg-faint"> scheduled for Thu 6:00 PM</div>
<div><span className="text-ok"> 3 channels on autopilot</span></div>
</div>
);
if (demo === "customers") return (
<div className={wrapStyle} style={wrapBg}>
<div className="flex items-center">
<span className="font-mono text-[22px] font-medium text-accent tracking-[-0.02em]">
+47<small className="text-fg-mute text-[11px] font-normal ml-1">this week</small>
</span>
</div>
<div className="flex items-center gap-2">
{["35", "260", "155", "80"].map((h) => (
<span key={h} className="w-4 h-4 rounded-full shrink-0" style={{ background: `oklch(0.55 0.14 ${h})` }} />
))}
<span className="text-fg-mute">found you via Google</span>
</div>
<div><span className="text-ok"> tracking toward 100</span></div>
</div>
);
return null;
}
function DemoRow({ tag, tagKind, align, children }) {
const styles = tagKind === "you"
? { color: "oklch(0.85 0.06 250)", background: "oklch(0.28 0.04 250)" }
: tagKind === "ai"
? { color: "var(--c-accent)", background: "oklch(0.35 0.10 35 / 0.4)" }
: { color: "var(--c-fg-faint)", background: "oklch(0.22 0.01 60)" };
return (
<div className={`flex gap-2 ${align === "center" ? "items-center" : "items-start"}`}>
<span className="font-mono text-[10px] px-1.5 py-px rounded shrink-0 tracking-[0.04em] mt-px" style={styles}>{tag}</span>
<span className="text-fg-dim">{children}</span>
</div>
);
}

View File

@@ -0,0 +1,90 @@
import { useEffect, useState } from "react";
const STEPS = [
"Drafting the screens",
"Setting up logins",
"Saving your stuff",
"Putting it online",
];
export default function LaunchModal({ prompt, onClose }) {
const [step, setStep] = useState(0);
useEffect(() => {
const onKey = (e) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
useEffect(() => {
if (step >= STEPS.length) return undefined;
const t = setTimeout(() => setStep(step + 1), 700);
return () => clearTimeout(t);
}, [step]);
return (
<div onClick={onClose}
className="fixed inset-0 z-[100] grid place-items-center p-6 backdrop-blur-md"
style={{ background: "oklch(0.10 0.005 60 / 0.7)", animation: "fadein .2s ease" }}>
<div onClick={(e) => e.stopPropagation()}
className="relative w-full max-w-[540px] rounded-[20px] p-7 pb-6 border border-hairline-2"
style={{
background: "linear-gradient(180deg, oklch(0.20 0.009 60), oklch(0.17 0.008 60))",
boxShadow: "0 30px 80px -20px oklch(0 0 0 / 0.6), 0 0 60px -20px var(--c-accent-glow)",
}}>
<button type="button" onClick={onClose}
className="absolute top-3.5 right-3.5 w-7 h-7 rounded-md text-fg-mute hover:text-fg hover:bg-[oklch(0.25_0.01_60)]"
aria-label="Close"></button>
<div className="flex items-center gap-2.5 text-accent font-mono text-[11px] uppercase tracking-[0.1em]">
<i className="w-1.5 h-1.5 rounded-full animate-pulse-ok"
style={{ background: "var(--c-accent)", boxShadow: "0 0 12px var(--c-accent-glow)" }} />
Vibn is on it
</div>
<h3 className="mt-3 text-[24px] font-medium tracking-[-0.018em] leading-[1.15]">Keep vibing we've got the rest.</h3>
<div className="mt-3.5 p-3.5 rounded-[10px] border border-hairline font-mono text-[13px] text-fg-dim leading-[1.5]"
style={{ background: "oklch(0.16 0.008 60)" }}>
"{prompt}"
</div>
<div className="mt-[18px] flex flex-col gap-2.5">
{STEPS.map((s, i) => (
<Step key={s} label={s} state={i < step ? "done" : i === step ? "active" : "pending"} />
))}
</div>
<div className="mt-[18px] text-center font-mono text-[11px] text-fg-faint tracking-[0.04em]">
No homework · No setup · No new tools to learn
</div>
</div>
</div>
);
}
function Step({ label, state }) {
return (
<div className={[
"flex items-center gap-3 px-3.5 py-[11px] rounded-[10px] border border-hairline text-[14px] transition-colors",
state === "done" ? "text-fg" : "text-fg-dim",
].join(" ")} style={{ background: "oklch(0.165 0.008 60)" }}>
{state === "done" ? (
<svg className="w-[18px] h-[18px] text-ok shrink-0" viewBox="0 0 20 20" fill="none">
<path d="M4 10.5 8 14.5 16 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
) : state === "active" ? (
<span className="w-3.5 h-3.5 rounded-full border-2 shrink-0"
style={{
borderColor: "oklch(0.30 0.01 60)",
borderTopColor: "var(--c-accent)",
animation: "spin .9s linear infinite",
}} />
) : (
<svg className="w-[18px] h-[18px] text-fg-faint shrink-0" viewBox="0 0 20 20" fill="none">
<circle cx="10" cy="10" r="6" stroke="currentColor" strokeWidth="1.5"/>
</svg>
)}
<span>{label}</span>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { Logo, Arrow } from "../lib/primitives.jsx";
export default function Nav({ scrolled }) {
return (
<nav
className={[
"sticky top-0 z-50 backdrop-blur-md transition-colors",
"bg-[oklch(0.155_0.008_60/0.55)]",
scrolled ? "border-b border-hairline" : "border-b border-transparent",
].join(" ")}
>
<div className="wrap flex items-center justify-between h-16">
<Logo href="/" />
<div className="hidden md:flex gap-7 text-fg-mute text-[14px]">
<a href="#how" className="hover:text-fg">How it works</a>
<a href="#" className="hover:text-fg">Templates</a>
<a href="#" className="hover:text-fg">Pricing</a>
<a href="#" className="hover:text-fg">Stories</a>
</div>
<div className="flex items-center gap-2.5">
<a href="#" className="btn btn-ghost h-9 px-4 text-sm hidden sm:inline-flex">Sign in</a>
<a href="/beta.html" className="btn btn-primary h-9 px-4 text-sm">
Request invite <Arrow size={12} />
</a>
</div>
</div>
</nav>
);
}

View File

@@ -0,0 +1,140 @@
import { Eyebrow } from "../lib/primitives.jsx";
export default function Wall() {
return (
<section id="the-wall" className="relative py-[clamp(60px,9vh,110px)]">
<div className="wrap">
<div className="text-center max-w-[760px] mx-auto mb-14">
<Eyebrow>The wall</Eyebrow>
<h2 className="mt-[18px] font-medium text-[clamp(36px,4.8vw,64px)] tracking-[-0.025em] leading-[1.02] text-balance">
Every other tool stops{" "}
<em className="not-italic text-accent" style={{ textShadow: "0 0 30px var(--c-accent-glow)" }}>
right here
</em>.
</h2>
<p className="mt-5 text-fg-mute text-[17px] text-balance">
You built it. It works on your laptop. Then the chat hands you a list.
</p>
</div>
{/* Faux app window */}
<div className="relative max-w-[880px] mx-auto rounded-2xl border border-hairline overflow-hidden backdrop-blur-md"
style={{ background: "oklch(0.165 0.008 60 / 0.85)", boxShadow: "0 30px 80px -20px oklch(0 0 0 / 0.6)" }}>
<WindowBar />
<div className="p-6 flex flex-col gap-4">
<Msg who="user" name="You · just now" body="okay it works!! how do i put this online so my customers can use it?" />
<Msg who="ai" name="Generic AI · just now" body={<HomeworkBody />} />
<Msg who="user" name="You · now" body={<Typing />} muted />
</div>
</div>
{/* Punchline */}
<div className="mt-14 text-center">
<div className="w-px h-14 mx-auto mb-7"
style={{ background: "linear-gradient(180deg, transparent, var(--c-hairline), transparent)" }} />
<p className="font-medium tracking-[-0.022em] leading-[1.2] text-balance text-fg-mute text-[clamp(28px,3.4vw,42px)]">
And just like that <em className="italic text-fg">the vibe is gone.</em>
</p>
</div>
</div>
</section>
);
}
function WindowBar() {
return (
<div className="flex items-center gap-3.5 px-3.5 py-[11px] border-b border-hairline font-mono text-[12px] text-fg-mute"
style={{ background: "oklch(0.20 0.009 60 / 0.85)" }}>
<div className="flex gap-[7px]">
<i className="w-[11px] h-[11px] rounded-full" style={{ background: "oklch(0.40 0.01 60)" }} />
<i className="w-[11px] h-[11px] rounded-full" style={{ background: "oklch(0.40 0.01 60)" }} />
<i className="w-[11px] h-[11px] rounded-full" style={{ background: "oklch(0.40 0.01 60)" }} />
</div>
<span className="ml-2 text-fg-faint tracking-[0.02em]">untitled-project · main</span>
<span className="ml-auto px-2 py-0.5 rounded text-fg-faint text-[11px]" style={{ background: "oklch(0.25 0.01 60)" }}>
generic ai coder · chat
</span>
</div>
);
}
function Msg({ who, name, body, muted }) {
return (
<div className="flex gap-3 items-start text-[14.5px] leading-[1.55]">
<div className={[
"w-[26px] h-[26px] rounded-[7px] grid place-items-center font-mono text-[11px] font-semibold shrink-0",
who === "user" ? "" : "",
].join(" ")}
style={who === "user"
? { background: "oklch(0.28 0.01 60)", color: "var(--c-fg-dim)" }
: { background: "oklch(0.30 0.02 250)", color: "oklch(0.85 0.06 250)" }
}>
{who === "user" ? "YOU" : "AI"}
</div>
<div className="flex-1 min-w-0">
<div className="font-mono text-[11px] text-fg-faint tracking-[0.04em] uppercase mb-0.5">{name}</div>
<div className={who === "user" && !muted ? "text-fg" : "text-fg-dim"}>{body}</div>
</div>
</div>
);
}
function HomeworkBody() {
const items = [
["Sign up for Supabase", "and create a project for your database."],
["Configure authentication", "with Supabase Auth or Clerk — pick one."],
["Create a GitHub repo", ", commit your code, and push it."],
["Deploy to Vercel", ": connect repo, configure framework preset."],
["Add environment variables", "for your API keys and DB url in the Vercel dashboard."],
["Set up DNS", "for your custom domain and verify nameservers with your registrar."],
["Configure SSL / TLS certificates", "for HTTPS (or use Vercel's automatic provisioning)."],
["Set up Stripe", "if you want to take payments, and configure webhooks."],
];
// Per-row fade values for "overload" feeling
const opacities = [1, 1, 1, 0.82, 0.65, 0.48, 0.34, 0.22];
const blurs = [0, 0, 0, 0, 0, 0.2, 0.4, 0.7];
return (
<>
<p className="text-fg-dim">Great job 🎉 Your app is running locally. To take it live, you'll need to set a few things up first:</p>
<ol className="list-none p-0 mt-3 flex flex-col gap-2">
{items.map(([title, rest], i) => (
<li key={i}
className="flex items-start gap-3 px-3.5 py-3 rounded-[10px] border border-hairline text-fg-dim text-[13.5px] transition-opacity"
style={{
background: "oklch(0.20 0.009 60)",
opacity: opacities[i],
filter: blurs[i] ? `blur(${blurs[i]}px)` : "none",
}}
>
<span className="font-mono text-[11px] text-fg-faint px-1.5 py-px rounded shrink-0"
style={{ background: "oklch(0.16 0.008 60)" }}>
{String(i + 1).padStart(2, "0")}
</span>
<span>
<b className="text-fg font-medium">{title}</b> {rest}
</span>
<span className="ml-auto font-mono text-[11px] text-fg-faint px-[7px] py-px border border-hairline rounded shrink-0">
external
</span>
</li>
))}
</ol>
<div className="-mt-2.5 pt-[30px] font-mono text-[11px] text-fg-faint text-center"
style={{ background: "linear-gradient(180deg, transparent, oklch(0.165 0.008 60 / 0.85))" }}>
23 more steps
</div>
</>
);
}
function Typing() {
return (
<span className="inline-flex gap-[3px] items-center py-1 text-fg-mute">
{[0, 1, 2].map((i) => (
<i key={i}
className="w-[5px] h-[5px] rounded-full bg-fg-mute"
style={{ animation: `bounce-dot 1.2s ${i * 0.15}s infinite ease-in-out` }} />
))}
</span>
);
}

View File

@@ -0,0 +1,37 @@
import { Eyebrow } from "../../lib/primitives.jsx";
const BENEFITS = [
{ icon: "lightning", title: "First access", body: "Skip the queue when public beta opens. You build before everyone else." },
{ icon: "gift", title: "90 days of Pro, free", body: "Full launch features — hosting, marketing, customer acquisition — on the house." },
{ icon: "chat", title: "Direct line to the team", body: "Private channel with the people building Vibn. Your feedback ships." },
];
export default function Benefits() {
return (
<section className="mt-16">
<div className="text-center mb-7"><Eyebrow>What you get on the inside</Eyebrow></div>
<div className="grid gap-3.5 grid-cols-1 md:grid-cols-3">
{BENEFITS.map((b) => (
<div key={b.title} className="p-6 rounded-[14px] border border-hairline"
style={{ background: "linear-gradient(180deg, oklch(0.20 0.009 60 / 0.35), oklch(0.17 0.008 60 / 0.35))" }}>
<div className="w-9 h-9 rounded-[9px] grid place-items-center border border-hairline text-accent mb-3.5"
style={{ background: "oklch(0.22 0.011 60)" }}>
<Icon name={b.icon} />
</div>
<h3 className="text-[16px] font-medium tracking-[-0.01em]">{b.title}</h3>
<p className="mt-1.5 text-fg-mute text-[13.5px] leading-[1.5]">{b.body}</p>
</div>
))}
</div>
</section>
);
}
function Icon({ name }) {
const p = { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none",
stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round" };
if (name === "lightning") return <svg {...p}><path d="M11 2 4 11h5l-1 7 7-9h-5l1-7Z"/></svg>;
if (name === "gift") return <svg {...p}><rect x="3" y="7.5" width="14" height="10"/><path d="M3 11h14M10 7.5V18M7 7.5a2 2 0 1 1 3-2.5 2 2 0 1 1 3 2.5"/></svg>;
if (name === "chat") return <svg {...p}><path d="M3.5 11.5a6 6 0 1 1 3.4 5.4L3 18l1.1-3.9a6 6 0 0 1-.6-2.6Z"/></svg>;
return null;
}

View File

@@ -0,0 +1,172 @@
import { Arrow } from "../../lib/primitives.jsx";
const ROLES = [
{ value: "smb", label: "Small business owner", hint: "I run a shop, salon, studio, café…" },
{ value: "freelancer", label: "Freelancer / agency", hint: "I build tools for clients" },
{ value: "ideaperson", label: "I just have an idea", hint: "First-time builder, no code" },
];
const SOURCES = ["Reddit", "Twitter / X", "TikTok", "YouTube", "A friend", "Google", "Something else"];
export default function BetaForm({ form, setForm, submitting, onSubmit }) {
const update = (k, v) => setForm((f) => ({ ...f, [k]: v }));
const valid = /\S+@\S+\.\S+/.test(form.email) && form.build.trim().length > 4;
const handle = (e) => {
e.preventDefault();
if (!valid || submitting) return;
onSubmit();
};
return (
<form onSubmit={handle} noValidate
className="relative flex flex-col gap-7 py-9 px-5 sm:px-11 rounded-[22px] border border-hairline backdrop-blur-xl"
style={{
background: "linear-gradient(180deg, oklch(0.20 0.009 60 / 0.6), oklch(0.17 0.008 60 / 0.6))",
boxShadow: "0 30px 80px -20px oklch(0 0 0 / 0.6)",
}}>
<span aria-hidden="true" className="absolute top-0 left-0 right-0 h-px opacity-60"
style={{ background: "linear-gradient(90deg, transparent, var(--c-accent), transparent)" }} />
<Field num="01" title="What's your email?" hint="So we can send you the invite when it's your turn.">
<input
type="email" required
className="f-input" placeholder="you@somewhere.com" autoComplete="email"
value={form.email} onChange={(e) => update("email", e.target.value)}
/>
</Field>
<Field num="02" title="What should we call you?" hint="Optional, but nice to know.">
<input
type="text" className="f-input" placeholder="First name or handle" autoComplete="given-name"
value={form.name} onChange={(e) => update("name", e.target.value)}
/>
</Field>
<Field num="03" title="What's the first thing you want to build?"
hint="Free-form. The vibe matters more than the spec." required>
<div className="rounded-xl border border-hairline overflow-hidden transition-all"
style={{ background: "oklch(0.16 0.008 60 / 0.8)" }}>
<textarea
rows={4}
placeholder="A booking site for my dog grooming business with reminders, payments and a wait list…"
className="w-full border-0 bg-transparent text-fg text-[16px] leading-[1.5] py-3.5 px-4 pb-2.5 outline-none resize-y min-h-[110px] placeholder:text-fg-faint"
value={form.build} onChange={(e) => update("build", e.target.value)}
/>
<div className="flex items-center justify-between px-3.5 py-2.5 border-t border-hairline font-mono text-[11px] text-fg-faint tracking-[0.02em]">
<span className="text-accent">{form.build.length > 0 ? `${form.build.length} chars` : "go wild"}</span>
<span> + Enter to submit the form</span>
</div>
</div>
</Field>
<Field num="04" title="Which one are you?">
<div className="grid grid-cols-1 gap-2">
{ROLES.map((r) => (
<button key={r.value} type="button"
className={[
"relative text-left py-3.5 pr-12 pl-4 rounded-xl border flex flex-col gap-0.5 transition-all",
form.role === r.value
? "border-accent bg-[oklch(0.20_0.04_35/0.4)] shadow-[0_0_0_3px_oklch(0.74_0.175_35/0.1)]"
: "border-hairline hover:border-hairline-2",
].join(" ")}
style={{ background: form.role !== r.value ? "oklch(0.16 0.008 60 / 0.6)" : undefined }}
onClick={() => update("role", r.value)}
>
<span className="text-[15px] font-medium text-fg">{r.label}</span>
<span className="text-[13px] text-fg-mute">{r.hint}</span>
<span className={[
"absolute top-1/2 right-4 -translate-y-1/2 w-5 h-5 rounded-full grid place-items-center transition-all",
form.role === r.value
? "bg-accent border-accent text-accent-fg"
: "border-[1.5px] border-hairline-2 bg-transparent",
].join(" ")}>
<svg width="12" height="12" viewBox="0 0 14 14" fill="none"
className={form.role === r.value ? "opacity-100" : "opacity-0"}>
<path d="M3 7.2 5.8 10 11 4.2" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</span>
</button>
))}
</div>
</Field>
<Field num="05" title="How'd you hear about us?" hint="Optional. Helps us know what's working.">
<div className="flex flex-wrap gap-2">
{SOURCES.map((s) => (
<button key={s} type="button"
onClick={() => update("source", form.source === s ? "" : s)}
className={[
"px-3.5 py-2 rounded-full border text-[13px] transition-colors",
form.source === s
? "border-accent bg-[oklch(0.20_0.04_35/0.4)] text-fg"
: "border-hairline bg-[oklch(0.16_0.008_60/0.6)] text-fg-dim hover:border-hairline-2 hover:text-fg",
].join(" ")}
>{s}</button>
))}
</div>
</Field>
<div className="flex flex-col items-center gap-3.5 mt-2">
<button type="submit" disabled={!valid || submitting}
className="btn btn-primary w-full max-w-[320px] h-14 text-base">
{submitting ? (<><Spinner /> Sending</>) : (<>Request my invite <Arrow /></>)}
</button>
<p className="font-mono text-[11px] text-fg-faint tracking-[0.03em] text-center text-balance">
No credit card · No spam, just one email when you're in · Unsubscribe anytime
</p>
</div>
{/* Component-level CSS for the field inputs (Tailwind doesn't cover the
custom focus glow shadow cleanly). */}
<style>{`
.f-input {
width: 100%; box-sizing: border-box;
padding: 14px 16px;
background: oklch(0.16 0.008 60 / 0.8);
border: 1px solid var(--c-hairline);
border-radius: 12px;
color: var(--c-fg);
font: 16px/1.5 'Geist', system-ui, sans-serif;
outline: none;
transition: border-color .15s, background .15s, box-shadow .15s;
}
.f-input::placeholder { color: var(--c-fg-faint); }
.f-input:focus,
form > div > div:focus-within {
border-color: oklch(0.74 0.175 35 / 0.65);
background: oklch(0.18 0.009 60 / 0.95);
box-shadow: 0 0 0 3px oklch(0.74 0.175 35 / 0.15), 0 0 30px -10px var(--c-accent-glow);
}
`}</style>
</form>
);
}
function Field({ num, title, hint, required, children }) {
return (
<div className="flex flex-col gap-3">
<div className="flex items-start gap-3.5">
<span className="font-mono text-[11px] tracking-[0.1em] text-fg-faint px-2 py-1 border border-hairline rounded-md shrink-0 mt-0.5">
{num}{required && <em className="not-italic text-accent ml-px">*</em>}
</span>
<div className="flex-1">
<div className="text-[17px] font-medium text-fg tracking-[-0.01em]">{title}</div>
{hint && <div className="mt-0.5 text-[13px] text-fg-mute">{hint}</div>}
</div>
</div>
<div>{children}</div>
</div>
);
}
function Spinner() {
return (
<span className="w-4 h-4 rounded-full border-2 inline-block"
style={{
borderColor: "oklch(0 0 0 / 0.2)",
borderTopColor: "var(--c-accent-fg)",
animation: "spin .9s linear infinite",
}} />
);
}

View File

@@ -0,0 +1,150 @@
import { useMemo, useState } from "react";
import { Eyebrow } from "../../lib/primitives.jsx";
export default function Confirmed({ form, queuePos }) {
const [copied, setCopied] = useState(false);
const ref = useMemo(() => {
const seed = form.email || form.name || "anon";
let h = 5;
for (const c of seed) h = (h * 33 + c.charCodeAt(0)) >>> 0;
return "v-" + h.toString(36).slice(0, 6);
}, [form.email, form.name]);
const link = typeof window !== "undefined"
? `${window.location.origin}/join?ref=${ref}`
: `vibn.app/join?ref=${ref}`;
const copyLink = () => {
try { navigator.clipboard.writeText(link); } catch (e) { /* noop */ }
setCopied(true);
setTimeout(() => setCopied(false), 1800);
};
const pct = Math.max(2, Math.min(98, 100 - (queuePos - 2100) / 9));
return (
<div className="flex flex-col gap-7">
<div className="text-center">
<div className="inline-grid place-items-center w-16 h-16 rounded-full mb-4 text-ok"
style={{
background: "oklch(0.78 0.16 155 / 0.1)",
border: "1px solid oklch(0.78 0.16 155 / 0.4)",
boxShadow: "0 0 40px oklch(0.78 0.16 155 / 0.3)",
}}>
<svg width="32" height="32" viewBox="0 0 32 32" fill="none">
<circle cx="16" cy="16" r="15" stroke="currentColor" strokeWidth="1.5" opacity=".25"/>
<path d="M10 16.5 14.5 21 22 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<Eyebrow>You're on the list</Eyebrow>
<h1 className="mt-3.5 font-medium tracking-[-0.03em] leading-none text-[clamp(40px,6.4vw,80px)] text-balance">
{form.name
? <>Welcome, <em className="not-italic text-accent" style={{ textShadow: "0 0 40px var(--c-accent-glow)" }}>{form.name}</em>.</>
: <>You're <em className="not-italic text-accent" style={{ textShadow: "0 0 40px var(--c-accent-glow)" }}>in line</em>.</>}
</h1>
<p className="mt-5 text-[clamp(16px,1.6vw,19px)] text-fg-dim text-balance">
We got your invite request keep an eye on{" "}
<b className="font-mono font-medium text-fg">{form.email}</b>.
</p>
</div>
{/* Queue card */}
<div className="p-7 rounded-[18px] border border-hairline"
style={{ background: "linear-gradient(180deg, oklch(0.20 0.009 60 / 0.6), oklch(0.17 0.008 60 / 0.6))" }}>
<div className="flex justify-between items-end gap-3.5 mb-6">
<div>
<div className="font-mono text-[11px] uppercase tracking-[0.1em] text-fg-faint">your spot in line</div>
<div className="mt-2 font-mono font-medium text-accent tracking-[-0.04em] leading-none text-[clamp(48px,7vw,76px)]"
style={{ textShadow: "0 0 40px var(--c-accent-glow)" }}>
#{queuePos.toLocaleString()}
</div>
</div>
<div className="text-right">
<div className="font-mono text-[26px] font-medium text-fg leading-none">
50<small className="text-fg-mute text-[13px] font-normal ml-0.5">/wk</small>
</div>
<div className="font-mono text-[11px] uppercase tracking-[0.1em] text-fg-faint">letting in</div>
</div>
</div>
<div className="relative h-1.5 rounded-full mb-6 mt-9" style={{ background: "oklch(0.22 0.01 60)" }}>
<div className="absolute left-0 top-0 bottom-0 rounded-full transition-[width] duration-700"
style={{
width: `${pct}%`,
background: "linear-gradient(90deg, oklch(0.65 0.15 35), var(--c-accent))",
boxShadow: "0 0 12px var(--c-accent-glow)",
}} />
<div className="absolute top-1/2 -translate-y-1/2 w-3.5 h-3.5 rounded-full"
style={{
left: `${pct}%`,
transform: "translate(-50%, -50%)",
background: "var(--c-accent)",
boxShadow: "0 0 0 3px var(--c-bg), 0 0 18px var(--c-accent-glow)",
}}>
<span className="absolute bottom-full left-1/2 -translate-x-1/2 -translate-y-2 font-mono text-[11px] tracking-[0.04em] text-accent whitespace-nowrap">
You
</span>
</div>
</div>
<div className="font-mono text-[12px] text-fg-mute tracking-[0.02em]">
You should hear from us in ~<b className="text-fg font-medium">{Math.ceil((queuePos - 50) / 50)} weeks</b>. Don't want to wait?
</div>
</div>
{/* Refer card */}
<div className="p-7 rounded-[18px] border border-hairline"
style={{ background: "linear-gradient(180deg, oklch(0.20 0.009 60 / 0.6), oklch(0.17 0.008 60 / 0.6))" }}>
<Eyebrow>Skip the line</Eyebrow>
<h3 className="mt-3 text-[22px] font-medium tracking-[-0.018em]">Send 3 friends — jump to the front.</h3>
<p className="mt-1.5 text-fg-mute text-[14px]">Each friend who joins via your link bumps you up 500 spots.</p>
<div className="mt-4 flex gap-2 items-stretch">
<div className="flex-1 px-3.5 py-3 rounded-[10px] border border-hairline text-[13px] tracking-[0.01em] text-fg-dim flex items-center overflow-hidden whitespace-nowrap"
style={{ background: "oklch(0.16 0.008 60)" }}>
<span className="font-mono">
<span className="text-fg-faint">vibn.app/join?ref=</span>
<b className="text-accent font-medium">{ref}</b>
</span>
</div>
<button type="button" onClick={copyLink} className="btn btn-ghost h-auto px-[18px]">
{copied ? "Copied!" : "Copy link"}
</button>
</div>
<div className="mt-3.5 flex flex-wrap gap-2">
{[["x", "Share on X"], ["reddit", "Post to Reddit"], ["mail", "Email a friend"]].map(([k, label]) => (
<a key={k} href="#"
className="inline-flex items-center gap-2 px-3.5 py-2 rounded-full border border-hairline text-[13px] text-fg-dim transition-colors hover:text-fg hover:border-hairline-2"
style={{ background: "oklch(0.16 0.008 60 / 0.5)" }}>
<ShareIcon name={k} /> {label}
</a>
))}
</div>
</div>
{form.build && (
<div className="p-6 px-7 rounded-2xl border border-dashed border-hairline">
<Eyebrow>What we'll help you build first</Eyebrow>
<div className="mt-3 italic text-fg text-[18px] tracking-[-0.005em] leading-[1.4] text-balance">
"{form.build}"
</div>
</div>
)}
</div>
);
}
function ShareIcon({ name }) {
const p = { width: 14, height: 14, viewBox: "0 0 16 16", fill: "currentColor" };
if (name === "x") return <svg {...p}><path d="M9.2 7 13.7 2h-1.4L8.6 6.3 5.6 2H2l4.7 6.8L2 14h1.4l4.1-4.7 3.3 4.7H14L9.2 7Z"/></svg>;
if (name === "reddit") return <svg {...p}><circle cx="8" cy="9" r="6"/></svg>;
if (name === "mail") return (
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="2" y="3.5" width="12" height="9" rx="1.5"/>
<path d="m3 5 5 3.8L13 5"/>
</svg>
);
return null;
}

View File

@@ -0,0 +1,73 @@
// Shared primitives — used across homepage and beta page.
export function LogoMark({ size = 26, blink = true }) {
return (
<span className="logo-mark" style={{ width: size, height: size }}>
<svg
viewBox="0 0 36 32"
width="74%" height="74%"
fill="currentColor"
stroke="currentColor"
strokeWidth="1.2"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M4 5 L10 5 L12 18 L14 5 L20 5 L12 27 Z" />
<rect
x="22.5" y="23" width="9.5" height="3.8" rx="0.7"
className={blink ? "logo-caret" : ""}
/>
</svg>
</span>
);
}
export function Logo({ size = 26, href = "/" }) {
return (
<a href={href} className="inline-flex items-center gap-[9px] font-semibold text-[17px] tracking-[-0.02em]">
<LogoMark size={size} />
<span>vibn</span>
</a>
);
}
export function Arrow({ size = 14 }) {
return (
<svg className="arrow" width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
}
export function Eyebrow({ children }) {
return <div className="eyebrow">{children}</div>;
}
export function Glow({ color = "var(--c-accent-glow)", size = 700, opacity = 1, style = {} }) {
return (
<div
aria-hidden="true"
className="absolute pointer-events-none"
style={{
width: size, height: size,
background: `radial-gradient(circle at center, ${color} 0%, transparent 62%)`,
filter: "blur(20px)",
opacity,
...style,
}}
/>
);
}
export function TrustStrip({ items }) {
return (
<div className="font-mono flex flex-wrap gap-x-[18px] gap-y-2 text-[12px] text-fg-mute tracking-[0.04em]">
{items.map((item, i) => (
<span key={i} className="contents">
{i > 0 && <span className="text-fg-faint">·</span>}
<span>{item}</span>
</span>
))}
</div>
);
}

View File

@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

Some files were not shown because too many files have changed in this diff Show More