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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.60.0"
|
version = "0.60.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.60.0"
|
version = "0.60.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
236
frontend/audit.mjs
Normal file
236
frontend/audit.mjs
Normal 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();
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.60.0",
|
"version": "0.60.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -73,9 +73,12 @@
|
|||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
z-index: var(--z-sticky);
|
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;
|
display: grid;
|
||||||
grid-auto-columns: 1fr;
|
grid-template-columns: repeat(var(--bottom-nav-tabs, 4), minmax(0, 1fr));
|
||||||
grid-auto-flow: column;
|
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
/* Safe-area inset lifts the bar above the iOS home indicator
|
/* Safe-area inset lifts the bar above the iOS home indicator
|
||||||
@@ -101,6 +104,9 @@
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
padding: var(--space-2) var(--space-1);
|
padding: var(--space-2) var(--space-1);
|
||||||
min-height: 56px;
|
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);
|
color: var(--text-muted);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: color var(--transition);
|
transition: color var(--transition);
|
||||||
@@ -116,8 +122,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
|
max-width: 100%;
|
||||||
font-size: var(--font-xs);
|
font-size: var(--font-xs);
|
||||||
font-weight: var(--weight-medium);
|
font-weight: var(--weight-medium);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -69,7 +69,11 @@
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
text-decoration: none;
|
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 {
|
a.chip:hover {
|
||||||
@@ -91,8 +95,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chip-label {
|
.chip-label {
|
||||||
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
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 {
|
.chip-remove {
|
||||||
|
|||||||
@@ -93,6 +93,11 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
list-style: none;
|
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 {
|
.cover-link {
|
||||||
@@ -172,6 +177,10 @@
|
|||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
line-clamp: 2;
|
line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-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 {
|
.title:hover {
|
||||||
@@ -187,4 +196,15 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -282,6 +282,19 @@ img {
|
|||||||
height: auto;
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
import LogOut from '@lucide/svelte/icons/log-out';
|
import LogOut from '@lucide/svelte/icons/log-out';
|
||||||
import Shield from '@lucide/svelte/icons/shield';
|
import Shield from '@lucide/svelte/icons/shield';
|
||||||
import House from '@lucide/svelte/icons/house';
|
import House from '@lucide/svelte/icons/house';
|
||||||
import Search from '@lucide/svelte/icons/search';
|
|
||||||
import BookOpen from '@lucide/svelte/icons/book-open';
|
import BookOpen from '@lucide/svelte/icons/book-open';
|
||||||
import User from '@lucide/svelte/icons/user';
|
import User from '@lucide/svelte/icons/user';
|
||||||
import '$lib/styles/tokens.css';
|
import '$lib/styles/tokens.css';
|
||||||
@@ -54,15 +53,17 @@
|
|||||||
|
|
||||||
const layoutTitle = $derived(STATIC_TITLES[$page.route?.id ?? ''] ?? 'Mangalord');
|
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 /
|
// (Phase 5) hosting a SegmentedControl over Bookmarks / Collections /
|
||||||
// History; the older top-level /bookmarks + /collections routes stay
|
// History; the older top-level /bookmarks + /collections routes stay
|
||||||
// active for desktop users and as deep-link targets, so they remain
|
// active for desktop users and as deep-link targets, so they remain
|
||||||
// in the `match` set. Browse temporarily shares Home's destination
|
// in the `match` set. Upload replaced the placeholder Browse tab
|
||||||
// until the curated-feed split lands.
|
// (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[] = [
|
const MOBILE_TABS: BottomNavTab[] = [
|
||||||
{ label: 'Home', href: '/', icon: House, match: [] },
|
{ label: 'Home', href: '/', icon: House, match: [] },
|
||||||
{ label: 'Browse', href: '/', icon: Search, match: [] },
|
{ label: 'Upload', href: '/upload', icon: Upload, match: ['/upload'] },
|
||||||
{
|
{
|
||||||
label: 'Library',
|
label: 'Library',
|
||||||
href: '/library',
|
href: '/library',
|
||||||
@@ -437,11 +438,14 @@
|
|||||||
main {
|
main {
|
||||||
/* On mobile, swap the desktop-header reservation for the
|
/* On mobile, swap the desktop-header reservation for the
|
||||||
AppBar's. BottomNav reservation goes on the bottom so
|
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-top: calc(var(--mobile-app-bar-h) + var(--space-3));
|
||||||
padding-bottom: calc(var(--app-bottom-nav-h) + var(--space-4));
|
padding-bottom: calc(var(--app-bottom-nav-h) + var(--space-4));
|
||||||
padding-left: var(--space-3);
|
padding-left: var(--space-4);
|
||||||
padding-right: var(--space-3);
|
padding-right: var(--space-4);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -582,6 +582,17 @@
|
|||||||
max-width: 28rem;
|
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 {
|
.filters-toggle {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -847,10 +858,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.manga-grid {
|
.manga-grid {
|
||||||
/* Lock to two columns on phones; the auto-fill default
|
/* Four covers per row on phones (per user preference) at a
|
||||||
packs 2-3 unpredictably at narrow widths. */
|
tight gap. `minmax(0, 1fr)` is load-bearing — without it
|
||||||
grid-template-columns: repeat(2, 1fr);
|
the implicit `minmax(auto, 1fr)` lets grid cells grow to
|
||||||
gap: var(--space-3);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -754,6 +754,10 @@
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
margin: var(--space-3) 0;
|
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 {
|
.tag-row {
|
||||||
@@ -947,10 +951,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@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 {
|
.mobile-hero {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: block;
|
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;
|
overflow: hidden;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
min-height: 220px;
|
min-height: 220px;
|
||||||
@@ -983,8 +1000,12 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
padding: var(--space-2) var(--space-2);
|
/* 16px horizontal gutter + 16px above the buttons so they
|
||||||
padding-top: calc(var(--space-2) + var(--safe-top));
|
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;
|
min-height: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1016,7 +1037,10 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-3);
|
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 {
|
.hero-cover {
|
||||||
@@ -1049,6 +1073,7 @@
|
|||||||
line-clamp: 2;
|
line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-authors {
|
.hero-authors {
|
||||||
@@ -1089,10 +1114,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.overview {
|
.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);
|
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 +
|
/* Inline action-row is replaced by the AppBar bookmark icon +
|
||||||
overflow sheet on mobile; the inline Continue link is
|
overflow sheet on mobile; the inline Continue link is
|
||||||
replaced by the sticky bottom CTA. */
|
replaced by the sticky bottom CTA. */
|
||||||
@@ -1155,8 +1199,13 @@
|
|||||||
color: var(--primary-contrast);
|
color: var(--primary-contrast);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
text-align: center;
|
||||||
font-weight: var(--weight-semibold);
|
font-weight: var(--weight-semibold);
|
||||||
box-shadow: var(--shadow-md);
|
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 {
|
.cta:hover {
|
||||||
|
|||||||
Reference in New Issue
Block a user