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 %} + + + + + + + {% endif %} + ``` + +- [x] **Replace the map block in `home.html.twig`** + + Find the `{% if map_entries|length > 0 %}` block (around line 125) and replace from there to end of `{% endblock %}`: + + ```twig + {% if map_entries|length > 0 %} + + + + + {% endif %} + {% endblock %} + ``` + +- [x] **Verify mini-map at `http://localhost:8081/trips/japan-korea-2026/dailies`** + + - Mini-map appears above journal feed with dark vector tiles + - Journey line animates in + - Click a marker → navigates to that entry's page (not a popup) + - On mobile: pinch-zoom within the mini-map requires two fingers; one finger scrolls the page past it + - "View full map →" link works + +- [x] **Verify home map at `http://localhost:8081`** + + - Left column sticky map shows dark vector tiles + - Journey line animates in + - Click a marker → page scrolls to the matching entry card in the right column + - On mobile (< 768px): map collapses to 40vh above the feed, touch-scroll works on page + +- [x] **Commit** + + ```bash + git -C user add themes/intotheeast/templates/dailies.html.twig themes/intotheeast/templates/home.html.twig + git -C user commit -m "feat: migrate mini-map and home map to MapLibre GL" + ``` + +- [x] **Final sync** + + ```bash + make content-push + ``` diff --git a/docs/superpowers/plans/2026-06-19-story-mode.md b/docs/superpowers/plans/2026-06-19-story-mode.md new file mode 100644 index 0000000..289ea7d --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-story-mode.md @@ -0,0 +1,1366 @@ +# Story Mode 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:** Build a rich long-form story post type for the intotheeast travel blog, with a cinematic hero section, four shortcode storytelling blocks (ChapterBreak, ScrollySection, PullQuote, SnapGallery), and a listing page — all consistent with the existing Field Notes design system. + +**Architecture:** A new Grav plugin (`story-blocks`) registers four shortcodes via ShortcodeCore's `onShortcodeHandlers` event. Each shortcode returns self-contained HTML with embedded CSS. `story.html.twig` renders a full-viewport hero (Ken Burns + scroll-driven overlay) followed by the Markdown body (where shortcodes expand). `stories.html.twig` becomes a card grid. Story CSS is added to the existing `style.css`. + +**Tech Stack:** Grav ShortcodeCore (already installed), Scrollama 3.x (CDN, story pages only), IntersectionObserver (native browser API), vanilla JS, PHP shortcode classes following the `shortcode-gallery-plusplus` pattern. + +## Global Constraints + +- Plugin namespace: `Grav\Plugin\Shortcodes` — matches existing shortcode plugin pattern +- All shortcode PHP classes extend `Shortcode` and implement `init()` with `$this->shortcode->getHandlers()->add()` +- Image filenames in shortcode parameters are resolved to URLs via `$page->url() . '/' . $imageName` +- Current page tracked by listening to `onPageContentRaw` event — same pattern as `shortcode-gallery-plusplus` +- Scrollama CDN: `https://cdn.jsdelivr.net/npm/scrollama@3/build/scrollama.min.js` +- Design tokens used throughout CSS: `--color-canvas`, `--color-ink`, `--color-ink-2`, `--color-ink-muted`, `--color-accent`, `--color-border`, `--color-paper`, `--color-surface-raised`, `--font-display`, `--font-ui`, `--space-*`, `--radius-sm`, `--radius-md`, `--shadow-md`, `--site-header-height` +- All animations respect `prefers-reduced-motion: reduce` +- Story frontmatter fields: `title`, `date`, `end_date` (optional), `location_name`, `location_country`, `lat`, `lng`, `hero_image` (filename), `hero_alt`, `published` +- Story pages live at `user/pages/01.trips//04.stories/./story.md` with template `story` +- Run `make content-push` after all tasks to sync + +--- + +### Task 1: Plugin scaffold + ChapterBreak shortcode + +**Files:** +- Create: `user/plugins/story-blocks/story-blocks.php` +- Create: `user/plugins/story-blocks/story-blocks.yaml` +- Create: `user/plugins/story-blocks/shortcodes/ChapterBreakShortcode.php` + +**Interfaces:** +- Produces: `[chapter-break image="file.jpg" title="..." number="II" alt="..." /]` shortcode +- Produces: `StoryBlocksPlugin::getCurrentPage()` — used by all shortcodes in later tasks + +**What:** Bootstrap the plugin and implement the first shortcode. Once this task is done, `[chapter-break ...]` in any story's Markdown body will render a full-bleed atmospheric scene divider. + +- [x] **Create `user/plugins/story-blocks/story-blocks.yaml`** + + ```yaml + name: Story Blocks + version: 1.0.0 + description: Storytelling shortcode blocks for long-form travel stories + author: + name: Mischa + homepage: https://github.com/m-cluitmans + keywords: shortcode, story, storytelling + bugs: '' + license: MIT + dependencies: + - { name: grav, version: '>=2.0.0-rc.1' } + - { name: shortcode-core, version: '>=5.0.0' } + enabled: true + ``` + +- [x] **Create `user/plugins/story-blocks/story-blocks.php`** + + ```php + ['onShortcodeHandlers', 0], + 'onPageContentRaw' => ['onPageContentRaw', 1000], + ]; + } + + public function onPageContentRaw(Event $event): void + { + $this->currentPage = $event['page']; + } + + public function getCurrentPage() + { + return $this->currentPage; + } + + public function onShortcodeHandlers(): void + { + $this->grav['shortcode']->registerAllShortcodes(__DIR__ . '/shortcodes'); + } + } + ``` + +- [x] **Create `user/plugins/story-blocks/shortcodes/ChapterBreakShortcode.php`** + + ```php + shortcode->getHandlers()->add('chapter-break', function (ShortcodeInterface $sc) { + $plugin = $this->grav['plugins']->getPlugin('story-blocks'); + $page = $plugin ? $plugin->getCurrentPage() : null; + + $imageName = $sc->getParameter('image', ''); + $title = htmlspecialchars($sc->getParameter('title', ''), ENT_QUOTES); + $number = htmlspecialchars($sc->getParameter('number', ''), ENT_QUOTES); + $alt = htmlspecialchars($sc->getParameter('alt', $title), ENT_QUOTES); + $imageUrl = ($page && $imageName) ? $page->url() . '/' . $imageName : $imageName; + + $numberHtml = $number + ? '' + : ''; + + return << +
+ {$alt} + +
+
+ {$numberHtml} +

