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

2
backend/Cargo.lock generated
View File

@@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
version = "0.55.0"
version = "0.56.0"
dependencies = [
"anyhow",
"argon2",

View File

@@ -1,6 +1,6 @@
[package]
name = "mangalord"
version = "0.55.0"
version = "0.56.0"
edition = "2021"
default-run = "mangalord"

View File

@@ -0,0 +1,84 @@
import { test, expect, type Page } from '@playwright/test';
// Mobile chrome contract: the AppBar + BottomNav are visible on phone
// viewports and the desktop header is hidden. On desktop, the reverse.
// On the reader route, both mobile bars are hidden so the reader's own
// chrome owns the screen. The 640px cutover is the project's single
// existing breakpoint and is also used here.
const MOBILE = { width: 390, height: 844 } as const;
const DESKTOP = { width: 1280, height: 720 } as const;
async function mockAnonymous(page: Page) {
await page.route('**/api/v1/auth/config', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
});
});
await page.route('**/api/v1/auth/me', async (route) => {
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'unauthenticated', message: 'unauthenticated' } })
});
});
await page.route('**/api/v1/mangas*', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } })
});
});
}
test.describe('mobile chrome', () => {
test('phone viewport on /: bottom nav visible, desktop header hidden', async ({ page }) => {
await mockAnonymous(page);
await page.setViewportSize(MOBILE);
await page.goto('/');
await expect(page.getByTestId('bottom-nav')).toBeVisible();
await expect(page.getByTestId('mobile-app-bar')).toBeVisible();
await expect(page.locator('header.desktop-header')).toBeHidden();
});
test('desktop viewport on /: desktop header visible, mobile chrome hidden', async ({ page }) => {
await mockAnonymous(page);
await page.setViewportSize(DESKTOP);
await page.goto('/');
await expect(page.locator('header.desktop-header')).toBeVisible();
await expect(page.getByTestId('bottom-nav')).toBeHidden();
await expect(page.getByTestId('mobile-app-bar')).toBeHidden();
});
test('phone viewport: Home tab carries aria-current on /', async ({ page }) => {
await mockAnonymous(page);
await page.setViewportSize(MOBILE);
await page.goto('/');
const home = page.getByTestId('bottom-nav-home');
await expect(home).toHaveAttribute('aria-current', 'page');
});
test('phone viewport: tapping Library navigates to /bookmarks and marks itself active', async ({ page }) => {
await mockAnonymous(page);
await page.setViewportSize(MOBILE);
await page.goto('/');
await page.getByTestId('bottom-nav-library').click();
await expect(page).toHaveURL(/\/bookmarks$/);
await expect(page.getByTestId('bottom-nav-library')).toHaveAttribute('aria-current', 'page');
});
test('phone viewport: login route hides the mobile chrome (auth gateway pattern)', async ({ page }) => {
await mockAnonymous(page);
await page.setViewportSize(MOBILE);
await page.goto('/login');
await expect(page.getByTestId('bottom-nav')).toBeHidden();
await expect(page.getByTestId('mobile-app-bar')).toBeHidden();
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
"version": "0.55.0",
"version": "0.56.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -3,7 +3,10 @@
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
/>
<title>Mangalord</title>
<script>
(function () {

View File

@@ -0,0 +1,114 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
let {
title,
backHref,
onBack,
trailing,
testid
}: {
title: string;
/**
* If set, the leading slot renders a back chevron linking here.
* Mutually exclusive with onBack (href wins). Omit both for a
* brand-only bar (no leading control).
*/
backHref?: string;
onBack?: () => void;
trailing?: Snippet;
testid?: string;
} = $props();
</script>
<header class="app-bar" data-testid={testid}>
<div class="leading">
{#if backHref}
<a
href={backHref}
class="back"
aria-label="Back"
data-testid={testid ? `${testid}-back` : undefined}
>
<ChevronLeft size={22} aria-hidden="true" />
</a>
{:else if onBack}
<button
type="button"
class="back"
onclick={onBack}
aria-label="Back"
data-testid={testid ? `${testid}-back` : undefined}
>
<ChevronLeft size={22} aria-hidden="true" />
</button>
{/if}
</div>
<h1 class="title">{title}</h1>
<div class="trailing">
{#if trailing}{@render trailing()}{/if}
</div>
</header>
<style>
.app-bar {
display: grid;
grid-template-columns: 44px 1fr auto;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
padding-top: calc(var(--space-2) + var(--safe-top));
padding-left: calc(var(--space-3) + var(--safe-left));
padding-right: calc(var(--space-3) + var(--safe-right));
background: var(--surface);
border-bottom: 1px solid var(--border);
min-height: 48px;
}
.leading {
display: flex;
align-items: center;
justify-content: flex-start;
}
.back {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
padding: 0;
background: transparent;
color: var(--text);
border: 1px solid transparent;
border-radius: var(--radius-md);
text-decoration: none;
}
.back:hover {
background: var(--surface-elevated);
text-decoration: none;
}
.title {
font-size: var(--font-lg);
font-weight: var(--weight-semibold);
line-height: var(--leading-tight);
margin: 0;
text-align: center;
/* Long titles ellipsize rather than wrap — the bar must stay
a single row to keep its measured height predictable. */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trailing {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-1);
min-width: 44px;
}
</style>

View File

@@ -0,0 +1,40 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/svelte';
import { createRawSnippet } from 'svelte';
import AppBar from './AppBar.svelte';
afterEach(() => cleanup());
describe('AppBar', () => {
it('renders the title as the h1', () => {
render(AppBar, { props: { title: 'Mangalord' } });
expect(screen.getByRole('heading', { level: 1, name: 'Mangalord' })).toBeTruthy();
});
it('omits the back control when neither backHref nor onBack is provided', () => {
render(AppBar, { props: { title: 'Home' } });
expect(screen.queryByRole('link', { name: 'Back' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Back' })).toBeNull();
});
it('renders a back link when backHref is provided', () => {
render(AppBar, { props: { title: 'Detail', backHref: '/library' } });
const back = screen.getByRole('link', { name: 'Back' });
expect((back as HTMLAnchorElement).getAttribute('href')).toBe('/library');
});
it('renders a back button calling onBack when onBack is provided (and no href)', () => {
const onBack = vi.fn();
render(AppBar, { props: { title: 'Detail', onBack } });
screen.getByRole('button', { name: 'Back' }).click();
expect(onBack).toHaveBeenCalledOnce();
});
it('renders the trailing snippet when provided', () => {
const trailing = createRawSnippet(() => ({
render: () => `<button type="button" aria-label="More">…</button>`
}));
render(AppBar, { props: { title: 'Detail', trailing } });
expect(screen.getByRole('button', { name: 'More' })).toBeTruthy();
});
});

View File

@@ -0,0 +1,123 @@
<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);
display: grid;
grid-auto-columns: 1fr;
grid-auto-flow: column;
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;
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 {
font-size: var(--font-xs);
font-weight: var(--weight-medium);
line-height: 1;
}
</style>

View File

@@ -0,0 +1,72 @@
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/svelte';
import Home 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 BottomNav, { type BottomNavTab } from './BottomNav.svelte';
afterEach(() => cleanup());
const tabs: BottomNavTab[] = [
{ label: 'Home', href: '/', icon: Home, match: [] },
{ label: 'Browse', href: '/browse', icon: Search, match: [] },
{
label: 'Library',
href: '/library',
icon: BookOpen,
match: ['/bookmarks', '/collections', '/profile/bookmarks', '/profile/collections', '/profile/history']
},
{
label: 'Account',
href: '/profile/account',
icon: User,
match: ['/profile', '/profile/preferences']
}
];
describe('BottomNav', () => {
it('renders all tabs as links with correct hrefs', () => {
render(BottomNav, { props: { tabs, currentPath: '/' } });
for (const tab of tabs) {
const link = screen.getByRole('link', { name: new RegExp(tab.label) });
expect((link as HTMLAnchorElement).getAttribute('href')).toBe(tab.href);
}
});
it('marks Home as aria-current on the root path', () => {
render(BottomNav, { props: { tabs, currentPath: '/' } });
const home = screen.getByRole('link', { name: /Home/ });
expect(home.getAttribute('aria-current')).toBe('page');
const library = screen.getByRole('link', { name: /Library/ });
expect(library.getAttribute('aria-current')).toBeNull();
});
it('marks Library as active when on /bookmarks', () => {
render(BottomNav, { props: { tabs, currentPath: '/bookmarks' } });
const library = screen.getByRole('link', { name: /Library/ });
expect(library.getAttribute('aria-current')).toBe('page');
});
it('marks Library as active on nested /profile/collections', () => {
render(BottomNav, { props: { tabs, currentPath: '/profile/collections' } });
const library = screen.getByRole('link', { name: /Library/ });
expect(library.getAttribute('aria-current')).toBe('page');
});
it('prefers the longer prefix when matches overlap (Account beats Library on /profile/account)', () => {
render(BottomNav, { props: { tabs, currentPath: '/profile/account' } });
const account = screen.getByRole('link', { name: /Account/ });
const library = screen.getByRole('link', { name: /Library/ });
expect(account.getAttribute('aria-current')).toBe('page');
expect(library.getAttribute('aria-current')).toBeNull();
});
it('leaves all tabs inactive on an unknown route', () => {
render(BottomNav, { props: { tabs, currentPath: '/unknown' } });
for (const tab of tabs) {
const link = screen.getByRole('link', { name: new RegExp(tab.label) });
expect(link.getAttribute('aria-current')).toBeNull();
}
});
});

View File

@@ -0,0 +1,74 @@
<script lang="ts" generics="T extends string">
interface Option {
label: string;
value: T;
}
let {
options,
value,
onchange,
ariaLabel,
testid
}: {
options: Option[];
value: T;
onchange: (next: T) => void;
ariaLabel: string;
testid?: string;
} = $props();
</script>
<div class="segmented" role="radiogroup" aria-label={ariaLabel} data-testid={testid}>
{#each options as opt (opt.value)}
<button
type="button"
role="radio"
aria-checked={opt.value === value}
class="seg"
class:active={opt.value === value}
onclick={() => {
if (opt.value !== value) onchange(opt.value);
}}
data-testid={testid ? `${testid}-${opt.value}` : undefined}
>
{opt.label}
</button>
{/each}
</div>
<style>
.segmented {
display: inline-flex;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 2px;
gap: 2px;
}
.seg {
height: 32px;
padding: 0 var(--space-3);
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-muted);
font-size: var(--font-sm);
font-weight: var(--weight-medium);
cursor: pointer;
transition:
background var(--transition),
color var(--transition);
}
.seg:hover:not(.active) {
color: var(--text);
}
.seg.active {
background: var(--surface-elevated);
color: var(--text);
box-shadow: var(--shadow-sm);
}
</style>

View File

@@ -0,0 +1,53 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/svelte';
import SegmentedControl from './SegmentedControl.svelte';
afterEach(() => cleanup());
const opts = [
{ label: 'Bookmarks', value: 'bookmarks' as const },
{ label: 'Collections', value: 'collections' as const },
{ label: 'History', value: 'history' as const }
];
describe('SegmentedControl', () => {
it('marks the active option with aria-checked=true and a single .active class', () => {
render(SegmentedControl, {
props: {
options: opts,
value: 'collections',
onchange: () => {},
ariaLabel: 'Library tab'
}
});
const active = screen.getByRole('radio', { name: 'Collections' });
expect(active.getAttribute('aria-checked')).toBe('true');
const inactive = screen.getByRole('radio', { name: 'Bookmarks' });
expect(inactive.getAttribute('aria-checked')).toBe('false');
});
it('fires onchange with the new value when an inactive option is clicked', () => {
const onchange = vi.fn();
render(SegmentedControl, {
props: { options: opts, value: 'bookmarks', onchange, ariaLabel: 'Library tab' }
});
screen.getByRole('radio', { name: 'History' }).click();
expect(onchange).toHaveBeenCalledWith('history');
});
it('does not fire onchange when the currently active option is clicked again', () => {
const onchange = vi.fn();
render(SegmentedControl, {
props: { options: opts, value: 'bookmarks', onchange, ariaLabel: 'Library tab' }
});
screen.getByRole('radio', { name: 'Bookmarks' }).click();
expect(onchange).not.toHaveBeenCalled();
});
it('exposes the group with the provided aria-label', () => {
render(SegmentedControl, {
props: { options: opts, value: 'bookmarks', onchange: () => {}, ariaLabel: 'Library tab' }
});
expect(screen.getByRole('radiogroup', { name: 'Library tab' })).toBeTruthy();
});
});

View File

@@ -0,0 +1,227 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Snippet } from 'svelte';
import X from '@lucide/svelte/icons/x';
let {
open,
title,
onClose,
children,
footer,
testid
}: {
open: boolean;
title: string;
onClose: () => void;
children: Snippet;
footer?: Snippet;
testid?: string;
} = $props();
let panel: HTMLDivElement | undefined = $state();
let previouslyFocused: HTMLElement | null = null;
$effect(() => {
if (open) {
previouslyFocused = document.activeElement as HTMLElement | null;
queueMicrotask(() => panel?.focus());
} else if (previouslyFocused) {
previouslyFocused.focus();
previouslyFocused = null;
}
});
function focusable(): HTMLElement[] {
if (!panel) return [];
const selector = [
'a[href]',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(',');
return Array.from(panel.querySelectorAll<HTMLElement>(selector));
}
function onKeydown(e: KeyboardEvent) {
if (!open) return;
if (e.key === 'Escape') {
e.stopPropagation();
onClose();
return;
}
if (e.key === 'Tab') {
const items = focusable();
if (items.length === 0) {
e.preventDefault();
panel?.focus();
return;
}
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement as HTMLElement | null;
if (e.shiftKey) {
if (active === first || !panel?.contains(active)) {
e.preventDefault();
last.focus();
}
} else if (active === last) {
e.preventDefault();
first.focus();
}
}
}
onMount(() => {
document.addEventListener('keydown', onKeydown);
return () => document.removeEventListener('keydown', onKeydown);
});
function onScrimClick(e: MouseEvent) {
if (e.target === e.currentTarget) onClose();
}
</script>
{#if open}
<div
class="scrim"
onclick={onScrimClick}
role="presentation"
data-testid={testid ? `${testid}-scrim` : undefined}
>
<div
class="panel"
role="dialog"
aria-modal="true"
aria-labelledby="sheet-title"
tabindex="-1"
bind:this={panel}
data-testid={testid}
>
<div class="handle" aria-hidden="true"></div>
<header class="header">
<h2 id="sheet-title">{title}</h2>
<button
type="button"
class="close"
onclick={onClose}
aria-label="Close"
title="Close"
data-testid={testid ? `${testid}-close` : undefined}
>
<X size={18} aria-hidden="true" />
</button>
</header>
<div class="body">{@render children()}</div>
{#if footer}
<footer class="footer">{@render footer()}</footer>
{/if}
</div>
</div>
{/if}
<style>
.scrim {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: var(--z-modal);
display: flex;
align-items: flex-end;
justify-content: center;
animation: scrim-in 180ms ease-out;
}
@keyframes scrim-in {
from {
background: rgba(0, 0, 0, 0);
}
to {
background: rgba(0, 0, 0, 0.4);
}
}
.panel {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-top-left-radius: var(--radius-lg);
border-top-right-radius: var(--radius-lg);
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.2);
width: 100%;
max-width: 32rem;
max-height: 85vh;
display: flex;
flex-direction: column;
outline: none;
/* Honor home-indicator safe area so the footer / body don't
sit under the iOS gesture area. */
padding-bottom: var(--safe-bottom);
animation: panel-in 220ms ease-out;
}
@keyframes panel-in {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.handle {
width: 36px;
height: 4px;
margin: var(--space-2) auto var(--space-1);
background: var(--border-strong);
border-radius: var(--radius-pill);
flex-shrink: 0;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-4) var(--space-3);
border-bottom: 1px solid var(--border);
}
.header h2 {
margin: 0;
font-size: var(--font-lg);
}
.close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background: transparent;
color: var(--text-muted);
border: 1px solid transparent;
border-radius: var(--radius-sm);
}
.close:hover {
background: var(--surface-elevated);
color: var(--text);
}
.body {
padding: var(--space-4);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.footer {
padding: var(--space-3) var(--space-4);
border-top: 1px solid var(--border);
display: flex;
gap: var(--space-2);
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,84 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/svelte';
import { createRawSnippet } from 'svelte';
import Sheet from './Sheet.svelte';
afterEach(() => cleanup());
function bodySnippet(text: string) {
return createRawSnippet(() => ({
render: () => `<p>${text}</p>`
}));
}
describe('Sheet', () => {
it('renders nothing when open=false', () => {
render(Sheet, {
props: {
open: false,
title: 'Filter',
onClose: () => {},
children: bodySnippet('hello')
}
});
expect(screen.queryByRole('dialog')).toBeNull();
});
it('renders the dialog with title and body when open=true', () => {
render(Sheet, {
props: {
open: true,
title: 'Filter',
onClose: () => {},
children: bodySnippet('body content')
}
});
const dialog = screen.getByRole('dialog', { name: 'Filter' });
expect(dialog).toBeTruthy();
expect(screen.getByText('body content')).toBeTruthy();
});
it('calls onClose when the X close button is clicked', () => {
const onClose = vi.fn();
render(Sheet, {
props: {
open: true,
title: 'Filter',
onClose,
children: bodySnippet('x')
}
});
screen.getByRole('button', { name: 'Close' }).click();
expect(onClose).toHaveBeenCalledOnce();
});
it('calls onClose when the scrim is clicked (sheet variant always dismisses on scrim tap)', () => {
const onClose = vi.fn();
const { container } = render(Sheet, {
props: {
open: true,
title: 'Filter',
onClose,
children: bodySnippet('x'),
testid: 'filter-sheet'
}
});
const scrim = container.querySelector('[data-testid="filter-sheet-scrim"]') as HTMLElement;
scrim.click();
expect(onClose).toHaveBeenCalledOnce();
});
it('calls onClose when Escape is pressed', async () => {
const onClose = vi.fn();
render(Sheet, {
props: {
open: true,
title: 'Filter',
onClose,
children: bodySnippet('x')
}
});
await fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledOnce();
});
});

View File

@@ -66,9 +66,27 @@
in +layout.svelte and the reader, so they reflect rendered
size and survive font / zoom / wrap changes. */
--app-header-h: 60px;
--mobile-app-bar-h: 48px;
--app-bottom-nav-h: 56px;
--reader-nav-h: 56px;
--reader-bar-h: 56px;
/* Mobile breakpoint tokens. CSS @media queries can't read custom
properties, so these are documented anchors — keep the pixel
values in @media (max-width: 640px) etc. in lockstep. */
--bp-sm: 640px;
--bp-md: 768px;
--bp-lg: 1024px;
/* Safe-area helpers for notched / home-indicator devices. Resolve
to 0 on devices without insets, so they're safe everywhere as
long as <meta name="viewport" content="…, viewport-fit=cover">
is set (see app.html). */
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-left: env(safe-area-inset-left, 0px);
--safe-right: env(safe-area-inset-right, 0px);
--z-dropdown: 10;
--z-sticky: 50;
--z-modal: 100;

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>