fix(frontend): mobile layout polish + overflow handling (0.60.1)

Visual cleanup pass after wiring up real data — the placeholder
content used during the five mobile phases hid a few sharp edges
that became obvious as soon as user-supplied tags / author handles
/ descriptions hit the screen.

- Mobile gutter unified at 16px (was 12px) — matches iOS / Material
  standard and gives chips, description text, and chapter rows
  visible breathing room from the screen edge. Hero negative margin
  in /manga/[id] tracks the change so the bleed still hits viewport.
- Catalog grid switched from 2 columns to 4 with
  `repeat(4, minmax(0, 1fr))` (per user preference). The `minmax(0,
  ...)` is load-bearing — without it the implicit `minmax(auto, 1fr)`
  lets a long single-word card title push the column past the
  viewport edge. MangaCard's `.author` and `.genres` lines hide
  below 640px so every card in the 4-col layout has identical height
  (cover + 2-line title clamp).
- Same minmax(0, 1fr) trick applied to the detail page's
  `.overview` grid track, plus defensive `min-width: 0` /
  `max-width: 100%` on `.meta` and `.chip-row` so a long
  unbreakable tag or romanized URL can't drag the metadata column
  past the article gutter.
- BottomNav swapped from `grid-auto-columns` (= `minmax(auto, 1fr)`)
  to an explicit `repeat(4, minmax(0, 1fr))`, plus `min-width: 0` on
  `.tab` and an ellipsizing `.label` so a long tab label stays
  inside the bar. The Browse placeholder is replaced with Upload —
  a real action, no longer a no-op duplicate of Home.
- Hero appbar gets 16px all-around inset (was 8px) so the back / ⋯
  circle buttons aren't kissing the screen edge or the hero top.
  Hero content gets 20px horizontal padding so the cover thumb +
  title sit visibly off the hero scrim. Description gains
  `overflow-wrap: anywhere` for long URLs / romanized titles.
- Chip now allows `overflow-wrap: anywhere` on its label and uses
  `max-width: 100%` + `min-width: 0` so a long token wraps to its
  own line within the chip-row and ellipsizes inside the chip.
- Mobile catalog search-row gains `flex-basis: calc(100% - 36px -
  --space-2)` so the search input takes its own line — the Filter
  and Sort chips wrap to a second row instead of squeezing the
  input down to a few characters.
- Global safety net: `html, body { overflow-x: hidden }` below
  640px so any future bug that lets an element overflow the
  viewport doesn't expose touch-pan that shifts the whole layout.
  Fixed descendants (bottom nav, CTA bar) are unaffected.
- audit.mjs — small dev helper that walks all 12 user-facing
  routes at 390px and reports horizontal overflow, internal
  scrollers, and edge-bleeding controls. Used to drive these fixes
  and useful for future mobile changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-08 20:58:21 +02:00
parent 1e3fd27308
commit 00577071fd
11 changed files with 379 additions and 23 deletions

236
frontend/audit.mjs Normal file
View File

