From 555560f426fa185f868a642a9f72d8be0bdb0684 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Thu, 2 Jul 2026 16:51:13 -0400 Subject: [PATCH] refactor(extension): TS-composed inspector view + brand/layout token split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the static media/inspector.{html,css} markup with a TS-composed view: atoms (text/pill/icon) -> components (metric/preview) -> view (views/inspector.ts), each owning its styles via constructable stylesheets (style.ts registry). Design values live in brand.css (tokens); composition in layout.css (formal selectors); the shell (run_inspector.ts) owns only CSP/nonce/stylesheet wiring, and the webview entry (inspector_webview.ts) is pure mount + message plumbing. Also de-yellows the inspector: --color-run now follows the theme's progress color, and the <0||0> mark / hero metric label drop the accent literal (--color-accent stays defined in brand.css, just unapplied). Contract test now pins the shell<->view seam (stylesheets + bundle linked, CSP grants, brand token present) instead of DOM ids — ids are internal to the view. packaging.test's required-assets list swaps inspector.{html,css} for brand.css/layout.css to match. Co-Authored-By: Claude Fable 5 --- packages/extension/media/brand.css | 42 +++++++ packages/extension/media/inspector.css | 52 --------- packages/extension/media/inspector.html | 19 ---- packages/extension/media/layout.css | 13 +++ packages/extension/media/ui/atoms/icon.ts | 17 +++ packages/extension/media/ui/atoms/pill.ts | 36 ++++++ packages/extension/media/ui/atoms/text.ts | 23 ++++ .../extension/media/ui/components/metric.ts | 35 ++++++ .../extension/media/ui/components/preview.ts | 74 ++++++++++++ packages/extension/media/ui/style.ts | 13 +++ .../extension/media/ui/views/inspector.ts | 105 ++++++++++++++++++ packages/extension/src/inspector_webview.ts | 100 ++--------------- packages/extension/src/run_inspector.ts | 67 +++-------- .../test/inspector_view_contract.test.ts | 75 ++++--------- packages/extension/test/packaging.test.ts | 4 +- packages/extension/tsconfig.json | 2 +- 16 files changed, 407 insertions(+), 270 deletions(-) create mode 100644 packages/extension/media/brand.css delete mode 100644 packages/extension/media/inspector.css delete mode 100644 packages/extension/media/inspector.html create mode 100644 packages/extension/media/layout.css create mode 100644 packages/extension/media/ui/atoms/icon.ts create mode 100644 packages/extension/media/ui/atoms/pill.ts create mode 100644 packages/extension/media/ui/atoms/text.ts create mode 100644 packages/extension/media/ui/components/metric.ts create mode 100644 packages/extension/media/ui/components/preview.ts create mode 100644 packages/extension/media/ui/style.ts create mode 100644 packages/extension/media/ui/views/inspector.ts diff --git a/packages/extension/media/brand.css b/packages/extension/media/brand.css new file mode 100644 index 00000000..ca649f79 --- /dev/null +++ b/packages/extension/media/brand.css @@ -0,0 +1,42 @@ +/* brand.css — style variables only. Prefer --vscode-* (inherit the theme); + * literals are reserved for brand identity. */ + +:root { + /* color */ + --color-accent: #FFF676; + --color-on-accent: #000000; + --color-ok: var(--vscode-testing-iconPassed); + --color-fail: var(--vscode-errorForeground); + --color-run: var(--vscode-progressBar-background); + --color-dim: var(--vscode-descriptionForeground); + + /* text */ + --text-font: var(--vscode-font-family); + --text-mono: var(--vscode-editor-font-family, monospace); + --text-body: var(--vscode-font-size, 12px); + --text-label: 0.72em; + --text-small: 0.8em; + --text-value: 1.08em; + --text-hero: 1.3em; + + /* space */ + --space-xs: 4px; + --space-sm: 8px; + --space-md: 12px; + --space-lg: 16px; + + /* square */ + --square-dot: 8px; + --square-icon: 16px; + + /* border */ + --border-width: 1px; + --border-radius: 4px; + --border-radius-round: 1000px; + --border-color: var(--vscode-widget-border, var(--vscode-panel-border)); + --border-color-hero: color-mix(in srgb, var(--color-accent) 45%, var(--vscode-panel-border)); + + /* background */ + --bg-box: var(--vscode-editorWidget-background, transparent); + --bg-plot: var(--vscode-editor-background); +} diff --git a/packages/extension/media/inspector.css b/packages/extension/media/inspector.css deleted file mode 100644 index 7ee67ab7..00000000 --- a/packages/extension/media/inspector.css +++ /dev/null @@ -1,52 +0,0 @@ -: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 deleted file mode 100644 index cb81fee6..00000000 --- a/packages/extension/media/inspector.html +++ /dev/null @@ -1,19 +0,0 @@ -
-
<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/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(/