Files
intotheeast-com/docs/working/plans/2026-06-21-homepage-redesign.md
T

36 KiB
Raw Blame History

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

Goal: Context-aware homepage with a persistent two-column map+feed layout: active-trip mode shows the live feed + GPX on the home map; between-trips mode shows a curated highlights grid from all trips with markers-only on the map.

Architecture: Single home.html.twig with a {% if config.site.travelling %} branch. Active trip branch keeps the existing feed and adds GPX loading to the home map. Between-trips branch selects one random featured:true entry per trip (max 6), renders a highlight card grid, and passes coordinates to the map (no GPX, no journey line). Three blueprint files expose new data fields. A site-config blueprint exposes the mode switch and active-trip selector in Admin2.

Tech Stack: Grav CMS 2.0 (PHP/Twig), MapLibre GL v4, toGeoJSON CDN, Playwright (Node.js)

Global Constraints

  • All user/ file changes committed via git -C user from the project root (or git from within user/)
  • Test files in tests/ui/ and scripts/ committed via plain git from the project root
  • No new Grav plugins
  • No JS build pipeline — plain CSS and vanilla JS only
  • config.site.active_trip stores a full page route: /trips/italy-2026-demo (not a bare slug)
  • config.site.travelling is true for active-trip mode, false for between-trips mode
  • entry.header.featured and story.header.featured (bool) gate highlight eligibility — no type-based auto-include; both stories and journal entries use the same flag
  • trip.header.tagline (string) is the trip description shown on highlight cards
  • Dev server at http://localhost:8081 must be running (make start) for all Playwright tests
  • Demo data must be loaded (make demo-load) before running Playwright tests
  • Playwright test IDs continue sequentially: next map test is M8; next home tests are H2H5

Task 1: Blueprints, config, and demo seed data

Files:

  • Create: user/blueprints/config/site.yaml
  • Modify: user/themes/intotheeast/blueprints/trip.yaml — add tagline field in Trip tab
  • Modify: user/themes/intotheeast/blueprints/entry.yaml — add featured toggle in Entry tab
  • Modify: user/themes/intotheeast/blueprints/story.yaml — add featured toggle in Publishing tab
  • Modify: user/config/site.yaml — change active_trip to full route, add travelling: true
  • Modify: user/pages/01.trips/italy-2026-demo/04.stories/val-dorcia-at-dawn/story.md — add featured: true
  • Modify: first journal entry under user/pages/01.trips/italy-2026-demo/01.dailies/ — add featured: true
  • Modify: matching demo source files in user/docs/demo/trips/italy-2026-demo/ — mirror the featured: true additions

Interfaces:

  • Produces: config.site.travelling (bool) — read in Twig as config.site.travelling

  • Produces: config.site.active_trip (string, full route) — used in Task 2 path lookups

  • Produces: entry.header.featured / story.header.featured (bool) — used in Task 3 selection logic

  • Produces: trip.header.tagline (string) — used in Task 3 card rendering

  • Step 1: Record baseline test result

make test

Expected: 13 passed, 1 failed (parent set to /trips/japan-korea-2026/dailies — pre-existing). Record this so you can verify nothing changed after your edits.

  • Step 2: Create user/blueprints/config/site.yaml
form:
  validation: loose
  fields:
    active_trip:
      type: pages
      label: Active Trip
      start_route: '/trips'
      show_root: false
      show_slug: true

    travelling:
      type: toggle
      label: Currently Travelling
      highlight: 1
      default: false
      options:
        1: 'Yes'
        0: 'No'
      validate:
        type: bool

Note: type: pages is confirmed present in Admin2's JS bundle but untested in a site config blueprint. If it fails to render in Admin2, fall back to type: select with explicit options: entries — one per trip slug — and no other code changes are needed.

  • Step 3: Add tagline to user/themes/intotheeast/blueprints/trip.yaml

In the trip tab's fields block, after header.album_url, add:

            header.tagline:
              type: text
              label: Tagline
              placeholder: '6 weeks from Venice to Sicily by train'
              help: 'Short description shown on homepage highlight cards'
  • Step 4: Add featured toggle to user/themes/intotheeast/blueprints/entry.yaml