@@ -0,0 +1,236 @@
import { chromium } from '@playwright/test';
const MID = 'a1111111-1111-1111-1111-111111111111';
const CID = 'c1111111-1111-1111-1111-111111111111';
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 1 });
const page = await ctx.newPage();
page.on('pageerror', e => console.log(' PAGE ERROR:', e.message));
// Generic stubs so every page renders deterministically without a backend
async function mock() {
await page.unrouteAll().catch(() => {});
await page.route('**/api/v1/auth/config', r => r.fulfill({ status: 200, contentType: 'application/json', body: '{"self_register_enabled":true,"private_mode":false}' }));
await page.route('**/api/v1/auth/me', r => r.fulfill({ status: 401, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' }));
await page.route('**/api/v1/auth/me/preferences', r => r.fulfill({ status: 401, contentType: 'application/json', body: '{}' }));
await page.route('**/api/v1/me/bookmarks*', r => r.fulfill({ status: 401, contentType: 'application/json', body: '{}' }));
await page.route('**/api/v1/me/collections*', r => r.fulfill({ status: 401, contentType: 'application/json', body: '{}' }));
await page.route('**/api/v1/me/read-progress*', r => r.fulfill({ status: 401, contentType: 'application/json', body: '{}' }));
await page.route('**/api/v1/mangas/**', r => {
const url = new URL(r.request().url());
const segs = url.pathname.split('/');
const last = segs[segs.length - 1];
if (last === MID) {
return r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({
id: MID,
title: 'A Very Long Manga Title That Should Ellipsize On Narrow Screens',
status: 'ongoing',
alt_titles: ['Alt One', 'Alt Two'],
// Realistic worst case: a normal-length lorem-ipsum prefix
// followed by a long unbreakable token (e.g. a crawl-source
// URL or a romanized title with no separators). The token
// is what pushed the description past the screen edge on
// user devices.
description: 'Lorem ipsum dolor sit amet '.repeat(8) + ' AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
cover_image_path: `mangas/${MID}/cover.png`,
created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
authors: [{ id: 'au1', name: 'Kentaro-Miura-with-an-extremely-long-author-name' }],
genres: [{ id: 'g1', name: 'Action' }, { id: 'g2', name: 'Dark Fantasy' }, { id: 'g3', name: 'Adventure' }],
// Mix of normal-length and one extreme tag with no soft-break
// opportunities — exercises chip wrap behavior under stress.
tags: [
{ id: 't1', name: 'psychological', added_by: null },
{ id: 't2', name: 'school', added_by: null },
{ id: 't3', name: 'SuperLongUnbrokenTagNameThatDoesNotFitInAnyReasonableChipWidth', added_by: null }
]
}) });
}
if (last === CID) {
return r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ id: CID, manga_id: MID, number: 1, title: 'The Brand', page_count: 3, created_at: '2026-01-01T00:00:00Z' }) });
}
if (last === 'pages') {
return r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ pages: [
{ id: 'p1', chapter_id: CID, page_number: 1, storage_key: 'x.png', content_type: 'image/png' },
{ id: 'p2', chapter_id: CID, page_number: 2, storage_key: 'y.png', content_type: 'image/png' },
{ id: 'p3', chapter_id: CID, page_number: 3, storage_key: 'z.png', content_type: 'image/png' }
] }) });
}
// Mangas list or chapters list
if (url.pathname.endsWith('/chapters')) {
return r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({
items: [{ id: CID, manga_id: MID, number: 1, title: 'The Brand', page_count: 3, created_at: '2026-01-01T00:00:00Z' }],
page: { limit: 50, offset: 0, total: 1 }
}) });
}
const items = Array.from({ length: 8 }, (_, i) => ({
id: `m${i + 1}`, title: `Manga ${i + 1} with a long title that ellipsizes`,
status: 'ongoing', alt_titles: [], description: null, cover_image_path: null,
created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
authors: [{ id: 'a1', name: 'Author' }], genres: [{ id: 'g1', name: 'Action' }], tags: []
}));
return r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items, page: { limit: 50, offset: 0, total: 8 } }) });
});
await page.route('**/api/v1/genres*', r => r.fulfill({ status: 200, contentType: 'application/json', body: '[]' }));
await page.route('**/api/v1/files/**', r => r.fulfill({ status: 200, contentType: 'image/png', body: Buffer.from('89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63000100000005000158a3b62a0000000049454e44ae426082', 'hex') }));
}
const routes = [
['/', 'catalog'],
['/login', 'login'],
['/register', 'register'],
['/upload', 'upload'],
[`/manga/${MID}`, 'detail'],
[`/manga/${MID}/chapter/${CID}`, 'reader'],
['/library', 'library'],
['/profile', 'profile-overview'],
['/profile/account','account'],
['/profile/preferences','preferences'],
['/bookmarks', 'bookmarks-top'],
['/collections', 'collections-top']
];
// Identify elements that "kiss" the viewport edges — their right
// edge is essentially flush with the screen edge. For interactive
// controls (buttons, anchors, inputs, .chip*) this is almost always
// a design bug rather than intentional full-bleed chrome.
function bleeders(thresholdPx) {
const vw = window.innerWidth;
const bad = [];
const candidates = document.querySelectorAll('button, a, input, select, textarea, [class*="chip"], [class*="card"], h1, h2, h3, p');
for (const el of candidates) {
const r = el.getBoundingClientRect();
if (r.width === 0) continue;
// Skip elements whose ancestor is itself position:fixed at the
// edges — that's intentional chrome (bottom nav, CTA bar).
let p = el;
let inFixed = false;
while (p) {
const cs = getComputedStyle(p);
if (cs.position === 'fixed') { inFixed = true; break; }
p = p.parentElement;
}
if (inFixed) continue;
// Touching right edge?
if (vw - r.right < thresholdPx) {
const tag = el.tagName.toLowerCase();
const cls = (typeof el.className === 'string' ? el.className : '').split(' ').filter(c => !!c && !c.startsWith('s-')).slice(0, 2).join('.');
const id = el.dataset.testid ?? cls;
bad.push(`right-bleed ${tag}.${id} right=${Math.round(r.right)}`);
}
// Touching left edge?
if (r.left < thresholdPx) {
const tag = el.tagName.toLowerCase();
const cls = (typeof el.className === 'string' ? el.className : '').split(' ').filter(c => !!c && !c.startsWith('s-')).slice(0, 2).join('.');
const id = el.dataset.testid ?? cls;
bad.push(`left-bleed ${tag}.${id} left=${Math.round(r.left)}`);
}
}
// de-dup
return [...new Set(bad)];
}
for (const [path, name] of routes) {
await mock();
try {
await page.goto('http://localhost:5173' + path, { waitUntil: 'networkidle', timeout: 8000 });
} catch (e) {
console.log(`${name.padEnd(20)} NAV ERROR: ${e.message.slice(0, 60)}`);
continue;
}
await page.waitForTimeout(400);
const m = await page.evaluate(() => {
const all = Array.from(document.querySelectorAll('*'));
const vw = window.innerWidth;
const offenders = [];
for (const el of all) {
const r = el.getBoundingClientRect();
if (r.right > vw + 0.5 || r.width > vw + 0.5) {
const tag = el.tagName.toLowerCase();
const className = typeof el.className === 'string'
? el.className.split(' ').filter(c => !!c).slice(0, 2).join('.')
: '';
const id = el.dataset.testid ?? className;
offenders.push(`${tag}.${id} right=${Math.round(r.right)} w=${Math.round(r.width)}`);
}
}
// Detect horizontal overflow from scroll measurement, but skip
// elements that intentionally clip via overflow:hidden — that's
// ellipsization, not a layout bug.
const scrollers = [];
for (const el of all) {
if (el.scrollWidth > el.clientWidth + 0.5) {
const cs = getComputedStyle(el);
if (cs.overflowX === 'hidden' || cs.overflowX === 'scroll' || cs.overflowX === 'auto') continue;
const tag = el.tagName.toLowerCase();
const className = typeof el.className === 'string'
? el.className.split(' ').filter(c => !!c).slice(0, 2).join('.')
: '';
const id = el.dataset.testid ?? className;
// Walk up to find the offending child too
let child = '';
for (const c of el.children) {
const r = c.getBoundingClientRect();
const er = el.getBoundingClientRect();
if (r.right > er.right + 0.5) {
child = ` child:${c.tagName.toLowerCase()}.${typeof c.className === 'string' ? c.className.split(' ').filter(x => !!x).slice(0, 2).join('.') : ''}(right=${Math.round(r.right)})`;
break;
}
}
scrollers.push(`${tag}.${id} scrollW=${el.scrollWidth} clientW=${el.clientWidth}${child}`);
}
}
return {
innerWidth: vw,
docW: document.documentElement.scrollWidth,
bodyW: document.body.scrollWidth,
offenders: offenders.slice(0, 8),
scrollers: scrollers.slice(0, 8)
};
});
// Also flag elements that touch the viewport edges (no inset gutter)
const bleed = await page.evaluate((b) => {
// re-execute the bleeders function here because page context is fresh
const vw = window.innerWidth;
const threshold = 4;
const bad = [];
const candidates = document.querySelectorAll('button, a, input, select, textarea, [class*="chip"], [class*="card"], h1, h2, h3, p');
for (const el of candidates) {
const r = el.getBoundingClientRect();
if (r.width === 0) continue;
let p = el;
let inFixed = false;
while (p) {
const cs = getComputedStyle(p);
if (cs.position === 'fixed') { inFixed = true; break; }
p = p.parentElement;
}
if (inFixed) continue;
const tag = el.tagName.toLowerCase();
const cls = (typeof el.className === 'string' ? el.className : '').split(' ').filter(c => !!c && !c.startsWith('s-')).slice(0, 2).join('.');
const id = el.dataset.testid ?? cls;
if (vw - r.right < threshold) bad.push(`right-bleed ${tag}.${id} right=${Math.round(r.right)}`);
if (r.left < threshold) bad.push(`left-bleed ${tag}.${id} left=${Math.round(r.left)}`);
}
return [...new Set(bad)];
});
const safe = m.docW <= m.innerWidth && m.bodyW <= m.innerWidth && m.offenders.length === 0 && m.scrollers.length === 0 && bleed.length === 0;
console.log(`${name.padEnd(20)} ${safe ? '✅' : '⚠️ '} doc=${m.docW} body=${m.bodyW} inner=${m.innerWidth}`);
if (m.offenders.length) {
console.log(' --- elements past viewport ---');
for (const o of m.offenders) console.log(' ', o);
}
if (m.scrollers.length) {
console.log(' --- horizontal scrollers ---');
for (const s of m.scrollers) console.log(' ', s);
}
if (bleed.length) {
console.log(' --- edge bleeders (touch viewport) ---');
for (const b of bleed.slice(0, 12)) console.log(' ', b);
}
await page.screenshot({ path: `/tmp/audit-${name}.png`, fullPage: false });
}
await browser.close();