From 554dc79829f23ac4d48416c4cb3b16753c667aeb Mon Sep 17 00:00:00 2001 From: Jack Champagne Date: Mon, 29 Jun 2026 17:10:15 -0400 Subject: [PATCH 1/2] refactor(extension): extract Run Inspector view to media files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the inline CSS and body markup out of run_inspector.ts into media/inspector.css and media/inspector.html so the design lane can own look-and-feel without touching the plumbing; renderHtml keeps only the security/wiring shell (CSP, nonce, resource URIs). Pure refactor — body markup byte-identical, CSS identical modulo indent. Adds inspector_view_contract.test.ts pinning the DOM-id + message seam inspector_webview.ts depends on. --- packages/extension/media/inspector.css | 52 +++++++++++ packages/extension/media/inspector.html | 19 ++++ packages/extension/src/run_inspector.ts | 88 ++++--------------- packages/extension/test/__mocks__/vscode.ts | 1 + .../test/inspector_view_contract.test.ts | 71 +++++++++++++++ packages/extension/test/packaging.test.ts | 2 + 6 files changed, 160 insertions(+), 73 deletions(-) create mode 100644 packages/extension/media/inspector.css create mode 100644 packages/extension/media/inspector.html create mode 100644 packages/extension/test/inspector_view_contract.test.ts diff --git a/packages/extension/media/inspector.css b/packages/extension/media/inspector.css new file mode 100644 index 000000000..7ee67ab74 --- /dev/null +++ b/packages/extension/media/inspector.css @@ -0,0 +1,52 @@ +:root { + --amico-accent: #FFF676; /* amico yellow */ + --amico-run: #FFF676; /* running — brand yellow */ + --amico-ok: #3fb950; /* converged green */ + --amico-fail: #f85149; /* failed red */ +} +* { box-sizing: border-box; } +body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); + padding: 14px; font-size: 12px; display: flex; flex-direction: column; gap: 12px; + height: 100vh; overflow-y: auto; } +/* ---- top bar ---- */ +.topbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } +.brand { display: flex; align-items: center; gap: 9px; font-size: 13px; font-weight: 600; } +.mark { font-family: var(--vscode-editor-font-family, monospace); color: var(--amico-accent); + letter-spacing: 1px; font-weight: 700; + border: 1px solid color-mix(in srgb, var(--amico-accent) 55%, transparent); + border-radius: 6px; padding: 1px 7px; font-size: 12px; } +.runlabel { font-family: var(--vscode-editor-font-family, monospace); font-size: 11px; opacity: 0.6; } +.badge { margin-left: auto; font-size: 10.5px; font-weight: 600; letter-spacing: 0.5px; + text-transform: uppercase; padding: 3px 10px; border-radius: 999px; + border: 1px solid currentColor; display: inline-flex; align-items: center; gap: 6px; } +.badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; } +.badge.idle { color: var(--vscode-descriptionForeground); opacity: 0.7; } +.badge.running { color: var(--amico-run); } +.badge.running::before { animation: pulse 1.1s ease-in-out infinite; } +.badge.done { color: var(--amico-ok); } +.badge.failed { color: var(--amico-fail); } +@keyframes pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.35; transform: scale(0.7); } } +/* ---- plot hero ---- */ +/* min-height keeps the pulse plot a real plot, not a thin bar, when the + bottom panel is short; body scrolls if the panel can't fit it all. */ +.image-host { flex: 1 1 240px; min-height: 240px; min-width: 0; position: relative; + background: var(--vscode-editor-background); + border: 1px solid var(--vscode-panel-border); border-radius: 8px; padding: 6px; + display: grid; place-items: stretch; overflow: hidden; } +img.preview { grid-column: 1; grid-row: 1; width: 100%; height: 100%; + object-fit: contain; display: block; transition: opacity 120ms ease; } +.placeholder { place-self: center; text-align: center; opacity: 0.55; display: flex; + flex-direction: column; align-items: center; gap: 10px; } +.placeholder .mark { font-size: 20px; padding: 4px 12px; opacity: 0.8; } +.placeholder .hint { font-style: italic; max-width: 240px; line-height: 1.5; } +/* ---- metric cards ---- */ +.metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(112px, 1fr)); gap: 8px; } +.card { background: color-mix(in srgb, var(--vscode-panel-border) 25%, transparent); + border: 1px solid var(--vscode-panel-border); border-radius: 7px; padding: 8px 10px; + display: flex; flex-direction: column; gap: 3px; } +.card .k { font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.6px; + opacity: 0.55; font-weight: 600; } +.card .v { font-family: var(--vscode-editor-font-family, monospace); font-size: 14px; } +.card.hero { border-color: color-mix(in srgb, var(--amico-accent) 45%, var(--vscode-panel-border)); } +.card.hero .k { color: var(--amico-accent); opacity: 0.85; } +.card.hero .v { font-size: 17px; font-weight: 600; } diff --git a/packages/extension/media/inspector.html b/packages/extension/media/inspector.html new file mode 100644 index 000000000..cb81fee60 --- /dev/null +++ b/packages/extension/media/inspector.html @@ -0,0 +1,19 @@ +
+
<0||0> Run Inspector
+ + idle +
+
+ frame preview A + frame preview B +
+ <0||0> + No solve in progress — fire one from the Amicode chat, or run “Replay demo run”. +
+
+
+
objective
+
iteration
+
feasibility
+
optimality
+
diff --git a/packages/extension/src/run_inspector.ts b/packages/extension/src/run_inspector.ts index 28eed8e34..a94af9f9b 100644 --- a/packages/extension/src/run_inspector.ts +++ b/packages/extension/src/run_inspector.ts @@ -1,5 +1,6 @@ import * as vscode from "vscode"; import * as path from "node:path"; +import { readFileSync } from "node:fs"; import { inspectorResourceRootDirs } from "./opencode_paths"; // ============================================================================ @@ -184,7 +185,19 @@ class InspectorView implements vscode.WebviewViewProvider { const scriptUri = webview.asWebviewUri( vscode.Uri.joinPath(this.ctx.extensionUri, "dist", "inspector_webview.js"), ); + const styleUri = webview.asWebviewUri( + vscode.Uri.joinPath(this.ctx.extensionUri, "media", "inspector.css"), + ); const nonce = newNonce(); + // The "look" (body markup) lives in media/inspector.html and the "feel" + // (styling) in media/inspector.css — both owned by the design lane. This + // method owns only the security/wiring shell: the CSP, the nonce, and the + // resource URIs. The DOM-id + message contract between that markup and + // inspector_webview.ts is pinned by inspector_view_contract.test.ts. + const body = readFileSync( + vscode.Uri.joinPath(this.ctx.extensionUri, "media", "inspector.html").fsPath, + "utf8", + ); return /* html */ ` @@ -194,81 +207,10 @@ class InspectorView implements vscode.WebviewViewProvider { img-src ${webview.cspSource} data: blob: https:; script-src 'nonce-${nonce}'; style-src ${webview.cspSource} 'unsafe-inline';"> - + -
-
<0||0> Run Inspector
- - idle -
-
- frame preview A - frame preview B -
- <0||0> - No solve in progress — fire one from the Amicode chat, or run “Replay demo run”. -
-
-
-
objective
-
iteration
-
feasibility
-
optimality
-
+${body} `; diff --git a/packages/extension/test/__mocks__/vscode.ts b/packages/extension/test/__mocks__/vscode.ts index 1995b5c29..f8817cc3e 100644 --- a/packages/extension/test/__mocks__/vscode.ts +++ b/packages/extension/test/__mocks__/vscode.ts @@ -6,6 +6,7 @@ export const window = { showErrorMessage: () => Promise.resolve(undefined), showWarningMessage: () => Promise.resolve(undefined), createOutputChannel: () => ({ appendLine() {}, append() {}, dispose() {} }), + registerWebviewViewProvider: () => ({ dispose() {} }), }; export const commands = { executeCommand: () => Promise.resolve(undefined) }; export const workspace = { diff --git a/packages/extension/test/inspector_view_contract.test.ts b/packages/extension/test/inspector_view_contract.test.ts new file mode 100644 index 000000000..68f71518f --- /dev/null +++ b/packages/extension/test/inspector_view_contract.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { registerRunInspector } from "../src/run_inspector"; + +// Pins the plumbing⇄view contract that the run_inspector.ts split now straddles: +// the markup (media/inspector.html) and styling (media/inspector.css) are owned +// by the design lane, while run_inspector.ts + inspector_webview.ts are the +// plumbing. This test reds if a look-and-feel change drops a DOM id or a CSP +// grant the webview script depends on — i.e. it lets design iterate freely while +// guarding the exact seam the two lanes share. Renders through the public +// WebviewViewProvider surface (resolveWebviewView), not internals. + +const PKG_ROOT = join(__dirname, ".."); + +function renderInspectorHtml(): string { + const ctx = { extensionUri: { fsPath: PKG_ROOT }, subscriptions: [] as unknown[] }; + const inspector = registerRunInspector(ctx as never, "/tmp/runs-test"); + let captured = ""; + const view = { + webview: { + options: {}, + cspSource: "vscode-webview://unit", + asWebviewUri: (u: { fsPath?: string }) => ({ toString: () => "vscode-webview://unit/" + (u?.fsPath ?? String(u)) }), + postMessage: () => undefined, + set html(v: string) { captured = v; }, + get html() { return captured; }, + }, + onDidDispose: () => ({ dispose() {} }), + }; + inspector.resolveWebviewView(view as never); + return captured; +} + +// The contract is derived from the script source, not hand-listed: every id the +// webview script reads/writes MUST exist in the design-owned markup, else the +// live inspector silently breaks with no test failure. +const SCRIPT = readFileSync(join(PKG_ROOT, "src", "inspector_webview.ts"), "utf8"); +function idsReferencedByScript(): string[] { + const ids = new Set(); + for (const m of SCRIPT.matchAll(/\$\(\s*"([^"]+)"\s*\)/g)) ids.add(m[1]); + for (const m of SCRIPT.matchAll(/getElementById\(\s*"([^"]+)"\s*\)/g)) ids.add(m[1]); + return [...ids]; +} + +describe("Run Inspector view contract (plumbing ⇄ media/inspector.{html,css})", () => { + const html = renderInspectorHtml(); + + it("renders every DOM id the webview script depends on", () => { + const ids = idsReferencedByScript(); + expect(ids.length).toBeGreaterThan(0); // guard the regex itself + for (const id of ids) { + expect(html, `markup is missing id="${id}" (inspector_webview.ts drives it)`).toContain(`id="${id}"`); + } + }); + + it("links the external stylesheet and keeps the CSP authorizing it + the nonce'd script", () => { + expect(html).toMatch(/]+rel="stylesheet"[^>]+href="vscode-webview:\/\/unit\/[^"]*inspector\.css"/); + expect(html).toMatch(/style-src vscode-webview:\/\/unit/); // the linked sheet's source must be granted + expect(html).toMatch(/script-src 'nonce-/); + expect(html).toMatch(/ `; @@ -224,6 +238,26 @@ function newNonce(): string { return s; } +/** Self-contained fallback shown when the view markup can't be read (corrupt or + * partial install). No external resources/scripts so it can't itself fail to + * render; keeps webview.html set so the panel shows a message, not a blank. */ +function renderFallbackHtml(err: unknown): string { + const detail = (err instanceof Error ? err.message : String(err)) + .replace(/&/g, "&").replace(/ + + + + + + +

Run Inspector failed to load its view (media/inspector.html).

+

This usually means a corrupt or partial install — try reinstalling the extension.

+

${detail}

+ +`; +} + export function registerRunInspector(ctx: vscode.ExtensionContext, runsRoot: string): InspectorView { INSPECTOR = new InspectorView(ctx, runsRoot); ctx.subscriptions.push( diff --git a/packages/extension/test/inspector_view_contract.test.ts b/packages/extension/test/inspector_view_contract.test.ts index 68f71518f..e2d9cd052 100644 --- a/packages/extension/test/inspector_view_contract.test.ts +++ b/packages/extension/test/inspector_view_contract.test.ts @@ -32,9 +32,10 @@ function renderInspectorHtml(): string { return captured; } -// The contract is derived from the script source, not hand-listed: every id the -// webview script reads/writes MUST exist in the design-owned markup, else the -// live inspector silently breaks with no test failure. +// Every id the webview script reads/writes MUST exist in the design-owned +// markup, else the live inspector silently breaks with no test failure. The +// regex below recovers the literal $("id")/getElementById("id") lookups; the +// computed hot-path lookups it can't see are pinned explicitly just below. const SCRIPT = readFileSync(join(PKG_ROOT, "src", "inspector_webview.ts"), "utf8"); function idsReferencedByScript(): string[] { const ids = new Set(); @@ -43,20 +44,42 @@ function idsReferencedByScript(): string[] { return [...ids]; } +// Ids addressed only computationally — the double-buffer swap ($("preview-" + +// buffer)) and the metric fan-out (for (const id of [...]) $(id)). The literal +// regex is blind to these; they pass its check today only because they also +// happen to appear as literals elsewhere, so a design edit that drops that +// incidental alias would go unguarded. Listed explicitly rather than parsed out +// of the source on purpose: this single-run seam is temporary (Phase 1.3 +// reshapes the inspector into per-run views and this test goes with it), so a +// fully-derived id contract would be throwaway. +const COMPUTED_FORM_IDS = ["preview-a", "preview-b", "m-obj", "m-iter", "m-pr", "m-du"]; + describe("Run Inspector view contract (plumbing ⇄ media/inspector.{html,css})", () => { const html = renderInspectorHtml(); - it("renders every DOM id the webview script depends on", () => { + it("renders every DOM id the webview script depends on (literal + computed-form)", () => { const ids = idsReferencedByScript(); expect(ids.length).toBeGreaterThan(0); // guard the regex itself - for (const id of ids) { + for (const id of [...new Set([...ids, ...COMPUTED_FORM_IDS])]) { expect(html, `markup is missing id="${id}" (inspector_webview.ts drives it)`).toContain(`id="${id}"`); } }); - it("links the external stylesheet and keeps the CSP authorizing it + the nonce'd script", () => { + it("links the external stylesheet and keeps the CSP authorizing every grant the view depends on", () => { expect(html).toMatch(/]+rel="stylesheet"[^>]+href="vscode-webview:\/\/unit\/[^"]*inspector\.css"/); - expect(html).toMatch(/style-src vscode-webview:\/\/unit/); // the linked sheet's source must be granted + + // Pin grants to their directive, not just "appears somewhere in the CSP". + const styleSrc = html.match(/style-src([^;]*)/)?.[1] ?? ""; + expect(styleSrc, "style-src must grant the webview source for the linked stylesheet").toContain("vscode-webview://unit"); + // 'unsafe-inline' is the load-bearing grant: the static style="opacity:0" + // attrs on preview-a/b need it (runtime .style mutations aren't CSP-governed). + // Drop it and the previews start visible instead of fading in. + expect(styleSrc, "style-src must keep 'unsafe-inline' for the static style attrs").toContain("'unsafe-inline'"); + + // iter-frame PNGs load as asWebviewUri → vscode-webview:// URIs; img-src must grant the source. + const imgSrc = html.match(/img-src([^;]*)/)?.[1] ?? ""; + expect(imgSrc, "img-src must grant the webview source for iter-frame PNGs").toContain("vscode-webview://unit"); + expect(html).toMatch(/script-src 'nonce-/); expect(html).toMatch(/