In the entry tab's fields block, after header.force_connect, add:

            header.featured:
              type: toggle
              label: Featured highlight
              help: 'Show as a homepage highlight when not travelling'
              highlight: 1
              default: 0
              options:
                1: 'Yes'
                0: 'No'
              validate:
                type: bool
  • Step 5: Add featured toggle to user/themes/intotheeast/blueprints/story.yaml

In the publishing tab's fields block, after header.published, add:

            header.featured:
              type: toggle
              label: Featured highlight
              help: 'Show as a homepage highlight when not travelling'
              highlight: 1
              default: 0
              options:
                1: 'Yes'
                0: 'No'
              validate:
                type: bool
  • Step 6: Update user/config/site.yaml

Replace the file contents with:

title: 'Into the East'
description: 'A travel blog by Mischa'
author:
  name: Mischa
  email: mischa@gorinskat.nl
taxonomies: [category, tag]
metadata:
  description: 'Into the East — travel journal'
active_trip: /trips/italy-2026-demo
travelling: true
  • Step 7: Mark the demo story as featured

Open user/pages/01.trips/italy-2026-demo/04.stories/val-dorcia-at-dawn/story.md and add featured: true to its YAML frontmatter block. For example, if the existing frontmatter ends with published: true, add the line after it:

featured: true
  • Step 8: Mark one demo journal entry as featured

Find the first entry folder:

ls user/pages/01.trips/italy-2026-demo/01.dailies/ | head -1

Open user/pages/01.trips/italy-2026-demo/01.dailies/<that-slug>/entry.md and add featured: true to its YAML frontmatter. Ensure the entry has lat and lng set — if the first entry doesn't, pick the first one that does (check with grep -l "^lat:" user/pages/01.trips/italy-2026-demo/01.dailies/*/entry.md | head -1).

  • Step 9: Mirror featured flags to demo source

The demo source lives in user/docs/demo/trips/italy-2026-demo/. Apply the same featured: true additions to:

  • user/docs/demo/trips/italy-2026-demo/stories/val-dorcia-at-dawn/story.md
  • The matching journal entry in user/docs/demo/trips/italy-2026-demo/dailies/<slug>/entry.md

This ensures featured flags survive make demo-reset.

  • Step 10: Verify tests unchanged
make test

Expected: identical to Step 1 (13 passed, 1 pre-existing failure). These are YAML-only changes — no template or script changed.

  • Step 11: Commit
git -C user add blueprints/config/site.yaml \
    themes/intotheeast/blueprints/trip.yaml \
    themes/intotheeast/blueprints/entry.yaml \
    themes/intotheeast/blueprints/story.yaml \
    config/site.yaml \
    pages/01.trips/italy-2026-demo/04.stories/val-dorcia-at-dawn/story.md \
    docs/demo/trips/italy-2026-demo/stories/val-dorcia-at-dawn/story.md
git -C user commit -m "feat: add blueprints for active_trip/travelling config, tagline, featured fields"

Then commit the journal entry (substitute the actual slug discovered in Step 8):

git -C user add pages/01.trips/italy-2026-demo/01.dailies/<slug>/entry.md \
    docs/demo/trips/italy-2026-demo/dailies/<slug>/entry.md
git -C user commit -m "chore: mark demo entries as featured for homepage highlight testing"

Task 2: Active trip mode — route-based lookup + GPX on home map

Files:

  • Modify: user/themes/intotheeast/templates/home.html.twig — full replacement
  • Modify: tests/ui/maps.spec.js — add M8

Interfaces:

  • Consumes: config.site.travelling (bool) — Task 1

  • Consumes: config.site.active_trip (full route string) — Task 1

  • Produces: window.homeMap global (already existed — now with GPX sources home-gpx-0 … and home-journey)

  • Produces: {% else %} placeholder in template for Task 3 to fill

  • Step 1: Write failing test M8

Add to tests/ui/maps.spec.js:

