From 6dcb720a0fef22708bab5798e3222fb0f2a48ccd Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 6 Jun 2026 19:46:16 +0200 Subject: [PATCH] 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) --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- frontend/e2e/mobile-chrome.spec.ts | 84 +++++++ frontend/package.json | 2 +- frontend/src/app.html | 5 +- frontend/src/lib/components/AppBar.svelte | 114 +++++++++ .../src/lib/components/AppBar.svelte.test.ts | 40 +++ frontend/src/lib/components/BottomNav.svelte | 123 ++++++++++ .../lib/components/BottomNav.svelte.test.ts | 72 ++++++ .../lib/components/SegmentedControl.svelte | 74 ++++++ .../SegmentedControl.svelte.test.ts | 53 ++++ frontend/src/lib/components/Sheet.svelte | 227 ++++++++++++++++++ .../src/lib/components/Sheet.svelte.test.ts | 84 +++++++ frontend/src/lib/styles/tokens.css | 18 ++ frontend/src/routes/+layout.svelte | 163 +++++++++++-- 15 files changed, 1045 insertions(+), 18 deletions(-) create mode 100644 frontend/e2e/mobile-chrome.spec.ts create mode 100644 frontend/src/lib/components/AppBar.svelte create mode 100644 frontend/src/lib/components/AppBar.svelte.test.ts create mode 100644 frontend/src/lib/components/BottomNav.svelte create mode 100644 frontend/src/lib/components/BottomNav.svelte.test.ts create mode 100644 frontend/src/lib/components/SegmentedControl.svelte create mode 100644 frontend/src/lib/components/SegmentedControl.svelte.test.ts create mode 100644 frontend/src/lib/components/Sheet.svelte create mode 100644 frontend/src/lib/components/Sheet.svelte.test.ts diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 78f890b..5f63575 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.55.0" +version = "0.56.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index b45b810..5ee5a97 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.55.0" +version = "0.56.0" edition = "2021" default-run = "mangalord" diff --git a/frontend/e2e/mobile-chrome.spec.ts b/frontend/e2e/mobile-chrome.spec.ts new file mode 100644 index 0000000..0cc9ff8 --- /dev/null +++ b/frontend/e2e/mobile-chrome.spec.ts @@ -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(); + }); +}); diff --git a/frontend/package.json b/frontend/package.json index 4c674e8..c808836 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.55.0", + "version": "0.56.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/app.html b/frontend/src/app.html index d73162b..ac28d6d 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -3,7 +3,10 @@ - + Mangalord + +
+
+ {#if backHref} + + + {:else if onBack} + + {/if} +
+

{title}

+
+ {#if trailing}{@render trailing()}{/if} +
+
+ + diff --git a/frontend/src/lib/components/AppBar.svelte.test.ts b/frontend/src/lib/components/AppBar.svelte.test.ts new file mode 100644 index 0000000..4634f07 --- /dev/null +++ b/frontend/src/lib/components/AppBar.svelte.test.ts @@ -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: () => `` + })); + render(AppBar, { props: { title: 'Detail', trailing } }); + expect(screen.getByRole('button', { name: 'More' })).toBeTruthy(); + }); +}); diff --git a/frontend/src/lib/components/BottomNav.svelte b/frontend/src/lib/components/BottomNav.svelte new file mode 100644 index 0000000..4bf0677 --- /dev/null +++ b/frontend/src/lib/components/BottomNav.svelte @@ -0,0 +1,123 @@ + + + + + diff --git a/frontend/src/lib/components/BottomNav.svelte.test.ts b/frontend/src/lib/components/BottomNav.svelte.test.ts new file mode 100644 index 0000000..5122b60 --- /dev/null +++ b/frontend/src/lib/components/BottomNav.svelte.test.ts @@ -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(); + } + }); +}); diff --git a/frontend/src/lib/components/SegmentedControl.svelte b/frontend/src/lib/components/SegmentedControl.svelte new file mode 100644 index 0000000..7c36015 --- /dev/null +++ b/frontend/src/lib/components/SegmentedControl.svelte @@ -0,0 +1,74 @@ + + +
+ {#each options as opt (opt.value)} + + {/each} +
+ + diff --git a/frontend/src/lib/components/SegmentedControl.svelte.test.ts b/frontend/src/lib/components/SegmentedControl.svelte.test.ts new file mode 100644 index 0000000..9f393f6 --- /dev/null +++ b/frontend/src/lib/components/SegmentedControl.svelte.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/lib/components/Sheet.svelte b/frontend/src/lib/components/Sheet.svelte new file mode 100644 index 0000000..bed6931 --- /dev/null +++ b/frontend/src/lib/components/Sheet.svelte @@ -0,0 +1,227 @@ + + +{#if open} + +{/if} + + diff --git a/frontend/src/lib/components/Sheet.svelte.test.ts b/frontend/src/lib/components/Sheet.svelte.test.ts new file mode 100644 index 0000000..b59ff7e --- /dev/null +++ b/frontend/src/lib/components/Sheet.svelte.test.ts @@ -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: () => `

${text}

` + })); +} + +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(); + }); +}); diff --git a/frontend/src/lib/styles/tokens.css b/frontend/src/lib/styles/tokens.css index b1cad87..579bfd7 100644 --- a/frontend/src/lib/styles/tokens.css +++ b/frontend/src/lib/styles/tokens.css @@ -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 + 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; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 1f530af..4508652 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -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 , 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} -
+{#if showMobileChrome} +
+ +
+{/if} + +