{$title}

+ +
+
+ HTML; + }); + } + } + ``` + +- [x] **Verify the plugin is recognised by Grav** + + Open `http://localhost:8081/admin2` → Plugins. `Story Blocks` should appear in the list and be enabled. + + If it doesn't appear, check the YAML filename matches the plugin folder name exactly (`story-blocks`), and confirm no PHP syntax errors: + ```bash + docker exec intotheeast_grav php -l /var/www/html/user/plugins/story-blocks/story-blocks.php + docker exec intotheeast_grav php -l /var/www/html/user/plugins/story-blocks/shortcodes/ChapterBreakShortcode.php + ``` + +- [x] **Create a test story page to verify ChapterBreak renders** + + Create `user/pages/01.trips/japan-korea-2026/04.stories/99.test-shortcodes/story.md`: + + ```yaml + --- + title: Shortcode Test + date: 2026-03-28 + hero_image: '' + published: false + --- + Intro paragraph before the chapter break. + + [chapter-break title="Into the Shrine" number="I" /] + + Text after the chapter break. + ``` + + Open `http://localhost:8081/trips/japan-korea-2026/stories/test-shortcodes`. The chapter break should render as an HTML div (even without CSS it should appear in source). Confirm no PHP errors. + +- [x] **Delete the test page** + + ```bash + rm -rf user/pages/01.trips/japan-korea-2026/04.stories/99.test-shortcodes + ``` + +- [x] **Commit** + + ```bash + git -C user add plugins/story-blocks/ + git -C user commit -m "feat: add story-blocks plugin with chapter-break shortcode" + ``` + +--- + +### Task 2: ScrollySection shortcode + +**Files:** +- Create: `user/plugins/story-blocks/shortcodes/ScrollySectionShortcode.php` + +**Interfaces:** +- Consumes: `StoryBlocksPlugin::getCurrentPage()` from Task 1 +- Produces: `[scrolly-section image="file.jpg" alt="..." caption="..."]Step one --- Step two[/scrolly-section]` shortcode +- The rendered HTML depends on Scrollama JS (loaded by `story.html.twig` in Task 4) to split `---` into steps at runtime + +**What:** The sticky-image scrollytelling block. Content between the tags becomes the step text; `---` on its own line separates steps. Scrollama (loaded on story pages) detects step entry/exit and drives image pan and overlay effects. + +- [x] **Create `user/plugins/story-blocks/shortcodes/ScrollySectionShortcode.php`** + + ```php + shortcode->getHandlers()->add('scrolly-section', function (ShortcodeInterface $sc) { + $plugin = $this->grav['plugins']->getPlugin('story-blocks'); + $page = $plugin ? $plugin->getCurrentPage() : null; + + $imageName = $sc->getParameter('image', ''); + $alt = htmlspecialchars($sc->getParameter('alt', ''), ENT_QUOTES); + $caption = htmlspecialchars($sc->getParameter('caption', ''), ENT_QUOTES); + $content = $sc->getContent(); /* raw inner content — Scrollama JS splits on
*/ + $imageUrl = ($page && $imageName) ? $page->url() . '/' . $imageName : $imageName; + + $captionHtml = $caption + ? '

