fix(ui): meet 44px touch-target floor on shared mobile primitives
The desktop/mobile consistency review found primary mobile controls rendering below the ~44px comfortable touch floor. Address the genuinely shared primitives in one pass: - Add a --tap-min: 44px token (documented anchor for the floor). - tokens.css: text inputs, selects, and submit buttons grow to --tap-min under the 640px breakpoint (fixes the 36px auth/login form controls). Scoped to form controls + type=submit so dense icon buttons aren't inflated. - SegmentedControl: .seg options reach the floor on mobile (it doubles as the catalog sort toggle and the Library tab switcher). - Chip: expand .chip-remove's tappable area to --tap-min via a centered pseudo-element so the 16px glyph stays compact and tag rows don't grow. Pure-CSS responsive change — jsdom can't evaluate @media or layout, so the test pins the stylesheet contract (each shared primitive bumps to --tap-min inside the mobile breakpoint), guarding against the bump being dropped. The per-route .icon-btn (copy-pasted across seven files) is left for a separate refactor. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.88.0"
|
version = "0.88.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.88.0"
|
version = "0.88.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.88.0",
|
"version": "0.88.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -125,4 +125,24 @@
|
|||||||
background: var(--danger-soft-bg);
|
background: var(--danger-soft-bg);
|
||||||
color: var(--danger);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -126,4 +126,13 @@
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
box-shadow: var(--shadow-sm);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -78,6 +78,12 @@
|
|||||||
--bp-md: 768px;
|
--bp-md: 768px;
|
||||||
--bp-lg: 1024px;
|
--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
|
/* Safe-area helpers for notched / home-indicator devices. Resolve
|
||||||
to 0 on devices without insets, so they're safe everywhere as
|
to 0 on devices without insets, so they're safe everywhere as
|
||||||
long as <meta name="viewport" content="…, viewport-fit=cover">
|
long as <meta name="viewport" content="…, viewport-fit=cover">
|
||||||
@@ -293,6 +299,23 @@ img {
|
|||||||
body {
|
body {
|
||||||
overflow-x: hidden;
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
79
frontend/src/lib/styles/touch-targets.test.ts
Normal file
79
frontend/src/lib/styles/touch-targets.test.ts
Normal file
@@ -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\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user