diff --git a/docs/superpowers/plans/2026-06-12-f11-preview-toolbar.md b/docs/superpowers/plans/2026-06-12-f11-preview-toolbar.md new file mode 100644 index 0000000..ca7c86f --- /dev/null +++ b/docs/superpowers/plans/2026-06-12-f11-preview-toolbar.md @@ -0,0 +1,123 @@ +# Implementation Plan: F11 — Preview Toolbar as the Primary Interaction Surface (#43) + +**Spec:** `docs/superpowers/specs/2026-06-12-f11-preview-toolbar-interaction-surface.md` +**Anchor:** Feature `benstull/vscode-cowriting-plugin#43` (F11, `type/feature`, `priority/P1`) +**Session:** vscode-cowriting-plugin-0037 + +This plan transcribes the spec's §7.2 slicing plan into concrete, file-level +tasks. Each slice is independently green (unit + host E2E) before the next. Host +E2E is this app's required tier (no browser/deploy stage — a VS Code extension); +no LLM in CI (edit turns stubbed). The webview's visual rendering, the adaptive +label, and the selection→source DOM lookup are manual-smoke only. + +--- + +## SLICE-1 — Pin baseline button + reachability *(the immediate win)* + +Homes the orphaned `cowriting.pinDiffBaseline` command and gives the writer a +reachable Pin control in the preview toolbar. + +**Tasks** + +1. **Host message routing** (`src/trackChangesPreview.ts`): extract the inline + `onDidReceiveMessage` body into a private `handleWebviewMessage(document, m)` + method; add a `pinBaseline` branch that calls `this.diffView.pin(document)` + (the *previewed* document — not `activeTextEditor`). The existing + `onDidChangeBaseline` subscription already re-renders with cleared marks. +2. **Test seam**: add `receiveMessage(uriString, m)` that resolves the doc and + calls `handleWebviewMessage`, so host E2E can simulate the raw webview message + and exercise the real routing. +3. **Webview** (`media/preview.ts` + `.css`): add a `⌖ Pin baseline` button in + `#cw-header`; click → `postMessage({ type: "pinBaseline" })`; theme-aware CSS. +4. **Shell HTML** (`shellHtml`): add the ` Review @@ -246,6 +271,15 @@ export class TrackChangesPreviewController implements vscode.Disposable { isOpen(uriString: string): boolean { return this.panels.has(uriString); } + /** + * F11 test seam: deliver an inbound webview message to the real routing, as if + * the sealed webview had posted it. Exercises message→seam wiring without a + * live webview DOM (which is manual-smoke only). No-op if no doc/panel. + */ + receiveMessage(uriString: string, m: ToolbarMsg): void { + const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString); + if (doc && this.panels.has(uriString)) this.handleWebviewMessage(doc, m); + } getLastModel(uriString: string): BlockOp[] | undefined { return this.lastModel.get(uriString); } diff --git a/test/e2e/suite/f11Toolbar.test.ts b/test/e2e/suite/f11Toolbar.test.ts new file mode 100644 index 0000000..cc07ea2 --- /dev/null +++ b/test/e2e/suite/f11Toolbar.test.ts @@ -0,0 +1,69 @@ +import * as assert from "assert"; +import * as fs from "fs"; +import * as path from "path"; +import * as vscode from "vscode"; +import type { CowritingApi } from "../../../src/extension"; + +const WS = process.env.E2E_WORKSPACE!; +const settle = () => new Promise((r) => setTimeout(r, 400)); + +async function getApi(): Promise { + const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; + const api = (await ext.activate()) as CowritingApi; + assert.ok(api?.trackChangesPreviewController && api?.diffViewController, "exports preview + diffView"); + return api; +} + +/** Create + open a fresh markdown doc under WS, returning the doc + its uri key. */ +async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> { + const abs = path.join(WS, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, body, "utf8"); + const uri = vscode.Uri.file(abs); + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc); + await settle(); + return { doc, key: uri.toString() }; +} + +// F11 host E2E (no LLM): the preview toolbar is the primary interaction surface. +// The webview posts intent messages; the host routes them through the existing +// F4/F6/F3 seams (INV-35). The webview DOM (real button clicks) is sealed and +// manual-smoke only; here we simulate the inbound messages via `receiveMessage`. +suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () => { + // SLICE-1: the Pin baseline button. + test("pinBaseline message pins the PREVIEWED doc → marks clear, baseline reason is pinned (PUC-5, INV-35)", async () => { + const { doc, key } = await freshDoc("docs/f11pin.md", "# F11 pin\n\nA baseline paragraph that will diverge.\n"); + const api = await getApi(); + await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); + await settle(); + assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "panel open"); + + // Diverge from the opened baseline so the preview carries a real change-mark. + const edit = new vscode.WorkspaceEdit(); + edit.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nA freshly typed paragraph that diverges.\n"); + assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied"); + await settle(); + const marked = (api.trackChangesPreviewController.getLastModel(key) ?? []).some((o) => o.kind !== "unchanged"); + assert.ok(marked, "the typed paragraph shows as a change before pinning"); + + // Simulate the webview's Pin baseline button posting its intent. + api.trackChangesPreviewController.receiveMessage(key, { type: "pinBaseline" }); + await settle(); + + const model = api.trackChangesPreviewController.getLastModel(key) ?? []; + assert.ok(model.length > 0 && model.every((o) => o.kind === "unchanged"), "after pin, every block is unchanged"); + assert.strictEqual(api.diffViewController.getBaseline(key)?.reason, "pinned", "baseline reason advanced to pinned"); + }); + + // SLICE-1 reachability: the orphaned pin command gets a real palette `when`. + test("pinDiffBaseline is reachable from the command palette (when: editorLangId == markdown)", async () => { + const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8")); + const entry = (pkg.contributes.menus.commandPalette as Array<{ command: string; when?: string }>).find( + (m) => m.command === "cowriting.pinDiffBaseline", + ); + assert.ok(entry, "pinDiffBaseline has a commandPalette entry"); + assert.notStrictEqual(entry!.when, "false", "it is no longer hidden (when:false)"); + assert.match(entry!.when ?? "", /editorLangId == markdown/, "guarded on markdown"); + }); +});