// ── M8: Home map has GPX journey source on active trip ────────────────────────
test('M8: home map has a journey source after GPX settles (active trip)', async ({ page }) => {
    // Requires travelling: true in user/config/site.yaml (set in Task 1).
    // Requires GPX files attached to the active trip (italy-2026-demo has 7).
    const errors = [];
    page.on('pageerror', e => errors.push(e.message));

    await page.goto('/');
    await expect(page.locator('#home-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
    await expect(page.locator('#home-map .maplibregl-marker').first()).toBeVisible({ timeout: 15000 });

    await page.waitForFunction(function () {
        return window.homeMap &&
               (window.homeMap.getSource('home-journey') !== undefined ||
                window.homeMap.getSource('home-gpx-0') !== undefined);
    }, { timeout: 20000 });

    const hasSource = await page.evaluate(function () {
        return !!(window.homeMap.getSource('home-journey') || window.homeMap.getSource('home-gpx-0'));
    });

    expect(hasSource, 'Home map has a journey or GPX source').toBe(true);
    expect(errors, 'No JS errors on home page').toHaveLength(0);
});

Run to confirm it fails:

npx playwright test tests/ui/maps.spec.js --grep "M8"

Expected: FAIL (GPX sources not yet added to home map).

  • Step 2: Replace home.html.twig with the active-trip-mode version

Replace the entire file with:

{% extends 'partials/base.html.twig' %}

{% block content %}
{% set trip_route = config.site.active_trip %}
{% set trip = grav.pages.find(trip_route) %}

{% if config.site.travelling %}
{# ══════════════════════════════════════════════════════════ ACTIVE TRIP MODE #}

{% set dailies_page = grav.pages.find(trip_route ~ '/dailies') %}
{% set stories_page = grav.pages.find(trip_route ~ '/stories') %}
{% set journal_entries = dailies_page ? dailies_page.children.published() : [] %}
{% set story_entries = stories_page ? stories_page.children.published() : [] %}

{% set all_items = [] %}
{% for e in journal_entries %}
    {% set all_items = all_items|merge([{'type': 'journal', 'page': e, 'date': e.header.date}]) %}
{% endfor %}
{% for s in story_entries %}
    {% set all_items = all_items|merge([{'type': 'story', 'page': s, 'date': s.header.date}]) %}
{% endfor %}
{% set all_items = all_items|sort_by_key('date', 3) %}

{% set journal_count = journal_entries|length %}
{% set story_count = story_entries|length %}

{% set map_entries = [] %}
{% for item in all_items %}
    {% if item.type == 'journal' and item.page.header.lat is not empty and item.page.header.lng is not empty %}
        {% set map_entries = map_entries|merge([{
            'lat': item.page.header.lat|number_format(6, '.', ''),
            'lng': item.page.header.lng|number_format(6, '.', ''),
            'slug': item.page.slug,
            'title': item.page.title,
            'url': item.page.url,
            'force_connect': item.page.header.force_connect ? true : false
        }]) %}
    {% endif %}
{% endfor %}

{% set home_gpx_urls = [] %}
{% if trip %}
    {% for name, media in trip.media.all %}
        {% if name|split('.')|last == 'gpx' %}
            {% set home_gpx_urls = home_gpx_urls|merge([trip.url ~ '/' ~ name]) %}
        {% endif %}
    {% endfor %}
{% endif %}

<div class="home-layout">
    <div class="home-map-col">
        <div class="home-map" id="home-map"></div>
    </div>

    <div class="home-feed-col">
        <div class="home-trip-header">
            <h1 class="home-trip-name">{{ trip ? trip.title : trip_route }}</h1>
            <span class="home-trip-counts">
                {{ journal_count }} journal {{ journal_count == 1 ? 'entry' : 'entries' }}
                {% if story_count > 0 %} · {{ story_count }} {{ story_count == 1 ? 'story' : 'stories' }}{% endif %}
            </span>
        </div>

        <div class="feed">
            {% if all_items|length > 0 %}
                {% for item in all_items %}
                    {% set entry = item.page %}

                    {% if item.type == 'journal' %}
                    {% set weather_icons = {
                        'Sunny': '☀️', 'Partly cloudy': '⛅', 'Cloudy': '☁️',
                        'Foggy': '🌫️', 'Drizzle': '🌦️', 'Rain': '🌧️',
                        'Snow': '❄️', 'Thunderstorm': '⛈️'
                    } %}
                    <article class="journal-post" id="entry-{{ entry.slug }}" data-lat="{{ entry.header.lat }}" data-lng="{{ entry.header.lng }}">
                        <header class="journal-post-header">
                            <h2 class="journal-post-title">{{ entry.title }}</h2>
                            <p class="journal-post-meta">
                                <a class="journal-post-permalink" href="{{ entry.url }}">
                                    <time datetime="{{ entry.date|date('Y-m-d') }}">{{ entry.date|date('d M Y')|upper }}</time>
                                </a>
                                {% if entry.header.location_city or entry.header.location_country %}
                                <span class="journal-post-location">
                                    · 📍
                                    {%- set _loc = [] -%}
                                    {%- if entry.header.location_city -%}{%- set _loc = _loc|merge([entry.header.location_city]) -%}{%- endif -%}
                                    {%- if entry.header.location_country -%}{%- set _loc = _loc|merge([entry.header.location_country]) -%}{%- endif -%}
                                    {{ _loc|join(', ') }}
                                </span>
                                {% endif %}
                                {% if entry.header.weather_desc %}
                                <span class="journal-post-weather">· {{ weather_icons[entry.header.weather_desc] ?? '' }} {{ entry.header.weather_desc }}</span>
                                {% endif %}
                            </p>
                        </header>

                        {% set images = entry.media.images %}
                        {% if images|length > 0 %}
                        <div class="journal-photo-strip" data-slides="{{ images|length }}">
                            {% for img in images %}
                            <div class="journal-photo-slide">
                                <img src="{{ img.cropResize(900, 600).url }}" alt="{{ entry.title }}" loading="lazy">
                            </div>
                            {% endfor %}
                        </div>
                        {% if images|length > 1 %}
                        <div class="journal-photo-dots" aria-hidden="true">
                            {% for img in images %}
                            <span class="journal-photo-dot{% if loop.first %} is-active{% endif %}"></span>
                            {% endfor %}
                        </div>
                        {% endif %}
                        {% endif %}

                        <div class="journal-post-body">{{ entry.content|raw }}</div>
                    </article>
                    {% else %}
                    {% set hero = null %}
                    {% if entry.header.hero_image and entry.media[entry.header.hero_image] is defined %}
                        {% set hero = entry.media[entry.header.hero_image] %}
                    {% elseif entry.media.images|length > 0 %}
                        {% set hero = entry.media.images|first %}
                    {% endif %}
                    <a class="entry-card entry-card--story" id="entry-{{ entry.slug }}" href="{{ entry.url }}">
                        {% if hero %}
                        <div class="entry-card-photo entry-card-photo--story">
                            <img src="{{ hero.cropResize(720, 405).url }}" alt="{{ entry.title }}" loading="lazy">
                        </div>
                        {% endif %}
                        <div class="entry-card-body">
                            <span class="story-badge">✦ Story</span>
                            <h2 class="entry-title">{{ entry.title }}</h2>
                        </div>
                    </a>
                    {% endif %}
                {% endfor %}
            {% else %}
                <p class="feed-empty">No entries yet. The journey is about to begin.</p>
            {% endif %}
        </div>
    </div>
</div>

{% if map_entries|length > 0 %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.css">
<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.js"></script>
{% if home_gpx_urls|length > 0 %}
<script src="https://cdn.jsdelivr.net/npm/@mapbox/togeojson@0.16.2/togeojson.min.js"></script>
{% endif %}
<script src="{{ url('theme://js/maplibre-utils.js') }}"></script>
<script>
var HOME_ENTRIES  = {{ map_entries|json_encode|raw }};
var HOME_GPX_URLS = {{ home_gpx_urls|json_encode|raw }};

var homeMap = new maplibregl.Map({
    container: 'home-map',
    style: MapUtils.MAP_STYLE,
    center: [20, 20],
    zoom: 2
});

homeMap.on('load', function () {
    var bounds = new maplibregl.LngLatBounds();
    var coords = [];

    HOME_ENTRIES.forEach(function (entry, i) {
        var isLatest = (i === HOME_ENTRIES.length - 1);
        var lngLat   = [parseFloat(entry.lng), parseFloat(entry.lat)];
        coords.push(lngLat);
        bounds.extend(lngLat);

        var el = MapUtils.createDotMarker(isLatest);
        el.dataset.url = entry.url;
        var popup = new maplibregl.Popup({ offset: 12, closeButton: false, closeOnClick: false, className: 'map-tip-popup' })
            .setLngLat(lngLat)
            .setHTML('<span class="map-tip">' + entry.title + '</span>');
        el.addEventListener('mouseenter', function () { popup.addTo(homeMap); });
        el.addEventListener('mouseleave', function () { popup.remove(); });
        el.addEventListener('click', function () {
            var card = document.getElementById('entry-' + entry.slug);
            if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' });
        });

        new maplibregl.Marker({ element: el }).setLngLat(lngLat).addTo(homeMap);
    });

    /* Draw simple journey line immediately; replaced below if GPX is present */
    MapUtils.addJourneyLine(homeMap, coords, 'home-journey');

    if (HOME_ENTRIES.length === 1) {
        homeMap.jumpTo({ center: coords[0], zoom: 10 });
    } else {
        homeMap.fitBounds(bounds, { padding: 60, maxZoom: 11 });
    }

    setTimeout(function () { homeMap.resize(); }, 100);

    if (HOME_GPX_URLS.length > 0) {
        Promise.all(HOME_GPX_URLS.map(function (url, idx) {
            return fetch(url)
                .then(function (r) { return r.text(); })
                .then(function (text) {
                    var xml     = new DOMParser().parseFromString(text, 'text/xml');
                    var geojson = toGeoJSON.gpx(xml);
                    var sid     = 'home-gpx-' + idx;
                    homeMap.addSource(sid, { type: 'geojson', data: geojson });
                    homeMap.addLayer({
                        id: sid + '-line', type: 'line', source: sid,
                        layout: { 'line-join': 'round', 'line-cap': 'round' },
                        paint:  { 'line-color': MapUtils.ACCENT, 'line-width': 2, 'line-opacity': 0.7 }
                    });
                    return MapUtils.extractTrackpoints(geojson);
                })
                .catch(function (err) { console.warn('GPX load failed:', url, err); return []; });
        })).then(function (allTrackpoints) {
            if (homeMap.getLayer('home-journey')) homeMap.removeLayer('home-journey');
            if (homeMap.getSource('home-journey')) homeMap.removeSource('home-journey');
            var valid    = allTrackpoints.filter(function (tp) { return tp.length > 0; });
            var segments = MapUtils.buildJourneySegments(HOME_ENTRIES, valid, 10);
            MapUtils.addJourneySegments(homeMap, segments, 'home-journey');
        });
    }
});
</script>
{% endif %}

{% else %}
{# ════════════════════════════════════════════════ BETWEEN-TRIPS MODE (Task 3) #}
<p class="feed-empty" style="padding: 2rem;">Off season — highlights coming in Task 3.</p>
{% endif %}

{% endblock %}
  • Step 3: Run M8 test
npx playwright test tests/ui/maps.spec.js --grep "M8"

Expected: PASS. The home map now loads GPX URLs, adds home-gpx-N layer sources, and replaces the simple home-journey source with connector-suppressed segments.

  • Step 4: Run existing home + map tests
npx playwright test tests/ui/maps.spec.js tests/ui/home.spec.js

Expected: M4 and H1 still pass; M8 passes.

  • Step 5: Commit
git -C user add themes/intotheeast/templates/home.html.twig
git -C user commit -m "feat: add travelling branch and GPX to home map (active trip mode)"
git add tests/ui/maps.spec.js
git commit -m "test(maps): add M8 — home map GPX source on active trip"

Task 3: Between-trips highlights mode + CSS + Playwright tests

Files:

  • Modify: user/themes/intotheeast/templates/home.html.twig — replace {% else %} placeholder with full highlights branch
  • Modify: user/themes/intotheeast/css/style.css — append highlight card and grid styles
  • Create: tests/ui/home-highlights.spec.js

Interfaces:

  • Consumes: config.site.travelling (bool) — Task 1

  • Consumes: entry.header.featured / story.header.featured (bool) — Task 1

  • Consumes: trip.header.tagline (string) — Task 1

  • Produces: .home-highlights-grid — the grid container, used in Playwright selectors

  • Produces: .home-highlight-card[id="highlight-<slug>"] — per-card IDs for map marker scroll-to

  • Produces: .home-highlights-cta — CTA link to /trips

  • Produces: window.homeMap global in between-trips mode (same name, separate branch)

  • Step 1: Write failing tests

Create tests/ui/home-highlights.spec.js:

// @ts-check
// Tests: H2H5 — Between-trips highlights mode
// These tests temporarily set travelling: false in user/config/site.yaml,
// run the assertions, then restore the original value.
// Requires demo data with featured entries: run `make demo-load` first.
const { test, expect } = require('@playwright/test');
const fs = require('fs');
const path = require('path');

const SITE_YAML_PATH = path.join(__dirname, '../../user/config/site.yaml');

test.describe('Between-trips highlights mode', () => {
    let originalSiteYaml;

    test.beforeAll(async () => {
        originalSiteYaml = fs.readFileSync(SITE_YAML_PATH, 'utf8');
        const patched = originalSiteYaml.replace(/^travelling:\s*true/m, 'travelling: false');
        fs.writeFileSync(SITE_YAML_PATH, patched);
        // Brief pause for Grav to re-read config on next request
        await new Promise(r => setTimeout(r, 400));
    });

    test.afterAll(async () => {
        fs.writeFileSync(SITE_YAML_PATH, originalSiteYaml);
    });

    // ── H2: Highlights grid is visible ──────────────────────────────────────────
    test('H2: homepage shows highlights grid when not travelling', async ({ page }) => {
        await page.goto('/');
        await expect(page.locator('.home-highlights-grid')).toBeVisible({ timeout: 10000 });
    });

    // ── H3: Highlight cards contain trip link ────────────────────────────────────
    test('H3: highlight cards have a View-trip link', async ({ page }) => {
        await page.goto('/');
        await expect(page.locator('.home-highlight-card').first()).toBeVisible({ timeout: 10000 });
        await expect(page.locator('.home-highlight-trip-link').first()).toBeVisible();
    });

    // ── H4: Between-trips home map renders without JS errors ────────────────────
    test('H4: home map renders in between-trips mode without JS errors', async ({ page }) => {
        const errors = [];
        page.on('pageerror', e => errors.push(e.message));
        await page.goto('/');
        await expect(page.locator('#home-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
        expect(errors, 'No JS errors').toHaveLength(0);
    });

    // ── H5: CTA links to /trips ──────────────────────────────────────────────────
    test('H5: "Explore all past trips" CTA links to /trips', async ({ page }) => {
        await page.goto('/');
        const cta = page.locator('.home-highlights-cta');
        await expect(cta).toBeVisible({ timeout: 10000 });
        await expect(cta).toHaveAttribute('href', /\/trips/);
    });
});

Run to confirm they fail:

npx playwright test tests/ui/home-highlights.spec.js

Expected: H2H5 all FAIL (.home-highlights-grid not present).

  • Step 2: Append highlight CSS to user/themes/intotheeast/css/style.css

Append to the end of style.css:

/* ── Between-trips highlights grid ──────────────────────────────────────────── */

.home-highlights-header {
    margin-bottom: var(--space-8);
    padding-bottom: var(--space-6);
    border-bottom: 1px solid var(--color-border);
}

.home-highlights-title {
    font-family: var(--font-display);
    font-size: var(--text-2xl);
    font-weight: 400;
    color: var(--color-ink);
    margin-bottom: var(--space-2);
}

.home-highlights-subtitle {
    font-size: var(--text-sm);
    color: var(--color-ink-muted);
}

.home-highlights-grid {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: var(--space-6);
    margin-bottom: var(--space-10);
}

@media (max-width: 900px) {
    .home-highlights-grid { grid-template-columns: repeat(2, 1fr); }
}

@media (max-width: 600px) {
    .home-highlights-grid { grid-template-columns: 1fr; }
}

.home-highlight-card {
    border: 1px solid var(--color-border);
    border-radius: var(--radius-md);
    background: var(--color-canvas);
    overflow: hidden;
    display: flex;
    flex-direction: column;
}

.home-highlight-image {
    aspect-ratio: 16 / 9;
    overflow: hidden;
}

.home-highlight-image img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
}

.home-highlight-body {
    padding: var(--space-4);
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    flex: 1;
}

.home-highlight-badge {
    font-size: var(--text-xs);
    font-weight: 600;
    font-variant: small-caps;
    letter-spacing: 0.08em;
    color: var(--color-accent);
}

.home-highlight-badge--journal {
    color: var(--color-ink-muted);
}

.home-highlight-title {
    font-family: var(--font-display);
    font-size: var(--text-lg);
    font-weight: 400;
    color: var(--color-ink);
    text-decoration: none;
    line-height: 1.3;
}

.home-highlight-title:hover { color: var(--color-accent); }

.home-highlight-trip {
    margin-top: auto;
    padding-top: var(--space-3);
    border-top: 1px solid var(--color-border);
    font-size: var(--text-xs);
    color: var(--color-ink-muted);
    display: flex;
    flex-direction: column;
    gap: var(--space-1);
}

.home-highlight-trip-name {
    font-weight: 600;
    color: var(--color-ink-2);
}

.home-highlight-tagline { font-style: italic; }

.home-highlight-trip-link {
    color: var(--color-accent);
    text-decoration: none;
    font-weight: 500;
}

.home-highlight-trip-link:hover { text-decoration: underline; }

.home-highlights-cta-wrap {
    text-align: center;
    padding-top: var(--space-4);
    border-top: 1px solid var(--color-border);
}

.home-highlights-cta {
    display: inline-block;
    color: var(--color-accent);
    font-size: var(--text-sm);
    font-weight: 500;
    text-decoration: none;
    padding: var(--space-3) var(--space-6);
    border: 1px solid var(--color-accent);
    border-radius: var(--radius-sm);
}

.home-highlights-cta:hover {
    background: var(--color-accent);
    color: var(--color-canvas);
}
  • Step 3: Replace the placeholder {% else %} branch in home.html.twig

Find this exact block (added in Task 2):

{% else %}
{# ════════════════════════════════════════════════ BETWEEN-TRIPS MODE (Task 3) #}
<p class="feed-empty" style="padding: 2rem;">Off season — highlights coming in Task 3.</p>
{% endif %}

Replace it with:

{% else %}
{# ══════════════════════════════════════════════════════ BETWEEN-TRIPS MODE #}

{# ── Highlight selection ─────────────────────────────────────────────────── #}
{% set trips_page = grav.pages.find('/trips') %}
{% set pool = [] %}
{% if trips_page %}
    {% for trip_item in trips_page.children.published() %}
        {% set t_dailies = grav.pages.find(trip_item.route ~ '/dailies') %}
        {% set t_stories = grav.pages.find(trip_item.route ~ '/stories') %}
        {% set candidates = [] %}
        {% if t_dailies %}
            {% for e in t_dailies.children.published() %}
                {% if e.header.featured %}
                    {% set candidates = candidates|merge([{'type': 'journal', 'page': e, 'trip': trip_item}]) %}
                {% endif %}
            {% endfor %}
        {% endif %}
        {% if t_stories %}
            {% for s in t_stories.children.published() %}
                {% if s.header.featured %}
                    {% set candidates = candidates|merge([{'type': 'story', 'page': s, 'trip': trip_item}]) %}
                {% endif %}
            {% endfor %}
        {% endif %}
        {% if candidates|length > 0 %}
            {% set pool = pool|merge([random(candidates)]) %}
        {% endif %}
    {% endfor %}
{% endif %}
{% set pool = pool|shuffle %}
{% set highlights = pool|slice(0, 6) %}

{# ── Map entries (entries with coordinates) ──────────────────────────────── #}
{% set highlights_map_entries = [] %}
{% for item in highlights %}
    {% if item.page.header.lat is not empty and item.page.header.lng is not empty %}
        {% set highlights_map_entries = highlights_map_entries|merge([{
            'lat': item.page.header.lat|number_format(6, '.', ''),
            'lng': item.page.header.lng|number_format(6, '.', ''),
            'slug': item.page.slug,
            'title': item.page.title,
            'url': item.page.url
        }]) %}
    {% endif %}
{% endfor %}

<div class="home-layout">
    <div class="home-map-col">
        <div class="home-map" id="home-map"></div>
    </div>

    <div class="home-feed-col">
        <div class="home-highlights-header">
            <h1 class="home-highlights-title">Into the East</h1>
            <p class="home-highlights-subtitle">A few moments from past journeys</p>
        </div>

        {% if highlights|length > 0 %}
        <div class="home-highlights-grid">
            {% for item in highlights %}
            {% set entry = item.page %}
            {% set hero = null %}
            {% if entry.header.hero_image and entry.media[entry.header.hero_image] is defined %}
                {% set hero = entry.media[entry.header.hero_image] %}
            {% elseif entry.media.images|length > 0 %}
                {% set hero = entry.media.images|first %}
            {% endif %}
            <div class="home-highlight-card" id="highlight-{{ entry.slug }}">
                {% if hero %}
                <div class="home-highlight-image">
                    <img src="{{ hero.cropResize(720, 405).url }}" alt="{{ entry.title }}" loading="lazy">
                </div>
                {% endif %}
                <div class="home-highlight-body">
                    {% if item.type == 'story' %}
                    <span class="home-highlight-badge">✦ Story</span>
                    {% else %}
                    <span class="home-highlight-badge home-highlight-badge--journal">▸ Journal</span>
                    {% endif %}
                    <a class="home-highlight-title" href="{{ entry.url }}">{{ entry.title }}</a>
                    <div class="home-highlight-trip">
                        <span class="home-highlight-trip-name">{{ item.trip.title }}</span>
                        {% if item.trip.header.tagline %}
                        <span class="home-highlight-tagline">{{ item.trip.header.tagline }}</span>
                        {% endif %}
                        <a class="home-highlight-trip-link" href="{{ item.trip.url }}">→ View trip</a>
                    </div>
                </div>
            </div>
            {% endfor %}
        </div>
        {% else %}
        <p class="feed-empty">No highlights yet — mark entries as featured to show them here.</p>
        {% endif %}

        <div class="home-highlights-cta-wrap">
            <a class="home-highlights-cta" href="/trips">Explore all past trips →</a>
        </div>
    </div>
</div>

{% if highlights_map_entries|length > 0 %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.css">
<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.js"></script>
<script src="{{ url('theme://js/maplibre-utils.js') }}"></script>
<script>
var HIGHLIGHTS_ENTRIES = {{ highlights_map_entries|json_encode|raw }};

var homeMap = new maplibregl.Map({
    container: 'home-map',
    style: MapUtils.MAP_STYLE,
    center: [20, 20],
    zoom: 2
});

homeMap.on('load', function () {
    if (HIGHLIGHTS_ENTRIES.length === 0) return;

    var bounds = new maplibregl.LngLatBounds();

    HIGHLIGHTS_ENTRIES.forEach(function (entry) {
        var lngLat = [parseFloat(entry.lng), parseFloat(entry.lat)];
        bounds.extend(lngLat);

        var el = MapUtils.createDotMarker(false);
        var popup = new maplibregl.Popup({ offset: 12, closeButton: false, closeOnClick: false, className: 'map-tip-popup' })
            .setLngLat(lngLat)
            .setHTML('<span class="map-tip">' + entry.title + '</span>');
        el.addEventListener('mouseenter', function () { popup.addTo(homeMap); });
        el.addEventListener('mouseleave', function () { popup.remove(); });
        el.addEventListener('click', function () {
            var card = document.getElementById('highlight-' + entry.slug);
            if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' });
        });

        new maplibregl.Marker({ element: el }).setLngLat(lngLat).addTo(homeMap);
    });

    if (HIGHLIGHTS_ENTRIES.length === 1) {
        homeMap.jumpTo({ center: [parseFloat(HIGHLIGHTS_ENTRIES[0].lng), parseFloat(HIGHLIGHTS_ENTRIES[0].lat)], zoom: 8 });
    } else {
        homeMap.fitBounds(bounds, { padding: 60, maxZoom: 8 });
    }

    setTimeout(function () { homeMap.resize(); }, 100);
});
</script>
{% endif %}

{% endif %}

{% endblock %}
  • Step 4: Run H2H5 tests
npx playwright test tests/ui/home-highlights.spec.js

Expected: H2, H3, H4, H5 all PASS.

  • Step 5: Verify no regressions
make test
npx playwright test tests/ui/

Expected: make test same as baseline (13 passed, 1 pre-existing failure). All Playwright tests pass — H1 and M4 use travelling: true; H2H5 temporarily flip to false and restore it.

  • Step 6: Commit
git -C user add themes/intotheeast/templates/home.html.twig \
    themes/intotheeast/css/style.css
git -C user commit -m "feat: add between-trips highlights mode with grid and map markers"
git add tests/ui/home-highlights.spec.js
git commit -m "test(home): add H2H5 between-trips highlights Playwright tests"