@@ -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 `#<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 38– 110: 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 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-<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 ( 7 2 0 , 4 0 5 ) .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.7 s ease-out forwards ;
}
```
Replace with:
``` css
. journal-post . is-highlighted ,
. entry-card . is-highlighted ,
. story-card . is-highlighted {
animation : card-highlight 0.7 s 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.4 s ease ;
}
. panel . is-open {
max-height : 600 px ;
}
```
## Fluid Font Sizing with clamp()
``` css
. stat-value {
font-size : clamp ( 2 rem , 6 vw , var ( - - text -3 xl ) ) ;
}
```
- `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 , 1 fr ) ) ; }
. 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 : 100 dvh !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 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.