diff --git a/docs/working/plans/2026-06-22-align-maps-tests.md b/docs/working/plans/2026-06-22-align-maps-tests.md new file mode 100644 index 0000000..8500c85 --- /dev/null +++ b/docs/working/plans/2026-06-22-align-maps-tests.md @@ -0,0 +1,790 @@ +# Feed-Map Alignment, Stories Map & E2E Tests + +> **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:** Extract the shared feed mini-map into a reusable Twig partial, add the same map to the stories listing page, fix dailies attribution/marker-click bugs, document session learnings, and add regression tests for all new UX features. + +**Architecture:** A single `partials/feed-map.html.twig` partial handles all mini-map surfaces (dailies + stories), accepting parameterised `map_id`, `map_var`, `card_prefix`, and `show_journey` variables. The trip page (`trip.html.twig`) is a different layout and stays separate. Tests live in the existing `tests/ui/maps/` folder; new map-UX tests (panels, sort, fullscreen) go into `tests/ui/maps/map-ux.spec.js`. + +**Tech Stack:** Grav CMS 2.0 Twig templates, MapLibre GL JS v4, Playwright E2E tests (Node.js/Chromium). + +## Global Constraints + +- **Never read or expose `.env`** — contains sensitive credentials; pass it to `make` commands only +- **Dev server URL:** `http://localhost:8081` +- **Playwright runs via:** `npx playwright test --project=chromium` (auth session already cached in `tests/.auth/user.json`) +- **All Twig template changes** go to `user/themes/intotheeast/templates/` — commit with `git -C user/ commit` +- **All CSS changes** go to `user/themes/intotheeast/css/style.css` — commit with `git -C user/ commit` +- **Test changes** go to `tests/ui/` in the main project repo — commit with `git commit` (not `git -C user/`) +- **Docs/CLAUDE.md changes** go in the main project repo — commit with `git commit` +- **Demo trip slug:** `italy-2026-demo` — all E2E tests use this trip +- **MapLibre global vars:** `window.feedMap` (dailies), `window.storiesMap` (stories), `window.tripMap` (trip page) +- **Marker click behaviour:** scroll to `#` + flash `.is-highlighted`. Fall back to `window.location.href = entry.url` only if the card element is not found +- **Attribution fix:** after map `load`, call `map.getContainer().querySelector('.maplibregl-ctrl-attrib')?.removeAttribute('open')` +- **Twig include:** use `{% include '...' with {...} only %}` — Grav global functions (`url()`) still work under `only` +- **Worktree root:** `/home/mischa/Nextcloud/Projects/travel-blog-intotheeast/.claude/worktrees/align-maps-tests/` +- **user/ repo path:** `/home/mischa/Nextcloud/Projects/travel-blog-intotheeast/.claude/worktrees/align-maps-tests/user/` — this is a git submodule; commit there with `git -C user/ ...` + +--- + +### Task 1: Create `partials/feed-map.html.twig` shared partial + +**Files:** +- Create: `user/themes/intotheeast/templates/partials/feed-map.html.twig` + +**Interfaces:** +- Produces: A Twig partial renderable via `{% include 'partials/feed-map.html.twig' with {...} only %}`. +- The `window.` MapLibre instance is accessible to Playwright tests as e.g. `window.feedMap`. + +- [ ] **Step 1: Create the partial file** + +Create `user/themes/intotheeast/templates/partials/feed-map.html.twig`: + +```twig +{# + Feed mini-map partial — shared by dailies.html.twig and stories.html.twig. + + Required variables (via {% include ... with {...} only %}): + map_entries — array: [{lat, lng, title, slug, url, type, force_connect, transport_mode}] + map_id — string: HTML id for the map div (e.g. 'feed-map', 'stories-map') + map_var — string: JS variable name for the MapLibre Map (e.g. 'feedMap', 'storiesMap') + link_href — string|null: URL for "View full map" link; null/empty hides the link + card_prefix — string: prefix for scroll-to card IDs ('entry-' or 'story-') + trip_page — Grav page: trip page for autoconnect setting (used when show_journey is true) + show_journey — bool: whether to draw the route connector line between markers +#} +{% if map_entries|length > 0 %} +
+
+ +
+ {% if link_href %} + View full map → + {% endif %} +
+ + + + + + +{% endif %} +``` + +- [ ] **Step 2: Verify the file exists** + +```bash +ls -la user/themes/intotheeast/templates/partials/feed-map.html.twig +``` + +Expected: file exists, size > 2000 bytes. + +- [ ] **Step 3: Commit to user repo** + +```bash +git -C user/ add themes/intotheeast/templates/partials/feed-map.html.twig +git -C user/ commit -m "feat: add shared feed-map partial (dailies + stories)" +``` + +--- + +### Task 2: Refactor dailies to use the shared partial + +**Files:** +- Modify: `user/themes/intotheeast/templates/dailies.html.twig` + +The current inline map block (lines 38–110: from `{% if map_entries|length > 0 %}` through the fullscreen ``) is replaced with a single `{% include %}`. + +**Interfaces:** +- Consumes: `partials/feed-map.html.twig` (Task 1). +- The `window.feedMap` global is still produced (now by the partial). + +- [ ] **Step 1: Verify M3 passes as baseline** + +```bash +npx playwright test tests/ui/maps/maps.spec.js --project=chromium --grep="M3" 2>&1 | tail -3 +``` + +Expected: `1 passed`. + +- [ ] **Step 2: Replace the inline map block in dailies.html.twig** + +In `user/themes/intotheeast/templates/dailies.html.twig`, find the entire block: + +```twig +{% if map_entries|length > 0 %} +
+``` + +…through the end of the second `` tag (the fullscreen toggle script). Delete those ~73 lines and replace with: + +```twig +{% include 'partials/feed-map.html.twig' with { + 'map_entries': map_entries, + 'map_id': 'feed-map', + 'map_var': 'feedMap', + 'link_href': page.parent().url ~ '/map', + 'card_prefix': 'entry-', + 'trip_page': trip_page, + 'show_journey': true +} only %} +``` + +The `map_entries` and `trip_page` variables are already set above this line in dailies.html.twig (lines 21–36), so they're available. + +- [ ] **Step 3: Confirm the page renders** + +```bash +curl -s http://localhost:8081/trips/italy-2026-demo/dailies | grep -c "maplibregl" +``` + +Expected: count ≥ 2 (CSS link + JS script). + +- [ ] **Step 4: Run M3** + +```bash +npx playwright test tests/ui/maps/maps.spec.js --project=chromium --grep="M3" 2>&1 | tail -3 +``` + +Expected: `1 passed`. + +- [ ] **Step 5: Commit** + +```bash +git -C user/ add themes/intotheeast/templates/dailies.html.twig +git -C user/ commit -m "refactor(dailies): use shared feed-map partial" +``` + +--- + +### Task 3: Add map + story card IDs to the stories listing page + +**Files:** +- Modify: `user/themes/intotheeast/templates/stories.html.twig` +- Modify: `user/themes/intotheeast/css/style.css` + +All 4 demo stories already have `lat`/`lng` in their frontmatter (42–43° N, 11° E), so no content changes needed. + +**Interfaces:** +- Consumes: `partials/feed-map.html.twig` (Task 1). +- Produces: `window.storiesMap` global; story cards with `id="story-"`. + +- [ ] **Step 1: Rewrite stories.html.twig** + +Full replacement for `user/themes/intotheeast/templates/stories.html.twig`: + +```twig +{% extends 'partials/base.html.twig' %} + +{% block content %} +{% set stories = page.children.published().order('date', 'asc') %} + +{# Collect stories that have coordinates for the mini-map #} +{% set map_entries = [] %} +{% for story in stories %} + {% if story.header.lat is not empty and story.header.lng is not empty %} + {% set map_entries = map_entries|merge([{ + 'lat': story.header.lat, + 'lng': story.header.lng, + 'title': story.title, + 'slug': story.slug, + 'url': story.url, + 'type': 'story', + 'force_connect': false, + 'transport_mode': null + }]) %} + {% endif %} +{% endfor %} + +{% set trip_page = page.parent() %} + +{% include 'partials/feed-map.html.twig' with { + 'map_entries': map_entries, + 'map_id': 'stories-map', + 'map_var': 'storiesMap', + 'link_href': null, + 'card_prefix': 'story-', + 'trip_page': trip_page, + 'show_journey': false +} only %} + +
+
+

