feat(frontend): mobile primitives + bottom-nav chrome (0.56.0)

Phase 1 of the mobile redesign: add the reusable primitives every
later phase plugs into, and switch the layout to a 4-tab bottom nav
+ compact AppBar on phone viewports while leaving the desktop header
untouched above 640px.

- New primitives: BottomNav, Sheet, AppBar, SegmentedControl, all
  with vitest unit tests.
- Layout swaps the desktop header for AppBar + BottomNav under the
  existing 640px breakpoint. Login / register / reader routes opt
  out of the mobile chrome.
- Library tab currently points at /bookmarks; the curated /library
  wrapper lands in Phase 5.
- Independent ResizeObservers publish --app-header-h,
  --mobile-app-bar-h, --app-bottom-nav-h, with a zero-height skip so
  hidden bars don't clobber the visible one's value.
- viewport-fit=cover + env(safe-area-inset-*) tokens for notched
  devices and the iOS home indicator.
- Playwright spec covers visibility at 390px / 1280px viewports and
  tab navigation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-06 19:46:16 +02:00
parent d6ac648ac9
commit 6dcb720a0f
15 changed files with 1045 additions and 18 deletions

View File

@@ -7,17 +7,25 @@
import { preferences } from '$lib/preferences.svelte';
import { session } from '$lib/session.svelte';
import { theme } from '$lib/theme.svelte';
import AppBar from '$lib/components/AppBar.svelte';
import BottomNav, { type BottomNavTab } from '$lib/components/BottomNav.svelte';
import Upload from '@lucide/svelte/icons/upload';
import UserCircle from '@lucide/svelte/icons/user-circle';
import Bookmark from '@lucide/svelte/icons/bookmark';
import FolderOpen from '@lucide/svelte/icons/folder-open';
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';
let { children, data } = $props();
let loggingOut = $state(false);
let headerEl: HTMLElement | undefined = $state();
let appBarEl: HTMLElement | undefined = $state();
let bottomNavEl: HTMLElement | undefined = $state();
// Static-route title map. Dynamic pages (manga / author / collection /
// chapter) override this via their own <svelte:head><title>, since the
@@ -45,6 +53,46 @@
const layoutTitle = $derived(STATIC_TITLES[$page.route?.id ?? ''] ?? 'Mangalord');
// Mobile bottom-nav tabs. Library consolidates Bookmarks / Collections /
// History — it currently links to /bookmarks as the closest existing
// route; Phase 5 introduces /library with internal segmented sub-tabs
// and this href flips at that point. Browse temporarily shares Home's
// destination until the curated-feed split lands.
const MOBILE_TABS: BottomNavTab[] = [
{ label: 'Home', href: '/', icon: House, match: [] },
{ label: 'Browse', href: '/', icon: Search, match: [] },
{
label: 'Library',
href: '/bookmarks',
icon: BookOpen,
match: [
'/bookmarks',
'/collections',
'/profile/bookmarks',
'/profile/collections',
'/profile/history'
]
},
{
label: 'Account',
href: '/profile/account',
icon: User,
match: ['/profile', '/profile/preferences']
}
];
// Auth-gate and reader routes hide the mobile chrome entirely — the
// bottom nav would be visual clutter on a login form and would fight
// the reader's own chrome for screen real-estate. The desktop header
// is unaffected; it continues to follow the reader fullscreen slide
// transform.
const HIDE_MOBILE_CHROME_PATHS = new Set(['/login', '/register']);
const showMobileChrome = $derived.by(() => {
if (HIDE_MOBILE_CHROME_PATHS.has($page.url.pathname)) return false;
if ($page.route?.id === '/manga/[id]/chapter/[chapter_id]') return false;
return true;
});
// Seed authConfig from the universal layout load. $effect keeps
// the store in sync if `data` is replaced by a subsequent layout
// load (client-side nav). The first run also covers initial
@@ -58,25 +106,55 @@
theme.init();
preferences.init();
if (!session.loaded) session.refresh();
});
// Publish the header's measured height as a CSS custom
// property so sticky descendants (e.g. the reader nav) can
// pin themselves directly below it without guessing. A
// ResizeObserver keeps it in sync as the viewport reflows
// (the nav `flex-wrap: wrap`s on narrow widths), the user
// zooms, or fonts swap. Hard-coded pixel offsets in tokens
// are wrong in principle — actual height varies with all
// of the above.
// Three runtime height publishers — each is an independent $effect
// keyed by its element ref, so they handle mount, unmount, and
// SPA navigation (e.g. entering / leaving the reader, which
// toggles `showMobileChrome`) without leaking observers.
//
// Skipping publish when offsetHeight === 0 is what lets the
// desktop header and the mobile AppBar coexist in the DOM: only
// the one visible at the current viewport publishes a non-zero
// height; the other (display: none) stays at its CSS fallback.
$effect(() => {
if (!headerEl) return;
const el = headerEl;
const publish = () => {
document.documentElement.style.setProperty(
'--app-header-h',
`${headerEl!.offsetHeight}px`
);
const h = el.offsetHeight;
if (h > 0) document.documentElement.style.setProperty('--app-header-h', `${h}px`);
};
publish();
const ro = new ResizeObserver(publish);
ro.observe(headerEl);
ro.observe(el);
return () => ro.disconnect();
});
$effect(() => {
if (!appBarEl) return;
const el = appBarEl;
const publish = () => {
const h = el.offsetHeight;
if (h > 0)
document.documentElement.style.setProperty('--mobile-app-bar-h', `${h}px`);
};
publish();
const ro = new ResizeObserver(publish);
ro.observe(el);
return () => ro.disconnect();
});
$effect(() => {
if (!bottomNavEl) return;
const el = bottomNavEl;
const publish = () => {
const h = el.offsetHeight;
if (h > 0)
document.documentElement.style.setProperty('--app-bottom-nav-h', `${h}px`);
};
publish();
const ro = new ResizeObserver(publish);
ro.observe(el);
return () => ro.disconnect();
});
@@ -109,7 +187,13 @@
<title>{layoutTitle}</title>
</svelte:head>
<header bind:this={headerEl}>
{#if showMobileChrome}
<div class="mobile-app-bar-wrap" bind:this={appBarEl}>
<AppBar title="Mangalord" testid="mobile-app-bar" />
</div>
{/if}
<header bind:this={headerEl} class="desktop-header">
<nav aria-label="primary">
<a class="brand" href="/">Mangalord</a>
<a class="nav-link" href="/upload">
@@ -167,6 +251,12 @@
{@render children()}
</main>
{#if showMobileChrome}
<div class="mobile-bottom-nav-wrap" bind:this={bottomNavEl}>
<BottomNav tabs={MOBILE_TABS} currentPath={$page.url.pathname} />
</div>
{/if}
<style>
header {
padding: var(--space-3) var(--space-4);
@@ -290,4 +380,49 @@
edge-to-edge once the header has slid off. */
padding-top: 0;
}
/* Mobile chrome (AppBar + BottomNav) is rendered always when the
route opts in, but CSS-hides above the breakpoint so the desktop
header is the only visible top-chrome. The ResizeObserver in the
script ignores zero offsetHeight so the hidden element doesn't
clobber the visible one's published var. */
.mobile-app-bar-wrap {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: var(--z-sticky);
transform: translateY(0);
transition: transform 220ms ease-out;
}
:global(html[data-reader-fullscreen='true']) .mobile-app-bar-wrap {
transform: translateY(-100%);
}
.mobile-app-bar-wrap,
.mobile-bottom-nav-wrap {
display: none;
}
@media (max-width: 640px) {
.desktop-header {
display: none;
}
.mobile-app-bar-wrap,
.mobile-bottom-nav-wrap {
display: block;
}
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. */
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);
}
}
</style>