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

552 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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:
```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 "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 `@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 │
│ 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-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-revealed` clears all
- `.chapter-break__rule``40px × 2px` teal (`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 with `margin-top: calc(-(100vh - var(--site-header-height)))`
**PullQuote:**
- `.pull-quote` — bleeds `1.5rem` each side beyond prose column
- `.pull-quote__inner``backdrop-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__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 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):
```html
<!-- 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:
```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