@aarwitz/tapp 0.15.0

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 (77) hide show
  1. package/AGENTS.md +123 -0
  2. package/Harness/OCQAHarness/AppDelegate.swift +21 -0
  3. package/Harness/OCQAHarness/Info.plist +26 -0
  4. package/Harness/OCQAHarness.xcodeproj/project.pbxproj +199 -0
  5. package/Harness/OCQAHarness.xcodeproj/xcshareddata/xcschemes/OCQAHarnessUITests.xcscheme +22 -0
  6. package/Harness/OCQAHarnessUITests/ExplorerTests.swift +4526 -0
  7. package/Harness/OCQAHarnessUITests/Info.plist +22 -0
  8. package/Harness/generate-harness-xcodeproj.rb +254 -0
  9. package/LICENSE +21 -0
  10. package/README.md +374 -0
  11. package/bin/tapp.js +1382 -0
  12. package/browser/app.css +227 -0
  13. package/browser/app.js +675 -0
  14. package/browser/index.html +195 -0
  15. package/browser/product-contract.js +25 -0
  16. package/browser/view-model.js +16 -0
  17. package/docs/BROWSER-PRODUCT.md +72 -0
  18. package/docs/PRODUCT-ENGINE.md +102 -0
  19. package/docs/application-model.md +276 -0
  20. package/docs/scenarios.md +95 -0
  21. package/mcp-server/src/android-driver.js +287 -0
  22. package/mcp-server/src/android-explorer.js +197 -0
  23. package/mcp-server/src/android-flow.js +89 -0
  24. package/mcp-server/src/application-model.js +1597 -0
  25. package/mcp-server/src/browser-product.js +659 -0
  26. package/mcp-server/src/browser-workspaces.js +234 -0
  27. package/mcp-server/src/ci-report.js +557 -0
  28. package/mcp-server/src/ci-setup.js +359 -0
  29. package/mcp-server/src/contract-authoring.js +10 -0
  30. package/mcp-server/src/enrich.js +57 -0
  31. package/mcp-server/src/flow-runtime.js +127 -0
  32. package/mcp-server/src/html-report.js +124 -0
  33. package/mcp-server/src/index.js +3775 -0
  34. package/mcp-server/src/maintenance-proposal.js +178 -0
  35. package/mcp-server/src/managed-operation.js +61 -0
  36. package/mcp-server/src/pr-selection.js +841 -0
  37. package/mcp-server/src/product-execution.js +155 -0
  38. package/mcp-server/src/product-operations.js +526 -0
  39. package/mcp-server/src/project-config.js +101 -0
  40. package/mcp-server/src/release-contract.d.ts +81 -0
  41. package/mcp-server/src/release-contract.js +226 -0
  42. package/mcp-server/src/report.js +363 -0
  43. package/mcp-server/src/scenario-runtime.js +139 -0
  44. package/mcp-server/src/static-server.js +44 -0
  45. package/mcp-server/src/task-runtime.js +266 -0
  46. package/mcp-server/src/ui-map.js +661 -0
  47. package/mcp-server/src/web-explorer.js +493 -0
  48. package/mcp-server/src/web-flow.js +238 -0
  49. package/package.json +82 -0
  50. package/scripts/android-corpus-e2e.sh +30 -0
  51. package/scripts/ci-gate.sh +323 -0
  52. package/scripts/cleanup-xcode.sh +157 -0
  53. package/scripts/compile-contract.js +27 -0
  54. package/scripts/compile-flow.js +18 -0
  55. package/scripts/corpus-apps.txt +9 -0
  56. package/scripts/corpus-sweep.sh +121 -0
  57. package/scripts/coverage-eval.sh +92 -0
  58. package/scripts/coverage_eval_parse.py +95 -0
  59. package/scripts/deploy-and-build.sh +99 -0
  60. package/scripts/flow-platform.js +18 -0
  61. package/scripts/flow_ai_judge.py +102 -0
  62. package/scripts/flow_lib.py +154 -0
  63. package/scripts/mutation-recall-desktop.sh +186 -0
  64. package/scripts/mutation-recall.sh +121 -0
  65. package/scripts/mutation_lib.py +128 -0
  66. package/scripts/mutation_operators.py +144 -0
  67. package/scripts/platform-gate.js +186 -0
  68. package/scripts/pr-plan.js +68 -0
  69. package/scripts/quick-capture.sh +419 -0
  70. package/scripts/run-android-flow.js +27 -0
  71. package/scripts/run-flow.sh +90 -0
  72. package/scripts/run-web-flow.js +28 -0
  73. package/scripts/run-web-scenario.js +23 -0
  74. package/scripts/validation-matrix.sh +146 -0
  75. package/scripts/vision-fp-eval.sh +206 -0
  76. package/scripts/vision_escalation_responder.py +147 -0
  77. package/scripts/vision_fp_probe.py +221 -0