' . $caption . '

' + : ''; + + return << + +
+
+ {$content} +
+
+
+ HTML; + }); + } + } + ``` + +- [x] **Register the new shortcode** + + No change needed in `story-blocks.php` — `registerAllShortcodes` picks up all PHP files in the `shortcodes/` folder automatically. + +- [x] **Verify PHP syntax** + + ```bash + docker exec intotheeast_grav php -l /var/www/html/user/plugins/story-blocks/shortcodes/ScrollySectionShortcode.php + ``` + +- [x] **Commit** + + ```bash + git -C user add plugins/story-blocks/shortcodes/ScrollySectionShortcode.php + git -C user commit -m "feat: add scrolly-section shortcode (Scrollama-driven sticky image steps)" + ``` + +--- + +### Task 3: PullQuote and SnapGallery shortcodes + +**Files:** +- Create: `user/plugins/story-blocks/shortcodes/PullQuoteShortcode.php` +- Create: `user/plugins/story-blocks/shortcodes/SnapGalleryShortcode.php` + +**Interfaces:** +- Consumes: `StoryBlocksPlugin::getCurrentPage()` from Task 1 +- Produces: `[pull-quote image="file.jpg" alt="..."]Quote text.[/pull-quote]` +- Produces: `[snap-gallery images="a.jpg,b.jpg,c.jpg" captions="Cap 1,Cap 2,Cap 3" alts="Alt 1,Alt 2,Alt 3" /]` + +- [x] **Create `user/plugins/story-blocks/shortcodes/PullQuoteShortcode.php`** + + ```php + shortcode->getHandlers()->add('pull-quote', function (ShortcodeInterface $sc) { + $plugin = $this->grav['plugins']->getPlugin('story-blocks'); + $page = $plugin ? $plugin->getCurrentPage() : null; + + $imageName = $sc->getParameter('image', ''); + $alt = htmlspecialchars($sc->getParameter('alt', ''), ENT_QUOTES); + $content = trim($sc->getContent()); + $imageUrl = ($page && $imageName) ? $page->url() . '/' . $imageName : ''; + + $bgHtml = ''; + if ($imageUrl) { + $bgHtml = << + {$alt} +
+ + HTML; + } + + $innerClass = $imageUrl ? 'pull-quote__inner' : 'pull-quote__inner pull-quote__inner--no-image'; + + return << + {$bgHtml} +
+ +

{$content}

+ +
+ + HTML; + }); + } + } + ``` + +- [x] **Create `user/plugins/story-blocks/shortcodes/SnapGalleryShortcode.php`** + + ```php + shortcode->getHandlers()->add('snap-gallery', function (ShortcodeInterface $sc) { + $plugin = $this->grav['plugins']->getPlugin('story-blocks'); + $page = $plugin ? $plugin->getCurrentPage() : null; + $baseUrl = $page ? $page->url() . '/' : ''; + + $images = array_map('trim', explode(',', $sc->getParameter('images', ''))); + $captions = array_map('trim', explode(',', $sc->getParameter('captions', ''))); + $alts = array_map('trim', explode(',', $sc->getParameter('alts', ''))); + + $slidesHtml = ''; + $dotsHtml = ''; + + foreach ($images as $i => $filename) { + if (!$filename) continue; + $url = $baseUrl . $filename; + $caption = htmlspecialchars($captions[$i] ?? '', ENT_QUOTES); + $alt = htmlspecialchars($alts[$i] ?? '', ENT_QUOTES); + $eager = $i === 0 ? 'eager' : 'lazy'; + $active = $i === 0 ? ' is-active' : ''; + + $captionTag = $caption + ? '
' . $caption . '
' + : ''; + + $slidesHtml .= << + + {$alt} + {$captionTag} + + HTML; + $dotsHtml .= ''; + } + + return << +
+ {$slidesHtml} + +
+ + HTML; + }); + } + } + ``` + +- [x] **Verify PHP syntax for both files** + + ```bash + docker exec intotheeast_grav php -l /var/www/html/user/plugins/story-blocks/shortcodes/PullQuoteShortcode.php + docker exec intotheeast_grav php -l /var/www/html/user/plugins/story-blocks/shortcodes/SnapGalleryShortcode.php + ``` + +- [x] **Commit** + + ```bash + git -C user add plugins/story-blocks/shortcodes/PullQuoteShortcode.php plugins/story-blocks/shortcodes/SnapGalleryShortcode.php + git -C user commit -m "feat: add pull-quote and snap-gallery shortcodes" + ``` + +--- + +### Task 4: `story.html.twig` — hero section and body + +**Files:** +- Create: `user/themes/intotheeast/templates/story.html.twig` + +**Interfaces:** +- Consumes: page frontmatter fields `hero_image`, `hero_alt`, `title`, `date`, `end_date`, `location_name` +- Consumes: `{{ page.content|raw }}` — Markdown + expanded shortcodes from Tasks 1–3 +- Produces: full-page story template at any URL matching template `story` + +**What:** Full-viewport hero (sticky image + Ken Burns + scroll-driven overlay) followed by the story body in a prose column. The escape link floats fixed top-left. Scrollama is loaded here (story pages only) to power ScrollySection blocks. + +- [x] **Create `user/themes/intotheeast/templates/story.html.twig`** + + ```twig + {% extends 'partials/base.html.twig' %} + + {% block nav %} + ← Stories + {% endblock %} + + {% block content %} + {% set hero = null %} + {% if page.header.hero_image and page.media[page.header.hero_image] is defined %} + {% set hero = page.media[page.header.hero_image] %} + {% endif %} + + {% set date_str = page.date|date('d M Y') %} + {% if page.header.end_date %} + {% set end_str = page.header.end_date|date('d M Y') %} + {% set date_str = page.date|date('d M') ~ '–' ~ end_str %} + {% endif %} + + {% set location = page.header.location_name ?? '' %} + +
+
+ {% if hero %} + {{ page.header.hero_alt ?? page.title }} + {% else %} +
+ {% endif %} +
+ +
+

