SLOPSHOPPER

foxel-rmshield

Foxel rmShield: holds risky shell commands (rm -rf, git reset --hard, git clean, force push, migrations) and shows what they would change, with Delete and…

newpanebandguardtoastprocess
★ 1v0.1.0Apache-2.0updated 2026-10-15yuliantoari/Foxel-rmShield/foxel-rmshield
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · foxel-rmshield
│ ┃ Foxel rmShield ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ ◈ rm -rf build will delete files (could │ ⏺ Read(src/auth.ts) │ ┃ │ not measure: no totals) │ ⎿ Read 6 lines │ ┃ │ in app │ ⏺ Update(src/auth.ts) │ ┃ │ │ ⎿ Added 2 lines, removed 1 line │ ┃ │ ⚠ I couldn't measure what build would │ ⏺ Bash(rm -rf build && git push --force origin main) │ ┃ │ delete (no totals). Whatever it deletes │ ⎿ Denied by foxel-rmshield: Foxel rmShield held this comman │ ┃ │ can't be undone. │ │ ┃ │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ 1: Proceed 2: Cancel │ │ ┃ │ Held by Foxel rmShield │ ✻ Worked for 42s · done 4:20 PM │ ┃ ╰──────────────────────────────────────────╯ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Foxel rmShield · while holding a tool call
╭──────────────────────────────────────────────────────────╮ │ ◈ rm -rf build will delete files (could not measure: no │ │ totals) │ │ in app │ │ │ │ ⚠ I couldn't measure what build would delete (no │ │ totals). Whatever it deletes can't be undone. │ │ │ │ 1: Proceed 2: Cancel │ │ Held by Foxel rmShield │ ╰──────────────────────────────────────────────────────────╯
README

Foxel rmShield

A Claude Code mod that holds a risky shell command and shows you what it would change before it runs. Foxel stands guard with a shield while the command is held; you press Delete (run it) or Cancel (refuse it). Claude sees the refusal and the reason.

HeldHeld (dark)
Foxel rmShield holding rm -rf buildDark theme
RunningDone
Foxel rmShield while the command runsFoxel rmShield after the command ran

The images show the card the mod draws. The Delete N files and Cancel buttons are native Claude Code buttons shown right under it and are not part of the images. The progress bar is an animation that marks the phases (Held, Deleting, Done); it does not count deleted files.

What it holds

When Claude calls the Bash tool with one of these, the call is stopped, measured, and shown:

CommandWhat the card shows
rm -r, rm -R, rm -f, rm -rf (also --recursive, --force, -fr)The files it would delete, grouped by folder, with counts and sizes, and how many are tracked by git. Globs and ~ are expanded.
git reset --hardThe files with uncommitted changes.
git checkout -- ., git restore .The files with unstaged changes.
git cleanThe untracked paths it would remove (from git clean -n).
git push --force, -f, +ref, --force-with-leaseThe remote commits your HEAD does not have, which the push would drop.
manage.py migrate, db:migrate, alembic upgrade, prisma migrateThe pending migrations, from the tool's own status command.
any other migrateA note that the pending migrations cannot be listed.

It sees through sudo, env, command, exec, nohup, time, nice, \rm, /bin/rm, cd dir &&, git -C dir, chains (&&, ;, |, ||), subshells, braces and if/while/for. Every other command runs as normal.

What it does not hold

Be aware of the gaps; this is a safety net, not a sandbox.

  • It only hooks the Bash tool. Commands sent through the PowerShell tool are not held.
  • Not recognised: rm.exe, cmd /c, powershell -c, git push -uf, pushes that delete a branch (:branch, --delete), --mirror, git checkout -f, git switch -f, git checkout -- <path>, git restore <path>.
  • Commands hidden inside other commands are not unwrapped: $( ), backticks, bash -c, eval, xargs rm, find -delete.
  • When the target uses shell expansion ($VAR, {a,b}) the card says it cannot measure it; the command is still held.

Behaviour you can rely on

  • Fails closed. If the mod hits an internal error before the command runs, the command is refused, never run unguarded. After the command has run it is never run a second time.
  • Cancel has focus, so pressing Enter refuses the command.
  • A held command that gets no answer for 10 minutes is refused.
  • Measuring is read-only: bash with the paths passed as arguments (never as script text), read-only git commands, sleep, and, for migrations, the project's own status command. No network access, no file writes.
  • The card follows the light or dark theme. In a plain terminal it falls back to a text pane.

Requirements

  • Claude Code with mods support (2.1.287 or later).
  • Windows with Git for Windows in C:\Program Files\Git. The module starts bash.exe and sleep.exe by those full paths. On macOS or Linux, change BASH_BIN and SLEEP_BIN at the top of foxel-rmshield/hooks/foxel-rmshield.mjs to "bash" and "sleep".

Install

Check the plugin, then try it for one session:

claude plugin validate ./foxel-rmshield
claude --plugin-dir ./foxel-rmshield

Or add this repository as a local marketplace and install it:

claude plugin marketplace add ./
claude plugin install foxel-rmshield@foxel-rmshield --scope user

Restart Claude Code after installing or updating, because mods load at start. A good first check: ask Claude to run rm -rf on a throwaway folder; the Foxel rmShield card must appear. If the command runs with no card, the mod did not load.

Files

FilePurpose
foxel-rmshield/.claude-plugin/plugin.jsonPlugin manifest
foxel-rmshield/hooks/hooks.jsonPoints to the module
foxel-rmshield/hooks/foxel-rmshield.mjsThe whole mod: classifier, measuring, card, phases
.claude-plugin/marketplace.jsonLets this repository act as a marketplace

License

Apache-2.0. Copyright 2026 Foxel. See LICENSE.

