diff --git a/CLAUDE.md b/CLAUDE.md index a72ae1ad..de434f8e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,7 @@ applies. Read the relevant doc before editing, not after the tests go red. | `channels.py`, `channel_shim.py`, `turns.py`, `pane_session_hook.py`, `codex_pane_session_hook.py`, `pane_permission_hook.py` | `docs/channels.md` — MCP tools, shim reconnect protocol, pane→session mapping (live sid first, `pane_sessions` fallback), the `PermissionRequest` recorder behind a banner's Allow / Deny (its match key; prints nothing, fails open) | | `narrator.py`, `rename_ai.py`, `/api/rename`, `/api/name-pin` | `docs/narrator.md` — regeneration rules, humans win renames, name pinning | | `launch_policy.py`, `usage.py` (`choose_launch`), `poke.py`, the spawn/resume paths in `routes/sessions.py` (incl. move-account), `open_ops.py`, `channels.py`, the `spawn_model` / `spawn_account` settings, header pin pickers, `UsagePill` / `usageSummary.js` | `docs/account-routing.md` — earliest-reset routing, walls vs. session pressure, what `auto`/`default` mean, the session poke, the move-anyway override, the 💤 waste marker | -| `reviews.py`, `routes/reviews.py`, `static/src/review/*`, `skills/walkthrough/SKILL.md` (symlinked into each account's `skills/` by `bin/periscope install-skill`) | `docs/superpowers/specs/2026-10-26-review-walkthrough-design.md` — D1 periscope never mutates git/GitHub, D2 files under `~/.claude/reviews` with one writer per file, D6 queue sourced from `gh` with panes joined on | +| `reviews.py`, `routes/reviews.py`, `static/src/review/*`, `skills/walkthrough/SKILL.md` (symlinked into each account's `skills/` by `bin/periscope install-skill`) | `docs/superpowers/specs/2026-10-26-review-walkthrough-design.md` — D1 periscope never mutates git/GitHub, D2 files under `~/.claude/reviews` with one writer per file, D6 queue sourced from `gh` with panes joined on; the walkthrough view's layout and `reviewed.json`: `docs/superpowers/specs/2026-10-29-walkthrough-two-column-design.md` | | `src-tauri/` | `docs/tauri-shell.md` — build via `.app` not `cargo tauri dev`, webview recycling | | `static/src/notify/`, `notifications.rs`, `tauri.js` `notify` / `onNotificationOpened` / `onNotificationAction`, `NotifySettings` | `docs/superpowers/specs/2026-10-21-native-notifications-design.md` (its revision sections first) — one pass per applied state (`stateSeq`), grace counted in passes, keys that survive a restart, webview/shell version skew; buttons: `2026-10-26-native-notifications-2b-design.md` (its "Revised in implementation (PR 2)" section first) — categories are data the webview registers, one per variant whose buttons all apply; Allow / Deny / Reply / Ask agent to fix POST to `routes/pane_actions.py`, which re-checks the pane when pressed | | `bin/periscope update`, `updater.py`, `bin/release`, `CHANGELOG.md`, the launchd plist | `docs/updating.md` — updates target the newest release tag (never `main`'s tip), why pull+restart is not `update`, the bootout guards | diff --git a/docs/superpowers/plans/2026-10-29-walkthrough-two-column.md b/docs/superpowers/plans/2026-10-29-walkthrough-two-column.md new file mode 100644 index 00000000..9f124019 --- /dev/null +++ b/docs/superpowers/plans/2026-10-29-walkthrough-two-column.md @@ -0,0 +1,2257 @@ +# Walkthrough two-column stops: implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rebuild the review walkthrough view as the THI-443 spec describes (`docs/superpowers/specs/2026-10-29-walkthrough-two-column-design.md`): + +- a fixed header over one scrolling body; +- each stop a card, with its diagrams in a full-width row, then its prose beside its files and code changes; +- legible, themed diagrams that open full size; +- per-stop reviewed marks saved in `reviewed.json`; +- a collapsed comment box; +- a scoped type scale and contrast pass. + +**Architecture:** +- **Pure helpers** in `static/src/review/walkthrough.js` (parsing, signatures, evidence grouping) and `static/src/split/mermaidTheme.js` carry the logic, all tested without a DOM. +- **`Walkthrough.jsx`** splits into a fetching `Walkthrough` and a pure `WalkthroughView`, so render-to-string tests can see the page. +- **Server side** is two small helpers in `periscope/reviews.py` and one POST route in `periscope/routes/reviews.py`. +- **CSS** is scoped under `.walkthrough`, because the Reviews queue shares `.wt-head`, `.wt-sub` and `.wt-empty`. + +**Tech stack:** Preact + `@preact/signals`, Vite, vitest (`preact-render-to-string`, `environment: "node"`, no DOM), FastAPI, pytest, mermaid 11. + +**Ground rules for this repo:** +- Work in the worktree `/Users/thibaultdody/github/tomplex/periscope/.claude/worktrees/thi-443-walkthrough-layout`, branch `thibaultdody/thi-443-review-walkthrough-full-width-layout-legible-diagrams`. +- Comments explain *why*, tersely. Match the surrounding style. Biome is lint-only; keep the terse hand-written UI style. +- Routes raise `HTTPException(status, detail)` with real status codes. +- Commit after every task. Single-line messages ending with the `Co-Authored-By` trailer from the session's attribution rule. +- Never push or open a PR without the user's go-ahead. +- `static/dist/app.js` is committed and must be rebuilt (`npm run build`) after `static/src/` changes. That's done once, in Task 11. + +--- + +## File map + +| File | Change | Responsibility | +|---|---|---| +| `static/src/review/walkthrough.js` | modify | `parseWalkthrough` lifts diagrams and returns `problemMarkdown` and `source`; new `stopSig`, `isReviewed`, `withMark`, `evidenceFor`, `hiddenSummary`, `evidenceIndex` | +| `static/src/review/__tests__/walkthrough.test.js` | modify | cases for all of the above | +| `static/src/split/mermaidTheme.js` | create | pure `mermaidTheme({color, font})` → mermaid config | +| `static/src/split/__tests__/mermaidTheme.test.js` | create | the token mapping | +| `static/src/overlays/Lightbox.jsx` | create | `lightbox` signal, `openLightbox`, `closeLightbox`, `nextFocus`, `` (Task 5 created it in `split/`; its code review moved it) | +| `static/src/split/mermaidRender.js` | create | DOM side of diagrams: lazy mermaid load, token → hex, font wait, `renderDiagram(code)` (from Task 5's code review) | +| `static/src/overlays/Overlays.jsx` | modify | mounts `` at the app root (inside `#split-view` it would sit under the header) | +| `static/src/split/__tests__/lightbox.test.jsx` | create | host render, open/close, keys | +| `static/src/split/Mermaid.jsx` | modify | base theme from `mermaidTheme`, `zoom` prop | +| `static/src/split/markdown.jsx` | modify | `zoom` option for images and diagrams | +| `static/src/split/__tests__/markdown.test.jsx` | modify | zoom cases | +| `periscope/reviews.py` | modify | `reviewed_path`, `read_reviewed`, `set_reviewed` | +| `tests/test_reviews.py` | modify | helper cases | +| `periscope/routes/reviews.py` | modify | `POST /api/reviews/reviewed`, `reviewed` in the walkthrough GET, shared `_check_stop` | +| `tests/routes/test_reviews.py` | modify | route cases | +| `static/src/review/Walkthrough.jsx` | rewrite | `Walkthrough` (fetch, marks) and `WalkthroughView` (pure page), `Stop`, `Evidence`, `Contents`, `ReviewedToggle`, `CommentForm`, `CommentBox`, `sendHint`, `commentOutcome` | +| `static/src/review/__tests__/walkthroughRender.test.jsx` | create | the page's render states | +| `static/styles.css` | modify | kind tokens in `:root`; the walkthrough block rewritten; lightbox | +| `docs/superpowers/specs/2026-10-26-review-walkthrough-design.md` | modify | D5 points at the new spec | +| `skills/walkthrough/SKILL.md` | modify | one line in Diagrams | +| `CLAUDE.md` | modify | the reviews row names the new spec | + +--- + +### Task 0: Set up the worktree + +- [ ] **Step 1: Install frontend dependencies and sync Python** + +Run (in the worktree): +```bash +npm ci --no-audit --no-fund +uv sync --locked +``` +Expected: both finish without errors. + +- [ ] **Step 2: Confirm the suites are green before changing anything** + +Run: +```bash +npx vitest run static/src/review static/src/split +uv run pytest tests/test_reviews.py tests/routes/test_reviews.py -q +``` +Expected: all pass. + +--- + +### Task 1: The parser lifts diagrams and keeps the problem's markdown + +**Files:** +- Modify: `static/src/review/walkthrough.js` (`parseWalkthrough`) +- Test: `static/src/review/__tests__/walkthrough.test.js` + +- [ ] **Step 1: Write the failing tests** + +Append to the end of `static/src/review/__tests__/walkthrough.test.js`: + +````js +describe("parseWalkthrough lifts diagrams (THI-443 D1)", () => { + const FENCES = `# T + +> branch: feat/x · head: abc + +Panes **cost** more and \`nothing\` said so. + +## Adds a gauge +> kind: new-subsystem · refs: a.py + +Before. + +\`\`\`mermaid +flowchart LR + a --> b +\`\`\` + +Between. + +- a list item + + \`\`\`mermaid + flowchart TD + nested --> stays + \`\`\` + +\`\`\`mermaid +flowchart LR + c --> d +\`\`\` + +After. +`; + const w = parseWalkthrough(FENCES); + const s = w.stops[0]; + + it("returns the top-level mermaid fences in order", () => { + expect(s.diagrams).toEqual(["flowchart LR\n a --> b", "flowchart LR\n c --> d"]); + }); + it("cuts only those fences out of the body and leaves a nested one in place", () => { + expect(s.bodyMarkdown).not.toContain("a --> b"); + expect(s.bodyMarkdown).not.toContain("c --> d"); + for (const kept of ["Before.", "Between.", "After.", "nested --> stays"]) expect(s.bodyMarkdown).toContain(kept); + }); + it("keeps the stop's whole source, diagrams included, for its signature", () => { + expect(s.source.startsWith("## Adds a gauge\n> kind: new-subsystem")).toBe(true); + expect(s.source).toContain("a --> b"); + }); + it("returns the problem statement as markdown beside the plain text", () => { + expect(w.problemMarkdown).toBe("Panes **cost** more and `nothing` said so."); + expect(w.problem).toBe("Panes cost more and nothing said so."); + }); + it("gives a stop without diagrams an empty list", () => { + expect(parseWalkthrough(DOC).stops[0].diagrams).toEqual([]); + }); +}); +```` + +(`DOC` is the constant already defined at the top of this file.) + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run static/src/review/__tests__/walkthrough.test.js` +Expected: the 5 new tests fail (`s.diagrams` is `undefined`, `w.problemMarkdown` is `undefined`). + +- [ ] **Step 3: Implement** + +In `static/src/review/walkthrough.js`, inside `parseWalkthrough`: + +Replace +```js + let problem = ""; +``` +with +```js + let problem = ""; + let problemMarkdown = ""; +``` + +Replace +```js + if (blocks[i]?.type === "paragraph") { problem = textOf(blocks[i]); i++; } +``` +with +```js + if (blocks[i]?.type === "paragraph") { + problem = textOf(blocks[i]); + problemMarkdown = src.slice(blocks[i].position.start.offset, blocks[i].position.end.offset); + i++; + } +``` + +Replace +```js + const bodyEnd = j < blocks.length ? blocks[j].position.start.offset : src.length; + const base = slug(stopTitle); + const n = (ids.get(base) || 0) + 1; + ids.set(base, n); + stops.push({ + id: n === 1 ? base : `${base}-${n}`, + title: stopTitle, + kind, + knownKind: kind != null && KINDS.has(kind), + refs, + bodyMarkdown: src.slice(bodyStart, bodyEnd), + }); +``` +with +```js + const bodyEnd = j < blocks.length ? blocks[j].position.start.offset : src.length; + // Top-level mermaid fences move to the stop's full-width diagram row + // (THI-443 D1). A fence inside a list item stays where it is: lifting it + // out would reorder content we can't place cleanly. + const diagrams = []; + let body = ""; + let cursor = bodyStart; + for (let k = i + 1; k < j; k++) { + const node = blocks[k]; + if (node.type !== "code" || node.lang !== "mermaid") continue; + diagrams.push(node.value); + body += src.slice(cursor, node.position.start.offset); + cursor = node.position.end.offset; + } + body += src.slice(cursor, bodyEnd); + const base = slug(stopTitle); + const n = (ids.get(base) || 0) + 1; + ids.set(base, n); + stops.push({ + id: n === 1 ? base : `${base}-${n}`, + title: stopTitle, + kind, + knownKind: kind != null && KINDS.has(kind), + refs, + // The whole stop as written, diagrams included: stopSig hashes this. + source: src.slice(b.position.start.offset, bodyEnd), + diagrams, + bodyMarkdown: body, + }); +``` + +Replace +```js + return { title, header, problem, stops }; +``` +with +```js + return { title, header, problem, problemMarkdown, stops }; +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npx vitest run static/src/review/__tests__/walkthrough.test.js` +Expected: all pass, the existing `parseWalkthrough` cases included. + +- [ ] **Step 5: Commit** + +```bash +git add static/src/review/walkthrough.js static/src/review/__tests__/walkthrough.test.js +git commit -m "reviews walkthrough: parseWalkthrough lifts top-level mermaid fences into stop.diagrams, keeps stop.source and the problem's markdown (THI-443)" -m "Co-Authored-By: Claude Opus 5.5 (1M context) " +``` + +--- + +### Task 2: Stop signatures and reviewed marks (pure) + +**Files:** +- Modify: `static/src/review/walkthrough.js` +- Test: `static/src/review/__tests__/walkthrough.test.js` + +- [ ] **Step 1: Write the failing tests** + +Change the import line at the top of `static/src/review/__tests__/walkthrough.test.js` to: +```js +import { + filterRows, isReviewed, matchRefs, nextSort, parseWalkthrough, sortRows, staleLabel, stopSig, unresolvedTotal, withMark, +} from "../walkthrough.js"; +``` + +Append: +```js +describe("stopSig, isReviewed, withMark (THI-443 D6)", () => { + const w = parseWalkthrough(DOC); + const first = (doc) => parseWalkthrough(doc).stops[0]; + + it("is 8 hex chars and stable for the same source", () => { + expect(stopSig(w.stops[0])).toMatch(/^[0-9a-f]{8}$/); + expect(stopSig(first(DOC))).toBe(stopSig(w.stops[0])); + }); + it("changes with a one-character edit to the title, the metadata or the text", () => { + const sig = stopSig(w.stops[0]); + // String.replace with a string pattern edits the first stop only. + for (const edited of [ + DOC.replace("## Adds a gauge", "## Adds a gaugE"), + DOC.replace("kind: new-subsystem", "kind: new-subsystems"), + DOC.replace("Prose one.", "Prose one!"), + ]) expect(stopSig(first(edited))).not.toBe(sig); + }); + it("ignores trailing whitespace, so appending a stop keeps the old last stop's mark", () => { + // The last stop's source runs to the end of the file; a re-prepare that adds + // a stop gives it a trailing blank line it didn't have. + const a = "# T\n\n## One\n\nbody one\n\n## Two\n\nbody two\n"; + const b = `${a}\n## Three\n\nbody three\n`; + expect(stopSig(parseWalkthrough(b).stops[1])).toBe(stopSig(parseWalkthrough(a).stops[1])); + }); + it("counts a stop reviewed only while its title maps to its current sig", () => { + const s = w.stops[2]; + expect(isReviewed(s, { [s.title]: stopSig(s) })).toBe(true); + expect(isReviewed(s, { [s.title]: "00000000" })).toBe(false); + expect(isReviewed(s, {})).toBe(false); + expect(isReviewed(s, null)).toBe(false); + }); + it("withMark sets or clears one title and leaves the rest alone", () => { + const marks = { A: "11111111", B: "22222222" }; + expect(withMark(marks, "A", "33333333")).toEqual({ A: "33333333", B: "22222222" }); + expect(withMark(marks, "A", null)).toEqual({ B: "22222222" }); + expect(marks).toEqual({ A: "11111111", B: "22222222" }); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run static/src/review/__tests__/walkthrough.test.js` +Expected: the 5 new tests fail with `stopSig is not a function` (or the same for `isReviewed` / `withMark`). The existing tests still pass, because a missing named export imports as `undefined` rather than failing the file. + +- [ ] **Step 3: Implement** + +Append to `static/src/review/walkthrough.js`, after `nextSort`: +```js +// FNV-1a, 32-bit, over the stop as written. A reviewed mark is tied to this: +// any rewrite of the title, metadata or text gives a new sig and lapses the +// mark (THI-443 D6), as the Changes tab's viewedSig lapses when a file changes. +export function stopSig(stop) { + // trimEnd: appending a stop, or the file's final newline, must not lapse + // the old last stop's mark. + const s = (stop.source || "").trimEnd(); + let h = 0x811c9dc5; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return (h >>> 0).toString(16).padStart(8, "0"); +} + +export function isReviewed(stop, reviewed) { + return !!reviewed && reviewed[stop.title] === stopSig(stop); +} + +// One stop's mark set (sig) or cleared (null), leaving the others alone. +export function withMark(reviewed, title, sig) { + const next = { ...reviewed }; + if (sig == null) delete next[title]; + else next[title] = sig; + return next; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npx vitest run static/src/review/__tests__/walkthrough.test.js` +Expected: every test in the file passes. + +- [ ] **Step 5: Commit** + +```bash +git add static/src/review/walkthrough.js static/src/review/__tests__/walkthrough.test.js +git commit -m "reviews walkthrough: stopSig (FNV-1a of the stop's source), isReviewed and withMark for per-stop reviewed marks (THI-443)" -m "Co-Authored-By: Claude Opus 5.5 (1M context) " +``` + +--- + +### Task 3: Evidence grouping (pure) + +**Files:** +- Modify: `static/src/review/walkthrough.js` +- Test: `static/src/review/__tests__/walkthrough.test.js` + +- [ ] **Step 1: Write the failing tests** + +Change the import at the top of `static/src/review/__tests__/walkthrough.test.js` to: +```js +import { + evidenceFor, evidenceIndex, filterRows, hiddenSummary, isReviewed, matchRefs, nextSort, + parseWalkthrough, sortRows, staleLabel, stopSig, unresolvedTotal, withMark, +} from "../walkthrough.js"; +import { parseCite } from "../../split/mdMeta.js"; +``` + +Append: +```js +describe("evidenceFor, hiddenSummary, evidenceIndex (THI-443 D5)", () => { + const files = [ + { path: "a/one.py", hunks: [ + { new_start: 1, lines: [{ kind: "add", text: "x" }] }, + { new_start: 50, lines: [{ kind: "ctx", text: "y" }] }, + ] }, + { path: "dist/app.js", hunks: [], generated: true }, + ]; + const refs = ["a/one.py", "a/one.py:200-210", "b/missing.py", "dist/app.js"].map(parseCite); + + it("shows refs with hunks and sorts the rest into missing, generated and unchanged", () => { + const ev = evidenceFor(refs, files); + expect(ev.noDiff).toBe(false); + expect(ev.shown.map((e) => [e.ref.raw, e.name, e.hunks.length])).toEqual([["a/one.py", "one.py", 2]]); + expect(ev.hidden.missing.map((r) => r.raw)).toEqual(["b/missing.py"]); + expect(ev.hidden.generated.map((r) => r.raw)).toEqual(["dist/app.js"]); + expect(ev.hidden.unchanged.map((r) => r.raw)).toEqual(["a/one.py:200-210"]); + }); + it("summarizes the hidden refs in one line, skipping empty groups", () => { + expect(hiddenSummary(evidenceFor(refs, files).hidden)).toBe( + "1 not in this diff · 1 generated · 1 unchanged at the cited lines", + ); + expect(hiddenSummary(evidenceFor(refs.slice(0, 1), files).hidden)).toBe(""); + }); + it("lists every ref with no hunks when there is no worktree diff", () => { + const ev = evidenceFor(refs, null); + expect(ev.noDiff).toBe(true); + expect(ev.shown.map((e) => [e.name, e.hunks.length])).toEqual([["one.py", 0], ["one.py", 0], ["missing.py", 0], ["app.js", 0]]); + expect(hiddenSummary(ev.hidden)).toBe(""); + }); + it("lands a prose cite on the first shown file with its path", () => { + const { shown } = evidenceFor(refs, files); + expect(evidenceIndex(shown, parseCite("a/one.py:3"))).toBe(0); + expect(evidenceIndex(shown, parseCite("b/missing.py"))).toBe(-1); + }); + it("prefers the cited range whose hunks cover the cite's line", () => { + const { shown } = evidenceFor(["a/one.py:1-5", "a/one.py:50-60"].map(parseCite), files); + expect(evidenceIndex(shown, parseCite("a/one.py:50"))).toBe(1); + expect(evidenceIndex(shown, parseCite("a/one.py:2"))).toBe(0); + // No line, or a line no hunk covers: the first entry for the path. + expect(evidenceIndex(shown, parseCite("a/one.py"))).toBe(0); + expect(evidenceIndex(shown, parseCite("a/one.py:999"))).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run static/src/review/__tests__/walkthrough.test.js` +Expected: FAIL, `evidenceFor is not a function`. + +- [ ] **Step 3: Implement** + +Append to `static/src/review/walkthrough.js`: +```js +const basename = (p) => p.slice(p.lastIndexOf("/") + 1); + +// The code-changes column (THI-443 D5). `shown` gets a file-list row and a +// block of hunks; `hidden` folds into one summary line, so files with nothing +// to show don't bury the ones that do. diffFiles null means no worktree: every +// ref is listed and none has hunks. +export function evidenceFor(refs, diffFiles) { + const hidden = { missing: [], generated: [], unchanged: [] }; + if (diffFiles == null) { + return { shown: refs.map((ref) => ({ ref, name: basename(ref.path), hunks: [] })), hidden, noDiff: true }; + } + const shown = []; + for (const m of matchRefs(refs, diffFiles)) { + if (!m.file) hidden.missing.push(m.ref); + else if (m.generated) hidden.generated.push(m.ref); + else if (!m.hunks.length) hidden.unchanged.push(m.ref); + else shown.push({ ref: m.ref, name: basename(m.ref.path), hunks: m.hunks }); + } + return { shown, hidden, noDiff: false }; +} + +export function hiddenSummary(hidden) { + const parts = []; + if (hidden.missing.length) parts.push(`${hidden.missing.length} not in this diff`); + if (hidden.generated.length) parts.push(`${hidden.generated.length} generated`); + if (hidden.unchanged.length) parts.push(`${hidden.unchanged.length} unchanged at the cited lines`); + return parts.join(" · "); +} + +// A cite chip in the prose lands on the file-list entry for its path whose +// hunks cover its line; with no line, or none covering it, the first entry for +// the path. A stop citing a.py:1-5 and a.py:50-60 sends a.py:52 to the second. +export function evidenceIndex(shown, cite) { + const covers = (e) => cite.start != null && e.hunks.some((h) => h.new_start <= cite.end && newEnd(h) >= cite.start); + const hit = shown.findIndex((e) => e.ref.path === cite.path && covers(e)); + return hit >= 0 ? hit : shown.findIndex((e) => e.ref.path === cite.path); +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npx vitest run static/src/review/__tests__/walkthrough.test.js` +Expected: every test in the file passes. + +- [ ] **Step 5: Commit** + +```bash +git add static/src/review/walkthrough.js static/src/review/__tests__/walkthrough.test.js +git commit -m "reviews walkthrough: evidenceFor splits a stop's refs into shown (with hunks) and hidden (missing, generated, unchanged); hiddenSummary and evidenceIndex (THI-443)" -m "Co-Authored-By: Claude Opus 5.5 (1M context) " +``` + +--- + +### Task 4: Diagrams in periscope's theme + +**Files:** +- Create: `static/src/split/mermaidTheme.js` +- Create: `static/src/split/__tests__/mermaidTheme.test.js` +- Modify: `static/src/split/Mermaid.jsx` + +- [ ] **Step 1: Write the failing test** + +Create `static/src/split/__tests__/mermaidTheme.test.js`: +```js +import { describe, expect, it } from "vitest"; +import { mermaidTheme } from "../mermaidTheme.js"; + +describe("mermaidTheme", () => { + const hex = { + "--bg-1": "#111111", "--bg-1-hi": "#1a1a1a", "--bg-2": "#222222", + "--fg-0": "#f0f0f0", "--fg-1": "#d0d0d0", "--fg-2": "#a0a0a0", + }; + const t = mermaidTheme({ color: (n) => hex[n], font: "Hanken Grotesk, sans-serif" }); + + it("uses mermaid's configurable base theme in dark mode", () => { + expect(t.theme).toBe("base"); + expect(t.themeVariables.darkMode).toBe(true); + }); + it("maps periscope's tokens onto nodes, lines, labels and groups", () => { + expect(t.themeVariables).toMatchObject({ + primaryColor: "#222222", + primaryTextColor: "#f0f0f0", + nodeTextColor: "#f0f0f0", + primaryBorderColor: "#a0a0a0", + lineColor: "#a0a0a0", + clusterBorder: "#a0a0a0", + edgeLabelBackground: "#111111", + background: "#111111", + clusterBkg: "#1a1a1a", + secondaryColor: "#1a1a1a", + textColor: "#d0d0d0", + fontFamily: "Hanken Grotesk, sans-serif", + fontSize: "14px", + }); + }); + it("reads only opaque tokens, which the canvas converter can paint", () => { + const asked = new Set(); + mermaidTheme({ color: (n) => { asked.add(n); return "#000000"; }, font: "x" }); + expect([...asked].sort()).toEqual(["--bg-1", "--bg-1-hi", "--bg-2", "--fg-0", "--fg-1", "--fg-2"]); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `npx vitest run static/src/split/__tests__/mermaidTheme.test.js` +Expected: FAIL, `Failed to resolve import "../mermaidTheme.js"`. + +- [ ] **Step 3: Implement the builder** + +Create `static/src/split/mermaidTheme.js`: +```js +// Mermaid's "base" theme in periscope's colors (THI-443 D4), app-wide: file +// previews draw diagrams with it too. `color(name)` returns a token as +// #rrggbb. mermaid's color math can't parse the stylesheet's oklch(), so +// Mermaid.jsx converts each token before it gets here. Pure, so the mapping is +// testable without a DOM. +export function mermaidTheme({ color, font }) { + const card = color("--bg-1"); + const raised = color("--bg-1-hi"); + const line = color("--fg-2"); // ≥ 3:1 against a card: borders and arrows must read + return { + theme: "base", + themeVariables: { + darkMode: true, + fontFamily: font, + fontSize: "14px", + background: card, + primaryColor: color("--bg-2"), + primaryTextColor: color("--fg-0"), + nodeTextColor: color("--fg-0"), + titleColor: color("--fg-0"), + primaryBorderColor: line, + lineColor: line, + clusterBorder: line, + secondaryColor: raised, + clusterBkg: raised, + tertiaryColor: card, + edgeLabelBackground: card, + textColor: color("--fg-1"), + }, + }; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run static/src/split/__tests__/mermaidTheme.test.js` +Expected: PASS (3 tests). + +- [ ] **Step 5: Wire it into `Mermaid.jsx`** + +In `static/src/split/Mermaid.jsx`: + +Add after the existing `import { useEffect, useRef, useState } from "preact/hooks";`: +```js +import { mermaidTheme } from "./mermaidTheme.js"; +``` + +Replace +```js +let mermaidP = null; +function loadMermaid() { + if (!mermaidP) { + mermaidP = import("mermaid").then((m) => { + const mermaid = m.default; + // The dashboard has one (dark) theme; following prefers-color-scheme + // would draw a light diagram on the dark UI whenever macOS is light. + // Without suppressErrorRendering, render() on bad source draws the "Syntax + // error" bomb SVG into a temp div on document.body and throws before + // removing it, leaving a stray error diagram on the dashboard. + mermaid.initialize({ + startOnLoad: false, + theme: "dark", + securityLevel: "strict", + suppressErrorRendering: true, + }); +``` +with +```js +const tokenValue = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + +let paint = null; +// Paint a token on a 1×1 canvas and read the pixel back: the portable way to +// turn the stylesheet's oklch() into the #rrggbb mermaid's color parser takes. +function tokenHex(name) { + if (!paint) paint = document.createElement("canvas").getContext("2d", { willReadFrequently: true }); + paint.clearRect(0, 0, 1, 1); + paint.fillStyle = "#000"; + paint.fillStyle = tokenValue(name); + paint.fillRect(0, 0, 1, 1); + const [r, g, b] = paint.getImageData(0, 0, 1, 1).data; + return `#${[r, g, b].map((x) => x.toString(16).padStart(2, "0")).join("")}`; +} + +let mermaidP = null; +function loadMermaid() { + if (!mermaidP) { + mermaidP = import("mermaid").then((m) => { + const mermaid = m.default; + // Periscope's own tokens on mermaid's base theme (THI-443 D4), read once: + // the dashboard has one dark theme, so following prefers-color-scheme + // would only ever draw a light diagram on the dark UI. + // Without suppressErrorRendering, render() on bad source draws the "Syntax + // error" bomb SVG into a temp div on document.body and throws before + // removing it, leaving a stray error diagram on the dashboard. + mermaid.initialize({ + startOnLoad: false, + securityLevel: "strict", + suppressErrorRendering: true, + ...mermaidTheme({ color: tokenHex, font: tokenValue("--sans") }), + }); +``` + +- [ ] **Step 6: Run the split tests** + +Run: `npx vitest run static/src/split` +Expected: all pass. The markdown mermaid tests only check the host markup; `loadMermaid` never runs under render-to-string. + +- [ ] **Step 7: Commit** + +```bash +git add static/src/split/mermaidTheme.js static/src/split/__tests__/mermaidTheme.test.js static/src/split/Mermaid.jsx +git commit -m "diagrams: mermaid's base theme in periscope's tokens (node fill bg-2, text fg-0, lines fg-2, Hanken Grotesk 14px), oklch tokens converted to hex on a 1×1 canvas (THI-443)" -m "Co-Authored-By: Claude Opus 5.5 (1M context) " +``` + +--- + +### Task 5: Full-size view for diagrams and images + +> **Executed, then revised by code review (2026-10-29).** This text is the plan as run. Follow-up commits then changed four things: +> - the lightbox moved to `overlays/Lightbox.jsx`; +> - mermaid loading, token conversion, the font wait and `renderDiagram` moved to `split/mermaidRender.js`; +> - the lightbox re-renders a diagram from its `code` (fresh ids) rather than copying its SVG, ignores drags and clicks on links, and has a pure `nextFocus`; +> - `mermaidTheme` sets explicit category palettes (pie, cScale, git, notes, gantt), and the ⤢ button is disabled until the diagram has drawn, with an optional `label`. + +**Files:** +- Create: `static/src/split/Lightbox.jsx` +- Create: `static/src/split/__tests__/lightbox.test.jsx` +- Modify: `static/src/split/Mermaid.jsx`, `static/src/split/markdown.jsx`, `static/src/overlays/Overlays.jsx` +- Test: `static/src/split/__tests__/markdown.test.jsx` + +Design notes from the plan review: +- **The host is mounted in `Overlays.jsx`,** at the app root. Inside `#split-view` (`position: fixed; z-index: 1`), the sticky app header (`z-index: 10`) would paint over the lightbox, ✕ included. +- **An image is wrapped in a real `', + ); + }); + it("gives a diagram a click target and a real full-size button", () => { + const out = html(diagram, { mermaid: true, zoom: true }); + expect(out).toContain('class="md-mermaid-svg is-zoomable"'); + expect(out).not.toContain('role="button"'); + expect(out).toContain( + '', + ); + }); + it("leaves a linked image a plain image, so a click follows the link only", () => { + const out = html("[![badge](https://x.test/b.svg)](https://x.test/)", { zoom: true }); + expect(out).toContain(' { + if (!item) return; + // The scroller, not ✕: arrow keys and Page Down then scroll a big diagram. + body.current?.focus(); + if (item.kind !== "svg" || !svgHost.current) return; + // Only ever mermaid's own sanitized SVG, copied from a rendered diagram + // (Mermaid.jsx's innerHTML rule). + svgHost.current.innerHTML = item.svg; + const svg = svgHost.current.querySelector("svg"); + const vb = svg?.viewBox?.baseVal; + if (svg && vb?.width) { + // Natural size: mermaid's inline max-width and width="100%" would refit it. + svg.style.maxWidth = "none"; + svg.setAttribute("width", String(Math.round(vb.width))); + svg.setAttribute("height", String(Math.round(vb.height))); + } + }, [item]); + if (!item) return null; + const onBackdrop = (e) => { if (e.target === e.currentTarget) closeLightbox(); }; + // aria-modal promises focus stays in here: Tab moves between the scroller and ✕. + const onKeyDown = (e) => { + if (e.key !== "Tab") return; + e.preventDefault(); + (document.activeElement === closeBtn.current ? body.current : closeBtn.current)?.focus(); + }; + return ( +