{{ page.title }}

+

+ + {% if location %} · {{ location }}{% endif %} +

+
+ +
+
+ +
+ {{ page.content|raw }} + + +
+ + + + {% 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 %} +
+ {{ story.title }} +
+ {% else %} +
+ {% endif %} +
+ + {% if story.header.location_name %} + 📍 {{ story.header.location_name }}{% if story.header.location_country %}, {{ story.header.location_country }}{% endif %} + {% endif %} +

{{ story.title }}

+ Read story → +
+
+ {% endfor %} +
+ {% else %} +

No stories yet — check back soon.

+ {% endif %} +
+ {% endblock %} + ``` + +- [x] **Add story CSS to `style.css`** + + At the end of `style.css`, add the following block: + + ```css + /* ── Story pages ─────────────────────────────────────────────────────────── */ + + /* Override site-main constraints for story pages */ + .template-story .site-main { max-width: none; padding: 0; } + + /* Floating escape link */ + .story-escape { + position: fixed; + top: calc(var(--site-header-height) + var(--space-4)); + left: var(--space-4); + z-index: 100; + font-family: var(--font-ui); + font-size: var(--text-sm); + font-weight: 500; + color: rgba(255,255,255,0.85); + background: rgba(26,24,20,0.55); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + border: 1px solid rgba(255,255,255,0.12); + border-radius: var(--radius-full); + padding: var(--space-2) var(--space-4); + text-decoration: none; + transition: background 0.2s; + } + .story-escape:hover { background: rgba(26,24,20,0.8); } + + /* Hero */ + .story-hero { position: relative; } + .story-hero__img-wrap { + position: sticky; + top: 0; + height: 100vh; + width: 100%; + overflow: hidden; + } + .story-hero__img { + width: 100%; + height: 100%; + object-fit: cover; + animation: storyKenBurns 12s ease-out forwards; + transform-origin: center center; + } + @keyframes storyKenBurns { + from { transform: scale(1.06); } + to { transform: scale(1); } + } + @media (prefers-reduced-motion: reduce) { + .story-hero__img { animation: none; } + } + .story-hero__img-placeholder { + width: 100%; + height: 100%; + background: linear-gradient(135deg, #1A1814 0%, #2A2720 100%); + } + .story-hero__overlay { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 1; + } + .story-hero__content { + position: absolute; + bottom: 18%; + left: 0; + width: 100%; + padding: 0 var(--space-8); + text-align: center; + color: #fff; + z-index: 2; + } + .story-hero__title { + font-family: var(--font-display); + font-size: clamp(2.2rem, 6vw, 4.5rem); + font-weight: 900; + line-height: 1.08; + letter-spacing: -0.02em; + margin-bottom: var(--space-3); + text-shadow: 0 2px 20px rgba(0,0,0,0.5); + animation: storyReveal 0.9s cubic-bezier(.16,1,.3,1) 0.2s both; + } + .story-hero__meta { + font-family: var(--font-ui); + font-size: var(--text-base); + opacity: 0.85; + letter-spacing: 0.04em; + text-shadow: 0 1px 6px rgba(0,0,0,0.4); + animation: storyReveal 0.9s cubic-bezier(.16,1,.3,1) 0.55s both; + margin: 0; + } + @keyframes storyReveal { + from { filter: blur(10px); opacity: 0; transform: translateY(22px); } + to { filter: blur(0); opacity: 1; transform: translateY(0); } + } + @media (prefers-reduced-motion: reduce) { + .story-hero__title, .story-hero__meta { animation: none; opacity: 1; filter: none; transform: none; } + } + .story-hero__scroll-cue { + position: absolute; + bottom: var(--space-8); + left: 50%; + transform: translateX(-50%); + color: rgba(255,255,255,0.7); + z-index: 2; + animation: storyCueBounce 2s ease-in-out infinite; + transition: opacity 0.4s; + } + .story-hero__scroll-cue.is-hidden { opacity: 0; pointer-events: none; } + @keyframes storyCueBounce { + 0%, 100% { transform: translateX(-50%) translateY(0); } + 50% { transform: translateX(-50%) translateY(6px); } + } + @media (prefers-reduced-motion: reduce) { + .story-hero__scroll-cue { animation: none; } + } + .story-hero__spacer { height: 40vh; position: relative; z-index: 3; } + + /* Story body */ + .story-body { + max-width: 680px; + margin: 0 auto; + padding: var(--space-16) var(--space-6) var(--space-16); + } + .story-body p { + font-family: var(--font-ui); + font-size: 1.0625rem; + line-height: 1.85; + color: var(--color-ink-2); + margin-bottom: var(--space-6); + } + .story-body h2, .story-body h3 { + font-family: var(--font-display); + color: var(--color-ink); + margin-top: var(--space-10); + margin-bottom: var(--space-4); + } + .story-footer { + margin-top: var(--space-16); + padding-top: var(--space-8); + border-top: 1px solid var(--color-border); + } + .story-footer a { + font-family: var(--font-ui); + font-size: var(--text-sm); + color: var(--color-accent); + text-decoration: none; + } + + /* ── ChapterBreak ─────────────────────────────────────────── */ + .chapter-break { + position: relative; + width: 100vw; + left: 50%; + margin-left: -50vw; + height: 60vh; + min-height: 320px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + margin-top: var(--space-16); + margin-bottom: var(--space-16); + } + .chapter-break__bg { position: absolute; inset: 0; } + .chapter-break__img { width: 100%; height: 100%; object-fit: cover; display: block; } + .chapter-break__tint { + position: absolute; + inset: 0; + background: linear-gradient(to bottom, rgba(0,0,0,0.4) 0%, rgba(0,0,0,0.6) 100%); + } + .chapter-break__panel { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-2); + padding: var(--space-10) var(--space-12); + background: rgba(26,24,20,0.25); + backdrop-filter: blur(18px) saturate(1.4); + -webkit-backdrop-filter: blur(18px) saturate(1.4); + border: 1px solid rgba(255,255,255,0.12); + border-radius: var(--radius-sm); + max-width: 520px; + width: 90%; + text-align: center; + opacity: 0; + filter: blur(12px); + transform: translateY(28px); + transition: opacity 0.9s cubic-bezier(.16,1,.3,1), filter 0.9s cubic-bezier(.16,1,.3,1), transform 0.9s cubic-bezier(.16,1,.3,1); + } + .chapter-break__panel.is-revealed { opacity: 1; filter: blur(0); transform: translateY(0); } + @media (prefers-reduced-motion: reduce) { + .chapter-break__panel { opacity: 1 !important; filter: none !important; transform: none !important; transition: none !important; } + } + .chapter-break__number { + font-family: var(--font-ui); + font-size: var(--text-xs); + font-weight: 600; + letter-spacing: 0.22em; + text-transform: uppercase; + color: rgba(255,255,255,0.6); + } + .chapter-break__title { + font-family: var(--font-display); + font-size: clamp(1.6rem, 4vw, 2.6rem); + font-weight: 700; + line-height: 1.15; + color: #fff; + text-shadow: 0 2px 16px rgba(0,0,0,0.4); + margin: 0; + } + .chapter-break__rule { + width: 40px; + height: 2px; + background: var(--color-accent); + border-radius: 1px; + margin-top: var(--space-2); + } + + /* ── ScrollySection ───────────────────────────────────────── */ + .scrolly { + position: relative; + display: grid; + grid-template-columns: 55% 45%; + width: 100vw; + left: 50%; + margin-left: -50vw; + margin-top: var(--space-16); + margin-bottom: var(--space-16); + align-items: start; + } + .scrolly__media { + position: sticky; + top: var(--site-header-height); + height: calc(100vh - var(--site-header-height)); + overflow: hidden; + } + .scrolly__media-inner { position: relative; width: 100%; height: 100%; } + .scrolly__img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: 50% 50%; + display: block; + will-change: filter, object-position; + transition: filter 0.6s cubic-bezier(.16,1,.3,1), object-position 1.2s cubic-bezier(.16,1,.3,1); + } + .scrolly .scrolly__img { margin: 0; border-radius: 0; max-width: none; } + .scrolly__img-overlay { + position: absolute; + inset: 0; + background: rgba(0,0,0,0); + transition: background 0.6s ease; + pointer-events: none; + } + .scrolly__caption { + position: absolute; + bottom: var(--space-4); + left: var(--space-4); + right: var(--space-4); + font-family: var(--font-ui); + font-size: var(--text-xs); + color: rgba(255,255,255,0.65); + text-align: center; + pointer-events: none; + margin: 0; + } + .scrolly__steps-content { display: none; } + :global(.scrolly-step) { + min-height: 60vh; + display: flex; + align-items: center; + padding: var(--space-16) var(--space-8); + } + .scrolly-step { min-height: 60vh; display: flex; align-items: center; padding: var(--space-16) var(--space-8); } + .scrolly-step__inner { + background: rgba(26,24,20,0.92); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + padding: var(--space-8) var(--space-8); + opacity: 0; + transform: translateY(20px); + transition: opacity 0.55s cubic-bezier(.16,1,.3,1), transform 0.55s cubic-bezier(.16,1,.3,1); + } + .scrolly-step.is-active .scrolly-step__inner { opacity: 1; transform: translateY(0); } + @media (prefers-reduced-motion: reduce) { + .scrolly-step__inner { opacity: 1 !important; transform: none !important; transition: none !important; } + } + .scrolly-step__inner p { + font-family: var(--font-ui); + font-size: 1.05rem; + line-height: 1.8; + color: var(--color-ink-2); + margin-bottom: var(--space-3); + } + .scrolly-step__inner p:last-child { margin-bottom: 0; } + .scrolly-step:last-child { padding-bottom: 50vh; } + @media (max-width: 768px), (pointer: coarse) { + .scrolly { display: block; } + .scrolly__steps { margin-top: calc(-(100vh - var(--site-header-height))); position: relative; z-index: 1; } + .scrolly-step { min-height: 80vh; padding: var(--space-8) var(--space-6); align-items: center; justify-content: center; } + .scrolly-step:last-child { padding-bottom: 50vh; } + } + + /* ── PullQuote ────────────────────────────────────────────── */ + .pull-quote { + position: relative; + width: calc(100% + 3rem); + margin-left: -1.5rem; + margin-right: -1.5rem; + margin-top: var(--space-12); + margin-bottom: var(--space-12); + overflow: hidden; + border-radius: var(--radius-sm); + border: none; + background: transparent; + padding: 0; + opacity: 0; + filter: blur(6px); + transform: translateY(24px); + transition: opacity 0.85s cubic-bezier(.16,1,.3,1), filter 0.85s cubic-bezier(.16,1,.3,1), transform 0.85s cubic-bezier(.16,1,.3,1); + } + .pull-quote.is-revealed { opacity: 1; filter: blur(0); transform: translateY(0); } + @media (prefers-reduced-motion: reduce) { + .pull-quote { opacity: 1 !important; filter: none !important; transform: none !important; transition: none !important; } + } + .pull-quote__bg { position: absolute; inset: 0; z-index: 0; } + .pull-quote__bg-img { width: 100%; height: 100%; object-fit: cover; display: block; } + .pull-quote__bg-tint { position: absolute; inset: 0; background: rgba(0,0,0,0.55); } + .pull-quote__inner { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + padding: var(--space-10) var(--space-8); + backdrop-filter: blur(14px) saturate(1.3); + -webkit-backdrop-filter: blur(14px) saturate(1.3); + background: rgba(26,24,20,0.08); + text-align: center; + } + .pull-quote__inner--no-image { + background: var(--color-canvas); + backdrop-filter: none; + -webkit-backdrop-filter: none; + } + .pull-quote__mark { + font-family: var(--font-display); + font-size: 5rem; + line-height: 0.8; + color: var(--color-accent); + opacity: 0.4; + display: block; + } + .pull-quote__mark--close { margin-top: var(--space-2); } + .pull-quote__text { + font-family: var(--font-display); + font-size: clamp(1.2rem, 3vw, 1.6rem); + font-style: italic; + color: #fff; + line-height: 1.5; + margin: var(--space-4) 0; + } + .pull-quote__inner--no-image .pull-quote__text { color: var(--color-ink); } + + /* ── SnapGallery ──────────────────────────────────────────── */ + .pgallery { + width: 100vw; + left: 50%; + margin-left: -50vw; + position: relative; + margin-top: var(--space-16); + margin-bottom: var(--space-16); + } + .pgallery__frame { + position: relative; + height: 100vh; + overflow-y: scroll; + scroll-snap-type: y mandatory; + -webkit-overflow-scrolling: touch; + } + .pgallery__slide { + position: relative; + height: 100vh; + display: flex; + align-items: center; + justify-content: center; + scroll-snap-align: start; + scroll-snap-stop: always; + } + .pgallery__bg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + filter: blur(20px) brightness(0.4); + transform: scale(1.05); + } + .pgallery__fg { + position: relative; + z-index: 1; + max-height: 90vh; + max-width: 90vw; + object-fit: contain; + } + .pgallery__caption { + position: absolute; + bottom: var(--space-8); + left: 0; + right: 0; + z-index: 2; + text-align: center; + font-family: var(--font-ui); + font-size: var(--text-sm); + color: rgba(255,255,255,0.8); + margin: 0; + padding: 0 var(--space-8); + } + .pgallery__dots { + position: absolute; + right: var(--space-4); + top: 50%; + transform: translateY(-50%); + z-index: 3; + display: flex; + flex-direction: column; + gap: var(--space-2); + } + .pgallery__dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: rgba(255,255,255,0.35); + transition: background 0.25s; + display: block; + } + .pgallery__dot.is-active { background: var(--color-accent); } + + /* ── Stories listing ──────────────────────────────────────── */ + .stories-listing { padding: var(--space-10) 0; } + .stories-listing__heading { + font-family: var(--font-display); + font-size: var(--text-2xl); + font-weight: 400; + color: var(--color-ink); + margin-bottom: var(--space-10); + } + .stories-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--space-8); + } + @media (max-width: 640px) { .stories-grid { grid-template-columns: 1fr; } } + .story-card { + text-decoration: none; + color: inherit; + display: flex; + flex-direction: column; + border-radius: var(--radius-md); + overflow: hidden; + background: var(--color-canvas); + border: 1px solid var(--color-border); + transition: box-shadow 0.2s; + } + .story-card:hover { box-shadow: var(--shadow-md); } + .story-card__photo { aspect-ratio: 16/9; overflow: hidden; } + .story-card__photo img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform 0.4s ease; } + .story-card:hover .story-card__photo img { transform: scale(1.03); } + .story-card__photo--empty { background: var(--color-surface-raised); } + .story-card__body { padding: var(--space-5); } + .story-card__date { + display: block; + font-family: var(--font-ui); + font-size: var(--text-xs); + color: var(--color-ink-muted); + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: var(--space-1); + } + .story-card__location { + display: block; + font-family: var(--font-ui); + font-size: var(--text-xs); + color: var(--color-ink-muted); + margin-bottom: var(--space-3); + } + .story-card__title { + font-family: var(--font-display); + font-size: var(--text-lg); + font-weight: 400; + color: var(--color-ink); + margin-bottom: var(--space-3); + line-height: var(--leading-snug); + } + .story-card__cta { + font-family: var(--font-ui); + font-size: var(--text-sm); + color: var(--color-accent); + } + .stories-empty { + font-family: var(--font-ui); + color: var(--color-ink-muted); + font-style: italic; + } + ``` + +- [x] **Verify stories listing at `http://localhost:8081/trips/japan-korea-2026/stories`** + + Without any story pages: "No stories yet — check back soon." should appear. No 500 error. + +- [x] **Verify story template class is applied** + + View source of any story page. The `` tag should include `template-story` in its class list (Grav adds this automatically). The `.template-story .site-main` override removes max-width/padding, allowing full-bleed blocks. + +- [x] **Commit** + + ```bash + git -C user add themes/intotheeast/templates/stories.html.twig themes/intotheeast/css/style.css + git -C user commit -m "feat: add stories listing page and all story/shortcode CSS" + ``` + +--- + +### Task 6: Demo story content + +**Files:** +- Create: `user/docs/demo/trips/japan-korea-2026/04.stories/01.the-thousand-gates/story.md` +- Modify: `Makefile` (add story folder to `demo-load` and `demo-reset` targets) + +**What:** A sample story that exercises all four shortcode blocks. No image files are committed — the tester drops a few JPEGs into the folder manually to verify image resolution. The story covers 28–29 March (Kyoto days from the existing journal demo). + +- [x] **Create `user/docs/demo/trips/japan-korea-2026/04.stories/01.the-thousand-gates/story.md`** + + ```yaml + --- + title: The Thousand Gates + date: 2026-03-28 + end_date: 2026-03-29 + location_name: Kyoto + location_country: Japan + lat: 34.967 + lng: 135.773 + hero_image: hero.jpg + hero_alt: A vermillion torii gate at dawn, half-lit by morning sun + published: true + --- + We left the ryokan before sunrise. Kyoto in March has a particular quality of light — not yet warm, but already golden at the edges. The streets were empty except for a few cyclists and one very confused vending machine that kept flashing its lights at nothing. + + [chapter-break image="shrine-gate.jpg" title="Fushimi Inari" number="I" alt="Rows of vermillion torii gates stretching into darkness" /] + + The path up through Fushimi Inari begins the moment you pass the main shrine. There is no dramatic threshold — just a gate, then another gate, then several thousand more. Each was donated by a business or family. You can read their names on the back of each post, small kanji pressed into the lacquered red. + + [scrolly-section image="torii-path.jpg" alt="Tunnel of torii gates seen from below" caption="Senbon Torii — the Thousand Gates"] + The first gate smells of fresh lacquer. Someone has recently repainted it, and the colour is almost aggressive in its redness. + + --- + + By the tenth gate the smell is gone and the city has disappeared. Pine trees close in on both sides. The only sounds are other footsteps and the occasional crow. + + --- + + By the hundredth gate you stop counting. The path becomes the thing itself — not a means to a destination but a place to be. + + --- + + Near the summit there is a small shrine with fox statues wearing tiny red bibs. An old woman is arranging fresh flowers in front of them, moving with the unhurried certainty of someone who has done this ten thousand times. + [/scrolly-section] + + [pull-quote image="summit.jpg" alt="View over Kyoto from the hilltop"] + The gates never seemed to end — and somewhere around gate five hundred, I stopped wanting them to. + [/pull-quote] + + By the time we descended, the city had woken up. Taxis, schoolchildren, a delivery truck arguing with a narrow alley. We found a coffee shop down a side street that did not appear to expect visitors and sat there for an hour watching nothing in particular happen. + + [snap-gallery images="gallery-1.jpg,gallery-2.jpg,gallery-3.jpg,gallery-4.jpg" captions="Morning light on the main shrine,Fox statues at the upper shrine,Looking back through the gates,Coffee stop on the descent" alts="Sunlit shrine building,Stone fox statues with red bibs,Torii gates receding into distance,Small coffee shop interior" /] + + That evening we had ramen in a place with eight seats and a chef who appeared to be operating entirely by memory. There was no menu. You sat down and food appeared. It was the best meal of the trip. + ``` + +- [x] **Find the `demo-load` target in the Makefile and add the story folder** + + Find the `demo-load` target. It copies journal entries from `user/docs/demo/trips/japan-korea-2026/` into `user/pages/`. Add the stories folder copy alongside the existing entries copy: + + ```makefile + # After the existing journal entry copy line, add: + cp -r user/docs/demo/trips/japan-korea-2026/04.stories user/pages/01.trips/japan-korea-2026/ 2>/dev/null || true + ``` + +- [x] **Find the `demo-reset` target and add cleanup for the demo story** + + ```makefile + # After the existing journal entry removal, add: + rm -rf user/pages/01.trips/japan-korea-2026/04.stories/01.the-thousand-gates + ``` + +- [x] **Run `make demo-load` and verify the story appears** + + ```bash + make demo-load + ``` + + Then open `http://localhost:8081/trips/japan-korea-2026/stories`. The "The Thousand Gates" card should appear (no hero photo — that's expected; drop a JPEG named `hero.jpg` in the folder to test). + + Open the story page. Verify: + - Full-viewport area renders (even without hero image — the placeholder gradient shows) + - Prose text renders below the hero + - `[chapter-break ...]` renders an HTML structure (even without image it shows the title panel) + - `[scrolly-section ...]` renders with steps visible (Scrollama JS splits on `---`) + - `[pull-quote ...]` renders the frosted quote block + - `[snap-gallery ...]` renders the gallery frame (slides visible even without images) + - No console errors + +- [x] **Drop a test JPEG into the story folder and verify image resolution** + + ```bash + cp /path/to/any.jpg user/pages/01.trips/japan-korea-2026/04.stories/01.the-thousand-gates/hero.jpg + ``` + + Reload the story page. The hero image should fill the viewport with Ken Burns animation. The escape link (← Stories) should float top-left over the image. + +- [x] **Verify scrollytelling on desktop** + + Scroll through the story: + - Chapter break panel blurs in when it enters the viewport + - ScrollySection: image stays sticky, text panels scroll past, each step fades in when it enters + - Pull quote blurs in on scroll + - Snap gallery: scroll within the gallery frame advances one photo at a time; dots update + +- [x] **Verify on mobile (375px viewport)** + + - ScrollySection collapses to full-screen sticky image with text panels overlaid + - SnapGallery works with swipe (one swipe = one photo) + +- [x] **Run `make demo-reset` and verify the story is removed** + + ```bash + make demo-reset + ``` + + Check `user/pages/01.trips/japan-korea-2026/04.stories/` — `01.the-thousand-gates` should be gone. + +- [x] **Commit** + + ```bash + git -C user add docs/demo/trips/japan-korea-2026/04.stories/ + git -C user commit -m "docs: add demo story content (The Thousand Gates, all four shortcode blocks)" + git -C user add Makefile + git -C user commit -m "build: add story folder to demo-load and demo-reset targets" + ``` + +- [x] **Final sync** + + ```bash + make content-push + ```