50 KiB
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
Shortcodeand implementinit()with$this->shortcode->getHandlers()->add() - Image filenames in shortcode parameters are resolved to URLs via
$page->url() . '/' . $imageName - Current page tracked by listening to
onPageContentRawevent — same pattern asshortcode-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.mdwith templatestory - Run
make content-pushafter 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.
-
Create
user/plugins/story-blocks/story-blocks.yamlname: 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 -
Create
user/plugins/story-blocks/story-blocks.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'); } } -
Create
user/plugins/story-blocks/shortcodes/ChapterBreakShortcode.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; }); } } -
Verify the plugin is recognised by Grav
Open
http://localhost:8081/admin2→ Plugins.Story Blocksshould 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: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 -
Create a test story page to verify ChapterBreak renders
Create
user/pages/01.trips/japan-korea-2026/04.stories/99.test-shortcodes/story.md:--- 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. -
Delete the test page
rm -rf user/pages/01.trips/japan-korea-2026/04.stories/99.test-shortcodes -
Commit
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.twigin 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.
-
Create
user/plugins/story-blocks/shortcodes/ScrollySectionShortcode.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; }); } } -
Register the new shortcode
No change needed in
story-blocks.php—registerAllShortcodespicks up all PHP files in theshortcodes/folder automatically. -
Verify PHP syntax
docker exec intotheeast_grav php -l /var/www/html/user/plugins/story-blocks/shortcodes/ScrollySectionShortcode.php -
Commit
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" /] -
Create
user/plugins/story-blocks/shortcodes/PullQuoteShortcode.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; }); } } -
Create
user/plugins/story-blocks/shortcodes/SnapGalleryShortcode.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; }); } } -
Verify PHP syntax for both files
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 -
Commit
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 1–3 - 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.
-
Create
user/themes/intotheeast/templates/story.html.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 %} -
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:--- 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. -
Delete the test page
rm -rf user/pages/01.trips/japan-korea-2026/04.stories/99.hero-test -
Commit
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_namefrontmatter
What: Replace the skeleton stories listing page with a card grid. Add all story + shortcode CSS to style.css in one block.
-
Replace
stories.html.twigcompletely{% 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 %} -
Add story CSS to
style.cssAt the end of
style.css, add the following block:/* ── 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; } -
Verify stories listing at
http://localhost:8081/trips/japan-korea-2026/storiesWithout any story pages: "No stories yet — check back soon." should appear. No 500 error.
-
Verify story template class is applied
View source of any story page. The
<body>tag should includetemplate-storyin its class list (Grav adds this automatically). The.template-story .site-mainoverride removes max-width/padding, allowing full-bleed blocks. -
Commit
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 todemo-loadanddemo-resettargets)
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 28–29 March (Kyoto days from the existing journal demo).
-
Create
user/docs/demo/trips/japan-korea-2026/04.stories/01.the-thousand-gates/story.md--- 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. -
Find the
demo-loadtarget in the Makefile and add the story folderFind the
demo-loadtarget. It copies journal entries fromuser/docs/demo/trips/japan-korea-2026/intouser/pages/. Add the stories folder copy alongside the existing entries copy:# 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 -
Find the
demo-resettarget and add cleanup for the demo story# After the existing journal entry removal, add: rm -rf user/pages/01.trips/japan-korea-2026/04.stories/01.the-thousand-gates -
Run
make demo-loadand verify the story appearsmake demo-loadThen 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 namedhero.jpgin 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
-
Drop a test JPEG into the story folder and verify image resolution
cp /path/to/any.jpg user/pages/01.trips/japan-korea-2026/04.stories/01.the-thousand-gates/hero.jpgReload 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.
-
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
-
Verify on mobile (375px viewport)
- ScrollySection collapses to full-screen sticky image with text panels overlaid
- SnapGallery works with swipe (one swipe = one photo)
-
Run
make demo-resetand verify the story is removedmake demo-resetCheck
user/pages/01.trips/japan-korea-2026/04.stories/—01.the-thousand-gatesshould be gone. -
Commit
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" -
Final sync
make content-push