diff --git a/.gitignore b/.gitignore
index 42d40eb..75a4c6a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,3 +24,4 @@ docs/immich-workflow/*.json
# OS
.DS_Store
+tests/ui/.auth/
diff --git a/docs/working/plans/2026-06-21-playwright-tests.md b/docs/working/plans/2026-06-21-playwright-tests.md
new file mode 100644
index 0000000..69ce258
--- /dev/null
+++ b/docs/working/plans/2026-06-21-playwright-tests.md
@@ -0,0 +1,971 @@
+# Playwright Tests — Improvement & Expansion
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Reorganise the flat `tests/ui/` into feature subdirectories, fix stale trip-slug references that cause 30 pre-existing failures, add missing test files from the main branch, add a GPX Manager end-to-end suite (GM1–GM7), extend the post form suite (P6–P8), and extend axe scans (AX6–AX7).
+
+**Architecture:** All changes stay inside `tests/`. Playwright's `testDir: './tests/ui'` recurses automatically so `playwright.config.js` is untouched. End-to-end tests hit the live Grav server at `http://localhost:8081`; demo data is loaded by `globalSetup` via `make demo-load`.
+
+**Tech Stack:** Playwright (Node), Grav REST API (`/api/v1`), axe-core via `@axe-core/playwright`, existing `helpers.js`.
+
+## Global Constraints
+
+- Branch: `worktree-playwright-tests`; working directory: `.claude/worktrees/playwright-tests/`
+- Grav server must be running at `http://localhost:8081` before running tests
+- Demo trip used in tests: `italy-2026-demo` (slug used in all fixture references)
+- `helpers.js` stays at `tests/ui/helpers.js` — moved specs update their import from `./helpers` to `../helpers`
+- `auth.setup.js` `testMatch: /auth\.setup\.js/` resolves by filename — works at any depth
+- P2 remains skipped (photo upload needs post-form work first)
+- End-to-end GPX Manager tests make real API calls — `afterAll` cleans up uploaded fixture files
+- Run tests: `npx playwright test --reporter=line`
+
+---
+
+## File Map
+
+```
+tests/
+ fixtures/
+ test-photo.jpg (existing — untouched)
+ test-nonimage.txt (existing — untouched)
+ test-route.gpx (NEW — Task 2)
+ ui/
+ helpers.js (stays here — shared)
+ auth/
+ auth.setup.js (moved — Task 3)
+ auth.spec.js (moved — Task 3)
+ post/
+ post.spec.js (moved + P6-P8 — Tasks 3 & 5)
+ validation.spec.js (moved — Task 3)
+ gpx/
+ gpx-journey.spec.js (moved — Task 3)
+ gpx-manager.spec.js (NEW — Task 4)
+ maps/
+ maps.spec.js (moved — Task 3)
+ stories/
+ stories.spec.js (moved — Task 3)
+ dailies/
+ dailies.spec.js (moved — Task 3)
+ home/
+ home.spec.js (NEW — Task 1)
+ home-highlights.spec.js (NEW — Task 1)
+ nav/
+ nav.spec.js (moved — Task 3)
+ trip/
+ trip-filter.spec.js (moved — Task 3)
+ a11y/
+ accessibility.spec.js (NEW — Task 1; AX6-AX7 added — Task 6)
+ global-setup.js (untouched)
+```
+
+---
+
+## Task 1: Sync missing test files and fix stale trip-slug references
+
+30 of 49 tests fail on the current branch because tests reference `japan-korea-2026` and `italy-2025` trips that no longer match the demo data. This task brings the branch up to parity with `main`'s up-to-date test files.
+
+**Files to overwrite (correct content below):**
+- `tests/ui/nav.spec.js`
+- `tests/ui/dailies.spec.js`
+- `tests/ui/stories.spec.js`
+- `tests/ui/gpx-journey.spec.js`
+
+**Files to create:**
+- `tests/ui/home.spec.js`
+- `tests/ui/home-highlights.spec.js`
+- `tests/ui/accessibility.spec.js`
+
+- [ ] **Step 1: Overwrite nav.spec.js**
+
+Replace `tests/ui/nav.spec.js` entirely with:
+
+```js
+// @ts-check
+// Tests: N1–N5 — page loads and navigation links
+const { test, expect } = require('@playwright/test');
+
+// ── N1: /trips/italy-2026-demo/dailies renders ───────────────────────────────
+test('N1: /trips/italy-2026-demo/dailies page loads with site header', async ({ page }) => {
+ const errors = [];
+ page.on('pageerror', e => errors.push(e.message));
+
+ await page.goto('/trips/italy-2026-demo/dailies');
+ await expect(page.locator('.site-header')).toBeVisible();
+ await expect(page).toHaveTitle(/Into the East/i);
+ expect(errors).toHaveLength(0);
+});
+
+// ── N2: /trips/italy-2026-demo/map renders without JS errors ─────────────────
+test('N2: /trips/italy-2026-demo/map page loads without JS errors', async ({ page }) => {
+ const errors = [];
+ page.on('pageerror', e => errors.push(e.message));
+
+ await page.goto('/trips/italy-2026-demo/map');
+ await expect(page.locator('.site-header')).toBeVisible();
+ expect(errors).toHaveLength(0);
+});
+
+// ── N3: /trips/italy-2026-demo/stats renders ─────────────────────────────────
+test('N3: /trips/italy-2026-demo/stats page loads with site header', async ({ page }) => {
+ const errors = [];
+ page.on('pageerror', e => errors.push(e.message));
+
+ await page.goto('/trips/italy-2026-demo/stats');
+ await expect(page.locator('.site-header')).toBeVisible();
+ expect(errors).toHaveLength(0);
+});
+
+// ── N4: trip page has Journal filter button (replaced nav link) ───────────────
+test('N4: trip page filter bar has Journal button', async ({ page }) => {
+ await page.goto('/trips/italy-2026-demo');
+ await expect(page.locator('.trip-filter-btn[data-filter="journal"]')).toBeVisible();
+});
+
+// ── N5: "Map" nav link goes to /map ──────────────────────────────────────────
+test.skip('N5: Map nav link navigates to /map', async ({ page }) => {
+ await page.goto('/');
+ await page.click('nav a[href*="map"]');
+ await expect(page).toHaveURL(/\/map/);
+});
+```
+
+- [ ] **Step 2: Overwrite dailies.spec.js**
+
+Replace `tests/ui/dailies.spec.js` entirely with:
+
+```js
+// @ts-check
+// Tests: T1–T6 — dailies feed and individual entry pages
+const { test, expect } = require('@playwright/test');
+
+// Known fixture entries that always exist in the repo
+const KNOWN_SLUG = '2026-09-01-0700-setting-off-from-campiglia.entry';
+const KNOWN_TITLE = 'Setting Off from Campiglia';
+const KNOWN_CITY = 'Campiglia Marittima';
+const KNOWN_COUNTRY = 'Italy';
+
+// Use two real entries from central-asia-2023 to verify descending order
+const NEWER_SLUG = '2023-10-18-pixelfed-22.entry'; // newest date in that trip
+const OLDER_SLUG = '2023-08-28-pixelfed-1.entry'; // oldest date in that trip
+
+// ── T1: Dailies page loads ─────────────────────────────────────────────────────
+test('T1: /trips/italy-2026-demo/dailies loads and shows at least one entry card', async ({ page }) => {
+ await page.goto('/trips/italy-2026-demo/dailies');
+ await expect(page.locator('.journal-post').first()).toBeVisible();
+ await expect(page.locator('.site-header')).toBeVisible();
+});
+
+// ── T2: Entries are newest-first ──────────────────────────────────────────────
+// Verify using two known real entries from central-asia-2023 (22 entries, stable order).
+test('T2: dailies shows newer entries before older entries', async ({ page }) => {
+ await page.goto('/trips/central-asia-2023/dailies');
+
+ // Use attribute selector to handle dots in slug names (CSS dots are class selectors)
+ const newerCard = page.locator(`.journal-post[id="entry-${NEWER_SLUG}"]`);
+ const olderCard = page.locator(`.journal-post[id="entry-${OLDER_SLUG}"]`);
+
+ await expect(newerCard).toBeVisible();
+ await expect(olderCard).toBeVisible();
+
+ // The newer entry should appear higher in the DOM (lower index)
+ const newerIdx = await newerCard.evaluate(el => {
+ return [...document.querySelectorAll('.journal-post')].findIndex(c => c.id === el.id);
+ });
+ const olderIdx = await olderCard.evaluate(el => {
+ return [...document.querySelectorAll('.journal-post')].findIndex(c => c.id === el.id);
+ });
+
+ expect(newerIdx).toBeLessThan(olderIdx);
+});
+
+// ── T3: Individual entry page loads ───────────────────────────────────────────
+test('T3: individual entry page loads at /trips/italy-2026-demo/dailies/{slug}', async ({ page }) => {
+ await page.goto(`/trips/italy-2026-demo/dailies/${KNOWN_SLUG}`);
+ await expect(page.locator('article.entry')).toBeVisible();
+ await expect(page.locator('.site-header')).toBeVisible();
+});
+
+// ── T4: Entry page shows title, date, and content ─────────────────────────────
+test('T4: entry page shows title and body content', async ({ page }) => {
+ await page.goto(`/trips/italy-2026-demo/dailies/${KNOWN_SLUG}`);
+ await expect(page.locator('.entry-title')).toContainText(KNOWN_TITLE);
+ await expect(page.locator('.entry-body')).not.toBeEmpty();
+ await expect(page.locator('time.entry-date')).toBeVisible();
+});
+
+// ── T5: Entry page shows location when present ────────────────────────────────
+test('T5: entry page shows city and country when set', async ({ page }) => {
+ await page.goto(`/trips/italy-2026-demo/dailies/${KNOWN_SLUG}`);
+ await expect(page.locator('.entry-location')).toContainText(KNOWN_CITY);
+ await expect(page.locator('.entry-location')).toContainText(KNOWN_COUNTRY);
+});
+
+// ── T6: Entry page has a fixed top back pill and a footer back pill ───────────────
+test('T6: entry page has fixed back pill at top and back pill in footer', async ({ page }) => {
+ const KNOWN_ENTRY = `/trips/italy-2026-demo/dailies/${KNOWN_SLUG}`;
+ await page.goto(KNOWN_ENTRY);
+ await expect(page.locator('article.entry')).toBeVisible();
+ const topPill = page.locator('.entry-back-fixed');
+ await expect(topPill).toBeVisible();
+ await expect(topPill).toHaveText(/← Back/);
+ const footerPill = page.locator('.entry-footer .back-pill');
+ await expect(footerPill).toBeVisible();
+ await expect(footerPill).toHaveText(/← Back/);
+});
+```
+
+- [ ] **Step 3: Overwrite stories.spec.js**
+
+Replace `tests/ui/stories.spec.js` entirely with:
+
+```js
+// @ts-check
+// Tests: S1–S7 — story mode rendering and navigation
+// Requires demo data: run `make demo-load` before this suite.
+const { test, expect } = require('@playwright/test');
+
+const STORIES_URL = '/trips/italy-2026-demo/stories';
+const STORY_GALLERY = '/trips/italy-2026-demo/stories/val-dorcia-at-dawn';
+const STORY_SCROLLY = '/trips/italy-2026-demo/stories/sorano-rock-and-time';
+const DEMO_STORY = '/trips/italy-2026-demo/stories/val-dorcia-at-dawn';
+
+// ── S1: Stories listing shows cards ──────────────────────────────────────────
+test('S1: stories listing renders at least 3 story cards', async ({ page }) => {
+ await page.goto(STORIES_URL);
+ const cards = page.locator('.story-card');
+ await expect(cards.first()).toBeVisible({ timeout: 5000 });
+ const count = await cards.count();
+ expect(count, 'At least 3 story cards').toBeGreaterThanOrEqual(3);
+});
+
+// ── S2: Gallery-led story — hero image + snap-gallery + chapter-break + text-only pull-quote ──
+test('S2: gallery-led story renders hero, snap-gallery, chapter-break, text-only pull-quote', async ({ page }) => {
+ await page.goto(STORY_GALLERY);
+ await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
+ await expect(page.locator('.story-hero__img-placeholder')).toHaveCount(0);
+ const galleries = page.locator('.pgallery');
+ await expect(galleries.first()).toBeVisible();
+ expect(await galleries.count(), 'Two snap-galleries').toBe(2);
+ await expect(page.locator('.chapter-break')).toBeVisible();
+ await expect(page.locator('.pull-quote__inner--no-image')).toBeVisible();
+});
+
+// ── S3: Scrolly-led story — two scrolly-sections + pull-quote with image ─────
+test('S3: scrolly-led story renders two scrolly-sections and pull-quote with background image', async ({ page }) => {
+ await page.goto(STORY_SCROLLY);
+ await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
+ const scrollySections = page.locator('.scrolly');
+ await expect(scrollySections.first()).toBeVisible();
+ expect(await scrollySections.count(), 'Two scrolly-sections').toBe(2);
+ await expect(page.locator('.pull-quote__bg')).toBeVisible();
+});
+
+// ── S4: Scrolly story loads without JS errors (Scrollama CDN) ────────────────
+test('S4: scrolly story page loads without JS errors', async ({ page }) => {
+ const errors = [];
+ page.on('pageerror', e => errors.push(e.message));
+ await page.goto(STORY_SCROLLY);
+ await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
+ await page.waitForTimeout(1000);
+ expect(errors, 'No JS errors on story page').toHaveLength(0);
+});
+
+// ── S5: Back button returns to stories listing ────────────────────────────────
+test('S5: back button navigates back to stories listing', async ({ page }) => {
+ await page.goto(STORIES_URL);
+ await page.locator('.story-card').first().click();
+ await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
+ await page.locator('.story-escape').click();
+ await expect(page).toHaveURL(/italy-2026-demo\/stories$/);
+ await expect(page.locator('.story-card').first()).toBeVisible();
+});
+
+// ── S6: Demo story — hero image sanity check ─────────────────────────────────
+test('S6: demo story renders hero image without placeholder', async ({ page }) => {
+ await page.goto(DEMO_STORY);
+ await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
+ await expect(page.locator('.story-hero__img-placeholder')).toHaveCount(0);
+});
+
+// ── S7: Story body back link is styled as a back-pill ────────────────────────
+test('S7: story body back link has back-pill class', async ({ page }) => {
+ await page.goto('/trips/italy-2026-demo/stories/val-dorcia-at-dawn');
+ await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
+ await page.evaluate(() => window.scrollBy(0, window.innerHeight * 1.5));
+ await page.waitForTimeout(300);
+ const bodyBack = page.locator('.story-footer .back-pill');
+ await expect(bodyBack).toBeAttached();
+ await expect(bodyBack).toHaveText(/← Back/);
+});
+```
+
+- [ ] **Step 4: Overwrite gpx-journey.spec.js**
+
+Replace only the `getMapUtils` function to point at `italy-2026-demo`. Change line 9:
+
+```js
+async function getMapUtils(page) {
+ await page.goto('/trips/italy-2026-demo/map');
+ await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
+}
+```
+
+All G1–G5 test bodies are unchanged — only the URL in `getMapUtils` changes.
+
+- [ ] **Step 5: Create home.spec.js**
+
+Create `tests/ui/home.spec.js`:
+
+```js
+// @ts-check
+// Tests: H1 — home page journal feed
+const { test, expect } = require('@playwright/test');
+
+// ── H1: Home page renders inline journal posts ─────────────────────────────────
+test('H1: home page shows at least one inline journal-post block', async ({ page }) => {
+ await page.goto('/');
+ await expect(page.locator('.journal-post').first()).toBeVisible();
+ await expect(page.locator('.site-header')).toBeVisible();
+});
+```
+
+- [ ] **Step 6: Create home-highlights.spec.js**
+
+Create `tests/ui/home-highlights.spec.js`:
+
+```js
+// @ts-check
+// Tests: H2–H5 — Between-trips highlights mode
+// These tests temporarily set travelling: false in user/config/site.yaml,
+// run the assertions, then restore the original value.
+// Requires demo data with featured entries: run `make demo-load` first.
+const { test, expect } = require('@playwright/test');
+const fs = require('fs');
+const path = require('path');
+
+const SITE_YAML_PATH = path.join(__dirname, '../../user/config/site.yaml');
+
+test.describe('Between-trips highlights mode', () => {
+ let originalSiteYaml;
+
+ test.beforeAll(async () => {
+ originalSiteYaml = fs.readFileSync(SITE_YAML_PATH, 'utf8');
+ const patched = originalSiteYaml.replace(/^travelling:\s*true/m, 'travelling: false');
+ fs.writeFileSync(SITE_YAML_PATH, patched);
+ await new Promise(r => setTimeout(r, 400));
+ });
+
+ test.afterAll(async () => {
+ fs.writeFileSync(SITE_YAML_PATH, originalSiteYaml);
+ });
+
+ // ── H2: Highlights grid is visible ──────────────────────────────────────────
+ test('H2: homepage shows highlights grid when not travelling', async ({ page }) => {
+ await page.goto('/');
+ await expect(page.locator('.home-highlights-grid')).toBeVisible({ timeout: 10000 });
+ });
+
+ // ── H3: Highlight cards contain trip link ────────────────────────────────────
+ test('H3: highlight cards have a View-trip link', async ({ page }) => {
+ await page.goto('/');
+ await expect(page.locator('.home-highlight-card').first()).toBeVisible({ timeout: 10000 });
+ await expect(page.locator('.home-highlight-trip-link').first()).toBeVisible();
+ });
+
+ // ── H4: Between-trips home map renders without JS errors ────────────────────
+ test('H4: home map renders in between-trips mode without JS errors', async ({ page }) => {
+ const errors = [];
+ page.on('pageerror', e => errors.push(e.message));
+ await page.goto('/');
+ await expect(page.locator('#home-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
+ expect(errors, 'No JS errors').toHaveLength(0);
+ });
+
+ // ── H5: CTA links to /trips ──────────────────────────────────────────────────
+ test('H5: "Explore all past trips" CTA links to /trips', async ({ page }) => {
+ await page.goto('/');
+ const cta = page.locator('.home-highlights-cta');
+ await expect(cta).toBeVisible({ timeout: 10000 });
+ await expect(cta).toHaveAttribute('href', /\/trips/);
+ });
+});
+```
+
+- [ ] **Step 7: Create accessibility.spec.js**
+
+Create `tests/ui/accessibility.spec.js` with the full A1–A5 + AX1–AX5 suite:
+
+```js
+// @ts-check
+// Tests: A1–A5 (feature checks) and AX1–AX5 (axe scans)
+const { test, expect } = require('@playwright/test');
+
+// ── A1: Skip link ──────────────────────────────────────────────────────────────
+test('A1: skip link targets #main-content and is first focusable element', async ({ page }) => {
+ await page.goto('/');
+ const skipLink = page.locator('.skip-link');
+ await expect(skipLink).toBeAttached();
+ await expect(skipLink).toHaveAttribute('href', '#main-content');
+ await expect(page.locator('#main-content')).toBeAttached();
+});
+
+// ── A2: Color token contrast ───────────────────────────────────────────────────
+test('A2: contrast tokens meet WCAG AA 4.5:1 floor', async ({ page }) => {
+ await page.goto('/');
+ const [muted, accent] = await page.evaluate(() => [
+ getComputedStyle(document.documentElement).getPropertyValue('--color-ink-muted').trim(),
+ getComputedStyle(document.documentElement).getPropertyValue('--color-accent').trim(),
+ ]);
+ expect(muted.toLowerCase()).toBe('#90887e');
+ expect(accent.toLowerCase()).toBe('#2e9880');
+});
+
+// ── A3: Filter button aria-pressed + toggle aria-expanded ──────────────────────
+const TRIP_URL = '/trips/italy-2026-demo';
+
+test('A3a: All-content filter has aria-pressed="true" on load', async ({ page }) => {
+ await page.goto(TRIP_URL);
+ await expect(page.locator('.trip-filter-btn[data-filter="all"]')).toHaveAttribute('aria-pressed', 'true');
+ await expect(page.locator('.trip-filter-btn[data-filter="journal"]')).toHaveAttribute('aria-pressed', 'false');
+ await expect(page.locator('.trip-filter-btn[data-filter="story"]')).toHaveAttribute('aria-pressed', 'false');
+});
+
+test('A3b: clicking Journal filter toggles aria-pressed', async ({ page }) => {
+ await page.goto(TRIP_URL);
+ await page.click('.trip-filter-btn[data-filter="journal"]');
+ await expect(page.locator('.trip-filter-btn[data-filter="journal"]')).toHaveAttribute('aria-pressed', 'true');
+ await expect(page.locator('.trip-filter-btn[data-filter="all"]')).toHaveAttribute('aria-pressed', 'false');
+});
+
+test('A3c: Stats toggle has aria-expanded="false" and aria-controls on load', async ({ page }) => {
+ await page.goto(TRIP_URL);
+ await expect(page.locator('#trip-stats-toggle')).toHaveAttribute('aria-expanded', 'false');
+ await expect(page.locator('#trip-stats-toggle')).toHaveAttribute('aria-controls', 'trip-stats-block');
+});
+
+test('A3d: clicking Stats toggle sets aria-expanded="true" then back to false', async ({ page }) => {
+ await page.goto(TRIP_URL);
+ await page.click('#trip-stats-toggle');
+ await expect(page.locator('#trip-stats-toggle')).toHaveAttribute('aria-expanded', 'true');
+ await page.click('#trip-stats-toggle');
+ await expect(page.locator('#trip-stats-toggle')).toHaveAttribute('aria-expanded', 'false');
+});
+
+const ITALY_URL = '/trips/italy-2026-demo';
+
+test('A3e: Cycling toggle has aria-expanded="false" and aria-controls on load', async ({ page }) => {
+ await page.goto(ITALY_URL);
+ await expect(page.locator('#trip-cycling-toggle')).toHaveAttribute('aria-expanded', 'false');
+ await expect(page.locator('#trip-cycling-toggle')).toHaveAttribute('aria-controls', 'trip-cycling-block');
+});
+
+test('A3f: clicking Cycling toggle sets aria-expanded="true" then back to false', async ({ page }) => {
+ await page.goto(ITALY_URL);
+ await page.click('#trip-cycling-toggle');
+ await expect(page.locator('#trip-cycling-toggle')).toHaveAttribute('aria-expanded', 'true');
+ await page.click('#trip-cycling-toggle');
+ await expect(page.locator('#trip-cycling-toggle')).toHaveAttribute('aria-expanded', 'false');
+});
+
+// ── A4: Photo strip keyboard navigation ───────────────────────────────────────
+test('A4a: all photo strips have role=region and aria-label', async ({ page }) => {
+ await page.goto('/trips/italy-2026-demo/dailies');
+ const strips = page.locator('.journal-photo-strip');
+ const count = await strips.count();
+ if (count === 0) return;
+ for (let i = 0; i < count; i++) {
+ await expect(strips.nth(i)).toHaveAttribute('role', 'region');
+ await expect(strips.nth(i)).toHaveAttribute('aria-label', 'Photo strip');
+ }
+});
+
+test('A4b: multi-slide photo strips have accessible prev/next controls', async ({ page }) => {
+ await page.goto('/trips/italy-2026-demo/dailies');
+ const multiCount = await page.locator('.journal-photo-strip').evaluateAll(
+ els => els.filter(el => parseInt(el.dataset.slides, 10) >= 2).length
+ );
+ if (multiCount === 0) return;
+ await expect(page.locator('.strip-prev').first()).toBeAttached();
+ await expect(page.locator('.strip-next').first()).toBeAttached();
+ await expect(page.locator('.strip-prev').first()).toHaveAttribute('aria-label', 'Previous photo');
+ await expect(page.locator('.strip-next').first()).toHaveAttribute('aria-label', 'Next photo');
+});
+
+// ── A5: GPX delete button unique accessible names ──────────────────────────────
+test('A5: GPX delete buttons have unique aria-labels per filename', async ({ page }) => {
+ await page.route('**/api/v1/pages**/media', async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ data: [
+ { filename: 'tokyo-day1.gpx', size: 102400, modified: '2026-03-25T10:00:00Z' }
+ ]
+ })
+ });
+ });
+ await page.goto('/gpx-manager');
+ const deleteBtn = page.locator('.gpx-trip').first().locator('.gpx-delete[data-filename="tokyo-day1.gpx"]');
+ await expect(deleteBtn).toBeVisible();
+ await expect(deleteBtn).toHaveAttribute('aria-label', 'Delete tokyo-day1.gpx');
+});
+
+// ── AX1–AX5: axe-core WCAG 2.1 AA regression scans ───────────────────────────
+const { AxeBuilder } = require('@axe-core/playwright');
+
+const WCAG_TAGS = ['wcag2a', 'wcag2aa'];
+const BLOCKING = ['critical', 'serious'];
+
+function axeScan(id, url) {
+ test(`${id}: ${url} passes axe WCAG 2.1 AA (critical/serious)`, async ({ page }) => {
+ await page.goto(url);
+ const results = await new AxeBuilder({ page }).withTags(WCAG_TAGS).analyze();
+ const violations = results.violations.filter(v => BLOCKING.includes(v.impact));
+ expect(
+ violations,
+ violations.map(v =>
+ `[${v.impact}] ${v.id}: ${v.description}\n ` +
+ v.nodes.map(n => n.html).join('\n ')
+ ).join('\n\n')
+ ).toHaveLength(0);
+ });
+}
+
+axeScan('AX1', '/');
+axeScan('AX2', '/trips/italy-2026-demo');
+axeScan('AX3', '/trips/italy-2026-demo/dailies');
+axeScan('AX4', '/trips/italy-2026-demo/dailies/2026-09-01-0700-setting-off-from-campiglia.entry');
+axeScan('AX5', '/trips');
+```
+
+- [ ] **Step 8: Run tests to verify the fix**
+
+```bash
+npx playwright test --reporter=line 2>&1 | tail -5
+```
+
+Expected: at least 40 of the 62 tests pass (the pre-existing M7 marker-click, home map, and between-trips tests may still fail if the site state doesn't match — that's acceptable; what must NOT happen is failures in N1–N4, T1–T6, G1–G5, S1–S7).
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add tests/ui/nav.spec.js tests/ui/dailies.spec.js tests/ui/stories.spec.js \
+ tests/ui/gpx-journey.spec.js tests/ui/home.spec.js \
+ tests/ui/home-highlights.spec.js tests/ui/accessibility.spec.js
+git commit -m "test: fix stale trip-slug references; add home, highlights, a11y specs"
+```
+
+---
+
+## Task 2: Add test-route.gpx fixture
+
+- [ ] **Step 1: Create tests/fixtures/test-route.gpx**
+
+```xml
+
+