# 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//04.stories/./story.md` with template `story` - Run `make content-push` after all tasks to sync --- ### Task 1: Plugin scaffold + ChapterBreak shortcode **Files:** - Create: `user/plugins/story-blocks/story-blocks.php` - Create: `user/plugins/story-blocks/story-blocks.yaml` - Create: `user/plugins/story-blocks/shortcodes/ChapterBreakShortcode.php` **Interfaces:** - Produces: `[chapter-break image="file.jpg" title="..." number="II" alt="..." /]` shortcode - Produces: `StoryBlocksPlugin::getCurrentPage()` — used by all shortcodes in later tasks **What:** Bootstrap the plugin and implement the first shortcode. Once this task is done, `[chapter-break ...]` in any story's Markdown body will render a full-bleed atmospheric scene divider. - [x] **Create `user/plugins/story-blocks/story-blocks.yaml`** ```yaml name: Story Blocks version: 1.0.0 description: Storytelling shortcode blocks for long-form travel stories author: name: Mischa homepage: https://github.com/m-cluitmans keywords: shortcode, story, storytelling bugs: '' license: MIT dependencies: - { name: grav, version: '>=2.0.0-rc.1' } - { name: shortcode-core, version: '>=5.0.0' } enabled: true ``` - [x] **Create `user/plugins/story-blocks/story-blocks.php`** ```php ['onShortcodeHandlers', 0], 'onPageContentRaw' => ['onPageContentRaw', 1000], ]; } public function onPageContentRaw(Event $event): void { $this->currentPage = $event['page']; } public function getCurrentPage() { return $this->currentPage; } public function onShortcodeHandlers(): void { $this->grav['shortcode']->registerAllShortcodes(__DIR__ . '/shortcodes'); } } ``` - [x] **Create `user/plugins/story-blocks/shortcodes/ChapterBreakShortcode.php`** ```php 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 ? '' : ''; return <<
{$alt}
{$numberHtml}

{$title}

HTML; }); } } ``` - [x] **Verify the plugin is recognised by Grav** Open `http://localhost:8081/admin2` → Plugins. `Story Blocks` should appear in the list and be enabled. If it doesn't appear, check the YAML filename matches the plugin folder name exactly (`story-blocks`), and confirm no PHP syntax errors: ```bash docker exec intotheeast_grav php -l /var/www/html/user/plugins/story-blocks/story-blocks.php docker exec intotheeast_grav php -l /var/www/html/user/plugins/story-blocks/shortcodes/ChapterBreakShortcode.php ``` - [x] **Create a test story page to verify ChapterBreak renders** Create `user/pages/01.trips/japan-korea-2026/04.stories/99.test-shortcodes/story.md`: ```yaml --- title: Shortcode Test date: 2026-03-28 hero_image: '' published: false --- Intro paragraph before the chapter break. [chapter-break title="Into the Shrine" number="I" /] Text after the chapter break. ``` Open `http://localhost:8081/trips/japan-korea-2026/stories/test-shortcodes`. The chapter break should render as an HTML div (even without CSS it should appear in source). Confirm no PHP errors. - [x] **Delete the test page** ```bash rm -rf user/pages/01.trips/japan-korea-2026/04.stories/99.test-shortcodes ``` - [x] **Commit** ```bash git -C user add plugins/story-blocks/ git -C user commit -m "feat: add story-blocks plugin with chapter-break shortcode" ``` --- ### Task 2: ScrollySection shortcode **Files:** - Create: `user/plugins/story-blocks/shortcodes/ScrollySectionShortcode.php` **Interfaces:** - Consumes: `StoryBlocksPlugin::getCurrentPage()` from Task 1 - Produces: `[scrolly-section image="file.jpg" alt="..." caption="..."]Step one --- Step two[/scrolly-section]` shortcode - The rendered HTML depends on Scrollama JS (loaded by `story.html.twig` in Task 4) to split `---` into steps at runtime **What:** The sticky-image scrollytelling block. Content between the tags becomes the step text; `---` on its own line separates steps. Scrollama (loaded on story pages) detects step entry/exit and drives image pan and overlay effects. - [x] **Create `user/plugins/story-blocks/shortcodes/ScrollySectionShortcode.php`** ```php 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
*/ $imageUrl = ($page && $imageName) ? $page->url() . '/' . $imageName : $imageName; $captionHtml = $caption ? '

