From 00577071fd9c86cc477aff2c16279a66b6422214 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Mon, 8 Jun 2026 20:58:21 +0200 Subject: [PATCH] fix(frontend): mobile layout polish + overflow handling (0.60.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- frontend/audit.mjs | 236 +++++++++++++++++++ frontend/package.json | 2 +- frontend/src/lib/components/BottomNav.svelte | 14 +- frontend/src/lib/components/Chip.svelte | 12 +- frontend/src/lib/components/MangaCard.svelte | 20 ++ frontend/src/lib/styles/tokens.css | 13 + frontend/src/routes/+layout.svelte | 20 +- frontend/src/routes/+page.svelte | 22 +- frontend/src/routes/manga/[id]/+page.svelte | 59 ++++- 11 files changed, 379 insertions(+), 23 deletions(-) create mode 100644 frontend/audit.mjs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index d3813f7..2e1d268 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.60.0" +version = "0.60.1" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index abb8d58..71afb92 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.60.0" +version = "0.60.1" edition = "2021" default-run = "mangalord" diff --git a/frontend/audit.mjs b/frontend/audit.mjs new file mode 100644 index 0000000..3f8f350 --- /dev/null +++ b/frontend/audit.mjs @@ -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(); diff --git a/frontend/package.json b/frontend/package.json index aeaba1c..6814999 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.60.0", + "version": "0.60.1", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/components/BottomNav.svelte b/frontend/src/lib/components/BottomNav.svelte index 4bf0677..4c261ed 100644 --- a/frontend/src/lib/components/BottomNav.svelte +++ b/frontend/src/lib/components/BottomNav.svelte @@ -73,9 +73,12 @@ right: 0; bottom: 0; z-index: var(--z-sticky); + /* Explicit columns with `minmax(0, 1fr)` so a single wide tab + label can't push the bar past the viewport edge. `grid-auto- + columns: 1fr` resolves to `minmax(auto, 1fr)`, whose auto + min == intrinsic content width — exactly what we don't want. */ display: grid; - grid-auto-columns: 1fr; - grid-auto-flow: column; + grid-template-columns: repeat(var(--bottom-nav-tabs, 4), minmax(0, 1fr)); background: var(--surface); border-top: 1px solid var(--border); /* Safe-area inset lifts the bar above the iOS home indicator @@ -101,6 +104,9 @@ gap: 2px; padding: var(--space-2) var(--space-1); min-height: 56px; + /* Mirror the grid-column min-width:0 escape so a long label + clips with the cell rather than expanding it. */ + min-width: 0; color: var(--text-muted); text-decoration: none; transition: color var(--transition); @@ -116,8 +122,12 @@ } .label { + max-width: 100%; font-size: var(--font-xs); font-weight: var(--weight-medium); line-height: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } diff --git a/frontend/src/lib/components/Chip.svelte b/frontend/src/lib/components/Chip.svelte index b4f2cb4..ad7928e 100644 --- a/frontend/src/lib/components/Chip.svelte +++ b/frontend/src/lib/components/Chip.svelte @@ -69,7 +69,11 @@ border: 1px solid var(--border); color: var(--text); text-decoration: none; - white-space: nowrap; + /* Cap the chip at its row width so a long author handle or + tag slug doesn't push past the chip-row's edge. min-width: 0 + is the flex-item escape so it can actually shrink. */ + max-width: 100%; + min-width: 0; } a.chip:hover { @@ -91,8 +95,14 @@ } .chip-label { + min-width: 0; overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; + /* `anywhere` lets a no-space token (a romanized title, a tag + slug) break before it ellipsizes — keeps the chip inside + its row even when the label has no soft-break points. */ + overflow-wrap: anywhere; } .chip-remove { diff --git a/frontend/src/lib/components/MangaCard.svelte b/frontend/src/lib/components/MangaCard.svelte index 917f62e..c8f16b8 100644 --- a/frontend/src/lib/components/MangaCard.svelte +++ b/frontend/src/lib/components/MangaCard.svelte @@ -93,6 +93,11 @@ flex-direction: column; gap: var(--space-2); list-style: none; + /* Grid items default to min-width: auto which equals the + intrinsic content size — long author / title strings then + push the cell past `1fr`. Forcing 0 lets the column control + the width and the children ellipsize inside it. */ + min-width: 0; } .cover-link { @@ -172,6 +177,10 @@ -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; + /* A title made of a single unbreakable string would push the + card past its 1fr column. `anywhere` keeps normal whitespace + breaks preferred but lets the renderer cut a long word. */ + overflow-wrap: anywhere; } .title:hover { @@ -187,4 +196,15 @@ overflow: hidden; text-overflow: ellipsis; } + + /* On phones the catalog packs 4 covers per row — at that width + the author + genre lines just become two strips of ellipsized + noise, and dropping them keeps every cell exactly the same + height (cover + 2-line title clamp). */ + @media (max-width: 640px) { + .author, + .genres { + display: none; + } + } diff --git a/frontend/src/lib/styles/tokens.css b/frontend/src/lib/styles/tokens.css index 579bfd7..5cea82f 100644 --- a/frontend/src/lib/styles/tokens.css +++ b/frontend/src/lib/styles/tokens.css @@ -282,6 +282,19 @@ img { height: auto; } +/* Horizontal-overflow safety net on phones. Manga apps never have a + legitimate need for horizontal page scroll, and an off-screen + element here often means another bug — but it's better to clip + silently than to expose touch-pan that shifts the whole layout + under the user's thumb. position:fixed descendants are unaffected, + so the bottom nav / sticky CTAs still span edge-to-edge. */ +@media (max-width: 640px) { + html, + body { + overflow-x: hidden; + } +} + @media (prefers-reduced-motion: reduce) { *, *::before, diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 391944d..c8d4095 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -16,7 +16,6 @@ import LogOut from '@lucide/svelte/icons/log-out'; import Shield from '@lucide/svelte/icons/shield'; import House from '@lucide/svelte/icons/house'; - import Search from '@lucide/svelte/icons/search'; import BookOpen from '@lucide/svelte/icons/book-open'; import User from '@lucide/svelte/icons/user'; import '$lib/styles/tokens.css'; @@ -54,15 +53,17 @@ const layoutTitle = $derived(STATIC_TITLES[$page.route?.id ?? ''] ?? 'Mangalord'); - // Mobile bottom-nav tabs. Library is now its own wrapper at /library + // Mobile bottom-nav tabs. Library is its own wrapper at /library // (Phase 5) hosting a SegmentedControl over Bookmarks / Collections / // History; the older top-level /bookmarks + /collections routes stay // active for desktop users and as deep-link targets, so they remain - // in the `match` set. Browse temporarily shares Home's destination - // until the curated-feed split lands. + // in the `match` set. Upload replaced the placeholder Browse tab + // (which used to share Home's destination) with a real action; the + // curated-feed split is deferred until there's curated content to + // show. const MOBILE_TABS: BottomNavTab[] = [ { label: 'Home', href: '/', icon: House, match: [] }, - { label: 'Browse', href: '/', icon: Search, match: [] }, + { label: 'Upload', href: '/upload', icon: Upload, match: ['/upload'] }, { label: 'Library', href: '/library', @@ -437,11 +438,14 @@ main { /* On mobile, swap the desktop-header reservation for the AppBar's. BottomNav reservation goes on the bottom so - the last bit of content isn't trapped under the bar. */ + the last bit of content isn't trapped under the bar. + 16px horizontal gutter matches iOS/Material standard + and gives chips, description text, and chapter rows + enough breathing room from the screen edge. */ padding-top: calc(var(--mobile-app-bar-h) + var(--space-3)); padding-bottom: calc(var(--app-bottom-nav-h) + var(--space-4)); - padding-left: var(--space-3); - padding-right: var(--space-3); + padding-left: var(--space-4); + padding-right: var(--space-4); } } diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index d077ab7..384ea48 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -582,6 +582,17 @@ max-width: 28rem; } + @media (max-width: 640px) { + /* Give the search input its own row so the Filter + Sort chips + can't squeeze it down to a few characters. The chips wrap to + a second row naturally; the icon-btn submit stays paired with + the input on row one. */ + .search { + flex-basis: calc(100% - 36px - var(--space-2)); + max-width: none; + } + } + .filters-toggle { display: inline-flex; align-items: center; @@ -847,10 +858,13 @@ } .manga-grid { - /* Lock to two columns on phones; the auto-fill default - packs 2-3 unpredictably at narrow widths. */ - grid-template-columns: repeat(2, 1fr); - gap: var(--space-3); + /* Four covers per row on phones (per user preference) at a + tight gap. `minmax(0, 1fr)` is load-bearing — without it + the implicit `minmax(auto, 1fr)` lets grid cells grow to + their intrinsic min-content width and push the whole + grid past the viewport edge. */ + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--space-2); } } diff --git a/frontend/src/routes/manga/[id]/+page.svelte b/frontend/src/routes/manga/[id]/+page.svelte index a444ec1..c32105b 100644 --- a/frontend/src/routes/manga/[id]/+page.svelte +++ b/frontend/src/routes/manga/[id]/+page.svelte @@ -754,6 +754,10 @@ white-space: pre-wrap; color: var(--text); margin: var(--space-3) 0; + /* Long URLs / romanized titles in the description shouldn't + push the article past the screen — `anywhere` breaks them + before they overflow. */ + overflow-wrap: anywhere; } .tag-row { @@ -947,10 +951,23 @@ } @media (max-width: 640px) { + /* article gets explicit horizontal clip so the hero's + negative margins (which bleed edge-to-edge by extending + past main's `--space-3` gutters) don't show up as touch- + pannable internal scroll. Fixed-position descendants like + .cta-bar escape this clip so the sticky CTA still spans + the full viewport. */ + article { + overflow-x: hidden; + } + .mobile-hero { position: relative; display: block; - margin: 0 calc(-1 * var(--space-3)) var(--space-4); + /* Negative margin cancels main's 16px mobile gutter so the + blurred backdrop runs edge-to-edge — keep this number in + sync with main's padding-left/right in +layout.svelte. */ + margin: 0 calc(-1 * var(--space-4)) var(--space-4); overflow: hidden; color: #fff; min-height: 220px; @@ -983,8 +1000,12 @@ display: flex; align-items: center; gap: var(--space-1); - padding: var(--space-2) var(--space-2); - padding-top: calc(var(--space-2) + var(--safe-top)); + /* 16px horizontal gutter + 16px above the buttons so they + aren't kissing the screen top or hero edges. iOS / Material + both ship app-bar buttons with ~16dp inset; tighter than + that reads as "stuck to the border". */ + padding: var(--space-4) var(--space-4); + padding-top: calc(var(--space-4) + var(--safe-top)); min-height: 48px; } @@ -1016,7 +1037,10 @@ z-index: 1; display: flex; gap: var(--space-3); - padding: var(--space-3) var(--space-3) var(--space-4); + /* 20px side padding gives the cover thumb + title visible + separation from the hero's edge (vs. main's 16px gutter, + which on a tinted hero reads as flush). */ + padding: var(--space-3) var(--space-5) var(--space-5); } .hero-cover { @@ -1049,6 +1073,7 @@ line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; + overflow-wrap: anywhere; } .hero-authors { @@ -1089,10 +1114,29 @@ } .overview { - grid-template-columns: 1fr; + /* `1fr` resolves to `minmax(auto, 1fr)` and the `auto` + minimum is the intrinsic content size — a single long + unbroken tag or URL in .meta then pushes the whole + column past the article's gutter. `minmax(0, 1fr)` + clamps it so the grid item respects parent width and + its children's `overflow-wrap: anywhere` actually + takes effect. */ + grid-template-columns: minmax(0, 1fr); margin-bottom: var(--space-4); } + .overview .meta { + /* Defensive — every flex/grid container down this tree + needs a min-width:0 escape so unbreakable tokens can't + re-expand. */ + min-width: 0; + } + + .overview .chip-row { + min-width: 0; + max-width: 100%; + } + /* Inline action-row is replaced by the AppBar bookmark icon + overflow sheet on mobile; the inline Continue link is replaced by the sticky bottom CTA. */ @@ -1155,8 +1199,13 @@ color: var(--primary-contrast); border-radius: var(--radius-md); text-decoration: none; + text-align: center; font-weight: var(--weight-semibold); box-shadow: var(--shadow-md); + /* "Continue {chapterLabel}" can carry an unbroken slug like + a hash or romanized title; `anywhere` keeps the button + from pushing past the .cta-bar's padded gutter. */ + overflow-wrap: anywhere; } .cta:hover {