diff --git a/CLAUDE.md b/CLAUDE.md index 795eb0a..c6c587e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,8 @@ 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`. +**View sizes (2026-10-05):** `docs/mockups/view-zoom/04-explorer-style.html`; spec `docs/superpowers/specs/2026-10-05-view-sizes-design.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 diff --git a/docs/design/redesign-direction.md b/docs/design/redesign-direction.md index 5fa67f3..f7d1f8d 100644 --- a/docs/design/redesign-direction.md +++ b/docs/design/redesign-direction.md @@ -32,3 +32,6 @@ Mockup rounds live in `docs/mockups/terminal*/` (each has an `index.html` galler 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. +- View sizes (2026-10-05): the files view has Explorer-style sizes (Details, Tiles, Medium / Large / Extra + large icons), Ctrl+wheel and a View menu. Chosen: `docs/mockups/view-zoom/04-explorer-style.html`; spec + `docs/superpowers/specs/2026-10-05-view-sizes-design.md`. diff --git a/docs/mockups/cli-settings-skill.png b/docs/mockups/cli-settings-skill.png index 7f48ded..b46a0be 100644 Binary files a/docs/mockups/cli-settings-skill.png and b/docs/mockups/cli-settings-skill.png differ diff --git a/docs/mockups/terminal-v5/shots/impl-10-s2.png b/docs/mockups/terminal-v5/shots/impl-10-s2.png index 34c1d9b..cdbb309 100644 Binary files a/docs/mockups/terminal-v5/shots/impl-10-s2.png 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 index 1f5e735..2366a40 100644 Binary files a/docs/mockups/terminal-v5/shots/impl-collapsed.png 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 index 5cec0d2..4ec458d 100644 Binary files a/docs/mockups/terminal-v5/shots/impl-completed.png 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 index 2f36ef7..9a8e65c 100644 Binary files a/docs/mockups/terminal-v5/shots/impl-empty.png 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 index 4a89abc..14dce8e 100644 Binary files a/docs/mockups/terminal-v5/shots/impl-generate.png 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 index 15619f2..4a78eb7 100644 Binary files a/docs/mockups/terminal-v5/shots/impl-info.png 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 index 87743bc..5e2f643 100644 Binary files a/docs/mockups/terminal-v5/shots/impl-menu.png 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 index 8fc6514..106a7fd 100644 Binary files a/docs/mockups/terminal-v5/shots/impl-preview.png 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 index 479cc1e..711a975 100644 Binary files a/docs/mockups/terminal-v5/shots/impl-settings.png 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 index 198948f..5c8fb2b 100644 Binary files a/docs/mockups/terminal-v5/shots/impl-tools.png and b/docs/mockups/terminal-v5/shots/impl-tools.png differ diff --git a/docs/mockups/view-zoom/01-steps-with-toast.html b/docs/mockups/view-zoom/01-steps-with-toast.html new file mode 100644 index 0000000..e4bbdb6 --- /dev/null +++ b/docs/mockups/view-zoom/01-steps-with-toast.html @@ -0,0 +1,645 @@ + + + + + + +Filesmith View Zoom + + + + + + + + + +
+
Filesmith
+ + +
+ + + +
+
+ +
+ + +
+
+ +
+ 9 files, 1 selected + +
+
+ + + +
+
+
+
name
+
kind
+
size
+
result
+
status
+
+ +
+
+
+
9 files
+
+
380 MB
+
214 MB41 MBso far
+
3 of 9 done
+
+
List
+
+
+ + +
+ + + + diff --git a/docs/mockups/view-zoom/02-slider-in-toolbar.html b/docs/mockups/view-zoom/02-slider-in-toolbar.html new file mode 100644 index 0000000..0da4cb3 --- /dev/null +++ b/docs/mockups/view-zoom/02-slider-in-toolbar.html @@ -0,0 +1,592 @@ + + + + + + +Filesmith View Zoom Slider + + + + + + + + + +
+
Filesmith
+ + +
+ + + +
+
+ +
+ + +
+
+ +
+ 9 files, 1 selected +
+ + +
+ +
+ +
+
+
+ +
+ +
+
+
+
+ + +
+ + + + diff --git a/docs/mockups/view-zoom/03-list-grid-toggle-plus-zoom.html b/docs/mockups/view-zoom/03-list-grid-toggle-plus-zoom.html new file mode 100644 index 0000000..98ade6f --- /dev/null +++ b/docs/mockups/view-zoom/03-list-grid-toggle-plus-zoom.html @@ -0,0 +1,519 @@ + + + + + + +Filesmith View Zoom + + + + + + + + + +
+
Filesmith
+ + +
+ + + +
+
+ +
+ + +
+
+ +
+ 9 files, 1 selected +
+ + list 2/3 + +
+
+
+ + +
+
+
+
+ +
+
+
+
+ + +
+ + + + diff --git a/docs/mockups/view-zoom/04-explorer-style.html b/docs/mockups/view-zoom/04-explorer-style.html new file mode 100644 index 0000000..81ec660 --- /dev/null +++ b/docs/mockups/view-zoom/04-explorer-style.html @@ -0,0 +1,615 @@ + + + + + + +Filesmith View Sizes + + + + + + + + + +
+
Filesmith
+ + +
+ + + +
+
+ +
+ + +
+
+ +
+ 9 files, 1 selected + +
+
+
+
+
+
name
+
kind
+
size
+
result
+
status
+
+
+
+
+ +
+
+ + +
+ + + + diff --git a/docs/mockups/view-zoom/index.html b/docs/mockups/view-zoom/index.html new file mode 100644 index 0000000..79a9649 --- /dev/null +++ b/docs/mockups/view-zoom/index.html @@ -0,0 +1,33 @@ + + + + + +View Zoom Mockups + + + +

Filesmith view zoom

+

Four concepts for resizing the files view. Each screenshot opens the live mockup at that size: hold Ctrl and scroll inside the files view (or Ctrl+= / Ctrl+-, Ctrl+0 resets).

+

01 Steps with toast

Five fixed steps from compact list to large grid; a brief toast names the size, a view dropdown at the toolbar end sets it without a wheel.

01 Steps with toast, compact list#size=0 compact list01 Steps with toast, list#size=1 list01 Steps with toast, large list#size=2 large list01 Steps with toast, grid S#size=3 grid S01 Steps with toast, grid L#size=4 grid L01 Steps with toast, view menuview menu

02 Slider in toolbar

A list-to-grid slider with a size label sits in the files toolbar and always shows the current size; Ctrl+wheel snaps it between six stops.

02 Slider in toolbar, compact list#size=0 compact list02 Slider in toolbar, list#size=1 list02 Slider in toolbar, large list#size=2 large list02 Slider in toolbar, small grid#size=3 small grid02 Slider in toolbar, grid#size=4 grid02 Slider in toolbar, large grid#size=5 large grid

03 List/Grid toggle plus zoom

A List/Grid segmented toggle picks the mode; Ctrl+wheel changes density inside it (3 list sizes, 3 grid sizes) with a -/+ readout.

03 List/Grid toggle plus zoom, list 1/3#size=0 list 1/303 List/Grid toggle plus zoom, list 2/3#size=1 list 2/303 List/Grid toggle plus zoom, list 3/3#size=2 list 3/303 List/Grid toggle plus zoom, grid 1/3#size=3 grid 1/303 List/Grid toggle plus zoom, grid 2/3#size=4 grid 2/303 List/Grid toggle plus zoom, grid 3/3#size=5 grid 3/3

04 Explorer style

Sizes named like Windows File Explorer (Details, Tiles, Medium, Large, Extra large icons) in a View menu with Ctrl+Shift+1..5.

04 Explorer style, details#size=0 details04 Explorer style, tiles#size=1 tiles04 Explorer style, medium icons#size=2 medium icons04 Explorer style, large icons#size=3 large icons04 Explorer style, extra large icons#size=4 extra large icons04 Explorer style, view menuview menu
+ + diff --git a/docs/mockups/view-zoom/shots/01-steps-with-toast-menu.png b/docs/mockups/view-zoom/shots/01-steps-with-toast-menu.png new file mode 100644 index 0000000..01ed266 Binary files /dev/null and b/docs/mockups/view-zoom/shots/01-steps-with-toast-menu.png differ diff --git a/docs/mockups/view-zoom/shots/01-steps-with-toast-size0.png b/docs/mockups/view-zoom/shots/01-steps-with-toast-size0.png new file mode 100644 index 0000000..be40490 Binary files /dev/null and b/docs/mockups/view-zoom/shots/01-steps-with-toast-size0.png differ diff --git a/docs/mockups/view-zoom/shots/01-steps-with-toast-size1.png b/docs/mockups/view-zoom/shots/01-steps-with-toast-size1.png new file mode 100644 index 0000000..48c56e4 Binary files /dev/null and b/docs/mockups/view-zoom/shots/01-steps-with-toast-size1.png differ diff --git a/docs/mockups/view-zoom/shots/01-steps-with-toast-size2.png b/docs/mockups/view-zoom/shots/01-steps-with-toast-size2.png new file mode 100644 index 0000000..52805f7 Binary files /dev/null and b/docs/mockups/view-zoom/shots/01-steps-with-toast-size2.png differ diff --git a/docs/mockups/view-zoom/shots/01-steps-with-toast-size3.png b/docs/mockups/view-zoom/shots/01-steps-with-toast-size3.png new file mode 100644 index 0000000..82ac274 Binary files /dev/null and b/docs/mockups/view-zoom/shots/01-steps-with-toast-size3.png differ diff --git a/docs/mockups/view-zoom/shots/01-steps-with-toast-size4.png b/docs/mockups/view-zoom/shots/01-steps-with-toast-size4.png new file mode 100644 index 0000000..95a4441 Binary files /dev/null and b/docs/mockups/view-zoom/shots/01-steps-with-toast-size4.png differ diff --git a/docs/mockups/view-zoom/shots/02-slider-in-toolbar-size0.png b/docs/mockups/view-zoom/shots/02-slider-in-toolbar-size0.png new file mode 100644 index 0000000..bf0f216 Binary files /dev/null and b/docs/mockups/view-zoom/shots/02-slider-in-toolbar-size0.png differ diff --git a/docs/mockups/view-zoom/shots/02-slider-in-toolbar-size1.png b/docs/mockups/view-zoom/shots/02-slider-in-toolbar-size1.png new file mode 100644 index 0000000..3275743 Binary files /dev/null and b/docs/mockups/view-zoom/shots/02-slider-in-toolbar-size1.png differ diff --git a/docs/mockups/view-zoom/shots/02-slider-in-toolbar-size2.png b/docs/mockups/view-zoom/shots/02-slider-in-toolbar-size2.png new file mode 100644 index 0000000..681beb3 Binary files /dev/null and b/docs/mockups/view-zoom/shots/02-slider-in-toolbar-size2.png differ diff --git a/docs/mockups/view-zoom/shots/02-slider-in-toolbar-size3.png b/docs/mockups/view-zoom/shots/02-slider-in-toolbar-size3.png new file mode 100644 index 0000000..cbfe17b Binary files /dev/null and b/docs/mockups/view-zoom/shots/02-slider-in-toolbar-size3.png differ diff --git a/docs/mockups/view-zoom/shots/02-slider-in-toolbar-size4.png b/docs/mockups/view-zoom/shots/02-slider-in-toolbar-size4.png new file mode 100644 index 0000000..119af1d Binary files /dev/null and b/docs/mockups/view-zoom/shots/02-slider-in-toolbar-size4.png differ diff --git a/docs/mockups/view-zoom/shots/02-slider-in-toolbar-size5.png b/docs/mockups/view-zoom/shots/02-slider-in-toolbar-size5.png new file mode 100644 index 0000000..4e1bead Binary files /dev/null and b/docs/mockups/view-zoom/shots/02-slider-in-toolbar-size5.png differ diff --git a/docs/mockups/view-zoom/shots/03-list-grid-toggle-plus-zoom-size0.png b/docs/mockups/view-zoom/shots/03-list-grid-toggle-plus-zoom-size0.png new file mode 100644 index 0000000..db81f6c Binary files /dev/null and b/docs/mockups/view-zoom/shots/03-list-grid-toggle-plus-zoom-size0.png differ diff --git a/docs/mockups/view-zoom/shots/03-list-grid-toggle-plus-zoom-size1.png b/docs/mockups/view-zoom/shots/03-list-grid-toggle-plus-zoom-size1.png new file mode 100644 index 0000000..710aadc Binary files /dev/null and b/docs/mockups/view-zoom/shots/03-list-grid-toggle-plus-zoom-size1.png differ diff --git a/docs/mockups/view-zoom/shots/03-list-grid-toggle-plus-zoom-size2.png b/docs/mockups/view-zoom/shots/03-list-grid-toggle-plus-zoom-size2.png new file mode 100644 index 0000000..a68f795 Binary files /dev/null and b/docs/mockups/view-zoom/shots/03-list-grid-toggle-plus-zoom-size2.png differ diff --git a/docs/mockups/view-zoom/shots/03-list-grid-toggle-plus-zoom-size3.png b/docs/mockups/view-zoom/shots/03-list-grid-toggle-plus-zoom-size3.png new file mode 100644 index 0000000..0279aff Binary files /dev/null and b/docs/mockups/view-zoom/shots/03-list-grid-toggle-plus-zoom-size3.png differ diff --git a/docs/mockups/view-zoom/shots/03-list-grid-toggle-plus-zoom-size4.png b/docs/mockups/view-zoom/shots/03-list-grid-toggle-plus-zoom-size4.png new file mode 100644 index 0000000..c8ba178 Binary files /dev/null and b/docs/mockups/view-zoom/shots/03-list-grid-toggle-plus-zoom-size4.png differ diff --git a/docs/mockups/view-zoom/shots/03-list-grid-toggle-plus-zoom-size5.png b/docs/mockups/view-zoom/shots/03-list-grid-toggle-plus-zoom-size5.png new file mode 100644 index 0000000..83f1bb1 Binary files /dev/null and b/docs/mockups/view-zoom/shots/03-list-grid-toggle-plus-zoom-size5.png differ diff --git a/docs/mockups/view-zoom/shots/04-explorer-style-menu.png b/docs/mockups/view-zoom/shots/04-explorer-style-menu.png new file mode 100644 index 0000000..19fb089 Binary files /dev/null and b/docs/mockups/view-zoom/shots/04-explorer-style-menu.png differ diff --git a/docs/mockups/view-zoom/shots/04-explorer-style-size0.png b/docs/mockups/view-zoom/shots/04-explorer-style-size0.png new file mode 100644 index 0000000..c5e7875 Binary files /dev/null and b/docs/mockups/view-zoom/shots/04-explorer-style-size0.png differ diff --git a/docs/mockups/view-zoom/shots/04-explorer-style-size1.png b/docs/mockups/view-zoom/shots/04-explorer-style-size1.png new file mode 100644 index 0000000..213dc11 Binary files /dev/null and b/docs/mockups/view-zoom/shots/04-explorer-style-size1.png differ diff --git a/docs/mockups/view-zoom/shots/04-explorer-style-size2.png b/docs/mockups/view-zoom/shots/04-explorer-style-size2.png new file mode 100644 index 0000000..8bb9a92 Binary files /dev/null and b/docs/mockups/view-zoom/shots/04-explorer-style-size2.png differ diff --git a/docs/mockups/view-zoom/shots/04-explorer-style-size3.png b/docs/mockups/view-zoom/shots/04-explorer-style-size3.png new file mode 100644 index 0000000..5749c70 Binary files /dev/null and b/docs/mockups/view-zoom/shots/04-explorer-style-size3.png differ diff --git a/docs/mockups/view-zoom/shots/04-explorer-style-size4.png b/docs/mockups/view-zoom/shots/04-explorer-style-size4.png new file mode 100644 index 0000000..9964920 Binary files /dev/null and b/docs/mockups/view-zoom/shots/04-explorer-style-size4.png differ diff --git a/docs/mockups/view-zoom/shots/_index.png b/docs/mockups/view-zoom/shots/_index.png new file mode 100644 index 0000000..cb8f02c Binary files /dev/null and b/docs/mockups/view-zoom/shots/_index.png differ diff --git a/docs/mockups/view-zoom/shots/impl-menu.png b/docs/mockups/view-zoom/shots/impl-menu.png new file mode 100644 index 0000000..ed83b3a Binary files /dev/null and b/docs/mockups/view-zoom/shots/impl-menu.png differ diff --git a/docs/mockups/view-zoom/shots/impl-size0.png b/docs/mockups/view-zoom/shots/impl-size0.png new file mode 100644 index 0000000..a39c776 Binary files /dev/null and b/docs/mockups/view-zoom/shots/impl-size0.png differ diff --git a/docs/mockups/view-zoom/shots/impl-size1.png b/docs/mockups/view-zoom/shots/impl-size1.png new file mode 100644 index 0000000..49a52ff Binary files /dev/null and b/docs/mockups/view-zoom/shots/impl-size1.png differ diff --git a/docs/mockups/view-zoom/shots/impl-size2.png b/docs/mockups/view-zoom/shots/impl-size2.png new file mode 100644 index 0000000..60e3239 Binary files /dev/null and b/docs/mockups/view-zoom/shots/impl-size2.png differ diff --git a/docs/mockups/view-zoom/shots/impl-size3.png b/docs/mockups/view-zoom/shots/impl-size3.png new file mode 100644 index 0000000..3ab1f04 Binary files /dev/null and b/docs/mockups/view-zoom/shots/impl-size3.png differ diff --git a/docs/mockups/view-zoom/shots/impl-size4.png b/docs/mockups/view-zoom/shots/impl-size4.png new file mode 100644 index 0000000..bd9ab59 Binary files /dev/null and b/docs/mockups/view-zoom/shots/impl-size4.png differ diff --git a/docs/mockups/view-zoom/shots/impl-size5.png b/docs/mockups/view-zoom/shots/impl-size5.png new file mode 100644 index 0000000..ea19118 Binary files /dev/null and b/docs/mockups/view-zoom/shots/impl-size5.png differ diff --git a/docs/superpowers/plans/2026-10-05-view-sizes.md b/docs/superpowers/plans/2026-10-05-view-sizes.md new file mode 100644 index 0000000..6859dd9 --- /dev/null +++ b/docs/superpowers/plans/2026-10-05-view-sizes.md @@ -0,0 +1,2589 @@ +# View Sizes 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:** Give the files view five Explorer-style view sizes (Details, Tiles, Medium icons, Large icons, Extra large icons), switched by Ctrl+wheel, keyboard shortcuts and a View menu button, persisted per app, with every row behaviour (selection, right-click menu, group headers, states, open, keyboard, drop) kept in each size. + +**Architecture:** Pure, Vitest-covered modules hold the logic (`viewSize.ts`: sizes, stepping, key mapping, wheel accumulation, thumbnail px; `gridKeys.ts`: 2D keyboard navigation; `cardModel.ts`: the compact status line). A `useViewSize` hook persists the size in `localStorage` like `useSidebar`. `QueueTable` keeps one roving-focus/selection/menu pipeline and switches only the row renderer (`QueueRow` / `QueueTile` / `QueueCard`, sharing a `RowProps` shape); a `ViewMenu` lives in the toolbar; App wires the keys and lazily requests 256px thumbnails for the two largest sizes through the existing `thumbnail` IPC (main unchanged). + +**Tech Stack:** Electron 43, React 19 + TypeScript strict, Vite (electron-vite 5), Tailwind v4 + plain CSS in `src/renderer/src/theme/*.css`, Vitest, Playwright Test (`_electron`). + +**Spec:** `docs/superpowers/specs/2026-10-05-view-sizes-design.md` (read it fully; section numbers below refer to it). Target mockup: `docs/mockups/view-zoom/04-explorer-style.html` (copy its CSS values; shots in `docs/mockups/view-zoom/shots/04-explorer-style-*.png`). + +## Global Constraints + +- Branch `feat/36-view-sizes` (already checked out). Do not switch branches, do not push, never touch `main`. One PR at the end, opened by the orchestrator, not by a task. +- Commits: `type(scope): subject`, body line `Refs #36`, last line `Co-Authored-By: Claude Opus 5.5 `. +- No em-dashes (U+2014) anywhere: code, comments, CSS, docs, commit messages. `test/no-em-dash.test.ts` scans the new files (Task 8). +- Strict monochrome: every colour literal outside `theme/tokens.css` is a grey (r=g=b); use the tokens (`--bg-0`, `--hover`, `--selected`, `--line`, `--line-strong`, `--fg1..3`, `--inv-*`). Square corners. `test/monochrome-source.test.ts` enforces it. +- `.tsx` files export React components only; pure helpers and types go in `.ts` files with Vitest tests in `test/*.test.ts`. +- Size ids and labels exactly: `details` Details, `tiles` Tiles, `medium` Medium icons, `large` Large icons, `xl` Extra large icons. Shortcuts Ctrl+Shift+1..5 in that order. Storage key `filesmith.viewSize`. +- Wheel: threshold 40px, at most one step per 90ms, accumulation dropped after 250ms idle, deltaMode 1 x40, deltaMode 2 x400; wheel up (negative deltaY) = bigger. +- Thumbnails: 128px for Details/Tiles/Medium, 256px for Large/Extra large. +- Fade 150ms `cubic-bezier(.2,.7,.2,1)` from opacity .25; flash 450ms. None of it under `prefers-reduced-motion: reduce`. +- Version 0.6.0 -> 0.7.0 (Task 8). +- Before any e2e run, close a running Filesmith: `pwsh -NoProfile -Command "Get-Process Filesmith -ErrorAction SilentlyContinue | Stop-Process -Force"`, then `npm run build`. + +## Review Focus + +- **Focus after a size change:** a keyboard user on a row presses Ctrl+Shift+3; the row element is replaced by a card. Expected: focus lands on the same item's card and it is scrolled into view, not lost to ``. Pinned by the e2e "keeps focus" test in Task 7 (implemented in Task 4). +- **Non-US keyboards and the numpad:** on Nordic layouts `+` and `-` are unshifted keys with other codes and Shift+1 is `!`. Expected: `+`, numpad `+`, Ctrl+Shift+= all step bigger; Ctrl+Shift+1..5 work by `code`. Pinned in Task 1's `viewKeyFor` tests. +- **Electron's default menu zoom accelerators** (Ctrl+= / Ctrl+- / Ctrl+0 zoom the whole page). Expected: the page zoom factor stays 1 because the files view handler calls `preventDefault`. Pinned by the e2e zoom-factor assertion in Task 7. +- **A stored value from another version or hand-edited** (`"huge"`, `"3"`, empty). Expected: Details, no crash. Pinned in Task 1's `parseViewSize` tests. +- **Grids with a short last row and several convert groups:** Down from the row above a short last row, Up/Down across group headers. Expected: Explorer-like (last item; nearest column in the neighbouring group). Pinned in Task 2's `gridNeighbor` tests. + +--- + +## File map + +``` +src/renderer/src/components/queue/ + viewSize.ts NEW sizes, stepSize, parseViewSize, viewKeyFor, wheelDelta, wheelStep, thumbPx + gridKeys.ts NEW gridNeighbor, moveFor + cardModel.ts NEW cardLine, kindIcon + rowProps.ts NEW RowProps (shared row renderer props, types only) + useViewSize.ts NEW persisted size hook + ViewMenu.tsx NEW toolbar View button + menu + Thumb.tsx NEW thumbnail / placeholder for tiles and cards + QueueTile.tsx NEW Tiles renderer + QueueCard.tsx NEW icon-grid renderer + QueueTable.tsx MOD size switch, grid nav, wheel, fade, focus keep + QueueToolbar.tsx MOD ViewMenu slot + StatusCell.tsx MOD export StatusBadge + ResultCell.tsx MOD export ResultText + TotalsRow.tsx MOD flat variant + tableKeys.ts MOD Left/Right +src/renderer/src/components/icons/shapes.ts MOD 6 icons +src/shared/icons.ts MOD 6 names +src/renderer/src/theme/viewsizes.css NEW +src/renderer/src/index.css MOD import +src/renderer/src/App.tsx MOD hook, keys, big thumbs, props +test/view-size.test.ts, test/grid-keys.test.ts, test/card-model.test.ts NEW +test/table-keys.test.ts, test/icons.test.ts, test/no-em-dash.test.ts MOD +e2e/viewsizes.spec.ts NEW; e2e/ui.spec.ts, e2e/visual.spec.ts MOD +package.json, package-lock.json (0.7.0), docs/design/redesign-direction.md, CLAUDE.md MOD +``` + +--- + +### Task 1: View-size model + +**Files:** +- Create: `src/renderer/src/components/queue/viewSize.ts` +- Test: `test/view-size.test.ts` + +**Interfaces:** +- Consumes: `KeyLike` from `src/renderer/src/components/queue/tableKeys.ts`. (`ViewIcon` is a string-literal union; Task 3 adds the same five names to `ICON_NAMES`, after which a `ViewIcon` is assignable to `IconName` and can be passed to ``.) +- Produces: + - `type ViewSize = 'details' | 'tiles' | 'medium' | 'large' | 'xl'` + - `interface ViewSizeDef { id: ViewSize; label: string; icon: ViewIcon; digit: number }`, `type ViewIcon = 'view-details' | 'view-tiles' | 'view-medium' | 'view-large' | 'view-xl'` + - `VIEW_SIZES: readonly ViewSizeDef[]`, `DEFAULT_VIEW: ViewSize`, `VIEW_KEY = 'filesmith.viewSize'` + - `viewDef(s: ViewSize): ViewSizeDef`, `stepSize(s: ViewSize, delta: number): ViewSize`, `parseViewSize(v: unknown): ViewSize` + - `type ViewKey = { kind: 'step'; delta: 1 | -1 } | { kind: 'set'; size: ViewSize }`, `interface ViewKeyLike extends KeyLike { code: string }`, `viewKeyFor(e: ViewKeyLike): ViewKey | null` + - `interface WheelState { acc: number; at: number; last: number }`, `WHEEL_IDLE: WheelState`, `wheelDelta(deltaY: number, deltaMode: number): number`, `wheelStep(s: WheelState, delta: number, now: number): { state: WheelState; step: -1 | 0 | 1 }` + - `thumbPx(s: ViewSize): 128 | 256` + +- [ ] **Step 1: Write the failing test** + +`test/view-size.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { + DEFAULT_VIEW, + parseViewSize, + stepSize, + thumbPx, + VIEW_KEY, + VIEW_SIZES, + viewDef, + viewKeyFor, + wheelDelta, + WHEEL_IDLE, + wheelStep, + type ViewKeyLike +} from '../src/renderer/src/components/queue/viewSize' + +const k = (key: string, code: string, o: Partial> = {}): ViewKeyLike => ({ + key, + code, + ctrlKey: true, + metaKey: false, + shiftKey: false, + altKey: false, + ...o +}) + +describe('view sizes', () => { + it('lists the five Explorer sizes in order with their Ctrl+Shift digit', () => { + expect(VIEW_SIZES.map((s) => [s.id, s.label, s.digit])).toEqual([ + ['details', 'Details', 1], + ['tiles', 'Tiles', 2], + ['medium', 'Medium icons', 3], + ['large', 'Large icons', 4], + ['xl', 'Extra large icons', 5] + ]) + expect(DEFAULT_VIEW).toBe('details') + expect(VIEW_KEY).toBe('filesmith.viewSize') + expect(viewDef('large').icon).toBe('view-large') + }) + it('steps one size and clamps at both ends', () => { + expect(stepSize('details', 1)).toBe('tiles') + expect(stepSize('medium', -1)).toBe('tiles') + expect(stepSize('xl', 1)).toBe('xl') + expect(stepSize('details', -1)).toBe('details') + }) + it('reads a stored value, falling back to Details for anything unknown', () => { + expect(parseViewSize('large')).toBe('large') + for (const bad of [null, undefined, '', 'huge', '3', 3, {}]) expect(parseViewSize(bad)).toBe('details') + }) + it('gives 256px thumbnails only to the two largest sizes', () => { + expect(VIEW_SIZES.map((s) => thumbPx(s.id))).toEqual([128, 128, 128, 256, 256]) + }) +}) + +describe('view keys', () => { + it('maps Ctrl+= / Ctrl++ / numpad + / Ctrl+Shift+= to bigger', () => { + expect(viewKeyFor(k('=', 'Equal'))).toEqual({ kind: 'step', delta: 1 }) + expect(viewKeyFor(k('+', 'NumpadAdd'))).toEqual({ kind: 'step', delta: 1 }) + expect(viewKeyFor(k('+', 'Equal', { shiftKey: true }))).toEqual({ kind: 'step', delta: 1 }) + // Nordic layouts: + is its own unshifted key (code Minus) + expect(viewKeyFor(k('+', 'Minus'))).toEqual({ kind: 'step', delta: 1 }) + }) + it('maps Ctrl+- (any layout, numpad) to smaller and Ctrl+0 to Details', () => { + expect(viewKeyFor(k('-', 'Minus'))).toEqual({ kind: 'step', delta: -1 }) + expect(viewKeyFor(k('-', 'Slash'))).toEqual({ kind: 'step', delta: -1 }) + expect(viewKeyFor(k('-', 'NumpadSubtract'))).toEqual({ kind: 'step', delta: -1 }) + expect(viewKeyFor(k('0', 'Digit0'))).toEqual({ kind: 'set', size: 'details' }) + expect(viewKeyFor(k('0', 'Numpad0'))).toEqual({ kind: 'set', size: 'details' }) + }) + it('maps Ctrl+Shift+1..5 by code, whatever character the layout makes', () => { + expect(viewKeyFor(k('!', 'Digit1', { shiftKey: true }))).toEqual({ kind: 'set', size: 'details' }) + expect(viewKeyFor(k('#', 'Digit3', { shiftKey: true }))).toEqual({ kind: 'set', size: 'medium' }) + expect(viewKeyFor(k('%', 'Digit5', { shiftKey: true }))).toEqual({ kind: 'set', size: 'xl' }) + expect(viewKeyFor(k('&', 'Digit6', { shiftKey: true }))).toBeNull() + }) + it('works with Cmd, ignores Alt, plain keys and other Ctrl keys', () => { + expect(viewKeyFor(k('=', 'Equal', { ctrlKey: false, metaKey: true }))).toEqual({ kind: 'step', delta: 1 }) + expect(viewKeyFor(k('=', 'Equal', { ctrlKey: false }))).toBeNull() + expect(viewKeyFor(k('=', 'Equal', { altKey: true }))).toBeNull() + expect(viewKeyFor(k('a', 'KeyA'))).toBeNull() + expect(viewKeyFor(k('3', 'Digit3'))).toBeNull() + }) +}) + +describe('Ctrl+wheel accumulation', () => { + it('scales line and page deltas to pixels', () => { + expect(wheelDelta(100, 0)).toBe(100) + expect(wheelDelta(3, 1)).toBe(120) + expect(wheelDelta(1, 2)).toBe(400) + }) + it('steps once per mouse notch, wheel up = bigger, at most once per 90ms', () => { + let r = wheelStep(WHEEL_IDLE, -100, 1000) + expect(r.step).toBe(1) + r = wheelStep(r.state, -100, 1050) + expect(r.step).toBe(0) + r = wheelStep(r.state, -100, 1200) + expect(r.step).toBe(1) + expect(wheelStep(WHEEL_IDLE, 100, 1000).step).toBe(-1) + }) + it('accumulates small trackpad deltas and drops a stale accumulation', () => { + let r = wheelStep(WHEEL_IDLE, -10, 1000) + r = wheelStep(r.state, -10, 1010) + r = wheelStep(r.state, -10, 1020) + expect(r.step).toBe(0) + r = wheelStep(r.state, -10, 1030) + expect(r.step).toBe(1) + const stale = wheelStep(WHEEL_IDLE, -30, 1000) + expect(wheelStep(stale.state, -30, 1400).step).toBe(0) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/view-size.test.ts` +Expected: FAIL, cannot resolve `../src/renderer/src/components/queue/viewSize`. + +- [ ] **Step 3: Write the implementation** + +`src/renderer/src/components/queue/viewSize.ts`: + +```ts +import type { KeyLike } from './tableKeys' + +// Pure model for the files view sizes (spec 1-3), named like Windows File +// Explorer. Kept out of the .tsx files so Vitest and Fast Refresh both work. + +export type ViewSize = 'details' | 'tiles' | 'medium' | 'large' | 'xl' +export type ViewIcon = 'view-details' | 'view-tiles' | 'view-medium' | 'view-large' | 'view-xl' + +export interface ViewSizeDef { + id: ViewSize + label: string + icon: ViewIcon + /** Ctrl+Shift+ jumps to this size. */ + digit: number +} + +export const VIEW_SIZES: readonly ViewSizeDef[] = [ + { id: 'details', label: 'Details', icon: 'view-details', digit: 1 }, + { id: 'tiles', label: 'Tiles', icon: 'view-tiles', digit: 2 }, + { id: 'medium', label: 'Medium icons', icon: 'view-medium', digit: 3 }, + { id: 'large', label: 'Large icons', icon: 'view-large', digit: 4 }, + { id: 'xl', label: 'Extra large icons', icon: 'view-xl', digit: 5 } +] + +export const DEFAULT_VIEW: ViewSize = 'details' +export const VIEW_KEY = 'filesmith.viewSize' + +const indexOf = (s: ViewSize): number => VIEW_SIZES.findIndex((d) => d.id === s) + +export function viewDef(s: ViewSize): ViewSizeDef { + return VIEW_SIZES[Math.max(0, indexOf(s))] +} + +export function stepSize(s: ViewSize, delta: number): ViewSize { + const i = Math.max(0, Math.min(VIEW_SIZES.length - 1, indexOf(s) + delta)) + return VIEW_SIZES[i].id +} + +export function parseViewSize(v: unknown): ViewSize { + return VIEW_SIZES.find((d) => d.id === v)?.id ?? DEFAULT_VIEW +} + +export type ViewKey = { kind: 'step'; delta: 1 | -1 } | { kind: 'set'; size: ViewSize } +export interface ViewKeyLike extends KeyLike { + code: string +} + +/** Ctrl+= / Ctrl++ bigger, Ctrl+- smaller, Ctrl+0 Details, Ctrl+Shift+1..5 a size. + * Digits match on `code`, so Shift+1 = `!` on Nordic layouts still works. */ +export function viewKeyFor(e: ViewKeyLike): ViewKey | null { + if (!(e.ctrlKey || e.metaKey) || e.altKey) return null + if (e.shiftKey) { + const m = /^Digit([1-5])$/.exec(e.code) + if (m) return { kind: 'set', size: VIEW_SIZES[Number(m[1]) - 1].id } + return e.key === '+' ? { kind: 'step', delta: 1 } : null + } + if (e.key === '=' || e.key === '+') return { kind: 'step', delta: 1 } + if (e.key === '-') return { kind: 'step', delta: -1 } + if (e.key === '0') return { kind: 'set', size: DEFAULT_VIEW } + return null +} + +export interface WheelState { + acc: number + /** Time of the last wheel event. */ + at: number + /** Time of the last step. */ + last: number +} +export const WHEEL_IDLE: WheelState = { acc: 0, at: -Infinity, last: -Infinity } + +const THRESHOLD = 40 +const MIN_GAP_MS = 90 +const STALE_MS = 250 + +export function wheelDelta(deltaY: number, deltaMode: number): number { + return deltaMode === 1 ? deltaY * 40 : deltaMode === 2 ? deltaY * 400 : deltaY +} + +/** One step per notch; trackpad deltas accumulate. Wheel up (negative) = bigger. */ +export function wheelStep( + s: WheelState, + delta: number, + now: number +): { state: WheelState; step: -1 | 0 | 1 } { + const acc = (now - s.at > STALE_MS ? 0 : s.acc) + delta + if (Math.abs(acc) >= THRESHOLD && now - s.last > MIN_GAP_MS) + return { state: { acc: 0, at: now, last: now }, step: acc < 0 ? 1 : -1 } + return { state: { acc, at: now, last: s.last }, step: 0 } +} + +/** Thumbnail pixels a size needs (spec 5): the 128px one covers up to Medium. */ +export function thumbPx(s: ViewSize): 128 | 256 { + return s === 'large' || s === 'xl' ? 256 : 128 +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run test/view-size.test.ts` +Expected: PASS (all tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer/src/components/queue/viewSize.ts test/view-size.test.ts +git commit -m "feat(view): view-size model, keys and wheel accumulation" -m "Refs #36" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: Grid navigation, card status line, Left/Right keys + +**Files:** +- Create: `src/renderer/src/components/queue/gridKeys.ts`, `src/renderer/src/components/queue/cardModel.ts` +- Modify: `src/renderer/src/components/queue/tableKeys.ts` +- Test: `test/grid-keys.test.ts`, `test/card-model.test.ts`, `test/table-keys.test.ts` + +**Interfaces:** +- Consumes: `ViewSize` (Task 1); `RowView` from `rowModel.ts`; `ItemStatus` from `../../state`; `FileKind` from `@shared/types`; `IconName` from `@shared/icons`. +- Produces: + - `TableKey` gains `'left' | 'right' | 'extendLeft' | 'extendRight'`. + - `type GridDir = 'up' | 'down' | 'left' | 'right'`; `moveFor(k: TableKey): { dir: GridDir; extend: boolean } | null`; `gridNeighbor(groups: string[][], id: string, dir: GridDir, cols: number): string | undefined`. + - `interface CardLine { kind: ItemStatus; icon: IconName | null; strong?: string; text?: string; eta?: string; pct?: string; extra?: string; title?: string }`; `cardLine(v: RowView, size: ViewSize): CardLine`; `kindIcon(k: FileKind): IconName`. + +- [ ] **Step 1: Write the failing tests** + +Append to `test/table-keys.test.ts` inside the `describe('tableKey', ...)` block: + +```ts + it('maps Left/Right for the grid sizes, Shift extends', () => { + expect(tableKey(k('ArrowLeft'))).toBe('left') + expect(tableKey(k('ArrowRight'))).toBe('right') + expect(tableKey(k('ArrowLeft', { shiftKey: true }))).toBe('extendLeft') + expect(tableKey(k('ArrowRight', { shiftKey: true }))).toBe('extendRight') + }) +``` + +`test/grid-keys.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { gridNeighbor, moveFor } from '../src/renderer/src/components/queue/gridKeys' + +// Two groups in a 4-column grid: +// a b c d (group 1) +// e f g +// h i (group 2) +const G = [['a', 'b', 'c', 'd', 'e', 'f', 'g'], ['h', 'i']] + +describe('gridNeighbor', () => { + it('walks left and right in visual order, across groups', () => { + expect(gridNeighbor(G, 'd', 'right', 4)).toBe('e') + expect(gridNeighbor(G, 'g', 'right', 4)).toBe('h') + expect(gridNeighbor(G, 'h', 'left', 4)).toBe('g') + expect(gridNeighbor(G, 'a', 'left', 4)).toBeUndefined() + expect(gridNeighbor(G, 'i', 'right', 4)).toBeUndefined() + }) + it('moves one visual row in the same column', () => { + expect(gridNeighbor(G, 'a', 'down', 4)).toBe('e') + expect(gridNeighbor(G, 'c', 'down', 4)).toBe('g') + expect(gridNeighbor(G, 'f', 'up', 4)).toBe('b') + }) + it('reaches a short last row from the column above it', () => { + expect(gridNeighbor(G, 'd', 'down', 4)).toBe('g') + }) + it('crosses group headers at the nearest column', () => { + expect(gridNeighbor(G, 'e', 'down', 4)).toBe('h') + expect(gridNeighbor(G, 'g', 'down', 4)).toBe('i') + expect(gridNeighbor(G, 'h', 'up', 4)).toBe('e') + expect(gridNeighbor(G, 'i', 'up', 4)).toBe('f') + expect(gridNeighbor(G, 'a', 'up', 4)).toBeUndefined() + expect(gridNeighbor(G, 'i', 'down', 4)).toBeUndefined() + }) + it('is the plain list order with one column (Details), where Left/Right do nothing', () => { + expect(gridNeighbor(G, 'g', 'down', 1)).toBe('h') + expect(gridNeighbor(G, 'h', 'up', 1)).toBe('g') + expect(gridNeighbor(G, 'b', 'up', 1)).toBe('a') + expect(gridNeighbor(G, 'b', 'right', 1)).toBeUndefined() + }) + it('returns nothing for an unknown id', () => { + expect(gridNeighbor(G, 'zz', 'down', 4)).toBeUndefined() + }) +}) + +describe('moveFor', () => { + it('maps arrow keys to a direction and whether Shift extends', () => { + expect(moveFor('up')).toEqual({ dir: 'up', extend: false }) + expect(moveFor('extendDown')).toEqual({ dir: 'down', extend: true }) + expect(moveFor('left')).toEqual({ dir: 'left', extend: false }) + expect(moveFor('extendRight')).toEqual({ dir: 'right', extend: true }) + expect(moveFor('toggle')).toBeNull() + expect(moveFor('menu')).toBeNull() + }) +}) +``` + +`test/card-model.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { cardLine, kindIcon } from '../src/renderer/src/components/queue/cardModel' +import type { RowView } from '../src/renderer/src/components/queue/rowModel' + +const base: RowView = { + kind: 'PNG', + size: '1.8 MB', + result: null, + status: { kind: 'ready', text: '' }, + action: { kind: 'remove', icon: 'close', label: 'Remove', ghost: true } +} +const done: RowView = { + ...base, + result: { text: '486 KB', pct: '-77%', estimate: false, grew: false }, + status: { kind: 'done', text: 'Done' } +} +const running: RowView = { + ...base, + result: { text: '~410 KB', pct: '-77%', estimate: true, grew: false }, + status: { kind: 'running', text: '', pct: '62%', eta: '(4s)' } +} +const failed: RowView = { + ...base, + status: { kind: 'failed', text: 'Unsupported compression', title: 'Unsupported compression in TIFF' } +} + +describe('cardLine', () => { + it('done: result, then %, then the source size as the cards grow', () => { + expect(cardLine(done, 'medium')).toEqual({ kind: 'done', icon: 'check', strong: '486 KB' }) + expect(cardLine(done, 'large')).toEqual({ kind: 'done', icon: 'check', strong: '486 KB', pct: '-77%' }) + expect(cardLine(done, 'xl')).toEqual({ + kind: 'done', + icon: 'check', + strong: '486 KB', + pct: '-77%', + extra: '1.8 MB' + }) + expect(cardLine({ ...done, result: null }, 'medium').strong).toBe('Done') + }) + it('running: 62%(4s), plus the estimate from Large up', () => { + expect(cardLine(running, 'medium')).toEqual({ kind: 'running', icon: 'sync', strong: '62%', eta: '(4s)' }) + expect(cardLine(running, 'large').extra).toBe('~410 KB') + }) + it('queued and canceled add the source size from Large up', () => { + const q: RowView = { ...base, status: { kind: 'queued', text: 'Queued' } } + expect(cardLine(q, 'medium')).toEqual({ kind: 'queued', icon: 'clock', text: 'Queued' }) + expect(cardLine(q, 'large').extra).toBe('1.8 MB') + const c: RowView = { ...base, status: { kind: 'canceled', text: 'Canceled' } } + expect(cardLine(c, 'medium')).toEqual({ kind: 'canceled', icon: 'close', text: 'Canceled' }) + expect(cardLine(c, 'xl').extra).toBe('1.8 MB') + }) + it('failed: "Failed" on Medium, the short error from Large up, full error as title', () => { + expect(cardLine(failed, 'medium')).toEqual({ + kind: 'failed', + icon: 'warning', + text: 'Failed', + title: 'Unsupported compression in TIFF' + }) + expect(cardLine(failed, 'large').text).toBe('Unsupported compression') + }) + it('ready: just the source size', () => { + expect(cardLine(base, 'medium')).toEqual({ kind: 'ready', icon: null, text: '1.8 MB' }) + }) +}) + +describe('kindIcon', () => { + it('maps each file kind to a glyph', () => { + expect(kindIcon('image')).toBe('image') + expect(kindIcon('video')).toBe('video') + expect(kindIcon('audio')).toBe('audio') + expect(kindIcon('pdf')).toBe('pdf') + expect(kindIcon('document')).toBe('doc') + expect(kindIcon('text')).toBe('text') + expect(kindIcon('archive')).toBe('archive') + expect(kindIcon('other')).toBe('doc') + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run test/table-keys.test.ts test/grid-keys.test.ts test/card-model.test.ts` +Expected: FAIL (`left` is null; `gridKeys` and `cardModel` do not resolve). + +- [ ] **Step 3: Write the implementation** + +`src/renderer/src/components/queue/tableKeys.ts`, replace the type and the two arrow lines: + +```ts +export type TableKey = + | 'up' + | 'down' + | 'left' + | 'right' + | 'extendUp' + | 'extendDown' + | 'extendLeft' + | 'extendRight' + | 'toggle' + | 'selectAll' + | 'remove' + | 'open' + | 'menu' +``` + +and in `tableKey`, after the `ArrowDown` line: + +```ts + if (e.key === 'ArrowLeft') return e.shiftKey ? 'extendLeft' : 'left' + if (e.key === 'ArrowRight') return e.shiftKey ? 'extendRight' : 'right' +``` + +`src/renderer/src/components/queue/gridKeys.ts`: + +```ts +import type { TableKey } from './tableKeys' + +// Keyboard movement over the files view (spec 4): Details is one column, Tiles +// two, the icon grids as many as fit. `groups` holds each convert group's ids in +// display order; each group starts a new visual row under its header. + +export type GridDir = 'up' | 'down' | 'left' | 'right' + +const MOVES: Partial> = { + up: { dir: 'up', extend: false }, + down: { dir: 'down', extend: false }, + left: { dir: 'left', extend: false }, + right: { dir: 'right', extend: false }, + extendUp: { dir: 'up', extend: true }, + extendDown: { dir: 'down', extend: true }, + extendLeft: { dir: 'left', extend: true }, + extendRight: { dir: 'right', extend: true } +} + +export function moveFor(k: TableKey): { dir: GridDir; extend: boolean } | null { + return MOVES[k] ?? null +} + +export function gridNeighbor( + groups: string[][], + id: string, + dir: GridDir, + cols: number +): string | undefined { + const g = groups.findIndex((list) => list.includes(id)) + if (g < 0) return undefined + const c = Math.max(1, Math.floor(cols)) + if (dir === 'left' || dir === 'right') { + if (c === 1) return undefined + const flat = groups.flat() + return flat[flat.indexOf(id) + (dir === 'left' ? -1 : 1)] + } + const list = groups[g] + const i = list.indexOf(id) + const col = i % c + if (dir === 'down') { + if (i + c < list.length) return list[i + c] + // A short last row below: land on its last item, like Explorer. + if (Math.floor(i / c) < Math.floor((list.length - 1) / c)) return list[list.length - 1] + const next = groups[g + 1] + return next ? next[Math.min(col, next.length - 1)] : undefined + } + if (i - c >= 0) return list[i - c] + const prev = groups[g - 1] + if (!prev) return undefined + const lastRowStart = Math.floor((prev.length - 1) / c) * c + return prev[Math.min(lastRowStart + col, prev.length - 1)] +} +``` + +`src/renderer/src/components/queue/cardModel.ts`: + +```ts +import type { IconName } from '@shared/icons' +import type { FileKind } from '@shared/types' +import type { ItemStatus } from '../../state' +import type { RowView } from './rowModel' +import type { ViewSize } from './viewSize' + +// The compact status line under an icon-grid card (spec 4, table). Larger +// cards add detail: the change %, the estimate, the source size. + +export interface CardLine { + kind: ItemStatus + icon: IconName | null + /** Bold lead: the result size, or the running percentage. */ + strong?: string + /** Plain text: Queued, Canceled, Failed / the short error, or the source size. */ + text?: string + eta?: string + pct?: string + /** Right-aligned fg3 extra: the estimate or the source size. */ + extra?: string + title?: string +} + +export function cardLine(v: RowView, size: ViewSize): CardLine { + const big = size === 'large' || size === 'xl' + const src = big ? v.size : undefined + const s = v.status + switch (s.kind) { + case 'done': + return { + kind: 'done', + icon: 'check', + strong: v.result?.text ?? 'Done', + pct: big ? (v.result?.pct ?? undefined) : undefined, + extra: size === 'xl' ? v.size : undefined + } + case 'running': + return { + kind: 'running', + icon: 'sync', + strong: s.pct ?? '…', + eta: s.eta, + extra: big ? v.result?.text : undefined + } + case 'queued': + return { kind: 'queued', icon: 'clock', text: 'Queued', extra: src } + case 'failed': + return { kind: 'failed', icon: 'warning', text: big ? s.text : 'Failed', title: s.title } + case 'canceled': + return { kind: 'canceled', icon: 'close', text: 'Canceled', extra: src } + default: + return { kind: 'ready', icon: null, text: v.size } + } +} + +const KIND_ICON: Record = { + image: 'image', + video: 'video', + audio: 'audio', + pdf: 'pdf', + document: 'doc', + text: 'text', + archive: 'archive', + other: 'doc' +} + +/** The glyph a card shows when there is no thumbnail. */ +export function kindIcon(k: FileKind): IconName { + return KIND_ICON[k] +} +``` + +- [ ] **Step 4: Keep Details correct until Task 4** + +The current `onRowKey` in `QueueTable.tsx` sends every key it does not name to its final `else` (open the row menu), so Left/Right would open the menu in Details. Add this guard right after `if (!k) return` (Task 4 replaces the whole function): + +```ts + if (k === 'left' || k === 'right' || k === 'extendLeft' || k === 'extendRight') return +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npx vitest run test/table-keys.test.ts test/grid-keys.test.ts test/card-model.test.ts && npm run typecheck && npm run lint` +Expected: PASS; typecheck and lint clean. + +- [ ] **Step 6: Commit** + +```bash +git add src/renderer/src/components/queue/gridKeys.ts src/renderer/src/components/queue/cardModel.ts src/renderer/src/components/queue/tableKeys.ts src/renderer/src/components/queue/QueueTable.tsx test/grid-keys.test.ts test/card-model.test.ts test/table-keys.test.ts +git commit -m "feat(view): grid keyboard navigation and card status line" -m "Refs #36" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: Icons, persisted size, View menu and keyboard shortcuts + +**Files:** +- Modify: `src/shared/icons.ts`, `src/renderer/src/components/icons/shapes.ts`, `test/icons.test.ts` +- Create: `src/renderer/src/components/queue/useViewSize.ts`, `src/renderer/src/components/queue/ViewMenu.tsx`, `src/renderer/src/theme/viewsizes.css` +- Modify: `src/renderer/src/index.css`, `src/renderer/src/components/queue/QueueToolbar.tsx`, `src/renderer/src/App.tsx` + +**Interfaces:** +- Consumes: everything from Task 1. +- Produces: + - Icon names `view-details`, `view-tiles`, `view-medium`, `view-large`, `view-xl`, `mouse`. + - `useViewSize(): { size: ViewSize; flash: boolean; setSize: (s: ViewSize, opts?: { flash?: boolean }) => boolean; step: (delta: number) => boolean }` (stable callbacks; `step` always flashes; both return whether the size changed). + - ``; button accessible name `View: