Compares the browser screenshots Claude takes of the same URL at the same size, before and after an edit: both side by side, a map of the cells that changed…

English | 日本語
Personal Claude Code mods (function-hook plugins), published as one marketplace.
| Mod | What it does |
|---|---|
agent-pane | Live pane of the session's subagents: running ones with elapsed time, finished ones with result and duration |
done-gate | Flags unverified edits, stops repeated failing commands, and blocks git --no-verify |
sandbox-lens | Tallies sandbox denials seen in Bash output and reports them with /sandbox-report |
skill-hint | Suggests up to three matching skills above the prompt from what was typed; never invokes them. |
todo-agent | Collects #TODO(agent) follow-ups from Claude's replies into a per-project list with a /todos pane. |
context-meter | Band above the prompt with a Gruvbox sparkline of context use per turn, plus tokens and cost |
tool-row | Redraws finished tool-call rows in the terminal as one compact line with a Nerd Font icon, Gruvbox colors and a short stat |
image-preview | Thumbnails of the images a session writes, reads or generates: under the tool's row in the terminal, and in an /images pane |
self-audit | After each turn, a cached fork of the conversation audits Claude's own reply against the CLAUDE.md reporting rules and flags violations above the prompt |
context-diet | Trims the skill listing to what the project kind needs (off by default); /context-diet shows what was dropped and the tokens saved |
browser-cam | Live pane of the browser Claude drives: the latest screenshot from Claude in Chrome, the page URL and the recent actions |
prompt-coach | Holds back a typed prompt that looks ambiguous (no done-condition, unclear target) and offers guesses at what it meant, sending it as is, or adding one; on by default |
loopback | Feeds what self-audit, done-gate and sandbox-lens found back to Claude beside the next prompt, instead of only showing it to the person, skipping rules hit-rate found mostly wrong; off by default |
zukan | Error book: catches the errors Bash commands hit across sessions and repos, records what got past each one, and when one comes back tells Claude and the person how it was fixed last time |
pr-lamp | The current branch's pull request as a lamp: checks, review, unresolved threads and what is left before a merge, in the status line and a /pr pane; red checks and new review comments go to Claude beside the next prompt |
fog-map | Exploration map: the repository as a tree under fog, cleared where Claude read and marked where it edited, made a file or hit an error, with the importers an edit may have broken unseen; /map |
assertion-guard | Catches edits that weaken tests to pass: assertions removed, expectations loosened or rewritten, tests skipped, errors swallowed; asks Claude for the reason beside the edit |
negative-space | When an edit adds authorization, validation or error handling, lists the refusal and boundary-value tests no test touched this session covers; /negative-space |
mutation-tap | /mutate runs a mutation test limited to the lines changed since HEAD and lists the mutants the tests did not catch |
perf-delta | /perf compares a benchmark before and after (a saved baseline, or a git ref in a temporary worktree): median time, peak memory, spread, and whether the change beats the noise |
handoff-capsule | Before a compaction and at session end, keeps the goal, decisions, open items, commands to reproduce and files touched; the next session picks which parts to bring back |
port-lantern | The dev servers the session started: URL, port, whether each still listens, and their latest log lines, in the status line and /ports |
artifact-shelf | A shelf of what sessions made (CSV, PDF, HTML, video, patches…), tied to the call and request that made each; /shelf searches by kind, word and time |
focus-hud | One status line: goal, target, what is running and for how long, and what the session waits on; /focus |
side-effect-ledger | A ledger of what a git diff never shows (push, publish, deploy, PRs, API writes, messages) with outcome, how to undo, and whether the undo was checked; /ledger |
trust-label | Labels external text (web, issues, logs, browser pages, services) with its source beside the result and flags candidate injected instructions; a heuristic, never a safety verdict; /trust |
visual-diff | Compares browser screenshots of the same URL and size before and after an edit: side by side, a map of changed cells, regions, and the edits in between; /vdiff |
journey-trace | Records Claude's browser actions and the expected states as a replayable recipe; /journey md for a bug report, /journey e2e for a Playwright draft |
hit-rate | Gives each finding of done-gate, assertion-guard, self-audit and zukan a later verdict from what the session did next, keeps each rule's precision across sessions, and names the rules loopback stops feeding; /hit-rate; off by default |
token-lens | Checks an element's actual colours, spacing and type against the project's design tokens and lists off-token values with the nearest token; /token-lens <selector> |
At the prompt of a terminal session:
/plugin install <mod> --marketplace simota/claude-mods
From a shell:
claude plugin install <mod> --marketplace simota/claude-mods
This repository is private: installing needs read access on GitHub and a git credential for github.com on the machine (an SSH key, or gh auth login then gh auth setup-git).
Options are set with /plugin configure <mod>@simota-mods in a session, or from a shell:
echo '{"enabled":"true"}' | claude plugin configure <mod>@simota-mods --values-stdin
A change made with /plugin configure reloads the mod; one made from a shell applies after a restart. Bands above the prompt stack: each mod draws its row above what the others draw.
/agent-pane opens the pane: running subagents first with elapsed time, then finished ones with ✓/✗ and duration; a background run that was stopped shows ■ 停止. A background run that left the roster without a result shows ? 結果不明; a teammate or remote launch shows ↗ 起動済み(追跡外).agents: N running while any run.Works on its own, no command:
ls build, npm ci are not checks), and only when its exit status is the command's: the check is last, or only && follows it (npm test | tail does not count).--no-verify (or a prefix of it from --no-ver), and git commit -n / -nm, is denied, also inside subshells, $(…), backticks, bash -c "…", eval and wrappers such as sudo/timeout. The value of an option (-m "…"), a comment or a heredoc never triggers it. So is a git command that moves its hooks for the run (-c core.hooksPath=…, --config-env core.hooksPath=…) or carries a hook manager's off switch in its environment (HUSKY=0, LEFTHOOK=0, pre-commit's SKIP=…), or moves the hooks through config in its environment (GIT_CONFIG_KEY_n=core.hooksPath, GIT_CONFIG_PARAMETERS). ANSI-C quoting ($'--no-\x76erify'), env -S "…", bash -c -- '…' and a shell reading its script from stdin (bash <<< '…', a heredoc, echo '…' | sh) are read through. It is a best-effort guard: git aliases (git ci -n), a hook path written into config beforehand and an environment exported by an earlier command are not detected./sandbox-report lists the sandbox denials seen in Bash output, for this session and all time: denied hosts and paths, counts, last seen, an example command, and which setting would allow each./sandbox-report reset clears the record.127.0.0.1:3000), since an entry without a port opens every port.<sandbox_violations> block, and Operation not permitted lines a program printed about itself, are read. For a command that succeeded or was interrupted, the block is taken from where the engine puts it, apart from the command's own output, so one inside a shown file or page is ignored even when it ends the output. A failed command keeps only its error text: the block that ends it (before an agent-proxy note) is read, and there a block the command printed itself cannot be told from the harness's. Credentials in the example command (tokens, passwords, Authorization/Cookie and other credential headers such as X-API-Key, URL userinfo, keys in env assignments including run-together names such as PGPASSWORD, known key shapes) are masked. Each section shows the top 10; when the record is full (200), the least recently seen entry gives way.After a prompt is sent, up to three skills matching it show above the prompt (💡 skill候補: /coding-debug …), only ones that exist in the session. Nothing is invoked; 閉じる or the next prompt (a slash command included) clears it. A prompt that already names a skill (/name anywhere, or a hyphenated skill name such as coding-debug) gets no hint; built-in commands such as /clear and path segments such as src/review/ do not count as naming one. Plugin skills (plugin:name) are suggested by their full name.
#TODO(agent): <action> (after an optional quote or list bullet) in Claude's main-loop replies are collected per project (repo root or cwd). A mention inside a sentence, a table or inline code is not a TODO; subagent replies are ignored./todos opens the pane of this project's open items, each with a 完了 button./todos all lists every project's open items as text, marked as stored data.TODO(agent): N while the project has open items.A band with a sparkline of context use per turn and ctx 63% ▲4 · 126k · turn in 120k / out 8k · $1.23: the context size of the last request, then the whole turn's input/output (shown when the turn reported usage). The percentage turns yellow from 50% and red from 80%.
| Option | Default | |
|---|---|---|
band | true | Draw the band (samples are recorded either way) |
Finished tool calls are drawn as one compact line with a Nerd Font icon: Edit/Write path +A −D, Read with line count, Bash (red on error), Agent, web and MCP calls. The result block under a compacted row is hidden, so a call is one line; an errored result stays. A row another mod decorated (an image-preview thumbnail) is left as it is, result included. Runs Claude Code folds into one count line (Read 3 files, ran 2 shell commands) stay folded. Terminal only.
| Option | Default | |
|---|---|---|
compact | true | Off leaves every row and result as Claude Code draws them |
/images opens a pane, newest first; h/l step through them.sips after the tool result has gone back, so tool calls never wait on it. Converted files live in a private cache ($TMPDIR or ~/.cache), newest 50 kept. A killed session's folder is removed by a later session's start once a day untouched; a running session touches its own every hour (again after a plugin reload), so it is kept while idle, though not across a day the machine sleeps./tmp/a b/x.jpg) joins at most 8 preceding pieces and 4096 characters; the first 64 paths named are read, and of those the first 4 that exist are shown.| Option | Default | |
|---|---|---|
inline | true | Thumbnail under the tool row |
maxRows | 12 | Rows a thumbnail may take, 1 to 60 |
After each reply of at least minChars characters, one tool-less fork of the conversation (served mostly from the prompt cache), given the reply itself as quoted data, checks it against the CLAUDE.md rules: claims done without evidence, first line not the result, unverified assertions, bypassed checks, scope inflation. Only violations of the five rules whose quote is found in the reply are kept (a made-up quote or rule is dropped); the quote is compared past markdown marks, bullets, curly quotes, case, spacing and a closing full stop, and one shortened with … is found piece by piece. Violations show in red above the prompt; 詳細 or /self-audit opens the pane with quotes and the fork's token use. Results belong to the latest reply only: when it was not audited, the pane says why.
| Option | Default | |
|---|---|---|
enabled | true | |
minChars | 200 | Shorter replies are not audited |
Cost: one extra request per turn on the session's model, plus the reply text (up to 12,000 characters) as uncached input; a cold cache (after 5 minutes idle or a model switch) resends the whole conversation.
Off by default. When on, the skill listing is trimmed to what the project kind needs (Swift, Android, Rust, web, Python, Go, detected from the repo root and one level below). Coding, quality, planning and research skills are always kept; red-team, dynamic-*, persona and operation skills are dropped; image, design, ux-scan and brand-scan are dropped for Rust, Python and Go. Only the main loop's listing is reported. A dropped skill named in a prompt is still described for that prompt.
/context-diet shows the project kind, what was kept and dropped in the latest listing, and the tokens saved (estimate). A listing it cannot read reliably goes out unchanged./context-diet on|off|reset overrides the option for this session. Switching rebuilds the listing, so the prompt cache misses once.| Option | Default | |
|---|---|---|
enabled | false | |
keep | Comma list of names or prefix* never dropped; wins over drop | |
drop | Comma list of names or prefix* always dropped |
type ‹7文字›), since the field may be a password; a URL's secrets (token parameters, userinfo, key-shaped parts) are masked./cam toggles the pane; in a fullscreen terminal it opens by itself once per session, on the first browser call. Screenshots are decoded only while the pane is open, and written to a private per-session folder that is removed at session end. A killed session's folder is removed by a later session's start once a day untouched; a running session touches its own every hour (again after a plugin reload), so it is kept while idle, though not across a day the machine sleeps.| Option | Default | |
|---|---|---|
autoOpen | true | Open on the first browser call (fullscreen terminal) |
On by default (enabled: false turns it off). A typed prompt that looks ambiguous (no done-condition and an unclear target, or only evaluative words like いい感じ) is held back, and a band shows what is missing with up to three guesses at what it meant (judged along the lines of the clarify skill: the likeliest reading first, each a different deliverable with a checkable done-condition):
a / b / d: a guess goes into the prompt to edit and send (not held again)s: このまま送る sends it unchangede: 補足して送る puts it back with 完了条件: to fill in (not held again)q: 閉じる puts it back in the promptTyping another prompt instead sends that one and puts the held text back into the prompt. The held text is never lost: when a send fails or is dropped beneath, or putting words back fails, it stays held with its band (unless another prompt was held since) and its words are shown in a toast; a prompt still held at /clear goes straight back into the box, and one held when the session quits comes back at this project's next interactive session start. Words go into an empty box as they are, and are appended after a draft typed meanwhile on a line of their own, never over it.
Slash commands, prompts under 12 or over 1500 characters, prompts with any attachment or an @ mention, prompts sent by a plugin, and prompts typed while a turn runs are not judged. A timeout or error sends the prompt unchanged; the same text is not held twice (within the last 50 judged). Sending can wait up to timeoutMs.
| Option | Default | |
|---|---|---|
enabled | true | |
model | claude-haiku-5-5 | Judging model (alias or id) |
timeoutMs | 3000 | 500 to 3000 |
Off by default. When on, the new findings of the detector mods are attached to the next prompt typed at an idle prompt, as context only the model reads, so the correction does not wait for the person to retype it:
self-audit: the violations of the latest audited replydone-gate: edits with no passing check after them (again after a check passed and new edits were left unverified; 閉じる on its band takes the flag down, and nothing is fed)sandbox-lens: denials seen this session, each onceEach finding goes back once per session, marked as data that may be wrong for Claude to check before acting. A toast says how many went back and from where. Slash commands, hidden prompts, prompts sent by a plugin and prompts typed while a turn runs carry nothing. A detector that is not installed contributes nothing.
With hit-rate on, the rules it muted are not fed (sandbox-lens denials never are muted).
/loopback shows what was fed last and the self-audit repeat rate: of the rules fed back, how many self-audit flagged again on the next audited reply (all sessions). The rate is what the design is judged by (.genius/decisions/loopback.md). It also names the rules hit-rate muted.| Option | Default | |
|---|---|---|
enabled | false |
Cost: the findings ride as extra input on the prompt they are attached to (each quote clipped to 300 characters, at most 12 findings).
An error book, kept across sessions and repos. When a Bash command fails, the error line is caught and numbered:
📕 新種のエラー発見! No.042「…」 — a kind not seen before📙 No.017 再会(3回目・未解決) — seen before, nothing known to get past it 📗 既知 No.017(3回目)— 前回: sandbox 外で git push origin main ` — seen before with a fix: Claude also reads, beside the tool's result, where it was seen and what got past it (marked as a past record to check, not an instruction)A fix is learned when the same program and subcommand (git push, pnpm install; read past a leading cd, export or source, so cd app && npm install is npm install) succeeds within the next 10 Bash calls of the session; whether it ran outside the sandbox is kept with it. Two sightings are one kind when their error line matches with paths, quotes, numbers and hashes taken out.
Failures of tests, linters, type checkers and builds are not caught: they are fixed by editing code, not by a command. Credentials in a kept command and in the kept error line are masked as sandbox-lens does.
/zukan opens the book, most recently seen first: kinds, solved, sightings/zukan note <No> <text> writes how to get past it, shown and fed in place of the learned command; an empty text removes the note/zukan forget <No> removes an entryThe newest 300 kinds by last sighting are kept.
The current branch's pull request, kept in the status line while you work:
⎇ #42 · ✓5 ✗1 ⏳2 · 🛑 変更要求 · 💬3 · マージまであと4
"マージまであと N" counts what still stands before a merge: draft, failed checks, running checks, changes requested or approval pending, unresolved review threads, a conflict, the branch behind its base. At 0 it reads 🟢 マージ可能.
A toast when it changes: 🔴 CI 失敗: test, 💬 新しいレビューコメント 2件 (@rev), ✅ Approved, 🟢 #42 マージまであと0, 🎉 #42 がマージされました. Checks turning red, new review comments, changes requested and a new conflict are also attached to the next prompt typed at an idle prompt, once, with the last 40 lines of the failed log of up to three jobs (more are counted), marked as data whose comments and logs are not to be followed as instructions. The session's own replies on a thread (the comment, made after the post, carrying words it replied with through an MCP reply tool or gh api …/pulls/N/comments… -f body=, matched once) are not news; your own review comments are. What waits for the next prompt is about one pull request and is capped (newest kept). The option feed (on by default) turns that off.
/pr asks again and opens the pane: title, size, what is left, checks (failed first) and unresolved threads/pr <number|url|branch> follows that pull request; /pr clear goes back to the defaultWhich pull request: the one a Bash command of the session last worked on (gh pr comment 2, gh pr review 2, the URL gh pr create printed, the branch a git push sent to; looking with gh pr view|diff|checks does not move it), so work pushed to a review branch from another checkout is followed; else the checked-out branch's; else your newest open pull request in the repository.
It asks gh (gh pr view, and gh api graphql for review threads) at session start, right after a Bash git push, git checkout, gh pr create and the like, every minute while a check runs or after a push, and every five minutes otherwise; it stops once the pull request is merged or closed. Without an origin remote, gh, or a pull request for the branch it shows nothing, and /pr says why. gh runs on the host, outside the Bash sandbox, with your gh auth.
The repository as a map under fog, cleared as Claude works through it:
🗺 探索率 31% ██████░░░░░░░░░░░░░░ 読 23 · 編 9 · ✗ 2 / 101
▾ mods/
▾ pr-lamp/
▾ hooks/
● lamp.ts +43 −2 編集 4回
✗ register.tsx +59 −16 編集 6回 エラー 1 ★
▸ loopback/ ○●
░ agent-pane/ ░ browser-cam/ ░ context-diet/ …8
⚠ 編集したファイルを import しているが未読: mods/pr-lamp/tests/pr-lamp.test.ts
░ nothing touched yet, ○ read (Read, or cat, sed, grep… in Bash), ● edited (Edit, Write, NotebookEdit, sed -i), ✚ made by the session, ✗ named with a line number by a failed Bash command since its last edit, ★ the file touched last. A directory holding an edit, a new file or an error is open; one only read is closed with its marks beside it; untouched directories and files at a level share one fog row.
Two warnings, each a likely oversight: a file edited without being read, and a file that imports an edited one (JS/TS and Python, found by git grep) that was never opened. A very large repository's file list is cut to what one state value holds (3 M characters of paths), so it is still mapped, partly.
/map opens the pane🗺 31% · ●9 · ✗2 · ⚠1🗺 新しい区域を開拓: mods/zukan/)The map is the session's, subagents' calls included; files outside the repository ar
hooks/register.tsx 347 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { VdDiff, VdEdit, VdShot } from '../types'
5import { base64ToBytes, BROWSER_TOOL, changedCells, keyOf, mapLines, mayLeave, parseBmp, pickBefore, pngSize, regionsOf, share, tabUrlOf, urlFrom } from './diff'
6
7const PANE = 'visual-diff'
8const TITLE = '画面の差分'
9const DIR_NAME = 'claude-visual-diff'
10const KEEP_SHOTS = 20
11const KEEP_EDITS = 200
12const SMALL_PX = 320
13const GRID = 32
14
15const shots = atom({ plugin: 'visual-diff', key: 'shots' } as const, [] as VdShot[])
16const edits = atom({ plugin: 'visual-diff', key: 'edits' } as const, [] as VdEdit[])
17const url = atom({ plugin: 'visual-diff', key: 'url' } as const, '')
18const isStale = atom({ plugin: 'visual-diff', key: 'isStale' } as const, false)
19const diff = atom({ plugin: 'visual-diff', key: 'diff' } as const, null as VdDiff | null)
20const baselines = atom({ plugin: 'visual-diff', key: 'baselines' } as const, {} as Record<string, string>)
21const note = atom({ plugin: 'visual-diff', key: 'note' } as const, null as string | null)
22
23const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
24const TABS_TOOL = 'mcp__claude-in-chrome__tabs_context_mcp'
25
26// Screenshots are read one at a time, in the order their calls finished.
27let queue: Promise<void> = Promise.resolve()
28const enqueue = ($: EngineInterface, work: () => Promise<void>) => {
29 queue = queue.then(work).catch(err => $.ui.log(`visual-diff: ${String(err)}`, { to: 'debug' }))
30 return queue
31}
32
33/** The last image block of a tool's result: MCP's `{ type: 'image', data, mimeType }` or the API's `source`. */
34function screenshotOf(result: unknown): { data: string; mime: string } | undefined {
35 const content = Array.isArray(result) ? result : Array.isArray((result as { content?: unknown } | null)?.content) ? (result as { content: unknown[] }).content : []
36 let found: { data: string; mime: string } | undefined
37 for (const block of content as Record<string, unknown>[]) {
38 if (block?.type !== 'image') continue
39 const source = (block.source ?? {}) as Record<string, unknown>
40 const data = typeof block.data === 'string' ? block.data : source.data
41 const mime = typeof block.mimeType === 'string' ? block.mimeType : source.media_type
42 if (typeof data === 'string' && data !== '') found = { data, mime: typeof mime === 'string' ? mime : 'image/png' }
43 }
44 return found
45}
46
47const sh = ($: EngineInterface, script: string, args: string[], stdin?: string) =>
48 $.process.run(['/bin/sh', '-c', script, 'sh', ...args], { timeoutMs: 20_000, ...(stdin === undefined ? {} : { stdin }) }).catch(() => undefined)
49
50/** The mod's own folder under TMPDIR, else ~/.cache. */
51async function ownFolder($: EngineInterface): Promise<string | undefined> {
52 const base = ((await $.env.get('TMPDIR')) || `${(await $.env.get('HOME')) ?? ''}/.cache`).replace(/\/+$/, '')
53 return base === '' || base === '/.cache' ? undefined : `${base}/${DIR_NAME}`
54}
55
56const nameOf = (sessionId: string) => sessionId.replace(/[^\w-]/g, '_')
57const sessionFolderName = async ($: EngineInterface) => nameOf(await $.session.id())
58
59/**
60 * The session's folder, made 0700 and checked to be ours, and so is the mod's folder it sits in:
61 * a link or another user's folder there would send its writes and removals elsewhere.
62 */
63async function folder($: EngineInterface): Promise<string | undefined> {
64 const own = await ownFolder($)
65 if (own === undefined) return undefined
66 const dir = `${own}/${await sessionFolderName($)}`
67 const made = await sh(
68 $,
69 'umask 077 && mkdir -p -m 700 -- "$1" && [ -d "$1" ] && [ ! -L "$1" ] && [ -O "$1" ] && chmod 700 -- "$1" && mkdir -p -m 700 -- "$2" && [ -d "$2" ] && [ ! -L "$2" ] && [ -O "$2" ]',
70 [own, dir],
71 )
72 if (made?.exitCode !== 0) return undefined
73 liveDirs.add(dir)
74 keepAlive($)
75 return dir
76}
77
78// A session killed before its end leaves its folder: removed by a later start once a day old.
79const STALE_MS = 24 * 60 * 60 * 1000
80// A live session touches its folder this often, so one left idle for a day is not taken for killed.
81const HEARTBEAT_MS = 60 * 60 * 1000
82// Session folders this load of the module writes into.
83const liveDirs = new Set<string>()
84let heartbeat: Timer | undefined
85
86// Each hour while this process keeps a folder, its mtime is moved on: another session's sweep
87// reads a folder untouched for a day as a killed session's, however idle this one is.
88function keepAlive($: EngineInterface): void {
89 heartbeat ??= $.clock.every(HEARTBEAT_MS, () => {
90 if (liveDirs.size === 0) {
91 heartbeat?.cancel()
92 heartbeat = undefined
93 return
94 }
95 for (const dir of liveDirs) void $.process.run(['touch', '-c', '--', dir]).catch(() => undefined)
96 })
97}
98
99// Removes a session's folder, both levels checked again right before: ours, no link.
100const REMOVE_CHECKED = '[ -d "$1" ] && [ ! -L "$1" ] && [ -O "$1" ] && [ -d "$2" ] && [ ! -L "$2" ] && [ -O "$2" ] && rm -rf -- "$2"'
101
102async function sweepStale($: EngineInterface): Promise<void> {
103 const own = await ownFolder($)
104 if (own === undefined) return
105 const current = await sessionFolderName($)
106 const cutoff = (await $.clock.now()) - STALE_MS
107 for (const entry of await $.fs.list(own).catch(() => [])) {
108 if (entry.kind !== 'dir' || entry.name === current || !/^[\w-]+$/.test(entry.name)) continue
109 const stat = await $.fs.stat(`${own}/${entry.name}`).catch(() => undefined)
110 if (stat === undefined || stat.mtimeMs >= cutoff) continue
111 await sh($, REMOVE_CHECKED, [own, `${own}/${entry.name}`])
112 }
113}
114
115/** Writes the picture as PNG and a small BMP beside it; answers the shot, or why not. */
116async function keep($: EngineInterface, shot: { data: string; mime: string }, seq: number, at: number, page: string): Promise<VdShot | string> {
117 const dir = await folder($)
118 if (dir === undefined) return '画像の保存先を用意できませんでした'
119 // The time in the name: a counter that starts again (a resumed session) never meets a file already there.
120 const name = `${seq}-${at}`
121 const png = `${dir}/${name}.png`
122 const isPng = shot.mime === 'image/png'
123 const target = isPng ? png : `${dir}/${name}.raw`
124 const wrote = await sh($, 'set -C && base64 -d > "$1"', [target], shot.data)
125 if (wrote?.exitCode !== 0) return '画像を書き出せませんでした'
126 if (!isPng) {
127 const converted = await $.process.run(['sips', '-s', 'format', 'png', target, '--out', png], { timeoutMs: 20_000 }).catch(() => undefined)
128 await sh($, 'rm -f -- "$1"', [target])
129 if (converted?.exitCode !== 0) return `${shot.mime} を PNG に変換できませんでした(sips が必要です)`
130 }
131 let size = isPng ? pngSize(shot.data) : undefined
132 if (size === undefined) {
133 const measured = await $.process.run(['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', png]).catch(() => undefined)
134 const w = Number(measured?.stdout.match(/pixelWidth:\s*(\d+)/)?.[1])
135 const h = Number(measured?.stdout.match(/pixelHeight:\s*(\d+)/)?.[1])
136 if (w > 0 && h > 0) size = { width: w, height: h }
137 }
138 if (size === undefined) return '画像の大きさを読めませんでした'
139 const bmp = `${dir}/${name}.bmp`
140 const small =
141 (await $.process.run(['sips', '-Z', String(SMALL_PX), '-s', 'format', 'bmp', png, '--out', bmp], { timeoutMs: 20_000 }).catch(() => undefined))?.exitCode === 0 ||
142 (await $.process.run(['magick', png, '-resize', `${SMALL_PX}x${SMALL_PX}`, `BMP3:${bmp}`], { timeoutMs: 20_000 }).catch(() => undefined))?.exitCode === 0
143 return { id: `${seq}`, seq, at, url: page, ...size, png, ...(small ? { bmp } : {}) }
144}
145
146async function pixels($: EngineInterface, path: string) {
147 const read = await $.fs.read(path, { as: 'bytes' }).catch(() => undefined)
148 return read === undefined || typeof read === 'string' ? undefined : parseBmp(base64ToBytes(read.base64))
149}
150
151/** Compares a new shot with its before, and keeps the result. */
152async function compare($: EngineInterface, after: VdShot): Promise<void> {
153 const before = pickBefore(await read($, shots), await read($, edits), after, (await read($, baselines))[keyOf(after)])
154 if (before === undefined) return
155 if (before.bmp === undefined || after.bmp === undefined) {
156 await update($, note, () => '比較用の縮小画像を作れませんでした(sips か ImageMagick が必要です)')
157 return
158 }
159 const [a, b] = await Promise.all([pixels($, before.bmp), pixels($, after.bmp)])
160 const cells = a === undefined || b === undefined ? undefined : changedCells(a, b, GRID)
161 if (a === undefined || cells === undefined) return
162 const cellPx = Math.ceil(a.width / GRID) * (after.width / a.width)
163 const regions = regionsOf(cells, cellPx, after)
164 const between = [...new Set((await read($, edits)).filter(edit => edit.at > before.at && edit.at <= after.at).map(edit => edit.path))]
165 const result: VdDiff = {
166 before,
167 after,
168 cells: cells.map(row => row.map(on => (on ? '1' : '0')).join('')),
169 regions,
170 changedPct: share(cells),
171 edits: between,
172 at: await $.clock.now(),
173 }
174 await update($, diff, () => result)
175 await update($, note, () => null)
176 $.ui.toast(result.regions.length === 0 ? '🖼 画面差分: 変化なし — /vdiff' : `🖼 画面差分: ${result.regions.length}領域・${result.changedPct}% 変化 — /vdiff`)
177}
178
179/**
180 * The page a shot shows: the last navigation's, unless an action since may have left it; then
181 * the tab listing is asked for that tab. Empty when neither can tell.
182 */
183async function pageOf($: EngineInterface, tabId: number | undefined): Promise<string> {
184 if (!(await read($, isStale))) return read($, url)
185 if (tabId === undefined) return ''
186 const listed = await $.tool.call({ tool: TABS_TOOL } as never).catch(() => undefined)
187 const found = listed === undefined || listed.deny !== undefined || listed.isError === true ? undefined : tabUrlOf(listed.text ?? '', tabId)
188 if (found === undefined) return ''
189 await update($, url, () => found)
190 await update($, isStale, () => false)
191 return found
192}
193
194async function ingest($: EngineInterface, shot: { data: string; mime: string }, at: number, tabId: number | undefined): Promise<void> {
195 const seq = ((await read($, shots)).at(-1)?.seq ?? 0) + 1
196 const page = await pageOf($, tabId)
197 const kept = await keep($, shot, seq, at, page)
198 if (typeof kept === 'string') {
199 await update($, note, () => kept)
200 return
201 }
202 if (page === '') await update($, note, () => `#${seq} はクリックなどの後でページの URL を確かめられなかったため、比較に使いません`)
203 const pinned = new Set(Object.values(await read($, baselines)))
204 const list = await update($, shots, all => [...all, kept])
205 // Older shots go, a pinned baseline stays; their files go with them.
206 const dropped = list.filter(one => !pinned.has(one.id)).slice(0, Math.max(0, list.length - KEEP_SHOTS))
207 if (dropped.length > 0) {
208 const gone = new Set(dropped.map(one => one.id))
209 await update($, shots, all => all.filter(one => !gone.has(one.id)))
210 await sh($, 'rm -f -- "$@"', dropped.flatMap(one => [one.png, ...(one.bmp === undefined ? [] : [one.bmp])]))
211 }
212 await compare($, kept)
213}
214
215export const register: Register = on => {
216 on('session.start', async ($, e, next) => {
217 await $.command.register({ name: 'vdiff', description: '同じ URL・画面サイズのスクリーンショットの変更前後を比較(/vdiff · base で基準に固定 · clear)', argumentHint: '[base | clear]' })
218 const started = await next(e)
219 $.clock.after(0, () => void sweepStale($).catch(() => undefined))
220 // After a reload this load has made nothing, yet the session's folder may be there already.
221 const own = await ownFolder($)
222 const dir = own === undefined ? undefined : `${own}/${await sessionFolderName($)}`
223 const stat = dir === undefined ? undefined : await $.fs.stat(dir).catch(() => undefined)
224 if (dir !== undefined && stat?.kind === 'dir' && !stat.isLink) {
225 liveDirs.add(dir)
226 keepAlive($)
227 }
228 return started
229 })
230
231 on('session.end', async ($, e, next) => {
232 // The ending session's folder, named from its id: the shots kept may be an earlier session's.
233 // One session runs at a time, so nothing is left to touch; the next shot makes its folder anew.
234 liveDirs.clear()
235 const own = await ownFolder($)
236 const name = nameOf(e.sessionId)
237 // As the sweep reads names: an empty one would name the mod's whole folder, every session's.
238 if (own !== undefined && /^[\w-]+$/.test(name)) await sh($, REMOVE_CHECKED, [own, `${own}/${name}`])
239 return next(e)
240 })
241
242 on('tool.call', async ($, e, next) => {
243 const tool = String(e.tool)
244 const isBrowser = BROWSER_TOOL.test(tool)
245 if (!isBrowser && !EDIT_TOOLS.has(tool)) return next(e)
246 const input = e as unknown as Record<string, unknown>
247 const ran = await next(e)
248 if (ran.deny !== undefined || ran.isError === true) return ran
249 const at = await $.clock.now()
250 if (!isBrowser) {
251 const path = typeof input.file_path === 'string' ? input.file_path : typeof input.notebook_path === 'string' ? input.notebook_path : undefined
252 if (path !== undefined) await update($, edits, list => [...list, { path, at }].slice(-KEEP_EDITS))
253 return ran
254 }
255 const name = tool.replace(BROWSER_TOOL, '')
256 const tabId = typeof input.tabId === 'number' ? input.tabId : undefined
257 const page = urlFrom(name, input, ran.text ?? '')
258 if (page !== undefined) {
259 await update($, url, () => page)
260 await update($, isStale, () => false)
261 } else if (mayLeave(name, input)) await update($, isStale, () => true)
262 const shot = screenshotOf(ran.result)
263 // Decoded from a timer, so the model never waits on the conversion.
264 if (shot !== undefined) $.clock.after(0, () => void enqueue($, () => ingest($, shot, at, tabId)))
265 return ran
266 }).catch(($, e, next) => next(e)) // an observer: a failure here never changes the call
267
268 on('command.run', { command: 'vdiff' }, async ($, e) => {
269 const args = e.args.trim()
270 const latest = (await read($, shots)).at(-1)
271 if (args === 'base') {
272 if (latest === undefined) return { text: '🖼 まだスクリーンショットがありません。' }
273 await update($, baselines, all => ({ ...all, [keyOf(latest)]: latest.id }))
274 return { text: `🖼 ${keyOf(latest)} の基準を最新のスクリーンショットに固定しました。` }
275 }
276 if (args === 'clear') {
277 await update($, baselines, () => ({}))
278 return { text: '🖼 基準の固定を外しました(編集前の最新と比べます)。' }
279 }
280 await $.ui.open({ id: PANE, title: TITLE })
281 const shown = await read($, diff)
282 if (shown === null) return { text: '🖼 まだ比較できる組がありません(同じ URL・サイズで2枚以上撮ると比較します)。' }
283 return {
284 text: [
285 `🖼 ${keyOf(shown.after)}: ${shown.changedPct}% 変化、${shown.regions.length}領域`,
286 ...shown.regions.slice(0, 5).map(r => `- (${r.x}, ${r.y}) ${r.width}×${r.height}`),
287 shown.edits.length > 0 ? `間の編集: ${shown.edits.join(', ')}` : '間の編集: なし',
288 ].join('\n'),
289 }
290 })
291
292 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
293 const { Box, Text } = $.ui.resolve(e)
294 const shown = await read($, diff)
295 const problem = await read($, note)
296 if (shown === null) {
297 return (
298 <Box flexDirection="column">
299 <Text dimColor>同じ URL・画面サイズのスクリーンショットが2枚そろうと、ここに変更前後を並べます。</Text>
300 {problem !== null && <Text color="yellow">{problem}</Text>}
301 </Box>
302 )
303 }
304 const head = `🖼 ${shown.after.url || '(URL 不明)'} @${shown.after.width}×${shown.after.height} · 変化 ${shown.changedPct}% · ${shown.regions.length}領域`
305 const details = (
306 <Box flexDirection="column">
307 {mapLines(shown.cells).map((line, index) => (
308 <Text key={`m-${index}`} color="magenta">{line}</Text>
309 ))}
310 {shown.regions.slice(0, 5).map((r, index) => (
311 <Text key={`r-${index}`}>{`□ (${r.x}, ${r.y}) ${r.width}×${r.height}`}</Text>
312 ))}
313 <Text dimColor wrap="truncate-end">{shown.edits.length > 0 ? `間の編集: ${shown.edits.map(p => p.slice(p.lastIndexOf('/') + 1)).join(', ')}` : '間の編集: なし'}</Text>
314 {problem !== null && <Text color="yellow">{problem}</Text>}
315 </Box>
316 )
317 if (e.surface === 'terminal') {
318 const { Image } = $.ui.resolve(e)
319 const columns = Math.max(8, Math.min(255, Math.floor((e.props.bodyColumns - 3) / 2)))
320 const rows = Math.max(3, Math.min(40, Math.round((columns * shown.after.height) / shown.after.width / 2)))
321 return (
322 <Box flexDirection="column">
323 <Text bold wrap="truncate-end">{head}</Text>
324 <Box flexDirection="row">
325 <Box flexDirection="column" marginRight={1}>
326 <Text dimColor>{`前 #${shown.before.seq}`}</Text>
327 <Image key="before" source={{ file: shown.before.png, format: 'png', generation: shown.before.seq }} columns={columns} rows={rows} alt={`変更前 #${shown.before.seq}`} />
328 </Box>
329 <Box flexDirection="column">
330 <Text dimColor>{`後 #${shown.after.seq}`}</Text>
331 <Image key="after" source={{ file: shown.after.png, format: 'png', generation: shown.after.seq }} columns={columns} rows={rows} alt={`変更後 #${shown.after.seq}`} />
332 </Box>
333 </Box>
334 {details}
335 </Box>
336 )
337 }
338 return (
339 <Box flexDirection="column">
340 <Text bold>{head}</Text>
341 <Text dimColor>{`前 #${shown.before.seq} → 後 #${shown.after.seq}`}</Text>
342 {details}
343 </Box>
344 )
345 })
346}
347hooks/diff.ts 221 lines1// The comparison, pure: decoding a BMP, the grid of cells that changed, the regions they
2// make, and which earlier screenshot a new one is compared with.
3
4import type { VdEdit, VdRegion, VdShot } from '../types'
5
6export const BROWSER_TOOL = /^mcp__(?:remote-devices__)?(?:claude-in-chrome|Claude_Browser)__/
7
8export function base64ToBytes(base64: string): Uint8Array {
9 const text = atob(base64)
10 const bytes = new Uint8Array(text.length)
11 for (let i = 0; i < text.length; i += 1) bytes[i] = text.charCodeAt(i)
12 return bytes
13}
14
15/** Width and height from a PNG's IHDR, base64. */
16export function pngSize(base64: string): { width: number; height: number } | undefined {
17 let head: Uint8Array
18 try {
19 head = base64ToBytes(base64.slice(0, 32))
20 } catch {
21 return undefined
22 }
23 if (head.length < 24 || head[1] !== 0x50 || head[2] !== 0x4e || head[3] !== 0x47) return undefined
24 const u32 = (at: number) => (((head[at] ?? 0) << 24) | ((head[at + 1] ?? 0) << 16) | ((head[at + 2] ?? 0) << 8) | (head[at + 3] ?? 0)) >>> 0
25 return { width: u32(16), height: u32(20) }
26}
27
28export type Pixels = { width: number; height: number; rgb: Uint8Array }
29
30/** An uncompressed 24- or 32-bit BMP (what `sips -s format bmp` writes) as RGB rows, top first. */
31export function parseBmp(bytes: Uint8Array): Pixels | undefined {
32 if (bytes.length < 54 || bytes[0] !== 0x42 || bytes[1] !== 0x4d) return undefined
33 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
34 const offset = view.getUint32(10, true)
35 const width = view.getInt32(18, true)
36 const rawHeight = view.getInt32(22, true)
37 const bpp = view.getUint16(28, true)
38 const compression = view.getUint32(30, true)
39 if (width <= 0 || rawHeight === 0 || (bpp !== 24 && bpp !== 32) || (compression !== 0 && compression !== 3)) return undefined
40 const height = Math.abs(rawHeight)
41 const isTopDown = rawHeight < 0
42 const stride = Math.floor((bpp * width + 31) / 32) * 4
43 if (offset + stride * height > bytes.length) return undefined
44 const step = bpp / 8
45 const rgb = new Uint8Array(width * height * 3)
46 for (let y = 0; y < height; y += 1) {
47 const row = offset + (isTopDown ? y : height - 1 - y) * stride
48 for (let x = 0; x < width; x += 1) {
49 const at = row + x * step
50 const out = (y * width + x) * 3
51 rgb[out] = bytes[at + 2] ?? 0
52 rgb[out + 1] = bytes[at + 1] ?? 0
53 rgb[out + 2] = bytes[at] ?? 0
54 }
55 }
56 return { width, height, rgb }
57}
58
59const PIXEL_DELTA = 24
60const CELL_SHARE = 0.01
61
62/**
63 * Which cells of a `cols`-wide grid changed between two pictures of one size: a cell changed
64 * when more than 1% of its pixels moved by more than 24 in a channel.
65 */
66export function changedCells(a: Pixels, b: Pixels, cols = 32): boolean[][] | undefined {
67 if (a.width !== b.width || a.height !== b.height) return undefined
68 const cell = Math.max(1, Math.ceil(a.width / cols))
69 const rows = Math.ceil(a.height / cell)
70 const across = Math.ceil(a.width / cell)
71 const counts = Array.from({ length: rows }, () => new Array<number>(across).fill(0))
72 for (let y = 0; y < a.height; y += 1) {
73 for (let x = 0; x < a.width; x += 1) {
74 const i = (y * a.width + x) * 3
75 const d = Math.max(Math.abs((a.rgb[i] ?? 0) - (b.rgb[i] ?? 0)), Math.abs((a.rgb[i + 1] ?? 0) - (b.rgb[i + 1] ?? 0)), Math.abs((a.rgb[i + 2] ?? 0) - (b.rgb[i + 2] ?? 0)))
76 if (d > PIXEL_DELTA) {
77 const row = counts[Math.floor(y / cell)] as number[]
78 const at = Math.floor(x / cell)
79 row[at] = (row[at] ?? 0) + 1
80 }
81 }
82 }
83 return counts.map((row, r) =>
84 row.map((n, c) => {
85 const w = Math.min(cell, a.width - c * cell)
86 const h = Math.min(cell, a.height - r * cell)
87 return n / (w * h) > CELL_SHARE
88 }),
89 )
90}
91
92/** The changed cells grouped into rectangles (4-connected), in the original's pixels. */
93export function regionsOf(cells: boolean[][], cellPx: number, limit: { width: number; height: number }): VdRegion[] {
94 const seen = cells.map(row => row.map(() => false))
95 const out: VdRegion[] = []
96 for (let r = 0; r < cells.length; r += 1) {
97 for (let c = 0; c < (cells[r]?.length ?? 0); c += 1) {
98 if (!cells[r]?.[c] || seen[r]?.[c]) continue
99 let [top, left, bottom, right] = [r, c, r, c]
100 const stack: [number, number][] = [[r, c]]
101 ;(seen[r] as boolean[])[c] = true
102 while (stack.length > 0) {
103 const [y, x] = stack.pop() as [number, number]
104 top = Math.min(top, y)
105 bottom = Math.max(bottom, y)
106 left = Math.min(left, x)
107 right = Math.max(right, x)
108 for (const [dy, dx] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) {
109 const ny = y + dy
110 const nx = x + dx
111 if (cells[ny]?.[nx] && !seen[ny]?.[nx]) {
112 ;(seen[ny] as boolean[])[nx] = true
113 stack.push([ny, nx])
114 }
115 }
116 }
117 const x = Math.round(left * cellPx)
118 const y = Math.round(top * cellPx)
119 out.push({ x, y, width: Math.min(limit.width, Math.round((right + 1) * cellPx)) - x, height: Math.min(limit.height, Math.round((bottom + 1) * cellPx)) - y })
120 }
121 }
122 return out.sort((a, b) => b.width * b.height - a.width * a.height)
123}
124
125export const share = (cells: boolean[][]): number => {
126 const all = cells.flat()
127 return all.length === 0 ? 0 : Math.round((all.filter(Boolean).length / all.length) * 1000) / 10
128}
129
130/** The grid as text, two rows of cells per line: █ both changed, ▀ top, ▄ bottom, · neither. */
131export function mapLines(cells: readonly string[]): string[] {
132 const lines: string[] = []
133 for (let r = 0; r < cells.length; r += 2) {
134 const top = cells[r] ?? ''
135 const bottom = cells[r + 1] ?? ''
136 let line = ''
137 for (let c = 0; c < top.length; c += 1) {
138 const t = top[c] === '1'
139 const b = bottom[c] === '1'
140 line += t && b ? '█' : t ? '▀' : b ? '▄' : '·'
141 }
142 lines.push(line)
143 }
144 return lines
145}
146
147export const keyOf = (shot: Pick<VdShot, 'url' | 'width' | 'height'>): string => `${shot.url} @${shot.width}×${shot.height}`
148
149/**
150 * The screenshot a new one is compared with: the person's baseline for that URL and size;
151 * none for a shot whose URL could not be told;
152 * else the latest of the same URL and size taken before an edit that came before the new
153 * one; else simply the previous one of that URL and size.
154 */
155export function pickBefore(shots: readonly VdShot[], edits: readonly VdEdit[], latest: VdShot, baselineId?: string): VdShot | undefined {
156 if (latest.url === '') return undefined
157 const same = shots.filter(one => one.id !== latest.id && keyOf(one) === keyOf(latest) && one.at <= latest.at)
158 const pinned = baselineId === undefined ? undefined : same.find(one => one.id === baselineId)
159 if (pinned !== undefined) return pinned
160 const beforeEdit = [...same].reverse().find(one => edits.some(edit => edit.at > one.at && edit.at <= latest.at))
161 return beforeEdit ?? same.at(-1)
162}
163
164/** The page's URL as a navigation or a new tab said it, from the first lines of its answer. */
165export function urlFrom(tool: string, input: Record<string, unknown>, text: string): string | undefined {
166 if (!/^(?:navigate|tabs_create(?:_mcp)?)$/.test(tool)) return undefined
167 for (const line of text.split('\n').slice(0, 3)) {
168 const m = line.match(/^URL: (https?:\/\/\S+)$/)
169 if (m?.[1] !== undefined) return m[1]
170 }
171 return typeof input.url === 'string' && /^https?:\/\//.test(input.url) ? input.url : undefined
172}
173
174// Actions after which the tab may show another page: a click can follow a link or submit a form,
175// a key can press Enter, a drag can drop a link. A screenshot or a read changes nothing.
176const MAY_LEAVE = new Set(['left_click', 'right_click', 'double_click', 'triple_click', 'key', 'left_click_drag'])
177
178/** Whether a browser call may have moved the tab to another page without saying where. */
179export function mayLeave(tool: string, input: Record<string, unknown>): boolean {
180 if (tool === 'computer') return typeof input.action === 'string' && MAY_LEAVE.has(input.action)
181 if (tool === 'navigate') return typeof input.url !== 'string' || !/^https?:\/\//.test(input.url)
182 return tool === 'form_input' || tool === 'javascript_tool'
183}
184
185/**
186 * The URL a tab listing gives for one tab: an object of the listing that holds that id and a
187 * URL, else a line that names the id and a URL; undefined when the listing does not tie the
188 * two together.
189 */
190export function tabUrlOf(text: string, tabId: number): string | undefined {
191 const isUrl = (value: unknown): value is string => typeof value === 'string' && /^https?:\/\//.test(value)
192 const visit = (value: unknown): string | undefined => {
193 if (Array.isArray(value)) {
194 for (const one of value) {
195 const found = visit(one)
196 if (found !== undefined) return found
197 }
198 return undefined
199 }
200 if (typeof value !== 'object' || value === null) return undefined
201 const node = value as Record<string, unknown>
202 if ((node.tabId === tabId || node.id === tabId) && isUrl(node.url)) return node.url
203 return visit(Object.values(node))
204 }
205 const start = text.search(/[[{]/)
206 if (start >= 0) {
207 try {
208 const found = visit(JSON.parse(text.slice(start)))
209 if (found !== undefined) return found
210 } catch {
211 // Not JSON: read it line by line.
212 }
213 }
214 const id = new RegExp(`(?<![\\d.])${tabId}(?![\\d.])`)
215 for (const line of text.split('\n')) {
216 const found = line.match(/https?:\/\/[^\s'"<>)\]]+/)?.[0]
217 if (found !== undefined && id.test(line.replace(found, ''))) return found.replace(/[.,;]+$/, '')
218 }
219 return undefined
220}
221types/index.d.ts 51 lines1/** A screenshot kept for comparing: the page it shows, its size, and its files. */
2export type VdShot = {
3 id: string
4 seq: number
5 at: number
6 /** The page's URL when it was taken; empty when it could not be told, and such a shot is never paired. */
7 url: string
8 width: number
9 height: number
10 /** The picture as PNG, and a copy scaled down to BMP for comparing, in the session's folder. */
11 png: string
12 bmp?: string
13}
14
15/** A file the session edited, and when. */
16export type VdEdit = { path: string; at: number }
17
18/** A changed area, in the screenshot's own pixels. */
19export type VdRegion = { x: number; y: number; width: number; height: number }
20
21/** The latest comparison. */
22export type VdDiff = {
23 before: VdShot
24 after: VdShot
25 /** The grid of cells, one string per row, `1` where the cell changed. */
26 cells: string[]
27 regions: VdRegion[]
28 /** The share of cells that changed, in percent. */
29 changedPct: number
30 /** Files edited between the two shots. */
31 edits: string[]
32 at: number
33}
34
35declare module 'claude-code' {
36 interface PluginState {
37 'visual-diff': {
38 shots: VdShot[]
39 edits: VdEdit[]
40 /** The page the browser shows now, as the last navigation or tab listing said. */
41 url: string
42 /** Set after an action that may have left that page (a click, a key): the URL is checked again before a shot is filed. */
43 isStale: boolean
44 diff: VdDiff | null
45 /** A screenshot the person chose to compare later ones against, per URL and size. */
46 baselines: Record<string, string>
47 note: string | null
48 }
49 }
50}
51