← back to Ventura Claw
viewer/public/app.js
165 lines
// VenturaClaw — Live Build Viewer
// Connector tiles, phase tracker, SSE event stream
const PHASES = [
{ id: 0, title: "Phase 0 — Scaffold (Next.js + Tailwind + Drizzle + NextAuth)" },
{ id: 1, title: "Phase 1 — DB schema (users, roles, audits, approvals, …)" },
{ id: 2, title: "Phase 2 — RBAC (admin / user gates)" },
{ id: 3, title: "Phase 3 — Liquid-glass design system" },
{ id: 4, title: "Phase 4 — Mock connector registry" },
{ id: 5, title: "Phase 5 — Core screens + /admin/*" },
{ id: 6, title: "Phase 6 — EXA spec research → connector-specs.json" },
{ id: 7, title: "Phase 7 — Real MCP wiring (Shopify, Stripe, PayPal, Gmail, Slack, CF)" },
{ id: 8, title: "Phase 8 — Approval gate + audit log enforcement" },
{ id: 9, title: "Phase 9 — Smoke tests (Playwright per role)" }
];
// [Display name, category, slug, simple-icons slug or null, hex tint]
const CONNECTORS = [
["Shopify","commerce","shopify","shopify","#95BF47"],
["Etsy","commerce","etsy","etsy","#F1641E"],
["WooCommerce","commerce","woocommerce","woocommerce","#7F54B3"],
["Stripe","payments","stripe","stripe","#635BFF"],
["PayPal","payments","paypal","paypal","#00457C"],
["Shopify Payments","payments","shopify_payments","shopify","#95BF47"],
["Square","payments","square","square","#000000"],
["Gmail","email","gmail","gmail","#EA4335"],
["Google Sheets","email","google_sheets","googlesheets","#34A853"],
["Google Drive","email","google_drive","googledrive","#4285F4"],
["Outlook","email","outlook","microsoftoutlook","#0078D4"],
["Purelymail","email","purelymail",null,"#7C3AED"],
["Slack","comms","slack","slack","#4A154B"],
["RingCentral","comms","ringcentral","ringcentral","#FF7A00"],
["Twilio","comms","twilio","twilio","#F22F46"],
["Discord","comms","discord","discord","#5865F2"],
["Instagram","social","instagram","instagram","#E4405F"],
["Facebook","social","facebook","facebook","#1877F2"],
["TikTok","social","tiktok","tiktok","#000000"],
["Pinterest","social","pinterest","pinterest","#BD081C"],
["LinkedIn","social","linkedin","linkedin","#0A66C2"],
["YouTube Shorts","social","youtube_shorts","youtube","#FF0000"],
["X / Twitter","social","x_twitter","x","#000000"],
["Threads","social","threads","threads","#000000"],
["Bluesky","social","bluesky","bluesky","#0085FF"],
["Reddit","social","reddit","reddit","#FF4500"],
["Mailchimp","marketing","mailchimp","mailchimp","#FFE01B"],
["Klaviyo","marketing","klaviyo","klaviyo","#000000"],
["Constant Contact","marketing","constant_contact",null,"#1856ED"],
["Action Network","marketing","action_network",null,"#EE3424"],
["ClickUp","tasks","clickup","clickup","#7B68EE"],
["Asana","tasks","asana","asana","#F06A6A"],
["Trello","tasks","trello","trello","#0079BF"],
["Monday","tasks","monday","mondaydotcom","#FF3D57"],
["Notion","tasks","notion","notion","#000000"],
["HubSpot","crm","hubspot","hubspot","#FF7A59"],
["Salesforce","crm","salesforce","salesforce","#00A1E0"],
["Zendesk","support","zendesk","zendesk","#03363D"],
["Gorgias","support","gorgias",null,"#1F2937"],
["Intercom","support","intercom","intercom","#1F8DED"],
["Canva","creative","canva","canva","#00C4CC"],
["Figma","creative","figma","figma","#F24E1E"],
["Adobe","creative","adobe","adobe","#FF0000"],
["Paper","creative","paper",null,"#FF8A00"],
["Cloudflare","security","cloudflare","cloudflare","#F38020"],
["GoDaddy","domain","godaddy","godaddy","#1BDBDB"],
["Vercel","hosting","vercel","vercel","#000000"],
["AWS","hosting","aws","amazonaws","#FF9900"],
["Sucuri","security","sucuri",null,"#13B26C"],
["n8n","automation","n8n","n8n","#EA4B71"],
["Zapier","automation","zapier","zapier","#FF4A00"],
["Make","automation","make","make","#6D00CC"],
["Browserbase","automation","browserbase",null,"#0EA5E9"],
["PostgreSQL","data","postgresql","postgresql","#4169E1"],
["Supabase","data","supabase","supabase","#3ECF8E"],
["Airtable","data","airtable","airtable","#FCB400"]
];
const phaseEl = document.getElementById('phase-list');
const phaseState = new Map();
PHASES.forEach(p => {
const li = document.createElement('li');
li.className = 'phase';
li.id = `phase-${p.id}`;
li.innerHTML = `<span class="orb orb-pending"></span>
<span class="phase-title">${p.title}</span>
<span class="phase-meta">queued</span>`;
phaseEl.appendChild(li);
phaseState.set(p.id, 'pending');
});
function logoMarkup(name, simpleSlug, tint) {
if (simpleSlug) {
const url = `https://cdn.simpleicons.org/${simpleSlug}/${tint.replace('#','')}`;
return `<img class="logo" src="${url}" alt="${name}" loading="lazy" onerror="this.outerHTML='<div class=\\'logo logo-fallback\\' style=\\'background:${tint}\\'>${name[0]}</div>'" />`;
}
return `<div class="logo logo-fallback" style="background:${tint}">${name[0]}</div>`;
}
const grid = document.getElementById('connector-grid');
CONNECTORS.forEach(([name, cat, slug, simpleSlug, tint]) => {
const d = document.createElement('div');
d.className = 'connector-tile';
d.dataset.connector = slug;
d.innerHTML = `
<div class="tile-head">
${logoMarkup(name, simpleSlug, tint)}
<div class="tile-meta">
<div class="cat">${cat}</div>
<div class="name">${name}</div>
</div>
</div>
<div class="stat"><span class="orb orb-pending"></span><span class="stat-label">mock pending</span></div>`;
grid.appendChild(d);
});
const stream = document.getElementById('stream');
const counter = document.getElementById('event-count');
let count = 0;
function setPhase(id, status, meta) {
const li = document.getElementById(`phase-${id}`);
if (!li) return;
li.classList.remove('pending','running','done','blocked');
li.classList.add(status);
const orb = li.querySelector('.orb');
orb.className = `orb orb-${status}`;
if (meta) li.querySelector('.phase-meta').textContent = meta;
phaseState.set(id, status);
refreshOverall();
}
function setConnector(slug, status, label) {
const tile = document.querySelector(`[data-connector="${slug}"]`);
if (!tile) return;
const orb = tile.querySelector('.orb');
orb.className = `orb orb-${status}`;
if (label) tile.querySelector('.stat-label').textContent = label;
}
function refreshOverall() {
const lbl = document.getElementById('overall-label');
const orb = document.querySelector('#overall-status .orb');
const states = [...phaseState.values()];
if (states.every(s => s === 'done')) { lbl.textContent = 'Build complete'; orb.className = 'orb orb-done'; return; }
if (states.some(s => s === 'blocked')) { lbl.textContent = 'Blocked'; orb.className = 'orb orb-blocked'; return; }
if (states.some(s => s === 'running')) { lbl.textContent = 'Building…'; orb.className = 'orb orb-running'; return; }
lbl.textContent = 'Idle'; orb.className = 'orb orb-pending';
}
function pushEvent(ev) {
count++; counter.textContent = count;
if (ev.kind === 'phase' && ev.phase != null) setPhase(ev.phase, ev.status, ev.meta || ev.status);
if (ev.kind === 'connector' && ev.connector) setConnector(ev.connector, ev.status, ev.label);
const el = document.createElement('div');
el.className = `event ${ev.level || 'info'}`;
const t = ev.ts ? new Date(ev.ts).toLocaleTimeString() : new Date().toLocaleTimeString();
el.innerHTML = `<time>${t}</time><span class="lvl">${ev.level || 'info'}</span><span>${(ev.msg || '').replace(/[<>]/g,'')}</span>`;
stream.appendChild(el);
while (stream.childElementCount > 500) stream.removeChild(stream.firstChild);
stream.scrollTop = stream.scrollHeight;
}
const es = new EventSource('/events');
es.onmessage = (e) => { try { pushEvent(JSON.parse(e.data)); } catch {} };
es.onerror = () => { /* SSE retries automatically */ };