feat: align maps (shared partial), add stories map, add regression tests
- Extract feed mini-map into shared partials/feed-map.html.twig - Refactor dailies to use shared partial - Add map to stories listing page with story card IDs - Attribution always starts collapsed; marker click exits fullscreen then scrolls - Add M9-M11 (stories map) + MUX1-5 (panel/sort/fullscreen) E2E tests: 84 passed - Document session learnings (MapLibre v4 quirks, CSS patterns, PhotoSwipe v5) - Update CLAUDE.md with shared partial architecture
This commit is contained in:
@@ -35,6 +35,28 @@ The site is structured around Trip entities. Key facts:
|
|||||||
- GPX route files live as media on the trip page itself, served via leaflet-gpx CDN
|
- GPX route files live as media on the trip page itself, served via leaflet-gpx CDN
|
||||||
- Manage GPX files (view/upload/delete) at `/gpx-manager` — requires admin login; filenames are auto-slugified on upload
|
- Manage GPX files (view/upload/delete) at `/gpx-manager` — requires admin login; filenames are auto-slugified on upload
|
||||||
|
|
||||||
|
### 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`.
|
||||||
|
|
||||||
### GPX file management
|
### GPX file management
|
||||||
|
|
||||||
GPX files are stored as page media on the trip page (`user/pages/01.trips/<slug>/`). They are picked up automatically by `map.html.twig` via `trip_page.media.all`.
|
GPX files are stored as page media on the trip page (`user/pages/01.trips/<slug>/`). They are picked up automatically by `map.html.twig` via `trip_page.media.all`.
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# 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
|
||||||
|
.feed-map-wrap.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.
|
||||||
@@ -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(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 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.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// @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);
|
||||||
|
});
|
||||||
@@ -125,3 +125,39 @@ test('M8: home map has a journey source after GPX settles (active trip)', async
|
|||||||
expect(hasSource, 'Home map has a journey or GPX source').toBe(true);
|
expect(hasSource, 'Home map has a journey or GPX source').toBe(true);
|
||||||
expect(errors, 'No JS errors on home page').toHaveLength(0);
|
expect(errors, 'No JS errors on home page').toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── 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 });
|
||||||
|
// Wait for markers (added in map.on('load')) to ensure the load callback has run,
|
||||||
|
// which is also where removeAttribute('open') executes.
|
||||||
|
await expect(page.locator('#feed-map .maplibregl-marker').first()).toBeVisible({ timeout: 15000 });
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user