diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 332ac16..5a0b785 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.88.0" +version = "0.88.1" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 88ade25..20507fc 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.88.0" +version = "0.88.1" edition = "2021" default-run = "mangalord" diff --git a/frontend/package.json b/frontend/package.json index 18e80b3..bac57a1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.88.0", + "version": "0.88.1", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/components/Chip.svelte b/frontend/src/lib/components/Chip.svelte index ad7928e..6ae3243 100644 --- a/frontend/src/lib/components/Chip.svelte +++ b/frontend/src/lib/components/Chip.svelte @@ -125,4 +125,24 @@ background: var(--danger-soft-bg); color: var(--danger); } + + /* On touch the 16px glyph is far below the tap floor. Rather than grow + the chip (which would inflate every tag row), overlay an invisible, + centered hit area sized to --tap-min — the glyph stays compact while + the tappable region reaches a comfortable size. */ + @media (max-width: 640px) { + .chip-remove { + position: relative; + } + + .chip-remove::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + min-width: var(--tap-min); + min-height: var(--tap-min); + } + } diff --git a/frontend/src/lib/components/SegmentedControl.svelte b/frontend/src/lib/components/SegmentedControl.svelte index a75571d..926d53b 100644 --- a/frontend/src/lib/components/SegmentedControl.svelte +++ b/frontend/src/lib/components/SegmentedControl.svelte @@ -126,4 +126,13 @@ color: var(--text); box-shadow: var(--shadow-sm); } + + /* This control doubles as the primary sort toggle (catalog) and the + Library tab switcher on mobile, so its options must meet the touch + floor on phones. */ + @media (max-width: 640px) { + .seg { + min-height: var(--tap-min); + } + } diff --git a/frontend/src/lib/styles/tokens.css b/frontend/src/lib/styles/tokens.css index ec36f0a..bd336b7 100644 --- a/frontend/src/lib/styles/tokens.css +++ b/frontend/src/lib/styles/tokens.css @@ -78,6 +78,12 @@ --bp-md: 768px; --bp-lg: 1024px; + /* Comfortable touch-target floor for primary controls on phones. + Controls compact to their desktop height by default and grow to + this minimum under the 640px breakpoint. See touch-targets.test.ts + for the shared-primitive contract. */ + --tap-min: 44px; + /* Safe-area helpers for notched / home-indicator devices. Resolve to 0 on devices without insets, so they're safe everywhere as long as @@ -293,6 +299,23 @@ img { body { overflow-x: hidden; } + + /* Touch-target floor on phones. Form controls and submit buttons keep + their compact 36px desktop height above the breakpoint but grow to a + comfortable tap size here. `min-height` (not `height`) so the + already-tall textarea is unaffected. Scoped to text fields, selects, + and `type=submit` deliberately — bare `button` would also catch dense + icon buttons (chip remove, segmented options, reader chrome) that meet + the floor through their own hit-area rules instead. */ + input[type='text'], + input[type='search'], + input[type='password'], + input[type='number'], + input[type='email'], + select, + button[type='submit'] { + min-height: var(--tap-min); + } } @media (prefers-reduced-motion: reduce) { diff --git a/frontend/src/lib/styles/touch-targets.test.ts b/frontend/src/lib/styles/touch-targets.test.ts new file mode 100644 index 0000000..c4e4520 --- /dev/null +++ b/frontend/src/lib/styles/touch-targets.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +// Touch-target contract for the SHARED interactive primitives. +// +// WCAG / platform HIG put the comfortable touch floor at ~44px. jsdom can't +// evaluate @media queries or compute layout, so we can't assert rendered box +// sizes in a unit test. Instead we pin the CSS *contract*: each shared control +// grows to the --tap-min floor inside the mobile breakpoint. This guards +// against a future edit silently dropping the mobile bump (the regression the +// desktop/mobile consistency review surfaced). +// +// Scope is deliberately the genuinely-shared primitives — the global +// input/button rules in tokens.css, SegmentedControl, and Chip. The per-route +// `.icon-btn` is copy-pasted across seven files and is a separate refactor. + +// Resolved from the vitest cwd (the frontend root) — jsdom doesn't hand us a +// file:// import.meta.url to anchor on. +const read = (rel: string) => readFileSync(resolve(process.cwd(), rel), 'utf8'); + +const TOKENS = read('src/lib/styles/tokens.css'); +const SEGMENTED = read('src/lib/components/SegmentedControl.svelte'); +const CHIP = read('src/lib/components/Chip.svelte'); + +// Pull the text inside every `@media (max-width: 640px) { ... }` block, +// brace-balanced so nested rules are captured. Returns the concatenation so a +// `.includes` check only matches declarations that live under the mobile query. +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; +} + +// Collapse whitespace so `min-height:\n var(--tap-min)` matches regardless of +// the formatter's line breaks. +const squish = (s: string) => s.replace(/\s+/g, ' '); + +describe('shared touch-target contract', () => { + it('defines a --tap-min token of 44px', () => { + expect(squish(TOKENS)).toMatch(/--tap-min:\s*44px/); + }); + + it('grows global inputs and buttons to the touch floor on mobile', () => { + const mobile = squish(mobileBlocks(TOKENS)); + // input/select group and button rule both reach --tap-min under 640px. + expect(mobile).toMatch(/input[^}]*min-height:\s*var\(--tap-min\)/); + expect(mobile).toMatch(/button[^{]*\{[^}]*min-height:\s*var\(--tap-min\)/); + }); + + it('grows SegmentedControl options to the touch floor on mobile', () => { + const mobile = squish(mobileBlocks(SEGMENTED)); + expect(mobile).toMatch(/\.seg[^}]*min-height:\s*var\(--tap-min\)/); + }); + + it("expands the chip remove button's hit area to the touch floor on mobile", () => { + const mobile = squish(mobileBlocks(CHIP)); + // The visible glyph stays small; the tappable area reaches --tap-min. + expect(mobile).toMatch(/\.chip-remove[^}]*min-(height|block-size):\s*var\(--tap-min\)/); + }); +});