@appsforgood/next-supabase-kit 0.1.0 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +31 -1
  2. package/DOGFOOD.md +1 -1
  3. package/README.md +24 -7
  4. package/REPOSITORY_SETTINGS.md +2 -1
  5. package/SUPPLY_CHAIN.md +5 -5
  6. package/UPGRADE.md +14 -10
  7. package/antigravity/commands/audit.toml +16 -0
  8. package/antigravity/commands/copy.toml +16 -0
  9. package/antigravity/commands/frontend.toml +17 -0
  10. package/antigravity/commands/handoff.toml +16 -0
  11. package/antigravity/commands/plan.toml +18 -0
  12. package/antigravity/commands/security.toml +16 -0
  13. package/antigravity/commands/setup.toml +16 -0
  14. package/antigravity/commands/ship.toml +17 -0
  15. package/antigravity/commands/upgrade.toml +17 -0
  16. package/antigravity/plugin.json +58 -0
  17. package/assistant-adapters/README.md +1 -0
  18. package/assistant-adapters/antigravity.md +56 -0
  19. package/assistant-adapters/claude-code-subagents.md +1 -1
  20. package/assistant-adapters/codex-agents.md +2 -1
  21. package/assistant-adapters/cursor-agent-kit.mdc +2 -2
  22. package/assistant-adapters/github-copilot-instructions.md +1 -1
  23. package/assistant-adapters/github-next-supabase.instructions.md +1 -1
  24. package/dist/index.js +3956 -1105
  25. package/dist/index.js.map +1 -1
  26. package/dist/studio/office/assets/office.css +551 -0
  27. package/dist/studio/office/assets/office.js +1105 -0
  28. package/dist/studio/wizard/assets/wizard.css +525 -0
  29. package/dist/studio/wizard/assets/wizard.js +692 -0
  30. package/examples/next-supabase-installed/.agent-kit/manifest.json +13 -12
  31. package/examples/next-supabase-installed/.agent-kit/model-routing.json +7 -0
  32. package/examples/next-supabase-installed/README.md +3 -3
  33. package/examples/next-supabase-installed/audit-output.json +24 -7
  34. package/examples/next-supabase-installed/tree.txt +6 -0
  35. package/model-routing/default-model-routing.json +7 -0
  36. package/package.json +13 -5
  37. package/runtime-skills/README.md +7 -0
  38. package/runtime-skills/accessibility-wcag/SKILL.md +8 -0
  39. package/runtime-skills/agent-handoff-tracing/SKILL.md +8 -0
  40. package/runtime-skills/best-practice-maturity-review/SKILL.md +8 -0
  41. package/runtime-skills/content-first-design/SKILL.md +8 -0
  42. package/runtime-skills/conversion-copywriting/SKILL.md +8 -0
  43. package/runtime-skills/deployment-observability/SKILL.md +8 -0
  44. package/runtime-skills/docs-maintainer/SKILL.md +8 -0
  45. package/runtime-skills/frontend-design-system/SKILL.md +8 -0
  46. package/runtime-skills/frontend-distinctiveness-benchmark/SKILL.md +8 -0
  47. package/runtime-skills/frontend-product-quality-rubric/SKILL.md +8 -0
  48. package/runtime-skills/landing-page-copy/SKILL.md +8 -0
  49. package/runtime-skills/nextjs-app-router/SKILL.md +8 -0
  50. package/runtime-skills/onboarding-empty-state-copy/SKILL.md +8 -0
  51. package/runtime-skills/owasp-security-review/SKILL.md +8 -0
  52. package/runtime-skills/planning-council/SKILL.md +8 -0
  53. package/runtime-skills/positioning-messaging/SKILL.md +8 -0
  54. package/runtime-skills/postgres-migrations/SKILL.md +8 -0
  55. package/runtime-skills/product-voice-tone/SKILL.md +8 -0
  56. package/runtime-skills/reference-led-design-critique/SKILL.md +8 -0
  57. package/runtime-skills/supabase-auth-rls/SKILL.md +8 -0
  58. package/runtime-skills/testing-qa/SKILL.md +8 -0
  59. package/runtime-skills/upgrade-maintenance/SKILL.md +8 -0
  60. package/runtime-skills/visual-regression-qa/SKILL.md +8 -0
  61. package/schemas/onboarding-state.schema.json +33 -0
  62. package/templates/next-supabase/.github/workflows/agent-kit-audit.yml +35 -0
  63. package/templates/next-supabase/AGENTS.md +1 -1
  64. package/templates/next-supabase/ASSISTANT_ADAPTERS.md +21 -3
  65. package/templates/next-supabase/CLAUDE.md +39 -0
  66. package/templates/next-supabase/DECISIONS.md +14 -0
  67. package/templates/next-supabase/DOCS.md +4 -0
  68. package/templates/next-supabase/MODEL_ROUTING.md +12 -0
  69. package/templates/next-supabase/QUALITY_GATES.md +4 -0
  70. package/templates/next-supabase/SPEC.md +2 -0
  71. package/templates/next-supabase/TESTING.md +3 -0
  72. package/templates/next-supabase/UPGRADE.md +8 -4
