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…

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.
| Held | Held (dark) |
|---|---|
![]() | ![]() |
| Running | Done |
|---|---|
![]() | ![]() |
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.
When Claude calls the Bash tool with one of these, the call is stopped, measured, and shown:
| Command | What 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 --hard | The files with uncommitted changes. |
git checkout -- ., git restore . | The files with unstaged changes. |
git clean | The untracked paths it would remove (from git clean -n). |
git push --force, -f, +ref, --force-with-lease | The remote commits your HEAD does not have, which the push would drop. |
manage.py migrate, db:migrate, alembic upgrade, prisma migrate | The pending migrations, from the tool's own status command. |
any other migrate | A 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.
Be aware of the gaps; this is a safety net, not a sandbox.
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>.$( ), backticks, bash -c, eval, xargs rm, find -delete.$VAR, {a,b}) the card says it cannot measure it; the command is still held.git commands, sleep, and, for migrations, the project's own status command. No network access, no file writes.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".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.
| File | Purpose |
|---|---|
foxel-rmshield/.claude-plugin/plugin.json | Plugin manifest |
foxel-rmshield/hooks/hooks.json | Points to the module |
foxel-rmshield/hooks/foxel-rmshield.mjs | The whole mod: classifier, measuring, card, phases |
.claude-plugin/marketplace.json | Lets this repository act as a marketplace |
Apache-2.0. Copyright 2026 Foxel. See LICENSE.
hooks/foxel-rmshield.mjs 1400 lines1// 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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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)}}