21 KiB
Design Spec: Story Mode + MapLibre Migration
Date: 2026-06-19 Inspired by: Sabdia — a friend's sabbatical blog built on Astro + Keystatic + MapLibre
Scope
Two parallel features:
- Story Mode — a rich long-form post type alongside journal dailies, with cinematic storytelling blocks (hero, chapter breaks, scrollytelling, pull quotes, snap gallery)
- 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 callpolyline.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-gpxto@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/<trip-slug>/04.stories/
stories.md ← listing page, template: stories
01.<story-slug>/
story.md ← individual story, template: story
hero.jpg
photo-a.jpg
photo-b.jpg
stories.md frontmatter:
title: Stories
template: stories
published: true
1.2 Story frontmatter schema
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.scrolllistener (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 <script src> in story.html.twig).
1.7 CSS additions (story-specific)
New CSS block added to style.css under a /* ── Story pages ── section:
Story layout:
.story-hero—position: relative; height: 100vh; overflow: hidden.story-hero__img—position: sticky; top: 0; width: 100%; height: 100vh; object-fit: cover.story-hero__overlay—position: fixed; inset: 0; pointer-events: none(JS-driven opacity).story-hero__content—position: absolute; bottom: 18%; text-align: center; color: #fff.story-escape—position: fixed; top: 1rem; left: 1rem; z-index: 100; color: var(--color-ink); background: var(--color-canvas); ....story-body—max-width: 680px; margin: 0 auto; padding: var(--space-16) var(--space-6).story-body p—font-family: var(--font-ui); font-size: 1.0625rem; line-height: 1.85; color: var(--color-ink-2)
ChapterBreak:
.chapter-break— full-bleed breakout,60vh, overflow hidden.chapter-break__panel—backdrop-filter: blur(18px); background: rgba(26,24,20,0.25); border: 1px solid rgba(255,255,255,0.12); border-radius: var(--radius-sm)- Initial state:
opacity: 0; filter: blur(12px); transform: translateY(28px)→.is-revealedclears all .chapter-break__rule—40px × 2pxteal (var(--color-accent)) rule below title
ScrollySection:
.scrolly—display: grid; grid-template-columns: 55% 45%; width: 100vw(full-bleed breakout).scrolly__media—position: sticky; top: var(--site-header-height); height: calc(100vh - var(--site-header-height)).scrolly-step__inner—background: rgba(26,24,20,0.92); backdrop-filter: blur(4px); border-radius: var(--radius-sm); border: 1px solid var(--color-border)- Mobile (
max-width: 768px): single column, steps overlay the sticky image withmargin-top: calc(-(100vh - var(--site-header-height)))
PullQuote:
.pull-quote— bleeds1.5remeach side beyond prose column.pull-quote__inner—backdrop-filter: blur(14px); background: rgba(26,24,20,0.12)(with image) orvar(--color-canvas)(without)- Large
"marks:font-family: var(--font-display); font-size: 5rem; color: var(--color-accent); opacity: 0.4
SnapGallery:
.pgallery__frame—height: 100vh; scroll-snap-type: y mandatory; overflow-y: scroll.pgallery__bg—object-fit: cover; filter: blur(20px) brightness(0.4)(blurred backdrop).pgallery__fg—object-fit: contain(full foreground image).pgallery__dot.is-active—background: var(--color-accent)
All animations respect prefers-reduced-motion: reduce — transitions set to none,
initial states set to final states immediately.
1.8 Demo story content
One sample story added to user/docs/demo/trips/japan-korea-2026/ following existing
demo conventions. Story covers 28–29 March (Kyoto days already in journal demo):
user/docs/demo/trips/japan-korea-2026/04.stories/01.the-thousand-gates/
story.md
Frontmatter mirrors the schema. Body uses all four shortcode types so they can be QA'd
in one pass. No binary image assets — make demo-load copies the folder; tester drops
a few JPEGs in to exercise hero + photo blocks.
Part 2 — MapLibre GL Migration
2.1 Scope
Three files change. No new page routes. GPX file storage and delivery unchanged.
| File | Change |
|---|---|
map.html.twig |
Full rewrite of JS + CDN refs; CSS class renames |
dailies.html.twig |
Mini-map JS + CDN refs rewritten |
home.html.twig |
Home map JS + CDN refs rewritten |
style.css |
Leaflet overrides removed; MapLibre overrides added |
CDN changes (all three map templates):
<!-- Remove -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.min.css">
<script src="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/leaflet-gpx@2.1.2/gpx.min.js"></script>
<!-- Add -->
<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>
<!-- GPX maps only: -->
<script src="https://cdn.jsdelivr.net/npm/@mapbox/togeojson@0.16.2/togeojson.min.js"></script>
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:
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:
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:
12pxteal dot with white border - Latest/current entry:
18pxteal 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):
/* 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):
/* ── 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