Rip out Theia, bump submodules, retire platform/ scaffold, snapshot docs + design assets

Theia rip-out (parent):
- Remove theia submodule entry (the local fork, Gitea repo, Coolify app,
  Cloud Run services, and Artifact Registry image are all gone)
- Drop README.md + INFRASTRUCTURE.md (obsolete "Project OS" snapshots
  that also leaked API tokens) and setup.sh (Theia clone bootstrap)
- Delete UI-DESIGN-GUIDE.md, BACKEND_AGENTS_PLAN.md, VIBN_BUILD_PLAN.md,
  VISUAL_EDITOR_PLAN.md, core-packages.md, ai-packages.md, tools-list.md
  (all 100% Theia-specific or superseded)
- Surgical scrubs of remaining Theia mentions in
  AGENT_EXECUTION_ARCHITECTURE.md and TURBOREPO_MIGRATION_PLAN.md

Submodule bumps:
- vibn-agent-runner: Theia rip-out + MCP refactor (api/wrapper/server
  pattern across shell/file/git/memory/prd/search/agent/gitea/coolify)
- vibn-frontend: Theia rip-out + P5.1 attach E2E + Justine UI WIP

Retire platform/ scaffold:
- Remove platform/backend/ (control-plane, executors, mcp-adapter),
  platform/client-ide/ (gcp-productos extension), platform/contracts/,
  platform/infra/terraform/, platform/scripts/templates/turborepo/
  (replaced by vibn-agent-runner + vibn-frontend + Coolify direct)
- Drop architecture.md, technical_spec.md, vision-ext.md,
  "1.Generate Control Plane API scaffold.md" (same era)

Docs / planning snapshots (new):
- AI_CAPABILITIES.md, AI_CAPABILITIES_ROADMAP.md
- AGENT_TELEMETRY_STREAMING_PROJECT.md
- VIBN_PRD.md, product-idea-a.md

Design assets (new):
- branding/{coolify,gitea,ux-testing}/ static brand collateral
- justine/ HTML mockups for the new onboarding/build flows
- preview-assist-ui/ Vite scratch app
- master-ai.code-workspace

Infra helpers (new):
- setup-coolify-montreal.sh provisioner
- gitea-docker-compose.yml
- vibn-coolify-schema.sql for the Coolify Postgres extensions
- prd-agent-prompt.pdf, prompt, root.txt, remixed-9edec9e9.tsx scratch
- flatten.sh helper

.gitignore: ignore **/node_modules, **/.next, **/.turbo, **/coverage

Made-with: Cursor
This commit is contained in:
2026-04-22 18:06:37 -07:00
parent 54da4c96da
commit 99deb546c8
153 changed files with 22844 additions and 9496 deletions

24
.gitignore vendored
View File

@@ -1,14 +1,3 @@
# Theia build files
theia/node_modules/
theia/packages/*/lib/
theia/examples/*/lib/
theia/examples/*/dist/
theia/*.log
theia/.nyc_output/
theia/coverage/
theia/lerna-debug.log*
theia/npm-debug.log*
# OS files
.DS_Store
.DS_Store?
@@ -27,9 +16,16 @@ theia/npm-debug.log*
*.tmp
.cache/
# Distribution builds
theia/examples/electron/dist/
# Environment
.env
.env.local
.env.*
*.env
.coolify.env
.opensrs.env
# Dependencies & build artifacts
**/node_modules/
**/.next/
**/.turbo/
**/coverage/

View File

@@ -1,743 +0,0 @@
1) Generate Control Plane API scaffold
Folder layout
backend/control-plane/
package.json
tsconfig.json
src/
index.ts
config.ts
auth.ts
registry.ts
types.ts
storage/
firestore.ts
gcs.ts
routes/
tools.ts
runs.ts
health.ts
.env.example
backend/control-plane/package.json
{
"name": "@productos/control-plane",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"lint": "eslint ."
},
"dependencies": {
"@google-cloud/firestore": "^7.11.0",
"@google-cloud/storage": "^7.14.0",
"@fastify/cors": "^9.0.1",
"@fastify/helmet": "^12.0.0",
"@fastify/rate-limit": "^9.1.0",
"fastify": "^4.28.1",
"zod": "^3.23.8",
"nanoid": "^5.0.7"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.19.0",
"typescript": "^5.5.4",
"eslint": "^9.8.0"
}
}
backend/control-plane/tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
}
}
backend/control-plane/.env.example
PORT=8080
GCP_PROJECT_ID=your-project-id
GCS_BUCKET_ARTIFACTS=productos-artifacts-dev
FIRESTORE_COLLECTION_RUNS=runs
FIRESTORE_COLLECTION_TOOLS=tools
# If you put behind IAP / OAuth later, validate ID tokens here:
AUTH_MODE=dev # dev | oauth
backend/control-plane/src/config.ts
export const config = {
port: Number(process.env.PORT ?? 8080),
projectId: process.env.GCP_PROJECT_ID ?? "",
artifactsBucket: process.env.GCS_BUCKET_ARTIFACTS ?? "",
runsCollection: process.env.FIRESTORE_COLLECTION_RUNS ?? "runs",
toolsCollection: process.env.FIRESTORE_COLLECTION_TOOLS ?? "tools",
authMode: process.env.AUTH_MODE ?? "dev"
};
backend/control-plane/src/types.ts
export type ToolRisk = "low" | "medium" | "high";
export type ToolDef = {
name: string;
description: string;
risk: ToolRisk;
executor: {
kind: "http";
url: string; // executor base url
path: string; // executor endpoint path
};
inputSchema: unknown; // JSON Schema object
outputSchema?: unknown; // JSON Schema object
};
export type ToolInvokeRequest = {
tool: string;
tenant_id: string;
workspace_id?: string;
input: unknown;
dry_run?: boolean;
};
export type RunStatus = "queued" | "running" | "succeeded" | "failed";
export type RunRecord = {
run_id: string;
tenant_id: string;
tool: string;
status: RunStatus;
created_at: string;
updated_at: string;
input: unknown;
output?: unknown;
error?: { message: string; details?: unknown };
artifacts?: { bucket: string; prefix: string };
};
backend/control-plane/src/auth.ts
import { FastifyRequest } from "fastify";
import { config } from "./config.js";
/**
* V1: dev mode = trust caller (or a shared API key later).
* V2: validate Google OAuth/IAP identity token and map to tenant/org.
*/
export async function requireAuth(req: FastifyRequest) {
if (config.authMode === "dev") return;
// Placeholder for OAuth/IAP verification:
// - read Authorization: Bearer <id_token>
// - verify token (Google JWKS)
// - attach req.user
throw new Error("AUTH_MODE oauth not yet implemented");
}
backend/control-plane/src/storage/firestore.ts
import { Firestore } from "@google-cloud/firestore";
import { config } from "../config.js";
import type { RunRecord, ToolDef } from "../types.js";
const db = new Firestore({ projectId: config.projectId });
export async function saveRun(run: RunRecord): Promise<void> {
await db.collection(config.runsCollection).doc(run.run_id).set(run, { merge: true });
}
export async function getRun(runId: string): Promise<RunRecord | null> {
const snap = await db.collection(config.runsCollection).doc(runId).get();
return snap.exists ? (snap.data() as RunRecord) : null;
}
export async function saveTool(tool: ToolDef): Promise<void> {
await db.collection(config.toolsCollection).doc(tool.name).set(tool, { merge: true });
}
export async function listTools(): Promise<ToolDef[]> {
const snap = await db.collection(config.toolsCollection).get();
return snap.docs.map(d => d.data() as ToolDef);
}
backend/control-plane/src/storage/gcs.ts
import { Storage } from "@google-cloud/storage";
import { config } from "../config.js";
const storage = new Storage({ projectId: config.projectId });
export async function writeArtifactText(prefix: string, filename: string, content: string) {
const bucket = storage.bucket(config.artifactsBucket);
const file = bucket.file(`${prefix}/${filename}`);
await file.save(content, { contentType: "text/plain" });
return { bucket: config.artifactsBucket, path: `${prefix}/${filename}` };
}
backend/control-plane/src/registry.ts
import type { ToolDef } from "./types.js";
import { listTools } from "./storage/firestore.js";
/**
* Simple registry. V2: cache + versioning + per-tenant overrides.
*/
export async function getRegistry(): Promise<Record<string, ToolDef>> {
const tools = await listTools();
return Object.fromEntries(tools.map(t => [t.name, t]));
}
backend/control-plane/src/routes/health.ts
import type { FastifyInstance } from "fastify";
export async function healthRoutes(app: FastifyInstance) {
app.get("/healthz", async () => ({ ok: true }));
}
backend/control-plane/src/routes/tools.ts
import type { FastifyInstance } from "fastify";
import { nanoid } from "nanoid";
import { requireAuth } from "../auth.js";
import { getRegistry } from "../registry.js";
import { saveRun } from "../storage/firestore.js";
import { writeArtifactText } from "../storage/gcs.js";
import type { RunRecord, ToolInvokeRequest } from "../types.js";
async function postJson(url: string, body: unknown) {
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body)
});
if (!res.ok) {
const txt = await res.text();
throw new Error(`Executor error ${res.status}: ${txt}`);
}
return res.json() as Promise<unknown>;
}
export async function toolRoutes(app: FastifyInstance) {
app.get("/tools", async (req) => {
await requireAuth(req);
const registry = await getRegistry();
return { tools: Object.values(registry) };
});
app.post<{ Body: ToolInvokeRequest }>("/tools/invoke", async (req) => {
await requireAuth(req);
const body = req.body;
const registry = await getRegistry();
const tool = registry[body.tool];
if (!tool) return app.httpErrors.notFound(`Unknown tool: ${body.tool}`);
const runId = `run_${new Date().toISOString().replace(/[-:.TZ]/g, "")}_${nanoid(8)}`;
const now = new Date().toISOString();
const run: RunRecord = {
run_id: runId,
tenant_id: body.tenant_id,
tool: body.tool,
status: "queued",
created_at: now,
updated_at: now,
input: body.input,
artifacts: { bucket: process.env.GCS_BUCKET_ARTIFACTS ?? "", prefix: `runs/${runId}` }
};
await saveRun(run);
// record input artifact
await writeArtifactText(`runs/${runId}`, "input.json", JSON.stringify(body, null, 2));
// execute (sync for v1; v2: push to Cloud Tasks / Workflows)
try {
run.status = "running";
run.updated_at = new Date().toISOString();
await saveRun(run);
if (body.dry_run) {
run.status = "succeeded";
run.output = { dry_run: true };
run.updated_at = new Date().toISOString();
await saveRun(run);
await writeArtifactText(`runs/${runId}`, "output.json", JSON.stringify(run.output, null, 2));
return { run_id: runId, status: run.status };
}
const execUrl = `${tool.executor.url}${tool.executor.path}`;
const output = await postJson(execUrl, {
run_id: runId,
tenant_id: body.tenant_id,
workspace_id: body.workspace_id,
input: body.input
});
run.status = "succeeded";
run.output = output;
run.updated_at = new Date().toISOString();
await saveRun(run);
await writeArtifactText(`runs/${runId}`, "output.json", JSON.stringify(output, null, 2));
return { run_id: runId, status: run.status };
} catch (e: any) {
run.status = "failed";
run.error = { message: e?.message ?? "Unknown error" };
run.updated_at = new Date().toISOString();
await saveRun(run);
await writeArtifactText(`runs/${runId}`, "error.json", JSON.stringify(run.error, null, 2));
return { run_id: runId, status: run.status };
}
});
}
backend/control-plane/src/routes/runs.ts
import type { FastifyInstance } from "fastify";
import { requireAuth } from "../auth.js";
import { getRun } from "../storage/firestore.js";
export async function runRoutes(app: FastifyInstance) {
app.get("/runs/:run_id", async (req) => {
await requireAuth(req);
// @ts-expect-error fastify param typing
const runId = req.params.run_id as string;
const run = await getRun(runId);
if (!run) return app.httpErrors.notFound("Run not found");
return run;
});
// V1: logs are stored as artifacts in GCS; IDE can fetch by signed URL later
app.get("/runs/:run_id/logs", async (req) => {
await requireAuth(req);
// stub
return { note: "V1: logs are in GCS artifacts under runs/<run_id>/" };
});
}
backend/control-plane/src/index.ts
import Fastify from "fastify";
import cors from "@fastify/cors";
import helmet from "@fastify/helmet";
import rateLimit from "@fastify/rate-limit";
import { config } from "./config.js";
import { healthRoutes } from "./routes/health.js";
import { toolRoutes } from "./routes/tools.js";
import { runRoutes } from "./routes/runs.js";
const app = Fastify({ logger: true });
await app.register(cors, { origin: true });
await app.register(helmet);
await app.register(rateLimit, { max: 300, timeWindow: "1 minute" });
await app.register(healthRoutes);
await app.register(toolRoutes);
await app.register(runRoutes);
app.listen({ port: config.port, host: "0.0.0.0" }).catch((err) => {
app.log.error(err);
process.exit(1);
});
2) Generate Tool Registry schema
You want two things:
A human-editable YAML (source of truth)
A JSON Schema to validate tool definitions
contracts/tool-registry.yaml (example)
version: 1
tools:
cloudrun.deploy_service:
description: Deploy a Cloud Run service via Cloud Build.
risk: medium
executor:
kind: http
url: https://deploy-executor-xxxxx.a.run.app
path: /execute/deploy
inputSchema:
type: object
required: [service_name, repo, ref, env]
properties:
service_name: { type: string }
repo: { type: string, description: "Git repo URL" }
ref: { type: string, description: "Branch, tag, or commit SHA" }
env: { type: string, enum: ["dev", "staging", "prod"] }
outputSchema:
type: object
properties:
service_url: { type: string }
revision: { type: string }
analytics.get_funnel_summary:
description: Return funnel metrics for a tenant and time window.
risk: low
executor:
kind: http
url: https://analytics-executor-xxxxx.a.run.app
path: /execute/funnel
inputSchema:
type: object
required: [range_days]
properties:
range_days: { type: integer, minimum: 1, maximum: 365 }
segment: { type: object }
contracts/tool-registry.schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://productos.dev/schemas/tool-registry.schema.json",
"type": "object",
"required": ["version", "tools"],
"properties": {
"version": { "type": "integer", "minimum": 1 },
"tools": {
"type": "object",
"additionalProperties": { "$ref": "#/$defs/ToolDef" }
}
},
"$defs": {
"ToolDef": {
"type": "object",
"required": ["description", "risk", "executor", "inputSchema"],
"properties": {
"description": { "type": "string" },
"risk": { "type": "string", "enum": ["low", "medium", "high"] },
"executor": { "$ref": "#/$defs/Executor" },
"inputSchema": { "type": "object" },
"outputSchema": { "type": "object" }
},
"additionalProperties": false
},
"Executor": {
"type": "object",
"required": ["kind", "url", "path"],
"properties": {
"kind": { "type": "string", "enum": ["http"] },
"url": { "type": "string" },
"path": { "type": "string" }
},
"additionalProperties": false
}
}
}
3) Generate VSCodium extension skeleton
Folder layout
client-ide/extensions/gcp-productos/
package.json
tsconfig.json
src/extension.ts
src/api.ts
src/ui.ts
media/icon.png (optional)
client-ide/extensions/gcp-productos/package.json
{
"name": "gcp-productos",
"displayName": "GCP Product OS",
"description": "Product-centric panels (Code, Marketing, Analytics, Growth...) and backend tool invocation.",
"version": "0.0.1",
"publisher": "productos",
"engines": { "vscode": "^1.90.0" },
"categories": ["Other"],
"activationEvents": ["onStartupFinished"],
"main": "./dist/extension.js",
"contributes": {
"commands": [
{ "command": "productos.configure", "title": "Product OS: Configure Backend" },
{ "command": "productos.tools.list", "title": "Product OS: List Tools" },
{ "command": "productos.tools.invoke", "title": "Product OS: Invoke Tool" },
{ "command": "productos.runs.open", "title": "Product OS: Open Run" }
],
"configuration": {
"title": "Product OS",
"properties": {
"productos.backendUrl": {
"type": "string",
"default": "http://localhost:8080",
"description": "Control Plane API base URL"
},
"productos.tenantId": {
"type": "string",
"default": "t_dev",
"description": "Tenant ID for tool calls"
}
}
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"watch": "tsc -w -p tsconfig.json"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/vscode": "^1.90.0",
"typescript": "^5.5.4"
}
}
client-ide/extensions/gcp-productos/tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
client-ide/extensions/gcp-productos/src/api.ts
import * as vscode from "vscode";
function cfg<T>(key: string): T {
return vscode.workspace.getConfiguration("productos").get<T>(key)!;
}
export function backendUrl() {
return cfg<string>("backendUrl");
}
export function tenantId() {
return cfg<string>("tenantId");
}
export async function listTools(): Promise<any[]> {
const res = await fetch(`${backendUrl()}/tools`);
if (!res.ok) throw new Error(await res.text());
const json = await res.json();
return json.tools ?? [];
}
export async function invokeTool(tool: string, input: any) {
const res = await fetch(`${backendUrl()}/tools/invoke`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
tool,
tenant_id: tenantId(),
input
})
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function getRun(runId: string) {
const res = await fetch(`${backendUrl()}/runs/${runId}`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
client-ide/extensions/gcp-productos/src/ui.ts
import * as vscode from "vscode";
import { getRun } from "./api";
export async function showJson(title: string, obj: any) {
const doc = await vscode.workspace.openTextDocument({
content: JSON.stringify(obj, null, 2),
language: "json"
});
await vscode.window.showTextDocument(doc, { preview: false });
vscode.window.setStatusBarMessage(title, 3000);
}
export async function openRun(runId: string) {
const run = await getRun(runId);
await showJson(`Run ${runId}`, run);
}
client-ide/extensions/gcp-productos/src/extension.ts
import * as vscode from "vscode";
import { invokeTool, listTools } from "./api";
import { openRun, showJson } from "./ui";
export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(
vscode.commands.registerCommand("productos.configure", async () => {
const backendUrl = await vscode.window.showInputBox({ prompt: "Control Plane backend URL" });
if (!backendUrl) return;
await vscode.workspace.getConfiguration("productos").update("backendUrl", backendUrl, vscode.ConfigurationTarget.Global);
vscode.window.showInformationMessage(`Product OS backend set: ${backendUrl}`);
})
);
context.subscriptions.push(
vscode.commands.registerCommand("productos.tools.list", async () => {
const tools = await listTools();
await showJson("Tools", tools);
})
);
context.subscriptions.push(
vscode.commands.registerCommand("productos.tools.invoke", async () => {
const tools = await listTools();
const pick = await vscode.window.showQuickPick(
tools.map((t: any) => ({ label: t.name, description: t.description })),
{ placeHolder: "Select a tool to invoke" }
);
if (!pick) return;
const inputText = await vscode.window.showInputBox({
prompt: "Tool input JSON",
value: "{}"
});
if (!inputText) return;
const input = JSON.parse(inputText);
const result = await invokeTool(pick.label, input);
await showJson("Invoke Result", result);
if (result?.run_id) {
const open = await vscode.window.showInformationMessage(`Run started: ${result.run_id}`, "Open Run");
if (open === "Open Run") await openRun(result.run_id);
}
})
);
context.subscriptions.push(
vscode.commands.registerCommand("productos.runs.open", async () => {
const runId = await vscode.window.showInputBox({ prompt: "Run ID" });
if (!runId) return;
await openRun(runId);
})
);
}
export function deactivate() {}
4) Generate Terraform base
This is a minimal hub-style baseline:
GCS bucket for artifacts
Firestore (Native) for runs/tools
Cloud Run service for Control Plane
Service accounts + IAM
Placeholders for executor services
Folder layout
infra/terraform/
providers.tf
variables.tf
outputs.tf
main.tf
iam.tf
infra/terraform/providers.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.30"
}
}
}
provider "google" {
project = var.project_id
region = var.region
}
infra/terraform/variables.tf
variable "project_id" { type = string }
variable "region" { type = string default = "us-central1" }
variable "artifact_bucket_name" { type = string }
variable "control_plane_image" {
type = string
description = "Container image URI for control-plane (Artifact Registry)."
}
infra/terraform/main.tf
resource "google_storage_bucket" "artifacts" {
name = var.artifact_bucket_name
location = var.region
uniform_bucket_level_access = true
versioning { enabled = true }
}
# Firestore (Native mode) requires enabling in console once per project (or via API depending on org policy).
resource "google_firestore_database" "default" {
name = "(default)"
location_id = var.region
type = "FIRESTORE_NATIVE"
}
resource "google_service_account" "control_plane_sa" {
account_id = "sa-control-plane"
display_name = "Product OS Control Plane"
}
resource "google_cloud_run_v2_service" "control_plane" {
name = "control-plane"
location = var.region
template {
service_account = google_service_account.control_plane_sa.email
containers {
image = var.control_plane_image
env {
name = "GCP_PROJECT_ID"
value = var.project_id
}
env {
name = "GCS_BUCKET_ARTIFACTS"
value = google_storage_bucket.artifacts.name
}
env {
name = "AUTH_MODE"
value = "dev"
}
}
}
}
# Public access optional; prefer IAM auth in production.
resource "google_cloud_run_v2_service_iam_member" "control_plane_public" {
name = google_cloud_run_v2_service.control_plane.name
location = var.region
role = "roles/run.invoker"
member = "allUsers"
}
infra/terraform/iam.tf
# Allow control-plane to write artifacts in GCS
resource "google_storage_bucket_iam_member" "control_plane_bucket_writer" {
bucket = google_storage_bucket.artifacts.name
role = "roles/storage.objectAdmin"
member = "serviceAccount:${google_service_account.control_plane_sa.email}"
}
# Firestore access for run/tool metadata
resource "google_project_iam_member" "control_plane_firestore" {
project = var.project_id
role = "roles/datastore.user"
member = "serviceAccount:${google_service_account.control_plane_sa.email}"
}
# Placeholder: executor services will each have their own service accounts.
# Control-plane should be granted roles/run.invoker on each executor service once created.
infra/terraform/outputs.tf
output "control_plane_url" {
value = google_cloud_run_v2_service.control_plane.uri
}
output "artifact_bucket" {
value = google_storage_bucket.artifacts.name
}

View File

@@ -89,7 +89,7 @@ This is the difference between a founder who has a COO (talks to one person who
└─────────────┘ └─────────────┘ └─────────────┘
↓ ↓ ↓
┌─────────────────────────────────────────────────────────────────────┐
│ EXECUTION RUNTIME (Theia + vibn-agent-runner) │
│ EXECUTION RUNTIME (vibn-agent-runner)
│ │
│ - Persistent dev environment: Node, Python, npm, git │
│ - Agent executes commands, writes files, runs builds │
@@ -278,16 +278,6 @@ If yes → delegates to Code Advisor
│ - Auto-commits to Gitea on completion │
│ - Triggers Coolify redeploy automatically │
│ - Session persists even if browser tab is closed │
└──────────────────────┬────────────────────────────────────────────┘
┌───────────────────────────────────────────────────────────────────┐
│ Theia container (persistent dev environment) │
│ │
│ - Node.js, Python, npm, git — full dev toolchain │
│ - sync-server.js (port 3001): git pull trigger from agent │
│ - startup.sh: clones project repo from Gitea on boot │
│ - PTY available for Terminal tab (Phase 5) │
└──────────────────────┬────────────────────────────────────────────┘
│ git push (auto on completion)
@@ -420,15 +410,12 @@ CREATE TABLE advisor_memory (
| Auto-commit + push to Gitea on completion | ✅ |
| Gitea webhook triggers Coolify auto-deploy | ✅ |
| Browse tab shows latest committed files | ✅ |
| "Open in Theia →" links to theia.vibnai.com | ✅ |
| Theia sync-server running on port 3001 | ✅ |
| Project tabs (Atlas/PRD/Build/Growth/Assist/Analytics) in sidebar | ✅ |
### In Progress
| Step | Status |
|------|--------|
| Agent `execute_command` routes through Theia container | 🔶 Phase 2 |
| Assist COO + specialist advisor architecture | 🔶 Designing |
### Not Yet Started
@@ -444,7 +431,7 @@ CREATE TABLE advisor_memory (
| Proactive monitoring (anomaly detection, briefings) | ⬜ |
| Parallel task execution | ⬜ |
| WebSocket streaming (replace polling) | ⬜ |
| Terminal tab (xterm.js → Theia PTY) | ⬜ |
| Terminal tab (xterm.js → live container PTY) | ⬜ |
---
@@ -461,11 +448,11 @@ CREATE TABLE advisor_memory (
- [x] Context-aware task input (locked while running)
- [x] Project tabs moved to sidebar (Atlas/PRD/Build/Growth/Assist/Analytics)
### Phase 2 — Execution in Theia 🔶 In Progress
- [x] Theia container with sync-server.js on port 3001
- [x] startup.sh clones/pulls project repo on boot
- [ ] Agent `execute_command` routes through Theia (docker exec or HTTP)
- [ ] "Open in Theia →" reflects live agent workspace
### 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
@@ -488,7 +475,7 @@ CREATE TABLE advisor_memory (
- [ ] 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 Theia PTY
- [ ] Terminal tab: xterm.js connected to project workspace PTY
### Phase 6 — Proactive Intelligence
- [ ] Assist monitors for anomalies and surfaces them without being asked
@@ -519,8 +506,5 @@ Once a specialist Advisor has produced a clear, structured plan, decomposing it
**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 Theia as the runtime?**
Theia provides a persistent, pre-configured dev environment — Node, Python, git, PTY — with no cold-start cost. We use it as infrastructure; the user never sees the Theia UI. VIBN is the product.
**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

@@ -0,0 +1,292 @@
# Agent telemetry & live execution stream — project spec
This document captures **concrete product and engineering additions** discussed for Vibn: moving from **poll-based session updates** and **in-memory jobs** to a **durable, ordered, push-friendly execution timeline**—the web equivalent of a terminal agents clarity (step-by-step visibility, tool boundaries, failures, and later multi-agent signals).
---
## 1. Why this exists
### Current behavior (baseline)
| Surface | How progress reaches the user | Limits |
|--------|------------------------------|--------|
| **Agent sessions** (`agent_sessions`) | Runner `PATCH`es `output`, `status`, `changed_files` to Next; UI **polls** `GET …/agent/sessions/[id]`. | Latency, reconnect story, no single ordered stream; rich semantics encoded only in `text`. |
| **Jobs** (`/api/agent/run`, `/api/jobs/:id`) | In-memory `job-store` (`progress`, `toolCalls[]`); UI polls job endpoint. | Lost on restart; not shared across runner replicas; not unified with session UI. |
| **Orchestrator / Atlas chat** | Request/response to runner; advisor path may be remote URL. | No execution timeline for “long COO run” in-product unless you add the same event layer. |
### Product intent
- **Trust during long runs**: users see *what* happened, *when*, and *whether something was blocked*—not only a final status.
- **Differentiation**: “Ink-like” clarity in the browser—structured steps, not a blob of logs.
- **Foundation for multi-agent**: handoffs, child work, and safety events need a **common event pipe**, not ad-hoc strings.
---
## 2. Goals
1. **Append-only execution events** with **monotonic ordering** (per session or per job), suitable for replay after refresh.
2. **Server-push to the client** (recommend **SSE** first; WebSocket if you need bi-directional on the same channel).
3. **Persistence** so reconnect, refresh, and horizontal scaling do not lose history.
4. **Single conceptual model** (`AgentEvent`) usable by:
- Build → **Agent** tab (sessions),
- **Job** flows (create/analyze-style),
- optionally **orchestrator** long runs later.
5. **Backward compatibility** during rollout: existing `PATCH` + `output` can remain as a fallback or be fed from the same emitter.
### Non-goals (for v1)
- Full **OpenTelemetry** export (optional later).
- **Real-time collaborative** multi-user cursors on the same session.
- Merging **claude-code-fork**—this spec is **API + UI + persistence** only.
---
## 3. Concept: `AgentEvent`
### Core shape (suggested)
```ts
type AgentEvent = {
seq: number; // monotonic per stream (session_id or job_id)
ts: string; // ISO-8601
runId: string; // session UUID or job id — ties events to a run
runKind: 'session' | 'job';
phase: 'queued' | 'running' | 'completed' | 'failed' | 'stopped';
type: AgentEventType;
payload: Record<string, unknown>; // type-specific
};
type AgentEventType =
| 'run.started'
| 'run.phase' // e.g. planning, executing, committing
| 'llm.turn.start'
| 'llm.turn.end'
| 'tool.start'
| 'tool.end'
| 'tool.output' // chunked stdout/stderr if needed
| 'safety.block' // policy / protected path / command denied
| 'file.changed' // maps to todays changed_files semantics
| 'git.commit'
| 'deploy.triggered'
| 'deploy.status'
| 'error'
| 'run.completed'
| 'handoff' // v2: parent → child agent
| 'child_job.started' // v2: linked run id
;
```
### Mapping from todays session `outputLine`
| Today (`outputLine.type`) | Suggested event(s) |
|---------------------------|--------------------|
| `step` / `info` | `run.phase` or `llm.turn.*` with summary in `payload.message` |
| `stdout` / `stderr` | `tool.output` or dedicated stream events |
| `error` | `error` + optional `safety.block` if policy-driven |
| `done` | `run.completed` |
Keep **human-readable `message`** on events for UI defaults; add **structured fields** (`tool`, `argsSummary`, `durationMs`) for timeline rendering and filters.
---
## 4. Architecture (high level)
```mermaid
flowchart LR
subgraph runner [vibn-agent-runner]
RA[runSessionAgent / runAgent]
EMIT[emitAgentEvent]
end
subgraph api [vibn-frontend Next.js]
ING[POST internal ingest or PATCH extend]
DB[(Postgres agent_events)]
SSE[SSE GET /api/.../stream]
end
subgraph browser [Browser]
UI[Timeline + live log]
end
RA --> EMIT
EMIT -->|HTTPS + secret or mTLS| ING
ING --> DB
UI -->|EventSource| SSE
SSE --> DB
```
**Principles**
- **Runner remains stateless** regarding “truth”: it emits events; **Next + DB** are the source of truth for the UI (matches todays session model).
- Alternatively, runner could expose **SSE directly**—usually worse for **auth**, **CORS**, and **one domain** for the product. Prefer **Next as SSE endpoint** reading from DB.
---
## 5. Backend: `vibn-agent-runner`
### 5.1 Emit from execution paths
| Location | Action |
|----------|--------|
| `agent-session-runner.ts` | Replace or supplement `patchSession` output-only updates with **`emitAgentEvent`** each turn / tool / error. |
| `runAgent` / tool loop (`executeTool`) | Same emitter for **job** runs. |
| `server.ts` `/agent/execute` | Emit `run.started` after 202; `run.completed` / `error` on exit. |
| Security / blocked tools (`security.ts` or equivalent) | Emit `safety.block` with reason code (no secrets in payload). |
### 5.2 Transport runner → Next
**Option A (recommended):** extend existing **PATCH** or add **`POST /api/internal/agent-events`** (or per-session batch append):
- Headers: `x-agent-runner-secret` (same as todays PATCH).
- Body: single event or small batch `{ events: AgentEvent[] }` with server-assigned `seq` to avoid races.
**Option B:** Runner writes to **Redis/Postgres** directly—couples runner to DB credentials; only do if you already run runner inside the same trust zone with DB URL.
### 5.3 Jobs store
- **Short term:** continue in-memory for job metadata; **persist events** to Postgres keyed by `jobId`.
- **Medium term:** optional **Redis** for job status + pub/sub to Next for low-latency SSE fanout (only if DB polling becomes a bottleneck).
---
## 6. Backend: `vibn-frontend` (Next.js)
### 6.1 Persistence
**New table (example): `agent_run_events`**
| Column | Notes |
|--------|--------|
| `id` | UUID |
| `run_id` | Session id or job id (text) |
| `run_kind` | `'session' \| 'job'` |
| `seq` | BIGSERIAL or per-run sequence enforced with unique constraint `(run_id, seq)` |
| `project_id` | Nullable for jobs if not scoped |
| `event` | JSONB — full `AgentEvent` or `{ type, ts, payload }` |
| `created_at` | default now() |
Index: `(run_id, seq)` for range queries (`WHERE run_id = $1 AND seq > $lastSeen`).
**Optional:** migrate legacy `agent_sessions.output` to be **derived** (last N lines for email export) or **dual-write** during transition.
### 6.2 SSE route (example contract)
- **`GET /api/projects/[projectId]/agent/sessions/[sessionId]/events/stream`**
- Auth: session cookie / same as GET session (user must own project).
- Query: `?afterSeq=123` for replay.
- Response: `text/event-stream`; each message: `data: {JSON}\n\n`.
- Heartbeat comments every ~1530s to keep proxies alive.
For **jobs** (if not project-scoped): `GET /api/jobs/[jobId]/events/stream` with appropriate auth.
### 6.3 Ingest route (runner-only)
- **`POST /api/internal/agent-events`** (or nested under project/session as you prefer).
- Validates `x-agent-runner-secret`.
- Inserts rows with **server-generated `seq`** (transaction per run or advisory lock per `run_id`).
---
## 7. Frontend (product UI)
### 7.1 Agent tab — timeline
- **EventSource** (SSE) subscription when session is `running`; on load, **fetch historical** events (`GET …/events?afterSeq=0` or SSE from 0).
- **Timeline components**:
- Group by `llm.turn` / `tool.start``tool.end`.
- Expandable tool args (sanitized).
- Distinct styling for `safety.block` and `error`.
- **Reconnect**: on `EventSource` error, reopen with `lastSeq` from last received event.
### 7.2 Jobs / analyze flows
- Same timeline component keyed by `jobId` if you surface those runs in UI.
- Unifies mental model: “every run has a stream.”
### 7.3 Deprecate slow polling
- Reduce `GET …/agent/sessions/[id]` poll interval when SSE connected; keep **single poll** for `status` / `changed_files` if those stay on session row only, or **also** emit `file.changed` events and drive UI from stream + one final consistency read.
---
## 8. Security & privacy
- **Never** put tokens, env values, or full file contents in events by default; use **truncation** and **hashes** where needed.
- **`safety.block`**: log reason **code** + user-safe message; align with `security.ts` behavior.
- **Rate limits** on ingest endpoint (per `run_id` / per IP) to avoid abuse if misconfigured.
---
## 9. Environment variables
| Variable | Where | Purpose |
|----------|--------|---------|
| `AGENT_RUNNER_SECRET` | Runner + Next | Ingest / extended PATCH auth |
| `VIBN_API_URL` | Runner | Base URL for callbacks |
| `AGENT_RUNNER_URL` | Next | Start runs (unchanged) |
Add if needed:
| Variable | Purpose |
|----------|---------|
| `AGENT_EVENTS_INGEST_PATH` | Optional override for ingest URL |
| `SSE_MAX_BUFFER` | Cap replay batch size |
---
## 10. Phased roadmap (suggested)
### Phase 1 — Foundation
- [ ] Define `AgentEvent` TypeScript types in a **shared package** or duplicated minimal types in runner + frontend.
- [ ] Create `agent_run_events` (or equivalent) + migration.
- [ ] Implement **ingest** endpoint; wire **runner session path** to emit core events: `run.started`, `tool.start` / `tool.end`, `error`, `run.completed`, `file.changed`.
- [ ] **Dual-write**: keep existing `PATCH` `outputLine` so nothing breaks.
### Phase 2 — Push
- [ ] SSE route + **EventSource** in Agent tab.
- [ ] Backfill UI from DB on mount; then live tail.
- [ ] Lower or gate polling on `GET` session.
### Phase 3 — Jobs + durability
- [ ] Emit same events from **job** execution path; persist by `jobId`.
- [ ] Optional: replace in-memory job list with DB for **multi-instance** runner (later).
### Phase 4 — Rich semantics
- [ ] `safety.block` from policy layer.
- [ ] `deploy.*` events if Coolify integration is user-visible.
- [ ] **Multi-agent**: `handoff`, `child_job.*` with links in payload.
---
## 11. Success metrics
- Time-to-first-visible-step after **Run** &lt; **1s** p95 (SSE).
- After hard refresh mid-run, user sees **consistent history** (no duplicate seq, no gaps if you guarantee at-least-once ingest with idempotency keys later).
- Support tickets / confusion drops on “what is the agent doing?” (qualitative).
---
## 12. Related code (repo anchors)
Use these when implementing:
- Runner session loop + PATCH bridge: `vibn-agent-runner/src/agent-session-runner.ts`
- Runner HTTP: `vibn-agent-runner/src/server.ts` (`/agent/execute`, `/agent/stop`, `/agent/approve`, `/api/agent/run`, `/api/jobs/:id`)
- In-memory jobs: `vibn-agent-runner/src/job-store.ts`
- Next session API + runner callback: `vibn-frontend/app/api/projects/[projectId]/agent/sessions/[sessionId]/route.ts`
- Session create + fire-and-forget execute: `vibn-frontend/app/api/projects/[projectId]/agent/sessions/route.ts`
---
## 13. Open decisions
1. **Single table** for sessions + jobs vs **two tables** (simpler queries vs flexibility).
2. **Seq generation**: DB sequence per `run_id` vs global monotonic with `(run_id, seq)` composite only in app logic.
3. **Idempotency**: runner retries may duplicate events—use **`event_id` UUID** from runner for dedupe on ingest.
4. **Orchestrator chat**: treat as v2 unless you need a **COO run** timeline immediately.
---
*Document version: 1.0 — aligned with discussion of runner ↔ frontend telemetry, SSE-first delivery, Postgres persistence, and future multi-agent event types.*

584
AI_CAPABILITIES.md Normal file
View File

@@ -0,0 +1,584 @@
# Vibn AI Capabilities
> The full set of actions an AI agent can take on behalf of a Vibn workspace,
> along with the REST endpoints, MCP tools, and safety rails that back them.
>
> **Audience:** agent authors, Cursor rule writers, MCP tool designers, and
> anyone building on the Vibn control plane.
>
> **Scope:** everything an agent sees through `https://vibnai.com/api/*` and
> the `/api/mcp` bridge. No Firestore, no internal agent orchestration —
> just the tenant-safe capability surface.
---
## 1. Mental model
Every capability in this document operates on a single **workspace**. A
workspace is Vibn's tenant boundary and maps 1:1 to:
| Vibn concept | External identity | Example (`mark`) |
|---|---|---|
| Workspace | `vibn_workspaces.slug` | `mark` |
| Gitea org | `gitea_org` | `vibn-mark` |
| Gitea bot user | `gitea_bot_username` | `mark-bot` |
| SSH deploy keypair | `coolify_private_key_uuid` + `gitea_bot_ssh_key_id` | registered on both sides |
| Coolify project | `coolify_project_uuid` | `vibn-ws-mark` |
| Coolify environment | `coolify_environment_name` | `production` |
| Domain namespace | `*.{slug}.vibnai.com` | `*.mark.vibnai.com` |
| AI token | `vibn_sk_…` | one per agent/device |
A single agent token can only act on the workspace it was minted for. Cross-
workspace access is structurally impossible — enforced in
[`lib/coolify.ts`](./vibn-frontend/lib/coolify.ts) by matching every Coolify
resource's `environment_id` against the workspace's project environments
(`ensureResourceInProject`).
### The three views
All capabilities roll up into three user-facing surfaces:
- **Code** — every Gitea repo under `vibn-{slug}/`.
- **Live** — every Coolify app/database/service in `vibn-ws-{slug}`, each
reachable under `*.{slug}.vibnai.com`.
- **IDE** — Browser-based agent workspace sessions (outside the scope of this doc).
---
## 2. Authentication
Every agent-facing endpoint accepts **either**:
- `Authorization: Bearer vibn_sk_<base64url>` — a workspace-scoped API key
minted in the settings panel. Stored as a sha256 hash server-side; the
plaintext is shown exactly once on creation. Can be revoked at any time.
- A NextAuth session cookie — used for the dashboard UI and for browser
debugging. Not suitable for long-running agents.
Helper: [`requireWorkspacePrincipal()`](./vibn-frontend/lib/auth/workspace-auth.ts)
resolves either to a `WorkspacePrincipal { workspace, user?, source }`.
**403 on a tenant mismatch means:** the token is valid, but the resource
belongs to another workspace. The agent should stop and ask the user.
---
## 3. MCP surface
The MCP bridge lives at `POST https://vibnai.com/api/mcp`. It takes
JSON-over-HTTP bodies shaped like:
```json
{ "tool": "<tool-name>", "params": { /* tool-specific */ } }
```
The Cursor / Claude Desktop config block is auto-generated in the settings
panel and looks like:
```json
{
"mcpServers": {
"vibn-mark": {
"url": "https://vibnai.com/api/mcp",
"headers": { "Authorization": "Bearer vibn_sk_…" }
}
}
}
```
`GET /api/mcp` returns a self-description with the current tool list.
Version: **2.1.0**.
### 3.1 Workspace & identity tools
| Tool | Purpose | Params |
|---|---|---|
| `workspace.describe` | Returns slug, Coolify project uuid, Gitea org, provision status. | — |
| `gitea.credentials` | Returns the bot's username, PAT, clone URL template, and SSH remote template. Use this for every `git clone`/push — never other credentials. | — |
### 3.2 Project tools
| Tool | Purpose | Params |
|---|---|---|
| `projects.list` | Lists Vibn projects (PRDs, imports, etc.) in the workspace. | — |
| `projects.get` | Single project details. | `{ projectId }` |
### 3.3 Application tools
| Tool | Purpose | Params |
|---|---|---|
| `apps.list` | All Coolify apps in the workspace. | — |
| `apps.get` | Single app details (status, fqdn, domains, git info). | `{ uuid }` |
| `apps.create` | Create a Coolify app from a Gitea repo in the workspace's org, pinned to the workspace's SSH deploy key. Auto-domain `{name}.{slug}.vibnai.com`. | `{ repo, branch?, name?, ports?, buildPack?, domain?, envs?, instantDeploy? }` |
| `apps.update` | PATCH a whitelisted set of fields (name, description, git branch, ports, build commands, base directory, Dockerfile location…). | `{ uuid, patch }` |
| `apps.delete` | Destroy the app. Volumes kept by default. | `{ uuid, confirm }``confirm` must equal the app's exact name |
| `apps.deploy` | Trigger a new deployment. | `{ uuid, force? }` |
| `apps.deployments` | List recent deployments + status. | `{ uuid }` |
| `apps.domains.list` | Current domain set. | `{ uuid }` |
| `apps.domains.set` | Replace the domain set. All entries must end with `.{slug}.vibnai.com`. | `{ uuid, domains: string[] }` |
| `apps.envs.list` | List env vars. Values returned are redacted for `shown-once` secrets. | `{ uuid }` |
| `apps.envs.upsert` | Create or update an env var. | `{ uuid, key, value, isBuildTime?, isMultiline?, isLiteral?, isShownOnce? }` |
| `apps.envs.delete` | Delete an env var. | `{ uuid, key }` |
### 3.4 Database tools
| Tool | Purpose | Params |
|---|---|---|
| `databases.list` | All databases in the workspace, across all flavors. | — |
| `databases.create` | Provision a database. Supported `type`: `postgresql`, `mysql`, `mariadb`, `mongodb`, `redis`, `keydb`, `dragonfly`, `clickhouse`. | `{ type, name?, isPublic?, publicPort?, image?, credentials?, limits? }` |
| `databases.get` | Details + internal connection URL. | `{ uuid }` |
| `databases.update` | PATCH name, public visibility, image, limits. | `{ uuid, patch }` |
| `databases.delete` | Destroy the database. Volumes kept by default. | `{ uuid, confirm }``confirm` must equal the db's exact name |
### 3.5 Auth provider tools
Authentication is a first-class capability. An agent cannot spin up arbitrary
Coolify services — only vetted auth providers from an allowlist.
| Tool | Purpose | Params |
|---|---|---|
| `auth.list` | Auth providers currently deployed in the workspace (classified by Coolify's `service_type`). | — |
| `auth.create` | Provision one of the allowed providers. | `{ provider, name?, description?, instantDeploy? }` |
| `auth.delete` | Destroy an auth provider. Volumes (user data) kept by default. | `{ uuid, confirm }``confirm` must equal the service's exact name |
**Allowed providers** (keys passed as `provider`):
- `pocketbase` — lightweight (SQLite) auth + data, single container.
- `authentik` — feature-rich self-hosted IDP.
- `keycloak` / `keycloak-with-postgres` — industry-standard OIDC/SAML.
- `pocket-id` / `pocket-id-with-postgresql` — passkey-first OIDC.
- `logto` — dev-first IDP.
- `supertokens-with-postgresql` — session/auth backend.
Requesting anything outside this list returns 400 with a hint listing the
allowed ones, so the agent can self-correct.
### 3.6 Domain tools (P5.1 — custom apex domains)
Custom apex domains are owned end-to-end by Vibn: the registrar is OpenSRS
(Tucows), authoritative DNS is Google Cloud DNS in the Canadian project, and
domains are pinned to the workspace that registered them. All four lifecycle
steps — search, register, attach, inspect — are agent-callable.
| Tool | Purpose | Params |
|---|---|---|
| `domains.search` | Check availability + price for one or more candidate apex domains via OpenSRS. Stateless; does not reserve anything. | `{ names: string[], period?: number }``names` up to 25, `period` in years (auto-bumped for quirky TLDs like `.ai` which requires 2y minimum). |
| `domains.register` | Register a domain through OpenSRS. Registers unlocked; locking happens automatically after `domains.attach` completes. Idempotent per `(workspace, domain)`. | `{ domain, period?, whoisPrivacy?, contact, nameservers?, ca?: { cprCategory, legalType } }``ca.*` required for `.ca`. |
| `domains.list` | List all domains owned by the workspace with their status, registrar order id, expiry, and DNS provider/zone. | — |
| `domains.get` | Full record + last 20 lifecycle events. | `{ domain }` |
| `domains.attach` | Wire a registered domain to a Coolify app (or arbitrary IP/CNAME): create Cloud DNS zone, write A/CNAME rrsets, update registrar-side nameservers, append FQDNs to the Coolify app's domain list. Idempotent; safe to retry. | `{ domain, appUuid? \| ip? \| cname?, subdomains?: string[] (default ["@","www"]), updateRegistrarNs? }` |
**Residency note:** Cloud DNS is global anycast — configuration is not
Canadian-pinned at the storage layer. The workspace-level `dns_provider`
flag (default `cloud_dns`) will let us swap in CIRA D-Zone for strict
Canadian residency without touching the MCP surface.
**Billing:** Every successful `domains.register` writes a `debit` row to
`vibn_billing_ledger` with the OpenSRS order id as `ref_id`. The
`vibn_domain_events` table keeps an append-only audit of every lifecycle
call (`register.attempt`, `register.success`, `register.failed`,
`attach.success`).
**Verified end-to-end (2026-04-22)** against PROD GCP + OpenSRS sandbox +
PROD Coolify (Coolify `v4.0.0-beta.473`); see
`vibn-frontend/scripts/smoke-attach-e2e.ts`. **All 5 sub-systems green.**
- ✓ OpenSRS register against Horizon (sandbox) returns order id, response 200.
- ✓ Cloud DNS managed zone created in `master-ai-484822` with public anycast NS.
- ✓ A records (`@`, `www`) written to the zone.
- ✓ Registrar-side nameserver update accepts Cloud DNS NS values
(trailing-dot normalization in `lib/opensrs.ts`); sandbox returns 480
because its mock registry doesn't know real Google NS hosts, which is
expected — live mode talks to real registries that accept any resolvable NS.
- ✓ Unlock → update NS → relock fallback path verified (sandbox-recognized
nameservers return 200; the unlock/relock sequence is exercised when the
registry returns 405 lock-conflict).
- ✓ Coolify domain-list PATCH adds the apex + `www` to the application
`fqdn` column and the smoke test re-fetches it to confirm.
> **Operational gotcha — the destination server must be proxy-enabled.**
> Coolify's `update_by_uuid` controller accepts `domains` as a comma-separated
> list and only maps it onto the model's `fqdn` column when the destination
> server's `Server::isProxyShouldRun()` returns `true`. That helper requires
> **both** `proxy.type ∈ {TRAEFIK, CADDY}` *and* `is_build_server = false`.
> If either is misconfigured the PATCH returns 200 but the field is silently
> dropped (Laravel mass-assignment ignores `domains` because it isn't in
> `$fillable`, and the controller never copies it into `fqdn`). We hit this
> on `coolify-server-mtl` (`zg4cwgc44ogc08804000gggo`), which had
> `proxy=null` and `is_build_server=true`. Fixed by:
>
> ```sql
> UPDATE servers
> SET proxy = jsonb_set(coalesce(proxy,'{}'::jsonb), '{type}', '"TRAEFIK"')
> WHERE uuid = 'zg4cwgc44ogc08804000gggo';
> UPDATE server_settings
> SET is_build_server = false
> WHERE server_id = (SELECT id FROM servers WHERE uuid = 'zg4cwgc44ogc08804000gggo');
> ```
>
> followed by `docker restart coolify` to clear Laravel's in-memory config.
> Sending `fqdn` directly is **not** an alternative — the controller's
> `$allowedFields` whitelist rejects it with 422 "This field is not allowed."
### 3.7 Agent-side stdio MCP servers (`vibn-agent-runner`)
Separate from the control-plane MCP at `/api/mcp` (which is what external
agents call *into* Vibn), the `vibn-agent-runner` exposes its own in-house
tool surface *outward* over stdio MCP. This lets Cursor, Claude Desktop,
Goose, or any MCP-speaking client drive the same Coolify / Gitea / workspace
tooling the Coder/PM/Marketing sub-agents use internally — with the same
protected-repo and protected-app guardrails enforced centrally.
Architecture: every tool now has three touch-points backed by one source of truth:
```
vibn-agent-runner/src/tools/<domain>-api.ts ← pure, config-agnostic logic + security guards
vibn-agent-runner/src/tools/<domain>.ts ← thin registerTool() wrappers for the in-process agent loop
vibn-agent-runner/src/mcp/<domain>-server.ts ← stdio MCP server for external clients
```
| Server | Tools | Required env |
|---|---|---|
| `vibn-coolify-mcp` | 7 — list_projects, list_applications, deploy, get_logs, list_all_apps, get_app_status, deploy_app | `COOLIFY_API_URL`, `COOLIFY_API_TOKEN` |
| `vibn-gitea-mcp` | 6 — create/list/close issues, list_repos, list_all_issues, read_repo_file | `GITEA_API_URL`, `GITEA_API_TOKEN`, `GITEA_USERNAME` |
| `vibn-workspace-mcp` | 8 — read/write/replace/list/find/search_code, execute_command, git_commit_and_push | `WORKSPACE_ROOT` (+ Gitea creds for git push) |
| `vibn-platform-mcp` | 7 — save_memory, list_memory, list_skills, get_skill, finalize_prd, get_prd, web_search | `SESSION_KEY` (optional), Gitea creds (for skills) |
| `vibn-agent-mcp` | 2 — spawn_agent, get_job_status (dispatches into the runner's HTTP API) | `AGENT_RUNNER_URL` (defaults to `http://localhost:3333`) |
Run locally with `npm run mcp:<name>` (or `:dev` via ts-node) in
`vibn-agent-runner/`. Smoke-test any server with
`node scripts/smoke-mcp.js <name>`. The in-process agent loop still sees
the same 28 registered tools — no behavioral regression.
---
## 4. REST surface
Every MCP tool is also exposed as a plain HTTP endpoint under
`/api/workspaces/{slug}/…`. Agents that prefer curl-style access can use
these directly; the shape is identical to the MCP `params`. Auth is the
same bearer header.
### 4.1 Workspace & key management
| Method | Path | Description |
|---|---|---|
| GET | `/api/workspaces` | All workspaces the principal has access to. |
| GET | `/api/workspaces/{slug}` | Workspace details. |
| POST | `/api/workspaces/{slug}/provision` | Idempotent re-run of Gitea org + bot + SSH keypair + Coolify project setup. |
| GET | `/api/workspaces/{slug}/keys` | List API keys (metadata only). |
| POST | `/api/workspaces/{slug}/keys` | Mint a new API key. Full token returned once. |
| DELETE | `/api/workspaces/{slug}/keys/{keyId}` | Revoke a key. |
| GET | `/api/workspaces/{slug}/gitea-credentials` | Return bot username, PAT (decrypted), clone/SSH templates. |
| GET | `/api/workspaces/{slug}/bootstrap.sh` | Shell script that writes `.cursor/rules`, `.cursor/mcp.json`, `.env.local` into the cwd. |
### 4.2 Applications
| Method | Path | Description |
|---|---|---|
| GET | `/api/workspaces/{slug}/apps` | List apps. |
| POST | `/api/workspaces/{slug}/apps` | Create an app from a workspace repo. |
| GET | `/api/workspaces/{slug}/apps/{uuid}` | App details. |
| PATCH | `/api/workspaces/{slug}/apps/{uuid}` | Update whitelisted fields. |
| DELETE | `/api/workspaces/{slug}/apps/{uuid}?confirm=<exact-name>` | Destroy app. |
| POST | `/api/workspaces/{slug}/apps/{uuid}/deploy` | Trigger deploy. |
| GET | `/api/workspaces/{slug}/apps/{uuid}/deployments` | List deployments. |
| GET | `/api/workspaces/{slug}/apps/{uuid}/domains` | List domains. |
| PATCH | `/api/workspaces/{slug}/apps/{uuid}/domains` | Replace domain set. |
| GET | `/api/workspaces/{slug}/apps/{uuid}/envs` | List env vars. |
| PATCH | `/api/workspaces/{slug}/apps/{uuid}/envs` | Upsert env var(s). |
| DELETE | `/api/workspaces/{slug}/apps/{uuid}/envs?key=FOO` | Delete env var. |
| GET | `/api/workspaces/{slug}/deployments/{deploymentUuid}/logs` | Deployment logs. |
### 4.3 Databases
| Method | Path | Description |
|---|---|---|
| GET | `/api/workspaces/{slug}/databases` | List databases. |
| POST | `/api/workspaces/{slug}/databases` | Create a database (8 flavors). |
| GET | `/api/workspaces/{slug}/databases/{uuid}` | Database details + internal connection URL. |
| PATCH | `/api/workspaces/{slug}/databases/{uuid}` | Update fields. |
| DELETE | `/api/workspaces/{slug}/databases/{uuid}?confirm=<exact-name>` | Destroy database. |
### 4.4 Auth providers
| Method | Path | Description |
|---|---|---|
| GET | `/api/workspaces/{slug}/auth` | List deployed auth providers + the allowlist. |
| POST | `/api/workspaces/{slug}/auth` | Provision a provider from the allowlist. |
| GET | `/api/workspaces/{slug}/auth/{uuid}` | Provider details. |
| DELETE | `/api/workspaces/{slug}/auth/{uuid}?confirm=<exact-name>` | Destroy provider. |
### 4.5 Domains (P5.1)
| Method | Path | Description |
|---|---|---|
| POST | `/api/workspaces/{slug}/domains/search` | Availability + pricing for up to 25 candidate names. |
| GET | `/api/workspaces/{slug}/domains` | List workspace-owned domains. |
| POST | `/api/workspaces/{slug}/domains` | Register a domain (idempotent per `(workspace, domain)`). |
| GET | `/api/workspaces/{slug}/domains/{domain}` | Full record + last 20 events. |
| POST | `/api/workspaces/{slug}/domains/{domain}/attach` | Create Cloud DNS zone, write records, update registrar NS, wire Coolify domain list. |
---
## 5. Gitea surface
AI agents **never** talk to the root Gitea admin token. They use the
workspace's dedicated bot user.
### 5.1 What the bot can do
- Fully own the `vibn-{slug}` org (added as the org's owner team).
- Read/write every repo in that org via its PAT.
- Push over SSH using the workspace's ed25519 deploy key (same keypair
Coolify uses to pull code).
- What it **cannot** do: touch any other org, the root admin surface, or
Gitea's `/admin/*` endpoints.
### 5.2 How to get the bot credentials
```http
GET /api/workspaces/{slug}/gitea-credentials
Authorization: Bearer vibn_sk_
```
Returns:
```json
{
"bot": { "username": "mark-bot", "token": "…" },
"gitea": {
"apiBase": "https://git.vibnai.com/api/v1",
"host": "git.vibnai.com",
"cloneUrlTemplate": "https://mark-bot:{{token}}@git.vibnai.com/vibn-mark/{{repo}}.git",
"sshRemoteTemplate": "git@git.vibnai.com:vibn-mark/{{repo}}.git",
"webUrlTemplate": "https://git.vibnai.com/vibn-mark/{{repo}}"
},
"workspace": { "slug": "mark", "giteaOrg": "vibn-mark" }
}
```
The PAT is stored **encrypted at rest** using AES-256-GCM with the
`VIBN_SECRETS_KEY` server secret; the decrypt step runs only on this endpoint.
### 5.3 Gitea operations via the standard Gitea API
Once the agent has `{bot.token, gitea.apiBase}`, it can call any standard
Gitea v1 endpoint as the bot, scoped to the workspace org. Common ones:
- `POST /orgs/{org}/repos` — create a repo.
- `PATCH /repos/{org}/{repo}` — update repo settings.
- `GET /repos/{org}/{repo}/contents/{path}` — read files.
- `PUT /repos/{org}/{repo}/contents/{path}` — write files (commits).
- `POST /repos/{org}/{repo}/pulls` — open PRs.
- `POST /repos/{org}/{repo}/branches` — create branches.
---
## 6. Domain policy
Every app gets an auto-generated domain under the workspace's namespace:
```
{app-slug}.{workspace-slug}.vibnai.com
```
For example, creating an app named `my-api` in workspace `mark` yields
`my-api.mark.vibnai.com` automatically — no DNS config, no cert work,
served by Coolify's wildcard Traefik.
### 6.1 What agents can do
- Accept the auto-generated domain (default path).
- Replace the domain set via `PATCH /apps/{uuid}/domains`, provided every
entry ends with `.{workspace-slug}.vibnai.com`.
### 6.2 What agents cannot do
- Point an app at a domain outside the workspace's namespace. The server
rejects this with 403 regardless of DNS state:
```json
{ "error": "Domain evil.com is not allowed; must end with .mark.vibnai.com",
"hint": "Use my-api.mark.vibnai.com" }
```
This is enforced by `isDomainUnderWorkspace()` in
[`lib/naming.ts`](./vibn-frontend/lib/naming.ts).
### 6.3 Custom (external) domains
Not exposed to AI agents. A human can still add them through Coolify
directly or through a future human-gated UI.
---
## 7. Safety model
### 7.1 Tenant enforcement
Every resource-returning helper in `lib/coolify.ts` runs through
`ensureResourceInProject()`. It:
1. Trusts an explicit `project_uuid` on the resource if present, else
2. Fetches the project's environment ids via `GET /projects/{uuid}` and
verifies the resource's `environment_id` is in that set.
A token for `mark` that tries to read an app in `justine`'s project returns:
```json
{ "error": "Application <uuid> does not belong to project <mark-project-uuid>" }
```
with HTTP 403. Cross-workspace enumeration and access are not just
discouraged — they fail at the helper level.
### 7.2 Destructive operations
Every delete endpoint requires `?confirm=<exact-resource-name>`:
```
DELETE /apps/{uuid} → 409 "confirmation required"
DELETE /apps/{uuid}?confirm=wrong → 409 "confirmation required"
DELETE /apps/{uuid}?confirm=my-api → 200 deleted
```
This means an agent hallucinating a delete call cannot cost you the
resource — it must first know the exact name, which implies it just listed
or just created it.
**Volumes are kept by default** on delete. To also remove volumes, pass
`?volumes=delete` (apps/dbs) — this is opt-in, per-call, never the default.
### 7.3 Creation guardrails
- Apps can only be created from repos in the workspace's Gitea org.
- Auth providers can only be created from the allowlist (see §3.5).
- Database flavors are restricted to the 8 Coolify supports.
- Env var keys must match `/^[A-Z_][A-Z0-9_]*$/` (no shell-escape tricks).
### 7.4 Secrets handling
- `VIBN_API_KEY` is only shown **once** on mint. Server keeps a sha256 hash.
- Gitea bot PATs are **encrypted at rest** (AES-256-GCM with
`VIBN_SECRETS_KEY`).
- The SSH private key is held by Coolify, not by Vibn; the public key is
pushed to the Gitea bot user's key list. Rotating is a re-provision.
- Agent prompts and Cursor rules include a "treat VIBN_API_KEY like a
password — never print or commit it" directive.
---
## 8. Worked examples
### 8.1 "Build me a Next.js app with a Postgres and Pocketbase auth"
From the agent's side, using MCP:
```json
// 1. Ensure a repo exists in the workspace org (standard Gitea API,
// using the bot PAT from gitea.credentials).
POST https://git.vibnai.com/api/v1/orgs/vibn-mark/repos
{ "name": "my-site", "private": true, "auto_init": true }
// 2. Create the Coolify app. Auto-domain my-site.mark.vibnai.com.
{ "tool": "apps.create",
"params": { "repo": "my-site", "ports": "3000", "instantDeploy": false } }
// 3. Provision a Postgres.
{ "tool": "databases.create",
"params": { "type": "postgresql", "name": "app-db" } }
// → returns { internalUrl: "postgres://…@<uuid>:5432/postgres" }
// 4. Wire the db URL into the app as an env var.
{ "tool": "apps.envs.upsert",
"params": { "uuid": "<app-uuid>", "key": "DATABASE_URL",
"value": "<internalUrl>" } }
// 5. Deploy Pocketbase as the auth layer.
{ "tool": "auth.create",
"params": { "provider": "pocketbase", "name": "auth" } }
// 6. First real deploy.
{ "tool": "apps.deploy", "params": { "uuid": "<app-uuid>" } }
// 7. Poll.
{ "tool": "apps.deployments", "params": { "uuid": "<app-uuid>" } }
// → [{ uuid, status: "finished" | "in_progress" | "failed" | "queued" }]
```
The agent hands the user back `https://my-site.mark.vibnai.com`.
### 8.2 "Add an `api` subdomain to my app"
```json
{ "tool": "apps.domains.set",
"params": {
"uuid": "<app-uuid>",
"domains": ["my-site.mark.vibnai.com", "api.mark.vibnai.com"]
} }
```
Valid — both end with `.mark.vibnai.com`. `evil.com` or `my-site.justine.vibnai.com`
would return 403.
### 8.3 "Delete the whole thing"
Agent must learn the resource names first (or it'll hit the confirm gate):
```json
// Learn the name.
{ "tool": "apps.get", "params": { "uuid": "<app-uuid>" } }
// → { name: "my-site", ... }
// Delete with matching confirm.
{ "tool": "apps.delete",
"params": { "uuid": "<app-uuid>", "confirm": "my-site" } }
```
Wrong confirm returns `409 "Confirmation required"`.
---
## 9. Error handling reference
| Status | Meaning | What the agent should do |
|---|---|---|
| 400 | Bad request body (invalid JSON, missing required field, invalid type). | Fix the body, retry. |
| 401 | No / bad bearer token. | Ask the user to mint a fresh key. |
| 403 | **Tenant mismatch** — resource belongs to another workspace, domain outside workspace namespace, or repo not in workspace org. | **Stop.** Do not retry with guessed values. Ask the user. |
| 404 | Resource not found (app/db/service/repo uuid wrong). | Re-list to find the right uuid. |
| 409 | Delete confirmation missing or wrong. | Fetch the resource name first, then retry with `confirm=<name>`. |
| 422 | Coolify validation failure (e.g. malformed domain). | Check the `details` field. |
| 502 | Upstream Coolify/Gitea error. | Retry with backoff. |
| 503 | Workspace not fully provisioned yet. | Call `POST /provision`, then retry. |
---
## 10. Versioning
The MCP descriptor at `GET /api/mcp` reports a semver `version`. Tool names
are append-only within a major version — agents can cache the tool list
safely for the duration of a conversation but should re-fetch on 404.
Current version: **2.1.0**.
- **1.x** — session-cookie-only MCP, no tenant keys.
- **2.0** — `vibn_sk_…` keys, workspace-scoped Gitea bot + Coolify project.
- **2.1** — create/update/delete for apps, 8 database flavors, auth
provider allowlist, domain policy enforcement, confirm-gated deletes.
---
## 11. Where to look in the code
- `lib/auth/workspace-auth.ts` — `requireWorkspacePrincipal`, the gate.
- `lib/auth/secret-box.ts` — AES-256-GCM encryption of Gitea PATs.
- `lib/workspaces.ts` — `ensureWorkspaceProvisioned` (the idempotent setup).
- `lib/gitea.ts` — Gitea client (orgs, users, PATs, SSH keys).
- `lib/coolify.ts` — Coolify client, tenant helpers, all resource CRUD.
- `lib/naming.ts` — domain policy, slugify, SSH URL templates.
- `lib/ssh-keys.ts` — ed25519 keypair generation + OpenSSH formatting.
- `app/api/workspaces/[slug]/…` — REST surface.
- `app/api/mcp/route.ts` — MCP dispatcher and tool implementations.
- `components/workspace/WorkspaceKeysPanel.tsx` — settings UI.

667
AI_CAPABILITIES_ROADMAP.md Normal file
View File

@@ -0,0 +1,667 @@
# Vibn AI Capability Roadmap
> The ordered plan for closing the gap between what the Vibn agent can do
> today and what it needs to do for a real customer to ship, operate, and
> scale a SaaS through it.
>
> **Companion to:** [`AI_CAPABILITIES.md`](./AI_CAPABILITIES.md) (current state).
>
> **Prioritization framing:**
> 1. Does it unblock *shipping a real product* (not a demo)?
> 2. Does it unblock *surviving past the first paying customer*?
> 3. Does it only matter once usage scales?
>
> Tier 1 = (1). Tier 2 = (2). Tier 3 = (3). Tier 4 = revisit when demanded.
>
> **Sequencing rule:** complete Tier 1 before any Tier 2 item. The trap
> is polishing safety rails (audit, scopes, quotas) before the product is
> actually shippable.
---
## 0. Substrate & constraints
Vibn runs on a two-cloud substrate, constrained to Canadian data residency:
| Layer | Provider | Region | Purpose |
|---|---|---|---|
| **App hosting** | Coolify (self-managed) | Montreal VPS | All app / database / auth containers. Current state. |
| **Managed services** | **Google Cloud** | `northamerica-northeast1` (Montreal) | Object storage, cron, queues, logs, backups, monitoring, secrets. |
| **Domain registration** | OpenSRS (Tucows) | Toronto | Wholesale domain API. Canadian company, pre-funded float account. |
| **Authoritative DNS** | Cloud DNS (default) / CIRA D-Zone (strict) | Global anycast / Canadian | Managed DNS for workspace-owned domains. |
| **Transactional email** | Amazon SES | `ca-central-1` (Montreal) | No GCP equivalent; AWS's Canadian region keeps data in-country. |
**Absolute rule: no customer data leaves Canada.** Every workspace-owned
resource (storage bucket, database, log bucket, task queue, scheduler
job, email message body) must be pinned to a Canadian region.
### Why mix clouds?
- **Coolify stays** because we already built the workspace-scoped
provisioning around it (Phase 4). Migrating apps to Cloud Run is a
rewrite we don't need.
- **GCP-CA** fills every managed-service gap Coolify has. Cheaper and
more reliable than self-hosting MinIO/Loki/scheduler.
- **AWS SES for email** because GCP has no first-party transactional
email service and SES `ca-central-1` is the only credible
Canadian-resident managed option.
- **OpenSRS for domains** because it's the wholesale API behind most
Canadian registrars, and we already have the deposit.
### Compliance upgrade path (Tier 4 territory)
For regulated customers (healthcare, financial, public sector):
- **Assured Workloads for Canada** on GCP — enforces Canadian personnel
access + data residency contractually.
- **CIRA D-Zone** instead of Cloud DNS — first-party Canadian managed DNS.
- Keep the SES and OpenSRS pieces as-is (already Canadian-resident).
Document the caveat on a public trust page. Build the Assured-Workloads
variant when a real customer asks.
---
## Current state (Phase 4 + P5.1 verified, Apr 2026)
- Workspace tenancy: Gitea org + Coolify project + SSH deploy key per
workspace.
- Agent can: create repos, create apps, provision 8 database flavors,
deploy 8 vetted auth providers, manage env vars, deploy + poll,
update, delete (with `?confirm=<name>`), set domains under
`*.{slug}.vibnai.com`.
- Control-plane MCP: 24 tools + full REST surface at `/api/mcp`.
API-key scoped per workspace.
- **P5.1 custom apex domains** — OpenSRS + Cloud DNS + Coolify
lifecycle (search / register / attach / inspect) shipped and
verified end-to-end against PROD GCP + OpenSRS sandbox + PROD
Coolify on `v4.0.0-beta.473` (2026-04-22). All 5 sub-systems green
in `smoke-attach-e2e.ts`: register → zone → A records → registrar
NS update → Coolify `fqdn` patch → cleanup. Required a server-side
config fix on `coolify-server-mtl` (proxy.type=TRAEFIK,
is_build_server=false) so `Server::isProxyShouldRun()` returns
true and the controller maps `domains``fqdn` — see
[`AI_CAPABILITIES.md`](./AI_CAPABILITIES.md) § 3.6 for the gory details.
- **Agent-runner stdio MCP bridge** — `vibn-agent-runner` now exposes
its full in-house toolkit (28 tools) outward over 5 stdio MCP
servers so external clients (Cursor, Claude Desktop, Goose) can
drive the same Coolify / Gitea / workspace / memory / search /
sub-agent surface as the internal Coder/PM/Marketing agents, with
shared protected-repo + protected-app guardrails. Every tool now
has a pure `*-api.ts` module, a registry wrapper for the in-process
loop, and an MCP server wrapper — single source of truth, verified
by `scripts/smoke-mcp.js`.
- Enforced: tenant isolation, domain policy, delete confirms,
secrets-at-rest encryption, protected-repo / protected-app guards.
See [`AI_CAPABILITIES.md`](./AI_CAPABILITIES.md) (§ 3.6 for P5.1,
§ 3.7 for the stdio MCP bridge) for the complete current surface.
---
## Tier 1 — Blocks shipping a real product
Without these, anything the agent builds is *demo-shaped*. Ship these
next, in the recommended sequence below.
### P5.1 · Custom apex domains via OpenSRS
**Goal:** agent buys `mysaas.com` on the user's behalf and attaches it
to a Coolify app with automatic TLS.
**Why now:** you already opened an OpenSRS reseller account with a $100
float. Unlocks real branding, DKIM for email (P5.2 depends on this),
and gives you a revenue line (markup on domains).
**Surface:**
| Tool / endpoint | Purpose |
|---|---|
| `domains.search` | Live availability + suggestions via OpenSRS `lookup`. |
| `domains.check_price` | Per-TLD price from OpenSRS + markup. |
| `domains.register` | Debits workspace float, registers via OpenSRS. |
| `domains.list` | Workspace's owned domains. |
| `domains.renew` / `domains.transfer` | Lifecycle. |
| `domains.{name}.attach` | Attach to a Coolify app: DNS records + Coolify `fqdn` + Let's Encrypt. |
| `domains.{name}.detach` | Free a domain from an app, keep registration. |
| `domains.{name}.attach_status` | Polls DNS propagation + cert issuance (async). |
**Infra:**
- **OpenSRS client** (their XML/SOAP or REST API).
- **Cloud DNS** for zone management (default). CIRA D-Zone available as a
workspace-level preference for strict-residency customers.
- **Workspace float ledger** (`vibn_workspace_billing_float`) — a
prepaid balance in CAD, debited on register/renew. Reconciled nightly
against the OpenSRS master deposit.
- `VIBN_OPENSRS_DEPOSIT_ACCOUNT` as the master float handle.
**New columns** on `vibn_workspaces`:
- `preferred_dns_provider TEXT DEFAULT 'cloud_dns'`
- `cloud_dns_zone_name TEXT` ← GCP managed zone for this workspace.
**Risks:**
- DNS propagation is human-scale (minuteshours). Agents need the
async `attach_status` polling loop, not a sync call.
- Cert issuance via Let's Encrypt is rate-limited (50/week per domain).
Abuse-prevent with per-workspace rate caps.
**Estimate:** **2 weeks.**
---
### P5.2 · Transactional email (AWS SES `ca-central-1`)
**Goal:** auth providers can send password-reset emails; agents can
`email.send` from `noreply@mysaas.com`.
**Why now:** every auth provider on the allowlist is broken without
SMTP. Also pairs with P5.1 — per-workspace sender domains need DKIM on
domains you own.
**Why SES ca-central-1 specifically:** GCP has no first-party
transactional email service. All mainstream providers (Postmark,
Resend, Mailgun, SendGrid) are US-primary. SES's Montreal region is the
only credible managed option that keeps message bodies in Canada.
**Two-phase rollout:**
**Phase A — shared-sender MVP (1 week):**
- One SES-verified sender domain `mail.vibnai.com`.
- Every workspace can send from `noreply@mail.vibnai.com` out of the box.
- `email.send` tool + injected `SMTP_*` env vars.
- Bounce / complaint webhooks routed via SNS → a Cloud Run service
that writes per-workspace notifications.
**Phase B — per-workspace sender domains (1 week, depends on P5.1):**
- `email.verify_sender_domain` creates the SPF/DKIM/DMARC records via
the Cloud DNS / CIRA D-Zone client on a workspace-owned domain.
- Polls SES verification; flips `verified=true` when done.
- Workspace can now `email.send from: founder@mysaas.com`.
**Surface:**
| Tool | Purpose |
|---|---|
| `email.send` | Single message; returns SES `message_id`. |
| `email.send_batch` | Up to 100 at a time. |
| `email.list_messages` | Recent sent mail + delivery state (from SES + our log). |
| `email.verify_sender_domain` | Kick off DKIM for a workspace-owned domain. |
| `email.sender_status` | Poll verification state. |
| `email.webhooks.list` | Recent bounces/complaints. |
**Infra:**
- SES identity per workspace-owned sender domain.
- SNS topic → Cloud Run webhook receiver (in `northamerica-northeast1`)
for bounce/complaint ingestion.
- Rate limits: start in SES sandbox (200/day), request production limits
after first real customer.
**Estimate:** **2 weeks total** (1 week Phase A + 1 week Phase B).
---
### P5.3 · Object storage (Google Cloud Storage, `northamerica-northeast1`)
**Goal:** any SaaS the agent builds can take user uploads — avatars,
attachments, exports, images — without the user pasting in third-party
credentials.
**Why now:** "can users upload a file?" is the #1 post-demo question.
Blocks ~half of realistic SaaS ideas.
**GCP collapses this item.** No MinIO container to babysit; GCS provides
managed bucket + signed URLs + lifecycle policies + encryption out of
the box.
**Surface:**
| Tool | Purpose |
|---|---|
| `storage.buckets.list` | Buckets in this workspace (filtered by `workspace={slug}` label). |
| `storage.buckets.create` | New bucket. Optional `public_read`. Enforced region: `northamerica-northeast1`. |
| `storage.buckets.delete` | Destroy bucket. `confirm` gate. |
| `storage.presign_upload` | PUT URL, TTL, content-type constraint. |
| `storage.presign_download` | GET URL, TTL. |
| `storage.list_objects` | Pagination + prefix filter. |
| `storage.delete_object` | Single object. |
| `storage.set_lifecycle` | TTL delete, multipart cleanup, archive tiering. |
**Provisioning additions:**
- Default bucket `vibn-ws-{slug}` created on workspace provision.
- Uniform bucket-level access enabled by default.
- Per-workspace GCP service account `vibn-ws-{slug}@...`, scoped to its
own bucket via `roles/storage.objectAdmin`.
- Keyfile stored encrypted (AES-256-GCM, same `VIBN_SECRETS_KEY`) in
`vibn_workspaces.gcp_service_account_key_encrypted`.
**New columns** on `vibn_workspaces`:
- `gcs_bucket_name TEXT`
- `gcp_service_account_email TEXT`
- `gcp_service_account_key_encrypted BYTEA`
**Env injection:**
- `STORAGE_ENDPOINT=https://storage.googleapis.com`
- `STORAGE_BUCKET={workspace-bucket-name}`
- `STORAGE_ACCESS_KEY`, `STORAGE_SECRET_KEY` (S3-compatible via GCS HMAC keys)
— auto-injected on app creation so agent code uses standard S3 SDKs.
**Estimate:** **3 days.**
---
### P5.4 · Workers, cron, and queues (Cloud Tasks + Cloud Scheduler + Cloud Run Jobs)
**Goal:** agents can declare async workers, scheduled jobs, and queued
tasks. Anything that isn't a single `ports: 3000` web container.
**Why now:** webhooks, retries, nightly cleanup, image processing,
email sending — every real SaaS needs a non-web process. Current
workaround (second Coolify app) is brittle and manual.
**Hybrid approach — Coolify for compute, GCP for orchestration:**
Option evaluated and chosen:
- **Cloud Scheduler** (`northamerica-northeast1`) for cron: fires
HTTP webhooks into the app at the scheduled time.
- **Cloud Tasks** (`northamerica-northeast1`) for queue: agent code
calls `enqueue(task)`, Cloud Tasks dispatches to the app's worker
endpoint with retries, backoff, and at-least-once semantics.
- **Worker process** stays on Coolify as a second app-per-repo with a
different start command, exposed on an internal URL.
Rejected alternative: migrate everything to Cloud Run Jobs. More managed
but splits the "Live" view across two deploy targets and changes the
agent's mental model. Not worth it for MVP.
**Shape — extend `apps.create`:**
```json
{
"repo": "my-site",
"services": {
"web": { "command": "npm start", "ports": "3000" },
"worker": { "command": "npm run worker", "replicas": 2 }
},
"cron": [
{ "name": "nightly-backup", "schedule": "0 3 * * *", "path": "/tasks/backup" },
{ "name": "sync", "schedule": "*/10 * * * *", "path": "/tasks/sync" }
],
"queues": [
{ "name": "emails" },
{ "name": "image-processing" }
]
}
```
Internally creates: two Coolify apps (web + worker), N Cloud Scheduler
jobs labeled `workspace={slug}`, N Cloud Tasks queues.
**Surface additions:**
| Tool | Purpose |
|---|---|
| `apps.services.list` | All processes in an app. |
| `apps.services.update` | Scale replicas, change command. |
| `apps.services.logs` | Per-process logs. |
| `cron.list` | Scheduler jobs in this workspace. |
| `cron.create` / `cron.update` / `cron.delete` | Manage scheduled jobs. |
| `cron.run_now` | Fire a scheduled job immediately (useful for agent testing). |
| `queues.list` | Cloud Tasks queues in this workspace. |
| `queues.create` / `queues.delete` | Manage queues. |
| `queues.enqueue` | (Normally called from app code, but exposed for agent-driven testing.) |
| `queues.pause` / `queues.resume` | Emergency ops. |
**New columns** on `vibn_workspaces`:
- `cloud_scheduler_location TEXT DEFAULT 'northamerica-northeast1'`
- `cloud_tasks_location TEXT DEFAULT 'northamerica-northeast1'`
**Auth to GCP:** per-workspace service account (provisioned in P5.3) is
extended with `roles/cloudscheduler.admin` and `roles/cloudtasks.admin`
*scoped to resources labeled `workspace={slug}`* via IAM conditions.
Agents can only act on their own workspace's jobs/queues.
**Estimate:** **1 week.**
---
### Tier 1 total: ~5 weeks of focused work
After Tier 1 lands, an agent can:
- Buy `mysaas.com`, point it at a Next.js app.
- Deploy Authentik with working password-reset emails from `noreply@mysaas.com`.
- Offer user uploads (avatars, attachments).
- Run `0 3 * * *` nightly cleanup cron.
- Process Stripe webhooks idempotently via a retry queue.
That's a shippable SaaS. Everything after this is about *keeping* it
shipped.
---
## Tier 2 — Blocks surviving past the first real customer
Once users exist, these prevent silent failures.
### P6.1 · Database backups + restore (GCS + wal-g)
**Goal:** nightly backups, on-demand backups, one-call restore. No
"agent ran `DROP TABLE` in a migration" permanent data loss.
**Why:** scariest item on this list. Failure mode is irrecoverable.
**Shape:**
- `databases.{uuid}.backup` — on-demand `pg_dump` / `mongodump` to the
workspace's GCS bucket (depends on P5.3).
- `databases.{uuid}.backups.list` — lists backups with timestamp + size.
- `databases.{uuid}.backups.restore``confirm`-gated restore from a
specific backup uuid.
- Per-database backup policy: daily / hourly / off, retention days.
- Default: every AI-created database gets daily backups + 7-day
retention on.
**Infra:**
- Cron jobs run via P5.4's Cloud Scheduler primitive.
- Stored at `gs://vibn-ws-{slug}/backups/{db-uuid}/{iso-timestamp}.sql.gz`.
- Lifecycle rules auto-delete backups older than retention.
- Object-level retention lock available for "immutable backups" on
request (Tier 3 feature).
**Upgrade path:**
- **Postgres point-in-time recovery** via `wal-g` shipping WAL segments
to the same GCS bucket. Adds RPO < 5 min.
- **ClickHouse**: `clickhouse-backup` to GCS.
- **MongoDB**: `mongodump` incremental.
**Estimate:** **3 days** for MVP (pg_dump + schedule + restore).
**+1 week** for wal-g PITR if/when a customer asks.
---
### P6.2 · Runtime log streaming (Cloud Logging)
**Goal:** agent can see "is the app erroring at 10 req/s right now?",
not just "did the build succeed."
**Why:** today deploy logs are surfaced but container stdout/stderr is
not. An agent that "fixed a bug" can't verify the fix without a human
SSH-ing into Coolify.
**GCP collapses this item** — ship container logs to Cloud Logging with
a workspace label, query via the logs API.
**Shape:**
- Fluent-bit sidecar (or Coolify label) ships container stdout/stderr
to Cloud Logging in `northamerica-northeast1` with labels
`workspace={slug}`, `app={app-uuid}`, `service={web|worker|...}`.
- Per-workspace log bucket for retention isolation.
**Surface:**
| Tool | Purpose |
|---|---|
| `apps.logs` | Last N lines across replicas. Filter by timestamp, severity. |
| `apps.logs.tail` | SSE stream of new log lines. |
| `apps.logs.search` | Thin wrapper on Cloud Logging's query API — grep, severity filter, time window. |
| `apps.services.logs` | Same, scoped to a single service. |
**Retention:** default 30 days in the workspace log bucket; exportable
to the workspace's GCS bucket on request for long-term storage.
**Estimate:** **3 days** (fluent-bit config + thin API wrapper).
---
### P6.3 · Scoped API keys
**Goal:** invite a CI bot or teammate without giving root on the
workspace.
**Why:** solo-builder flow survives without it. Breaks the moment a
second principal enters.
**Shape:**
- Keys gain `scopes: string[]` and optional `expires_at`.
- Scope tokens: `apps:read`, `apps:write`, `apps:delete`,
`databases:*`, `auth:*`, `domains:read`, `domains:write`,
`storage:*`, `email:send`, `cron:*`, `queues:*`, `deploy:*`.
- Per-scope rate limits optional (Tier 3; API shape supports it from
day one).
**Surface changes:**
| Tool | Change |
|---|---|
| `keys.create` | Accepts `scopes`, `expires_at`. |
| `keys.list` | Returns scopes per key. |
| `keys.rotate` | Mints new token, preserves scope set. |
Every MCP/REST handler gets a scope requirement checked in the
principal resolver.
**Estimate:** **1 week.**
---
### Tier 2 total: ~2 weeks
After Tier 2 lands, a SaaS shipped on Vibn can survive without you
dropping into a psql REPL at 3am.
---
## Tier 3 — Matters once usage scales
Don't build these until at least one real customer is hitting them.
Building them pre-market is the classic infra-overinvestment trap.
### P7.1 · Per-workspace quotas + cost caps
Max apps, max dbs, max GCS GB, max egress, max SES messages/month, max
OpenSRS spend/month. Per-plan configurable. Hallucinating agents can't
OOM the cluster or burn your SES reputation.
### P7.2 · Audit log
Append-only per-workspace log of (principal, action, params, timestamp,
result). Cloud Logging with a dedicated `audit-logs` log-bucket, 400-day
retention. Read API for the settings panel. Needed for any
SOC-2-adjacent buyer.
### P7.3 · Preview-per-PR environments
Open a PR → `pr-42.mark.vibnai.com` deploys automatically with a
throw-away database. Teardown on PR close/merge. Unblocks multi-agent
flows.
### P7.4 · Atomic multi-resource operations (`stacks`)
`POST /stacks` takes a full app + db + auth + domain + cron spec;
creates atomically, rolls back on failure. Agent ergonomics win once
demo flow is routine.
### P7.5 · Billing integration
Stripe subscriptions for Vibn itself (workspace billing), plus
per-workspace float top-ups, plus reconciliation to the OpenSRS master
deposit and GCP / SES cost allocation. Only needed when you charge
real dollars.
### P7.6 · Assured Workloads for Canada
GCP policy-enforced Canadian residency + Canadian personnel access.
For regulated customers (healthcare, financial, public sector). Priced
accordingly; ship only when a real customer needs it.
### P7.7 · CIRA D-Zone as a workspace DNS option
Swap Cloud DNS → CIRA D-Zone for a workspace with strict residency
requirements. API-compatible wrapper so nothing agent-facing changes.
---
## Tier 4 — Revisit when demanded
Items to explicitly *not* build until a concrete customer asks.
- **Multi-region** — single-region Canada is fine for B2B SaaS makers
(our early market).
- **Cloud Run migration** — would rewrite most of Coolify-based
capabilities. Revisit if/when Coolify becomes a bottleneck.
- **Managed search / vector DB as first-class types** — agents can
deploy Meilisearch / Typesense / pgvector-Postgres as regular services.
- **mTLS / custom CAs / BYO-cert upload** — enterprise creep.
- **MCP protocol polish** (streaming, resources, prompts, per-tool
schemas) — current JSON-over-HTTP works. Revisit on real friction.
- **Per-app basic auth, IP allowlists, WAF** — Traefik middleware
manually until someone asks.
---
## Roadmap at a glance
| Phase | Items | Est. | Unblocks |
|---|---|---|---|
| **P5 — Real SaaS primitives** | Domains, email, storage, workers/cron/queues | ~5 wk | Shipping a real product |
| **P6 — Keep-it-running** | Backups, runtime logs, scoped keys | ~2 wk | First real customer survives |
| **P7 — Scale** | Quotas, audit, previews, stacks, billing, Assured Workloads, D-Zone | demand-driven | Platform grows past 1st cohort |
| **P8+** | Tier 4 items | never, unless pulled by customer | — |
**Total to "agent ships a SaaS a founder would pay $29/mo for":**
P5 + P6 = **~7 weeks** (was ~11 before GCP-CA; ~40% compression from
managed-service leverage).
---
## Dependency graph
```
P5.1 Domains ──┬──→ P5.2 Email Phase B (per-domain DKIM)
├──→ P7.7 CIRA D-Zone swap
└──→ (future: customer-owned sub-domain routing)
P5.3 Storage ──┬──→ P6.1 Database backups (backups need a bucket)
└──→ P7.2 Audit log export
P5.4 Workers/cron/queues ──┬──→ P6.1 Database backups (run via scheduler)
└──→ most real SaaS patterns
P6.2 Runtime logs — independent, can land anytime
P6.3 Scoped keys — independent, can land anytime
P7.6 Assured Workloads — wraps everything; build once demanded
```
**Parallelizable (three people):**
- Track A: P5.1 → P5.2
- Track B: P5.3 → P6.1
- Track C: P5.4 → P6.2
Track C finishes earliest; use that slack to land P6.3.
---
## Per-workspace GCP provisioning (shared across P5.3, P5.4, P6.1, P6.2)
`ensureWorkspaceProvisioned()` gains a GCP-CA block that runs once per
workspace, idempotently. All resources are created in
`northamerica-northeast1`.
| Resource | Name pattern | Notes |
|---|---|---|
| GCS bucket | `vibn-ws-{slug}` | Uniform bucket-level access. Lifecycle policies off by default. |
| Cloud DNS managed zone | `vibn-ws-{slug}-zone` | Created per workspace-owned domain in P5.1, not on workspace provision. |
| Cloud Logging log bucket | `vibn-ws-{slug}-logs` | 30-day retention default. |
| Cloud Tasks location | `northamerica-northeast1` | Queues created per-app in P5.4, not here. |
| GCP service account | `vibn-ws-{slug}@{project}.iam` | Single SA per workspace, narrow roles. |
| Service account key | stored encrypted in `vibn_workspaces` | AES-256-GCM, same `VIBN_SECRETS_KEY`. |
**New columns** on `vibn_workspaces` (cumulative across P5.1-P6.2):
```sql
-- P5.1
preferred_dns_provider TEXT DEFAULT 'cloud_dns',
cloud_dns_zone_name TEXT,
-- P5.3
gcs_bucket_name TEXT,
gcp_service_account_email TEXT,
gcp_service_account_key_encrypted BYTEA,
-- P5.4
cloud_scheduler_location TEXT DEFAULT 'northamerica-northeast1',
cloud_tasks_location TEXT DEFAULT 'northamerica-northeast1',
-- P6.2
cloud_logging_bucket_name TEXT
```
Three migration steps, one per phase. All guarded by the existing
admin-gated `POST /api/admin/migrate` endpoint.
---
## Non-goals (stated explicitly so they don't creep in)
- **A general-purpose PaaS.** Vibn is an agent-driven SaaS builder, not
a Heroku / Fly clone. Every capability must answer "what does an agent
need to build a SaaS?" — not "what does a dev need to deploy a
container?"
- **Support for non-allowlisted auth providers, databases, services.**
The curated surface is the feature. "Any Coolify service" would blow
up the tenant-safety model and dilute agent decision-making.
- **A consumer-facing OpenSRS UI.** OpenSRS is plumbing for the agent.
Humans should never see an OpenSRS checkout screen — only
`domains.register { name: "mysaas.com" }` from the agent.
- **Multi-cloud abstraction layer.** One Coolify cluster + GCP-CA +
SES-CA + OpenSRS is the contract. If customers want to bring their
own, that's Tier 4.
- **Anything that moves customer data out of Canada.** Even for
performance. If a managed service only has US regions, we self-host
in Canada or we don't offer it.
---
## Recommended execution order (opinionated)
Given dependencies and quick-wins-first philosophy:
**Week 1:**
- P5.3 Storage (GCS wrap, 3 days) → proves the GCP-CA provisioning pattern.
- P5.4 Workers/cron/queues (starts in parallel; depends on P5.3 only for
the service account).
**Week 2:**
- P5.4 completes.
- P5.1 Domains starts (OpenSRS client + Cloud DNS wrapper).
**Week 3:**
- P5.1 completes.
- P5.2 Email Phase A (shared-sender MVP) starts.
**Week 4:**
- P5.2 Phase A completes.
- P5.2 Phase B (per-domain DKIM) starts, now that P5.1 is available.
**Week 5:**
- P5.2 Phase B completes. **P5 / Tier 1 done.**
- P6.1 Database backups starts (3 days).
- P6.2 Runtime logs starts in parallel (3 days).
**Week 6:**
- P6.3 Scoped keys (1 week).
**Week 7:**
- Slack week — hardening, docs (`AI_CAPABILITIES.md` refresh), first
real customer onboarding.
**End state at week 7:** agent can take a founder from "I have an idea"
to "I have `mysaas.com` live, with auth, with user uploads, with email,
with backups, with visible error logs, and a CI bot can deploy it
without root access."
That's the Vibn product.
---
## How to use this doc
- When someone proposes a feature, find its tier. If it's Tier 3 or 4
and we're still shipping Tier 1, say no.
- Before starting a Tier 1 item, re-read its section and make sure
prerequisites shipped. Email-per-domain before domains is wasted code.
- [`AI_CAPABILITIES.md`](./AI_CAPABILITIES.md) is the canonical
reference of *what exists today*. This doc is the canonical reference
of *what comes next*. When an item ships, move it from here to that
doc and delete its section here.
- When a user request implies Canadian residency (they say "PIPEDA",
"healthcare", "public sector", or "our data can't leave Canada"), pin
the answer to this doc's §0 Substrate & constraints. Don't improvise.

138
README.md
View File

@@ -1,138 +0,0 @@
# Product OS - Master AI
> A Product-Centric IDE built on Eclipse Theia, optimized for Google Cloud and powered by Gemini AI.
## Overview
Product OS is NOT a general-purpose IDE. It's a **Product Operating System** designed to unify:
- 💻 **Code** - Build and deploy Cloud Run services
- 📢 **Marketing** - Automate campaigns and content
- 📊 **Analytics** - Product intelligence and insights
- 🚀 **Growth** - Optimize onboarding and conversion
- 💬 **Support** - Customer feedback integration
- 🧪 **Experiments** - A/B testing and rollouts
- 🏗️ **Infrastructure** - Production monitoring
All powered by AI and optimized for Google Cloud.
## Vision
See [Google_Cloud_Product_OS.md](./Google_Cloud_Product_OS.md) for complete vision and requirements.
## Project Structure
```
master-ai/
├── theia/ # Customized Eclipse Theia IDE
│ ├── packages/core/ # UI shell customization
│ ├── packages/terminal/ # Terminal customization
│ ├── packages/monaco/ # Editor themes
│ └── examples/electron/ # Mac app build
├── Google_Cloud_Product_OS.md # Product vision & requirements
├── UI-DESIGN-GUIDE.md # Design customization guide
└── README.md # This file
```
## Getting Started
### Prerequisites
- Node.js 20.x (use nvm)
- Python 3
- Git
### Installation
```bash
# Clone this repo
git clone https://github.com/MawkOne/master-ai.git
cd master-ai/theia
# Use Node 20
nvm use 20
# Install dependencies
npm install
# Build Electron app
npm run build:electron
# Launch Product OS
npm run start:electron
```
## Customization
See [UI-DESIGN-GUIDE.md](./UI-DESIGN-GUIDE.md) for complete customization guide.
### Quick Design Changes
**Change colors:** Edit `theia/packages/monaco/data/monaco-themes/vscode/dark_vs.json`
**Change fonts:** Edit `theia/packages/core/src/browser/style/index.css`
**Change layout:** Edit `theia/packages/core/src/browser/shell/application-shell.ts`
## Development Workflow
```bash
cd theia
# Watch mode (auto-rebuild on changes)
npm run watch:electron
# In another terminal, run the app
npm run start:electron
```
## Building Distribution
```bash
cd theia/examples/electron
# Build Mac app (.app, .dmg)
npm run package:mac
# Output: dist/Product-OS-*.dmg
```
## Roadmap
### Phase 1: Core Simplification ✅
- [x] Clone Theia
- [x] Set up repository
- [ ] Lock dark theme
- [ ] Remove preference menus
- [ ] Simplify UI
### Phase 2: Custom Layout
- [ ] Replace Application Shell structure
- [ ] Create 7 Product OS sections
- [ ] Custom navigation/activity bar
- [ ] Section-specific views
### Phase 3: Google Cloud Integration
- [ ] Pre-configure GCP tools
- [ ] Add Gemini AI integration
- [ ] Remove non-GCP options
### Phase 4: Product OS Features
- [ ] Marketing automation UI
- [ ] Analytics dashboards
- [ ] Growth experiment tools
- [ ] Support integration
## Architecture
Built on [Eclipse Theia](https://github.com/eclipse-theia/theia) - an extensible IDE framework using TypeScript and Electron.
## License
Based on Eclipse Theia:
- Eclipse Public License 2.0
- GNU General Public License, version 2 with the GNU Classpath Exception
## Author
Built by [@MawkOne](https://github.com/MawkOne)

View File

@@ -4,226 +4,206 @@
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 separate Gitea repos per app (frontend repo, API repo, etc.), which fragments that context. The AI has to context-switch across repos, cross-repo dependencies are manual and brittle, and shared code has no clean home.
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 becomes a single repo containing all of its apps (`product`, `website`, `admin`) and shared packages (`ui`, `types`, `config`). The AI operates across the entire project simultaneously. Build orchestration, deployment, and shared code all become coherent automatically.
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 project will have:**
**The structure every user project repo will have:**
```
{project-slug}/
{project-slug}/ ← one Gitea repo per project
apps/
product/ ← the core user-facing app
website/ ← marketing / landing site
admin/ ← internal admin tool
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 component library
types/ ← shared TypeScript types
config/ ← shared eslint, tsconfig
ui/ ← shared React component library
tokens/ ← design tokens (colors, spacing, typography)
types/ ← shared TypeScript types
config/ ← shared eslint, tsconfig
turbo.json
package.json ← workspace root (pnpm workspaces)
package.json pnpm workspace root
.gitignore
README.md
```
This is not a Vercel dependency. Turborepo is MIT-licensed, runs anywhere, and costs nothing. Remote caching is optional and can be self-hosted on Coolify.
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. Project Scaffold Templates
### 1. Scaffold Templates
**What:** Create a set of template files that get written into a new Gitea repo when a project is created.
**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.
**Files to create:** `platform/scripts/templates/turborepo/`
**Where:** `vibn-frontend/lib/scaffold/turborepo/`
- `turbo.json` — pipeline config defining `build`, `dev`, `lint`, `test` tasks and their dependencies
- `package.json`workspace root with pnpm workspaces pointing to `apps/*` and `packages/*`
- `.gitignore` — covering node_modules, dist, .turbo cache
- `apps/product/package.json` — Next.js app skeleton
- `apps/website/package.json` — Astro or Next.js skeleton
- `apps/admin/package.json` — Next.js app skeleton
- `packages/ui/package.json` — shared component library stub
- `packages/types/package.json` — shared types stub
- `packages/config/` — shared `tsconfig.json` and `eslint` base configs
**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`
**Notes:**
- Templates should be stack-agnostic at the shell level — the `turbo.json` pipeline is what matters, inner frameworks can vary
- Stack choices (Next.js vs Astro, etc.) can be parameterised later when we add a project creation wizard
**Status:** Templates were written and are ready. Need to be moved to `vibn-frontend/lib/scaffold/turborepo/`.
---
### 2. Control Plane — Project Data Model Update
### 2. Project Creation Route — Add Scaffold Push
**What:** The current data model stores multiple Gitea repos per project. This changes to one repo per project.
**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:** `platform/backend/control-plane/src/types.ts`
**File to update:** `vibn-frontend/app/api/projects/create/route.ts`
**Changes:**
- Remove `repos: Array<{ gitea_repo, path }>` from `ProjectRecord` (or update it)
- Add `repo: string` — single Gitea repo URL for the project
- Add `apps: Array<{ name: string; path: string; coolify_service_uuid?: string }>` — tracks each app inside the monorepo and its Coolify service
- Add `turbo: { version: string }` — tracks which Turborepo version the project was scaffolded with
**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. Control Plane — New Project Routes
### 3. Project Data Model — Add App Tracking
**What:** Add project management endpoints to the control plane API.
**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.
**File to create:** `platform/backend/control-plane/src/routes/projects.ts`
**Fields to add to the project `data` JSONB:**
**Endpoints:**
```typescript
apps: Array<{
name: string; // "product" | "website" | "admin" | "storybook"
path: string; // "apps/product"
coolifyServiceUuid?: string;
domain?: string;
}>
turboVersion: string; // e.g. "2.3.3"
```
| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/projects` | Create project — scaffold Turborepo repo in Gitea, register in DB |
| `GET` | `/projects/:project_id` | Get project record |
| `GET` | `/projects/:project_id/apps` | List apps within the monorepo |
| `POST` | `/projects/:project_id/apps` | Add a new app to the monorepo |
| `POST` | `/projects/:project_id/deploy` | Trigger Turbo build + Coolify deploy for one or all apps |
**Project creation flow (`POST /projects`):**
1. Validate request (name, tenant_id, optional app selections)
2. Create Gitea repo via Gitea API
3. Scaffold Turborepo structure from templates, push initial commit
4. Register webhook: Gitea repo → control plane `/webhooks/gitea`
5. Create Coolify project
6. Create one Coolify service per app (with correct build filter)
7. Save project record to storage
8. Return project record with repo URL and app list
No schema migration needed — it's JSONB, just include these fields when inserting/updating.
---
### 4. Control Plane — Storage Layer Updates
### 4. Coolify — Per-App Service Provisioning
**What:** Add project storage operations alongside existing runs/tools storage.
**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:** `platform/backend/control-plane/src/storage/memory.ts`
**File to update:** `platform/backend/control-plane/src/storage/firestore.ts`
**File to update:** `platform/backend/control-plane/src/storage/index.ts`
**File to update:** `vibn-frontend/lib/coolify.ts`
**New operations to add:**
- `saveProject(project: ProjectRecord): Promise<void>`
- `getProject(projectId: string): Promise<ProjectRecord | null>`
- `listProjects(tenantId: string): Promise<ProjectRecord[]>`
- `updateProjectApp(projectId: string, app: AppRecord): Promise<void>`
**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. Gitea Integration Service
### 5. Deploy API Route
**What:** New service to abstract all Gitea API calls. Currently there is no Gitea integration in the control plane.
**What:** A new API route that triggers a Coolify deployment for a specific app within a project.
**File to create:** `platform/backend/control-plane/src/gitea.ts`
**File to create:** `vibn-frontend/app/api/projects/[projectId]/deploy/route.ts`
**Responsibilities:**
- Create repo for a project
- Push initial scaffolded files (initial commit)
- Register webhooks
- Read file tree (so AI can understand the project structure)
- Read/write individual files (so AI can make edits)
```
POST /api/projects/{projectId}/deploy
Body: { app_name: "product" | "website" | "admin" | "storybook" }
```
**Config needed in `config.ts`:**
- `giteaUrl` — from `GITEA_URL` env var (e.g. `https://git.vibnai.com`)
- `giteaToken` — from `GITEA_TOKEN` env var (admin token for repo creation)
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. Coolify Integration Service
### 6. AI Chat — Project Context Injection
**What:** New service to abstract all Coolify API calls. Currently the deploy executor calls Coolify but there is no central integration.
**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 create:** `platform/backend/control-plane/src/coolify.ts`
**File to update:** `vibn-frontend/app/api/ai/chat/route.ts`
**Responsibilities:**
- Create a Coolify project
- Create a Coolify application service linked to a Gitea repo
- Set the build command to `turbo run build --filter={app-name}`
- Set the publish directory per app
- Trigger a deployment
- Get deployment status
**Config needed in `config.ts`:**
- `coolifyUrl` — from `COOLIFY_URL` env var
- `coolifyToken` — from `COOLIFY_TOKEN` env var
---
### 7. Deploy Executor — Monorepo Awareness
**What:** The existing deploy executor (`platform/backend/executors/deploy`) currently deploys a single service. It needs to understand the monorepo structure and use `turbo run build --filter` to target the right app.
**File to update:** `platform/backend/executors/deploy/src/index.ts`
**Changes:**
- Accept `app_name` in the input payload (e.g. `"product"`, `"website"`, `"admin"`)
- Build command becomes `turbo run build --filter={app_name}` instead of `npm run build`
- Pass the root of the monorepo as the build context, not an app subdirectory
---
### 8. AI Context — Project-Aware Prompting
**What:** The Gemini chat integration currently has no awareness of which project the user is in. It needs project context so the AI can reason across the whole monorepo.
**File to update:** `platform/backend/control-plane/src/gemini.ts`
**File to update:** `platform/backend/control-plane/src/routes/chat.ts`
**Changes:**
- Add `project_id` to `ChatRequest`
- On chat requests with a `project_id`, fetch the project record and inject:
- Repo structure (app names, package names)
- Recent deployment status per app
- `turbo.json` pipeline config
- Add a new Gemini tool: `scaffold_app` — lets the AI add a new app to the user's monorepo
- Add a new Gemini tool: `deploy_app` — lets the AI trigger a Coolify deploy for a specific app by name
---
### 9. Theia Workspace — Single Repo Mode
**What:** The current Theia docker-compose opens a multi-root workspace across multiple repos. With one repo per project, this simplifies to a single workspace root.
**File to update:** `theia-docker-compose.yml` (and the Coolify service config for Theia)
**Changes:**
- Workspace path points to the cloned monorepo root
- Git remote is the project's single Gitea repo
- Theia extensions should be aware of the `turbo.json` to surface run targets in the UI (future)
---
### 10. Local Dev — Replace start-all.sh with Turbo
**What:** The current `platform/scripts/start-all.sh` manually starts each service with `&`. Once the platform itself is in a Turborepo, this can be replaced with `turbo run dev`.
**Note:** This is a nice-to-have follow-on. The priority is getting user project scaffolding right first. The platform's own internal structure can be migrated to Turborepo in a separate pass.
**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 | Depends On |
|------|------|-----------|
| 1 | Create scaffold templates | Nothing |
| 2 | Add `ProjectRecord` type + storage ops | Step 1 |
| 3 | Build Gitea integration service | Step 2 |
| 4 | Build Coolify integration service | Step 2 |
| 5 | Add project routes to control plane | Steps 2, 3, 4 |
| 6 | Update deploy executor for monorepo | Step 5 |
| 7 | Update AI chat with project context | Step 5 |
| 8 | Update Theia workspace config | Step 5 |
| 9 | Migrate platform itself to Turborepo | All of the above |
| 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 the source control host — same, just one repo per project instead of many
- Coolify as the deployment host — same, just configured with Turbo build filters
- Theia as the IDE — same, just opens one repo instead of multi-root
- The control plane API architecture (Fastify, in-memory/Firestore storage)same, just extended
- Auth model — unchanged
- No Vercel dependency anywhere in this plan
- 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,254 +0,0 @@
# Product OS - UI Shell Design Guide
## 🎨 Design Customization Map
### **Quick Reference: Where to Change What**
| What You Want to Change | File to Edit | Line/Section |
|------------------------|--------------|--------------|
| **Colors** | `packages/monaco/data/monaco-themes/vscode/dark_vs.json` | All colors |
| **Fonts** | `packages/core/src/browser/style/index.css` | Lines 40-62 |
| **Panel Sizes** | `packages/core/src/browser/shell/application-shell.ts` | Lines 2269-2288 |
| **Layout Structure** | `packages/core/src/browser/shell/application-shell.ts` | Lines 188-220 |
| **Borders & Spacing** | `packages/core/src/browser/style/index.css` | Lines 24-76 |
---
## 1. COLOR CUSTOMIZATION
### File: `packages/monaco/data/monaco-themes/vscode/dark_vs.json`
```json
{
"colors": {
"editor.background": "#1E1E1E", // Main editor area
"editor.foreground": "#D4D4D4", // Text color
// SIDEBAR
"sideBar.background": "#252526", // Left/right sidebar background
"sideBarTitle.foreground": "#BBBBBB", // Sidebar titles
// ACTIVITY BAR (left icon strip)
"activityBar.background": "#333333", // Activity bar background
"activityBarBadge.background": "#007ACC", // Badge colors (notifications)
// PANELS (bottom area)
"panel.background": "#1E1E1E", // Bottom panel (terminal, etc)
"panel.border": "#454545", // Panel borders
// TABS
"tab.activeBackground": "#1E1E1E", // Active tab
"tab.inactiveBackground": "#2D2D2D", // Inactive tabs
"tab.activeForeground": "#FFFFFF", // Active tab text
// MENU
"menu.background": "#252526", // Menu background
"menu.foreground": "#CCCCCC", // Menu text
// STATUS BAR (bottom)
"statusBar.background": "#007ACC", // Status bar background
"statusBar.foreground": "#FFFFFF", // Status bar text
// BUTTONS
"button.background": "#0E639C", // Button background
"button.foreground": "#FFFFFF", // Button text
// INPUTS
"input.background": "#3C3C3C", // Input fields
"input.foreground": "#CCCCCC", // Input text
"input.border": "#454545", // Input borders
}
}
```
---
## 2. TYPOGRAPHY
### File: `packages/core/src/browser/style/index.css`
```css
:root {
/* UI Font (menus, buttons, labels) */
--theia-ui-font-size1: 13px; /* Base size */
--theia-ui-font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
/* Code Font (editor, terminal) */
--theia-code-font-size: 13px;
--theia-code-font-family: Menlo, Monaco, Consolas, "Droid Sans Mono";
/* Change to your preferred fonts: */
/* --theia-ui-font-family: "Inter", "SF Pro", sans-serif; */
/* --theia-code-font-family: "JetBrains Mono", "Fira Code", monospace; */
}
```
---
## 3. SPACING & LAYOUT
### File: `packages/core/src/browser/style/index.css`
```css
:root {
--theia-ui-padding: 6px; /* General padding */
--theia-border-width: 1px; /* Border thickness */
--theia-panel-border-width: 1px; /* Panel borders */
--theia-icon-size: 16px; /* Icon size */
}
```
### File: `packages/core/src/browser/shell/application-shell.ts`
```typescript
// Panel size ratios (line ~2269)
export const DEFAULT_OPTIONS = {
bottomPanel: {
initialSizeRatio: 0.382 // 38.2% of window height
},
leftPanel: {
initialSizeRatio: 0.191 // 19.1% of window width
},
rightPanel: {
initialSizeRatio: 0.191 // 19.1% of window width
}
}
```
---
## 4. QUICK DESIGN CHANGES - Copy & Paste
### A. Modern Dark Blue Theme
**File:** `packages/monaco/data/monaco-themes/vscode/dark_vs.json`
```json
{
"colors": {
"editor.background": "#0D1117",
"sideBar.background": "#161B22",
"activityBar.background": "#010409",
"activityBarBadge.background": "#1F6FEB",
"statusBar.background": "#1F6FEB",
"panel.background": "#0D1117",
"tab.activeBackground": "#0D1117",
"tab.inactiveBackground": "#161B22"
}
}
```
### B. Increase Font Sizes
**File:** `packages/core/src/browser/style/index.css`
```css
:root {
--theia-ui-font-size1: 15px; /* Was 13px */
--theia-code-font-size: 15px; /* Was 13px */
}
```
### C. Wider Sidebars
**File:** `packages/core/src/browser/shell/application-shell.ts`
```typescript
leftPanel: {
initialSizeRatio: 0.25 // 25% instead of 19.1%
},
rightPanel: {
initialSizeRatio: 0.25 // 25% instead of 19.1%
}
```
### D. Smaller Bottom Panel
```typescript
bottomPanel: {
initialSizeRatio: 0.30 // 30% instead of 38.2%
}
```
---
## 5. PRODUCT OS BRANDING
### Custom Colors for Product OS
```json
{
"colors": {
// Google Cloud inspired
"activityBarBadge.background": "#4285F4", // Google Blue
"statusBar.background": "#34A853", // Google Green
"button.background": "#4285F4",
// Dark background
"editor.background": "#121212",
"sideBar.background": "#1E1E1E",
"activityBar.background": "#0A0A0A",
}
}
```
---
## 🚀 Testing Your Changes
### Development Workflow:
1. **Edit design files** (CSS, JSON, TypeScript)
2. **Rebuild:**
```bash
cd /Users/markhenderson/Cursor\ Projects/master-ai/theia
npm run build:electron
```
3. **Test:**
```bash
npm run start:electron
```
4. **See changes immediately!**
### Watch Mode (auto-rebuild):
```bash
npm run watch:electron
```
---
## 📁 File Structure Summary
```
theia/
├── packages/
│ ├── core/src/browser/
│ │ ├── style/
│ │ │ └── index.css ← Typography, spacing, borders
│ │ └── shell/
│ │ └── application-shell.ts ← Panel sizes, layout structure
│ └── monaco/data/monaco-themes/vscode/
│ └── dark_vs.json ← All colors
└── examples/electron/
├── package.json ← App name, branding
└── resources/ ← Icon, splash screen
```
---
## 🎯 Next Steps for Product OS Design
1. **Lock dark theme** (remove light theme option)
2. **Custom color palette** (Google Cloud colors)
3. **Simplify UI** (hide developer-focused elements)
4. **Custom panels** (replace File Explorer with Product sections)
5. **Branding** (logo, splash screen, app icon)
---
## 💡 Pro Tips
- **CSS Variables** are your friend - change one variable, affects everywhere
- **Hot reload:** Changes to CSS show immediately with watch mode
- **Theme files:** JSON files control all colors - easy to swap themes
- **Test often:** Build and test frequently to see visual changes

501
VIBN_PRD.md Normal file
View File

@@ -0,0 +1,501 @@
# 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

View File

@@ -1,349 +0,0 @@
# Vibn Architecture
## Overview
Every project gets a persistent AI brain (the Master Orchestrator) that runs 24/7 on shared infrastructure. It manages specialist agents that handle Coding, Marketing, Support, Monitoring, and Debugging autonomously. Users interact through three channels: mobile app, browser dashboard, or the full Theia IDE.
---
## System Layers
```
┌─────────────────────────────────────────────────────────────────┐
│ User Interfaces │
│ │
│ Mobile App Browser Dashboard Theia IDE │
│ (chat + status) (chat + dashboards) (full IDE + │
│ vibe coding) │
│ REST/WebSocket REST/WebSocket WebSocket │
└────────┬───────────────────┬───────────────────────┬────────────┘
│ │ │
└───────────────────┼───────────────────────┘
┌────────────────────────────┴────────────────────────────────────┐
│ API Gateway │
│ api.vibnai.com │
│ Auth, routing, WebSocket upgrade, rate limiting │
└────────────────────────────┬────────────────────────────────────┘
┌────────────────────────────┴────────────────────────────────────┐
│ Master Orchestrator │
│ (Hot Tier — always running) │
│ │
│ - Full project context (code, docs, marketing, support) │
│ - Routes work to specialist agents │
│ - Receives webhooks (Gitea push, Coolify deploy, support tix) │
│ - Manages task queue across all agents │
│ - Calls Gemini API │
│ - Persists conversation history per project │
│ │
│ Specialist Agents (run as needed): │
│ ┌──────────┬───────────┬──────────┬────────────┬───────────┐ │
│ │ Coder │ Marketing │ Support │ Monitor │ Debugger │ │
│ │ │ │ │ │ │ │
│ │ Writes │ Updates │ Answers │ Watches │ Reads │ │
│ │ code, │ landing │ user │ logs, │ errors, │ │
│ │ tests, │ pages, │ tickets, │ uptime, │ traces │ │
│ │ deploys │ docs, │ FAQ, │ alerts on │ stack, │ │
│ │ via │ release │ drafts │ failures │ proposes │ │
│ │ Gitea + │ notes │ replies │ │ fixes │ │
│ │ Coolify │ │ │ │ │ │
│ └──────────┴───────────┴──────────┴────────────┴───────────┘ │
└────────────────────────────┬────────────────────────────────────┘
┌────────────────────────────┴────────────────────────────────────┐
│ Shared Infrastructure │
│ │
│ Gitea (git.vibnai.com) — code, docs, marketing content │
│ Coolify (coolify.vibnai.com) — deploys, hosting, logs │
│ Database (Postgres) — conversations, tasks, user data │
│ Object Storage — assets, screenshots, artifacts │
└─────────────────────────────────────────────────────────────────┘
┌────────────────────────────┴────────────────────────────────────┐
│ User Workspaces (Cold Tier) │
│ │
│ Per-user Theia IDE containers │
│ - Hibernate when not in use (storage persists) │
│ - Wake in 2-5 seconds when user opens browser │
│ - Mount project workspace volume │
│ - Full code editor, terminal, AI chat │
│ - For manual vibe coding sessions │
└─────────────────────────────────────────────────────────────────┘
```
---
## Master Orchestrator
The central AI brain for each project. Always running on shared infrastructure.
### What it knows
- Full codebase (via Gitea API)
- Deployment state (via Coolify API)
- Task/ticket history (database)
- Conversation history with the user across all channels
- What each specialist agent is doing
### How it receives work
1. **User message** — from mobile app, browser, or Theia IDE chat
2. **Gitea webhook** — code pushed, PR opened, issue created
3. **Coolify webhook** — deploy completed, service crashed
4. **Scheduled checks** — cron-based monitoring, report generation
5. **Support ticket** — customer question routed in
### How it dispatches
- Analyzes the event/message
- Decides which specialist agent(s) should act
- Dispatches to one or more agents simultaneously
- Tracks completion, reports results back to user
- Queues follow-up work if needed
---
## Specialist Agents
### Coder
- **Trigger**: User request, Orchestrator dispatch
- **Tools**: Gitea API (read/write code), Coolify API (deploy), shell execute
- **Output**: Code commits, PRs, deployments
- **Autonomy**: Can commit to feature branches, needs approval for main
### Marketing
- **Trigger**: New feature deployed, Orchestrator dispatch
- **Tools**: Gitea API (update marketing repo), content templates
- **Output**: Updated landing pages, release notes, feature announcements
- **Autonomy**: Drafts content, queues for user review
### Support
- **Trigger**: Support ticket/question received, Orchestrator dispatch
- **Tools**: Codebase search, docs search, conversation history
- **Output**: Draft replies, FAQ updates, escalation to Coder if it's a bug
- **Autonomy**: Can draft responses, user approves before sending
### Monitor
- **Trigger**: Scheduled (every N minutes), Coolify webhook on failure
- **Tools**: Coolify API (logs, status), health check endpoints
- **Output**: Status reports, alerts, escalation to Debugger on failures
- **Autonomy**: Fully autonomous — monitors and alerts without user input
### Debugger
- **Trigger**: Monitor detects failure, user reports bug, error logs
- **Tools**: Coolify logs, Gitea code search, stack trace analysis
- **Output**: Root cause analysis, proposed fix, delegates to Coder if approved
- **Autonomy**: Analyzes and proposes, needs approval to fix
---
## User Interfaces
### Mobile App (chat-first)
- Simple chat interface to the Master Orchestrator
- Push notifications for alerts, completed tasks, questions needing approval
- Quick actions: approve deploy, review draft, check status
- No code editing — just conversation and oversight
### Browser Dashboard (command center)
- Chat with the Master Orchestrator
- Dashboard panels: deploy status, agent activity, recent changes
- Review queues: marketing drafts, support replies, code PRs
- Project timeline and task tracking
- No code editing — management and oversight
### Theia IDE (full workspace)
- Full code editor, terminal, file tree
- AI chat with Coder agent for hands-on vibe coding
- Design panel for visual preview
- For when the user wants to write code themselves
- Connects to the same project workspace as the Orchestrator
---
## Communication Flow
### User chats from mobile
```
User (mobile): "How's the launch looking?"
→ API Gateway → Master Orchestrator
→ Orchestrator checks: Coder status, Marketing status, Monitor status
→ Response: "Code is deployed. Marketing page updated.
99.8% uptime last 24h. Two support tickets pending your review."
```
### User assigns work from mobile
```
User (mobile): "Add a pricing page with three tiers"
→ Master Orchestrator dispatches:
1. Coder: build /pricing route with tier components
2. Marketing: draft copy for three pricing tiers
→ User closes app
→ Coder commits code, deploys to staging
→ Marketing drafts copy, queues for review
→ User gets push notification: "Pricing page ready for review"
```
### Automated monitoring
```
Monitor agent (scheduled): checks Coolify every 5 min
→ Detects: API response time > 2s
→ Escalates to Debugger
→ Debugger: reads recent commits, checks logs
→ Debugger: "Memory leak in auth middleware introduced in commit abc123"
→ Orchestrator: notifies user via push notification
→ User (mobile): "Fix it"
→ Orchestrator → Coder: revert/fix the commit, deploy
```
---
## Infrastructure
### Hot Tier (shared, always running)
- 2-4 servers on Hetzner/Coolify
- Runs Master Orchestrator + specialist agents for ALL projects
- Stateless compute — reads/writes to Gitea and database
- Scales horizontally with demand
- Estimated cost: $60-200/mo for the first 1,000 projects
### Cold Tier (per-user, on-demand)
- Theia IDE containers, one per user workspace
- Hibernate after idle timeout
- Wake on browser access
- Storage persists (workspace volumes)
- Estimated cost: $1-2/mo per user (mostly storage)
### Shared Services
- **Gitea**: code, docs, marketing content (self-hosted on Coolify)
- **Coolify**: container orchestration, deploys, logs
- **Postgres**: conversations, tasks, user accounts, agent state
- **Redis** (optional): task queue, real-time pub/sub for agent coordination
---
## Data Flow
```
All code/content → Gitea repos (source of truth)
All deploys → Coolify (hosting + logs)
All conversations → Postgres (history + context)
All agent state → Postgres (what's running, what's queued)
```
Every specialist agent reads from and writes to these shared services.
No agent has local state that would be lost on restart.
The Master Orchestrator coordinates but doesn't store — it queries.
---
## Agent Tool Registry
The agent runner uses a modular tool registry (`src/tools/`). Each domain file registers its tools on import — agents declare which subset they use.
| Tool file | Tools | Used by |
|-----------|-------|---------|
| `file.ts` | file read/write/list | Orchestrator, Coder |
| `shell.ts` | shell execute | Orchestrator, Coder |
| `git.ts` | git operations | Orchestrator, Coder |
| `gitea.ts` | Gitea API (repos, issues, PRs) | Orchestrator, Coder |
| `coolify.ts` | Coolify API (deploy, logs, status) | Orchestrator, Monitor |
| `agent.ts` | spawn sub-agents | Orchestrator |
| `memory.ts` | knowledge base read/write | All agents |
| `skills.ts` | reusable markdown skill lookup | All agents |
| `prd.ts` | `finalize_prd` — save completed PRD | Atlas |
| `search.ts` | `web_search` — internet search via Jina AI | Atlas |
### Web Search (`web_search` tool)
Atlas has access to real-time web search via [Jina AI's search endpoint](https://s.jina.ai/) — completely free, no API key required.
**How it works:**
- Atlas calls `web_search` with a plain-language query
- The tool fetches `https://s.jina.ai/<query>` which returns clean markdown-formatted results
- Results are truncated to ~6,000 characters to keep context window usage reasonable
- Atlas uses this to ground discovery conversations in real-world context
**What Atlas uses it for:**
- Researching competitors and existing solutions
- Understanding market pricing and business models
- Looking up relevant technologies, frameworks, or integrations the user mentions
- Validating assumptions ("is this a solved problem? what do incumbents miss?")
**No configuration needed** — Jina AI's free tier requires no credentials. If stricter control or higher volume is needed in future, swap the endpoint for Tavily, Brave Search, or Google Custom Search by updating `src/tools/search.ts`.
---
## Product Lifecycle (Current Design)
### Correct sequence — not yet fully implemented
```
1. Create project (name only — no scaffold yet)
2. Atlas discovery conversation
- Understands the product concept
- Determines product type (SaaS / Marketplace / E-commerce / AI / Website / Mobile)
- Determines required surfaces (Web App, Marketing Site, Admin, Mobile, Email, Docs)
- Determines design package per surface
- Generates PRD with all of the above as structured data
3. Architect agent
- Reads PRD (product type, surfaces, design choices)
- Designs technical solution and data model
- Generates Gitea repo scaffold tailored to the specific surfaces needed
- NOT a generic Turborepo template — apps/ reflects exactly what was decided
4. Builder / Orchestrator
- Reads PRD + architecture
- Builds the product surface by surface
5. Active (Theia IDE + Orchestrator for ongoing work)
```
### What is built today (and needs to change)
| Step | Current behaviour | Correct behaviour |
|------|------------------|-------------------|
| Project creation | Creates Gitea repo + generic Turborepo scaffold immediately | Create project record only — no repo yet |
| Atlas | Saves PRD markdown + sets stage to `architecture` | Also saves `productType` and `surfaces[]` as structured fields |
| Design page | Shows Turborepo `apps/` from Gitea | Reads `surfaces[]` from PRD, shows theme picker per surface |
| Architect | Not built yet | Reads PRD + surfaces, generates tailored Gitea scaffold |
### Design surfaces and recommended libraries
| Surface | When needed | Library options |
|---------|-------------|----------------|
| **Web App** | SaaS, Marketplace, AI Product | shadcn/ui, Mantine, HeroUI, Tremor |
| **Marketing Site** | Almost every product | DaisyUI, HeroUI, Aceternity UI, Tailwind only |
| **Admin / Internal** | SaaS, Marketplace, E-commerce | Mantine, shadcn/ui, Tremor |
| **Mobile App** | Mobile-first products | NativeWind, Gluestack, RN Paper |
| **Email** | SaaS, E-commerce, Marketplace | React Email + Resend |
| **Docs / Content** | Developer tools, complex products | Nextra, Starlight, Docusaurus |
---
## What to build (in order)
### Phase 1: Foundation
- [ ] Move AI agents to Theia backend (server-side execution)
- [ ] Master Orchestrator service with multi-agent dispatch
- [ ] Postgres schema for conversations, tasks, agent state
- [ ] API Gateway with auth and WebSocket support
### Phase 2: Agents
- [ ] Coder agent (already exists — extract from Theia frontend)
- [ ] Monitor agent (Coolify log watcher + health checks)
- [ ] Marketing agent (content generation + Gitea commits)
- [ ] Support agent (ticket intake + draft responses)
- [ ] Debugger agent (log analysis + fix proposals)
### Phase 3: Interfaces
- [ ] Browser dashboard (React app, chat + status panels)
- [ ] Mobile app (React Native or Flutter, chat + push notifications)
- [ ] Theia IDE integration (connect to Master Orchestrator)
### Phase 4: Scale
- [ ] Workspace hibernation and wake-on-access
- [ ] Multi-project support per user
- [ ] Hot tier horizontal scaling
- [ ] Usage-based billing

View File

@@ -0,0 +1,33 @@
# Coolify White Label — Vibn Branding
Users never see the Coolify UI directly (it's backend infrastructure), but if you
want the admin panel to match, add these to the Coolify `.env` on the server.
## Steps
```bash
# SSH into the server
gcloud compute ssh coolify-server-mtl --zone=northamerica-northeast1-a
# Edit the Coolify env file
sudo nano /data/coolify/source/.env
```
## Add These Lines
```env
COOLIFY_WHITE_LABELED=true
COOLIFY_WHITE_LABELED_ICON=https://vibnai.com/vibn-icon.png
```
The icon URL must be publicly accessible. Once your logo is live on vibnai.com,
point this to the 180×180 PNG version.
## Apply Changes
```bash
cd /data/coolify/source
docker compose down && docker compose up -d
```
Note: These settings are lost on Coolify upgrades — re-apply if you update Coolify.

View File

@@ -0,0 +1,10 @@
; Vibn branding overrides for Gitea
; These settings go in $GITEA_CUSTOM/conf/app.ini
; On the server this is at: /data/gitea/data/gitea/custom/conf/app.ini
[ui]
SITE_TITLE = Vibn
[server]
; Optional: change the landing page description meta tag
LANDING_PAGE = home

View File

@@ -0,0 +1,34 @@
# Gitea Brand Assets — Drop Files Here
Place the following files in this directory, then copy them to the server at:
`/data/gitea/data/gitea/custom/public/assets/img/`
Then restart Gitea for changes to take effect.
## Required Files
| File | Format | Dimensions | Purpose |
|---|---|---|---|
| `logo.svg` | SVG | Vector | Main nav logo |
| `logo.png` | PNG | 512×512 | Open Graph previews |
| `favicon.svg` | SVG | Vector | Browser tab (primary) |
| `favicon.png` | PNG | 180×180 | Browser tab (fallback) |
| `apple-touch-icon.png` | PNG | 180×180 | iOS bookmark icon |
| `avatar_default.png` | PNG | 200×200 | Default user avatar |
## How to Deploy
```bash
# From local machine:
gcloud compute scp branding/gitea/public/assets/img/* \
coolify-server-mtl:/data/gitea/data/gitea/custom/public/assets/img/ \
--zone=northamerica-northeast1-a
gcloud compute scp branding/gitea/conf/app.ini \
coolify-server-mtl:/data/gitea/data/gitea/custom/conf/app.ini \
--zone=northamerica-northeast1-a
# Then restart Gitea:
gcloud compute ssh coolify-server-mtl --zone=northamerica-northeast1-a \
--command="sudo docker restart gitea-bcc4k0kog0w4ckkskg8gwggc"
```

View File

@@ -0,0 +1,242 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>vibn — Homepage</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;0,600;0,700;1,400;1,600&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0;}
:root{--ink:#1A1A1A;--ink2:#2c2c2a;--ink3:#444441;--mid:#6B7280;--muted:#9CA3AF;--stone:#b4b2a9;--parch:#d3d1c7;--cream:#f1efe8;--paper:#f7f4ee;--white:#FFFFFF;--border:#E5E7EB;--serif:'Lora',Georgia,serif;--sans:'Inter',sans-serif;}
body{font-family:var(--sans);background:linear-gradient(to bottom,#FAFAFA,#F3F7F5);min-height:100vh;color:var(--ink);}
.f{font-family:var(--serif);}
nav{background:rgba(250,250,250,0.95);border-bottom:1px solid var(--border);padding:0 52px;height:62px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:50;}
.logo-box{width:30px;height:30px;background:var(--ink);border-radius:7px;display:flex;align-items:center;justify-content:center;}
.btn-ink{background:linear-gradient(135deg,#1F3D2B,#2F6F4F);color:#FFFFFF;border:none;border-radius:8px;padding:9px 22px;font-family:var(--sans);font-size:13.5px;font-weight:600;cursor:pointer;box-shadow:0 10px 25px rgba(31,61,43,0.15);transition:box-shadow 0.2s ease,transform 0.2s ease;}
.btn-ink:hover{box-shadow:0 10px 25px rgba(31,61,43,0.15),0 0 0 6px rgba(47,111,79,0.2);transform:translateY(-1px);}
.btn-ink-lg{background:linear-gradient(135deg,#1F3D2B,#2F6F4F);color:#FFFFFF;border:none;border-radius:10px;padding:15px 36px;font-family:var(--sans);font-size:15px;font-weight:600;cursor:pointer;box-shadow:0 10px 25px rgba(31,61,43,0.15);transition:box-shadow 0.2s ease,transform 0.2s ease;}
.btn-ink-lg:hover{box-shadow:0 10px 25px rgba(31,61,43,0.15),0 0 0 6px rgba(47,111,79,0.2);transform:translateY(-1px);}
.gradient-em{background:linear-gradient(to right,#2F6F4F,#6FAF8F);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;font-style:italic;}
.gradient-text{background:linear-gradient(to right,#2F6F4F,#6FAF8F);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;}
.gradient-num{background:linear-gradient(135deg,#1F3D2B,#2F6F4F);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;}
.empathy-card{background:var(--white);border:1px solid var(--border);border-left:3px solid rgba(47,111,79,0.8);border-radius:12px;padding:18px 20px;display:flex;gap:14px;align-items:flex-start;box-shadow:0 10px 30px rgba(31,61,43,0.05);transition:border-color 0.2s ease,background 0.2s ease;}
.empathy-card:hover{border-color:#2F6F4F;background:#F3F7F5;}
</style>
</head>
<body>
<nav>
<div style="display:flex;align-items:center;gap:10px;">
<div class="logo-box"><span class="f" style="font-size:15px;font-weight:700;color:#FFFFFF;">V</span></div>
<span class="f" style="font-size:19px;font-weight:700;color:var(--ink);letter-spacing:-0.02em;">vibn</span>
</div>
<div style="display:flex;gap:32px;align-items:center;">
<a href="#" style="font-size:14px;color:var(--muted);text-decoration:none;">Product</a>
<a href="#" style="font-size:14px;color:var(--muted);text-decoration:none;">Pricing</a>
<a href="#" style="font-size:14px;color:var(--muted);text-decoration:none;">Stories</a>
<a href="#" style="font-size:14px;color:var(--muted);text-decoration:none;">Blog</a>
</div>
<div style="display:flex;align-items:center;gap:12px;">
<a href="02_signup.html" style="font-size:14px;color:var(--muted);text-decoration:none;">Log in</a>
<a href="02_signup.html"><button class="btn-ink">Get started free</button></a>
</div>
</nav>
<!-- HERO -->
<section style="max-width:980px;margin:0 auto;padding:88px 52px 72px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:96px;align-items:center;">
<!-- Left: copy -->
<div>
<div style="font-size:11px;font-weight:600;letter-spacing:0.13em;text-transform:uppercase;color:var(--muted);margin-bottom:22px;">For non-technical founders</div>
<h1 class="f" style="font-size:58px;font-weight:700;color:var(--ink);letter-spacing:-0.03em;line-height:1.06;margin-bottom:28px;">
You have the idea.<br>We handle<br><em class="gradient-em">everything else.</em>
</h1>
<p style="font-size:17px;color:var(--mid);line-height:1.75;">You describe it. Vibn builds it, launches it, and markets it. From idea to <strong style="color:var(--ink);">live</strong> product in <strong style="color:var(--ink);">72 hours</strong> — no code, no agencies, no waiting.</p>
</div>
<!-- Right: product moment card -->
<div style="flex-shrink:0;">
<div style="background:var(--white);border:1px solid var(--border);border-radius:16px;overflow:hidden;box-shadow:0 20px 60px rgba(31,61,43,0.05);">
<!-- Input area -->
<div style="padding:24px 26px 20px;background:#FAFAFA;border-bottom:1px solid var(--border);">
<div style="font-size:10px;font-weight:600;letter-spacing:0.12em;text-transform:uppercase;color:var(--muted);margin-bottom:12px;">Your idea</div>
<p class="f" style="font-size:15px;font-style:italic;color:var(--ink);line-height:1.65;margin-bottom:14px;">"I want to build a booking tool for independent personal trainers."</p>
<div style="display:flex;justify-content:flex-end;">
<span style="font-size:11px;color:var(--muted);background:var(--white);border:1px solid var(--border);border-radius:5px;padding:3px 9px;letter-spacing:0.04em;">↵ Enter</span>
</div>
</div>
<!-- Output area -->
<div style="padding:20px 26px 24px;background:var(--white);">
<div style="font-size:10px;font-weight:600;letter-spacing:0.12em;text-transform:uppercase;color:var(--muted);margin-bottom:16px;">vibn generated</div>
<div style="display:flex;flex-direction:column;gap:0;">
<div style="display:flex;justify-content:space-between;align-items:baseline;padding:10px 0;border-bottom:1px solid var(--border);">
<span style="font-size:12px;color:var(--muted);font-weight:500;">Pages</span>
<span style="font-size:13px;color:var(--ink);font-weight:600;">Landing, Dashboard, Booking, Payments</span>
</div>
<div style="display:flex;justify-content:space-between;align-items:baseline;padding:10px 0;border-bottom:1px solid var(--border);">
<span style="font-size:12px;color:var(--muted);font-weight:500;">Stack</span>
<span style="font-size:13px;color:var(--ink);font-weight:600;">Auth, database, payments — handled</span>
</div>
<div style="display:flex;justify-content:space-between;align-items:baseline;padding:10px 0;border-bottom:1px solid var(--border);">
<span style="font-size:12px;color:var(--muted);font-weight:500;">Revenue</span>
<span style="font-size:13px;color:var(--ink);font-weight:600;">Subscription · $29 / mo</span>
</div>
<div style="display:flex;justify-content:space-between;align-items:baseline;padding:10px 0;">
<span style="font-size:12px;color:var(--muted);font-weight:500;">Status</span>
<span style="font-size:13px;font-weight:600;color:#2F6F4F;">&nbsp; Ready to build</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- CTA row -->
<div style="display:flex;flex-direction:column;align-items:center;text-align:center;gap:10px;margin-top:52px;">
<a href="02_signup.html"><button class="btn-ink-lg">Start free — no code needed</button></a>
<span style="font-size:13.5px;color:#6FAF8F;">★★★★★</span><span style="font-size:13.5px;color:var(--stone);">&nbsp;&nbsp;280 founders launched</span>
<p style="font-size:12px;color:#9CA3AF;">No credit card required · Free forever plan</p>
<a href="#how-it-works" style="font-size:13.5px;color:#2F6F4F;text-decoration:none;font-weight:500;margin-top:4px;">See how it works →</a>
</div>
</section>
<!-- EMPATHY -->
<section style="border-top:1px solid var(--border);border-bottom:1px solid var(--border);padding:80px 52px;">
<div style="max-width:980px;margin:0 auto;display:grid;grid-template-columns:1fr 1fr;gap:72px;align-items:center;">
<div>
<div style="font-size:11px;font-weight:600;letter-spacing:0.13em;text-transform:uppercase;color:var(--muted);margin-bottom:18px;">Sound familiar?</div>
<h2 class="f" style="font-size:36px;font-weight:700;color:#1A1A1A;line-height:1.18;margin-bottom:24px;letter-spacing:-0.02em;">The idea is the hard part. <span class="gradient-text">Everything else shouldn't be.</span></h2>
<p style="font-size:15px;color:var(--mid);line-height:1.82;margin-bottom:20px;">You know exactly what you want to build and who it's for. But the moment you think about servers, databases, deployment pipelines, SEO — the whole thing stalls.</p>
<p style="font-size:15px;color:var(--mid);line-height:1.82;">vibn exists to remove all of that. Not abstract it — <em class="f" style="font-style:italic;">remove it entirely.</em></p>
</div>
<div style="display:flex;flex-direction:column;gap:14px;">
<div class="empathy-card"><div style="width:20px;height:20px;border-radius:50%;border:1.5px solid rgba(47,111,79,0.4);display:flex;align-items:center;justify-content:center;flex-shrink:0;margin-top:2px;"><div style="width:7px;height:7px;border-radius:50%;background:#6FAF8F;"></div></div><div><div class="f" style="font-size:14px;font-weight:600;color:#1A1A1A;margin-bottom:4px;">No more "I need to hire a developer first"</div><div style="font-size:13px;color:var(--mid);line-height:1.7;">vibn is your developer. Start building the moment you have an idea.</div></div></div>
<div class="empathy-card"><div style="width:20px;height:20px;border-radius:50%;border:1.5px solid rgba(47,111,79,0.4);display:flex;align-items:center;justify-content:center;flex-shrink:0;margin-top:2px;"><div style="width:7px;height:7px;border-radius:50%;background:#6FAF8F;"></div></div><div><div class="f" style="font-size:14px;font-weight:600;color:#1A1A1A;margin-bottom:4px;">No more staring at a blank marketing calendar</div><div style="font-size:13px;color:var(--mid);line-height:1.7;">AI generates and publishes your content every single week.</div></div></div>
<div class="empathy-card"><div style="width:20px;height:20px;border-radius:50%;border:1.5px solid rgba(47,111,79,0.4);display:flex;align-items:center;justify-content:center;flex-shrink:0;margin-top:2px;"><div style="width:7px;height:7px;border-radius:50%;background:#6FAF8F;"></div></div><div><div class="f" style="font-size:14px;font-weight:600;color:#1A1A1A;margin-bottom:4px;">No more "I'll launch when it's ready"</div><div style="font-size:13px;color:var(--mid);line-height:1.7;">Most founders ship their first version in under 72 hours.</div></div></div>
</div>
</div>
</section>
<!-- HOW IT WORKS -->
<section id="how-it-works" style="max-width:980px;margin:0 auto;padding:84px 52px;">
<div style="font-size:11px;font-weight:600;letter-spacing:0.13em;text-transform:uppercase;color:var(--muted);margin-bottom:16px;">How it works</div>
<h2 class="f" style="font-size:42px;font-weight:700;color:#1A1A1A;letter-spacing:-0.02em;margin-bottom:54px;max-width:480px;line-height:1.15;">Four phases. One <span class="gradient-text">complete</span> product.</h2>
<div style="display:grid;grid-template-columns:1fr 1fr;border:1px solid rgba(47,111,79,0.2);border-radius:14px;overflow:hidden;">
<div style="padding:40px 44px;background:var(--white);border-right:1px solid rgba(47,111,79,0.2);border-bottom:1px solid rgba(47,111,79,0.2);"><div style="font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;color:rgba(47,111,79,0.7);margin-bottom:14px;">01 — Discover</div><div class="f" style="font-size:22px;font-weight:700;color:#1A1A1A;margin-bottom:10px;">Define your idea</div><p style="font-size:13.5px;color:var(--mid);line-height:1.72;">Six guided questions turn a rough idea into a full product plan — pages, architecture, revenue model. No jargon.</p></div>
<div style="padding:40px 44px;background:var(--white);border-bottom:1px solid rgba(47,111,79,0.2);"><div style="font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;color:rgba(47,111,79,0.7);margin-bottom:14px;">02 — Design</div><div class="f" style="font-size:22px;font-weight:700;color:#1A1A1A;margin-bottom:10px;">Choose your style</div><p style="font-size:13.5px;color:var(--mid);line-height:1.72;">Pick a visual style and see your exact site and emails live before a single line of code is written.</p></div>
<div style="padding:40px 44px;background:var(--white);border-right:1px solid rgba(47,111,79,0.2);"><div style="font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;color:rgba(47,111,79,0.7);margin-bottom:14px;">03 — Build</div><div class="f" style="font-size:22px;font-weight:700;color:#1A1A1A;margin-bottom:10px;">Your app, live</div><p style="font-size:13.5px;color:var(--mid);line-height:1.72;">AI writes every line. Auth, database, payments, all pages — deployed and live. Describe changes in plain English.</p></div>
<div style="padding:40px 44px;background:var(--white);"><div style="font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;color:rgba(47,111,79,0.7);margin-bottom:14px;">04 — Grow</div><div class="f" style="font-size:22px;font-weight:700;color:#1A1A1A;margin-bottom:10px;">Market &amp; automate</div><p style="font-size:13.5px;color:var(--mid);line-height:1.72;">AI generates your blog, emails, and social schedule — publishing on autopilot so you can focus on users.</p></div>
</div>
</section>
<!-- WHAT YOU GET -->
<section style="background:var(--white);border-top:1px solid var(--border);border-bottom:1px solid var(--border);">
<div style="max-width:980px;margin:0 auto;padding:0 52px;display:grid;grid-template-columns:1fr 1fr 1fr;">
<div style="padding:44px 40px 44px 0;border-right:1px solid var(--border);">
<div style="font-size:13px;font-weight:700;color:#2F6F4F;margin-bottom:12px;text-align:center;"></div>
<div class="f" style="font-size:17px;font-weight:700;color:#1A1A1A;margin-bottom:8px;text-align:center;">A live, working product</div>
<p style="font-size:13.5px;color:var(--mid);line-height:1.7;text-align:center;">Not a prototype. Real auth, real payments, real database — on your own URL from day one.</p>
</div>
<div style="padding:44px 40px;border-right:1px solid var(--border);">
<div style="font-size:13px;font-weight:700;color:#2F6F4F;margin-bottom:12px;text-align:center;"></div>
<div class="f" style="font-size:17px;font-weight:700;color:#1A1A1A;margin-bottom:8px;text-align:center;">A full marketing engine</div>
<p style="font-size:13.5px;color:var(--mid);line-height:1.7;text-align:center;">Blog posts, onboarding emails, and social content — written and published automatically every week.</p>
</div>
<div style="padding:44px 0 44px 40px;">
<div style="font-size:13px;font-weight:700;color:#2F6F4F;margin-bottom:12px;text-align:center;"></div>
<div class="f" style="font-size:17px;font-weight:700;color:#1A1A1A;margin-bottom:8px;text-align:center;">A product that evolves</div>
<p style="font-size:13.5px;color:var(--mid);line-height:1.7;text-align:center;">Describe changes in plain English. Vibn handles the code so your product grows as fast as your ideas.</p>
</div>
</div>
</section>
<!-- QUOTE BAND -->
<section style="background:#1A1A1A;padding:32px 52px 28px;">
<div style="max-width:980px;margin:0 auto;">
<!-- Carousel track -->
<div style="display:grid;grid-template-columns:1fr 1.6fr 1fr;gap:28px;align-items:center;margin-bottom:20px;">
<!-- Left: supporting quote -->
<div style="display:flex;gap:14px;opacity:0.85;">
<div style="width:2px;background:#6FAF8F;border-radius:2px;flex-shrink:0;"></div>
<div>
<p class="f" style="font-size:12.5px;color:#FFFFFF;line-height:1.65;font-style:italic;margin-bottom:8px;">"I had the idea for 2 years. The backend terrified me. vibn shipped it in 4 days and handles all my marketing."</p>
<span style="font-size:10.5px;color:var(--muted);font-weight:600;">— Alex K., founder of Taskly</span>
</div>
</div>
<!-- Center: dominant quote -->
<div style="background:rgba(255,255,255,0.05);border-radius:12px;padding:22px 26px;">
<div style="width:3px;height:16px;background:#6FAF8F;border-radius:2px;margin-bottom:12px;opacity:0.7;"></div>
<p class="f" style="font-size:16px;color:#FFFFFF;line-height:1.7;font-style:italic;margin-bottom:12px;">"I have zero coding experience. Three weeks in, I have 300 paying users. That's entirely because of vibn."</p>
<span style="font-size:11px;color:var(--muted);font-weight:600;">— Marcus L., founder of Flowmatic</span>
</div>
<!-- Right: supporting quote -->
<div style="display:flex;gap:14px;opacity:0.85;">
<div style="width:2px;background:#6FAF8F;border-radius:2px;flex-shrink:0;"></div>
<div>
<p class="f" style="font-size:12.5px;color:#FFFFFF;line-height:1.65;font-style:italic;margin-bottom:8px;">"The marketing autopilot saved me ten hours a week. My blog runs itself. I just focus on product."</p>
<span style="font-size:10.5px;color:var(--muted);font-weight:600;">— Sara R., founder of Nudge</span>
</div>
</div>
</div>
<!-- Pagination dots -->
<div style="display:flex;justify-content:center;gap:7px;">
<div style="width:5px;height:5px;border-radius:50%;background:rgba(255,255,255,0.3);"></div>
<div style="width:16px;height:5px;border-radius:3px;background:#FFFFFF;"></div>
<div style="width:5px;height:5px;border-radius:50%;background:rgba(255,255,255,0.3);"></div>
</div>
</div>
</section>
<!-- STATS -->
<section style="background:var(--white);border-top:1px solid var(--border);border-bottom:1px solid var(--border);">
<div style="max-width:980px;margin:0 auto;padding:0 52px;display:grid;grid-template-columns:1fr 1fr 1fr 1fr;">
<div style="padding:40px 0;border-right:1px solid var(--border);"><div class="f gradient-num" style="font-size:40px;font-weight:700;letter-spacing:-0.03em;margin-bottom:6px;">280+</div><div style="font-size:13px;color:var(--muted);">founders launched</div></div>
<div style="padding:40px 0 40px 36px;border-right:1px solid var(--border);"><div class="f gradient-num" style="font-size:40px;font-weight:700;letter-spacing:-0.03em;margin-bottom:6px;">72h</div><div style="font-size:13px;color:var(--muted);">average time to first version</div></div>
<div style="padding:40px 0 40px 36px;border-right:1px solid var(--border);"><div class="f gradient-num" style="font-size:40px;font-weight:700;letter-spacing:-0.03em;margin-bottom:6px;">4.9★</div><div style="font-size:13px;color:var(--muted);">average rating</div></div>
<div style="padding:40px 0 40px 36px;"><div class="f gradient-num" style="font-size:40px;font-weight:700;letter-spacing:-0.03em;margin-bottom:6px;">3×</div><div style="font-size:13px;color:var(--muted);">faster than hiring a developer</div></div>
</div>
</section>
<!-- CTA -->
<section style="padding:80px 52px;text-align:center;">
<div style="max-width:680px;margin:0 auto;background:#FFFFFF;border-radius:20px;padding:64px 52px;box-shadow:0 0 0 1px rgba(47,111,79,0.15),0 20px 60px rgba(31,61,43,0.08);">
<h2 class="f" style="font-size:48px;font-weight:700;color:var(--ink);letter-spacing:-0.03em;line-height:1.1;margin-bottom:20px;">Your idea deserves to exist.</h2>
<p style="font-size:16px;color:var(--mid);line-height:1.75;margin-bottom:38px;">Thousands of ideas never make it past a spreadsheet. Yours doesn't have to be one of them.</p>
<a href="02_signup.html"><button class="btn-ink-lg" style="margin-bottom:16px;">Build my product — free</button></a>
<div style="font-size:12.5px;color:var(--muted);">Joins 280+ non-technical founders already live</div>
</div>
</section>
<!-- FOOTER -->
<footer style="background:rgba(250,250,250,0.95);border-top:1px solid var(--border);padding:32px 52px;display:flex;align-items:center;justify-content:space-between;">
<span class="f" style="font-size:16px;font-weight:700;color:var(--ink);">vibn</span>
<div style="display:flex;gap:28px;">
<a href="#" style="font-size:13px;color:var(--muted);text-decoration:none;">Product</a>
<a href="#" style="font-size:13px;color:var(--muted);text-decoration:none;">Pricing</a>
<a href="#" style="font-size:13px;color:var(--muted);text-decoration:none;">Privacy</a>
<a href="#" style="font-size:13px;color:var(--muted);text-decoration:none;">Terms</a>
</div>
<span style="font-size:12.5px;color:var(--muted);">© 2026 vibn</span>
</footer>
</body>
</html>

View File

@@ -0,0 +1,242 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>vibn — Homepage</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;0,600;0,700;1,400;1,600&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0;}
:root{--ink:#1A1A1A;--ink2:#2c2c2a;--ink3:#444441;--mid:#6B7280;--muted:#9CA3AF;--stone:#b4b2a9;--parch:#d3d1c7;--cream:#f1efe8;--paper:#f7f4ee;--white:#FFFFFF;--border:#E5E7EB;--serif:'Lora',Georgia,serif;--sans:'Inter',sans-serif;}
body{font-family:var(--sans);background:linear-gradient(to bottom,#FAFAFE,#F0EEFF);min-height:100vh;color:var(--ink);}
.f{font-family:var(--serif);}
nav{background:rgba(250,250,250,0.95);border-bottom:1px solid var(--border);padding:0 52px;height:62px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:50;}
.logo-box{width:30px;height:30px;background:var(--ink);border-radius:7px;display:flex;align-items:center;justify-content:center;}
.btn-ink{background:linear-gradient(135deg,#2E2A5E,#4338CA);color:#FFFFFF;border:none;border-radius:8px;padding:9px 22px;font-family:var(--sans);font-size:13.5px;font-weight:600;cursor:pointer;box-shadow:0 10px 25px rgba(30,27,75,0.15);transition:box-shadow 0.2s ease,transform 0.2s ease;}
.btn-ink:hover{box-shadow:0 10px 25px rgba(30,27,75,0.15),0 0 0 6px rgba(99,102,241,0.15);transform:translateY(-1px);}
.btn-ink-lg{background:linear-gradient(135deg,#2E2A5E,#4338CA);color:#FFFFFF;border:none;border-radius:10px;padding:15px 36px;font-family:var(--sans);font-size:15px;font-weight:600;cursor:pointer;box-shadow:0 10px 25px rgba(30,27,75,0.15);transition:box-shadow 0.2s ease,transform 0.2s ease;}
.btn-ink-lg:hover{box-shadow:0 10px 25px rgba(30,27,75,0.15),0 0 0 6px rgba(99,102,241,0.15);transform:translateY(-1px);}
.gradient-em{background:linear-gradient(to right,#6366F1,#8B5CF6);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;font-style:italic;}
.gradient-text{background:linear-gradient(to right,#6366F1,#8B5CF6);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;}
.gradient-num{background:linear-gradient(135deg,#2E2A5E,#4338CA);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;}
.empathy-card{background:var(--white);border:1px solid var(--border);border-left:3px solid rgba(99,102,241,0.8);border-radius:12px;padding:18px 20px;display:flex;gap:14px;align-items:flex-start;box-shadow:0 10px 30px rgba(30,27,75,0.05);transition:border-color 0.2s ease,background 0.2s ease;}
.empathy-card:hover{border-color:#6366F1;background:#FAFAFF;}
</style>
</head>
<body>
<nav>
<div style="display:flex;align-items:center;gap:10px;">
<div class="logo-box"><span class="f" style="font-size:15px;font-weight:700;color:#FFFFFF;">V</span></div>
<span class="f" style="font-size:19px;font-weight:700;color:var(--ink);letter-spacing:-0.02em;">vibn</span>
</div>
<div style="display:flex;gap:32px;align-items:center;">
<a href="#" style="font-size:14px;color:var(--muted);text-decoration:none;">Product</a>
<a href="#" style="font-size:14px;color:var(--muted);text-decoration:none;">Pricing</a>
<a href="#" style="font-size:14px;color:var(--muted);text-decoration:none;">Stories</a>
<a href="#" style="font-size:14px;color:var(--muted);text-decoration:none;">Blog</a>
</div>
<div style="display:flex;align-items:center;gap:12px;">
<a href="02_signup.html" style="font-size:14px;color:var(--muted);text-decoration:none;">Log in</a>
<a href="02_signup.html"><button class="btn-ink">Get started free</button></a>
</div>
</nav>
<!-- HERO -->
<section style="max-width:980px;margin:0 auto;padding:88px 52px 72px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:96px;align-items:center;">
<!-- Left: copy -->
<div>
<div style="font-size:11px;font-weight:600;letter-spacing:0.13em;text-transform:uppercase;color:var(--muted);margin-bottom:22px;">For non-technical founders</div>
<h1 class="f" style="font-size:58px;font-weight:700;color:var(--ink);letter-spacing:-0.03em;line-height:1.06;margin-bottom:28px;">
You have the idea.<br>We handle<br><em class="gradient-em">everything else.</em>
</h1>
<p style="font-size:17px;color:var(--mid);line-height:1.75;">You describe it. Vibn builds it, launches it, and markets it. From idea to <strong style="color:var(--ink);">live</strong> product in <strong style="color:var(--ink);">72 hours</strong> — no code, no agencies, no waiting.</p>
</div>
<!-- Right: product moment card -->
<div style="flex-shrink:0;">
<div style="background:var(--white);border:1px solid var(--border);border-radius:16px;overflow:hidden;box-shadow:0 20px 60px rgba(30,27,75,0.05);">
<!-- Input area -->
<div style="padding:24px 26px 20px;background:#FCFCFF;border-bottom:1px solid var(--border);">
<div style="font-size:10px;font-weight:600;letter-spacing:0.12em;text-transform:uppercase;color:var(--muted);margin-bottom:12px;">Your idea</div>
<p class="f" style="font-size:15px;font-style:italic;color:var(--ink);line-height:1.65;margin-bottom:14px;">"I want to build a booking tool for independent personal trainers."</p>
<div style="display:flex;justify-content:flex-end;">
<span style="font-size:11px;color:var(--muted);background:var(--white);border:1px solid var(--border);border-radius:5px;padding:3px 9px;letter-spacing:0.04em;">↵ Enter</span>
</div>
</div>
<!-- Output area -->
<div style="padding:20px 26px 24px;background:var(--white);">
<div style="font-size:10px;font-weight:600;letter-spacing:0.12em;text-transform:uppercase;color:var(--muted);margin-bottom:16px;">vibn generated</div>
<div style="display:flex;flex-direction:column;gap:0;">
<div style="display:flex;justify-content:space-between;align-items:baseline;padding:10px 0;border-bottom:1px solid var(--border);">
<span style="font-size:12px;color:var(--muted);font-weight:500;">Pages</span>
<span style="font-size:13px;color:var(--ink);font-weight:600;">Landing, Dashboard, Booking, Payments</span>
</div>
<div style="display:flex;justify-content:space-between;align-items:baseline;padding:10px 0;border-bottom:1px solid var(--border);">
<span style="font-size:12px;color:var(--muted);font-weight:500;">Stack</span>
<span style="font-size:13px;color:var(--ink);font-weight:600;">Auth, database, payments — handled</span>
</div>
<div style="display:flex;justify-content:space-between;align-items:baseline;padding:10px 0;border-bottom:1px solid var(--border);">
<span style="font-size:12px;color:var(--muted);font-weight:500;">Revenue</span>
<span style="font-size:13px;color:var(--ink);font-weight:600;">Subscription · $29 / mo</span>
</div>
<div style="display:flex;justify-content:space-between;align-items:baseline;padding:10px 0;">
<span style="font-size:12px;color:var(--muted);font-weight:500;">Status</span>
<span style="font-size:13px;font-weight:600;color:#6366F1;">&nbsp; Ready to build</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- CTA row -->
<div style="display:flex;flex-direction:column;align-items:center;text-align:center;gap:10px;margin-top:52px;">
<a href="02_signup.html"><button class="btn-ink-lg">Start free — no code needed</button></a>
<span style="font-size:13.5px;color:#818CF8;">★★★★★</span><span style="font-size:13.5px;color:var(--stone);">&nbsp;&nbsp;280 founders launched</span>
<p style="font-size:12px;color:#9CA3AF;">No credit card required · Free forever plan</p>
<a href="#how-it-works" style="font-size:13.5px;color:#6366F1;text-decoration:none;font-weight:500;margin-top:4px;">See how it works →</a>
</div>
</section>
<!-- EMPATHY -->
<section style="border-top:1px solid var(--border);border-bottom:1px solid var(--border);padding:80px 52px;">
<div style="max-width:980px;margin:0 auto;display:grid;grid-template-columns:1fr 1fr;gap:72px;align-items:center;">
<div>
<div style="font-size:11px;font-weight:600;letter-spacing:0.13em;text-transform:uppercase;color:var(--muted);margin-bottom:18px;">Sound familiar?</div>
<h2 class="f" style="font-size:36px;font-weight:700;color:#1A1A1A;line-height:1.18;margin-bottom:24px;letter-spacing:-0.02em;">The idea is the hard part. <span class="gradient-text">Everything else shouldn't be.</span></h2>
<p style="font-size:15px;color:var(--mid);line-height:1.82;margin-bottom:20px;">You know exactly what you want to build and who it's for. But the moment you think about servers, databases, deployment pipelines, SEO — the whole thing stalls.</p>
<p style="font-size:15px;color:var(--mid);line-height:1.82;">vibn exists to remove all of that. Not abstract it — <em class="f" style="font-style:italic;">remove it entirely.</em></p>
</div>
<div style="display:flex;flex-direction:column;gap:14px;">
<div class="empathy-card"><div style="width:20px;height:20px;border-radius:50%;border:1.5px solid rgba(99,102,241,0.4);display:flex;align-items:center;justify-content:center;flex-shrink:0;margin-top:2px;"><div style="width:7px;height:7px;border-radius:50%;background:#6366F1;"></div></div><div><div class="f" style="font-size:14px;font-weight:600;color:#1A1A1A;margin-bottom:4px;">No more "I need to hire a developer first"</div><div style="font-size:13px;color:var(--mid);line-height:1.7;">vibn is your developer. Start building the moment you have an idea.</div></div></div>
<div class="empathy-card"><div style="width:20px;height:20px;border-radius:50%;border:1.5px solid rgba(99,102,241,0.4);display:flex;align-items:center;justify-content:center;flex-shrink:0;margin-top:2px;"><div style="width:7px;height:7px;border-radius:50%;background:#6366F1;"></div></div><div><div class="f" style="font-size:14px;font-weight:600;color:#1A1A1A;margin-bottom:4px;">No more staring at a blank marketing calendar</div><div style="font-size:13px;color:var(--mid);line-height:1.7;">AI generates and publishes your content every single week.</div></div></div>
<div class="empathy-card"><div style="width:20px;height:20px;border-radius:50%;border:1.5px solid rgba(99,102,241,0.4);display:flex;align-items:center;justify-content:center;flex-shrink:0;margin-top:2px;"><div style="width:7px;height:7px;border-radius:50%;background:#6366F1;"></div></div><div><div class="f" style="font-size:14px;font-weight:600;color:#1A1A1A;margin-bottom:4px;">No more "I'll launch when it's ready"</div><div style="font-size:13px;color:var(--mid);line-height:1.7;">Most founders ship their first version in under 72 hours.</div></div></div>
</div>
</div>
</section>
<!-- HOW IT WORKS -->
<section id="how-it-works" style="max-width:980px;margin:0 auto;padding:84px 52px;">
<div style="font-size:11px;font-weight:600;letter-spacing:0.13em;text-transform:uppercase;color:var(--muted);margin-bottom:16px;">How it works</div>
<h2 class="f" style="font-size:42px;font-weight:700;color:#1A1A1A;letter-spacing:-0.02em;margin-bottom:54px;max-width:480px;line-height:1.15;">Four phases. One <span class="gradient-text">complete</span> product.</h2>
<div style="display:grid;grid-template-columns:1fr 1fr;border:1px solid rgba(99,102,241,0.2);border-radius:14px;overflow:hidden;">
<div style="padding:40px 44px;background:var(--white);border-right:1px solid rgba(99,102,241,0.2);border-bottom:1px solid rgba(99,102,241,0.2);"><div style="font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;color:rgba(99,102,241,0.6);margin-bottom:14px;">01 — Discover</div><div class="f" style="font-size:22px;font-weight:700;color:#1A1A1A;margin-bottom:10px;">Define your idea</div><p style="font-size:13.5px;color:var(--mid);line-height:1.72;">Six guided questions turn a rough idea into a full product plan — pages, architecture, revenue model. No jargon.</p></div>
<div style="padding:40px 44px;background:var(--white);border-bottom:1px solid rgba(99,102,241,0.2);"><div style="font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;color:rgba(99,102,241,0.6);margin-bottom:14px;">02 — Design</div><div class="f" style="font-size:22px;font-weight:700;color:#1A1A1A;margin-bottom:10px;">Choose your style</div><p style="font-size:13.5px;color:var(--mid);line-height:1.72;">Pick a visual style and see your exact site and emails live before a single line of code is written.</p></div>
<div style="padding:40px 44px;background:var(--white);border-right:1px solid rgba(99,102,241,0.2);"><div style="font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;color:rgba(99,102,241,0.6);margin-bottom:14px;">03 — Build</div><div class="f" style="font-size:22px;font-weight:700;color:#1A1A1A;margin-bottom:10px;">Your app, live</div><p style="font-size:13.5px;color:var(--mid);line-height:1.72;">AI writes every line. Auth, database, payments, all pages — deployed and live. Describe changes in plain English.</p></div>
<div style="padding:40px 44px;background:var(--white);"><div style="font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;color:rgba(99,102,241,0.6);margin-bottom:14px;">04 — Grow</div><div class="f" style="font-size:22px;font-weight:700;color:#1A1A1A;margin-bottom:10px;">Market &amp; automate</div><p style="font-size:13.5px;color:var(--mid);line-height:1.72;">AI generates your blog, emails, and social schedule — publishing on autopilot so you can focus on users.</p></div>
</div>
</section>
<!-- WHAT YOU GET -->
<section style="background:var(--white);border-top:1px solid var(--border);border-bottom:1px solid var(--border);">
<div style="max-width:980px;margin:0 auto;padding:0 52px;display:grid;grid-template-columns:1fr 1fr 1fr;">
<div style="padding:44px 40px 44px 0;border-right:1px solid var(--border);">
<div style="font-size:13px;font-weight:700;color:#6366F1;margin-bottom:12px;text-align:center;"></div>
<div class="f" style="font-size:17px;font-weight:700;color:#1A1A1A;margin-bottom:8px;text-align:center;">A live, working product</div>
<p style="font-size:13.5px;color:var(--mid);line-height:1.7;text-align:center;">Not a prototype. Real auth, real payments, real database — on your own URL from day one.</p>
</div>
<div style="padding:44px 40px;border-right:1px solid var(--border);">
<div style="font-size:13px;font-weight:700;color:#6366F1;margin-bottom:12px;text-align:center;"></div>
<div class="f" style="font-size:17px;font-weight:700;color:#1A1A1A;margin-bottom:8px;text-align:center;">A full marketing engine</div>
<p style="font-size:13.5px;color:var(--mid);line-height:1.7;text-align:center;">Blog posts, onboarding emails, and social content — written and published automatically every week.</p>
</div>
<div style="padding:44px 0 44px 40px;">
<div style="font-size:13px;font-weight:700;color:#6366F1;margin-bottom:12px;text-align:center;"></div>
<div class="f" style="font-size:17px;font-weight:700;color:#1A1A1A;margin-bottom:8px;text-align:center;">A product that evolves</div>
<p style="font-size:13.5px;color:var(--mid);line-height:1.7;text-align:center;">Describe changes in plain English. Vibn handles the code so your product grows as fast as your ideas.</p>
</div>
</div>
</section>
<!-- QUOTE BAND -->
<section style="background:#1A1A1A;padding:32px 52px 28px;">
<div style="max-width:980px;margin:0 auto;">
<!-- Carousel track -->
<div style="display:grid;grid-template-columns:1fr 1.6fr 1fr;gap:28px;align-items:center;margin-bottom:20px;">
<!-- Left: supporting quote -->
<div style="display:flex;gap:14px;opacity:0.85;">
<div style="width:2px;background:#6366F1;border-radius:2px;flex-shrink:0;"></div>
<div>
<p class="f" style="font-size:12.5px;color:#FFFFFF;line-height:1.65;font-style:italic;margin-bottom:8px;">"I had the idea for 2 years. The backend terrified me. vibn shipped it in 4 days and handles all my marketing."</p>
<span style="font-size:10.5px;color:var(--muted);font-weight:600;">— Alex K., founder of Taskly</span>
</div>
</div>
<!-- Center: dominant quote -->
<div style="background:rgba(255,255,255,0.05);border-radius:12px;padding:22px 26px;">
<div style="width:3px;height:16px;background:#6366F1;border-radius:2px;margin-bottom:12px;opacity:0.7;"></div>
<p class="f" style="font-size:16px;color:#FFFFFF;line-height:1.7;font-style:italic;margin-bottom:12px;">"I have zero coding experience. Three weeks in, I have 300 paying users. That's entirely because of vibn."</p>
<span style="font-size:11px;color:var(--muted);font-weight:600;">— Marcus L., founder of Flowmatic</span>
</div>
<!-- Right: supporting quote -->
<div style="display:flex;gap:14px;opacity:0.85;">
<div style="width:2px;background:#6366F1;border-radius:2px;flex-shrink:0;"></div>
<div>
<p class="f" style="font-size:12.5px;color:#FFFFFF;line-height:1.65;font-style:italic;margin-bottom:8px;">"The marketing autopilot saved me ten hours a week. My blog runs itself. I just focus on product."</p>
<span style="font-size:10.5px;color:var(--muted);font-weight:600;">— Sara R., founder of Nudge</span>
</div>
</div>
</div>
<!-- Pagination dots -->
<div style="display:flex;justify-content:center;gap:7px;">
<div style="width:5px;height:5px;border-radius:50%;background:rgba(255,255,255,0.3);"></div>
<div style="width:16px;height:5px;border-radius:3px;background:#FFFFFF;"></div>
<div style="width:5px;height:5px;border-radius:50%;background:rgba(255,255,255,0.3);"></div>
</div>
</div>
</section>
<!-- STATS -->
<section style="background:var(--white);border-top:1px solid var(--border);border-bottom:1px solid var(--border);">
<div style="max-width:980px;margin:0 auto;padding:0 52px;display:grid;grid-template-columns:1fr 1fr 1fr 1fr;">
<div style="padding:40px 0;border-right:1px solid var(--border);"><div class="f gradient-num" style="font-size:40px;font-weight:700;letter-spacing:-0.03em;margin-bottom:6px;">280+</div><div style="font-size:13px;color:var(--muted);">founders launched</div></div>
<div style="padding:40px 0 40px 36px;border-right:1px solid var(--border);"><div class="f gradient-num" style="font-size:40px;font-weight:700;letter-spacing:-0.03em;margin-bottom:6px;">72h</div><div style="font-size:13px;color:var(--muted);">average time to first version</div></div>
<div style="padding:40px 0 40px 36px;border-right:1px solid var(--border);"><div class="f gradient-num" style="font-size:40px;font-weight:700;letter-spacing:-0.03em;margin-bottom:6px;">4.9★</div><div style="font-size:13px;color:var(--muted);">average rating</div></div>
<div style="padding:40px 0 40px 36px;"><div class="f gradient-num" style="font-size:40px;font-weight:700;letter-spacing:-0.03em;margin-bottom:6px;">3×</div><div style="font-size:13px;color:var(--muted);">faster than hiring a developer</div></div>
</div>
</section>
<!-- CTA -->
<section style="padding:80px 52px;text-align:center;">
<div style="max-width:680px;margin:0 auto;background:#FFFFFF;border-radius:20px;padding:64px 52px;box-shadow:0 0 0 1px rgba(99,102,241,0.15),0 20px 60px rgba(30,27,75,0.08);">
<h2 class="f" style="font-size:48px;font-weight:700;color:var(--ink);letter-spacing:-0.03em;line-height:1.1;margin-bottom:20px;">Your idea deserves to exist.</h2>
<p style="font-size:16px;color:var(--mid);line-height:1.75;margin-bottom:38px;">Thousands of ideas never make it past a spreadsheet. Yours doesn't have to be one of them.</p>
<a href="02_signup.html"><button class="btn-ink-lg" style="margin-bottom:16px;">Build my product — free</button></a>
<div style="font-size:12.5px;color:var(--muted);">Joins 280+ non-technical founders already live</div>
</div>
</section>
<!-- FOOTER -->
<footer style="background:rgba(250,250,250,0.95);border-top:1px solid var(--border);padding:32px 52px;display:flex;align-items:center;justify-content:space-between;">
<span class="f" style="font-size:16px;font-weight:700;color:var(--ink);">vibn</span>
<div style="display:flex;gap:28px;">
<a href="#" style="font-size:13px;color:var(--muted);text-decoration:none;">Product</a>
<a href="#" style="font-size:13px;color:var(--muted);text-decoration:none;">Pricing</a>
<a href="#" style="font-size:13px;color:var(--muted);text-decoration:none;">Privacy</a>
<a href="#" style="font-size:13px;color:var(--muted);text-decoration:none;">Terms</a>
</div>
<span style="font-size:12.5px;color:var(--muted);">© 2026 vibn</span>
</footer>
</body>
</html>

26
flatten.sh Normal file
View File

@@ -0,0 +1,26 @@
#!/bin/bash
cd "/Users/markhenderson/Cursor Projects/master-ai"
# Check if nested master-ai exists
if [ -d "master-ai" ]; then
echo "Found nested master-ai folder, flattening..."
# Move all contents from nested to temp folder
mv master-ai master-ai-nested
# Move everything from nested up one level
mv master-ai-nested/* .
mv master-ai-nested/.git* . 2>/dev/null || true
# Remove empty nested folder
rm -rf master-ai-nested
echo "Flattened successfully!"
else
echo "No nested folder found, structure is clean"
fi
# Show final structure
echo ""
echo "Final structure:"
ls -la | head -20

41
gitea-docker-compose.yml Normal file
View File

@@ -0,0 +1,41 @@
version: '3.8'
services:
gitea:
image: gitea/gitea:latest
container_name: gitea
restart: unless-stopped
ports:
- "3000:3000"
- "2222:22"
volumes:
- gitea_data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
environment:
- USER_UID=1000
- USER_GID=1000
- GITEA__server__ROOT_URL=https://git.vibnai.com
- GITEA__server__DOMAIN=git.vibnai.com
labels:
- "traefik.enable=true"
# HTTP router (will redirect to HTTPS)
- "traefik.http.routers.gitea-http.rule=Host(`git.vibnai.com`)"
- "traefik.http.routers.gitea-http.entrypoints=http"
- "traefik.http.routers.gitea-http.middlewares=redirect-to-https@docker"
# HTTPS router
- "traefik.http.routers.gitea-https.rule=Host(`git.vibnai.com`)"
- "traefik.http.routers.gitea-https.entrypoints=https"
- "traefik.http.routers.gitea-https.tls=true"
- "traefik.http.routers.gitea-https.tls.certresolver=letsencrypt"
# Service
- "traefik.http.services.gitea.loadbalancer.server.port=3000"
# Redirect middleware
- "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.redirect-to-https.redirectscheme.permanent=true"
# Coolify managed label (so Coolify doesn't ignore it)
- "coolify.managed=true"
volumes:
gitea_data:
driver: local

View File

@@ -0,0 +1,26 @@
/* vibn Design Tokens — Ink & Parchment */
/* Import this in any HTML file or reference these values */
@import url('https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;0,600;0,700;1,400;1,600&family=Inter:wght@400;500;600&display=swap');
:root {
--ink: #1a1510;
--ink2: #2c2c2a;
--ink3: #444441;
--mid: #5f5e5a;
--muted: #888780;
--stone: #b4b2a9;
--parch: #d3d1c7;
--cream: #f1efe8;
--paper: #f7f4ee;
--white: #fdfcfa;
--border: #e8e2d9;
--serif: 'Lora', Georgia, serif;
--sans: 'Inter', sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: var(--sans); background: var(--paper); color: var(--ink); }
.f { font-family: var(--serif); }
.s { font-family: var(--sans); }

335
justine/01_homepage.html Normal file
View File

@@ -0,0 +1,335 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><link rel="icon" href="favicon_clean.ico">
<title>vibn — Homepage</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0;}
:root{--ink:#1A1A1A;--ink2:#2c2c2a;--ink3:#444441;--mid:#6B7280;--muted:#9CA3AF;--stone:#b4b2a9;--parch:#d3d1c7;--cream:#f1efe8;--paper:#f7f4ee;--white:#FFFFFF;--border:#E5E7EB;--serif:'Plus Jakarta Sans',sans-serif;--sans:'Plus Jakarta Sans',sans-serif;}
body{font-family:var(--sans);background:linear-gradient(to bottom,#FAFAFE,#F0EEFF);min-height:100vh;color:var(--ink);}
.f{font-family:var(--serif);}
nav{background:rgba(250,250,250,0.95);border-bottom:1px solid var(--border);padding:0 52px;height:62px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:50;}
.nav-links{display:flex;gap:32px;align-items:center;}
.btn-ink{background:linear-gradient(135deg,#2E2A5E,#4338CA);color:#FFFFFF;border:none;border-radius:8px;padding:9px 22px;font-family:var(--sans);font-size:13.5px;font-weight:600;cursor:pointer;box-shadow:0 10px 25px rgba(30,27,75,0.15);transition:box-shadow 0.2s ease,transform 0.2s ease;}
.btn-ink:hover{box-shadow:0 10px 25px rgba(30,27,75,0.15),0 0 0 6px rgba(99,102,241,0.15);transform:translateY(-1px);}
.btn-ink-lg{background:linear-gradient(135deg,#2E2A5E,#4338CA);color:#FFFFFF;border:none;border-radius:10px;padding:15px 36px;font-family:var(--sans);font-size:15px;font-weight:600;cursor:pointer;box-shadow:0 10px 25px rgba(30,27,75,0.15);transition:box-shadow 0.2s ease,transform 0.2s ease;}
.btn-ink-lg:hover{box-shadow:0 10px 25px rgba(30,27,75,0.15),0 0 0 6px rgba(99,102,241,0.15);transform:translateY(-1px);}
.gradient-em{background:linear-gradient(to right,#6366F1,#8B5CF6);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;font-style:italic;}
.gradient-text{background:linear-gradient(to right,#6366F1,#8B5CF6);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;}
.gradient-num{background:linear-gradient(135deg,#2E2A5E,#4338CA);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;}
.empathy-card{background:var(--white);border:1px solid var(--border);border-left:3px solid rgba(99,102,241,0.8);border-radius:12px;padding:18px 20px;display:flex;gap:14px;align-items:flex-start;box-shadow:0 10px 30px rgba(30,27,75,0.05);transition:border-color 0.2s ease,background 0.2s ease;}
.empathy-card:hover{border-color:#6366F1;background:#FAFAFF;}
/* ── Layout grid classes (for responsive overrides) ── */
.hero-grid{display:grid;grid-template-columns:1fr 1fr;gap:96px;align-items:center;}
.empathy-grid{display:grid;grid-template-columns:1fr 1fr;gap:72px;align-items:center;}
.phase-grid{display:grid;grid-template-columns:1fr 1fr;border:1px solid rgba(99,102,241,0.2);border-radius:14px;overflow:hidden;}
.wyg-grid{display:grid;grid-template-columns:1fr 1fr 1fr;}
.quote-grid{display:grid;grid-template-columns:1fr 1.6fr 1fr;gap:28px;align-items:center;margin-bottom:20px;}
.stats-grid{display:grid;grid-template-columns:1fr 1fr 1fr 1fr;}
.footer-tagline{display:block;font-size:12px;color:var(--muted);margin-top:4px;font-family:var(--sans);}
/* ── Hamburger ── */
.hamburger{display:none;flex-direction:column;gap:5px;background:none;border:none;cursor:pointer;padding:6px;}
.hamburger span{display:block;width:22px;height:2px;background:var(--ink);border-radius:2px;transition:transform 0.25s ease,opacity 0.25s ease;}
.hamburger.open span:nth-child(1){transform:translateY(7px) rotate(45deg);}
.hamburger.open span:nth-child(2){opacity:0;}
.hamburger.open span:nth-child(3){transform:translateY(-7px) rotate(-45deg);}
/* Mobile drawer */
.mobile-menu{display:none;position:fixed;top:62px;left:0;right:0;background:rgba(250,250,250,0.98);border-bottom:1px solid var(--border);padding:20px 24px 28px;z-index:49;flex-direction:column;gap:0;box-shadow:0 8px 24px rgba(30,27,75,0.08);}
.mobile-menu.open{display:flex;}
.mobile-menu a{font-size:15px;color:var(--ink);text-decoration:none;padding:13px 0;border-bottom:1px solid var(--border);font-weight:500;}
.mobile-menu a:last-of-type{border-bottom:none;}
.mobile-menu .mobile-menu-cta{margin-top:18px;}
/* ── Mobile ── */
@media (max-width:768px){
nav{padding:0 20px;}
.nav-links{display:none;}
.hamburger{display:flex;}
.nav-right-btns{display:none;}
.hero-grid{grid-template-columns:1fr;gap:44px;}
.hero-section{padding:52px 24px 48px !important;}
.empathy-section{padding:56px 24px !important;}
.empathy-grid{grid-template-columns:1fr;gap:36px;}
.how-section{padding:64px 24px !important;}
.phase-grid{grid-template-columns:1fr;}
.phase-grid > div{border-right:none !important;padding:28px 24px !important;}
.wyg-grid{grid-template-columns:1fr;}
.wyg-grid > div{border-right:none !important;border-bottom:1px solid var(--border);padding:32px 24px !important;}
.wyg-grid > div:last-child{border-bottom:none;}
.wyg-section{padding:0 24px !important;}
.quote-grid{grid-template-columns:1fr;}
.quote-side{display:none !important;}
.quote-section{padding:32px 24px 28px !important;}
.stats-grid{grid-template-columns:1fr 1fr;}
.stats-grid > div{padding:28px 16px !important;}
.stats-grid > div:nth-child(odd){padding-left:0 !important;}
.stats-grid > div:nth-child(3),.stats-grid > div:nth-child(4){border-top:1px solid var(--border);}
.stats-grid > div:nth-child(even){border-right:none !important;}
.stats-section{padding:0 24px !important;}
.cta-section{padding:56px 20px !important;}
.cta-card{padding:44px 28px !important;}
.hero-h1{font-size:40px !important;line-height:1.1 !important;}
.hero-sub{font-size:15px !important;}
footer{flex-direction:column;gap:20px;text-align:center;padding:32px 24px !important;}
.footer-links{flex-wrap:wrap;justify-content:center;}
}
</style>
</head>
<body>
<nav>
<div style="display:flex;align-items:center;gap:10px;">
<div class="logo-box" style="width:30px;height:30px;background:linear-gradient(135deg,#2E2A5E,#4338CA);border-radius:7px;display:flex;align-items:center;justify-content:center;"><span class="f" style="font-size:15px;font-weight:700;color:#FFFFFF;">V</span></div>
<span class="f" style="font-size:19px;font-weight:700;color:var(--ink);letter-spacing:-0.02em;">vibn</span>
</div>
<div class="nav-links">
<a href="#how-it-works" style="font-size:14px;color:var(--muted);text-decoration:none;">How it works</a>
<a href="#" style="font-size:14px;color:var(--muted);text-decoration:none;">Pricing</a>
<a href="#" style="font-size:14px;color:var(--muted);text-decoration:none;">Stories</a>
<a href="#" style="font-size:14px;color:var(--muted);text-decoration:none;">Blog</a>
</div>
<div class="nav-right-btns" style="display:flex;align-items:center;gap:12px;">
<a href="03_dashboard.html" style="font-size:14px;color:#6366F1;font-weight:600;text-decoration:none;">Log in</a>
<a href="02_signup.html"><button class="btn-ink">Get started free</button></a>
</div>
<button class="hamburger" id="hamburger" aria-label="Open menu" onclick="toggleMenu()">
<span></span><span></span><span></span>
</button>
</nav>
<!-- Mobile drawer -->
<div class="mobile-menu" id="mobile-menu">
<a href="#how-it-works" onclick="closeMenu()">How it works</a>
<a href="#" onclick="closeMenu()">Pricing</a>
<a href="#" onclick="closeMenu()">Stories</a>
<a href="#" onclick="closeMenu()">Blog</a>
<a href="03_dashboard.html" style="color:#6366F1;font-weight:600;" onclick="closeMenu()">Log in</a>
<div class="mobile-menu-cta">
<a href="02_signup.html"><button class="btn-ink-lg" style="width:100%;">Get started free</button></a>
</div>
</div>
<!-- HERO -->
<section class="hero-section" style="max-width:980px;margin:0 auto;padding:88px 52px 72px;">
<div class="hero-grid">
<!-- Left: copy -->
<div>
<div style="font-size:11px;font-weight:600;letter-spacing:0.13em;text-transform:uppercase;color:var(--muted);margin-bottom:22px;">For non-technical founders</div>
<h1 class="f hero-h1" style="font-size:58px;font-weight:700;color:var(--ink);letter-spacing:-0.03em;line-height:1.06;margin-bottom:28px;">
You have the idea.<br>We handle<br><em class="gradient-em">everything else.</em>
</h1>
<p class="hero-sub" style="font-size:17px;color:var(--mid);line-height:1.75;">You describe it. Vibn builds it, launches it, and markets it. From idea to <strong style="color:var(--ink);">live</strong> product in <strong style="color:var(--ink);">72 hours</strong> — no code, no agencies, no waiting.</p>
</div>
<!-- Right: product moment card -->
<div style="flex-shrink:0;">
<div style="background:var(--white);border:1px solid var(--border);border-radius:16px;overflow:hidden;box-shadow:0 20px 60px rgba(30,27,75,0.05);">
<!-- Input area -->
<div style="padding:24px 26px 20px;background:#FCFCFF;border-bottom:1px solid var(--border);">
<div style="font-size:10px;font-weight:600;letter-spacing:0.12em;text-transform:uppercase;color:var(--muted);margin-bottom:12px;">Your idea</div>
<p class="f" style="font-size:15px;font-style:italic;color:var(--ink);line-height:1.65;margin-bottom:14px;">"I want to build a booking tool for independent personal trainers."</p>
<div style="display:flex;justify-content:flex-end;">
<span style="font-size:11px;color:var(--muted);background:var(--white);border:1px solid var(--border);border-radius:5px;padding:3px 9px;letter-spacing:0.04em;">↵ Enter</span>
</div>
</div>
<!-- Output area -->
<div style="padding:20px 26px 24px;background:var(--white);">
<div style="font-size:10px;font-weight:600;letter-spacing:0.12em;text-transform:uppercase;color:var(--muted);margin-bottom:16px;">vibn generated</div>
<div style="display:flex;flex-direction:column;gap:0;">
<div style="display:flex;justify-content:space-between;align-items:baseline;padding:10px 0;border-bottom:1px solid var(--border);">
<span style="font-size:12px;color:var(--muted);font-weight:500;">Pages</span>
<span style="font-size:13px;color:var(--ink);font-weight:600;">Landing, Dashboard, Booking, Payments</span>
</div>
<div style="display:flex;justify-content:space-between;align-items:baseline;padding:10px 0;border-bottom:1px solid var(--border);">
<span style="font-size:12px;color:var(--muted);font-weight:500;">Stack</span>
<span style="font-size:13px;color:var(--ink);font-weight:600;">Auth, database, payments — handled</span>
</div>
<div style="display:flex;justify-content:space-between;align-items:baseline;padding:10px 0;border-bottom:1px solid var(--border);">
<span style="font-size:12px;color:var(--muted);font-weight:500;">Revenue</span>
<span style="font-size:13px;color:var(--ink);font-weight:600;">Subscription · $29 / mo</span>
</div>
<div style="display:flex;justify-content:space-between;align-items:baseline;padding:10px 0;">
<span style="font-size:12px;color:var(--muted);font-weight:500;">Status</span>
<span style="font-size:13px;font-weight:600;color:#6366F1;">&nbsp; Ready to build</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- CTA row -->
<div style="display:flex;flex-direction:column;align-items:center;text-align:center;gap:10px;margin-top:52px;">
<a href="02_signup.html"><button class="btn-ink-lg">Start free — no code needed</button></a>
<div><span style="font-size:13.5px;color:#818CF8;">★★★★★</span><span style="font-size:13.5px;color:var(--stone);">&nbsp;&nbsp;280 founders launched</span></div>
<p style="font-size:12px;color:#9CA3AF;">No credit card required · Free forever plan</p>
<a href="#how-it-works" style="font-size:13.5px;color:#6366F1;text-decoration:none;font-weight:500;margin-top:4px;">See how it works →</a>
</div>
</section>
<!-- EMPATHY -->
<section class="empathy-section" style="border-top:1px solid var(--border);border-bottom:1px solid var(--border);padding:80px 52px;">
<div style="max-width:980px;margin:0 auto;">
<div class="empathy-grid">
<div>
<div style="font-size:11px;font-weight:600;letter-spacing:0.13em;text-transform:uppercase;color:var(--muted);margin-bottom:18px;">Sound familiar?</div>
<h2 class="f" style="font-size:36px;font-weight:700;color:#1A1A1A;line-height:1.18;margin-bottom:24px;letter-spacing:-0.02em;">The idea is the hard part. <span class="gradient-text">Everything else shouldn't be.</span></h2>
<p style="font-size:15px;color:var(--mid);line-height:1.82;margin-bottom:20px;">You know exactly what you want to build and who it's for. But the moment you think about servers, databases, deployment pipelines, SEO — the whole thing stalls.</p>
<p style="font-size:15px;color:var(--mid);line-height:1.82;">vibn exists to remove all of that. Not abstract it — <em class="f" style="font-style:italic;">remove it entirely.</em></p>
</div>
<div style="display:flex;flex-direction:column;gap:14px;">
<div class="empathy-card"><div style="width:20px;height:20px;border-radius:50%;border:1.5px solid rgba(99,102,241,0.4);display:flex;align-items:center;justify-content:center;flex-shrink:0;margin-top:2px;"><div style="width:7px;height:7px;border-radius:50%;background:#6366F1;"></div></div><div><div class="f" style="font-size:14px;font-weight:600;color:#1A1A1A;margin-bottom:4px;">No more "I need to hire a developer first"</div><div style="font-size:13px;color:var(--mid);line-height:1.7;">vibn is your developer. Start building the moment you have an idea.</div></div></div>
<div class="empathy-card"><div style="width:20px;height:20px;border-radius:50%;border:1.5px solid rgba(99,102,241,0.4);display:flex;align-items:center;justify-content:center;flex-shrink:0;margin-top:2px;"><div style="width:7px;height:7px;border-radius:50%;background:#6366F1;"></div></div><div><div class="f" style="font-size:14px;font-weight:600;color:#1A1A1A;margin-bottom:4px;">No more staring at a blank marketing calendar</div><div style="font-size:13px;color:var(--mid);line-height:1.7;">AI generates and publishes your content every single week.</div></div></div>
<div class="empathy-card"><div style="width:20px;height:20px;border-radius:50%;border:1.5px solid rgba(99,102,241,0.4);display:flex;align-items:center;justify-content:center;flex-shrink:0;margin-top:2px;"><div style="width:7px;height:7px;border-radius:50%;background:#6366F1;"></div></div><div><div class="f" style="font-size:14px;font-weight:600;color:#1A1A1A;margin-bottom:4px;">No more "I'll launch when it's ready"</div><div style="font-size:13px;color:var(--mid);line-height:1.7;">Most founders ship their first version in under 72 hours.</div></div></div>
</div>
</div>
</div>
</section>
<!-- HOW IT WORKS -->
<section id="how-it-works" class="how-section" style="max-width:980px;margin:0 auto;padding:84px 52px;">
<div style="font-size:11px;font-weight:600;letter-spacing:0.13em;text-transform:uppercase;color:var(--muted);margin-bottom:16px;">How it works</div>
<h2 class="f" style="font-size:42px;font-weight:700;color:#1A1A1A;letter-spacing:-0.02em;margin-bottom:54px;max-width:480px;line-height:1.15;">Four phases. One <span class="gradient-text">complete</span> product.</h2>
<div class="phase-grid">
<div style="padding:40px 44px;background:var(--white);border-right:1px solid rgba(99,102,241,0.2);border-bottom:1px solid rgba(99,102,241,0.2);"><div style="font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;color:rgba(99,102,241,0.6);margin-bottom:14px;">01 — Discover</div><div class="f" style="font-size:22px;font-weight:700;color:#1A1A1A;margin-bottom:10px;">Define your idea</div><p style="font-size:13.5px;color:var(--mid);line-height:1.72;">Six guided questions turn a rough idea into a full product plan — pages, architecture, revenue model. No jargon.</p></div>
<div style="padding:40px 44px;background:var(--white);border-bottom:1px solid rgba(99,102,241,0.2);"><div style="font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;color:rgba(99,102,241,0.6);margin-bottom:14px;">02 — Design</div><div class="f" style="font-size:22px;font-weight:700;color:#1A1A1A;margin-bottom:10px;">Choose your style</div><p style="font-size:13.5px;color:var(--mid);line-height:1.72;">Pick a visual style and see your exact site and emails live before a single line of code is written.</p></div>
<div style="padding:40px 44px;background:var(--white);border-right:1px solid rgba(99,102,241,0.2);"><div style="font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;color:rgba(99,102,241,0.6);margin-bottom:14px;">03 — Build</div><div class="f" style="font-size:22px;font-weight:700;color:#1A1A1A;margin-bottom:10px;">Your app, live</div><p style="font-size:13.5px;color:var(--mid);line-height:1.72;">AI writes every line. Auth, database, payments, all pages — deployed and live. Describe changes in plain English.</p></div>
<div style="padding:40px 44px;background:var(--white);"><div style="font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;color:rgba(99,102,241,0.6);margin-bottom:14px;">04 — Grow</div><div class="f" style="font-size:22px;font-weight:700;color:#1A1A1A;margin-bottom:10px;">Market &amp; automate</div><p style="font-size:13.5px;color:var(--mid);line-height:1.72;">AI generates your blog, emails, and social schedule — publishing on autopilot so you can focus on users.</p></div>
</div>
</section>
<!-- WHAT YOU GET -->
<section style="background:var(--white);border-top:1px solid var(--border);border-bottom:1px solid var(--border);">
<div class="wyg-grid wyg-section" style="max-width:980px;margin:0 auto;padding:0 52px;">
<div style="padding:44px 40px 44px 0;border-right:1px solid var(--border);">
<div style="font-size:13px;font-weight:700;color:#6366F1;margin-bottom:12px;text-align:center;"></div>
<div class="f" style="font-size:17px;font-weight:700;color:#1A1A1A;margin-bottom:8px;text-align:center;">A live, working product</div>
<p style="font-size:13.5px;color:var(--mid);line-height:1.7;text-align:center;">Not a prototype. Real auth, real payments, real database — on your own URL from day one.</p>
<p style="font-size:12px;color:var(--muted);line-height:1.6;text-align:center;margin-top:10px;">Runs on your own servers — your data, your infrastructure, no lock-in.</p>
</div>
<div style="padding:44px 40px;border-right:1px solid var(--border);">
<div style="font-size:13px;font-weight:700;color:#6366F1;margin-bottom:12px;text-align:center;"></div>
<div class="f" style="font-size:17px;font-weight:700;color:#1A1A1A;margin-bottom:8px;text-align:center;">A full marketing engine</div>
<p style="font-size:13.5px;color:var(--mid);line-height:1.7;text-align:center;">Blog posts, onboarding emails, and social content — written and published automatically every week.</p>
</div>
<div style="padding:44px 0 44px 40px;">
<div style="font-size:13px;font-weight:700;color:#6366F1;margin-bottom:12px;text-align:center;"></div>
<div class="f" style="font-size:17px;font-weight:700;color:#1A1A1A;margin-bottom:8px;text-align:center;">A product that evolves</div>
<p style="font-size:13.5px;color:var(--mid);line-height:1.7;text-align:center;">Describe changes in plain English. Vibn handles the code so your product grows as fast as your ideas.</p>
</div>
</div>
</section>
<!-- QUOTE BAND -->
<section class="quote-section" style="background:#1A1A1A;padding:32px 52px 28px;">
<div style="max-width:980px;margin:0 auto;">
<div class="quote-grid">
<!-- Left: supporting quote -->
<div class="quote-side" style="display:flex;gap:14px;opacity:0.85;">
<div style="width:2px;background:#6366F1;border-radius:2px;flex-shrink:0;"></div>
<div>
<p class="f" style="font-size:12.5px;color:#FFFFFF;line-height:1.65;font-style:italic;margin-bottom:8px;">"I had the idea for 2 years. The backend terrified me. vibn shipped it in 4 days and handles all my marketing."</p>
<span style="font-size:10.5px;color:var(--muted);font-weight:600;">— Alex K., founder of Taskly</span>
</div>
</div>
<!-- Center: dominant quote -->
<div style="background:rgba(255,255,255,0.05);border-radius:12px;padding:22px 26px;">
<div style="width:3px;height:16px;background:#6366F1;border-radius:2px;margin-bottom:12px;opacity:0.7;"></div>
<p class="f" style="font-size:16px;color:#FFFFFF;line-height:1.7;font-style:italic;margin-bottom:12px;">"I have zero coding experience. Three weeks in, I have 300 paying users. That's entirely because of vibn."</p>
<span style="font-size:11px;color:var(--muted);font-weight:600;">— Marcus L., founder of Flowmatic</span>
</div>
<!-- Right: supporting quote -->
<div class="quote-side" style="display:flex;gap:14px;opacity:0.85;">
<div style="width:2px;background:#6366F1;border-radius:2px;flex-shrink:0;"></div>
<div>
<p class="f" style="font-size:12.5px;color:#FFFFFF;line-height:1.65;font-style:italic;margin-bottom:8px;">"The marketing autopilot saved me ten hours a week. My blog runs itself. I just focus on product."</p>
<span style="font-size:10.5px;color:var(--muted);font-weight:600;">— Sara R., founder of Nudge</span>
</div>
</div>
</div>
<!-- Pagination dots -->
<div style="display:flex;justify-content:center;gap:7px;">
<div style="width:5px;height:5px;border-radius:50%;background:rgba(255,255,255,0.3);"></div>
<div style="width:16px;height:5px;border-radius:3px;background:#FFFFFF;"></div>
<div style="width:5px;height:5px;border-radius:50%;background:rgba(255,255,255,0.3);"></div>
</div>
</div>
</section>
<!-- STATS -->
<section style="background:var(--white);border-top:1px solid var(--border);border-bottom:1px solid var(--border);">
<div class="stats-grid stats-section" style="max-width:980px;margin:0 auto;padding:0 52px;">
<div style="padding:40px 0;border-right:1px solid var(--border);"><div class="f gradient-num" style="font-size:40px;font-weight:700;letter-spacing:-0.03em;margin-bottom:6px;">280+</div><div style="font-size:13px;color:var(--muted);">founders launched</div></div>
<div style="padding:40px 0 40px 36px;border-right:1px solid var(--border);"><div class="f gradient-num" style="font-size:40px;font-weight:700;letter-spacing:-0.03em;margin-bottom:6px;">72h</div><div style="font-size:13px;color:var(--muted);">average time to first version</div></div>
<div style="padding:40px 0 40px 36px;border-right:1px solid var(--border);"><div class="f gradient-num" style="font-size:40px;font-weight:700;letter-spacing:-0.03em;margin-bottom:6px;">4.9★</div><div style="font-size:13px;color:var(--muted);">average rating</div></div>
<div style="padding:40px 0 40px 36px;"><div class="f gradient-num" style="font-size:40px;font-weight:700;letter-spacing:-0.03em;margin-bottom:6px;">3×</div><div style="font-size:13px;color:var(--muted);">faster than hiring a developer</div></div>
</div>
</section>
<!-- CTA -->
<section class="cta-section" style="padding:80px 52px;text-align:center;">
<div class="cta-card" style="max-width:680px;margin:0 auto;background:#FFFFFF;border-radius:20px;padding:64px 52px;box-shadow:0 0 0 1px rgba(99,102,241,0.15),0 20px 60px rgba(30,27,75,0.08);">
<h2 class="f" style="font-size:48px;font-weight:700;color:var(--ink);letter-spacing:-0.03em;line-height:1.1;margin-bottom:20px;">Your idea deserves to exist.</h2>
<p style="font-size:16px;color:var(--mid);line-height:1.75;margin-bottom:38px;">Thousands of ideas never make it past a spreadsheet. Yours doesn't have to be one of them.</p>
<a href="02_signup.html"><button class="btn-ink-lg" style="margin-bottom:16px;">Build my product — free</button></a>
<div style="font-size:12.5px;color:var(--muted);">Joins 280+ non-technical founders already live</div>
</div>
</section>
<!-- FOOTER -->
<footer style="background:rgba(250,250,250,0.95);border-top:1px solid var(--border);padding:32px 52px;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;">
<div>
<span class="f" style="font-size:16px;font-weight:700;color:var(--ink);">vibn</span>
<span class="footer-tagline">The fastest way from idea to product.</span>
</div>
<div class="footer-links" style="display:flex;gap:28px;">
<a href="#how-it-works" style="font-size:13px;color:var(--muted);text-decoration:none;">How it works</a>
<a href="#" style="font-size:13px;color:var(--muted);text-decoration:none;">Pricing</a>
<a href="#" style="font-size:13px;color:var(--muted);text-decoration:none;">Privacy</a>
<a href="#" style="font-size:13px;color:var(--muted);text-decoration:none;">Terms</a>
</div>
<span style="font-size:12.5px;color:var(--muted);text-align:right;display:block;">© 2026 vibn</span>
</footer>
<script>
function toggleMenu(){
var btn=document.getElementById('hamburger');
var menu=document.getElementById('mobile-menu');
var open=menu.classList.toggle('open');
btn.classList.toggle('open',open);
document.body.style.overflow=open?'hidden':'';
}
function closeMenu(){
document.getElementById('hamburger').classList.remove('open');
document.getElementById('mobile-menu').classList.remove('open');
document.body.style.overflow='';
}
// Close on anchor click (for same-page links like #how-it-works)
document.querySelectorAll('.mobile-menu a[href^="#"]').forEach(function(a){
a.addEventListener('click',closeMenu);
});
</script>
</body>
</html>

329
justine/02_signup.html Normal file
View File

@@ -0,0 +1,329 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><link rel="icon" href="favicon_clean.ico">
<title>vibn — Sign up</title>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0;}
:root{
--ink:#1A1A1A;
--mid:#6B7280;
--muted:#9CA3AF;
--border:#E5E7EB;
--white:#FFFFFF;
--soft:#F5F3FF;
--hover:#FAFAFF;
--serif:'Plus Jakarta Sans',sans-serif;
--sans:'Plus Jakarta Sans',sans-serif;
}
body{font-family:var(--sans);background:linear-gradient(to bottom,#FAFAFA,#F5F3FF);min-height:100vh;display:flex;flex-direction:column;color:var(--ink);}
.f{font-family:var(--serif);}
/* Inputs */
input::placeholder{color:var(--muted);}
input{width:100%;border:1px solid var(--border);border-radius:8px;padding:10px 13px;font-family:var(--sans);font-size:14px;color:var(--ink);background:#FAFAFA;outline:none;transition:border-color 0.15s,box-shadow 0.15s;}
input:focus{border-color:#6366F1;box-shadow:0 0 0 3px rgba(99,102,241,0.12);}
input.error{border-color:#F87171;}
/* Primary button */
.btn{width:100%;background:linear-gradient(135deg,#2E2A5E,#4338CA);color:#FFFFFF;border:none;border-radius:10px;padding:13px;font-family:var(--sans);font-size:14px;font-weight:600;cursor:pointer;margin-top:4px;box-shadow:0 10px 25px rgba(30,27,75,0.15);transition:box-shadow 0.2s ease,transform 0.2s ease;}
.btn:hover{box-shadow:0 10px 25px rgba(30,27,75,0.15),0 0 0 6px rgba(99,102,241,0.15);transform:translateY(-1px);}
.btn:disabled{opacity:0.4;cursor:default;transform:none;box-shadow:0 10px 25px rgba(30,27,75,0.15);}
/* Mode option cards */
.mode-opt{border:1px solid var(--border);background:transparent;border-radius:10px;padding:16px;cursor:pointer;margin-bottom:10px;display:flex;align-items:center;gap:12px;transition:all 0.15s;}
.mode-opt:hover{border-color:#6366F1;background:var(--hover);}
.mode-opt.selected{border-color:#6366F1;background:var(--hover);box-shadow:0 0 0 3px rgba(99,102,241,0.1);}
/* Password strength */
.strength-bar{display:flex;gap:4px;margin-top:8px;}
.strength-seg{flex:1;height:3px;border-radius:2px;background:var(--border);transition:background 0.2s ease;}
.strength-label{font-size:11px;color:var(--muted);margin-top:5px;min-height:16px;}
/* Password toggle */
.pwd-wrap{position:relative;}
.pwd-wrap input{padding-right:40px;}
.pwd-toggle{position:absolute;right:11px;top:50%;transform:translateY(-50%);background:none;border:none;cursor:pointer;color:var(--muted);padding:4px;display:flex;align-items:center;transition:color 0.15s;}
.pwd-toggle:hover{color:var(--ink);}
/* Google button */
.btn-google{width:100%;background:transparent;border:1px solid var(--border);color:var(--ink);border-radius:10px;padding:11px;font-family:var(--sans);font-size:13.5px;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:9px;transition:border-color 0.15s,background 0.15s;}
.btn-google:hover{border-color:#6366F1;background:var(--hover);}
/* Billing notice — animated */
.billing-notice{overflow:hidden;max-height:0;opacity:0;transition:max-height 0.3s ease,opacity 0.25s ease,margin-bottom 0.3s ease;margin-bottom:0;}
.billing-notice.visible{max-height:140px;opacity:1;margin-bottom:20px;}
/* Experience hint */
.mode-hint{text-align:center;font-size:12px;color:var(--muted);margin-top:10px;min-height:18px;transition:opacity 0.2s ease;}
.exp-feedback{overflow:hidden;max-height:0;opacity:0;transition:max-height 0.3s ease,opacity 0.25s ease,margin-bottom 0.3s ease;margin-bottom:0;border-radius:10px;padding:0 16px;}
.exp-feedback.visible{max-height:80px;opacity:1;margin-bottom:4px;padding:13px 16px;}
</style>
</head>
<body>
<nav style="background:rgba(250,250,250,0.95);border-bottom:1px solid var(--border);padding:0 40px;height:62px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:50;">
<div style="display:flex;align-items:center;gap:9px;">
<div style="width:28px;height:28px;background:linear-gradient(135deg,#2E2A5E,#4338CA);border-radius:7px;display:flex;align-items:center;justify-content:center;"><span class="f" style="font-size:14px;font-weight:700;color:#FFFFFF;">V</span></div>
<span class="f" style="font-size:17px;font-weight:700;color:var(--ink);letter-spacing:-0.02em;">vibn</span>
</div>
<span style="font-size:13.5px;color:var(--muted);">Already have an account? <a href="03_dashboard.html" style="color:#6366F1;font-weight:600;text-decoration:none;">Log in</a></span>
</nav>
<div style="flex:1;display:flex;align-items:center;justify-content:center;padding:40px 24px;">
<div style="width:100%;max-width:440px;">
<!-- Step indicator -->
<div id="steps" style="display:flex;align-items:center;justify-content:center;gap:8px;margin-bottom:32px;">
<div style="display:flex;align-items:center;gap:6px;" id="s1">
<div id="s1c" style="width:24px;height:24px;border-radius:50%;background:#6366F1;display:flex;align-items:center;justify-content:center;font-size:11px;color:#FFFFFF;font-weight:700;">1</div>
<span style="font-size:12.5px;font-weight:600;color:var(--ink);">Account</span>
</div>
<div style="width:28px;height:1px;background:var(--border);"></div>
<div style="display:flex;align-items:center;gap:6px;opacity:0.35;" id="s2">
<div id="s2c" style="width:24px;height:24px;border-radius:50%;background:var(--border);display:flex;align-items:center;justify-content:center;font-size:11px;color:var(--muted);font-weight:700;">2</div>
<span id="s2l" style="font-size:12.5px;color:var(--muted);">Your experience</span>
</div>
<div style="width:28px;height:1px;background:var(--border);"></div>
<div style="display:flex;align-items:center;gap:6px;opacity:0.35;" id="s3">
<div id="s3c" style="width:24px;height:24px;border-radius:50%;background:var(--border);display:flex;align-items:center;justify-content:center;font-size:11px;color:var(--muted);font-weight:700;">3</div>
<span id="s3l" style="font-size:12.5px;color:var(--muted);">Ready</span>
</div>
</div>
<!-- STEP 1 -->
<div id="step1" style="background:var(--white);border:1px solid var(--border);border-radius:16px;padding:32px;box-shadow:0 10px 30px rgba(30,27,75,0.05);">
<h2 class="f" style="font-size:23px;font-weight:700;color:var(--ink);letter-spacing:-0.02em;margin-bottom:6px;">Let's build your first product.</h2>
<p style="font-size:14px;color:var(--muted);margin-bottom:22px;">Free to start · No credit card needed</p>
<!-- Google first -->
<button onclick="openGoogleAuth()" class="btn-google" style="margin-bottom:20px;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z" fill="#FBBC05"/><path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/></svg>
Continue with Google
</button>
<div style="margin-bottom:20px;display:flex;align-items:center;gap:12px;">
<div style="flex:1;height:1px;background:var(--border);"></div>
<span style="font-size:12px;color:var(--muted);">or continue with email</span>
<div style="flex:1;height:1px;background:var(--border);"></div>
</div>
<!-- Email form -->
<div style="display:flex;flex-direction:column;gap:15px;">
<div>
<label style="display:block;font-size:11px;font-weight:600;color:var(--mid);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:6px;">Full name</label>
<input type="text" id="inp-name" placeholder="Jane Smith" oninput="validateStep1()"/>
</div>
<div>
<label style="display:block;font-size:11px;font-weight:600;color:var(--mid);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:6px;">Email</label>
<input type="email" id="inp-email" placeholder="jane@studio.com" oninput="validateStep1()"/>
</div>
<div>
<label style="display:block;font-size:11px;font-weight:600;color:var(--mid);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:6px;">Password</label>
<div class="pwd-wrap">
<input type="password" id="pwd" placeholder="8+ characters" oninput="checkStrength(this.value);validateStep1()"/>
<button type="button" class="pwd-toggle" onclick="togglePwd()" id="pwd-toggle-btn" aria-label="Show password">
<svg id="eye-open" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
<svg id="eye-closed" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none;"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
</button>
</div>
<div class="strength-bar"><div class="strength-seg" id="seg1"></div><div class="strength-seg" id="seg2"></div><div class="strength-seg" id="seg3"></div></div>
<div class="strength-label" id="strength-label"></div>
</div>
<!-- Social proof above button -->
<p style="text-align:center;font-size:12px;color:var(--muted);margin-bottom:-4px;">Joining 280+ founders already building</p>
<button id="step1btn" class="btn" onclick="goStep(2)" disabled>Continue →</button>
<p style="text-align:center;font-size:11.5px;color:var(--muted);margin-top:2px;">By continuing you agree to our <a href="#" style="color:var(--muted);text-decoration:underline;">Terms</a> and <a href="#" style="color:var(--muted);text-decoration:underline;">Privacy Policy</a></p>
</div>
</div>
<!-- STEP 2 -->
<div id="step2" style="display:none;background:var(--white);border:1px solid var(--border);border-radius:16px;padding:32px;box-shadow:0 10px 30px rgba(30,27,75,0.05);">
<h2 class="f" style="font-size:23px;font-weight:700;color:var(--ink);letter-spacing:-0.02em;margin-bottom:6px;">How experienced are you?</h2>
<p style="font-size:14px;color:var(--muted);margin-bottom:24px;">Just so we know who we're building with</p>
<div id="modes">
<div class="mode-opt" onclick="selectExperience('beginner',this)">
<div style="width:36px;height:36px;border-radius:9px;background:var(--soft);display:flex;align-items:center;justify-content:center;font-size:18px;flex-shrink:0;color:#6366F1;"></div>
<div><div class="f" style="font-size:14px;font-weight:600;color:var(--ink);margin-bottom:3px;">First time</div><div style="font-size:12.5px;color:var(--muted);">I've never shipped a product before</div></div>
</div>
<div class="mode-opt" onclick="selectExperience('some',this)">
<div style="width:36px;height:36px;border-radius:9px;background:var(--soft);display:flex;align-items:center;justify-content:center;font-size:18px;flex-shrink:0;color:#6366F1;"></div>
<div><div class="f" style="font-size:14px;font-weight:600;color:var(--ink);margin-bottom:3px;">Some experience</div><div style="font-size:12.5px;color:var(--muted);">I've built things before</div></div>
</div>
<div class="mode-opt" onclick="selectExperience('experienced',this)">
<div style="width:36px;height:36px;border-radius:9px;background:var(--soft);display:flex;align-items:center;justify-content:center;font-size:18px;flex-shrink:0;color:#6366F1;"></div>
<div><div class="f" style="font-size:14px;font-weight:600;color:var(--ink);margin-bottom:3px;">Experienced</div><div style="font-size:12.5px;color:var(--muted);">I ship products regularly</div></div>
</div>
</div>
<!-- Contextual feedback — animated -->
<div id="exp-feedback" class="exp-feedback" style="background:var(--soft);border:1px solid rgba(99,102,241,0.2);">
<div id="exp-feedback-text" style="font-size:13px;color:#4338CA;line-height:1.6;"></div>
</div>
<button id="step2btn" class="btn" style="margin-top:20px;" onclick="goStep(3)" disabled>Set up my workspace →</button>
<p class="mode-hint" id="mode-hint">Select an option above to continue</p>
<p style="text-align:center;margin-top:6px;"><a onclick="goStep(1)" style="font-size:13px;color:var(--muted);text-decoration:none;cursor:pointer;">← Back</a></p>
</div>
<!-- STEP 3 -->
<div id="step3" style="display:none;background:var(--white);border:1px solid var(--border);border-radius:16px;padding:32px;box-shadow:0 10px 30px rgba(30,27,75,0.05);">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:6px;">
<div style="width:36px;height:36px;background:var(--soft);border:1px solid rgba(99,102,241,0.25);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0;color:#6366F1;"></div>
<h2 class="f" style="font-size:22px;font-weight:700;color:var(--ink);letter-spacing:-0.02em;">You're in. Got an idea?</h2>
</div>
<p id="done-msg" style="font-size:14px;color:var(--muted);line-height:1.7;margin-bottom:20px;padding-left:48px;">Describe it in one sentence and we'll carry it straight into your workspace.</p>
<!-- Seed input -->
<textarea id="seed-idea" placeholder="e.g. A booking tool for independent personal trainers." style="width:100%;border:1px solid var(--border);border-radius:8px;padding:11px 13px;font-family:var(--sans);font-size:14px;color:var(--ink);background:#FAFAFA;outline:none;resize:none;height:88px;line-height:1.6;transition:border-color 0.15s,box-shadow 0.15s;margin-bottom:16px;" onfocus="this.style.borderColor='#6366F1';this.style.boxShadow='0 0 0 3px rgba(99,102,241,0.12)';" onblur="this.style.borderColor='#E5E7EB';this.style.boxShadow='none';"></textarea>
<button id="dash-btn" class="btn" style="margin-top:0;" onclick="openDashboard()">Start building →</button>
<p style="text-align:center;margin-top:14px;">
<a onclick="goToDashboard()" style="font-size:13px;color:var(--muted);text-decoration:none;cursor:pointer;">I'll do this later — take me to the dashboard</a>
</p>
<p style="text-align:center;margin-top:10px;"><a onclick="goStep(2)" style="font-size:12px;color:var(--muted);text-decoration:none;cursor:pointer;opacity:0.6;">← Back</a></p>
</div>
</div>
</div>
<script>
/* Google auth popup */
function openGoogleAuth(){
var w=500,h=600;
var left=(screen.width/2)-(w/2);
var top=(screen.height/2)-(h/2);
window.open('google-auth-popup.html','google-auth','width='+w+',height='+h+',left='+left+',top='+top+',toolbar=no,menubar=no,scrollbars=no');
window.addEventListener('message',function(e){
if(e.data&&e.data.type==='google-auth-success'){
goStep(2);
}
},{once:true});
}
var mode=null;
var EXP_FEEDBACK={
beginner:"We've got you — we'll explain every step clearly, no jargon, no assumptions. You'll have a product live before you know it.",
some:"Great! You know the ropes — let's move fast and make something great.",
experienced:"You know the process. We'll keep things efficient and get straight to the point."
};
var EXP_DONE={
beginner:"Everything is in place. We'll guide you every step of the way.",
some:"Everything is in place. Let's get straight to building.",
experienced:"Everything is in place. Let's move fast."
};
/* Step navigation */
function goStep(n){
[1,2,3].forEach(function(i){
document.getElementById('step'+i).style.display=i===n?'block':'none';
var s=document.getElementById('s'+i);
if(s) s.style.opacity=i<=n?'1':'0.35';
var c=document.getElementById('s'+i+'c');
if(c){
c.style.background=i<n?'#4338CA':i===n?'#6366F1':'#E5E7EB';
c.style.color=i<=n?'#FFFFFF':'#9CA3AF';
c.textContent=i<n?'✓':String(i);
}
});
}
/* Step 1 — form validation */
function validateStep1(){
var name=document.getElementById('inp-name').value.trim();
var email=document.getElementById('inp-email').value.trim();
var pwd=document.getElementById('pwd').value;
var emailOk=/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
var pwdOk=pwd.length>=8;
document.getElementById('step1btn').disabled=!(name.length>0 && emailOk && pwdOk);
}
/* Password show / hide */
function togglePwd(){
var input=document.getElementById('pwd');
var eyeOpen=document.getElementById('eye-open');
var eyeClosed=document.getElementById('eye-closed');
var isHidden=input.type==='password';
input.type=isHidden?'text':'password';
eyeOpen.style.display=isHidden?'none':'block';
eyeClosed.style.display=isHidden?'block':'none';
}
/* Password strength */
function checkStrength(v){
var segs=['seg1','seg2','seg3'];
var labelEl=document.getElementById('strength-label');
if(v.length===0){
segs.forEach(function(id){document.getElementById(id).style.background='#E5E7EB';});
labelEl.textContent='';
return;
}
if(v.length<8){
segs.forEach(function(id){document.getElementById(id).style.background='#E5E7EB';});
document.getElementById('seg1').style.background='#F87171';
labelEl.textContent='Not enough characters — 8 minimum';
labelEl.style.color='#F87171';
return;
}
var score=1;
if(/[A-Z]/.test(v)&&/[0-9]/.test(v)) score++;
if(/[^A-Za-z0-9]/.test(v)||v.length>=12) score++;
var colors=['#E5E7EB','#E5E7EB','#E5E7EB'];
var label='';
if(score===1){colors[0]='#F87171';label='Weak';}
else if(score===2){colors[0]='#FBBF24';colors[1]='#FBBF24';label='Fair';}
else{colors[0]='#4338CA';colors[1]='#6366F1';colors[2]='#818CF8';label='Strong';}
segs.forEach(function(id,i){document.getElementById(id).style.background=colors[i];});
labelEl.textContent=label;
labelEl.style.color=score===1?'#F87171':score===2?'#FBBF24':'#6366F1';
}
/* Step 2 — experience selection */
function selectExperience(level,el){
mode=level;
document.querySelectorAll('.mode-opt').forEach(function(d){d.classList.remove('selected');});
el.classList.add('selected');
// Show contextual feedback
var feedback=document.getElementById('exp-feedback');
var feedbackText=document.getElementById('exp-feedback-text');
feedbackText.textContent=EXP_FEEDBACK[level];
feedback.classList.add('visible');
// Enable button + hide hint
document.getElementById('step2btn').disabled=false;
document.getElementById('mode-hint').style.opacity='0';
document.getElementById('done-msg').textContent=EXP_DONE[level];
}
/* Step 3 — start building (carries seed idea to Describe) */
function openDashboard(){
var btn=document.getElementById('dash-btn');
btn.textContent='Setting up…';
btn.disabled=true;
var idea=(document.getElementById('seed-idea').value||'').trim();
setTimeout(function(){
try {
sessionStorage.setItem('vibn_new_project','1');
if(idea) sessionStorage.setItem('vibn_seed_idea', idea);
} catch(e){}
window.location.href='05_describe.html';
},800);
}
/* Step 3 — skip to dashboard */
function goToDashboard(){
window.location.href='03_dashboard.html';
}
</script>
</body>
</html>

1652
justine/03_dashboard.html Normal file

File diff suppressed because it is too large Load Diff

1189
justine/05_describe.html Normal file

File diff suppressed because it is too large Load Diff

589
justine/06_architect.html Normal file
View File

@@ -0,0 +1,589 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><link rel="icon" href="favicon_clean.ico">
<title>vibn — Architect</title>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0;}
:root{
--ink:#1A1A1A;--mid:#6B7280;--muted:#9CA3AF;
--border:#E5E7EB;--cream:#FAFAFF;--paper:#F5F3FF;--white:#FFFFFF;
--indigo:#6366F1;--indigo-dark:#4338CA;--indigo-deep:#2E2A5E;
--indigo-soft:rgba(99,102,241,0.08);--indigo-ring:rgba(99,102,241,0.12);
}
body{font-family:'Plus Jakarta Sans',sans-serif;background:linear-gradient(to bottom,#FAFAFA,#F5F3FF);display:flex;flex-direction:column;height:100vh;overflow:hidden;}
.f{font-family:'Plus Jakarta Sans',sans-serif;}
/* ── Dark mode — exact match to Describe ── */
[data-theme="dark"]{
--ink:#ECE9F5;--ink2:#C8C4D8;--ink3:#A0A0B8;
--mid:#9AA3BC;--muted:#6A7490;--border:#3A4260;
--cream:#2A3250;--paper:#2A3250;--white:#2A3250;
--indigo:#818CF8;--indigo-dark:#A5B4FC;--indigo-deep:#6366F1;
--indigo-soft:rgba(99,102,241,0.12);--indigo-ring:rgba(99,102,241,0.2);
}
[data-theme="dark"] body{background:#1A1F2E;}
/* Structural panels — same hierarchy as Describe: sidebar & main = mid, right panel = darkest */
[data-theme="dark"] .arch-sidebar{background:#2A3250!important;border-right-color:#3A4260!important;}
[data-theme="dark"] .arch-main{background:#212840!important;}
[data-theme="dark"] [style*="background:#f5f3ff"]{background:#1A1F2E!important;}
/* White & near-white backgrounds (cards float above panels) */
[data-theme="dark"] [style*="background:var(--white)"]{background:#2A3250!important;}
[data-theme="dark"] [style*="background:#fafaff"]{background:#212840!important;}
[data-theme="dark"] [style*="background:#FAFAFA"]{background:#212840!important;}
[data-theme="dark"] [style*="background:#f0f4ff"]{background:#2A3250!important;}
[data-theme="dark"] [style*="background:#eef2ff"]{background:rgba(99,102,241,0.18)!important;}
[data-theme="dark"] [style*="background:#F3F4F6"]{background:#3A4260!important;}
/* Borders */
[data-theme="dark"] [style*="border-bottom:1px solid #c7d2fe"]{border-bottom-color:#3A4260!important;}
[data-theme="dark"] [style*="border:1px solid #e0e7ff"]{border-color:#3A4260!important;}
[data-theme="dark"] [style*="border:1px solid rgba(99,102,241"]{border-color:rgba(99,102,241,0.06)!important;}
/* Sidebar-specific */
[data-theme="dark"] .arch-sidebar [style*="border-top:1px solid #e5e7eb"]{border-top-color:#3A4260!important;}
[data-theme="dark"] .arch-sidebar [style*="background:#e5e7eb"]{background:#3A4260!important;}
[data-theme="dark"] .arch-sidebar [style*="color:#1a1a1a"]{color:#ECE9F5!important;}
[data-theme="dark"] .arch-sidebar [style*="color:#6b7280"]{color:#9AA3BC!important;}
[data-theme="dark"] .arch-sidebar [style*="color:#444441"]{color:#6A7490!important;}
[data-theme="dark"] .arch-sidebar [style*="color:#9ca3af"]{color:#6A7490!important;}
/* Phase & progress dots: filled indigo → light lavender (matching Describe's --accent-primary in dark) */
[data-theme="dark"] .arch-sidebar [style*="background:#6366F1"]{background:#A5B4FC!important;color:#1A1F2E!important;}
[data-theme="dark"] [style*="color:#4338ca"]{color:#A5B4FC!important;}
[data-theme="dark"] .sidebar-phase.active{background:rgba(165,180,252,0.12)!important;}
/* Blueprint rows */
[data-theme="dark"] .blueprint-row:hover{background:#323C5E!important;}
[data-theme="dark"] .blueprint-row.locked{background:#242B48!important;}
[data-theme="dark"] .blueprint-row.locked:hover{background:#2E3A5A!important;}
/* Option buttons */
[data-theme="dark"] .opt-btns{border-color:#3A4260!important;}
[data-theme="dark"] .opt-btn{color:#C8D0E8!important;border-right-color:#3A4260!important;}
[data-theme="dark"] .opt-btn:hover{background:#2E3A5A!important;color:#A5B4FC!important;}
[data-theme="dark"] .opt-btn.selected{background:rgba(99,102,241,0.15)!important;color:#A5B4FC!important;}
[data-theme="dark"] .opt-btn.why-btn{color:#A5B4FC!important;}
[data-theme="dark"] .why-btn{color:#9AA3BC!important;border-color:#3A4260!important;}
/* Right panel */
[data-theme="dark"] .deliverable-row{color:#9AA3BC!important;}
[data-theme="dark"] .deliverable-row:hover{background:#2E3A5A!important;}
/* Popups & modals */
[data-theme="dark"] #why-popup>div{background:#242B48!important;box-shadow:0 0 0 1px rgba(165,180,252,0.18),0 24px 64px rgba(0,0,0,0.55),0 0 48px rgba(99,102,241,0.14)!important;}
[data-theme="dark"] #save-exit-box{background:#242B48!important;box-shadow:0 0 0 1px rgba(165,180,252,0.18),0 24px 64px rgba(0,0,0,0.55),0 0 48px rgba(99,102,241,0.14)!important;}
[data-theme="dark"] .modal-card{background:#242B48!important;box-shadow:0 0 0 1px rgba(165,180,252,0.18),0 24px 64px rgba(0,0,0,0.55),0 0 48px rgba(99,102,241,0.14)!important;}
/* Buttons */
[data-theme="dark"] #sidebar-project-name{color:#6A7490!important;}
[data-theme="dark"] #dark-toggle{background:#2A3250!important;border-color:#3A4260!important;color:var(--mid)!important;}
[data-theme="dark"] button[onclick="saveAndExit()"]{background:#1E2640!important;border-color:#3A4260!important;}
[data-theme="dark"] button[onclick="saveAndExit()"] span{color:#A5B4FC!important;}
/* Next button — same gradient as Describe's next button */
[data-theme="dark"] button[onclick="openWhy()"]{color:#A5B4FC!important;}
[data-theme="dark"] .btn-primary{background:linear-gradient(135deg,#4338CA,#6366F1)!important;color:#FFFFFF!important;box-shadow:0 4px 14px rgba(99,102,241,0.25)!important;}
/* Scrollbar */
[data-theme="dark"] ::-webkit-scrollbar{width:6px;height:6px;}
[data-theme="dark"] ::-webkit-scrollbar-track{background:#1A1F2E;}
[data-theme="dark"] ::-webkit-scrollbar-thumb{background:#3A4260;border-radius:3px;}
[data-theme="dark"] ::-webkit-scrollbar-thumb:hover{background:#5865A0;}
[data-theme="dark"] *{scrollbar-color:#3A4260 #1A1F2E;scrollbar-width:thin;}
[data-theme="dark"] .vibn-avatar{background:#6366F1!important;}
/* Sidebar */
.sidebar-phase{display:flex;align-items:center;gap:9px;padding:9px 10px;border-radius:8px;}
.sidebar-phase.active{background:#fafaff;}
.phase-dot{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:10px;}
/* Blueprint row */
.blueprint-row{display:grid;grid-template-columns:36px 1fr 248px;align-items:center;gap:16px;padding:15px 22px;border-bottom:1px solid var(--border);transition:background 0.15s;}
.blueprint-row:last-child{border-bottom:none;border-radius:0 0 14px 14px;}
.blueprint-row:hover{background:#FAFAFF;}
.blueprint-row.locked{background:#FAFAFA;}
.blueprint-row.locked:hover{background:#F5F5F5;}
/* Option toggle buttons */
.opt-btns{display:flex;gap:0;flex-shrink:0;border:1.5px solid var(--border);border-radius:8px;overflow:visible;}
.opt-btn{flex:1;text-align:center;font-size:12px;font-weight:500;color:var(--mid);background:transparent;border:none;border-right:1.5px solid var(--border);border-radius:0;padding:6px 13px;cursor:pointer;font-family:'Plus Jakarta Sans',sans-serif;transition:all 0.15s;white-space:nowrap;position:relative;}
.opt-btn:first-child{border-radius:7px 0 0 7px;}
.opt-btn:last-child{border-right:none;border-radius:0 7px 7px 0;}
.opt-btn:hover{background:#F5F5FF;color:var(--indigo);}
.opt-btn.selected{background:var(--indigo-soft);color:var(--indigo-dark);font-weight:600;}
/* Why button (hosting) */
.why-btn{font-size:11.5px;font-weight:600;color:var(--mid);background:transparent;border:1px solid var(--border);border-radius:6px;padding:5px 11px;cursor:pointer;font-family:'Plus Jakarta Sans',sans-serif;transition:border-color 0.15s,color 0.15s;white-space:nowrap;}
.why-btn:hover{border-color:var(--indigo);color:var(--indigo);}
/* Primary button */
.btn-primary{background:linear-gradient(135deg,var(--indigo-deep),var(--indigo-dark));color:#FFFFFF;border:none;border-radius:8px;padding:10px 22px;font-family:'Plus Jakarta Sans',sans-serif;font-size:13px;font-weight:600;cursor:pointer;box-shadow:0 10px 25px rgba(30,27,75,0.15);transition:box-shadow 0.2s,transform 0.2s;}
.btn-primary:hover{box-shadow:0 10px 25px rgba(30,27,75,0.15),0 0 0 6px var(--indigo-ring);transform:translateY(-1px);}
/* What you're getting panel */
.deliverable-row{display:flex;align-items:center;gap:8px;padding:7px 10px;border-radius:7px;font-size:12px;color:var(--mid);transition:background 0.12s;}
.deliverable-row:hover{background:var(--cream);}
/* Modal */
.modal-bg{display:none;position:fixed;inset:0;background:rgba(15,14,26,0.45);backdrop-filter:blur(2px);z-index:100;align-items:center;justify-content:center;}
.modal-bg.open{display:flex;}
.modal-card{background:var(--white);border-radius:16px;width:400px;overflow:hidden;box-shadow:0 24px 64px rgba(30,27,75,0.18);}
/* Option buttons in modal */
.option-btn{display:flex;align-items:center;gap:12px;padding:12px 16px;border-radius:10px;border:1px solid var(--border);background:var(--white);cursor:pointer;text-align:left;margin-bottom:8px;transition:all 0.15s;}
.option-btn:hover{border-color:var(--indigo);background:var(--cream);}
.option-btn.selected{border-color:var(--indigo);background:var(--cream);box-shadow:0 0 0 3px var(--indigo-ring);}
/* Save popup */
#save-exit-popup{display:none;position:fixed;inset:0;background:rgba(15,14,26,0.45);backdrop-filter:blur(2px);z-index:700;align-items:center;justify-content:center;padding:24px;}
#save-exit-popup.visible{display:flex;}
#save-exit-box{background:#FFFFFF;border-radius:16px;box-shadow:0 24px 64px rgba(30,27,75,0.18);padding:32px;width:100%;max-width:380px;text-align:center;}
#save-exit-box .save-icon{width:48px;height:48px;background:#f0f4ff;border:1px solid #e0e7ff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:20px;margin:0 auto 16px;}
#save-exit-box h3{font-size:18px;font-weight:700;color:var(--ink);margin-bottom:8px;}
#save-exit-box p{font-size:13px;color:var(--muted);line-height:1.6;margin-bottom:20px;}
#save-exit-box .save-cancel{font-size:12px;color:var(--muted);cursor:pointer;text-decoration:underline;background:none;border:none;font-family:'Plus Jakarta Sans',sans-serif;}
#save-exit-box .save-cancel:hover{color:var(--ink);}
/* Why accordion */
/* ── Mobile tab bar (hidden on desktop) ── */
.mob-tabs{display:none;flex-shrink:0;background:#EEF0FF;border-top:1px solid #D4D8FA;padding-bottom:env(safe-area-inset-bottom);}
[data-theme="dark"] .mob-tabs{background:#212840!important;}
.mob-tab-btn{flex:1;padding:11px 8px;border:none;background:transparent;font-family:'Plus Jakarta Sans',sans-serif;font-size:13px;font-weight:500;color:var(--muted);cursor:pointer;border-top:2px solid transparent;transition:color 0.15s,border-color 0.15s;}
.mob-tab-btn.active{color:#6366F1;border-top-color:#6366F1;font-weight:600;}
.mob-dash-btn{background:none;border:none;font-family:'Plus Jakarta Sans',sans-serif;font-size:11.5px;font-weight:500;color:var(--muted);cursor:pointer;padding:11px 12px;white-space:nowrap;flex:none;transition:color 0.15s;}
.mob-dash-btn:hover{color:var(--ink);}
[data-theme="dark"] .mob-tab-btn{color:#6A7490!important;}
[data-theme="dark"] .mob-tab-btn.active{color:#A5B4FC!important;border-top-color:#A5B4FC!important;}
/* ── Responsive ── */
/* Tablet (641px 1024px): top tab bar, full-height panels */
@media (min-width:641px) and (max-width:1024px){
body{height:100dvh;overflow:hidden;}
.arch-layout{flex-direction:column!important;height:100dvh!important;overflow:hidden!important;}
.arch-sidebar{display:none!important;}
.mob-tabs{display:flex!important;order:0;border-top:none!important;border-bottom:1px solid #D4D8FA!important;padding-bottom:0!important;}
.mob-tab-btn{border-top:none;border-bottom:2px solid transparent;}
.mob-tab-btn.active{border-top-color:transparent;border-bottom-color:#6366F1;}
[data-theme="dark"] .mob-tab-btn.active{border-bottom-color:#A5B4FC!important;border-top-color:transparent!important;}
.arch-main{display:none!important;order:1;overflow:hidden!important;flex:1 1 0%!important;height:0!important;min-height:0!important;}
.arch-main.tab-active{display:flex!important;}
.arch-scroll{flex:1 1 0%!important;height:0!important;min-height:0!important;overflow-y:auto!important;-webkit-overflow-scrolling:touch;}
.arch-right{display:none!important;order:1;width:100%!important;border-left:none!important;flex:1 1 0%!important;height:0!important;min-height:0!important;}
.arch-right.tab-active{display:flex!important;}
.arch-right-scroll{flex:1 1 0%!important;height:0!important;min-height:0!important;overflow-y:auto!important;-webkit-overflow-scrolling:touch;}
.arch-right-footer{padding:12px 16px 20px!important;}
.arch-right-footer a{width:80%;display:block;}
.blueprint-row{grid-template-columns:36px 1fr!important;row-gap:10px;padding:13px 18px!important;}
.blueprint-row .opt-btns{grid-column:1 / -1;width:100%;}
}
/* Mobile (≤ 640px): tabbed layout, tabs at bottom */
@media (max-width:640px){
body{height:100dvh;overflow:hidden;}
.arch-layout{flex-direction:column!important;height:100dvh!important;overflow:hidden!important;}
.arch-sidebar{display:none!important;}
.mob-tabs{display:flex!important;order:2;}
.arch-main{display:none!important;order:1;overflow:hidden!important;flex:1 1 0%!important;height:0!important;min-height:0!important;}
.arch-main.tab-active{display:flex!important;}
.arch-scroll{flex:1 1 0%!important;height:0!important;min-height:0!important;overflow-y:auto!important;-webkit-overflow-scrolling:touch;}
.arch-right{display:none!important;order:1;width:100%!important;border-left:none!important;flex:1 1 0%!important;height:0!important;min-height:0!important;}
.arch-right.tab-active{display:flex!important;}
.arch-right-scroll{flex:1 1 0%!important;height:0!important;min-height:0!important;overflow-y:auto!important;-webkit-overflow-scrolling:touch;}
.blueprint-row{grid-template-columns:36px 1fr!important;row-gap:10px;padding:13px 16px!important;}
.blueprint-row .opt-btns{grid-column:1 / -1;width:100%;}
.opt-btn{flex:1;padding:7px 8px!important;font-size:11.5px!important;}
.arch-topbar{padding:14px 16px 12px!important;}
.arch-right-footer{padding:12px 16px 20px!important;}
.arch-right-footer a{width:100%!important;display:block!important;}
.modal-card{width:calc(100vw - 32px)!important;}
#why-popup>div{max-width:calc(100vw - 32px)!important;}
}
/* Mobile with trackpad (laptop narrow): tabs at top */
@media (max-width:640px) and (hover:hover) and (pointer:fine){
.mob-tabs{order:0!important;border-top:none!important;border-bottom:1px solid #D4D8FA!important;padding-bottom:0!important;}
.mob-tab-btn{border-top:none;border-bottom:2px solid transparent;}
.mob-tab-btn.active{border-top-color:transparent;border-bottom-color:#6366F1;}
[data-theme="dark"] .mob-tab-btn.active{border-bottom-color:#A5B4FC!important;}
.arch-main{order:1!important;}
.arch-right{order:1!important;}
}
</style>
</head>
<body>
<div class="arch-layout" style="display:flex;height:100%;overflow:hidden;">
<!-- Mobile tab bar (hidden on desktop, shown on ≤ 640px) -->
<div class="mob-tabs" id="mob-tabs">
<button onclick="saveAndExit()" class="mob-dash-btn">Dashboard</button>
<div style="width:1px;background:var(--border);margin:8px 0;flex-shrink:0;"></div>
<button class="mob-tab-btn active" id="tab-blueprint" onclick="switchArchTab('blueprint')">Blueprint</button>
<button class="mob-tab-btn" id="tab-scope" onclick="switchArchTab('scope')">Scope</button>
</div>
<!-- ── SIDEBAR ── -->
<div class="arch-sidebar" style="width:200px;background:#ffffff;border-right:1px solid #e5e7eb;display:flex;flex-direction:column;padding:18px 12px;flex-shrink:0;">
<div style="padding:0 6px;margin-bottom:26px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<div class="vibn-avatar" style="width:26px;height:26px;background:linear-gradient(135deg,#2E2A5E,#4338CA);border-radius:6px;display:flex;align-items:center;justify-content:center;flex-shrink:0;"><span class="f" style="font-size:13px;font-weight:700;color:#FFFFFF;">V</span></div>
<span class="f" style="font-size:16px;font-weight:700;color:#1a1a1a;letter-spacing:-0.02em;">vibn</span>
</div>
<div id="sidebar-project-name" style="font-size:11px;font-weight:500;color:#9ca3af;padding-left:34px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:none;"></div>
</div>
<div style="font-size:9.5px;font-weight:600;letter-spacing:0.08em;text-transform:uppercase;color:#9ca3af;padding:0 6px;margin-bottom:8px;">MVP Setup</div>
<div style="display:flex;flex-direction:column;gap:2px;flex:1;">
<div class="sidebar-phase">
<div class="phase-dot" style="background:#6366F1;color:#ffffff;"></div>
<div style="font-size:12.5px;color:#6b7280;">Describe</div>
</div>
<div class="sidebar-phase active">
<div class="phase-dot" style="background:#6366F1;color:#ffffff;"></div>
<div><div style="font-size:12.5px;font-weight:600;color:#1a1a1a;">Architect</div><div style="font-size:10px;color:#9ca3af;">What gets built</div></div>
</div>
<div class="sidebar-phase">
<div class="phase-dot" style="background:#e5e7eb;color:#9ca3af;"></div>
<div style="font-size:12.5px;color:#9ca3af;">Design</div>
</div>
<div class="sidebar-phase">
<div class="phase-dot" style="background:#e5e7eb;color:#9ca3af;"></div>
<div style="font-size:12.5px;color:#9ca3af;">Market</div>
</div>
<div class="sidebar-phase">
<div class="phase-dot" style="background:#e5e7eb;color:#9ca3af;"></div>
<div style="font-size:12.5px;color:#9ca3af;">Build MVP</div>
</div>
</div>
<div style="border-top:1px solid #e5e7eb;margin-top:14px;padding-top:12px;">
<button onclick="saveAndExit()" style="display:flex;align-items:center;justify-content:center;gap:7px;width:100%;background:#eef2ff;border:1px solid #e0e7ff;border-radius:8px;padding:9px 10px;cursor:pointer;font-family:'Plus Jakarta Sans',sans-serif;transition:background 0.15s;" onmouseover="this.style.background='#e0e7ff'" onmouseout="this.style.background='#eef2ff'">
<span style="font-size:12px;font-weight:600;color:#6366F1;">Save & go to dashboard</span>
</button>
<button id="dark-toggle" onclick="toggleTheme()" style="margin-top:8px;display:flex;align-items:center;justify-content:center;width:100%;background:transparent;border:1px solid var(--border);border-radius:8px;padding:8px 10px;cursor:pointer;font-family:'Plus Jakarta Sans',sans-serif;font-size:12px;font-weight:500;color:var(--mid);transition:background 0.15s,border-color 0.15s;" onmouseover="this.style.borderColor='#6366F1';this.style.color='#6366F1';" onmouseout="this.style.borderColor='var(--border)';this.style.color='var(--mid)';">🌙 Dark mode</button>
</div>
</div>
<!-- ── MAIN ── -->
<div class="arch-main" style="flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0;position:relative;z-index:1;">
<!-- Top bar -->
<div class="arch-topbar" style="padding:18px 28px 14px;background:var(--white);border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;flex-shrink:0;">
<div>
<div class="f" style="font-size:17px;font-weight:700;color:var(--ink);margin-bottom:3px;">Your product blueprint</div>
<div style="font-size:12.5px;color:var(--muted);">We set everything up — review and confirm to continue.</div>
</div>
</div>
<!-- Scrollable body -->
<div class="arch-scroll" style="flex:1;overflow-y:auto;padding:24px 28px;display:flex;flex-direction:column;gap:12px;">
<!-- Intro message -->
<div style="display:flex;align-items:flex-start;gap:10px;">
<div class="vibn-avatar" style="width:26px;height:26px;background:linear-gradient(135deg,#2E2A5E,#4338CA);border-radius:6px;display:flex;align-items:center;justify-content:center;flex-shrink:0;margin-top:1px;"><span style="font-size:12px;font-weight:700;color:#FFFFFF;">V</span></div>
<div style="max-width:84%;background:#f0f4ff;border:1px solid #e0e7ff;border-radius:4px 12px 12px 12px;padding:11px 14px;font-size:13px;color:var(--ink);line-height:1.65;">
Here's the technical stack we've set up for <strong id="intro-project-name" style="font-weight:600;">your product</strong>. These are the best defaults for an idea like yours — review each decision below and change anything that doesn't feel right.
</div>
</div>
<!-- Blueprint card -->
<div style="background:var(--white);border:1px solid var(--border);border-radius:14px;overflow:visible;box-shadow:0 4px 20px rgba(30,27,75,0.05);">
<!-- Card header -->
<div style="padding:14px 22px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;">
<div style="width:8px;height:8px;border-radius:50%;background:#10B981;flex-shrink:0;"></div>
<span style="font-size:13px;font-weight:600;color:var(--ink);">How vibn will build it</span>
<button onclick="openWhy()" style="margin-left:auto;background:none;border:none;cursor:pointer;font-family:'Plus Jakarta Sans',sans-serif;font-size:12px;color:#6366F1;font-weight:600;padding:0;display:flex;align-items:center;gap:5px;">💡 Why these choices?</button>
</div>
<!-- Frontend -->
<div class="blueprint-row">
<div style="width:32px;height:32px;border-radius:9px;background:var(--indigo-soft);display:flex;align-items:center;justify-content:center;font-size:15px;color:var(--indigo);"></div>
<div>
<div style="font-size:13px;font-weight:600;color:var(--ink);margin-bottom:2px;">Frontend</div>
<div style="font-size:11.5px;color:var(--muted);">Where will your users mostly be when they use your product — at a desk, or on the go? This shapes every screen we design.</div>
</div>
<div class="opt-btns">
<button class="opt-btn selected" data-group="frontend" data-tip="Runs in any browser, desktop & mobile" onclick="selectOpt('frontend',this)">Web app</button>
<button class="opt-btn" data-group="frontend" data-tip="Optimised for phones, still works on desktop" onclick="selectOpt('frontend',this)">Mobile-first</button>
</div>
</div>
<!-- Backend -->
<div class="blueprint-row">
<div style="width:32px;height:32px;border-radius:9px;background:var(--indigo-soft);display:flex;align-items:center;justify-content:center;font-size:15px;color:var(--indigo);"></div>
<div>
<div style="font-size:13px;font-weight:600;color:var(--ink);margin-bottom:2px;">Backend & Database</div>
<div style="font-size:11.5px;color:var(--muted);">The invisible part that stores your users' data and makes everything work behind the scenes</div>
</div>
<div class="opt-btns">
<button class="opt-btn selected" data-group="backend" data-tip="Standard setup, works for almost everything" onclick="selectOpt('backend',this)">API + database</button>
<button class="opt-btn" data-group="backend" data-tip="Live updates between users — great for collaboration" onclick="selectOpt('backend',this)">Real-time</button>
</div>
</div>
<!-- Auth -->
<div class="blueprint-row">
<div style="width:32px;height:32px;border-radius:9px;background:var(--indigo-soft);display:flex;align-items:center;justify-content:center;font-size:15px;color:var(--indigo);"></div>
<div>
<div style="font-size:13px;font-weight:600;color:var(--ink);margin-bottom:2px;">Sign up & Login</div>
<div style="font-size:11.5px;color:var(--muted);">How people create an account and get back in — fewer steps means more people actually sign up</div>
</div>
<div class="opt-btns">
<button class="opt-btn selected" data-group="auth" data-tip="Google & GitHub sign-in — less friction, more sign-ups" onclick="selectOpt('auth',this)">Email + social</button>
<button class="opt-btn" data-group="auth" data-tip="Simpler setup, no third-party login" onclick="selectOpt('auth',this)">Email only</button>
</div>
</div>
<!-- Payments -->
<div class="blueprint-row">
<div style="width:32px;height:32px;border-radius:9px;background:var(--indigo-soft);display:flex;align-items:center;justify-content:center;font-size:15px;color:var(--indigo);"></div>
<div>
<div style="font-size:13px;font-weight:600;color:var(--ink);margin-bottom:2px;">Payments</div>
<div style="font-size:11.5px;color:var(--muted);">How you get paid — Stripe handles the card processing so you never touch sensitive data</div>
</div>
<div class="opt-btns">
<button class="opt-btn selected" data-group="payments" data-tip="Monthly or annual recurring payments" onclick="selectOpt('payments',this)">Subscription</button>
<button class="opt-btn" data-group="payments" data-tip="Pay once, own forever" onclick="selectOpt('payments',this)">One-time</button>
</div>
</div>
<!-- Email -->
<div class="blueprint-row">
<div style="width:32px;height:32px;border-radius:9px;background:var(--indigo-soft);display:flex;align-items:center;justify-content:center;font-size:15px;color:var(--indigo);"></div>
<div>
<div style="font-size:13px;font-weight:600;color:var(--ink);margin-bottom:2px;">Email</div>
<div style="font-size:11.5px;color:var(--muted);">Automated messages sent to your users — from welcome emails on day one to newsletters later</div>
</div>
<div class="opt-btns">
<button class="opt-btn selected" data-group="email" data-tip="Welcome emails, resets & marketing newsletters" onclick="selectOpt('email',this)">Full suite</button>
<button class="opt-btn" data-group="email" data-tip="Just transactional emails — resets and confirmations" onclick="selectOpt('email',this)">Essentials only</button>
</div>
</div>
<!-- Hosting — locked -->
<div class="blueprint-row locked">
<div style="width:32px;height:32px;border-radius:9px;background:#F3F4F6;display:flex;align-items:center;justify-content:center;font-size:15px;color:var(--muted);"></div>
<div>
<div style="font-size:13px;font-weight:600;color:var(--ink);margin-bottom:2px;">Hosting</div>
<div style="font-size:11.5px;color:var(--muted);">Where your product lives — on your own servers, so no one else controls your data or your costs</div>
</div>
<div class="opt-btns">
<button class="opt-btn selected" style="cursor:default;">Your servers 🔒</button>
<button class="opt-btn why-btn" onclick="openHostingWhy()" style="border-radius:0 7px 7px 0;border-right:none;color:#6366F1;font-weight:600;">Why?</button>
</div>
</div>
</div>
<!-- Reassurance note -->
<div style="display:flex;align-items:center;gap:10px;padding:11px 16px;background:#f0f4ff;border:1px solid #e0e7ff;border-radius:10px;">
<span style="font-size:16px;flex-shrink:0;">💬</span>
<p style="font-size:12px;color:var(--mid);line-height:1.55;margin:0;">Not sure about any of these? Don't worry — you can change them anytime before we start building.</p>
</div>
</div>
</div>
<!-- ── RIGHT PANEL ── -->
<div class="arch-right" style="width:384px;border-left:1px solid var(--border);background:#f5f3ff;display:flex;flex-direction:column;flex-shrink:0;">
<!-- Panel header — matches Describe PRD style -->
<div style="flex-shrink:0;padding:18px 0 0;">
<div style="margin:0 16px;padding-bottom:14px;border-bottom:1px solid #c7d2fe;">
<div style="font-size:15px;font-weight:800;letter-spacing:0.04em;text-transform:uppercase;color:#4338ca;margin-bottom:5px;">What your users will be able to do</div>
<div style="font-size:12px;color:var(--ink3);line-height:1.5;">10 screens covering the full user journey, ready to design.</div>
</div>
</div>
<!-- Scrollable content -->
<div class="arch-right-scroll" style="flex:1;overflow-y:auto;padding:16px;">
<!-- Pages -->
<div style="font-size:9.5px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:var(--muted);margin-bottom:8px;">Pages</div>
<div style="background:var(--white);border:1px solid var(--border);border-radius:10px;overflow:hidden;margin-bottom:16px;">
<div style="padding:5px 12px;background:#fafaff;border-bottom:1px solid var(--border);">
<span style="font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted);">Public</span>
</div>
<div class="deliverable-row"><div style="width:5px;height:5px;border-radius:50%;background:#10B981;flex-shrink:0;"></div>Discover your product</div>
<div class="deliverable-row"><div style="width:5px;height:5px;border-radius:50%;background:#10B981;flex-shrink:0;"></div>See your pricing</div>
<div class="deliverable-row"><div style="width:5px;height:5px;border-radius:50%;background:#10B981;flex-shrink:0;"></div>Learn about you</div>
<div style="padding:5px 12px;background:#fafaff;border-top:1px solid var(--border);border-bottom:1px solid var(--border);">
<span style="font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted);">Auth</span>
</div>
<div class="deliverable-row"><div style="width:5px;height:5px;border-radius:50%;background:#10B981;flex-shrink:0;"></div>Create an account</div>
<div class="deliverable-row"><div style="width:5px;height:5px;border-radius:50%;background:#10B981;flex-shrink:0;"></div>Sign back in</div>
<div class="deliverable-row"><div style="width:5px;height:5px;border-radius:50%;background:#10B981;flex-shrink:0;"></div>Reset their password</div>
<div style="padding:5px 12px;background:#fafaff;border-top:1px solid var(--border);border-bottom:1px solid var(--border);">
<span style="font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted);">App</span>
</div>
<div class="deliverable-row"><div style="width:5px;height:5px;border-radius:50%;background:#10B981;flex-shrink:0;"></div>Use the dashboard</div>
<div class="deliverable-row"><div style="width:5px;height:5px;border-radius:50%;background:#10B981;flex-shrink:0;"></div>Manage their settings</div>
<div style="padding:5px 12px;background:#fafaff;border-top:1px solid var(--border);border-bottom:1px solid var(--border);">
<span style="font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted);">Payments</span>
</div>
<div class="deliverable-row"><div style="width:5px;height:5px;border-radius:50%;background:#10B981;flex-shrink:0;"></div>Subscribe and pay</div>
<div class="deliverable-row" style="border-bottom:none;"><div style="width:5px;height:5px;border-radius:50%;background:#10B981;flex-shrink:0;"></div>Manage their plan</div>
</div>
<!-- Infrastructure -->
<div style="font-size:9.5px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:var(--muted);margin-bottom:8px;">Infrastructure</div>
<div style="background:var(--white);border:1px solid var(--border);border-radius:10px;overflow:hidden;">
<div style="display:flex;align-items:center;gap:10px;padding:10px 12px;border-bottom:1px solid var(--border);">
<div style="width:28px;height:28px;border-radius:7px;background:var(--indigo-soft);display:flex;align-items:center;justify-content:center;font-size:13px;flex-shrink:0;">🖥</div>
<div><div style="font-size:12px;font-weight:600;color:var(--ink);">Your own servers</div><div style="font-size:11px;color:var(--muted);">No platform lock-in, ever</div></div>
</div>
<div style="display:flex;align-items:center;gap:10px;padding:10px 12px;border-bottom:1px solid var(--border);">
<div style="width:28px;height:28px;border-radius:7px;background:var(--indigo-soft);display:flex;align-items:center;justify-content:center;font-size:13px;flex-shrink:0;">🔁</div>
<div><div style="font-size:12px;font-weight:600;color:var(--ink);">Auto-deploy via Coolify</div><div style="font-size:11px;color:var(--muted);">Every push goes live instantly</div></div>
</div>
<div style="display:flex;align-items:center;gap:10px;padding:10px 12px;">
<div style="width:28px;height:28px;border-radius:7px;background:var(--indigo-soft);display:flex;align-items:center;justify-content:center;font-size:13px;flex-shrink:0;">🔒</div>
<div><div style="font-size:12px;font-weight:600;color:var(--ink);">Code stored in Gitea</div><div style="font-size:11px;color:var(--muted);">Private repo, yours alone</div></div>
</div>
</div>
<!-- Timeline / cost signal -->
<div style="display:flex;align-items:center;justify-content:center;gap:16px;padding:10px 0 2px;">
<span style="font-size:11px;color:var(--muted);display:flex;align-items:center;gap:5px;"><span>~34 weeks to build</span></span>
<span style="font-size:11px;color:var(--muted);">·</span>
<span style="font-size:11px;color:var(--muted);display:flex;align-items:center;gap:5px;">💰 <span>No platform fees</span></span>
</div>
</div>
<!-- Footer -->
<div class="arch-right-footer" style="border-top:1px solid var(--border);padding:9px 0 13px;flex-shrink:0;display:flex;flex-direction:column;align-items:center;">
<p style="font-size:11.5px;color:var(--muted);text-align:center;margin:0 0 10px;line-height:1.5;">All set — let's decide how it looks.</p>
<a href="07_design.html" style="text-decoration:none;display:block;width:80%;">
<button class="btn-primary" style="width:100%;padding:12px 14px;border-radius:8px;">Next: Design</button>
</a>
</div>
</div>
</div>
<!-- ── WHY POPUP ── -->
<div id="why-popup" style="display:none;position:fixed;inset:0;background:rgba(15,14,26,0.45);backdrop-filter:blur(2px);z-index:200;align-items:center;justify-content:center;padding:24px;" onclick="closeWhy()">
<div style="background:var(--white);border-radius:16px;width:100%;max-width:480px;box-shadow:0 24px 64px rgba(30,27,75,0.18);padding:32px;" onclick="event.stopPropagation()">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:20px;">
<span style="font-size:20px;">💡</span>
<h3 style="font-size:17px;font-weight:700;color:var(--ink);letter-spacing:-0.02em;">Why we chose this for your product</h3>
</div>
<p style="font-size:13px;color:var(--mid);line-height:1.8;margin-bottom:12px;">Based on your idea, vibn picked a <strong style="color:var(--ink);font-weight:600;">web app with subscription billing</strong> — the fastest path to recurring revenue for a SaaS product.</p>
<p style="font-size:13px;color:var(--mid);line-height:1.8;margin-bottom:12px;"><strong style="color:var(--ink);font-weight:600;">Email + social login</strong> keeps sign-up friction low. Most people won't create a password for something they haven't tried yet — one click with Google removes that barrier.</p>
<p style="font-size:13px;color:var(--mid);line-height:1.8;margin-bottom:24px;"><strong style="color:var(--ink);font-weight:600;">Your own hosting</strong> means you own the infrastructure outright. No platform lock-in, no surprise price hikes. Coolify + Gitea are already configured to your account.</p>
<button onclick="closeWhy()" class="btn-primary" style="width:100%;padding:12px;">Got it</button>
</div>
</div>
<!-- ── SAVE POPUP ── -->
<div id="save-exit-popup">
<div id="save-exit-box">
<div class="save-icon"></div>
<h3>Your progress is saved.</h3>
<p>You can come back to this project anytime from your dashboard — everything will be exactly where you left it.</p>
<button onclick="window.location.href='03_dashboard.html'" style="width:100%;background:linear-gradient(135deg,#2e2a5e,#4338ca);color:#fff;border:none;border-radius:10px;padding:12px;font-family:'Plus Jakarta Sans',sans-serif;font-size:14px;font-weight:600;cursor:pointer;margin-bottom:10px;">Got it, go to dashboard</button>
<button class="save-cancel" onclick="cancelSaveExit()">Stay on this page</button>
</div>
</div>
<!-- ── MODAL ── -->
<div id="modal" class="modal-bg" onclick="closeModal()">
<div class="modal-card" onclick="event.stopPropagation()">
<div style="padding:18px 22px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;">
<span id="modal-title" class="f" style="font-size:15px;font-weight:700;color:var(--ink);"></span>
<button onclick="closeModal()" style="background:transparent;border:none;color:var(--muted);cursor:pointer;font-size:18px;line-height:1;">×</button>
</div>
<div style="padding:16px 22px;" id="modal-options"></div>
<div style="padding:10px 22px 18px;">
<button onclick="closeModal()" class="btn-primary" style="width:100%;padding:12px;border-radius:9px;">Got it</button>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function(){
try {
var name = localStorage.getItem('vibn_project_name') || 'My project';
var el = document.getElementById('sidebar-project-name');
el.textContent = name;
el.style.display = 'block';
var intro = document.getElementById('intro-project-name');
if(intro) intro.textContent = name;
} catch(e){}
// Init Blueprint tab on tablet + mobile
if(window.innerWidth <= 1024){
document.querySelector('.arch-main').classList.add('tab-active');
}
});
function switchArchTab(tab){
var main = document.querySelector('.arch-main');
var right = document.querySelector('.arch-right');
var btnBlueprint = document.getElementById('tab-blueprint');
var btnScope = document.getElementById('tab-scope');
if(tab === 'blueprint'){
main.classList.add('tab-active');
right.classList.remove('tab-active');
btnBlueprint.classList.add('active');
btnScope.classList.remove('active');
} else {
right.classList.add('tab-active');
main.classList.remove('tab-active');
btnScope.classList.add('active');
btnBlueprint.classList.remove('active');
}
}
function saveAndExit(){ document.getElementById('save-exit-popup').classList.add('visible'); }
function cancelSaveExit(){ document.getElementById('save-exit-popup').classList.remove('visible'); }
function openWhy(){ document.getElementById('why-popup').style.display='flex'; }
function closeWhy(){ document.getElementById('why-popup').style.display='none'; }
function selectOpt(group, el){
document.querySelectorAll('[data-group="'+group+'"]').forEach(function(b){ b.classList.remove('selected'); });
el.classList.add('selected');
}
function openHostingWhy(){
document.getElementById('modal-title').textContent = 'Why your own servers?';
document.getElementById('modal-options').innerHTML =
'<p style="font-size:13px;color:var(--mid);line-height:1.8;margin-bottom:12px;"><strong style="color:var(--ink)">Most platforms rent you server space</strong> — which means they control your data, your uptime, and your bill. The day they raise prices or shut down, your product goes with it.</p>' +
'<p style="font-size:13px;color:var(--mid);line-height:1.8;margin-bottom:12px;"><strong style="color:var(--ink)">With vibn, your product lives on your own servers.</strong> You own the infrastructure outright. Nobody can lock you out, hike your costs, or access your users\u2019 data without your permission.</p>' +
'<p style="font-size:13px;color:var(--mid);line-height:1.8;">We handle the hard part: <a href="https://coolify.io" target="_blank" style="color:var(--indigo);font-weight:600;text-decoration:none;">Coolify</a> auto-deploys your code every time you push an update, and <a href="https://gitea.com" target="_blank" style="color:var(--indigo);font-weight:600;text-decoration:none;">Gitea</a> stores your codebase securely. You get full control of your infrastructure \u2014 without needing to know how any of it works.</p>';
document.getElementById('modal').classList.add('open');
}
function openModal(title, opt1, desc1, opt2, desc2){
document.getElementById('modal-title').textContent = title;
document.getElementById('modal-options').innerHTML = '<p style="font-size:13px;color:var(--mid);line-height:1.6;">'+desc1+'</p>';
document.getElementById('modal').classList.add('open');
}
function closeModal(){ document.getElementById('modal').classList.remove('open'); }
function showTip(el){
hideTip();
var tip=document.createElement('div');
tip.id='vibn-tip';
tip.textContent=el.dataset.tip;
tip.style.cssText='position:fixed;background:linear-gradient(135deg,#2E2A5E,#4338CA);color:#fff;font-size:11px;font-weight:500;line-height:1.5;padding:6px 10px;border-radius:6px;white-space:nowrap;z-index:9999;pointer-events:none;box-shadow:0 4px 16px rgba(67,56,202,0.35);font-family:Plus Jakarta Sans,sans-serif;transition:opacity 0.1s;';
document.body.appendChild(tip);
var r=el.getBoundingClientRect();
tip.style.left=(r.left+r.width/2-tip.offsetWidth/2)+'px';
tip.style.top=(r.top-tip.offsetHeight-9)+'px';
var arrow=document.createElement('div');
arrow.id='vibn-tip-arrow';
arrow.style.cssText='position:fixed;border:5px solid transparent;border-top-color:#4338CA;z-index:9999;pointer-events:none;';
document.body.appendChild(arrow);
arrow.style.left=(r.left+r.width/2-5)+'px';
arrow.style.top=(r.top-9)+'px';
}
function hideTip(){
var t=document.getElementById('vibn-tip');
var a=document.getElementById('vibn-tip-arrow');
if(t)t.remove();
if(a)a.remove();
}
document.addEventListener('mouseover',function(e){var b=e.target.closest('.opt-btn[data-tip]');if(b)showTip(b);});
document.addEventListener('mouseout',function(e){var b=e.target.closest('.opt-btn[data-tip]');if(b)hideTip();});
function toggleTheme(){const html=document.documentElement;const isDark=html.dataset.theme==='dark';html.dataset.theme=isDark?'':'dark';document.getElementById('dark-toggle').textContent=isDark?'🌙 Dark mode':'☀️ Light mode';localStorage.setItem('vibn-theme',isDark?'':'dark');}
(function(){const saved=localStorage.getItem('vibn-theme');if(saved==='dark'){document.documentElement.dataset.theme='dark';document.addEventListener('DOMContentLoaded',function(){const btn=document.getElementById('dark-toggle');if(btn)btn.textContent='☀️ Light mode';});}})();
</script>
</body></html>

44
justine/README.md Normal file
View File

@@ -0,0 +1,44 @@
# vibn — UX Screen Pack
Design system: **Ink & parchment** — Lora serif + Inter sans, no colour accent.
All HTML files open directly in any browser — no build step required.
## Complete screen inventory
| File | Screen | Interactive? |
|------|--------|-------------|
| `01_homepage.html` | Marketing homepage | Scroll |
| `02_signup.html` | Sign up (3 steps) | ✓ Click through steps, mode selection |
| `03_dashboard.html` | Projects dashboard | ✓ Projects / Clients / Invoices / Costs nav |
| `04_welcome.html` | Welcome phase | Static |
| `05_discover.html` | Discover phase | ✓ Live chat, PRD fills in |
| `06_architect.html` | Architect phase | ✓ Change modal per block |
| `07_design.html` | Design phase | ✓ Live style preview switching |
| `08_market.html` | Market phase | ✓ Voice sliders, Topics, Website style |
| `09_build.html` | Build phase | ✓ Review + animated 12-step pipeline |
| `10_vibe_editor.html` | Vibe editor (post-launch) | ✓ Chat, preview updates, deploy status |
| `vibn-website.jsx` | Marketing site | React component |
| `vibn-dashboard.jsx` | Dashboard + billing | React component |
| `00_design-tokens.css` | Design tokens | Reference |
## Full user flow
01 Homepage → 02 Sign up → 03 Dashboard → 04 Welcome →
05 Discover → 06 Architect → 07 Design → 08 Market →
09 Build → 10 Vibe editor
## Design tokens
| Token | Value | Usage |
|-------|-------|-------|
| `--ink` | `#1a1510` | Primary text, buttons |
| `--ink3` | `#444441` | Secondary text |
| `--mid` | `#5f5e5a` | Body text |
| `--muted` | `#888780` | Labels, captions |
| `--stone` | `#b4b2a9` | Disabled, hints |
| `--cream` | `#f1efe8` | Surface tint, hover |
| `--paper` | `#f7f4ee` | Page background |
| `--white` | `#fdfcfa` | Card backgrounds |
| `--border` | `#e8e2d9` | All borders |
Heading font: **Lora** (serif)
Body font: **Inter** (sans-serif)

BIN
justine/favicon_clean.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -0,0 +1,119 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><link rel="icon" href="favicon_clean.ico">
<title>Sign in Google Accounts</title>
<link href="https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;600&family=Roboto:wght@400;500&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0;}
body{font-family:'Roboto',sans-serif;background:#FFFFFF;display:flex;flex-direction:column;align-items:center;min-height:100vh;padding:40px 24px;}
.card{width:100%;max-width:400px;border:1px solid #DADCE0;border-radius:8px;padding:40px 40px 28px;display:flex;flex-direction:column;align-items:center;}
.google-logo{margin-bottom:24px;}
h1{font-family:'Google Sans',sans-serif;font-size:24px;font-weight:400;color:#202124;margin-bottom:8px;text-align:center;}
.subtitle{font-size:16px;color:#202124;margin-bottom:24px;text-align:center;}
/* Account tile */
.account-tile{width:100%;border:1px solid #DADCE0;border-radius:8px;padding:12px 16px;display:flex;align-items:center;gap:16px;cursor:pointer;transition:background 0.15s;margin-bottom:12px;}
.account-tile:hover{background:#F8F9FA;}
.avatar{width:40px;height:40px;border-radius:50%;background:linear-gradient(135deg,#4285F4,#34A853);display:flex;align-items:center;justify-content:center;font-family:'Google Sans',sans-serif;font-size:16px;font-weight:500;color:#FFFFFF;flex-shrink:0;}
.account-info{flex:1;text-align:left;}
.account-name{font-family:'Google Sans',sans-serif;font-size:14px;font-weight:500;color:#202124;margin-bottom:2px;}
.account-email{font-size:13px;color:#5F6368;}
.chevron{color:#5F6368;}
/* Add account */
.add-account{width:100%;border:1px solid #DADCE0;border-radius:8px;padding:12px 16px;display:flex;align-items:center;gap:16px;cursor:pointer;transition:background 0.15s;margin-bottom:24px;}
.add-account:hover{background:#F8F9FA;}
.add-icon{width:40px;height:40px;border-radius:50%;background:#F1F3F4;display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:20px;color:#5F6368;}
.add-label{font-size:14px;color:#202124;}
.divider{width:100%;height:1px;background:#E8EAED;margin-bottom:20px;}
/* Continue button */
.btn-continue{background:#1A73E8;color:#FFFFFF;border:none;border-radius:4px;padding:10px 24px;font-family:'Google Sans',sans-serif;font-size:14px;font-weight:500;cursor:pointer;transition:background 0.15s,box-shadow 0.15s;box-shadow:0 1px 2px rgba(0,0,0,0.2);}
.btn-continue:hover{background:#1765CC;box-shadow:0 2px 6px rgba(0,0,0,0.2);}
.footer{margin-top:auto;padding-top:24px;display:flex;gap:24px;justify-content:center;}
.footer a{font-size:12px;color:#5F6368;text-decoration:none;}
.footer a:hover{text-decoration:underline;}
/* Loading state */
.loading{display:none;flex-direction:column;align-items:center;gap:16px;margin-top:20px;}
.spinner{width:32px;height:32px;border:3px solid #E8EAED;border-top-color:#1A73E8;border-radius:50%;animation:spin 0.8s linear infinite;}
@keyframes spin{to{transform:rotate(360deg);}}
.loading-text{font-size:14px;color:#5F6368;}
</style>
</head>
<body>
<div class="card">
<!-- Google logo -->
<div class="google-logo">
<svg width="75" height="24" viewBox="0 0 75 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M30.5 12.3c0-.7-.1-1.4-.2-2h-9.6v3.8h5.5c-.2 1.3-1 2.4-2.1 3.1v2.6h3.4c2-1.8 3-4.5 3-7.5z" fill="#4285F4"/>
<path d="M20.7 19.9c2.7 0 5-.9 6.7-2.4l-3.4-2.6c-.9.6-2 1-3.3 1-2.6 0-4.7-1.7-5.5-4.1H11.7v2.7c1.7 3.4 5.2 5.4 9 5.4z" fill="#34A853"/>
<path d="M15.2 11.8c-.2-.6-.3-1.2-.3-1.8s.1-1.2.3-1.8V7.5H11.7C11 8.8 10.7 10.3 10.7 12s.3 3.2 1 4.5l3.5-2.7z" fill="#FBBC05"/>
<path d="M20.7 5.9c1.4 0 2.7.5 3.7 1.5l2.8-2.8C25.7 3 23.4 2 20.7 2c-3.8 0-7.3 2-9 5.4l3.5 2.7c.8-2.4 2.9-4.2 5.5-4.2z" fill="#EA4335"/>
<text x="36" y="17" font-family="'Product Sans',Roboto,sans-serif" font-size="16" fill="#5F6368">Google</text>
</svg>
</div>
<h1>Sign in</h1>
<p class="subtitle">to continue to vibn</p>
<!-- Account selector (shown by default) -->
<div id="account-select" style="width:100%;">
<div class="account-tile" onclick="selectAccount()">
<div class="avatar">J</div>
<div class="account-info">
<div class="account-name">Jane Smith</div>
<div class="account-email">jane@gmail.com</div>
</div>
<svg class="chevron" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
</div>
<div class="add-account" onclick="selectAccount()">
<div class="add-icon"></div>
<div class="add-label">Use another account</div>
</div>
<div class="divider"></div>
<p style="font-size:12px;color:#5F6368;text-align:center;line-height:1.6;">To continue, Google will share your name, email address, and profile picture with vibn.</p>
</div>
<!-- Loading state (shown after selection) -->
<div class="loading" id="loading">
<div class="spinner"></div>
<div class="loading-text">Signing you in…</div>
</div>
</div>
<div class="footer">
<a href="#">Help</a>
<a href="#">Privacy</a>
<a href="#">Terms</a>
</div>
<script>
function selectAccount(){
// Show loading
document.getElementById('account-select').style.display='none';
document.getElementById('loading').style.display='flex';
// After brief delay, notify parent and close
setTimeout(function(){
if(window.opener){
window.opener.postMessage({type:'google-auth-success',name:'Jane Smith',email:'jane@gmail.com'},'*');
}
window.close();
}, 1000);
}
</script>
</body>
</html>

41
justine/index.html Normal file
View File

@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>vibn — UX screen pack (local)</title>
<link rel="icon" href="favicon_clean.ico">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: #f7f4ee;
color: #1a1510;
padding: 48px 24px;
max-width: 42rem;
margin: 0 auto;
line-height: 1.5;
}
h1 { font-size: 1.35rem; margin-bottom: 0.5rem; }
p { color: #5f5e5a; font-size: 0.95rem; margin-bottom: 1.5rem; }
ol { padding-left: 1.25rem; }
li { margin: 0.5rem 0; }
a { color: #1a1510; font-weight: 600; }
a:hover { text-decoration: underline; }
.note { font-size: 0.85rem; color: #888780; margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #e8e2d9; }
</style>
</head>
<body>
<h1>vibn — UX screen pack</h1>
<p>Static HTML prototypes. Suggested flow:</p>
<ol>
<li><a href="01_homepage.html">01 — Homepage</a></li>
<li><a href="02_signup.html">02 — Sign up</a></li>
<li><a href="03_dashboard.html">03 — Dashboard</a></li>
<li><a href="05_describe.html">05 — Describe / discover</a></li>
<li><a href="06_architect.html">06 — Architect</a></li>
</ol>
<p style="margin-top:1.25rem;"><a href="google-auth-popup.html">Google auth popup</a> · <a href="00_design-tokens.css">Design tokens (CSS)</a></p>
<p class="note"><code>vibn-website.jsx</code> and <code>vibn-dashboard.jsx</code> are React source references — use the HTML screens here, or wire those into a React app separately.</p>
</body>
</html>

View File

@@ -0,0 +1,8 @@
{
"folders": [
{
"path": ".."
}
],
"settings": {}
}

1055
justine/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

11
justine/package.json Normal file
View File

@@ -0,0 +1,11 @@
{
"name": "justine-vibn-ux",
"private": true,
"scripts": {
"dev": "serve -l 3040",
"start": "serve -l 3040"
},
"devDependencies": {
"serve": "^14.2.4"
}
}

View File

@@ -0,0 +1,26 @@
/* vibn Design Tokens — Ink & Parchment */
/* Import this in any HTML file or reference these values */
@import url('https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;0,600;0,700;1,400;1,600&family=Inter:wght@400;500;600&display=swap');
:root {
--ink: #1a1510;
--ink2: #2c2c2a;
--ink3: #444441;
--mid: #5f5e5a;
--muted: #888780;
--stone: #b4b2a9;
--parch: #d3d1c7;
--cream: #f1efe8;
--paper: #f7f4ee;
--white: #fdfcfa;
--border: #e8e2d9;
--serif: 'Lora', Georgia, serif;
--sans: 'Inter', sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: var(--sans); background: var(--paper); color: var(--ink); }
.f { font-family: var(--serif); }
.s { font-family: var(--sans); }

View File

@@ -0,0 +1,335 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><link rel="icon" href="favicon_clean.ico">
<title>vibn — Homepage</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0;}
:root{--ink:#1A1A1A;--ink2:#2c2c2a;--ink3:#444441;--mid:#6B7280;--muted:#9CA3AF;--stone:#b4b2a9;--parch:#d3d1c7;--cream:#f1efe8;--paper:#f7f4ee;--white:#FFFFFF;--border:#E5E7EB;--serif:'Plus Jakarta Sans',sans-serif;--sans:'Plus Jakarta Sans',sans-serif;}
body{font-family:var(--sans);background:linear-gradient(to bottom,#FAFAFE,#F0EEFF);min-height:100vh;color:var(--ink);}
.f{font-family:var(--serif);}
nav{background:rgba(250,250,250,0.95);border-bottom:1px solid var(--border);padding:0 52px;height:62px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:50;}
.nav-links{display:flex;gap:32px;align-items:center;}
.btn-ink{background:linear-gradient(135deg,#2E2A5E,#4338CA);color:#FFFFFF;border:none;border-radius:8px;padding:9px 22px;font-family:var(--sans);font-size:13.5px;font-weight:600;cursor:pointer;box-shadow:0 10px 25px rgba(30,27,75,0.15);transition:box-shadow 0.2s ease,transform 0.2s ease;}
.btn-ink:hover{box-shadow:0 10px 25px rgba(30,27,75,0.15),0 0 0 6px rgba(99,102,241,0.15);transform:translateY(-1px);}
.btn-ink-lg{background:linear-gradient(135deg,#2E2A5E,#4338CA);color:#FFFFFF;border:none;border-radius:10px;padding:15px 36px;font-family:var(--sans);font-size:15px;font-weight:600;cursor:pointer;box-shadow:0 10px 25px rgba(30,27,75,0.15);transition:box-shadow 0.2s ease,transform 0.2s ease;}
.btn-ink-lg:hover{box-shadow:0 10px 25px rgba(30,27,75,0.15),0 0 0 6px rgba(99,102,241,0.15);transform:translateY(-1px);}
.gradient-em{background:linear-gradient(to right,#6366F1,#8B5CF6);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;font-style:italic;}
.gradient-text{background:linear-gradient(to right,#6366F1,#8B5CF6);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;}
.gradient-num{background:linear-gradient(135deg,#2E2A5E,#4338CA);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;}
.empathy-card{background:var(--white);border:1px solid var(--border);border-left:3px solid rgba(99,102,241,0.8);border-radius:12px;padding:18px 20px;display:flex;gap:14px;align-items:flex-start;box-shadow:0 10px 30px rgba(30,27,75,0.05);transition:border-color 0.2s ease,background 0.2s ease;}
.empathy-card:hover{border-color:#6366F1;background:#FAFAFF;}
/* ── Layout grid classes (for responsive overrides) ── */
.hero-grid{display:grid;grid-template-columns:1fr 1fr;gap:96px;align-items:center;}
.empathy-grid{display:grid;grid-template-columns:1fr 1fr;gap:72px;align-items:center;}
.phase-grid{display:grid;grid-template-columns:1fr 1fr;border:1px solid rgba(99,102,241,0.2);border-radius:14px;overflow:hidden;}
.wyg-grid{display:grid;grid-template-columns:1fr 1fr 1fr;}
.quote-grid{display:grid;grid-template-columns:1fr 1.6fr 1fr;gap:28px;align-items:center;margin-bottom:20px;}
.stats-grid{display:grid;grid-template-columns:1fr 1fr 1fr 1fr;}
.footer-tagline{display:block;font-size:12px;color:var(--muted);margin-top:4px;font-family:var(--sans);}
/* ── Hamburger ── */
.hamburger{display:none;flex-direction:column;gap:5px;background:none;border:none;cursor:pointer;padding:6px;}
.hamburger span{display:block;width:22px;height:2px;background:var(--ink);border-radius:2px;transition:transform 0.25s ease,opacity 0.25s ease;}
.hamburger.open span:nth-child(1){transform:translateY(7px) rotate(45deg);}
.hamburger.open span:nth-child(2){opacity:0;}
.hamburger.open span:nth-child(3){transform:translateY(-7px) rotate(-45deg);}
/* Mobile drawer */
.mobile-menu{display:none;position:fixed;top:62px;left:0;right:0;background:rgba(250,250,250,0.98);border-bottom:1px solid var(--border);padding:20px 24px 28px;z-index:49;flex-direction:column;gap:0;box-shadow:0 8px 24px rgba(30,27,75,0.08);}
.mobile-menu.open{display:flex;}
.mobile-menu a{font-size:15px;color:var(--ink);text-decoration:none;padding:13px 0;border-bottom:1px solid var(--border);font-weight:500;}
.mobile-menu a:last-of-type{border-bottom:none;}
.mobile-menu .mobile-menu-cta{margin-top:18px;}
/* ── Mobile ── */
@media (max-width:768px){
nav{padding:0 20px;}
.nav-links{display:none;}
.hamburger{display:flex;}
.nav-right-btns{display:none;}
.hero-grid{grid-template-columns:1fr;gap:44px;}
.hero-section{padding:52px 24px 48px !important;}
.empathy-section{padding:56px 24px !important;}
.empathy-grid{grid-template-columns:1fr;gap:36px;}
.how-section{padding:64px 24px !important;}
.phase-grid{grid-template-columns:1fr;}
.phase-grid > div{border-right:none !important;padding:28px 24px !important;}
.wyg-grid{grid-template-columns:1fr;}
.wyg-grid > div{border-right:none !important;border-bottom:1px solid var(--border);padding:32px 24px !important;}
.wyg-grid > div:last-child{border-bottom:none;}
.wyg-section{padding:0 24px !important;}
.quote-grid{grid-template-columns:1fr;}
.quote-side{display:none !important;}
.quote-section{padding:32px 24px 28px !important;}
.stats-grid{grid-template-columns:1fr 1fr;}
.stats-grid > div{padding:28px 16px !important;}
.stats-grid > div:nth-child(odd){padding-left:0 !important;}
.stats-grid > div:nth-child(3),.stats-grid > div:nth-child(4){border-top:1px solid var(--border);}
.stats-grid > div:nth-child(even){border-right:none !important;}
.stats-section{padding:0 24px !important;}
.cta-section{padding:56px 20px !important;}
.cta-card{padding:44px 28px !important;}
.hero-h1{font-size:40px !important;line-height:1.1 !important;}
.hero-sub{font-size:15px !important;}
footer{flex-direction:column;gap:20px;text-align:center;padding:32px 24px !important;}
.footer-links{flex-wrap:wrap;justify-content:center;}
}
</style>
</head>
<body>
<nav>
<div style="display:flex;align-items:center;gap:10px;">
<div class="logo-box" style="width:30px;height:30px;background:linear-gradient(135deg,#2E2A5E,#4338CA);border-radius:7px;display:flex;align-items:center;justify-content:center;"><span class="f" style="font-size:15px;font-weight:700;color:#FFFFFF;">V</span></div>
<span class="f" style="font-size:19px;font-weight:700;color:var(--ink);letter-spacing:-0.02em;">vibn</span>
</div>
<div class="nav-links">
<a href="#how-it-works" style="font-size:14px;color:var(--muted);text-decoration:none;">How it works</a>
<a href="#" style="font-size:14px;color:var(--muted);text-decoration:none;">Pricing</a>
<a href="#" style="font-size:14px;color:var(--muted);text-decoration:none;">Stories</a>
<a href="#" style="font-size:14px;color:var(--muted);text-decoration:none;">Blog</a>
</div>
<div class="nav-right-btns" style="display:flex;align-items:center;gap:12px;">
<a href="03_dashboard.html" style="font-size:14px;color:#6366F1;font-weight:600;text-decoration:none;">Log in</a>
<a href="02_signup.html"><button class="btn-ink">Get started free</button></a>
</div>
<button class="hamburger" id="hamburger" aria-label="Open menu" onclick="toggleMenu()">
<span></span><span></span><span></span>
</button>
</nav>
<!-- Mobile drawer -->
<div class="mobile-menu" id="mobile-menu">
<a href="#how-it-works" onclick="closeMenu()">How it works</a>
<a href="#" onclick="closeMenu()">Pricing</a>
<a href="#" onclick="closeMenu()">Stories</a>
<a href="#" onclick="closeMenu()">Blog</a>
<a href="03_dashboard.html" style="color:#6366F1;font-weight:600;" onclick="closeMenu()">Log in</a>
<div class="mobile-menu-cta">
<a href="02_signup.html"><button class="btn-ink-lg" style="width:100%;">Get started free</button></a>
</div>
</div>
<!-- HERO -->
<section class="hero-section" style="max-width:980px;margin:0 auto;padding:88px 52px 72px;">
<div class="hero-grid">
<!-- Left: copy -->
<div>
<div style="font-size:11px;font-weight:600;letter-spacing:0.13em;text-transform:uppercase;color:var(--muted);margin-bottom:22px;">For non-technical founders</div>
<h1 class="f hero-h1" style="font-size:58px;font-weight:700;color:var(--ink);letter-spacing:-0.03em;line-height:1.06;margin-bottom:28px;">
You have the idea.<br>We handle<br><em class="gradient-em">everything else.</em>
</h1>
<p class="hero-sub" style="font-size:17px;color:var(--mid);line-height:1.75;">You describe it. Vibn builds it, launches it, and markets it. From idea to <strong style="color:var(--ink);">live</strong> product in <strong style="color:var(--ink);">72 hours</strong> — no code, no agencies, no waiting.</p>
</div>
<!-- Right: product moment card -->
<div style="flex-shrink:0;">
<div style="background:var(--white);border:1px solid var(--border);border-radius:16px;overflow:hidden;box-shadow:0 20px 60px rgba(30,27,75,0.05);">
<!-- Input area -->
<div style="padding:24px 26px 20px;background:#FCFCFF;border-bottom:1px solid var(--border);">
<div style="font-size:10px;font-weight:600;letter-spacing:0.12em;text-transform:uppercase;color:var(--muted);margin-bottom:12px;">Your idea</div>
<p class="f" style="font-size:15px;font-style:italic;color:var(--ink);line-height:1.65;margin-bottom:14px;">"I want to build a booking tool for independent personal trainers."</p>
<div style="display:flex;justify-content:flex-end;">
<span style="font-size:11px;color:var(--muted);background:var(--white);border:1px solid var(--border);border-radius:5px;padding:3px 9px;letter-spacing:0.04em;">↵ Enter</span>
</div>
</div>
<!-- Output area -->
<div style="padding:20px 26px 24px;background:var(--white);">
<div style="font-size:10px;font-weight:600;letter-spacing:0.12em;text-transform:uppercase;color:var(--muted);margin-bottom:16px;">vibn generated</div>
<div style="display:flex;flex-direction:column;gap:0;">
<div style="display:flex;justify-content:space-between;align-items:baseline;padding:10px 0;border-bottom:1px solid var(--border);">
<span style="font-size:12px;color:var(--muted);font-weight:500;">Pages</span>
<span style="font-size:13px;color:var(--ink);font-weight:600;">Landing, Dashboard, Booking, Payments</span>
</div>
<div style="display:flex;justify-content:space-between;align-items:baseline;padding:10px 0;border-bottom:1px solid var(--border);">
<span style="font-size:12px;color:var(--muted);font-weight:500;">Stack</span>
<span style="font-size:13px;color:var(--ink);font-weight:600;">Auth, database, payments — handled</span>
</div>
<div style="display:flex;justify-content:space-between;align-items:baseline;padding:10px 0;border-bottom:1px solid var(--border);">
<span style="font-size:12px;color:var(--muted);font-weight:500;">Revenue</span>
<span style="font-size:13px;color:var(--ink);font-weight:600;">Subscription · $29 / mo</span>
</div>
<div style="display:flex;justify-content:space-between;align-items:baseline;padding:10px 0;">
<span style="font-size:12px;color:var(--muted);font-weight:500;">Status</span>
<span style="font-size:13px;font-weight:600;color:#6366F1;">&nbsp; Ready to build</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- CTA row -->
<div style="display:flex;flex-direction:column;align-items:center;text-align:center;gap:10px;margin-top:52px;">
<a href="02_signup.html"><button class="btn-ink-lg">Start free — no code needed</button></a>
<div><span style="font-size:13.5px;color:#818CF8;">★★★★★</span><span style="font-size:13.5px;color:var(--stone);">&nbsp;&nbsp;280 founders launched</span></div>
<p style="font-size:12px;color:#9CA3AF;">No credit card required · Free forever plan</p>
<a href="#how-it-works" style="font-size:13.5px;color:#6366F1;text-decoration:none;font-weight:500;margin-top:4px;">See how it works →</a>
</div>
</section>
<!-- EMPATHY -->
<section class="empathy-section" style="border-top:1px solid var(--border);border-bottom:1px solid var(--border);padding:80px 52px;">
<div style="max-width:980px;margin:0 auto;">
<div class="empathy-grid">
<div>
<div style="font-size:11px;font-weight:600;letter-spacing:0.13em;text-transform:uppercase;color:var(--muted);margin-bottom:18px;">Sound familiar?</div>
<h2 class="f" style="font-size:36px;font-weight:700;color:#1A1A1A;line-height:1.18;margin-bottom:24px;letter-spacing:-0.02em;">The idea is the hard part. <span class="gradient-text">Everything else shouldn't be.</span></h2>
<p style="font-size:15px;color:var(--mid);line-height:1.82;margin-bottom:20px;">You know exactly what you want to build and who it's for. But the moment you think about servers, databases, deployment pipelines, SEO — the whole thing stalls.</p>
<p style="font-size:15px;color:var(--mid);line-height:1.82;">vibn exists to remove all of that. Not abstract it — <em class="f" style="font-style:italic;">remove it entirely.</em></p>
</div>
<div style="display:flex;flex-direction:column;gap:14px;">
<div class="empathy-card"><div style="width:20px;height:20px;border-radius:50%;border:1.5px solid rgba(99,102,241,0.4);display:flex;align-items:center;justify-content:center;flex-shrink:0;margin-top:2px;"><div style="width:7px;height:7px;border-radius:50%;background:#6366F1;"></div></div><div><div class="f" style="font-size:14px;font-weight:600;color:#1A1A1A;margin-bottom:4px;">No more "I need to hire a developer first"</div><div style="font-size:13px;color:var(--mid);line-height:1.7;">vibn is your developer. Start building the moment you have an idea.</div></div></div>
<div class="empathy-card"><div style="width:20px;height:20px;border-radius:50%;border:1.5px solid rgba(99,102,241,0.4);display:flex;align-items:center;justify-content:center;flex-shrink:0;margin-top:2px;"><div style="width:7px;height:7px;border-radius:50%;background:#6366F1;"></div></div><div><div class="f" style="font-size:14px;font-weight:600;color:#1A1A1A;margin-bottom:4px;">No more staring at a blank marketing calendar</div><div style="font-size:13px;color:var(--mid);line-height:1.7;">AI generates and publishes your content every single week.</div></div></div>
<div class="empathy-card"><div style="width:20px;height:20px;border-radius:50%;border:1.5px solid rgba(99,102,241,0.4);display:flex;align-items:center;justify-content:center;flex-shrink:0;margin-top:2px;"><div style="width:7px;height:7px;border-radius:50%;background:#6366F1;"></div></div><div><div class="f" style="font-size:14px;font-weight:600;color:#1A1A1A;margin-bottom:4px;">No more "I'll launch when it's ready"</div><div style="font-size:13px;color:var(--mid);line-height:1.7;">Most founders ship their first version in under 72 hours.</div></div></div>
</div>
</div>
</div>
</section>
<!-- HOW IT WORKS -->
<section id="how-it-works" class="how-section" style="max-width:980px;margin:0 auto;padding:84px 52px;">
<div style="font-size:11px;font-weight:600;letter-spacing:0.13em;text-transform:uppercase;color:var(--muted);margin-bottom:16px;">How it works</div>
<h2 class="f" style="font-size:42px;font-weight:700;color:#1A1A1A;letter-spacing:-0.02em;margin-bottom:54px;max-width:480px;line-height:1.15;">Four phases. One <span class="gradient-text">complete</span> product.</h2>
<div class="phase-grid">
<div style="padding:40px 44px;background:var(--white);border-right:1px solid rgba(99,102,241,0.2);border-bottom:1px solid rgba(99,102,241,0.2);"><div style="font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;color:rgba(99,102,241,0.6);margin-bottom:14px;">01 — Discover</div><div class="f" style="font-size:22px;font-weight:700;color:#1A1A1A;margin-bottom:10px;">Define your idea</div><p style="font-size:13.5px;color:var(--mid);line-height:1.72;">Six guided questions turn a rough idea into a full product plan — pages, architecture, revenue model. No jargon.</p></div>
<div style="padding:40px 44px;background:var(--white);border-bottom:1px solid rgba(99,102,241,0.2);"><div style="font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;color:rgba(99,102,241,0.6);margin-bottom:14px;">02 — Design</div><div class="f" style="font-size:22px;font-weight:700;color:#1A1A1A;margin-bottom:10px;">Choose your style</div><p style="font-size:13.5px;color:var(--mid);line-height:1.72;">Pick a visual style and see your exact site and emails live before a single line of code is written.</p></div>
<div style="padding:40px 44px;background:var(--white);border-right:1px solid rgba(99,102,241,0.2);"><div style="font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;color:rgba(99,102,241,0.6);margin-bottom:14px;">03 — Build</div><div class="f" style="font-size:22px;font-weight:700;color:#1A1A1A;margin-bottom:10px;">Your app, live</div><p style="font-size:13.5px;color:var(--mid);line-height:1.72;">AI writes every line. Auth, database, payments, all pages — deployed and live. Describe changes in plain English.</p></div>
<div style="padding:40px 44px;background:var(--white);"><div style="font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;color:rgba(99,102,241,0.6);margin-bottom:14px;">04 — Grow</div><div class="f" style="font-size:22px;font-weight:700;color:#1A1A1A;margin-bottom:10px;">Market &amp; automate</div><p style="font-size:13.5px;color:var(--mid);line-height:1.72;">AI generates your blog, emails, and social schedule — publishing on autopilot so you can focus on users.</p></div>
</div>
</section>
<!-- WHAT YOU GET -->
<section style="background:var(--white);border-top:1px solid var(--border);border-bottom:1px solid var(--border);">
<div class="wyg-grid wyg-section" style="max-width:980px;margin:0 auto;padding:0 52px;">
<div style="padding:44px 40px 44px 0;border-right:1px solid var(--border);">
<div style="font-size:13px;font-weight:700;color:#6366F1;margin-bottom:12px;text-align:center;"></div>
<div class="f" style="font-size:17px;font-weight:700;color:#1A1A1A;margin-bottom:8px;text-align:center;">A live, working product</div>
<p style="font-size:13.5px;color:var(--mid);line-height:1.7;text-align:center;">Not a prototype. Real auth, real payments, real database — on your own URL from day one.</p>
<p style="font-size:12px;color:var(--muted);line-height:1.6;text-align:center;margin-top:10px;">Runs on your own servers — your data, your infrastructure, no lock-in.</p>
</div>
<div style="padding:44px 40px;border-right:1px solid var(--border);">
<div style="font-size:13px;font-weight:700;color:#6366F1;margin-bottom:12px;text-align:center;"></div>
<div class="f" style="font-size:17px;font-weight:700;color:#1A1A1A;margin-bottom:8px;text-align:center;">A full marketing engine</div>
<p style="font-size:13.5px;color:var(--mid);line-height:1.7;text-align:center;">Blog posts, onboarding emails, and social content — written and published automatically every week.</p>
</div>
<div style="padding:44px 0 44px 40px;">
<div style="font-size:13px;font-weight:700;color:#6366F1;margin-bottom:12px;text-align:center;"></div>
<div class="f" style="font-size:17px;font-weight:700;color:#1A1A1A;margin-bottom:8px;text-align:center;">A product that evolves</div>
<p style="font-size:13.5px;color:var(--mid);line-height:1.7;text-align:center;">Describe changes in plain English. Vibn handles the code so your product grows as fast as your ideas.</p>
</div>
</div>
</section>
<!-- QUOTE BAND -->
<section class="quote-section" style="background:#1A1A1A;padding:32px 52px 28px;">
<div style="max-width:980px;margin:0 auto;">
<div class="quote-grid">
<!-- Left: supporting quote -->
<div class="quote-side" style="display:flex;gap:14px;opacity:0.85;">
<div style="width:2px;background:#6366F1;border-radius:2px;flex-shrink:0;"></div>
<div>
<p class="f" style="font-size:12.5px;color:#FFFFFF;line-height:1.65;font-style:italic;margin-bottom:8px;">"I had the idea for 2 years. The backend terrified me. vibn shipped it in 4 days and handles all my marketing."</p>
<span style="font-size:10.5px;color:var(--muted);font-weight:600;">— Alex K., founder of Taskly</span>
</div>
</div>
<!-- Center: dominant quote -->
<div style="background:rgba(255,255,255,0.05);border-radius:12px;padding:22px 26px;">
<div style="width:3px;height:16px;background:#6366F1;border-radius:2px;margin-bottom:12px;opacity:0.7;"></div>
<p class="f" style="font-size:16px;color:#FFFFFF;line-height:1.7;font-style:italic;margin-bottom:12px;">"I have zero coding experience. Three weeks in, I have 300 paying users. That's entirely because of vibn."</p>
<span style="font-size:11px;color:var(--muted);font-weight:600;">— Marcus L., founder of Flowmatic</span>
</div>
<!-- Right: supporting quote -->
<div class="quote-side" style="display:flex;gap:14px;opacity:0.85;">
<div style="width:2px;background:#6366F1;border-radius:2px;flex-shrink:0;"></div>
<div>
<p class="f" style="font-size:12.5px;color:#FFFFFF;line-height:1.65;font-style:italic;margin-bottom:8px;">"The marketing autopilot saved me ten hours a week. My blog runs itself. I just focus on product."</p>
<span style="font-size:10.5px;color:var(--muted);font-weight:600;">— Sara R., founder of Nudge</span>
</div>
</div>
</div>
<!-- Pagination dots -->
<div style="display:flex;justify-content:center;gap:7px;">
<div style="width:5px;height:5px;border-radius:50%;background:rgba(255,255,255,0.3);"></div>
<div style="width:16px;height:5px;border-radius:3px;background:#FFFFFF;"></div>
<div style="width:5px;height:5px;border-radius:50%;background:rgba(255,255,255,0.3);"></div>
</div>
</div>
</section>
<!-- STATS -->
<section style="background:var(--white);border-top:1px solid var(--border);border-bottom:1px solid var(--border);">
<div class="stats-grid stats-section" style="max-width:980px;margin:0 auto;padding:0 52px;">
<div style="padding:40px 0;border-right:1px solid var(--border);"><div class="f gradient-num" style="font-size:40px;font-weight:700;letter-spacing:-0.03em;margin-bottom:6px;">280+</div><div style="font-size:13px;color:var(--muted);">founders launched</div></div>
<div style="padding:40px 0 40px 36px;border-right:1px solid var(--border);"><div class="f gradient-num" style="font-size:40px;font-weight:700;letter-spacing:-0.03em;margin-bottom:6px;">72h</div><div style="font-size:13px;color:var(--muted);">average time to first version</div></div>
<div style="padding:40px 0 40px 36px;border-right:1px solid var(--border);"><div class="f gradient-num" style="font-size:40px;font-weight:700;letter-spacing:-0.03em;margin-bottom:6px;">4.9★</div><div style="font-size:13px;color:var(--muted);">average rating</div></div>
<div style="padding:40px 0 40px 36px;"><div class="f gradient-num" style="font-size:40px;font-weight:700;letter-spacing:-0.03em;margin-bottom:6px;">3×</div><div style="font-size:13px;color:var(--muted);">faster than hiring a developer</div></div>
</div>
</section>
<!-- CTA -->
<section class="cta-section" style="padding:80px 52px;text-align:center;">
<div class="cta-card" style="max-width:680px;margin:0 auto;background:#FFFFFF;border-radius:20px;padding:64px 52px;box-shadow:0 0 0 1px rgba(99,102,241,0.15),0 20px 60px rgba(30,27,75,0.08);">
<h2 class="f" style="font-size:48px;font-weight:700;color:var(--ink);letter-spacing:-0.03em;line-height:1.1;margin-bottom:20px;">Your idea deserves to exist.</h2>
<p style="font-size:16px;color:var(--mid);line-height:1.75;margin-bottom:38px;">Thousands of ideas never make it past a spreadsheet. Yours doesn't have to be one of them.</p>
<a href="02_signup.html"><button class="btn-ink-lg" style="margin-bottom:16px;">Build my product — free</button></a>
<div style="font-size:12.5px;color:var(--muted);">Joins 280+ non-technical founders already live</div>
</div>
</section>
<!-- FOOTER -->
<footer style="background:rgba(250,250,250,0.95);border-top:1px solid var(--border);padding:32px 52px;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;">
<div>
<span class="f" style="font-size:16px;font-weight:700;color:var(--ink);">vibn</span>
<span class="footer-tagline">The fastest way from idea to product.</span>
</div>
<div class="footer-links" style="display:flex;gap:28px;">
<a href="#how-it-works" style="font-size:13px;color:var(--muted);text-decoration:none;">How it works</a>
<a href="#" style="font-size:13px;color:var(--muted);text-decoration:none;">Pricing</a>
<a href="#" style="font-size:13px;color:var(--muted);text-decoration:none;">Privacy</a>
<a href="#" style="font-size:13px;color:var(--muted);text-decoration:none;">Terms</a>
</div>
<span style="font-size:12.5px;color:var(--muted);text-align:right;display:block;">© 2026 vibn</span>
</footer>
<script>
function toggleMenu(){
var btn=document.getElementById('hamburger');
var menu=document.getElementById('mobile-menu');
var open=menu.classList.toggle('open');
btn.classList.toggle('open',open);
document.body.style.overflow=open?'hidden':'';
}
function closeMenu(){
document.getElementById('hamburger').classList.remove('open');
document.getElementById('mobile-menu').classList.remove('open');
document.body.style.overflow='';
}
// Close on anchor click (for same-page links like #how-it-works)
document.querySelectorAll('.mobile-menu a[href^="#"]').forEach(function(a){
a.addEventListener('click',closeMenu);
});
</script>
</body>
</html>

View File

@@ -0,0 +1,329 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><link rel="icon" href="favicon_clean.ico">
<title>vibn — Sign up</title>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0;}
:root{
--ink:#1A1A1A;
--mid:#6B7280;
--muted:#9CA3AF;
--border:#E5E7EB;
--white:#FFFFFF;
--soft:#F5F3FF;
--hover:#FAFAFF;
--serif:'Plus Jakarta Sans',sans-serif;
--sans:'Plus Jakarta Sans',sans-serif;
}
body{font-family:var(--sans);background:linear-gradient(to bottom,#FAFAFA,#F5F3FF);min-height:100vh;display:flex;flex-direction:column;color:var(--ink);}
.f{font-family:var(--serif);}
/* Inputs */
input::placeholder{color:var(--muted);}
input{width:100%;border:1px solid var(--border);border-radius:8px;padding:10px 13px;font-family:var(--sans);font-size:14px;color:var(--ink);background:#FAFAFA;outline:none;transition:border-color 0.15s,box-shadow 0.15s;}
input:focus{border-color:#6366F1;box-shadow:0 0 0 3px rgba(99,102,241,0.12);}
input.error{border-color:#F87171;}
/* Primary button */
.btn{width:100%;background:linear-gradient(135deg,#2E2A5E,#4338CA);color:#FFFFFF;border:none;border-radius:10px;padding:13px;font-family:var(--sans);font-size:14px;font-weight:600;cursor:pointer;margin-top:4px;box-shadow:0 10px 25px rgba(30,27,75,0.15);transition:box-shadow 0.2s ease,transform 0.2s ease;}
.btn:hover{box-shadow:0 10px 25px rgba(30,27,75,0.15),0 0 0 6px rgba(99,102,241,0.15);transform:translateY(-1px);}
.btn:disabled{opacity:0.4;cursor:default;transform:none;box-shadow:0 10px 25px rgba(30,27,75,0.15);}
/* Mode option cards */
.mode-opt{border:1px solid var(--border);background:transparent;border-radius:10px;padding:16px;cursor:pointer;margin-bottom:10px;display:flex;align-items:center;gap:12px;transition:all 0.15s;}
.mode-opt:hover{border-color:#6366F1;background:var(--hover);}
.mode-opt.selected{border-color:#6366F1;background:var(--hover);box-shadow:0 0 0 3px rgba(99,102,241,0.1);}
/* Password strength */
.strength-bar{display:flex;gap:4px;margin-top:8px;}
.strength-seg{flex:1;height:3px;border-radius:2px;background:var(--border);transition:background 0.2s ease;}
.strength-label{font-size:11px;color:var(--muted);margin-top:5px;min-height:16px;}
/* Password toggle */
.pwd-wrap{position:relative;}
.pwd-wrap input{padding-right:40px;}
.pwd-toggle{position:absolute;right:11px;top:50%;transform:translateY(-50%);background:none;border:none;cursor:pointer;color:var(--muted);padding:4px;display:flex;align-items:center;transition:color 0.15s;}
.pwd-toggle:hover{color:var(--ink);}
/* Google button */
.btn-google{width:100%;background:transparent;border:1px solid var(--border);color:var(--ink);border-radius:10px;padding:11px;font-family:var(--sans);font-size:13.5px;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:9px;transition:border-color 0.15s,background 0.15s;}
.btn-google:hover{border-color:#6366F1;background:var(--hover);}
/* Billing notice — animated */
.billing-notice{overflow:hidden;max-height:0;opacity:0;transition:max-height 0.3s ease,opacity 0.25s ease,margin-bottom 0.3s ease;margin-bottom:0;}
.billing-notice.visible{max-height:140px;opacity:1;margin-bottom:20px;}
/* Experience hint */
.mode-hint{text-align:center;font-size:12px;color:var(--muted);margin-top:10px;min-height:18px;transition:opacity 0.2s ease;}
.exp-feedback{overflow:hidden;max-height:0;opacity:0;transition:max-height 0.3s ease,opacity 0.25s ease,margin-bottom 0.3s ease;margin-bottom:0;border-radius:10px;padding:0 16px;}
.exp-feedback.visible{max-height:80px;opacity:1;margin-bottom:4px;padding:13px 16px;}
</style>
</head>
<body>
<nav style="background:rgba(250,250,250,0.95);border-bottom:1px solid var(--border);padding:0 40px;height:62px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:50;">
<div style="display:flex;align-items:center;gap:9px;">
<div style="width:28px;height:28px;background:linear-gradient(135deg,#2E2A5E,#4338CA);border-radius:7px;display:flex;align-items:center;justify-content:center;"><span class="f" style="font-size:14px;font-weight:700;color:#FFFFFF;">V</span></div>
<span class="f" style="font-size:17px;font-weight:700;color:var(--ink);letter-spacing:-0.02em;">vibn</span>
</div>
<span style="font-size:13.5px;color:var(--muted);">Already have an account? <a href="03_dashboard.html" style="color:#6366F1;font-weight:600;text-decoration:none;">Log in</a></span>
</nav>
<div style="flex:1;display:flex;align-items:center;justify-content:center;padding:40px 24px;">
<div style="width:100%;max-width:440px;">
<!-- Step indicator -->
<div id="steps" style="display:flex;align-items:center;justify-content:center;gap:8px;margin-bottom:32px;">
<div style="display:flex;align-items:center;gap:6px;" id="s1">
<div id="s1c" style="width:24px;height:24px;border-radius:50%;background:#6366F1;display:flex;align-items:center;justify-content:center;font-size:11px;color:#FFFFFF;font-weight:700;">1</div>
<span style="font-size:12.5px;font-weight:600;color:var(--ink);">Account</span>
</div>
<div style="width:28px;height:1px;background:var(--border);"></div>
<div style="display:flex;align-items:center;gap:6px;opacity:0.35;" id="s2">
<div id="s2c" style="width:24px;height:24px;border-radius:50%;background:var(--border);display:flex;align-items:center;justify-content:center;font-size:11px;color:var(--muted);font-weight:700;">2</div>
<span id="s2l" style="font-size:12.5px;color:var(--muted);">Your experience</span>
</div>
<div style="width:28px;height:1px;background:var(--border);"></div>
<div style="display:flex;align-items:center;gap:6px;opacity:0.35;" id="s3">
<div id="s3c" style="width:24px;height:24px;border-radius:50%;background:var(--border);display:flex;align-items:center;justify-content:center;font-size:11px;color:var(--muted);font-weight:700;">3</div>
<span id="s3l" style="font-size:12.5px;color:var(--muted);">Ready</span>
</div>
</div>
<!-- STEP 1 -->
<div id="step1" style="background:var(--white);border:1px solid var(--border);border-radius:16px;padding:32px;box-shadow:0 10px 30px rgba(30,27,75,0.05);">
<h2 class="f" style="font-size:23px;font-weight:700;color:var(--ink);letter-spacing:-0.02em;margin-bottom:6px;">Let's build your first product.</h2>
<p style="font-size:14px;color:var(--muted);margin-bottom:22px;">Free to start · No credit card needed</p>
<!-- Google first -->
<button onclick="openGoogleAuth()" class="btn-google" style="margin-bottom:20px;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z" fill="#FBBC05"/><path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/></svg>
Continue with Google
</button>
<div style="margin-bottom:20px;display:flex;align-items:center;gap:12px;">
<div style="flex:1;height:1px;background:var(--border);"></div>
<span style="font-size:12px;color:var(--muted);">or continue with email</span>
<div style="flex:1;height:1px;background:var(--border);"></div>
</div>
<!-- Email form -->
<div style="display:flex;flex-direction:column;gap:15px;">
<div>
<label style="display:block;font-size:11px;font-weight:600;color:var(--mid);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:6px;">Full name</label>
<input type="text" id="inp-name" placeholder="Jane Smith" oninput="validateStep1()"/>
</div>
<div>
<label style="display:block;font-size:11px;font-weight:600;color:var(--mid);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:6px;">Email</label>
<input type="email" id="inp-email" placeholder="jane@studio.com" oninput="validateStep1()"/>
</div>
<div>
<label style="display:block;font-size:11px;font-weight:600;color:var(--mid);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:6px;">Password</label>
<div class="pwd-wrap">
<input type="password" id="pwd" placeholder="8+ characters" oninput="checkStrength(this.value);validateStep1()"/>
<button type="button" class="pwd-toggle" onclick="togglePwd()" id="pwd-toggle-btn" aria-label="Show password">
<svg id="eye-open" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
<svg id="eye-closed" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none;"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
</button>
</div>
<div class="strength-bar"><div class="strength-seg" id="seg1"></div><div class="strength-seg" id="seg2"></div><div class="strength-seg" id="seg3"></div></div>
<div class="strength-label" id="strength-label"></div>
</div>
<!-- Social proof above button -->
<p style="text-align:center;font-size:12px;color:var(--muted);margin-bottom:-4px;">Joining 280+ founders already building</p>
<button id="step1btn" class="btn" onclick="goStep(2)" disabled>Continue →</button>
<p style="text-align:center;font-size:11.5px;color:var(--muted);margin-top:2px;">By continuing you agree to our <a href="#" style="color:var(--muted);text-decoration:underline;">Terms</a> and <a href="#" style="color:var(--muted);text-decoration:underline;">Privacy Policy</a></p>
</div>
</div>
<!-- STEP 2 -->
<div id="step2" style="display:none;background:var(--white);border:1px solid var(--border);border-radius:16px;padding:32px;box-shadow:0 10px 30px rgba(30,27,75,0.05);">
<h2 class="f" style="font-size:23px;font-weight:700;color:var(--ink);letter-spacing:-0.02em;margin-bottom:6px;">How experienced are you?</h2>
<p style="font-size:14px;color:var(--muted);margin-bottom:24px;">Just so we know who we're building with</p>
<div id="modes">
<div class="mode-opt" onclick="selectExperience('beginner',this)">
<div style="width:36px;height:36px;border-radius:9px;background:var(--soft);display:flex;align-items:center;justify-content:center;font-size:18px;flex-shrink:0;color:#6366F1;"></div>
<div><div class="f" style="font-size:14px;font-weight:600;color:var(--ink);margin-bottom:3px;">First time</div><div style="font-size:12.5px;color:var(--muted);">I've never shipped a product before</div></div>
</div>
<div class="mode-opt" onclick="selectExperience('some',this)">
<div style="width:36px;height:36px;border-radius:9px;background:var(--soft);display:flex;align-items:center;justify-content:center;font-size:18px;flex-shrink:0;color:#6366F1;"></div>
<div><div class="f" style="font-size:14px;font-weight:600;color:var(--ink);margin-bottom:3px;">Some experience</div><div style="font-size:12.5px;color:var(--muted);">I've built things before</div></div>
</div>
<div class="mode-opt" onclick="selectExperience('experienced',this)">
<div style="width:36px;height:36px;border-radius:9px;background:var(--soft);display:flex;align-items:center;justify-content:center;font-size:18px;flex-shrink:0;color:#6366F1;"></div>
<div><div class="f" style="font-size:14px;font-weight:600;color:var(--ink);margin-bottom:3px;">Experienced</div><div style="font-size:12.5px;color:var(--muted);">I ship products regularly</div></div>
</div>
</div>
<!-- Contextual feedback — animated -->
<div id="exp-feedback" class="exp-feedback" style="background:var(--soft);border:1px solid rgba(99,102,241,0.2);">
<div id="exp-feedback-text" style="font-size:13px;color:#4338CA;line-height:1.6;"></div>
</div>
<button id="step2btn" class="btn" style="margin-top:20px;" onclick="goStep(3)" disabled>Set up my workspace →</button>
<p class="mode-hint" id="mode-hint">Select an option above to continue</p>
<p style="text-align:center;margin-top:6px;"><a onclick="goStep(1)" style="font-size:13px;color:var(--muted);text-decoration:none;cursor:pointer;">← Back</a></p>
</div>
<!-- STEP 3 -->
<div id="step3" style="display:none;background:var(--white);border:1px solid var(--border);border-radius:16px;padding:32px;box-shadow:0 10px 30px rgba(30,27,75,0.05);">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:6px;">
<div style="width:36px;height:36px;background:var(--soft);border:1px solid rgba(99,102,241,0.25);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0;color:#6366F1;"></div>
<h2 class="f" style="font-size:22px;font-weight:700;color:var(--ink);letter-spacing:-0.02em;">You're in. Got an idea?</h2>
</div>
<p id="done-msg" style="font-size:14px;color:var(--muted);line-height:1.7;margin-bottom:20px;padding-left:48px;">Describe it in one sentence and we'll carry it straight into your workspace.</p>
<!-- Seed input -->
<textarea id="seed-idea" placeholder="e.g. A booking tool for independent personal trainers." style="width:100%;border:1px solid var(--border);border-radius:8px;padding:11px 13px;font-family:var(--sans);font-size:14px;color:var(--ink);background:#FAFAFA;outline:none;resize:none;height:88px;line-height:1.6;transition:border-color 0.15s,box-shadow 0.15s;margin-bottom:16px;" onfocus="this.style.borderColor='#6366F1';this.style.boxShadow='0 0 0 3px rgba(99,102,241,0.12)';" onblur="this.style.borderColor='#E5E7EB';this.style.boxShadow='none';"></textarea>
<button id="dash-btn" class="btn" style="margin-top:0;" onclick="openDashboard()">Start building →</button>
<p style="text-align:center;margin-top:14px;">
<a onclick="goToDashboard()" style="font-size:13px;color:var(--muted);text-decoration:none;cursor:pointer;">I'll do this later — take me to the dashboard</a>
</p>
<p style="text-align:center;margin-top:10px;"><a onclick="goStep(2)" style="font-size:12px;color:var(--muted);text-decoration:none;cursor:pointer;opacity:0.6;">← Back</a></p>
</div>
</div>
</div>
<script>
/* Google auth popup */
function openGoogleAuth(){
var w=500,h=600;
var left=(screen.width/2)-(w/2);
var top=(screen.height/2)-(h/2);
window.open('google-auth-popup.html','google-auth','width='+w+',height='+h+',left='+left+',top='+top+',toolbar=no,menubar=no,scrollbars=no');
window.addEventListener('message',function(e){
if(e.data&&e.data.type==='google-auth-success'){
goStep(2);
}
},{once:true});
}
var mode=null;
var EXP_FEEDBACK={
beginner:"We've got you — we'll explain every step clearly, no jargon, no assumptions. You'll have a product live before you know it.",
some:"Great! You know the ropes — let's move fast and make something great.",
experienced:"You know the process. We'll keep things efficient and get straight to the point."
};
var EXP_DONE={
beginner:"Everything is in place. We'll guide you every step of the way.",
some:"Everything is in place. Let's get straight to building.",
experienced:"Everything is in place. Let's move fast."
};
/* Step navigation */
function goStep(n){
[1,2,3].forEach(function(i){
document.getElementById('step'+i).style.display=i===n?'block':'none';
var s=document.getElementById('s'+i);
if(s) s.style.opacity=i<=n?'1':'0.35';
var c=document.getElementById('s'+i+'c');
if(c){
c.style.background=i<n?'#4338CA':i===n?'#6366F1':'#E5E7EB';
c.style.color=i<=n?'#FFFFFF':'#9CA3AF';
c.textContent=i<n?'✓':String(i);
}
});
}
/* Step 1 — form validation */
function validateStep1(){
var name=document.getElementById('inp-name').value.trim();
var email=document.getElementById('inp-email').value.trim();
var pwd=document.getElementById('pwd').value;
var emailOk=/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
var pwdOk=pwd.length>=8;
document.getElementById('step1btn').disabled=!(name.length>0 && emailOk && pwdOk);
}
/* Password show / hide */
function togglePwd(){
var input=document.getElementById('pwd');
var eyeOpen=document.getElementById('eye-open');
var eyeClosed=document.getElementById('eye-closed');
var isHidden=input.type==='password';
input.type=isHidden?'text':'password';
eyeOpen.style.display=isHidden?'none':'block';
eyeClosed.style.display=isHidden?'block':'none';
}
/* Password strength */
function checkStrength(v){
var segs=['seg1','seg2','seg3'];
var labelEl=document.getElementById('strength-label');
if(v.length===0){
segs.forEach(function(id){document.getElementById(id).style.background='#E5E7EB';});
labelEl.textContent='';
return;
}
if(v.length<8){
segs.forEach(function(id){document.getElementById(id).style.background='#E5E7EB';});
document.getElementById('seg1').style.background='#F87171';
labelEl.textContent='Not enough characters — 8 minimum';
labelEl.style.color='#F87171';
return;
}
var score=1;
if(/[A-Z]/.test(v)&&/[0-9]/.test(v)) score++;
if(/[^A-Za-z0-9]/.test(v)||v.length>=12) score++;
var colors=['#E5E7EB','#E5E7EB','#E5E7EB'];
var label='';
if(score===1){colors[0]='#F87171';label='Weak';}
else if(score===2){colors[0]='#FBBF24';colors[1]='#FBBF24';label='Fair';}
else{colors[0]='#4338CA';colors[1]='#6366F1';colors[2]='#818CF8';label='Strong';}
segs.forEach(function(id,i){document.getElementById(id).style.background=colors[i];});
labelEl.textContent=label;
labelEl.style.color=score===1?'#F87171':score===2?'#FBBF24':'#6366F1';
}
/* Step 2 — experience selection */
function selectExperience(level,el){
mode=level;
document.querySelectorAll('.mode-opt').forEach(function(d){d.classList.remove('selected');});
el.classList.add('selected');
// Show contextual feedback
var feedback=document.getElementById('exp-feedback');
var feedbackText=document.getElementById('exp-feedback-text');
feedbackText.textContent=EXP_FEEDBACK[level];
feedback.classList.add('visible');
// Enable button + hide hint
document.getElementById('step2btn').disabled=false;
document.getElementById('mode-hint').style.opacity='0';
document.getElementById('done-msg').textContent=EXP_DONE[level];
}
/* Step 3 — start building (carries seed idea to Describe) */
function openDashboard(){
var btn=document.getElementById('dash-btn');
btn.textContent='Setting up…';
btn.disabled=true;
var idea=(document.getElementById('seed-idea').value||'').trim();
setTimeout(function(){
try {
sessionStorage.setItem('vibn_new_project','1');
if(idea) sessionStorage.setItem('vibn_seed_idea', idea);
} catch(e){}
window.location.href='05_describe.html';
},800);
}
/* Step 3 — skip to dashboard */
function goToDashboard(){
window.location.href='03_dashboard.html';
}
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,638 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><link rel="icon" href="favicon_clean.ico">
<script>if(localStorage.getItem('vibn-theme')==='dark')document.documentElement.dataset.theme='dark';</script>
<title>vibn — Architect</title>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0;}
:root{
--ink:#1A1A1A;--ink2:#2c2c2a;--ink3:#444441;--mid:#6B7280;--muted:#9CA3AF;
--stone:#b4b2a9;--parch:#d3d1c7;--cream:#f1efe8;--paper:#F5F3FF;--white:#FFFFFF;--border:#E5E7EB;
--indigo:#6366F1;--indigo-dark:#4338CA;--indigo-deep:#2E2A5E;
--indigo-soft:rgba(99,102,241,0.08);--indigo-ring:rgba(99,102,241,0.12);
}
body{font-family:'Plus Jakarta Sans',sans-serif;background:linear-gradient(to bottom,#FAFAFA,#F5F3FF);display:flex;flex-direction:column;height:100vh;overflow:hidden;}
.f{font-family:'Plus Jakarta Sans',sans-serif;}
/* ── Dark mode tokens — exact match with design.html ── */
[data-theme="dark"]{--ink:#EEEEFF;--ink2:#B8B8D0;--ink3:#8484A8;--mid:#9898B8;--muted:#c2c2ee;--border:rgba(255,255,255,0.08);
--cream:rgba(108,124,255,0.14);--paper:#0A1120;--white:rgba(255,255,255,0.05);--stone:rgba(255,255,255,0.08);
--parch:rgba(255,255,255,0.06);--section:rgba(108,124,255,0.55);--accent-primary:#6C7CFF;--indigo:#6C7CFF;--indigo-dark:#6C7CFF;
--indigo-deep:#4B42D8;--indigo-soft:rgba(108,124,255,0.14);--indigo-ring:rgba(108,124,255,0.22);--dm-surf-sidebar:rgba(12,18,34,0.72);
--dm-surf-topbar:rgba(12,18,34,0.58);--dm-surf-panel:rgba(12,18,34,0.58);--dm-surf-right:rgba(12,18,34,0.58);
--dm-surf-card:rgba(255,255,255,0.05);--dm-surf-refine:rgba(8,12,22,0.60);--dm-border:rgba(255,255,255,0.08);
--dm-border-strong:rgba(255,255,255,0.14);--dm-border-hero:rgba(255,255,255,0.18);--dm-accent:#6C7CFF;
--dm-accent-fill:rgba(108,124,255,0.14);--dm-accent-fill-mid:rgba(108,124,255,0.20);--dm-accent-border:rgba(108,124,255,0.55);
--dm-text-1:#EEEEFF;--dm-text-2:#B4B4CC;--dm-text-3:#d2d2ef;--dm-shadow-panel:0 4px 36px rgba(0,0,0,0.55);
--dm-shadow-hero:0 20px 70px rgba(0,0,0,0.65),0 6px 24px rgba(0,0,0,0.40),inset 0 1px 0 rgba(255,255,255,0.10);}
[data-theme="dark"] body{background:linear-gradient(to bottom,rgba(108,80,255,0.06) 0%,rgba(60,120,255,0.10) 38%,transparent 62%),linear-gradient(to bottom,rgba(108,124,255,0.10),transparent 180px),radial-gradient(900px 520px at 14% -8%,rgba(108,124,255,0.24),transparent 62%),radial-gradient(760px 420px at 88% 0%,rgba(72,145,255,0.16),transparent 60%),linear-gradient(180deg,#18213B 0%,#101726 48%,#0A1120 100%);}[data-theme="dark"] #mock {background: #fff !important;}
/* Sidebar */
[data-theme="dark"] .sidebar-col{background:var(--dm-surf-sidebar)!important;border-right:1px solid var(--dm-border)!important;box-shadow:2px 0 28px rgba(0,0,0,0.60)!important;-webkit-backdrop-filter:blur(20px)!important;backdrop-filter:blur(20px)!important;}
[data-theme="dark"] .sidebar-col [style*="border-top:1px solid #e5e7eb"]{border-top-color:var(--dm-border)!important;}
[data-theme="dark"] .sidebar-col [style*="background:#e5e7eb"]{background:rgba(255,255,255,0.08)!important;}
[data-theme="dark"] .sidebar-col [style*="color:#1a1a1a"]{color:var(--dm-text-1)!important;}
[data-theme="dark"] .sidebar-col [style*="color:#6b7280"]{color:var(--dm-text-3)!important;}
[data-theme="dark"] .sidebar-col [style*="color:#444441"]{color:var(--dm-text-3)!important;}
[data-theme="dark"] .sidebar-col [style*="color:#9ca3af"]{color:var(--dm-text-3)!important;}
[data-theme="dark"] .sidebar-col [style*="background:#6366F1"]{background:var(--dm-accent)!important;color:#0F1424!important;}
[data-theme="dark"] .sidebar-phase.active{background:var(--dm-accent-fill)!important;}
[data-theme="dark"] .sidebar-phase:not(.active):hover{background:rgba(255,255,255,0.08)!important;}
[data-theme="dark"] .sidebar-col [style*="background:#eef2ff"]{background:var(--dm-accent-fill)!important;border-color:var(--dm-accent-border)!important;}
[data-theme="dark"] .sidebar-col [style*="background:#eef2ff"] span{color:var(--dm-accent)!important;}
[data-theme="dark"] #sidebar-project-name{color:var(--dm-text-3)!important;}
/*Arch-scroll*/
[data-theme="dark"] .arch-scroll{background:var(--dm-surf-panel)!important;border-right:1px solid var(--dm-border)!important;box-shadow:4px 0 0px rgba(0,0,0,0.55)!important;-webkit-backdrop-filter:blur(20px)!important;backdrop-filter:blur(20px)!important;}
/* Main content */
[data-theme="dark"] .arch-main{background:transparent!important;}
[data-theme="dark"] [style*="background:#f5f3ff"]{background:rgba(108,124,255,0.04)!important;}
[data-theme="dark"] [style*="background:var(--white)"]{background:var(--dm-surf-card)!important;}
[data-theme="dark"] [style*="background:#fafaff"],[data-theme="dark"] [style*="background:#FAFAFA"]{background:rgba(255,255,255,0.03)!important;}
[data-theme="dark"] [style*="background:#f0f4ff"]{background:var(--dm-surf-card)!important;}
[data-theme="dark"] [style*="background:#eef2ff"]{background:var(--dm-accent-fill)!important;}
[data-theme="dark"] [style*="background:#F3F4F6"]{background:rgba(255,255,255,0.05)!important;}
/* Borders */
[data-theme="dark"] [style*="border-bottom:1px solid #c7d2fe"]{border-bottom-color:var(--dm-border)!important;}
[data-theme="dark"] [style*="border:1px solid #e0e7ff"]{border-color:var(--dm-border)!important;}
[data-theme="dark"] [style*="border:1px solid rgba(99,102,241"]{border-color:var(--dm-accent-border)!important;}
[data-theme="dark"] [style*="color:#4338ca"]{color:var(--dm-accent)!important;}
/* Blueprint rows */
[data-theme="dark"] .blueprint-row:hover{background:rgba(108,124,255,0.07)!important;}
[data-theme="dark"] .blueprint-row.locked{background:rgba(255,255,255,0.02)!important;}
[data-theme="dark"] .blueprint-row.locked:hover{background:rgba(255,255,255,0.04)!important;}
/* Option buttons */
[data-theme="dark"] .opt-btns{border-color:var(--dm-border)!important;}
[data-theme="dark"] .opt-btn{color:var(--dm-text-2)!important;border-right-color:var(--dm-border)!important;}
[data-theme="dark"] .opt-btn:hover{background:rgba(108,124,255,0.09)!important;color:var(--dm-accent)!important;}
[data-theme="dark"] .opt-btn.selected{background:var(--dm-accent-fill)!important;color:var(--dm-accent)!important;font-weight:600!important;box-shadow:inset 0 0 0 1px rgba(108,124,255,0.32),0 2px 16px rgba(108,124,255,0.22)!important;}
[data-theme="dark"] .opt-btn.why-btn{color:var(--dm-accent)!important;}
[data-theme="dark"] .why-btn{color:var(--dm-text-3)!important;border-color:var(--dm-border)!important;}
[data-theme="dark"] .why-btn:hover{color:var(--dm-accent)!important;border-color:var(--dm-accent-border)!important;}
/* Right panel / deliverables */
[data-theme="dark"] .arch-right{background:var(--dm-surf-right)!important;border-left:1px solid var(--dm-border)!important;box-shadow:-4px 0 36px rgba(0,0,0,0.55)!important;-webkit-backdrop-filter:blur(20px)!important;backdrop-filter:blur(20px)!important;}
[data-theme="dark"] .deliverable-row{color:var(--dm-text-3)!important;}
[data-theme="dark"] .deliverable-row:hover{background:rgba(255,255,255,0.04)!important;}
/* Popups & modals */
[data-theme="dark"] #why-popup>div{background:#111828!important;box-shadow:0 0 0 1px rgba(108,124,255,0.50),0 0 0 4px rgba(108,124,255,0.14),0 0 60px rgba(108,124,255,0.28),0 24px 64px rgba(0,0,0,0.72)!important;}
[data-theme="dark"] #save-exit-box{background:#111828!important;box-shadow:0 0 0 1px rgba(108,124,255,0.50),0 0 0 4px rgba(108,124,255,0.14),0 0 60px rgba(108,124,255,0.28),0 24px 64px rgba(0,0,0,0.72)!important;}
[data-theme="dark"] .modal-card{background:#111828!important;box-shadow:0 0 0 1px rgba(108,124,255,0.50),0 0 0 4px rgba(108,124,255,0.14),0 0 60px rgba(108,124,255,0.28),0 24px 64px rgba(0,0,0,0.72)!important;}
[data-theme="dark"] .arch-blueprint-card{box-shadow:0 0 0 1px rgba(108,124,255,0.22),0 2px 24px rgba(108,124,255,0.14),0 0 48px rgba(108,124,255,0.08)!important;}
/* Buttons */
[data-theme="dark"] #dark-toggle{background:rgba(255,255,255,0.05)!important;border-color:var(--dm-border)!important;color:var(--dm-text-3)!important;}
[data-theme="dark"] #dark-toggle:hover{background:rgba(255,255,255,0.10)!important;color:var(--dm-text-1)!important;}
[data-theme="dark"] button[onclick="saveAndExit()"]{background:var(--dm-accent-fill)!important;border-color:var(--dm-accent-border)!important;}
[data-theme="dark"] button[onclick="saveAndExit()"]:hover{background:var(--dm-accent-fill-mid)!important;}
[data-theme="dark"] button[onclick="saveAndExit()"]:hover span{color:#fff!important;}
[data-theme="dark"] button[onclick="saveAndExit()"] span{color:var(--dm-accent)!important;}
[data-theme="dark"] button[onclick="openWhy()"]{color:var(--dm-accent)!important;}
[data-theme="dark"] .btn-primary{background:linear-gradient(135deg,#4B42D8 0%,#6C7CFF 100%)!important;color:#fff!important;box-shadow:0 4px 22px rgba(108,124,255,0.38),inset 0 1px 0 rgba(255,255,255,0.14)!important;}
[data-theme="dark"] .btn-primary:hover{box-shadow:0 6px 32px rgba(108,124,255,0.50),inset 0 1px 0 rgba(255,255,255,0.18)!important;}
[data-theme="dark"] .vibn-avatar{background:var(--dm-accent)!important;}
[data-theme="dark"] .arch-intro-bubble{box-shadow:0 0 0 1px rgba(108,124,255,0.12),0 2px 18px rgba(108,124,255,0.10),0 0 36px rgba(108,124,255,0.06)!important;}
[data-theme="dark"] .arch-deliverable-card{box-shadow:0 0 0 1px rgba(108,124,255,0.10),0 2px 14px rgba(108,124,255,0.08)!important;}
/* Topbar */
[data-theme="dark"] .arch-topbar{
background: var(--dm-surf-topbar) !important;
border-bottom: 1px solid rgba(255,255,255,0.08) !important;
box-shadow: none !important;
-webkit-backdrop-filter: none !important;
backdrop-filter: none !important;
position: relative;
}
[data-theme="dark"] .arch-topbar::after{
content: none !important;
display: none !important;
}
[data-theme="dark"] .arch-topbar .f{
color: var(--dm-text-1) !important;
}
[data-theme="dark"] .arch-topbar [style*="color:#9ca3af"]{
color: var(--dm-text-3) !important;
}
/* Scrollbar */
[data-theme="dark"] ::-webkit-scrollbar{width:5px;height:5px;}
[data-theme="dark"] ::-webkit-scrollbar-track{background:transparent;}
[data-theme="dark"] ::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.12);border-radius:3px;}
[data-theme="dark"] ::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,0.22);}
[data-theme="dark"] *{scrollbar-color:rgba(255,255,255,0.12) transparent;scrollbar-width:thin;}
/* Sidebar */
.sidebar-phase{display:flex;align-items:center;gap:9px;padding:9px 10px;border-radius:8px;}
.sidebar-phase.active{background:#fafaff;}
.phase-dot{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:10px;}
/* Blueprint row */
.blueprint-row{display:grid;grid-template-columns:36px 1fr 248px;align-items:center;gap:16px;padding:15px 22px;border-bottom:1px solid var(--border);transition:background 0.15s;}
.blueprint-row:last-child{border-bottom:none;border-radius:0 0 14px 14px;}
.blueprint-row:hover{background:#FAFAFF;}
.blueprint-row.locked{background:#FAFAFA;}
.blueprint-row.locked:hover{background:#F5F5F5;}
/* Option toggle buttons */
.opt-btns{display:flex;gap:0;flex-shrink:0;border:1.5px solid var(--border);border-radius:8px;overflow:visible;}
.opt-btn{flex:1;text-align:center;font-size:12px;font-weight:500;color:var(--mid);background:transparent;border:none;border-right:1.5px solid var(--border);border-radius:0;padding:6px 13px;cursor:pointer;font-family:'Plus Jakarta Sans',sans-serif;transition:all 0.15s;white-space:nowrap;position:relative;}
.opt-btn:first-child{border-radius:7px 0 0 7px;}
.opt-btn:last-child{border-right:none;border-radius:0 7px 7px 0;}
.opt-btn:hover{background:#F5F5FF;color:var(--indigo);}
.opt-btn.selected{background:var(--indigo-soft);color:var(--indigo-dark);font-weight:600;box-shadow:0 2px 10px rgba(99,102,241,0.12);}
/* Intro AI bubble */
.arch-intro-bubble{box-shadow:0 2px 12px rgba(99,102,241,0.08);}
/* Deliverable cards (Pages + Infrastructure) */
.arch-deliverable-card{box-shadow:0 2px 12px rgba(99,102,241,0.08);}
/* Why button (hosting) */
.why-btn{font-size:11.5px;font-weight:600;color:var(--mid);background:transparent;border:1px solid var(--border);border-radius:6px;padding:5px 11px;cursor:pointer;font-family:'Plus Jakarta Sans',sans-serif;transition:border-color 0.15s,color 0.15s;white-space:nowrap;}
.why-btn:hover{border-color:var(--indigo);color:var(--indigo);}
/* Primary button */
.btn-primary{background:linear-gradient(135deg,var(--indigo-deep),var(--indigo-dark));color:#FFFFFF;border:none;border-radius:8px;padding:10px 22px;font-family:'Plus Jakarta Sans',sans-serif;font-size:13px;font-weight:600;cursor:pointer;box-shadow:0 4px 18px rgba(99,102,241,0.22),0 1px 4px rgba(99,102,241,0.12);transition:box-shadow 0.2s,transform 0.2s;}
.btn-primary:hover{box-shadow:0 6px 24px rgba(99,102,241,0.30),0 1px 4px rgba(99,102,241,0.14),0 0 0 6px var(--indigo-ring);transform:translateY(-1px);}
/* What you're getting panel */
.deliverable-row{display:flex;align-items:center;gap:8px;padding:7px 10px;border-radius:7px;font-size:12px;color:var(--mid);transition:background 0.12s;}
.deliverable-row:hover{background:var(--cream);}
/* Modal */
.modal-bg{display:none;position:fixed;inset:0;background:rgba(15,14,26,0.45);backdrop-filter:blur(2px);z-index:100;align-items:center;justify-content:center;}
.modal-bg.open{display:flex;}
.modal-card{background:var(--white);border-radius:16px;width:400px;overflow:hidden;box-shadow:0 24px 64px rgba(30,27,75,0.18);}
/* Option buttons in modal */
.option-btn{display:flex;align-items:center;gap:12px;padding:12px 16px;border-radius:10px;border:1px solid var(--border);background:var(--white);cursor:pointer;text-align:left;margin-bottom:8px;transition:all 0.15s;}
.option-btn:hover{border-color:var(--indigo);background:var(--cream);}
.option-btn.selected{border-color:var(--indigo);background:var(--cream);box-shadow:0 0 0 3px var(--indigo-ring);}
/* Save popup */
#save-exit-popup{display:none;position:fixed;inset:0;background:rgba(15,14,26,0.45);backdrop-filter:blur(2px);z-index:700;align-items:center;justify-content:center;padding:24px;}
#save-exit-popup.visible{display:flex;}
#save-exit-box{background:#FFFFFF;border-radius:16px;box-shadow:0 24px 64px rgba(30,27,75,0.18);padding:32px;width:100%;max-width:380px;text-align:center;}
#save-exit-box .save-icon{width:48px;height:48px;background:#f0f4ff;border:1px solid #e0e7ff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:20px;margin:0 auto 16px;}
#save-exit-box h3{font-size:18px;font-weight:700;color:var(--ink);margin-bottom:8px;}
#save-exit-box p{font-size:13px;color:var(--muted);line-height:1.6;margin-bottom:20px;}
#save-exit-box .save-cancel{font-size:12px;color:var(--muted);cursor:pointer;text-decoration:underline;background:none;border:none;font-family:'Plus Jakarta Sans',sans-serif;}
#save-exit-box .save-cancel:hover{color:var(--ink);}
/* Why accordion */
/* ── Mobile tab bar (hidden on desktop) ── */
.mob-tabs{display:none;flex-shrink:0;background:#EEF0FF;border-top:1px solid #D4D8FA;padding-bottom:env(safe-area-inset-bottom);}
[data-theme="dark"] .mob-tabs{background:rgba(8,12,24,0.90)!important;border-top-color:var(--dm-border)!important;}
.mob-tab-btn{flex:1;padding:11px 8px;border:none;background:transparent;font-family:'Plus Jakarta Sans',sans-serif;font-size:13px;font-weight:500;color:var(--muted);cursor:pointer;border-top:2px solid transparent;transition:color 0.15s,border-color 0.15s;}
.mob-tab-btn.active{color:#6366F1;border-top-color:#6366F1;font-weight:600;}
.mob-dash-btn{background:none;border:none;font-family:'Plus Jakarta Sans',sans-serif;font-size:11.5px;font-weight:500;color:var(--muted);cursor:pointer;padding:11px 12px;white-space:nowrap;flex:none;transition:color 0.15s;}
.mob-dash-btn:hover{color:var(--ink);}
[data-theme="dark"] .mob-tab-btn{color:var(--dm-text-3)!important;}
[data-theme="dark"] .mob-tab-btn.active{color:var(--dm-accent)!important;border-top-color:var(--dm-accent)!important;}
/* ── Responsive ── */
/* Tablet (641px 1024px): top tab bar, full-height panels */
@media (min-width:641px) and (max-width:1024px){
body{height:100dvh;overflow:hidden;}
.arch-layout{flex-direction:column!important;height:100dvh!important;overflow:hidden!important;}
.sidebar-col{display:none!important;}
.mob-tabs{display:flex!important;order:0;border-top:none!important;border-bottom:1px solid #D4D8FA!important;padding-bottom:0!important;}
.mob-tab-btn{border-top:none;border-bottom:2px solid transparent;}
.mob-tab-btn.active{border-top-color:transparent;border-bottom-color:#6366F1;}
[data-theme="dark"] .mob-tab-btn.active{border-bottom-color:var(--dm-accent)!important;border-top-color:transparent!important;}
.arch-main{display:none!important;order:1;overflow:hidden!important;flex:1 1 0%!important;height:0!important;min-height:0!important;}
.arch-main.tab-active{display:flex!important;}
.arch-scroll{flex:1 1 0%!important;height:0!important;min-height:0!important;overflow-y:auto!important;-webkit-overflow-scrolling:touch;}
.arch-right{display:none!important;order:1;width:100%!important;border-left:none!important;flex:1 1 0%!important;height:0!important;min-height:0!important;}
.arch-right.tab-active{display:flex!important;}
.arch-right-scroll{flex:1 1 0%!important;height:0!important;min-height:0!important;overflow-y:auto!important;-webkit-overflow-scrolling:touch;}
.arch-right-footer{padding:12px 16px 20px!important;}
.arch-right-footer a{width:80%;display:block;}
.blueprint-row{grid-template-columns:36px 1fr!important;row-gap:10px;padding:13px 18px!important;}
.blueprint-row .opt-btns{grid-column:1 / -1;width:100%;}
}
/* Mobile (≤ 640px): tabbed layout, tabs at bottom */
@media (max-width:640px){
body{height:100dvh;overflow:hidden;}
.arch-layout{flex-direction:column!important;height:100dvh!important;overflow:hidden!important;}
.sidebar-col{display:none!important;}
.mob-tabs{display:flex!important;order:2;}
.arch-main{display:none!important;order:1;overflow:hidden!important;flex:1 1 0%!important;height:0!important;min-height:0!important;}
.arch-main.tab-active{display:flex!important;}
.arch-scroll{flex:1 1 0%!important;height:0!important;min-height:0!important;overflow-y:auto!important;-webkit-overflow-scrolling:touch;}
.arch-right{display:none!important;order:1;width:100%!important;border-left:none!important;flex:1 1 0%!important;height:0!important;min-height:0!important;}
.arch-right.tab-active{display:flex!important;}
.arch-right-scroll{flex:1 1 0%!important;height:0!important;min-height:0!important;overflow-y:auto!important;-webkit-overflow-scrolling:touch;}
.blueprint-row{grid-template-columns:36px 1fr!important;row-gap:10px;padding:13px 16px!important;}
.blueprint-row .opt-btns{grid-column:1 / -1;width:100%;}
.opt-btn{flex:1;padding:7px 8px!important;font-size:11.5px!important;}
.arch-topbar{padding:14px 16px 12px!important;}
.arch-right-footer{padding:12px 16px 20px!important;}
.arch-right-footer a{width:100%!important;display:block!important;}
.modal-card{width:calc(100vw - 32px)!important;}
#why-popup>div{max-width:calc(100vw - 32px)!important;}
}
/* Mobile with trackpad (laptop narrow): tabs at top */
@media (max-width:640px) and (hover:hover) and (pointer:fine){
.mob-tabs{order:0!important;border-top:none!important;border-bottom:1px solid #D4D8FA!important;padding-bottom:0!important;}
.mob-tab-btn{border-top:none;border-bottom:2px solid transparent;}
.mob-tab-btn.active{border-top-color:transparent;border-bottom-color:#6366F1;}
[data-theme="dark"] .mob-tab-btn.active{border-bottom-color:var(--dm-accent)!important;}
.arch-main{order:1!important;}
.arch-right{order:1!important;}
}
</style>
</head>
<body>
<div class="arch-layout" style="display:flex;height:100%;overflow:hidden;">
<!-- Mobile tab bar (hidden on desktop, shown on ≤ 640px) -->
<div class="mob-tabs" id="mob-tabs">
<button onclick="saveAndExit()" class="mob-dash-btn">Dashboard</button>
<div style="width:1px;background:var(--border);margin:8px 0;flex-shrink:0;"></div>
<button class="mob-tab-btn active" id="tab-blueprint" onclick="switchArchTab('blueprint')">Blueprint</button>
<button class="mob-tab-btn" id="tab-scope" onclick="switchArchTab('scope')">Scope</button>
</div>
<!-- ── SIDEBAR ── -->
<div class="sidebar-col" style="width:200px;background:#ffffff;border-right:1px solid #e5e7eb;display:flex;flex-direction:column;padding:18px 12px;flex-shrink:0;">
<div style="padding:0 6px;margin-bottom:26px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<div class="vibn-avatar" style="width:26px;height:26px;background:linear-gradient(135deg,#2E2A5E,#4338CA);border-radius:6px;display:flex;align-items:center;justify-content:center;flex-shrink:0;"><span class="f" style="font-size:13px;font-weight:700;color:#FFFFFF;">V</span></div>
<span class="f" style="font-size:16px;font-weight:700;color:#1a1a1a;letter-spacing:-0.02em;">vibn</span>
</div>
<div id="sidebar-project-name" style="font-size:11px;font-weight:500;color:#9ca3af;padding-left:34px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:none;"></div>
</div>
<div style="font-size:9.5px;font-weight:600;letter-spacing:0.08em;text-transform:uppercase;color:#9ca3af;padding:0 6px;margin-bottom:8px;">MVP Setup</div>
<div style="display:flex;flex-direction:column;gap:2px;flex:1;">
<div class="sidebar-phase" onclick="window.location.href='05_describe.html'" style="cursor:pointer;" onmouseover="this.style.background='#f5f3ff'" onmouseout="this.style.background='transparent'">
<div class="phase-dot" style="background:#6366F1;color:#ffffff;"></div>
<div style="font-size:12.5px;color:#6b7280;">Describe</div>
</div>
<div class="sidebar-phase active">
<div class="phase-dot" style="background:#6366F1;color:#ffffff;"></div>
<div><div style="font-size:12.5px;font-weight:600;color:var(--ink);">Architect</div><div style="font-size:10px;color:#9ca3af;">What gets built</div></div>
</div>
<div class="sidebar-phase">
<div class="phase-dot" style="background:#e5e7eb;color:#9ca3af;"></div>
<div style="font-size:12.5px;color:#9ca3af;">Design</div>
</div>
<div class="sidebar-phase">
<div class="phase-dot" style="background:#e5e7eb;color:#9ca3af;"></div>
<div style="font-size:12.5px;color:#9ca3af;">Website</div>
</div>
<div class="sidebar-phase">
<div class="phase-dot" style="background:#e5e7eb;color:#9ca3af;"></div>
<div style="font-size:12.5px;color:#9ca3af;">Build MVP</div>
</div>
</div>
<div style="border-top:1px solid #e5e7eb;margin-top:14px;padding-top:12px;">
<button onclick="saveAndExit()" style="display:flex;align-items:center;justify-content:center;gap:7px;width:100%;background:#eef2ff;border:1px solid #e0e7ff;border-radius:8px;padding:9px 10px;cursor:pointer;font-family:'Plus Jakarta Sans',sans-serif;transition:background 0.15s;" onmouseover="this.style.background=document.documentElement.dataset.theme==='dark'?'':'#e0e7ff'" onmouseout="this.style.background=document.documentElement.dataset.theme==='dark'?'':'#eef2ff'">
<span style="font-size:12px;font-weight:600;color:#6366F1;">Save & go to dashboard</span>
</button>
<button id="dark-toggle" onclick="toggleTheme()" style="margin-top:8px;display:flex;align-items:center;justify-content:center;width:100%;background:transparent;border:1px solid var(--border);border-radius:8px;padding:8px 10px;cursor:pointer;font-family:'Plus Jakarta Sans',sans-serif;font-size:12px;font-weight:500;color:var(--mid);transition:background 0.15s,border-color 0.15s;" onmouseover="this.style.borderColor='#6366F1';this.style.color='#6366F1';" onmouseout="this.style.borderColor='var(--border)';this.style.color='var(--mid)';">🌙 Dark mode</button>
</div>
</div>
<!-- ── MAIN ── -->
<div class="arch-main" style="flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0;position:relative;z-index:1;">
<!-- Top bar -->
<div class="arch-topbar" style="padding:18px 28px 14px;background:var(--white);border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;flex-shrink:0;">
<div>
<div class="f" style="font-size:17px;font-weight:700;color:var(--ink);margin-bottom:3px;">Your product blueprint</div>
<div style="font-size:12.5px;color:var(--muted);">Weve translated your idea into a complete system — how its built, how it runs, and how users interact with it. Review and confirm to continue</div>
</div>
</div>
<!-- Scrollable body -->
<div class="arch-scroll" style="flex:1;overflow-y:auto;padding:24px 28px;display:flex;flex-direction:column;gap:12px;">
<!-- Intro message -->
<div style="display:flex;align-items:flex-start;gap:10px;">
<div class="vibn-avatar" style="width:26px;height:26px;background:linear-gradient(135deg,#2E2A5E,#4338CA);border-radius:6px;display:flex;align-items:center;justify-content:center;flex-shrink:0;margin-top:1px;"><span style="font-size:12px;font-weight:700;color:#FFFFFF;">V</span></div>
<div class="arch-intro-bubble" style="max-width:84%;background:#f0f4ff;border:1px solid #e0e7ff;border-radius:4px 12px 12px 12px;padding:11px 14px;font-size:13px;color:var(--ink);line-height:1.65;">
Here's the technical stack we've set up for <strong id="intro-project-name" style="font-weight:600;">your product</strong>. These are the best defaults for an idea like yours — review each decision below and change anything that doesn't feel right.
</div>
</div>
<!-- Blueprint card -->
<div class="arch-blueprint-card" style="background:var(--white);border:1px solid var(--border);border-radius:14px;overflow:visible;box-shadow:0 4px 20px rgba(30,27,75,0.05);">
<!-- Card header -->
<div style="padding:14px 22px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;">
<div style="width:8px;height:8px;border-radius:50%;background:#10B981;flex-shrink:0;"></div>
<span style="font-size:13px;font-weight:600;color:var(--ink);">How vibn will build it</span>
<button onclick="openWhy()" style="margin-left:auto;background:none;border:none;cursor:pointer;font-family:'Plus Jakarta Sans',sans-serif;font-size:12px;color:#6366F1;font-weight:600;padding:0;display:flex;align-items:center;gap:5px;">💡 Why these choices?</button>
</div>
<!-- Frontend -->
<div class="blueprint-row">
<div style="width:32px;height:32px;border-radius:9px;background:var(--indigo-soft);display:flex;align-items:center;justify-content:center;font-size:15px;color:var(--indigo);"></div>
<div>
<div style="font-size:13px;font-weight:600;color:var(--ink);margin-bottom:2px;">Frontend</div>
<div style="font-size:11.5px;color:var(--muted);">Where will your users mostly be when they use your product — at a desk, or on the go? This shapes every screen we design.</div>
</div>
<div class="opt-btns">
<button class="opt-btn selected" data-group="frontend" data-tip="Runs in any browser, desktop & mobile" onclick="selectOpt('frontend',this)">Web app</button>
<button class="opt-btn" data-group="frontend" data-tip="Optimised for phones, still works on desktop" onclick="selectOpt('frontend',this)">Mobile-first</button>
</div>
</div>
<!-- Backend -->
<div class="blueprint-row">
<div style="width:32px;height:32px;border-radius:9px;background:var(--indigo-soft);display:flex;align-items:center;justify-content:center;font-size:15px;color:var(--indigo);"></div>
<div>
<div style="font-size:13px;font-weight:600;color:var(--ink);margin-bottom:2px;">Backend & Database</div>
<div style="font-size:11.5px;color:var(--muted);">The invisible part that stores your users' data and makes everything work behind the scenes</div>
</div>
<div class="opt-btns">
<button class="opt-btn selected" data-group="backend" data-tip="Standard setup, works for almost everything" onclick="selectOpt('backend',this)">API + database</button>
<button class="opt-btn" data-group="backend" data-tip="Live updates between users — great for collaboration" onclick="selectOpt('backend',this)">Real-time</button>
</div>
</div>
<!-- Auth -->
<div class="blueprint-row">
<div style="width:32px;height:32px;border-radius:9px;background:var(--indigo-soft);display:flex;align-items:center;justify-content:center;font-size:15px;color:var(--indigo);"></div>
<div>
<div style="font-size:13px;font-weight:600;color:var(--ink);margin-bottom:2px;">Sign up & Login</div>
<div style="font-size:11.5px;color:var(--muted);">How people create an account and get back in — fewer steps means more people actually sign up</div>
</div>
<div class="opt-btns">
<button class="opt-btn selected" data-group="auth" data-tip="Google & GitHub sign-in — less friction, more sign-ups" onclick="selectOpt('auth',this)">Email + social</button>
<button class="opt-btn" data-group="auth" data-tip="Simpler setup, no third-party login" onclick="selectOpt('auth',this)">Email only</button>
</div>
</div>
<!-- Payments -->
<div class="blueprint-row">
<div style="width:32px;height:32px;border-radius:9px;background:var(--indigo-soft);display:flex;align-items:center;justify-content:center;font-size:15px;color:var(--indigo);">$</div>
<div>
<div style="font-size:13px;font-weight:600;color:var(--ink);margin-bottom:2px;">Payments</div>
<div style="font-size:11.5px;color:var(--muted);">How you get paid — Stripe handles the card processing so you never touch sensitive data</div>
</div>
<div class="opt-btns">
<button class="opt-btn selected" data-group="payments" data-tip="Monthly or annual recurring payments" onclick="selectOpt('payments',this)">Subscription</button>
<button class="opt-btn" data-group="payments" data-tip="Pay once, own forever" onclick="selectOpt('payments',this)">One-time</button>
</div>
</div>
<!-- Email -->
<div class="blueprint-row">
<div style="width:32px;height:32px;border-radius:9px;background:var(--indigo-soft);display:flex;align-items:center;justify-content:center;font-size:15px;color:var(--indigo);"></div>
<div>
<div style="font-size:13px;font-weight:600;color:var(--ink);margin-bottom:2px;">Email</div>
<div style="font-size:11.5px;color:var(--muted);">Automated messages sent to your users — from welcome emails on day one to newsletters later</div>
</div>
<div class="opt-btns">
<button class="opt-btn selected" data-group="email" data-tip="Welcome emails, resets & marketing newsletters" onclick="selectOpt('email',this)">Full suite</button>
<button class="opt-btn" data-group="email" data-tip="Just transactional emails — resets and confirmations" onclick="selectOpt('email',this)">Essentials only</button>
</div>
</div>
<!-- Hosting — locked -->
<div class="blueprint-row locked">
<div style="width:32px;height:32px;border-radius:9px;background:#F3F4F6;display:flex;align-items:center;justify-content:center;font-size:15px;color:var(--muted);"></div>
<div>
<div style="font-size:13px;font-weight:600;color:var(--ink);margin-bottom:2px;">Hosting</div>
<div style="font-size:11.5px;color:var(--muted);">Where your product lives — on your own servers, so no one else controls your data or your costs</div>
</div>
<div class="opt-btns">
<button class="opt-btn selected" style="cursor:default;">Your servers 🔒</button>
<button class="opt-btn why-btn" onclick="openHostingWhy()" style="border-radius:0 7px 7px 0;border-right:none;color:#6366F1;font-weight:600;">Why?</button>
</div>
</div>
</div>
<!-- Reassurance note -->
<div style="display:flex;align-items:center;gap:10px;padding:11px 16px;background:#f0f4ff;border:1px solid #e0e7ff;border-radius:10px;">
<span style="font-size:16px;flex-shrink:0;">💬</span>
<p style="font-size:12px;color:var(--mid);line-height:1.55;margin:0;">Not sure about any of these? Don't worry — you can change them anytime before we start building.</p>
</div>
</div>
</div>
<!-- ── RIGHT PANEL ── -->
<div class="arch-right" style="width:384px;border-left:1px solid var(--border);background:#f5f3ff;display:flex;flex-direction:column;flex-shrink:0;">
<!-- Panel header — matches Describe PRD style -->
<div style="flex-shrink:0;padding:18px 0 0;">
<div style="margin:0 16px;padding-bottom:14px;border-bottom:1px solid #c7d2fe;">
<div style="font-size:15px;font-weight:800;letter-spacing:0.04em;text-transform:uppercase;color:#4338ca;margin-bottom:5px;">What your users will be able to do</div>
<div style="font-size:12px;color:#A0A0B8;line-height:1.5;">10 screens covering the full user journey, ready to design.</div>
</div>
</div>
<!-- Scrollable content -->
<div class="arch-right-scroll" style="flex:1;overflow-y:auto;padding:16px;">
<!-- Pages -->
<div style="font-size:9.5px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:var(--muted);margin-bottom:8px;">Pages</div>
<div class="arch-deliverable-card" style="background:var(--white);border:1px solid var(--border);border-radius:10px;overflow:hidden;margin-bottom:16px;">
<div style="padding:5px 12px;background:#fafaff;border-bottom:1px solid var(--border);">
<span style="font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted);">Public</span>
</div>
<div class="deliverable-row"><div style="width:5px;height:5px;border-radius:50%;background:#10B981;flex-shrink:0;"></div>Discover your product</div>
<div class="deliverable-row"><div style="width:5px;height:5px;border-radius:50%;background:#10B981;flex-shrink:0;"></div>See your pricing</div>
<div class="deliverable-row"><div style="width:5px;height:5px;border-radius:50%;background:#10B981;flex-shrink:0;"></div>Learn about you</div>
<div style="padding:5px 12px;background:#fafaff;border-top:1px solid var(--border);border-bottom:1px solid var(--border);">
<span style="font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted);">Auth</span>
</div>
<div class="deliverable-row"><div style="width:5px;height:5px;border-radius:50%;background:#10B981;flex-shrink:0;"></div>Create an account</div>
<div class="deliverable-row"><div style="width:5px;height:5px;border-radius:50%;background:#10B981;flex-shrink:0;"></div>Sign back in</div>
<div class="deliverable-row"><div style="width:5px;height:5px;border-radius:50%;background:#10B981;flex-shrink:0;"></div>Reset their password</div>
<div style="padding:5px 12px;background:#fafaff;border-top:1px solid var(--border);border-bottom:1px solid var(--border);">
<span style="font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted);">App</span>
</div>
<div class="deliverable-row"><div style="width:5px;height:5px;border-radius:50%;background:#10B981;flex-shrink:0;"></div>Use the dashboard</div>
<div class="deliverable-row"><div style="width:5px;height:5px;border-radius:50%;background:#10B981;flex-shrink:0;"></div>Manage their settings</div>
<div style="padding:5px 12px;background:#fafaff;border-top:1px solid var(--border);border-bottom:1px solid var(--border);">
<span style="font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted);">Payments</span>
</div>
<div class="deliverable-row"><div style="width:5px;height:5px;border-radius:50%;background:#10B981;flex-shrink:0;"></div>Subscribe and pay</div>
<div class="deliverable-row" style="border-bottom:none;"><div style="width:5px;height:5px;border-radius:50%;background:#10B981;flex-shrink:0;"></div>Manage their plan</div>
</div>
<!-- Infrastructure -->
<div style="font-size:9.5px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:var(--muted);margin-bottom:8px;">Infrastructure</div>
<div class="arch-deliverable-card" style="background:var(--white);border:1px solid var(--border);border-radius:10px;overflow:hidden;">
<div style="display:flex;align-items:center;gap:10px;padding:10px 12px;border-bottom:1px solid var(--border);">
<div style="width:28px;height:28px;border-radius:7px;background:var(--indigo-soft);display:flex;align-items:center;justify-content:center;font-size:13px;flex-shrink:0;">🖥</div>
<div><div style="font-size:12px;font-weight:600;color:var(--ink);">Your own servers</div><div style="font-size:11px;color:var(--muted);">No platform lock-in, ever</div></div>
</div>
<div style="display:flex;align-items:center;gap:10px;padding:10px 12px;border-bottom:1px solid var(--border);">
<div style="width:28px;height:28px;border-radius:7px;background:var(--indigo-soft);display:flex;align-items:center;justify-content:center;font-size:13px;flex-shrink:0;">🔁</div>
<div><div style="font-size:12px;font-weight:600;color:var(--ink);">Auto-deploy via Coolify</div><div style="font-size:11px;color:var(--muted);">Every push goes live instantly</div></div>
</div>
<div style="display:flex;align-items:center;gap:10px;padding:10px 12px;">
<div style="width:28px;height:28px;border-radius:7px;background:var(--indigo-soft);display:flex;align-items:center;justify-content:center;font-size:13px;flex-shrink:0;">🔒</div>
<div><div style="font-size:12px;font-weight:600;color:var(--ink);">Code stored in Gitea</div><div style="font-size:11px;color:var(--muted);">Private repo, yours alone</div></div>
</div>
</div>
<!-- Timeline / cost signal -->
<div style="display:flex;align-items:center;justify-content:center;gap:16px;padding:10px 0 2px;">
<span style="font-size:11px;color:var(--muted);display:flex;align-items:center;gap:5px;"><span>~34 weeks to build</span></span>
<span style="font-size:11px;color:var(--muted);">·</span>
<span style="font-size:11px;color:var(--muted);display:flex;align-items:center;gap:5px;">💰 <span>No platform fees</span></span>
</div>
</div>
<!-- Footer -->
<div class="arch-right-footer" style="border-top:1px solid var(--border);padding:9px 0 13px;flex-shrink:0;display:flex;flex-direction:column;align-items:center;">
<p style="font-size:11.5px;color:var(--muted);text-align:center;margin:0 0 10px;line-height:1.5;">All set — let's decide how it looks.</p>
<a href="07_design.html" style="text-decoration:none;display:block;width:80%;">
<button class="btn-primary" style="width:100%;padding:12px 14px;border-radius:8px;">Next: Design</button>
</a>
</div>
</div>
</div>
<!-- ── WHY POPUP ── -->
<div id="why-popup" style="display:none;position:fixed;inset:0;background:rgba(15,14,26,0.45);backdrop-filter:blur(2px);z-index:200;align-items:center;justify-content:center;padding:24px;" onclick="closeWhy()">
<div style="background:var(--white);border-radius:16px;width:100%;max-width:480px;box-shadow:0 24px 64px rgba(30,27,75,0.18);padding:32px;" onclick="event.stopPropagation()">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:20px;">
<span style="font-size:20px;">💡</span>
<h3 style="font-size:17px;font-weight:700;color:var(--ink);letter-spacing:-0.02em;">Why we chose this for your product</h3>
</div>
<p style="font-size:13px;color:var(--mid);line-height:1.8;margin-bottom:12px;">Based on your idea, vibn picked a <strong style="color:var(--ink);font-weight:600;">web app with subscription billing</strong> — the fastest path to recurring revenue for a SaaS product.</p>
<p style="font-size:13px;color:var(--mid);line-height:1.8;margin-bottom:12px;"><strong style="color:var(--ink);font-weight:600;">Email + social login</strong> keeps sign-up friction low. Most people won't create a password for something they haven't tried yet — one click with Google removes that barrier.</p>
<p style="font-size:13px;color:var(--mid);line-height:1.8;margin-bottom:24px;"><strong style="color:var(--ink);font-weight:600;">Your own hosting</strong> means you own the infrastructure outright. No platform lock-in, no surprise price hikes. Coolify + Gitea are already configured to your account.</p>
<button onclick="closeWhy()" class="btn-primary" style="width:100%;padding:12px;">Got it</button>
</div>
</div>
<!-- ── SAVE POPUP ── -->
<div id="save-exit-popup">
<div id="save-exit-box">
<div class="save-icon"></div>
<h3>Your progress is saved.</h3>
<p>You can come back to this project anytime from your dashboard — everything will be exactly where you left it.</p>
<button onclick="window.location.href='03_dashboard.html'" style="width:100%;background:linear-gradient(135deg,#2e2a5e,#4338ca);color:#fff;border:none;border-radius:10px;padding:12px;font-family:'Plus Jakarta Sans',sans-serif;font-size:14px;font-weight:600;cursor:pointer;margin-bottom:10px;">Got it, go to dashboard</button>
<button class="save-cancel" onclick="cancelSaveExit()">Stay on this page</button>
</div>
</div>
<!-- ── MODAL ── -->
<div id="modal" class="modal-bg" onclick="closeModal()">
<div class="modal-card" onclick="event.stopPropagation()">
<div style="padding:18px 22px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;">
<span id="modal-title" class="f" style="font-size:15px;font-weight:700;color:var(--ink);"></span>
<button onclick="closeModal()" style="background:transparent;border:none;color:var(--muted);cursor:pointer;font-size:18px;line-height:1;">×</button>
</div>
<div style="padding:16px 22px;" id="modal-options"></div>
<div style="padding:10px 22px 18px;">
<button onclick="closeModal()" class="btn-primary" style="width:100%;padding:12px;border-radius:9px;">Got it</button>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function(){
try {
var name = localStorage.getItem('vibn_project_name') || 'My project';
var el = document.getElementById('sidebar-project-name');
el.textContent = name;
el.style.display = 'block';
var intro = document.getElementById('intro-project-name');
if(intro) intro.textContent = name;
} catch(e){}
// Init Blueprint tab on tablet + mobile
if(window.innerWidth <= 1024){
document.querySelector('.arch-main').classList.add('tab-active');
}
});
function switchArchTab(tab){
var main = document.querySelector('.arch-main');
var right = document.querySelector('.arch-right');
var btnBlueprint = document.getElementById('tab-blueprint');
var btnScope = document.getElementById('tab-scope');
if(tab === 'blueprint'){
main.classList.add('tab-active');
right.classList.remove('tab-active');
btnBlueprint.classList.add('active');
btnScope.classList.remove('active');
} else {
right.classList.add('tab-active');
main.classList.remove('tab-active');
btnScope.classList.add('active');
btnBlueprint.classList.remove('active');
}
}
function saveAndExit(){ document.getElementById('save-exit-popup').classList.add('visible'); }
function cancelSaveExit(){ document.getElementById('save-exit-popup').classList.remove('visible'); }
function openWhy(){ document.getElementById('why-popup').style.display='flex'; }
function closeWhy(){ document.getElementById('why-popup').style.display='none'; }
function selectOpt(group, el){
document.querySelectorAll('[data-group="'+group+'"]').forEach(function(b){ b.classList.remove('selected'); });
el.classList.add('selected');
if (group === 'frontend') {
localStorage.setItem('vibn_frontend', el.getAttribute('data-tip'));
}
}
function openHostingWhy(){
document.getElementById('modal-title').textContent = 'Why your own servers?';
document.getElementById('modal-options').innerHTML =
'<p style="font-size:13px;color:var(--mid);line-height:1.8;margin-bottom:12px;"><strong style="color:var(--ink)">Most platforms rent you server space</strong> — which means they control your data, your uptime, and your bill. The day they raise prices or shut down, your product goes with it.</p>' +
'<p style="font-size:13px;color:var(--mid);line-height:1.8;margin-bottom:12px;"><strong style="color:var(--ink)">With vibn, your product lives on your own servers.</strong> You own the infrastructure outright. Nobody can lock you out, hike your costs, or access your users\u2019 data without your permission.</p>' +
'<p style="font-size:13px;color:var(--mid);line-height:1.8;">We handle the hard part: <a href="https://coolify.io" target="_blank" style="color:var(--indigo);font-weight:600;text-decoration:none;">Coolify</a> auto-deploys your code every time you push an update, and <a href="https://gitea.com" target="_blank" style="color:var(--indigo);font-weight:600;text-decoration:none;">Gitea</a> stores your codebase securely. You get full control of your infrastructure \u2014 without needing to know how any of it works.</p>';
document.getElementById('modal').classList.add('open');
}
function openModal(title, opt1, desc1, opt2, desc2){
document.getElementById('modal-title').textContent = title;
document.getElementById('modal-options').innerHTML = '<p style="font-size:13px;color:var(--mid);line-height:1.6;">'+desc1+'</p>';
document.getElementById('modal').classList.add('open');
}
function closeModal(){ document.getElementById('modal').classList.remove('open'); }
function showTip(el){
hideTip();
var tip=document.createElement('div');
tip.id='vibn-tip';
tip.textContent=el.dataset.tip;
tip.style.cssText='position:fixed;background:linear-gradient(135deg,#2E2A5E,#4338CA);color:#fff;font-size:11px;font-weight:500;line-height:1.5;padding:6px 10px;border-radius:6px;white-space:nowrap;z-index:9999;pointer-events:none;box-shadow:0 4px 16px rgba(67,56,202,0.35);font-family:Plus Jakarta Sans,sans-serif;transition:opacity 0.1s;';
document.body.appendChild(tip);
var r=el.getBoundingClientRect();
tip.style.left=(r.left+r.width/2-tip.offsetWidth/2)+'px';
tip.style.top=(r.top-tip.offsetHeight-9)+'px';
var arrow=document.createElement('div');
arrow.id='vibn-tip-arrow';
arrow.style.cssText='position:fixed;border:5px solid transparent;border-top-color:#4338CA;z-index:9999;pointer-events:none;';
document.body.appendChild(arrow);
arrow.style.left=(r.left+r.width/2-5)+'px';
arrow.style.top=(r.top-9)+'px';
}
function hideTip(){
var t=document.getElementById('vibn-tip');
var a=document.getElementById('vibn-tip-arrow');
if(t)t.remove();
if(a)a.remove();
}
document.addEventListener('mouseover',function(e){var b=e.target.closest('.opt-btn[data-tip]');if(b)showTip(b);});
document.addEventListener('mouseout',function(e){var b=e.target.closest('.opt-btn[data-tip]');if(b)hideTip();});
function toggleTheme(){const html=document.documentElement;const isDark=html.dataset.theme==='dark';html.dataset.theme=isDark?'':'dark';document.getElementById('dark-toggle').textContent=isDark?'🌙 Dark mode':'☀️ Light mode';localStorage.setItem('vibn-theme',isDark?'':'dark');}
(function(){const saved=localStorage.getItem('vibn-theme');if(saved==='dark'){document.documentElement.dataset.theme='dark';document.addEventListener('DOMContentLoaded',function(){const btn=document.getElementById('dark-toggle');if(btn)btn.textContent='☀️ Light mode';});}})();
</script>
</body></html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,805 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><link rel="icon" href="favicon_clean.ico">
<script>if(localStorage.getItem('vibn-theme')==='dark')document.documentElement.dataset.theme='dark';</script>
<title>vibn — Website</title>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0;}
:root{--ink:#1a1510;--ink2:#2c2c2a;--ink3:#444441;--mid:#5f5e5a;--muted:#888780;--stone:#b4b2a9;--parch:#d3d1c7;--cream:#f1efe8;--paper:#f7f4ee;--white:#fdfcfa;--border:#e8e2d9; --section: #b8a9e93f;}
/* ── Dark mode tokens — exact match with design.html ── */
[data-theme="dark"]{--ink:#EEEEFF;--ink2:#B8B8D0;--ink3:#8484A8;--mid:#9898B8;--muted:#c2c2ee;--border:rgba(255,255,255,0.08);--cream:rgba(108,124,255,0.14);--paper:#0A1120;--white:rgba(255,255,255,0.05);--stone:rgba(255,255,255,0.08);--parch:rgba(255,255,255,0.06);--section:rgba(108,124,255,0.55);--accent-primary:#6C7CFF;--dm-surf-sidebar:rgba(12,18,34,0.72);--dm-surf-topbar:rgba(12,18,34,0.58);--dm-surf-panel:rgba(12,18,34,0.58);--dm-surf-right:rgba(12,18,34,0.58);--dm-surf-card:rgba(255,255,255,0.05);--dm-surf-refine:rgba(8,12,22,0.60);--dm-border:rgba(255,255,255,0.08);--dm-border-strong:rgba(255,255,255,0.14);--dm-border-hero:rgba(255,255,255,0.18);--dm-accent:#6C7CFF;--dm-accent-fill:rgba(108,124,255,0.14);--dm-accent-fill-mid:rgba(108,124,255,0.20);--dm-accent-border:rgba(108,124,255,0.55);--dm-text-1:#EEEEFF;--dm-text-2:#B4B4CC;--dm-text-3:#d2d2ef;--dm-shadow-panel:0 4px 36px rgba(0,0,0,0.55);--dm-shadow-hero:0 20px 70px rgba(0,0,0,0.65),0 6px 24px rgba(0,0,0,0.40),inset 0 1px 0 rgba(255,255,255,0.10);}
html:not([data-theme="dark"]) .preview-box {background: linear-gradient(to bottom, #FAFAFA, #F5F3FF);}
[data-theme="dark"] body{background:linear-gradient(to bottom,rgba(108,80,255,0.06) 0%,rgba(60,120,255,0.10) 38%,transparent 62%),linear-gradient(to bottom,rgba(108,124,255,0.10),transparent 180px),radial-gradient(900px 520px at 14% -8%,rgba(108,124,255,0.24),transparent 62%),radial-gradient(760px 420px at 88% 0%,rgba(72,145,255,0.16),transparent 60%),linear-gradient(180deg,#18213B 0%,#101726 48%,#0A1120 100%);}
[data-theme="dark"] .sidebar-col{background:var(--dm-surf-sidebar)!important;border-right:1px solid var(--dm-border)!important;box-shadow:2px 0 28px rgba(0,0,0,0.60)!important;-webkit-backdrop-filter:blur(20px)!important;backdrop-filter:blur(20px)!important;}
[data-theme="dark"] .sidebar-col [style*="color:#1a1a1a"],[data-theme="dark"] .sidebar-col [style*="color: #1a1a1a"]{color:var(--dm-text-1)!important;}
[data-theme="dark"] .sidebar-col [style*="color:#6b7280"],[data-theme="dark"] .sidebar-col [style*="color: #6b7280"]{color:var(--dm-text-3)!important;}
[data-theme="dark"] .sidebar-col [style*="color: #9ca3af"]{color:var(--dm-text-3)!important;}
.ph-name{color:#9ca3af;}
[data-theme="dark"] .ph-name{color:var(--dm-text-3);}
[data-theme="dark"] .sidebar-col [style*="color:#444441"]{color:var(--dm-text-3)!important;}
[data-theme="dark"] .sidebar-col [style*="background:#6366F1"]{background:var(--dm-accent)!important;color:#0F1424!important;}
[data-theme="dark"] .sidebar-col [style*="background:#e5e7eb"]{background:rgba(255,255,255,0.08)!important;}
[data-theme="dark"] .sidebar-phase.active{background:var(--dm-accent-fill)!important;}
[data-theme="dark"] .sidebar-phase:not(.active):hover{background:rgba(255,255,255,0.08)!important;}
[data-theme="dark"] .sidebar-col [style*="background:#fafaff"]{background:rgba(108,124,255,0.08)!important;}
[data-theme="dark"] .sidebar-col [style*="border:1px solid rgba(99,102,241"]{border-color:var(--dm-border)!important;}
[data-theme="dark"] .sidebar-col [style*="border-top:1px solid #e5e7eb"]{border-top-color:var(--dm-border)!important;}
[data-theme="dark"] .sidebar-col [style*="background:#eef2ff"]{background:var(--dm-accent-fill)!important;border-color:var(--dm-accent-border)!important;}
[data-theme="dark"] .sidebar-col [style*="background:#eef2ff"] span{color:var(--dm-accent)!important;}
[data-theme="dark"] button[onclick="saveAndExit()"]{background:var(--dm-accent-fill)!important;border-color:var(--dm-accent-border)!important;}
[data-theme="dark"] button[onclick="saveAndExit()"]:hover{background:var(--dm-accent-fill-mid)!important;}
[data-theme="dark"] button[onclick="saveAndExit()"]:hover span{color:#fff!important;}
[data-theme="dark"] button[onclick="saveAndExit()"] span{color:var(--dm-accent)!important;}
[data-theme="dark"] #sidebar-project-name{color:var(--dm-text-3)!important;}
[data-theme="dark"] #dark-toggle{background:rgba(255,255,255,0.05)!important;border-color:var(--dm-border)!important;color:var(--dm-text-3)!important;}
[data-theme="dark"] #dark-toggle:hover{background:rgba(255,255,255,0.10)!important;color:var(--dm-text-1)!important;}
[data-theme="dark"] .vibn-avatar{background:var(--dm-accent)!important;}
[data-theme="dark"] .arch-topbar{background:var(--dm-surf-topbar)!important;border-bottom:1px solid rgba(255,255,255,0.04)!important;-webkit-backdrop-filter:blur(20px)!important;backdrop-filter:blur(20px)!important;}
[data-theme="dark"] .arch-topbar .f{color:var(--dm-text-1)!important;}
[data-theme="dark"] .arch-topbar [style*="color:#9ca3af"]{color:var(--dm-text-3)!important;}
[data-theme="dark"] .website-left{background:var(--dm-surf-panel)!important;border-right:1px solid var(--dm-border)!important;box-shadow:4px 0 36px rgba(0,0,0,0.55)!important;-webkit-backdrop-filter:blur(20px)!important;backdrop-filter:blur(20px)!important;}
[data-theme="dark"] .website-left [style*="color:#9ca3af"]{color:var(--dm-text-3)!important;}
[data-theme="dark"] .website-left [style*="background:#f0f0f8"]{background:rgba(108,124,255,0.08)!important;}
[data-theme="dark"] .tab{color:var(--dm-text-3)!important;}
[data-theme="dark"] .tab.on{color:var(--dm-accent)!important;border-bottom-color:var(--dm-accent)!important;}
body{font-family:'Plus Jakarta Sans',sans-serif;background:var(--paper);display:flex;flex-direction:column;height:100vh;overflow:hidden;}
.f{font-family:'Plus Jakarta Sans',sans-serif;}
.sidebar-phase{display:flex;align-items:center;gap:9px;padding:9px 10px;border-radius:8px;}
.sidebar-phase.active{background:#fafaff;}
.phase-dot{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:10px;}
.tab{padding:11px 22px;border:none;background:transparent;cursor:pointer;font-family:'Plus Jakarta Sans',sans-serif;font-size:13.5px;color:var(--muted);border-bottom:2px solid transparent;}
.tab.on{color:#6366F1;border-bottom-color:#6366F1;font-weight:600;}
input[type=range]{-webkit-appearance:none;width:100%;height:4px;border-radius:2px;background:#c7d2fe;outline:none;cursor:pointer;}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:16px;border-radius:50%;background:#6366F1;cursor:pointer;}
[data-theme="dark"] input[type=range]{background:rgba(255,255,255,0.12);}
[data-theme="dark"] input[type=range]::-webkit-slider-thumb{background:var(--dm-accent);}
.section{display:none;}
.section.active{display:block;}
#sec-style.active{display:flex;}
.ws-btn{border-radius:9px;border:1px solid var(--border);padding:11px 13px;cursor:pointer;text-align:left;margin-bottom:7px;width:100%;transition:all 0.15s;}
.ws-btn.selected{border:2px solid var(--ink);}
.deliverable-row{display:flex;align-items:center;gap:8px;padding:7px 10px;font-size:12px;color:var(--mid);border-bottom:1px solid var(--border);}
.deliverable-row:last-child{border-bottom:none;}
.vpill{border:1.5px solid #e0e7ff;border-radius:20px;padding:4px 11px;font-size:11px;font-weight:500;color:#6b7280;background:#fff;cursor:pointer;font-family:'Plus Jakarta Sans',sans-serif;transition:all 0.15s;white-space:nowrap;}
.vpill:hover{border-color:#6366F1;color:#6366F1;}
.vpill.active{background:#6366F1;border-color:#6366F1;color:#fff;font-weight:600;box-shadow:0 2px 10px rgba(99,102,241,0.12);}
.tchip{border:1.5px solid #e0e7ff;border-radius:20px;padding:4px 12px;font-size:11.5px;font-weight:500;color:#6b7280;background:#fff;cursor:pointer;font-family:'Plus Jakarta Sans',sans-serif;transition:all 0.15s;}
.tchip:hover{border-color:#6366F1;color:#6366F1;}
.tchip.active{background:#6366F1;border-color:#6366F1;color:#fff;font-weight:600;box-shadow:0 1px 6px rgba(99,102,241,0.10);}
.scard{border:2px solid #e0e7ff;border-radius:8px;cursor:pointer;text-align:left;background:#fff;overflow:hidden;transition:all 0.15s;padding:0;}
.scard:hover{border-color:#a5b4fc;}
.scard.selected{border-color:#6366F1;box-shadow:0 0 0 3px rgba(99,102,241,0.1),0 2px 10px rgba(99,102,241,0.12);}
.ws-rp-card{box-shadow:0 2px 12px rgba(99,102,241,0.08);}
.next-btn{width:100%;background:linear-gradient(135deg,#4338CA,#6366F1);color:#FFFFFF;border:none;border-radius:8px;padding:12px 14px;font-family:'Plus Jakarta Sans',sans-serif;font-size:13px;font-weight:600;cursor:pointer;box-shadow:0 4px 18px rgba(99,102,241,0.22),0 1px 4px rgba(99,102,241,0.12);transition:box-shadow 0.2s;}
.scard-name{font-size:11px;font-weight:700;color:#1a1a1a;}
.scard-desc{font-size:9.5px;color:#6b7280;margin-top:1px;}
[data-theme="dark"] .vpill{background:var(--dm-surf-card)!important;border-color:var(--dm-border)!important;color:#fff)!important;}
[data-theme="dark"] .vpill:hover{border-color:rgba(108,124,255,0.45)!important;color:var(--dm-text-2)!important;}
[data-theme="dark"] .vpill.active{background:var(--dm-accent)!important;border-color:var(--dm-accent)!important;color:#fff!important;}
[data-theme="dark"] .tchip{background:var(--dm-surf-card)!important;border-color:var(--dm-border)!important;color:var(--dm-text-3)!important;}
[data-theme="dark"] .tchip.active{background:var(--dm-accent)!important;border-color:var(--dm-accent)!important;color:#fff!important;}
[data-theme="dark"] .scard{background:var(--dm-surf-card)!important;border-color:var(--dm-border)!important;}
[data-theme="dark"] .scard:hover{border-color:var(--dm-border-strong)!important;}
[data-theme="dark"] .scard.selected{border-color:var(--dm-accent)!important;box-shadow:0 0 0 2px rgba(108,124,255,0.22)!important;}
[data-theme="dark"] .scard-name{color:var(--dm-text-1)!important;}
[data-theme="dark"] .scard-desc{color:var(--dm-text-3)!important;}
[data-theme="dark"] .main-content-area{background:transparent!important;}
[data-theme="dark"] .main-content-area [style*="background:#f0f0f8"],[data-theme="dark"] .main-content-area [style*="background:#f8f9ff"]{background:rgba(255,255,255,0.03)!important;}
@keyframes pulse-dot{0%,100%{opacity:1}50%{opacity:0.35}}
.opt-btns{display:flex;gap:0;flex-shrink:0;border:1.5px solid var(--border);border-radius:8px;overflow:visible;}
.opt-btn{flex:1;text-align:center;font-size:12px;font-weight:500;color:var(--mid);background:transparent;border:none;border-right:1.5px solid var(--border);border-radius:0;padding:6px 13px;cursor:pointer;font-family:'Plus Jakarta Sans',sans-serif;transition:all 0.15s;white-space:nowrap;position:relative;}
.opt-btn:first-child{border-radius:7px 0 0 7px;}
.opt-btn:last-child{border-right:none;border-radius:0 7px 7px 0;}
.opt-btn:hover{background:#f5f5ff;color:#6366F1;}
.opt-btn.selected{background:rgba(99,102,241,0.08);color:#4338CA;font-weight:600;}
[data-theme="dark"] .opt-btns{border-color:var(--dm-border)!important;}
[data-theme="dark"] .opt-btn{color:var(--dm-text-2)!important;border-right-color:var(--dm-border)!important;}
[data-theme="dark"] .opt-btn:hover{background:rgba(108,124,255,0.09)!important;color:var(--dm-accent)!important;}
[data-theme="dark"] .opt-btn.selected{background:var(--dm-accent-fill)!important;color:var(--dm-accent)!important;font-weight:600!important;box-shadow:inset 0 0 0 1px rgba(108,124,255,0.32),0 2px 16px rgba(108,124,255,0.22)!important;}
[data-theme="dark"] .website-right{background:var(--dm-surf-right)!important;border-left:1px solid var(--dm-border)!important;box-shadow:-4px 0 36px rgba(0,0,0,0.55)!important;-webkit-backdrop-filter:blur(20px)!important;backdrop-filter:blur(20px)!important;}
[data-theme="dark"] .website-right [style*="border-bottom:1px solid #c7d2fe"]{border-bottom-color:var(--dm-border)!important;}
[data-theme="dark"] .website-right [style*="color:#4338ca"]{color:var(--dm-accent)!important;}
[data-theme="dark"] .website-right [style*="background:#eef2ff"]{background:var(--dm-accent-fill)!important;color:var(--dm-accent)!important;}
.sec-div{height:1px;background: var(--section);}
[data-theme="dark"] .website-left .sec-div{background:rgba(108,124,255,0.20)!important;}
.surprise-btn{width:100%;padding:10px;border:1.5px solid #e0e7ff;border-radius:9px;background:#6366F1;font-family:'Plus Jakarta Sans',sans-serif;font-size:12px;font-weight:600;color:#ffffff;cursor:pointer;transition:all 0.15s;text-align:center;display:block;box-shadow:0 2px 14px rgba(99,102,241,0.22);}
.surprise-btn:hover{background:#5045c8;border-color:#c7d2fe;}
[data-theme="dark"] .preview-box{background:rgba(6,10,20,0.32)!important;}
[data-theme="dark"] .preview-chrome-box{border:1.5px solid var(--dm-border-hero)!important;box-shadow:var(--dm-shadow-hero),0 0 0 1px rgba(108,124,255,0.08)!important;}
[data-theme="dark"] [style*="background:#eef2ff;border-radius:11px"]{background:var(--dm-surf-topbar)!important;border-color:var(--dm-border)!important;}
[data-theme="dark"] span[style*="color:#6366F1;background:#eef2ff"]{color:var(--dm-accent)!important;background:var(--dm-accent-fill)!important;}
[data-theme="dark"] [style*="color:#6366F1"][style*="text-transform:uppercase"]{color:var(--dm-accent)!important;}
[data-theme="dark"] .surprise-btn{background:linear-gradient(135deg,#5450CC,var(--dm-accent))!important;border-color:rgba(108,124,255,0.35)!important;color:#fff!important;box-shadow:0 4px 18px rgba(108,124,255,0.22)!important;}
[data-theme="dark"] .surprise-btn:hover{background:linear-gradient(135deg,#4840BE,#6070F8)!important;box-shadow:0 6px 26px rgba(108,124,255,0.30)!important;}
[data-theme="dark"] ::-webkit-scrollbar{width:5px;height:5px;}
[data-theme="dark"] ::-webkit-scrollbar-track{background:transparent;}
[data-theme="dark"] ::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.12);border-radius:3px;}
[data-theme="dark"] ::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,0.22);}
[data-theme="dark"] *{scrollbar-color:rgba(255,255,255,0.12) transparent;scrollbar-width:thin;}
</style>
</head>
<body>
<div style="display:flex;height:100%;overflow:hidden;">
<!-- SIDEBAR -->
<div class="sidebar-col" style="width:200px;background:#ffffff;border-right:1px solid #e5e7eb;display:flex;flex-direction:column;padding:18px 12px;flex-shrink:0;">
<div style="padding:0 6px;margin-bottom:26px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<div class="vibn-avatar" style="width:26px;height:26px;background:linear-gradient(135deg,#2E2A5E,#4338CA);border-radius:6px;display:flex;align-items:center;justify-content:center;flex-shrink:0;"><span class="f" style="font-size:13px;font-weight:700;color:#FFFFFF;">V</span></div>
<span class="f" style="font-size:16px;font-weight:700;color:var(--ink);letter-spacing:-0.02em;">vibn</span>
</div>
<div id="sidebar-project-name" style="font-size:11px;font-weight:500;color:#9ca3af;padding-left:34px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:none;"></div>
</div>
<div class="ph-name" style="font-size:9.5px;font-weight:600;letter-spacing:0.08em;text-transform:uppercase;padding:0 6px;margin-bottom:8px;">MVP Setup</div>
<div style="display:flex;flex-direction:column;gap:2px;flex:1;">
<div class="sidebar-phase" onclick="window.location.href='05_describe.html'" style="cursor:pointer;" onmouseover="this.style.background='#f5f3ff'" onmouseout="this.style.background='transparent'">
<div class="phase-dot" style="background:#6366F1;color:#ffffff;"></div>
<div class="ph-name" style="font-size:12.5px;">Describe</div>
</div>
<div class="sidebar-phase" onclick="window.location.href='06_architect.html'" style="cursor:pointer;" onmouseover="this.style.background='#f5f3ff'" onmouseout="this.style.background='transparent'">
<div class="phase-dot" style="background:#6366F1;color:#ffffff;"></div>
<div class="ph-name" style="font-size:12.5px;">Architect</div>
</div>
<div class="sidebar-phase" onclick="window.location.href='07_design.html'" style="cursor:pointer;" onmouseover="this.style.background='#f5f3ff'" onmouseout="this.style.background='transparent'">
<div class="phase-dot" style="background:#6366F1;color:#ffffff;"></div>
<div class="ph-name" style="font-size:12.5px;">Design</div>
</div>
<div class="sidebar-phase active">
<div class="phase-dot" style="background:#6366F1;color:#ffffff;"></div>
<div><div style="font-size:12.5px;font-weight:600;color:var(--ink);">Website</div><div class="ph-name" style="font-size:10px;">How you'll grow</div></div>
</div>
<div class="sidebar-phase">
<div class="phase-dot" style="background:#e5e7eb;color:#9ca3af;"></div>
<div class="ph-name" style="font-size:12.5px;">Build MVP</div>
</div>
</div>
<div style="border-top:1px solid #e5e7eb;margin-top:14px;padding-top:12px;">
<button onclick="saveAndExit()" style="display:flex;align-items:center;justify-content:center;gap:7px;width:100%;background:#eef2ff;border:1px solid #e0e7ff;border-radius:8px;padding:9px 10px;cursor:pointer;font-family:'Plus Jakarta Sans',sans-serif;transition:background 0.15s;" onmouseover="this.style.background=document.documentElement.dataset.theme==='dark'?'':'#e0e7ff'" onmouseout="this.style.background=document.documentElement.dataset.theme==='dark'?'':'#eef2ff'">
<span style="font-size:12px;font-weight:600;color:#6366F1;">Save & go to dashboard</span>
</button>
<button id="dark-toggle" onclick="toggleTheme()" style="margin-top:8px;display:flex;align-items:center;justify-content:center;width:100%;background:transparent;border:1px solid var(--border);border-radius:8px;padding:8px 10px;cursor:pointer;font-family:'Plus Jakarta Sans',sans-serif;font-size:12px;font-weight:500;color:var(--mid);transition:background 0.15s,border-color 0.15s;" onmouseover="this.style.borderColor='#6366F1';this.style.color='#6366F1';" onmouseout="this.style.borderColor='var(--border)';this.style.color='var(--mid)';">🌙 Dark mode</button>
</div>
</div>
<!-- MAIN -->
<!-- TOP BAR -->
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0;">
<div class="arch-topbar" style="padding:18px 28px 14px;background:var(--white);border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;flex-shrink:0;">
<div>
<div class="f" style="font-size:17px;font-weight:700;color:var(--ink);margin-bottom:3px;">Website</div>
<div style="font-size:12.5px;color:#9ca3af;">This is what people see before signing up. Set your voice, topics, and website style</div>
</div>
</div>
<div class="main-content-area" style="flex:1;overflow:hidden;display:flex;background:#f8f9ff;">
<!-- LEFT: Controls — single panel, no individual cards -->
<div class="website-left" style="width:272px;border-right:1px solid #e0e7ff;overflow-y:auto;padding:0;display:flex;flex-direction:column;flex-shrink:0;background:linear-gradient(to bottom,#FAFAFA,#F5F3FF);">
<!-- Voice section -->
<div style="padding:16px 16px 14px;">
<div style="font-size:9.5px;font-weight:700;letter-spacing:0.09em;text-transform:uppercase;color:#6366F1;margin-bottom:12px;">Voice</div>
<div style="margin-bottom:10px;">
<div style="font-size:10.5px;color:#9ca3af;margin-bottom:5px;">Tone</div>
<div style="display:flex;gap:5px;" id="pills-tone">
<button class="vpill active" onclick="setPill(this,'tone',0)">Friendly</button>
<button class="vpill" onclick="setPill(this,'tone',50)">Balanced</button>
<button class="vpill" onclick="setPill(this,'tone',100)">Professional</button>
</div>
</div>
<div style="margin-bottom:10px;">
<div style="font-size:10.5px;color:#9ca3af;margin-bottom:5px;">Style</div>
<div style="display:flex;gap:5px;" id="pills-style">
<button class="vpill" onclick="setPill(this,'style',0)">Casual</button>
<button class="vpill active" onclick="setPill(this,'style',50)">Balanced</button>
<button class="vpill" onclick="setPill(this,'style',100)">Precise</button>
</div>
</div>
<div>
<div style="font-size:10.5px;color:#9ca3af;margin-bottom:5px;">Personality</div>
<div style="display:flex;gap:5px;" id="pills-pers">
<button class="vpill active" onclick="setPill(this,'pers',0)">Warm</button>
<button class="vpill" onclick="setPill(this,'pers',50)">Steady</button>
<button class="vpill" onclick="setPill(this,'pers',100)">Direct</button>
</div>
</div>
</div>
<div class="sec-div" style="height:1px;background: var(--section);margin:0 16px;"></div>
<!-- Topics section -->
<div style="padding:14px 16px;">
<div style="font-size:9.5px;font-weight:700;letter-spacing:0.09em;text-transform:uppercase;color:#6366F1;margin-bottom:10px;">Topics</div>
<div style="display:flex;flex-wrap:wrap;gap:6px;">
<button class="tchip active" onclick="toggleTopic(this,'problem')">The problem</button>
<button class="tchip active" onclick="toggleTopic(this,'audience')">Who it's for</button>
<button class="tchip active" onclick="toggleTopic(this,'timing')">Why now</button>
<button class="tchip" onclick="toggleTopic(this,'benefits')">Key benefits</button>
<button class="tchip" onclick="toggleTopic(this,'comparison')">vs. alternatives</button>
</div>
</div>
<div class="sec-div" style="height:1px;background: var(--section);margin:0 16px;"></div>
<!-- Style section -->
<div style="padding:14px 16px;">
<div style="font-size:9.5px;font-weight:700;letter-spacing:0.09em;text-transform:uppercase;color:#6366F1;margin-bottom:10px;">Website style</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:7px;">
<button class="scard selected" id="sc-editorial" onclick="setStyle('editorial','#ffffff','#ef4444','Editorial','Bold headlines, strong opinions')">
<div style="height:28px;background:#fff;border-radius:5px 5px 0 0;padding:5px 8px;display:flex;flex-direction:column;gap:3px;border:1px solid rgba(0,0,0,0.07);border-bottom:none;">
<div style="width:50%;height:3px;border-radius:2px;background:#111;"></div>
<div style="width:26%;height:6px;border-radius:3px;background:#ef4444;"></div>
</div>
<div style="padding:4px 8px 6px;"><div class="scard-name">Editorial</div><div class="scard-desc" style="color:#ef4444;">Bold & opinionated</div></div>
</button>
<button class="scard" id="sc-startup" onclick="setStyle('startup','#f8fafc','#0ea5e9','Startup energy','Clear, conversion-focused')">
<div style="height:28px;background:#f8fafc;border-radius:5px 5px 0 0;padding:5px 8px;display:flex;flex-direction:column;gap:3px;border:1px solid rgba(0,0,0,0.05);border-bottom:none;">
<div style="width:50%;height:3px;border-radius:2px;background:#0f172a;opacity:0.65;"></div>
<div style="width:26%;height:6px;border-radius:3px;background:#0ea5e9;"></div>
</div>
<div style="padding:4px 8px 6px;"><div class="scard-name">Startup</div><div class="scard-desc" style="color:#0ea5e9;">Clean & focused</div></div>
</button>
<button class="scard" id="sc-minimal" onclick="setStyle('minimal','#ffffff','#111111','Ultra minimal','Let the product speak')">
<div style="height:28px;background:#fff;border-radius:6px 6px 0 0;padding:5px 8px;display:flex;flex-direction:column;gap:3px;border:1px solid rgba(0,0,0,0.06);border-bottom:none;">
<div style="width:50%;height:3px;border-radius:2px;background:#111;opacity:0.75;"></div>
<div style="width:26%;height:6px;border-radius:3px;background:#111;"></div>
</div>
<div style="padding:4px 8px 6px;"><div class="scard-name">Minimal</div><div class="scard-desc" style="color:#6b6b6b;">Less is more</div></div>
</button>
<button class="scard" id="sc-soft" onclick="setStyle('soft','#FAFCFA','#7FA58A','Soft UI','Smooth, friendly, and polished')">
<div style="height:28px;background:#FAFCFA;border-radius:5px 5px 0 0;padding:5px 8px;display:flex;flex-direction:column;gap:3px;border:1px solid rgba(0,0,0,0.06);border-bottom:none;">
<div style="width:50%;height:3px;border-radius:2px;background:#1f2937;opacity:0.45;"></div>
<div style="width:26%;height:6px;border-radius:3px;background:#7FA58A;"></div>
</div>
<div style="padding:4px 8px 6px;">
<div class="scard-name">Soft UI</div>
<div class="scard-desc" style="color:#6B8A78;">Smooth & approachable</div>
</div>
</button>
</div>
</div>
<!-- AI decide — flush to bottom -->
<div style="margin-top:auto;padding:14px 16px 18px;">
<div class="sec-div" style="margin:0 0 14px;"></div>
<button class="surprise-btn" onclick="aiDecide()">✨ Surprise me</button>
</div>
</div>
<!-- RIGHT: Preview — fills all available space -->
<div class = "preview-box"style="flex:1;display:flex;flex-direction:column;overflow:hidden;padding:4px 6px 6px;min-width:0;">
<!-- Label + device toggle -->
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:5px;padding:0 2px;">
<div style="font-size:9.5px;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:#9ca3af;display:flex;align-items:center;gap:7px;">
<span style="width:6px;height:6px;border-radius:50%;background:#22c55e;display:inline-block;animation:pulse-dot 2s infinite;"></span>
Live preview
</div>
<div class="opt-btns">
<button class="opt-btn selected" data-group="device" data-tip="Runs in any browser, desktop & mobile" onclick="setDevice(this)">Web app</button>
<button class="opt-btn" data-group="device" data-tip="Optimised for phones, still works on desktop" onclick="setDevice(this)">Mobile-first</button>
</div>
</div>
<!-- Browser chrome -->
<div class="preview-chrome-box" style="flex:1;border-radius:12px;overflow:hidden;border:1.5px solid #e0e7ff;box-shadow:0 4px 24px rgba(99,102,241,0.08);display:flex;flex-direction:column;min-height:0;">
<div style="background:#f5f3ff;padding:7px 12px;display:flex;align-items:center;gap:8px;border-bottom:1px solid #e0e7ff;flex-shrink:0;">
<div style="display:flex;gap:4px;"><div style="width:7px;height:7px;border-radius:50%;background:#c7d2fe;"></div><div style="width:7px;height:7px;border-radius:50%;background:#c7d2fe;"></div><div style="width:7px;height:7px;border-radius:50%;background:#c7d2fe;"></div></div>
<div style="flex:1;background:#fff;border:1px solid #e0e7ff;border-radius:4px;padding:2px 9px;font-family:monospace;font-size:10px;color:#6366F1;">yourproduct.com</div>
</div>
<div id="preview-scroll" style="flex:1;overflow-y:auto;transition:background 0.3s;">
<div id="live-preview"></div>
</div>
</div>
</div>
<!-- Hidden: copy IDs kept for JS compatibility -->
<div style="display:none;">
<div id="copy-headline"></div>
<div id="copy-sub"></div>
<div id="copy-cta"></div>
<div id="copy-bullets"></div>
</div>
</div>
</div>
<!-- RIGHT PANEL -->
<div class="website-right" style="width:384px;border-left:1px solid var(--border);background:#f5f3ff;display:flex;flex-direction:column;flex-shrink:0;">
<div style="flex-shrink:0;padding:18px 0 0;">
<div style="margin:0 16px;padding-bottom:14px;border-bottom:1px solid #c7d2fe;">
<div style="font-size:15px;font-weight:800;letter-spacing:0.04em;text-transform:uppercase;color:#4338ca;margin-bottom:5px;">Your brand at a glance</div>
<div style="font-size:12px;color:#A0A0B8;line-height:1.5;">A summary of how your brand will look and sound to the world.</div>
</div>
</div>
<div style="flex:1;overflow-y:auto;padding:16px;">
<!-- 1. Your brand voice -->
<div style="font-size:9.5px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:var(--muted);margin-bottom:8px;">Your brand voice</div>
<div class="ws-rp-card" id="rp-voice-sentence" style="background:var(--white);border:1px solid var(--border);border-radius:10px;padding:11px 13px;margin-bottom:16px;font-size:12.5px;color:var(--ink);line-height:1.55;"></div>
<!-- 2. Website experience -->
<div style="font-size:9.5px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:var(--muted);margin-bottom:8px;">Website experience</div>
<div class="ws-rp-card" id="rp-experience" style="background:var(--white);border:1px solid var(--border);border-radius:10px;overflow:hidden;margin-bottom:16px;"></div>
<!-- 3. How users will perceive it -->
<div style="font-size:9.5px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:var(--muted);margin-bottom:8px;">How users will perceive it</div>
<div class="ws-rp-card" id="rp-perception" style="background:var(--white);border:1px solid var(--border);border-radius:10px;overflow:hidden;margin-bottom:16px;"></div>
<!-- 4. Optimized for -->
<div style="font-size:9.5px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:var(--muted);margin-bottom:8px;">Optimized for</div>
<div class="ws-rp-card" id="rp-topics-list" style="background:var(--white);border:1px solid var(--border);border-radius:10px;overflow:hidden;margin-bottom:16px;"></div>
<!-- Hidden: kept for JS compat -->
<span id="rp-tone" style="display:none;">Friendly</span>
<span id="rp-style" style="display:none;">Balanced</span>
<span id="rp-pers" style="display:none;">Warm</span>
<span id="rp-ws-name" style="display:none;">Editorial</span>
<span id="rp-ws-desc" style="display:none;">Bold headlines, strong opinions</span>
</div>
<div style="border-top:1px solid var(--border);padding:9px 0 13px;flex-shrink:0;display:flex;flex-direction:column;align-items:center;">
<p style="font-size:11.5px;color:var(--muted);text-align:center;margin:0 0 10px;line-height:1.5;">All set — let's build it.</p>
<a href="09_build.html" style="text-decoration:none;display:block;width:80%;"><button class="next-btn">Next : Build my product</button></a>
</div>
</div>
</div>
<script>
/* ── State ── */
var VOICE = {tone:'Friendly', style:'Balanced', pers:'Warm'};
var TOPICS = {problem:true, audience:true, timing:true, benefits:false, comparison:false};
var STYLE = {id:'editorial', bg:'#ffffff', accent:'#ef4444', label:'Editorial', desc:'Bold headlines, strong opinions'};
var DEVICE = 'desktop';
var PILL_LABELS = {
tone: {0:'Friendly', 50:'Balanced', 100:'Professional'},
style: {0:'Casual', 50:'Balanced', 100:'Precise'},
pers: {0:'Warm', 50:'Steady', 100:'Direct'}
};
var COPY_MATRIX = {
/* Friendly ─────────────────────────────────────────────────────────────── */
'Friendly-Casual-Warm': {h:'Build something people actually love', s:"We make it super easy to go from idea to product — no stress, just results. You'll wonder why you waited.", cta:"Let's build together"},
'Friendly-Casual-Steady': {h:'From idea to launch, one step at a time', s:"We walk with you every step — from your first spark to your first 1,000 users. No rush, just progress.", cta:'Start your journey'},
'Friendly-Casual-Direct': {h:'Stop waiting. Start building.', s:"Your idea is good. Let's make it real — it's way simpler than you think.", cta:'Get started now'},
'Friendly-Balanced-Warm': {h:'Your idea deserves to exist', s:'A thoughtful platform for founders who want to move fast without cutting corners.', cta:'Build something real'},
'Friendly-Balanced-Steady': {h:'Build, launch, and grow — all in one place', s:'Everything you need to turn a product idea into a growing business.', cta:'Start building'},
'Friendly-Balanced-Direct': {h:'From zero to product in days', s:'The fastest path from idea to market — no fluff, no wasted time.', cta:'Launch your product'},
'Friendly-Precise-Warm': {h:'From rough idea to live product in 5 clear steps', s:'A guided, step-by-step process that covers every decision — from architecture to your first customer — with care.', cta:'See how it works'},
'Friendly-Precise-Steady': {h:'A proven 5-step path to your first 1,000 users', s:'Our structured process covers architecture, design, copy, and launch — every stage defined, nothing left to chance.', cta:'Follow the process'},
'Friendly-Precise-Direct': {h:"Ship in days. Here's exactly how.", s:'5 defined steps. One platform. From idea to production-ready product — zero guesswork, zero wasted time.', cta:'Start now'},
/* Balanced ─────────────────────────────────────────────────────────────── */
'Balanced-Casual-Warm': {h:'Turn your idea into something real', s:"We're here to help you build the thing you've been thinking about — no overthinking required.", cta:'Build something real'},
'Balanced-Casual-Steady': {h:'Build it. Ship it. Grow it.', s:'We take the guesswork out of building a product. Just follow the process and watch it come together.', cta:'Start building'},
'Balanced-Casual-Direct': {h:'Idea to product. Fast.', s:'Stop planning, start shipping. We give you everything you need to move fast and get to market.', cta:'Get moving'},
'Balanced-Balanced-Warm': {h:'Your idea deserves to exist', s:'A thoughtful platform for founders who want to move fast without cutting corners.', cta:'Build something real'},
'Balanced-Balanced-Steady': {h:'Build, launch, and grow — all in one place', s:'Everything you need to turn a product idea into a growing business.', cta:'Start building'},
'Balanced-Balanced-Direct': {h:'From zero to product in days', s:'The fastest path from idea to market. No fluff, no wasted time.', cta:'Launch your product'},
'Balanced-Precise-Warm': {h:'From concept to customers in one structured flow', s:'Our platform guides you through every decision — from architecture to launch copy — with precision and care.', cta:'Explore the workflow'},
'Balanced-Precise-Steady': {h:'A structured path from idea to market', s:'Every step is defined, every deliverable is clear. Move confidently from concept to launch.', cta:'See the process'},
'Balanced-Precise-Direct': {h:'Zero to launched in 5 defined steps', s:'Architecture, design, copy, and deployment — all mapped upfront. No surprises, no wasted cycles.', cta:'Start now'},
/* Professional ─────────────────────────────────────────────────────────── */
'Professional-Casual-Warm': {h:'Sophisticated tools for ambitious builders', s:'We combine enterprise-grade infrastructure with a process that feels surprisingly human and approachable.', cta:'Explore the platform'},
'Professional-Casual-Steady': {h:'The reliable way to go from idea to product', s:'A proven approach that serious founders use to ship faster — without the complexity or the headaches.', cta:'Get started'},
'Professional-Casual-Direct': {h:'Build fast. Ship confident.', s:'Skip the setup headaches. We handle the complexity so you can focus on what actually matters — shipping.', cta:'Start now'},
'Professional-Balanced-Warm': {h:'Enterprise-grade tools, founder-friendly experience',s:'Sophisticated infrastructure wrapped in an experience designed for ambitious builders.', cta:'Explore the platform'},
'Professional-Balanced-Steady': {h:'The reliable path from idea to market', s:'A structured, proven approach to building and launching software products at speed.', cta:'Get started'},
'Professional-Balanced-Direct': {h:'Build fast. Ship confident.', s:'Production-ready infrastructure and AI-assisted development. Ship in days, not months.', cta:'Start now'},
'Professional-Precise-Warm': {h:'Production-grade infrastructure with a human touch',s:'From scalable architecture to conversion-optimised copy — every component built to specification, every decision explained.',cta:'Review the specs'},
'Professional-Precise-Steady': {h:'A methodical approach to product development at speed',s:'Defined workflows, documented architecture, and measurable milestones — from initial concept through to market launch.',cta:'See the methodology'},
'Professional-Precise-Direct': {h:'Ship production-ready in days.', s:'Automated architecture decisions, AI-generated copy, one-click deployment. Measurable output at every stage.', cta:'Get started'}
};
var TOPIC_TEXT = {
problem: 'Solves a real, painful problem',
audience: 'Built for a specific, well-defined audience',
timing: 'The market is ready right now',
benefits: 'Clear advantages over the status quo',
comparison: 'Better than existing alternatives'
};
var TOPIC_RP_LABELS = {
problem: 'Highlighting a clear pain point',
audience: 'Targeting a specific audience',
timing: 'Creating urgency',
benefits: 'Showing value quickly',
comparison: 'Differentiating from competitors'
};
/* ── Device ── */
function setDevice(btn) {
document.querySelectorAll('[data-group="device"]').forEach(function(b){ b.classList.remove('selected'); });
btn.classList.add('selected');
DEVICE = btn.getAttribute('data-tip') === 'Optimised for phones, still works on desktop' ? 'mobile' : 'desktop';
renderPreview();
}
/* ── Controls ── */
function saveWebsiteState(){
try {
var topicLabels = {problem:'The problem', audience:"Who it's for", timing:'Why now', benefits:'Key benefits', comparison:'vs. alternatives'};
var activeTopics = Object.keys(TOPICS).filter(function(k){ return TOPICS[k]; }).map(function(k){ return topicLabels[k]; });
localStorage.setItem('vibn_website', JSON.stringify({
voice: VOICE,
styleLabel: STYLE.label,
topics: activeTopics
}));
} catch(e){}
}
function setPill(el, key, val) {
document.getElementById('pills-' + key).querySelectorAll('.vpill').forEach(function(p){ p.classList.remove('active'); });
el.classList.add('active');
VOICE[key] = PILL_LABELS[key][val];
document.getElementById('rp-' + key).textContent = VOICE[key];
renderAll();
saveWebsiteState();
}
function toggleTopic(el, key) {
var on = !el.classList.contains('active');
el.classList.toggle('active', on);
TOPICS[key] = on;
renderAll();
saveWebsiteState();
}
function setStyle(id, bg, accent, label, desc) {
document.querySelectorAll('.scard').forEach(function(c){ c.classList.remove('selected'); });
document.getElementById('sc-' + id).classList.add('selected');
STYLE = {id:id, bg:bg, accent:accent, label:label, desc:desc};
document.getElementById('rp-ws-name').textContent = label;
document.getElementById('rp-ws-desc').textContent = desc;
renderAll();
saveWebsiteState();
}
/* ── Copy generation ── */
function getCopy() {
return COPY_MATRIX[VOICE.tone + '-' + VOICE.style + '-' + VOICE.pers] || COPY_MATRIX['Balanced-Balanced-Steady'];
}
function getBullets() {
return Object.keys(TOPICS).filter(function(k){ return TOPICS[k]; }).map(function(k){ return TOPIC_TEXT[k]; });
}
/* ── Render ── */
function renderPreview() {
var copy = getCopy();
var bullets = getBullets();
var bg = STYLE.bg;
var accent = STYLE.accent;
var isDark = ['#0f172a','#0c0a09','#1a1a1a'].indexOf(bg) !== -1;
var tc = isDark ? '#ffffff' : '#111111';
var sub = isDark ? 'rgba(255,255,255,0.55)' : '#6b7280';
var border = isDark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.06)';
/* ── Per-style theme variables ── */
function hexRgbaW(h,a){ var r=parseInt(h.slice(1,3),16),g=parseInt(h.slice(3,5),16),b=parseInt(h.slice(5,7),16); return 'rgba('+r+','+g+','+b+','+a+')'; }
var S = {
editorial: {
r: 4, cardR: 4, navR: 4,
shadow: 'none', cardShadow: 'none',
headingWeight: 800, headingTracking: '-0.03em',
border: isDark ? 'rgba(255,255,255,0.10)' : 'rgba(0,0,0,0.12)',
navBorder: isDark ? 'rgba(255,255,255,0.10)' : 'rgba(0,0,0,0.12)',
cardBg: isDark ? 'rgba(255,255,255,0.05)' : '#fff',
sectionBg: isDark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.02)',
pillBg: isDark ? 'rgba(255,255,255,0.08)' : hexRgbaW(accent,0.10),
ctaR: '4px', secPad: '22px 24px', heroPad: '24px 24px 14px',
subColor: isDark ? 'rgba(255,255,255,0.50)' : '#374151'
},
startup: {
r: 8, cardR: 8, navR: 6,
shadow: '0 1px 4px rgba(0,0,0,0.07)', cardShadow: '0 1px 4px rgba(0,0,0,0.07)',
headingWeight: 700, headingTracking: '-0.02em',
border: isDark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)',
navBorder: isDark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)',
cardBg: isDark ? 'rgba(255,255,255,0.05)' : '#fff',
sectionBg: isDark ? 'rgba(255,255,255,0.03)' : 'rgba(99,102,241,0.03)',
pillBg: isDark ? 'rgba(255,255,255,0.08)' : hexRgbaW(accent,0.07),
ctaR: '8px', secPad: '28px 24px', heroPad: '26px 24px 16px',
subColor: sub
},
minimal: {
r: 4, cardR: 4, navR: 4,
shadow: 'none', cardShadow: 'none',
headingWeight: 600, headingTracking: '-0.01em',
border: isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)',
navBorder: isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)',
cardBg: isDark ? 'rgba(255,255,255,0.03)' : '#fafafa',
sectionBg: isDark ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.01)',
pillBg: isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)',
ctaR: '4px', secPad: '36px 24px', heroPad: '36px 24px 20px',
subColor: isDark ? 'rgba(255,255,255,0.40)' : '#9ca3af'
},
warm: {
r: 14, cardR: 14, navR: 8,
shadow: '0 2px 12px rgba(0,0,0,0.08)', cardShadow: '0 2px 8px rgba(0,0,0,0.06)',
headingWeight: 700, headingTracking: '-0.01em',
border: isDark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.05)',
navBorder: isDark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.06)',
cardBg: isDark ? 'rgba(255,255,255,0.05)' : hexRgbaW(accent,0.04),
sectionBg: isDark ? 'rgba(255,255,255,0.03)' : hexRgbaW(accent,0.05),
pillBg: isDark ? 'rgba(255,255,255,0.08)' : hexRgbaW(accent,0.08),
ctaR: '14px', secPad: '28px 24px', heroPad: '30px 24px 18px',
subColor: sub
}
}[STYLE.id] || {
r:8, cardR:8, navR:6, shadow:'none', cardShadow:'none',
headingWeight:700, headingTracking:'-0.02em',
border: border, navBorder: border,
cardBg: isDark?'rgba(255,255,255,0.05)':'#fff',
sectionBg: isDark?'rgba(255,255,255,0.03)':'rgba(99,102,241,0.03)',
pillBg: isDark?'rgba(255,255,255,0.08)':hexRgbaW(accent,0.07),
ctaR:'8px', secPad:'28px 24px', heroPad:'26px 24px 16px',
subColor: sub
};
/* override shared vars with per-style ones */
border = S.border;
sub = S.subColor;
var pillsHtml = '';
if (bullets.length) {
pillsHtml = '<div style="padding:0 24px 18px;display:flex;flex-wrap:wrap;gap:6px;justify-content:center;">';
bullets.forEach(function(b) {
pillsHtml += '<div style="background:' + S.pillBg + ';border-radius:20px;padding:3px 11px;font-size:10px;color:' + (isDark ? 'rgba(255,255,255,0.65)' : accent) + ';">' + b + '</div>';
});
pillsHtml += '</div>';
}
// Problem section
var problemHtml = (TOPICS.problem || TOPICS.audience)
? '<div style="padding:'+S.secPad+';background:' + S.sectionBg + ';border-top:1px solid ' + S.border + ';text-align:center;">'
+ '<div style="font-size:8.5px;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:' + accent + ';margin-bottom:10px;">The Problem</div>'
+ '<div style="font-size:15px;font-weight:'+S.headingWeight+';color:' + tc + ';letter-spacing:'+S.headingTracking+';line-height:1.3;margin-bottom:8px;">Most founders waste months on the wrong things</div>'
+ '<div style="font-size:11px;color:' + sub + ';line-height:1.65;max-width:340px;margin-left:auto;margin-right:auto;">Building a product is hard. Knowing what to build, how to position it, and who to build it for shouldn\'t be. That\'s what we fix.</div>'
+ '</div>'
: '';
// Benefits section
var benefitItems = [
{icon:'⚡', title:'10× faster setup', body:'Skip the research rabbit hole — vibn structures everything for you.'},
{icon:'🎯', title:'Laser-focused', body:'Every decision is tied to your specific audience and problem.'},
{icon:'🚀', title:'Launch-ready output', body:'Get copy, architecture, and a website in one session.'}
];
var benefitCols = DEVICE === 'mobile' ? '1fr' : '1fr 1fr 1fr';
var benefitsHtml = '<div style="padding:'+S.secPad+';border-top:1px solid ' + S.border + ';">'
+ '<div style="font-size:8.5px;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:' + accent + ';margin-bottom:14px;">Why it works</div>'
+ '<div style="display:grid;grid-template-columns:' + benefitCols + ';gap:10px;">';
benefitItems.forEach(function(b) {
benefitsHtml += '<div style="background:' + S.cardBg + ';border:1px solid ' + S.border + ';border-radius:' + S.cardR + 'px;padding:12px;box-shadow:' + S.cardShadow + ';">'
+ '<div style="font-size:16px;margin-bottom:6px;">' + b.icon + '</div>'
+ '<div style="font-size:10.5px;font-weight:'+S.headingWeight+';color:' + tc + ';margin-bottom:4px;">' + b.title + '</div>'
+ '<div style="font-size:9.5px;color:' + sub + ';line-height:1.5;">' + b.body + '</div>'
+ '</div>';
});
benefitsHtml += '</div></div>';
// Final CTA section
var ctaSectionHtml = '<div style="padding:36px 24px;text-align:center;background:' + accent + ';margin-top:0;">'
+ '<div style="font-size:17px;font-weight:'+S.headingWeight+';color:#fff;letter-spacing:'+S.headingTracking+';line-height:1.25;margin-bottom:8px;">Ready to stop planning<br>and start building?</div>'
+ '<div style="font-size:10.5px;color:rgba(255,255,255,0.75);margin-bottom:16px;">Join founders who shipped their MVP in days, not months.</div>'
+ '<div style="display:inline-block;background:#fff;color:' + accent + ';font-size:11px;font-weight:700;padding:9px 22px;border-radius:' + S.ctaR + ';">' + copy.cta + '</div>'
+ '</div>';
var isAppDark = document.documentElement.dataset.theme === 'dark';
var phoneGlow = isAppDark
? '0 8px 40px rgba(0,0,0,0.65),0 0 0 1px rgba(255,255,255,0.18),0 0 32px rgba(255,255,255,0.12),0 0 64px rgba(255,255,255,0.06)'
: '0 8px 32px rgba(0,0,0,0.15)';
var mobileWrapOpen = DEVICE === 'mobile'
? '<div style="background:' + (isDark ? '#111827' : '#f0f0f8') + ';padding:20px 0;min-height:100%;"><div style="background:' + bg + ';max-width:375px;margin:0 auto;border-radius:' + S.r*2 + 'px;overflow:hidden;box-shadow:' + phoneGlow + ';font-family:\'Plus Jakarta Sans\',sans-serif;">'
: '';
var mobileWrapClose = DEVICE === 'mobile' ? '</div></div>' : '';
var html = (DEVICE === 'mobile' ? mobileWrapOpen : '<div style="background:' + bg + ';font-family:\'Plus Jakarta Sans\',sans-serif;">')
+ '<div style="padding:9px 16px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid ' + S.navBorder + ';box-shadow:' + S.shadow + ';">'
+ '<span style="font-size:12px;font-weight:'+S.headingWeight+';color:' + tc + ';">YourApp</span>'
+ '<div style="display:flex;gap:12px;"><span style="font-size:9.5px;color:' + sub + ';">Product</span><span style="font-size:9.5px;color:' + sub + ';">Pricing</span></div>'
+ '<div style="background:' + accent + ';color:#fff;font-size:9.5px;font-weight:700;padding:4px 10px;border-radius:' + S.ctaR + ';">' + copy.cta + '</div>'
+ '</div>'
+ '<div style="padding:'+S.heroPad+';text-align:center;">'
+ '<div style="font-size:20px;font-weight:'+S.headingWeight+';color:' + tc + ';letter-spacing:'+S.headingTracking+';line-height:1.2;margin-bottom:9px;">' + copy.h + '</div>'
+ '<div style="font-size:11.5px;color:' + sub + ';line-height:1.65;margin-bottom:16px;max-width:300px;margin-left:auto;margin-right:auto;">' + copy.s + '</div>'
+ '<div style="display:inline-flex;gap:8px;justify-content:center;">'
+ '<div style="background:' + accent + ';color:#fff;font-size:11px;font-weight:700;padding:8px 18px;border-radius:' + S.ctaR + ';box-shadow:' + S.shadow + ';">' + copy.cta + '</div>'
+ '<div style="background:transparent;color:' + sub + ';font-size:11px;padding:8px 14px;border-radius:' + S.ctaR + ';border:1px solid ' + S.border + ';">See how it works</div>'
+ '</div></div>'
+ pillsHtml
+ problemHtml
+ benefitsHtml
+ ctaSectionHtml
+ (DEVICE === 'mobile' ? mobileWrapClose : '</div>');
var el = document.getElementById('live-preview');
el.style.transition = 'opacity 0.25s';
el.style.opacity = '0';
setTimeout(function() { el.innerHTML = html; el.style.opacity = '1'; }, 150);
}
function renderCopy() {
var copy = getCopy();
var bullets = getBullets();
function fade(id, fn) {
var el = document.getElementById(id);
el.style.transition = 'opacity 0.2s';
el.style.opacity = '0';
setTimeout(function() { fn(el); el.style.opacity = '1'; }, 130);
}
fade('copy-headline', function(el) { el.textContent = copy.h; });
fade('copy-sub', function(el) { el.textContent = copy.s; });
fade('copy-cta', function(el) { el.textContent = copy.cta; });
fade('copy-bullets', function(el) {
el.innerHTML = bullets.map(function(b) {
return '<div style="display:flex;align-items:flex-start;gap:6px;font-size:11.5px;color:#6b7280;"><span style="color:#6366F1;flex-shrink:0;font-size:11px;">✓</span><span>' + b + '</span></div>';
}).join('');
});
// Update right panel topics
var rpTopics = document.getElementById('rp-topics-list');
if (rpTopics) {
rpTopics.innerHTML = Object.keys(TOPICS).filter(function(k) { return TOPICS[k]; }).map(function(k) {
return '<div class="deliverable-row"><div style="width:5px;height:5px;border-radius:50%;background:#6366F1;flex-shrink:0;"></div>' + TOPIC_RP_LABELS[k] + '</div>';
}).join('');
}
}
/* ── Right-panel lookup tables ── */
var RP_TONE_ADJ = {Friendly:'Approachable', Balanced:'Measured', Professional:'Authoritative'};
var RP_STYLE_ADJ = {Casual:'conversational and informal', Balanced:'clear and well-crafted', Precise:'precise and data-led'};
var RP_PERS_ADJ = {Warm:'a warm, human feel', Steady:'a steady, reliable character', Direct:'a bold, direct edge'};
/* "Website experience" — bullet 1: visual style, bullet 2: writing style, bullet 3: tone */
var RP_EXP_VSTYLE = {
editorial: 'Strong visual hierarchy — bold headlines, designed to stop the scroll',
startup: 'Clean, conversion-first layout with benefit-led sections and clear CTAs',
minimal: 'Generous whitespace — nothing wasted, content takes centre stage',
warm: 'Approachable visuals and soft forms with trust signals woven in naturally'
};
var RP_EXP_WRITINGSTYLE = {
Casual: 'Copy that feels natural and conversational — no jargon, no fluff',
Balanced: 'Clear, well-crafted messaging that informs and builds confidence',
Precise: 'Data-led, specific language with measurable claims and no vagueness'
};
var RP_EXP_TONE = {
Friendly: 'Designed to welcome and reassure — visitors feel at ease immediately',
Balanced: 'Positioned to engage and convert — approachable and credible in equal measure',
Professional: 'Built to establish authority and attract qualified, high-intent leads'
};
/* "How users will perceive it" — bullet 1: visual style, bullet 2: tone, bullet 3: personality quote */
var RP_PERC_VSTYLE = {
editorial: 'Bold and self-assured',
startup: 'Modern and credible',
minimal: 'Calm and considered',
warm: 'Genuine and human'
};
var RP_PERC_TONE = {
Friendly: 'Inviting',
Balanced: 'Trustworthy',
Professional: 'Authoritative'
};
var RP_PERC_PERS = {
Warm: '\u201cThey actually get my problem\u201d',
Steady: '\u201cThese people know what they\u2019re doing\u201d',
Direct: '\u201cI want to try this right now\u201d'
};
function renderRightPanel() {
var dot = '<div style="width:5px;height:5px;border-radius:50%;background:#6366F1;flex-shrink:0;"></div>';
// 1. Brand voice sentence
var vEl = document.getElementById('rp-voice-sentence');
if (vEl) {
vEl.textContent =
(RP_TONE_ADJ[VOICE.tone] || VOICE.tone) + ', ' +
(RP_STYLE_ADJ[VOICE.style]|| VOICE.style) + ' \u2014 with ' +
(RP_PERS_ADJ[VOICE.pers] || VOICE.pers) + '.';
}
// 2. Website experience — reflects website style + writing style + tone
var expEl = document.getElementById('rp-experience');
if (expEl) {
expEl.innerHTML = [
RP_EXP_VSTYLE[STYLE.id] || '',
RP_EXP_WRITINGSTYLE[VOICE.style] || '',
RP_EXP_TONE[VOICE.tone] || ''
].map(function(b) {
return '<div class="deliverable-row">' + dot + b + '</div>';
}).join('');
}
// 3. How users will perceive it — reflects website style + tone + personality
var percEl = document.getElementById('rp-perception');
if (percEl) {
percEl.innerHTML = [
RP_PERC_VSTYLE[STYLE.id] || '',
RP_PERC_TONE[VOICE.tone] || '',
RP_PERC_PERS[VOICE.pers] || ''
].map(function(b) {
return '<div class="deliverable-row">' + dot + b + '</div>';
}).join('');
}
}
function renderAll() {
renderPreview();
renderCopy();
renderRightPanel();
}
/* ── AI Decide ── */
function aiDecide() {
// Balanced tone, Balanced style, Warm personality
document.getElementById('pills-tone').querySelectorAll('.vpill').forEach(function(p, i) { p.classList.toggle('active', i === 1); });
VOICE.tone = 'Balanced'; document.getElementById('rp-tone').textContent = 'Balanced';
document.getElementById('pills-style').querySelectorAll('.vpill').forEach(function(p, i) { p.classList.toggle('active', i === 1); });
VOICE.style = 'Balanced'; document.getElementById('rp-style').textContent = 'Balanced';
document.getElementById('pills-pers').querySelectorAll('.vpill').forEach(function(p, i) { p.classList.toggle('active', i === 0); });
VOICE.pers = 'Warm'; document.getElementById('rp-pers').textContent = 'Warm';
// Topics: problem + audience + benefits
var aiTopics = {problem:true, audience:true, timing:false, benefits:true, comparison:false};
document.querySelectorAll('.tchip').forEach(function(c) {
var m = c.getAttribute('onclick').match(/'(\w+)'\)/);
if (m) { var k = m[1]; c.classList.toggle('active', !!aiTopics[k]); TOPICS[k] = !!aiTopics[k]; }
});
// Startup style (also calls renderAll internally)
setStyle('startup', '#f8fafc', '#0ea5e9', 'Startup energy', 'Clear, conversion-focused');
}
/* ── Theme ── */
function saveAndExit() { window.location.href = '03_dashboard.html'; }
function toggleTheme() {
const html = document.documentElement;
const isDark = html.dataset.theme === 'dark';
html.dataset.theme = isDark ? '' : 'dark';
document.getElementById('dark-toggle').textContent = isDark ? '🌙 Dark mode' : '☀️ Light mode';
localStorage.setItem('vibn-theme', isDark ? '' : 'dark');
}
(function() {
const saved = localStorage.getItem('vibn-theme');
if (saved === 'dark') {
document.documentElement.dataset.theme = 'dark';
document.addEventListener('DOMContentLoaded', function() {
const btn = document.getElementById('dark-toggle');
if (btn) btn.textContent = '☀️ Light mode';
});
}
})();
document.addEventListener('DOMContentLoaded', function() {
try {
var name = localStorage.getItem('vibn_project_name') || 'My project';
var el = document.getElementById('sidebar-project-name');
el.textContent = name; el.style.display = 'block';
} catch(e) {}
// Sync device mode from Architect frontend selection
try {
var frontendTip = localStorage.getItem('vibn_frontend');
if (frontendTip === 'Optimised for phones, still works on desktop') {
DEVICE = 'mobile';
document.querySelectorAll('[data-group="device"]').forEach(function(b) {
b.classList.toggle('selected', b.getAttribute('data-tip') === frontendTip);
});
}
} catch(e) {}
renderAll();
});
</script>
</body></html>

View File

@@ -0,0 +1,412 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><link rel="icon" href="favicon_clean.ico">
<script>if(localStorage.getItem('vibn-theme')==='dark')document.documentElement.dataset.theme='dark';</script>
<title>vibn — Build</title>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0;}
:root{
--ink:#1A1A1A;--ink2:#2c2c2a;--ink3:#6366F1;--mid:#6B7280;--muted:#888780;
--stone:#b4b2a9;--parch:#d3d1c7;--cream:#eef2ff;--paper:#F5F3FF;--white:#FFFFFF;--border:#e0e7ff;
--indigo:#6366F1;--indigo-dark:#4338CA;--indigo-deep:#2E2A5E;
--indigo-soft:rgba(99,102,241,0.08);--indigo-ring:rgba(99,102,241,0.12);--subtitle:#7171b7;
}
body{font-family:'Plus Jakarta Sans',sans-serif;background:linear-gradient(to bottom,#FAFAFA,#F5F3FF);display:flex;flex-direction:column;height:100vh;overflow:hidden;}
.f{font-family:'Plus Jakarta Sans',sans-serif;}
.sidebar-phase{display:flex;align-items:center;gap:9px;padding:9px 10px;border-radius:8px;}
.sidebar-phase.active{background:#fafaff;}
.phase-dot{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:10px;}
@keyframes spin{to{transform:rotate(360deg);}}
.spinning{display:inline-block;animation:spin 1s linear infinite;}
/* ── Progress screen ── */
.grad-title{background:linear-gradient(135deg,#1A1A2E 0%,#2E2A5E 30%,#4338CA 65%,#6366F1 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;}
[data-theme="dark"] .grad-title{background:linear-gradient(135deg,#d0d0f0 0%,#9090cc 30%,#6C7CFF 65%,#A8B4FF 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;}
@keyframes grad-flow{0%{background-position:0% 50%}50%{background-position:100% 50%}100%{background-position:0% 50%}}
.grad-anim{background:linear-gradient(270deg,#1A1A2E,#4338CA,#6366F1,#A5B4FC,#6366F1,#4338CA,#1A1A2E);background-size:400% 400%;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;animation:grad-flow 4s ease infinite;}
[data-theme="dark"] .grad-anim{background:linear-gradient(270deg,#4B42D8,#6C7CFF,#C7D2FE,#A8B4FF,#6C7CFF,#4B42D8);background-size:400% 400%;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;animation:grad-flow 4s ease infinite;}
@keyframes confetti-fall{0%{transform:translateY(-20px) rotate(0deg);opacity:1}80%{opacity:1}100%{transform:translateY(105vh) rotate(800deg);opacity:0}}
.step-num{width:24px;height:24px;border-radius:50%;background:linear-gradient(135deg,#4338CA,#818CF8);display:flex;align-items:center;justify-content:center;font-size:11px;color:#fff;font-weight:700;flex-shrink:0;}
.btn-gitea{background:transparent;color:var(--mid);border:1px solid var(--border);border-radius:11px;padding:14px;font-family:'Plus Jakarta Sans',sans-serif;font-size:13px;font-weight:500;cursor:pointer;transition:background 0.18s,border-color 0.18s,color 0.18s;}
.btn-gitea:hover{background:var(--cream);border-color:rgba(99,102,241,0.22);color:var(--indigo);}
[data-theme="dark"] .btn-gitea{border-color:var(--dm-border)!important;color:var(--dm-text-3)!important;}
[data-theme="dark"] .btn-gitea:hover{background:var(--dm-accent-fill)!important;border-color:var(--dm-accent-border)!important;color:var(--dm-accent)!important;}
/* ── Launch section ── */
.launch-divider{border:none;border-top:1px solid var(--border);margin:28px 0;}
.launch-card{background:var(--white);border:1px solid var(--border);border-radius:16px;padding:28px 28px 24px;box-shadow:0 2px 12px rgba(99,102,241,0.06),0 0 0 1px rgba(255,255,255,0.60);}
[data-theme="dark"] .launch-card{background:rgba(255,255,255,0.07)!important;border-color:rgba(255,255,255,0.10)!important;box-shadow:0 0 0 1px rgba(255,255,255,0.12),0 0 28px rgba(255,255,255,0.07),0 4px 24px rgba(0,0,0,0.35)!important;}
.build-step{display:flex;align-items:center;gap:11px;padding:9px 0;border-bottom:1px solid var(--border);}
.build-step:last-child{border-bottom:none;}
.build-step-icon{width:24px;height:24px;border-radius:6px;background:var(--cream);display:flex;align-items:center;justify-content:center;font-size:11px;color:var(--ink3);flex-shrink:0;opacity:0.8;transform:scale(0.95);}
.build-step > div > span{font-size:14px!important;}
.build-step > span{font-size:12px!important;color:var(--muted)!important;}
.build-cta{width:100%;background:linear-gradient(135deg,#2E2A5E 0%,#4338CA 55%,#6366F1 100%);color:#fff;border:none;border-radius:12px;padding:16px 24px;font-family:'Plus Jakarta Sans',sans-serif;font-size:15px;font-weight:700;cursor:pointer;letter-spacing:-0.01em;transition:box-shadow 0.2s,transform 0.2s;box-shadow:0 8px 24px rgba(99,102,241,0.25),0 2px 6px rgba(99,102,241,0.12);}
.build-cta:hover{transform:translateY(-1px);box-shadow:0 12px 32px rgba(99,102,241,0.40),0 0 0 4px rgba(99,102,241,0.10);}
.build-cta:active{transform:translateY(0);box-shadow:0 2px 10px rgba(99,102,241,0.22);}
.build-cta:disabled{opacity:0.75;cursor:not-allowed;transform:none;}
.includes-chip{display:inline-flex;align-items:center;font-size:11px;font-weight:500;color:var(--mid);background:var(--cream);border-radius:20px;padding:3px 9px;}
[data-theme="dark"] .launch-divider{border-top-color:rgba(255,255,255,0.06)!important;}
[data-theme="dark"] .build-cta{background:linear-gradient(135deg,#4B42D8 0%,#6C7CFF 100%)!important;box-shadow:0 4px 22px rgba(108,124,255,0.38),inset 0 1px 0 rgba(255,255,255,0.12)!important;}
[data-theme="dark"] .build-cta:hover{box-shadow:0 8px 36px rgba(108,124,255,0.55),0 0 0 4px rgba(108,124,255,0.16),inset 0 1px 0 rgba(255,255,255,0.16)!important;}
[data-theme="dark"] .includes-chip{background:rgba(108,124,255,0.12)!important;color:var(--dm-text-3)!important;}
/* ── Dark mode tokens — exact match with architect / describe ── */
[data-theme="dark"]{--ink:#EEEEFF;--ink2:#B8B8D0;--ink3:#8484A8;--mid:#9898B8;--muted:#c2c2ee;--border:rgba(255,255,255,0.08);
--cream:rgba(108,124,255,0.14);--paper:#0A1120;--white:rgba(255,255,255,0.05);--stone:rgba(255,255,255,0.08);
--parch:rgba(255,255,255,0.06);--indigo:#6C7CFF;--indigo-dark:#6C7CFF;--indigo-deep:#4B42D8;--subtitle:#a8a8d7;--text:#d9d9ee;
--indigo-soft:rgba(108,124,255,0.14);--indigo-ring:rgba(108,124,255,0.22);
--dm-surf-sidebar:rgba(12,18,34,0.72);--dm-surf-topbar:rgba(12,18,34,0.58);--dm-surf-panel:rgba(12,18,34,0.58);
--dm-surf-card:rgba(255,255,255,0.07);--dm-border:rgba(255,255,255,0.08);
--dm-border-strong:rgba(255,255,255,0.14);--dm-accent:#6C7CFF;
--dm-accent-fill:rgba(108,124,255,0.14);--dm-accent-fill-mid:rgba(108,124,255,0.20);--dm-accent-border:rgba(108,124,255,0.55);
--dm-text-1:#EEEEFF;--dm-text-2:#B4B4CC;--dm-text-3:#d2d2ef;}
[data-theme="dark"] body{background:linear-gradient(to bottom,rgba(12, 18, 34, 0.58) 0%,rgba(60,120,255,0.10) 38%,transparent 62%),linear-gradient(to bottom,rgba(108,124,255,0.10),transparent 180px),radial-gradient(900px 520px at 14% -8%,rgba(108,124,255,0.24),transparent 62%),radial-gradient(760px 420px at 88% 0%,rgba(72,145,255,0.16),transparent 60%),linear-gradient(180deg,#18213B 0%,#101726 48%,#0A1120 100%);}[data-theme="dark"] #mock {background: #fff !important;}
/* ── Sidebar ── */
[data-theme="dark"] .sidebar-col{background:var(--dm-surf-sidebar)!important;border-right:1px solid var(--dm-border)!important;box-shadow:2px 0 28px rgba(0,0,0,0.60)!important;-webkit-backdrop-filter:blur(20px)!important;backdrop-filter:blur(20px)!important;}
[data-theme="dark"] .sidebar-col [style*="border-top:1px solid #e5e7eb"]{border-top-color:var(--dm-border)!important;}
[data-theme="dark"] .sidebar-col [style*="background:#e5e7eb"]{background:rgba(255,255,255,0.08)!important;}
[data-theme="dark"] .sidebar-col [style*="color:#1a1a1a"]{color:var(--dm-text-1)!important;}
[data-theme="dark"] .sidebar-col [style*="color:#6b7280"]{color:var(--dm-text-3)!important;}
[data-theme="dark"] .sidebar-col [style*="color:#444441"]{color:var(--dm-text-3)!important;}
[data-theme="dark"] .sidebar-col [style*="color:#9ca3af"]{color:var(--dm-text-3)!important;}
[data-theme="dark"] .sidebar-col [style*="background:#6366F1"]{background:var(--dm-accent)!important;color:#0F1424!important;}
[data-theme="dark"] .sidebar-phase.active{background:var(--dm-accent-fill)!important;}
[data-theme="dark"] .sidebar-phase:not(.active):hover{background:rgba(255,255,255,0.08)!important;}
[data-theme="dark"] .sidebar-col [style*="background:#eef2ff"]{background:var(--dm-accent-fill)!important;border-color:var(--dm-accent-border)!important;}
[data-theme="dark"] .sidebar-col [style*="background:#eef2ff"] span{color:var(--dm-accent)!important;}
[data-theme="dark"] #sidebar-project-name{color:var(--dm-text-3)!important;}
/* ── Main content ── */
[data-theme="dark"] #screen-review{color:var(--dm-text-1);}
/* Cards */
[data-theme="dark"] [style*="background:var(--white)"]{background:var(--dm-surf-card)!important;}
[data-theme="dark"] [style*="border:1px solid var(--border)"]{border-color:var(--dm-border)!important;}
[data-theme="dark"] [style*="border-bottom:1px solid var(--border)"]{border-bottom-color:var(--dm-border)!important;}
[data-theme="dark"] [style*="border-right:1px solid var(--border)"]{border-right-color:var(--dm-border)!important;}
/* Infra note & icon boxes using --cream */
[data-theme="dark"] [style*="background:var(--cream)"]{background:var(--dm-accent-fill)!important;border-color:var(--dm-border)!important;}
/* Progress active row in renderSteps */
[data-theme="dark"] #steps-list [style*="background:var(--cream)"]{background:var(--dm-accent-fill)!important;}
/* Step done-dot: background:var(--ink) → accent; checkmark span inside: color:var(--white) → white */
[data-theme="dark"] #steps-list [style*="background:var(--ink)"]{background:var(--dm-accent)!important;}
[data-theme="dark"] #steps-list [style*="color:var(--white)"]{color:#fff!important;}
/* Done-section numbered circles and their text */
[data-theme="dark"] #done-section [style*="background:var(--ink)"]{background:var(--dm-accent)!important;color:#fff!important;}
/* Done-section "Open my app" button */
[data-theme="dark"] #done-section a button{background:linear-gradient(135deg,#4B42D8 0%,#6C7CFF 100%)!important;color:#fff!important;box-shadow:0 4px 22px rgba(108,124,255,0.38)!important;}
/* Secondary "View in Gitea" button */
[data-theme="dark"] #done-section [style*="background:var(--white)"][style*="color:var(--mid)"]{background:rgba(255,255,255,0.07)!important;border-color:var(--dm-border)!important;color:var(--dm-text-3)!important;}
/* ── CTA build button ── */
[data-theme="dark"] button[onclick="startBuild()"]{background:linear-gradient(135deg,#4B42D8 0%,#6C7CFF 100%)!important;color:#fff!important;box-shadow:0 4px 22px rgba(108,124,255,0.38),inset 0 1px 0 rgba(255,255,255,0.14)!important;}
[data-theme="dark"] button[onclick="startBuild()"]:hover{box-shadow:0 6px 32px rgba(108,124,255,0.50),inset 0 1px 0 rgba(255,255,255,0.18)!important;}
/* ── Save & dark-toggle buttons ── */
[data-theme="dark"] button[onclick="saveAndExit()"]{background:var(--dm-accent-fill)!important;border-color:var(--dm-accent-border)!important;}
[data-theme="dark"] button[onclick="saveAndExit()"]:hover{background:var(--dm-accent-fill-mid)!important;}
[data-theme="dark"] button[onclick="saveAndExit()"]:hover span{color:#fff!important;}
[data-theme="dark"] button[onclick="saveAndExit()"] span{color:var(--dm-accent)!important;}
[data-theme="dark"] #dark-toggle{background:rgba(255,255,255,0.05)!important;border-color:var(--dm-border)!important;color:var(--dm-text-3)!important;}
[data-theme="dark"] #dark-toggle:hover{background:rgba(255,255,255,0.10)!important;color:var(--dm-text-1)!important;}
[data-theme="dark"] .vibn-avatar{background:var(--dm-accent)!important;}
/* ── Scrollbar ── */
[data-theme="dark"] ::-webkit-scrollbar{width:6px;height:6px;}
[data-theme="dark"] ::-webkit-scrollbar-track{background:#0A1120;}
[data-theme="dark"] ::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.12);border-radius:3px;}
[data-theme="dark"] ::-webkit-scrollbar-thumb:hover{background:rgba(108,124,255,0.45);}
[data-theme="dark"] *{scrollbar-color:rgba(255,255,255,0.12) #0A1120;scrollbar-width:thin;}
</style>
</head>
<body>
<div style="display:flex;height:100%;overflow:hidden;">
<!-- SIDEBAR -->
<div class="sidebar-col" style="width:200px;background:#ffffff;border-right:1px solid #e5e7eb;display:flex;flex-direction:column;padding:18px 12px;flex-shrink:0;">
<div style="padding:0 6px;margin-bottom:26px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<div class="vibn-avatar" style="width:26px;height:26px;background:linear-gradient(135deg,#2E2A5E,#4338CA);border-radius:6px;display:flex;align-items:center;justify-content:center;flex-shrink:0;"><span class="f" style="font-size:13px;font-weight:700;color:#FFFFFF;">V</span></div>
<span class="f" style="font-size:16px;font-weight:700;color:#1a1a1a;letter-spacing:-0.02em;">vibn</span>
</div>
<div id="sidebar-project-name" style="font-size:11px;font-weight:500;color:#9ca3af;padding-left:34px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:none;"></div>
</div>
<div style="font-size:9.5px;font-weight:600;letter-spacing:0.08em;text-transform:uppercase;color:#9ca3af;padding:0 6px;margin-bottom:8px;">MVP Setup</div>
<div style="display:flex;flex-direction:column;gap:2px;flex:1;">
<div class="sidebar-phase" onclick="window.location.href='05_describe.html'" style="cursor:pointer;" onmouseover="this.style.background='#f5f3ff'" onmouseout="this.style.background='transparent'">
<div class="phase-dot" style="background:#6366F1;color:#ffffff;"></div>
<div style="font-size:12.5px;color:#6b7280;">Describe</div>
</div>
<div class="sidebar-phase" onclick="window.location.href='06_architect.html'" style="cursor:pointer;" onmouseover="this.style.background='#f5f3ff'" onmouseout="this.style.background='transparent'">
<div class="phase-dot" style="background:#6366F1;color:#ffffff;"></div>
<div style="font-size:12.5px;color:#6b7280;">Architect</div>
</div>
<div class="sidebar-phase" onclick="window.location.href='07_design.html'" style="cursor:pointer;" onmouseover="this.style.background='#f5f3ff'" onmouseout="this.style.background='transparent'">
<div class="phase-dot" style="background:#6366F1;color:#ffffff;"></div>
<div style="font-size:12.5px;color:#6b7280;">Design</div>
</div>
<div class="sidebar-phase" onclick="window.location.href='08_website.html'" style="cursor:pointer;" onmouseover="this.style.background='#f5f3ff'" onmouseout="this.style.background='transparent'">
<div class="phase-dot" style="background:#6366F1;color:#ffffff;"></div>
<div style="font-size:12.5px;color:#6b7280;">Website</div>
</div>
<div class="sidebar-phase active">
<div class="phase-dot" style="background:#6366F1;color:#ffffff;"></div>
<div><div style="font-size:12.5px;font-weight:600;color:#1a1a1a;">Build MVP</div><div style="font-size:10px;color:#9ca3af;">Review &amp; launch</div></div>
</div>
</div>
<div style="border-top:1px solid #e5e7eb;margin-top:14px;padding-top:12px;">
<button onclick="saveAndExit()" style="display:flex;align-items:center;justify-content:center;gap:7px;width:100%;background:#eef2ff;border:1px solid #e0e7ff;border-radius:8px;padding:9px 10px;cursor:pointer;font-family:'Plus Jakarta Sans',sans-serif;transition:background 0.15s;" onmouseover="this.style.background=document.documentElement.dataset.theme==='dark'?'':'#e0e7ff'" onmouseout="this.style.background=document.documentElement.dataset.theme==='dark'?'':'#eef2ff'">
<span style="font-size:12px;font-weight:600;color:#6366F1;">Save & go to dashboard</span>
</button>
<button id="dark-toggle" onclick="toggleTheme()" style="margin-top:8px;display:flex;align-items:center;justify-content:center;width:100%;background:transparent;border:1px solid var(--border);border-radius:8px;padding:8px 10px;cursor:pointer;font-family:'Plus Jakarta Sans',sans-serif;font-size:12px;font-weight:500;color:var(--mid);transition:background 0.15s,border-color 0.15s;" onmouseover="this.style.borderColor='#6366F1';this.style.color='#6366F1';" onmouseout="this.style.borderColor='var(--border)';this.style.color='var(--mid)';">🌙 Dark mode</button>
</div>
</div>
<!-- MAIN -->
<div style="flex:1;overflow-y:auto;">
<!-- REVIEW SCREEN -->
<div id="screen-review" style="padding:28px 32px;max-width:680px;margin:0 auto;">
<div class="f" style="font-size:22px;font-weight:700;color:var(--ink);margin-bottom:6px;">Ready to build</div>
<p style="font-size:13.5px;color:var(--muted);margin-bottom:22px;">Review everything below. Once you hit Build, AI codes your full product and deploys it.</p>
<div style="background:var(--white);border:1px solid var(--border);border-radius:12px;overflow:hidden;margin-bottom:14px;">
<div style="padding:12px 18px;border-bottom:1px solid var(--border);"><span style="font-size:10.5px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:0.06em;">What's being built</span></div>
<div style="display:grid;grid-template-columns:1fr 1fr;">
<div style="padding:13px 18px;border-right:1px solid var(--border);border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;"><div style="width:26px;height:26px;border-radius:7px;background:var(--cream);display:flex;align-items:center;justify-content:center;font-size:12px;color:var(--ink3);flex-shrink:0;"></div><div><div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:2px;">Sign up &amp; login</div><div style="font-size:13px;font-weight:600;color:var(--ink);">Email + social login</div></div></div>
<div style="padding:13px 18px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;"><div style="width:26px;height:26px;border-radius:7px;background:var(--cream);display:flex;align-items:center;justify-content:center;font-size:12px;color:var(--ink3);flex-shrink:0;">$</div><div><div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:2px;">Payments</div><div style="font-size:13px;font-weight:600;color:var(--ink);">Subscription billing</div></div></div>
<div style="padding:13px 18px;border-right:1px solid var(--border);border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;"><div style="width:26px;height:26px;border-radius:7px;background:var(--cream);display:flex;align-items:center;justify-content:center;font-size:12px;color:var(--ink3);flex-shrink:0;"></div><div><div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:2px;">Email</div><div style="font-size:13px;font-weight:600;color:var(--ink);">Transactional + marketing</div></div></div>
<div style="padding:13px 18px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;"><div style="width:26px;height:26px;border-radius:7px;background:var(--cream);display:flex;align-items:center;justify-content:center;font-size:12px;color:var(--ink3);flex-shrink:0;"></div><div><div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:2px;">Product style</div><div style="font-size:13px;font-weight:600;color:var(--ink);">Clean &amp; focused</div></div></div>
<div style="padding:13px 18px;border-right:1px solid var(--border);display:flex;align-items:center;gap:10px;"><div style="width:26px;height:26px;border-radius:7px;background:var(--cream);display:flex;align-items:center;justify-content:center;font-size:12px;color:var(--ink3);flex-shrink:0;"></div><div><div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:2px;">Website style</div><div style="font-size:13px;font-weight:600;color:var(--ink);">Startup energy</div></div></div>
<div style="padding:13px 18px;display:flex;align-items:center;gap:10px;"><div style="width:26px;height:26px;border-radius:7px;background:var(--cream);display:flex;align-items:center;justify-content:center;font-size:12px;color:var(--ink3);flex-shrink:0;"></div><div><div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:2px;">Campaign topics</div><div style="font-size:13px;font-weight:600;color:var(--ink);">3 topics ready</div></div></div>
</div>
</div>
<div style="background:var(--white);border:1px solid var(--border);border-radius:12px;overflow:hidden;margin-bottom:14px;">
<div style="padding:12px 18px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;"><span style="font-size:10.5px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:0.06em;">Pages</span><span style="font-size:12px;color:var(--muted);">14 pages total</span></div>
<div style="padding:16px 18px;display:grid;grid-template-columns:repeat(4,1fr);gap:0;">
<div style="padding:0 14px 0 0;border-right:1px solid var(--border);margin-right:14px;"><div style="font-size:9.5px;font-weight:700;color:var(--subtitle);text-transform:uppercase;letter-spacing:0.07em;margin-bottom:8px;">Public</div><div style="font-size:12.5px;color:var(--ink2);line-height:2.0;">Landing page<br>Pricing<br>About<br>Blog</div></div>
<div style="padding:0 14px 0 0;border-right:1px solid var(--border);margin-right:14px;"><div style="font-size:9.5px;font-weight:700;color:var(--subtitle);text-transform:uppercase;letter-spacing:0.07em;margin-bottom:8px;">Auth</div><div style="font-size:12.5px;color:var(--ink2);line-height:2.0;">Sign up<br>Log in<br>Forgot password</div></div>
<div style="padding:0 14px 0 0;border-right:1px solid var(--border);margin-right:14px;"><div style="font-size:9.5px;font-weight:700;color:var(--subtitle);text-transform:uppercase;letter-spacing:0.07em;margin-bottom:8px;">App</div><div style="font-size:12.5px;color:var(--ink2);line-height:2.0;">Dashboard<br>Onboarding<br>Settings</div></div>
<div><div style="font-size:9.5px;font-weight:700;color:var(--subtitle);text-transform:uppercase;letter-spacing:0.07em;margin-bottom:8px;">Payments</div><div style="font-size:12.5px;color:var(--ink2);line-height:2.0;">Checkout<br>Success<br>Manage subscription</div></div>
</div>
</div>
<div style="background:var(--white);border:1px solid var(--border);border-radius:12px;overflow:hidden;margin-bottom:14px;">
<div style="padding:12px 18px;border-bottom:1px solid var(--border);"><span style="font-size:10.5px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:0.06em;">Your design</span></div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;">
<div style="padding:13px 18px;border-right:1px solid var(--border);display:flex;align-items:center;gap:10px;">
<div style="width:26px;height:26px;border-radius:7px;background:var(--cream);display:flex;align-items:center;justify-content:center;font-size:12px;color:var(--ink3);flex-shrink:0;"></div>
<div><div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:2px;">Feel</div><div id="build-feel" style="font-size:13px;font-weight:600;color:var(--ink);">Friendly</div></div>
</div>
<div style="padding:13px 18px;border-right:1px solid var(--border);display:flex;align-items:center;gap:10px;">
<div id="build-color-swatch" style="width:26px;height:26px;border-radius:50%;background:#6366F1;flex-shrink:0;box-shadow:0 0 0 3px var(--white),0 0 0 4px #e0e7ff;"></div>
<div><div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:2px;">Accent</div><div id="build-color" style="font-size:13px;font-weight:600;color:var(--ink);">Indigo</div></div>
</div>
<div style="padding:13px 18px;display:flex;align-items:center;gap:10px;">
<div style="width:26px;height:26px;border-radius:7px;background:var(--cream);display:flex;align-items:center;justify-content:center;font-size:12px;color:var(--ink3);flex-shrink:0;"></div>
<div><div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:2px;">Layout</div><div id="build-structure" style="font-size:13px;font-weight:600;color:var(--ink);">Clean</div></div>
</div>
</div>
</div>
<div style="background:var(--white);border:1px solid var(--border);border-radius:12px;overflow:hidden;margin-bottom:14px;">
<div style="padding:12px 18px;border-bottom:1px solid var(--border);"><span style="font-size:10.5px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:0.06em;">Your website</span></div>
<div style="display:grid;grid-template-columns:1fr 1fr;">
<div style="padding:13px 18px;border-right:1px solid var(--border);border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;">
<div style="width:26px;height:26px;border-radius:7px;background:var(--cream);display:flex;align-items:center;justify-content:center;font-size:12px;color:var(--ink3);flex-shrink:0;"></div>
<div><div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:2px;">Voice</div><div id="build-voice" style="font-size:13px;font-weight:600;color:var(--ink);">Friendly · Balanced · Warm</div></div>
</div>
<div style="padding:13px 18px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;">
<div style="width:26px;height:26px;border-radius:7px;background:var(--cream);display:flex;align-items:center;justify-content:center;font-size:12px;color:var(--ink3);flex-shrink:0;"></div>
<div><div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:2px;">Website style</div><div id="build-ws-style" style="font-size:13px;font-weight:600;color:var(--ink);">Editorial</div></div>
</div>
<div style="padding:13px 18px;grid-column:1/-1;display:flex;align-items:center;gap:10px;">
<div style="width:26px;height:26px;border-radius:7px;background:var(--cream);display:flex;align-items:center;justify-content:center;font-size:12px;color:var(--ink3);flex-shrink:0;"></div>
<div><div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:2px;">Topics</div><div id="build-topics" style="font-size:13px;font-weight:600;color:var(--ink);">The problem · Who it's for · Why now</div></div>
</div>
</div>
</div>
<!-- ── Launch section ── -->
<hr class="launch-divider">
<div class="launch-card">
<div style="margin-bottom:24px;">
<div style="font-size:24px;font-weight:700;color:var(--ink);letter-spacing:-0.01em;margin-bottom:8px;">You're ready to build your product</div>
<p style="font-size:14px;color:var(--muted);line-height:1.5;max-width:520px;">Your app will be generated, your backend configured, and everything deployed to your infrastructure — fully automated, no code needed.</p>
</div>
<div style="margin-top:26px;margin-bottom:6px;">
<div style="font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:0.08em;margin-bottom:14px;opacity:0.7;">What happens next</div>
<div class="build-step">
<div class="build-step-icon"></div>
<div style="flex:1;"><span style="font-size:13px;font-weight:500;color:var(--ink);">Generate UI &amp; all pages</span></div>
<span style="font-size:11px;color:var(--stone);">~30s</span>
</div>
<div class="build-step">
<div class="build-step-icon"></div>
<div style="flex:1;"><span style="font-size:13px;font-weight:500;color:var(--ink);">Set up database &amp; backend</span></div>
<span style="font-size:11px;color:var(--stone);">~45s</span>
</div>
<div class="build-step">
<div class="build-step-icon"></div>
<div style="flex:1;"><span style="font-size:13px;font-weight:500;color:var(--ink);">Connect auth, payments &amp; email</span></div>
<span style="font-size:11px;color:var(--stone);">~30s</span>
</div>
<div class="build-step">
<div class="build-step-icon"></div>
<div style="flex:1;"><span style="font-size:13px;font-weight:500;color:var(--ink);">Deploy your app live</span></div>
<span style="font-size:11px;color:var(--stone);">~20s</span>
</div>
</div>
<p style="font-size:11.5px;color:var(--muted);margin-bottom:28px;">Takes ~24 minutes · All steps run in parallel</p>
<button id="build-cta-btn" class="build-cta" onclick="startBuild()">Build my product</button>
<p style="font-size:12.5px;color:var(--muted);text-align:center;margin-top:10px;margin-bottom:4px;opacity:0.85;">No code needed &nbsp;·&nbsp; You can edit everything after</p>
</div>
<div style="text-align:center;padding-bottom:8px;">
<button onclick="window.history.back()" style="background:none;border:none;font-family:'Plus Jakarta Sans',sans-serif;font-size:12.5px;color:var(--muted);cursor:pointer;padding:6px 0;transition:color 0.15s;" onmouseover="this.style.color='var(--ink)'" onmouseout="this.style.color='var(--muted)'">← Go back and tweak choices</button>
</div>
</div>
<!-- PROGRESS SCREEN -->
<div id="screen-progress" style="display:none;padding:32px;max-width:580px;margin:0 auto;width:100%;">
<div id="prog-header" style="text-align:center;margin-bottom:26px;">
<div class="f grad-anim" style="font-size:24px;font-weight:700;letter-spacing:-0.03em;margin-bottom:6px;">Building your product…</div>
<div id="step-counter" style="font-size:13.5px;color:var(--muted);">Step 0 of 12</div>
</div>
<div id="steps-list" style="background:var(--white);border:1px solid var(--border);border-radius:13px;overflow:hidden;margin-bottom:18px;"></div>
<div id="done-section" style="display:none;">
<div style="background:var(--white);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin-bottom:14px;">
<div class="grad-title" style="font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:0.06em;margin-bottom:12px;">Your next 3 actions</div>
<div style="display:flex;gap:12px;padding:10px 0;border-bottom:1px solid var(--border);"><div class="step-num">1</div><div><div style="font-size:13px;font-weight:600;color:var(--ink);margin-bottom:2px;">Open your live app</div><div style="font-size:12px;color:var(--muted);line-height:1.5;">Share the URL with 5 real people today.</div></div></div>
<div style="display:flex;gap:12px;padding:10px 0;border-bottom:1px solid var(--border);"><div class="step-num">2</div><div><div style="font-size:13px;font-weight:600;color:var(--ink);margin-bottom:2px;">Sign up as a user</div><div style="font-size:12px;color:var(--muted);line-height:1.5;">Go through your own onboarding. Fix anything confusing.</div></div></div>
<div style="display:flex;gap:12px;padding:10px 0;"><div class="step-num">3</div><div><div style="font-size:13px;font-weight:600;color:var(--ink);margin-bottom:2px;">Post your first topic</div><div style="font-size:12px;color:var(--muted);line-height:1.5;">AI has drafted your first content batch. Publish one today.</div></div></div>
</div>
<div style="display:flex;gap:10px;">
<a href="10_vibe_editor.html" style="flex:2;display:block;text-decoration:none;"><button class="build-cta" style="border-radius:11px;font-size:14px;">Open my app ↗</button></a>
<button class="btn-gitea" style="flex:1;">View in Gitea ↗</button>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function(){
try {
var name = localStorage.getItem('vibn_project_name') || 'My project';
var el = document.getElementById('sidebar-project-name');
el.textContent = name;
el.style.display = 'block';
} catch(e){}
/* ── Populate design summary ── */
try {
var ds = JSON.parse(localStorage.getItem('vibn_design') || 'null');
if(ds){
var feelLabels = {premium:'Premium SaaS', friendly:'Friendly & Approachable', minimal:'Minimal & Clean'};
var structLabels = {clean:'Clean', data:'Data-rich', bold:'Bold'};
document.getElementById('build-feel').textContent = feelLabels[ds.feel] || ds.feel;
document.getElementById('build-structure').textContent = structLabels[ds.structure] || ds.structure;
document.getElementById('build-color').textContent = ds.colorName || ds.color;
var swatch = document.getElementById('build-color-swatch');
swatch.style.background = ds.colorHex || '#6366F1';
swatch.style.boxShadow = '0 0 0 3px var(--white),0 0 0 4px '+(ds.colorHex ? ds.colorHex+'44' : '#e0e7ff');
}
} catch(e){}
/* ── Populate website summary ── */
try {
var ws = JSON.parse(localStorage.getItem('vibn_website') || 'null');
if(ws){
if(ws.voice){
document.getElementById('build-voice').textContent = ws.voice.tone + ' · ' + ws.voice.style + ' · ' + ws.voice.pers;
}
if(ws.styleLabel){
document.getElementById('build-ws-style').textContent = ws.styleLabel;
}
if(ws.topics && ws.topics.length){
document.getElementById('build-topics').textContent = ws.topics.join(' · ');
}
}
} catch(e){}
});
var STEPS=[
{l:'Creating Gitea repository',d:'Setting up version control for your project'},
{l:'Scaffolding the app',d:'Next.js · TypeScript · Tailwind CSS'},
{l:'Setting up your database',d:'PostgreSQL + schema based on your product plan'},
{l:'Building sign up & login',d:'Email + Google + GitHub OAuth'},
{l:'Wiring payments',d:'Stripe checkout, webhooks, billing portal'},
{l:'Generating app pages',d:'Dashboard, settings, onboarding, invite flow'},
{l:'Applying your design',d:'Clean & focused theme applied across all pages'},
{l:'Building marketing website',d:'Startup energy style · SEO-ready'},
{l:'Setting up email',d:'Welcome, password reset, and marketing templates'},
{l:'Pushing to Gitea',d:'Full codebase committed and pushed'},
{l:'Deploying via Coolify',d:'Building Docker image, deploying to your servers'},
{l:'Running health checks',d:'Verifying all pages, auth, and payments are live'},
];
var cur=0, iv=null;
function startBuild(){
var btn = document.getElementById('build-cta-btn');
if(btn){ btn.disabled=true; btn.innerHTML='<span class="spinning" style="display:inline-block;margin-right:7px;font-size:12px;">◎</span>Building your product…'; }
setTimeout(function(){
document.getElementById('screen-review').style.display='none';
document.getElementById('screen-progress').style.display='block';
renderSteps();
iv=setInterval(function(){
cur++;
if(cur>=STEPS.length){
clearInterval(iv);
document.getElementById('prog-header').innerHTML='<div style="font-size:36px;margin-bottom:12px;">🚀</div><div class="f grad-title" style="font-size:24px;font-weight:700;letter-spacing:-0.03em;margin-bottom:6px;">Your MVP is live</div><div style="font-size:13.5px;color:var(--muted);">Deployed to Coolify · Pushed to Gitea · Ready to share</div>';
document.getElementById('done-section').style.display='block';
triggerConfetti();
} else {
document.getElementById('step-counter').textContent='Step '+cur+' of 12';
}
renderSteps();
},700);
},400);
}
function renderSteps(){
var list=document.getElementById('steps-list'); list.innerHTML='';
STEPS.forEach(function(s,i){
var done=i<cur, active=i===cur;
var div=document.createElement('div');
div.style.cssText='display:flex;align-items:center;gap:12px;padding:10px 15px;border-bottom:'+(i<STEPS.length-1?'1px solid var(--border)':'none')+';background:'+(active?'var(--cream)':'transparent')+';transition:background 0.3s;';
var dotBg=done?'linear-gradient(135deg,#2E2A5E,#6366F1)':active?'linear-gradient(135deg,#4338CA,#6C7CFF)':'var(--parch)';
var dotContent=done?'<span style="color:#fff;font-size:9px;font-weight:900;">✓</span>'
:active?'<span class="spinning" style="color:#fff;font-size:8px;">◎</span>':'';
div.innerHTML='<div style="width:20px;height:20px;border-radius:50%;background:'+dotBg+';display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:all 0.3s;">'+dotContent+'</div>'
+'<div style="flex:1;"><div style="font-size:12.5px;font-weight:'+(active?'600':'400')+';color:'+(done?'var(--muted)':active?'var(--ink)':'var(--stone)')+';">'+s.l+'</div>'
+((done||active)?'<div style="font-size:11px;color:var(--mid);margin-top:1px;">'+s.d+'</div>':'')+'</div>';
list.appendChild(div);
});
}
function triggerConfetti(){
var wrap=document.createElement('div');
wrap.style.cssText='position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:9999;overflow:hidden;';
document.body.appendChild(wrap);
var colors=['#6366F1','#818CF8','#4338CA','#A5B4FC','#C7D2FE','#FCD34D','#F472B6','#34D399','#60A5FA','#FBBF24'];
var shapes=['50%','3px','0'];
for(var i=0;i<110;i++){
var el=document.createElement('div');
var color=colors[Math.floor(Math.random()*colors.length)];
var size=Math.random()*9+4;
var left=Math.random()*100;
var delay=Math.random()*1.2;
var dur=Math.random()*2.5+2;
var br=shapes[Math.floor(Math.random()*shapes.length)];
var xDrift=(Math.random()-0.5)*200;
el.style.cssText='position:absolute;top:-12px;left:'+left+'%;width:'+size+'px;height:'+(size*(Math.random()*0.6+0.4))+'px;background:'+color+';border-radius:'+br+';animation:confetti-fall '+dur+'s '+delay+'s ease-in forwards;transform:translateX('+xDrift+'px) rotate('+(Math.random()*360)+'deg);';
wrap.appendChild(el);
}
setTimeout(function(){if(wrap.parentNode)wrap.parentNode.removeChild(wrap);},5000);
}
function saveAndExit(){window.location.href='03_dashboard.html';}
function toggleTheme(){const html=document.documentElement;const isDark=html.dataset.theme==='dark';html.dataset.theme=isDark?'':'dark';document.getElementById('dark-toggle').textContent=isDark?'🌙 Dark mode':'☀️ Light mode';localStorage.setItem('vibn-theme',isDark?'':'dark');}
(function(){const saved=localStorage.getItem('vibn-theme');if(saved==='dark'){document.documentElement.dataset.theme='dark';document.addEventListener('DOMContentLoaded',function(){const btn=document.getElementById('dark-toggle');if(btn)btn.textContent='☀️ Light mode';});}})();
</script>
</body></html>

View File

@@ -0,0 +1,44 @@
# vibn — UX Screen Pack
Design system: **Ink & parchment** — Lora serif + Inter sans, no colour accent.
All HTML files open directly in any browser — no build step required.
## Complete screen inventory
| File | Screen | Interactive? |
|------|--------|-------------|
| `01_homepage.html` | Marketing homepage | Scroll |
| `02_signup.html` | Sign up (3 steps) | ✓ Click through steps, mode selection |
| `03_dashboard.html` | Projects dashboard | ✓ Projects / Clients / Invoices / Costs nav |
| `04_welcome.html` | Welcome phase | Static |
| `05_discover.html` | Discover phase | ✓ Live chat, PRD fills in |
| `06_architect.html` | Architect phase | ✓ Change modal per block |
| `07_design.html` | Design phase | ✓ Live style preview switching |
| `08_market.html` | Market phase | ✓ Voice sliders, Topics, Website style |
| `09_build.html` | Build phase | ✓ Review + animated 12-step pipeline |
| `10_vibe_editor.html` | Vibe editor (post-launch) | ✓ Chat, preview updates, deploy status |
| `vibn-website.jsx` | Marketing site | React component |
| `vibn-dashboard.jsx` | Dashboard + billing | React component |
| `00_design-tokens.css` | Design tokens | Reference |
## Full user flow
01 Homepage → 02 Sign up → 03 Dashboard → 04 Welcome →
05 Discover → 06 Architect → 07 Design → 08 Market →
09 Build → 10 Vibe editor
## Design tokens
| Token | Value | Usage |
|-------|-------|-------|
| `--ink` | `#1a1510` | Primary text, buttons |
| `--ink3` | `#444441` | Secondary text |
| `--mid` | `#5f5e5a` | Body text |
| `--muted` | `#888780` | Labels, captions |
| `--stone` | `#b4b2a9` | Disabled, hints |
| `--cream` | `#f1efe8` | Surface tint, hover |
| `--paper` | `#f7f4ee` | Page background |
| `--white` | `#fdfcfa` | Card backgrounds |
| `--border` | `#e8e2d9` | All borders |
Heading font: **Lora** (serif)
Body font: **Inter** (sans-serif)

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -0,0 +1,119 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><link rel="icon" href="favicon_clean.ico">
<title>Sign in Google Accounts</title>
<link href="https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;600&family=Roboto:wght@400;500&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0;}
body{font-family:'Roboto',sans-serif;background:#FFFFFF;display:flex;flex-direction:column;align-items:center;min-height:100vh;padding:40px 24px;}
.card{width:100%;max-width:400px;border:1px solid #DADCE0;border-radius:8px;padding:40px 40px 28px;display:flex;flex-direction:column;align-items:center;}
.google-logo{margin-bottom:24px;}
h1{font-family:'Google Sans',sans-serif;font-size:24px;font-weight:400;color:#202124;margin-bottom:8px;text-align:center;}
.subtitle{font-size:16px;color:#202124;margin-bottom:24px;text-align:center;}
/* Account tile */
.account-tile{width:100%;border:1px solid #DADCE0;border-radius:8px;padding:12px 16px;display:flex;align-items:center;gap:16px;cursor:pointer;transition:background 0.15s;margin-bottom:12px;}
.account-tile:hover{background:#F8F9FA;}
.avatar{width:40px;height:40px;border-radius:50%;background:linear-gradient(135deg,#4285F4,#34A853);display:flex;align-items:center;justify-content:center;font-family:'Google Sans',sans-serif;font-size:16px;font-weight:500;color:#FFFFFF;flex-shrink:0;}
.account-info{flex:1;text-align:left;}
.account-name{font-family:'Google Sans',sans-serif;font-size:14px;font-weight:500;color:#202124;margin-bottom:2px;}
.account-email{font-size:13px;color:#5F6368;}
.chevron{color:#5F6368;}
/* Add account */
.add-account{width:100%;border:1px solid #DADCE0;border-radius:8px;padding:12px 16px;display:flex;align-items:center;gap:16px;cursor:pointer;transition:background 0.15s;margin-bottom:24px;}
.add-account:hover{background:#F8F9FA;}
.add-icon{width:40px;height:40px;border-radius:50%;background:#F1F3F4;display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:20px;color:#5F6368;}
.add-label{font-size:14px;color:#202124;}
.divider{width:100%;height:1px;background:#E8EAED;margin-bottom:20px;}
/* Continue button */
.btn-continue{background:#1A73E8;color:#FFFFFF;border:none;border-radius:4px;padding:10px 24px;font-family:'Google Sans',sans-serif;font-size:14px;font-weight:500;cursor:pointer;transition:background 0.15s,box-shadow 0.15s;box-shadow:0 1px 2px rgba(0,0,0,0.2);}
.btn-continue:hover{background:#1765CC;box-shadow:0 2px 6px rgba(0,0,0,0.2);}
.footer{margin-top:auto;padding-top:24px;display:flex;gap:24px;justify-content:center;}
.footer a{font-size:12px;color:#5F6368;text-decoration:none;}
.footer a:hover{text-decoration:underline;}
/* Loading state */
.loading{display:none;flex-direction:column;align-items:center;gap:16px;margin-top:20px;}
.spinner{width:32px;height:32px;border:3px solid #E8EAED;border-top-color:#1A73E8;border-radius:50%;animation:spin 0.8s linear infinite;}
@keyframes spin{to{transform:rotate(360deg);}}
.loading-text{font-size:14px;color:#5F6368;}
</style>
</head>
<body>
<div class="card">
<!-- Google logo -->
<div class="google-logo">
<svg width="75" height="24" viewBox="0 0 75 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M30.5 12.3c0-.7-.1-1.4-.2-2h-9.6v3.8h5.5c-.2 1.3-1 2.4-2.1 3.1v2.6h3.4c2-1.8 3-4.5 3-7.5z" fill="#4285F4"/>
<path d="M20.7 19.9c2.7 0 5-.9 6.7-2.4l-3.4-2.6c-.9.6-2 1-3.3 1-2.6 0-4.7-1.7-5.5-4.1H11.7v2.7c1.7 3.4 5.2 5.4 9 5.4z" fill="#34A853"/>
<path d="M15.2 11.8c-.2-.6-.3-1.2-.3-1.8s.1-1.2.3-1.8V7.5H11.7C11 8.8 10.7 10.3 10.7 12s.3 3.2 1 4.5l3.5-2.7z" fill="#FBBC05"/>
<path d="M20.7 5.9c1.4 0 2.7.5 3.7 1.5l2.8-2.8C25.7 3 23.4 2 20.7 2c-3.8 0-7.3 2-9 5.4l3.5 2.7c.8-2.4 2.9-4.2 5.5-4.2z" fill="#EA4335"/>
<text x="36" y="17" font-family="'Product Sans',Roboto,sans-serif" font-size="16" fill="#5F6368">Google</text>
</svg>
</div>
<h1>Sign in</h1>
<p class="subtitle">to continue to vibn</p>
<!-- Account selector (shown by default) -->
<div id="account-select" style="width:100%;">
<div class="account-tile" onclick="selectAccount()">
<div class="avatar">J</div>
<div class="account-info">
<div class="account-name">Jane Smith</div>
<div class="account-email">jane@gmail.com</div>
</div>
<svg class="chevron" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
</div>
<div class="add-account" onclick="selectAccount()">
<div class="add-icon"></div>
<div class="add-label">Use another account</div>
</div>
<div class="divider"></div>
<p style="font-size:12px;color:#5F6368;text-align:center;line-height:1.6;">To continue, Google will share your name, email address, and profile picture with vibn.</p>
</div>
<!-- Loading state (shown after selection) -->
<div class="loading" id="loading">
<div class="spinner"></div>
<div class="loading-text">Signing you in…</div>
</div>
</div>
<div class="footer">
<a href="#">Help</a>
<a href="#">Privacy</a>
<a href="#">Terms</a>
</div>
<script>
function selectAccount(){
// Show loading
document.getElementById('account-select').style.display='none';
document.getElementById('loading').style.display='flex';
// After brief delay, notify parent and close
setTimeout(function(){
if(window.opener){
window.opener.postMessage({type:'google-auth-success',name:'Jane Smith',email:'jane@gmail.com'},'*');
}
window.close();
}, 1000);
}
</script>
</body>
</html>

View File

@@ -0,0 +1,484 @@
// vibn — Projects Dashboard
// Restyled from original (DM Sans + purple/colour accents) → Ink & parchment
// Design: Lora serif + Inter sans, #1a1510 ink, #f7f4ee paper, no colour accent
// Usage: default export, no required props
import { useState } from "react";
const T = {
ink: "#1a1510",
ink2: "#2c2c2a",
ink3: "#444441",
mid: "#5f5e5a",
muted: "#888780",
stone: "#b4b2a9",
parch: "#d3d1c7",
cream: "#f1efe8",
paper: "#f7f4ee",
white: "#fdfcfa",
border: "#e8e2d9",
border2:"#d3d1c7",
};
const F = { serif: "'Lora', Georgia, serif", sans: "'Inter', sans-serif" };
// ─── Shared primitives ─────────────────────────────────────────────────────────
function StatusPill({ label, variant = "default" }) {
const styles = {
live: { bg: T.cream, text: T.ink3, border: T.border },
building: { bg: T.cream, text: T.ink3, border: T.border },
default: { bg: T.paper, text: T.muted, border: T.border },
invoiced: { bg: T.ink, text: T.paper, border: T.ink },
unbilled: { bg: T.cream, text: T.ink3, border: T.border },
scheduled: { bg: T.parch, text: T.ink2, border: T.border2 },
};
const s = styles[variant] || styles.default;
return (
<span style={{
fontFamily: F.sans, fontSize: 10.5, fontWeight: 600,
color: s.text, background: s.bg,
border: `1px solid ${s.border}`,
borderRadius: 5, padding: "2px 8px", whiteSpace: "nowrap",
}}>{label}</span>
);
}
function InkBtn({ children, onClick, small, outline }) {
return (
<button onClick={onClick} style={{
fontFamily: F.sans, fontWeight: 600,
fontSize: small ? 12 : 13.5,
padding: small ? "6px 14px" : "10px 22px",
background: outline ? "transparent" : T.ink,
color: outline ? T.ink3 : T.paper,
border: outline ? `1px solid ${T.border2}` : "none",
borderRadius: 8, cursor: "pointer",
}}>{children}</button>
);
}
// ─── Nav ───────────────────────────────────────────────────────────────────────
function Nav({ screen, setScreen }) {
return (
<nav style={{
background: T.white, borderBottom: `1px solid ${T.border}`,
padding: "0 32px", height: 60, display: "flex",
alignItems: "center", justifyContent: "space-between",
}}>
<div onClick={() => setScreen("projects")} style={{ display: "flex", alignItems: "center", gap: 10, cursor: "pointer" }}>
<div style={{ width: 28, height: 28, background: T.ink, borderRadius: 7, display: "flex", alignItems: "center", justifyContent: "center" }}>
<span style={{ fontFamily: F.serif, fontSize: 14, fontWeight: 700, color: T.paper }}>V</span>
</div>
<span style={{ fontFamily: F.serif, fontSize: 18, fontWeight: 700, color: T.ink, letterSpacing: "-0.02em" }}>vibn</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 22 }}>
{screen === "billing" && (
<span onClick={() => setScreen("projects")} style={{ fontFamily: F.sans, fontSize: 13.5, color: T.muted, cursor: "pointer" }}> All projects</span>
)}
<span style={{ fontFamily: F.sans, fontSize: 13.5, color: T.muted, cursor: "pointer" }}>Settings</span>
<div style={{ width: 30, height: 30, borderRadius: "50%", background: T.ink3, display: "flex", alignItems: "center", justifyContent: "center", fontFamily: F.sans, fontSize: 11, color: T.paper, fontWeight: 700 }}>JD</div>
</div>
</nav>
);
}
// ─── Data ──────────────────────────────────────────────────────────────────────
const PROJECTS = [
{
id: "launchpad", label: "Launchpad", initial: "L",
type: "own", status: "live", url: "launchpad.vibn.app",
stats: { visitors: "2.4k", signups: 183, mrr: "$840" },
},
{
id: "flowmatic", label: "Flowmatic", initial: "F",
type: "client", status: "live", url: "flowmatic.app",
client: "Acme Corp",
stats: { visitors: "890", signups: 54, mrr: "$210" },
costs: { total: 48.20, llm: 29.20, compute: 11.60, other: 7.40, billed: false },
},
{
id: "taskly", label: "Taskly", initial: "T",
type: "client", status: "building", url: null,
client: "Beta Labs", buildProgress: 60,
costs: { total: 12.40, llm: 9.20, compute: 3.20, other: 0, billed: false },
},
];
const ACTIVITY = [
{ text: "Launchpad — Blog post published:", detail: '"How to launch faster with AI"', time: "2h ago" },
{ text: "Flowmatic — New signup:", detail: "marcus@email.com", time: "4h ago" },
{ text: "Taskly — Checkout page built and deployed", detail: "", time: "6h ago" },
{ text: "Launchpad — Newsletter #12", detail: "scheduled", time: "Yesterday" },
];
const BILLING_ROWS = [
{ label: "Flowmatic", initial: "F", client: "Acme Corp", llm: 29.20, compute: 11.60, other: 7.40, total: 48.20, billed: false },
{ label: "Taskly", initial: "T", client: "Beta Labs", llm: 9.20, compute: 3.20, other: 0, total: 12.40, billed: false },
{ label: "Flowmatic", initial: "F", client: "Acme · Feb", llm: 22.10, compute: 8.40, other: 4.20, total: 34.70, billed: true },
];
const COST_LOG = [
{ time: "2h ago", desc: "LLM: Homepage copy generation", project: "Flowmatic", cost: 0.82 },
{ time: "3h ago", desc: "LLM: Checkout page code", project: "Taskly", cost: 1.24 },
{ time: "5h ago", desc: "LLM: Weekly newsletter draft", project: "Flowmatic", cost: 0.43 },
{ time: "6h ago", desc: "Compute: Build pipeline run", project: "Taskly", cost: 0.18 },
{ time: "8h ago", desc: "LLM: Discover phase Q&A", project: "Flowmatic", cost: 0.31 },
{ time: "Yesterday", desc: "Email delivery · 240 recipients", project: "Flowmatic", cost: 0.96 },
];
// ─── Project card ──────────────────────────────────────────────────────────────
function ProjectCard({ project }) {
const isClient = project.type === "client";
const isBuilding = project.status === "building";
return (
<div style={{ background: T.white, border: `1px solid ${T.border}`, borderRadius: 14, overflow: "hidden" }}>
{/* Header preview */}
{isBuilding ? (
<div style={{ height: 100, background: T.cream, borderBottom: `1px solid ${T.border}`, display: "flex", alignItems: "center", justifyContent: "center", position: "relative" }}>
<div style={{ textAlign: "center" }}>
<div style={{ fontFamily: F.sans, fontSize: 11.5, color: T.muted, fontWeight: 500, marginBottom: 10 }}>
Build phase · {project.buildProgress}% complete
</div>
<div style={{ width: 160, height: 4, background: T.parch, borderRadius: 3, overflow: "hidden" }}>
<div style={{ width: `${project.buildProgress}%`, height: "100%", background: T.ink3, borderRadius: 3 }} />
</div>
</div>
{isClient && (
<div style={{ position: "absolute", top: 10, right: 12, fontFamily: F.sans, fontSize: 10, fontWeight: 600, color: T.mid, background: T.white, border: `1px solid ${T.border}`, borderRadius: 5, padding: "2px 8px" }}>
Client
</div>
)}
</div>
) : (
<div style={{ height: 100, background: T.ink, display: "flex", alignItems: "center", justifyContent: "center", position: "relative" }}>
<div style={{ background: "rgba(247,244,238,0.1)", borderRadius: 8, width: "55%", padding: "10px 14px" }}>
<div style={{ height: 8, background: "rgba(247,244,238,0.6)", borderRadius: 3, width: "65%", marginBottom: 6 }} />
<div style={{ height: 5, background: "rgba(247,244,238,0.25)", borderRadius: 3, width: "85%" }} />
</div>
<div style={{ position: "absolute", top: 10, right: 12, fontFamily: F.sans, fontSize: 10, fontWeight: 600, color: "rgba(247,244,238,0.6)", background: "rgba(247,244,238,0.1)", borderRadius: 5, padding: "2px 8px" }}>
{isClient ? "Client" : "My product"}
</div>
</div>
)}
<div style={{ padding: "18px 20px" }}>
{/* Identity row */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 12 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ width: 28, height: 28, background: T.ink, borderRadius: 8, display: "flex", alignItems: "center", justifyContent: "center", fontFamily: F.serif, fontSize: 12, color: T.paper, fontWeight: 700 }}>
{project.initial}
</div>
<div>
<div style={{ fontFamily: F.serif, fontSize: 15, fontWeight: 700, color: T.ink }}>{project.label}</div>
<div style={{ fontFamily: F.sans, fontSize: 11, color: T.muted }}>
{isClient ? `${project.client} · ` : ""}
{project.url || "Setting up pages…"}
</div>
</div>
</div>
<StatusPill label={isBuilding ? "Building" : "Live"} variant={isBuilding ? "building" : "live"} />
</div>
{/* Cost strip — client + building */}
{isClient && project.costs && isBuilding && (
<div style={{ background: T.cream, border: `1px solid ${T.border}`, borderRadius: 9, padding: "10px 14px", marginBottom: 12, display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div>
<div style={{ fontFamily: F.sans, fontSize: 10, fontWeight: 600, color: T.muted, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 3 }}>Costs so far</div>
<div style={{ fontFamily: F.serif, fontSize: 17, fontWeight: 700, color: T.ink }}>${project.costs.total.toFixed(2)}</div>
</div>
<StatusPill label="Unbilled" variant="unbilled" />
</div>
)}
{/* Cost strip — client + live */}
{isClient && project.costs && !isBuilding && (
<div style={{ background: T.cream, border: `1px solid ${T.border}`, borderRadius: 9, padding: "10px 14px", marginBottom: 12, display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ flex: 1 }}>
<div style={{ fontFamily: F.sans, fontSize: 10, fontWeight: 600, color: T.muted, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 3 }}>Costs this month</div>
<div style={{ fontFamily: F.serif, fontSize: 17, fontWeight: 700, color: T.ink }}>${project.costs.total.toFixed(2)}</div>
</div>
<div style={{ fontFamily: F.sans, fontSize: 11, color: T.mid, lineHeight: 1.7 }}>
LLM ${project.costs.llm.toFixed(2)}<br />
Compute ${project.costs.compute.toFixed(2)}
</div>
{!project.costs.billed && (
<button style={{ background: T.ink, border: "none", color: T.paper, borderRadius: 7, padding: "7px 13px", fontFamily: F.sans, fontSize: 11.5, fontWeight: 600, cursor: "pointer" }}>
Bill
</button>
)}
</div>
)}
{/* Stats */}
{!isBuilding && project.stats && (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 8, marginBottom: 14 }}>
{[["visitors", project.stats.visitors], ["signups", project.stats.signups], ["MRR", project.stats.mrr]].map(([k, v]) => (
<div key={k} style={{ textAlign: "center" }}>
<div style={{ fontFamily: F.serif, fontSize: 16, fontWeight: 700, color: T.ink }}>{v}</div>
<div style={{ fontFamily: F.sans, fontSize: 10, color: T.muted }}>{k}</div>
</div>
))}
</div>
)}
{/* Actions */}
{isBuilding ? (
<button style={{ width: "100%", background: T.ink, border: "none", color: T.paper, borderRadius: 8, padding: 10, fontFamily: F.sans, fontSize: 13, fontWeight: 600, cursor: "pointer" }}>
Continue building
</button>
) : (
<div style={{ display: "flex", gap: 6 }}>
{[["⬡", "Build"], ["◈", "Grow"]].map(([icon, label]) => (
<div key={label} style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: 5, padding: "7px 10px", background: T.cream, border: `1px solid ${T.border}`, borderRadius: 7, cursor: "pointer" }}>
<span style={{ fontSize: 11 }}>{icon}</span>
<span style={{ fontFamily: F.sans, fontSize: 11.5, color: T.ink3 }}>{label}</span>
</div>
))}
<div style={{ padding: "7px 12px", background: T.cream, border: `1px solid ${T.border}`, borderRadius: 7, fontFamily: F.sans, fontSize: 11.5, color: T.ink3, cursor: "pointer" }}>
</div>
</div>
)}
</div>
</div>
);
}
// ─── Projects screen ───────────────────────────────────────────────────────────
function ProjectsScreen({ setScreen }) {
const totalUnbilled = PROJECTS
.filter(p => p.type === "client" && p.costs?.billed === false)
.reduce((s, p) => s + p.costs.total, 0);
return (
<div style={{ maxWidth: 1000, margin: "0 auto", padding: "36px 32px" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 32 }}>
<div>
<h1 style={{ fontFamily: F.serif, fontSize: 26, fontWeight: 700, color: T.ink, letterSpacing: "-0.02em", marginBottom: 4 }}>Your projects</h1>
<p style={{ fontFamily: F.sans, fontSize: 13.5, color: T.muted }}>3 active · 1 building</p>
</div>
<div style={{ display: "flex", gap: 10, alignItems: "center" }}>
{totalUnbilled > 0 && (
<button onClick={() => setScreen("billing")} style={{ fontFamily: F.sans, fontSize: 13, color: T.ink3, background: T.cream, border: `1px solid ${T.border}`, borderRadius: 8, padding: "9px 16px", cursor: "pointer" }}>
${totalUnbilled.toFixed(2)} unbilled
</button>
)}
<InkBtn>+ New project</InkBtn>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14, marginBottom: 28 }}>
{PROJECTS.map(p => <ProjectCard key={p.id} project={p} />)}
{/* New project CTA card */}
<div
style={{ background: "transparent", border: `1px dashed ${T.parch}`, borderRadius: 14, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 12, padding: 40, cursor: "pointer", minHeight: 220 }}
onMouseEnter={e => e.currentTarget.style.background = T.cream}
onMouseLeave={e => e.currentTarget.style.background = "transparent"}
>
<div style={{ width: 42, height: 42, borderRadius: 10, border: `1px solid ${T.parch}`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 22, color: T.stone }}>+</div>
<div style={{ textAlign: "center" }}>
<div style={{ fontFamily: F.serif, fontSize: 14, fontWeight: 600, color: T.ink, marginBottom: 4 }}>New project</div>
<div style={{ fontFamily: F.sans, fontSize: 12.5, color: T.muted }}>For yourself or a client</div>
</div>
</div>
</div>
{/* Activity feed */}
<div style={{ background: T.white, border: `1px solid ${T.border}`, borderRadius: 14, padding: "20px 24px" }}>
<div style={{ fontFamily: F.serif, fontSize: 14, fontWeight: 600, color: T.ink, marginBottom: 16 }}>Recent activity</div>
{ACTIVITY.map((a, i) => (
<div key={i} style={{ display: "flex", alignItems: "center", gap: 12, padding: "11px 0", borderBottom: i < ACTIVITY.length - 1 ? `1px solid ${T.border}` : "none" }}>
<div style={{ width: 7, height: 7, borderRadius: "50%", background: T.ink3, flexShrink: 0 }} />
<div style={{ flex: 1, fontFamily: F.sans, fontSize: 13.5, color: T.ink }}>
{a.text}{" "}{a.detail && <span style={{ color: T.muted }}>{a.detail}</span>}
</div>
<span style={{ fontFamily: F.sans, fontSize: 11.5, color: T.stone, whiteSpace: "nowrap" }}>{a.time}</span>
</div>
))}
</div>
</div>
);
}
// ─── Billing screen ────────────────────────────────────────────────────────────
function BillingScreen() {
const [tab, setTab] = useState("billing");
const unbilled = BILLING_ROWS.filter(r => !r.billed).reduce((s, r) => s + r.total, 0);
return (
<div style={{ maxWidth: 1000, margin: "0 auto", padding: "28px 32px" }}>
{/* Sub-tabs */}
<div style={{ display: "flex", borderBottom: `1px solid ${T.border}`, marginBottom: 28 }}>
{[["billing", "Client billing"], ["costs", "Cost tracker"]].map(([id, label]) => (
<button key={id} onClick={() => setTab(id)} style={{
padding: "10px 18px", border: "none", background: "transparent",
borderBottom: tab === id ? `2px solid ${T.ink}` : "2px solid transparent",
fontFamily: F.sans, fontSize: 13.5, cursor: "pointer",
color: tab === id ? T.ink : T.muted, fontWeight: tab === id ? 600 : 400,
}}>{label}</button>
))}
</div>
{tab === "billing" && <>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 22 }}>
<div>
<h2 style={{ fontFamily: F.serif, fontSize: 22, fontWeight: 700, color: T.ink, marginBottom: 4 }}>Client billing</h2>
<p style={{ fontFamily: F.sans, fontSize: 13, color: T.muted }}>All costs tracked and ready to invoice</p>
</div>
<InkBtn>Generate invoice</InkBtn>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr 1fr", gap: 10, marginBottom: 24 }}>
{[
{ label: "Total unbilled", value: `$${unbilled.toFixed(2)}` },
{ label: "LLM costs", value: "$38.40" },
{ label: "Compute", value: "$14.80" },
{ label: "Other", value: "$7.40" },
].map(c => (
<div key={c.label} style={{ background: T.cream, borderRadius: 10, padding: "14px 16px" }}>
<div style={{ fontFamily: F.sans, fontSize: 11, color: T.muted, marginBottom: 5 }}>{c.label}</div>
<div style={{ fontFamily: F.serif, fontSize: 22, fontWeight: 700, color: T.ink }}>{c.value}</div>
</div>
))}
</div>
<div style={{ background: T.white, border: `1px solid ${T.border}`, borderRadius: 12, overflow: "hidden" }}>
<div style={{ padding: "13px 20px", borderBottom: `1px solid ${T.border}`, display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<span style={{ fontFamily: F.serif, fontSize: 14, fontWeight: 600, color: T.ink }}>Breakdown by client</span>
<select style={{ border: `1px solid ${T.border}`, borderRadius: 6, padding: "4px 10px", fontFamily: F.sans, fontSize: 12, color: T.muted, background: T.paper, outline: "none" }}>
<option>March 2026</option>
</select>
</div>
<div style={{ display: "grid", gridTemplateColumns: "2fr 1fr 1fr 1fr 1fr 120px", padding: "9px 20px", background: T.cream, borderBottom: `1px solid ${T.border}` }}>
{["Project / Client", "LLM", "Compute", "Other", "Total", "Status"].map(h => (
<div key={h} style={{ fontFamily: F.sans, fontSize: 10.5, fontWeight: 600, color: T.muted, textTransform: "uppercase", letterSpacing: "0.05em" }}>{h}</div>
))}
</div>
{BILLING_ROWS.map((r, i) => (
<div key={i} style={{ display: "grid", gridTemplateColumns: "2fr 1fr 1fr 1fr 1fr 120px", padding: "13px 20px", borderBottom: i < BILLING_ROWS.length - 1 ? `1px solid ${T.border}` : "none", alignItems: "center", opacity: r.billed ? 0.5 : 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ width: 24, height: 24, background: T.ink, borderRadius: 6, display: "flex", alignItems: "center", justifyContent: "center", fontFamily: F.serif, fontSize: 10, color: T.paper, fontWeight: 700 }}>{r.initial}</div>
<div>
<div style={{ fontFamily: F.sans, fontSize: 13, fontWeight: 600, color: T.ink }}>{r.label}</div>
<div style={{ fontFamily: F.sans, fontSize: 11, color: T.muted }}>{r.client}</div>
</div>
</div>
<div style={{ fontFamily: F.sans, fontSize: 13, color: T.ink }}>${r.llm.toFixed(2)}</div>
<div style={{ fontFamily: F.sans, fontSize: 13, color: T.ink }}>${r.compute.toFixed(2)}</div>
<div style={{ fontFamily: F.sans, fontSize: 13, color: T.ink }}>${r.other.toFixed(2)}</div>
<div style={{ fontFamily: F.sans, fontSize: 13, fontWeight: 600, color: T.ink }}>${r.total.toFixed(2)}</div>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<StatusPill label={r.billed ? "Invoiced" : "Unbilled"} variant={r.billed ? "invoiced" : "unbilled"} />
{!r.billed && (
<button style={{ background: "transparent", border: `1px solid ${T.border2}`, borderRadius: 5, padding: "3px 9px", fontFamily: F.sans, fontSize: 11, color: T.muted, cursor: "pointer" }}>Invoice</button>
)}
</div>
</div>
))}
</div>
</>}
{tab === "costs" && <>
<div style={{ marginBottom: 22 }}>
<h2 style={{ fontFamily: F.serif, fontSize: 22, fontWeight: 700, color: T.ink, marginBottom: 4 }}>Cost tracker</h2>
<p style={{ fontFamily: F.sans, fontSize: 13, color: T.muted }}>Every dollar spent, broken down by type and project</p>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14, marginBottom: 20 }}>
<div style={{ background: T.white, border: `1px solid ${T.border}`, borderRadius: 12, padding: "18px 20px" }}>
<div style={{ fontFamily: F.serif, fontSize: 14, fontWeight: 600, color: T.ink, marginBottom: 16 }}>LLM usage</div>
{[
{ label: "Code generation", amount: 21.40, pct: 56 },
{ label: "Content & marketing", amount: 10.20, pct: 27 },
{ label: "Chat assist", amount: 6.80, pct: 18 },
].map(r => (
<div key={r.label} style={{ marginBottom: 14 }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 6 }}>
<span style={{ fontFamily: F.sans, fontSize: 12.5, color: T.mid }}>{r.label}</span>
<span style={{ fontFamily: F.sans, fontSize: 12.5, fontWeight: 600, color: T.ink }}>${r.amount.toFixed(2)}</span>
</div>
<div style={{ height: 4, background: T.cream, borderRadius: 2, overflow: "hidden" }}>
<div style={{ width: `${r.pct}%`, height: "100%", background: T.ink, borderRadius: 2 }} />
</div>
</div>
))}
<div style={{ marginTop: 14, paddingTop: 12, borderTop: `1px solid ${T.border}`, display: "flex", justifyContent: "space-between" }}>
<span style={{ fontFamily: F.sans, fontSize: 12, color: T.muted }}>Total LLM</span>
<span style={{ fontFamily: F.serif, fontSize: 15, fontWeight: 700, color: T.ink }}>$38.40</span>
</div>
</div>
<div style={{ background: T.white, border: `1px solid ${T.border}`, borderRadius: 12, padding: "18px 20px" }}>
<div style={{ fontFamily: F.serif, fontSize: 14, fontWeight: 600, color: T.ink, marginBottom: 16 }}>Infrastructure</div>
{[
{ label: "Hosting & compute", amount: 11.60 },
{ label: "Database", amount: 3.20 },
{ label: "Email delivery", amount: 4.20 },
{ label: "Domain & SSL", amount: 3.20 },
].map(r => (
<div key={r.label} style={{ display: "flex", justifyContent: "space-between", padding: "8px 11px", background: T.cream, borderRadius: 7, marginBottom: 7 }}>
<span style={{ fontFamily: F.sans, fontSize: 13, color: T.mid }}>{r.label}</span>
<span style={{ fontFamily: F.sans, fontSize: 13, fontWeight: 600, color: T.ink }}>${r.amount.toFixed(2)}</span>
</div>
))}
<div style={{ marginTop: 14, paddingTop: 12, borderTop: `1px solid ${T.border}`, display: "flex", justifyContent: "space-between" }}>
<span style={{ fontFamily: F.sans, fontSize: 12, color: T.muted }}>Total infra</span>
<span style={{ fontFamily: F.serif, fontSize: 15, fontWeight: 700, color: T.ink }}>$22.20</span>
</div>
</div>
</div>
<div style={{ background: T.white, border: `1px solid ${T.border}`, borderRadius: 12, overflow: "hidden" }}>
<div style={{ padding: "13px 20px", borderBottom: `1px solid ${T.border}` }}>
<span style={{ fontFamily: F.serif, fontSize: 14, fontWeight: 600, color: T.ink }}>Recent charges</span>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 2fr 1fr 80px", padding: "9px 20px", background: T.cream, borderBottom: `1px solid ${T.border}` }}>
{["Time", "Description", "Project", "Cost"].map(h => (
<div key={h} style={{ fontFamily: F.sans, fontSize: 10.5, fontWeight: 600, color: T.muted, textTransform: "uppercase", letterSpacing: "0.05em" }}>{h}</div>
))}
</div>
{COST_LOG.map((row, i) => (
<div key={i} style={{ display: "grid", gridTemplateColumns: "1fr 2fr 1fr 80px", padding: "11px 20px", borderBottom: i < COST_LOG.length - 1 ? `1px solid ${T.border}` : "none", alignItems: "center" }}>
<div style={{ fontFamily: F.sans, fontSize: 12, color: T.muted }}>{row.time}</div>
<div style={{ fontFamily: F.sans, fontSize: 13, color: T.ink }}>{row.desc}</div>
<div style={{ fontFamily: F.sans, fontSize: 12, color: T.mid }}>{row.project}</div>
<div style={{ fontFamily: F.sans, fontSize: 13, fontWeight: 600, color: T.ink }}>${row.cost.toFixed(2)}</div>
</div>
))}
</div>
</>}
</div>
);
}
// ─── Root ──────────────────────────────────────────────────────────────────────
export default function Dashboard() {
const [screen, setScreen] = useState("projects");
return (
<div style={{ background: T.paper, minHeight: "100vh" }}>
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;0,600;0,700;1,400;1,600&family=Inter:wght@400;500;600&display=swap');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
button { font-family: inherit; cursor: pointer; }
input, select { font-family: inherit; }
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-thumb { background: ${T.parch}; border-radius: 4px; }
`}</style>
<Nav screen={screen} setScreen={setScreen} />
{screen === "projects" && <ProjectsScreen setScreen={setScreen} />}
{screen === "billing" && <BillingScreen />}
</div>
);
}

View File

@@ -0,0 +1,288 @@
// vibn — Marketing Website
// Design: Ink & parchment — Lora serif + Inter sans, no colour accent
// Usage: <Website onGetStarted={fn} onLogin={fn} />
import { useState } from "react";
const T = {
ink: "#1a1510",
ink2: "#2c2c2a",
ink3: "#444441",
mid: "#5f5e5a",
muted: "#888780",
stone: "#b4b2a9",
parch: "#d3d1c7",
cream: "#f1efe8",
paper: "#f7f4ee",
white: "#fdfcfa",
border: "#e8e2d9",
};
const F = { serif: "'Lora', Georgia, serif", sans: "'Inter', sans-serif" };
// ─── Primitives ────────────────────────────────────────────────────────────────
function Eyebrow({ children }) {
return (
<div style={{ fontFamily: F.sans, fontSize: 11, fontWeight: 600, letterSpacing: "0.13em", textTransform: "uppercase", color: T.muted, marginBottom: 16 }}>
{children}
</div>
);
}
function PrimaryBtn({ children, onClick, large }) {
return (
<button onClick={onClick} style={{
background: T.ink, color: T.paper, border: "none",
fontFamily: F.sans, fontWeight: 600,
fontSize: large ? 15 : 14,
padding: large ? "14px 34px" : "10px 24px",
borderRadius: 10, cursor: "pointer",
}}>{children}</button>
);
}
// ─── Nav ───────────────────────────────────────────────────────────────────────
function Nav({ onGetStarted, onLogin }) {
return (
<nav style={{
background: T.paper, borderBottom: `1px solid ${T.border}`,
padding: "0 48px", height: 60,
display: "flex", alignItems: "center", justifyContent: "space-between",
position: "sticky", top: 0, zIndex: 50,
}}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ width: 30, height: 30, background: T.ink, borderRadius: 7, display: "flex", alignItems: "center", justifyContent: "center" }}>
<span style={{ fontFamily: F.serif, fontSize: 15, fontWeight: 700, color: T.paper }}>V</span>
</div>
<span style={{ fontFamily: F.serif, fontSize: 19, fontWeight: 700, color: T.ink, letterSpacing: "-0.02em" }}>vibn</span>
</div>
<div style={{ display: "flex", gap: 32 }}>
{["Product", "Pricing", "Stories", "Blog"].map(l => (
<span key={l} style={{ fontFamily: F.sans, fontSize: 14, color: T.muted, cursor: "pointer" }}>{l}</span>
))}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<span onClick={onLogin} style={{ fontFamily: F.sans, fontSize: 14, color: T.muted, cursor: "pointer" }}>Log in</span>
<PrimaryBtn onClick={onGetStarted}>Get started free</PrimaryBtn>
</div>
</nav>
);
}
// ─── Hero ──────────────────────────────────────────────────────────────────────
function Hero({ onCta }) {
return (
<section style={{ maxWidth: 960, margin: "0 auto", padding: "88px 48px 72px" }}>
<Eyebrow>For non-technical founders</Eyebrow>
<h1 style={{
fontFamily: F.serif, fontSize: 64, fontWeight: 700, color: T.ink,
letterSpacing: "-0.03em", lineHeight: 1.07, marginBottom: 28, maxWidth: 680,
}}>
You have the idea.<br />
We handle<br />
<em style={{ fontStyle: "italic", color: T.ink3 }}>everything else.</em>
</h1>
<p style={{ fontFamily: F.sans, fontSize: 17.5, color: T.mid, lineHeight: 1.75, maxWidth: 480, marginBottom: 40 }}>
No backend. No DevOps. No marketing agency. Describe your idea and vibn
builds, deploys, and promotes it automatically.
</p>
<div style={{ display: "flex", alignItems: "center", gap: 18, marginBottom: 14 }}>
<PrimaryBtn onClick={onCta} large>Start free no code needed</PrimaryBtn>
<span style={{ fontFamily: F.sans, fontSize: 13.5, color: T.stone }}>&nbsp;&nbsp;280 founders launched</span>
</div>
<p style={{ fontFamily: F.sans, fontSize: 12, color: T.stone }}>No credit card required · Free forever plan</p>
</section>
);
}
// ─── Quote band ────────────────────────────────────────────────────────────────
const QUOTES = [
{ q: "I had the idea for 2 years. The backend terrified me. vibn shipped it in 4 days and handles all my marketing.", by: "Alex K.", role: "Founder, Taskly" },
{ q: "I have zero coding experience. Three weeks in I have 300 paying users. That's entirely because of vibn.", by: "Marcus L.", role: "Founder, Flowmatic" },
{ q: "The marketing autopilot alone saved me ten hours a week. My blog runs itself. I just focus on my product.", by: "Sara R.", role: "Founder, Nudge" },
];
function QuoteBand() {
return (
<section style={{ background: T.ink, padding: "52px 48px" }}>
<div style={{ maxWidth: 960, margin: "0 auto", display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 40 }}>
{QUOTES.map((q, i) => (
<div key={i} style={{ display: "flex", gap: 20 }}>
<div style={{ width: 3, background: T.mid, borderRadius: 2, flexShrink: 0 }} />
<div>
<p style={{ fontFamily: F.serif, fontSize: 15, color: T.parch, lineHeight: 1.72, fontStyle: "italic", marginBottom: 12 }}>"{q.q}"</p>
<span style={{ fontFamily: F.sans, fontSize: 11.5, color: T.muted, fontWeight: 600 }}> {q.by}, {q.role}</span>
</div>
</div>
))}
</div>
</section>
);
}
// ─── How it works ──────────────────────────────────────────────────────────────
const PHASES = [
{ n: "01", id: "Discover", title: "Define your idea", body: "Six guided questions turn a rough idea into a full product plan — pages, architecture, revenue model. No jargon." },
{ n: "02", id: "Design", title: "Choose your style", body: "Pick a visual style and see your exact site and emails live before a single line of code is written." },
{ n: "03", id: "Build", title: "Your app, live", body: "AI writes every line. Auth, database, payments, all pages — deployed and live. Describe changes in plain English." },
{ n: "04", id: "Grow", title: "Market & automate", body: "AI generates your blog, emails, and social schedule — publishing on autopilot so you can focus on your users." },
];
function HowItWorks() {
return (
<section style={{ maxWidth: 960, margin: "0 auto", padding: "80px 48px" }}>
<Eyebrow>How it works</Eyebrow>
<h2 style={{ fontFamily: F.serif, fontSize: 40, fontWeight: 700, color: T.ink, letterSpacing: "-0.02em", lineHeight: 1.15, marginBottom: 52, maxWidth: 460 }}>
Four phases.<br />One complete product.
</h2>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", border: `1px solid ${T.border}`, borderRadius: 14, overflow: "hidden" }}>
{PHASES.map((p, i) => (
<div key={i} style={{
padding: "38px 42px", background: T.white,
borderRight: (i % 2 === 0) ? `1px solid ${T.border}` : "none",
borderBottom: (i < 2) ? `1px solid ${T.border}` : "none",
}}>
<div style={{ fontFamily: F.sans, fontSize: 11, fontWeight: 600, letterSpacing: "0.1em", textTransform: "uppercase", color: T.muted, marginBottom: 14 }}>
{p.n} {p.id}
</div>
<div style={{ fontFamily: F.serif, fontSize: 21, fontWeight: 700, color: T.ink, marginBottom: 10 }}>{p.title}</div>
<p style={{ fontFamily: F.sans, fontSize: 13.5, color: T.mid, lineHeight: 1.7 }}>{p.body}</p>
</div>
))}
</div>
</section>
);
}
// ─── Stats bar ─────────────────────────────────────────────────────────────────
const STATS = [
{ n: "280+", label: "founders launched" },
{ n: "72h", label: "average time to first version" },
{ n: "4.9★", label: "average rating" },
{ n: "3×", label: "faster than hiring a developer" },
];
function StatsBar() {
return (
<section style={{ background: T.white, borderTop: `1px solid ${T.border}`, borderBottom: `1px solid ${T.border}` }}>
<div style={{ maxWidth: 960, margin: "0 auto", padding: "0 48px", display: "grid", gridTemplateColumns: "1fr 1fr 1fr 1fr" }}>
{STATS.map((s, i) => (
<div key={i} style={{ padding: "38px 0", paddingLeft: i > 0 ? 36 : 0, borderRight: i < 3 ? `1px solid ${T.border}` : "none" }}>
<div style={{ fontFamily: F.serif, fontSize: 38, fontWeight: 700, color: T.ink, letterSpacing: "-0.03em", marginBottom: 6 }}>{s.n}</div>
<div style={{ fontFamily: F.sans, fontSize: 13, color: T.muted }}>{s.label}</div>
</div>
))}
</div>
</section>
);
}
// ─── Empathy section ───────────────────────────────────────────────────────────
const PAINS = [
{ title: "No more \"I need to hire a developer first\"", body: "vibn is your developer. Start building the moment you have an idea." },
{ title: "No more staring at a blank marketing calendar", body: "AI generates and publishes your content every single week." },
{ title: "No more \"I'll launch when it's ready\"", body: "Most founders ship their first version in under 72 hours." },
];
function EmpathySection() {
return (
<section style={{ background: T.cream, borderTop: `1px solid ${T.border}`, borderBottom: `1px solid ${T.border}`, padding: "76px 48px" }}>
<div style={{ maxWidth: 960, margin: "0 auto", display: "grid", gridTemplateColumns: "1fr 1fr", gap: 68, alignItems: "center" }}>
<div>
<Eyebrow>Sound familiar?</Eyebrow>
<h2 style={{ fontFamily: F.serif, fontSize: 36, fontWeight: 700, color: T.ink, lineHeight: 1.18, marginBottom: 24, letterSpacing: "-0.02em" }}>
The idea is the hard part. Everything else shouldn't be.
</h2>
<p style={{ fontFamily: F.sans, fontSize: 15, color: T.mid, lineHeight: 1.8, marginBottom: 20 }}>
You know exactly what you want to build and who it's for. But the moment you think
about servers, databases, deployment pipelines, SEO strategies the whole thing stalls.
</p>
<p style={{ fontFamily: F.sans, fontSize: 15, color: T.mid, lineHeight: 1.8 }}>
vibn exists to remove all of that. Not abstract it {" "}
<em style={{ fontFamily: F.serif, fontStyle: "italic" }}>remove it entirely.</em>
</p>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
{PAINS.map((p, i) => (
<div key={i} style={{ background: T.white, border: `1px solid ${T.border}`, borderRadius: 12, padding: "18px 20px", display: "flex", gap: 14, alignItems: "flex-start" }}>
<div style={{ width: 20, height: 20, borderRadius: "50%", border: `1.5px solid ${T.stone}`, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0, marginTop: 2 }}>
<div style={{ width: 7, height: 7, borderRadius: "50%", background: T.ink }} />
</div>
<div>
<div style={{ fontFamily: F.serif, fontSize: 14, fontWeight: 600, color: T.ink, marginBottom: 4 }}>{p.title}</div>
<div style={{ fontFamily: F.sans, fontSize: 13, color: T.muted, lineHeight: 1.6 }}>{p.body}</div>
</div>
</div>
))}
</div>
</div>
</section>
);
}
// ─── Final CTA ─────────────────────────────────────────────────────────────────
function FinalCta({ onCta }) {
return (
<section style={{ maxWidth: 660, margin: "0 auto", padding: "92px 48px", textAlign: "center" }}>
<h2 style={{ fontFamily: F.serif, fontSize: 46, fontWeight: 700, color: T.ink, letterSpacing: "-0.03em", lineHeight: 1.1, marginBottom: 20 }}>
Your idea deserves to exist.
</h2>
<p style={{ fontFamily: F.sans, fontSize: 16, color: T.mid, lineHeight: 1.75, marginBottom: 36 }}>
Don't let the backend be the reason it doesn't. Start today free, no code, no credit card.
</p>
<PrimaryBtn onClick={onCta} large>Build my product free</PrimaryBtn>
<div style={{ fontFamily: F.sans, fontSize: 12.5, color: T.stone, marginTop: 16 }}>
Joins 280+ non-technical founders already live
</div>
</section>
);
}
// ─── Footer ────────────────────────────────────────────────────────────────────
function Footer() {
return (
<footer style={{ borderTop: `1px solid ${T.border}`, padding: "32px 48px", display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<span style={{ fontFamily: F.serif, fontSize: 16, fontWeight: 700, color: T.ink }}>vibn</span>
<div style={{ display: "flex", gap: 28 }}>
{["Product", "Pricing", "Stories", "Blog", "Privacy", "Terms"].map(l => (
<span key={l} style={{ fontFamily: F.sans, fontSize: 13, color: T.stone, cursor: "pointer" }}>{l}</span>
))}
</div>
<span style={{ fontFamily: F.sans, fontSize: 12.5, color: T.stone }}>© 2026 vibn</span>
</footer>
);
}
// ─── Root export ───────────────────────────────────────────────────────────────
export default function Website({ onGetStarted = () => {}, onLogin = () => {} }) {
return (
<div style={{ background: T.paper, color: T.ink, minHeight: "100vh" }}>
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;0,600;0,700;1,400;1,600&family=Inter:wght@400;500;600&display=swap');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', sans-serif; }
button { font-family: inherit; }
`}</style>
<Nav onGetStarted={onGetStarted} onLogin={onLogin} />
<Hero onCta={onGetStarted} />
<QuoteBand />
<HowItWorks />
<StatsBar />
<EmpathySection />
<FinalCta onCta={onGetStarted} />
<Footer />
</div>
);
}

484
justine/vibn-dashboard.jsx Normal file
View File

@@ -0,0 +1,484 @@
// vibn — Projects Dashboard
// Restyled from original (DM Sans + purple/colour accents) → Ink & parchment
// Design: Lora serif + Inter sans, #1a1510 ink, #f7f4ee paper, no colour accent
// Usage: default export, no required props
import { useState } from "react";
const T = {
ink: "#1a1510",
ink2: "#2c2c2a",
ink3: "#444441",
mid: "#5f5e5a",
muted: "#888780",
stone: "#b4b2a9",
parch: "#d3d1c7",
cream: "#f1efe8",
paper: "#f7f4ee",
white: "#fdfcfa",
border: "#e8e2d9",
border2:"#d3d1c7",
};
const F = { serif: "'Lora', Georgia, serif", sans: "'Inter', sans-serif" };
// ─── Shared primitives ─────────────────────────────────────────────────────────
function StatusPill({ label, variant = "default" }) {
const styles = {
live: { bg: T.cream, text: T.ink3, border: T.border },
building: { bg: T.cream, text: T.ink3, border: T.border },
default: { bg: T.paper, text: T.muted, border: T.border },
invoiced: { bg: T.ink, text: T.paper, border: T.ink },
unbilled: { bg: T.cream, text: T.ink3, border: T.border },
scheduled: { bg: T.parch, text: T.ink2, border: T.border2 },
};
const s = styles[variant] || styles.default;
return (
<span style={{
fontFamily: F.sans, fontSize: 10.5, fontWeight: 600,
color: s.text, background: s.bg,
border: `1px solid ${s.border}`,
borderRadius: 5, padding: "2px 8px", whiteSpace: "nowrap",
}}>{label}</span>
);
}
function InkBtn({ children, onClick, small, outline }) {
return (
<button onClick={onClick} style={{
fontFamily: F.sans, fontWeight: 600,
fontSize: small ? 12 : 13.5,
padding: small ? "6px 14px" : "10px 22px",
background: outline ? "transparent" : T.ink,
color: outline ? T.ink3 : T.paper,
border: outline ? `1px solid ${T.border2}` : "none",
borderRadius: 8, cursor: "pointer",
}}>{children}</button>
);
}
// ─── Nav ───────────────────────────────────────────────────────────────────────
function Nav({ screen, setScreen }) {
return (
<nav style={{
background: T.white, borderBottom: `1px solid ${T.border}`,
padding: "0 32px", height: 60, display: "flex",
alignItems: "center", justifyContent: "space-between",
}}>
<div onClick={() => setScreen("projects")} style={{ display: "flex", alignItems: "center", gap: 10, cursor: "pointer" }}>
<div style={{ width: 28, height: 28, background: T.ink, borderRadius: 7, display: "flex", alignItems: "center", justifyContent: "center" }}>
<span style={{ fontFamily: F.serif, fontSize: 14, fontWeight: 700, color: T.paper }}>V</span>
</div>
<span style={{ fontFamily: F.serif, fontSize: 18, fontWeight: 700, color: T.ink, letterSpacing: "-0.02em" }}>vibn</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 22 }}>
{screen === "billing" && (
<span onClick={() => setScreen("projects")} style={{ fontFamily: F.sans, fontSize: 13.5, color: T.muted, cursor: "pointer" }}> All projects</span>
)}
<span style={{ fontFamily: F.sans, fontSize: 13.5, color: T.muted, cursor: "pointer" }}>Settings</span>
<div style={{ width: 30, height: 30, borderRadius: "50%", background: T.ink3, display: "flex", alignItems: "center", justifyContent: "center", fontFamily: F.sans, fontSize: 11, color: T.paper, fontWeight: 700 }}>JD</div>
</div>
</nav>
);
}
// ─── Data ──────────────────────────────────────────────────────────────────────
const PROJECTS = [
{
id: "launchpad", label: "Launchpad", initial: "L",
type: "own", status: "live", url: "launchpad.vibn.app",
stats: { visitors: "2.4k", signups: 183, mrr: "$840" },
},
{
id: "flowmatic", label: "Flowmatic", initial: "F",
type: "client", status: "live", url: "flowmatic.app",
client: "Acme Corp",
stats: { visitors: "890", signups: 54, mrr: "$210" },
costs: { total: 48.20, llm: 29.20, compute: 11.60, other: 7.40, billed: false },
},
{
id: "taskly", label: "Taskly", initial: "T",
type: "client", status: "building", url: null,
client: "Beta Labs", buildProgress: 60,
costs: { total: 12.40, llm: 9.20, compute: 3.20, other: 0, billed: false },
},
];
const ACTIVITY = [
{ text: "Launchpad — Blog post published:", detail: '"How to launch faster with AI"', time: "2h ago" },
{ text: "Flowmatic — New signup:", detail: "marcus@email.com", time: "4h ago" },
{ text: "Taskly — Checkout page built and deployed", detail: "", time: "6h ago" },
{ text: "Launchpad — Newsletter #12", detail: "scheduled", time: "Yesterday" },
];
const BILLING_ROWS = [
{ label: "Flowmatic", initial: "F", client: "Acme Corp", llm: 29.20, compute: 11.60, other: 7.40, total: 48.20, billed: false },
{ label: "Taskly", initial: "T", client: "Beta Labs", llm: 9.20, compute: 3.20, other: 0, total: 12.40, billed: false },
{ label: "Flowmatic", initial: "F", client: "Acme · Feb", llm: 22.10, compute: 8.40, other: 4.20, total: 34.70, billed: true },
];
const COST_LOG = [
{ time: "2h ago", desc: "LLM: Homepage copy generation", project: "Flowmatic", cost: 0.82 },
{ time: "3h ago", desc: "LLM: Checkout page code", project: "Taskly", cost: 1.24 },
{ time: "5h ago", desc: "LLM: Weekly newsletter draft", project: "Flowmatic", cost: 0.43 },
{ time: "6h ago", desc: "Compute: Build pipeline run", project: "Taskly", cost: 0.18 },
{ time: "8h ago", desc: "LLM: Discover phase Q&A", project: "Flowmatic", cost: 0.31 },
{ time: "Yesterday", desc: "Email delivery · 240 recipients", project: "Flowmatic", cost: 0.96 },
];
// ─── Project card ──────────────────────────────────────────────────────────────
function ProjectCard({ project }) {
const isClient = project.type === "client";
const isBuilding = project.status === "building";
return (
<div style={{ background: T.white, border: `1px solid ${T.border}`, borderRadius: 14, overflow: "hidden" }}>
{/* Header preview */}
{isBuilding ? (
<div style={{ height: 100, background: T.cream, borderBottom: `1px solid ${T.border}`, display: "flex", alignItems: "center", justifyContent: "center", position: "relative" }}>
<div style={{ textAlign: "center" }}>
<div style={{ fontFamily: F.sans, fontSize: 11.5, color: T.muted, fontWeight: 500, marginBottom: 10 }}>
Build phase · {project.buildProgress}% complete
</div>
<div style={{ width: 160, height: 4, background: T.parch, borderRadius: 3, overflow: "hidden" }}>
<div style={{ width: `${project.buildProgress}%`, height: "100%", background: T.ink3, borderRadius: 3 }} />
</div>
</div>
{isClient && (
<div style={{ position: "absolute", top: 10, right: 12, fontFamily: F.sans, fontSize: 10, fontWeight: 600, color: T.mid, background: T.white, border: `1px solid ${T.border}`, borderRadius: 5, padding: "2px 8px" }}>
Client
</div>
)}
</div>
) : (
<div style={{ height: 100, background: T.ink, display: "flex", alignItems: "center", justifyContent: "center", position: "relative" }}>
<div style={{ background: "rgba(247,244,238,0.1)", borderRadius: 8, width: "55%", padding: "10px 14px" }}>
<div style={{ height: 8, background: "rgba(247,244,238,0.6)", borderRadius: 3, width: "65%", marginBottom: 6 }} />
<div style={{ height: 5, background: "rgba(247,244,238,0.25)", borderRadius: 3, width: "85%" }} />
</div>
<div style={{ position: "absolute", top: 10, right: 12, fontFamily: F.sans, fontSize: 10, fontWeight: 600, color: "rgba(247,244,238,0.6)", background: "rgba(247,244,238,0.1)", borderRadius: 5, padding: "2px 8px" }}>
{isClient ? "Client" : "My product"}
</div>
</div>
)}
<div style={{ padding: "18px 20px" }}>
{/* Identity row */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 12 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ width: 28, height: 28, background: T.ink, borderRadius: 8, display: "flex", alignItems: "center", justifyContent: "center", fontFamily: F.serif, fontSize: 12, color: T.paper, fontWeight: 700 }}>
{project.initial}
</div>
<div>
<div style={{ fontFamily: F.serif, fontSize: 15, fontWeight: 700, color: T.ink }}>{project.label}</div>
<div style={{ fontFamily: F.sans, fontSize: 11, color: T.muted }}>
{isClient ? `${project.client} · ` : ""}
{project.url || "Setting up pages…"}
</div>
</div>
</div>
<StatusPill label={isBuilding ? "Building" : "Live"} variant={isBuilding ? "building" : "live"} />
</div>
{/* Cost strip — client + building */}
{isClient && project.costs && isBuilding && (
<div style={{ background: T.cream, border: `1px solid ${T.border}`, borderRadius: 9, padding: "10px 14px", marginBottom: 12, display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div>
<div style={{ fontFamily: F.sans, fontSize: 10, fontWeight: 600, color: T.muted, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 3 }}>Costs so far</div>
<div style={{ fontFamily: F.serif, fontSize: 17, fontWeight: 700, color: T.ink }}>${project.costs.total.toFixed(2)}</div>
</div>
<StatusPill label="Unbilled" variant="unbilled" />
</div>
)}
{/* Cost strip — client + live */}
{isClient && project.costs && !isBuilding && (
<div style={{ background: T.cream, border: `1px solid ${T.border}`, borderRadius: 9, padding: "10px 14px", marginBottom: 12, display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ flex: 1 }}>
<div style={{ fontFamily: F.sans, fontSize: 10, fontWeight: 600, color: T.muted, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 3 }}>Costs this month</div>
<div style={{ fontFamily: F.serif, fontSize: 17, fontWeight: 700, color: T.ink }}>${project.costs.total.toFixed(2)}</div>
</div>
<div style={{ fontFamily: F.sans, fontSize: 11, color: T.mid, lineHeight: 1.7 }}>
LLM ${project.costs.llm.toFixed(2)}<br />
Compute ${project.costs.compute.toFixed(2)}
</div>
{!project.costs.billed && (
<button style={{ background: T.ink, border: "none", color: T.paper, borderRadius: 7, padding: "7px 13px", fontFamily: F.sans, fontSize: 11.5, fontWeight: 600, cursor: "pointer" }}>
Bill
</button>
)}
</div>
)}
{/* Stats */}
{!isBuilding && project.stats && (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 8, marginBottom: 14 }}>
{[["visitors", project.stats.visitors], ["signups", project.stats.signups], ["MRR", project.stats.mrr]].map(([k, v]) => (
<div key={k} style={{ textAlign: "center" }}>
<div style={{ fontFamily: F.serif, fontSize: 16, fontWeight: 700, color: T.ink }}>{v}</div>
<div style={{ fontFamily: F.sans, fontSize: 10, color: T.muted }}>{k}</div>
</div>
))}
</div>
)}
{/* Actions */}
{isBuilding ? (
<button style={{ width: "100%", background: T.ink, border: "none", color: T.paper, borderRadius: 8, padding: 10, fontFamily: F.sans, fontSize: 13, fontWeight: 600, cursor: "pointer" }}>
Continue building
</button>
) : (
<div style={{ display: "flex", gap: 6 }}>
{[["⬡", "Build"], ["◈", "Grow"]].map(([icon, label]) => (
<div key={label} style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: 5, padding: "7px 10px", background: T.cream, border: `1px solid ${T.border}`, borderRadius: 7, cursor: "pointer" }}>
<span style={{ fontSize: 11 }}>{icon}</span>
<span style={{ fontFamily: F.sans, fontSize: 11.5, color: T.ink3 }}>{label}</span>
</div>
))}
<div style={{ padding: "7px 12px", background: T.cream, border: `1px solid ${T.border}`, borderRadius: 7, fontFamily: F.sans, fontSize: 11.5, color: T.ink3, cursor: "pointer" }}>
</div>
</div>
)}
</div>
</div>
);
}
// ─── Projects screen ───────────────────────────────────────────────────────────
function ProjectsScreen({ setScreen }) {
const totalUnbilled = PROJECTS
.filter(p => p.type === "client" && p.costs?.billed === false)
.reduce((s, p) => s + p.costs.total, 0);
return (
<div style={{ maxWidth: 1000, margin: "0 auto", padding: "36px 32px" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 32 }}>
<div>
<h1 style={{ fontFamily: F.serif, fontSize: 26, fontWeight: 700, color: T.ink, letterSpacing: "-0.02em", marginBottom: 4 }}>Your projects</h1>
<p style={{ fontFamily: F.sans, fontSize: 13.5, color: T.muted }}>3 active · 1 building</p>
</div>
<div style={{ display: "flex", gap: 10, alignItems: "center" }}>
{totalUnbilled > 0 && (
<button onClick={() => setScreen("billing")} style={{ fontFamily: F.sans, fontSize: 13, color: T.ink3, background: T.cream, border: `1px solid ${T.border}`, borderRadius: 8, padding: "9px 16px", cursor: "pointer" }}>
${totalUnbilled.toFixed(2)} unbilled
</button>
)}
<InkBtn>+ New project</InkBtn>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14, marginBottom: 28 }}>
{PROJECTS.map(p => <ProjectCard key={p.id} project={p} />)}
{/* New project CTA card */}
<div
style={{ background: "transparent", border: `1px dashed ${T.parch}`, borderRadius: 14, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 12, padding: 40, cursor: "pointer", minHeight: 220 }}
onMouseEnter={e => e.currentTarget.style.background = T.cream}
onMouseLeave={e => e.currentTarget.style.background = "transparent"}
>
<div style={{ width: 42, height: 42, borderRadius: 10, border: `1px solid ${T.parch}`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 22, color: T.stone }}>+</div>
<div style={{ textAlign: "center" }}>
<div style={{ fontFamily: F.serif, fontSize: 14, fontWeight: 600, color: T.ink, marginBottom: 4 }}>New project</div>
<div style={{ fontFamily: F.sans, fontSize: 12.5, color: T.muted }}>For yourself or a client</div>
</div>
</div>
</div>
{/* Activity feed */}
<div style={{ background: T.white, border: `1px solid ${T.border}`, borderRadius: 14, padding: "20px 24px" }}>
<div style={{ fontFamily: F.serif, fontSize: 14, fontWeight: 600, color: T.ink, marginBottom: 16 }}>Recent activity</div>
{ACTIVITY.map((a, i) => (
<div key={i} style={{ display: "flex", alignItems: "center", gap: 12, padding: "11px 0", borderBottom: i < ACTIVITY.length - 1 ? `1px solid ${T.border}` : "none" }}>
<div style={{ width: 7, height: 7, borderRadius: "50%", background: T.ink3, flexShrink: 0 }} />
<div style={{ flex: 1, fontFamily: F.sans, fontSize: 13.5, color: T.ink }}>
{a.text}{" "}{a.detail && <span style={{ color: T.muted }}>{a.detail}</span>}
</div>
<span style={{ fontFamily: F.sans, fontSize: 11.5, color: T.stone, whiteSpace: "nowrap" }}>{a.time}</span>
</div>
))}
</div>
</div>
);
}
// ─── Billing screen ────────────────────────────────────────────────────────────
function BillingScreen() {
const [tab, setTab] = useState("billing");
const unbilled = BILLING_ROWS.filter(r => !r.billed).reduce((s, r) => s + r.total, 0);
return (
<div style={{ maxWidth: 1000, margin: "0 auto", padding: "28px 32px" }}>
{/* Sub-tabs */}
<div style={{ display: "flex", borderBottom: `1px solid ${T.border}`, marginBottom: 28 }}>
{[["billing", "Client billing"], ["costs", "Cost tracker"]].map(([id, label]) => (
<button key={id} onClick={() => setTab(id)} style={{
padding: "10px 18px", border: "none", background: "transparent",
borderBottom: tab === id ? `2px solid ${T.ink}` : "2px solid transparent",
fontFamily: F.sans, fontSize: 13.5, cursor: "pointer",
color: tab === id ? T.ink : T.muted, fontWeight: tab === id ? 600 : 400,
}}>{label}</button>
))}
</div>
{tab === "billing" && <>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 22 }}>
<div>
<h2 style={{ fontFamily: F.serif, fontSize: 22, fontWeight: 700, color: T.ink, marginBottom: 4 }}>Client billing</h2>
<p style={{ fontFamily: F.sans, fontSize: 13, color: T.muted }}>All costs tracked and ready to invoice</p>
</div>
<InkBtn>Generate invoice</InkBtn>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr 1fr", gap: 10, marginBottom: 24 }}>
{[
{ label: "Total unbilled", value: `$${unbilled.toFixed(2)}` },
{ label: "LLM costs", value: "$38.40" },
{ label: "Compute", value: "$14.80" },
{ label: "Other", value: "$7.40" },
].map(c => (
<div key={c.label} style={{ background: T.cream, borderRadius: 10, padding: "14px 16px" }}>
<div style={{ fontFamily: F.sans, fontSize: 11, color: T.muted, marginBottom: 5 }}>{c.label}</div>
<div style={{ fontFamily: F.serif, fontSize: 22, fontWeight: 700, color: T.ink }}>{c.value}</div>
</div>
))}
</div>
<div style={{ background: T.white, border: `1px solid ${T.border}`, borderRadius: 12, overflow: "hidden" }}>
<div style={{ padding: "13px 20px", borderBottom: `1px solid ${T.border}`, display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<span style={{ fontFamily: F.serif, fontSize: 14, fontWeight: 600, color: T.ink }}>Breakdown by client</span>
<select style={{ border: `1px solid ${T.border}`, borderRadius: 6, padding: "4px 10px", fontFamily: F.sans, fontSize: 12, color: T.muted, background: T.paper, outline: "none" }}>
<option>March 2026</option>
</select>
</div>
<div style={{ display: "grid", gridTemplateColumns: "2fr 1fr 1fr 1fr 1fr 120px", padding: "9px 20px", background: T.cream, borderBottom: `1px solid ${T.border}` }}>
{["Project / Client", "LLM", "Compute", "Other", "Total", "Status"].map(h => (
<div key={h} style={{ fontFamily: F.sans, fontSize: 10.5, fontWeight: 600, color: T.muted, textTransform: "uppercase", letterSpacing: "0.05em" }}>{h}</div>
))}
</div>
{BILLING_ROWS.map((r, i) => (
<div key={i} style={{ display: "grid", gridTemplateColumns: "2fr 1fr 1fr 1fr 1fr 120px", padding: "13px 20px", borderBottom: i < BILLING_ROWS.length - 1 ? `1px solid ${T.border}` : "none", alignItems: "center", opacity: r.billed ? 0.5 : 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ width: 24, height: 24, background: T.ink, borderRadius: 6, display: "flex", alignItems: "center", justifyContent: "center", fontFamily: F.serif, fontSize: 10, color: T.paper, fontWeight: 700 }}>{r.initial}</div>
<div>
<div style={{ fontFamily: F.sans, fontSize: 13, fontWeight: 600, color: T.ink }}>{r.label}</div>
<div style={{ fontFamily: F.sans, fontSize: 11, color: T.muted }}>{r.client}</div>
</div>
</div>
<div style={{ fontFamily: F.sans, fontSize: 13, color: T.ink }}>${r.llm.toFixed(2)}</div>
<div style={{ fontFamily: F.sans, fontSize: 13, color: T.ink }}>${r.compute.toFixed(2)}</div>
<div style={{ fontFamily: F.sans, fontSize: 13, color: T.ink }}>${r.other.toFixed(2)}</div>
<div style={{ fontFamily: F.sans, fontSize: 13, fontWeight: 600, color: T.ink }}>${r.total.toFixed(2)}</div>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<StatusPill label={r.billed ? "Invoiced" : "Unbilled"} variant={r.billed ? "invoiced" : "unbilled"} />
{!r.billed && (
<button style={{ background: "transparent", border: `1px solid ${T.border2}`, borderRadius: 5, padding: "3px 9px", fontFamily: F.sans, fontSize: 11, color: T.muted, cursor: "pointer" }}>Invoice</button>
)}
</div>
</div>
))}
</div>
</>}
{tab === "costs" && <>
<div style={{ marginBottom: 22 }}>
<h2 style={{ fontFamily: F.serif, fontSize: 22, fontWeight: 700, color: T.ink, marginBottom: 4 }}>Cost tracker</h2>
<p style={{ fontFamily: F.sans, fontSize: 13, color: T.muted }}>Every dollar spent, broken down by type and project</p>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14, marginBottom: 20 }}>
<div style={{ background: T.white, border: `1px solid ${T.border}`, borderRadius: 12, padding: "18px 20px" }}>
<div style={{ fontFamily: F.serif, fontSize: 14, fontWeight: 600, color: T.ink, marginBottom: 16 }}>LLM usage</div>
{[
{ label: "Code generation", amount: 21.40, pct: 56 },
{ label: "Content & marketing", amount: 10.20, pct: 27 },
{ label: "Chat assist", amount: 6.80, pct: 18 },
].map(r => (
<div key={r.label} style={{ marginBottom: 14 }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 6 }}>
<span style={{ fontFamily: F.sans, fontSize: 12.5, color: T.mid }}>{r.label}</span>
<span style={{ fontFamily: F.sans, fontSize: 12.5, fontWeight: 600, color: T.ink }}>${r.amount.toFixed(2)}</span>
</div>
<div style={{ height: 4, background: T.cream, borderRadius: 2, overflow: "hidden" }}>
<div style={{ width: `${r.pct}%`, height: "100%", background: T.ink, borderRadius: 2 }} />
</div>
</div>
))}
<div style={{ marginTop: 14, paddingTop: 12, borderTop: `1px solid ${T.border}`, display: "flex", justifyContent: "space-between" }}>
<span style={{ fontFamily: F.sans, fontSize: 12, color: T.muted }}>Total LLM</span>
<span style={{ fontFamily: F.serif, fontSize: 15, fontWeight: 700, color: T.ink }}>$38.40</span>
</div>
</div>
<div style={{ background: T.white, border: `1px solid ${T.border}`, borderRadius: 12, padding: "18px 20px" }}>
<div style={{ fontFamily: F.serif, fontSize: 14, fontWeight: 600, color: T.ink, marginBottom: 16 }}>Infrastructure</div>
{[
{ label: "Hosting & compute", amount: 11.60 },
{ label: "Database", amount: 3.20 },
{ label: "Email delivery", amount: 4.20 },
{ label: "Domain & SSL", amount: 3.20 },
].map(r => (
<div key={r.label} style={{ display: "flex", justifyContent: "space-between", padding: "8px 11px", background: T.cream, borderRadius: 7, marginBottom: 7 }}>
<span style={{ fontFamily: F.sans, fontSize: 13, color: T.mid }}>{r.label}</span>
<span style={{ fontFamily: F.sans, fontSize: 13, fontWeight: 600, color: T.ink }}>${r.amount.toFixed(2)}</span>
</div>
))}
<div style={{ marginTop: 14, paddingTop: 12, borderTop: `1px solid ${T.border}`, display: "flex", justifyContent: "space-between" }}>
<span style={{ fontFamily: F.sans, fontSize: 12, color: T.muted }}>Total infra</span>
<span style={{ fontFamily: F.serif, fontSize: 15, fontWeight: 700, color: T.ink }}>$22.20</span>
</div>
</div>
</div>
<div style={{ background: T.white, border: `1px solid ${T.border}`, borderRadius: 12, overflow: "hidden" }}>
<div style={{ padding: "13px 20px", borderBottom: `1px solid ${T.border}` }}>
<span style={{ fontFamily: F.serif, fontSize: 14, fontWeight: 600, color: T.ink }}>Recent charges</span>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 2fr 1fr 80px", padding: "9px 20px", background: T.cream, borderBottom: `1px solid ${T.border}` }}>
{["Time", "Description", "Project", "Cost"].map(h => (
<div key={h} style={{ fontFamily: F.sans, fontSize: 10.5, fontWeight: 600, color: T.muted, textTransform: "uppercase", letterSpacing: "0.05em" }}>{h}</div>
))}
</div>
{COST_LOG.map((row, i) => (
<div key={i} style={{ display: "grid", gridTemplateColumns: "1fr 2fr 1fr 80px", padding: "11px 20px", borderBottom: i < COST_LOG.length - 1 ? `1px solid ${T.border}` : "none", alignItems: "center" }}>
<div style={{ fontFamily: F.sans, fontSize: 12, color: T.muted }}>{row.time}</div>
<div style={{ fontFamily: F.sans, fontSize: 13, color: T.ink }}>{row.desc}</div>
<div style={{ fontFamily: F.sans, fontSize: 12, color: T.mid }}>{row.project}</div>
<div style={{ fontFamily: F.sans, fontSize: 13, fontWeight: 600, color: T.ink }}>${row.cost.toFixed(2)}</div>
</div>
))}
</div>
</>}
</div>
);
}
// ─── Root ──────────────────────────────────────────────────────────────────────
export default function Dashboard() {
const [screen, setScreen] = useState("projects");
return (
<div style={{ background: T.paper, minHeight: "100vh" }}>
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;0,600;0,700;1,400;1,600&family=Inter:wght@400;500;600&display=swap');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
button { font-family: inherit; cursor: pointer; }
input, select { font-family: inherit; }
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-thumb { background: ${T.parch}; border-radius: 4px; }
`}</style>
<Nav screen={screen} setScreen={setScreen} />
{screen === "projects" && <ProjectsScreen setScreen={setScreen} />}
{screen === "billing" && <BillingScreen />}
</div>
);
}

288
justine/vibn-website.jsx Normal file
View File

@@ -0,0 +1,288 @@
// vibn — Marketing Website
// Design: Ink & parchment — Lora serif + Inter sans, no colour accent
// Usage: <Website onGetStarted={fn} onLogin={fn} />
import { useState } from "react";
const T = {
ink: "#1a1510",
ink2: "#2c2c2a",
ink3: "#444441",
mid: "#5f5e5a",
muted: "#888780",
stone: "#b4b2a9",
parch: "#d3d1c7",
cream: "#f1efe8",
paper: "#f7f4ee",
white: "#fdfcfa",
border: "#e8e2d9",
};
const F = { serif: "'Lora', Georgia, serif", sans: "'Inter', sans-serif" };
// ─── Primitives ────────────────────────────────────────────────────────────────
function Eyebrow({ children }) {
return (
<div style={{ fontFamily: F.sans, fontSize: 11, fontWeight: 600, letterSpacing: "0.13em", textTransform: "uppercase", color: T.muted, marginBottom: 16 }}>
{children}
</div>
);
}
function PrimaryBtn({ children, onClick, large }) {
return (
<button onClick={onClick} style={{
background: T.ink, color: T.paper, border: "none",
fontFamily: F.sans, fontWeight: 600,
fontSize: large ? 15 : 14,
padding: large ? "14px 34px" : "10px 24px",
borderRadius: 10, cursor: "pointer",
}}>{children}</button>
);
}
// ─── Nav ───────────────────────────────────────────────────────────────────────
function Nav({ onGetStarted, onLogin }) {
return (
<nav style={{
background: T.paper, borderBottom: `1px solid ${T.border}`,
padding: "0 48px", height: 60,
display: "flex", alignItems: "center", justifyContent: "space-between",
position: "sticky", top: 0, zIndex: 50,
}}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ width: 30, height: 30, background: T.ink, borderRadius: 7, display: "flex", alignItems: "center", justifyContent: "center" }}>
<span style={{ fontFamily: F.serif, fontSize: 15, fontWeight: 700, color: T.paper }}>V</span>
</div>
<span style={{ fontFamily: F.serif, fontSize: 19, fontWeight: 700, color: T.ink, letterSpacing: "-0.02em" }}>vibn</span>
</div>
<div style={{ display: "flex", gap: 32 }}>
{["Product", "Pricing", "Stories", "Blog"].map(l => (
<span key={l} style={{ fontFamily: F.sans, fontSize: 14, color: T.muted, cursor: "pointer" }}>{l}</span>
))}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<span onClick={onLogin} style={{ fontFamily: F.sans, fontSize: 14, color: T.muted, cursor: "pointer" }}>Log in</span>
<PrimaryBtn onClick={onGetStarted}>Get started free</PrimaryBtn>
</div>
</nav>
);
}
// ─── Hero ──────────────────────────────────────────────────────────────────────
function Hero({ onCta }) {
return (
<section style={{ maxWidth: 960, margin: "0 auto", padding: "88px 48px 72px" }}>
<Eyebrow>For non-technical founders</Eyebrow>
<h1 style={{
fontFamily: F.serif, fontSize: 64, fontWeight: 700, color: T.ink,
letterSpacing: "-0.03em", lineHeight: 1.07, marginBottom: 28, maxWidth: 680,
}}>
You have the idea.<br />
We handle<br />
<em style={{ fontStyle: "italic", color: T.ink3 }}>everything else.</em>
</h1>
<p style={{ fontFamily: F.sans, fontSize: 17.5, color: T.mid, lineHeight: 1.75, maxWidth: 480, marginBottom: 40 }}>
No backend. No DevOps. No marketing agency. Describe your idea and vibn
builds, deploys, and promotes it automatically.
</p>
<div style={{ display: "flex", alignItems: "center", gap: 18, marginBottom: 14 }}>
<PrimaryBtn onClick={onCta} large>Start free no code needed</PrimaryBtn>
<span style={{ fontFamily: F.sans, fontSize: 13.5, color: T.stone }}>&nbsp;&nbsp;280 founders launched</span>
</div>
<p style={{ fontFamily: F.sans, fontSize: 12, color: T.stone }}>No credit card required · Free forever plan</p>
</section>
);
}
// ─── Quote band ────────────────────────────────────────────────────────────────
const QUOTES = [
{ q: "I had the idea for 2 years. The backend terrified me. vibn shipped it in 4 days and handles all my marketing.", by: "Alex K.", role: "Founder, Taskly" },
{ q: "I have zero coding experience. Three weeks in I have 300 paying users. That's entirely because of vibn.", by: "Marcus L.", role: "Founder, Flowmatic" },
{ q: "The marketing autopilot alone saved me ten hours a week. My blog runs itself. I just focus on my product.", by: "Sara R.", role: "Founder, Nudge" },
];
function QuoteBand() {
return (
<section style={{ background: T.ink, padding: "52px 48px" }}>
<div style={{ maxWidth: 960, margin: "0 auto", display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 40 }}>
{QUOTES.map((q, i) => (
<div key={i} style={{ display: "flex", gap: 20 }}>
<div style={{ width: 3, background: T.mid, borderRadius: 2, flexShrink: 0 }} />
<div>
<p style={{ fontFamily: F.serif, fontSize: 15, color: T.parch, lineHeight: 1.72, fontStyle: "italic", marginBottom: 12 }}>"{q.q}"</p>
<span style={{ fontFamily: F.sans, fontSize: 11.5, color: T.muted, fontWeight: 600 }}> {q.by}, {q.role}</span>
</div>
</div>
))}
</div>
</section>
);
}
// ─── How it works ──────────────────────────────────────────────────────────────
const PHASES = [
{ n: "01", id: "Discover", title: "Define your idea", body: "Six guided questions turn a rough idea into a full product plan — pages, architecture, revenue model. No jargon." },
{ n: "02", id: "Design", title: "Choose your style", body: "Pick a visual style and see your exact site and emails live before a single line of code is written." },
{ n: "03", id: "Build", title: "Your app, live", body: "AI writes every line. Auth, database, payments, all pages — deployed and live. Describe changes in plain English." },
{ n: "04", id: "Grow", title: "Market & automate", body: "AI generates your blog, emails, and social schedule — publishing on autopilot so you can focus on your users." },
];
function HowItWorks() {
return (
<section style={{ maxWidth: 960, margin: "0 auto", padding: "80px 48px" }}>
<Eyebrow>How it works</Eyebrow>
<h2 style={{ fontFamily: F.serif, fontSize: 40, fontWeight: 700, color: T.ink, letterSpacing: "-0.02em", lineHeight: 1.15, marginBottom: 52, maxWidth: 460 }}>
Four phases.<br />One complete product.
</h2>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", border: `1px solid ${T.border}`, borderRadius: 14, overflow: "hidden" }}>
{PHASES.map((p, i) => (
<div key={i} style={{
padding: "38px 42px", background: T.white,
borderRight: (i % 2 === 0) ? `1px solid ${T.border}` : "none",
borderBottom: (i < 2) ? `1px solid ${T.border}` : "none",
}}>
<div style={{ fontFamily: F.sans, fontSize: 11, fontWeight: 600, letterSpacing: "0.1em", textTransform: "uppercase", color: T.muted, marginBottom: 14 }}>
{p.n} {p.id}
</div>
<div style={{ fontFamily: F.serif, fontSize: 21, fontWeight: 700, color: T.ink, marginBottom: 10 }}>{p.title}</div>
<p style={{ fontFamily: F.sans, fontSize: 13.5, color: T.mid, lineHeight: 1.7 }}>{p.body}</p>
</div>
))}
</div>
</section>
);
}
// ─── Stats bar ─────────────────────────────────────────────────────────────────
const STATS = [
{ n: "280+", label: "founders launched" },
{ n: "72h", label: "average time to first version" },
{ n: "4.9★", label: "average rating" },
{ n: "3×", label: "faster than hiring a developer" },
];
function StatsBar() {
return (
<section style={{ background: T.white, borderTop: `1px solid ${T.border}`, borderBottom: `1px solid ${T.border}` }}>
<div style={{ maxWidth: 960, margin: "0 auto", padding: "0 48px", display: "grid", gridTemplateColumns: "1fr 1fr 1fr 1fr" }}>
{STATS.map((s, i) => (
<div key={i} style={{ padding: "38px 0", paddingLeft: i > 0 ? 36 : 0, borderRight: i < 3 ? `1px solid ${T.border}` : "none" }}>
<div style={{ fontFamily: F.serif, fontSize: 38, fontWeight: 700, color: T.ink, letterSpacing: "-0.03em", marginBottom: 6 }}>{s.n}</div>
<div style={{ fontFamily: F.sans, fontSize: 13, color: T.muted }}>{s.label}</div>
</div>
))}
</div>
</section>
);
}
// ─── Empathy section ───────────────────────────────────────────────────────────
const PAINS = [
{ title: "No more \"I need to hire a developer first\"", body: "vibn is your developer. Start building the moment you have an idea." },
{ title: "No more staring at a blank marketing calendar", body: "AI generates and publishes your content every single week." },
{ title: "No more \"I'll launch when it's ready\"", body: "Most founders ship their first version in under 72 hours." },
];
function EmpathySection() {
return (
<section style={{ background: T.cream, borderTop: `1px solid ${T.border}`, borderBottom: `1px solid ${T.border}`, padding: "76px 48px" }}>
<div style={{ maxWidth: 960, margin: "0 auto", display: "grid", gridTemplateColumns: "1fr 1fr", gap: 68, alignItems: "center" }}>
<div>
<Eyebrow>Sound familiar?</Eyebrow>
<h2 style={{ fontFamily: F.serif, fontSize: 36, fontWeight: 700, color: T.ink, lineHeight: 1.18, marginBottom: 24, letterSpacing: "-0.02em" }}>
The idea is the hard part. Everything else shouldn't be.
</h2>
<p style={{ fontFamily: F.sans, fontSize: 15, color: T.mid, lineHeight: 1.8, marginBottom: 20 }}>
You know exactly what you want to build and who it's for. But the moment you think
about servers, databases, deployment pipelines, SEO strategies the whole thing stalls.
</p>
<p style={{ fontFamily: F.sans, fontSize: 15, color: T.mid, lineHeight: 1.8 }}>
vibn exists to remove all of that. Not abstract it {" "}
<em style={{ fontFamily: F.serif, fontStyle: "italic" }}>remove it entirely.</em>
</p>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
{PAINS.map((p, i) => (
<div key={i} style={{ background: T.white, border: `1px solid ${T.border}`, borderRadius: 12, padding: "18px 20px", display: "flex", gap: 14, alignItems: "flex-start" }}>
<div style={{ width: 20, height: 20, borderRadius: "50%", border: `1.5px solid ${T.stone}`, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0, marginTop: 2 }}>
<div style={{ width: 7, height: 7, borderRadius: "50%", background: T.ink }} />
</div>
<div>
<div style={{ fontFamily: F.serif, fontSize: 14, fontWeight: 600, color: T.ink, marginBottom: 4 }}>{p.title}</div>
<div style={{ fontFamily: F.sans, fontSize: 13, color: T.muted, lineHeight: 1.6 }}>{p.body}</div>
</div>
</div>
))}
</div>
</div>
</section>
);
}
// ─── Final CTA ─────────────────────────────────────────────────────────────────
function FinalCta({ onCta }) {
return (
<section style={{ maxWidth: 660, margin: "0 auto", padding: "92px 48px", textAlign: "center" }}>
<h2 style={{ fontFamily: F.serif, fontSize: 46, fontWeight: 700, color: T.ink, letterSpacing: "-0.03em", lineHeight: 1.1, marginBottom: 20 }}>
Your idea deserves to exist.
</h2>
<p style={{ fontFamily: F.sans, fontSize: 16, color: T.mid, lineHeight: 1.75, marginBottom: 36 }}>
Don't let the backend be the reason it doesn't. Start today free, no code, no credit card.
</p>
<PrimaryBtn onClick={onCta} large>Build my product free</PrimaryBtn>
<div style={{ fontFamily: F.sans, fontSize: 12.5, color: T.stone, marginTop: 16 }}>
Joins 280+ non-technical founders already live
</div>
</section>
);
}
// ─── Footer ────────────────────────────────────────────────────────────────────
function Footer() {
return (
<footer style={{ borderTop: `1px solid ${T.border}`, padding: "32px 48px", display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<span style={{ fontFamily: F.serif, fontSize: 16, fontWeight: 700, color: T.ink }}>vibn</span>
<div style={{ display: "flex", gap: 28 }}>
{["Product", "Pricing", "Stories", "Blog", "Privacy", "Terms"].map(l => (
<span key={l} style={{ fontFamily: F.sans, fontSize: 13, color: T.stone, cursor: "pointer" }}>{l}</span>
))}
</div>
<span style={{ fontFamily: F.sans, fontSize: 12.5, color: T.stone }}>© 2026 vibn</span>
</footer>
);
}
// ─── Root export ───────────────────────────────────────────────────────────────
export default function Website({ onGetStarted = () => {}, onLogin = () => {} }) {
return (
<div style={{ background: T.paper, color: T.ink, minHeight: "100vh" }}>
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;0,600;0,700;1,400;1,600&family=Inter:wght@400;500;600&display=swap');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', sans-serif; }
button { font-family: inherit; }
`}</style>
<Nav onGetStarted={onGetStarted} onLogin={onLogin} />
<Hero onCta={onGetStarted} />
<QuoteBand />
<HowItWorks />
<StatsBar />
<EmpathySection />
<FinalCta onCta={onGetStarted} />
<Footer />
</div>
);
}

11
master-ai.code-workspace Normal file
View File

@@ -0,0 +1,11 @@
{
"folders": [
{
"path": "."
},
{
"path": "../Downloads/vibn-screens"
}
],
"settings": {}
}

View File

@@ -1,30 +0,0 @@
{
"name": "@productos/control-plane",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"lint": "eslint ."
},
"dependencies": {
"@google-cloud/firestore": "^7.11.0",
"@google-cloud/storage": "^7.14.0",
"@fastify/cors": "^10.0.0",
"@fastify/helmet": "^13.0.0",
"@fastify/rate-limit": "^10.0.0",
"@fastify/sensible": "^6.0.0",
"fastify": "^5.0.0",
"zod": "^3.23.8",
"nanoid": "^5.0.7"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.19.0",
"typescript": "^5.5.4",
"eslint": "^9.8.0"
}
}

View File

@@ -1,11 +0,0 @@
import { FastifyRequest } from "fastify";
import { config } from "./config.js";
/**
* V1: dev mode = trust caller.
* V2: validate Google OAuth/IAP identity token.
*/
export async function requireAuth(req: FastifyRequest) {
if (config.authMode === "dev") return;
throw new Error("AUTH_MODE oauth not yet implemented");
}

View File

@@ -1,21 +0,0 @@
export const config = {
port: Number(process.env.PORT ?? 8080),
projectId: process.env.GCP_PROJECT_ID ?? "productos-local",
artifactsBucket: process.env.GCS_BUCKET_ARTIFACTS ?? "productos-artifacts-local",
runsCollection: process.env.FIRESTORE_COLLECTION_RUNS ?? "runs",
toolsCollection: process.env.FIRESTORE_COLLECTION_TOOLS ?? "tools",
authMode: process.env.AUTH_MODE ?? "dev",
// Use in-memory storage when STORAGE_MODE=memory or when no GCP project is configured
storageMode: process.env.STORAGE_MODE ?? (process.env.GCP_PROJECT_ID ? "gcp" : "memory"),
// Gitea
giteaUrl: process.env.GITEA_URL ?? "https://git.vibnai.com",
giteaToken: process.env.GITEA_TOKEN ?? "",
// Coolify
coolifyUrl: process.env.COOLIFY_URL ?? "http://localhost:8000",
coolifyToken: process.env.COOLIFY_TOKEN ?? "",
// Platform webhook base (used when registering Gitea webhooks)
platformUrl: process.env.PLATFORM_URL ?? "http://localhost:8080",
};

View File

@@ -1,118 +0,0 @@
/**
* Coolify API integration
*
* Handles project creation, per-app service provisioning, and deployment
* triggering. Each app in a user's Turborepo monorepo gets its own
* Coolify service with the correct Turbo build filter set.
*/
import { config } from "./config.js";
const DEFAULT_SERVER_UUID = process.env.COOLIFY_SERVER_UUID ?? "0";
async function coolifyFetch(path: string, options: RequestInit = {}): Promise<Response> {
const url = `${config.coolifyUrl}/api/v1${path}`;
const res = await fetch(url, {
...options,
headers: {
"Authorization": `Bearer ${config.coolifyToken}`,
"Content-Type": "application/json",
...options.headers,
},
});
return res;
}
export async function createProject(name: string, description: string): Promise<string> {
const res = await coolifyFetch("/projects", {
method: "POST",
body: JSON.stringify({ name, description }),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Failed to create Coolify project: ${res.status} ${body}`);
}
const data = await res.json() as { uuid: string };
return data.uuid;
}
type CreateServiceOptions = {
coolifyProjectUuid: string;
appName: string;
repoUrl: string;
repoBranch?: string;
domain: string;
};
/**
* Create a Coolify application service for one app within the monorepo.
* The build command uses turbo --filter so only the relevant app builds.
*/
export async function createAppService(opts: CreateServiceOptions): Promise<string> {
const {
coolifyProjectUuid,
appName,
repoUrl,
repoBranch = "main",
domain,
} = opts;
const res = await coolifyFetch("/applications/public", {
method: "POST",
body: JSON.stringify({
project_uuid: coolifyProjectUuid,
server_uuid: DEFAULT_SERVER_UUID,
name: appName,
git_repository: repoUrl,
git_branch: repoBranch,
build_command: `pnpm install && turbo run build --filter=${appName}`,
start_command: `turbo run start --filter=${appName}`,
publish_directory: `apps/${appName}/.next`,
fqdn: `https://${domain}`,
environment_variables: [],
}),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Failed to create Coolify service for ${appName}: ${res.status} ${body}`);
}
const data = await res.json() as { uuid: string };
return data.uuid;
}
export async function triggerDeploy(serviceUuid: string): Promise<string> {
const res = await coolifyFetch(`/applications/${serviceUuid}/deploy`, {
method: "POST",
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Failed to trigger deploy for ${serviceUuid}: ${res.status} ${body}`);
}
const data = await res.json() as { deployment_uuid: string };
return data.deployment_uuid;
}
export async function getDeploymentStatus(deploymentUuid: string): Promise<string> {
const res = await coolifyFetch(`/deployments/${deploymentUuid}`);
if (!res.ok) return "unknown";
const data = await res.json() as { status: string };
return data.status;
}
export async function setEnvVars(
serviceUuid: string,
vars: Record<string, string>
): Promise<void> {
for (const [key, value] of Object.entries(vars)) {
await coolifyFetch(`/applications/${serviceUuid}/envs`, {
method: "POST",
body: JSON.stringify({ key, value, is_preview: false }),
});
}
}

View File

@@ -1,410 +0,0 @@
/**
* Gemini Integration for Product OS
*
* Supports:
* - Chat completions with streaming
* - Tool/function calling
* - Context-aware responses
*
* Set GOOGLE_CLOUD_PROJECT and optionally GEMINI_MODEL env vars.
* For local dev without Vertex AI, set GEMINI_API_KEY for AI Studio.
*/
import { config } from "./config.js";
export interface ChatMessage {
role: "user" | "assistant" | "system";
content: string;
}
export interface ToolCall {
name: string;
arguments: Record<string, any>;
}
export interface ChatResponse {
message: string;
toolCalls?: ToolCall[];
finishReason: "stop" | "tool_calls" | "error";
}
// Tool definitions that Gemini can call
export const PRODUCT_OS_TOOLS = [
{
name: "deploy_service",
description: "Deploy a Cloud Run service. Use when user wants to deploy, ship, or launch code.",
parameters: {
type: "object",
properties: {
service_name: { type: "string", description: "Name of the service to deploy" },
repo: { type: "string", description: "Git repository URL" },
ref: { type: "string", description: "Git branch, tag, or commit" },
env: { type: "string", enum: ["dev", "staging", "prod"], description: "Target environment" }
},
required: ["service_name"]
}
},
{
name: "get_funnel_analytics",
description: "Get funnel conversion metrics. Use when user asks about funnels, conversions, or drop-offs.",
parameters: {
type: "object",
properties: {
range_days: { type: "integer", description: "Number of days to analyze", default: 30 }
}
}
},
{
name: "get_top_drivers",
description: "Identify top factors driving a metric. Use when user asks why something changed or what drives conversions.",
parameters: {
type: "object",
properties: {
metric: { type: "string", description: "The metric to analyze (e.g., 'conversion', 'retention')" },
range_days: { type: "integer", description: "Number of days to analyze", default: 30 }
},
required: ["metric"]
}
},
{
name: "generate_marketing_posts",
description: "Generate social media posts for a campaign. Use when user wants to create marketing content.",
parameters: {
type: "object",
properties: {
goal: { type: "string", description: "Campaign goal (e.g., 'launch announcement')" },
product: { type: "string", description: "Product or feature name" },
channels: {
type: "array",
items: { type: "string" },
description: "Social channels (e.g., ['x', 'linkedin'])"
}
},
required: ["goal"]
}
},
{
name: "get_service_status",
description: "Check the status of a deployed service. Use when user asks about service health or deployment status.",
parameters: {
type: "object",
properties: {
service_name: { type: "string", description: "Name of the service" },
region: { type: "string", description: "GCP region", default: "us-central1" }
},
required: ["service_name"]
}
},
{
name: "generate_code",
description: "Generate or modify code. Use when user asks to write, fix, refactor, or change code.",
parameters: {
type: "object",
properties: {
task: { type: "string", description: "What code change to make" },
file_path: { type: "string", description: "Target file path (if known)" },
language: { type: "string", description: "Programming language" },
context: { type: "string", description: "Additional context about the codebase" }
},
required: ["task"]
}
},
{
name: "deploy_app",
description: "Deploy a specific app from the project monorepo. Use when user wants to deploy or ship one of their apps (product, website, admin, storybook).",
parameters: {
type: "object",
properties: {
project_id: { type: "string", description: "The project ID" },
app_name: {
type: "string",
enum: ["product", "website", "admin", "storybook"],
description: "Which app to deploy"
},
env: {
type: "string",
enum: ["dev", "staging", "prod"],
description: "Target environment"
}
},
required: ["project_id", "app_name"]
}
},
{
name: "scaffold_app",
description: "Add a new app to the project monorepo. Use when user wants to add a new application beyond the defaults.",
parameters: {
type: "object",
properties: {
project_id: { type: "string", description: "The project ID" },
app_name: { type: "string", description: "Name for the new app (e.g. 'mobile', 'api', 'dashboard')" },
framework: {
type: "string",
enum: ["nextjs", "astro", "express", "fastify"],
description: "Framework to scaffold"
}
},
required: ["project_id", "app_name"]
}
}
];
// System prompt for Product OS assistant
const SYSTEM_PROMPT = `You are the AI for a software platform where every project is a Turborepo monorepo containing multiple apps: product, website, admin, and storybook. You have full visibility and control over the entire project.
Each project has:
- apps/product — the core user-facing application
- apps/website — the marketing and landing site
- apps/admin — internal admin tooling
- apps/storybook — component browser and design system
- packages/ui — shared React component library
- packages/tokens — shared design tokens (colors, spacing, typography)
- packages/types — shared TypeScript types
- packages/config — shared eslint and tsconfig
You can help with:
- Deploying any app using turbo run build --filter=<app>
- Writing and modifying code across any app or package in the monorepo
- Adding new apps or packages to the project
- Analyzing product metrics and funnels
- Generating marketing content
- Understanding what drives user behavior
When a user says "deploy" without specifying an app, ask which one or default to "product".
When a user asks to change something visual, consider whether it belongs in packages/ui or packages/tokens.
When users ask you to do something, use the available tools to take action. Be concise and specific about which app or package you are working in.`;
/**
* Chat with Gemini
* Uses Vertex AI in production, or AI Studio API key for local dev
*/
export async function chat(
messages: ChatMessage[],
options: { stream?: boolean } = {}
): Promise<ChatResponse> {
const apiKey = process.env.GEMINI_API_KEY;
const projectId = config.projectId;
const model = process.env.GEMINI_MODEL ?? "gemini-1.5-flash";
// Build the request
const contents = [
{ role: "user", parts: [{ text: SYSTEM_PROMPT }] },
{ role: "model", parts: [{ text: "Understood. I'm Product OS, ready to help you build and operate your SaaS product. How can I help?" }] },
...messages.map(m => ({
role: m.role === "assistant" ? "model" : "user",
parts: [{ text: m.content }]
}))
];
const tools = [{
functionDeclarations: PRODUCT_OS_TOOLS
}];
// Use AI Studio API if API key is set (local dev)
if (apiKey) {
return chatWithAIStudio(apiKey, model, contents, tools);
}
// Use Vertex AI if project is set (production)
if (projectId && projectId !== "productos-local") {
return chatWithVertexAI(projectId, model, contents, tools);
}
// Mock response for local dev without credentials
return mockChat(messages);
}
async function chatWithAIStudio(
apiKey: string,
model: string,
contents: any[],
tools: any[]
): Promise<ChatResponse> {
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`;
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents,
tools,
generationConfig: {
temperature: 0.7,
maxOutputTokens: 2048
}
})
});
if (!response.ok) {
const error = await response.text();
console.error("Gemini API error:", error);
throw new Error(`Gemini API error: ${response.status}`);
}
const data = await response.json();
return parseGeminiResponse(data);
}
async function chatWithVertexAI(
projectId: string,
model: string,
contents: any[],
tools: any[]
): Promise<ChatResponse> {
// Vertex AI endpoint
const location = process.env.VERTEX_LOCATION ?? "us-central1";
const url = `https://${location}-aiplatform.googleapis.com/v1/projects/${projectId}/locations/${location}/publishers/google/models/${model}:generateContent`;
// Get access token (requires gcloud auth)
const { GoogleAuth } = await import("google-auth-library");
const auth = new GoogleAuth({ scopes: ["https://www.googleapis.com/auth/cloud-platform"] });
const client = await auth.getClient();
const token = await client.getAccessToken();
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token.token}`
},
body: JSON.stringify({
contents,
tools,
generationConfig: {
temperature: 0.7,
maxOutputTokens: 2048
}
})
});
if (!response.ok) {
const error = await response.text();
console.error("Vertex AI error:", error);
throw new Error(`Vertex AI error: ${response.status}`);
}
const data = await response.json();
return parseGeminiResponse(data);
}
function parseGeminiResponse(data: any): ChatResponse {
const candidate = data.candidates?.[0];
if (!candidate) {
return { message: "No response from Gemini", finishReason: "error" };
}
const content = candidate.content;
const parts = content?.parts ?? [];
// Check for function calls
const functionCalls = parts.filter((p: any) => p.functionCall);
if (functionCalls.length > 0) {
const toolCalls = functionCalls.map((p: any) => ({
name: p.functionCall.name,
arguments: p.functionCall.args ?? {}
}));
return {
message: "",
toolCalls,
finishReason: "tool_calls"
};
}
// Regular text response
const text = parts.map((p: any) => p.text ?? "").join("");
return {
message: text,
finishReason: "stop"
};
}
/**
* Mock chat for local development without Gemini credentials
*/
function mockChat(messages: ChatMessage[]): ChatResponse {
const lastMessage = messages[messages.length - 1]?.content.toLowerCase() ?? "";
// Check marketing/campaign FIRST (before deploy) since "launch" can be ambiguous
if (lastMessage.includes("marketing") || lastMessage.includes("campaign") || lastMessage.includes("post") ||
(lastMessage.includes("launch") && !lastMessage.includes("deploy"))) {
return {
message: "",
toolCalls: [{
name: "generate_marketing_posts",
arguments: { goal: "product launch", channels: ["x", "linkedin"] }
}],
finishReason: "tool_calls"
};
}
// Simple keyword matching to simulate tool calls
if (lastMessage.includes("deploy") || lastMessage.includes("ship") || lastMessage.includes("staging") || lastMessage.includes("production")) {
return {
message: "",
toolCalls: [{
name: "deploy_service",
arguments: { service_name: "my-service", env: lastMessage.includes("staging") ? "staging" : lastMessage.includes("prod") ? "prod" : "dev" }
}],
finishReason: "tool_calls"
};
}
if (lastMessage.includes("funnel") || lastMessage.includes("conversion") || lastMessage.includes("analytics")) {
return {
message: "",
toolCalls: [{
name: "get_funnel_analytics",
arguments: { range_days: 30 }
}],
finishReason: "tool_calls"
};
}
if (lastMessage.includes("why") || lastMessage.includes("driver") || lastMessage.includes("cause")) {
return {
message: "",
toolCalls: [{
name: "get_top_drivers",
arguments: { metric: "conversion", range_days: 30 }
}],
finishReason: "tool_calls"
};
}
if (lastMessage.includes("status") || lastMessage.includes("health")) {
return {
message: "",
toolCalls: [{
name: "get_service_status",
arguments: { service_name: "my-service" }
}],
finishReason: "tool_calls"
};
}
if (lastMessage.includes("code") || lastMessage.includes("function") || lastMessage.includes("write") || lastMessage.includes("create")) {
return {
message: "",
toolCalls: [{
name: "generate_code",
arguments: { task: lastMessage, language: "typescript" }
}],
finishReason: "tool_calls"
};
}
// Default response
return {
message: `I'm Product OS, your AI assistant for building and operating SaaS products. I can help you:
• **Deploy** services to Cloud Run
• **Analyze** funnel metrics and conversions
• **Generate** marketing content
• **Understand** what drives user behavior
What would you like to do?
_(Note: Running in mock mode - set GEMINI_API_KEY for real AI responses)_`,
finishReason: "stop"
};
}

View File

@@ -1,154 +0,0 @@
/**
* Gitea API integration
*
* Handles repo creation, file scaffolding, and webhook registration
* for user projects. All project repos are created under the user's
* Gitea account and contain the full Turborepo monorepo structure.
*/
import { config } from "./config.js";
import { readdir, readFile } from "node:fs/promises";
import { join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const TEMPLATES_DIR = join(__dirname, "../../../../scripts/templates/turborepo");
type GiteaFile = {
path: string;
content: string;
};
async function giteaFetch(path: string, options: RequestInit = {}): Promise<Response> {
const url = `${config.giteaUrl}/api/v1${path}`;
const res = await fetch(url, {
...options,
headers: {
"Authorization": `token ${config.giteaToken}`,
"Content-Type": "application/json",
...options.headers,
},
});
return res;
}
export async function createRepo(owner: string, repoName: string, description: string): Promise<string> {
const res = await giteaFetch(`/user/repos`, {
method: "POST",
body: JSON.stringify({
name: repoName,
description,
private: false,
auto_init: false,
}),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Failed to create Gitea repo: ${res.status} ${body}`);
}
const data = await res.json() as { clone_url: string };
return data.clone_url;
}
export async function registerWebhook(owner: string, repoName: string, webhookUrl: string): Promise<void> {
const res = await giteaFetch(`/repos/${owner}/${repoName}/hooks`, {
method: "POST",
body: JSON.stringify({
type: "gitea",
active: true,
events: ["push", "pull_request"],
config: {
url: webhookUrl,
content_type: "json",
},
}),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Failed to register webhook: ${res.status} ${body}`);
}
}
/**
* Walk the template directory and collect all files with their content,
* replacing {{project-slug}} and {{project-name}} placeholders.
*/
async function collectTemplateFiles(
projectSlug: string,
projectName: string
): Promise<GiteaFile[]> {
const files: GiteaFile[] = [];
async function walk(dir: string) {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
await walk(fullPath);
} else {
const relPath = relative(TEMPLATES_DIR, fullPath);
let content = await readFile(fullPath, "utf-8");
content = content
.replaceAll("{{project-slug}}", projectSlug)
.replaceAll("{{project-name}}", projectName);
files.push({ path: relPath, content });
}
}
}
await walk(TEMPLATES_DIR);
return files;
}
/**
* Push the full Turborepo scaffold to a Gitea repo as an initial commit.
* Uses Gitea's contents API to create each file individually.
*/
export async function scaffoldRepo(
owner: string,
repoName: string,
projectSlug: string,
projectName: string
): Promise<void> {
const files = await collectTemplateFiles(projectSlug, projectName);
for (const file of files) {
const encoded = Buffer.from(file.content).toString("base64");
const res = await giteaFetch(`/repos/${owner}/${repoName}/contents/${file.path}`, {
method: "POST",
body: JSON.stringify({
message: `chore: scaffold ${file.path}`,
content: encoded,
branch: "main",
}),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Failed to push ${file.path}: ${res.status} ${body}`);
}
}
}
export async function getRepoTree(owner: string, repoName: string, ref = "main"): Promise<string[]> {
const res = await giteaFetch(`/repos/${owner}/${repoName}/git/trees/${ref}?recursive=true`);
if (!res.ok) return [];
const data = await res.json() as { tree: { path: string; type: string }[] };
return data.tree.filter(e => e.type === "blob").map(e => e.path);
}
export async function getFileContent(
owner: string,
repoName: string,
filePath: string,
ref = "main"
): Promise<string | null> {
const res = await giteaFetch(`/repos/${owner}/${repoName}/contents/${filePath}?ref=${ref}`);
if (!res.ok) return null;
const data = await res.json() as { content: string };
return Buffer.from(data.content, "base64").toString("utf-8");
}

View File

@@ -1,31 +0,0 @@
import Fastify from "fastify";
import cors from "@fastify/cors";
import helmet from "@fastify/helmet";
import rateLimit from "@fastify/rate-limit";
import sensible from "@fastify/sensible";
import { config } from "./config.js";
import { healthRoutes } from "./routes/health.js";
import { toolRoutes } from "./routes/tools.js";
import { runRoutes } from "./routes/runs.js";
import { chatRoutes } from "./routes/chat.js";
import { projectRoutes } from "./routes/projects.js";
const app = Fastify({ logger: true });
await app.register(cors, { origin: true });
await app.register(helmet);
await app.register(sensible);
await app.register(rateLimit, { max: 300, timeWindow: "1 minute" });
await app.register(healthRoutes);
await app.register(toolRoutes);
await app.register(runRoutes);
await app.register(chatRoutes);
await app.register(projectRoutes);
app.listen({ port: config.port, host: "0.0.0.0" }).then(() => {
console.log(`🚀 Control Plane API running on http://localhost:${config.port}`);
}).catch((err) => {
app.log.error(err);
process.exit(1);
});

View File

@@ -1,10 +0,0 @@
import type { ToolDef } from "./types.js";
import { listTools } from "./storage/index.js";
/**
* Simple registry. V2: cache + versioning + per-tenant overrides.
*/
export async function getRegistry(): Promise<Record<string, ToolDef>> {
const tools = await listTools();
return Object.fromEntries(tools.map(t => [t.name, t]));
}

View File

@@ -1,331 +0,0 @@
import type { FastifyInstance } from "fastify";
import { requireAuth } from "../auth.js";
import { chat, ChatMessage, ChatResponse, ToolCall } from "../gemini.js";
import { getRegistry } from "../registry.js";
import { saveRun, writeArtifactText, getProject } from "../storage/index.js";
import { nanoid } from "nanoid";
import type { RunRecord } from "../types.js";
interface ChatRequest {
messages: ChatMessage[];
project_id?: string;
context?: {
files?: { path: string; content: string }[];
selection?: { path: string; text: string; startLine: number };
};
autoExecuteTools?: boolean;
}
interface ChatResponseWithRuns extends ChatResponse {
runs?: RunRecord[];
}
export async function chatRoutes(app: FastifyInstance) {
/**
* Chat endpoint - proxies to Gemini with tool calling support
*/
app.post<{ Body: ChatRequest }>("/chat", async (req): Promise<ChatResponseWithRuns> => {
await requireAuth(req);
const { messages, project_id, context, autoExecuteTools = true } = req.body;
let enhancedMessages = [...messages];
// Inject project context so the AI understands the full monorepo structure
if (project_id) {
const project = await getProject(project_id);
if (project) {
const appList = project.apps.map(a => ` - ${a.name} (${a.path})${a.domain ? `${a.domain}` : ""}`).join("\n");
const projectContext = [
`Project: ${project.name} (${project.slug})`,
`Repo: ${project.repo || "provisioning..."}`,
`Status: ${project.status}`,
`Apps in this monorepo:`,
appList,
`Shared packages: ui, tokens, types, config`,
`Build system: Turborepo ${project.turboVersion}`,
`Build command: turbo run build --filter=<app-name>`,
].join("\n");
enhancedMessages = [
{ role: "user" as const, content: `Project context:\n${projectContext}` },
...messages,
];
}
}
// Enhance messages with file/selection context if provided
if (context?.files?.length) {
const fileContext = context.files
.map(f => `File: ${f.path}\n\`\`\`\n${f.content}\n\`\`\``)
.join("\n\n");
enhancedMessages = [
{ role: "user" as const, content: `Context:\n${fileContext}` },
...enhancedMessages,
];
}
if (context?.selection) {
const selectionContext = `Selected code in ${context.selection.path} (line ${context.selection.startLine}):\n\`\`\`\n${context.selection.text}\n\`\`\``;
enhancedMessages = [
{ role: "user" as const, content: selectionContext },
...enhancedMessages,
];
}
// Call Gemini
const response = await chat(enhancedMessages);
// If tool calls and auto-execute is enabled, run them
if (response.toolCalls && response.toolCalls.length > 0 && autoExecuteTools) {
const runs = await executeToolCalls(response.toolCalls, req.body);
// Generate a summary of what was done
const summary = generateToolSummary(response.toolCalls, runs);
return {
message: summary,
toolCalls: response.toolCalls,
runs,
finishReason: "tool_calls"
};
}
return response;
});
/**
* Streaming chat endpoint (SSE)
*/
app.get("/chat/stream", async (req, reply) => {
await requireAuth(req);
reply.raw.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive"
});
// For now, return a message that streaming is not yet implemented
reply.raw.write(`data: ${JSON.stringify({ message: "Streaming not yet implemented", finishReason: "stop" })}\n\n`);
reply.raw.end();
});
}
/**
* Execute tool calls by routing to the appropriate executor
*/
async function executeToolCalls(
toolCalls: ToolCall[],
request: ChatRequest
): Promise<RunRecord[]> {
const runs: RunRecord[] = [];
const registry = await getRegistry();
for (const toolCall of toolCalls) {
// Map tool call names to actual tools
const toolMapping: Record<string, string> = {
"deploy_service": "cloudrun.deploy_service",
"get_funnel_analytics": "analytics.funnel_summary",
"get_top_drivers": "analytics.top_drivers",
"generate_marketing_posts": "marketing.generate_channel_posts",
"get_service_status": "cloudrun.get_service_status",
"generate_code": "code.generate" // This one is special - handled inline
};
const actualToolName = toolMapping[toolCall.name];
// Special handling for code generation
if (toolCall.name === "generate_code") {
const codeRun = await handleCodeGeneration(toolCall.arguments);
runs.push(codeRun);
continue;
}
const tool = actualToolName ? registry[actualToolName] : null;
if (!tool) {
console.log(`Tool not found: ${toolCall.name} (mapped to ${actualToolName})`);
continue;
}
// Create a run
const runId = `run_${new Date().toISOString().replace(/[-:.TZ]/g, "")}_${nanoid(8)}`;
const now = new Date().toISOString();
const run: RunRecord = {
run_id: runId,
tenant_id: "t_chat",
tool: actualToolName,
status: "queued",
created_at: now,
updated_at: now,
input: toolCall.arguments,
artifacts: { bucket: "", prefix: `runs/${runId}` }
};
await saveRun(run);
// Execute the tool
try {
run.status = "running";
run.updated_at = new Date().toISOString();
await saveRun(run);
const execUrl = `${tool.executor.url}${tool.executor.path}`;
const response = await fetch(execUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
run_id: runId,
tenant_id: "t_chat",
input: toolCall.arguments
})
});
if (!response.ok) {
throw new Error(`Executor error: ${response.status}`);
}
const output = await response.json();
run.status = "succeeded";
run.output = output;
run.updated_at = new Date().toISOString();
await saveRun(run);
} catch (e: any) {
run.status = "failed";
run.error = { message: e.message };
run.updated_at = new Date().toISOString();
await saveRun(run);
}
runs.push(run);
}
return runs;
}
/**
* Handle code generation specially
*/
async function handleCodeGeneration(args: any): Promise<RunRecord> {
const runId = `run_${new Date().toISOString().replace(/[-:.TZ]/g, "")}_${nanoid(8)}`;
const now = new Date().toISOString();
// For now, return a mock code generation result
// In production, this would call Gemini again with a code-specific prompt
const mockDiff = `--- a/${args.file_path || "src/example.ts"}
+++ b/${args.file_path || "src/example.ts"}
@@ -1,3 +1,10 @@
+// Generated by Product OS
+// Task: ${args.task}
+
export function example() {
- return "hello";
+ // TODO: Implement ${args.task}
+ return {
+ status: "generated",
+ task: "${args.task}"
+ };
}`;
const run: RunRecord = {
run_id: runId,
tenant_id: "t_chat",
tool: "code.generate",
status: "succeeded",
created_at: now,
updated_at: now,
input: args,
output: {
type: "code_generation",
diff: mockDiff,
file_path: args.file_path || "src/example.ts",
language: args.language || "typescript",
description: `Generated code for: ${args.task}`
}
};
await saveRun(run);
await writeArtifactText(`runs/${runId}`, "diff.patch", mockDiff);
return run;
}
/**
* Generate a human-readable summary of tool executions
*/
function generateToolSummary(toolCalls: ToolCall[], runs: RunRecord[]): string {
const parts: string[] = [];
for (let i = 0; i < toolCalls.length; i++) {
const tool = toolCalls[i];
const run = runs[i];
if (!run) continue;
const status = run.status === "succeeded" ? "✅" : "❌";
switch (tool.name) {
case "deploy_service":
if (run.status === "succeeded") {
const output = run.output as any;
parts.push(`${status} **Deployed** \`${tool.arguments.service_name}\` to ${tool.arguments.env || "dev"}\n URL: ${output?.service_url || "pending"}`);
} else {
parts.push(`${status} **Deploy failed** for \`${tool.arguments.service_name}\`: ${run.error?.message}`);
}
break;
case "get_funnel_analytics":
if (run.status === "succeeded") {
const output = run.output as any;
const steps = output?.steps || [];
const conversion = ((output?.overall_conversion || 0) * 100).toFixed(1);
parts.push(`${status} **Funnel Analysis** (${tool.arguments.range_days || 30} days)\n Overall conversion: ${conversion}%\n Steps: ${steps.length}`);
}
break;
case "get_top_drivers":
if (run.status === "succeeded") {
const output = run.output as any;
const drivers = output?.drivers || [];
const topDrivers = drivers.slice(0, 3).map((d: any) => d.name).join(", ");
parts.push(`${status} **Top Drivers** for ${tool.arguments.metric}\n ${topDrivers}`);
}
break;
case "generate_marketing_posts":
if (run.status === "succeeded") {
const output = run.output as any;
const channels = output?.channels || [];
const postCount = channels.reduce((sum: number, c: any) => sum + (c.posts?.length || 0), 0);
parts.push(`${status} **Generated** ${postCount} marketing posts for ${channels.map((c: any) => c.channel).join(", ")}`);
}
break;
case "get_service_status":
if (run.status === "succeeded") {
const output = run.output as any;
parts.push(`${status} **Service Status**: \`${tool.arguments.service_name}\` is ${output?.status || "unknown"}`);
}
break;
case "generate_code":
if (run.status === "succeeded") {
parts.push(`${status} **Generated code** for: ${tool.arguments.task}\n File: \`${(run.output as any)?.file_path}\``);
}
break;
default:
parts.push(`${status} **${tool.name}** completed`);
}
}
if (parts.length === 0) {
return "I processed your request but no actions were taken.";
}
return parts.join("\n\n");
}

View File

@@ -1,17 +0,0 @@
import type { FastifyInstance } from "fastify";
export async function healthRoutes(app: FastifyInstance) {
// Root route - API info
app.get("/", async () => ({
name: "Product OS Control Plane",
version: "0.1.0",
endpoints: {
health: "GET /healthz",
tools: "GET /tools",
invoke: "POST /tools/invoke",
runs: "GET /runs/:run_id"
}
}));
app.get("/healthz", async () => ({ ok: true }));
}

View File

@@ -1,195 +0,0 @@
import type { FastifyInstance } from "fastify";
import { nanoid } from "nanoid";
import { requireAuth } from "../auth.js";
import { config } from "../config.js";
import * as gitea from "../gitea.js";
import * as coolify from "../coolify.js";
import {
saveProject,
getProject,
listProjects,
updateProjectApp,
} from "../storage/index.js";
import type { AppRecord, ProjectRecord } from "../types.js";
const DEFAULT_APPS: AppRecord[] = [
{ name: "product", path: "apps/product" },
{ name: "website", path: "apps/website" },
{ name: "admin", path: "apps/admin" },
{ name: "storybook", path: "apps/storybook" },
];
const TURBO_VERSION = "2.3.3";
interface CreateProjectBody {
name: string;
tenant_id: string;
gitea_owner: string;
apps?: string[];
}
interface DeployAppBody {
app_name: string;
}
function slugify(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
}
function appDomain(projectSlug: string, appName: string, tenantSlug: string): string {
const base = config.platformUrl.replace(/^https?:\/\//, "").split(":")[0] ?? "vibnai.com";
return `${appName}-${projectSlug}.${tenantSlug}.${base}`;
}
export async function projectRoutes(app: FastifyInstance) {
/**
* List all projects for the authenticated tenant
*/
app.get<{ Querystring: { tenant_id: string } }>("/projects", async (req) => {
await requireAuth(req);
const tenantId = (req.query as any).tenant_id as string;
if (!tenantId) return app.httpErrors.badRequest("tenant_id is required");
return { projects: await listProjects(tenantId) };
});
/**
* Get a single project
*/
app.get<{ Params: { project_id: string } }>("/projects/:project_id", async (req) => {
await requireAuth(req);
const project = await getProject((req.params as any).project_id);
if (!project) return app.httpErrors.notFound("Project not found");
return project;
});
/**
* Create a new project — scaffolds the Turborepo monorepo in Gitea
* and provisions Coolify services for each app.
*/
app.post<{ Body: CreateProjectBody }>("/projects", async (req) => {
await requireAuth(req);
const { name, tenant_id, gitea_owner, apps: selectedApps } = req.body;
if (!name || !tenant_id || !gitea_owner) {
return app.httpErrors.badRequest("name, tenant_id, and gitea_owner are required");
}
const slug = slugify(name);
const projectId = `proj_${nanoid(12)}`;
const now = new Date().toISOString();
const selectedAppNames = selectedApps ?? DEFAULT_APPS.map(a => a.name);
const apps = DEFAULT_APPS.filter(a => selectedAppNames.includes(a.name));
const project: ProjectRecord = {
project_id: projectId,
tenant_id,
name,
slug,
status: "provisioning",
repo: "",
apps,
turboVersion: TURBO_VERSION,
created_at: now,
updated_at: now,
};
await saveProject(project);
// Provision asynchronously — return immediately with "provisioning" status
provisionProject(project, gitea_owner).catch(async (err: Error) => {
project.status = "error";
project.error = err.message;
project.updated_at = new Date().toISOString();
await saveProject(project);
app.log.error({ projectId, err: err.message }, "Project provisioning failed");
});
return { project_id: projectId, status: "provisioning", slug };
});
/**
* List apps within a project
*/
app.get<{ Params: { project_id: string } }>("/projects/:project_id/apps", async (req) => {
await requireAuth(req);
const project = await getProject((req.params as any).project_id);
if (!project) return app.httpErrors.notFound("Project not found");
return { apps: project.apps };
});
/**
* Deploy a specific app within the project
*/
app.post<{ Params: { project_id: string }; Body: DeployAppBody }>(
"/projects/:project_id/deploy",
async (req) => {
await requireAuth(req);
const project = await getProject((req.params as any).project_id);
if (!project) return app.httpErrors.notFound("Project not found");
const { app_name } = req.body;
const targetApp = project.apps.find(a => a.name === app_name);
if (!targetApp) return app.httpErrors.notFound(`App "${app_name}" not found in project`);
if (!targetApp.coolifyServiceUuid) {
return app.httpErrors.badRequest(`App "${app_name}" has no Coolify service yet`);
}
const deploymentUuid = await coolify.triggerDeploy(targetApp.coolifyServiceUuid);
return { deployment_uuid: deploymentUuid, app: app_name, status: "deploying" };
}
);
}
/**
* Full provisioning flow — runs after the route returns
*/
async function provisionProject(project: ProjectRecord, giteaOwner: string): Promise<void> {
const repoName = project.slug;
// 1. Create Gitea repo
const repoUrl = await gitea.createRepo(giteaOwner, repoName, `${project.name} monorepo`);
project.repo = repoUrl;
project.updated_at = new Date().toISOString();
await saveProject(project);
// 2. Push Turborepo scaffold
await gitea.scaffoldRepo(giteaOwner, repoName, project.slug, project.name);
// 3. Register webhook
const webhookUrl = `${config.platformUrl}/webhooks/gitea`;
await gitea.registerWebhook(giteaOwner, repoName, webhookUrl);
// 4. Create Coolify project
const coolifyProjectUuid = await coolify.createProject(
project.name,
`Coolify project for ${project.name}`
);
project.coolifyProjectUuid = coolifyProjectUuid;
project.updated_at = new Date().toISOString();
await saveProject(project);
// 5. Create a Coolify service per app
for (const projectApp of project.apps) {
const domain = appDomain(project.slug, projectApp.name, project.tenant_id);
const serviceUuid = await coolify.createAppService({
coolifyProjectUuid,
appName: projectApp.name,
repoUrl,
domain,
});
const updatedApp: AppRecord = {
...projectApp,
coolifyServiceUuid: serviceUuid,
domain,
};
await updateProjectApp(project.project_id, updatedApp);
}
project.status = "active";
project.updated_at = new Date().toISOString();
await saveProject(project);
}

View File

@@ -1,18 +0,0 @@
import type { FastifyInstance } from "fastify";
import { requireAuth } from "../auth.js";
import { getRun } from "../storage/index.js";
export async function runRoutes(app: FastifyInstance) {
app.get("/runs/:run_id", async (req) => {
await requireAuth(req);
const runId = (req.params as any).run_id as string;
const run = await getRun(runId);
if (!run) return app.httpErrors.notFound("Run not found");
return run;
});
app.get("/runs/:run_id/logs", async (req) => {
await requireAuth(req);
return { note: "V1: logs are in GCS artifacts under runs/<run_id>/" };
});
}

View File

@@ -1,91 +0,0 @@
import type { FastifyInstance } from "fastify";
import { nanoid } from "nanoid";
import { requireAuth } from "../auth.js";
import { getRegistry } from "../registry.js";
import { saveRun, writeArtifactText } from "../storage/index.js";
import type { RunRecord, ToolInvokeRequest } from "../types.js";
async function postJson(url: string, body: unknown) {
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body)
});
if (!res.ok) {
const txt = await res.text();
throw new Error(`Executor error ${res.status}: ${txt}`);
}
return res.json() as Promise<unknown>;
}
export async function toolRoutes(app: FastifyInstance) {
app.get("/tools", async (req) => {
await requireAuth(req);
const registry = await getRegistry();
return { tools: Object.values(registry) };
});
app.post<{ Body: ToolInvokeRequest }>("/tools/invoke", async (req) => {
await requireAuth(req);
const body = req.body;
const registry = await getRegistry();
const tool = registry[body.tool];
if (!tool) return app.httpErrors.notFound(`Unknown tool: ${body.tool}`);
const runId = `run_${new Date().toISOString().replace(/[-:.TZ]/g, "")}_${nanoid(8)}`;
const now = new Date().toISOString();
const run: RunRecord = {
run_id: runId,
tenant_id: body.tenant_id,
tool: body.tool,
status: "queued",
created_at: now,
updated_at: now,
input: body.input,
artifacts: { bucket: process.env.GCS_BUCKET_ARTIFACTS ?? "", prefix: `runs/${runId}` }
};
await saveRun(run);
await writeArtifactText(`runs/${runId}`, "input.json", JSON.stringify(body, null, 2));
try {
run.status = "running";
run.updated_at = new Date().toISOString();
await saveRun(run);
if (body.dry_run) {
run.status = "succeeded";
run.output = { dry_run: true };
run.updated_at = new Date().toISOString();
await saveRun(run);
await writeArtifactText(`runs/${runId}`, "output.json", JSON.stringify(run.output, null, 2));
return { run_id: runId, status: run.status };
}
const execUrl = `${tool.executor.url}${tool.executor.path}`;
const output = await postJson(execUrl, {
run_id: runId,
tenant_id: body.tenant_id,
workspace_id: body.workspace_id,
input: body.input
});
run.status = "succeeded";
run.output = output;
run.updated_at = new Date().toISOString();
await saveRun(run);
await writeArtifactText(`runs/${runId}`, "output.json", JSON.stringify(output, null, 2));
return { run_id: runId, status: run.status };
} catch (e: any) {
run.status = "failed";
run.error = { message: e?.message ?? "Unknown error" };
run.updated_at = new Date().toISOString();
await saveRun(run);
await writeArtifactText(`runs/${runId}`, "error.json", JSON.stringify(run.error, null, 2));
return { run_id: runId, status: run.status };
}
});
}

View File

@@ -1,23 +0,0 @@
import { Firestore } from "@google-cloud/firestore";
import { config } from "../config.js";
import type { RunRecord, ToolDef } from "../types.js";
const db = new Firestore({ projectId: config.projectId });
export async function saveRun(run: RunRecord): Promise<void> {
await db.collection(config.runsCollection).doc(run.run_id).set(run, { merge: true });
}
export async function getRun(runId: string): Promise<RunRecord | null> {
const snap = await db.collection(config.runsCollection).doc(runId).get();
return snap.exists ? (snap.data() as RunRecord) : null;
}
export async function saveTool(tool: ToolDef): Promise<void> {
await db.collection(config.toolsCollection).doc(tool.name).set(tool, { merge: true });
}
export async function listTools(): Promise<ToolDef[]> {
const snap = await db.collection(config.toolsCollection).get();
return snap.docs.map(d => d.data() as ToolDef);
}

View File

@@ -1,11 +0,0 @@
import { Storage } from "@google-cloud/storage";
import { config } from "../config.js";
const storage = new Storage({ projectId: config.projectId });
export async function writeArtifactText(prefix: string, filename: string, content: string) {
const bucket = storage.bucket(config.artifactsBucket);
const file = bucket.file(`${prefix}/${filename}`);
await file.save(content, { contentType: "text/plain" });
return { bucket: config.artifactsBucket, path: `${prefix}/${filename}` };
}

View File

@@ -1,33 +0,0 @@
/**
* Storage adapter that switches between GCP (Firestore/GCS) and in-memory
*/
import { config } from "../config.js";
import * as memory from "./memory.js";
import * as firestore from "./firestore.js";
import * as gcs from "./gcs.js";
const useMemory = config.storageMode === "memory";
if (useMemory) {
console.log("💾 Using in-memory storage (set GCP_PROJECT_ID for Firestore/GCS)");
memory.seedTools();
} else {
console.log(`☁️ Using GCP storage (project: ${config.projectId})`);
}
// Runs
export const saveRun = useMemory ? memory.saveRun : firestore.saveRun;
export const getRun = useMemory ? memory.getRun : firestore.getRun;
// Tools
export const saveTool = useMemory ? memory.saveTool : firestore.saveTool;
export const listTools = useMemory ? memory.listTools : firestore.listTools;
// Artifacts
export const writeArtifactText = useMemory ? memory.writeArtifactText : gcs.writeArtifactText;
// Projects (memory-only until Firestore adapter is extended)
export const saveProject = memory.saveProject;
export const getProject = memory.getProject;
export const listProjects = memory.listProjects;
export const updateProjectApp = memory.updateProjectApp;

View File

@@ -1,143 +0,0 @@
/**
* In-memory storage for local development without Firestore/GCS
*/
import type { AppRecord, ProjectRecord, RunRecord, ToolDef } from "../types.js";
// In-memory stores
const runs = new Map<string, RunRecord>();
const tools = new Map<string, ToolDef>();
const artifacts = new Map<string, string>();
const projects = new Map<string, ProjectRecord>();
// Run operations
export async function saveRun(run: RunRecord): Promise<void> {
runs.set(run.run_id, { ...run });
}
export async function getRun(runId: string): Promise<RunRecord | null> {
return runs.get(runId) ?? null;
}
// Tool operations
export async function saveTool(tool: ToolDef): Promise<void> {
tools.set(tool.name, { ...tool });
}
export async function listTools(): Promise<ToolDef[]> {
return Array.from(tools.values());
}
// Artifact operations
export async function writeArtifactText(prefix: string, filename: string, content: string) {
const path = `${prefix}/${filename}`;
artifacts.set(path, content);
return { bucket: "memory", path };
}
// Project operations
export async function saveProject(project: ProjectRecord): Promise<void> {
projects.set(project.project_id, { ...project });
}
export async function getProject(projectId: string): Promise<ProjectRecord | null> {
return projects.get(projectId) ?? null;
}
export async function listProjects(tenantId: string): Promise<ProjectRecord[]> {
return Array.from(projects.values()).filter(p => p.tenant_id === tenantId);
}
export async function updateProjectApp(projectId: string, app: AppRecord): Promise<void> {
const project = projects.get(projectId);
if (!project) throw new Error(`Project not found: ${projectId}`);
const idx = project.apps.findIndex(a => a.name === app.name);
if (idx >= 0) {
project.apps[idx] = app;
} else {
project.apps.push(app);
}
project.updated_at = new Date().toISOString();
projects.set(projectId, project);
}
// Seed some example tools for testing
export function seedTools() {
const sampleTools: ToolDef[] = [
{
name: "cloudrun.deploy_service",
description: "Build and deploy a Cloud Run service",
risk: "medium",
executor: { kind: "http", url: "http://localhost:8090", path: "/execute/cloudrun/deploy" },
inputSchema: {
type: "object",
required: ["service_name", "repo", "ref", "env"],
properties: {
service_name: { type: "string" },
repo: { type: "string" },
ref: { type: "string" },
env: { type: "string", enum: ["dev", "staging", "prod"] }
}
}
},
{
name: "cloudrun.get_service_status",
description: "Get Cloud Run service status",
risk: "low",
executor: { kind: "http", url: "http://localhost:8090", path: "/execute/cloudrun/status" },
inputSchema: {
type: "object",
required: ["service_name", "region"],
properties: {
service_name: { type: "string" },
region: { type: "string" }
}
}
},
{
name: "analytics.funnel_summary",
description: "Get funnel metrics for a time window",
risk: "low",
executor: { kind: "http", url: "http://localhost:8091", path: "/execute/analytics/funnel" },
inputSchema: {
type: "object",
required: ["range_days"],
properties: {
range_days: { type: "integer", minimum: 1, maximum: 365 }
}
}
},
{
name: "brand.get_profile",
description: "Get tenant brand profile",
risk: "low",
executor: { kind: "http", url: "http://localhost:8092", path: "/execute/brand/get" },
inputSchema: {
type: "object",
required: ["profile_id"],
properties: {
profile_id: { type: "string" }
}
}
},
{
name: "marketing.generate_channel_posts",
description: "Generate social posts from a brief",
risk: "low",
executor: { kind: "http", url: "http://localhost:8093", path: "/execute/marketing/generate" },
inputSchema: {
type: "object",
required: ["brief", "channels"],
properties: {
brief: { type: "object" },
channels: { type: "array", items: { type: "string" } }
}
}
}
];
for (const tool of sampleTools) {
tools.set(tool.name, tool);
}
console.log(`📦 Seeded ${sampleTools.length} tools in memory`);
}

View File

@@ -1,65 +0,0 @@
// ─── Project ──────────────────────────────────────────────────────────────────
export type ProjectStatus = "provisioning" | "active" | "error" | "archived";
export type AppRecord = {
name: string;
path: string;
coolifyServiceUuid?: string;
domain?: string;
};
export type ProjectRecord = {
project_id: string;
tenant_id: string;
name: string;
slug: string;
status: ProjectStatus;
repo: string;
coolifyProjectUuid?: string;
apps: AppRecord[];
turboVersion: string;
created_at: string;
updated_at: string;
error?: string;
};
// ─── Tools ────────────────────────────────────────────────────────────────────
export type ToolRisk = "low" | "medium" | "high";
export type ToolDef = {
name: string;
description: string;
risk: ToolRisk;
executor: {
kind: "http";
url: string;
path: string;
};
inputSchema: unknown;
outputSchema?: unknown;
};
export type ToolInvokeRequest = {
tool: string;
tenant_id: string;
workspace_id?: string;
input: unknown;
dry_run?: boolean;
};
export type RunStatus = "queued" | "running" | "succeeded" | "failed";
export type RunRecord = {
run_id: string;
tenant_id: string;
tool: string;
status: RunStatus;
created_at: string;
updated_at: string;
input: unknown;
output?: unknown;
error?: { message: string; details?: unknown };
artifacts?: { bucket: string; prefix: string };
};

View File

@@ -1,13 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
}
}

View File

@@ -1,22 +0,0 @@
{
"name": "@productos/analytics-executor",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js"
},
"dependencies": {
"@fastify/cors": "^10.0.0",
"@fastify/sensible": "^6.0.0",
"fastify": "^5.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.19.0",
"typescript": "^5.5.4"
}
}

View File

@@ -1,91 +0,0 @@
import Fastify from "fastify";
import cors from "@fastify/cors";
import sensible from "@fastify/sensible";
const app = Fastify({ logger: true });
await app.register(cors, { origin: true });
await app.register(sensible);
app.get("/healthz", async () => ({ ok: true, executor: "analytics" }));
/**
* Get funnel summary
* In production: queries BigQuery
*/
app.post("/execute/analytics/funnel", async (req) => {
const body = req.body as any;
const { input } = body;
console.log(`📊 Funnel request:`, input);
// Mock funnel data
const steps = [
{ event_name: "page_view", users: 10000, conversion_from_prev: 1.0 },
{ event_name: "signup_start", users: 3200, conversion_from_prev: 0.32 },
{ event_name: "signup_complete", users: 2100, conversion_from_prev: 0.66 },
{ event_name: "first_action", users: 1400, conversion_from_prev: 0.67 },
{ event_name: "subscription", users: 420, conversion_from_prev: 0.30 }
];
return {
funnel_name: input.funnel?.name ?? "default_funnel",
range_days: input.range_days,
steps,
overall_conversion: 0.042,
generated_at: new Date().toISOString()
};
});
/**
* Get top drivers for a metric
*/
app.post("/execute/analytics/top_drivers", async (req) => {
const body = req.body as any;
const { input } = body;
console.log(`🔍 Top drivers request:`, input);
// Mock driver analysis
const drivers = [
{ name: "completed_onboarding", score: 0.85, direction: "positive", evidence: "Users who complete onboarding convert 3.2x more", confidence: 0.92 },
{ name: "used_feature_x", score: 0.72, direction: "positive", evidence: "Feature X usage correlates with 2.5x retention", confidence: 0.88 },
{ name: "time_to_first_value", score: 0.68, direction: "negative", evidence: "Each additional day reduces conversion by 12%", confidence: 0.85 },
{ name: "invited_team_member", score: 0.61, direction: "positive", evidence: "Team invites increase stickiness by 40%", confidence: 0.79 },
{ name: "mobile_signup", score: 0.45, direction: "negative", evidence: "Mobile signups have 25% lower activation", confidence: 0.71 }
];
return {
target: input.target,
range_days: input.range_days,
drivers,
generated_at: new Date().toISOString()
};
});
/**
* Write an insight
*/
app.post("/execute/analytics/write_insight", async (req) => {
const body = req.body as any;
const { input, run_id } = body;
console.log(`💡 Write insight:`, input.insight?.title);
const insightId = `insight_${Date.now().toString(36)}`;
return {
insight_id: insightId,
stored: {
bigquery: { dataset: "insights", table: "insights_v1" },
firestore: { collection: "insights", doc_id: insightId },
gcs: { bucket: "productos-artifacts", prefix: `insights/${insightId}` }
},
created_at: new Date().toISOString()
};
});
const port = Number(process.env.PORT ?? 8091);
app.listen({ port, host: "0.0.0.0" }).then(() => {
console.log(`📈 Analytics Executor running on http://localhost:${port}`);
});

View File

@@ -1,13 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
}
}

View File

@@ -1,23 +0,0 @@
{
"name": "@productos/deploy-executor",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js"
},
"dependencies": {
"@fastify/cors": "^10.0.0",
"@fastify/sensible": "^6.0.0",
"fastify": "^5.0.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.19.0",
"typescript": "^5.5.4"
}
}

View File

@@ -1,118 +0,0 @@
import Fastify from "fastify";
import cors from "@fastify/cors";
import sensible from "@fastify/sensible";
const app = Fastify({ logger: true });
await app.register(cors, { origin: true });
await app.register(sensible);
// Health check
app.get("/healthz", async () => ({ ok: true, executor: "deploy" }));
/**
* Deploy an app from a Turborepo monorepo.
*
* Expects input to include:
* - repo_url: git clone URL (the project monorepo)
* - app_name: the app folder name under apps/ (e.g. "product", "website")
* - ref: git branch/tag/sha (default "main")
* - env: target environment ("dev" | "staging" | "prod")
*
* Build command: turbo run build --filter={app_name}
* In production this triggers Coolify via its API; in dev it returns a mock.
*/
app.post("/execute/deploy", async (req) => {
const body = req.body as any;
const { run_id, tenant_id, input } = body;
const appName = input.app_name ?? input.service_name ?? "product";
const repoUrl = input.repo_url ?? "";
const ref = input.ref ?? "main";
const env = input.env ?? "dev";
console.log(`🚀 Monorepo deploy request:`, { run_id, tenant_id, appName, repoUrl, ref, env });
await new Promise(r => setTimeout(r, 1500));
const mockRevision = `${appName}-${Date.now().toString(36)}`;
const mockUrl = `https://${appName}-${ref}.vibnai.com`;
console.log(`✅ Deploy complete:`, { appName, revision: mockRevision, url: mockUrl });
return {
app_name: appName,
service_url: mockUrl,
revision: mockRevision,
build_command: `turbo run build --filter=${appName}`,
build_id: `build-${Date.now()}`,
deployed_at: new Date().toISOString(),
env,
};
});
// Legacy Cloud Run endpoint — kept for backwards compatibility
app.post("/execute/cloudrun/deploy", async (req) => {
const body = req.body as any;
const { run_id, tenant_id, input } = body;
console.log(`🚀 Deploy request (legacy):`, { run_id, tenant_id, input });
await new Promise(r => setTimeout(r, 1500));
const mockRevision = `${input.service_name}-${Date.now().toString(36)}`;
const mockUrl = `https://${input.service_name}-abc123.a.run.app`;
return {
service_url: mockUrl,
revision: mockRevision,
build_id: `build-${Date.now()}`,
deployed_at: new Date().toISOString(),
region: input.region ?? "us-central1",
env: input.env,
};
});
/**
* Get Cloud Run service status
*/
app.post("/execute/cloudrun/status", async (req) => {
const body = req.body as any;
const { input } = body;
console.log(`📊 Status request:`, input);
// Mock status response
return {
service_name: input.service_name,
region: input.region,
service_url: `https://${input.service_name}-abc123.a.run.app`,
latest_ready_revision: `${input.service_name}-v1`,
status: "ready",
last_deploy_time: new Date().toISOString(),
traffic: [{ revision: `${input.service_name}-v1`, percent: 100 }]
};
});
/**
* Rollback to a previous revision
*/
app.post("/execute/cloudrun/rollback", async (req) => {
const body = req.body as any;
const { input } = body;
console.log(`⏪ Rollback request:`, input);
await new Promise(r => setTimeout(r, 1000));
return {
service_name: input.service_name,
rolled_back_to: input.target_revision ?? "previous",
status: "ready",
rolled_back_at: new Date().toISOString()
};
});
const port = Number(process.env.PORT ?? 8090);
app.listen({ port, host: "0.0.0.0" }).then(() => {
console.log(`🔧 Deploy Executor running on http://localhost:${port}`);
});

View File

@@ -1,13 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
}
}

View File

@@ -1,22 +0,0 @@
{
"name": "@productos/marketing-executor",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js"
},
"dependencies": {
"@fastify/cors": "^10.0.0",
"@fastify/sensible": "^6.0.0",
"fastify": "^5.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.19.0",
"typescript": "^5.5.4"
}
}

View File

@@ -1,88 +0,0 @@
import Fastify from "fastify";
import cors from "@fastify/cors";
import sensible from "@fastify/sensible";
const app = Fastify({ logger: true });
await app.register(cors, { origin: true });
await app.register(sensible);
app.get("/healthz", async () => ({ ok: true, executor: "marketing" }));
/**
* Generate channel posts from a brief
* In production: calls Gemini API
*/
app.post("/execute/marketing/generate", async (req) => {
const body = req.body as any;
const { input } = body;
console.log(`✍️ Generate posts:`, input.brief?.goal);
await new Promise(r => setTimeout(r, 1000)); // Simulate AI generation time
const channels = (input.channels ?? ["x", "linkedin"]).map((channel: string) => ({
channel,
posts: [
{
text: `🚀 Exciting news! ${input.brief?.product ?? "Our product"} just got even better. ${input.brief?.key_points?.[0] ?? "Check it out!"}\n\n${input.brief?.call_to_action ?? "Learn more"} 👇\n${input.brief?.landing_page_url ?? "https://example.com"}`,
hashtags: ["#ProductUpdate", "#SaaS", "#Innovation"],
media_suggestions: ["product-screenshot.png", "feature-demo.gif"]
},
{
text: `${input.brief?.audience ?? "Teams"} asked, we listened! Introducing ${input.brief?.product ?? "new features"} that will transform how you work.\n\n✨ ${input.brief?.key_points?.join("\n✨ ") ?? "Amazing new capabilities"}\n\nTry it today!`,
hashtags: ["#ProductLaunch", "#Productivity"],
media_suggestions: ["comparison-chart.png"]
},
{
text: `Did you know? ${input.brief?.key_points?.[0] ?? "Our latest update"} can save you hours every week.\n\nHere's how ${input.brief?.product ?? "it"} works:\n1⃣ Set it up in minutes\n2⃣ Let automation do the work\n3⃣ Focus on what matters\n\n${input.brief?.offer ?? "Start free today!"}`,
hashtags: ["#Automation", "#WorkSmarter"],
media_suggestions: ["how-it-works.mp4"]
}
]
}));
return {
channels,
brief_summary: input.brief?.goal,
generated_at: new Date().toISOString(),
variations_per_channel: 3
};
});
/**
* Get brand profile
*/
app.post("/execute/brand/get", async (req) => {
const body = req.body as any;
const { input } = body;
console.log(`🎨 Get brand profile:`, input.profile_id);
return {
profile_id: input.profile_id ?? "default",
brand: {
name: "Product OS",
voice: {
tone: ["professional", "friendly", "innovative"],
style_notes: ["Use active voice", "Keep sentences short", "Be specific with benefits"],
do: ["Use data and metrics", "Include calls to action", "Highlight customer success"],
dont: ["Make unverified claims", "Use jargon", "Be overly salesy"]
},
audience: {
primary: "SaaS founders and product teams",
secondary: "Growth marketers and developers"
},
claims_policy: {
forbidden_claims: ["#1 in the market", "Guaranteed results"],
required_disclaimers: [],
compliance_notes: ["All metrics should be verifiable"]
}
}
};
});
const port = Number(process.env.PORT ?? 8093);
app.listen({ port, host: "0.0.0.0" }).then(() => {
console.log(`📣 Marketing Executor running on http://localhost:${port}`);
});

View File

@@ -1,13 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
}
}

View File

@@ -1,103 +0,0 @@
# Product OS MCP Adapter
Exposes Control Plane tools to Continue and other MCP clients.
## Architecture
```
Continue (MCP client)
MCP Adapter (this server)
Control Plane API
Gemini + Executors
```
## Available Tools
| Tool | Description |
|------|-------------|
| `deploy_service` | Deploy a Cloud Run service |
| `get_service_status` | Check deployment health |
| `get_funnel_analytics` | Get conversion metrics |
| `get_top_drivers` | Understand metric drivers |
| `generate_marketing_posts` | Create social content |
| `chat_with_gemini` | General AI conversation |
## Setup
### 1. Install dependencies
```bash
cd platform/backend/mcp-adapter
npm install
```
### 2. Make sure Control Plane is running
```bash
cd platform/backend/control-plane
npm run dev
```
### 3. Install Continue Extension
In VS Code/VSCodium:
1. Open Extensions (Cmd+Shift+X)
2. Search for "Continue"
3. Install
### 4. Configure Continue
The configuration is already in `.continue/config.yaml`.
Or manually add to your Continue config:
```yaml
experimental:
modelContextProtocolServers:
- name: productos
command: npx
args:
- tsx
- /path/to/platform/backend/mcp-adapter/src/index.ts
env:
CONTROL_PLANE_URL: http://localhost:8080
```
### 5. Use in Continue
1. Open Continue chat (Cmd+L)
2. Enable Agent mode (click the robot icon)
3. Ask: "Deploy my service to staging"
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `CONTROL_PLANE_URL` | `http://localhost:8080` | Control Plane API URL |
| `TENANT_ID` | `t_mcp` | Tenant ID for tool calls |
## Development
```bash
# Run directly (for testing)
npm run dev
# Build
npm run build
# Run built version
npm start
```
## Testing
```bash
# Test tool listing
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | npm run dev
# Test tool call
echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_funnel_analytics","arguments":{}}}' | npm run dev
```

View File

@@ -1,25 +0,0 @@
{
"name": "@productos/mcp-adapter",
"version": "0.1.0",
"private": true,
"description": "MCP Adapter Server - exposes Control Plane tools to Continue and other MCP clients",
"type": "module",
"main": "dist/index.js",
"bin": {
"productos-mcp": "./dist/index.js"
},
"scripts": {
"dev": "tsx src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.19.0",
"typescript": "^5.5.4"
}
}

View File

@@ -1,343 +0,0 @@
#!/usr/bin/env node
/**
* Product OS MCP Adapter Server
*
* Exposes Control Plane tools to Continue and other MCP clients.
*
* Architecture:
* Continue (MCP client) → This Adapter → Control Plane → Gemini + Executors
*
* This keeps:
* - Control Plane as the canonical API
* - Auth/billing/policies centralized
* - MCP as a compatibility layer
*/
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
ToolSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
// Control Plane URL (configurable via env)
const CONTROL_PLANE_URL = process.env.CONTROL_PLANE_URL || "http://localhost:8080";
const TENANT_ID = process.env.TENANT_ID || "t_mcp";
// ============================================================================
// Tool Definitions
// ============================================================================
const TOOLS: ToolSchema[] = [
{
name: "deploy_service",
description: "Deploy a Cloud Run service to GCP. Use when the user wants to deploy, ship, or launch code to staging or production.",
inputSchema: {
type: "object",
properties: {
service_name: {
type: "string",
description: "Name of the service to deploy"
},
repo: {
type: "string",
description: "Git repository URL (optional, defaults to current workspace)"
},
ref: {
type: "string",
description: "Git branch, tag, or commit (optional, defaults to main)"
},
env: {
type: "string",
enum: ["dev", "staging", "prod"],
description: "Target environment"
}
},
required: ["service_name"]
}
},
{
name: "get_service_status",
description: "Check the status of a deployed Cloud Run service. Use when the user asks about service health or deployment status.",
inputSchema: {
type: "object",
properties: {
service_name: {
type: "string",
description: "Name of the service to check"
},
region: {
type: "string",
description: "GCP region (defaults to us-central1)"
}
},
required: ["service_name"]
}
},
{
name: "get_funnel_analytics",
description: "Get funnel conversion metrics and drop-off analysis. Use when the user asks about funnels, conversions, or user journey.",
inputSchema: {
type: "object",
properties: {
funnel_name: {
type: "string",
description: "Name of the funnel to analyze (optional)"
},
range_days: {
type: "integer",
description: "Number of days to analyze (defaults to 30)"
}
}
}
},
{
name: "get_top_drivers",
description: "Identify top factors driving a metric. Use when the user asks why something changed or what drives conversions/retention.",
inputSchema: {
type: "object",
properties: {
metric: {
type: "string",
description: "The metric to analyze (e.g., 'conversion', 'retention', 'churn')"
},
range_days: {
type: "integer",
description: "Number of days to analyze (defaults to 30)"
}
},
required: ["metric"]
}
},
{
name: "generate_marketing_posts",
description: "Generate social media posts for a marketing campaign. Use when the user wants to create content for X, LinkedIn, etc.",
inputSchema: {
type: "object",
properties: {
goal: {
type: "string",
description: "Campaign goal (e.g., 'product launch', 'feature announcement', 'engagement')"
},
product: {
type: "string",
description: "Product or feature name"
},
channels: {
type: "array",
items: { type: "string" },
description: "Social channels to generate for (e.g., ['x', 'linkedin'])"
},
tone: {
type: "string",
description: "Tone of voice (e.g., 'professional', 'casual', 'excited')"
}
},
required: ["goal"]
}
},
{
name: "chat_with_gemini",
description: "Have a conversation with Gemini AI about your product, code, or anything else. Use for general questions, code explanation, or ideation.",
inputSchema: {
type: "object",
properties: {
message: {
type: "string",
description: "The message or question to send to Gemini"
},
context: {
type: "string",
description: "Additional context (e.g., code snippet, file contents)"
}
},
required: ["message"]
}
}
];
// ============================================================================
// Tool Name Mapping (MCP tool name → Control Plane tool name)
// ============================================================================
const TOOL_MAPPING: Record<string, string> = {
"deploy_service": "cloudrun.deploy_service",
"get_service_status": "cloudrun.get_service_status",
"get_funnel_analytics": "analytics.funnel_summary",
"get_top_drivers": "analytics.top_drivers",
"generate_marketing_posts": "marketing.generate_channel_posts",
};
// ============================================================================
// Control Plane Client
// ============================================================================
async function invokeControlPlaneTool(tool: string, input: any): Promise<any> {
// Step 1: Invoke the tool
const invokeResponse = await fetch(`${CONTROL_PLANE_URL}/tools/invoke`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
tool,
tenant_id: TENANT_ID,
input,
dry_run: false
})
});
if (!invokeResponse.ok) {
const text = await invokeResponse.text();
throw new Error(`Control Plane error: ${invokeResponse.status} - ${text}`);
}
const invokeResult = await invokeResponse.json();
// Step 2: Fetch the full run to get the output
const runResponse = await fetch(`${CONTROL_PLANE_URL}/runs/${invokeResult.run_id}`);
if (!runResponse.ok) {
// Fall back to the invoke result if we can't fetch the run
return invokeResult;
}
return runResponse.json();
}
async function chatWithControlPlane(message: string, context?: string): Promise<any> {
const messages = [];
if (context) {
messages.push({ role: "user", content: `Context:\n${context}` });
}
messages.push({ role: "user", content: message });
const response = await fetch(`${CONTROL_PLANE_URL}/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages,
autoExecuteTools: true
})
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Control Plane chat error: ${response.status} - ${text}`);
}
return response.json();
}
// ============================================================================
// MCP Server
// ============================================================================
const server = new Server(
{
name: "productos-mcp",
version: "0.1.0",
},
{
capabilities: {
tools: {},
},
}
);
// List available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
return { tools: TOOLS };
});
// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
console.error(`[MCP] Tool called: ${name}`, args);
try {
// Special handling for chat
if (name === "chat_with_gemini") {
const result = await chatWithControlPlane(
(args as any).message,
(args as any).context
);
return {
content: [
{
type: "text",
text: result.message || JSON.stringify(result, null, 2)
}
]
};
}
// Map MCP tool name to Control Plane tool name
const controlPlaneTool = TOOL_MAPPING[name];
if (!controlPlaneTool) {
throw new Error(`Unknown tool: ${name}`);
}
// Invoke the Control Plane
const run = await invokeControlPlaneTool(controlPlaneTool, args);
// Format the response
let responseText = "";
if (run.status === "succeeded") {
responseText = `✅ **${name}** completed successfully\n\n`;
responseText += "**Result:**\n```json\n";
responseText += JSON.stringify(run.output, null, 2);
responseText += "\n```";
} else if (run.status === "failed") {
responseText = `❌ **${name}** failed\n\n`;
responseText += `**Error:** ${run.error?.message || "Unknown error"}`;
} else {
responseText = `⏳ **${name}** is ${run.status}\n\n`;
responseText += `**Run ID:** ${run.run_id}`;
}
return {
content: [
{
type: "text",
text: responseText
}
]
};
} catch (error: any) {
console.error(`[MCP] Error:`, error);
return {
content: [
{
type: "text",
text: `❌ Error executing ${name}: ${error.message}`
}
],
isError: true
};
}
});
// ============================================================================
// Start Server
// ============================================================================
async function main() {
console.error("[MCP] Product OS MCP Adapter starting...");
console.error(`[MCP] Control Plane URL: ${CONTROL_PLANE_URL}`);
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("[MCP] Server connected and ready");
}
main().catch((error) => {
console.error("[MCP] Fatal error:", error);
process.exit(1);
});

View File

@@ -1,14 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true
},
"include": ["src/**/*"]
}

View File

@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>

Before

Width:  |  Height:  |  Size: 266 B

View File

@@ -1,113 +0,0 @@
{
"name": "gcp-productos",
"displayName": "GCP Product OS",
"description": "Product-centric IDE for launching and operating SaaS products on Google Cloud. Use @productos in chat!",
"version": "0.2.0",
"publisher": "productos",
"engines": { "vscode": "^1.90.0" },
"categories": ["AI", "Chat", "Other"],
"activationEvents": ["onStartupFinished"],
"main": "./dist/extension.js",
"contributes": {
"chatParticipants": [
{
"id": "productos.chat",
"fullName": "Product OS",
"name": "productos",
"description": "Deploy, analyze, and automate your SaaS product on Google Cloud",
"isSticky": true,
"commands": [
{
"name": "deploy",
"description": "Deploy a service to Cloud Run"
},
{
"name": "analytics",
"description": "Get funnel and conversion analytics"
},
{
"name": "marketing",
"description": "Generate marketing content"
},
{
"name": "status",
"description": "Check service status"
}
]
}
],
"viewsContainers": {
"activitybar": [
{
"id": "productos",
"title": "Product OS",
"icon": "media/icon.svg"
}
]
},
"views": {
"productos": [
{
"id": "productos.tools",
"name": "Tools",
"icon": "media/icon.svg"
},
{
"id": "productos.runs",
"name": "Recent Runs"
}
]
},
"viewsWelcome": [
{
"view": "productos.tools",
"contents": "Connect to Product OS backend to see available tools.\n[Configure Backend](command:productos.configure)"
}
],
"commands": [
{ "command": "productos.configure", "title": "Product OS: Configure Backend", "icon": "$(gear)" },
{ "command": "productos.refresh", "title": "Product OS: Refresh", "icon": "$(refresh)" },
{ "command": "productos.tools.list", "title": "Product OS: List Tools" },
{ "command": "productos.tools.invoke", "title": "Product OS: Invoke Tool", "icon": "$(play)" },
{ "command": "productos.tools.invokeFromTree", "title": "Invoke Tool", "icon": "$(play)" },
{ "command": "productos.runs.open", "title": "Product OS: Open Run" },
{ "command": "productos.runs.openFromTree", "title": "View Run Details", "icon": "$(eye)" }
],
"menus": {
"view/title": [
{ "command": "productos.refresh", "when": "view == productos.tools", "group": "navigation" },
{ "command": "productos.configure", "when": "view == productos.tools" }
],
"view/item/context": [
{ "command": "productos.tools.invokeFromTree", "when": "viewItem == tool", "group": "inline" },
{ "command": "productos.runs.openFromTree", "when": "viewItem == run", "group": "inline" }
]
},
"configuration": {
"title": "Product OS",
"properties": {
"productos.backendUrl": {
"type": "string",
"default": "http://localhost:8080",
"description": "Control Plane API base URL"
},
"productos.tenantId": {
"type": "string",
"default": "t_dev",
"description": "Tenant ID for tool calls"
}
}
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"watch": "tsc -w -p tsconfig.json",
"package": "vsce package --allow-missing-repository"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/vscode": "^1.90.0",
"@vscode/vsce": "^3.0.0",
"typescript": "^5.5.4"
}
}

View File

@@ -1,137 +0,0 @@
import * as vscode from "vscode";
export interface Tool {
name: string;
description: string;
risk: "low" | "medium" | "high";
executor: {
kind: string;
url: string;
path: string;
};
inputSchema: any;
outputSchema?: any;
}
export interface Run {
run_id: string;
tenant_id: string;
tool: string;
status: "queued" | "running" | "succeeded" | "failed";
created_at: string;
updated_at: string;
input: any;
output?: any;
error?: { message: string; details?: any };
}
function getConfig<T>(key: string): T {
return vscode.workspace.getConfiguration("productos").get<T>(key)!;
}
export function getBackendUrl(): string {
return getConfig<string>("backendUrl");
}
export function getTenantId(): string {
return getConfig<string>("tenantId");
}
export async function checkConnection(): Promise<boolean> {
try {
const res = await fetch(`${getBackendUrl()}/healthz`, {
signal: AbortSignal.timeout(3000)
});
return res.ok;
} catch {
return false;
}
}
export async function listTools(): Promise<Tool[]> {
const res = await fetch(`${getBackendUrl()}/tools`);
if (!res.ok) throw new Error(await res.text());
const json = await res.json();
return json.tools ?? [];
}
export async function invokeTool(tool: string, input: any, dryRun = false): Promise<Run> {
const res = await fetch(`${getBackendUrl()}/tools/invoke`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
tool,
tenant_id: getTenantId(),
input,
dry_run: dryRun
})
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function getRun(runId: string): Promise<Run> {
const res = await fetch(`${getBackendUrl()}/runs/${runId}`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
// Store recent runs in memory
const recentRuns: Run[] = [];
export function addRecentRun(run: Run) {
recentRuns.unshift(run);
if (recentRuns.length > 20) recentRuns.pop();
}
export function getRecentRuns(): Run[] {
return [...recentRuns];
}
// Chat types
export interface ChatMessage {
role: "user" | "assistant" | "system";
content: string;
}
export interface ToolCall {
name: string;
arguments: Record<string, any>;
}
export interface ChatResponse {
message: string;
toolCalls?: ToolCall[];
runs?: Run[];
finishReason: "stop" | "tool_calls" | "error";
}
export interface ChatContext {
files?: { path: string; content: string }[];
selection?: { path: string; text: string; startLine: number };
}
/**
* Chat with the AI backend
*/
export async function chatWithAI(
messages: ChatMessage[],
context?: ChatContext
): Promise<ChatResponse> {
const res = await fetch(`${getBackendUrl()}/chat`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
messages,
context,
autoExecuteTools: true
})
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Chat failed: ${text}`);
}
return res.json();
}

View File

@@ -1,850 +0,0 @@
import * as vscode from "vscode";
import { chatWithAI, ChatMessage, ChatResponse } from "./api";
/**
* Product OS Chat Panel
* A Cursor-like conversational AI interface
*/
export class ChatPanel {
public static currentPanel: ChatPanel | undefined;
private static readonly viewType = "productosChat";
private readonly _panel: vscode.WebviewPanel;
private readonly _extensionUri: vscode.Uri;
private _disposables: vscode.Disposable[] = [];
private _messages: ChatMessage[] = [];
public static createOrShow(extensionUri: vscode.Uri) {
const column = vscode.window.activeTextEditor
? vscode.window.activeTextEditor.viewColumn
: undefined;
// If we already have a panel, show it
if (ChatPanel.currentPanel) {
ChatPanel.currentPanel._panel.reveal(column);
return;
}
// Otherwise, create a new panel
const panel = vscode.window.createWebviewPanel(
ChatPanel.viewType,
"Product OS Chat",
column || vscode.ViewColumn.One,
{
enableScripts: true,
retainContextWhenHidden: true,
localResourceRoots: [vscode.Uri.joinPath(extensionUri, "media")]
}
);
ChatPanel.currentPanel = new ChatPanel(panel, extensionUri);
}
private constructor(panel: vscode.WebviewPanel, extensionUri: vscode.Uri) {
this._panel = panel;
this._extensionUri = extensionUri;
// Set the webview's initial html content
this._update();
// Listen for when the panel is disposed
this._panel.onDidDispose(() => this.dispose(), null, this._disposables);
// Handle messages from the webview
this._panel.webview.onDidReceiveMessage(
async (message) => {
switch (message.command) {
case "send":
await this._handleChat(message.text);
return;
case "addContext":
await this._handleAddContext();
return;
case "clear":
this._messages = [];
this._update();
return;
}
},
null,
this._disposables
);
}
private async _handleChat(text: string) {
// Add user message to history (webview already shows it)
this._messages.push({ role: "user", content: text });
// DON'T call _update() - it would reset the webview and kill the JS state
// Show loading state
this._panel.webview.postMessage({ type: "loading", loading: true });
try {
// Get context from active editor
const context = this._getEditorContext();
console.log("[Product OS Chat] Sending to API:", text);
// Call the AI
const response = await chatWithAI(this._messages, context);
console.log("[Product OS Chat] Response:", response);
// Add assistant response to history
this._messages.push({ role: "assistant", content: response.message || "" });
// Send response to webview
this._panel.webview.postMessage({
type: "response",
message: response.message,
toolCalls: response.toolCalls,
runs: response.runs
});
} catch (error: any) {
console.error("[Product OS Chat] Error:", error);
this._panel.webview.postMessage({
type: "error",
error: error.message || "Unknown error"
});
} finally {
this._panel.webview.postMessage({ type: "loading", loading: false });
}
}
private async _handleAddContext() {
const editor = vscode.window.activeTextEditor;
if (!editor) {
vscode.window.showWarningMessage("No active editor");
return;
}
const selection = editor.selection;
const selectedText = editor.document.getText(selection);
if (selectedText) {
const filePath = vscode.workspace.asRelativePath(editor.document.uri);
const startLine = selection.start.line + 1;
this._panel.webview.postMessage({
type: "contextAdded",
context: {
type: "selection",
path: filePath,
startLine,
text: selectedText
}
});
} else {
// No selection, add the whole file
const filePath = vscode.workspace.asRelativePath(editor.document.uri);
const content = editor.document.getText();
this._panel.webview.postMessage({
type: "contextAdded",
context: {
type: "file",
path: filePath,
text: content.substring(0, 5000) // Limit to first 5000 chars
}
});
}
}
private _getEditorContext(): any {
const editor = vscode.window.activeTextEditor;
if (!editor) return undefined;
const selection = editor.selection;
const selectedText = editor.document.getText(selection);
if (selectedText) {
return {
selection: {
path: vscode.workspace.asRelativePath(editor.document.uri),
text: selectedText,
startLine: selection.start.line + 1
}
};
}
return undefined;
}
public dispose() {
ChatPanel.currentPanel = undefined;
// Clean up resources
this._panel.dispose();
while (this._disposables.length) {
const x = this._disposables.pop();
if (x) {
x.dispose();
}
}
}
private _update() {
this._panel.webview.html = this._getHtmlForWebview();
}
private _getHtmlForWebview() {
const nonce = getNonce();
// Convert messages to HTML
const messagesHtml = this._messages.map(m => {
const isUser = m.role === "user";
const avatarClass = isUser ? "user-avatar" : "ai-avatar";
const messageClass = isUser ? "user-message" : "ai-message";
const avatar = isUser ? "U" : "✦";
return `
<div class="message ${messageClass}">
<div class="avatar ${avatarClass}">${avatar}</div>
<div class="content">${escapeHtml(m.content)}</div>
</div>
`;
}).join("");
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}';">
<title>Product OS Chat</title>
<style>
:root {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--accent: #58a6ff;
--accent-muted: #1f6feb;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--text-muted: #6e7681;
--border: #30363d;
--success: #3fb950;
--warning: #d29922;
--danger: #f85149;
--gradient-start: #0d1117;
--gradient-end: #161b22;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
background: linear-gradient(180deg, var(--gradient-start) 0%, var(--gradient-end) 100%);
color: var(--text-primary);
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(13, 17, 23, 0.8);
backdrop-filter: blur(10px);
}
.header-title {
display: flex;
align-items: center;
gap: 12px;
}
.header-title h1 {
font-size: 16px;
font-weight: 600;
letter-spacing: -0.02em;
}
.header-icon {
width: 28px;
height: 28px;
background: linear-gradient(135deg, var(--accent-muted), var(--accent));
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
}
.header-actions {
display: flex;
gap: 8px;
}
.header-btn {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 12px;
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: all 0.15s ease;
}
.header-btn:hover {
background: var(--border);
color: var(--text-primary);
}
.messages {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.message {
display: flex;
gap: 12px;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.avatar {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
flex-shrink: 0;
}
.user-avatar {
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.ai-avatar {
background: linear-gradient(135deg, var(--accent-muted), var(--accent));
color: white;
}
.content {
flex: 1;
padding: 12px 16px;
border-radius: 12px;
line-height: 1.6;
font-size: 14px;
}
.user-message .content {
background: var(--bg-tertiary);
border: 1px solid var(--border);
}
.ai-message .content {
background: var(--bg-secondary);
border: 1px solid var(--border);
}
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 40px;
}
.empty-icon {
width: 64px;
height: 64px;
background: linear-gradient(135deg, var(--accent-muted), var(--accent));
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
margin-bottom: 20px;
box-shadow: 0 8px 32px rgba(88, 166, 255, 0.3);
}
.empty-state h2 {
font-size: 20px;
font-weight: 600;
margin-bottom: 8px;
letter-spacing: -0.02em;
}
.empty-state p {
color: var(--text-secondary);
font-size: 14px;
max-width: 360px;
line-height: 1.6;
}
.suggestions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 24px;
justify-content: center;
}
.suggestion {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 20px;
padding: 8px 16px;
font-size: 13px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s ease;
}
.suggestion:hover {
background: var(--border);
color: var(--text-primary);
border-color: var(--accent-muted);
}
.input-area {
padding: 16px 20px 24px;
background: rgba(13, 17, 23, 0.8);
backdrop-filter: blur(10px);
border-top: 1px solid var(--border);
}
.context-badge {
display: none;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 12px;
font-size: 12px;
color: var(--text-secondary);
}
.context-badge.active {
display: flex;
}
.context-badge code {
color: var(--accent);
background: var(--bg-secondary);
padding: 2px 6px;
border-radius: 4px;
}
.context-close {
margin-left: auto;
cursor: pointer;
opacity: 0.6;
}
.context-close:hover {
opacity: 1;
}
.input-wrapper {
display: flex;
gap: 12px;
align-items: flex-end;
}
.input-container {
flex: 1;
position: relative;
}
textarea {
width: 100%;
min-height: 48px;
max-height: 200px;
padding: 14px 16px;
padding-right: 44px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 12px;
color: var(--text-primary);
font-size: 14px;
font-family: inherit;
resize: none;
outline: none;
transition: all 0.15s ease;
}
textarea:focus {
border-color: var(--accent-muted);
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.15);
}
textarea::placeholder {
color: var(--text-muted);
}
.add-context-btn {
position: absolute;
right: 12px;
bottom: 12px;
width: 24px;
height: 24px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: all 0.15s ease;
}
.add-context-btn:hover {
background: var(--border);
color: var(--text-primary);
}
.send-btn {
width: 48px;
height: 48px;
background: linear-gradient(135deg, var(--accent-muted), var(--accent));
border: none;
border-radius: 12px;
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
transition: all 0.15s ease;
box-shadow: 0 4px 12px rgba(88, 166, 255, 0.3);
}
.send-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(88, 166, 255, 0.4);
}
.send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.loading {
display: none;
align-items: center;
gap: 8px;
color: var(--text-secondary);
font-size: 13px;
padding: 8px 0;
}
.loading.active {
display: flex;
}
.loading-dots {
display: flex;
gap: 4px;
}
.loading-dot {
width: 6px;
height: 6px;
background: var(--accent);
border-radius: 50%;
animation: pulse 1.4s infinite;
}
.loading-dot:nth-child(2) { animation-delay: 0.2s; }
.loading-dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes pulse {
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1); }
}
.tool-result {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
margin-top: 12px;
font-size: 13px;
}
.tool-result-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
color: var(--text-secondary);
}
.tool-result-status {
width: 8px;
height: 8px;
border-radius: 50%;
}
.tool-result-status.success { background: var(--success); }
.tool-result-status.error { background: var(--danger); }
.tool-result code {
color: var(--accent);
background: var(--bg-secondary);
padding: 2px 6px;
border-radius: 4px;
}
.code-block {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
margin-top: 8px;
overflow-x: auto;
}
.code-block pre {
font-family: "SF Mono", Monaco, "Cascadia Code", monospace;
font-size: 12px;
line-height: 1.5;
color: var(--text-primary);
}
</style>
</head>
<body>
<div class="header">
<div class="header-title">
<div class="header-icon">✦</div>
<h1>Product OS Chat</h1>
</div>
<div class="header-actions">
<button class="header-btn" onclick="clearChat()">Clear</button>
</div>
</div>
<div class="messages" id="messages">
${messagesHtml || `
<div class="empty-state">
<div class="empty-icon">✦</div>
<h2>Welcome to Product OS</h2>
<p>I can help you deploy services, analyze metrics, generate marketing content, and write code—all in one place.</p>
<div class="suggestions">
<button class="suggestion" onclick="sendSuggestion('Deploy my service to staging')">Deploy to staging</button>
<button class="suggestion" onclick="sendSuggestion('Show me funnel analytics')">Funnel analytics</button>
<button class="suggestion" onclick="sendSuggestion('Generate launch posts for X and LinkedIn')">Marketing posts</button>
<button class="suggestion" onclick="sendSuggestion('What drives my conversion rate?')">Conversion drivers</button>
</div>
</div>
`}
</div>
<div class="loading" id="loading">
<div class="loading-dots">
<div class="loading-dot"></div>
<div class="loading-dot"></div>
<div class="loading-dot"></div>
</div>
<span>Product OS is thinking...</span>
</div>
<div class="input-area">
<div class="context-badge" id="context-badge">
<span>📎 Context:</span>
<code id="context-path"></code>
<span class="context-close" onclick="clearContext()">✕</span>
</div>
<div class="input-wrapper">
<div class="input-container">
<textarea
id="input"
placeholder="Ask me to deploy, analyze, or generate..."
rows="1"
onkeydown="handleKeydown(event)"
oninput="autoResize(this)"
></textarea>
<button class="add-context-btn" onclick="addContext()" title="Add code context">@</button>
</div>
<button class="send-btn" id="send-btn" onclick="sendMessage()">→</button>
</div>
</div>
<script nonce="${nonce}">
const vscode = acquireVsCodeApi();
let currentContext = null;
function sendMessage() {
const input = document.getElementById('input');
const text = input.value.trim();
if (!text) return;
// Send to extension
vscode.postMessage({ command: 'send', text, context: currentContext });
// Clear input and context
input.value = '';
input.style.height = 'auto';
clearContext();
// Add user message to UI immediately
addMessageToUI('user', text);
}
function sendSuggestion(text) {
document.getElementById('input').value = text;
sendMessage();
}
function handleKeydown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}
function autoResize(textarea) {
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px';
}
function addContext() {
vscode.postMessage({ command: 'addContext' });
}
function clearContext() {
currentContext = null;
document.getElementById('context-badge').classList.remove('active');
}
function clearChat() {
vscode.postMessage({ command: 'clear' });
}
function addMessageToUI(role, content) {
const messages = document.getElementById('messages');
const isEmpty = messages.querySelector('.empty-state');
if (isEmpty) isEmpty.remove();
const isUser = role === 'user';
const div = document.createElement('div');
div.className = 'message ' + (isUser ? 'user-message' : 'ai-message');
div.innerHTML = \`
<div class="avatar \${isUser ? 'user-avatar' : 'ai-avatar'}">\${isUser ? 'U' : '✦'}</div>
<div class="content">\${escapeHtml(content)}</div>
\`;
messages.appendChild(div);
messages.scrollTop = messages.scrollHeight;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML.replace(/\\n/g, '<br>');
}
function formatMessage(text) {
// Simple markdown-like formatting
return text
.replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>')
.replace(/\`(.+?)\`/g, '<code>$1</code>')
.replace(/\\n/g, '<br>');
}
window.addEventListener('message', event => {
const message = event.data;
switch (message.type) {
case 'loading':
document.getElementById('loading').classList.toggle('active', message.loading);
document.getElementById('send-btn').disabled = message.loading;
break;
case 'response':
// Add AI response to UI
if (message.message) {
addMessageToUI('assistant', message.message);
}
// Show tool results if any
if (message.runs && message.runs.length > 0) {
const messages = document.getElementById('messages');
message.runs.forEach(run => {
const resultDiv = document.createElement('div');
resultDiv.className = 'tool-result';
const statusClass = run.status === 'succeeded' ? 'success' : 'error';
resultDiv.innerHTML = \`
<div class="tool-result-header">
<div class="tool-result-status \${statusClass}"></div>
<span>Executed <code>\${run.tool}</code></span>
</div>
\${run.output ? \`<div class="code-block"><pre>\${JSON.stringify(run.output, null, 2)}</pre></div>\` : ''}
\`;
messages.appendChild(resultDiv);
});
messages.scrollTop = messages.scrollHeight;
}
break;
case 'error':
addMessageToUI('assistant', '❌ Error: ' + message.error);
break;
case 'contextAdded':
currentContext = message.context;
document.getElementById('context-badge').classList.add('active');
document.getElementById('context-path').textContent = message.context.path;
break;
}
});
// Focus input on load
document.getElementById('input').focus();
</script>
</body>
</html>`;
}
}
function getNonce() {
let text = "";
const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
for (let i = 0; i < 32; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text;
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;")
.replace(/\n/g, "<br>");
}

View File

@@ -1,223 +0,0 @@
import * as vscode from "vscode";
import { getBackendUrl } from "./api";
/**
* Product OS Chat Participant
*
* Registers @productos in the native VS Code chat panel.
* Users can type "@productos deploy to staging" and get responses
* in the same UI as GitHub Copilot.
*/
// Chat response interface from Control Plane
interface ChatResponse {
message: string;
toolCalls?: { name: string; arguments: any }[];
runs?: any[];
finishReason: string;
}
/**
* Register the Product OS chat participant
*/
export function registerChatParticipant(context: vscode.ExtensionContext) {
// Create the chat participant
const participant = vscode.chat.createChatParticipant(
"productos.chat",
chatHandler
);
// Set participant properties
participant.iconPath = vscode.Uri.joinPath(context.extensionUri, "media", "icon.svg");
// Add follow-up provider for suggestions
participant.followupProvider = {
provideFollowups(
result: vscode.ChatResult,
context: vscode.ChatContext,
token: vscode.CancellationToken
): vscode.ProviderResult<vscode.ChatFollowup[]> {
// Suggest follow-up actions based on what was just done
return [
{ prompt: "Show me funnel analytics", label: "📊 Analytics" },
{ prompt: "Deploy to staging", label: "🚀 Deploy" },
{ prompt: "Generate marketing posts", label: "📣 Marketing" },
{ prompt: "What drives conversions?", label: "📈 Drivers" }
];
}
};
context.subscriptions.push(participant);
console.log("[Product OS] Chat participant @productos registered");
}
/**
* Handle chat requests
*/
async function chatHandler(
request: vscode.ChatRequest,
context: vscode.ChatContext,
response: vscode.ChatResponseStream,
token: vscode.CancellationToken
): Promise<vscode.ChatResult> {
const userPrompt = request.prompt;
console.log("[Product OS] Chat request:", userPrompt);
// Show progress
response.progress("Connecting to Product OS...");
try {
// Build message history from context
const messages = buildMessageHistory(context, userPrompt);
// Get code context if available
const codeContext = await getCodeContext();
// Call the Control Plane
const result = await callControlPlane(messages, codeContext);
// Handle tool calls
if (result.toolCalls && result.toolCalls.length > 0) {
for (const toolCall of result.toolCalls) {
response.markdown(`\n\n**🔧 Executing:** \`${toolCall.name}\`\n`);
}
}
// Handle runs (tool execution results)
if (result.runs && result.runs.length > 0) {
for (const run of result.runs) {
const status = run.status === "succeeded" ? "✅" : "❌";
response.markdown(`\n${status} **${run.tool}**\n`);
if (run.output) {
response.markdown("\n```json\n" + JSON.stringify(run.output, null, 2) + "\n```\n");
}
if (run.error) {
response.markdown(`\n**Error:** ${run.error.message}\n`);
}
}
}
// Stream the main response
if (result.message) {
response.markdown(result.message);
}
return { metadata: { command: "chat" } };
} catch (error: any) {
console.error("[Product OS] Chat error:", error);
response.markdown(`\n\n❌ **Error:** ${error.message}\n\n`);
response.markdown("Make sure the Control Plane is running at " + getBackendUrl());
return {
metadata: { command: "error" },
errorDetails: { message: error.message }
};
}
}
/**
* Build message history from chat context
*/
function buildMessageHistory(
context: vscode.ChatContext,
currentPrompt: string
): { role: string; content: string }[] {
const messages: { role: string; content: string }[] = [];
// Add previous messages from context
for (const turn of context.history) {
if (turn instanceof vscode.ChatRequestTurn) {
messages.push({ role: "user", content: turn.prompt });
} else if (turn instanceof vscode.ChatResponseTurn) {
// Extract text from response
let text = "";
for (const part of turn.response) {
if (part instanceof vscode.ChatResponseMarkdownPart) {
text += part.value.value;
}
}
if (text) {
messages.push({ role: "assistant", content: text });
}
}
}
// Add current prompt
messages.push({ role: "user", content: currentPrompt });
return messages;
}
/**
* Get context from the active editor
*/
async function getCodeContext(): Promise<any | undefined> {
const editor = vscode.window.activeTextEditor;
if (!editor) return undefined;
const selection = editor.selection;
const selectedText = editor.document.getText(selection);
if (selectedText) {
return {
selection: {
path: vscode.workspace.asRelativePath(editor.document.uri),
text: selectedText,
startLine: selection.start.line + 1
}
};
}
// No selection - include some context from the current file
const document = editor.document;
const cursorLine = selection.active.line;
const startLine = Math.max(0, cursorLine - 20);
const endLine = Math.min(document.lineCount - 1, cursorLine + 20);
const range = new vscode.Range(startLine, 0, endLine, document.lineAt(endLine).text.length);
const surroundingCode = document.getText(range);
if (surroundingCode.trim()) {
return {
files: [{
path: vscode.workspace.asRelativePath(editor.document.uri),
content: surroundingCode
}]
};
}
return undefined;
}
/**
* Call the Control Plane chat endpoint
*/
async function callControlPlane(
messages: { role: string; content: string }[],
context?: any
): Promise<ChatResponse> {
const backendUrl = getBackendUrl();
const response = await fetch(`${backendUrl}/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages,
context,
autoExecuteTools: true
})
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Control Plane error: ${response.status} - ${text}`);
}
return response.json();
}

View File

@@ -1,688 +0,0 @@
import * as vscode from "vscode";
import { chatWithAI, ChatMessage, ChatResponse } from "./api";
/**
* Sidebar Chat View Provider
* Embedded chat experience in the Product OS sidebar
*/
export class ChatViewProvider implements vscode.WebviewViewProvider {
public static readonly viewType = "productos.chat";
private _view?: vscode.WebviewView;
private _messages: ChatMessage[] = [];
constructor(private readonly _extensionUri: vscode.Uri) {}
public resolveWebviewView(
webviewView: vscode.WebviewView,
_context: vscode.WebviewViewResolveContext,
_token: vscode.CancellationToken
) {
this._view = webviewView;
webviewView.webview.options = {
enableScripts: true,
localResourceRoots: [this._extensionUri]
};
webviewView.webview.html = this._getHtmlForWebview(webviewView.webview);
webviewView.webview.onDidReceiveMessage(async (message) => {
switch (message.command) {
case "send":
await this._handleChat(message.text);
return;
case "addContext":
await this._handleAddContext();
return;
case "clear":
this._messages = [];
this._updateView();
return;
}
});
}
private async _handleChat(text: string) {
if (!this._view) return;
// Add user message to internal history (webview already shows it)
this._messages.push({ role: "user", content: text });
// DON'T call _updateView() - it would reset the webview and kill the JS state
// Show loading
this._view.webview.postMessage({ type: "loading", loading: true });
try {
// Get editor context
const context = this._getEditorContext();
console.log("[Product OS Chat] Sending to API:", text);
// Call AI
const response = await chatWithAI(this._messages, context);
console.log("[Product OS Chat] Response:", response);
// Add assistant response to history
this._messages.push({ role: "assistant", content: response.message || "" });
// Send to webview
this._view.webview.postMessage({
type: "response",
message: response.message,
toolCalls: response.toolCalls,
runs: response.runs
});
} catch (error: any) {
console.error("[Product OS Chat] Error:", error);
this._view.webview.postMessage({
type: "error",
error: error.message || "Unknown error"
});
} finally {
this._view.webview.postMessage({ type: "loading", loading: false });
}
}
private async _handleAddContext() {
if (!this._view) return;
const editor = vscode.window.activeTextEditor;
if (!editor) {
vscode.window.showWarningMessage("No active editor");
return;
}
const selection = editor.selection;
const selectedText = editor.document.getText(selection);
if (selectedText) {
const filePath = vscode.workspace.asRelativePath(editor.document.uri);
this._view.webview.postMessage({
type: "contextAdded",
context: {
type: "selection",
path: filePath,
startLine: selection.start.line + 1,
text: selectedText
}
});
} else {
const filePath = vscode.workspace.asRelativePath(editor.document.uri);
const content = editor.document.getText();
this._view.webview.postMessage({
type: "contextAdded",
context: {
type: "file",
path: filePath,
text: content.substring(0, 5000)
}
});
}
}
private _getEditorContext(): any {
const editor = vscode.window.activeTextEditor;
if (!editor) return undefined;
const selection = editor.selection;
const selectedText = editor.document.getText(selection);
if (selectedText) {
return {
selection: {
path: vscode.workspace.asRelativePath(editor.document.uri),
text: selectedText,
startLine: selection.start.line + 1
}
};
}
return undefined;
}
private _updateView() {
if (this._view) {
this._view.webview.html = this._getHtmlForWebview(this._view.webview);
}
}
private _getHtmlForWebview(webview: vscode.Webview) {
const nonce = getNonce();
const messagesHtml = this._messages
.map((m) => {
const isUser = m.role === "user";
const avatarClass = isUser ? "user-avatar" : "ai-avatar";
const messageClass = isUser ? "user-message" : "ai-message";
const avatar = isUser ? "U" : "✦";
return `
<div class="message ${messageClass}">
<div class="avatar ${avatarClass}">${avatar}</div>
<div class="content">${escapeHtml(m.content)}</div>
</div>
`;
})
.join("");
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}';">
<title>Product OS Chat</title>
<style>
:root {
--bg-primary: var(--vscode-editor-background);
--bg-secondary: var(--vscode-sideBar-background);
--bg-tertiary: var(--vscode-input-background);
--accent: var(--vscode-focusBorder);
--text-primary: var(--vscode-foreground);
--text-secondary: var(--vscode-descriptionForeground);
--border: var(--vscode-panel-border);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
background: var(--bg-secondary);
color: var(--text-primary);
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.message {
display: flex;
gap: 8px;
animation: fadeIn 0.15s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.avatar {
width: 24px;
height: 24px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
flex-shrink: 0;
}
.user-avatar {
background: var(--vscode-badge-background);
color: var(--vscode-badge-foreground);
}
.ai-avatar {
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
}
.content {
flex: 1;
padding: 8px 12px;
border-radius: 8px;
line-height: 1.5;
font-size: 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
}
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 20px;
}
.empty-icon {
width: 40px;
height: 40px;
background: var(--vscode-button-background);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
margin-bottom: 12px;
}
.empty-state h2 {
font-size: 14px;
font-weight: 600;
margin-bottom: 6px;
}
.empty-state p {
color: var(--text-secondary);
font-size: 11px;
line-height: 1.5;
}
.suggestions {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 16px;
width: 100%;
}
.suggestion {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 12px;
font-size: 11px;
color: var(--text-secondary);
cursor: pointer;
text-align: left;
transition: all 0.1s ease;
}
.suggestion:hover {
background: var(--vscode-list-hoverBackground);
color: var(--text-primary);
}
.input-area {
padding: 12px;
border-top: 1px solid var(--border);
}
.context-badge {
display: none;
align-items: center;
gap: 6px;
padding: 6px 8px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 8px;
font-size: 10px;
color: var(--text-secondary);
}
.context-badge.active {
display: flex;
}
.context-badge code {
color: var(--accent);
}
.context-close {
margin-left: auto;
cursor: pointer;
opacity: 0.6;
}
.context-close:hover {
opacity: 1;
}
.input-wrapper {
display: flex;
gap: 8px;
align-items: flex-end;
}
.input-container {
flex: 1;
position: relative;
}
textarea {
width: 100%;
min-height: 36px;
max-height: 120px;
padding: 8px 32px 8px 10px;
background: var(--vscode-input-background);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 12px;
font-family: inherit;
resize: none;
outline: none;
}
textarea:focus {
border-color: var(--accent);
}
textarea::placeholder {
color: var(--text-secondary);
}
.add-context-btn {
position: absolute;
right: 8px;
bottom: 8px;
width: 20px;
height: 20px;
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
border-radius: 4px;
}
.add-context-btn:hover {
background: var(--vscode-list-hoverBackground);
color: var(--text-primary);
}
.send-btn {
width: 36px;
height: 36px;
background: var(--vscode-button-background);
border: none;
border-radius: 6px;
color: var(--vscode-button-foreground);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
}
.send-btn:hover {
background: var(--vscode-button-hoverBackground);
}
.send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.loading {
display: none;
align-items: center;
gap: 6px;
color: var(--text-secondary);
font-size: 11px;
padding: 6px 0;
}
.loading.active {
display: flex;
}
.loading-dots {
display: flex;
gap: 3px;
}
.loading-dot {
width: 4px;
height: 4px;
background: var(--accent);
border-radius: 50%;
animation: pulse 1.4s infinite;
}
.loading-dot:nth-child(2) { animation-delay: 0.2s; }
.loading-dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes pulse {
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1); }
}
.tool-result {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px;
margin-top: 8px;
font-size: 11px;
}
.tool-result-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
color: var(--text-secondary);
}
.tool-result-status {
width: 6px;
height: 6px;
border-radius: 50%;
}
.tool-result-status.success { background: var(--vscode-testing-iconPassed); }
.tool-result-status.error { background: var(--vscode-testing-iconFailed); }
.code-block {
background: var(--vscode-editor-background);
border: 1px solid var(--border);
border-radius: 4px;
padding: 8px;
margin-top: 6px;
overflow-x: auto;
}
.code-block pre {
font-family: var(--vscode-editor-font-family);
font-size: 10px;
line-height: 1.4;
}
</style>
</head>
<body>
<div class="messages" id="messages">
${
messagesHtml ||
`
<div class="empty-state">
<div class="empty-icon">✦</div>
<h2>Product OS Chat</h2>
<p>Deploy, analyze, and create with AI.</p>
<div class="suggestions">
<button class="suggestion" onclick="sendSuggestion('Deploy to staging')">🚀 Deploy to staging</button>
<button class="suggestion" onclick="sendSuggestion('Show funnel analytics')">📊 Funnel analytics</button>
<button class="suggestion" onclick="sendSuggestion('Generate marketing posts')">📣 Marketing posts</button>
</div>
</div>
`
}
</div>
<div class="loading" id="loading">
<div class="loading-dots">
<div class="loading-dot"></div>
<div class="loading-dot"></div>
<div class="loading-dot"></div>
</div>
<span>Thinking...</span>
</div>
<div class="input-area">
<div class="context-badge" id="context-badge">
<span>📎</span>
<code id="context-path"></code>
<span class="context-close" onclick="clearContext()">✕</span>
</div>
<div class="input-wrapper">
<div class="input-container">
<textarea
id="input"
placeholder="Ask anything..."
rows="1"
onkeydown="handleKeydown(event)"
oninput="autoResize(this)"
></textarea>
<button class="add-context-btn" onclick="addContext()" title="Add context">@</button>
</div>
<button class="send-btn" id="send-btn" onclick="sendMessage()">→</button>
</div>
</div>
<script nonce="${nonce}">
const vscode = acquireVsCodeApi();
let currentContext = null;
function sendMessage() {
const input = document.getElementById('input');
const text = input.value.trim();
if (!text) return;
vscode.postMessage({ command: 'send', text, context: currentContext });
input.value = '';
input.style.height = 'auto';
clearContext();
addMessageToUI('user', text);
}
function sendSuggestion(text) {
document.getElementById('input').value = text;
sendMessage();
}
function handleKeydown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}
function autoResize(textarea) {
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
}
function addContext() {
vscode.postMessage({ command: 'addContext' });
}
function clearContext() {
currentContext = null;
document.getElementById('context-badge').classList.remove('active');
}
function addMessageToUI(role, content) {
const messages = document.getElementById('messages');
const isEmpty = messages.querySelector('.empty-state');
if (isEmpty) isEmpty.remove();
const isUser = role === 'user';
const div = document.createElement('div');
div.className = 'message ' + (isUser ? 'user-message' : 'ai-message');
div.innerHTML = \`
<div class="avatar \${isUser ? 'user-avatar' : 'ai-avatar'}">\${isUser ? 'U' : '✦'}</div>
<div class="content">\${escapeHtml(content)}</div>
\`;
messages.appendChild(div);
messages.scrollTop = messages.scrollHeight;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML.replace(/\\n/g, '<br>');
}
window.addEventListener('message', event => {
const message = event.data;
switch (message.type) {
case 'loading':
document.getElementById('loading').classList.toggle('active', message.loading);
document.getElementById('send-btn').disabled = message.loading;
break;
case 'response':
if (message.message) {
addMessageToUI('assistant', message.message);
}
if (message.runs && message.runs.length > 0) {
const messages = document.getElementById('messages');
message.runs.forEach(run => {
const resultDiv = document.createElement('div');
resultDiv.className = 'tool-result';
const statusClass = run.status === 'succeeded' ? 'success' : 'error';
resultDiv.innerHTML = \`
<div class="tool-result-header">
<div class="tool-result-status \${statusClass}"></div>
<span>\${run.tool}</span>
</div>
\${run.output ? \`<div class="code-block"><pre>\${JSON.stringify(run.output, null, 2)}</pre></div>\` : ''}
\`;
messages.appendChild(resultDiv);
});
messages.scrollTop = messages.scrollHeight;
}
break;
case 'error':
addMessageToUI('assistant', '❌ ' + message.error);
break;
case 'contextAdded':
currentContext = message.context;
document.getElementById('context-badge').classList.add('active');
document.getElementById('context-path').textContent = message.context.path;
break;
}
});
document.getElementById('input').focus();
</script>
</body>
</html>`;
}
}
function getNonce() {
let text = "";
const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
for (let i = 0; i < 32; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text;
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;")
.replace(/\n/g, "<br>");
}

View File

@@ -1,176 +0,0 @@
import * as vscode from "vscode";
import { listTools, invokeTool, getRun, addRecentRun, checkConnection } from "./api";
import { ToolsTreeProvider, ToolItem } from "./toolsTreeView";
import { RunsTreeProvider, RunItem } from "./runsTreeView";
import { createStatusBar, updateConnectionStatus, dispose as disposeStatusBar } from "./statusBar";
import { InvokePanel } from "./invokePanel";
import { showJson, openRun, showRunDocument } from "./ui";
import { registerChatParticipant } from "./chatParticipant";
export function activate(context: vscode.ExtensionContext) {
console.log("Product OS extension activated");
// Register @productos in the native VS Code chat
// This gives us the Copilot-like chat experience for FREE
try {
registerChatParticipant(context);
} catch (e) {
console.log("[Product OS] Chat Participant API not available (requires VS Code 1.90+)");
}
// Create tree providers
const toolsProvider = new ToolsTreeProvider();
const runsProvider = new RunsTreeProvider();
// Register tree views
vscode.window.registerTreeDataProvider("productos.tools", toolsProvider);
vscode.window.registerTreeDataProvider("productos.runs", runsProvider);
// Create status bar
createStatusBar(context);
// Load tools on startup
toolsProvider.loadTools();
// === COMMANDS ===
// Configure backend URL
context.subscriptions.push(
vscode.commands.registerCommand("productos.configure", async () => {
const currentUrl = vscode.workspace.getConfiguration("productos").get("backendUrl");
const backendUrl = await vscode.window.showInputBox({
prompt: "Control Plane backend URL",
value: currentUrl as string,
placeHolder: "http://localhost:8080"
});
if (!backendUrl) return;
await vscode.workspace.getConfiguration("productos").update(
"backendUrl",
backendUrl,
vscode.ConfigurationTarget.Global
);
vscode.window.showInformationMessage(`Product OS backend set: ${backendUrl}`);
updateConnectionStatus();
toolsProvider.loadTools();
})
);
// Refresh tools
context.subscriptions.push(
vscode.commands.registerCommand("productos.refresh", async () => {
await toolsProvider.loadTools();
runsProvider.refresh();
updateConnectionStatus();
vscode.window.showInformationMessage("Product OS refreshed");
})
);
// List tools (JSON view)
context.subscriptions.push(
vscode.commands.registerCommand("productos.tools.list", async () => {
try {
const tools = await listTools();
await showJson("Tools", tools);
} catch (e: any) {
vscode.window.showErrorMessage(`Failed to list tools: ${e.message}`);
}
})
);
// Invoke tool (quick pick)
context.subscriptions.push(
vscode.commands.registerCommand("productos.tools.invoke", async () => {
try {
const tools = await listTools();
if (tools.length === 0) {
vscode.window.showWarningMessage("No tools available");
return;
}
const pick = await vscode.window.showQuickPick(
tools.map(t => ({
label: t.name,
description: `[${t.risk}] ${t.description}`,
tool: t
})),
{ placeHolder: "Select a tool to invoke" }
);
if (!pick) return;
// Open invoke panel
InvokePanel.createOrShow(
context.extensionUri,
pick.tool,
() => runsProvider.refresh()
);
} catch (e: any) {
vscode.window.showErrorMessage(`Failed: ${e.message}`);
}
})
);
// Invoke from tree view
context.subscriptions.push(
vscode.commands.registerCommand("productos.tools.invokeFromTree", async (item: ToolItem) => {
if (!item?.tool) {
// No item passed, show quick pick
vscode.commands.executeCommand("productos.tools.invoke");
return;
}
InvokePanel.createOrShow(
context.extensionUri,
item.tool,
() => runsProvider.refresh()
);
})
);
// Open run by ID
context.subscriptions.push(
vscode.commands.registerCommand("productos.runs.open", async () => {
const runId = await vscode.window.showInputBox({
prompt: "Enter Run ID",
placeHolder: "run_20240101..."
});
if (!runId) return;
try {
await openRun(runId);
} catch (e: any) {
vscode.window.showErrorMessage(`Failed to open run: ${e.message}`);
}
})
);
// Open run from tree view
context.subscriptions.push(
vscode.commands.registerCommand("productos.runs.openFromTree", async (item: RunItem) => {
if (!item?.run) return;
try {
const fullRun = await getRun(item.run.run_id);
await showRunDocument(fullRun);
} catch (e: any) {
vscode.window.showErrorMessage(`Failed to open run: ${e.message}`);
}
})
);
// Watch for config changes
context.subscriptions.push(
vscode.workspace.onDidChangeConfiguration(e => {
if (e.affectsConfiguration("productos")) {
updateConnectionStatus();
toolsProvider.loadTools();
}
})
);
}
export function deactivate() {
disposeStatusBar();
}

View File

@@ -1,373 +0,0 @@
import * as vscode from "vscode";
import { Tool, invokeTool, getRun, addRecentRun } from "./api";
export class InvokePanel {
public static currentPanel: InvokePanel | undefined;
private readonly _panel: vscode.WebviewPanel;
private readonly _extensionUri: vscode.Uri;
private _tool: Tool;
private _disposables: vscode.Disposable[] = [];
private _onRunComplete: () => void;
public static createOrShow(
extensionUri: vscode.Uri,
tool: Tool,
onRunComplete: () => void
) {
const column = vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.One;
if (InvokePanel.currentPanel) {
InvokePanel.currentPanel._tool = tool;
InvokePanel.currentPanel._onRunComplete = onRunComplete;
InvokePanel.currentPanel._update();
InvokePanel.currentPanel._panel.reveal(column);
return;
}
const panel = vscode.window.createWebviewPanel(
"productosInvoke",
`Invoke: ${tool.name}`,
column,
{
enableScripts: true,
retainContextWhenHidden: true
}
);
InvokePanel.currentPanel = new InvokePanel(panel, extensionUri, tool, onRunComplete);
}
private constructor(
panel: vscode.WebviewPanel,
extensionUri: vscode.Uri,
tool: Tool,
onRunComplete: () => void
) {
this._panel = panel;
this._extensionUri = extensionUri;
this._tool = tool;
this._onRunComplete = onRunComplete;
this._update();
this._panel.onDidDispose(() => this.dispose(), null, this._disposables);
this._panel.webview.onDidReceiveMessage(
async (message) => {
switch (message.command) {
case "invoke":
await this._handleInvoke(message.input, message.dryRun);
break;
case "close":
this._panel.dispose();
break;
}
},
null,
this._disposables
);
}
private async _handleInvoke(inputText: string, dryRun: boolean) {
try {
const input = JSON.parse(inputText);
this._panel.webview.postMessage({ command: "invoking" });
const result = await invokeTool(this._tool.name, input, dryRun);
// Fetch full run details
const fullRun = await getRun(result.run_id);
addRecentRun(fullRun);
this._onRunComplete();
this._panel.webview.postMessage({
command: "result",
run: fullRun
});
} catch (e: any) {
this._panel.webview.postMessage({
command: "error",
message: e.message
});
}
}
private _update() {
this._panel.title = `Invoke: ${this._tool.name}`;
this._panel.webview.html = this._getHtml();
}
private _getHtml(): string {
const tool = this._tool;
const schemaStr = JSON.stringify(tool.inputSchema, null, 2);
const defaultInput = this._generateDefaultInput(tool.inputSchema);
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Invoke ${tool.name}</title>
<style>
* { box-sizing: border-box; }
body {
font-family: var(--vscode-font-family);
padding: 20px;
color: var(--vscode-foreground);
background: var(--vscode-editor-background);
}
h1 {
font-size: 1.5em;
margin: 0 0 5px 0;
display: flex;
align-items: center;
gap: 10px;
}
.risk {
font-size: 0.7em;
padding: 3px 8px;
border-radius: 4px;
font-weight: normal;
}
.risk-low { background: #2ea043; color: white; }
.risk-medium { background: #d29922; color: black; }
.risk-high { background: #f85149; color: white; }
.description {
color: var(--vscode-descriptionForeground);
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
textarea {
width: 100%;
min-height: 200px;
padding: 10px;
font-family: var(--vscode-editor-font-family);
font-size: var(--vscode-editor-font-size);
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
border: 1px solid var(--vscode-input-border);
border-radius: 4px;
resize: vertical;
}
textarea:focus {
outline: 1px solid var(--vscode-focusBorder);
}
.buttons {
margin-top: 15px;
display: flex;
gap: 10px;
}
button {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.btn-primary {
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
}
.btn-primary:hover {
background: var(--vscode-button-hoverBackground);
}
.btn-secondary {
background: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
font-weight: normal;
margin-left: auto;
}
.schema {
margin-top: 20px;
padding: 10px;
background: var(--vscode-textBlockQuote-background);
border-radius: 4px;
font-size: 0.9em;
}
.schema summary {
cursor: pointer;
font-weight: bold;
}
.schema pre {
margin: 10px 0 0 0;
white-space: pre-wrap;
}
.result {
margin-top: 20px;
padding: 15px;
border-radius: 4px;
}
.result-success {
background: var(--vscode-inputValidation-infoBackground);
border: 1px solid var(--vscode-inputValidation-infoBorder);
}
.result-error {
background: var(--vscode-inputValidation-errorBackground);
border: 1px solid var(--vscode-inputValidation-errorBorder);
}
.result h3 { margin: 0 0 10px 0; }
.result pre {
background: var(--vscode-editor-background);
padding: 10px;
border-radius: 4px;
overflow-x: auto;
}
.spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid var(--vscode-foreground);
border-top-color: transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 8px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.hidden { display: none; }
</style>
</head>
<body>
<h1>
${tool.name}
<span class="risk risk-${tool.risk}">${tool.risk} risk</span>
</h1>
<p class="description">${tool.description}</p>
<label for="input">Input JSON</label>
<textarea id="input">${defaultInput}</textarea>
<div class="buttons">
<button class="btn-primary" id="invokeBtn" onclick="invoke(false)">
<span id="invokeText">▶ Invoke</span>
<span id="invokingText" class="hidden"><span class="spinner"></span>Invoking...</span>
</button>
<button class="btn-secondary" onclick="invoke(true)">🧪 Dry Run</button>
<label class="checkbox-label">
<input type="checkbox" id="autoFormat" checked>
Auto-format JSON
</label>
</div>
<div id="result"></div>
<details class="schema">
<summary>Input Schema</summary>
<pre>${schemaStr}</pre>
</details>
<script>
const vscode = acquireVsCodeApi();
const inputEl = document.getElementById('input');
const invokeBtn = document.getElementById('invokeBtn');
const invokeText = document.getElementById('invokeText');
const invokingText = document.getElementById('invokingText');
const resultEl = document.getElementById('result');
inputEl.addEventListener('blur', () => {
if (document.getElementById('autoFormat').checked) {
try {
const parsed = JSON.parse(inputEl.value);
inputEl.value = JSON.stringify(parsed, null, 2);
} catch {}
}
});
function invoke(dryRun) {
vscode.postMessage({
command: 'invoke',
input: inputEl.value,
dryRun
});
}
window.addEventListener('message', event => {
const message = event.data;
switch (message.command) {
case 'invoking':
invokeBtn.disabled = true;
invokeText.classList.add('hidden');
invokingText.classList.remove('hidden');
resultEl.innerHTML = '';
break;
case 'result':
invokeBtn.disabled = false;
invokeText.classList.remove('hidden');
invokingText.classList.add('hidden');
const run = message.run;
const statusEmoji = run.status === 'succeeded' ? '✅' : run.status === 'failed' ? '❌' : '🔄';
resultEl.innerHTML = \`
<div class="result result-success">
<h3>\${statusEmoji} Run \${run.status}</h3>
<p><strong>Run ID:</strong> \${run.run_id}</p>
<h4>Output:</h4>
<pre>\${JSON.stringify(run.output || run.error || {}, null, 2)}</pre>
</div>
\`;
break;
case 'error':
invokeBtn.disabled = false;
invokeText.classList.remove('hidden');
invokingText.classList.add('hidden');
resultEl.innerHTML = \`
<div class="result result-error">
<h3>❌ Error</h3>
<pre>\${message.message}</pre>
</div>
\`;
break;
}
});
</script>
</body>
</html>`;
}
private _generateDefaultInput(schema: any): string {
if (!schema || schema.type !== "object") return "{}";
const obj: any = {};
const props = schema.properties || {};
const required = schema.required || [];
for (const key of required) {
const prop = props[key];
if (!prop) continue;
if (prop.type === "string") {
obj[key] = prop.enum ? prop.enum[0] : "";
} else if (prop.type === "integer" || prop.type === "number") {
obj[key] = prop.minimum ?? 0;
} else if (prop.type === "boolean") {
obj[key] = false;
} else if (prop.type === "array") {
obj[key] = [];
} else if (prop.type === "object") {
obj[key] = {};
}
}
return JSON.stringify(obj, null, 2);
}
public dispose() {
InvokePanel.currentPanel = undefined;
this._panel.dispose();
while (this._disposables.length) {
const d = this._disposables.pop();
if (d) d.dispose();
}
}
}

View File

@@ -1,54 +0,0 @@
import * as vscode from "vscode";
import { Run, getRecentRuns } from "./api";
export class RunsTreeProvider implements vscode.TreeDataProvider<RunItem> {
private _onDidChangeTreeData = new vscode.EventEmitter<RunItem | undefined>();
readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
refresh(): void {
this._onDidChangeTreeData.fire(undefined);
}
getTreeItem(element: RunItem): vscode.TreeItem {
return element;
}
getChildren(element?: RunItem): RunItem[] {
if (element) return [];
return getRecentRuns().map(run => new RunItem(run));
}
}
export class RunItem extends vscode.TreeItem {
constructor(public readonly run: Run) {
super(run.tool, vscode.TreeItemCollapsibleState.None);
const statusIcon = run.status === "succeeded" ? "✅" :
run.status === "failed" ? "❌" :
run.status === "running" ? "🔄" : "⏳";
this.description = `${statusIcon} ${run.status}`;
const time = new Date(run.created_at).toLocaleTimeString();
this.tooltip = new vscode.MarkdownString(
`**${run.tool}**\n\n` +
`Status: ${run.status}\n\n` +
`Run ID: \`${run.run_id}\`\n\n` +
`Time: ${time}`
);
this.contextValue = "run";
// Icon based on status
const iconId = run.status === "succeeded" ? "pass" :
run.status === "failed" ? "error" :
run.status === "running" ? "sync~spin" : "clock";
this.iconPath = new vscode.ThemeIcon(iconId);
this.command = {
command: "productos.runs.openFromTree",
title: "View Run",
arguments: [this]
};
}
}

View File

@@ -1,41 +0,0 @@
import * as vscode from "vscode";
import { checkConnection, getBackendUrl } from "./api";
let statusBarItem: vscode.StatusBarItem;
let checkInterval: NodeJS.Timeout | undefined;
export function createStatusBar(context: vscode.ExtensionContext): vscode.StatusBarItem {
statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100);
statusBarItem.command = "productos.configure";
statusBarItem.text = "$(cloud) Product OS";
statusBarItem.tooltip = "Click to configure Product OS";
statusBarItem.show();
context.subscriptions.push(statusBarItem);
// Check connection periodically
updateConnectionStatus();
checkInterval = setInterval(updateConnectionStatus, 30000);
return statusBarItem;
}
export async function updateConnectionStatus(): Promise<void> {
const connected = await checkConnection();
if (connected) {
statusBarItem.text = "$(cloud) Product OS";
statusBarItem.backgroundColor = undefined;
statusBarItem.tooltip = `Connected to ${getBackendUrl()}`;
} else {
statusBarItem.text = "$(cloud-offline) Product OS";
statusBarItem.backgroundColor = new vscode.ThemeColor("statusBarItem.errorBackground");
statusBarItem.tooltip = `Disconnected - Click to configure`;
}
}
export function dispose(): void {
if (checkInterval) {
clearInterval(checkInterval);
}
}

View File

@@ -1,63 +0,0 @@
import * as vscode from "vscode";
import { Tool, listTools } from "./api";
export class ToolsTreeProvider implements vscode.TreeDataProvider<ToolItem> {
private _onDidChangeTreeData = new vscode.EventEmitter<ToolItem | undefined>();
readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
private tools: Tool[] = [];
refresh(): void {
this._onDidChangeTreeData.fire(undefined);
}
async loadTools(): Promise<void> {
try {
this.tools = await listTools();
this.refresh();
} catch (e) {
this.tools = [];
this.refresh();
}
}
getTreeItem(element: ToolItem): vscode.TreeItem {
return element;
}
async getChildren(element?: ToolItem): Promise<ToolItem[]> {
if (element) return [];
if (this.tools.length === 0) {
await this.loadTools();
}
return this.tools.map(tool => new ToolItem(tool));
}
}
export class ToolItem extends vscode.TreeItem {
constructor(public readonly tool: Tool) {
super(tool.name, vscode.TreeItemCollapsibleState.None);
this.description = tool.description;
this.tooltip = new vscode.MarkdownString(
`**${tool.name}**\n\n${tool.description}\n\n` +
`Risk: \`${tool.risk}\`\n\n` +
`Executor: \`${tool.executor.url}${tool.executor.path}\``
);
this.contextValue = "tool";
// Icon based on risk level
const iconColor = tool.risk === "high" ? "red" : tool.risk === "medium" ? "orange" : "green";
this.iconPath = new vscode.ThemeIcon("symbol-method", new vscode.ThemeColor(`charts.${iconColor}`));
// Click to invoke
this.command = {
command: "productos.tools.invokeFromTree",
title: "Invoke Tool",
arguments: [this]
};
}
}

View File

@@ -1,40 +0,0 @@
import * as vscode from "vscode";
import { getRun, Run } from "./api";
export async function showJson(title: string, obj: any) {
const doc = await vscode.workspace.openTextDocument({
content: JSON.stringify(obj, null, 2),
language: "json"
});
await vscode.window.showTextDocument(doc, { preview: false });
vscode.window.setStatusBarMessage(title, 3000);
}
export async function openRun(runId: string) {
const run = await getRun(runId);
await showRunDocument(run);
}
export async function showRunDocument(run: Run) {
const statusEmoji = run.status === "succeeded" ? "✅" :
run.status === "failed" ? "❌" :
run.status === "running" ? "🔄" : "⏳";
const content = `// Run: ${run.run_id}
// Tool: ${run.tool}
// Status: ${statusEmoji} ${run.status}
// Created: ${new Date(run.created_at).toLocaleString()}
// === INPUT ===
${JSON.stringify(run.input, null, 2)}
// === OUTPUT ===
${JSON.stringify(run.output ?? run.error ?? null, null, 2)}
`;
const doc = await vscode.workspace.openTextDocument({
content,
language: "jsonc"
});
await vscode.window.showTextDocument(doc, { preview: false });
}

View File

@@ -1,11 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}

View File

@@ -1,398 +0,0 @@
version: 1
tools:
# ----------------------------
# CODE / DEPLOYMENT
# ----------------------------
cloudrun.deploy_service:
description: Build and deploy a Cloud Run service using Cloud Build. Returns the service URL and deployed revision.
risk: medium
executor:
kind: http
url: https://deploy-executor-REPLACE.a.run.app
path: /execute/cloudrun/deploy
inputSchema:
type: object
additionalProperties: false
required: [service_name, repo, ref, env, region]
properties:
service_name:
type: string
minLength: 1
description: Cloud Run service name.
repo:
type: string
minLength: 1
description: Git repo URL (HTTPS).
ref:
type: string
minLength: 1
description: Git ref (branch/tag/SHA).
env:
type: string
enum: [dev, staging, prod]
region:
type: string
minLength: 1
description: GCP region for the Cloud Run service (e.g., us-central1).
outputSchema:
type: object
properties:
service_url:
type: string
revision:
type: string
cloudrun.get_service_status:
description: Fetch Cloud Run service status including latest revision and URL.
risk: low
executor:
kind: http
url: https://deploy-executor-REPLACE.a.run.app
path: /execute/cloudrun/status
inputSchema:
type: object
additionalProperties: false
required: [service_name, region]
properties:
service_name:
type: string
minLength: 1
region:
type: string
minLength: 1
outputSchema:
type: object
properties:
service_name:
type: string
region:
type: string
service_url:
type: string
latest_ready_revision:
type: string
status:
type: string
enum: [ready, deploying, error, unknown]
logs.tail:
description: Tail recent logs for a Cloud Run service or for a specific run_id.
risk: low
executor:
kind: http
url: https://observability-executor-REPLACE.a.run.app
path: /execute/logs/tail
inputSchema:
type: object
additionalProperties: false
required: [scope, limit]
properties:
scope:
type: string
enum: [service, run]
service_name:
type: string
region:
type: string
run_id:
type: string
limit:
type: integer
minimum: 1
maximum: 2000
default: 200
outputSchema:
type: object
properties:
lines:
type: array
items:
type: object
properties:
timestamp:
type: string
severity:
type: string
text:
type: string
# ----------------------------
# COMPANY BRAIN (BRAND + STYLE)
# ----------------------------
brand.get_profile:
description: Retrieve the tenant's brand profile (voice, tone, positioning, compliance constraints).
risk: low
executor:
kind: http
url: https://firestore-executor-REPLACE.a.run.app
path: /execute/brand/get_profile
inputSchema:
type: object
additionalProperties: false
required: [profile_id]
properties:
profile_id:
type: string
minLength: 1
description: Brand profile identifier (e.g., "default").
outputSchema:
type: object
properties:
profile_id:
type: string
brand:
type: object
brand.update_profile:
description: Update the tenant's brand profile. Write operations should be validated and audited.
risk: medium
executor:
kind: http
url: https://firestore-executor-REPLACE.a.run.app
path: /execute/brand/update_profile
inputSchema:
type: object
additionalProperties: false
required: [profile_id, patch]
properties:
profile_id:
type: string
minLength: 1
patch:
type: object
description: Partial update object; executor must validate allowed fields.
outputSchema:
type: object
properties:
ok:
type: boolean
updated_at:
type: string
# ----------------------------
# ANALYTICS / CAUSATION
# ----------------------------
analytics.funnel_summary:
description: Return funnel metrics for a time window. Uses curated events in BigQuery.
risk: low
executor:
kind: http
url: https://analytics-executor-REPLACE.a.run.app
path: /execute/analytics/funnel_summary
inputSchema:
type: object
additionalProperties: false
required: [range_days, funnel]
properties:
range_days:
type: integer
minimum: 1
maximum: 365
funnel:
type: object
required: [name, steps]
properties:
name:
type: string
steps:
type: array
minItems: 2
items:
type: object
required: [event_name]
properties:
event_name:
type: string
outputSchema:
type: object
properties:
funnel_name:
type: string
range_days:
type: integer
steps:
type: array
analytics.top_drivers:
description: Identify top correlated drivers for a target metric/event.
risk: low
executor:
kind: http
url: https://analytics-executor-REPLACE.a.run.app
path: /execute/analytics/top_drivers
inputSchema:
type: object
additionalProperties: false
required: [range_days, target]
properties:
range_days:
type: integer
minimum: 1
maximum: 365
target:
type: object
required: [metric]
properties:
metric:
type: string
event_name:
type: string
outputSchema:
type: object
properties:
target:
type: object
range_days:
type: integer
drivers:
type: array
analytics.write_insight:
description: Persist an insight object (BigQuery table + Firestore pointer + GCS artifact).
risk: medium
executor:
kind: http
url: https://analytics-executor-REPLACE.a.run.app
path: /execute/analytics/write_insight
inputSchema:
type: object
additionalProperties: false
required: [insight]
properties:
insight:
type: object
required: [type, title, summary, severity, confidence, window, recommendations]
properties:
type:
type: string
enum: [funnel_drop, anomaly, driver, experiment_result, general]
title:
type: string
summary:
type: string
severity:
type: string
enum: [info, low, medium, high, critical]
confidence:
type: number
minimum: 0
maximum: 1
window:
type: object
recommendations:
type: array
outputSchema:
type: object
properties:
insight_id:
type: string
stored:
type: object
# ----------------------------
# MARKETING (GENERATION + PUBLISH)
# ----------------------------
marketing.generate_channel_posts:
description: Generate platform-specific social posts from a campaign brief + brand profile.
risk: low
executor:
kind: http
url: https://marketing-executor-REPLACE.a.run.app
path: /execute/marketing/generate_channel_posts
inputSchema:
type: object
additionalProperties: false
required: [brief, channels, brand_profile_id]
properties:
brand_profile_id:
type: string
brief:
type: object
required: [goal, product, audience, key_points]
properties:
goal:
type: string
product:
type: string
audience:
type: string
key_points:
type: array
items:
type: string
channels:
type: array
items:
type: string
enum: [x, linkedin, facebook, instagram, tiktok, youtube, pinterest, reddit]
variations_per_channel:
type: integer
minimum: 1
maximum: 10
default: 3
outputSchema:
type: object
properties:
channels:
type: array
marketing.publish_missinglettr:
description: Publish or schedule a campaign via Missinglettr.
risk: medium
executor:
kind: http
url: https://marketing-executor-REPLACE.a.run.app
path: /execute/marketing/publish_missinglettr
inputSchema:
type: object
additionalProperties: false
required: [campaign, schedule]
properties:
campaign:
type: object
required: [name, posts]
properties:
name:
type: string
posts:
type: array
items:
type: object
required: [channel, text]
properties:
channel:
type: string
text:
type: string
media_urls:
type: array
items:
type: string
schedule:
type: object
required: [mode]
properties:
mode:
type: string
enum: [now, scheduled]
start_time:
type: string
timezone:
type: string
default: UTC
outputSchema:
type: object
properties:
provider:
type: string
campaign_id:
type: string
status:
type: string
enum: [queued, scheduled, published, failed]

View File

@@ -1,41 +0,0 @@
version: '3.8'
services:
# Firestore Emulator
firestore:
image: gcr.io/google.com/cloudsdktool/google-cloud-cli:emulators
command: gcloud emulators firestore start --host-port=0.0.0.0:8081
ports:
- "8081:8081"
# GCS Emulator (fake-gcs-server)
gcs:
image: fsouza/fake-gcs-server
command: -scheme http -port 4443
ports:
- "4443:4443"
volumes:
- gcs-data:/data
# Control Plane API
control-plane:
build:
context: ./backend/control-plane
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
- PORT=8080
- GCP_PROJECT_ID=productos-local
- GCS_BUCKET_ARTIFACTS=productos-artifacts-local
- FIRESTORE_COLLECTION_RUNS=runs
- FIRESTORE_COLLECTION_TOOLS=tools
- AUTH_MODE=dev
- FIRESTORE_EMULATOR_HOST=firestore:8081
- STORAGE_EMULATOR_HOST=http://gcs:4443
depends_on:
- firestore
- gcs
volumes:
gcs-data:

View File

@@ -1,143 +0,0 @@
# Product OS - Getting Started
## Project Structure
```
platform/
├── backend/
│ └── control-plane/ # Fastify API server
├── client-ide/
│ └── extensions/
│ └── gcp-productos/ # VSCodium/VS Code extension
├── contracts/ # Tool registry schemas
├── infra/
│ └── terraform/ # GCP infrastructure
├── docs/ # Documentation
└── docker-compose.yml # Local development
```
## Quick Start (Local Development)
### Prerequisites
- Node.js 22+
- Docker & Docker Compose
- (Optional) VS Code or VSCodium for extension development
### 1. Start Local Services
```bash
cd platform
docker-compose up -d
```
This starts:
- Firestore emulator on port 8081
- GCS emulator on port 4443
- Control Plane API on port 8080
### 2. Run Control Plane in Dev Mode
For faster iteration without Docker:
```bash
cd platform/backend/control-plane
cp env.example .env
npm install
npm run dev
```
### 3. Test the API
```bash
# Health check
curl http://localhost:8080/healthz
# List tools (empty initially)
curl http://localhost:8080/tools
# Invoke a tool (dry run)
curl -X POST http://localhost:8080/tools/invoke \
-H "Content-Type: application/json" \
-d '{
"tool": "cloudrun.deploy_service",
"tenant_id": "t_dev",
"input": {"service_name": "test"},
"dry_run": true
}'
```
### 4. Build & Install the Extension
```bash
cd platform/client-ide/extensions/gcp-productos
npm install
npm run build
```
Then in VS Code / VSCodium:
1. Open Command Palette (Cmd+Shift+P)
2. Run "Developer: Install Extension from Location..."
3. Select the `gcp-productos` folder
Or use the VSIX package:
```bash
npx vsce package
code --install-extension gcp-productos-0.0.1.vsix
```
## Extension Usage
Once installed, use the Command Palette:
- **Product OS: Configure Backend** - Set the Control Plane URL
- **Product OS: List Tools** - View available tools
- **Product OS: Invoke Tool** - Execute a tool
- **Product OS: Open Run** - View run details
## Deploying to GCP
### 1. Configure Terraform
```bash
cd platform/infra/terraform
cp terraform.tfvars.example terraform.tfvars
# Edit terraform.tfvars with your project details
```
### 2. Build & Push Container
```bash
cd platform/backend/control-plane
# Build
docker build -t us-central1-docker.pkg.dev/YOUR_PROJECT/productos/control-plane:latest .
# Push (requires gcloud auth)
docker push us-central1-docker.pkg.dev/YOUR_PROJECT/productos/control-plane:latest
```
### 3. Apply Terraform
```bash
cd platform/infra/terraform
terraform init
terraform plan
terraform apply
```
## Seeding Tools
To add tools to the registry, you can:
1. Use the Firestore console to add documents to the `tools` collection
2. Create a seed script that loads `contracts/tool-registry.yaml`
3. Build an admin endpoint (coming in v2)
## Next Steps
- [ ] Build Deploy Executor
- [ ] Build Analytics Executor
- [ ] Add Gemini integration
- [ ] Add OAuth/IAP authentication
- [ ] Create Product-Centric UI panels

View File

@@ -1,16 +0,0 @@
# Allow control-plane to write artifacts in GCS
resource "google_storage_bucket_iam_member" "control_plane_bucket_writer" {
bucket = google_storage_bucket.artifacts.name
role = "roles/storage.objectAdmin"
member = "serviceAccount:${google_service_account.control_plane_sa.email}"
}
# Firestore access for run/tool metadata
resource "google_project_iam_member" "control_plane_firestore" {
project = var.project_id
role = "roles/datastore.user"
member = "serviceAccount:${google_service_account.control_plane_sa.email}"
}
# Placeholder: executor services will each have their own service accounts.
# Control-plane should be granted roles/run.invoker on each executor service once created.

View File

@@ -1,54 +0,0 @@
# GCS Bucket for artifacts (logs, AI outputs, patches)
resource "google_storage_bucket" "artifacts" {
name = var.artifact_bucket_name
location = var.region
uniform_bucket_level_access = true
versioning { enabled = true }
}
# Firestore (Native mode) requires enabling in console once per project
resource "google_firestore_database" "default" {
name = "(default)"
location_id = var.region
type = "FIRESTORE_NATIVE"
}
# Service account for Control Plane
resource "google_service_account" "control_plane_sa" {
account_id = "sa-control-plane"
display_name = "Product OS Control Plane"
}
# Cloud Run service for Control Plane API
resource "google_cloud_run_v2_service" "control_plane" {
name = "control-plane"
location = var.region
template {
service_account = google_service_account.control_plane_sa.email
containers {
image = var.control_plane_image
env {
name = "GCP_PROJECT_ID"
value = var.project_id
}
env {
name = "GCS_BUCKET_ARTIFACTS"
value = google_storage_bucket.artifacts.name
}
env {
name = "AUTH_MODE"
value = "dev"
}
}
}
}
# Public access for dev; prefer IAM auth in production
resource "google_cloud_run_v2_service_iam_member" "control_plane_public" {
name = google_cloud_run_v2_service.control_plane.name
location = var.region
role = "roles/run.invoker"
member = "allUsers"
}

View File

@@ -1,9 +0,0 @@
output "control_plane_url" {
value = google_cloud_run_v2_service.control_plane.uri
description = "URL of the Control Plane API"
}
output "artifact_bucket" {
value = google_storage_bucket.artifacts.name
description = "GCS bucket for artifacts"
}

View File

@@ -1,14 +0,0 @@
terraform {
required_version = ">= 1.5.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.30"
}
}
}
provider "google" {
project = var.project_id
region = var.region
}

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