Source 1 files
hooks/foxel-rmshield.mjs 1400 lines
1// Copyright 2026 Foxel
2// SPDX-License-Identifier: Apache-2.0
3//
4// Foxel rmShield: holds a risky Bash command and shows what it would change.
5//
6// tool.call (Bash): if the command is risky, work out what it would change, open a
7// pane with Proceed and Cancel, and hold the call until one is pressed.
8// ui.render (Pane): draws the report. If the surface won't place the pane (a
9// narrow terminal), the same report is drawn in the AbovePrompt band instead.
10//
11// Holding: a hook has 10 s of its own time, but time spent inside a `$` call is
12// free. So the hold loop waits on a short `$.process.run(["sleep", ...])` until
13// a button's onPress sets the decision.
14//
15// The host reads `on(...)` and `$.noun.method(...)` from source, so they are
16// spelled literally, and helpers that take `$` are top-level functions.
17
18const PANE_ID = "foxel-rmshield";
19const POLL_SECONDS = "0.25";
20// Windows: Git for Windows is not on the PATH the host uses, so use its full paths.
21const BASH_BIN = "C:/Program Files/Git/bin/bash.exe";
22const SLEEP_BIN = "C:/Program Files/Git/usr/bin/sleep.exe";
23const HOLD_LIMIT_MS = 10 * 60 * 1000;
24const LIST_MAX = 10;
25const FILE_CAP = 2000; // rm: files measured one by one; past this only the totals
26const PATH_CAP = 200; // rm: expanded targets listed (for git); the totals count them all
27// After Proceed: polls of sweep before the command runs, and how long "Done" stays up.
28const RUN_PREROLL_POLLS = 2;
29// How far the sweep goes before the command has run: modest, since nothing is done yet.
30const RUN_PROGRESS = 0.5;
31const DONE_SECONDS = "1.2";
32
33
34
35// The call being held, or null. One at a time: Bash calls in a turn run in order.
36let held = null;
37
38// What the model is told when the guard itself fails before the command runs.
39const INTERNAL_DENY = "Foxel rmShield held this command and did not run it: it hit an internal error. Do not retry it unless the user asks you to.";
40// What each command that ran answered, by its event, so a failure after the run
41// hands that answer back instead of running the command again.
42const ran = new WeakMap();
43
44export function register(on) {
45  // The host runs the tool when a guard fails, so this one fails closed: any
46  // throw before the command runs refuses it, and after it never runs it twice.
47  on("tool.call", { tool: "Bash" }, async ($, e, next) => {
48    let isRun = false;
49    try {
50      return await holdCall($, e, next, () => {
51        isRun = true;
52      });
53    } catch (error) {
54      if (isRun) {
55        throw error; // the command's own failure, or one after it: its answer stands
56      }
57      return { deny: INTERNAL_DENY };
58    }
59  }).catch(($, e, next) => {
60    // A budget overrun, or a throw past the try above.
61    if (!next.called) {
62      return { deny: INTERNAL_DENY };
63    }
64    if (ran.has(e)) {
65      return ran.get(e);
66    }
67    throw new Error("Foxel rmShield failed after the command started; its answer stands");
68  });
69
70  on("ui.render", { component: "Pane" }, ($, e, next) => {
71    if (e.requestId !== PANE_ID || held === null || held.report === null) {
72      return next(e);
73    }
74    return drawFor($.ui.resolve(e), held, e, "pane");
75  });
76
77  on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
78    if (held === null || held.report === null || held.where !== "band") {
79      return next(e);
80    }
81    return drawFor($.ui.resolve(e), held, e, "band");
82  });
83}
84
85/** Holds a Bash call while the user decides; `markRun` is called just before the command runs. */
86async function holdCall($, e, next, markRun) {
87  const run = async () => {
88    markRun();
89    const result = await next(e);
90    ran.set(e, result);
91    return result;
92  };
93  const risk = classify(String(e.command ?? ""));
94  if (risk === null) {
95    return run();
96  }
97  // One hold at a time. If another risky call is already held (a subagent's,
98  // say), wait until it is answered. `held` is claimed with no await between
99  // the check and the claim, so two waiting calls can't both get through.
100  while (held !== null) {
101    if (next.signal.aborted) {
102      return { deny: "Foxel rmShield held this command and did not run it: the turn was interrupted. Do not retry it unless the user asks you to." };
103    }
104    await $.process.run([SLEEP_BIN, POLL_SECONDS], { timeoutMs: 5000 });
105  }
106  const mine = { command: String(e.command), risk, report: null, decision: null, where: "pane", phase: "held", progress: 0 };
107  held = mine;
108
109  let opened = { isPlaced: false };
110  let summary = risk.label;
111  // Whatever happens from here, the pane closes and the hold is released.
112  try {
113    try {
114      // Measure where the command will run: the session folder, moved by any
115      // `cd dir &&` or `git -C dir` earlier in the same command line.
116      const sessionCwd = await $.session.cwd();
117      const cwd = risk.dir ? await resolveDir($, sessionCwd, risk.dir) : sessionCwd;
118      mine.report = cwd === null
119        ? { summary: `${risk.label} in ${risk.dir}`, lines: [], note: `Couldn't find the folder ${risk.dir}, so I couldn't measure what this would change.`, where: baseName(risk.dir) }
120        : await measure($, risk, cwd);
121      summary = mine.report.summary;
122
123      opened = (await $.ui.open({ id: PANE_ID, title: "Foxel rmShield", focus: true, rows: paneRows(mine) })) ?? { isPlaced: false };
124      if (!opened.isPlaced) {
125        mine.where = "band";
126      }
127      $.ui.invalidate("ui.render");
128
129      const startedAt = await $.clock.now();
130      while (mine.decision === null) {
131        if (next.signal.aborted) {
132          mine.decision = "interrupted";
133          break;
134        }
135        if ((await $.clock.now()) - startedAt > HOLD_LIMIT_MS) {
136          mine.decision = "timeout";
137          break;
138        }
139        await $.process.run([SLEEP_BIN, POLL_SECONDS], { timeoutMs: 5000 });
140        // The hold's clock, so a redraw continues the CSS animations instead of restarting them.
141        mine.tick = (mine.tick ?? 0) + 1;
142      }
143    } catch {
144      // Anything unexpected refuses the command, even if Proceed was pressed meanwhile.
145      mine.decision = "error";
146    }
147
148    if (mine.decision !== "proceed") {
149      const why = {
150        cancel: "the user pressed Cancel",
151        timeout: "no answer within 10 minutes",
152        interrupted: "the turn was interrupted",
153        error: "Foxel rmShield hit an error while holding it",
154      }[mine.decision] ?? "no answer was recorded";
155      return { deny: `Foxel rmShield held this command and did not run it: ${why}. It would have: ${summary}. Do not retry it unless the user asks you to.` };
156    }
157
158    try {
159      $.ui.toast("Foxel rmShield: running it");
160    } catch {
161      // a toast is only a courtesy
162    }
163    // Show the run: a short sweep, then the command; "Done" only once it ran and wasn't refused.
164    mine.phase = "deleting";
165    for (let i = 0; i < RUN_PREROLL_POLLS; i += 1) {
166      mine.tick = (mine.tick ?? 0) + 1;
167      mine.progress = ((i + 1) * RUN_PROGRESS) / RUN_PREROLL_POLLS;
168      invalidateQuietly($);
169      await $.process.run([SLEEP_BIN, POLL_SECONDS], { timeoutMs: 5000 });
170      // Checked after each pause, so an interruption during the last one still refuses.
171      if (next.signal.aborted) {
172        return { deny: `Foxel rmShield held this command and did not run it: the turn was interrupted. It would have: ${summary}. Do not retry it unless the user asks you to.` };
173      }
174    }
175    const result = await run();
176    if (result !== null && typeof result === "object" && "deny" in result && result.deny) {
177      return result; // refused beneath: no "Done"
178    }
179    mine.phase = "done";
180    mine.progress = 1;
181    invalidateQuietly($);
182    try {
183      await $.process.run([SLEEP_BIN, DONE_SECONDS], { timeoutMs: 5000 });
184    } catch {
185      // the command already ran; its result matters more than the pause
186    }
187    return result;
188  } finally {
189    await closeHeld($, mine, opened);
190  }
191}
192
193/** Asks for a redraw; a failure to redraw never changes what happens to the command. */
194function invalidateQuietly($) {
195  try {
196    $.ui.invalidate("ui.render");
197  } catch {
198    // the next redraw catches up
199  }
200}
201
202// Closes this call's pane, then releases the hold, so the next call's pane
203// can't be the one that gets closed. Nothing here can throw.
204async function closeHeld($, mine, opened) {
205  try {
206    if (opened?.isPlaced) {
207      await $.ui.close({ id: PANE_ID });
208    }
209  } catch {
210    // the pane is already gone
211  }
212  if (held === mine) {
213    held = null;
214  }
215  try {
216    $.ui.invalidate("ui.render");
217  } catch {
218    // nothing left to draw
219  }
220}
221
222// ---- What counts as risky -------------------------------------------------
223
224// sudo options that take a value, so the value isn't read as the command.
225const SUDO_VALUE_OPTIONS = new Set(["-u", "-g", "-C", "-D", "-h", "-p", "-r", "-t", "-T", "-U"]);
226// Commands that only read, so a bare word "migrate" in them isn't a migration.
227const READ_ONLY = new Set(["ls", "cat", "echo", "printf", "grep", "rg", "find", "less", "head", "tail", "cd", "git"]);
228
229/** A folder a later `cd arg` moves to, given the folder so far (null = the session folder). */
230function joinDir(dir, arg) {
231  if (arg === undefined || arg === "~" || arg.startsWith("/") || arg.startsWith("~/")) {
232    return arg ?? "~";
233  }
234  return dir ? `${dir}/${arg}` : arg;
235}
236
237/** The first risky segment of a shell command, or null. */
238function classify(command) {
239  let dir = null; // where a `cd` earlier on the line moved to; null means the session folder
240  const scopes = []; // dir to restore when a ( subshell ) closes
241  const pushed = []; // pushd stack, for popd
242  // An unescaped backslash before a newline continues the line, so the shell
243  // drops both; an escaped one (`\\`) is a literal backslash and the line ends.
244  for (const raw of command.replace(/(?<!\\)((?:\\\\)*)\\\r?\n/g, "$1").split(/&&|\|\||;|\||\n/)) {
245    const opens = (raw.match(/^\s*\(+/)?.[0].trim().length) ?? 0;
246    // Trailing redirects and & don't hide a closing ) : `(cd sub && make) > log`.
247    const tail = raw.replace(/(?:\s*(?:\d*>>?|&>>?|<)\s*\S+|\s*&)+\s*$/, "");
248    const closes = (tail.match(/\)+\s*$/)?.[0].trim().length) ?? 0;
249    for (let k = 0; k < opens; k += 1) {
250      scopes.push(dir);
251    }
252    const risk = classifySegment(raw, dir, pushed);
253    if (risk !== null && risk.cd === undefined) {
254      return risk;
255    }
256    if (risk !== null) {
257      dir = risk.cd; // a cd, pushd or popd moved the folder
258    }
259    for (let k = 0; k < closes && scopes.length > 0; k += 1) {
260      dir = scopes.pop(); // a cd inside ( ... ) doesn't outlive it
261    }
262  }
263  return null;
264}
265
266// Words that can come before the real command without changing what it does.
267const PREFIXES = new Set(["command", "exec", "env", "nohup", "time", "then", "do", "else", "!"]);
268
269/** One segment: a risk, { cd } for a folder change, or null. */
270function classifySegment(segment, dir, pushed) {
271  {
272    // Trailing redirects and & go first, so `(rm -rf build) > log` ends in its ).
273    // A digit redirect counts only as a word of its own or right after ), since
274    // `build2>log` is the argument build2. Then ( and ) group wherever they
275    // stand; { and } only as words of their own, since {a,b} is brace expansion.
276    const bare = segment.trim().replace(/(?:(?:\s+|(?<=\)))(?:\d*>>?&?|&>>?|<)\s*\S+|\s*&)+\s*$/, "");
277    const words = tokenize(bare.replace(/^\(+\s*/, "").replace(/^\{\s+/, "").replace(/\s*\)+$/, "").replace(/(^|\s)\}+$/, ""));
278    while (words.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) {
279      words.shift(); // leading VAR=value
280    }
281    if (words[0] === "sudo") {
282      words.shift();
283      while (words.length > 0 && words[0].startsWith("-")) {
284        const option = words.shift();
285        if (SUDO_VALUE_OPTIONS.has(option)) {
286          words.shift();
287        }
288      }
289    }
290    while (words.length > 0 && (PREFIXES.has(words[0]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]))) {
291      words.shift();
292    }
293    if (words[0] === "nice") {
294      words.shift();
295      if (words[0] === "-n") {
296        words.splice(0, 2);
297      } else if (/^-\d+$/.test(words[0] ?? "")) {
298        words.shift();
299      }
300    }
301    const [first, ...args] = words;
302    if (first === undefined) {
303      return null;
304    }
305    const cmd = first.replace(/^\\/, ""); // \rm skips aliases; it's still rm
306    if (cmd === "cd") {
307      return { cd: args[0] === "-" ? "-" : joinDir(dir, args[0]) };
308    }
309    if (cmd === "pushd") {
310      pushed.push(dir);
311      return { cd: joinDir(dir, args[0]) };
312    }
313    if (cmd === "popd") {
314      return { cd: pushed.length > 0 ? pushed.pop() : "-" };
315    }
316    if (cmd === "rm" || cmd.endsWith("/rm")) {
317      const flags = args.filter((a) => a.startsWith("-"));
318      const recursive = flags.some((f) => f === "--recursive" || (/^-[^-]/.test(f) && /[rR]/.test(f)));
319      const force = flags.some((f) => f === "--force" || (/^-[^-]/.test(f) && f.includes("f")));
320      if (recursive || force) {
321        const targets = withoutRedirects(args).filter((a) => !a.startsWith("-") || a === "-");
322        return { kind: "rm", label: `rm ${flags.join(" ")}`.trim(), targets, dir };
323      }
324    }
325    if (cmd === "git") {
326      // Git's own options come before the subcommand; -C moves where it runs.
327      let gitDir = dir;
328      let i = 0;
329      while (i < args.length && args[i].startsWith("-")) {
330        if (args[i] === "-C" && i + 1 < args.length) {
331          gitDir = joinDir(gitDir, args[i + 1]);
332          i += 2;
333        } else if (args[i] === "-c" && i + 1 < args.length) {
334          i += 2;
335        } else {
336          i += 1;
337        }
338      }
339      const sub = args[i];
340      const rest = args.slice(i + 1);
341      if (sub === "reset" && rest.includes("--hard")) {
342        return { kind: "git-reset", label: "git reset --hard", args: rest, dir: gitDir };
343      }
344      if (sub === "clean") {
345        return { kind: "git-clean", label: "git clean", args: rest, dir: gitDir };
346      }
347      if (sub === "push" && rest.some((a) => a === "--force" || a === "-f" || a.startsWith("--force-with-lease") || /^\+/.test(a))) {
348        return { kind: "git-push-force", label: "git push --force", args: rest, dir: gitDir };
349      }
350      const stagedOnly = sub === "restore" && rest.includes("--staged") && !rest.includes("--worktree") && !rest.includes("-W");
351      if ((sub === "checkout" || sub === "restore") && rest.includes(".") && !stagedOnly) {
352        return { kind: "git-checkout", label: `git ${sub} -- .`, args: rest, dir: gitDir };
353      }
354    }
355    const joined = words.join(" ");
356    if (/\balembic\s+upgrade\b/.test(joined)) {
357      return { kind: "migrate", tool: "alembic", label: "alembic upgrade", dir };
358    }
359    if (/\bdb:migrate(?!:status\b)/.test(joined)) {
360      return { kind: "migrate", tool: "rails", label: "db:migrate", dir };
361    }
362    if (/\bprisma\s+migrate\b/.test(joined)) {
363      return { kind: "migrate", tool: "prisma", label: "prisma migrate", dir };
364    }
365    if (/\bmanage\.py\s+migrate\b/.test(joined)) {
366      return { kind: "migrate", tool: "django", label: "manage.py migrate", dir };
367    }
368    if (!READ_ONLY.has(cmd) && args.includes("migrate")) {
369      return { kind: "migrate", tool: "unknown", label: "migrate", dir };
370    }
371  }
372  return null;
373}
374
375// Resolves a `cd` target to an absolute folder, or null if it doesn't exist.
376// The target is passed as an argument, never as source.
377const CD_SCRIPT = `unset CDPATH; d="$1"; case "$d" in "~") d="$HOME";; "~/"*) d="$HOME/\${d#\\~/}";; esac; cd -- "$d" 2>/dev/null && { pwd -W 2>/dev/null || pwd -P; }`;
378
379async function resolveDir($, sessionCwd, dir) {
380  if (dir === "-") {
381    return null; // `cd -` depends on the shell's history
382  }
383  const run = await $.process.run([BASH_BIN, "-c", CD_SCRIPT, "foxel-rmshield", dir], { cwd: sessionCwd, timeoutMs: 5000 });
384  const out = run.stdout.trim();
385  return run.exitCode === 0 && out !== "" ? out : null;
386}
387
388/** The words left once redirections (`>log`, `2>&1`, `> log`, `x>log`) are taken out. */
389function withoutRedirects(words) {
390  const out = [];
391  for (let i = 0; i < words.length; i += 1) {
392    const w = words[i];
393    const at = w.search(/[<>]/);
394    if (at < 0) {
395      out.push(w);
396      continue;
397    }
398    const before = w.slice(0, at);
399    if (before !== "" && before !== "&" && !/^\d+$/.test(before)) {
400      out.push(before); // x>log: x is still an argument
401    }
402    if (/^(?:\d*|&)[<>]+&?$/.test(w)) {
403      i += 1; // a bare operator: the next word is where it points
404    }
405  }
406  return out;
407}
408
409/** Splits one segment into words, honouring quotes. Good enough to read flags and paths. */
410function tokenize(text) {
411  const words = [];
412  const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g;
413  let m;
414  while ((m = re.exec(text)) !== null) {
415    words.push(m[1] ?? m[2] ?? m[3]);
416  }
417  return words;
418}
419
420// ---- Measuring what the command would change -------------------------------------------
421
422/** { summary, lines, note, where } for the pane. Never throws: a failed read is said, not hidden. */
423async function measure($, risk, cwd) {
424  let report;
425  try {
426    if (risk.kind === "rm") {
427      report = await measureRm($, risk, cwd);
428    } else if (risk.kind === "migrate") {
429      report = await measureMigrations($, risk, cwd);
430    } else {
431      report = await measureGit($, risk, cwd);
432    }
433  } catch (error) {
434    report = { summary: `${risk.label} (could not measure it: ${String(error?.message ?? error).slice(0, 300)})`, lines: [], note: `Could not measure: ${String(error?.message ?? error).slice(0, 200)}` };
435  }
436  report.where = baseName(cwd);
437  return report;
438}
439
440/** The last part of a path, Windows or POSIX style. */
441function baseName(path) {
442  const trimmed = String(path ?? "").replace(/[\\/]+$/, "");
443  return trimmed.split(/[\\/]/).pop() || String(path ?? "");
444}
445
446// The paths are passed to bash as arguments, never as source, so nothing in
447// them runs. compgen -G expands a glob without command substitution.
448const RM_SCRIPT = `
449shopt -s nullglob dotglob
450paths=()
451for p in "$@"; do
452  case "$p" in "~"|"~/"*) p="$HOME\${p#\\~}";; esac
453  if [[ "$p" == *[*?[]* ]]; then
454    while IFS= read -r m; do paths+=("$m"); done < <(compgen -G "$p")
455  elif [[ -e "$p" || -L "$p" ]]; then
456    paths+=("$p")
457  fi
458done
459if (( \${#paths[@]} == 0 )); then echo "@total 0 0 0"; exit 0; fi
460# A relative path gets ./ in front, so find never reads a name like -delete as an action.
461for i in "\${!paths[@]}"; do case "\${paths[$i]}" in /*|[A-Za-z]:*) ;; *) paths[$i]="./\${paths[$i]}";; esac; done
462# The paths reach find on its input, so a glob of thousands can't overflow a command line.
463list() { printf '%s\\0' "\${paths[@]}" | find -files0-from - \\( -type f -o -type l \\) "$@" 2>/dev/null; }
464# The totals come first, so a cut or a failure later on can't hide them.
465echo "@total $(list -printf '%s\\n' | awk '{ n++; s += $1 } END { printf "%d %.0f", n, s }') \${#paths[@]}"
466# Where this runs, both spellings, so absolute paths can be shown relative to it.
467echo "@cwd $(pwd -W 2>/dev/null)"
468echo "@cwd $(pwd)"
469for p in "\${paths[@]:0:${PATH_CAP}}"; do echo "@path $p"; done
470# Then the files as size<TAB>path, the first ${FILE_CAP}.
471list -printf '%s\\t%p\\n' | head -n ${FILE_CAP}
472`;
473
474async function measureRm($, risk, cwd) {
475  if (risk.targets.length === 0) {
476    return { summary: "rm with no paths", lines: [], note: "No paths to expand." };
477  }
478  // A target the shell still has to expand ($x, `cmd`, {a,b}) can't be measured
479  // from its spelling; say so rather than report that nothing matches.
480  const expanded = risk.targets.find((t) => /[$`{}]/.test(t));
481  if (expanded !== undefined) {
482    return { summary: `delete (can't measure: shell expansion in ${expanded})`, lines: [], note: `${expanded} uses shell expansion, so I can't tell which files this would delete.` };
483  }
484  let run;
485  try {
486    run = await $.process.run([BASH_BIN, "-c", RM_SCRIPT, "foxel-rmshield", ...risk.targets], { cwd, timeoutMs: 60000 });
487  } catch {
488    run = null;
489  }
490  const bases = [];
491  const top = [];
492  const seen = new Set();
493  const entries = [];
494  let totals = null;
495  // Never "nothing" when the measure failed: a missing total, a cut or a failed script is said as such.
496  const failure = run === null ? "it failed or took too long"
497    : run.isStdoutTruncated ? "output too large"
498      : run.exitCode !== 0 ? "script failed"
499        : /^@total \d+ \d+ \d+\r?(\n|$)/.test(run.stdout) ? null : "no totals";
500  if (failure !== null) {
501    return { summary: `delete files (could not measure: ${failure})`, lines: [], note: `I couldn't measure what ${risk.targets.join(" ")} would delete (${failure}). Whatever it deletes can't be undone.` };
502  }
503  for (const line of run.stdout.split("\n").map((l) => l.replace(/\r$/, ""))) {
504    if (line.startsWith("@cwd ")) {
505      if (line.length > 5) {
506        bases.push(line.slice(5).replace(/\/$/, "").toLowerCase());
507      }
508    } else if (line.startsWith("@path ")) {
509      top.push(relativeTo(bases, line.slice(6))); // git doesn't read /c/... or /tmp/... paths
510    } else if (line.startsWith("@total ") && totals === null) {
511      totals = line.slice(7);
512    } else if (line.includes("\t")) {
513      const tab = line.indexOf("\t");
514      const path = relativeTo(bases, line.slice(tab + 1));
515      if (!seen.has(path)) {
516        seen.add(path); // overlapping targets list a file twice
517        const slash = path.lastIndexOf("/");
518        entries.push({ path, dir: slash < 0 ? "" : path.slice(0, slash), name: path.slice(slash + 1), bytes: Number(line.slice(0, tab)) || 0 });
519      }
520    }
521  }
522  let [files, bytes, found] = totals.split(" ").map(Number);
523  if (!found) {
524    return { summary: `delete nothing: no file matches ${risk.targets.join(" ")}`, lines: [], note: "The paths don't exist, so rm has nothing to remove." };
525  }
526  if (!files) {
527    return { summary: `delete ${found} ${found === 1 ? "path" : "paths"} with no files in ${found === 1 ? "it" : "them"}`, lines: [], note: `Paths: ${risk.targets.join(" ")}` };
528  }
529  const capped = files > FILE_CAP;
530  if (!capped) {
531    files = entries.length;
532    bytes = entries.reduce((sum, e) => sum + e.bytes, 0);
533  }
534  return {
535    summary: `delete ${files} ${files === 1 ? "file" : "files"} (about ${size(bytes)})`,
536    lines: [],
537    entries,
538    files,
539    bytes,
540    capped,
541    // When the paths were cut, ask git about the whole folder instead.
542    tracked: await countTracked($, cwd, top.length < found ? [] : top, entries),
543    note: capped ? `Listed the first ${FILE_CAP} of ${files} files; folder totals count only those.` : `Paths: ${risk.targets.join(" ")}`,
544  };
545}
546
547/** A path from find, relative to the folder it was measured in when it's inside it. */
548function relativeTo(bases, path) {
549  const plain = path.replace(/^(?:\.\/)+/, ""); // `rm -rf .` lists ././name
550  const lower = plain.toLowerCase();
551  for (const base of bases) {
552    if (base !== "" && lower.startsWith(`${base}/`)) {
553      return plain.slice(base.length + 1) || ".";
554    }
555  }
556  return plain || ".";
557}
558
559/** How many entries git tracks: a number, null outside a repository, undefined if git couldn't say. */
560async function countTracked($, cwd, top, entries) {
561  // Too many top-level paths for one command line: list the whole folder instead.
562  const spec = top.join(" ").length > 24000 ? [] : top;
563  let run;
564  try {
565    // The paths are already expanded, so git must not read * or : in them.
566    run = await $.process.run(["git", "--literal-pathspecs", "ls-files", "-z", "--", ...spec], { cwd, timeoutMs: 15000 });
567  } catch {
568    return undefined;
569  }
570  if (run.exitCode !== 0) {
571    return /not a git repository/i.test(run.stderr) ? null : undefined;
572  }
573  const tracked = new Set(run.stdout.split("\0").filter((p) => p !== "").map((p) => p.toLowerCase()));
574  return entries.filter((e) => tracked.has(e.path.toLowerCase())).length;
575}
576
577async function measureGit($, risk, cwd) {
578  if (risk.kind === "git-push-force") {
579    return await measurePush($, risk, cwd);
580  }
581  if (risk.kind === "git-clean") {
582    const flags = [];
583    const paths = [];
584    for (let i = 0; i < risk.args.length; i += 1) {
585      const a = risk.args[i];
586      if (a === "--") {
587        paths.push(...risk.args.slice(i + 1));
588        break;
589      }
590      if (a === "-e" || a === "--exclude") {
591        flags.push(a, risk.args[i + 1] ?? "");
592        i += 1;
593      } else if (a.startsWith("--exclude=") || /^-e./.test(a)) {
594        flags.push(a);
595      } else if (/^-[a-zA-Z]+$/.test(a)) {
596        const kept = a.replace(/[finq]/g, ""); // -n is added below; -f, -i and -q would change the dry run
597        if (kept !== "-") {
598          flags.push(kept);
599        }
600      } else if (!a.startsWith("-")) {
601        paths.push(a);
602      }
603    }
604    const run = await $.process.run(["git", "clean", "-n", ...flags, "--", ...paths], { cwd, timeoutMs: 15000 });
605    if (run.exitCode !== 0) {
606      return { summary: "git clean (could not dry-run it)", lines: [], note: run.stderr.trim().slice(0, 200) };
607    }
608    const gone = run.stdout.split("\n").filter((l) => l.startsWith("Would remove ")).map((l) => l.slice(13));
609    return {
610      summary: gone.length === 0 ? "remove nothing: no untracked files match" : `remove ${gone.length} untracked ${gone.length === 1 ? "path" : "paths"}`,
611      lines: gone.slice(0, LIST_MAX),
612      more: Math.max(0, gone.length - LIST_MAX),
613      note: "From git clean -n. Untracked files are not in git, so they can't be recovered.",
614    };
615  }
616  const status = await $.process.run(["git", "status", "--porcelain"], { cwd, timeoutMs: 15000 });
617  if (status.exitCode !== 0) {
618    return { summary: `${risk.label} (not a git repo here?)`, lines: [], note: status.stderr.trim().slice(0, 200) };
619  }
620  const rows = status.stdout.split("\n").filter((l) => l.length > 3 && !l.startsWith("??"));
621  // reset --hard drops staged and unstaged changes; checkout -- . drops unstaged ones.
622  const lost = risk.kind === "git-reset" ? rows : rows.filter((l) => l[1] !== " ");
623  const stat = await $.process.run(["git", "diff", "--shortstat", risk.kind === "git-reset" ? "HEAD" : "--"], { cwd, timeoutMs: 15000 });
624  return {
625    summary: lost.length === 0 ? "discard nothing: no uncommitted changes" : `discard uncommitted changes in ${lost.length} ${lost.length === 1 ? "file" : "files"}`,
626    lines: lost.slice(0, LIST_MAX).map((l) => `${l.slice(0, 2)} ${l.slice(3)}`),
627    more: Math.max(0, lost.length - LIST_MAX),
628    note: stat.stdout.trim() !== "" ? `${stat.stdout.trim()}. Uncommitted changes can't be recovered.` : "From git status --porcelain.",
629  };
630}
631
632async function measurePush($, risk, cwd) {
633  const positional = risk.args.filter((a) => !a.startsWith("-"));
634  const remote = positional[0] ?? "origin";
635  // A refspec is src:dst. With no colon, the local branch of the same name is pushed.
636  const spec = (positional[1] ?? "").replace(/^\+/, "");
637  let [source, branch] = spec.includes(":") ? spec.split(":") : [spec, spec];
638  branch = (branch ?? "").replace(/^refs\/heads\//, "");
639  if (!branch) {
640    const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
641    branch = head.stdout.trim();
642    source = "HEAD";
643  } else if (branch === "HEAD") {
644    // `git push origin HEAD` pushes the current branch to its namesake.
645    const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
646    branch = head.stdout.trim();
647    source = "HEAD";
648  }
649  source = source || "HEAD";
650  const ref = `${remote}/${branch}`;
651  const known = await $.process.run(["git", "rev-parse", "--verify", "--quiet", ref], { cwd, timeoutMs: 10000 });
652  if (known.exitCode !== 0) {
653    return { summary: `force-push to ${ref}`, lines: [], note: `No local copy of ${ref}, so I can't tell which commits the push would drop. Run git fetch first.` };
654  }
655  const log = await $.process.run(["git", "log", "--oneline", "--no-decorate", `${source}..${ref}`], { cwd, timeoutMs: 15000 });
656  const dropped = log.stdout.split("\n").filter((l) => l !== "");
657  return {
658    summary: dropped.length === 0 ? `force-push to ${ref}: drops no commits` : `force-push to ${ref}: drops ${dropped.length} ${dropped.length === 1 ? "commit" : "commits"}`,
659    lines: dropped.slice(0, LIST_MAX),
660    more: Math.max(0, dropped.length - LIST_MAX),
661    note: `Commits on ${ref} that ${source} doesn't have, as of the last fetch.`,
662  };
663}
664
665const MIGRATION_LISTERS = {
666  django: { argv: ["python3", "manage.py", "showmigrations", "--plan"], pending: (l) => l.startsWith("[ ]"), strip: (l) => l.slice(4) },
667  alembic: { argv: ["alembic", "history", "-r", "current:head"], pending: (l) => l.includes("->"), strip: (l) => l },
668  rails: { argv: ["bin/rails", "db:migrate:status"], pending: (l) => /^\s*down\b/.test(l), strip: (l) => l.trim() },
669  prisma: { argv: ["npx", "--no-install", "prisma", "migrate", "status"], pending: (l) => /^\s{2}\S/.test(l), strip: (l) => l.trim() },
670};
671
672async function measureMigrations($, risk, cwd) {
673  const lister = MIGRATION_LISTERS[risk.tool];
674  if (lister === undefined) {
675    return { summary: "run migrations", lines: [], note: "I can't list the pending migrations for this tool, so the list is not shown." };
676  }
677  let run;
678  try {
679    run = await $.process.run(lister.argv, { cwd, timeoutMs: 20000 });
680  } catch (error) {
681    run = { exitCode: -1, stdout: "", stderr: String(error?.message ?? error) };
682  }
683  if (run.exitCode !== 0) {
684    return { summary: `run ${risk.label}`, lines: [], note: `Couldn't list pending migrations (${lister.argv.join(" ")} failed).` };
685  }
686  const pending = run.stdout.split("\n").filter(lister.pending).map(lister.strip);
687  return {
688    summary: pending.length === 0 ? `run ${risk.label}: nothing pending` : `apply ${pending.length} pending ${pending.length === 1 ? "migration" : "migrations"}`,
689    lines: pending.slice(0, LIST_MAX),
690    more: Math.max(0, pending.length - LIST_MAX),
691    note: `From ${lister.argv.join(" ")}.`,
692  };
693}
694
695function size(bytes) {
696  if (!Number.isFinite(bytes) || bytes < 1024) {
697    return `${bytes || 0} B`;
698  }
699  const units = ["KB", "MB", "GB", "TB"];
700  let n = bytes;
701  let i = -1;
702  while (n >= 1024 && i < units.length - 1) {
703    n /= 1024;
704    i += 1;
705  }
706  return `${n.toFixed(n < 10 ? 1 : 0)} ${units[i]}`;
707}
708
709// ---- Drawing --------------------------------------------------------------
710// Tokyo Night (dark), the user's skin palette.
711const COLOR = { fg: "#c0caf5", muted: "#737aa2", err: "#f7768e", warn: "#e0af68", ok: "#9ece6a", rule: "#3b4261", accent: "#7aa2f7" };
712const PANE_MAX_ROWS = 24;
713// The pane can be this narrow and its font proportional, so heights are
714// estimated for this width (an over-estimate when the pane is wider).
715const NARROW_COLUMNS = 34;
716// The terminal's stand-in for Foxel: one shield glyph beside the header.
717const MARK = "◈";
718const MARK_COLUMNS = 1;
719// Text width beside the mark (border + padding 4, mark 1 + margin 1) and for the
720// banner (4), less 2 so wider glyphs in a proportional font still fit the estimate.
721const HEADER_COLUMNS = NARROW_COLUMNS - 4 - MARK_COLUMNS - 1 - 2;
722const BANNER_COLUMNS = NARROW_COLUMNS - 4 - 2;
723
724/** Lines `text` takes when word-wrapped to `columns`. */
725function wrappedLines(text, columns) {
726  let lines = 1;
727  let used = 0;
728  for (const word of text.split(" ")) {
729    let w = word.length;
730    const need = used === 0 ? w : used + 1 + w;
731    if (need <= columns) {
732      used = need;
733      continue;
734    }
735    if (used > 0) {
736      lines += 1;
737    }
738    while (w > columns) {
739      lines += 1; // a word longer than the line is broken
740      w -= columns;
741    }
742    used = w;
743  }
744  return lines;
745}
746
747/** [command, verb, amount] for the header line. */
748function headline(state) {
749  const { report, risk } = state;
750  const subject = shorten((risk.kind === "rm" ? `${risk.label} ${risk.targets.join(" ")}` : risk.label).trim(), 48);
751  if (report.entries !== undefined) {
752    return [subject, " will delete ", `${report.files} ${report.files === 1 ? "file" : "files"} · ${size(report.bytes)}`];
753  }
754  return /^(delete|discard|remove|force-push|apply|run)\b/.test(report.summary) ? [subject, " will ", report.summary] : [subject, " · ", report.summary];
755}
756
757/**
758 * Rows the pane needs besides the list, counted at the narrow width: border 2,
759 * header (wrapped line + folder, at least 3), margin 1, banner
760 * (wrapped), rule 1, capped note 1, margin 1, buttons 1, hint 1.
761 */
762function fixedRows(state) {
763  const header = Math.max(3, wrappedLines(headline(state).join(""), HEADER_COLUMNS) + 1);
764  const warning = wrappedLines(`⚠ ${banner(state).text}`, BANNER_COLUMNS);
765  return 2 + header + 1 + warning + 1 + (state.report.capped ? 1 : 0) + 1 + 1 + 1;
766}
767
768/** Rows the list may use: the list is what gets cut, never the buttons. */
769function listRoom(state) {
770  return Math.max(0, PANE_MAX_ROWS - fixedRows(state));
771}
772
773function paneRows(state) {
774  const rows = listRows(state.report, listRoom(state)).length;
775  // With no list there's no rule either.
776  const text = fixedRows(state) + rows - (rows === 0 ? 1 : 0);
777  // The desktop card, in rows of ROW_PX at the narrowest width, plus the buttons.
778  // The surface isn't known yet, so take the larger.
779  const svg = Math.ceil(cardLayout(state, 240).height / ROW_PX) + BUTTON_ROWS;
780  return Math.min(PANE_MAX_ROWS, Math.max(text, svg));
781}
782
783/**
784 * The list under the rule, one line per row, cut to `room` rows. rm groups
785 * files by folder, largest first; the other kinds show their lines as they are.
786 */
787function listRows(report, room) {
788  if (report.entries === undefined) {
789    const lines = report.lines ?? [];
790    const hidden = report.more ?? 0;
791    const shown = lines.length + (hidden > 0 ? 1 : 0) <= room ? lines.length : Math.max(0, room - 1);
792    const rows = lines.slice(0, shown).map((text) => ({ type: "line", text }));
793    const more = hidden + lines.length - shown;
794    if (more > 0 && room > 0) {
795      rows.push({ type: "more", text: `+ ${more} more` });
796    }
797    return rows;
798  }
799  const groups = new Map();
800  for (const e of report.entries) {
801    const g = groups.get(e.dir) ?? { dir: e.dir, bytes: 0, count: 0, shown: [] };
802    g.bytes += e.bytes;
803    g.count += 1;
804    groups.set(e.dir, g);
805  }
806  // Largest files first, across all folders; a folder's row is paid for by its first file.
807  const bySize = [...report.entries].sort((a, b) => b.bytes - a.bytes || a.path.localeCompare(b.path));
808  let used = 0;
809  let shownCount = 0;
810  let shownBytes = 0;
811  for (const e of bySize) {
812    const g = groups.get(e.dir);
813    const cost = g.shown.length === 0 ? 2 : 1;
814    const left = report.files - shownCount - 1;
815    if (shownCount >= LIST_MAX || used + cost + (left > 0 ? 1 : 0) > room) {
816      break;
817    }
818    g.shown.push(e);
819    used += cost;
820    shownCount += 1;
821    shownBytes += e.bytes;
822  }
823  const rows = [];
824  const order = [...groups.values()].filter((g) => g.shown.length > 0).sort((a, b) => b.bytes - a.bytes || a.dir.localeCompare(b.dir));
825  for (const g of order) {
826    // A capped list only knows its own files, so a folder's count and size are lower bounds.
827    rows.push({ type: "group", label: `${g.dir === "" ? "." : g.dir}/`, count: g.count, bytes: g.bytes, atLeast: report.capped === true });
828    for (const e of g.shown) {
829      rows.push({ type: "file", label: e.name, bytes: e.bytes });
830    }
831  }
832  const more = report.files - shownCount;
833  if (more > 0 && room > 0) {
834    rows.push({ type: "more", text: `+ ${more} more ${more === 1 ? "file" : "files"} · ${size(Math.max(0, report.bytes - shownBytes))}` });
835  }
836  return rows;
837}
838
839/** The warning under the header: { tone, text }. */
840function banner(state) {
841  const { report } = state;
842  if (report.entries !== undefined) {
843    const { tracked } = report;
844    const counted = report.capped ? report.entries.length : report.files;
845    if (tracked === null) {
846      return { tone: COLOR.err, text: "Not in a git repository, so deleted files can't be restored." };
847    }
848    if (tracked === undefined) {
849      return { tone: COLOR.err, text: "Couldn't check these files against git, so assume they can't be restored." };
850    }
851    if (report.capped) {
852      // Only the listed files were checked: say so, and say nothing of the rest.
853      const checked = `Checked the first ${counted} of ${report.files} files:`;
854      if (tracked === 0) {
855        return { tone: COLOR.err, text: `${checked} none are tracked by git, so they can't be restored. The others weren't checked.` };
856      }
857      return { tone: COLOR.warn, text: `${checked} ${tracked} are tracked by git and can be restored, ${counted - tracked} can't. The others weren't checked.` };
858    }
859    if (tracked === 0) {
860      return { tone: COLOR.err, text: counted === 1 ? "This file isn't tracked by git, so it can't be restored." : "None of these are tracked by git, so they can't be restored." };
861    }
862    if (tracked >= counted) {
863      return { tone: COLOR.warn, text: counted === 1 ? "This file is tracked by git, so its committed version can be restored." : `All ${counted} files are tracked by git, so their committed versions can be restored.` };
864    }
865    return { tone: COLOR.warn, text: `${tracked} of ${counted} files are tracked by git and can be restored; the other ${counted - tracked} can't.` };
866  }
867  let text = report.note ?? "";
868  if (state.risk.kind === "git-clean") {
869    text = text.replace(/^From git clean -n\.\s*/, "");
870  }
871  if ((state.risk.kind === "git-reset" || state.risk.kind === "git-checkout") && text.startsWith("From git status")) {
872    text = "Uncommitted changes can't be recovered.";
873  }
874  return { tone: COLOR.err, text: text || "Foxel rmShield couldn't tell what this would change." };
875}
876
877/** "4 files", or "≥4 files" when the list was capped and the folder may hold more. */
878const groupCount = (row) => `${row.atLeast ? "≥" : ""}${row.count} ${row.count === 1 ? "file" : "files"}`;
879
880function buttonLabel(state) {
881  const { report } = state;
882  const kind = state.risk.kind;
883  const count = kind === "rm" ? report.files ?? 0 : kind === "git-clean" ? (report.lines ?? []).length + (report.more ?? 0) : 0;
884  if (kind === "rm" || kind === "git-clean") {
885    return count > 0 ? `Delete ${count} ${count === 1 ? "file" : "files"}` : "Proceed";
886  }
887  return { "git-reset": "Discard changes", "git-checkout": "Discard changes", "git-push-force": "Force push", migrate: "Run migration" }[kind] ?? "Proceed";
888}
889
890function shorten(text, width) {
891  return text.length > width ? `${text.slice(0, width - 1)}…` : text;
892}
893
894function draw(t, state) {
895  const { Box, Text, Button } = t;
896  const { report, risk } = state;
897  const rows = listRows(report, listRoom(state));
898  const [subject, verb, amount] = headline(state);
899  const warning = banner(state);
900
901  // Each list row is one line of boxes: the name takes what's left and is cut;
902  // the count and size keep their width. Nothing is padded with spaces, since
903  // the pane's font can be proportional.
904  const line = (key, children, extra = {}) => Box({ key, flexDirection: "row", height: 1, overflow: "hidden", ...extra, children });
905  const name = (children, indent = 0) => Box({ key: "name", flexGrow: 1, flexShrink: 1, overflow: "hidden", height: 1, paddingLeft: indent, children: Text({ wrap: "truncate-end", children }) });
906  // Sizes that hold a fifth or more of the bytes stand out.
907  const sizeBox = (bytes, strong, atLeast = false) => Box({ key: "size", flexShrink: 0, width: 8, justifyContent: "flex-end", children: Text({ color: strong ? COLOR.fg : COLOR.muted, bold: strong, children: `${atLeast ? "≥" : ""}${size(bytes)}` }) });
908  const big = (bytes) => bytes >= 0.2 * (report.bytes || 1);
909  const list = rows.map((row, i) => {
910    const key = `r${i}`;
911    if (row.type === "group") {
912      return line(key, [
913        name([Text({ key: "mark", color: COLOR.muted, children: "▸ " }), Text({ key: "dir", color: COLOR.fg, bold: true, children: row.label })]),
914        Box({ key: "count", flexShrink: 0, marginLeft: 1, children: Text({ color: COLOR.muted, children: groupCount(row) }) }),
915        sizeBox(row.bytes, true, row.atLeast),
916      ]);
917    }
918    if (row.type === "file") {
919      return line(key, [name(Text({ color: COLOR.fg, children: row.label }), 2), sizeBox(row.bytes, big(row.bytes))]);
920    }
921    if (row.type === "more") {
922      return line(key, [name(Text({ color: COLOR.muted, children: row.text }))], { paddingLeft: 2 });
923    }
924    // git status lines start with a two-letter status code.
925    if ((risk.kind === "git-reset" || risk.kind === "git-checkout") && /^.. /.test(row.text)) {
926      return line(key, [
927        Box({ key: "code", flexShrink: 0, width: 3, children: Text({ color: COLOR.warn, children: row.text.slice(0, 2) }) }),
928        name(Text({ color: COLOR.fg, children: row.text.slice(3) })),
929      ], { paddingLeft: 2 });
930    }
931    return line(key, [name(Text({ color: COLOR.fg, children: row.text }))], { paddingLeft: 2 });
932  });
933
934  // The buttons answer the call this pane was drawn for, never whichever one is held now.
935  const decide = (choice) => () => {
936    if (state.decision === null) {
937      state.decision = choice;
938    }
939  };
940  return Box({
941    flexDirection: "column",
942    borderStyle: "round",
943    borderColor: COLOR.muted,
944    paddingX: 1,
945    children: [
946      Box({
947        key: "header",
948        flexDirection: "row",
949        children: [
950          Box({ key: "mark", width: MARK_COLUMNS, flexShrink: 0, marginRight: 1, children: Text({ color: COLOR.accent, bold: true, children: MARK }) }),
951          Box({
952            key: "what",
953            flexDirection: "column",
954            flexGrow: 1,
955            flexShrink: 1,
956            children: [
957              Text({
958                key: "cost",
959                wrap: "wrap",
960                children: [
961                  Text({ key: "cmd", color: COLOR.fg, bold: true, children: subject }),
962                  Text({ key: "verb", color: COLOR.muted, children: verb }),
963                  Text({ key: "amount", color: COLOR.err, bold: true, children: amount }),
964                ],
965              }),
966              Text({ key: "where", color: COLOR.muted, wrap: "truncate-end", children: `in ${report.where ?? "this folder"}` }),
967            ],
968          }),
969        ],
970      }),
971      Box({ key: "banner", marginTop: 1, children: Text({ color: warning.tone, wrap: "wrap", children: `⚠ ${warning.text}` }) }),
972      rows.length > 0 ? Box({ key: "rule", height: 1, overflow: "hidden", children: Text({ color: COLOR.rule, wrap: "wrap", children: "─".repeat(200) }) }) : null,
973      rows.length > 0 ? Box({ key: "list", flexDirection: "column", children: list }) : null,
974      report.capped ? Text({ key: "note", color: COLOR.muted, italic: true, wrap: "truncate-end", children: report.note }) : null,
975      // Once answered, the buttons give way to the run's progress, so they can't be pressed twice.
976      isHeld(state)
977        ? Box({
978          key: "buttons",
979          marginTop: 1,
980          gap: 2,
981          children: [
982            Button({ key: "proceed", label: buttonLabel(state), hotkey: "1", plain: true, onPress: decide("proceed") }),
983            Button({ key: "cancel", label: "Cancel", hotkey: "2", plain: true, autoFocus: true, onPress: decide("cancel") }),
984          ],
985        })
986        : Box({ key: "status", marginTop: 1, children: Text({ color: phaseColor(state), bold: true, wrap: "truncate-end", children: `${pillWord(state)} ${percentOf(state)}` }) }),
987      isHeld(state) ? Text({ key: "hint", color: COLOR.muted, wrap: "truncate-end", children: "Held by Foxel rmShield" }) : null,
988    ],
989  });
990}
991
992// ---- Desktop drawing ------------------------------------------------------
993//
994// The desktop surface draws one SVG card: Foxel, the user's pixel fox, guarding
995// the command with a shield, a progress row (pill, dotted track, percent) that
996// sweeps when the command runs, the warning and the list. Everything moves by
997// CSS, so a held pane needs no redraws. The dither and the text helpers follow
998// the savvy-progress band.
999
1000const isHeld = (state) => (state.phase ?? "held") === "held";
1001const pillWord = (state) => {
1002  const phase = state.phase ?? "held";
1003  if (phase === "done") {
1004    return "Done";
1005  }
1006  if (phase === "deleting") {
1007    return state.risk.kind === "rm" || state.risk.kind === "git-clean" ? "Deleting" : "Running";
1008  }
1009  return "Held";
1010};
1011const phaseColor = (state) => ({ deleting: COLOR.err, done: COLOR.ok }[state.phase] ?? COLOR.accent);
1012const percentOf = (state) => `${Math.round(Math.max(0, Math.min(1, state.progress ?? 0)) * 100)}%`;
1013
1014/** The desktop draw when the surface can show SVG, the text draw otherwise. */
1015function drawFor(t, state, e, where) {
1016  if (e.surface === "terminal" || !("Svg" in t)) {
1017    return draw(t, state);
1018  }
1019  const columns = e.props?.bodyColumns || 40;
1020  const W = Math.max(240, Math.min(900, columns * 8 - (where === "band" ? 24 : 8)));
1021  return drawSvg(t, state, W);
1022}
1023
1024const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif";
1025const SVG_HEADER_H = 64;
1026const SVG_ROW_H = 32;
1027const BAR_H = 16;
1028const CELL = 3;
1029// Rows shown under the banner on the desktop, the "+ K more" row not counted.
1030const SVG_LIST_ROWS = 6;
1031
1032// Escapes text for the SVG. Tabs and line breaks become spaces, other control
1033// characters (an ANSI escape from git or a migration tool) and U+FFFE/U+FFFF
1034// become U+FFFD, and bidi controls are dropped, so the SVG stays well-formed and
1035// a file name can't be made to read backwards.
1036const xml = (s) => String(s)
1037  .replace(/[\t\n\r]/g, " ")
1038  .replace(/[\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, "")
1039  .replace(/[\u0000-\u001F\u007F-\u009F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "\uFFFD")
1040  .replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c] ?? c);
1041
1042// Rough advance of system UI text, in em; good enough to fit a line.
1043const WIDE = /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿가-힣豈-﫿!-⦆�\u{1F300}-\u{1FAFF}]/u;
1044const charEm = (ch) => (WIDE.test(ch) ? 1 : /[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%mw]/.test(ch) ? 0.72 : 0.56);
1045const textWidth = (s, px) => [...s].reduce((w, ch) => w + charEm(ch) * px, 0);
1046const fitText = (s, px, maxW) => {
1047  if (textWidth(s, px) <= maxW) {
1048    return s;
1049  }
1050  let out = "";
1051  for (const ch of s) {
1052    if (textWidth(`${out}${ch}…`, px) > maxW) {
1053      break;
1054    }
1055    out += ch;
1056  }
1057  return `${out}…`;
1058};
1059
1060// Deterministic noise, so the dither doesn't shimmer between redraws.
1061const noise = (x, y) => {
1062  const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453;
1063  return s - Math.floor(s);
1064};
1065
1066// A CSS delay that continues an animation from the hold's clock, so a redraw
1067// doesn't restart it.
1068const phaseDelay = (period, clock) => `-${(clock % period).toFixed(2)}s`;
1069
1070// Foxel with a shield, from the user's foxel-04-tameng.svg: its 1×1 pixels as one
1071// path per colour per layer, cropped to 52×64. back is the outline colour under
1072// the shield, so a moving shield never shows a hole; lid covers the eyes for a
1073// blink; glow is a brighter copy of the shield's check; spark is added.
1074const FOXEL = {
1075  back: [
1076    ["#3b1c0a", "M4 34h4v1h-4zM32 34h4v1h-4zM3 35h6v1h-6zM31 35h5v1h-5zM3 36h6v1h-6zM31 36h6v1h-6zM2 37h35v1h-35zM2 38h35v1h-35zM2 39h35v1h-35zM2 40h35v1h-35zM2 41h35v1h-35zM2 42h35v1h-35zM2 43h35v1h-35zM1 44h36v1h-36zM1 45h36v1h-36zM0 46h38v1h-38zM0 47h38v1h-38zM1 48h36v1h-36zM1 49h36v1h-36zM2 50h30v1h-30zM7 51h25v1h-25zM8 52h22v1h-22zM9 53h20v1h-20zM11 54h17v1h-17zM13 55h13v1h-13zM14 56h11v1h-11zM15 57h9v1h-9zM17 58h6v1h-6zM18 59h3v1h-3zM19 60h1v1h-1z"],
1077  ],
1078  body: [
1079    ["#3b1c0a", "M6 3h1v1h-1zM33 3h1v1h-1zM5 4h1v1h-1zM7 4h1v1h-1zM32 4h1v1h-1zM34 4h2v1h-2zM5 5h1v1h-1zM8 5h1v1h-1zM31 5h1v1h-1zM34 5h2v1h-2zM5 6h1v1h-1zM9 6h1v1h-1zM13 6h3v1h-3zM30 6h1v1h-1zM34 6h2v1h-2zM5 7h1v1h-1zM10 7h3v1h-3zM16 7h1v1h-1zM26 7h4v1h-4zM34 7h2v1h-2zM4 8h1v1h-1zM8 8h3v1h-3zM17 8h4v1h-4zM25 8h1v1h-1zM29 8h3v1h-3zM35 8h1v1h-1zM4 9h1v1h-1zM7 9h1v1h-1zM21 9h3v1h-3zM25 9h1v1h-1zM32 9h1v1h-1zM35 9h1v1h-1zM4 10h1v1h-1zM6 10h1v1h-1zM24 10h1v1h-1zM33 10h1v1h-1zM35 10h1v1h-1zM4 11h1v1h-1zM6 11h1v1h-1zM33 11h1v1h-1zM35 11h1v1h-1zM4 12h2v1h-2zM34 12h1v1h-1zM36 12h1v1h-1zM4 13h1v1h-1zM35 13h2v1h-2zM3 14h2v1h-2zM35 14h2v1h-2zM3 15h1v1h-1zM36 15h1v1h-1zM3 16h1v1h-1zM36 16h1v1h-1zM3 17h1v1h-1zM8 17h10v1h-10zM22 17h10v1h-10zM36 17h1v1h-1zM8 18h1v1h-1zM17 18h1v1h-1zM22 18h1v1h-1zM31 18h1v1h-1zM36 18h1v1h-1zM8 19h1v1h-1zM17 19h1v1h-1zM22 19h1v1h-1zM31 19h1v1h-1zM36 19h1v1h-1zM17 20h1v1h-1zM22 20h1v1h-1zM36 20h1v1h-1zM3 21h1v1h-1zM17 21h1v1h-1zM22 21h1v1h-1zM36 21h1v1h-1zM3 22h1v1h-1zM8 22h1v1h-1zM17 22h1v1h-1zM22 22h1v1h-1zM31 22h1v1h-1zM36 22h2v1h-2zM3 23h2v1h-2zM8 23h1v1h-1zM17 23h1v1h-1zM22 23h1v1h-1zM31 23h1v1h-1zM35 23h1v1h-1zM38 23h1v1h-1zM2 24h1v1h-1zM5 24h1v1h-1zM8 24h1v1h-1zM17 24h1v1h-1zM22 24h1v1h-1zM31 24h1v1h-1zM34 24h1v1h-1zM38 24h1v1h-1zM2 25h1v1h-1zM6 25h1v1h-1zM8 25h1v1h-1zM18 25h4v1h-4zM31 25h1v1h-1zM33 25h1v1h-1zM39 25h1v1h-1zM1 26h1v1h-1zM7 26h1v1h-1zM32 26h1v1h-1zM40 26h1v1h-1zM2 27h4v1h-4zM34 27h6v1h-6zM5 28h1v1h-1zM34 28h1v1h-1zM36 28h1v1h-1zM4 29h1v1h-1zM36 29h1v1h-1zM4 30h1v1h-1zM37 30h1v1h-1zM5 31h3v1h-3zM32 31h5v1h-5zM8 32h2v1h-2zM30 32h2v1h-2zM10 33h2v1h-2zM28 33h2v1h-2zM10 34h1v1h-1zM12 34h1v1h-1zM27 34h1v1h-1zM29 34h1v1h-1zM36 34h1v1h-1zM9 35h1v1h-1zM13 35h2v1h-2zM25 35h2v1h-2zM30 35h1v1h-1zM36 35h1v1h-1zM9 36h22v1h-22zM37 36h1v1h-1zM37 37h1v1h-1zM37 38h1v1h-1zM37 39h1v1h-1zM37 40h1v1h-1zM37 41h1v1h-1zM37 42h1v1h-1zM37 43h1v1h-1zM37 44h1v1h-1zM37 45h1v1h-1zM38 46h1v1h-1zM38 47h1v1h-1zM37 48h1v1h-1zM37 49h1v1h-1zM32 50h5v1h-5zM32 51h1v1h-1zM30 52h2v1h-2zM29 53h1v1h-1zM28 54h1v1h-1zM11 55h2v1h-2zM26 55h3v1h-3zM11 56h1v1h-1zM13 56h1v1h-1zM25 56h1v1h-1zM28 56h1v1h-1zM11 57h1v1h-1zM14 57h1v1h-1zM24 57h1v1h-1zM28 57h1v1h-1zM10 58h2v1h-2zM15 58h2v1h-2zM23 58h1v1h-1zM28 58h2v1h-2zM9 59h1v1h-1zM17 59h1v1h-1zM21 59h2v1h-2zM30 59h1v1h-1zM9 60h1v1h-1zM12 60h1v1h-1zM14 60h1v1h-1zM16 60h1v1h-1zM18 60h1v1h-1zM20 60h1v1h-1zM23 60h1v1h-1zM25 60h1v1h-1zM27 60h1v1h-1zM30 60h1v1h-1zM8 61h1v1h-1zM19 61h1v1h-1zM31 61h1v1h-1zM8 62h1v1h-1zM31 62h1v1h-1zM9 63h22v1h-22z"],
1080    ["#5a2a0c", "M6 4h1v1h-1zM33 4h1v1h-1zM7 5h1v1h-1zM33 5h1v1h-1zM33 6h1v1h-1zM33 7h1v1h-1zM34 8h1v1h-1zM34 9h1v1h-1zM35 12h1v1h-1zM29 59h1v1h-1zM29 60h1v1h-1zM30 61h1v1h-1zM9 62h22v1h-22z"],
1081    ["#9a5a2a", "M6 5h1v1h-1zM32 5h1v1h-1zM6 6h1v1h-1zM6 7h1v1h-1zM5 8h1v1h-1zM5 9h1v1h-1zM10 59h2v1h-2zM28 59h1v1h-1zM10 60h1v1h-1zM21 60h1v1h-1zM9 61h1v1h-1zM20 61h1v1h-1z"],
1082    ["#7a3a12", "M7 6h1v1h-1zM32 6h1v1h-1zM7 7h2v1h-2zM31 7h2v1h-2zM6 8h2v1h-2zM32 8h2v1h-2zM12 59h5v1h-5zM23 59h5v1h-5zM11 60h1v1h-1zM13 60h1v1h-1zM15 60h1v1h-1zM17 60h1v1h-1zM22 60h1v1h-1zM24 60h1v1h-1zM26 60h1v1h-1zM28 60h1v1h-1zM10 61h9v1h-9zM21 61h9v1h-9z"],
1083    ["#e8691a", "M8 6h1v1h-1zM9 7h1v1h-1zM6 9h1v1h-1zM34 10h1v1h-1zM34 11h1v1h-1zM36 23h2v1h-2zM36 24h2v1h-2zM35 25h4v1h-4zM28 34h1v1h-1zM12 35h1v1h-1zM28 35h2v1h-2zM27 56h1v1h-1zM27 57h1v1h-1zM27 58h1v1h-1z"],
1084    ["#ffa855", "M31 6h1v1h-1zM30 7h1v1h-1zM5 10h1v1h-1zM5 11h1v1h-1zM9 18h1v1h-1zM13 18h4v1h-4zM23 18h4v1h-4zM30 18h1v1h-1zM3 24h1v1h-1zM35 24h1v1h-1zM11 34h1v1h-1zM10 35h1v1h-1zM27 35h1v1h-1zM12 56h1v1h-1zM26 56h1v1h-1zM12 57h1v1h-1zM25 57h1v1h-1zM12 58h1v1h-1zM24 58h1v1h-1z"],
1085    ["#eef0fa", "M13 7h3v1h-3zM11 8h2v1h-2zM8 9h3v1h-3zM29 9h1v1h-1zM7 10h1v1h-1zM7 11h1v1h-1zM4 15h1v1h-1zM4 16h1v1h-1zM4 17h1v1h-1zM18 17h1v1h-1zM32 17h1v1h-1zM4 18h1v1h-1zM18 18h1v1h-1zM32 18h1v1h-1zM4 19h1v1h-1zM18 19h1v1h-1zM32 19h1v1h-1zM4 20h1v1h-1zM18 20h1v1h-1zM32 20h1v1h-1zM18 21h1v1h-1zM32 21h1v1h-1zM18 22h1v1h-1zM32 22h1v1h-1zM32 23h1v1h-1z"],
1086    ["#c9cde0", "M13 8h4v1h-4zM26 8h1v1h-1zM11 9h10v1h-10zM26 9h3v1h-3zM8 10h16v1h-16zM25 10h6v1h-6zM8 11h23v1h-23zM5 15h3v1h-3zM18 15h4v1h-4zM32 15h2v1h-2zM5 16h3v1h-3zM18 16h4v1h-4zM32 16h2v1h-2zM5 17h1v1h-1zM19 17h1v1h-1zM33 17h1v1h-1zM5 18h1v1h-1zM19 18h1v1h-1zM33 18h1v1h-1zM5 19h1v1h-1zM19 19h1v1h-1zM33 19h1v1h-1zM5 20h1v1h-1zM19 20h1v1h-1zM33 20h1v1h-1zM5 21h1v1h-1zM19 21h1v1h-1zM33 21h1v1h-1zM19 22h1v1h-1zM33 22h1v1h-1z"],
1087    ["#8a90b0", "M27 8h2v1h-2zM30 9h2v1h-2zM31 10h2v1h-2zM31 11h2v1h-2zM8 15h10v1h-10zM22 15h10v1h-10zM34 15h2v1h-2zM8 16h1v1h-1zM11 16h7v1h-7zM22 16h7v1h-7zM31 16h1v1h-1zM34 16h2v1h-2zM6 17h2v1h-2zM20 17h2v1h-2zM34 17h2v1h-2zM6 18h2v1h-2zM20 18h2v1h-2zM34 18h2v1h-2zM6 19h2v1h-2zM20 19h2v1h-2zM34 19h2v1h-2zM6 20h2v1h-2zM20 20h2v1h-2zM34 20h2v1h-2zM4 21h1v1h-1zM6 21h2v1h-2zM20 21h2v1h-2zM34 21h2v1h-2zM4 22h4v1h-4zM20 22h2v1h-2zM34 22h2v1h-2zM5 23h3v1h-3zM18 23h4v1h-4zM33 23h2v1h-2zM6 24h2v1h-2zM18 24h4v1h-4zM32 24h2v1h-2zM7 25h1v1h-1zM32 25h1v1h-1z"],
1088    ["#ff8a2b", "M33 9h1v1h-1zM9 16h2v1h-2zM29 16h2v1h-2zM9 19h1v1h-1zM13 19h4v1h-4zM23 19h4v1h-4zM30 19h1v1h-1zM8 20h2v1h-2zM13 20h4v1h-4zM23 20h4v1h-4zM30 20h2v1h-2zM8 21h2v1h-2zM13 21h3v1h-3zM24 21h3v1h-3zM30 21h2v1h-2zM9 22h5v1h-5zM26 22h5v1h-5zM9 23h2v1h-2zM29 23h2v1h-2zM11 35h1v1h-1zM13 57h1v1h-1zM26 57h1v1h-1zM13 58h2v1h-2zM25 58h2v1h-2z"],
1089    ["#ffe47a", "M6 12h1v1h-1zM5 13h1v1h-1zM5 14h1v1h-1z"],
1090    ["#ffd23f", "M7 12h25v1h-25zM6 13h2v1h-2zM9 13h3v1h-3zM13 13h3v1h-3zM17 13h6v1h-6zM24 13h3v1h-3zM28 13h3v1h-3zM32 13h1v1h-1zM6 14h27v1h-27z"],
1091    ["#e0a820", "M32 12h2v1h-2zM33 13h2v1h-2zM33 14h2v1h-2z"],
1092    ["#ffffff", "M8 13h1v1h-1zM12 13h1v1h-1zM16 13h1v1h-1zM23 13h1v1h-1zM27 13h1v1h-1zM31 13h1v1h-1zM10 18h1v1h-1zM29 18h1v1h-1zM11 19h1v1h-1zM6 27h1v1h-1zM6 28h1v1h-1z"],
1093    ["#2a1a3a", "M11 18h2v1h-2zM27 18h2v1h-2zM10 19h1v1h-1zM12 19h1v1h-1zM27 19h3v1h-3zM10 20h3v1h-3zM27 20h3v1h-3zM10 21h3v1h-3zM27 21h3v1h-3zM18 29h4v1h-4zM19 30h2v1h-2z"],
1094    ["#fff4e0", "M16 21h1v1h-1zM23 21h1v1h-1zM14 22h3v1h-3zM23 22h3v1h-3zM11 23h6v1h-6zM23 23h6v1h-6zM4 24h1v1h-1zM9 24h8v1h-8zM23 24h8v1h-8zM10 25h8v1h-8zM22 25h8v1h-8zM6 26h1v1h-1zM10 26h20v1h-20zM33 26h1v1h-1zM7 27h25v1h-25zM7 28h25v1h-25zM8 29h10v1h-10zM22 29h10v1h-10zM10 30h9v1h-9zM21 30h9v1h-9zM12 31h16v1h-16zM13 32h14v1h-14zM15 33h10v1h-10zM19 34h2v1h-2z"],
1095    ["#f0dcb8", "M3 25h3v1h-3zM34 25h1v1h-1zM2 26h4v1h-4zM34 26h6v1h-6zM32 27h2v1h-2zM32 28h2v1h-2zM35 28h1v1h-1zM5 29h3v1h-3zM32 29h4v1h-4zM5 30h5v1h-5zM30 30h7v1h-7zM8 31h4v1h-4zM28 31h4v1h-4zM10 32h3v1h-3zM27 32h3v1h-3zM12 33h3v1h-3zM25 33h3v1h-3zM13 34h6v1h-6zM21 34h6v1h-6zM15 35h10v1h-10z"],
1096    ["#ff9fb4", "M9 25h1v1h-1zM30 25h1v1h-1zM8 26h2v1h-2zM30 26h2v1h-2z"],
1097  ],
1098  lid: [
1099    ["#ff8a2b", "M10 18h3v1h-3zM27 18h3v1h-3zM10 19h3v1h-3zM27 19h3v1h-3zM10 20h3v1h-3zM27 20h3v1h-3zM10 21h3v1h-3zM27 21h3v1h-3z"],
1100  ],
1101  plume: [
1102    ["#3b1c0a", "M19 0h1v1h-1zM16 1h3v1h-3zM20 1h2v1h-2zM12 2h4v1h-4zM22 2h1v1h-1zM11 3h1v1h-1zM23 3h2v1h-2zM12 4h1v1h-1zM25 4h2v1h-2zM13 5h2v1h-2zM27 5h1v1h-1zM26 6h1v1h-1z"],
1103    ["#5b3fd6", "M19 1h1v1h-1zM21 2h1v1h-1zM12 3h1v1h-1zM13 4h2v1h-2zM20 4h3v1h-3zM24 4h1v1h-1zM15 5h1v1h-1zM18 5h5v1h-5zM26 5h1v1h-1zM16 6h1v1h-1zM21 6h3v1h-3zM25 6h1v1h-1zM17 7h4v1h-4zM23 7h1v1h-1zM25 7h1v1h-1zM21 8h4v1h-4zM24 9h1v1h-1z"],
1104    ["#9a86ff", "M16 2h3v1h-3zM20 2h1v1h-1zM13 3h3v1h-3zM23 4h1v1h-1zM25 5h1v1h-1z"],
1105    ["#7c5cff", "M19 2h1v1h-1zM16 3h6v1h-6zM15 4h5v1h-5zM16 5h2v1h-2zM23 5h2v1h-2zM17 6h4v1h-4zM24 6h1v1h-1zM21 7h2v1h-2zM24 7h1v1h-1z"],
1106    ["#3f2aa8", "M22 3h1v1h-1z"],
1107  ],
1108  tail: [
1109    ["#3b1c0a", "M37 19h2v1h-2zM39 20h1v1h-1zM37 21h1v1h-1zM40 21h2v1h-2zM42 22h1v1h-1zM43 23h1v1h-1zM43 24h1v1h-1zM44 25h1v1h-1zM45 26h1v1h-1zM46 27h1v1h-1zM47 28h1v1h-1zM48 29h1v1h-1zM48 30h1v1h-1zM49 31h1v1h-1zM35 32h1v1h-1zM49 32h1v1h-1zM36 33h1v1h-1zM49 33h1v1h-1zM49 34h1v1h-1zM37 35h1v1h-1zM50 35h1v1h-1zM50 36h1v1h-1zM50 37h1v1h-1zM50 38h1v1h-1zM51 39h1v1h-1zM51 40h1v1h-1zM51 41h1v1h-1zM51 42h1v1h-1zM51 43h1v1h-1zM50 44h1v1h-1zM50 45h1v1h-1zM50 46h1v1h-1zM49 47h1v1h-1zM49 48h1v1h-1zM49 49h1v1h-1zM49 50h1v1h-1zM48 51h1v1h-1zM48 52h1v1h-1zM47 53h1v1h-1zM46 54h1v1h-1zM45 55h1v1h-1zM44 56h1v1h-1zM43 57h1v1h-1zM42 58h1v1h-1zM41 59h1v1h-1zM39 60h2v1h-2zM32 61h7v1h-7z"],
1110    ["#f0dcb8", "M37 20h2v1h-2zM38 21h2v1h-2zM40 22h2v1h-2zM41 23h2v1h-2zM41 24h2v1h-2zM42 25h1v1h-1z"],
1111    ["#fff4e0", "M38 22h2v1h-2zM39 23h2v1h-2zM39 24h2v1h-2zM40 25h2v1h-2zM41 26h2v1h-2zM40 27h2v1h-2zM37 28h5v1h-5zM37 29h3v1h-3z"],
1112    ["#e8691a", "M43 25h1v1h-1zM43 26h2v1h-2zM44 27h2v1h-2zM45 28h2v1h-2zM46 29h2v1h-2zM46 30h2v1h-2zM47 31h2v1h-2zM36 32h1v1h-1zM47 32h2v1h-2zM37 33h1v1h-1zM47 33h2v1h-2zM37 34h1v1h-1zM47 34h2v1h-2zM48 35h2v1h-2zM48 36h2v1h-2zM48 37h2v1h-2zM48 38h2v1h-2zM49 39h2v1h-2zM49 40h2v1h-2zM49 41h2v1h-2zM49 42h2v1h-2zM49 43h2v1h-2zM48 44h2v1h-2zM48 45h2v1h-2zM48 46h2v1h-2zM47 47h2v1h-2zM47 48h2v1h-2zM47 49h2v1h-2zM47 50h2v1h-2zM46 51h2v1h-2zM46 52h2v1h-2zM45 53h2v1h-2zM44 54h2v1h-2zM43 55h2v1h-2zM42 56h2v1h-2zM41 57h2v1h-2zM39 58h3v1h-3zM31 59h10v1h-10zM31 60h8v1h-8z"],
1113    ["#ff8a2b", "M42 27h2v1h-2zM42 28h3v1h-3zM40 29h6v1h-6zM38 30h8v1h-8zM38 31h9v1h-9zM37 32h10v1h-10zM38 33h9v1h-9zM38 34h9v1h-9zM39 35h9v1h-9zM39 36h9v1h-9zM39 37h9v1h-9zM39 38h9v1h-9zM38 39h11v1h-11zM38 40h11v1h-11zM38 41h11v1h-11zM38 42h11v1h-11zM38 43h11v1h-11zM38 44h10v1h-10zM38 45h10v1h-10zM39 46h9v1h-9zM39 47h8v1h-8zM39 48h8v1h-8zM38 49h9v1h-9zM38 50h9v1h-9zM37 51h9v1h-9zM33 52h13v1h-13zM32 53h13v1h-13zM30 54h14v1h-14zM29 55h14v1h-14zM29 56h13v1h-13zM29 57h12v1h-12zM30 58h9v1h-9z"],
1114    ["#ffa855", "M37 31h1v1h-1zM38 35h1v1h-1zM38 36h1v1h-1zM38 37h1v1h-1zM38 38h1v1h-1zM38 48h1v1h-1zM37 50h1v1h-1zM33 51h4v1h-4zM32 52h1v1h-1zM30 53h2v1h-2zM29 54h1v1h-1z"],
1115  ],
1116  shield: [
1117    ["#3b1c0a", "M4 34h4v1h-4zM32 34h4v1h-4zM3 35h1v1h-1zM8 35h1v1h-1zM31 35h1v1h-1zM3 36h1v1h-1zM7 36h2v1h-2zM31 36h2v1h-2zM36 36h1v1h-1zM2 37h1v1h-1zM6 37h1v1h-1zM33 37h1v1h-1zM2 38h1v1h-1zM6 38h1v1h-1zM33 38h1v1h-1zM2 39h1v1h-1zM6 39h1v1h-1zM33 39h1v1h-1zM2 40h1v1h-1zM6 40h1v1h-1zM33 40h1v1h-1zM2 41h1v1h-1zM6 41h1v1h-1zM33 41h1v1h-1zM2 42h1v1h-1zM9 42h1v1h-1zM30 42h1v1h-1zM2 43h8v1h-8zM30 43h7v1h-7zM1 44h1v1h-1zM9 44h1v1h-1zM29 44h1v1h-1zM1 45h1v1h-1zM9 45h1v1h-1zM29 45h1v1h-1zM0 46h1v1h-1zM10 46h1v1h-1zM28 46h1v1h-1zM0 47h1v1h-1zM10 47h1v1h-1zM28 47h1v1h-1zM1 48h1v1h-1zM9 48h1v1h-1zM29 48h1v1h-1zM1 49h1v1h-1zM4 49h1v1h-1zM6 49h1v1h-1zM9 49h1v1h-1zM29 49h1v1h-1zM32 49h1v1h-1zM34 49h1v1h-1zM2 50h7v1h-7zM30 50h2v1h-2zM7 51h1v1h-1zM8 52h1v1h-1zM9 53h2v1h-2zM11 54h1v1h-1z"],
1118    ["#ffa855", "M4 35h3v1h-3zM32 35h3v1h-3zM4 36h1v1h-1zM3 37h1v1h-1zM3 38h1v1h-1zM3 39h1v1h-1zM3 40h1v1h-1zM3 41h1v1h-1zM3 42h1v1h-1zM31 42h1v1h-1z"],
1119    ["#e8691a", "M7 35h1v1h-1zM35 35h1v1h-1zM6 36h1v1h-1zM35 36h1v1h-1zM36 37h1v1h-1zM36 38h1v1h-1zM36 39h1v1h-1zM36 40h1v1h-1zM36 41h1v1h-1zM8 42h1v1h-1zM36 42h1v1h-1z"],
1120    ["#ff8a2b", "M5 36h1v1h-1zM33 36h2v1h-2zM4 37h2v1h-2zM34 37h2v1h-2zM4 38h2v1h-2zM34 38h2v1h-2zM4 39h2v1h-2zM34 39h2v1h-2zM4 40h2v1h-2zM34 40h2v1h-2zM4 41h2v1h-2zM34 41h2v1h-2zM4 42h4v1h-4zM32 42h4v1h-4z"],
1121    ["#c9cde0", "M7 37h21v1h-21zM7 38h21v1h-21zM7 39h2v1h-2zM7 40h2v1h-2zM7 41h2v1h-2zM9 50h1v1h-1zM8 51h4v1h-4z"],
1122    ["#8a90b0", "M28 37h5v1h-5zM28 38h5v1h-5zM31 39h2v1h-2zM31 40h2v1h-2zM31 41h2v1h-2zM28 51h4v1h-4zM9 52h4v1h-4zM27 52h3v1h-3zM11 53h3v1h-3zM25 53h4v1h-4zM12 54h3v1h-3zM24 54h4v1h-4zM13 55h4v1h-4zM23 55h3v1h-3zM14 56h4v1h-4zM21 56h4v1h-4zM15 57h4v1h-4zM20 57h4v1h-4zM17 58h6v1h-6zM18 59h3v1h-3zM19 60h1v1h-1z"],
1123    ["#ffe47a", "M9 39h22v1h-22z"],
1124    ["#ffd23f", "M9 40h22v1h-22zM9 41h22v1h-22z"],
1125    ["#7c5cff", "M10 42h10v1h-10zM10 43h10v1h-10zM10 44h10v1h-10zM10 45h10v1h-10zM11 46h2v1h-2zM15 46h4v1h-4zM11 47h2v1h-2zM16 47h2v1h-2zM10 48h4v1h-4zM10 49h5v1h-5zM19 49h1v1h-1zM10 50h6v1h-6zM18 50h2v1h-2zM12 51h8v1h-8zM13 52h7v1h-7zM14 53h6v1h-6zM15 54h5v1h-5zM17 55h3v1h-3zM18 56h2v1h-2zM19 57h1v1h-1z"],
1126    ["#5b3fd6", "M20 42h3v1h-3zM25 42h5v1h-5zM20 43h2v1h-2zM25 43h5v1h-5zM20 44h1v1h-1zM24 44h5v1h-5zM23 45h6v1h-6zM22 46h6v1h-6zM21 47h7v1h-7zM20 48h9v1h-9zM20 49h9v1h-9zM20 50h10v1h-10zM20 51h8v1h-8zM20 52h7v1h-7zM20 53h5v1h-5zM20 54h4v1h-4zM20 55h3v1h-3zM20 56h1v1h-1z"],
1127    ["#2fe6d1", "M23 42h2v1h-2zM22 43h1v1h-1zM21 44h1v1h-1zM20 45h1v1h-1zM13 46h2v1h-2zM19 46h1v1h-1zM14 47h2v1h-2zM18 47h1v1h-1zM15 48h3v1h-3zM16 49h1v1h-1z"],
1128    ["#1fb3a1", "M23 43h2v1h-2zM22 44h2v1h-2zM21 45h2v1h-2zM20 46h2v1h-2zM13 47h1v1h-1zM19 47h2v1h-2zM14 48h1v1h-1zM18 48h2v1h-2zM15 49h1v1h-1zM17 49h2v1h-2zM16 50h2v1h-2z"],
1129    ["#b36a35", "M2 44h6v1h-6zM30 44h6v1h-6zM2 45h1v1h-1zM30 45h1v1h-1zM1 46h1v1h-1zM29 46h1v1h-1zM2 48h1v1h-1zM30 48h1v1h-1z"],
1130    ["#5a2a0c", "M8 44h1v1h-1zM36 44h1v1h-1zM8 45h1v1h-1zM36 45h1v1h-1zM9 46h1v1h-1zM37 46h1v1h-1zM1 47h1v1h-1zM9 47h1v1h-1zM29 47h1v1h-1zM37 47h1v1h-1zM8 48h1v1h-1zM36 48h1v1h-1zM2 49h2v1h-2zM5 49h1v1h-1zM7 49h2v1h-2zM30 49h2v1h-2zM33 49h1v1h-1zM35 49h2v1h-2z"],
1131    ["#8a4a1c", "M3 45h5v1h-5zM31 45h5v1h-5zM2 46h7v1h-7zM30 46h7v1h-7zM2 47h7v1h-7zM30 47h7v1h-7zM3 48h5v1h-5zM31 48h5v1h-5z"],
1132  ],
1133  glow: [
1134    ["#b8fff6", "M23 42h2v1h-2zM22 43h3v1h-3zM21 44h3v1h-3zM20 45h3v1h-3zM13 46h2v1h-2zM19 46h3v1h-3zM13 47h3v1h-3zM18 47h3v1h-3zM14 48h6v1h-6zM15 49h4v1h-4zM16 50h2v1h-2z"],
1135  ],
1136  spark: [
1137    ["#ffe47a", "M6 31h1v1h-1zM5 32h3v1h-3zM6 33h1v1h-1zM35 52h1v1h-1zM3 53h1v1h-1zM34 53h3v1h-3zM2 54h3v1h-3zM35 54h1v1h-1zM3 55h1v1h-1z"],
1138  ],
1139};
1140const FOXEL_W = 52;
1141const FOXEL_H = 64;
1142
1143/** Foxel at (x, y), one pixel per unit, in layers the card's CSS animates per phase. */
1144function foxelSvg(x, y, phase) {
1145  const layer = (name, cls) => `<g class="${cls}">${FOXEL[name].map(([c, d]) => `<path fill="${c}" d="${d}"/>`).join("")}</g>`;
1146  const mood = phase === "done" ? "fxd" : phase === "deleting" ? "fxr" : "fxh";
1147  return `<g transform="translate(${x},${y})" shape-rendering="crispEdges"><g class="fx ${mood}">${layer("back", "fx-back")}${layer("tail", "fx-tail")}<g class="fx-body">${layer("body", "fx-skin")}${layer("lid", "fx-lid")}${layer("plume", "fx-pl")}</g><g class="fx-sh">${layer("shield", "fx-plate")}${layer("glow", "fx-ck")}</g>${layer("spark", "fx-sp")}</g></g>`;
1148}
1149
1150// The card's colours follow the theme: Tokyo Night Day on light, Tokyo Night on dark.
1151// pf is the phase colour (pill and fill), pt the text on the pill.
1152const THEME_CSS = `
1153.bg{fill:#eceef3;stroke:#dcdfe8}.fg{fill:#343b58}.mu{fill:#6c7293}.er{fill:#d4375f}.wa{fill:#8c6c3e}
1154.tk{fill:#c4c8da}.ln{stroke:#d3d6e2}.pt{fill:#ffffff}
1155.p-held .pf{fill:#2e7de9}.p-deleting .pf{fill:#d4375f}.p-done .pf{fill:#3f7d2c}
1156.p-held .pk{stroke:#2e7de9}.p-deleting .pk{stroke:#d4375f}.p-done .pk{stroke:#3f7d2c}
1157@media (prefers-color-scheme: dark){
1158.bg{fill:#25262e;stroke:#34353f}.fg{fill:#c0caf5}.mu{fill:#8089b3}.er{fill:#f7768e}.wa{fill:#e0af68}
1159.tk{fill:#3b4261}.ln{stroke:#34353f}.pt{fill:#1f2335}
1160.p-held .pf{fill:#7aa2f7}.p-deleting .pf{fill:#f7768e}.p-done .pf{fill:#9ece6a}
1161.p-held .pk{stroke:#7aa2f7}.p-deleting .pk{stroke:#f7768e}.p-done .pk{stroke:#9ece6a}
1162}`;
1163
1164// Foxel. Held: the shield bobs a pixel, its check glows, the body breathes, the
1165// tail sways, the plume flutters, the eyes blink. Deleting: the shield thrusts
1166// and shakes, the tail whips, sparks flicker, Foxel leans in. Done: a hop, the
1167// shield lifted, the check flashes, sparks pop. Moves are whole pixels (steps)
1168// where they translate, so the pixel art stays crisp.
1169// The bar: pixels twinkle, a sheen glides over the fill, the pill breathes while held.
1170const cardCss = (clock, fillW) => `<style>${THEME_CSS}
1171g{transform-box:fill-box}
1172.fx-lid,.fx-ck,.fx-sp{opacity:0}
1173.fxh .fx-body{transform-origin:50% 100%;animation:fxbr 2.4s ease-in-out infinite ${phaseDelay(2.4, clock)}}
1174@keyframes fxbr{50%{transform:scaleY(1.02)}}
1175.fxh .fx-sh{animation:fxbob 1.6s steps(1) infinite ${phaseDelay(1.6, clock)}}
1176@keyframes fxbob{0%{transform:translateY(0)}50%{transform:translateY(-1px)}}
1177.fxh .fx-ck{animation:fxck 1.6s ease-in-out infinite ${phaseDelay(1.6, clock)}}
1178@keyframes fxck{50%{opacity:.85}}
1179.fxh .fx-tail{transform-origin:20% 100%;animation:fxsw 2.2s ease-in-out infinite ${phaseDelay(2.2, clock)}}
1180@keyframes fxsw{25%{transform:rotate(5deg)}75%{transform:rotate(-5deg)}}
1181.fxh .fx-pl{transform-origin:50% 100%;animation:fxpl 1.2s ease-in-out infinite ${phaseDelay(1.2, clock)}}
1182@keyframes fxpl{25%{transform:rotate(4deg)}75%{transform:rotate(-4deg)}}
1183.fxh .fx-lid{animation:fxbl 4s steps(1) infinite ${phaseDelay(4, clock)}}
1184@keyframes fxbl{0%{opacity:0}94%{opacity:1}97%{opacity:0}}
1185.fxr{transform:translateX(1px)}
1186.fxr .fx-sh{transform-origin:50% 50%;animation:fxth .25s steps(1) infinite}
1187@keyframes fxth{0%{transform:scale(1.05)}50%{transform:scale(1.05) translateX(1px)}}
1188.fxr .fx-ck{opacity:.6}
1189.fxr .fx-tail{transform-origin:20% 100%;animation:fxwh .4s ease-in-out infinite ${phaseDelay(0.4, clock)}}
1190@keyframes fxwh{25%{transform:rotate(9deg)}75%{transform:rotate(-9deg)}}
1191.fxr .fx-sp{animation:fxsp .3s steps(1) infinite}
1192@keyframes fxsp{0%{opacity:1}50%{opacity:.15}}
1193.fxd{animation:fxhop .9s steps(1) infinite ${phaseDelay(0.9, clock)}}
1194@keyframes fxhop{0%{transform:translateY(0)}30%{transform:translateY(-2px)}60%{transform:translateY(0)}}
1195.fxd .fx-sh{transform:translateY(-1px)}
1196.fxd .fx-ck{animation:fxfl .45s steps(1) infinite}
1197@keyframes fxfl{0%{opacity:.9}50%{opacity:0}}
1198.fxd .fx-sp{animation:fxpop .9s steps(1) infinite ${phaseDelay(0.9, clock)}}
1199@keyframes fxpop{0%{opacity:0}30%{opacity:1}70%{opacity:0}}
1200.t0,.t1,.t2,.t3{animation:tw 2.2s ease-in-out infinite ${phaseDelay(2.2, clock)}}