package/browser/app.js ADDED
@@ -0,0 +1,675 @@
1
+ import { customerProductContract } from "./product-contract.js";
2
+ import { operationIsPending, productJourneyFlags } from "./view-model.js";
3
+
4
+ const API_BASE = String(document.querySelector('meta[name="tapp-api-base"]')?.content || "").replace(/\/$/, "");
5
+ const LOGIN_URL = String(document.querySelector('meta[name="tapp-login-url"]')?.content || "");
6
+ function apiRoute(route) {
7
+ if (!API_BASE) return route;
8
+ if (route === "/api" || route.startsWith("/api/")) return `${API_BASE}${route.slice(4)}`;
9
+ if (route === "/evidence" || route.startsWith("/evidence/")) return `${API_BASE}${route}`;
10
+ return route;
11
+ }
12
+
13
+ const state = {
14
+ csrf: "", session: null, project: null, activeJob: null, activeView: "overview",
15
+ selectedTargetId: "", selectedRunId: "", selectedFindingId: "", githubRepositories: [],
16
+ uploadedEntries: [], live: { active:false },
17
+ };
18
+ const $ = (selector) => document.querySelector(selector);
19
+ const $$ = (selector) => [...document.querySelectorAll(selector)];
20
+ const esc = (value) => String(value ?? "").replace(/[&<>"']/g, (character) => ({ "&":"&amp;", "<":"&lt;", ">":"&gt;", '"':"&quot;", "'":"&#39;" }[character]));
21
+ const pretty = (value) => String(value || "").replaceAll("_", " ").replaceAll("-", " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
22
+ const compactDate = (value) => value ? new Intl.DateTimeFormat(undefined, { month:"short", day:"numeric", hour:"numeric", minute:"2-digit" }).format(new Date(value)) : "—";
23
+
24
+ function toast(message, error = false) {
25
+ const node = $("#toast");
26
+ node.textContent = message;
27
+ node.className = `toast${error ? " error" : ""}`;
28
+ clearTimeout(node._timer);
29
+ node._timer = setTimeout(() => node.classList.add("hidden"), 6000);
30
+ }
31
+
32
+ async function api(route, options = {}) {
33
+ const mutation = options.method && options.method !== "GET";
34
+ const headers = { ...(options.body !== undefined ? { "content-type":"application/json" } : {}), ...(mutation ? { "x-tapp-csrf":state.csrf } : {}), ...(options.headers || {}) };
35
+ const response = await fetch(apiRoute(route), { ...options, headers });
36
+ const data = await response.json().catch(() => ({}));
37
+ if (response.status === 401 && LOGIN_URL) {
38
+ location.replace(LOGIN_URL);
39
+ throw Object.assign(new Error("Sign in to continue"), { status:401, code:"authentication-required" });
40
+ }
41
+ if (!response.ok) throw Object.assign(new Error(data.error || `Request failed (${response.status})`), { status: response.status, code: data.code });
42
+ return data;
43
+ }
44
+
45
+ function captureId(report) {
46
+ const values = [report?.relativeMarkersFilePath, report?.uiMap?.path, report?.reportHtml, report?.recording, report?.capture?.path].filter(Boolean);
47
+ for (const value of values) {
48
+ const match = String(value).replaceAll("\\", "/").match(/\/captures\/([^/]+)/);
49
+ if (match) return match[1];
50
+ }
51
+ return report?.capture?.id || "";
52
+ }
53
+
54
+ function reportLink(report, label = "Open visual evidence") {
55
+ const id = captureId(report);
56
+ return id ? `<a class="button-link" href="${esc(apiRoute(`/evidence/captures/${encodeURIComponent(id)}/report.html`))}" target="_blank" rel="noreferrer">${esc(label)}</a>` : "";
57
+ }
58
+
59
+ function currentTarget() { return (state.project?.targets || []).find((target) => target.id === state.selectedTargetId) || null; }
60
+
61
+ function targetStorageKey() { return `tapp:selected-target:${state.project?.repository?.id || "none"}`; }
62
+
63
+ function chooseDefaultTarget() {
64
+ const targets = state.project?.targets || [];
65
+ if (targets.some((target) => target.id === state.selectedTargetId)) return;
66
+ const remembered = localStorage.getItem(targetStorageKey());
67
+ if (remembered && targets.some((target) => target.id === remembered)) state.selectedTargetId = remembered;
68
+ else state.selectedTargetId = targets.length === 1 ? targets[0].id : "";
69
+ }
70
+
71
+ function operationPayload(extra = {}) {
72
+ const target = currentTarget();
73
+ return {
74
+ ...(target ? { target:target.id, platform:target.platform } : {}),
75
+ ...(target?.platform === "android" && $("#android-serial")?.value.trim() ? { serial:$("#android-serial").value.trim() } : {}),
76
+ ...($("#test-email")?.value ? { testEmail:$("#test-email").value } : {}),
77
+ ...($("#test-password")?.value ? { testPassword:$("#test-password").value } : {}),
78
+ ...extra,
79
+ };
80
+ }
81
+
82
+ function activateView(view) {
83
+ if (!customerProductContract.views.some((item) => item.id === view)) view = "overview";
84
+ state.activeView = view;
85
+ $$('[data-view-panel]').forEach((panel) => panel.classList.toggle("active", panel.dataset.viewPanel === view));
86
+ $$('.primary-nav [data-view]').forEach((button) => button.classList.toggle("active", button.dataset.view === view));
87
+ history.replaceState(null, "", `#${view}`);
88
+ window.scrollTo({ top:0, behavior:"instant" });
89
+ }
90
+
91
+ function showSourceOnboarding() {
92
+ $("#source-onboarding").classList.remove("hidden");
93
+ $("#product-shell").classList.add("hidden");
94
+ }
95
+
96
+ function showProduct() {
97
+ $("#source-onboarding").classList.add("hidden");
98
+ $("#product-shell").classList.remove("hidden");
99
+ activateView(location.hash.slice(1) || state.activeView);
100
+ }
101
+
102
+ function renderRepositoryChrome() {
103
+ const repositories = state.session?.repositories || [];
104
+ const current = state.project?.repository || state.session?.repository;
105
+ const switcher = $("#repository-switcher");
106
+ switcher.classList.toggle("hidden", repositories.length < 1);
107
+ switcher.innerHTML = repositories.map((repository) => `<option value="${esc(repository.id)}" ${repository.id === current?.id ? "selected" : ""}>${esc(repository.name)} · ${esc(repository.source?.kind || "repository")}</option>`).join("") + '<option value="__add__">+ Add repository…</option>';
108
+ $("#refresh-project").classList.toggle("hidden", !current);
109
+ if (!current) return;
110
+ $("#sidebar-project-name").textContent = state.project?.application?.name || current.name;
111
+ $("#sidebar-project-source").textContent = current.source?.label || current.root || "Repository";
112
+ $("#project-avatar").textContent = (state.project?.application?.name || current.name || "T").slice(0, 1).toUpperCase();
113
+ $("#repository-title").textContent = current.source?.label || current.name;
114
+ $("#repository-detail").textContent = current.source?.note || (current.ephemeral ? "This is an isolated working copy. Review and export changes before updating the source repository." : `Direct local checkout · ${current.root}`);
115
+ $("#settings-repository").innerHTML = `<dl class="settings-dl"><div><dt>Source</dt><dd>${esc(pretty(current.source?.kind))}</dd></div><div><dt>Location</dt><dd><code>${esc(current.root)}</code></dd></div><div><dt>Write mode</dt><dd>${current.ephemeral ? "Isolated review copy" : "Direct checkout"}</dd></div></dl>`;
116
+ }
117
+
118
+ function renderTargets() {
119
+ const targets = state.project?.targets || [];
120
+ chooseDefaultTarget();
121
+ const requiresChoice = targets.length > 1 && !currentTarget();
122
+ $("#target-stage").classList.toggle("requires-choice", requiresChoice);
123
+ $("#target-choice-callout").classList.toggle("hidden", !requiresChoice);
124
+ $("#targets").innerHTML = targets.length ? targets.map((target) => {
125
+ const selected = target.id === state.selectedTargetId;
126
+ const detail = target.platform === "ios" ? `${target.build?.proposedScheme || "scheme unresolved"} · ${target.runtime?.surface || "iOS Simulator"}`
127
+ : target.platform === "android" ? `${target.runtime?.applicationId || "application id unresolved"} · ${target.build?.task || "assembleDebug"}`
128
+ : `${target.runtime?.management || "runtime unresolved"} · ${target.build?.start || target.build?.tool || "web"}`;
129
+ return `<button type="button" class="target-card ${selected ? "selected" : ""}" data-target-id="${esc(target.id)}" aria-pressed="${selected ? "true" : "false"}"><span class="platform-icon ${esc(target.platform)}">${target.platform === "ios" ? "iOS" : target.platform === "android" ? "A" : "W"}</span><span><b>${esc(target.name)}</b><small>${esc(pretty(target.platform))} · ${esc(detail)}</small><em class="target-state ${esc(target.status)}">${esc(pretty(target.status))}</em></span><i>${selected ? "✓" : ""}</i></button>`;
130
+ }).join("") : '<div class="empty">Inspect the repository to detect iOS, Android, and web targets.</div>';
131
+ const target = currentTarget();
132
+ $("#target-status").textContent = target ? `${pretty(target.platform)} · ${target.name}` : targets.length > 1 ? "Selection required" : "No target selected";
133
+ $("#sidebar-target").textContent = target ? `${pretty(target.platform)} · ${target.name}` : "Select a target";
134
+ $("#sidebar-runner").textContent = target ? (state.session?.runners?.find((runner) => runner.platform === target.platform)?.label || "Runner resolves at execution") : "Runner not selected";
135
+ $("#runtime-row").classList.toggle("hidden", !targets.length);
136
+ $("#serial-field").classList.toggle("hidden", target?.platform !== "android");
137
+ $("#explore").disabled = !target;
138
+ $("#explore").textContent = target && targets.length > 1 ? `Build & explore ${target.name}` : "Build, launch & explore";
139
+ $("#start-session").disabled = !target || state.live?.active === true;
140
+ $("#explore-help").textContent = !target ? (targets.length > 1 ? `Choose one of the ${targets.length} detected targets. Tapp will not select one for you.` : "Inspect to discover a runnable target.")
141
+ : target.platform === "ios" ? "Builds the selected scheme for an iOS simulator, installs it, then explores."
142
+ : target.platform === "android" ? "Builds the selected Gradle application, installs its APK, then explores."
143
+ : "Builds and starts the selected repository web application, then explores it in Chromium.";
144
+ }
145
+
146
+ function refreshLiveFrame() {
147
+ if (!state.live?.active) return;
148
+ const image = $("#live-frame-image");
149
+ const loading = $("#live-frame-loading");
150
+ loading.classList.remove("hidden");
151
+ image.onload = () => loading.classList.add("hidden");
152
+ image.onerror = () => { loading.textContent = "Live frame could not be captured. The semantic controls remain available."; };
153
+ image.src = apiRoute(`/api/live-session/frame?at=${Date.now()}`);
154
+ }
155
+
156
+ function renderLiveSession() {
157
+ const live = state.live || { active:false };
158
+ $("#live-session-panel").classList.toggle("hidden", !live.active);
159
+ if (!live.active) return;
160
+ $("#live-screen-title").textContent = live.screenTitle || live.targetName || "Running target";
161
+ $("#live-platform").textContent = `${pretty(live.platform)} · ${live.targetName || "live target"}`;
162
+ $("#live-recorded-steps").textContent = `${live.recordedSteps || 0} recorded step${live.recordedSteps === 1 ? "" : "s"}`;
163
+ $("#save-live-flow").disabled = !live.recordedSteps;
164
+ const controls = (live.elements || []).filter((element) => element.label || element.id);
165
+ $("#live-control-count").textContent = `${controls.length} available`;
166
+ $("#live-control-list").innerHTML = controls.length ? controls.map((element, index) => {
167
+ const target = element.id || element.label;
168
+ const input = element.role === "input" || element.secure || /input|textfield|edittext|textarea|secure/i.test(element.type);
169
+ return `<button class="live-control" data-live-control="${index}" data-live-target="${esc(target)}" data-live-input="${input ? "true" : "false"}" ${element.enabled === false || (!element.hittable && !input) ? "disabled" : ""}><span><strong>${esc(element.label || element.id)}</strong><small>${esc(element.id || "No stable identifier")} · ${esc(element.type || element.role || "control")}</small></span><em>${input ? "fill" : element.clickable ? "tap" : "inspect"}</em></button>`;
170
+ }).join("") : '<div class="empty">The current screen exposed no semantic controls. Add accessibility identifiers or use the map evidence to remediate.</div>';
171
+ refreshLiveFrame();
172
+ }
173
+
174
+ function renderRequirements() {
175
+ const requirements = state.project?.requirements || [];
176
+ $("#requirements").innerHTML = requirements.length ? `<ul class="requirements">${requirements.map((item) => `<li class="${item.severity === "blocking" ? "blocking" : ""}"><span>${item.severity === "blocking" ? "!" : "i"}</span><div><b>${esc(item.message)}</b><small>${esc(item.remediation || "")}</small></div></li>`).join("")}</ul>` : "";
177
+ }
178
+
179
+ function renderMap(map) {
180
+ const nodes = map?.nodes || [];
181
+ const edges = map?.edges || [];
182
+ const controls = nodes.reduce((total, node) => total + (node.controls?.length || 0), 0);
183
+ const covered = nodes.filter((node) => (node.coveredBy?.tasks?.length || 0) + (node.coveredBy?.contracts?.length || 0) > 0).length;
184
+ $("#map-metric").textContent = `${nodes.length} states · ${edges.length} transitions`;
185
+ $("#map-canvas").innerHTML = nodes.length ? nodes.map((node, index) => {
186
+ const tasks = node.coveredBy?.tasks || [], contracts = node.coveredBy?.contracts || [];
187
+ return `<article class="map-node" style="--node-order:${index}"><div class="map-node-title"><span>${String(index + 1).padStart(2, "0")}</span><b>${esc(node.stateLabel || node.name)}</b></div><small>${node.controls?.length || 0} controls · ${esc((node.platforms || []).join("/"))} · ${esc(node.status)}</small><div class="coverage">${tasks.map((item) => `<span class="tag covered">Task: ${esc(item)}</span>`).join("")}${contracts.map((item) => `<span class="tag covered">Contract: ${esc(item)}</span>`).join("")}${!tasks.length && !contracts.length ? '<span class="tag">uncovered</span>' : ""}</div></article>`;
188
+ }).join("") : '<div class="empty">Explore the real product to build its map.</div>';
189
+ $("#coverage-summary").innerHTML = `<p class="eyebrow">MAP COVERAGE</p><dl><div><dt>States</dt><dd>${nodes.length}</dd></div><div><dt>Transitions</dt><dd>${edges.length}</dd></div><div><dt>Controls</dt><dd>${controls}</dd></div><div><dt>Covered states</dt><dd>${covered}/${nodes.length}</dd></div></dl><p class="coverage-honesty">Coverage means observed and contract-linked UI states—not source-line coverage or proof of every possible input.</p>`;
190
+ $("#overview-coverage").innerHTML = `<div><strong>${nodes.length}</strong><small>states observed</small></div><div><strong>${edges.length}</strong><small>transitions</small></div><div><strong>${controls}</strong><small>semantic controls</small></div><div><strong>${nodes.length ? Math.round(covered / nodes.length * 100) : 0}%</strong><small>contract-linked</small></div>`;
191
+ $("#transition-list").innerHTML = edges.length ? `<h3>Observed transitions</h3>${edges.map((edge) => `<div><code>${esc(edge.from)}</code><span>→ ${esc(edge.action?.target || edge.action?.label || edge.action || "action")} →</span><code>${esc(edge.to)}</code><em>${esc(edge.status)}</em></div>`).join("")}` : "";
192
+ $("#screen-list").innerHTML = nodes.length ? `<table><thead><tr><th>Screen / state</th><th>Platform</th><th>Status</th><th>Controls</th><th>Coverage</th></tr></thead><tbody>${nodes.map((node) => `<tr><td><strong>${esc(node.stateLabel || node.name)}</strong><small><code>${esc(node.id)}</code></small></td><td>${esc((node.platforms || []).join(", "))}</td><td>${esc(node.status)}</td><td>${node.controls?.length || 0}</td><td>${(node.coveredBy?.tasks?.length || 0)} Tasks · ${(node.coveredBy?.contracts?.length || 0)} contracts</td></tr>`).join("")}</tbody></table>` : '<div class="empty">No observed screens yet.</div>';
193
+ }
194
+
195
+ function decisionValue(value) { return value === "accepted" ? "approved" : value || "pending"; }
196
+
197
+ function planHtml(plan) {
198
+ const items = plan?.items || [];
199
+ return items.length ? items.map((item) => `<article class="plan-item" data-id="${esc(item.id)}" data-criticality="${esc(item.criticality)}" data-original="${esc(decisionValue(item.decision))}"><div><div class="plan-title"><h3>${esc(item.title)}</h3><span class="criticality ${esc(item.criticality)}">${esc(item.criticality)}</span></div><p>${esc(item.businessValue)}</p><small class="risk">${esc(item.risk || item.requiredValidation || "Requires real deterministic validation")}</small><div class="plan-meta"><span class="tag">${esc(item.origin)}</span>${(item.platforms || []).map((platform) => `<span class="tag">${esc(platform)}</span>`).join("")}${(item.actors || []).map((actor) => `<span class="tag">actor: ${esc(actor)}</span>`).join("")}${item.generation ? `<span class="tag ${item.generation.trusted ? "covered" : "warning"}">${esc(item.generation.status)}</span>` : ""}</div></div><select aria-label="Decision for ${esc(item.title)}" ${item.origin === "committed" ? "disabled" : ""}><option value="pending" ${decisionValue(item.decision)==="pending"?"selected":""}>Needs review</option><option value="approved" ${decisionValue(item.decision)==="approved"?"selected":""}>Approve</option><option value="deferred" ${decisionValue(item.decision)==="deferred"?"selected":""}>Defer</option><option value="rejected" ${decisionValue(item.decision)==="rejected"?"selected":""}>Reject</option></select></article>`).join("") : '<div class="empty">Inspect and explore to create a grounded release plan.</div>';
200
+ }
201
+
202
+ function renderPlan(plan) {
203
+ const items = plan?.items || [];
204
+ const pending = items.filter((item) => item.decision === "pending").length;
205
+ $("#plan-status").textContent = plan ? `${items.length} proposed · ${pending} pending` : "No plan";
206
+ $("#plan-list").innerHTML = planHtml(plan);
207
+ $("#coverage-plan-list").innerHTML = planHtml(plan);
208
+ $("#contract-count").textContent = state.project?.model?.artifacts?.contracts?.length || 0;
209
+ }
210
+
211
+ function renderArtifacts(model, plan) {
212
+ const tasks = model?.artifacts?.tasks || [];
213
+ const contracts = model?.artifacts?.contracts || [];
214
+ const flows = state.project?.flows || [];
215
+ const items = plan?.items || [];
216
+ const generated = items.filter((item) => item.generation?.path);
217
+ const drafts = generated.filter((item) => item.generation.status !== "promoted");
218
+ const validatedDrafts = drafts.filter((item) => item.generation?.trusted === true || item.generation?.status === "validated-draft");
219
+ const promoted = generated.filter((item) => item.generation.status === "promoted");
220
+ $("#validation-status").textContent = validatedDrafts.length ? "Validated drafts available" : drafts.length ? `${drafts.length} untrusted draft(s)` : promoted.length || contracts.length ? "Promoted suite ready" : "Not generated";
221
+ $("#generate").disabled = !items.some((item) => item.decision === "approved" && item.origin !== "committed" && !item.generation?.path);
222
+ $("#validate-drafts").disabled = !drafts.length || !currentTarget();
223
+ $("#promote").disabled = !validatedDrafts.length;
224
+ const artifacts = [
225
+ ...contracts.map((item) => ({ kind:"Contract", name:item.name, path:item.path, status:"committed", platforms:item.platforms || [] })),
226
+ ...tasks.map((item) => ({ kind:"Task", name:item.name, path:item.path, status:"committed", platforms:item.platforms || Object.keys(item.implementations || {}) })),
227
+ ...flows.map((item) => ({ kind:"Flow", name:item.name, path:item.path, status:item.status, platforms:[item.platform], steps:item.steps })),
228
+ ...drafts.map((item) => ({ kind:"Draft", name:item.name, path:item.generation.path, status:item.generation.trusted ? "validated" : item.generation.status || "untrusted", platforms:item.platforms || [] })),
229
+ ];
230
+ $("#artifact-list").innerHTML = artifacts.length ? artifacts.map((item) => `<div class="artifact"><span><b>${esc(item.kind)}</b> · ${esc(item.name)} <span class="tag ${item.status === "validated" || item.status === "committed" ? "covered" : "warning"}">${esc(item.status)}</span>${item.steps ? ` <small>${item.steps} steps</small>` : ""}</span><span>${(item.platforms || []).map((platform) => `<i>${esc(platform)}</i>`).join("")}<code>${esc(item.path)}</code></span></div>`).join("") : '<div class="empty">No generated or committed Tasks, Flows, or contracts yet.</div>';
231
+ }
232
+
233
+ function latestRun() { return (state.project?.evidence || []).find((run) => run.report) || null; }
234
+
235
+ function renderDecision(run) {
236
+ const latest = run || latestRun();
237
+ const card = $("#decision-card");
238
+ if (!latest) {
239
+ card.className = "decision-card neutral";
240
+ $("#decision-title").textContent = "Not evaluated";
241
+ $("#decision-detail").textContent = "Run a conclusive release gate to establish evidence.";
242
+ $("#overview-evidence").innerHTML = '<div class="empty">No release run yet.</div>';
243
+ return;
244
+ }
245
+ const report = latest.report;
246
+ const failed = report.gate?.failed === true;
247
+ const verdict = failed ? "blocked" : report.inconclusive ? "caution" : report.verdict || "caution";
248
+ card.className = `decision-card ${verdict}`;
249
+ $("#decision-title").textContent = failed ? "Do not merge" : report.inconclusive ? "Inconclusive" : report.verdict === "ready" ? "Ready to merge" : "Review required";
250
+ $("#decision-detail").textContent = report.gate?.reasons?.join(" · ") || report.headline || "Review the evidence below.";
251
+ $("#overview-evidence").innerHTML = `<div class="latest-run-line"><span class="verdict-dot ${esc(verdict)}"></span><div><strong>${esc(report.headline || pretty(verdict))}</strong><small>${esc(pretty(report.platform || "unknown"))} · ${compactDate(latest.createdAt)} · ${(report.contracts || []).filter((item) => item.passed).length}/${(report.contracts || []).length} contracts passed</small></div></div><p>${esc((report.gate?.reasons || ["No blocking release-gate reason reported."])[0])}</p>${reportLink(report)}`;
252
+ }
253
+
254
+ function renderEvidence(runs) {
255
+ const latest = runs.find((run) => run.report);
256
+ const button = $("#create-baseline");
257
+ button.disabled = state.project?.state?.baselineReady === true || !latest || latest.report?.gate?.failed === true || latest.report?.inconclusive === true;
258
+ button.dataset.runId = latest?.id || "";
259
+ button.textContent = state.project?.state?.baselineReady ? "Baseline active" : "Establish baseline";
260
+ if (!latest) { $("#latest-evidence").innerHTML = '<div class="empty">No release run yet.</div>'; return; }
261
+ const report = latest.report;
262
+ const failed = report.gate?.failed === true;
263
+ $("#latest-evidence").innerHTML = `<article class="evidence-card"><small>Gate</small><strong class="${failed ? "bad" : "good"}">${failed ? "BLOCK" : "PASS"}</strong><small>${esc(report.platform || "unknown")}</small></article><article class="evidence-card"><small>Coverage</small><strong>${report.screensExplored || 0}</strong><small>states · ${report.actionsPerformed || 0} actions</small></article><article class="evidence-card"><small>Contracts</small><strong>${(report.contracts || []).filter((item) => item.passed).length}/${(report.contracts || []).length}</strong><small>deterministic replay</small></article><article class="evidence-card"><small>Findings</small><strong>${report.findingCounts?.total ?? (report.findings || []).length}</strong><small>${report.regression?.new?.length || 0} new vs baseline</small></article><article class="evidence-card wide"><small>Decision rationale</small><h3>${esc(report.headline || "Release gate completed")}</h3><ul class="findings-list">${(report.gate?.reasons || ["No blocking gate reason reported."]).map((item) => `<li>${esc(item)}</li>`).join("")}</ul>${reportLink(report, "Open full report")}</article><article class="evidence-card wide"><small>Known boundary</small><h3>What this run did not prove</h3><ul class="findings-list">${(report.notChecked || ["Only observed and committed journeys are covered."]).slice(0, 5).map((item) => `<li>${esc(item)}</li>`).join("")}</ul></article>`;
264
+ }
265
+
266
+ function allFindings() {
267
+ return (state.project?.evidence || []).flatMap((run) => (run.report?.findings || []).map((finding, index) => ({ ...finding, __runId:run.id, __createdAt:run.createdAt, __id:`${run.id}:${finding.findingKey || finding.id || index}` })));
268
+ }
269
+
270
+ function findingTitle(finding) { return finding.title || finding.message || finding.type || "Finding"; }
271
+
272
+ function renderFindings() {
273
+ const filter = $("#finding-filter").value;
274
+ const findings = allFindings().filter((finding) => filter === "all" || String(finding.severity).toLowerCase() === filter);
275
+ $("#finding-count").textContent = allFindings().filter((finding) => ["critical", "high"].includes(String(finding.severity).toLowerCase())).length;
276
+ $("#findings-list").innerHTML = findings.length ? findings.map((finding) => `<button class="finding-row ${finding.__id === state.selectedFindingId ? "selected" : ""}" data-finding-id="${esc(finding.__id)}"><span class="severity ${esc(String(finding.severity || "info").toLowerCase())}"></span><span><strong>${esc(findingTitle(finding))}</strong><small>${esc(finding.screen || finding.category || pretty(finding.type))} · ${compactDate(finding.__createdAt)}</small></span><em>${esc(finding.severity || "info")}</em></button>`).join("") : '<div class="empty">No findings match this view.</div>';
277
+ const selected = findings.find((finding) => finding.__id === state.selectedFindingId) || findings[0];
278
+ if (selected) state.selectedFindingId = selected.__id;
279
+ $("#finding-detail").innerHTML = selected ? `<p class="eyebrow">${esc(selected.severity || "FINDING")}</p><h2>${esc(findingTitle(selected))}</h2><p>${esc(selected.description || selected.detail || "Tapp recorded this behavior during the release run.")}</p><dl class="detail-dl"><div><dt>Screen</dt><dd>${esc(selected.screen || "—")}</dd></div><div><dt>Category</dt><dd>${esc(pretty(selected.category || selected.type || "unknown"))}</dd></div><div><dt>Target</dt><dd>${esc(selected.target || "—")}</dd></div><div><dt>Run</dt><dd><code>${esc(selected.__runId)}</code></dd></div></dl>${selected.evidence ? `<h3>Evidence</h3><pre>${esc(typeof selected.evidence === "string" ? selected.evidence : JSON.stringify(selected.evidence, null, 2))}</pre>` : ""}<p class="honesty">Finding acknowledgement and suppression are not yet shared across browser, CLI, and CI, so this browser does not offer a misleading local-only “ignore” button.</p>` : '<div class="empty">Select a finding to inspect its evidence.</div>';
280
+ }
281
+
282
+ function renderRuns(runs) {
283
+ $("#run-count").textContent = runs.length;
284
+ $("#runs-list").innerHTML = runs.length ? runs.map((run) => {
285
+ const report = run.report;
286
+ const failed = report?.gate?.failed === true;
287
+ const status = !report ? run.status : failed ? "blocked" : report.inconclusive ? "inconclusive" : report.verdict || "completed";
288
+ return `<button class="run-row ${run.id === state.selectedRunId ? "selected" : ""}" data-run-id="${esc(run.id)}"><span class="run-status ${esc(status)}">${failed ? "×" : report ? "✓" : "…"}</span><span><strong>${esc(report?.headline || `Release run ${run.id.slice(-8)}`)}</strong><small>${compactDate(run.createdAt)} · ${esc(pretty(report?.platform || "unknown"))}</small></span><em>${esc(pretty(status))}</em></button>`;
289
+ }).join("") : '<div class="empty">No release runs yet.</div>';
290
+ const selected = runs.find((run) => run.id === state.selectedRunId) || runs[0];
291
+ if (selected) state.selectedRunId = selected.id;
292
+ renderRunDetail(selected);
293
+ }
294
+
295
+ function renderRunDetail(run) {
296
+ if (!run?.report) { $("#run-detail").innerHTML = '<div class="empty">Select a completed run to inspect its evidence.</div>'; return; }
297
+ const report = run.report;
298
+ const contracts = report.contracts || [];
299
+ $("#run-detail").innerHTML = `<div class="run-detail-head"><div><p class="eyebrow">${esc(report.gate?.failed ? "BLOCKED" : report.inconclusive ? "INCONCLUSIVE" : "RELEASE RUN")}</p><h2>${esc(report.headline || "Release gate completed")}</h2><small><code>${esc(run.id)}</code> · ${compactDate(run.createdAt)}</small></div>${reportLink(report, "Visual report")}</div><div class="run-stats"><div><strong>${report.screensExplored || 0}</strong><small>states</small></div><div><strong>${report.actionsPerformed || 0}</strong><small>actions</small></div><div><strong>${contracts.filter((item) => item.passed).length}/${contracts.length}</strong><small>contracts</small></div><div><strong>${report.findingCounts?.total ?? (report.findings || []).length}</strong><small>findings</small></div></div><h3>Decision rationale</h3><ul class="detail-list">${(report.gate?.reasons || ["No blocking gate reason reported."]).map((item) => `<li>${esc(item)}</li>`).join("")}</ul><h3>Deterministic contracts</h3>${contracts.length ? contracts.map((contract) => `<div class="contract-result"><span class="${contract.passed ? "pass" : "fail"}">${contract.passed ? "✓" : "×"}</span><div><strong>${esc(contract.title || contract.name || contract.path)}</strong><small>${esc(contract.detail || contract.path || "Deterministic replay")}</small></div></div>`).join("") : '<p class="muted">No committed release contracts applied to this target.</p>'}<h3>What Tapp checked</h3><ul class="detail-list">${(report.checkedFor || []).slice(0, 8).map((item) => `<li>${esc(item)}</li>`).join("")}</ul><h3>Not checked</h3><ul class="detail-list boundary">${(report.notChecked || []).slice(0, 8).map((item) => `<li>${esc(item)}</li>`).join("")}</ul>`;
300
+ }
301
+
302
+ function renderApplication(model) {
303
+ if (!model) { $("#application-model").innerHTML = '<div class="empty">Inspect the repository to build its Application Model.</div>'; return; }
304
+ const sections = [
305
+ ["Targets", model.targets || [], (item) => `${pretty(item.platform)} · ${item.name}`, (item) => item.status],
306
+ ["Capabilities", model.capabilities || [], (item) => item.name, (item) => item.status],
307
+ ["Actors", model.actors || [], (item) => item.name, (item) => (item.roles || []).join(", ") || item.session],
308
+ ["Business entities", model.entities || [], (item) => item.name, (item) => item.status],
309
+ ["Critical journeys", model.journeys || [], (item) => item.name, (item) => item.criticality],
310
+ ["Revenue paths", model.revenuePaths || [], (item) => item.name, (item) => item.criticality],
311
+ ];
312
+ $("#application-model").innerHTML = sections.map(([title, items, label, meta]) => `<article class="panel model-card"><p class="eyebrow">${esc(title.toUpperCase())}</p><strong>${items.length}</strong>${items.length ? `<ul>${items.slice(0, 12).map((item) => `<li><span>${esc(label(item))}</span><small>${esc(meta(item) || "observed")}</small></li>`).join("")}</ul>` : '<p class="muted">Nothing grounded yet.</p>'}</article>`).join("");
313
+ }
314
+
315
+ function renderRunners() {
316
+ $("#runner-list").innerHTML = (state.session?.runners || []).map((runner) => `<div class="runner-row"><span class="runner-dot ${esc(runner.status)}"></span><div><strong>${esc(runner.label)}</strong><small>${esc(pretty(runner.platform))} · ${esc(pretty(runner.status))}${runner.remediation ? ` · ${esc(runner.remediation)}` : ""}</small></div></div>`).join("");
317
+ const actors = state.project?.actors || [];
318
+ $("#actor-list").innerHTML = actors.length ? actors.map((actor) => `<div class="actor-row"><span>${esc(actor.name.slice(0, 1).toUpperCase())}</span><div><strong>${esc(actor.name)}</strong><small>${esc((actor.roles || []).join(", ") || actor.session || "actor")} · ${(actor.credentialRequirements || []).length} credential binding(s)</small></div></div>`).join("") : '<p class="muted">No actors are configured. Tapp will ask only when a reviewed journey requires one.</p>';
319
+ }
320
+
321
+ function renderJourney(status) {
322
+ const flags = productJourneyFlags({ status, hasTarget:!!currentTarget(), hasEvidence:(state.project?.evidence || []).some((run) => run.report) });
323
+ $$(".journey button").forEach((button, index) => button.classList.toggle("done", !!flags[index]));
324
+ }
325
+
326
+ function renderProject() {
327
+ const project = state.project;
328
+ renderRepositoryChrome();
329
+ if (!project?.connected) { showSourceOnboarding(); return; }
330
+ showProduct();
331
+ $("#app-name").textContent = project.application?.name || "Unnamed product";
332
+ const platforms = project.application?.platforms?.map(pretty).join(" · ") || "Targets not inspected";
333
+ $("#app-summary").textContent = `${platforms} · ${project.capabilities?.length || 0} known capabilities · ${project.actors?.length || 0} configured actors. One model and evidence contract across every interface.`;
334
+ $("#connect-status").textContent = project.state.inspected ? (project.state.explored ? "Observed" : "Inspected") : "Not inspected";
335
+ renderTargets();
336
+ renderRequirements();
337
+ renderMap(project.map);
338
+ renderPlan(project.plan);
339
+ renderArtifacts(project.model, project.plan);
340
+ renderDecision();
341
+ renderEvidence(project.evidence || []);
342
+ renderRuns(project.evidence || []);
343
+ renderFindings();
344
+ renderApplication(project.model);
345
+ renderRunners();
346
+ renderLiveSession();
347
+ renderJourney(project.state);
348
+ }
349
+
350
+ async function refreshSession() {
351
+ state.session = await api("/api/session");
352
+ state.csrf = state.session.csrfToken;
353
+ const local = state.session.mode === "local";
354
+ $("#mode-pill").textContent = local ? "Local engine · private" : "Execution · local Mac";
355
+ $("#privacy-note-title").textContent = local ? "Your code stays on this machine in local mode." : "Your repository stays in your private Tapp workspace.";
356
+ $("#privacy-note-detail").textContent = local ? "Ordinary exploration, replay, and merge decisions are deterministic and require no API key." : "Builds and application runs execute on your connected outbound runner; Render remains the control plane.";
357
+ if (!local) {
358
+ $("#github-source-copy").textContent = "Repository upload works now. GitHub App connection will add selected-repository import without changing the product workflow.";
359
+ $("#github-source-note").textContent = "Use This Mac now; GitHub can be added independently later.";
360
+ const result = await api("/api/repositories/local");
361
+ $("#local-repositories").innerHTML = result.repositories?.length
362
+ ? result.repositories.map(repo=>`<button class="primary" data-local-runner="${esc(repo.runnerId)}" data-local-repository="${esc(repo.id)}">Use ${esc(repo.name)} <small>${esc(repo.runnerName)}${repo.branch ? ` · ${esc(repo.branch)}`:""}</small></button>`).join("")
363
+ : '<small>Start the Tapp runner on this Mac to make local checkouts available.</small>';
364
+ }
365
+ }
366
+
367
+ async function refresh() {
368
+ [state.project, state.live] = await Promise.all([api("/api/project"), api("/api/live-session")]);
369
+ renderProject();
370
+ }
371
+
372
+ async function runOperation(name, body = {}) {
373
+ if (state.activeJob) throw new Error("Another product operation is still running");
374
+ const started = await api(`/api/operations/${name}`, { method:"POST", body:JSON.stringify(body) });
375
+ state.activeJob = started.job.id;
376
+ const drawer = $("#operation-drawer");
377
+ drawer.classList.remove("hidden");
378
+ $("#operation-title").textContent = pretty(name);
379
+ $("#operation-progress").textContent = "Starting operation";
380
+ $("#operation-progress-bar").style.width = "6%";
381
+ try {
382
+ while (true) {
383
+ await new Promise((resolve) => setTimeout(resolve, 650));
384
+ const job = await api(`/api/jobs/${state.activeJob}`);
385
+ const progress = job.progress.at(-1);
386
+ $("#operation-progress").textContent = progress?.text || "Working…";
387
+ const ratio = progress?.current && progress?.total ? Math.min(92, Math.round(progress.current / progress.total * 100)) : Math.min(88, 8 + job.progress.length * 4);
388
+ $("#operation-progress-bar").style.width = `${ratio}%`;
389
+ if (operationIsPending(job.status)) continue;
390
+ if (job.status === "failed") throw new Error(job.error?.message || "Operation failed");
391
+ if (job.status !== "completed") throw new Error(`Operation ended in an unknown '${job.status}' state`);
392
+ $("#operation-progress-bar").style.width = "100%";
393
+ if (name === "ci-preview") { $("#ci-preview").textContent = job.result.workflow; $("#ci-preview").classList.remove("hidden"); }
394
+ if (name === "session-act" && job.result.status !== "ok") toast(job.result.detail || `The action ended with ${job.result.status}`, true);
395
+ else if (name === "session-save-flow") toast(`Saved ${job.result.path} as a reviewable deterministic Flow`);
396
+ else if (name === "gate" && job.result.passed === false) toast("The release gate blocked this revision. Tapp did not soften the result.", true);
397
+ else toast(`${pretty(name)} completed`);
398
+ return job.result;
399
+ }
400
+ } finally {
401
+ state.activeJob = null;
402
+ try {
403
+ await refreshSession();
404
+ await refresh();
405
+ } finally {
406
+ // The operation is not visually complete until the resulting canonical
407
+ // product snapshot/frame has rendered. Hiding this earlier caused stale
408
+ // decisions and black loading frames to flash after successful work.
409
+ drawer.classList.add("hidden");
410
+ }
411
+ }
412
+ }
413
+
414
+ async function beginRepositoryJourney() {
415
+ await runOperation("initialize", { write:true });
416
+ const targets = state.project?.targets || [];
417
+ chooseDefaultTarget();
418
+ const target = currentTarget();
419
+ if (targets.length !== 1) {
420
+ if (targets.length > 1) toast("Repository inspected. Choose which target Tapp should build and explore.");
421
+ return;
422
+ }
423
+ if (!target || target.status !== "configured") {
424
+ toast("Repository inspected. Resolve the reported target requirement before Tapp builds it.", true);
425
+ return;
426
+ }
427
+ toast(`Detected ${pretty(target.platform)} target ${target.name}. Building and exploring now.`);
428
+ await runOperation("initialize", operationPayload({ explore:true }));
429
+ }
430
+
431
+ const BLOCKED_UPLOAD_DIRECTORIES = new Set([".git", ".gradle", ".next", ".DS_Store", "Carthage", "DerivedData", "Pods", "build", "dist", "node_modules", "vendor"]);
432
+
433
+ function normalizeInputFiles(fileList) {
434
+ const files = [...fileList];
435
+ const prefix = files[0]?.webkitRelativePath?.split("/")[0] || "repository";
436
+ return files.flatMap((file) => {
437
+ const raw = file.webkitRelativePath || file.name;
438
+ const parts = raw.split("/");
439
+ if (parts[0] === prefix && parts.length > 1) parts.shift();
440
+ if (!parts.length || parts.some((part) => BLOCKED_UPLOAD_DIRECTORIES.has(part))) return [];
441
+ return [{ file, path:parts.join("/") }];
442
+ });
443
+ }
444
+
445
+ async function entriesFromWebkitEntry(entry, prefix = "") {
446
+ if (entry.isFile) {
447
+ const file = await new Promise((resolve, reject) => entry.file(resolve, reject));
448
+ return [{ file, path:`${prefix}${entry.name}` }];
449
+ }
450
+ if (!entry.isDirectory || BLOCKED_UPLOAD_DIRECTORIES.has(entry.name)) return [];
451
+ const reader = entry.createReader();
452
+ const children = [];
453
+ while (true) {
454
+ const batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject));
455
+ if (!batch.length) break;
456
+ children.push(...batch);
457
+ }
458
+ const nextPrefix = prefix ? `${prefix}${entry.name}/` : "";
459
+ return (await Promise.all(children.map((child) => entriesFromWebkitEntry(child, nextPrefix)))).flat();
460
+ }
461
+
462
+ async function entriesFromDrop(event) {
463
+ const items = [...event.dataTransfer.items];
464
+ const entries = items.map((item) => item.webkitGetAsEntry?.()).filter(Boolean);
465
+ if (entries.length) {
466
+ const rootName = entries.length === 1 && entries[0].isDirectory ? entries[0].name : "repository";
467
+ const files = (await Promise.all(entries.map((entry) => entriesFromWebkitEntry(entry)))).flat();
468
+ return { name:rootName, entries:files };
469
+ }
470
+ return { name:"repository", entries:[...event.dataTransfer.files].map((file) => ({ file, path:file.name })) };
471
+ }
472
+
473
+ // The webkitdirectory input (and some drop implementations) silently exclude hidden
474
+ // entries, which destroys committed .autotap/ config. The File System Access picker
475
+ // enumerates hidden files, so it is preferred wherever it exists.
476
+ async function entriesFromDirectoryHandle(handle, prefix = "") {
477
+ const entries = [];
478
+ for await (const child of handle.values()) {
479
+ if (BLOCKED_UPLOAD_DIRECTORIES.has(child.name)) continue;
480
+ if (child.kind === "file") entries.push({ file:await child.getFile(), path:`${prefix}${child.name}` });
481
+ else if (child.kind === "directory") entries.push(...await entriesFromDirectoryHandle(child, `${prefix}${child.name}/`));
482
+ }
483
+ return entries;
484
+ }
485
+
486
+ async function chooseFolder() {
487
+ if (!window.showDirectoryPicker) { $("#folder-input").click(); return; }
488
+ let handle;
489
+ try {
490
+ handle = await window.showDirectoryPicker({ mode:"read" });
491
+ } catch (error) {
492
+ if (error?.name === "AbortError") return;
493
+ $("#folder-input").click();
494
+ return;
495
+ }
496
+ await uploadRepository(handle.name, await entriesFromDirectoryHandle(handle), { hiddenComplete:true });
497
+ }
498
+
499
+ async function uploadRepository(name, entries, { hiddenComplete = false } = {}) {
500
+ if (!entries.length) throw new Error("The selected folder contained no uploadable source files");
501
+ if (state.activeJob) throw new Error("Another product operation is still running");
502
+ state.activeJob = "repository-upload";
503
+ const drawer = $("#operation-drawer");
504
+ drawer.classList.remove("hidden");
505
+ $("#operation-title").textContent = "Import local repository";
506
+ $("#operation-progress-bar").style.width = "2%";
507
+ let upload;
508
+ try {
509
+ const bytes = entries.reduce((total, entry) => total + entry.file.size, 0);
510
+ upload = await api("/api/repositories/uploads", { method:"POST", body:JSON.stringify({ name, expectedFiles:entries.length, expectedBytes:bytes }) });
511
+ let cursor = 0;
512
+ let completed = 0;
513
+ const worker = async () => {
514
+ while (cursor < entries.length) {
515
+ const entry = entries[cursor++];
516
+ $("#operation-progress").textContent = `Uploading ${entry.path} · ${completed}/${entries.length}`;
517
+ const response = await fetch(apiRoute(`/api/repositories/uploads/${upload.id}/files`), { method:"PUT", headers:{ "content-type":"application/octet-stream", "x-tapp-csrf":state.csrf, "x-tapp-relative-path":encodeURIComponent(entry.path) }, body:entry.file });
518
+ const result = await response.json().catch(() => ({}));
519
+ if (!response.ok) throw new Error(result.error || `Could not upload ${entry.path}`);
520
+ completed += 1;
521
+ $("#operation-progress-bar").style.width = `${Math.max(3, Math.round(completed / entries.length * 94))}%`;
522
+ }
523
+ };
524
+ await Promise.all(Array.from({ length:Math.min(4, entries.length) }, worker));
525
+ await api(`/api/repositories/uploads/${upload.id}/complete`, { method:"POST", body:"{}" });
526
+ $("#operation-progress-bar").style.width = "100%";
527
+ const hasHiddenEntries = entries.some((entry) => entry.path.split("/").some((part) => part.startsWith(".")));
528
+ if (!hiddenComplete && !hasHiddenEntries) {
529
+ toast("Imported, but no hidden files (like .autotap/) came through — this browser's folder picker skips them. Committed Tapp config was not uploaded.", true);
530
+ } else {
531
+ toast("Repository imported into an isolated Tapp workspace");
532
+ }
533
+ } catch (error) {
534
+ if (upload?.id) await api(`/api/repositories/uploads/${upload.id}`, { method:"DELETE" }).catch(() => {});
535
+ throw error;
536
+ } finally {
537
+ state.activeJob = null;
538
+ drawer.classList.add("hidden");
539
+ await refreshSession();
540
+ await refresh();
541
+ }
542
+ await beginRepositoryJourney();
543
+ }
544
+
545
+ async function openGithubDialog() {
546
+ const dialog = $("#github-dialog");
547
+ dialog.showModal();
548
+ $("#github-repositories").innerHTML = '<div class="empty">Loading authorized repositories…</div>';
549
+ try {
550
+ const result = await api("/api/repositories/github");
551
+ state.githubRepositories = result.repositories || [];
552
+ if (result.unavailable) {
553
+ $("#github-repositories").innerHTML = `<div class="remediation"><strong>GitHub connection is not configured yet</strong><p>${esc(result.unavailable)}</p></div>`;
554
+ } else renderGithubRepositories();
555
+ } catch (error) {
556
+ $("#github-repositories").innerHTML = `<div class="remediation"><strong>GitHub needs attention</strong><p>${esc(error.message)}</p></div>`;
557
+ }
558
+ }
559
+
560
+ function renderGithubRepositories() {
561
+ const query = $("#github-search").value.trim().toLowerCase();
562
+ const repositories = state.githubRepositories.filter((repository) => !query || repository.nameWithOwner.toLowerCase().includes(query));
563
+ $("#github-repositories").innerHTML = repositories.length ? repositories.map((repository) => `<button type="button" data-github-repository="${esc(repository.nameWithOwner)}"><span class="repository-icon">${esc(repository.name.slice(0, 1).toUpperCase())}</span><span><strong>${esc(repository.nameWithOwner)}</strong><small>${repository.private ? "Private" : "Public"} · ${esc(repository.permission || "authorized")} · ${esc(repository.defaultBranch || "default branch")}</small></span><em>Connect</em></button>`).join("") : '<div class="empty">No repositories match this search.</div>';
564
+ }
565
+
566
+ async function connectGithub(repository) {
567
+ $("#github-dialog").close();
568
+ await runOperation("connect-github", { repository });
569
+ await beginRepositoryJourney();
570
+ }
571
+
572
+ function reviewDecisions() {
573
+ const decisions = { approve:[], reject:[], defer:[] };
574
+ $$("#plan-list .plan-item").forEach((node) => {
575
+ const value = node.querySelector("select").value;
576
+ if (value === node.dataset.original) return;
577
+ if (value === "approved") decisions.approve.push(node.dataset.id);
578
+ if (value === "rejected") decisions.reject.push(node.dataset.id);
579
+ if (value === "deferred") decisions.defer.push(node.dataset.id);
580
+ });
581
+ return decisions;
582
+ }
583
+
584
+ document.addEventListener("click", (event) => {
585
+ const viewButton = event.target.closest("[data-view]");
586
+ if (viewButton && $("#product-shell").contains(viewButton)) activateView(viewButton.dataset.view);
587
+ const targetButton = event.target.closest("[data-target-id]");
588
+ if (targetButton) {
589
+ state.selectedTargetId = targetButton.dataset.targetId;
590
+ localStorage.setItem(targetStorageKey(), state.selectedTargetId);
591
+ renderTargets(); renderJourney(state.project.state); renderArtifacts(state.project.model, state.project.plan);
592
+ }
593
+ const runButton = event.target.closest("[data-run-id]");
594
+ if (runButton) { state.selectedRunId = runButton.dataset.runId; renderRuns(state.project.evidence || []); }
595
+ const findingButton = event.target.closest("[data-finding-id]");
596
+ if (findingButton) { state.selectedFindingId = findingButton.dataset.findingId; renderFindings(); }
597
+ const coverageButton = event.target.closest("[data-coverage-tab]");
598
+ if (coverageButton) {
599
+ $$('[data-coverage-tab]').forEach((button) => button.classList.toggle("active", button === coverageButton));
600
+ $$('[data-coverage-panel]').forEach((panel) => panel.classList.toggle("active", panel.dataset.coveragePanel === coverageButton.dataset.coverageTab));
601
+ }
602
+ const githubButton = event.target.closest("[data-github-repository]");
603
+ if (githubButton) connectGithub(githubButton.dataset.githubRepository).catch((error) => toast(error.message, true));
604
+ const jump = event.target.closest("[data-jump]");
605
+ if (jump) {
606
+ const destination = jump.dataset.jump;
607
+ const view = ["map"].includes(destination) ? "coverage" : ["contracts", "validate"].includes(destination) ? "contracts" : destination === "evidence" ? "settings" : "overview";
608
+ activateView(view);
609
+ setTimeout(() => document.getElementById(destination)?.scrollIntoView({ behavior:"smooth", block:"start" }), 20);
610
+ }
611
+ const liveControl = event.target.closest("[data-live-control]");
612
+ if (liveControl) {
613
+ const target = liveControl.dataset.liveTarget;
614
+ $("#live-action-target").value = target;
615
+ if (liveControl.dataset.liveInput === "true") {
616
+ $("#live-action-text").focus();
617
+ } else {
618
+ runOperation("session-act", { action:"tap", id:target }).catch((error) => toast(error.message, true));
619
+ }
620
+ }
621
+ });
622
+
623
+ $("#brand-home").addEventListener("click", () => state.project?.connected ? activateView("overview") : showSourceOnboarding());
624
+ $("#refresh-project").addEventListener("click", () => refresh().catch((error) => toast(error.message, true)));
625
+ $("#change-source").addEventListener("click", showSourceOnboarding);
626
+ $("#settings-add-repository").addEventListener("click", showSourceOnboarding);
627
+ $("#browse-folder").addEventListener("click", () => chooseFolder().catch((error) => toast(error.message, true)));
628
+ $("#repository-drop-zone").addEventListener("keydown", (event) => { if (["Enter", " "].includes(event.key)) { event.preventDefault(); chooseFolder().catch((error) => toast(error.message, true)); } });
629
+ $("#folder-input").addEventListener("change", (event) => {
630
+ const entries = normalizeInputFiles(event.target.files);
631
+ const name = event.target.files[0]?.webkitRelativePath?.split("/")[0] || "repository";
632
+ uploadRepository(name, entries).catch((error) => toast(error.message, true));
633
+ event.target.value = "";
634
+ });
635
+ for (const name of ["dragenter", "dragover"]) $("#repository-drop-zone").addEventListener(name, (event) => { event.preventDefault(); $("#repository-drop-zone").classList.add("dragging"); });
636
+ for (const name of ["dragleave", "drop"]) $("#repository-drop-zone").addEventListener(name, (event) => { event.preventDefault(); $("#repository-drop-zone").classList.remove("dragging"); });
637
+ $("#repository-drop-zone").addEventListener("drop", async (event) => { try { const dropped = await entriesFromDrop(event); await uploadRepository(dropped.name, dropped.entries); } catch (error) { toast(error.message, true); } });
638
+ $("#connect-github").addEventListener("click", () => openGithubDialog());
639
+ $("#github-search").addEventListener("input", renderGithubRepositories);
640
+ $("#repository-switcher").addEventListener("change", async (event) => {
641
+ if (event.target.value === "__add__") { showSourceOnboarding(); return; }
642
+ try { await api("/api/repositories/select", { method:"POST", body:JSON.stringify({ id:event.target.value }) }); state.selectedTargetId = ""; await refreshSession(); await refresh(); } catch (error) { toast(error.message, true); }
643
+ });
644
+ $("#inspect").addEventListener("click", () => runOperation("initialize", { write:true }).catch((error) => toast(error.message, true)));
645
+ $("#explore").addEventListener("click", () => runOperation("initialize", operationPayload({ explore:true })).catch((error) => toast(error.message, true)));
646
+ $("#start-session").addEventListener("click", () => runOperation("session-start", operationPayload()).catch((error) => toast(error.message, true)));
647
+ $("#refresh-live-session").addEventListener("click", () => runOperation("session-act", { action:"tree" }).catch((error) => toast(error.message, true)));
648
+ $("#end-session").addEventListener("click", () => runOperation("session-end").catch((error) => toast(error.message, true)));
649
+ $("#save-live-flow").addEventListener("click", () => { const name=$("#live-flow-name").value.trim(); if (!name) { toast("Name the Flow before saving it", true); return; } runOperation("session-save-flow", operationPayload({ name })).catch((error) => toast(error.message, true)); });
650
+ $("#live-tap").addEventListener("click", () => { const id=$("#live-action-target").value.trim(); if (!id) { toast("Choose or enter a semantic target", true); return; } runOperation("session-act", { action:"tap", id }).catch((error) => toast(error.message, true)); });
651
+ $("#live-type").addEventListener("click", () => { const id=$("#live-action-target").value.trim(), text=$("#live-action-text").value; if (!id) { toast("Choose or enter a field", true); return; } runOperation("session-act", { action:"type", id, text }).catch((error) => toast(error.message, true)); });
652
+ $("#live-back").addEventListener("click", () => runOperation("session-act", { action:"back" }).catch((error) => toast(error.message, true)));
653
+ $("#live-swipe").addEventListener("click", () => runOperation("session-act", { action:"swipe", direction:"up" }).catch((error) => toast(error.message, true)));
654
+ $("#approve-high").addEventListener("click", () => $$("#plan-list .plan-item").forEach((node) => { if (["high", "critical"].includes(node.dataset.criticality) && node.querySelector("select").value === "pending") node.querySelector("select").value = "approved"; }));
655
+ $("#defer-rest").addEventListener("click", () => $$("#plan-list .plan-item select").forEach((select) => { if (select.value === "pending") select.value = "deferred"; }));
656
+ $("#save-review").addEventListener("click", () => { const decisions = reviewDecisions(); if (![...decisions.approve, ...decisions.reject, ...decisions.defer].length) { toast("No review decisions changed"); return; } runOperation("review", decisions).catch((error) => toast(error.message, true)); });
657
+ $("#generate").addEventListener("click", () => runOperation("generate").catch((error) => toast(error.message, true)));
658
+ $("#validate-drafts").addEventListener("click", () => runOperation("validate", operationPayload()).catch((error) => toast(error.message, true)));
659
+ $("#promote").addEventListener("click", () => runOperation("promote").catch((error) => toast(error.message, true)));
660
+ $("#run-gate").addEventListener("click", () => runOperation("gate", operationPayload()).catch((error) => toast(error.message, true)));
661
+ $("#create-baseline").addEventListener("click", () => runOperation("baseline", operationPayload({ runId:$("#create-baseline").dataset.runId })).catch((error) => toast(error.message, true)));
662
+ $("#preview-ci").addEventListener("click", () => runOperation("ci-preview").catch((error) => toast(error.message, true)));
663
+ $("#install-ci").addEventListener("click", () => runOperation("ci-install").catch((error) => toast(error.message, true)));
664
+ $("#finding-filter").addEventListener("change", renderFindings);
665
+
666
+ try {
667
+ await refreshSession();
668
+ await refresh();
669
+ // A repository supplied by `tapp app /path` should enter the same journey as
670
+ // drag/drop and GitHub: inspect immediately, then stop for an explicit target
671
+ // choice when detection is ambiguous.
672
+ if (state.project?.connected && !state.project?.state?.inspected) await runOperation("initialize", { write:true });
673
+ } catch (error) { toast(error.message, true); }
674
+ const localButton = event.target.closest("[data-local-repository]");
675
+ if(localButton) api("/api/repositories/local",{method:"POST",body:JSON.stringify({runnerId:localButton.dataset.localRunner,repositoryId:localButton.dataset.localRepository})}).then(async()=>{state.selectedTargetId="";await refreshSession();await refresh();}).catch(error=>toast(error.message,true));