From d5561bfeb22aab58e8e2ad0dd5e31b3b097fa2bd Mon Sep 17 00:00:00 2001 From: eleith Date: Mon, 2 Nov 2026 05:40:42 -0800 Subject: [PATCH] feat(web): add navigation progress bar --- .changeset/navigation-progress.md | 5 + apps/web/e2e/navigation-progress.e2e.ts | 211 ++++++++++++++++++ .../components/NavigationProgressBar.svelte | 121 ++++++++++ apps/web/src/routes/+layout.svelte | 3 + 4 files changed, 340 insertions(+) create mode 100644 .changeset/navigation-progress.md create mode 100644 apps/web/e2e/navigation-progress.e2e.ts create mode 100644 apps/web/src/lib/components/NavigationProgressBar.svelte diff --git a/.changeset/navigation-progress.md b/.changeset/navigation-progress.md new file mode 100644 index 00000000..2e97634d --- /dev/null +++ b/.changeset/navigation-progress.md @@ -0,0 +1,5 @@ +--- +'@when/web': patch +--- + +Show a themed navigation progress bar using native SvelteKit state and Svelte motion. Preserve a short completion cue across full-page transitions with session storage, handle back-to-back navigation safely, and respect reduced-motion preferences. diff --git a/apps/web/e2e/navigation-progress.e2e.ts b/apps/web/e2e/navigation-progress.e2e.ts new file mode 100644 index 00000000..c4507921 --- /dev/null +++ b/apps/web/e2e/navigation-progress.e2e.ts @@ -0,0 +1,211 @@ +import { expect, test, type Page, type Locator } from './support/test.ts'; +import { CHAT_MEETING } from './support/seed.ts'; + +const STORAGE_KEY = 'when.navigation-pending'; +const progressBar = (page: Page) => page.getByRole('progressbar', { name: 'Loading page' }); +const themeButton = (page: Page) => + page.getByRole('button', { + name: /^(Switch to dark theme|Switch to light theme|Use system theme)$/ + }); + +type ProgressProbe = { incoming: string | null; seen: boolean }; +type ObservedWindow = typeof window & { progressProbe: ProgressProbe }; + +async function openWithPausedClock(page: Page) { + const noon = new Date(); + noon.setUTCHours(12, 0, 0, 0); + await page.clock.install({ time: noon }); + await page.goto('/'); + await expect(themeButton(page)).toBeEnabled(); + await page.clock.pauseAt(new Date(noon.getTime() + 60_000)); + await expect(progressBar(page)).toHaveCount(0); +} + +async function followLink(page: Page, link: Locator) { + await link.focus(); + await page.keyboard.press('Enter'); + // Kit defers link navigation through RAF/timers; input alone cannot advance a paused clock. + await page.clock.runFor(100); +} + +async function holdPageLoad(page: Page, path: string) { + const requested = Promise.withResolvers(); + const release = Promise.withResolvers(); + const dataPath = path === '/' ? '/__data.json' : `${path}/__data.json`; + await page.route( + (url) => url.pathname === dataPath, + async (route) => { + requested.resolve(); + await release.promise; + await route.continue(); + }, + { times: 1 } + ); + return { requested: requested.promise, release: release.resolve }; +} + +async function fillScale(bar: Locator) { + return bar + .locator('.fill') + .evaluate( + (fill) => + fill.getBoundingClientRect().width / fill.parentElement!.getBoundingClientRect().width + ); +} + +test('a new navigation survives the previous navigation finishing', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'no-preference' }); + await openWithPausedClock(page); + const bar = progressBar(page); + const first = await holdPageLoad(page, '/schedule/chat'); + try { + const link = page.getByRole('link', { name: CHAT_MEETING.title }); + await followLink(page, link); + await first.requested; + await expect(bar).toBeVisible(); + await expect(bar).not.toHaveAttribute('aria-valuenow'); + await expect(bar).toHaveCSS('pointer-events', 'none'); + await page.clock.runFor(300); + const pendingScale = await fillScale(bar); + expect(pendingScale).toBeGreaterThan(0.2); + expect(pendingScale).toBeLessThan(0.9); + first.release(); + await expect(page).toHaveURL('/schedule/chat'); + await page.clock.runFor(32); + expect(await fillScale(bar)).toBeGreaterThan(pendingScale + 0.05); + + const second = await holdPageLoad(page, '/'); + try { + const back = page.getByRole('link', { name: 'Back', exact: true }); + await followLink(page, back); + await second.requested; + await expect(bar).toBeVisible(); + // Virtual frames cross the old completion deadline while the next response is held. + await page.clock.runFor(500); + await expect(bar).toBeVisible(); + expect(await fillScale(bar)).toBeGreaterThan(0.2); + expect(await fillScale(bar)).toBeLessThan(0.9); + second.release(); + await expect(page).toHaveURL('/'); + await page.clock.runFor(450); + await expect(bar).toHaveCount(0); + expect(await page.evaluate((key) => sessionStorage.getItem(key), STORAGE_KEY)).toBeNull(); + } finally { + second.release(); + } + } finally { + first.release(); + } +}); + +test('document transitions consume a fresh handoff and reject stale or invalid markers', async ({ + page +}) => { + await openWithPausedClock(page); + await page.addInitScript((key) => { + const probe: ProgressProbe = { incoming: sessionStorage.getItem(key), seen: false }; + (window as ObservedWindow).progressProbe = probe; + new MutationObserver(() => { + if (document.querySelector('[role="progressbar"][aria-label="Loading page"]')) + probe.seen = true; + }).observe(document, { childList: true, subtree: true }); + }, STORAGE_KEY); + await page.reload(); + await expect(themeButton(page)).toBeEnabled(); + const probe = await page.evaluate(() => (window as ObservedWindow).progressProbe); + expect(Number.isFinite(Number(probe.incoming))).toBe(true); + expect(probe.incoming).not.toBeNull(); + await expect + .poll(() => page.evaluate(() => (window as ObservedWindow).progressProbe.seen)) + .toBe(true); + expect(await page.evaluate((key) => sessionStorage.getItem(key), STORAGE_KEY)).toBeNull(); + await expect(progressBar(page)).toBeVisible(); + expect(await fillScale(progressBar(page))).toBeCloseTo(0.9, 2); + await page.clock.runFor(250); + await expect(progressBar(page)).toHaveCount(0); + + // Exercise the persisted-page consumer without depending on browser BFCache eligibility. + await page.evaluate((key) => { + sessionStorage.setItem(key, String(Date.now() - 5000)); + window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true })); + }, STORAGE_KEY); + await expect(progressBar(page)).toBeVisible(); + expect(await page.evaluate((key) => sessionStorage.getItem(key), STORAGE_KEY)).toBeNull(); + await page.clock.runFor(250); + await expect(progressBar(page)).toHaveCount(0); + + for (const marker of ['invalid', 'stale', 'future']) { + await page.evaluate( + ({ key, marker }) => { + (window as ObservedWindow).progressProbe.seen = false; + let value = 'not-a-time'; + if (marker === 'stale') value = String(Date.now() - 5001); + else if (marker === 'future') value = String(Date.now() + 1); + sessionStorage.setItem(key, value); + window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true })); + }, + { key: STORAGE_KEY, marker } + ); + await page.clock.runFor(32); + await expect(progressBar(page)).toHaveCount(0); + expect(await page.evaluate(() => (window as ObservedWindow).progressProbe.seen)).toBe(false); + expect(await page.evaluate((key) => sessionStorage.getItem(key), STORAGE_KEY)).toBeNull(); + } +}); + +test('blocked handoff storage leaves reduced-motion navigation usable', async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.addInitScript((blockedKey) => { + const getItem = Storage.prototype.getItem; + const setItem = Storage.prototype.setItem; + const removeItem = Storage.prototype.removeItem; + Storage.prototype.getItem = function (key) { + if (key === blockedKey) throw new DOMException('Storage blocked', 'SecurityError'); + return getItem.call(this, key); + }; + Storage.prototype.setItem = function (key, value) { + if (key === blockedKey) throw new DOMException('Storage blocked', 'SecurityError'); + return setItem.call(this, key, value); + }; + Storage.prototype.removeItem = function (key) { + if (key === blockedKey) throw new DOMException('Storage blocked', 'SecurityError'); + return removeItem.call(this, key); + }; + }, STORAGE_KEY); + await openWithPausedClock(page); + const held = await holdPageLoad(page, '/schedule/chat'); + try { + const link = page.getByRole('link', { name: CHAT_MEETING.title }); + await followLink(page, link); + await held.requested; + const bar = progressBar(page); + await expect(bar).toBeVisible(); + await expect(bar).not.toHaveAttribute('aria-valuenow'); + const scale = await fillScale(bar); + expect(scale).toBeCloseTo(0.9, 2); + await page.clock.runFor(1000); + expect(await fillScale(bar)).toBeCloseTo(scale, 5); + const bounds = await bar.boundingBox(); + expect(bounds!.x).toBe(0); + expect(bounds!.y).toBe(0); + expect(bounds!.width).toBe(390); + held.release(); + await expect(page).toHaveURL('/schedule/chat'); + await page.clock.runFor(450); + await expect(bar).toHaveCount(0); + } finally { + held.release(); + } + await page.reload(); + await expect(page.getByRole('heading', { name: 'Step 1 of 3: Pick a day' })).toBeVisible(); + const back = page.getByRole('link', { name: 'Back', exact: true }); + await followLink(page, back); + await expect(page).toHaveURL('/'); + await expect(themeButton(page)).toBeEnabled(); + await page.clock.runFor(450); + await expect(progressBar(page)).toHaveCount(0); + expect(errors).toEqual([]); +}); diff --git a/apps/web/src/lib/components/NavigationProgressBar.svelte b/apps/web/src/lib/components/NavigationProgressBar.svelte new file mode 100644 index 00000000..01ab066a --- /dev/null +++ b/apps/web/src/lib/components/NavigationProgressBar.svelte @@ -0,0 +1,121 @@ + + +{#if visible} + +{/if} + + diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte index 607a3b17..fba2f10e 100644 --- a/apps/web/src/routes/+layout.svelte +++ b/apps/web/src/routes/+layout.svelte @@ -1,5 +1,6 @@