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

943 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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**
```bash
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`**
```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:
```yaml
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:
```yaml
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:
```yaml
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:
```yaml
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:
```yaml
featured: true
```
- [ ] **Step 8: Mark one demo journal entry as featured**
Find the first entry folder:
```bash
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**
```bash
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**
```bash
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):
```bash
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`:
```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:
```bash
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:
```twig
{% 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**
```bash
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**
```bash
npx playwright test tests/ui/maps.spec.js tests/ui/home.spec.js
```
Expected: M4 and H1 still pass; M8 passes.
- [ ] **Step 5: Commit**
```bash
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`:
```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:
```bash
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`:
```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):
```twig
{% 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:
```twig
{% 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**
```bash
npx playwright test tests/ui/home-highlights.spec.js
```
Expected: H2, H3, H4, H5 all PASS.
- [ ] **Step 5: Verify no regressions**
```bash
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**
```bash
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"
```