' . $caption . '

' : ''; return <<
{$content}
HTML; }); } } ``` - [x] **Register the new shortcode** No change needed in `story-blocks.php` — `registerAllShortcodes` picks up all PHP files in the `shortcodes/` folder automatically. - [x] **Verify PHP syntax** ```bash docker exec intotheeast_grav php -l /var/www/html/user/plugins/story-blocks/shortcodes/ScrollySectionShortcode.php ``` - [x] **Commit** ```bash git -C user add plugins/story-blocks/shortcodes/ScrollySectionShortcode.php git -C user commit -m "feat: add scrolly-section shortcode (Scrollama-driven sticky image steps)" ``` --- ### Task 3: PullQuote and SnapGallery shortcodes **Files:** - Create: `user/plugins/story-blocks/shortcodes/PullQuoteShortcode.php` - Create: `user/plugins/story-blocks/shortcodes/SnapGalleryShortcode.php` **Interfaces:** - Consumes: `StoryBlocksPlugin::getCurrentPage()` from Task 1 - Produces: `[pull-quote image="file.jpg" alt="..."]Quote text.[/pull-quote]` - Produces: `[snap-gallery images="a.jpg,b.jpg,c.jpg" captions="Cap 1,Cap 2,Cap 3" alts="Alt 1,Alt 2,Alt 3" /]` - [x] **Create `user/plugins/story-blocks/shortcodes/PullQuoteShortcode.php`** ```php 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 = << {$alt}
HTML; } $innerClass = $imageUrl ? 'pull-quote__inner' : 'pull-quote__inner pull-quote__inner--no-image'; return << {$bgHtml}

{$content}

HTML; }); } } ``` - [x] **Create `user/plugins/story-blocks/shortcodes/SnapGalleryShortcode.php`** ```php 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 ? '
' . $caption . '
' : ''; $slidesHtml .= << {$alt} {$captionTag} HTML; $dotsHtml .= ''; } return <<
{$slidesHtml}
HTML; }); } } ``` - [x] **Verify PHP syntax for both files** ```bash docker exec intotheeast_grav php -l /var/www/html/user/plugins/story-blocks/shortcodes/PullQuoteShortcode.php docker exec intotheeast_grav php -l /var/www/html/user/plugins/story-blocks/shortcodes/SnapGalleryShortcode.php ``` - [x] **Commit** ```bash git -C user add plugins/story-blocks/shortcodes/PullQuoteShortcode.php plugins/story-blocks/shortcodes/SnapGalleryShortcode.php git -C user commit -m "feat: add pull-quote and snap-gallery shortcodes" ``` --- ### Task 4: `story.html.twig` — hero section and body **Files:** - Create: `user/themes/intotheeast/templates/story.html.twig` **Interfaces:** - Consumes: page frontmatter fields `hero_image`, `hero_alt`, `title`, `date`, `end_date`, `location_name` - Consumes: `{{ page.content|raw }}` — Markdown + expanded shortcodes from Tasks 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. - [x] **Create `user/themes/intotheeast/templates/story.html.twig`** ```twig {% extends 'partials/base.html.twig' %} {% block nav %} ← Stories {% 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 ?? '' %}
{% if hero %} {{ page.header.hero_alt ?? page.title }} {% else %}
{% endif %}

{{ page.title }}

{% if location %} · {{ location }}{% endif %}

{{ page.content|raw }}
{% endblock %} ``` - [x] **Verify template is picked up by Grav** Create a minimal story page at `user/pages/01.trips/japan-korea-2026/04.stories/99.hero-test/story.md`: ```yaml --- title: Hero Test date: 2026-03-28 hero_image: '' published: false --- Just some text. ``` Visit `http://localhost:8081/trips/japan-korea-2026/stories/hero-test`. The page should render without a 500 error (no hero image is fine — the placeholder div renders). No console errors. - [x] **Delete the test page** ```bash rm -rf user/pages/01.trips/japan-korea-2026/04.stories/99.hero-test ``` - [x] **Commit** ```bash git -C user add themes/intotheeast/templates/story.html.twig git -C user commit -m "feat: add story.html.twig with hero scroll effect and shortcode JS" ``` --- ### Task 5: `stories.html.twig` listing page + story CSS **Files:** - Modify: `user/themes/intotheeast/templates/stories.html.twig` - Modify: `user/themes/intotheeast/css/style.css` **Interfaces:** - Consumes: child pages of the stories listing page, each with `hero_image`, `title`, `date`, `end_date`, `location_name` frontmatter **What:** Replace the skeleton stories listing page with a card grid. Add all story + shortcode CSS to `style.css` in one block. - [x] **Replace `stories.html.twig` completely** ```twig {% extends 'partials/base.html.twig' %} {% block content %} {% set stories = page.children.published().order('date', 'asc') %}

