diff --git a/docs/working/plans/2026-06-22-asset-pipeline.md b/docs/working/plans/2026-06-22-asset-pipeline.md
new file mode 100644
index 0000000..d5aac36
--- /dev/null
+++ b/docs/working/plans/2026-06-22-asset-pipeline.md
@@ -0,0 +1,967 @@
+# Asset Pipeline & Frontend Reliability 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.
+
+**Goal:** Eliminate all CDN dependencies, self-host fonts, and deduplicate shared JS logic into versioned bundles built via Docker.
+
+**Architecture:** esbuild (via throwaway Docker Node container) produces two IIFE bundles — `js/main.js` (universal UI + fonts) and `js/map.js` (MapLibre + GPX utils) — plus extracted CSS in `css-compiled/`. Templates register assets via Grav's Asset Manager instead of hardcoded CDN tags. All duplicated inline JS moves to the main bundle; map init code stays in templates.
+
+**Tech Stack:** esbuild 0.21+, Node 20 Alpine (Docker only), MapLibre GL 4, PhotoSwipe 5, Scrollama 3, @mapbox/togeojson 0.16, @fontsource-variable/dm-sans, @fontsource/dm-serif-display, Grav 2.0 Asset Manager.
+
+## Global Constraints
+
+- All commands run inside Docker — no local Node.js required
+- Output files (`js/main.js`, `js/map.js`, `css-compiled/*.css`, `fonts/*.woff2`) are committed to the user/ repo
+- `node_modules/` is gitignored
+- Working directory for all file edits: `user/themes/intotheeast/`
+- All JS bundles use `--format=iife` so templates can reference `maplibregl`, `MapUtils`, `toGeoJSON` as window globals
+- Grav Asset Manager is used for all asset registration — no hardcoded `
+
+
+ ```
+
+- [ ] **Step 2: Add map_assets block immediately after `{% block content %}`**
+
+After the opening `{% block content %}` line, add:
+
+```twig
+{% block map_assets %}
+{% do assets.addCss('theme://css-compiled/map.css') %}
+{% do assets.addJs('theme://js/map.js', {group: 'bottom'}) %}
+{% endblock %}
+```
+
+- [ ] **Step 3: Remove the duplicated JS blocks**
+
+Remove the following `
+
+```
+
+Add the map assets block at the very top of the `{% if map_entries|length > 0 %}` block (before the `
`):
+
+```twig
+{% block map_assets %}
+{% do assets.addCss('theme://css-compiled/map.css') %}
+{% do assets.addJs('theme://js/map.js', {group: 'bottom'}) %}
+{% endblock %}
+```
+
+- [ ] **Step 2: map.html.twig — remove CDN tags, add map_assets block**
+
+In `user/themes/intotheeast/templates/map.html.twig`:
+
+Remove lines 39–42:
+```html
+
+
+
+
+```
+
+Add after `{% block content %}`:
+```twig
+{% block map_assets %}
+{% do assets.addCss('theme://css-compiled/map.css') %}
+{% do assets.addJs('theme://js/map.js', {group: 'bottom'}) %}
+{% endblock %}
+```
+
+- [ ] **Step 3: story.html.twig — remove Scrollama CDN tag**
+
+In `user/themes/intotheeast/templates/story.html.twig`:
+
+Remove line 72:
+```html
+
+```
+
+Scrollama is now bundled in `main.js` and exposed as `window.scrollama`. The existing inline script that calls `scrollama()` will work unchanged.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git -C user add themes/intotheeast/templates/partials/feed-map.html.twig themes/intotheeast/templates/map.html.twig themes/intotheeast/templates/story.html.twig
+git -C user commit -m "refactor: remove CDN tags from feed-map, map, story templates"
+```
+
+---
+
+### Task 8: End-to-end verification — no external requests, all features intact
+
+**Files:** None modified. Verification only.
+
+- [ ] **Step 1: Verify zero external requests on the trip page**
+
+Open `http://localhost:8081/trips/japan-korea-2026`. Open DevTools → Network tab → reload.
+
+Check these domains appear zero times:
+- `cdn.jsdelivr.net`
+- `fonts.googleapis.com`
+- `fonts.gstatic.com`
+
+- [ ] **Step 2: Verify trip page features**
+
+- Map renders with markers and GPX track
+- Marker click scrolls to entry card and flashes it
+- Fullscreen map toggle expands/collapses
+- Filter bar: All / Journal / Stories each filter correctly
+- Sort toggle (↑/↓) reverses feed order
+- Stats panel opens and closes (click "Stats ▾" button)
+- Cycling panel opens and closes if GPX present ("Cycling ▾" button)
+- GPX distance figure populates in stats grid
+- Cycling stats grid populates (distance, gain, loss, highest, lowest, moving time, avg speed)
+- Back-to-top button appears after scrolling down; click scrolls to top
+- Journal photo strip: swipe/scroll dots sync; ‹ › buttons navigate; expand button opens PhotoSwipe; arrow keys advance; click outside closes
+
+- [ ] **Step 3: Verify story page**
+
+Open a story URL (e.g. `http://localhost:8081/trips/italy-2026-demo/stories/sorano-rock-and-time`):
+- Hero image loads
+- Scroll overlay darkens/lightens on scroll
+- Story title fades into nav bar as hero scrolls out
+- Back-to-top button appears and works
+- If page has `.scrolly` sections: they animate on scroll
+- No console errors
+
+- [ ] **Step 4: Check built file sizes**
+
+```bash
+ls -lh user/themes/intotheeast/js/main.js user/themes/intotheeast/js/map.js user/themes/intotheeast/css-compiled/main.css user/themes/intotheeast/css-compiled/map.css
+```
+
+Rough expected sizes (minified):
+- `main.js`: ~80–150 KB (PhotoSwipe + Scrollama + UI code)
+- `map.js`: ~600–900 KB (MapLibre GL dominates)
+- `main.css`: ~30–60 KB (PhotoSwipe + @font-face rules)
+- `map.css`: ~80–120 KB (MapLibre GL styles)
+
+If `map.js` is unexpectedly small (<100 KB), MapLibre GL may not have bundled — check that `import maplibregl from 'maplibre-gl'` is in `js/src/map.js`.
+
+- [ ] **Step 5: Final commit**
+
+```bash
+git -C user add -A
+git -C user status # confirm only expected files
+git -C user commit -m "chore: verify asset pipeline — all CDN deps eliminated"
+git add -A
+git commit -m "chore: complete asset pipeline — self-hosted deps, deduplicated JS"
+```