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

29 KiB
Raw Blame History

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:

{#
  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
ls -la user/themes/intotheeast/templates/partials/feed-map.html.twig

Expected: file exists, size > 2000 bytes.

  • Step 3: Commit to user repo
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

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:

{% 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:

{% 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
curl -s http://localhost:8081/trips/italy-2026-demo/dailies | grep -c "maplibregl"

Expected: count ≥ 2 (CSS link + JS script).

  • Step 4: Run M3
npx playwright test tests/ui/maps/maps.spec.js --project=chromium --grep="M3" 2>&1 | tail -3

Expected: 1 passed.

  • Step 5: Commit
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:

{% 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:

.journal-post.is-highlighted,
.entry-card.is-highlighted {
    animation: card-highlight 0.7s ease-out forwards;
}

Replace with:

.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
curl -s http://localhost:8081/trips/italy-2026-demo/stories | grep -c "storiesMap"

Expected: count ≥ 2.

Also check story card IDs:

curl -s http://localhost:8081/trips/italy-2026-demo/stories | grep 'id="story-'

Expected: 4 lines (one per demo story).

  • Step 4: Commit
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:

# 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:

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:

.panel {
    max-height: 0;
    overflow: hidden;
    transition: max-height 0.4s ease;
}
.panel.is-open {
    max-height: 600px;
}

Fluid Font Sizing with clamp()

.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

@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:

var el = pswp.currSlide && pswp.currSlide.container;
if (!el) return;
el.classList.add('pswp-key-from-right');

Mobile Fullscreen Map Pattern

.map-col.is-fullscreen {
    position: fixed !important;
    inset: 0;
    z-index: 9999;
    height: 100dvh !important;
}
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:

if (isFullscreen) {
    fsBtn.click();
    setTimeout(scrollAndHighlight, 450);
} else {
    scrollAndHighlight();
}

Shared Twig Partial Pattern

{% 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:

### 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
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:


// ── 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
// @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
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
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
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-testsmain

  • user/ repo: push main to origin

  • Step 1: Verify all 5 tasks are committed

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
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)
make content-push
  • Step 4: Confirm tests still pass on main
npx playwright test --project=chromium 2>&1 | grep -E "passed|failed"

Expected: pass count ≥ baseline.