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

50 KiB
Raw Blame History

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.

  • Create user/plugins/story-blocks/story-blocks.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
    
  • 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 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:

    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.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.

  • 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.phpregisterAllShortcodes picks up all PHP files in the shortcodes/ 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 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.

  • 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_name frontmatter

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.twig completely

    {% 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.css

    At 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/stories

    Without 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 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.

  • 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 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).

  • 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-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:

    # 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-reset target 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-load and verify the story appears

    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
  • 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.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.

  • 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-reset and verify the story is removed

    make demo-reset
    

    Check user/pages/01.trips/japan-korea-2026/04.stories/01.the-thousand-gates should 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