diff --git a/CLAUDE.md b/CLAUDE.md
index 7bb0466..811f537 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -24,12 +24,17 @@ Remove BG / Generate / Tools, each owning one queue that may hold several
convert groups at once. A selection never spans two convert groups, because one
options panel can only describe one target set. See `src/shared/tabs.ts`.
+**Signed-off redesign (2026-10-04):** `docs/mockups/terminal-v5/10-s2-vscode-grouped.html`, dark-only,
+strict monochrome. Rules and feedback trail: `docs/design/redesign-direction.md`.
+
**The look is designed collaboratively with the owner. Make NO visual assumptions.**
Before building or restyling any UI, present mockups (self-contained browser HTML, like the
RCMM Show/Hide exploration), offer options, and iterate to explicit sign-off. This covers
layout, components, palette, typography, motion, empty/loading states, and the file-preview
-experience. The current renderer is a deliberately plain placeholder until that design work
-happens. Engineering/plumbing (main process, tool modules, IPC, tests, packaging) moves fast
+experience. The renderer implements the signed-off terminal design (rules in
+`docs/design/redesign-direction.md`): dark only, strict monochrome, every colour is a token in
+`src/renderer/src/theme/tokens.css`, and `test/monochrome-source.test.ts` fails on any other colour
+literal. New screens still go through mockups first. Engineering/plumbing (main process, tool modules, IPC, tests, packaging) moves fast
without design ceremony.
## Scope (v1, built in phases)
@@ -57,7 +62,9 @@ src/
(planned) output.ts collision-safe output naming (ported from RCMM Get-UniqueOutPath)
(planned) ipc.ts renderer <-> engine wiring
preload/ contextBridge — the typed `window.filesmith` API
- renderer/ React UI (placeholder until designed)
+ renderer/ React UI: shell/ (title bar, sidebar, status bar), queue/ (files table),
+ inspector/ (Options, Preview, Info), options/ (one settings file per verb),
+ views/ (Generate, Tools, Completed, Settings), ui/ (primitives), icons/, theme/ (tokens, CSS)
shared/ types.ts — Job, ToolId, FileKind, Options, progress events
tabs.ts — the VERB-first navigation model (rail tabs + Tools cards)
resources/bin/ bundled CLI binaries (gitignored; fetched by scripts, packed by electron-builder)
diff --git a/docs/design/redesign-direction.md b/docs/design/redesign-direction.md
new file mode 100644
index 0000000..5fa67f3
--- /dev/null
+++ b/docs/design/redesign-direction.md
@@ -0,0 +1,34 @@
+# Redesign direction (owner feedback, 2026-10-04)
+
+**LOCKED IN (2026-10-04): `docs/mockups/terminal-v5/10-s2-vscode-grouped.html`** is the target design.
+Build the renderer to match it. The rest of this file is the feedback trail that led there.
+
+Mockup rounds live in `docs/mockups/terminal*/` (each has an `index.html` gallery and `shots/`).
+
+- **Dark only.** Light mode is being removed. Black or near-black background. Themes come later.
+- **Strict monochrome.** Greys only (r=g=b). Errors use a glyph and weight, never red.
+- **Round 1** (`terminal/`): rejected. Too cluttered, too much information, too colourful.
+- **Round 2** (`terminal-v2/`): better, but too minimal. Wants icons on tabs and controls.
+- **Round 3** (`terminal-v3/`): "more the look", but a straight copy of Wind's settings page. Do our own thing.
+- **Round 4** (`terminal-v4/`): IDE-inspired (VS Code settings editor, JetBrains settings/tool windows, Zed,
+ Xcode). Keep round 3's density, icons everywhere, simple and easy to navigate.
+- **Pick from round 4:** `terminal-v4/10-explorer-details` (details table + right inspector). Wanted changes:
+ left sidebar collapsible, expanded shows icon + label; no "Drop files here" row (files are added from the
+ top). Variations in `terminal-v5/`.
+- **Pick from v5:** `terminal-v5/05-e-flush-panels` (flush, square, mono). Controls must clearly read as
+ buttons: plain words with an underline or dotted link styling were not obvious enough.
+- Running rows: no progress line under the row. Show progress compactly as `62%(4s)`, not "about 4 s left",
+ in the status column.
+- Result column: the arrow sits on the size/result split. Done = actual result, running = grey `~estimate`,
+ queued/failed = empty. The batch estimate shows in the right panel before you press Convert.
+- Post-build feedback (2026-10-04): Add files is grey (not white); no white left bars on selected rows or the
+ active sidebar item; the active item's count is a plain number, not an inverted box. Add files has no fill,
+ like the other toolbar buttons. No white fills anywhere: selected segments, chips, checkboxes and the primary
+ button use grey `#2a2a2a` with light text (the `--inv-*` tokens). The Tools tab is "PDF Tools", with no
+ divider line above it.
+- Files toolbar is only Add files + count. Row actions live in a right-click menu (Open, Show in File
+ Explorer, Retry, Stop, Clear finished, Remove from list, Delete); destructive ones confirm first. While work
+ runs, the run button becomes a red Stop (`--stop-*`, the app's one hue). Preview/Info show nothing for a
+ multi-selection ("Select one file to preview").
+- Wind is a reference, not a template. Avoid its signatures: the floating progress+button pill, the icon-tile
+ header band, and big rounded cards of right-aligned setting rows.
diff --git a/docs/mockups/terminal-v2/01-wind-plain.html b/docs/mockups/terminal-v2/01-wind-plain.html
new file mode 100644
index 0000000..1dbc083
--- /dev/null
+++ b/docs/mockups/terminal-v2/01-wind-plain.html
@@ -0,0 +1,174 @@
+
+
+
+
+
+Filesmith
+
+
+
+
+
+
+
+ Convert
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+ Tools
+
+
+
+ Convert
+
+ + drop files
+
+
+ IMG_4821.HEIC done
+ screenshot.png 62%
+ hero-banner.png queued
+ scan_0091.tiff ✕ failed
+
+
+
+
Format
+
+ WebP
+ JPG
+ PNG
+ AVIF
+
+
Quality
+
+
+
+ Convert 4 files
+
+
+
+
diff --git a/docs/mockups/terminal-v2/02-wind-settings-card.html b/docs/mockups/terminal-v2/02-wind-settings-card.html
new file mode 100644
index 0000000..4fd4009
--- /dev/null
+++ b/docs/mockups/terminal-v2/02-wind-settings-card.html
@@ -0,0 +1,249 @@
+
+
+
+
+
+
+Filesmith
+
+
+
+
+
+
+
+ Convert
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+ Tools
+
+
+
+
+
Convert
+
+
+ IMG_4821.HEIC done
+ screenshot.png 62%
+ hero-banner.png queued
+ scan_0091.tiff ✕ failed
+ + drop files or browse
+
+
+
+
+
Format
+
+
WebP ›
+
+ WebP
+ JPG
+ PNG
+ AVIF
+
+
+
+
+
+
+
Convert 4 files
+
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v2/03-wind-no-rail.html b/docs/mockups/terminal-v2/03-wind-no-rail.html
new file mode 100644
index 0000000..b7b100b
--- /dev/null
+++ b/docs/mockups/terminal-v2/03-wind-no-rail.html
@@ -0,0 +1,180 @@
+
+
+
+
+
+
+Filesmith, no rail
+
+
+
+
+
+
+ Convert
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+ Tools
+
+
+
+ Convert
+
+
+ IMG_4821.HEIC done
+ screenshot.png 62%
+ hero-banner.png queued
+ scan_0091.tiff failed
+
+ + drop files
+
+
+
+
+ WebP
+ JPG
+ PNG
+ AVIF
+
+
+
+ 80
+
+
+
+
+ Convert 4 files
+
+
+
diff --git a/docs/mockups/terminal-v2/04-wind-icon-rail.html b/docs/mockups/terminal-v2/04-wind-icon-rail.html
new file mode 100644
index 0000000..c5e733e
--- /dev/null
+++ b/docs/mockups/terminal-v2/04-wind-icon-rail.html
@@ -0,0 +1,153 @@
+
+
+
+
+
+Filesmith, icon rail
+
+
+
+
+
+
+
+
+ Convert
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+ Tools
+
+
+
+ Convert
+
+
+
+ IMG_4821.HEIC done
+ screenshot.png 62%
+ hero-banner.png queued
+ scan_0091.tiff ✕ failed
+
+
+
+
Format
+
+ WebP
+ JPG
+ PNG
+ AVIF
+
+
+
+
+
Convert 4 files
+
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v2/05-prompt-caret.html b/docs/mockups/terminal-v2/05-prompt-caret.html
new file mode 100644
index 0000000..8f6fb25
--- /dev/null
+++ b/docs/mockups/terminal-v2/05-prompt-caret.html
@@ -0,0 +1,161 @@
+
+
+
+
+
+
+Filesmith Prompt
+
+
+
+
+
+
+
+ Convert
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+ Tools
+
+
+
+
+
› convert_
+
+
+
+ IMG_4821.HEIC done
+ screenshot.png 62%
+ hero-banner.png queued
+ scan_0091.tiff ✕ failed
+
+
+ drop files
+
+
+ webp
+ jpg
+ png
+ avif
+
+
+
Convert 4 files
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v2/06-one-sentence.html b/docs/mockups/terminal-v2/06-one-sentence.html
new file mode 100644
index 0000000..8ceb0a0
--- /dev/null
+++ b/docs/mockups/terminal-v2/06-one-sentence.html
@@ -0,0 +1,186 @@
+
+
+
+
+
+
+Filesmith One Sentence
+
+
+
+
+
+
+
+ convert
+ compress
+ resize
+ upscale
+ remove bg
+ generate
+ tools
+
+
+
+
+ IMG_4821.HEIC done
+ screenshot.png 62%
+ hero-banner.png queued
+ scan_0091.tiff ✕ failed
+
+
+ › convert 4 files to webp at 80
+ run ↵
+
+ jpg png webp avif gif
+
+
+ 60 70 80 90 100
+
+
+
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v2/07-verb-in-title.html b/docs/mockups/terminal-v2/07-verb-in-title.html
new file mode 100644
index 0000000..80694eb
--- /dev/null
+++ b/docs/mockups/terminal-v2/07-verb-in-title.html
@@ -0,0 +1,154 @@
+
+
+
+
+
+
+Filesmith
+
+
+
+
+
+
+
+
+ Convert
+
+
+
+
+
+
+ IMG_4821.HEIC done
+ screenshot.png 62%
+ hero-banner.png queued
+ scan_0091.tiff ✕ failed
+
+
+ drop files
+
+
+
+ WebP
+ JPG
+ PNG
+ AVIF
+
+
Convert 4 files
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v2/08-verbs-at-bottom.html b/docs/mockups/terminal-v2/08-verbs-at-bottom.html
new file mode 100644
index 0000000..99db75a
--- /dev/null
+++ b/docs/mockups/terminal-v2/08-verbs-at-bottom.html
@@ -0,0 +1,125 @@
+
+
+
+
+
+Filesmith, verbs at the bottom
+
+
+
+
+
+
+
+ Convert
+
+ + drop files or browse
+
+
+ IMG_4821.HEIC done
+ screenshot.png 62%
+ hero-banner.png queued
+ scan_0091.tiff ✕ failed
+
+
+
+ WebP
+ JPG
+ PNG
+ AVIF
+
+
+ Convert 4 files
+
+
+
+ Convert
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+ Tools
+
+
+
+
diff --git a/docs/mockups/terminal-v2/09-one-batch-bar.html b/docs/mockups/terminal-v2/09-one-batch-bar.html
new file mode 100644
index 0000000..8239441
--- /dev/null
+++ b/docs/mockups/terminal-v2/09-one-batch-bar.html
@@ -0,0 +1,128 @@
+
+
+
+
+
+
+Filesmith
+
+
+
+
+
+
+
+
+ Convert
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+ Tools
+
+
+
+
+
Convert
+
+
+ drop files
+
+
+ IMG_4821.HEIC done
+ screenshot.png running
+ hero-banner.png queued
+ scan_0091.tiff failed
+
+
+
+
+ WebP
+ JPG
+ PNG
+ AVIF
+
+
+
Convert 4 files
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v2/10-bracket-bar.html b/docs/mockups/terminal-v2/10-bracket-bar.html
new file mode 100644
index 0000000..e33d456
--- /dev/null
+++ b/docs/mockups/terminal-v2/10-bracket-bar.html
@@ -0,0 +1,136 @@
+
+
+
+
+
+
+Filesmith Bracket Bar
+
+
+
+
+
+
+
+ convert
+ compress
+ resize
+ upscale
+ remove bg
+ generate
+ tools
+
+
+
+
+
convert
+
+
+ IMG_4821.HEIC done
+ screenshot.png [######···· ]
+ hero-banner.png queued
+ scan_0091.tiff failed
+
+
+
+ webp
+ jpg
+ png
+ avif
+
+
+
convert 4 files
+
+
+
+
+
diff --git a/docs/mockups/terminal-v2/11-glyph-gutter.html b/docs/mockups/terminal-v2/11-glyph-gutter.html
new file mode 100644
index 0000000..f7f2815
--- /dev/null
+++ b/docs/mockups/terminal-v2/11-glyph-gutter.html
@@ -0,0 +1,123 @@
+
+
+
+
+
+Filesmith Glyph Column
+
+
+
+
+
+
+
+
+ Convert
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+ Tools
+
+
+
+ Convert
+
+
+ IMG_4821.HEIC
+
+
+ screenshot.png
+ hero-banner.png
+ scan_0091.tiff
+ drop files
+
+
+
+ WebP
+ JPG
+ PNG
+ AVIF
+
+
+ Convert 4 files
+
+
+
+
+
diff --git a/docs/mockups/terminal-v2/12-heavy-rule.html b/docs/mockups/terminal-v2/12-heavy-rule.html
new file mode 100644
index 0000000..cf885fc
--- /dev/null
+++ b/docs/mockups/terminal-v2/12-heavy-rule.html
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+Filesmith Heavy Rule
+
+
+
+
+
+
+
+
+ Convert
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+ Tools
+
+
+
+
+ IMG_4821.HEIC done
+ screenshot.png 62%
+ hero-banner.png queued
+ scan_0091.tiff failed
+
+
+
+ WebP
+ JPG
+ PNG
+ AVIF
+
+
+ Convert 4 files
+
+
+
+
diff --git a/docs/mockups/terminal-v2/13-big-verb.html b/docs/mockups/terminal-v2/13-big-verb.html
new file mode 100644
index 0000000..ebca730
--- /dev/null
+++ b/docs/mockups/terminal-v2/13-big-verb.html
@@ -0,0 +1,140 @@
+
+
+
+
+
+
+Filesmith Big Verb
+
+
+
+
+
+
+
+
+ Convert
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+ Tools
+
+
+
+ Convert
+
+
+ IMG_4821.HEIC done
+ screenshot.png 62%
+ hero-banner.png queued
+ scan_0091.tiff ✕ failed
+
+
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v2/14-two-halves.html b/docs/mockups/terminal-v2/14-two-halves.html
new file mode 100644
index 0000000..1c2c2e1
--- /dev/null
+++ b/docs/mockups/terminal-v2/14-two-halves.html
@@ -0,0 +1,114 @@
+
+
+
+
+
+Filesmith
+
+
+
+
+
+
+ Convert
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+ Tools
+
+
+
+
+
+ IMG_4821.HEIC done
+ screenshot.png 62%
+ hero-banner.png queued
+ scan_0091.tiff failed
+
+ + drop files
+
+
+
+
+ WebP
+ JPG
+ PNG
+ AVIF
+
+ Convert 4 files
+
+
+
+
diff --git a/docs/mockups/terminal-v2/15-key-value-grid.html b/docs/mockups/terminal-v2/15-key-value-grid.html
new file mode 100644
index 0000000..c2ea561
--- /dev/null
+++ b/docs/mockups/terminal-v2/15-key-value-grid.html
@@ -0,0 +1,159 @@
+
+
+
+
+
+Filesmith
+
+
+
+
+
+
+
+ Convert
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+ Tools
+
+
+
+ Convert
+
+
+
IMG_4821.HEIC done
+
screenshot.png 62%
+
hero-banner.png queued
+
scan_0091.tiff failed
+
+
+
+
format
+
+ webp
+ jpg
+ png
+ avif
+
+
quality
+
80
+
+
Convert 4 files
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v2/16-drop-frame.html b/docs/mockups/terminal-v2/16-drop-frame.html
new file mode 100644
index 0000000..e42d706
--- /dev/null
+++ b/docs/mockups/terminal-v2/16-drop-frame.html
@@ -0,0 +1,183 @@
+
+
+
+
+
+Filesmith
+
+
+
+
+
+
+
+ Convert
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+ Tools
+
+
+
+ Convert
+
+
+
+ IMG_4821.HEIC done
+ screenshot.png 62%
+ hero-banner.png queued
+ scan_0091.tiff failed
+
+ drop files or browse
+
+
+
+
+ WebP
+ JPG
+ PNG
+ AVIF
+
+
Convert 4 files
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v2/17-inversion-only.html b/docs/mockups/terminal-v2/17-inversion-only.html
new file mode 100644
index 0000000..a5b90f2
--- /dev/null
+++ b/docs/mockups/terminal-v2/17-inversion-only.html
@@ -0,0 +1,119 @@
+
+
+
+
+
+Filesmith – Inversion
+
+
+
+
+
+
+
+
+
+
+
+
+
+
convert
+
+
+ IMG_4821.HEIC done
+ screenshot.png 62%
+ hero-banner.png queued
+ scan_0091.tiff failed
+
+
+
+ webp
+ jpg
+ png
+ avif
+
+
+
convert 4 files
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v2/18-right-rail.html b/docs/mockups/terminal-v2/18-right-rail.html
new file mode 100644
index 0000000..54e04ce
--- /dev/null
+++ b/docs/mockups/terminal-v2/18-right-rail.html
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+Filesmith Right Rail
+
+
+
+
+
+
+
+
+ Convert
+
+
+ IMG_4821.HEIC done
+ screenshot.png 62%
+ hero-banner.png queued
+ scan_0091.tiff failed
+
+ + drop files
+
+
+ WebP
+ JPG
+ PNG
+ AVIF
+
+
+ Convert 4 files
+
+
+
+
+ Convert
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+ Tools
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v2/19-one-number.html b/docs/mockups/terminal-v2/19-one-number.html
new file mode 100644
index 0000000..affb3ce
--- /dev/null
+++ b/docs/mockups/terminal-v2/19-one-number.html
@@ -0,0 +1,150 @@
+
+
+
+
+
+
+Filesmith
+
+
+
+
+
+
+
+
+ Convert
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+ Tools
+
+
+
+
+ IMG_4821.HEIC
+ screenshot.png 62%
+ hero-banner.png
+ scan_0091.tiff failed
+
+
+
+
+ WebP
+ JPG
+ PNG
+ AVIF
+
+
Convert 4 files
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v2/20-empty-until-dropped.html b/docs/mockups/terminal-v2/20-empty-until-dropped.html
new file mode 100644
index 0000000..256fdd1
--- /dev/null
+++ b/docs/mockups/terminal-v2/20-empty-until-dropped.html
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+Filesmith
+
+
+
+
+
+
+
+ Convert
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+ Tools
+
+
+
+
+
+ IMG_4821.HEIC done
+ screenshot.png 62%
+ hero-banner.png queued
+ scan_0091.tiff failed
+
+
+
+ drop files
+
+
+
+ WebP
+ JPG
+ PNG
+ AVIF
+
+
Convert 4 files
+
+
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v2/index.html b/docs/mockups/terminal-v2/index.html
new file mode 100644
index 0000000..ffa5961
--- /dev/null
+++ b/docs/mockups/terminal-v2/index.html
@@ -0,0 +1,40 @@
+Terminal v2 mockups
+
+
+
+01 Wind, plain
Wind's Settings shell with nothing extra added. A left rail, a title, four lines and one white pill. It looks like Wind's sibling app.
+02 Wind, settings card
The queue and the options sit in Wind-style cards, so the convert page reads like a settings page.
+03 Wind, no rail
Wind's tokens, but the rail is removed. The verbs become a quiet word row under the title bar, which frees the whole width for black space.
+04 Wind, icon rail
The closest to a shipping desktop app: a Wind rail with small 1.5px icons beside each verb, and the content kept very sparse.
+05 Prompt
The title is a prompt: '› convert_' with a slow blinking underscore. That one device carries the terminal feel.
+06 One sentence
The options read as one line: 'convert 4 files to webp at 80'. The underlined words are the controls.
+07 Verb in the title
No rail at all. The title 'Convert ⌄' is the navigation, and clicking it drops the 7 verbs.
+08 Verbs at the bottom
The work sits at the top of the window and the 7 verbs rest along the bottom edge like a quiet dock line.
+09 One bar
The rows show only a word each. Progress for the whole batch is one 1px white line under the queue.
+10 Bracket bar
Only the running file shows a text bar, '[######····]'. That bracketed bar is the only typographic device.
+11 Glyph column
Status is a single glyph in a left gutter, '✓ ○ · ✕', so the right side of every row stays empty.
+12 One heavy rule
A single 2px white rule under the title is the only line on screen. Everything else floats.
+13 Big verb
The verb is the hero: 'Convert' set at 88px light in the top-left. The rest is small and quiet.
+14 Two halves
Files on the left half and the decision on the right. Each half has one job, separated by black space only.
+15 Key and value
Everything is a two-column mono list: names with statuses, and settings with values, all on one alignment line.
+16 Drop frame
The queue lives inside a large dashed region. The drop target and the list are the same object.
+17 Inversion
Inversion is the only emphasis anywhere: the active verb, the chosen format and the failed word are small black-on-light blocks. No pills, no lines, no bold.
+18 Right rail
Content first: the work starts at the left edge and the verbs sit in a thin right-hand list.
+19 One number
Rows carry only names, dimmed when done. A single large '62%' beside the running row is the one live number on screen.
+20 Nothing until dropped
The resting screen is almost blank: the verb list and one centred line 'drop files'. The queue, format and button appear only once files arrive.
+
\ No newline at end of file
diff --git a/docs/mockups/terminal-v2/shots/01-wind-plain.png b/docs/mockups/terminal-v2/shots/01-wind-plain.png
new file mode 100644
index 0000000..259f861
Binary files /dev/null and b/docs/mockups/terminal-v2/shots/01-wind-plain.png differ
diff --git a/docs/mockups/terminal-v2/shots/02-wind-settings-card.png b/docs/mockups/terminal-v2/shots/02-wind-settings-card.png
new file mode 100644
index 0000000..23480d5
Binary files /dev/null and b/docs/mockups/terminal-v2/shots/02-wind-settings-card.png differ
diff --git a/docs/mockups/terminal-v2/shots/03-wind-no-rail.png b/docs/mockups/terminal-v2/shots/03-wind-no-rail.png
new file mode 100644
index 0000000..2bd04a9
Binary files /dev/null and b/docs/mockups/terminal-v2/shots/03-wind-no-rail.png differ
diff --git a/docs/mockups/terminal-v2/shots/04-wind-icon-rail.png b/docs/mockups/terminal-v2/shots/04-wind-icon-rail.png
new file mode 100644
index 0000000..bb391ed
Binary files /dev/null and b/docs/mockups/terminal-v2/shots/04-wind-icon-rail.png differ
diff --git a/docs/mockups/terminal-v2/shots/05-prompt-caret.png b/docs/mockups/terminal-v2/shots/05-prompt-caret.png
new file mode 100644
index 0000000..f790014
Binary files /dev/null and b/docs/mockups/terminal-v2/shots/05-prompt-caret.png differ
diff --git a/docs/mockups/terminal-v2/shots/06-one-sentence.png b/docs/mockups/terminal-v2/shots/06-one-sentence.png
new file mode 100644
index 0000000..b9293ca
Binary files /dev/null and b/docs/mockups/terminal-v2/shots/06-one-sentence.png differ
diff --git a/docs/mockups/terminal-v2/shots/07-verb-in-title.png b/docs/mockups/terminal-v2/shots/07-verb-in-title.png
new file mode 100644
index 0000000..f5d5821
Binary files /dev/null and b/docs/mockups/terminal-v2/shots/07-verb-in-title.png differ
diff --git a/docs/mockups/terminal-v2/shots/08-verbs-at-bottom.png b/docs/mockups/terminal-v2/shots/08-verbs-at-bottom.png
new file mode 100644
index 0000000..169ad09
Binary files /dev/null and b/docs/mockups/terminal-v2/shots/08-verbs-at-bottom.png differ
diff --git a/docs/mockups/terminal-v2/shots/09-one-batch-bar.png b/docs/mockups/terminal-v2/shots/09-one-batch-bar.png
new file mode 100644
index 0000000..f191ab7
Binary files /dev/null and b/docs/mockups/terminal-v2/shots/09-one-batch-bar.png differ
diff --git a/docs/mockups/terminal-v2/shots/10-bracket-bar.png b/docs/mockups/terminal-v2/shots/10-bracket-bar.png
new file mode 100644
index 0000000..e656b2f
Binary files /dev/null and b/docs/mockups/terminal-v2/shots/10-bracket-bar.png differ
diff --git a/docs/mockups/terminal-v2/shots/11-glyph-gutter.png b/docs/mockups/terminal-v2/shots/11-glyph-gutter.png
new file mode 100644
index 0000000..0842e47
Binary files /dev/null and b/docs/mockups/terminal-v2/shots/11-glyph-gutter.png differ
diff --git a/docs/mockups/terminal-v2/shots/12-heavy-rule.png b/docs/mockups/terminal-v2/shots/12-heavy-rule.png
new file mode 100644
index 0000000..45ec198
Binary files /dev/null and b/docs/mockups/terminal-v2/shots/12-heavy-rule.png differ
diff --git a/docs/mockups/terminal-v2/shots/13-big-verb.png b/docs/mockups/terminal-v2/shots/13-big-verb.png
new file mode 100644
index 0000000..6687901
Binary files /dev/null and b/docs/mockups/terminal-v2/shots/13-big-verb.png differ
diff --git a/docs/mockups/terminal-v2/shots/14-two-halves.png b/docs/mockups/terminal-v2/shots/14-two-halves.png
new file mode 100644
index 0000000..17c8be1
Binary files /dev/null and b/docs/mockups/terminal-v2/shots/14-two-halves.png differ
diff --git a/docs/mockups/terminal-v2/shots/15-key-value-grid.png b/docs/mockups/terminal-v2/shots/15-key-value-grid.png
new file mode 100644
index 0000000..2c08f89
Binary files /dev/null and b/docs/mockups/terminal-v2/shots/15-key-value-grid.png differ
diff --git a/docs/mockups/terminal-v2/shots/16-drop-frame.png b/docs/mockups/terminal-v2/shots/16-drop-frame.png
new file mode 100644
index 0000000..e37febf
Binary files /dev/null and b/docs/mockups/terminal-v2/shots/16-drop-frame.png differ
diff --git a/docs/mockups/terminal-v2/shots/17-inversion-only.png b/docs/mockups/terminal-v2/shots/17-inversion-only.png
new file mode 100644
index 0000000..508a88d
Binary files /dev/null and b/docs/mockups/terminal-v2/shots/17-inversion-only.png differ
diff --git a/docs/mockups/terminal-v2/shots/18-right-rail.png b/docs/mockups/terminal-v2/shots/18-right-rail.png
new file mode 100644
index 0000000..09dda9c
Binary files /dev/null and b/docs/mockups/terminal-v2/shots/18-right-rail.png differ
diff --git a/docs/mockups/terminal-v2/shots/19-one-number.png b/docs/mockups/terminal-v2/shots/19-one-number.png
new file mode 100644
index 0000000..d116322
Binary files /dev/null and b/docs/mockups/terminal-v2/shots/19-one-number.png differ
diff --git a/docs/mockups/terminal-v2/shots/20-empty-until-dropped.png b/docs/mockups/terminal-v2/shots/20-empty-until-dropped.png
new file mode 100644
index 0000000..786ef43
Binary files /dev/null and b/docs/mockups/terminal-v2/shots/20-empty-until-dropped.png differ
diff --git a/docs/mockups/terminal-v2/shots/_index.png b/docs/mockups/terminal-v2/shots/_index.png
new file mode 100644
index 0000000..aa9e65e
Binary files /dev/null and b/docs/mockups/terminal-v2/shots/_index.png differ
diff --git a/docs/mockups/terminal-v3/01-wind-classic.html b/docs/mockups/terminal-v3/01-wind-classic.html
new file mode 100644
index 0000000..66490f3
--- /dev/null
+++ b/docs/mockups/terminal-v3/01-wind-classic.html
@@ -0,0 +1,305 @@
+
+
+
+
+
+
+Filesmith Wind Classic
+
+
+
+
+
+
+
+
+ Search Ctrl K
+
+ Convert6
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+ Tools
+
+ Completed142
+
+
+
+
+
+
+
+ Convert Change images to another format.
+
+
+
+
+
+ Queue 2 of 6 done
+
+
+
+
IMG_4821.HEIC 3.2 MB
+
+
+
+
+
+
product-shot.jpg 2.1 MB
+
+
+
+
+
+
screenshot-2026-10-03.png 1.8 MB
+
+
+
+
+
+
hero-banner@2x.png 4.6 MB
+
queued
+
+
+
+
+
scan_0091.tiff 22.4 MB
+
+
+
+
+
+
logo-final.bmp 1.1 MB
+
queued
+
+
+
+ Drop more files here
+
+
+
+ Options
+
+
+
Format .webp
+
+ WebP
+ JPG
+ PNG
+ AVIF
+ JXL
+ TIFF
+
+
+
+
Quality
+
+ Smaller
+ Balanced
+ Best
+
+
+
+
Output
+
+
Next to source
+
Choose folder…
+
+
+
+
Keep metadata Camera info and color profile
+
+
+
+ Convert 6 files
+
+
+
+
+ Converting
+ 1 failed
+
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v3/02-wind-stacked.html b/docs/mockups/terminal-v3/02-wind-stacked.html
new file mode 100644
index 0000000..6f85a92
--- /dev/null
+++ b/docs/mockups/terminal-v3/02-wind-stacked.html
@@ -0,0 +1,291 @@
+
+
+
+
+
+Filesmith Wind Stacked
+
+
+
+
+
+
+
+
+
+ Convert6
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+ Tools
+
+ Completed142
+
+
+
+
+
+
+
+
+
Convert
+
Change images to another format.
+
+
+
+
+
+
+
Queue 1 failed
+
+
+
+
IMG_4821.HEIC 3.2 MB
+
+
+
+
+
+
product-shot.jpg 2.1 MB
+
+
+
+
+
+
screenshot-2026-10-03.png 1.8 MB
+
62%
+
+
+
+
+
hero-banner@2x.png 4.6 MB
+
queued
+
+
+
+
+
scan_0091.tiff 22.4 MB
+
+
+
+
+
+
logo-final.bmp 1.1 MB
+
queued
+
+
+
+
+
Options .webp
+
+
+
Format WebP, small and widely supported
+
+ WebP
+ JPG
+ PNG
+ AVIF
+ JXL
+ TIFF
+
+
+
+
Quality Balanced, sharp at a fraction of the size
+
+ Smaller
+ Balanced
+ Best
+
+
+
+
Output Where converted files are saved
+
+ Next to source
+ Choose folder…
+
+
+
+
Keep metadata Camera data, dates and color profile
+
+
+
+
+
+
+
+
2 of 6 done
+
Convert 6 files
+
+
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v3/03-wind-tiled-nav.html b/docs/mockups/terminal-v3/03-wind-tiled-nav.html
new file mode 100644
index 0000000..61e8d80
--- /dev/null
+++ b/docs/mockups/terminal-v3/03-wind-tiled-nav.html
@@ -0,0 +1,326 @@
+
+
+
+
+
+Filesmith, Wind Tiled Nav
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filesmith
+ convert
+
+
+
+
+
+ Search Ctrl K
+
+ Convert 6
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+ Tools
+
+
+
+ Completed 142
+
+
+
+
+ Settings 0.3.0
+
+
+
+
+
+
+
+
Convert
+
Change images to another format.
+
+ 6 files
+ Add files
+
+
+
+
+
Queue 2 of 6 done
+
+
+
+
+ 640 KB
+
+
+
+
+
+ 486 KB
+
+
+
+
+ screenshot-2026-10-03.png
1.8 MB
+ 62%
+
+
+
+
+
+ queued
+
+
+
+
+
+ Unsupported compression
+
+
+
+
+
+ queued
+
+
+
+ Drop more files here
+
+
+
+
Options .webp
+
+
Format
+
+ WebP
+ JPG
+ PNG
+ AVIF
+ JXL
+ TIFF
+
+
+
+
Quality
+
+ Smaller
+ Balanced
+ Best
+
+
+
+
Output
+
+ Next to source
+ Choose folder…
+
+
+
+ Keep metadata
+
+
+
+ Convert 6 files
+
+
+
+
+
+ Converting
+ 1 failed
+
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v3/04-wind-grouped-rail.html b/docs/mockups/terminal-v3/04-wind-grouped-rail.html
new file mode 100644
index 0000000..4370f4c
--- /dev/null
+++ b/docs/mockups/terminal-v3/04-wind-grouped-rail.html
@@ -0,0 +1,318 @@
+
+
+
+
+
+Filesmith, Wind Grouped Rail
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Search Ctrl K
+
+ Image
+
+ Convert6
+ Compress
+ Resize
+
+
+ AI
+
+ Upscale
+ Remove BG
+ Generate
+
+
+ More
+
+ Tools
+ Completed142
+
+
+
+
+
+
+
+
+
+
Convert
+
Change images to another format.
+
+ 6 files
+ Add files
+
+
+
+
+
+
Queue2 of 6 done
+
+
+
+
+
+
screenshot-2026-10-03.png
1.8 MB
+
+
+
+
+
+
+
Unsupported compression
+
+
+
Drop more files here
+
+
+
+
+
+
Format.webp
+
+
+ WebP JPG PNG
+ AVIF JXL TIFF
+
+
+
+
+
Quality and output
+
+
Quality
+
Smaller Balanced Best
+
+
Save to
+
Next to source
+
Choose folder…
+
+
+
+
Metadata
+
+
Keep metadata Camera info and color profile
+
+
+
+
+
Convert 6 files
+
+
+
+
+ Converting
+ 1 failed
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v3/05-wind-command-header.html b/docs/mockups/terminal-v3/05-wind-command-header.html
new file mode 100644
index 0000000..a443521
--- /dev/null
+++ b/docs/mockups/terminal-v3/05-wind-command-header.html
@@ -0,0 +1,311 @@
+
+
+
+
+
+Filesmith – Wind Command Header
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Search Ctrl K
+ Convert 6
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+ Tools
+
+ Completed 142
+
+
+
+
+
+
+
+
+
+
Convert
+
Change images to another format.
+
+
+ 6 files
+ Add files
+ Convert 6 files
+
+
+
+
+
+
+
+ Queue 1 failed
+
+
+
+
IMG_4821.HEIC
+
3.2 MB → 640 KB
+
+
+
+
+
product-shot.jpg
+
2.1 MB → 486 KB
+
+
+
+
+
screenshot-2026-10-03.png 1.8 MB
+
+
+
+
+
+
hero-banner@2x.png 4.6 MB
+
queued
+
+
+
+
+
scan_0091.tiff 22.4 MB
+
Unsupported compression
+
+
+
+
+
logo-final.bmp 1.1 MB
+
queued
+
+
+
Drop more files here
+
+
+
+
+
+
+
+
+
+ Converting
+ Saved next to source
+
+
+
+
diff --git a/docs/mockups/terminal-v3/06-linear-inspector.html b/docs/mockups/terminal-v3/06-linear-inspector.html
new file mode 100644
index 0000000..c580164
--- /dev/null
+++ b/docs/mockups/terminal-v3/06-linear-inspector.html
@@ -0,0 +1,352 @@
+
+
+
+
+
+
+Filesmith Linear Inspector
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Search Ctrl K
+
+
+
+
+
+
+
+
+
+
+
+
Convert
+
Change images to another format.
+
+
+ 6 files
+ Add files
+
+
+
+ Name Size Status
+
+
+
+
+ IMG_4821.HEIC
+ 3.2 MB
+ 640 KB
+
+
+
+
+ product-shot.jpg
+ 2.1 MB
+ 486 KB
+
+
+
+
+ screenshot-2026-10-03.png
+ 1.8 MB
+ 62%
+
+
+
+
+ hero-banner@2x.png
+ 4.6 MB
+ queued
+
+
+
+
+ scan_0091.tiff
+ 22.4 MB
+ Unsupported compression
+
+
+
+
+ logo-final.bmp
+ 1.1 MB
+ queued
+
+
+
+
+ Drop more files here
+
+
+ Converting
+ 1 failed
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v3/07-raycast-panel.html b/docs/mockups/terminal-v3/07-raycast-panel.html
new file mode 100644
index 0000000..ebe461e
--- /dev/null
+++ b/docs/mockups/terminal-v3/07-raycast-panel.html
@@ -0,0 +1,349 @@
+
+
+
+
+
+Filesmith Raycast Panel
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Convert 6
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+ Tools
+
+ Completed 142
+
+
+ 0.3.0
+
+
+
+
+
+
+
+
+
Convert
+
Change images to another format.
+
+
+ 6 files
+ Add files
+
+
+
+
+
+
Queue 1 failed
+
+
+
+
+
+
+
+
+
+
Unsupported compression
+
+
+
+
+
Drop more files here
+
+
+
+
+
Format .webp
+
+ WebP
+ JPG
+ PNG
+ AVIF
+ JXL
+ TIFF
+
+
+
+
Quality
+
+ Smaller
+ Balanced
+ Best
+
+
+
+
Output
+
+ Next to source
+ Choose folder…
+
+
+
+
Keep metadata
EXIF, colour profile, dates
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v3/08-zed-activity-bar.html b/docs/mockups/terminal-v3/08-zed-activity-bar.html
new file mode 100644
index 0000000..dd2469a
--- /dev/null
+++ b/docs/mockups/terminal-v3/08-zed-activity-bar.html
@@ -0,0 +1,334 @@
+
+
+
+
+
+Filesmith 08 Zed Activity Bar
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filesmith
+ convert
+
+
+
+
+
+
+ Convert 6
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+ Tools
+
+
+
+ Completed 142
+
+
+
+
+
+
+
+
Convert
+
Change images to another format.
+
+ 6 files
+ Add files
+
+
+
+
+
+ Queue 2 of 6 done
+
+
+
+
+
+
+
screenshot-2026-10-03.png
1.8 MB
+
62%
+
+
+
+
+
+
+
Unsupported compression
+
+
+
+
+
Drop more files here
+
+
+
+
+
+ Options
+
+
+
Format .webp
+
+ WebP .webp
+ JPG .jpg
+ PNG .png
+ AVIF .avif
+ JXL .jxl
+ TIFF .tiff
+
+
+
+
Quality 82
+
Smaller Balanced Best
+
+
+
Output
+
+ Next to source
+ Choose folder…
+
+
+
+
Keep metadata
EXIF, color profile, dates
+
+
+ Convert 6 files
+
+
+
+
+
+
+
+
+ Converting 1 file
+ 1 failed
+
+
+
+
+
diff --git a/docs/mockups/terminal-v3/09-top-icon-tabs.html b/docs/mockups/terminal-v3/09-top-icon-tabs.html
new file mode 100644
index 0000000..c651a3b
--- /dev/null
+++ b/docs/mockups/terminal-v3/09-top-icon-tabs.html
@@ -0,0 +1,288 @@
+
+
+
+
+
+Filesmith Top Tabs
+
+
+
+
+
+
+
+
+ Convert6
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+ Tools
+
+ Completed142
+
+
+
+
+
+
+
+ Convert Change images to another format.
+
+ 6 files
+ Add files
+
+
+
+
+
+ Queue 2 of 6 done
+
+
+
+
+
+
+
screenshot-2026-10-03.png
1.8 MB
+
+
+
+
+
+
+
+
Drop more files here
+
+
+
+
+
+ Options
+
+
+
Format .webp
+
+ WebP JPG PNG
+ AVIF JXL TIFF
+
+
+
+
Quality
+
Smaller Balanced Best
+
+
+
Output
+
Next to source
+
Choose folder…
+
+
+
+
Keep metadata
EXIF, color profile, dates
+
+
+
+
+
+
+
+
+
+ Converting 1 file
+ 1 failed
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v3/10-segmented-switcher.html b/docs/mockups/terminal-v3/10-segmented-switcher.html
new file mode 100644
index 0000000..c61e961
--- /dev/null
+++ b/docs/mockups/terminal-v3/10-segmented-switcher.html
@@ -0,0 +1,342 @@
+
+
+
+
+
+Filesmith, Segmented Switcher
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Search Ctrl K
+
+ Convert6
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+ Tools
+
+
+ Completed142
+
+ 0.3.0
+
+
+
+
+
+
+
+
+
Convert
+
Change images to another format.
+
+
+ 6 files
+ Add files
+
+
+
+
+
+
+ Queue 2 of 6 done
+
+
+
+
+
+
screenshot-2026-10-03.png
1.8 MB
+
+ 62%
+
+
+
+
+
+
+
+ Unsupported compression
+
+
+
+
+
Drop more files here
+
+
+
+
+ Options
+
+
+
Format .webp
+
+ WebP
+ JPG
+ PNG
+ AVIF
+ JXL
+ TIFF
+
+
+
+
Quality
+
+ Smaller
+ Balanced
+ Best
+
+
+
+
Output
+
+ Next to source
+ Choose folder…
+
+
+
+
Convert 6 files
+
+
+
+
+
+
+
+ Converting 1 file
+ 1 failed
+
+
+
+
diff --git a/docs/mockups/terminal-v3/11-bottom-dock.html b/docs/mockups/terminal-v3/11-bottom-dock.html
new file mode 100644
index 0000000..6c3d778
--- /dev/null
+++ b/docs/mockups/terminal-v3/11-bottom-dock.html
@@ -0,0 +1,291 @@
+
+
+
+
+
+Filesmith - Bottom Dock
+
+
+
+
+
+
+
+ Filesmith
+ convert
+
+
+
+
+
+
+
+
Convert
+
Change images to another format.
+
+
+
6 files
+
Add files
+
+
+
+
+
+
+
+
+ Queue 2 of 6 done
+
+
+
+
+
+
+
screenshot-2026-10-03.png
1.8 MB
+
+
+
+
+
+
+
+
Drop more files here
+
+
+
+
+
+ Options
+
+
+
Format .webp
+
+ WebP JPG PNG
+ AVIF JXL TIFF
+
+
+
+
Quality
+
Smaller Balanced Best
+
+
+
Output
+
+
Next to source
+
Choose folder…
+
+
+
+
Keep metadata
Date, camera and location
+
+
+
+
+
+
+
+
+
+
+
+
+ Convert
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+ Tools
+
+ 142 Completed
+
+ Settings
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v3/12-thumb-grid.html b/docs/mockups/terminal-v3/12-thumb-grid.html
new file mode 100644
index 0000000..7a5f4bb
--- /dev/null
+++ b/docs/mockups/terminal-v3/12-thumb-grid.html
@@ -0,0 +1,316 @@
+
+
+
+
+
+Filesmith, Thumbnail Grid
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filesmith
+ convert
+
+
+
+
+
+
+
+ Search Ctrl K
+ Convert 6
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+ Tools
+
+ Completed 142
+
+ Settings 0.3.0
+
+
+
+
+
+
Convert Change images to another format.
+
6 files
+
Add files
+
+
+
+
+ Queue 2 of 6 done
+
+
+
+
+ Done640 KB
+
+
+
+
+
+ Done486 KB
+
+
+
+
+
+
+
+
+
+
+
+ Queued
+
+
+
+
+
+ Unsupported compression
+
+
+
+
+
+ Queued
+
+
+ Drop more files here
+
+
+
+ Options
+
+
+
Format.webp
+
+ WebP JPG PNG
+ AVIF JXL TIFF
+
+
+
+
Quality
+
Smaller Balanced Best
+
+
+
Output
+
Next to source
+
Choose folder…
+
+
+
Keep metadata
EXIF, colour profile, dates
+
+
+
+ Convert 6 files
+
+
+
+
+
+
+ Converting
+ 1 failed
+
+
+
+
diff --git a/docs/mockups/terminal-v3/13-quiet-table.html b/docs/mockups/terminal-v3/13-quiet-table.html
new file mode 100644
index 0000000..b4e1e11
--- /dev/null
+++ b/docs/mockups/terminal-v3/13-quiet-table.html
@@ -0,0 +1,360 @@
+
+
+
+
+
+Filesmith Quiet Table
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Convert
+
Change images to another format.
+
+
+ 6 files
+ Add files
+
+
+
+
+
Queue 2 of 6 done
+
+
+ Name
+ Size
+ Result
+ Status
+
+
+
+
+
IMG_4821.HEIC
+
3.2 MB
+
640 KB
+
Done
+
+
+
+
+
product-shot.jpg
+
2.1 MB
+
486 KB
+
Done
+
+
+
+
+
screenshot-2026-10-03.png
+
1.8 MB
+
–
+
62%
+
+
+
+
+
hero-banner@2x.png
+
4.6 MB
+
–
+
Queued
+
+
+
+
+
scan_0091.tiff
+
22.4 MB
+
–
+
Unsupported compression
+
+
+
+
+
logo-final.bmp
+
1.1 MB
+
–
+
Queued
+
+
+
+
Drop more files here
+
+
+
+
+
Options
+
+
+
Format.webp
+
+ WebP
+ JPG
+ PNG
+ AVIF
+ JXL
+ TIFF
+
+
+
+
Quality
+
+ Smaller
+ Balanced
+ Best
+
+
+
+
Output
+
+ Next to source
+ Choose folder…
+
+
+
+
Keep metadata
Dates, camera and color profile
+
+
+
+ Convert 6 files
+
+
+
+
+
+
+ Converting 1 file
+ 1 failed
+
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v3/14-prompt-title.html b/docs/mockups/terminal-v3/14-prompt-title.html
new file mode 100644
index 0000000..c72844f
--- /dev/null
+++ b/docs/mockups/terminal-v3/14-prompt-title.html
@@ -0,0 +1,315 @@
+
+
+
+
+
+Filesmith Prompt Title
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filesmith
+ convert
+
+
+
+
+
+ SearchCtrl K
+
+ Convert6
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+ Tools
+
+ Completed142
+
+
+
+
+
+
+
+
+
+
+
> convert
+
Change images to another format.
+
+
+ 6 files
+ Add files
+
+
+
+
+
+
+
# queue2 of 6 done
+
+
+
+
+
+
screenshot-2026-10-03.png
1.8 MB
+
+
+
+
+
+
+
Unsupported compression
+
+
+
+
Drop more files here
+
+
+
+
# options
+
+
+
format .webp
+
+ WebP JPG PNG
+ AVIF JXL TIFF
+
+
+
+
quality
+
Smaller Balanced Best
+
+
+
output
+
+ Next to source
+ Choose folder…
+
+
+
+
metadata
Keep EXIF, colour profile, dates
+
+
+
+
Convert 6 files
+
+
+
+
+ Converting 1 file
+ 1 failed
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v3/15-glyph-gutter.html b/docs/mockups/terminal-v3/15-glyph-gutter.html
new file mode 100644
index 0000000..fa8bafd
--- /dev/null
+++ b/docs/mockups/terminal-v3/15-glyph-gutter.html
@@ -0,0 +1,318 @@
+
+
+
+
+
+Filesmith – Glyph Gutter
+
+
+
+
+
+
+
+ Filesmith
+ convert
+
+
+
+
+
+
+
+ Convert6
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+ Tools
+
+
+ Completed142
+
+
+
+ Settings0.3.0
+
+
+
+
+
+
+
Convert
+
Change images to another format.
+
+
+ 6 files
+ Add files
+
+
+
+
+
+
Queue 2 of 6 done
+
+
+
+
+
+
+
+
+
+
screenshot-2026-10-03.png
1.8 MB
+
+
+
+
+
+
+
+
+
+
+
Unsupported compression
+
+
+
+
+
+
+
+
Drop more files here
+
+
+
+
+
+
+ Options
+
+
+
Format .webp
+
+ WebP
+ JPG
+ PNG
+ AVIF
+ JXL
+ TIFF
+
+
+
+
Quality
+
+ Smaller
+ Balanced
+ Best
+
+
+
+
Output
+
Next to source
+
Choose folder…
+
+
+
Keep metadata
EXIF, color profile
+
+
+
+ Convert 6 files
+
+
+
+
+ Converting
+ 1 failed
+
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v3/16-drop-frame.html b/docs/mockups/terminal-v3/16-drop-frame.html
new file mode 100644
index 0000000..ce09a9e
--- /dev/null
+++ b/docs/mockups/terminal-v3/16-drop-frame.html
@@ -0,0 +1,325 @@
+
+
+
+
+
+
+Filesmith Drop Frame
+
+
+
+
+
+
+
+ Filesmith
+ convert
+
+
+
+
+
+
+
+ Convert6
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+ Tools
+
+
+ Completed142
+
+
+
+ Settings0.3.0
+
+
+
+
+
+
+
Convert
+
Change images to another format.
+
+
+
+
+
+
+
Queue 2 of 6 done
+
+
+
+
+
+
+
+
+
+
+
+
screenshot-2026-10-03.png
1.8 MB
+
+
+
+
+
+
+
+
+
+
+
+
Drop files anywhere in this frame, or browse
+
+
+
+
+
Options
+
+
+
Format .webp
+
+ WebP JPG PNG
+ AVIF JXL TIFF
+
+
+
+
Quality
+
Smaller Balanced Best
+
+
+
Output
+
+
Next to source
+
Choose folder…
+
+
+
+
Keep metadata
Dates, camera and location
+
+
+
+
+
+
+
+
+ Converting
+ 1 failed
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v3/17-preview-column.html b/docs/mockups/terminal-v3/17-preview-column.html
new file mode 100644
index 0000000..c82cf73
--- /dev/null
+++ b/docs/mockups/terminal-v3/17-preview-column.html
@@ -0,0 +1,341 @@
+
+
+
+
+
+Filesmith – Preview Column
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Search Ctrl K
+
+ Convert 6
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+ Tools
+
+ Completed 142
+
+
+
+
+
+
+
+
+
+
Queue 2 of 6 done
+
+
+
+
IMG_4821.HEIC 3.2 MB
+
3.2 MB 640 KB
+
+
+
+
+
product-shot.jpg 2.1 MB
+
2.1 MB 486 KB
+
+
+
+
+
screenshot-2026-10-03.png 1.8 MB
+
+
+
+
+
+
hero-banner@2x.png 4.6 MB
+
queued
+
+
+
+
+
scan_0091.tiff 22.4 MB
+
Unsupported compression
+
+
+
+
+
logo-final.bmp 1.1 MB
+
queued
+
+
+
Drop more files here
+
+
+
+
+
Preview .webp
+
+
+
+
+
+
+
+
+
+
+
+
+
hero-banner@2x.png
+
2880 × 1200
+
+
+ 4.6 MB ~920 KB
+ -80%
+
+
+
+
Format
+
+ WebP JPG PNG
+ AVIF JXL TIFF
+
+
+
+
+
Quality
+
Smaller Balanced Best
+
+
+
+
Output
+
Next to source Choose folder…
+
+
+
+
+
Convert 6 files
+
+
+
+
+
+ Converting
+ 1 failed
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v3/18-ruled-sheet.html b/docs/mockups/terminal-v3/18-ruled-sheet.html
new file mode 100644
index 0000000..8248149
--- /dev/null
+++ b/docs/mockups/terminal-v3/18-ruled-sheet.html
@@ -0,0 +1,322 @@
+
+
+
+
+
+
+Filesmith Ruled Sheet
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filesmith
+ convert
+
+
+
+
+
+
+
+ Search Ctrl K
+
+ Convert 6
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+ Tools
+
+
+
+ Completed 142
+
+
+
+
+
+
+
+
+
Convert
+
Change images to another format.
+
+
6 files
+
Add files
+
+
+
+
+ Queue2 of 6 done
+
+
+
+
+
+
screenshot-2026-10-03.png
1.8 MB
+
+
+
+
+
+
+
+
Unsupported compression
+
+
+
+
+ Drop more files here
+
+
+
+
+
+
+
+
+ Converting 1 file
+ 1 failed
+
+
+
+
+
diff --git a/docs/mockups/terminal-v3/19-verb-sentence.html b/docs/mockups/terminal-v3/19-verb-sentence.html
new file mode 100644
index 0000000..71db577
--- /dev/null
+++ b/docs/mockups/terminal-v3/19-verb-sentence.html
@@ -0,0 +1,290 @@
+
+
+
+
+
+Filesmith Verb Sentence
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filesmith
+ convert
+
+
+
+
+
+
+
+ Search Ctrl K
+
+ Convert6
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+ Tools
+
+
+
+ Completed142
+
+
+
+
+
+
+
+
+
+
+
Convert
+
Change images to another format.
+
+
6 files
+
+
+
+
+
+ Convert 6 files to WebP , Balanced , next to source .
+
+
+
+
Keep metadata
+
Convert 6 files
+
+
+
+
+
+
Queue
+
+ 2 of 6 done
+ Add files
+
+
+
+
+
IMG_4821.HEIC
+
3.2 MB
+
640 KB webp
+
+
+
+
+
product-shot.jpg
+
2.1 MB
+
486 KB webp
+
+
+
+
+
screenshot-2026-10-03.png
+
1.8 MB
+
62%
+
+
+
+
+
hero-banner@2x.png
+
4.6 MB
+
queued
+
+
+
+
+
scan_0091.tiff
+
22.4 MB
+
Unsupported compression
+
+
+
+
+
logo-final.bmp
+
1.1 MB
+
queued
+
+
+ Drop more files here
+
+
+
+ Converting 1 file
+ 1 failed
+
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v3/20-toolbar-strip.html b/docs/mockups/terminal-v3/20-toolbar-strip.html
new file mode 100644
index 0000000..1a1895b
--- /dev/null
+++ b/docs/mockups/terminal-v3/20-toolbar-strip.html
@@ -0,0 +1,295 @@
+
+
+
+
+
+Filesmith Toolbar Strip
+
+
+
+
+
+
+
+
+
Filesmith
+
convert
+
+
+
+
+
+
+
+
+ Search Ctrl K
+
+ Convert 6
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+ Tools
+
+
+ Completed 142
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Quality
+
+ Smaller
+ Balanced
+ Best
+
+
+
+
+
Output
+
+
Next to source
+
Choose folder…
+
+
+
+
Keep metadata
+
Convert 6 files
+
+
+
+
Queue 2 of 6 done
+
+
+
+
+
+
screenshot-2026-10-03.png
1.8 MB
+
+
+
+
+
+
+
Drop more files here
+
+
+
+
+ Converting
+ 1 failed
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v3/index.html b/docs/mockups/terminal-v3/index.html
new file mode 100644
index 0000000..53b28e4
--- /dev/null
+++ b/docs/mockups/terminal-v3/index.html
@@ -0,0 +1,44 @@
+
+
+Filesmith mockups, round 3
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v3/shots/01-wind-classic.png b/docs/mockups/terminal-v3/shots/01-wind-classic.png
new file mode 100644
index 0000000..636116f
Binary files /dev/null and b/docs/mockups/terminal-v3/shots/01-wind-classic.png differ
diff --git a/docs/mockups/terminal-v3/shots/02-wind-stacked.png b/docs/mockups/terminal-v3/shots/02-wind-stacked.png
new file mode 100644
index 0000000..a60d605
Binary files /dev/null and b/docs/mockups/terminal-v3/shots/02-wind-stacked.png differ
diff --git a/docs/mockups/terminal-v3/shots/03-wind-tiled-nav.png b/docs/mockups/terminal-v3/shots/03-wind-tiled-nav.png
new file mode 100644
index 0000000..b032f5c
Binary files /dev/null and b/docs/mockups/terminal-v3/shots/03-wind-tiled-nav.png differ
diff --git a/docs/mockups/terminal-v3/shots/04-wind-grouped-rail.png b/docs/mockups/terminal-v3/shots/04-wind-grouped-rail.png
new file mode 100644
index 0000000..e43a0e0
Binary files /dev/null and b/docs/mockups/terminal-v3/shots/04-wind-grouped-rail.png differ
diff --git a/docs/mockups/terminal-v3/shots/05-wind-command-header.png b/docs/mockups/terminal-v3/shots/05-wind-command-header.png
new file mode 100644
index 0000000..68f36e8
Binary files /dev/null and b/docs/mockups/terminal-v3/shots/05-wind-command-header.png differ
diff --git a/docs/mockups/terminal-v3/shots/06-linear-inspector.png b/docs/mockups/terminal-v3/shots/06-linear-inspector.png
new file mode 100644
index 0000000..f6ed282
Binary files /dev/null and b/docs/mockups/terminal-v3/shots/06-linear-inspector.png differ
diff --git a/docs/mockups/terminal-v3/shots/07-raycast-panel.png b/docs/mockups/terminal-v3/shots/07-raycast-panel.png
new file mode 100644
index 0000000..9e3a879
Binary files /dev/null and b/docs/mockups/terminal-v3/shots/07-raycast-panel.png differ
diff --git a/docs/mockups/terminal-v3/shots/08-zed-activity-bar.png b/docs/mockups/terminal-v3/shots/08-zed-activity-bar.png
new file mode 100644
index 0000000..7382790
Binary files /dev/null and b/docs/mockups/terminal-v3/shots/08-zed-activity-bar.png differ
diff --git a/docs/mockups/terminal-v3/shots/09-top-icon-tabs.png b/docs/mockups/terminal-v3/shots/09-top-icon-tabs.png
new file mode 100644
index 0000000..2fb254c
Binary files /dev/null and b/docs/mockups/terminal-v3/shots/09-top-icon-tabs.png differ
diff --git a/docs/mockups/terminal-v3/shots/10-segmented-switcher.png b/docs/mockups/terminal-v3/shots/10-segmented-switcher.png
new file mode 100644
index 0000000..5c2cec6
Binary files /dev/null and b/docs/mockups/terminal-v3/shots/10-segmented-switcher.png differ
diff --git a/docs/mockups/terminal-v3/shots/11-bottom-dock.png b/docs/mockups/terminal-v3/shots/11-bottom-dock.png
new file mode 100644
index 0000000..dd84058
Binary files /dev/null and b/docs/mockups/terminal-v3/shots/11-bottom-dock.png differ
diff --git a/docs/mockups/terminal-v3/shots/12-thumb-grid.png b/docs/mockups/terminal-v3/shots/12-thumb-grid.png
new file mode 100644
index 0000000..b230b54
Binary files /dev/null and b/docs/mockups/terminal-v3/shots/12-thumb-grid.png differ
diff --git a/docs/mockups/terminal-v3/shots/13-quiet-table.png b/docs/mockups/terminal-v3/shots/13-quiet-table.png
new file mode 100644
index 0000000..43df70a
Binary files /dev/null and b/docs/mockups/terminal-v3/shots/13-quiet-table.png differ
diff --git a/docs/mockups/terminal-v3/shots/14-prompt-title.png b/docs/mockups/terminal-v3/shots/14-prompt-title.png
new file mode 100644
index 0000000..f76933b
Binary files /dev/null and b/docs/mockups/terminal-v3/shots/14-prompt-title.png differ
diff --git a/docs/mockups/terminal-v3/shots/15-glyph-gutter.png b/docs/mockups/terminal-v3/shots/15-glyph-gutter.png
new file mode 100644
index 0000000..bef99f6
Binary files /dev/null and b/docs/mockups/terminal-v3/shots/15-glyph-gutter.png differ
diff --git a/docs/mockups/terminal-v3/shots/16-drop-frame.png b/docs/mockups/terminal-v3/shots/16-drop-frame.png
new file mode 100644
index 0000000..675e13a
Binary files /dev/null and b/docs/mockups/terminal-v3/shots/16-drop-frame.png differ
diff --git a/docs/mockups/terminal-v3/shots/17-preview-column.png b/docs/mockups/terminal-v3/shots/17-preview-column.png
new file mode 100644
index 0000000..811a502
Binary files /dev/null and b/docs/mockups/terminal-v3/shots/17-preview-column.png differ
diff --git a/docs/mockups/terminal-v3/shots/18-ruled-sheet.png b/docs/mockups/terminal-v3/shots/18-ruled-sheet.png
new file mode 100644
index 0000000..ff4cbc9
Binary files /dev/null and b/docs/mockups/terminal-v3/shots/18-ruled-sheet.png differ
diff --git a/docs/mockups/terminal-v3/shots/19-verb-sentence.png b/docs/mockups/terminal-v3/shots/19-verb-sentence.png
new file mode 100644
index 0000000..b1a0552
Binary files /dev/null and b/docs/mockups/terminal-v3/shots/19-verb-sentence.png differ
diff --git a/docs/mockups/terminal-v3/shots/20-toolbar-strip.png b/docs/mockups/terminal-v3/shots/20-toolbar-strip.png
new file mode 100644
index 0000000..e43e809
Binary files /dev/null and b/docs/mockups/terminal-v3/shots/20-toolbar-strip.png differ
diff --git a/docs/mockups/terminal-v3/shots/_index.png b/docs/mockups/terminal-v3/shots/_index.png
new file mode 100644
index 0000000..8e9f3d2
Binary files /dev/null and b/docs/mockups/terminal-v3/shots/_index.png differ
diff --git a/docs/mockups/terminal-v4/01-activity-explorer.html b/docs/mockups/terminal-v4/01-activity-explorer.html
new file mode 100644
index 0000000..718bda9
--- /dev/null
+++ b/docs/mockups/terminal-v4/01-activity-explorer.html
@@ -0,0 +1,437 @@
+
+
+
+
+
+Filesmith Explorer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 6
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ screenshot-2026-10-03.png
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
PNG WebP
+
+
+ Dimensions 2880 × 1800
+ Format PNG WebP
+ Size 1.8 MB ~410 KB
+ Progress 62%
+
+
+
+
+
+
+
+ Converting 3 of 6
+ 1 failed
+ 2 of 6 done
+
+
+
+
diff --git a/docs/mockups/terminal-v4/02-settings-editor.html b/docs/mockups/terminal-v4/02-settings-editor.html
new file mode 100644
index 0000000..26ce804
--- /dev/null
+++ b/docs/mockups/terminal-v4/02-settings-editor.html
@@ -0,0 +1,440 @@
+
+
+
+
+
+Filesmith, Convert
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filesmith
+ Convert
+
+
+
+
+
+
+
+
+
+ 6
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 4 settings
+
+
+
+
+
+
+ Images
+
+ Format
+ Quality
+ Output
+ Metadata
+
+
+
+
+ Format
+
+
+
Format: TargetModified
+
The file type every image in the queue is converted to.
+
+
+ WebP JPG PNG AVIF JXL TIFF
+
+
+
+
+ Quality
+
+
+
Quality: LevelModified
+
Trade file size against detail. Balanced suits most photos.
+
+
+ Smaller Balanced Best
+
+
+
+
+ Output
+
+
+
Output: Location
+
Where converted files are saved. Originals are never replaced.
+
+ Next to source
+ Choose folder…
+
+
+
+ Metadata
+
+
+
Metadata: Keep
+
Keep camera data, dates and colour profile in the converted file.
+
+
+
+
+
+
+
+
+
+
+
+ Converting 3 of 6
+ 1 failed
+
+ 2 of 6 done
+
+
+
+
diff --git a/docs/mockups/terminal-v4/03-twin-stripes.html b/docs/mockups/terminal-v4/03-twin-stripes.html
new file mode 100644
index 0000000..c73f4a4
--- /dev/null
+++ b/docs/mockups/terminal-v4/03-twin-stripes.html
@@ -0,0 +1,426 @@
+
+
+
+
+
+Filesmith, Twin Stripes
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 6
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Name
+
Size
+
Result
+
Status
+
+
+
+
+
+
IMG_4821.HEIC
+
3.2 MB
+
640 KB −80%
+
Done
+
+
+
+
+
+
product-shot.jpg
+
2.1 MB
+
486 KB −77%
+
Done
+
+
+
+
+
+
screenshot-2026-10-03.png
+
1.8 MB
+
+
Converting
+
+
+
+
+
+
hero-banner@2x.png
+
4.6 MB
+
+
Queued
+
+
+
+
+
+
scan_0091.tiff
+
22.4 MB
+
Unsupported compression
+
Failed
+
+
+
+
+
+
logo-final.bmp
+
1.1 MB
+
+
Queued
+
+
+
+
Drop files here or browse
+
+
+
+
+
+
Preview product-shot.jpg
+
+
+
+
+
+
+
+
+
Output product-shot.webp
+
Format JPG WebP
+
Size 2.1 MB 486 KB
+
Dimensions 2400 × 1600
+
Quality Balanced, q 82
+
Metadata Kept
+
Saved to Next to source
+
+
+
+
+
+
+
+
+
+
+
Format
+
+ WebP JPG PNG AVIF JXL TIFF
+
+
Small files, opens almost everywhere
+
+
+
Quality
+
Smaller Balanced Best
+
Sharp at a fraction of the size
+
+
+
Output
+
+ Next to source
+ Choose folder…
+
+
+
+
Metadata
+
+ Keep metadata Camera data, dates and colour profile
+
+
Reset to defaults
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v4/04-verb-tabs.html b/docs/mockups/terminal-v4/04-verb-tabs.html
new file mode 100644
index 0000000..b4fe8fc
--- /dev/null
+++ b/docs/mockups/terminal-v4/04-verb-tabs.html
@@ -0,0 +1,440 @@
+
+
+
+
+
+Filesmith – Verb Tabs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filesmith
+
+
+
+
+
+
+
+
+ Convert 6
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+ Tools
+
+
+
+
+
+
+
+
+
+ Convert
+
+ Images
+
+ WebP
+
+
+
+
+
+
+
+ Name
+ Type
+ Size
+ Status
+
+
+
+
+
+ IMG_4821.HEIC
+ HEIC
+ 3.2 MB 640 KB
+ Done
+
+
+
+
+ product-shot.jpg
+ JPG
+ 2.1 MB 486 KB
+ Done
+
+
+
+
+ screenshot-2026-10-03.png
+ PNG
+ 1.8 MB
+ 62%
+
+
+
+
+ hero-banner@2x.png
+ PNG
+ 4.6 MB
+ Queued
+
+
+
+
+ scan_0091.tiff
+ TIFF
+ 22.4 MB
+ Unsupported compression
+
+
+
+
+ logo-final.bmp
+ BMP
+ 1.1 MB
+ Queued
+
+
+
+
Drop files here or add files
+
+
+
+
+
+ Details
+ IMG_4821.HEIC
+
+
+
+
+
+
+
+
Format HEIC WebP
+
Dimensions 4032 × 3024
+
Size 3.2 MB 640 KB
+
Saved 80%
+
Metadata Kept
+
Output IMG_4821.webp
+
+
+
+
+
+
+
+
+ Options
+
+
+
+
+
Target
+
+
Format
+
+ WebP
+ JPG
+ PNG
+ AVIF
+ JXL
+ TIFF
+
+
+
+
+
+
+
Output
+
+ Location
+ Next to source
+
+
+ Metadata
+ Keep metadata
+
+
+
+
+
+
+
+
+
+ Converting
+ 1 failed
+
+ 2 of 6 done
+
+
+
+
diff --git a/docs/mockups/terminal-v4/05-project-widget.html b/docs/mockups/terminal-v4/05-project-widget.html
new file mode 100644
index 0000000..c92b135
--- /dev/null
+++ b/docs/mockups/terminal-v4/05-project-widget.html
@@ -0,0 +1,494 @@
+
+
+
+
+
+Filesmith
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Convert
+
+
+ Batch 1 · 6 files
+
+ 142
+
+
+
+
+
+
Convert6
+
Compress
+
Resize
+
Upscale
+
Remove BG
+
Generate
+
+
Tools
+
+
+
+
+
+
+ Add files
+ Retry failed
+
+
+
+
+
+
+
+
+
+ Name
+ Size Result Status
+
+
+
+
+ IMG_4821.HEIC
+ 3.2 MB
+
+ 640 KB
+ done
+
+
+
+
+ product-shot.jpg
+ 2.1 MB
+
+ 486 KB
+ done
+
+
+
+
+ screenshot-2026-10-03.png
+ 1.8 MB
+
+ 62%
+
+
+
+
+ hero-banner@2x.png
+ 4.6 MB
+
+ queued
+
+
+
+
+ scan_0091.tiff
+ 22.4 MB
+
+ Unsupported compression
+
+
+
+
+ logo-final.bmp
+ 1.1 MB
+
+ queued
+
+
+
Drop files here, or add files
+
+
+
+
+
+
+ IMG_4821.HEIC
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
HEIC
+
WebP
+
+
Original Converted
+
+
+
File
+
Dimensions 4032 × 3024
+
Format HEIC → WebP
+
Size 3.2 MB → 640 KB
+
Saved 80%
+
Output
+
Name IMG_4821.webp
+
Folder Next to source
+
Metadata Kept
+
+
+
+
+
+
+
+
+ Options
+
+
+
+
+
+
+
+
Format.webp
+
Target
+
+ WebP JPG PNG AVIF JXL TIFF
+
+
+
Small files, works everywhere
+
+
+
+
Quality
+
Preset
+
Smaller Balanced Best
+
+
+
+
+
+
Output
+
Location
+
+ Next to source
+
+
+
+ Choose folder…
+
+
If name exists
+ Add a number
+
+
+
+
+
Metadata
+
Keep
+ Keep metadata
+
+
Camera data, dates and colour profile
+
+
+
+ Advanced
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v4/06-preferences-tree.html b/docs/mockups/terminal-v4/06-preferences-tree.html
new file mode 100644
index 0000000..a9d54d7
--- /dev/null
+++ b/docs/mockups/terminal-v4/06-preferences-tree.html
@@ -0,0 +1,361 @@
+
+
+
+
+
+Filesmith Preferences Tree
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filesmith
+
+
+
+
+
+
+
+
+
+
+
+ Search options
+
+ Convert 6
+ Format WebP
+ Quality
+ Output
+ Metadata
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+ Tools
+
+
+
+
+
+
+
+
+
Convert Format
+
For 6 images
+
Reset
+
+
+
+
Target format
+
Save as
+
+ WebP JPG PNG AVIF JXL TIFF
+
+
Small files, opens everywhere
+
+
+
+
Quality
+
Level
+
Smaller Balanced Best
+
Looks the same as the source at about a quarter of the size
+
+
+
+
Output
+
Save to
+
+ Next to source
+ Choose folder
+
+
Browse
+
+
+
+
Metadata
+
+ Keep metadataCamera data, dates and color profile
+
+
+
+
+
+
+
+
+
+ Queue6
+
+
+
+
+ Add files
+ Convert 6 files
+
+
+
+ Name Dimensions Size Status
+
+ IMG_4821.HEIC4032 × 3024
+ 3.2 MB640 KB
+ Done
+ product-shot.jpg2400 × 2400
+ 2.1 MB486 KB
+ Done
+ screenshot-2026-10-03.png2880 × 1800
+ 1.8 MB
+ 62%
+ hero-banner@2x.png3200 × 1200
+ 4.6 MB
+ Queued
+ scan_0091.tiff5100 × 7000
+ 22.4 MB
+ Unsupported compression
+ logo-final.bmp1024 × 1024
+ 1.1 MB
+ Queued
+
+
+
Drop files here or browse
+
+
+
+
+
+ Converting 3 of 6
+ 1 failed
+ 2 of 6 done
+
+
+
+
diff --git a/docs/mockups/terminal-v4/07-zed-hairline.html b/docs/mockups/terminal-v4/07-zed-hairline.html
new file mode 100644
index 0000000..6674212
--- /dev/null
+++ b/docs/mockups/terminal-v4/07-zed-hairline.html
@@ -0,0 +1,411 @@
+
+
+
+
+
+Filesmith, Hairline
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filesmith / Convert
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Convert 6 files
+
+
+
+
+ Add files
+ Convert 6 files
+
+
+ Name Size Result
+
+
+
+ IMG_4821.HEIC
+ 3.2 MB
+ 640 KB
+
+
+
+
+
HEIC 3.2 MB
+
WebP 640 KB
+
+
+
Format HEIC WebP
+
Size 3.2 MB 640 KB −80%
+
Dimensions 4032 × 3024
+
Saved to IMG_4821.webp
+
+
+
+
+
+ product-shot.jpg
+ 2.1 MB
+ 486 KB
+
+
+
+
+ screenshot-2026-10-03.png
+ 1.8 MB
+ 62%
+
+
+
+
+ hero-banner@2x.png
+ 4.6 MB
+ queued
+
+
+
+
+ scan_0091.tiff
+ 22.4 MB
+ Unsupported compression
+
+
+
+
+ logo-final.bmp
+ 1.1 MB
+ queued
+
+
+
+
+
+
+
+
+
+
+
+
+
Format .webp
+
+ WebP
+ JPG
+ PNG
+ AVIF
+ JXL
+ TIFF
+
+
+
+
Quality q 82
+
+ Smaller
+ Balanced
+ Best
+
+
+
+
Output
+
+
Next to source
+
Choose folder
+
+
+
+
Metadata
+
+
+
Keep metadata
Camera data, dates and colour profile
+
+
+
+
+
+
+
+ Converting 3 of 6
+ 1 failed
+ 2 of 6 done
+
+
+
+
diff --git a/docs/mockups/terminal-v4/08-fleet-grid.html b/docs/mockups/terminal-v4/08-fleet-grid.html
new file mode 100644
index 0000000..1170b08
--- /dev/null
+++ b/docs/mockups/terminal-v4/08-fleet-grid.html
@@ -0,0 +1,360 @@
+
+
+
+
+
+Filesmith, Fleet Grid
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filesmith
+ Batch 1 · 6 images
+
+
+
+
+
+
+ Convert6
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+
+
+
Tools
+
+
Completed142
+
+
+
+
+
+
+
+
+
+
Options
+
+ .webp
+
+
+
+
Format
+
+
+ WebP JPG PNG AVIF JXL TIFF
+
+
+
+
Quality
+
+
+ Smaller Balanced· q 80 Best
+
+
+
+
Output
+
+ Next to source
+ Choose folder
+
+
+
Keep metadata
+
+
Camera data, dates and colour profile
+
+
+
+
+ 6 images → WebP, balanced, next to source
+
+ Convert 6 files
+
+
+
+
+
Queue
+ 6
+
+ Add files
+
+
+
+
+
+
+ Name
+ Type
+ Size
+ Status
+
+
+
+
+
+
IMG_4821.HEIC
+
HEIC
+
3.2 MB 640 KB
+
done
+
+
+
+
+
product-shot.jpg
+
JPG
+
2.1 MB 486 KB
+
done
+
+
+
+
+
screenshot-2026-10-03.png
+
PNG
+
1.8 MB
+
62%
+
+
+
+
+
hero-banner@2x.png
+
PNG
+
4.6 MB
+
queued
+
+
+
+
+
scan_0091.tiff
+
TIFF
+
22.4 MB
+
Unsupported compression
+
+
+
+
+
logo-final.bmp
+
BMP
+
1.1 MB
+
queued
+
+
+
+
+
+
+
+
+
+
+
+ Converting 3 of 6
+ 1 failed
+
+ 2 of 6 done
+
+
+
diff --git a/docs/mockups/terminal-v4/09-walkthrough-steps.html b/docs/mockups/terminal-v4/09-walkthrough-steps.html
new file mode 100644
index 0000000..6c17acf
--- /dev/null
+++ b/docs/mockups/terminal-v4/09-walkthrough-steps.html
@@ -0,0 +1,534 @@
+
+
+
+
+
+Filesmith, Walkthrough
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 6
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Convert images
+
4 of 4 ready
+
+
+
+
+
+ 1
+ Add files 6 images, 35.2 MB
+
+
+
+
Drop files on the queue, or pick them here.
+
Add files
+
+
+
+
+ 2
+ Format WebP
+
+
+
+
Every file in the queue becomes this format.
+
+ WebP
+ JPG
+ PNG
+ AVIF
+ JXL
+ TIFF
+
+
Transparency yes Typical size -70%
+
+ Next: Quality
+
+
+
+
+
+ 3
+ Quality Balanced
+
+
+
+
Trade file size against detail.
+
+ Smaller Balanced Best
+
+
Next: Output
+
+
+
+
+ 4
+ Output Next to source, keep metadata
+
+
+
+
Where converted files are saved.
+
Next to source
+
Choose folder…
+
Keep metadata
+
+
+
+
+
+
+
+
Batch
+
+ Files 6 images
+ Converts to WebP, balanced
+ Input 35.2 MB
+ Estimated 7.9 MB -78%
+
+
+
+
+
+
+
+
+
+
Queue 6
+
+ Add files
+
+
+
+
+
+
+
+
+
+
+ IMG_4821.HEIC
+ 3.2 MB 640 KB
+ done
+
+
+
+ product-shot.jpg
+ 2.1 MB 486 KB
+ done
+
+
+
+ screenshot-2026-10-03.png
+ 1.8 MB
+ 62%
+
+
+
+ hero-banner@2x.png
+ 4.6 MB
+ queued
+
+
+
+ scan_0091.tiff
+ 22.4 MB
+ Unsupported compression
+
+
+
+ logo-final.bmp
+ 1.1 MB
+ queued
+
+
+
Drop files here or browse
+
+
+
+
+
+
+
+
IMG_4821.HEIC
+
+
+
+
+
+
+
+
+
+ HEIC Source 3.2 MB
+
+
+
+ WebP Result 640 KB
+
+
+
+
+
File
+
+ Dimensions 4032 × 3024
+ Format HEIC → WebP
+ Size 3.2 MB → 640 KB
+ Saved 80%
+
+
+
+
Output
+
+ Name IMG_4821.webp
+ Location Next to source
+ Metadata Kept
+ Took 1.4 s
+
+
+
+
+
+
+
+
+
+
+ Converting 3 of 6
+ 1 failed
+ 2 of 6 done
+
+
+
+
+
diff --git a/docs/mockups/terminal-v4/10-explorer-details.html b/docs/mockups/terminal-v4/10-explorer-details.html
new file mode 100644
index 0000000..b2bcea5
--- /dev/null
+++ b/docs/mockups/terminal-v4/10-explorer-details.html
@@ -0,0 +1,493 @@
+
+
+
+
+
+
+Filesmith Details
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filesmith
+
+
+ Convert
+
+ Images
+
+
+
+
+
+
+
+ 6
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Name
+
Kind
+
Size
+
Result
+
Status
+
+
+
+
IMG_4821.HEIC
+
HEIC
+
3.2 MB
+
640 KB −80%
+
Done
+
+
+
+
product-shot.jpg
+
JPG
+
2.1 MB
+
486 KB −77%
+
Done
+
+
+
+
screenshot-2026-10-03.png
+
PNG
+
1.8 MB
+
62%
+
Converting
+
+
+
+
hero-banner@2x.png
+
PNG
+
4.6 MB
+
+
Queued
+
+
+
+
scan_0091.tiff
+
TIFF
+
22.4 MB
+
+
Unsupported compression
+
+
+
+
logo-final.bmp
+
BMP
+
1.1 MB
+
+
Queued
+
+
+ Drop files here, or browse
+
+
+
6 files
+
+
35.2 MB
+
5.3 MB 1.1 MB so far
+
2 of 6 done
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v4/11-nova-prefpane.html b/docs/mockups/terminal-v4/11-nova-prefpane.html
new file mode 100644
index 0000000..c18f1d1
--- /dev/null
+++ b/docs/mockups/terminal-v4/11-nova-prefpane.html
@@ -0,0 +1,371 @@
+
+
+
+
+
+Filesmith Pref Pane
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filesmith
+ Convert
+
+
+
+
+
+ Convert 6
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+
+ Tools
+
+ Completed 142
+ Settings
+
+
+
+
+
+
+
+
+
+
+
+
+ Name
+ Type
+ Size
+
+ Status
+
+
+
+
+
+ IMG_4821.HEIC
+ HEIC
+ 3.2 MB 640 KB
+
+ Done
+
+
+
+
+ product-shot.jpg
+ JPG
+ 2.1 MB 486 KB
+
+ Done
+
+
+
+
+ screenshot-2026-10-03.png
+ PNG
+ 1.8 MB
+
+ 62%
+
+
+
+
+ hero-banner@2x.png
+ PNG
+ 4.6 MB
+
+ Queued
+
+
+
+
+ scan_0091.tiff
+ TIFF
+ 22.4 MB
+
+ Unsupported compression
+
+
+
+
+ logo-final.bmp
+ BMP
+ 1.1 MB
+
+ Queued
+
+
+ Drop more files here or
browse
+
+
+
+
+ Converting 1 of 6
+ 1 failed
+
+ 2 of 6 done
+
+
+
+
diff --git a/docs/mockups/terminal-v4/12-options-sidebar.html b/docs/mockups/terminal-v4/12-options-sidebar.html
new file mode 100644
index 0000000..779978f
--- /dev/null
+++ b/docs/mockups/terminal-v4/12-options-sidebar.html
@@ -0,0 +1,439 @@
+
+
+
+
+
+
+Filesmith Options Sidebar
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filesmith
+ Convert / 6 files
+
+
+
+
+
+
+ 6
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Convert options
+
+
+
+
+
+
+
+
+
+ Format
+ Every image in the queue is written as this type.
+
+ WebP JPG PNG AVIF JXL TIFF
+
+
+
+
+
+ Quality
+ Trade file size against visible detail.
+
+ Smaller Balanced Best
+
+
+
+
+
+ Output
+ Where converted files are saved.
+
+ Next to source
+ Choose folder…
+
+
+
+
+
+ Metadata
+
+
+ Keep camera data, dates and colour profile
+
+
+
+
Advanced3
+
+
+
+
+
+
+
+
+
Queue 6 files
+
+ Add files
+
+ Clear
+
+
+
+
+
+
+ Name
+ Type
+ Size
+ Status
+
+
+
+
+
+
IMG_4821.HEIC
HEIC
+
3.2 MB 640 KB
+
Done
+
+
+
+
+
+
product-shot.jpg
JPG
+
2.1 MB 486 KB
+
Done
+
+
+
+
+
+
screenshot-2026-10-03.png
PNG
+
1.8 MB
+
+
+
+
+
+
+
hero-banner@2x.png
PNG
+
4.6 MB
+
Queued
+
+
+
+
+
+
scan_0091.tiff
TIFF
+
22.4 MB
+
Unsupported compression
+
+
+
+
+
+
logo-final.bmp
BMP
+
1.1 MB
+
Queued
+
+
+
+
Drop files here, or add files
+
+
+
+
+
+
+
+
File
screenshot-2026-10-03.png
+
Dimensions
2880 × 1800
+
Size
1.8 MB estimated ~ 310 KB
+
Converting to
WebP, balanced
+
Saves to
Next to source
+
Progress
62%
+
+
+
+
+
+
+
+ Converting 3 of 6
+ 1 failed
+ 2 of 6 done
+
+
+
diff --git a/docs/mockups/terminal-v4/13-run-configurations.html b/docs/mockups/terminal-v4/13-run-configurations.html
new file mode 100644
index 0000000..07251d6
--- /dev/null
+++ b/docs/mockups/terminal-v4/13-run-configurations.html
@@ -0,0 +1,388 @@
+
+
+
+
+
+Filesmith, Run Configurations
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filesmith
+
+ ConvertImages
+
+
+ WebP · Balanced
+
+
+
+
+
+
+
+
+
+ 6
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Setups
+
+
+
+
+ Convert images3
+ WebP · Balanced.webp
+ AVIF · Best.avif
+ JPG · Smaller.jpg
+
+
+
+
+
+
+
+
+
+
+ Queue 6
+
+ Add files
+ Retry failed
+ Clear finished
+
+ Convert 6 files
+
+
+
+
+ Name Size Status
+
+ IMG_4821.HEIC
+ 3.2 MB 640 KB
+ Done
+
+
+ product-shot.jpg
+ 2.1 MB 486 KB
+ Done
+
+
+ screenshot-2026-10-03.png
+ 1.8 MB
+ 62%
+
+
+ hero-banner@2x.png
+ 4.6 MB
+ Queued
+
+
+ scan_0091.tiff
+ 22.4 MB
+ Unsupported compression
+
+
+ logo-final.bmp
+ 1.1 MB
+ Queued
+
+
+ Drop files here or add files
+
+
+
+
+
+
+ Converting 3 of 6
+ 1 failed
+
+ 2 of 6 done
+
+
+
+
diff --git a/docs/mockups/terminal-v4/14-command-center.html b/docs/mockups/terminal-v4/14-command-center.html
new file mode 100644
index 0000000..c2d629b
--- /dev/null
+++ b/docs/mockups/terminal-v4/14-command-center.html
@@ -0,0 +1,383 @@
+
+
+
+
+
+Filesmith Command Center
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Convert images to WebP…
+ Convert
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Convert
+
+ Images
+
+ WebP
+
+ Quality Balanced
+
+ Next to source
+
+ Keep metadata
+
+
+
+
+
+ Add files
+ Convert 6 files
+
+
+
+
+
+
+
+
+
+ Converting 3 of 6
+ 1 failed
+
+ 2 of 6 done
+
+
+
+
diff --git a/docs/mockups/terminal-v4/15-split-editor.html b/docs/mockups/terminal-v4/15-split-editor.html
new file mode 100644
index 0000000..1459029
--- /dev/null
+++ b/docs/mockups/terminal-v4/15-split-editor.html
@@ -0,0 +1,522 @@
+
+
+
+
+
+Filesmith
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filesmith
+
+ Batch 1
+
+
+
+
+
+
+
+
+
+
+ 6
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Queue 6
+
Completed 142
+
+
+
+
+ Name
+ Size
+ Result
+
+
+
+
+
+ IMG_4821.HEIC
+ 3.2 MB
+ 640 KB
+
+
+
+
+ product-shot.jpg
+ 2.1 MB
+ 486 KB
+
+
+
+
+ screenshot-2026-10-03.png
+ 1.8 MB
+ 62%
+
+
+
+
+ hero-banner@2x.png
+ 4.6 MB
+ queued
+
+
+
+
+ scan_0091.tiff
+ 22.4 MB
+ Unsupported compression
+
+
+
+
+ logo-final.bmp
+ 1.1 MB
+ queued
+
+
+
+ Drop files here or browse
+
+
+
+
+
+
+
+
+
+
product-shot.jpg
+
+
+
+
+
+
+
+
+ Properties
+
+ Property Before After
+
+ Dimensions 2400 × 1600 2400 × 1600
+ Format JPG WebP
+ Size 2.1 MB 486 KB
+ Quality q 92 Balanced, q 80
+ Metadata EXIF, ICC Kept
+ Saved 77%
+
+
+
+
+
+
+
+
+ Converting 3 of 6
+ 1 failed
+
+ 2 of 6 done
+
+
+
+
diff --git a/docs/mockups/terminal-v4/16-state-tree.html b/docs/mockups/terminal-v4/16-state-tree.html
new file mode 100644
index 0000000..e4c3213
--- /dev/null
+++ b/docs/mockups/terminal-v4/16-state-tree.html
@@ -0,0 +1,460 @@
+
+
+
+
+
+Filesmith – State Tree
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filesmith / Batch 1
+
+
+
+
+
+
+ 6
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Convert
+
+ 6 images
+
+ WebP
+
+
+ Add files
+ Convert 6 files
+
+
+
+
+
+
+
+
+ Converting
+ 1 failed
+ 2 of 6 done
+
+
+
+
diff --git a/docs/mockups/terminal-v4/17-sublime-quiet.html b/docs/mockups/terminal-v4/17-sublime-quiet.html
new file mode 100644
index 0000000..a84c1da
--- /dev/null
+++ b/docs/mockups/terminal-v4/17-sublime-quiet.html
@@ -0,0 +1,324 @@
+
+
+
+
+
+Filesmith - Quiet
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Convert6
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+
+ Tools
+
+
+
+
+
+
+
+
To
+
+ WebP JPG PNG AVIF JXL TIFF
+
+
+
Quality
+
+ Smaller Balanced Best
+
+
+
Save to
+ Next to source
+
+
Keep metadata
+
+
Add files
+
+
Convert 6 files
+
+
+
+
+
+
+ Name
+ Size
+ Status
+
+
+
+
+
+
+ IMG_4821.HEIC
+ 3.2 MB 640 KB
+
+
+
+
+
+
+
+ product-shot.jpg
+ 2.1 MB 486 KB
+
+
+
+
+
+
+
+ screenshot-2026-10-03.png
+ 1.8 MB
+ 62%
+
+
+
+
+
+
+ hero-banner@2x.png
+ 4.6 MB
+
+
+
+
+
+
+
+ scan_0091.tiff
+ 22.4 MB
+ Unsupported compression
+
+
+
+
+
+
+ logo-final.bmp
+ 1.1 MB
+
+
+
+
+ Drop files here or browse
+
+
+
+
+
+
+
+
+ Compare
+ Before
+ After
+ product-shot.jpg 2.1 MB → 486 KB
+
+
+
+
+ Converting 3 of 6
+ 1 failed
+
+ 2 of 6 done
+
+
+
+
diff --git a/docs/mockups/terminal-v4/18-labelled-activity.html b/docs/mockups/terminal-v4/18-labelled-activity.html
new file mode 100644
index 0000000..a8a47d3
--- /dev/null
+++ b/docs/mockups/terminal-v4/18-labelled-activity.html
@@ -0,0 +1,366 @@
+
+
+
+
+
+
+Filesmith Labelled Bar
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filesmith
+
+
+ Convert
+
+ Images to WebP
+
+
+
+
+
+
+
+ 6 Convert
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+
+ Tools
+
+ 142 Completed
+ Settings
+
+
+
+
+
+
Queue 6 files
+
+ Retry failed
+ Clear finished
+
+
+
+
+
+
+
+ Name
+ Size
+ Status
+
+
+
+
+ IMG_4821.HEIC
+ 3.2 MB 640 KB
+ Done
+
+
+
+
+ product-shot.jpg
+ 2.1 MB 486 KB
+ Done
+
+
+
+
+ screenshot-2026-10-03.png
+ 1.8 MB
+ 62%
+
+
+
+
+ hero-banner@2x.png
+ 4.6 MB
+ Queued
+
+
+
+
+ scan_0091.tiff
+ 22.4 MB
+ Unsupported compression
+
+
+
+
+ logo-final.bmp
+ 1.1 MB
+ Queued
+
+
+
+ Drop more files here
+
+
+
+
+
+
+
+ Converting 3 of 6
+ 1 failed
+ 2 of 6 done
+
+
+
+
diff --git a/docs/mockups/terminal-v4/19-properties-window.html b/docs/mockups/terminal-v4/19-properties-window.html
new file mode 100644
index 0000000..4210585
--- /dev/null
+++ b/docs/mockups/terminal-v4/19-properties-window.html
@@ -0,0 +1,363 @@
+
+
+
+
+
+Filesmith Properties Window
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 6
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Add files
+ Retry failed
+
+ Convert 6 files
+
+
+
+ Name Type Size Status
+
+
+
+
+ IMG_4821.HEIC HEIC
+ 3.2 MB 640 KB
+ Done
+
+
+
+ product-shot.jpg JPG
+ 2.1 MB 486 KB
+ Done
+
+
+
+ screenshot-2026-10-03.png PNG
+ 1.8 MB
+ 62%
+
+
+
+ hero-banner@2x.png PNG
+ 4.6 MB
+ Queued
+
+
+
+ scan_0091.tiff TIFF
+ 22.4 MB
+ Unsupported compression
+
+
+
+ logo-final.bmp BMP
+ 1.1 MB
+ Queued
+
+
+ Drop files here or browse
+
+
+ Preview
+
+
+
+
+
+
+ Properties
+
+
+
+
+
+ Convert 6 images
+
+
+
+
+
+
+
+
+
Format
+
+ Target
+ WebP
+
+
+
Quality
+
+ Level
+ Balanced
+
+
+
Output
+
+ Location
+ Next to source
+
+
+ Keep metadata
+ True
+
+
Naming2
+
Advanced3
+
Selected fileproduct-shot.jpg
+
+
+
+
Target
+
The format every file in this queue is saved as.
+
+
+
+
+
+ Converting 3 of 6
+ 1 failed
+ 2 of 6 done
+
+
+
+
diff --git a/docs/mockups/terminal-v4/20-navigator-selector.html b/docs/mockups/terminal-v4/20-navigator-selector.html
new file mode 100644
index 0000000..162237d
--- /dev/null
+++ b/docs/mockups/terminal-v4/20-navigator-selector.html
@@ -0,0 +1,477 @@
+
+
+
+
+
+Filesmith
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filesmith
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
6
+
+
+
+
+
+
+
+
142
+
+
+
+
+
+
+
+
+
+ IMG_4821.HEIC
+ 3.2 MB640 KB
+
+
+
+
+ product-shot.jpg
+ 2.1 MB486 KB
+
+
+
+
+ screenshot-2026-10-03.png
+ 62%
+
+
+
+
+
+ hero-banner@2x.png
+ 4.6 MB queued
+
+
+
+
+ scan_0091.tiff
+ Unsupported compression
+
+
+
+
+ logo-final.bmp
+ 1.1 MB queued
+
+
+
+
+ Drop files here to add them
+
+
+
+ Earlier today 12
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Convert
+
+
Images
+
+
WebP
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
JPG 2.1 MB
+
WebP 486 KB
+
+
+
+
+
product-shot.jpg
+
+
1600 × 1200
+
+
Fit
+
+
+
+
+
+
+
+
+
+
Format
+
+ WebP JPG PNG AVIF JXL TIFF
+
+
Small files that open everywhere.
+
+
+
+
Quality
+
+ Smaller Balanced Best
+
+
Sharp at a fraction of the size.
+
+
+
+
Output
+
Next to source
+
Choose folder
+
+
+
+
Metadata
+
Keep metadata
+
Camera data, dates and colour profile.
+
+
+
+
Selection
+
+
Dimensions 1600 × 1200
+
Size 2.1 MB 486 KB
+
Saved 77%
+
+
+
+
+
+
+
+
+
+
+ Converting 3 of 6
+ 1 failed
+
+ 2 of 6 done
+
+
+
+
diff --git a/docs/mockups/terminal-v4/index.html b/docs/mockups/terminal-v4/index.html
new file mode 100644
index 0000000..d865cf8
--- /dev/null
+++ b/docs/mockups/terminal-v4/index.html
@@ -0,0 +1,146 @@
+
+
+Filesmith IDE mockups
+
+
+
+
+
+ 01 Explorer
+ VS Code activity bar (2px edge bar active, badge), Explorer sidebar view with 22px collapsible section headers, empty editor as preview, status bar
+ The VS Code Explorer turned into a file tool. A narrow activity bar holds the verbs, and a 300px sidebar stacks collapsible QUEUE, OPTIONS and OUTPUT sections. The editor area previews the selected file. It is calm and familiar, and nothing floats.
+
+
+
+ 02 Settings Editor
+ VS Code Settings editor (search + category tree + stacked rows, modified bar), VS Code secondary side bar, activity bar
+ Convert options read like VS Code's Settings UI: a search field, a category tree and stacked setting rows with a one-line description each. The queue lives in a secondary sidebar on the right. It is the literal 'IDE settings page' feel the owner asked for.
+
+
+
+ 03 Twin Stripes
+ JetBrains New UI stripes (filled square active, separators), run widget in the title bar, tool windows (Options docked right), compact toolbar
+ JetBrains New UI framing: a tool window stripe on each edge, the queue as the main table, and a run widget in the title bar that holds the primary action, the way IntelliJ holds Run.
+
+
+
+ 04 Verb Tabs
+ VS Code editor tabs (active top border, merges into content), breadcrumbs bar, JetBrains-style property inspector rows
+ The verbs are editor tabs across the top, each with an icon, and Convert is the active tab merging into the content. A breadcrumb names the target. A plain property inspector holds the options. There is no rail at all.
+
+
+
+ 05 Project Widget
+ Zed / JetBrains title bar project picker, Zed flat list panes, inspector pane with disclosure groups
+ Zed and JetBrains style: no rail. The verb is picked from a 'Convert ▾' widget in the title bar, so the whole window belongs to a flat queue list and an inspector. The most uncluttered take.
+
+
+
+ 06 Preferences Tree
+ JetBrains Settings dialog (tree with search, breadcrumb header, titled-separator groups), horizontal split
+ The JetBrains Settings dialog as a whole app. A 220px tree lists the verbs, and each verb expands to its option pages (Convert › Format, Quality, Output). A breadcrumb header and titled separators replace cards, and the queue sits in a lower split.
+
+
+
+ 07 Hairline
+ Zed (thin dock, crisp mono, hairline panes, slim pane headers), VS Code status bar
+ Zed minimalism with enough substance: a 40px icon column, one hairline split between queue and options, mono for data. Quiet, but every verb, row and button carries an icon.
+
+
+
+ 08 Fleet Grid
+ JetBrains Fleet top toolbar and centred workspace, property grid / key-value table, compact toolbar
+ JetBrains Fleet: a top toolbar instead of a rail, a centred workspace, and options as a crisp key-value property grid. Modern IDE-lite with air, still compact.
+
+
+
+ 09 Walkthrough
+ VS Code Welcome / Walkthrough step list (expanding step, check glyphs), activity bar, status bar
+ Borrowed from VS Code's Get Started walkthroughs: the left pane is a short numbered checklist (Add files, Format, Quality, Output), the active step expands inline to its control, finished steps show a check and their value. Setup becomes a guided menu, the queue fills the rest.
+
+
+
+ 10 Details + Inspector
+ JetBrains / Explorer list with sortable column headers, Xcode inspector segmented icon tabs, property grid for Info
+ Explorer details view meets the Xcode inspector. The queue is a sortable column table, and the right inspector switches between Options, Preview and Info with icon segments. Power-user, but tidy.
+
+
+
+ 11 Pref Pane
+ Nova / macOS preference-pane icon toolbar (square, monochrome), two-column right-aligned label forms, status bar
+ Panic Nova and classic preference panes: a top toolbar of square verb icons with small labels, and a two-column right-aligned form beside the queue. Reads like a well-made desktop app, not a web page.
+
+
+
+ 12 Options Sidebar
+ VS Code activity bar, a sidebar view titled like settings with stacked setting rows, editor area as details list, editor header actions
+ An activity bar plus a sidebar that IS the options: flush setting rows stacked in a 320px view, separated by hairlines, no cards, no row icons. The editor area is a large, airy details list of the queue.
+
+
+
+ 13 Run Configurations
+ JetBrains Run/Debug Configurations dialog (config list + form), JetBrains stripe, compact toolbar
+ JetBrains Run/Debug Configurations as the options model: a short list of named targets on the left, the selected one's form on the right, and the queue below. Picking a setup feels like picking a run config.
+
+
+
+ 14 Command Center
+ VS Code Command Center in the title bar, quick pick (closed trigger), breadcrumbs, status bar
+ The VS Code Command Center drives everything: a flat title-bar field searches verbs, files and settings. The verb and format show as a breadcrumb of dropdown triggers. Minimal chrome for keyboard users.
+
+
+
+ 15 Split Editor
+ VS Code split editor groups with their own tabs, property grid, sidebar collapsible sections
+ Two editor groups side by side: the queue on the left, and a per-file before/after view on the right with a property grid. Options sit in a collapsible sidebar section. Best layout for checking results.
+
+
+
+ 16 State Tree
+ VS Code tree view with indent guides and focus outline, Problems view grouping, inspector disclosure groups
+ The queue is a tree whose top nodes are states (Failed, Running, Queued, Done) with counts, and indent guides lead to the files. Problems surface first, the way an IDE's Problems view does. Options sit as a centred form.
+
+
+
+ 17 Quiet
+ Sublime Text quiet chrome, tab top-line, single-line inline bar (Find panel), status bar
+ Sublime-quiet: almost no chrome, verb tabs with icon and label in the title bar, and every option squeezed into one inline bar above the queue, like Sublime's Find panel. The window is almost all files.
+
+
+
+ 18 Labelled Bar
+ Labelled tool-window bar (Android Studio / Fleet), JetBrains property rows, status bar
+ An activity bar with a small label under every icon, like IDE tool-window names, so it is easy to navigate without tooltips, next to a compact property panel. Friendliest for non-developers.
+
+
+
+ 19 Properties Window
+ Visual Studio Properties window (categorised grid, description pane), tool window title bars with tab wells, document tab toolbar
+ Classic Visual Studio docking: the queue is a document tab, and a Properties tool window holds the options as a categorised property grid with a description box at the bottom that explains the focused setting. Familiar, structured, explanation only on demand.
+
+
+
+ 20 Navigator
+ Xcode navigator selector bar and jump bar, JetBrains titled separators, VS Code status bar
+ Xcode's navigator selector bar: the verbs are a row of icons across the top of the left pane, which holds the queue below them. A jump bar names the target, and an inspector holds the options. A single-pane-per-job layout that is not any one IDE.
+
+
+
diff --git a/docs/mockups/terminal-v4/shots/01-activity-explorer.png b/docs/mockups/terminal-v4/shots/01-activity-explorer.png
new file mode 100644
index 0000000..90539d5
Binary files /dev/null and b/docs/mockups/terminal-v4/shots/01-activity-explorer.png differ
diff --git a/docs/mockups/terminal-v4/shots/02-settings-editor.png b/docs/mockups/terminal-v4/shots/02-settings-editor.png
new file mode 100644
index 0000000..8a2ddb9
Binary files /dev/null and b/docs/mockups/terminal-v4/shots/02-settings-editor.png differ
diff --git a/docs/mockups/terminal-v4/shots/03-twin-stripes.png b/docs/mockups/terminal-v4/shots/03-twin-stripes.png
new file mode 100644
index 0000000..0d34b73
Binary files /dev/null and b/docs/mockups/terminal-v4/shots/03-twin-stripes.png differ
diff --git a/docs/mockups/terminal-v4/shots/04-verb-tabs.png b/docs/mockups/terminal-v4/shots/04-verb-tabs.png
new file mode 100644
index 0000000..0b23700
Binary files /dev/null and b/docs/mockups/terminal-v4/shots/04-verb-tabs.png differ
diff --git a/docs/mockups/terminal-v4/shots/05-project-widget.png b/docs/mockups/terminal-v4/shots/05-project-widget.png
new file mode 100644
index 0000000..61e419e
Binary files /dev/null and b/docs/mockups/terminal-v4/shots/05-project-widget.png differ
diff --git a/docs/mockups/terminal-v4/shots/06-preferences-tree.png b/docs/mockups/terminal-v4/shots/06-preferences-tree.png
new file mode 100644
index 0000000..458dbc3
Binary files /dev/null and b/docs/mockups/terminal-v4/shots/06-preferences-tree.png differ
diff --git a/docs/mockups/terminal-v4/shots/07-zed-hairline.png b/docs/mockups/terminal-v4/shots/07-zed-hairline.png
new file mode 100644
index 0000000..13b7d09
Binary files /dev/null and b/docs/mockups/terminal-v4/shots/07-zed-hairline.png differ
diff --git a/docs/mockups/terminal-v4/shots/08-fleet-grid.png b/docs/mockups/terminal-v4/shots/08-fleet-grid.png
new file mode 100644
index 0000000..1d67aee
Binary files /dev/null and b/docs/mockups/terminal-v4/shots/08-fleet-grid.png differ
diff --git a/docs/mockups/terminal-v4/shots/09-walkthrough-steps.png b/docs/mockups/terminal-v4/shots/09-walkthrough-steps.png
new file mode 100644
index 0000000..30138ee
Binary files /dev/null and b/docs/mockups/terminal-v4/shots/09-walkthrough-steps.png differ
diff --git a/docs/mockups/terminal-v4/shots/10-explorer-details.png b/docs/mockups/terminal-v4/shots/10-explorer-details.png
new file mode 100644
index 0000000..9669e6e
Binary files /dev/null and b/docs/mockups/terminal-v4/shots/10-explorer-details.png differ
diff --git a/docs/mockups/terminal-v4/shots/11-nova-prefpane.png b/docs/mockups/terminal-v4/shots/11-nova-prefpane.png
new file mode 100644
index 0000000..623d798
Binary files /dev/null and b/docs/mockups/terminal-v4/shots/11-nova-prefpane.png differ
diff --git a/docs/mockups/terminal-v4/shots/12-options-sidebar.png b/docs/mockups/terminal-v4/shots/12-options-sidebar.png
new file mode 100644
index 0000000..cf02fe7
Binary files /dev/null and b/docs/mockups/terminal-v4/shots/12-options-sidebar.png differ
diff --git a/docs/mockups/terminal-v4/shots/13-run-configurations.png b/docs/mockups/terminal-v4/shots/13-run-configurations.png
new file mode 100644
index 0000000..a69d743
Binary files /dev/null and b/docs/mockups/terminal-v4/shots/13-run-configurations.png differ
diff --git a/docs/mockups/terminal-v4/shots/14-command-center.png b/docs/mockups/terminal-v4/shots/14-command-center.png
new file mode 100644
index 0000000..6d61c58
Binary files /dev/null and b/docs/mockups/terminal-v4/shots/14-command-center.png differ
diff --git a/docs/mockups/terminal-v4/shots/15-split-editor.png b/docs/mockups/terminal-v4/shots/15-split-editor.png
new file mode 100644
index 0000000..5b629e4
Binary files /dev/null and b/docs/mockups/terminal-v4/shots/15-split-editor.png differ
diff --git a/docs/mockups/terminal-v4/shots/16-state-tree.png b/docs/mockups/terminal-v4/shots/16-state-tree.png
new file mode 100644
index 0000000..538d00e
Binary files /dev/null and b/docs/mockups/terminal-v4/shots/16-state-tree.png differ
diff --git a/docs/mockups/terminal-v4/shots/17-sublime-quiet.png b/docs/mockups/terminal-v4/shots/17-sublime-quiet.png
new file mode 100644
index 0000000..a1b30c4
Binary files /dev/null and b/docs/mockups/terminal-v4/shots/17-sublime-quiet.png differ
diff --git a/docs/mockups/terminal-v4/shots/18-labelled-activity.png b/docs/mockups/terminal-v4/shots/18-labelled-activity.png
new file mode 100644
index 0000000..626d605
Binary files /dev/null and b/docs/mockups/terminal-v4/shots/18-labelled-activity.png differ
diff --git a/docs/mockups/terminal-v4/shots/19-properties-window.png b/docs/mockups/terminal-v4/shots/19-properties-window.png
new file mode 100644
index 0000000..6829593
Binary files /dev/null and b/docs/mockups/terminal-v4/shots/19-properties-window.png differ
diff --git a/docs/mockups/terminal-v4/shots/20-navigator-selector.png b/docs/mockups/terminal-v4/shots/20-navigator-selector.png
new file mode 100644
index 0000000..e506d52
Binary files /dev/null and b/docs/mockups/terminal-v4/shots/20-navigator-selector.png differ
diff --git a/docs/mockups/terminal-v4/shots/_index.png b/docs/mockups/terminal-v4/shots/_index.png
new file mode 100644
index 0000000..677e39e
Binary files /dev/null and b/docs/mockups/terminal-v4/shots/_index.png differ
diff --git a/docs/mockups/terminal-v5/01-a-refined.html b/docs/mockups/terminal-v5/01-a-refined.html
new file mode 100644
index 0000000..efa5a8c
--- /dev/null
+++ b/docs/mockups/terminal-v5/01-a-refined.html
@@ -0,0 +1,518 @@
+
+
+
+
+
+
+Filesmith Refined
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filesmith
+
+
+ Convert
+
+ Images
+
+
+
+
+
+
+
+ Convert 6 6
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+
+ Tools
+
+ Completed 142
+ Settings
+
+ Collapse Ctrl B
+
+
+
+
+
+
+
+
+
Name
+
Kind
+
Size
+
Result
+
Status
+
+
+
+
IMG_4821.HEIC
+
HEIC
+
3.2 MB
+
640 KB −80%
+
Done
+
+
+
+
product-shot.jpg
+
JPG
+
2.1 MB
+
486 KB −77%
+
Done
+
+
+
+
screenshot-2026-10-03.png
+
PNG
+
1.8 MB
+
62%
+
Converting
+
+
+
+
hero-banner@2x.png
+
PNG
+
4.6 MB
+
+
Queued
+
+
+
+
scan_0091.tiff
+
TIFF
+
22.4 MB
+
+
Unsupported compression
+
+
+
+
logo-final.bmp
+
BMP
+
1.1 MB
+
+
Queued
+
+
+
+
+
6 files
+
+
35.2 MB
+
5.3 MB 1.1 MB so far
+
2 of 6 done
+
+
+
+
+
+
+
+
+
+ Converting 3 of 62 done
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v5/02-b-sectioned-inspector.html b/docs/mockups/terminal-v5/02-b-sectioned-inspector.html
new file mode 100644
index 0000000..c5c13af
--- /dev/null
+++ b/docs/mockups/terminal-v5/02-b-sectioned-inspector.html
@@ -0,0 +1,455 @@
+
+
+
+
+
+
+Filesmith Sectioned
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filesmith
+
+
+ Convert
+
+ Images
+
+
+
+
+
+
+
+ Convert 6
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+
+ Tools
+
+ Completed 142
+ Settings
+
+ Collapse
+
+
+
+
+
+
+
+
Name
+
Kind
+
Size
+
Result
+
Status
+
+
+
+
IMG_4821.HEIC
+
HEIC
+
3.2 MB
+
640 KB −80%
+
Done
+
+
+
+
product-shot.jpg
+
JPG
+
2.1 MB
+
486 KB −77%
+
Done
+
+
+
+
screenshot-2026-10-03.png
+
PNG
+
1.8 MB
+
62%
+
Converting
+
+
+
+
hero-banner@2x.png
+
PNG
+
4.6 MB
+
+
Queued
+
+
+
+
scan_0091.tiff
+
TIFF
+
22.4 MB
+
+
Unsupported compression
+
+
+
+
logo-final.bmp
+
BMP
+
1.1 MB
+
+
Queued
+
+
+
+
+
6 files
+
+
35.2 MB
+
5.3 MB 1.1 MB so far
+
2 of 6 done
+
+
+
+
+
+
+
+
+
+
product-shot.jpg
+
JPG, 3000 × 2000, sRGB
+
2.1 MB486 KB −77%
+
+
+
+
+
+
+
+
+
+
+ Converting 3 of 6
+ 2 done
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v5/03-c-stacked-settings.html b/docs/mockups/terminal-v5/03-c-stacked-settings.html
new file mode 100644
index 0000000..b2f7694
--- /dev/null
+++ b/docs/mockups/terminal-v5/03-c-stacked-settings.html
@@ -0,0 +1,520 @@
+
+
+
+
+
+
+Filesmith Stacked Settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filesmith
+
+
+ Convert
+
+ Images
+
+
+
+
+
+
+
+
+
+
+ Convert 6
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+
+ Tools
+
+ Completed 142
+ Settings
+
+
+
+
+
+
+
+
+
+
+
+
IMG_4821.HEIC Done
+
HEIC 3.2 MB 640 KB −80%
+
+
+
+
+
+
product-shot.jpg Done
+
JPG 2.1 MB 486 KB −77%
+
+
+
+
+
+
screenshot-2026-10-03.png Converting
+
PNG 1.8 MB 62%
+
+
+
+
+
+
hero-banner@2x.png Queued
+
PNG 4.6 MB
+
+
+
+
+
+
scan_0091.tiff Unsupported compression
+
TIFF 22.4 MB Re-save the scan as LZW or uncompressed TIFF, then retry
+
+
+
+
+
+
logo-final.bmp Queued
+
BMP 1.1 MB
+
+
+
+
+
+
+ 6 files
+ 35.2 MB
+ 5.3 MB1.1 MB so far
+ 2 of 6 done
+
+
+
+
+
+
+
+ Ctrl+F
+
+
+
+
Convert These settings apply to all 6 files in the queue.
+
+ Format Output Naming Selected file
+
+
+
+
+
+ Output
+
+
+
Save to
+
Originals are never overwritten.
+
Next to source
+
+
+
Metadata
+
Keep metadata
+
Camera data, dates and colour profile.
+
+
+
+
+
+ Namingname.webp
+
+
+
File name
+
Clashing names get (2), (3) added.
+
Keep original name
+
+
+
+
+
+ Selected fileproduct-shot.jpg
+
+
+
+
+
Size 2.1 MB to 486 KB Dimensions 3000 × 2000 Output product-shot.webp
+
+
Compare before and after
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v5/04-d-preview-first.html b/docs/mockups/terminal-v5/04-d-preview-first.html
new file mode 100644
index 0000000..2f1669f
--- /dev/null
+++ b/docs/mockups/terminal-v5/04-d-preview-first.html
@@ -0,0 +1,511 @@
+
+
+
+
+
+
+Filesmith Preview First
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filesmith
+
+
+ Convert
+
+ Images
+
+
+
+
+
+
+
+ Convert 6 6
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+
+ Tools
+
+ Completed 142
+ Settings
+
+ Collapse Ctrl+B
+
+
+
+
+
+
+
+
Name
+
Kind
+
Size
+
Result
+
+
+
+
+
+
In progress1 62%
+
+
screenshot-2026-10-03.png
+
PNG
+
1.8 MB
+
62% Converting to WebP
+
+
+
+
+
Queued2 5.7 MB
+
+
hero-banner@2x.png
+
PNG
+
4.6 MB
+
Next up, to WebP
+
+
+
+
logo-final.bmp
+
BMP
+
1.1 MB
+
2nd in line, to WebP
+
+
+
+
+
Done2 5.3 MB1.1 MB
+
+
IMG_4821.HEIC
+
HEIC
+
3.2 MB
+
640 KB −80% IMG_4821.webp
+
+
+
+
product-shot.jpg
+
JPG
+
2.1 MB
+
486 KB −77% product-shot.webp
+
+
+
+
+
Failed1 Retry all
+
+
scan_0091.tiff
+
TIFF
+
22.4 MB
+
Unsupported compression LZW, 16-bit planar
+
+
+
+
+
+
6 files
+
+
35.2 MB
+
5.3 MB 1.1 MB so far 2 of 6 done
+
+
+
+
+
+
+
+
+
+
IMG_4821.HEIC
+
+
+
+
+
+
+
+
+
3.2 MB 640 KB −80%
+
4032 × 3024 sRGB metadata kept
+
+ Output IMG_4821.webp
+ Folder Pictures\2026-10
+ Took 0.8 s
+
+
Open Show in folder
+
+
+
+
Convert applies to all 6 files
+
Format
+
+ WebP JPG PNG AVIF JXL TIFF
+
+
+
Quality
+
+ Smaller Balanced Best
+
+
+
Save to
+ Next to source
+
+
Naming
+ name.webp if taken: (2)
+
+
+ Keep metadatacamera, dates
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v5/05-e-flush-panels.html b/docs/mockups/terminal-v5/05-e-flush-panels.html
new file mode 100644
index 0000000..0fab48e
--- /dev/null
+++ b/docs/mockups/terminal-v5/05-e-flush-panels.html
@@ -0,0 +1,511 @@
+
+
+
+
+
+
+Filesmith Flush
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filesmith
+
+
+ convert / images
+
+
+
+
+
+
+
+
+ operations
+
+
+
+
Convert 6
+
Compress
+
Resize
+
Upscale
+
Remove BG
+
Generate
+
+
Tools
+
+
+
+ Completed 142
+ Settings
+
+
+
+
+
+
+
+
+
+
name
+
kind
+
size
+
result
+
status
+
+
+
+
+
IMG_4821.HEIC
+
HEIC
+
3.2 MB
+
640 KB -80%
+
Done
+
+
+
+
+
product-shot.jpg
+
JPG
+
2.1 MB
+
486 KB -77%
+
Done
+
+
+
+
+
screenshot-2026-10-03.png
+
PNG
+
1.8 MB
+
~410 KB -77%
+
62% (4s)
+
+
+
+
+
hero-banner@2x.png
+
PNG
+
4.6 MB
+
+
Queued
+
+
+
+
+
scan_0091.tiff
+
TIFF
+
22.4 MB
+
+
Unsupported compression
+
+
+
+
+
logo-final.bmp
+
BMP
+
1.1 MB
+
+
Queued
+
+
+
+
+
+
6 files
+
+
35.2 MB
+
5.3 MB 1.1 MB so far
+
2 of 6 done
+
+
+
+
+
+
+
+
+ Converting 3 of 6
+ 2 of 6 done
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v5/05-e1-boxed.html b/docs/mockups/terminal-v5/05-e1-boxed.html
new file mode 100644
index 0000000..1deba40
--- /dev/null
+++ b/docs/mockups/terminal-v5/05-e1-boxed.html
@@ -0,0 +1,528 @@
+
+
+
+
+
+
+Filesmith Flush Boxed
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filesmith
+
+
+ convert / images
+
+
+
+
+
+
+
+
+ operations
+
+
+
+
Convert 6
+
Compress
+
Resize
+
Upscale
+
Remove BG
+
Generate
+
+
Tools
+
+
+
+ Completed 142
+ Settings
+
+
+
+
+
+
+
+
+
+
name
+
kind
+
size
+
result
+
status
+
+
+
+
+
IMG_4821.HEIC
+
HEIC
+
3.2 MB
+
640 KB -80%
+
Done
+
+
+
+
+
product-shot.jpg
+
JPG
+
2.1 MB
+
486 KB -77%
+
Done
+
+
+
+
+
screenshot-2026-10-03.png
+
PNG
+
1.8 MB
+
~410 KB -77%
+
62% (4s)
+
+
+
+
+
hero-banner@2x.png
+
PNG
+
4.6 MB
+
+
Queued
+
+
+
+
+
scan_0091.tiff
+
TIFF
+
22.4 MB
+
+
Unsupported compression
+
+
+
+
+
logo-final.bmp
+
BMP
+
1.1 MB
+
+
Queued
+
+
+
+
+
+
6 files
+
+
35.2 MB
+
5.3 MB 1.1 MB so far
+
2 of 6 done
+
+
+
+
+
+
+
+
+ Converting 3 of 6
+ 2 of 6 done
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v5/05-e2-raised.html b/docs/mockups/terminal-v5/05-e2-raised.html
new file mode 100644
index 0000000..80e1a8d
--- /dev/null
+++ b/docs/mockups/terminal-v5/05-e2-raised.html
@@ -0,0 +1,529 @@
+
+
+
+
+
+
+Filesmith Flush
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filesmith
+
+
+ convert / images
+
+
+
+
+
+
+
+
+ operations
+
+
+
+
Convert 6
+
Compress
+
Resize
+
Upscale
+
Remove BG
+
Generate
+
+
Tools
+
+
+
+ Completed 142
+ Settings
+
+
+
+
+
+
+
+
+
+
name
+
kind
+
size
+
result
+
status
+
+
+
+
+
IMG_4821.HEIC
+
HEIC
+
3.2 MB
+
640 KB -80%
+
Done
+
+
+
+
+
product-shot.jpg
+
JPG
+
2.1 MB
+
486 KB -77%
+
Done
+
+
+
+
+
screenshot-2026-10-03.png
+
PNG
+
1.8 MB
+
~410 KB -77%
+
62% (4s)
+
+
+
+
+
hero-banner@2x.png
+
PNG
+
4.6 MB
+
+
Queued
+
+
+
+
+
scan_0091.tiff
+
TIFF
+
22.4 MB
+
+
Unsupported compression
+
+
+
+
+
logo-final.bmp
+
BMP
+
1.1 MB
+
+
Queued
+
+
+
+
+
+
6 files
+
+
35.2 MB
+
5.3 MB 1.1 MB so far
+
2 of 6 done
+
+
+
+
+
+
+
+
+ Converting 3 of 6
+ 2 of 6 done
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v5/05-e3-bracketed.html b/docs/mockups/terminal-v5/05-e3-bracketed.html
new file mode 100644
index 0000000..3cbd420
--- /dev/null
+++ b/docs/mockups/terminal-v5/05-e3-bracketed.html
@@ -0,0 +1,535 @@
+
+
+
+
+
+
+Filesmith Bracketed
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filesmith
+
+
+ convert / images
+
+
+
+
+
+
+
+
+ operations
+
+
+
+
Convert 6
+
Compress
+
Resize
+
Upscale
+
Remove BG
+
Generate
+
+
Tools
+
+
+
+ Completed 142
+ Settings
+
+
+
+
+
+
+
+
+
+
name
+
kind
+
size
+
result
+
status
+
+
+
+
+
IMG_4821.HEIC
+
HEIC
+
3.2 MB
+
640 KB -80%
+
Done
+
+
+
+
+
product-shot.jpg
+
JPG
+
2.1 MB
+
486 KB -77%
+
Done
+
+
+
+
+
screenshot-2026-10-03.png
+
PNG
+
1.8 MB
+
~410 KB -77%
+
62% (4s)
+
+
+
+
+
hero-banner@2x.png
+
PNG
+
4.6 MB
+
+
Queued
+
+
+
+
+
scan_0091.tiff
+
TIFF
+
22.4 MB
+
+
Unsupported compression
+
+
+
+
+
logo-final.bmp
+
BMP
+
1.1 MB
+
+
Queued
+
+
+
+
+
+
6 files
+
+
35.2 MB
+
5.3 MB 1.1 MB so far
+
2 of 6 done
+
+
+
+
+
+
+
+
+ Converting 3 of 6
+ 2 of 6 done
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v5/06-t1-table-boxed.html b/docs/mockups/terminal-v5/06-t1-table-boxed.html
new file mode 100644
index 0000000..cea70b8
--- /dev/null
+++ b/docs/mockups/terminal-v5/06-t1-table-boxed.html
@@ -0,0 +1,572 @@
+
+
+
+
+
+
+Filesmith Table Boxed
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filesmith
+
+
+ convert / images
+
+
+
+
+
+
+
+
+ operations
+
+
+
+
Convert 6
+
Compress
+
Resize
+
Upscale
+
Remove BG
+
Generate
+
+
Tools
+
+
+
+ Completed 142
+ Settings
+
+
+
+
+
+
+
+
+
+
name
+
kind
+
size
+
result
+
status
+
+
+
+
+
IMG_4821.HEIC
+
HEIC
+
3.2 MB
+
640 KB -80%
+
Done
+
+
+
+
+
product-shot.jpg
+
JPG
+
2.1 MB
+
486 KB -77%
+
Done
+
+
+
+
+
screenshot-2026-10-03.png
+
PNG
+
1.8 MB
+
~410 KB -77%
+
62% (4s)
+
+
+
+
+
hero-banner@2x.png
+
PNG
+
4.6 MB
+
+
Queued
+
+
+
+
+
scan_0091.tiff
+
TIFF
+
22.4 MB
+
+
Unsupported compression
+
+
+
+
+
logo-final.bmp
+
BMP
+
1.1 MB
+
+
Queued
+
+
+
+
+
+
6 files
+
+
35.2 MB
+
5.3 MB 1.1 MB so far
+
2 of 6 done
+
+
+
+
+
+
+
+
+ Converting 3 of 6
+ 2 of 6 done
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v5/07-t2-table-filled.html b/docs/mockups/terminal-v5/07-t2-table-filled.html
new file mode 100644
index 0000000..5c2dfeb
--- /dev/null
+++ b/docs/mockups/terminal-v5/07-t2-table-filled.html
@@ -0,0 +1,546 @@
+
+
+
+
+
+
+Filesmith Table Filled
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filesmith
+
+
+ convert / images
+
+
+
+
+
+
+
+
+ operations
+
+
+
+
Convert 6
+
Compress
+
Resize
+
Upscale
+
Remove BG
+
Generate
+
+
Tools
+
+
+
+ Completed 142
+ Settings
+
+
+
+
+
+
+
+
+
+
name
+
kind
+
size
+
result
+
status
+
+
+
+
+
IMG_4821.HEIC
+
HEIC
+
3.2 MB
+
640 KB -80%
+
Done
+
+
+
+
+
product-shot.jpg
+
JPG
+
2.1 MB
+
486 KB -77%
+
Done
+
+
+
+
+
screenshot-2026-10-03.png
+
PNG
+
1.8 MB
+
~410 KB -77%
+
62% (4s)
+
+
+
+
+
hero-banner@2x.png
+
PNG
+
4.6 MB
+
+
Queued
+
+
+
+
+
scan_0091.tiff
+
TIFF
+
22.4 MB
+
+
Unsupported compression
+
+
+
+
+
logo-final.bmp
+
BMP
+
1.1 MB
+
+
Queued
+
+
+
+
+
+
6 files
+
+
35.2 MB
+
5.3 MB 1.1 MB so far
+
2 of 6 done
+
+
+
+
+
+
+
+
+ Converting 3 of 6
+ 2 of 6 done
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v5/08-t3-table-zebra.html b/docs/mockups/terminal-v5/08-t3-table-zebra.html
new file mode 100644
index 0000000..e1fd90a
--- /dev/null
+++ b/docs/mockups/terminal-v5/08-t3-table-zebra.html
@@ -0,0 +1,631 @@
+
+
+
+
+
+
+Filesmith Zebra Grid
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filesmith
+
+
+ convert / images
+
+
+
+
+
+
+
+
+ operations
+
+
+
+
Convert 6
+
Compress
+
Resize
+
Upscale
+
Remove BG
+
Generate
+
+
Tools
+
+
+
+ Completed 142
+ Settings
+
+
+
+
+
+
+
+
+
+
name
+
kind
+
size
+
result
+
status
+
+
+
+
+
IMG_4821.HEIC
+
HEIC
+
3.2 MB
+
640 KB -80%
+
Done
+
+
+
+
+
product-shot.jpg
+
JPG
+
2.1 MB
+
486 KB -77%
+
Done
+
+
+
+
+
screenshot-2026-10-03.png
+
PNG
+
1.8 MB
+
~410 KB -77%
+
62% (4s)
+
+
+
+
+
hero-banner@2x.png
+
PNG
+
4.6 MB
+
+
Queued
+
+
+
+
+
scan_0091.tiff
+
TIFF
+
22.4 MB
+
+
Unsupported compression
+
+
+
+
+
logo-final.bmp
+
BMP
+
1.1 MB
+
+
Queued
+
+
+
+
+
+
6 files
+
+
35.2 MB
+
5.3 MB 1.1 MB so far
+
2 of 6 done
+
+
+
+
+
+
+
+
+ Converting 3 of 6
+ 2 of 6 done
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v5/09-s1-vscode-classic.html b/docs/mockups/terminal-v5/09-s1-vscode-classic.html
new file mode 100644
index 0000000..072d706
--- /dev/null
+++ b/docs/mockups/terminal-v5/09-s1-vscode-classic.html
@@ -0,0 +1,576 @@
+
+
+
+
+
+
+Filesmith VS Code Classic
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filesmith
+
+
+ convert / images
+
+
+
+
+
+
+
+
+ operations
+
+
+
+
Convert 6
+
Compress
+
Resize
+
Upscale
+
Remove BG
+
Generate
+
+
Tools
+
+
+
+ Completed 142
+ Settings
+
+
+
+
+
+
+
+
+
+
name
+
kind
+
size
+
result
+
status
+
+
+
+
+
IMG_4821.HEIC
+
HEIC
+
3.2 MB
+
640 KB -80%
+
Done
+
+
+
+
+
product-shot.jpg
+
JPG
+
2.1 MB
+
486 KB -77%
+
Done
+
+
+
+
+
screenshot-2026-10-03.png
+
PNG
+
1.8 MB
+
~410 KB -77%
+
62% (4s)
+
+
+
+
+
hero-banner@2x.png
+
PNG
+
4.6 MB
+
+
Queued
+
+
+
+
+
scan_0091.tiff
+
TIFF
+
22.4 MB
+
+
Unsupported compression
+
+
+
+
+
logo-final.bmp
+
BMP
+
1.1 MB
+
+
Queued
+
+
+
+
+
+
6 files
+
+
35.2 MB
+
5.3 MB 1.1 MB so far
+
2 of 6 done
+
+
+
+
+
+
+
+
+ Converting 3 of 6
+ 2 of 6 done
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v5/10-s2-vscode-grouped.html b/docs/mockups/terminal-v5/10-s2-vscode-grouped.html
new file mode 100644
index 0000000..c87e5c7
--- /dev/null
+++ b/docs/mockups/terminal-v5/10-s2-vscode-grouped.html
@@ -0,0 +1,602 @@
+
+
+
+
+
+
+Filesmith Settings Grouped
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filesmith
+
+
+ convert / images
+
+
+
+
+
+
+
+
+ operations
+
+
+
+
Convert 6
+
Compress
+
Resize
+
Upscale
+
Remove BG
+
Generate
+
+
Tools
+
+
+
+ Completed 142
+ Settings
+
+
+
+
+
+
+
+
+
+
name
+
kind
+
size
+
result
+
status
+
+
+
+
+
IMG_4821.HEIC
+
HEIC
+
3.2 MB
+
640 KB -80%
+
Done
+
+
+
+
+
product-shot.jpg
+
JPG
+
2.1 MB
+
486 KB -77%
+
Done
+
+
+
+
+
screenshot-2026-10-03.png
+
PNG
+
1.8 MB
+
~410 KB -77%
+
62% (4s)
+
+
+
+
+
hero-banner@2x.png
+
PNG
+
4.6 MB
+
+
Queued
+
+
+
+
+
scan_0091.tiff
+
TIFF
+
22.4 MB
+
+
Unsupported compression
+
+
+
+
+
logo-final.bmp
+
BMP
+
1.1 MB
+
+
Queued
+
+
+
+
+
+
6 files
+
+
35.2 MB
+
5.3 MB 1.1 MB so far
+
2 of 6 done
+
+
+
+
+
+
+
+
+ Converting 3 of 6
+ 2 of 6 done
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v5/11-s3-vscode-compact.html b/docs/mockups/terminal-v5/11-s3-vscode-compact.html
new file mode 100644
index 0000000..d2cc2be
--- /dev/null
+++ b/docs/mockups/terminal-v5/11-s3-vscode-compact.html
@@ -0,0 +1,588 @@
+
+
+
+
+
+
+Filesmith VS Code Compact
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filesmith
+
+
+ convert / images
+
+
+
+
+
+
+
+
+ operations
+
+
+
+
Convert 6
+
Compress
+
Resize
+
Upscale
+
Remove BG
+
Generate
+
+
Tools
+
+
+
+ Completed 142
+ Settings
+
+
+
+
+
+
+
+
+
+
name
+
kind
+
size
+
result
+
status
+
+
+
+
+
IMG_4821.HEIC
+
HEIC
+
3.2 MB
+
640 KB -80%
+
Done
+
+
+
+
+
product-shot.jpg
+
JPG
+
2.1 MB
+
486 KB -77%
+
Done
+
+
+
+
+
screenshot-2026-10-03.png
+
PNG
+
1.8 MB
+
~410 KB -77%
+
62% (4s)
+
+
+
+
+
hero-banner@2x.png
+
PNG
+
4.6 MB
+
+
Queued
+
+
+
+
+
scan_0091.tiff
+
TIFF
+
22.4 MB
+
+
Unsupported compression
+
+
+
+
+
logo-final.bmp
+
BMP
+
1.1 MB
+
+
Queued
+
+
+
+
+
+
6 files
+
+
35.2 MB
+
5.3 MB 1.1 MB so far
+
2 of 6 done
+
+
+
+
+
+
+
+
+ Converting 3 of 6
+ 2 of 6 done
+
+
+
+
+
+
diff --git a/docs/mockups/terminal-v5/index.html b/docs/mockups/terminal-v5/index.html
new file mode 100644
index 0000000..bb9b4cc
--- /dev/null
+++ b/docs/mockups/terminal-v5/index.html
@@ -0,0 +1,72 @@
+
+
+Filesmith Explorer Variants
+
+Filesmith / terminal-v5 / explorer-details variants
+5 takes on terminal-v4 10-explorer-details. Every one has a collapsible left sidebar that shows labels and icons, and none has the files-added line at the top. They differ in the right-side panel and the main view. Each row shows the sidebar expanded and collapsed. Click to open the mockup.
+
+
+
+ Closest to the original: the explorer-details layout with a collapsible labelled sidebar and no files-added line at the top.
+
+
+
+
+ Right panel without top tabs: one scrolling inspector with JetBrains-style titled separators (FORMAT, QUALITY, OUTPUT, NAMING), label-left/control-right rows and a small summary of the selected file.
+
+
+
+
+ Right panel like the VS Code settings editor: each setting stacked (bold title, grey description, full-width control), generous rhythm, collapsible groups.
+
+
+
+
+ Right panel opens with a compact before/after preview of the selected file, with the options below it.
+
+
+
+
+ Flatter and more brutalist: no rounded corners, panels split by 1px lines only, segmented controls without fills (selected = underline or inverted text).
+
+
+
+
diff --git a/docs/mockups/terminal-v5/shots/01-a-refined-collapsed.png b/docs/mockups/terminal-v5/shots/01-a-refined-collapsed.png
new file mode 100644
index 0000000..7015710
Binary files /dev/null and b/docs/mockups/terminal-v5/shots/01-a-refined-collapsed.png differ
diff --git a/docs/mockups/terminal-v5/shots/01-a-refined.png b/docs/mockups/terminal-v5/shots/01-a-refined.png
new file mode 100644
index 0000000..7016974
Binary files /dev/null and b/docs/mockups/terminal-v5/shots/01-a-refined.png differ
diff --git a/docs/mockups/terminal-v5/shots/02-b-sectioned-inspector-collapsed.png b/docs/mockups/terminal-v5/shots/02-b-sectioned-inspector-collapsed.png
new file mode 100644
index 0000000..3afadc0
Binary files /dev/null and b/docs/mockups/terminal-v5/shots/02-b-sectioned-inspector-collapsed.png differ
diff --git a/docs/mockups/terminal-v5/shots/02-b-sectioned-inspector.png b/docs/mockups/terminal-v5/shots/02-b-sectioned-inspector.png
new file mode 100644
index 0000000..ae2dc2d
Binary files /dev/null and b/docs/mockups/terminal-v5/shots/02-b-sectioned-inspector.png differ
diff --git a/docs/mockups/terminal-v5/shots/03-c-stacked-settings-collapsed.png b/docs/mockups/terminal-v5/shots/03-c-stacked-settings-collapsed.png
new file mode 100644
index 0000000..ae7c74b
Binary files /dev/null and b/docs/mockups/terminal-v5/shots/03-c-stacked-settings-collapsed.png differ
diff --git a/docs/mockups/terminal-v5/shots/03-c-stacked-settings.png b/docs/mockups/terminal-v5/shots/03-c-stacked-settings.png
new file mode 100644
index 0000000..4fc5c07
Binary files /dev/null and b/docs/mockups/terminal-v5/shots/03-c-stacked-settings.png differ
diff --git a/docs/mockups/terminal-v5/shots/04-d-preview-first-collapsed.png b/docs/mockups/terminal-v5/shots/04-d-preview-first-collapsed.png
new file mode 100644
index 0000000..a6149d9
Binary files /dev/null and b/docs/mockups/terminal-v5/shots/04-d-preview-first-collapsed.png differ
diff --git a/docs/mockups/terminal-v5/shots/04-d-preview-first.png b/docs/mockups/terminal-v5/shots/04-d-preview-first.png
new file mode 100644
index 0000000..083a154
Binary files /dev/null and b/docs/mockups/terminal-v5/shots/04-d-preview-first.png differ
diff --git a/docs/mockups/terminal-v5/shots/05-e-flush-panels-collapsed.png b/docs/mockups/terminal-v5/shots/05-e-flush-panels-collapsed.png
new file mode 100644
index 0000000..f3e4eea
Binary files /dev/null and b/docs/mockups/terminal-v5/shots/05-e-flush-panels-collapsed.png differ
diff --git a/docs/mockups/terminal-v5/shots/05-e-flush-panels.png b/docs/mockups/terminal-v5/shots/05-e-flush-panels.png
new file mode 100644
index 0000000..cbbc564
Binary files /dev/null and b/docs/mockups/terminal-v5/shots/05-e-flush-panels.png differ
diff --git a/docs/mockups/terminal-v5/shots/05-e1-boxed.png b/docs/mockups/terminal-v5/shots/05-e1-boxed.png
new file mode 100644
index 0000000..dd8f575
Binary files /dev/null and b/docs/mockups/terminal-v5/shots/05-e1-boxed.png differ
diff --git a/docs/mockups/terminal-v5/shots/05-e2-raised.png b/docs/mockups/terminal-v5/shots/05-e2-raised.png
new file mode 100644
index 0000000..ea070f3
Binary files /dev/null and b/docs/mockups/terminal-v5/shots/05-e2-raised.png differ
diff --git a/docs/mockups/terminal-v5/shots/05-e3-bracketed.png b/docs/mockups/terminal-v5/shots/05-e3-bracketed.png
new file mode 100644
index 0000000..80124dc
Binary files /dev/null and b/docs/mockups/terminal-v5/shots/05-e3-bracketed.png differ
diff --git a/docs/mockups/terminal-v5/shots/06-t1-table-boxed.png b/docs/mockups/terminal-v5/shots/06-t1-table-boxed.png
new file mode 100644
index 0000000..6f50041
Binary files /dev/null and b/docs/mockups/terminal-v5/shots/06-t1-table-boxed.png differ
diff --git a/docs/mockups/terminal-v5/shots/07-t2-table-filled.png b/docs/mockups/terminal-v5/shots/07-t2-table-filled.png
new file mode 100644
index 0000000..13d200b
Binary files /dev/null and b/docs/mockups/terminal-v5/shots/07-t2-table-filled.png differ
diff --git a/docs/mockups/terminal-v5/shots/08-t3-table-zebra.png b/docs/mockups/terminal-v5/shots/08-t3-table-zebra.png
new file mode 100644
index 0000000..ec50223
Binary files /dev/null and b/docs/mockups/terminal-v5/shots/08-t3-table-zebra.png differ
diff --git a/docs/mockups/terminal-v5/shots/09-s1-vscode-classic.png b/docs/mockups/terminal-v5/shots/09-s1-vscode-classic.png
new file mode 100644
index 0000000..c69538a
Binary files /dev/null and b/docs/mockups/terminal-v5/shots/09-s1-vscode-classic.png differ
diff --git a/docs/mockups/terminal-v5/shots/10-s2-vscode-grouped.png b/docs/mockups/terminal-v5/shots/10-s2-vscode-grouped.png
new file mode 100644
index 0000000..ff50f70
Binary files /dev/null and b/docs/mockups/terminal-v5/shots/10-s2-vscode-grouped.png differ
diff --git a/docs/mockups/terminal-v5/shots/11-s3-vscode-compact.png b/docs/mockups/terminal-v5/shots/11-s3-vscode-compact.png
new file mode 100644
index 0000000..5da33dc
Binary files /dev/null and b/docs/mockups/terminal-v5/shots/11-s3-vscode-compact.png differ
diff --git a/docs/mockups/terminal-v5/shots/_index.png b/docs/mockups/terminal-v5/shots/_index.png
new file mode 100644
index 0000000..27d48dc
Binary files /dev/null and b/docs/mockups/terminal-v5/shots/_index.png differ
diff --git a/docs/mockups/terminal-v5/shots/impl-10-s2.png b/docs/mockups/terminal-v5/shots/impl-10-s2.png
new file mode 100644
index 0000000..34c1d9b
Binary files /dev/null and b/docs/mockups/terminal-v5/shots/impl-10-s2.png differ
diff --git a/docs/mockups/terminal-v5/shots/impl-collapsed.png b/docs/mockups/terminal-v5/shots/impl-collapsed.png
new file mode 100644
index 0000000..1f5e735
Binary files /dev/null and b/docs/mockups/terminal-v5/shots/impl-collapsed.png differ
diff --git a/docs/mockups/terminal-v5/shots/impl-completed.png b/docs/mockups/terminal-v5/shots/impl-completed.png
new file mode 100644
index 0000000..5cec0d2
Binary files /dev/null and b/docs/mockups/terminal-v5/shots/impl-completed.png differ
diff --git a/docs/mockups/terminal-v5/shots/impl-empty.png b/docs/mockups/terminal-v5/shots/impl-empty.png
new file mode 100644
index 0000000..2f36ef7
Binary files /dev/null and b/docs/mockups/terminal-v5/shots/impl-empty.png differ
diff --git a/docs/mockups/terminal-v5/shots/impl-generate.png b/docs/mockups/terminal-v5/shots/impl-generate.png
new file mode 100644
index 0000000..4a89abc
Binary files /dev/null and b/docs/mockups/terminal-v5/shots/impl-generate.png differ
diff --git a/docs/mockups/terminal-v5/shots/impl-info.png b/docs/mockups/terminal-v5/shots/impl-info.png
new file mode 100644
index 0000000..15619f2
Binary files /dev/null and b/docs/mockups/terminal-v5/shots/impl-info.png differ
diff --git a/docs/mockups/terminal-v5/shots/impl-menu.png b/docs/mockups/terminal-v5/shots/impl-menu.png
new file mode 100644
index 0000000..87743bc
Binary files /dev/null and b/docs/mockups/terminal-v5/shots/impl-menu.png differ
diff --git a/docs/mockups/terminal-v5/shots/impl-preview.png b/docs/mockups/terminal-v5/shots/impl-preview.png
new file mode 100644
index 0000000..8fc6514
Binary files /dev/null and b/docs/mockups/terminal-v5/shots/impl-preview.png differ
diff --git a/docs/mockups/terminal-v5/shots/impl-settings.png b/docs/mockups/terminal-v5/shots/impl-settings.png
new file mode 100644
index 0000000..479cc1e
Binary files /dev/null and b/docs/mockups/terminal-v5/shots/impl-settings.png differ
diff --git a/docs/mockups/terminal-v5/shots/impl-tools.png b/docs/mockups/terminal-v5/shots/impl-tools.png
new file mode 100644
index 0000000..198948f
Binary files /dev/null and b/docs/mockups/terminal-v5/shots/impl-tools.png differ
diff --git a/docs/mockups/terminal/01-wind-native.html b/docs/mockups/terminal/01-wind-native.html
new file mode 100644
index 0000000..64fb829
--- /dev/null
+++ b/docs/mockups/terminal/01-wind-native.html
@@ -0,0 +1,440 @@
+
+
+
+
+
+
+Filesmith · Wind Native
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Search actions Ctrl K
+
+
+
+ Convert8
+
+ Compress
+
+ Resize
+
+ Upscale
+
+ Remove BG
+
+ Generate
+
+ Tools
+
+
+
+
+
+
+
+
+
+
+
+
Convert
+
Change format
+
+
8 files 4 groups
+
+
+
+
+
+
+
+
Drop files here or browse
+
Add files Ctrl O
+
+
+
+
+
Images 4 images selected
+ Deselect
+
+
+
+
IMG_4821.HEIC
HEIC → WebP · 3.2 MB
+
3.2 MB → 640 KB -80%
+
+
+
+
+
+
screenshot-2026-10-03 141522.png
PNG → WebP · 1.8 MB
+
62% ETA 4 s
+
+
+
+
+
+
+
hero-banner@2x.png
PNG → WebP · 4.6 MB
+
Queued
+
+
+
+
+
+
scan_receipt_0091.tiff
TIFF → WebP · 22.4 MB
+
Unsupported TIFF compression (JPEG-in-TIFF, old-style)
+
+
+
+
+
+
+
Video 1 video Different group, click to switch
+
+
MOV
+
screen-recording-standup.mov
MOV → MP4 · 812 MB
+
18.4% ETA 3 min 12 s
+
+
+
+
+
+
+
+
Documents 2 documents
+
+
PDF
+
Q3-board-deck.pdf
PDF → DOCX · 14.1 MB
+
Queued
+
+
+
+
TXT
+
release-notes-0.3.txt
TXT → PDF · 6 KB
+
6 KB → 48 KB
+
+
+
+
+
+
+
+
Archives 1 archive
+
+
CBZ
+
One-Piece-v104.cbz
CBZ → CB7 · 186 MB
+
Queued
+
+
+
+
+
+
+
+
+
Images · 4 selected
+
Settings apply to the selected group only.
+
+
+
+
Target format .webp
+
+ PNG
+ JPG
+ WebP
+ AVIF
+ JXL
+ TIFF
+ BMP
+ GIF
+ ICO
+
+
+
+
+
Quality
+
+ Smaller Balanced Best
+
+
WebP q82. Good for photos and screenshots.
+
+
+
+
Output Next to source
+
+
Never overwrites. Collisions save as name (converted).webp, then (2).
+
+
+
+
+
+
Selected sources 32.0 MB in
+
+ HEIC 1 · 3.2 MB
+ PNG 2 · 6.4 MB
+ TIFF 1 · 22.4 MB
+ Engine ImageMagick 7.1.1
+
+
+
+
+ Clear finished
+
+ Convert 4 images Ctrl ↵
+
+
+
+
+
+
+
+
+ 41%
+ 2 of 8 done · 2 running · 1 failed
+
+ RTX 5090 · Real-ESRGAN ready
+
+ ffmpeg 7.1 ImageMagick 7.1.1 mutool 1.24 7-Zip 24.08 Rar.exe not found
+
+
+
+
diff --git a/docs/mockups/terminal/02-wind-dense-inspector.html b/docs/mockups/terminal/02-wind-dense-inspector.html
new file mode 100644
index 0000000..e7aad97
--- /dev/null
+++ b/docs/mockups/terminal/02-wind-dense-inspector.html
@@ -0,0 +1,483 @@
+
+
+
+
+
+Filesmith · Dense Inspector
+
+
+
+
+
+
+
+
+
+ convert / images
+
+
+
+
+
+
+
+
+
+
+ Operations edit
+
+ convert 8
+ compress ^2
+ resize ^3
+ upscale ^4
+ remove-bg ^5
+ generate ^6
+ tools ^7
+
+
+
+
completed 142
+
filesmith 0.3.0
+
+
+
+
+
+
+
Convert
+ Change format
+ 8 files
+
+ select groupCtrl A
+ clear finished
+
+
+
+
+
add files Ctrl O or drop anywhere in this window
+
+
images · video · audio · pdf · docs · archives
+
+
+
+
+
+
+
Name
+
Size
+
Route
+
Status
+
Progress
+
+
+
+
+
+
+
Images4 images · active group
+
31.9 MB · → WebP
+
+
+
+
+
HEIC
+
IMG_4821.HEIC
+
3.2 MB
+
HEIC→ WebP
+
3.2M → 640K -80%
+
done
+
+
+
+
+
+
PNG
+
screenshot-2026-10-03 141522.png
+
1.8 MB
+
PNG→ WebP
+
running · eta 4s
+
62%
+
+
+
+
+
+
PNG
+
hero-banner@2x.png
+
4.6 MB
+
PNG→ WebP
+
queued · #1
+
–
+
+
+
+
+
+
TIFF
+
scan_receipt_0091.tiff failed Unsupported TIFF compression (JPEG-in-TIFF, old-style). Re-save as standard TIFF or PNG and retry.
+
22.4 MB
+
TIFF→ WebP
+
failed · Unsupported TIFF compression (JPEG-in-TIFF, old-style)
+
err
+
+
+
+
+
+
+ Video1 video · select to switch group
+ 812 MB · → MP4
+
+
+
+ MOV
+ screen-recording-standup.mov
+ 812 MB
+ MOV→ MP4
+ running · eta 3m 12s
+ 18.4%
+
+
+
+
+
+
+ Documents2 documents
+ 14.1 MB · mixed
+
+
+
+ PDF
+ Q3-board-deck.pdf
+ 14.1 MB
+ PDF→ DOCX
+ queued
+ –
+
+
+
+
+ TXT
+ release-notes-0.3.txt
+ 6 KB
+ TXT→ PDF
+ 6K → 48K +700%
+ done
+
+
+
+
+
+
+
+ Archives1 archive
+ 186 MB · → CB7
+
+
+
+ CBZ
+ One-Piece-v104.cbz
+ 186 MB
+ CBZ→ CB7
+ queued
+ –
+
+
+
+
drop more files anywhere · they join the matching group
+
+
+
+
+
+
+
Images · 4 selected convert group 1/4
+
+
+
Target format webp
+
+ PNG
+ JPG
+ WebP
+ AVIF
+ JXL
+ TIFF
+ BMP
+ GIF
+ ICO
+
+
PNG and TIFF are disabled: 2 selected files already use them.
+
+
+
+
Quality q 82
+
+ Smaller
+ Balanced
+ Best
+
+
+
+
+
Output
+
Next to source
+
Change folder…D:\Photos\2026-10\
+
Never overwrites. Collisions save as name (converted).webp, then (2).
+
+
+
+
Selection
+
+ Files 4 images
+ Input 31.9 MB
+ Est. output ~6.1 MB
+ Engine ImageMagick 7.1.1
+
+
+
+
+
Focused row 2 / 4
+
+ File screenshot-2026-10-03 141522.png
+ Dimensions 2880 × 1800
+ Progress 62% · eta 4s
+ Writes …141522.webp
+
+
+
+
+
Convert 4 images Ctrl ↵
+
1 done · 1 run · 1 fail skips done rows
+
+
+
+
+
+
+ 41%
+ 2 of 8 done · 2 running · 1 failed
+
+
+ ffmpeg 7.1 · ImageMagick 7.1.1 · mutool 1.24 · 7-Zip 24.08 · Rar.exe not found
+
+ RTX 5090 · Real-ESRGAN ready
+
+
+
+
diff --git a/docs/mockups/terminal/03-tmux-panes.html b/docs/mockups/terminal/03-tmux-panes.html
new file mode 100644
index 0000000..7bdce06
--- /dev/null
+++ b/docs/mockups/terminal/03-tmux-panes.html
@@ -0,0 +1,561 @@
+
+
+
+
+
+
+Filesmith, Tmux Panes
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filesmith
+ session filesmith : window 0:convert
+
+ Alt 0-6 switch verb Tab next pane
+
+
+
+
+
+
+
+ ─ verbs ─
+ 0 convert 8 Change format
+ 1 compress Shrink file size
+ 2 resize Scale by percent or exact size
+ 3 upscale Enlarge 2x to 4x with AI
+ 4 remove bg Cut the subject out with AI
+ 5 generate Create an image from a text prompt
+ 6 tools One-off jobs, PDF split, merge, burst…
+
+
+ c completed 142 Everything Filesmith has produced
+
+
+
+
+
+
+
+ ─ queue [8] ─
+
+
Convert
+ Change format
+ 8 files
+
+
+ + add files or drop them anywhere in this pane
+ Ctrl O
+
+
+
+
+
── images (4) · selected
+
+
+ ✓ heic
+ IMG_4821.HEIC 3.2M
+ heic→ webp
+ 3.2M → 640K -80%
+ ⋯
+
+
+ ▸ png
+ screenshot-2026-10-03 141522.png 1.8M
+ png→ webp
+ [██████▌···· ] 62% eta 4s
+ ⋯
+
+
+
+ · png
+ hero-banner@2x.png 4.6M
+ png→ webp
+ queued
+ ⋯
+
+
+
+ ✗ tiff
+ scan_receipt_0091.tiff 22.4M
+ tiff→ webp
+ failed
+ ⋯
+
+
└ Unsupported TIFF compression (JPEG-in-TIFF, old-style) retry
+
+
── video (1) · other group, not in selection
+
+
+ ▸ mov
+ screen-recording-standup.mov 812M
+ mov→ mp4
+ [██········· ] 18.4% eta 3m12s
+ ⋯
+
+
+
── documents (2)
+
+
+ · pdf
+ Q3-board-deck.pdf 14.1M
+ pdf→ docx
+ queued
+ ⋯
+
+
+
+ ✓ txt
+ release-notes-0.3.txt 6K
+ txt→ pdf
+ 6K → 48K
+ ⋯
+
+
+
+
── archives (1)
+
+
+ · cbz
+ One-Piece-v104.cbz 186M
+ cbz→ cb7
+ queued
+ ⋯
+
+
+
+
+
+
+
+
+ ─ log [2 running] ─
+ magick "screenshot-2026-10-03 141522.png" -quality 80 -define webp:method=4 "screenshot-2026-10-03 141522.webp"
+ encoding 2880x1800 rgba, pass 2 of 3, 62%
+ ffmpeg -i screen-recording-standup.mov -c:v libx264 -crf 23 -preset medium -c:a aac screen-recording-standup.mp4
+ frame= 4412 fps=118 q=23.0 size= 38912kB time=00:02:27.06 bitrate=2167.8kbits/s speed=3.93x
+ scan_receipt_0091.tiff: magick exited 1, Unsupported TIFF compression (JPEG-in-TIFF, old-style)
+
+
+
+
+
+
+ ─ options: images ─
+
+
+
+
Convert 4 images Ctrl Enter
+
3 pending · 1 to retry via ImageMagick
+
+
+
+
+
+
+ [filesmith]
+ 0:convert*
+ 1:compress
+ 2:resize
+ 3:upscale
+ 4:removebg
+ 5:generate
+ 6:tools
+ c:completed
+
+ ffmpeg 7.1
+ ImageMagick 7.1.1
+ mutool 1.24
+ 7-Zip 24.08
+ Rar.exe not found, cbr write off
+ 41%
+
+
+
+
diff --git a/docs/mockups/terminal/04-command-first.html b/docs/mockups/terminal/04-command-first.html
new file mode 100644
index 0000000..c7b5449
--- /dev/null
+++ b/docs/mockups/terminal/04-command-first.html
@@ -0,0 +1,435 @@
+
+
+
+
+
+
+Filesmith · Command First
+
+
+
+
+
+
+
+
+
+
+
+
+ 1 convert 8
+ 2 compress
+ 3 resize
+ 4 upscale
+ 5 remove-bg
+ 6 generate
+ 7 tools
+
+
+ 0 completed 142
+
+
+
+
+
+
+
>
+
+
convert
+
on: images (4 of 4)
+
to: webp
+
quality: balanced
+
out: next-to-source
+
+ option
+
+
Ctrl K commands
+
Convert 4 images Ctrl Enter
+
+
+
+
to:webp target format
+
+ webp
+ avif
+ jxl
+ jpg
+ gif
+ bmp
+ ico
+
+ png
+ tiff
+
+
png, tiff off: already a source format ← → moveEnter pickEsc close
+
+
+
+
+
+ convert 4 images → webp · balanced · next to source
+
+ never overwrites · collisions save as name (converted).webp, then (2)
+
+
+
+
+
+
Convert Change format
+
+
8 files
+
+
+ add files or drop anywhere
+ Ctrl O
+
+
+
+
images · 4 selected tokens apply to this group
+
+
+
HEIC
+
IMG_4821.HEIC
+
3.2 MB
+
HEIC → WebP
+
done · 3.2 MB → 640 KB (-80%)
+
+
+
+
+
+
PNG
+
screenshot-2026-10-03 141522.png
+
1.8 MB
+
PNG → WebP
+
62% · eta 4 s
+
+
+
+
+
PNG
+
hero-banner@2x.png
+
4.6 MB
+
PNG → WebP
+
queued
+
+
+
+
+
TIFF
+
scan_receipt_0091.tiff
+
22.4 MB
+
TIFF → WebP
+
failed · Unsupported TIFF compression (JPEG-in-TIFF, old-style)
+
+
+
+
+
video · 1 select to retarget on:
+
+
+
+
MOV
+
screen-recording-standup.mov
+
812 MB
+
MOV → MP4
+
18.4% · eta 3 min 12 s
+
+
+
+
+
documents · 2
+
+
+
PDF
+
Q3-board-deck.pdf
+
14.1 MB
+
PDF → DOCX
+
queued
+
+
+
+
+
TXT
+
release-notes-0.3.txt
+
6 KB
+
TXT → PDF
+
done · 6 KB → 48 KB
+
+
+
+
+
archives · 1
+
+
+
CBZ
+
One-Piece-v104.cbz
+
186 MB
+
CBZ → CB7
+
queued
+
+
+
+
+
+
+
+ 2 of 8 done · 2 running · 1 failed
+ 41%
+ clear finished
+
+ ffmpeg 7.1 ImageMagick 7.1.1 mutool 1.24 7-Zip 24.08 Rar.exe not found
+
+ RTX 5090 · Real-ESRGAN ready
+
+ v0.3.0
+
+
+
+
+
diff --git a/docs/mockups/terminal/05-tui-function-keys.html b/docs/mockups/terminal/05-tui-function-keys.html
new file mode 100644
index 0000000..685e070
--- /dev/null
+++ b/docs/mockups/terminal/05-tui-function-keys.html
@@ -0,0 +1,399 @@
+
+
+
+
+
+Filesmith · TUI Function Keys
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
F1 Convert
+ Change format
+
+ 8 files
+ sort group
+ next group Tab
+
+
+
+
+
+ ╡ Queue 8 files · 4 groups ╞
+ ╡ a dd · s top · d el ╞
+
+
+ + add files Ctrl O
+ or drop anywhere in this box
+ convert groups form by kind
+
+
+
+
ST
NAME
SIZE
ROUTE
PROGRESS
RESULT
+
+
+ ├─ images (4) ─ active group, 4 selected ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
+
+
+
*
OK
IMG_4821.HEIC
3.2 MB
HEIC → WebP
+
[|||||||||||||||| 100%]
+
3.2 MB → 640 KB -80%
+
⋯
+
+
+
*
RUN
screenshot-2026-10-03 141522.png
1.8 MB
PNG → WebP
+
[|||||||||| 62%]
+
eta 4 s
+
■ ⋯
+
+
+
*
WAIT
hero-banner@2x.png
4.6 MB
PNG → WebP
+
[ 0%]
+
queued, 1st
+
■ ⋯
+
+
+
*
ERR
scan_receipt_0091.tiff
22.4 MB
TIFF → WebP
+
[|||| 24%]
+
not converted
+
⋯
+
+
+
└ Unsupported TIFF compression (JPEG-in-TIFF, old-style). Re-save as standard TIFF, then retry with r .
+
+
+ ├─ video (1) ─ other group, not selectable while images is active ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
+
+
RUN
screen-recording-standup.mov
812 MB
MOV → MP4
+
[||| 18.4%]
+
eta 3 min 12 s
+
■ ⋯
+
+
+ ├─ documents (2) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
+
+
WAIT
Q3-board-deck.pdf
14.1 MB
PDF → DOCX
+
[ 0%]
+
queued
+
■ ⋯
+
+
+
OK
release-notes-0.3.txt
6 KB
TXT → PDF
+
[|||||||||||||||| 100%]
+
6 KB → 48 KB
+
⋯
+
+
+ └─ archives (1) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
+
+
WAIT
One-Piece-v104.cbz
186 MB
CBZ → CB7
+
[ 0%]
+
queued
+
■ ⋯
+
+
+
+
+
+
+
╞═ cursor ═ hero-banner@2x.png
+
+ from D:\Design\exports\hero-banner@2x.png
+ image PNG · 2880 × 1200 · RGBA · 4.6 MB
+ writes D:\Design\exports\hero-banner@2x.webp
+ state queued, starts when screenshot-2026-10-03 141522.png finishes
+
+
+
+ ╡ ↑↓ move Space mark Tab group F10 menu ╞
+
+
+
+
+ ╡ Options images · 4 selected ╞
+
+
+
t arget formatfrom PNG HEIC TIFF
+
+ ( ) PNG
+ ( ) JPG
+ (•) WebP
+ ( ) AVIF
+ ( ) JXL
+ ( ) TIFF
+ ( ) BMP
+ ( ) GIF
+ ( ) ICO
+
+
PNG and TIFF are off: a selected file is already that format.
+
+
+
+
q ualityWebP q 82
+
+ ( ) Smaller
+ (•) Balanced
+ ( ) Best
+
+
+
+
+
o utputCtrl Shift O
+
+ (•) Next to source
+ ( ) Folder…
+
+
D:\Photos\2026-10\ per file
+
+
+ Never overwrites. Collisions save as name (converted).webp, then (2).
+
+
+
will write 4 files
+
+ ok IMG_4821.webp
+ run screenshot-2026-10-03 141522.webp
+ hero-banner@2x.webp
+ scan_receipt_0091.webp
+
+
+
+
+
in 32.0 MB · 4 images
+
engine ImageMagick 7.1.1
+
left to run 2 of 4
+
+ Convert 4 images Enter
+ greyed with a dashed edge when nothing is marked
+
+
+
+
+
+ Total
+ [||||||||||||||||||||||||| 41% ]
+ 2/8 done
+ 2 run
+ 1 fail
+ 4 wait
+
+ Clear finished Ctrl L
+
+
+ ffmpeg 7.1
+ ImageMagick 7.1.1
+ mutool 1.24
+ 7-Zip 24.08
+ Rar.exe not found, CBR writing off
+
+ RTX 5090 · Real-ESRGAN ready
+
+
+
+
+
+ F1 Convert 8
+ F2 Compress
+ F3 Resize
+ F4 Upscale
+ F5 RemoveBG
+ F6 Generate
+ F7 Tools
+ F8 Edit
+ F9 Done 142
+
+
+
+
diff --git a/docs/mockups/terminal/06-repl-log.html b/docs/mockups/terminal/06-repl-log.html
new file mode 100644
index 0000000..0b7edb7
--- /dev/null
+++ b/docs/mockups/terminal/06-repl-log.html
@@ -0,0 +1,377 @@
+
+
+
+
+
+
+Filesmith REPL Log
+
+
+
+
+
+
+
+
+
+
+
+
+
+ C8
+ Z
+ R
+ U
+ B
+ G
+ T
+
+
+
+
+
+
+
+
+ 0.3.0
+
+
+
+
+
+
+
14:01:58 # filesmith 0.3.0 · convert: change format · drop files anywhere in this window
+
14:02:11 > dropped 8 files, sorted into 4 groups by kind
+
+
+
+
+
# images · 4 selected · options below
+
+
+
14:02:11 + IMG_4821.HEIC 3.2 MB heic → webp
+
+
= 640 KB -80% from 3.2 MB · 0.8 s
+
+
+
+
14:02:11 + screenshot-2026-10-03 141522.png 1.8 MB png → webp
+
+
... 62% eta 4 s
+
+
+
+
14:02:11 + hero-banner@2x.png 4.6 MB png → webp
+
+
· queued, next after screenshot-2026-10-03 141522.png
+
+
+
+
+
14:02:12 ! scan_receipt_0091.tiff 22.4 MB tiff → webp
+
+
! Unsupported TIFF compression (JPEG-in-TIFF, old-style)Retry as JPG
+
+
+
+
+
+
+
# video · 1 not in selection, click this line to switch options
+
+
14:02:11 + screen-recording-standup.mov 812 MB mov → mp4
+
+
... 18.4% eta 3 min 12 s
+
+
+
+
+
+
# documents · 2
+
+
14:02:11 + Q3-board-deck.pdf 14.1 MB pdf → docx
+
+
· queued
+
+
+
14:02:11 + release-notes-0.3.txt 6 KB txt → pdf
+
+
= 48 KB from 6 KB · 0.3 s
+
+
+
+
+
+
# archives · 1
+
+
14:02:11 + One-Piece-v104.cbz 186 MB cbz → cb7
+
+
· queued
+
+
+
+
14:06:40 + drop more files anywhere, or add files Ctrl O
+
+
+
+
+
+
+
images · 4 selected Each choice rewrites the command below.
Hide options Esc
+
--to
+
+ png
+ jpg
+ webp
+ avif
+ jxl
+ tiff
+ bmp
+ gif
+ ico
+ png and tiff are off, some sources already are
+
+
+
--quality
+
+ smaller
+ balanced
+ best
+
+
+
--out
+
+ next-to-source
+ Change folder...
+ last used D:\Photos\2026-10\
+ Never overwrites. Collisions save as name (converted).webp, then (2).
+
+
+
+
+
+
+
+
+
+ $ convert 4 images --to webp --quality balanced --out next-to-source
+
+
Edit options
+
Convert 4 images Enter
+
+
+
+
+
+
+ 41% 2 of 8 done · 2 running · 1 failed
+ ffmpeg 7.1 ImageMagick 7.1.1 mutool 1.24 7-Zip 24.08 Rar.exe not found
+ RTX 5090 · Real-ESRGAN readyclear finished
+
+
+
+
+
+
diff --git a/docs/mockups/terminal/07-swiss-rules.html b/docs/mockups/terminal/07-swiss-rules.html
new file mode 100644
index 0000000..d4803db
--- /dev/null
+++ b/docs/mockups/terminal/07-swiss-rules.html
@@ -0,0 +1,463 @@
+
+
+
+
+
+
+Filesmith Swiss Rules
+
+
+
+
+
+
+
+
+
+
+
+ Convert 8
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+ Tools
+
+
+
+
+ Completed 142
+
+
+
+
+
Convert
+
Change format. Pick one group, choose a target, run it.
+
+
+
+
41%
+
Total 2 of 8 done 2 running · 1 failed
+
+
+
+
+
+
+
+ Add files
+ or drop them anywhere in this column
+
+ Ctrl O
+
+
+
+
Images 4 images Selected
+
+
+
01 HEIC
+
IMG_4821.HEIC 3.2 MB
+
HEIC/ WEBP
+
−80% 3.2 MB → 640 KB
+
+
+
+
+
02 PNG
+
screenshot-2026-10-03 141522.png 1.8 MB
+
PNG/ WEBP
+
62% ETA 4 s
+
+
+
+
+
03 PNG
+
hero-banner@2x.png 4.6 MB
+
PNG/ WEBP
+
Queued
+
+
+
+
+
04 TIFF
+
scan_receipt_0091.tiff 22.4 MB
+
TIFF/ WEBP
+
Failed Retry after fix
+
+
Unsupported TIFF compression (JPEG-in-TIFF, old-style)
+
+
+
+
+
Video 1 video Not in selection, one group at a time
+
+
05 MOV
+
screen-recording-standup.mov 812 MB
+
MOV/ MP4
+
18.4% ETA 3 min 12 s
+
+
+
+
+
+
Documents 2 documents
+
+
06 PDF
+
Q3-board-deck.pdf 14.1 MB
+
PDF/ DOCX
+
Queued
+
+
+
+
07 TXT
+
release-notes-0.3.txt 6 KB
+
TXT/ PDF
+
Done 6 KB → 48 KB
+
+
+
+
+
+
Archives 1 archive
+
+
08 CBZ
+
One-Piece-v104.cbz 186 MB
+
CBZ/ CB7
+
Queued
+
+
+
+
+
+
+
Images 4 selected
+
+
+
Target format Sources: HEIC, PNG, TIFF
+
+ PNG
+ JPG
+ WEBP
+ AVIF
+ JXL
+ TIFF
+ BMP
+ GIF
+ ICO
+
+
+
+
Quality WebP q80, effort 4
+
+ Smaller
+ Balanced
+ Best
+
+
+
+
Output
+
+
Next to source
D:\Photos\2026-10\
+
Change folder
+
+
Never overwrites. Collisions save as name (converted).webp, then (2).
+
+
+
Selection 4 of 4 images
+
+
32.0 MB in
+
1 done
+
1 running
+
1 queued
+
1 failed
+
+
Running again skips done rows and retries the failed one.
+
+
+ Convert 4 images Ctrl Enter
+
+
+
+
+
+
+
diff --git a/docs/mockups/terminal/08-geist-breadcrumb.html b/docs/mockups/terminal/08-geist-breadcrumb.html
new file mode 100644
index 0000000..4468543
--- /dev/null
+++ b/docs/mockups/terminal/08-geist-breadcrumb.html
@@ -0,0 +1,456 @@
+
+
+
+
+
+
+Filesmith · Geist Breadcrumb
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filesmith
+
+ Ctrl K jump to anything
+
+
+
+
+
+
+
+
+ 8
+
+
+
+
+
+
+
+ 142
+ Completed Everything Filesmith has produced
+
+
+ 0.3.0
+
+
+
+
+
+
+ filesmith /
+ convert /
+ images
+ 8 files
+
+
+
+
Convert
+
Change format
+
+
+ Pause queue
+ Clear finished
+
+
+
+
+
+ Drop files here or browse
+ images, video, audio, pdf, docs, archives Ctrl O
+
+
+ IMAGES · 4Selected group · options on the right
+
+
+
+
+
HEIC →WEBP
+
3.2 MB → 640 KB -80%
+
+
+
+
+
+
screenshot-2026-10-03 141522.png
1.8 MB
+
PNG →WEBP
+
62% ETA 4 s
+
+
+
+
+
+
+
PNG →WEBP
+
Queued
+
+
+
+
+
+
scan_receipt_0091.tiff
22.4 MBUnsupported TIFF compression (JPEG-in-TIFF, old-style)
+
TIFF →WEBP
+
FailedRetry
+
+
+
+
+ VIDEO · 1Separate group, select to edit
+
+
+
MOV
+
screen-recording-standup.mov
812 MB
+
MOV →MP4
+
18.4% ETA 3 min 12 s
+
+
+
+
+ DOCUMENTS · 2
+
+
+
PDF
+
+
PDF →DOCX
+
Queued
+
+
+
+
+
TXT
+
release-notes-0.3.txt
6 KB
+
TXT →PDF
+
6 KB → 48 KB
+
+
+
+
+ ARCHIVES · 1
+
+
+
CBZ
+
+
CBZ →CB7
+
Queued
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 2 of 8 done · 2 running · 1 failed
+
+ 41%
+
+
+ ffmpeg 7.1 ImageMagick 7.1.1 mutool 1.24 7-Zip 24.08
+ Rar.exe not found
+
+
+ RTX 5090 · Real-ESRGAN ready
+
+
+
+
diff --git a/docs/mockups/terminal/09-explorer-tree.html b/docs/mockups/terminal/09-explorer-tree.html
new file mode 100644
index 0000000..2cc3cec
--- /dev/null
+++ b/docs/mockups/terminal/09-explorer-tree.html
@@ -0,0 +1,535 @@
+
+
+
+
+
+Filesmith, Explorer Tree
+
+
+
+
+
+
+
+
+
+
+
+
+ Go to file, verb or commandCtrl K
+
+
+
+
+
+
+
+
+
+
+
+
+
images.convert 4
+
video.convert 1
+
documents.convert 2
+
archives.convert 1
+
+
+
+
Convert
+
Change format
+
images
+
8 files in Convert · 4 images here
+
+
+
+
1 # Images · 4 selected
+
2 # sources: heic ×1, png ×2, tiff ×1 · 32.0 MB total
+
3
+
4 target :
+
+
6 png jpg webp avif jxl tiff bmp gif ico
+
7 # png, tiff off: a selected file is already that format
+
+
9 smaller balanced best
+
10
+
11 output :
+
12 folder : next-to-source # Change folder…
+
13 # e.g. D:\Photos\2026-10\IMG_4821.webp
+
14 naming : never-overwrite
+
15 # collisions save as name (converted).webp, then name (converted) (2).webp
+
16
+
17 inputs :
+
18 - IMG_4821.HEIC 3.2 MB heic → webp done 3.2 MB → 640 KB -80%
+
19 - screenshot-2026-10-03 141522.png 1.8 MB png → webp running 62% eta 4 s
+
20 - hero-banner@2x.png 4.6 MB png → webp queued
+
21 - scan_receipt_0091.tiff 22.4 MB tiff → webp failed
+
22 # Unsupported TIFF compression (JPEG-in-TIFF, old-style)
+
+
+
+ heic, png, tiff → webp · balanced · next to source
+ Retry failed
+ Convert 4 imagesCtrl Enter
+
+
+
+
+
+
+
+
+
+
+ output
+ problems 1
+ engines 5
+
+ Clear finished
+ Stop all
+
+
+
14:22:03 images IMG_4821.HEIC → IMG_4821.webp done · 3.2 MB → 640 KB (-80%) · magick
+
14:22:04 documents release-notes-0.3.txt → .pdf done · 6 KB → 48 KB · mutool
+
14:22:06 images scan_receipt_0091.tiff failed · Unsupported TIFF compression (JPEG-in-TIFF, old-style)
+
14:22:07 video screen-recording-standup.mov → .mp4 running 18.4% · 812 MB · eta 3 min 12 s · ffmpeg
+
14:22:09 images screenshot-2026-10-03 141522.png → .webp running 62% · eta 4 s · magick
+
14:22:09 queued hero-banner@2x.png, Q3-board-deck.pdf, One-Piece-v104.cbz png → webp, pdf → docx, cbz → cb7 · 204.7 MB
+
+
+
+
+
+
+
+ 2 of 8 done · 2 running · 1 failed
+ 41%
+
+ ffmpeg 7.1
+ ImageMagick 7.1.1
+ mutool 1.24
+ 7-Zip 24.08
+ Rar.exe not found
+ RTX 5090 · Real-ESRGAN ready
+
+
+
+
diff --git a/docs/mockups/terminal/10-powerline-status.html b/docs/mockups/terminal/10-powerline-status.html
new file mode 100644
index 0000000..6b8dd30
--- /dev/null
+++ b/docs/mockups/terminal/10-powerline-status.html
@@ -0,0 +1,389 @@
+
+
+
+
+
+
+Filesmith Powerline
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Convert
+ Change format
+ 8 files
+
+
+ add files Ctrl O or drop anywhere in the window
+
+
+
+
+
Images 4 images · 4 selected
+ Esc close options
+
+
+
+
+
f Format
+
+ png
+ jpg
+ webp
+ avif
+ jxl
+ tiff
+ bmp
+ gif
+ ico
+ png, tiff: some sources already are
+
+
+
+
q Quality
+
+ Smaller
+ Balanced
+ Best
+
+
+
+
o Output
+
+ Next to source
+ Change folder…
+ last used D:\Photos\2026-10\
+
+
+
+ Never overwrites. Collisions save as name (converted).webp, then (2).
+ Ctrl Enter convert 4 images
+
+
+
+
+
heic
+
IMG_4821.HEIC 3.2 MB · heic → webp
+
3.2 MB → 640 KB -80%
+
+
+
+
png
+
screenshot-2026-10-03 141522.png 1.8 MB · png → webp
+
62% · 4 s
+
+
+
+
png
+
hero-banner@2x.png 4.6 MB · png → webp
+
queued
+
+
+
+
+
tiff
+
scan_receipt_0091.tiff 22.4 MB · tiff → webp
+
failed
+
+
E: Unsupported TIFF compression (JPEG-in-TIFF, old-style)
+
+
+
+
+
Video 1 video other group, select with Tab
+
+
mov
+
screen-recording-standup.mov 812 MB · mov → mp4
+
18.4% · 3 min 12 s
+
+
+
+
+
+
Documents 2 documents
+
+
pdf
+
Q3-board-deck.pdf 14.1 MB · pdf → docx
+
queued
+
+
+
+
txt
+
release-notes-0.3.txt 6 KB · txt → pdf
+
6 KB → 48 KB +700%
+
+
+
+
+
+
Archives 1 archive
+
+
cbz
+
One-Piece-v104.cbz 186 MB · cbz → cb7
+
queued
+
+
+
+
+
+
+
+
+ 1 CONVERT8
+
+
+ 2 compress
+ 3 resize
+ 4 upscale
+ 5 remove bg
+ 6 generate
+ 7 tools
+ 0 completed 142
+
+
+ images · 4 selected
+
+ 2/8 done · 2 running · 1 failed
+ 41%
+
+
+ Convert 4 images Ctrl ⏎
+
+
+
+
+ : command? keysTab next group
+
+ clear finished
+
+
+ ffmpeg 7.1 magick 7.1.1 mutool 1.24 7z 24.08 rar.exe ✗ not found, cbr off
+
+ RTX 5090 · real-esrgan ready
+
+ v0.3.0
+
+
+
+
diff --git a/docs/mockups/terminal/11-bottom-dock.html b/docs/mockups/terminal/11-bottom-dock.html
new file mode 100644
index 0000000..ba6748f
--- /dev/null
+++ b/docs/mockups/terminal/11-bottom-dock.html
@@ -0,0 +1,448 @@
+
+
+
+
+
+
+Filesmith Bottom Dock
+
+
+
+
+
+
+
+
+
+
+
+ / convert
+
+ 1 –7 switch mode Ctrl K commands
+
+
+
+
+
+
+
+
Convert
+
Change format
+
8 files Clear finished
+
+
+ + add files or drop anywhere in the window Ctrl O
+
+
+
Images 4 images 4 selected · options active
+
+
+
+
IMG_4821.HEIC
HEIC → WebP 3.2 MB
+
+
+
+
+
+
+
screenshot-2026-10-03 141522.png
PNG → WebP 1.8 MB
+
+
+
+
+
+
+
+
hero-banner@2x.png
PNG → WebP 4.6 MB
+
+
+
+
+
+
+
scan_receipt_0091.tiff
TIFF → WebP 22.4 MB Unsupported TIFF compression (JPEG-in-TIFF, old-style)
+
+
+
+
+
Video 1 video Shift+click to switch group
+
+
MOV
+
screen-recording-standup.mov
MOV → MP4 812 MB
+
+
+
+
+
+
Documents 2 documents
+
+
PDF
+
Q3-board-deck.pdf
PDF → DOCX 14.1 MB
+
+
+
+
+
TXT
+
release-notes-0.3.txt
TXT → PDF 6 KB
+
+
+
+
+
+
Archives 1 archive
+
+
CBZ
+
One-Piece-v104.cbz
CBZ → CB7 186 MB
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1 8
+ Convert
+ 2
+ Compress
+ 3
+ Resize
+ 4
+ Upscale
+ 5
+ Remove BG
+ 6
+ Generate
+ 7
+ Tools
+
+ 0 142
+ Completed
+
+ edit
+
+
+
+
diff --git a/docs/mockups/terminal/12-verb-tint.html b/docs/mockups/terminal/12-verb-tint.html
new file mode 100644
index 0000000..4dfed91
--- /dev/null
+++ b/docs/mockups/terminal/12-verb-tint.html
@@ -0,0 +1,432 @@
+
+
+
+
+
+
+Filesmith Verb Tint
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ~/queue · 8 files
+
+
+
+
+
+
+
+ convert 8
+ compress
+ resize
+ upscale
+ remove bg
+ generate
+ tools
+
+ completed 142
+
+
+ 0.3.0
+
+
+
+
+ Images 4 selected
+
+
+
to
+
+ png
+ jpg
+ webp
+ avif
+ jxl
+ tiff
+ bmp
+ gif
+ ico
+
+
+
+
+
quality
+
+ Smaller
+ Balanced
+ Best
+
+
+
+
+ out
+ next to source
+
+
+
+ Convert 4 images Ctrl Enter
+
+
+
+
+
+ add files Ctrl O
+ or drop anywhere in the window
+
+ Never overwrites. Collisions save as name (converted).webp, then (2).
+
+
+
+ name route size progress result
+
+
+
+
+
Images 4 images4 of 4 selected clear finished
+
+
+ heic
+ IMG_4821.HEIC
+ HEIC→ WebP
+ 3.2 MB
+ done
+ 3.2 MB → 640 KB -80%
+
+
+
+
+ png
+ screenshot-2026-10-03 141522.png
+ PNG→ WebP
+ 1.8 MB
+ 62%
+ eta 4 s
+
+
+
+
+ png
+ hero-banner@2x.png
+ PNG→ WebP
+ 4.6 MB
+ queued
+
+
+
+
+
+ tiff
+ scan_receipt_0091.tiff
+ TIFF→ WebP
+ 22.4 MB
+ failed
+ Unsupported TIFF compression (JPEG-in-TIFF, old-style)
+
+
+
+
+
Video 1 videoselect group to edit options
+
+ mov
+ screen-recording-standup.mov
+ MOV→ MP4
+ 812 MB
+ 18.4%
+ eta 3 min 12 s
+
+
+
+
+
Documents 2 documents
+
+ pdf
+ Q3-board-deck.pdf
+ PDF→ DOCX
+ 14.1 MB
+ queued
+
+
+
+
+ txt
+ release-notes-0.3.txt
+ TXT→ PDF
+ 6 KB
+ done
+ 6 KB → 48 KB
+
+
+
+
+
+
Archives 1 archive
+
+ cbz
+ One-Piece-v104.cbz
+ CBZ→ CB7
+ 186 MB
+ queued
+
+
+
+
+
+
+
+ log engine output, newest first copy hide
+
+
14:22:11 magick screenshot-2026-10-03 141522.png → .webp q=82 62%
+
14:22:09 magick error scan_receipt_0091.tiff: Unsupported TIFF compression (JPEG-in-TIFF, old-style)
+
14:22:07 magick ok IMG_4821.HEIC → IMG_4821.webp 3.2 MB → 640 KB 1.4 s
+
14:21:40 ffmpeg screen-recording-standup.mov → .mp4 libx264 crf 23 frame=3311 fps=58 18.4%
+
14:20:55 mutool ok release-notes-0.3.txt → release-notes-0.3.pdf 6 KB → 48 KB 0.3 s
+
+
+
+
+
+
+
+
+
diff --git a/docs/mockups/terminal/13-one-bit.html b/docs/mockups/terminal/13-one-bit.html
new file mode 100644
index 0000000..79a29fe
--- /dev/null
+++ b/docs/mockups/terminal/13-one-bit.html
@@ -0,0 +1,468 @@
+
+
+
+
+
+
+Filesmith One Bit
+
+
+
+
+
+
+
+
+
+
+ convert / queue / images
+
+ Command Ctrl K
+
+
+
+
+
+
+ Convert 8 Change format
+ Compress Shrink file size
+ Resize Scale by % or size
+ Upscale 2x to 4x with AI
+ Remove BG Cut subject out, AI
+ Generate Image from a prompt
+ Tools PDF and one-off jobs
+
+
+
+ Completed 142 All Filesmith output
+
+
+
+
+
+
+
Convert
+
Change format
+
+
+
+
+
+
+
+ Files
+ 8 files in 4 groups
+
+ Select all Ctrl A
+ Clear finished
+
+
+
+ +
+ Drop files here or browse
+
+ images, video, audio, PDF, documents, archives
+ Ctrl O
+
+
+
+
+
+
+
Images 4 images
+
Selected
+
+
+
HEIC
+
IMG_4821.HEIC 3.2 MB
+
HEIC →WEBP
+
OK 640 KB -80%
+
+
+
+
PNG
+
screenshot-2026-10-03 141522.png 1.8 MB
+
PNG →WEBP
+
62% 4 s
+
+
+
+
PNG
+
hero-banner@2x.png 4.6 MB
+
PNG →WEBP
+
Queued
+
+
+
+
TIFF
+
scan_receipt_0091.tiff 22.4 MB
+
TIFF →WEBP
+
Err failed Retry
+
+
Unsupported TIFF compression (JPEG-in-TIFF, old-style)
+
+
+
+
+
+ Video 1 video
+ other group, select to switch
+
+
+
MOV
+
screen-recording-standup.mov 812 MB
+
MOV →MP4
+
18.4% 3 min 12 s
+
+
+
+
+
+ Documents 2 documents
+
+
+
PDF
+
Q3-board-deck.pdf 14.1 MB
+
PDF →DOCX
+
Queued
+
+
+
+
TXT
+
release-notes-0.3.txt 6 KB
+
TXT →PDF
+
OK 6 KB → 48 KB
+
+
+
+
+
+ Archives 1 archive
+
+
+
CBZ
+
One-Piece-v104.cbz 186 MB
+
CBZ →CB7
+
Queued
+
+
+
+
+
+
Log last 6 events
+
+
14:21:58 done IMG_4821.HEIC → IMG_4821.webp, 640 KB
+
14:22:03 done release-notes-0.3.txt → release-notes-0.3.pdf, 48 KB
+
14:22:05 error scan_receipt_0091.tiff: Unsupported TIFF compression (JPEG-in-TIFF, old-style)
+
14:22:06 start screenshot-2026-10-03 141522.png with ImageMagick 7.1.1
+
14:22:06 start screen-recording-standup.mov with ffmpeg 7.1 (libx264, crf 23)
+
14:22:11 wait hero-banner@2x.png, Q3-board-deck.pdf, One-Piece-v104.cbz
+
>
+
+
+
+
+
+ Images 4 selected
+
+
+
Target format WebP
+
+ PNG
+ JPG
+ WEBP
+ AVIF
+ JXL
+ TIFF
+ BMP
+ GIF
+ ICO
+
+
PNG and TIFF are off: a selected file already uses that format.
+
+
+
+
Quality q 80
+
+ Smaller
+ Balanced
+ Best
+
+
+
+
+
Output
+
+ Next to source default
+ D:\Photos\2026-10\ last used
+
+
Change folder…
+
+
+
+
Runs as ImageMagick 7.1.1
+
magick <src> -quality 80
+ -define webp:method=6 <dst>.webp
+
+
+
Never overwrites. Collisions save as name (converted).webp, then (2).
+
+
+
+
4 images, 32.0 MB in est. 6.4 MB out
+
Convert 4 imagesCtrl Enter
+
+
+
+
+
+
+
+
+ 2 of 8 done 2 running 1 failed
+
+ 41%
+
+
+
+ ffmpeg 7.1
+ ImageMagick 7.1.1
+ mutool 1.24
+ 7-Zip 24.08
+ Rar.exe not found, no CBR
+ RTX 5090 · Real-ESRGAN ready
+
+
+
+
+
diff --git a/docs/mockups/terminal/14-serif-contrast.html b/docs/mockups/terminal/14-serif-contrast.html
new file mode 100644
index 0000000..8302e1c
--- /dev/null
+++ b/docs/mockups/terminal/14-serif-contrast.html
@@ -0,0 +1,416 @@
+
+
+
+
+
+
+Filesmith Serif Contrast
+
+
+
+
+
+
+
+
+
+
+
+ convert / images, video, documents, archives
+
+
+
+
+
+
+
+
+
+
+ Convert 8 Change format
+
+
+ Compress Shrink file size
+
+
+ Resize Scale by percent or exact size
+
+
+ Upscale Enlarge 2x to 4x with AI
+
+
+ Remove BG Cut the subject out with AI
+
+
+ Generate Create an image from a text prompt
+
+
+ Tools One-off jobs: PDF text, pages, merge, split
+
+
+
+
+
+ Completed 142 Everything Filesmith has produced
+
+
+
+
+
+
+
+
+
Convert
+
Change format, 8 files in the queue
+
+
+
+ Drop files here or browse Ctrl O
+
+
+
+
+
+
+
+
Images 4 images
+
+
+
+
IMG_4821.HEIC
HEIC → WebP · 3.2 MB
+
Done3.2 MB → 640 KB
+
-80%
+
+
+
+
+
screenshot-2026-10-03 141522.png
PNG → WebP · 1.8 MB
+
Running 62%About 4 s left
+
+
+
+
+
+
+
hero-banner@2x.png
PNG → WebP · 4.6 MB
+
QueuedNext in line
+
+
+
+
+
+
scan_receipt_0091.tiff
TIFF → WebP · 22.4 MB
Unsupported TIFF compression (JPEG-in-TIFF, old-style)
+
FailedRetry or remove
+
+
+
+
+
+
+
+
+
Video 1 video
+
+
+
MOV
+
screen-recording-standup.mov
MOV → MP4 · 812 MB
+
Running 18.4%About 3 min 12 s left
+
+
+
+
+
+
+
+
+
+
Documents 2 documents
+
+
+
PDF
+
Q3-board-deck.pdf
PDF → DOCX · 14.1 MB
+
QueuedAfter images
+
+
+
+
+
TXT
+
release-notes-0.3.txt
TXT → PDF · 6 KB
+
Done6 KB → 48 KB
+
+700%
+
+
+
+
+
+
+
+
Archives 1 archive
+
+
+
CBZ
+
One-Piece-v104.cbz
CBZ → CB7 · 186 MB
+
QueuedAfter documents
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/mockups/terminal/15-spreadsheet-grid.html b/docs/mockups/terminal/15-spreadsheet-grid.html
new file mode 100644
index 0000000..61ba51a
--- /dev/null
+++ b/docs/mockups/terminal/15-spreadsheet-grid.html
@@ -0,0 +1,506 @@
+
+
+
+
+
+
+Filesmith Spreadsheet Grid
+
+
+
+
+
+
+
+
+
+
+
+
+
+
A1:A4
+
fx
+
+ =CONVERT ( A1:A4 , "webp" , "balanced" , "next-to-source" )
+
+
evaluates to 4 jobs · 32.0 MB in · est. 6.4 MB out
+
+
+
+
+
+
+
+
A
+
B name
+
C size
+
D route
+
E status
+
F progress
+
G result
+
H
+
+
+
+
+
+
Images 4 images active group HEIC, PNG, TIFF
+
+
+
+
+
1
+
HEIC
+
IMG_4821.HEIC
+
3.2 MB
+
HEIC → WebP
+
done
+
100%
+
3.2 MB → 640 KB −80%
+
+
+
+
2
+
PNG
+
screenshot-2026-10-03 141522.png
+
1.8 MB
+
PNG → WebP
+
running
+
62%
+
ETA 4 s
+
+
+
+
3
+
PNG
+
hero-banner@2x.png
+
4.6 MB
+
PNG → WebP
+
queued
+
0%
+
waiting
+
+
+
+
4
+
TIFF
+
scan_receipt_0091.tiff
+
22.4 MB
+
TIFF → WebP
+
#ERR
+
–
+
retry
+
+
+
+
+
+
+
+
+
Video 1 video Other convert group, click its header to switch
+
+
+
5
+
MOV
+
screen-recording-standup.mov
+
812 MB
+
MOV → MP4
+
running
+
18.4%
+
ETA 3 min 12 s
+
+
+
+
+
+
+
Documents 2 documents
+
+
+
6
+
PDF
+
Q3-board-deck.pdf
+
14.1 MB
+
PDF → DOCX
+
queued
+
0%
+
waiting
+
+
+
+
7
+
TXT
+
release-notes-0.3.txt
+
6 KB
+
TXT → PDF
+
done
+
100%
+
6 KB → 48 KB
+
+
+
+
+
+
+
Archives 1 archive CBR output needs Rar.exe
+
+
+
8
+
CBZ
+
One-Piece-v104.cbz
+
186 MB
+
CBZ → CB7
+
queued
+
0%
+
waiting
+
+
+
+
+
+
9
+
add files drop here or browse, new rows join their convert group Ctrl O
+
+
+
+
+
+
+
E4 · #ERR
+
Unsupported TIFF compression (JPEG-in-TIFF, old-style). Re-save the scan as a standard TIFF or PNG, then retry.
+
+
+
+
+
+
+
+
+
+
+ Convert 8
+ Compress
+ Resize
+ Upscale
+ Remove BG
+ Generate
+ Tools
+
+ Completed 142
+ Edit tabs
+
+
+
+
+ Running
+ 2 of 8 done · 2 running · 1 failed 41%
+ Selection count 4 sum 32.0 MB
+ ffmpeg 7.1 ·ImageMagick 7.1.1 ·mutool 1.24 ·7-Zip 24.08 ·Rar.exe not found
+ 0.3.0
+
+
+
+
+
diff --git a/docs/mockups/terminal/16-warp-blocks.html b/docs/mockups/terminal/16-warp-blocks.html
new file mode 100644
index 0000000..60d9844
--- /dev/null
+++ b/docs/mockups/terminal/16-warp-blocks.html
@@ -0,0 +1,391 @@
+
+
+
+
+
+
+Filesmith Warp Blocks
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Convert8
+
Compress
+
Resize
+
Upscale
+
Remove BG
+
Generate
+
Tools
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $ convert images --to webp --quality balanced
+ 4 images · selected
+
+
✓ HEIC IMG_4821.HEIC HEIC → WebP3.2 MB
+
3.2 MB → 640 KB -80%
+
+
▸ PNG screenshot-2026-10-03 141522.png PNG → WebP1.8 MB
+
62% eta 4 s
+
+
· PNG hero-banner@2x.png PNG → WebP4.6 MB
+
queued
+
+
✕ TIFF scan_receipt_0091.tiff TIFF → WebP22.4 MB
+
failed
+
+
! Unsupported TIFF compression (JPEG-in-TIFF, old-style) · re-save as standard TIFF and retry
+
+ 1/4 done · 1 running · 1 failed 62% · eta 4 s Show output Retry failed
+
+
+
+
+
+ $ convert video --to mp4 --codec h264
+ 1 video
+
+
▸ MOV screen-recording-standup.mov MOV → MP4812 MB
+
18.4% eta 3 min 12 s
+
+
+ 0/1 done · 1 running 18% · eta 3 min 12 s click header to select block
+
+
+
+
+
+ $ convert documents --to docx,pdf
+ 2 documents
+
+
· PDF Q3-board-deck.pdf PDF → DOCX14.1 MB
+
queued
+
+
✓ TXT release-notes-0.3.txt TXT → PDF6 KB
+
6 KB → 48 KB
+
+
+ 1/2 done 50%
+
+
+
+
+
+ $ convert archives --to cb7
+ 1 archive
+
+
· CBZ One-Piece-v104.cbz CBZ → CB7186 MB
+
queued
+
+
+ 0/1 done · waiting
+
+
+
+
+
+ Images · 4 selectedblock 1/4
+
+
+
Target format --to
+
+ PNG
+ JPG
+ WebP
+ AVIF
+ JXL
+ TIFF
+ BMP
+ GIF
+ ICO
+
+
PNG, TIFF are source formats in this group
+
+
+
+
Quality --quality
+
+ Smaller Balanced Best
+
+
+
+
+
Output --out
+
+ Next to sourcedefault
+ Change folder…
+
+
+
+
+ Never overwrites. Collisions save as name (converted).webp, then (2).
+
+
+
+
+
+
+
+ + add files drop anywhere, or browse Ctrl O Browse
+
+
+
+
+ 41%
+ 2 of 8 done · 2 running · 1 failed
+
+ ffmpeg 7.1
+ ImageMagick 7.1.1
+ mutool 1.24
+ 7-Zip 24.08
+ Rar.exe not found
+ RTX 5090 · Real-ESRGAN ready
+
+
+
+
+
+
diff --git a/docs/mockups/terminal/17-linear-triptych.html b/docs/mockups/terminal/17-linear-triptych.html
new file mode 100644
index 0000000..0432f74
--- /dev/null
+++ b/docs/mockups/terminal/17-linear-triptych.html
@@ -0,0 +1,485 @@
+
+
+
+
+
+
+Filesmith · Linear Preview
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ / Convert / IMG_4821.HEIC
+
+ Search files and actions Ctrl K
+
+
+
+
+
+
+
+
+
+
Convert8
+
Compress
+
Resize
+
Upscale
+
Remove BG
+
Generate
+
Tools
+
+ Extract text Pages to PNG Merge · Split · Burst Extract images
+
+
+
+ Completed142
+
+
+
+
+
+
+
+
Convert 8 files
+
Change format
+
+ Drop files or browse Ctrl O
+
+
+
Images 4 4 selected
+
+
+
+
IMG_4821.HEIC
+
HEIC → WebP 3.2 MB → 640 KB
+
-80%
+
+
+
+
+
screenshot-2026-10-03 141522.png
+
PNG → WebP 1.8 MB ETA 4 s
+
62%
+
+
+
+
+
hero-banner@2x.png
+
PNG → WebP 4.6 MB queued
+
+
+
+
+
+
scan_receipt_0091.tiff
+
22.4 MB Unsupported TIFF compression (JPEG-in-TIFF, old-style)
+
+
+
+
Video 1
+
+
+
screen-recording-standup.mov
+
MOV → MP4 812 MB ETA 3m 12s
+
18.4%
+
+
+
Documents 2
+
+
+
Q3-board-deck.pdf
+
PDF → DOCX 14.1 MB queued
+
+
+
+
+
release-notes-0.3.txt
+
TXT → PDF 6 KB → 48 KB
+
done
+
+
+
Archives 1
+
+
+
One-Piece-v104.cbz
+
CBZ → CB7 186 MB queued
+
+
+
+
+
+ J K move
+ X select
+ Space preview
+
+
+
+
+
+
+
+
+
IMG_4821.HEIC
+
HEIC → WebP
+
+
Open
+
Show in folder
+
Remove
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Before HEIC 3.2 MB
+
After WebP 640 KB
+
4032 × 3024 · 12.2 MP
+
-80% · saved 2.56 MB
+
Fit 100% 200%
+
+
+
+ queued 14:02:11
+ 0.4 s
+ running 14:02:11.4
+ 3.6 s
+ done 14:02:15
+
+ 4.0 s total
+
+
+
+
+
Images · 4 selected
+
HEIC, PNG ×2, TIFF
+
+
options apply to this group only
+
+
+
+
Target format
+
+ PNG2 selected files are already PNG
+ JPG
+ WebP
+ AVIF
+ JXL
+ TIFF
+ BMP
+ GIF
+ ICO
+
+
Quality WebP q82
+
+ Smaller Balanced Best
+
+
+
+
+
Output
+
+
Next to source D:\Photos\2026-10\
+
+
Change folder…
+
Never overwrites. Collisions save as name (converted).webp, then (2).
+
+
+
+
1 done, 1 running, 1 queued, 1 failed · ≈ 1.4 MB out
+
+
Retry failed
+
Convert 4 images Ctrl ↵
+
+
+
+
+
+
+
+ 41%
+ 2 of 8 done · 2 running · 1 failed
+
+ Clear finished
+
+ ffmpeg 7.1 ImageMagick 7.1.1 mutool 1.24 7-Zip 24.08
+ Rar.exe not found, CBR off
+ RTX 5090 · Real-ESRGAN ready
+
+
+
+
+
diff --git a/docs/mockups/terminal/18-pipeline-board.html b/docs/mockups/terminal/18-pipeline-board.html
new file mode 100644
index 0000000..a99ab50
--- /dev/null
+++ b/docs/mockups/terminal/18-pipeline-board.html
@@ -0,0 +1,459 @@
+
+
+
+
+
+
+Filesmith Pipeline Board
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Convert 8
+
Compress
+
Resize
+
Upscale
+
Remove BG
+
Generate
+
Tools
+
+
+
+
+
+
+
+
+
Convert
+
Change format
+
+
+
8 files · 4 groups
+
+ board
+ list
+
+
+
+
+
Queued3
+
Running2
+
Done2
+
Failed1
+
+
+
add filesCtrl O
+
or drop anywhere on the board · files sort into lanes by kind
+
+
+
+
+
+ Images 4 images selected · 4
+
+
+
queued 1
+
+
+
hero-banner@2x.png
+
queued next up
+
+
+
+
running 1
+
+
+
screenshot-2026-10-03 141522.png
+
+
62% ETA 4 s
+
+
+
+
done 1
+
+
+
IMG_4821.HEIC
+
3.2 MB → 640 KB -80%
+
+
+
+
failed 1
+
+
+
scan_receipt_0091.tiff
+
Unsupported TIFF compression (JPEG-in-TIFF, old-style)
+
+
+
+
+
+
+
+ Video 1 video click lane to switch group
+
+
+
running 1
MOV screen-recording-standup.mov 812 MB 18.4% · 3:12
+
+
+
+
+
+
+ Documents 2 documents
+
+
queued 1
PDF Q3-board-deck.pdf 14.1 MB PDF → DOCX
+
+
done 1
TXT release-notes-0.3.txt 6 KB → 48 KB TXT → PDF
+
+
+
+
+
+ Archives 1 archive
+
+
queued 1
CBZ One-Piece-v104.cbz 186 MB CBZ → CB7
+
+
+
+
+
+
+
+
+
Engine logcopy clear finished
+
14:21:58 magick IMG_4821.HEIC start heic → webp -quality 82
+
14:22:07 magick IMG_4821.HEIC → IMG_4821.webp 640 KB 0.9 s
+
14:22:08 mutool release-notes-0.3.txt → release-notes-0.3.pdf 48 KB 0.2 s
+
14:22:09 magick scan_receipt_0091.tiff: Unsupported TIFF compression (JPEG-in-TIFF, old-style)
+
14:22:11 ffmpeg screen-recording-standup.mov frame=3312 fps=118 time=00:00:55.20 speed=3.9x
+
+
+
+
+
+
+
+
+
+ 2 of 8 done · 2 running · 1 failed
+
+ 41%
+
+ ffmpeg 7.1 ImageMagick 7.1.1 mutool 1.24 7-Zip 24.08 Rar.exe not found
+
+ RTX 5090 · Real-ESRGAN ready
+
+
+
+
diff --git a/docs/mockups/terminal/19-amber-manpage.html b/docs/mockups/terminal/19-amber-manpage.html
new file mode 100644
index 0000000..978e132
--- /dev/null
+++ b/docs/mockups/terminal/19-amber-manpage.html
@@ -0,0 +1,469 @@
+
+
+
+
+
+
+Filesmith Amber Manpage
+
+
+
+
+
+
+
+
+
+
+
+ Filesmith
man convert
+
+
+
+
+
+
+ FILESMITH(1)
+
+
1 convert(8)
+
2 compress
+
3 resize
+
4 upscale
+
5 removebg
+
6 generate
+
7 tools
+
·
+
0 completed(142)
+
+
+ Filesmith 0.3.0
+
+
+
+
+
+
+
+ NAME
+ convert - change file format
+
+
+
+ SYNOPSIS
+
+
+ $ filesmith convert -t webp -q balanced -o next-to-source IMG_4821.HEIC screenshot-2026-10-03… +2
+ Convert 4 images Ctrl ⏎
+
+
+ + add files or drop anywhere Ctrl O
+ 8 files in queue, 4 images selected
+
+
+
+
+
+ OPTIONS for Images · 4 selected
+
+
+
-t, --to FORMAT
+
+
+ png
+ jpg
+ webp
+ avif
+ jxl
+ tiff
+ bmp
+ gif
+ ico
+
+
png, tiff off: a selected source is already that format.
+
+
+
+
+
-q, --quality LEVEL
+
+
+ smaller
+ balanced
+ best
+
+
+
+
+
+
-o, --out DIR
+
+ Next to source (default) change folder…
+
+
+
+
+
--no-clobber always on
+
Never overwrites. Collisions save as name (converted).webp , then (2) .
+
+
+
+
+
+ FILES
+
+
+
Images (4) selected · active group
+
+
HEIC
+
IMG_4821.HEIC
+
heic → webp 3.2M → 640K -80% done
+
+
+
+
PNG
+
screenshot-2026-10-03 141522.png
+
png → webp 1.8M 62% eta 4s
+
+
+
+
PNG
+
hero-banner@2x.png
+
png → webp 4.6M queued
+
+
+
+
TIFF
+
scan_receipt_0091.tiff
+
tiff → webp 22.4M ERR unsupported tiff compression (JPEG-in-TIFF, old-style)
+
+
+
+
+
+
Video (1) other group, not in selection
+
+
MOV
+
screen-recording-standup.mov
+
mov → mp4 812M 18.4% eta 3m 12s
+
+
+
+
+
+
Documents (2)
+
+
PDF
+
Q3-board-deck.pdf
+
pdf → docx 14.1M queued
+
+
+
+
TXT
+
release-notes-0.3.txt
+
txt → pdf 6K → 48K done
+
+
+
+
+
+
Archives (1)
+
+
CBZ
+
One-Piece-v104.cbz
+
cbz → cb7 186M queued
+
+
+
+ Options describe one group at a time. Select a row in another group to switch.
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/mockups/terminal/20-hud-metrics.html b/docs/mockups/terminal/20-hud-metrics.html
new file mode 100644
index 0000000..c638383
--- /dev/null
+++ b/docs/mockups/terminal/20-hud-metrics.html
@@ -0,0 +1,450 @@
+
+
+
+
+
+Filesmith HUD Metrics
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
8 1 ConvertChange format
+
2 CompressShrink file size
+
3 ResizeScale by percent or exact size
+
4 UpscaleEnlarge 2x to 4x with AI
+
5 Remove BGCut the subject out with AI
+
6 GenerateCreate an image from a text prompt
+
7 ToolsPDF: extract text, pages to PNG, merge, split, burst
+
+
+
+ 142 0 CompletedEverything Filesmith has produced
+
+ 0.3.0
+
+
+
+
+
+
Convert
+ Change format
+ 8 files
+
+ + add files Ctrl O or drop anywhere
+
+
+
+
+
+
Progressoverall
+
41%
+
+
0 ETA 3 min 14 s 100
+
+
+
Done
+
2/ 8
+
+
2 run 1 fail 3 queued
+
+
+
Throughputlast 60 s · peak 51
+
38MB/s
+
⣀⣀⣠⣤⣴⣶⣦⣤⣴⣶⣾⣷⣶ ⣴⣾⣿⣿⣷⣶⣾⣿⣿⣷⣶⣾⣿⣷⣾
+
+
+
GPUAI tools
+
RTX 5090
+
+ Real-ESRGAN ready
+ rembg ready
+ VRAM 1.2 / 32 GB · idle
+
+
+
+
+
+
+
+ file size route status speed · progress
+
+ Images 4 images selected 1 done · 1 running · 1 failed
+
+
+
+
IMG_4821.HEIC 3.2 MB
+
HEIC → WebP done
+
3.2 MB → 640 KB -80%
+
+
+
+
+
screenshot-2026-10-03 141522.png 1.8 MB
+
PNG → WebP running
+
⣀⣤⣶⣿⣷⣶⣾⣿⣶⣴ 62% 4 s
+
+
+
+
+
+
hero-banner@2x.png 4.6 MB
+
PNG → WebP queued
+
next
+
+
+
+
TIF
+
scan_receipt_0091.tiff 22.4 MB
+
TIFF → WebP failed
+
Unsupported TIFF compression (JPEG-in-TIFF, old-style)
+
+
+
+
+ Video 1 video running
+
+
+
MOV
+
screen-recording-standup.mov 812 MB
+
MOV → MP4 running
+
⣶⣶⣤⣤⣶⣶⣶⣤⣴⣶ 18.4% 3 min 12 s
+
+
+
+
+
+ Documents 2 documents 1 done
+
+
+
PDF
+
Q3-board-deck.pdf 14.1 MB
+
PDF → DOCX queued
+
waiting
+
+
+
+
TXT
+
release-notes-0.3.txt 6 KB
+
TXT → PDF done
+
6 KB → 48 KB
+
+
+
+
+ Archives 1 archive queued
+
+
+
CBZ
+
One-Piece-v104.cbz 186 MB
+
CBZ → CB7 queued
+
waiting
+
+
+
+
+ end of queue R retry failedSpace pause allTab next group
+
+
+
+
+
+
Images · 4 selected
+
HEIC, PNG ×2, TIFF · 32.0 MB in
+
+
+
Target format F
+
+ PNG2 selected files are already PNG
+ JPG
+ WebP
+ AVIF
+ JXL
+ TIFFscan_receipt_0091 is already TIFF
+ BMP
+ GIF
+ ICO
+
+
Dashed formats match a selected source. Hover for why.
+
+
+
Quality Q
+
Smaller Balanced Best
+
+
+
+
Output
+
+ Next to source
+ Change folder…D:\Photos\2026-10\
+
+
Never overwrites. Collisions save as name (converted).webp, then (2).
+
+
+
+
est. output ≈ 6.4 MB · -80%
+
Convert 4 images Ctrl ↵
+
+
+
+
+
+
+
+
+ 2 of 8 done · 2 running · 1 failed
+
+ ffmpeg 7.1 ImageMagick 7.1.1 mutool 1.24 7-Zip 24.08 Rar.exe not found · CBR writing off
+ Clear finished
+
+
+
+
diff --git a/docs/mockups/terminal/index.html b/docs/mockups/terminal/index.html
new file mode 100644
index 0000000..d84336f
--- /dev/null
+++ b/docs/mockups/terminal/index.html
@@ -0,0 +1,118 @@
+
+
+
+
+
+Filesmith Terminal Mockups
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/mockups/terminal/shots/01-wind-native.png b/docs/mockups/terminal/shots/01-wind-native.png
new file mode 100644
index 0000000..9f7187e
Binary files /dev/null and b/docs/mockups/terminal/shots/01-wind-native.png differ
diff --git a/docs/mockups/terminal/shots/02-wind-dense-inspector.png b/docs/mockups/terminal/shots/02-wind-dense-inspector.png
new file mode 100644
index 0000000..8648467
Binary files /dev/null and b/docs/mockups/terminal/shots/02-wind-dense-inspector.png differ
diff --git a/docs/mockups/terminal/shots/03-tmux-panes.png b/docs/mockups/terminal/shots/03-tmux-panes.png
new file mode 100644
index 0000000..ce5ede0
Binary files /dev/null and b/docs/mockups/terminal/shots/03-tmux-panes.png differ
diff --git a/docs/mockups/terminal/shots/04-command-first.png b/docs/mockups/terminal/shots/04-command-first.png
new file mode 100644
index 0000000..8184524
Binary files /dev/null and b/docs/mockups/terminal/shots/04-command-first.png differ
diff --git a/docs/mockups/terminal/shots/05-tui-function-keys.png b/docs/mockups/terminal/shots/05-tui-function-keys.png
new file mode 100644
index 0000000..77d7090
Binary files /dev/null and b/docs/mockups/terminal/shots/05-tui-function-keys.png differ
diff --git a/docs/mockups/terminal/shots/06-repl-log.png b/docs/mockups/terminal/shots/06-repl-log.png
new file mode 100644
index 0000000..d868e30
Binary files /dev/null and b/docs/mockups/terminal/shots/06-repl-log.png differ
diff --git a/docs/mockups/terminal/shots/07-swiss-rules.png b/docs/mockups/terminal/shots/07-swiss-rules.png
new file mode 100644
index 0000000..d1e4a8d
Binary files /dev/null and b/docs/mockups/terminal/shots/07-swiss-rules.png differ
diff --git a/docs/mockups/terminal/shots/08-geist-breadcrumb.png b/docs/mockups/terminal/shots/08-geist-breadcrumb.png
new file mode 100644
index 0000000..e514087
Binary files /dev/null and b/docs/mockups/terminal/shots/08-geist-breadcrumb.png differ
diff --git a/docs/mockups/terminal/shots/09-explorer-tree.png b/docs/mockups/terminal/shots/09-explorer-tree.png
new file mode 100644
index 0000000..deb5a1c
Binary files /dev/null and b/docs/mockups/terminal/shots/09-explorer-tree.png differ
diff --git a/docs/mockups/terminal/shots/10-powerline-status.png b/docs/mockups/terminal/shots/10-powerline-status.png
new file mode 100644
index 0000000..f0ce2b6
Binary files /dev/null and b/docs/mockups/terminal/shots/10-powerline-status.png differ
diff --git a/docs/mockups/terminal/shots/11-bottom-dock.png b/docs/mockups/terminal/shots/11-bottom-dock.png
new file mode 100644
index 0000000..3a1cc82
Binary files /dev/null and b/docs/mockups/terminal/shots/11-bottom-dock.png differ
diff --git a/docs/mockups/terminal/shots/12-verb-tint.png b/docs/mockups/terminal/shots/12-verb-tint.png
new file mode 100644
index 0000000..3b193e9
Binary files /dev/null and b/docs/mockups/terminal/shots/12-verb-tint.png differ
diff --git a/docs/mockups/terminal/shots/13-one-bit.png b/docs/mockups/terminal/shots/13-one-bit.png
new file mode 100644
index 0000000..da0fabc
Binary files /dev/null and b/docs/mockups/terminal/shots/13-one-bit.png differ
diff --git a/docs/mockups/terminal/shots/14-serif-contrast.png b/docs/mockups/terminal/shots/14-serif-contrast.png
new file mode 100644
index 0000000..da3feb1
Binary files /dev/null and b/docs/mockups/terminal/shots/14-serif-contrast.png differ
diff --git a/docs/mockups/terminal/shots/15-spreadsheet-grid.png b/docs/mockups/terminal/shots/15-spreadsheet-grid.png
new file mode 100644
index 0000000..544eac0
Binary files /dev/null and b/docs/mockups/terminal/shots/15-spreadsheet-grid.png differ
diff --git a/docs/mockups/terminal/shots/16-warp-blocks.png b/docs/mockups/terminal/shots/16-warp-blocks.png
new file mode 100644
index 0000000..89ba96e
Binary files /dev/null and b/docs/mockups/terminal/shots/16-warp-blocks.png differ
diff --git a/docs/mockups/terminal/shots/17-linear-triptych.png b/docs/mockups/terminal/shots/17-linear-triptych.png
new file mode 100644
index 0000000..b71e1d6
Binary files /dev/null and b/docs/mockups/terminal/shots/17-linear-triptych.png differ
diff --git a/docs/mockups/terminal/shots/18-pipeline-board.png b/docs/mockups/terminal/shots/18-pipeline-board.png
new file mode 100644
index 0000000..4f3922a
Binary files /dev/null and b/docs/mockups/terminal/shots/18-pipeline-board.png differ
diff --git a/docs/mockups/terminal/shots/19-amber-manpage.png b/docs/mockups/terminal/shots/19-amber-manpage.png
new file mode 100644
index 0000000..52d614b
Binary files /dev/null and b/docs/mockups/terminal/shots/19-amber-manpage.png differ
diff --git a/docs/mockups/terminal/shots/20-hud-metrics.png b/docs/mockups/terminal/shots/20-hud-metrics.png
new file mode 100644
index 0000000..76263e0
Binary files /dev/null and b/docs/mockups/terminal/shots/20-hud-metrics.png differ
diff --git a/docs/mockups/terminal/shots/_index.png b/docs/mockups/terminal/shots/_index.png
new file mode 100644
index 0000000..513387d
Binary files /dev/null and b/docs/mockups/terminal/shots/_index.png differ
diff --git a/docs/superpowers/plans/2026-10-04-terminal-redesign.md b/docs/superpowers/plans/2026-10-04-terminal-redesign.md
new file mode 100644
index 0000000..d6f331d
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-04-terminal-redesign.md
@@ -0,0 +1,9155 @@
+# Terminal Redesign (S2 "VS Code grouped") Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Rebuild the Filesmith renderer to match the signed-off mockup `docs/mockups/terminal-v5/10-s2-vscode-grouped.html`: a dark, strictly monochrome, square, flush-panel workbench (title bar with breadcrumb, collapsible sidebar, files table, Options / Preview / Info inspector, status bar), with every existing behaviour kept.
+
+**Architecture:** Pure view logic (row model, totals, sorting, select-all, size estimates, breadcrumbs, status summary, keyboard maps) lands first in `.ts` files with Vitest tests. Then a token layer (`theme/*.css`, CSS custom properties mirrored into Tailwind v4 `@theme`) and a 16px icon registry, then `components/ui/` primitives, and finally the shell, queue table, inspector, options split and remaining views are rebuilt on top. Main-process changes are limited to the spec's M1 to M5 plus one additive preload method (`pickFolder`).
+
+**Tech Stack:** Electron 43, React 19, TypeScript strict, Vite via electron-vite 5, Tailwind CSS v4, Vitest 3, Playwright `_electron`, `@fontsource/ibm-plex-sans` and `@fontsource/ibm-plex-mono`.
+
+**Spec:** `docs/superpowers/specs/2026-10-04-terminal-redesign-design.md` (design rules and feedback trail: `docs/design/redesign-direction.md`; target: `docs/mockups/terminal-v5/10-s2-vscode-grouped.html`, screenshot `docs/mockups/terminal-v5/shots/10-s2-vscode-grouped.png`).
+
+## Global Constraints
+
+- **Dark only.** No `dark:` variants, no theme toggle, no `prefers-color-scheme` branch. Every colour is a CSS custom property defined once in `src/renderer/src/theme/tokens.css`.
+- **Strict monochrome.** Every colour has r=g=b. Errors, warnings and danger actions use a glyph, weight 600 and inverted fill, never red, green or amber. This includes the window close button, the former per-verb rail colours and `GROUP_COLOR`. The one exception is the Remove BG custom-colour swatch, which shows user data.
+- **Token values, verbatim:** `--bg-0 #0a0a0a`, `--bg-1 #0f0f0f`, `--hover #1a1a1a`, `--selected #202020`, `--field #141414`, `--track #2a2a2a`, `--line #262626`, `--line-strong #3a3a3a`, `--fg1 #ededed`, `--fg2 #b4b4b4`, `--fg3 #8c8c8c`, `--fg-disabled #5c5c5c`, `--inv-bg #ededed`, `--inv-fg #0a0a0a`, `--inv-hover #ffffff`, `--inv-active #bdbdbd`, `--focus #ededed`.
+- **Square and flush.** `border-radius: 0` globally, 1px borders only, no shadows, no cards except the estimate box. 2px `--fg1` bars mark active items.
+- **Type:** `--sans: "IBM Plex Sans", system-ui, sans-serif`, `--mono: "IBM Plex Mono", Consolas, monospace`, weights 400/500/600, bundled through `@fontsource` (no Google Fonts; CSP stays as is). Body `13px/1.4 var(--sans)`. Numbers use `tabular-nums`.
+- **Fixed heights:** 32 title bar / sidebar head / toolbar / tab bar / table row / info row; 28 table head / select / input / segment / in-row button; 36 sidebar item; 30 totals; 48 primary footer; 24 status bar.
+- **No drop row, no progress bar under a row.** Progress reads `62%(4s)` in the status column. The whole window accepts drops.
+- **Controls read as buttons** (boxed, outlined or inverted). No underlined or dotted words as controls.
+- **Avoid Wind's signatures:** no floating progress-plus-button pill, no icon-tile header band, no big rounded cards of right-aligned setting rows.
+- **No em-dashes anywhere**: code, comments, copy, commit messages, PR text. Use commas, en-dashes or rephrase.
+- **Renderer stays browser-only.** No `fs` or `child_process` in `src/renderer`. The preload API is additive only (`pickFolder`); no existing `window.filesmith.*` method changes signature.
+- **Never overwrite** a source or an existing output. Output paths still come from `reserveOutPath` / `reserveFileInDir` / `uniqueOutDir`.
+- **State semantics are frozen.** Existing reducer actions, `queueKey`, `optionsKey` and the v2 session schema keep their meaning. New actions are additive.
+- **React Fast Refresh rule:** `.tsx` files export components only. Pure helpers live in sibling `.ts` files so Vitest can import them.
+- **Tests:** unit tests in `test/*.test.ts` (renderer modules imported by relative path `../src/renderer/src/...`, shared through `@shared/...`); e2e in `e2e/*.spec.ts`.
+- **Owner checks O1 to O10** in spec section 5 are built as proposed there; they are approved together with this plan (O10 is the run scope, see Task 12). O7 resolution: ship OUTPUT > Location (M3); show FILES > If file exists as a fixed, disabled select reading `add (2)`; defer Naming and Keep metadata.
+- **Delivery:** one issue, one branch `feat/-terminal-redesign`, one PR. Never merge without the owner's explicit "merge" for this PR. Version bump to `0.5.0` inside the PR.
+- **Deleting files:** `trash ""` for anything outside this session's scratchpad (global rule); `git rm` for tracked files being removed by the change.
+
+## Review Focus
+
+- **Output folder removed between choosing it and pressing Run:** the job must fail with "Output folder not found" and must never fall back to writing somewhere else. Pinned in Task 3.
+- **Zero-byte source or folder output:** the result cell must not show `NaN%` or `-100%`; a folder output shows `folder` and no percentage, a zero-byte source shows no percentage. Pinned in Task 5.
+- **Re-running a row and restoring an old session:** a source re-queued in place must drop its previous `outputSize` / `outputPath`, and a `done` source restored from a pre-0.5 session (no `outputPath` on the source) must render `Done` with an empty result cell, not a crash or a stale value. Pinned in Tasks 4 and 5.
+- **Header checkbox in a mixed queue:** with a selection in group B it selects all of B and nothing else; with no selection it acts on the first group in display order; it never produces a cross-group selection. Pinned in Task 6.
+- **Estimator with nothing to go on:** unknown formats, audio without a bitrate, and zero-byte sources must yield `null` (empty cell, card hidden), never `0` or `NaN`; a batch with some non-estimable rows sums only the estimable ones and says how many. Pinned in Task 7.
+
+## File Structure
+
+```
+src/main/
+ index.ts M1 window size, nativeTheme, bg; M4 FILESMITH_USER_DATA
+ windowSize.ts NEW initialWindowSize(workArea) (pure)
+ ipc.ts + 'files:pick-folder'
+ output.ts reserveOutPath(..., outDir?) + resolveOutDir()
+ jobQueue.ts ctx.outDir from options.outDir
+ tools/tool.ts ToolContext.outDir
+ tools/estimate.ts onPct(pct, etaSec)
+ tools/registry.ts pass ctx.outDir to every reserve/unique call; forward eta
+src/preload/index.ts + pickFolder()
+src/shared/
+ icons.ts NEW ICON_NAMES, IconName
+ sizeEstimate.ts NEW estimateOutputBytes, medianRatio, estimateBatch
+ tabs.ts icon: IconName, SETTINGS_TAB, colour fields removed (Task 16)
+src/renderer/src/
+ index.css imports theme/*, Tailwind @theme mirror
+ main.tsx font imports
+ state.ts source->result link, select order, selectIds, hideFinished
+ theme/tokens.css NEW the only colour literals
+ theme/base.css NEW reset, focus, scrollbars, app grid, motion
+ theme/workbench.css NEW ported mockup component CSS
+ theme/controls.css NEW new controls: chips, range, popup, progress, disabled
+ theme/views.css NEW group rows, empty states, tools list, generate grid, menu, dialog, wipe
+ components/icons/ NEW Icon.tsx, shapes.ts
+ components/ui/ NEW Button, Checkbox, Segmented, Select, TextField, Setting, RangeField,
+ ChipGrid, ProgressBar, Tabs, EstimateCard, OutputSizeList,
+ roving.ts, selectNav.ts
+ components/shell/ NEW TitleBar, Breadcrumb, Sidebar, StatusBar, crumbs.ts, sidebarState.ts,
+ useSidebar.ts, railPrefs.ts, statusModel.ts, shortcuts.ts
+ components/queue/ NEW QueueToolbar, QueueTable, QueueRow, StatusCell, ResultCell, TotalsRow,
+ EmptyState, rowModel.ts, tableSort.ts, selectAll.ts, tableKeys.ts
+ components/inspector/ NEW Inspector, PreviewPane, InfoPane, Wipe, infoModel.ts
+ components/options/ NEW OptionsPane + one file per verb + generate/ + upscale/
+ components/views/ NEW GenerateView, ToolsView, CompletedView, SettingsView
+ components/ DEL TopBar, TabRail, OperationTitle, DropZone, Queue, OptionsPanel,
+ ToolsGrid, PromptBox, CompletedView (old), Icon (old)
+test/ new *.test.ts per pure module
+e2e/ selector updates, ui.spec.ts, visual.spec.ts
+```
+
+The app will look half-converted between Tasks 8 and 16. That is expected on the branch; each task still passes `npm test`, `npm run typecheck` and `npm run lint`.
+
+---
+
+### Task 0: Issue, branch and design record
+
+**Files:**
+
+- Add (currently untracked): `docs/design/redesign-direction.md`, `docs/mockups/terminal/`, `docs/mockups/terminal-v2/`, `docs/mockups/terminal-v3/`, `docs/mockups/terminal-v4/`, `docs/mockups/terminal-v5/`, `docs/superpowers/specs/2026-10-04-terminal-redesign-design.md`, `docs/superpowers/plans/2026-10-04-terminal-redesign.md`
+
+**Interfaces:**
+
+- Consumes: nothing.
+- Produces: branch `feat/-terminal-redesign` where `` is the new issue number. Every later commit lands here.
+
+- [ ] **Step 1: Create the issue**
+
+```bash
+gh issue create --title "Terminal redesign (S2 VS Code grouped)" --body "Rebuild the renderer to match docs/mockups/terminal-v5/10-s2-vscode-grouped.html. Spec: docs/superpowers/specs/2026-10-04-terminal-redesign-design.md. Plan: docs/superpowers/plans/2026-10-04-terminal-redesign.md."
+```
+
+Note the issue number `` it prints.
+
+- [ ] **Step 2: Branch from an up-to-date main**
+
+```bash
+git switch main && git pull --ff-only
+git switch -c feat/-terminal-redesign
+```
+
+- [ ] **Step 3: Commit the design record**
+
+`CLAUDE.md` has an uncommitted owner edit; leave it unstaged here. Task 18 edits `CLAUDE.md` and commits both together.
+
+```bash
+git add docs/design/redesign-direction.md docs/mockups/terminal docs/mockups/terminal-v2 docs/mockups/terminal-v3 docs/mockups/terminal-v4 docs/mockups/terminal-v5 docs/superpowers/specs/2026-10-04-terminal-redesign-design.md docs/superpowers/plans/2026-10-04-terminal-redesign.md
+git commit -m "docs(design): terminal redesign direction, mockups, spec and plan
+
+Co-Authored-By: Claude Opus 5.5 "
+```
+
+---
+
+### Task 1: Window defaults, dark native theme, test userData override, folder picker (M1, M4)
+
+**Files:**
+
+- Create: `src/main/windowSize.ts`
+- Modify: `src/main/index.ts:1` (imports), `src/main/index.ts:134-148` (`createWindow`), top-level before `requestSingleInstanceLock`
+- Modify: `src/main/ipc.ts` (after the `'files:pick'` handler at `:161-167`)
+- Modify: `src/preload/index.ts:21-23` (next to `pickFiles` / `pickImage`)
+- Test: `test/window-size.test.ts`
+
+**Interfaces:**
+
+- Consumes: nothing.
+- Produces: `initialWindowSize(work: { width: number; height: number }): { width: number; height: number }`; `MIN_WINDOW = { width: 1100, height: 640 }`; `window.filesmith.pickFolder(): Promise`; env `FILESMITH_USER_DATA` (absolute folder) redirects `userData` when set.
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+// test/window-size.test.ts
+import { describe, expect, it } from 'vitest'
+import { initialWindowSize, MIN_WINDOW } from '../src/main/windowSize'
+
+describe('initialWindowSize', () => {
+ it('uses 1440x900 when the work area is larger', () => {
+ expect(initialWindowSize({ width: 2560, height: 1400 })).toEqual({ width: 1440, height: 900 })
+ })
+
+ it('shrinks to the work area on a small display', () => {
+ expect(initialWindowSize({ width: 1366, height: 728 })).toEqual({ width: 1366, height: 728 })
+ })
+
+ it('never goes below the minimum, even on a tiny display', () => {
+ expect(initialWindowSize({ width: 1024, height: 600 })).toEqual(MIN_WINDOW)
+ })
+})
+```
+
+- [ ] **Step 2: Run it and watch it fail**
+
+Run: `npx vitest run test/window-size.test.ts`
+Expected: FAIL, cannot resolve `../src/main/windowSize`.
+
+- [ ] **Step 3: Implement**
+
+```ts
+// src/main/windowSize.ts
+// The redesign's table needs about 1440px to give the name column room
+// (spec section 3.7). Clamp to the display so a small laptop still fits.
+
+export const DEFAULT_WINDOW = { width: 1440, height: 900 }
+export const MIN_WINDOW = { width: 1100, height: 640 }
+
+export function initialWindowSize(work: { width: number; height: number }): {
+ width: number
+ height: number
+} {
+ return {
+ width: Math.max(MIN_WINDOW.width, Math.min(DEFAULT_WINDOW.width, work.width)),
+ height: Math.max(MIN_WINDOW.height, Math.min(DEFAULT_WINDOW.height, work.height))
+ }
+}
+```
+
+- [ ] **Step 4: Run the test**
+
+Run: `npx vitest run test/window-size.test.ts`
+Expected: PASS (3 tests).
+
+- [ ] **Step 5: Wire it into `createWindow`, set the dark native theme, add the userData override**
+
+In `src/main/index.ts` change the electron import to:
+
+```ts
+import { app, nativeTheme, protocol, screen, shell, BrowserWindow } from 'electron'
+import { initialWindowSize, MIN_WINDOW } from './windowSize'
+```
+
+Directly after the imports (before any `app.requestSingleInstanceLock()` call and before `app.whenReady`), add:
+
+```ts
+// Test hook (spec M4): e2e points userData at a temp folder to seed a session.
+// Read only when set, so a normal launch is unaffected.
+const userDataOverride = process.env['FILESMITH_USER_DATA']
+if (userDataOverride) app.setPath('userData', userDataOverride)
+```
+
+Replace the `new BrowserWindow({...})` options head in `createWindow()` with:
+
+```ts
+// Dark only: native scrollbars, dialogs and the pre-paint fill follow the app.
+nativeTheme.themeSource = 'dark'
+const size = initialWindowSize(screen.getPrimaryDisplay().workAreaSize)
+const mainWindow = new BrowserWindow({
+ width: size.width,
+ height: size.height,
+ minWidth: MIN_WINDOW.width,
+ minHeight: MIN_WINDOW.height,
+ show: false,
+ // Frameless: the renderer draws the 32px title bar and window controls.
+ frame: false,
+ backgroundColor: '#0a0a0a',
+ webPreferences: {
+ preload: join(__dirname, '../preload/index.js'),
+ sandbox: false
+ }
+})
+```
+
+- [ ] **Step 6: Add the folder picker (OUTPUT > Location needs it)**
+
+In `src/main/ipc.ts`, after the `'files:pick'` handler:
+
+```ts
+// Output folder for OUTPUT > Location. Additive: no existing channel changes.
+ipcMain.handle('files:pick-folder', async (e) => {
+ const r = await openDialog(e, {
+ title: 'Choose output folder',
+ properties: ['openDirectory', 'createDirectory']
+ })
+ return r.canceled || !r.filePaths.length ? null : r.filePaths[0]
+})
+```
+
+In `src/preload/index.ts`, after `pickImage`:
+
+```ts
+ /** Folder picker for the output location; resolves to the path or null. */
+ pickFolder: (): Promise => ipcRenderer.invoke('files:pick-folder'),
+```
+
+- [ ] **Step 7: Verify**
+
+Run: `npm run typecheck && npm run lint && npm test`
+Expected: all green.
+
+Run: `npm run dev`, check the window opens at 1440x900 (or the work area) with a near-black fill before the renderer paints, then close it.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add src/main/windowSize.ts src/main/index.ts src/main/ipc.ts src/preload/index.ts test/window-size.test.ts
+git commit -m "feat(main): dark window defaults, 1440x900 clamp, userData test hook, folder picker
+
+Co-Authored-By: Claude Opus 5.5 "
+```
+
+---
+
+### Task 2: Remaining-seconds estimate for ticker-driven jobs (M2)
+
+**Files:**
+
+- Modify: `src/main/tools/estimate.ts:19-47`
+- Modify: `src/main/tools/registry.ts:118`, `:442`, `:595`, `:724-730`, `:990`, `:1307-1309`
+- Test: `test/estimate.test.ts` (extend)
+
+**Interfaces:**
+
+- Consumes: `ToolContext.onProgress(percent, message?, etaSec?)` from `src/main/tools/tool.ts:7` (unchanged).
+- Produces: `estimateProgress(expectedSec, onPct: (pct: number, etaSec: number | null) => void, opts)`. `etaSec` is `ceil(expectedSec - elapsed)` while positive, otherwise `null` (the row then shows `62%` without a bracket).
+
+- [ ] **Step 1: Write the failing test** (append to `test/estimate.test.ts`)
+
+```ts
+describe('estimateProgress remaining seconds', () => {
+ it('counts down from the expected duration and goes null once overdue', () => {
+ vi.useFakeTimers()
+ const etas: (number | null)[] = []
+ const t = estimateProgress(2, (_p, eta) => etas.push(eta))
+ expect(etas[0]).toBe(2) // 2 - 0.2 = 1.8, rounded up
+ vi.advanceTimersByTime(1000) // elapsed 1.2s
+ expect(etas[etas.length - 1]).toBe(1)
+ vi.advanceTimersByTime(2000) // elapsed 3.2s, past the estimate
+ expect(etas[etas.length - 1]).toBeNull()
+ t.stop()
+ vi.useRealTimers()
+ })
+})
+```
+
+- [ ] **Step 2: Run it and watch it fail**
+
+Run: `npx vitest run test/estimate.test.ts`
+Expected: FAIL, `etas[0]` is `undefined`.
+
+- [ ] **Step 3: Implement** (in `estimate.ts`)
+
+Change the callback type and the tick:
+
+```ts
+export function estimateProgress(
+ expectedSec: number,
+ onPct: (pct: number, etaSec: number | null) => void,
+ opts: { ceiling?: number; startPct?: number } = {}
+): EstimateTicker {
+```
+
+and inside `tick`:
+
+```ts
+const pct = startPct + (ceiling - startPct) * (1 - Math.exp(-elapsed / tau))
+// The same expected duration that shapes the curve doubles as an ETA, so
+// image, PDF and rembg rows can show 62%(4s) like ffmpeg rows do.
+const left = expectedSec - elapsed
+onPct(Math.min(ceiling, pct), left > 0 ? Math.ceil(left) : null)
+```
+
+- [ ] **Step 4: Forward the ETA at every caller in `registry.ts`**
+
+Replace each `(p) => ctx.onProgress(p)` passed to `estimateProgress` with `(p, eta) => ctx.onProgress(p, undefined, eta)` (lines 118, 442, 595, 990). For the PiD ticker at `:724-730`:
+
+```ts
+est = estimateProgress(
+ expectedSec,
+ (p, eta) => {
+ lastPct = p
+ ctx.onProgress(p, undefined, eta)
+ },
+ { startPct: lastPct }
+)
+```
+
+For the archive from-pdf ticker at `:1307-1309`, which caps the estimate at 90%:
+
+```ts
+const est = estimateProgress(estimateSecForBytes(file.size, 0.15), (p, eta) =>
+ ctx.onProgress(Math.min(p, 90), undefined, eta)
+)
+```
+
+Check with `grep -n "estimateProgress(" src/main/tools/registry.ts` that all six call sites now take `(p, eta)`.
+
+`message` stays `undefined`, and the reducer already keeps the last message on a percent-only update (`state.ts:647`). A `null` ETA reaches the renderer as `undefined` (`jobQueue.ts:99`); Task 4 makes the reducer drop the previous ETA in that case, so an overdue row shows `62%` and not a frozen `(1s)`.
+
+- [ ] **Step 5: Verify**
+
+Run: `npx vitest run test/estimate.test.ts && npm run typecheck && npm run lint`
+Expected: PASS, typecheck and lint clean.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/main/tools/estimate.ts src/main/tools/registry.ts test/estimate.test.ts
+git commit -m "feat(engine): report remaining seconds from the estimated-progress ticker
+
+Co-Authored-By: Claude Opus 5.5 "
+```
+
+---
+
+### Task 3: Optional output folder (M3, OUTPUT > Location)
+
+**Files:**
+
+- Modify: `src/main/output.ts`
+- Modify: `src/main/tools/tool.ts:4-8`
+- Modify: `src/main/jobQueue.ts:89-100`
+- Modify: `src/main/tools/registry.ts` (every `reserveOutPath(file.path, ...)` and `uniqueOutDir(dirname(file.path), ...)`)
+- Test: `test/output.test.ts` (extend)
+
+**Interfaces:**
+
+- Consumes: `JobOptions.outDir?: string` (new optional key; empty string or absent means next to the source).
+- Produces: `reserveOutPath(sourcePath: string, ext: string, tag: string, outDir?: string): string`; `resolveOutDir(value: unknown): string | undefined` (throws `Error('Output folder not found: ')` when set but missing); `ToolContext.outDir?: string`.
+
+Every tool helper already has `ctx` in scope (they all call `ctx.onProgress`), so threading the folder through the context reaches the Comfy, ncnn and PiD helpers that never see `options`.
+
+- [ ] **Step 1: Write the failing tests** (append to `test/output.test.ts`; reuse that file's temp-dir setup and imports, adding `resolveOutDir` to the `../src/main/output` import and `mkdtempSync`, `mkdirSync`, `writeFileSync`, `existsSync` from `fs`, `join` from `path`, `tmpdir` from `os` if not already imported)
+
+```ts
+describe('output folder', () => {
+ it('reserves the output in the chosen folder, not next to the source', () => {
+ const root = mkdtempSync(join(tmpdir(), 'fs-outdir-'))
+ const srcDir = join(root, 'src')
+ const outDir = join(root, 'out')
+ mkdirSync(srcDir)
+ mkdirSync(outDir)
+ const src = join(srcDir, 'a.png')
+ writeFileSync(src, 'x')
+ const out = reserveOutPath(src, '.webp', 'converted', outDir)
+ expect(out).toBe(join(outDir, 'a.webp'))
+ expect(existsSync(join(srcDir, 'a.webp'))).toBe(false)
+ })
+
+ it('keeps collision safety inside the chosen folder', () => {
+ const root = mkdtempSync(join(tmpdir(), 'fs-outdir-'))
+ writeFileSync(join(root, 'a.webp'), 'existing')
+ const out = reserveOutPath(join(root, 'elsewhere', 'a.png'), '.webp', 'converted', root)
+ expect(out).toBe(join(root, 'a (converted).webp'))
+ })
+
+ it('treats an empty or missing option as next to the source', () => {
+ expect(resolveOutDir(undefined)).toBeUndefined()
+ expect(resolveOutDir('')).toBeUndefined()
+ })
+
+ it('refuses a folder that no longer exists instead of writing elsewhere', () => {
+ const gone = join(tmpdir(), `fs-gone-${Date.now()}`)
+ expect(() => resolveOutDir(gone)).toThrow(/Output folder not found/)
+ })
+})
+```
+
+- [ ] **Step 2: Run them and watch them fail**
+
+Run: `npx vitest run test/output.test.ts`
+Expected: FAIL, `resolveOutDir` is not exported and the first test writes next to the source.
+
+- [ ] **Step 3: Implement in `output.ts`**
+
+```ts
+/** Atomically-reserved output with a new extension: next to the source, or in
+ * `outDir` when the user chose an output folder. Collision rules are identical. */
+export function reserveOutPath(
+ sourcePath: string,
+ ext: string,
+ tag: string,
+ outDir?: string
+): string {
+ const dir = outDir ?? dirname(sourcePath)
+ const name = basename(sourcePath, extname(sourcePath))
+ return reserveFileInDir(dir, name, ext, tag)
+}
+
+/** The job's output folder from `options.outDir`, or undefined for "next to
+ * source". A folder that has gone missing is an error: silently writing next to
+ * the source would put files where the user did not ask for them. */
+export function resolveOutDir(value: unknown): string | undefined {
+ if (typeof value !== 'string' || value.trim() === '') return undefined
+ if (!existsSync(value)) throw new Error(`Output folder not found: ${value}`)
+ return value
+}
+```
+
+- [ ] **Step 4: Thread it through the context**
+
+`src/main/tools/tool.ts`, inside `ToolContext`:
+
+```ts
+ /** Folder chosen under OUTPUT > Location; undefined means next to the source. */
+ outDir?: string
+```
+
+`src/main/jobQueue.ts`, in `execute()` (add `resolveOutDir` to an `import { resolveOutDir } from './output'`):
+
+```ts
+ const file = fileInfoFromPath(req.input)
+ const outDir = resolveOutDir(req.options.outDir)
+ const output = await tool.run(file, req.options, {
+ signal: ctrl.signal,
+ outDir,
+ onProgress: (percent, message, etaSec) =>
+```
+
+`resolveOutDir` throwing lands in the existing `catch`, which emits `failed` with the message.
+
+- [ ] **Step 5: Pass `ctx.outDir` at every reserve site in `registry.ts`**
+
+Run these two mechanical replacements, then read every changed line:
+
+```bash
+sed -i -E "s/reserveOutPath\(file\.path, ([^)]*)\)/reserveOutPath(file.path, \1, ctx.outDir)/" src/main/tools/registry.ts
+sed -i -E "s/uniqueOutDir\(dirname\(file\.path\),/uniqueOutDir(ctx.outDir ?? dirname(file.path),/" src/main/tools/registry.ts
+grep -n "reserveOutPath\|uniqueOutDir" src/main/tools/registry.ts
+```
+
+Expected: every call ends in `ctx.outDir)` or starts with `ctx.outDir ??`. The multi-line `uniqueOutDir(` at `:432-433` and `:1164-1165` puts `dirname(file.path),` on its own line; edit those two by hand to `ctx.outDir ?? dirname(file.path),`. `src/main/generate/index.ts:120` is left alone (Generate has no Location setting).
+
+- [ ] **Step 6: Verify**
+
+Run: `npm test && npm run typecheck && npm run lint`
+Expected: all green, including the existing `registry*.test.ts` and `percent-paths.test.ts`.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/main/output.ts src/main/tools/tool.ts src/main/jobQueue.ts src/main/tools/registry.ts test/output.test.ts
+git commit -m "feat(engine): optional output folder with unchanged collision safety
+
+Co-Authored-By: Claude Opus 5.5 "
+```
+
+---
+
+### Task 4: State additions: source-to-result link, ordered range, explicit selection, clear finished, Settings tab id
+
+**Files:**
+
+- Modify: `src/renderer/src/state.ts` (`Action` union `:167-179`, `selectInQueue` `:419-455`, `markQueued` `:578-596`, `jobEvent` done branch `:604-637`)
+- Modify: `src/shared/tabs.ts` (`TabId` `:17-18`, `COMPLETED_TAB` `:100-108`, `tabById` `:223-226`)
+- Test: `test/queues.test.ts`, `test/verb-state.test.ts`, `test/tabs.test.ts` (extend)
+
+**Interfaces:**
+
+- Consumes: existing `reducer`, `initialState`, `QueueItem`.
+- Produces:
+ - Action `{ type: 'select'; id: string; mode: SelectMode; order?: string[] }`: `order` is the visible id order for range selection.
+ - Action `{ type: 'selectIds'; ids: string[] }`: replaces the current workspace selection; anchor becomes `ids[0] ?? null`.
+ - Action `{ type: 'hideFinished' }`: sets `hiddenInput` on `done` and `canceled` input rows of the current workspace.
+ - On a `done` job event the source row gains `outputPath` and `outputSize`; `markQueued` clears both, and `etaSec`.
+ - A `running` event that carries a percent but no `etaSec` clears the stored `etaSec` (it used to keep the previous value forever, which would freeze `62%(1s)` once a ticker runs past its estimate).
+ - `TabId` gains `'settings'`; `SETTINGS_TAB: Tab`; `tabById('settings')` returns it.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `test/queues.test.ts`:
+
+```ts
+describe('source to result link', () => {
+ it('writes the output path and size onto the source when its job finishes', () => {
+ let s = reducer(start, { type: 'addItems', files: [img('a.png')], key: CONVERT })
+ const id = s.queues[CONVERT]!.items[0].id
+ s = reducer(s, {
+ type: 'jobEvent',
+ event: { id, status: 'done', outputPath: 'C:/x/a.webp', outputSize: 4 }
+ })
+ const src = s.queues[CONVERT]!.items.find((i) => i.id === id)!
+ expect(src.outputPath).toBe('C:/x/a.webp')
+ expect(src.outputSize).toBe(4)
+ // The separate result item, which Completed relies on, is still appended.
+ expect(s.queues[CONVERT]!.items.filter((i) => i.isResult)).toHaveLength(1)
+ })
+
+ it('clears the previous result when the row is queued again in place', () => {
+ let s = reducer(start, { type: 'addItems', files: [img('a.png')], key: CONVERT })
+ const id = s.queues[CONVERT]!.items[0].id
+ s = reducer(s, {
+ type: 'jobEvent',
+ event: { id, status: 'done', outputPath: 'C:/x/a.webp', outputSize: 4 }
+ })
+ s = reducer(s, { type: 'markQueued', ids: [id] })
+ const src = s.queues[CONVERT]!.items.find((i) => i.id === id)!
+ expect(src.outputPath).toBeUndefined()
+ expect(src.outputSize).toBeUndefined()
+ })
+})
+
+describe('compact ETA', () => {
+ it('drops a stale ETA on a percent-only update and on re-queue', () => {
+ let s = reducer(start, { type: 'addItems', files: [img('a.png')], key: CONVERT })
+ const id = s.queues[CONVERT]!.items[0].id
+ const eta = (): number | undefined => s.queues[CONVERT]!.items.find((i) => i.id === id)!.etaSec
+ s = reducer(s, { type: 'jobEvent', event: { id, status: 'running', percent: 40, etaSec: 4 } })
+ expect(eta()).toBe(4)
+ s = reducer(s, { type: 'jobEvent', event: { id, status: 'running', message: 'Still going' } })
+ expect(eta()).toBe(4) // a message-only update keeps it
+ s = reducer(s, { type: 'jobEvent', event: { id, status: 'running', percent: 60 } })
+ expect(eta()).toBeUndefined()
+ s = reducer(s, { type: 'jobEvent', event: { id, status: 'running', percent: 70, etaSec: 2 } })
+ s = reducer(s, { type: 'markQueued', ids: [id] })
+ expect(eta()).toBeUndefined()
+ })
+})
+
+describe('clear finished', () => {
+ it('hides done and canceled inputs but keeps their results for Completed', () => {
+ let s = reducer(start, {
+ type: 'addItems',
+ files: [img('a.png'), img('b.png'), img('c.png')],
+ key: CONVERT
+ })
+ const [a, b] = s.queues[CONVERT]!.items.map((i) => i.id)
+ s = reducer(s, {
+ type: 'jobEvent',
+ event: { id: a, status: 'done', outputPath: 'C:/x/a.webp' }
+ })
+ s = reducer(s, { type: 'jobEvent', event: { id: b, status: 'canceled' } })
+ s = reducer(s, { type: 'hideFinished' })
+ const q = s.queues[CONVERT]!
+ expect(q.items.filter((i) => !i.isResult && !i.hiddenInput).map((i) => i.file.name)).toEqual([
+ 'c.png'
+ ])
+ expect(q.items.some((i) => i.isResult && i.outputPath === 'C:/x/a.webp')).toBe(true)
+ })
+})
+```
+
+Append to `test/verb-state.test.ts` (reuse its `img` helper if present, otherwise copy the `img` helper from `test/queues.test.ts`):
+
+```ts
+describe('selection in visible order', () => {
+ it('ranges over the order the table shows, not insertion order', () => {
+ let s = reducer(initialState, {
+ type: 'addItems',
+ files: [img('a.png'), img('b.png'), img('c.png')],
+ key: 'convert'
+ })
+ const [a, b, c] = s.queues.convert!.items.map((i) => i.id)
+ s = reducer(s, { type: 'select', id: c, mode: 'single' })
+ // Table sorted c, a, b: shift-click a selects c and a only.
+ s = reducer(s, { type: 'select', id: a, mode: 'range', order: [c, a, b] })
+ expect(s.queues.convert!.selected.sort()).toEqual([a, c].sort())
+ })
+
+ it('selectIds replaces the selection and drops ids outside the queue', () => {
+ let s = reducer(initialState, {
+ type: 'addItems',
+ files: [img('a.png'), img('b.png')],
+ key: 'convert'
+ })
+ const [a, b] = s.queues.convert!.items.map((i) => i.id)
+ s = reducer(s, { type: 'selectIds', ids: [b, a, 'nope'] })
+ expect(s.queues.convert!.selected).toEqual([b, a])
+ expect(s.queues.convert!.anchor).toBe(b)
+ })
+})
+```
+
+Append to `test/tabs.test.ts`:
+
+```ts
+import { SETTINGS_TAB } from '@shared/tabs'
+
+describe('settings tab', () => {
+ it('is reachable by id but is not one of the seven verbs', () => {
+ expect(tabById('settings')).toBe(SETTINGS_TAB)
+ expect(TABS.some((t) => t.id === 'settings')).toBe(false)
+ })
+})
+```
+
+(Merge the import into the file's existing `@shared/tabs` import.)
+
+- [ ] **Step 2: Run them and watch them fail**
+
+Run: `npx vitest run test/queues.test.ts test/verb-state.test.ts test/tabs.test.ts`
+Expected: FAIL (source has no `outputPath`; unknown action types; `SETTINGS_TAB` missing).
+
+- [ ] **Step 3: Implement in `state.ts`**
+
+Action union additions:
+
+```ts
+ | { type: 'select'; id: string; mode: SelectMode; order?: string[] }
+ | { type: 'selectIds'; ids: string[] }
+ | { type: 'hideFinished' }
+```
+
+(replace the existing `select` member with the one above).
+
+`selectInQueue` gains an `order` parameter:
+
+```ts
+function selectInQueue(q: QueueState, id: string, mode: SelectMode, visible?: string[]): QueueState {
+ const known = new Set(q.items.map((i) => i.id))
+ // Range follows what the user SEES: the table may be sorted.
+ const order = visible ? visible.filter((v) => known.has(v)) : q.items.map((i) => i.id)
+```
+
+and the reducer case becomes `return mapQueue(state, (q) => selectInQueue(q, action.id, action.mode, action.order))`.
+
+New reducer cases (before `default`):
+
+```ts
+ case 'selectIds':
+ return mapQueue(state, (q) => {
+ const known = new Set(q.items.filter(inInput).map((i) => i.id))
+ const ids = action.ids.filter((id) => known.has(id))
+ return { ...q, selected: ids, anchor: ids[0] ?? null }
+ })
+ case 'hideFinished':
+ return mapQueue(state, (q) => {
+ const items = q.items.map((i) =>
+ !i.isResult && (i.status === 'done' || i.status === 'canceled')
+ ? { ...i, hiddenInput: true }
+ : i
+ )
+ const kept = items.filter((i) => inInput(i) || inOutput(i))
+ const selectable = new Set(kept.filter(inInput).map((i) => i.id))
+ return {
+ items: kept,
+ selected: q.selected.filter((s) => selectable.has(s)),
+ anchor: q.anchor && selectable.has(q.anchor) ? q.anchor : null
+ }
+ })
+```
+
+`markQueued`: next to `outputPath: undefined,` add `outputSize: undefined,` and `etaSec: undefined,`.
+
+Non-done `jobEvent` branch (`state.ts:640-652`): replace `etaSec: e.etaSec ?? i.etaSec,` with
+
+```ts
+ // A fresh percent without an ETA means the tool no longer has one
+ // (ticker overdue, ffmpeg before its first time stamp): drop the old one.
+ etaSec: e.percent != null ? e.etaSec : i.etaSec,
+```
+
+`jobEvent` done branch: in the source update object add the link:
+
+```ts
+ ? {
+ ...i,
+ status: 'done' as ItemStatus,
+ percent: 100,
+ message: undefined,
+ error: undefined,
+ // The table shows source -> result on one row (spec 6.1).
+ outputPath: e.outputPath,
+ outputSize: e.outputSize
+ }
+```
+
+Check that `mapQueue` acts on the current workspace queue (it does for `select` and `clearSelection` today); `hideFinished` and `selectIds` use it the same way.
+
+- [ ] **Step 4: Implement in `tabs.ts`**
+
+```ts
+export type TabId =
+ | 'convert'
+ | 'compress'
+ | 'resize'
+ | 'upscale'
+ | 'removebg'
+ | 'generate'
+ | 'tools'
+ | 'completed'
+ | 'settings'
+```
+
+After `COMPLETED_TAB`:
+
+```ts
+/** Sidebar layout and tool status. Pinned to the bottom group, not a verb. */
+export const SETTINGS_TAB: Tab = {
+ id: 'settings',
+ label: 'Settings',
+ desc: 'Sidebar and tools',
+ color: '#6e6e6e',
+ icon: 'settings',
+ kinds: [],
+ tool: 'convert'
+}
+```
+
+(`color` is removed from every tab in Task 16; it stays here so the type still checks.)
+
+```ts
+export function tabById(id: TabId): Tab {
+ if (id === 'completed') return COMPLETED_TAB
+ if (id === 'settings') return SETTINGS_TAB
+ return TABS.find((t) => t.id === id) ?? TABS[0]
+}
+```
+
+- [ ] **Step 5: Run the tests**
+
+Run: `npm test && npm run typecheck`
+Expected: PASS. If `App.tsx` fails typecheck on an exhaustive `switch (state.tab)`, add a `case 'settings':` that falls through to the Completed branch for now; Task 15 gives it its own view.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/renderer/src/state.ts src/shared/tabs.ts test/queues.test.ts test/verb-state.test.ts test/tabs.test.ts
+git commit -m "feat(state): link sources to results, ordered range, selectIds, clear finished, settings tab
+
+Co-Authored-By: Claude Opus 5.5 "
+```
+
+---
+
+### Task 5: Row model (result, status, row action, totals, compact progress)
+
+**Files:**
+
+- Create: `src/renderer/src/components/queue/rowModel.ts`
+- Test: `test/row-model.test.ts`
+
+**Interfaces:**
+
+- Consumes: `QueueItem`, `inInput`, `ItemStatus` from `state.ts`; `formatBytes` from `@shared/compress`.
+- Produces (all exported from `rowModel.ts`):
+ - `formatEtaCompact(sec: number | null | undefined): string` gives `'(4s)'`, `'(2m)'`, `'(1h 5m)'` or `''`.
+ - `progressParts(pct: number, etaSec?: number | null): { pct: string; eta: string }` and `formatProgress(pct, etaSec?) => pct + eta` (`formatProgress(62, 4) === '62%(4s)'`).
+ - `kindLabel(ext: string): string` gives `'HEIC'`.
+ - `pctChange(src: number, out: number): number | null` and `formatPct(n: number): string` (`-80%`, `+12%`, `0%`).
+ - `shortError(message: string | undefined): string`.
+ - `type RowActionKind = 'reveal' | 'remove' | 'cancel' | 'retry'`
+ - `interface RowView { kind: string; size: string; result: ResultView | null; status: StatusView; action: RowActionView }` with
+ `ResultView { text: string; pct: string | null; estimate: boolean; grew: boolean }`,
+ `StatusView { kind: ItemStatus; text: string; pct?: string; eta?: string; title?: string }`,
+ `RowActionView { kind: RowActionKind; icon: 'folder' | 'close' | 'retry'; label: string; ghost: boolean }`.
+ - `rowView(item: QueueItem, estimateBytes?: number | null): RowView`
+ - `interface Totals { files: number; bytes: number; doneSrc: number; doneOut: number; done: number; failed: number; inFlight: number }` and `queueTotals(items: QueueItem[]): Totals` (input rows only).
+ - `doneSamples(items: QueueItem[], group: string, options: JobOptions): { source: number; output: number }[]` (done input rows of `group` whose `runOptions` equal `options`).
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+// test/row-model.test.ts
+import { describe, expect, it } from 'vitest'
+import {
+ doneSamples,
+ formatEtaCompact,
+ formatPct,
+ formatProgress,
+ kindLabel,
+ pctChange,
+ queueTotals,
+ rowView,
+ shortError
+} from '../src/renderer/src/components/queue/rowModel'
+import type { QueueItem } from '../src/renderer/src/state'
+
+const item = (over: Partial = {}): QueueItem => ({
+ id: over.id ?? 'a',
+ file: { path: 'C:/x/a.heic', name: 'a.heic', ext: '.heic', kind: 'image', size: 3_200_000 },
+ thumb: null,
+ status: 'ready',
+ percent: 0,
+ ...over
+})
+
+describe('compact progress', () => {
+ it('formats the mockup string', () => {
+ expect(formatProgress(62, 4)).toBe('62%(4s)')
+ })
+ it('drops the bracket without an ETA', () => {
+ expect(formatProgress(62.4, undefined)).toBe('62%')
+ expect(formatProgress(62.4, null)).toBe('62%')
+ })
+ it('keeps one decimal below 10%', () => {
+ expect(formatProgress(5.5, 120)).toBe('5.5%(2m)')
+ expect(formatProgress(0, 3)).toBe('0%(3s)')
+ })
+ it('formats minutes and hours', () => {
+ expect(formatEtaCompact(59)).toBe('(59s)')
+ expect(formatEtaCompact(90)).toBe('(2m)')
+ expect(formatEtaCompact(3900)).toBe('(1h 5m)')
+ expect(formatEtaCompact(-1)).toBe('')
+ expect(formatEtaCompact(Number.NaN)).toBe('')
+ })
+})
+
+describe('labels', () => {
+ it('derives the kind from the extension', () => {
+ expect(kindLabel('.heic')).toBe('HEIC')
+ expect(kindLabel('')).toBe('')
+ })
+ it('signs the percentage with a plain hyphen', () => {
+ expect(pctChange(1000, 200)).toBe(-80)
+ expect(formatPct(-80)).toBe('-80%')
+ expect(formatPct(12)).toBe('+12%')
+ expect(formatPct(0)).toBe('0%')
+ })
+ it('has no percentage for a zero-byte source', () => {
+ expect(pctChange(0, 10)).toBeNull()
+ })
+})
+
+describe('shortError', () => {
+ it('maps known failures to a short label', () => {
+ expect(shortError('magick: Unsupported compression method (5)')).toBe('Unsupported compression')
+ expect(shortError('RAR output needs WinRAR (Rar.exe) installed')).toBe('WinRAR not found')
+ expect(shortError('The archive is password protected')).toBe('Password-protected')
+ })
+ it('falls back to the first clause, capped at 32 characters', () => {
+ expect(shortError('Disk full. Free some space and retry')).toBe('Disk full')
+ const long = shortError('Something very long happened while encoding the frames')
+ expect(long).toHaveLength(32)
+ expect(long.endsWith('…')).toBe(true)
+ })
+ it('says Failed when there is no message', () => {
+ expect(shortError(undefined)).toBe('Failed')
+ })
+})
+
+describe('rowView', () => {
+ it('done: actual result, percentage, reveal action as ghost', () => {
+ const v = rowView(item({ status: 'done', outputPath: 'C:/x/a.webp', outputSize: 640_000 }))
+ expect(v.status).toMatchObject({ kind: 'done', text: 'Done' })
+ expect(v.result).toEqual({ text: '625 KB', pct: '-80%', estimate: false, grew: false })
+ expect(v.action).toEqual({
+ kind: 'reveal',
+ icon: 'folder',
+ label: 'Show in folder',
+ ghost: true
+ })
+ })
+ it('done with a folder output: no size, no percentage', () => {
+ const v = rowView(item({ status: 'done', outputPath: 'C:/x/a (pages)' }))
+ expect(v.result).toEqual({ text: 'folder', pct: null, estimate: false, grew: false })
+ })
+ it('done from an old session without a link: empty result, no crash', () => {
+ const v = rowView(item({ status: 'done' }))
+ expect(v.result).toBeNull()
+ expect(v.status.text).toBe('Done')
+ })
+ it('growth is flagged so the cell can use weight instead of amber', () => {
+ const v = rowView(item({ status: 'done', outputPath: 'C:/x/a.png', outputSize: 6_400_000 }))
+ expect(v.result).toMatchObject({ pct: '+100%', grew: true })
+ })
+ it('running: progress, compact ETA, grey estimate, cancel ghost', () => {
+ const v = rowView(
+ item({ status: 'running', percent: 62, hasProgress: true, etaSec: 4 }),
+ 410_000
+ )
+ expect(v.status).toMatchObject({ kind: 'running', pct: '62%', eta: '(4s)' })
+ expect(v.result).toMatchObject({ text: '~400 KB', estimate: true })
+ expect(v.action).toMatchObject({ kind: 'cancel', icon: 'close', ghost: true })
+ })
+ it('running without real progress shows an ellipsis and no estimate cell', () => {
+ const v = rowView(item({ status: 'running', hasProgress: false }), null)
+ expect(v.status.pct).toBe('…')
+ expect(v.result).toBeNull()
+ })
+ it('queued: no result, remove ghost', () => {
+ const v = rowView(item({ status: 'queued' }), 999)
+ expect(v.result).toBeNull()
+ expect(v.status.text).toBe('Queued')
+ expect(v.action).toMatchObject({ kind: 'remove', ghost: true })
+ })
+ it('failed: short label, full tooltip, retry always visible', () => {
+ const v = rowView(
+ item({ status: 'failed', error: 'Unsupported compression in TIFF. Try PNG.' })
+ )
+ expect(v.status).toMatchObject({
+ text: 'Unsupported compression',
+ title: 'Unsupported compression in TIFF. Try PNG.'
+ })
+ expect(v.result).toBeNull()
+ expect(v.action).toEqual({ kind: 'retry', icon: 'retry', label: 'Retry', ghost: false })
+ })
+ it('ready: blank status, remove ghost', () => {
+ const v = rowView(item())
+ expect(v.status.text).toBe('')
+ expect(v.action.kind).toBe('remove')
+ })
+ it('canceled: Canceled, retry ghost', () => {
+ const v = rowView(item({ status: 'canceled' }))
+ expect(v.status.text).toBe('Canceled')
+ expect(v.action).toMatchObject({ kind: 'retry', ghost: true })
+ })
+ it('kind and size columns', () => {
+ const v = rowView(item())
+ expect(v.kind).toBe('HEIC')
+ expect(v.size).toBe('3.1 MB')
+ })
+})
+
+describe('queueTotals', () => {
+ it('sums inputs, counts statuses, and only adds done rows with a file output', () => {
+ const t = queueTotals([
+ item({ id: '1', status: 'done', outputPath: 'o1', outputSize: 1_000_000 }),
+ item({ id: '2', status: 'done', outputPath: 'C:/x/folder' }),
+ item({ id: '3', status: 'running' }),
+ item({ id: '4', status: 'queued' }),
+ item({ id: '5', status: 'failed' }),
+ item({ id: 'r', isResult: true, status: 'done', outputPath: 'o1', outputSize: 1_000_000 }),
+ item({ id: 'h', hiddenInput: true })
+ ])
+ expect(t).toEqual({
+ files: 5,
+ bytes: 16_000_000,
+ doneSrc: 3_200_000,
+ doneOut: 1_000_000,
+ done: 2,
+ failed: 1,
+ inFlight: 2
+ })
+ })
+})
+
+describe('doneSamples', () => {
+ it('uses done rows of the same group run with the same options', () => {
+ const opts = { format: '.webp', quality: 'balanced' }
+ const s = doneSamples(
+ [
+ item({ id: '1', status: 'done', outputSize: 800_000, runOptions: opts }),
+ item({
+ id: '2',
+ status: 'done',
+ outputSize: 100,
+ runOptions: { ...opts, quality: 'best' }
+ }),
+ item({ id: '3', status: 'running', runOptions: opts })
+ ],
+ 'image',
+ { quality: 'balanced', format: '.webp' }
+ )
+ expect(s).toEqual([{ source: 3_200_000, output: 800_000 }])
+ })
+})
+```
+
+- [ ] **Step 2: Run it and watch it fail**
+
+Run: `npx vitest run test/row-model.test.ts`
+Expected: FAIL, module not found.
+
+- [ ] **Step 3: Implement**
+
+```ts
+// src/renderer/src/components/queue/rowModel.ts
+import type { JobOptions } from '@shared/types'
+import { formatBytes } from '@shared/compress'
+import { groupOf, inInput, type ItemStatus, type QueueItem } from '../../state'
+
+// Pure view model for one table row (spec 4.3) and the totals row (spec 4.4).
+// Kept apart from the .tsx files so React Fast Refresh and Vitest both work.
+
+export function formatEtaCompact(sec: number | null | undefined): string {
+ if (sec == null || !Number.isFinite(sec) || sec < 0) return ''
+ const s = Math.round(sec)
+ if (s < 60) return `(${Math.max(1, s)}s)`
+ const m = Math.round(sec / 60)
+ if (m < 60) return `(${m}m)`
+ return `(${Math.floor(m / 60)}h ${m % 60}m)`
+}
+
+export function progressParts(pct: number, etaSec?: number | null): { pct: string; eta: string } {
+ const n = pct > 0 && pct < 10 ? pct.toFixed(1) : String(Math.round(pct))
+ return { pct: `${n}%`, eta: formatEtaCompact(etaSec) }
+}
+
+export function formatProgress(pct: number, etaSec?: number | null): string {
+ const p = progressParts(pct, etaSec)
+ return p.pct + p.eta
+}
+
+export function kindLabel(ext: string): string {
+ return ext.replace(/^\./, '').toUpperCase()
+}
+
+export function pctChange(src: number, out: number): number | null {
+ if (!(src > 0) || !Number.isFinite(out)) return null
+ return Math.round(((out - src) / src) * 100)
+}
+
+export function formatPct(n: number): string {
+ return n > 0 ? `+${n}%` : `${n}%`
+}
+
+const KNOWN_ERRORS: [RegExp, string][] = [
+ [/unsupported compression/i, 'Unsupported compression'],
+ [/winrar|rar\.exe/i, 'WinRAR not found'],
+ [/password/i, 'Password-protected'],
+ [/no (en|de)code delegate|missing encoder/i, 'Missing encoder'],
+ [/not installed|could not be found|enoent|tool missing/i, 'Tool missing']
+]
+const MAX_ERROR = 32
+
+export function shortError(message: string | undefined): string {
+ const m = (message ?? '').trim()
+ if (!m) return 'Failed'
+ for (const [re, label] of KNOWN_ERRORS) if (re.test(m)) return label
+ const first = m.split(/[.:;\n]|,\s/)[0].trim() || m
+ return first.length > MAX_ERROR ? first.slice(0, MAX_ERROR - 1).trimEnd() + '…' : first
+}
+
+export type RowActionKind = 'reveal' | 'remove' | 'cancel' | 'retry'
+export interface ResultView {
+ text: string
+ pct: string | null
+ estimate: boolean
+ grew: boolean
+}
+export interface StatusView {
+ kind: ItemStatus
+ text: string
+ pct?: string
+ eta?: string
+ title?: string
+}
+export interface RowActionView {
+ kind: RowActionKind
+ icon: 'folder' | 'close' | 'retry'
+ label: string
+ ghost: boolean
+}
+export interface RowView {
+ kind: string
+ size: string
+ result: ResultView | null
+ status: StatusView
+ action: RowActionView
+}
+
+function resultFor(item: QueueItem, estimateBytes?: number | null): ResultView | null {
+ const src = item.file.size
+ if (item.status === 'done') {
+ if (item.outputSize != null) {
+ const pct = pctChange(src, item.outputSize)
+ return {
+ text: formatBytes(item.outputSize),
+ pct: pct == null ? null : formatPct(pct),
+ estimate: false,
+ grew: pct != null && pct > 0
+ }
+ }
+ return item.outputPath ? { text: 'folder', pct: null, estimate: false, grew: false } : null
+ }
+ if (item.status === 'running' && estimateBytes != null && estimateBytes > 0) {
+ const pct = pctChange(src, estimateBytes)
+ return {
+ text: `~${formatBytes(estimateBytes)}`,
+ pct: pct == null ? null : formatPct(pct),
+ estimate: true,
+ grew: pct != null && pct > 0
+ }
+ }
+ return null
+}
+
+function statusFor(item: QueueItem): StatusView {
+ switch (item.status) {
+ case 'done':
+ return { kind: 'done', text: 'Done' }
+ case 'queued':
+ return { kind: 'queued', text: 'Queued' }
+ case 'running': {
+ if (!item.hasProgress) return { kind: 'running', text: '', pct: '…', eta: '' }
+ const p = progressParts(item.percent, item.etaSec)
+ return { kind: 'running', text: '', pct: p.pct, eta: p.eta }
+ }
+ case 'failed':
+ return { kind: 'failed', text: shortError(item.error), title: item.error ?? 'Failed' }
+ case 'canceled':
+ return { kind: 'canceled', text: 'Canceled' }
+ default:
+ return { kind: 'ready', text: '' }
+ }
+}
+
+function actionFor(status: ItemStatus): RowActionView {
+ switch (status) {
+ case 'done':
+ return { kind: 'reveal', icon: 'folder', label: 'Show in folder', ghost: true }
+ case 'running':
+ return { kind: 'cancel', icon: 'close', label: 'Cancel', ghost: true }
+ case 'failed':
+ return { kind: 'retry', icon: 'retry', label: 'Retry', ghost: false }
+ case 'canceled':
+ return { kind: 'retry', icon: 'retry', label: 'Retry', ghost: true }
+ default:
+ return { kind: 'remove', icon: 'close', label: 'Remove', ghost: true }
+ }
+}
+
+export function rowView(item: QueueItem, estimateBytes?: number | null): RowView {
+ return {
+ kind: kindLabel(item.file.ext),
+ size: formatBytes(item.file.size),
+ result: resultFor(item, estimateBytes),
+ status: statusFor(item),
+ action: actionFor(item.status)
+ }
+}
+
+export interface Totals {
+ files: number
+ bytes: number
+ doneSrc: number
+ doneOut: number
+ done: number
+ failed: number
+ inFlight: number
+}
+
+export function queueTotals(items: QueueItem[]): Totals {
+ const t: Totals = { files: 0, bytes: 0, doneSrc: 0, doneOut: 0, done: 0, failed: 0, inFlight: 0 }
+ for (const i of items) {
+ if (!inInput(i)) continue
+ t.files += 1
+ t.bytes += i.file.size
+ if (i.status === 'done') {
+ t.done += 1
+ if (i.outputSize != null) {
+ t.doneSrc += i.file.size
+ t.doneOut += i.outputSize
+ }
+ } else if (i.status === 'failed') t.failed += 1
+ else if (i.status === 'queued' || i.status === 'running') t.inFlight += 1
+ }
+ return t
+}
+
+function sameOptions(a: JobOptions | undefined, b: JobOptions): boolean {
+ if (!a) return false
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)])
+ for (const k of keys) if (JSON.stringify(a[k]) !== JSON.stringify(b[k])) return false
+ return true
+}
+
+export function doneSamples(
+ items: QueueItem[],
+ group: string,
+ options: JobOptions
+): { source: number; output: number }[] {
+ return items
+ .filter(
+ (i) =>
+ inInput(i) &&
+ i.status === 'done' &&
+ i.outputSize != null &&
+ groupOf(i.file) === group &&
+ sameOptions(i.runOptions, options)
+ )
+ .map((i) => ({ source: i.file.size, output: i.outputSize as number }))
+}
+```
+
+Note: `formatBytes(640_000)` is `625 KB` and `formatBytes(410_000)` is `400 KB` (1024-based, as the app has always shown); the tests assert those exact strings.
+
+- [ ] **Step 4: Run the test**
+
+Run: `npx vitest run test/row-model.test.ts`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/renderer/src/components/queue/rowModel.ts test/row-model.test.ts
+git commit -m "feat(queue): pure row model for result, status, actions and totals
+
+Co-Authored-By: Claude Opus 5.5 "
+```
+
+---
+
+### Task 6: Table sorting, select-all and table keyboard map
+
+**Files:**
+
+- Create: `src/renderer/src/components/queue/tableSort.ts`, `src/renderer/src/components/queue/selectAll.ts`, `src/renderer/src/components/queue/tableKeys.ts`
+- Modify: `src/renderer/src/components/queueGroups.ts:9` (export `GROUP_ORDER`, add `groupLabel`)
+- Test: `test/table-sort.test.ts`, `test/select-all.test.ts`, `test/table-keys.test.ts`
+
+**Interfaces:**
+
+- Consumes: `QueueItem`, `groupOf`, `inInput` from `state.ts`; `GROUP_ORDER` from `queueGroups.ts`.
+- Produces:
+ - `type SortKey = 'name' | 'kind' | 'size' | 'result' | 'status'`; `interface SortState { key: SortKey; dir: 'asc' | 'desc' }`.
+ - `nextSort(cur: SortState | null, key: SortKey): SortState | null` (asc, desc, off).
+ - `sortItems(items: QueueItem[], sort: SortState | null): QueueItem[]` (stable, never mutates).
+ - `groupedRows(items: QueueItem[], sort: SortState | null): { group: string; label: string; items: QueueItem[] }[]` (groups in `GROUP_ORDER`, each sorted).
+ - `visibleOrder(groups): string[]`.
+ - `type CheckState = 'none' | 'mixed' | 'all'`; `activeGroupFor(items, selected): string | null`; `headerCheck(items, selected): CheckState`; `toggleAllIds(items, selected): string[]`.
+ - `export const GROUP_ORDER: string[]`; `groupLabel(group: string): string` from `queueGroups.ts`.
+ - `type TableKey = 'up' | 'down' | 'extendUp' | 'extendDown' | 'toggle' | 'selectAll' | 'remove' | 'open' | 'menu'`; `tableKey(e: KeyLike): TableKey | null` where `KeyLike = { key: string; ctrlKey: boolean; metaKey: boolean; shiftKey: boolean; altKey: boolean }`.
+
+- [ ] **Step 1: Write the failing tests**
+
+```ts
+// test/table-sort.test.ts
+import { describe, expect, it } from 'vitest'
+import {
+ groupedRows,
+ nextSort,
+ sortItems,
+ visibleOrder
+} from '../src/renderer/src/components/queue/tableSort'
+import type { QueueItem } from '../src/renderer/src/state'
+
+const it_ = (
+ id: string,
+ name: string,
+ ext: string,
+ size: number,
+ over: Partial = {}
+): QueueItem => ({
+ id,
+ file: { path: `C:/x/${name}`, name, ext, kind: ext === '.mp4' ? 'video' : 'image', size },
+ thumb: null,
+ status: 'ready',
+ percent: 0,
+ ...over
+})
+
+describe('nextSort', () => {
+ it('cycles ascending, descending, off', () => {
+ const a = nextSort(null, 'size')
+ expect(a).toEqual({ key: 'size', dir: 'asc' })
+ const b = nextSort(a, 'size')
+ expect(b).toEqual({ key: 'size', dir: 'desc' })
+ expect(nextSort(b, 'size')).toBeNull()
+ })
+ it('starts ascending when switching column', () => {
+ expect(nextSort({ key: 'size', dir: 'desc' }, 'name')).toEqual({ key: 'name', dir: 'asc' })
+ })
+})
+
+describe('sortItems', () => {
+ const rows = [
+ it_('1', 'b.png', '.png', 30),
+ it_('2', 'a.png', '.png', 10),
+ it_('3', 'c.jpg', '.jpg', 20)
+ ]
+ it('keeps insertion order when off and never mutates', () => {
+ const copy = [...rows]
+ expect(sortItems(rows, null).map((r) => r.id)).toEqual(['1', '2', '3'])
+ sortItems(rows, { key: 'size', dir: 'asc' })
+ expect(rows).toEqual(copy)
+ })
+ it('sorts by name, size and kind', () => {
+ expect(sortItems(rows, { key: 'name', dir: 'asc' }).map((r) => r.id)).toEqual(['2', '1', '3'])
+ expect(sortItems(rows, { key: 'size', dir: 'desc' }).map((r) => r.id)).toEqual(['1', '3', '2'])
+ expect(sortItems(rows, { key: 'kind', dir: 'asc' }).map((r) => r.id)).toEqual(['3', '1', '2'])
+ })
+ it('sorts by result size with empty results last', () => {
+ const r = [
+ it_('1', 'a.png', '.png', 10),
+ it_('2', 'b.png', '.png', 10, { status: 'done', outputSize: 5 }),
+ it_('3', 'c.png', '.png', 10, { status: 'done', outputSize: 2 })
+ ]
+ expect(sortItems(r, { key: 'result', dir: 'asc' }).map((x) => x.id)).toEqual(['3', '2', '1'])
+ expect(sortItems(r, { key: 'result', dir: 'desc' }).map((x) => x.id)).toEqual(['2', '3', '1'])
+ })
+ it('sorts by status with failures first', () => {
+ const r = [
+ it_('1', 'a.png', '.png', 1, { status: 'done' }),
+ it_('2', 'b.png', '.png', 1, { status: 'failed' }),
+ it_('3', 'c.png', '.png', 1, { status: 'running' })
+ ]
+ expect(sortItems(r, { key: 'status', dir: 'asc' }).map((x) => x.id)).toEqual(['2', '3', '1'])
+ })
+})
+
+describe('groupedRows', () => {
+ it('sorts within each group and keeps groups in display order', () => {
+ const g = groupedRows(
+ [it_('v', 'z.mp4', '.mp4', 99), it_('2', 'b.png', '.png', 2), it_('1', 'a.png', '.png', 1)],
+ { key: 'name', dir: 'asc' }
+ )
+ expect(g.map((x) => x.group)).toEqual(['image', 'video'])
+ expect(visibleOrder(g)).toEqual(['1', '2', 'v'])
+ })
+ it('skips hidden inputs and results', () => {
+ const g = groupedRows(
+ [
+ it_('1', 'a.png', '.png', 1, { hiddenInput: true }),
+ it_('2', 'b.png', '.png', 1, { isResult: true })
+ ],
+ null
+ )
+ expect(g).toEqual([])
+ })
+})
+```
+
+```ts
+// test/select-all.test.ts
+import { describe, expect, it } from 'vitest'
+import {
+ activeGroupFor,
+ headerCheck,
+ toggleAllIds
+} from '../src/renderer/src/components/queue/selectAll'
+import type { QueueItem } from '../src/renderer/src/state'
+
+const row = (id: string, ext: string): QueueItem => ({
+ id,
+ file: {
+ path: `C:/x/${id}${ext}`,
+ name: id + ext,
+ ext,
+ kind: ext === '.mp4' ? 'video' : 'image',
+ size: 1
+ },
+ thumb: null,
+ status: 'ready',
+ percent: 0
+})
+const items = [row('v1', '.mp4'), row('i1', '.png'), row('i2', '.png'), row('v2', '.mp4')]
+
+describe('active group', () => {
+ it('is the group of the selection', () => {
+ expect(activeGroupFor(items, ['v2'])).toBe('video')
+ })
+ it('falls back to the first group in display order, not insertion order', () => {
+ expect(activeGroupFor(items, [])).toBe('image')
+ })
+ it('is null for an empty queue', () => {
+ expect(activeGroupFor([], [])).toBeNull()
+ })
+})
+
+describe('header checkbox', () => {
+ it('reads none, mixed and all for the active group only', () => {
+ expect(headerCheck(items, [])).toBe('none')
+ expect(headerCheck(items, ['i1'])).toBe('mixed')
+ expect(headerCheck(items, ['i1', 'i2'])).toBe('all')
+ })
+ it('selects every row of the selected group and nothing else', () => {
+ expect(toggleAllIds(items, ['v1'])).toEqual(['v1', 'v2'])
+ })
+ it('selects the first display group when nothing is selected', () => {
+ expect(toggleAllIds(items, [])).toEqual(['i1', 'i2'])
+ })
+ it('clears when the whole group is already selected', () => {
+ expect(toggleAllIds(items, ['i1', 'i2'])).toEqual([])
+ })
+})
+```
+
+```ts
+// test/table-keys.test.ts
+import { describe, expect, it } from 'vitest'
+import { tableKey } from '../src/renderer/src/components/queue/tableKeys'
+
+const k = (
+ key: string,
+ mods: Partial<{ ctrlKey: boolean; metaKey: boolean; shiftKey: boolean; altKey: boolean }> = {}
+) => ({
+ key,
+ ctrlKey: false,
+ metaKey: false,
+ shiftKey: false,
+ altKey: false,
+ ...mods
+})
+
+describe('tableKey', () => {
+ it('maps the spec 4.1 keys', () => {
+ expect(tableKey(k('ArrowUp'))).toBe('up')
+ expect(tableKey(k('ArrowDown'))).toBe('down')
+ expect(tableKey(k('ArrowUp', { shiftKey: true }))).toBe('extendUp')
+ expect(tableKey(k('ArrowDown', { shiftKey: true }))).toBe('extendDown')
+ expect(tableKey(k(' '))).toBe('toggle')
+ expect(tableKey(k('a', { ctrlKey: true }))).toBe('selectAll')
+ expect(tableKey(k('A', { metaKey: true }))).toBe('selectAll')
+ expect(tableKey(k('Delete'))).toBe('remove')
+ expect(tableKey(k('Enter'))).toBe('open')
+ expect(tableKey(k('ContextMenu'))).toBe('menu')
+ expect(tableKey(k('F10', { shiftKey: true }))).toBe('menu')
+ })
+ it('ignores everything else, including Ctrl+Enter (that is Run)', () => {
+ expect(tableKey(k('Enter', { ctrlKey: true }))).toBeNull()
+ expect(tableKey(k('a'))).toBeNull()
+ expect(tableKey(k('ArrowUp', { altKey: true }))).toBeNull()
+ })
+})
+```
+
+- [ ] **Step 2: Run them and watch them fail**
+
+Run: `npx vitest run test/table-sort.test.ts test/select-all.test.ts test/table-keys.test.ts`
+Expected: FAIL, modules not found.
+
+- [ ] **Step 3: Export the group order and label**
+
+In `queueGroups.ts` change `const GROUP_ORDER` to `export const GROUP_ORDER` and add:
+
+```ts
+/** "Images" / "Video" / "Documents"; "Other" for an unknown group. */
+export function groupLabel(group: string): string {
+ return GROUP_LABEL[group] ?? 'Other'
+}
+
+/** Index of a group in display order; unknown groups sort last. */
+export function groupRank(group: string): number {
+ const i = GROUP_ORDER.indexOf(group)
+ return i < 0 ? GROUP_ORDER.length : i
+}
+```
+
+- [ ] **Step 4: Implement the three modules**
+
+```ts
+// src/renderer/src/components/queue/tableSort.ts
+import { groupOf, inInput, type ItemStatus, type QueueItem } from '../../state'
+import { groupLabel, groupRank } from '../queueGroups'
+
+export type SortKey = 'name' | 'kind' | 'size' | 'result' | 'status'
+export interface SortState {
+ key: SortKey
+ dir: 'asc' | 'desc'
+}
+
+/** Header click cycle: ascending, descending, back to insertion order. */
+export function nextSort(cur: SortState | null, key: SortKey): SortState | null {
+ if (!cur || cur.key !== key) return { key, dir: 'asc' }
+ return cur.dir === 'asc' ? { key, dir: 'desc' } : null
+}
+
+const STATUS_RANK: Record = {
+ failed: 0,
+ running: 1,
+ queued: 2,
+ ready: 3,
+ canceled: 4,
+ done: 5
+}
+
+function compare(a: QueueItem, b: QueueItem, key: SortKey): number {
+ switch (key) {
+ case 'name':
+ return a.file.name.localeCompare(b.file.name, undefined, {
+ numeric: true,
+ sensitivity: 'base'
+ })
+ case 'kind':
+ return a.file.ext.localeCompare(b.file.ext)
+ case 'size':
+ return a.file.size - b.file.size
+ case 'status':
+ return STATUS_RANK[a.status] - STATUS_RANK[b.status]
+ case 'result':
+ return (a.outputSize ?? 0) - (b.outputSize ?? 0)
+ }
+}
+
+export function sortItems(items: QueueItem[], sort: SortState | null): QueueItem[] {
+ if (!sort) return items.slice()
+ const sign = sort.dir === 'asc' ? 1 : -1
+ return items
+ .map((item, index) => ({ item, index }))
+ .sort((x, y) => {
+ if (sort.key === 'result') {
+ // Rows without a result always go last, whichever the direction.
+ const ex = x.item.outputSize == null
+ const ey = y.item.outputSize == null
+ if (ex !== ey) return ex ? 1 : -1
+ }
+ return sign * compare(x.item, y.item, sort.key) || x.index - y.index
+ })
+ .map((x) => x.item)
+}
+
+export interface RowGroup {
+ group: string
+ label: string
+ items: QueueItem[]
+}
+
+/** Input rows bucketed by convert group in display order, each bucket sorted. */
+export function groupedRows(items: QueueItem[], sort: SortState | null): RowGroup[] {
+ const by = new Map()
+ for (const i of items) {
+ if (!inInput(i)) continue
+ const g = groupOf(i.file)
+ const list = by.get(g)
+ if (list) list.push(i)
+ else by.set(g, [i])
+ }
+ return [...by.keys()]
+ .sort((a, b) => groupRank(a) - groupRank(b))
+ .map((g) => ({ group: g, label: groupLabel(g), items: sortItems(by.get(g)!, sort) }))
+}
+
+export function visibleOrder(groups: RowGroup[]): string[] {
+ return groups.flatMap((g) => g.items.map((i) => i.id))
+}
+```
+
+```ts
+// src/renderer/src/components/queue/selectAll.ts
+import { groupOf, inInput, type QueueItem } from '../../state'
+import { groupRank } from '../queueGroups'
+
+export type CheckState = 'none' | 'mixed' | 'all'
+
+/** The group the header checkbox speaks for: the selection's group, or the
+ * first group in display order when nothing is selected. */
+export function activeGroupFor(items: QueueItem[], selected: string[]): string | null {
+ const rows = items.filter(inInput)
+ const first = rows.find((i) => i.id === selected[0])
+ if (first) return groupOf(first.file)
+ const groups = [...new Set(rows.map((i) => groupOf(i.file)))]
+ if (!groups.length) return null
+ return groups.sort((a, b) => groupRank(a) - groupRank(b))[0]
+}
+
+function groupIds(items: QueueItem[], group: string | null): string[] {
+ return items.filter((i) => inInput(i) && groupOf(i.file) === group).map((i) => i.id)
+}
+
+export function headerCheck(items: QueueItem[], selected: string[]): CheckState {
+ const ids = groupIds(items, activeGroupFor(items, selected))
+ const n = ids.filter((id) => selected.includes(id)).length
+ if (n === 0) return 'none'
+ return n === ids.length ? 'all' : 'mixed'
+}
+
+/** What a header click selects: the whole active group, or nothing when the
+ * group is already fully selected. Never crosses groups. */
+export function toggleAllIds(items: QueueItem[], selected: string[]): string[] {
+ const ids = groupIds(items, activeGroupFor(items, selected))
+ return headerCheck(items, selected) === 'all' ? [] : ids
+}
+```
+
+```ts
+// src/renderer/src/components/queue/tableKeys.ts
+export type TableKey =
+ 'up' | 'down' | 'extendUp' | 'extendDown' | 'toggle' | 'selectAll' | 'remove' | 'open' | 'menu'
+
+export interface KeyLike {
+ key: string
+ ctrlKey: boolean
+ metaKey: boolean
+ shiftKey: boolean
+ altKey: boolean
+}
+
+/** Keyboard map for the files table (spec 4.1). Ctrl+Enter is left for Run. */
+export function tableKey(e: KeyLike): TableKey | null {
+ if (e.altKey) return null
+ const mod = e.ctrlKey || e.metaKey
+ if (mod) return e.key.toLowerCase() === 'a' && !e.shiftKey ? 'selectAll' : null
+ if (e.key === 'ArrowUp') return e.shiftKey ? 'extendUp' : 'up'
+ if (e.key === 'ArrowDown') return e.shiftKey ? 'extendDown' : 'down'
+ if (e.key === ' ') return 'toggle'
+ if (e.key === 'Delete') return 'remove'
+ if (e.key === 'Enter') return 'open'
+ if (e.key === 'ContextMenu' || (e.key === 'F10' && e.shiftKey)) return 'menu'
+ return null
+}
+```
+
+- [ ] **Step 5: Run the tests**
+
+Run: `npx vitest run test/table-sort.test.ts test/select-all.test.ts test/table-keys.test.ts test/queue-groups.test.ts`
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/renderer/src/components/queue/tableSort.ts src/renderer/src/components/queue/selectAll.ts src/renderer/src/components/queue/tableKeys.ts src/renderer/src/components/queueGroups.ts test/table-sort.test.ts test/select-all.test.ts test/table-keys.test.ts
+git commit -m "feat(queue): sort cycle, group-scoped select-all and table key map
+
+Co-Authored-By: Claude Opus 5.5 "
+```
+
+---
+
+### Task 7: Size estimator (per file and batch)
+
+**Files:**
+
+- Create: `src/shared/sizeEstimate.ts`
+- Test: `test/size-estimate.test.ts`
+
+**Interfaces:**
+
+- Consumes: `FileInfo`, `JobOptions`, `ToolId` from `@shared/types`; `estimatedPngBytes` from `@shared/compress`.
+- Produces:
+ - `interface Sample { source: number; output: number }`
+ - `medianRatio(samples: Sample[]): number | null`
+ - `ratioFor(tool: ToolId, file: FileInfo, options: JobOptions): number | null`
+ - `interface EstimateCtx { samples?: Sample[]; pixelRatio?: number | null; outPixels?: number | null }`
+ - `estimateOutputBytes(file: FileInfo, tool: ToolId, options: JobOptions, ctx?: EstimateCtx): number | null`
+ - `estimateBatch(rows: { size: number; estimate: number | null }[]): { from: number; to: number; files: number } | null`
+
+Order of evidence (spec 6.2): batch samples first, then pixels (resize, upscale), then the ratio table, else `null`.
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+// test/size-estimate.test.ts
+import { describe, expect, it } from 'vitest'
+import { estimateBatch, estimateOutputBytes, medianRatio, ratioFor } from '@shared/sizeEstimate'
+import type { FileInfo } from '@shared/types'
+
+const png: FileInfo = {
+ path: 'C:/a.png',
+ name: 'a.png',
+ ext: '.png',
+ kind: 'image',
+ size: 1_000_000
+}
+const mp4: FileInfo = {
+ path: 'C:/a.mp4',
+ name: 'a.mp4',
+ ext: '.mp4',
+ kind: 'video',
+ size: 10_000_000
+}
+const wav: FileInfo = {
+ path: 'C:/a.wav',
+ name: 'a.wav',
+ ext: '.wav',
+ kind: 'audio',
+ size: 5_000_000
+}
+const pdf: FileInfo = { path: 'C:/a.pdf', name: 'a.pdf', ext: '.pdf', kind: 'pdf', size: 2_000_000 }
+
+describe('medianRatio', () => {
+ it('takes the median output/source ratio', () => {
+ expect(
+ medianRatio([
+ { source: 10, output: 1 },
+ { source: 10, output: 3 },
+ { source: 10, output: 9 }
+ ])
+ ).toBeCloseTo(0.3)
+ expect(
+ medianRatio([
+ { source: 10, output: 2 },
+ { source: 10, output: 4 }
+ ])
+ ).toBeCloseTo(0.3)
+ })
+ it('ignores zero-byte sources and returns null with nothing usable', () => {
+ expect(medianRatio([])).toBeNull()
+ expect(medianRatio([{ source: 0, output: 5 }])).toBeNull()
+ })
+})
+
+describe('ratio table', () => {
+ it('knows image convert targets by quality preset', () => {
+ expect(ratioFor('convert', png, { format: '.webp', quality: 'balanced' })).toBeCloseTo(0.22)
+ expect(ratioFor('convert', png, { format: '.avif', quality: 'smaller' })).toBeCloseTo(0.1)
+ })
+ it('has no ratio for lossless or odd targets', () => {
+ expect(ratioFor('convert', png, { format: '.bmp' })).toBeNull()
+ expect(ratioFor('convert', mp4, { format: '.mkv' })).toBeNull()
+ })
+ it('scales video compress by codec, quality and scale', () => {
+ const full = ratioFor('compress', mp4, { videoCodec: 'h264', quality: 80, scale: 100 })!
+ const half = ratioFor('compress', mp4, { videoCodec: 'h264', quality: 80, scale: 50 })!
+ expect(full).toBeCloseTo(0.5)
+ expect(half).toBeCloseTo(0.125)
+ })
+ it('has no ratio for audio without a known source bitrate', () => {
+ expect(ratioFor('compress', wav, { audioCodec: 'mp3', audioBitrate: 192 })).toBeNull()
+ })
+ it('maps PDF levels', () => {
+ expect(ratioFor('compress', pdf, { pdfLevel: 'smallest' })).toBeCloseTo(0.3)
+ })
+ it('never estimates tools whose output is not comparable', () => {
+ for (const t of ['pdf', 'archive', 'generate', 'removebg'] as const)
+ expect(ratioFor(t, png, {})).toBeNull()
+ })
+})
+
+describe('estimateOutputBytes', () => {
+ it('prefers finished rows of the same batch over the table', () => {
+ const v = estimateOutputBytes(
+ png,
+ 'convert',
+ { format: '.webp', quality: 'balanced' },
+ {
+ samples: [{ source: 100, output: 50 }]
+ }
+ )
+ expect(v).toBe(500_000)
+ })
+ it('uses pixels for resize and upscale', () => {
+ expect(
+ estimateOutputBytes(png, 'resize', { mode: 'percent', percent: 50 }, { pixelRatio: 0.25 })
+ ).toBe(250_000)
+ expect(estimateOutputBytes(png, 'upscale', { upscaleFactor: 2 }, { outPixels: 1000 })).toBe(
+ 1200
+ )
+ })
+ it('returns null, never 0 or NaN, when there is nothing to go on', () => {
+ expect(estimateOutputBytes(png, 'resize', {}, {})).toBeNull()
+ expect(estimateOutputBytes({ ...png, size: 0 }, 'convert', { format: '.webp' })).toBeNull()
+ expect(estimateOutputBytes(wav, 'compress', { audioCodec: 'mp3' })).toBeNull()
+ })
+})
+
+describe('estimateBatch', () => {
+ it('sums only rows that have an estimate and counts them', () => {
+ expect(
+ estimateBatch([
+ { size: 100, estimate: 20 },
+ { size: 50, estimate: null },
+ { size: 300, estimate: 60 }
+ ])
+ ).toEqual({ from: 400, to: 80, files: 2 })
+ })
+ it('is null when no row can be estimated', () => {
+ expect(estimateBatch([{ size: 100, estimate: null }])).toBeNull()
+ expect(estimateBatch([])).toBeNull()
+ })
+})
+```
+
+- [ ] **Step 2: Run it and watch it fail**
+
+Run: `npx vitest run test/size-estimate.test.ts`
+Expected: FAIL, module not found.
+
+- [ ] **Step 3: Implement**
+
+```ts
+// src/shared/sizeEstimate.ts
+import type { FileInfo, JobOptions, ToolId } from './types'
+import { estimatedPngBytes } from './compress'
+
+// Output-size estimates for the result column and the batch card (spec 6.2).
+// Always shown with "~". Conservative by design: a wrong guess is worse than an
+// empty cell, so anything unpredictable returns null.
+
+export interface Sample {
+ source: number
+ output: number
+}
+
+export function medianRatio(samples: Sample[]): number | null {
+ const r = samples
+ .filter((s) => s.source > 0 && s.output >= 0)
+ .map((s) => s.output / s.source)
+ .sort((a, b) => a - b)
+ if (!r.length) return null
+ const mid = Math.floor(r.length / 2)
+ return r.length % 2 ? r[mid] : (r[mid - 1] + r[mid]) / 2
+}
+
+type Preset = 'smaller' | 'balanced' | 'best'
+const IMAGE_CONVERT: Record> = {
+ '.webp': { smaller: 0.15, balanced: 0.22, best: 0.35 },
+ '.avif': { smaller: 0.1, balanced: 0.16, best: 0.25 },
+ '.jxl': { smaller: 0.15, balanced: 0.22, best: 0.35 },
+ '.jpg': { smaller: 0.25, balanced: 0.35, best: 0.5 },
+ '.jpeg': { smaller: 0.25, balanced: 0.35, best: 0.5 }
+}
+const VIDEO_CODEC: Record = { h264: 0.5, h265: 0.35, av1: 0.3 }
+const PDF_LEVEL: Record = { lossless: 0.9, high: 0.7, balanced: 0.5, smallest: 0.3 }
+
+const num = (v: unknown, d: number): number => (typeof v === 'number' && Number.isFinite(v) ? v : d)
+const clamp01 = (n: number): number => Math.max(0.01, Math.min(1, n))
+
+export function ratioFor(tool: ToolId, file: FileInfo, options: JobOptions): number | null {
+ if (tool === 'convert') {
+ if (file.kind !== 'image') return null
+ const row = IMAGE_CONVERT[String(options.format ?? '')]
+ if (!row) return null
+ const q = String(options.quality ?? 'balanced') as Preset
+ return row[q] ?? row.balanced
+ }
+ if (tool === 'compress') {
+ const q = num(options.quality, 80)
+ if (file.kind === 'image') {
+ const fmt = String(options.imageFormat ?? 'keep')
+ if (fmt === 'webp') return clamp01(0.08 + (q / 100) * 0.3)
+ if (fmt === 'avif') return clamp01(0.05 + (q / 100) * 0.2)
+ return clamp01((q / 100) * 0.6)
+ }
+ if (file.kind === 'video') {
+ const base = VIDEO_CODEC[String(options.videoCodec ?? 'h264')]
+ if (base == null) return null
+ const s = num(options.scale, 100) / 100
+ return clamp01(base * (q / 80) * s * s)
+ }
+ if (file.kind === 'pdf') return PDF_LEVEL[String(options.pdfLevel ?? 'balanced')] ?? null
+ return null // audio: needs the source bitrate, which is not probed
+ }
+ return null
+}
+
+export interface EstimateCtx {
+ samples?: Sample[]
+ /** Output pixels / source pixels (resize), from the dimension probe. */
+ pixelRatio?: number | null
+ /** Output pixel count (upscale writes PNG). */
+ outPixels?: number | null
+}
+
+export function estimateOutputBytes(
+ file: FileInfo,
+ tool: ToolId,
+ options: JobOptions,
+ ctx: EstimateCtx = {}
+): number | null {
+ if (!(file.size > 0)) return null
+ const fromBatch = medianRatio(ctx.samples ?? [])
+ if (fromBatch != null) return Math.round(file.size * fromBatch)
+ if (tool === 'resize')
+ return ctx.pixelRatio != null && ctx.pixelRatio > 0
+ ? Math.round(file.size * ctx.pixelRatio)
+ : null
+ if (tool === 'upscale')
+ return ctx.outPixels != null && ctx.outPixels > 0
+ ? Math.round(estimatedPngBytes(ctx.outPixels, 1))
+ : null
+ const r = ratioFor(tool, file, options)
+ return r == null ? null : Math.round(file.size * r)
+}
+
+export function estimateBatch(
+ rows: { size: number; estimate: number | null }[]
+): { from: number; to: number; files: number } | null {
+ let from = 0
+ let to = 0
+ let files = 0
+ for (const r of rows) {
+ if (r.estimate == null) continue
+ from += r.size
+ to += r.estimate
+ files += 1
+ }
+ return files ? { from, to, files } : null
+}
+```
+
+`estimatedPngBytes(w, h)` is `w * h * 1.2` (`src/shared/compress.ts:118`); passing `(outPixels, 1)` reuses it unchanged.
+
+- [ ] **Step 4: Run the test**
+
+Run: `npx vitest run test/size-estimate.test.ts`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/shared/sizeEstimate.ts test/size-estimate.test.ts
+git commit -m "feat(shared): conservative output-size estimator with batch extrapolation
+
+Co-Authored-By: Claude Opus 5.5 "
+```
+
+---
+
+### Task 8: Design tokens, bundled fonts, base and workbench CSS (light theme removed)
+
+**Files:**
+
+- Create: `src/renderer/src/theme/tokens.css`, `src/renderer/src/theme/base.css`, `src/renderer/src/theme/workbench.css`
+- Modify: `src/renderer/src/index.css` (rewrite), `src/renderer/src/main.tsx:1-5` (font imports), `package.json` (dependencies)
+- Test: `test/theme-tokens.test.ts`
+
+**Interfaces:**
+
+- Consumes: nothing.
+- Produces: the CSS custom properties listed in Global Constraints plus `--sans`, `--mono`, `--backdrop`, `--sbw: 208px`, `--sbw-c: 48px`, `--cols: 40px 1fr 64px 88px 184px 232px`; Tailwind utilities `bg-bg-0 bg-bg-1 bg-hover bg-selected bg-field bg-track border-line border-line-strong text-fg1 text-fg2 text-fg3 text-fg-disabled bg-inv-bg text-inv-fg font-sans font-mono`; the class vocabulary every later task uses: `app wb no-insp titlebar brand tsep crumb winctl side side-head lbl toggle nav nav-sep spacer bottom item lab n center toolbar tbtn add ibtn tb-right count qtable qbody cols thead th tr td ck num sel box mixed on name thumb meta size res arr split est pct grew rc st done run q canceled fail prog eta row-act ghost fill totals insp tabs tab tl ibody ihead vs vs-gh vs-set vs-t vs-d vs-sel half vs-in vs-row sbtn seg vs-chk lt vs-est eh ev from dgrid ifoot primary statusbar sitem mini sright warn wipe line tag l r mono sr-only scroll-thin`.
+
+Mockup class names that collide with Tailwind utilities are renamed: `.table` becomes `.qtable`, `.grid` becomes `.dgrid`. Breadcrumb links become ``s, so `.crumb a` becomes `.crumb button`. Every hex literal in the mockup CSS becomes a token.
+
+- [ ] **Step 1: Add the fonts**
+
+```bash
+npm install --save-dev @fontsource/ibm-plex-sans @fontsource/ibm-plex-mono
+```
+
+(The project keeps every package in `devDependencies`; electron-vite bundles the CSS and font files into `out/renderer`.)
+
+- [ ] **Step 2: Write the failing test**
+
+```ts
+// test/theme-tokens.test.ts
+import { describe, expect, it } from 'vitest'
+import { readFileSync } from 'fs'
+import { join } from 'path'
+
+const css = readFileSync(join(__dirname, '../src/renderer/src/theme/tokens.css'), 'utf-8')
+
+const EXPECTED: Record = {
+ 'bg-0': '#0a0a0a',
+ 'bg-1': '#0f0f0f',
+ hover: '#1a1a1a',
+ selected: '#202020',
+ field: '#141414',
+ track: '#2a2a2a',
+ line: '#262626',
+ 'line-strong': '#3a3a3a',
+ fg1: '#ededed',
+ fg2: '#b4b4b4',
+ fg3: '#8c8c8c',
+ 'fg-disabled': '#5c5c5c',
+ 'inv-bg': '#ededed',
+ 'inv-fg': '#0a0a0a',
+ 'inv-hover': '#ffffff',
+ 'inv-active': '#bdbdbd',
+ focus: '#ededed'
+}
+
+function expand(hex: string): string {
+ const h = hex.slice(1).toLowerCase()
+ return h.length === 3
+ ? h
+ .split('')
+ .map((c) => c + c)
+ .join('')
+ : h.slice(0, 6)
+}
+
+describe('design tokens', () => {
+ it('defines every spec token with its exact value', () => {
+ for (const [name, value] of Object.entries(EXPECTED)) {
+ const m = css.match(new RegExp(`--${name}:\\s*(#[0-9a-fA-F]{3,8})`))
+ expect(m?.[1]?.toLowerCase(), name).toBe(value)
+ }
+ })
+
+ it('is strictly monochrome: every colour token has r = g = b', () => {
+ const all = [...css.matchAll(/--([\w-]+):\s*(#[0-9a-fA-F]{3,8})\b/g)]
+ expect(all.length).toBeGreaterThanOrEqual(Object.keys(EXPECTED).length)
+ for (const [, name, hex] of all) {
+ const h = expand(hex)
+ expect(h.slice(0, 2) === h.slice(2, 4) && h.slice(2, 4) === h.slice(4, 6), name).toBe(true)
+ }
+ })
+})
+```
+
+- [ ] **Step 3: Run it and watch it fail**
+
+Run: `npx vitest run test/theme-tokens.test.ts`
+Expected: FAIL, ENOENT for `tokens.css`.
+
+- [ ] **Step 4: Write `theme/tokens.css`** (the only file allowed to hold colour literals)
+
+```css
+/* Filesmith design tokens (spec 2.1). Dark only, strictly monochrome: every
+ colour has r = g = b. A future theme redefines these and nothing else. */
+:root {
+ --bg-0: #0a0a0a;
+ --bg-1: #0f0f0f;
+ --hover: #1a1a1a;
+ --selected: #202020;
+ --field: #141414;
+ --track: #2a2a2a;
+ --line: #262626;
+ --line-strong: #3a3a3a;
+ --fg1: #ededed;
+ --fg2: #b4b4b4;
+ --fg3: #8c8c8c;
+ --fg-disabled: #5c5c5c;
+ --inv-bg: #ededed;
+ --inv-fg: #0a0a0a;
+ --inv-hover: #ffffff;
+ --inv-active: #bdbdbd;
+ --focus: #ededed;
+ --backdrop: rgba(0, 0, 0, 0.6);
+
+ --sans: 'IBM Plex Sans', system-ui, sans-serif;
+ --mono: 'IBM Plex Mono', Consolas, monospace;
+
+ --sbw: 208px;
+ --sbw-c: 48px;
+ --cols: 40px 1fr 64px 88px 184px 232px;
+ color-scheme: dark;
+}
+```
+
+- [ ] **Step 5: Write `theme/base.css`**
+
+```css
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+ border-radius: 0;
+}
+html,
+body,
+#root {
+ height: 100%;
+ background: var(--bg-0);
+}
+body {
+ margin: 0;
+ overflow: hidden;
+ color: var(--fg1);
+ font: 13px/1.4 var(--sans);
+ -webkit-font-smoothing: antialiased;
+ user-select: none;
+ cursor: default;
+}
+input,
+textarea,
+.select-text {
+ user-select: text;
+}
+img {
+ -webkit-user-drag: none;
+}
+button {
+ font: inherit;
+ color: inherit;
+ background: none;
+ border: 0;
+ padding: 0;
+ cursor: pointer;
+}
+button:disabled,
+[aria-disabled='true'] {
+ cursor: default;
+}
+svg.i {
+ width: 16px;
+ height: 16px;
+ flex: none;
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 1.25;
+ stroke-linecap: square;
+ stroke-linejoin: miter;
+}
+svg.i * {
+ vector-effect: non-scaling-stroke;
+}
+svg.i12 {
+ width: 12px;
+ height: 12px;
+}
+svg.i10 {
+ width: 10px;
+ height: 10px;
+}
+:focus-visible {
+ outline: 1px solid var(--focus);
+ outline-offset: -1px;
+}
+.mono {
+ font-family: var(--mono);
+ font-variant-numeric: tabular-nums;
+}
+.drag {
+ -webkit-app-region: drag;
+}
+.no-drag {
+ -webkit-app-region: no-drag;
+}
+.scroll-thin {
+ scrollbar-width: thin;
+ scrollbar-color: var(--line-strong) var(--bg-0);
+}
+.scroll-thin::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+.scroll-thin::-webkit-scrollbar-track {
+ background: var(--bg-0);
+}
+.scroll-thin::-webkit-scrollbar-thumb {
+ background: var(--line-strong);
+}
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ white-space: nowrap;
+}
+
+/* App frame (spec 3): title bar, workbench, status bar. */
+.app {
+ display: grid;
+ grid-template-rows: 32px 1fr 24px;
+ width: 100vw;
+ height: 100vh;
+ min-width: 0;
+}
+
+/* Menus and dialogs only fade: no scale, no radius (spec 2.3). */
+@keyframes fs-fade {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+.ctx-pop,
+.modal-pop {
+ animation: fs-fade 0.12s ease-out;
+}
+@media (prefers-reduced-motion: reduce) {
+ .ctx-pop,
+ .modal-pop {
+ animation: none;
+ }
+}
+```
+
+- [ ] **Step 6: Write `theme/workbench.css`**
+
+This is the mockup's component CSS (`10-s2-vscode-grouped.html:50-319`), tokenised and renamed. Port it rule by rule in this order: title bar, workbench, sidebar, centre, table, inspector, segmented, small button, info grid, footer, status bar, preview, grouped settings, reduced motion. Apply exactly these transformations and nothing else:
+
+| Mockup | Port |
+| -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
+| `#ffffff` (hover on inverted surfaces) | `var(--inv-hover)` |
+| `#bdbdbd` (active on inverted surfaces) | `var(--inv-active)` |
+| `#141414` (`.vs-sel`, `.vs-in` fill) | `var(--field)` |
+| `#2a2a2a` (`.seg button:active`) | `var(--track)` |
+| `.table` | `.qtable` |
+| `.grid`, `.grid div`, `.grid dt`, `.grid dd` | `.dgrid`, `.dgrid > div`, `.dgrid dt`, `.dgrid dd` |
+| `.crumb a`, `.crumb a:hover` | `.crumb button` (add `font: inherit`), `.crumb button:hover` |
+| `.ihead b` | `.ihead h1` (add `margin: 0; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap`) |
+| `.sitem .mini`, `.sitem .mini i` | `.mini`, `.mini i` (drop the fixed `width: 43%` on `i`; width comes from inline style; add `overflow: hidden` on `.mini`) |
+| `.wipe` background gradient, `.wipe .flr`, `.wipe .btl` | `.wipe { background: var(--bg-1) }`; drop `.flr` and `.btl` (demo art); drop `left: 50%` from `.wipe .line` (set inline) |
+| `html,body`, `body`, `button`, `svg.i`, `:focus-visible`, `.mono`, `:root` | not ported here (they live in `base.css` and `tokens.css`) |
+| `.kv*`, `.dv*`, `.sub`, `.chk`, `.seg.dense`, `.estv*`, `.vs-est .bar*`, `.pane`, `.pane.on` | not ported (dead in S2) |
+
+Then append these rules, which the app needs and the static mockup does not:
+
+```css
+.wb.no-insp {
+ grid-template-columns: auto 1fr;
+}
+.center {
+ position: relative;
+}
+.qbody {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ overflow-x: hidden;
+}
+.thead,
+.totals {
+ flex: none;
+}
+.th.ck {
+ cursor: default;
+}
+.th.desc .chev {
+ transform: scaleY(-1);
+}
+.thumb {
+ object-fit: cover;
+ display: grid;
+ place-items: center;
+ font: 9px/1 var(--mono);
+ color: var(--fg3);
+ overflow: hidden;
+}
+.res .pct.grew {
+ font-weight: 600;
+}
+.st {
+ min-width: 0;
+}
+.st.canceled {
+ color: var(--fg3);
+}
+.st.fail {
+ overflow: hidden;
+}
+.row-act {
+ flex: none;
+}
+.insp {
+ min-width: 0;
+}
+.ihead span {
+ white-space: nowrap;
+}
+.dgrid {
+ border-top: 1px solid var(--line);
+}
+.dgrid > div {
+ min-height: 32px;
+ height: auto;
+}
+.dgrid dd {
+ gap: 8px;
+ padding: 6px 12px 6px 14px;
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+.sitem {
+ white-space: nowrap;
+}
+.count {
+ white-space: nowrap;
+}
+@media (prefers-reduced-motion: reduce) {
+ .seg button,
+ .sbtn,
+ .vs-sel,
+ .vs-in {
+ transition: none;
+ }
+}
+```
+
+Check: `grep -nE "#[0-9a-fA-F]{3,8}\b" src/renderer/src/theme/workbench.css` prints nothing.
+
+- [ ] **Step 7: Rewrite `index.css`**
+
+Replace the whole file with:
+
+```css
+@import 'tailwindcss';
+@import './theme/tokens.css';
+@import './theme/base.css';
+@import './theme/workbench.css';
+
+/* Mirror the tokens into Tailwind so utilities (bg-bg-0, text-fg2,
+ border-line) resolve to the variables; a future theme redefines only the
+ variables. The old light tokens (ink, muted, dim, canvas, accent*) are gone. */
+@theme inline {
+ --color-bg-0: var(--bg-0);
+ --color-bg-1: var(--bg-1);
+ --color-hover: var(--hover);
+ --color-selected: var(--selected);
+ --color-field: var(--field);
+ --color-track: var(--track);
+ --color-line: var(--line);
+ --color-line-strong: var(--line-strong);
+ --color-fg1: var(--fg1);
+ --color-fg2: var(--fg2);
+ --color-fg3: var(--fg3);
+ --color-fg-disabled: var(--fg-disabled);
+ --color-inv-bg: var(--inv-bg);
+ --color-inv-fg: var(--inv-fg);
+ --font-sans: var(--sans);
+ --font-mono: var(--mono);
+}
+```
+
+The dead viewer CSS (`.preview-stage`, `.fs-bar`, `.fs-art`, `.md-body`), the light `@theme` block, the old `ctxpop` / `modalpop` keyframes, the old `.scroll-thin` and the `@layer base` cursor rules all go with the old file (`base.css` now owns cursors). `.fs-fill` and `.fs-indet` go too; their replacement is the `ProgressBar` primitive (Task 10).
+
+- [ ] **Step 8: Import the fonts**
+
+At the top of `src/renderer/src/main.tsx`, before `import './index.css'`:
+
+```ts
+import '@fontsource/ibm-plex-sans/400.css'
+import '@fontsource/ibm-plex-sans/500.css'
+import '@fontsource/ibm-plex-sans/600.css'
+import '@fontsource/ibm-plex-mono/400.css'
+import '@fontsource/ibm-plex-mono/500.css'
+import '@fontsource/ibm-plex-mono/600.css'
+```
+
+- [ ] **Step 9: Verify**
+
+Run: `npx vitest run test/theme-tokens.test.ts && npm test && npm run typecheck && npm run lint && npm run build`
+Expected: all green; `out/renderer/assets` contains `ibm-plex-*.woff2` files.
+
+Run: `npm run dev`. The window is dark and IBM Plex renders; the old components look broken (their light classes no longer resolve). That is expected until Tasks 11 to 16 replace them.
+
+- [ ] **Step 10: Commit**
+
+```bash
+git add package.json package-lock.json src/renderer/src/theme src/renderer/src/index.css src/renderer/src/main.tsx test/theme-tokens.test.ts
+git commit -m "feat(theme): monochrome dark tokens, bundled IBM Plex, workbench CSS; drop light theme
+
+Co-Authored-By: Claude Opus 5.5 "
+```
+
+---
+
+### Task 9: 16px icon registry and typed icon names
+
+**Files:**
+
+- Create: `src/shared/icons.ts`, `src/renderer/src/components/icons/shapes.ts`, `src/renderer/src/components/icons/Icon.tsx`
+- Modify: `src/shared/tabs.ts` (`Tab.icon` `:18`, `ToolCard.icon` `:117`, the Generate icon `:78`, `COMPLETED_TAB.icon` `:105`)
+- Test: `test/icons.test.ts`
+
+**Interfaces:**
+
+- Consumes: nothing.
+- Produces: `ICON_NAMES` (readonly tuple) and `type IconName` from `@shared/icons`; `ICON_SHAPES: Record` where `type Prim = ['path', string] | ['rect', number, number, number, number] | ['circle', number, number, number]`; ` ` from `components/icons/Icon.tsx`. `Tab.icon` and `ToolCard.icon` are typed `IconName`.
+
+Shapes are plain data in a `.ts` file, so the registry is testable in Vitest without a JSX transform. The old `components/Icon.tsx` stays until its last consumer goes (Task 16). Window-control glyphs stay in `WinControls` as their own 10x10 paths (spec 2.4), so `minimize` / `maximize` are not registry entries.
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+// test/icons.test.ts
+import { describe, expect, it } from 'vitest'
+import { ICON_NAMES } from '@shared/icons'
+import { ICON_SHAPES } from '../src/renderer/src/components/icons/shapes'
+import { COMPLETED_TAB, SETTINGS_TAB, TABS, TOOL_CARDS } from '@shared/tabs'
+
+describe('icon registry', () => {
+ it('draws every declared icon', () => {
+ for (const n of ICON_NAMES) expect(ICON_SHAPES[n]?.length, n).toBeGreaterThan(0)
+ })
+ it('contains the full mockup set', () => {
+ const mockup = [
+ 'convert',
+ 'compress',
+ 'resize',
+ 'upscale',
+ 'removebg',
+ 'generate',
+ 'tools',
+ 'completed',
+ 'settings',
+ 'addfile',
+ 'folder',
+ 'play',
+ 'stop',
+ 'retry',
+ 'close',
+ 'check',
+ 'warning',
+ 'clock',
+ 'sync',
+ 'chev-r',
+ 'chev-d',
+ 'trash',
+ 'eye',
+ 'info',
+ 'arrow',
+ 'sidebar',
+ 'anvil'
+ ]
+ for (const n of mockup) expect(ICON_NAMES as readonly string[]).toContain(n)
+ })
+ it('stays inside the 16px grid', () => {
+ for (const [name, prims] of Object.entries(ICON_SHAPES))
+ for (const p of prims)
+ if (p[0] !== 'path')
+ for (const v of p.slice(1) as number[]) expect(v, name).toBeLessThanOrEqual(16)
+ })
+ it('every tab and tool card names a real icon', () => {
+ for (const t of [...TABS, COMPLETED_TAB, SETTINGS_TAB, ...TOOL_CARDS])
+ expect(ICON_NAMES as readonly string[]).toContain(t.icon)
+ })
+})
+```
+
+- [ ] **Step 2: Run it and watch it fail**
+
+Run: `npx vitest run test/icons.test.ts`
+Expected: FAIL, `@shared/icons` not found.
+
+- [ ] **Step 3: Implement `src/shared/icons.ts`**
+
+```ts
+// Icon names shared by the navigation model (tabs.ts) and the renderer's
+// registry, so a misspelt icon fails typecheck instead of rendering nothing.
+export const ICON_NAMES = [
+ 'convert',
+ 'compress',
+ 'resize',
+ 'upscale',
+ 'removebg',
+ 'generate',
+ 'tools',
+ 'completed',
+ 'settings',
+ 'addfile',
+ 'folder',
+ 'play',
+ 'stop',
+ 'retry',
+ 'close',
+ 'check',
+ 'warning',
+ 'clock',
+ 'sync',
+ 'chev-r',
+ 'chev-d',
+ 'trash',
+ 'eye',
+ 'info',
+ 'arrow',
+ 'sidebar',
+ 'anvil',
+ 'image',
+ 'pdf',
+ 'text',
+ 'merge',
+ 'split',
+ 'burst',
+ 'pull',
+ 'archive',
+ 'video',
+ 'audio',
+ 'doc',
+ 'unpack',
+ 'topdf',
+ 'tocbz',
+ 'edit',
+ 'grip',
+ 'dots'
+] as const
+
+export type IconName = (typeof ICON_NAMES)[number]
+```
+
+- [ ] **Step 4: Implement `components/icons/shapes.ts`**
+
+The first 27 entries are copied from the mockup's ``s (`10-s2-vscode-grouped.html:327-353`); the rest are drawn in the same 16px, 1.25 stroke language.
+
+```ts
+import type { IconName } from '@shared/icons'
+
+export type Prim =
+ ['path', string] | ['rect', number, number, number, number] | ['circle', number, number, number]
+
+const page = 'M3.5 1.5h6l3 3v10h-9z M9.5 1.5v3h3'
+const box = 'M2 2.5h12v3H2z M3 5.5v8h10v-8'
+
+export const ICON_SHAPES: Record = {
+ convert: [['path', 'M2.5 5.5h10.5M10.5 3l2.5 2.5-2.5 2.5M13.5 10.5H3M5.5 8L3 10.5 5.5 13']],
+ compress: [['path', 'M8 1.5V6M5.5 3.5L8 6l2.5-2.5M8 14.5V10M5.5 12.5L8 10l2.5 2.5M2.5 8h11']],
+ resize: [['path', 'M2.5 6V2.5H6M13.5 10v3.5H10M3 3l10 10']],
+ upscale: [
+ ['rect', 2.5, 8.5, 5, 5],
+ ['path', 'M9 2.5h4.5V7M13.5 2.5L8.5 7.5']
+ ],
+ removebg: [['path', 'M9.5 2.5l4 4-6 6H4.5l-2-2 7-8zM6 6l4 4M8 13.5h5.5']],
+ generate: [
+ ['path', 'M7 2l1.2 3.3L11.5 6.5 8.2 7.7 7 11 5.8 7.7 2.5 6.5l3.3-1.2zM12.5 10v4M10.5 12h4']
+ ],
+ tools: [
+ ['rect', 2.5, 2.5, 4.5, 4.5],
+ ['rect', 9, 2.5, 4.5, 4.5],
+ ['rect', 2.5, 9, 4.5, 4.5],
+ ['rect', 9, 9, 4.5, 4.5]
+ ],
+ completed: [
+ ['circle', 8, 8, 5.5],
+ ['path', 'M5.5 8.25l1.75 1.75L10.75 6.5']
+ ],
+ settings: [
+ ['path', 'M2.5 5h5M10.5 5h3M2.5 11h2M7.5 11h6'],
+ ['circle', 9, 5, 1.5],
+ ['circle', 6, 11, 1.5]
+ ],
+ addfile: [['path', 'M3.5 1.5h6l3 3v10h-9z M9.5 1.5v3h3M8 7v5M5.5 9.5h5']],
+ folder: [['path', 'M1.5 3.5h4.5l1.5 1.5h7v8h-13z']],
+ play: [['path', 'M5 3.5v9l7.5-4.5z']],
+ stop: [['rect', 4, 4, 8, 8]],
+ retry: [['path', 'M3 3v3h3M3.3 6A5 5 0 1 1 3 9.5']],
+ close: [['path', 'M4 4l8 8M12 4l-8 8']],
+ check: [['path', 'M3 8.5l3 3 7-7']],
+ warning: [['path', 'M8 2l6.5 11.5h-13zM8 6.5v3M8 11.5v.5']],
+ clock: [
+ ['circle', 8, 8, 5.5],
+ ['path', 'M8 5v3l2 1.5']
+ ],
+ sync: [['path', 'M13.5 8A5.5 5.5 0 1 1 8 2.5']],
+ 'chev-r': [['path', 'M6 4l4 4-4 4']],
+ 'chev-d': [['path', 'M4 6l4 4 4-4']],
+ trash: [['path', 'M3 4.5h10M6 4.5V2.5h4v2M4.5 4.5l.5 9h6l.5-9']],
+ eye: [
+ ['path', 'M1.5 8s2.5-4.5 6.5-4.5S14.5 8 14.5 8s-2.5 4.5-6.5 4.5S1.5 8 1.5 8z'],
+ ['circle', 8, 8, 2]
+ ],
+ info: [
+ ['circle', 8, 8, 5.5],
+ ['path', 'M8 7.5v3.5M8 5v.5']
+ ],
+ arrow: [['path', 'M3 8h10M10 5l3 3-3 3']],
+ sidebar: [
+ ['rect', 2, 2.5, 12, 11],
+ ['path', 'M6 2.5v11M10.5 6.5L9 8l1.5 1.5']
+ ],
+ anvil: [['path', 'M1.5 4.5h10c0 2 1.5 3 3 3v1h-5l-1 2h2v2h-6v-2h2l-1-2c-2.5 0-4-1.5-4-4z']],
+ image: [
+ ['rect', 2.5, 2.5, 11, 11],
+ ['path', 'M2.5 11l3.5-3.5 3 3 2-2 2.5 2.5'],
+ ['circle', 10.5, 5.5, 1]
+ ],
+ pdf: [['path', `${page}M5.5 9h5M5.5 11.5h3`]],
+ text: [['path', 'M3 4h10M3 7h10M3 10h10M3 13h6']],
+ merge: [['path', 'M2.5 3l5.5 5M13.5 3L8 8v6M5.5 11.5L8 14l2.5-2.5']],
+ split: [['path', 'M8 2v6l-5.5 5.5M8 8l5.5 5.5M5.5 4.5L8 2l2.5 2.5']],
+ burst: [
+ ['rect', 2.5, 3.5, 5, 6],
+ ['rect', 8.5, 6.5, 5, 6]
+ ],
+ pull: [['path', 'M2.5 6.5v7h11v-7M8 2v8M5.5 7.5L8 10l2.5-2.5']],
+ archive: [['path', `${box}M6.5 8.5h3`]],
+ video: [
+ ['rect', 1.5, 3.5, 9, 9],
+ ['path', 'M10.5 7l4-2.5v7l-4-2.5']
+ ],
+ audio: [
+ ['path', 'M6 12V3.5l7.5-1.5V10.5'],
+ ['circle', 4.5, 12, 1.5],
+ ['circle', 12, 10.5, 1.5]
+ ],
+ doc: [['path', `${page}M5.5 8h5M5.5 10.5h5M5.5 13h3`]],
+ unpack: [['path', `${box}M8 7v5M5.5 9.5L8 12l2.5-2.5`]],
+ topdf: [['path', 'M5.5 1.5h5l3 3v10h-8v-4M10.5 1.5v3h3M1.5 8h6M5 5.5L7.5 8 5 10.5']],
+ tocbz: [
+ ['rect', 6.5, 6.5, 8, 7],
+ ['path', 'M1.5 4h7M6 1.5L8.5 4 6 6.5']
+ ],
+ edit: [['path', 'M10.5 2.5l3 3-8 8h-3v-3zM9 4l3 3']],
+ grip: [
+ ['circle', 6, 4, 0.75],
+ ['circle', 10, 4, 0.75],
+ ['circle', 6, 8, 0.75],
+ ['circle', 10, 8, 0.75],
+ ['circle', 6, 12, 0.75],
+ ['circle', 10, 12, 0.75]
+ ],
+ dots: [
+ ['circle', 8, 3.5, 0.75],
+ ['circle', 8, 8, 0.75],
+ ['circle', 8, 12.5, 0.75]
+ ]
+}
+```
+
+- [ ] **Step 5: Implement `components/icons/Icon.tsx`**
+
+```tsx
+import type { JSX } from 'react'
+import type { IconName } from '@shared/icons'
+import { ICON_SHAPES } from './shapes'
+
+export function Icon({
+ name,
+ size = 16,
+ strokeWidth,
+ className = ''
+}: {
+ name: IconName
+ size?: 16 | 12 | 10
+ strokeWidth?: number
+ className?: string
+}): JSX.Element {
+ const cls = `i${size === 12 ? ' i12' : size === 10 ? ' i10' : ''}${className ? ' ' + className : ''}`
+ return (
+
+ {ICON_SHAPES[name].map((p, i) =>
+ p[0] === 'path' ? (
+
+ ) : p[0] === 'rect' ? (
+
+ ) : (
+
+ )
+ )}
+
+ )
+}
+```
+
+- [ ] **Step 6: Type the tab icons**
+
+In `src/shared/tabs.ts` add `import type { IconName } from './icons'`, change `icon: string` to `icon: IconName` in both `Tab` and `ToolCard`, change the Generate tab's `icon: 'image'` to `icon: 'generate'` and `COMPLETED_TAB`'s `icon: 'check'` to `icon: 'completed'`. The old `TabRail` / `ToolsGrid` cast `c.icon as IconName` (the old union) and still compile; they are deleted in Tasks 11 and 15.
+
+- [ ] **Step 7: Verify**
+
+Run: `npx vitest run test/icons.test.ts && npm test && npm run typecheck && npm run lint`
+Expected: PASS and clean.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add src/shared/icons.ts src/shared/tabs.ts src/renderer/src/components/icons test/icons.test.ts
+git commit -m "feat(ui): 16px stroke icon registry with typed icon names
+
+Co-Authored-By: Claude Opus 5.5 "
+```
+
+---
+
+### Task 10: UI primitives
+
+**Files:**
+
+- Create in `src/renderer/src/components/ui/`: `roving.ts`, `selectNav.ts`, `Button.tsx`, `Checkbox.tsx`, `Segmented.tsx`, `Select.tsx`, `TextField.tsx`, `Setting.tsx`, `RangeField.tsx`, `ChipGrid.tsx`, `ProgressBar.tsx`, `Tabs.tsx`, `EstimateCard.tsx`, `OutputSizeList.tsx`
+- Create: `src/renderer/src/theme/controls.css`
+- Modify: `src/renderer/src/index.css` (add `@import './theme/controls.css';` after `workbench.css`)
+- Test: `test/ui-nav.test.ts`
+
+**Interfaces:**
+
+- Consumes: `Icon` (Task 9); `formatBytes` from `@shared/compress`; `pctChange`, `formatPct` from `components/queue/rowModel.ts` (Task 5).
+- Produces (exact names and props; later tasks use them):
+ - `rovingIndex(key: string, i: number, n: number, axis?: 'x' | 'y'): number | null`
+ - `interface SelectOption { value: T; label: string; disabled?: boolean; reason?: string; group?: string }` and `nextEnabled(options: { label: string; disabled?: boolean }[], i: number, key: string): number | null`, both from `selectNav.ts`
+ - `AddFilesButton(props: ButtonHTMLAttributes)`, `IconButton({ icon, label, ...button })`, `SmallButton({ icon?, children, ...button })`, `RowAction({ icon, label, ghost?, ...button })`, `PrimaryButton({ icon?, children, ...button })`
+ - `Checkbox({ checked: boolean | 'mixed', onChange: () => void, label: string, size?: 'sm' | 'md', focusable?: boolean })`
+ - `Segmented({ value: T, options: { value: T; label: string }[], onChange: (v: T) => void, label: string })`
+ - `Select({ value: T, options: SelectOption[], onChange: (v: T) => void, label: string, half?: boolean, disabled?: boolean, placeholder?: string })`
+ - `TextField(props: InputHTMLAttributes)`, `NumberField({ value: number | '', onCommit: (v: number | '') => void, label: string, placeholder?: string, clamp?: (n: number) => number })`
+ - `SettingGroup({ title: string, collapsible?: boolean, open?: boolean, onToggle?: () => void, children })`, `Setting({ title: string, desc?: ReactNode, warn?: boolean, children? })`, `CheckSetting({ checked: boolean, onChange: (v: boolean) => void, label: string, sub?: string })`
+ - `RangeField({ label, value, min, max, step?, onChange: (v: number) => void, format?: (v: number) => string, ends?: [string, string] })`
+ - `interface Chip { value: T; label: string; disabled?: boolean; title?: string }` and `ChipGrid({ value: T | null, chips: Chip[], onChange, label, cols? })`
+ - `ProgressBar({ value?: number | null, label: string, wide?: boolean })` (`null` or omitted means indeterminate)
+ - `interface TabDef { id: T; label: string; icon: IconName }` and `Tabs({ tabs, value, onChange, label, idPrefix })`
+ - `EstimateCard({ from: number, to: number, files: number })`
+ - `interface SizeRow { name: string; from: string; to: string }` (exported from `OutputSizeList.tsx` as a type) and `OutputSizeList({ rows: SizeRow[] })`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+// test/ui-nav.test.ts
+import { describe, expect, it } from 'vitest'
+import { rovingIndex } from '../src/renderer/src/components/ui/roving'
+import { nextEnabled } from '../src/renderer/src/components/ui/selectNav'
+
+describe('rovingIndex', () => {
+ it('wraps left and right on the x axis', () => {
+ expect(rovingIndex('ArrowRight', 2, 3)).toBe(0)
+ expect(rovingIndex('ArrowLeft', 0, 3)).toBe(2)
+ })
+ it('uses up and down on the y axis only', () => {
+ expect(rovingIndex('ArrowDown', 0, 3, 'y')).toBe(1)
+ expect(rovingIndex('ArrowRight', 0, 3, 'y')).toBeNull()
+ })
+ it('jumps with Home and End', () => {
+ expect(rovingIndex('Home', 2, 3)).toBe(0)
+ expect(rovingIndex('End', 0, 3)).toBe(2)
+ })
+ it('does nothing for an empty set or another key', () => {
+ expect(rovingIndex('ArrowRight', 0, 0)).toBeNull()
+ expect(rovingIndex('a', 0, 3)).toBeNull()
+ })
+})
+
+describe('nextEnabled (select popup keyboard)', () => {
+ const opts = [
+ { label: 'webp' },
+ { label: 'png', disabled: true },
+ { label: 'avif' },
+ { label: 'jpg' }
+ ]
+ it('skips disabled options with the arrows and stops at the ends', () => {
+ expect(nextEnabled(opts, 0, 'ArrowDown')).toBe(2)
+ expect(nextEnabled(opts, 2, 'ArrowUp')).toBe(0)
+ expect(nextEnabled(opts, 3, 'ArrowDown')).toBe(3)
+ })
+ it('Home and End land on enabled options', () => {
+ expect(nextEnabled([{ label: 'x', disabled: true }, ...opts], 3, 'Home')).toBe(1)
+ expect(nextEnabled(opts, 0, 'End')).toBe(3)
+ })
+ it('type-ahead finds the next enabled match after the current one', () => {
+ expect(nextEnabled(opts, 0, 'a')).toBe(2)
+ expect(nextEnabled(opts, 0, 'p')).toBeNull() // png is disabled
+ expect(nextEnabled(opts, 2, 'W')).toBe(0)
+ })
+})
+```
+
+- [ ] **Step 2: Run it and watch it fail**
+
+Run: `npx vitest run test/ui-nav.test.ts`
+Expected: FAIL, modules not found.
+
+- [ ] **Step 3: Implement the two pure helpers**
+
+```ts
+// src/renderer/src/components/ui/roving.ts
+/** Next index for roving-tabindex groups (tabs, segments, chips). */
+export function rovingIndex(
+ key: string,
+ i: number,
+ n: number,
+ axis: 'x' | 'y' = 'x'
+): number | null {
+ if (n <= 0) return null
+ const back = axis === 'x' ? 'ArrowLeft' : 'ArrowUp'
+ const fwd = axis === 'x' ? 'ArrowRight' : 'ArrowDown'
+ if (key === back) return (i - 1 + n) % n
+ if (key === fwd) return (i + 1) % n
+ if (key === 'Home') return 0
+ if (key === 'End') return n - 1
+ return null
+}
+```
+
+```ts
+// src/renderer/src/components/ui/selectNav.ts
+export interface SelectOption {
+ value: T
+ label: string
+ disabled?: boolean
+ /** Shown after the label, for example "WinRAR not found" on a disabled
+ * target or "needs download" on a selectable Generate model. */
+ reason?: string
+ /** Option-group heading (Generate's architectures). */
+ group?: string
+}
+
+/** Keyboard movement inside the select popup: arrows skip disabled items and
+ * stop at the ends, Home/End land on enabled items, a printable key jumps to the
+ * next enabled label starting with it. */
+export function nextEnabled(
+ options: { label: string; disabled?: boolean }[],
+ i: number,
+ key: string
+): number | null {
+ const ok = (k: number): boolean => k >= 0 && k < options.length && !options[k].disabled
+ if (key === 'ArrowDown') {
+ for (let k = i + 1; k < options.length; k++) if (ok(k)) return k
+ return i
+ }
+ if (key === 'ArrowUp') {
+ for (let k = i - 1; k >= 0; k--) if (ok(k)) return k
+ return i
+ }
+ if (key === 'Home') {
+ for (let k = 0; k < options.length; k++) if (ok(k)) return k
+ return null
+ }
+ if (key === 'End') {
+ for (let k = options.length - 1; k >= 0; k--) if (ok(k)) return k
+ return null
+ }
+ if (key.length === 1) {
+ const c = key.toLowerCase()
+ for (let step = 1; step <= options.length; step++) {
+ const k = (i + step) % options.length
+ if (ok(k) && options[k].label.toLowerCase().startsWith(c)) return k
+ }
+ return null
+ }
+ return null
+}
+```
+
+- [ ] **Step 4: Run the test**
+
+Run: `npx vitest run test/ui-nav.test.ts`
+Expected: PASS.
+
+- [ ] **Step 5: Write the components**
+
+```tsx
+// src/renderer/src/components/ui/Button.tsx
+import type { ButtonHTMLAttributes, JSX, ReactNode } from 'react'
+import type { IconName } from '@shared/icons'
+import { Icon } from '../icons/Icon'
+
+type B = ButtonHTMLAttributes
+
+export function AddFilesButton({ children = 'Add files', ...rest }: B): JSX.Element {
+ return (
+
+
+ {children}
+
+ )
+}
+
+export function IconButton({
+ icon,
+ label,
+ ...rest
+}: B & { icon: IconName; label: string }): JSX.Element {
+ return (
+
+
+
+ )
+}
+
+export function SmallButton({
+ icon,
+ children,
+ ...rest
+}: B & { icon?: IconName; children: ReactNode }): JSX.Element {
+ return (
+
+ {icon && }
+ {children}
+
+ )
+}
+
+export function RowAction({
+ icon,
+ label,
+ ghost,
+ ...rest
+}: B & { icon: IconName; label: string; ghost?: boolean }): JSX.Element {
+ return (
+
+
+
+ )
+}
+
+export function PrimaryButton({
+ icon = 'play',
+ children,
+ ...rest
+}: B & { icon?: IconName; children: ReactNode }): JSX.Element {
+ return (
+
+
+ {children}
+
+ )
+}
+```
+
+```tsx
+// src/renderer/src/components/ui/Checkbox.tsx
+import type { JSX } from 'react'
+import { Icon } from '../icons/Icon'
+
+export function Checkbox({
+ checked,
+ onChange,
+ label,
+ size = 'sm',
+ focusable = false
+}: {
+ checked: boolean | 'mixed'
+ onChange: () => void
+ label: string
+ size?: 'sm' | 'md'
+ /** Table rows take focus themselves, so row boxes are tabindex -1 (spec 4.1). */
+ focusable?: boolean
+}): JSX.Element {
+ const on = checked === true
+ return (
+ {
+ e.stopPropagation()
+ e.preventDefault()
+ onChange()
+ }}
+ onKeyDown={(e) => {
+ if (e.key === ' ' || e.key === 'Enter') {
+ e.preventDefault()
+ e.stopPropagation()
+ onChange()
+ }
+ }}
+ >
+ {on && }
+
+ )
+}
+```
+
+```tsx
+// src/renderer/src/components/ui/Segmented.tsx
+import { useRef, type JSX } from 'react'
+import { rovingIndex } from './roving'
+
+export function Segmented({
+ value,
+ options,
+ onChange,
+ label
+}: {
+ value: T
+ options: { value: T; label: string }[]
+ onChange: (v: T) => void
+ label: string
+}): JSX.Element {
+ const refs = useRef<(HTMLButtonElement | null)[]>([])
+ const idx = options.findIndex((o) => o.value === value)
+ return (
+
+ {options.map((o, i) => (
+ {
+ refs.current[i] = el
+ }}
+ type="button"
+ role="radio"
+ aria-checked={i === idx}
+ tabIndex={i === (idx < 0 ? 0 : idx) ? 0 : -1}
+ className={i === idx ? 'on' : ''}
+ onClick={() => onChange(o.value)}
+ onKeyDown={(e) => {
+ const n = rovingIndex(e.key, i, options.length)
+ if (n == null) return
+ e.preventDefault()
+ onChange(options[n].value)
+ refs.current[n]?.focus()
+ }}
+ >
+ {o.label}
+
+ ))}
+
+ )
+}
+```
+
+```tsx
+// src/renderer/src/components/ui/Select.tsx
+import { useEffect, useId, useRef, useState, type JSX, type KeyboardEvent } from 'react'
+import { Icon } from '../icons/Icon'
+import { nextEnabled, type SelectOption } from './selectNav'
+
+// A custom listbox (spec 2.7): Chromium draws a native popup in light
+// OS chrome, which breaks the dark-only rule.
+export function Select({
+ value,
+ options,
+ onChange,
+ label,
+ half,
+ disabled,
+ placeholder = 'choose'
+}: {
+ value: T
+ options: SelectOption[]
+ onChange: (v: T) => void
+ label: string
+ half?: boolean
+ disabled?: boolean
+ placeholder?: string
+}): JSX.Element {
+ const [open, setOpen] = useState(false)
+ const [active, setActive] = useState(0)
+ const root = useRef(null)
+ const id = useId()
+ const current = options.find((o) => o.value === value)
+
+ useEffect(() => {
+ if (!open) return
+ const close = (e: MouseEvent): void => {
+ if (!root.current?.contains(e.target as Node)) setOpen(false)
+ }
+ const blur = (): void => setOpen(false)
+ window.addEventListener('mousedown', close)
+ window.addEventListener('blur', blur)
+ return () => {
+ window.removeEventListener('mousedown', close)
+ window.removeEventListener('blur', blur)
+ }
+ }, [open])
+
+ function show(): void {
+ setActive(
+ Math.max(
+ 0,
+ options.findIndex((o) => o.value === value)
+ )
+ )
+ setOpen(true)
+ }
+ function commit(i: number): void {
+ const o = options[i]
+ if (!o || o.disabled) return
+ onChange(o.value)
+ setOpen(false)
+ }
+ function onKey(e: KeyboardEvent): void {
+ if (!open) {
+ if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(e.key)) {
+ e.preventDefault()
+ show()
+ }
+ return
+ }
+ if (e.key === 'Escape') {
+ e.preventDefault()
+ setOpen(false)
+ return
+ }
+ if (e.key === 'Tab') {
+ setOpen(false)
+ return
+ }
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault()
+ commit(active)
+ return
+ }
+ const n = nextEnabled(options, active, e.key)
+ if (n != null) {
+ e.preventDefault()
+ setActive(n)
+ }
+ }
+
+ return (
+
+
(open ? setOpen(false) : show())}
+ onKeyDown={onKey}
+ >
+
+ {current ? current.label : {placeholder} }
+
+
+
+ {open && (
+
+ {options.flatMap((o, i) => {
+ const head = o.group && o.group !== options[i - 1]?.group ? o.group : null
+ const opt = (
+ e.preventDefault()}
+ onMouseEnter={() => !o.disabled && setActive(i)}
+ onClick={() => commit(i)}
+ >
+
+ {o.label}
+ {o.reason ? `, ${o.reason}` : ''}
+
+ {o.value === value && }
+
+ )
+ return head
+ ? [
+
+ {head.toUpperCase()}
+ ,
+ opt
+ ]
+ : [opt]
+ })}
+
+ )}
+
+ )
+}
+```
+
+```tsx
+// src/renderer/src/components/ui/TextField.tsx
+import { useState, type InputHTMLAttributes, type JSX } from 'react'
+
+export function TextField({
+ className = '',
+ ...rest
+}: InputHTMLAttributes): JSX.Element {
+ return
+}
+
+/** Commit-on-blur/Enter number input with optional clamping (replaces DimInput).
+ * The draft exists only while editing, so no effect has to sync it. */
+export function NumberField({
+ value,
+ onCommit,
+ label,
+ placeholder,
+ clamp
+}: {
+ value: number | ''
+ onCommit: (v: number | '') => void
+ label: string
+ placeholder?: string
+ clamp?: (n: number) => number
+}): JSX.Element {
+ const [draft, setDraft] = useState(null)
+ function commit(): void {
+ if (draft == null) return
+ const t = draft.trim()
+ if (t === '') onCommit('')
+ else {
+ const n = Number(t)
+ if (Number.isFinite(n)) onCommit(clamp ? clamp(n) : n)
+ }
+ setDraft(null)
+ }
+ return (
+ setDraft(e.target.value)}
+ onBlur={commit}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') commit()
+ if (e.key === 'Escape') setDraft(null)
+ }}
+ />
+ )
+}
+```
+
+```tsx
+// src/renderer/src/components/ui/Setting.tsx
+import type { JSX, ReactNode } from 'react'
+import { Icon } from '../icons/Icon'
+import { Checkbox } from './Checkbox'
+
+export function SettingGroup({
+ title,
+ collapsible,
+ open = true,
+ onToggle,
+ children
+}: {
+ title: string
+ collapsible?: boolean
+ open?: boolean
+ onToggle?: () => void
+ children: ReactNode
+}): JSX.Element {
+ return (
+
+
+ {collapsible ? (
+
+
+ {title}
+
+ ) : (
+ title
+ )}
+
+ {open && children}
+
+ )
+}
+
+export function Setting({
+ title,
+ desc,
+ warn,
+ children
+}: {
+ title: string
+ desc?: ReactNode
+ /** Prefix the description with the warning glyph (monochrome warning). */
+ warn?: boolean
+ children?: ReactNode
+}): JSX.Element {
+ return (
+
+
{title}
+ {desc != null && (
+
+ {warn && }
+ {desc}
+
+ )}
+ {children}
+
+ )
+}
+
+export function CheckSetting({
+ checked,
+ onChange,
+ label,
+ sub
+}: {
+ checked: boolean
+ onChange: (v: boolean) => void
+ label: string
+ sub?: string
+}): JSX.Element {
+ return (
+ onChange(!checked)}>
+ onChange(!checked)}
+ label={label}
+ size="md"
+ focusable
+ />
+
+ {label}
+ {sub && {sub} }
+
+
+ )
+}
+```
+
+```tsx
+// src/renderer/src/components/ui/RangeField.tsx
+import type { CSSProperties, JSX } from 'react'
+
+export function RangeField({
+ label,
+ value,
+ min,
+ max,
+ step = 1,
+ onChange,
+ format = String,
+ ends
+}: {
+ label: string
+ value: number
+ min: number
+ max: number
+ step?: number
+ onChange: (v: number) => void
+ format?: (v: number) => string
+ ends?: [string, string]
+}): JSX.Element {
+ const fill = max > min ? ((value - min) / (max - min)) * 100 : 0
+ return (
+
+
+ onChange(Number(e.target.value))}
+ style={{ '--fill': `${fill}%` } as CSSProperties}
+ />
+ {format(value)}
+
+ {ends && (
+
+ {ends[0]}
+ {ends[1]}
+
+ )}
+
+ )
+}
+```
+
+```tsx
+// src/renderer/src/components/ui/ChipGrid.tsx
+import { useRef, type JSX } from 'react'
+import { rovingIndex } from './roving'
+
+export interface Chip {
+ value: T
+ label: string
+ disabled?: boolean
+ title?: string
+}
+
+export function ChipGrid({
+ value,
+ chips,
+ onChange,
+ label,
+ cols = 4
+}: {
+ value: T | null
+ chips: Chip[]
+ onChange: (v: T) => void
+ label: string
+ cols?: number
+}): JSX.Element {
+ const refs = useRef<(HTMLButtonElement | null)[]>([])
+ const idx = chips.findIndex((c) => c.value === value && !c.disabled)
+ const focusIdx =
+ idx >= 0
+ ? idx
+ : Math.max(
+ 0,
+ chips.findIndex((c) => !c.disabled)
+ )
+ return (
+
+ {chips.map((c, i) => (
+ {
+ refs.current[i] = el
+ }}
+ type="button"
+ role="radio"
+ aria-checked={i === idx}
+ aria-disabled={c.disabled || undefined}
+ tabIndex={i === focusIdx ? 0 : -1}
+ title={c.title}
+ className={`chip${i === idx ? ' on' : ''}`}
+ onClick={() => !c.disabled && onChange(c.value)}
+ onKeyDown={(e) => {
+ let n = rovingIndex(e.key, i, chips.length)
+ if (n == null) return
+ e.preventDefault()
+ const dir = e.key === 'ArrowLeft' || e.key === 'End' ? -1 : 1
+ for (let s = 0; s < chips.length && chips[n].disabled; s++)
+ n = (n + dir + chips.length) % chips.length
+ if (chips[n].disabled) return
+ onChange(chips[n].value)
+ refs.current[n]?.focus()
+ }}
+ >
+ {c.label}
+
+ ))}
+
+ )
+}
+```
+
+```tsx
+// src/renderer/src/components/ui/ProgressBar.tsx
+import type { JSX } from 'react'
+
+/** 2px track with an fg1 fill; a null value is an indeterminate sweep (static
+ * under reduced motion). Replaces the three copies in the tool setup cards. */
+export function ProgressBar({
+ value,
+ label,
+ wide
+}: {
+ value?: number | null
+ label: string
+ wide?: boolean
+}): JSX.Element {
+ const ind = value == null
+ return (
+
+
+
+ )
+}
+```
+
+```tsx
+// src/renderer/src/components/ui/Tabs.tsx
+import { useRef, type JSX } from 'react'
+import type { IconName } from '@shared/icons'
+import { Icon } from '../icons/Icon'
+import { rovingIndex } from './roving'
+
+export interface TabDef {
+ id: T
+ label: string
+ icon: IconName
+}
+
+export function Tabs({
+ tabs,
+ value,
+ onChange,
+ label,
+ idPrefix
+}: {
+ tabs: TabDef[]
+ value: T
+ onChange: (v: T) => void
+ label: string
+ idPrefix: string
+}): JSX.Element {
+ const refs = useRef<(HTMLButtonElement | null)[]>([])
+ return (
+
+ {tabs.map((t, i) => (
+ {
+ refs.current[i] = el
+ }}
+ type="button"
+ role="tab"
+ id={`${idPrefix}-tab-${t.id}`}
+ aria-controls={`${idPrefix}-panel-${t.id}`}
+ aria-selected={t.id === value}
+ tabIndex={t.id === value ? 0 : -1}
+ className={`tab${t.id === value ? ' on' : ''}`}
+ onClick={() => onChange(t.id)}
+ onKeyDown={(e) => {
+ const n = rovingIndex(e.key, i, tabs.length)
+ if (n == null) return
+ e.preventDefault()
+ onChange(tabs[n].id)
+ refs.current[n]?.focus()
+ }}
+ >
+
+
+ {t.label}
+
+
+ ))}
+
+ )
+}
+```
+
+```tsx
+// src/renderer/src/components/ui/EstimateCard.tsx
+import type { JSX } from 'react'
+import { formatBytes } from '@shared/compress'
+import { Icon } from '../icons/Icon'
+import { formatPct, pctChange } from '../queue/rowModel'
+
+export function EstimateCard({
+ from,
+ to,
+ files
+}: {
+ from: number
+ to: number
+ files: number
+}): JSX.Element {
+ const pct = pctChange(from, to)
+ const noun = `${files} file${files === 1 ? '' : 's'}`
+ const label = `Estimated output for ${noun}: ${formatBytes(from)} to about ${formatBytes(to)}${
+ pct != null ? `, ${formatPct(pct)}` : ''
+ }`
+ return (
+
+
+ estimate
+ {noun}
+
+
+ {formatBytes(from)}
+
+ ~{formatBytes(to)}
+ {pct != null && {formatPct(pct)} }
+
+
+ )
+}
+```
+
+```tsx
+// src/renderer/src/components/ui/OutputSizeList.tsx
+import type { JSX } from 'react'
+
+export type SizeRow = { name: string; from: string; to: string }
+
+/** Input to output resolution list under the video scale, resize and upscale
+ * settings (replaces three identical copies). */
+export function OutputSizeList({ rows }: { rows: SizeRow[] }): JSX.Element | null {
+ if (!rows.length) return null
+ return (
+
+ {rows.map((r, i) => (
+
+ {r.name}
+
+ {r.from} to {r.to}
+
+
+ ))}
+
+ )
+}
+```
+
+- [ ] **Step 6: Write `theme/controls.css`**
+
+```css
+/* Controls the mockup does not draw, in its language (spec 2.5 and 2.7). */
+
+/* disabled: fg-disabled text, line border, no hover change */
+.sbtn:disabled,
+.vs-sel:disabled,
+.ibtn:disabled,
+.row-act:disabled,
+.chip[aria-disabled='true'] {
+ color: var(--fg-disabled);
+ border-color: var(--line);
+ background: transparent;
+}
+.sbtn:disabled svg,
+.vs-sel:disabled svg {
+ color: var(--fg-disabled);
+}
+.primary:disabled {
+ background: var(--selected);
+ color: var(--fg-disabled);
+}
+
+/* select popup */
+.vs-selwrap {
+ position: relative;
+ width: 100%;
+}
+.vs-selwrap.half {
+ width: 168px;
+}
+.vs-row .vs-selwrap {
+ flex: 1;
+ width: auto;
+}
+.vs-sel .ph {
+ color: var(--fg3);
+}
+.vs-pop {
+ position: absolute;
+ z-index: 30;
+ left: 0;
+ right: 0;
+ top: 100%;
+ max-height: 280px;
+ overflow-y: auto;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ background: var(--bg-0);
+ border: 1px solid var(--line-strong);
+ border-top: 0;
+}
+.vs-pop li[role='option'] {
+ height: 28px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 0 8px;
+ font: 13px var(--mono);
+ color: var(--fg2);
+ cursor: pointer;
+}
+.vs-pop li .val {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.vs-pop li.act {
+ background: var(--hover);
+ color: var(--fg1);
+}
+.vs-pop li.cur {
+ color: var(--fg1);
+ font-weight: 600;
+}
+.vs-pop li.dis {
+ color: var(--fg-disabled);
+ cursor: default;
+}
+.vs-pop-gh {
+ padding: 10px 8px 4px;
+ font: 600 11px/1 var(--mono);
+ letter-spacing: 0.09em;
+ color: var(--fg3);
+}
+
+/* group header that collapses (Generate > ADVANCED) */
+.vs-ghb {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font: inherit;
+ letter-spacing: inherit;
+ color: inherit;
+}
+.vs-ghb:hover {
+ color: var(--fg1);
+}
+.vs-d.warn {
+ display: flex;
+ gap: 6px;
+ align-items: flex-start;
+ color: var(--fg2);
+}
+.vs-d.warn svg {
+ margin-top: 2px;
+}
+
+/* chip grid: square outlined cells, the selected one inverted */
+.chips {
+ display: grid;
+ border-top: 1px solid var(--line-strong);
+ border-left: 1px solid var(--line-strong);
+}
+.chip {
+ height: 28px;
+ display: grid;
+ place-items: center;
+ font: 13px/1 var(--mono);
+ color: var(--fg2);
+ border-right: 1px solid var(--line-strong);
+ border-bottom: 1px solid var(--line-strong);
+ transition:
+ background-color 0.1s,
+ color 0.1s;
+}
+.chip:hover:not([aria-disabled='true']) {
+ background: var(--hover);
+ color: var(--fg1);
+}
+.chip:active:not([aria-disabled='true']) {
+ background: var(--track);
+}
+.chip.on {
+ background: var(--inv-bg);
+ color: var(--inv-fg);
+ font-weight: 600;
+}
+.chip.on:hover {
+ background: var(--inv-hover);
+}
+.chip:focus-visible {
+ outline-offset: -3px;
+ position: relative;
+ z-index: 1;
+}
+
+/* range: 2px track, fg1 fill, square 10px thumb */
+.rng-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+.rng output {
+ min-width: 52px;
+ text-align: right;
+ font-size: 13px;
+ color: var(--fg1);
+}
+.rng input[type='range'] {
+ flex: 1;
+ height: 16px;
+ margin: 0;
+ background: transparent;
+ appearance: none;
+ cursor: pointer;
+}
+.rng input[type='range']::-webkit-slider-runnable-track {
+ height: 2px;
+ background: linear-gradient(to right, var(--fg1) var(--fill), var(--track) var(--fill));
+}
+.rng input[type='range']::-webkit-slider-thumb {
+ appearance: none;
+ width: 10px;
+ height: 10px;
+ margin-top: -4px;
+ background: var(--fg1);
+ border: 0;
+}
+.rng input[type='range']:hover::-webkit-slider-thumb {
+ background: var(--inv-hover);
+}
+.rng input[type='range']:focus-visible {
+ outline-offset: 2px;
+}
+.rng-ends {
+ display: flex;
+ justify-content: space-between;
+ font-size: 11px;
+ color: var(--fg3);
+ margin-top: 2px;
+}
+
+/* progress: determinate fill or an indeterminate sweep */
+.mini.wide {
+ width: 100%;
+}
+.mini.ind i {
+ width: 30%;
+ animation: fs-sweep 1.2s linear infinite;
+}
+@keyframes fs-sweep {
+ from {
+ left: -30%;
+ }
+ to {
+ left: 100%;
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ .mini.ind i {
+ animation: none;
+ left: 0;
+ }
+ .chip {
+ transition: none;
+ }
+}
+
+/* output size list */
+.sizes {
+ margin: 4px 0 0;
+ padding: 0;
+ list-style: none;
+ font-size: 12px;
+ color: var(--fg2);
+}
+.sizes li {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 2px 0;
+}
+.sizes .nm {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--fg3);
+}
+```
+
+Add `@import './theme/controls.css';` after the `workbench.css` import in `index.css`.
+
+- [ ] **Step 7: Verify**
+
+Run: `npm test && npm run typecheck && npm run lint`
+Expected: all green (the components are not mounted yet; Tasks 11 to 15 mount them).
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add src/renderer/src/components/ui src/renderer/src/theme/controls.css src/renderer/src/index.css test/ui-nav.test.ts
+git commit -m "feat(ui): monochrome primitives (buttons, select, segmented, chips, range, tabs, settings)
+
+Co-Authored-By: Claude Opus 5.5 "
+```
+
+---
+
+### Task 11: App shell: title bar and breadcrumb, collapsible sidebar, status bar, global shortcuts
+
+**Files:**
+
+- Create in `src/renderer/src/components/shell/`: `crumbs.ts`, `sidebarState.ts`, `useSidebar.ts`, `railPrefs.ts`, `useRailPrefs.ts`, `statusModel.ts`, `shortcuts.ts`, `TitleBar.tsx`, `Breadcrumb.tsx`, `Sidebar.tsx`, `StatusBar.tsx`
+- Modify: `src/renderer/src/components/queue/selectAll.ts` (add `oneGroupIds`)
+- Modify: `src/renderer/src/App.tsx` (root layout `:852-1022`, `run()` `:661-729`, new state and effects)
+- Delete: `src/renderer/src/components/TopBar.tsx`, `src/renderer/src/components/TabRail.tsx`, `src/renderer/src/assets/fmark.png` (only if `grep -rn fmark src` shows no other user)
+- Test: `test/shell-model.test.ts`, `test/select-all.test.ts` (extend)
+
+**Interfaces:**
+
+- Consumes: `Icon`, `ProgressBar` (Tasks 9, 10); `TABS`, `COMPLETED_TAB`, `SETTINGS_TAB`, `tabById`, `type Tab`, `type TabId`, `type ToolCard` from `@shared/tabs`; `groupLabel` (Task 6); `inInput`, `groupOf` from `state.ts`; `activeGroupFor` (Task 6).
+- Produces:
+ - `type CrumbAction = 'tools' | 'clearSelection'`; `interface Crumb { label: string; action?: CrumbAction; ariaLabel?: string }`; `crumbsFor(tab: TabId, card: ToolCard | null, group: string | null): Crumb[]`
+ - `type SidebarPref = 'expanded' | 'collapsed'`; `NARROW_PX = 1280`; `isCollapsed(pref, narrow, override: boolean | null): boolean`; `afterToggle(pref, narrow, override): { pref: SidebarPref; override: boolean | null }`; hook `useSidebar(): { collapsed: boolean; toggle: () => void }`
+ - `normalizeOrder(saved: unknown, all: TabId[]): TabId[]`; `moveItem(list: T[], from: number, to: number): T[]`; `sidebarVerbs(order: TabId[], hidden: TabId[]): TabId[]` (excludes `tools` and hidden); hook `useRailPrefs(): { order: TabId[]; hidden: TabId[]; setOrder(o: TabId[]): void; toggleHidden(id: TabId): void }`
+ - `interface StatusSummary { running: { label: string; pct: number } | null; message: string | null; done: string | null; failed: number }`; `verbGerund(label: string): string`; `statusSummary(items: QueueItem[], batch: string[] | null, verb: string, message?: string | null): StatusSummary`
+ - `type Shortcut = 'toggleSidebar' | 'addFiles' | 'run'`; `shortcutFor(e: KeyLike): Shortcut | null` (`KeyLike` from `queue/tableKeys.ts`)
+ - `oneGroupIds(items: QueueItem[], ids: string[]): string[]` (keeps only ids in the display-first group among them)
+ - Components `TitleBar({ crumbs, onCrumb })`, `Sidebar({ tab, verbs, showTools, counts, completedCount, collapsed, onToggle, onSelect })`, `StatusBar({ summary, onFailedClick })`
+ - The sidebar is ``; each item is a `` whose text is the tab label.
+
+- [ ] **Step 1: Write the failing tests**
+
+```ts
+// test/shell-model.test.ts
+import { describe, expect, it } from 'vitest'
+import { crumbsFor } from '../src/renderer/src/components/shell/crumbs'
+import { afterToggle, isCollapsed } from '../src/renderer/src/components/shell/sidebarState'
+import {
+ moveItem,
+ normalizeOrder,
+ sidebarVerbs
+} from '../src/renderer/src/components/shell/railPrefs'
+import { statusSummary, verbGerund } from '../src/renderer/src/components/shell/statusModel'
+import { shortcutFor } from '../src/renderer/src/components/shell/shortcuts'
+import { TABS, toolCardById, type TabId } from '@shared/tabs'
+import type { QueueItem } from '../src/renderer/src/state'
+
+describe('breadcrumb', () => {
+ it('shows the verb, then the active group as the current segment', () => {
+ expect(crumbsFor('convert', null, 'image')).toEqual([
+ { label: 'convert', action: 'clearSelection' },
+ { label: 'images' }
+ ])
+ expect(crumbsFor('removebg', null, null)).toEqual([{ label: 'remove bg' }])
+ })
+ it('makes tools a Back to Tools button inside a card', () => {
+ expect(crumbsFor('tools', toolCardById('pdf-merge')!, 'doc')).toEqual([
+ { label: 'tools', action: 'tools', ariaLabel: 'Back to Tools' },
+ { label: 'merge' }
+ ])
+ expect(crumbsFor('tools', null, null)).toEqual([{ label: 'tools' }])
+ })
+ it('has a single segment for generate, completed and settings', () => {
+ for (const t of ['generate', 'completed', 'settings'] as TabId[])
+ expect(crumbsFor(t, null, 'image')).toEqual([{ label: t }])
+ })
+})
+
+describe('sidebar collapse', () => {
+ it('follows the stored preference on a wide window', () => {
+ expect(isCollapsed('expanded', false, null)).toBe(false)
+ expect(isCollapsed('collapsed', false, null)).toBe(true)
+ })
+ it('auto-collapses when narrow without touching the preference', () => {
+ expect(isCollapsed('expanded', true, null)).toBe(true)
+ expect(afterToggle('expanded', true, null)).toEqual({ pref: 'expanded', override: false })
+ })
+ it('a wide toggle flips and persists the preference', () => {
+ expect(afterToggle('expanded', false, null)).toEqual({ pref: 'collapsed', override: null })
+ expect(afterToggle('collapsed', false, null)).toEqual({ pref: 'expanded', override: null })
+ })
+})
+
+describe('rail preferences', () => {
+ const all = TABS.map((t) => t.id)
+ it('keeps a saved order, drops unknown ids and appends new verbs', () => {
+ expect(normalizeOrder(['resize', 'bogus', 'convert'], all).slice(0, 2)).toEqual([
+ 'resize',
+ 'convert'
+ ])
+ expect(normalizeOrder(['resize'], all)).toHaveLength(all.length)
+ expect(normalizeOrder('garbage', all)).toEqual(all)
+ })
+ it('moves an item', () => {
+ expect(moveItem(['a', 'b', 'c'], 0, 2)).toEqual(['b', 'c', 'a'])
+ })
+ it('lists visible verbs without Tools, which has its own slot', () => {
+ expect(sidebarVerbs(all, ['upscale'])).toEqual([
+ 'convert',
+ 'compress',
+ 'resize',
+ 'removebg',
+ 'generate'
+ ])
+ })
+})
+
+const row = (id: string, status: QueueItem['status'], percent = 0): QueueItem => ({
+ id,
+ file: { path: `C:/${id}.png`, name: `${id}.png`, ext: '.png', kind: 'image', size: 1 },
+ thumb: null,
+ status,
+ percent,
+ hasProgress: percent > 0
+})
+
+describe('status bar', () => {
+ const items = [
+ row('a', 'done'),
+ row('b', 'done'),
+ row('c', 'running', 62),
+ row('d', 'queued'),
+ row('e', 'queued'),
+ row('f', 'failed')
+ ]
+ it('reports the batch in flight, done count and failures', () => {
+ const s = statusSummary(items, ['a', 'b', 'c', 'd', 'e', 'f'], 'Convert')
+ expect(s.running).toEqual({ label: 'Converting 3 of 6', pct: 60 })
+ expect(s.done).toBe('2 of 6 done')
+ expect(s.failed).toBe(1)
+ })
+ it('drops the running item once the batch settles or without a batch', () => {
+ const settled = items.map((i) =>
+ i.status === 'queued' || i.status === 'running' ? { ...i, status: 'done' as const } : i
+ )
+ expect(statusSummary(settled, ['a', 'b', 'c', 'd', 'e', 'f'], 'Convert').running).toBeNull()
+ expect(statusSummary(items, null, 'Convert').running).toBeNull()
+ })
+ it('renders nothing for an empty queue', () => {
+ expect(statusSummary([], null, 'Convert')).toEqual({
+ running: null,
+ message: null,
+ done: null,
+ failed: 0
+ })
+ })
+ it('names each verb', () => {
+ expect(verbGerund('Remove BG')).toBe('Removing backgrounds')
+ expect(verbGerund('Merge')).toBe('Processing')
+ })
+})
+
+describe('global shortcuts', () => {
+ const k = (
+ key: string,
+ o: Partial<{ ctrlKey: boolean; metaKey: boolean; shiftKey: boolean; altKey: boolean }> = {}
+ ) => ({
+ key,
+ ctrlKey: false,
+ metaKey: false,
+ shiftKey: false,
+ altKey: false,
+ ...o
+ })
+ it('maps Ctrl+B, Ctrl+O and Ctrl+Enter', () => {
+ expect(shortcutFor(k('b', { ctrlKey: true }))).toBe('toggleSidebar')
+ expect(shortcutFor(k('O', { ctrlKey: true }))).toBe('addFiles')
+ expect(shortcutFor(k('Enter', { ctrlKey: true }))).toBe('run')
+ })
+ it('ignores plain keys and Alt combinations', () => {
+ expect(shortcutFor(k('b'))).toBeNull()
+ expect(shortcutFor(k('b', { ctrlKey: true, altKey: true }))).toBeNull()
+ })
+})
+```
+
+Append to `test/select-all.test.ts`:
+
+```ts
+import { oneGroupIds } from '../src/renderer/src/components/queue/selectAll'
+
+describe('oneGroupIds', () => {
+ it('keeps only the ids of the first display group among them', () => {
+ expect(oneGroupIds(items, ['v1', 'i2', 'v2'])).toEqual(['i2'])
+ expect(oneGroupIds(items, ['v1', 'v2'])).toEqual(['v1', 'v2'])
+ expect(oneGroupIds(items, [])).toEqual([])
+ })
+})
+```
+
+(Merge the import into the file's existing import from `selectAll`.)
+
+- [ ] **Step 2: Run them and watch them fail**
+
+Run: `npx vitest run test/shell-model.test.ts test/select-all.test.ts`
+Expected: FAIL, modules and `oneGroupIds` missing.
+
+- [ ] **Step 3: Implement the pure modules**
+
+```ts
+// src/renderer/src/components/shell/crumbs.ts
+import { tabById, type TabId, type ToolCard } from '@shared/tabs'
+import { groupLabel } from '../queueGroups'
+
+export type CrumbAction = 'tools' | 'clearSelection'
+export interface Crumb {
+ label: string
+ action?: CrumbAction
+ ariaLabel?: string
+}
+
+/** Title-bar breadcrumb per context (spec 3.1). */
+export function crumbsFor(tab: TabId, card: ToolCard | null, group: string | null): Crumb[] {
+ if (tab === 'tools')
+ return card
+ ? [
+ { label: 'tools', action: 'tools', ariaLabel: 'Back to Tools' },
+ { label: card.label.toLowerCase() }
+ ]
+ : [{ label: 'tools' }]
+ const label = tabById(tab).label.toLowerCase()
+ if (tab === 'generate' || tab === 'completed' || tab === 'settings' || !group) return [{ label }]
+ return [{ label, action: 'clearSelection' }, { label: groupLabel(group).toLowerCase() }]
+}
+```
+
+```ts
+// src/renderer/src/components/shell/sidebarState.ts
+export type SidebarPref = 'expanded' | 'collapsed'
+
+/** Below this width the sidebar auto-collapses (spec 3.7), without persisting. */
+export const NARROW_PX = 1280
+
+export function isCollapsed(pref: SidebarPref, narrow: boolean, override: boolean | null): boolean {
+ if (override != null) return override
+ return narrow || pref === 'collapsed'
+}
+
+/** A toggle on a wide window flips the stored preference; on a narrow window it
+ * only overrides for as long as the window stays narrow. */
+export function afterToggle(
+ pref: SidebarPref,
+ narrow: boolean,
+ override: boolean | null
+): { pref: SidebarPref; override: boolean | null } {
+ const now = isCollapsed(pref, narrow, override)
+ if (narrow) return { pref, override: !now }
+ return { pref: now ? 'expanded' : 'collapsed', override: null }
+}
+```
+
+```ts
+// src/renderer/src/components/shell/useSidebar.ts
+import { useCallback, useEffect, useState } from 'react'
+import { afterToggle, isCollapsed, NARROW_PX, type SidebarPref } from './sidebarState'
+
+const KEY = 'filesmith.sidebar'
+
+function readPref(): SidebarPref {
+ try {
+ return localStorage.getItem(KEY) === 'collapsed' ? 'collapsed' : 'expanded'
+ } catch {
+ return 'expanded'
+ }
+}
+
+export function useSidebar(): { collapsed: boolean; toggle: () => void } {
+ const [pref, setPref] = useState(readPref)
+ const [narrow, setNarrow] = useState(() => window.innerWidth < NARROW_PX)
+ // An override only applies to the width class it was made in, so crossing
+ // the breakpoint restores the stored preference without an effect.
+ const [override, setOverride] = useState<{ narrow: boolean; value: boolean } | null>(null)
+
+ useEffect(() => {
+ const on = (): void => setNarrow(window.innerWidth < NARROW_PX)
+ window.addEventListener('resize', on)
+ return () => window.removeEventListener('resize', on)
+ }, [])
+
+ const ov = override && override.narrow === narrow ? override.value : null
+ const toggle = useCallback(() => {
+ const next = afterToggle(pref, narrow, ov)
+ if (next.pref !== pref) {
+ setPref(next.pref)
+ try {
+ localStorage.setItem(KEY, next.pref)
+ } catch {
+ /* storage blocked: the toggle still works for this session */
+ }
+ }
+ setOverride(next.override == null ? null : { narrow, value: next.override })
+ }, [pref, narrow, ov])
+
+ return { collapsed: isCollapsed(pref, narrow, ov), toggle }
+}
+```
+
+```ts
+// src/renderer/src/components/shell/railPrefs.ts
+import type { TabId } from '@shared/tabs'
+
+// Same localStorage keys as the old rail, so a user's order and hidden verbs
+// carry over (spec 3.2).
+export const ORDER_KEY = 'filesmith.rail.tabOrder'
+export const HIDDEN_KEY = 'filesmith.rail.tabHidden'
+
+export function normalizeOrder(saved: unknown, all: TabId[]): TabId[] {
+ if (!Array.isArray(saved)) return all
+ const kept = saved.filter((id): id is TabId => all.includes(id as TabId))
+ return [...kept, ...all.filter((id) => !kept.includes(id))]
+}
+
+export function moveItem(list: T[], from: number, to: number): T[] {
+ const next = [...list]
+ const [moved] = next.splice(from, 1)
+ next.splice(to, 0, moved)
+ return next
+}
+
+/** The verbs shown in the sidebar's top group: ordered, visible, without Tools
+ * (Tools has its own slot under the separator). */
+export function sidebarVerbs(order: TabId[], hidden: TabId[]): TabId[] {
+ return order.filter((id) => id !== 'tools' && !hidden.includes(id))
+}
+```
+
+```ts
+// src/renderer/src/components/shell/useRailPrefs.ts
+import { useCallback, useState } from 'react'
+import { TABS, type TabId } from '@shared/tabs'
+import { HIDDEN_KEY, normalizeOrder, ORDER_KEY } from './railPrefs'
+
+const ALL = TABS.map((t) => t.id)
+
+function read(key: string): unknown {
+ try {
+ return JSON.parse(localStorage.getItem(key) ?? 'null')
+ } catch {
+ return null
+ }
+}
+function write(key: string, v: unknown): void {
+ try {
+ localStorage.setItem(key, JSON.stringify(v))
+ } catch {
+ /* storage blocked: keep the in-memory value */
+ }
+}
+
+export function useRailPrefs(): {
+ order: TabId[]
+ hidden: TabId[]
+ setOrder: (o: TabId[]) => void
+ toggleHidden: (id: TabId) => void
+} {
+ const [order, setOrderState] = useState(() => normalizeOrder(read(ORDER_KEY), ALL))
+ const [hidden, setHidden] = useState(() => {
+ const h = read(HIDDEN_KEY)
+ return Array.isArray(h) ? h.filter((id): id is TabId => ALL.includes(id)) : []
+ })
+ const setOrder = useCallback((o: TabId[]) => {
+ setOrderState(o)
+ write(ORDER_KEY, o)
+ }, [])
+ const toggleHidden = useCallback((id: TabId) => {
+ setHidden((prev) => {
+ const next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
+ write(HIDDEN_KEY, next)
+ return next
+ })
+ }, [])
+ return { order, hidden, setOrder, toggleHidden }
+}
+```
+
+```ts
+// src/renderer/src/components/shell/statusModel.ts
+import { inInput, type QueueItem } from '../../state'
+
+export interface StatusSummary {
+ running: { label: string; pct: number } | null
+ /** Generate's startup line ("Starting ComfyUI"), shown with an indeterminate bar. */
+ message: string | null
+ done: string | null
+ failed: number
+}
+
+const GERUND: Record = {
+ Convert: 'Converting',
+ Compress: 'Compressing',
+ Resize: 'Resizing',
+ Upscale: 'Upscaling',
+ 'Remove BG': 'Removing backgrounds',
+ Generate: 'Generating'
+}
+
+export function verbGerund(label: string): string {
+ return GERUND[label] ?? 'Processing'
+}
+
+/** Status bar content (spec 3.5 and 6.4). `batch` is the ids of the last run in
+ * this workspace; it is renderer-only and not persisted. */
+export function statusSummary(
+ items: QueueItem[],
+ batch: string[] | null,
+ verb: string,
+ message?: string | null
+): StatusSummary {
+ const inputs = items.filter(inInput)
+ let running: StatusSummary['running'] = null
+ if (batch?.length) {
+ const ids = new Set(batch)
+ const rows = inputs.filter((i) => ids.has(i.id))
+ const live = rows.filter((i) => i.status === 'queued' || i.status === 'running')
+ if (live.length) {
+ const done = rows.filter((i) => i.status === 'done').length
+ const run = rows.filter((i) => i.status === 'running')
+ const settled = rows.filter((i) => ['done', 'failed', 'canceled'].includes(i.status)).length
+ const partial = run.reduce((s, i) => s + (i.hasProgress ? i.percent / 100 : 0), 0)
+ running = {
+ label: `${verbGerund(verb)} ${done + run.length} of ${rows.length}`,
+ pct: Math.round(((settled + partial) / rows.length) * 100)
+ }
+ }
+ }
+ const doneN = inputs.filter((i) => i.status === 'done').length
+ return {
+ running,
+ message: message || null,
+ done: doneN ? `${doneN} of ${inputs.length} done` : null,
+ failed: inputs.filter((i) => i.status === 'failed').length
+ }
+}
+```
+
+```ts
+// src/renderer/src/components/shell/shortcuts.ts
+import type { KeyLike } from '../queue/tableKeys'
+
+export type Shortcut = 'toggleSidebar' | 'addFiles' | 'run'
+
+/** Global shortcuts (spec 4.1): Ctrl+B sidebar, Ctrl+O add files, Ctrl+Enter run. */
+export function shortcutFor(e: KeyLike): Shortcut | null {
+ if (!(e.ctrlKey || e.metaKey) || e.altKey) return null
+ const k = e.key.toLowerCase()
+ if (k === 'b' && !e.shiftKey) return 'toggleSidebar'
+ if (k === 'o' && !e.shiftKey) return 'addFiles'
+ if (k === 'enter') return 'run'
+ return null
+}
+```
+
+Append to `src/renderer/src/components/queue/selectAll.ts`:
+
+```ts
+/** Restrict a set of ids to one convert group (the first in display order among
+ * them), so bulk selections such as "select the failed rows" never span groups. */
+export function oneGroupIds(items: QueueItem[], ids: string[]): string[] {
+ const set = new Set(ids)
+ const rows = items.filter((i) => inInput(i) && set.has(i.id))
+ if (!rows.length) return []
+ const group = [...new Set(rows.map((i) => groupOf(i.file)))].sort(
+ (a, b) => groupRank(a) - groupRank(b)
+ )[0]
+ return ids.filter((id) => rows.some((r) => r.id === id && groupOf(r.file) === group))
+}
+```
+
+- [ ] **Step 4: Run the tests**
+
+Run: `npx vitest run test/shell-model.test.ts test/select-all.test.ts`
+Expected: PASS.
+
+- [ ] **Step 5: Write the shell components**
+
+```tsx
+// src/renderer/src/components/shell/Breadcrumb.tsx
+import { Fragment, type JSX } from 'react'
+import type { Crumb, CrumbAction } from './crumbs'
+
+export function Breadcrumb({
+ crumbs,
+ onCrumb
+}: {
+ crumbs: Crumb[]
+ onCrumb: (a: CrumbAction) => void
+}): JSX.Element {
+ return (
+
+ {crumbs.map((c, i) => (
+
+ {i > 0 && / }
+ {i === crumbs.length - 1 ? (
+ {c.label}
+ ) : c.action ? (
+ onCrumb(c.action as CrumbAction)}
+ >
+ {c.label}
+
+ ) : (
+ {c.label}
+ )}
+
+ ))}
+
+ )
+}
+```
+
+```tsx
+// src/renderer/src/components/shell/TitleBar.tsx
+import type { JSX } from 'react'
+import { Icon } from '../icons/Icon'
+import { Breadcrumb } from './Breadcrumb'
+import type { Crumb, CrumbAction } from './crumbs'
+
+export function TitleBar({
+ crumbs,
+ onCrumb
+}: {
+ crumbs: Crumb[]
+ onCrumb: (a: CrumbAction) => void
+}): JSX.Element {
+ return (
+
+
+
+ Filesmith
+
+
+
+ {/* Window controls: 10x10 glyphs at stroke 1; close is not red (spec 2.5). */}
+
+
window.filesmith.minimize()}
+ >
+
+
+
+
+
window.filesmith.toggleMaximize()}
+ >
+
+
+
+
+
window.filesmith.close()}
+ >
+
+
+
+
+
+
+ )
+}
+```
+
+```tsx
+// src/renderer/src/components/shell/Sidebar.tsx
+import type { JSX } from 'react'
+import { COMPLETED_TAB, SETTINGS_TAB, tabById, type Tab, type TabId } from '@shared/tabs'
+import { Icon } from '../icons/Icon'
+
+export function Sidebar({
+ tab,
+ verbs,
+ showTools,
+ counts,
+ completedCount,
+ collapsed,
+ onToggle,
+ onSelect
+}: {
+ tab: TabId
+ verbs: TabId[]
+ showTools: boolean
+ counts: Record
+ completedCount: number
+ collapsed: boolean
+ onToggle: () => void
+ onSelect: (t: TabId) => void
+}): JSX.Element {
+ const item = (t: Tab, n?: number): JSX.Element => (
+ onSelect(t.id)}
+ >
+
+ {t.label}
+ {n ? {n} : null}
+
+ )
+ return (
+
+
+ operations
+
+
+
+
+
+ {verbs.map((id) => item(tabById(id), counts[id]))}
+ {showTools && (
+ <>
+
+ {item(tabById('tools'), counts.tools)}
+ >
+ )}
+
+
+
+ {item(COMPLETED_TAB, completedCount)}
+ {item(SETTINGS_TAB)}
+
+
+ )
+}
+```
+
+```tsx
+// src/renderer/src/components/shell/StatusBar.tsx
+import type { JSX } from 'react'
+import { Icon } from '../icons/Icon'
+import { ProgressBar } from '../ui/ProgressBar'
+import type { StatusSummary } from './statusModel'
+
+export function StatusBar({
+ summary,
+ onFailedClick
+}: {
+ summary: StatusSummary
+ onFailedClick: () => void
+}): JSX.Element {
+ return (
+
+ )
+}
+```
+
+- [ ] **Step 6: Wire the shell into `App.tsx`**
+
+Imports: remove `TopBar` and `TabRail`; add
+
+```ts
+import { TitleBar } from './components/shell/TitleBar'
+import { Sidebar } from './components/shell/Sidebar'
+import { StatusBar } from './components/shell/StatusBar'
+import { crumbsFor } from './components/shell/crumbs'
+import { useSidebar } from './components/shell/useSidebar'
+import { useRailPrefs } from './components/shell/useRailPrefs'
+import { sidebarVerbs } from './components/shell/railPrefs'
+import { statusSummary } from './components/shell/statusModel'
+import { shortcutFor } from './components/shell/shortcuts'
+import { oneGroupIds } from './components/queue/selectAll'
+```
+
+New state next to the other `useState`s:
+
+```ts
+const sidebar = useSidebar()
+const rail = useRailPrefs()
+// Ids of the last run per workspace, for "Converting 3 of 6" (spec 6.4).
+const [batches, setBatches] = useState>({})
+```
+
+In `run()`: after the merge branch's `dispatch({ type: 'markQueued', ids: [anchorId], ... })` add `setBatches((b) => ({ ...b, [qKey]: [anchorId] }))`; after the per-file `dispatch({ type: 'markQueued', ids: targets.map((t) => t.id), ... })` add `setBatches((b) => ({ ...b, [qKey]: targets.map((t) => t.id) }))`.
+
+Global shortcuts effect (after the job-event effect). `run` and `browse` are re-created each render, so read them through the existing `latest` ref pattern: add a ref `const actions = useRef({ run, browse, runCount, toggle: sidebar.toggle })` and assign `actions.current = { run, browse, runCount, toggle: sidebar.toggle }` in the render body after `runCount` is computed, then:
+
+```ts
+useEffect(() => {
+ const onKey = (e: KeyboardEvent): void => {
+ const s = shortcutFor(e)
+ if (!s) return
+ e.preventDefault()
+ const a = actions.current
+ if (s === 'toggleSidebar') a.toggle()
+ else if (s === 'addFiles') void a.browse()
+ else if (s === 'run' && a.runCount > 0) void a.run()
+ }
+ window.addEventListener('keydown', onKey)
+ return () => window.removeEventListener('keydown', onKey)
+}, [])
+```
+
+Derived values before `return`:
+
+```ts
+const crumbs = crumbsFor(state.tab, card ?? null, activeGroup)
+const verbLabel = card ? card.label : tab.label
+const summary = statusSummary(
+ onToolsGrid || onCompleted || state.tab === 'settings' ? [] : cur.items,
+ batches[qKey] ?? null,
+ verbLabel,
+ genRun.running && genRun.message && !genRun.message.startsWith('Generating')
+ ? genRun.message
+ : null
+)
+const showInspector = !onToolsGrid && !onCompleted && state.tab !== 'settings'
+```
+
+Replace the outer layout. The root becomes:
+
+```tsx
+
+
+ a === 'tools'
+ ? dispatch({ type: 'setActiveTool', tool: null })
+ : dispatch({ type: 'clearSelection' })
+ }
+ />
+
+ dispatch({ type: 'setTab', tab: t })}
+ />
+ {/* existing stays for now; change its tag to
+ and drop its Tailwind classes */}
+ {/* existing OptionsPanel stays for now, rendered when showInspector */}
+
+
+ dispatch({
+ type: 'selectIds',
+ ids: oneGroupIds(
+ cur.items,
+ cur.items.filter((i) => i.status === 'failed').map((i) => i.id)
+ )
+ })
+ }
+ />
+
+
+
+```
+
+Move the `onDragOver` / `onDragLeave` / `onDrop` handlers from the old `` onto the `.app` root (spec 4.9: the whole window accepts drops). Keep the existing window-level `dragover`/`drop` `preventDefault` effect.
+
+- [ ] **Step 7: Delete the replaced components**
+
+```bash
+git rm src/renderer/src/components/TopBar.tsx src/renderer/src/components/TabRail.tsx
+grep -rn "fmark" src || git rm src/renderer/src/assets/fmark.png
+```
+
+(The second line removes the old logo only when nothing else references it; `build/icon.ico` and the installer icon are untouched, spec section 8.)
+
+- [ ] **Step 8: Verify**
+
+Run: `npm test && npm run typecheck && npm run lint`
+Expected: all green.
+
+Run: `npm run dev` and check by hand: title bar shows the anvil, `Filesmith`, the breadcrumb and three square window buttons (close turns light grey on hover, never red; minimize, maximize and close work); the sidebar lists six verbs, a separator, Tools, then Completed and Settings at the bottom; the toggle and Ctrl+B collapse it to 48px with labels faded, the state survives a restart; narrowing the window under 1280px collapses it without changing the stored state; dropping a file anywhere in the window adds it.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add -A src/renderer/src/components/shell src/renderer/src/components/queue/selectAll.ts src/renderer/src/App.tsx test/shell-model.test.ts test/select-all.test.ts
+git commit -m "feat(shell): title bar with breadcrumb, collapsible sidebar, status bar, global shortcuts
+
+Co-Authored-By: Claude Opus 5.5 "
+```
+
+---
+
+### Task 12: Files table, toolbar, row actions, keyboard and drops
+
+**Files:**
+
+- Create in `src/renderer/src/components/queue/`: `QueueToolbar.tsx`, `QueueTable.tsx`, `QueueRow.tsx`, `ResultCell.tsx`, `StatusCell.tsx`, `TotalsRow.tsx`, `EmptyState.tsx`
+- Create: `src/renderer/src/theme/views.css`; Modify: `src/renderer/src/index.css` (add `@import './theme/views.css';` last)
+- Modify: `src/renderer/src/App.tsx` (centre content for queue workspaces, `onItemClick` `:339-347`, new `retry`, `onRowAction`, toolbar handlers, per-row estimates, run scope)
+- Delete: `src/renderer/src/components/Queue.tsx`, `src/renderer/src/components/DropZone.tsx`
+
+**Interfaces:**
+
+- Consumes: `rowView`, `queueTotals`, `doneSamples`, `RowActionKind`, `Totals` (Task 5); `groupedRows`, `visibleOrder`, `nextSort`, `SortKey`, `SortState`, `RowGroup` (Task 6); `headerCheck`, `toggleAllIds`, `activeGroupFor` (Task 6); `tableKey`, `TableKey` (Task 6); `estimateOutputBytes` (Task 7); `Checkbox`, `RowAction`, `AddFilesButton`, `IconButton`, `SmallButton` (Task 10); reducer actions `select` (with `order`), `selectIds`, `hideFinished` (Task 4).
+- Produces:
+ - `QueueTable({ groups, totals, selected, activeGroup, sort, check, estimates, onSort, onToggleAll, onSelectAll, onRowClick, onToggleRow, onExtend, onOpen, onMenu, onAction, onRemove, onSelectGroup, onAdd })`; the table root is `role="grid"` with `aria-label="Files"`.
+ - `QueueToolbar({ files, selected, dropping, canRemove, canRetry, canClear, canStop, onAdd, onRemove, onRetry, onClear, onStop })`
+ - `EmptyState({ icon, title, line, action? })` (reused by Completed in Task 15)
+ - App functions `retry(ids: string[])`, `onRowAction(id, kind)`.
+ - Run scope (spec 4.1): with nothing selected, Run and the options act on every input row of the first group in display order; the inspector says `all N files`.
+
+- [ ] **Step 1: Write the table components**
+
+```tsx
+// src/renderer/src/components/queue/ResultCell.tsx
+import type { JSX } from 'react'
+import { Icon } from '../icons/Icon'
+import type { ResultView } from './rowModel'
+
+/** The arrow sits centred on the size|result column boundary, on a patch of the
+ * row background, and only when the cell has content (spec 4.3). */
+export function ResultCell({ result }: { result: ResultView | null }): JSX.Element {
+ return (
+
+ {result && (
+
+
+ {result.estimate ? {result.text} : {result.text} }
+ {result.pct && (
+
+ {result.pct}
+
+ )}
+
+ )}
+
+ )
+}
+```
+
+```tsx
+// src/renderer/src/components/queue/StatusCell.tsx
+import type { JSX } from 'react'
+import { Icon } from '../icons/Icon'
+import { RowAction } from '../ui/Button'
+import type { RowActionKind, RowActionView, StatusView } from './rowModel'
+
+export function StatusCell({
+ status,
+ action,
+ onAction
+}: {
+ status: StatusView
+ action: RowActionView
+ onAction: (k: RowActionKind) => void
+}): JSX.Element {
+ return (
+
+ {status.kind === 'failed' ? (
+
+
+ {status.text}
+
+ ) : status.kind === 'running' ? (
+
+
+
+ {status.pct}
+ {status.eta}
+
+
+ ) : status.kind === 'done' ? (
+
+
+ Done
+
+ ) : status.kind === 'queued' ? (
+
+
+ Queued
+
+ ) : status.kind === 'canceled' ? (
+
+
+ Canceled
+
+ ) : null}
+ {
+ e.stopPropagation()
+ onAction(action.kind)
+ }}
+ onDoubleClick={(e) => e.stopPropagation()}
+ />
+
+ )
+}
+```
+
+```tsx
+// src/renderer/src/components/queue/QueueRow.tsx
+import type { JSX, MouseEvent } from 'react'
+import type { QueueItem } from '../../state'
+import { Checkbox } from '../ui/Checkbox'
+import { ResultCell } from './ResultCell'
+import { StatusCell } from './StatusCell'
+import type { RowActionKind, RowView } from './rowModel'
+
+export function QueueRow({
+ item,
+ view,
+ selected,
+ dim,
+ focusable,
+ onClick,
+ onToggle,
+ onOpen,
+ onMenu,
+ onAction,
+ onKeyDown
+}: {
+ item: QueueItem
+ view: RowView
+ selected: boolean
+ /** A row of a different convert group than the selection (spec 2.6). */
+ dim: boolean
+ focusable: boolean
+ onClick: (e: MouseEvent) => void
+ onToggle: () => void
+ onOpen: () => void
+ onMenu: (x: number, y: number) => void
+ onAction: (k: RowActionKind) => void
+ onKeyDown: (e: React.KeyboardEvent) => void
+}): JSX.Element {
+ return (
+ {
+ e.preventDefault()
+ onMenu(e.clientX, e.clientY)
+ }}
+ onKeyDown={onKeyDown}
+ >
+
+
+
+
+ {item.thumb ? (
+
+ ) : (
+
+ {view.kind.slice(0, 3)}
+
+ )}
+
+ {item.file.name}
+
+ {/* Below 1180px the kind column folds into the name cell (spec 3.7). */}
+
+ {view.kind}
+
+
+
+ {view.kind}
+
+
+ {view.size}
+
+
+
+
+ )
+}
+```
+
+```tsx
+// src/renderer/src/components/queue/TotalsRow.tsx
+import type { JSX } from 'react'
+import { formatBytes } from '@shared/compress'
+import { Icon } from '../icons/Icon'
+import type { Totals } from './rowModel'
+
+export function TotalsRow({ totals: t }: { totals: Totals }): JSX.Element {
+ return (
+
+
+
+ {t.files} file{t.files === 1 ? '' : 's'}
+
+
+
+ {formatBytes(t.bytes)}
+
+
+ {t.doneSrc > 0 && (
+ <>
+ {formatBytes(t.doneSrc)}
+
+ {formatBytes(t.doneOut)}
+ so far
+ >
+ )}
+
+
+ {t.files > 0 && (
+
+ {t.done} of {t.files} done
+
+ )}
+
+
+ )
+}
+```
+
+```tsx
+// src/renderer/src/components/queue/EmptyState.tsx
+import type { JSX } from 'react'
+import type { IconName } from '@shared/icons'
+import { Icon } from '../icons/Icon'
+import { SmallButton } from '../ui/Button'
+
+/** Centred empty block (spec 4.12): no drop zone, an outlined button instead. */
+export function EmptyState({
+ icon,
+ title,
+ line,
+ action
+}: {
+ icon: IconName
+ title: string
+ line: string
+ action?: { label: string; onClick: () => void }
+}): JSX.Element {
+ return (
+
+
+
{title}
+
{line}
+ {action && (
+
+ {action.label}
+
+ )}
+
+ )
+}
+```
+
+```tsx
+// src/renderer/src/components/queue/QueueToolbar.tsx
+import type { JSX } from 'react'
+import { AddFilesButton, IconButton } from '../ui/Button'
+
+export function QueueToolbar({
+ files,
+ selected,
+ dropping,
+ canRemove,
+ canRetry,
+ canClear,
+ canStop,
+ onAdd,
+ onRemove,
+ onRetry,
+ onClear,
+ onStop
+}: {
+ files: number
+ selected: number
+ dropping: boolean
+ canRemove: boolean
+ canRetry: boolean
+ canClear: boolean
+ canStop: boolean
+ onAdd: () => void
+ onRemove: () => void
+ onRetry: () => void
+ onClear: () => void
+ onStop: () => void
+}): JSX.Element {
+ const count = dropping
+ ? 'Drop to add'
+ : `${files} file${files === 1 ? '' : 's'}${selected ? `, ${selected} selected` : ''}`
+ return (
+
+
+
+
+
+
+
+ {count}
+
+
+
+
+ )
+}
+```
+
+```tsx
+// src/renderer/src/components/queue/QueueTable.tsx
+import { Fragment, useRef, useState, type JSX, type KeyboardEvent, type MouseEvent } from 'react'
+import { Checkbox } from '../ui/Checkbox'
+import { Icon } from '../icons/Icon'
+import { groupOf } from '../../state'
+import { EmptyState } from './EmptyState'
+import { QueueRow } from './QueueRow'
+import { TotalsRow } from './TotalsRow'
+import { rowView, type RowActionKind, type Totals } from './rowModel'
+import type { CheckState } from './selectAll'
+import { tableKey } from './tableKeys'
+import { visibleOrder, type RowGroup, type SortKey, type SortState } from './tableSort'
+
+const HEADS: { key: SortKey; label: string; num?: boolean }[] = [
+ { key: 'name', label: 'name' },
+ { key: 'kind', label: 'kind' },
+ { key: 'size', label: 'size', num: true },
+ { key: 'result', label: 'result' },
+ { key: 'status', label: 'status' }
+]
+
+export function QueueTable({
+ groups,
+ totals,
+ selected,
+ activeGroup,
+ sort,
+ check,
+ estimates,
+ onSort,
+ onToggleAll,
+ onSelectAll,
+ onRowClick,
+ onToggleRow,
+ onExtend,
+ onOpen,
+ onMenu,
+ onAction,
+ onRemove,
+ onSelectGroup,
+ onAdd
+}: {
+ groups: RowGroup[]
+ totals: Totals
+ selected: string[]
+ activeGroup: string | null
+ sort: SortState | null
+ check: CheckState
+ estimates: Record
+ onSort: (k: SortKey) => void
+ onToggleAll: () => void
+ onSelectAll: () => void
+ onRowClick: (id: string, e: MouseEvent) => void
+ onToggleRow: (id: string) => void
+ /** Shift+Arrow: extend the range to this id. */
+ onExtend: (id: string) => void
+ onOpen: (id: string) => void
+ onMenu: (id: string, x: number, y: number) => void
+ onAction: (id: string, k: RowActionKind) => void
+ onRemove: (id: string) => void
+ onSelectGroup: (group: string) => void
+ onAdd: () => void
+}): JSX.Element {
+ const body = useRef(null)
+ const order = visibleOrder(groups)
+ const [focusId, setFocusId] = useState(null)
+ const tabStop = focusId && order.includes(focusId) ? focusId : (selected[0] ?? order[0] ?? null)
+
+ function focusRow(id: string | undefined): void {
+ if (!id) return
+ setFocusId(id)
+ body.current?.querySelector(`[data-id="${CSS.escape(id)}"]`)?.focus()
+ }
+
+ function onRowKey(id: string, e: KeyboardEvent): void {
+ if (e.target !== e.currentTarget) return
+ const k = tableKey(e)
+ if (!k) return
+ e.preventDefault()
+ const i = order.indexOf(id)
+ if (k === 'up' || k === 'down') focusRow(order[k === 'up' ? i - 1 : i + 1])
+ else if (k === 'extendUp' || k === 'extendDown') {
+ const next = order[k === 'extendUp' ? i - 1 : i + 1]
+ if (next) {
+ focusRow(next)
+ onExtend(next)
+ }
+ } else if (k === 'toggle') onToggleRow(id)
+ else if (k === 'selectAll') onSelectAll()
+ else if (k === 'remove') onRemove(id)
+ else if (k === 'open') onOpen(id)
+ else {
+ const r = e.currentTarget.getBoundingClientRect()
+ onMenu(id, r.left + 40, r.bottom)
+ }
+ }
+
+ return (
+
+
+
+
+
+ {HEADS.map((h) => {
+ const on = sort?.key === h.key
+ return (
+
onSort(h.key)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault()
+ onSort(h.key)
+ }
+ }}
+ >
+ {h.label}
+
+
+ )
+ })}
+
+
+ {groups.length === 0 ? (
+
+ ) : (
+ groups.map((g) => (
+
+ {groups.length > 1 && (
+
+ onSelectGroup(g.group)}
+ title={`Select all ${g.label.toLowerCase()}`}
+ >
+ {g.label.toUpperCase()}
+ {g.items.length}
+
+
+ )}
+ {g.items.map((item) => (
+ {
+ setFocusId(item.id)
+ onRowClick(item.id, e)
+ }}
+ onToggle={() => onToggleRow(item.id)}
+ onOpen={() => onOpen(item.id)}
+ onMenu={(x, y) => onMenu(item.id, x, y)}
+ onAction={(k) => onAction(item.id, k)}
+ onKeyDown={(e) => onRowKey(item.id, e)}
+ />
+ ))}
+
+ ))
+ )}
+
+
+
+ )
+}
+```
+
+- [ ] **Step 2: Write `theme/views.css` (queue part)**
+
+```css
+/* Views and states the mockup does not draw (spec 2.6, 3.3, 4.9, 4.12). */
+
+/* cross-group rows: fg3 text, thumbnail at 40% (monochrome replacement for opacity-40) */
+.tr.dim .name,
+.tr.dim .meta,
+.tr.dim .size {
+ color: var(--fg3);
+}
+.tr.dim .thumb {
+ opacity: 0.4;
+}
+
+/* mixed-queue group row: SettingGroup style, selects the whole group */
+.grp-row {
+ height: 28px;
+ border-bottom: 1px solid var(--line);
+}
+.grp {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 0 10px 0 16px;
+ font: 600 11px/1 var(--mono);
+ letter-spacing: 0.09em;
+ color: var(--fg3);
+}
+.grp::after {
+ content: '';
+ flex: 1;
+ height: 1px;
+ background: var(--line);
+}
+.grp .n {
+ font-weight: 500;
+ letter-spacing: 0;
+}
+.grp:hover {
+ color: var(--fg1);
+ background: var(--hover);
+}
+
+/* narrow window (spec 3.7, O5): below 1180px the kind column folds into the
+ name cell as a trailing fg3 tag. Hidden cells take no grid slot, so the
+ head, rows and totals all drop their third cell and use five columns. */
+.ktag {
+ display: none;
+}
+@media (max-width: 1179px) {
+ .cols {
+ --cols: 40px 1fr 88px 184px 232px;
+ }
+ .cols > :nth-child(3) {
+ display: none;
+ }
+ .ktag {
+ display: inline;
+ margin-left: auto;
+ }
+}
+
+/* totals arrow is inline, not the split arrow */
+.totals .tarr,
+.totals .pct {
+ color: var(--fg3);
+}
+
+/* drop target: a 1px fg1 inset outline around the centre panel */
+.center.dropping::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ outline: 1px solid var(--fg1);
+ outline-offset: -1px;
+ pointer-events: none;
+}
+
+/* empty states */
+.empty {
+ height: 100%;
+ min-height: 220px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ color: var(--fg2);
+}
+.empty .et {
+ font-weight: 600;
+ font-size: 14px;
+ color: var(--fg1);
+}
+.empty .el {
+ font-size: 12px;
+ color: var(--fg3);
+ margin-bottom: 6px;
+}
+.empty .sbtn {
+ align-self: center;
+}
+```
+
+Add `@import './theme/views.css';` as the last import in `index.css`.
+
+- [ ] **Step 3: Wire the table into `App.tsx`**
+
+Imports: remove `DropZone` and `Queues`; add
+
+```ts
+import { QueueTable } from './components/queue/QueueTable'
+import { QueueToolbar } from './components/queue/QueueToolbar'
+import { doneSamples, queueTotals, type RowActionKind } from './components/queue/rowModel'
+import { groupedRows, nextSort, visibleOrder, type SortState } from './components/queue/tableSort'
+import { activeGroupFor, headerCheck, toggleAllIds } from './components/queue/selectAll'
+import { estimateOutputBytes } from '@shared/sizeEstimate'
+import { resizedSize } from '@shared/resize'
+import type { JobOptions, ToolId } from '@shared/types'
+```
+
+State: `const [sorts, setSorts] = useState>({})`.
+
+**Run scope** (spec 4.1). Replace the selection-derived block (`:265-276`) so that with nothing selected the panel and Run act on the first display group:
+
+```ts
+const selectedItems = cur.items.filter((i) => cur.selected.includes(i.id))
+// With nothing selected, options and Run speak for the first group in the
+// queue (spec 4.1), not an empty panel.
+const firstGroup = activeGroupFor(cur.items, [])
+const scopeItems = selectedItems.length
+ ? selectedItems
+ : cur.items.filter((i) => inInput(i) && firstGroup != null && groupOf(i.file) === firstGroup)
+const selEff = scopeItems.map(effectiveFile)
+const activeKind: FileKind | null = selEff.length ? selEff[0].kind : null
+// Dimming and the breadcrumb follow the SELECTION only.
+const activeGroup: string | null = selectedItems.length
+ ? groupOf(effectiveFile(selectedItems[0]))
+ : null
+const scopeGroup: string | null = selEff.length ? groupOf(selEff[0]) : null
+const fallbackKind: FileKind = tab.kinds[0] ?? card?.kinds[0] ?? 'image'
+const optGroup = scopeGroup ?? convertGroup(fallbackKind, '')
+```
+
+and in the `runList` filter (`:298`) change `selectedItems.filter(` to `scopeItems.filter(`. Leave the rest of `runList`'s predicate unchanged. Everywhere the OptionsPanel props used `activeGroup` to decide whether to show options, pass `scopeGroup` instead (until Task 13 replaces the panel).
+
+**Row clicks follow the visible order:**
+
+```ts
+const sort = sorts[qKey] ?? null
+const groups = groupedRows(cur.items, sort)
+const order = visibleOrder(groups)
+
+function onItemClick(id: string, e: MouseEvent): void {
+ const mode: SelectMode = e.shiftKey ? 'range' : e.ctrlKey || e.metaKey ? 'toggle' : 'single'
+ dispatch({ type: 'select', id, mode, order })
+}
+```
+
+**Retry and row actions:**
+
+```ts
+function toolFor(it: QueueItem, opts: JobOptions): ToolId {
+ return engineFor(
+ state.tab,
+ groupOf(it.file),
+ card,
+ { kind: it.file.kind, ext: it.file.ext },
+ typeof opts.format === 'string' ? opts.format : undefined
+ ).tool
+}
+
+/** Same path as run() for one in-place item, with the options it last ran
+ * with (spec 4.3). A merge row retries with the same input list, because
+ * run() now stores `mergeInputs` in its `runOptions` (see below). */
+function retry(ids: string[]): void {
+ const started: string[] = []
+ for (const id of ids) {
+ const it = cur.items.find((i) => i.id === id)
+ if (!it || (it.status !== 'failed' && it.status !== 'canceled')) continue
+ const opts = it.runOptions ?? curOptions
+ dispatch({ type: 'markQueued', ids: [id], options: opts })
+ void window.filesmith.runJob({
+ id,
+ tool: toolFor(it, opts),
+ input: it.file.path,
+ options: opts
+ })
+ started.push(id)
+ }
+ if (started.length) setBatches((b) => ({ ...b, [qKey]: started }))
+}
+
+function onRowAction(id: string, kind: RowActionKind): void {
+ const it = cur.items.find((i) => i.id === id)
+ if (!it) return
+ if (kind === 'reveal') {
+ if (it.outputPath) window.filesmith.reveal(it.outputPath)
+ } else if (kind === 'cancel') cancelJob(id)
+ else if (kind === 'remove') dismiss([id], 'input')
+ else retry([id])
+}
+```
+
+In `run()`'s merge branch (`App.tsx:697`) change `dispatch({ type: 'markQueued', ids: [anchorId], options: opts })` to `dispatch({ type: 'markQueued', ids: [anchorId], options: { ...opts, mergeInputs: paths } })`. Without it a retried merge row would reach the engine with no input list and fail again with "Select at least two PDFs to merge" (`registry.ts:322-323`).
+
+**Per-row estimates** for running rows:
+
+```ts
+function estimateFor(it: QueueItem): number | null {
+ const opts = it.runOptions ?? curOptions
+ const t = toolFor(it, opts)
+ const d = vDims[it.file.path]
+ let pixelRatio: number | null = null
+ let outPixels: number | null = null
+ if (t === 'resize') {
+ if (String(opts.mode ?? 'percent') === 'percent')
+ pixelRatio = (Number(opts.percent ?? 50) / 100) ** 2
+ else if (d) {
+ const o = resizedSize(
+ d.width,
+ d.height,
+ numOrNull(opts.width),
+ numOrNull(opts.height),
+ opts.fit === 'stretch' ? 'stretch' : 'contain'
+ )
+ if (o) pixelRatio = (o.w * o.h) / (d.width * d.height)
+ }
+ } else if (t === 'upscale' && d) {
+ const f = Number(opts.upscaleFactor ?? 4)
+ outPixels = d.width * f * d.height * f
+ }
+ return estimateOutputBytes(it.file, t, opts, {
+ samples: doneSamples(cur.items, groupOf(it.file), opts),
+ pixelRatio,
+ outPixels
+ })
+}
+const estimates: Record = {}
+for (const i of cur.items)
+ if (inInput(i) && i.status === 'running') estimates[i.id] = estimateFor(i)
+```
+
+(`numOrNull` already exists at `:803`; move it above `estimateFor` if it is declared later.)
+
+**Toolbar flags and handlers:**
+
+```ts
+const inputs = cur.items.filter(inInput)
+const groupForBulk = activeGroupFor(cur.items, cur.selected)
+const failedInGroup = inputs.filter(
+ (i) => i.status === 'failed' && groupOf(i.file) === groupForBulk
+)
+const inFlight = inputs.filter((i) => i.status === 'queued' || i.status === 'running')
+```
+
+Replace the old `DropZone` + `Queues` branch of the centre with:
+
+```tsx
+<>
+ 0}
+ canRetry={failedInGroup.length > 0}
+ canClear={inputs.some((i) => i.status === 'done' || i.status === 'canceled')}
+ canStop={inFlight.length > 0}
+ onAdd={() => void browse()}
+ onRemove={() => dismiss(cur.selected, 'input')}
+ onRetry={() => retry(failedInGroup.map((i) => i.id))}
+ onClear={() => dispatch({ type: 'hideFinished' })}
+ onStop={() => inFlight.forEach((i) => cancelJob(i.id))}
+ />
+ setSorts((s) => ({ ...s, [qKey]: nextSort(sort, k) }))}
+ onToggleAll={() => dispatch({ type: 'selectIds', ids: toggleAllIds(cur.items, cur.selected) })}
+ onSelectAll={() => {
+ const ids = toggleAllIds(cur.items, cur.selected)
+ if (ids.length) dispatch({ type: 'selectIds', ids })
+ }}
+ onRowClick={onItemClick}
+ onToggleRow={(id) => dispatch({ type: 'select', id, mode: 'toggle', order })}
+ onExtend={(id) => dispatch({ type: 'select', id, mode: 'range', order })}
+ onOpen={(id) => {
+ const it = cur.items.find((i) => i.id === id)
+ if (it) openExternally('input', it)
+ }}
+ onMenu={(id, x, y) => {
+ const it = cur.items.find((i) => i.id === id)
+ if (it) openMenu('input', it, x, y)
+ }}
+ onAction={onRowAction}
+ onRemove={(id) => dismiss(cur.selected.includes(id) ? cur.selected : [id], 'input')}
+ onSelectGroup={(g) =>
+ dispatch({
+ type: 'selectIds',
+ ids: inputs.filter((i) => groupOf(i.file) === g).map((i) => i.id)
+ })
+ }
+ onAdd={() => void browse()}
+ />
+>
+```
+
+Set the centre element's class to `` `center${dragging ? ' dropping' : ''}` ``. The `.center` grid has rows `32px 1fr`; the toolbar and table are its two children. `dragging` is still set by the root's `onDragOver` and cleared by `onDragLeave` / `onDrop`; skip setting it while on Generate, the Tools grid, Completed or Settings (unchanged guard).
+
+Note on Ctrl+A: when the whole group is already selected `toggleAllIds` returns `[]`, so `onSelectAll` leaves the selection as is instead of clearing it.
+
+- [ ] **Step 4: Delete the replaced components**
+
+```bash
+git rm src/renderer/src/components/Queue.tsx src/renderer/src/components/DropZone.tsx
+```
+
+- [ ] **Step 5: Verify**
+
+Run: `npm test && npm run typecheck && npm run lint`
+Expected: all green (`queue-groups.test.ts` still passes; `groupItemsByGroup` stays exported for its test until Task 16 decides its fate).
+
+Run: `npm run dev` and check by hand with a mix of 6 images: column widths match the mockup (40 / name / 64 / 88 / 184 / 232), narrowing the window under 1180px drops the kind column and shows the kind as a grey tag at the end of the name cell, header labels are lowercase, the head and totals stay fixed while rows scroll, the result arrow sits exactly on the size/result border and its patch matches hover and selected backgrounds; a running row shows `NN%(Ns)` with no bar under it; a failed row shows the inverted pill and an always-visible Retry that re-runs it; done rows show `Show in folder` on hover and it reveals the OUTPUT; queued rows' Remove cancels and removes; clicking `size` sorts ascending, again descending (chevron flips), a third time restores the order; header checkbox selects the active group only; Up/Down move focus, Space toggles, Shift+Arrow extends, Ctrl+A selects the group, Delete removes, Enter opens, Shift+F10 opens the menu; an empty queue shows "No files yet" with an Add files button and keeps head and totals; dragging files over the window outlines the centre and the count reads `Drop to add`. Add a video to the image queue: group rows `IMAGES 6` / `VIDEO 1` appear, image rows dim when the video is selected, clicking a group row selects that group.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add -A src/renderer/src/components/queue src/renderer/src/theme/views.css src/renderer/src/index.css src/renderer/src/App.tsx
+git commit -m "feat(queue): details table with result arrow, compact progress, row actions, sort and keyboard
+
+Co-Authored-By: Claude Opus 5.5 "
+```
+
+---
+
+### Task 13: Inspector and the options split (grouped settings, estimate card, output location)
+
+**Files:**
+
+- Create: `src/renderer/src/components/inspector/Inspector.tsx`
+- Create in `src/renderer/src/components/options/`: `types.ts`, `OptionsPane.tsx`, `ConvertSettings.tsx`, `PageRenderSettings.tsx`, `CompressSettings.tsx`, `ResizeSettings.tsx`, `UpscaleSettings.tsx`, `RemoveBgSettings.tsx`, `PdfSettings.tsx`, `ArchiveSettings.tsx`, `OutputSettings.tsx`, `upscale/PidSetup.tsx`, `upscale/ComfySetup.tsx`, `generate/GenerateSettings.tsx`, `generate/ModelSelect.tsx`, `generate/CompanionDownload.tsx`, `generate/LocateComfy.tsx`, `generate/AddModel.tsx`, `generate/AdvancedSettings.tsx`, `generate/restore.ts`
+- Move (git mv): `components/useArchiveStatus.ts`, `useComfyModels.ts`, `useGenerateStatus.ts`, `usePidStatus.ts` into `components/options/hooks/`
+- Modify: `src/renderer/src/App.tsx` (replace ` `, memoize `onSet`, batch estimate, inspector state)
+- Delete: `src/renderer/src/components/OptionsPanel.tsx`, `PidUpscale.tsx`, `ComfyImport.tsx`, `OperationTitle.tsx`
+- Test: `test/generate-restore.test.ts`
+
+**Interfaces:**
+
+- Consumes: every primitive from Task 10; `Icon`; `estimateBatch` (Task 7); `groupNoun` from `queueGroups.ts`; `window.filesmith.pickFolder` (Task 1); existing hooks and IPC calls.
+- Produces:
+ - `type SetOption = (k: string, v: string | number | boolean) => void` (`options/types.ts`)
+ - `OptionsPane({ tab, tool, options, kind, srcExts, sourceExt, runCount, videoOutputs, resizeOutputs, upscaleOutputs, estimate, set })` where `estimate: { from: number; to: number; files: number } | null` and the three output lists are `SizeRow[]`
+ - `type InspTab = 'options' | 'preview' | 'info'` (exported from `inspector/Inspector.tsx` as a type) and `Inspector({ tab, onTab, title, sub, runLabel, runDisabled, onRun, children })`. Its title is an ``; the Run button carries `data-testid="run"`.
+ - `isRestoreName(label: string): boolean` (`generate/restore.ts`, moved verbatim from `OptionsPanel.tsx:1116`)
+
+Every existing control is kept (spec 4.2); headings group them; the old label becomes the setting title and the old hint or `HelpTip` text becomes the description. Behavioural fixes riding along: `ArchiveSettings` from-pdf reuses `PageRenderSettings`, so the quality slider writes `pageQuality` (the engine reads `options.pageQuality`, `registry.ts:1301`); `onSet` is memoized; `useArchiveStatus` is called once; the stale "three toggles" comment is dropped.
+
+- [ ] **Step 1: Move the restoration-name helper out and test it**
+
+Copy `isRestoreName` from `OptionsPanel.tsx:1116` (the function and its regex, unchanged) into `options/generate/restore.ts` as `export function isRestoreName(label: string): boolean`. Then:
+
+```ts
+// test/generate-restore.test.ts
+import { describe, expect, it } from 'vitest'
+import { isRestoreName } from '../src/renderer/src/components/options/generate/restore'
+
+describe('isRestoreName', () => {
+ it('flags restoration checkpoints so Generate does not auto-pick them', () => {
+ expect(isRestoreName('SUPIR-v0Q')).toBe(true)
+ })
+ it('leaves ordinary text-to-image models alone', () => {
+ expect(isRestoreName('sd_xl_base_1.0')).toBe(false)
+ })
+})
+```
+
+Run: `npx vitest run test/generate-restore.test.ts`
+Expected: PASS. (If the copied regex does not match `SUPIR-v0Q`, the copy is wrong: re-copy it from `OptionsPanel.tsx:1116`.)
+
+- [ ] **Step 2: Move the status hooks**
+
+```bash
+mkdir -p src/renderer/src/components/options/hooks
+git mv src/renderer/src/components/useArchiveStatus.ts src/renderer/src/components/useComfyModels.ts src/renderer/src/components/useGenerateStatus.ts src/renderer/src/components/usePidStatus.ts src/renderer/src/components/options/hooks/
+```
+
+Fix their relative imports (`../../../` depth changes) until `npm run typecheck` passes for those four files.
+
+- [ ] **Step 3: Shared type and the simple settings files**
+
+```ts
+// src/renderer/src/components/options/types.ts
+export type SetOption = (k: string, v: string | number | boolean) => void
+```
+
+```tsx
+// src/renderer/src/components/options/PageRenderSettings.tsx
+import type { JSX } from 'react'
+import type { JobOptions } from '@shared/types'
+import { RangeField } from '../ui/RangeField'
+import { Segmented } from '../ui/Segmented'
+import { Setting, SettingGroup } from '../ui/Setting'
+import type { SetOption } from './types'
+
+/** Page rendering for every route that turns a PDF into images. Writes
+ * `pageQuality`, never `quality`, so it cannot collide with convert's preset. */
+export function PageRenderSettings({
+ options,
+ set
+}: {
+ options: JobOptions
+ set: SetOption
+}): JSX.Element {
+ const pageFormat = String(options.pageFormat ?? 'jpg')
+ return (
+
+
+ set('dpi', v)}
+ format={(v) => `${v} dpi`}
+ />
+
+
+ set('pageFormat', v)}
+ />
+
+ {pageFormat === 'jpg' && (
+
+ set('pageQuality', v)}
+ ends={['smaller file', 'higher quality']}
+ />
+
+ )}
+
+ )
+}
+```
+
+```tsx
+// src/renderer/src/components/options/ConvertSettings.tsx
+import type { JSX } from 'react'
+import type { FileKind, JobOptions } from '@shared/types'
+import { familyFormats, isSameFormat, sharedTargets } from '@shared/convert'
+import { needsRar } from '@shared/archive'
+import { Select } from '../ui/Select'
+import type { SelectOption } from '../ui/selectNav'
+import { Segmented } from '../ui/Segmented'
+import { Setting, SettingGroup } from '../ui/Setting'
+import { OutputSettings } from './OutputSettings'
+import { PageRenderSettings } from './PageRenderSettings'
+import type { SetOption } from './types'
+
+export function ConvertSettings({
+ options,
+ kind,
+ sourceExt,
+ srcExts,
+ verb,
+ hasRar,
+ set
+}: {
+ options: JobOptions
+ kind: FileKind
+ sourceExt: string | null
+ srcExts: string[]
+ /** The archive verb this target resolves to, if any: repack / to-pdf / from-pdf. */
+ verb?: string
+ hasRar: boolean
+ set: SetOption
+}): JSX.Element {
+ // Targets valid for EVERY selected source, so a mixed pdf+docx selection never
+ // offers CBZ (which only the pdf could do).
+ const formats = srcExts.length
+ ? sharedTargets(kind, srcExts)
+ : familyFormats(kind, sourceExt ?? '')
+ const choices: SelectOption[] = formats.map((f) => {
+ const isSource = srcExts.some((e) => isSameFormat(f.ext, e))
+ const noRar = needsRar(f.ext) && !hasRar
+ return {
+ value: f.ext,
+ label: f.label.toLowerCase(),
+ disabled: isSource || noRar,
+ reason: noRar ? 'WinRAR not found' : isSource ? 'already this format' : undefined
+ }
+ })
+ return (
+ <>
+
+
+ set('format', v)}
+ />
+
+ {kind === 'image' && (
+
+ set('quality', v)}
+ />
+
+ )}
+ {verb === 'repack' && (
+
+ set('store', v === 'store')}
+ />
+
+ )}
+
+ {verb === 'from-pdf' && }
+ {!verb && }
+ >
+ )
+}
+```
+
+```tsx
+// src/renderer/src/components/options/OutputSettings.tsx
+import type { JSX } from 'react'
+import type { JobOptions } from '@shared/types'
+import { SmallButton } from '../ui/Button'
+import { Select } from '../ui/Select'
+import { Setting, SettingGroup } from '../ui/Setting'
+import type { SetOption } from './types'
+
+const baseName = (p: string): string => p.split(/[\\/]/).filter(Boolean).pop() ?? p
+
+/** OUTPUT > Location (engine support: Task 3) and FILES > If file exists, which
+ * is fixed because the never-overwrite rule leaves one possible value (O7). */
+export function OutputSettings({
+ options,
+ set
+}: {
+ options: JobOptions
+ set: SetOption
+}): JSX.Element {
+ const dir = typeof options.outDir === 'string' ? options.outDir : ''
+ async function choose(): Promise {
+ const p = await window.filesmith.pickFolder()
+ if (p) set('outDir', p)
+ }
+ return (
+ <>
+
+ {dir} : 'Files are written next to each source.'}
+ >
+
+ {
+ if (v === 'source') set('outDir', '')
+ }}
+ />
+ void choose()}>
+ Choose folder
+
+
+
+
+
+
+ undefined}
+ />
+
+
+ >
+ )
+}
+```
+
+```tsx
+// src/renderer/src/components/options/CompressSettings.tsx
+import type { JSX } from 'react'
+import type { FileKind, JobOptions } from '@shared/types'
+import {
+ AUDIO_BITRATES,
+ AUDIO_CODECS,
+ IMAGE_FORMATS,
+ PDF_LEVELS,
+ SCALE_MAX,
+ SCALE_MIN,
+ SCALE_STEP,
+ VIDEO_CODECS
+} from '@shared/compress'
+import { ChipGrid } from '../ui/ChipGrid'
+import { OutputSizeList, type SizeRow } from '../ui/OutputSizeList'
+import { RangeField } from '../ui/RangeField'
+import { Select } from '../ui/Select'
+import { CheckSetting, Setting, SettingGroup } from '../ui/Setting'
+import type { SetOption } from './types'
+
+const quality = (options: JobOptions, set: SetOption): JSX.Element => (
+
+ set('quality', v)}
+ ends={['smaller file', 'higher quality']}
+ />
+
+)
+
+export function CompressSettings({
+ options,
+ kind,
+ videoOutputs,
+ set
+}: {
+ options: JobOptions
+ kind: FileKind
+ videoOutputs: SizeRow[]
+ set: SetOption
+}): JSX.Element {
+ if (kind === 'pdf')
+ return (
+
+
+ set('pdfLevel', v)}
+ />
+
+
+ set('pdfGray', v)}
+ label="Convert to greyscale"
+ sub="Smaller, but every page loses its colour"
+ />
+
+
+ )
+ if (kind === 'video') {
+ const scale = Number(options.scale ?? 100)
+ return (
+
+
+ set('videoCodec', v)}
+ />
+
+
+ set('scale', v)}
+ format={(v) => (v === 100 ? 'original' : `${v}%`)}
+ ends={[`${SCALE_MIN}%`, 'original']}
+ />
+ {scale < 100 && }
+
+ {quality(options, set)}
+
+ )
+ }
+ if (kind === 'audio')
+ return (
+
+
+ set('audioCodec', v)}
+ />
+
+
+ ({ value: b, label: `${b}k` }))}
+ onChange={(v) => set('audioBitrate', v)}
+ />
+
+
+ )
+ return (
+
+
+ set('imageFormat', v)}
+ />
+
+ {quality(options, set)}
+
+ )
+}
+```
+
+`PDF_LEVELS`, `VIDEO_CODECS`, `AUDIO_CODECS` and `IMAGE_FORMATS` are `Choice[]` (`{ value, label }`), which is assignable to `SelectOption[]`.
+
+```tsx
+// src/renderer/src/components/options/ResizeSettings.tsx
+import type { JSX } from 'react'
+import type { JobOptions } from '@shared/types'
+import { RESIZE_FITS } from '@shared/resize'
+import { OutputSizeList, type SizeRow } from '../ui/OutputSizeList'
+import { RangeField } from '../ui/RangeField'
+import { Segmented } from '../ui/Segmented'
+import { Setting, SettingGroup } from '../ui/Setting'
+import { NumberField } from '../ui/TextField'
+import type { SetOption } from './types'
+
+const dim = (v: unknown): number | '' =>
+ v == null || v === '' || !Number.isFinite(Number(v)) ? '' : Number(v)
+
+export function ResizeSettings({
+ options,
+ outputs,
+ set
+}: {
+ options: JobOptions
+ outputs: SizeRow[]
+ set: SetOption
+}): JSX.Element {
+ const mode = String(options.mode ?? 'percent')
+ return (
+
+
+ set('mode', v)}
+ />
+
+ {mode === 'percent' ? (
+
+ set('percent', v)}
+ format={(v) => `${v}%`}
+ />
+
+ ) : (
+ <>
+
+
+ set('width', v)}
+ clamp={(n) => Math.max(1, Math.round(n))}
+ />
+ set('height', v)}
+ clamp={(n) => Math.max(1, Math.round(n))}
+ />
+
+
+
+ ({ value: f.value, label: f.label.toLowerCase() }))}
+ onChange={(v) => set('fit', v)}
+ />
+
+
+ >
+ )}
+
+ )
+}
+```
+
+```tsx
+// src/renderer/src/components/options/RemoveBgSettings.tsx
+import { useEffect, useState, type JSX } from 'react'
+import type { JobOptions } from '@shared/types'
+import { BG_DEFAULTS, BG_FILLS, type BgFill } from '@shared/removebg'
+import { SmallButton } from '../ui/Button'
+import { Select } from '../ui/Select'
+import { Setting, SettingGroup } from '../ui/Setting'
+import type { SetOption } from './types'
+
+/** One fill choice plus its custom colour or image. The model and matting
+ * thresholds are deliberately not exposed (see src/shared/removebg.ts). */
+export function RemoveBgSettings({
+ options,
+ set
+}: {
+ options: JobOptions
+ set: SetOption
+}): JSX.Element {
+ const fill = String(options.bgFill ?? BG_DEFAULTS.bgFill) as BgFill
+ const bgImage = String(options.bgImagePath ?? '')
+ const color = String(options.bgCustomColor ?? BG_DEFAULTS.bgCustomColor)
+ const [rembg, setRembg] = useState<{ ready: boolean; uvAvailable: boolean } | null>(null)
+ useEffect(() => {
+ let alive = true
+ void window.filesmith.removebgStatus().then((s) => alive && setRembg(s))
+ return () => {
+ alive = false
+ }
+ }, [])
+ const notice =
+ rembg && !rembg.ready ? (
+ rembg.uvAvailable ? (
+ 'The first run downloads the AI model once (a few hundred MB), then works offline.'
+ ) : (
+ <>
+ Needs the free uv tool: winget install astral-sh.uv, then reopen Filesmith.
+ >
+ )
+ ) : undefined
+ return (
+
+
+ set('bgFill', v)} />
+
+ {fill === 'custom' && (
+
+ {/* The swatch shows user data, the one non-grey value on screen (spec 4.2). */}
+
+
+ {color}
+ set('bgCustomColor', e.target.value)}
+ />
+
+
+ )}
+ {fill === 'image' && (
+ {bgImage.split(/[\\/]/).pop()} : undefined}
+ >
+
+ void window.filesmith.pickImage().then((p) => {
+ if (p) set('bgImagePath', p)
+ })
+ }
+ >
+ Choose image
+
+
+ )}
+
+ )
+}
+```
+
+```tsx
+// src/renderer/src/components/options/PdfSettings.tsx
+import type { JSX } from 'react'
+import type { JobOptions } from '@shared/types'
+import { RangeField } from '../ui/RangeField'
+import { Setting, SettingGroup } from '../ui/Setting'
+import { TextField } from '../ui/TextField'
+import type { SetOption } from './types'
+
+/** Each Tools card is one op, so there is no op picker (spec 4.2). */
+export function PdfSettings({
+ options,
+ runCount,
+ set
+}: {
+ options: JobOptions
+ runCount: number
+ set: SetOption
+}): JSX.Element | null {
+ const op = String(options.op ?? 'extract-text')
+ if (op === 'pages-to-images')
+ return (
+
+
+ set('dpi', v)}
+ format={(v) => `${v} dpi`}
+ />
+
+
+ )
+ if (op === 'split-range')
+ return (
+
+
+ Ranges and single pages, for example 1-3,5,8-10.
+ >
+ }
+ >
+ set('range', e.target.value)}
+ />
+
+
+ )
+ if (op === 'merge' && runCount < 2)
+ return (
+
+
+
+ )
+ return null
+}
+```
+
+```tsx
+// src/renderer/src/components/options/ArchiveSettings.tsx
+import type { JSX } from 'react'
+import type { JobOptions } from '@shared/types'
+import { ARCHIVE_FORMATS, COMIC_FORMATS, needsRar } from '@shared/archive'
+import { ChipGrid } from '../ui/ChipGrid'
+import { Segmented } from '../ui/Segmented'
+import { Setting, SettingGroup } from '../ui/Setting'
+import { PageRenderSettings } from './PageRenderSettings'
+import type { SetOption } from './types'
+
+/** Archive Tools cards (only reachable when such a card exists). from-pdf reuses
+ * PageRenderSettings, which fixes the old bug where this panel wrote `quality`
+ * while the engine reads `pageQuality`. */
+export function ArchiveSettings({
+ options,
+ srcExts,
+ hasRar,
+ set
+}: {
+ options: JobOptions
+ srcExts: string[]
+ hasRar: boolean
+ set: SetOption
+}): JSX.Element {
+ const op = String(options.op ?? 'repack')
+ const targets = (list: typeof ARCHIVE_FORMATS): JSX.Element => (
+
+ {
+ // .zip and .cbz are the same container but not the same file: compare by extension.
+ const isSource = srcExts.includes(f.ext)
+ const noRar = needsRar(f.ext) && !hasRar
+ return {
+ value: f.ext,
+ label: f.label.toLowerCase(),
+ disabled: isSource || noRar,
+ title: noRar
+ ? 'WinRAR not found'
+ : isSource
+ ? 'Files are already this format'
+ : undefined
+ }
+ })}
+ onChange={(v) => set('format', v)}
+ />
+
+ )
+ if (op === 'extract')
+ return (
+
+
+
+ )
+ if (op === 'to-pdf')
+ return (
+
+
+
+ )
+ if (op === 'from-pdf')
+ return (
+ <>
+ {targets(COMIC_FORMATS)}
+
+ >
+ )
+ return (
+
+ {targets(ARCHIVE_FORMATS)}
+
+ set('store', v === 'store')}
+ />
+
+
+ )
+}
+```
+
+The two info-only descriptions (`extract`, `to-pdf`) and the `.cbz` default target are copied word for word from the old `ArchiveOptions` (`OptionsPanel.tsx:1006-1033`).
+
+- [ ] **Step 4: Upscale settings and its setup blocks**
+
+`options/upscale/PidSetup.tsx`: move `PidInstallCard` from `components/PidUpscale.tsx` and `PidRemoveButton` from `OptionsPanel.tsx`, keeping every state, effect and IPC call (`pidInstalling`, `pidInstall`, `onPidProgress`, `pidRemove`, the two-step remove confirmation). Restyle as `Setting` blocks per spec 5.5, using this mapping:
+
+| Old markup | New |
+| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
+| card container with title + paragraph | `` (licence notice and VRAM text become the description) |
+| primary download button | `` |
+| remove / uninstall button | `` |
+| progress bar (rounded track + accent fill) | ` ` followed by `{pct}% ` |
+| any `text-[#...]`, `bg-[#...]`, `rounded-*`, `shadow-*` class | removed |
+
+`options/upscale/ComfySetup.tsx`: the same treatment for `ComfyImportCard` and its local `ProgressBar` from `components/ComfyImport.tsx` (three states: engine not built, scanning, ready; the unusable-files list renders as a ``). Delete the local `ProgressBar` in favour of `ui/ProgressBar`.
+
+```tsx
+// src/renderer/src/components/options/UpscaleSettings.tsx
+import { useEffect, useMemo, useState, type JSX } from 'react'
+import type { JobOptions } from '@shared/types'
+import {
+ UPSCALE_COMFY,
+ UPSCALE_FACTORS,
+ UPSCALE_GPU_MODES,
+ UPSCALE_MODELS,
+ type Choice,
+ type UpscaleModel
+} from '@shared/compress'
+import { SmallButton } from '../ui/Button'
+import { ChipGrid } from '../ui/ChipGrid'
+import { OutputSizeList, type SizeRow } from '../ui/OutputSizeList'
+import { Segmented } from '../ui/Segmented'
+import { Select } from '../ui/Select'
+import { Setting, SettingGroup } from '../ui/Setting'
+import { useComfyModels } from './hooks/useComfyModels'
+import { usePidStatus } from './hooks/usePidStatus'
+import { ComfyImportCard } from './upscale/ComfySetup'
+import { PidInstallCard, PidRemoveButton } from './upscale/PidSetup'
+import type { SetOption } from './types'
+
+export function UpscaleSettings({
+ options,
+ outputs,
+ set
+}: {
+ options: JobOptions
+ outputs: SizeRow[]
+ set: SetOption
+}): JSX.Element {
+ const factor = Number(options.upscaleFactor ?? 4)
+ const comfy = useComfyModels()
+ const { status: pid, refresh: refreshPid } = usePidStatus()
+ const rawModel = String(options.upscaleModel ?? 'photo')
+ const hasNvidia = Boolean(comfy.status?.nvidia || pid?.nvidia)
+ const [ncnn, setNcnn] = useState<{ value: string; label: string; user: boolean }[] | null>(null)
+ useEffect(() => {
+ let alive = true
+ void window.filesmith.upscaleModels().then((m) => alive && setNcnn(m))
+ return () => {
+ alive = false
+ }
+ }, [])
+ const comfyModels = comfy.status?.models
+ const comfyChoices = useMemo(
+ () =>
+ (comfyModels ?? []).map((m) => ({
+ value: `comfy:${m.path}`,
+ label: `${m.name}, ${m.scale}×${m.badge === 'experimental' ? ', experimental' : ''}`
+ })),
+ [comfyModels]
+ )
+ const ncnnChoices: Choice[] = ncnn?.length
+ ? ncnn.map((m) => ({
+ value: m.value as UpscaleModel,
+ label: m.user ? `${m.label}, added by you` : m.label
+ }))
+ : UPSCALE_MODELS
+ const isPid = rawModel === 'pid'
+ const isComfyPath = rawModel.startsWith('comfy:')
+ const inAi = isPid || isComfyPath || rawModel === 'comfy'
+ const category = inAi
+ ? 'comfy'
+ : ncnnChoices.some((c) => c.value === rawModel)
+ ? rawModel
+ : (ncnnChoices[0]?.value ?? 'photo')
+ const categoryChoices = [...ncnnChoices, ...(hasNvidia ? [UPSCALE_COMFY] : [])]
+ const showPid = hasNvidia && Boolean(pid?.installed || comfy.status?.pidReusable)
+ const subChoices = [
+ ...comfyChoices,
+ ...(showPid ? [{ value: 'pid', label: 'PiD (diffusion), 4×' }] : [])
+ ]
+ const subDefault = comfyChoices[0]?.value ?? (showPid ? 'pid' : 'comfy')
+ const subValue = isPid ? 'pid' : isComfyPath ? rawModel : subDefault
+ const pidNeedsInstall = isPid && pid != null && !pid.installed
+ const vramMb = pid?.nvidia?.vramMb ?? null
+ const lowVram = isPid && vramMb != null && vramMb < 12_000
+ const pickCategory = (v: string): void => set('upscaleModel', v === 'comfy' ? subDefault : v)
+ useEffect(() => {
+ if (pid == null || comfy.status == null) return
+ if (inAi && !hasNvidia) set('upscaleModel', 'photo')
+ else if (isComfyPath && !comfyChoices.some((c) => c.value === rawModel))
+ set('upscaleModel', subDefault)
+ else if (isPid && !showPid) set('upscaleModel', subDefault)
+ }, [
+ pid,
+ comfy.status,
+ hasNvidia,
+ inAi,
+ isComfyPath,
+ isPid,
+ showPid,
+ rawModel,
+ comfyChoices,
+ subDefault,
+ set
+ ])
+ const gpuReason = !hasNvidia ? (pid?.cudaReason ?? comfy.status?.cudaReason) : undefined
+
+ return (
+ <>
+
+
+ ({ value: f, label: `${f}×` }))}
+ onChange={(v) => set('upscaleFactor', v)}
+ />
+
+
+
+ {category === 'comfy' && subChoices.length > 0 && (
+ set('upscaleModel', v)}
+ />
+ )}
+ {!inAi && (
+ void window.filesmith.upscaleOpenModelsFolder()}
+ >
+ Add your own model
+
+ )}
+
+ {category === 'comfy' && pidNeedsInstall && }
+ {category === 'comfy' && isPid && pid?.installed && (
+
+ )}
+ {category === 'comfy' && comfy.status && (
+
+ )}
+
+ {(!isPid || lowVram) && (
+
+ {!isPid && (
+
+ ({
+ value: o.value,
+ label: o.label.toLowerCase()
+ }))}
+ onChange={(v) => set('gpuMode', v)}
+ />
+
+ )}
+ {lowVram && (
+
+ )}
+
+ )}
+ {outputs.length > 0 && (
+
+
+
+
+
+ )}
+ >
+ )
+}
+```
+
+The separators inside model labels change from `·` to `, ` only for display; stored values (`comfy:`, `pid`, `photo`, ...) are unchanged.
+
+- [ ] **Step 5: Generate settings**
+
+Create `generate/ModelSelect.tsx` exporting `ModelSelect({ models, value, onChange })`: a `Select` whose options are the models with `group` = the architecture group heading the old `ModelPicker` used for its ``s (`OptionsPanel.tsx:1123-1167`), no `disabled` flag, and `reason: 'needs download'` for non-runnable models (replacing the old dash-prefixed suffix). Non-runnable models must stay selectable, exactly as the old picker documents (`OptionsPanel.tsx:1119-1121`): picking one is what reveals `CompanionDownload` or the Try anyway button below, so disabling them would make both unreachable. Move `CompanionDownload` (`:1172-1241`), `LocateComfy` (`:1252-1276`) and `AddModel` (`:1284-1356`) into their own files under `generate/` with every effect, state and IPC call unchanged, restyled with the same mapping table as Step 4 (buttons become `SmallButton`; `AddModel`'s three actions use icons `addfile`, `folder`, `folder`; the missing-file list is ``; the download bar is ` `).
+
+`generate/AdvancedSettings.tsx` exports `AdvancedSettings({ options, info, set })` and holds Steps, CFG, Guidance and Seed from `OptionsPanel.tsx:1631-1725` with their ranges unchanged (`steps`: min 8 for sdxl and flux1 else 1, max 50; `cfg`: 1-15 step 0.5, sdxl only; `guidance`: 1-10 step 0.5 when `info.hasGuidance`; `seed`: `NumberField` plus a `CheckSetting` "Random seed" that writes `-1`). Each `HelpTip` text becomes that setting's `desc`. It renders inside `` with local `useState(false)` for `open`.
+
+`generate/GenerateSettings.tsx`:
+
+```tsx
+import { useEffect, useMemo, useRef, type JSX } from 'react'
+import type { JobOptions } from '@shared/types'
+import { GEN_MAX_COUNT, GEN_SIZES, GEN_STYLES, clampDim } from '@shared/generate'
+import { archInfoFor, type GenArch } from '@shared/genArch'
+import { SmallButton } from '../../ui/Button'
+import { ChipGrid } from '../../ui/ChipGrid'
+import { RangeField } from '../../ui/RangeField'
+import { Select } from '../../ui/Select'
+import { Setting, SettingGroup } from '../../ui/Setting'
+import { NumberField, TextField } from '../../ui/TextField'
+import { useGenerateStatus } from '../hooks/useGenerateStatus'
+import type { SetOption } from '../types'
+import { AddModel } from './AddModel'
+import { AdvancedSettings } from './AdvancedSettings'
+import { CompanionDownload } from './CompanionDownload'
+import { LocateComfy } from './LocateComfy'
+import { ModelSelect } from './ModelSelect'
+import { isRestoreName } from './restore'
+
+export function GenerateSettings({
+ options,
+ set
+}: {
+ options: JobOptions
+ set: SetOption
+}): JSX.Element {
+ const { status, refresh } = useGenerateStatus()
+ const model = String(options.model ?? '')
+ const w = Number(options.width ?? 1024)
+ const h = Number(options.height ?? 1024)
+ const isPreset = GEN_SIZES.some((s) => s.width === w && s.height === h)
+ const sizeValue =
+ String(options.sizeMode ?? '') === 'custom' || !isPreset ? 'custom' : `${w}x${h}`
+ const models = useMemo(() => status?.models ?? [], [status])
+ const selected = models.find((m) => m.name === model)
+ const tryAnyway = Boolean(options.tryAnyway)
+ const dimCaps = status?.dimCaps?.[selected?.arch ?? 'sdxl']
+ const arch: GenArch = selected?.arch ?? 'sdxl'
+ const info = archInfoFor(arch, status?.archInfo)
+ useEffect(() => {
+ if (!models.length || selected) return
+ const good =
+ models.find((m) => m.runnable && !isRestoreName(m.label)) ??
+ models.find((m) => m.runnable) ??
+ models[0]
+ if (good && good.name !== model) set('model', good.name)
+ }, [model, models, selected, set])
+ const prevArch = useRef(null)
+ useEffect(() => {
+ if (!status) return
+ if (prevArch.current === null) {
+ prevArch.current = arch
+ return
+ }
+ if (prevArch.current === arch) return
+ prevArch.current = arch
+ set('tryAnyway', 0)
+ set('steps', info.steps)
+ set('cfg', info.cfg)
+ if (info.hasGuidance) set('guidance', info.guidance)
+ }, [arch, info, set, status])
+
+ const sizeChoices = [
+ ...GEN_SIZES.map((s) => ({ value: `${s.width}x${s.height}`, label: s.label })),
+ { value: 'custom', label: 'custom' }
+ ]
+ return (
+ <>
+ {status && !status.available && }
+
+
+ {models.length > 0 && (
+ set('model', v)} />
+ )}
+ {selected && !selected.runnable && !selected.missing?.length && selected.tryAnyway && (
+ set('tryAnyway', tryAnyway ? 0 : 1)}
+ >
+ {tryAnyway ? 'Will try anyway, click to cancel' : 'Try anyway'}
+
+ )}
+
+ {selected && !selected.runnable && selected.missing?.length ? (
+
+ ) : null}
+
+
+
+ {info.cfg !== 1 && (
+
+ set('negative', e.target.value)}
+ />
+
+ )}
+
+ ({ value: s.value, label: s.label.toLowerCase() }))}
+ onChange={(v) => set('style', v)}
+ />
+
+
+
+
+ set('count', v)}
+ />
+
+
+ {
+ if (v === 'custom') return set('sizeMode', 'custom')
+ const [sw, sh] = v.split('x').map(Number)
+ set('sizeMode', 'preset')
+ set('width', sw)
+ set('height', sh)
+ }}
+ />
+ {sizeValue === 'custom' && (
+
+ v !== '' && set('width', v)}
+ clamp={(n) => clampDim(n, dimCaps)}
+ />
+ v !== '' && set('height', v)}
+ clamp={(n) => clampDim(n, dimCaps)}
+ />
+
+ )}
+
+
+
+ >
+ )
+}
+```
+
+Before writing this file, read `OptionsPanel.tsx:1562-1627` and match three details exactly: the `GEN_STYLES` item shape (`value`/`label`), how the old size `onChange` wrote `sizeMode`/`width`/`height`, and `clampDim`'s real signature. If any differs from the code above, follow the old code; the visual structure above is what changes.
+
+- [ ] **Step 6: The pane router and the inspector**
+
+```tsx
+// src/renderer/src/components/options/OptionsPane.tsx
+import type { JSX } from 'react'
+import type { FileKind, JobOptions, ToolId } from '@shared/types'
+import type { TabId } from '@shared/tabs'
+import { EstimateCard } from '../ui/EstimateCard'
+import type { SizeRow } from '../ui/OutputSizeList'
+import { ArchiveSettings } from './ArchiveSettings'
+import { CompressSettings } from './CompressSettings'
+import { ConvertSettings } from './ConvertSettings'
+import { GenerateSettings } from './generate/GenerateSettings'
+import { useArchiveStatus } from './hooks/useArchiveStatus'
+import { PdfSettings } from './PdfSettings'
+import { RemoveBgSettings } from './RemoveBgSettings'
+import { ResizeSettings } from './ResizeSettings'
+import type { SetOption } from './types'
+import { UpscaleSettings } from './UpscaleSettings'
+
+export function OptionsPane({
+ tab,
+ tool,
+ options,
+ kind,
+ srcExts,
+ sourceExt,
+ runCount,
+ videoOutputs,
+ resizeOutputs,
+ upscaleOutputs,
+ estimate,
+ set
+}: {
+ tab: TabId
+ tool: ToolId
+ options: JobOptions
+ kind: FileKind
+ srcExts: string[]
+ sourceExt: string | null
+ runCount: number
+ videoOutputs: SizeRow[]
+ resizeOutputs: SizeRow[]
+ upscaleOutputs: SizeRow[]
+ estimate: { from: number; to: number; files: number } | null
+ set: SetOption
+}): JSX.Element {
+ // One status call for the whole pane (it used to be made twice).
+ const { rar } = useArchiveStatus()
+ function body(): JSX.Element | null {
+ // Convert owns its target choice for every route, including archive ones.
+ if (tab === 'convert')
+ return (
+
+ )
+ switch (tool) {
+ case 'compress':
+ return (
+
+ )
+ case 'resize':
+ return
+ case 'upscale':
+ return
+ case 'removebg':
+ return
+ case 'pdf':
+ return
+ case 'archive':
+ return
+ case 'generate':
+ return
+ default:
+ return null
+ }
+ }
+ return (
+
+ {body()}
+ {estimate && }
+
+ )
+}
+```
+
+```tsx
+// src/renderer/src/components/inspector/Inspector.tsx
+import type { JSX, ReactNode } from 'react'
+import { PrimaryButton } from '../ui/Button'
+import { Tabs } from '../ui/Tabs'
+
+export type InspTab = 'options' | 'preview' | 'info'
+
+export function Inspector({
+ tab,
+ onTab,
+ title,
+ sub,
+ runLabel,
+ runDisabled,
+ onRun,
+ children
+}: {
+ tab: InspTab
+ onTab: (t: InspTab) => void
+ title: string
+ sub: string
+ runLabel: string
+ runDisabled: boolean
+ onRun: () => void
+ children: ReactNode
+}): JSX.Element {
+ return (
+
+
+
+
+
{title}
+ {sub}
+
+ {children}
+
+
+
+ )
+}
+```
+
+- [ ] **Step 7: Wire it into `App.tsx`**
+
+1. Memoize the setter (spec 4.2; the inline arrow re-ran two effects every render):
+
+```ts
+const onSet = useCallback(
+ (k: string, v: string | number | boolean) =>
+ dispatch({ type: 'setOption', group: optGroup, key: k, value: v }),
+ [optGroup]
+)
+```
+
+2. Inspector state: `const [inspTab, setInspTab] = useState('options')`. In the Sidebar `onSelect`, also call `setInspTab('options')`, so every workspace opens on its options and its `` names the verb.
+
+3. Change the three `VideoOutputRow[]` annotations to `SizeRow[]` (`import type { SizeRow } from './components/ui/OutputSizeList'`); the objects already have `name`, `from`, `to`.
+
+4. Batch estimate (spec 4.5), using the Task 12 `estimateFor` with the CURRENT options:
+
+```ts
+ function estimateFor(it: QueueItem, opts: JobOptions = it.runOptions ?? curOptions): number | null {
+```
+
+(change the signature; body unchanged) and
+
+```ts
+const batchEstimate =
+ tool === 'generate' || runList.length === 0
+ ? null
+ : estimateBatch(
+ runList.map((i) => ({ size: i.file.size, estimate: estimateFor(i, curOptions) }))
+ )
+```
+
+5. Inspector head and Run label:
+
+```ts
+const verbLabel = card ? card.label : tab.label
+const focused = cur.items.find(
+ (i) => i.id === (cur.anchor && cur.selected.includes(cur.anchor) ? cur.anchor : cur.selected[0])
+)
+const scopeCount = scopeItems.length
+const inspSub =
+ tool === 'generate'
+ ? ''
+ : inspTab !== 'options'
+ ? focused
+ ? 'selected'
+ : ''
+ : cur.selected.length
+ ? `${cur.selected.length} selected`
+ : scopeCount
+ ? `all ${scopeCount} file${scopeCount === 1 ? '' : 's'}`
+ : 'no files'
+const inspTitle = inspTab === 'options' || !focused ? verbLabel : focused.file.name
+const genCount = Number(curOptions.count ?? 1)
+const runLabel =
+ tool === 'generate'
+ ? genCount > 1
+ ? `Generate ${genCount} images`
+ : 'Generate'
+ : runCount > 0
+ ? `${verbLabel} ${groupNoun(optGroup, runCount)}`
+ : verbLabel
+```
+
+6. Replace ` ` with:
+
+```tsx
+{
+ showInspector && (
+ void run()}
+ >
+ {inspTab === 'options' ? (
+
+ ) : (
+
+ )}
+
+ )
+}
+```
+
+(Import `EmptyState` from `./components/queue/EmptyState`, `Inspector` and `type InspTab` from `./components/inspector/Inspector`, and `OptionsPane` from `./components/options/OptionsPane`. Task 14 replaces the `EmptyState` branch with the real Preview and Info panes.) Generate keeps the inspector; its options now show with no file selected because `OptionsPane` no longer gates on a selection (spec 4.1).
+
+7. Delete `OperationTitle` from the centre and its import.
+
+- [ ] **Step 8: Delete the replaced files**
+
+```bash
+git rm src/renderer/src/components/OptionsPanel.tsx src/renderer/src/components/PidUpscale.tsx src/renderer/src/components/ComfyImport.tsx src/renderer/src/components/OperationTitle.tsx
+```
+
+Add to `theme/views.css`:
+
+```css
+/* Remove BG custom colour: a 28px swatch button (user data, spec 4.2) */
+.swatch {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ height: 28px;
+ padding-right: 10px;
+ border: 1px solid var(--line-strong);
+ cursor: pointer;
+ align-self: flex-start;
+ font-size: 13px;
+}
+.swatch:hover {
+ border-color: var(--fg3);
+}
+.swatch:focus-within {
+ outline: 1px solid var(--focus);
+ outline-offset: 1px;
+}
+.swatch .sw {
+ width: 26px;
+ height: 26px;
+ border-right: 1px solid var(--line-strong);
+}
+.vs-set > .vs-selwrap + .vs-selwrap,
+.vs-set > .vs-selwrap + .sbtn,
+.vs-set > .rng + .sizes {
+ margin-top: 6px;
+}
+```
+
+- [ ] **Step 9: Verify**
+
+Run: `npm test && npm run typecheck && npm run lint`
+Expected: all green; `grep -rn "OptionsPanel\|OperationTitle\|PidUpscale\|ComfyImport'" src/renderer` prints nothing.
+
+Run: `npm run dev` and walk every verb: Convert (images: FORMAT with Format select and Quality segments, OUTPUT Location with Choose folder, FILES If file exists disabled `add (2)`, then the estimate card `35.2 MB -> ~7.6 MB -78%` style once files are present); Convert a PDF to CBZ (PAGES group, Page quality changes `pageQuality`); Compress image / video (Scale list below 100%) / audio (bitrate chips) / pdf (greyscale checkbox); Resize both modes; Upscale (factor chips, model select, AI sub-select on NVIDIA, GPU mode); Remove BG (fill select, custom colour swatch, background image button); Tools > Pages to PNG (DPI), Split (range field), Merge (hint with one PDF); Generate (MODEL / PROMPT / OUTPUT / ADVANCED collapsible). Open every select with the keyboard (Down, type-ahead, Enter, Esc). Choose an output folder, convert, confirm the file lands there; delete that folder, convert again, confirm the row fails with "Output folder not found".
+
+- [ ] **Step 10: Commit**
+
+```bash
+git add -A src/renderer/src/components/options src/renderer/src/components/inspector src/renderer/src/components/ui/OutputSizeList.tsx src/renderer/src/theme/views.css src/renderer/src/App.tsx test/generate-restore.test.ts
+git commit -m "feat(inspector): grouped options per verb, estimate card, output location; fix archive page quality
+
+Co-Authored-By: Claude Opus 5.5 "
+```
+
+---
+
+### Task 14: Preview and Info panes
+
+**Files:**
+
+- Create: `src/renderer/src/components/inspector/infoModel.ts`, `PreviewPane.tsx`, `InfoPane.tsx`, `Wipe.tsx`
+- Modify: `src/renderer/src/App.tsx` (render the panes, probe the focused file)
+- Modify: `src/renderer/src/theme/views.css` (wipe slider)
+- Test: `test/info-model.test.ts`
+
+**Interfaces:**
+
+- Consumes: `QueueItem`; `formatBytes`; `pctChange`, `formatPct`, `kindLabel` (Task 5); `ProgressBar`, `SmallButton`; `window.filesmith.thumbnail(path, size, kind)`.
+- Produces:
+ - `interface InfoRow { k: string; v: string; reveal?: string; selectable?: boolean }`
+ - `previewRows(item: QueueItem, dims: { width: number; height: number } | null): InfoRow[]` (`size`, `saved` when done with a file output, `pixels`)
+ - `infoRows(item: QueueItem, dims: { width: number; height: number } | null, target: string | null): InfoRow[]` (`format`, `pixels`, `size`, `output`, `error`)
+ - `wipeStep(key: string, pct: number): number | null` (Left/Right 5, Home 0, End 100)
+ - `PreviewPane({ item, outKind, dims })`, `InfoPane({ item, dims, target })`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+// test/info-model.test.ts
+import { describe, expect, it } from 'vitest'
+import { infoRows, previewRows, wipeStep } from '../src/renderer/src/components/inspector/infoModel'
+import type { QueueItem } from '../src/renderer/src/state'
+
+const base: QueueItem = {
+ id: 'a',
+ file: { path: 'C:/p/a.jpg', name: 'a.jpg', ext: '.jpg', kind: 'image', size: 2_202_010 },
+ thumb: null,
+ status: 'ready',
+ percent: 0
+}
+const done: QueueItem = { ...base, status: 'done', outputPath: 'C:/p/a.webp', outputSize: 497_664 }
+
+describe('previewRows', () => {
+ it('shows size, saved and pixels for a done file', () => {
+ expect(previewRows(done, { width: 4032, height: 3024 })).toEqual([
+ { k: 'size', v: '2.1 MB to 486 KB' },
+ { k: 'saved', v: '1.6 MB, -77%' },
+ { k: 'pixels', v: '4032 × 3024' }
+ ])
+ })
+ it('omits saved before the file is done and says unknown without a probe', () => {
+ expect(previewRows(base, null)).toEqual([
+ { k: 'size', v: '2.1 MB' },
+ { k: 'pixels', v: 'unknown' }
+ ])
+ })
+})
+
+describe('infoRows', () => {
+ it('uses the word "to", never an arrow', () => {
+ const rows = infoRows(done, { width: 10, height: 20 }, null)
+ expect(rows.find((r) => r.k === 'format')?.v).toBe('jpg to webp')
+ expect(rows.find((r) => r.k === 'output')).toEqual({
+ k: 'output',
+ v: 'C:/p/a.webp',
+ reveal: 'C:/p/a.webp'
+ })
+ expect(rows.map((r) => r.v).join(' ')).not.toMatch(/→|->/)
+ })
+ it('shows the planned target before a run', () => {
+ expect(infoRows(base, null, '.webp').find((r) => r.k === 'format')?.v).toBe('jpg to webp')
+ })
+ it('gives a failed row its full, selectable error', () => {
+ const r = infoRows(
+ { ...base, status: 'failed', error: 'Unsupported compression in TIFF' },
+ null,
+ null
+ )
+ expect(r.find((x) => x.k === 'error')).toEqual({
+ k: 'error',
+ v: 'Unsupported compression in TIFF',
+ selectable: true
+ })
+ })
+})
+
+describe('wipeStep', () => {
+ it('moves by 5 and clamps', () => {
+ expect(wipeStep('ArrowRight', 50)).toBe(55)
+ expect(wipeStep('ArrowLeft', 2)).toBe(0)
+ expect(wipeStep('ArrowRight', 98)).toBe(100)
+ expect(wipeStep('Home', 40)).toBe(0)
+ expect(wipeStep('End', 40)).toBe(100)
+ expect(wipeStep('a', 40)).toBeNull()
+ })
+})
+```
+
+- [ ] **Step 2: Run it and watch it fail**
+
+Run: `npx vitest run test/info-model.test.ts`
+Expected: FAIL, module not found.
+
+- [ ] **Step 3: Implement**
+
+```ts
+// src/renderer/src/components/inspector/infoModel.ts
+import { formatBytes } from '@shared/compress'
+import type { QueueItem } from '../../state'
+import { formatPct, pctChange } from '../queue/rowModel'
+
+export interface InfoRow {
+ k: string
+ v: string
+ reveal?: string
+ selectable?: boolean
+}
+
+type Dims = { width: number; height: number } | null
+
+const extOf = (p: string): string => {
+ const b = p.split(/[\\/]/).pop() ?? p
+ const i = b.lastIndexOf('.')
+ return i > 0 ? b.slice(i + 1).toLowerCase() : ''
+}
+const pixels = (d: Dims): string => (d ? `${d.width} × ${d.height}` : 'unknown')
+
+export function previewRows(item: QueueItem, dims: Dims): InfoRow[] {
+ const src = item.file.size
+ const out = item.status === 'done' ? item.outputSize : undefined
+ const rows: InfoRow[] = [
+ { k: 'size', v: out != null ? `${formatBytes(src)} to ${formatBytes(out)}` : formatBytes(src) }
+ ]
+ if (out != null) {
+ const pct = pctChange(src, out)
+ rows.push({
+ k: 'saved',
+ v: `${formatBytes(Math.max(0, src - out))}${pct != null ? `, ${formatPct(pct)}` : ''}`
+ })
+ }
+ rows.push({ k: 'pixels', v: pixels(dims) })
+ return rows
+}
+
+export function infoRows(item: QueueItem, dims: Dims, target: string | null): InfoRow[] {
+ const src = item.file.ext.replace(/^\./, '').toLowerCase()
+ const to =
+ item.status === 'done' && item.outputPath
+ ? extOf(item.outputPath)
+ : target
+ ? target.replace(/^\./, '').toLowerCase()
+ : ''
+ const rows: InfoRow[] = [
+ { k: 'format', v: to && to !== src ? `${src} to ${to}` : src },
+ { k: 'pixels', v: pixels(dims) },
+ {
+ k: 'size',
+ v:
+ item.status === 'done' && item.outputSize != null
+ ? `${formatBytes(item.file.size)} to ${formatBytes(item.outputSize)}`
+ : formatBytes(item.file.size)
+ }
+ ]
+ if (item.status === 'done' && item.outputPath)
+ rows.push({ k: 'output', v: item.outputPath, reveal: item.outputPath })
+ if (item.status === 'failed' && item.error)
+ rows.push({ k: 'error', v: item.error, selectable: true })
+ return rows
+}
+
+export function wipeStep(key: string, pct: number): number | null {
+ const clamp = (n: number): number => Math.max(0, Math.min(100, n))
+ if (key === 'ArrowLeft') return clamp(pct - 5)
+ if (key === 'ArrowRight') return clamp(pct + 5)
+ if (key === 'Home') return 0
+ if (key === 'End') return 100
+ return null
+}
+```
+
+(`formatBytes(2_202_010)` is `2.1 MB`, `formatBytes(497_664)` is `486 KB`, the difference `1_704_346` is `1.6 MB`, and `pctChange` gives `-77`.)
+
+- [ ] **Step 4: Run the test**
+
+Run: `npx vitest run test/info-model.test.ts`
+Expected: PASS.
+
+- [ ] **Step 5: Write the panes**
+
+```tsx
+// src/renderer/src/components/inspector/Wipe.tsx
+import { useRef, useState, type JSX } from 'react'
+import { wipeStep } from './infoModel'
+
+/** Source left, result right, a 1px fg1 divider you can drag (spec 4.6, O9). */
+export function Wipe({
+ left,
+ right,
+ leftTag,
+ rightTag
+}: {
+ left: string | null
+ right: string | null
+ leftTag: string
+ rightTag?: string
+}): JSX.Element {
+ const [pos, setPos] = useState(50)
+ const frame = useRef(null)
+ function fromPointer(clientX: number): void {
+ const r = frame.current?.getBoundingClientRect()
+ if (r) setPos(Math.max(0, Math.min(100, ((clientX - r.left) / r.width) * 100)))
+ }
+ const split = right != null
+ return (
+ {
+ if (!split) return
+ e.currentTarget.setPointerCapture(e.pointerId)
+ fromPointer(e.clientX)
+ }}
+ onPointerMove={(e) => {
+ if (split && e.buttons === 1) fromPointer(e.clientX)
+ }}
+ >
+ {left ? (
+
+ ) : (
+
{leftTag.split(' ')[0]}
+ )}
+ {split && right && (
+
+ )}
+ {split && (
+
{
+ const n = wipeStep(e.key, pos)
+ if (n == null) return
+ e.preventDefault()
+ setPos(n)
+ }}
+ />
+ )}
+ {leftTag}
+ {rightTag && {rightTag} }
+
+ )
+}
+```
+
+```tsx
+// src/renderer/src/components/inspector/PreviewPane.tsx
+import { useEffect, useState, type JSX } from 'react'
+import type { FileKind } from '@shared/types'
+import { formatBytes } from '@shared/compress'
+import type { QueueItem } from '../../state'
+import { previewRows } from './infoModel'
+import { Wipe } from './Wipe'
+
+const ext = (p: string): string => (p.split('.').pop() ?? '').toLowerCase()
+
+/** Keyed by item id in the parent, so its fetched images never leak between rows. */
+export function PreviewPane({
+ item,
+ outKind,
+ dims
+}: {
+ item: QueueItem
+ outKind: FileKind | null
+ dims: { width: number; height: number } | null
+}): JSX.Element {
+ const [srcImg, setSrcImg] = useState
(item.thumb)
+ const [outImg, setOutImg] = useState(null)
+ const out = item.status === 'done' && item.outputSize != null ? item.outputPath : undefined
+ useEffect(() => {
+ let alive = true
+ void window.filesmith
+ .thumbnail(item.file.path, 640, item.file.kind)
+ .then((t) => alive && t && setSrcImg(t))
+ if (out && outKind)
+ void window.filesmith.thumbnail(out, 640, outKind).then((t) => alive && setOutImg(t))
+ return () => {
+ alive = false
+ }
+ }, [item.file.path, item.file.kind, out, outKind])
+ return (
+ <>
+
+
+ {previewRows(item, dims).map((r) => (
+
+
{r.k}
+ {r.v}
+
+ ))}
+
+ >
+ )
+}
+```
+
+```tsx
+// src/renderer/src/components/inspector/InfoPane.tsx
+import type { JSX } from 'react'
+import type { QueueItem } from '../../state'
+import { SmallButton } from '../ui/Button'
+import { infoRows } from './infoModel'
+
+export function InfoPane({
+ item,
+ dims,
+ target
+}: {
+ item: QueueItem
+ dims: { width: number; height: number } | null
+ target: string | null
+}): JSX.Element {
+ return (
+
+ {infoRows(item, dims, target).map((r) => (
+
+
{r.k}
+
+ {r.v}
+ {r.reveal && (
+ window.filesmith.reveal(r.reveal as string)}
+ >
+ Show
+
+ )}
+
+
+ ))}
+
+ )
+}
+```
+
+Add to `theme/views.css`:
+
+```css
+.wipe {
+ cursor: default;
+ touch-action: none;
+}
+.wimg {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+}
+.wext {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ font-size: 13px;
+ color: var(--fg3);
+ text-transform: uppercase;
+}
+.wipe .line {
+ cursor: ew-resize;
+ z-index: 1;
+}
+.wipe .line::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: -6px;
+ right: -6px;
+}
+.wipe .line:focus-visible {
+ outline: 1px solid var(--focus);
+ outline-offset: 2px;
+}
+.wipe .tag {
+ z-index: 2;
+}
+```
+
+- [ ] **Step 6: Wire into `App.tsx`**
+
+Replace the Task 13 `EmptyState` branch in the inspector with:
+
+```tsx
+ ) : focused ? (
+ inspTab === 'preview' ? (
+
+ ) : (
+
+ )
+ ) : (
+
+ )}
+```
+
+Probe the focused file: in the dimension-probe effect (`App.tsx:758-787`), add `focused.file.path` to the paths it requests whenever `inspTab !== 'options'` and `focused.file.kind` is `image` or `video` (image via `imageDimensions`, video via `videoDimensions`, the same calls and the same `vDimsRequested` guard the effect already uses).
+
+- [ ] **Step 7: Verify**
+
+Run: `npx vitest run test/info-model.test.ts && npm test && npm run typecheck && npm run lint`
+Expected: all green.
+
+Run: `npm run dev`: select a converted image, open Preview: source and result side by side with tags `jpg 2.1 MB` / `webp 486 KB`, drag the divider and move it with the arrow keys; the grid shows size / saved / pixels. Open Info: `format jpg to webp`, pixels, size, output with a Show button that reveals it; a failed row shows its full error, selectable. A video row shows its frame thumbnail with one tag and no wipe.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add src/renderer/src/components/inspector src/renderer/src/theme/views.css src/renderer/src/App.tsx test/info-model.test.ts
+git commit -m "feat(inspector): preview wipe and info grid for the focused file
+
+Co-Authored-By: Claude Opus 5.5 "
+```
+
+---
+
+### Task 15: Generate, Tools, Completed and Settings views; menu, dialog and error screen
+
+**Files:**
+
+- Create in `src/renderer/src/components/views/`: `GenerateView.tsx`, `ToolsView.tsx`, `CompletedView.tsx`, `SettingsView.tsx`, `ToolStatus.tsx`
+- Modify: `src/renderer/src/components/inspector/infoModel.ts` (add `genInfoRows`)
+- Modify: `src/renderer/src/state.ts` (add `dismissAny`)
+- Modify: `src/renderer/src/components/ContextMenu.tsx`, `ConfirmDialog.tsx`, `ErrorBoundary.tsx` (restyle; `Boundary` export deleted)
+- Modify: `src/renderer/src/App.tsx` (centre per view, Completed actions across queues, Generate preview/info, menu icon names, one user-facing em-dash)
+- Modify: `src/renderer/src/theme/views.css`
+- Delete: `src/renderer/src/components/ToolsGrid.tsx`, `PromptBox.tsx`, `CompletedView.tsx` (the old one at `components/`)
+- Test: `test/queues.test.ts` (extend), `test/info-model.test.ts` (extend)
+
+**Interfaces:**
+
+- Consumes: `EmptyState` (Task 12), `SettingGroup`, `Setting`, `SmallButton`, `Checkbox`, `ProgressBar`, `Icon`; `moveItem` (Task 11) and `useRailPrefs` (Task 11); `CompletedItem`, `collectCompleted` from `components/completed.ts`; `rowView`, `kindLabel` (Task 5).
+- Produces:
+ - Action `{ type: 'dismissAny'; ids: string[]; column: 'input' | 'output' }`: hides that view of each id in whichever queue holds it (the Completed view's actions work from any tab).
+ - `genInfoRows(path: string, meta: { model: string; width: number; height: number; seed: number } | null): InfoRow[]`
+ - `GenerateView({ prompt, onPrompt, running, slots, results, aspect, canRun, focused, onRun, onCancel, onFocus, onOpen, onMenu })`
+ - `ToolsView({ onPick })`: `` with an `Tools ` header row
+ - `CompletedView({ entries, thumbs, onOpen, onReveal, onMenu, onDelete, onClear })` with `role="grid"` `aria-label="Completed"` and an `Completed `
+ - `SettingsView({ rail })` with an `Settings `; `rail` is the `useRailPrefs()` result
+
+Fix carried here: on the Completed tab `cur` is the empty `completed` queue, so the old output menu's Delete found no item and silently did nothing. Output actions now look items up across all queues and dismiss with `dismissAny`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `test/queues.test.ts`:
+
+```ts
+describe('dismissAny', () => {
+ it('hides a result in its own queue while another tab is open', () => {
+ let s = reducer(start, { type: 'addItems', files: [img('a.png')], key: CONVERT })
+ const id = s.queues[CONVERT]!.items[0].id
+ s = reducer(s, { type: 'jobEvent', event: { id, status: 'done', outputPath: 'C:/x/a.webp' } })
+ const result = s.queues[CONVERT]!.items.find((i) => i.isResult)!
+ s = reducer(s, { type: 'setTab', tab: 'completed' })
+ s = reducer(s, { type: 'dismissAny', ids: [result.id], column: 'output' })
+ expect(s.queues[CONVERT]!.items.some((i) => i.id === result.id)).toBe(false)
+ expect(s.queues[CONVERT]!.items.some((i) => i.id === id)).toBe(true) // the source stays
+ })
+})
+```
+
+Append to `test/info-model.test.ts`:
+
+```ts
+import { genInfoRows } from '../src/renderer/src/components/inspector/infoModel'
+
+describe('genInfoRows', () => {
+ it('shows model, size, seed and a revealable path', () => {
+ expect(
+ genInfoRows('C:/g/a.png', { model: 'flux1-dev', width: 1024, height: 768, seed: -1 })
+ ).toEqual([
+ { k: 'model', v: 'flux1-dev' },
+ { k: 'size', v: '1024 × 768' },
+ { k: 'seed', v: 'random' },
+ { k: 'path', v: 'C:/g/a.png', reveal: 'C:/g/a.png' }
+ ])
+ })
+ it('still shows the path for an image from an earlier session', () => {
+ expect(genInfoRows('C:/g/b.png', null)).toEqual([
+ { k: 'path', v: 'C:/g/b.png', reveal: 'C:/g/b.png' }
+ ])
+ })
+})
+```
+
+(Merge the import into the file's existing `infoModel` import.)
+
+- [ ] **Step 2: Run them and watch them fail**
+
+Run: `npx vitest run test/queues.test.ts test/info-model.test.ts`
+Expected: FAIL (unknown action; `genInfoRows` missing).
+
+- [ ] **Step 3: Implement**
+
+In `state.ts`, add to the `Action` union `| { type: 'dismissAny'; ids: string[]; column: 'input' | 'output' }` and the case:
+
+```ts
+ case 'dismissAny': {
+ const ids = new Set(action.ids)
+ const queues = { ...state.queues }
+ for (const [k, q] of Object.entries(queues) as [QueueKey, QueueState][]) {
+ if (!q.items.some((i) => ids.has(i.id))) continue
+ const items = q.items.map((i) =>
+ ids.has(i.id) ? (action.column === 'input' ? { ...i, hiddenInput: true } : { ...i, hiddenOutput: true }) : i
+ )
+ const kept = items.filter((i) => inInput(i) || inOutput(i))
+ const selectable = new Set(kept.filter(inInput).map((i) => i.id))
+ queues[k] = {
+ items: kept,
+ selected: q.selected.filter((s) => selectable.has(s)),
+ anchor: q.anchor && selectable.has(q.anchor) ? q.anchor : null
+ }
+ }
+ return { ...state, queues }
+ }
+```
+
+In `infoModel.ts`:
+
+```ts
+export function genInfoRows(
+ path: string,
+ meta: { model: string; width: number; height: number; seed: number } | null
+): InfoRow[] {
+ const rows: InfoRow[] = []
+ if (meta) {
+ rows.push({ k: 'model', v: meta.model })
+ rows.push({ k: 'size', v: `${meta.width} × ${meta.height}` })
+ rows.push({ k: 'seed', v: meta.seed < 0 ? 'random' : String(meta.seed) })
+ }
+ rows.push({ k: 'path', v: path, reveal: path })
+ return rows
+}
+```
+
+Run: `npx vitest run test/queues.test.ts test/info-model.test.ts`
+Expected: PASS.
+
+- [ ] **Step 4: Write the views**
+
+```tsx
+// src/renderer/src/components/views/ToolsView.tsx
+import type { JSX } from 'react'
+import { TOOL_CARDS, toolGroups } from '@shared/tabs'
+import { Icon } from '../icons/Icon'
+import { SettingGroup } from '../ui/Setting'
+
+/** A dense list rather than cards (spec 5.2): no colour swatches, no inspector. */
+export function ToolsView({ onPick }: { onPick: (id: string) => void }): JSX.Element {
+ return (
+ <>
+
+
Tools
+
+ {TOOL_CARDS.length} tool{TOOL_CARDS.length === 1 ? '' : 's'}
+
+
+
+ {toolGroups().map((g) => (
+
+
+ {g.cards.map((c) => (
+
+ onPick(c.id)}>
+
+ {c.label}
+ {c.desc}
+
+
+ ))}
+
+
+ ))}
+
+ >
+ )
+}
+```
+
+```tsx
+// src/renderer/src/components/views/GenerateView.tsx
+import type { JSX } from 'react'
+import { Icon } from '../icons/Icon'
+import { EmptyState } from '../queue/EmptyState'
+import { ProgressBar } from '../ui/ProgressBar'
+
+const mediaUrl = (p: string): string => `fsmedia://local/${encodeURIComponent(p)}`
+
+export function GenerateView({
+ prompt,
+ onPrompt,
+ running,
+ slots,
+ results,
+ aspect,
+ canRun,
+ focused,
+ onRun,
+ onCancel,
+ onFocus,
+ onOpen,
+ onMenu
+}: {
+ prompt: string
+ onPrompt: (v: string) => void
+ running: boolean
+ slots: { pct: number; path?: string }[]
+ results: string[]
+ aspect: string
+ canRun: boolean
+ focused: string | null
+ onRun: () => void
+ onCancel: () => void
+ onFocus: (path: string) => void
+ onOpen: (path: string) => void
+ onMenu: (path: string, x: number, y: number) => void
+}): JSX.Element {
+ const tile = (path: string, key: string): JSX.Element => (
+ onFocus(path)}
+ onDoubleClick={() => onOpen(path)}
+ onContextMenu={(e) => {
+ e.preventDefault()
+ onMenu(path, e.clientX, e.clientY)
+ }}
+ >
+
+
+ )
+ return (
+ <>
+
+
+
+ Generate
+
+ {running && (
+
+
+ Cancel
+
+ )}
+
+
+ >
+ )
+}
+```
+
+```tsx
+// src/renderer/src/components/views/CompletedView.tsx
+import { useState, type JSX, type MouseEvent } from 'react'
+import { formatBytes } from '@shared/compress'
+import type { QueueItem } from '../../state'
+import type { CompletedItem } from '../completed'
+import { Icon } from '../icons/Icon'
+import { EmptyState } from '../queue/EmptyState'
+import { ResultCell } from '../queue/ResultCell'
+import { kindLabel, rowView } from '../queue/rowModel'
+import { RowAction } from '../ui/Button'
+
+const baseName = (p: string): string => p.split(/[\\/]/).pop() ?? p
+const extOf = (p: string): string => {
+ const b = baseName(p)
+ const i = b.lastIndexOf('.')
+ return i > 0 ? b.slice(i) : ''
+}
+
+/** The files table in read-only mode (spec 5.3): name | from | kind | size | result | action. */
+export function CompletedView({
+ entries,
+ thumbs,
+ onOpen,
+ onReveal,
+ onMenu,
+ onDelete,
+ onClear
+}: {
+ entries: CompletedItem[]
+ thumbs: Record
+ onOpen: (item: QueueItem) => void
+ onReveal: (path: string) => void
+ onMenu: (item: QueueItem, x: number, y: number, ids: string[]) => void
+ onDelete: (ids: string[]) => void
+ onClear: (ids: string[]) => void
+}): JSX.Element {
+ const [picked, setPicked] = useState([])
+ const [anchor, setAnchor] = useState(null)
+ const ids = entries.map((e) => e.item.id)
+ const selected = picked.filter((id) => ids.includes(id))
+
+ function click(id: string, e: MouseEvent): void {
+ if (e.shiftKey && anchor && ids.includes(anchor)) {
+ const [a, b] = [ids.indexOf(anchor), ids.indexOf(id)].sort((x, y) => x - y)
+ setPicked(ids.slice(a, b + 1))
+ return
+ }
+ if (e.ctrlKey || e.metaKey)
+ setPicked((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]))
+ else setPicked([id])
+ setAnchor(id)
+ }
+
+ return (
+ <>
+
+
Completed
+
+ {entries.length} file{entries.length === 1 ? '' : 's'}
+
+
+ onDelete(selected)}
+ >
+
+ Delete selected
+
+ onClear(ids)}
+ title="Hides the list; files stay on disk"
+ >
+
+ Clear list
+
+
+
+
+
+ {['name', 'from', 'kind', 'size', 'result', ''].map((h, i) => (
+
+ {h}
+
+ ))}
+
+
+ {entries.length === 0 ? (
+
+ ) : (
+ entries.map(({ item, from }) => {
+ const out = item.outputPath as string
+ const thumb = thumbs[out] ?? null
+ const sel = selected.includes(item.id)
+ return (
+
click(item.id, e)}
+ onDoubleClick={() => onOpen(item)}
+ onContextMenu={(e) => {
+ e.preventDefault()
+ onMenu(item, e.clientX, e.clientY, sel ? selected : [item.id])
+ }}
+ >
+
+ {thumb ? (
+
+ ) : (
+
{kindLabel(extOf(out)).slice(0, 3)}
+ )}
+
+ {baseName(out)}
+
+
+
+ {from.toLowerCase()}
+
+
+ {kindLabel(extOf(out))}
+
+
+ {formatBytes(item.file.size)}
+
+
+
+ {
+ e.stopPropagation()
+ onReveal(out)
+ }}
+ />
+
+
+ )
+ })
+ )}
+
+
+ >
+ )
+}
+```
+
+```tsx
+// src/renderer/src/components/views/ToolStatus.tsx
+import { useEffect, useState, type JSX } from 'react'
+import { SmallButton } from '../ui/Button'
+import { Setting } from '../ui/Setting'
+
+/** Read-only status of the bundled and on-demand tools (spec 5.4), from the
+ * status calls the app already makes, plus the existing folder actions. */
+export function ToolStatus(): JSX.Element {
+ const [rar, setRar] = useState(null)
+ const [bg, setBg] = useState<{ ready: boolean; uvAvailable: boolean } | null>(null)
+ const [comfy, setComfy] = useState(undefined)
+ const [note, setNote] = useState(null)
+ useEffect(() => {
+ let alive = true
+ void window.filesmith.archiveStatus().then((s) => alive && setRar(s.rar))
+ void window.filesmith.removebgStatus().then((s) => alive && setBg(s))
+ void window.filesmith.comfyStatus().then((s) => alive && setComfy(s.folder ?? null))
+ return () => {
+ alive = false
+ }
+ }, [])
+ async function changeComfy(): Promise {
+ const folder = await window.filesmith.comfyPickFolder()
+ if (!folder) return
+ const r = await window.filesmith.comfySetFolder(folder)
+ if (r.ok) setComfy(folder)
+ setNote(r.ok ? null : (r.error ?? 'That folder could not be used.'))
+ }
+ return (
+ <>
+
+
+ {comfy} : 'Not set')}
+ >
+ void changeComfy()}>
+ Change folder
+
+
+
+ void window.filesmith.upscaleOpenModelsFolder()}>
+ Open folder
+
+
+
+ void window.filesmith.registryOpenFolder()}>
+ Open folder
+
+
+ >
+ )
+}
+```
+
+Before writing `ToolStatus`, check the exact return shapes of `archiveStatus`, `removebgStatus` and `comfyStatus` in `src/preload/index.ts` and adapt the three `.then` lines to them (for example `comfyStatus()` may expose the folder under a different key; `useComfyModels.ts` shows how the app reads it).
+
+```tsx
+// src/renderer/src/components/views/SettingsView.tsx
+import { useRef, useState, type JSX, type PointerEvent } from 'react'
+import { tabById, type TabId } from '@shared/tabs'
+import { Icon } from '../icons/Icon'
+import { moveItem } from '../shell/railPrefs'
+import { Checkbox } from '../ui/Checkbox'
+import { SettingGroup } from '../ui/Setting'
+import { ToolStatus } from './ToolStatus'
+
+type Rail = {
+ order: TabId[]
+ hidden: TabId[]
+ setOrder: (o: TabId[]) => void
+ toggleHidden: (id: TabId) => void
+}
+
+/** Sidebar order and visibility (moved from the old rail edit mode, spec 3.2 /
+ * 5.4) and tool status. Same pointer sortable as before, plus Alt+Up/Down. */
+export function SettingsView({ rail }: { rail: Rail }): JSX.Element {
+ const [drag, setDrag] = useState<{ index: number; offset: number } | null>(null)
+ const dragRef = useRef<{ index: number; offset: number } | null>(null)
+ const PITCH = 36
+
+ function begin(index: number, e: PointerEvent): void {
+ e.preventDefault()
+ const startY = e.clientY
+ const set = (d: { index: number; offset: number } | null): void => {
+ dragRef.current = d
+ setDrag(d)
+ }
+ set({ index, offset: 0 })
+ const move = (ev: globalThis.PointerEvent): void => set({ index, offset: ev.clientY - startY })
+ const up = (): void => {
+ window.removeEventListener('pointermove', move)
+ window.removeEventListener('pointerup', up)
+ const d = dragRef.current
+ set(null)
+ if (!d) return
+ const target = Math.max(
+ 0,
+ Math.min(rail.order.length - 1, d.index + Math.round(d.offset / PITCH))
+ )
+ if (target !== d.index) rail.setOrder(moveItem(rail.order, d.index, target))
+ }
+ window.addEventListener('pointermove', move)
+ window.addEventListener('pointerup', up)
+ }
+ const target = drag
+ ? Math.max(0, Math.min(rail.order.length - 1, drag.index + Math.round(drag.offset / PITCH)))
+ : null
+ function shift(i: number): number {
+ if (!drag || target == null) return 0
+ if (i === drag.index) return drag.offset
+ if (drag.index < target && i > drag.index && i <= target) return -PITCH
+ if (drag.index > target && i < drag.index && i >= target) return PITCH
+ return 0
+ }
+
+ return (
+ <>
+
+
Settings
+ sidebar and tools
+
+
+
+
+ {rail.order.map((id, i) => {
+ const t = tabById(id)
+ const shown = !rail.hidden.includes(id)
+ return (
+ {
+ if (!e.altKey || (e.key !== 'ArrowUp' && e.key !== 'ArrowDown')) return
+ e.preventDefault()
+ const to = e.key === 'ArrowUp' ? i - 1 : i + 1
+ if (to >= 0 && to < rail.order.length)
+ rail.setOrder(moveItem(rail.order, i, to))
+ }}
+ >
+ begin(i, e)} aria-hidden="true">
+
+
+
+ {t.label}
+ rail.toggleHidden(id)}
+ label={`Show ${t.label} in the sidebar`}
+ size="md"
+ focusable
+ />
+
+ )
+ })}
+
+
+
+
+
+
+ >
+ )
+}
+```
+
+- [ ] **Step 5: Restyle the menu, the dialog and the error screen**
+
+`ContextMenu.tsx`: change the import to `import { Icon } from './icons/Icon'` and `import type { IconName } from '@shared/icons'`; keep every effect (measure and flip, the five close listeners). Replace the container classes with `className="ctx-pop menu"` and render items as:
+
+```tsx
+{
+ menu.items.map((item, i) =>
+ item.sep ? (
+
+ ) : (
+ {
+ item.onClick()
+ onClose()
+ }}
+ >
+
+ {item.label}
+
+ )
+ )
+}
+```
+
+`ConfirmDialog.tsx`: keep the native `` logic, `autoFocus` rules and `onCancel`/backdrop click. Replace the markup classes:
+
+```tsx
+
+
+
+ {state.danger && }
+ {state.title}
+
+
{state.body}
+
+
+ {!state.hideCancel && (
+
+ Cancel
+
+ )}
+ {
+ state.onConfirm()
+ onClose()
+ }}
+ >
+ {state.confirmLabel}
+
+
+
+```
+
+`ErrorBoundary.tsx`: delete the unused `Boundary` export; render the fallback as
+
+```tsx
+
+
+
Something went wrong
+
{this.state.error.message}
+
+ window.location.reload()}>
+ Reload
+
+
+ Reset session
+
+
+
+```
+
+keeping its existing reset handler (the `sessionSave(null)` then reload logic at `:26-32`) under whatever name it has today.
+
+Append to `theme/views.css`:
+
+```css
+/* view header row (Tools, Completed, Settings): 32px, title like .ihead */
+.vhead {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding-left: 16px;
+ border-bottom: 1px solid var(--line);
+ min-width: 0;
+}
+.vhead h1 {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 600;
+}
+.vhead > span {
+ font: 12px var(--mono);
+ color: var(--fg3);
+}
+.vhead .tb-right {
+ align-self: stretch;
+}
+.vhead .tbtn {
+ border-right: 0;
+ border-left: 1px solid var(--line);
+ color: var(--fg2);
+}
+.vhead .tbtn:hover:not(:disabled),
+.toolbar .tbtn:not(.add):hover {
+ background: var(--hover);
+ color: var(--fg1);
+}
+.tbtn:disabled {
+ color: var(--fg-disabled);
+}
+.tbtn.add:disabled {
+ background: var(--selected);
+}
+.vbody {
+ min-height: 0;
+ overflow-y: auto;
+}
+
+/* tools list */
+.tlist {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+.trow {
+ width: 100%;
+ height: 40px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 0 16px;
+ text-align: left;
+ color: var(--fg2);
+}
+.trow:hover {
+ background: var(--hover);
+ color: var(--fg1);
+}
+.trow .tlab {
+ font-weight: 500;
+ color: var(--fg1);
+ min-width: 120px;
+}
+.trow .tdesc {
+ font-size: 12px;
+ color: var(--fg3);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* generate */
+.prompt {
+ display: block;
+ width: 100%;
+ min-height: calc(6 * 1.4em + 24px);
+ resize: vertical;
+ padding: 12px 16px;
+ background: var(--bg-0);
+ color: var(--fg1);
+ caret-color: var(--fg1);
+ border: 0;
+ border-bottom: 1px solid var(--line);
+ font: 13px/1.4 var(--sans);
+}
+.prompt::placeholder {
+ color: var(--fg3);
+}
+.prompt:focus {
+ outline: 1px solid var(--focus);
+ outline-offset: -1px;
+}
+.gen-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 1px;
+ background: var(--line);
+ border-bottom: 1px solid var(--line);
+}
+@media (max-width: 1279px) {
+ .gen-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+.gtile {
+ position: relative;
+ display: block;
+ overflow: hidden;
+ background: var(--bg-0);
+}
+.gtile img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+.gtile:hover {
+ outline: 1px solid var(--fg3);
+ outline-offset: -1px;
+}
+.gtile.sel {
+ outline: 2px solid var(--fg1);
+ outline-offset: -2px;
+}
+.gtile.pending {
+ display: grid;
+ place-items: center;
+ background: var(--bg-1);
+ color: var(--fg2);
+}
+.gtile.pending .mini {
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+}
+
+/* completed table */
+.done-table {
+ --cols: 1fr 120px 64px 88px 184px 48px;
+}
+.ccols {
+ display: grid;
+ grid-template-columns: var(--cols);
+ align-items: center;
+}
+.done-table .th {
+ cursor: default;
+}
+
+/* settings: sortable sidebar list */
+.sortlist {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+.sortrow {
+ position: relative;
+ height: 36px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 0 16px;
+ background: var(--bg-0);
+ color: var(--fg2);
+}
+.sortrow:hover {
+ background: var(--hover);
+ color: var(--fg1);
+}
+.sortrow.lifted {
+ z-index: 1;
+ background: var(--selected);
+ outline: 1px solid var(--line-strong);
+}
+.sortrow .grip {
+ cursor: grab;
+ color: var(--fg3);
+ display: grid;
+}
+.sortrow .slab {
+ flex: 1;
+}
+
+/* context menu: flush, square, no blur, no shadow */
+.menu {
+ position: fixed;
+ z-index: 50;
+ min-width: 196px;
+ padding: 4px 0;
+ background: var(--bg-0);
+ border: 1px solid var(--line-strong);
+}
+.menu-item {
+ width: 100%;
+ height: 28px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 0 12px;
+ font-size: 12px;
+ color: var(--fg2);
+ text-align: left;
+}
+.menu-item:hover,
+.menu-item:focus-visible {
+ background: var(--hover);
+ color: var(--fg1);
+}
+.menu-item.danger {
+ font-weight: 600;
+ color: var(--fg1);
+}
+.menu-sep {
+ height: 1px;
+ margin: 4px 0;
+ background: var(--line);
+}
+
+/* confirm dialog */
+.dlg {
+ margin: auto;
+ padding: 0;
+ max-width: 420px;
+ background: var(--bg-0);
+ color: var(--fg1);
+ border: 1px solid var(--line-strong);
+}
+.dlg::backdrop {
+ background: var(--backdrop);
+}
+.dlg-body {
+ padding: 18px 20px 16px;
+}
+.dlg-body h2 {
+ margin: 0;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 14px;
+ font-weight: 600;
+}
+.dlg-body p {
+ margin: 8px 0 0;
+ font-size: 13px;
+ line-height: 1.5;
+ color: var(--fg2);
+}
+.dlg-foot {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ padding: 0 20px 16px;
+}
+.dlg-foot .sbtn {
+ height: 28px;
+}
+.dlg-ok {
+ height: 28px;
+ padding: 0 14px;
+ background: var(--inv-bg);
+ color: var(--inv-fg);
+ font-weight: 600;
+ font-size: 12px;
+}
+.dlg-ok:hover {
+ background: var(--inv-hover);
+}
+.dlg-ok:active {
+ background: var(--inv-active);
+}
+
+/* render crash */
+.crash {
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ padding: 32px;
+ text-align: center;
+ background: var(--bg-0);
+ color: var(--fg1);
+}
+.crash h1 {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 600;
+}
+.crash p {
+ max-width: 520px;
+ margin: 0;
+ font-size: 12px;
+ color: var(--fg3);
+ overflow-wrap: anywhere;
+}
+.crash-actions {
+ display: flex;
+ gap: 8px;
+ margin-top: 6px;
+}
+```
+
+- [ ] **Step 6: Wire the views into `App.tsx`**
+
+1. Delete the inline `GenTile` (`:59-90`) and the imports of `ToolsGrid`, `PromptBox` and the old `CompletedView`; import the four views and `genInfoRows`.
+2. Menu icons: `'expand'` becomes `'eye'` (Open) and `'upload'` becomes `'arrow'` (Open in default app). Replace the em-dash in the "Could not delete" body with a comma: `` `${failed.join(', ')} could not be moved to the Recycle Bin, the file may be open in another app.` ``
+3. Output actions across queues:
+
+```ts
+const allItems = Object.values(state.queues).flatMap((q) => q?.items ?? [])
+```
+
+In `trashOutputs`, look items up in `allItems` instead of `cur.items`, and dispatch `{ type: 'dismissAny', ids: [id], column: 'output' }` instead of `dismiss`. In `openMenu`, add an optional fifth parameter `targetIds?: string[]`; on the output side use `targetIds ?? [item.id]` for `targets` (the Completed view passes its own selection).
+
+4. Generate metadata for Info: `const genMeta = useRef>({})`, and in `generate()`'s `onGenerateImage` handler, when `p.id === id`, record `genMeta.current[p.path] = { model: String(opts.model ?? ''), width: Number(opts.width ?? 1024), height: Number(opts.height ?? 1024), seed: Number(opts.seed ?? -1) }`. Add `const [genFocus, setGenFocus] = useState(null)`.
+5. Centre content by context:
+
+```tsx
+ {onCompleted ? (
+ openExternally('output', item)}
+ onReveal={(p) => window.filesmith.reveal(p)}
+ onMenu={(item, x, y, ids) => openMenu('output', item, x, y, ids)}
+ onDelete={(ids) =>
+ setConfirm({
+ title: ids.length === 1 ? 'Delete this file?' : `Delete ${ids.length} files?`,
+ body: 'They will be moved to the Recycle Bin.',
+ confirmLabel: 'Delete',
+ danger: true,
+ onConfirm: () => void trashOutputs(ids)
+ })
+ }
+ onClear={(ids) => dispatch({ type: 'dismissAny', ids, column: 'output' })}
+ />
+ ) : state.tab === 'settings' ? (
+
+ ) : onToolsGrid ? (
+ dispatch({ type: 'setActiveTool', tool: id })} />
+ ) : tool === 'generate' ? (
+ onSet('prompt', v)}
+ running={genRun.running}
+ slots={genRun.slots}
+ results={genResults}
+ aspect={genAspect}
+ canRun={runCount > 0}
+ focused={genFocus}
+ onRun={() => void run()}
+ onCancel={() => {
+ if (genActiveId.current) window.filesmith.generateCancel(genActiveId.current)
+ }}
+ onFocus={setGenFocus}
+ onOpen={(p) => window.filesmith.openFile(p)}
+ onMenu={openGenMenu}
+ />
+ ) : (
+ /* the Task 12 toolbar + table */
+ )}
+```
+
+6. Generate in the inspector: when `tool === 'generate'` and `inspTab !== 'options'`, render for `genFocus` instead of `focused`: Preview is `png `; Info is a `dl.dgrid` over `genInfoRows(genFocus, genMeta.current[genFocus] ?? null)` rendered exactly like `InfoPane`'s rows. With no `genFocus`, show the same `EmptyState` as before. The inspector title for Generate on Preview/Info is the file's base name.
+7. `previewGen` (used by the old tile) is replaced by `setGenFocus` plus `setInspTab('preview')`; keep the menu's "Open in default app" item calling `window.filesmith.openFile`.
+
+- [ ] **Step 7: Delete the replaced components**
+
+```bash
+git rm src/renderer/src/components/ToolsGrid.tsx src/renderer/src/components/PromptBox.tsx src/renderer/src/components/CompletedView.tsx
+```
+
+- [ ] **Step 8: Verify**
+
+Run: `npm test && npm run typecheck && npm run lint`
+Expected: all green.
+
+Run: `npm run dev` and check: Generate (prompt with a bottom hairline, toolbar Generate and Cancel, square tiles separated by 1px lines, pending tiles with a mono % and a bottom bar, startup message in the status bar with the indeterminate bar, Preview and Info of a clicked image); Tools (header `Tools`, `PDF` group, 40px rows, a click opens the workspace titled with the card label, breadcrumb `tools / merge` goes back); Completed (empty state, then rows with from / kind / size / result and the split arrow, Show in folder, Delete selected asks first and really moves the file to the Recycle Bin, Clear list hides rows but keeps files); Settings (drag a verb by its grip, Alt+Up/Down moves it, unticking hides it from the sidebar, the order survives a restart, tool status reads correctly, Change folder works); right-click menu (square, no blur, danger items bold not red); the large-upscale confirm (warning glyph, Cancel focused); and the crash screen (temporarily `throw` in a component under `npm run dev`, check it, then remove the throw).
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add -A src/renderer/src src/renderer/src/theme/views.css test/queues.test.ts test/info-model.test.ts
+git commit -m "feat(views): generate, tools, completed and settings in the workbench language; fix completed delete
+
+Co-Authored-By: Claude Opus 5.5 "
+```
+
+---
+
+### Task 16: Monochrome sweep, colour fields removed, dead code, guard test
+
+**Files:**
+
+- Modify: `src/shared/tabs.ts` (drop `color` from `Tab`, `ToolCard` and every entry)
+- Modify: `src/renderer/src/components/queueGroups.ts` (drop `GROUP_COLOR`)
+- Modify: `test/group-naming.test.ts` (drop the `GROUP_COLOR` block)
+- Delete: `src/renderer/src/components/Icon.tsx` (the old 24px registry)
+- Test: `test/monochrome-source.test.ts`
+
+**Interfaces:**
+
+- Consumes: everything above.
+- Produces: no colour literal outside `theme/tokens.css` in `src/renderer/src`, enforced by a test.
+
+- [ ] **Step 1: Write the failing guard**
+
+```ts
+// test/monochrome-source.test.ts
+import { describe, expect, it } from 'vitest'
+import { readdirSync, readFileSync, statSync } from 'fs'
+import { join, relative, sep } from 'path'
+
+const ROOT = join(__dirname, '../src/renderer/src')
+
+function walk(dir: string): string[] {
+ return readdirSync(dir).flatMap((n) => {
+ const p = join(dir, n)
+ return statSync(p).isDirectory() ? walk(p) : [p]
+ })
+}
+
+const grey = (h: string): boolean => {
+ const x =
+ h.length === 3
+ ? h
+ .split('')
+ .map((c) => c + c)
+ .join('')
+ : h
+ return x.slice(0, 2) === x.slice(2, 4) && x.slice(2, 4) === x.slice(4, 6)
+}
+
+describe('strict monochrome source', () => {
+ it('has no non-grey colour literal outside theme/tokens.css', () => {
+ const bad: string[] = []
+ for (const f of walk(ROOT)) {
+ if (!/\.(tsx?|css)$/.test(f) || f.endsWith(`theme${sep}tokens.css`)) continue
+ const src = readFileSync(f, 'utf-8')
+ for (const m of src.matchAll(/#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b/g))
+ if (!grey(m[1].toLowerCase())) bad.push(`${relative(ROOT, f)}: #${m[1]}`)
+ for (const m of src.matchAll(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/g))
+ if (!(m[1] === m[2] && m[2] === m[3])) bad.push(`${relative(ROOT, f)}: ${m[0]}`)
+ }
+ expect(bad).toEqual([])
+ })
+
+ it('carries no light-theme utility classes', () => {
+ const bad: string[] = []
+ const light =
+ /\b(text-ink|text-muted|text-dim|bg-canvas|bg-accent|text-accent|border-accent|bg-white|border-black\/|rounded-(lg|xl|2xl|full|\[))/
+ for (const f of walk(ROOT)) {
+ if (!/\.tsx?$/.test(f)) continue
+ const src = readFileSync(f, 'utf-8')
+ if (light.test(src)) bad.push(relative(ROOT, f))
+ }
+ expect(bad).toEqual([])
+ })
+})
+```
+
+- [ ] **Step 2: Run it and watch it fail**
+
+Run: `npx vitest run test/monochrome-source.test.ts`
+Expected: FAIL, listing `components/queueGroups.ts` (`GROUP_COLOR`) and the old `components/Icon.tsx` if it still holds literals, plus anything earlier tasks missed.
+
+- [ ] **Step 3: Remove the colour data and the old registry**
+
+```bash
+sed -i -E "/^\s*color: '#[0-9a-fA-F]{6}',?$/d; /^\s*color: string$/d" src/shared/tabs.ts
+grep -n "color" src/shared/tabs.ts
+grep -rn "components/Icon'\|from './Icon'\|from '../Icon'" src/renderer/src
+git rm src/renderer/src/components/Icon.tsx
+```
+
+Expected: the first `grep` prints nothing (re-check the `Tab` / `ToolCard` interfaces by eye for any leftover `color` doc comment), the second prints nothing before the `git rm`. Delete the `GROUP_COLOR` constant and its comment from `queueGroups.ts`, and the `describe('GROUP_COLOR', ...)` block from `test/group-naming.test.ts` (its `groupNoun` tests stay).
+
+- [ ] **Step 4: Fix every remaining hit**
+
+Re-run `npx vitest run test/monochrome-source.test.ts` and fix each listed file by replacing the literal or class with a token or a `views.css` rule. Then sweep user-facing copy for em-dashes:
+
+```bash
+grep -rnP "\x{2014}" src/renderer/src --include=*.tsx | grep -v "^\s*//\|{/\*"
+```
+
+Rewrite every hit that is user-visible text (JSX text, `title`, `aria-label`, dialog bodies, menu labels) with a comma or a colon.
+
+- [ ] **Step 5: Verify**
+
+Run: `npm test && npm run typecheck && npm run lint && npx prettier --check .`
+Expected: all green. If prettier fails, run `npm run format` and re-check.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add -A src test
+git commit -m "refactor(ui): enforce strict monochrome, drop verb and group colours and the old icon registry
+
+Co-Authored-By: Claude Opus 5.5 "
+```
+
+---
+
+### Task 17: End-to-end: selector updates, UI behaviour, screenshots
+
+**Files:**
+
+- Modify: `e2e/smoke.spec.ts:74-80`, `e2e/workflows.spec.ts:172-203`
+- Create: `e2e/ui.spec.ts`, `e2e/visual.spec.ts`
+
+**Interfaces:**
+
+- Consumes: the DOM contracts from Tasks 11 to 15: `nav[aria-label="Operations"]` with one button per tab; inspector ``; `data-testid="run"`; `role="grid"` named `Files`; `section[aria-label="Tools"]`; the `Back to Tools` breadcrumb button; the sidebar toggle's `aria-expanded`; `role="tab"` inspector tabs; env `FILESMITH_USER_DATA` (Task 1).
+- Produces: e2e coverage for navigation, sidebar collapse and persistence, inspector tabs, the empty queue, and screenshots in `docs/mockups/terminal-v5/shots/impl-*.png`.
+
+- [ ] **Step 1: Update the smoke boot test**
+
+Replace the body of `test('the app boots to the Convert workspace', ...)` with:
+
+```ts
+await expect(page.locator('h1', { hasText: 'Convert' }).first()).toBeVisible()
+// The pinned Run button is reachable without scrolling and names the verb.
+const run = page.getByTestId('run')
+await expect(run).toBeVisible()
+await expect(run).toHaveText(/Convert/)
+```
+
+(Keep any surrounding lines of that test that are not about these two locators.)
+
+- [ ] **Step 2: Update the workflow navigation tests**
+
+```ts
+/** Tools always opens on its grid, however you last left it. */
+async function openToolsGrid(p: Page): Promise {
+ await p
+ .getByRole('navigation', { name: 'Operations' })
+ .getByRole('button', { name: 'Tools' })
+ .click()
+ await expect(p.locator('h1', { hasText: 'Tools' }).first()).toBeVisible()
+}
+
+test('the rail lists every verb and opens its workspace', async () => {
+ const nav = page.getByRole('navigation', { name: 'Operations' })
+ for (const verb of ['Convert', 'Compress', 'Resize', 'Upscale', 'Remove BG', 'Generate']) {
+ await nav.getByRole('button', { name: verb }).click()
+ await expect(page.locator('h1', { hasText: verb }).first()).toBeVisible()
+ }
+ await openToolsGrid(page)
+ await nav.getByRole('button', { name: 'Convert' }).click()
+})
+
+test('Tools groups its one-off verbs and opens one as a workspace', async () => {
+ await openToolsGrid(page)
+ const tools = page.getByRole('region', { name: 'Tools' })
+ for (const t of ['Extract text', 'Pages to PNG', 'Merge', 'Split', 'Burst'])
+ await expect(tools.getByText(t, { exact: true }).first()).toBeVisible()
+ await expect(page.locator('text=Archive to PDF')).toHaveCount(0)
+ await expect(page.locator('text=PDF to CBZ')).toHaveCount(0)
+ await tools.getByRole('button', { name: /^Merge/ }).click()
+ await expect(page.locator('h1', { hasText: 'Merge' }).first()).toBeVisible()
+ await expect(page.getByRole('grid', { name: 'Files' })).toBeVisible()
+ await page.locator('button[aria-label="Back to Tools"]').first().click()
+ await expect(page.locator('h1', { hasText: 'Tools' }).first()).toBeVisible()
+ await page
+ .getByRole('navigation', { name: 'Operations' })
+ .getByRole('button', { name: 'Convert' })
+ .click()
+})
+```
+
+Keep the existing test names (other tooling may grep them) and replace only these bodies and the helper.
+
+- [ ] **Step 3: Add `e2e/ui.spec.ts`**
+
+```ts
+import { _electron, type ElectronApplication, type Page } from 'playwright'
+import { test, expect } from '@playwright/test'
+import { existsSync, mkdtempSync, rmSync } from 'fs'
+import { tmpdir } from 'os'
+import { join } from 'path'
+import { MAIN, ROOT } from './helpers'
+
+// UI behaviour the unit suite cannot reach: the sidebar, the inspector tabs and
+// the empty workspace. A private userData keeps the user's real session and
+// sidebar preference out of it.
+let app: ElectronApplication
+let page: Page
+let userData: string
+
+test.beforeAll(async () => {
+ test.skip(!existsSync(MAIN), 'run `npm run build` first')
+ userData = mkdtempSync(join(tmpdir(), 'filesmith-ui-'))
+ app = await _electron.launch({
+ args: [ROOT],
+ env: { ...process.env, FILESMITH_USER_DATA: userData }
+ })
+ page = await app.firstWindow()
+ await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1440, 900))
+})
+
+test.afterAll(async () => {
+ await app?.close()
+ if (userData) rmSync(userData, { recursive: true, force: true })
+})
+
+test('the app is dark and square', async () => {
+ const bg = await page.evaluate(() => getComputedStyle(document.body).backgroundColor)
+ expect(bg).toBe('rgb(10, 10, 10)')
+ const radius = await page.getByTestId('run').evaluate((el) => getComputedStyle(el).borderRadius)
+ expect(radius).toBe('0px')
+})
+
+test('the sidebar collapses with its toggle and Ctrl+B, and remembers it', async () => {
+ const nav = page.getByRole('navigation', { name: 'Operations' })
+ const toggle = nav.getByRole('button', { name: /sidebar/ })
+ await expect(toggle).toHaveAttribute('aria-expanded', 'true')
+ await toggle.click()
+ await expect(toggle).toHaveAttribute('aria-expanded', 'false')
+ await expect.poll(async () => (await nav.boundingBox())?.width).toBe(48)
+ await page.reload()
+ await expect(
+ page.getByRole('navigation', { name: 'Operations' }).getByRole('button', { name: /sidebar/ })
+ ).toHaveAttribute('aria-expanded', 'false')
+ await page.keyboard.press('Control+B')
+ await expect(
+ page.getByRole('navigation', { name: 'Operations' }).getByRole('button', { name: /sidebar/ })
+ ).toHaveAttribute('aria-expanded', 'true')
+ await expect
+ .poll(
+ async () => (await page.getByRole('navigation', { name: 'Operations' }).boundingBox())?.width
+ )
+ .toBe(208)
+})
+
+test('inspector tabs switch with a click and the arrow keys', async () => {
+ const tabs = page.getByRole('tablist', { name: 'Inspector' })
+ const options = tabs.getByRole('tab', { name: 'Options' })
+ const preview = tabs.getByRole('tab', { name: 'Preview' })
+ const info = tabs.getByRole('tab', { name: 'Info' })
+ await preview.click()
+ await expect(preview).toHaveAttribute('aria-selected', 'true')
+ await page.keyboard.press('ArrowRight')
+ await expect(info).toHaveAttribute('aria-selected', 'true')
+ await page.keyboard.press('Home')
+ await expect(options).toHaveAttribute('aria-selected', 'true')
+ // Switching tab must not shift the row: the bold width is reserved.
+ const before = await options.boundingBox()
+ await info.click()
+ expect((await options.boundingBox())?.width).toBe(before?.width)
+ await options.click()
+})
+
+test('an empty workspace offers Add files and a disabled Run', async () => {
+ await expect(page.getByText('No files yet')).toBeVisible()
+ await expect(
+ page.getByRole('grid', { name: 'Files' }).getByRole('button', { name: 'Add files' })
+ ).toBeVisible()
+ await expect(page.getByTestId('run')).toBeDisabled()
+ await expect(page.getByText('no files', { exact: true })).toBeVisible()
+})
+```
+
+Add `export` to `ROOT` and `MAIN` in `e2e/helpers.ts` if they are not already exported (the map says they are).
+
+- [ ] **Step 4: Add `e2e/visual.spec.ts`** (screenshots for the owner's side-by-side; no pixel assertions, spec 7.3)
+
+```ts
+import { _electron, type ElectronApplication, type Page } from 'playwright'
+import { test, expect } from '@playwright/test'
+import { execFileSync } from 'child_process'
+import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'fs'
+import { tmpdir } from 'os'
+import { join } from 'path'
+import { MAGICK, MAIN, ROOT, magickEnv } from './helpers'
+
+const SHOTS = join(ROOT, 'docs', 'mockups', 'terminal-v5', 'shots')
+let app: ElectronApplication
+let page: Page
+let userData: string
+let files: string
+
+const NAMES = [
+ 'IMG_2041.heic',
+ 'beach-panorama.png',
+ 'scan_0007.tiff',
+ 'portrait.jpg',
+ 'diagram.png',
+ 'IMG_2042.heic'
+]
+
+function make(name: string, size: string): string {
+ const p = join(files, name)
+ // A noisy image so sizes look realistic; HEIC falls back to JPG bytes if this magick lacks the encoder.
+ execFileSync(MAGICK, ['-size', size, 'plasma:', p], { env: magickEnv })
+ return p
+}
+
+test.beforeAll(async () => {
+ // Opt-in: the shots are tracked files, so a routine `npm run test:e2e` must not rewrite them.
+ test.skip(
+ !process.env['FILESMITH_SHOTS'],
+ 'set FILESMITH_SHOTS=1 to capture the redesign screenshots'
+ )
+ test.skip(!existsSync(MAIN) || !existsSync(MAGICK), 'needs `npm run build` and resources/bin')
+ userData = mkdtempSync(join(tmpdir(), 'filesmith-visual-'))
+ files = join(userData, 'files')
+ mkdirSync(files)
+ const paths = NAMES.map((n, i) =>
+ make(
+ n.endsWith('.heic') ? n.replace('.heic', '.jpg') : n,
+ i === 2 ? '6000x4000' : i === 1 ? '3000x2000' : '1600x1200'
+ )
+ )
+ const outs = [make('IMG_2041.webp', '800x600'), make('beach-panorama.webp', '1200x800')]
+ const info = (
+ p: string
+ ): { path: string; name: string; ext: string; kind: string; size: number } => {
+ const name = p.split(/[\\/]/).pop() as string
+ return {
+ path: p,
+ name,
+ ext: '.' + name.split('.').pop(),
+ kind: 'image',
+ size: statSync(p).size
+ }
+ }
+ const item = (i: number, over: Record): Record => ({
+ id: `seed-${i}`,
+ file: info(paths[i]),
+ thumb: null,
+ status: 'ready',
+ percent: 0,
+ ...over
+ })
+ const session = {
+ version: 2,
+ lastTool: null,
+ options: { 'convert:image': { format: '.webp', quality: 'balanced' } },
+ genResults: [],
+ queues: {
+ convert: {
+ items: [
+ item(0, {
+ status: 'done',
+ percent: 100,
+ outputPath: outs[0],
+ outputSize: statSync(outs[0]).size,
+ runOptions: { format: '.webp', quality: 'balanced' }
+ }),
+ item(1, {
+ status: 'done',
+ percent: 100,
+ outputPath: outs[1],
+ outputSize: statSync(outs[1]).size,
+ runOptions: { format: '.webp', quality: 'balanced' }
+ }),
+ item(2, {}),
+ item(3, {}),
+ item(4, {}),
+ item(5, {
+ status: 'failed',
+ error: 'Unsupported compression in this TIFF variant. Convert it to PNG first.'
+ })
+ ]
+ }
+ }
+ }
+ writeFileSync(join(userData, 'session.json'), JSON.stringify(session))
+ app = await _electron.launch({
+ args: [ROOT],
+ env: { ...process.env, FILESMITH_USER_DATA: userData }
+ })
+ page = await app.firstWindow()
+ await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1440, 900))
+ await expect(
+ page.getByRole('grid', { name: 'Files' }).getByRole('row', { name: /portrait/ })
+ ).toBeVisible()
+})
+
+test.afterAll(async () => {
+ await app?.close()
+ if (userData) rmSync(userData, { recursive: true, force: true })
+})
+
+test('capture the redesign next to the mockup', async () => {
+ // Select one row, as the mockup does, then start a real job on another so a
+ // running row with NN%(Ns) is on screen.
+ await page.getByRole('row', { name: /beach-panorama/ }).click()
+ await page.evaluate(
+ ({ input }) =>
+ window.filesmith.runJob({
+ id: 'seed-2',
+ tool: 'resize',
+ input,
+ options: { mode: 'percent', percent: 300 }
+ }),
+ { input: join(files, NAMES[2]) }
+ )
+ await expect(page.getByRole('row', { name: /scan_0007/ }).getByText(/%/)).toBeVisible({
+ timeout: 20_000
+ })
+ await page.screenshot({ path: join(SHOTS, 'impl-10-s2.png') })
+
+ await page
+ .getByRole('navigation', { name: 'Operations' })
+ .getByRole('button', { name: /sidebar/ })
+ .click()
+ await page.screenshot({ path: join(SHOTS, 'impl-collapsed.png') })
+ await page.keyboard.press('Control+B')
+
+ await page.getByRole('tab', { name: 'Preview' }).click()
+ await page.screenshot({ path: join(SHOTS, 'impl-preview.png') })
+ await page.getByRole('tab', { name: 'Info' }).click()
+ await page.screenshot({ path: join(SHOTS, 'impl-info.png') })
+ await page.getByRole('tab', { name: 'Options' }).click()
+
+ const nav = page.getByRole('navigation', { name: 'Operations' })
+ for (const [label, file] of [
+ ['Compress', 'impl-empty.png'],
+ ['Generate', 'impl-generate.png'],
+ ['Tools', 'impl-tools.png'],
+ ['Completed', 'impl-completed.png'],
+ ['Settings', 'impl-settings.png']
+ ]) {
+ await nav.getByRole('button', { name: label }).click()
+ await page.screenshot({ path: join(SHOTS, file) })
+ }
+})
+```
+
+The seeded `running`/`queued` states cannot be written into the session (restore settles them to `ready`, `state.ts:189-199`), so the running row comes from a real slow job; the two "queued" mockup rows show as ready (blank status). Say so in the PR.
+
+- [ ] **Step 5: Run the whole e2e suite**
+
+Run: `npm run build && npm run test:e2e`
+Expected: every spec passes (engine specs unchanged; `smoke`, `workflows`, `ui` green; `visual` skipped).
+
+Run: `FILESMITH_SHOTS=1 npx playwright test e2e/visual.spec.ts` (PowerShell: `$env:FILESMITH_SHOTS='1'; npx playwright test e2e/visual.spec.ts; Remove-Item Env:FILESMITH_SHOTS`)
+Expected: PASS, and the nine `impl-*.png` files are written next to the mockup shot.
+
+- [ ] **Step 6: Compare by hand**
+
+Open `docs/mockups/terminal-v5/shots/10-s2-vscode-grouped.png` and `impl-10-s2.png` side by side. Check, and fix in the owning task's files before continuing: 32/24px bars; 208px sidebar; table column widths; the split arrow position; the selected row's 2px bar; inspector 340px with the 48px footer; group headers FORMAT / OUTPUT / FILES; the estimate card; IBM Plex in every text; no colour anywhere.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add e2e docs/mockups/terminal-v5/shots/impl-*.png
+git commit -m "test(e2e): scoped selectors, sidebar and inspector behaviour, redesign screenshots
+
+Co-Authored-By: Claude Opus 5.5 "
+```
+
+---
+
+### Task 18: Version, docs, full gate, install, PR
+
+**Files:**
+
+- Modify: `package.json`, `package-lock.json` (version `0.5.0`)
+- Modify: `CLAUDE.md` (including the owner's pending uncommitted edit)
+- Modify: `README.md`, `docs/screenshots/*.png`
+
+**Interfaces:**
+
+- Consumes: everything above.
+- Produces: PR `feat/-terminal-redesign` into `main`, unmerged, with the gate results and screenshots.
+
+- [ ] **Step 1: Bump the version**
+
+```bash
+npm version 0.5.0 --no-git-tag-version
+```
+
+- [ ] **Step 2: Update `CLAUDE.md`**
+
+First run `git diff CLAUDE.md` and keep the owner's pending edit intact. Then:
+
+- In "Design process", replace "The current renderer is a deliberately plain placeholder until that design work happens." with: "The renderer implements the signed-off terminal design (`docs/mockups/terminal-v5/10-s2-vscode-grouped.html`, rules in `docs/design/redesign-direction.md`). Dark only, strict monochrome: every colour is a token in `src/renderer/src/theme/tokens.css`, and `test/monochrome-source.test.ts` fails on any other colour literal. New screens still go through mockups first."
+- In "Project layout", replace the `renderer/` line with `renderer/ React UI: shell/ (title bar, sidebar, status bar), queue/ (files table), inspector/ (Options, Preview, Info), options/ (one settings file per verb), views/ (Generate, Tools, Completed, Settings), ui/ (primitives), icons/, theme/ (tokens and CSS)`.
+- In "Working with the owner", replace "README-only changes commit directly." with "Every change, README included, goes through a branch and PR (global rule)."
+
+Keep it under 200 lines.
+
+- [ ] **Step 3: Refresh the README screenshots**
+
+Copy `impl-10-s2.png` to `docs/screenshots/queue.png`, `impl-generate.png` to `docs/screenshots/generate.png` and `impl-preview.png` to `docs/screenshots/image-preview.png`. Capture an Upscale workspace with a file selected (run the app with the visual userData from Task 17, or add one more entry to the visual loop) as `docs/screenshots/upscale.png`. In `README.md`, rewrite the four `alt` and caption texts that the redesign changed (the viewer caption becomes "Preview, side by side with the result"), using commas instead of em-dashes on every line you touch.
+
+- [ ] **Step 4: Run the full gate**
+
+```bash
+npm test && npm run typecheck && npm run lint && npx prettier --check . && npm run build && npm run test:e2e
+```
+
+Expected: every step green. Record the counts (unit tests passed, e2e specs passed) for the PR body.
+
+- [ ] **Step 5: Package, install, launch**
+
+```bash
+npm run package
+```
+
+Wait for electron-builder to finish (the installer is written incrementally). Then, in PowerShell:
+
+```powershell
+Get-Process Filesmith -ErrorAction SilentlyContinue | Stop-Process -Force
+Start-Process -Wait "dist\Filesmith-Setup-x64-0.5.0.exe" -ArgumentList '/S'
+Start-Process "$env:LOCALAPPDATA\Programs\Filesmith\Filesmith.exe"
+(Get-Item "$env:LOCALAPPDATA\Programs\Filesmith\Filesmith.exe").VersionInfo.ProductVersion
+```
+
+Expected: the installed app launches dark and reports `0.5.0`. Check every view by hand at 1440x900 and at the 1100x640 minimum (sidebar auto-collapsed below 1280, no horizontal scroll, name column ellipsis), once with the keyboard only, and once with Windows "Show animations" off (reduced motion: the sidebar snaps, no sweep).
+
+- [ ] **Step 6: Commit and push**
+
+```bash
+git add package.json package-lock.json CLAUDE.md README.md docs/screenshots
+git commit -m "chore: 0.5.0, docs and screenshots for the terminal redesign
+
+Co-Authored-By: Claude Opus 5.5 "
+git push -u origin feat/-terminal-redesign
+```
+
+- [ ] **Step 7: Open the PR**
+
+```bash
+gh pr create --base main --title "feat: terminal redesign (S2 VS Code grouped)" --body "Closes #.
+
+Implements docs/superpowers/specs/2026-10-04-terminal-redesign-design.md against the signed-off mockup docs/mockups/terminal-v5/10-s2-vscode-grouped.html.
+
+What changes
+- Dark-only, strictly monochrome, square workbench: title bar with breadcrumb, collapsible sidebar (Ctrl+B, remembered), files table with result arrow on the size/result split and NN%(Ns) progress, Options / Preview / Info inspector, status bar.
+- Options split into one grouped settings file per verb; estimate card; OUTPUT > Location (new optional engine outDir, collision safety unchanged).
+- Remaining-seconds estimate for non-ffmpeg jobs; per-row and batch size estimates.
+- Settings view (sidebar order and visibility, tool status); Completed as a read-only table.
+- Fixes: archive from-pdf page quality now reaches the engine; Completed delete now works from the Completed tab.
+
+Owner checks O1 to O10 are built as proposed in spec section 5 (O7: Location shipped, If file exists fixed, Naming and Keep metadata deferred).
+
+Verification
+- npm test, typecheck, lint, prettier --check, build, test:e2e: all green ().
+- Installed dist/Filesmith-Setup-x64-0.5.0.exe locally; launched and checked every view at 1440x900 and 1100x640, keyboard only, reduced motion.
+- Screenshots: docs/mockups/terminal-v5/shots/impl-*.png next to 10-s2-vscode-grouped.png. The two queued mockup rows appear as ready because a restored session settles in-flight rows.
+
+Unsigned build, as before.
+
+🤖 Generated with [Claude Code](https://claude.com/claude-code)"
+```
+
+- [ ] **Step 8: Stop and ask**
+
+Report the PR link, the gate results and a one-line recommendation, then ask "merge?" once. Do not merge, enable auto-merge or push to `main` without the owner's explicit approval of this PR. After an approved squash-merge: delete the branch locally and remotely, install the merged release build and confirm it reports `0.5.0`.
+
+---
+
+## Self-Review
+
+**Spec coverage**
+
+| Spec section | Task |
+| ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 1 hard rules (dark, monochrome, square, flush, buttons, no drop row, no row bar, no Wind signatures) | 8, 10, 12, 16 (guard test), 17 (dark and square e2e) |
+| M1 window, nativeTheme, background | 1 |
+| M2 ETA for ticker jobs | 2 |
+| M3 output folder | 3, 13 (OUTPUT > Location) |
+| M4 userData override | 1, 17 |
+| M5 bundled fonts | 8 |
+| 2.1 tokens + Tailwind mirror + monochrome tests | 8, 16 |
+| 2.2 type, 2.3 lines / focus / motion | 8, 10 |
+| 2.4 icons, typed `IconName` | 9 (window controls keep their own 10x10 paths in `TitleBar`, so `minimize`/`maximize` are not registry entries) |
+| 2.5 component inventory | 10, 11, 12, 13, 15 |
+| 2.6 cross-group rows | 12 (`.tr.dim`) |
+| 2.7 select popup | 10 |
+| 3.1 title bar and breadcrumb | 11 |
+| 3.2 sidebar, collapse, Ctrl+B, rail edit moved | 11, 15 |
+| 3.3 toolbar, table, group rows | 12 |
+| 3.4 inspector, 3.5 status bar | 11, 13 |
+| 3.6 views replacing the table | 15 |
+| 3.7 window size, auto-collapse under 1280, kind fold under 1180 | 1, 11, 12 (`.ktag` and the `max-width: 1179px` rule; at the 1100px minimum with the sidebar auto-collapsed the name column gets about 170px instead of about 100px) |
+| 4.1 selection, checkbox column, keyboard, sorting, run scope | 4, 6, 11, 12 |
+| 4.2 options per verb + clean-ups + pageQuality fix | 13 |
+| 4.3 result and status columns, retry | 5, 12 |
+| 4.4 totals, 4.5 batch estimate | 5, 7, 12, 13 |
+| 4.6 Preview, 4.7 Info | 14, 15 (Generate) |
+| 4.8 toolbar actions | 4 (`hideFinished`), 12 |
+| 4.9 drops | 11, 12 |
+| 4.10 menu, 4.11 dialogs | 15 |
+| 4.12 empty states | 12, 15 |
+| 5.1 to 5.6 undrawn screens | 13 (setup cards), 15 |
+| 6.1 source-to-result link | 4 |
+| 6.2 estimates, 6.3 ETA, 6.4 totals and batch, 6.5 short error | 7, 2, 5, 11 |
+| 7.1 unit tests | 1 to 7, 9 to 11, 13 to 16 |
+| 7.2 e2e selectors | 17 |
+| 7.3 screenshots, 7.4 gate | 17, 18 |
+| 9 delivery | 0, 18 |
+
+**Deviations and additions to flag in review**
+
+- `window.filesmith.pickFolder()` is a new, additive preload method and IPC channel (Task 1, listed in spec M3). OUTPUT > Location needs a folder picker and the existing `comfyPickFolder` is titled and seeded for ComfyUI. No existing method changes.
+- Run scope (spec O10): today Run acts only on the selection; spec 4.1 says it acts on the whole first group when nothing is selected. Task 12 implements the spec. This is a behaviour change worth the owner's eye.
+- Found while reviewing: a retried PDF merge row would fail (its stored options had no input list); Task 12 stores `mergeInputs` in `runOptions`. A stale ETA would freeze at `(1s)` once a ticker ran past its estimate; Task 4 clears it. Non-runnable Generate models stay selectable (Task 13), as today.
+- The `Settings` id joins `TabId` (Task 4) but not `TABS`, so `tabs.test.ts`'s seven-verb pin holds.
+- OUTPUT / FILES groups render only on Convert (non-archive routes), exactly as spec 4.2 lists them; the engine honours `outDir` for every tool if a later design adds the setting elsewhere.
+- Found while planning: deleting from the Completed tab was a silent no-op (its lookups used the empty `completed` queue). Fixed in Task 15 with `dismissAny`.
+
+**Type consistency check**
+
+`SetOption`, `SizeRow`, `SelectOption`, `RowActionKind`, `SortState`, `CheckState`, `Totals`, `StatusSummary`, `InspTab`, `Crumb` and `InfoRow` are each defined once (Tasks 5, 6, 10, 11, 13, 14) and imported by those names wherever used. `reserveOutPath`'s fourth parameter and `ToolContext.outDir` (Task 3) are the only engine signature changes; `estimateProgress`'s callback gains a second argument (Task 2), and every caller is updated in the same task.
+
+**Review Focus cross-check**
+
+Each Review Focus line has its test: missing output folder (Task 3, `resolveOutDir`), zero-byte and folder outputs (Task 5, `pctChange` and `rowView` folder case), re-run and old-session rows (Task 4 `markQueued` clears, Task 5 "done from an old session"), header checkbox across groups (Task 6), estimator nulls and partial batches (Task 7).
diff --git a/docs/superpowers/specs/2026-10-04-terminal-redesign-design.md b/docs/superpowers/specs/2026-10-04-terminal-redesign-design.md
new file mode 100644
index 0000000..32c8d4c
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-04-terminal-redesign-design.md
@@ -0,0 +1,663 @@
+# Terminal redesign (S2 "VS Code grouped") – design
+
+Date: 2026-10-04
+Status: proposed
+
+Target: `docs/mockups/terminal-v5/10-s2-vscode-grouped.html`
+(screenshot `docs/mockups/terminal-v5/shots/10-s2-vscode-grouped.png`), locked in by the owner on
+2026-10-04. The feedback trail and hard rules are in `docs/design/redesign-direction.md`.
+
+## 1. Goal and scope
+
+Rebuild the renderer so that it matches the signed-off mockup: a dark, strictly monochrome, square,
+flush-panel workbench made up of a title bar with a breadcrumb, a collapsible labelled sidebar, a
+details table of files, a right inspector (Options / Preview / Info) and a status bar.
+
+The redesign changes the renderer only. Behaviour stays the same: verb-first navigation, one queue per
+workspace, convert groups, the rule that a selection never spans two groups, every option each verb
+has today, Generate, Tools, Completed, session persistence, and the never-overwrite rule.
+
+Hard rules (from `redesign-direction.md`; not negotiable in this work):
+
+- **Dark only.** The light theme is removed entirely: no `dark:` variants, no toggle and no
+ `prefers-color-scheme` branch. Themes may come later. To make that possible, every colour is a CSS
+ custom property.
+- **Strict monochrome.** Every colour is a grey with r=g=b. Errors, warnings and danger actions use a
+ glyph, font weight and inverted fill, never red, green or amber. This includes the window close button,
+ per-verb rail colours and `GROUP_COLOR`.
+- **Every corner is square** (`border-radius: 0` globally).
+- **Flush panels**, separated only by 1px lines. There are no shadows and no cards, apart from the
+ estimate box.
+- **Controls read as buttons**: they are boxed, outlined or inverted. Underlined or dotted-link words are
+ not used as controls. The two exceptions are drawn in the signed-off mockup itself: the breadcrumb's
+ parent segment (underlined on hover) and the status bar's `1 failed` item. Neither is the only way to do
+ anything: the sidebar also returns to the Tools grid, and the failed rows carry their own Retry.
+- **No "Drop files here" row.** Files are added through the toolbar's Add files button, and the whole
+ window still accepts drops.
+- **No progress bar under a running row.** Progress shows as `62%(4s)` in the status column.
+- **Avoid Wind's signatures**: the floating progress-plus-button pill, the icon-tile header band, and big
+ rounded cards of right-aligned setting rows.
+
+Main process, IPC, preload and tool modules stay untouched, except for the small additions listed
+below. Each one is also tracked in section 6.
+
+| # | Change outside the renderer | Why | Size |
+| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
+| M1 | `src/main/index.ts`: `backgroundColor` becomes `#0a0a0a`; `nativeTheme.themeSource = 'dark'`; default window 1440×900 (clamped to the work area), minimum 1100×640 | No light flash before the first paint; dark native scrollbars and dialogs; the layout needs the width (section 3.7) | a few lines |
+| M2 | `src/main/tools/estimate.ts` plus its callers: `estimateProgress` also reports a remaining-seconds estimate through the existing `onProgress(pct, msg, etaSec)` | Image, PDF and rembg jobs can show `62%(4s)` and not only ffmpeg jobs | small; the signature only gains a value the job queue already forwards |
+| M3 | `src/main/output.ts` and `registry.ts`: optional `outDir` job option. When it is set, outputs are reserved in that folder rather than next to the source; collision safety is unchanged. A chosen folder that no longer exists fails the job ("Output folder not found"), never falls back. Plus one additive IPC channel `files:pick-folder` and preload method `pickFolder()` for the Choose folder button | The OUTPUT › Location setting | moderate; **owner decision O7** |
+| M4 | Test-only: `FILESMITH_USER_DATA` env override for `app.setPath('userData', …)`, read only when it is set | Lets e2e seed a session so the screenshot test has rows | 3 lines |
+| M5 | `package.json`: add `@fontsource/ibm-plex-sans` and `@fontsource/ibm-plex-mono` (400/500/600) | The mockup uses IBM Plex. Fonts are bundled so they work offline and stay within the CSP (no Google Fonts) | dependency only |
+
+No existing IPC channel or preload signature changes; the only addition is `pickFolder()` (M3).
+`FilesmithApi` keeps every method the e2e specs drive.
+
+## 2. Design system
+
+### 2.1 Tokens
+
+All tokens are CSS custom properties on `:root` in `src/renderer/src/theme/tokens.css`. They are mirrored
+into Tailwind v4 `@theme` so that utility classes (`bg-bg-0`, `text-fg2`, `border-line`) resolve to the
+variables. A future theme then only has to redefine the variables. The old tokens (`ink`, `muted`, `dim`,
+`canvas`, `line`, `accent*`) are deleted. So is the inline black-accent override in `OptionsPanel`.
+
+| Token | Value | Use |
+| --------------- | --------- | ---------------------------------------------------------------------------------------- |
+| `--bg-0` | `#0a0a0a` | app, panels, body; the patch behind the split arrow; preview tags |
+| `--bg-1` | `#0f0f0f` | setting block hover and focus-within |
+| `--hover` | `#1a1a1a` | hover on any neutral surface |
+| `--selected` | `#202020` | selected row; `:active` on neutral surfaces |
+| `--field` | `#141414` | select and text-field fill |
+| `--track` | `#2a2a2a` | progress track; segment `:active` |
+| `--line` | `#262626` | every 1px hairline |
+| `--line-strong` | `#3a3a3a` | table head bottom, totals top, control outlines, thumbnails, estimate box, preview frame |
+| `--fg1` | `#ededed` | primary text, active bars, focus |
+| `--fg2` | `#b4b4b4` | secondary text, idle icons |
+| `--fg3` | `#8c8c8c` | labels, meta, placeholders, checkbox border |
+| `--fg-disabled` | `#5c5c5c` | disabled text (for example a RAR target without WinRAR) |
+| `--inv-bg` | `#ededed` | inverted fills: primary, Add files, selected segment, checked box, badge, failed pill |
+| `--inv-fg` | `#0a0a0a` | text on inverted fills |
+| `--inv-hover` | `#ffffff` | hover on inverted surfaces |
+| `--inv-active` | `#bdbdbd` | `:active` on inverted surfaces |
+| `--focus` | `#ededed` | focus outlines |
+
+A unit test parses `tokens.css` and asserts that every colour token has r=g=b. That is the monochrome
+guard. A lint-style test also greps `src/renderer/src/**/*.{ts,tsx,css}` for hex or `rgb(` literals
+outside `tokens.css` and fails on any non-grey value.
+
+### 2.2 Type
+
+- `--sans: "IBM Plex Sans", system-ui, sans-serif`; `--mono: "IBM Plex Mono", Consolas, monospace`.
+ Weights 400, 500 and 600, bundled through `@fontsource` (M5).
+- Body: `13px/1.4 var(--sans)`, antialiased, `--fg1`. Every number uses `tabular-nums`.
+- The scale is fixed to the sizes the mockup uses: 16/600 mono (estimate value), 14/600 sans (inspector
+ head), 13 sans/mono (body, names, controls), 12 sans/mono (meta, tabs, status, descriptions),
+ 11.5 mono (`code` in descriptions), 11 mono (table head, group heads at 600 with `.09em` tracking,
+ badges), 9 mono (collapsed badge). The sizes live in the component rules (`theme/workbench.css`,
+ `controls.css`, `views.css`), ported from the mockup; there is no separate type-utility file. The ad hoc
+ Tailwind pixel sizes in the current components go away.
+- Labels are lowercase where the mockup has them lowercase: the table head (`name kind size result
+status`), the sidebar head (`operations`), the segment values and the estimate head. Group heads
+ (`FORMAT`, `OUTPUT`, `FILES`) are uppercase literals.
+
+### 2.3 Spacing, lines, focus and motion
+
+- Fixed heights, each including its border: 32 (title bar, sidebar head, toolbar, tab bar, table row,
+ info row), 28 (table head, select, input, segment, in-row button), 36 (sidebar item), 30 (totals),
+ 48 (primary footer), 24 (status bar).
+- Every border is 1px. Active markers are 2px `--fg1` bars: left edge for a sidebar item, a selected row
+ and a focused setting; bottom edge for a tab.
+- Focus defaults to `:focus-visible { outline: 1px solid var(--focus); outline-offset: -1px }`. The
+ offsets per component are given in 2.5. The primary button uses a 2px outline with an inset gap ring.
+- Motion: sidebar width `.18s cubic-bezier(.2,.7,.2,1)`; toggle mirror `.18s`; label and badge opacity
+ `.12s`; control colour, background and border `.1s`. Under `prefers-reduced-motion: reduce`, all of
+ these are `none` and the `i-sync` spin (if added) stops. The `ctx-pop` and `modal-pop` animations are
+ kept only as an opacity fade, with no scale and no radius.
+
+### 2.4 Icons
+
+One module, `src/renderer/src/components/icons/Icon.tsx`. Its 16×16 viewBox, stroke-only registry
+replaces the current 24×24 `PATHS`. The attributes are `fill:none`, `stroke:currentColor`,
+`stroke-width:1.25`, square caps, miter joins and `vector-effect: non-scaling-stroke`. Sizes are 16
+(default) and 12; window controls use their own 10×10 paths at stroke 1.
+
+The registry holds the mockup set: `convert compress resize upscale removebg generate tools completed
+settings addfile folder play stop retry close check warning clock sync chev-r chev-d trash eye info arrow
+sidebar anvil`. It adds the icons the current code needs and the mockup lacks, drawn in the same
+16px/1.25 language: `image pdf text merge split burst pull archive video audio doc unpack topdf tocbz edit
+grip dots`. Minimize and maximize are not registry entries; they are the window controls' own 10×10 paths. `IconName` is a string-literal union. `Tab.icon` and `ToolCard.icon` in
+`src/shared/tabs.ts` are typed as that union, through a shared `IconName` type in `src/shared/icons.ts`,
+so a bad name fails typecheck instead of being cast. Unused media icons (`play/pause/volume*/fullscreen`
+from the removed viewer) are dropped. `play` is redrawn as the primary-button triangle.
+
+The brand mark changes from the white-tiled `fmark.png` to the `i-anvil` glyph. The app/installer icon
+is out of scope (section 8).
+
+### 2.5 Component inventory
+
+Each primitive lives in `src/renderer/src/components/ui/` and covers every state the mockup defines.
+Disabled is not drawn in the mockup; its rule is `--fg-disabled` text, `--line` border, no hover change
+and `cursor: default`.
+
+| Primitive | Mockup class | States (hover / active / on / focus / disabled) |
+| --------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `TitleBar`, `WinControls` | `.titlebar`, `.winctl` | 46px buttons, left border; hover `--hover`/fg1; active inverted; close is **not** red |
+| `Breadcrumb` | `.crumb` | link segment: fg3, and fg1 with a 3px-offset underline on hover; current segment is `` 500 fg1. Link segments are ``s (section 7.2) |
+| `Sidebar`, `SidebarItem` | `.side`, `.item` | hover `--hover`/fg1; active `--selected`; on: fg1, 600, 2px left bar, `aria-current="page"`, inverted count chip; collapsed: labels faded out, badge pinned top-right, only the active item's badge kept |
+| `ToolbarButton` (`add`, `icon`) | `.tbtn.add`, `.ibtn` | Add files inverted, hover `--inv-hover`, active `--inv-active`; icon buttons 36px, hover `--hover`, active `--selected`, disabled when there is nothing to act on |
+| `Table`, `TableHead`, `TableRow`, `TotalsRow` | `.thead/.th/.tr/.td/.totals` | head cell hover `--hover`; sort chevron hidden until hover or sorted, `aria-sort`; row hover `--hover`, selected `--selected` plus a 2px left bar covering the borders above and below, focus outline inset, cross-group rows dimmed (2.6) |
+| `Checkbox` | `.box` | 14px (table) or 16px (setting); hover border fg1; on: inverted with a check; mixed: an 8×2 bar; focus offset 2 |
+| `RowAction` | `.row-act` | 24×24 outlined; hover border fg2 + `--hover`; active inverted; `ghost` variant is hidden until row hover or focus-within; focus offset 1 |
+| `StatusCell` | `.st` | `done` (check, "Done", fg2), `run` (sync, `62% (4s)`), `queued` (clock, "Queued", fg3), `ready` (section 4.3), `canceled` (section 4.3), `fail` (inverted pill, warning glyph, short label) |
+| `ResultCell` | `.rc`, `.arr.split` | arrow centred on the size/result boundary with a background patch matching the row state; done `` 600 fg1 plus `.pct`; running `~est` fg2 plus `.pct`; empty otherwise |
+| `Tabs` | `.tabs/.tab` | hover fg1/`--hover`; on 600 plus a 2px bottom bar; bold width reserved through `data-t` so the row never shifts; `role=tablist`, arrow-key roving |
+| `InspectorHead` | `.ihead` | title `` (section 7.2) at 14/600 plus a 12px mono sub-label |
+| `SettingGroup` | `.vs-gh` | 11/600 mono uppercase with a hairline to the right edge |
+| `Setting` | `.vs-set/.vs-t/.vs-d` | hover and focus-within `--bg-1`; focus-within shows a 2px left bar |
+| `Select` | `.vs-sel` | filled `--field`; hover border fg3 and `--hover`; active `--selected`; focus border `--focus`; `half` width; optional option groups (replaces both `ChoiceSelect` and `ModelPicker`); popup list in 2.7 |
+| `TextField`, `NumberField` | `.vs-in` | hover border fg3; focus border `--focus`; `spellcheck=false`; `NumberField` keeps `DimInput`'s commit-on-blur/Enter and clamping |
+| `Segmented` | `.seg` | `role=radiogroup`, arrow keys; hover `--hover`; active `--track`; on inverted 600, on+hover `--inv-hover`; focus offset -3 (`--inv-fg` on the selected button) |
+| `CheckSetting` | `.vs-chk` | label + sub-line; Space/Enter toggle |
+| `SmallButton` | `.sbtn` | outlined; hover `--hover`/fg3 border; active inverted; focus offset 1 |
+| `ChipGrid` | new (a segmented control in a grid) | the format, bitrate, factor, PDF op and style choices. Chips are square outlined cells; on is inverted; disabled uses `--fg-disabled` with a tooltip (`WinRAR not found`) |
+| `RangeField` | new | label, value readout in mono, a 2px `--track` slider with an `--fg1` fill and a square 10px thumb, end labels. Replaces about 9 copies |
+| `EstimateCard` | `.vs-est` | head `estimate … N files`; value `from → ~to` and `-N%`; full-sentence `aria-label` |
+| `ProgressBar` | `.mini` | 2px track with an fg1 fill; determinate or indeterminate (an fg1 segment sweeping, static under reduced motion). Replaces the 3 copies in CompanionDownload, ComfyImport and PidInstallCard |
+| `PrimaryButton` | `.ifoot .primary` | inverted, `i-play`; hover `--inv-hover`; active `--inv-active`; disabled: `--selected` fill with `--fg-disabled` text; 2px focus ring with an inset gap |
+| `StatusBar`, `StatusItem` | `.statusbar/.sitem` | hover `--hover`/fg1; right group uses left borders; `.warn` 600 fg1 with the warning glyph |
+| `ContextMenu` | restyled | section 4.10 |
+| `ConfirmDialog` | restyled | section 4.11 |
+
+### 2.6 Cross-group rows
+
+A row from a different convert group than the current selection renders at fg3 text with its thumbnail
+at 40% opacity. It keeps the existing tooltip "A different file type than the current selection".
+Clicking it moves the selection, as today. This is the monochrome replacement for the current
+`opacity-40` card.
+
+### 2.7 Select popup (not drawn in the mockup)
+
+Proposal: a native `` is not used, because Chromium draws the popup list in light OS chrome. A
+custom listbox drops directly below the field at the field's width, flush and square, `--bg-0` with a
+`--line-strong` border. Items are 28px, 13px mono. Hover gives `--hover`; the current item is fg1 600
+with an `i-check`; disabled items are `--fg-disabled` with a reason suffix (for example `WinRAR not
+found`). An enabled item may carry a suffix too (Generate's `needs download`, see 4.2). Option group headings use the `SettingGroup` style. Keyboard: arrows, Home/End, type-ahead,
+Enter, Esc. **Owner check O3.**
+
+## 3. Layout
+
+The `body` is a grid of `32px 1fr 24px` rows (title bar, workbench, status bar) at `100vw × 100vh`. The
+workbench is a grid of `auto 1fr 340px` columns (sidebar, centre, inspector) with `min-height: 0`.
+
+### 3.1 Title bar
+
+A 32px drag region with brand (anvil + "Filesmith"), a 1px separator, the breadcrumb and window
+controls pushed right. Every control inside it is `no-drag`. The window stays `frame: false`.
+Minimize/maximize/close keep calling `window.filesmith.minimize/toggleMaximize/close`.
+
+Breadcrumb content per context:
+
+| Context | Breadcrumb |
+| ----------------------------------- | ----------------------------------------------------------------- |
+| Verb workspace, no selection | `convert` (current) |
+| Verb workspace with an active group | `convert / images` (group label, lowercase) |
+| Tools grid | `tools` |
+| Tools card | `tools / merge` (the `tools` segment is the Back to Tools button) |
+| Generate | `generate` |
+| Completed | `completed` |
+| Settings | `settings` |
+
+### 3.2 Sidebar
+
+208px expanded, 48px collapsed, each including a 1px right border.
+
+- Head: the lowercase label `operations` and the toggle (`i-sidebar`, mirrored when collapsed).
+- Top group: the six verbs from `TABS` in their current order. Then a separator and Tools. Then a
+ spacer. The bottom group (top border) is Completed and Settings.
+- Badges: the input count for verbs and Tools (rolled up from `tools:*` queues, as today); Completed shows
+ the completed count.
+- **Collapse state** is a `useSidebar()` hook backed by localStorage key `filesmith.sidebar` (a per-user
+ view preference, like the existing `filesmith.rail.*` keys). It defaults to expanded. The toggle and
+ **Ctrl+B** flip it. `aria-expanded`, `aria-label` ("Collapse sidebar" / "Expand sidebar") and `title`
+ ("… (Ctrl+B)") stay in sync. Every item has a `title` attribute, which is the tooltip when collapsed.
+- **Rail edit mode** (reorder and hide, today a pencil in the rail header) cannot live in the head
+ anymore, because the head holds only the label and the toggle. Proposal: it moves to Settings › Sidebar
+ as a list with grip, label and a visibility checkbox. It keeps the same localStorage keys
+ (`filesmith.rail.tabOrder`, `filesmith.rail.tabHidden`) and the same pointer sortable. **Owner check
+ O6.**
+
+### 3.3 Centre: toolbar and table
+
+- **Toolbar** (32px): Add files (inverted) | Remove selected | Retry failed | Clear finished … right: `N
+files, M selected` | Stop all. Section 4.8 describes what each does.
+- **Table**: columns `40px 1fr 64px 88px 184px 232px` (checkbox, name, kind, size, result, status).
+ The head and totals are fixed; only the rows region scrolls (`.scroll-thin` restyled to a square 8px
+ `--line-strong` thumb on `--bg-0`). The totals row pins to the bottom even when there are few rows.
+- In a mixed queue (two or more groups), a 28px group row (`SettingGroup` style: `IMAGES 6` and a
+ hairline) sits above each group's rows. It is not selectable, and clicking it selects the whole group.
+ In a single-group queue there is no group row, as the mockup shows. **Owner check O4** (the mixed table
+ is not designed).
+
+### 3.4 Inspector
+
+340px with a 1px left border. Rows `32px 1fr auto`: tabs, a scrolling body and a 48px footer.
+
+- Tabs: Options (`settings`), Preview (`eye`), Info (`info`). The selected tab is per-session view state
+ in App, not persisted.
+- `ihead`: Options shows `` with `all N files` / `N selected` / `no files`. Preview and Info show the
+ focused file's name with `selected`.
+- Footer: the primary Run button (section 4.1).
+
+### 3.5 Status bar
+
+24px, 12px mono: `[sync] Converting 3 of 6 [mini]` | `2 of 6 done` … right: `[warning] 1 failed`.
+Items with nothing to say are not rendered. Clicking `1 failed` selects the failed rows of the current
+workspace.
+
+### 3.6 Views that replace the table
+
+Generate, Tools grid, Completed and Settings occupy the centre (toolbar row plus content), as described in
+section 5. The inspector is shown for Generate and hidden for the Tools grid, Completed and Settings. In
+those views the centre spans both columns, as today.
+
+### 3.7 Window size
+
+The fixed table columns add up to 608px. Expanded sidebar plus inspector is 548px. At today's 1160px
+default, the name column would get about 4px. Proposal (M1): the default window becomes 1440×900, clamped
+to the display work area, and the minimum becomes 1100×640. Below 1280px wide the sidebar auto-collapses
+(not persisted, so the user's choice is restored when the window widens). Below 1180px the `kind` column
+folds into the name cell as a trailing fg3 tag. **Owner check O5.**
+
+## 4. Behaviour mapping
+
+Nothing in `state.ts`'s reducer, keys or session schema changes meaning. The new view logic is pure
+functions in sibling `.ts` files (section 7.1).
+
+### 4.1 Common to every verb workspace
+
+- **Queue and groups.** One queue per workspace (`queueKey`), possibly holding several convert groups.
+ Selection modes stay as they are: click = single, Ctrl/Meta = toggle (moves to the clicked group if it
+ differs), Shift = range within the anchor's group. **A selection never spans groups.**
+- **Checkbox column.** A row checkbox toggles the row (same as Ctrl+click). The header checkbox shows
+ none / mixed / all **for the active group** (the group of the current selection or, with no
+ selection, the first group in display order). Clicking it selects all rows of that group, or clears
+ the selection when all are selected. It never selects across groups. The checkbox is `tabindex=-1`;
+ the row takes focus.
+- **Keyboard (new).** In the table: Up/Down move focus; Space toggles; Shift+Up/Down extends the range;
+ Ctrl+A selects all of the active group; Delete removes the selection from the list (the same as the
+ menu item); Enter opens the focused file (as double-click does today); the context-menu key or
+ Shift+F10 opens the menu. Global: Ctrl+B toggles the sidebar; Ctrl+O = Add files; Ctrl+Enter = Run
+ when enabled. **Owner check O8** (shortcut set).
+- **Sorting (new).** Clicking a header sorts by that column ascending, a second click sorts descending
+ (the chevron flips), and a third click goes back to insertion order. The sort is view-only in App
+ state (per workspace, not persisted) and applies within each group. Range selection follows the
+ visible order.
+- **Options.** The Options tab renders the verb's settings in grouped VS Code style (section 4.2). Options
+ are keyed per group as today. With no selection, the panel shows the options of the first group in the
+ queue rather than an empty panel; with an empty queue it shows `no files` and the Add files hint (4.12).
+- **Run button.** The label is ` ` ("Convert 6 images"). The mockup's
+ "Convert 6 files" uses the generic noun, while the code uses the group noun; the group noun is kept
+ because it is more precise, which is a deliberate deviation. It is disabled when `runCount === 0`. The
+ oversized-upscale confirm, PDF merge special case and per-file job dispatch in `run()` are unchanged.
+- **Run scope.** Run acts on `runList`: the runnable selection or, when nothing is selected, every row of
+ the first group in display order. Today an empty selection runs nothing, so the second half is new
+ (**owner check O10**).
+
+### 4.2 Options per verb (grouped settings)
+
+Every existing control is kept. Headings group them; the setting title is the current label, and the
+description is the current hint or help text. The `HelpTip` hover tooltips become `.vs-d` description
+text.
+
+| Verb / context | Groups and settings |
+| ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Convert** (image, video, audio, doc, sheet, slide) | FORMAT: Format (`Select` of `sharedTargets`; same-format and RAR-without-WinRAR targets disabled with reasons); Quality (`Segmented` smaller / balanced / best, images only). OUTPUT: Location (O7). FILES: If file exists (O7), Metadata (O7). Then the estimate card |
+| **Convert** › archive repack | FORMAT: Format (archive targets); Compression (`Segmented` store / normal + hint) |
+| **Convert** › from-pdf | FORMAT: Format; PAGES: DPI (`RangeField` 72–400 step 2); Page format (`Segmented` jpg / png + hint); Page quality (`RangeField`, writes `pageQuality`) |
+| **Compress** › image | FORMAT: Format (keep / webp / avif); Quality (`RangeField` 10–100) |
+| **Compress** › video | VIDEO: Codec (h264 / h265 / av1); Scale (`RangeField` 25–100, "original" at 100) with the output resolution list beneath it when below 100; Quality (`RangeField`) |
+| **Compress** › audio | AUDIO: Codec (keep / mp3 / aac / opus); Bitrate (`ChipGrid` 320 … 64) |
+| **Compress** › pdf | PDF: Level (lossless / high / balanced / smallest); Greyscale (`CheckSetting`; replaces the only toggle switch) |
+| **Resize** | SIZE: Mode (`Segmented` percent / dimensions); Percent (`RangeField` 5–200) or Width and Height (`NumberField`, placeholder `auto`) plus Fit (`Segmented` contain / stretch); the output size list |
+| **Upscale** | MODEL: Factor (`ChipGrid` 2× 3× 4×); Model (category `Select`, then sub-`Select` for AI models; "Add your own model" as a `SmallButton` with `i-folder`); the GPU reason as description text; the PiD install card, PiD remove and Comfy import card (section 5). PERFORMANCE: GPU mode (`Segmented` full / background); the low-VRAM warning as a warning-glyph description. Then the output size list |
+| **Remove BG** | BACKGROUND: Fill (`Select`); Custom colour (a 28px swatch button opening the native colour input; the swatch shows the chosen colour, which is the one place a non-grey value appears, because it is user content); Background image (`SmallButton` "Choose image" + file name). The first-run download or missing-`uv` notice is a setting description with the warning glyph |
+| **Tools** › PDF cards | One card = one op, so there is no op chip grid. Pages to images: DPI. Split range: Range (`TextField`, placeholder `1-3,5,8-10`). Merge: the hint "Select 2 or more PDFs" as a description while `runCount < 2` |
+| **Tools** › archive cards (when present) | Extract / to-pdf: an info description only. From-pdf: reuses `PageRenderOptions`, which **fixes the existing bug** where `ArchiveOptions` wrote `quality` and the engine read `pageQuality` |
+| **Generate** | MODEL: Model (`Select` with architecture option groups; non-runnable models stay selectable with a `needs download` suffix, as today, because selecting one is what reveals the download card or Try anyway); CompanionDownload / reason + Try anyway; Add model / Open folder / Change ComfyUI folder as `SmallButton`s. PROMPT: Negative (`TextField`, hidden when cfg is 1); Style (`ChipGrid`). OUTPUT: Count (`RangeField` 1–8); Size (`Select` + custom Width/Height `NumberField`s with clamping). ADVANCED: a collapsible `SettingGroup` (chevron) holding Steps, CFG, Guidance and Seed (+ Random `CheckSetting`) |
+
+Engineering clean-ups that come with the split (no behaviour change except the bug fix):
+`OptionsPanel.tsx` becomes `components/options/` with one file per verb plus `primitives`; a memoized
+`onSet` (it is recreated every render today, re-running two effects); one `useArchiveStatus` call instead
+of two; the stale "three toggles" comment removed.
+
+### 4.3 Result and status columns
+
+Derived per row by `rowView(item, ctx)` in `components/queue/rowModel.ts`.
+
+| Status | Status cell | Result cell | Row action |
+| ------------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
+| `ready` (never run) | blank, no glyph | empty | `i-close` Remove (ghost) |
+| `queued` | `i-clock` "Queued" fg3 | empty | `i-close` Remove (ghost): cancels the queued job **and** removes the row (today the inline × only cancels) |
+| `running` | `i-sync` + `62% (4s)`; `(4s)` only when `etaSec` is known; `!hasProgress` shows `… ` | `~410 KB -77%` in fg2 when an estimate exists, otherwise empty | `i-close` Cancel (ghost) |
+| `done` | `i-check` "Done" fg2 | `640 KB -80%`; a directory output shows `N files ` or `folder ` and no percentage | `i-folder` Show in folder (ghost), revealing the **output** |
+| `failed` | inverted pill, `i-warning`, short label | empty | `i-retry` Retry, **always visible** |
+| `canceled` | `i-close` "Canceled" fg3 | empty | `i-retry` Retry (ghost) |
+
+**Ready** shows a blank status, because the mockup has no ready label. **Owner check O4.**
+
+- The split arrow is drawn on the size/result boundary only when the result cell has content.
+- Compact ETA: `formatEtaCompact(sec)` gives `(4s)`, `(2m)`, `(1h 5m)`. The existing `formatEta` stays
+ for any other uses. A progress update without an ETA clears the stored one, and a re-queue resets it, so
+ an overdue row reads `62%`, never a frozen `62%(1s)`.
+- **Percentage** is `round((out - src) / src * 100)`, signed, with `-` meaning smaller. A growth result
+ shows `+N%` in **600 weight** (the monochrome replacement for amber) and the tooltip "Larger than the
+ original".
+- **Short failure label:** `shortError(message)` maps known messages to a short label (for example
+ `Unsupported compression`, `WinRAR not found`, `Password-protected`, `Tool missing`). Otherwise it
+ takes the first clause, capped at 32 characters with an ellipsis. The full text is the pill's tooltip,
+ and it appears selectable in the Info tab.
+- **Retry** marks the row queued with its stored `runOptions` and dispatches the job, which is the same
+ path as `run()` for one item. A PDF merge stores its ordered input list in `runOptions`, so a merge
+ retries with the same files.
+
+### 4.4 Totals row
+
+`queueTotals(items)` in `rowModel.ts`, over the input rows of the current workspace (all groups):
+
+- `N files` | `` | ` → done result sum so far` (shown only when at
+ least one done row has a file output) | `D of N done `.
+- "Done" counts the status as it stands, so it can include rows done in earlier runs (there is no batch
+ concept in the data; this matches what the user sees in the status column).
+
+### 4.5 Batch estimate card
+
+Shown in Options under the last group, before Run, whenever `runList` is non-empty and an estimate exists:
+`estimate … N files`; ` → ~ -N%`. Hidden when no estimator applies (for example PDF
+split, extract text, Generate). The source is section 6.2.
+
+### 4.6 Preview tab
+
+For the focused row (the last-clicked row, or the first selected one):
+
+- **Done image or PDF-to-image output:** a 208px wipe frame with the source on the left and the result on
+ the right, a 1px fg1 divider and tags (`jpg 2.1 MB` / `webp 486 KB`). The divider drags horizontally
+ (pointer and arrow keys, `role=slider`). Images come from the existing 128px thumbnail calls at a larger
+ size (`thumbnail(path, 640)`).
+- **Not yet done:** the source only, with a single tag.
+- **Video, audio, document, archive:** the thumbnail if there is one, otherwise the extension in a 208px
+ frame. There is no player (the in-app viewer was removed earlier, and this is not reintroduced).
+- Below it, a grid of `size`, `saved`, `pixels`.
+- **Owner check O9** (draggable wipe and non-image fallbacks are undesigned).
+
+### 4.7 Info tab
+
+A `dl` grid for the focused row: `format` (`jpg to webp`), `pixels`, `size`, `colour` (only when known
+from the probe), `metadata` (O7), `output` (path, revealable), and for failed rows `error` (full text,
+selectable). Values use the word "to", not an arrow.
+
+### 4.8 Toolbar actions
+
+| Button | Does | Enabled when |
+| --------------- | ------------------------------------------------------------------- | --------------------------- |
+| Add files | `browse()` | always on a queue workspace |
+| Remove selected | `dismiss` on the selection (cancels in-flight jobs first, as today) | selection is non-empty |
+| Retry failed | Retry every failed row in the active group | any failed row |
+| Clear finished | Hide done and canceled input rows (results stay in Completed) | any done or canceled row |
+| Stop all | `cancelJob` for every queued or running row in this workspace | any in flight |
+
+The toolbar count reads `N files, M selected` (or just `N files`).
+
+### 4.9 Drops
+
+The whole window accepts drops, routed to the current workspace through `accepts()` as today. While
+dragging, a 1px `--fg1` inset outline is drawn around the centre panel, and the toolbar count reads `Drop
+to add`. There is no drop row.
+
+### 4.10 Context menu
+
+The current items and logic are unchanged: input menu, output menu, generated-tile menu. Restyle: flush
+`--bg-0`, 1px `--line-strong` border, no blur, no shadow, square, 28px items with 16px icons, 12px sans
+labels. Hover is `--hover`/fg1; separators are `--line`. Danger items (Remove, Delete) show the label at
+600 with `i-trash` or `i-close`; they are **not** red. Flip and close behaviour is unchanged.
+
+### 4.11 Dialogs
+
+`ConfirmDialog` keeps its API and uses. Restyle: a square `--bg-0` panel with a 1px `--line-strong`
+border, a backdrop of `rgba(0,0,0,.6)`, a 14/600 title, a 13px fg2 body and a footer of `SmallButton`
+Cancel plus inverted confirm. A `danger` confirm keeps focus on Cancel and shows `i-warning` before the
+title.
+
+### 4.12 Empty states (owner check O2)
+
+- **Empty queue:** the table head and the totals row stay. The body shows a single centred block: `i-addfile`,
+ "No files yet", the fg3 line "Add files or drop them anywhere in the window" and an outlined
+ `SmallButton` "Add files". There is no drop zone. The inspector shows `no files` and a disabled Run.
+- **No completed items:** the same pattern: "Nothing finished yet" / "Files you convert, compress or
+ resize land here".
+
+### 4.13 Per-verb notes
+
+- **Convert, Compress, Resize, Upscale, Remove BG:** the table view described above. The inline output
+ size lists (video scale, resize, upscale) render as a mono two-column list under their setting. The
+ oversized-upscale confirm is unchanged.
+- **Generate:** section 5.1.
+- **Tools:** the grid (section 5.2); a card opens a normal table workspace titled with the card's label.
+- **Completed:** section 5.3.
+
+## 5. Screens the mockup does not show
+
+The project rule is "make NO visual assumptions". Every item here is a proposal in the established
+language. The owner checks below are collected as **open items O1 to O10** for the single approval, and the
+plan builds each one as a quick static check (a screenshot from the running branch) before it is wired
+in.
+
+### 5.1 Generate (O1)
+
+Centre: the toolbar row holds `Generate` controls (an inverted `Generate` button with `i-play` and a
+`Cancel` `SmallButton` while running). Below it is the prompt as a full-width borderless textarea on
+`--bg-0` with a 1px bottom line, 13px sans, min 6 lines. Under that is the results grid: square cells
+separated by 1px lines (no gaps, no radius), aspect from width/height, 2 or 3 columns. Pending slots show
+`--bg-1` with a centred mono `42%` and a 2px `ProgressBar` along the bottom edge. The startup message is a
+status-bar item with the indeterminate mini bar rather than a card. The inspector Options tab holds
+`GenerateOptions` (4.2). Preview shows the focused image larger; Info shows the seed, model, size and path.
+The Run button reads `Generate` (or `Generate 4 images`).
+
+### 5.2 Tools grid (O1)
+
+A dense list rather than cards: a `SettingGroup` heading per group (`PDF`), then 40px rows with a 16px
+icon, label (13/500) and description (12 fg3), with hover `--hover` and Enter/click to open. The title is
+an `` "Tools" in a 32px header row, styled like `ihead`. No colour swatches. No inspector.
+
+### 5.3 Completed (O1)
+
+The same table component in read-only mode: columns `name | from | kind | size | result | ` (no
+checkbox, status replaced by the row action `i-folder`). `name` is the output file name; `from` is the
+verb label; `size` the source size; `result` the output size and percentage with the split arrow. Newest
+first by default. The toolbar has `Delete selected` (with the existing confirm) and `Clear list` (hides
+rows, does not delete files). The context menu is the existing output menu.
+
+### 5.4 Settings (O6)
+
+A new view behind the sidebar Settings item. It uses the grouped settings style in the centre (no
+inspector):
+
+- SIDEBAR: reorder and visibility (moved from rail edit mode).
+- TOOLS: status of the bundled and on-demand tools (read-only, from the existing status calls), plus the
+ existing ComfyUI folder change and the models-folder buttons.
+
+No new preferences are introduced beyond what exists.
+
+### 5.5 Tool setup cards: ComfyImport, PiD install, CompanionDownload, LocateComfy (O1)
+
+They stay inline in the Upscale and Generate options, restyled as `Setting` blocks rather than cards: a
+title, a description (licence notice, VRAM requirement, missing file list in mono), a `SmallButton`
+action and a full-width `ProgressBar` with a mono percentage while running. Remove/uninstall actions are
+`SmallButton`s with `i-trash`.
+
+### 5.6 ErrorBoundary (O1)
+
+A centred block in the workbench: `i-warning`, "Something went wrong" (14/600), the error message in
+mono fg3, and `SmallButton`s Reload and Reset session.
+
+### 5.7 Select popup, mixed-group table, ready status, window size, shortcuts, run scope
+
+See O3, O4, O5, O8 and O10.
+
+### Open items for the owner's single approval
+
+| # | Item | Proposal |
+| --- | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| O1 | Generate, Tools grid, Completed, tool setup cards, ErrorBoundary | Sections 5.1, 5.2, 5.3, 5.5, 5.6 |
+| O2 | Empty states | Section 4.12 |
+| O3 | Select popup list | Section 2.7 |
+| O4 | Mixed-group table (group rows) and the `ready` / `canceled` status display | Sections 3.3, 4.3 |
+| O5 | Window size and narrow-width behaviour | Section 3.7 |
+| O6 | Settings view, and moving rail edit mode into it | Sections 3.2, 5.4 |
+| O7 | OUTPUT and FILES settings: Location, Naming, If file exists, Keep metadata | Ship **Location** (next to source / chosen folder, needs M3). Show **If file exists** as a fixed, disabled select reading `add (2)` with the description "Existing files are never overwritten" (the never-overwrite rule makes it the only value). **Defer Naming and Keep metadata** (both need per-tool engine work; metadata stripping differs per tool). Alternative: defer all four and drop the OUTPUT/FILES groups |
+| O8 | Keyboard shortcut set | Section 4.1 |
+| O9 | Preview: draggable wipe, non-image fallbacks | Section 4.6 |
+| O10 | Run scope versus the mockup | The mockup shows one selected row while Options reads `all 6 files` and Run reads `Convert 6 files`, which suggests "selection is only the focus, Run takes the whole batch". The proposal keeps today's rule (Run takes the selection, header `N selected`) and adds: with nothing selected, Run and Options take the whole first group (`all N files`). Alternative: checkboxes alone set the run scope, and a plain click only focuses a row for Preview and Info (section 4.1) |
+
+## 6. Data gaps
+
+### 6.1 Source-to-result link
+
+On a `done` event the reducer appends a separate result item and leaves the source with no link to it.
+**Minimal approach, renderer only:** in the `jobEvent` done branch, also write `outputPath` and
+`outputSize` onto the source item (the fields already exist on `QueueItem`). `normalizeItem` and the
+session snapshot already carry these fields. The result item, Completed and the output menu are
+unchanged. `markQueued` already resets `outputPath` on re-run; it also resets `outputSize`. Tests:
+`queues.test.ts` gains a case.
+
+### 6.2 Size estimates (per file and batch)
+
+No estimator exists. **Minimal approach:** a pure `estimateOutputBytes(file, tool, options)` in
+`src/shared/sizeEstimate.ts`:
+
+1. **Batch extrapolation first.** Once one or more rows with the same group and run options are done in
+ this workspace, use their median output/source ratio, applied to `file.size`.
+2. **Otherwise a ratio table** keyed by tool, target format and quality preset (for example image
+ convert to webp/balanced ≈ 0.22, avif ≈ 0.16; video compress by codec × quality; audio by bitrate ×
+ duration is not available, so a bitrate ratio against the source bitrate is used only when the
+ probe has it, otherwise none).
+3. **Otherwise none**, and the cell stays empty. Resize and Upscale estimate pixels from the existing
+ dimension probes × a bytes-per-pixel ratio for the format.
+
+The batch card sums the same function over `runList`. The values are always shown with `~`. The ratio
+table starts conservative and lives with its tests; it is the one piece of new tuning in this work.
+
+### 6.3 ETA for non-ffmpeg jobs
+
+Only ffmpeg jobs report `etaSec`. **Minimal approach (M2):** `estimateProgress` already knows
+`expectedSec`; it reports `max(0, expectedSec - elapsed)` alongside the percentage through the existing
+`onProgress` third argument. Nothing else changes in main. Without M2, those rows show `62%` with no
+`(…)`, which is acceptable as a fallback.
+
+### 6.4 Kind, totals, status counts
+
+All derivable in the renderer: kind = `ext.slice(1).toUpperCase()`; totals and status counts are sums
+and counts over input rows (4.4). "Converting 3 of 6" in the status bar uses a renderer-only **batch**:
+the ids passed to the last `run()` in that workspace (kept in App state, not persisted). It shows
+`ing of ` while any batch item is in flight, and disappears when the
+batch settles.
+
+### 6.5 Short error label
+
+Renderer-only `shortError()` (4.3). No engine error codes are introduced.
+
+### 6.6 Output folder and file naming
+
+Section 5, O7, and M3.
+
+## 7. Testing and verification
+
+### 7.1 Unit tests (Vitest, `test/*.test.ts`)
+
+The new pure logic lives in `.ts` files beside the components (react-refresh rule):
+
+- `row-model.test.ts`: `rowView` for every status, including directory outputs, growth percentages and
+ unknown estimates; `queueTotals`; `formatEtaCompact`; `kindLabel`; `shortError`.
+- `table-sort.test.ts`: sort comparators and the three-click cycle; sorting stays within groups.
+- `select-all.test.ts`: the header checkbox none/mixed/all for the active group; never across groups.
+- `table-keys.test.ts`, `shell-model.test.ts`, `ui-nav.test.ts`, `info-model.test.ts`, `icons.test.ts`,
+ `window-size.test.ts`, `generate-restore.test.ts`: the remaining pure modules (keyboard maps,
+ breadcrumb, sidebar collapse, status bar, select popup keys, Preview/Info rows, icon registry).
+- `size-estimate.test.ts`: batch extrapolation (median), ratio table fallbacks, "none" cases, batch sum.
+- `estimate.test.ts`: extend for the remaining-seconds value (M2).
+- `output.test.ts`: extend for `outDir` (M3, only if O7 keeps Location).
+- `theme-tokens.test.ts`: every colour token in `tokens.css` has r=g=b. `monochrome-source.test.ts`: no
+ non-grey literal in the renderer source outside `tokens.css` (the Remove BG swatch is user data, not a
+ literal), and no light-theme utility classes.
+- `queues.test.ts`: the done event writes `outputPath`/`outputSize` on the source; ETA reset; clear
+ finished; `dismissAny`.
+- `group-naming.test.ts`: kept. `GROUP_COLOR` is deleted (no longer used, it was the only coloured group
+ data), so its assertion is removed in the same PR.
+- `tabs.test.ts`: still pins the seven verbs in rail order; the `IconName` typing change is covered by
+ typecheck.
+
+### 7.2 E2E selector updates
+
+There are no `data-testid`s today, and Playwright's `has-text` is case-insensitive, so the lowercase
+breadcrumb `convert` would capture `button:has-text("Convert").first()`. The specs move to scoped role
+selectors, and the DOM keeps the contracts they need:
+
+| Today | After |
+| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
+| `h1` hasText `` | The inspector `ihead` title is an ``; Tools grid, Completed and Settings have their own ``. Specs keep `h1` |
+| `button:has-text("").first()` | `page.getByRole('navigation', { name: 'Operations' }).getByRole('button', { name: '' })` |
+| `button:has-text("Convert").last()` (Run) | `page.getByTestId('run')` |
+| `text=Files` | `page.getByRole('grid', { name: 'Files' })` (the table's `aria-label`) |
+| `button[aria-label="Back to Tools"]` | kept: the breadcrumb `tools` segment is a button with that `aria-label` |
+| Tools card labels visible | kept; labels come from `TOOL_CARDS` |
+
+`window.filesmith.*` contracts used by the engine specs are untouched.
+
+### 7.3 Screenshot comparison
+
+A new `e2e/visual.spec.ts` launches the built app with `FILESMITH_USER_DATA` (M4) pointing at a temp
+folder seeded with a session that approximates the mockup's six rows (fixture images generated with the
+bundled magick: 2 done, 1 failed, the rest ready). A restored session settles in-flight rows, so the
+running row comes from one real slow job started by the spec, and the mockup's two queued rows appear as
+ready. It sets the window to 1440×900 and saves `docs/mockups/terminal-v5/shots/impl-10-s2.png` next to
+the mockup shot, plus collapsed-sidebar, Preview, Info, empty-queue, Generate, Tools, Completed and
+Settings shots. It runs only with `FILESMITH_SHOTS=1`, so a routine e2e run does not rewrite tracked
+images. A separate `e2e/ui.spec.ts` (also on a private userData) covers dark and square, sidebar collapse
+and persistence, inspector tab keys and the empty workspace.
+Comparison with the mockup is a manual side-by-side in the PR (thumbnails and the running state differ,
+so a pixel diff is not meaningful). After the owner approves, the shots become `toHaveScreenshot`
+baselines for regressions.
+
+### 7.4 PR gate
+
+```
+npm test && npm run typecheck && npm run lint && npx prettier --check . && npm run build && npm run test:e2e
+```
+
+Then `npm run package`, install the NSIS build locally and launch it (per the global shipping rules and
+the always-install-and-open note), check every view by hand at 1440×900 and at the minimum size, with the
+keyboard only, and with reduced motion on.
+
+## 8. Out of scope / later
+
+- **Themes** (the token layer makes them possible), including any light theme. Light mode is removed, not
+ deferred.
+- Naming patterns and Keep metadata (unless O7 says otherwise).
+- A real trial-encode size estimator; the ratio table is the v1.
+- An in-app media viewer or player.
+- New verbs, operations or engine options beyond M1 to M5.
+- The app and installer icon (the title bar uses the anvil glyph; the `.ico` stays).
+- `toHaveScreenshot` baselines before the owner approves the shots.
+- Dead code clean-up beyond what the rewrite deletes anyway (the old viewer CSS, `DropZone`, `Boundary`,
+ `OperationTitle`, the media icons go with the files they live in).
+
+## 9. Delivery
+
+- One GitHub issue "Terminal redesign (S2 VS Code grouped)", linking this spec and the mockup.
+- One branch `feat/-terminal-redesign`, one PR, squash-merged after the owner's explicit approval.
+- The PR carries everything: the renderer rewrite, M1 to M5, the test updates, the version bump to
+ **0.5.0** (minor, a feature), the `CLAUDE.md` update (the renderer is no longer a placeholder; the
+ design rules and the token file are pointed to) and the README screenshot refresh.
+- The untracked `docs/design/redesign-direction.md` and `docs/mockups/terminal*/` go into the same PR,
+ so the design record ships with the code.
+- Before asking "merge?": the full gate (7.4), the packaged build installed and launched on this machine,
+ and the screenshots attached to the PR.
diff --git a/e2e/rowmenu.spec.ts b/e2e/rowmenu.spec.ts
new file mode 100644
index 0000000..b22094f
--- /dev/null
+++ b/e2e/rowmenu.spec.ts
@@ -0,0 +1,115 @@
+import { _electron, type ElectronApplication, type Page } from 'playwright'
+import { test, expect } from '@playwright/test'
+import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
+import { tmpdir } from 'os'
+import { join } from 'path'
+import { MAIN, ROOT } from './helpers'
+
+// The files table's right-click menu and the one-file inspector panes, on a
+// seeded session. Nothing here deletes a file: Delete is only opened and
+// cancelled, so the test never touches the Recycle Bin.
+let app: ElectronApplication
+let page: Page
+let userData: string
+
+const NAMES = ['alpha.png', 'bravo.png', 'charlie.png']
+
+test.beforeAll(async () => {
+ test.skip(!existsSync(MAIN), 'run `npm run build` first')
+ userData = mkdtempSync(join(tmpdir(), 'filesmith-menu-'))
+ const files = join(userData, 'files')
+ mkdirSync(files)
+ const items = NAMES.map((name, i) => {
+ const path = join(files, name)
+ writeFileSync(path, 'not really a png')
+ return {
+ id: `m-${i}`,
+ file: { path, name, ext: '.png', kind: 'image', size: 16 },
+ thumb: null,
+ status: i === 2 ? 'failed' : 'ready',
+ percent: 0,
+ ...(i === 2 ? { error: 'Broken' } : {})
+ }
+ })
+ const session = {
+ version: 2,
+ lastTool: null,
+ options: {},
+ genResults: [],
+ queues: { convert: { items } }
+ }
+ writeFileSync(join(userData, 'session.json'), JSON.stringify(session))
+ app = await _electron.launch({
+ args: [ROOT],
+ env: { ...process.env, FILESMITH_USER_DATA: userData }
+ })
+ page = await app.firstWindow()
+ await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1440, 900))
+ await expect(row('alpha')).toBeVisible()
+})
+
+test.afterAll(async () => {
+ await app?.close()
+ if (userData) rmSync(userData, { recursive: true, force: true })
+})
+
+const row = (name: string) =>
+ page.getByRole('grid', { name: 'Files' }).getByRole('row', { name: new RegExp(name) })
+const menu = () => page.getByRole('menu')
+const item = (name: string | RegExp) => menu().getByRole('menuitem', { name })
+
+test('right-clicking an unselected row selects it and opens its menu', async () => {
+ await row('bravo').click({ button: 'right' })
+ await expect(row('bravo')).toHaveAttribute('aria-selected', 'true')
+ await expect(item('Show in File Explorer')).toBeEnabled()
+ await expect(item('Remove from list')).toBeEnabled()
+ await expect(item('Delete file')).toBeEnabled()
+ await expect(item('Retry')).toBeDisabled()
+ await expect(item('Stop')).toBeDisabled()
+ // Keyboard: focus starts on the first entry and arrows walk the live ones.
+ await expect(item('Open')).toBeFocused()
+ await page.keyboard.press('ArrowDown')
+ await expect(item('Show in File Explorer')).toBeFocused()
+ await page.keyboard.press('Escape')
+ await expect(menu()).toHaveCount(0)
+})
+
+test('a multi-selection menu counts, and Preview and Info show no single file', async () => {
+ await row('alpha').click()
+ await row('charlie').click({ modifiers: ['Shift'] })
+ await row('bravo').click({ button: 'right' })
+ await expect(item('Remove 3 from list')).toBeVisible()
+ await expect(item('Delete 3 files')).toBeVisible()
+ await expect(item('Open')).toBeDisabled()
+ await expect(item('Show in File Explorer')).toBeDisabled()
+ await expect(item('Retry')).toBeEnabled()
+ // Delete asks first, names the count and the Recycle Bin; cancel leaves all.
+ await item('Delete 3 files').click()
+ const dlg = page.getByRole('dialog')
+ await expect(dlg).toContainText('Delete 3 files?')
+ await expect(dlg).toContainText('Recycle Bin')
+ await dlg.getByRole('button', { name: 'Cancel' }).click()
+ await expect(row('alpha')).toBeVisible()
+
+ await page.getByRole('tab', { name: 'Preview' }).click()
+ await expect(page.getByText('Select one file to preview')).toBeVisible()
+ await page.getByRole('tab', { name: 'Info' }).click()
+ await expect(page.getByText('Select one file to see its info')).toBeVisible()
+ await page.getByRole('tab', { name: 'Options' }).click()
+})
+
+test('Remove from list asks first; the Delete key opens the same confirm', async () => {
+ await row('charlie').click()
+ await row('charlie').press('Delete')
+ const dlg = page.getByRole('dialog')
+ await expect(dlg).toContainText('Remove this file from the list?')
+ await dlg.getByRole('button', { name: 'Cancel' }).click()
+ await expect(row('charlie')).toBeVisible()
+
+ await row('charlie').click({ button: 'right' })
+ await item('Remove from list').click()
+ await dlg.getByRole('button', { name: 'Remove' }).click()
+ await expect(row('charlie')).toHaveCount(0)
+ // The file on disk is untouched.
+ expect(existsSync(join(userData, 'files', 'charlie.png'))).toBe(true)
+})
diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts
index befb12b..18cf69f 100644
--- a/e2e/smoke.spec.ts
+++ b/e2e/smoke.spec.ts
@@ -75,8 +75,10 @@ test('the app boots to the Convert workspace', async () => {
// The rail names the verb, so the app opens on the first one rather than on
// a file type.
await expect(page.locator('h1', { hasText: 'Convert' }).first()).toBeVisible()
- // The primary action is on screen without scrolling (the pinned footer).
- await expect(page.locator('button', { hasText: 'Convert' }).last()).toBeVisible()
+ // The pinned Run button is reachable without scrolling and names the verb.
+ const run = page.getByTestId('run')
+ await expect(run).toBeVisible()
+ await expect(run).toHaveText(/Convert/)
})
test('convert PNG -> JPG lands a collision-safe output', async () => {
diff --git a/e2e/ui.spec.ts b/e2e/ui.spec.ts
new file mode 100644
index 0000000..7c1aa80
--- /dev/null
+++ b/e2e/ui.spec.ts
@@ -0,0 +1,89 @@
+import { _electron, type ElectronApplication, type Locator, type Page } from 'playwright'
+import { test, expect } from '@playwright/test'
+import { existsSync, mkdtempSync, rmSync } from 'fs'
+import { tmpdir } from 'os'
+import { join } from 'path'
+import { MAIN, ROOT } from './helpers'
+
+// UI behaviour the unit suite cannot reach: the sidebar, the inspector tabs and
+// the empty workspace. A private userData keeps the user's real session and
+// sidebar preference out of it.
+let app: ElectronApplication
+let page: Page
+let userData: string
+
+test.beforeAll(async () => {
+ test.skip(!existsSync(MAIN), 'run `npm run build` first')
+ userData = mkdtempSync(join(tmpdir(), 'filesmith-ui-'))
+ app = await _electron.launch({
+ args: [ROOT],
+ env: { ...process.env, FILESMITH_USER_DATA: userData }
+ })
+ page = await app.firstWindow()
+ await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1440, 900))
+})
+
+test.afterAll(async () => {
+ await app?.close()
+ if (userData) rmSync(userData, { recursive: true, force: true })
+})
+
+const sidebar = (p: Page): Locator => p.getByRole('navigation', { name: 'Operations' })
+const sidebarToggle = (p: Page): Locator => sidebar(p).getByRole('button', { name: /sidebar/ })
+
+test('the app is dark and square', async () => {
+ const bg = await page.evaluate(() => getComputedStyle(document.body).backgroundColor)
+ expect(bg).toBe('rgb(10, 10, 10)')
+ const radius = await page.getByTestId('run').evaluate((el) => getComputedStyle(el).borderRadius)
+ expect(radius).toBe('0px')
+})
+
+test('the sidebar collapses with its toggle and Ctrl+B, and remembers it', async () => {
+ const toggle = sidebarToggle(page)
+ await expect(toggle).toHaveAttribute('aria-expanded', 'true')
+ await toggle.click()
+ await expect(toggle).toHaveAttribute('aria-expanded', 'false')
+ await expect.poll(async () => (await sidebar(page).boundingBox())?.width).toBe(48)
+ await page.reload()
+ await expect(sidebarToggle(page)).toHaveAttribute('aria-expanded', 'false')
+ await page.keyboard.press('Control+B')
+ await expect(sidebarToggle(page)).toHaveAttribute('aria-expanded', 'true')
+ await expect.poll(async () => (await sidebar(page).boundingBox())?.width).toBe(208)
+})
+
+test('inspector tabs switch with a click and the arrow keys', async () => {
+ const tabs = page.getByRole('tablist', { name: 'Inspector' })
+ const options = tabs.getByRole('tab', { name: 'Options' })
+ const preview = tabs.getByRole('tab', { name: 'Preview' })
+ const info = tabs.getByRole('tab', { name: 'Info' })
+ await preview.click()
+ await expect(preview).toHaveAttribute('aria-selected', 'true')
+ await page.keyboard.press('ArrowRight')
+ await expect(info).toHaveAttribute('aria-selected', 'true')
+ await page.keyboard.press('Home')
+ await expect(options).toHaveAttribute('aria-selected', 'true')
+ // Switching tab must not shift the row: the bold width is reserved.
+ const before = await options.boundingBox()
+ await info.click()
+ expect((await options.boundingBox())?.width).toBe(before?.width)
+ await options.click()
+})
+
+test('an empty workspace offers Add files and a disabled Run', async () => {
+ await expect(page.getByText('No files yet')).toBeVisible()
+ await expect(
+ page.getByRole('grid', { name: 'Files' }).getByRole('button', { name: 'Add files' })
+ ).toBeVisible()
+ await expect(page.getByTestId('run')).toBeDisabled()
+ await expect(page.getByText('no files', { exact: true })).toBeVisible()
+ await expect(page.getByRole('row', { name: 'Totals' })).toHaveCount(0)
+})
+
+test('the toolbar is just Add files; row actions live in the right-click menu', async () => {
+ const bar = page.getByRole('toolbar', { name: 'File actions' })
+ const names = await bar
+ .getByRole('button')
+ .evaluateAll((els) => els.map((e) => e.getAttribute('aria-label') ?? e.textContent?.trim()))
+ expect(names).toEqual(['Add files'])
+ await expect(page.getByTestId('stop')).toHaveCount(0)
+})
diff --git a/e2e/visual.spec.ts b/e2e/visual.spec.ts
new file mode 100644
index 0000000..f9f43bf
--- /dev/null
+++ b/e2e/visual.spec.ts
@@ -0,0 +1,172 @@
+import { _electron, type ElectronApplication, type Page } from 'playwright'
+import { test, expect } from '@playwright/test'
+import { execFileSync } from 'child_process'
+import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'fs'
+import { tmpdir } from 'os'
+import { join } from 'path'
+import { MAGICK, MAIN, ROOT, magickEnv } from './helpers'
+
+// Screenshots for the owner's side-by-side with the signed-off mockup. No pixel
+// assertions (spec 7.3): the shots are evidence, not a gate.
+const SHOTS = join(ROOT, 'docs', 'mockups', 'terminal-v5', 'shots')
+let app: ElectronApplication
+let page: Page
+let userData: string
+let files: string
+
+const NAMES = [
+ 'IMG_2041.heic',
+ 'beach-panorama.png',
+ 'scan_0007.tiff',
+ 'portrait.jpg',
+ 'diagram.png',
+ 'IMG_2042.heic'
+]
+
+function make(name: string, size: string): string {
+ const p = join(files, name)
+ // A noisy image so sizes look realistic; HEIC names get JPG bytes, which the
+ // bundled magick always encodes.
+ execFileSync(MAGICK, ['-size', size, 'plasma:', p], { env: magickEnv })
+ return p
+}
+
+type SeedFile = { path: string; name: string; ext: string; kind: string; size: number }
+
+function info(p: string): SeedFile {
+ const name = p.split(/[\\/]/).pop() as string
+ return { path: p, name, ext: '.' + name.split('.').pop(), kind: 'image', size: statSync(p).size }
+}
+
+test.beforeAll(async () => {
+ // Opt-in: the shots are tracked files, so a routine `npm run test:e2e` must not rewrite them.
+ test.skip(
+ !process.env['FILESMITH_SHOTS'],
+ 'set FILESMITH_SHOTS=1 to capture the redesign screenshots'
+ )
+ test.skip(!existsSync(MAIN) || !existsSync(MAGICK), 'needs `npm run build` and resources/bin')
+ userData = mkdtempSync(join(tmpdir(), 'filesmith-visual-'))
+ files = join(userData, 'files')
+ mkdirSync(files)
+ const paths = NAMES.map((n, i) =>
+ make(
+ n.endsWith('.heic') ? n.replace('.heic', '.jpg') : n,
+ i === 2 ? '6000x4000' : i === 1 ? '3000x2000' : '1600x1200'
+ )
+ )
+ const outs = [make('IMG_2041.webp', '800x600'), make('beach-panorama.webp', '1200x800')]
+ const item = (i: number, over: Record): Record => ({
+ id: `seed-${i}`,
+ file: info(paths[i]),
+ thumb: null,
+ status: 'ready',
+ percent: 0,
+ ...over
+ })
+ const done = (i: number, out: string): Record =>
+ item(i, {
+ status: 'done',
+ percent: 100,
+ outputPath: out,
+ outputSize: statSync(out).size,
+ runOptions: { format: '.webp', quality: 'balanced' }
+ })
+ const session = {
+ version: 2,
+ lastTool: null,
+ options: { 'convert:image': { format: '.webp', quality: 'balanced' } },
+ genResults: [],
+ queues: {
+ convert: {
+ items: [
+ done(0, outs[0]),
+ done(1, outs[1]),
+ item(2, {}),
+ item(3, {}),
+ item(4, {}),
+ item(5, {
+ status: 'failed',
+ error: 'Unsupported compression in this TIFF variant. Convert it to PNG first.'
+ })
+ ]
+ }
+ }
+ }
+ writeFileSync(join(userData, 'session.json'), JSON.stringify(session))
+ app = await _electron.launch({
+ args: [ROOT],
+ env: { ...process.env, FILESMITH_USER_DATA: userData }
+ })
+ page = await app.firstWindow()
+ await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1440, 900))
+ await expect(
+ page.getByRole('grid', { name: 'Files' }).getByRole('row', { name: /portrait/ })
+ ).toBeVisible()
+})
+
+test.afterAll(async () => {
+ await app?.close()
+ if (userData) rmSync(userData, { recursive: true, force: true })
+})
+
+test('capture the redesign next to the mockup', async () => {
+ // Select one row, as the mockup does, then start a real job on another so a
+ // running row with NN%(Ns) is on screen. The status reads `NN%`; the result
+ // cell's signed estimate (`-98%`) must not match.
+ await page.getByRole('row', { name: /beach-panorama/ }).click()
+ await page.evaluate(
+ ({ input }) =>
+ window.filesmith.runJob({
+ id: 'seed-2',
+ tool: 'resize',
+ input,
+ options: { mode: 'percent', percent: 300 } as never
+ }),
+ { input: join(files, NAMES[2]) }
+ )
+ await expect(page.getByRole('row', { name: /scan_0007/ }).getByText(/^\d[\d.]*%/)).toBeVisible({
+ timeout: 20_000
+ })
+ await page.screenshot({ path: join(SHOTS, 'impl-10-s2.png') })
+
+ // The right-click menu on the selected row.
+ await page.getByRole('row', { name: /beach-panorama/ }).click({ button: 'right' })
+ await expect(page.getByRole('menu')).toBeVisible()
+ // Let the fade-in finish so the shot shows the settled menu.
+ await expect
+ .poll(() => page.getByRole('menu').evaluate((el) => el.getAnimations().length))
+ .toBe(0)
+ await page.screenshot({ path: join(SHOTS, 'impl-menu.png') })
+ await page.keyboard.press('Escape')
+
+ await page
+ .getByRole('navigation', { name: 'Operations' })
+ .getByRole('button', { name: /sidebar/ })
+ .click()
+ // Wait out the width transition so the shot shows the settled 48px rail.
+ await expect
+ .poll(
+ async () => (await page.getByRole('navigation', { name: 'Operations' }).boundingBox())?.width
+ )
+ .toBe(48)
+ await page.screenshot({ path: join(SHOTS, 'impl-collapsed.png') })
+ await page.keyboard.press('Control+B')
+
+ await page.getByRole('tab', { name: 'Preview' }).click()
+ await page.screenshot({ path: join(SHOTS, 'impl-preview.png') })
+ await page.getByRole('tab', { name: 'Info' }).click()
+ await page.screenshot({ path: join(SHOTS, 'impl-info.png') })
+ await page.getByRole('tab', { name: 'Options' }).click()
+
+ const nav = page.getByRole('navigation', { name: 'Operations' })
+ for (const [label, file] of [
+ ['Compress', 'impl-empty.png'],
+ ['Generate', 'impl-generate.png'],
+ ['Tools', 'impl-tools.png'],
+ ['Completed', 'impl-completed.png'],
+ ['Settings', 'impl-settings.png']
+ ]) {
+ await nav.getByRole('button', { name: label }).click()
+ await page.screenshot({ path: join(SHOTS, file) })
+ }
+})
diff --git a/e2e/workflows.spec.ts b/e2e/workflows.spec.ts
index d0ead18..bc301c0 100644
--- a/e2e/workflows.spec.ts
+++ b/e2e/workflows.spec.ts
@@ -171,35 +171,42 @@ test.afterAll(async () => {
/** Tools always opens on its grid, however you last left it. */
async function openToolsGrid(p: Page): Promise {
- await p.locator('button:has-text("Tools")').first().click()
+ await p
+ .getByRole('navigation', { name: 'Operations' })
+ .getByRole('button', { name: 'Tools' })
+ .click()
await expect(p.locator('h1', { hasText: 'Tools' }).first()).toBeVisible()
}
test('the rail lists every verb and opens its workspace', async () => {
+ const nav = page.getByRole('navigation', { name: 'Operations' })
for (const verb of ['Convert', 'Compress', 'Resize', 'Upscale', 'Remove BG', 'Generate']) {
- await page.locator(`button:has-text("${verb}")`).first().click()
+ await nav.getByRole('button', { name: verb }).click()
await expect(page.locator('h1', { hasText: verb }).first()).toBeVisible()
}
await openToolsGrid(page)
- await page.locator('button:has-text("Convert")').first().click()
+ await nav.getByRole('button', { name: 'Convert' }).click()
})
test('Tools groups its one-off verbs and opens one as a workspace', async () => {
await openToolsGrid(page)
- for (const t of ['Extract text', 'Pages to PNG', 'Merge', 'Split', 'Burst']) {
- await expect(page.locator(`text=${t}`).first()).toBeVisible()
- }
+ const tools = page.getByRole('region', { name: 'Tools' })
+ for (const t of ['Extract text', 'Pages to PNG', 'Merge', 'Split', 'Burst'])
+ await expect(tools.getByText(t, { exact: true }).first()).toBeVisible()
// Archive work is an ordinary Convert now, not a tool hidden in here.
await expect(page.locator('text=Archive to PDF')).toHaveCount(0)
await expect(page.locator('text=PDF to CBZ')).toHaveCount(0)
- await page.locator('button:has-text("Merge")').first().click()
+ await tools.getByRole('button', { name: /^Merge/ }).click()
// A tool workspace is an ordinary queue titled with the tool's name.
await expect(page.locator('h1', { hasText: 'Merge' }).first()).toBeVisible()
- await expect(page.locator('text=Files').first()).toBeVisible()
+ await expect(page.getByRole('grid', { name: 'Files' })).toBeVisible()
// Back returns to the grid, which is the app's only second level.
await page.locator('button[aria-label="Back to Tools"]').first().click()
await expect(page.locator('h1', { hasText: 'Tools' }).first()).toBeVisible()
- await page.locator('button:has-text("Convert")').first().click()
+ await page
+ .getByRole('navigation', { name: 'Operations' })
+ .getByRole('button', { name: 'Convert' })
+ .click()
})
// NOTE: the mixed-kind queue, its group headers and the kind-scoped options
diff --git a/package-lock.json b/package-lock.json
index 029701c..14fd14d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,15 +1,18 @@
{
"name": "filesmith",
- "version": "0.2.1",
+ "version": "0.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "filesmith",
- "version": "0.2.1",
+ "version": "0.5.0",
+ "hasInstallScript": true,
"license": "MIT",
"devDependencies": {
"@eslint/js": "^10.0.1",
+ "@fontsource/ibm-plex-mono": "^5.3.0",
+ "@fontsource/ibm-plex-sans": "^5.3.0",
"@playwright/test": "^1.62.1",
"@tailwindcss/vite": "^4.3.2",
"@types/node": "^26.1.1",
@@ -1221,6 +1224,26 @@
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
+ "node_modules/@fontsource/ibm-plex-mono": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.3.0.tgz",
+ "integrity": "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==",
+ "dev": true,
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
+ "node_modules/@fontsource/ibm-plex-sans": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-sans/-/ibm-plex-sans-5.3.0.tgz",
+ "integrity": "sha512-CbE4CbbEEZJX860XyUiRpsksXIQR8Rp2XDva2VO53NJox9tVNtusrysd2x5YkUEY3ErQ66W1IiiQL8/wihhw5w==",
+ "dev": true,
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
diff --git a/package.json b/package.json
index 476c3e4..5acc24f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "filesmith",
- "version": "0.4.0",
+ "version": "0.5.0",
"description": "A desktop file toolkit: convert, compress, resize, upscale, remove backgrounds, and PDF tools.",
"author": "Max",
"license": "MIT",
@@ -23,6 +23,8 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
+ "@fontsource/ibm-plex-mono": "^5.3.0",
+ "@fontsource/ibm-plex-sans": "^5.3.0",
"@playwright/test": "^1.62.1",
"@tailwindcss/vite": "^4.3.2",
"@types/node": "^26.1.1",
diff --git a/src/main/index.ts b/src/main/index.ts
index b0bbea6..ab58cf3 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -1,4 +1,4 @@
-import { app, protocol, shell, BrowserWindow } from 'electron'
+import { app, nativeTheme, protocol, screen, shell, BrowserWindow } from 'electron'
import { join, extname } from 'path'
import { createReadStream, readdirSync, rmSync, statSync } from 'fs'
import { tmpdir } from 'os'
@@ -10,6 +10,12 @@ import { spandrelSidecar } from './comfy/sidecar'
import { stopComfyServer } from './generate'
import { ensureUserLayers } from './registry/load'
import { scheduleChannelRefresh } from './registry/channel'
+import { initialWindowSize, MIN_WINDOW } from './windowSize'
+
+// Test hook (spec M4): e2e points userData at a temp folder to seed a session.
+// Read only when set, so a normal launch is unaffected.
+const userDataOverride = process.env['FILESMITH_USER_DATA']
+if (userDataOverride) app.setPath('userData', userDataOverride)
// Remove temp dirs orphaned by a previous HARD crash (normal runs delete their
// own in a finally). Guarded by age so a concurrent second instance's in-use
@@ -132,15 +138,18 @@ function serveMedia(request: Request): Response {
}
function createWindow(): void {
+ // Dark only: native scrollbars, dialogs and the pre-paint fill follow the app.
+ nativeTheme.themeSource = 'dark'
+ const size = initialWindowSize(screen.getPrimaryDisplay().workAreaSize)
const mainWindow = new BrowserWindow({
- width: 1160,
- height: 760,
- minWidth: 900,
- minHeight: 580,
+ width: size.width,
+ height: size.height,
+ minWidth: MIN_WINDOW.width,
+ minHeight: MIN_WINDOW.height,
show: false,
- // Frameless: the app draws its own top strip + window controls (RCMM-style).
+ // Frameless: the renderer draws the 32px title bar and window controls.
frame: false,
- backgroundColor: '#f4f4f6',
+ backgroundColor: '#0a0a0a',
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false
diff --git a/src/main/ipc.ts b/src/main/ipc.ts
index 35b002a..25266b6 100644
--- a/src/main/ipc.ts
+++ b/src/main/ipc.ts
@@ -165,6 +165,14 @@ export function registerGlobalIpc(): JobQueue {
})
return r.canceled ? [] : r.filePaths.map(fileInfoFromPath).filter(isSupported)
})
+ // Output folder for OUTPUT > Location. Additive: no existing channel changes.
+ ipcMain.handle('files:pick-folder', async (e) => {
+ const r = await openDialog(e, {
+ title: 'Choose output folder',
+ properties: ['openDirectory', 'createDirectory']
+ })
+ return r.canceled || !r.filePaths.length ? null : r.filePaths[0]
+ })
// Video DISPLAY dimensions (rotation-aware) for the compress scale preview.
ipcMain.handle('video:dimensions', (_e, p: string) => probeDimensions(p))
// Images go through ImageMagick instead: ffprobe rejects very large ones and
diff --git a/src/main/jobQueue.ts b/src/main/jobQueue.ts
index fe00245..6f66e16 100644
--- a/src/main/jobQueue.ts
+++ b/src/main/jobQueue.ts
@@ -2,6 +2,7 @@ import { cpus } from 'os'
import { statSync } from 'fs'
import type { JobEvent, JobRequest, ToolId } from '@shared/types'
import { fileInfoFromPath } from './fileInfo'
+import { resolveOutDir } from './output'
import { getTool } from './tools/registry'
import { ToolMissingError } from './run'
import { toolMissingMessage } from './toolResolver'
@@ -88,8 +89,10 @@ export class JobQueue {
this.emit({ id: req.id, status: 'running' })
try {
const file = fileInfoFromPath(req.input)
+ const outDir = resolveOutDir(req.options.outDir)
const output = await tool.run(file, req.options, {
signal: ctrl.signal,
+ outDir,
onProgress: (percent, message, etaSec) =>
this.emit({
id: req.id,
diff --git a/src/main/output.ts b/src/main/output.ts
index dd50130..5286a88 100644
--- a/src/main/output.ts
+++ b/src/main/output.ts
@@ -38,13 +38,28 @@ export function reserveFileInDir(dir: string, name: string, ext: string, tag: st
}
}
-/** Atomically-reserved output next to the source with a new extension. */
-export function reserveOutPath(sourcePath: string, ext: string, tag: string): string {
- const dir = dirname(sourcePath)
+/** Atomically-reserved output with a new extension: next to the source, or in
+ * `outDir` when the user chose an output folder. Collision rules are identical. */
+export function reserveOutPath(
+ sourcePath: string,
+ ext: string,
+ tag: string,
+ outDir?: string
+): string {
+ const dir = outDir ?? dirname(sourcePath)
const name = basename(sourcePath, extname(sourcePath))
return reserveFileInDir(dir, name, ext, tag)
}
+/** The job's output folder from `options.outDir`, or undefined for "next to
+ * source". A folder that has gone missing is an error: silently writing next to
+ * the source would put files where the user did not ask for them. */
+export function resolveOutDir(value: unknown): string | undefined {
+ if (typeof value !== 'string' || value.trim() === '') return undefined
+ if (!existsSync(value)) throw new Error(`Output folder not found: ${value}`)
+ return value
+}
+
/** Collision-free directory: `base` -> `base (2)` -> `base (3)` ... */
export function uniqueOutDir(dir: string, base: string): string {
let cand = join(dir, base)
diff --git a/src/main/tools/estimate.ts b/src/main/tools/estimate.ts
index 25879c0..ac3e482 100644
--- a/src/main/tools/estimate.ts
+++ b/src/main/tools/estimate.ts
@@ -15,10 +15,11 @@ export interface EstimateTicker {
* that eases from 0 toward `ceiling` (default 95, never reached) — reaching ~95%
* of the ceiling at `expectedSec`, then creeping the rest. `startPct` lets a
* restart continue from where a previous ticker left off instead of snapping back.
+ * `etaSec` is the rounded-up seconds left of `expectedSec`, or null once overdue.
*/
export function estimateProgress(
expectedSec: number,
- onPct: (pct: number) => void,
+ onPct: (pct: number, etaSec: number | null) => void,
opts: { ceiling?: number; startPct?: number } = {}
): EstimateTicker {
const ceiling = opts.ceiling ?? 95
@@ -30,7 +31,10 @@ export function estimateProgress(
const tick = (): void => {
elapsed += intervalMs / 1000
const pct = startPct + (ceiling - startPct) * (1 - Math.exp(-elapsed / tau))
- onPct(Math.min(ceiling, pct))
+ // The same expected duration that shapes the curve doubles as an ETA, so
+ // image, PDF and rembg rows can show 62%(4s) like ffmpeg rows do.
+ const left = expectedSec - elapsed
+ onPct(Math.min(ceiling, pct), left > 0 ? Math.ceil(left) : null)
}
tick() // emit an initial value immediately so the bar goes determinate at once
let timer: ReturnType | null = setInterval(tick, intervalMs)
diff --git a/src/main/tools/registry.ts b/src/main/tools/registry.ts
index 1c165c2..64b1e6f 100644
--- a/src/main/tools/registry.ts
+++ b/src/main/tools/registry.ts
@@ -115,7 +115,9 @@ async function runToOutput(
// If the tool reports no real progress (no onStderr parser) but the caller
// gave an expected duration, drive an estimated bar so the % always moves.
const est =
- !onStderr && estimateSec ? estimateProgress(estimateSec, (p) => ctx.onProgress(p)) : null
+ !onStderr && estimateSec
+ ? estimateProgress(estimateSec, (p, eta) => ctx.onProgress(p, undefined, eta))
+ : null
try {
const { code, stderr } = await run(tool, argsFor(toolOut), { signal: ctx.signal, onStderr })
// magick prints "no encode delegate for this image format" as a WARNING and
@@ -201,7 +203,7 @@ const convertTool: ToolModule = {
// PDF -> plain text extracts reliably via mutool, no LibreOffice required.
if (file.kind === 'pdf' && isSameFormat(targetExt, '.txt')) {
- const output = reserveOutPath(file.path, '.txt', 'converted')
+ const output = reserveOutPath(file.path, '.txt', 'converted', ctx.outDir)
return runToOutput(
resolveTool('mutool'),
(out) => buildPdfTextArgs(file.path, out),
@@ -249,7 +251,7 @@ const convertTool: ToolModule = {
last || `LibreOffice couldn't convert to ${targetExt.replace('.', '').toUpperCase()}`
)
}
- const output = reserveOutPath(file.path, targetExt, 'converted')
+ const output = reserveOutPath(file.path, targetExt, 'converted', ctx.outDir)
try {
if (isSameFormat(targetExt, '.txt')) {
// Drop the UTF-8 BOM the encoded-Text filter prepends.
@@ -276,7 +278,7 @@ const convertTool: ToolModule = {
}
}
- const output = reserveOutPath(file.path, targetExt, 'converted')
+ const output = reserveOutPath(file.path, targetExt, 'converted', ctx.outDir)
if (kindTool === 'ffmpeg') {
// Real progress for media transcodes (they can run for a long time).
const duration = await probeDuration(file.path)
@@ -322,7 +324,7 @@ const pdfTool: ToolModule = {
if (op === 'merge') {
const inputs = Array.isArray(options.mergeInputs) ? options.mergeInputs : [file.path]
if (inputs.length < 2) throw new Error('Select at least two PDFs to merge')
- const output = reserveOutPath(file.path, '.pdf', 'merged')
+ const output = reserveOutPath(file.path, '.pdf', 'merged', ctx.outDir)
ctx.onProgress(undefined, `Merging ${inputs.length} PDFs…`)
return runToOutput(
mutool,
@@ -340,7 +342,7 @@ const pdfTool: ToolModule = {
if (op === 'split-range') {
const pages = normalizePageRange(String(options.range ?? ''))
if (!pages) throw new Error('Enter pages to keep, e.g. 1-3,5')
- const output = reserveOutPath(file.path, '.pdf', 'pages')
+ const output = reserveOutPath(file.path, '.pdf', 'pages', ctx.outDir)
ctx.onProgress(undefined, `Extracting pages ${pages}…`)
return runToOutput(
mutool,
@@ -361,7 +363,7 @@ const pdfTool: ToolModule = {
if (count < 1)
throw new Error(info.stderr.trim().split('\n').pop()?.trim() || 'Could not read the PDF')
const base = basename(file.path, extname(file.path))
- const dir = uniqueOutDir(dirname(file.path), base + ' (split)')
+ const dir = uniqueOutDir(ctx.outDir ?? dirname(file.path), base + ' (split)')
mkdirSync(dir, { recursive: true })
const width = String(count).length
try {
@@ -391,7 +393,7 @@ const pdfTool: ToolModule = {
// emits font-* files, which we drop so the folder is images-only.
if (op === 'extract-images') {
const base = basename(file.path, extname(file.path))
- const dir = uniqueOutDir(dirname(file.path), base + ' (images)')
+ const dir = uniqueOutDir(ctx.outDir ?? dirname(file.path), base + ' (images)')
mkdirSync(dir, { recursive: true })
ctx.onProgress(undefined, 'Extracting images…')
const { code, stderr } = await run(mutool, buildPdfExtractArgs(file.path), {
@@ -430,7 +432,7 @@ const pdfTool: ToolModule = {
if (op === 'pages-to-images') {
const dpi = Math.max(36, Math.min(600, Number(options.dpi ?? 150)))
const dir = uniqueOutDir(
- dirname(file.path),
+ ctx.outDir ?? dirname(file.path),
basename(file.path, extname(file.path)) + ' (pages)'
)
mkdirSync(dir, { recursive: true })
@@ -439,7 +441,9 @@ const pdfTool: ToolModule = {
// expanded too. Render into a neutral temp dir then, and move the pages.
const renderDir = dir.includes('%') ? mkdtempSync(join(tmpdir(), 'filesmith-pages-')) : dir
ctx.onProgress(undefined, `Rendering pages @ ${dpi} DPI…`)
- const est = estimateProgress(estimateSecForBytes(file.size, 0.15), (p) => ctx.onProgress(p))
+ const est = estimateProgress(estimateSecForBytes(file.size, 0.15), (p, eta) =>
+ ctx.onProgress(p, undefined, eta)
+ )
try {
const { code, stderr } = await run(mutool, buildPdfImagesArgs(file.path, renderDir, dpi), {
signal: ctx.signal
@@ -465,7 +469,7 @@ const pdfTool: ToolModule = {
}
// extract-text
- const output = reserveOutPath(file.path, '.txt', 'text')
+ const output = reserveOutPath(file.path, '.txt', 'text', ctx.outDir)
ctx.onProgress(undefined, 'Extracting text…')
return runToOutput(
mutool,
@@ -485,7 +489,7 @@ const resizeTool: ToolModule = {
const spec = buildResizeSpec(options)
if (!isValidResizeSpec(spec))
throw new Error('Enter a width, height, or percentage to resize by')
- const output = reserveOutPath(file.path, file.ext, 'resized')
+ const output = reserveOutPath(file.path, file.ext, 'resized', ctx.outDir)
ctx.onProgress(undefined, `Resizing ${spec}…`)
const animated = normalizeExt(file.ext) === '.gif'
return runToOutput(
@@ -511,7 +515,7 @@ const compressTool: ToolModule = {
if (file.kind === 'pdf') {
const level = String(options.pdfLevel ?? 'balanced') as PdfLevel
const gray = Boolean(options.pdfGray)
- const output = reserveOutPath(file.path, '.pdf', 'compressed')
+ const output = reserveOutPath(file.path, '.pdf', 'compressed', ctx.outDir)
if (level === 'lossless') {
ctx.onProgress(undefined, 'Compressing PDF (lossless)…')
return runToOutput(
@@ -543,7 +547,7 @@ const compressTool: ToolModule = {
if (file.kind === 'video') {
const codec = String(options.videoCodec ?? 'h264') as VideoCodec
const scale = Number(options.scale ?? 100)
- const output = reserveOutPath(file.path, '.mp4', 'compressed')
+ const output = reserveOutPath(file.path, '.mp4', 'compressed', ctx.outDir)
// Real progress: ffmpeg's `time=` against the source duration. Long
// re-encodes (a full movie) otherwise look stuck on an indeterminate bar.
const duration = await probeDuration(file.path)
@@ -566,7 +570,12 @@ const compressTool: ToolModule = {
if (file.kind === 'audio') {
const codec = String(options.audioCodec ?? 'keep') as AudioCodec
const bitrate = Number(options.audioBitrate ?? 192)
- const output = reserveOutPath(file.path, audioOutputExt(codec, file.ext), 'compressed')
+ const output = reserveOutPath(
+ file.path,
+ audioOutputExt(codec, file.ext),
+ 'compressed',
+ ctx.outDir
+ )
const duration = await probeDuration(file.path)
ctx.onProgress(duration ? 0 : undefined, `Compressing audio (${bitrate}k)…`)
return runToOutput(
@@ -592,9 +601,11 @@ const compressTool: ToolModule = {
// Declared outside try so the catch can clean the placeholder; reserved
// INSIDE try so a throw there still hits the finally that removes tmp.
let output: string | undefined
- const est = estimateProgress(estimateSecForBytes(file.size, 0.08), (p) => ctx.onProgress(p))
+ const est = estimateProgress(estimateSecForBytes(file.size, 0.08), (p, eta) =>
+ ctx.onProgress(p, undefined, eta)
+ )
try {
- output = reserveOutPath(file.path, file.ext, 'compressed')
+ output = reserveOutPath(file.path, file.ext, 'compressed', ctx.outDir)
const { code, stderr } = await run(
resolveTool('caesiumclt'),
buildCompressArgs(file.path, tmp, quality),
@@ -622,7 +633,7 @@ const compressTool: ToolModule = {
// Convert to webp/avif, or re-encode a non-Caesium source format in place —
// both via ImageMagick, output extension chosen by the target format.
const outExt = imageFormat === 'keep' ? file.ext : `.${imageFormat}`
- const output = reserveOutPath(file.path, outExt, 'compressed')
+ const output = reserveOutPath(file.path, outExt, 'compressed', ctx.outDir)
return runToOutput(
resolveTool('magick'),
(out) => buildMagickCompressArgs(file.path, out, quality),
@@ -723,9 +734,9 @@ async function upscaleWithPid(file: FileInfo, factor: number, ctx: ToolContext):
est?.stop()
est = estimateProgress(
expectedSec,
- (p) => {
+ (p, eta) => {
lastPct = p
- ctx.onProgress(p)
+ ctx.onProgress(p, undefined, eta)
},
{ startPct: lastPct }
)
@@ -740,7 +751,7 @@ async function upscaleWithPid(file: FileInfo, factor: number, ctx: ToolContext):
})
if (code !== 0 || !existsSync(src)) throw new Error(describeToolError(stderr, 'magick', code))
}
- output = reserveOutPath(file.path, '.png', 'upscaled')
+ output = reserveOutPath(file.path, '.png', 'upscaled', ctx.outDir)
ctx.onProgress(undefined, 'Starting PiD…')
// The sidecar writes to a temp target, copied onto the reserved name only
// on success: a cancelled python run keeps going and writes its target
@@ -819,7 +830,7 @@ async function upscaleWithComfy(
})
if (code !== 0 || !existsSync(src)) throw new Error(describeToolError(stderr, 'magick', code))
}
- output = reserveOutPath(file.path, '.png', 'upscaled')
+ output = reserveOutPath(file.path, '.png', 'upscaled', ctx.outDir)
const label = background
? `Upscaling with ${model.name} (background)…`
: `Upscaling with ${model.name}…`
@@ -906,7 +917,7 @@ const upscaleTool: ToolModule = {
throw new Error(
'No AI upscale models are installed. Reinstall Filesmith, or add a Real-ESRGAN .param/.bin pair to your models folder.'
)
- output = reserveOutPath(file.path, '.png', 'upscaled')
+ output = reserveOutPath(file.path, '.png', 'upscaled', ctx.outDir)
const label = background
? `Upscaling ${factor}× (${ncnn.label}, background)…`
: `Upscaling ${factor}× (${ncnn.label})…`
@@ -983,11 +994,11 @@ const removebgTool: ToolModule = {
throw new Error(describeToolError(stderr, 'magick', code))
}
- output = reserveOutPath(file.path, '.png', 'no-bg')
+ output = reserveOutPath(file.path, '.png', 'no-bg', ctx.outDir)
// The first run of a model pays a download; every run pays a load. Say so,
// because a silent multi-second wait reads as a hang.
ctx.onProgress(undefined, 'Loading model…')
- est = estimateProgress(6, (p) => ctx.onProgress(p))
+ est = estimateProgress(6, (p, eta) => ctx.onProgress(p, undefined, eta))
const { code, stderr } = await run(
rembg.cmd,
[...rembg.prefix, ...buildRembgArgs(src, output, options)],
@@ -1162,7 +1173,7 @@ const archiveTool: ToolModule = {
// Unpack into a new folder next to the source.
if (op === 'extract') {
const dir = uniqueOutDir(
- dirname(file.path),
+ ctx.outDir ?? dirname(file.path),
basename(file.path, extname(file.path)) + ' (extracted)'
)
mkdirSync(dir, { recursive: true })
@@ -1198,7 +1209,7 @@ const archiveTool: ToolModule = {
assertRarTarget(targetExt)
const store = options.store !== false
const temp = await extractToTemp(file.path, ctx)
- const output = reserveOutPath(file.path, targetExt, 'converted')
+ const output = reserveOutPath(file.path, targetExt, 'converted', ctx.outDir)
try {
await packDir(temp, output, targetExt, store, ctx)
if (!existsSync(output) || statSync(output).size === 0)
@@ -1237,7 +1248,7 @@ const archiveTool: ToolModule = {
return p
})
- const output = reserveOutPath(file.path, '.pdf', 'converted')
+ const output = reserveOutPath(file.path, '.pdf', 'converted', ctx.outDir)
const magick = resolveTool('magick')
// Windows caps a command line at 32767 characters and a long comic
// blows past it, so build part PDFs and let mutool merge join them.
@@ -1303,9 +1314,9 @@ const archiveTool: ToolModule = {
// Always a neutral temp dir, so mutool draw's printf `-o` pattern can
// never expand a `%` inherited from the source file's name.
const temp = mkdtempSync(join(tmpdir(), 'filesmith-arc-'))
- const output = reserveOutPath(file.path, targetExt, 'converted')
- const est = estimateProgress(estimateSecForBytes(file.size, 0.15), (p) =>
- ctx.onProgress(Math.min(p, 90))
+ const output = reserveOutPath(file.path, targetExt, 'converted', ctx.outDir)
+ const est = estimateProgress(estimateSecForBytes(file.size, 0.15), (p, eta) =>
+ ctx.onProgress(Math.min(p, 90), undefined, eta)
)
try {
ctx.onProgress(undefined, `Rendering pages @ ${dpi} DPI…`)
diff --git a/src/main/tools/tool.ts b/src/main/tools/tool.ts
index 456677c..377b67c 100644
--- a/src/main/tools/tool.ts
+++ b/src/main/tools/tool.ts
@@ -3,6 +3,8 @@ import type { FileInfo, JobOptions } from '@shared/types'
/** Runtime context handed to a tool's run(): cancellation + progress reporting. */
export interface ToolContext {
signal: AbortSignal
+ /** Folder chosen under OUTPUT > Location; undefined means next to the source. */
+ outDir?: string
/** `etaSec` is seconds remaining, when the tool can estimate it. */
onProgress: (percent: number | undefined, message?: string, etaSec?: number | null) => void
}
diff --git a/src/main/windowSize.ts b/src/main/windowSize.ts
new file mode 100644
index 0000000..ece35b3
--- /dev/null
+++ b/src/main/windowSize.ts
@@ -0,0 +1,15 @@
+// The redesign's table needs about 1440px to give the name column room
+// (spec section 3.7). Clamp to the display so a small laptop still fits.
+
+export const DEFAULT_WINDOW = { width: 1440, height: 900 }
+export const MIN_WINDOW = { width: 1100, height: 640 }
+
+export function initialWindowSize(work: { width: number; height: number }): {
+ width: number
+ height: number
+} {
+ return {
+ width: Math.max(MIN_WINDOW.width, Math.min(DEFAULT_WINDOW.width, work.width)),
+ height: Math.max(MIN_WINDOW.height, Math.min(DEFAULT_WINDOW.height, work.height))
+ }
+}
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 4e321d9..7485d2b 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -21,6 +21,8 @@ const api = {
pickFiles: (): Promise => ipcRenderer.invoke('files:pick'),
/** Pick one image to use as a Remove Background backdrop. */
pickImage: (): Promise => ipcRenderer.invoke('image:pick'),
+ /** Folder picker for the output location; resolves to the path or null. */
+ pickFolder: (): Promise => ipcRenderer.invoke('files:pick-folder'),
classify: (paths: string[]): Promise => ipcRenderer.invoke('files:classify', paths),
/** Resolve the absolute path of a dropped File (Electron removed File.path). */
pathForFile: (file: File): string => webUtils.getPathForFile(file),
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index 1b39ee9..bc36cff 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -8,7 +8,7 @@ import {
type JSX,
type MouseEvent
} from 'react'
-import type { FileInfo, FileKind } from '@shared/types'
+import type { FileInfo, FileKind, JobOptions, ToolId } from '@shared/types'
import {
canCompress,
convertGroup,
@@ -34,7 +34,6 @@ import {
defaultOptionsFor,
emptyQueue,
inInput,
- inOutput,
groupOf,
newId,
parseSession,
@@ -45,49 +44,49 @@ import {
type SelectMode
} from './state'
import { engineFor, tabAccepts, tabById, toolCardById, type TabId } from '@shared/tabs'
-import { TopBar } from './components/TopBar'
-import { TabRail } from './components/TabRail'
-import { OperationTitle } from './components/OperationTitle'
-import { ToolsGrid } from './components/ToolsGrid'
-import { CompletedView } from './components/CompletedView'
+import { TitleBar } from './components/shell/TitleBar'
+import { Sidebar } from './components/shell/Sidebar'
+import { StatusBar } from './components/shell/StatusBar'
+import { crumbsFor } from './components/shell/crumbs'
+import { useSidebar } from './components/shell/useSidebar'
+import { useRailPrefs } from './components/shell/useRailPrefs'
+import { sidebarVerbs } from './components/shell/railPrefs'
+import { statusSummary } from './components/shell/statusModel'
+import { shortcutFor } from './components/shell/shortcuts'
+import {
+ activeGroupFor,
+ headerCheck,
+ oneGroupIds,
+ toggleAllIds
+} from './components/queue/selectAll'
+import { QueueTable } from './components/queue/QueueTable'
+import { QueueToolbar } from './components/queue/QueueToolbar'
+import { doneSamples, queueTotals, type RowActionKind } from './components/queue/rowModel'
+import {
+ deleteConfirm,
+ menuTargets,
+ removeConfirm,
+ revealPath,
+ rowMenuModel,
+ type RowMenuAction
+} from './components/queue/rowMenu'
+import { groupedRows, nextSort, visibleOrder, type SortState } from './components/queue/tableSort'
+import { estimateBatch, estimateOutputBytes } from '@shared/sizeEstimate'
+import { EmptyState } from './components/queue/EmptyState'
+import { Inspector, type InspTab } from './components/inspector/Inspector'
+import { InfoGrid, InfoPane } from './components/inspector/InfoPane'
+import { genInfoRows } from './components/inspector/infoModel'
+import { PreviewPane } from './components/inspector/PreviewPane'
+import { OptionsPane } from './components/options/OptionsPane'
+import { useOptionSetter } from './components/options/useOptionSetter'
+import type { SizeRow } from './components/ui/OutputSizeList'
+import { groupNoun } from './components/queueGroups'
import { collectCompleted } from './components/completed'
-import { DropZone } from './components/DropZone'
-import { PromptBox } from './components/PromptBox'
+import { CompletedView } from './components/views/CompletedView'
+import { GenerateView } from './components/views/GenerateView'
+import { SettingsView } from './components/views/SettingsView'
+import { ToolsView } from './components/views/ToolsView'
import type { GenerateOptions } from '@shared/generate'
-
-/** One generated-image thumbnail: click to preview, right-click for the menu. */
-function GenTile({
- path,
- aspect,
- onPreview,
- onMenu
-}: {
- path: string
- aspect: string
- onPreview: (path: string) => void
- onMenu: (path: string, x: number, y: number) => void
-}): JSX.Element {
- return (
- onPreview(path)}
- onContextMenu={(e) => {
- e.preventDefault()
- onMenu(path, e.clientX, e.clientY)
- }}
- title={path}
- style={{ aspectRatio: aspect }}
- className="overflow-hidden rounded-xl border border-black/[.08] bg-white transition hover:border-accent"
- >
-
-
- )
-}
-import { Queues } from './components/Queue'
-import { OptionsPanel, type VideoOutputRow } from './components/OptionsPanel'
import { ContextMenu, type MenuState } from './components/ContextMenu'
import { ConfirmDialog, type ConfirmState } from './components/ConfirmDialog'
@@ -123,6 +122,13 @@ export default function App(): JSX.Element {
}>({ running: false, slots: [] })
const genIdRef = useRef(0)
const genActiveId = useRef(null)
+ // Model, size and seed of each image generated this session, for Info.
+ // State, not a ref: Info reads it during render.
+ const [genMeta, setGenMeta] = useState<
+ Record
+ >({})
+ // The generated image the inspector's Preview and Info show.
+ const [genFocus, setGenFocus] = useState(null)
const [outThumbs, setOutThumbs] = useState>({})
const [menu, setMenu] = useState(null)
// Which column/tool the open preview window is showing, so we can push live
@@ -143,6 +149,14 @@ export default function App(): JSX.Element {
// Session persistence: restore the last session (queues + produced files) on
// launch, pruning anything whose file was deleted since; then save on change.
const hydrated = useRef(false)
+ const sidebar = useSidebar()
+ const rail = useRailPrefs()
+ // Ids of the last run per workspace, for "Converting 3 of 6" (spec 6.4).
+ const [batches, setBatches] = useState>({})
+ // Per-workspace column sort; null is insertion order (spec 4.2).
+ const [sorts, setSorts] = useState>({})
+ // Inspector tab: per-session view state, not persisted (spec 3.4).
+ const [inspTab, setInspTab] = useState('options')
useEffect(() => {
let alive = true
@@ -211,6 +225,8 @@ export default function App(): JSX.Element {
const onToolsGrid = state.tab === 'tools' && !card
// Completed is a view of results, not a place you do work.
const onCompleted = state.tab === 'completed'
+ // Every item in every queue, for the output actions the Completed view drives.
+ const allItems = Object.values(state.queues).flatMap((q) => q?.items ?? [])
// Stream job progress/terminal events into state.
useEffect(() => window.filesmith.onJobEvent((e) => dispatch({ type: 'jobEvent', event: e })), [])
@@ -263,19 +279,30 @@ export default function App(): JSX.Element {
// Operations key off each item's EFFECTIVE file (a result's output file), so
// selecting an output and running uses the output's type.
const selectedItems = cur.items.filter((i) => cur.selected.includes(i.id))
- const selEff = selectedItems.map(effectiveFile)
+ // With nothing selected, options and Run speak for the first group in the
+ // queue (spec 4.1), not an empty panel.
+ const firstGroup = activeGroupFor(cur.items, [])
+ const scopeItems = selectedItems.length
+ ? selectedItems
+ : cur.items.filter((i) => inInput(i) && firstGroup != null && groupOf(i.file) === firstGroup)
+ const selEff = scopeItems.map(effectiveFile)
const activeKind: FileKind | null = selEff.length ? selEff[0].kind : null
- const activeGroup: string | null = selEff.length ? groupOf(selEff[0]) : null
- // Nothing selected yet: fall back to the first kind this verb accepts, so the
+ // Dimming and the breadcrumb follow the SELECTION only.
+ const activeGroup: string | null = selectedItems.length
+ ? groupOf(effectiveFile(selectedItems[0]))
+ : null
+ const scopeGroup: string | null = selEff.length ? groupOf(selEff[0]) : null
+ // An empty queue: fall back to the first kind this verb accepts, so the
// options panel can still show what it would do.
const fallbackKind: FileKind = tab.kinds[0] ?? card?.kinds[0] ?? 'image'
- const optGroup = activeGroup ?? convertGroup(fallbackKind, '')
+ const optGroup = scopeGroup ?? convertGroup(fallbackKind, '')
const srcNorms = new Set(selEff.map((f) => normalizeExt(f.ext)))
const srcExts = [...srcNorms] // every selected source format (for greying targets)
const sourceExt: string | null = srcNorms.size === 1 ? [...srcNorms][0] : null
const optKey = optionsKey(state.tab, state.activeTool, optGroup)
const curOptions =
state.options[optKey] ?? defaultOptionsFor(state.tab, state.activeTool, optGroup)
+ const onSet = useOptionSetter(dispatch, optGroup)
// On Convert the engine depends on the TARGET as well as the source: a .cbz
// to .cb7 is archive/repack, a .pdf to .cbz is archive/from-pdf, and a .png
// to .webp is the plain convert tool.
@@ -295,7 +322,7 @@ export default function App(): JSX.Element {
// The files a run would actually process: selected, runnable, tool-compatible,
// and (for convert) not already the target format.
- const runList: QueueItem[] = selectedItems.filter((i) => {
+ const runList: QueueItem[] = scopeItems.filter((i) => {
if (!canRun(i)) return false
const f = effectiveFile(i)
// The queue can hold several kinds, so check this file against the verb AND
@@ -336,14 +363,18 @@ export default function App(): JSX.Element {
dispatch({ type: 'setOption', group: optGroup, key: 'op', value: engine.op })
}, [engine.op, curOptions.op, optGroup, state.tab])
+ // Row clicks follow the VISIBLE order, so a shift-range on a sorted table
+ // covers the rows the user sees between the two clicks.
+ const sort = sorts[qKey] ?? null
+ const groups = groupedRows(cur.items, sort)
+ const order = visibleOrder(groups)
+
function onItemClick(id: string, e: MouseEvent): void {
- const item = cur.items.find((i) => i.id === id)
- if (!item) return
// The one-group rule lives in the reducer, which MOVES the selection to a
// file from another group rather than extending into it. Swallowing the
// click here instead made ctrl+click on a dimmed row do nothing at all.
const mode: SelectMode = e.shiftKey ? 'range' : e.ctrlKey || e.metaKey ? 'toggle' : 'single'
- dispatch({ type: 'select', id, mode })
+ dispatch({ type: 'select', id, mode, order })
}
// Hand the file to whatever the OS already uses for it. Filesmith used to
@@ -380,7 +411,9 @@ export default function App(): JSX.Element {
async function trashOutputs(ids: string[]): Promise {
const failed: string[] = []
for (const id of ids) {
- const it = cur.items.find((x) => x.id === id)
+ // Across every queue: on the Completed tab `cur` is the empty completed
+ // queue, so a lookup there found nothing and Delete silently did nothing.
+ const it = allItems.find((x) => x.id === id)
const out = it?.outputPath
if (!out) continue
const ok = await window.filesmith.trashFile(out)
@@ -397,13 +430,13 @@ export default function App(): JSX.Element {
return rest
})
evictProbe(out)
- dispatch({ type: 'dismiss', id, column: 'output' })
+ dispatch({ type: 'dismissAny', ids: [id], column: 'output' })
}
if (failed.length)
setConfirm({
title:
failed.length === 1 ? 'Could not delete file' : `Could not delete ${failed.length} files`,
- body: `${failed.join(', ')} could not be moved to the Recycle Bin — the file may be open in another app.`,
+ body: `${failed.join(', ')} could not be moved to the Recycle Bin, the file may be open in another app.`,
confirmLabel: 'OK',
hideCancel: true,
onConfirm: () => {}
@@ -426,65 +459,145 @@ export default function App(): JSX.Element {
void window.filesmith.cancelJob(id)
}
- // Build the right-click / ⋯ menu for a queue item. Destructive actions apply
- // to the whole selection when the clicked item is part of a multi-selection;
- // otherwise just to that one item.
- function openMenu(side: 'input' | 'output', item: QueueItem, x: number, y: number): void {
- const inSel = cur.selected.includes(item.id) && cur.selected.length > 1
- const visible = side === 'input' ? inInput : inOutput
- const targets = inSel
- ? cur.selected.filter((id) => {
- const it = cur.items.find((x) => x.id === id)
- return it != null && visible(it)
- })
- : [item.id]
- const n = targets.length
+ function toolFor(it: QueueItem, opts: JobOptions): ToolId {
+ return engineFor(
+ state.tab,
+ groupOf(it.file),
+ card,
+ { kind: it.file.kind, ext: it.file.ext },
+ typeof opts.format === 'string' ? opts.format : undefined
+ ).tool
+ }
- if (side === 'input') {
- // Cancel applies to whichever of the targeted rows are actually in flight.
- const cancellable = targets.filter((id) => {
- const it = cur.items.find((x) => x.id === id)
- return it != null && (it.status === 'queued' || it.status === 'running')
+ /** Same path as run() for one in-place item, with the options it last ran
+ * with (spec 4.3). A merge row retries with the same input list, because
+ * run() stores `mergeInputs` in its `runOptions`. */
+ function retry(ids: string[]): void {
+ const started: string[] = []
+ for (const id of ids) {
+ const it = cur.items.find((i) => i.id === id)
+ if (!it || (it.status !== 'failed' && it.status !== 'canceled')) continue
+ const opts = it.runOptions ?? curOptions
+ dispatch({ type: 'markQueued', ids: [id], options: opts })
+ void window.filesmith.runJob({
+ id,
+ tool: toolFor(it, opts),
+ input: it.file.path,
+ options: opts
})
- setMenu({
- x,
- y,
- items: [
- { label: 'Open', icon: 'expand', onClick: () => openExternally('input', item) },
- {
- label: 'Reveal in Explorer',
- icon: 'folder',
- onClick: () => window.filesmith.reveal(item.file.path)
- },
- ...(cancellable.length
- ? [
- {
- label: cancellable.length > 1 ? `Cancel ${cancellable.length} jobs` : 'Cancel',
- icon: 'close' as const,
- onClick: () => cancellable.forEach(cancelJob)
- }
- ]
- : []),
- { sep: true },
- {
- label: n > 1 ? `Remove ${n} from list` : 'Remove from list',
- icon: 'trash',
- danger: true,
- onClick: () => dismiss(targets, 'input')
- }
- ]
+ started.push(id)
+ }
+ if (started.length) setBatches((b) => ({ ...b, [qKey]: started }))
+ }
+
+ function onRowAction(id: string, kind: RowActionKind): void {
+ const it = cur.items.find((i) => i.id === id)
+ if (!it) return
+ if (kind === 'reveal') {
+ if (it.outputPath) window.filesmith.reveal(it.outputPath)
+ } else if (kind === 'cancel') cancelJob(id)
+ else if (kind === 'remove') dismiss([id], 'input')
+ else retry([id])
+ }
+
+ /** "Are you sure" before rows leave the list; files on disk stay put. */
+ function confirmRemove(ids: string[]): void {
+ if (!ids.length) return
+ setConfirm({
+ ...removeConfirm(ids.length),
+ confirmLabel: 'Remove',
+ danger: true,
+ onConfirm: () => dismiss(ids, 'input')
+ })
+ }
+
+ /** Move the rows' SOURCE files to the Recycle Bin (reversible, never a hard
+ * delete), then drop those rows. A file that could not be recycled keeps its
+ * row and is named in an alert. */
+ async function trashSources(ids: string[]): Promise {
+ const rows = cur.items.filter((i) => ids.includes(i.id) && inInput(i))
+ const failed: string[] = []
+ // One source can sit in several rows (a re-run clone): recycle it once.
+ for (const path of [...new Set(rows.map((r) => r.file.path))]) {
+ const ok = await window.filesmith.trashFile(path)
+ if (!ok) {
+ failed.push(baseName(path))
+ continue
+ }
+ evictProbe(path)
+ for (const r of rows)
+ if (r.file.path === path) dispatch({ type: 'dismiss', id: r.id, column: 'input' })
+ }
+ if (failed.length)
+ setConfirm({
+ title:
+ failed.length === 1 ? 'Could not delete file' : `Could not delete ${failed.length} files`,
+ body: `${failed.join(', ')} could not be moved to the Recycle Bin, the file may be open in another app.`,
+ confirmLabel: 'OK',
+ hideCancel: true,
+ onConfirm: () => {}
})
- return
+ }
+
+ /** The files table's right-click menu. A selected row acts on the whole
+ * selection; any other row becomes the selection first (the reducer keeps it
+ * inside one convert group). */
+ function openRowMenu(id: string, x: number, y: number): void {
+ const ids = menuTargets(id, cur.selected)
+ if (!cur.selected.includes(id)) dispatch({ type: 'select', id, mode: 'single', order })
+ const rows = cur.items.filter((i) => ids.includes(i.id))
+ const act = (a: RowMenuAction): void => {
+ const one = rows.length === 1 ? rows[0] : null
+ if (a === 'open') {
+ if (one) openExternally('input', one)
+ } else if (a === 'reveal') {
+ if (one) window.filesmith.reveal(revealPath(one))
+ } else if (a === 'retry') retry(rows.map((r) => r.id))
+ else if (a === 'stop')
+ rows
+ .filter((r) => r.status === 'queued' || r.status === 'running')
+ .forEach((r) => cancelJob(r.id))
+ else if (a === 'clear') dispatch({ type: 'hideFinished' })
+ else if (a === 'remove') confirmRemove(rows.map((r) => r.id))
+ else
+ setConfirm({
+ ...deleteConfirm(rows.length),
+ confirmLabel: rows.length > 1 ? `Delete ${rows.length} files` : 'Delete',
+ danger: true,
+ onConfirm: () => void trashSources(rows.map((r) => r.id))
+ })
}
+ setMenu({
+ x,
+ y,
+ items: rowMenuModel(rows, cur.items).map((e) =>
+ e.sep
+ ? e
+ : {
+ label: e.label,
+ icon: e.icon,
+ danger: e.danger,
+ disabled: e.disabled,
+ onClick: () => act(e.action)
+ }
+ )
+ })
+ }
+
+ // The Completed view's right-click menu: acts on that view's own selection,
+ // which spans every queue's results.
+ function openOutputMenu(item: QueueItem, x: number, y: number, targetIds?: string[]): void {
+ const targets = targetIds ?? [item.id]
+ const n = targets.length
const out = item.outputPath
if (!out) return
setMenu({
x,
y,
items: [
- { label: 'Open', icon: 'expand', onClick: () => openExternally('output', item) },
+ { label: 'Open', icon: 'eye', onClick: () => openExternally('output', item) },
{
- label: 'Reveal in Explorer',
+ label: 'Show in File Explorer',
icon: 'folder',
onClick: () => window.filesmith.reveal(out)
},
@@ -570,9 +683,10 @@ export default function App(): JSX.Element {
: `The largest result will be roughly ${formatBytes(worst)} (about ${formatBytes(total)} in total), and may take a long time.`
}
- /** Open a generated image in whatever views images on this machine. */
+ /** Show a generated image in the inspector's Preview. */
function previewGen(path: string): void {
- window.filesmith.openFile(path)
+ setGenFocus(path)
+ setInspTab('preview')
}
function openGenMenu(path: string, x: number, y: number): void {
@@ -580,14 +694,14 @@ export default function App(): JSX.Element {
x,
y,
items: [
- { label: 'Open', icon: 'expand', onClick: () => previewGen(path) },
+ { label: 'Open', icon: 'eye', onClick: () => previewGen(path) },
{
label: 'Open in default app',
- icon: 'upload',
+ icon: 'arrow',
onClick: () => window.filesmith.openFile(path)
},
{
- label: 'Reveal in Explorer',
+ label: 'Show in File Explorer',
icon: 'folder',
onClick: () => window.filesmith.reveal(path)
},
@@ -599,6 +713,7 @@ export default function App(): JSX.Element {
onClick: () => {
void window.filesmith.trashFile(path)
setGenResults((prev) => prev.filter((p) => p !== path))
+ setGenFocus((f) => (f === path ? null : f))
}
}
]
@@ -631,6 +746,15 @@ export default function App(): JSX.Element {
})
const unsubI = window.filesmith.onGenerateImage((p) => {
if (p.id !== id) return
+ setGenMeta((m) => ({
+ ...m,
+ [p.path]: {
+ model: String(opts.model ?? ''),
+ width: Number(opts.width ?? 1024),
+ height: Number(opts.height ?? 1024),
+ seed: Number(opts.seed ?? -1)
+ }
+ }))
finished[p.index] = p.path
setGenRun((r) => ({
...r,
@@ -694,7 +818,8 @@ export default function App(): JSX.Element {
dispatch({ type: 'addSources', items: [src], key: qKey })
anchorId = src.id
}
- dispatch({ type: 'markQueued', ids: [anchorId], options: opts })
+ dispatch({ type: 'markQueued', ids: [anchorId], options: { ...opts, mergeInputs: paths } })
+ setBatches((b) => ({ ...b, [qKey]: [anchorId] }))
void window.filesmith.runJob({
id: anchorId,
tool: 'pdf',
@@ -724,6 +849,7 @@ export default function App(): JSX.Element {
if (newSources.length) dispatch({ type: 'addSources', items: newSources, key: qKey })
if (!targets.length) return
dispatch({ type: 'markQueued', ids: targets.map((t) => t.id), options: opts })
+ setBatches((b) => ({ ...b, [qKey]: targets.map((t) => t.id) }))
for (const t of targets) {
void window.filesmith.runJob({ id: t.id, tool, input: t.path, options: opts })
}
@@ -741,6 +867,28 @@ export default function App(): JSX.Element {
: isMerge && runList.length < 2
? 0
: runList.length
+
+ // Global shortcuts (spec 4.1). `run` and `browse` are re-created each render,
+ // so the window listener reads the latest ones through a ref, refreshed after
+ // every render like `latest` above (assigning it during render breaks the
+ // react-hooks/refs rule).
+ const actions = useRef({ run, browse, runCount, toggle: sidebar.toggle })
+ useEffect(() => {
+ actions.current = { run, browse, runCount, toggle: sidebar.toggle }
+ })
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent): void => {
+ const s = shortcutFor(e)
+ if (!s) return
+ e.preventDefault()
+ const a = actions.current
+ if (s === 'toggleSidebar') a.toggle()
+ else if (s === 'addFiles') void a.browse()
+ else if (s === 'run' && a.runCount > 0) void a.run()
+ }
+ window.addEventListener('keydown', onKey)
+ return () => window.removeEventListener('keydown', onKey)
+ }, [])
// The kind the options panel should key off is what will actually RUN, not the
// anchor item: a PDF co-selected with a non-compressible doc (same group)
// leaves the anchor 'document' while only the PDF runs. All-same-kind -> that
@@ -768,15 +916,33 @@ export default function App(): JSX.Element {
.map((f) => f.path)
: []
const compressVideoPaths = tool === 'compress' ? probePaths : []
+ // The focused file, shown by the Preview and Info panes (spec 4.6). Both
+ // describe ONE file, so a multi-selection shows neither rather than quietly
+ // picking one of the files.
+ const multiSelected = cur.selected.length > 1
+ const focused = multiSelected ? undefined : cur.items.find((i) => i.id === cur.selected[0])
+ // Preview and Info show the focused file's pixels, so probe it too.
+ const focusedProbe =
+ inspTab !== 'options' &&
+ focused &&
+ (focused.file.kind === 'image' || focused.file.kind === 'video')
+ ? focused.file
+ : null
+ const probeKinds: Record = {}
+ for (const p of probePaths)
+ probeKinds[p] = tool === 'upscale' || tool === 'resize' ? 'image' : 'video'
+ if (focusedProbe && !(focusedProbe.path in probeKinds))
+ probeKinds[focusedProbe.path] = focusedProbe.kind === 'image' ? 'image' : 'video'
+ const probeKey = Object.keys(probeKinds)
+ .map((p) => `${probeKinds[p]}:${p}`)
+ .join('|')
useEffect(() => {
- for (const p of probePaths) {
+ for (const [p, kind] of Object.entries(probeKinds)) {
if (p in vDims || vDimsRequested.current.has(p)) continue
vDimsRequested.current.add(p)
// Images resolve via ImageMagick, video via ffprobe (rotation-aware).
const probe =
- tool === 'upscale' || tool === 'resize'
- ? window.filesmith.imageDimensions(p)
- : window.filesmith.videoDimensions(p)
+ kind === 'image' ? window.filesmith.imageDimensions(p) : window.filesmith.videoDimensions(p)
void probe.then((d) => {
// A failed probe returns null; don't cache it — drop the request marker
// so it can be re-probed (transient errors, a file still being written).
@@ -785,9 +951,9 @@ export default function App(): JSX.Element {
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [probePaths.join('|')])
+ }, [probeKey])
const compressScale = Number(curOptions.scale ?? 100)
- const videoOutputs: VideoOutputRow[] = compressVideoPaths.map((p) => {
+ const videoOutputs: SizeRow[] = compressVideoPaths.map((p) => {
const d = vDims[p]
const name = baseName(p)
if (!d) return { path: p, name, from: '…', to: '…' }
@@ -801,7 +967,7 @@ export default function App(): JSX.Element {
const resizeOpts = curOptions
const numOrNull = (v: unknown): number | null =>
v == null || v === '' || !Number.isFinite(Number(v)) ? null : Number(v)
- const resizeOutputs: VideoOutputRow[] =
+ const resizeOutputs: SizeRow[] =
tool === 'resize' && String(resizeOpts.mode ?? 'percent') === 'dimensions'
? probePaths.map((p) => {
const d = vDims[p]
@@ -820,7 +986,7 @@ export default function App(): JSX.Element {
: []
const upscaleFactor = Number(curOptions.upscaleFactor ?? 4)
- const upscaleOutputs: VideoOutputRow[] =
+ const upscaleOutputs: SizeRow[] =
tool === 'upscale'
? probePaths.map((p) => {
const d = vDims[p]
@@ -833,9 +999,57 @@ export default function App(): JSX.Element {
})
: []
+ // Per-row size estimates for running rows (spec 4.3), from the options each
+ // row actually runs with and the done rows of the same group and options.
+ function estimateFor(
+ it: QueueItem,
+ opts: JobOptions = it.runOptions ?? curOptions
+ ): number | null {
+ const t = toolFor(it, opts)
+ const d = vDims[it.file.path]
+ let pixelRatio: number | null = null
+ let outPixels: number | null = null
+ if (t === 'resize') {
+ if (String(opts.mode ?? 'percent') === 'percent')
+ pixelRatio = (Number(opts.percent ?? 50) / 100) ** 2
+ else if (d) {
+ const o = resizedSize(
+ d.width,
+ d.height,
+ numOrNull(opts.width),
+ numOrNull(opts.height),
+ opts.fit === 'stretch' ? 'stretch' : 'contain'
+ )
+ if (o) pixelRatio = (o.w * o.h) / (d.width * d.height)
+ }
+ } else if (t === 'upscale' && d) {
+ const f = Number(opts.upscaleFactor ?? 4)
+ outPixels = d.width * f * d.height * f
+ }
+ return estimateOutputBytes(it.file, t, opts, {
+ samples: doneSamples(cur.items, groupOf(it.file), opts),
+ pixelRatio,
+ outPixels
+ })
+ }
+ const estimates: Record = {}
+ for (const i of cur.items)
+ if (inInput(i) && i.status === 'running') estimates[i.id] = estimateFor(i)
+ // The batch card under the options speaks for the CURRENT options (spec 4.5).
+ const batchEstimate =
+ tool === 'generate' || runList.length === 0
+ ? null
+ : estimateBatch(
+ runList.map((i) => ({ size: i.file.size, estimate: estimateFor(i, curOptions) }))
+ )
+
+ // Toolbar and Stop flags.
+ const inputs = cur.items.filter(inInput)
+ const inFlight = inputs.filter((i) => i.status === 'queued' || i.status === 'running')
+
// Everything produced, across every workspace, for the Completed tab.
const completed = collectCompleted(state.queues, (key) => {
- if (key.startsWith('tools:')) return toolCardById(key.slice(6))?.label ?? 'Tools'
+ if (key.startsWith('tools:')) return toolCardById(key.slice(6))?.label ?? 'PDF Tools'
return tabById(key as TabId).label
})
@@ -849,173 +1063,283 @@ export default function App(): JSX.Element {
counts[bucket] = (counts[bucket] ?? 0) + n
}
+ const crumbs = crumbsFor(state.tab, card ?? null, activeGroup)
+ const verbLabel = card ? card.label : tab.label
+ const summary = statusSummary(
+ onToolsGrid || onCompleted || state.tab === 'settings' ? [] : cur.items,
+ batches[qKey] ?? null,
+ verbLabel,
+ genRun.running && genRun.message && !genRun.message.startsWith('Generating')
+ ? genRun.message
+ : null
+ )
+ const showInspector = !onToolsGrid && !onCompleted && state.tab !== 'settings'
+
+ // Inspector head and Run label (spec 3.4, 4.1).
+ const scopeCount = scopeItems.length
+ const inspSub =
+ tool === 'generate'
+ ? ''
+ : inspTab !== 'options'
+ ? focused
+ ? 'selected'
+ : multiSelected
+ ? `${cur.selected.length} selected`
+ : ''
+ : cur.selected.length
+ ? `${cur.selected.length} selected`
+ : scopeCount
+ ? `all ${scopeCount} file${scopeCount === 1 ? '' : 's'}`
+ : 'no files'
+ const inspTitle =
+ tool === 'generate'
+ ? inspTab !== 'options' && genFocus
+ ? baseName(genFocus)
+ : verbLabel
+ : inspTab === 'options' || !focused
+ ? verbLabel
+ : focused.file.name
+ const genCount = Number(curOptions.count ?? 1)
+ // Name the GROUP, not "files": in a mixed queue "Convert 2 files" hides which two.
+ const runLabel =
+ tool === 'generate'
+ ? genCount > 1
+ ? `Generate ${genCount} images`
+ : 'Generate'
+ : runCount > 0
+ ? `${verbLabel} ${groupNoun(optGroup, runCount)}`
+ : verbLabel
+
return (
-
-
-
-
{
+ e.preventDefault()
+ if (tool !== 'generate' && !onToolsGrid && !onCompleted && state.tab !== 'settings')
+ setDragging(true)
+ }}
+ onDragLeave={(e) => {
+ if (e.currentTarget === e.target) setDragging(false)
+ }}
+ onDrop={onDrop}
+ >
+
+ a === 'tools'
+ ? dispatch({ type: 'setActiveTool', tool: null })
+ : dispatch({ type: 'clearSelection' })
+ }
+ />
+
+
dispatch({ type: 'setTab', tab: t })}
+ collapsed={sidebar.collapsed}
+ onToggle={sidebar.toggle}
+ onSelect={(t) => {
+ setInspTab('options')
+ dispatch({ type: 'setTab', tab: t })
+ }}
/>
<>
- {
- e.preventDefault()
- if (tool !== 'generate' && !onToolsGrid && !onCompleted) setDragging(true)
- }}
- onDragLeave={(e) => {
- if (e.currentTarget === e.target) setDragging(false)
- }}
- onDrop={onDrop}
- >
- {/* The rail names the verb, so this heading just repeats it back and
- carries the file count (or the open tool's name inside Tools). */}
- dispatch({ type: 'setActiveTool', tool: null }) : undefined}
- />
+
{onCompleted ? (
openExternally('output', item)}
- onMenu={(item, x, y) => openMenu('output', item, x, y)}
+ onReveal={(p) => window.filesmith.reveal(p)}
+ onMenu={openOutputMenu}
+ onDelete={(ids) =>
+ setConfirm({
+ title: ids.length === 1 ? 'Delete this file?' : `Delete ${ids.length} files?`,
+ body: 'They will be moved to the Recycle Bin.',
+ confirmLabel: 'Delete',
+ danger: true,
+ onConfirm: () => void trashOutputs(ids)
+ })
+ }
+ onClear={(ids) => dispatch({ type: 'dismissAny', ids, column: 'output' })}
/>
+ ) : state.tab === 'settings' ? (
+
) : onToolsGrid ? (
- dispatch({ type: 'setActiveTool', tool: id })} />
+ dispatch({ type: 'setActiveTool', tool: id })} />
) : tool === 'generate' ? (
- <>
-
- dispatch({ type: 'setOption', group: optGroup, key: 'prompt', value: v })
- }
- />
- {genRun.running && (
-
- {
- if (genActiveId.current)
- window.filesmith.generateCancel(genActiveId.current)
- }}
- className="rounded-lg border border-black/[.12] bg-white px-3.5 py-1.5 text-[12.5px] font-semibold text-ink transition hover:border-[#e0483d] hover:text-[#e0483d]"
- >
- Cancel
-
-
- )}
- {/* Startup indicator only ("Starting ComfyUI…" / "Connecting…").
- Once images are sampling, the per-tile bars carry it, so the
- redundant "Generating X of Y" line is dropped. */}
- {genRun.running && genRun.message && !genRun.message.startsWith('Generating') && (
-
- )}
- {(genRun.running || genResults.length > 0) && (
-
- {/* This run's tiles first (each fills as it finishes)… */}
- {genRun.running &&
- genRun.slots.map((slot, i) =>
- slot.path ? (
-
- ) : (
-
-
-
-
- {slot.pct > 0 ? `${slot.pct}%` : 'Generating…'}
-
-
-
-
- )
- )}
- {/* …then everything generated earlier stays visible. */}
- {genResults.map((path) => (
-
- ))}
-
- )}
- >
+ onSet('prompt', v)}
+ running={genRun.running}
+ slots={genRun.slots}
+ results={genResults}
+ aspect={genAspect}
+ canRun={runCount > 0}
+ focused={genFocus}
+ onRun={() => void run()}
+ onCancel={() => {
+ if (genActiveId.current) window.filesmith.generateCancel(genActiveId.current)
+ }}
+ onFocus={previewGen}
+ onOpen={(p) => window.filesmith.openFile(p)}
+ onMenu={openGenMenu}
+ />
) : (
<>
- void browse()}
+ void browse()}
/>
- setSorts((s) => ({ ...s, [qKey]: nextSort(sort, k) }))}
+ onToggleAll={() =>
+ dispatch({ type: 'selectIds', ids: toggleAllIds(cur.items, cur.selected) })
+ }
+ onSelectAll={() => {
+ const ids = toggleAllIds(cur.items, cur.selected)
+ if (ids.length) dispatch({ type: 'selectIds', ids })
+ }}
+ onRowClick={onItemClick}
+ onToggleRow={(id) => dispatch({ type: 'select', id, mode: 'toggle', order })}
+ onExtend={(id) => dispatch({ type: 'select', id, mode: 'range', order })}
+ onOpen={(id) => {
+ const it = cur.items.find((i) => i.id === id)
+ if (it) openExternally('input', it)
+ }}
+ onMenu={openRowMenu}
+ onAction={onRowAction}
+ onRemove={(id) => confirmRemove(menuTargets(id, cur.selected))}
+ onSelectGroup={(g) =>
+ dispatch({
+ type: 'selectIds',
+ ids: inputs.filter((i) => groupOf(i.file) === g).map((i) => i.id)
+ })
+ }
+ onAdd={() => void browse()}
/>
>
)}
-
-
- {!onToolsGrid && !onCompleted && (
- dispatch({ type: 'setOption', group: optGroup, key: k, value: v })}
+
+
+ {showInspector && (
+ void run()}
- />
+ stopping={tool === 'generate' ? genRun.running : inFlight.length > 0}
+ onStop={() => {
+ if (tool === 'generate') {
+ if (genActiveId.current) window.filesmith.generateCancel(genActiveId.current)
+ } else inFlight.forEach((i) => cancelJob(i.id))
+ }}
+ >
+ {inspTab === 'options' ? (
+
+ ) : tool === 'generate' ? (
+ genFocus ? (
+ inspTab === 'preview' ? (
+
+
+
+ {extOfPath(genFocus).replace(/^\./, '').toLowerCase() || 'png'}
+
+
+ ) : (
+
+ )
+ ) : (
+
+ )
+ ) : focused ? (
+ inspTab === 'preview' ? (
+
+ ) : (
+
+ )
+ ) : multiSelected ? (
+
+ ) : (
+
+ )}
+
)}
>
+
+ dispatch({
+ type: 'selectIds',
+ ids: oneGroupIds(
+ cur.items,
+ cur.items.filter((i) => i.status === 'failed').map((i) => i.id)
+ )
+ })
+ }
+ />
diff --git a/src/renderer/src/assets/fmark.png b/src/renderer/src/assets/fmark.png
deleted file mode 100644
index 8e972e4..0000000
Binary files a/src/renderer/src/assets/fmark.png and /dev/null differ
diff --git a/src/renderer/src/components/ComfyImport.tsx b/src/renderer/src/components/ComfyImport.tsx
deleted file mode 100644
index 260a347..0000000
--- a/src/renderer/src/components/ComfyImport.tsx
+++ /dev/null
@@ -1,182 +0,0 @@
-import { useCallback, useEffect, useState, type JSX } from 'react'
-import type { ComfyModel } from '@shared/comfy'
-import type { ComfyStatus } from './useComfyModels'
-
-// The "Import from ComfyUI" flow, shown under the Upscale model picker when an
-// NVIDIA GPU is present. Three states: engine not built (one-time env setup),
-// engine ready but no folder chosen (browse), and folder chosen (rescan / change
-// + an optional "not usable" disclosure for files spandrel couldn't load).
-
-function ProgressBar({ pct, step }: { pct: number | null; step: string }): JSX.Element {
- return (
-
-
- {pct == null ? (
-
- ) : (
-
- )}
-
-
- {step}
- {pct != null && {pct}% }
-
-
- )
-}
-
-export function ComfyImportCard({
- status,
- refresh
-}: {
- status: ComfyStatus
- refresh: () => void
-}): JSX.Element {
- const [busy, setBusy] = useState<'install' | 'scan' | null>(null)
- const [progress, setProgress] = useState<{ step: string; pct: number | null } | null>(null)
- const [error, setError] = useState
(null)
- const [lastScan, setLastScan] = useState(null)
-
- useEffect(() => window.filesmith.onComfyProgress(setProgress), [])
-
- const scan = useCallback(
- (folder: string): void => {
- setBusy('scan')
- setError(null)
- void window.filesmith.comfyScan(folder).then((r) => {
- setBusy(null)
- if (r.ok) {
- setLastScan(r.models ?? [])
- refresh()
- } else setError(r.error ?? 'Scan failed')
- })
- },
- [refresh]
- )
-
- const pickAndScan = useCallback((): void => {
- void window.filesmith.comfyPickFolder().then((folder) => {
- if (folder) scan(folder)
- })
- }, [scan])
-
- // Record the folder without a scan: the scan needs the engine we don't have
- // yet, but remembering the location is exactly what lets the engine be found.
- const locateOnly = useCallback((): void => {
- setError(null)
- void window.filesmith.comfyPickFolder().then((folder) => {
- if (!folder) return
- void window.filesmith.comfySetFolder(folder).then((r) => {
- if (r.ok) refresh()
- else setError(r.error ?? "Couldn't use that folder")
- })
- })
- }, [refresh])
-
- const installEngine = useCallback((): void => {
- setBusy('install')
- setError(null)
- setProgress(null)
- void window.filesmith.comfyInstall().then((r) => {
- setBusy(null)
- if (r.ok) refresh()
- else setError(r.error ?? 'Setup failed')
- })
- }, [refresh])
-
- const unsupported = (lastScan ?? []).filter((m) => m.badge === 'unsupported')
-
- return (
-
- {!status.engineReady ? (
- <>
- {/* The download size is the one thing the buttons can't say. */}
-
- Use your own ComfyUI upscale models.{' '}
- {status.envExists ? (
- <>Quick one-time setup.>
- ) : (
- <>
- One-time setup: ~3 GB , shared with
- PiD.
- >
- )}
-
- {busy === 'install' ? (
-
- ) : (
-
-
- Set up upscale engine
-
- {/* Browse is deliberately available BEFORE the engine exists. It
- used to render only when engineReady, and engineReady depends
- on finding a ComfyUI Python — so a user whose ComfyUI was not
- in the guessed paths had to download 3 GB before the app would
- let them point at the ComfyUI they already had. Pointing at the
- folder is what makes discovery work, so it has to come first. */}
-
- {status.folder ? 'Change ComfyUI folder' : 'I already have ComfyUI — locate it'}
-
- {status.folder && (
-
- Using {status.folder}
-
- )}
-
- )}
- >
- ) : busy === 'scan' ? (
-
- ) : (
- <>
- {status.folder && (
-
- From {status.folder}
-
- )}
-
-
- {status.folder ? 'Change folder' : 'Browse to ComfyUI folder'}
-
- {status.folder && (
- scan(status.folder as string)}
- className="rounded-xl border border-black/[.12] bg-white px-3.5 py-2.5 text-[13px] font-semibold text-ink transition hover:border-[#b9b9c8]"
- >
- Rescan
-
- )}
-
- {unsupported.length > 0 && (
-
-
- {unsupported.length} file{unsupported.length === 1 ? '' : 's'} not usable
-
-
- {unsupported.map((m) => (
-
- {m.name} — {m.reason ?? 'unsupported'}
-
- ))}
-
-
- )}
- >
- )}
- {error &&
{error}
}
-
- )
-}
diff --git a/src/renderer/src/components/CompletedView.tsx b/src/renderer/src/components/CompletedView.tsx
deleted file mode 100644
index aff5c2f..0000000
--- a/src/renderer/src/components/CompletedView.tsx
+++ /dev/null
@@ -1,130 +0,0 @@
-import type { JSX, MouseEvent } from 'react'
-import { formatBytes, type QueueItem } from '../state'
-import type { CompletedItem } from './completed'
-import { baseName } from '@shared/fileKind'
-import { Icon } from './Icon'
-
-const extOf = (n: string): string => {
- const i = n.lastIndexOf('.')
- return i > 0 ? n.slice(i + 1).toUpperCase() : ''
-}
-
-function Row({
- entry,
- thumb,
- onOpen,
- onMenu
-}: {
- entry: CompletedItem
- thumb: string | null
- onOpen: () => void
- onMenu: (x: number, y: number) => void
-}): JSX.Element {
- const { item, from } = entry
- const out = item.outputPath as string
- return (
- {
- e.preventDefault()
- onMenu(e.clientX, e.clientY)
- }}
- className="group flex cursor-pointer items-center gap-3 rounded-2xl border border-black/[.07] bg-white p-2.5 shadow-[0_1px_3px_rgba(0,0,0,.05),0_8px_22px_rgba(20,20,40,.05)] transition hover:border-black/[.14]"
- >
-
- {thumb ? (
-
- ) : (
-
- {extOf(out)}
-
- )}
-
-
-
{baseName(out)}
-
- {from}
- ·
- {extOf(out)}
- {item.outputSize != null && (
- <>
- ·
- {formatBytes(item.outputSize)}
- {/* Change vs the source. Green when it shrank; amber when it grew
- (re-encoding an already-optimised file can do that) - hiding
- that just makes compression look broken. */}
- {item.file.size > 0 &&
- (item.outputSize < item.file.size ? (
-
- −{Math.round((1 - item.outputSize / item.file.size) * 100)}%
-
- ) : (
-
- +{Math.round((item.outputSize / item.file.size - 1) * 100)}%
-
- ))}
- >
- )}
-
-
-
{
- e.stopPropagation()
- const r = (e.currentTarget as HTMLElement).getBoundingClientRect()
- onMenu(r.right, r.bottom + 6)
- }}
- aria-label="More"
- className="grid h-7 w-7 shrink-0 place-items-center rounded-lg text-dim opacity-0 transition hover:bg-black/[.05] hover:text-ink group-hover:opacity-100"
- >
-
-
-
- )
-}
-
-/**
- * Everything the app has produced, in one place. This used to be a second
- * column beside the queue in every workspace, which meant results were split
- * across seven screens and each workspace spent half its width on files the
- * user had already finished with.
- */
-export function CompletedView({
- entries,
- thumbs,
- onOpen,
- onMenu
-}: {
- entries: CompletedItem[]
- thumbs: Record
- onOpen: (item: QueueItem) => void
- onMenu: (item: QueueItem, x: number, y: number) => void
-}): JSX.Element {
- if (entries.length === 0) {
- return (
-
-
-
Nothing finished yet
-
- Files you convert, compress or resize land here.
-
-
-
- )
- }
- return (
-
- {entries.map((e) => (
- onOpen(e.item)}
- onMenu={(x, y) => onMenu(e.item, x, y)}
- />
- ))}
-
- )
-}
diff --git a/src/renderer/src/components/ConfirmDialog.tsx b/src/renderer/src/components/ConfirmDialog.tsx
index 18da47f..aa5a310 100644
--- a/src/renderer/src/components/ConfirmDialog.tsx
+++ b/src/renderer/src/components/ConfirmDialog.tsx
@@ -1,4 +1,6 @@
import { useEffect, useRef, type JSX } from 'react'
+import { Icon } from './icons/Icon'
+import { SmallButton } from './ui/Button'
export interface ConfirmState {
title: string
@@ -19,7 +21,7 @@ export interface ConfirmState {
* trapping, Escape, and the backdrop come from the platform rather than
* hand-rolled z-index and key handlers.
*
- * `m-auto` on the dialog is load-bearing: centring a modal in the top
+ * `margin: auto` on `.dlg` is load-bearing: centring a modal in the top
* layer relies on the UA's `margin: auto`, and Tailwind's preflight zeroes every
* element's margin, which pins it to the top-left corner instead.
*/
@@ -51,32 +53,32 @@ export function ConfirmDialog({
// Clicking the backdrop (the dialog element itself, outside the card).
if (e.target === ref.current) onClose()
}}
- className="modal-pop m-auto max-w-[420px] rounded-[18px] border border-black/[.08] bg-white p-0 text-ink shadow-[0_24px_60px_rgba(0,0,0,.22)] backdrop:bg-black/25"
+ className="modal-pop dlg"
>
-
-
{state.title}
-
{state.body}
-
- {!state.hideCancel && (
-
- Cancel
-
- )}
- {
- state.onConfirm()
- onClose()
- }}
- className="rounded-xl bg-accent px-4 py-2.5 text-[13px] font-semibold text-white transition hover:bg-accent-hi"
- >
- {state.confirmLabel}
-
-
+
+
+ {state.danger && }
+ {state.title}
+
+
{state.body}
+
+
+ {!state.hideCancel && (
+
+ Cancel
+
+ )}
+ {
+ state.onConfirm()
+ onClose()
+ }}
+ >
+ {state.confirmLabel}
+
)
diff --git a/src/renderer/src/components/ContextMenu.tsx b/src/renderer/src/components/ContextMenu.tsx
index 1ee5062..136b6a9 100644
--- a/src/renderer/src/components/ContextMenu.tsx
+++ b/src/renderer/src/components/ContextMenu.tsx
@@ -1,9 +1,24 @@
-import { useEffect, useLayoutEffect, useRef, type JSX } from 'react'
-import { Icon, type IconName } from './Icon'
+import {
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ type JSX,
+ type KeyboardEvent as ReactKeyboardEvent
+} from 'react'
+import type { IconName } from '@shared/icons'
+import { Icon } from './icons/Icon'
export type MenuItem =
| { sep: true }
- | { sep?: false; label: string; icon: IconName; danger?: boolean; onClick: () => void }
+ | {
+ sep?: false
+ label: string
+ icon: IconName
+ danger?: boolean
+ /** Shown greyed out, so the menu keeps one shape for every selection. */
+ disabled?: boolean
+ onClick: () => void
+ }
export interface MenuState {
x: number
@@ -13,7 +28,7 @@ export interface MenuState {
/**
* A floating context menu anchored at (x, y). Measures itself, then flips
- * horizontally/vertically so it never spills off-screen — so the same call
+ * horizontally/vertically so it never spills off-screen, so the same call
* works for a cursor position (right-click) or a button corner (the ⋯).
*/
export function ContextMenu({
@@ -30,6 +45,7 @@ export function ContextMenu({
useLayoutEffect(() => {
const el = ref.current
if (!menu || !el) return
+ const prev = document.activeElement as HTMLElement | null
const { width, height } = el.getBoundingClientRect()
const pad = 8
const left = menu.x + width > window.innerWidth - pad ? menu.x - width : menu.x
@@ -37,8 +53,38 @@ export function ContextMenu({
el.style.left = `${Math.max(pad, left)}px`
el.style.top = `${Math.max(pad, top)}px`
el.style.visibility = 'visible'
+ // Keyboard users land on the first live entry, as in a native menu.
+ el.querySelector
('.menu-item:not(:disabled)')?.focus()
+ // Hand focus back to whatever opened the menu (a table row) on close,
+ // unless the user has already moved it somewhere else.
+ return () => {
+ const a = document.activeElement
+ if ((!a || a === document.body || el.contains(a)) && prev?.isConnected) prev.focus()
+ }
}, [menu])
+ // Arrow keys walk the enabled entries (disabled buttons are skipped, since
+ // they cannot take focus); Home/End jump to the ends.
+ function onMenuKey(e: ReactKeyboardEvent): void {
+ const keys = ['ArrowDown', 'ArrowUp', 'Home', 'End']
+ if (!keys.includes(e.key)) return
+ e.preventDefault()
+ const items = Array.from(
+ ref.current?.querySelectorAll('.menu-item:not(:disabled)') ?? []
+ )
+ if (!items.length) return
+ const i = items.indexOf(document.activeElement as HTMLButtonElement)
+ const next =
+ e.key === 'Home'
+ ? 0
+ : e.key === 'End'
+ ? items.length - 1
+ : e.key === 'ArrowDown'
+ ? (i + 1) % items.length
+ : (i - 1 + items.length) % items.length
+ items[next].focus()
+ }
+
// Dismiss on any outside interaction.
useEffect(() => {
if (!menu) return
@@ -68,32 +114,26 @@ export function ContextMenu({
role="menu"
onMouseDown={(e) => e.stopPropagation()}
onContextMenu={(e) => e.preventDefault()}
- className="ctx-pop fixed z-50 min-w-[196px] rounded-[13px] border border-black/[.08] bg-white/90 p-[5px] shadow-[0_10px_40px_rgba(20,20,40,.18)] backdrop-blur-xl"
+ onKeyDown={onMenuKey}
+ className="ctx-pop menu"
style={{ left: menu.x, top: menu.y, visibility: 'hidden' }}
>
{menu.items.map((item, i) =>
item.sep ? (
-
+
) : (
{
item.onClick()
onClose()
}}
- className={`group flex w-full items-center gap-2.5 rounded-[9px] px-2.5 py-2 text-left text-[13px] transition ${
- item.danger
- ? 'text-[#d1362b] hover:bg-[#d1362b] hover:text-white'
- : 'text-ink hover:bg-accent hover:text-white'
- }`}
>
-
+
{item.label}
)
diff --git a/src/renderer/src/components/DropZone.tsx b/src/renderer/src/components/DropZone.tsx
deleted file mode 100644
index c22be9c..0000000
--- a/src/renderer/src/components/DropZone.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import type { JSX } from 'react'
-import { Icon } from './Icon'
-
-export function DropZone({
- dragging,
- label = 'Drop files here',
- grow = false,
- onBrowse
-}: {
- dragging: boolean
- /** Names the type this workspace accepts, e.g. "Drop images here". */
- label?: string
- /** Fill the available height instead of the compact fixed size. Used when the
- * queue is empty, so the whole workspace is one big target. */
- grow?: boolean
- onBrowse: () => void
-}): JSX.Element {
- return (
-
- {/* The icon is the empty state's anchor; once files exist the zone is a
- slim target and the glyph would just eat the queue's room. */}
-
-
-
-
-
- {label}
-
-
- or browse
-
-
-
- )
-}
diff --git a/src/renderer/src/components/ErrorBoundary.tsx b/src/renderer/src/components/ErrorBoundary.tsx
index ce7e07a..de87dfd 100644
--- a/src/renderer/src/components/ErrorBoundary.tsx
+++ b/src/renderer/src/components/ErrorBoundary.tsx
@@ -1,7 +1,9 @@
-import { Component, type ErrorInfo, type JSX, type ReactNode } from 'react'
+import { Component, type ErrorInfo, type ReactNode } from 'react'
+import { Icon } from './icons/Icon'
+import { SmallButton } from './ui/Button'
// The window is frameless: a render throw used to blank it entirely, with no
-// menu bar and no way to recover — and a throw caused by a bad persisted item
+// menu bar and no way to recover, and a throw caused by a bad persisted item
// reproduced on every launch. This boundary keeps the failure visible and
// offers the two exits that actually help: reload, or reset the saved session
// and reload.
@@ -35,34 +37,23 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, State> {
render(): ReactNode {
if (!this.state.error) return this.props.children
return (
-
-
-
Something went wrong
-
- {this.state.error.message}
-
-
- window.location.reload()}
- className="flex-1 rounded-xl bg-black py-2.5 text-[13px] font-semibold text-white transition hover:bg-[#242424]"
- >
- Reload
-
-
- Reset session
-
-
+
+
+
Something went wrong
+
{this.state.error.message}
+
+ window.location.reload()}>
+ Reload
+
+
+ Reset session
+
)
}
}
-
-/** One JSX Element wrapper so main.tsx stays a single expression. */
-export function Boundary({ children }: { children: ReactNode }): JSX.Element {
- return
{children}
-}
diff --git a/src/renderer/src/components/Icon.tsx b/src/renderer/src/components/Icon.tsx
deleted file mode 100644
index 594fc1d..0000000
--- a/src/renderer/src/components/Icon.tsx
+++ /dev/null
@@ -1,165 +0,0 @@
-import type { JSX } from 'react'
-
-export type IconName =
- | 'convert'
- | 'compress'
- | 'resize'
- | 'upscale'
- | 'removebg'
- | 'pdf'
- | 'archive'
- | 'tools'
- | 'merge'
- | 'split'
- | 'burst'
- | 'pull'
- | 'unpack'
- | 'topdf'
- | 'tocbz'
- | 'upload'
- | 'check'
- | 'clock'
- | 'min'
- | 'max'
- | 'close'
- | 'eye'
- | 'folder'
- | 'trash'
- | 'dots'
- | 'chevron-left'
- | 'chevron-down'
- | 'edit'
- | 'grip'
- | 'chevron-right'
- | 'play'
- | 'pause'
- | 'volume'
- | 'volume-mute'
- | 'fullscreen'
- | 'expand'
- | 'image'
- | 'video'
- | 'audio'
- | 'doc'
- | 'text'
-
-const PATHS: Record
= {
- convert: ,
- compress: ,
- image: (
-
- ),
- video: ,
- audio: ,
- doc: (
-
- ),
- text: ,
- 'chevron-down': ,
- edit: ,
- grip: ,
- resize: ,
- upscale: ,
- removebg: (
- <>
-
-
- >
- ),
- pdf: ,
- archive: ,
- tools: ,
- // One glyph per tool card: reusing convert/resize/upscale here made Split,
- // Burst and Extract read as the same action at a glance.
- merge: ,
- split: ,
- burst: ,
- pull: ,
- unpack: ,
- topdf: ,
- tocbz: ,
- upload: ,
- check: ,
- clock: (
- <>
-
-
- >
- ),
- min: ,
- max: ,
- close: ,
- eye: (
- <>
-
-
- >
- ),
- folder: ,
- trash: (
-
- ),
- dots: (
- <>
-
-
-
- >
- ),
- 'chevron-left': ,
- 'chevron-right': ,
- play: ,
- pause: (
- <>
-
-
- >
- ),
- volume: (
- <>
-
-
- >
- ),
- 'volume-mute': (
- <>
-
-
- >
- ),
- fullscreen: (
-
- ),
- expand: (
- <>
-
-
-
-
- >
- )
-}
-
-export function Icon({
- name,
- className,
- strokeWidth = 2
-}: {
- name: IconName
- className?: string
- strokeWidth?: number
-}): JSX.Element {
- return (
-
- {PATHS[name]}
-
- )
-}
diff --git a/src/renderer/src/components/OperationTitle.tsx b/src/renderer/src/components/OperationTitle.tsx
deleted file mode 100644
index 002f22a..0000000
--- a/src/renderer/src/components/OperationTitle.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import type { JSX } from 'react'
-import { Icon } from './Icon'
-
-/**
- * The page heading: the verb ("Convert", "Compress"), or the open tool's name
- * inside Tools, plus a file count once files exist.
- *
- * The rail already names the verb, so this heading repeats it back as the
- * workspace's own title. Inside a Tools workspace it also carries the way back
- * to the grid, which is the only place in the app with a second level.
- */
-export function OperationTitle({
- title,
- desc,
- color,
- fileCount,
- onBack
-}: {
- title: string
- desc: string
- color: string
- fileCount: number
- /** Present only inside a Tools workspace. */
- onBack?: () => void
-}): JSX.Element {
- return (
-
-
- {onBack && (
-
-
-
- )}
-
- {title}
-
-
- {/* The line always occupies its height so nothing below it shifts when a
- file is added and the count appears. Falls back to the verb's own
- one-liner while the queue is empty. */}
-
- {fileCount > 0 ? `${fileCount} file${fileCount === 1 ? '' : 's'}` : desc}
-
-
- )
-}
diff --git a/src/renderer/src/components/OptionsPanel.tsx b/src/renderer/src/components/OptionsPanel.tsx
deleted file mode 100644
index 8e865c4..0000000
--- a/src/renderer/src/components/OptionsPanel.tsx
+++ /dev/null
@@ -1,1872 +0,0 @@
-import { useEffect, useMemo, useRef, useState, type CSSProperties, type JSX } from 'react'
-import type { FileKind, JobOptions } from '@shared/types'
-import { familyFormats, isSameFormat, sharedTargets, type FormatOption } from '@shared/convert'
-import {
- AUDIO_BITRATES,
- AUDIO_CODECS,
- IMAGE_FORMATS,
- PDF_LEVELS,
- SCALE_MAX,
- SCALE_MIN,
- SCALE_STEP,
- UPSCALE_COMFY,
- UPSCALE_FACTORS,
- UPSCALE_GPU_MODES,
- UPSCALE_MODELS,
- VIDEO_CODECS,
- type Choice,
- type UpscaleModel
-} from '@shared/compress'
-import { ARCHIVE_FORMATS, COMIC_FORMATS, needsRar } from '@shared/archive'
-import type { TabId } from '@shared/tabs'
-import { RESIZE_FITS, type ResizeFit } from '@shared/resize'
-import { BG_DEFAULTS, BG_FILLS, type BgFill } from '@shared/removebg'
-import { GEN_SIZES, GEN_STYLES, GEN_MAX_COUNT, clampDim } from '@shared/generate'
-import { archInfoFor, type GenArch, type GenModel } from '@shared/genArch'
-import { Icon } from './Icon'
-import type { ToolId } from '@shared/types'
-import { groupNoun } from './queueGroups'
-import { PidInstallCard } from './PidUpscale'
-import { usePidStatus } from './usePidStatus'
-import { ComfyImportCard } from './ComfyImport'
-import { useComfyModels } from './useComfyModels'
-import { useGenerateStatus } from './useGenerateStatus'
-import { useArchiveStatus } from './useArchiveStatus'
-
-/** A live "input → output" resolution row for the video resolution preview. */
-export interface VideoOutputRow {
- path: string
- name: string
- from: string
- to: string
-}
-
-function Label({ children }: { children: string }): JSX.Element {
- return (
-
- {children}
-
- )
-}
-
-/** A small "?" badge whose hover tooltip explains an option. */
-function HelpTip({ text }: { text: string }): JSX.Element {
- return (
-
- ?
-
- )
-}
-
-function Segmented({
- value,
- options,
- onChange
-}: {
- value: T
- options: { value: T; label: string }[]
- onChange: (v: T) => void
-}): JSX.Element {
- return (
-
- {options.map((o) => (
- onChange(o.value)}
- className={`flex-1 rounded-lg py-1.5 text-[12.5px] font-semibold transition ${
- value === o.value
- ? 'bg-white text-ink shadow-[0_1px_2px_rgba(0,0,0,.06)]'
- : 'text-muted hover:text-ink'
- }`}
- >
- {o.label}
-
- ))}
-
- )
-}
-
-function ConvertOptions({
- options,
- activeKind,
- sourceExt,
- srcExts,
- verb,
- hasRar,
- set
-}: {
- options: JobOptions
- activeKind: FileKind | null
- sourceExt: string | null
- srcExts: string[]
- /** The archive verb this target resolves to, if any: repack / to-pdf / from-pdf. */
- verb?: string
- hasRar: boolean
- set: (k: string, v: string | number | boolean) => void
-}): JSX.Element {
- const kind = activeKind ?? 'image'
- // Targets valid for EVERY selected source, so a mixed pdf+docx selection
- // never offers CBZ (which only the pdf could do).
- const formats = srcExts.length
- ? sharedTargets(kind, srcExts)
- : familyFormats(kind, sourceExt ?? '')
- return (
- <>
-
-
Target format
-
- {formats.map((f) => {
- // Grey out (and block) any format a selected source already is: with
- // a PNG + JPEG selection, neither PNG nor JPEG is a valid target.
- const isSource = srcExts.some((e) => isSameFormat(f.ext, e))
- const noRar = needsRar(f.ext) && !hasRar
- const disabled = isSource || noRar
- const sel = options.format === f.ext && !disabled
- return (
- set('format', f.ext)}
- className={`rounded-xl border py-2.5 text-[13px] font-semibold transition ${
- sel
- ? 'border-accent bg-accent-soft text-accent shadow-[0_0_0_3px_rgba(0,0,0,.06)]'
- : disabled
- ? 'cursor-not-allowed border-black/[.06] bg-white text-[#c4c4cc]'
- : 'border-black/[.10] bg-white text-[#33333a] hover:border-[#b9b9c8]'
- }`}
- >
- {f.label}
-
- )
- })}
-
-
- {kind === 'image' && (
-
- Quality
- set('quality', v)}
- options={[
- { value: 'smaller', label: 'Smaller' },
- { value: 'balanced', label: 'Balanced' },
- { value: 'best', label: 'Best' }
- ]}
- />
-
- )}
- {/* Repacking an archive: the pages are already compressed images, so
- storing them is the same size and faster. */}
- {verb === 'repack' && (
-
-
Compression
-
set('store', v === 'store')}
- options={[
- { value: 'store', label: 'Store' },
- { value: 'normal', label: 'Normal' }
- ]}
- />
-
- Comic pages are already compressed images, so Store is faster at the same size.
-
-
- )}
- {/* PDF into a comic archive: its pages have to be rendered first. */}
- {verb === 'from-pdf' && }
- >
- )
-}
-
-/** Page-rendering controls shared by every route that turns a PDF into images. */
-function PageRenderOptions({
- options,
- set
-}: {
- options: JobOptions
- set: (k: string, v: string | number | boolean) => void
-}): JSX.Element {
- const pageFormat = String(options.pageFormat ?? 'jpg')
- return (
- <>
-
-
-
- Resolution
-
-
- {Number(options.dpi ?? 150)} DPI
-
-
-
set('dpi', Number(e.target.value))}
- className="w-full accent-accent"
- />
-
-
-
Page format
-
set('pageFormat', v)}
- options={[
- { value: 'jpg', label: 'JPEG' },
- { value: 'png', label: 'PNG' }
- ]}
- />
-
- {pageFormat === 'jpg'
- ? 'Much smaller files, the usual choice for comics.'
- : 'Lossless, but a long comic runs to hundreds of megabytes.'}
-
-
- {pageFormat === 'jpg' && (
-
- )}
- >
- )
-}
-
-/** A styled single-select dropdown. */
-function ChoiceSelect({
- value,
- choices,
- onChange
-}: {
- value: T
- choices: Choice[]
- onChange: (v: T) => void
-}): JSX.Element {
- return (
-
-
onChange(e.target.value as T)}
- className="w-full cursor-pointer appearance-none rounded-xl border border-black/[.10] bg-white py-2.5 pl-3.5 pr-9 text-[13px] font-semibold text-ink outline-none transition hover:border-[#b9b9c8] focus:border-accent"
- >
- {choices.map((c) => (
-
- {c.label}
-
- ))}
-
-
-
-
-
- )
-}
-
-function QualitySlider({
- options,
- set,
- field = 'quality',
- fallback = 80
-}: {
- options: JobOptions
- set: (k: string, v: string | number | boolean) => void
- /** Which option this drives. Page rendering uses `pageQuality` so it cannot
- * collide with convert's preset `quality` in a shared option bag. */
- field?: string
- fallback?: number
-}): JSX.Element {
- const q = Number(options[field] ?? fallback)
- return (
-
-
- Quality
- {q}
-
-
set(field, Number(e.target.value))}
- className="w-full accent-accent"
- />
-
- Smaller file
- Higher quality
-
-
- )
-}
-
-function CompressOptions({
- options,
- activeKind,
- videoOutputs,
- set
-}: {
- options: JobOptions
- activeKind: FileKind | null
- videoOutputs?: VideoOutputRow[]
- set: (k: string, v: string | number | boolean) => void
-}): JSX.Element {
- if (activeKind === 'pdf') {
- const gray = Boolean(options.pdfGray)
- return (
- <>
-
- Level
- set('pdfLevel', v)}
- />
-
- set('pdfGray', !gray)}
- className="flex items-center justify-between rounded-xl border border-black/[.10] bg-white px-3.5 py-2.5 text-left transition hover:border-[#b9b9c8]"
- >
- Convert to grayscale
-
-
-
-
- >
- )
- }
-
- if (activeKind === 'video') {
- return (
- <>
-
- Codec
- set('videoCodec', v)}
- />
-
-
-
-
- Scale
-
-
- {Number(options.scale ?? 100) === 100 ? 'Original' : `${Number(options.scale)}%`}
-
-
-
set('scale', Number(e.target.value))}
- className="w-full accent-accent"
- />
-
- {SCALE_MIN}%
- Original
-
-
- {Number(options.scale ?? 100) < 100 && videoOutputs && videoOutputs.length > 0 && (
-
-
Output
-
- {videoOutputs.map((r) => (
-
-
- {r.name}
-
-
- {r.from}
- {r.to !== r.from && → {r.to} }
-
-
- ))}
-
-
- )}
-
- >
- )
- }
-
- if (activeKind === 'audio') {
- const br = Number(options.audioBitrate ?? 192)
- return (
- <>
-
- Format
- set('audioCodec', v)}
- />
-
-
-
Bitrate
-
- {AUDIO_BITRATES.map((b) => {
- const sel = br === b
- return (
- set('audioBitrate', b)}
- className={`rounded-xl border py-2 text-[12.5px] font-semibold transition ${
- sel
- ? 'border-accent bg-accent-soft text-accent shadow-[0_0_0_3px_rgba(0,0,0,.06)]'
- : 'border-black/[.10] bg-white text-[#33333a] hover:border-[#b9b9c8]'
- }`}
- >
- {b}k
-
- )
- })}
-
-
- >
- )
- }
-
- // Image
- return (
- <>
-
- Format
- set('imageFormat', v)}
- />
-
-
- >
- )
-}
-
-/**
- * Image Upscale (Real-ESRGAN). Deliberately a separate tool from Resize: this
- * reconstructs detail with a neural net rather than interpolating, so the only
- * choices that matter are how much bigger and which model. The output list is
- * the whole explanation the panel needs — no prose about GPUs or file formats.
- */
-function UpscaleOptions({
- options,
- outputs,
- set
-}: {
- options: JobOptions
- outputs?: VideoOutputRow[]
- set: (k: string, v: string | number) => void
-}): JSX.Element {
- const factor = Number(options.upscaleFactor ?? 4)
- const comfy = useComfyModels()
- const { status: pid, refresh: refreshPid } = usePidStatus()
-
- // Two-level picker: Photo / Anime / "AI models" (NVIDIA-only). The AI-models
- // category holds a SECOND picker of every AI upscaler — the user's imported
- // ComfyUI ESRGAN models AND PiD (diffusion). Stored value is 'photo' | 'anime'
- // | 'pid' | 'comfy' (category placeholder) | 'comfy:'.
- const rawModel = String(options.upscaleModel ?? 'photo')
- const hasNvidia = Boolean(comfy.status?.nvidia || pid?.nvidia)
- // Which Real-ESRGAN models exist is read from disk, not frozen at build time.
- // This is the only AI upscaler an AMD/Intel user has, and it used to be locked
- // to the two names the build script happened to copy.
- const [ncnn, setNcnn] = useState<{ value: string; label: string; user: boolean }[] | null>(null)
- useEffect(() => {
- void window.filesmith.upscaleModels().then(setNcnn)
- }, [])
- const comfyModels = comfy.status?.models
- // Memoized: the validity effect below depends on this list and calls set()
- // from inside itself — with a freshly-mapped array every render it escaped
- // looping only by convergence.
- const comfyChoices = useMemo(
- () =>
- (comfyModels ?? []).map((m) => ({
- value: `comfy:${m.path}`,
- // Verified models read clean; only flag the unvetted ones, so the
- // marker isn't mistaken for a "selected" checkmark.
- label: `${m.name} · ${m.scale}×${m.badge === 'experimental' ? ' · experimental' : ''}`
- })),
- [comfyModels]
- )
- // Fall back to the legacy Photo/Anime aliases until the disk scan returns, so
- // the picker is never empty for a frame.
- const ncnnChoices: Choice[] = ncnn?.length
- ? ncnn.map((m) => ({
- value: m.value as UpscaleModel,
- label: m.user ? `${m.label} · added by you` : m.label
- }))
- : UPSCALE_MODELS
- const isPid = rawModel === 'pid'
- const isComfyPath = rawModel.startsWith('comfy:')
- const inAi = isPid || isComfyPath || rawModel === 'comfy'
- const category = inAi
- ? 'comfy'
- : ncnnChoices.some((c) => c.value === rawModel)
- ? rawModel
- : (ncnnChoices[0]?.value ?? 'photo')
- const categoryChoices = [...ncnnChoices, ...(hasNvidia ? [UPSCALE_COMFY] : [])]
- // PiD is only offered when it's actually attainable: already installed, or its
- // weights are reusable from the user's ComfyUI (so setup is quick). Otherwise
- // it isn't listed — it shouldn't look available when it isn't.
- const showPid = hasNvidia && Boolean(pid?.installed || comfy.status?.pidReusable)
- // The AI-models sub-picker: imported ESRGAN models first, then PiD.
- const subChoices = [
- ...comfyChoices,
- ...(showPid ? [{ value: 'pid', label: 'PiD (diffusion) · 4×' }] : [])
- ]
- const subDefault = comfyChoices[0]?.value ?? (showPid ? 'pid' : 'comfy')
- const subValue = isPid ? 'pid' : isComfyPath ? rawModel : subDefault
- const pidNeedsInstall = isPid && pid != null && !pid.installed
- const vramMb = pid?.nvidia?.vramMb ?? null
- const lowVram = isPid && vramMb != null && vramMb < 12_000
-
- const pickCategory = (v: string): void => {
- // "AI models" jumps to the first available upscaler (an imported model, else
- // PiD); the sub-picker then swaps between them.
- if (v === 'comfy') set('upscaleModel', subDefault)
- else set('upscaleModel', v)
- }
-
- // Keep the stored value valid once both statuses have loaded: an AI choice with
- // no GPU falls back to Photo; a comfy: that's gone, or PiD that isn't
- // showable, falls back to the first available AI model (or the empty category).
- useEffect(() => {
- if (pid == null || comfy.status == null) return
- if (inAi && !hasNvidia) set('upscaleModel', 'photo')
- else if (isComfyPath && !comfyChoices.some((c) => c.value === rawModel))
- set('upscaleModel', subDefault)
- else if (isPid && !showPid) set('upscaleModel', subDefault)
- }, [
- pid,
- comfy.status,
- hasNvidia,
- inAi,
- isComfyPath,
- isPid,
- showPid,
- rawModel,
- comfyChoices,
- subDefault,
- set
- ])
-
- return (
- <>
-
-
Scale
-
- {UPSCALE_FACTORS.map((f) => {
- const sel = factor === f
- return (
- set('upscaleFactor', f)}
- className={`rounded-xl border py-2.5 text-[13px] font-semibold transition ${
- sel
- ? 'border-accent bg-accent-soft text-accent shadow-[0_0_0_3px_rgba(0,0,0,.06)]'
- : 'border-black/[.10] bg-white text-[#33333a] hover:border-[#b9b9c8]'
- }`}
- >
- {f}×
-
- )
- })}
-
-
-
-
Model
-
- {/* The drop-in models folder that upscale:models reads was reachable
- only by knowing the %APPDATA% path by heart. */}
- {!inAi && (
-
void window.filesmith.upscaleOpenModelsFolder()}
- className="mt-2 text-[11.5px] font-medium text-muted underline-offset-2 hover:text-ink hover:underline"
- >
- Add your own model… (opens the models folder)
-
- )}
- {/* Why the AI tier is missing, instead of silently hiding it: the
- verdict was computed in main and thrown away before any UI. */}
- {!hasNvidia && (pid?.cudaReason || comfy.status?.cudaReason) && (
-
- {pid?.cudaReason ?? comfy.status?.cudaReason}
-
- )}
- {category === 'comfy' && (
- <>
- {/* Second picker: which AI upscaler (imported ESRGAN models + PiD). */}
- {subChoices.length > 0 && (
-
- set('upscaleModel', v)}
- />
-
- )}
- {pidNeedsInstall && (
-
- )}
- {/* The way OUT of a poisoned install: pidInstalled() is a bare
- existsSync, so a corrupt multi-GB weight was otherwise
- unrecoverable from the UI. Two-step to avoid an accidental
- 6 GB re-download. */}
- {isPid && pid?.installed &&
}
- {comfy.status && (
-
-
-
- )}
- >
- )}
-
- {/* GPU usage. The option names carry their own meaning, so there is no
- explanatory line under them. PiD's diffusion runs in one pass and can't
- be paced, so it gets no control at all rather than a disabled one. */}
- {!isPid && (
-
- GPU usage
- set('gpuMode', v)}
- options={UPSCALE_GPU_MODES.map((o) => ({ value: o.value, label: o.label }))}
- />
-
- )}
- {/* Not option help: a capability warning the user can't infer from the UI. */}
- {isPid && lowVram && (
-
- Your GPU reports ~{Math.round((vramMb as number) / 1024)} GB — PiD will auto-reduce
- resolution on large images to fit.
-
- )}
- {outputs && outputs.length > 0 && (
-
-
Output
-
- {outputs.map((r) => (
-
-
- {r.name}
-
-
- {r.from}
- {r.to !== r.from && → {r.to} }
-
-
- ))}
-
-
- )}
- >
- )
-}
-
-function ResizeOptions({
- options,
- outputs,
- set
-}: {
- options: JobOptions
- outputs?: VideoOutputRow[]
- set: (k: string, v: string | number) => void
-}): JSX.Element {
- const mode = String(options.mode ?? 'percent')
- const fit = (options.fit === 'stretch' ? 'stretch' : 'contain') as ResizeFit
- return (
- <>
-
- Mode
- set('mode', v)}
- options={[
- { value: 'percent', label: 'Percent' },
- { value: 'dimensions', label: 'Dimensions' }
- ]}
- />
-
- {mode === 'percent' ? (
-
-
-
- Scale
-
-
- {Number(options.percent ?? 50)}%
-
-
-
set('percent', Number(e.target.value))}
- className="w-full accent-accent"
- />
-
- ) : (
-
- )}
- {mode === 'dimensions' && (
-
- Fit
- set('fit', v)}
- options={RESIZE_FITS.map((f) => ({ value: f.value, label: f.label }))}
- />
-
- )}
- {/* The resulting size, per file. Without it, "Keep aspect" silently
- discards whichever of width/height isn't the limiting one, and the
- resize looks broken when a changed number does nothing. */}
- {outputs && outputs.length > 0 && (
-
-
Output
-
- {outputs.map((r) => (
-
-
- {r.name}
-
-
- {r.from}
- {r.to !== r.from && → {r.to} }
-
-
- ))}
-
-
- )}
- >
- )
-}
-
-/**
- * Remove Background (rembg): one dropdown and three toggles.
- *
- * There is no model picker. The engine supports four licence-vetted models, but
- * birefnet-general beats the others by a wide margin (IoU 0.87 vs 0.82 isnet and
- * 0.39 u2net on the one independent benchmark), so offering the weaker ones is
- * offering a worse cutout. Same reasoning for the alpha-matting trimap
- * thresholds: tuning knobs, not decisions, left at rembg's defaults.
- */
-function RemoveBgOptions({
- options,
- set
-}: {
- options: JobOptions
- set: (k: string, v: string | number | boolean) => void
-}): JSX.Element {
- const fill = String(options.bgFill ?? BG_DEFAULTS.bgFill) as BgFill
- const bgImage = String(options.bgImagePath ?? '')
- // Disclose up front that background removal is AI-powered and, on first use,
- // downloads a model — so a user who doesn't want AI can simply not use it,
- // and one who does knows what to expect instead of hitting a mid-run error.
- const [rembg, setRembg] = useState<{ ready: boolean; uvAvailable: boolean } | null>(null)
- useEffect(() => {
- void window.filesmith.removebgStatus().then(setRembg)
- }, [])
- return (
-
- {rembg && !rembg.ready && (
-
- {rembg.uvAvailable ? (
- <>First run downloads the AI model once (a few hundred MB), then works offline.>
- ) : (
- <>
- Needs the free uv tool:{' '}
- winget install astral-sh.uv , then reopen
- Filesmith.
- >
- )}
-
- )}
-
Background
-
set('bgFill', v)} />
- {fill === 'custom' && (
- set('bgCustomColor', e.target.value)}
- className="mt-2 h-9 w-full cursor-pointer rounded-xl border border-black/[.10] bg-white p-1"
- />
- )}
- {fill === 'image' && (
-
- void window.filesmith.pickImage().then((p) => {
- if (p) set('bgImagePath', p)
- })
- }
- className="mt-2 flex w-full items-center gap-2 rounded-xl border border-black/[.10] bg-white px-3 py-2.5 text-left text-[12.5px] font-semibold transition hover:border-[#b9b9c8]"
- >
-
-
- {bgImage ? bgImage.split(/[\\/]/).pop() : 'Choose image…'}
-
-
- )}
-
- )
-}
-
-// Every PDF operation. The explanation lives in the hover tooltip, not on the
-// panel: "Burst" and "Extract imgs" need a gloss, but not one taking up space
-// permanently for the users who already know what they mean.
-const PDF_OPS: { value: string; label: string; title: string }[] = [
- { value: 'extract-text', label: 'Text', title: 'Extract the text layer to a .txt file' },
- { value: 'pages-to-images', label: 'Pages → PNG', title: 'Render each page to a PNG' },
- { value: 'merge', label: 'Merge', title: 'Combine the selected PDFs, in queue order, into one' },
- { value: 'split-range', label: 'Split', title: 'Keep only the pages you list' },
- { value: 'split-pages', label: 'Burst', title: 'Save every page as its own PDF' },
- { value: 'extract-images', label: 'Extract imgs', title: 'Pull out every embedded image' }
-]
-
-function PdfOptions({
- options,
- runCount,
- set
-}: {
- options: JobOptions
- runCount: number
- set: (k: string, v: string | number) => void
-}): JSX.Element {
- const op = String(options.op ?? 'extract-text')
- return (
- <>
-
-
Operation
-
- {PDF_OPS.map((o) => {
- const sel = op === o.value
- return (
- set('op', o.value)}
- className={`rounded-xl border py-2.5 text-[12.5px] font-semibold transition ${
- sel
- ? 'border-accent bg-accent-soft text-accent shadow-[0_0_0_3px_rgba(0,0,0,.06)]'
- : 'border-black/[.10] bg-white text-[#33333a] hover:border-[#b9b9c8]'
- }`}
- >
- {o.label}
-
- )
- })}
-
-
- {op === 'pages-to-images' && (
-
-
-
- Resolution
-
-
- {Number(options.dpi ?? 150)} DPI
-
-
-
set('dpi', Number(e.target.value))}
- className="w-full accent-accent"
- />
-
- )}
- {op === 'split-range' && (
-
- Pages to keep
- set('range', e.target.value)}
- className="w-full rounded-xl border border-black/[.10] bg-white px-3 py-2 text-sm outline-none focus:border-accent"
- />
-
- )}
- {/* Merge is the one op whose requirement isn't visible in the UI (it needs
- 2+ files), so it keeps a line. Every other op's name says enough. */}
- {op === 'merge' && runCount < 2 && (
- Select 2 or more PDFs.
- )}
- >
- )
-}
-
-/** Target-format chips shared by the two archive operations that write one.
- * A format is greyed rather than hidden when it needs WinRAR: a user looking
- * for CBR gets an answer on hover instead of a missing option. */
-function ArchiveTargets({
- formats,
- value,
- srcExts,
- hasRar,
- set
-}: {
- formats: FormatOption[]
- value: string
- srcExts: string[]
- hasRar: boolean
- set: (k: string, v: string | number | boolean) => void
-}): JSX.Element {
- return (
-
-
Target format
-
- {formats.map((f) => {
- // .zip and .cbz are the same container but not the same file, so the
- // comparison is by extension, not by container: repacking a .zip as
- // a .cbz is exactly what a comic reader needs.
- const isSource = srcExts.includes(f.ext)
- const noRar = needsRar(f.ext) && !hasRar
- const disabled = isSource || noRar
- const sel = value === f.ext && !disabled
- return (
- set('format', f.ext)}
- className={`rounded-xl border py-2.5 text-[13px] font-semibold transition ${
- sel
- ? 'border-accent bg-accent-soft text-accent shadow-[0_0_0_3px_rgba(0,0,0,.06)]'
- : disabled
- ? 'cursor-not-allowed border-black/[.06] bg-white text-[#c4c4cc]'
- : 'border-black/[.10] bg-white text-[#33333a] hover:border-[#b9b9c8]'
- }`}
- >
- {f.label}
-
- )
- })}
-
-
- )
-}
-
-function ArchiveOptions({
- options,
- srcExts,
- set
-}: {
- options: JobOptions
- srcExts: string[]
- set: (k: string, v: string | number | boolean) => void
-}): JSX.Element {
- const op = String(options.op ?? 'repack')
- const { rar } = useArchiveStatus()
- const format = String(options.format ?? '.cbz')
-
- if (op === 'extract') {
- return (
-
- Each archive is unpacked into its own folder next to it.
-
- )
- }
-
- if (op === 'to-pdf') {
- return (
-
- Pages are ordered by filename, the way a reader shows them.
-
- )
- }
-
- if (op === 'from-pdf') {
- const pageFormat = String(options.pageFormat ?? 'jpg')
- return (
- <>
-
-
-
-
- Resolution
-
-
- {Number(options.dpi ?? 150)} DPI
-
-
-
set('dpi', Number(e.target.value))}
- className="w-full accent-accent"
- />
-
-
-
Page format
-
set('pageFormat', v)}
- options={[
- { value: 'jpg', label: 'JPEG' },
- { value: 'png', label: 'PNG' }
- ]}
- />
-
- {pageFormat === 'jpg'
- ? 'Much smaller files, the usual choice for comics.'
- : 'Lossless, but a long comic runs to hundreds of megabytes.'}
-
-
- {pageFormat === 'jpg' && }
- >
- )
- }
-
- // repack
- return (
- <>
-
-
-
Compression
-
set('store', v === 'store')}
- options={[
- { value: 'store', label: 'Store' },
- { value: 'normal', label: 'Normal' }
- ]}
- />
-
- Comic pages are already compressed images, so Store is faster at the same size.
-
-
- >
- )
-}
-
-/** A checkpoint whose name marks it as restoration/refiner/inpaint — a valid
- * model but not text-to-image, so we never auto-select it as the default. */
-function isRestoreName(name: string): boolean {
- return /supir|refiner|inpaint|upscal|controlnet/i.test(name)
-}
-
-/** Model dropdown grouped by architecture (Checkpoints / Flux / Z-Image / …).
- * Non-runnable models stay selectable and are annotated, so picking one reveals
- * the download card rather than being a dead, greyed-out row. */
-function ModelPicker({
- models,
- value,
- onChange
-}: {
- models: GenModel[]
- value: string
- onChange: (v: string) => void
-}): JSX.Element {
- const groups: string[] = []
- for (const m of models) if (!groups.includes(m.group)) groups.push(m.group)
- return (
-
-
onChange(e.target.value)}
- className="w-full cursor-pointer appearance-none rounded-xl border border-black/[.10] bg-white py-2.5 pl-3.5 pr-9 text-[13px] font-semibold text-ink outline-none transition hover:border-[#b9b9c8] focus:border-accent"
- >
- {groups.map((g) => (
-
- {models
- .filter((m) => m.group === g)
- .map((m) => (
-
- {m.label}
- {m.runnable ? '' : ' — needs download'}
-
- ))}
-
- ))}
-
-
-
-
-
- )
-}
-
-/** Shown when the selected model is missing its text-encoder / VAE files. Fetches
- * them into the user's ComfyUI folders, then refreshes so the model goes runnable
- * — the "works for anyone" path, not just a machine that already has the files. */
-function CompanionDownload({
- model,
- onDone
-}: {
- model: GenModel
- onDone: () => void
-}): JSX.Element {
- const [busy, setBusy] = useState(false)
- const [msg, setMsg] = useState(null)
- const [pct, setPct] = useState(null)
- const [err, setErr] = useState(null)
- const missing = model.missing ?? []
-
- const start = async (): Promise => {
- setBusy(true)
- setErr(null)
- const id = `dl-${model.name}`
- const off = window.filesmith.onGenerateDownloadProgress((p) => {
- if (p.id !== id) return
- setMsg(`${p.label} (${p.index}/${p.total})`)
- setPct(p.pct)
- })
- try {
- const r = await window.filesmith.generateDownload(id, model.name)
- if (!r.ok) setErr(r.error ?? 'Download failed.')
- else onDone()
- } finally {
- off()
- setBusy(false)
- }
- }
-
- return (
-
-
- This model needs {missing.length} file{missing.length === 1 ? '' : 's'} you don't have
- yet:
-
-
- {missing.map((m) => (
-
- • {m.label} ({m.approxSize})
-
- ))}
-
- {busy ? (
-
-
- {msg ?? 'Starting…'}
- {pct == null ? '' : `${pct}%`}
-
-
-
- ) : (
-
- Download required files
-
- )}
- {err &&
{err}
}
-
- )
-}
-
-/**
- * "ComfyUI wasn't found" — with a way out. This used to be a dead end: static
- * text telling the user to "open ComfyUI once so Filesmith can locate it", which
- * nothing in the code implements (availability is a filesystem walk, not a probe
- * of a running server). Discovery is path guessing, so a ComfyUI on F:, on a NAS,
- * under C:\AI\comfy or in a OneDrive-redirected Documents was simply unreachable
- * and the notice never cleared. Locating it writes the same store that generate,
- * upscale and companion discovery all read first, so one pick fixes all three.
- */
-function LocateComfy({ onLocated }: { onLocated: () => void }): JSX.Element {
- const [err, setErr] = useState(null)
- const pick = (): void => {
- setErr(null)
- void window.filesmith.comfyPickFolder().then((folder) => {
- if (!folder) return
- void window.filesmith.comfySetFolder(folder).then((r) => {
- if (r.ok) onLocated()
- else setErr(r.error ?? "Couldn't use that folder")
- })
- })
- }
- return (
-
-
ComfyUI wasn't found automatically.
-
- Locate my ComfyUI folder
-
- {err &&
{err}
}
-
- )
-}
-
-/**
- * "Add a model" — the escape hatch that means a new architecture never requires
- * an app release. Importing a ComfyUI "Export (API)" workflow is the highest-
- * leverage case: a user who can already generate a model inside ComfyUI can now
- * generate it here, with no knowledge of Filesmith's internals.
- */
-function AddModel({
- onAdded,
- comfyFolder
-}: {
- onAdded: () => void
- comfyFolder?: string | null
-}): JSX.Element {
- const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null)
- // Changing the ComfyUI folder belongs here as much as in Upscale — both panels
- // read the same store. It used to be reachable from Generate ONLY through the
- // "not found" banner, so once a ComfyUI was located there was no way to point
- // at a different one without going to the Upscale tab.
- const pickComfy = (): void => {
- setMsg(null)
- void window.filesmith.comfyPickFolder().then((folder) => {
- if (!folder) return
- void window.filesmith.comfySetFolder(folder).then((r) => {
- if (r.ok) onAdded()
- else setMsg({ ok: false, text: r.error ?? "Couldn't use that folder" })
- })
- })
- }
- const importOne = (): void => {
- setMsg(null)
- void window.filesmith.registryImport().then((r) => {
- if (!r.ok) {
- if (r.error) setMsg({ ok: false, text: r.error })
- return
- }
- setMsg({
- ok: true,
- text: [`Added ${r.ids?.join(', ')}.`, ...(r.notes ?? [])].join(' ')
- })
- onAdded()
- })
- }
- return (
-
-
-
- Add a model…
-
- void window.filesmith.registryOpenFolder()}
- title="Open the folder where your own model entries live"
- className="rounded-xl border border-black/[.12] bg-white px-3 py-2 text-[12.5px] font-semibold text-ink transition hover:border-[#b9b9c8]"
- >
- Open folder
-
-
-
- ComfyUI
-
- {comfyFolder ?? 'Choose folder…'}
-
-
- {msg && (
-
- {msg.text}
-
- )}
-
- )
-}
-
-/** Remove the PiD install (repair path). Two clicks: the first arms it. */
-function PidRemoveButton({ onRemoved }: { onRemoved: () => void }): JSX.Element {
- const [armed, setArmed] = useState(false)
- const [error, setError] = useState(null)
- const remove = (): void => {
- if (!armed) {
- setArmed(true)
- return
- }
- setArmed(false)
- void window.filesmith.pidInstalling().then((busy) => {
- if (busy) {
- setError('An install is running. Wait for it to finish first.')
- return
- }
- void window.filesmith.pidRemove().then((r) => {
- if (!r.ok) setError(r.error ?? 'Could not remove the install.')
- else {
- setError(null)
- onRemoved()
- }
- })
- })
- }
- return (
-
-
setArmed(false)}
- className={`text-[11.5px] font-medium underline-offset-2 hover:underline ${
- armed ? 'text-[#e0483d]' : 'text-muted hover:text-ink'
- }`}
- >
- {armed ? 'Click again to remove the PiD install (~6 GB)' : 'Remove PiD install…'}
-
- {error &&
{error}
}
-
- )
-}
-
-/** A dimension field that clamps on BLUR, not on every keystroke: clamping
- * while typing turned "832" into 256 the moment its first digit landed, which
- * made the field impossible to use at all. Steps by the model's own dimStep. */
-function DimInput({
- value,
- caps,
- onCommit
-}: {
- value: number
- caps?: { minDim?: number; maxDim?: number; dimStep?: number }
- onCommit: (v: number) => void
-}): JSX.Element {
- const [text, setText] = useState(String(value))
- // Re-sync when the committed value changes from OUTSIDE (preset picked):
- // the render-time adjustment pattern, not an effect (no extra paint).
- const [lastValue, setLastValue] = useState(value)
- if (value !== lastValue) {
- setLastValue(value)
- setText(String(value))
- }
- const commit = (): void => {
- const n = Number(text)
- onCommit(clampDim(Number.isFinite(n) && n > 0 ? n : value, caps))
- }
- return (
- setText(e.target.value)}
- onBlur={commit}
- onKeyDown={(e) => {
- if (e.key === 'Enter') commit()
- }}
- className="w-full rounded-xl border border-black/[.10] bg-white px-3 py-2 text-sm outline-none focus:border-accent"
- />
- )
-}
-
-/**
- * Text-to-image generation options. The prompt lives in the center PromptBox;
- * this panel carries the model + sampling settings and the availability notice.
- */
-function GenerateOptions({
- options,
- set
-}: {
- options: JobOptions
- set: (k: string, v: string | number) => void
-}): JSX.Element {
- const { status, refresh } = useGenerateStatus()
- const model = String(options.model ?? '')
- const w = Number(options.width ?? 1024)
- const h = Number(options.height ?? 1024)
- const seed = Number(options.seed ?? -1)
- // An explicit mode: derived-from-dimensions alone made "Custom…" unreachable
- // (the defaults equal a preset, so the select snapped straight back and the
- // width/height inputs never mounted in any build).
- const isPreset = GEN_SIZES.some((s) => s.width === w && s.height === h)
- const sizeValue =
- String(options.sizeMode ?? '') === 'custom' || !isPreset ? 'custom' : `${w}x${h}`
-
- const models = useMemo(() => status?.models ?? [], [status])
- const selected = models.find((m) => m.name === model)
- // "Try anyway" is per-model and deliberately not sticky: it must be a
- // conscious choice each time, not a setting that quietly stays on.
- const tryAnyway = Boolean(options.tryAnyway)
- // Dimension limits come from the model's own registry entry. One global 2048
- // ceiling was a guess about SDXL applied to every architecture, silently
- // capping models whose native resolution is higher.
- const dimCaps = status?.dimCaps?.[selected?.arch ?? 'sdxl']
- const arch: GenArch = selected?.arch ?? 'sdxl'
- // Sampler defaults come from the REGISTRY when the main process supplied them,
- // so a user-added family gets its own defaults instead of a built-in's (cfg 7
- // on a distilled model produces garbage). The compiled table is the fallback.
- const info = archInfoFor(arch, status?.archInfo)
-
- // Default to a runnable text-to-image model once the list loads, and steer away
- // from a restoration checkpoint (SUPIR) or a vanished selection.
- useEffect(() => {
- if (!models.length) return
- if (!selected) {
- const good =
- models.find((m) => m.runnable && !isRestoreName(m.label)) ??
- models.find((m) => m.runnable) ??
- models[0]
- if (good && good.name !== model) set('model', good.name)
- }
- }, [model, models, selected, set])
-
- // When the architecture changes, reset the sampler knobs to that arch's sane
- // defaults (cfg MUST be 1 for Flux/Z-Image/Krea; steps differ per family).
- // Only AFTER the scan resolves, and only on a real change: the ref used to
- // start null with arch defaulting to 'sdxl' mid-scan, so every mount fired
- // the reset twice and the persisted Steps/CFG were never once honoured.
- const prevArch = useRef(null)
- useEffect(() => {
- if (!status) return
- if (prevArch.current === null) {
- prevArch.current = arch
- return
- }
- if (prevArch.current === arch) return
- prevArch.current = arch
- set('tryAnyway', 0)
- set('steps', info.steps)
- set('cfg', info.cfg)
- if (info.hasGuidance) set('guidance', info.guidance)
- }, [arch, info, set, status])
-
- return (
- <>
- {status && !status.available && }
-
-
Model
- {models.length ? (
-
set('model', v)} />
- ) : (
-
- No image models found in your ComfyUI models folder.
-
- )}
- {selected && !selected.runnable && selected.missing?.length ? (
-
-
-
- ) : selected && !selected.runnable && selected.reason ? (
-
-
{selected.reason}
- {/* An unrecognized model is not a forbidden one. Send it through a
- generic graph and let ComfyUI give its own verdict — the user
- learns something either way, which beats a dead end. */}
- {selected.tryAnyway && (
-
set('tryAnyway', tryAnyway ? 0 : 1)}
- className={`w-full rounded-xl border px-3 py-2 text-[12.5px] font-semibold transition ${
- tryAnyway
- ? 'border-accent bg-accent-soft text-accent'
- : 'border-black/[.12] bg-white text-ink hover:border-[#b9b9c8]'
- }`}
- >
- {tryAnyway ? 'Will try anyway — click to cancel' : 'Try anyway'}
-
- )}
-
- ) : null}
-
-
- {/* Negative prompt only affects arches that use real CFG (SDXL). Flux / Z-Image
- / Krea run at cfg 1, where the negative branch is inert — so hide it there
- rather than let it look like it does something. */}
- {info.cfg !== 1 && (
-
- Negative prompt
- set('negative', e.target.value)}
- className="w-full rounded-xl border border-black/[.10] bg-white px-3 py-2 text-sm outline-none focus:border-accent"
- />
-
- )}
-
-
Style
-
- {GEN_STYLES.map((s) => {
- const sel = String(options.style ?? 'none') === s.id
- return (
- set('style', s.id)}
- className={`rounded-xl border py-2 text-[12px] font-semibold transition ${
- sel
- ? 'border-accent bg-accent-soft text-accent shadow-[0_0_0_3px_rgba(0,0,0,.06)]'
- : 'border-black/[.10] bg-white text-[#33333a] hover:border-[#b9b9c8]'
- }`}
- >
- {s.label}
-
- )
- })}
-
-
-
-
- Images
- {Number(options.count ?? 1)}
-
-
set('count', Number(e.target.value))}
- className="w-full accent-accent"
- />
-
-
-
Size
-
({ value: `${s.width}x${s.height}`, label: s.label })),
- { value: 'custom', label: 'Custom…' }
- ]}
- onChange={(v) => {
- if (v === 'custom') {
- set('sizeMode', 'custom')
- return
- }
- set('sizeMode', 'preset')
- const [ww, hh] = v.split('x').map(Number)
- set('width', ww)
- set('height', hh)
- }}
- />
- {sizeValue === 'custom' && (
-
-
- Width
- set('width', v)} />
-
-
- Height
- set('height', v)} />
-
-
- )}
-
- {/* Power-user knobs. The defaults are good for almost everything, so they
- live in a collapsed section with a one-line explanation on each. */}
-
-
- Advanced
-
-
-
-
-
- Steps
-
-
-
- {Number(options.steps ?? info.steps)}
-
-
-
set('steps', Number(e.target.value))}
- className="w-full accent-accent"
- />
-
- {arch === 'sdxl' ? (
-
-
-
- Guidance (CFG)
-
-
-
- {Number(options.cfg ?? 7)}
-
-
-
set('cfg', Number(e.target.value))}
- className="w-full accent-accent"
- />
-
- ) : info.hasGuidance ? (
-
-
-
- Guidance
-
-
-
- {Number(options.guidance ?? info.guidance)}
-
-
-
set('guidance', Number(e.target.value))}
- className="w-full accent-accent"
- />
-
- ) : null}
-
-
-
- Seed
-
-
-
-
- set('seed', e.target.value === '' ? -1 : Number(e.target.value))}
- className="w-full rounded-xl border border-black/[.10] bg-white px-3 py-2 text-sm outline-none focus:border-accent"
- />
- set('seed', -1)}
- className={`rounded-xl border px-3.5 py-2 text-[12.5px] font-semibold transition ${
- seed < 0
- ? 'border-accent bg-accent-soft text-accent'
- : 'border-black/[.12] bg-white text-ink hover:border-[#b9b9c8]'
- }`}
- >
- Random
-
-
-
-
-
- >
- )
-}
-
-export function OptionsPanel({
- tab,
- tool,
- label,
- options,
- activeKind,
- activeGroup,
- optGroup,
- runKind,
- fallbackKind,
- videoOutputs,
- upscaleOutputs,
- resizeOutputs,
- sourceExt,
- srcExts,
- runCount,
- onSet,
- onRun
-}: {
- /** The rail tab this workspace belongs to. */
- tab: TabId
- /** The engine tool this workspace runs. */
- tool: ToolId
- /** The verb's own name, used by the Run button. */
- label: string
- options: JobOptions
- activeKind: FileKind | null
- /** The selected convert group, or null with nothing selected. */
- activeGroup: string | null
- /** The group the options actually belong to (falls back when nothing is selected). */
- optGroup: string
- runKind: FileKind | null
- /** The kind to show options for when nothing is selected: the category's own. */
- fallbackKind: FileKind
- videoOutputs?: VideoOutputRow[]
- upscaleOutputs?: VideoOutputRow[]
- resizeOutputs?: VideoOutputRow[]
- sourceExt: string | null
- srcExts: string[]
- runCount: number
- onSet: (k: string, v: string | number | boolean) => void
- onRun: () => void
-}): JSX.Element {
- // Generate is the one tab whose options exist without any file selected: its
- // prompt IS the input.
- const showOptions = tool === 'generate' || activeGroup != null
- // On Convert, ONE component owns the target grid for every route, so a .pdf
- // aimed at .cbz still picks its target in the same place a .png does.
- const isConvertTab = tab === 'convert'
- const { rar } = useArchiveStatus()
-
- function renderOptions(): JSX.Element | null {
- if (!showOptions) return null
- // Convert owns its target grid for every route it can take, including the
- // archive ones, so the choice lives in one place whatever the file is.
- if (isConvertTab) {
- return (
-
- )
- }
- switch (tool) {
- case 'compress':
- return (
-
- )
- case 'resize':
- return
- case 'upscale':
- return
- case 'removebg':
- return
- case 'pdf':
- return
- case 'archive':
- return
- case 'generate':
- return
- default:
- return null
- }
- }
- return (
- // Every option control (slider, toggle, run button, selected chip) reads the
- // accent variables, so overriding them here themes the whole panel black in
- // one place, without touching the rail or the coloured switcher above.
- // Three regions: a fixed header (the switcher), a scrolling middle (the
- // option groups), and a PINNED footer holding the primary action - which
- // used to be an ordinary last child of one scroll container and left the
- // viewport entirely at the app's own default window size.
-
- {/* No operation switcher (the rail IS the operation) and no scope banner:
- the Run button already names what it will act on. */}
-
- {/* EXACTLY ONE panel, chosen here. This used to be a list of
- `tool === x &&` lines, and two of them lost their guard in a
- reformat, so the Convert tab rendered its own options AND the
- archive tool's: two Target format grids, two Resolution sliders.
- A single return makes that impossible rather than merely fixed.
-
- Options appear only once something is selected: offering PNG / JPG
- targets with an empty queue invited a choice that could not be
- acted on. Generate is the exception, since its prompt IS the
- input. */}
- {renderOptions()}
-
-
-
-
- {label}
- {/* Name the GROUP, not "files": in a mixed queue "Convert 2 files"
- hides which two. */}
- {tool !== 'generate' && runCount > 0 ? ` ${groupNoun(optGroup, runCount)}` : ''}
-
-
-
- )
-}
diff --git a/src/renderer/src/components/PidUpscale.tsx b/src/renderer/src/components/PidUpscale.tsx
deleted file mode 100644
index faf9c23..0000000
--- a/src/renderer/src/components/PidUpscale.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-import { useCallback, useEffect, useState, type JSX } from 'react'
-
-// The PiD (NVIDIA) upscaler tier is an on-demand engine: a multi-GB download the
-// user opts into, gated behind an NVIDIA GPU. The first-use install card here
-// carries that flow (licence notice + one-click download + progress); the GPU /
-// install-state probe lives beside it in usePidStatus.
-
-/**
- * First-use install for the PiD engine. Shown when PiD is selected but not yet
- * installed. Leads with the licence reality (NVIDIA's weights are non-commercial)
- * because that's the one thing a user can't undo by uninstalling, then a single
- * download button that streams step + percent while it runs.
- */
-export function PidInstallCard({ onInstalled }: { onInstalled: () => void }): JSX.Element {
- const [installing, setInstalling] = useState(false)
- const [progress, setProgress] = useState<{ step: string; pct: number | null } | null>(null)
- const [error, setError] = useState(null)
-
- // Subscribe once; the main process streams 'pid:progress' during install.
- useEffect(() => window.filesmith.onPidProgress(setProgress), [])
-
- const install = useCallback((): void => {
- setInstalling(true)
- setError(null)
- setProgress(null)
- void window.filesmith.pidInstall().then((r) => {
- setInstalling(false)
- if (r.ok) onInstalled()
- else setError(r.error ?? 'Install failed')
- })
- }, [onInstalled])
-
- const pct = progress?.pct ?? null
- return (
-
- {/* Cost and licence, nothing else — both are things the user cannot infer
- and must know BEFORE committing to a multi-GB download. */}
-
- ~6 GB one-time download ·{' '}
- non-commercial use only
-
-
- {installing ? (
-
-
- {pct == null ? (
- // Indeterminate: a step is running with no byte-count (env build, extract).
-
- ) : (
-
- )}
-
-
- {progress?.step ?? 'Preparing…'}
- {pct != null && {pct}% }
-
-
- ) : (
-
- Download PiD (~6 GB)
-
- )}
-
- {error &&
{error}
}
-
- )
-}
diff --git a/src/renderer/src/components/PromptBox.tsx b/src/renderer/src/components/PromptBox.tsx
deleted file mode 100644
index b984f5c..0000000
--- a/src/renderer/src/components/PromptBox.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import type { JSX } from 'react'
-
-// Replaces the drop zone in the Generate workspace: there's no file to drop, so
-// the central target becomes the prompt input. Same compact footprint as the
-// drop box (it does not grow to fill the page).
-export function PromptBox({
- value,
- onChange
-}: {
- value: string
- onChange: (v: string) => void
-}): JSX.Element {
- return (
-
-
- )
-}
diff --git a/src/renderer/src/components/Queue.tsx b/src/renderer/src/components/Queue.tsx
deleted file mode 100644
index c751e9d..0000000
--- a/src/renderer/src/components/Queue.tsx
+++ /dev/null
@@ -1,290 +0,0 @@
-import type { JSX, MouseEvent } from 'react'
-import type { JobOptions, ToolId } from '@shared/types'
-import { formatBytes, formatEta, groupOf, inInput, type QueueItem } from '../state'
-import { groupItemsByGroup } from './queueGroups'
-import { Icon } from './Icon'
-
-const PDF_OP_LABEL: Record = {
- 'extract-text': 'Extract text',
- 'pages-to-images': 'Pages to PNG',
- merge: 'Merge PDFs',
- 'split-range': 'Split pages',
- 'split-pages': 'Burst pages',
- 'extract-images': 'Extract images'
-}
-
-function inputSub(item: QueueItem, tool: ToolId, panelOptions: JobOptions): string {
- const src = item.file.ext.replace('.', '').toUpperCase()
- // Once a row has run, describe what IT did; only a not-yet-run row previews
- // the current panel settings.
- const options = item.runOptions ?? panelOptions
- if (tool === 'convert') {
- const to = String(options.format ?? '.webp')
- .replace('.', '')
- .toUpperCase()
- return `${src} → ${to}`
- }
- if (tool === 'resize') {
- if (options.mode === 'percent') return `Resize ${options.percent}%`
- const w = options.width === '' || options.width == null ? 'auto' : options.width
- const h = options.height === '' || options.height == null ? 'auto' : options.height
- return `Resize ${w}×${h}${options.fit === 'stretch' ? ' stretched' : ''}`
- }
- // Name the operation the row is actually part of - the old fall-through
- // stamped "Compress" on upscale, removebg and every PDF verb.
- if (tool === 'upscale') return `Upscale ${options.upscaleFactor ?? 4}×`
- if (tool === 'removebg') return 'Remove background'
- if (tool === 'pdf') return PDF_OP_LABEL[String(options.op)] ?? 'PDF'
- if (tool === 'compress') return `Compress ${src}`
- return src
-}
-
-function StatusCell({ item }: { item: QueueItem }): JSX.Element | null {
- if (item.status === 'done')
- return (
-
-
-
- )
- if (item.status === 'failed')
- return Failed
- if (item.status === 'canceled')
- return Canceled
- if (item.status === 'running')
- return (
-
-
- {/* One decimal below 10% so a long encode visibly MOVES instead of
- sitting on "0%" for minutes. */}
- {item.hasProgress
- ? `${item.percent < 10 ? item.percent.toFixed(1) : Math.round(item.percent)}%`
- : ''}
-
- )
- return
-}
-
-function ColumnHead({ title }: { title: string }): JSX.Element {
- // shrink-0, never flex-1: inside a vertical Column a growing header would push
- // the cards to the bottom of the column instead of stacking them at the top.
- return (
-
- {title}
-
- )
-}
-
-function Column({
- title,
- children
-}: {
- title: string
- children: JSX.Element[] | JSX.Element
-}): JSX.Element {
- // Each column keeps its header so the area below the drop zone reads as
- // "inputs here, outputs there". That header is the only label: no item count.
- return (
-
- )
-}
-
-function Kebab({ onOpen }: { onOpen: (x: number, y: number) => void }): JSX.Element {
- return (
- {
- e.stopPropagation()
- const r = e.currentTarget.getBoundingClientRect()
- onOpen(r.right, r.bottom + 6)
- }}
- className="no-drag grid h-[26px] w-[26px] shrink-0 place-items-center rounded-lg text-[#9a9aa6] opacity-0 transition hover:bg-[#f0f0f5] hover:text-ink focus-visible:opacity-100 group-hover:opacity-100"
- >
-
-
- )
-}
-
-function InputCard({
- item,
- tool,
- options,
- selected,
- compatible,
- onClick,
- onOpen,
- onMenu,
- onCancel
-}: {
- item: QueueItem
- tool: ToolId
- options: JobOptions
- selected: boolean
- compatible: boolean
- onClick: (e: MouseEvent) => void
- onOpen: () => void
- onMenu: (x: number, y: number) => void
- onCancel: () => void
-}): JSX.Element {
- // Determinate only once the job reports a real percentage — a long encode
- // legitimately sits at 0-1% for a while and must NOT fall back to the fake fill.
- const indeterminate = item.status === 'running' && !item.hasProgress
- return (
- {
- e.preventDefault()
- onMenu(e.clientX, e.clientY)
- }}
- title={compatible ? undefined : 'A different file type than the current selection'}
- className={`group flex cursor-pointer items-center gap-3 rounded-2xl border bg-white p-2.5 shadow-[0_1px_3px_rgba(0,0,0,.05),0_8px_22px_rgba(20,20,40,.05)] transition ${
- selected
- ? 'border-accent ring-2 ring-accent/60'
- : 'border-black/[.07] hover:border-black/[.14]'
- } ${compatible ? '' : 'opacity-40'}`}
- >
-
- {item.thumb ? (
-
- ) : (
-
- {item.file.ext.replace('.', '')}
-
- )}
-
-
-
{item.file.name}
-
- {item.status === 'failed' && item.error ? (
- // Say WHY it failed. "Failed" alone leaves the user guessing.
- {item.error}
- ) : (
- <>
- {formatBytes(item.file.size)} · {' '}
- {item.status === 'running' && item.etaSec != null && formatEta(item.etaSec)
- ? formatEta(item.etaSec)
- : inputSub(item, tool, options)}
- >
- )}
-
- {item.status === 'running' && (
-
- )}
-
-
-
- {/* An in-flight row is stoppable right where its status shows. */}
- {item.status === 'running' || item.status === 'queued' ? (
- {
- e.stopPropagation()
- onCancel()
- }}
- className="no-drag grid h-[26px] w-[26px] shrink-0 place-items-center rounded-lg text-[#9a9aa6] transition hover:bg-[#f0f0f5] hover:text-[#e0483d]"
- >
-
-
- ) : (
-
- )}
-
-
- )
-}
-
-/** A hairline header naming one convert group inside a mixed queue. */
-function GroupHead({ label, count }: { label: string; count: string }): JSX.Element {
- return (
-
- {label}
- {count}
-
-
- )
-}
-
-export function Queues({
- items,
- tool,
- options,
- selected,
- activeGroup,
- onItemClick,
- onOpen,
- onMenu,
- onCancel
-}: {
- items: QueueItem[]
- tool: ToolId
- options: JobOptions
- selected: string[]
- activeGroup: string | null
- onItemClick: (id: string, e: MouseEvent) => void
- onOpen: (side: 'input' | 'output', item: QueueItem) => void
- onMenu: (side: 'input' | 'output', item: QueueItem, x: number, y: number) => void
- onCancel: (id: string) => void
-}): JSX.Element {
- const inputs = items.filter(inInput)
- if (inputs.length === 0) {
- return (
-
-
-
- No items in queue
-
-
- )
- }
- const card = (i: QueueItem): JSX.Element => (
- onItemClick(i.id, e)}
- onOpen={() => onOpen('input', i)}
- onMenu={(x, y) => onMenu('input', i, x, y)}
- onCancel={() => onCancel(i.id)}
- />
- )
- // Headers only when the queue actually holds more than one group, so the
- // everyday single-kind batch looks exactly as it always has.
- const groups = groupItemsByGroup(inputs)
- return (
-
-
- {groups
- ? groups.map((g) => (
-
-
- {g.items.map(card)}
-
- ))
- : inputs.map(card)}
-
-
- )
-}
diff --git a/src/renderer/src/components/TabRail.tsx b/src/renderer/src/components/TabRail.tsx
deleted file mode 100644
index 87c047c..0000000
--- a/src/renderer/src/components/TabRail.tsx
+++ /dev/null
@@ -1,285 +0,0 @@
-import { useEffect, useRef, useState, type JSX } from 'react'
-import { COMPLETED_TAB, TABS, type TabId } from '@shared/tabs'
-import { Icon, type IconName } from './Icon'
-
-const ALL_IDS = TABS.map((c) => c.id)
-const byId = (id: TabId): (typeof TABS)[number] => TABS.find((c) => c.id === id) ?? TABS[0]
-
-// The rail's layout is a user preference (order + which verbs are hidden), so it
-// lives in localStorage rather than app state: it outlives a session and never
-// needs to reach the engine. The keys are tab-specific: a stored CATEGORY order
-// from the old rail must not half-apply to verbs.
-const ORDER_KEY = 'filesmith.rail.tabOrder'
-const HIDDEN_KEY = 'filesmith.rail.tabHidden'
-
-function loadOrder(): TabId[] {
- try {
- const saved = JSON.parse(localStorage.getItem(ORDER_KEY) ?? '[]') as TabId[]
- const kept = saved.filter((id) => ALL_IDS.includes(id))
- return [...kept, ...ALL_IDS.filter((id) => !kept.includes(id))]
- } catch {
- return ALL_IDS
- }
-}
-function loadHidden(): Set {
- try {
- return new Set((JSON.parse(localStorage.getItem(HIDDEN_KEY) ?? '[]') as TabId[]) ?? [])
- } catch {
- return new Set()
- }
-}
-
-const clamp = (n: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, n))
-
-/**
- * The verb rail, with an iOS-style edit mode: a pencil reveals a drag grip and a
- * visibility checkmark on each row. The rail names what you want DONE; the file
- * kind is a property of what you dropped, not a place you navigate to.
- *
- * The reorder is a hand-rolled pointer sortable rather than native HTML5 drag,
- * which is janky (no live gap, a ghost image, no control over motion). Here the
- * grabbed row lifts and follows the pointer while the others slide out of its
- * way with a transition, and the new order commits on release. The maths keys
- * off the measured row pitch so it stays correct regardless of spacing.
- */
-export function TabRail({
- tab,
- counts,
- completedCount,
- onSelect
-}: {
- tab: TabId
- counts: Record
- /** Produced files across every workspace. */
- completedCount: number
- onSelect: (c: TabId) => void
-}): JSX.Element {
- const [order, setOrder] = useState(loadOrder)
- const [hidden, setHidden] = useState>(loadHidden)
- const [editing, setEditing] = useState(false)
-
- // Live drag state. `pitch` is the row-to-row distance in px, measured on grab.
- // Mirrored into a ref so the pointerup handler reads the final offset without a
- // stale closure, and so committing the move never happens inside a state
- // updater (which StrictMode double-invokes, applying the move twice).
- type Drag = { index: number; offset: number; pitch: number }
- const [drag, setDrag] = useState(null)
- const dragRef = useRef(null)
- const rowEls = useRef<(HTMLButtonElement | null)[]>([])
- const startY = useRef(0)
-
- useEffect(() => localStorage.setItem(ORDER_KEY, JSON.stringify(order)), [order])
- useEffect(() => localStorage.setItem(HIDDEN_KEY, JSON.stringify([...hidden])), [hidden])
-
- const rows = editing ? order : order.filter((id) => !hidden.has(id))
-
- function toggleHidden(id: TabId): void {
- setHidden((prev) => {
- const next = new Set(prev)
- if (next.has(id)) next.delete(id)
- else next.add(id)
- return next
- })
- }
-
- function stopEditing(): void {
- setEditing(false)
- if (hidden.has(tab)) {
- const firstVisible = order.find((id) => !hidden.has(id))
- if (firstVisible) onSelect(firstVisible)
- }
- }
-
- // --- pointer sortable ------------------------------------------------------
- function beginDrag(index: number, e: React.PointerEvent): void {
- e.preventDefault()
- startY.current = e.clientY
- // Measure the actual pitch from the first two rows; fall back to one row's
- // height if there's only one.
- const a = rowEls.current[0]?.getBoundingClientRect()
- const b = rowEls.current[1]?.getBoundingClientRect()
- const pitch = a && b ? b.top - a.top : (rowEls.current[index]?.offsetHeight ?? 44) + 4
- const init = { index, offset: 0, pitch }
- dragRef.current = init
- setDrag(init)
-
- const move = (ev: PointerEvent): void => {
- const next = { index, offset: ev.clientY - startY.current, pitch }
- dragRef.current = next
- setDrag(next)
- }
- const up = (): void => {
- window.removeEventListener('pointermove', move)
- window.removeEventListener('pointerup', up)
- const d = dragRef.current
- dragRef.current = null
- setDrag(null)
- if (!d) return
- const target = clamp(d.index + Math.round(d.offset / d.pitch), 0, order.length - 1)
- if (target === d.index) return
- setOrder((prev) => {
- const nextOrder = [...prev]
- const [moved] = nextOrder.splice(d.index, 1)
- nextOrder.splice(target, 0, moved)
- return nextOrder
- })
- }
- window.addEventListener('pointermove', move)
- window.addEventListener('pointerup', up)
- }
-
- // The slot the grabbed row currently hovers over.
- const target =
- drag == null
- ? null
- : clamp(drag.index + Math.round(drag.offset / drag.pitch), 0, rows.length - 1)
-
- /** Vertical shift for the row at `i` while a drag is in progress. */
- function shiftFor(i: number): { ty: number; lifted: boolean; animate: boolean } {
- if (drag == null || target == null) return { ty: 0, lifted: false, animate: false }
- if (i === drag.index) return { ty: drag.offset, lifted: true, animate: false }
- // Rows between the source and the target slide one pitch to fill the gap.
- if (drag.index < target && i > drag.index && i <= target)
- return { ty: -drag.pitch, lifted: false, animate: true }
- if (drag.index > target && i < drag.index && i >= target)
- return { ty: drag.pitch, lifted: false, animate: true }
- return { ty: 0, lifted: false, animate: true }
- }
-
- return (
-
-
-
- Do
-
- (editing ? stopEditing() : setEditing(true))}
- title={editing ? 'Done' : 'Edit'}
- className={`grid h-7 w-7 place-items-center rounded-lg transition ${
- editing ? 'bg-accent text-white' : 'text-dim hover:bg-black/[.05] hover:text-ink'
- }`}
- >
- {editing ? (
- OK
- ) : (
-
- )}
-
-
-
- {rows.map((id, i) => {
- const c = byId(id)
- const active = c.id === tab && !editing
- const isHidden = hidden.has(id)
- const n = counts[c.id] ?? 0
-
- if (editing) {
- const { ty, lifted, animate } = shiftFor(i)
- return (
- {
- rowEls.current[i] = el
- }}
- onClick={() => toggleHidden(id)}
- style={{
- transform: `translateY(${ty}px)`,
- transition: animate ? 'transform .2s cubic-bezier(.2,0,0,1)' : 'none',
- zIndex: lifted ? 30 : 1,
- touchAction: 'none'
- }}
- className={`relative flex h-11 items-center gap-2.5 rounded-xl px-1.5 text-left text-[13.5px] font-medium ${
- lifted ? 'bg-white shadow-[0_10px_24px_rgba(20,20,45,.16)] scale-[1.02]' : ''
- } ${isHidden ? 'opacity-45' : 'text-[#33333a]'}`}
- >
- {/* Grip: the drag handle. Its own pointer handler starts the drag;
- stopping click propagation keeps a tap on it from toggling. */}
- beginDrag(i, e)}
- onClick={(e) => e.stopPropagation()}
- className="grid h-8 w-6 shrink-0 cursor-grab touch-none place-items-center text-[#b4b4bd] active:cursor-grabbing"
- >
-
-
-
-
-
- {c.label}
-
- {!isHidden && }
-
-
- )
- }
-
- return (
- onSelect(c.id)}
- className={`flex items-center gap-3 rounded-xl px-2.5 py-2 text-left text-[13.5px] font-medium transition ${
- active
- ? 'bg-accent text-white shadow-[0_6px_16px_rgba(91,91,214,.28)]'
- : 'text-[#33333a] hover:bg-black/[.04]'
- }`}
- >
-
-
-
- {c.label}
- {n > 0 && (
-
- {n}
-
- )}
-
- )
- })}
- {/* Pinned to the bottom, below everything the user can reorder: results
- are where you END up, not something you set out to do. */}
- onSelect('completed')}
- aria-current={tab === 'completed' ? 'true' : undefined}
- className={`mt-auto flex items-center gap-2.5 rounded-[13px] px-2.5 py-2 text-left transition ${
- tab === 'completed' ? 'bg-[#22b364]' : 'hover:bg-black/[.035]'
- }`}
- >
-
-
-
-
- Completed
-
- {completedCount > 0 && (
-
- {completedCount}
-
- )}
-
-
- )
-}
diff --git a/src/renderer/src/components/ToolsGrid.tsx b/src/renderer/src/components/ToolsGrid.tsx
deleted file mode 100644
index 403d8a9..0000000
--- a/src/renderer/src/components/ToolsGrid.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import type { JSX } from 'react'
-import { toolGroups } from '@shared/tabs'
-import { Icon, type IconName } from './Icon'
-
-/**
- * The Tools tab's chooser: the long tail of one-off verbs, grouped by what they
- * act on so the section headings do the finding rather than a flat wall of
- * cards. Picking one turns the workspace into an ordinary queue with that tool
- * as its title.
- */
-export function ToolsGrid({ onPick }: { onPick: (id: string) => void }): JSX.Element {
- return (
-
- {toolGroups().map((g) => (
-
-
- {g.name}
-
-
- {g.cards.map((c) => (
-
onPick(c.id)}
- className="rounded-2xl border border-black/[.075] bg-white p-[15px] text-left transition hover:-translate-y-px hover:border-accent/40 hover:shadow-[0_6px_16px_rgba(20,20,45,.08)]"
- >
-
-
-
- {c.label}
- {c.desc}
-
- ))}
-
-
- ))}
-
- )
-}
diff --git a/src/renderer/src/components/TopBar.tsx b/src/renderer/src/components/TopBar.tsx
deleted file mode 100644
index 60a0f04..0000000
--- a/src/renderer/src/components/TopBar.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import type { JSX, ReactNode } from 'react'
-import { Icon } from './Icon'
-import fmark from '../assets/fmark.png'
-
-function WinBtn({ children, onClick }: { children: ReactNode; onClick: () => void }): JSX.Element {
- return (
-
- {children}
-
- )
-}
-
-/** Frameless top strip: the drag handle plus the custom window controls, which
- run full-height flush into the top-right corner (the OS rounds the corner). */
-export function TopBar(): JSX.Element {
- const w = window.filesmith
- return (
-
-
-
-
-
-
Filesmith
-
-
- w.minimize()}>
-
-
- w.toggleMaximize()}>
-
-
- w.close()}>
-
-
-
-
- )
-}
diff --git a/src/renderer/src/components/icons/Icon.tsx b/src/renderer/src/components/icons/Icon.tsx
new file mode 100644
index 0000000..b9fd0d0
--- /dev/null
+++ b/src/renderer/src/components/icons/Icon.tsx
@@ -0,0 +1,35 @@
+import type { JSX } from 'react'
+import type { IconName } from '@shared/icons'
+import { ICON_SHAPES } from './shapes'
+
+export function Icon({
+ name,
+ size = 16,
+ strokeWidth,
+ className = ''
+}: {
+ name: IconName
+ size?: 16 | 12 | 10
+ strokeWidth?: number
+ className?: string
+}): JSX.Element {
+ const cls = `i${size === 12 ? ' i12' : size === 10 ? ' i10' : ''}${className ? ' ' + className : ''}`
+ return (
+
+ {ICON_SHAPES[name].map((p, i) =>
+ p[0] === 'path' ? (
+
+ ) : p[0] === 'rect' ? (
+
+ ) : (
+
+ )
+ )}
+
+ )
+}
diff --git a/src/renderer/src/components/icons/shapes.ts b/src/renderer/src/components/icons/shapes.ts
new file mode 100644
index 0000000..d6cea59
--- /dev/null
+++ b/src/renderer/src/components/icons/shapes.ts
@@ -0,0 +1,112 @@
+import type { IconName } from '@shared/icons'
+
+export type Prim =
+ ['path', string] | ['rect', number, number, number, number] | ['circle', number, number, number]
+
+const page = 'M3.5 1.5h6l3 3v10h-9z M9.5 1.5v3h3'
+const box = 'M2 2.5h12v3H2z M3 5.5v8h10v-8'
+
+export const ICON_SHAPES: Record = {
+ convert: [['path', 'M2.5 5.5h10.5M10.5 3l2.5 2.5-2.5 2.5M13.5 10.5H3M5.5 8L3 10.5 5.5 13']],
+ compress: [['path', 'M8 1.5V6M5.5 3.5L8 6l2.5-2.5M8 14.5V10M5.5 12.5L8 10l2.5 2.5M2.5 8h11']],
+ resize: [['path', 'M2.5 6V2.5H6M13.5 10v3.5H10M3 3l10 10']],
+ upscale: [
+ ['rect', 2.5, 8.5, 5, 5],
+ ['path', 'M9 2.5h4.5V7M13.5 2.5L8.5 7.5']
+ ],
+ removebg: [['path', 'M9.5 2.5l4 4-6 6H4.5l-2-2 7-8zM6 6l4 4M8 13.5h5.5']],
+ generate: [
+ ['path', 'M7 2l1.2 3.3L11.5 6.5 8.2 7.7 7 11 5.8 7.7 2.5 6.5l3.3-1.2zM12.5 10v4M10.5 12h4']
+ ],
+ tools: [
+ ['rect', 2.5, 2.5, 4.5, 4.5],
+ ['rect', 9, 2.5, 4.5, 4.5],
+ ['rect', 2.5, 9, 4.5, 4.5],
+ ['rect', 9, 9, 4.5, 4.5]
+ ],
+ completed: [
+ ['circle', 8, 8, 5.5],
+ ['path', 'M5.5 8.25l1.75 1.75L10.75 6.5']
+ ],
+ settings: [
+ ['path', 'M2.5 5h5M10.5 5h3M2.5 11h2M7.5 11h6'],
+ ['circle', 9, 5, 1.5],
+ ['circle', 6, 11, 1.5]
+ ],
+ addfile: [['path', 'M3.5 1.5h6l3 3v10h-9z M9.5 1.5v3h3M8 7v5M5.5 9.5h5']],
+ folder: [['path', 'M1.5 3.5h4.5l1.5 1.5h7v8h-13z']],
+ play: [['path', 'M5 3.5v9l7.5-4.5z']],
+ stop: [['rect', 4, 4, 8, 8]],
+ retry: [['path', 'M3 3v3h3M3.3 6A5 5 0 1 1 3 9.5']],
+ close: [['path', 'M4 4l8 8M12 4l-8 8']],
+ check: [['path', 'M3 8.5l3 3 7-7']],
+ warning: [['path', 'M8 2l6.5 11.5h-13zM8 6.5v3M8 11.5v.5']],
+ clock: [
+ ['circle', 8, 8, 5.5],
+ ['path', 'M8 5v3l2 1.5']
+ ],
+ sync: [['path', 'M13.5 8A5.5 5.5 0 1 1 8 2.5']],
+ 'chev-r': [['path', 'M6 4l4 4-4 4']],
+ 'chev-d': [['path', 'M4 6l4 4 4-4']],
+ cleardone: [['path', 'M2 4.5l1.25 1.25L5.5 3.5M2 10.5l1.25 1.25L5.5 9.5M7.5 4.5H14M7.5 10.5H14']],
+ trash: [['path', 'M3 4.5h10M6 4.5V2.5h4v2M4.5 4.5l.5 9h6l.5-9']],
+ eye: [
+ ['path', 'M1.5 8s2.5-4.5 6.5-4.5S14.5 8 14.5 8s-2.5 4.5-6.5 4.5S1.5 8 1.5 8z'],
+ ['circle', 8, 8, 2]
+ ],
+ info: [
+ ['circle', 8, 8, 5.5],
+ ['path', 'M8 7.5v3.5M8 5v.5']
+ ],
+ arrow: [['path', 'M3 8h10M10 5l3 3-3 3']],
+ sidebar: [
+ ['rect', 2, 2.5, 12, 11],
+ ['path', 'M6 2.5v11M10.5 6.5L9 8l1.5 1.5']
+ ],
+ anvil: [['path', 'M1.5 4.5h10c0 2 1.5 3 3 3v1h-5l-1 2h2v2h-6v-2h2l-1-2c-2.5 0-4-1.5-4-4z']],
+ image: [
+ ['rect', 2.5, 2.5, 11, 11],
+ ['path', 'M2.5 11l3.5-3.5 3 3 2-2 2.5 2.5'],
+ ['circle', 10.5, 5.5, 1]
+ ],
+ pdf: [['path', `${page}M5.5 9h5M5.5 11.5h3`]],
+ text: [['path', 'M3 4h10M3 7h10M3 10h10M3 13h6']],
+ merge: [['path', 'M2.5 3l5.5 5M13.5 3L8 8v6M5.5 11.5L8 14l2.5-2.5']],
+ split: [['path', 'M8 2v6l-5.5 5.5M8 8l5.5 5.5M5.5 4.5L8 2l2.5 2.5']],
+ burst: [
+ ['rect', 2.5, 3.5, 5, 6],
+ ['rect', 8.5, 6.5, 5, 6]
+ ],
+ pull: [['path', 'M2.5 6.5v7h11v-7M8 2v8M5.5 7.5L8 10l2.5-2.5']],
+ archive: [['path', `${box}M6.5 8.5h3`]],
+ video: [
+ ['rect', 1.5, 3.5, 9, 9],
+ ['path', 'M10.5 7l4-2.5v7l-4-2.5']
+ ],
+ audio: [
+ ['path', 'M6 12V3.5l7.5-1.5V10.5'],
+ ['circle', 4.5, 12, 1.5],
+ ['circle', 12, 10.5, 1.5]
+ ],
+ doc: [['path', `${page}M5.5 8h5M5.5 10.5h5M5.5 13h3`]],
+ unpack: [['path', `${box}M8 7v5M5.5 9.5L8 12l2.5-2.5`]],
+ topdf: [['path', 'M5.5 1.5h5l3 3v10h-8v-4M10.5 1.5v3h3M1.5 8h6M5 5.5L7.5 8 5 10.5']],
+ tocbz: [
+ ['rect', 6.5, 6.5, 8, 7],
+ ['path', 'M1.5 4h7M6 1.5L8.5 4 6 6.5']
+ ],
+ edit: [['path', 'M10.5 2.5l3 3-8 8h-3v-3zM9 4l3 3']],
+ grip: [
+ ['circle', 6, 4, 0.75],
+ ['circle', 10, 4, 0.75],
+ ['circle', 6, 8, 0.75],
+ ['circle', 10, 8, 0.75],
+ ['circle', 6, 12, 0.75],
+ ['circle', 10, 12, 0.75]
+ ],
+ dots: [
+ ['circle', 8, 3.5, 0.75],
+ ['circle', 8, 8, 0.75],
+ ['circle', 8, 12.5, 0.75]
+ ]
+}
diff --git a/src/renderer/src/components/inspector/InfoPane.tsx b/src/renderer/src/components/inspector/InfoPane.tsx
new file mode 100644
index 0000000..efe50c3
--- /dev/null
+++ b/src/renderer/src/components/inspector/InfoPane.tsx
@@ -0,0 +1,44 @@
+import type { JSX } from 'react'
+import type { QueueItem } from '../../state'
+import { SmallButton } from '../ui/Button'
+import { infoRows, type InfoRow } from './infoModel'
+
+/** The k/v grid itself, shared by queue files and generated images. */
+export function InfoGrid({ rows }: { rows: InfoRow[] }): JSX.Element {
+ return (
+
+ {rows.map((r) => (
+
+
{r.k}
+
+ {r.v}
+ {r.reveal && (
+ window.filesmith.reveal(r.reveal as string)}
+ >
+ Show
+
+ )}
+
+
+ ))}
+
+ )
+}
+
+export function InfoPane({
+ item,
+ dims,
+ target
+}: {
+ item: QueueItem
+ dims: { width: number; height: number } | null
+ target: string | null
+}): JSX.Element {
+ return
+}
diff --git a/src/renderer/src/components/inspector/Inspector.tsx b/src/renderer/src/components/inspector/Inspector.tsx
new file mode 100644
index 0000000..09f3d98
--- /dev/null
+++ b/src/renderer/src/components/inspector/Inspector.tsx
@@ -0,0 +1,76 @@
+import type { JSX, ReactNode } from 'react'
+import { PrimaryButton } from '../ui/Button'
+import { Tabs } from '../ui/Tabs'
+
+export type InspTab = 'options' | 'preview' | 'info'
+
+/** The right column (spec 3.4): tabs, a scrolling body with the head, and a
+ * pinned footer holding Run. */
+export function Inspector({
+ tab,
+ onTab,
+ title,
+ sub,
+ runLabel,
+ runDisabled,
+ onRun,
+ stopping = false,
+ onStop,
+ children
+}: {
+ tab: InspTab
+ onTab: (t: InspTab) => void
+ title: string
+ sub: string
+ runLabel: string
+ runDisabled: boolean
+ onRun: () => void
+ /** While work runs, Run becomes Stop (owner, 2026-10-04). */
+ stopping?: boolean
+ onStop?: () => void
+ children: ReactNode
+}): JSX.Element {
+ return (
+
+
+
+
+
{title}
+ {sub}
+
+ {children}
+
+
+ {stopping ? (
+
+ Stop
+
+ ) : (
+
+ {runLabel}
+
+ )}
+
+
+ )
+}
diff --git a/src/renderer/src/components/inspector/PreviewPane.tsx b/src/renderer/src/components/inspector/PreviewPane.tsx
new file mode 100644
index 0000000..9cf04bd
--- /dev/null
+++ b/src/renderer/src/components/inspector/PreviewPane.tsx
@@ -0,0 +1,54 @@
+import { useEffect, useState, type JSX } from 'react'
+import type { FileKind } from '@shared/types'
+import { formatBytes } from '@shared/compress'
+import type { QueueItem } from '../../state'
+import { previewRows } from './infoModel'
+import { Wipe } from './Wipe'
+
+const ext = (p: string): string => (p.split('.').pop() ?? '').toLowerCase()
+
+/** Keyed by item id in the parent, so its fetched images never leak between rows. */
+export function PreviewPane({
+ item,
+ outKind,
+ dims
+}: {
+ item: QueueItem
+ outKind: FileKind | null
+ dims: { width: number; height: number } | null
+}): JSX.Element {
+ const [srcImg, setSrcImg] = useState(item.thumb)
+ const [outImg, setOutImg] = useState(null)
+ const out = item.status === 'done' && item.outputSize != null ? item.outputPath : undefined
+ useEffect(() => {
+ let alive = true
+ void window.filesmith
+ .thumbnail(item.file.path, 640, item.file.kind)
+ .then((t) => alive && t && setSrcImg(t))
+ if (out && outKind)
+ void window.filesmith.thumbnail(out, 640, outKind).then((t) => alive && setOutImg(t))
+ return () => {
+ alive = false
+ }
+ }, [item.file.path, item.file.kind, out, outKind])
+ return (
+ <>
+
+
+ {previewRows(item, dims).map((r) => (
+
+
{r.k}
+ {r.v}
+
+ ))}
+
+ >
+ )
+}
diff --git a/src/renderer/src/components/inspector/Wipe.tsx b/src/renderer/src/components/inspector/Wipe.tsx
new file mode 100644
index 0000000..2970a9b
--- /dev/null
+++ b/src/renderer/src/components/inspector/Wipe.tsx
@@ -0,0 +1,66 @@
+import { useRef, useState, type JSX } from 'react'
+import { wipeStep } from './infoModel'
+
+/** Source left, result right, a 1px fg1 divider you can drag (spec 4.6, O9). */
+export function Wipe({
+ left,
+ right,
+ leftTag,
+ rightTag
+}: {
+ left: string | null
+ right: string | null
+ leftTag: string
+ rightTag?: string
+}): JSX.Element {
+ const [pos, setPos] = useState(50)
+ const frame = useRef(null)
+ function fromPointer(clientX: number): void {
+ const r = frame.current?.getBoundingClientRect()
+ if (r) setPos(Math.max(0, Math.min(100, ((clientX - r.left) / r.width) * 100)))
+ }
+ const split = right != null
+ return (
+ {
+ if (!split) return
+ e.currentTarget.setPointerCapture(e.pointerId)
+ fromPointer(e.clientX)
+ }}
+ onPointerMove={(e) => {
+ if (split && e.buttons === 1) fromPointer(e.clientX)
+ }}
+ >
+ {left ? (
+
+ ) : (
+
{leftTag.split(' ')[0]}
+ )}
+ {split && right && (
+
+ )}
+ {split && (
+
{
+ const n = wipeStep(e.key, pos)
+ if (n == null) return
+ e.preventDefault()
+ setPos(n)
+ }}
+ />
+ )}
+ {leftTag}
+ {rightTag && {rightTag} }
+
+ )
+}
diff --git a/src/renderer/src/components/inspector/infoModel.ts b/src/renderer/src/components/inspector/infoModel.ts
new file mode 100644
index 0000000..c520193
--- /dev/null
+++ b/src/renderer/src/components/inspector/infoModel.ts
@@ -0,0 +1,86 @@
+import { formatBytes } from '@shared/compress'
+import type { QueueItem } from '../../state'
+import { formatPct, pctChange } from '../queue/rowModel'
+
+export interface InfoRow {
+ k: string
+ v: string
+ reveal?: string
+ selectable?: boolean
+}
+
+type Dims = { width: number; height: number } | null
+
+const extOf = (p: string): string => {
+ const b = p.split(/[\\/]/).pop() ?? p
+ const i = b.lastIndexOf('.')
+ return i > 0 ? b.slice(i + 1).toLowerCase() : ''
+}
+const pixels = (d: Dims): string => (d ? `${d.width} × ${d.height}` : 'unknown')
+
+export function previewRows(item: QueueItem, dims: Dims): InfoRow[] {
+ const src = item.file.size
+ const out = item.status === 'done' ? item.outputSize : undefined
+ const rows: InfoRow[] = [
+ { k: 'size', v: out != null ? `${formatBytes(src)} to ${formatBytes(out)}` : formatBytes(src) }
+ ]
+ if (out != null) {
+ const pct = pctChange(src, out)
+ rows.push({
+ k: 'saved',
+ v: `${formatBytes(Math.max(0, src - out))}${pct != null ? `, ${formatPct(pct)}` : ''}`
+ })
+ }
+ rows.push({ k: 'pixels', v: pixels(dims) })
+ return rows
+}
+
+export function infoRows(item: QueueItem, dims: Dims, target: string | null): InfoRow[] {
+ const src = item.file.ext.replace(/^\./, '').toLowerCase()
+ const to =
+ item.status === 'done' && item.outputPath
+ ? extOf(item.outputPath)
+ : target
+ ? target.replace(/^\./, '').toLowerCase()
+ : ''
+ const rows: InfoRow[] = [
+ { k: 'format', v: to && to !== src ? `${src} to ${to}` : src },
+ { k: 'pixels', v: pixels(dims) },
+ {
+ k: 'size',
+ v:
+ item.status === 'done' && item.outputSize != null
+ ? `${formatBytes(item.file.size)} to ${formatBytes(item.outputSize)}`
+ : formatBytes(item.file.size)
+ }
+ ]
+ if (item.status === 'done' && item.outputPath)
+ rows.push({ k: 'output', v: item.outputPath, reveal: item.outputPath })
+ if (item.status === 'failed' && item.error)
+ rows.push({ k: 'error', v: item.error, selectable: true })
+ return rows
+}
+
+/** Info rows for a generated image; `meta` is null for one from an earlier session. */
+export function genInfoRows(
+ path: string,
+ meta: { model: string; width: number; height: number; seed: number } | null
+): InfoRow[] {
+ const rows: InfoRow[] = []
+ if (meta) {
+ rows.push({ k: 'model', v: meta.model })
+ rows.push({ k: 'size', v: `${meta.width} × ${meta.height}` })
+ rows.push({ k: 'seed', v: meta.seed < 0 ? 'random' : String(meta.seed) })
+ }
+ rows.push({ k: 'path', v: path, reveal: path })
+ return rows
+}
+
+export function wipeStep(key: string, pct: number): number | null {
+ const clamp = (n: number): number => Math.max(0, Math.min(100, n))
+ if (key === 'ArrowLeft') return clamp(pct - 5)
+ if (key === 'ArrowRight') return clamp(pct + 5)
+ if (key === 'Home') return 0
+ if (key === 'End') return 100
+ return null
+}
diff --git a/src/renderer/src/components/options/ArchiveSettings.tsx b/src/renderer/src/components/options/ArchiveSettings.tsx
new file mode 100644
index 0000000..03866b9
--- /dev/null
+++ b/src/renderer/src/components/options/ArchiveSettings.tsx
@@ -0,0 +1,90 @@
+import type { JSX } from 'react'
+import type { JobOptions } from '@shared/types'
+import { ARCHIVE_FORMATS, COMIC_FORMATS, needsRar } from '@shared/archive'
+import { ChipGrid } from '../ui/ChipGrid'
+import { Segmented } from '../ui/Segmented'
+import { Setting, SettingGroup } from '../ui/Setting'
+import { PageRenderSettings } from './PageRenderSettings'
+import type { SetOption } from './types'
+
+/** Archive Tools cards (only reachable when such a card exists). from-pdf reuses
+ * PageRenderSettings, which fixes the old bug where this panel wrote `quality`
+ * while the engine reads `pageQuality`. */
+export function ArchiveSettings({
+ options,
+ srcExts,
+ hasRar,
+ set
+}: {
+ options: JobOptions
+ srcExts: string[]
+ hasRar: boolean
+ set: SetOption
+}): JSX.Element {
+ const op = String(options.op ?? 'repack')
+ const targets = (list: typeof ARCHIVE_FORMATS): JSX.Element => (
+
+ {
+ // .zip and .cbz are the same container but not the same file: compare by extension.
+ const isSource = srcExts.includes(f.ext)
+ const noRar = needsRar(f.ext) && !hasRar
+ return {
+ value: f.ext,
+ label: f.label.toLowerCase(),
+ disabled: isSource || noRar,
+ title: noRar
+ ? 'WinRAR not found'
+ : isSource
+ ? 'Files are already this format'
+ : undefined
+ }
+ })}
+ onChange={(v) => set('format', v)}
+ />
+
+ )
+ if (op === 'extract')
+ return (
+
+
+
+ )
+ if (op === 'to-pdf')
+ return (
+
+
+
+ )
+ if (op === 'from-pdf')
+ return (
+ <>
+
{targets(COMIC_FORMATS)}
+
+ >
+ )
+ return (
+
+ {targets(ARCHIVE_FORMATS)}
+
+ set('store', v === 'store')}
+ />
+
+
+ )
+}
diff --git a/src/renderer/src/components/options/CompressSettings.tsx b/src/renderer/src/components/options/CompressSettings.tsx
new file mode 100644
index 0000000..4b6a7c9
--- /dev/null
+++ b/src/renderer/src/components/options/CompressSettings.tsx
@@ -0,0 +1,132 @@
+import type { JSX } from 'react'
+import type { FileKind, JobOptions } from '@shared/types'
+import {
+ AUDIO_BITRATES,
+ AUDIO_CODECS,
+ IMAGE_FORMATS,
+ PDF_LEVELS,
+ SCALE_MAX,
+ SCALE_MIN,
+ SCALE_STEP,
+ VIDEO_CODECS
+} from '@shared/compress'
+import { ChipGrid } from '../ui/ChipGrid'
+import { OutputSizeList, type SizeRow } from '../ui/OutputSizeList'
+import { RangeField } from '../ui/RangeField'
+import { Select } from '../ui/Select'
+import { CheckSetting, Setting, SettingGroup } from '../ui/Setting'
+import type { SetOption } from './types'
+
+const quality = (options: JobOptions, set: SetOption): JSX.Element => (
+
+ set('quality', v)}
+ ends={['smaller file', 'higher quality']}
+ />
+
+)
+
+export function CompressSettings({
+ options,
+ kind,
+ videoOutputs,
+ set
+}: {
+ options: JobOptions
+ kind: FileKind
+ videoOutputs: SizeRow[]
+ set: SetOption
+}): JSX.Element {
+ if (kind === 'pdf')
+ return (
+
+
+ set('pdfLevel', v)}
+ />
+
+
+ set('pdfGray', v)}
+ label="Convert to greyscale"
+ sub="Smaller, but every page loses its colour"
+ />
+
+
+ )
+ if (kind === 'video') {
+ const scale = Number(options.scale ?? 100)
+ return (
+
+
+ set('videoCodec', v)}
+ />
+
+
+ set('scale', v)}
+ format={(v) => (v === 100 ? 'original' : `${v}%`)}
+ ends={[`${SCALE_MIN}%`, 'original']}
+ />
+ {scale < 100 && }
+
+ {quality(options, set)}
+
+ )
+ }
+ if (kind === 'audio')
+ return (
+
+
+ set('audioCodec', v)}
+ />
+
+
+ ({ value: b, label: `${b}k` }))}
+ onChange={(v) => set('audioBitrate', v)}
+ />
+
+
+ )
+ return (
+
+
+ set('imageFormat', v)}
+ />
+
+ {quality(options, set)}
+
+ )
+}
diff --git a/src/renderer/src/components/options/ConvertSettings.tsx b/src/renderer/src/components/options/ConvertSettings.tsx
new file mode 100644
index 0000000..d9c4d22
--- /dev/null
+++ b/src/renderer/src/components/options/ConvertSettings.tsx
@@ -0,0 +1,94 @@
+import type { JSX } from 'react'
+import type { FileKind, JobOptions } from '@shared/types'
+import { familyFormats, isSameFormat, sharedTargets } from '@shared/convert'
+import { needsRar } from '@shared/archive'
+import { Select } from '../ui/Select'
+import type { SelectOption } from '../ui/selectNav'
+import { Segmented } from '../ui/Segmented'
+import { Setting, SettingGroup } from '../ui/Setting'
+import { OutputSettings } from './OutputSettings'
+import { PageRenderSettings } from './PageRenderSettings'
+import type { SetOption } from './types'
+
+export function ConvertSettings({
+ options,
+ kind,
+ sourceExt,
+ srcExts,
+ verb,
+ hasRar,
+ set
+}: {
+ options: JobOptions
+ kind: FileKind
+ sourceExt: string | null
+ srcExts: string[]
+ /** The archive verb this target resolves to, if any: repack / to-pdf / from-pdf. */
+ verb?: string
+ hasRar: boolean
+ set: SetOption
+}): JSX.Element {
+ // Targets valid for EVERY selected source, so a mixed pdf+docx selection never
+ // offers CBZ (which only the pdf could do).
+ const formats = srcExts.length
+ ? sharedTargets(kind, srcExts)
+ : familyFormats(kind, sourceExt ?? '')
+ const choices: SelectOption
[] = formats.map((f) => {
+ // Grey out (and block) any format a selected source already is: with a
+ // PNG + JPEG selection, neither PNG nor JPEG is a valid target.
+ const isSource = srcExts.some((e) => isSameFormat(f.ext, e))
+ const noRar = needsRar(f.ext) && !hasRar
+ return {
+ value: f.ext,
+ label: f.label.toLowerCase(),
+ disabled: isSource || noRar,
+ reason: noRar ? 'WinRAR not found' : isSource ? 'already this format' : undefined
+ }
+ })
+ return (
+ <>
+
+
+ set('format', v)}
+ />
+
+ {kind === 'image' && (
+
+ set('quality', v)}
+ />
+
+ )}
+ {verb === 'repack' && (
+
+ set('store', v === 'store')}
+ />
+
+ )}
+
+ {verb === 'from-pdf' && }
+ {!verb && }
+ >
+ )
+}
diff --git a/src/renderer/src/components/options/OptionsPane.tsx b/src/renderer/src/components/options/OptionsPane.tsx
new file mode 100644
index 0000000..4695725
--- /dev/null
+++ b/src/renderer/src/components/options/OptionsPane.tsx
@@ -0,0 +1,89 @@
+import type { JSX } from 'react'
+import type { FileKind, JobOptions, ToolId } from '@shared/types'
+import type { TabId } from '@shared/tabs'
+import { EstimateCard } from '../ui/EstimateCard'
+import type { SizeRow } from '../ui/OutputSizeList'
+import { ArchiveSettings } from './ArchiveSettings'
+import { CompressSettings } from './CompressSettings'
+import { ConvertSettings } from './ConvertSettings'
+import { GenerateSettings } from './generate/GenerateSettings'
+import { useArchiveStatus } from './hooks/useArchiveStatus'
+import { PdfSettings } from './PdfSettings'
+import { RemoveBgSettings } from './RemoveBgSettings'
+import { ResizeSettings } from './ResizeSettings'
+import type { SetOption } from './types'
+import { UpscaleSettings } from './UpscaleSettings'
+
+/** EXACTLY ONE settings body per workspace, chosen in one place: a list of
+ * `tool === x &&` lines once lost a guard and rendered two panels at once. */
+export function OptionsPane({
+ tab,
+ tool,
+ options,
+ kind,
+ srcExts,
+ sourceExt,
+ runCount,
+ videoOutputs,
+ resizeOutputs,
+ upscaleOutputs,
+ estimate,
+ set
+}: {
+ tab: TabId
+ tool: ToolId
+ options: JobOptions
+ kind: FileKind
+ srcExts: string[]
+ sourceExt: string | null
+ runCount: number
+ videoOutputs: SizeRow[]
+ resizeOutputs: SizeRow[]
+ upscaleOutputs: SizeRow[]
+ estimate: { from: number; to: number; files: number } | null
+ set: SetOption
+}): JSX.Element {
+ // One status call for the whole pane (it used to be made twice).
+ const { rar } = useArchiveStatus()
+ function body(): JSX.Element | null {
+ // Convert owns its target choice for every route, including archive ones.
+ if (tab === 'convert')
+ return (
+
+ )
+ switch (tool) {
+ case 'compress':
+ return (
+
+ )
+ case 'resize':
+ return
+ case 'upscale':
+ return
+ case 'removebg':
+ return
+ case 'pdf':
+ return
+ case 'archive':
+ return
+ case 'generate':
+ return
+ default:
+ return null
+ }
+ }
+ return (
+
+ {body()}
+ {estimate && }
+
+ )
+}
diff --git a/src/renderer/src/components/options/OutputSettings.tsx b/src/renderer/src/components/options/OutputSettings.tsx
new file mode 100644
index 0000000..4d22a48
--- /dev/null
+++ b/src/renderer/src/components/options/OutputSettings.tsx
@@ -0,0 +1,68 @@
+import type { JSX } from 'react'
+import type { JobOptions } from '@shared/types'
+import { SmallButton } from '../ui/Button'
+import { Select } from '../ui/Select'
+import { Setting, SettingGroup } from '../ui/Setting'
+import type { SetOption } from './types'
+
+const baseName = (p: string): string => p.split(/[\\/]/).filter(Boolean).pop() ?? p
+
+/** OUTPUT > Location (engine support: Task 3) and FILES > If file exists, which
+ * is fixed because the never-overwrite rule leaves one possible value (O7). */
+export function OutputSettings({
+ options,
+ set
+}: {
+ options: JobOptions
+ set: SetOption
+}): JSX.Element {
+ const dir = typeof options.outDir === 'string' ? options.outDir : ''
+ async function choose(): Promise {
+ const p = await window.filesmith.pickFolder()
+ if (p) set('outDir', p)
+ }
+ return (
+ <>
+
+ {dir} : 'Files are written next to each source.'}
+ >
+
+ {
+ if (v === 'source') set('outDir', '')
+ }}
+ />
+ void choose()}>
+ Choose folder
+
+
+
+
+
+
+ undefined}
+ />
+
+
+ >
+ )
+}
diff --git a/src/renderer/src/components/options/PageRenderSettings.tsx b/src/renderer/src/components/options/PageRenderSettings.tsx
new file mode 100644
index 0000000..4a91401
--- /dev/null
+++ b/src/renderer/src/components/options/PageRenderSettings.tsx
@@ -0,0 +1,63 @@
+import type { JSX } from 'react'
+import type { JobOptions } from '@shared/types'
+import { RangeField } from '../ui/RangeField'
+import { Segmented } from '../ui/Segmented'
+import { Setting, SettingGroup } from '../ui/Setting'
+import type { SetOption } from './types'
+
+/** Page rendering for every route that turns a PDF into images. Writes
+ * `pageQuality`, never `quality`, so it cannot collide with convert's preset. */
+export function PageRenderSettings({
+ options,
+ set
+}: {
+ options: JobOptions
+ set: SetOption
+}): JSX.Element {
+ const pageFormat = String(options.pageFormat ?? 'jpg')
+ return (
+
+
+ set('dpi', v)}
+ format={(v) => `${v} dpi`}
+ />
+
+
+ set('pageFormat', v)}
+ />
+
+ {pageFormat === 'jpg' && (
+
+ set('pageQuality', v)}
+ ends={['smaller file', 'higher quality']}
+ />
+
+ )}
+
+ )
+}
diff --git a/src/renderer/src/components/options/PdfSettings.tsx b/src/renderer/src/components/options/PdfSettings.tsx
new file mode 100644
index 0000000..8981c2e
--- /dev/null
+++ b/src/renderer/src/components/options/PdfSettings.tsx
@@ -0,0 +1,63 @@
+import type { JSX } from 'react'
+import type { JobOptions } from '@shared/types'
+import { RangeField } from '../ui/RangeField'
+import { Setting, SettingGroup } from '../ui/Setting'
+import { TextField } from '../ui/TextField'
+import type { SetOption } from './types'
+
+/** Each Tools card is one op, so there is no op picker (spec 4.2). */
+export function PdfSettings({
+ options,
+ runCount,
+ set
+}: {
+ options: JobOptions
+ runCount: number
+ set: SetOption
+}): JSX.Element | null {
+ const op = String(options.op ?? 'extract-text')
+ if (op === 'pages-to-images')
+ return (
+
+
+ set('dpi', v)}
+ format={(v) => `${v} dpi`}
+ />
+
+
+ )
+ if (op === 'split-range')
+ return (
+
+
+ Ranges and single pages, for example 1-3,5,8-10.
+ >
+ }
+ >
+ set('range', e.target.value)}
+ />
+
+
+ )
+ // Merge is the one op whose requirement is not visible in the UI (it needs 2+ files).
+ if (op === 'merge' && runCount < 2)
+ return (
+
+
+
+ )
+ return null
+}
diff --git a/src/renderer/src/components/options/RemoveBgSettings.tsx b/src/renderer/src/components/options/RemoveBgSettings.tsx
new file mode 100644
index 0000000..1eb0b9e
--- /dev/null
+++ b/src/renderer/src/components/options/RemoveBgSettings.tsx
@@ -0,0 +1,80 @@
+import { useEffect, useState, type JSX } from 'react'
+import type { JobOptions } from '@shared/types'
+import { BG_DEFAULTS, BG_FILLS, type BgFill } from '@shared/removebg'
+import { SmallButton } from '../ui/Button'
+import { Select } from '../ui/Select'
+import { Setting, SettingGroup } from '../ui/Setting'
+import type { SetOption } from './types'
+
+/** One fill choice plus its custom colour or image. The model and matting
+ * thresholds are deliberately not exposed (see src/shared/removebg.ts). */
+export function RemoveBgSettings({
+ options,
+ set
+}: {
+ options: JobOptions
+ set: SetOption
+}): JSX.Element {
+ const fill = String(options.bgFill ?? BG_DEFAULTS.bgFill) as BgFill
+ const bgImage = String(options.bgImagePath ?? '')
+ const color = String(options.bgCustomColor ?? BG_DEFAULTS.bgCustomColor)
+ // Disclose up front that background removal is AI-powered and, on first use,
+ // downloads a model, so nobody hits a mid-run surprise.
+ const [rembg, setRembg] = useState<{ ready: boolean; uvAvailable: boolean } | null>(null)
+ useEffect(() => {
+ let alive = true
+ void window.filesmith.removebgStatus().then((s) => alive && setRembg(s))
+ return () => {
+ alive = false
+ }
+ }, [])
+ const notice =
+ rembg && !rembg.ready ? (
+ rembg.uvAvailable ? (
+ 'The first run downloads the AI model once (a few hundred MB), then works offline.'
+ ) : (
+ <>
+ Needs the free uv tool: winget install astral-sh.uv, then reopen Filesmith.
+ >
+ )
+ ) : undefined
+ return (
+
+
+ set('bgFill', v)} />
+
+ {fill === 'custom' && (
+
+ {/* The swatch shows user data, the one non-grey value on screen (spec 4.2). */}
+
+
+ {color}
+ set('bgCustomColor', e.target.value)}
+ />
+
+
+ )}
+ {fill === 'image' && (
+ {bgImage.split(/[\\/]/).pop()} : undefined}
+ >
+
+ void window.filesmith.pickImage().then((p) => {
+ if (p) set('bgImagePath', p)
+ })
+ }
+ >
+ Choose image
+
+
+ )}
+
+ )
+}
diff --git a/src/renderer/src/components/options/ResizeSettings.tsx b/src/renderer/src/components/options/ResizeSettings.tsx
new file mode 100644
index 0000000..425363d
--- /dev/null
+++ b/src/renderer/src/components/options/ResizeSettings.tsx
@@ -0,0 +1,84 @@
+import type { JSX } from 'react'
+import type { JobOptions } from '@shared/types'
+import { RESIZE_FITS } from '@shared/resize'
+import { OutputSizeList, type SizeRow } from '../ui/OutputSizeList'
+import { RangeField } from '../ui/RangeField'
+import { Segmented } from '../ui/Segmented'
+import { Setting, SettingGroup } from '../ui/Setting'
+import { NumberField } from '../ui/TextField'
+import type { SetOption } from './types'
+
+const dim = (v: unknown): number | '' =>
+ v == null || v === '' || !Number.isFinite(Number(v)) ? '' : Number(v)
+
+export function ResizeSettings({
+ options,
+ outputs,
+ set
+}: {
+ options: JobOptions
+ outputs: SizeRow[]
+ set: SetOption
+}): JSX.Element {
+ const mode = String(options.mode ?? 'percent')
+ return (
+
+
+ set('mode', v)}
+ />
+
+ {mode === 'percent' ? (
+
+ set('percent', v)}
+ format={(v) => `${v}%`}
+ />
+
+ ) : (
+ <>
+
+
+ set('width', v)}
+ clamp={(n) => Math.max(1, Math.round(n))}
+ />
+ set('height', v)}
+ clamp={(n) => Math.max(1, Math.round(n))}
+ />
+
+
+
+ ({ value: f.value, label: f.label.toLowerCase() }))}
+ onChange={(v) => set('fit', v)}
+ />
+
+
+ >
+ )}
+
+ )
+}
diff --git a/src/renderer/src/components/options/UpscaleSettings.tsx b/src/renderer/src/components/options/UpscaleSettings.tsx
new file mode 100644
index 0000000..2392714
--- /dev/null
+++ b/src/renderer/src/components/options/UpscaleSettings.tsx
@@ -0,0 +1,196 @@
+import { useEffect, useMemo, useState, type JSX } from 'react'
+import type { JobOptions } from '@shared/types'
+import {
+ UPSCALE_COMFY,
+ UPSCALE_FACTORS,
+ UPSCALE_GPU_MODES,
+ UPSCALE_MODELS,
+ type Choice,
+ type UpscaleModel
+} from '@shared/compress'
+import { SmallButton } from '../ui/Button'
+import { ChipGrid } from '../ui/ChipGrid'
+import { OutputSizeList, type SizeRow } from '../ui/OutputSizeList'
+import { Segmented } from '../ui/Segmented'
+import { Select } from '../ui/Select'
+import { Setting, SettingGroup } from '../ui/Setting'
+import { useComfyModels } from './hooks/useComfyModels'
+import { usePidStatus } from './hooks/usePidStatus'
+import { ComfyImportCard } from './upscale/ComfySetup'
+import { PidInstallCard, PidRemoveButton } from './upscale/PidSetup'
+import type { SetOption } from './types'
+
+/**
+ * Image Upscale. Two-level model picker: the Real-ESRGAN models on disk, plus an
+ * "AI models" category (NVIDIA only) holding a second picker of the imported
+ * ComfyUI ESRGAN models and PiD. Stored value is a model id | 'pid' | 'comfy'
+ * (category placeholder) | 'comfy:'.
+ */
+export function UpscaleSettings({
+ options,
+ outputs,
+ set
+}: {
+ options: JobOptions
+ outputs: SizeRow[]
+ set: SetOption
+}): JSX.Element {
+ const factor = Number(options.upscaleFactor ?? 4)
+ const comfy = useComfyModels()
+ const { status: pid, refresh: refreshPid } = usePidStatus()
+ const rawModel = String(options.upscaleModel ?? 'photo')
+ const hasNvidia = Boolean(comfy.status?.nvidia || pid?.nvidia)
+ // Which Real-ESRGAN models exist is read from disk, not frozen at build time.
+ const [ncnn, setNcnn] = useState<{ value: string; label: string; user: boolean }[] | null>(null)
+ useEffect(() => {
+ let alive = true
+ void window.filesmith.upscaleModels().then((m) => alive && setNcnn(m))
+ return () => {
+ alive = false
+ }
+ }, [])
+ const comfyModels = comfy.status?.models
+ // Memoized: the validity effect below depends on this list and calls set().
+ const comfyChoices = useMemo(
+ () =>
+ (comfyModels ?? []).map((m) => ({
+ value: `comfy:${m.path}`,
+ label: `${m.name}, ${m.scale}×${m.badge === 'experimental' ? ', experimental' : ''}`
+ })),
+ [comfyModels]
+ )
+ // Fall back to the legacy Photo/Anime aliases until the disk scan returns, so
+ // the picker is never empty for a frame.
+ const ncnnChoices: Choice[] = ncnn?.length
+ ? ncnn.map((m) => ({
+ value: m.value as UpscaleModel,
+ label: m.user ? `${m.label}, added by you` : m.label
+ }))
+ : UPSCALE_MODELS
+ const isPid = rawModel === 'pid'
+ const isComfyPath = rawModel.startsWith('comfy:')
+ const inAi = isPid || isComfyPath || rawModel === 'comfy'
+ const category = inAi
+ ? 'comfy'
+ : ncnnChoices.some((c) => c.value === rawModel)
+ ? rawModel
+ : (ncnnChoices[0]?.value ?? 'photo')
+ const categoryChoices = [...ncnnChoices, ...(hasNvidia ? [UPSCALE_COMFY] : [])]
+ // PiD is only offered when it is actually attainable: already installed, or
+ // its weights are reusable from the user's ComfyUI.
+ const showPid = hasNvidia && Boolean(pid?.installed || comfy.status?.pidReusable)
+ const subChoices = [
+ ...comfyChoices,
+ ...(showPid ? [{ value: 'pid', label: 'PiD (diffusion), 4×' }] : [])
+ ]
+ const subDefault = comfyChoices[0]?.value ?? (showPid ? 'pid' : 'comfy')
+ const subValue = isPid ? 'pid' : isComfyPath ? rawModel : subDefault
+ const pidNeedsInstall = isPid && pid != null && !pid.installed
+ const vramMb = pid?.nvidia?.vramMb ?? null
+ const lowVram = isPid && vramMb != null && vramMb < 12_000
+ const pickCategory = (v: string): void => set('upscaleModel', v === 'comfy' ? subDefault : v)
+
+ // Keep the stored value valid once both statuses have loaded: an AI choice
+ // with no GPU falls back to Photo; a vanished comfy:, or PiD that is not
+ // showable, falls back to the first available AI model.
+ useEffect(() => {
+ if (pid == null || comfy.status == null) return
+ if (inAi && !hasNvidia) set('upscaleModel', 'photo')
+ else if (isComfyPath && !comfyChoices.some((c) => c.value === rawModel))
+ set('upscaleModel', subDefault)
+ else if (isPid && !showPid) set('upscaleModel', subDefault)
+ }, [
+ pid,
+ comfy.status,
+ hasNvidia,
+ inAi,
+ isComfyPath,
+ isPid,
+ showPid,
+ rawModel,
+ comfyChoices,
+ subDefault,
+ set
+ ])
+ // Why the AI tier is missing, instead of silently hiding it.
+ const gpuReason = !hasNvidia ? (pid?.cudaReason ?? comfy.status?.cudaReason) : undefined
+
+ return (
+ <>
+
+
+ ({ value: f, label: `${f}×` }))}
+ onChange={(v) => set('upscaleFactor', v)}
+ />
+
+
+
+ {category === 'comfy' && subChoices.length > 0 && (
+ set('upscaleModel', v)}
+ />
+ )}
+ {!inAi && (
+ void window.filesmith.upscaleOpenModelsFolder()}
+ >
+ Add your own model
+
+ )}
+
+ {category === 'comfy' && pidNeedsInstall && }
+ {category === 'comfy' && isPid && pid?.installed && (
+
+ )}
+ {category === 'comfy' && comfy.status && (
+
+ )}
+
+ {(!isPid || lowVram) && (
+
+ {/* PiD runs in one pass and cannot be paced, so it gets no control. */}
+ {!isPid && (
+
+ ({
+ value: o.value,
+ label: o.label.toLowerCase()
+ }))}
+ onChange={(v) => set('gpuMode', v)}
+ />
+
+ )}
+ {lowVram && (
+
+ )}
+
+ )}
+ {outputs.length > 0 && (
+
+
+
+
+
+ )}
+ >
+ )
+}
diff --git a/src/renderer/src/components/options/generate/AddModel.tsx b/src/renderer/src/components/options/generate/AddModel.tsx
new file mode 100644
index 0000000..e69f6d8
--- /dev/null
+++ b/src/renderer/src/components/options/generate/AddModel.tsx
@@ -0,0 +1,75 @@
+import { useState, type JSX } from 'react'
+import { Icon } from '../../icons/Icon'
+import { SmallButton } from '../../ui/Button'
+import { Setting } from '../../ui/Setting'
+
+/**
+ * "Add a model": the escape hatch that means a new architecture never requires
+ * an app release. Importing a ComfyUI "Export (API)" workflow lets a user who can
+ * already generate a model inside ComfyUI generate it here. Changing the ComfyUI
+ * folder lives here too, since Generate and Upscale read the same store.
+ */
+export function AddModel({
+ onAdded,
+ comfyFolder
+}: {
+ onAdded: () => void
+ comfyFolder?: string | null
+}): JSX.Element {
+ const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null)
+ const pickComfy = (): void => {
+ setMsg(null)
+ void window.filesmith.comfyPickFolder().then((folder) => {
+ if (!folder) return
+ void window.filesmith.comfySetFolder(folder).then((r) => {
+ if (r.ok) onAdded()
+ else setMsg({ ok: false, text: r.error ?? "Couldn't use that folder" })
+ })
+ })
+ }
+ const importOne = (): void => {
+ setMsg(null)
+ void window.filesmith.registryImport().then((r) => {
+ if (!r.ok) {
+ if (r.error) setMsg({ ok: false, text: r.error })
+ return
+ }
+ setMsg({
+ ok: true,
+ text: [`Added ${r.ids?.join(', ')}.`, ...(r.notes ?? [])].join(' ')
+ })
+ onAdded()
+ })
+ }
+ return (
+ {comfyFolder} : 'Choose your ComfyUI folder.'}
+ >
+
+
+ Add a model
+
+ void window.filesmith.registryOpenFolder()}
+ title="Open the folder where your own model entries live"
+ >
+ Open folder
+
+
+
+ Change ComfyUI folder
+
+ {msg &&
+ (msg.ok ? (
+ {msg.text}
+ ) : (
+
+
+ {msg.text}
+
+ ))}
+
+ )
+}
diff --git a/src/renderer/src/components/options/generate/AdvancedSettings.tsx b/src/renderer/src/components/options/generate/AdvancedSettings.tsx
new file mode 100644
index 0000000..8eb7681
--- /dev/null
+++ b/src/renderer/src/components/options/generate/AdvancedSettings.tsx
@@ -0,0 +1,89 @@
+import { useState, type JSX } from 'react'
+import type { JobOptions } from '@shared/types'
+import type { ArchInfo, GenArch } from '@shared/genArch'
+import { RangeField } from '../../ui/RangeField'
+import { CheckSetting, Setting, SettingGroup } from '../../ui/Setting'
+import { NumberField } from '../../ui/TextField'
+import type { SetOption } from '../types'
+
+/** Power-user knobs. The defaults are good for almost everything, so they live
+ * in a collapsed group with a one-line description on each. */
+export function AdvancedSettings({
+ options,
+ arch,
+ info,
+ set
+}: {
+ options: JobOptions
+ arch: GenArch
+ info: ArchInfo
+ set: SetOption
+}): JSX.Element {
+ const [open, setOpen] = useState(false)
+ const seed = Number(options.seed ?? -1)
+ const random = !(seed >= 0)
+ return (
+ setOpen((o) => !o)}>
+
+ set('steps', v)}
+ />
+
+ {arch === 'sdxl' ? (
+
+ set('cfg', v)}
+ />
+
+ ) : info.hasGuidance ? (
+
+ set('guidance', v)}
+ />
+
+ ) : null}
+
+ set('seed', v === '' ? -1 : Math.max(0, Math.round(v)))}
+ />
+
+ // Unticking picks a concrete seed, so the field shows what will run.
+ set('seed', v ? -1 : Math.floor(Math.random() * 2 ** 31))
+ }
+ label="Random seed"
+ />
+
+
+ )
+}
diff --git a/src/renderer/src/components/options/generate/CompanionDownload.tsx b/src/renderer/src/components/options/generate/CompanionDownload.tsx
new file mode 100644
index 0000000..ee28cfc
--- /dev/null
+++ b/src/renderer/src/components/options/generate/CompanionDownload.tsx
@@ -0,0 +1,77 @@
+import { useState, type JSX } from 'react'
+import type { GenModel } from '@shared/genArch'
+import { Icon } from '../../icons/Icon'
+import { SmallButton } from '../../ui/Button'
+import { ProgressBar } from '../../ui/ProgressBar'
+import { Setting } from '../../ui/Setting'
+
+/** Shown when the selected model is missing its text-encoder / VAE files. Fetches
+ * them into the user's ComfyUI folders, then refreshes so the model goes runnable:
+ * the "works for anyone" path, not just a machine that already has the files. */
+export function CompanionDownload({
+ model,
+ onDone
+}: {
+ model: GenModel
+ onDone: () => void
+}): JSX.Element {
+ const [busy, setBusy] = useState(false)
+ const [msg, setMsg] = useState(null)
+ const [pct, setPct] = useState(null)
+ const [err, setErr] = useState(null)
+ const missing = model.missing ?? []
+
+ const start = async (): Promise => {
+ setBusy(true)
+ setErr(null)
+ const id = `dl-${model.name}`
+ const off = window.filesmith.onGenerateDownloadProgress((p) => {
+ if (p.id !== id) return
+ setMsg(`${p.label} (${p.index}/${p.total})`)
+ setPct(p.pct)
+ })
+ try {
+ const r = await window.filesmith.generateDownload(id, model.name)
+ if (!r.ok) setErr(r.error ?? 'Download failed.')
+ else onDone()
+ } finally {
+ off()
+ setBusy(false)
+ }
+ }
+
+ return (
+
+
+ {missing.map((m) => (
+
+ {m.label}
+ {m.approxSize}
+
+ ))}
+
+ {busy ? (
+ <>
+
+
+ {msg ?? 'Starting'}
+ {pct != null ? ` ${pct}%` : ''}
+
+ >
+ ) : (
+ void start()}>
+ Download required files
+
+ )}
+ {err && (
+
+
+ {err}
+
+ )}
+
+ )
+}
diff --git a/src/renderer/src/components/options/generate/GenerateSettings.tsx b/src/renderer/src/components/options/generate/GenerateSettings.tsx
new file mode 100644
index 0000000..0cbce2b
--- /dev/null
+++ b/src/renderer/src/components/options/generate/GenerateSettings.tsx
@@ -0,0 +1,180 @@
+import { useEffect, useMemo, useRef, type JSX } from 'react'
+import type { JobOptions } from '@shared/types'
+import { GEN_MAX_COUNT, GEN_SIZES, GEN_STYLES, clampDim } from '@shared/generate'
+import { archInfoFor, type GenArch } from '@shared/genArch'
+import { SmallButton } from '../../ui/Button'
+import { ChipGrid } from '../../ui/ChipGrid'
+import { RangeField } from '../../ui/RangeField'
+import { Select } from '../../ui/Select'
+import { Setting, SettingGroup } from '../../ui/Setting'
+import { NumberField, TextField } from '../../ui/TextField'
+import { useGenerateStatus } from '../hooks/useGenerateStatus'
+import type { SetOption } from '../types'
+import { AddModel } from './AddModel'
+import { AdvancedSettings } from './AdvancedSettings'
+import { CompanionDownload } from './CompanionDownload'
+import { LocateComfy } from './LocateComfy'
+import { ModelSelect } from './ModelSelect'
+import { isRestoreName } from './restore'
+
+/** Text-to-image options. The prompt lives in the centre; this pane carries the
+ * model, prompt extras, output and sampling settings. */
+export function GenerateSettings({
+ options,
+ set
+}: {
+ options: JobOptions
+ set: SetOption
+}): JSX.Element {
+ const { status, refresh } = useGenerateStatus()
+ const model = String(options.model ?? '')
+ const w = Number(options.width ?? 1024)
+ const h = Number(options.height ?? 1024)
+ // An explicit mode: derived-from-dimensions alone made "custom" unreachable
+ // (the defaults equal a preset, so the select snapped straight back).
+ const isPreset = GEN_SIZES.some((s) => s.width === w && s.height === h)
+ const sizeValue =
+ String(options.sizeMode ?? '') === 'custom' || !isPreset ? 'custom' : `${w}x${h}`
+ const models = useMemo(() => status?.models ?? [], [status])
+ const selected = models.find((m) => m.name === model)
+ // "Try anyway" is per-model and deliberately not sticky.
+ const tryAnyway = Boolean(options.tryAnyway)
+ // Dimension limits come from the model's own registry entry.
+ const dimCaps = status?.dimCaps?.[selected?.arch ?? 'sdxl']
+ const arch: GenArch = selected?.arch ?? 'sdxl'
+ // Sampler defaults come from the registry when main supplied them.
+ const info = archInfoFor(arch, status?.archInfo)
+
+ // Default to a runnable text-to-image model once the list loads, and steer
+ // away from a restoration checkpoint (SUPIR) or a vanished selection.
+ useEffect(() => {
+ if (!models.length || selected) return
+ const good =
+ models.find((m) => m.runnable && !isRestoreName(m.label)) ??
+ models.find((m) => m.runnable) ??
+ models[0]
+ if (good && good.name !== model) set('model', good.name)
+ }, [model, models, selected, set])
+
+ // When the architecture changes, reset the sampler knobs to that arch's sane
+ // defaults. Only after the scan resolves, and only on a real change.
+ const prevArch = useRef(null)
+ useEffect(() => {
+ if (!status) return
+ if (prevArch.current === null) {
+ prevArch.current = arch
+ return
+ }
+ if (prevArch.current === arch) return
+ prevArch.current = arch
+ set('tryAnyway', 0)
+ set('steps', info.steps)
+ set('cfg', info.cfg)
+ if (info.hasGuidance) set('guidance', info.guidance)
+ }, [arch, info, set, status])
+
+ const sizeChoices = [
+ ...GEN_SIZES.map((s) => ({ value: `${s.width}x${s.height}`, label: s.label })),
+ { value: 'custom', label: 'custom' }
+ ]
+ const needsFiles = Boolean(selected && !selected.runnable && selected.missing?.length)
+ const unrunnable = selected && !selected.runnable && !needsFiles ? selected : null
+ return (
+ <>
+ {status && !status.available && }
+
+
+ {models.length > 0 && (
+ set('model', v)} />
+ )}
+ {/* An unrecognised model is not a forbidden one: send it through a
+ generic graph and let ComfyUI give its own verdict. */}
+ {unrunnable?.reason && unrunnable.tryAnyway && (
+ set('tryAnyway', tryAnyway ? 0 : 1)}
+ >
+ {tryAnyway ? 'Will try anyway, click to cancel' : 'Try anyway'}
+
+ )}
+
+ {selected && needsFiles && }
+
+
+
+ {/* Negative prompt only affects arches that use real CFG (SDXL). At cfg 1
+ the negative branch is inert, so it is hidden there. */}
+ {info.cfg !== 1 && (
+
+ set('negative', e.target.value)}
+ />
+
+ )}
+
+ ({ value: s.id, label: s.label.toLowerCase() }))}
+ onChange={(v) => set('style', v)}
+ />
+
+
+
+
+ set('count', v)}
+ />
+
+
+ {
+ if (v === 'custom') {
+ set('sizeMode', 'custom')
+ return
+ }
+ set('sizeMode', 'preset')
+ const [sw, sh] = v.split('x').map(Number)
+ set('width', sw)
+ set('height', sh)
+ }}
+ />
+ {sizeValue === 'custom' && (
+
+ v !== '' && set('width', v)}
+ clamp={(n) => clampDim(n, dimCaps)}
+ />
+ v !== '' && set('height', v)}
+ clamp={(n) => clampDim(n, dimCaps)}
+ />
+
+ )}
+
+
+
+ >
+ )
+}
diff --git a/src/renderer/src/components/options/generate/LocateComfy.tsx b/src/renderer/src/components/options/generate/LocateComfy.tsx
new file mode 100644
index 0000000..122d31c
--- /dev/null
+++ b/src/renderer/src/components/options/generate/LocateComfy.tsx
@@ -0,0 +1,39 @@
+import { useState, type JSX } from 'react'
+import { Icon } from '../../icons/Icon'
+import { SmallButton } from '../../ui/Button'
+import { Setting, SettingGroup } from '../../ui/Setting'
+
+/**
+ * "ComfyUI was not found", with a way out. Discovery is path guessing, so a
+ * ComfyUI on another drive, a NAS or a redirected Documents folder was otherwise
+ * unreachable. Locating it writes the same store that generate, upscale and
+ * companion discovery all read first, so one pick fixes all three.
+ */
+export function LocateComfy({ onLocated }: { onLocated: () => void }): JSX.Element {
+ const [err, setErr] = useState(null)
+ const pick = (): void => {
+ setErr(null)
+ void window.filesmith.comfyPickFolder().then((folder) => {
+ if (!folder) return
+ void window.filesmith.comfySetFolder(folder).then((r) => {
+ if (r.ok) onLocated()
+ else setErr(r.error ?? "Couldn't use that folder")
+ })
+ })
+ }
+ return (
+
+
+
+ Locate my ComfyUI folder
+
+ {err && (
+
+
+ {err}
+
+ )}
+
+
+ )
+}
diff --git a/src/renderer/src/components/options/generate/ModelSelect.tsx b/src/renderer/src/components/options/generate/ModelSelect.tsx
new file mode 100644
index 0000000..79ef631
--- /dev/null
+++ b/src/renderer/src/components/options/generate/ModelSelect.tsx
@@ -0,0 +1,32 @@
+import type { JSX } from 'react'
+import type { GenModel } from '@shared/genArch'
+import { Select } from '../../ui/Select'
+
+/** Model picker grouped by architecture (Checkpoints / Flux / Z-Image / ...).
+ * Non-runnable models stay selectable and are annotated, so picking one reveals
+ * the download block or Try anyway instead of being a dead, greyed-out row. */
+export function ModelSelect({
+ models,
+ value,
+ onChange
+}: {
+ models: GenModel[]
+ value: string
+ onChange: (v: string) => void
+}): JSX.Element {
+ // Keep each architecture's models together, in first-seen group order, so the
+ // popup draws one heading per group.
+ const groups: string[] = []
+ for (const m of models) if (!groups.includes(m.group)) groups.push(m.group)
+ const options = groups.flatMap((g) =>
+ models
+ .filter((m) => m.group === g)
+ .map((m) => ({
+ value: m.name,
+ label: m.label,
+ group: g,
+ reason: m.runnable ? undefined : 'needs download'
+ }))
+ )
+ return
+}
diff --git a/src/renderer/src/components/options/generate/restore.ts b/src/renderer/src/components/options/generate/restore.ts
new file mode 100644
index 0000000..6c0cb55
--- /dev/null
+++ b/src/renderer/src/components/options/generate/restore.ts
@@ -0,0 +1,5 @@
+/** A checkpoint whose name marks it as restoration/refiner/inpaint: a valid
+ * model but not text-to-image, so we never auto-select it as the default. */
+export function isRestoreName(label: string): boolean {
+ return /supir|refiner|inpaint|upscal|controlnet/i.test(label)
+}
diff --git a/src/renderer/src/components/useArchiveStatus.ts b/src/renderer/src/components/options/hooks/useArchiveStatus.ts
similarity index 89%
rename from src/renderer/src/components/useArchiveStatus.ts
rename to src/renderer/src/components/options/hooks/useArchiveStatus.ts
index 85a6180..53c8339 100644
--- a/src/renderer/src/components/useArchiveStatus.ts
+++ b/src/renderer/src/components/options/hooks/useArchiveStatus.ts
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react'
-// Kept apart from OptionsPanel so that file keeps exporting only components
+// Kept apart from the options components so those files export only components
// (React Fast Refresh requires that), matching usePidStatus.
/**
diff --git a/src/renderer/src/components/useComfyModels.ts b/src/renderer/src/components/options/hooks/useComfyModels.ts
similarity index 100%
rename from src/renderer/src/components/useComfyModels.ts
rename to src/renderer/src/components/options/hooks/useComfyModels.ts
diff --git a/src/renderer/src/components/useGenerateStatus.ts b/src/renderer/src/components/options/hooks/useGenerateStatus.ts
similarity index 100%
rename from src/renderer/src/components/useGenerateStatus.ts
rename to src/renderer/src/components/options/hooks/useGenerateStatus.ts
diff --git a/src/renderer/src/components/usePidStatus.ts b/src/renderer/src/components/options/hooks/usePidStatus.ts
similarity index 100%
rename from src/renderer/src/components/usePidStatus.ts
rename to src/renderer/src/components/options/hooks/usePidStatus.ts
diff --git a/src/renderer/src/components/options/types.ts b/src/renderer/src/components/options/types.ts
new file mode 100644
index 0000000..f24d9a9
--- /dev/null
+++ b/src/renderer/src/components/options/types.ts
@@ -0,0 +1 @@
+export type SetOption = (k: string, v: string | number | boolean) => void
diff --git a/src/renderer/src/components/options/upscale/ComfySetup.tsx b/src/renderer/src/components/options/upscale/ComfySetup.tsx
new file mode 100644
index 0000000..7c2a501
--- /dev/null
+++ b/src/renderer/src/components/options/upscale/ComfySetup.tsx
@@ -0,0 +1,162 @@
+import { useCallback, useEffect, useState, type JSX } from 'react'
+import type { ComfyModel } from '@shared/comfy'
+import { Icon } from '../../icons/Icon'
+import { SmallButton } from '../../ui/Button'
+import { ProgressBar } from '../../ui/ProgressBar'
+import { Setting } from '../../ui/Setting'
+import type { ComfyStatus } from '../hooks/useComfyModels'
+
+// The "Import from ComfyUI" flow, shown under the Upscale model picker when an
+// NVIDIA GPU is present. Three states: engine not built (one-time env setup),
+// scanning, and ready (browse / change / rescan, plus the list of files the
+// engine could not load).
+
+export function ComfyImportCard({
+ status,
+ refresh
+}: {
+ status: ComfyStatus
+ refresh: () => void
+}): JSX.Element {
+ const [busy, setBusy] = useState<'install' | 'scan' | null>(null)
+ const [progress, setProgress] = useState<{ step: string; pct: number | null } | null>(null)
+ const [error, setError] = useState(null)
+ const [lastScan, setLastScan] = useState(null)
+
+ useEffect(() => window.filesmith.onComfyProgress(setProgress), [])
+
+ const scan = useCallback(
+ (folder: string): void => {
+ setBusy('scan')
+ setError(null)
+ void window.filesmith.comfyScan(folder).then((r) => {
+ setBusy(null)
+ if (r.ok) {
+ setLastScan(r.models ?? [])
+ refresh()
+ } else setError(r.error ?? 'Scan failed')
+ })
+ },
+ [refresh]
+ )
+
+ const pickAndScan = useCallback((): void => {
+ void window.filesmith.comfyPickFolder().then((folder) => {
+ if (folder) scan(folder)
+ })
+ }, [scan])
+
+ // Record the folder without a scan: the scan needs the engine we do not have
+ // yet, but remembering the location is exactly what lets the engine be found.
+ const locateOnly = useCallback((): void => {
+ setError(null)
+ void window.filesmith.comfyPickFolder().then((folder) => {
+ if (!folder) return
+ void window.filesmith.comfySetFolder(folder).then((r) => {
+ if (r.ok) refresh()
+ else setError(r.error ?? "Couldn't use that folder")
+ })
+ })
+ }, [refresh])
+
+ const installEngine = useCallback((): void => {
+ setBusy('install')
+ setError(null)
+ setProgress(null)
+ void window.filesmith.comfyInstall().then((r) => {
+ setBusy(null)
+ if (r.ok) refresh()
+ else setError(r.error ?? 'Setup failed')
+ })
+ }, [refresh])
+
+ const unsupported = (lastScan ?? []).filter((m) => m.badge === 'unsupported')
+ const errorLine = error && (
+
+
+ {error}
+
+ )
+
+ if (!status.engineReady) {
+ const pct = progress?.pct ?? null
+ return (
+
+ {busy === 'install' ? (
+ <>
+
+
+ {progress?.step ?? 'Preparing'}
+ {pct != null ? ` ${pct}%` : ''}
+
+ >
+ ) : (
+ <>
+
+ Set up upscale engine
+
+ {/* Browse is deliberately available BEFORE the engine exists:
+ pointing at the folder is what makes discovery work. */}
+
+ {status.folder ? 'Change ComfyUI folder' : 'Locate my ComfyUI folder'}
+
+ {status.folder && (
+
+ {status.folder}
+
+ )}
+ >
+ )}
+ {errorLine}
+
+ )
+ }
+
+ if (busy === 'scan')
+ return (
+
+
+
+ )
+
+ return (
+ {status.folder} : 'Choose your ComfyUI folder.'}
+ >
+
+
+ {status.folder ? 'Change folder' : 'Browse to ComfyUI folder'}
+
+ {status.folder && (
+ scan(status.folder as string)}>
+ Rescan
+
+ )}
+
+ {unsupported.length > 0 && (
+ <>
+
+ {unsupported.length} file{unsupported.length === 1 ? '' : 's'} not usable
+
+
+ {unsupported.map((m) => (
+
+ {m.name}
+ {m.reason ?? 'unsupported'}
+
+ ))}
+
+ >
+ )}
+ {errorLine}
+
+ )
+}
diff --git a/src/renderer/src/components/options/upscale/PidSetup.tsx b/src/renderer/src/components/options/upscale/PidSetup.tsx
new file mode 100644
index 0000000..d13a0c8
--- /dev/null
+++ b/src/renderer/src/components/options/upscale/PidSetup.tsx
@@ -0,0 +1,100 @@
+import { useCallback, useEffect, useState, type JSX } from 'react'
+import { Icon } from '../../icons/Icon'
+import { SmallButton } from '../../ui/Button'
+import { ProgressBar } from '../../ui/ProgressBar'
+import { Setting } from '../../ui/Setting'
+
+// The PiD (NVIDIA) upscaler tier is an on-demand engine: a multi-GB download the
+// user opts into, gated behind an NVIDIA GPU. The GPU and install-state probe
+// lives beside it in hooks/usePidStatus.
+
+/**
+ * First-use install for the PiD engine. Shown when PiD is selected but not yet
+ * installed. Leads with the licence reality (NVIDIA's weights are non-commercial)
+ * because that is the one thing a user cannot undo by uninstalling, then a single
+ * download button that streams step and percent while it runs.
+ */
+export function PidInstallCard({ onInstalled }: { onInstalled: () => void }): JSX.Element {
+ const [installing, setInstalling] = useState(false)
+ const [progress, setProgress] = useState<{ step: string; pct: number | null } | null>(null)
+ const [error, setError] = useState(null)
+
+ // Subscribe once; the main process streams 'pid:progress' during install.
+ useEffect(() => window.filesmith.onPidProgress(setProgress), [])
+
+ const install = useCallback((): void => {
+ setInstalling(true)
+ setError(null)
+ setProgress(null)
+ void window.filesmith.pidInstall().then((r) => {
+ setInstalling(false)
+ if (r.ok) onInstalled()
+ else setError(r.error ?? 'Install failed')
+ })
+ }, [onInstalled])
+
+ const pct = progress?.pct ?? null
+ return (
+
+ {installing ? (
+ <>
+
+
+ {progress?.step ?? 'Preparing'}
+ {pct != null ? ` ${pct}%` : ''}
+
+ >
+ ) : (
+
+ Download PiD (about 6 GB)
+
+ )}
+ {error && (
+
+
+ {error}
+
+ )}
+
+ )
+}
+
+/** Remove the PiD install (repair path). Two clicks: the first arms it, so an
+ * accidental click cannot cost a 6 GB re-download. */
+export function PidRemoveButton({ onRemoved }: { onRemoved: () => void }): JSX.Element {
+ const [armed, setArmed] = useState(false)
+ const [error, setError] = useState(null)
+ const remove = (): void => {
+ if (!armed) {
+ setArmed(true)
+ return
+ }
+ setArmed(false)
+ void window.filesmith.pidInstalling().then((busy) => {
+ if (busy) {
+ setError('An install is running. Wait for it to finish first.')
+ return
+ }
+ void window.filesmith.pidRemove().then((r) => {
+ if (!r.ok) setError(r.error ?? 'Could not remove the install.')
+ else {
+ setError(null)
+ onRemoved()
+ }
+ })
+ })
+ }
+ return (
+
+ setArmed(false)}>
+ {armed ? 'Click again to remove PiD' : 'Remove PiD install'}
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+ )
+}
diff --git a/src/renderer/src/components/options/useOptionSetter.ts b/src/renderer/src/components/options/useOptionSetter.ts
new file mode 100644
index 0000000..2b79cc9
--- /dev/null
+++ b/src/renderer/src/components/options/useOptionSetter.ts
@@ -0,0 +1,15 @@
+import { useCallback, type Dispatch } from 'react'
+import type { Action } from '../../state'
+import type { SetOption } from './types'
+
+/** A stable option setter for one options group. Memoized because the settings
+ * panes list it as an effect dependency, and an inline arrow re-ran those
+ * effects on every render (spec 4.2). A hook of its own so the React Compiler
+ * sees the group as a plain argument. */
+export function useOptionSetter(dispatch: Dispatch, group: string): SetOption {
+ return useCallback(
+ (k: string, v: string | number | boolean) =>
+ dispatch({ type: 'setOption', group, key: k, value: v }),
+ [dispatch, group]
+ )
+}
diff --git a/src/renderer/src/components/queue/EmptyState.tsx b/src/renderer/src/components/queue/EmptyState.tsx
new file mode 100644
index 0000000..f96fd39
--- /dev/null
+++ b/src/renderer/src/components/queue/EmptyState.tsx
@@ -0,0 +1,30 @@
+import type { JSX } from 'react'
+import type { IconName } from '@shared/icons'
+import { Icon } from '../icons/Icon'
+import { SmallButton } from '../ui/Button'
+
+/** Centred empty block (spec 4.12): no drop zone, an outlined button instead. */
+export function EmptyState({
+ icon,
+ title,
+ line,
+ action
+}: {
+ icon: IconName
+ title: string
+ line: string
+ action?: { label: string; onClick: () => void }
+}): JSX.Element {
+ return (
+
+
+
{title}
+
{line}
+ {action && (
+
+ {action.label}
+
+ )}
+
+ )
+}
diff --git a/src/renderer/src/components/queue/QueueRow.tsx b/src/renderer/src/components/queue/QueueRow.tsx
new file mode 100644
index 0000000..3aa27a8
--- /dev/null
+++ b/src/renderer/src/components/queue/QueueRow.tsx
@@ -0,0 +1,79 @@
+import type { JSX, KeyboardEvent, MouseEvent } from 'react'
+import type { QueueItem } from '../../state'
+import { Checkbox } from '../ui/Checkbox'
+import { ResultCell } from './ResultCell'
+import { StatusCell } from './StatusCell'
+import type { RowActionKind, RowView } from './rowModel'
+
+export function QueueRow({
+ item,
+ view,
+ selected,
+ dim,
+ focusable,
+ onClick,
+ onToggle,
+ onOpen,
+ onMenu,
+ onAction,
+ onKeyDown
+}: {
+ item: QueueItem
+ view: RowView
+ selected: boolean
+ /** A row of a different convert group than the selection (spec 2.6). */
+ dim: boolean
+ focusable: boolean
+ onClick: (e: MouseEvent) => void
+ onToggle: () => void
+ onOpen: () => void
+ onMenu: (x: number, y: number) => void
+ onAction: (k: RowActionKind) => void
+ onKeyDown: (e: KeyboardEvent) => void
+}): JSX.Element {
+ return (
+ {
+ e.preventDefault()
+ onMenu(e.clientX, e.clientY)
+ }}
+ onKeyDown={onKeyDown}
+ >
+
+
+
+
+ {item.thumb ? (
+
+ ) : (
+
+ {view.kind.slice(0, 3)}
+
+ )}
+
+ {item.file.name}
+
+ {/* Below 1180px the kind column folds into the name cell (spec 3.7). */}
+
+ {view.kind}
+
+
+
+ {view.kind}
+
+
+ {view.size}
+
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/queue/QueueTable.tsx b/src/renderer/src/components/queue/QueueTable.tsx
new file mode 100644
index 0000000..4141cb7
--- /dev/null
+++ b/src/renderer/src/components/queue/QueueTable.tsx
@@ -0,0 +1,183 @@
+import { Fragment, useRef, useState, type JSX, type KeyboardEvent, type MouseEvent } from 'react'
+import { Checkbox } from '../ui/Checkbox'
+import { Icon } from '../icons/Icon'
+import { groupOf } from '../../state'
+import { EmptyState } from './EmptyState'
+import { QueueRow } from './QueueRow'
+import { TotalsRow } from './TotalsRow'
+import { rowView, type RowActionKind, type Totals } from './rowModel'
+import type { CheckState } from './selectAll'
+import { tableKey } from './tableKeys'
+import { visibleOrder, type RowGroup, type SortKey, type SortState } from './tableSort'
+
+const HEADS: { key: SortKey; label: string; num?: boolean }[] = [
+ { key: 'name', label: 'name' },
+ { key: 'kind', label: 'kind' },
+ { key: 'size', label: 'size', num: true },
+ { key: 'result', label: 'result' },
+ { key: 'status', label: 'status' }
+]
+
+export function QueueTable({
+ groups,
+ totals,
+ selected,
+ activeGroup,
+ sort,
+ check,
+ estimates,
+ onSort,
+ onToggleAll,
+ onSelectAll,
+ onRowClick,
+ onToggleRow,
+ onExtend,
+ onOpen,
+ onMenu,
+ onAction,
+ onRemove,
+ onSelectGroup,
+ onAdd
+}: {
+ groups: RowGroup[]
+ totals: Totals
+ selected: string[]
+ activeGroup: string | null
+ sort: SortState | null
+ check: CheckState
+ estimates: Record
+ onSort: (k: SortKey) => void
+ onToggleAll: () => void
+ onSelectAll: () => void
+ onRowClick: (id: string, e: MouseEvent) => void
+ onToggleRow: (id: string) => void
+ /** Shift+Arrow: extend the range to this id. */
+ onExtend: (id: string) => void
+ onOpen: (id: string) => void
+ onMenu: (id: string, x: number, y: number) => void
+ onAction: (id: string, k: RowActionKind) => void
+ onRemove: (id: string) => void
+ onSelectGroup: (group: string) => void
+ onAdd: () => void
+}): JSX.Element {
+ const body = useRef(null)
+ const order = visibleOrder(groups)
+ const [focusId, setFocusId] = useState(null)
+ const tabStop = focusId && order.includes(focusId) ? focusId : (selected[0] ?? order[0] ?? null)
+
+ function focusRow(id: string | undefined): void {
+ if (!id) return
+ setFocusId(id)
+ body.current?.querySelector(`[data-id="${CSS.escape(id)}"]`)?.focus()
+ }
+
+ function onRowKey(id: string, e: KeyboardEvent): void {
+ if (e.target !== e.currentTarget) return
+ const k = tableKey(e)
+ if (!k) return
+ e.preventDefault()
+ const i = order.indexOf(id)
+ if (k === 'up' || k === 'down') focusRow(order[k === 'up' ? i - 1 : i + 1])
+ else if (k === 'extendUp' || k === 'extendDown') {
+ const next = order[k === 'extendUp' ? i - 1 : i + 1]
+ if (next) {
+ focusRow(next)
+ onExtend(next)
+ }
+ } else if (k === 'toggle') onToggleRow(id)
+ else if (k === 'selectAll') onSelectAll()
+ else if (k === 'remove') onRemove(id)
+ else if (k === 'open') onOpen(id)
+ else {
+ const r = e.currentTarget.getBoundingClientRect()
+ onMenu(id, r.left + 40, r.bottom)
+ }
+ }
+
+ return (
+
+
+
+
+
+ {HEADS.map((h) => {
+ const on = sort?.key === h.key
+ const desc = on && sort.dir === 'desc'
+ return (
+
onSort(h.key)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault()
+ onSort(h.key)
+ }
+ }}
+ >
+ {h.label}
+
+
+ )
+ })}
+
+
+ {groups.length === 0 ? (
+
+ ) : (
+ groups.map((g) => (
+
+ {groups.length > 1 && (
+
+ onSelectGroup(g.group)}
+ title={`Select all ${g.label.toLowerCase()}`}
+ >
+ {g.label.toUpperCase()}
+ {g.items.length}
+
+
+ )}
+ {g.items.map((item) => (
+ {
+ setFocusId(item.id)
+ onRowClick(item.id, e)
+ }}
+ onToggle={() => onToggleRow(item.id)}
+ onOpen={() => onOpen(item.id)}
+ onMenu={(x, y) => onMenu(item.id, x, y)}
+ onAction={(k) => onAction(item.id, k)}
+ onKeyDown={(e) => onRowKey(item.id, e)}
+ />
+ ))}
+
+ ))
+ )}
+
+ {totals.files > 0 && }
+
+ )
+}
diff --git a/src/renderer/src/components/queue/QueueToolbar.tsx b/src/renderer/src/components/queue/QueueToolbar.tsx
new file mode 100644
index 0000000..44a704e
--- /dev/null
+++ b/src/renderer/src/components/queue/QueueToolbar.tsx
@@ -0,0 +1,30 @@
+import type { JSX } from 'react'
+import { AddFilesButton } from '../ui/Button'
+
+/** Add files and the count. Row actions (remove, delete, clear finished) live in
+ * the right-click menu on the rows themselves. */
+export function QueueToolbar({
+ files,
+ selected,
+ dropping,
+ onAdd
+}: {
+ files: number
+ selected: number
+ dropping: boolean
+ onAdd: () => void
+}): JSX.Element {
+ const count = dropping
+ ? 'Drop to add'
+ : `${files} file${files === 1 ? '' : 's'}${selected ? `, ${selected} selected` : ''}`
+ return (
+
+ )
+}
diff --git a/src/renderer/src/components/queue/ResultCell.tsx b/src/renderer/src/components/queue/ResultCell.tsx
new file mode 100644
index 0000000..646a03f
--- /dev/null
+++ b/src/renderer/src/components/queue/ResultCell.tsx
@@ -0,0 +1,26 @@
+import type { JSX } from 'react'
+import { Icon } from '../icons/Icon'
+import type { ResultView } from './rowModel'
+
+/** The arrow sits centred on the size|result column boundary, on a patch of the
+ * row background, and only when the cell has content (spec 4.3). */
+export function ResultCell({ result }: { result: ResultView | null }): JSX.Element {
+ return (
+
+ {result && (
+
+
+ {result.estimate ? {result.text} : {result.text} }
+ {result.pct && (
+
+ {result.pct}
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/src/renderer/src/components/queue/StatusCell.tsx b/src/renderer/src/components/queue/StatusCell.tsx
new file mode 100644
index 0000000..4439e34
--- /dev/null
+++ b/src/renderer/src/components/queue/StatusCell.tsx
@@ -0,0 +1,58 @@
+import type { JSX } from 'react'
+import { Icon } from '../icons/Icon'
+import { RowAction } from '../ui/Button'
+import type { RowActionKind, RowActionView, StatusView } from './rowModel'
+
+export function StatusCell({
+ status,
+ action,
+ onAction
+}: {
+ status: StatusView
+ action: RowActionView
+ onAction: (k: RowActionKind) => void
+}): JSX.Element {
+ return (
+
+ {status.kind === 'failed' ? (
+
+
+ {status.text}
+
+ ) : status.kind === 'running' ? (
+
+
+
+ {status.pct}
+ {status.eta}
+
+
+ ) : status.kind === 'done' ? (
+
+
+ Done
+
+ ) : status.kind === 'queued' ? (
+
+
+ Queued
+
+ ) : status.kind === 'canceled' ? (
+
+
+ Canceled
+
+ ) : null}
+ {
+ e.stopPropagation()
+ onAction(action.kind)
+ }}
+ onDoubleClick={(e) => e.stopPropagation()}
+ />
+
+ )
+}
diff --git a/src/renderer/src/components/queue/TotalsRow.tsx b/src/renderer/src/components/queue/TotalsRow.tsx
new file mode 100644
index 0000000..6c51050
--- /dev/null
+++ b/src/renderer/src/components/queue/TotalsRow.tsx
@@ -0,0 +1,36 @@
+import type { JSX } from 'react'
+import { formatBytes } from '@shared/compress'
+import { Icon } from '../icons/Icon'
+import type { Totals } from './rowModel'
+
+export function TotalsRow({ totals: t }: { totals: Totals }): JSX.Element {
+ return (
+
+
+
+ {t.files} file{t.files === 1 ? '' : 's'}
+
+
+
+ {formatBytes(t.bytes)}
+
+
+ {t.doneSrc > 0 && (
+ <>
+ {formatBytes(t.doneSrc)}
+
+ {formatBytes(t.doneOut)}
+ so far
+ >
+ )}
+
+
+ {t.files > 0 && (
+
+ {t.done} of {t.files} done
+
+ )}
+
+
+ )
+}
diff --git a/src/renderer/src/components/queue/rowMenu.ts b/src/renderer/src/components/queue/rowMenu.ts
new file mode 100644
index 0000000..29a0b08
--- /dev/null
+++ b/src/renderer/src/components/queue/rowMenu.ts
@@ -0,0 +1,105 @@
+// src/renderer/src/components/queue/rowMenu.ts
+import type { IconName } from '@shared/icons'
+import { inInput, type QueueItem } from '../../state'
+
+// Pure model for the files table's right-click menu: which rows it acts on and
+// which entries apply. Kept apart from App.tsx so Vitest can cover it.
+
+export type RowMenuAction = 'open' | 'reveal' | 'retry' | 'stop' | 'remove' | 'delete' | 'clear'
+
+export type RowMenuEntry =
+ | { sep: true }
+ | {
+ sep?: false
+ action: RowMenuAction
+ label: string
+ icon: IconName
+ disabled: boolean
+ danger?: boolean
+ }
+
+/** Right-clicking a selected row acts on the whole selection; any other row
+ * becomes the selection on its own (the caller selects it). */
+export function menuTargets(clicked: string, selected: string[]): string[] {
+ return selected.includes(clicked) ? selected : [clicked]
+}
+
+const inFlight = (i: QueueItem): boolean => i.status === 'queued' || i.status === 'running'
+const retryable = (i: QueueItem): boolean => i.status === 'failed' || i.status === 'canceled'
+const finished = (i: QueueItem): boolean =>
+ inInput(i) && (i.status === 'done' || i.status === 'canceled')
+
+/** The path Show in File Explorer points at: the result once a row is done,
+ * otherwise the source. */
+export function revealPath(i: QueueItem): string {
+ return i.status === 'done' && i.outputPath ? i.outputPath : i.file.path
+}
+
+const plural = (n: number, one: string, many: string): string => (n === 1 ? one : many)
+
+export function rowMenuModel(targets: QueueItem[], queue: QueueItem[]): RowMenuEntry[] {
+ const n = targets.length
+ const one = n === 1 ? targets[0] : null
+ const stops = targets.filter(inFlight).length
+ const retries = targets.filter(retryable).length
+ const clearable = queue.filter(finished).length
+ return [
+ { action: 'open', label: 'Open', icon: 'eye', disabled: !one },
+ {
+ action: 'reveal',
+ label: 'Show in File Explorer',
+ icon: 'folder',
+ disabled: !one
+ },
+ { sep: true },
+ {
+ action: 'retry',
+ label: retries > 1 ? `Retry ${retries} files` : 'Retry',
+ icon: 'retry',
+ disabled: retries === 0
+ },
+ {
+ action: 'stop',
+ label: stops > 1 ? `Stop ${stops} jobs` : 'Stop',
+ icon: 'stop',
+ disabled: stops === 0
+ },
+ { sep: true },
+ {
+ action: 'clear',
+ label: 'Clear finished',
+ icon: 'cleardone',
+ disabled: clearable === 0
+ },
+ { sep: true },
+ {
+ action: 'remove',
+ label: n > 1 ? `Remove ${n} from list` : 'Remove from list',
+ icon: 'close',
+ disabled: n === 0
+ },
+ {
+ action: 'delete',
+ label: n > 1 ? `Delete ${n} files` : 'Delete file',
+ icon: 'trash',
+ danger: true,
+ // A running job is still reading its source: stop it first.
+ disabled: n === 0 || stops > 0
+ }
+ ]
+}
+
+/** Confirm copy for Remove from list and Delete, naming the count. */
+export function removeConfirm(n: number): { title: string; body: string } {
+ return {
+ title: n === 1 ? 'Remove this file from the list?' : `Remove ${n} files from the list?`,
+ body: 'Files on disk are not touched; running jobs are stopped.'
+ }
+}
+
+export function deleteConfirm(n: number): { title: string; body: string } {
+ return {
+ title: n === 1 ? 'Delete this file?' : `Delete ${n} files?`,
+ body: `${plural(n, 'The source file goes', `The ${n} source files go`)} to the Recycle Bin and ${plural(n, 'leaves', 'leave')} the list. Results already made are kept.`
+ }
+}
diff --git a/src/renderer/src/components/queue/rowModel.ts b/src/renderer/src/components/queue/rowModel.ts
new file mode 100644
index 0000000..c4aacdc
--- /dev/null
+++ b/src/renderer/src/components/queue/rowModel.ts
@@ -0,0 +1,209 @@
+// src/renderer/src/components/queue/rowModel.ts
+import type { JobOptions } from '@shared/types'
+import { formatBytes } from '@shared/compress'
+import { groupOf, inInput, type ItemStatus, type QueueItem } from '../../state'
+
+// Pure view model for one table row (spec 4.3) and the totals row (spec 4.4).
+// Kept apart from the .tsx files so React Fast Refresh and Vitest both work.
+
+export function formatEtaCompact(sec: number | null | undefined): string {
+ if (sec == null || !Number.isFinite(sec) || sec < 0) return ''
+ const s = Math.round(sec)
+ if (s < 60) return `(${Math.max(1, s)}s)`
+ const m = Math.round(sec / 60)
+ if (m < 60) return `(${m}m)`
+ return `(${Math.floor(m / 60)}h ${m % 60}m)`
+}
+
+export function progressParts(pct: number, etaSec?: number | null): { pct: string; eta: string } {
+ const n = pct > 0 && pct < 10 ? pct.toFixed(1) : String(Math.round(pct))
+ return { pct: `${n}%`, eta: formatEtaCompact(etaSec) }
+}
+
+export function formatProgress(pct: number, etaSec?: number | null): string {
+ const p = progressParts(pct, etaSec)
+ return p.pct + p.eta
+}
+
+export function kindLabel(ext: string): string {
+ return ext.replace(/^\./, '').toUpperCase()
+}
+
+export function pctChange(src: number, out: number): number | null {
+ if (!(src > 0) || !Number.isFinite(out)) return null
+ const pct = Math.round(((out - src) / src) * 100)
+ // A non-empty output never saved everything: 29 MB to 51 KB reads -99%, not -100%.
+ return pct === -100 && out > 0 ? -99 : pct
+}
+
+export function formatPct(n: number): string {
+ return n > 0 ? `+${n}%` : `${n}%`
+}
+
+const KNOWN_ERRORS: [RegExp, string][] = [
+ [/unsupported compression/i, 'Unsupported compression'],
+ [/winrar|rar\.exe/i, 'WinRAR not found'],
+ [/password/i, 'Password-protected'],
+ [/no (en|de)code delegate|missing encoder/i, 'Missing encoder'],
+ [/not installed|could not be found|enoent|tool missing/i, 'Tool missing']
+]
+const MAX_ERROR = 32
+
+export function shortError(message: string | undefined): string {
+ const m = (message ?? '').trim()
+ if (!m) return 'Failed'
+ for (const [re, label] of KNOWN_ERRORS) if (re.test(m)) return label
+ const first = m.split(/[.:;\n]|,\s/)[0].trim() || m
+ return first.length > MAX_ERROR ? first.slice(0, MAX_ERROR - 1).trimEnd() + '…' : first
+}
+
+export type RowActionKind = 'reveal' | 'remove' | 'cancel' | 'retry'
+export interface ResultView {
+ text: string
+ pct: string | null
+ estimate: boolean
+ grew: boolean
+}
+export interface StatusView {
+ kind: ItemStatus
+ text: string
+ pct?: string
+ eta?: string
+ title?: string
+}
+export interface RowActionView {
+ kind: RowActionKind
+ icon: 'folder' | 'close' | 'retry'
+ label: string
+ ghost: boolean
+}
+export interface RowView {
+ kind: string
+ size: string
+ result: ResultView | null
+ status: StatusView
+ action: RowActionView
+}
+
+function resultFor(item: QueueItem, estimateBytes?: number | null): ResultView | null {
+ const src = item.file.size
+ if (item.status === 'done') {
+ if (item.outputSize != null) {
+ const pct = pctChange(src, item.outputSize)
+ return {
+ text: formatBytes(item.outputSize),
+ pct: pct == null ? null : formatPct(pct),
+ estimate: false,
+ grew: pct != null && pct > 0
+ }
+ }
+ return item.outputPath ? { text: 'folder', pct: null, estimate: false, grew: false } : null
+ }
+ if (item.status === 'running' && estimateBytes != null && estimateBytes > 0) {
+ const pct = pctChange(src, estimateBytes)
+ return {
+ text: `~${formatBytes(estimateBytes)}`,
+ pct: pct == null ? null : formatPct(pct),
+ estimate: true,
+ grew: pct != null && pct > 0
+ }
+ }
+ return null
+}
+
+function statusFor(item: QueueItem): StatusView {
+ switch (item.status) {
+ case 'done':
+ return { kind: 'done', text: 'Done' }
+ case 'queued':
+ return { kind: 'queued', text: 'Queued' }
+ case 'running': {
+ if (!item.hasProgress) return { kind: 'running', text: '', pct: '…', eta: '' }
+ const p = progressParts(item.percent, item.etaSec)
+ return { kind: 'running', text: '', pct: p.pct, eta: p.eta }
+ }
+ case 'failed':
+ return { kind: 'failed', text: shortError(item.error), title: item.error ?? 'Failed' }
+ case 'canceled':
+ return { kind: 'canceled', text: 'Canceled' }
+ default:
+ return { kind: 'ready', text: '' }
+ }
+}
+
+function actionFor(status: ItemStatus): RowActionView {
+ switch (status) {
+ case 'done':
+ return { kind: 'reveal', icon: 'folder', label: 'Show in folder', ghost: true }
+ case 'running':
+ return { kind: 'cancel', icon: 'close', label: 'Cancel', ghost: true }
+ case 'failed':
+ return { kind: 'retry', icon: 'retry', label: 'Retry', ghost: false }
+ case 'canceled':
+ return { kind: 'retry', icon: 'retry', label: 'Retry', ghost: true }
+ default:
+ return { kind: 'remove', icon: 'close', label: 'Remove', ghost: true }
+ }
+}
+
+export function rowView(item: QueueItem, estimateBytes?: number | null): RowView {
+ return {
+ kind: kindLabel(item.file.ext),
+ size: formatBytes(item.file.size),
+ result: resultFor(item, estimateBytes),
+ status: statusFor(item),
+ action: actionFor(item.status)
+ }
+}
+
+export interface Totals {
+ files: number
+ bytes: number
+ doneSrc: number
+ doneOut: number
+ done: number
+ failed: number
+ inFlight: number
+}
+
+export function queueTotals(items: QueueItem[]): Totals {
+ const t: Totals = { files: 0, bytes: 0, doneSrc: 0, doneOut: 0, done: 0, failed: 0, inFlight: 0 }
+ for (const i of items) {
+ if (!inInput(i)) continue
+ t.files += 1
+ t.bytes += i.file.size
+ if (i.status === 'done') {
+ t.done += 1
+ if (i.outputSize != null) {
+ t.doneSrc += i.file.size
+ t.doneOut += i.outputSize
+ }
+ } else if (i.status === 'failed') t.failed += 1
+ else if (i.status === 'queued' || i.status === 'running') t.inFlight += 1
+ }
+ return t
+}
+
+function sameOptions(a: JobOptions | undefined, b: JobOptions): boolean {
+ if (!a) return false
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)])
+ for (const k of keys) if (JSON.stringify(a[k]) !== JSON.stringify(b[k])) return false
+ return true
+}
+
+export function doneSamples(
+ items: QueueItem[],
+ group: string,
+ options: JobOptions
+): { source: number; output: number }[] {
+ return items
+ .filter(
+ (i) =>
+ inInput(i) &&
+ i.status === 'done' &&
+ i.outputSize != null &&
+ groupOf(i.file) === group &&
+ sameOptions(i.runOptions, options)
+ )
+ .map((i) => ({ source: i.file.size, output: i.outputSize as number }))
+}
diff --git a/src/renderer/src/components/queue/selectAll.ts b/src/renderer/src/components/queue/selectAll.ts
new file mode 100644
index 0000000..e30d33a
--- /dev/null
+++ b/src/renderer/src/components/queue/selectAll.ts
@@ -0,0 +1,45 @@
+import { groupOf, inInput, type QueueItem } from '../../state'
+import { groupRank } from '../queueGroups'
+
+export type CheckState = 'none' | 'mixed' | 'all'
+
+/** The group the header checkbox speaks for: the selection's group, or the
+ * first group in display order when nothing is selected. */
+export function activeGroupFor(items: QueueItem[], selected: string[]): string | null {
+ const rows = items.filter(inInput)
+ const first = rows.find((i) => i.id === selected[0])
+ if (first) return groupOf(first.file)
+ const groups = [...new Set(rows.map((i) => groupOf(i.file)))]
+ if (!groups.length) return null
+ return groups.sort((a, b) => groupRank(a) - groupRank(b))[0]
+}
+
+function groupIds(items: QueueItem[], group: string | null): string[] {
+ return items.filter((i) => inInput(i) && groupOf(i.file) === group).map((i) => i.id)
+}
+
+export function headerCheck(items: QueueItem[], selected: string[]): CheckState {
+ const ids = groupIds(items, activeGroupFor(items, selected))
+ const n = ids.filter((id) => selected.includes(id)).length
+ if (n === 0) return 'none'
+ return n === ids.length ? 'all' : 'mixed'
+}
+
+/** What a header click selects: the whole active group, or nothing when the
+ * group is already fully selected. Never crosses groups. */
+export function toggleAllIds(items: QueueItem[], selected: string[]): string[] {
+ const ids = groupIds(items, activeGroupFor(items, selected))
+ return headerCheck(items, selected) === 'all' ? [] : ids
+}
+
+/** Restrict a set of ids to one convert group (the first in display order among
+ * them), so bulk selections such as "select the failed rows" never span groups. */
+export function oneGroupIds(items: QueueItem[], ids: string[]): string[] {
+ const set = new Set(ids)
+ const rows = items.filter((i) => inInput(i) && set.has(i.id))
+ if (!rows.length) return []
+ const group = [...new Set(rows.map((i) => groupOf(i.file)))].sort(
+ (a, b) => groupRank(a) - groupRank(b)
+ )[0]
+ return ids.filter((id) => rows.some((r) => r.id === id && groupOf(r.file) === group))
+}
diff --git a/src/renderer/src/components/queue/tableKeys.ts b/src/renderer/src/components/queue/tableKeys.ts
new file mode 100644
index 0000000..13ac8e6
--- /dev/null
+++ b/src/renderer/src/components/queue/tableKeys.ts
@@ -0,0 +1,24 @@
+export type TableKey =
+ 'up' | 'down' | 'extendUp' | 'extendDown' | 'toggle' | 'selectAll' | 'remove' | 'open' | 'menu'
+
+export interface KeyLike {
+ key: string
+ ctrlKey: boolean
+ metaKey: boolean
+ shiftKey: boolean
+ altKey: boolean
+}
+
+/** Keyboard map for the files table (spec 4.1). Ctrl+Enter is left for Run. */
+export function tableKey(e: KeyLike): TableKey | null {
+ if (e.altKey) return null
+ const mod = e.ctrlKey || e.metaKey
+ if (mod) return e.key.toLowerCase() === 'a' && !e.shiftKey ? 'selectAll' : null
+ if (e.key === 'ArrowUp') return e.shiftKey ? 'extendUp' : 'up'
+ if (e.key === 'ArrowDown') return e.shiftKey ? 'extendDown' : 'down'
+ if (e.key === ' ') return 'toggle'
+ if (e.key === 'Delete') return 'remove'
+ if (e.key === 'Enter') return 'open'
+ if (e.key === 'ContextMenu' || (e.key === 'F10' && e.shiftKey)) return 'menu'
+ return null
+}
diff --git a/src/renderer/src/components/queue/tableSort.ts b/src/renderer/src/components/queue/tableSort.ts
new file mode 100644
index 0000000..fb2143a
--- /dev/null
+++ b/src/renderer/src/components/queue/tableSort.ts
@@ -0,0 +1,83 @@
+import { groupOf, inInput, type ItemStatus, type QueueItem } from '../../state'
+import { groupLabel, groupRank } from '../queueGroups'
+
+export type SortKey = 'name' | 'kind' | 'size' | 'result' | 'status'
+export interface SortState {
+ key: SortKey
+ dir: 'asc' | 'desc'
+}
+
+/** Header click cycle: ascending, descending, back to insertion order. */
+export function nextSort(cur: SortState | null, key: SortKey): SortState | null {
+ if (!cur || cur.key !== key) return { key, dir: 'asc' }
+ return cur.dir === 'asc' ? { key, dir: 'desc' } : null
+}
+
+const STATUS_RANK: Record = {
+ failed: 0,
+ running: 1,
+ queued: 2,
+ ready: 3,
+ canceled: 4,
+ done: 5
+}
+
+function compare(a: QueueItem, b: QueueItem, key: SortKey): number {
+ switch (key) {
+ case 'name':
+ return a.file.name.localeCompare(b.file.name, undefined, {
+ numeric: true,
+ sensitivity: 'base'
+ })
+ case 'kind':
+ return a.file.ext.localeCompare(b.file.ext)
+ case 'size':
+ return a.file.size - b.file.size
+ case 'status':
+ return STATUS_RANK[a.status] - STATUS_RANK[b.status]
+ case 'result':
+ return (a.outputSize ?? 0) - (b.outputSize ?? 0)
+ }
+}
+
+export function sortItems(items: QueueItem[], sort: SortState | null): QueueItem[] {
+ if (!sort) return items.slice()
+ const sign = sort.dir === 'asc' ? 1 : -1
+ return items
+ .map((item, index) => ({ item, index }))
+ .sort((x, y) => {
+ if (sort.key === 'result') {
+ // Rows without a result always go last, whichever the direction.
+ const ex = x.item.outputSize == null
+ const ey = y.item.outputSize == null
+ if (ex !== ey) return ex ? 1 : -1
+ }
+ return sign * compare(x.item, y.item, sort.key) || x.index - y.index
+ })
+ .map((x) => x.item)
+}
+
+export interface RowGroup {
+ group: string
+ label: string
+ items: QueueItem[]
+}
+
+/** Input rows bucketed by convert group in display order, each bucket sorted. */
+export function groupedRows(items: QueueItem[], sort: SortState | null): RowGroup[] {
+ const by = new Map()
+ for (const i of items) {
+ if (!inInput(i)) continue
+ const g = groupOf(i.file)
+ const list = by.get(g)
+ if (list) list.push(i)
+ else by.set(g, [i])
+ }
+ return [...by.keys()]
+ .sort((a, b) => groupRank(a) - groupRank(b))
+ .map((g) => ({ group: g, label: groupLabel(g), items: sortItems(by.get(g)!, sort) }))
+}
+
+export function visibleOrder(groups: RowGroup[]): string[] {
+ return groups.flatMap((g) => g.items.map((i) => i.id))
+}
diff --git a/src/renderer/src/components/queueGroups.ts b/src/renderer/src/components/queueGroups.ts
index f8a4931..e12a7f8 100644
--- a/src/renderer/src/components/queueGroups.ts
+++ b/src/renderer/src/components/queueGroups.ts
@@ -6,7 +6,7 @@ import { groupOf, type QueueItem } from '../state'
/** Display order for the group headers: the everyday kinds first, then the
* document family, then the long tail. */
-const GROUP_ORDER = ['image', 'video', 'audio', 'doc', 'sheet', 'slide', 'archive']
+export const GROUP_ORDER = ['image', 'video', 'audio', 'doc', 'sheet', 'slide', 'archive']
const GROUP_NOUN: Record string> = {
image: (n) => `${n} image${n === 1 ? '' : 's'}`,
video: (n) => `${n} video${n === 1 ? '' : 's'}`,
@@ -26,15 +26,15 @@ const GROUP_LABEL: Record = {
archive: 'Archives'
}
-/** The dot colour beside a group's name in the options panel. */
-export const GROUP_COLOR: Record = {
- image: '#5b5bd6',
- video: '#e0483d',
- audio: '#f5920b',
- doc: '#12b3a6',
- sheet: '#12b3a6',
- slide: '#12b3a6',
- archive: '#a16207'
+/** "Images" / "Video" / "Documents"; "Other" for an unknown group. */
+export function groupLabel(group: string): string {
+ return GROUP_LABEL[group] ?? 'Other'
+}
+
+/** Index of a group in display order; unknown groups sort last. */
+export function groupRank(group: string): number {
+ const i = GROUP_ORDER.indexOf(group)
+ return i < 0 ? GROUP_ORDER.length : i
}
/** "2 images" / "1 document" / "3 archives". Named by CONVERT GROUP, not file
diff --git a/src/renderer/src/components/shell/Breadcrumb.tsx b/src/renderer/src/components/shell/Breadcrumb.tsx
new file mode 100644
index 0000000..fb14f62
--- /dev/null
+++ b/src/renderer/src/components/shell/Breadcrumb.tsx
@@ -0,0 +1,34 @@
+import { Fragment, type JSX } from 'react'
+import type { Crumb, CrumbAction } from './crumbs'
+
+export function Breadcrumb({
+ crumbs,
+ onCrumb
+}: {
+ crumbs: Crumb[]
+ onCrumb: (a: CrumbAction) => void
+}): JSX.Element {
+ return (
+
+ {crumbs.map((c, i) => (
+
+ {i > 0 && / }
+ {i === crumbs.length - 1 ? (
+ {c.label}
+ ) : c.action ? (
+ onCrumb(c.action as CrumbAction)}
+ >
+ {c.label}
+
+ ) : (
+ {c.label}
+ )}
+
+ ))}
+
+ )
+}
diff --git a/src/renderer/src/components/shell/Sidebar.tsx b/src/renderer/src/components/shell/Sidebar.tsx
new file mode 100644
index 0000000..45626e4
--- /dev/null
+++ b/src/renderer/src/components/shell/Sidebar.tsx
@@ -0,0 +1,64 @@
+import type { JSX } from 'react'
+import { COMPLETED_TAB, SETTINGS_TAB, tabById, type Tab, type TabId } from '@shared/tabs'
+import { Icon } from '../icons/Icon'
+
+export function Sidebar({
+ tab,
+ verbs,
+ showTools,
+ counts,
+ completedCount,
+ collapsed,
+ onToggle,
+ onSelect
+}: {
+ tab: TabId
+ verbs: TabId[]
+ showTools: boolean
+ counts: Record
+ completedCount: number
+ collapsed: boolean
+ onToggle: () => void
+ onSelect: (t: TabId) => void
+}): JSX.Element {
+ const item = (t: Tab, n?: number): JSX.Element => (
+ onSelect(t.id)}
+ >
+
+ {t.label}
+ {n ? {n} : null}
+
+ )
+ return (
+
+
+ operations
+
+
+
+
+
+ {verbs.map((id) => item(tabById(id), counts[id]))}
+ {showTools && item(tabById('tools'), counts.tools)}
+
+
+
+ {item(COMPLETED_TAB, completedCount)}
+ {item(SETTINGS_TAB)}
+
+
+ )
+}
diff --git a/src/renderer/src/components/shell/StatusBar.tsx b/src/renderer/src/components/shell/StatusBar.tsx
new file mode 100644
index 0000000..0a0d554
--- /dev/null
+++ b/src/renderer/src/components/shell/StatusBar.tsx
@@ -0,0 +1,45 @@
+import type { JSX } from 'react'
+import { Icon } from '../icons/Icon'
+import { ProgressBar } from '../ui/ProgressBar'
+import type { StatusSummary } from './statusModel'
+
+export function StatusBar({
+ summary,
+ onFailedClick
+}: {
+ summary: StatusSummary
+ onFailedClick: () => void
+}): JSX.Element {
+ return (
+
+ )
+}
diff --git a/src/renderer/src/components/shell/TitleBar.tsx b/src/renderer/src/components/shell/TitleBar.tsx
new file mode 100644
index 0000000..fa1d98f
--- /dev/null
+++ b/src/renderer/src/components/shell/TitleBar.tsx
@@ -0,0 +1,56 @@
+import type { JSX } from 'react'
+import { Icon } from '../icons/Icon'
+import { Breadcrumb } from './Breadcrumb'
+import type { Crumb, CrumbAction } from './crumbs'
+
+export function TitleBar({
+ crumbs,
+ onCrumb
+}: {
+ crumbs: Crumb[]
+ onCrumb: (a: CrumbAction) => void
+}): JSX.Element {
+ return (
+
+
+
+ Filesmith
+
+
+
+ {/* Window controls: 10x10 glyphs at stroke 1; close is not red (spec 2.5). */}
+
+
window.filesmith.minimize()}
+ >
+
+
+
+
+
window.filesmith.toggleMaximize()}
+ >
+
+
+
+
+
window.filesmith.close()}
+ >
+
+
+
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/shell/crumbs.ts b/src/renderer/src/components/shell/crumbs.ts
new file mode 100644
index 0000000..737e2a4
--- /dev/null
+++ b/src/renderer/src/components/shell/crumbs.ts
@@ -0,0 +1,23 @@
+import { tabById, type TabId, type ToolCard } from '@shared/tabs'
+import { groupLabel } from '../queueGroups'
+
+export type CrumbAction = 'tools' | 'clearSelection'
+export interface Crumb {
+ label: string
+ action?: CrumbAction
+ ariaLabel?: string
+}
+
+/** Title-bar breadcrumb per context (spec 3.1). */
+export function crumbsFor(tab: TabId, card: ToolCard | null, group: string | null): Crumb[] {
+ if (tab === 'tools')
+ return card
+ ? [
+ { label: 'pdf tools', action: 'tools', ariaLabel: 'Back to Tools' },
+ { label: card.label.toLowerCase() }
+ ]
+ : [{ label: 'pdf tools' }]
+ const label = tabById(tab).label.toLowerCase()
+ if (tab === 'generate' || tab === 'completed' || tab === 'settings' || !group) return [{ label }]
+ return [{ label, action: 'clearSelection' }, { label: groupLabel(group).toLowerCase() }]
+}
diff --git a/src/renderer/src/components/shell/railPrefs.ts b/src/renderer/src/components/shell/railPrefs.ts
new file mode 100644
index 0000000..3c8df7a
--- /dev/null
+++ b/src/renderer/src/components/shell/railPrefs.ts
@@ -0,0 +1,25 @@
+import type { TabId } from '@shared/tabs'
+
+// Same localStorage keys as the old rail, so a user's order and hidden verbs
+// carry over (spec 3.2).
+export const ORDER_KEY = 'filesmith.rail.tabOrder'
+export const HIDDEN_KEY = 'filesmith.rail.tabHidden'
+
+export function normalizeOrder(saved: unknown, all: TabId[]): TabId[] {
+ if (!Array.isArray(saved)) return all
+ const kept = saved.filter((id): id is TabId => all.includes(id as TabId))
+ return [...kept, ...all.filter((id) => !kept.includes(id))]
+}
+
+export function moveItem(list: T[], from: number, to: number): T[] {
+ const next = [...list]
+ const [moved] = next.splice(from, 1)
+ next.splice(to, 0, moved)
+ return next
+}
+
+/** The verbs shown in the sidebar's top group: ordered, visible, without Tools
+ * (Tools has its own slot under the separator). */
+export function sidebarVerbs(order: TabId[], hidden: TabId[]): TabId[] {
+ return order.filter((id) => id !== 'tools' && !hidden.includes(id))
+}
diff --git a/src/renderer/src/components/shell/shortcuts.ts b/src/renderer/src/components/shell/shortcuts.ts
new file mode 100644
index 0000000..f40c5b8
--- /dev/null
+++ b/src/renderer/src/components/shell/shortcuts.ts
@@ -0,0 +1,13 @@
+import type { KeyLike } from '../queue/tableKeys'
+
+export type Shortcut = 'toggleSidebar' | 'addFiles' | 'run'
+
+/** Global shortcuts (spec 4.1): Ctrl+B sidebar, Ctrl+O add files, Ctrl+Enter run. */
+export function shortcutFor(e: KeyLike): Shortcut | null {
+ if (!(e.ctrlKey || e.metaKey) || e.altKey) return null
+ const k = e.key.toLowerCase()
+ if (k === 'b' && !e.shiftKey) return 'toggleSidebar'
+ if (k === 'o' && !e.shiftKey) return 'addFiles'
+ if (k === 'enter') return 'run'
+ return null
+}
diff --git a/src/renderer/src/components/shell/sidebarState.ts b/src/renderer/src/components/shell/sidebarState.ts
new file mode 100644
index 0000000..fbe2613
--- /dev/null
+++ b/src/renderer/src/components/shell/sidebarState.ts
@@ -0,0 +1,21 @@
+export type SidebarPref = 'expanded' | 'collapsed'
+
+/** Below this width the sidebar auto-collapses (spec 3.7), without persisting. */
+export const NARROW_PX = 1280
+
+export function isCollapsed(pref: SidebarPref, narrow: boolean, override: boolean | null): boolean {
+ if (override != null) return override
+ return narrow || pref === 'collapsed'
+}
+
+/** A toggle on a wide window flips the stored preference; on a narrow window it
+ * only overrides for as long as the window stays narrow. */
+export function afterToggle(
+ pref: SidebarPref,
+ narrow: boolean,
+ override: boolean | null
+): { pref: SidebarPref; override: boolean | null } {
+ const now = isCollapsed(pref, narrow, override)
+ if (narrow) return { pref, override: !now }
+ return { pref: now ? 'expanded' : 'collapsed', override: null }
+}
diff --git a/src/renderer/src/components/shell/statusModel.ts b/src/renderer/src/components/shell/statusModel.ts
new file mode 100644
index 0000000..948121d
--- /dev/null
+++ b/src/renderer/src/components/shell/statusModel.ts
@@ -0,0 +1,56 @@
+import { inInput, type QueueItem } from '../../state'
+
+export interface StatusSummary {
+ running: { label: string; pct: number } | null
+ /** Generate's startup line ("Starting ComfyUI"), shown with an indeterminate bar. */
+ message: string | null
+ done: string | null
+ failed: number
+}
+
+const GERUND: Record = {
+ Convert: 'Converting',
+ Compress: 'Compressing',
+ Resize: 'Resizing',
+ Upscale: 'Upscaling',
+ 'Remove BG': 'Removing backgrounds',
+ Generate: 'Generating'
+}
+
+export function verbGerund(label: string): string {
+ return GERUND[label] ?? 'Processing'
+}
+
+/** Status bar content (spec 3.5 and 6.4). `batch` is the ids of the last run in
+ * this workspace; it is renderer-only and not persisted. */
+export function statusSummary(
+ items: QueueItem[],
+ batch: string[] | null,
+ verb: string,
+ message?: string | null
+): StatusSummary {
+ const inputs = items.filter(inInput)
+ let running: StatusSummary['running'] = null
+ if (batch?.length) {
+ const ids = new Set(batch)
+ const rows = inputs.filter((i) => ids.has(i.id))
+ const live = rows.filter((i) => i.status === 'queued' || i.status === 'running')
+ if (live.length) {
+ const done = rows.filter((i) => i.status === 'done').length
+ const run = rows.filter((i) => i.status === 'running')
+ const settled = rows.filter((i) => ['done', 'failed', 'canceled'].includes(i.status)).length
+ const partial = run.reduce((s, i) => s + (i.hasProgress ? i.percent / 100 : 0), 0)
+ running = {
+ label: `${verbGerund(verb)} ${done + run.length} of ${rows.length}`,
+ pct: Math.round(((settled + partial) / rows.length) * 100)
+ }
+ }
+ }
+ const doneN = inputs.filter((i) => i.status === 'done').length
+ return {
+ running,
+ message: message || null,
+ done: doneN ? `${doneN} of ${inputs.length} done` : null,
+ failed: inputs.filter((i) => i.status === 'failed').length
+ }
+}
diff --git a/src/renderer/src/components/shell/useRailPrefs.ts b/src/renderer/src/components/shell/useRailPrefs.ts
new file mode 100644
index 0000000..bacd504
--- /dev/null
+++ b/src/renderer/src/components/shell/useRailPrefs.ts
@@ -0,0 +1,45 @@
+import { useCallback, useState } from 'react'
+import { TABS, type TabId } from '@shared/tabs'
+import { HIDDEN_KEY, normalizeOrder, ORDER_KEY } from './railPrefs'
+
+const ALL = TABS.map((t) => t.id)
+
+function read(key: string): unknown {
+ try {
+ return JSON.parse(localStorage.getItem(key) ?? 'null')
+ } catch {
+ return null
+ }
+}
+function write(key: string, v: unknown): void {
+ try {
+ localStorage.setItem(key, JSON.stringify(v))
+ } catch {
+ /* storage blocked: keep the in-memory value */
+ }
+}
+
+export function useRailPrefs(): {
+ order: TabId[]
+ hidden: TabId[]
+ setOrder: (o: TabId[]) => void
+ toggleHidden: (id: TabId) => void
+} {
+ const [order, setOrderState] = useState(() => normalizeOrder(read(ORDER_KEY), ALL))
+ const [hidden, setHidden] = useState(() => {
+ const h = read(HIDDEN_KEY)
+ return Array.isArray(h) ? h.filter((id): id is TabId => ALL.includes(id as TabId)) : []
+ })
+ const setOrder = useCallback((o: TabId[]) => {
+ setOrderState(o)
+ write(ORDER_KEY, o)
+ }, [])
+ const toggleHidden = useCallback((id: TabId) => {
+ setHidden((prev) => {
+ const next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
+ write(HIDDEN_KEY, next)
+ return next
+ })
+ }, [])
+ return { order, hidden, setOrder, toggleHidden }
+}
diff --git a/src/renderer/src/components/shell/useSidebar.ts b/src/renderer/src/components/shell/useSidebar.ts
new file mode 100644
index 0000000..380360e
--- /dev/null
+++ b/src/renderer/src/components/shell/useSidebar.ts
@@ -0,0 +1,42 @@
+import { useCallback, useEffect, useState } from 'react'
+import { afterToggle, isCollapsed, NARROW_PX, type SidebarPref } from './sidebarState'
+
+const KEY = 'filesmith.sidebar'
+
+function readPref(): SidebarPref {
+ try {
+ return localStorage.getItem(KEY) === 'collapsed' ? 'collapsed' : 'expanded'
+ } catch {
+ return 'expanded'
+ }
+}
+
+export function useSidebar(): { collapsed: boolean; toggle: () => void } {
+ const [pref, setPref] = useState(readPref)
+ const [narrow, setNarrow] = useState(() => window.innerWidth < NARROW_PX)
+ // An override only applies to the width class it was made in, so crossing
+ // the breakpoint restores the stored preference without an effect.
+ const [override, setOverride] = useState<{ narrow: boolean; value: boolean } | null>(null)
+
+ useEffect(() => {
+ const on = (): void => setNarrow(window.innerWidth < NARROW_PX)
+ window.addEventListener('resize', on)
+ return () => window.removeEventListener('resize', on)
+ }, [])
+
+ const ov = override && override.narrow === narrow ? override.value : null
+ const toggle = useCallback(() => {
+ const next = afterToggle(pref, narrow, ov)
+ if (next.pref !== pref) {
+ setPref(next.pref)
+ try {
+ localStorage.setItem(KEY, next.pref)
+ } catch {
+ /* storage blocked: the toggle still works for this session */
+ }
+ }
+ setOverride(next.override == null ? null : { narrow, value: next.override })
+ }, [pref, narrow, ov])
+
+ return { collapsed: isCollapsed(pref, narrow, ov), toggle }
+}
diff --git a/src/renderer/src/components/ui/Button.tsx b/src/renderer/src/components/ui/Button.tsx
new file mode 100644
index 0000000..f14461f
--- /dev/null
+++ b/src/renderer/src/components/ui/Button.tsx
@@ -0,0 +1,72 @@
+import type { ButtonHTMLAttributes, JSX, ReactNode } from 'react'
+import type { IconName } from '@shared/icons'
+import { Icon } from '../icons/Icon'
+
+type B = ButtonHTMLAttributes
+
+export function AddFilesButton({ children = 'Add files', ...rest }: B): JSX.Element {
+ return (
+
+
+ {children}
+
+ )
+}
+
+export function IconButton({
+ icon,
+ label,
+ ...rest
+}: B & { icon: IconName; label: string }): JSX.Element {
+ return (
+
+
+
+ )
+}
+
+export function SmallButton({
+ icon,
+ children,
+ ...rest
+}: B & { icon?: IconName; children: ReactNode }): JSX.Element {
+ return (
+
+ {icon && }
+ {children}
+
+ )
+}
+
+export function RowAction({
+ icon,
+ label,
+ ghost,
+ ...rest
+}: B & { icon: IconName; label: string; ghost?: boolean }): JSX.Element {
+ return (
+
+
+
+ )
+}
+
+export function PrimaryButton({
+ icon = 'play',
+ children,
+ className,
+ ...rest
+}: B & { icon?: IconName; children: ReactNode }): JSX.Element {
+ return (
+
+
+ {children}
+
+ )
+}
diff --git a/src/renderer/src/components/ui/Checkbox.tsx b/src/renderer/src/components/ui/Checkbox.tsx
new file mode 100644
index 0000000..66a1ddd
--- /dev/null
+++ b/src/renderer/src/components/ui/Checkbox.tsx
@@ -0,0 +1,42 @@
+import type { JSX } from 'react'
+import { Icon } from '../icons/Icon'
+
+export function Checkbox({
+ checked,
+ onChange,
+ label,
+ size = 'sm',
+ focusable = false
+}: {
+ checked: boolean | 'mixed'
+ onChange: () => void
+ label: string
+ size?: 'sm' | 'md'
+ /** Table rows take focus themselves, so row boxes are tabindex -1 (spec 4.1). */
+ focusable?: boolean
+}): JSX.Element {
+ const on = checked === true
+ return (
+ {
+ e.stopPropagation()
+ e.preventDefault()
+ onChange()
+ }}
+ onKeyDown={(e) => {
+ if (e.key === ' ' || e.key === 'Enter') {
+ e.preventDefault()
+ e.stopPropagation()
+ onChange()
+ }
+ }}
+ >
+ {on && }
+
+ )
+}
diff --git a/src/renderer/src/components/ui/ChipGrid.tsx b/src/renderer/src/components/ui/ChipGrid.tsx
new file mode 100644
index 0000000..97c986a
--- /dev/null
+++ b/src/renderer/src/components/ui/ChipGrid.tsx
@@ -0,0 +1,71 @@
+import { useRef, type JSX } from 'react'
+import { rovingIndex } from './roving'
+
+export interface Chip {
+ value: T
+ label: string
+ disabled?: boolean
+ title?: string
+}
+
+export function ChipGrid({
+ value,
+ chips,
+ onChange,
+ label,
+ cols = 4
+}: {
+ value: T | null
+ chips: Chip[]
+ onChange: (v: T) => void
+ label: string
+ cols?: number
+}): JSX.Element {
+ const refs = useRef<(HTMLButtonElement | null)[]>([])
+ const idx = chips.findIndex((c) => c.value === value && !c.disabled)
+ const focusIdx =
+ idx >= 0
+ ? idx
+ : Math.max(
+ 0,
+ chips.findIndex((c) => !c.disabled)
+ )
+ return (
+
+ {chips.map((c, i) => (
+ {
+ refs.current[i] = el
+ }}
+ type="button"
+ role="radio"
+ aria-checked={i === idx}
+ aria-disabled={c.disabled || undefined}
+ tabIndex={i === focusIdx ? 0 : -1}
+ title={c.title}
+ className={`chip${i === idx ? ' on' : ''}`}
+ onClick={() => !c.disabled && onChange(c.value)}
+ onKeyDown={(e) => {
+ let n = rovingIndex(e.key, i, chips.length)
+ if (n == null) return
+ e.preventDefault()
+ const dir = e.key === 'ArrowLeft' || e.key === 'End' ? -1 : 1
+ for (let s = 0; s < chips.length && chips[n].disabled; s++)
+ n = (n + dir + chips.length) % chips.length
+ if (chips[n].disabled) return
+ onChange(chips[n].value)
+ refs.current[n]?.focus()
+ }}
+ >
+ {c.label}
+
+ ))}
+
+ )
+}
diff --git a/src/renderer/src/components/ui/EstimateCard.tsx b/src/renderer/src/components/ui/EstimateCard.tsx
new file mode 100644
index 0000000..76d075e
--- /dev/null
+++ b/src/renderer/src/components/ui/EstimateCard.tsx
@@ -0,0 +1,34 @@
+import type { JSX } from 'react'
+import { formatBytes } from '@shared/compress'
+import { Icon } from '../icons/Icon'
+import { formatPct, pctChange } from '../queue/rowModel'
+
+export function EstimateCard({
+ from,
+ to,
+ files
+}: {
+ from: number
+ to: number
+ files: number
+}): JSX.Element {
+ const pct = pctChange(from, to)
+ const noun = `${files} file${files === 1 ? '' : 's'}`
+ const label = `Estimated output for ${noun}: ${formatBytes(from)} to about ${formatBytes(to)}${
+ pct != null ? `, ${formatPct(pct)}` : ''
+ }`
+ return (
+
+
+