Files
intotheeast-com/docs/working/plans/2026-06-22-align-maps-tests.md
T

791 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `#<card_prefix><slug>` + 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.<map_var>` 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 %}
<div class="feed-map-wrap">
<div class="feed-map" id="{{ map_id }}">
<button class="feed-map-fullscreen-btn" id="{{ map_id }}-fullscreen" aria-label="Expand map">
<svg class="feed-map-fs-open" aria-hidden="true" width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
<path d="M0 0v4h1.5V1.5H4V0z M14 0H10v1.5h2.5V4H14z M0 14v-4h1.5v2.5H4V14z M14 14H10v-1.5h2.5V10H14z"/>
</svg>
<span class="feed-map-fs-close" aria-hidden="true">✕</span>
</button>
</div>
{% if link_href %}
<a class="feed-map-link" href="{{ link_href }}">View full map →</a>
{% endif %}
</div>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.css">
<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.js"></script>
<script src="{{ url('theme://js/maplibre-utils.js') }}"></script>
<script>
{% set js_suffix = map_id|replace({'-': '_'})|upper %}
var MAP_ENTRIES_{{ js_suffix }} = {{ map_entries|json_encode|raw }};
{% if show_journey %}
{% set _ac = trip_page ? (trip_page.header.autoconnect ?? 'on') : 'on' %}
var AUTOCONNECT_{{ js_suffix }} = "{{ _ac == 'intelligent_gpx' ? 'on' : _ac }}";
{% endif %}
var {{ map_var }} = new maplibregl.Map({
container: '{{ map_id }}',
style: MapUtils.MAP_STYLE,
center: [20, 20],
zoom: 2,
attributionControl: false
});
{{ map_var }}.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-left');
{{ map_var }}.on('load', function () {
var attrib = {{ map_var }}.getContainer().querySelector('.maplibregl-ctrl-attrib');
if (attrib) attrib.removeAttribute('open');
var bounds = new maplibregl.LngLatBounds();
var entries = MAP_ENTRIES_{{ js_suffix }};
entries.forEach(function (entry, i) {
var isLatest = (entry.type !== 'story') && (i === entries.length - 1);
var lngLat = [parseFloat(entry.lng), parseFloat(entry.lat)];
bounds.extend(lngLat);
var el = entry.type === 'story' ? MapUtils.createStoryMarker() : MapUtils.createDotMarker(isLatest);
el.dataset.url = entry.url;
var popup = new maplibregl.Popup({ offset: 12, closeButton: false, closeOnClick: false, className: 'map-tip-popup' })
.setLngLat(lngLat)
.setHTML('<span class="map-tip">' + entry.title + '</span>');
el.addEventListener('mouseenter', function () { popup.addTo({{ map_var }}); });
el.addEventListener('mouseleave', function () { popup.remove(); });
el.addEventListener('click', function () {
var card = document.getElementById('{{ card_prefix }}' + entry.slug);
var mapWrap = document.querySelector('.feed-map-wrap');
var isFs = mapWrap && mapWrap.classList.contains('is-fullscreen');
function scrollAndHighlight() {
if (!card) { window.location.href = entry.url; return; }
window.location.hash = '{{ card_prefix }}' + entry.slug;
setTimeout(function () {
card.classList.add('is-highlighted');
setTimeout(function () { card.classList.remove('is-highlighted'); }, 700);
}, 350);
}
if (isFs) {
var fsBtn = document.getElementById('{{ map_id }}-fullscreen');
if (fsBtn) fsBtn.click();
setTimeout(scrollAndHighlight, 450);
} else {
scrollAndHighlight();
}
});
new maplibregl.Marker({ element: el }).setLngLat(lngLat).addTo({{ map_var }});
});
if (entries.length === 1) {
{{ map_var }}.jumpTo({ center: [parseFloat(entries[0].lng), parseFloat(entries[0].lat)], zoom: 10 });
} else {
{{ map_var }}.fitBounds(bounds, { padding: 60, maxZoom: 11 });
}
{% if show_journey %}
var segments = MapUtils.buildJourneySegments(entries, { connectMode: AUTOCONNECT_{{ js_suffix }} });
MapUtils.addJourneySegments({{ map_var }}, segments, '{{ map_id }}-journey');
{% endif %}
});
</script>
<script>
(function() {
var fsBtn = document.getElementById('{{ map_id }}-fullscreen');
var mapWrap = document.querySelector('.feed-map-wrap');
if (!fsBtn || !mapWrap) return;
fsBtn.addEventListener('click', function() {
var isFs = mapWrap.classList.toggle('is-fullscreen');
fsBtn.setAttribute('aria-label', isFs ? 'Close map' : 'Expand map');
document.body.style.overflow = isFs ? 'hidden' : '';
setTimeout(function() { typeof {{ map_var }} !== 'undefined' && {{ map_var }}.resize(); }, 50);
});
})();
</script>
{% 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 38110: from `{% if map_entries|length > 0 %}` through the fullscreen `</script>`) 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 %}
<div class="feed-map-wrap">
```
…through the end of the second `</script>` 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 2136), 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 (4243° 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-<slug>"`.
- [ ] **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 %}
<div class="stories-listing">
<div class="stories-listing__header">
<h1 class="stories-listing__heading">Stories</h1>
<button class="trip-stats-btn" id="feed-sort-toggle" aria-label="Sort: oldest first">↑ Oldest first</button>
</div>
{% if stories|length > 0 %}
<div class="stories-grid">
{% 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 %}
<a class="story-card" id="story-{{ story.slug }}" href="{{ story.url }}">
{% if hero %}
<div class="story-card__photo">
<img src="{{ hero.cropResize(720, 405).url }}" alt="{{ story.title }}" loading="lazy">
</div>
{% else %}
<div class="story-card__photo story-card__photo--empty"></div>
{% endif %}
<div class="story-card__body">
<time class="story-card__date" datetime="{{ story.date|date('Y-m-d') }}">{{ date_str }}</time>
{% if story.header.location_name %}
<span class="story-card__location">📍 {{ story.header.location_name }}{% if story.header.location_country %}, {{ story.header.location_country }}{% endif %}</span>
{% endif %}
<h2 class="story-card__title">{{ story.title }}</h2>
<span class="story-card__cta">Read story →</span>
</div>
</a>
{% endfor %}
</div>
{% else %}
<p class="stories-empty">No stories yet — check back soon.</p>
{% endif %}
</div>
<script>
(function() {
var sortBtn = document.getElementById('feed-sort-toggle');
if (!sortBtn) return;
var grid = document.querySelector('.stories-grid');
if (!grid) return;
var ascending = true;
sortBtn.addEventListener('click', function() {
ascending = !ascending;
var cards = Array.from(grid.querySelectorAll('.story-card'));
cards.reverse().forEach(function(el) { grid.appendChild(el); });
sortBtn.textContent = ascending ? '↑ Oldest first' : '↓ Newest first';
sortBtn.setAttribute('aria-label', ascending ? 'Sort: oldest first' : 'Sort: newest first');
sortBtn.classList.toggle('is-active', !ascending);
});
})();
</script>
{% 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 `<details>` 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 `#<card_prefix><slug>` + 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 M9M11)
- Create: `tests/ui/maps/map-ux.spec.js` (MUX1MUX5)
**Interfaces:**
- Consumes: live dev server at `http://localhost:8081` with demo content loaded.
- Produces: 8 new passing tests.
> **Important:** Tasks 13 must be complete before running these tests (they test the newly built behaviour).
- [ ] **Step 1: Append M9M11 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: MUX1MUX5 — 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: M1M7, M9M11, MUX1MUX5 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.