- <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/media/layout.css b/packages/extension/media/layout.css
new file mode 100644
index 00000000..baf2942c
--- /dev/null
+++ b/packages/extension/media/layout.css
@@ -0,0 +1,13 @@
+/* layout.css — formal layout selectors. Composition only; values from brand.css. */
+* { box-sizing: border-box; }
+.stack { display: flex; flex-direction: column; gap: var(--space-md); }
+.row { display: flex; align-items: center; gap: var(--space-md); }
+.wrap { flex-wrap: wrap; }
+.grid-fit { display: grid; grid-template-columns: repeat(auto-fit, minmax(var(--grid-min, 112px), 1fr)); gap: var(--space-sm); }
+.grow { flex: 1; }
+.push-end { margin-left: auto; }
+.scroll-y { overflow-y: auto; }
+.gap-xs { gap: var(--space-xs); }
+.gap-sm { gap: var(--space-sm); }
+.gap-lg { gap: var(--space-lg); }
+.pad-lg { padding: var(--space-lg); }
diff --git a/packages/extension/media/ui/atoms/icon.ts b/packages/extension/media/ui/atoms/icon.ts
new file mode 100644
index 00000000..2ebacf9d
--- /dev/null
+++ b/packages/extension/media/ui/atoms/icon.ts
@@ -0,0 +1,17 @@
+// Icon atoms. The mark is the <0||0> brand ket — intentionally not native.
+
+import { defineStyle } from "../style";
+
+defineStyle("mark", `
+ .mark { font-family: var(--text-mono);
+ letter-spacing: 1px; font-weight: 700;
+ border: var(--border-width) solid var(--border-color);
+ border-radius: var(--border-radius); padding: 1px 7px; }
+`);
+
+export function mark(): HTMLSpanElement {
+ const el = document.createElement("span");
+ el.className = "mark";
+ el.textContent = "<0||0>";
+ return el;
+}
diff --git a/packages/extension/media/ui/atoms/pill.ts b/packages/extension/media/ui/atoms/pill.ts
new file mode 100644
index 00000000..d7b3ab26
--- /dev/null
+++ b/packages/extension/media/ui/atoms/pill.ts
@@ -0,0 +1,36 @@
+// Pill atom — a status indicator. State is a class applied here, in TS.
+
+import { defineStyle } from "../style";
+
+defineStyle("pill", `
+ .pill { font-size: var(--text-small); font-weight: 600; letter-spacing: 0.5px;
+ text-transform: uppercase; padding: var(--space-xs) var(--space-md);
+ border-radius: var(--border-radius-round);
+ border: var(--border-width) solid currentColor;
+ display: inline-flex; align-items: center; gap: var(--space-sm); }
+ .pill::before { content: ""; width: var(--square-dot); height: var(--square-dot);
+ border-radius: 50%; background: currentColor; }
+ .pill.idle { color: var(--color-dim); }
+ .pill.running { color: var(--color-run); }
+ .pill.running::before { animation: pill-pulse 1.1s ease-in-out infinite; }
+ .pill.done { color: var(--color-ok); }
+ .pill.failed { color: var(--color-fail); }
+ @keyframes pill-pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.35; transform: scale(0.7); } }
+`);
+
+export type PillState = "idle" | "running" | "done" | "failed";
+
+export interface PillAtom {
+ el: HTMLSpanElement;
+ set(state: PillState, label: string): void;
+}
+
+export function pill(state: PillState = "idle", label = state): PillAtom {
+ const el = document.createElement("span");
+ const set = (s: PillState, l: string) => {
+ el.className = "pill " + s;
+ el.textContent = l;
+ };
+ set(state, label);
+ return { el, set };
+}
diff --git a/packages/extension/media/ui/atoms/text.ts b/packages/extension/media/ui/atoms/text.ts
new file mode 100644
index 00000000..f1bef606
--- /dev/null
+++ b/packages/extension/media/ui/atoms/text.ts
@@ -0,0 +1,23 @@
+// Text atom — a span that owns its text content.
+
+import { defineStyle } from "../style";
+
+defineStyle("text", `
+ .mono { font-family: var(--text-mono); }
+ .dim { color: var(--color-dim); }
+ .small { font-size: var(--text-small); }
+ .label-k { font-size: var(--text-label); text-transform: uppercase;
+ letter-spacing: 0.6px; font-weight: 600; color: var(--color-dim); }
+`);
+
+export interface TextAtom {
+ el: HTMLSpanElement;
+ set(text: string): void;
+}
+
+export function text(className = "", initial = ""): TextAtom {
+ const el = document.createElement("span");
+ if (className) el.className = className;
+ el.textContent = initial;
+ return { el, set(t) { el.textContent = t; } };
+}
diff --git a/packages/extension/media/ui/components/metric.ts b/packages/extension/media/ui/components/metric.ts
new file mode 100644
index 00000000..a0cb3e68
--- /dev/null
+++ b/packages/extension/media/ui/components/metric.ts
@@ -0,0 +1,35 @@
+// Metric component — one labeled number card. hero = the number that matters.
+
+import { defineStyle } from "../style";
+import { text } from "../atoms/text";
+
+defineStyle("metric", `
+ .metric { background: var(--bg-box);
+ border: var(--border-width) solid var(--border-color);
+ border-radius: var(--border-radius); padding: var(--space-sm) var(--space-md);
+ display: flex; flex-direction: column; gap: var(--space-xs); }
+ .metric .v { font-family: var(--text-mono); font-size: var(--text-value); }
+ .metric.hero { border-color: var(--border-color-hero); }
+ .metric.hero .v { font-size: var(--text-hero); font-weight: 600; }
+`);
+
+export interface Metric {
+ el: HTMLDivElement;
+ value(v: string): void;
+ label(l: string): void;
+ clear(): void;
+}
+
+export function metric(labelText: string, opts: { hero?: boolean } = {}): Metric {
+ const el = document.createElement("div");
+ el.className = opts.hero ? "metric hero" : "metric";
+ const l = text("label-k", labelText);
+ const v = text("v", "–");
+ el.append(l.el, v.el);
+ return {
+ el,
+ value: (t) => v.set(t),
+ label: (t) => l.set(t),
+ clear: () => v.set("–"),
+ };
+}
diff --git a/packages/extension/media/ui/components/preview.ts b/packages/extension/media/ui/components/preview.ts
new file mode 100644
index 00000000..fa75eafb
--- /dev/null
+++ b/packages/extension/media/ui/components/preview.ts
@@ -0,0 +1,74 @@
+// Preview component — double-buffered image host with a placeholder overlay.
+// Preloads each frame into the hidden buffer and flips opacity on decode, so
+// iter frames swap at 5 Hz with zero flicker.
+
+import { defineStyle } from "../style";
+import { text } from "../atoms/text";
+import { mark } from "../atoms/icon";
+
+defineStyle("preview", `
+ .preview-host { flex: 1 1 240px; min-height: 240px; min-width: 0; position: relative;
+ background: var(--bg-plot);
+ border: var(--border-width) solid var(--border-color);
+ border-radius: var(--border-radius); padding: var(--space-sm);
+ display: grid; place-items: stretch; overflow: hidden; }
+ .preview-host img { grid-column: 1; grid-row: 1; width: 100%; height: 100%;
+ object-fit: contain; display: block; opacity: 0;
+ transition: opacity 120ms ease; }
+ .preview-placeholder { place-self: center; text-align: center; opacity: 0.55;
+ display: flex; flex-direction: column; align-items: center;
+ gap: var(--space-sm); }
+ .preview-placeholder .mark { font-size: var(--text-hero); padding: var(--space-xs) var(--space-md); opacity: 0.8; }
+ .preview-placeholder .hint { font-style: italic; max-width: 240px; line-height: 1.5; }
+`);
+
+export interface Preview {
+ el: HTMLDivElement;
+ /** Show a frame; onShown fires after the buffer flip. */
+ show(url: string, onShown: () => void): void;
+ /** Clear both buffers and surface the placeholder with a hint. */
+ waiting(hint: string): void;
+}
+
+export function preview(initialHint: string): Preview {
+ const el = document.createElement("div");
+ el.className = "preview-host";
+ const a = document.createElement("img");
+ const b = document.createElement("img");
+ const hint = text("hint", initialHint);
+ const placeholder = document.createElement("div");
+ placeholder.className = "preview-placeholder";
+ placeholder.append(mark(), hint.el);
+ el.append(a, b, placeholder);
+ let visible = a;
+
+ return {
+ el,
+ show(url, onShown) {
+ placeholder.style.display = "none";
+ const incoming = visible === a ? b : a;
+ const outgoing = visible;
+ const flip = () => {
+ incoming.style.opacity = "1";
+ outgoing.style.opacity = "0";
+ visible = incoming;
+ onShown();
+ };
+ incoming.src = url;
+ if (typeof incoming.decode === "function") {
+ incoming.decode().then(flip).catch(flip);
+ } else {
+ incoming.addEventListener("load", function once() {
+ incoming.removeEventListener("load", once);
+ flip();
+ });
+ }
+ },
+ waiting(hintText) {
+ a.style.opacity = "0";
+ b.style.opacity = "0";
+ hint.set(hintText);
+ placeholder.style.display = "flex";
+ },
+ };
+}
diff --git a/packages/extension/media/ui/style.ts b/packages/extension/media/ui/style.ts
new file mode 100644
index 00000000..9f46f52c
--- /dev/null
+++ b/packages/extension/media/ui/style.ts
@@ -0,0 +1,13 @@
+// Style registry — atoms/components/views own their styles in TS, injected
+// once per key via constructable stylesheets (not governed by style-src CSP).
+// Values come from brand.css variables; layout comes from layout.css selectors.
+
+const registered = new Set();
+
+export function defineStyle(key: string, css: string): void {
+ if (registered.has(key)) return;
+ registered.add(key);
+ const sheet = new CSSStyleSheet();
+ sheet.replaceSync(css);
+ document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
+}
diff --git a/packages/extension/media/ui/views/inspector.ts b/packages/extension/media/ui/views/inspector.ts
new file mode 100644
index 00000000..59f9e235
--- /dev/null
+++ b/packages/extension/media/ui/views/inspector.ts
@@ -0,0 +1,105 @@
+// Inspector view — pure composition of atoms/components + layout selectors.
+// Owns the message protocol (runlabel / iteration / warming / completed /
+// refresh / ping) shared with run_inspector.ts.
+
+import { defineStyle } from "../style";
+import { mark } from "../atoms/icon";
+import { pill } from "../atoms/pill";
+import { text } from "../atoms/text";
+import { metric } from "../components/metric";
+import { preview } from "../components/preview";
+
+defineStyle("inspector-view", `
+ body { margin: 0; height: 100vh; font-family: var(--text-font);
+ font-size: var(--text-body); color: var(--vscode-foreground); }
+ .brand { font-weight: 600; }
+`);
+
+const IDLE_HINT = "No solve in progress — fire one from the Amicode chat, or run “Replay demo run”.";
+const WARMING_HINT = "Julia warming up — compiling the solver + plotter (~1–2 min). Frames will stream here.";
+
+export interface InspectorView {
+ el: HTMLElement;
+ onMessage(msg: unknown): void;
+}
+
+export function createInspectorView(post: (msg: unknown) => void): InspectorView {
+ const status = pill("idle");
+ const runLabel = text("mono small dim");
+ const frames = preview(IDLE_HINT);
+ const hero = metric("objective", { hero: true });
+ const iteration = metric("iteration");
+ const feasibility = metric("feasibility");
+ const optimality = metric("optimality");
+ const metrics = [hero, iteration, feasibility, optimality];
+
+ const brand = document.createElement("div");
+ brand.className = "row gap-sm brand";
+ brand.append(mark(), text("", "Run Inspector").el);
+
+ const topbar = document.createElement("div");
+ topbar.className = "row wrap";
+ status.el.classList.add("push-end");
+ topbar.append(brand, runLabel.el, status.el);
+
+ const grid = document.createElement("div");
+ grid.className = "grid-fit";
+ grid.append(...metrics.map((m) => m.el));
+
+ const el = document.createElement("div");
+ el.className = "stack pad-lg scroll-y";
+ el.style.height = "100vh";
+ el.append(topbar, frames.el, grid);
+
+ return {
+ el,
+ onMessage(msg: any): void {
+ if (!msg || typeof msg !== "object") return;
+ switch (msg.type) {
+ case "ping": {
+ post({ type: "pong", seq: msg.seq, t0: msg.t0 });
+ break;
+ }
+ case "runlabel": {
+ runLabel.set(String(msg.text ?? ""));
+ break;
+ }
+ case "iteration": {
+ hero.label("objective");
+ hero.value((msg.f_val as number).toExponential(4));
+ iteration.value(String(msg.iter));
+ feasibility.value((msg.eq_viol as number).toExponential(2));
+ optimality.value((msg.kkt_error as number).toExponential(2));
+ status.set("running", "running");
+ break;
+ }
+ case "warming": {
+ // A NEW run started but has no frame yet — clear the previous run's
+ // plot + stats so the old iter-N image doesn't linger while the new
+ // solve compiles/warms up.
+ frames.waiting(WARMING_HINT);
+ for (const m of metrics) m.clear();
+ hero.label("objective");
+ status.set("running", "warming up");
+ break;
+ }
+ case "completed": {
+ // Authoritative terminal state from the watcher (FINISHED on disk).
+ const ok = msg.status === "completed";
+ status.set(ok ? "done" : "failed", ok ? "converged" : String(msg.status));
+ // Promote the hero card to the final fidelity — the number that matters.
+ if (ok && typeof msg.fidelity === "number") {
+ hero.label("fidelity");
+ hero.value((msg.fidelity as number).toFixed(5));
+ }
+ break;
+ }
+ case "refresh": {
+ frames.show(msg.url, () => iteration.value(String(msg.iter)));
+ status.set("running", "running"); // a new frame means a live solve; completion arrives via "completed"
+ break;
+ }
+ }
+ },
+ };
+}
diff --git a/packages/extension/src/inspector_webview.ts b/packages/extension/src/inspector_webview.ts
index 33857c80..47ed85e5 100644
--- a/packages/extension/src/inspector_webview.ts
+++ b/packages/extension/src/inspector_webview.ts
@@ -1,100 +1,16 @@
-// Run Inspector webview script — runs inside the sandboxed Chromium webview.
-// - double-buffer image swap (zero flicker between iter frames at 5 Hz)
-// - status badge (idle / running / converged) + researcher metric cards
-// (objective, iteration, feasibility, optimality) driven by AMICODE_ITER.
+// Run Inspector webview entry — mounts the TS-composed view (media/ui/views/
+// inspector.ts). No static markup: the view builds its own DOM from atoms/
+// components; brand.css + layout.css are linked by the shell (run_inspector.ts).
+
+import { createInspectorView } from "../media/ui/views/inspector";
declare function acquireVsCodeApi(): {
postMessage(msg: unknown): void;
};
const vscodeApi = acquireVsCodeApi();
-const $ = (id: string) => document.getElementById(id) as HTMLElement;
-
-let visibleBuffer: "a" | "b" = "a";
-
-function setBadge(state: "idle" | "running" | "done" | "failed", text: string): void {
- const badge = $("badge");
- badge.className = "badge " + state;
- badge.textContent = text;
-}
-
-window.addEventListener("message", (e) => {
- const msg = e.data;
- if (!msg || typeof msg !== "object") return;
-
- switch (msg.type) {
- case "ping": {
- vscodeApi.postMessage({ type: "pong", seq: msg.seq, t0: msg.t0 });
- break;
- }
- case "runlabel": {
- $("runlabel").textContent = String(msg.text ?? "");
- break;
- }
- case "iteration": {
- $("m-obj-k").textContent = "objective";
- $("m-iter").textContent = String(msg.iter);
- $("m-obj").textContent = (msg.f_val as number).toExponential(4);
- $("m-pr").textContent = (msg.eq_viol as number).toExponential(2);
- $("m-du").textContent = (msg.kkt_error as number).toExponential(2);
- setBadge("running", "running");
- break;
- }
- case "warming": {
- // A NEW run started but has no frame yet — clear the PREVIOUS run's plot +
- // stats and show the warming message, so the old iter-N image doesn't linger
- // on screen while the new solve compiles/warms up.
- (document.getElementById("preview-a") as HTMLImageElement).style.opacity = "0";
- (document.getElementById("preview-b") as HTMLImageElement).style.opacity = "0";
- for (const id of ["m-obj", "m-iter", "m-pr", "m-du"]) $(id).textContent = "–";
- $("m-obj-k").textContent = "objective";
- const ph = document.getElementById("placeholder");
- const hint = document.getElementById("m-hint");
- if (hint) hint.textContent = "Julia warming up — compiling the solver + plotter (~1–2 min). Frames will stream here.";
- if (ph) ph.style.display = "flex"; // explicit: [hidden] is overridden by .placeholder{display:flex}
- setBadge("running", "warming up");
- break;
- }
- case "completed": {
- // Authoritative terminal state from the watcher (FINISHED on disk).
- const ok = msg.status === "completed";
- setBadge(ok ? "done" : "failed", ok ? "converged" : String(msg.status));
- // Promote the hero card to the final fidelity — the number that matters.
- if (ok && typeof msg.fidelity === "number") {
- $("m-obj-k").textContent = "fidelity";
- $("m-obj").textContent = (msg.fidelity as number).toFixed(5);
- }
- break;
- }
- case "refresh": {
- const placeholder = document.getElementById("placeholder");
- if (placeholder) placeholder.style.display = "none"; // explicit hide (see warming note)
-
- // Double-buffer image swap — preload into hidden buffer, flip opacity on decode.
- const incomingBuffer = visibleBuffer === "a" ? "b" : "a";
- const incomingImg = $("preview-" + incomingBuffer) as HTMLImageElement;
- const outgoingImg = $("preview-" + visibleBuffer) as HTMLImageElement;
-
- const handleLoaded = () => {
- incomingImg.style.opacity = "1";
- outgoingImg.style.opacity = "0";
- visibleBuffer = incomingBuffer;
- $("m-iter").textContent = String(msg.iter);
- };
-
- incomingImg.src = msg.url;
- if (typeof incomingImg.decode === "function") {
- incomingImg.decode().then(handleLoaded).catch(handleLoaded);
- } else {
- incomingImg.addEventListener("load", function once() {
- incomingImg.removeEventListener("load", once);
- handleLoaded();
- });
- }
- setBadge("running", "running"); // a new frame means a live solve; completion arrives via "completed"
- break;
- }
- }
-});
+const view = createInspectorView((msg) => vscodeApi.postMessage(msg));
+document.body.append(view.el);
+window.addEventListener("message", (e) => view.onMessage(e.data));
vscodeApi.postMessage({ type: "log", text: "inspector_webview booted" });
diff --git a/packages/extension/src/run_inspector.ts b/packages/extension/src/run_inspector.ts
index abf011f9..ad5ff2eb 100644
--- a/packages/extension/src/run_inspector.ts
+++ b/packages/extension/src/run_inspector.ts
@@ -1,6 +1,5 @@
import * as vscode from "vscode";
import * as path from "node:path";
-import { readFileSync } from "node:fs";
import { inspectorResourceRootDirs } from "./opencode_paths";
// ============================================================================
@@ -182,36 +181,20 @@ class InspectorView implements vscode.WebviewViewProvider {
}
private renderHtml(webview: vscode.Webview): string {
- 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 uri = (...parts: string[]) =>
+ webview.asWebviewUri(vscode.Uri.joinPath(this.ctx.extensionUri, ...parts));
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.
+ // The view is TS-composed (media/ui/views/inspector.ts → dist bundle): the
+ // script builds its own DOM from atoms/components and injects their styles
+ // via constructable stylesheets (not CSP-governed). This method owns only
+ // the security/wiring shell: CSP, nonce, and the brand/layout stylesheet
+ // URIs. The shell⇄view seam is pinned by inspector_view_contract.test.ts.
//
- // style-src keeps 'unsafe-inline' deliberately — it is load-bearing for the
- // static style="opacity:0" attrs on preview-a/b in inspector.html (runtime
- // .style mutations aren't CSP-governed, so those attrs are the only thing
- // that needs it). Don't strip it as "dead" now that the stylesheet moved
- // external; the contract test guards this grant.
- let body: string;
- try {
- body = readFileSync(
- vscode.Uri.joinPath(this.ctx.extensionUri, "media", "inspector.html").fsPath,
- "utf8",
- );
- } catch (err) {
- // A corrupt/partial install (media/inspector.html missing) would otherwise
- // throw out of resolveWebviewView before webview.html is ever set → an
- // opaque blank panel. Degrade to a readable message instead.
- return renderFallbackHtml(err);
- }
+ // style-src keeps 'unsafe-inline' deliberately — the view sets element
+ // .style properties at runtime (buffer opacity flips, placeholder toggle);
+ // those aren't CSP-governed, but keeping the grant future-proofs static
+ // style attrs the design lane may add. img-src carries the runs-root via
+ // localResourceRoots for iter-frame PNGs.
return /* html */ `
@@ -221,11 +204,11 @@ class InspectorView implements vscode.WebviewViewProvider {
img-src ${webview.cspSource} data: blob: https:;
script-src 'nonce-${nonce}';
style-src ${webview.cspSource} 'unsafe-inline';">
-
+
+
-${body.trimEnd()}
-
+
`;
}
@@ -238,26 +221,6 @@ 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 e2d9cd05..02a869a3 100644
--- a/packages/extension/test/inspector_view_contract.test.ts
+++ b/packages/extension/test/inspector_view_contract.test.ts
@@ -1,15 +1,18 @@
import { describe, it, expect } from "vitest";
-import { readFileSync } from "node:fs";
+import { existsSync, 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.
+// Pins the shell⇄view seam after the 2026-07-01 rewrite: the view is
+// TS-composed (media/ui/views/inspector.ts — it builds its own DOM and injects
+// atom/component styles via constructable stylesheets), so the old static-id
+// markup contract is gone. What remains load-bearing, and is pinned here:
+// 1. the shell links brand.css + layout.css and the dist view bundle;
+// 2. the CSP authorizes every grant the view depends on;
+// 3. the design-owned stylesheets exist and brand.css carries the brand token.
+// The message protocol (runlabel/iteration/warming/completed/refresh/ping) is
+// exercised end-to-end by the watcher tests; ids/classes are now internal to
+// the view and free to change.
const PKG_ROOT = join(__dirname, "..");
@@ -32,63 +35,31 @@ function renderInspectorHtml(): string {
return captured;
}
-// 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();
- 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];
-}
-
-// 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})", () => {
+describe("Run Inspector shell contract (plumbing ⇄ TS-composed view)", () => {
const html = renderInspectorHtml();
- 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 [...new Set([...ids, ...COMPUTED_FORM_IDS])]) {
- expect(html, `markup is missing id="${id}" (inspector_webview.ts drives it)`).toContain(`id="${id}"`);
- }
+ it("links the design-owned stylesheets and the view bundle", () => {
+ expect(html).toMatch(/]+rel="stylesheet"[^>]+href="vscode-webview:\/\/unit\/[^"]*brand\.css"/);
+ expect(html).toMatch(/]+rel="stylesheet"[^>]+href="vscode-webview:\/\/unit\/[^"]*layout\.css"/);
+ expect(html).toMatch(/