@@ -0,0 +1,1105 @@
1
+ /* global OFFICE_BOOT */
2
+ (function officeApp() {
3
+ const boot = window.OFFICE_BOOT || {};
4
+ const isStudio = boot.mode === "studio";
5
+ const TILE = boot.tileSize || 24;
6
+ const SCALE = boot.scale || 4;
7
+ const MAP_W = boot.mapWidth || 28;
8
+ const MAP_H = boot.mapHeight || 18;
9
+ const BREAK_RUG = { x: 11, y: 9, w: 7, h: 4 };
10
+ const AMENITY_MSG = {
11
+ coffee: ["Fresh brew — back to work!", "Caffeine acquired.", "One more espresso?"],
12
+ cooler: ["Hydration break.", "Cold water hits different.", "Stay hydrated, ship faster."]
13
+ };
14
+
15
+ const ROLE_COLORS = {
16
+ planner: ["#7c3aed", "#5b21b6"],
17
+ engineer: ["#0ea5e9", "#0369a1"],
18
+ design: ["#ec4899", "#be185d"],
19
+ ops: ["#f59e0b", "#b45309"]
20
+ };
21
+
22
+ const state = {
23
+ depth: "undecided",
24
+ form: {},
25
+ progress: {},
26
+ onboarding: {},
27
+ agents: boot.agents || [],
28
+ stations: boot.stations || [],
29
+ hoverId: null,
30
+ activeStationId: null,
31
+ frame: 0,
32
+ reducedMotion: window.matchMedia("(prefers-reduced-motion: reduce)").matches,
33
+ depthSweep: null,
34
+ confetti: [],
35
+ handoffPulse: null,
36
+ studioSessionId: boot.activeSessionId || "",
37
+ studioEvents: [],
38
+ speechBubbles: []
39
+ };
40
+
41
+ const agentRuntime = {};
42
+
43
+ const els = {
44
+ canvas: document.getElementById("office-floor"),
45
+ projectName: document.getElementById("project-name"),
46
+ progressPill: document.getElementById("progress-pill"),
47
+ sessionPill: document.getElementById("session-pill"),
48
+ stationList: document.getElementById("station-list"),
49
+ status: document.getElementById("status"),
50
+ hoverLabel: document.getElementById("hover-label"),
51
+ panel: document.getElementById("panel"),
52
+ panelTitle: document.getElementById("panel-title"),
53
+ panelBody: document.getElementById("panel-body"),
54
+ panelClose: document.getElementById("panel-close"),
55
+ panelCancel: document.getElementById("panel-cancel"),
56
+ panelSave: document.getElementById("panel-save"),
57
+ depthModal: document.getElementById("depth-modal"),
58
+ depthGrid: document.getElementById("depth-grid"),
59
+ reviewBtn: document.getElementById("review-btn"),
60
+ reviewModal: document.getElementById("review-modal"),
61
+ reviewList: document.getElementById("review-list"),
62
+ reviewCancel: document.getElementById("review-cancel"),
63
+ reviewSave: document.getElementById("review-save"),
64
+ nameplateLayer: document.getElementById("nameplate-layer"),
65
+ bubbleLayer: document.getElementById("bubble-layer"),
66
+ officeHint: document.getElementById("office-hint"),
67
+ canvasWrap: document.querySelector(".canvas-wrap"),
68
+ transcriptList: document.getElementById("transcript-list")
69
+ };
70
+
71
+ const ctx = els.canvas?.getContext("2d");
72
+ if (!ctx || !els.canvas) {
73
+ if (els.status) {
74
+ els.status.className = "status error";
75
+ els.status.textContent = "Canvas failed to initialize.";
76
+ }
77
+ return;
78
+ }
79
+
80
+ const logicalW = MAP_W * TILE;
81
+ const logicalH = MAP_H * TILE;
82
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
83
+ els.canvas.width = logicalW * dpr;
84
+ els.canvas.height = logicalH * dpr;
85
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
86
+ els.canvas.style.width = logicalW * SCALE + "px";
87
+ els.canvas.style.height = logicalH * SCALE + "px";
88
+
89
+ function wizardSectionForStation(station) {
90
+ if (!station || station.kind === "amenity") return null;
91
+ if (station.kind === "agent") return "team";
92
+ if (station.section === "agent") return "team";
93
+ return station.section;
94
+ }
95
+
96
+ function allAgentBriefsComplete() {
97
+ const ids = state.agents.map((a) => a.id);
98
+ if (!ids.length) return false;
99
+ return ids.every((id) => Boolean(state.form["agentBrief_" + id]?.trim()));
100
+ }
101
+
102
+ function initAgentRuntime() {
103
+ for (const station of state.stations) {
104
+ if (station.kind !== "agent" || !station.agentId) continue;
105
+ const hx = station.x + 1;
106
+ const hy = station.y + 1;
107
+ agentRuntime[station.agentId] = {
108
+ tileX: hx,
109
+ tileY: hy,
110
+ targetX: hx,
111
+ targetY: hy,
112
+ homeX: hx,
113
+ homeY: hy,
114
+ state: "idle",
115
+ direction: "down",
116
+ frame: 0,
117
+ breakTimer: 120 + Math.floor(Math.random() * 180),
118
+ breakTarget: null
119
+ };
120
+ }
121
+ }
122
+
123
+ initAgentRuntime();
124
+
125
+ function setStatus(kind, message) {
126
+ els.status.className = kind ? "status " + kind : "status";
127
+ els.status.textContent = message || "";
128
+ if (message) {
129
+ window.clearTimeout(setStatus._timer);
130
+ setStatus._timer = window.setTimeout(() => setStatus("", ""), 4000);
131
+ }
132
+ }
133
+
134
+ function escapeHtml(value) {
135
+ return String(value ?? "")
136
+ .replace(/&/g, "&")
137
+ .replace(/</g, "&lt;")
138
+ .replace(/>/g, "&gt;")
139
+ .replace(/"/g, "&quot;");
140
+ }
141
+
142
+ function agentRole(agentId) {
143
+ if (agentId === "planner") return "planner";
144
+ if (["lead-architect", "nextjs-engineer", "supabase-postgres-engineer"].includes(agentId)) return "engineer";
145
+ if (["frontend-design-lead", "marketing-copy-lead"].includes(agentId)) return "design";
146
+ return "ops";
147
+ }
148
+
149
+ function visibleStations() {
150
+ const depth = state.depth === "undecided" ? "quick" : state.depth;
151
+ return state.stations.filter((s) => s.depths.includes(depth) || s.depths.includes("undecided"));
152
+ }
153
+
154
+ function fieldValue(name) {
155
+ const el = document.querySelector('#panel-body [name="' + name + '"]');
156
+ return el && "value" in el ? String(el.value).trim() : state.form[name] || "";
157
+ }
158
+
159
+ function collectPanelForm() {
160
+ document.querySelectorAll("#panel-body [name]").forEach((el) => {
161
+ if ("value" in el) state.form[el.name] = el.value;
162
+ });
163
+ }
164
+
165
+ async function api(path, options) {
166
+ const response = await fetch(path, options);
167
+ const data = await response.json();
168
+ if (!response.ok) throw new Error(data.error || "Request failed");
169
+ return data;
170
+ }
171
+
172
+ function stationStatus(station) {
173
+ const sectionId = wizardSectionForStation(station);
174
+ if (!sectionId) return "open";
175
+ const sections = state.progress?.sections || [];
176
+ const match = sections.find((s) => s.id === sectionId);
177
+ if (match) return match.status === "done" ? "done" : "open";
178
+ return "open";
179
+ }
180
+
181
+ async function loadState() {
182
+ if (isStudio) {
183
+ await loadStudioState();
184
+ return;
185
+ }
186
+ const data = await api("/api/state");
187
+ state.form = data.form || {};
188
+ state.progress = data.progress || {};
189
+ state.onboarding = data.onboarding || {};
190
+ state.depth = data.onboarding?.depth || "undecided";
191
+ if (Array.isArray(data.agents) && data.agents.length) state.agents = data.agents;
192
+ els.projectName.textContent = data.projectName || "your project";
193
+ updateProgressUi();
194
+ renderStationList();
195
+ if (state.depth === "undecided") showDepthModal();
196
+ else {
197
+ els.depthModal.hidden = true;
198
+ showOfficeHint();
199
+ }
200
+ renderNameplates();
201
+ }
202
+
203
+ async function loadStudioState() {
204
+ const sessionsRes = await api("/api/sessions");
205
+ state.studioSessionId = sessionsRes.activeSessionId || boot.activeSessionId || "";
206
+ if (els.sessionPill) {
207
+ els.sessionPill.textContent = state.studioSessionId ? state.studioSessionId.slice(0, 24) : "No session";
208
+ }
209
+ if (els.projectName) els.projectName.textContent = sessionsRes.sessions?.[0]?.title || "Council session";
210
+ if (els.officeHint) {
211
+ els.officeHint.classList.remove("hidden");
212
+ window.setTimeout(() => els.officeHint?.classList.add("hidden"), 6000);
213
+ }
214
+ connectStudioStream();
215
+ }
216
+
217
+ function connectStudioStream() {
218
+ if (!state.studioSessionId) return;
219
+ const url = "/api/events/stream?sessionId=" + encodeURIComponent(state.studioSessionId);
220
+ const source = new EventSource(url);
221
+ source.addEventListener("snapshot", (ev) => {
222
+ try {
223
+ const payload = JSON.parse(ev.data);
224
+ state.studioEvents = payload.events || [];
225
+ renderTranscript();
226
+ } catch {
227
+ // ignore
228
+ }
229
+ });
230
+ source.addEventListener("event", (ev) => {
231
+ try {
232
+ const payload = JSON.parse(ev.data);
233
+ if (payload.event) {
234
+ state.studioEvents.push(payload.event);
235
+ renderTranscript();
236
+ handleStudioEvent(payload.event);
237
+ }
238
+ } catch {
239
+ // ignore
240
+ }
241
+ });
242
+ source.onerror = () => {
243
+ source.close();
244
+ };
245
+ }
246
+
247
+ function eventLabel(event) {
248
+ if (event.text) return event.text;
249
+ if (event.decision) return event.decision;
250
+ if (event.type === "handoff") return "Handoff → " + (event.toAgentId || "?");
251
+ if (event.command) return event.command;
252
+ return event.type;
253
+ }
254
+
255
+ function renderTranscript() {
256
+ if (!els.transcriptList) return;
257
+ els.transcriptList.innerHTML = state.studioEvents
258
+ .slice(-80)
259
+ .map(
260
+ (ev) =>
261
+ '<li><span class="tx-time">' +
262
+ escapeHtml((ev.createdAt || "").slice(11, 19)) +
263
+ '</span> <strong>' +
264
+ escapeHtml(ev.agentId || ev.fromAgentId || "session") +
265
+ "</strong> " +
266
+ escapeHtml(eventLabel(ev)) +
267
+ "</li>"
268
+ )
269
+ .join("");
270
+ els.transcriptList.scrollTop = els.transcriptList.scrollHeight;
271
+ }
272
+
273
+ function handleStudioEvent(event) {
274
+ const agentId = event.agentId || event.fromAgentId;
275
+ if (agentId && agentRuntime[agentId]) {
276
+ agentRuntime[agentId].state = event.type === "handoff" ? "walking" : "working";
277
+ addSpeechBubble(agentId, eventLabel(event));
278
+ }
279
+ if (event.type === "handoff" && event.fromAgentId && event.toAgentId) {
280
+ state.handoffPulse = { frame: 0, from: event.fromAgentId, to: event.toAgentId };
281
+ const toRt = agentRuntime[event.toAgentId];
282
+ if (toRt) {
283
+ toRt.state = "walking";
284
+ toRt.targetX = toRt.homeX;
285
+ toRt.targetY = toRt.homeY;
286
+ }
287
+ }
288
+ }
289
+
290
+ function addSpeechBubble(agentId, text) {
291
+ const station = state.stations.find((s) => s.agentId === agentId);
292
+ if (!station) return;
293
+ const rt = agentRuntime[agentId];
294
+ const cx = (rt ? rt.tileX : station.x + 1) * TILE;
295
+ const cy = (rt ? rt.tileY : station.y) * TILE;
296
+ state.speechBubbles.push({
297
+ agentId,
298
+ text: String(text).slice(0, 72),
299
+ x: cx,
300
+ y: cy - 8,
301
+ frame: 0,
302
+ ttl: state.reducedMotion ? 120 : 240
303
+ });
304
+ renderSpeechDom();
305
+ }
306
+
307
+ function renderSpeechDom() {
308
+ if (!els.bubbleLayer) return;
309
+ const canvasRect = els.canvas.getBoundingClientRect();
310
+ const wrapRect = els.canvasWrap?.getBoundingClientRect() || canvasRect;
311
+ const offsetLeft = canvasRect.left - wrapRect.left;
312
+ const offsetTop = canvasRect.top - wrapRect.top;
313
+ const scaleX = canvasRect.width / logicalW;
314
+ const scaleY = canvasRect.height / logicalH;
315
+ els.bubbleLayer.innerHTML = state.speechBubbles
316
+ .map((b) => {
317
+ const left = offsetLeft + b.x * scaleX;
318
+ const top = offsetTop + b.y * scaleY - 28;
319
+ return (
320
+ '<span class="speech-bubble" style="left:' +
321
+ left +
322
+ "px;top:" +
323
+ top +
324
+ 'px">' +
325
+ escapeHtml(b.text) +
326
+ "</span>"
327
+ );
328
+ })
329
+ .join("");
330
+ }
331
+
332
+ function updateProgressUi() {
333
+ const pct = state.progress?.percent ?? 0;
334
+ if (els.progressPill) els.progressPill.textContent = pct + "% ready";
335
+ }
336
+
337
+ function spawnConfetti(x, y) {
338
+ if (state.reducedMotion) return;
339
+ for (let i = 0; i < 12; i += 1) {
340
+ state.confetti.push({
341
+ x,
342
+ y,
343
+ vx: (Math.random() - 0.5) * 2,
344
+ vy: -Math.random() * 2 - 0.5,
345
+ color: ["#4ade80", "#99f6e4", "#fbbf24", "#f472b6"][i % 4],
346
+ ttl: 60 + Math.floor(Math.random() * 30)
347
+ });
348
+ }
349
+ }
350
+
351
+ function renderStationList() {
352
+ if (!els.stationList) return;
353
+ const items = visibleStations().filter((s) => s.kind !== "amenity");
354
+ els.stationList.innerHTML = items
355
+ .map((s) => {
356
+ const st = stationStatus(s);
357
+ return (
358
+ '<li><button type="button" data-station="' +
359
+ s.id +
360
+ '" class="' +
361
+ (st === "done" ? "done" : "") +
362
+ '">' +
363
+ escapeHtml(s.label) +
364
+ '<span class="chip">' +
365
+ (st === "done" ? "✓" : "…") +
366
+ "</span></button></li>"
367
+ );
368
+ })
369
+ .join("");
370
+ els.stationList.querySelectorAll("[data-station]").forEach((btn) => {
371
+ btn.addEventListener("click", () => openStation(btn.getAttribute("data-station")));
372
+ });
373
+ }
374
+
375
+ function showOfficeHint() {
376
+ if (!els.officeHint) return;
377
+ els.officeHint.classList.remove("hidden");
378
+ window.clearTimeout(showOfficeHint._timer);
379
+ showOfficeHint._timer = window.setTimeout(() => {
380
+ els.officeHint.classList.add("hidden");
381
+ }, 8000);
382
+ }
383
+
384
+ function renderNameplates() {
385
+ if (!els.nameplateLayer || !els.canvasWrap) return;
386
+ const canvasRect = els.canvas.getBoundingClientRect();
387
+ const wrapRect = els.canvasWrap.getBoundingClientRect();
388
+ const offsetLeft = canvasRect.left - wrapRect.left;
389
+ const offsetTop = canvasRect.top - wrapRect.top;
390
+ const scaleX = canvasRect.width / logicalW;
391
+ const scaleY = canvasRect.height / logicalH;
392
+ els.nameplateLayer.innerHTML = visibleStations()
393
+ .filter((s) => s.kind !== "amenity")
394
+ .map((station) => {
395
+ const cx = offsetLeft + (station.x + station.w / 2) * TILE * scaleX;
396
+ const cy = offsetTop + station.y * TILE * scaleY - 4;
397
+ const st = stationStatus(station);
398
+ return (
399
+ '<span class="nameplate ' +
400
+ st +
401
+ '" style="left:' +
402
+ cx +
403
+ "px;top:" +
404
+ cy +
405
+ 'px">' +
406
+ escapeHtml(station.label) +
407
+ "</span>"
408
+ );
409
+ })
410
+ .join("");
411
+ }
412
+
413
+ function showDepthModal() {
414
+ if (!els.depthModal) return;
415
+ els.depthModal.hidden = false;
416
+ els.depthGrid.innerHTML = [
417
+ ["quick", "Quick (~10 min)", "IDE, agent briefings, product essentials."],
418
+ ["standard", "Standard (~15 min)", "Quick plus visual QA station."],
419
+ ["complete", "Complete (~25 min)", "Standard plus design and copy archives."]
420
+ ]
421
+ .map(
422
+ ([id, title, desc]) =>
423
+ '<button type="button" class="depth-card" data-depth="' +
424
+ id +
425
+ '"><strong>' +
426
+ escapeHtml(title) +
427
+ "</strong><p>" +
428
+ escapeHtml(desc) +
429
+ "</p></button>"
430
+ )
431
+ .join("");
432
+ els.depthGrid.querySelectorAll("[data-depth]").forEach((btn) => {
433
+ btn.addEventListener("click", async () => {
434
+ state.depth = btn.getAttribute("data-depth");
435
+ state.depthSweep = { frame: 0, depth: state.depth };
436
+ await api("/api/state", {
437
+ method: "PATCH",
438
+ headers: { "Content-Type": "application/json" },
439
+ body: JSON.stringify({ depth: state.depth, currentSection: "ide" })
440
+ });
441
+ els.depthModal.hidden = true;
442
+ renderStationList();
443
+ renderNameplates();
444
+ showOfficeHint();
445
+ setStatus("ok", "Depth set to " + state.depth + ". Click a desk to brief an agent.");
446
+ });
447
+ });
448
+ }
449
+
450
+ function fillRect(x, y, w, h, color) {
451
+ ctx.fillStyle = color;
452
+ ctx.fillRect(x, y, w, h);
453
+ }
454
+
455
+ function inBreakRug(tx, ty) {
456
+ return tx >= BREAK_RUG.x && tx < BREAK_RUG.x + BREAK_RUG.w && ty >= BREAK_RUG.y && ty < BREAK_RUG.y + BREAK_RUG.h;
457
+ }
458
+
459
+ function drawFloor() {
460
+ for (let ty = 0; ty < MAP_H; ty += 1) {
461
+ for (let tx = 0; tx < MAP_W; tx += 1) {
462
+ const edge = tx === 0 || ty === 0 || tx === MAP_W - 1 || ty === MAP_H - 1;
463
+ let base = (tx + ty) % 2 ? "#334155" : "#3f4f63";
464
+ if (inBreakRug(tx, ty)) base = (tx + ty) % 2 ? "#4a3728" : "#5c4433";
465
+ fillRect(tx * TILE, ty * TILE, TILE, TILE, edge ? "#475569" : base);
466
+ }
467
+ }
468
+ for (let wx = 3; wx < MAP_W - 3; wx += 7) {
469
+ fillRect(wx * TILE + 4, 2, TILE - 8, 6, "#bae6fd");
470
+ fillRect(wx * TILE + 6, 4, TILE - 12, 2, "#e0f2fe");
471
+ }
472
+ fillRect(BREAK_RUG.x * TILE, BREAK_RUG.y * TILE, BREAK_RUG.w * TILE, 3, "#78716c");
473
+ }
474
+
475
+ function drawZoneProps(station) {
476
+ const x = station.x * TILE;
477
+ const y = station.y * TILE;
478
+ if (station.id === "ide") {
479
+ fillRect(x + 8, y + 8, TILE - 4, TILE - 8, "#0ea5e9");
480
+ fillRect(x + TILE, y + 10, 6, 4, "#38bdf8");
481
+ } else if (station.id === "product") {
482
+ fillRect(x + 10, y + 6, TILE * 2, 8, "#fef08a");
483
+ fillRect(x + TILE * 2, y + 8, 4, 12, "#ca8a04");
484
+ } else if (station.id === "access") {
485
+ fillRect(x + TILE, y + TILE, 8, TILE, "#991b1b");
486
+ fillRect(x + TILE + 2, y + TILE + 4, 4, 8, "#fca5a5");
487
+ } else if (station.id === "review") {
488
+ fillRect(x + 12, y + 6, TILE * 2, TILE - 4, "#ecfccb");
489
+ }
490
+ }
491
+
492
+ function drawZone(station, color) {
493
+ const x = station.x * TILE;
494
+ const y = station.y * TILE;
495
+ const w = station.w * TILE;
496
+ const h = station.h * TILE;
497
+ fillRect(x + 2, y + 2, w - 4, h - 4, color);
498
+ fillRect(x, y, w, 3, "#94a3b8");
499
+ fillRect(x, y + h - 3, w, 3, "#64748b");
500
+ fillRect(x, y, 3, h, "#94a3b8");
501
+ fillRect(x + w - 3, y, 3, h, "#64748b");
502
+ drawZoneProps(station);
503
+ }
504
+
505
+ function drawZoneLabel(station) {
506
+ ctx.fillStyle = "#e2e8f0";
507
+ ctx.font = "9px monospace";
508
+ ctx.fillText(station.label.slice(0, 14), station.x * TILE + 6, station.y * TILE + station.h * TILE - 6);
509
+ }
510
+
511
+ function drawDesk(tx, ty, lampOn, working) {
512
+ fillRect(tx * TILE, ty * TILE + TILE - 8, TILE * 3, 8, "#78716c");
513
+ fillRect(tx * TILE + 6, ty * TILE + 8, TILE * 3 - 12, 12, "#a8a29e");
514
+ fillRect(tx * TILE + TILE * 3 - 10, ty * TILE + 4, 6, 10, lampOn ? "#fbbf24" : "#64748b");
515
+ if (working) {
516
+ fillRect(tx * TILE + 10, ty * TILE + 10, TILE * 2 - 8, 10, "#1e293b");
517
+ fillRect(tx * TILE + 12, ty * TILE + 12, TILE * 2 - 12, 6, "#38bdf8");
518
+ }
519
+ }
520
+
521
+ function drawAgentSprite(px, py, role, frame, working) {
522
+ const colors = ROLE_COLORS[role] || ROLE_COLORS.ops;
523
+ const bob = state.reducedMotion ? 0 : Math.sin(frame * 0.08) * 1;
524
+ const ax = px + 8;
525
+ const ay = py + bob;
526
+ fillRect(ax + 6, ay, 10, 10, colors[0]);
527
+ fillRect(ax + 4, ay + 10, 14, 12, colors[1]);
528
+ fillRect(ax + 2, ay + 12, 5, 8, colors[1]);
529
+ fillRect(ax + 15, ay + 12, 5, 8, colors[1]);
530
+ fillRect(ax + 7, ay + 3, 2, 2, "#fff");
531
+ fillRect(ax + 11, ay + 3, 2, 2, "#fff");
532
+ if (working) {
533
+ fillRect(ax + 16, ay + 14, 8, 4, "#64748b");
534
+ }
535
+ }
536
+
537
+ function drawAgentAtDesk(station, frame, working) {
538
+ drawDesk(station.x, station.y, stationStatus(station) === "done", working);
539
+ const role = agentRole(station.agentId);
540
+ drawAgentSprite(station.x * TILE, station.y * TILE + 2, role, frame, working);
541
+ }
542
+
543
+ function drawAgent(station, frame) {
544
+ const rt = agentRuntime[station.agentId];
545
+ const working = rt?.state === "working" || (state.activeStationId === station.id && !isStudio);
546
+ if (rt && (rt.state === "walking" || rt.state === "break")) {
547
+ const px = rt.tileX * TILE;
548
+ const py = rt.tileY * TILE;
549
+ drawAgentSprite(px, py, agentRole(station.agentId), rt.frame, false);
550
+ return;
551
+ }
552
+ drawAgentAtDesk(station, frame, working);
553
+ }
554
+
555
+ function drawAmenity(station) {
556
+ const x = station.x * TILE;
557
+ const y = station.y * TILE;
558
+ if (station.amenityId === "coffee") {
559
+ fillRect(x + 4, y + 6, TILE + 8, TILE - 4, "#57534e");
560
+ fillRect(x + 10, y + 2, 10, 8, "#292524");
561
+ if (!state.reducedMotion) {
562
+ const steam = Math.sin(state.frame * 0.12 + x) * 2;
563
+ fillRect(x + 12, y - 4 + steam, 3, 6, "rgba(255,255,255,0.35)");
564
+ fillRect(x + 18, y - 6 + steam * 0.8, 3, 8, "rgba(255,255,255,0.25)");
565
+ }
566
+ } else if (station.amenityId === "cooler") {
567
+ fillRect(x + 2, y + 4, TILE + 4, TILE * 2 - 6, "#e2e8f0");
568
+ fillRect(x + 8, y + 8, 8, 10, "#38bdf8");
569
+ if (!state.reducedMotion && state.frame % 20 < 10) {
570
+ fillRect(x + 20, y + 6, 2, 2, "#fef08a");
571
+ fillRect(x + 22, y + 10, 2, 2, "#fef08a");
572
+ }
573
+ }
574
+ }
575
+
576
+ function drawHighlight(station) {
577
+ const x = station.x * TILE - 2;
578
+ const y = station.y * TILE - 2;
579
+ const w = station.w * TILE + 4;
580
+ const h = station.h * TILE + 4;
581
+ ctx.strokeStyle = "#99f6e4";
582
+ ctx.lineWidth = 2;
583
+ ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1);
584
+ }
585
+
586
+ function drawLabel(station) {
587
+ if (stationStatus(station) !== "done") return;
588
+ const cx = (station.x + station.w / 2) * TILE;
589
+ const cy = station.y * TILE - 4;
590
+ fillRect(cx - 5, cy - 5, 10, 10, "#4ade80");
591
+ fillRect(cx - 3, cy - 1, 6, 2, "#fff");
592
+ fillRect(cx - 1, cy, 2, 4, "#fff");
593
+ }
594
+
595
+ function drawDepthSweep() {
596
+ if (!state.depthSweep) return;
597
+ state.depthSweep.frame += 1;
598
+ const alpha = Math.max(0, 1 - state.depthSweep.frame / 90);
599
+ ctx.fillStyle = "rgba(153, 246, 228, " + alpha * 0.25 + ")";
600
+ ctx.fillRect(0, 0, logicalW, logicalH);
601
+ if (state.depthSweep.frame > 90) state.depthSweep = null;
602
+ }
603
+
604
+ function drawConfetti() {
605
+ state.confetti = state.confetti.filter((p) => {
606
+ p.x += p.vx;
607
+ p.y += p.vy;
608
+ p.vy += 0.05;
609
+ p.ttl -= 1;
610
+ fillRect(p.x, p.y, 3, 3, p.color);
611
+ return p.ttl > 0;
612
+ });
613
+ }
614
+
615
+ function drawHandoffPulse() {
616
+ if (!state.handoffPulse) return;
617
+ state.handoffPulse.frame += 1;
618
+ const fromSt = state.stations.find((s) => s.agentId === state.handoffPulse.from);
619
+ const toSt = state.stations.find((s) => s.agentId === state.handoffPulse.to);
620
+ if (fromSt && toSt) {
621
+ const fx = (fromSt.x + 1) * TILE;
622
+ const fy = (fromSt.y + 1) * TILE;
623
+ const tx = (toSt.x + 1) * TILE;
624
+ const ty = (toSt.y + 1) * TILE;
625
+ const t = state.handoffPulse.frame / 60;
626
+ const alpha = Math.max(0, 1 - t);
627
+ ctx.strokeStyle = "rgba(251, 191, 36, " + alpha + ")";
628
+ ctx.lineWidth = 2;
629
+ ctx.beginPath();
630
+ ctx.moveTo(fx, fy);
631
+ ctx.lineTo(tx, ty);
632
+ ctx.stroke();
633
+ }
634
+ if (state.handoffPulse.frame > 60) state.handoffPulse = null;
635
+ }
636
+
637
+ function updateAgents() {
638
+ if (state.reducedMotion || isStudio) return;
639
+ for (const station of state.stations) {
640
+ if (station.kind !== "agent" || !station.agentId) continue;
641
+ const rt = agentRuntime[station.agentId];
642
+ if (!rt) continue;
643
+ rt.frame += 1;
644
+
645
+ const panelOpenForThis = state.activeStationId === station.id;
646
+ if (panelOpenForThis) {
647
+ rt.state = "working";
648
+ rt.targetX = rt.homeX;
649
+ rt.targetY = rt.homeY;
650
+ } else if (rt.state === "working") {
651
+ rt.state = "idle";
652
+ }
653
+
654
+ if (rt.state === "idle" || rt.state === "break") {
655
+ rt.breakTimer -= 1;
656
+ if (rt.breakTimer <= 0 && rt.state === "idle") {
657
+ const amenity = state.stations.find((s) => s.kind === "amenity" && s.amenityId);
658
+ if (amenity) {
659
+ rt.state = "break";
660
+ rt.breakTarget = amenity.amenityId;
661
+ rt.targetX = amenity.x + amenity.w / 2;
662
+ rt.targetY = amenity.y + amenity.h / 2;
663
+ }
664
+ rt.breakTimer = 180 + Math.floor(Math.random() * 240);
665
+ }
666
+ if (rt.state === "break" && Math.abs(rt.tileX - rt.targetX) < 0.05 && Math.abs(rt.tileY - rt.targetY) < 0.05) {
667
+ if (rt.breakTimer <= 60) {
668
+ rt.state = "walking";
669
+ rt.targetX = rt.homeX;
670
+ rt.targetY = rt.homeY;
671
+ }
672
+ }
673
+ }
674
+
675
+ if (rt.state === "walking" || rt.state === "break") {
676
+ const speed = 0.06;
677
+ const dx = rt.targetX - rt.tileX;
678
+ const dy = rt.targetY - rt.tileY;
679
+ if (Math.abs(dx) < 0.04 && Math.abs(dy) < 0.04) {
680
+ rt.tileX = rt.targetX;
681
+ rt.tileY = rt.targetY;
682
+ if (rt.state === "walking" && rt.tileX === rt.homeX && rt.tileY === rt.homeY) rt.state = "idle";
683
+ } else {
684
+ rt.tileX += Math.sign(dx) * Math.min(Math.abs(dx), speed);
685
+ rt.tileY += Math.sign(dy) * Math.min(Math.abs(dy), speed);
686
+ rt.direction = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? "right" : "left") : dy > 0 ? "down" : "up";
687
+ }
688
+ }
689
+ }
690
+ }
691
+
692
+ function renderOffice() {
693
+ ctx.imageSmoothingEnabled = false;
694
+ drawFloor();
695
+ const stations = visibleStations();
696
+ for (const station of stations) {
697
+ if (station.kind === "zone" || station.kind === "review") {
698
+ const colors = {
699
+ ide: "#1e3a5f",
700
+ product: "#1e4035",
701
+ access: "#4a1e1e",
702
+ ui: "#3b1e4a",
703
+ messaging: "#4a3a1e",
704
+ visualQa: "#1e3a4a",
705
+ designDoc: "#2a2a4a",
706
+ messagingDoc: "#4a2a3a",
707
+ applyDrafts: "#2a4a3a",
708
+ review: "#1e4a3a"
709
+ };
710
+ drawZone(station, colors[station.id] || colors[station.section] || "#334155");
711
+ drawZoneLabel(station);
712
+ }
713
+ if (station.kind === "amenity") drawAmenity(station);
714
+ }
715
+ for (const station of stations) {
716
+ if (station.kind === "agent" && station.agentId) drawAgent(station, state.frame);
717
+ drawLabel(station);
718
+ }
719
+ drawDepthSweep();
720
+ drawHandoffPulse();
721
+ drawConfetti();
722
+ state.speechBubbles = state.speechBubbles.filter((b) => {
723
+ b.frame += 1;
724
+ return b.frame < b.ttl;
725
+ });
726
+ if (state.frame % 30 === 0) {
727
+ renderNameplates();
728
+ renderSpeechDom();
729
+ }
730
+ if (state.hoverId) {
731
+ const hovered = stations.find((s) => s.id === state.hoverId);
732
+ if (hovered) drawHighlight(hovered);
733
+ }
734
+ }
735
+
736
+ function canvasCoords(event) {
737
+ const rect = els.canvas.getBoundingClientRect();
738
+ const x = ((event.clientX - rect.left) / rect.width) * logicalW;
739
+ const y = ((event.clientY - rect.top) / rect.height) * logicalH;
740
+ return { x, y };
741
+ }
742
+
743
+ function hitTest(mx, my) {
744
+ const stations = visibleStations().slice().reverse();
745
+ for (const station of stations) {
746
+ const x = station.x * TILE;
747
+ const y = station.y * TILE;
748
+ const w = station.w * TILE;
749
+ const h = station.h * TILE;
750
+ if (mx >= x && mx <= x + w && my >= y && my <= y + h) return station;
751
+ }
752
+ return null;
753
+ }
754
+
755
+ function inputField(name, label, hint, type, placeholder, optional) {
756
+ const val = escapeHtml(state.form[name] || "");
757
+ const req = optional ? "" : " required";
758
+ if (type === "textarea") {
759
+ return (
760
+ "<label for=\"p-" +
761
+ name +
762
+ "\">" +
763
+ escapeHtml(label) +
764
+ (hint ? "<span>" + escapeHtml(hint) + "</span>" : "") +
765
+ '</label><textarea id="p-' +
766
+ name +
767
+ '" name="' +
768
+ name +
769
+ '" placeholder="' +
770
+ escapeHtml(placeholder || "") +
771
+ '"' +
772
+ req +
773
+ ">" +
774
+ val +
775
+ "</textarea>"
776
+ );
777
+ }
778
+ return (
779
+ "<label for=\"p-" +
780
+ name +
781
+ "\">" +
782
+ escapeHtml(label) +
783
+ (hint ? "<span>" + escapeHtml(hint) + "</span>" : "") +
784
+ '</label><input id="p-' +
785
+ name +
786
+ '" name="' +
787
+ name +
788
+ '" type="text" value="' +
789
+ val +
790
+ '" placeholder="' +
791
+ escapeHtml(placeholder || "") +
792
+ '"' +
793
+ req +
794
+ ">"
795
+ );
796
+ }
797
+
798
+ function renderPanelContent(station) {
799
+ if (station.kind === "agent" && station.agentId) {
800
+ const agent = state.agents.find((a) => a.id === station.agentId);
801
+ const field = "agentBrief_" + station.agentId;
802
+ return (
803
+ '<p class="agent-role">' +
804
+ escapeHtml(agent?.roleSummary || "") +
805
+ '</p><label for="p-' +
806
+ field +
807
+ '">Project briefing<span>Optional — what is unique about this project for ' +
808
+ escapeHtml(station.label) +
809
+ "?</span></label><textarea id=\"p-" +
810
+ field +
811
+ '" name="' +
812
+ field +
813
+ '" placeholder="Constraints, priorities, things not obvious from the repo…">' +
814
+ escapeHtml(state.form[field] || "") +
815
+ "</textarea>"
816
+ );
817
+ }
818
+
819
+ const maps = {
820
+ ide: () => {
821
+ const opts = (boot.ideSurfaces || [])
822
+ .map(
823
+ (s) =>
824
+ '<option value="' +
825
+ s.id +
826
+ '"' +
827
+ (state.form.ideSurface === s.id ? " selected" : "") +
828
+ ">" +
829
+ escapeHtml(s.label) +
830
+ "</option>"
831
+ )
832
+ .join("");
833
+ return (
834
+ '<label for="p-ideSurface">Primary AI coding tool</label><select id="p-ideSurface" name="ideSurface" required><option value="">Choose…</option>' +
835
+ opts +
836
+ '</select><p class="why">Instructions load from the path your IDE reads.</p>'
837
+ );
838
+ },
839
+ product: () =>
840
+ inputField("productSummary", "Product summary", "One paragraph.", "textarea", "What does it do?", false) +
841
+ '<label for="p-productCategory">Category</label><select id="p-productCategory" name="productCategory">' +
842
+ (boot.categories || [])
843
+ .map((c) => '<option value="' + c + '"' + (state.form.productCategory === c ? " selected" : "") + ">" + c + "</option>")
844
+ .join("") +
845
+ "</select>" +
846
+ inputField("primaryAudience", "Primary audience", "", "text", "Who uses or pays?", false) +
847
+ inputField("primaryWorkflows", "Top workflows", "One per line.", "textarea", "Workflow one", false),
848
+ access: () =>
849
+ '<label for="p-tenantModel">Who uses the system?</label><select id="p-tenantModel" name="tenantModel">' +
850
+ (boot.tenantModels || [])
851
+ .map((c) => '<option value="' + c + '"' + (state.form.tenantModel === c ? " selected" : "") + ">" + c + "</option>")
852
+ .join("") +
853
+ "</select>" +
854
+ inputField("owner", "Project owner", "Optional.", "text", "", true) +
855
+ (boot.hasSupabase
856
+ ? '<div class="hint-box">Supabase detected.<button type="button" id="apply-supabase-auth">Insert auth baseline</button></div>'
857
+ : "") +
858
+ inputField("authModel", "Authentication model", "Rules agents must preserve.", "textarea", "Describe auth boundaries.", false),
859
+ ui: () =>
860
+ inputField("uiPreferred", "UI should feel like…", "", "textarea", "Clear, readable, task-first.", false) +
861
+ inputField("uiAvoid", "UI should avoid…", "Optional.", "textarea", "Generic dashboards.", true),
862
+ messaging: () => {
863
+ const q = state.form.qualityTarget || "baseline-setup";
864
+ return (
865
+ inputField("valueProposition", "Value proposition", "", "textarea", "What outcome do users get?", false) +
866
+ inputField("proof", "Proof points", "One per line.", "textarea", "", true) +
867
+ inputField("objections", "Objections", "One per line.", "textarea", "", true) +
868
+ '<label for="p-qualityTarget">Quality target</label><select id="p-qualityTarget" name="qualityTarget">' +
869
+ ["baseline-setup", "needs-improvement", "best-practice-candidate"]
870
+ .map((v) => '<option value="' + v + '"' + (q === v ? " selected" : "") + ">" + v + "</option>")
871
+ .join("") +
872
+ "</select>"
873
+ );
874
+ },
875
+ visualQa: () => {
876
+ const t = state.form.visualQaTier || "baseline";
877
+ return (
878
+ '<label for="p-visualQaTier">Visual QA tier</label><select id="p-visualQaTier" name="visualQaTier">' +
879
+ [
880
+ ["baseline", "Baseline — manual screenshot review"],
881
+ ["strong", "Strong — Playwright + review"],
882
+ ["mature", "Mature — visual regression CI"]
883
+ ]
884
+ .map(([v, l]) => '<option value="' + v + '"' + (t === v ? " selected" : "") + ">" + escapeHtml(l) + "</option>")
885
+ .join("") +
886
+ "</select>"
887
+ );
888
+ },
889
+ designDoc: () =>
890
+ inputField("designAudience", "Design audience", "", "text", state.form.primaryAudience || "", true) +
891
+ inputField("designContent", "Content inventory", "", "textarea", "", true) +
892
+ inputField("designAntiReferences", "Anti-references", "", "textarea", "", true),
893
+ messagingDoc: () =>
894
+ inputField("msgAudience", "Primary audience", "", "text", state.form.primaryAudience || "", true) +
895
+ inputField("msgPain", "Painful problem", "", "textarea", "", true) +
896
+ inputField("msgOutcome", "Desired outcome", "", "textarea", "", true),
897
+ review: () => '<p class="why">Use Review &amp; save in the header when you are ready.</p>'
898
+ };
899
+
900
+ const section = station.section;
901
+ return maps[section] ? maps[section]() : "<p>No fields for this station.</p>";
902
+ }
903
+
904
+ function openStation(stationId) {
905
+ if (isStudio) return;
906
+ if (state.depth === "undecided") {
907
+ showDepthModal();
908
+ return;
909
+ }
910
+ const station = visibleStations().find((s) => s.id === stationId);
911
+ if (!station) return;
912
+ if (station.kind === "amenity") {
913
+ const msgs = AMENITY_MSG[station.amenityId] || ["Nice break."];
914
+ setStatus("ok", msgs[Math.floor(Math.random() * msgs.length)]);
915
+ return;
916
+ }
917
+ if (station.kind === "review") {
918
+ openReview();
919
+ return;
920
+ }
921
+ state.activeStationId = stationId;
922
+ els.panelTitle.textContent = station.label;
923
+ els.panelBody.innerHTML = renderPanelContent(station);
924
+ els.panel.classList.remove("hidden");
925
+ const applyBtn = document.getElementById("apply-supabase-auth");
926
+ if (applyBtn) {
927
+ applyBtn.addEventListener("click", () => {
928
+ const el = document.querySelector("#panel-body [name='authModel']");
929
+ if (el) el.value = boot.recommendedSupabaseAuth || "";
930
+ });
931
+ }
932
+ const first = els.panelBody.querySelector("input, textarea, select");
933
+ if (first) first.focus();
934
+ }
935
+
936
+ function closePanel() {
937
+ els.panel.classList.add("hidden");
938
+ state.activeStationId = null;
939
+ }
940
+
941
+ async function markStationProgress(station) {
942
+ const sectionId = wizardSectionForStation(station);
943
+ if (!sectionId) return;
944
+ if (sectionId === "team" && !allAgentBriefsComplete()) return;
945
+ const prev = stationStatus(station);
946
+ await api("/api/state", {
947
+ method: "PATCH",
948
+ headers: { "Content-Type": "application/json" },
949
+ body: JSON.stringify({ completeSection: sectionId, currentSection: sectionId })
950
+ });
951
+ const data = await api("/api/state");
952
+ state.progress = data.progress;
953
+ state.onboarding = data.onboarding;
954
+ if (prev !== "done" && stationStatus(station) === "done") {
955
+ const cx = (station.x + station.w / 2) * TILE;
956
+ const cy = station.y * TILE;
957
+ spawnConfetti(cx, cy);
958
+ }
959
+ }
960
+
961
+ async function savePanel() {
962
+ collectPanelForm();
963
+ const station = visibleStations().find((s) => s.id === state.activeStationId);
964
+ if (!station) return;
965
+ try {
966
+ await api("/api/draft", {
967
+ method: "POST",
968
+ headers: { "Content-Type": "application/json" },
969
+ body: JSON.stringify({ form: state.form })
970
+ });
971
+ if (station.section === "ide" && fieldValue("ideSurface")) {
972
+ await api("/api/checklist/ide", {
973
+ method: "POST",
974
+ headers: { "Content-Type": "application/json" },
975
+ body: JSON.stringify({ ideSurface: fieldValue("ideSurface") })
976
+ });
977
+ }
978
+ if (station.section === "visualQa" && fieldValue("visualQaTier")) {
979
+ await api("/api/checklist/visual-qa", {
980
+ method: "POST",
981
+ headers: { "Content-Type": "application/json" },
982
+ body: JSON.stringify({ tier: fieldValue("visualQaTier") })
983
+ });
984
+ }
985
+ if (station.section === "designDoc") {
986
+ await api("/api/drafts/design", {
987
+ method: "POST",
988
+ headers: { "Content-Type": "application/json" },
989
+ body: JSON.stringify({
990
+ audience: fieldValue("designAudience"),
991
+ contentInventory: fieldValue("designContent"),
992
+ antiReferences: fieldValue("designAntiReferences")
993
+ })
994
+ });
995
+ }
996
+ if (station.section === "messagingDoc") {
997
+ await api("/api/drafts/messaging", {
998
+ method: "POST",
999
+ headers: { "Content-Type": "application/json" },
1000
+ body: JSON.stringify({
1001
+ audience: fieldValue("msgAudience"),
1002
+ pain: fieldValue("msgPain"),
1003
+ outcome: fieldValue("msgOutcome")
1004
+ })
1005
+ });
1006
+ }
1007
+ await markStationProgress(station);
1008
+ updateProgressUi();
1009
+ renderStationList();
1010
+ closePanel();
1011
+ setStatus("ok", "Saved " + station.label);
1012
+ } catch (error) {
1013
+ setStatus("error", error.message);
1014
+ }
1015
+ }
1016
+
1017
+ function openReview() {
1018
+ const ide = (boot.ideSurfaces || []).find((s) => s.id === state.form.ideSurface);
1019
+ const briefCount = Object.keys(state.form).filter((k) => k.startsWith("agentBrief_") && state.form[k]?.trim()).length;
1020
+ const items = [
1021
+ ["IDE", ide ? ide.label : state.form.ideSurface || "—"],
1022
+ ["Agent briefings", briefCount ? briefCount + " specialist(s)" : "—"],
1023
+ ["Product", state.form.productSummary || "—"],
1024
+ ["Audience", state.form.primaryAudience || "—"],
1025
+ ["Auth", state.form.authModel || "—"],
1026
+ ["Value prop", state.form.valueProposition || "—"],
1027
+ ["Quality", state.form.qualityTarget || "—"]
1028
+ ];
1029
+ els.reviewList.innerHTML = items.map(([k, v]) => "<div><dt>" + escapeHtml(k) + "</dt><dd>" + escapeHtml(v) + "</dd></div>").join("");
1030
+ els.reviewModal.classList.remove("hidden");
1031
+ }
1032
+
1033
+ async function saveProject() {
1034
+ try {
1035
+ await api("/api/context", {
1036
+ method: "POST",
1037
+ headers: { "Content-Type": "application/json" },
1038
+ body: JSON.stringify(state.form)
1039
+ });
1040
+ els.reviewModal.classList.add("hidden");
1041
+ setStatus("ok", "Saved project context and agent briefings.");
1042
+ const data = await api("/api/state");
1043
+ state.progress = data.progress;
1044
+ updateProgressUi();
1045
+ renderStationList();
1046
+ } catch (error) {
1047
+ setStatus("error", error.message);
1048
+ }
1049
+ }
1050
+
1051
+ if (!isStudio) {
1052
+ els.canvas.addEventListener("mousemove", (event) => {
1053
+ const { x, y } = canvasCoords(event);
1054
+ const hit = hitTest(x, y);
1055
+ state.hoverId = hit ? hit.id : null;
1056
+ if (hit) {
1057
+ els.hoverLabel.textContent = hit.label + (stationStatus(hit) === "done" ? " ✓" : "");
1058
+ els.hoverLabel.classList.remove("hidden");
1059
+ els.hoverLabel.style.left = event.clientX + 12 + "px";
1060
+ els.hoverLabel.style.top = event.clientY + 12 + "px";
1061
+ } else {
1062
+ els.hoverLabel.classList.add("hidden");
1063
+ }
1064
+ });
1065
+
1066
+ els.canvas.addEventListener("mouseleave", () => {
1067
+ state.hoverId = null;
1068
+ els.hoverLabel.classList.add("hidden");
1069
+ });
1070
+
1071
+ els.canvas.addEventListener("click", (event) => {
1072
+ const { x, y } = canvasCoords(event);
1073
+ const hit = hitTest(x, y);
1074
+ if (hit) openStation(hit.id);
1075
+ });
1076
+
1077
+ if (els.panelClose) els.panelClose.addEventListener("click", closePanel);
1078
+ if (els.panelCancel) els.panelCancel.addEventListener("click", closePanel);
1079
+ if (els.panelSave) els.panelSave.addEventListener("click", savePanel);
1080
+ if (els.reviewBtn) els.reviewBtn.addEventListener("click", openReview);
1081
+ if (els.reviewCancel) els.reviewCancel.addEventListener("click", () => els.reviewModal.classList.add("hidden"));
1082
+ if (els.reviewSave) els.reviewSave.addEventListener("click", saveProject);
1083
+
1084
+ document.addEventListener("keydown", (event) => {
1085
+ if (event.key === "Escape") {
1086
+ closePanel();
1087
+ if (els.reviewModal) els.reviewModal.classList.add("hidden");
1088
+ }
1089
+ });
1090
+ }
1091
+
1092
+ function loop() {
1093
+ state.frame += 1;
1094
+ updateAgents();
1095
+ renderOffice();
1096
+ window.requestAnimationFrame(loop);
1097
+ }
1098
+
1099
+ loop();
1100
+ loadState().catch((error) => setStatus("error", error.message));
1101
+ window.addEventListener("resize", () => {
1102
+ renderNameplates();
1103
+ renderSpeechDom();
1104
+ });
1105
+ })();