Stories

{% if stories|length > 0 %}
{% 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 %} {% if hero %}
{{ story.title }}
{% else %}
{% endif %}
{% if story.header.location_name %} 📍 {{ story.header.location_name }}{% if story.header.location_country %}, {{ story.header.location_country }}{% endif %} {% endif %}

{{ story.title }}

Read story →
{% endfor %}
{% else %}

No stories yet — check back soon.

{% endif %}
{% endblock %} ``` - [x] **Add story CSS to `style.css`** At the end of `style.css`, add the following block: ```css /* ── Story pages ─────────────────────────────────────────────────────────── */ /* Override site-main constraints for story pages */ .template-story .site-main { max-width: none; padding: 0; } /* Floating escape link */ .story-escape { position: fixed; top: calc(var(--site-header-height) + var(--space-4)); left: var(--space-4); z-index: 100; font-family: var(--font-ui); font-size: var(--text-sm); font-weight: 500; color: rgba(255,255,255,0.85); background: rgba(26,24,20,0.55); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); border: 1px solid rgba(255,255,255,0.12); border-radius: var(--radius-full); padding: var(--space-2) var(--space-4); text-decoration: none; transition: background 0.2s; } .story-escape:hover { background: rgba(26,24,20,0.8); } /* Hero */ .story-hero { position: relative; } .story-hero__img-wrap { position: sticky; top: 0; height: 100vh; width: 100%; overflow: hidden; } .story-hero__img { width: 100%; height: 100%; object-fit: cover; animation: storyKenBurns 12s ease-out forwards; transform-origin: center center; } @keyframes storyKenBurns { from { transform: scale(1.06); } to { transform: scale(1); } } @media (prefers-reduced-motion: reduce) { .story-hero__img { animation: none; } } .story-hero__img-placeholder { width: 100%; height: 100%; background: linear-gradient(135deg, #1A1814 0%, #2A2720 100%); } .story-hero__overlay { position: fixed; inset: 0; pointer-events: none; z-index: 1; } .story-hero__content { position: absolute; bottom: 18%; left: 0; width: 100%; padding: 0 var(--space-8); text-align: center; color: #fff; z-index: 2; } .story-hero__title { font-family: var(--font-display); font-size: clamp(2.2rem, 6vw, 4.5rem); font-weight: 900; line-height: 1.08; letter-spacing: -0.02em; margin-bottom: var(--space-3); text-shadow: 0 2px 20px rgba(0,0,0,0.5); animation: storyReveal 0.9s cubic-bezier(.16,1,.3,1) 0.2s both; } .story-hero__meta { font-family: var(--font-ui); font-size: var(--text-base); opacity: 0.85; letter-spacing: 0.04em; text-shadow: 0 1px 6px rgba(0,0,0,0.4); animation: storyReveal 0.9s cubic-bezier(.16,1,.3,1) 0.55s both; margin: 0; } @keyframes storyReveal { from { filter: blur(10px); opacity: 0; transform: translateY(22px); } to { filter: blur(0); opacity: 1; transform: translateY(0); } } @media (prefers-reduced-motion: reduce) { .story-hero__title, .story-hero__meta { animation: none; opacity: 1; filter: none; transform: none; } } .story-hero__scroll-cue { position: absolute; bottom: var(--space-8); left: 50%; transform: translateX(-50%); color: rgba(255,255,255,0.7); z-index: 2; animation: storyCueBounce 2s ease-in-out infinite; transition: opacity 0.4s; } .story-hero__scroll-cue.is-hidden { opacity: 0; pointer-events: none; } @keyframes storyCueBounce { 0%, 100% { transform: translateX(-50%) translateY(0); } 50% { transform: translateX(-50%) translateY(6px); } } @media (prefers-reduced-motion: reduce) { .story-hero__scroll-cue { animation: none; } } .story-hero__spacer { height: 40vh; position: relative; z-index: 3; } /* Story body */ .story-body { max-width: 680px; margin: 0 auto; padding: var(--space-16) var(--space-6) var(--space-16); } .story-body p { font-family: var(--font-ui); font-size: 1.0625rem; line-height: 1.85; color: var(--color-ink-2); margin-bottom: var(--space-6); } .story-body h2, .story-body h3 { font-family: var(--font-display); color: var(--color-ink); margin-top: var(--space-10); margin-bottom: var(--space-4); } .story-footer { margin-top: var(--space-16); padding-top: var(--space-8); border-top: 1px solid var(--color-border); } .story-footer a { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--color-accent); text-decoration: none; } /* ── ChapterBreak ─────────────────────────────────────────── */ .chapter-break { position: relative; width: 100vw; left: 50%; margin-left: -50vw; height: 60vh; min-height: 320px; overflow: hidden; display: flex; align-items: center; justify-content: center; margin-top: var(--space-16); margin-bottom: var(--space-16); } .chapter-break__bg { position: absolute; inset: 0; } .chapter-break__img { width: 100%; height: 100%; object-fit: cover; display: block; } .chapter-break__tint { position: absolute; inset: 0; background: linear-gradient(to bottom, rgba(0,0,0,0.4) 0%, rgba(0,0,0,0.6) 100%); } .chapter-break__panel { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: var(--space-2); padding: var(--space-10) var(--space-12); background: rgba(26,24,20,0.25); backdrop-filter: blur(18px) saturate(1.4); -webkit-backdrop-filter: blur(18px) saturate(1.4); border: 1px solid rgba(255,255,255,0.12); border-radius: var(--radius-sm); max-width: 520px; width: 90%; text-align: center; opacity: 0; filter: blur(12px); transform: translateY(28px); transition: opacity 0.9s cubic-bezier(.16,1,.3,1), filter 0.9s cubic-bezier(.16,1,.3,1), transform 0.9s cubic-bezier(.16,1,.3,1); } .chapter-break__panel.is-revealed { opacity: 1; filter: blur(0); transform: translateY(0); } @media (prefers-reduced-motion: reduce) { .chapter-break__panel { opacity: 1 !important; filter: none !important; transform: none !important; transition: none !important; } } .chapter-break__number { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: 600; letter-spacing: 0.22em; text-transform: uppercase; color: rgba(255,255,255,0.6); } .chapter-break__title { font-family: var(--font-display); font-size: clamp(1.6rem, 4vw, 2.6rem); font-weight: 700; line-height: 1.15; color: #fff; text-shadow: 0 2px 16px rgba(0,0,0,0.4); margin: 0; } .chapter-break__rule { width: 40px; height: 2px; background: var(--color-accent); border-radius: 1px; margin-top: var(--space-2); } /* ── ScrollySection ───────────────────────────────────────── */ .scrolly { position: relative; display: grid; grid-template-columns: 55% 45%; width: 100vw; left: 50%; margin-left: -50vw; margin-top: var(--space-16); margin-bottom: var(--space-16); align-items: start; } .scrolly__media { position: sticky; top: var(--site-header-height); height: calc(100vh - var(--site-header-height)); overflow: hidden; } .scrolly__media-inner { position: relative; width: 100%; height: 100%; } .scrolly__img { width: 100%; height: 100%; object-fit: cover; object-position: 50% 50%; display: block; will-change: filter, object-position; transition: filter 0.6s cubic-bezier(.16,1,.3,1), object-position 1.2s cubic-bezier(.16,1,.3,1); } .scrolly .scrolly__img { margin: 0; border-radius: 0; max-width: none; } .scrolly__img-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0); transition: background 0.6s ease; pointer-events: none; } .scrolly__caption { position: absolute; bottom: var(--space-4); left: var(--space-4); right: var(--space-4); font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.65); text-align: center; pointer-events: none; margin: 0; } .scrolly__steps-content { display: none; } :global(.scrolly-step) { min-height: 60vh; display: flex; align-items: center; padding: var(--space-16) var(--space-8); } .scrolly-step { min-height: 60vh; display: flex; align-items: center; padding: var(--space-16) var(--space-8); } .scrolly-step__inner { background: rgba(26,24,20,0.92); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: var(--space-8) var(--space-8); opacity: 0; transform: translateY(20px); transition: opacity 0.55s cubic-bezier(.16,1,.3,1), transform 0.55s cubic-bezier(.16,1,.3,1); } .scrolly-step.is-active .scrolly-step__inner { opacity: 1; transform: translateY(0); } @media (prefers-reduced-motion: reduce) { .scrolly-step__inner { opacity: 1 !important; transform: none !important; transition: none !important; } } .scrolly-step__inner p { font-family: var(--font-ui); font-size: 1.05rem; line-height: 1.8; color: var(--color-ink-2); margin-bottom: var(--space-3); } .scrolly-step__inner p:last-child { margin-bottom: 0; } .scrolly-step:last-child { padding-bottom: 50vh; } @media (max-width: 768px), (pointer: coarse) { .scrolly { display: block; } .scrolly__steps { margin-top: calc(-(100vh - var(--site-header-height))); position: relative; z-index: 1; } .scrolly-step { min-height: 80vh; padding: var(--space-8) var(--space-6); align-items: center; justify-content: center; } .scrolly-step:last-child { padding-bottom: 50vh; } } /* ── PullQuote ────────────────────────────────────────────── */ .pull-quote { position: relative; width: calc(100% + 3rem); margin-left: -1.5rem; margin-right: -1.5rem; margin-top: var(--space-12); margin-bottom: var(--space-12); overflow: hidden; border-radius: var(--radius-sm); border: none; background: transparent; padding: 0; opacity: 0; filter: blur(6px); transform: translateY(24px); transition: opacity 0.85s cubic-bezier(.16,1,.3,1), filter 0.85s cubic-bezier(.16,1,.3,1), transform 0.85s cubic-bezier(.16,1,.3,1); } .pull-quote.is-revealed { opacity: 1; filter: blur(0); transform: translateY(0); } @media (prefers-reduced-motion: reduce) { .pull-quote { opacity: 1 !important; filter: none !important; transform: none !important; transition: none !important; } } .pull-quote__bg { position: absolute; inset: 0; z-index: 0; } .pull-quote__bg-img { width: 100%; height: 100%; object-fit: cover; display: block; } .pull-quote__bg-tint { position: absolute; inset: 0; background: rgba(0,0,0,0.55); } .pull-quote__inner { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; padding: var(--space-10) var(--space-8); backdrop-filter: blur(14px) saturate(1.3); -webkit-backdrop-filter: blur(14px) saturate(1.3); background: rgba(26,24,20,0.08); text-align: center; } .pull-quote__inner--no-image { background: var(--color-canvas); backdrop-filter: none; -webkit-backdrop-filter: none; } .pull-quote__mark { font-family: var(--font-display); font-size: 5rem; line-height: 0.8; color: var(--color-accent); opacity: 0.4; display: block; } .pull-quote__mark--close { margin-top: var(--space-2); } .pull-quote__text { font-family: var(--font-display); font-size: clamp(1.2rem, 3vw, 1.6rem); font-style: italic; color: #fff; line-height: 1.5; margin: var(--space-4) 0; } .pull-quote__inner--no-image .pull-quote__text { color: var(--color-ink); } /* ── SnapGallery ──────────────────────────────────────────── */ .pgallery { width: 100vw; left: 50%; margin-left: -50vw; position: relative; margin-top: var(--space-16); margin-bottom: var(--space-16); } .pgallery__frame { position: relative; height: 100vh; overflow-y: scroll; scroll-snap-type: y mandatory; -webkit-overflow-scrolling: touch; } .pgallery__slide { position: relative; height: 100vh; display: flex; align-items: center; justify-content: center; scroll-snap-align: start; scroll-snap-stop: always; } .pgallery__bg { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; filter: blur(20px) brightness(0.4); transform: scale(1.05); } .pgallery__fg { position: relative; z-index: 1; max-height: 90vh; max-width: 90vw; object-fit: contain; } .pgallery__caption { position: absolute; bottom: var(--space-8); left: 0; right: 0; z-index: 2; text-align: center; font-family: var(--font-ui); font-size: var(--text-sm); color: rgba(255,255,255,0.8); margin: 0; padding: 0 var(--space-8); } .pgallery__dots { position: absolute; right: var(--space-4); top: 50%; transform: translateY(-50%); z-index: 3; display: flex; flex-direction: column; gap: var(--space-2); } .pgallery__dot { width: 6px; height: 6px; border-radius: 50%; background: rgba(255,255,255,0.35); transition: background 0.25s; display: block; } .pgallery__dot.is-active { background: var(--color-accent); } /* ── Stories listing ──────────────────────────────────────── */ .stories-listing { padding: var(--space-10) 0; } .stories-listing__heading { font-family: var(--font-display); font-size: var(--text-2xl); font-weight: 400; color: var(--color-ink); margin-bottom: var(--space-10); } .stories-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--space-8); } @media (max-width: 640px) { .stories-grid { grid-template-columns: 1fr; } } .story-card { text-decoration: none; color: inherit; display: flex; flex-direction: column; border-radius: var(--radius-md); overflow: hidden; background: var(--color-canvas); border: 1px solid var(--color-border); transition: box-shadow 0.2s; } .story-card:hover { box-shadow: var(--shadow-md); } .story-card__photo { aspect-ratio: 16/9; overflow: hidden; } .story-card__photo img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform 0.4s ease; } .story-card:hover .story-card__photo img { transform: scale(1.03); } .story-card__photo--empty { background: var(--color-surface-raised); } .story-card__body { padding: var(--space-5); } .story-card__date { display: block; font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-ink-muted); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: var(--space-1); } .story-card__location { display: block; font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-ink-muted); margin-bottom: var(--space-3); } .story-card__title { font-family: var(--font-display); font-size: var(--text-lg); font-weight: 400; color: var(--color-ink); margin-bottom: var(--space-3); line-height: var(--leading-snug); } .story-card__cta { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--color-accent); } .stories-empty { font-family: var(--font-ui); color: var(--color-ink-muted); font-style: italic; } ``` - [x] **Verify stories listing at `http://localhost:8081/trips/japan-korea-2026/stories`** Without any story pages: "No stories yet — check back soon." should appear. No 500 error. - [x] **Verify story template class is applied** View source of any story page. The `` tag should include `template-story` in its class list (Grav adds this automatically). The `.template-story .site-main` override removes max-width/padding, allowing full-bleed blocks. - [x] **Commit** ```bash git -C user add themes/intotheeast/templates/stories.html.twig themes/intotheeast/css/style.css git -C user commit -m "feat: add stories listing page and all story/shortcode CSS" ``` --- ### Task 6: Demo story content **Files:** - Create: `user/docs/demo/trips/japan-korea-2026/04.stories/01.the-thousand-gates/story.md` - Modify: `Makefile` (add story folder to `demo-load` and `demo-reset` targets) **What:** A sample story that exercises all four shortcode blocks. No image files are committed — the tester drops a few JPEGs into the folder manually to verify image resolution. The story covers 28–29 March (Kyoto days from the existing journal demo). - [x] **Create `user/docs/demo/trips/japan-korea-2026/04.stories/01.the-thousand-gates/story.md`** ```yaml --- title: The Thousand Gates date: 2026-03-28 end_date: 2026-03-29 location_name: Kyoto location_country: Japan lat: 34.967 lng: 135.773 hero_image: hero.jpg hero_alt: A vermillion torii gate at dawn, half-lit by morning sun published: true --- We left the ryokan before sunrise. Kyoto in March has a particular quality of light — not yet warm, but already golden at the edges. The streets were empty except for a few cyclists and one very confused vending machine that kept flashing its lights at nothing. [chapter-break image="shrine-gate.jpg" title="Fushimi Inari" number="I" alt="Rows of vermillion torii gates stretching into darkness" /] The path up through Fushimi Inari begins the moment you pass the main shrine. There is no dramatic threshold — just a gate, then another gate, then several thousand more. Each was donated by a business or family. You can read their names on the back of each post, small kanji pressed into the lacquered red. [scrolly-section image="torii-path.jpg" alt="Tunnel of torii gates seen from below" caption="Senbon Torii — the Thousand Gates"] The first gate smells of fresh lacquer. Someone has recently repainted it, and the colour is almost aggressive in its redness. --- By the tenth gate the smell is gone and the city has disappeared. Pine trees close in on both sides. The only sounds are other footsteps and the occasional crow. --- By the hundredth gate you stop counting. The path becomes the thing itself — not a means to a destination but a place to be. --- Near the summit there is a small shrine with fox statues wearing tiny red bibs. An old woman is arranging fresh flowers in front of them, moving with the unhurried certainty of someone who has done this ten thousand times. [/scrolly-section] [pull-quote image="summit.jpg" alt="View over Kyoto from the hilltop"] The gates never seemed to end — and somewhere around gate five hundred, I stopped wanting them to. [/pull-quote] By the time we descended, the city had woken up. Taxis, schoolchildren, a delivery truck arguing with a narrow alley. We found a coffee shop down a side street that did not appear to expect visitors and sat there for an hour watching nothing in particular happen. [snap-gallery images="gallery-1.jpg,gallery-2.jpg,gallery-3.jpg,gallery-4.jpg" captions="Morning light on the main shrine,Fox statues at the upper shrine,Looking back through the gates,Coffee stop on the descent" alts="Sunlit shrine building,Stone fox statues with red bibs,Torii gates receding into distance,Small coffee shop interior" /] That evening we had ramen in a place with eight seats and a chef who appeared to be operating entirely by memory. There was no menu. You sat down and food appeared. It was the best meal of the trip. ``` - [x] **Find the `demo-load` target in the Makefile and add the story folder** Find the `demo-load` target. It copies journal entries from `user/docs/demo/trips/japan-korea-2026/` into `user/pages/`. Add the stories folder copy alongside the existing entries copy: ```makefile # After the existing journal entry copy line, add: cp -r user/docs/demo/trips/japan-korea-2026/04.stories user/pages/01.trips/japan-korea-2026/ 2>/dev/null || true ``` - [x] **Find the `demo-reset` target and add cleanup for the demo story** ```makefile # After the existing journal entry removal, add: rm -rf user/pages/01.trips/japan-korea-2026/04.stories/01.the-thousand-gates ``` - [x] **Run `make demo-load` and verify the story appears** ```bash make demo-load ``` Then open `http://localhost:8081/trips/japan-korea-2026/stories`. The "The Thousand Gates" card should appear (no hero photo — that's expected; drop a JPEG named `hero.jpg` in the folder to test). Open the story page. Verify: - Full-viewport area renders (even without hero image — the placeholder gradient shows) - Prose text renders below the hero - `[chapter-break ...]` renders an HTML structure (even without image it shows the title panel) - `[scrolly-section ...]` renders with steps visible (Scrollama JS splits on `---`) - `[pull-quote ...]` renders the frosted quote block - `[snap-gallery ...]` renders the gallery frame (slides visible even without images) - No console errors - [x] **Drop a test JPEG into the story folder and verify image resolution** ```bash cp /path/to/any.jpg user/pages/01.trips/japan-korea-2026/04.stories/01.the-thousand-gates/hero.jpg ``` Reload the story page. The hero image should fill the viewport with Ken Burns animation. The escape link (← Stories) should float top-left over the image. - [x] **Verify scrollytelling on desktop** Scroll through the story: - Chapter break panel blurs in when it enters the viewport - ScrollySection: image stays sticky, text panels scroll past, each step fades in when it enters - Pull quote blurs in on scroll - Snap gallery: scroll within the gallery frame advances one photo at a time; dots update - [x] **Verify on mobile (375px viewport)** - ScrollySection collapses to full-screen sticky image with text panels overlaid - SnapGallery works with swipe (one swipe = one photo) - [x] **Run `make demo-reset` and verify the story is removed** ```bash make demo-reset ``` Check `user/pages/01.trips/japan-korea-2026/04.stories/` — `01.the-thousand-gates` should be gone. - [x] **Commit** ```bash git -C user add docs/demo/trips/japan-korea-2026/04.stories/ git -C user commit -m "docs: add demo story content (The Thousand Gates, all four shortcode blocks)" git -C user add Makefile git -C user commit -m "build: add story folder to demo-load and demo-reset targets" ``` - [x] **Final sync** ```bash make content-push ```