chore: deploy final layout and routing polish to live

This commit is contained in:
2026-06-08 14:46:21 -07:00
parent f9ae97d31c
commit f670fee691
35 changed files with 9 additions and 9144 deletions

View File

@@ -1,510 +0,0 @@
# VIBN Agent Execution Architecture
## Goal
Give every product builder a personal AI that thinks like a COO — one that understands their product, monitors what's happening, and can direct specialists to get work done on their behalf. The user talks to one AI. That AI figures out what needs to happen and delegates accordingly.
---
## The Mental Model
**The user has one AI.** It happens to have specialists behind it.
The user does not manage or navigate to Code agents, Growth agents, or Analytics agents. They talk to their Assist AI — their personal product COO — and that COO routes work to the right specialist internally. The module tabs (Code, Growth, Analytics) show the *output and status* of delegated work, not separate AI interfaces the user has to navigate between.
This is the difference between a founder who has a COO (talks to one person who directs the team) vs. a founder who manages every department directly (exhausting, requires expertise they don't have).
---
## Core AI Architecture
```
┌─────────────────────────────────────────────────────────────────────┐
│ USER (non-technical founder) │
│ │
│ "I want more signups" │
│ "Why did revenue drop last week?" │
│ "Add a referral program" │
│ "What should I build next?" │
└───────────────────────────┬─────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ ASSIST — Personal AI / COO (Tier B: Claude Sonnet) │
│ │
│ The user's only AI interface. Reasons across the entire │
│ business. Surfaces insights proactively. Delegates all work │
│ to the right specialist. Never asks the user to choose which │
│ module or agent to use — it figures that out. │
│ │
│ Full context available: │
│ - PRD + product vision │
│ - Everything that's been built (Gitea) │
│ - All past agent sessions and their outcomes │
│ - Live analytics and deployment status │
│ - Persistent memory: decisions made, patterns, preferences │
│ - Web search: competitors, market trends, growth tactics │
│ │
│ Proactive behaviors: │
│ - Surfaces anomalies before being asked │
│ - Tracks open questions and follows up │
│ - Monitors deploy outcomes and flags regressions │
│ - Briefs the founder on what happened while they were away │
└──────────────┬───────────────┬───────────────┬──────────────────────┘
│ │ │
delegates │ delegates │ delegates │
▼ ▼ ▼
┌────────────────┐ ┌──────────────────┐ ┌────────────────────┐
│ CODE ADVISOR │ │ GROWTH ADVISOR │ │ ANALYTICS ADVISOR │
│ (Tier B) │ │ (Tier B) │ │ (Tier B) │
│ │ │ │ │ │
│ Technical │ │ Acquisition, │ │ Data queries, │
│ scoping. │ │ activation, │ │ anomaly detection, │
│ Reads codebase │ │ retention. │ │ correlates data │
│ before writing │ │ Researches what │ │ with deploys and │
│ a single line. │ │ works for this │ │ events. │
│ │ │ product type. │ │ │
│ NOT user- │ │ NOT user- │ │ NOT user- │
│ facing. │ │ facing. │ │ facing. │
└───────┬────────┘ └────────┬─────────┘ └──────────┬─────────┘
│ │ │
└───────────────────┴───────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ ORCHESTRATOR (Tier A: Gemini Flash) │
│ │
│ Receives a structured plan from whichever Advisor handled the │
│ work. Breaks it into discrete tasks. Assigns tiers based on │
│ complexity. Runs tasks in parallel where possible. │
└──────────┬───────────────┬───────────────┬──────────────────────────┘
↓ ↓ ↓
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ TIER A │ │ TIER B │ │ TIER C │
│Gemini Flash │ │Claude Sonnet│ │Claude Sonnet│
│ │ │ │ │ (→ Opus) │
│Simple edits │ │Feature work │ │Complex arch │
│Copy, rename │ │New components│ │Hard debugs │
│Config tweaks│ │API routes │ │Refactors │
└─────────────┘ └─────────────┘ └─────────────┘
↓ ↓ ↓
┌─────────────────────────────────────────────────────────────────────┐
│ EXECUTION RUNTIME (vibn-agent-runner) │
│ │
│ - Persistent dev environment: Node, Python, npm, git │
│ - Agent executes commands, writes files, runs builds │
│ - Self-corrects on errors (re-prompts with error context) │
│ - Auto-commits to Gitea on completion │
│ - Triggers Coolify redeploy automatically │
│ - Session persists even if browser is closed │
└─────────────────────────────────────────────────────────────────────┘
```
**The confirmation moment is critical UX.** Before any work is delegated, Assist always surfaces a plain-language summary card:
> *"Here's my plan: I'm going to add a referral program. That means building a referral code system, an invite email, and a dashboard for your users. This will take ~8 minutes. Want me to start?"*
The user never sees task tiers, agent assignments, or technical scope. They see what's happening and confirm.
---
## Assist — Personal AI / COO
The user's primary and only AI interface. All other specialists work behind it.
### What it does
**Reactive** — answers anything the user asks:
- "What should I build next?"
- "Why did signups drop?"
- "How does our product compare to [competitor]?"
- "Add a referral program"
- "Fix the checkout bug"
**Proactive** — surfaces things without being asked:
- Detects when a deploy caused an error spike
- Flags when a key metric moves significantly
- Follows up on decisions made in past conversations ("You said you wanted to revisit the onboarding flow this week")
- Delivers a brief when the founder returns after time away
### Full example
```
User: "I want more signups but I don't know what's blocking it"
Assist COO
- Pulls current signup funnel from analytics
- Reads landing page + onboarding flow from Gitea
- Checks recent deploys for regressions
- Searches web for conversion benchmarks for this product category
- Cross-references PRD: what was the original promise to users?
- Asks: "Are people finding you organically or are you mostly getting
referrals? That changes what I'd recommend."
- User: "Mostly referrals from friends"
- COO: "Then your acquisition isn't the problem — your activation is.
People aren't converting after they land. Your onboarding skips the
'aha moment'. Here's what I'd do..."
- Presents plan: "1. Rewrite the onboarding welcome email.
2. Add an empty-state prompt on the dashboard.
3. Test a 30-day trial instead of 14.
Shall I start?"
On confirm → delegates to Growth Advisor (items 12) + Code Advisor (item 3)
Orchestrator breaks into tasks → Execution agents build and deploy
Assist follows up: "Done. The onboarding email and dashboard prompt are live.
The trial length change needs your input on pricing — want to talk through it?"
```
### What Assist does NOT do itself
- Write or execute code → delegates to Code Advisor
- Design and run growth experiments → delegates to Growth Advisor
- Run raw data queries → delegates to Analytics Advisor
The user never has to know this routing is happening.
### Persistent Memory
Assist maintains a memory of the product that grows over time:
```
"User prefers simple, fast solutions over architecturally correct ones"
"Decided against social login in Jan 2026 — too complex for current stage"
"Trial length has been discussed 3x — founder is nervous about revenue impact"
"Mobile conversion has been an open problem since Nov 2025"
```
This is what makes it feel like a COO rather than a chatbot. It remembers context across sessions so the user never has to repeat themselves.
---
## Specialist Advisors (Delegated, Not User-Facing)
These three Advisors are invoked by Assist, not by the user directly. Their module tabs (Code, Growth, Analytics) in the sidebar show **status and output** — not chat interfaces.
### Code Advisor
Handles anything that requires changes to the codebase. Scopes work technically before a single line is written.
```
[Invoked by Assist with]: "Add Stripe checkout to the admin app"
Code Advisor
- Reads package.json — Stripe not yet installed
- Reads existing payment-related files (none found)
- Searches: current Stripe Next.js best practices
- Scopes: "3 tasks: install SDK, create checkout API route,
add checkout button to pricing page"
- Returns structured plan to Orchestrator
Orchestrator:
Task 1 (Tier A): npm install stripe
Task 2 (Tier B): POST /api/checkout route
Task 3 (Tier B): CheckoutButton component on pricing page
Execution → commit → redeploy
Assist reports back to user: "Stripe checkout is live."
```
**Context it uses:**
- Full codebase via Gitea
- Past agent sessions and what they changed
- Build logs and Coolify deploy history
- Web search: API docs, package versions, error solutions
### Growth Advisor
Handles acquisition, activation, and retention work. Researches what's proven to work before proposing anything.
```
[Invoked by Assist with]: "Build a referral program"
Growth Advisor
- Checks current user count + retention from analytics
- Reviews existing onboarding flow from codebase
- Searches: referral mechanics that work for this product type + stage
- Proposes: "Double-sided reward — referrer gets 1 month free,
referee gets 14-day trial. This pattern converts at ~18% for
B2B SaaS at your stage."
- Returns scoped plan
Orchestrator → 4 tasks → Execution
```
**Context it uses:**
- User base metrics from Analytics
- Current user flow from Gitea
- Past growth experiments and outcomes
- Web search: growth playbooks, conversion benchmarks, competitor tactics
### Analytics Advisor
Handles all data interpretation. Correlates numbers with events to find the actual story behind a metric.
```
[Invoked by Assist with]: "Why did signups drop last week?"
Analytics Advisor
- Queries signup data: 30-day trend, last week specifically
- Checks Gitea: any deploys last week? Yes — Tuesday at 2pm
- Checks error logs: mobile error rate went from 0.2% to 8.4% post-deploy
- Finds: specific commit that changed signup form validation
- Returns: "Tuesday's deploy broke mobile signup. Here's the commit."
Assist reports to user + asks: "Want me to fix it?"
If yes → delegates to Code Advisor
```
**Context it uses:**
- Product event data (signups, activations, churns, feature usage)
- Deployment history (Gitea commits + Coolify deploy timestamps)
- Error logs and performance metrics
- Web search: category benchmarks for comparison
---
## Shared Execution Infrastructure
```
┌───────────────────────────────────────────────────────────────────┐
│ vibn-agent-runner (Node.js/TypeScript) │
│ │
│ - Receives task + tier assignment from Orchestrator │
│ - Instantiates the correct LLM client (Tier A/B/C) │
│ - Executes tools: write_file, execute_command, read_file, etc. │
│ - Self-corrects on errors (re-prompts with error output) │
│ - Writes step-by-step output to Postgres after every step │
│ - Auto-commits to Gitea on completion │
│ - Triggers Coolify redeploy automatically │
│ - Session persists even if browser tab is closed │
└──────────────────────┬────────────────────────────────────────────┘
│ git push (auto on completion)
┌───────────────────────────────────────────────────────────────────┐
│ Gitea (git.vibnai.com) │
│ - Source of truth for all committed code │
│ - Webhook → triggers Coolify auto-deploy │
└──────────────────────┬────────────────────────────────────────────┘
│ auto-deploy
┌───────────────────────────────────────────────────────────────────┐
│ Coolify (coolify.vibnai.com) │
│ - Builds, deploys, manages domains + SSL │
└───────────────────────────────────────────────────────────────────┘
```
---
## Model Tier Reference
| Tier | Model | Used For | Cost Profile |
|------|-------|----------|--------------|
| A | Gemini 2.5 Flash | Orchestration, simple tasks, routing, summaries | Very low |
| B | Claude Sonnet 4.6 | Assist COO, specialist advisors, feature-level tasks | Medium |
| C | Claude Sonnet 4.6 (→ Opus) | Hard debugging, architecture changes, complex reasoning | High |
All tiers overridable via env vars: `TIER_A_MODEL`, `TIER_B_MODEL`, `TIER_C_MODEL`.
---
## Data Model
### `agent_sessions` table (Postgres) — exists
```sql
CREATE TABLE agent_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id TEXT NOT NULL,
module TEXT NOT NULL DEFAULT 'code',
-- code | growth | analytics (delegated modules)
app_name TEXT NOT NULL,
app_path TEXT NOT NULL,
task TEXT NOT NULL,
plan JSONB,
status TEXT NOT NULL DEFAULT 'pending',
-- pending | running | done | approved | failed | stopped
output JSONB NOT NULL DEFAULT '[]',
changed_files JSONB NOT NULL DEFAULT '[]',
error TEXT,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
### `advisor_conversations` table (Postgres) — to be built
Stores every conversation the COO has had with the user. Each row is one session (may span many turns).
```sql
CREATE TABLE advisor_conversations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id TEXT NOT NULL,
-- 'assist' for user-facing COO conversations.
-- 'code' | 'growth' | 'analytics' for internal specialist invocations.
module TEXT NOT NULL DEFAULT 'assist',
messages JSONB NOT NULL DEFAULT '[]',
-- Full message history: [{role, content, timestamp}]
context_snapshot JSONB,
-- State of PRD, codebase summary, analytics at conversation start.
-- Used for debugging ("what did it know when it said that?")
outcome TEXT,
-- null | 'tasked' | 'declined' | 'deferred' | 'monitoring'
-- If this conversation resulted in execution, link to the session(s)
session_ids UUID[] DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
### `advisor_memory` table (Postgres) — to be built
Long-term memory for the COO. Persists facts, decisions, and patterns across conversations. This is what makes the AI feel like a real assistant rather than a stateless chatbot.
```sql
CREATE TABLE advisor_memory (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id TEXT NOT NULL,
-- Category of memory
category TEXT NOT NULL,
-- 'decision' — "Decided not to add social login — too complex for now"
-- 'preference' — "Founder prefers speed over architectural correctness"
-- 'open' — "Mobile conversion problem still unresolved"
-- 'context' — "Target user is non-technical indie founders"
-- 'experiment' — "Tried 30-day trial in Feb — didn't impact conversion"
key TEXT NOT NULL, -- short label for retrieval
value TEXT NOT NULL, -- full memory content
confidence REAL DEFAULT 1.0, -- 01, decays if contradicted
-- Where this memory came from
source_conversation_id UUID REFERENCES advisor_conversations(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
---
## Current End-to-End Status (March 2026)
### What works today
| Step | Status |
|------|--------|
| User types task → session created in Postgres | ✅ |
| Agent runner receives task, calls Claude Sonnet 4.6 via Vertex AI | ✅ |
| Agent executes commands and writes files in runner workspace | ✅ |
| Step output streamed to Postgres in real time | ✅ |
| Frontend polls and shows live output log | ✅ |
| Auto-commit + push to Gitea on completion | ✅ |
| Gitea webhook triggers Coolify auto-deploy | ✅ |
| Browse tab shows latest committed files | ✅ |
| Project tabs (Atlas/PRD/Build/Growth/Assist/Analytics) in sidebar | ✅ |
### In Progress
| Step | Status |
|------|--------|
| Assist COO + specialist advisor architecture | 🔶 Designing |
### Not Yet Started
| Step | Status |
|------|--------|
| `advisor_conversations` + `advisor_memory` DB tables | ⬜ |
| Assist COO (user-facing personal AI) | ⬜ |
| Code Advisor (delegated specialist) | ⬜ |
| Growth Advisor (delegated specialist) | ⬜ |
| Analytics Advisor (delegated specialist) | ⬜ |
| Orchestrator (task decomposition + tier routing) | ⬜ |
| Proactive monitoring (anomaly detection, briefings) | ⬜ |
| Parallel task execution | ⬜ |
| WebSocket streaming (replace polling) | ⬜ |
| Terminal tab (xterm.js → live container PTY) | ⬜ |
---
## Build Phases
### Phase 1 — Execution Foundation ✅
- [x] `agent_sessions` DB table + REST API routes
- [x] Agent runner with Claude Sonnet (Tier B)
- [x] Three-tier LLM clients (Gemini Flash, Claude via Vertex)
- [x] Session output streamed to Postgres per step
- [x] Auto-commit + Coolify deploy on completion
- [x] Frontend: Browse / Agent / Terminal tabs
- [x] Adaptive polling, auto-select active session
- [x] Context-aware task input (locked while running)
- [x] Project tabs moved to sidebar (Atlas/PRD/Build/Growth/Assist/Analytics)
### Phase 2 — Per-project Sandboxed Workspaces
- [ ] Per-project ephemeral container (cold tier — wakes on demand)
- [ ] Agent `execute_command` routes through the project workspace container
- [ ] Persistent volume per project for caches / installed deps
- [ ] In-browser file viewer reflects live agent workspace
### Phase 3 — Assist COO + Specialist Advisors
- [ ] `advisor_conversations` + `advisor_memory` DB tables + API
- [ ] Assist COO: stateful multi-turn conversation with full project context
- [ ] Assist COO: persistent memory across conversations (advisor_memory)
- [ ] Assist COO: web search tool (competitor research, growth tactics, docs)
- [ ] Code Advisor: reads codebase, scopes tasks before execution
- [ ] Growth Advisor: researches what works, proposes specific experiments
- [ ] Analytics Advisor: queries data, correlates with deploys, finds stories
- [ ] Assist delegates to specialists internally — user sees one AI
- [ ] Confirmation card UX: plain-language plan before any execution begins
### Phase 4 — Orchestrator
- [ ] Orchestrator: decomposes advisor plans into tiered tasks
- [ ] Parallel task execution (multiple agents simultaneously)
- [ ] Task dependency graph (task B waits for task A's output)
- [ ] Cross-module task routing (one confirm → Code + Growth tasks in parallel)
### Phase 5 — Streaming & Persistence
- [ ] WebSocket replaces polling for live output
- [ ] Browser reconnect: full log replay from Postgres + live tail
- [ ] Background notifications (in-app + email) on completion/failure
- [ ] Terminal tab: xterm.js connected to project workspace PTY
### Phase 6 — Proactive Intelligence
- [ ] Assist monitors for anomalies and surfaces them without being asked
- [ ] Morning briefing: digest of what happened since last session
- [ ] Memory improves over time: learns founder preferences and product patterns
- [ ] Orchestrator estimates task time + complexity before confirming
- [ ] Agents validate their own output (tests, type-checks, linting)
---
## Key Design Decisions
**Why one AI interface (the COO), not four module AIs?**
Non-technical founders don't want to manage a team of AI tools. They want to talk to one AI that knows their business and gets things done. Routing work to specialists is the COO's job, not the founder's. Hiding that complexity is the product.
**Why is Assist Tier B (Claude Sonnet) and not the fastest model?**
Assist is the only AI the user ever talks to. They'll judge the entire product by how well it understands them, how good its advice is, and how natural the conversation feels. This warrants the strongest reasoning model available. The savings come from using cheaper tiers for execution, not from cutting corners on the user-facing intelligence.
**Why does Assist have persistent memory (advisor_memory)?**
Without memory, every conversation starts from zero. The COO has to re-learn that the founder doesn't want to use social login, that mobile conversion is an ongoing problem, that the trial length decision was deliberate. Memory is what transforms a chatbot into an assistant that actually knows you.
**Why are the specialist advisors not user-facing?**
Users shouldn't have to decide "is this a Code question or a Growth question?" That's the COO's job. The module tabs exist to show what's been delegated and what the status is — not as separate AI chat interfaces. The user's relationship is with Assist, not with the individual specialists.
**Why is the Orchestrator Tier A (cheap)?**
Once a specialist Advisor has produced a clear, structured plan, decomposing it into tasks is mechanical. It doesn't require deep reasoning — it requires fast, reliable routing. Gemini Flash is ideal: low latency, high quota, very low cost.
**Why auto-commit by default?**
The target user is a non-technical founder. Requiring approval on every task creates friction and undermines the "describe it, it ships" value proposition. Gitea + Coolify already provide a rollback path if something goes wrong.
**Why store everything in Postgres?**
Browser sessions end. Postgres is the source of truth. Every conversation turn, every memory item, every execution step, every outcome is written immediately. The WebSocket stream (Phase 5) is a convenience layer on top of the database, not a replacement.

View File

@@ -1,299 +0,0 @@
Google Cloud Product OS
Product-Centric IDE + SaaS Autopilot Platform (Requirements & Architecture)
Vision
Build a Product-Centric IDE and Automation Platform dedicated exclusively to:
Launching, growing, and operating SaaS products on Google Cloud
This is NOT a general-purpose IDE.
This is a Product Operating System (Product OS) designed to unify:
Code
Marketing
Analytics
Growth
Support
Experiments
Infrastructure
AI-driven automation
into one coherent platform.
It delivers:
A Cursor-like experience
Without Cursor cost
Powered by Gemini (Vertex AI)
Optimized specifically for Google Cloud
Focused exclusively on building & automating products
Core Product Principles
1. Product-Centric, Not Code-Centric
This platform optimizes for:
Shipping, launching, growing, and optimizing products, not just writing code.
2. Opinionated for Google Cloud
This system is:
Cloud Run-first
Firestore / Cloud SQL-native
BigQuery-native
Cloud Build-native
Gemini-native
No AWS, no Azure, no multi-cloud abstraction.
3. Automation First
Everything is:
Automatable
Observable
Auditable
Optimizable
4. AI as a Product Operator
The AI is not just a coding assistant.
It is a:
Product Operator AI
capable of coordinating marketing, growth, support, analytics, and code.
IDE Structure: Product-Centric Layout
Instead of a traditional IDE layout, the system must expose:
Product OS
├── Code
├── Marketing
├── Analytics
├── Growth
├── Support
├── Experiments
└── Infrastructure
Each section is first-class and AI-assisted.
Section Requirements
1. Code Section
Purpose:
Build and deploy product services
Must support:
Cloud Run services
Cloud SQL / Firestore integration
Secrets management
Logs & traces
Rollbacks
Service templates
Not required:
Arbitrary framework support
Every programming language
Optimized languages:
TypeScript / Node
Python
2. Marketing Section
Purpose:
Automate go-to-market and content execution
Must support:
Campaign generation
Social scheduling (Missinglettr)
Blog generation & updates
Landing page updates
Brand voice control
Product update → campaign pipeline
AI must:
Convert product changes into launch content
Adapt content to brand style
3. Analytics Section
Purpose:
Understand product performance and causality
Must support:
Funnels
Retention
Activation
Cohorts
LTV
Causal drivers
Experiment results
NOT a SQL editor.
This is a Product Intelligence Interface.
AI must answer:
"Why did conversion change?"
"What caused activation to drop?"
"What should we test next?"
4. Growth Section
Purpose:
Optimize onboarding and conversion
Must support:
Funnel definitions
Onboarding flows
Growth experiments
A/B tests
Nudge systems
Conversion optimization
AI must:
Detect drop-offs
Recommend experiments
Evaluate uplift
5. Support Section
Purpose:
Integrate customer feedback and product health
Must support:
Ticket ingestion
AI-assisted replies
Knowledge base generation
Product issue detection
Issue → fix pipeline
AI must:
Generate replies
Detect recurring issues
Recommend fixes
6. Experiments Section
Purpose:
Coordinate A/B tests and product experiments
Must support:
Experiment definitions
Targeting
Metrics tracking
Statistical significance
Rollout controls
AI must:
Suggest experiments
Analyze results
Recommend actions
7. Infrastructure Section
Purpose:
Manage and monitor production systems
Must support:
Cloud Run deployments
Firestore / Cloud SQL management
Secrets
Logs
Traces
Alerts
Cost monitoring
AI must:
Detect anomalies
Recommend optimizations
Automate fixes

View File

@@ -1,209 +0,0 @@
# Turborepo Monorepo Per-Project Migration Plan
## Why We Are Making This Change
The core thesis of this platform is that **one AI controls everything in one project**. For that to work, the AI needs a complete mental model of the project — all apps, all shared code, all dependencies — in a single coherent context.
The current architecture creates a single Gitea repo per project with no enforced internal structure. The AI has no reliable way to know where apps live, what shares code with what, or how to trigger a targeted build for one part of the project.
By adopting **Turborepo monorepo per project**, every project repo gets a standardised structure containing all of its apps (`product`, `website`, `admin`, `storybook`) and shared packages (`ui`, `tokens`, `types`, `config`). The AI operates across the entire project simultaneously. Build orchestration, deployment, and shared code all become coherent automatically.
**The structure every user project repo will have:**
```
{project-slug}/ ← one Gitea repo per project
apps/
product/ ← core user-facing app (Next.js)
website/ ← marketing / landing site (Next.js)
admin/ ← internal admin tool (Next.js)
storybook/ ← component browser and design system
packages/
ui/ ← shared React component library
tokens/ ← design tokens (colors, spacing, typography)
types/ ← shared TypeScript types
config/ ← shared eslint, tsconfig
turbo.json
package.json ← pnpm workspace root
.gitignore
README.md
```
Turborepo is MIT-licensed, runs anywhere, and costs nothing. No Vercel dependency.
---
## Infrastructure Context
Everything runs on a single GCP VM (`34.19.250.135`, Montreal) via Docker + Traefik:
| Service | URL | Repo |
|---|---|---|
| Platform frontend | `vibnai.com` | `git.vibnai.com/mark/vibn-frontend` |
| Gitea | `git.vibnai.com` | — |
| Coolify | `coolify.vibnai.com` | — |
| PostgreSQL | internal | — |
**All platform logic lives in `vibn-frontend`** (Next.js). There is no separate control plane service. The backend is Next.js API routes in `app/api/`. Storage is PostgreSQL via raw SQL queries (no ORM layer in use for project data).
**Integrations that already exist and should not be replaced:**
- `lib/gitea.ts` — full Gitea API client (create repo, webhooks, signature verification)
- `lib/coolify.ts` — full Coolify API client (projects, databases, applications, deployments)
- `app/api/projects/create/route.ts` — project creation flow (creates Gitea repo)
- `app/api/webhooks/gitea/route.ts` — receives Gitea push/PR events
- `app/api/webhooks/coolify/route.ts` — receives Coolify deployment events
- `app/api/ai/chat/route.ts` — AI chat with Gemini
- `lib/auth/authOptions.ts` — NextAuth v4 with Prisma adapter
---
## Scope of Changes
### 1. Scaffold Templates
**What:** A set of template files written into the user's Gitea repo when a project is created, giving every project the standard Turborepo monorepo structure.
**Where:** `vibn-frontend/lib/scaffold/turborepo/`
**Files to create:**
- `turbo.json` — pipeline: `build`, `dev`, `lint`, `type-check`, `test`
- `package.json` — pnpm workspace root pointing to `apps/*` and `packages/*`
- `.gitignore`
- `README.md` — project-specific (name injected at scaffold time)
- `apps/product/` — Next.js 15, references shared `ui`, `tokens`, `types`
- `apps/website/` — Next.js 15
- `apps/admin/` — Next.js 15
- `apps/storybook/` — Storybook 8
- `packages/ui/` — Button, Card, Input, Badge components using CSS token vars
- `packages/tokens/` — design tokens as TS + CSS custom properties
- `packages/types/` — shared `User`, `ApiResponse`, `PaginatedResponse` types
- `packages/config/``tsconfig.base.json` and `eslint.config.js`
**Status:** Templates were written and are ready. Need to be moved to `vibn-frontend/lib/scaffold/turborepo/`.
---
### 2. Project Creation Route — Add Scaffold Push
**What:** The existing `app/api/projects/create/route.ts` already creates a Gitea repo. It needs one additional step: push the Turborepo scaffold as the initial commit.
**File to update:** `vibn-frontend/app/api/projects/create/route.ts`
**Current flow:**
1. Create Gitea repo (`auto_init: true` — creates empty repo with README)
2. Register webhook
3. Save project record to PostgreSQL
**New step to add after repo creation:**
- Read scaffold template files from `lib/scaffold/turborepo/`
- Replace `{{project-slug}}` and `{{project-name}}` placeholders
- Push each file to the Gitea repo via the contents API
- This replaces the default empty `auto_init` commit
**Note:** Change `auto_init: true` to `auto_init: false` since we are pushing the scaffold ourselves.
---
### 3. Project Data Model — Add App Tracking
**What:** The `fs_projects` table stores project data as a JSONB `data` column. The `data` object needs two new fields to track the monorepo apps and their Coolify services.
**Fields to add to the project `data` JSONB:**
```typescript
apps: Array<{
name: string; // "product" | "website" | "admin" | "storybook"
path: string; // "apps/product"
coolifyServiceUuid?: string;
domain?: string;
}>
turboVersion: string; // e.g. "2.3.3"
```
No schema migration needed — it's JSONB, just include these fields when inserting/updating.
---
### 4. Coolify — Per-App Service Provisioning
**What:** When a project is created, each app in the monorepo gets its own Coolify service with the correct Turbo build filter. This extends the existing `lib/coolify.ts`.
**File to update:** `vibn-frontend/lib/coolify.ts`
**Add function:**
```typescript
createMonorepoAppService(opts: {
projectUuid: string;
appName: string; // e.g. "product"
gitRepo: string; // the project's Gitea clone URL
domain: string; // e.g. "product-taskmaster.vibnai.com"
}): Promise<CoolifyApplication>
```
Build command: `pnpm install && turbo run build --filter={appName}`
**Wire into project creation:** After Gitea repo is created and scaffold is pushed, create one Coolify service per app and store the `coolifyServiceUuid` in the project's `apps` array.
---
### 5. Deploy API Route
**What:** A new API route that triggers a Coolify deployment for a specific app within a project.
**File to create:** `vibn-frontend/app/api/projects/[projectId]/deploy/route.ts`
```
POST /api/projects/{projectId}/deploy
Body: { app_name: "product" | "website" | "admin" | "storybook" }
```
Flow:
1. Load project from PostgreSQL
2. Find the app's `coolifyServiceUuid`
3. Call `deployApplication(uuid)` from `lib/coolify.ts`
4. Return deployment UUID
---
### 6. AI Chat — Project Context Injection
**What:** The existing `app/api/ai/chat/route.ts` handles Gemini chat. It needs to inject monorepo structure context when a `projectId` is present in the request.
**File to update:** `vibn-frontend/app/api/ai/chat/route.ts`
**Add to chat request handling:**
- Accept optional `projectId`
- When present, load the project from PostgreSQL
- Inject into the system prompt:
- Project name, slug, repo URL
- List of apps and their domains
- Shared packages available
- Turbo version and build command pattern
- Add two new Gemini tools:
- `deploy_app` — triggers `POST /api/projects/{projectId}/deploy`
- `scaffold_app` — adds a new app folder to the monorepo via Gitea contents API
---
## Implementation Order
| Step | Task | File | Depends On |
|------|------|------|-----------|
| 1 | Move scaffold templates into `vibn-frontend/lib/scaffold/` | `lib/scaffold/turborepo/**` | — |
| 2 | Update project creation to push scaffold | `app/api/projects/create/route.ts` | Step 1 |
| 3 | Add app tracking fields to project data | `app/api/projects/create/route.ts` | Step 2 |
| 4 | Add `createMonorepoAppService` to Coolify lib | `lib/coolify.ts` | — |
| 5 | Wire Coolify per-app provisioning into project creation | `app/api/projects/create/route.ts` | Steps 3, 4 |
| 6 | Add deploy route | `app/api/projects/[projectId]/deploy/route.ts` | Step 4 |
| 7 | Inject monorepo context into AI chat | `app/api/ai/chat/route.ts` | Step 3 |
---
## What Does Not Change
- Gitea as source control — same, one repo per project (already the case)
- Coolify as deployment host — same, extended with per-app services
- NextAuth for auth — unchanged
- PostgreSQL + JSONB for project storage — unchanged
- `lib/gitea.ts` and `lib/coolify.ts` — extended, not replaced
- No Vercel dependency anywhere

View File

@@ -1,501 +0,0 @@
# vibn — Product Requirements Document
**Version:** 1.0
**Date:** March 2026
**Author:** Mark Henderson / Atlas AI
**Status:** Draft
---
## 1. Executive Summary
vibn is a template-first SaaS product builder for non-technical founders. It turns a product idea into a fully deployed, live web application — without writing code. Users describe their idea through a guided 6-phase wizard (Discover → Architect → Design → Market → Build), and vibn's AI agents scaffold, build, and deploy the product onto the user's own self-hosted infrastructure (Gitea + Coolify). vibn is positioned as "Shopify for building software": opinionated, template-driven, and designed to dramatically reduce failure rates compared to blank-page AI coding tools. The target customer is a non-technical or low-technical founder who has a validated idea and wants to get to a live product and first paying user in under 72 hours.
---
## 2. Problem Statement
**The problem:** Non-technical founders cannot build software products without hiring developers or becoming one themselves. Existing AI coding tools (Cursor, Replit, v0) assume technical literacy. General-purpose AI (ChatGPT) produces code snippets that can't be deployed. Developer agencies cost $50200k and take 612 months. The gap between "I have a great idea" and "I have a live product" remains enormous.
**Who experiences it:** Solo founders, domain experts (lawyers, trainers, consultants, operators) who want to productize a service, career changers, and micro-agencies wanting to scale client delivery without headcount.
**What they do today instead:**
- Hire a freelance developer (slow, expensive, dependency risk)
- Use no-code tools like Bubble or Webflow (limited, technical ceiling, hard to customize)
- Try to learn to code (fails 90%+ of the time for non-native coders)
- Sit on the idea indefinitely
**Why current alternatives fall short:**
- Bubble/Webflow: Hit a wall as soon as real backend logic is needed; proprietary and not portable
- AI coding tools: Require knowing what to ask, how to debug, how to deploy — the hard parts remain
- Agencies: Take too long, cost too much, and the founder loses control
- Hiring: Creates single-point-of-failure dependency
---
## 3. Vision & Success Metrics
**Vision:** vibn is the fastest path from idea to live product for anyone who can describe what they want. It removes every technical barrier between a non-technical founder and a running SaaS — planning, building, deploying, and marketing — while keeping the user in control and the infrastructure on their own servers.
**Success metrics (v1, 6-month targets):**
| Metric | Target |
|---|---|
| Time from signup to deployed app | < 72 hours (median) |
| % of builds that deploy successfully on first attempt | > 85% |
| Monthly active builders | 500 |
| Projects reaching "live" status | 200 |
| Net Revenue Retention (NRR) | > 100% |
| Gross margin | > 65% |
| Paying customers at 6 months | 150 |
**Key milestones:**
- Month 1: Private beta with 10 hand-selected founders
- Month 2: 50 projects initiated, first 20 live
- Month 3: Public waitlist open, payment enabled
- Month 6: Self-serve onboarding, 150 paying customers
---
## 4. Target Users & Personas
### Persona A — The Non-Technical Founder ("The Builder")
- **Who:** A domain expert (ex: fitness coach, lawyer, ops manager) who has identified a software problem in their industry. No coding background. Has validated the idea informally with peers.
- **Primary goal:** Go from idea to a working product they can show to real users and start charging for.
- **Pain points:** Doesn't know where to start technically; has been burned by developers before; doesn't trust no-code tools for "real" products; overwhelmed by choices.
- **Happy path:** Describes idea in the Discover phase → reviews and approves architecture → picks a visual style → sets brand voice → hits "Build" → shares a live URL within 48 hours.
- **What they value:** Speed, control, clarity. They want to see something real, not a mock.
### Persona B — The Micro-Agency Operator ("The Producer")
- **Who:** A freelancer or small agency (15 people) that builds web products for clients. Currently using developers or outsourcing. Wants to deliver faster and at higher margin.
- **Primary goal:** Build client products in days, not months. Manage multiple projects from one dashboard. Bill clients for AI compute costs with markup.
- **Pain points:** Hiring developers is expensive and slow. Coordinating freelancers is painful. Margins are thin. Can't take on more work without more headcount.
- **Happy path:** Creates a new client project → walks through wizard on behalf of client → client reviews and approves → vibn builds and deploys → operator bills client with AI cost markup shown.
- **What they value:** Speed, multi-project management, billing visibility, client-presentable output.
### Permissions Matrix
| Capability | Builder (own project) | Producer (client project) |
|---|---|---|
| Create project | ✓ | ✓ |
| Run wizard phases | ✓ | ✓ |
| Trigger build | ✓ | ✓ |
| View live app URL | ✓ | ✓ |
| View cost breakdown | Own costs only | Full client cost breakdown |
| Bill client | — | ✓ |
| Manage custom domain | ✓ | ✓ |
| Access Gitea repo | ✓ | ✓ |
| Request changes post-launch | ✓ | ✓ |
---
## 5. User Flows & Journeys
### Primary Flow — New Builder (Non-Technical Founder)
1. Lands on vibn marketing site (`vibn.app`)
2. Clicks "Get started free" → enters email
3. Completes **Welcome phase**: sees 5-step overview of what vibn does, clicks "Let's build it"
4. **Discover phase**: guided 6-question chat conversation — idea, problem, users, value, revenue, features. Sees live PRD panel filling in as they answer. Continues when all 6 answered.
5. **Architect phase**: Reviews AI-generated architecture (frontend, backend, auth, payments, email, hosting). Each block shows the chosen option and why. Can edit any block. Confirms with "Plan looks good — next: Design".
6. **Design phase**: Picks visual feel from 6 presets (Clean, Bold, Warm, Fresh, Electric, Luxury). Sees live mock of their app updating in real time.
7. **Market phase**: Sets brand voice (sliders for tone, style, personality). Reviews and edits 3 AI-generated content topics. Previews their marketing website style.
8. **Build phase**: Reviews full summary (auth, payments, email, style, website, topics, pages). Clicks "Build my MVP". Watches 12-step live build progress. Receives live URL + Gitea repo link.
9. Redirected to **Dashboard** — sees project as "Live" with URL, stats, and action buttons.
### Secondary Flow — Returning User (Dashboard → Change Request)
1. Logs in → lands on Dashboard (projects screen)
2. Selects an existing project → clicks "Build" or "Grow"
3. Enters the relevant phase of the wizard in edit mode
4. Makes changes → re-triggers partial build
5. Returns to Dashboard, sees updated deployment
### Secondary Flow — Agency Producer (Client Project)
1. Logs in → clicks "+ New project"
2. Tags project as "Client" and enters client name
3. Walks through wizard as normal (can be done with client present or on their behalf)
4. After build: sees project card with "Client" tag, cost breakdown, and "Bill →" button
5. Clicks "Bill →" → generates itemized invoice (LLM costs + compute + markup)
6. Views unbilled total across all clients in Billing screen
### Onboarding Flow
1. Email signup → verify email
2. Welcome wizard (Welcome phase of builder)
3. First project created automatically — user is never left on an empty dashboard
4. If user exits mid-wizard, project is saved as draft and resumed on next login
### Error / Recovery Flows
- **Build fails mid-way:** User sees which step failed, error plain-English explanation, and "Retry" button. Failed build does not charge full credits.
- **Payment setup missing:** If user chose Stripe billing in Architect but hasn't connected Stripe, they're prompted before Build is triggered.
- **Custom domain fails DNS:** In-app guide walks through DNS setup; app is still live on vibn subdomain in the meantime.
- **User exits mid-wizard:** Progress is auto-saved per phase. Resumable from Dashboard.
---
## 6. Feature Requirements
### 6.1 Must Have (v1 Launch)
**Builder Wizard — 6-Phase Flow**
- *Description:* The core product experience. A sequential, guided wizard that takes a user from idea to deployed product.
- *User story:* As a non-technical founder, I want to answer plain-English questions and have AI figure out the architecture, code, and deployment — so I never have to think about technical choices.
- *Acceptance criteria:* All 6 phases completable end-to-end. Progress saved between sessions. Each phase produces a visible artifact (PRD, architecture plan, design preview, etc.).
**Discover Phase — Conversational PRD Builder**
- *Description:* 6-question guided chat. Each answer populates a live PRD panel. AI synthesizes answers into a structured product plan.
- *Acceptance criteria:* All 6 questions answered before proceeding. PRD panel shows structured output per question. "Plan looks good" CTA advances to next phase.
**Architect Phase — Architecture Selection**
- *Description:* AI proposes 6 architecture blocks (Frontend, Backend, Auth, Payments, Email, Hosting). Each block is explainable in plain English and editable.
- *Acceptance criteria:* All 6 blocks shown with default selection and rationale. User can change any block via dropdown/modal. Hosting block is locked to self-hosted (Coolify + Gitea). Pages list shown.
**Design Phase — Visual Feel Picker**
- *Description:* 6 visual presets. Selecting a preset updates a live app mock in real time.
- *Acceptance criteria:* 6 presets rendered correctly. Live mock updates within 300ms of selection. Continue CTA available once selection made.
**Market Phase — Voice + Topics + Website**
- *Description:* Brand voice sliders (tone, style, personality). AI-generated content topics (add/edit/remove). Website style picker with live preview.
- *Acceptance criteria:* Voice sliders affect AI content generation downstream. Topics editable with add/remove. Website preview updates with style selection.
**Build Phase — Review + Deploy**
- *Description:* Full summary of all decisions. "Build my MVP" button triggers 12-step build pipeline. Live progress shown. On completion: app URL + Gitea link.
- *Acceptance criteria:* All decisions shown accurately from prior phases. Build progress shows step-by-step status. On success: live URL displayed and functional. On failure: clear error + retry option.
**Dashboard — Projects View**
- *Description:* Home screen after login. Shows all projects with status, basic stats, and actions.
- *Acceptance criteria:* Projects shown as cards with status (Live/Building), URL, and key stats (visitors, signups, MRR). "Continue building" for in-progress builds. "+ New project" creates a new wizard session.
**Dashboard — Billing View (Agency)**
- *Description:* Client billing tab showing unbilled costs by client, LLM/compute/other breakdown, invoice generation.
- *Acceptance criteria:* Unbilled totals accurate. "Bill →" generates invoice. Cost log shows itemized charges.
**Authentication**
- *Description:* Email-based signup/login for the vibn platform itself.
- *Acceptance criteria:* Email + password signup. Email verification required. Forgot password flow. Session persists across browser restarts.
**Deployment Integration (Coolify + Gitea)**
- *Description:* Every built project is pushed to user's Gitea repo and deployed via Coolify automatically.
- *Acceptance criteria:* Gitea repo created on build start. Code committed on completion. Coolify deploy triggered automatically. App live on `[project].vibn.app` subdomain.
**Floating AI Chat (Assist)**
- *Description:* Phase-aware chat assistant available throughout the builder wizard. Persists across phase navigation.
- *Acceptance criteria:* Chat available from Discover through Build phases. Phase-specific starter suggestions. Chat history persists across phase changes. Does not reset on navigation.
---
### 6.2 Should Have (Fast Follow — Months 23)
**Custom Domain Support**
- Users can connect their own domain to a deployed project.
- In-app DNS setup guide. SSL auto-provisioned via Coolify.
**Post-Build Change Requests**
- Users can request changes to their live product in plain English.
- AI interprets, diffs the codebase, applies change, redeploys.
**Marketing Autopilot**
- AI generates and schedules blog posts, email newsletters, and social content based on topics defined in Market phase.
- Initial manual approval required; can be set to auto-publish.
**Credit Usage Display**
- Show real-time credit consumption during builds.
- Warn before triggering tasks estimated to cost > X credits.
- User-configurable spending cap per project.
**Template Marketplace Access**
- Starter templates browsable before creating a project.
- Template selection sets pre-configured architecture defaults.
---
### 6.3 Could Have (Future — Months 46)
**Client-Facing Project Portal**
- Agency clients can log in to review progress, approve phases, and view their live app — without accessing the vibn dashboard directly.
**Stripe Connect for Invoice Payment**
- Agency operators can receive payment from clients directly via vibn.
**Analytics Dashboard (per project)**
- Built-in lightweight analytics (page views, signups, MRR) sourced from the deployed app's database.
**Invite Team Members**
- Multiple vibn users can collaborate on a single project.
**Mobile App (iOS/Android)**
- Native app for monitoring live projects and approving content scheduled by marketing autopilot.
**Template Marketplace (Sell/Buy)**
- Third-party developers can submit templates; users can purchase premium templates.
---
### 6.4 Explicitly Out of Scope (v1)
| Feature | Reason excluded |
|---|---|
| Mobile app (iOS/Android) builder output | All v1 builds are web apps; native app generation is a later capability |
| Real-time multi-user collaboration on wizard | Single-user flow only in v1; collaboration is v2 |
| Self-hosting vibn itself (white-label) | Not offered in v1; Enterprise tier future consideration |
| AI voice/video generation | Out of scope; vibn generates text and code only |
| Direct Stripe Connect marketplace | Invoice workflow is manual export only in v1 |
| Custom AI model selection by users | Model routing is automatic; users do not choose models |
| Offline/desktop app | Web-only |
| HIPAA / SOC2 compliance | Out of scope for v1; required before any healthcare customers |
---
## 7. Screen-by-Screen Specification
### 7.1 Marketing Website (`vibn.app`)
- **Purpose:** Acquire non-technical founders. Convert to "Get started free" or "Log in".
- **Key elements:** Hero headline ("You have the idea. We handle everything else."), 5-step how-it-works, pull quotes from 3 founders, stats bar (280+ launched, 72h avg, 4.9 rating), empathy section, final CTA.
- **Actions:** Get started free → Welcome wizard. Log in → Dashboard.
- **Notes:** Lora serif + Inter sans, ink/parchment palette. No color accents.
### 7.2 Welcome Phase
- **Purpose:** Orient the user, set expectations, build confidence.
- **Key elements:** 5-step overview of the vibn process. "Let's build it →" CTA. Tagline: "From idea to live product. No code needed."
- **Actions:** "Let's build it" → Discover phase.
### 7.3 Builder Sidebar (phases 26)
- **Purpose:** Persistent navigation and progress tracking during the wizard.
- **Key elements:** vibn logo. Progress checklist (Product plan, Architecture, Product design, Marketing). Phase nav (Discover, Architect, Design, Market, Build MVP). User avatar + name + plan at bottom.
- **Notes:** Sidebar is hidden on Welcome and Website screens. Always visible during builder phases.
### 7.4 Discover Phase
- **Purpose:** Capture the product idea as structured data. Output: PRD.
- **Key elements (left panel):** Phase header, progress bar across 6 questions, AI message bubble per question, user input field.
- **Key elements (right panel):** "Your Product Plan" — live-updating sections: Idea, Problem, Users, Value, Revenue, Features. Each fills in as answered.
- **Actions:** User types answers. AI asks follow-up. After 6 questions: "Plan looks good — next: Architect →" CTA.
### 7.5 Architect Phase
- **Purpose:** Let user review and confirm the technical architecture in plain English.
- **Key elements (center):** Phase header. 6 architecture blocks as horizontal-scrollable cards (Frontend, Backend, Auth, Payments, Email, Hosting). Each card shows: icon, chosen option, plain-English explanation, "Change →" button. "Why?" expandable for each block. Infra note (Coolify + Gitea).
- **Key elements (right panel):** "Pages to Build" — grouped by Public, Auth, App, Payments.
- **Actions:** "Change →" opens selection modal with 24 alternatives per block. "Confirm — next: Design →" CTA.
### 7.6 Design Phase
- **Purpose:** Choose a visual style for the product.
- **Key elements (left):** 6 feel cards (Clean, Bold, Warm, Fresh, Electric, Luxury) — each with label, reference product, and color/style preview.
- **Key elements (right):** Live app mock that updates to reflect selected feel. Shows a plausible dashboard UI in that style.
- **Actions:** Click a feel card → mock updates. "Next: Market →" CTA.
### 7.7 Market Phase — Voice Tab
- **Purpose:** Set the brand voice for AI-generated content.
- **Key elements:** 3 slider pairs: Tone (Friendly ↔ Professional), Style (Conversational ↔ Precise), Personality (Warm ↔ Direct). "Voice preview" section shows how the brand would introduce itself.
- **Actions:** Sliders adjust in real time. Tab switches to Topics or Website.
### 7.8 Market Phase — Topics Tab
- **Purpose:** Define the content topics AI will generate and publish.
- **Key elements:** 3 pre-generated topic cards (title, angle, channels). Each editable. "Add topic" button. Remove button per card.
- **Actions:** Edit, add, remove topics. "Next: Website →" tab.
### 7.9 Market Phase — Website Tab
- **Purpose:** Choose the marketing website visual style.
- **Key elements:** 4 website style options (Editorial, Startup Energy, Ultra Minimal, Warm & Human). Live website preview panel updates on selection.
- **Actions:** Click style → preview updates. "Plan looks good — next: Build →" CTA.
### 7.10 Build Phase — Review Screen
- **Purpose:** Final review before triggering the build.
- **Key elements:** Summary grid (Auth, Payments, Email, Product Style, Website Style, Campaign Topics). Pages list (by group). Infra deployment note. "▲ Build my MVP" button. Disclaimer: ~15 minutes, refinable after launch.
- **Actions:** "Build my MVP" → transitions to Build Progress screen.
### 7.11 Build Phase — Progress Screen
- **Purpose:** Show real-time build progress.
- **Key elements:** 12-step checklist with: completed steps (green checkmark), active step (animated indicator), pending steps (grey). Step label + detail line. Progress header showing step count.
- **On completion:** "Your MVP is live" screen — app URL ("Open my app ↗"), Gitea link ("View in Gitea ↗"), "Your next 3 actions" card.
### 7.12 Dashboard — Projects Screen
- **Purpose:** Manage all projects from one place.
- **Key elements:** "Your projects" header with count. Unbilled total button (if agency projects exist). "+ New project" button. Project cards (2-column grid): status thumbnail, project identity (name, URL, client if applicable), status pill (Live/Building), cost strip (client projects), stats (visitors, signups, MRR), action buttons (Build, Grow, ↗). New project CTA card (dashed border, "+" icon).
- **Activity feed:** Recent events across all projects (content published, new signups, build events).
### 7.13 Dashboard — Billing Screen (Client Billing tab)
- **Purpose:** Manage invoicing for agency operators.
- **Key elements:** Summary stats (total unbilled, LLM costs, compute, other). Billing table (by client, by month). Each row: project, LLM, compute, other, total, status pill. "Invoice" button per unbilled row. "Generate invoice" button (global).
### 7.14 Dashboard — Billing Screen (Cost Tracker tab)
- **Purpose:** Understand AI and infrastructure cost breakdown.
- **Key elements:** LLM usage breakdown (code gen, content, chat assist) with bar charts. Infrastructure breakdown (hosting, database, email, domain). Recent charges log (time, description, project, cost).
### 7.15 Floating AI Chat (Assist)
- **Purpose:** On-demand AI help throughout the wizard.
- **Key elements:** Dark header with "Assist · [phase]" + live green dot. Message thread (user + assistant bubbles). Phase-specific starter suggestions (3 clickable). Input field + send button.
- **Behavior:** Persists open/closed state and message history across phase changes. Accessible via 💬 bubble button at bottom right.
---
## 8. Business Model & Pricing
### Revenue Model
**Subscription + Credits** (not unlimited AI)
The subscription covers fixed platform value (infrastructure orchestration, templates, UX, dashboard, Gitea/Coolify integration, team ops). Credits cover variable AI compute costs (LLM calls across Tier A/B/C, build pipelines, content generation).
### Pricing Tiers
| Tier | Price | Templates | Projects | Credits included | Target |
|---|---|---|---|---|---|
| **Free** | $0/mo | Starter only | 1 active | 50 credits/mo | Evaluators |
| **Builder** | $49/mo | Starter + Builder | 3 active | 500 credits/mo | Solo founders |
| **Pro** | $149/mo | All templates | Unlimited | 2,000 credits/mo | Active builders + agencies |
| **Enterprise** | Custom | Custom + private | Unlimited | Custom | Teams, compliance needs |
**Credit top-ups:** Available at $0.10/credit (10 credits = $1). Minimum top-up: $10.
### AI Cost Structure (Internal)
Three-tier model routing:
- **Tier A (40% of calls):** Gemini Flash-class — orchestration, summaries, routing, log parsing. ~$0.0001/1k tokens.
- **Tier B (45% of calls):** Mid-tier coding model (GLM-5 or Qwen Coder via Vertex) — code gen, feature building, refactors. ~$0.002/1k tokens.
- **Tier C (15% of calls):** Premium escalation (Claude Sonnet or Gemini Pro) — architecture decisions, high-risk changes, repeated failures. ~$0.015/1k tokens.
**Credit pricing:** Each credit = approximately $0.10 of platform value (AI + margin). Exact credit cost per action surfaced to user before triggering high-cost tasks.
### Cost Estimate Per Build (v1 template-based app)
| Item | Estimated cost |
|---|---|
| Discover/Architect/Design/Market phases (Tier A/B) | ~$0.80 |
| Full code generation (Tier B, ~8,000 LOC) | ~$2.40 |
| Deployment orchestration | ~$0.20 |
| **Total per build** | **~$3.40** |
| **Charged at markup** | **~40 credits ($4.00)** |
At $49/mo (500 credits), a Builder subscriber can complete ~12 full builds per month within plan.
---
## 9. Integrations & External Dependencies
| Integration | Purpose | Notes |
|---|---|---|
| **Gitea (self-hosted)** | Code storage and version control for every built project | Required. All repos pushed here on build completion. |
| **Coolify (self-hosted)** | Build pipeline, deployment, container orchestration | Required. Auto-deploys on Gitea push. |
| **Google Vertex AI** | Tier A/B/C model calls | Primary AI provider. Gemini Flash (A), mid-tier MaaS (B), Claude/Gemini Pro (C). |
| **Stripe** | Subscription billing for vibn platform fees | Customers pay vibn via Stripe. Stripe not required in built apps unless user selects it in Architect. |
| **Resend / Postmark** | Transactional emails (signup, password reset, notifications) | For vibn platform emails. Built apps may use same if email selected in Architect. |
| **PostgreSQL** | Platform database (conversations, project state, tasks, billing) | Self-hosted in hot tier. |
| **Redis** | Job queue, pubsub for build pipeline events | Optional but recommended for build reliability. |
**No external data import requirements in v1.** Built apps start fresh; no migration tooling in scope.
---
## 10. Non-Functional Requirements
### Performance
- Wizard phase transitions: < 200ms
- Live design mock updates: < 300ms after style selection
- Build pipeline: Median < 15 minutes for a template-based app
- Dashboard load: < 1 second (projects list)
- AI chat response: First token within 1 second
### Platform
- **Primary:** Web (desktop browser) — Chrome, Safari, Firefox, Edge
- **Secondary:** Responsive mobile web for dashboard viewing (not wizard)
- **Not in scope v1:** Native iOS/Android apps
### Accessibility
- WCAG 2.1 AA compliance for all interactive elements
- Keyboard navigable wizard phases
- Sufficient color contrast across all design tokens (ink on paper palette passes AA)
### Compliance & Regulatory
- **GDPR:** Data processing agreements available for EU users. User data deletable on request.
- **PCI DSS:** vibn does not store card data; handled entirely by Stripe.
- **HIPAA:** Out of scope for v1. No healthcare data processed.
- **SOC 2:** Target for Enterprise tier; not required at launch.
### Data Privacy & Security
- All user project code stored in user's own Gitea instance (user owns their data)
- vibn platform database stores: conversation history, project metadata, billing records
- AI conversations not used for model training (Vertex API terms)
- Secrets (API keys, Stripe keys) stored encrypted, never logged
- Build logs retained for 30 days, then purged
### Scalability Assumptions (v1)
- Designed for 500 MAU at launch
- Build pipeline: 20 concurrent builds supported
- Horizontal scaling of worker pool via Coolify
---
## 11. Risks & Mitigations
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Build success rate < 85% due to AI code quality | Medium | High | Template-first architecture dramatically reduces open-ended generation. Fallback retry mechanism. Tiered escalation to better model on repeated failure. |
| LLM costs exceed credit pricing margins | Medium | High | 3-tier routing keeps 85% of calls on cheap models. Per-step token limits. Aggressive context summarization. Max retries cap (3). |
| Users don't understand "credits" model | High | Medium | In-app cost estimation before every build. Plain-English explanations. "This build will use ~40 credits." Spending caps user-configurable. |
| Coolify/Gitea self-hosted infra reliability | Low | High | Hot tier always-on. Healthcheck monitoring. Auto-restart policies. Graceful failure messaging in build UI. |
| Non-technical users abandon wizard mid-way | High | Medium | Progress auto-saved per phase. Resume from dashboard. Floating AI chat for unblocking. Encourage "good enough" answers — no wrong answers in Discover. |
| Scope creep in wizard phases | Medium | Medium | Each phase has a strict set of decisions. No free-form architecture input. Locked hosting block prevents deviation. |
| Competition from Replit, Bolt, v0 | High | Medium | Differentiator is self-hosted infra (user owns everything), template-first (higher success rate), and the end-to-end wizard (no coding literacy required). |
| Agency use case underperforms | Low | Low | Agency (Producer persona) is v1 secondary target. Builder persona is primary. Billing screen can be iterated post-launch. |
---
## 12. Open Questions & Assumptions
### Open Questions
1. **Template library scope at launch:** How many starter templates exist at v1 launch? What are they? (Minimum: SaaS CRUD + landing page. What else?)
2. **Subdomain structure:** Are projects deployed to `[project-name].vibn.app` or `[user-slug]-[project].vibn.app`? (Collision risk if single namespace.)
3. **Build pipeline timing:** Is 15-minute median build time achievable for first template? What's the P95?
4. **Gitea/Coolify provisioning:** Is each user getting their own Gitea org? How are Coolify environments namespaced per user?
5. **Free tier limits:** Should free tier require a credit card? (Conversion vs. abuse risk tradeoff.)
6. **Change requests post-launch:** How are iterative changes billed? Per-change credit cost, or separate workflow?
7. **Marketing autopilot publishing:** In v1, does AI content require manual approval before publishing, or is auto-publish available?
8. **Wizard re-entry:** Can a user go back and redo an earlier phase after completing Build? Does this trigger a rebuild?
### Assumptions Made
- vibn's Gitea and Coolify infrastructure are already operational and stable before v1 user onboarding begins.
- Template-based builds (vs. blank-page builds) keep success rates above 85%.
- Non-technical founders are willing to pay $49$149/month for a solution that reliably delivers a live product.
- The 6-phase wizard is completable in one sitting (~2030 minutes) for a user with a clear idea.
- Vertex AI API access and model availability (Gemini Flash, mid-tier MaaS) is stable and within budget.
- Users do not need to understand or manage their Gitea/Coolify infrastructure directly — vibn abstracts it entirely.
- The primary acquisition channel for v1 is content marketing and founder communities (not paid ads).
---
## 13. Appendix
### Glossary
| Term | Definition |
|---|---|
| **Build** | The automated process of AI generating code, committing to Gitea, and deploying via Coolify |
| **Wizard** | The 6-phase guided flow: Discover → Architect → Design → Market → Build |
| **Phase** | A single stage of the wizard, each producing a specific artifact |
| **Template** | A pre-built starter codebase that vibn AI builds upon instead of generating from scratch |
| **Credits** | vibn's unit of AI compute consumption; consumed during builds, content generation, and chat |
| **Hot tier** | Always-running shared infrastructure (API gateway, orchestrator, Postgres, Redis, Gitea, Coolify) |
| **Cold tier** | Per-user on-demand containers (agent workspace instances, hibernated when inactive) |
| **Tier A/B/C** | Three levels of AI model quality/cost, automatically routed by the orchestrator based on task complexity |
| **Producer** | A vibn user building products for clients (agency use case) |
| **Builder** | A vibn user building a product for themselves (founder use case) |
| **PRD** | Product Requirements Document — the structured output of the Discover phase |
| **Gitea** | Self-hosted open-source Git service; stores all project codebases |
| **Coolify** | Self-hosted deployment platform; builds and runs all deployed apps |
### Reference Materials
- Product strategy document: `product-idea-a.md`
- Builder wizard UI prototype: `preview-assist-ui/src/App.jsx`
- Marketing website prototype: `preview-assist-ui/src/Website.jsx`
- Dashboard prototype: `preview-assist-ui/src/Dashboard.jsx`
- PRD agent system prompt: `prd-agent-prompt.pdf`
### Competitor Reference
- **Bolt.new / Lovable:** AI coding from scratch; no deployment, no templates, requires iteration by user
- **Replit:** Strong coding environment; technical literacy required; no guided wizard
- **Webflow:** No-code UI builder; no real backend; visual but limited
- **Bubble:** No-code with backend; steep learning curve; proprietary lock-in
- **v0 (Vercel):** UI generation only; no deployment, no product planning
- **Agencies:** Custom development; 612 month timelines; $50k$200k budgets

Binary file not shown.

View File

@@ -1,409 +0,0 @@
VibnAI Plan Summary — “Shopify Template Model” + Your Infra + Model Routing + Pricing
Below is the consolidated plan weve converged on: VibnAI as a template-first product builder (Shopify-style), with your own hosted infra, and usage-based AI credits powered by Vertex marketplace models with smart routing.
1) Product Strategy: VibnAI Is Shopify for Building Software
Core positioning
VibnAI is not “blank page AI coding.”
VibnAI is:
Build production-ready apps from elite starter templates
then customize via guided AI workflows.
This reduces:
token burn
failure loops
architectural ambiguity
debugging chaos
And increases:
predictability
success rate
margins
retention
Template-first rule
No project starts from an empty repo by default.
Users must choose:
a starter template, or
“Advanced: Custom Build” (explicitly warned as costlier)
2) Platform Architecture: Your Infra + Event-Driven AI
High-level architecture decisions
You host the infrastructure layer yourself (Hot + Cold tiers). AI compute is purchased via credits.
Hot tier (shared, always running)
API Gateway (auth, WebSockets, rate limits)
Orchestrator service (task routing + state machine)
Job queue + worker pool
Postgres (conversations, tasks, state)
Redis (optional: queue/pubsub)
Gitea (code/content source-of-truth)
Coolify (deploys, logs, runtime orchestration)
Key rule: The hot tier is always on, but it should be cheap to run because it is mostly event-driven and does not constantly call expensive models.
Cold tier (per-user, on-demand)
Agent workspace containers
Hibernate / wake-on-access
Persistent storage volumes
“Master Orchestrator” behavior change (critical cost control)
Even if its “always running,” it should behave like:
event-driven
stateless compute
minimal model calls
structured memory, not replaying chat history
Structured memory > conversation replay
Instead of resending entire conversation history, persist and inject:
project summary
architecture summary
repo map summary
deploy state
open tasks
known bugs
This is a major cost reducer.
3) AI Model Strategy: 3-Tier Routing (Cost-Efficient Orchestration)
Youre building your own agents, but the principle applies: choose models per tool/task.
Tier A / Tier B / Tier C (the blend)
We landed on this operational blend:
40% Tier A (cheap)
45% Tier B (mid / workhorse coder)
15% Tier C (premium escalation)
This is not arbitrary—it aligns with tool/task reality:
most actions are parsing, routing, search, summarizing (cheap)
most code edits and implementations are workhorse coding (mid)
only a small fraction require deep reasoning / high-stakes decisions (premium)
Tier purpose
Tier A — Cheap “Utility / Router”
Use for:
routing decisions
summarizing logs, errors, context
file discovery + search interpretation
command suggestion drafts
task context updates
chat summaries / naming
monitoring analysis
This tier should handle the majority of orchestration.
Tier B — Workhorse Coding Model
Use for:
generating diffs
writing/refactoring code
tests
standard bug fixes
“agent mode” loops when tasks are scoped
iterating on features inside templates
This tier should handle most coding.
Tier C — Premium Escalation Model
Use only when:
architecture decisions
high-risk changes (deploy, infra, migrations)
cross-service debugging
persistent failures (2 failed iterations)
very large diffs / multi-file refactors
security-sensitive changes
This tier should be rare by design.
4) Vertex Models: What to Use in Each Tier
You wanted to stay on Google infra and Vertex marketplace/API models.
Recommended mapping (Vertex-first)
Tier A (cheap)
Gemini Flash-class model (fast, low cost)
Use for orchestration, summaries, extraction, routing, log parsing.
Tier B (mid / coding workhorse)
Pick one:
GLM-5 MaaS (Vertex) — strong reasoning + cost-effective
Qwen coder MaaS (Vertex) — strong coding, predictable cost
This model does the heavy lifting for code edits and feature building.
Tier C (premium escalation)
Pick one:
Claude Sonnet 4.6 on Vertex (reliability + long-chain coding)
or Gemini 3.1 Pro Preview (if it proves better for your workflows)
This is your “expert brain” used sparingly.
5) Routing Policy: How the System Chooses Models
Youre not letting users pick models manually. The orchestrator routes based on task complexity and risk.
Default rules
All “read/search/list/summarize” → Tier A
Most code edits/refactors/tests → Tier B
High-risk or repeated failure → Tier C
Escalation triggers (simple + effective)
Escalate Tier B → Tier C when any of these happen:
2 failed iterations (tests still failing, same error persists)
Touching >5 files
Diff size exceeds ~400 LOC changed
Deployment / infra / secrets / migration steps involved
Context pressure (approaching model limits)
De-escalation rule
Once the hard part is resolved (cause found / plan decided), drop back to Tier B for implementation.
6) Business Model: Subscription + Credits (Not “Unlimited AI”)
You clarified the intended split:
Subscription covers your fixed costs
Subscription pays for:
your hosted infrastructure (hot tier + shared services)
Agent workspace orchestration (cold tier)
your people costs (support, ops, ongoing development)
product value (templates, UX, dashboards, workflows)
baseline included usage / small AI overhead
Credits cover variable compute
Credits pay for:
model calls (Tier A/B/C)
heavy tasks (builds, refactors, debugging loops)
long chain tasks
autonomous agent execution
This protects you from heavy users and keeps margins predictable.
7) Template Access as a Tiered Product (Shopify-style)
Templates are the moat
Templates reduce:
architecture planning cost
retry loops
token burn
complexity and failure rates
Templates also create:
differentiation
a marketplace opportunity later
compounding margins
Tiering via template access
Instead of just “more AI,” higher tiers unlock better starter systems.
Example approach:
Starter tier
landing page template
simple SaaS CRUD template
basic auth + Stripe
limited integrations
Builder tier
multi-tenant SaaS template
marketplace template
analytics dashboard template
stronger RBAC patterns
more integrations
Pro tier
“OpsOS / analytics warehouse” template
monitoring + alerting template
ML-ready pipeline template
advanced data model scaffolds
Enterprise
custom templates
compliance add-ons
private deployments
dedicated support / SLAs
8) Credit Pricing: Fixed Markup per Model
You said you want:
credits based on user actions, with fixed markup on every model
This implies:
Each model has an internal “true cost”
You charge credits at a consistent markup multiplier
Premium models may have a higher markup (optional), but you can keep it fixed if you prefer simplicity
How it should feel to the user
“This action will cost ~X credits”
“Set a spending cap per day/project”
“Require approval if a task is estimated > Y credits”
This prevents runaway spending and builds trust.
9) Key Risk Controls We Agreed Are Necessary
To make this sellable and safe:
Token and autonomy guardrails
max tokens per step
max retries per task
auto-summarize context aggressively
store structured memory, not chat replay
only send diffs / minimal file slices
caching where possible (especially for repeated prefixes)
UX controls
show credit burn in real time
warn/approve for high-cost tasks
allow user-set budgets
explain why escalation happened (briefly)
10) The End State
VibnAI becomes:
A template-first “product builder OS”
powered by multi-model orchestration
hosted on your infra
with predictable economics via subscription + credits
and a defensible moat via templates + routing intelligence

View File

@@ -1,991 +0,0 @@
import { useState, useEffect, useRef } from "react";
const FontLoader = () => (
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400&family=Outfit:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap');
* { margin:0; padding:0; box-sizing:border-box; }
body { background: #f6f4f0; }
@keyframes enter { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:translateY(0); } }
@keyframes blink { 0%,100%{opacity:.2} 50%{opacity:.8} }
@keyframes breathe { 0%,100%{transform:scale(1)} 50%{transform:scale(1.15)} }
::selection { background: #1a1a1a; color: #fff; }
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #d0ccc4; border-radius: 10px; }
input::placeholder { color: #b5b0a6; }
input:focus, textarea:focus { outline: none; }
button { font-family: 'Outfit', sans-serif; cursor: pointer; }
textarea { font-family: 'Outfit', sans-serif; resize: vertical; }
`}</style>
);
/* ─── DATA ─── */
const projects = [
{ id: 1, name: "Meridian", desc: "Client portal for boutique agencies", status: "building", progress: 68, features: 12, phase: "Frontend Gen", lastActive: "2h ago", color: "#3d5afe", domain: "meridian-app.stackless.dev", repo: "stackless/meridian-build", created: "Jan 12, 2026" },
{ id: 2, name: "Tidepool", desc: "Marine research data platform", status: "prd", progress: 45, features: 7, phase: "Features", lastActive: "20m ago", color: "#00897b", domain: null, repo: null, created: "Feb 3, 2026" },
{ id: 3, name: "Canopy", desc: "Internal team knowledge base", status: "live", progress: 100, features: 18, phase: "Deployed", lastActive: "1d ago", color: "#2e7d32", domain: "canopy.stackless.dev", customDomain: "kb.acmecorp.com", repo: "stackless/canopy-build", created: "Nov 28, 2025" },
{ id: 4, name: "Foxglove", desc: "Prescription mgmt for pharmacies", status: "prd", progress: 20, features: 3, phase: "Discovery", lastActive: "now", color: "#e65100", domain: null, repo: null, created: "Feb 27, 2026" },
];
const activityFeed = [
{ time: "2 min ago", project: "Foxglove", action: "Atlas completed Users & Personas phase", type: "atlas" },
{ time: "18 min ago", project: "Foxglove", action: "You described the core prescription workflow", type: "user" },
{ time: "1h ago", project: "Meridian", action: "Build: Dashboard UI component generated", type: "build" },
{ time: "2h ago", project: "Meridian", action: "Build: Authentication system passed all tests", type: "build" },
{ time: "3h ago", project: "Tidepool", action: "Atlas captured 7 features in MoSCoW framework", type: "atlas" },
{ time: "5h ago", project: "Tidepool", action: "You approved Problem Statement section", type: "user" },
{ time: "8h ago", project: "Meridian", action: "Build: Database schema deployed", type: "build" },
{ time: "1d ago", project: "Canopy", action: "Custom domain kb.acmecorp.com verified and active", type: "deploy" },
{ time: "1d ago", project: "Canopy", action: "v1.2 deployed — added search filters", type: "deploy" },
{ time: "2d ago", project: "Meridian", action: "PRD approved — build pipeline started", type: "deploy" },
{ time: "2d ago", project: "Tidepool", action: "Project created", type: "user" },
{ time: "3d ago", project: "Foxglove", action: "Project created", type: "user" },
];
const chatHistory = [
{ from: "atlas", text: "I see you're building Foxglove — prescription management for small pharmacies. We've locked in the problem statement and identified your primary user.\n\nNow let's map the core workflow. When a pharmacist opens Foxglove first thing in the morning, what do they need to see?" },
{ from: "user", text: "They need to see incoming prescriptions from doctors. The current systems are super clunky. They want to see new scripts, verify them, and mark them as filled." },
{ from: "atlas", text: "Clean workflow. Three stages:\n\n1. Receive — new scripts appear in a queue\n2. Verify — check dosage, interactions, patient history\n3. Fill — mark dispensed, update stock\n\nTwo things I want to nail down: does the pharmacist need to message the prescribing doctor back through Foxglove if there's a dosage flag? And are we building for single-location pharmacies or chains with 23 stores?" },
];
const prdData = [
{ name: "Executive Summary", status: "done", pct: 100 },
{ name: "Problem Statement", status: "done", pct: 100 },
{ name: "Users & Personas", status: "done", pct: 100 },
{ name: "User Flows", status: "active", pct: 60 },
{ name: "Feature Requirements", status: "pending", pct: 20 },
{ name: "Screen Specs", status: "pending", pct: 0 },
{ name: "Business Model", status: "pending", pct: 0 },
{ name: "Non-Functional Reqs", status: "pending", pct: 0 },
{ name: "Risks", status: "pending", pct: 0 },
];
const discoveryPhases = [
{ name: "Big Picture", done: true },
{ name: "Users", done: true },
{ name: "Features", active: true },
{ name: "Business Model" },
{ name: "Screens" },
{ name: "Risks" },
];
/* ─── Micro Components ─── */
const Tag = ({ children, color = "#1a1a1a", bg = "#1a1a1a10" }) => (
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, padding: "3px 9px", borderRadius: 4, fontSize: "0.68rem", fontWeight: 600, letterSpacing: "0.02em", color, background: bg, fontFamily: "Outfit" }}>{children}</span>
);
const StatusDot = ({ status }) => {
const c = status === "live" ? "#2e7d32" : status === "building" ? "#3d5afe" : "#d4a04a";
return <span style={{ width: 7, height: 7, borderRadius: "50%", background: c, display: "inline-block", flexShrink: 0, animation: status === "building" ? "breathe 2.5s ease infinite" : "none" }} />;
};
const SectionLabel = ({ children }) => (
<div style={{ fontSize: "0.6rem", fontWeight: 600, color: "#a09a90", letterSpacing: "0.1em", textTransform: "uppercase", marginBottom: 12 }}>{children}</div>
);
const Card = ({ children, style: s = {}, hover = true, ...rest }) => {
const [hovered, setHovered] = useState(false);
return (
<div
onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)}
style={{ background: "#fff", border: `1px solid ${hovered && hover ? "#d0ccc4" : "#e8e4dc"}`, borderRadius: 10, boxShadow: hovered && hover ? "0 2px 8px #1a1a1a0a" : "0 1px 2px #1a1a1a05", transition: "all 0.15s", ...s }}
{...rest}
>{children}</div>
);
};
const Btn = ({ children, variant = "primary", style: s = {}, ...rest }) => {
const styles = variant === "primary"
? { background: "#1a1a1a", color: "#fff", border: "1px solid #1a1a1a" }
: variant === "secondary"
? { background: "#fff", color: "#1a1a1a", border: "1px solid #e0dcd4" }
: { background: "transparent", color: "#a09a90", border: "1px solid transparent" };
return (
<button style={{ padding: "8px 16px", borderRadius: 7, fontSize: "0.78rem", fontWeight: 600, transition: "opacity 0.15s", ...styles, ...s }}
onMouseEnter={e => e.currentTarget.style.opacity = "0.8"}
onMouseLeave={e => e.currentTarget.style.opacity = "1"}
{...rest}
>{children}</button>
);
};
const InputField = ({ label, value, onChange, placeholder, type = "text", mono = false }) => (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: "0.72rem", fontWeight: 600, color: "#6b6560", marginBottom: 6 }}>{label}</div>
<input type={type} value={value} onChange={onChange} placeholder={placeholder}
style={{ width: "100%", padding: "9px 13px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#faf8f5", fontSize: "0.84rem", fontFamily: mono ? "IBM Plex Mono" : "Outfit", color: "#1a1a1a" }} />
</div>
);
const Toggle = ({ on, onToggle, label }) => (
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "12px 0", borderBottom: "1px solid #f0ece4" }}>
<span style={{ fontSize: "0.84rem", color: "#1a1a1a" }}>{label}</span>
<button onClick={onToggle} style={{
width: 38, height: 22, borderRadius: 11, border: "none", padding: 2,
background: on ? "#1a1a1a" : "#ddd8d0", transition: "background 0.2s",
display: "flex", alignItems: "center",
}}>
<div style={{ width: 18, height: 18, borderRadius: "50%", background: "#fff", transition: "transform 0.2s", transform: on ? "translateX(16px)" : "translateX(0)" }} />
</button>
</div>
);
/* ─── SIDEBAR ─── */
const Sidebar = ({ activeProject, setActiveProject, view, setView }) => (
<nav style={{ width: 220, height: "100vh", background: "#fff", borderRight: "1px solid #e8e4dc", display: "flex", flexDirection: "column", fontFamily: "Outfit, sans-serif", flexShrink: 0 }}>
<div style={{ padding: "22px 18px 18px", display: "flex", alignItems: "center", gap: 9 }}>
<div style={{ width: 28, height: 28, borderRadius: 7, background: "#1a1a1a", display: "flex", alignItems: "center", justifyContent: "center", color: "#fff", fontSize: "0.82rem", fontWeight: 700, fontFamily: "Newsreader, serif" }}>S</div>
<span style={{ fontSize: "0.95rem", fontWeight: 600, color: "#1a1a1a", letterSpacing: "-0.03em" }}>stackless</span>
</div>
<div style={{ padding: "4px 10px" }}>
{[
{ id: "home", label: "Projects", icon: "⌗" },
{ id: "activity", label: "Activity", icon: "↗" },
{ id: "settings", label: "Settings", icon: "⚙" },
].map(n => (
<button key={n.id}
onClick={() => { setView(n.id); setActiveProject(null); }}
style={{
width: "100%", display: "flex", alignItems: "center", gap: 9,
padding: "8px 10px", borderRadius: 6, border: "none",
background: view === n.id && !activeProject ? "#f6f4f0" : "transparent",
color: view === n.id && !activeProject ? "#1a1a1a" : "#6b6560",
fontSize: "0.82rem", fontWeight: view === n.id && !activeProject ? 600 : 500,
transition: "all 0.12s",
}}
onMouseEnter={e => e.currentTarget.style.background = "#f6f4f0"}
onMouseLeave={e => { if (!(view === n.id && !activeProject)) e.currentTarget.style.background = "transparent"; }}
>
<span style={{ fontSize: "0.8rem", opacity: 0.45, width: 18, textAlign: "center" }}>{n.icon}</span>
{n.label}
</button>
))}
</div>
<div style={{ height: 1, background: "#eae6de", margin: "10px 18px" }} />
<div style={{ padding: "2px 10px", flex: 1, overflow: "auto" }}>
<div style={{ fontSize: "0.6rem", fontWeight: 600, color: "#a09a90", letterSpacing: "0.1em", textTransform: "uppercase", padding: "6px 10px 8px" }}>Projects</div>
{projects.map(p => (
<button key={p.id}
onClick={() => { setActiveProject(p); setView("project"); }}
style={{
width: "100%", display: "flex", alignItems: "center", gap: 9,
padding: "7px 10px", borderRadius: 6, border: "none",
background: activeProject?.id === p.id ? "#f6f4f0" : "transparent",
color: "#1a1a1a", fontSize: "0.82rem", fontWeight: activeProject?.id === p.id ? 600 : 450,
transition: "background 0.12s", textAlign: "left",
}}
onMouseEnter={e => e.currentTarget.style.background = "#f6f4f0"}
onMouseLeave={e => { if (activeProject?.id !== p.id) e.currentTarget.style.background = "transparent"; }}
>
<StatusDot status={p.status} />
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.name}</span>
</button>
))}
</div>
<div style={{ padding: "14px 18px", borderTop: "1px solid #eae6de", display: "flex", alignItems: "center", gap: 9 }}>
<div style={{ width: 28, height: 28, borderRadius: "50%", background: "#f0ece4", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.72rem", fontWeight: 600, color: "#8a8478" }}>M</div>
<div>
<div style={{ fontSize: "0.78rem", fontWeight: 500, color: "#1a1a1a" }}>Michael</div>
<div style={{ fontSize: "0.62rem", color: "#a09a90" }}>Pro plan</div>
</div>
</div>
</nav>
);
/* ─── ACTIVITY PAGE ─── */
const ActivityPage = ({ setActiveProject, setView }) => {
const [filter, setFilter] = useState("all");
const filtered = filter === "all" ? activityFeed : activityFeed.filter(a => a.type === filter);
const typeIcon = (t) => t === "atlas" ? "A" : t === "build" ? "⚡" : t === "deploy" ? "▲" : "●";
const typeColor = (t) => t === "atlas" ? "#1a1a1a" : t === "build" ? "#3d5afe" : t === "deploy" ? "#2e7d32" : "#8a8478";
return (
<div style={{ padding: "44px 52px", maxWidth: 720, fontFamily: "Outfit, sans-serif", animation: "enter 0.35s ease both" }}>
<h1 style={{ fontFamily: "Newsreader, serif", fontSize: "1.9rem", fontWeight: 400, color: "#1a1a1a", letterSpacing: "-0.03em", marginBottom: 4 }}>Activity</h1>
<p style={{ fontSize: "0.82rem", color: "#a09a90", marginBottom: 28 }}>Everything happening across your projects</p>
{/* Filters */}
<div style={{ display: "flex", gap: 4, marginBottom: 24 }}>
{[
{ id: "all", label: "All" },
{ id: "atlas", label: "Atlas" },
{ id: "build", label: "Builds" },
{ id: "deploy", label: "Deploys" },
{ id: "user", label: "You" },
].map(f => (
<button key={f.id} onClick={() => setFilter(f.id)}
style={{
padding: "6px 14px", borderRadius: 6, border: "none",
background: filter === f.id ? "#1a1a1a" : "#fff",
color: filter === f.id ? "#fff" : "#6b6560",
fontSize: "0.75rem", fontWeight: 600, transition: "all 0.12s",
}}>{f.label}</button>
))}
</div>
{/* Feed */}
<div style={{ position: "relative", paddingLeft: 24 }}>
{/* Timeline line */}
<div style={{ position: "absolute", left: 8, top: 8, bottom: 8, width: 1, background: "#e8e4dc" }} />
{filtered.map((item, i) => (
<div key={i} style={{
display: "flex", gap: 14, marginBottom: 4, padding: "12px 16px",
animation: `enter 0.3s ease ${i * 0.03}s both`,
borderRadius: 8, transition: "background 0.12s", position: "relative",
}}
onMouseEnter={e => e.currentTarget.style.background = "#fff"}
onMouseLeave={e => e.currentTarget.style.background = "transparent"}
>
{/* Dot on timeline */}
<div style={{
position: "absolute", left: -20, top: 18,
width: 9, height: 9, borderRadius: "50%",
background: typeColor(item.type), border: "2px solid #f6f4f0",
}} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 3 }}>
<button onClick={() => {
const proj = projects.find(p => p.name === item.project);
if (proj) { setActiveProject(proj); setView("project"); }
}} style={{
background: "none", border: "none", padding: 0,
fontSize: "0.82rem", fontWeight: 600, color: "#1a1a1a",
textDecoration: "none",
}}
onMouseEnter={e => e.currentTarget.style.textDecoration = "underline"}
onMouseLeave={e => e.currentTarget.style.textDecoration = "none"}
>{item.project}</button>
<span style={{ fontSize: "0.68rem", color: "#b5b0a6" }}>·</span>
<span style={{ fontSize: "0.72rem", color: "#b5b0a6" }}>{item.time}</span>
</div>
<div style={{ fontSize: "0.82rem", color: "#6b6560", lineHeight: 1.5 }}>{item.action}</div>
</div>
</div>
))}
</div>
</div>
);
};
/* ─── SETTINGS PAGE ─── */
const SettingsPage = () => {
const [settingsTab, setSettingsTab] = useState("account");
const [emailNotifs, setEmailNotifs] = useState(true);
const [buildNotifs, setBuildNotifs] = useState(true);
const [atlasDigest, setAtlasDigest] = useState(false);
const [darkMode, setDarkMode] = useState(false);
return (
<div style={{ display: "flex", height: "100vh", fontFamily: "Outfit, sans-serif", animation: "enter 0.35s ease both" }}>
{/* Settings nav */}
<div style={{ width: 200, padding: "44px 8px 44px 52px", flexShrink: 0 }}>
<h1 style={{ fontFamily: "Newsreader, serif", fontSize: "1.9rem", fontWeight: 400, color: "#1a1a1a", letterSpacing: "-0.03em", marginBottom: 28 }}>Settings</h1>
{[
{ id: "account", label: "Account" },
{ id: "notifications", label: "Notifications" },
{ id: "billing", label: "Plan & Billing" },
{ id: "team", label: "Team" },
{ id: "domains", label: "Domains" },
{ id: "api", label: "API Keys" },
{ id: "danger", label: "Danger Zone" },
].map(t => (
<button key={t.id} onClick={() => setSettingsTab(t.id)}
style={{
display: "block", width: "100%", textAlign: "left",
padding: "7px 12px", borderRadius: 6, border: "none",
background: settingsTab === t.id ? "#fff" : "transparent",
color: settingsTab === t.id ? "#1a1a1a" : "#8a8478",
fontSize: "0.82rem", fontWeight: settingsTab === t.id ? 600 : 450,
marginBottom: 2, transition: "all 0.12s",
boxShadow: settingsTab === t.id ? "0 1px 3px #1a1a1a08" : "none",
}}>{t.label}</button>
))}
</div>
{/* Settings content */}
<div style={{ flex: 1, padding: "44px 52px", overflow: "auto" }}>
{settingsTab === "account" && (
<div style={{ maxWidth: 480, animation: "enter 0.25s ease" }}>
<h2 style={{ fontSize: "1.05rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 4 }}>Account</h2>
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: 28 }}>Manage your profile and preferences</p>
<Card style={{ padding: "24px", marginBottom: 20 }} hover={false}>
<div style={{ display: "flex", alignItems: "center", gap: 16, marginBottom: 24 }}>
<div style={{ width: 52, height: 52, borderRadius: "50%", background: "#f0ece4", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "1.1rem", fontWeight: 600, color: "#8a8478" }}>M</div>
<div>
<div style={{ fontSize: "0.95rem", fontWeight: 600, color: "#1a1a1a" }}>Michael</div>
<div style={{ fontSize: "0.78rem", color: "#a09a90" }}>michael@example.com</div>
</div>
<Btn variant="secondary" style={{ marginLeft: "auto", padding: "6px 14px", fontSize: "0.72rem" }}>Change photo</Btn>
</div>
<InputField label="Full name" value="Michael" onChange={() => {}} />
<InputField label="Email" value="michael@example.com" onChange={() => {}} type="email" />
<InputField label="Company" value="" onChange={() => {}} placeholder="Optional" />
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 8 }}>
<Btn variant="primary" style={{ padding: "8px 20px" }}>Save changes</Btn>
</div>
</Card>
<Card style={{ padding: "24px" }} hover={false}>
<h3 style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 4 }}>Preferences</h3>
<p style={{ fontSize: "0.75rem", color: "#a09a90", marginBottom: 12 }}>Customize your workspace</p>
<Toggle on={darkMode} onToggle={() => setDarkMode(!darkMode)} label="Dark mode" />
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "12px 0", borderBottom: "1px solid #f0ece4" }}>
<span style={{ fontSize: "0.84rem", color: "#1a1a1a" }}>Default project view</span>
<select style={{ padding: "4px 10px", borderRadius: 5, border: "1px solid #e0dcd4", fontSize: "0.78rem", fontFamily: "Outfit", color: "#1a1a1a", background: "#faf8f5" }}>
<option>Chat</option>
<option>PRD</option>
<option>Build</option>
</select>
</div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "12px 0" }}>
<span style={{ fontSize: "0.84rem", color: "#1a1a1a" }}>Atlas personality</span>
<select style={{ padding: "4px 10px", borderRadius: 5, border: "1px solid #e0dcd4", fontSize: "0.78rem", fontFamily: "Outfit", color: "#1a1a1a", background: "#faf8f5" }}>
<option>Balanced</option>
<option>Concise</option>
<option>Thorough</option>
</select>
</div>
</Card>
</div>
)}
{settingsTab === "notifications" && (
<div style={{ maxWidth: 480, animation: "enter 0.25s ease" }}>
<h2 style={{ fontSize: "1.05rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 4 }}>Notifications</h2>
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: 28 }}>Choose what you hear about and when</p>
<Card style={{ padding: "24px" }} hover={false}>
<Toggle on={emailNotifs} onToggle={() => setEmailNotifs(!emailNotifs)} label="Email notifications" />
<Toggle on={buildNotifs} onToggle={() => setBuildNotifs(!buildNotifs)} label="Build completion alerts" />
<Toggle on={atlasDigest} onToggle={() => setAtlasDigest(!atlasDigest)} label="Daily Atlas digest" />
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "12px 0" }}>
<span style={{ fontSize: "0.84rem", color: "#1a1a1a" }}>Notification frequency</span>
<select style={{ padding: "4px 10px", borderRadius: 5, border: "1px solid #e0dcd4", fontSize: "0.78rem", fontFamily: "Outfit", color: "#1a1a1a", background: "#faf8f5" }}>
<option>Real-time</option>
<option>Hourly digest</option>
<option>Daily digest</option>
</select>
</div>
</Card>
</div>
)}
{settingsTab === "billing" && (
<div style={{ maxWidth: 480, animation: "enter 0.25s ease" }}>
<h2 style={{ fontSize: "1.05rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 4 }}>Plan & Billing</h2>
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: 28 }}>Manage your subscription and usage</p>
<Card style={{ padding: "24px", marginBottom: 16 }} hover={false}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 18 }}>
<div>
<div style={{ fontSize: "0.62rem", fontWeight: 600, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 4 }}>Current plan</div>
<div style={{ fontFamily: "Newsreader", fontSize: "1.4rem", color: "#1a1a1a" }}>Pro</div>
</div>
<Tag color="#2e7d32" bg="#2e7d3210">Active</Tag>
</div>
<div style={{ display: "flex", gap: 24, paddingTop: 16, borderTop: "1px solid #f0ece4" }}>
{[
{ label: "Projects", value: "10", used: "4" },
{ label: "Builds/mo", value: "20", used: "3" },
{ label: "Deploys", value: "Unlimited", used: "—" },
].map((m, i) => (
<div key={i}>
<div style={{ fontSize: "0.62rem", color: "#b5b0a6", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 3 }}>{m.label}</div>
<div style={{ fontSize: "0.88rem", color: "#1a1a1a" }}>
<span style={{ fontFamily: "IBM Plex Mono", fontWeight: 500 }}>{m.used}</span>
<span style={{ color: "#b5b0a6" }}> / {m.value}</span>
</div>
</div>
))}
</div>
</Card>
<Card style={{ padding: "20px", display: "flex", alignItems: "center", justifyContent: "space-between" }} hover={false}>
<div>
<div style={{ fontSize: "0.84rem", fontWeight: 500, color: "#1a1a1a" }}>Payment method</div>
<div style={{ fontSize: "0.78rem", color: "#a09a90", fontFamily: "IBM Plex Mono" }}> 4242 expires 08/27</div>
</div>
<Btn variant="secondary" style={{ padding: "6px 14px", fontSize: "0.72rem" }}>Update</Btn>
</Card>
</div>
)}
{settingsTab === "team" && (
<div style={{ maxWidth: 480, animation: "enter 0.25s ease" }}>
<h2 style={{ fontSize: "1.05rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 4 }}>Team</h2>
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: 28 }}>Manage collaborators and permissions</p>
<Card style={{ padding: "24px" }} hover={false}>
{[
{ name: "Michael", email: "michael@example.com", role: "Owner" },
{ name: "Craig F.", email: "craig@example.com", role: "Editor" },
].map((m, i) => (
<div key={i} style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 0", borderBottom: i === 0 ? "1px solid #f0ece4" : "none" }}>
<div style={{ width: 32, height: 32, borderRadius: "50%", background: "#f0ece4", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.72rem", fontWeight: 600, color: "#8a8478" }}>{m.name[0]}</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: "0.84rem", fontWeight: 500, color: "#1a1a1a" }}>{m.name}</div>
<div style={{ fontSize: "0.72rem", color: "#a09a90" }}>{m.email}</div>
</div>
<Tag color="#6b6560" bg="#f0ece4">{m.role}</Tag>
</div>
))}
<div style={{ marginTop: 16 }}>
<Btn variant="secondary" style={{ width: "100%", fontSize: "0.78rem" }}>+ Invite team member</Btn>
</div>
</Card>
</div>
)}
{settingsTab === "domains" && (
<div style={{ maxWidth: 480, animation: "enter 0.25s ease" }}>
<h2 style={{ fontSize: "1.05rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 4 }}>Domains</h2>
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: 28 }}>Custom domains for your deployed projects</p>
<Card style={{ padding: "24px" }} hover={false}>
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "10px 0", borderBottom: "1px solid #f0ece4" }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: "0.84rem", fontFamily: "IBM Plex Mono", fontWeight: 500, color: "#1a1a1a" }}>kb.acmecorp.com</div>
<div style={{ fontSize: "0.72rem", color: "#a09a90" }}> Canopy</div>
</div>
<Tag color="#2e7d32" bg="#2e7d3210">Verified</Tag>
</div>
<div style={{ marginTop: 16 }}>
<Btn variant="secondary" style={{ width: "100%", fontSize: "0.78rem" }}>+ Add custom domain</Btn>
</div>
</Card>
</div>
)}
{settingsTab === "api" && (
<div style={{ maxWidth: 480, animation: "enter 0.25s ease" }}>
<h2 style={{ fontSize: "1.05rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 4 }}>API Keys</h2>
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: 28 }}>Access your projects programmatically</p>
<Card style={{ padding: "24px" }} hover={false}>
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "10px 0", borderBottom: "1px solid #f0ece4" }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: "0.82rem", fontWeight: 500, color: "#1a1a1a" }}>Production key</div>
<div style={{ fontSize: "0.78rem", fontFamily: "IBM Plex Mono", color: "#a09a90" }}>sk_live_3kF9</div>
</div>
<Btn variant="secondary" style={{ padding: "5px 12px", fontSize: "0.7rem" }}>Reveal</Btn>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "10px 0" }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: "0.82rem", fontWeight: 500, color: "#1a1a1a" }}>Test key</div>
<div style={{ fontSize: "0.78rem", fontFamily: "IBM Plex Mono", color: "#a09a90" }}>sk_test_7mR2</div>
</div>
<Btn variant="secondary" style={{ padding: "5px 12px", fontSize: "0.7rem" }}>Reveal</Btn>
</div>
<div style={{ marginTop: 16 }}>
<Btn variant="secondary" style={{ width: "100%", fontSize: "0.78rem" }}>+ Generate new key</Btn>
</div>
</Card>
</div>
)}
{settingsTab === "danger" && (
<div style={{ maxWidth: 480, animation: "enter 0.25s ease" }}>
<h2 style={{ fontSize: "1.05rem", fontWeight: 600, color: "#d32f2f", marginBottom: 4 }}>Danger Zone</h2>
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: 28 }}>Irreversible actions</p>
<Card style={{ padding: "20px", borderColor: "#f5d5d5" }} hover={false}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div>
<div style={{ fontSize: "0.84rem", fontWeight: 500, color: "#1a1a1a" }}>Delete account</div>
<div style={{ fontSize: "0.75rem", color: "#a09a90" }}>Permanently remove your account and all projects</div>
</div>
<Btn variant="secondary" style={{ color: "#d32f2f", borderColor: "#f5d5d5", padding: "6px 14px", fontSize: "0.72rem" }}>Delete</Btn>
</div>
</Card>
</div>
)}
</div>
</div>
);
};
/* ─── PROJECT DETAIL ─── */
const ProjectDetail = ({ project }) => {
const [tab, setTab] = useState("chat");
const [msgs, setMsgs] = useState(chatHistory);
const [input, setInput] = useState("");
const [typing, setTyping] = useState(false);
const endRef = useRef(null);
useEffect(() => { endRef.current?.scrollIntoView({ behavior: "smooth" }); }, [msgs, typing]);
const send = () => {
if (!input.trim()) return;
setMsgs(p => [...p, { from: "user", text: input }]);
setInput("");
setTyping(true);
setTimeout(() => {
setTyping(false);
setMsgs(p => [...p, { from: "atlas", text: "Good instinct. For multi-location, the core question is inventory: shared pool vs per-store tracking.\n\nOption A — Shared inventory. Simpler, but Store B can't trust the count if Store A just dispensed.\n\nOption B — Per-location with transfers. Accurate, adds a \"request stock\" workflow.\n\nMost independents start single-location and add multi-store as v2. Want to scope it that way?" }]);
}, 2000);
};
const prdPct = Math.round(prdData.reduce((a, s) => a + s.pct, 0) / prdData.length);
return (
<div style={{ display: "flex", height: "100vh", fontFamily: "Outfit, sans-serif", animation: "enter 0.3s ease" }}>
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
{/* Header */}
<div style={{ padding: "18px 32px", borderBottom: "1px solid #e8e4dc", display: "flex", alignItems: "center", justifyContent: "space-between", background: "#fff" }}>
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
<div style={{ width: 34, height: 34, borderRadius: 9, background: project.color + "12", display: "flex", alignItems: "center", justifyContent: "center" }}>
<span style={{ fontFamily: "Newsreader", fontSize: "1rem", fontWeight: 500, color: project.color }}>{project.name[0]}</span>
</div>
<div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<h2 style={{ fontSize: "1.05rem", fontWeight: 600, color: "#1a1a1a", letterSpacing: "-0.02em" }}>{project.name}</h2>
<Tag color={project.status === "live" ? "#2e7d32" : project.status === "building" ? "#3d5afe" : "#9a7b3a"}
bg={project.status === "live" ? "#2e7d3210" : project.status === "building" ? "#3d5afe10" : "#d4a04a12"}>
{project.status === "live" ? "Live" : project.status === "building" ? "Building" : "Defining"}
</Tag>
</div>
<p style={{ fontSize: "0.75rem", color: "#a09a90", marginTop: 1 }}>{project.desc}</p>
</div>
</div>
<div style={{ fontFamily: "IBM Plex Mono", fontSize: "0.78rem", fontWeight: 500, color: "#1a1a1a", background: "#f6f4f0", padding: "6px 12px", borderRadius: 6 }}>
{project.progress}%
</div>
</div>
{/* Tabs */}
<div style={{ padding: "0 32px", borderBottom: "1px solid #e8e4dc", display: "flex", gap: 0, background: "#fff" }}>
{[
{ id: "chat", label: "Atlas" },
{ id: "prd", label: "PRD" },
{ id: "build", label: "Build" },
{ id: "deploy", label: "Deploy" },
{ id: "projsettings", label: "Settings" },
].map(t => (
<button key={t.id} onClick={() => setTab(t.id)} style={{
padding: "12px 18px", border: "none", background: "none",
fontSize: "0.8rem", fontWeight: 500, cursor: "pointer",
color: tab === t.id ? "#1a1a1a" : "#a09a90",
borderBottom: tab === t.id ? "2px solid #1a1a1a" : "2px solid transparent",
transition: "all 0.12s", fontFamily: "Outfit",
}}>{t.label}</button>
))}
</div>
<div style={{ flex: 1, overflow: "hidden", display: "flex", background: "#f6f4f0" }}>
{/* CHAT */}
{tab === "chat" && (
<div style={{ flex: 1, display: "flex", flexDirection: "column" }}>
<div style={{ flex: 1, overflow: "auto", padding: "28px 32px" }}>
{msgs.map((m, i) => (
<div key={i} style={{ display: "flex", gap: 12, marginBottom: 22, animation: i >= chatHistory.length ? "enter 0.3s ease" : `enter 0.35s ease ${i * 0.08}s both` }}>
<div style={{ width: 28, height: 28, borderRadius: 7, flexShrink: 0, marginTop: 2, background: m.from === "atlas" ? "#1a1a1a" : "#e8e4dc", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.68rem", fontWeight: 700, color: m.from === "atlas" ? "#fff" : "#8a8478", fontFamily: m.from === "atlas" ? "Newsreader" : "Outfit" }}>
{m.from === "atlas" ? "A" : "M"}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: "0.68rem", fontWeight: 600, color: "#a09a90", marginBottom: 5, textTransform: "uppercase", letterSpacing: "0.04em" }}>{m.from === "atlas" ? "Atlas" : "You"}</div>
<div style={{ fontSize: "0.88rem", color: "#2a2824", lineHeight: 1.72, whiteSpace: "pre-wrap" }}>
{m.text.split(/(\*\*.*?\*\*)/).map((seg, j) => seg.startsWith("**") && seg.endsWith("**") ? <strong key={j} style={{ fontWeight: 600, color: "#1a1a1a" }}>{seg.slice(2, -2)}</strong> : seg)}
</div>
</div>
</div>
))}
{typing && (
<div style={{ display: "flex", gap: 12, animation: "enter 0.2s ease" }}>
<div style={{ width: 28, height: 28, borderRadius: 7, background: "#1a1a1a", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.68rem", fontWeight: 700, color: "#fff", fontFamily: "Newsreader" }}>A</div>
<div style={{ display: "flex", gap: 5, paddingTop: 10 }}>
{[0,1,2].map(d => <div key={d} style={{ width: 5, height: 5, borderRadius: "50%", background: "#b5b0a6", animation: `blink 1s ease ${d * 0.15}s infinite` }} />)}
</div>
</div>
)}
<div ref={endRef} />
</div>
<div style={{ padding: "14px 32px 22px" }}>
<div style={{ display: "flex", gap: 8, padding: "5px 5px 5px 16px", background: "#fff", border: "1px solid #e0dcd4", borderRadius: 10, alignItems: "center", boxShadow: "0 1px 4px #1a1a1a06" }}>
<input value={input} onChange={e => setInput(e.target.value)} onKeyDown={e => e.key === "Enter" && send()} placeholder="Describe your thinking..."
style={{ flex: 1, border: "none", background: "none", fontSize: "0.86rem", fontFamily: "Outfit", color: "#1a1a1a", padding: "8px 0" }} />
<button onClick={send} style={{ padding: "9px 16px", borderRadius: 7, border: "none", background: input.trim() ? "#1a1a1a" : "#eae6de", color: input.trim() ? "#fff" : "#b5b0a6", fontSize: "0.78rem", fontWeight: 600, transition: "all 0.15s" }}>Send</button>
</div>
</div>
</div>
)}
{/* PRD */}
{tab === "prd" && (
<div style={{ flex: 1, overflow: "auto", padding: "28px 32px", animation: "enter 0.3s ease" }}>
<div style={{ display: "flex", alignItems: "center", gap: 16, padding: "16px 20px", background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10, marginBottom: 20, boxShadow: "0 1px 2px #1a1a1a05" }}>
<div style={{ fontFamily: "IBM Plex Mono", fontSize: "1.4rem", fontWeight: 500, color: "#1a1a1a", minWidth: 48 }}>{prdPct}%</div>
<div style={{ flex: 1 }}>
<div style={{ height: 4, borderRadius: 2, background: "#eae6de" }}>
<div style={{ height: "100%", borderRadius: 2, width: `${prdPct}%`, background: "#1a1a1a", transition: "width 0.6s ease" }} />
</div>
</div>
<span style={{ fontSize: "0.75rem", color: "#a09a90" }}>{prdData.filter(s => s.status === "done").length}/{prdData.length} approved</span>
</div>
{prdData.map((s, i) => (
<div key={i} style={{ display: "flex", alignItems: "center", gap: 12, padding: "14px 18px", marginBottom: 4, background: "#fff", borderRadius: 8, border: "1px solid #e8e4dc", animation: `enter 0.3s ease ${i * 0.04}s both`, cursor: "pointer", transition: "border-color 0.12s" }}
onMouseEnter={e => e.currentTarget.style.borderColor = "#d0ccc4"}
onMouseLeave={e => e.currentTarget.style.borderColor = "#e8e4dc"}>
<div style={{ width: 24, height: 24, borderRadius: 6, flexShrink: 0, background: s.status === "done" ? "#2e7d3210" : s.status === "active" ? "#d4a04a12" : "#f6f4f0", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.65rem", fontWeight: 700, color: s.status === "done" ? "#2e7d32" : s.status === "active" ? "#9a7b3a" : "#c5c0b8" }}>
{s.status === "done" ? "✓" : s.status === "active" ? "◐" : "○"}
</div>
<span style={{ flex: 1, fontSize: "0.84rem", color: "#1a1a1a", fontWeight: 450 }}>{s.name}</span>
<div style={{ width: 60, height: 3, borderRadius: 2, background: "#eae6de" }}>
<div style={{ height: "100%", borderRadius: 2, width: `${s.pct}%`, background: s.status === "done" ? "#2e7d32" : s.status === "active" ? "#d4a04a" : "#d0ccc4" }} />
</div>
<span style={{ fontSize: "0.68rem", fontFamily: "IBM Plex Mono", color: s.status === "done" ? "#2e7d32" : "#a09a90", fontWeight: 500, minWidth: 28, textAlign: "right" }}>{s.pct}%</span>
</div>
))}
</div>
)}
{/* BUILD */}
{tab === "build" && (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", padding: 40, animation: "enter 0.3s ease" }}>
{project.status === "prd" ? (
<div style={{ textAlign: "center", maxWidth: 360 }}>
<div style={{ width: 56, height: 56, borderRadius: 14, background: "#fff", border: "1px solid #e8e4dc", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "1.4rem", margin: "0 auto 18px", boxShadow: "0 2px 8px #1a1a1a08" }}>🔒</div>
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.3rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 8 }}>Complete your PRD first</h3>
<p style={{ fontSize: "0.82rem", color: "#a09a90", lineHeight: 1.6, marginBottom: 20 }}>Approve all sections with Atlas, then the builder unlocks automatically.</p>
<div style={{ display: "inline-flex", padding: "8px 16px", borderRadius: 7, background: "#fff", border: "1px solid #e0dcd4", fontSize: "0.78rem", color: "#6b6560", fontWeight: 500 }}>
<span style={{ fontFamily: "IBM Plex Mono", fontWeight: 600, color: "#1a1a1a", marginRight: 6 }}>{prdPct}%</span>complete
</div>
</div>
) : (
<div style={{ width: "100%", maxWidth: 500 }}>
<h3 style={{ fontFamily: "Newsreader", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 18 }}>Build progress</h3>
{["Auth System", "Database", "Dashboard UI", "Rx Queue", "Inventory", "API"].map((f, i) => {
const pct = Math.max(0, Math.min(100, project.progress + (i * -12)));
return (
<div key={i} style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 16px", marginBottom: 4, borderRadius: 8, background: "#fff", border: "1px solid #e8e4dc", animation: `enter 0.3s ease ${i * 0.05}s both` }}>
<StatusDot status={pct >= 100 ? "live" : pct > 0 ? "building" : "prd"} />
<span style={{ flex: 1, fontSize: "0.84rem", color: "#1a1a1a" }}>{f}</span>
<div style={{ width: 80, height: 3, borderRadius: 2, background: "#eae6de" }}>
<div style={{ height: "100%", width: `${pct}%`, borderRadius: 2, background: pct >= 100 ? "#2e7d32" : "#3d5afe" }} />
</div>
<span style={{ fontFamily: "IBM Plex Mono", fontSize: "0.7rem", color: "#a09a90", minWidth: 28, textAlign: "right" }}>{pct}%</span>
</div>
);
})}
</div>
)}
</div>
)}
{/* DEPLOY */}
{tab === "deploy" && (
<div style={{ flex: 1, overflow: "auto", padding: "28px 32px", animation: "enter 0.3s ease" }}>
<div style={{ maxWidth: 560 }}>
<h3 style={{ fontFamily: "Newsreader", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 4 }}>Deployment</h3>
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: 24 }}>Links, environments, and hosting for {project.name}</p>
{/* URLs card */}
<Card style={{ padding: "22px", marginBottom: 12 }} hover={false}>
<SectionLabel>Project URLs</SectionLabel>
{project.domain ? (
<>
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 0", borderBottom: "1px solid #f0ece4" }}>
<div style={{ width: 28, height: 28, borderRadius: 6, background: "#f6f4f0", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.7rem", color: "#8a8478" }}></div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: "0.68rem", color: "#b5b0a6", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.04em" }}>Staging</div>
<div style={{ fontSize: "0.84rem", fontFamily: "IBM Plex Mono", color: "#3d5afe", fontWeight: 500 }}>{project.domain}</div>
</div>
<Btn variant="secondary" style={{ padding: "5px 12px", fontSize: "0.7rem" }}>Open </Btn>
</div>
{project.customDomain && (
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 0", borderBottom: "1px solid #f0ece4" }}>
<div style={{ width: 28, height: 28, borderRadius: 6, background: "#2e7d3210", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.7rem", color: "#2e7d32" }}></div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: "0.68rem", color: "#b5b0a6", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.04em" }}>Production</div>
<div style={{ fontSize: "0.84rem", fontFamily: "IBM Plex Mono", color: "#2e7d32", fontWeight: 500 }}>{project.customDomain}</div>
</div>
<Tag color="#2e7d32" bg="#2e7d3210">SSL Active</Tag>
<Btn variant="secondary" style={{ padding: "5px 12px", fontSize: "0.7rem" }}>Open </Btn>
</div>
)}
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 0" }}>
<div style={{ width: 28, height: 28, borderRadius: 6, background: "#f6f4f0", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.7rem", color: "#8a8478" }}></div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: "0.68rem", color: "#b5b0a6", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.04em" }}>Build repo</div>
<div style={{ fontSize: "0.84rem", fontFamily: "IBM Plex Mono", color: "#6b6560", fontWeight: 500 }}>{project.repo}</div>
</div>
<Btn variant="secondary" style={{ padding: "5px 12px", fontSize: "0.7rem" }}>View </Btn>
</div>
</>
) : (
<div style={{ padding: "18px 0", textAlign: "center" }}>
<p style={{ fontSize: "0.82rem", color: "#a09a90", marginBottom: 12 }}>No deployment yet complete your PRD and build to get a live URL.</p>
<div style={{ display: "inline-flex", padding: "6px 14px", borderRadius: 6, background: "#f6f4f0", fontSize: "0.75rem", color: "#8a8478" }}>
<span style={{ fontFamily: "IBM Plex Mono", marginRight: 6 }}>{project.progress}%</span> to deployment
</div>
</div>
)}
</Card>
{/* Custom domain */}
{project.domain && !project.customDomain && (
<Card style={{ padding: "22px", marginBottom: 12 }} hover={false}>
<SectionLabel>Custom Domain</SectionLabel>
<p style={{ fontSize: "0.82rem", color: "#6b6560", lineHeight: 1.6, marginBottom: 14 }}>
Point your own domain to this project. We'll handle SSL certificates automatically.
</p>
<div style={{ display: "flex", gap: 8 }}>
<input placeholder="app.yourdomain.com" style={{ flex: 1, padding: "9px 13px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#faf8f5", fontSize: "0.84rem", fontFamily: "IBM Plex Mono", color: "#1a1a1a" }} />
<Btn variant="primary" style={{ padding: "9px 18px" }}>Connect</Btn>
</div>
</Card>
)}
{/* Environment vars */}
<Card style={{ padding: "22px", marginBottom: 12 }} hover={false}>
<SectionLabel>Environment Variables</SectionLabel>
{project.domain ? (
<>
{[
{ key: "DATABASE_URL", val: "••••••••••••" },
{ key: "API_SECRET", val: "••••••••••••" },
{ key: "SMTP_HOST", val: "mail.stackless.dev" },
].map((env, i) => (
<div key={i} style={{ display: "flex", gap: 10, padding: "8px 0", borderBottom: i < 2 ? "1px solid #f0ece4" : "none", alignItems: "center" }}>
<span style={{ fontFamily: "IBM Plex Mono", fontSize: "0.78rem", fontWeight: 500, color: "#1a1a1a", minWidth: 130 }}>{env.key}</span>
<span style={{ fontFamily: "IBM Plex Mono", fontSize: "0.78rem", color: "#a09a90", flex: 1 }}>{env.val}</span>
<button style={{ background: "none", border: "none", fontSize: "0.7rem", color: "#a09a90", padding: "2px 6px" }}>Edit</button>
</div>
))}
<div style={{ marginTop: 14 }}>
<Btn variant="secondary" style={{ fontSize: "0.75rem", padding: "7px 14px" }}>+ Add variable</Btn>
</div>
</>
) : (
<p style={{ fontSize: "0.82rem", color: "#a09a90", padding: "10px 0" }}>Available after first build completes.</p>
)}
</Card>
{/* Deploy history */}
<Card style={{ padding: "22px" }} hover={false}>
<SectionLabel>Deploy History</SectionLabel>
{project.status === "live" ? (
<>
{[
{ version: "v1.2", time: "1 day ago", status: "live", note: "Added search filters" },
{ version: "v1.1", time: "5 days ago", status: "previous", note: "Bug fix: auth timeout" },
{ version: "v1.0", time: "2 weeks ago", status: "previous", note: "Initial launch" },
].map((d, i) => (
<div key={i} style={{ display: "flex", alignItems: "center", gap: 12, padding: "10px 0", borderBottom: i < 2 ? "1px solid #f0ece4" : "none" }}>
<span style={{ fontFamily: "IBM Plex Mono", fontSize: "0.78rem", fontWeight: 600, color: "#1a1a1a", minWidth: 36 }}>{d.version}</span>
<span style={{ flex: 1, fontSize: "0.82rem", color: "#6b6560" }}>{d.note}</span>
<span style={{ fontSize: "0.72rem", color: "#b5b0a6" }}>{d.time}</span>
{d.status === "live" && <Tag color="#2e7d32" bg="#2e7d3210">Current</Tag>}
</div>
))}
</>
) : project.domain ? (
<p style={{ fontSize: "0.82rem", color: "#a09a90", padding: "10px 0" }}>First deploy will appear here once the build completes.</p>
) : (
<p style={{ fontSize: "0.82rem", color: "#a09a90", padding: "10px 0" }}>No deploys yet.</p>
)}
</Card>
</div>
</div>
)}
{/* PROJECT SETTINGS */}
{tab === "projsettings" && (
<div style={{ flex: 1, overflow: "auto", padding: "28px 32px", animation: "enter 0.3s ease" }}>
<div style={{ maxWidth: 480 }}>
<h3 style={{ fontFamily: "Newsreader", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 4 }}>Project Settings</h3>
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: 24 }}>Configure {project.name}</p>
<Card style={{ padding: "22px", marginBottom: 12 }} hover={false}>
<SectionLabel>General</SectionLabel>
<InputField label="Project name" value={project.name} onChange={() => {}} />
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: "0.72rem", fontWeight: 600, color: "#6b6560", marginBottom: 6 }}>Description</div>
<textarea value={project.desc} onChange={() => {}} rows={2}
style={{ width: "100%", padding: "9px 13px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#faf8f5", fontSize: "0.84rem", color: "#1a1a1a", fontFamily: "Outfit" }} />
</div>
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<Btn variant="primary" style={{ padding: "8px 20px" }}>Save</Btn>
</div>
</Card>
<Card style={{ padding: "22px", marginBottom: 12 }} hover={false}>
<SectionLabel>Collaborators</SectionLabel>
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "8px 0" }}>
<div style={{ width: 28, height: 28, borderRadius: "50%", background: "#f0ece4", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.68rem", fontWeight: 600, color: "#8a8478" }}>M</div>
<span style={{ flex: 1, fontSize: "0.82rem", color: "#1a1a1a" }}>Michael</span>
<Tag color="#6b6560" bg="#f0ece4">Owner</Tag>
</div>
<Btn variant="secondary" style={{ width: "100%", marginTop: 12, fontSize: "0.75rem" }}>+ Invite to project</Btn>
</Card>
<Card style={{ padding: "22px", marginBottom: 12 }} hover={false}>
<SectionLabel>Export</SectionLabel>
<p style={{ fontSize: "0.82rem", color: "#6b6560", marginBottom: 14, lineHeight: 1.6 }}>Download your PRD or project data for external use.</p>
<div style={{ display: "flex", gap: 8 }}>
<Btn variant="secondary" style={{ fontSize: "0.75rem" }}>Export PRD as PDF</Btn>
<Btn variant="secondary" style={{ fontSize: "0.75rem" }}>Export as JSON</Btn>
</div>
</Card>
<Card style={{ padding: "20px", borderColor: "#f5d5d5" }} hover={false}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div>
<div style={{ fontSize: "0.84rem", fontWeight: 500, color: "#d32f2f" }}>Delete project</div>
<div style={{ fontSize: "0.75rem", color: "#a09a90" }}>This action cannot be undone</div>
</div>
<Btn variant="secondary" style={{ color: "#d32f2f", borderColor: "#f5d5d5", padding: "6px 14px", fontSize: "0.72rem" }}>Delete</Btn>
</div>
</Card>
</div>
</div>
)}
</div>
</div>
{/* Right panel */}
<div style={{ width: 230, borderLeft: "1px solid #e8e4dc", background: "#fff", padding: "22px 18px", overflow: "auto", flexShrink: 0 }}>
<SectionLabel>Discovery</SectionLabel>
{discoveryPhases.map((ph, i) => (
<div key={i} style={{ display: "flex", alignItems: "center", gap: 10, padding: "9px 0", borderBottom: i < discoveryPhases.length - 1 ? "1px solid #f0ece4" : "none" }}>
<div style={{ width: 20, height: 20, borderRadius: 5, flexShrink: 0, background: ph.done ? "#2e7d3210" : ph.active ? "#d4a04a12" : "#f6f4f0", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.58rem", fontWeight: 700, color: ph.done ? "#2e7d32" : ph.active ? "#9a7b3a" : "#c5c0b8" }}>
{ph.done ? "✓" : ph.active ? "→" : i + 1}
</div>
<span style={{ fontSize: "0.78rem", fontWeight: ph.active ? 600 : 400, color: ph.done ? "#6b6560" : ph.active ? "#1a1a1a" : "#b5b0a6" }}>{ph.name}</span>
</div>
))}
<div style={{ height: 1, background: "#f0ece4", margin: "16px 0" }} />
<SectionLabel>Captured</SectionLabel>
{[
{ k: "Users", v: "Pharmacist" },
{ k: "Workflow", v: "Receive → Verify → Fill" },
{ k: "Scope", v: "Single-location MVP" },
{ k: "Open", v: "Multi-location support?" },
].map((item, i) => (
<div key={i} style={{ marginBottom: 14 }}>
<div style={{ fontSize: "0.62rem", color: "#b5b0a6", textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 3, fontWeight: 600 }}>{item.k}</div>
<div style={{ fontSize: "0.8rem", color: "#4a4640", lineHeight: 1.45 }}>{item.v}</div>
</div>
))}
<div style={{ height: 1, background: "#f0ece4", margin: "16px 0" }} />
<SectionLabel>Project Info</SectionLabel>
{[
{ k: "Created", v: project.created },
{ k: "Last active", v: project.lastActive },
{ k: "Features", v: `${project.features} defined` },
].map((item, i) => (
<div key={i} style={{ marginBottom: 12 }}>
<div style={{ fontSize: "0.62rem", color: "#b5b0a6", textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 3, fontWeight: 600 }}>{item.k}</div>
<div style={{ fontSize: "0.8rem", color: "#4a4640" }}>{item.v}</div>
</div>
))}
</div>
</div>
);
};
/* ─── DASHBOARD ─── */
const Home = ({ setActiveProject, setView }) => {
const [showNew, setShowNew] = useState(false);
return (
<div style={{ padding: "44px 52px", maxWidth: 900, fontFamily: "Outfit, sans-serif", animation: "enter 0.35s ease both" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", marginBottom: 36 }}>
<div>
<h1 style={{ fontFamily: "Newsreader, serif", fontSize: "1.9rem", fontWeight: 400, color: "#1a1a1a", letterSpacing: "-0.03em", lineHeight: 1.15, marginBottom: 4 }}>Projects</h1>
<p style={{ fontSize: "0.82rem", color: "#a09a90" }}>4 total · 2 in definition · 1 building · 1 live</p>
</div>
<Btn variant="primary" onClick={() => setShowNew(!showNew)} style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ fontSize: "1rem", lineHeight: 1, fontWeight: 300 }}>+</span> New project
</Btn>
</div>
{showNew && (
<Card style={{ padding: "22px 26px", marginBottom: 28, animation: "enter 0.25s ease" }} hover={false}>
<div style={{ display: "flex", alignItems: "center", gap: 9, marginBottom: 14 }}>
<div style={{ width: 28, height: 28, borderRadius: 7, background: "#1a1a1a", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.72rem", fontWeight: 700, color: "#fff", fontFamily: "Newsreader" }}>A</div>
<div>
<span style={{ fontSize: "0.82rem", fontWeight: 600, color: "#1a1a1a" }}>Atlas</span>
<span style={{ fontSize: "0.72rem", color: "#a09a90", marginLeft: 8 }}>product strategist</span>
</div>
</div>
<p style={{ fontSize: "0.84rem", color: "#6b6560", lineHeight: 1.6, marginBottom: 16 }}>Tell me what you want to build. One sentence is fine — I'll ask the right questions to figure out the rest.</p>
<div style={{ display: "flex", gap: 8 }}>
<input placeholder="e.g. A scheduling tool for independent yoga instructors..." style={{ flex: 1, padding: "10px 14px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#faf8f5", fontSize: "0.84rem", fontFamily: "Outfit", color: "#1a1a1a" }} />
<Btn variant="primary">Start </Btn>
</div>
</Card>
)}
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{projects.map((p, i) => (
<button key={p.id} onClick={() => { setActiveProject(p); setView("project"); }}
style={{
width: "100%", display: "flex", alignItems: "center", padding: "18px 22px", borderRadius: 10,
background: "#fff", border: "1px solid #e8e4dc", cursor: "pointer", fontFamily: "Outfit",
transition: "all 0.15s", textAlign: "left", animation: `enter 0.35s ease ${i * 0.05}s both`,
boxShadow: "0 1px 2px #1a1a1a05",
}}
onMouseEnter={e => { e.currentTarget.style.borderColor = "#d0ccc4"; e.currentTarget.style.boxShadow = "0 2px 8px #1a1a1a0a"; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = "#e8e4dc"; e.currentTarget.style.boxShadow = "0 1px 2px #1a1a1a05"; }}
>
<div style={{ width: 36, height: 36, borderRadius: 9, marginRight: 16, background: p.color + "12", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
<span style={{ fontFamily: "Newsreader, serif", fontSize: "1.05rem", fontWeight: 500, color: p.color }}>{p.name[0]}</span>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 2 }}>
<span style={{ fontSize: "0.9rem", fontWeight: 600, color: "#1a1a1a" }}>{p.name}</span>
<Tag color={p.status === "live" ? "#2e7d32" : p.status === "building" ? "#3d5afe" : "#9a7b3a"} bg={p.status === "live" ? "#2e7d3210" : p.status === "building" ? "#3d5afe10" : "#d4a04a12"}>
<StatusDot status={p.status} /> {p.status === "live" ? "Live" : p.status === "building" ? "Building" : "Defining"}
</Tag>
</div>
<span style={{ fontSize: "0.78rem", color: "#a09a90" }}>{p.desc}</span>
</div>
<div style={{ display: "flex", gap: 28, alignItems: "center", flexShrink: 0 }}>
<div style={{ textAlign: "right" }}>
<div style={{ fontSize: "0.62rem", color: "#b5b0a6", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 2 }}>Phase</div>
<div style={{ fontSize: "0.78rem", color: "#6b6560" }}>{p.phase}</div>
</div>
<div style={{ textAlign: "right" }}>
<div style={{ fontSize: "0.62rem", color: "#b5b0a6", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 2 }}>Features</div>
<div style={{ fontSize: "0.78rem", color: "#6b6560" }}>{p.features}</div>
</div>
<div style={{ textAlign: "right", minWidth: 40 }}>
<div style={{ fontSize: "0.62rem", color: "#b5b0a6", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 2 }}>Progress</div>
<div style={{ fontSize: "0.78rem", color: "#1a1a1a", fontWeight: 600, fontFamily: "IBM Plex Mono" }}>{p.progress}%</div>
</div>
<div style={{ width: 60, height: 3, borderRadius: 2, background: "#eae6de", flexShrink: 0 }}>
<div style={{ height: "100%", borderRadius: 2, width: `${p.progress}%`, background: p.status === "live" ? "#2e7d32" : p.status === "building" ? "#3d5afe" : "#d4a04a", transition: "width 0.6s ease" }} />
</div>
</div>
</button>
))}
</div>
</div>
);
};
/* ─── ROOT ─── */
export default function Stackless() {
const [view, setView] = useState("home");
const [activeProject, setActiveProject] = useState(null);
return (
<div style={{ display: "flex", height: "100vh", background: "#f6f4f0", overflow: "hidden" }}>
<FontLoader />
<Sidebar activeProject={activeProject} setActiveProject={setActiveProject} view={view} setView={setView} />
<div style={{ flex: 1, overflow: "auto" }}>
{view === "home" && !activeProject && <Home setActiveProject={setActiveProject} setView={setView} />}
{view === "activity" && <ActivityPage setActiveProject={setActiveProject} setView={setView} />}
{view === "settings" && <SettingsPage />}
{activeProject && <ProjectDetail project={activeProject} />}
</div>
</div>
);
}

View File

@@ -1,496 +0,0 @@
-- ==================================================
-- VIBN UNIFIED SCHEMA FOR COOLIFY
-- Combines Firebase collections + Railway PostgreSQL
-- ==================================================
-- Extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ==================================================
-- USERS (replaces Firebase Auth + Firestore users)
-- ==================================================
CREATE TABLE users (
id SERIAL PRIMARY KEY,
uid VARCHAR(255) UNIQUE NOT NULL, -- Firebase UID or generated
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255), -- For email/password auth
display_name VARCHAR(255),
photo_url VARCHAR(500),
workspace VARCHAR(255) UNIQUE NOT NULL, -- URL slug: "marks-account"
-- OAuth providers
google_id VARCHAR(255) UNIQUE,
github_id VARCHAR(255) UNIQUE,
-- Metadata
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
last_login TIMESTAMP,
settings JSONB DEFAULT '{}'::jsonb
);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_workspace ON users(workspace);
-- ==================================================
-- CLIENTS (Organizations/Companies)
-- ==================================================
CREATE TABLE clients (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) UNIQUE NOT NULL, -- URL-friendly
owner_user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
-- Subscription/Billing
subscription_tier VARCHAR(50) DEFAULT 'free', -- free, starter, pro, enterprise
stripe_customer_id VARCHAR(255),
max_users INTEGER DEFAULT 5,
max_projects INTEGER DEFAULT 10,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
metadata JSONB DEFAULT '{}'::jsonb
);
CREATE INDEX idx_clients_owner ON clients(owner_user_id);
CREATE INDEX idx_clients_slug ON clients(slug);
-- ==================================================
-- PROJECTS (replaces Firebase projects collection)
-- ==================================================
CREATE TABLE projects (
id SERIAL PRIMARY KEY,
firebase_id VARCHAR(255) UNIQUE, -- For migration compatibility
client_id INTEGER REFERENCES clients(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, -- Owner
-- Project Identity
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL,
workspace VARCHAR(255) NOT NULL, -- User workspace slug
-- Product Details
product_name VARCHAR(255) NOT NULL,
product_vision TEXT,
project_type VARCHAR(50) DEFAULT 'scratch', -- scratch, existing
-- Status
status VARCHAR(50) DEFAULT 'active', -- active, on_hold, completed, archived
current_phase VARCHAR(50) DEFAULT 'collection', -- collection, extraction, vision, mvp, marketing
phase_status VARCHAR(50) DEFAULT 'not_started', -- not_started, in_progress, completed
-- Integrations
workspace_path VARCHAR(500), -- Local directory path
workspace_name VARCHAR(255),
is_for_client BOOLEAN DEFAULT false,
has_logo BOOLEAN DEFAULT false,
has_domain BOOLEAN DEFAULT false,
has_website BOOLEAN DEFAULT false,
has_github BOOLEAN DEFAULT false,
has_chatgpt BOOLEAN DEFAULT false,
github_repo VARCHAR(500),
chatgpt_project_id VARCHAR(255),
-- Git/Deployment
gitea_repo_url VARCHAR(500),
coolify_app_id VARCHAR(255),
coolify_project_uuid VARCHAR(255),
deployment_url VARCHAR(500),
-- Phase Data
phase_data JSONB DEFAULT '{}'::jsonb, -- Stores all phase artifacts
phase_history JSONB DEFAULT '[]'::jsonb,
phase_scores JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(client_id, slug)
);
CREATE INDEX idx_projects_client ON projects(client_id);
CREATE INDEX idx_projects_user ON projects(user_id);
CREATE INDEX idx_projects_workspace ON projects(workspace);
CREATE INDEX idx_projects_status ON projects(status);
CREATE INDEX idx_projects_firebase_id ON projects(firebase_id);
-- ==================================================
-- PROJECT_CONTRIBUTORS (Team collaboration)
-- ==================================================
CREATE TABLE project_contributors (
id SERIAL PRIMARY KEY,
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(50) DEFAULT 'developer', -- owner, admin, developer, viewer
permissions JSONB DEFAULT '{"read":true,"write":true,"admin":false}'::jsonb,
joined_at TIMESTAMP DEFAULT NOW(),
total_time_minutes INTEGER DEFAULT 0,
UNIQUE(project_id, user_id)
);
CREATE INDEX idx_contributors_project ON project_contributors(project_id);
CREATE INDEX idx_contributors_user ON project_contributors(user_id);
-- ==================================================
-- SESSIONS (replaces both Firebase sessions + Railway logs)
-- ==================================================
CREATE TABLE sessions (
id SERIAL PRIMARY KEY,
session_id VARCHAR(255) UNIQUE NOT NULL,
firebase_id VARCHAR(255), -- For migration
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
-- Timing
started_at TIMESTAMP NOT NULL DEFAULT NOW(),
last_updated TIMESTAMP NOT NULL DEFAULT NOW(),
ended_at TIMESTAMP,
duration_minutes INTEGER DEFAULT 0,
-- Status
status VARCHAR(50) DEFAULT 'active', -- active, idle, completed
needs_project_association BOOLEAN DEFAULT false,
-- Context
workspace_path VARCHAR(500),
workspace_name VARCHAR(255),
ide_name VARCHAR(50) DEFAULT 'Cursor', -- Cursor, VS Code, etc.
-- Content
conversation JSONB DEFAULT '[]'::jsonb, -- Full chat history
file_changes JSONB DEFAULT '[]'::jsonb, -- Files modified
files_modified TEXT[], -- Quick array for queries
-- Metrics
message_count INTEGER DEFAULT 0,
user_message_count INTEGER DEFAULT 0,
assistant_message_count INTEGER DEFAULT 0,
file_change_count INTEGER DEFAULT 0,
-- AI Usage & Cost
total_tokens INTEGER DEFAULT 0,
prompt_tokens INTEGER DEFAULT 0,
completion_tokens INTEGER DEFAULT 0,
estimated_cost_usd NUMERIC(10,6) DEFAULT 0,
model VARCHAR(100),
-- AI Insights
summary TEXT,
conversation_summary TEXT,
tasks_identified JSONB DEFAULT '[]'::jsonb,
decisions_made JSONB DEFAULT '[]'::jsonb,
technologies_used JSONB DEFAULT '[]'::jsonb,
metadata JSONB DEFAULT '{}'::jsonb
);
CREATE INDEX idx_sessions_project ON sessions(project_id);
CREATE INDEX idx_sessions_user ON sessions(user_id);
CREATE INDEX idx_sessions_status ON sessions(status);
CREATE INDEX idx_sessions_started ON sessions(started_at);
CREATE INDEX idx_sessions_session_id ON sessions(session_id);
CREATE INDEX idx_sessions_firebase_id ON sessions(firebase_id);
-- ==================================================
-- WORK_COMPLETED (AI-extracted accomplishments)
-- ==================================================
CREATE TABLE work_completed (
id SERIAL PRIMARY KEY,
session_id INTEGER REFERENCES sessions(id) ON DELETE CASCADE,
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
description TEXT,
category VARCHAR(100), -- frontend, backend, database, deployment, testing, docs, bugfix
files_modified JSONB DEFAULT '[]'::jsonb,
completed_at TIMESTAMP DEFAULT NOW(),
extracted_by_ai BOOLEAN DEFAULT true
);
CREATE INDEX idx_work_completed_project ON work_completed(project_id);
CREATE INDEX idx_work_completed_session ON work_completed(session_id);
CREATE INDEX idx_work_completed_category ON work_completed(category);
-- ==================================================
-- ARCHITECTURAL_DECISIONS (Design decisions)
-- ==================================================
CREATE TABLE architectural_decisions (
id SERIAL PRIMARY KEY,
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
identified_by_session_id INTEGER REFERENCES sessions(id) ON DELETE SET NULL,
title VARCHAR(500) NOT NULL,
context TEXT,
decision TEXT NOT NULL,
consequences TEXT,
status VARCHAR(50) DEFAULT 'accepted', -- proposed, accepted, deprecated, superseded
tags JSONB DEFAULT '[]'::jsonb,
decided_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_arch_decisions_project ON architectural_decisions(project_id);
CREATE INDEX idx_arch_decisions_status ON architectural_decisions(status);
-- ==================================================
-- ARCHITECTURE_DOCS (Auto-maintained documentation)
-- ==================================================
CREATE TABLE architecture_docs (
id SERIAL PRIMARY KEY,
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
doc_type VARCHAR(100) NOT NULL, -- tech_stack, data_model, api_design, deployment
content TEXT NOT NULL,
version INTEGER DEFAULT 1,
last_ai_update TIMESTAMP,
last_manual_update TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(project_id, doc_type)
);
CREATE INDEX idx_arch_docs_project ON architecture_docs(project_id);
CREATE INDEX idx_arch_docs_type ON architecture_docs(doc_type);
-- ==================================================
-- TASKS (Project tasks)
-- ==================================================
CREATE TABLE tasks (
id SERIAL PRIMARY KEY,
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
description TEXT,
status VARCHAR(50) DEFAULT 'todo', -- todo, in_progress, review, done, blocked
priority VARCHAR(50) DEFAULT 'medium', -- low, medium, high, urgent
assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
completed_at TIMESTAMP,
identified_by_session_id INTEGER REFERENCES sessions(id) ON DELETE SET NULL,
related_sessions JSONB DEFAULT '[]'::jsonb,
metadata JSONB DEFAULT '{}'::jsonb
);
CREATE INDEX idx_tasks_project ON tasks(project_id);
CREATE INDEX idx_tasks_status ON tasks(status);
CREATE INDEX idx_tasks_assigned ON tasks(assigned_to);
-- ==================================================
-- ANALYSES (replaces Firebase analyses collection)
-- ==================================================
CREATE TABLE analyses (
id SERIAL PRIMARY KEY,
firebase_id VARCHAR(255), -- For migration
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
type VARCHAR(50) NOT NULL, -- code, chatgpt, github, combined
summary TEXT,
tech_stack JSONB DEFAULT '[]'::jsonb,
features JSONB DEFAULT '[]'::jsonb,
raw_data JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_analyses_project ON analyses(project_id);
CREATE INDEX idx_analyses_type ON analyses(type);
-- ==================================================
-- API_KEYS (User API keys for extension)
-- ==================================================
CREATE TABLE api_keys (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
key_hash VARCHAR(255) UNIQUE NOT NULL,
key_prefix VARCHAR(20), -- First few chars for display
name VARCHAR(255), -- User-friendly name
last_used TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP,
revoked BOOLEAN DEFAULT false
);
CREATE INDEX idx_api_keys_user ON api_keys(user_id);
CREATE INDEX idx_api_keys_hash ON api_keys(key_hash);
-- ==================================================
-- USER_SESSIONS (Authentication sessions)
-- ==================================================
CREATE TABLE user_sessions (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(500) UNIQUE NOT NULL,
refresh_token VARCHAR(500),
created_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP,
last_activity TIMESTAMP DEFAULT NOW(),
ip_address VARCHAR(45),
user_agent TEXT
);
CREATE INDEX idx_user_sessions_token ON user_sessions(token);
CREATE INDEX idx_user_sessions_user ON user_sessions(user_id);
CREATE INDEX idx_user_sessions_expires ON user_sessions(expires_at);
-- ==================================================
-- KNOWLEDGE_ITEMS (Chat/Code context for AI)
-- ==================================================
CREATE TABLE knowledge_items (
id SERIAL PRIMARY KEY,
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
item_type VARCHAR(50) NOT NULL, -- chat_history, code_file, github_issue, documentation
source VARCHAR(100), -- cursor, chatgpt, github, manual
title VARCHAR(500),
content TEXT,
metadata JSONB DEFAULT '{}'::jsonb,
-- Status
processed BOOLEAN DEFAULT false,
chunk_count INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_knowledge_items_project ON knowledge_items(project_id);
CREATE INDEX idx_knowledge_items_type ON knowledge_items(item_type);
CREATE INDEX idx_knowledge_items_processed ON knowledge_items(processed);
-- ==================================================
-- ANALYTICS VIEWS
-- ==================================================
-- Project health overview
CREATE OR REPLACE VIEW project_health AS
SELECT
p.id as project_id,
p.name as project_name,
p.status,
p.current_phase,
c.name as client_name,
COUNT(DISTINCT s.id) as session_count,
COUNT(DISTINCT s.user_id) as active_users,
SUM(s.total_tokens) as total_tokens,
SUM(s.estimated_cost_usd) as total_cost,
COUNT(DISTINCT t.id) FILTER (WHERE t.status = 'done') as completed_tasks,
COUNT(DISTINCT t.id) FILTER (WHERE t.status != 'done') as pending_tasks,
MAX(s.last_updated) as last_activity
FROM projects p
LEFT JOIN clients c ON p.client_id = c.id
LEFT JOIN sessions s ON s.project_id = p.id
LEFT JOIN tasks t ON t.project_id = p.id
GROUP BY p.id, p.name, p.status, p.current_phase, c.name;
-- User activity summary
CREATE OR REPLACE VIEW user_activity AS
SELECT
u.id as user_id,
u.email,
u.display_name,
COUNT(DISTINCT s.id) as session_count,
COUNT(DISTINCT s.project_id) as projects_worked_on,
SUM(s.total_tokens) as total_tokens,
SUM(s.estimated_cost_usd) as total_cost,
MAX(s.last_updated) as last_activity
FROM users u
LEFT JOIN sessions s ON s.user_id = u.id
GROUP BY u.id, u.email, u.display_name;
-- Organization billing summary
CREATE OR REPLACE VIEW organization_billing AS
SELECT
c.id as client_id,
c.name as organization_name,
c.subscription_tier,
COUNT(DISTINCT u.id) as total_users,
COUNT(DISTINCT p.id) as total_projects,
COUNT(DISTINCT s.id) as total_sessions,
SUM(s.total_tokens) as total_tokens,
SUM(s.estimated_cost_usd) as total_cost,
SUM(s.estimated_cost_usd) FILTER (
WHERE s.started_at >= DATE_TRUNC('month', CURRENT_DATE)
) as current_month_cost
FROM clients c
LEFT JOIN projects p ON p.client_id = c.id
LEFT JOIN sessions s ON s.project_id = p.id
LEFT JOIN users u ON u.id = c.owner_user_id OR EXISTS (
SELECT 1 FROM project_contributors pc
WHERE pc.project_id = p.id AND pc.user_id = u.id
)
GROUP BY c.id, c.name, c.subscription_tier;
-- ==================================================
-- HELPER FUNCTIONS
-- ==================================================
-- Auto-update updated_at timestamps
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
-- Apply to tables
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_clients_updated_at BEFORE UPDATE ON clients
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_projects_updated_at BEFORE UPDATE ON projects
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_sessions_updated_at BEFORE UPDATE ON sessions
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ==================================================
-- SEED DATA (Optional)
-- ==================================================
-- Create default user (you)
INSERT INTO users (uid, email, display_name, workspace)
VALUES
('mark-admin-001', 'mark@getacquired.com', 'Mark Henderson', 'marks-account')
ON CONFLICT (email) DO NOTHING;
-- Create default client (your organization)
INSERT INTO clients (name, slug, owner_user_id, subscription_tier)
VALUES
('VIBN', 'vibn', 1, 'enterprise')
ON CONFLICT (slug) DO NOTHING;
COMMENT ON TABLE users IS 'VIBN users (developers, team members)';
COMMENT ON TABLE clients IS 'Organizations/Companies using VIBN';
COMMENT ON TABLE projects IS 'Development projects under organizations';
COMMENT ON TABLE sessions IS 'AI coding sessions with full conversation logs';
COMMENT ON TABLE work_completed IS 'AI-extracted accomplishments from sessions';
COMMENT ON TABLE architectural_decisions IS 'Design decisions made during development';

View File

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

View File

@@ -1,45 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Vibn — Onboarding</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="onboarding.css" />
<template id="__bundler_thumbnail" data-bg-color="#27201d">
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="200" fill="#27201d"/>
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#e9835d"/>
<stop offset="1" stop-color="#b53a25"/>
</linearGradient>
</defs>
<circle cx="100" cy="100" r="58" fill="url(#g)"/>
<g fill="#1a0f0a" stroke="#1a0f0a" stroke-width="2" stroke-linejoin="round">
<path d="M82 78 L94 78 L98 110 L102 78 L114 78 L102 130 Z"/>
<rect x="118" y="120" width="14" height="5" rx="1"/>
</g>
</svg>
</template>
<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="primitives.jsx"></script>
<script type="text/babel" src="onboarding-primitives.jsx"></script>
<script type="text/babel" src="onboarding-fork.jsx"></script>
<script type="text/babel" src="onboarding-entrepreneur.jsx"></script>
<script type="text/babel" src="onboarding-owner.jsx"></script>
<script type="text/babel" src="onboarding-consultant.jsx"></script>
<script type="text/babel" src="onboarding-build.jsx"></script>
<script type="text/babel" src="onboarding-app.jsx"></script>
</body>
</html>

View File

@@ -1,28 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Vibn — Onboarding</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="onboarding.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="primitives.jsx"></script>
<script type="text/babel" src="onboarding-primitives.jsx"></script>
<script type="text/babel" src="onboarding-fork.jsx"></script>
<script type="text/babel" src="onboarding-entrepreneur.jsx"></script>
<script type="text/babel" src="onboarding-owner.jsx"></script>
<script type="text/babel" src="onboarding-consultant.jsx"></script>
<script type="text/babel" src="onboarding-build.jsx"></script>
<script type="text/babel" src="onboarding-app.jsx"></script>
</body>
</html>

View File

@@ -1,227 +0,0 @@
// App — composes the page. Includes the sticky nav, the success modal that
// appears when the user submits the hero prompt, and the Tweaks panel.
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"accent": ["#ff6b47", "#ffae9a", "#9c3a1f"],
"heroVariant": "promise",
"showStopMarker": true,
"showLivePill": false
}/*EDITMODE-END*/;
const ACCENT_PRESETS = {
coral: ["#ff6b47", "#ffae9a", "#9c3a1f"], // warm coral (default)
amber: ["#ffb347", "#ffd9a3", "#9c6e1f"], // soft amber
lime: ["#9ee649", "#d2f3a6", "#3f7a1c"], // electric lime
violet: ["#b07cff", "#dabfff", "#5a2fa3"], // violet
};
function applyAccent(arr) {
// arr[0] is the hero color we map to var(--accent); compute soft + glow + fg.
const hero = arr[0];
const soft = `${hero}24`; // 14% alpha
const glow = `${hero}59`; // 35% alpha
const root = document.documentElement;
root.style.setProperty("--accent", hero);
root.style.setProperty("--accent-soft", soft);
root.style.setProperty("--accent-glow", glow);
// Foreground on accent: derive a dark-on-accent for primary buttons.
root.style.setProperty("--accent-fg", "#1a0f0a");
}
function App() {
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
const [scrolled, setScrolled] = React.useState(false);
const [showLaunch, setShowLaunch] = React.useState(null);
React.useEffect(() => {
applyAccent(t.accent);
}, [t.accent]);
React.useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 8);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
const handleStart = (prompt) => {
setShowLaunch(prompt || "Build me a tool for my business.");
};
return (
<>
<Nav scrolled={scrolled} />
<main>
<Hero onStart={handleStart} variant={t.heroVariant} />
<Wall />
<CrossedOut />
<Journey />
<Audience />
<Closing />
</main>
<Footer />
{showLaunch !== null && (
<LaunchModal prompt={showLaunch} onClose={() => setShowLaunch(null)} />
)}
<TweaksPanel title="Tweaks">
<TweakSection label="Look">
<TweakColor
label="Accent"
value={t.accent}
options={[
ACCENT_PRESETS.coral,
ACCENT_PRESETS.amber,
ACCENT_PRESETS.lime,
ACCENT_PRESETS.violet,
]}
onChange={(v) => setTweak("accent", v)}
/>
</TweakSection>
<TweakSection label="Hero">
<TweakRadio
label="Headline"
value={t.heroVariant}
options={[
{ value: "quote", label: "Reddit quote" },
{ value: "promise", label: "The promise" },
]}
onChange={(v) => setTweak("heroVariant", v)}
/>
<TweakToggle
label="Live pill"
value={t.showLivePill}
onChange={(v) => setTweak("showLivePill", v)}
/>
</TweakSection>
<TweakSection label="Journey">
<TweakToggle
label="Show 'where others stop' marker"
value={t.showStopMarker}
onChange={(v) => setTweak("showStopMarker", v)}
/>
</TweakSection>
</TweaksPanel>
{/* Tweak-driven CSS overrides */}
<style>{`
${t.showLivePill ? "" : ".live-pill { display: none !important; }"}
${t.showStopMarker ? "" : ".stop-marker { display: none !important; }"}
`}</style>
</>
);
}
function Nav({ scrolled }) {
return (
<nav className={`nav${scrolled ? " scrolled" : ""}`}>
<div className="wrap nav-inner">
<Logo />
<div className="nav-links">
<a href="#how">How it works</a>
<a href="#">Templates</a>
<a href="#">Pricing</a>
<a href="#">Stories</a>
</div>
<div className="nav-cta">
<a href="#" className="btn btn-ghost" style={{ display: "inline-flex" }}>Sign in</a>
<a href="Beta Signup.html" className="btn btn-primary">
Request invite <Arrow size={12} />
</a>
</div>
</div>
</nav>
);
}
// Modal that fires when the user submits the hero prompt. Reassures them their
// vibe will be honored — playfully sells the rest of the flow.
function LaunchModal({ prompt, onClose }) {
React.useEffect(() => {
const onKey = (e) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const [step, setStep] = React.useState(0);
React.useEffect(() => {
if (step >= 4) return undefined;
const t = setTimeout(() => setStep(step + 1), 700);
return () => clearTimeout(t);
}, [step]);
return (
<div className="modal-backdrop" onClick={onClose}>
<style>{`
.modal-backdrop {
position: fixed; inset: 0; z-index: 100;
background: oklch(0.10 0.005 60 / 0.7);
backdrop-filter: blur(8px);
display: grid; place-items: center;
padding: 24px;
animation: fadein .2s ease;
}
@keyframes fadein { from { opacity: 0; } }
.modal {
position: relative;
width: 100%; max-width: 540px;
background: linear-gradient(180deg, oklch(0.20 0.009 60), oklch(0.17 0.008 60));
border: 1px solid var(--hairline-2);
border-radius: 20px;
padding: 28px 28px 24px;
box-shadow: 0 30px 80px -20px oklch(0 0 0 / 0.6), 0 0 60px -20px var(--accent-glow);
}
.modal-close {
position: absolute; top: 14px; right: 14px;
width: 28px; height: 28px;
color: var(--fg-mute);
border-radius: 6px;
}
.modal-close:hover { color: var(--fg); background: oklch(0.25 0.01 60); }
.modal-eye { display: flex; align-items: center; gap: 10px; color: var(--accent); font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; }
.modal-eye i { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 12px var(--accent-glow); animation: pulse 2s ease-out infinite; }
.modal-title { margin-top: 12px; font-size: 24px; font-weight: 500; letter-spacing: -0.018em; line-height: 1.15; }
.modal-prompt { margin-top: 14px; padding: 12px 14px; border-radius: 10px; background: oklch(0.16 0.008 60); border: 1px solid var(--hairline); font-family: var(--font-mono); font-size: 13px; color: var(--fg-dim); line-height: 1.5; }
.modal-steps { margin-top: 18px; display: flex; flex-direction: column; gap: 10px; }
.modal-step { display: flex; align-items: center; gap: 12px; padding: 11px 14px; border-radius: 10px; background: oklch(0.165 0.008 60); border: 1px solid var(--hairline); font-size: 14px; color: var(--fg-dim); transition: all .25s; }
.modal-step.done { color: var(--fg); }
.modal-step.done .check { color: var(--ok); }
.modal-step .check { width: 18px; height: 18px; color: var(--fg-faint); flex-shrink: 0; }
.modal-step .spinner { width: 14px; height: 14px; border-radius: 50%; border: 2px solid oklch(0.30 0.01 60); border-top-color: var(--accent); animation: spin .9s linear infinite; flex-shrink: 0; }
@keyframes spin { to { transform: rotate(360deg); } }
.modal-foot { margin-top: 18px; text-align: center; font-family: var(--font-mono); font-size: 11px; color: var(--fg-faint); letter-spacing: 0.04em; }
`}</style>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<button type="button" className="modal-close" onClick={onClose}></button>
<div className="modal-eye"><i /> Vibn is on it</div>
<h3 className="modal-title">Keep vibing we've got the rest.</h3>
<div className="modal-prompt">"{prompt}"</div>
<div className="modal-steps">
{["Drafting the screens", "Setting up logins", "Saving your stuff", "Putting it online"].map((s, i) => (
<div key={s} className={`modal-step${i < step ? " done" : ""}`}>
{i < step ? (
<svg className="check" viewBox="0 0 20 20" fill="none">
<path d="M4 10.5 8 14.5 16 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
) : i === step ? (
<span className="spinner" />
) : (
<svg className="check" viewBox="0 0 20 20" fill="none">
<circle cx="10" cy="10" r="6" stroke="currentColor" strokeWidth="1.5" />
</svg>
)}
<span>{s}</span>
</div>
))}
</div>
<div className="modal-foot">No homework · No setup · No new tools to learn</div>
</div>
</div>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<App />);

View File

@@ -1,177 +0,0 @@
// Who it's for — three audience cards, each with a Reddit-style customer quote
// and Vibn's answer.
const AUDIENCE = [
{
label: "Small business owners",
icon: "shop",
quote: "I'm paying $312/month for software that does 60% of what I need and zero of the rest.",
source: "u/coffeeshop_owner · r/smallbusiness",
answer: "Build the tool that actually fits your shop — exactly your workflow, no monthly fee bleed.",
},
{
label: "Freelancers building for clients",
icon: "spark",
quote: "My client wants a quote tool. I can mock the frontend in a day. The backend? Two weeks I don't have.",
source: "u/agency_of_one · r/freelance",
answer: "Deliver the whole thing — login, data, hosting — in the same chat where you built the screens.",
},
{
label: "Anyone with an idea",
icon: "spark2",
quote: "I built the homepage in an afternoon. Then the AI told me to 'just deploy it' and I cried.",
source: "u/first_time_builder · r/sideproject",
answer: "No deploys. No GitHub. No fear. The thing you described is online, with logins, ready for users.",
},
];
function Audience() {
return (
<section className="section audience">
<style>{`
.audience-head { text-align: center; max-width: 820px; margin: 0 auto 56px; }
.audience-title {
font-size: clamp(36px, 4.8vw, 64px);
font-weight: 500; letter-spacing: -0.025em; line-height: 1.02;
text-wrap: balance;
}
.audience-sub {
margin-top: 20px;
color: var(--fg-mute);
font-size: 17px;
}
.audience-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18px;
}
@media (max-width: 1000px) { .audience-grid { grid-template-columns: 1fr; } }
.a-card {
position: relative;
padding: 28px 26px 26px;
border-radius: 18px;
background: linear-gradient(180deg, oklch(0.20 0.009 60 / 0.55), oklch(0.17 0.008 60 / 0.55));
border: 1px solid var(--hairline);
display: flex; flex-direction: column;
min-height: 380px;
overflow: hidden;
}
.a-card::after {
content: "";
position: absolute;
top: 0; left: 24px; right: 24px;
height: 1px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
opacity: .6;
}
.a-icon {
width: 40px; height: 40px;
border-radius: 10px;
display: grid; place-items: center;
background: oklch(0.22 0.011 60);
border: 1px solid var(--hairline);
color: var(--accent);
margin-bottom: 18px;
}
.a-label {
font-size: 19px; font-weight: 500;
letter-spacing: -0.015em;
color: var(--fg);
}
.a-quote {
margin: 18px 0 0;
padding: 16px 18px;
background: oklch(0.16 0.008 60 / 0.55);
border-left: 2px solid var(--accent);
border-radius: 4px 10px 10px 4px;
font-style: italic;
color: var(--fg-dim);
font-size: 14.5px;
line-height: 1.5;
position: relative;
}
.a-source {
margin-top: 8px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-faint);
letter-spacing: 0.02em;
}
.a-answer {
margin-top: auto;
padding-top: 22px;
font-size: 15px;
color: var(--fg);
line-height: 1.5;
display: flex; gap: 10px; align-items: flex-start;
}
.a-answer .label {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
padding: 3px 7px;
background: oklch(0.74 0.175 35 / 0.12);
border: 1px solid oklch(0.74 0.175 35 / 0.4);
border-radius: 4px;
margin-top: 1px;
flex-shrink: 0;
}
`}</style>
<div className="wrap">
<div className="audience-head">
<Eyebrow>Who Vibn is for</Eyebrow>
<h2 className="audience-title" style={{ marginTop: 18 }}>
People who have an idea not a stack.
</h2>
<p className="audience-sub">
If you've ever felt this, Vibn was built for you.
</p>
</div>
<div className="audience-grid">
{AUDIENCE.map((a) => (
<div className="a-card" key={a.label}>
<div className="a-icon"><AudienceIcon name={a.icon} /></div>
<div className="a-label">{a.label}</div>
<div className="a-quote">
"{a.quote}"
<div className="a-source"> {a.source}</div>
</div>
<div className="a-answer">
<span className="label">Vibn</span>
<span>{a.answer}</span>
</div>
</div>
))}
</div>
</div>
</section>
);
}
function AudienceIcon({ name }) {
const p = { width: 20, height: 20, viewBox: "0 0 20 20", fill: "none",
stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round" };
if (name === "shop") return (
<svg {...p}><path d="M3.5 6.5h13l-1 9.5h-11l-1-9.5Z"/><path d="M7 6.5V5a3 3 0 0 1 6 0v1.5"/></svg>
);
if (name === "spark") return (
<svg {...p}><path d="M10 3v4M10 13v4M3 10h4M13 10h4M5.3 5.3l2.8 2.8M11.9 11.9l2.8 2.8M14.7 5.3l-2.8 2.8M8.1 11.9l-2.8 2.8"/></svg>
);
if (name === "spark2") return (
<svg {...p}><path d="M10 2.5v3M10 14.5v3M2.5 10h3M14.5 10h3"/><circle cx="10" cy="10" r="3"/></svg>
);
return null;
}
Object.assign(window, { Audience });

View File

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

View File

@@ -1,150 +0,0 @@
// Closing CTA + Footer.
function Closing() {
return (
<section className="section closing">
<style>{`
.closing {
padding-block: clamp(100px, 14vh, 180px);
position: relative; overflow: hidden;
text-align: center;
}
.closing-glow {
position: absolute; inset: 0;
pointer-events: none;
}
.closing-inner {
position: relative;
max-width: 900px; margin: 0 auto;
}
.closing-title {
font-size: clamp(40px, 6vw, 84px);
font-weight: 500; letter-spacing: -0.03em;
line-height: 1.02;
text-wrap: balance;
}
.closing-title .accent { color: var(--accent); }
.closing-title em {
font-style: normal;
background: linear-gradient(180deg, var(--accent), oklch(0.62 0.18 18));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.closing-sub {
margin-top: 28px;
font-size: clamp(17px, 1.6vw, 21px);
color: var(--fg-dim);
text-wrap: balance;
max-width: 640px; margin-inline: auto;
}
.closing-cta {
margin-top: 36px;
display: inline-flex; flex-direction: column; align-items: center; gap: 14px;
}
.closing-cta .btn { height: 56px; padding: 0 28px; font-size: 16px; }
.closing-cta .row {
display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
justify-content: center;
}
`}</style>
<Glow color="oklch(0.74 0.175 35 / 0.35)" size={1000}
style={{ top: "20%", left: "50%", transform: "translateX(-50%)" }} />
<Glow color="oklch(0.45 0.10 35 / 0.20)" size={600}
style={{ bottom: "-200px", left: "50%", transform: "translateX(-50%)" }} />
<div className="wrap closing-inner">
<h2 className="closing-title">
If you can <em>describe</em> it,
<br/>you can <em>build</em> it.
</h2>
<p className="closing-sub">
And you can keep building it all the way to customers.
<br />No new tools. No homework. No going back to the wall.
</p>
<div className="closing-cta">
<div className="row">
<a href="Beta Signup.html" className="btn btn-primary">
Request invite <Arrow />
</a>
<a href="#how" className="btn btn-ghost">See how it works</a>
</div>
<TrustStrip items={["No credit card", "No homework", "No new tools to learn"]} />
</div>
</div>
</section>
);
}
function Footer() {
return (
<footer className="vibn-footer">
<style>{`
.vibn-footer {
position: relative;
padding: 40px 0 32px;
border-top: 1px solid var(--hairline);
background: oklch(0.14 0.008 60);
}
.vibn-footer-inner {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 32px;
flex-wrap: wrap;
}
.vibn-footer-trust {
display: flex; gap: 20px; align-items: center;
flex-wrap: wrap;
font-family: var(--font-mono);
font-size: 12px;
color: var(--fg-mute);
letter-spacing: 0.03em;
}
.vibn-footer-trust .item {
display: inline-flex; align-items: center; gap: 8px;
}
.vibn-footer-trust .sep { color: var(--fg-faint); }
.vibn-footer-bottom {
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid var(--hairline);
display: flex; justify-content: space-between; align-items: center;
gap: 16px;
flex-wrap: wrap;
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-faint);
letter-spacing: 0.04em;
}
.vibn-footer-links {
display: flex; gap: 18px;
}
.vibn-footer-links a:hover { color: var(--fg-dim); }
`}</style>
<div className="wrap">
<div className="vibn-footer-inner">
<Logo />
<div className="vibn-footer-trust">
<span className="item">🇨🇦 Built in Canada</span>
<span className="sep">·</span>
<span className="item">Your data stays safe</span>
<span className="sep">·</span>
<span className="item">No credit card to start</span>
</div>
</div>
<div className="vibn-footer-bottom">
<span>© 2026 Vibn Inc. · Made for makers, not engineers.</span>
<div className="vibn-footer-links">
<a href="#">Privacy</a>
<a href="#">Terms</a>
<a href="#">Status</a>
<a href="#">Changelog</a>
</div>
</div>
</div>
</footer>
);
}
Object.assign(window, { Closing, Footer });

View File

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

View File

@@ -1,370 +0,0 @@
// Hero: the Reddit quote headline + prompt input.
// Visitors can type into the prompt; cycling placeholders, suggestion chips, submit handler logs to console.
const HERO_PLACEHOLDERS = [
"A booking site for my dog grooming business…",
"An invoice tracker for my freelance clients…",
"A members-only recipe site for my supper club…",
"A custom CRM for our 3-person real estate team…",
"A tip calculator app for our restaurant staff…",
"A waitlist site for my new ceramics studio…",
];
const HERO_CHIPS = [
"📋 Client intake form",
"📅 Booking site",
"🧾 Invoice tracker",
"🛒 Online store",
"📰 Email newsletter",
];
function Hero({ onStart, variant = "quote" }) {
const [text, setText] = React.useState("");
const [phIdx, setPhIdx] = React.useState(0);
const [phChars, setPhChars] = React.useState(0);
const [deleting, setDeleting] = React.useState(false);
const taRef = React.useRef(null);
// Type-on placeholder when textarea is empty.
React.useEffect(() => {
if (text.length > 0) return undefined;
const full = HERO_PLACEHOLDERS[phIdx];
const speed = deleting ? 18 : 38;
const t = setTimeout(() => {
if (!deleting) {
if (phChars < full.length) setPhChars(phChars + 1);
else setTimeout(() => setDeleting(true), 1700);
} else {
if (phChars > 0) setPhChars(phChars - 1);
else {
setDeleting(false);
setPhIdx((phIdx + 1) % HERO_PLACEHOLDERS.length);
}
}
}, speed);
return () => clearTimeout(t);
}, [text, phIdx, phChars, deleting]);
const placeholder = HERO_PLACEHOLDERS[phIdx].slice(0, phChars);
const submit = () => {
const value = text || HERO_PLACEHOLDERS[phIdx];
if (onStart) onStart(value);
};
const useChip = (chip) => {
const clean = chip.replace(/^[^\w]+/, "").trim();
setText(`Build me ${clean.toLowerCase()} for my business.`);
if (taRef.current) taRef.current.focus();
};
return (
<header className="section hero">
<style>{`
.hero {
padding-top: clamp(60px, 9vh, 120px);
padding-bottom: clamp(60px, 10vh, 120px);
position: relative;
overflow: hidden;
}
.hero-inner {
position: relative;
display: flex; flex-direction: column; align-items: center;
text-align: center;
gap: 28px;
}
.hero-quote {
font-size: clamp(44px, 7.4vw, 104px);
font-weight: 500;
letter-spacing: -0.035em;
line-height: 0.98;
text-wrap: balance;
position: relative;
color: var(--fg);
}
.hero-quote .mark {
color: var(--accent);
font-family: "Geist", serif;
font-weight: 500;
line-height: 0;
vertical-align: -0.05em;
margin-inline: -0.08em;
text-shadow: 0 0 30px var(--accent-glow);
}
.hero-attribution {
font-family: var(--font-mono);
font-size: 12px;
color: var(--fg-faint);
letter-spacing: 0.04em;
margin-top: 6px;
display: inline-flex; align-items: center; gap: 8px;
}
.hero-attribution::before, .hero-attribution::after {
content: ""; width: 24px; height: 1px;
background: var(--hairline);
}
.hero-sub {
font-size: clamp(20px, 2.2vw, 28px);
color: var(--fg-dim);
letter-spacing: -0.01em;
text-wrap: balance;
max-width: 720px;
}
.hero-sub b {
color: var(--fg);
font-weight: 500;
}
/* Prompt input */
.prompt {
width: 100%;
max-width: 720px;
margin-top: 14px;
position: relative;
}
.prompt-frame {
position: relative;
border-radius: var(--r-xl);
padding: 1px;
background: linear-gradient(180deg,
oklch(0.50 0.06 35 / 0.6),
oklch(0.30 0.012 60 / 0.4) 40%,
oklch(0.25 0.012 60 / 0.4));
box-shadow:
0 30px 80px -20px oklch(0 0 0 / 0.6),
0 0 80px -20px var(--accent-glow);
}
.prompt-inner {
background: linear-gradient(180deg, oklch(0.19 0.009 60 / 0.92), oklch(0.17 0.008 60 / 0.92));
border-radius: calc(var(--r-xl) - 1px);
padding: 18px 18px 14px;
backdrop-filter: blur(20px);
}
.prompt textarea {
width: 100%;
min-height: 96px;
background: transparent;
border: 0;
color: var(--fg);
font: 17px/1.45 var(--font-sans);
resize: none;
outline: none;
padding: 6px 4px;
}
.prompt textarea::placeholder {
color: var(--fg-faint);
}
.prompt-typed {
/* simulated placeholder w/ blinking caret */
position: absolute;
top: 24px; left: 22px; right: 22px;
pointer-events: none;
color: var(--fg-faint);
font: 17px/1.45 var(--font-sans);
text-align: left;
}
.prompt-typed::after {
content: "";
display: inline-block;
width: 8px; height: 18px;
background: var(--accent);
vertical-align: -3px;
margin-left: 2px;
animation: blink 1s steps(2) infinite;
box-shadow: 0 0 12px var(--accent-glow);
}
@keyframes blink { 50% { opacity: 0; } }
.prompt-bar {
display: flex; align-items: center; justify-content: space-between;
gap: 14px;
margin-top: 6px;
padding-top: 12px;
border-top: 1px solid var(--hairline);
}
.prompt-tools {
display: flex; gap: 6px; color: var(--fg-mute);
}
.prompt-tool {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 10px; border-radius: 999px;
font-size: 12px;
color: var(--fg-mute);
border: 1px solid transparent;
transition: border-color .15s, color .15s;
}
.prompt-tool:hover { color: var(--fg-dim); border-color: var(--hairline); }
.prompt-send {
display: inline-flex; align-items: center; gap: 8px;
height: 36px; padding: 0 14px 0 16px;
border-radius: 999px;
background: var(--accent);
color: var(--accent-fg);
font-weight: 500; font-size: 14px;
box-shadow: 0 0 0 1px oklch(0.84 0.16 35 / 0.5) inset, 0 8px 28px -8px var(--accent-glow);
transition: transform .12s;
}
.prompt-send:hover { transform: translateY(-1px); }
.chips {
display: flex; flex-wrap: wrap; gap: 8px; justify-content: center;
margin-top: 12px;
font-size: 13px;
}
.chip {
padding: 7px 14px;
border-radius: 999px;
border: 1px solid var(--hairline);
background: oklch(0.20 0.009 60 / 0.4);
color: var(--fg-dim);
font-family: var(--font-sans);
transition: border-color .15s, color .15s, transform .12s;
}
.chip:hover { border-color: var(--hairline-2); color: var(--fg); transform: translateY(-1px); }
.hero-cta {
display: flex; gap: 12px; align-items: center;
margin-top: 10px;
flex-wrap: wrap; justify-content: center;
}
.live-pill {
display: inline-flex; align-items: center; gap: 8px;
padding: 6px 12px; border-radius: 999px;
background: oklch(0.78 0.16 155 / 0.10);
border: 1px solid oklch(0.78 0.16 155 / 0.35);
color: oklch(0.85 0.14 155);
font-family: var(--font-mono);
font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase;
}
.live-pill .dot {
width: 6px; height: 6px; border-radius: 50%;
background: oklch(0.78 0.16 155);
box-shadow: 0 0 0 0 oklch(0.78 0.16 155 / 0.6);
animation: pulse 2s ease-out infinite;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 oklch(0.78 0.16 155 / 0.6); }
70% { box-shadow: 0 0 0 8px oklch(0.78 0.16 155 / 0); }
100% { box-shadow: 0 0 0 0 oklch(0.78 0.16 155 / 0); }
}
@media (max-width: 760px) {
.prompt textarea { min-height: 80px; }
.prompt-typed { top: 22px; left: 20px; right: 20px; font-size: 15px; }
.prompt textarea { font-size: 15px; }
.prompt-tools { display: none; }
}
`}</style>
{/* ambient glows behind hero */}
<Glow color="oklch(0.74 0.175 35 / 0.30)" size={900}
style={{ top: "-200px", left: "50%", transform: "translateX(-50%)" }} />
<Glow color="oklch(0.45 0.10 35 / 0.30)" size={600}
style={{ top: "20%", left: "-200px" }} />
<Glow color="oklch(0.45 0.10 35 / 0.20)" size={500}
style={{ top: "30%", right: "-150px" }} />
<div className="wrap hero-inner">
<span className="live-pill"><span className="dot" /> Live from minute one</span>
{variant === "promise" ? (
<>
<h1 className="hero-quote">
Keep <span className="mark">vibing</span>.
<br/>All the way to launch.
</h1>
<div className="hero-attribution mono">idea live marketed customers</div>
<p className="hero-sub">
<b>"I built my product, now what?"</b> Vibn is the answer.
<br/>Your AI handles the technical stuff, puts your idea online, and helps you find your first customers.
</p>
</>
) : (
<>
<h1 className="hero-quote">
<span className="mark" style={{ fontSize: "0.95em" }}>"</span>I built my product,
<br/>now what<span className="mark" style={{ fontSize: "0.95em" }}>?"</span>
</h1>
<div className="hero-attribution mono">posted 2 hours ago · r/SideProject</div>
<p className="hero-sub">
<b>Keep vibing.</b> All the way to launch.
<br/>Your AI handles the technical stuff, puts your idea online, and helps you find your first customers.
</p>
</>
)}
{/* Prompt */}
<div className="prompt">
<div className="prompt-frame">
<div className="prompt-inner">
<div style={{ position: "relative" }}>
<textarea
ref={taRef}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) submit(); }}
placeholder=""
aria-label="Describe what you want to build"
/>
{text.length === 0 && (
<div className="prompt-typed">{placeholder}</div>
)}
</div>
<div className="prompt-bar">
<div className="prompt-tools">
<button className="prompt-tool" type="button" title="Attach a screenshot">
<PromptIcon name="paperclip" /> Screenshot
</button>
<button className="prompt-tool" type="button" title="Voice prompt">
<PromptIcon name="mic" /> Voice
</button>
<button className="prompt-tool" type="button" title="Start from a template">
<PromptIcon name="grid" /> Templates
</button>
</div>
<button className="prompt-send" type="button" onClick={submit}>
Start building <Arrow size={13} />
</button>
</div>
</div>
</div>
{/* Suggestion chips */}
<div className="chips">
{HERO_CHIPS.map((c) => (
<button className="chip" type="button" key={c} onClick={() => useChip(c)}>{c}</button>
))}
</div>
</div>
<div className="hero-cta">
<button className="btn btn-primary" type="button" onClick={submit}>
Start building free <Arrow />
</button>
<a href="#how" className="btn btn-ghost">See how it works</a>
</div>
<TrustStrip items={["No credit card", "No homework", "No new tools to learn"]} />
</div>
</header>
);
}
function PromptIcon({ name }) {
const props = { width: 13, height: 13, viewBox: "0 0 16 16", fill: "none",
stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round" };
if (name === "paperclip") return (
<svg {...props}><path d="M11.5 6.5 6.6 11.4a2 2 0 1 1-2.8-2.8l5.4-5.4a3.5 3.5 0 1 1 5 5L8.6 13.7" /></svg>
);
if (name === "mic") return (
<svg {...props}><rect x="6" y="2" width="4" height="8" rx="2"/><path d="M3.5 8a4.5 4.5 0 0 0 9 0M8 13v2"/></svg>
);
if (name === "grid") return (
<svg {...props}><rect x="2.5" y="2.5" width="4.5" height="4.5"/><rect x="9" y="2.5" width="4.5" height="4.5"/><rect x="2.5" y="9" width="4.5" height="4.5"/><rect x="9" y="9" width="4.5" height="4.5"/></svg>
);
return null;
}
Object.assign(window, { Hero });

View File

@@ -1,219 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Vibn — Keep vibing. All the way to launch.</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="icon" type="image/png" href="assets/logo-black.png" />
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet" />
<style>
:root {
--bg: oklch(0.155 0.008 60);
--bg-1: oklch(0.185 0.009 60);
--bg-2: oklch(0.225 0.010 60);
--hairline: oklch(0.32 0.010 60 / 0.55);
--hairline-2: oklch(0.40 0.012 60 / 0.35);
--fg: oklch(0.97 0.005 80);
--fg-dim: oklch(0.78 0.006 80);
--fg-mute: oklch(0.58 0.006 80);
--fg-faint: oklch(0.42 0.006 80);
--accent: oklch(0.74 0.175 35); /* warm coral, default */
--accent-soft: oklch(0.74 0.175 35 / 0.18);
--accent-glow: oklch(0.74 0.175 35 / 0.35);
--accent-fg: oklch(0.18 0.04 35);
--ok: oklch(0.78 0.16 155);
--r-sm: 8px;
--r-md: 12px;
--r-lg: 18px;
--r-xl: 28px;
--maxw: 1240px;
--pad: clamp(20px, 4vw, 56px);
--font-sans: "Geist", ui-sans-serif, system-ui, -apple-system, "Helvetica Neue", sans-serif;
--font-mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--fg);
font-family: var(--font-sans);
font-weight: 400;
line-height: 1.45;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overflow-x: hidden;
}
body::before {
/* subtle grid */
content: "";
position: fixed; inset: 0;
background-image:
linear-gradient(to right, oklch(0.30 0.01 60 / 0.10) 1px, transparent 1px),
linear-gradient(to bottom, oklch(0.30 0.01 60 / 0.10) 1px, transparent 1px);
background-size: 56px 56px;
mask-image: radial-gradient(ellipse 90% 70% at 50% 30%, #000 30%, transparent 80%);
-webkit-mask-image: radial-gradient(ellipse 90% 70% at 50% 30%, #000 30%, transparent 80%);
pointer-events: none;
z-index: 0;
}
body::after {
/* film grain */
content: "";
position: fixed; inset: 0;
pointer-events: none;
z-index: 1;
opacity: 0.035;
mix-blend-mode: overlay;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%25' height='100%25' filter='url(%23n)' opacity='0.85'/></svg>");
}
/* base typography */
h1, h2, h3, h4 { margin: 0; font-weight: 500; letter-spacing: -0.02em; line-height: 1.05; }
p { margin: 0; }
a { color: inherit; text-decoration: none; }
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
::selection { background: var(--accent); color: var(--accent-fg); }
/* re-usable layout */
.wrap {
position: relative;
width: 100%;
max-width: var(--maxw);
margin: 0 auto;
padding-inline: var(--pad);
z-index: 2;
}
.mono { font-family: var(--font-mono); font-feature-settings: "ss01" on; }
.eyebrow {
display: inline-flex; align-items: center; gap: 8px;
font-family: var(--font-mono);
font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase;
color: var(--fg-mute);
}
.eyebrow::before {
content: ""; width: 5px; height: 5px; border-radius: 50%;
background: var(--accent); box-shadow: 0 0 12px var(--accent-glow);
}
/* primary button */
.btn {
display: inline-flex; align-items: center; gap: 10px;
height: 46px; padding: 0 22px;
border-radius: 999px;
font-weight: 500;
transition: transform .12s ease, box-shadow .2s ease, background .2s ease;
white-space: nowrap;
}
.btn-primary {
background: var(--accent);
color: var(--accent-fg);
box-shadow:
0 0 0 1px oklch(0.84 0.16 35 / 0.5) inset,
0 10px 40px -10px var(--accent-glow),
0 0 50px -8px var(--accent-glow);
}
.btn-primary:hover { transform: translateY(-1px); }
.btn-primary .arrow { transition: transform .15s ease; }
.btn-primary:hover .arrow { transform: translateX(3px); }
.btn-ghost {
color: var(--fg-dim);
border: 1px solid var(--hairline);
background: oklch(0.20 0.009 60 / 0.4);
backdrop-filter: blur(8px);
}
.btn-ghost:hover { color: var(--fg); border-color: var(--hairline-2); }
/* gradient hairline card */
.card {
position: relative;
background: linear-gradient(180deg, oklch(0.20 0.009 60 / 0.55), oklch(0.17 0.008 60 / 0.5));
border-radius: var(--r-lg);
padding: 28px;
}
.card::before {
content: ""; position: absolute; inset: 0; border-radius: inherit; padding: 1px;
background: linear-gradient(180deg, var(--hairline-2), oklch(0.22 0.010 60 / 0.2));
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
/* hide ::-webkit-scrollbar globally where it'd jitter layout */
.no-scrollbar::-webkit-scrollbar { display: none; }
/* nav */
.nav {
position: sticky; top: 0;
z-index: 50;
backdrop-filter: blur(12px) saturate(140%);
-webkit-backdrop-filter: blur(12px) saturate(140%);
background: oklch(0.155 0.008 60 / 0.55);
border-bottom: 1px solid oklch(0.30 0.01 60 / 0.0);
transition: border-color .2s;
}
.nav.scrolled { border-bottom-color: oklch(0.30 0.01 60 / 0.4); }
.nav-inner {
display: flex; align-items: center; justify-content: space-between;
height: 64px;
}
.logo {
display: inline-flex; align-items: center; gap: 9px;
font-weight: 600; font-size: 17px; letter-spacing: -0.02em;
}
.logo-mark {
width: 26px; height: 26px; border-radius: 50%;
background: linear-gradient(135deg, var(--accent) 0%, oklch(0.65 0.20 18) 100%);
box-shadow: 0 0 22px var(--accent-glow), inset 0 1px 0 oklch(1 0 0 / 0.25);
display: grid; place-items: center;
color: var(--accent-fg);
flex-shrink: 0;
}
.logo-mark svg { display: block; }
.logo-caret { animation: caret-blink 1.4s steps(2) infinite; }
@keyframes caret-blink { 50% { opacity: 0.25; } }
.nav-links { display: flex; gap: 28px; color: var(--fg-mute); font-size: 14px; }
.nav-links a:hover { color: var(--fg); }
.nav-cta { display: flex; gap: 10px; align-items: center; }
.nav-cta .btn { height: 36px; padding: 0 16px; font-size: 14px; }
@media (max-width: 760px) { .nav-links { display: none; } }
/* section spacing */
.section { position: relative; padding-block: clamp(80px, 11vh, 140px); }
.section-tight { padding-block: clamp(60px, 8vh, 100px); }
/* responsive grid helper */
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
@media (max-width: 880px) { .grid-3 { grid-template-columns: 1fr; } }
</style>
<!-- React + Babel -->
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="tweaks-panel.jsx"></script>
<script type="text/babel" src="primitives.jsx"></script>
<script type="text/babel" src="hero.jsx"></script>
<script type="text/babel" src="wall.jsx"></script>
<script type="text/babel" src="crossed.jsx"></script>
<script type="text/babel" src="journey.jsx"></script>
<script type="text/babel" src="audience.jsx"></script>
<script type="text/babel" src="closing.jsx"></script>
<script type="text/babel" src="app.jsx"></script>
</body>
</html>

View File

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

View File

@@ -1,181 +0,0 @@
// Root onboarding app — owns the route state and the answers dict.
// Routes: fork → <path> → build → ready. A floating debug navigator (toggle
// in the lower-right) lets reviewers jump between any screen without
// filling out the form.
function OnboardingApp() {
const initialName = React.useMemo(() => {
try { return localStorage.getItem("vibn:firstName") || ""; } catch { return ""; }
}, []);
const [stage, setStage] = React.useState("fork"); // fork | path | build | ready
const [path, setPath] = React.useState(null); // entrepreneur | owner | consultant
const [forkChoice, setForkChoice] = React.useState(null);
const [step, setStep] = React.useState(0);
const [data, setData] = React.useState({});
const [debugOpen, setDebugOpen] = React.useState(false);
const update = (patch) => setData((d) => ({ ...d, ...patch }));
// ── transitions ──────────────────────────────────────────────────────
const confirmFork = () => {
if (!forkChoice) return;
setPath(forkChoice);
setStep(0);
setStage("path");
};
const backToFork = () => { setStage("fork"); setStep(0); };
const completePath = () => setStage("build");
const openWorkspace = () => setStage("ready");
const close = () => { window.location.href = "index.html"; };
const openChat = () => { window.location.href = "index.html"; };
// ⌘↵ advances on whatever the current primary action is
React.useEffect(() => {
const handler = (e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
const btn = document.querySelector(".btn-primary:not([disabled])");
if (btn) btn.click();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
// ── render ───────────────────────────────────────────────────────────
let body;
if (stage === "fork") {
body = (
<ForkScreen
name={initialName}
value={forkChoice}
onChange={setForkChoice}
onClose={close}
onNext={confirmFork}
/>
);
} else if (stage === "path") {
const props = {
data, onUpdate: update,
onBack: backToFork,
onClose: close,
onComplete: completePath,
onJumpToStep: setStep,
step,
};
if (path === "entrepreneur") body = <EntrepreneurPath {...props} />;
else if (path === "owner") body = <OwnerPath {...props} />;
else body = <ConsultantPath {...props} />;
} else if (stage === "build") {
body = (
<BuildScreen
path={path} data={data}
onBack={() => setStage("path")}
onClose={close}
onOpen={openWorkspace}
/>
);
} else {
body = <ReadyScreen path={path} data={data} onClose={close} onOpenChat={openChat} />;
}
return (
<div className="app">
{body}
<DebugNav
open={debugOpen} setOpen={setDebugOpen}
stage={stage} path={path} step={step}
onJump={(s, p, idx) => {
if (s === "fork") setStage("fork");
else if (s === "build") { setPath(p); setStage("build"); }
else if (s === "ready") { setPath(p); setStage("ready"); }
else { setPath(p); setStep(idx); setStage("path"); }
}}
/>
</div>
);
}
// ── Debug navigator ──────────────────────────────────────────────────────
function DebugNav({ open, setOpen, stage, path, step, onJump }) {
const groups = [
{ title: "Start", rows: [
{ label: "01 · Fork", active: stage === "fork", go: () => onJump("fork") },
]},
{ title: "Entrepreneur", rows: [
{ label: "02 · Idea", active: stage === "path" && path === "entrepreneur" && step === 0, go: () => onJump("path", "entrepreneur", 0) },
{ label: "03 · Audience", active: stage === "path" && path === "entrepreneur" && step === 1, go: () => onJump("path", "entrepreneur", 1) },
{ label: "04 · Goal", active: stage === "path" && path === "entrepreneur" && step === 2, go: () => onJump("path", "entrepreneur", 2) },
{ label: "05 · Vibe", active: stage === "path" && path === "entrepreneur" && step === 3, go: () => onJump("path", "entrepreneur", 3) },
]},
{ title: "Owner", rows: [
{ label: "02 · Business", active: stage === "path" && path === "owner" && step === 0, go: () => onJump("path", "owner", 0) },
{ label: "03 · Stack", active: stage === "path" && path === "owner" && step === 1, go: () => onJump("path", "owner", 1) },
{ label: "04 · First fix", active: stage === "path" && path === "owner" && step === 2, go: () => onJump("path", "owner", 2) },
{ label: "05 · Scale", active: stage === "path" && path === "owner" && step === 3, go: () => onJump("path", "owner", 3) },
]},
{ title: "Consultant", rows: [
{ label: "02 · Client", active: stage === "path" && path === "consultant" && step === 0, go: () => onJump("path", "consultant", 0) },
{ label: "03 · Brief", active: stage === "path" && path === "consultant" && step === 1, go: () => onJump("path", "consultant", 1) },
{ label: "04 · Scope", active: stage === "path" && path === "consultant" && step === 2, go: () => onJump("path", "consultant", 2) },
{ label: "05 · Handoff", active: stage === "path" && path === "consultant" && step === 3, go: () => onJump("path", "consultant", 3) },
]},
{ title: "Finish", rows: [
{ label: "Build · entrepreneur", active: stage === "build" && path === "entrepreneur", go: () => onJump("build", "entrepreneur") },
{ label: "Build · owner", active: stage === "build" && path === "owner", go: () => onJump("build", "owner") },
{ label: "Build · consultant", active: stage === "build" && path === "consultant", go: () => onJump("build", "consultant") },
{ label: "Ready", active: stage === "ready", go: () => onJump("ready", path || "entrepreneur") },
]},
];
return (
<div className="debug">
{open && (
<div className="debug-panel">
{groups.map((g) => (
<React.Fragment key={g.title}>
<div style={{
fontFamily: "var(--font-mono)",
fontSize: 9.5,
color: "var(--fg-faint)",
letterSpacing: "0.14em",
textTransform: "uppercase",
padding: "8px 8px 4px",
}}>{g.title}</div>
{g.rows.map((r) => (
<button
key={r.label}
type="button"
className={"debug-row" + (r.active ? " active" : "")}
onClick={r.go}
>
{r.active && <b> </b>}{r.label}
</button>
))}
</React.Fragment>
))}
<button
type="button"
className="debug-row"
onClick={() => setOpen(false)}
style={{ marginTop: 8, justifyContent: "center", color: "var(--fg-mute)" }}
>
Close
</button>
</div>
)}
<button
type="button"
className="debug-toggle"
onClick={() => setOpen((o) => !o)}
title="Designer navigator"
>
<span style={{ color: "var(--accent)", marginRight: 6 }}></span>
{stage === "path" ? `${path} · step ${step + 1}` : stage}
</button>
</div>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<OnboardingApp />);

View File

@@ -1,445 +0,0 @@
// Build + Ready screens. The build screen shows the terminal stream + a live
// preview stencil; ready is a quiet confirmation page with the workspace URL.
// ── Per-path build plans ───────────────────────────────────────────────────
function buildPlanFor(path, data) {
const common = [
{ line: "vibn init — reading brief", ms: 600 },
{ line: "↳ provisioning workspace", ms: 700 },
{ line: "↳ wiring auth (email + Google)", ms: 800 },
{ line: "↳ minting database & seed schema", ms: 700 },
];
if (path === "entrepreneur") {
return [
...common,
{ line: `↳ generating landing page · vibe "${data.vibe || "warm"}"`, ms: 900 },
{ line: `↳ writing copy aimed at "${(data.audience || "your audience").slice(0, 40)}"`, ms: 800 },
{ line: "↳ wiring email capture + Stripe payment link", ms: 700 },
{ line: "↳ scaffolding admin: subscribers, sales, comments", ms: 800 },
{ line: `↳ tuning launch plan for goal: ${data.goal || "first_customer"}`, ms: 700 },
{ line: "↳ publishing preview → " + workspaceUrlFor(path, data), ms: 900 },
{ line: "ready.", ms: 400, ok: true },
];
}
if (path === "owner") {
return [
...common,
{ line: `↳ modelling ${data.biz || "small business"} for "${data.bizName || "your business"}"`, ms: 800 },
{ line: `↳ importing your stack (${(data.tools || []).length} tools)`, ms: 800 },
{ line: `↳ building module: ${labelFor(data.firstThing)}`, ms: 1000 },
{ line: "↳ generating customer + job records (10 sample)", ms: 700 },
{ line: "↳ scheduling daily ops view + weekly report", ms: 700 },
{ line: `↳ wiring savings tracker · est. $${(data.spend || 0)}/mo replaced`, ms: 800 },
{ line: "↳ publishing preview → " + workspaceUrlFor(path, data), ms: 900 },
{ line: "ready.", ms: 400, ok: true },
];
}
return [
...common,
{ line: `↳ branding workspace for "${data.clientName || "client"}"`, ms: 800 },
{ line: `↳ scaffolding scope (${(data.scope || []).length} modules)`, ms: 1000 },
...(data.scope || []).slice(0, 4).map((s) => ({ line: `${s}`, ms: 350 })),
{ line: "↳ generating handoff document + invoice template", ms: 700 },
{ line: `↳ setting deploy target: ${data.handoff || "subdomain"}`, ms: 700 },
{ line: "↳ publishing preview → " + workspaceUrlFor(path, data), ms: 900 },
{ line: "ready.", ms: 400, ok: true },
];
}
function labelFor(id) {
const map = {
booking: "Bookings & scheduling",
invoicing: "Quotes & invoices",
customers: "Customer portal",
inventory: "Inventory & orders",
team: "Team & dispatch",
marketing: "Marketing site",
};
return map[id] || "your first workflow";
}
function workspaceUrlFor(path, data) {
const seed =
(path === "owner" && data.bizName) ||
(path === "consultant" && data.clientName) ||
(path === "entrepreneur" && (data.audience || data.idea)) ||
"your-workspace";
const slug = String(seed).toLowerCase()
.replace(/[^a-z0-9\s-]/g, "")
.trim()
.split(/\s+/)
.slice(0, 3)
.join("-")
.slice(0, 28) || "your-workspace";
return `${slug}.vibn.app`;
}
const BUILD_BIZ_LABEL = {
service: "Trades / services",
retail: "Retail",
food: "Food & drink",
appointments: "Appointments",
events: "Events / hospitality",
other: "Small business",
};
// ── Build screen ───────────────────────────────────────────────────────────
function BuildScreen({ path, data, onBack, onClose, onOpen }) {
const plan = React.useMemo(() => buildPlanFor(path, data), [path, data]);
const [lineIdx, setLineIdx] = React.useState(0);
const [done, setDone] = React.useState(false);
const logRef = React.useRef(null);
React.useEffect(() => {
if (lineIdx >= plan.length) { setDone(true); return undefined; }
const t = setTimeout(() => setLineIdx(lineIdx + 1), plan[lineIdx].ms);
return () => clearTimeout(t);
}, [lineIdx, plan]);
React.useEffect(() => {
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
}, [lineIdx]);
const url = workspaceUrlFor(path, data);
const lane = LANE_LABELS[path];
const previewTitle =
path === "owner" ? (data.bizName || "Your business") :
path === "consultant" ? (data.clientName || "Your client") :
"Your launch page";
const previewSub =
path === "owner" ? `${BUILD_BIZ_LABEL[data.biz] || "Small business"} · ${labelFor(data.firstThing)}` :
path === "consultant" ? (data.industry || "Project") :
(data.audience || "An idea worth building").slice(0, 64);
const pct = done ? 1 : lineIdx / plan.length;
return (
<>
<WizardTop
onBack={onBack}
onClose={onClose}
lane={lane}
stepText={done ? "Done" : "Building"}
progress={pct}
/>
<main className="wiz-body" style={{ paddingTop: "clamp(28px, 5vh, 56px)" }}>
<div className="wiz-card xwide" style={{ gap: 18 }}>
<style>{`
.b-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr);
gap: 18px;
}
@media (max-width: 920px) { .b-grid { grid-template-columns: 1fr; } }
.b-pane {
border-radius: 12px;
border: 1px solid var(--hairline);
background: linear-gradient(180deg, oklch(0.16 0.008 60 / 0.92), oklch(0.14 0.008 60 / 0.92));
min-height: 420px;
display: flex; flex-direction: column;
overflow: hidden;
}
.b-pane-bar {
display: flex; align-items: center; gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid var(--hairline);
background: oklch(0.16 0.008 60 / 0.6);
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-mute);
letter-spacing: 0.04em;
}
.b-pane-bar .live {
margin-left: auto;
display: inline-flex; align-items: center; gap: 6px;
color: oklch(0.85 0.16 155);
font-size: 10.5px; letter-spacing: 0.08em; text-transform: uppercase;
}
.b-pane-bar .live::before {
content: ""; width: 5px; height: 5px; border-radius: 50%;
background: oklch(0.78 0.16 155);
box-shadow: 0 0 0 0 oklch(0.78 0.16 155 / 0.6);
animation: pulse 2s ease-out infinite;
}
.b-log {
flex: 1;
padding: 14px 16px;
font-family: var(--font-mono);
font-size: 12.5px;
line-height: 1.7;
color: var(--fg-dim);
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
.b-log .l-ok { color: oklch(0.85 0.16 155); font-weight: 500; }
.b-log .l-current { color: var(--fg); }
.b-log .l-done { color: var(--fg-mute); }
.b-log .l-done::before { content: "✓ "; color: var(--ok); margin-right: 2px; }
.b-log .l-current::before { content: "● "; color: var(--accent); margin-right: 2px;
animation: blink 1s steps(2) infinite; }
.b-cursor {
display: inline-block;
width: 7px; height: 13px; vertical-align: -2px;
background: var(--accent);
margin-left: 2px;
animation: blink 1s steps(2) infinite;
box-shadow: 0 0 12px var(--accent-glow);
}
.b-prev-bar {
padding: 10px 14px;
display: flex; gap: 10px; align-items: center;
border-bottom: 1px solid var(--hairline);
background: oklch(0.16 0.008 60 / 0.5);
}
.b-prev-url {
font-family: var(--font-mono);
font-size: 11.5px;
color: var(--fg-mute);
padding: 4px 12px;
background: oklch(0.13 0.008 60);
border: 1px solid var(--hairline);
border-radius: 999px;
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.b-prev-stage {
flex: 1;
display: flex; align-items: center; justify-content: center;
padding: 22px;
}
.b-stencil {
width: 100%;
display: flex; flex-direction: column; gap: 12px;
opacity: 0;
animation: stencil-in 0.7s ease-out forwards;
}
@keyframes stencil-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.b-stencil-block {
border-radius: 9px;
background: linear-gradient(135deg, oklch(0.22 0.011 60), oklch(0.18 0.009 60));
border: 1px solid var(--hairline);
position: relative; overflow: hidden;
}
.b-stencil-block::after {
content: "";
position: absolute; inset: 0;
background: linear-gradient(90deg, transparent, oklch(1 0 0 / 0.06), transparent);
transform: translateX(-100%);
animation: shimmer 2s ease-in-out infinite;
}
@keyframes shimmer { to { transform: translateX(100%); } }
@keyframes blink { 50% { opacity: 0.25; } }
@keyframes pulse {
0% { box-shadow: 0 0 0 0 oklch(0.78 0.16 155 / 0.6); }
70% { box-shadow: 0 0 0 8px oklch(0.78 0.16 155 / 0); }
100% { box-shadow: 0 0 0 0 oklch(0.78 0.16 155 / 0); }
}
.b-foot {
display: flex; align-items: center; justify-content: space-between;
gap: 14px;
padding-top: 6px;
}
.b-foot-status {
font-family: var(--font-mono);
font-size: 11px; letter-spacing: 0.06em;
color: var(--fg-faint);
}
.b-foot-status b { color: var(--accent); font-weight: 500; }
`}</style>
<WizardQ
title={done ? "Your workspace is live." : "Building your workspace…"}
sub={done
? "Open it to see what Vibn made. Every change from here happens in the chat."
: "Nothing for you to do. Vibn is scaffolding everything from your answers."}
/>
<div className="b-grid">
{/* terminal */}
<div className="b-pane">
<div className="b-pane-bar">
<span style={{ width: 9, height: 9, borderRadius: "50%", background: "oklch(0.65 0.18 25 / 0.7)" }} />
<span style={{ width: 9, height: 9, borderRadius: "50%", background: "oklch(0.78 0.13 80 / 0.7)" }} />
<span style={{ width: 9, height: 9, borderRadius: "50%", background: "oklch(0.72 0.16 145 / 0.7)" }} />
<span style={{ marginLeft: 6 }}>vibn build {url}</span>
{!done && <span className="live">live</span>}
</div>
<div className="b-log" ref={logRef}>
{plan.slice(0, lineIdx + 1).map((p, i) => {
const isCurrent = i === lineIdx && !done;
const isOk = p.ok && i <= lineIdx;
const cls = isOk ? "l-ok" : isCurrent ? "l-current" : "l-done";
return <div key={i} className={cls}>{p.line}</div>;
})}
{!done && <span className="b-cursor" aria-hidden="true" />}
</div>
</div>
{/* preview */}
<div className="b-pane">
<div className="b-prev-bar">
<span style={{ display: "flex", gap: 5 }}>
<span style={{ width: 9, height: 9, borderRadius: "50%", background: "oklch(0.30 0.010 60)" }}/>
<span style={{ width: 9, height: 9, borderRadius: "50%", background: "oklch(0.30 0.010 60)" }}/>
<span style={{ width: 9, height: 9, borderRadius: "50%", background: "oklch(0.30 0.010 60)" }}/>
</span>
<span className="b-prev-url">https://{url}</span>
</div>
<div className="b-prev-stage">
<div className="b-stencil">
<div style={{ fontSize: 20, fontWeight: 500, letterSpacing: "-0.02em", color: "var(--fg)", lineHeight: 1.15 }}>
{previewTitle}
</div>
<div style={{ fontSize: 13, color: "var(--fg-mute)", lineHeight: 1.45 }}>
{previewSub}
</div>
<div style={{ height: 4 }} />
<div className="b-stencil-block" style={{ height: 80 }} />
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
<div className="b-stencil-block" style={{ height: 60, animationDelay: "0.4s" }} />
<div className="b-stencil-block" style={{ height: 60, animationDelay: "0.8s" }} />
</div>
</div>
</div>
</div>
</div>
<div className="b-foot">
<span className="b-foot-status">
{done ? "build complete" : <>Building <b>{Math.min(lineIdx, plan.length)}/{plan.length}</b></>}
</span>
{done && (
<button type="button" className="btn btn-primary btn-wiz" onClick={onOpen}>
Open my workspace <Arrow size={13} />
</button>
)}
</div>
</div>
</main>
</>
);
}
// ── Ready screen ───────────────────────────────────────────────────────────
function ReadyScreen({ path, data, onClose, onOpenChat }) {
const url = workspaceUrlFor(path, data);
const summary = summaryFor(path, data);
return (
<>
<WizardTop
onClose={onClose}
lane={LANE_LABELS[path]}
stepText="Ready"
progress={1}
/>
<WizardBody>
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 8 }}>
<span
style={{
width: 32, height: 32, borderRadius: 8,
background: "linear-gradient(135deg, var(--accent), oklch(0.65 0.20 18))",
boxShadow: "0 0 18px var(--accent-glow)",
display: "grid", placeItems: "center",
color: "var(--accent-fg)", flexShrink: 0,
}}
>
<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>
</span>
</div>
<WizardQ
title="You're in."
sub="Workspace provisioned, first build is online. Every change from here happens in the chat."
/>
<div
style={{
borderRadius: 10,
border: "1px solid var(--hairline)",
background: "oklch(0.18 0.009 60 / 0.6)",
overflow: "hidden",
}}
>
<div
style={{
padding: "14px 16px",
borderBottom: "1px solid var(--hairline)",
display: "flex", alignItems: "center", gap: 10,
}}
>
<span className="mono" style={{ fontSize: 10.5, color: "var(--fg-faint)", letterSpacing: "0.12em", textTransform: "uppercase" }}>
Workspace URL
</span>
<span className="mono" style={{ fontSize: 14, color: "var(--fg)", marginLeft: "auto" }}>
{url}
</span>
</div>
<div style={{ padding: "12px 16px 14px", display: "flex", flexDirection: "column", gap: 8 }}>
{summary.map((row, i) => (
<div key={i} style={{ display: "flex", gap: 14, alignItems: "baseline", fontSize: 13.5 }}>
<span
className="mono"
style={{
width: 86, flexShrink: 0,
color: "var(--fg-faint)",
fontSize: 10.5, letterSpacing: "0.08em", textTransform: "uppercase",
}}
>
{row.label}
</span>
<span style={{ color: "var(--fg-dim)" }}>{row.value}</span>
</div>
))}
</div>
</div>
<div className="wiz-foot">
<a href="index.html" className="wiz-skip">Back to home</a>
<button type="button" className="btn btn-primary btn-wiz" onClick={onOpenChat}>
Open the build chat <Arrow size={13} />
</button>
</div>
</WizardBody>
</>
);
}
function summaryFor(path, data) {
if (path === "owner") {
return [
{ label: "Business", value: `${data.bizName || "Untitled"} · ${BUILD_BIZ_LABEL[data.biz] || "Small business"}` },
{ label: "Replacing", value: `${(data.tools || []).length} tools · ~$${data.spend || 0}/mo` },
{ label: "First fix", value: labelFor(data.firstThing) },
{ label: "Team", value: `${data.team || 1} · ${(data.customers || 0).toLocaleString()} cust/mo` },
];
}
if (path === "consultant") {
return [
{ label: "Client", value: `${data.clientName || "Untitled"} · ${data.industry || ""}` },
{ label: "Scope", value: `${(data.scope || []).length} modules` },
{ label: "Brief", value: (data.brief || "").slice(0, 60) + ((data.brief || "").length > 60 ? "…" : "") },
{ label: "Handoff", value: data.handoff || "subdomain" },
];
}
return [
{ label: "Building", value: (data.idea || "").slice(0, 64) + ((data.idea || "").length > 64 ? "…" : "") },
{ label: "Audience", value: (data.audience || "").slice(0, 64) },
{ label: "Goal", value: data.goal || "first_customer" },
{ label: "Vibe", value: data.vibe || "warm" },
];
}
Object.assign(window, { BuildScreen, ReadyScreen });

View File

@@ -1,294 +0,0 @@
// Consultant path — 4 steps for freelancers building for a client.
const CONS_TOTAL = 4;
const CONS_STEP_NAMES = ["Client", "Brief", "Scope", "Handoff"];
function ConsClient({ clientName, industry, contact, onChange }) {
return (
<>
<WizardQ
title="Who are you building for?"
sub="Used to brand the preview and the handoff doc."
/>
<Field label="Client / company">
<input
className="wiz-input"
placeholder="Rivera & Co Roofing"
value={clientName}
onChange={(e) => onChange({ clientName: e.target.value })}
autoFocus
/>
</Field>
<Field label="What they do">
<input
className="wiz-input"
placeholder="Residential roofing, mostly insurance jobs"
value={industry}
onChange={(e) => onChange({ industry: e.target.value })}
/>
</Field>
<Field label="Your point of contact" optional hint="So Vibn can address them in the handoff.">
<input
className="wiz-input"
placeholder="Marisol Rivera, Owner"
value={contact}
onChange={(e) => onChange({ contact: e.target.value })}
/>
</Field>
</>
);
}
const BRIEF_TEMPLATES = [
{ id: "quote_tool", label: "Quote tool",
body: "Customers request a quote with a few photos and a project description. The team reviews, sends a polished PDF, customer signs and pays a deposit online." },
{ id: "booking", label: "Booking system",
body: "Customers see real availability, book a service window, and get reminders. The team has a daily view of jobs with addresses and contact info." },
{ id: "portal", label: "Customer portal",
body: "Logged-in customers see past jobs, invoices, documents, and can message the business. The business sees a single page per customer." },
{ id: "internal", label: "Internal ops",
body: "Replace the spreadsheets the team is currently using. CRUD on jobs/customers, simple reports, role-based access, export to accounting." },
];
function ConsBrief({ brief, onChange }) {
return (
<>
<WizardQ
title="What did they ask for?"
sub="Paste the brief, or start from a template and edit."
/>
<Field label="Start from a template" optional>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{BRIEF_TEMPLATES.map((t) => (
<button
key={t.id}
type="button"
className="preset-chip"
style={{ padding: "8px 14px", fontSize: 13 }}
onClick={() => onChange(t.body)}
>
{t.label}
</button>
))}
</div>
</Field>
<Field label="Brief">
<textarea
className="wiz-input"
style={{ minHeight: 160 }}
placeholder="The client wants…"
value={brief}
onChange={(e) => onChange(e.target.value)}
/>
</Field>
</>
);
}
const SCOPE_GROUPS = [
{ label: "Foundations", items: ["Auth & accounts", "Roles & permissions", "Database & admin", "Hosting & domain"] },
{ label: "Customer-facing", items: ["Marketing site", "Booking / scheduling", "Quote requests", "Customer portal", "Self-serve forms"] },
{ label: "Money", items: ["Stripe / payments", "Invoices & receipts", "Subscriptions", "Refunds & disputes"] },
{ label: "Ops", items: ["Dashboards & reports", "CSV import / export", "Email + SMS notifs", "Audit log"] },
];
function ConsScope({ scope, onChange }) {
const toggle = (item) => {
if (scope.includes(item)) onChange(scope.filter((x) => x !== item));
else onChange([...scope, item]);
};
return (
<>
<WizardQ
title="What's in scope?"
sub="Tick what you've signed up to deliver. The rest you can add later — billable."
/>
<div
style={{
display: "grid",
gap: 12,
gridTemplateColumns: "repeat(2, 1fr)",
}}
>
{SCOPE_GROUPS.map((g) => (
<div
key={g.label}
style={{
padding: "14px 14px 10px",
borderRadius: 10,
border: "1px solid var(--hairline)",
background: "oklch(0.18 0.009 60 / 0.6)",
}}
>
<div
className="mono"
style={{
fontSize: 10.5, letterSpacing: "0.12em", textTransform: "uppercase",
color: "var(--fg-mute)", marginBottom: 10,
}}
>
{g.label}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{g.items.map((it) => {
const active = scope.includes(it);
return (
<button
key={it}
type="button"
onClick={() => toggle(it)}
style={{
display: "flex", alignItems: "center", gap: 10,
textAlign: "left",
padding: "6px 4px",
color: active ? "var(--fg)" : "var(--fg-dim)",
borderRadius: 6,
fontSize: 13.5,
}}
>
<span
style={{
width: 16, height: 16,
flexShrink: 0,
borderRadius: 4,
border: `1px solid ${active ? "var(--accent)" : "var(--hairline-2)"}`,
background: active ? "var(--accent)" : "transparent",
color: "var(--accent-fg)",
display: "grid", placeItems: "center",
}}
>
{active && (
<svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor"
strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m3 8.5 3.2 3.2L13 5"/>
</svg>
)}
</span>
{it}
</button>
);
})}
</div>
</div>
))}
</div>
</>
);
}
function ConsHandoff({ data, onChange }) {
return (
<>
<WizardQ
title="And finally — delivery."
sub="Where it lives, how you bill. Change later from settings."
/>
<Field label="Brand colors" optional hint="Hex, or just describe it. Paste a link, drop a screenshot — Vibn will figure it out.">
<input
className="wiz-input"
placeholder="#1B4D3E and #F2E2C4 — matches their truck wraps"
value={data.brand || ""}
onChange={(e) => onChange({ brand: e.target.value })}
/>
</Field>
<Field label="Where should it live?">
<PresetGroup
options={[
{ id: "subdomain", label: "Vibn subdomain", desc: "client-name.vibn.app — fastest." },
{ id: "custom", label: "Their custom domain", desc: "We'll walk you through DNS." },
{ id: "transfer", label: "Transfer ownership", desc: "They own it. You stay billable as editor." },
]}
value={data.handoff}
onChange={(v) => onChange({ handoff: v })}
columns={1}
/>
</Field>
<Field label="Your hourly rate" optional hint="Used on the handoff doc & invoice template.">
<div style={{ position: "relative" }}>
<span
className="mono"
style={{
position: "absolute", left: 14, top: "50%", transform: "translateY(-50%)",
color: "var(--fg-faint)", fontSize: 14.5, pointerEvents: "none",
}}
>$</span>
<input
className="wiz-input"
type="number"
min="0"
placeholder="120"
value={data.rate || ""}
onChange={(e) => onChange({ rate: e.target.value })}
style={{ paddingLeft: 26, paddingRight: 58 }}
/>
<span
className="mono"
style={{
position: "absolute", right: 14, top: "50%", transform: "translateY(-50%)",
color: "var(--fg-faint)", fontSize: 11.5, letterSpacing: "0.04em",
pointerEvents: "none",
}}
>/ hour</span>
</div>
</Field>
</>
);
}
// ── Path wrapper ───────────────────────────────────────────────────────────
function ConsultantPath({ data, onUpdate, onBack, onClose, onComplete, onJumpToStep, step }) {
const next = () => {
if (step < CONS_TOTAL - 1) onJumpToStep(step + 1);
else onComplete();
};
const back = () => {
if (step === 0) onBack();
else onJumpToStep(step - 1);
};
let body, canNext;
if (step === 0) {
body = (
<ConsClient
clientName={data.clientName || ""}
industry={data.industry || ""}
contact={data.contact || ""}
onChange={onUpdate}
/>
);
canNext = (data.clientName || "").trim().length >= 2 && (data.industry || "").trim().length >= 3;
} else if (step === 1) {
body = <ConsBrief brief={data.brief || ""} onChange={(v) => onUpdate({ brief: v })} />;
canNext = (data.brief || "").trim().length >= 30;
} else if (step === 2) {
body = <ConsScope scope={data.scope || []} onChange={(v) => onUpdate({ scope: v })} />;
canNext = (data.scope || []).length >= 2;
} else {
body = <ConsHandoff data={data} onChange={onUpdate} />;
canNext = !!data.handoff;
}
return (
<>
<WizardTop
onBack={back}
onClose={onClose}
lane={LANE_LABELS.consultant}
stepText={CONS_STEP_NAMES[step]}
current={step + 2}
total={5}
/>
<WizardBody width={step === 2 ? "wide" : null}>
{body}
<WizardFooter
onNext={next}
canNext={canNext}
nextLabel={step === CONS_TOTAL - 1 ? "Spin up project →" : "Continue"}
hint={canNext ? "⌘↵" : null}
/>
</WizardBody>
</>
);
}
Object.assign(window, { ConsultantPath, CONS_TOTAL });

View File

@@ -1,274 +0,0 @@
// Entrepreneur path — 4 steps. Each step is a focused question.
const ENTREP_TOTAL = 4;
const ENTREP_STEP_NAMES = ["Idea", "Audience", "Goal", "Look"];
const IDEA_PROMPTS = [
"A community for indie game devs to swap playtesters, with weekly demo nights",
"An AI tool that turns my handwritten recipe notes into a clean cookbook for my family",
"A waitlist + scheduler for my pottery studio — small classes, six people max",
"A subscription box service for cold-brew enthusiasts, with monthly tasting cards",
"A simple tool that turns my Strava data into framed art prints I can sell",
];
function EntrepIdea({ value, onChange }) {
const [phIdx, setPhIdx] = React.useState(0);
const [phChars, setPhChars] = React.useState(0);
const [deleting, setDeleting] = React.useState(false);
React.useEffect(() => {
if (value.length > 0) return undefined;
const full = IDEA_PROMPTS[phIdx];
const speed = deleting ? 18 : 38;
const t = setTimeout(() => {
if (!deleting) {
if (phChars < full.length) setPhChars(phChars + 1);
else setTimeout(() => setDeleting(true), 1500);
} else {
if (phChars > 0) setPhChars(phChars - 1);
else { setDeleting(false); setPhIdx((phIdx + 1) % IDEA_PROMPTS.length); }
}
}, speed);
return () => clearTimeout(t);
}, [value, phIdx, phChars, deleting]);
return (
<>
<WizardQ
title="What are you building?"
sub="One paragraph is enough. Talk like you would to a friend."
/>
<div style={{ position: "relative" }}>
<textarea
className="wiz-input"
style={{ minHeight: 140, fontSize: 15 }}
value={value}
onChange={(e) => onChange(e.target.value)}
autoFocus
aria-label="Describe your idea"
/>
{value.length === 0 && (
<div
style={{
position: "absolute", top: 12, left: 14, right: 14,
pointerEvents: "none",
color: "var(--fg-faint)",
font: "14.5px/1.5 var(--font-sans)",
}}
>
{IDEA_PROMPTS[phIdx].slice(0, phChars)}
<span
style={{
display: "inline-block",
width: 7, height: 14, verticalAlign: "-2px",
background: "var(--accent)", marginLeft: 1,
animation: "blink 1s steps(2) infinite",
boxShadow: "0 0 10px var(--accent-glow)",
}}
/>
</div>
)}
</div>
<div
className="mono"
style={{ fontSize: 11, color: "var(--fg-faint)", letterSpacing: "0.06em", marginTop: -16 }}
>
{value.length} chars · be specific where it matters
</div>
</>
);
}
const AUDIENCE_PRESETS = [
"Me and people like me",
"A small community I'm part of",
"Local people in my city",
"Anyone searching for this",
"Other small businesses",
"Hobbyists in a niche I love",
];
function EntrepAudience({ value, onChange }) {
const isPreset = AUDIENCE_PRESETS.includes(value);
return (
<>
<WizardQ
title="Who is it for?"
sub="The clearer your audience, the better the copy Vibn writes for it."
/>
<ChipGroup
options={AUDIENCE_PRESETS}
values={value ? [value] : []}
onChange={(arr) => onChange(arr[arr.length - 1] || "")}
/>
<Field label="Or describe them in your own words" optional>
<input
className="wiz-input"
placeholder="e.g. dog owners in Brooklyn who walk before work"
value={!isPreset ? value : ""}
onChange={(e) => onChange(e.target.value)}
/>
</Field>
</>
);
}
const GOALS = [
{ id: "first_customer", icon: "🎯", label: "First real customer",
desc: "Someone I don't know pays me. Even once." },
{ id: "ten_users", icon: "👥", label: "Ten weekly users",
desc: "A signal the thing actually does something useful." },
{ id: "mrr_1k", icon: "📈", label: "$1k MRR",
desc: "Enough to take it seriously." },
{ id: "side_quit", icon: "🚪", label: "Replace my day job",
desc: "The long road. Make this the main thing." },
{ id: "audience", icon: "📣", label: "Build a tiny audience",
desc: "200 emails, a community, something I can talk to." },
{ id: "ship_it", icon: "🚀", label: "Just ship it",
desc: "I want the thing to exist." },
];
function EntrepGoal({ value, onChange }) {
return (
<>
<WizardQ
title="What does “working” look like?"
sub="Helps Vibn decide what to build first — a landing page that converts, or a tool that retains."
/>
<PresetGroup
options={GOALS.map((g) => ({
id: g.id, label: g.label, desc: g.desc,
icon: <span style={{ fontSize: 14 }}>{g.icon}</span>,
}))}
value={value}
onChange={onChange}
columns={2}
/>
</>
);
}
const VIBES = [
{ id: "warm", name: "Warm coral", swatch: "linear-gradient(135deg, #E27855, #B33B2A)",
desc: "Confident, hand-built, warm." },
{ id: "ink", name: "Ink & paper", swatch: "linear-gradient(135deg, #1d1d1d, #4a4a4a)",
desc: "Editorial, serif, quiet." },
{ id: "sage", name: "Sage matte", swatch: "linear-gradient(135deg, #7BA890, #3F6B57)",
desc: "Calm, modern, slightly herbal." },
{ id: "neon", name: "Neon arcade", swatch: "linear-gradient(135deg, #5B6CFF, #FF3DDB)",
desc: "Loud, fun, late-night." },
{ id: "cream", name: "Cream linen", swatch: "linear-gradient(135deg, #F2E7D5, #C9A977)",
desc: "Cozy and beige." },
{ id: "later", name: "Decide later", swatch: "repeating-linear-gradient(45deg, oklch(0.30 0.010 60), oklch(0.30 0.010 60) 6px, oklch(0.22 0.010 60) 6px, oklch(0.22 0.010 60) 12px)",
desc: "Vibn picks one that fits." },
];
function EntrepVibe({ value, onChange }) {
return (
<>
<WizardQ
title="Pick a starting vibe."
sub="Every color and font is a tweak away once the site is live."
/>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 10,
}}
>
{VIBES.map((v) => {
const active = value === v.id;
return (
<button
key={v.id}
type="button"
onClick={() => onChange(v.id)}
style={{
padding: "10px 10px 10px",
borderRadius: 11,
border: `1px solid ${active ? "var(--accent)" : "var(--hairline)"}`,
background: active ? "oklch(0.20 0.04 35 / 0.4)" : "oklch(0.18 0.009 60 / 0.6)",
boxShadow: active ? "0 0 0 3px oklch(0.74 0.175 35 / 0.1)" : "none",
textAlign: "left",
color: "var(--fg)",
display: "flex", flexDirection: "column", gap: 8,
transition: "border-color .15s, background .15s",
}}
>
<span
style={{
height: 52, borderRadius: 7,
background: v.swatch,
border: "1px solid oklch(1 0 0 / 0.08)",
boxShadow: "inset 0 1px 0 oklch(1 0 0 / 0.18)",
}}
/>
<span style={{ fontSize: 13, fontWeight: 500, letterSpacing: "-0.005em" }}>
{v.name}
</span>
<span style={{ fontSize: 11.5, color: "var(--fg-mute)", lineHeight: 1.4 }}>
{v.desc}
</span>
</button>
);
})}
</div>
</>
);
}
// ── Path wrapper ───────────────────────────────────────────────────────────
function EntrepreneurPath({ data, onUpdate, onBack, onClose, onComplete, onJumpToStep, step }) {
const next = () => {
if (step < ENTREP_TOTAL - 1) onJumpToStep(step + 1);
else onComplete();
};
const back = () => {
if (step === 0) onBack();
else onJumpToStep(step - 1);
};
let body, canNext, onSkip = null;
if (step === 0) {
body = <EntrepIdea value={data.idea || ""} onChange={(v) => onUpdate({ idea: v })} />;
canNext = (data.idea || "").trim().length >= 8;
} else if (step === 1) {
body = <EntrepAudience value={data.audience || ""} onChange={(v) => onUpdate({ audience: v })} />;
canNext = (data.audience || "").trim().length >= 3;
} else if (step === 2) {
body = <EntrepGoal value={data.goal} onChange={(v) => onUpdate({ goal: v })} />;
canNext = !!data.goal;
} else {
body = <EntrepVibe value={data.vibe} onChange={(v) => onUpdate({ vibe: v })} />;
canNext = !!data.vibe;
onSkip = () => { onUpdate({ vibe: "later" }); next(); };
}
// 5 total: fork(1) + 4 path steps
return (
<>
<WizardTop
onBack={back}
onClose={onClose}
lane={LANE_LABELS.entrepreneur}
stepText={ENTREP_STEP_NAMES[step]}
current={step + 2}
total={5}
/>
<WizardBody width={step === 2 || step === 3 ? "wide" : null}>
{body}
<WizardFooter
onNext={next}
canNext={canNext}
nextLabel={step === ENTREP_TOTAL - 1 ? "Build →" : "Continue"}
hint={canNext ? "⌘↵" : null}
onSkip={onSkip}
skipLabel="Pick for me"
/>
</WizardBody>
</>
);
}
Object.assign(window, { EntrepreneurPath, ENTREP_TOTAL });

View File

@@ -1,134 +0,0 @@
// Step 1: the only branching question — "which describes you?"
// Quiet radio-style cards. No quotes, no marketing, no glow theatrics.
const FORKS = [
{
id: "entrepreneur",
label: "I'm building my own thing",
hint: "Idea → live → first customer. You're the founder.",
icon: (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor"
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="9" cy="9" r="3"/>
<path d="M9 2.5v2M9 13.5v2M2.5 9h2M13.5 9h2"/>
</svg>
),
},
{
id: "owner",
label: "I run a business",
hint: "Replace the stack of tools you currently rent.",
icon: (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor"
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M3 6h12l-1 9H4L3 6Z"/>
<path d="M6 6V4.5a3 3 0 0 1 6 0V6"/>
</svg>
),
},
{
id: "consultant",
label: "I build for clients",
hint: "A workspace per client. Bill for the system, not the hours.",
icon: (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor"
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M2.5 15 9 3l6.5 12"/>
<path d="M5.5 12h7"/>
</svg>
),
},
];
function ForkScreen({ name, value, onChange, onClose, onNext }) {
return (
<>
<WizardTop
onBack={null}
onClose={onClose}
stepText="Pick your lane"
current={1}
total={5}
/>
<WizardBody>
<WizardQ
title={name ? `Welcome, ${name}. Which sounds like you?` : "Which one sounds like you?"}
sub="Vibn asks different questions on the next screens depending on the answer. You can change this later."
/>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{FORKS.map((f) => {
const active = value === f.id;
return (
<button
key={f.id}
type="button"
onClick={() => onChange(f.id)}
onDoubleClick={() => { onChange(f.id); onNext(); }}
style={{
display: "flex", alignItems: "center", gap: 14,
padding: "14px 16px",
borderRadius: 12,
border: `1px solid ${active ? "var(--accent)" : "var(--hairline)"}`,
background: active ? "oklch(0.20 0.04 35 / 0.4)" : "oklch(0.18 0.009 60 / 0.6)",
boxShadow: active ? "0 0 0 3px oklch(0.74 0.175 35 / 0.1)" : "none",
textAlign: "left",
color: "var(--fg)",
transition: "border-color .15s, background .15s",
cursor: "pointer",
}}
>
<span style={{
width: 36, height: 36, flexShrink: 0,
borderRadius: 9,
background: active ? "oklch(0.74 0.175 35 / 0.18)" : "oklch(0.22 0.011 60)",
border: "1px solid var(--hairline)",
color: active ? "var(--accent)" : "var(--fg-mute)",
display: "grid", placeItems: "center",
}}>
{f.icon}
</span>
<span style={{ display: "flex", flexDirection: "column", gap: 2, flex: 1 }}>
<span style={{ fontSize: 15, fontWeight: 500, letterSpacing: "-0.008em" }}>
{f.label}
</span>
<span style={{ fontSize: 13, color: "var(--fg-mute)", lineHeight: 1.4 }}>
{f.hint}
</span>
</span>
<span
style={{
width: 18, height: 18, flexShrink: 0,
borderRadius: "50%",
border: `1.5px solid ${active ? "var(--accent)" : "var(--hairline-2)"}`,
background: active ? "var(--accent)" : "transparent",
display: "grid", placeItems: "center",
color: "var(--accent-fg)",
transition: "border-color .15s, background .15s",
}}
>
{active && (
<svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor"
strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m3 8.5 3.2 3.2L13 5"/>
</svg>
)}
</span>
</button>
);
})}
</div>
<WizardFooter
canNext={!!value}
onNext={onNext}
nextLabel="Continue"
hint={value ? "Press ⌘↵" : null}
/>
</WizardBody>
</>
);
}
Object.assign(window, { ForkScreen });

View File

@@ -1,262 +0,0 @@
// Owner path — 4 steps for small-business owners replacing their stack.
const OWNER_TOTAL = 4;
const OWNER_STEP_NAMES = ["Business", "Stack", "First fix", "Scale"];
const BIZ_KINDS = [
{ id: "service", icon: "🛠", label: "Trades / home services", desc: "Plumbing, HVAC, landscaping, cleaning" },
{ id: "retail", icon: "🛍", label: "Retail / shop", desc: "Vintage, boutique, market, online" },
{ id: "food", icon: "🥐", label: "Food & drink", desc: "Café, bakery, food truck, catering" },
{ id: "appointments", icon: "💈", label: "Appointment-based", desc: "Salon, studio, clinic, tutoring" },
{ id: "events", icon: "🎟", label: "Events / hospitality", desc: "Venue, rental, planning" },
{ id: "other", icon: "✦", label: "Something else", desc: "We'll learn from your answers" },
];
function OwnerBiz({ value, name, onChange, onNameChange }) {
return (
<>
<WizardQ
title="What does your business do?"
sub="Roughly. We tailor the next screens to match."
/>
<PresetGroup
options={BIZ_KINDS.map((b) => ({
id: b.id, label: b.label, desc: b.desc,
icon: <span style={{ fontSize: 14 }}>{b.icon}</span>,
}))}
value={value}
onChange={onChange}
columns={2}
/>
<Field label="Business name">
<input
className="wiz-input"
placeholder="Sunrise Plumbing, Pearl Lane Bakery…"
value={name}
onChange={(e) => onNameChange(e.target.value)}
/>
</Field>
</>
);
}
const STACK_TOOLS = [
"Square / POS",
"Stripe",
"Calendly",
"Acuity",
"Shopify",
"QuickBooks",
"Mailchimp",
"Instagram",
"Google Sheets",
"Notion / Airtable",
"Wix / Squarespace",
"WhatsApp / Slack",
"A printed binder",
"Head + notepad",
];
function OwnerStack({ tools, spend, onToolsChange, onSpendChange }) {
return (
<>
<WizardQ
title="What are you renting right now?"
sub="Tap everything you pay for. Approximate is fine."
/>
<Field label="Tools & subscriptions">
<ChipGroup
options={STACK_TOOLS}
values={tools || []}
onChange={onToolsChange}
allowOther
/>
</Field>
<Field label="About how much per month?" hint="Across all your software, ballpark.">
<Slider
min={0} max={1500} step={25}
value={spend ?? 250}
onChange={onSpendChange}
format={(v) => v === 0 ? "$0" : v === 1500 ? "$1.5k+" : `$${v}`}
/>
</Field>
{(tools || []).length > 0 && (
<div
style={{
padding: "12px 14px",
borderRadius: 10,
border: "1px solid var(--hairline)",
background: "oklch(0.18 0.009 60 / 0.6)",
fontSize: 13.5,
lineHeight: 1.5,
color: "var(--fg-dim)",
display: "flex", gap: 12, alignItems: "flex-start",
}}
>
<span style={{
width: 6, height: 6, borderRadius: "50%",
background: "var(--accent)", boxShadow: "0 0 10px var(--accent-glow)",
marginTop: 7, flexShrink: 0,
}} />
<span>
<b style={{ color: "var(--fg)", fontWeight: 500 }}>{tools.length} tool{tools.length === 1 ? "" : "s"}</b>
{spend ? <> · ~<b style={{ color: "var(--fg)", fontWeight: 500 }}>${spend}/mo</b></> : null}.
Replaced by one workspace, owned by you.
</span>
</div>
)}
</>
);
}
const OWNER_FIRST_THINGS = [
{ id: "booking", icon: "📅", label: "Bookings & scheduling", desc: "Customers book themselves." },
{ id: "invoicing", icon: "🧾", label: "Quotes, invoices, payments", desc: "Send a quote, get paid, no chasing." },
{ id: "customers", icon: "👥", label: "Customer history & portal", desc: "One place per customer." },
{ id: "inventory", icon: "📦", label: "Inventory & orders", desc: "Track stock, sales, suppliers." },
{ id: "team", icon: "🪪", label: "Team & job dispatch", desc: "Assign jobs, log hours." },
{ id: "marketing", icon: "📣", label: "Website + email + reviews", desc: "A site that converts, list that follows up." },
];
function OwnerFirstThing({ value, onChange }) {
return (
<>
<WizardQ
title="What's burning first?"
sub="The one workflow you wish was already replaced. Vibn builds it on day one."
/>
<PresetGroup
options={OWNER_FIRST_THINGS.map((f) => ({
id: f.id, label: f.label, desc: f.desc,
icon: <span style={{ fontSize: 14 }}>{f.icon}</span>,
}))}
value={value}
onChange={onChange}
columns={2}
/>
</>
);
}
const OWNER_HOW_LONG = [
{ id: "starting", label: "Just starting" },
{ id: "1_3", label: "13 years" },
{ id: "3_10", label: "310 years" },
{ id: "10_plus", label: "10+ years" },
];
function OwnerScale({ customers, team, howLong, onCustomers, onTeam, onHowLong }) {
return (
<>
<WizardQ
title="A little about scale."
sub="Sensible defaults — table view vs. cards, daily vs. monthly reports."
/>
<Field label="Customers per month">
<Slider
min={0} max={2000} step={10}
value={customers ?? 50}
onChange={onCustomers}
format={(v) => v === 0 ? "0" : v >= 2000 ? "2k+" : v.toLocaleString()}
/>
</Field>
<Field label="Team size (incl. you)">
<Slider
min={1} max={50} step={1}
value={team ?? 1}
onChange={onTeam}
format={(v) => v >= 50 ? "50+" : `${v}`}
/>
</Field>
<Field label="How long have you been at this?">
<div className="chips">
{OWNER_HOW_LONG.map((h) => (
<button
key={h.id}
type="button"
className={"chip" + (howLong === h.id ? " active" : "")}
onClick={() => onHowLong(h.id)}
>
{h.label}
</button>
))}
</div>
</Field>
</>
);
}
// ── Path wrapper ───────────────────────────────────────────────────────────
function OwnerPath({ data, onUpdate, onBack, onClose, onComplete, onJumpToStep, step }) {
const next = () => {
if (step < OWNER_TOTAL - 1) onJumpToStep(step + 1);
else onComplete();
};
const back = () => {
if (step === 0) onBack();
else onJumpToStep(step - 1);
};
let body, canNext;
if (step === 0) {
body = (
<OwnerBiz
value={data.biz}
name={data.bizName || ""}
onChange={(v) => onUpdate({ biz: v })}
onNameChange={(v) => onUpdate({ bizName: v })}
/>
);
canNext = !!data.biz && (data.bizName || "").trim().length >= 2;
} else if (step === 1) {
body = (
<OwnerStack
tools={data.tools || []}
spend={data.spend}
onToolsChange={(v) => onUpdate({ tools: v })}
onSpendChange={(v) => onUpdate({ spend: v })}
/>
);
canNext = (data.tools || []).length >= 1;
} else if (step === 2) {
body = <OwnerFirstThing value={data.firstThing} onChange={(v) => onUpdate({ firstThing: v })} />;
canNext = !!data.firstThing;
} else {
body = (
<OwnerScale
customers={data.customers}
team={data.team}
howLong={data.howLong}
onCustomers={(v) => onUpdate({ customers: v })}
onTeam={(v) => onUpdate({ team: v })}
onHowLong={(v) => onUpdate({ howLong: v })}
/>
);
canNext = !!data.howLong;
}
return (
<>
<WizardTop
onBack={back}
onClose={onClose}
lane={LANE_LABELS.owner}
stepText={OWNER_STEP_NAMES[step]}
current={step + 2}
total={5}
/>
<WizardBody width={step === 0 || step === 2 ? "wide" : null}>
{body}
<WizardFooter
onNext={next}
canNext={canNext}
nextLabel={step === OWNER_TOTAL - 1 ? "Build my workspace →" : "Continue"}
hint={canNext ? "⌘↵" : null}
/>
</WizardBody>
</>
);
}
Object.assign(window, { OwnerPath, OWNER_TOTAL });

View File

@@ -1,333 +0,0 @@
// Shared building blocks for the onboarding flow.
// All <style> belongs in onboarding.css; this file is JSX only.
// ── Wizard top bar ─────────────────────────────────────────────────────────
// Sticky, thin. Holds: back arrow · vibn mark · centered step label · close.
// A 2px progress bar runs along its bottom edge.
function WizardTop({
onBack, onClose,
lane, // "Solo / quiet entrepreneur" etc.
stepText, // "Idea" or "Pick your lane"
current, total, // 1-indexed
progress, // 0..1 (optional override)
}) {
const pct = typeof progress === "number"
? Math.max(0, Math.min(1, progress))
: (typeof current === "number" && typeof total === "number"
? Math.max(0, Math.min(1, current / total))
: 0);
return (
<header className="wiz-top">
<div className="wiz-top-row">
<button
type="button"
className="wiz-iconbtn"
onClick={onBack}
disabled={!onBack}
aria-label="Back"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor"
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M13 8H3M7 4 3 8l4 4"/>
</svg>
</button>
<a href="index.html" className="wiz-logo" aria-label="vibn — home">
<LogoMark size={22} />
<span>vibn</span>
</a>
<div className="wiz-step">
{lane && <span className="lane">{lane}</span>}
{lane && stepText && <span className="dot" />}
{stepText && (
<span>
{typeof current === "number" && typeof total === "number" && (
<>
<b>{current}</b> <span style={{ opacity: 0.6 }}>/ {total}</span>{" · "}
</>
)}
{stepText}
</span>
)}
</div>
<button
type="button"
className="wiz-iconbtn"
onClick={onClose}
aria-label="Save & exit"
title="Save & exit"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor"
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m4 4 8 8M12 4l-8 8"/>
</svg>
</button>
</div>
<div className="wiz-progress">
<div className="wiz-progress-fill" style={{ width: `${pct * 100}%` }} />
</div>
</header>
);
}
// ── Wizard body wrapper ────────────────────────────────────────────────────
function WizardBody({ children, width }) {
const cls = "wiz-card" + (width === "wide" ? " wide" : width === "xwide" ? " xwide" : "");
return (
<main className="wiz-body">
<div className={cls}>{children}</div>
</main>
);
}
// ── Question heading ───────────────────────────────────────────────────────
function WizardQ({ title, sub }) {
return (
<div className="wiz-q">
<h2>{title}</h2>
{sub && <p>{sub}</p>}
</div>
);
}
// ── Footer (back / hint / continue) ────────────────────────────────────────
function WizardFooter({
onBack, onNext, canNext = true,
nextLabel = "Continue",
hint,
onSkip, skipLabel = "Skip",
}) {
return (
<div className="wiz-foot">
<div className="wiz-foot-left">
{onSkip && (
<button type="button" className="wiz-skip" onClick={onSkip}>
{skipLabel}
</button>
)}
</div>
<div className="wiz-foot-right">
{hint && <span className="wiz-hint">{hint}</span>}
<button
type="button"
className="btn btn-primary btn-wiz"
disabled={!canNext}
onClick={() => canNext && onNext && onNext()}
>
{nextLabel} <Arrow size={13} />
</button>
</div>
</div>
);
}
// ── Field wrappers (wizard variants) ───────────────────────────────────────
function Field({ label, hint, children, optional }) {
return (
<label className="wiz-field">
{label && (
<span className="wiz-field-label">
{label}
{optional && (
<span style={{ color: "var(--fg-faint)", fontWeight: 400, marginLeft: 8, fontSize: 12 }}>
optional
</span>
)}
</span>
)}
{children}
{hint && <span className="wiz-field-hint">{hint}</span>}
</label>
);
}
// ── Chip group (multi-select) ──────────────────────────────────────────────
function ChipGroup({ options, values, onChange, allowOther = false }) {
const [other, setOther] = React.useState("");
const customs = (values || []).filter((v) => !options.includes(v));
const toggle = (v) => {
if (!onChange) return;
if (values.includes(v)) onChange(values.filter((x) => x !== v));
else onChange([...values, v]);
};
return (
<div>
<div className="chips">
{options.map((opt) => (
<button
type="button" key={opt}
className={"chip" + (values.includes(opt) ? " active" : "")}
onClick={() => toggle(opt)}
>
{opt}
</button>
))}
{customs.map((c) => (
<button
type="button" key={c}
className="chip active"
onClick={() => toggle(c)}
title="Click to remove"
>
{c} <span style={{ marginLeft: 4, opacity: 0.6 }}>×</span>
</button>
))}
</div>
{allowOther && (
<form
onSubmit={(e) => {
e.preventDefault();
const v = other.trim();
if (v && !values.includes(v)) onChange([...values, v]);
setOther("");
}}
style={{ marginTop: 10, display: "flex", gap: 8 }}
>
<input
type="text"
className="wiz-input"
placeholder="Add your own…"
value={other}
onChange={(e) => setOther(e.target.value)}
style={{ flex: 1 }}
/>
<button
type="submit"
className="btn btn-ghost"
style={{ height: 42, padding: "0 14px", fontSize: 13, borderRadius: 10 }}
disabled={!other.trim()}
>
Add
</button>
</form>
)}
</div>
);
}
// ── Preset group (single-select cards) ─────────────────────────────────────
function PresetGroup({ options, value, onChange, columns = 1 }) {
return (
<div
style={{
display: "grid",
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
gap: 8,
width: "100%",
}}
>
{options.map((opt) => {
const active = value === opt.id;
return (
<button
key={opt.id}
type="button"
onClick={() => onChange(opt.id)}
style={{
textAlign: "left",
padding: "12px 14px",
borderRadius: 10,
border: `1px solid ${active ? "var(--accent)" : "var(--hairline)"}`,
background: active ? "oklch(0.20 0.04 35 / 0.4)" : "oklch(0.18 0.009 60 / 0.6)",
boxShadow: active ? "0 0 0 3px oklch(0.74 0.175 35 / 0.1)" : "none",
transition: "border-color .15s, background .15s",
color: "var(--fg)",
display: "flex", alignItems: "flex-start", gap: 12,
}}
>
{opt.icon && (
<span style={{
width: 28, height: 28, flexShrink: 0,
borderRadius: 8,
background: active ? "oklch(0.74 0.175 35 / 0.18)" : "oklch(0.22 0.011 60)",
border: "1px solid var(--hairline)",
color: active ? "var(--accent)" : "var(--fg-mute)",
display: "grid", placeItems: "center",
fontSize: 14,
marginTop: 1,
}}>
{opt.icon}
</span>
)}
<span style={{ display: "flex", flexDirection: "column", gap: 2, flex: 1, minWidth: 0 }}>
<span style={{ fontSize: 14, fontWeight: 500, letterSpacing: "-0.005em" }}>
{opt.label}
</span>
{opt.desc && (
<span style={{ fontSize: 12.5, color: "var(--fg-mute)", lineHeight: 1.45 }}>
{opt.desc}
</span>
)}
</span>
{active && (
<span style={{
width: 16, height: 16, borderRadius: "50%",
background: "var(--accent)",
display: "grid", placeItems: "center",
color: "var(--accent-fg)",
flexShrink: 0,
marginTop: 6,
}}>
<svg width="9" height="9" viewBox="0 0 16 16" fill="none" stroke="currentColor"
strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m3 8.5 3.2 3.2L13 5"/>
</svg>
</span>
)}
</button>
);
})}
</div>
);
}
// ── Slider ─────────────────────────────────────────────────────────────────
function Slider({ min, max, step = 1, value, onChange, format }) {
return (
<div style={{ width: "100%" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
<span className="mono" style={{ fontSize: 11, color: "var(--fg-faint)", letterSpacing: "0.04em" }}>
{format ? format(min) : min}
</span>
<span
className="mono"
style={{
fontSize: 18,
color: "var(--fg)",
letterSpacing: "-0.01em",
fontWeight: 500,
}}
>
{format ? format(value) : value}
</span>
<span className="mono" style={{ fontSize: 11, color: "var(--fg-faint)", letterSpacing: "0.04em" }}>
{format ? format(max) : max}
</span>
</div>
<input
type="range"
min={min} max={max} step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
style={{ width: "100%", marginTop: 6, accentColor: "var(--accent)" }}
/>
</div>
);
}
// Lane labels — used by WizardTop and elsewhere.
const LANE_LABELS = {
entrepreneur: "Solo entrepreneur",
owner: "Small business owner",
consultant: "Building for clients",
};
Object.assign(window, {
WizardTop, WizardBody, WizardQ, WizardFooter,
Field, ChipGroup, PresetGroup, Slider,
LANE_LABELS,
});

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

Submodule talkcody deleted from 5543bf9264

Submodule temp-spec-kit deleted from 51e6a140e2

View File

@@ -395,7 +395,7 @@ export function PresetGroup({
<div
style={{
width: "100%",
height: 170,
height: 300,
background: "oklch(0.14 0.008 60 / 0.7)",
borderBottom: "1px solid var(--hairline)",
display: "grid",

View File

@@ -87,6 +87,13 @@ export default function OnboardingApp() {
// ── transitions ──────────────────────────────────────────────────────
const confirmFork = () => {
if (!forkChoice) return;
if (forkChoice === "undecided") {
// Bypasses the questionnaire and goes straight to the Name Workspace step!
setPath("entrepreneur");
setStep(3); // step === 3 is Page 5 (Name your workspace)
setStage("path");
return;
}
setPath(forkChoice);
setStep(0);
setStage("path");

View File

@@ -1,7 +1,7 @@
"use client";
import { VIBNSidebar } from "@/components/layout/vibn-sidebar";
import { ReactNode, useEffect, useState } from "react";
import { ReactNode } from "react";
import { useParams } from "next/navigation";
import { Toaster } from "sonner";
@@ -9,33 +9,6 @@ export default function ProjectsLayout({ children }: { children: ReactNode }) {
const params = useParams();
const workspace = params.workspace as string;
const [isAgency, setIsAgency] = useState<boolean | null>(null);
useEffect(() => {
fetch(`/api/workspaces/${workspace}`, {
credentials: "include",
cache: "no-store",
})
.then((res) => res.json())
.then((data) => setIsAgency(!!data.isAgency))
.catch(() => setIsAgency(false));
}, [workspace]);
// While loading, just render the background
if (isAgency === null) {
return <div style={{ height: "100vh", background: "#f6f4f0" }} />;
}
// Agency users provide their own full-screen layouts (e.g. AgencyDashboard)
if (isAgency) {
return (
<>
{children}
<Toaster position="top-center" />
</>
);
}
return (
<>
<div