diff --git a/docs/superpowers/plans/2026-06-19-maplibre-migration.md b/docs/superpowers/plans/2026-06-19-maplibre-migration.md
new file mode 100644
index 0000000..eac398c
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-19-maplibre-migration.md
@@ -0,0 +1,538 @@
+# MapLibre GL Migration Implementation Plan
+
+> **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.
+
+**Status:** ✅ Complete (2026-06-20)
+
+**Goal:** Replace Leaflet JS across all three maps (full map, mini-map on dailies, home page map) with MapLibre GL JS, add an animated journey line, and improve map CSS using our design tokens.
+
+**Architecture:** A shared JS utility file (`maplibre-utils.js`) provides `animateJourneyLine`, `addJourneyLine`, and `createDotMarker` — reused by all three map templates. Each template loads MapLibre GL + the utility file, then calls these helpers. GPX rendering switches from `leaflet-gpx` to `@mapbox/togeojson` + MapLibre GeoJSON layers.
+
+**Tech Stack:** MapLibre GL JS 4.x (CDN), `@mapbox/togeojson` 0.16.2 (CDN), CARTO dark-matter vector style (free, no key), vanilla JS (no framework).
+
+## Global Constraints
+
+- MapLibre GL CDN: `https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.js` and `.css`
+- toGeoJSON CDN: `https://cdn.jsdelivr.net/npm/@mapbox/togeojson@0.16.2/togeojson.min.js`
+- Map tile style URL: `https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json`
+- Accent colour (journey line, markers): `#2A8C73` — matches `--color-accent` in `tokens.css`
+- Latest-entry marker accent: `#155244` (same as current Leaflet code)
+- Animation duration: 5000ms, ease-out cubic
+- Respect `prefers-reduced-motion: reduce` — skip animation, show full line immediately
+- `cooperativeGestures` on embedded maps (mini-map, home map); full-page map uses default (free) gestures
+- No new Grav plugins, no npm — CDN only
+- Run `make content-push` after changes to sync to production git repo
+
+---
+
+### Task 1: CSS — Remove Leaflet override, add MapLibre design-token styles
+
+**Files:**
+- Modify: `user/themes/intotheeast/css/style.css` (around line 371)
+
+**What:** Delete the one Leaflet-specific rule and add a MapLibre CSS block that styles navigation controls, attribution bar, popups, and cursor using design tokens.
+
+- [x] **Open style.css and find the Leaflet block**
+
+ Locate (around line 371):
+ ```css
+ /* match CartoDB dark tile background so no grey flash on load/zoom */
+ .leaflet-container { background: #282828 !important; }
+ ```
+
+- [x] **Delete that rule and replace with the MapLibre block**
+
+ Delete the line above. Immediately after the `.map-empty { ... }` block (around line 381), add:
+
+ ```css
+ /* ── MapLibre GL overrides ───────────────────────────────────────────────── */
+
+ /* Navigation controls (zoom +/−) */
+ .maplibregl-ctrl-group {
+ background: var(--color-canvas);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ box-shadow: var(--shadow-sm);
+ }
+ .maplibregl-ctrl-group button {
+ color: var(--color-ink-2);
+ }
+ .maplibregl-ctrl-group button:hover {
+ background: var(--color-surface-raised);
+ color: var(--color-ink);
+ }
+ .maplibregl-ctrl-group button + button {
+ border-top: 1px solid var(--color-border);
+ }
+
+ /* Attribution bar */
+ .maplibregl-ctrl-attrib {
+ background: rgba(26, 24, 20, 0.75) !important;
+ color: var(--color-ink-muted) !important;
+ font-family: var(--font-ui);
+ font-size: 0.7rem;
+ backdrop-filter: blur(4px);
+ -webkit-backdrop-filter: blur(4px);
+ }
+ .maplibregl-ctrl-attrib a {
+ color: var(--color-accent) !important;
+ }
+
+ /* Popup */
+ .maplibregl-popup-content {
+ background: var(--color-canvas);
+ color: var(--color-ink);
+ font-family: var(--font-ui);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-md);
+ padding: var(--space-4);
+ }
+ .maplibregl-popup-tip {
+ border-top-color: var(--color-canvas) !important;
+ }
+ .maplibregl-popup-close-button {
+ color: var(--color-ink-muted);
+ font-size: 1.1rem;
+ padding: var(--space-1) var(--space-2);
+ }
+ .maplibregl-popup-close-button:hover {
+ color: var(--color-ink);
+ background: transparent;
+ }
+
+ /* Cursor */
+ .maplibregl-canvas-container.maplibregl-interactive { cursor: grab; }
+ .maplibregl-canvas-container.maplibregl-interactive:active { cursor: grabbing; }
+ ```
+
+- [x] **Verify: open `http://localhost:8081/map` in browser**
+
+ If no entries exist, run `make demo-load` first. Check:
+ - No JS errors in console
+ - Page layout unchanged (map still fills viewport below nav)
+
+- [x] **Commit**
+
+ ```bash
+ git -C user add themes/intotheeast/css/style.css
+ git -C user commit -m "style: swap Leaflet CSS override for MapLibre design-token styles"
+ ```
+
+---
+
+### Task 2: Shared JS utilities file
+
+**Files:**
+- Create: `user/themes/intotheeast/js/maplibre-utils.js`
+
+**Interfaces:**
+- Produces: `window.MapUtils.animateJourneyLine(map, coords, sourceId)`, `window.MapUtils.addJourneyLine(map, coords, sourceId)`, `window.MapUtils.createDotMarker(isLatest)`, `window.MapUtils.MAP_STYLE`, `window.MapUtils.ACCENT`
+- Loaded by: all three map templates via ``
+
+**What:** Extract the animated journey line logic and marker factory into a single file so all three templates share one implementation.
+
+- [x] **Create `user/themes/intotheeast/js/maplibre-utils.js`**
+
+ ```js
+ /* Shared MapLibre GL utilities — loaded by map.html.twig, dailies.html.twig, home.html.twig */
+ (function (global) {
+ var ACCENT = '#2A8C73';
+ var ACCENT_DIM = '#155244';
+ var MAP_STYLE = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
+
+ /* Build a GeoJSON LineString feature */
+ function lineFeature(coords) {
+ return { type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } };
+ }
+
+ /*
+ * Progressively draw the journey line using a requestAnimationFrame loop.
+ * coords: [[lng, lat], ...] in chronological order.
+ * sourceId: the MapLibre source id to update each frame.
+ */
+ function animateJourneyLine(map, coords, sourceId) {
+ if (coords.length < 2) return;
+
+ /* Cumulative Euclidean distance between waypoints */
+ var segDist = [0];
+ for (var i = 1; i < coords.length; i++) {
+ var dx = coords[i][0] - coords[i - 1][0];
+ var dy = coords[i][1] - coords[i - 1][1];
+ segDist.push(segDist[i - 1] + Math.sqrt(dx * dx + dy * dy));
+ }
+ var totalDist = segDist[segDist.length - 1];
+ var DURATION = 5000;
+ var startTime = performance.now();
+
+ function frame(now) {
+ if (!map.getSource(sourceId)) return; /* map was removed */
+ var t = Math.min((now - startTime) / DURATION, 1);
+ var eased = 1 - Math.pow(1 - t, 3); /* ease-out cubic */
+ var target = eased * totalDist;
+
+ var animCoords = [coords[0]];
+ for (var j = 1; j < coords.length; j++) {
+ if (segDist[j] <= target) {
+ animCoords.push(coords[j]);
+ } else {
+ var frac = (target - segDist[j - 1]) / (segDist[j] - segDist[j - 1]);
+ animCoords.push([
+ coords[j - 1][0] + (coords[j][0] - coords[j - 1][0]) * frac,
+ coords[j - 1][1] + (coords[j][1] - coords[j - 1][1]) * frac
+ ]);
+ break;
+ }
+ }
+
+ map.getSource(sourceId).setData(lineFeature(animCoords));
+ if (t < 1) requestAnimationFrame(frame);
+ }
+
+ requestAnimationFrame(frame);
+ }
+
+ /*
+ * Add a journey line source + two layers (glow + main) to a loaded map,
+ * then animate or draw instantly based on prefers-reduced-motion.
+ */
+ function addJourneyLine(map, coords, sourceId) {
+ if (coords.length < 2) return;
+
+ map.addSource(sourceId, { type: 'geojson', data: lineFeature([coords[0]]) });
+
+ map.addLayer({
+ id: sourceId + '-glow', type: 'line', source: sourceId,
+ layout: { 'line-join': 'round', 'line-cap': 'round' },
+ paint: { 'line-color': ACCENT, 'line-width': 6, 'line-opacity': 0.18 }
+ });
+
+ map.addLayer({
+ id: sourceId + '-line', type: 'line', source: sourceId,
+ layout: { 'line-join': 'round', 'line-cap': 'round' },
+ paint: { 'line-color': ACCENT, 'line-width': 2.5, 'line-opacity': 0.85 }
+ });
+
+ var reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ if (reducedMotion) {
+ map.getSource(sourceId).setData(lineFeature(coords));
+ } else {
+ animateJourneyLine(map, coords, sourceId);
+ }
+ }
+
+ /*
+ * Return a styled
element for a map marker dot.
+ * isLatest: make it larger with a teal ring.
+ */
+ function createDotMarker(isLatest) {
+ var el = document.createElement('div');
+ var size = isLatest ? 18 : 12;
+ var bg = isLatest ? ACCENT_DIM : ACCENT;
+ var ring = isLatest ? ',0 0 0 4px rgba(42,140,115,0.25)' : '';
+ el.style.cssText = [
+ 'width:' + size + 'px',
+ 'height:' + size + 'px',
+ 'background:' + bg,
+ 'border:2px solid #fff',
+ 'border-radius:50%',
+ 'box-shadow:0 1px 4px rgba(0,0,0,0.4)' + ring,
+ 'cursor:pointer'
+ ].join(';');
+ return el;
+ }
+
+ global.MapUtils = { MAP_STYLE: MAP_STYLE, ACCENT: ACCENT, addJourneyLine: addJourneyLine, createDotMarker: createDotMarker };
+ })(window);
+ ```
+
+- [x] **Verify the file parses without syntax errors**
+
+ ```bash
+ node --check /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user/themes/intotheeast/js/maplibre-utils.js
+ ```
+
+ Expected: no output (clean parse).
+
+- [x] **Commit**
+
+ ```bash
+ git -C user add themes/intotheeast/js/maplibre-utils.js
+ git -C user commit -m "feat: add shared MapLibre GL utilities (journey line, markers)"
+ ```
+
+---
+
+### Task 3: Full map page — migrate map.html.twig
+
+**Files:**
+- Modify: `user/themes/intotheeast/templates/map.html.twig`
+
+**Interfaces:**
+- Consumes: `window.MapUtils` from Task 2 (`MAP_STYLE`, `addJourneyLine`, `createDotMarker`)
+- Twig data shape consumed unchanged: `map_entries` array with `lat`, `lng`, `title`, `date`, `url`, `hero` keys; `gpx_urls` array of strings
+
+**What:** Replace the Leaflet map + GPX rendering with MapLibre GL. Keep all Twig data-gathering logic at the top unchanged. Only the HTML/CSS/JS at the bottom changes.
+
+- [x] **Replace everything from `
` to end of `{% endblock %}`**
+
+ The Twig data-gathering at the top (lines 1–33) is unchanged. Replace from line 35 onwards with:
+
+ ```twig
+
+
+
+
+
+
+
+
+ {% endblock %}
+ ```
+
+- [x] **Verify in browser at `http://localhost:8081/trips/japan-korea-2026/map`**
+
+ With demo data loaded (`make demo-load`):
+ - Dark vector map fills the viewport
+ - 7 teal dot markers visible on Japan→Korea route
+ - Journey line animates in over ~5 seconds on load
+ - Click a marker → popup appears with date, title, "Read entry →" link
+ - Navigate controls (zoom +/−) are styled with dark background (design tokens)
+ - Attribution bar is dark/muted (not white)
+ - No console errors
+
+- [x] **Commit**
+
+ ```bash
+ git -C user add themes/intotheeast/templates/map.html.twig
+ git -C user commit -m "feat: migrate full map page to MapLibre GL with animated journey line"
+ ```
+
+---
+
+### Task 4: Embedded maps — migrate dailies mini-map and home map
+
+**Files:**
+- Modify: `user/themes/intotheeast/templates/dailies.html.twig` (mini-map section, around lines 37–78)
+- Modify: `user/themes/intotheeast/templates/home.html.twig` (map section, around lines 126–168)
+
+**Interfaces:**
+- Consumes: `window.MapUtils` from Task 2
+- Twig data shapes unchanged: `map_entries` (both files) with `lat`, `lng`, `title`, `slug`, `url` keys
+
+**What:** Both embedded maps follow the same pattern — no GPX, no popup (markers navigate on click), `cooperativeGestures: true` to prevent mobile scroll-trap, animated line via `MapUtils.addJourneyLine`.
+
+- [x] **Replace the map block in `dailies.html.twig`**
+
+ Find the `{% if map_entries|length > 0 %}` block (around line 31) and replace from there to the closing `{% endif %}` and the script block:
+
+ ```twig
+ {% if map_entries|length > 0 %}
+
+
+
+
+ {% endblock %}
+ ```
+
+- [x] **Verify template is picked up by Grav**
+
+ Create a minimal story page at `user/pages/01.trips/japan-korea-2026/04.stories/99.hero-test/story.md`:
+
+ ```yaml
+ ---
+ title: Hero Test
+ date: 2026-03-28
+ hero_image: ''
+ published: false
+ ---
+ Just some text.
+ ```
+
+ Visit `http://localhost:8081/trips/japan-korea-2026/stories/hero-test`. The page should render without a 500 error (no hero image is fine — the placeholder div renders). No console errors.
+
+- [x] **Delete the test page**
+
+ ```bash
+ rm -rf user/pages/01.trips/japan-korea-2026/04.stories/99.hero-test
+ ```
+
+- [x] **Commit**
+
+ ```bash
+ git -C user add themes/intotheeast/templates/story.html.twig
+ git -C user commit -m "feat: add story.html.twig with hero scroll effect and shortcode JS"
+ ```
+
+---
+
+### Task 5: `stories.html.twig` listing page + story CSS
+
+**Files:**
+- Modify: `user/themes/intotheeast/templates/stories.html.twig`
+- Modify: `user/themes/intotheeast/css/style.css`
+
+**Interfaces:**
+- Consumes: child pages of the stories listing page, each with `hero_image`, `title`, `date`, `end_date`, `location_name` frontmatter
+
+**What:** Replace the skeleton stories listing page with a card grid. Add all story + shortcode CSS to `style.css` in one block.
+
+- [x] **Replace `stories.html.twig` completely**
+
+ ```twig
+ {% extends 'partials/base.html.twig' %}
+
+ {% block content %}
+ {% set stories = page.children.published().order('date', 'asc') %}
+
+
+
Stories
+
+ {% if stories|length > 0 %}
+
+ {% for story in stories %}
+ {% set hero = null %}
+ {% if story.header.hero_image and story.media[story.header.hero_image] is defined %}
+ {% set hero = story.media[story.header.hero_image] %}
+ {% endif %}
+
+ {% set date_str = story.date|date('d M Y') %}
+ {% if story.header.end_date %}
+ {% set date_str = story.date|date('d M') ~ '–' ~ story.header.end_date|date('d M Y') %}
+ {% endif %}
+
+
+ {% if hero %}
+