Files
intotheeast-com/docs/working/plans/2026-06-19-story-mode.md
T

1367 lines
50 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.
# 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/<trip-slug>/04.stories/<n>.<story-slug>/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
<?php
namespace Grav\Plugin;
use Grav\Common\Plugin;
use RocketTheme\Toolbox\Event\Event;
class StoryBlocksPlugin extends Plugin
{
private $currentPage = null;
public static function getSubscribedEvents(): array
{
return [
'onShortcodeHandlers' => ['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
<?php
namespace Grav\Plugin\Shortcodes;
use Thunder\Shortcode\Shortcode\ShortcodeInterface;
class ChapterBreakShortcode extends Shortcode
{
public function init(): void
{
$this->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
? '<span class="chapter-break__number" aria-hidden="true">' . $number . '</span>'
: '';
return <<<HTML
<div class="chapter-break" aria-label="Chapter: {$title}">
<div class="chapter-break__bg">
<img src="{$imageUrl}" alt="{$alt}" class="chapter-break__img" loading="lazy">
<div class="chapter-break__tint" aria-hidden="true"></div>
</div>
<div class="chapter-break__panel">
{$numberHtml}
<h2 class="chapter-break__title">{$title}</h2>
<div class="chapter-break__rule" aria-hidden="true"></div>
</div>
</div>
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
<?php
namespace Grav\Plugin\Shortcodes;
use Thunder\Shortcode\Shortcode\ShortcodeInterface;
class ScrollySectionShortcode extends Shortcode
{
public function init(): void
{
$this->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 <hr> */
$imageUrl = ($page && $imageName) ? $page->url() . '/' . $imageName : $imageName;
$captionHtml = $caption
? '<p class="scrolly__caption">' . $caption . '</p>'
: '';
return <<<HTML
<div class="scrolly">
<div class="scrolly__media" aria-hidden="true">
<div class="scrolly__media-inner">
<img src="{$imageUrl}" alt="{$alt}" class="scrolly__img" loading="lazy">
<div class="scrolly__img-overlay"></div>
</div>
{$captionHtml}
</div>
<div class="scrolly__steps">
<div class="scrolly__steps-content">
{$content}
</div>
</div>
</div>
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
<?php
namespace Grav\Plugin\Shortcodes;
use Thunder\Shortcode\Shortcode\ShortcodeInterface;
class PullQuoteShortcode extends Shortcode
{
public function init(): void
{
$this->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 = <<<HTML
<div class="pull-quote__bg" aria-hidden="true">
<img src="{$imageUrl}" alt="{$alt}" class="pull-quote__bg-img" loading="lazy">
<div class="pull-quote__bg-tint"></div>
</div>
HTML;
}
$innerClass = $imageUrl ? 'pull-quote__inner' : 'pull-quote__inner pull-quote__inner--no-image';
return <<<HTML
<blockquote class="pull-quote" aria-label="Pull quote">
{$bgHtml}
<div class="{$innerClass}">
<span class="pull-quote__mark" aria-hidden="true">"</span>
<p class="pull-quote__text">{$content}</p>
<span class="pull-quote__mark pull-quote__mark--close" aria-hidden="true">"</span>
</div>
</blockquote>
HTML;
});
}
}
```
- [x] **Create `user/plugins/story-blocks/shortcodes/SnapGalleryShortcode.php`**
```php
<?php
namespace Grav\Plugin\Shortcodes;
use Thunder\Shortcode\Shortcode\ShortcodeInterface;
class SnapGalleryShortcode extends Shortcode
{
public function init(): void
{
$this->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
? '<figcaption class="pgallery__caption">' . $caption . '</figcaption>'
: '';
$slidesHtml .= <<<HTML
<figure class="pgallery__slide" data-index="{$i}">
<img src="{$url}" alt="" class="pgallery__bg" aria-hidden="true" loading="{$eager}">
<img src="{$url}" alt="{$alt}" class="pgallery__fg" loading="{$eager}">
{$captionTag}
</figure>
HTML;
$dotsHtml .= '<span class="pgallery__dot' . $active . '" data-dot="' . $i . '" aria-hidden="true"></span>';
}
return <<<HTML
<div class="pgallery">
<div class="pgallery__frame" role="region" aria-label="Photo gallery">
{$slidesHtml}
<div class="pgallery__dots" aria-hidden="true">{$dotsHtml}</div>
</div>
</div>
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 13
- 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 %}
<a class="story-escape" href="{{ page.parent().url }}">← Stories</a>
{% 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 ?? '' %}
<div class="story-hero" id="story-hero">
<div class="story-hero__img-wrap">
{% if hero %}
<img src="{{ hero.url }}" alt="{{ page.header.hero_alt ?? page.title }}" class="story-hero__img" loading="eager">
{% else %}
<div class="story-hero__img-placeholder"></div>
{% endif %}
</div>
<div class="story-hero__overlay" id="story-overlay" aria-hidden="true"></div>
<div class="story-hero__content">
<h1 class="story-hero__title">{{ page.title }}</h1>
<p class="story-hero__meta">
<time datetime="{{ page.date|date('Y-m-d') }}">{{ date_str }}</time>
{% if location %} · {{ location }}{% endif %}
</p>
</div>
<div class="story-hero__scroll-cue" id="story-scroll-cue" aria-hidden="true">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9"/>
</svg>
</div>
<div class="story-hero__spacer"></div>
</div>
<div class="story-body">
{{ page.content|raw }}
<footer class="story-footer">
<a href="{{ page.parent().url }}">← Back to stories</a>
</footer>
</div>
<script src="https://cdn.jsdelivr.net/npm/scrollama@3/build/scrollama.min.js"></script>
<script>
/* ── Hero scroll effect ──────────────────────────────────── */
(function () {
var overlay = document.getElementById('story-overlay');
var cue = document.getElementById('story-scroll-cue');
var hidden = false;
var ticking = false;
if (!overlay) return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
function update() {
var progress = Math.min(window.scrollY / window.innerHeight, 1);
overlay.style.background = 'rgba(0,0,0,' + (progress * 0.65).toFixed(3) + ')';
if (progress >= 1) overlay.style.display = 'none';
else overlay.style.display = '';
if (!hidden && window.scrollY > 80) {
cue.classList.add('is-hidden');
hidden = true;
}
ticking = false;
}
window.addEventListener('scroll', function () {
if (!ticking) { requestAnimationFrame(update); ticking = true; }
}, { passive: true });
update();
})();
/* ── ChapterBreak scroll-reveal ─────────────────────────── */
(function () {
var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var panels = document.querySelectorAll('.chapter-break__panel');
if (!panels.length) return;
if (reduced) { panels.forEach(function (p) { p.classList.add('is-revealed'); }); return; }
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (e.isIntersecting) { e.target.classList.add('is-revealed'); io.unobserve(e.target); }
});
}, { threshold: 0.25 });
panels.forEach(function (p) { io.observe(p); });
})();
/* ── PullQuote scroll-reveal ─────────────────────────────── */
(function () {
var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var quotes = document.querySelectorAll('.pull-quote');
if (!quotes.length) return;
if (reduced) { quotes.forEach(function (q) { q.classList.add('is-revealed'); }); return; }
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (e.isIntersecting) { e.target.classList.add('is-revealed'); io.unobserve(e.target); }
});
}, { threshold: 0.2 });
quotes.forEach(function (q) { io.observe(q); });
})();
/* ── SnapGallery dot indicator ───────────────────────────── */
(function () {
document.querySelectorAll('.pgallery').forEach(function (gallery) {
var slides = gallery.querySelectorAll('.pgallery__slide');
var dots = gallery.querySelectorAll('.pgallery__dot');
if (!slides.length || !dots.length) return;
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (e.isIntersecting) {
var idx = parseInt(e.target.dataset.index, 10);
dots.forEach(function (d) { d.classList.remove('is-active'); });
if (dots[idx]) dots[idx].classList.add('is-active');
}
});
}, { threshold: 0.5, root: gallery.querySelector('.pgallery__frame') });
slides.forEach(function (s) { io.observe(s); });
});
})();
/* ── ScrollySection (Scrollama) ──────────────────────────── */
(function () {
var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var sections = document.querySelectorAll('.scrolly');
if (!sections.length || typeof scrollama === 'undefined') return;
var panOffsets = ['50% 40%', '50% 50%', '50% 60%', '50% 45%', '50% 55%'];
sections.forEach(function (section) {
var img = section.querySelector('.scrolly__img');
var overlay = section.querySelector('.scrolly__img-overlay');
var contentEl = section.querySelector('.scrolly__steps-content');
var stepsWrap = section.querySelector('.scrolly__steps');
if (!img || !contentEl || !stepsWrap) return;
/* Split slot content on <hr> into individual step divs */
var nodes = Array.from(contentEl.childNodes);
var groups = [];
var curr = [];
nodes.forEach(function (n) {
if (n.nodeName === 'HR') { if (curr.length) groups.push(curr); curr = []; }
else curr.push(n);
});
if (curr.length) groups.push(curr);
if (!groups.length) groups.push(nodes);
groups.forEach(function (group) {
var step = document.createElement('div');
step.className = 'scrolly-step';
var inner = document.createElement('div');
inner.className = 'scrolly-step__inner';
group.forEach(function (n) { inner.appendChild(n.cloneNode(true)); });
step.appendChild(inner);
stepsWrap.appendChild(step);
});
if (reduced) {
section.querySelectorAll('.scrolly-step').forEach(function (s) { s.classList.add('is-active'); });
return;
}
/* Initial blur */
img.style.filter = 'blur(8px)';
img.style.transform = 'scale(1.04)';
img.style.transition = 'filter 0.7s cubic-bezier(.16,1,.3,1), transform 0.7s cubic-bezier(.16,1,.3,1), object-position 1.2s cubic-bezier(.16,1,.3,1)';
new IntersectionObserver(function (entries, obs) {
if (entries[0].isIntersecting) {
img.style.filter = 'blur(0)';
img.style.transform = 'scale(1)';
obs.disconnect();
}
}, { threshold: 0.1 }).observe(section);
scrollama()
.setup({ step: Array.from(section.querySelectorAll('.scrolly-step')), offset: 0.55 })
.onStepEnter(function (d) {
d.element.classList.add('is-active');
img.style.objectPosition = panOffsets[d.index % panOffsets.length];
if (overlay) overlay.style.background = 'rgba(0,0,0,' + (0.05 + (d.index % 3) * 0.05) + ')';
})
.onStepExit(function (d) {
if (d.direction === 'up') d.element.classList.remove('is-active');
});
});
})();
</script>
{% 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') %}
<div class="stories-listing">
<h1 class="stories-listing__heading">Stories</h1>
{% if stories|length > 0 %}
<div class="stories-grid">
{% 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 %}
<a class="story-card" href="{{ story.url }}">
{% if hero %}
<div class="story-card__photo">
<img src="{{ hero.cropResize(720, 405).url }}" alt="{{ story.title }}" loading="lazy">
</div>
{% else %}
<div class="story-card__photo story-card__photo--empty"></div>
{% endif %}
<div class="story-card__body">
<time class="story-card__date">{{ date_str }}</time>
{% if story.header.location_name %}
<span class="story-card__location">📍 {{ story.header.location_name }}{% if story.header.location_country %}, {{ story.header.location_country }}{% endif %}</span>
{% endif %}
<h2 class="story-card__title">{{ story.title }}</h2>
<span class="story-card__cta">Read story →</span>
</div>
</a>
{% endfor %}
</div>
{% else %}
<p class="stories-empty">No stories yet — check back soon.</p>
{% endif %}
</div>
{% 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 `<body>` 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 2829 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
```