Stories

+ +
+ + {% if stories|length > 0 %} +
+ {% for story in stories %} + {% set hero = null %} + {% if story.header.hero_image and story.media[story.header.hero_image] is defined %} + {% set hero = story.media[story.header.hero_image] %} + {% endif %} + + {% set date_str = story.date|date('d M Y') %} + {% if story.header.end_date %} + {% set date_str = story.date|date('d M') ~ '–' ~ story.header.end_date|date('d M Y') %} + {% endif %} + + + {% if hero %} +
+ {{ story.title }} +
+ {% else %} +
+ {% endif %} +
+ + {% if story.header.location_name %} + 📍 {{ story.header.location_name }}{% if story.header.location_country %}, {{ story.header.location_country }}{% endif %} + {% endif %} +

{{ story.title }}

+ Read story → +
+
+ {% endfor %} +
+ {% else %} +

No stories yet — check back soon.

+ {% endif %} +
+ +{% endblock %} +``` + +- [ ] **Step 2: Add `.story-card.is-highlighted` to style.css** + +In `user/themes/intotheeast/css/style.css`, find: + +```css +.journal-post.is-highlighted, +.entry-card.is-highlighted { + animation: card-highlight 0.7s ease-out forwards; +} +``` + +Replace with: + +```css +.journal-post.is-highlighted, +.entry-card.is-highlighted, +.story-card.is-highlighted { + animation: card-highlight 0.7s ease-out forwards; +} +``` + +- [ ] **Step 3: Verify stories page renders a map** + +```bash +curl -s http://localhost:8081/trips/italy-2026-demo/stories | grep -c "storiesMap" +``` + +Expected: count ≥ 2. + +Also check story card IDs: + +```bash +curl -s http://localhost:8081/trips/italy-2026-demo/stories | grep 'id="story-' +``` + +Expected: 4 lines (one per demo story). + +- [ ] **Step 4: Commit** + +```bash +git -C user/ add themes/intotheeast/templates/stories.html.twig themes/intotheeast/css/style.css +git -C user/ commit -m "feat(stories): add mini-map via shared partial, add story card IDs" +``` + +--- + +### Task 4: Document session learnings and update CLAUDE.md + +**Files:** +- Create: `docs/working/learnings/2026-06-22-mobile-ux-learnings.md` +- Modify: `CLAUDE.md` + +**Interfaces:** +- No code dependencies. Standalone documentation task. + +- [ ] **Step 1: Create learnings document** + +Create `docs/working/learnings/2026-06-22-mobile-ux-learnings.md`: + +```markdown +# Mobile UX Session Learnings — 2026-06-22 + +Discoveries from the mobile polish session (stat scaling, map fullscreen, panel toggles, shared partials). + +## MapLibre GL JS v4 — Attribution starts expanded despite compact: true + +**Problem:** `new maplibregl.AttributionControl({ compact: true })` renders a `
` element. In MapLibre v4, this element has `open` set after `map.on('load')` fires, so the attribution panel starts expanded even though `compact: true` was passed. + +**Fix:** In the `load` handler, explicitly remove the `open` attribute: +```js +map.on('load', function () { + var attrib = map.getContainer().querySelector('.maplibregl-ctrl-attrib'); + if (attrib) attrib.removeAttribute('open'); +}); +``` + +**Also:** To avoid the default attribution control conflicting with a custom button in `bottom-right`, disable it in the constructor and add it manually to `bottom-left`: +```js +var map = new maplibregl.Map({ ..., attributionControl: false }); +map.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-left'); +``` + +## CSS Panel Animation — max-height beats grid-template-rows: 0fr + +**Problem:** `grid-template-rows: 0fr → 1fr` transition fails when the direct grid child has `overflow: hidden`. The child creates a Block Formatting Context (BFC) that prevents `0fr` from collapsing to zero height. + +**Fix:** Use `max-height` transition on the outer container: +```css +.panel { + max-height: 0; + overflow: hidden; + transition: max-height 0.4s ease; +} +.panel.is-open { + max-height: 600px; +} +``` + +## Fluid Font Sizing with clamp() + +```css +.stat-value { + font-size: clamp(2rem, 6vw, var(--text-3xl)); +} +``` + +- `clamp(min, preferred, max)`: scales linearly between min and max +- `6vw` at 333px viewport = 20px = 1.25rem, but floor is 2rem (32px) +- Keep labels at `--text-xs` (0.75rem) intentionally — the contrast makes values pop + +## CSS Grid — Spanning the Lone Last Item in a 2-Column Grid + +```css +@media (max-width: 600px) { + .my-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .my-grid .item:last-child:nth-child(odd) { grid-column: 1 / -1; } +} +``` + +- `minmax(0, 1fr)` — strictly equal columns (bare `1fr` has a hidden `auto` minimum) +- `:last-child:nth-child(odd)` — matches an item that is both last and in an odd position + +## PhotoSwipe v5 — Correct Element for CSS Animations + +**Problem:** `pswp.currSlide.el` is `undefined` in PhotoSwipe v5. + +**Fix:** Use `pswp.currSlide.container` — the DOM wrapper for the current slide: +```js +var el = pswp.currSlide && pswp.currSlide.container; +if (!el) return; +el.classList.add('pswp-key-from-right'); +``` + +## Mobile Fullscreen Map Pattern + +```css +.map-col.is-fullscreen { + position: fixed !important; + inset: 0; + z-index: 9999; + height: 100dvh !important; +} +``` + +```js +fsBtn.addEventListener('click', function() { + var isFs = mapCol.classList.toggle('is-fullscreen'); + document.body.style.overflow = isFs ? 'hidden' : ''; + setTimeout(function() { map.resize(); }, 50); +}); +``` + +**Marker click while fullscreen:** Exit fullscreen first, then scroll after the transition: +```js +if (isFullscreen) { + fsBtn.click(); + setTimeout(scrollAndHighlight, 450); +} else { + scrollAndHighlight(); +} +``` + +## Shared Twig Partial Pattern + +```twig +{% include 'partials/feed-map.html.twig' with { + 'map_entries': map_entries, + 'map_id': 'feed-map', + 'map_var': 'feedMap', + 'link_href': page.parent().url ~ '/map', + 'card_prefix': 'entry-', + 'trip_page': trip_page, + 'show_journey': true +} only %} +``` + +Grav's global Twig functions (`url()`, `theme_var()`) remain available with `only`. Only parent template variables are excluded. +``` + +- [ ] **Step 2: Add shared partial section to CLAUDE.md** + +In `CLAUDE.md`, find the exact text: + +```markdown +### GPX file management +``` + +Insert the following block immediately before that line: + +```markdown +### Shared feed-map partial + +The mini-map above the feed is shared across two pages via a Twig partial: + +- **Partial:** `user/themes/intotheeast/templates/partials/feed-map.html.twig` +- **Used by:** `dailies.html.twig` and `stories.html.twig` +- **NOT used by:** `trip.html.twig` (uses its own `#trip-map` / `.home-map-col` layout) + +**Parameters (passed via `{% include ... with {...} only %}`):** + +| Parameter | Type | Description | +|---|---|---| +| `map_entries` | array | `[{lat, lng, title, slug, url, type, force_connect, transport_mode}]` | +| `map_id` | string | HTML id for map div: `'feed-map'` or `'stories-map'` | +| `map_var` | string | JS global variable: `'feedMap'` or `'storiesMap'` | +| `link_href` | string\|null | "View full map" link URL; `null` hides it | +| `card_prefix` | string | Scroll-to ID prefix: `'entry-'` (dailies) or `'story-'` (stories) | +| `trip_page` | Page | Trip page object for autoconnect setting | +| `show_journey` | bool | `true` draws the route connector; `false` skips it | + +The partial always: starts attribution collapsed, shows the fullscreen button (mobile-only, CSS `display:none` ≥769px), and on marker click scrolls to `#` + flashes `.is-highlighted`. + +``` + +- [ ] **Step 3: Commit docs** + +```bash +mkdir -p docs/working/learnings +git add docs/working/learnings/2026-06-22-mobile-ux-learnings.md CLAUDE.md +git commit -m "docs: add mobile-ux session learnings and shared partial architecture" +``` + +--- + +### Task 5: E2E tests for stories map, attribution, panels, sort, and fullscreen + +**Files:** +- Modify: `tests/ui/maps/maps.spec.js` (add M9–M11) +- Create: `tests/ui/maps/map-ux.spec.js` (MUX1–MUX5) + +**Interfaces:** +- Consumes: live dev server at `http://localhost:8081` with demo content loaded. +- Produces: 8 new passing tests. + +> **Important:** Tasks 1–3 must be complete before running these tests (they test the newly built behaviour). + +- [ ] **Step 1: Append M9–M11 to the end of `tests/ui/maps/maps.spec.js`** + +Add after the last line of the existing file: + +```js + +// ── M9: Stories mini-map renders MapLibre canvas ────────────────────────────── +test('M9: Stories mini-map renders MapLibre GL canvas without JS errors', async ({ page }) => { + const errors = []; + page.on('pageerror', e => errors.push(e.message)); + + await page.goto('/trips/italy-2026-demo/stories'); + await expect(page.locator('#stories-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 }); + expect(errors, 'No JS errors on stories page').toHaveLength(0); +}); + +// ── M10: Stories mini-map has at least one story marker ────────────────────── +test('M10: Stories mini-map has at least one story marker', async ({ page }) => { + await page.goto('/trips/italy-2026-demo/stories'); + await expect(page.locator('#stories-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#stories-map .maplibregl-marker').first()).toBeVisible({ timeout: 15000 }); + + const markerCount = await page.locator('#stories-map .maplibregl-marker').count(); + expect(markerCount, 'At least one story marker').toBeGreaterThan(0); +}); + +// ── M11: Dailies attribution control starts collapsed ───────────────────────── +test('M11: Dailies mini-map attribution starts collapsed (no open attribute)', async ({ page }) => { + await page.goto('/trips/italy-2026-demo/dailies'); + await expect(page.locator('#feed-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#feed-map .maplibregl-ctrl-attrib')).toBeVisible({ timeout: 10000 }); + + const hasOpen = await page.evaluate(function () { + var attrib = document.querySelector('#feed-map .maplibregl-ctrl-attrib'); + return attrib ? attrib.hasAttribute('open') : null; + }); + expect(hasOpen, 'Attribution is collapsed (no open attribute)').toBe(false); +}); +``` + +- [ ] **Step 2: Create `tests/ui/maps/map-ux.spec.js`** + +```js +// @ts-check +// Tests: MUX1–MUX5 — Map UX features: panel toggles, sort toggle, fullscreen button +// Requires demo data: `make demo-load` before running. +const { test, expect } = require('@playwright/test'); + +// ── MUX1: Trip stats panel toggles open and closed ────────────────────────── +test('MUX1: trip stats panel opens and closes on button click', async ({ page }) => { + await page.goto('/trips/italy-2026-demo'); + + const statsBtn = page.locator('#trip-stats-toggle'); + const statsBlock = page.locator('#trip-stats-block'); + + await expect(statsBtn).toBeVisible(); + await expect(statsBlock).not.toHaveClass(/is-open/); + + await statsBtn.click(); + await expect(statsBlock).toHaveClass(/is-open/); + await expect(page.locator('.trip-stats-grid')).toBeVisible(); + + await statsBtn.click(); + await expect(statsBlock).not.toHaveClass(/is-open/); +}); + +// ── MUX2: Trip cycling panel toggles open and closed ──────────────────────── +test('MUX2: trip cycling panel opens and closes on button click', async ({ page }) => { + await page.goto('/trips/italy-2026-demo'); + + const cyclingBtn = page.locator('#trip-cycling-toggle'); + const cyclingBlock = page.locator('#trip-cycling-block'); + + await expect(cyclingBtn).toBeVisible(); + await expect(cyclingBlock).not.toHaveClass(/is-open/); + + await cyclingBtn.click(); + await expect(cyclingBlock).toHaveClass(/is-open/); + + await cyclingBtn.click(); + await expect(cyclingBlock).not.toHaveClass(/is-open/); +}); + +// ── MUX3: Trip page map has a fullscreen button in the DOM ──────────────────── +test('MUX3: trip page map has a fullscreen toggle button', async ({ page }) => { + await page.goto('/trips/italy-2026-demo'); + await expect(page.locator('#trip-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 }); + + const fsBtn = page.locator('#trip-map-fullscreen'); + await expect(fsBtn).toBeAttached(); + await expect(fsBtn).toHaveAttribute('aria-label', 'Expand map'); +}); + +// ── MUX4: Dailies sort toggle reverses entry order ─────────────────────────── +test('MUX4: dailies sort toggle reverses the feed entry order', async ({ page }) => { + await page.goto('/trips/italy-2026-demo/dailies'); + + const sortBtn = page.locator('#feed-sort-toggle'); + await expect(sortBtn).toBeVisible(); + + const firstBefore = await page.locator('[data-type]').first().getAttribute('id'); + + await sortBtn.click(); + + const firstAfter = await page.locator('[data-type]').first().getAttribute('id'); + expect(firstAfter, 'Entry order reversed after sort').not.toBe(firstBefore); + + await sortBtn.click(); + const firstRestored = await page.locator('[data-type]').first().getAttribute('id'); + expect(firstRestored, 'Entry order restored after second toggle').toBe(firstBefore); +}); + +// ── MUX5: Stories sort toggle reverses story card order ───────────────────── +test('MUX5: stories sort toggle reverses the story card order', async ({ page }) => { + await page.goto('/trips/italy-2026-demo/stories'); + + const sortBtn = page.locator('#feed-sort-toggle'); + await expect(sortBtn).toBeVisible(); + + const firstBefore = await page.locator('.story-card').first().getAttribute('id'); + + await sortBtn.click(); + + const firstAfter = await page.locator('.story-card').first().getAttribute('id'); + expect(firstAfter, 'Story order reversed after sort').not.toBe(firstBefore); + + await sortBtn.click(); + const firstRestored = await page.locator('.story-card').first().getAttribute('id'); + expect(firstRestored, 'Story order restored after second toggle').toBe(firstBefore); +}); +``` + +- [ ] **Step 3: Run the new maps tests** + +```bash +npx playwright test tests/ui/maps/ --project=chromium 2>&1 | tail -10 +``` + +Expected: M1–M7, M9–M11, MUX1–MUX5 pass. (M8 is a pre-existing failure — active trip GPX config.) + +- [ ] **Step 4: Run the full suite — verify no regressions** + +```bash +npx playwright test --project=chromium 2>&1 | grep -E "^[[:space:]]*(passed|failed|skipped)" +``` + +Expected: pass count ≥ 76 (baseline), failed count ≤ 4 (pre-existing). + +- [ ] **Step 5: Commit tests** + +```bash +git add tests/ui/maps/maps.spec.js tests/ui/maps/map-ux.spec.js +git commit -m "test: add M9-M11 stories map + MUX1-5 panel/sort/fullscreen regression tests" +``` + +--- + +### Task 6: Merge worktree branch to main and push user/ content + +**Files:** +- Main repo: merge `worktree-align-maps-tests` → `main` +- user/ repo: push `main` to origin + +- [ ] **Step 1: Verify all 5 tasks are committed** + +```bash +git log --oneline -10 +git -C user/ log --oneline -5 +``` + +Expected: docs commit, tests commit in main repo; at least 3 commits in user/ (partial, dailies refactor, stories+CSS). + +- [ ] **Step 2: Exit worktree and merge to main** + +```bash +cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast +git merge worktree-align-maps-tests --no-ff -m "feat: align maps, add stories map, add regression tests" +``` + +- [ ] **Step 3: Push user/ content to origin (triggers production pull)** + +```bash +make content-push +``` + +- [ ] **Step 4: Confirm tests still pass on main** + +```bash +npx playwright test --project=chromium 2>&1 | grep -E "passed|failed" +``` + +Expected: pass count ≥ baseline.