diff --git a/frontend/src/routes/collections/[id]/+page.svelte b/frontend/src/routes/collections/[id]/+page.svelte index 981e4d1..4c8585a 100644 --- a/frontend/src/routes/collections/[id]/+page.svelte +++ b/frontend/src/routes/collections/[id]/+page.svelte @@ -406,6 +406,19 @@ opacity: 1; } + /* Touch has no hover, so the reveal-on-hover treatment leaves the + remove buttons invisible and undiscoverable. Show them persistently + and enlarge the hit area on phones. (Capped well below 44px because + the button floats over a 4-up cover thumbnail — a full touch-floor + square would swallow the cover.) */ + @media (max-width: 640px) { + .remove { + opacity: 1; + width: 32px; + height: 32px; + } + } + .icon-btn { display: inline-flex; align-items: center; diff --git a/frontend/src/routes/collections/[id]/remove-touch.test.ts b/frontend/src/routes/collections/[id]/remove-touch.test.ts new file mode 100644 index 0000000..504344a --- /dev/null +++ b/frontend/src/routes/collections/[id]/remove-touch.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +// The per-card "remove" buttons on a collection are revealed on hover / +// focus-within (opacity 0 → 1), which leaves them invisible and +// undiscoverable on touch. They must be shown persistently at the mobile +// breakpoint. jsdom can't evaluate @media or layout, so this pins the CSS +// contract: the 640px block makes `.remove` opaque. (Same approach as the +// shared touch-target contract test.) + +const SRC = readFileSync( + resolve(process.cwd(), 'src/routes/collections/[id]/+page.svelte'), + 'utf8' +); + +// Brace-balanced extraction of every `@media (max-width: 640px) { ... }` body. +function mobileBlocks(css: string): string { + const marker = '@media (max-width: 640px)'; + let out = ''; + let from = 0; + for (;;) { + const start = css.indexOf(marker, from); + if (start === -1) break; + const open = css.indexOf('{', start); + if (open === -1) break; + let depth = 0; + let i = open; + for (; i < css.length; i++) { + if (css[i] === '{') depth++; + else if (css[i] === '}') { + depth--; + if (depth === 0) break; + } + } + out += css.slice(open + 1, i) + '\n'; + from = i + 1; + } + return out; +} + +const squish = (s: string) => s.replace(/\s+/g, ' '); + +describe('collection remove buttons on touch', () => { + it('reveals the remove buttons persistently at the mobile breakpoint', () => { + const mobile = squish(mobileBlocks(SRC)); + expect(mobile).toMatch(/\.remove[^}]*opacity:\s*1/); + }); +});