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

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