Files
intotheeast-com/docs/superpowers/specs/2026-06-19-story-mode-and-maplibre-design.md
T

21 KiB
Raw Blame History

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:

  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.

Journal entries are short daily posts — a grid of 38 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 "2829 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 @keyframesscale(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   │
│ 2829 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-heroposition: relative; height: 100vh; overflow: hidden
  • .story-hero__imgposition: sticky; top: 0; width: 100%; height: 100vh; object-fit: cover
  • .story-hero__overlayposition: fixed; inset: 0; pointer-events: none (JS-driven opacity)
  • .story-hero__contentposition: absolute; bottom: 18%; text-align: center; color: #fff
  • .story-escapeposition: fixed; top: 1rem; left: 1rem; z-index: 100; color: var(--color-ink); background: var(--color-canvas); ...
  • .story-bodymax-width: 680px; margin: 0 auto; padding: var(--space-16) var(--space-6)
  • .story-body pfont-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__panelbackdrop-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-revealed clears all
  • .chapter-break__rule40px × 2px teal (var(--color-accent)) rule below title

ScrollySection:

  • .scrollydisplay: grid; grid-template-columns: 55% 45%; width: 100vw (full-bleed breakout)
  • .scrolly__mediaposition: sticky; top: var(--site-header-height); height: calc(100vh - var(--site-header-height))
  • .scrolly-step__innerbackground: 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 with margin-top: calc(-(100vh - var(--site-header-height)))

PullQuote:

  • .pull-quote — bleeds 1.5rem each side beyond prose column
  • .pull-quote__innerbackdrop-filter: blur(14px); background: rgba(26,24,20,0.12) (with image) or var(--color-canvas) (without)
  • Large " marks: font-family: var(--font-display); font-size: 5rem; color: var(--color-accent); opacity: 0.4

SnapGallery:

  • .pgallery__frameheight: 100vh; scroll-snap-type: y mandatory; overflow-y: scroll
  • .pgallery__bgobject-fit: cover; filter: blur(20px) brightness(0.4) (blurred backdrop)
  • .pgallery__fgobject-fit: contain (full foreground image)
  • .pgallery__dot.is-activebackground: 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 2829 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: 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):

/* 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