# Design Spec: Story Mode + MapLibre Migration *Date: 2026-06-19* *Inspired by: [Sabdia](https://github.com/m-cluitmans/Sabdia) — a friend's sabbatical blog built on Astro + Keystatic + MapLibre* --- ## Scope Two parallel features: 1. **Story Mode** — a rich long-form post type alongside journal dailies, with cinematic storytelling blocks (hero, chapter breaks, scrollytelling, pull quotes, snap gallery) 2. **MapLibre GL migration** — replace Leaflet across all three maps (full map, mini-map, home map) with MapLibre GL JS; add animated journey line; improve CSS integration --- ## Decisions Log ### Why MapLibre GL instead of Leaflet Leaflet renders raster PNG tiles. MapLibre GL renders vector tiles in WebGL. Key gains: - **Animated journey line** — MapLibre's GeoJSON source model makes RAF-loop animation trivial (`source.setData()` per frame). On Leaflet you'd call `polyline.setLatLngs()` which also works, but MapLibre gives us everything below for free too. - **Smooth zoom** — continuous sub-pixel zoom vs Leaflet's tile-snap zoom levels - **Retina crisp** — vector geometry scales perfectly on HiDPI screens - **Future-proof** — 3D terrain, tilt/pitch, per-feature click events, style control, outdoor/topo/satellite styles for GPX track maps all become straightforward - **GPX styling** — switching from `leaflet-gpx` to `@mapbox/togeojson` + GeoJSON layer gives per-point colour control (speed, elevation gradients) later Cost: ~280KB (vs ~40KB Leaflet). Acceptable — cached after first visit. Tile source stays the same: CARTO dark vector style — free, no API key. ### Why shortcodes for story blocks (not modular pages or blueprint lists) Evaluated three approaches for in-prose storytelling blocks: | Approach | What it is | Verdict | |---|---|---| | **Shortcodes** | `[chapter-break ...]` inline in Markdown | ✅ Chosen | | Modular pages | Each block = a child page in Admin | ✗ Ruled out | | Blueprint list + elements | `sections:` YAML list with type selector | ✗ Ruled out | **Modular pages** are how most Grav storytelling themes work (Quark, Oxygen, all HTML5UP ports). Each block gets proper Admin form fields. But a 1,500-word story with two chapter breaks requires five child pages — navigating between them on mobile while traveling is painful. Prose ends up fragmented across "text" module pages. **Blueprint list with elements field** (Grav's conditional field groups) could render blocks as a structured "Add section" list in Admin. But prose still has to go in a "text" type section, so a story becomes a long list of `text/chapter-break/text/scrolly/ text/gallery` entries rather than a flowing document. **Shortcodes** keep everything in one Markdown editor — prose flows naturally, blocks are inserted inline. The `shortcode-gallery-plusplus` plugin already in our stack brings `shortcode-core` as a dependency, so no new plugin is needed. Grav Admin2 has no rich block-editor like Keystatic/Markdoc. Shortcodes are the closest practical equivalent for mixed prose+blocks authoring on mobile. *Future option:* If Admin2 ever gains inline block components (or we add a Flex Object definition), the shortcode content can be migrated — the block semantics are identical. ### Why gallery stays as lightbox on journal entries Journal entries are short daily posts — a grid of 3–8 photos suits them. The snap gallery is a deliberate slow storytelling device (one photo fills the screen, reader swipes through). That pacing fits stories, not a daily feed card. ### Weather not added to story frontmatter Weather is a journal-entry concept (captured at the moment of a daily post via Open-Meteo). Stories are retrospective long-form narratives — weather would be referenced in prose if relevant, not as a metadata badge. --- ## Part 1 — Story Mode ### 1.1 Page structure Stories live as child pages under `04.stories/`: ``` user/pages/01.trips//04.stories/ stories.md ← listing page, template: stories 01./ story.md ← individual story, template: story hero.jpg photo-a.jpg photo-b.jpg ``` `stories.md` frontmatter: ```yaml title: Stories template: stories published: true ``` ### 1.2 Story frontmatter schema ```yaml title: Into the Hills of Kyoto date: 2026-03-28 # start date — shown in hero header end_date: 2026-03-29 # optional; shown as "28–29 Mar 2026" location_name: Kyoto # city/region; shown in hero header location_country: Japan # used for stats de-duplication lat: 34.967 # main GPS coordinate — shows pin on /map lng: 135.773 hero_image: hero.jpg # filename in page media; required for hero section hero_alt: The vermillion gate at Fushimi Inari at dawn published: true ``` Fields deliberately excluded: `weather_*` (not meaningful for stories). ### 1.3 Shortcode blocks Four blocks implemented as ShortcodeCore shortcodes. All image paths are **filenames only** (e.g. `shrine.jpg`) — resolved against the story's own page media folder, same convention as `hero_image`. #### ChapterBreak Full-bleed atmospheric photo with a frosted-glass title panel. Reveals on scroll via IntersectionObserver (blur + translateY → clear). ``` [chapter-break image="shrine-gate.jpg" title="The Long Walk Up" number="II" /] ``` | Attribute | Required | Description | |---|---|---| | `image` | yes | Page media filename | | `title` | yes | Chapter title, displayed in frosted panel | | `number` | no | Roman numeral or label shown above title | | `alt` | no | Alt text (defaults to `title`) | Renders as `60vh` full-bleed block with dark gradient tint over the image and a `backdrop-filter: blur(18px)` panel containing the chapter number + title + teal rule. #### ScrollySection NYT-style sticky image (55% left column) with text panels that scroll past on the right. Steps are separated by `---` inside the shortcode body. Powered by **Scrollama** (CDN). ``` [scrolly-section image="torii-path.jpg" alt="Thousands of torii gates"] The path stretched further than I could see. --- Each gate was donated by a business or family, a prayer made physical. --- By the tenth minute of walking, the city had disappeared entirely. [/scrolly-section] ``` | Attribute | Required | Description | |---|---|---| | `image` | yes | Page media filename — sticky background | | `alt` | no | Image alt text | | `caption` | no | Small caption shown bottom-left of image | On mobile: full-screen sticky image with text panels scrolling over it (same layout, single column — image behind, text on top with semi-transparent card). Image starts blurred (`blur(8px) scale(1.04)`), unblurs when section enters viewport. Between steps: subtle pan (object-position cycles through 5 offsets) + slight overlay darkening for depth. #### PullQuote Frosted-glass quote block with optional atmospheric background image. Reveals on scroll. ``` [pull-quote image="lanterns.jpg"] The torii gates never seemed to end — and I didn't want them to. [/pull-quote] ``` | Attribute | Required | Description | |---|---|---| | `image` | no | Page media filename — background photo | | `alt` | no | Alt text for background image | Without `image`: renders on `--color-canvas` (warm dark surface, solid). With `image`: full-bleed image behind frosted glass panel. Large decorative `"` marks above and below the quote text (DM Serif Display, 5rem). #### SnapGallery Full-screen snap-scroll photo sequence. One photo per swipe. Snap physics are pure CSS (`scroll-snap-type: y mandatory` + `scroll-snap-stop: always` on the scroll container). Dot indicator active state updated via a small IntersectionObserver on each slide. ``` [snap-gallery images="photo-a.jpg,photo-b.jpg,photo-c.jpg" captions="The approach,Summit view,Descent" alts="Hikers on trail,Mountain panorama,Forest path" /] ``` | Attribute | Required | Description | |---|---|---| | `images` | yes | Comma-separated page media filenames | | `captions` | no | Comma-separated captions (positional) | | `alts` | no | Comma-separated alt texts (positional) | Each slide: blurred cover-crop background + contain-fit foreground image + caption fades in at bottom. Dot indicator on the right edge. Page-level `scroll-snap-align: start` with `proximity` (not mandatory) so normal page scroll is unaffected. ### 1.4 Template: `story.html.twig` Extends `partials/base.html.twig` but overrides the nav block to show only a floating escape link. Full layout: ``` ┌────────────────────────────────────────┐ │ ← Back (position: fixed, top-left) │ │ │ │ HERO — 100vh │ │ sticky image, Ken Burns zoom-out │ │ title blurs up from bottom │ │ date · location beneath title │ │ ↓ bounce scroll indicator │ │ 40vh spacer (scroll trigger zone) │ │ │ ├────────────────────────────────────────┤ │ STORY BODY │ │ max-width: 680px, centred │ │ font: DM Serif Display (headings) │ │ DM Sans (prose) │ │ {{ page.content|raw }} │ │ (Markdown + shortcode blocks) │ │ │ │ ← Back to stories (footer) │ └────────────────────────────────────────┘ ``` **Hero scroll behaviour (vanilla JS, no library):** - `window.scroll` listener (passive, rAF-throttled) - `progress = scrollY / innerHeight` (0→1 as hero scrolls away) - At progress > 0: dark overlay fades in (`rgba(0,0,0, progress * 0.6)`) - Scroll indicator hides after `scrollY > 80px` - At progress ≥ 1: overlay removed from DOM **Ken Burns animation:** CSS `@keyframes` — `scale(1.06) → scale(1)` over 12s, `ease-out`, `forwards`. Respects `prefers-reduced-motion: reduce`. **Text reveal:** Title and date animate in with `filter: blur(10px) + translateY(22px) → clear` at 0.2s / 0.55s delay. Respects `prefers-reduced-motion`. ### 1.5 Template: `stories.html.twig` Listing of published stories for the active trip. Grid of story cards: ``` ┌──────────────┐ ┌──────────────┐ │ hero thumb │ │ hero thumb │ │ │ │ │ │ Kyoto Hills │ │ Seoul Rain │ │ 28–29 Mar │ │ 1 Apr │ │ Kyoto │ │ Seoul │ └──────────────┘ └──────────────┘ ``` 2-column grid on desktop, single column on mobile. Each card links to the story. Empty state: "No stories yet — check back soon." Stories are also listed as cards in `dailies.html.twig`'s combined feed (already implemented — the template merges journal entries and stories by date). ### 1.6 JS dependencies | Library | How loaded | Size | Purpose | |---|---|---|---| | **Scrollama** | CDN (`jsdelivr`) | ~4KB | ScrollySection step detection | | IntersectionObserver | Native browser API | — | ChapterBreak + PullQuote reveal, SnapGallery dots | Scrollama is only loaded on story pages (inline ` ``` Tile style URL (same CARTO dark, now as vector style): ``` https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json ``` ### 2.2 Animated journey line Port of Sabdia's `animateJourneyLine` to vanilla JS against MapLibre's GeoJSON source API: ```js map.on('load', () => { // Add an empty source map.addSource('journey', { type: 'geojson', data: { type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: [] } } }); // Glow layer (wide, low opacity) map.addLayer({ id: 'journey-glow', type: 'line', source: 'journey', paint: { 'line-color': '#2A8C73', 'line-width': 6, 'line-opacity': 0.18 } }); // Main line map.addLayer({ id: 'journey-line', type: 'line', source: 'journey', paint: { 'line-color': '#2A8C73', 'line-width': 2.5, 'line-opacity': 0.85 } }); animateJourneyLine(map, coords); // RAF loop, ease-out cubic, 5000ms }); ``` RAF loop builds coordinate array incrementally using cumulative Euclidean distance + ease-out cubic easing. On `prefers-reduced-motion: reduce`: skip animation, set full coordinates immediately. Teal values use `var(--color-accent)` equivalent (`#2A8C73`) — matches our design tokens. ### 2.3 GPX rendering Replace `leaflet-gpx` with `@mapbox/togeojson` + MapLibre GeoJSON source: ```js fetch(gpxUrl) .then(r => r.text()) .then(text => { const gpx = new DOMParser().parseFromString(text, 'text/xml'); const geojson = toGeoJSON.gpx(gpx); map.addSource('gpx-track', { type: 'geojson', data: geojson }); map.addLayer({ id: 'gpx-track-line', type: 'line', source: 'gpx-track', paint: { 'line-color': '#2A8C73', 'line-width': 2, 'line-opacity': 0.7 } }); }); ``` Multiple GPX files (trip has several tracks): each gets its own numbered source/layer pair. ### 2.4 Markers and popups MapLibre uses `maplibregl.Marker` (custom DOM element) + `maplibregl.Popup`. Existing popup HTML content (hero thumbnail, date, title, link) is unchanged. Marker style (same visual as current): - Regular entries: `12px` teal dot with white border - Latest/current entry: `18px` teal dot with outer ring (`box-shadow: 0 0 0 4px rgba(42,140,115,0.25)`) Popup styled via CSS (see §2.5). ### 2.5 CSS improvements over Leaflet **Remove (Leaflet-specific):** ```css /* DELETE — no longer needed */ .leaflet-container { background: #282828 !important; } ``` MapLibre sets its canvas background from the style JSON (`background-color` in the style's `background` layer). CARTO dark-matter style uses `#1a1a1a` — no flash on load. **Add (MapLibre):** ```css /* ── MapLibre GL overrides ───────────────────────────────────────────────────── */ /* Navigation controls (zoom +/−, compass) */ .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); } .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 — pointer hand over clickable markers */ .maplibregl-canvas-container.maplibregl-interactive { cursor: grab; } .maplibregl-canvas-container.maplibregl-interactive:active { cursor: grabbing; } ``` **Mobile scroll-trap prevention:** For embedded maps (mini-map on dailies, home map), initialize with `cooperativeGestures: true` — requires two fingers to pan on touch. The full-page `/map` uses normal gestures (`cooperativeGestures: false`, the default). *Note: verify `cooperativeGestures` is available in the chosen MapLibre GL 4.x version during implementation; if absent, use `dragPan: false` on touch-only + a two-finger hint overlay as fallback.* ### 2.6 What is NOT migrated now Features from Sabdia's map that were explicitly deferred: | Feature | Decision | |---|---| | Ghost pins for upcoming/planned stops | Documented; deferred — requires `show_preview` frontmatter field + Twig logic | | Pulsing amber dot for current location | Documented; deferred — requires "current entry" detection logic | | `flyTo()` on marker click | Deferred — nice UX upgrade, implement after migration stabilises | | 3D terrain | Deferred — requires DEM tile source (MapTiler key) | | Per-story inline MapBlock shortcode | Deferred — implement as part of story mode v2 | | MapTiler outdoor/satellite/topo styles for GPX | Deferred — requires MapTiler API key | These are preserved here so they can be picked up in a later milestone without needing to re-research the Sabdia implementation. --- ## Out of scope - Story-specific inline MapBlock shortcode (deferred, see §2.6) - Animated hero video (requires server-side FFmpeg, not available in Grav) - Push notifications for new stories - Story-level statistics (word count, reading time) - Co-authoring / Travel Buddy equivalent - 3D flyover video