Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/design/redesign-direction.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,5 @@ Mockup rounds live in `docs/mockups/terminal*/` (each has an `index.html` galler
- 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`.
- View menu (2026-10-05, #41): View menu = 3 sizes (Details, Tiles, Extra large icons), no icons, no
shortcuts; the other sizes via Ctrl+wheel.
Binary file modified docs/mockups/view-zoom/shots/impl-menu.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 14 additions & 11 deletions docs/superpowers/specs/2026-10-05-view-sizes-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,19 +35,21 @@ Settings are not part of this work.
scaled by 40. Ctrl+wheel anywhere else (inspector, sidebar) does nothing special; Electron does not zoom the
page on Ctrl+wheel, and the files view listener is the only one that calls `preventDefault`.
- **Keyboard**, whenever the files view is shown: `Ctrl+=` / `Ctrl++` (also numpad +, and Ctrl+Shift+= ) =
bigger, `Ctrl+-` (also numpad -) = smaller, `Ctrl+0` = reset to Details, `Ctrl+Shift+1..6` jump to a size.
Digits are matched on `KeyboardEvent.code` (`Digit1..6`) so Nordic layouts, where Shift+1 is `!`, work.
bigger, `Ctrl+-` (also numpad -) = smaller, `Ctrl+0` = reset to Details. There are no per-size
shortcuts (Ctrl+Shift+1..6 was dropped on 2026-10-05, issue #41).
The handler calls `preventDefault`, which also keeps Electron's default menu zoom accelerators
(zoomIn / zoomOut / resetZoom) from zooming the whole page; e2e asserts the zoom factor stays 1.
- **View menu button** at the right end of the files toolbar, after the count: view glyph, current label
(min-width 96px so the toolbar does not jump), chevron. `aria-haspopup="menu"`, `aria-expanded`,
- **View menu button** at the right end of the files toolbar, after the count: current label (min-width 96px
so the toolbar does not jump) and a chevron, no size glyph. The label names the current size, any of the six
(e.g. `Medium icons` after Ctrl+wheel). `aria-haspopup="menu"`, `aria-expanded`,
`title="View (Ctrl+wheel to resize)"`. It flashes `--inv-bg` for 450ms when the size changes by wheel or key
(no flash under reduced motion). Its menu (anchored under the button, right-aligned, `.menu` styling,
min-width 272px) lists the six sizes as `menuitemradio` with a tick, the size glyph, label and
`Ctrl+Shift+N`; a separator; `Reset to Details Ctrl+0`; a separator; two hint lines
(`Ctrl + wheel over the list`, `Bigger, smaller Ctrl+= / Ctrl+-`). Focus lands on the checked entry; arrows
walk the entries, Home/End jump, Enter/Space/click picks and closes, Escape or an outside click closes and
focus returns to the button.
min-width 200px) offers only three sizes as plain-text `menuitemradio` entries: `Details`, `Tiles`,
`Extra large icons`, with a check mark on the current one when it is one of these. No size glyphs, no
shortcut text, no Reset row, no hint rows (owner request 2026-10-05, issue #41). Large details, Medium
icons and Large icons are reached only by Ctrl+wheel (or Ctrl+= / Ctrl+-). Focus lands on the checked
entry, or the first when none is checked; arrows walk the entries, Home/End jump, Enter/Space/click picks
and closes, Escape or an outside click closes and focus returns to the button.
- Clamped at both ends: stepping past Details or Extra large icons does nothing (no flash, no announcement).
- A polite live region announces `View: <label>` on every change.
- **Motion:** a 150ms fade of the list body (`opacity .25 -> 1`, `cubic-bezier(.2,.7,.2,1)`) through the Web
Expand Down Expand Up @@ -131,7 +133,7 @@ no play glyph on videos, since nothing in the grid plays them (owner, 2026-10-05
| renderer | `queue/viewSize.ts` (model, keys, wheel), `queue/gridKeys.ts`, `queue/cardModel.ts` (pure, tested) |
| renderer | `queue/useViewSize.ts` (persisted hook), `queue/ViewMenu.tsx`, `queue/QueueTile.tsx`, `queue/QueueCard.tsx`, `queue/Thumb.tsx` |
| renderer | `QueueTable` (size switch, wheel, keyboard grid nav, fade), `QueueToolbar` (menu slot), `TotalsRow` (flat), `StatusCell` / `ResultCell` (extract inline parts), `tableKeys` (Left/Right) |
| renderer | `theme/viewsizes.css` (new, imported from `index.css`), 7 new icons (`view-details`, `view-details-l`, `view-tiles`, `view-medium`, `view-large`, `view-xl`, `mouse`) |
| renderer | `theme/viewsizes.css` (new, imported from `index.css`), no size icons (the 7 view glyphs were removed with the menu icons, issue #41) |
| renderer | `queue/thumbSize.ts` (bucket per size and DPR, cache lookup, pure, tested), `queue/useDevicePixelRatio.ts` |
| App | `useViewSize`, keyboard shortcuts, DPR-sized thumbnail requests |
| main | `thumbnail.ts`: fall back to magick / ffmpeg when the OS thumbnail is smaller than a big request |
Expand All @@ -144,7 +146,8 @@ no play glyph on videos, since nothing in the grid plays them (owner, 2026-10-05
accumulation), grid neighbour navigation (columns, short last row, group crossing), card line per state and
size, `tableKey` Left/Right, icon registry, no em-dash scan of the new files.
- Playwright (`npm run build` first): Ctrl+wheel over the list changes the size and Ctrl+wheel over the
inspector does not; the View menu lists six sizes and picks one; Ctrl+Shift+3 / Ctrl+0 / Ctrl+= / Ctrl+-;
inspector does not; the View menu lists three plain sizes and picks one; Ctrl+wheel reaches Medium / Large icons;
Ctrl+Shift+digits do nothing; Ctrl+0 / Ctrl+= / Ctrl+-;
the page zoom factor stays 1; the size survives a reload; selection, Shift+click and right-click work on
cards; arrow keys move focus in a grid.
- Gate: `npm run typecheck`, `npm run lint`, `npm test`, `npm run build`, `npm run test:e2e`.
Expand Down
89 changes: 64 additions & 25 deletions e2e/viewsizes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,33 +71,66 @@ async function ctrlWheel(x: number, y: number, dy: number): Promise<void> {
await page.waitForTimeout(150)
}

test('the View menu lists the six sizes and picks one', async () => {
const ORDER = ['details', 'details-l', 'tiles', 'medium', 'large', 'xl']
/** Reach any size from the keyboard: back to Details, then Ctrl+= per step. */
async function goTo(size: string): Promise<void> {
await page.keyboard.press('Control+0')
for (let i = 0; i < ORDER.indexOf(size); i++) await page.keyboard.press('Control+=')
await sizeIs(size)
}

test('the View menu lists three plain sizes and picks one', async () => {
await sizeIs('details')
await expect(viewBtn()).toHaveAttribute('aria-label', 'View: Details')
// No glyph in front of the label on the button, only the chevron after it.
await expect(viewBtn().locator('svg')).toHaveCount(1)
await viewBtn().click()
const menu = page.getByRole('menu', { name: 'View' })
const items = menu.getByRole('menuitemradio')
await expect(items).toHaveText([
/^Details\s*Ctrl\+Shift\+1/,
/Large details\s*Ctrl\+Shift\+2/,
/Tiles\s*Ctrl\+Shift\+3/,
/Medium icons\s*Ctrl\+Shift\+4/,
/Large icons\s*Ctrl\+Shift\+5/,
/Extra large icons\s*Ctrl\+Shift\+6/
])
await expect(items).toHaveText(['Details', 'Tiles', 'Extra large icons'])
// No shortcuts, no Reset row, no hint rows; only the check mark glyph per entry.
await expect(menu.getByRole('menuitem')).toHaveCount(0)
await expect(menu.getByRole('separator')).toHaveCount(0)
await expect(menu).not.toContainText('Ctrl')
for (let i = 0; i < 3; i++) await expect(items.nth(i).locator('svg')).toHaveCount(1)
await expect(items.first()).toHaveAttribute('aria-checked', 'true')
await expect(items.first()).toBeFocused()
await items.nth(4).click()
await items.nth(2).click()
await expect(menu).toHaveCount(0)
await sizeIs('large')
await expect(viewBtn()).toHaveAttribute('aria-label', 'View: Large icons')
await sizeIs('xl')
await expect(viewBtn()).toHaveAttribute('aria-label', 'View: Extra large icons')
// Group headers stay, one per convert group.
await expect(grid().getByRole('gridcell', { name: /IMAGES/ })).toBeVisible()
await expect(grid().getByRole('gridcell', { name: /VIDEO/ })).toBeVisible()
await viewBtn().click()
await items.nth(1).click()
await sizeIs('tiles')
await page.keyboard.press('Control+0')
await sizeIs('details')
})

test('Ctrl+wheel reaches the sizes the menu does not list', async () => {
const g = (await grid().boundingBox())!
for (const s of ['details-l', 'tiles', 'medium']) {
await ctrlWheel(g.x + g.width / 2, g.y + g.height / 2, -100)
await sizeIs(s)
}
await expect(viewBtn()).toHaveAttribute('aria-label', 'View: Medium icons')
await expect(viewBtn()).toContainText('Medium icons')
// The menu has no entry for Medium: nothing is checked, focus lands on the first.
await viewBtn().click()
const items = page.getByRole('menu', { name: 'View' }).getByRole('menuitemradio')
await expect(items).toHaveCount(3)
await expect(page.locator('.viewmenu [aria-checked="true"]')).toHaveCount(0)
await expect(items.first()).toBeFocused()
await page.keyboard.press('Escape')
await ctrlWheel(g.x + g.width / 2, g.y + g.height / 2, -100)
await sizeIs('large')
await expect(viewBtn()).toHaveAttribute('aria-label', 'View: Large icons')
expect(await zoom()).toBe(1)
await page.keyboard.press('Control+0')
})

test('Ctrl+wheel over the list steps the size; over the inspector it does nothing', async () => {
const g = (await grid().boundingBox())!
await ctrlWheel(g.x + g.width / 2, g.y + g.height / 2, -100)
Expand All @@ -113,17 +146,14 @@ test('Ctrl+wheel over the list steps the size; over the inspector it does nothin
await page.keyboard.press('Control+0')
})

test('keyboard shortcuts change the size without zooming the page', async () => {
await page.keyboard.press('Control+Shift+Digit2')
test('Ctrl+= / Ctrl+- / Ctrl+0 step the size without zooming the page', async () => {
await page.keyboard.press('Control+=')
await sizeIs('details-l')
await page.keyboard.press('Control+=')
await sizeIs('tiles')
await page.keyboard.press('Control+-')
await sizeIs('details-l')
await page.keyboard.press('Control+Shift+Digit3')
await sizeIs('tiles')
await page.keyboard.press('Control+Shift+Digit6')
await sizeIs('xl')
await goTo('xl')
await page.keyboard.press('Control+=')
await sizeIs('xl')
await page.keyboard.press('Control+0')
Expand All @@ -133,17 +163,28 @@ test('keyboard shortcuts change the size without zooming the page', async () =>
expect(await zoom()).toBe(1)
})

test('there are no per-size shortcuts', async () => {
for (const d of ['Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5', 'Digit6']) {
await page.keyboard.press(`Control+Shift+${d}`)
await sizeIs('details')
}
await goTo('tiles')
await page.keyboard.press('Control+Shift+Digit1')
await sizeIs('tiles')
await page.keyboard.press('Control+0')
expect(await zoom()).toBe(1)
})

test('a size change keeps focus on the same item', async () => {
await row('bravo').click()
await page.keyboard.press('Control+Shift+Digit4')
await sizeIs('medium')
await goTo('medium')
await expect(page.locator('[data-id="v-1"]')).toBeFocused()
await page.keyboard.press('Control+0')
await expect(page.locator('[data-id="v-1"]')).toBeFocused()
})

test('selection, the right-click menu and arrow keys work in an icon grid', async () => {
await page.keyboard.press('Control+Shift+Digit4')
await goTo('medium')
await row('alpha').click()
await row('charlie').click({ modifiers: ['Shift'] })
for (const n of ['alpha', 'bravo', 'charlie'])
Expand All @@ -166,8 +207,7 @@ test('selection, the right-click menu and arrow keys work in an icon grid', asyn
})

test('the chosen size survives a reload', async () => {
await page.keyboard.press('Control+Shift+Digit5')
await sizeIs('large')
await goTo('large')
await page.reload()
await expect(row('alpha')).toBeVisible()
await sizeIs('large')
Expand All @@ -185,8 +225,7 @@ test('Large details is the Details table with taller rows and a bigger thumb', a
}))
await sizeIs('details')
const small = await geom()
await page.keyboard.press('Control+Shift+Digit2')
await sizeIs('details-l')
await goTo('details-l')
await expect(viewBtn()).toHaveAttribute('aria-label', 'View: Large details')
const big = await geom()
expect(small.h).toBe(32)
Expand Down
6 changes: 4 additions & 2 deletions e2e/visual.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,10 @@ test('capture each view size next to the view-zoom mockup', async () => {
.getByRole('button', { name: 'Convert' })
.click()
await page.getByRole('row', { name: /beach-panorama/ }).click()
// No per-size shortcuts: start at Details, then Ctrl+= one size at a time.
await page.keyboard.press('Control+0')
for (let n = 1; n <= 6; n++) {
await page.keyboard.press(`Control+Shift+Digit${n}`)
if (n > 1) await page.keyboard.press('Control+=')
await expect(page.getByRole('grid', { name: 'Files' })).toHaveAttribute(
'data-size',
['details', 'details-l', 'tiles', 'medium', 'large', 'xl'][n - 1]
Expand All @@ -185,7 +187,7 @@ test('capture each view size next to the view-zoom mockup', async () => {
await page.waitForTimeout(800)
await page.screenshot({ path: join(VZ, `impl-size${n - 1}.png`) })
}
await page.keyboard.press('Control+Shift+Digit3')
await page.keyboard.press('Control+0')
await page.getByRole('button', { name: /^View: / }).click()
await expect(page.getByRole('menu', { name: 'View' })).toBeVisible()
await page.screenshot({ path: join(VZ, 'impl-menu.png') })
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "filesmith",
"version": "0.7.1",
"version": "0.7.2",
"description": "A desktop file toolkit: convert, compress, resize, upscale, remove backgrounds, and PDF tools.",
"author": "Max",
"license": "MIT",
Expand Down
37 changes: 0 additions & 37 deletions src/renderer/src/components/icons/shapes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,42 +108,5 @@ export const ICON_SHAPES: Record<IconName, Prim[]> = {
['circle', 8, 3.5, 0.75],
['circle', 8, 8, 0.75],
['circle', 8, 12.5, 0.75]
],
'view-details': [['path', 'M2.5 3.5h11M2.5 6.5h11M2.5 9.5h11M2.5 12.5h11']],
'view-details-l': [
['rect', 2.5, 2.5, 3, 3],
['rect', 2.5, 6.5, 3, 3],
['rect', 2.5, 10.5, 3, 3],
['path', 'M7.5 4h6M7.5 8h6M7.5 12h6']
],
'view-tiles': [
['rect', 2.5, 2.5, 4, 4],
['rect', 2.5, 9.5, 4, 4],
['path', 'M8.5 3.5h5M8.5 5.5h3M8.5 10.5h5M8.5 12.5h3']
],
'view-medium': [
['rect', 2.5, 2.5, 3, 3],
['rect', 6.5, 2.5, 3, 3],
['rect', 10.5, 2.5, 3, 3],
['rect', 2.5, 6.5, 3, 3],
['rect', 6.5, 6.5, 3, 3],
['rect', 10.5, 6.5, 3, 3],
['rect', 2.5, 10.5, 3, 3],
['rect', 6.5, 10.5, 3, 3],
['rect', 10.5, 10.5, 3, 3]
],
'view-large': [
['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]
],
'view-xl': [
['rect', 2.5, 2.5, 11, 8],
['path', 'M2.5 13.5h7']
],
mouse: [
['rect', 4.5, 1.5, 7, 13],
['path', 'M8 4v2.5']
]
}
46 changes: 14 additions & 32 deletions src/renderer/src/components/queue/ViewMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { useEffect, useLayoutEffect, useRef, useState, type JSX, type KeyboardEvent } from 'react'
import { Icon } from '../icons/Icon'
import { DEFAULT_VIEW, VIEW_SIZES, viewDef, type ViewSize } from './viewSize'
import { MENU_SIZES, viewDef, type ViewSize } from './viewSize'

/** The files toolbar's View button and its menu (spec 2). */
/** The files toolbar's View button and its menu (spec 2). The menu offers three
* sizes as plain text; the other three are reached with Ctrl+wheel, and the
* button always names the current size, whichever it is. */
export function ViewMenu({
size,
flash,
Expand All @@ -26,7 +28,11 @@ export function ViewMenu({
el.style.top = `${r.bottom}px`
el.style.left = `${Math.max(8, r.right - el.offsetWidth)}px`
el.style.visibility = 'visible'
el.querySelector<HTMLElement>('[aria-checked="true"]')?.focus()
// After Ctrl+wheel the size may be one the menu does not list: focus the first entry.
const first =
el.querySelector<HTMLElement>('[aria-checked="true"]') ??
el.querySelector<HTMLElement>('.menu-item')
first?.focus()
}, [open])

useEffect(() => {
Expand Down Expand Up @@ -91,7 +97,6 @@ export function ViewMenu({
title="View (Ctrl+wheel to resize)"
onClick={() => setOpen((o) => !o)}
>
<Icon name={def.icon} />
<span className="vl">{def.label}</span>
<Icon name="chev-d" size={12} className="k" />
</button>
Expand All @@ -108,42 +113,19 @@ export function ViewMenu({
onKeyDown={onKey}
onContextMenu={(e) => e.preventDefault()}
>
{VIEW_SIZES.map((d) => (
{MENU_SIZES.map((id) => (
<button
key={d.id}
key={id}
type="button"
role="menuitemradio"
aria-checked={d.id === size}
aria-checked={id === size}
className="menu-item"
onClick={() => pick(d.id)}
onClick={() => pick(id)}
>
<Icon name="check" size={12} className="tick" />
<Icon name={d.icon} />
<span className="ml">{d.label}</span>
<span className="sc">Ctrl+Shift+{d.digit}</span>
<span className="ml">{viewDef(id).label}</span>
</button>
))}
<div className="menu-sep" role="separator" />
<button
type="button"
role="menuitem"
className="menu-item"
onClick={() => pick(DEFAULT_VIEW)}
>
<span className="tick" />
<Icon name="retry" />
<span className="ml">Reset to Details</span>
<span className="sc">Ctrl+0</span>
</button>
<div className="menu-sep" role="separator" />
<div className="menu-hint">
<Icon name="mouse" />
<span>Ctrl + wheel over the list</span>
</div>
<div className="menu-hint indent">
<span>Bigger, smaller</span>
<span className="sc">Ctrl+= / Ctrl+-</span>
</div>
</div>
)}
</>
Expand Down
Loading
Loading