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:
24
.gitignore
vendored
24
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
292
AGENT_TELEMETRY_STREAMING_PROJECT.md
Normal file
292
AGENT_TELEMETRY_STREAMING_PROJECT.md
Normal 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 agent’s 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 today’s 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 today’s 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 today’s 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 today’s 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 ~15–30s 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** < **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
584
AI_CAPABILITIES.md
Normal 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
667
AI_CAPABILITIES_ROADMAP.md
Normal 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 (minutes–hours). 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
138
README.md
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
501
VIBN_PRD.md
Normal 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 $50–200k and take 6–12 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 (1–5 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 2–3)
|
||||
|
||||
**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 4–6)
|
||||
|
||||
**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 2–6)
|
||||
- **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 2–4 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 (~20–30 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; 6–12 month timelines; $50k–$200k budgets
|
||||
349
architecture.md
349
architecture.md
@@ -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
|
||||
33
branding/coolify/README.md
Normal file
33
branding/coolify/README.md
Normal 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.
|
||||
10
branding/gitea/conf/app.ini
Normal file
10
branding/gitea/conf/app.ini
Normal 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
|
||||
34
branding/gitea/public/assets/img/README.md
Normal file
34
branding/gitea/public/assets/img/README.md
Normal 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"
|
||||
```
|
||||
242
branding/ux-testing/01_homepage.restructured-green.html
Normal file
242
branding/ux-testing/01_homepage.restructured-green.html
Normal 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;">⬤ 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);"> 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 & 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>
|
||||
242
branding/ux-testing/01_homepage.restructured.html
Normal file
242
branding/ux-testing/01_homepage.restructured.html
Normal 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;">⬤ 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);"> 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 & 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
26
flatten.sh
Normal 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
41
gitea-docker-compose.yml
Normal 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
|
||||
26
justine/00_design-tokens.css
Normal file
26
justine/00_design-tokens.css
Normal 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
335
justine/01_homepage.html
Normal 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;">⬤ 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);"> 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 & 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
329
justine/02_signup.html
Normal 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
1652
justine/03_dashboard.html
Normal file
File diff suppressed because it is too large
Load Diff
1189
justine/05_describe.html
Normal file
1189
justine/05_describe.html
Normal file
File diff suppressed because it is too large
Load Diff
589
justine/06_architect.html
Normal file
589
justine/06_architect.html
Normal 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>~3–4 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
44
justine/README.md
Normal 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
BIN
justine/favicon_clean.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
119
justine/google-auth-popup.html
Normal file
119
justine/google-auth-popup.html
Normal 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
41
justine/index.html
Normal 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>
|
||||
8
justine/master-ai.code-workspace
Normal file
8
justine/master-ai.code-workspace
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": ".."
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
}
|
||||
1055
justine/package-lock.json
generated
Normal file
1055
justine/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
11
justine/package.json
Normal file
11
justine/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
26
justine/vibn front end/00_design-tokens.css
Normal file
26
justine/vibn front end/00_design-tokens.css
Normal 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/vibn front end/01_homepage.html
Normal file
335
justine/vibn front end/01_homepage.html
Normal 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;">⬤ 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);"> 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 & 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/vibn front end/02_signup.html
Normal file
329
justine/vibn front end/02_signup.html
Normal 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>
|
||||
2012
justine/vibn front end/03_dashboard.html
Normal file
2012
justine/vibn front end/03_dashboard.html
Normal file
File diff suppressed because it is too large
Load Diff
1221
justine/vibn front end/05_describe.html
Normal file
1221
justine/vibn front end/05_describe.html
Normal file
File diff suppressed because it is too large
Load Diff
638
justine/vibn front end/06_architect.html
Normal file
638
justine/vibn front end/06_architect.html
Normal 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);">We’ve translated your idea into a complete system — how it’s 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>~3–4 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>
|
||||
1361
justine/vibn front end/07_design.html
Normal file
1361
justine/vibn front end/07_design.html
Normal file
File diff suppressed because it is too large
Load Diff
805
justine/vibn front end/08_website.html
Normal file
805
justine/vibn front end/08_website.html
Normal 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>
|
||||
412
justine/vibn front end/09_build.html
Normal file
412
justine/vibn front end/09_build.html
Normal 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 & 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 & 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 & 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 & 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 & 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 & 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 ~2–4 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 · 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>
|
||||
44
justine/vibn front end/README.md
Normal file
44
justine/vibn front end/README.md
Normal 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/vibn front end/favicon_clean.ico
Normal file
BIN
justine/vibn front end/favicon_clean.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
119
justine/vibn front end/google-auth-popup.html
Normal file
119
justine/vibn front end/google-auth-popup.html
Normal 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>
|
||||
484
justine/vibn front end/vibn-dashboard.jsx
Normal file
484
justine/vibn front end/vibn-dashboard.jsx
Normal 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 front end/vibn-website.jsx
Normal file
288
justine/vibn front end/vibn-website.jsx
Normal 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 }}>★★★★★ 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
484
justine/vibn-dashboard.jsx
Normal 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
288
justine/vibn-website.jsx
Normal 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 }}>★★★★★ 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
11
master-ai.code-workspace
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
},
|
||||
{
|
||||
"path": "../Downloads/vibn-screens"
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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]));
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>/" };
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}` };
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "Bundler",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "Bundler",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "Bundler",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "Bundler",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/**/*"]
|
||||
}
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\n/g, "<br>");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\n/g, "<br>");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user