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>
134 lines
4.3 KiB
Svelte
134 lines
4.3 KiB
Svelte
<script lang="ts">
|
|
import type { Component } from 'svelte';
|
|
|
|
export interface BottomNavTab {
|
|
label: string;
|
|
href: string;
|
|
icon: Component<{ size?: number; 'aria-hidden'?: boolean | 'true' | 'false' }>;
|
|
/**
|
|
* URL pathname prefixes that mark this tab as active. Longest
|
|
* matching prefix wins across tabs, so order matters less than
|
|
* specificity (e.g. '/profile/account' beats '/profile'). The
|
|
* tab's own `href` is implicitly included.
|
|
*/
|
|
match: string[];
|
|
}
|
|
|
|
let {
|
|
tabs,
|
|
currentPath,
|
|
testid = 'bottom-nav'
|
|
}: {
|
|
tabs: BottomNavTab[];
|
|
currentPath: string;
|
|
testid?: string;
|
|
} = $props();
|
|
|
|
/**
|
|
* Pick the tab with the longest `match` prefix that matches the
|
|
* current path. Ties (no match) fall back to no active tab. Each
|
|
* tab's own href is treated as a match candidate too, so a Home
|
|
* tab pointing at '/' is active on '/' without an explicit entry.
|
|
*/
|
|
const activeHref = $derived.by(() => {
|
|
let best: { href: string; len: number } | null = null;
|
|
for (const tab of tabs) {
|
|
const candidates = [tab.href, ...tab.match];
|
|
for (const prefix of candidates) {
|
|
if (!prefixMatches(currentPath, prefix)) continue;
|
|
if (!best || prefix.length > best.len) {
|
|
best = { href: tab.href, len: prefix.length };
|
|
}
|
|
}
|
|
}
|
|
return best?.href ?? null;
|
|
});
|
|
|
|
function prefixMatches(path: string, prefix: string): boolean {
|
|
if (prefix === '/') return path === '/';
|
|
return path === prefix || path.startsWith(prefix + '/');
|
|
}
|
|
</script>
|
|
|
|
<nav class="bottom-nav" aria-label="Primary" data-testid={testid}>
|
|
{#each tabs as tab (tab.href + tab.label)}
|
|
{@const isActive = activeHref === tab.href}
|
|
<a
|
|
href={tab.href}
|
|
class="tab"
|
|
class:active={isActive}
|
|
aria-current={isActive ? 'page' : undefined}
|
|
data-testid={`${testid}-${tab.label.toLowerCase()}`}
|
|
>
|
|
<tab.icon size={22} aria-hidden="true" />
|
|
<span class="label">{tab.label}</span>
|
|
</a>
|
|
{/each}
|
|
</nav>
|
|
|
|
<style>
|
|
.bottom-nav {
|
|
position: fixed;
|
|
left: 0;
|
|
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-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
|
|
without collapsing the layout on non-notch devices (env()
|
|
resolves to 0 there). */
|
|
padding-bottom: var(--safe-bottom);
|
|
padding-left: var(--safe-left);
|
|
padding-right: var(--safe-right);
|
|
transition: transform 220ms ease-out;
|
|
}
|
|
|
|
/* Reader fullscreen mode slides the bar off-screen, mirroring the
|
|
top-header pattern in +layout.svelte. */
|
|
:global(html[data-reader-fullscreen='true']) .bottom-nav {
|
|
transform: translateY(100%);
|
|
}
|
|
|
|
.tab {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
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);
|
|
}
|
|
|
|
.tab:hover {
|
|
color: var(--text);
|
|
text-decoration: none;
|
|
}
|
|
|
|
.tab.active {
|
|
color: var(--primary);
|
|
}
|
|
|
|
.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>
|