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

View File

@@ -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;
}
</style>

View File

@@ -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 {

View File

@@ -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;
}
}
</style>

View File

@@ -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,

View File

@@ -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);
}
}
</style>

View File

@@ -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);
}
}
</style>

View File

@@ -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 {