docs: merge worktree-docs-restructure into main; move new superpowers/ files to working/

This commit is contained in:
2026-06-21 13:50:12 +02:00
57 changed files with 2736 additions and 193 deletions
@@ -0,0 +1,508 @@
# Grav 2.0 Upgrade 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:** Upgrade the local dev Docker environment from linuxserver/grav 1.7 to getgrav/grav 2.0 RC, validate the full Milestone 1 posting workflow, and update the production install script for a fresh Grav 2.0 deploy.
**Architecture:** Two tracks in sequence — (1) swap the Docker image and update all dependent config/paths, boot the site with `make setup`, run the existing test suite; (2) update `server-install.sh` so `make remote-install` deploys Grav 2.0 fresh on the production PHP 8.4 server. The `user/` directory (content, config, theme, custom plugins) is already isolated as a git repo and requires only a small compatibility addition to `cache-on-save`.
**Tech Stack:** Grav CMS 2.0.0-rc.9, PHP 8.4 (production) / Docker `getgrav/grav` with PHP 8.3 (dev), Apache, Twig 3, Symfony 7, Playwright (UI tests).
## Global Constraints
- All work on branch `update-to-2.0` (already created)
- Never read `.env` — contains sensitive credentials
- Only modify files in the project root or `user/` subfolders
- `user/config/system.yaml` is tracked in the **user/ git repo** — commit it with `git -C user add config/system.yaml && git -C user commit ...`, NOT from the main repo
- `user/plugins/cache-on-save/` is tracked in the **main repo** (after adding `.gitignore` exception) — commit blueprints.yaml with `git add user/plugins/cache-on-save/blueprints.yaml` from the project root
- Container name stays `intotheeast_grav`; local port stays `8081`
- `make` commands are the only way to interact with the remote server
- Grav 2.0 requires PHP ≥ 8.3 (dev container uses 8.3 default; production uses 8.4 — both compliant)
- Production download URL format: `https://getgrav.org/download/core/grav-admin/${GRAV_VERSION}${GRAV_CHANNEL_SUFFIX}`
---
## Files Changed
| File | Action | Reason |
|---|---|---|
| `docker-compose.yml` | Modify | Switch image, update volume + PHP ini path, add env var |
| `Makefile` | Modify | Three `docker exec` targets hardcode linuxserver's `/app/www/public` path |
| `.gitignore` | Modify | Add `!user/plugins/cache-on-save/` exception to track the custom plugin in the main repo |
| `user/plugins/cache-on-save/blueprints.yaml` | Create | Grav 2.0 compat flag (required by GPM) — committed to main repo |
| `user/config/system.yaml` | Modify | Switch GPM channel from `stable` to `testing` |
| `scripts/server-install.sh` | Modify | Support `GRAV_CHANNEL_SUFFIX` for `?testing` query param on 2.0 RC download |
---
## Task 1: Swap Docker image and fix container paths
**Files:**
- Modify: `docker-compose.yml`
- Modify: `Makefile`
**Interfaces:**
- Produces: A running Grav 2.0 container reachable at `http://localhost:8081` with `user/` mounted at `/var/www/html/user` and PHP upload limits applied via `/usr/local/etc/php/conf.d/php-local.ini`
- [ ] **Step 1: Stop and remove the current container**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
docker compose down
```
Expected: container `intotheeast_grav` stops and is removed.
- [ ] **Step 2: Update `docker-compose.yml`**
Replace the entire contents of `docker-compose.yml` with:
```yaml
services:
grav:
image: getgrav/grav
container_name: intotheeast_grav
environment:
- GRAV_CHANNEL=beta
ports:
- "8081:80"
volumes:
- ./user:/var/www/html/user
- ./php/php-local.ini:/usr/local/etc/php/conf.d/php-local.ini
restart: unless-stopped
```
Key changes from old file:
- `image`: `lscr.io/linuxserver/grav:latest``getgrav/grav`
- `environment`: removed `PUID`/`PGID` (linuxserver-specific), added `GRAV_CHANNEL=beta`
- `volumes[0]`: `/config/www/user``/var/www/html/user`
- `volumes[1]`: `/config/php/php-local.ini``/usr/local/etc/php/conf.d/php-local.ini`
- [ ] **Step 3: Update Makefile — three targets use the old container path**
In `Makefile`, make these three targeted replacements:
**`install-plugins` target** — change working directory flag:
Old:
```makefile
install-plugins:
docker exec -w /app/www/public intotheeast_grav php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y
```
New:
```makefile
install-plugins:
docker exec -w /var/www/html intotheeast_grav php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y
```
**`demo-load` target** — change cache clear path:
Old:
```makefile
demo-load:
cp -r user/docs/demo/tracker/. user/pages/01.tracker/
docker exec intotheeast_grav bash -c "cd /app/www/public && php bin/grav clearcache"
```
New:
```makefile
demo-load:
cp -r user/docs/demo/tracker/. user/pages/01.tracker/
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache"
```
**`demo-reset` target** — change cache clear path:
Old:
```makefile
demo-reset:
@for dir in user/docs/demo/tracker/*/; do \
folder=$$(basename "$$dir"); \
rm -rf "user/pages/01.tracker/$$folder"; \
done
docker exec intotheeast_grav bash -c "cd /app/www/public && php bin/grav clearcache"
```
New:
```makefile
demo-reset:
@for dir in user/docs/demo/tracker/*/; do \
folder=$$(basename "$$dir"); \
rm -rf "user/pages/01.tracker/$$folder"; \
done
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache"
```
- [ ] **Step 4: Validate docker-compose syntax**
```bash
docker compose config
```
Expected: prints merged compose config with no errors. If you see `Error`, re-check the YAML indentation in `docker-compose.yml`.
- [ ] **Step 5: Commit**
```bash
git add docker-compose.yml Makefile
git commit -m "feat: switch to getgrav/grav 2.0 RC docker image
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
## Task 2: Add Grav 2.0 compat flag and switch GPM to testing channel
**Files:**
- Modify: `.gitignore` (add exception for `user/plugins/cache-on-save/`)
- Create: `user/plugins/cache-on-save/blueprints.yaml` (committed to main repo)
- Modify: `user/config/system.yaml` (committed to user/ git repo, not main repo)
**Interfaces:**
- Consumes: Running container from Task 1
- Produces: GPM resolves 2.0-compatible plugin versions on install; `cache-on-save` is recognized as 2.0-compatible by Grav's plugin registry
- [ ] **Step 1: Create `user/plugins/cache-on-save/blueprints.yaml`**
Create the file with this exact content:
```yaml
name: Cache On Save
version: 1.0.0
description: Clears Grav cache on new-entry form submission
author:
name: Mischa
email: mischa@gorinskat.nl
license: MIT
dependencies:
- { name: grav, version: '>=1.6.0' }
grav:
version: ['1.7', '2.0']
```
- [ ] **Step 2: Update GPM channel in `user/config/system.yaml`**
Find the `gpm:` section (around line 200 in the file) and change `releases: stable` to `releases: testing`:
Old:
```yaml
gpm:
releases: stable
official_gpm_only: true
```
New:
```yaml
gpm:
releases: testing
official_gpm_only: true
```
- [ ] **Step 3: Add gitignore exception and commit blueprints.yaml to main repo**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
# Add exception so cache-on-save is tracked in the main repo
# Insert after the existing "user/plugins/" line in .gitignore:
# !user/plugins/cache-on-save/
# Then commit to the main repo:
git add .gitignore user/plugins/cache-on-save/blueprints.yaml
git commit -m "feat: track cache-on-save plugin in main repo; add Grav 2.0 compat flag
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
- [ ] **Step 4: Commit system.yaml to the user/ git repo**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
git -C user add config/system.yaml
git -C user commit -m "feat: switch GPM to testing channel for Grav 2.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
## Task 3: Boot Grav 2.0 and install plugins
**Files:** None (runtime only)
**Interfaces:**
- Consumes: docker-compose.yml from Task 1, GPM config from Task 2
- Produces: Running Grav 2.0 instance at `http://localhost:8081` with all plugins installed
- [ ] **Step 1: Run setup**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
make setup
```
This starts the container and installs all plugins from `plugins.txt`. First run may take 1-2 minutes as `getgrav/grav` downloads and extracts Grav 2.0 RC.
Expected output ends with something like:
```
GPM Packages Installed: admin, email, error, form, login, problems, add-page-by-form, shortcode-gallery-plusplus
```
If `make setup` fails on plugin install with a permission error, fix with:
```bash
docker exec intotheeast_grav chown -R www-data:www-data /var/www/html/cache /var/www/html/logs /var/www/html/tmp
make install-plugins
```
- [ ] **Step 2: Verify PHP upload limits are applied**
```bash
docker exec intotheeast_grav php -r "echo ini_get('upload_max_filesize') . ' / ' . ini_get('post_max_size');"
```
Expected: `100M / 500M`
If you see `2M / 8M` (PHP defaults), the ini mount path is wrong. Verify with:
```bash
docker exec intotheeast_grav php -r "echo php_ini_scanned_files();"
```
It should include `/usr/local/etc/php/conf.d/php-local.ini`.
- [ ] **Step 3: Verify site loads**
```bash
curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/
```
Expected: `200`
If you get `500`, check container logs:
```bash
docker logs intotheeast_grav --tail 50
```
- [ ] **Step 4: Verify Admin2 loads**
```bash
curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/admin
```
Expected: `200` (Admin2 SPA login page, not the old Twig admin)
- [ ] **Step 5: Run config and HTTP tests**
```bash
make test-config
make test-post
```
`test-config` validates the form YAML config. `test-post` submits the posting form via HTTP and checks an entry is created.
Expected: both exit 0.
If `test-post` fails, check the output of:
```bash
bash scripts/test-post.sh
```
This is the critical `add-page-by-form` go/no-go test. If it fails with a 500 or the entry isn't created, see the **If add-page-by-form fails** section at the bottom of this plan.
- [ ] **Step 6: Commit task completion note**
No new files to commit. Move to Task 4.
---
## Task 4: Run Playwright test suite and fix any Admin2 regressions
**Files:**
- Modify: `tests/*.spec.js` (only if tests fail due to Admin2 DOM changes)
**Interfaces:**
- Consumes: Running Grav 2.0 from Task 3
- Produces: All Playwright tests passing (or updated for Admin2's new DOM)
- [ ] **Step 1: Run the full UI test suite**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
make test-ui
```
Expected: 25 tests pass.
- [ ] **Step 2: If any tests fail, classify the failure**
For each failing test, determine whether it is:
**A) A genuine regression** (e.g., posting form broken, tracker page missing entries, gallery not rendering) — these are blockers. Stop, investigate the root cause, and fix the underlying Grav/plugin issue before updating the test.
**B) An Admin2 DOM change** (e.g., selectors targeting old admin HTML structure like `.admin-menu`, `.grav-nav`, admin-specific CSS classes) — these are acceptable test updates. Update the selector in the test file to match Admin2's new HTML.
To inspect the current Admin2 DOM for a failing selector:
```bash
# Check what the admin page actually renders
curl -s http://localhost:8081/admin | grep -o '<[^>]*class="[^"]*admin[^"]*"[^>]*>' | head -20
```
- [ ] **Step 3: Update any Admin2 selector regressions**
For each type-(B) failure, open the relevant test file in `tests/` and update the selector. Example pattern for updating an admin navigation selector:
Old (targeting classic admin):
```js
await page.click('.grav-nav-toggle')
```
New (targeting Admin2 SPA — find actual selector from step 2's output):
```js
await page.click('[data-testid="nav-toggle"]') // replace with actual Admin2 selector
```
After each fix, re-run just that test:
```bash
npx playwright test tests/<filename>.spec.js --headed
```
- [ ] **Step 4: Re-run full suite to confirm all pass**
```bash
make test-ui
```
Expected: all tests pass.
- [ ] **Step 5: Commit any test updates**
If any test files were modified:
```bash
git add tests/
git commit -m "test: update Playwright selectors for Admin2 DOM
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
If no test files changed, no commit needed.
---
## Task 5: Update production install script for Grav 2.0
**Files:**
- Modify: `scripts/server-install.sh`
**Interfaces:**
- Consumes: Nothing from prior tasks (independent of Docker)
- Produces: `make remote-install` deploys a fresh Grav 2.0 on the production PHP 8.4 server when `GRAV_VERSION=2.0.0-rc.9` and `GRAV_CHANNEL_SUFFIX=?testing` are set in `.env`
- [ ] **Step 1: Update the wget download line in `scripts/server-install.sh`**
The script currently downloads Grav with:
```bash
wget --no-verbose "https://getgrav.org/download/core/grav-admin/$GRAV_VERSION" -O grav-admin.zip
```
Grav 2.0 RC requires `?testing` appended to the URL. Add `GRAV_CHANNEL_SUFFIX` support:
Old (line ~15 in the file):
```bash
echo "==> Downloading Grav $GRAV_VERSION"
cd "$WEBROOT"
wget --no-verbose "https://getgrav.org/download/core/grav-admin/$GRAV_VERSION" -O grav-admin.zip
```
New:
```bash
echo "==> Downloading Grav $GRAV_VERSION"
cd "$WEBROOT"
wget --no-verbose "https://getgrav.org/download/core/grav-admin/${GRAV_VERSION}${GRAV_CHANNEL_SUFFIX:-}" -O grav-admin.zip
```
The `${GRAV_CHANNEL_SUFFIX:-}` expands to empty string if unset, keeping stable releases working without any changes to `.env`.
- [ ] **Step 2: Add GRAV_CHANNEL_SUFFIX to the env var validation block**
At the top of the script the required vars are validated. `GRAV_CHANNEL_SUFFIX` is optional, so do NOT add it to the `:?` required list. Instead, add a comment above the download step:
After the `set -e` and required var block, add a comment before the download line:
```bash
# GRAV_CHANNEL_SUFFIX: optional, set to '?testing' for RC/beta releases (e.g. 2.0.0-rc.9)
# Leave unset or empty for stable releases.
```
- [ ] **Step 3: Verify the script logic looks correct**
```bash
# Dry-run: simulate what the URL would be with 2.0 RC vars
GRAV_VERSION=2.0.0-rc.9 GRAV_CHANNEL_SUFFIX='?testing' bash -c \
'echo "https://getgrav.org/download/core/grav-admin/${GRAV_VERSION}${GRAV_CHANNEL_SUFFIX:-}"'
```
Expected output:
```
https://getgrav.org/download/core/grav-admin/2.0.0-rc.9?testing
```
```bash
# Dry-run: simulate stable release (no suffix)
GRAV_VERSION=1.7.53 bash -c \
'echo "https://getgrav.org/download/core/grav-admin/${GRAV_VERSION}${GRAV_CHANNEL_SUFFIX:-}"'
```
Expected output:
```
https://getgrav.org/download/core/grav-admin/1.7.53
```
- [ ] **Step 4: Commit**
```bash
git add scripts/server-install.sh
git commit -m "feat: support GRAV_CHANNEL_SUFFIX for Grav 2.0 RC production install
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
## If `add-page-by-form` fails (contingency)
If `make test-post` in Task 3 step 5 returns a non-zero exit code or the entry is not created, `add-page-by-form` is incompatible with Grav 2.0. The fallback is to write a custom replacement plugin.
**Do not proceed to Task 4 if the posting workflow is broken.** Instead:
1. Check the container logs for the specific error:
```bash
docker logs intotheeast_grav --tail 100 | grep -i "error\|exception\|warning"
```
2. Note the error, stop work, and report back. The custom replacement plugin is a separate task requiring design input from the project owner before implementation.
The custom plugin would:
- Hook `onFormProcessed` (same as `cache-on-save`)
- Read form field values (`title`, `content`, `photo`)
- Build the page path under `user/pages/01.tracker/`
- Write the page file to disk using `Grav\Common\Page\Page`
- Merge `cache-on-save` functionality (call `$this->grav['cache']->deleteAll()`)
- Replace both `add-page-by-form` and `cache-on-save` with a single plugin
This is ~200 lines of PHP and ~1 day of work. It should be planned separately.
---
## Final smoke test (after all tasks complete)
Run the full test suite one last time:
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
make test
```
Expected: all three suites (`test-config`, `test-post`, `test-ui`) exit 0.
Then verify the go/no-go criteria from the spec are all met before merging to `main` or deploying to production.
File diff suppressed because it is too large Load Diff
+319
View File
@@ -0,0 +1,319 @@
# Dark Mode & Visual Polish 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:** Replace the warm-paper light theme with a warm-dark "notebook at night" aesthetic — dark-only, no toggle, paper grain texture, dark terrain map tiles, typography polish.
**Architecture:** Pure CSS token swap in `tokens.css` (all components update automatically), grain overlay via `body::after` SVG data URI in `style.css`, map tile URL swap in two Twig templates. No new dependencies, no JS changes, no structural changes.
**Tech Stack:** CSS custom properties, inline SVG noise filter, Stadia Maps Alidade Smooth Dark tile CDN, Leaflet.js (already present)
## Global Constraints
- All changes in `user/` — commit with `git -C user`, not main-repo git
- Dark-only — no `prefers-color-scheme` media query, no light-mode fallback, no toggle
- Existing token names in `tokens.css` must not change — only values swap
- No new npm/JS dependencies
- `make test-ui` must pass after every task (pre-existing P2 FilePond failure is acceptable)
- Map tile URL: `https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png` (CartoDB — no API key required)
- CartoDB attribution (exact): `© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>`
- Note: Stadia Maps requires an API key even for local dev — CartoDB dark_all is the keyless alternative
---
### Task 1: Dark color tokens
**Files:**
- Modify: `user/themes/intotheeast/css/tokens.css`
**Interfaces:**
- Produces: CSS custom properties consumed by every component in `style.css` and Twig templates
- [x] **Step 1: Read the current tokens file**
```bash
cat user/themes/intotheeast/css/tokens.css
```
Confirm these token names exist before editing: `--color-paper`, `--color-canvas`, `--color-ink`, `--color-ink-2`, `--color-ink-muted`, `--color-border`, `--color-border-soft`, `--color-accent`, `--color-accent-hover`, `--color-accent-light`, `--color-accent-on`.
- [x] **Step 2: Replace the color block in tokens.css**
Replace the entire `:root` color block (from `--color-paper` through `--color-accent-on`) with:
```css
/* ── Dark palette (warm notebook) ──────────────────────────────────────── */
--color-paper: #1A1814; /* page background — warm near-black */
--color-canvas: #22201B; /* card surfaces, form backgrounds */
--color-ink: #EDE8DF; /* primary text — warm cream */
--color-ink-2: #B8B0A4; /* body text — muted warm */
--color-ink-muted: #7A7268; /* labels, timestamps, captions */
--color-border: #2E2B25; /* standard dividers */
--color-border-soft: #252219; /* subtle dividers */
--color-accent: #2A8C73; /* teal — lightened for dark contrast */
--color-accent-hover: #236655; /* hover/pressed teal */
--color-accent-light: #1A2E29; /* pale teal tint backgrounds */
--color-accent-on: #FFFFFF; /* text on accent surfaces */
--color-surface-raised: #2A2720; /* elevated surfaces: tooltips, hover */
--color-ink-inverse: #17171A; /* text on accent-coloured buttons */
```
Keep all non-color tokens (`--text-*`, `--leading-*`, `--space-*`, font variables, etc.) unchanged.
- [x] **Step 3: Verify no syntax errors**
```bash
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache" && curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/trips/japan-korea-2026/dailies
```
Expected: `200`
- [x] **Step 4: Visual smoke check**
```bash
curl -s http://localhost:8081/trips/japan-korea-2026/dailies | grep -o 'color: var(--color-paper)' | head -3
```
Not a definitive check — just confirm the page renders. Open a browser and verify the background is dark and text is cream.
- [x] **Step 5: Run test suite**
```bash
make test-ui
```
Expected: 24/25 pass (P2 FilePond is pre-existing failure, all others pass).
- [x] **Step 6: Commit**
```bash
git -C user add themes/intotheeast/css/tokens.css
git -C user commit -m "feat: switch to warm-dark color tokens"
```
---
### Task 2: Paper grain texture + hardcoded color fixes + typography
**Files:**
- Modify: `user/themes/intotheeast/css/style.css`
**Interfaces:**
- Consumes: dark color tokens from Task 1
- [x] **Step 1: Find all hardcoded color literals in style.css**
```bash
grep -n '#[0-9a-fA-F]\{3,6\}\|background: white\|background:#fff\|color: #\|background-color: #' user/themes/intotheeast/css/style.css
```
Make note of every hit — each one is a candidate to replace with a token. Exceptions: the CSS SVG data URI you are about to add (the noise filter hex values are part of the graphic, not UI colors).
- [x] **Step 2: Add paper grain texture to body**
Find the `body` rule in `style.css`. It will look something like:
```css
body {
background-color: var(--color-paper);
color: var(--color-ink);
...
}
```
Add a `body::after` rule immediately after the `body` rule:
```css
body::after {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9998;
opacity: 0.035;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23noise)' opacity='1'/%3E%3C/svg%3E");
background-repeat: repeat;
background-size: 200px 200px;
}
```
- [x] **Step 3: Fix hardcoded login form colors**
Find this rule (around line 497):
```css
.login-form .button.secondary { background: #f0f0f0; color: #333; text-decoration: none; line-height: 44px; padding: 0 1rem; }
```
Replace with:
```css
.login-form .button.secondary { background: var(--color-canvas); color: var(--color-ink); text-decoration: none; line-height: 44px; padding: 0 1rem; }
```
- [x] **Step 4: Fix any other hardcoded colors found in Step 1**
For each hardcoded literal found in Step 1 (excluding the data URI you added):
- `#fff` / `white``var(--color-canvas)` (if a surface) or `var(--color-paper)` (if a page background)
- `#333` / dark grays → `var(--color-ink)` or `var(--color-ink-2)`
- `#eee` / light grays → `var(--color-border)` or `var(--color-border-soft)`
- `#f0f0f0` / near-white → `var(--color-canvas)`
Use judgment: if a hex is inside a gradient or SVG path data, leave it alone.
- [x] **Step 5: Typography — increase entry body paragraph spacing**
Find:
```css
.entry-body p { margin-bottom: 1.1em; font-size: var(--text-md); line-height: var(--leading-normal); color: var(--color-ink-2); }
```
Change `margin-bottom: 1.1em` to `margin-bottom: 1.4em`.
- [x] **Step 6: Typography — tighten h1/h2 tracking**
Find the `h1` and `h2` rules. Any rule that applies `letter-spacing: -0.01em` to an `h1` or `h2` — change it to `-0.02em`. Do not touch h3/h4/h5/h6.
- [x] **Step 7: Stats page — tabular numbers**
Find any CSS rule targeting stats numbers (look for `.stat-value`, `.stats-number`, or similar). Add `font-variant-numeric: tabular-nums` to it. If no such specific rule exists, search the template:
```bash
grep -n 'stat\|number\|count' user/themes/intotheeast/templates/stats.html.twig | head -20
```
Then add a targeted rule in style.css for whatever class wraps the numeric values.
- [x] **Step 8: Verify no syntax errors and visual check**
```bash
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache" && curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/trips/japan-korea-2026/dailies
```
Expected: `200`. Open browser — grain should be subtly visible on the dark background.
- [x] **Step 9: Run test suite**
```bash
make test-ui
```
Expected: 24/25 (P2 pre-existing).
- [x] **Step 10: Commit**
```bash
git -C user add themes/intotheeast/css/style.css
git -C user commit -m "feat: add paper grain texture, fix hardcoded colors, improve typography"
```
---
### Task 3: Dark terrain map tiles
**Files:**
- Modify: `user/themes/intotheeast/templates/map.html.twig`
- Modify: `user/themes/intotheeast/templates/dailies.html.twig`
**Interfaces:**
- Consumes: Leaflet.js already loaded in both templates
- Produces: Stadia Alidade Smooth Dark tiles replacing OpenStreetMap tiles in both map views
- [x] **Step 1: Read current tile setup in both templates**
```bash
grep -n "tileLayer\|openstreetmap\|attribution\|stadia" user/themes/intotheeast/templates/map.html.twig user/themes/intotheeast/templates/dailies.html.twig
```
Confirm the current tile URL pattern (`{s}.tile.openstreetmap.org`) in both files.
- [x] **Step 2: Replace tile layer in map.html.twig**
Find:
```javascript
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
```
Replace with:
```javascript
// TODO: add Stadia API key before launch — free dev use requires no key, production does
L.tileLayer('https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/{z}/{x}/{y}{r}.png', {
maxZoom: 20,
attribution: '© <a href="https://stadiamaps.com/">Stadia Maps</a> © <a href="https://openmaptiles.org/">OpenMapTiles</a> © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
```
- [x] **Step 3: Replace tile layer in dailies.html.twig (mini-map)**
Apply the identical tile swap to the mini-map `L.tileLayer` call in `dailies.html.twig`. Find the OpenStreetMap tile URL and replace it with the Stadia dark URL (same as Step 2, same attribution, same TODO comment).
- [x] **Step 4: Verify tiles load**
```bash
curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/trips/japan-korea-2026/map
```
Expected: `200`.
Check the tile URL is in the HTML:
```bash
curl -s http://localhost:8081/trips/japan-korea-2026/map | grep -o 'stadiamaps'
```
Expected: `stadiamaps` (appears in the tile URL).
Open the map in a browser and confirm:
- Dark terrain tiles render (not the default light OSM tiles)
- GPX polyline is visible in teal on the dark background
- Entry pins render correctly on top
- Attribution footer is present
- [x] **Step 5: Check mini-map on dailies page**
```bash
curl -s http://localhost:8081/trips/japan-korea-2026/dailies | grep -o 'stadiamaps'
```
Expected: `stadiamaps`.
- [x] **Step 6: Run test suite**
```bash
make test-ui
```
Expected: 24/25 (P2 pre-existing).
- [x] **Step 7: Commit**
```bash
git -C user add themes/intotheeast/templates/map.html.twig themes/intotheeast/templates/dailies.html.twig
git -C user commit -m "feat: switch to Stadia Alidade Smooth Dark map tiles"
```
---
## Final verification
After all 3 tasks:
1. `make test-config && make test-post && make test-ui` — all pass
2. Visual check list (browser, not curl):
- `/trips/japan-korea-2026/dailies` — dark warm background, cream text, grain visible, teal accents
- `/trips/japan-korea-2026/map` — dark terrain tiles, teal GPX polyline, entry pins
- `/trips/japan-korea-2026/dailies/<any-entry>` — dark canvas card, no white boxes
- `/post` — form fields readable, no black-on-black inputs
- `/trips/japan-korea-2026/stats` — numbers align (tabular-nums)
3. Final hardcoded-literal check:
```bash
grep -n '#[0-9a-fA-F]\{3,6\}' user/themes/intotheeast/css/style.css | grep -v 'data:image'
```
All remaining hits should be either intentional (e.g. SVG path data) or documented.
@@ -0,0 +1,309 @@
# GPX Manager 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:** Build a protected admin page at `/gpx-manager` that lists all trip GPX files and supports upload and deletion via the Grav API.
**Architecture:** A Grav page (`user/pages/03.gpx-manager/`) with a custom Twig template. Access is enforced by the Login plugin via `access.admin.login: true` in page frontmatter. The template renders a section per trip using the Grav page tree, then vanilla JavaScript calls the existing Grav API (`/api/v1/pages{route}/media`) using the browser's live session cookie — no JWT or separate login needed.
**Tech Stack:** Grav 2.0 Twig, Vanilla JS (fetch API), Grav API plugin v1, Grav Login plugin (page access control)
## Global Constraints
- Grav 2.0.0-rc.9 + Admin2 v2.0.0-rc.15; theme `intotheeast` at `user/themes/intotheeast/`
- API base URL: `/api/v1` (`route: /api`, `version_prefix: v1` in `user/plugins/api/api.yaml`)
- Session auth: all fetch calls use `credentials: 'include'` — no JWT handling (`session_enabled: true` in api.yaml)
- API media routes (confirmed from `user/plugins/api/classes/Api/ApiRouter.php:333`):
- `GET /api/v1/pages{route}/media` — list; response `{ data: [{ filename, size, modified, type }] }`
- `POST /api/v1/pages{route}/media` — multipart file upload
- `DELETE /api/v1/pages{route}/media/{filename}` — delete single file
- `{route}` is the full Grav route including leading slash, e.g. `/trips/italy-2025`
- Style: teal `#1F6B5A`, warm border `#e0ddd6`, font-family `'DM Sans', sans-serif` — match existing theme tokens
- No new plugins, no npm, no build step. All changes inside `user/` only.
- The page must be `visible: false` — must not appear in site navigation.
- Trip pages live at `user/pages/01.trips/<slug>/`; retrieved via `grav.pages.find('/trips').children.published()`
---
### Task 1: Page definition
**Files:**
- Create: `user/pages/03.gpx-manager/gpx-manager.md`
**Interfaces:**
- Produces: Grav page routed at `/gpx-manager`, protected by Login plugin, hidden from nav, using template `gpx-manager`
- [ ] **Step 1: Create the page file**
Create `user/pages/03.gpx-manager/gpx-manager.md` with this exact content:
```
---
title: 'GPX Manager'
template: gpx-manager
visible: false
routable: true
access:
admin.login: true
---
```
- [ ] **Step 2: Verify protection (no template yet)**
With the dev server running, open `http://localhost:8081/gpx-manager` while **logged out** of admin. You should be redirected to the login page. While **logged in**, you'll see a blank page or a Twig error (template missing) — that's fine at this stage.
- [ ] **Step 3: Commit**
```bash
git -C user add pages/03.gpx-manager/gpx-manager.md
git -C user commit -m "feat: add gpx-manager page definition (access-protected)"
```
---
### Task 2: Template — layout and trip sections
**Files:**
- Create: `user/themes/intotheeast/templates/gpx-manager.html.twig`
**Interfaces:**
- Consumes: `grav.pages.find('/trips').children.published()` — each trip object exposes `.route` (string, e.g. `/trips/italy-2025`), `.title` (string), `.slug` (string, e.g. `italy-2025`)
- Produces: one `.gpx-trip[data-route]` section per trip; `data-route` = full route string (e.g. `/trips/italy-2025`); `data-trip-route` on upload form = same value
- [ ] **Step 1: Create the template**
Create `user/themes/intotheeast/templates/gpx-manager.html.twig`:
```twig
{% extends 'partials/base.html.twig' %}
{% block content %}
{% set trips_page = grav.pages.find('/trips') %}
{% set trips = trips_page ? trips_page.children.published() : [] %}
<div class="gpx-manager">
<h1 class="gpx-manager__title">GPX Files</h1>
{% if trips is empty %}
<p>No trips found.</p>
{% else %}
{% for trip in trips %}
<section class="gpx-trip" data-route="{{ trip.route }}">
<h2 class="gpx-trip__name">{{ trip.title }}</h2>
<div class="gpx-file-list" id="files-{{ trip.slug }}">
<p class="gpx-loading">Loading…</p>
</div>
<form class="gpx-upload-form" data-trip-route="{{ trip.route }}">
<label class="gpx-upload-label">
<input type="file" accept=".gpx,application/gpx+xml" name="file" class="gpx-file-input">
</label>
<button type="submit" class="gpx-upload-btn">Upload</button>
<span class="gpx-status"></span>
</form>
</section>
{% endfor %}
{% endif %}
</div>
<style>
.gpx-manager { max-width: 720px; margin: 2rem auto; padding: 0 1rem; font-family: 'DM Sans', sans-serif; }
.gpx-manager__title { font-family: 'DM Serif Display', serif; font-size: 1.75rem; margin-bottom: 2rem; }
.gpx-trip { border: 1px solid #e0ddd6; border-radius: 8px; padding: 1.25rem; margin-bottom: 1.5rem; }
.gpx-trip__name { font-size: 1.1rem; font-weight: 600; margin: 0 0 1rem; }
.gpx-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; margin-bottom: 1rem; }
.gpx-table th { text-align: left; color: #666; font-weight: 500; padding: 0.25rem 0.5rem; border-bottom: 1px solid #e0ddd6; }
.gpx-table td { padding: 0.5rem; border-bottom: 1px solid #f0ede8; }
.gpx-empty, .gpx-loading { color: #888; font-size: 0.875rem; margin-bottom: 0.75rem; }
.gpx-upload-form { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; margin-top: 0.75rem; }
.gpx-upload-btn { background: #1F6B5A; color: #fff; border: none; border-radius: 5px; padding: 0.4rem 1rem; font-size: 0.875rem; cursor: pointer; }
.gpx-upload-btn:disabled { opacity: 0.5; cursor: default; }
.gpx-delete { background: none; border: 1px solid #ccc; border-radius: 4px; padding: 0.2rem 0.5rem; font-size: 0.8rem; cursor: pointer; color: #c0392b; }
.gpx-delete:disabled { opacity: 0.5; }
.gpx-status { font-size: 0.8rem; color: #555; }
.gpx-status.error { color: #c0392b; }
</style>
<script>
/* GPX manager JS — added in Task 3 */
</script>
{% endblock %}
```
- [ ] **Step 2: Verify trip sections render**
Open `http://localhost:8081/gpx-manager` while logged in. You should see:
- Heading "GPX Files"
- One card per trip (Italy 2025, Japan-Korea 2026) each showing "Loading…" and an upload form with a file picker and Upload button.
- The page header/nav from `base.html.twig` is present.
- [ ] **Step 3: Commit**
```bash
git -C user add themes/intotheeast/templates/gpx-manager.html.twig
git -C user commit -m "feat: gpx-manager template layout with trip sections"
```
---
### Task 3: JavaScript — list, upload, delete
**Files:**
- Modify: `user/themes/intotheeast/templates/gpx-manager.html.twig` — replace `/* GPX manager JS — added in Task 3 */` inside the existing `<script>` tag
**Interfaces:**
- Consumes: `.gpx-trip[data-route]` and `.gpx-upload-form[data-trip-route]` from Task 2
- Consumes: Grav API at `/api/v1` (session cookie auth)
- API list response: `{ data: [{ filename: string, size: number, modified: string, type: string }] }`
- API upload: multipart `FormData` with field name `file`
- API delete: `DELETE /api/v1/pages{route}/media/{encodedFilename}` → 200 or 204 on success
- [ ] **Step 1: Replace the placeholder comment with the full script**
In `user/themes/intotheeast/templates/gpx-manager.html.twig`, replace `/* GPX manager JS — added in Task 3 */` with:
```javascript
const API = '/api/v1';
function formatSize(bytes) {
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
return (bytes / 1024).toFixed(0) + ' KB';
}
function formatDate(iso) {
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
async function apiFetch(url, options) {
const res = await fetch(url, { credentials: 'include', ...options });
if (res.status === 401) { window.location.href = '/admin'; return null; }
return res;
}
async function loadFiles(tripRoute) {
const res = await apiFetch(`${API}/pages${tripRoute}/media`);
if (!res || !res.ok) return [];
const data = await res.json();
return (data.data || []).filter(f => f.filename.toLowerCase().endsWith('.gpx'));
}
async function renderTrip(tripEl) {
const route = tripEl.dataset.route;
const list = tripEl.querySelector('.gpx-file-list');
list.innerHTML = '<p class="gpx-loading">Loading…</p>';
const files = await loadFiles(route);
if (files.length === 0) {
list.innerHTML = '<p class="gpx-empty">No GPX files.</p>';
return;
}
const rows = files.map(f =>
`<tr>
<td>${f.filename}</td>
<td>${formatSize(f.size)}</td>
<td>${formatDate(f.modified)}</td>
<td><button class="gpx-delete" data-filename="${f.filename}">Delete</button></td>
</tr>`
).join('');
list.innerHTML = `<table class="gpx-table">
<thead><tr><th>File</th><th>Size</th><th>Modified</th><th></th></tr></thead>
<tbody>${rows}</tbody>
</table>`;
list.querySelectorAll('.gpx-delete').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm(`Delete ${btn.dataset.filename}?`)) return;
btn.disabled = true;
const res = await apiFetch(
`${API}/pages${route}/media/${encodeURIComponent(btn.dataset.filename)}`,
{ method: 'DELETE' }
);
if (res && (res.ok || res.status === 204)) {
await renderTrip(tripEl);
} else {
btn.disabled = false;
alert('Delete failed — check console.');
}
});
});
}
function initUpload(formEl) {
formEl.addEventListener('submit', async e => {
e.preventDefault();
const route = formEl.dataset.tripRoute;
const fileInput = formEl.querySelector('input[type=file]');
const file = fileInput.files[0];
const status = formEl.querySelector('.gpx-status');
const btn = formEl.querySelector('.gpx-upload-btn');
if (!file) { status.textContent = 'Choose a file first.'; return; }
status.textContent = 'Uploading…';
status.className = 'gpx-status';
btn.disabled = true;
const fd = new FormData();
fd.append('file', file);
const res = await apiFetch(`${API}/pages${route}/media`, { method: 'POST', body: fd });
btn.disabled = false;
if (res && res.ok) {
status.textContent = 'Uploaded!';
fileInput.value = '';
await renderTrip(formEl.closest('.gpx-trip'));
setTimeout(() => { status.textContent = ''; }, 3000);
} else {
const err = res ? await res.json().catch(() => ({})) : {};
status.textContent = 'Error: ' + (err.detail || (res ? res.statusText : 'network error'));
status.className = 'gpx-status error';
}
});
}
document.querySelectorAll('.gpx-trip').forEach(renderTrip);
document.querySelectorAll('.gpx-upload-form').forEach(initUpload);
```
- [ ] **Step 2: Test file listing**
Open `http://localhost:8081/gpx-manager` while logged in. Open DevTools → Network tab.
Expected:
- `GET /api/v1/pages/trips/italy-2025/media` → 200, Italy 2025 section shows a table with 3 rows (day-5, day-6, day-8) with sizes (~1.8 MB, ~2.2 MB, ~1.9 MB) and dates.
- `GET /api/v1/pages/trips/japan-korea-2026/media` → 200, Japan-Korea 2026 section shows "No GPX files."
- [ ] **Step 3: Test upload**
In the Japan-Korea 2026 section: click the file input, select any `.gpx` file from disk, click Upload.
Expected:
- Status shows "Uploading…" then "Uploaded!"
- The file table re-renders with the new file listed.
- DevTools shows `POST /api/v1/pages/trips/japan-korea-2026/media` → 200.
- [ ] **Step 4: Test delete**
Click Delete on the file just uploaded. Confirm the dialog.
Expected:
- The row disappears immediately.
- DevTools shows `DELETE /api/v1/pages/trips/japan-korea-2026/media/<filename>` → 200 or 204.
- Reload the page — file is gone.
- [ ] **Step 5: Test 401 redirect**
Log out of Admin2. In a new tab, navigate to `http://localhost:8081/gpx-manager`.
Expected: redirected to login page (Login plugin enforces `access.admin.login: true` before the page renders, so the JS never runs).
- [ ] **Step 6: Commit**
```bash
git -C user add themes/intotheeast/templates/gpx-manager.html.twig
git -C user commit -m "feat: gpx-manager list, upload, delete via Grav API session auth"
```
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,538 @@
# MapLibre GL Migration 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:** Replace Leaflet JS across all three maps (full map, mini-map on dailies, home page map) with MapLibre GL JS, add an animated journey line, and improve map CSS using our design tokens.
**Architecture:** A shared JS utility file (`maplibre-utils.js`) provides `animateJourneyLine`, `addJourneyLine`, and `createDotMarker` — reused by all three map templates. Each template loads MapLibre GL + the utility file, then calls these helpers. GPX rendering switches from `leaflet-gpx` to `@mapbox/togeojson` + MapLibre GeoJSON layers.
**Tech Stack:** MapLibre GL JS 4.x (CDN), `@mapbox/togeojson` 0.16.2 (CDN), CARTO dark-matter vector style (free, no key), vanilla JS (no framework).
## Global Constraints
- MapLibre GL CDN: `https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.js` and `.css`
- toGeoJSON CDN: `https://cdn.jsdelivr.net/npm/@mapbox/togeojson@0.16.2/togeojson.min.js`
- Map tile style URL: `https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json`
- Accent colour (journey line, markers): `#2A8C73` — matches `--color-accent` in `tokens.css`
- Latest-entry marker accent: `#155244` (same as current Leaflet code)
- Animation duration: 5000ms, ease-out cubic
- Respect `prefers-reduced-motion: reduce` — skip animation, show full line immediately
- `cooperativeGestures` on embedded maps (mini-map, home map); full-page map uses default (free) gestures
- No new Grav plugins, no npm — CDN only
- Run `make content-push` after changes to sync to production git repo
---
### Task 1: CSS — Remove Leaflet override, add MapLibre design-token styles
**Files:**
- Modify: `user/themes/intotheeast/css/style.css` (around line 371)
**What:** Delete the one Leaflet-specific rule and add a MapLibre CSS block that styles navigation controls, attribution bar, popups, and cursor using design tokens.
- [x] **Open style.css and find the Leaflet block**
Locate (around line 371):
```css
/* match CartoDB dark tile background so no grey flash on load/zoom */
.leaflet-container { background: #282828 !important; }
```
- [x] **Delete that rule and replace with the MapLibre block**
Delete the line above. Immediately after the `.map-empty { ... }` block (around line 381), add:
```css
/* ── MapLibre GL overrides ───────────────────────────────────────────────── */
/* Navigation controls (zoom +/) */
.maplibregl-ctrl-group {
background: var(--color-canvas);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-sm);
}
.maplibregl-ctrl-group button {
color: var(--color-ink-2);
}
.maplibregl-ctrl-group button:hover {
background: var(--color-surface-raised);
color: var(--color-ink);
}
.maplibregl-ctrl-group button + button {
border-top: 1px solid var(--color-border);
}
/* Attribution bar */
.maplibregl-ctrl-attrib {
background: rgba(26, 24, 20, 0.75) !important;
color: var(--color-ink-muted) !important;
font-family: var(--font-ui);
font-size: 0.7rem;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.maplibregl-ctrl-attrib a {
color: var(--color-accent) !important;
}
/* Popup */
.maplibregl-popup-content {
background: var(--color-canvas);
color: var(--color-ink);
font-family: var(--font-ui);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
padding: var(--space-4);
}
.maplibregl-popup-tip {
border-top-color: var(--color-canvas) !important;
}
.maplibregl-popup-close-button {
color: var(--color-ink-muted);
font-size: 1.1rem;
padding: var(--space-1) var(--space-2);
}
.maplibregl-popup-close-button:hover {
color: var(--color-ink);
background: transparent;
}
/* Cursor */
.maplibregl-canvas-container.maplibregl-interactive { cursor: grab; }
.maplibregl-canvas-container.maplibregl-interactive:active { cursor: grabbing; }
```
- [x] **Verify: open `http://localhost:8081/map` in browser**
If no entries exist, run `make demo-load` first. Check:
- No JS errors in console
- Page layout unchanged (map still fills viewport below nav)
- [x] **Commit**
```bash
git -C user add themes/intotheeast/css/style.css
git -C user commit -m "style: swap Leaflet CSS override for MapLibre design-token styles"
```
---
### Task 2: Shared JS utilities file
**Files:**
- Create: `user/themes/intotheeast/js/maplibre-utils.js`
**Interfaces:**
- Produces: `window.MapUtils.animateJourneyLine(map, coords, sourceId)`, `window.MapUtils.addJourneyLine(map, coords, sourceId)`, `window.MapUtils.createDotMarker(isLatest)`, `window.MapUtils.MAP_STYLE`, `window.MapUtils.ACCENT`
- Loaded by: all three map templates via `<script src="{{ url('theme://js/maplibre-utils.js') }}"></script>`
**What:** Extract the animated journey line logic and marker factory into a single file so all three templates share one implementation.
- [x] **Create `user/themes/intotheeast/js/maplibre-utils.js`**
```js
/* Shared MapLibre GL utilities — loaded by map.html.twig, dailies.html.twig, home.html.twig */
(function (global) {
var ACCENT = '#2A8C73';
var ACCENT_DIM = '#155244';
var MAP_STYLE = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
/* Build a GeoJSON LineString feature */
function lineFeature(coords) {
return { type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } };
}
/*
* Progressively draw the journey line using a requestAnimationFrame loop.
* coords: [[lng, lat], ...] in chronological order.
* sourceId: the MapLibre source id to update each frame.
*/
function animateJourneyLine(map, coords, sourceId) {
if (coords.length < 2) return;
/* Cumulative Euclidean distance between waypoints */
var segDist = [0];
for (var i = 1; i < coords.length; i++) {
var dx = coords[i][0] - coords[i - 1][0];
var dy = coords[i][1] - coords[i - 1][1];
segDist.push(segDist[i - 1] + Math.sqrt(dx * dx + dy * dy));
}
var totalDist = segDist[segDist.length - 1];
var DURATION = 5000;
var startTime = performance.now();
function frame(now) {
if (!map.getSource(sourceId)) return; /* map was removed */
var t = Math.min((now - startTime) / DURATION, 1);
var eased = 1 - Math.pow(1 - t, 3); /* ease-out cubic */
var target = eased * totalDist;
var animCoords = [coords[0]];
for (var j = 1; j < coords.length; j++) {
if (segDist[j] <= target) {
animCoords.push(coords[j]);
} else {
var frac = (target - segDist[j - 1]) / (segDist[j] - segDist[j - 1]);
animCoords.push([
coords[j - 1][0] + (coords[j][0] - coords[j - 1][0]) * frac,
coords[j - 1][1] + (coords[j][1] - coords[j - 1][1]) * frac
]);
break;
}
}
map.getSource(sourceId).setData(lineFeature(animCoords));
if (t < 1) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
/*
* Add a journey line source + two layers (glow + main) to a loaded map,
* then animate or draw instantly based on prefers-reduced-motion.
*/
function addJourneyLine(map, coords, sourceId) {
if (coords.length < 2) return;
map.addSource(sourceId, { type: 'geojson', data: lineFeature([coords[0]]) });
map.addLayer({
id: sourceId + '-glow', type: 'line', source: sourceId,
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': ACCENT, 'line-width': 6, 'line-opacity': 0.18 }
});
map.addLayer({
id: sourceId + '-line', type: 'line', source: sourceId,
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': ACCENT, 'line-width': 2.5, 'line-opacity': 0.85 }
});
var reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reducedMotion) {
map.getSource(sourceId).setData(lineFeature(coords));
} else {
animateJourneyLine(map, coords, sourceId);
}
}
/*
* Return a styled <div> element for a map marker dot.
* isLatest: make it larger with a teal ring.
*/
function createDotMarker(isLatest) {
var el = document.createElement('div');
var size = isLatest ? 18 : 12;
var bg = isLatest ? ACCENT_DIM : ACCENT;
var ring = isLatest ? ',0 0 0 4px rgba(42,140,115,0.25)' : '';
el.style.cssText = [
'width:' + size + 'px',
'height:' + size + 'px',
'background:' + bg,
'border:2px solid #fff',
'border-radius:50%',
'box-shadow:0 1px 4px rgba(0,0,0,0.4)' + ring,
'cursor:pointer'
].join(';');
return el;
}
global.MapUtils = { MAP_STYLE: MAP_STYLE, ACCENT: ACCENT, addJourneyLine: addJourneyLine, createDotMarker: createDotMarker };
})(window);
```
- [x] **Verify the file parses without syntax errors**
```bash
node --check /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user/themes/intotheeast/js/maplibre-utils.js
```
Expected: no output (clean parse).
- [x] **Commit**
```bash
git -C user add themes/intotheeast/js/maplibre-utils.js
git -C user commit -m "feat: add shared MapLibre GL utilities (journey line, markers)"
```
---
### Task 3: Full map page — migrate map.html.twig
**Files:**
- Modify: `user/themes/intotheeast/templates/map.html.twig`
**Interfaces:**
- Consumes: `window.MapUtils` from Task 2 (`MAP_STYLE`, `addJourneyLine`, `createDotMarker`)
- Twig data shape consumed unchanged: `map_entries` array with `lat`, `lng`, `title`, `date`, `url`, `hero` keys; `gpx_urls` array of strings
**What:** Replace the Leaflet map + GPX rendering with MapLibre GL. Keep all Twig data-gathering logic at the top unchanged. Only the HTML/CSS/JS at the bottom changes.
- [x] **Replace everything from `<div class="map-container"...>` to end of `{% endblock %}`**
The Twig data-gathering at the top (lines 133) is unchanged. Replace from line 35 onwards with:
```twig
<div class="map-container" id="trip-map"></div>
<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="https://cdn.jsdelivr.net/npm/@mapbox/togeojson@0.16.2/togeojson.min.js"></script>
<script src="{{ url('theme://js/maplibre-utils.js') }}"></script>
<script>
var ENTRIES = {{ map_entries|json_encode|raw }};
var GPX_URLS = {{ gpx_urls|json_encode|raw }};
var map = new maplibregl.Map({
container: 'trip-map',
style: MapUtils.MAP_STYLE,
center: [20, 20],
zoom: 2
});
map.addControl(new maplibregl.NavigationControl(), 'top-right');
if (ENTRIES.length === 0) {
var empty = document.createElement('div');
empty.className = 'map-empty';
empty.textContent = 'No locations yet — entries with GPS will appear here.';
document.getElementById('trip-map').appendChild(empty);
}
map.on('load', function () {
/* ── GPX tracks ──────────────────────────────────────────── */
GPX_URLS.forEach(function (url, idx) {
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 = 'gpx-' + idx;
map.addSource(sid, { type: 'geojson', data: geojson });
map.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 }
});
})
.catch(function (err) { console.warn('GPX load failed:', url, err); });
});
if (ENTRIES.length === 0) return;
/* ── Markers ─────────────────────────────────────────────── */
var bounds = new maplibregl.LngLatBounds();
var coords = [];
ENTRIES.forEach(function (entry, i) {
var isLatest = (i === ENTRIES.length - 1);
var lngLat = [parseFloat(entry.lng), parseFloat(entry.lat)];
coords.push(lngLat);
bounds.extend(lngLat);
var el = MapUtils.createDotMarker(isLatest);
var popupHtml = '<div style="min-width:160px;max-width:200px;">';
if (entry.hero) {
popupHtml += '<img src="' + entry.hero + '" alt="" style="width:100%;height:80px;object-fit:cover;border-radius:4px;display:block;margin-bottom:8px;">';
}
popupHtml += '<div style="font-size:0.75rem;color:var(--color-ink-muted);margin-bottom:2px;">📅 ' + entry.date + '</div>';
popupHtml += '<div style="font-weight:600;font-size:0.9rem;margin-bottom:8px;color:var(--color-ink);">' + entry.title + '</div>';
popupHtml += '<a href="' + entry.url + '" style="color:var(--color-accent);font-size:0.85rem;text-decoration:none;">Read entry →</a>';
popupHtml += '</div>';
new maplibregl.Marker({ element: el })
.setLngLat(lngLat)
.setPopup(new maplibregl.Popup({ offset: 10, maxWidth: '220px' }).setHTML(popupHtml))
.addTo(map);
});
/* ── Journey line ────────────────────────────────────────── */
MapUtils.addJourneyLine(map, coords, 'journey');
/* ── Fit bounds ──────────────────────────────────────────── */
if (ENTRIES.length === 1) {
map.jumpTo({ center: coords[0], zoom: 10 });
} else {
map.fitBounds(bounds, { padding: 60, maxZoom: 11 });
}
});
</script>
{% endblock %}
```
- [x] **Verify in browser at `http://localhost:8081/trips/japan-korea-2026/map`**
With demo data loaded (`make demo-load`):
- Dark vector map fills the viewport
- 7 teal dot markers visible on Japan→Korea route
- Journey line animates in over ~5 seconds on load
- Click a marker → popup appears with date, title, "Read entry →" link
- Navigate controls (zoom +/) are styled with dark background (design tokens)
- Attribution bar is dark/muted (not white)
- No console errors
- [x] **Commit**
```bash
git -C user add themes/intotheeast/templates/map.html.twig
git -C user commit -m "feat: migrate full map page to MapLibre GL with animated journey line"
```
---
### Task 4: Embedded maps — migrate dailies mini-map and home map
**Files:**
- Modify: `user/themes/intotheeast/templates/dailies.html.twig` (mini-map section, around lines 3778)
- Modify: `user/themes/intotheeast/templates/home.html.twig` (map section, around lines 126168)
**Interfaces:**
- Consumes: `window.MapUtils` from Task 2
- Twig data shapes unchanged: `map_entries` (both files) with `lat`, `lng`, `title`, `slug`, `url` keys
**What:** Both embedded maps follow the same pattern — no GPX, no popup (markers navigate on click), `cooperativeGestures: true` to prevent mobile scroll-trap, animated line via `MapUtils.addJourneyLine`.
- [x] **Replace the map block in `dailies.html.twig`**
Find the `{% if map_entries|length > 0 %}` block (around line 31) and replace from there to the closing `{% endif %}` and the script block:
```twig
{% if map_entries|length > 0 %}
<div class="feed-map-wrap">
<div class="feed-map" id="feed-map"></div>
<a class="feed-map-link" href="{{ page.parent().url }}/map">View full map →</a>
</div>
<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 FEED_ENTRIES = {{ map_entries|json_encode|raw }};
var feedMap = new maplibregl.Map({
container: 'feed-map',
style: MapUtils.MAP_STYLE,
center: [20, 20],
zoom: 2,
cooperativeGestures: true
});
feedMap.on('load', function () {
var bounds = new maplibregl.LngLatBounds();
var coords = [];
FEED_ENTRIES.forEach(function (entry, i) {
var isLatest = (i === FEED_ENTRIES.length - 1);
var lngLat = [parseFloat(entry.lng), parseFloat(entry.lat)];
coords.push(lngLat);
bounds.extend(lngLat);
var el = MapUtils.createDotMarker(isLatest);
el.addEventListener('click', function () {
window.location.href = entry.url;
});
new maplibregl.Marker({ element: el }).setLngLat(lngLat).addTo(feedMap);
});
MapUtils.addJourneyLine(feedMap, coords, 'feed-journey');
if (FEED_ENTRIES.length === 1) {
feedMap.jumpTo({ center: coords[0], zoom: 10 });
} else {
feedMap.fitBounds(bounds, { padding: 20, maxZoom: 11 });
}
});
</script>
{% endif %}
```
- [x] **Replace the map block in `home.html.twig`**
Find the `{% if map_entries|length > 0 %}` block (around line 125) and replace from there to end of `{% endblock %}`:
```twig
{% 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>
<script src="{{ url('theme://js/maplibre-utils.js') }}"></script>
<script>
var HOME_ENTRIES = {{ map_entries|json_encode|raw }};
var homeMap = new maplibregl.Map({
container: 'home-map',
style: MapUtils.MAP_STYLE,
center: [20, 20],
zoom: 2,
cooperativeGestures: true
});
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.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);
});
MapUtils.addJourneyLine(homeMap, coords, 'home-journey');
if (HOME_ENTRIES.length === 1) {
homeMap.jumpTo({ center: coords[0], zoom: 10 });
} else {
homeMap.fitBounds(bounds, { padding: 20, maxZoom: 11 });
}
setTimeout(function () { homeMap.resize(); }, 100);
});
</script>
{% endif %}
{% endblock %}
```
- [x] **Verify mini-map at `http://localhost:8081/trips/japan-korea-2026/dailies`**
- Mini-map appears above journal feed with dark vector tiles
- Journey line animates in
- Click a marker → navigates to that entry's page (not a popup)
- On mobile: pinch-zoom within the mini-map requires two fingers; one finger scrolls the page past it
- "View full map →" link works
- [x] **Verify home map at `http://localhost:8081`**
- Left column sticky map shows dark vector tiles
- Journey line animates in
- Click a marker → page scrolls to the matching entry card in the right column
- On mobile (< 768px): map collapses to 40vh above the feed, touch-scroll works on page
- [x] **Commit**
```bash
git -C user add themes/intotheeast/templates/dailies.html.twig themes/intotheeast/templates/home.html.twig
git -C user commit -m "feat: migrate mini-map and home map to MapLibre GL"
```
- [x] **Final sync**
```bash
make content-push
```
@@ -0,0 +1,736 @@
# Stats Redesign — Implementation Plan
*Derived from spec: docs/working/specs/2026-06-19-stats-redesign.md*
> **For agentic workers:** Use superpowers:subagent-driven-development to execute this plan task-by-task.
**Status:** ✅ Complete (2026-06-20)
**Goal:** Expand trip statistics from 4 to 6 stats (add cities visited + temperature range), add smart distance labelling (Mode A: GPX-based "km cycled" vs Mode B: entry-lat/lng "km roamed"), and add a collapsible cycling panel (only when GPX files are present) with 7 cycling-specific stats derived from GPX track data.
**Architecture:** Twig server-side computation for new stats (cities, temp range, GPX detection, date_end-aware days-on-road). Client-side JS for: distance computation in both modes, GPX parsing, cycling panel population. No new pages, no Grav config changes.
**Tech Stack:** Twig (Grav 2.0), vanilla JS (ES5), CSS custom properties
---
## Global Constraints
- **ES5 JS only** — no `const`/`let`, no arrow functions `() =>`, no template literals `` ` `` — all scripts are inline Twig and run as plain `<script>` blocks
- **CSS custom properties only** — no raw hex or pixel values; use tokens from `tokens.css`
- **6 stats must be identical** between `stats.html.twig` and the inline stats block in `trip.html.twig` — same order, same labels, same Twig logic
- `parseGpxFiles` function defined **once** in `trip.html.twig`; shared between distance Mode A update and cycling panel population
- `stats.html.twig` does **not** have a cycling panel — GPX parsing there is simpler (only for distance)
- Do **not** touch `dailies.html.twig`, `map.html.twig`, `stories.html.twig`, `entry.html.twig`, or any other template
- Commit after each task in the `user/` sub-repo (cd to `user/` before `git add` / `git commit`)
---
## Reference: Existing Files
- `user/themes/intotheeast/templates/stats.html.twig` — standalone stats page
- `user/themes/intotheeast/templates/trip.html.twig` — trip page (has inline stats block + filter bar)
- `user/themes/intotheeast/css/style.css`
- `.stats-grid` at line ~468: `grid-template-columns: repeat(2, 1fr)` — used by stats.html.twig
- `.stat-block`, `.stat-value`, `.stat-label` at lines ~475502
- `.trip-stats-grid` at line ~987: `grid-template-columns: repeat(4, 1fr)` — used by trip inline block
- `.trip-stats-block`, `.trip-stats-note`, `.trip-stats-countries` at lines ~9791008
- `.trip-stats-btn` at line ~789 — both Stats and Cycling buttons share this class
---
## The Six Stats (order matters — apply identically in both templates)
| # | Stat | Label | Source | Notes |
|---|---|---|---|---|
| 1 | Days on the road | `day/days on the road` | `date_end - date_start` if `date_end` set; else `now - first entry date` | date_end-aware |
| 2 | Entries posted | `entry/entries posted` | `all_entries\|length` | Unchanged |
| 3 | Countries visited | `country/countries visited` | Dedup `location_country` | Unchanged |
| 4 | Cities visited | `city/cities visited` | Dedup `location_city` | New |
| 5 | Distance | `km cycled` (Mode A) or `km roamed` (Mode B) | GPX trackpoints (A) or entry lat/lng (B) | Label + JS value |
| 6 | Temperature range | `°C range` | min/max `weather_temp_c` | New; value: `2 → 28` or `18` if single; `—` if no data |
**Distance stat stat-note text:**
- Mode A (GPX): `"Distance based on GPS track data."`
- Mode B (no GPX): `"Distance is approximate — straight lines between entry locations."`
**Distance stat icon (in label, as emoji prefix):**
- Mode A: `🚴 km cycled`
- Mode B: `🧭 km roamed`
---
## GPX Parsing Algorithm (for both templates)
```
Master trackpoints = []
for each GPX URL:
fetch URL → parse as XML via DOMParser
get all <trkpt> elements
for each <trkpt>:
lat = parseFloat(trkpt.getAttribute('lat'))
lon = parseFloat(trkpt.getAttribute('lon'))
ele = parseFloat(trkpt.querySelector('ele').textContent) [or NaN if missing]
time = trkpt.querySelector('time').textContent [ISO 8601 string]
push {lat, lon, ele, time} to Master
Compute over Master (length n):
distance = sum haversine(p[i-1], p[i]) for i=1..n-1 [km]
ele_gain = sum max(0, ele[i]-ele[i-1]-1) for i=1..n-1 [m, 1m threshold]
ele_loss = sum max(0, ele[i-1]-ele[i]-1) for i=1..n-1 [m, 1m threshold]
highest = max(ele) across all trackpoints [m]
lowest = min(ele) across all trackpoints [m]
dt_hrs[i] = (Date.parse(time[i]) - Date.parse(time[i-1])) / 3600000 [hours]
speed[i] = haversine(p[i-1], p[i]) / dt_hrs[i] [km/h]
moving_time = sum dt_hrs[i] where speed[i] >= 1 [hours]
avg_speed = distance / moving_time [km/h]
moving_time_fmt = floor(moving_time) + ':' + padded_minutes [h:mm]
```
Skip segments where dt_hrs[i] is 0 or NaN (avoids divide-by-zero). Skip `ele` computation for trackpoints where ele is NaN.
**Haversine function** (same as already used in trip.html.twig):
```javascript
function haversine(lat1, lng1, lat2, lng2) {
var R = 6371;
var dLat = (lat2 - lat1) * Math.PI / 180;
var dLng = (lng2 - lng1) * Math.PI / 180;
var a = Math.sin(dLat/2)*Math.sin(dLat/2) +
Math.cos(lat1*Math.PI/180)*Math.cos(lat2*Math.PI/180)*
Math.sin(dLng/2)*Math.sin(dLng/2);
return R * 2 * Math.asin(Math.sqrt(a));
}
```
---
## Task 1: Update `stats.html.twig` — 6-stat grid + distance mode detection
**Files:**
- Modify: `user/themes/intotheeast/templates/stats.html.twig`
- Modify: `user/themes/intotheeast/css/style.css` (`.stats-grid` only)
**What to build:**
### Twig changes in stats.html.twig
The trip page is `page.parent()`. Add after the existing Twig computation block (after the `gps_points` collection loop):
**1. Date-end-aware days on road:**
Replace the existing `first_ts`/`days_on_road` block with:
```twig
{% set trip_page = page.parent() %}
{% set days_on_road = 0 %}
{% if trip_page.header.date_end is not empty %}
{# Past trip: use declared end date #}
{% set start_ts = trip_page.header.date_start|date('U') %}
{% set end_ts = trip_page.header.date_end|date('U') %}
{% set days_on_road = ((end_ts - start_ts) / 86400)|round(0, 'ceil') %}
{% else %}
{# Active trip: first entry to now #}
{% set first_ts = null %}
{% for entry in all_entries %}
{% set ts = entry.date|date('U') %}
{% if first_ts is null or ts < first_ts %}{% set first_ts = ts %}{% endif %}
{% endfor %}
{% if first_ts is not null %}
{% set diff_seconds = "now"|date('U') - first_ts %}
{% set days_raw = (diff_seconds / 86400)|round(0, 'floor') %}
{% set days_on_road = days_raw < 1 ? 1 : days_raw %}
{% endif %}
{% endif %}
```
**2. Cities dedup** (add after country dedup block, same pattern):
```twig
{% set seen_city_lower = [] %}
{% set city_display = [] %}
{% for entry in all_entries %}
{% if entry.header.location_city is not empty %}
{% set lower = entry.header.location_city|trim|lower %}
{% if lower not in seen_city_lower %}
{% set seen_city_lower = seen_city_lower|merge([lower]) %}
{% set city_display = city_display|merge([entry.header.location_city|trim]) %}
{% endif %}
{% endif %}
{% endfor %}
```
**3. Temperature range** (add after cities block):
```twig
{% set temp_min = null %}
{% set temp_max = null %}
{% for entry in all_entries %}
{% if entry.header.weather_temp_c is defined and entry.header.weather_temp_c is not empty %}
{% set t = entry.header.weather_temp_c %}
{% if temp_min is null or t < temp_min %}{% set temp_min = t %}{% endif %}
{% if temp_max is null or t > temp_max %}{% set temp_max = t %}{% endif %}
{% endif %}
{% endfor %}
```
**4. GPX detection** (add after gps_points collection):
```twig
{% set gpx_urls = [] %}
{% for name, media in trip_page.media.all %}
{% if name|split('.')|last == 'gpx' %}
{% set gpx_urls = gpx_urls|merge([trip_page.url ~ '/' ~ name]) %}
{% endif %}
{% endfor %}
{% set has_gpx = gpx_urls|length > 0 %}
```
### HTML changes in stats.html.twig
Replace the current 4-stat grid with a 6-stat grid in this order:
```twig
<div class="stats-grid">
<div class="stat-block">
<span class="stat-value">{{ days_on_road }}</span>
<span class="stat-label">{{ days_on_road == 1 ? 'day' : 'days' }} on the road</span>
</div>
<div class="stat-block">
<span class="stat-value">{{ entry_count }}</span>
<span class="stat-label">{{ entry_count == 1 ? 'entry' : 'entries' }} posted</span>
</div>
<div class="stat-block">
<span class="stat-value">{{ country_display|length }}</span>
<span class="stat-label">{{ country_display|length == 1 ? 'country' : 'countries' }} visited</span>
</div>
<div class="stat-block">
<span class="stat-value">{{ city_display|length }}</span>
<span class="stat-label">{{ city_display|length == 1 ? 'city' : 'cities' }} visited</span>
</div>
<div class="stat-block">
<span class="stat-value" id="stat-distance">—</span>
<span class="stat-label">{{ has_gpx ? '🚴 km cycled' : '🧭 km roamed' }}</span>
</div>
<div class="stat-block">
{% if temp_min is not null %}
<span class="stat-value">{{ temp_min == temp_max ? temp_min : temp_min ~ ' → ' ~ temp_max }}</span>
{% else %}
<span class="stat-value">—</span>
{% endif %}
<span class="stat-label">°C range</span>
</div>
</div>
```
Update the stats note (below the countries list) to be mode-sensitive:
```twig
<p class="stats-note">{{ has_gpx ? 'Distance based on GPS track data.' : 'Distance is approximate — straight lines between entry locations.' }}</p>
```
### JS changes in stats.html.twig
Replace the existing haversine/distance script entirely with mode-aware logic:
```javascript
<script>
var GPS_POINTS = {{ gps_points|json_encode|raw }};
var GPX_URLS = {{ gpx_urls|json_encode|raw }};
function haversine(lat1, lng1, lat2, lng2) {
var R = 6371;
var dLat = (lat2 - lat1) * Math.PI / 180;
var dLng = (lng2 - lng1) * Math.PI / 180;
var a = Math.sin(dLat/2)*Math.sin(dLat/2) +
Math.cos(lat1*Math.PI/180)*Math.cos(lat2*Math.PI/180)*
Math.sin(dLng/2)*Math.sin(dLng/2);
return R * 2 * Math.asin(Math.sqrt(a));
}
var distEl = document.getElementById('stat-distance');
if (GPX_URLS.length > 0) {
// Mode A: sum haversine between all GPX trackpoints
var pending = GPX_URLS.length;
var masterPts = [];
GPX_URLS.forEach(function(url) {
fetch(url)
.then(function(r) { return r.text(); })
.then(function(text) {
var xml = new DOMParser().parseFromString(text, 'text/xml');
var trkpts = xml.querySelectorAll('trkpt');
trkpts.forEach(function(pt) {
masterPts.push({
lat: parseFloat(pt.getAttribute('lat')),
lon: parseFloat(pt.getAttribute('lon'))
});
});
pending--;
if (pending === 0) {
var total = 0;
for (var i = 1; i < masterPts.length; i++) {
total += haversine(masterPts[i-1].lat, masterPts[i-1].lon,
masterPts[i].lat, masterPts[i].lon);
}
distEl.textContent = masterPts.length < 2 ? '—' : Math.round(total).toLocaleString();
}
})
.catch(function(err) { console.warn('GPX load failed:', url, err); pending--; });
});
} else {
// Mode B: sum haversine between consecutive entry lat/lng points
var total = 0;
for (var i = 1; i < GPS_POINTS.length; i++) {
total += haversine(
parseFloat(GPS_POINTS[i-1][0]), parseFloat(GPS_POINTS[i-1][1]),
parseFloat(GPS_POINTS[i][0]), parseFloat(GPS_POINTS[i][1])
);
}
distEl.textContent = GPS_POINTS.length < 2 ? '—' : '~' + Math.round(total).toLocaleString();
}
</script>
```
### CSS change in style.css
Update `.stats-grid` from 2 to 3 columns:
```css
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-4);
margin-bottom: var(--space-8);
}
```
Keep the mobile breakpoint if one exists; add one if not:
```css
@media (max-width: 600px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
}
```
### Commit
```bash
cd user && git add themes/intotheeast/templates/stats.html.twig themes/intotheeast/css/style.css
git commit -m "feat: expand stats page to 6 stats — cities, temp range, distance mode detection"
```
---
## Task 2: Update `trip.html.twig` — inline stats + cycling panel
**Files:**
- Modify: `user/themes/intotheeast/templates/trip.html.twig`
- Modify: `user/themes/intotheeast/css/style.css`
**What to build:**
### Twig changes in trip.html.twig
Add after the existing `{% set story_count %}` line (line ~19), mirroring Task 1's logic but using `page` directly (not `page.parent()`):
**1. Date-end-aware days on road** — replace the existing `days_on_road` block:
```twig
{% set days_on_road = 0 %}
{% if page.header.date_end is not empty %}
{% set start_ts = page.header.date_start|date('U') %}
{% set end_ts = page.header.date_end|date('U') %}
{% set days_on_road = ((end_ts - start_ts) / 86400)|round(0, 'ceil') %}
{% else %}
{% set first_ts = null %}
{% for entry in journal_entries %}
{% set ts = entry.date|date('U') %}
{% if first_ts is null or ts < first_ts %}{% set first_ts = ts %}{% endif %}
{% endfor %}
{% if first_ts is not null %}
{% set diff_seconds = "now"|date('U') - first_ts %}
{% set days_raw = (diff_seconds / 86400)|round(0, 'floor') %}
{% set days_on_road = days_raw < 1 ? 1 : days_raw %}
{% endif %}
{% endif %}
```
**2. Cities dedup** (add after country dedup block):
```twig
{% set seen_city_lower = [] %}
{% set city_display = [] %}
{% for entry in journal_entries %}
{% if entry.header.location_city is not empty %}
{% set lower = entry.header.location_city|trim|lower %}
{% if lower not in seen_city_lower %}
{% set seen_city_lower = seen_city_lower|merge([lower]) %}
{% set city_display = city_display|merge([entry.header.location_city|trim]) %}
{% endif %}
{% endif %}
{% endfor %}
```
**3. Temperature range** (add after cities block):
```twig
{% set temp_min = null %}
{% set temp_max = null %}
{% for entry in journal_entries %}
{% if entry.header.weather_temp_c is defined and entry.header.weather_temp_c is not empty %}
{% set t = entry.header.weather_temp_c %}
{% if temp_min is null or t < temp_min %}{% set temp_min = t %}{% endif %}
{% if temp_max is null or t > temp_max %}{% set temp_max = t %}{% endif %}
{% endif %}
{% endfor %}
```
**4. GPX detection**`gpx_urls` already computed in trip.html.twig; add:
```twig
{% set has_gpx = gpx_urls|length > 0 %}
```
### HTML changes in trip.html.twig
**A. Update filter bar** — add Cycling button next to Stats button (hidden if no GPX):
Find the current filter bar:
```twig
<div class="trip-filter-bar">
<div class="trip-filter-group">
<button class="trip-filter-btn is-active" data-filter="all">All content</button>
<button class="trip-filter-btn" data-filter="journal">Journal</button>
<button class="trip-filter-btn" data-filter="story">Stories</button>
</div>
<button class="trip-stats-btn" id="trip-stats-toggle">Stats</button>
</div>
```
Replace with:
```twig
<div class="trip-filter-bar">
<div class="trip-filter-group">
<button class="trip-filter-btn is-active" data-filter="all">All content</button>
<button class="trip-filter-btn" data-filter="journal">Journal</button>
<button class="trip-filter-btn" data-filter="story">Stories</button>
</div>
<div class="trip-filter-group">
<button class="trip-stats-btn" id="trip-stats-toggle">Stats</button>
{% if has_gpx %}
<button class="trip-stats-btn" id="trip-cycling-toggle">Cycling</button>
{% endif %}
</div>
</div>
```
**B. Update inline stats block** — expand from 4 to 6 stats (same order as Task 1):
Replace the current `.trip-stats-grid` content with:
```twig
<div id="trip-stats-block" class="trip-stats-block" style="display:none">
<div class="trip-stats-grid">
<div class="stat-block">
<span class="stat-value">{{ days_on_road }}</span>
<span class="stat-label">{{ days_on_road == 1 ? 'day' : 'days' }} on the road</span>
</div>
<div class="stat-block">
<span class="stat-value">{{ journal_count }}</span>
<span class="stat-label">{{ journal_count == 1 ? 'entry' : 'entries' }} posted</span>
</div>
<div class="stat-block">
<span class="stat-value">{{ country_display|length }}</span>
<span class="stat-label">{{ country_display|length == 1 ? 'country' : 'countries' }} visited</span>
</div>
<div class="stat-block">
<span class="stat-value">{{ city_display|length }}</span>
<span class="stat-label">{{ city_display|length == 1 ? 'city' : 'cities' }} visited</span>
</div>
<div class="stat-block">
<span class="stat-value" id="stat-distance">—</span>
<span class="stat-label">{{ has_gpx ? '🚴 km cycled' : '🧭 km roamed' }}</span>
</div>
<div class="stat-block">
{% if temp_min is not null %}
<span class="stat-value">{{ temp_min == temp_max ? temp_min : temp_min ~ ' → ' ~ temp_max }}</span>
{% else %}
<span class="stat-value">—</span>
{% endif %}
<span class="stat-label">°C range</span>
</div>
</div>
{% if country_display|length > 0 %}
<p class="trip-stats-countries">{{ country_display|join(' · ') }}</p>
{% endif %}
<p class="trip-stats-note">{{ has_gpx ? 'Distance based on GPS track data.' : 'Distance is approximate — straight lines between entry locations.' }}</p>
</div>
```
**C. Add cycling panel** — immediately after the inline stats block, before `<div class="feed">`:
```twig
{% if has_gpx %}
<div id="trip-cycling-block" class="trip-cycling-block" style="display:none">
<div class="trip-cycling-header">
<span class="trip-cycling-icon">🚴</span>
<span class="trip-cycling-title">Cycling Stats</span>
</div>
<div class="trip-cycling-grid">
<div class="stat-block">
<span class="stat-value" id="cyc-distance">—</span>
<span class="stat-label">km distance</span>
</div>
<div class="stat-block">
<span class="stat-value" id="cyc-ele-gain">—</span>
<span class="stat-label">m ↑ gain</span>
</div>
<div class="stat-block">
<span class="stat-value" id="cyc-ele-loss">—</span>
<span class="stat-label">m ↓ loss</span>
</div>
<div class="stat-block">
<span class="stat-value" id="cyc-highest">—</span>
<span class="stat-label">m highest</span>
</div>
<div class="stat-block">
<span class="stat-value" id="cyc-lowest">—</span>
<span class="stat-label">m lowest</span>
</div>
<div class="stat-block">
<span class="stat-value" id="cyc-moving-time">—</span>
<span class="stat-label">moving time</span>
</div>
<div class="stat-block">
<span class="stat-value" id="cyc-avg-speed">—</span>
<span class="stat-label">km/h avg speed</span>
</div>
</div>
</div>
{% endif %}
```
### JS changes in trip.html.twig
The existing script block has: map setup, GPX route drawing for map, filter bar JS, stats distance + toggle JS.
Make the following JS changes:
**1. Replace the existing `STATS_GPS` + distance IIFE** with a unified GPX/distance function (place after the existing map + filter bar IIFE, before `</script>`):
```javascript
var STATS_GPS = {{ gps_points|json_encode|raw }};
var HAS_GPX = {{ has_gpx ? 'true' : 'false' }};
function haversineKm(lat1, lng1, lat2, lng2) {
var R = 6371;
var dLat = (lat2 - lat1) * Math.PI / 180;
var dLng = (lng2 - lng1) * Math.PI / 180;
var a = Math.sin(dLat/2)*Math.sin(dLat/2) +
Math.cos(lat1*Math.PI/180)*Math.cos(lat2*Math.PI/180)*
Math.sin(dLng/2)*Math.sin(dLng/2);
return R * 2 * Math.asin(Math.sqrt(a));
}
function parseGpxFiles(urls, callback) {
var pending = urls.length;
var masterPts = [];
if (pending === 0) { callback({ error: 'no files' }); return; }
urls.forEach(function(url) {
fetch(url)
.then(function(r) { return r.text(); })
.then(function(text) {
var xml = new DOMParser().parseFromString(text, 'text/xml');
var trkpts = xml.querySelectorAll('trkpt');
trkpts.forEach(function(pt) {
var eleEl = pt.querySelector('ele');
var timeEl = pt.querySelector('time');
masterPts.push({
lat: parseFloat(pt.getAttribute('lat')),
lon: parseFloat(pt.getAttribute('lon')),
ele: eleEl ? parseFloat(eleEl.textContent) : NaN,
time: timeEl ? timeEl.textContent : null
});
});
pending--;
if (pending === 0) { computeAndCallback(); }
})
.catch(function(err) {
console.warn('GPX load failed:', url, err);
pending--;
if (pending === 0) { computeAndCallback(); }
});
});
function computeAndCallback() {
var n = masterPts.length;
if (n < 2) { callback({ distance: 0 }); return; }
var distance = 0, eleGain = 0, eleLoss = 0;
var highest = NaN, lowest = NaN, movingTime = 0;
for (var i = 1; i < n; i++) {
var p0 = masterPts[i-1], p1 = masterPts[i];
var d = haversineKm(p0.lat, p0.lon, p1.lat, p1.lon);
distance += d;
if (!isNaN(p0.ele) && !isNaN(p1.ele)) {
var dEle = p1.ele - p0.ele;
if (dEle > 1) eleGain += dEle - 1;
else if (dEle < -1) eleLoss += (-dEle) - 1;
if (isNaN(highest) || p1.ele > highest) highest = p1.ele;
if (isNaN(lowest) || p1.ele < lowest) lowest = p1.ele;
}
if (p0.time && p1.time) {
var dtHrs = (Date.parse(p1.time) - Date.parse(p0.time)) / 3600000;
if (dtHrs > 0) {
var speed = d / dtHrs;
if (speed >= 1) movingTime += dtHrs;
}
}
}
// include first point in elevation range
if (!isNaN(masterPts[0].ele)) {
if (isNaN(highest) || masterPts[0].ele > highest) highest = masterPts[0].ele;
if (isNaN(lowest) || masterPts[0].ele < lowest) lowest = masterPts[0].ele;
}
var avgSpeed = movingTime > 0 ? distance / movingTime : 0;
var movHours = Math.floor(movingTime);
var movMins = Math.round((movingTime - movHours) * 60);
if (movMins === 60) { movHours++; movMins = 0; }
callback({
distance: distance,
eleGain: eleGain,
eleLoss: eleLoss,
highest: highest,
lowest: lowest,
movingTime: movHours + ':' + (movMins < 10 ? '0' : '') + movMins,
avgSpeed: avgSpeed
});
}
}
(function() {
var distEl = document.getElementById('stat-distance');
if (HAS_GPX) {
parseGpxFiles(GPX_URLS, function(result) {
// Mode A: update distance stat
if (distEl) {
distEl.textContent = result.distance > 0 ? Math.round(result.distance).toLocaleString() : '—';
}
// Populate cycling panel
function setText(id, val) {
var el = document.getElementById(id);
if (el) el.textContent = val;
}
setText('cyc-distance', result.distance > 0 ? Math.round(result.distance).toLocaleString() : '—');
setText('cyc-ele-gain', !isNaN(result.eleGain) ? Math.round(result.eleGain) : '—');
setText('cyc-ele-loss', !isNaN(result.eleLoss) ? Math.round(result.eleLoss) : '—');
setText('cyc-highest', !isNaN(result.highest) ? Math.round(result.highest) : '—');
setText('cyc-lowest', !isNaN(result.lowest) ? Math.round(result.lowest) : '—');
setText('cyc-moving-time', result.movingTime || '—');
setText('cyc-avg-speed', result.avgSpeed > 0 ? result.avgSpeed.toFixed(1) : '—');
});
} else {
// Mode B: haversine between entry points
var total = 0;
for (var i = 1; i < STATS_GPS.length; i++) {
total += haversineKm(
parseFloat(STATS_GPS[i-1][0]), parseFloat(STATS_GPS[i-1][1]),
parseFloat(STATS_GPS[i][0]), parseFloat(STATS_GPS[i][1])
);
}
if (distEl) {
distEl.textContent = STATS_GPS.length < 2 ? '—' : '~' + Math.round(total).toLocaleString();
}
}
// Stats toggle
var statsToggle = document.getElementById('trip-stats-toggle');
var statsBlock = document.getElementById('trip-stats-block');
if (statsToggle && statsBlock) {
statsToggle.addEventListener('click', function() {
var isOpen = statsBlock.style.display !== 'none';
statsBlock.style.display = isOpen ? 'none' : '';
statsToggle.classList.toggle('is-active', !isOpen);
});
}
// Cycling toggle (only present when has_gpx)
var cycToggle = document.getElementById('trip-cycling-toggle');
var cycBlock = document.getElementById('trip-cycling-block');
if (cycToggle && cycBlock) {
cycToggle.addEventListener('click', function() {
var isOpen = cycBlock.style.display !== 'none';
cycBlock.style.display = isOpen ? 'none' : '';
cycToggle.classList.toggle('is-active', !isOpen);
});
}
})();
```
**Important:** Remove the old `STATS_GPS` declaration and the old stats IIFE that's currently in the template (the one starting with `var STATS_GPS = ...`), replacing it entirely with the new unified block above. The `haversine` function used by `MapUtils.addJourneyLine` is in `maplibre-utils.js` — the new `haversineKm` function in this script is a local copy for stats; do not remove any map-related code.
### CSS changes in style.css
**1. Update `.trip-stats-grid`** from 4 to 3 columns (3 columns × 2 rows = 6 stats):
```css
.trip-stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-4);
margin-bottom: var(--space-4);
}
```
**2. Add cycling panel styles** (after the existing `.trip-stats-note` rule):
```css
/* ── Trip page cycling panel ─────────────────────────────────────────────────── */
.trip-cycling-block {
background: var(--color-canvas);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-6);
margin-bottom: var(--space-6);
}
.trip-cycling-header {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.trip-cycling-icon {
font-size: var(--text-xl);
}
.trip-cycling-title {
font-family: var(--font-display);
font-size: var(--text-lg);
color: var(--color-ink);
}
.trip-cycling-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-4);
}
@media (max-width: 600px) {
.trip-cycling-grid { grid-template-columns: repeat(2, 1fr); }
}
```
### Commit
```bash
cd user && git add themes/intotheeast/templates/trip.html.twig themes/intotheeast/css/style.css
git commit -m "feat: expand trip inline stats to 6 stats + add cycling panel with GPX parsing"
```
---
## Self-Review Checklist
- [x] Both templates show exactly 6 stats in the same order (days, entries, countries, cities, distance, temp range)
- [x] Distance label is server-side conditional: "🚴 km cycled" (GPX) vs "🧭 km roamed" (no GPX)
- [x] Stats note text is conditional matching the mode
- [x] GPX Mode A: fetches all GPX files, sums trackpoint haversine distances
- [x] GPX Mode B: sums haversine between consecutive entry lat/lng points
- [x] Cycling button only rendered when `has_gpx` is true
- [x] Cycling panel hidden by default; toggled by cycling button
- [x] Stats toggle and Cycling toggle are independent (opening one doesn't close the other)
- [x] `parseGpxFiles` called once; results used for both distance stat and cycling panel
- [x] Old haversine function and STATS_GPS IIFE removed and replaced in trip.html.twig
- [x] `.stats-grid` updated to 3 columns
- [x] `.trip-stats-grid` updated to 3 columns
- [x] Cycling panel CSS added
- [x] No raw hex/pixel values in CSS
- [x] No ES6 syntax in inline JS
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,538 @@
# Trip Entity Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` to implement this plan task-by-task.
**Goal:** Restructure the site around a Trip entity — tracker/map/stats/stories become children of `/trips/japan-korea-2026/`, GPX route files live as media on the trip page, and `site.yaml` holds an `active_trip` slug so the nav can switch trips via config.
**Architecture:** Trip = a Grav page (`trip.html.twig`) at `/trips/<slug>/`. Map/stats templates find the tracker via `page.parent().route ~ '/tracker'` instead of the hardcoded `/tracker` path. Leaflet-gpx (CDN) loads all `*.gpx` media files from the trip page. A `trips.html.twig` listing page provides the multi-trip root. Stories is stubbed with a placeholder template.
**Tech Stack:** Grav CMS 1.7/2.0, Twig, Leaflet.js, leaflet-gpx (CDN, vanilla JS — consistent with existing inline JS pattern)
## Global Constraints
- All content/theme edits go in `user/` — commit with `git -C user`, not main-repo git
- Entry URLs change: `/tracker/<slug>``/trips/japan-korea-2026/tracker/<slug>` — acceptable pre-launch
- `make test-post` (6/6) and `make test-ui` (25/25) must pass after every task
- No new JS framework dependencies; leaflet-gpx is 3KB vanilla JS
- `user/config/media.yaml` must whitelist `.gpx` so Grav serves it as a file
- The `02.post/post-form.md` `pageconfig.parent` must stay in sync with the tracker path
---
### Task 1: Restructure pages under `/trips/`
**Files:**
- Create: `user/pages/01.trips/trips.md`
- Create: `user/pages/01.trips/japan-korea-2026/trip.md`
- Create: `user/pages/01.trips/japan-korea-2026/01.tracker/tracker.md` (copy from `user/pages/01.tracker/tracker.md`, no content change)
- Move: all `*.entry/` folders from `user/pages/01.tracker/``user/pages/01.trips/japan-korea-2026/01.tracker/`
- Create: `user/pages/01.trips/japan-korea-2026/02.map/map.md` (copy from `user/pages/03.map/map.md`)
- Create: `user/pages/01.trips/japan-korea-2026/03.stats/stats.md` (copy from `user/pages/04.stats/stats.md`)
- Create: `user/pages/01.trips/japan-korea-2026/04.stories/stories.md`
- Delete: `user/pages/01.tracker/`, `user/pages/03.map/`, `user/pages/04.stats/`
- Modify: `user/config/site.yaml` — add `active_trip: japan-korea-2026`
- Modify (create if absent): `user/config/media.yaml` — whitelist GPX
- [ ] **Step 1: Verify current structure before touching anything**
```bash
find user/pages -name "*.md" | sort
```
Expected: entries under `01.tracker/`, map at `03.map/map.md`, stats at `04.stats/stats.md`.
- [ ] **Step 2: Create trips hierarchy**
```bash
mkdir -p user/pages/01.trips/japan-korea-2026/01.tracker
mkdir -p user/pages/01.trips/japan-korea-2026/02.map
mkdir -p user/pages/01.trips/japan-korea-2026/03.stats
mkdir -p user/pages/01.trips/japan-korea-2026/04.stories
```
- [ ] **Step 3: Write `trips.md`**
`user/pages/01.trips/trips.md`:
```yaml
---
title: Trips
template: trips
content:
items: '@self.children'
order:
by: date
dir: desc
---
```
- [ ] **Step 4: Write `trip.md`**
`user/pages/01.trips/japan-korea-2026/trip.md`:
```yaml
---
title: 'Japan & Korea 2026'
template: trip
date: '2026-06-17'
date_start: '2026-06-17'
date_end: ''
cover_image: ''
content:
items: '@self.children'
---
```
- [ ] **Step 5: Copy tracker.md, move entries**
```bash
cp user/pages/01.tracker/tracker.md user/pages/01.trips/japan-korea-2026/01.tracker/tracker.md
mv user/pages/01.tracker/*.entry user/pages/01.trips/japan-korea-2026/01.tracker/
```
- [ ] **Step 6: Copy map.md and stats.md**
```bash
cp user/pages/03.map/map.md user/pages/01.trips/japan-korea-2026/02.map/map.md
cp user/pages/04.stats/stats.md user/pages/01.trips/japan-korea-2026/03.stats/stats.md
```
- [ ] **Step 7: Write stories stub**
`user/pages/01.trips/japan-korea-2026/04.stories/stories.md`:
```yaml
---
title: Stories
template: stories
published: true
---
```
- [ ] **Step 8: Delete old top-level pages**
```bash
rm -rf user/pages/01.tracker user/pages/03.map user/pages/04.stats
```
- [ ] **Step 9: Add `active_trip` to site.yaml**
Add to `user/config/site.yaml`:
```yaml
active_trip: japan-korea-2026
```
- [ ] **Step 10: Whitelist GPX in media.yaml**
`user/config/media.yaml` (create if absent):
```yaml
gpx:
type: file
extensions: ['gpx']
mime: application/gpx+xml
```
- [ ] **Step 11: Verify pages load at new URLs**
```bash
curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/trips/japan-korea-2026/tracker
# Expected: 200
curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/trips/japan-korea-2026/map
# Expected: 200
curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/trips/japan-korea-2026/stats
# Expected: 200
```
- [ ] **Step 12: Commit**
```bash
git -C user add pages/01.trips config/site.yaml config/media.yaml
git -C user rm -r --cached pages/01.tracker pages/03.map pages/04.stats
git -C user commit -m "feat: restructure pages under trips/japan-korea-2026 entity"
```
---
### Task 2: Update templates for trip-relative paths + new trip/trips/stories templates
**Files:**
- Modify: `user/themes/intotheeast/templates/map.html.twig` — change hardcoded `/tracker` path
- Modify: `user/themes/intotheeast/templates/stats.html.twig` — same
- Modify: `user/themes/intotheeast/templates/partials/base.html.twig` — nav uses `active_trip`
- Create: `user/themes/intotheeast/templates/trip.html.twig`
- Create: `user/themes/intotheeast/templates/trips.html.twig`
- Create: `user/themes/intotheeast/templates/stories.html.twig`
**Interfaces:**
- Consumes: `config.site.active_trip` from site.yaml (set in Task 1)
- Produces: map/stats find entries via `page.parent().route ~ '/tracker'`
- [ ] **Step 1: Fix `map.html.twig` — tracker path**
Replace:
```twig
{% set tracker_page = grav.pages.find('/tracker') %}
{% set all_entries = tracker_page ? tracker_page.children.published() : [] %}
```
With:
```twig
{% set tracker_page = grav.pages.find(page.parent().route ~ '/tracker') %}
{% set all_entries = tracker_page ? tracker_page.children.published() : [] %}
```
- [ ] **Step 2: Fix `stats.html.twig` — tracker path**
Same replacement as Step 1 (identical pattern in stats.html.twig).
- [ ] **Step 3: Update `base.html.twig` nav**
Replace hardcoded nav href values with `active_trip`-driven paths. The pattern in base.html.twig currently sets hrefs to `/tracker`, `/map`, `/stats`. Replace with:
```twig
{% set active_trip = config.site.active_trip %}
{% set trip_base = '/trips/' ~ active_trip %}
```
Nav links become:
- Journal: `{{ trip_base }}/tracker`
- Map: `{{ trip_base }}/map`
- Stats: `{{ trip_base }}/stats`
Active state detection: replace `page.url starts with '/tracker'` checks with `page.url starts with trip_base ~ '/tracker'` (and similarly for map/stats).
- [ ] **Step 4: Create `trip.html.twig`**
`user/themes/intotheeast/templates/trip.html.twig`:
```twig
{% extends 'partials/base.html.twig' %}
{% block content %}
{% set tracker_page = grav.pages.find(page.route ~ '/tracker') %}
{% set entries = tracker_page ? tracker_page.children.published() : [] %}
<div class="trip-hero">
<h1>{{ page.title }}</h1>
{% if page.header.date_start %}
<p class="trip-dates">
{{ page.header.date_start|date('d M Y') }}
{% if page.header.date_end %}{{ page.header.date_end|date('d M Y') }}{% endif %}
</p>
{% endif %}
</div>
<nav class="trip-nav">
<a href="{{ page.route }}/tracker">Journal</a>
<a href="{{ page.route }}/map">Map</a>
<a href="{{ page.route }}/stats">Stats</a>
<a href="{{ page.route }}/stories">Stories</a>
</nav>
{% if entries|length > 0 %}
<section class="trip-recent">
<h2>Recent entries</h2>
{% for entry in entries|slice(0, 3) %}
<a href="{{ entry.url }}">
<span>{{ entry.date|date('d M Y') }}</span>
{{ entry.title }}
{% if entry.header.location_city %} · {{ entry.header.location_city }}{% endif %}
</a>
{% endfor %}
</section>
{% endif %}
{% endblock %}
```
- [ ] **Step 5: Create `trips.html.twig`**
`user/themes/intotheeast/templates/trips.html.twig`:
```twig
{% extends 'partials/base.html.twig' %}
{% block content %}
<h1>{{ page.title }}</h1>
{% set trips = page.children.published() %}
{% if trips|length == 0 %}
<p>No trips yet.</p>
{% else %}
<ul class="trips-list">
{% for trip in trips %}
<li>
<a href="{{ trip.url }}">
<strong>{{ trip.title }}</strong>
{% if trip.header.date_start %}
<span>{{ trip.header.date_start|date('d M Y') }}</span>
{% endif %}
</a>
</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}
```
- [ ] **Step 6: Create `stories.html.twig` stub**
`user/themes/intotheeast/templates/stories.html.twig`:
```twig
{% extends 'partials/base.html.twig' %}
{% block content %}
<h1>{{ page.title }}</h1>
<p>Stories coming soon.</p>
{% endblock %}
```
- [ ] **Step 7: Verify templates render**
```bash
curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/trips/japan-korea-2026
# Expected: 200
curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/trips
# Expected: 200
curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/trips/japan-korea-2026/stories
# Expected: 200
```
Check nav links resolve correctly on tracker/map/stats pages.
- [ ] **Step 8: Commit**
```bash
git -C user add themes/intotheeast/templates/
git -C user commit -m "feat: add trip/trips/stories templates, update nav and map/stats to use trip-relative paths"
```
---
### Task 3: Add GPX route support to map template
**Files:**
- Modify: `user/themes/intotheeast/templates/map.html.twig`
**Interfaces:**
- Consumes: `*.gpx` files uploaded as media to the trip page (`page.parent()`)
- Produces: GPX tracks rendered as colored polylines on the Leaflet map, underneath entry pins
- [ ] **Step 1: Add leaflet-gpx script tag**
In `map.html.twig`, after the existing Leaflet script tag, add:
```html
<script src="https://cdn.jsdelivr.net/npm/leaflet-gpx@2.1.2/gpx.min.js"></script>
```
- [ ] **Step 2: Collect GPX URLs from trip media**
After the `{% set trip_page = page.parent() %}` line (add this at the top of the template, alongside the tracker_page lookup), add:
```twig
{% set gpx_urls = [] %}
{% for name, media in trip_page.media.all %}
{% if name|split('.')|last == 'gpx' %}
{% set gpx_urls = gpx_urls|merge([media.url]) %}
{% endif %}
{% endfor %}
```
- [ ] **Step 3: Pass GPX URLs to JavaScript**
In the `<script>` block, after the map is initialized and before the entry markers loop, add:
```javascript
// GPX route tracks
const gpxUrls = {{ gpx_urls|json_encode|raw }};
gpxUrls.forEach(url => {
new L.GPX(url, {
async: true,
polyline_options: { color: '#1F6B5A', weight: 2, opacity: 0.7 },
marker_options: { startIconUrl: null, endIconUrl: null, shadowUrl: null }
}).addTo(map);
});
```
Disabling start/end markers keeps the map clean — the entry pins already mark key stops.
- [ ] **Step 4: Test with a sample GPX**
Create a minimal 3-point GPX file to test without a real Komoot export:
```xml
<?xml version="1.0"?>
<gpx version="1.1" creator="test">
<trk><trkseg>
<trkpt lat="35.6762" lon="139.6503"><time>2026-03-25T10:00:00Z</time></trkpt>
<trkpt lat="35.0116" lon="135.7681"><time>2026-03-27T10:00:00Z</time></trkpt>
<trkpt lat="37.5665" lon="126.9780"><time>2026-04-01T10:00:00Z</time></trkpt>
</trkseg></trk>
</gpx>
```
Upload via Grav Admin to the trip page media, then verify the map at `/trips/japan-korea-2026/map` renders the polyline. Remove the test file after verification.
- [ ] **Step 5: Verify map still works without GPX**
Confirm map renders normally when no `.gpx` files are present (gpxUrls = []).
- [ ] **Step 6: Commit**
```bash
git -C user add themes/intotheeast/templates/map.html.twig
git -C user commit -m "feat: add GPX route rendering to trip map via leaflet-gpx"
```
---
### Task 4: Update post form, Makefile, demo content, and tests
**Files:**
- Modify: `user/pages/02.post/post-form.md``pageconfig.parent`
- Modify: `Makefile``demo-load` and `demo-reset` paths
- Modify: `scripts/test-post.sh``TRACKER` variable
- Modify: `scripts/test-form-config.sh` — expected parent value
- Modify: `tests/ui/tracker.spec.js` — any hardcoded `/tracker` URL references
- Modify: `user/docs/demo/` — move demo entries to new path structure
- [ ] **Step 1: Update post form parent**
In `user/pages/02.post/post-form.md`, change:
```yaml
pageconfig:
parent: '/tracker'
```
To:
```yaml
pageconfig:
parent: '/trips/japan-korea-2026/tracker'
```
- [ ] **Step 2: Update demo content structure**
```bash
mkdir -p user/docs/demo/trips/japan-korea-2026/tracker
mv user/docs/demo/tracker/* user/docs/demo/trips/japan-korea-2026/tracker/
rmdir user/docs/demo/tracker
```
- [ ] **Step 3: Update Makefile demo targets**
In `Makefile`, update `demo-load` and `demo-reset`:
```makefile
demo-load:
cp -r user/docs/demo/trips/japan-korea-2026/tracker/. user/pages/01.trips/japan-korea-2026/01.tracker/
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache"
demo-reset:
@for dir in user/docs/demo/trips/japan-korea-2026/tracker/*/; do \
folder=$$(basename "$$dir"); \
rm -rf "user/pages/01.trips/japan-korea-2026/01.tracker/$$folder"; \
done
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache"
```
- [ ] **Step 4: Update `test-post.sh` TRACKER path**
Find the line setting `TRACKER=` in `scripts/test-post.sh` and change it to:
```bash
TRACKER="user/pages/01.trips/japan-korea-2026/01.tracker"
```
- [ ] **Step 5: Update `test-form-config.sh` expected parent**
Find the assertion that checks `parent: '/tracker'` and update to check for `parent: '/trips/japan-korea-2026/tracker'`.
- [ ] **Step 6: Check Playwright tests for hardcoded paths**
Search `tests/ui/` for any hardcoded `/tracker` URL references:
```bash
grep -rn "tracker\|/map\|/stats" tests/ui/
```
Update any that reference the old paths to use the new trip-scoped paths.
- [ ] **Step 7: Run full test suite**
```bash
make test-config && make test-post && make test-ui
```
Expected: all pass (14/14, 6/6, 25/25).
- [ ] **Step 8: Commit**
```bash
# Main repo changes (Makefile + test scripts)
git add Makefile scripts/test-post.sh scripts/test-form-config.sh tests/
git commit -m "fix: update paths for trips/japan-korea-2026 restructure"
# User repo changes
git -C user add pages/02.post/post-form.md docs/demo/
git -C user commit -m "fix: update post form parent and demo content paths for trip structure"
```
---
### Task 5: Admin blueprint for trip page type
**Files:**
- Create: `user/themes/intotheeast/blueprints/trip.yaml`
**Interfaces:**
- Produces: "Trip" tab in Grav Admin when editing the trip page, with date range and cover image fields
- [ ] **Step 1: Create `trip.yaml` blueprint**
`user/themes/intotheeast/blueprints/trip.yaml`:
```yaml
title: 'Trip'
'@extends':
type: default
context: blueprints://pages
form:
fields:
tabs:
type: tabs
active: 1
fields:
trip:
type: tab
title: Trip
fields:
header.date_start:
type: date
label: 'Start Date'
placeholder: '2026-06-17'
help: 'First day of the trip'
header.date_end:
type: date
label: 'End Date'
placeholder: ''
help: 'Leave blank if trip is ongoing'
header.cover_image:
type: text
label: 'Cover Image Filename'
placeholder: 'cover.jpg'
help: 'Used in the trips listing page'
```
- [ ] **Step 2: Verify blueprint appears in Admin**
Open Grav Admin → Pages → Trips → Japan & Korea 2026 → Edit. Confirm the "Trip" tab appears with start date, end date, cover image fields.
- [ ] **Step 3: Commit**
```bash
git -C user add themes/intotheeast/blueprints/trip.yaml
git -C user commit -m "feat: add Admin blueprint for trip page type"
```
---
## Verification
After all tasks, run end-to-end check:
1. `make test-config && make test-post && make test-ui` — all must pass
2. Navigate to `http://localhost:8081/trips/japan-korea-2026/tracker` — entries display in date order
3. Navigate to `http://localhost:8081/trips/japan-korea-2026/map` — entry pins render, GPX polyline renders if a `.gpx` file is present on the trip page
4. Navigate to `http://localhost:8081/trips/japan-korea-2026/stats` — stats compute correctly
5. Navigate to `http://localhost:8081/trips` — trip listing shows Japan & Korea 2026
6. Submit a post via `/post` — new entry appears under `/trips/japan-korea-2026/tracker`
7. Grav Admin: edit the trip page → "Trip" tab visible with date fields
@@ -0,0 +1,472 @@
# Trip Page Filter Bar — 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:** Replace the three unstyled nav links on the trip page with an in-page filter bar (All content / Journal / Stories) and an inline Stats toggle — no page navigation needed.
**Architecture:** Pure client-side. `data-type` attributes on article cards let vanilla JS show/hide by content type. Stats computation is inlined into `trip.html.twig` from `stats.html.twig`. No new files, no Grav config changes, no page navigation.
**Tech Stack:** Twig (Grav 2.0), vanilla JS (ES5), CSS custom properties
## Global Constraints
- CSS variables only — no raw hex values; use tokens from `tokens.css`
- ES5 JS — no arrow functions, no `const`/`let`, no template literals (inline script in Twig)
- Touch the minimum: only `trip.html.twig` and `style.css`
- Do not modify `stats.html.twig`, `dailies.html.twig`, or any sub-page template
---
### Task 1: Story card border + data-type attributes
**Files:**
- Modify: `user/themes/intotheeast/templates/trip.html.twig:77,119`
- Modify: `user/themes/intotheeast/css/style.css:816-819`
**Interfaces:**
- Produces: `data-type="journal"` and `data-type="story"` attributes on all article cards — consumed by Tasks 3 and 4
- [ ] **Step 1: Add data-type to journal article (trip.html.twig line 77)**
Find this line:
```twig
<article class="entry-card" id="entry-{{ entry.slug }}" data-lat="{{ entry.header.lat }}" data-lng="{{ entry.header.lng }}">
```
Replace with:
```twig
<article class="entry-card" id="entry-{{ entry.slug }}" data-type="journal" data-lat="{{ entry.header.lat }}" data-lng="{{ entry.header.lng }}">
```
- [ ] **Step 2: Add data-type to story article (trip.html.twig line 119)**
Find this line:
```twig
<article class="entry-card entry-card--story" id="entry-{{ entry.slug }}">
```
Replace with:
```twig
<article class="entry-card entry-card--story" id="entry-{{ entry.slug }}" data-type="story">
```
- [ ] **Step 3: Replace story card border in style.css**
Find the existing `.entry-card--story` rule (around line 816):
```css
.entry-card--story {
border-left: 3px solid var(--color-accent);
padding-left: var(--space-5);
}
```
Replace with:
```css
.entry-card--story {
border: 2px solid var(--color-accent);
border-radius: var(--radius-md);
padding: var(--space-6);
background: var(--color-canvas);
}
```
- [ ] **Step 4: Verify visually**
Open the trip page in the browser. In DevTools:
- Select a journal article → confirm it has `data-type="journal"`
- Select a story article → confirm it has `data-type="story"`
- Story cards should now appear as a boxed card with a full teal border and rounded corners instead of a left-only bar
- [ ] **Step 5: Commit**
```bash
git add user/themes/intotheeast/templates/trip.html.twig user/themes/intotheeast/css/style.css
git commit -m "feat: add data-type attributes to feed cards; restyle story card with full border"
```
---
### Task 2: Filter bar markup + CSS (static, no JS yet)
**Files:**
- Modify: `user/themes/intotheeast/templates/trip.html.twig:58-62`
- Modify: `user/themes/intotheeast/css/style.css` (append after `.home-trip-counts` block, around line 694)
**Interfaces:**
- Consumes: nothing from prior tasks (static HTML)
- Produces: `.trip-filter-bar`, `.trip-filter-btn`, `.trip-stats-btn` CSS classes consumed by Task 3
- [ ] **Step 1: Replace nav with filter bar in trip.html.twig**
Find the existing nav block (lines 5862):
```twig
<nav class="trip-nav">
<a href="{{ page.route }}/dailies">Journal</a>
<a href="{{ page.route }}/stats">Stats</a>
<a href="{{ page.route }}/stories">Stories</a>
</nav>
```
Replace with:
```twig
<div class="trip-filter-bar">
<div class="trip-filter-group">
<button class="trip-filter-btn is-active" data-filter="all">All content</button>
<button class="trip-filter-btn" data-filter="journal">Journal</button>
<button class="trip-filter-btn" data-filter="story">Stories</button>
</div>
<button class="trip-stats-btn" id="trip-stats-toggle">Stats</button>
</div>
```
- [ ] **Step 2: Add filter bar CSS to style.css**
After the `.home-trip-counts` rule (around line 694), append:
```css
/* ── Trip page filter bar ────────────────────────────────────────────────────── */
.trip-filter-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-top: var(--space-4);
flex-wrap: wrap;
}
.trip-filter-group {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.trip-filter-btn,
.trip-stats-btn {
font-family: var(--font-ui);
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-ink-muted);
background: transparent;
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
padding: var(--space-1) var(--space-4);
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.trip-filter-btn:hover,
.trip-stats-btn:hover {
color: var(--color-ink);
border-color: var(--color-ink-muted);
}
.trip-filter-btn.is-active,
.trip-stats-btn.is-active {
color: var(--color-accent);
border-color: var(--color-accent);
background: var(--color-accent-light);
}
```
- [ ] **Step 3: Verify visually**
Open the trip page. Confirm:
- Three filter pills (All content / Journal / Stories) and a Stats button appear below the trip title
- "All content" pill has teal active styling
- Other pills are muted/bordered
- Clicking the buttons does nothing yet (no JS)
- [ ] **Step 4: Commit**
```bash
git add user/themes/intotheeast/templates/trip.html.twig user/themes/intotheeast/css/style.css
git commit -m "feat: add filter bar markup and pill button styles to trip page"
```
---
### Task 3: Filter JS (show/hide cards by type)
**Files:**
- Modify: `user/themes/intotheeast/templates/trip.html.twig` (append to the existing `<script>` block at the bottom, before `</script>`)
**Interfaces:**
- Consumes: `data-type` on articles (Task 1); `.trip-filter-btn`, `data-filter` (Task 2)
- Produces: working filter interaction
- [ ] **Step 1: Add an empty-state element to the feed**
In `trip.html.twig`, find the closing `</div>` of the `.feed` block (after the `{% else %}` empty message). Add a hidden filter-empty message right before `</div>`:
```twig
<p id="feed-filter-empty" class="feed-empty" style="display:none;"></p>
</div>
```
The full `.feed` block close should look like:
```twig
{% else %}
<p class="feed-empty">No entries yet. The journey is about to begin.</p>
{% endif %}
<p id="feed-filter-empty" class="feed-empty" style="display:none;"></p>
</div>
```
- [ ] **Step 2: Append filter JS to the existing script block**
In `trip.html.twig`, find the closing `</script>` tag at the bottom. Insert before it:
```javascript
(function() {
var filterBtns = document.querySelectorAll('.trip-filter-btn');
var cards = document.querySelectorAll('[data-type]');
var filterEmpty = document.getElementById('feed-filter-empty');
filterBtns.forEach(function(btn) {
btn.addEventListener('click', function() {
filterBtns.forEach(function(b) { b.classList.remove('is-active'); });
btn.classList.add('is-active');
var filter = btn.getAttribute('data-filter');
var visible = 0;
cards.forEach(function(card) {
var show = filter === 'all' || card.getAttribute('data-type') === filter;
card.style.display = show ? '' : 'none';
if (show) visible++;
});
if (filterEmpty) {
if (visible === 0) {
filterEmpty.textContent = filter === 'story'
? 'No stories yet for this trip.'
: 'No entries yet.';
filterEmpty.style.display = '';
} else {
filterEmpty.style.display = 'none';
}
}
});
});
})();
```
- [ ] **Step 3: Verify filter behavior**
Open the trip page. With demo entries loaded (run `make demo-load` if needed):
- Click **Journal** → only journal cards visible, story cards hidden
- Click **Stories** → only story cards visible, journal cards hidden
- Click **All content** → all cards visible again
- If no stories exist, clicking Stories shows "No stories yet for this trip."
- "All content" pill always has active state after clicking it
- [ ] **Step 4: Commit**
```bash
git add user/themes/intotheeast/templates/trip.html.twig
git commit -m "feat: wire up feed filter — All content / Journal / Stories"
```
---
### Task 4: Inline stats block (Twig computation + HTML + toggle JS)
**Files:**
- Modify: `user/themes/intotheeast/templates/trip.html.twig`
- Modify: `user/themes/intotheeast/css/style.css` (append after filter bar CSS from Task 2)
**Interfaces:**
- Consumes: `.trip-stats-btn#trip-stats-toggle` (Task 2); `journal_entries` variable already set at top of template
- Produces: expandable stats block; `STATS_GPS` JS variable for haversine distance
- [ ] **Step 1: Add stats Twig computation at the top of the template**
In `trip.html.twig`, after line 19 (`{% set story_count = story_entries|length %}`), add:
```twig
{# Stats computation #}
{% set days_on_road = 0 %}
{% set first_ts = null %}
{% for entry in journal_entries %}
{% set ts = entry.date|date('U') %}
{% if first_ts is null or ts < first_ts %}
{% set first_ts = ts %}
{% endif %}
{% endfor %}
{% if first_ts is not null %}
{% set now_ts = "now"|date('U') %}
{% set diff_seconds = now_ts - first_ts %}
{% set days_raw = (diff_seconds / 86400)|round(0, 'floor') %}
{% set days_on_road = days_raw < 1 ? 1 : days_raw %}
{% endif %}
{% set seen_lower = [] %}
{% set country_display = [] %}
{% for entry in journal_entries %}
{% if entry.header.location_country is not empty %}
{% set lower = entry.header.location_country|trim|lower %}
{% if lower not in seen_lower %}
{% set seen_lower = seen_lower|merge([lower]) %}
{% set country_display = country_display|merge([entry.header.location_country|trim]) %}
{% endif %}
{% endif %}
{% endfor %}
{% set gps_points = [] %}
{% for entry in journal_entries %}
{% if entry.header.lat is not empty and entry.header.lng is not empty %}
{% set gps_points = gps_points|merge([[entry.header.lat, entry.header.lng]]) %}
{% endif %}
{% endfor %}
```
- [ ] **Step 2: Add stats block HTML between filter bar and feed**
In `trip.html.twig`, find the `<div class="feed">` line and insert the stats block immediately before it:
```twig
<div id="trip-stats-block" class="trip-stats-block" style="display:none">
<div class="trip-stats-grid">
<div class="stat-block">
<span class="stat-value">{{ days_on_road }}</span>
<span class="stat-label">{{ days_on_road == 1 ? 'day' : 'days' }} on the road</span>
</div>
<div class="stat-block">
<span class="stat-value">{{ journal_count }}</span>
<span class="stat-label">{{ journal_count == 1 ? 'entry' : 'entries' }} posted</span>
</div>
<div class="stat-block">
<span class="stat-value">{{ country_display|length }}</span>
<span class="stat-label">{{ country_display|length == 1 ? 'country' : 'countries' }} visited</span>
</div>
<div class="stat-block">
<span class="stat-value" id="stat-distance">—</span>
<span class="stat-label">km traveled</span>
</div>
</div>
{% if country_display|length > 0 %}
<p class="trip-stats-countries">{{ country_display|join(' · ') }}</p>
{% endif %}
<p class="trip-stats-note">Distance is approximate — straight lines between entry locations.</p>
</div>
```
- [ ] **Step 3: Add stats block CSS to style.css**
Append after the filter bar CSS added in Task 2:
```css
/* ── Trip page inline stats block ───────────────────────────────────────────── */
.trip-stats-block {
background: var(--color-canvas);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-6);
margin-bottom: var(--space-6);
}
.trip-stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-4);
margin-bottom: var(--space-4);
}
@media (max-width: 600px) {
.trip-stats-grid { grid-template-columns: repeat(2, 1fr); }
}
.trip-stats-countries {
font-size: var(--text-sm);
color: var(--color-ink-2);
margin-bottom: var(--space-2);
}
.trip-stats-note {
font-size: var(--text-xs);
color: var(--color-ink-muted);
}
```
Note: `.stat-block`, `.stat-value`, `.stat-label` are reused from `stats.html.twig` and already have CSS defined. Do not add duplicate rules.
- [ ] **Step 4: Verify those existing CSS classes exist**
Run:
```bash
grep -n "\.stat-block\|\.stat-value\|\.stat-label" user/themes/intotheeast/css/style.css
```
Expected: at least 3 matches. If not found, copy from `stats.html.twig`'s inline `<style>` block if one exists.
- [ ] **Step 5: Add stats toggle JS + haversine distance**
In `trip.html.twig`, append to the existing `<script>` block (before `</script>`):
```javascript
var STATS_GPS = {{ gps_points|json_encode|raw }};
(function() {
function haversine(lat1, lng1, lat2, lng2) {
var R = 6371;
var dLat = (lat2 - lat1) * Math.PI / 180;
var dLng = (lng2 - lng1) * Math.PI / 180;
var a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLng / 2) * Math.sin(dLng / 2);
return R * 2 * Math.asin(Math.sqrt(a));
}
var totalKm = 0;
for (var i = 1; i < STATS_GPS.length; i++) {
totalKm += haversine(
parseFloat(STATS_GPS[i - 1][0]), parseFloat(STATS_GPS[i - 1][1]),
parseFloat(STATS_GPS[i][0]), parseFloat(STATS_GPS[i][1])
);
}
var distEl = document.getElementById('stat-distance');
if (distEl) {
distEl.textContent = STATS_GPS.length < 2 ? '—' : '~' + Math.round(totalKm).toLocaleString();
}
var statsToggle = document.getElementById('trip-stats-toggle');
var statsBlock = document.getElementById('trip-stats-block');
if (statsToggle && statsBlock) {
statsToggle.addEventListener('click', function() {
var isOpen = statsBlock.style.display !== 'none';
statsBlock.style.display = isOpen ? 'none' : '';
statsToggle.classList.toggle('is-active', !isOpen);
});
}
})();
```
- [ ] **Step 6: Verify stats block**
Open the trip page with demo entries loaded:
- Click **Stats** → inline block expands between filter bar and feed; Stats button turns teal
- Block shows: days on road, entries count, countries count, km distance (or `—` if < 2 GPS points)
- Countries list shows below the grid if any entries have `location_country`
- Click **Stats** again → block collapses, button returns to default style
- [ ] **Step 7: Commit**
```bash
git add user/themes/intotheeast/templates/trip.html.twig user/themes/intotheeast/css/style.css
git commit -m "feat: add inline stats block with toggle to trip page"
```
---
## Self-Review
**Spec coverage:**
- ✅ Filter bar with All / Journal / Stories (Task 2, 3)
- ✅ Mutually exclusive, one active at a time (Task 3 JS)
- ✅ JS show/hide via data-type (Task 1, 3)
- ✅ Empty state for Stories filter (Task 3)
- ✅ Stats as inline expansion (Task 4)
- ✅ Stats toggle with active state (Task 4)
- ✅ Story card full border (Task 1)
- ✅ Sub-pages untouched — no changes to stats.html.twig or dailies.html.twig
**Placeholder scan:** None — all steps contain exact code.
**Type consistency:**
- `data-filter="story"` on button matches `data-type="story"` on article — comparison in Task 3 JS: `card.getAttribute('data-type') === filter`
- `id="trip-stats-toggle"` set in Task 2 HTML, read in Task 4 JS ✅
- `id="trip-stats-block"` set in Task 4 HTML, read in Task 4 JS ✅
- `id="feed-filter-empty"` set in Task 3 HTML, read in Task 3 JS ✅
- `id="stat-distance"` set in Task 4 HTML, read in Task 4 JS ✅
- `STATS_GPS` set in Task 4 JS, consumed in Task 4 haversine loop ✅
- `.stat-block` / `.stat-value` / `.stat-label` reused from existing CSS — Task 4 Step 4 verifies they exist ✅
@@ -0,0 +1,301 @@
# Tuscany Demo Stories 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:** Add three Italy 2025 Tuscany demo stories that showcase distinct story-mode composition patterns.
**Architecture:** Three `story.md` files in `user/docs/demo/trips/italy-2025/04.stories/`, committed to the user repo. One Makefile line added to `demo-load` to copy the folder into pages, committed to the main repo. No image files — shortcode image params reference filenames that won't resolve without real photos, consistent with the existing Japan demo story.
**Tech Stack:** Grav CMS page frontmatter (YAML), Markdown prose, story-blocks shortcodes (chapter-break, scrolly-section, pull-quote, snap-gallery), Makefile.
## Global Constraints
- Story pages live at `user/docs/demo/trips/italy-2025/04.stories/<n>.<slug>/story.md` (note: `user/` is a separate git repo — all story commits use `git -C user ...`)
- Shortcode syntax: `[chapter-break title="..." number="..." image="..." alt="..." /]`, `[scrolly-section image="..." alt="..." caption="..."]...[/scrolly-section]`, `[pull-quote image="..."]...[/pull-quote]` (image param optional), `[snap-gallery images="a.jpg,b.jpg" captions="Cap 1,Cap 2" alts="Alt 1,Alt 2" /]`
- ScrollySection steps separated by `---` on its own line inside the shortcode tags
- All frontmatter fields required: `title`, `date`, `location_name`, `location_country`, `lat`, `lng`, `hero_image`, `hero_alt`, `published: true`
- `end_date` optional (Story 2 only)
- Makefile changes go in the **main repo** (`git add Makefile && git commit ...` — no `-C user`)
- Dev server: `http://localhost:8081`, Docker container: `intotheeast_grav`
---
### Task 1: Three story.md files
**Files:**
- Create: `user/docs/demo/trips/italy-2025/04.stories/01.val-dorcia-dawn/story.md`
- Create: `user/docs/demo/trips/italy-2025/04.stories/02.long-climb-montalcino/story.md`
- Create: `user/docs/demo/trips/italy-2025/04.stories/03.one-evening-siena/story.md`
**Interfaces:**
- Produces: three story pages loadable by `make demo-load` into `user/pages/01.trips/italy-2025/04.stories/`
- Consumed by: Task 2 (Makefile verification)
- [ ] **Create the demo stories directory**
```bash
mkdir -p user/docs/demo/trips/italy-2025/04.stories/01.val-dorcia-dawn
mkdir -p user/docs/demo/trips/italy-2025/04.stories/02.long-climb-montalcino
mkdir -p user/docs/demo/trips/italy-2025/04.stories/03.one-evening-siena
```
- [ ] **Create Story 1: `01.val-dorcia-dawn/story.md`**
Pattern: gallery-led. Demonstrates two snap-galleries, chapter-break as scene divider, text-only pull-quote (no image param).
```markdown
---
title: The Val d'Orcia at Dawn
date: 2025-09-05
location_name: Val d'Orcia
location_country: Italy
lat: 43.078
lng: 11.676
hero_image: hero.jpg
hero_alt: Cypress-lined dirt road at first light, Tuscany
published: true
---
We left camp before the heat arrived. At six in the morning the Val d'Orcia belongs entirely to the light — long shadows, pale gold, not a car on the white roads. The kind of silence that has texture.
[snap-gallery images="dawn-wide.jpg,rolling-hills.jpg,cypress-allee.jpg,dirt-road.jpg" captions="First light on the valley floor,The hills fold endlessly east,The cypress road — every photo you have ever seen of Tuscany,Dust rising behind us on the gravel" alts="Wide valley at dawn with golden light,Rolling green hills under morning sky,Long avenue of cypress trees,White gravel road with dust cloud" /]
We stopped twice before nine. Once for a puncture, once because the view demanded it.
[chapter-break image="heat-haze.jpg" title="The Hour Before Heat" alt="Hazy hillside shimmering in early morning warmth" /]
By ten the temperature had shifted. The colours changed too — softer, more diffuse, the sky turning white at the edges. We dropped into the lower valley and the road surface changed from gravel to packed earth, then back again.
[snap-gallery images="gravel-detail.jpg,wheel-shadow.jpg,water-bottle.jpg,road-shadow.jpg" captions="The texture of Tuscan gravel — coarser than it looks,Long shadow at the day's first steep pitch,Half a litre left and forty kilometres to go,The road ahead disappears into the heat" alts="Close-up of pale gravel surface,Bicycle wheel casting shadow on road,Half-empty water bottle in cage,Road vanishing into bright haze" /]
[pull-quote]
The best hours of a cycling day are the ones nobody sees. Four in the morning to ten. Then it belongs to the sun.
[/pull-quote]
We made Pienza by noon. It was already thirty degrees and the ice cream queue was six deep.
```
- [ ] **Create Story 2: `02.long-climb-montalcino/story.md`**
Pattern: scrollytelling-led. Demonstrates two scrolly-sections with different step counts (3 vs. 5), pull-quote with image, chapter-break with number.
```markdown
---
title: The Long Climb to Montalcino
date: 2025-09-05
end_date: 2025-09-06
location_name: Montalcino
location_country: Italy
lat: 43.058
lng: 11.489
hero_image: hero.jpg
hero_alt: Hairpin road climbing through olive groves towards a hilltop town
published: true
---
The profile showed fourteen kilometres at an average of six percent. In practice it was steeper at the bottom and gentler at the top, which is the worst possible arrangement. We started climbing at two in the afternoon, which was also the worst possible decision.
[scrolly-section image="climb-road.jpg" alt="Empty road rising steeply through olive groves" caption="SP55 — 14km, 840m elevation gain"]
The first kilometre is the most honest. You find out immediately whether your legs have anything to say.
---
By the halfway point the olive groves had given way to scrub oak and the road had narrowed to a single lane. No cars had passed in forty minutes. The silence was absolute except for breathing.
---
Then, at the last bend before the top, the town appeared. Just the outline of it — a tower, a wall, rooftops. It was enough.
[/scrolly-section]
[chapter-break image="town-gate.jpg" title="Montalcino" number="II" alt="Medieval town gate with stone archway" /]
[pull-quote image="vineyard.jpg" alt="Rows of Brunello vines descending from hilltop town"]
From the top you could see the whole valley we had spent two days riding through. It looked completely flat from up here.
[/pull-quote]
We found a bar in the main piazza. The owner brought two glasses of water without being asked. Then two more. Then a small plate of bread and oil that nobody ordered. We sat there for an hour.
[scrolly-section image="piazza.jpg" alt="Shaded medieval piazza with stone buildings" caption="Piazza del Popolo, Montalcino"]
The piazza at five in the afternoon is a different place from the piazza at noon. People have returned from wherever they go during the heat.
---
A wine shop with barrels in the window and a handwritten list on a chalkboard. We looked at it for a long time and bought nothing. The prices were very reasonable and this felt suspicious.
---
A cat on a warm stone wall, watching traffic that did not exist. It had clearly been watching this traffic for years.
---
The fortress walls turn amber just before sunset. You could photograph this from a hundred different angles and it would look the same in all of them: very good.
---
The descent back to the valley takes twenty minutes. The climb took two and a half hours. This ratio never stops feeling wrong.
[/scrolly-section]
We found the agriturismo by following a handwritten sign nailed to a cypress tree. It was exactly what it promised to be.
```
- [ ] **Create Story 3: `03.one-evening-siena/story.md`**
Pattern: mood/fragment piece. Demonstrates pull-quote as opening element, 2-step scrolly-section (minimum), snap-gallery as mid-story element, pull-quote with image as closing element.
```markdown
---
title: One Evening in Siena
date: 2025-09-05
location_name: Siena
location_country: Italy
lat: 43.318
lng: 11.330
hero_image: hero.jpg
hero_alt: The Piazza del Campo at dusk, terracotta rooftops fading to blue
published: true
---
[pull-quote]
Siena is not a city that tries to impress you. It has been here for a thousand years and intends to be here for a thousand more. You fit around it.
[/pull-quote]
We rolled in at half past six, legs finished, panniers heavier than they started. The Campo appeared without warning at the end of a narrow street and we both stopped pedalling at exactly the same moment.
[scrolly-section image="campo.jpg" alt="Piazza del Campo seen from the upper rim, sloping shell-shaped square"]
The square fills from the edges inward as evening comes. First the locals — people who have been here before and know which bench faces west. Then the tourists, then the pigeons, then the shadows.
---
A busker with a cello at the top of the slope. A couple arguing quietly in a language I couldn't place. Three children running in a circle for reasons nobody questioned. The ordinary business of a city at the end of a summer day.
[/scrolly-section]
[snap-gallery images="campo-dusk.jpg,doorway.jpg,gelato.jpg" captions="The Campo at the moment the light goes warm,A doorway on Via di Città — every doorway in Siena looks like this,The mandatory stop: stracciatella, obviously" alts="Wide shot of Campo at golden hour,Ornate stone doorway with iron lantern,Close-up of gelato cone with city behind" /]
We found a place to eat down three flights of stairs in a basement that appeared to have no ventilation and no menu. It was perfect. The relief of sitting down after eight hours on a bike is a specific physical sensation that is difficult to describe to anyone who has not experienced it.
[pull-quote image="sunset.jpg" alt="Sunset view over Siena rooftops from high vantage point"]
Cycling makes you earn every place you arrive at. Siena earned.
[/pull-quote]
```
- [ ] **Verify shortcode syntax in all three files**
Check that every shortcode tag opens and closes correctly:
```bash
grep -n "\[chapter-break\|\[scrolly-section\|\[/scrolly-section\]\|\[pull-quote\|\[/pull-quote\]\|\[snap-gallery" \
user/docs/demo/trips/italy-2025/04.stories/01.val-dorcia-dawn/story.md \
user/docs/demo/trips/italy-2025/04.stories/02.long-climb-montalcino/story.md \
user/docs/demo/trips/italy-2025/04.stories/03.one-evening-siena/story.md
```
Expected: every `[scrolly-section` has a matching `[/scrolly-section]`, every `[pull-quote` has a matching `[/pull-quote]`.
- [ ] **Commit to user repo**
```bash
git -C user add docs/demo/trips/italy-2025/04.stories/
git -C user commit -m "docs: add three Tuscany demo stories (gallery-led, scrollytelling-led, mood-fragment)"
```
---
### Task 2: Makefile update + end-to-end verification
**Files:**
- Modify: `Makefile` (main repo — no `-C user`)
**Interfaces:**
- Consumes: `user/docs/demo/trips/italy-2025/04.stories/` from Task 1
- Produces: `make demo-load` copies all three stories into pages; stories listing shows 3 cards; each story page renders shortcode HTML
- [ ] **Add the stories copy line to `demo-load`**
In `Makefile`, find this line:
```makefile
cp user/docs/demo/trips/italy-2025/stories.md user/pages/01.trips/italy-2025/04.stories/stories.md 2>/dev/null || true
```
Add the following line immediately after it:
```makefile
cp -r user/docs/demo/trips/italy-2025/04.stories/. user/pages/01.trips/italy-2025/04.stories/ 2>/dev/null || true
```
Note: use `04.stories/.` (trailing slash-dot) to copy the **contents** of the folder into the existing `04.stories/` directory (which already contains `stories.md`), rather than creating a nested `04.stories/04.stories/` structure.
- [ ] **Run `make demo-load` and clear cache**
```bash
make demo-load
```
Expected output ends with Grav cache cleared.
- [ ] **Verify stories listing shows 3 cards**
```bash
curl -s http://localhost:8081/trips/italy-2025/stories | grep -c 'story-card'
```
Expected: `3` (one card per story).
- [ ] **Verify Story 1 renders both snap-galleries and text-only pull-quote**
```bash
curl -s http://localhost:8081/trips/italy-2025/stories/val-dorcia-dawn | grep -c 'pgallery\|pull-quote'
```
Expected: at least `3` (2 pgallery divs + 1 pull-quote).
Also verify the text-only pull-quote (no background image div):
```bash
curl -s http://localhost:8081/trips/italy-2025/stories/val-dorcia-dawn | grep 'pull-quote__inner--no-image'
```
Expected: one match (the text-only pull-quote variant).
- [ ] **Verify Story 2 renders two scrolly-sections**
```bash
curl -s http://localhost:8081/trips/italy-2025/stories/long-climb-montalcino | grep -c 'class="scrolly"'
```
Expected: `2` (two scrolly-section blocks).
Also verify pull-quote with image (should NOT have `--no-image` class):
```bash
curl -s http://localhost:8081/trips/italy-2025/stories/long-climb-montalcino | grep 'pull-quote__bg'
```
Expected: one match (the background image div for the pull-quote).
- [ ] **Verify Story 3 renders pull-quote as opener and closer**
```bash
curl -s http://localhost:8081/trips/italy-2025/stories/one-evening-siena | grep -c 'pull-quote'
```
Expected: at least `4` (two pull-quote elements × ~2 class references each).
Verify the mid-story snap-gallery:
```bash
curl -s http://localhost:8081/trips/italy-2025/stories/one-evening-siena | grep -c 'pgallery'
```
Expected: at least `1`.
- [ ] **Run `make demo-reset` and verify cleanup**
```bash
make demo-reset
```
Verify Italy stories are gone:
```bash
ls user/pages/01.trips/italy-2025/ 2>/dev/null || echo "italy-2025 removed"
```
Expected: `italy-2025 removed` (the entire italy-2025 folder is removed by `demo-reset`).
- [ ] **Commit Makefile to main repo**
```bash
git add Makefile
git commit -m "build: add Italy 2025 stories folder to demo-load target"
```
@@ -0,0 +1,841 @@
# Accessibility Audit 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:** 🔄 In progress — Task 1 complete (skip link), Tasks 26 open
**Goal:** Fix all eight WCAG 2.1 AA failures identified in the accessibility audit and add axe-core Playwright regression tests.
**Architecture:** Six sequential tasks — each implements one audit finding (or related group), writes a Playwright test first, then implements the fix in the relevant template/CSS/JS files. All tests go into a new `tests/ui/accessibility.spec.js` file that grows task by task. Task 6 adds axe-core automated scans on top of the feature-specific checks.
**Tech Stack:** Grav 2.0 Twig templates, CSS custom properties, vanilla JS, Playwright with `@axe-core/playwright`
## Global Constraints
- Target standard: WCAG 2.1 Level AA
- Dev server: http://localhost:8081 (Docker container `intotheeast_grav`)
- Two git repos: outer at `/home/mischa/Nextcloud/Projects/travel-blog-intotheeast`, user subrepo at `/home/mischa/Projects/travel-blog-intotheeast/user`
- Template files are in the **user subrepo** (`user/themes/intotheeast/templates/`, `user/themes/intotheeast/css/`) — commit there first, then commit the outer repo with the updated `user/` pointer
- Tests and `package.json` are in the **outer repo** only
- Run all Playwright tests with: `cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast && npx playwright test --project=chromium`
- Demo data must be loaded before running tests: `make demo-load` (run from outer repo)
- Never read `.env`; pass it only to `make` / `docker compose`
- Do NOT add comments to CSS or JS unless the WHY is non-obvious
**Note on F8 (lightbox alt text):** The existing JS in `entry.html.twig` already handles this correctly — `open(index)` sets `lbImg.alt = btn.dataset.alt` which is populated from `{{ image.filename }}`. No code change needed for F8.
---
### Task 1: Skip link + `<main id="main-content">`
**Fixes:** F1 (WCAG 2.4.1 Bypass Blocks)
**Files:**
- Modify: `user/themes/intotheeast/templates/partials/base.html.twig`
- Modify: `user/themes/intotheeast/css/style.css`
- Create: `tests/ui/accessibility.spec.js`
**Interfaces:**
- Produces: `.skip-link` element, `#main-content` id on `<main>` — both consumed by A1 test
- [ ] **Step 1: Create the failing test**
Create `tests/ui/accessibility.spec.js`:
```js
// @ts-check
// Tests: A1A5 (feature checks) and AX1AX5 (axe scans)
const { test, expect } = require('@playwright/test');
// ── A1: Skip link ──────────────────────────────────────────────────────────────
test('A1: skip link targets #main-content and is first focusable element', async ({ page }) => {
await page.goto('/');
const skipLink = page.locator('.skip-link');
await expect(skipLink).toBeAttached();
await expect(skipLink).toHaveAttribute('href', '#main-content');
await expect(page.locator('#main-content')).toBeAttached();
});
```
- [ ] **Step 2: Run A1 to verify it fails**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
npx playwright test --project=chromium tests/ui/accessibility.spec.js
```
Expected: FAIL — `.skip-link` not found.
- [ ] **Step 3: Add skip link to `base.html.twig`**
In `user/themes/intotheeast/templates/partials/base.html.twig`:
Change line 1516 (the opening `<body>` and `<header>`):
```html
<body class="{% if page.template == 'map' %}map-page{% endif %}{% if page.template == 'home' or page.template == 'trip' %} home-page{% endif %}{% if page.template == 'story' %} template-story{% endif %}">
<header class="site-header">
```
Replace with:
```html
<body class="{% if page.template == 'map' %}map-page{% endif %}{% if page.template == 'home' or page.template == 'trip' %} home-page{% endif %}{% if page.template == 'story' %} template-story{% endif %}">
<a class="skip-link" href="#main-content">Skip to main content</a>
<header class="site-header">
```
Then change line 25:
```html
<main class="site-main">
```
Replace with:
```html
<main class="site-main" id="main-content">
```
- [ ] **Step 4: Add skip-link CSS to `style.css`**
In `user/themes/intotheeast/css/style.css`, find the `:focus-visible` rule (around line 782):
```css
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
```
Add this block directly before it:
```css
.skip-link {
position: absolute;
left: -10000px;
top: auto;
width: 1px;
height: 1px;
overflow: hidden;
}
.skip-link:focus-visible {
left: 0;
top: 0;
width: auto;
height: auto;
overflow: visible;
padding: var(--space-2) var(--space-4);
background: var(--color-accent);
color: var(--color-accent-on);
font-family: var(--font-ui);
font-size: var(--text-sm);
font-weight: 600;
text-decoration: none;
z-index: 9999;
}
```
- [ ] **Step 5: Run A1 to verify it passes**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
npx playwright test --project=chromium tests/ui/accessibility.spec.js
```
Expected: PASS.
- [ ] **Step 6: Run existing tests to check for regressions**
```bash
npx playwright test --project=chromium tests/ui/nav.spec.js tests/ui/home.spec.js tests/ui/dailies.spec.js
```
Expected: all pass.
- [ ] **Step 7: Commit**
```bash
cd /home/mischa/Projects/travel-blog-intotheeast/user
git add themes/intotheeast/templates/partials/base.html.twig themes/intotheeast/css/style.css
git commit -m "feat(a11y): add skip-to-main link and main landmark id"
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
git add tests/ui/accessibility.spec.js user
git commit -m "test(a11y): add A1 skip link test"
```
---
### Task 2: Color token contrast fixes
**Fixes:** F2 (`--color-ink-muted` fails 4.5:1), F3 (`--color-accent` fails 4.5:1)
**Files:**
- Modify: `user/themes/intotheeast/css/tokens.css`
- Modify: `tests/ui/accessibility.spec.js`
**Interfaces:**
- Consumes: `tests/ui/accessibility.spec.js` from Task 1
- Produces: updated token values verified by A2 test
- [ ] **Step 1: Add the failing test**
Append to `tests/ui/accessibility.spec.js`:
```js
// ── A2: Color token contrast ───────────────────────────────────────────────────
test('A2: contrast tokens meet WCAG AA 4.5:1 floor', async ({ page }) => {
await page.goto('/');
const [muted, accent] = await page.evaluate(() => [
getComputedStyle(document.documentElement).getPropertyValue('--color-ink-muted').trim(),
getComputedStyle(document.documentElement).getPropertyValue('--color-accent').trim(),
]);
expect(muted.toLowerCase()).toBe('#90887e');
expect(accent.toLowerCase()).toBe('#2e9880');
});
```
- [ ] **Step 2: Run A2 to verify it fails**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
npx playwright test --project=chromium tests/ui/accessibility.spec.js --grep "A2"
```
Expected: FAIL — values are `#7a7268` and `#2a8c73`.
- [ ] **Step 3: Update `tokens.css`**
In `user/themes/intotheeast/css/tokens.css`, make three changes:
Change `--color-ink-muted`:
```css
--color-ink-muted: #7A7268; /* labels, timestamps, captions */
```
```css
--color-ink-muted: #90887E; /* labels, timestamps, captions */
```
Change `--color-accent`:
```css
--color-accent: #2A8C73; /* teal — lightened for dark contrast */
```
```css
--color-accent: #2E9880; /* teal — lightened for dark contrast */
```
Change `--color-accent-hover`:
```css
--color-accent-hover: #236655; /* hover/pressed teal */
```
```css
--color-accent-hover: #287A68; /* hover/pressed teal */
```
Contrast verification (for reference only — these numbers are correct):
- `#90887E` on `#1A1814` = 5.07:1 ✓, on `#22201B` = 4.66:1 ✓
- `#2E9880` on `#1A1814` = 5.00:1 ✓, on `#22201B` = 4.59:1 ✓
- `#287A68` on `#1A1814` = 3.58:1 ✓ (non-text, needs 3:1)
- [ ] **Step 4: Run A2 to verify it passes**
```bash
npx playwright test --project=chromium tests/ui/accessibility.spec.js --grep "A2"
```
Expected: PASS.
- [ ] **Step 5: Run all accessibility tests**
```bash
npx playwright test --project=chromium tests/ui/accessibility.spec.js
```
Expected: A1 and A2 pass.
- [ ] **Step 6: Commit**
```bash
cd /home/mischa/Projects/travel-blog-intotheeast/user
git add themes/intotheeast/css/tokens.css
git commit -m "feat(a11y): fix --color-ink-muted and --color-accent contrast ratios"
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
git add tests/ui/accessibility.spec.js user
git commit -m "test(a11y): add A2 color contrast token test"
```
---
### Task 3: ARIA states for filter buttons and toggle panels
**Fixes:** F4 (filter buttons lack `aria-pressed`), F5 (toggle buttons lack `aria-expanded`)
**Files:**
- Modify: `user/themes/intotheeast/templates/trip.html.twig`
- Modify: `tests/ui/accessibility.spec.js`
**Interfaces:**
- Consumes: `tests/ui/accessibility.spec.js` from Task 2
- Produces: `aria-pressed` on `.trip-filter-btn`, `aria-expanded` + `aria-controls` on `#trip-stats-toggle` and `#trip-cycling-toggle`
- [ ] **Step 1: Add the failing tests**
Append to `tests/ui/accessibility.spec.js`:
```js
// ── A3: Filter button aria-pressed + toggle aria-expanded ──────────────────────
const TRIP_URL = '/trips/japan-korea-2026';
test('A3a: All-content filter has aria-pressed="true" on load', async ({ page }) => {
await page.goto(TRIP_URL);
await expect(page.locator('.trip-filter-btn[data-filter="all"]')).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('.trip-filter-btn[data-filter="journal"]')).toHaveAttribute('aria-pressed', 'false');
await expect(page.locator('.trip-filter-btn[data-filter="story"]')).toHaveAttribute('aria-pressed', 'false');
});
test('A3b: clicking Journal filter toggles aria-pressed', async ({ page }) => {
await page.goto(TRIP_URL);
await page.click('.trip-filter-btn[data-filter="journal"]');
await expect(page.locator('.trip-filter-btn[data-filter="journal"]')).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('.trip-filter-btn[data-filter="all"]')).toHaveAttribute('aria-pressed', 'false');
});
test('A3c: Stats toggle has aria-expanded="false" and aria-controls on load', async ({ page }) => {
await page.goto(TRIP_URL);
await expect(page.locator('#trip-stats-toggle')).toHaveAttribute('aria-expanded', 'false');
await expect(page.locator('#trip-stats-toggle')).toHaveAttribute('aria-controls', 'trip-stats-block');
});
test('A3d: clicking Stats toggle sets aria-expanded="true" then back to false', async ({ page }) => {
await page.goto(TRIP_URL);
await page.click('#trip-stats-toggle');
await expect(page.locator('#trip-stats-toggle')).toHaveAttribute('aria-expanded', 'true');
await page.click('#trip-stats-toggle');
await expect(page.locator('#trip-stats-toggle')).toHaveAttribute('aria-expanded', 'false');
});
```
- [ ] **Step 2: Run A3aA3d to verify they fail**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
npx playwright test --project=chromium tests/ui/accessibility.spec.js --grep "A3"
```
Expected: all four FAIL — attributes not present.
- [ ] **Step 3: Add `aria-pressed` to filter buttons in template**
In `user/themes/intotheeast/templates/trip.html.twig`, find lines 124128:
```html
<div class="trip-filter-group">
<button class="trip-filter-btn is-active" data-filter="all">All content</button>
<button class="trip-filter-btn" data-filter="journal">Journal</button>
<button class="trip-filter-btn" data-filter="story">Stories</button>
</div>
```
Replace with:
```html
<div class="trip-filter-group">
<button class="trip-filter-btn is-active" data-filter="all" aria-pressed="true">All content</button>
<button class="trip-filter-btn" data-filter="journal" aria-pressed="false">Journal</button>
<button class="trip-filter-btn" data-filter="story" aria-pressed="false">Stories</button>
</div>
```
- [ ] **Step 4: Add `aria-expanded` + `aria-controls` to toggle buttons in template**
Find lines 129134:
```html
<div class="trip-filter-group">
<button class="trip-stats-btn" id="trip-stats-toggle">Stats</button>
{% if has_gpx %}
<button class="trip-stats-btn" id="trip-cycling-toggle">Cycling</button>
{% endif %}
</div>
```
Replace with:
```html
<div class="trip-filter-group">
<button class="trip-stats-btn" id="trip-stats-toggle" aria-expanded="false" aria-controls="trip-stats-block">Stats</button>
{% if has_gpx %}
<button class="trip-stats-btn" id="trip-cycling-toggle" aria-expanded="false" aria-controls="trip-cycling-block">Cycling</button>
{% endif %}
</div>
```
- [ ] **Step 5: Update the filter JS to toggle `aria-pressed`**
In the same file, find the filter click handler inside the `<script>` block (around line 384):
```js
filterBtns.forEach(function(btn) {
btn.addEventListener('click', function() {
filterBtns.forEach(function(b) { b.classList.remove('is-active'); });
btn.classList.add('is-active');
```
Replace those four lines with:
```js
filterBtns.forEach(function(btn) {
btn.addEventListener('click', function() {
filterBtns.forEach(function(b) { b.classList.remove('is-active'); b.setAttribute('aria-pressed', 'false'); });
btn.classList.add('is-active');
btn.setAttribute('aria-pressed', 'true');
```
- [ ] **Step 6: Update the stats toggle JS to set `aria-expanded`**
Find the stats toggle handler (around line 548):
```js
statsToggle.addEventListener('click', function() {
var isOpen = statsBlock.style.display !== 'none';
statsBlock.style.display = isOpen ? 'none' : '';
statsToggle.classList.toggle('is-active', !isOpen);
});
```
Replace with:
```js
statsToggle.addEventListener('click', function() {
var isOpen = statsBlock.style.display !== 'none';
statsBlock.style.display = isOpen ? 'none' : '';
statsToggle.classList.toggle('is-active', !isOpen);
statsToggle.setAttribute('aria-expanded', isOpen ? 'false' : 'true');
});
```
- [ ] **Step 7: Update the cycling toggle JS to set `aria-expanded`**
Find the cycling toggle handler (around line 560):
```js
cycToggle.addEventListener('click', function() {
var isOpen = cycBlock.style.display !== 'none';
cycBlock.style.display = isOpen ? 'none' : '';
cycToggle.classList.toggle('is-active', !isOpen);
});
```
Replace with:
```js
cycToggle.addEventListener('click', function() {
var isOpen = cycBlock.style.display !== 'none';
cycBlock.style.display = isOpen ? 'none' : '';
cycToggle.classList.toggle('is-active', !isOpen);
cycToggle.setAttribute('aria-expanded', isOpen ? 'false' : 'true');
});
```
- [ ] **Step 8: Run A3aA3d to verify they pass**
```bash
npx playwright test --project=chromium tests/ui/accessibility.spec.js --grep "A3"
```
Expected: all four PASS.
- [ ] **Step 9: Run the existing filter tests to check for regressions**
```bash
npx playwright test --project=chromium tests/ui/trip-filter.spec.js
```
Expected: all pass.
- [ ] **Step 10: Commit**
```bash
cd /home/mischa/Projects/travel-blog-intotheeast/user
git add themes/intotheeast/templates/trip.html.twig
git commit -m "feat(a11y): add aria-pressed to filter buttons and aria-expanded to stats/cycling toggles"
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
git add tests/ui/accessibility.spec.js user
git commit -m "test(a11y): add A3a-A3d aria-pressed and aria-expanded tests"
```
---
### Task 4: Photo strip keyboard navigation
**Fixes:** F6 (photo strip not keyboard-navigable)
**Files:**
- Modify: `user/themes/intotheeast/templates/partials/base.html.twig`
- Modify: `user/themes/intotheeast/css/style.css`
- Modify: `tests/ui/accessibility.spec.js`
**Interfaces:**
- Consumes: `tests/ui/accessibility.spec.js` from Task 3
- Produces: `.strip-controls` div with `.strip-prev` / `.strip-next` buttons injected by JS for strips with `data-slides >= 2`; all strips get `role="region"` and `aria-label="Photo strip"`
- [ ] **Step 1: Add the failing tests**
Append to `tests/ui/accessibility.spec.js`:
```js
// ── A4: Photo strip keyboard navigation ───────────────────────────────────────
test('A4a: all photo strips have role=region and aria-label', async ({ page }) => {
await page.goto('/trips/japan-korea-2026/dailies');
const strips = page.locator('.journal-photo-strip');
const count = await strips.count();
if (count === 0) return;
for (let i = 0; i < count; i++) {
await expect(strips.nth(i)).toHaveAttribute('role', 'region');
await expect(strips.nth(i)).toHaveAttribute('aria-label', 'Photo strip');
}
});
test('A4b: multi-slide photo strips have accessible prev/next controls', async ({ page }) => {
await page.goto('/trips/japan-korea-2026/dailies');
const multiCount = await page.locator('.journal-photo-strip').evaluateAll(
els => els.filter(el => parseInt(el.dataset.slides, 10) >= 2).length
);
if (multiCount === 0) return;
await expect(page.locator('.strip-prev').first()).toBeAttached();
await expect(page.locator('.strip-next').first()).toBeAttached();
await expect(page.locator('.strip-prev').first()).toHaveAttribute('aria-label', 'Previous photo');
await expect(page.locator('.strip-next').first()).toHaveAttribute('aria-label', 'Next photo');
});
```
- [ ] **Step 2: Run A4aA4b to verify they fail**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
npx playwright test --project=chromium tests/ui/accessibility.spec.js --grep "A4"
```
Expected: A4a FAIL (no `role` attribute), A4b PASS vacuously (no multi-slide strips in demo data).
- [ ] **Step 3: Replace the dot-sync IIFE in `base.html.twig`**
In `user/themes/intotheeast/templates/partials/base.html.twig`, find the IIFE (lines 3041):
```js
<script>
(function () {
document.querySelectorAll('.journal-photo-strip').forEach(function (strip) {
var dots = strip.nextElementSibling;
if (!dots || !dots.classList.contains('journal-photo-dots')) return;
var dotEls = Array.from(dots.querySelectorAll('.journal-photo-dot'));
strip.addEventListener('scroll', function () {
var idx = Math.round(strip.scrollLeft / strip.offsetWidth);
dotEls.forEach(function (d, i) { d.classList.toggle('is-active', i === idx); });
}, { passive: true });
});
})();
</script>
```
Replace with:
```js
<script>
(function () {
document.querySelectorAll('.journal-photo-strip').forEach(function (strip) {
strip.setAttribute('role', 'region');
strip.setAttribute('aria-label', 'Photo strip');
var slideCount = parseInt(strip.dataset.slides, 10) || 1;
var dots = strip.nextElementSibling;
if (!dots || !dots.classList.contains('journal-photo-dots')) return;
var dotEls = Array.from(dots.querySelectorAll('.journal-photo-dot'));
strip.addEventListener('scroll', function () {
var idx = Math.round(strip.scrollLeft / strip.offsetWidth);
dotEls.forEach(function (d, i) { d.classList.toggle('is-active', i === idx); });
}, { passive: true });
if (slideCount < 2) return;
var prev = document.createElement('button');
prev.className = 'strip-prev';
prev.setAttribute('aria-label', 'Previous photo');
prev.textContent = '';
prev.addEventListener('click', function () {
strip.scrollBy({ left: -strip.offsetWidth, behavior: 'smooth' });
});
var next = document.createElement('button');
next.className = 'strip-next';
next.setAttribute('aria-label', 'Next photo');
next.textContent = '';
next.addEventListener('click', function () {
strip.scrollBy({ left: strip.offsetWidth, behavior: 'smooth' });
});
var controls = document.createElement('div');
controls.className = 'strip-controls';
controls.appendChild(prev);
controls.appendChild(next);
dots.insertAdjacentElement('afterend', controls);
});
})();
</script>
```
- [ ] **Step 4: Add strip-controls CSS to `style.css`**
In `user/themes/intotheeast/css/style.css`, find the `.journal-photo-dot.is-active` rule (around line 245):
```css
.journal-photo-dot.is-active {
background: var(--color-ink-muted);
}
```
Add this block directly after it:
```css
.strip-controls {
display: flex;
justify-content: center;
gap: var(--space-3);
margin-top: calc(-1 * var(--space-2));
margin-bottom: var(--space-4);
}
.strip-prev,
.strip-next {
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-ink-2);
border-radius: var(--radius-sm);
padding: var(--space-1) var(--space-3);
font-size: var(--text-md);
line-height: 1;
cursor: pointer;
}
.strip-prev:hover,
.strip-next:hover {
border-color: var(--color-accent);
color: var(--color-accent);
}
```
- [ ] **Step 5: Run A4aA4b to verify they pass**
```bash
npx playwright test --project=chromium tests/ui/accessibility.spec.js --grep "A4"
```
Expected: both PASS.
- [ ] **Step 6: Run the full accessibility suite**
```bash
npx playwright test --project=chromium tests/ui/accessibility.spec.js
```
Expected: A1A4 all pass.
- [ ] **Step 7: Commit**
```bash
cd /home/mischa/Projects/travel-blog-intotheeast/user
git add themes/intotheeast/templates/partials/base.html.twig themes/intotheeast/css/style.css
git commit -m "feat(a11y): add keyboard prev/next to photo strip and region landmark"
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
git add tests/ui/accessibility.spec.js user
git commit -m "test(a11y): add A4a-A4b photo strip keyboard tests"
```
---
### Task 5: GPX delete button unique accessible names
**Fixes:** F7 (delete buttons have no unique name)
**Files:**
- Modify: `user/themes/intotheeast/templates/gpx-manager.html.twig`
- Modify: `tests/ui/accessibility.spec.js`
**Interfaces:**
- Consumes: `tests/ui/accessibility.spec.js` from Task 4
- Produces: delete buttons with `aria-label="Delete <filename>"`
- [ ] **Step 1: Add the failing test**
Append to `tests/ui/accessibility.spec.js`:
```js
// ── A5: GPX delete button unique accessible names ──────────────────────────────
test('A5: GPX delete buttons have unique aria-labels per filename', async ({ page }) => {
await page.route('**/api/v1/pages**/media', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: [
{ filename: 'tokyo-day1.gpx', size: 102400, modified: '2026-03-25T10:00:00Z' }
]
})
});
});
await page.goto('/gpx-manager');
const deleteBtn = page.locator('.gpx-delete[data-filename="tokyo-day1.gpx"]');
await expect(deleteBtn).toBeVisible();
await expect(deleteBtn).toHaveAttribute('aria-label', 'Delete tokyo-day1.gpx');
});
```
- [ ] **Step 2: Run A5 to verify it fails**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
npx playwright test --project=chromium tests/ui/accessibility.spec.js --grep "A5"
```
Expected: FAIL — `aria-label` attribute not present on the delete button.
- [ ] **Step 3: Add `aria-label` to the delete button in `gpx-manager.html.twig`**
In `user/themes/intotheeast/templates/gpx-manager.html.twig`, find line 99 inside the `rows` template string:
```js
<td><button class="gpx-delete" data-filename="${f.filename}">Delete</button></td>
```
Replace with:
```js
<td><button class="gpx-delete" data-filename="${f.filename}" aria-label="Delete ${f.filename}">Delete</button></td>
```
- [ ] **Step 4: Run A5 to verify it passes**
```bash
npx playwright test --project=chromium tests/ui/accessibility.spec.js --grep "A5"
```
Expected: PASS.
- [ ] **Step 5: Run full accessibility suite**
```bash
npx playwright test --project=chromium tests/ui/accessibility.spec.js
```
Expected: A1A5 all pass.
- [ ] **Step 6: Commit**
```bash
cd /home/mischa/Projects/travel-blog-intotheeast/user
git add themes/intotheeast/templates/gpx-manager.html.twig
git commit -m "feat(a11y): add unique aria-label to GPX delete buttons"
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
git add tests/ui/accessibility.spec.js user
git commit -m "test(a11y): add A5 GPX delete button accessible name test"
```
---
### Task 6: axe-core WCAG 2.1 AA regression scans
**Adds:** AX1AX5 automated axe scans across all main page types
**Files:**
- Modify: `package.json`
- Modify: `tests/ui/accessibility.spec.js`
**Interfaces:**
- Consumes: `tests/ui/accessibility.spec.js` from Task 5
- Produces: five axe scans that fail on any `critical` or `serious` WCAG violation
- [ ] **Step 1: Install `@axe-core/playwright`**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
npm install --save-dev @axe-core/playwright
```
After install, `package.json` devDependencies should include `"@axe-core/playwright": "^4.x.x"` (exact semver will vary).
- [ ] **Step 2: Add the axe scans to `accessibility.spec.js`**
Append to `tests/ui/accessibility.spec.js`:
```js
// ── AX1AX5: axe-core WCAG 2.1 AA regression scans ───────────────────────────
const { AxeBuilder } = require('@axe-core/playwright');
const WCAG_TAGS = ['wcag2a', 'wcag2aa'];
const BLOCKING = ['critical', 'serious'];
function axeScan(id, url) {
test(`${id}: ${url} passes axe WCAG 2.1 AA (critical/serious)`, async ({ page }) => {
await page.goto(url);
const results = await new AxeBuilder({ page }).withTags(WCAG_TAGS).analyze();
const violations = results.violations.filter(v => BLOCKING.includes(v.impact));
expect(
violations,
violations.map(v =>
`[${v.impact}] ${v.id}: ${v.description}\n ` +
v.nodes.map(n => n.html).join('\n ')
).join('\n\n')
).toHaveLength(0);
});
}
axeScan('AX1', '/');
axeScan('AX2', '/trips/japan-korea-2026');
axeScan('AX3', '/trips/japan-korea-2026/dailies');
axeScan('AX4', '/trips/japan-korea-2026/dailies/2026-03-25-1540-wheels-down-narita');
axeScan('AX5', '/trips');
```
- [ ] **Step 3: Run the axe scans**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
npx playwright test --project=chromium tests/ui/accessibility.spec.js --grep "AX"
```
Expected: all five PASS (after Tasks 15 fixed the known violations). If any fail, read the violation output — it will name the rule ID, description, and offending HTML. Fix the violation if it represents a real issue, or note it in the ledger if it is a known limitation outside scope (e.g. map canvas element).
- [ ] **Step 4: Run the full accessibility test suite**
```bash
npx playwright test --project=chromium tests/ui/accessibility.spec.js
```
Expected: A1A5 and AX1AX5 all pass (10 tests total).
- [ ] **Step 5: Run the full Playwright suite to check for regressions**
```bash
npx playwright test --project=chromium
```
Expected: all tests pass (or pre-existing failures only — check the progress ledger for known pre-existing failures before marking as blocker).
- [ ] **Step 6: Commit**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
git add package.json package-lock.json tests/ui/accessibility.spec.js
git commit -m "test(a11y): add axe-core WCAG 2.1 AA regression scans AX1-AX5"
```
@@ -0,0 +1,969 @@
# Demo Data 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:** Replace patchwork demo content with a single high-quality `italy-2026-demo` trip following the real 8-day Tuscan cycling loop, with 12 journal entries (with photos) and 4 stories exercising all shortcode types.
**Architecture:** All demo source files live in `user/docs/demo/trips/italy-2026-demo/`. `make demo-load` copies them into `user/pages/01.trips/italy-2026-demo/` inside Docker. Images are downloaded once and committed to the demo source so the Makefile stays simple (cp -r copies everything). Tests in `tests/ui/stories.spec.js` reference story slugs that must match the new folder names.
**Tech Stack:** Grav CMS page files (YAML frontmatter + Markdown), story-blocks shortcodes (`snap-gallery`, `scrolly-section`, `chapter-break`, `pull-quote`), picsum.photos placeholder images, Playwright tests, Make.
## Global Constraints
- All content paths are relative to project root: `/home/mischa/Nextcloud/Projects/travel-blog-intotheeast/`
- `user/` is a **separate git repo** — commit content changes there with `git -C user commit`
- Never read `.env`; never ssh directly to production
- Demo trip slug: `italy-2026-demo`; fictional dates: 2026-09-01 to 2026-09-08
- `hero_image: ''` in all entry frontmatter — template auto-selects `01.jpg` as hero
- Story images: 1600×1000 from `https://picsum.photos/seed/<seed>/1600/1000`
- Entry images: 1200×800 from `https://picsum.photos/seed/<seed>/1200/800`
- Dev server: `http://localhost:8081`
---
### Task 1: Cleanup, GPX rename, trip.md, dailies.md
**Files:**
- Delete: `user/docs/demo/trips/japan-korea-2026/` (entire folder)
- Delete: `user/docs/demo/trips/italy-2025/dailies/` (entries only)
- Delete: `user/docs/demo/trips/italy-2025/04.stories/` (stories only)
- Delete: `user/docs/demo/trips/italy-2026-demo/dailies/` (replace with new entries in later tasks)
- Delete: `user/docs/demo/trips/italy-2026-demo/04.stories/` (replace with new stories in later tasks)
- Rename: 4 GPX files in `user/docs/demo/trips/italy-2026-demo/`
- Modify: `user/docs/demo/trips/italy-2026-demo/trip.md`
- Create: `user/docs/demo/trips/italy-2026-demo/dailies/dailies.md`
- Modify: `CLAUDE.md` (update demo-load description)
- [ ] **Step 1: Remove old demo data**
```bash
rm -rf user/docs/demo/trips/japan-korea-2026
rm -rf user/docs/demo/trips/italy-2025/dailies
rm -rf user/docs/demo/trips/italy-2025/04.stories
rm -rf user/docs/demo/trips/italy-2026-demo/dailies
rm -rf user/docs/demo/trips/italy-2026-demo/04.stories
```
- [ ] **Step 2: Rename the 4 new GPX files**
```bash
cd user/docs/demo/trips/italy-2026-demo
mv "2025-10-11_2627663255_TGE Tuscany 2025 Final Route - 8 days - Day 1 - from Venturina Terme to Sugherella.gpx" \
day-1-campiglia-to-sugherella.gpx
mv "2025-10-12_2630489431_TGE Tuscany 2025 Final Route - 8 days - Day 2.gpx" \
day-2-sugherella-to-orbetello.gpx
mv "2025-10-13_2632495944_TGE Tuscany 2025 Final Route - 8 days - Day 3.gpx" \
day-3-orbetello-to-sorano.gpx
mv "2025-10-14_2634086364_TGE Tuscany 2025 Final Route - 8 days - Day 4.gpx" \
day-4-sorano-to-val-dorcia.gpx
cd ../../../..
```
- [ ] **Step 3: Verify 7 GPX files exist with correct names**
```bash
ls user/docs/demo/trips/italy-2026-demo/*.gpx
```
Expected output (7 files):
```
day-1-campiglia-to-sugherella.gpx
day-2-sugherella-to-orbetello.gpx
day-3-orbetello-to-sorano.gpx
day-4-sorano-to-val-dorcia.gpx
day-5-val-dorcia-to-siena.gpx
day-6-siena-to-florence.gpx
day-8-coast-to-piombino.gpx
```
- [ ] **Step 4: Update trip.md**
Write `user/docs/demo/trips/italy-2026-demo/trip.md`:
```markdown
---
title: 'Tuscany 2026'
template: trip
date: '2026-09-01'
date_start: '2026-09-01'
date_end: '2026-09-08'
cover_image: ''
---
```
- [ ] **Step 5: Create dailies index page**
Create `user/docs/demo/trips/italy-2026-demo/dailies/dailies.md`:
```markdown
---
title: Journal
template: dailies
---
```
- [ ] **Step 6: Recreate empty story and entry directories**
```bash
mkdir -p user/docs/demo/trips/italy-2026-demo/dailies
mkdir -p user/docs/demo/trips/italy-2026-demo/04.stories
```
- [ ] **Step 7: Update CLAUDE.md demo-load description**
In `CLAUDE.md`, find the line:
```
- `make demo-load` — load demo entries for both trips (Japan/Korea 2026 + Italy 2025 with real GPX)
```
Replace with:
```
- `make demo-load` — load demo content into `italy-2026-demo` trip (journal entries + stories + GPX)
```
- [ ] **Step 8: Commit**
```bash
git -C user add -A
git -C user commit -m "chore(demo): cleanup old demo data, rename GPX files, update trip.md"
git add CLAUDE.md
git commit -m "docs: update demo-load description in CLAUDE.md"
```
---
### Task 2: Update stories.spec.js for new story slugs
**Files:**
- Modify: `tests/ui/stories.spec.js`
The existing tests point to old story slugs (`val-dorcia-dawn`, `long-climb-montalcino`). New slugs are `val-dorcia-at-dawn` and `sorano-rock-and-time`. The shortcode assertions stay identical — the new stories are designed to match them.
- [ ] **Step 1: Update slug constants and comments in stories.spec.js**
Replace the top of `tests/ui/stories.spec.js` (lines up to the first test):
```javascript
// @ts-check
// Tests: S1S7 — story mode rendering and navigation
// Requires demo data: run `make demo-load` before this suite.
const { test, expect } = require('@playwright/test');
const STORIES_URL = '/trips/italy-2026-demo/stories';
const STORY_GALLERY = '/trips/italy-2026-demo/stories/val-dorcia-at-dawn'; // gallery-led: snap-gallery × 2, chapter-break, text-only pull-quote
const STORY_SCROLLY = '/trips/italy-2026-demo/stories/sorano-rock-and-time'; // scrolly-led: scrolly-section × 2, chapter-break, pull-quote with image
const DEMO_STORY = '/trips/italy-2026-demo/stories/val-dorcia-at-dawn'; // used for cross-trip hero sanity check
```
- [ ] **Step 2: Update the two hardcoded URLs in S7**
In `tests/ui/stories.spec.js`, find and replace the hardcoded URL in S7:
Old:
```javascript
test('S7: story body back link has back-pill class', async ({ page }) => {
await page.goto('/trips/italy-2026-demo/stories/val-dorcia-dawn');
```
New:
```javascript
test('S7: story body back link has back-pill class', async ({ page }) => {
await page.goto('/trips/italy-2026-demo/stories/val-dorcia-at-dawn');
```
- [ ] **Step 3: Verify tests reference correct slugs**
```bash
grep -n "val-dorcia\|montalcino\|sorano\|florence-without" tests/ui/stories.spec.js
```
Expected: no references to `val-dorcia-dawn` or `long-climb-montalcino`; `val-dorcia-at-dawn` and `sorano-rock-and-time` appear.
- [ ] **Step 4: Commit**
```bash
git add tests/ui/stories.spec.js
git commit -m "test(stories): update story slugs to match new demo content"
```
---
### Task 3: Write Story 1 — Sorano: Rock and Time
**Target:** `user/docs/demo/trips/italy-2026-demo/04.stories/01.sorano-rock-and-time/`
**Test coverage:** `STORY_SCROLLY` — needs scrolly-section × 2, chapter-break, pull-quote with image
**Files:**
- Create: `user/docs/demo/trips/italy-2026-demo/04.stories/01.sorano-rock-and-time/story.md`
- Create: `user/docs/demo/trips/italy-2026-demo/04.stories/01.sorano-rock-and-time/hero.jpg`
- Create: `user/docs/demo/trips/italy-2026-demo/04.stories/01.sorano-rock-and-time/photo-1.jpg`
- Create: `user/docs/demo/trips/italy-2026-demo/04.stories/01.sorano-rock-and-time/photo-2.jpg`
- [ ] **Step 1: Create story directory and download images**
```bash
mkdir -p user/docs/demo/trips/italy-2026-demo/04.stories/01.sorano-rock-and-time
SDIR=user/docs/demo/trips/italy-2026-demo/04.stories/01.sorano-rock-and-time
curl -sL "https://picsum.photos/seed/demo-s1-hero/1600/1000" -o "$SDIR/hero.jpg"
curl -sL "https://picsum.photos/seed/demo-s1-1/1600/1000" -o "$SDIR/photo-1.jpg"
curl -sL "https://picsum.photos/seed/demo-s1-2/1600/1000" -o "$SDIR/photo-2.jpg"
```
- [ ] **Step 2: Write story.md**
Write `user/docs/demo/trips/italy-2026-demo/04.stories/01.sorano-rock-and-time/story.md`:
```markdown
---
title: 'Sorano: Rock and Time'
date: '2026-09-03'
location_name: Sorano
location_country: Italy
lat: 42.683
lng: 11.715
hero_image: hero.jpg
hero_alt: Medieval town of Sorano clinging to pale tufa cliffs at dusk
published: true
---
The road from Orbetello climbs inland through scrubland and heat. For most of the afternoon there is nothing on the horizon except sky and the occasional electricity pylon. Then, at the top of a ridge, Sorano appears — and the word "appears" does not quite cover it. The town has been carved from a cliff of tufa, a pale volcanic rock so soft you can score it with a fingernail. The buildings are the cliff and the cliff is the buildings.
[scrolly-section image="hero.jpg" alt="Medieval town of Sorano seen from the approach road, perched on pale tufa cliffs" caption="Sorano — tufa cliff town, Grosseto province"]
The approach by bike gives you an unusually long time to study it. The descent into the valley and the climb back up take perhaps forty minutes, and the town is visible for most of that time, doing nothing, requiring nothing.
---
Close up the rock is extraordinary. Hundreds of tomb niches cut into the cliff face — Etruscan graves, most of them open to the sky now, their contents long removed. The people who built this town chose to live surrounded by the evidence of their own mortality. This seems either very brave or very sensible.
---
The gate into the old town is fifteenth century and narrow enough that loaded bikes don't fit without turning sideways. Inside, the air is noticeably cooler and the alleys are steep, paved with the same pale tufa, worn smooth by centuries of feet.
[/scrolly-section]
We found a wall to lean the bikes against and sat looking south over the valley we had come from. The light was going amber. Below us, the road we had ridden was already in shadow.
[chapter-break image="photo-1.jpg" title="After Dark" number="II" alt="Narrow medieval alley in Sorano at dusk, pale stone walls glowing warm" /]
[pull-quote image="photo-1.jpg" alt="Stone alley in Sorano lit by a single lantern at night"]
A town built on rock, carved from rock, returning slowly to rock. Two thousand years of human effort and the cliff remains indifferent.
[/pull-quote]
[scrolly-section image="photo-2.jpg" alt="View south from the tufa cliff walls of Sorano at dusk" caption="Val di Fiora, from the old walls"]
One restaurant was open. The menu was four items. We had the pasta with wild boar and the pasta with truffles and a carafe of local wine that cost six euros and was excellent.
---
The owner sat at the next table watching a football match on his phone without headphones. Nobody minded. The town outside was completely quiet.
[/scrolly-section]
We were in bed before nine. Sorano at night is absolutely silent. It has been this quiet, in approximately this configuration, for a very long time.
```
- [ ] **Step 3: Verify shortcode counts match test S3 expectations**
```bash
grep -c "scrolly-section" user/docs/demo/trips/italy-2026-demo/04.stories/01.sorano-rock-and-time/story.md
grep -c "chapter-break" user/docs/demo/trips/italy-2026-demo/04.stories/01.sorano-rock-and-time/story.md
grep -c "pull-quote image=" user/docs/demo/trips/italy-2026-demo/04.stories/01.sorano-rock-and-time/story.md
```
Expected: `2` scrolly-section tags (opening tags only), `1` chapter-break, `1` pull-quote with image.
- [ ] **Step 4: Commit**
```bash
git -C user add -A
git -C user commit -m "feat(demo): add story 1 — Sorano: Rock and Time"
```
---
### Task 4: Write Story 2 — Val d'Orcia at Dawn
**Target:** `user/docs/demo/trips/italy-2026-demo/04.stories/02.val-dorcia-at-dawn/`
**Test coverage:** `STORY_GALLERY` — needs snap-gallery × 2, chapter-break, text-only pull-quote
**Files:**
- Create: `user/docs/demo/trips/italy-2026-demo/04.stories/02.val-dorcia-at-dawn/story.md`
- Create: `hero.jpg`, `photo-1.jpg`, `photo-2.jpg` (same directory)
- [ ] **Step 1: Create story directory and download images**
```bash
mkdir -p user/docs/demo/trips/italy-2026-demo/04.stories/02.val-dorcia-at-dawn
SDIR=user/docs/demo/trips/italy-2026-demo/04.stories/02.val-dorcia-at-dawn
curl -sL "https://picsum.photos/seed/demo-s2-hero/1600/1000" -o "$SDIR/hero.jpg"
curl -sL "https://picsum.photos/seed/demo-s2-1/1600/1000" -o "$SDIR/photo-1.jpg"
curl -sL "https://picsum.photos/seed/demo-s2-2/1600/1000" -o "$SDIR/photo-2.jpg"
```
- [ ] **Step 2: Write story.md**
Write `user/docs/demo/trips/italy-2026-demo/04.stories/02.val-dorcia-at-dawn/story.md`:
```markdown
---
title: "Val d'Orcia at Dawn"
date: '2026-09-05'
location_name: Val d'Orcia
location_country: Italy
lat: 43.078
lng: 11.676
hero_image: hero.jpg
hero_alt: Wide Tuscan valley at dawn, long cypress shadows across pale gravel road
published: true
---
We left before the heat arrived. The alarm was five-thirty and the sky outside the tent was still more grey than blue. The valley was invisible in the dark except as an absence — a vast silence below us where the shapes of hills ought to be. By six the light had changed. The Val d'Orcia is one of those landscapes that photographers wait years to shoot at this hour, and you can see why: the light arrives at an angle that makes everything look like something from a different century.
[snap-gallery images="hero.jpg,photo-1.jpg,photo-2.jpg" captions="Six in the morning: the valley belongs entirely to the light,The Cypress Road — every photograph of Tuscany was taken here or somewhere like it,A farmhouse that has been sitting on this hill for four hundred years" alts="Wide misty Tuscan valley at dawn with long shadows,Straight road lined by tall cypress trees in morning light,Stone farmhouse on a hilltop with rolling landscape behind" /]
The roads down here are white gravel — strade bianche — and the tyres make a particular sound on them that you don't get anywhere else. We rode for two hours without seeing a car. The only other people were two elderly men walking a dog in the opposite direction. They waved.
[chapter-break image="photo-1.jpg" title="The Hour Before Heat" alt="Cypress road vanishing into a hazy summer morning" /]
By nine the temperature had already shifted. The quality of the light changed — softer, more diffuse, the sky turning white at the edges. The windows of the farmhouses began to open. Dogs that had been invisible in the dark became visible on walls and in doorways, watching us with professional detachment.
[snap-gallery images="photo-2.jpg,hero.jpg" captions="The road changes from asphalt to gravel to packed earth and back again without warning,The valley floor at nine: the shadows have shortened, the colours have flattened" alts="Farmhouse detail with terracotta roof and single cypress tree,Tuscan valley road in mid-morning haze" /]
[pull-quote]
The best hours of a cycling day are the ones nobody else sees. Before the heat arrives, before the cafes open, before the traffic comes. Everything belongs to you then.
[/pull-quote]
We reached Pienza at eleven-thirty. The ice-cream queue was eight deep and entirely justified.
```
- [ ] **Step 3: Verify shortcode counts match test S2 expectations**
```bash
grep -c "snap-gallery" user/docs/demo/trips/italy-2026-demo/04.stories/02.val-dorcia-at-dawn/story.md
grep -c "chapter-break" user/docs/demo/trips/italy-2026-demo/04.stories/02.val-dorcia-at-dawn/story.md
grep -c "pull-quote__inner--no-image\|^\[pull-quote\]" user/docs/demo/trips/italy-2026-demo/04.stories/02.val-dorcia-at-dawn/story.md
```
Simpler check — verify exactly 2 `[snap-gallery` tags and 1 `[pull-quote]` (no `image=`):
```bash
grep -c "\[snap-gallery" user/docs/demo/trips/italy-2026-demo/04.stories/02.val-dorcia-at-dawn/story.md
grep "\[pull-quote" user/docs/demo/trips/italy-2026-demo/04.stories/02.val-dorcia-at-dawn/story.md
```
Expected: `2` snap-gallery, and the pull-quote line has no `image=` attribute.
- [ ] **Step 4: Commit**
```bash
git -C user add -A
git -C user commit -m "feat(demo): add story 2 — Val d'Orcia at Dawn"
```
---
### Task 5: Write Story 3 — One Evening in Siena
**Target:** `user/docs/demo/trips/italy-2026-demo/04.stories/03.one-evening-siena/`
**Primary shortcode:** `pull-quote` with background image; also uses `chapter-break` and `scrolly-section`
**Files:**
- Create: `user/docs/demo/trips/italy-2026-demo/04.stories/03.one-evening-siena/story.md`
- Create: `hero.jpg`, `photo-1.jpg` (same directory)
- [ ] **Step 1: Create story directory and download images**
```bash
mkdir -p user/docs/demo/trips/italy-2026-demo/04.stories/03.one-evening-siena
SDIR=user/docs/demo/trips/italy-2026-demo/04.stories/03.one-evening-siena
curl -sL "https://picsum.photos/seed/demo-s3-hero/1600/1000" -o "$SDIR/hero.jpg"
curl -sL "https://picsum.photos/seed/demo-s3-1/1600/1000" -o "$SDIR/photo-1.jpg"
```
- [ ] **Step 2: Write story.md**
Write `user/docs/demo/trips/italy-2026-demo/04.stories/03.one-evening-siena/story.md`:
```markdown
---
title: 'One Evening in Siena'
date: '2026-09-05'
location_name: Siena
location_country: Italy
lat: 43.318
lng: 11.330
hero_image: hero.jpg
hero_alt: Piazza del Campo at dusk, terracotta paving fading from gold to shadow
published: true
---
[pull-quote image="hero.jpg" alt="Piazza del Campo seen from the upper rim at golden hour"]
Siena is not a city that tries to impress you. It has been here for a thousand years and intends to be here for a thousand more. You fit around it, not the other way.
[/pull-quote]
We rolled in at half past six, legs finished, panniers heavier than they started. The Campo appeared without warning at the end of a narrow street and we both stopped pedalling at exactly the same moment. That particular square does something to people. It is partly the shape — a shallow bowl, a scallop shell, the way it holds you — and partly the light at that hour, which turns the terracotta pavement the colour of old copper.
[chapter-break image="photo-1.jpg" title="The Campo" number="I" alt="Detail of Siena's herringbone brick pavement catching the last light" /]
[scrolly-section image="hero.jpg" alt="Piazza del Campo filling with people as evening comes" caption="Campo, 19:00 — the square fills from the edges inward"]
The locals arrive first. They know which spot faces west and which benches stay in the shade longest. Then the tourists, then the pigeons, then the long shadows.
---
A busker with an accordion near the Fonte Gaia. A group of students lying on the slope reading. Three children running in a circle for reasons nobody questioned.
---
We sat on the pavement with our backs against the warm brickwork of the Palazzo Pubblico and did not move for forty minutes. The relief of sitting still after eight hours on a bike is a specific physical sensation. It travels upward from your legs and settles somewhere just behind the sternum.
[/scrolly-section]
We found a place for dinner three streets away, down a flight of steps with no sign outside. The pasta was handmade, the wine was local, the bill was reasonable. We were in bed by ten. Tomorrow: Florence.
```
- [ ] **Step 3: Commit**
```bash
git -C user add -A
git -C user commit -m "feat(demo): add story 3 — One Evening in Siena"
```
---
### Task 6: Write Story 4 — Florence Without a Map
**Target:** `user/docs/demo/trips/italy-2026-demo/04.stories/04.florence-without-a-map/`
**Primary shortcode:** `chapter-break` as structural divider; also uses `snap-gallery`, `pull-quote` (text-only), `scrolly-section`
**Files:**
- Create: `user/docs/demo/trips/italy-2026-demo/04.stories/04.florence-without-a-map/story.md`
- Create: `hero.jpg`, `photo-1.jpg` (same directory)
- [ ] **Step 1: Create story directory and download images**
```bash
mkdir -p user/docs/demo/trips/italy-2026-demo/04.stories/04.florence-without-a-map
SDIR=user/docs/demo/trips/italy-2026-demo/04.stories/04.florence-without-a-map
curl -sL "https://picsum.photos/seed/demo-s4-hero/1600/1000" -o "$SDIR/hero.jpg"
curl -sL "https://picsum.photos/seed/demo-s4-1/1600/1000" -o "$SDIR/photo-1.jpg"
```
- [ ] **Step 2: Write story.md**
Write `user/docs/demo/trips/italy-2026-demo/04.stories/04.florence-without-a-map/story.md`:
```markdown
---
title: 'Florence Without a Map'
date: '2026-09-07'
location_name: Florence
location_country: Italy
lat: 43.769
lng: 11.255
hero_image: hero.jpg
hero_alt: Arno river at midday with Ponte Vecchio, ochre buildings reflected in still water
published: true
---
No route today. No GPS, no distance target, no reason to be anywhere by any particular time. After six days of forward motion this felt almost wrong — the instinct to check the elevation profile arriving at nothing. We put the bikes in the hotel basement and walked out into Florence on foot.
[chapter-break image="hero.jpg" title="Day Seven" number="VII" alt="Arno river and Ponte Vecchio from Ponte Santa Trinita at midday" /]
[snap-gallery images="hero.jpg,photo-1.jpg" captions="The Arno at noon — greener than expected, the bridges older than you remember,Via dei Servi: washing lines, shutters, a cat on a warm stone ledge that had been warm since morning" alts="Arno river with Ponte Vecchio reflected in still water at midday,Narrow Florence street with laundry strung between buildings" /]
[pull-quote]
Cycling makes you earn every city you arrive at. Florence, we got for free. It felt like a gift and a debt simultaneously.
[/pull-quote]
[scrolly-section image="photo-1.jpg" alt="Narrow Oltrarno street in afternoon light" caption="Oltrarno, 14:00"]
The Uffizi had a queue that stretched around two corners and disappeared into a side street. We looked at it for a moment and went to find coffee instead. This felt correct.
---
A covered market in the Oltrarno that nobody had told us about. A man selling leather goods from a table he clearly reassembled each morning from identical components. A small dog sleeping under a fruit stall in a precisely calculated patch of shade.
---
We crossed the Ponte Vecchio at two in the afternoon, which is exactly the wrong time to cross the Ponte Vecchio, and it was still worth it. The light off the Arno at that hour is genuinely extraordinary and all the photographs in the world do not prepare you for it.
[/scrolly-section]
Dinner near the apartment, early. Feet sore in a different way from legs sore — a smaller, more concentrated complaint. Tomorrow: the last day. The coast road home.
```
- [ ] **Step 3: Commit**
```bash
git -C user add -A
git -C user commit -m "feat(demo): add story 4 — Florence Without a Map"
```
---
### Task 7: Write Journal Entries — Days 14 (entries 16)
**Target:** `user/docs/demo/trips/italy-2026-demo/dailies/`
Each entry directory name: `<slug>.entry/`
Each entry contains: `entry.md` + numbered images `01.jpg`, `02.jpg`, …
- [ ] **Step 1: Entry 1 — Setting Off from Campiglia (2 photos)**
```bash
EDIR=user/docs/demo/trips/italy-2026-demo/dailies/2026-09-01-0700-setting-off-from-campiglia.entry
mkdir -p "$EDIR"
curl -sL "https://picsum.photos/seed/demo-d1-1/1200/800" -o "$EDIR/01.jpg"
curl -sL "https://picsum.photos/seed/demo-d1-2/1200/800" -o "$EDIR/02.jpg"
```
Write `$EDIR/entry.md`:
```markdown
---
title: 'Setting Off from Campiglia'
date: '2026-09-01 07:00'
template: entry
published: true
hero_image: ''
lat: 43.024
lng: 10.603
location_city: Campiglia Marittima
location_country: Italy
weather_temp_c: 27
weather_desc: Sunny
---
Seven in the morning and the coast road is still cool. We loaded the bikes in the car park below the old town, the panniers heavier than they should be and the weather forecast saying nine consecutive days of sun. The route heads south first — down into the Maremma, then east, then a long loop back. Eight days. Nobody goes this way in September except cyclists and people who have got lost.
```
- [ ] **Step 2: Entry 2 — Maremma in Full Sun (3 photos)**
```bash
EDIR=user/docs/demo/trips/italy-2026-demo/dailies/2026-09-02-1130-maremma-in-full-sun.entry
mkdir -p "$EDIR"
curl -sL "https://picsum.photos/seed/demo-d2a-1/1200/800" -o "$EDIR/01.jpg"
curl -sL "https://picsum.photos/seed/demo-d2a-2/1200/800" -o "$EDIR/02.jpg"
curl -sL "https://picsum.photos/seed/demo-d2a-3/1200/800" -o "$EDIR/03.jpg"
```
Write `$EDIR/entry.md`:
```markdown
---
title: 'Maremma in Full Sun'
date: '2026-09-02 11:30'
template: entry
published: true
hero_image: ''
lat: 42.612
lng: 11.171
location_city: Maremma
location_country: Italy
weather_temp_c: 29
weather_desc: Sunny
---
Eleven-thirty and already thirty degrees. The Maremma is agricultural land and scrubland and very little else, and in September it has the quality of a landscape that has given up trying. The road is straight, the sun is direct, the shadows are almost vertical. We stopped at a petrol station and drank two cans of something cold each. The man at the counter looked at us like people who had made a series of questionable decisions.
```
- [ ] **Step 3: Entry 3 — The Lagoon at Dusk (3 photos)**
```bash
EDIR=user/docs/demo/trips/italy-2026-demo/dailies/2026-09-02-1900-the-lagoon-at-dusk.entry
mkdir -p "$EDIR"
curl -sL "https://picsum.photos/seed/demo-d2b-1/1200/800" -o "$EDIR/01.jpg"
curl -sL "https://picsum.photos/seed/demo-d2b-2/1200/800" -o "$EDIR/02.jpg"
curl -sL "https://picsum.photos/seed/demo-d2b-3/1200/800" -o "$EDIR/03.jpg"
```
Write `$EDIR/entry.md`:
```markdown
---
title: 'The Lagoon at Dusk'
date: '2026-09-02 19:00'
template: entry
published: true
hero_image: ''
lat: 42.442
lng: 11.218
location_city: Orbetello
location_country: Italy
weather_temp_c: 24
weather_desc: Partly cloudy
---
Orbetello sits on a causeway between two lagoons and at dusk the light does something remarkable to the water. Pink flamingos — real ones, not ornamental — were standing in the shallows on the western side, perfectly still. We ate at a table outside overlooking the eastern lagoon. The sky turned orange and then purple and then a deep blue that was almost indistinguishable from the water. The wine was cold and the pasta had clams.
```
- [ ] **Step 4: Entry 4 — Orbetello Morning (2 photos)**
```bash
EDIR=user/docs/demo/trips/italy-2026-demo/dailies/2026-09-03-0800-orbetello-morning.entry
mkdir -p "$EDIR"
curl -sL "https://picsum.photos/seed/demo-d3a-1/1200/800" -o "$EDIR/01.jpg"
curl -sL "https://picsum.photos/seed/demo-d3a-2/1200/800" -o "$EDIR/02.jpg"
```
Write `$EDIR/entry.md`:
```markdown
---
title: 'Orbetello Morning'
date: '2026-09-03 08:00'
template: entry
published: true
hero_image: ''
lat: 42.442
lng: 11.217
location_city: Orbetello
location_country: Italy
weather_temp_c: 22
weather_desc: Sunny
---
The lagoon at eight in the morning is a different thing from the lagoon at eight in the evening. Flat, silver, nearly silent. A single fisherman in a small boat about two hundred metres out, not appearing to fish. We left before the town had properly woken up, heading northeast on roads that climbed immediately and steeply into a landscape of oak and limestone that felt nothing like the coast we had left behind twenty minutes before.
```
- [ ] **Step 5: Entry 5 — Tufa and Towers (2 photos)**
```bash
EDIR=user/docs/demo/trips/italy-2026-demo/dailies/2026-09-03-1700-tufa-and-towers.entry
mkdir -p "$EDIR"
curl -sL "https://picsum.photos/seed/demo-d3b-1/1200/800" -o "$EDIR/01.jpg"
curl -sL "https://picsum.photos/seed/demo-d3b-2/1200/800" -o "$EDIR/02.jpg"
```
Write `$EDIR/entry.md`:
```markdown
---
title: 'Tufa and Towers'
date: '2026-09-03 17:00'
template: entry
published: true
hero_image: ''
lat: 42.683
lng: 11.715
location_city: Sorano
location_country: Italy
weather_temp_c: 26
weather_desc: Sunny
---
Sorano appears on the horizon an hour before you reach it: a cluster of towers and walls on a pale cliff, floating above the valley. The closer you get the stranger it becomes. The town is not built on rock — the town is rock, volcanic tufa carved and inhabited over two thousand years. The Etruscans started it. Everyone since has just kept adding floors. We are staying the night and it already feels like somewhere that requires more time than we have.
```
- [ ] **Step 6: Entry 6 — The Long Climb North (4 photos)**
```bash
EDIR=user/docs/demo/trips/italy-2026-demo/dailies/2026-09-04-1500-the-long-climb-north.entry
mkdir -p "$EDIR"
curl -sL "https://picsum.photos/seed/demo-d4-1/1200/800" -o "$EDIR/01.jpg"
curl -sL "https://picsum.photos/seed/demo-d4-2/1200/800" -o "$EDIR/02.jpg"
curl -sL "https://picsum.photos/seed/demo-d4-3/1200/800" -o "$EDIR/03.jpg"
curl -sL "https://picsum.photos/seed/demo-d4-4/1200/800" -o "$EDIR/04.jpg"
```
Write `$EDIR/entry.md`:
```markdown
---
title: 'The Long Climb North'
date: '2026-09-04 15:00'
template: entry
published: true
hero_image: ''
lat: 43.077
lng: 11.678
location_city: "Val d'Orcia"
location_country: Italy
weather_temp_c: 23
weather_desc: Partly cloudy
---
Today was the hardest day. The route from Sorano to the Val d'Orcia crosses the eastern slope of Monte Amiata, which sounds manageable on a map and is not manageable at all. By noon we had climbed eleven hundred metres. By two we were somewhere above Seggiano in thin cloud, the views long gone, legs complaining in a language that had become very specific. Then the cloud lifted and the Val d'Orcia was simply there below us: pale roads, dark cypress, the whole thing exactly as advertised. Sometimes the landscapes that have been photographed to death are still worth arriving at.
```
- [ ] **Step 7: Commit entries 16**
```bash
git -C user add -A
git -C user commit -m "feat(demo): add journal entries days 14 with photos"
```
---
### Task 8: Write Journal Entries — Days 58 (entries 712)
- [ ] **Step 1: Entry 7 — Before the Heat Arrives (2 photos)**
```bash
EDIR=user/docs/demo/trips/italy-2026-demo/dailies/2026-09-05-0830-before-the-heat-arrives.entry
mkdir -p "$EDIR"
curl -sL "https://picsum.photos/seed/demo-d5a-1/1200/800" -o "$EDIR/01.jpg"
curl -sL "https://picsum.photos/seed/demo-d5a-2/1200/800" -o "$EDIR/02.jpg"
```
Write `$EDIR/entry.md`:
```markdown
---
title: 'Before the Heat Arrives'
date: '2026-09-05 08:30'
template: entry
published: true
hero_image: ''
lat: 43.078
lng: 11.676
location_city: Pienza
location_country: Italy
weather_temp_c: 21
weather_desc: Sunny
---
Six o'clock and the valley below Pienza is still in shadow. We left camp early on purpose — the route to Siena is long and September sun waits for no one. On the strade bianche the tyres make a sound like distant applause. No cars for the first two hours. Just the road and the light doing things to the cypress trees that would be embarrassing to describe in any other context.
```
- [ ] **Step 2: Entry 8 — Into Siena (3 photos)**
```bash
EDIR=user/docs/demo/trips/italy-2026-demo/dailies/2026-09-05-1800-into-siena.entry
mkdir -p "$EDIR"
curl -sL "https://picsum.photos/seed/demo-d5b-1/1200/800" -o "$EDIR/01.jpg"
curl -sL "https://picsum.photos/seed/demo-d5b-2/1200/800" -o "$EDIR/02.jpg"
curl -sL "https://picsum.photos/seed/demo-d5b-3/1200/800" -o "$EDIR/03.jpg"
```
Write `$EDIR/entry.md`:
```markdown
---
title: 'Into Siena'
date: '2026-09-05 18:00'
template: entry
published: true
hero_image: ''
lat: 43.318
lng: 11.335
location_city: Siena
location_country: Italy
weather_temp_c: 25
weather_desc: Sunny
---
The approach to Siena by bike is through streets that get progressively older and steeper until suddenly the Campo is there. We had both seen it in photographs and the photographs are accurate in every way except one: they do not tell you how the square smells — stone and frying onions and the particular warm stillness of a Sienese summer evening. We sat on the pavement with our backs against the Palazzo Pubblico for forty minutes and did not want to be anywhere else.
```
- [ ] **Step 3: Entry 9 — Florence by Nightfall (3 photos)**
```bash
EDIR=user/docs/demo/trips/italy-2026-demo/dailies/2026-09-06-2000-florence-by-nightfall.entry
mkdir -p "$EDIR"
curl -sL "https://picsum.photos/seed/demo-d6-1/1200/800" -o "$EDIR/01.jpg"
curl -sL "https://picsum.photos/seed/demo-d6-2/1200/800" -o "$EDIR/02.jpg"
curl -sL "https://picsum.photos/seed/demo-d6-3/1200/800" -o "$EDIR/03.jpg"
```
Write `$EDIR/entry.md`:
```markdown
---
title: 'Florence by Nightfall'
date: '2026-09-06 20:00'
template: entry
published: true
hero_image: ''
lat: 43.767
lng: 11.253
location_city: Florence
location_country: Italy
weather_temp_c: 21
weather_desc: Cloudy
---
A long day. Siena to Florence is ninety kilometres and involves two significant climbs before you reach the Chianti hills, after which it becomes more manageable but you have already used the legs you needed. We came in from the south as the light was going, the city materialising from a distance as a density of rooftops and towers. The Arno appeared between buildings and we crossed it and then we were in, which is always a slightly surprising moment after a long day.
```
- [ ] **Step 4: Entry 10 — One Rest Day (2 photos)**
```bash
EDIR=user/docs/demo/trips/italy-2026-demo/dailies/2026-09-07-1400-one-rest-day.entry
mkdir -p "$EDIR"
curl -sL "https://picsum.photos/seed/demo-d7-1/1200/800" -o "$EDIR/01.jpg"
curl -sL "https://picsum.photos/seed/demo-d7-2/1200/800" -o "$EDIR/02.jpg"
```
Write `$EDIR/entry.md`:
```markdown
---
title: 'One Rest Day'
date: '2026-09-07 14:00'
template: entry
published: true
hero_image: ''
lat: 43.769
lng: 11.255
location_city: Florence
location_country: Italy
weather_temp_c: 22
weather_desc: Partly cloudy
---
The bikes stayed in the basement. We walked instead, which after six days of cycling felt simultaneously easier and harder — easier on the legs, harder on the feet, which are used to being passive. Florence does not require a plan. Every street contains something. We crossed the Arno four times from different bridges, each one giving a slightly different version of the same view, all of them good.
```
- [ ] **Step 5: Entry 11 — Dawn on the Cecina Coast (1 photo)**
```bash
EDIR=user/docs/demo/trips/italy-2026-demo/dailies/2026-09-08-0730-dawn-on-the-cecina-coast.entry
mkdir -p "$EDIR"
curl -sL "https://picsum.photos/seed/demo-d8a-1/1200/800" -o "$EDIR/01.jpg"
```
Write `$EDIR/entry.md`:
```markdown
---
title: 'Dawn on the Cecina Coast'
date: '2026-09-08 07:30'
template: entry
published: true
hero_image: ''
lat: 43.553
lng: 10.313
location_city: Cecina
location_country: Italy
weather_temp_c: 20
weather_desc: Sunny
---
The last day starts on the coast road south of Cecina, the sea visible between the pine trees. We have been inland for most of the week and the smell of salt water is a surprise. The road is flat, which after eight days of Tuscan hills feels almost suspicious. We rode in silence for the first hour. There was nothing that needed saying.
```
- [ ] **Step 6: Entry 12 — Home (2 photos)**
```bash
EDIR=user/docs/demo/trips/italy-2026-demo/dailies/2026-09-08-1630-home.entry
mkdir -p "$EDIR"
curl -sL "https://picsum.photos/seed/demo-d8b-1/1200/800" -o "$EDIR/01.jpg"
curl -sL "https://picsum.photos/seed/demo-d8b-2/1200/800" -o "$EDIR/02.jpg"
```
Write `$EDIR/entry.md`:
```markdown
---
title: 'Home'
date: '2026-09-08 16:30'
template: entry
published: true
hero_image: ''
lat: 43.017
lng: 10.587
location_city: Campiglia Marittima
location_country: Italy
weather_temp_c: 26
weather_desc: Sunny
---
The old town of Campiglia was visible on its hill for the last twenty kilometres, appearing and disappearing between the trees the way it had appeared on the horizon eight days ago when we left. The loop is complete: same car park, same view across the coast, different legs. The bikes went back in the car and we sat on a wall and counted the countries and the kilometres and the pasta dishes. Eight days, one loop, Tuscany in September. It was exactly what it was supposed to be.
```
- [ ] **Step 7: Verify 12 entry directories exist**
```bash
ls user/docs/demo/trips/italy-2026-demo/dailies/ | grep -c "\.entry$"
```
Expected: `12`
- [ ] **Step 8: Commit entries 712**
```bash
git -C user add -A
git -C user commit -m "feat(demo): add journal entries days 58 with photos"
```
---
### Task 9: Verify with demo-load and run tests
**Prerequisite:** Docker dev server running at `http://localhost:8081`
- [ ] **Step 1: Reset any existing demo content**
```bash
make demo-reset
```
- [ ] **Step 2: Load demo content**
```bash
make demo-load
```
Expected: no errors; ends with `Cache cleared`.
- [ ] **Step 3: Smoke check in browser**
Open `http://localhost:8081/trips/italy-2026-demo` — verify:
- Trip page shows "Tuscany 2026"
- Filter bar shows All / Journal / Stories
- Journal entries visible (should show most recent first)
- Stories tab shows 4 story cards
Open one entry (e.g. Entry 6 with 4 photos) and verify:
- Hero image renders (01.jpg)
- Gallery grid shows all 4 photos
- Lightbox opens on click
Open `http://localhost:8081/trips/italy-2026-demo/stories/sorano-rock-and-time` and verify:
- `scrolly` sections render
- `chapter-break` renders
- `pull-quote` with background image renders
Open `http://localhost:8081/trips/italy-2026-demo/map` and verify:
- All 7 GPX routes render on the map
- Entry markers appear at the correct coordinates
- [ ] **Step 4: Run the full test suite**
```bash
npm run test:ui
```
Expected: all tests pass. Key tests to watch:
- `S1`: stories listing shows ≥ 3 cards → passes (4 stories)
- `S2`: gallery story has 2 snap-galleries, chapter-break, text-only pull-quote
- `S3`: scrolly story has 2 scrolly-sections, chapter-break, pull-quote with image
- `S4`: no JS errors on scrolly story
- `S5`: back button navigates to stories listing
- `S6/S7`: demo story hero renders, back-pill present
If a test fails, diagnose before moving on — do not proceed to final commit with failing tests.
- [ ] **Step 5: Final commit**
```bash
git -C user add -A
git -C user commit -m "chore(demo): verify demo content complete — all tests passing"
```
---
## Self-Review Checklist
- [x] Spec § 1 Cleanup → Task 1
- [x] Spec § 2 GPX rename (4 files) → Task 1, Step 23
- [x] Spec § 3 Journal entries (12) → Tasks 78
- [x] Spec § 4 Stories (4) → Tasks 36; shortcode counts designed to match S2/S3 test assertions
- [x] Spec § 5 Makefile — existing `cp -r` commands already handle images; no Makefile changes needed
- [x] Spec § 6 trip.md update → Task 1, Step 4
- [x] Spec § 7 What is NOT changing — italy-2025 pages untouched, japan-korea-2026 pages untouched ✓
- [x] stories.spec.js slug update → Task 2 (covers both constants and the hardcoded S7 URL)
- [x] `dailies.md` index page → Task 1, Step 5 (needed for Grav to render the dailies listing after demo-reset)
- [x] No placeholder text in any step
- [x] All 4 shortcode types appear across 4 stories, with STORY_GALLERY and STORY_SCROLLY matching test assertion counts
@@ -0,0 +1,935 @@
# GPX Connector Logic 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:** Suppress the straight-line connector between adjacent map markers when a single GPX file covers both endpoints; keep connectors for uncovered gaps; add `force_connect` and `transport_mode` fields to entry/story blueprints.
**Architecture:** Pure client-side. GPX files are already fetched to display tracks; their parsed trackpoints are reused to run a same-file proximity check per adjacent marker pair. Journey segments are built after all GPX fetches settle (Promise.all). The algorithm lives in `maplibre-utils.js` as pure functions exposed on `MapUtils`.
**Tech Stack:** Vanilla JS (ES5 IIFE pattern matching existing code), MapLibre GL 4, `@mapbox/togeojson` 0.16.2, Grav 2 blueprint YAML, Playwright for tests.
## Global Constraints
- ES5 syntax only in all JS — no arrow functions, const/let, template literals, or modules (matching existing `maplibre-utils.js` style)
- All JS functions inside the existing `maplibre-utils.js` IIFE
- Grav blueprint fields use `header.<fieldname>` prefix in the `form.fields` tree
- Proximity threshold: **10 km** (hardcoded, not configurable)
- Trackpoints stored internally as `[lat, lng]` (latitude first); MapLibre coords are `[lng, lat]` (longitude first) — never mix these up
- Demo data required for Playwright tests: run `make demo-load` before the test suite
- Dev server runs at `http://localhost:8081`
---
### Task 1: Blueprint — add `force_connect` and `transport_mode` fields
**Files:**
- Modify: `user/themes/intotheeast/blueprints/entry.yaml`
- Create: `user/themes/intotheeast/blueprints/story.yaml`
**Interfaces:**
- Produces: `entry.header.force_connect` (bool, default false), `entry.header.transport_mode` (string, default null) available in Twig templates and Admin2 UI
- [ ] **Step 1: Add a Journey tab to `entry.yaml`**
In `user/themes/intotheeast/blueprints/entry.yaml`, append this tab section after the `publishing:` tab block (before the closing of the `tabs.fields` block). The final file should end with:
```yaml
journey:
type: tab
title: Journey
fields:
header.transport_mode:
type: select
label: How I arrived here
default: ''
options:
'': '— not specified —'
'walking': 'Walking'
'bicycle': 'Bicycle'
'bus': 'Bus'
'train': 'Train'
'car': 'Car'
header.force_connect:
type: toggle
label: Force connector line
help: 'When GPX tracks are present, always draw a connector from the previous marker to this one'
highlight: 1
default: 0
options:
1: 'Yes'
0: 'No'
validate:
type: bool
```
- [ ] **Step 2: Create `story.yaml` blueprint**
Create `user/themes/intotheeast/blueprints/story.yaml` with this full content (covers all existing story frontmatter fields plus the new Journey tab):
```yaml
title: 'Story'
form:
fields:
tabs:
type: tabs
active: 1
fields:
content:
type: tab
title: Content
fields:
header.title:
type: text
label: Title
validate:
required: true
header.date:
type: datetime
label: Date
format: 'Y-m-d H:i'
validate:
required: true
header.hero_image:
type: text
label: Hero Image
placeholder: 'hero.jpg'
help: 'Filename of the hero image (upload via Media tab)'
header.hero_alt:
type: text
label: Hero Image Alt Text
placeholder: 'Description of the hero image'
content:
type: markdown
label: Content
validate:
required: true
location:
type: tab
title: Location
fields:
header.location_name:
type: text
label: Location Name
placeholder: 'e.g. Val d''Orcia'
header.location_country:
type: text
label: Country
placeholder: 'e.g. Italy'
header.lat:
type: text
label: Latitude
placeholder: '43.0780'
help: 'GPS latitude (decimal degrees)'
header.lng:
type: text
label: Longitude
placeholder: '11.6760'
help: 'GPS longitude (decimal degrees)'
publishing:
type: tab
title: Publishing
fields:
header.published:
type: toggle
label: Published
highlight: 1
default: 1
options:
1: 'Yes'
0: 'No'
validate:
type: bool
journey:
type: tab
title: Journey
fields:
header.transport_mode:
type: select
label: How I arrived here
default: ''
options:
'': '— not specified —'
'walking': 'Walking'
'bicycle': 'Bicycle'
'bus': 'Bus'
'train': 'Train'
'car': 'Car'
header.force_connect:
type: toggle
label: Force connector line
help: 'When GPX tracks are present, always draw a connector from the previous marker to this one'
highlight: 1
default: 0
options:
1: 'Yes'
0: 'No'
validate:
type: bool
```
- [ ] **Step 3: Manual verification**
Open Admin2 at `http://localhost:8081/admin` → edit any entry under a dailies folder → confirm a "Journey" tab appears with "How I arrived here" select and "Force connector line" toggle. Then open any story page → confirm the same "Journey" tab is present.
- [ ] **Step 4: Commit**
```bash
git add user/themes/intotheeast/blueprints/entry.yaml user/themes/intotheeast/blueprints/story.yaml
git commit -m "feat: add force_connect and transport_mode fields to entry and story blueprints"
```
---
### Task 2: Algorithm functions in `maplibre-utils.js`
**Files:**
- Modify: `user/themes/intotheeast/js/maplibre-utils.js`
- Create: `tests/ui/gpx-journey.spec.js`
**Interfaces:**
- Produces:
- `MapUtils.extractTrackpoints(geojson)``[[lat, lng], ...]`
- `MapUtils.buildJourneySegments(entries, allTrackpoints, thresholdKm)``[[lng, lat], ...][]`
- `MapUtils.addJourneySegments(map, segments, baseSourceId)` → void
- Consumes: `toGeoJSON.gpx()` output (GeoJSON FeatureCollection)
- [ ] **Step 1: Write failing Playwright tests**
Create `tests/ui/gpx-journey.spec.js`:
```javascript
// @ts-check
// Tests: G1G4 — buildJourneySegments algorithm correctness
// These tests load the italy-2025 map page (which has GPX) to get MapUtils in scope,
// then call the functions with synthetic data via page.evaluate.
// Requires demo data: run `make demo-load` before this suite.
const { test, expect } = require('@playwright/test');
async function getMapUtils(page) {
await page.goto('/trips/italy-2025/map');
await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
}
// G1: No GPX → all pairs connected in one segment
test('G1: all markers connected when no GPX files present', async ({ page }) => {
await getMapUtils(page);
var count = await page.evaluate(function () {
var entries = [
{ lat: '43.0', lng: '11.0', force_connect: false },
{ lat: '44.0', lng: '12.0', force_connect: false },
{ lat: '45.0', lng: '13.0', force_connect: false }
];
return MapUtils.buildJourneySegments(entries, [], 10).length;
});
expect(count).toBe(1);
});
// G2: Same GPX file covers both markers → connector suppressed (0 segments)
test('G2: connector suppressed when same GPX file covers both markers', async ({ page }) => {
await getMapUtils(page);
var count = await page.evaluate(function () {
var e1 = { lat: '43.000', lng: '11.000', force_connect: false };
var e2 = { lat: '43.010', lng: '11.010', force_connect: false };
// Trackpoints covering both (stored as [lat, lng])
var track = [[43.000, 11.000], [43.005, 11.005], [43.010, 11.010]];
return MapUtils.buildJourneySegments([e1, e2], [track], 10).length;
});
expect(count).toBe(0);
});
// G3: force_connect overrides GPX suppression
test('G3: force_connect keeps connector even when GPX covers both markers', async ({ page }) => {
await getMapUtils(page);
var count = await page.evaluate(function () {
var e1 = { lat: '43.000', lng: '11.000', force_connect: false };
var e2 = { lat: '43.010', lng: '11.010', force_connect: true };
var track = [[43.000, 11.000], [43.005, 11.005], [43.010, 11.010]];
return MapUtils.buildJourneySegments([e1, e2], [track], 10).length;
});
expect(count).toBe(1);
});
// G4: Markers near DIFFERENT GPX files → connector kept
test('G4: connector kept when markers are near different GPX files', async ({ page }) => {
await getMapUtils(page);
var count = await page.evaluate(function () {
var e1 = { lat: '43.000', lng: '11.000', force_connect: false };
var e2 = { lat: '45.000', lng: '13.000', force_connect: false };
// Two separate files — each only covers one marker
var trackA = [[43.000, 11.000], [43.005, 11.005]]; // near e1 only
var trackB = [[45.000, 13.000], [45.005, 13.005]]; // near e2 only
return MapUtils.buildJourneySegments([e1, e2], [trackA, trackB], 10).length;
});
expect(count).toBe(1);
});
// G5: First pair suppressed, second pair kept → one segment [e2, e3]
test('G5: suppressed first pair leaves one segment from e2 to e3', async ({ page }) => {
await getMapUtils(page);
var count = await page.evaluate(function () {
// e1→e2: covered by track → suppressed; e1 is orphaned (< 2 pts, not pushed)
// e2→e3: not covered → connector kept → segment [e2, e3]
var e1 = { lat: '43.000', lng: '11.000', force_connect: false };
var e2 = { lat: '43.010', lng: '11.010', force_connect: false };
var e3 = { lat: '45.000', lng: '13.000', force_connect: false };
var track = [[43.000, 11.000], [43.005, 11.005], [43.010, 11.010]]; // covers e1 and e2 only
var segs = MapUtils.buildJourneySegments([e1, e2, e3], [track], 10);
return segs.length;
});
expect(count).toBe(1); // one segment: [e2 → e3]
});
```
- [ ] **Step 2: Run tests to confirm they fail**
```bash
npx playwright test tests/ui/gpx-journey.spec.js
```
Expected: All 5 tests fail with `MapUtils.buildJourneySegments is not a function` (or similar).
- [ ] **Step 3: Add algorithm functions to `maplibre-utils.js`**
Inside the IIFE in `user/themes/intotheeast/js/maplibre-utils.js`, add the following functions **before** the `global.MapUtils = ...` line at the bottom:
```javascript
/* ── GPX connector algorithm ────────────────────────────────────────── */
/* Haversine distance in km between two [lat, lng] points */
function haversineKm(lat1, lng1, lat2, lng2) {
var R = 6371;
var dLat = (lat2 - lat1) * Math.PI / 180;
var dLng = (lng2 - lng1) * Math.PI / 180;
var a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLng / 2) * Math.sin(dLng / 2);
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
/*
* Extract trackpoints from a toGeoJSON output.
* Returns [[lat, lng], ...] — latitude first (internal convention).
* GeoJSON coordinates are [lng, lat]; we flip them here.
*/
function extractTrackpoints(geojson) {
var points = [];
(geojson.features || []).forEach(function (feat) {
var coords = [];
if (feat.geometry.type === 'LineString') {
coords = feat.geometry.coordinates;
} else if (feat.geometry.type === 'MultiLineString') {
feat.geometry.coordinates.forEach(function (line) {
coords = coords.concat(line);
});
}
coords.forEach(function (c) { points.push([c[1], c[0]]); }); // [lng,lat] → [lat,lng]
});
return points;
}
/*
* Check whether a marker is within thresholdKm of any trackpoint in the array.
* trackpoints: [[lat, lng], ...] (internal convention, latitude first).
* Samples every 10th point for performance; always checks the last point.
*/
function isNearTrack(markerLat, markerLng, trackpoints, thresholdKm) {
if (!trackpoints || trackpoints.length === 0) return false;
var degLat = thresholdKm / 111;
var degLng = thresholdKm / (111 * Math.cos(markerLat * Math.PI / 180));
for (var i = 0; i < trackpoints.length; i += 10) {
var pt = trackpoints[i];
if (Math.abs(pt[0] - markerLat) > degLat || Math.abs(pt[1] - markerLng) > degLng) continue;
if (haversineKm(markerLat, markerLng, pt[0], pt[1]) <= thresholdKm) return true;
}
var last = trackpoints[trackpoints.length - 1];
return haversineKm(markerLat, markerLng, last[0], last[1]) <= thresholdKm;
}
/*
* Build journey line segments from entries and GPX trackpoints.
*
* entries: [{lat, lng, force_connect}, ...] in chronological order
* allTrackpoints: [ [[lat,lng],...], ... ] — one sub-array per GPX file
* thresholdKm: proximity radius (default 10)
*
* Returns array of segments, each segment being [[lng, lat], ...] in MapLibre
* coordinate order. A segment with < 2 points is omitted.
*
* Rules:
* - No GPX files → all adjacent pairs connected (one segment)
* - GPX present, pair covered by same file → connector suppressed
* - GPX present, pair NOT covered by any single file → connector drawn
* - force_connect on arriving entry → always draw connector
*/
function buildJourneySegments(entries, allTrackpoints, thresholdKm) {
thresholdKm = thresholdKm || 10;
var hasGpx = allTrackpoints && allTrackpoints.length > 0;
var segments = [];
var current = [];
for (var i = 0; i < entries.length; i++) {
var e = entries[i];
var lngLat = [parseFloat(e.lng), parseFloat(e.lat)]; // MapLibre: [lng, lat]
if (i === 0) {
current.push(lngLat);
continue;
}
var prev = entries[i - 1];
var connect;
if (!hasGpx || e.force_connect) {
connect = true;
} else {
var pLat = parseFloat(prev.lat);
var pLng = parseFloat(prev.lng);
var cLat = parseFloat(e.lat);
var cLng = parseFloat(e.lng);
var covered = false;
for (var f = 0; f < allTrackpoints.length; f++) {
if (isNearTrack(pLat, pLng, allTrackpoints[f], thresholdKm) &&
isNearTrack(cLat, cLng, allTrackpoints[f], thresholdKm)) {
covered = true;
break;
}
}
connect = !covered;
}
if (connect) {
current.push(lngLat);
} else {
if (current.length >= 2) segments.push(current);
current = [lngLat]; // start new segment from this point
}
}
if (current.length >= 2) segments.push(current);
return segments;
}
/*
* Draw journey segments — calls addJourneyLine once per segment.
* baseSourceId: e.g. 'journey' → sources become 'journey-0', 'journey-1', ...
* (single segment gets plain 'journey' for backwards compatibility).
*/
function addJourneySegments(map, segments, baseSourceId) {
segments.forEach(function (coords, i) {
var sid = segments.length === 1 ? baseSourceId : baseSourceId + '-' + i;
addJourneyLine(map, coords, sid);
});
}
```
- [ ] **Step 4: Update the `MapUtils` export**
Replace the existing `global.MapUtils = ...` line at the bottom of the IIFE with:
```javascript
global.MapUtils = {
MAP_STYLE: MAP_STYLE,
ACCENT: ACCENT,
addJourneyLine: addJourneyLine,
addJourneySegments: addJourneySegments,
buildJourneySegments: buildJourneySegments,
extractTrackpoints: extractTrackpoints,
createDotMarker: createDotMarker
};
```
- [ ] **Step 5: Run tests to confirm G1G5 pass**
```bash
npx playwright test tests/ui/gpx-journey.spec.js
```
Expected: All 5 tests pass.
- [ ] **Step 6: Commit**
```bash
git add user/themes/intotheeast/js/maplibre-utils.js tests/ui/gpx-journey.spec.js
git commit -m "feat: add GPX proximity algorithm to MapUtils (buildJourneySegments, extractTrackpoints)"
```
---
### Task 3: Rewire `map.html.twig` to use the algorithm
**Files:**
- Modify: `user/themes/intotheeast/templates/map.html.twig`
**Interfaces:**
- Consumes: `MapUtils.extractTrackpoints`, `MapUtils.buildJourneySegments`, `MapUtils.addJourneySegments`
- Consumes: `entry.header.force_connect` from Grav page frontmatter
- [ ] **Step 1: Add `force_connect` to the Twig entry serialisation**
In `map.html.twig`, the `map_entries` loop (lines 2431) builds the entry JSON. Add `force_connect` to the merge array:
```twig
{% set map_entries = map_entries|merge([{
'lat': entry.header.lat|number_format(6, '.', ''),
'lng': entry.header.lng|number_format(6, '.', ''),
'title': entry.title,
'date': entry.date|date('d M Y'),
'url': entry.url,
'hero': hero_url,
'force_connect': entry.header.force_connect ? true : false
}]) %}
```
- [ ] **Step 2: Restructure the JS section in `map.html.twig`**
Replace the entire `<script>` block (lines 42115) with the following. Key changes: GPX loading now returns Promises with extracted trackpoints; markers and bounds are set up before GPX loads; journey segments are drawn only after Promise.all resolves.
```javascript
<script>
var ENTRIES = {{ map_entries|json_encode|raw }};
var GPX_URLS = {{ gpx_urls|json_encode|raw }};
var map = new maplibregl.Map({
container: 'trip-map',
style: MapUtils.MAP_STYLE,
center: [20, 20],
zoom: 2
});
map.addControl(new maplibregl.NavigationControl(), 'top-right');
if (ENTRIES.length === 0) {
var empty = document.createElement('div');
empty.className = 'map-empty';
empty.textContent = 'No locations yet — entries with GPS will appear here.';
document.getElementById('trip-map').appendChild(empty);
}
map.on('load', function () {
if (ENTRIES.length === 0) return;
/* ── Markers + bounds ──────────────────────────────────────── */
var bounds = new maplibregl.LngLatBounds();
ENTRIES.forEach(function (entry, i) {
var isLatest = (i === ENTRIES.length - 1);
var lngLat = [parseFloat(entry.lng), parseFloat(entry.lat)];
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(map); });
el.addEventListener('mouseleave', function () { popup.remove(); });
el.addEventListener('click', function () { window.location.href = entry.url; });
new maplibregl.Marker({ element: el }).setLngLat(lngLat).addTo(map);
});
/* ── Fit bounds ─────────────────────────────────────────────── */
if (ENTRIES.length === 1) {
map.jumpTo({ center: [parseFloat(ENTRIES[0].lng), parseFloat(ENTRIES[0].lat)], zoom: 10 });
} else {
map.fitBounds(bounds, { padding: 100, maxZoom: 11 });
}
/* ── GPX tracks + journey segments ─────────────────────────── */
Promise.all(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 = 'gpx-' + idx;
map.addSource(sid, { type: 'geojson', data: geojson });
map.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) {
var validTrackpoints = allTrackpoints.filter(function (tp) { return tp.length > 0; });
var segments = MapUtils.buildJourneySegments(ENTRIES, validTrackpoints, 10);
MapUtils.addJourneySegments(map, segments, 'journey');
});
});
</script>
```
- [ ] **Step 3: Verify the page loads without JS errors**
```bash
npx playwright test tests/ui/maps.spec.js --grep "M1|M2"
```
Expected: M1 and M2 pass (canvas renders, markers visible, no JS errors).
- [ ] **Step 4: Commit**
```bash
git add user/themes/intotheeast/templates/map.html.twig
git commit -m "feat: use buildJourneySegments in map.html.twig — suppress connectors covered by GPX"
```
---
### Task 4: Rewire `trip.html.twig` mini-map to use the algorithm
**Files:**
- Modify: `user/themes/intotheeast/templates/trip.html.twig`
**Interfaces:**
- Consumes: `MapUtils.extractTrackpoints`, `MapUtils.buildJourneySegments`, `MapUtils.addJourneySegments`
- Consumes: `item.page.header.force_connect` from Grav page frontmatter
- [ ] **Step 1: Add `force_connect` to the Twig entry serialisation**
In `trip.html.twig`, the `map_entries` loop (around line 89100) currently builds:
```twig
{% 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
}]) %}
```
Add `force_connect`:
```twig
{% 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
}]) %}
```
- [ ] **Step 2: Restructure the tripMap JS section**
The tripMap JS block starts around line 303 (`tripMap.on('load', function () {`). Replace the entire `tripMap.on('load', ...)` block with the new version below. Everything outside `tripMap.on('load', ...)` (the `var tripMap = ...` declaration, `setTimeout(function() { tripMap.resize(); }, 100);`, and the filter bar JS) stays unchanged.
Replace from `tripMap.on('load', function () {` through the closing `});` of that callback with:
```javascript
tripMap.on('load', function () {
if (TRIP_ENTRIES.length === 0) {
tripMap.jumpTo({ center: [0, 20], zoom: 2 });
return;
}
/* ── Markers + bounds ──────────────────────────────────────── */
var bounds = new maplibregl.LngLatBounds();
TRIP_ENTRIES.forEach(function (entry, i) {
var isLatest = (i === TRIP_ENTRIES.length - 1);
var lngLat = [parseFloat(entry.lng), parseFloat(entry.lat)];
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(tripMap); });
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(tripMap);
});
/* ── Fit bounds ─────────────────────────────────────────────── */
if (TRIP_ENTRIES.length === 1) {
tripMap.jumpTo({ center: [parseFloat(TRIP_ENTRIES[0].lng), parseFloat(TRIP_ENTRIES[0].lat)], zoom: 10 });
} else {
tripMap.fitBounds(bounds, { padding: 60, maxZoom: 11 });
}
/* ── GPX tracks + journey segments ─────────────────────────── */
Promise.all(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 = 'gpx-' + idx;
tripMap.addSource(sid, { type: 'geojson', data: geojson });
tripMap.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) {
var validTrackpoints = allTrackpoints.filter(function (tp) { return tp.length > 0; });
var segments = MapUtils.buildJourneySegments(TRIP_ENTRIES, validTrackpoints, 10);
MapUtils.addJourneySegments(tripMap, segments, 'trip-journey');
});
});
```
- [ ] **Step 3: Check the stats section — preserve any remaining JS below the map block**
Scan `trip.html.twig` for `parseGpxFiles` (around line 494). This is a separate GPX parsing call for the stats section. **Do not modify it** — it is a different code path and uses its own GPX fetching logic.
- [ ] **Step 4: Verify the trip page renders without JS errors**
```bash
npx playwright test tests/ui/maps.spec.js --grep "M4"
```
Expected: M4 passes (home map canvas renders, no JS errors).
Also manually visit `http://localhost:8081/trips/italy-2025` in a browser and confirm the mini-map renders, markers appear, and the browser console shows no errors.
- [ ] **Step 5: Commit**
```bash
git add user/themes/intotheeast/templates/trip.html.twig
git commit -m "feat: use buildJourneySegments in trip.html.twig mini-map"
```
---
### Task 5: Rewire `dailies.html.twig` mini-map to use the algorithm
**Files:**
- Modify: `user/themes/intotheeast/templates/dailies.html.twig`
**Interfaces:**
- Consumes: `MapUtils.extractTrackpoints`, `MapUtils.buildJourneySegments`, `MapUtils.addJourneySegments`
- [ ] **Step 1: Add GPX URL collection to the Twig section of `dailies.html.twig`**
After the existing `{% set map_entries = [] %}` block (around line 1829), add GPX URL collection from the parent trip page. Insert before the `{% if map_entries|length > 0 %}` line:
```twig
{# Collect GPX URLs from parent trip page for connector algorithm #}
{% set trip_page = page.parent() %}
{% set gpx_urls = [] %}
{% for name, media in trip_page.media.all %}
{% if name|split('.')|last == 'gpx' %}
{% set gpx_urls = gpx_urls|merge([trip_page.url ~ '/' ~ name]) %}
{% endif %}
{% endfor %}
```
- [ ] **Step 2: Add `force_connect` to the Twig entry serialisation**
In the existing `map_entries` loop (lines 2128), add `force_connect`:
```twig
{% set map_entries = map_entries|merge([{
'lat': item.page.header.lat,
'lng': item.page.header.lng,
'title': item.page.title,
'slug': item.page.slug,
'url': item.page.url,
'force_connect': item.page.header.force_connect ? true : false
}]) %}
```
- [ ] **Step 3: Add `togeojson` script and `GPX_URLS` variable to the JS section**
Inside the `{% if map_entries|length > 0 %}` block, the existing script tags are (lines 3739):
```html
<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>
```
Add the toGeoJSON script between maplibre-gl.js and maplibre-utils.js:
```html
<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="https://cdn.jsdelivr.net/npm/@mapbox/togeojson@0.16.2/togeojson.min.js"></script>
<script src="{{ url('theme://js/maplibre-utils.js') }}"></script>
```
And add the `GPX_URLS` variable immediately after `FEED_ENTRIES`:
```javascript
var FEED_ENTRIES = {{ map_entries|json_encode|raw }};
var GPX_URLS = {{ gpx_urls|json_encode|raw }};
```
- [ ] **Step 4: Restructure `feedMap.on('load', ...)` to use Promise.all**
Replace the existing `feedMap.on('load', function () { ... });` block with:
```javascript
feedMap.on('load', function () {
var bounds = new maplibregl.LngLatBounds();
FEED_ENTRIES.forEach(function (entry, i) {
var isLatest = (i === FEED_ENTRIES.length - 1);
var lngLat = [parseFloat(entry.lng), parseFloat(entry.lat)];
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(feedMap); });
el.addEventListener('mouseleave', function () { popup.remove(); });
el.addEventListener('click', function () { window.location.href = entry.url; });
new maplibregl.Marker({ element: el }).setLngLat(lngLat).addTo(feedMap);
});
if (FEED_ENTRIES.length === 1) {
feedMap.jumpTo({ center: [parseFloat(FEED_ENTRIES[0].lng), parseFloat(FEED_ENTRIES[0].lat)], zoom: 10 });
} else {
feedMap.fitBounds(bounds, { padding: 60, maxZoom: 11 });
}
Promise.all(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);
return MapUtils.extractTrackpoints(geojson);
})
.catch(function (err) {
console.warn('GPX load failed (feed-map):', url, err);
return [];
});
})).then(function (allTrackpoints) {
var validTrackpoints = allTrackpoints.filter(function (tp) { return tp.length > 0; });
var segments = MapUtils.buildJourneySegments(FEED_ENTRIES, validTrackpoints, 10);
MapUtils.addJourneySegments(feedMap, segments, 'feed-journey');
});
});
```
Note: the feed-map does **not** display GPX tracks as lines (it's a compact mini-map). GPX files are fetched solely for the proximity algorithm. This is intentional.
- [ ] **Step 5: Verify no JS errors on the dailies page**
```bash
npx playwright test tests/ui/maps.spec.js --grep "M3"
```
Expected: M3 passes (dailies mini-map canvas renders, no JS errors).
- [ ] **Step 6: Commit**
```bash
git add user/themes/intotheeast/templates/dailies.html.twig
git commit -m "feat: apply GPX connector algorithm to dailies feed mini-map"
```
---
### Task 6: Integration tests — verify algorithm is wired end-to-end
**Files:**
- Modify: `tests/ui/maps.spec.js`
**Interfaces:**
- Consumes: italy-2025 demo data (has GPX files); run `make demo-load` first
- [ ] **Step 1: Add end-to-end tests to `maps.spec.js`**
Append these tests to `tests/ui/maps.spec.js`:
```javascript
// ── M5: Italy map — no JS errors with GPX present ────────────────────────────
test('M5: Italy map page renders without JS errors (GPX present)', async ({ page }) => {
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.goto('/trips/italy-2025/map');
await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
// Wait for markers to confirm map.on('load') completed
await expect(page.locator('.maplibregl-marker').first()).toBeVisible({ timeout: 15000 });
// Give Promise.all time to resolve
await page.waitForTimeout(3000);
expect(errors, 'No JS errors on Italy map page').toHaveLength(0);
});
// ── M6: Italy map — journey source exists after GPX loads ────────────────────
test('M6: Italy map has a journey MapLibre source after GPX settles', async ({ page }) => {
await page.goto('/trips/italy-2025/map');
await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.maplibregl-marker').first()).toBeVisible({ timeout: 15000 });
// Wait until the journey source appears — addJourneySegments runs inside Promise.all.then()
// `var map = ...` in map.html.twig is a plain <script> var → available as window.map.
await page.waitForFunction(function () {
return window.map &&
(window.map.getSource('journey') !== undefined ||
window.map.getSource('journey-0') !== undefined);
}, { timeout: 15000 });
const hasSource = await page.evaluate(function () {
return !!(window.map.getSource('journey') || window.map.getSource('journey-0'));
});
expect(hasSource).toBe(true);
});
```
- [ ] **Step 2: Run the full test suite**
```bash
npx playwright test
```
Expected: All existing tests (M1M4, F1F7, G1G5, N-series, etc.) pass plus M5 and M6.
- [ ] **Step 3: Commit**
```bash
git add tests/ui/maps.spec.js
git commit -m "test: add M5M6 integration tests for GPX connector logic"
```
@@ -0,0 +1,857 @@
# Inline Journal Feed 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:** Replace click-through journal entry cards with fully inline posts (photo strip + full text) across the trip page, dailies page, and home page.
**Architecture:** Each journal entry becomes an `<article class="journal-post">` block that renders all its images in a CSS scroll-snap strip with dot indicators, followed by the full body text. The `id`, `data-type`, `data-lat`, `data-lng` attributes stay on the root so map targeting, filter JS, and flash animation continue to work. Story cards in all three feeds are unchanged.
**Tech Stack:** Grav 2.0 Twig templates, CSS scroll-snap (no library), vanilla JS IntersectionObserver-free dot sync via scroll event, Playwright tests
## Global Constraints
- All CSS values must use design tokens (`var(--...)`) — no hard-coded colours, sizes, or radii
- `id="entry-{{ entry.slug }}"` must remain on the journal post root (map scroll targeting)
- `data-type="journal"` must remain on the journal post root (filter bar JS)
- `data-lat` and `data-lng` must remain on the journal post root (map marker rendering)
- Story cards (`<a class="entry-card entry-card--story">`) are not touched by any task
- Two git repos: user content at `/home/mischa/Projects/travel-blog-intotheeast/user/` (separate git repo); outer repo at `/home/mischa/Nextcloud/Projects/travel-blog-intotheeast/`. Templates and CSS commit to the user subrepo; tests commit to the outer repo. Always update the outer repo's `user` submodule pointer in the same commit as the test changes.
- Dev server: http://localhost:8081
---
## File Map
| File | Change |
|---|---|
| `user/themes/intotheeast/css/style.css` | Add `.journal-post` component; remove journal-card-only rules; update `.is-highlighted` selector |
| `user/themes/intotheeast/templates/partials/base.html.twig` | Add photo-strip dot-sync JS before `</body>` |
| `user/themes/intotheeast/templates/dailies.html.twig` | Replace journal card block with `.journal-post`; add `weather_icons` |
| `user/themes/intotheeast/templates/trip.html.twig` | Replace journal card block with `.journal-post`; add `weather_icons` |
| `user/themes/intotheeast/templates/home.html.twig` | Replace journal card block with `.journal-post`; add `weather_icons` |
| `tests/ui/dailies.spec.js` | Update T1 selector; update T2 selectors |
| `tests/ui/maps.spec.js` | Update M7 selector |
| `tests/ui/home.spec.js` | New file — H1 test |
---
### Task 1: CSS foundation + dot-sync JS
Add all new `.journal-post` CSS and the photo-strip dot-sync JS. Remove CSS classes that are only used by the old journal entry card (not by story cards). This task has no template changes — existing tests must still pass at the end.
**Files:**
- Modify: `user/themes/intotheeast/css/style.css`
- Modify: `user/themes/intotheeast/templates/partials/base.html.twig`
**Interfaces:**
- Produces: `.journal-post`, `.journal-post-header`, `.journal-post-title`, `.journal-post-meta`, `.journal-post-permalink`, `.journal-post-location`, `.journal-post-weather`, `.journal-photo-strip`, `.journal-photo-slide`, `.journal-photo-dots`, `.journal-photo-dot.is-active`, `.journal-post-body`, `.journal-post.is-highlighted` — all usable by Tasks 24
- [x] **Step 1: Add `.journal-post` CSS block to `style.css`**
In `user/themes/intotheeast/css/style.css`, find the line:
```css
/* ── Single entry ────────────────────────────────────────────────────────────── */
```
Insert the following block **before** that comment:
```css
/* ── Journal post (inline feed) ─────────────────────────────────────────────── */
.journal-post {
border-bottom: 1px solid var(--color-border);
padding-bottom: var(--space-12);
margin-bottom: var(--space-12);
}
.journal-post-header {
margin-bottom: var(--space-4);
}
.journal-post-title {
font-family: var(--font-display);
font-size: var(--text-xl);
font-weight: 400;
line-height: var(--leading-snug);
color: var(--color-ink);
margin-bottom: var(--space-2);
}
.journal-post-meta {
font-size: var(--text-xs);
color: var(--color-ink-muted);
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
}
.journal-post-permalink {
color: var(--color-ink-muted);
text-decoration: none;
font-weight: 700;
letter-spacing: 0.07em;
}
.journal-post-permalink:hover { color: var(--color-accent); }
.journal-post-location,
.journal-post-weather {
color: var(--color-ink-muted);
}
.journal-photo-strip {
display: flex;
overflow-x: scroll;
scroll-snap-type: x mandatory;
scrollbar-width: none;
border-radius: var(--radius-md);
margin-bottom: var(--space-3);
}
.journal-photo-strip::-webkit-scrollbar { display: none; }
.journal-photo-slide {
flex: 0 0 100%;
scroll-snap-align: start;
aspect-ratio: 3 / 2;
overflow: hidden;
}
.journal-photo-slide img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.journal-photo-dots {
display: flex;
justify-content: center;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.journal-photo-dot {
width: 6px;
height: 6px;
border-radius: 9999px;
background: var(--color-border);
transition: background 0.2s;
}
.journal-photo-dot.is-active {
background: var(--color-ink-muted);
}
.journal-post-body {
font-size: var(--text-base);
line-height: var(--leading-normal);
color: var(--color-ink-2);
}
.journal-post-body p { margin-bottom: var(--space-4); }
.journal-post-body p:last-child { margin-bottom: 0; }
.journal-post.is-highlighted {
animation: card-highlight 0.7s ease-out forwards;
}
```
- [x] **Step 2: Remove journal-card-only CSS rules from `style.css`**
These rules are only used by the old journal entry card. Story cards do not use them. Remove each block exactly as shown.
**Remove `.entry-card-photo-overlay` and its children:**
```css
.entry-card-photo-overlay {
position: absolute;
inset: auto 0 0 0;
padding: var(--space-5) var(--space-4) var(--space-3);
background: linear-gradient(to top, rgba(0,0,0,0.58) 0%, transparent 100%);
display: flex;
align-items: flex-end;
gap: var(--space-3);
flex-wrap: wrap;
}
.entry-date-overlay {
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.08em;
color: rgba(255,255,255,0.92);
}
.entry-location-overlay {
font-size: var(--text-xs);
color: rgba(255,255,255,0.85);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 180px;
}
```
Replace with nothing (delete the block entirely).
**Remove the text-only meta block and its comment:**
```css
/* Card: text-only variant */
.entry-card-textmeta {
display: flex;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-3);
flex-wrap: wrap;
}
.entry-date-plain {
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.07em;
color: var(--color-ink-muted);
}
.entry-location-plain {
font-size: var(--text-xs);
color: var(--color-ink-muted);
}
```
Replace with nothing.
**Remove `.entry-excerpt` and `.entry-read-more`:**
```css
.entry-excerpt {
font-size: var(--text-base);
line-height: var(--leading-normal);
color: var(--color-ink-2);
margin-bottom: var(--space-3);
}
.entry-read-more {
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-accent);
}
```
Replace with nothing.
**Replace `.entry-card.is-highlighted` with `.journal-post.is-highlighted`:**
Find:
```css
.entry-card.is-highlighted {
animation: card-highlight 0.7s ease-out forwards;
}
```
Replace with:
```css
.journal-post.is-highlighted {
animation: card-highlight 0.7s ease-out forwards;
}
```
- [x] **Step 3: Add dot-sync JS to `base.html.twig`**
In `user/themes/intotheeast/templates/partials/base.html.twig`, find:
```twig
{{ assets.js('bottom')|raw }}
</body>
```
Replace with:
```twig
{{ assets.js('bottom')|raw }}
<script>
(function () {
document.querySelectorAll('.journal-photo-strip').forEach(function (strip) {
var dots = strip.nextElementSibling;
if (!dots || !dots.classList.contains('journal-photo-dots')) return;
var dotEls = Array.from(dots.querySelectorAll('.journal-photo-dot'));
strip.addEventListener('scroll', function () {
var idx = Math.round(strip.scrollLeft / strip.offsetWidth);
dotEls.forEach(function (d, i) { d.classList.toggle('is-active', i === idx); });
}, { passive: true });
});
})();
</script>
</body>
```
- [x] **Step 4: Run existing tests to confirm nothing broke**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
npx playwright test --project=chromium tests/ui/dailies.spec.js tests/ui/maps.spec.js tests/ui/trip-filter.spec.js tests/ui/stories.spec.js
```
Expected: all existing tests pass (M7 still passes because `trip.html.twig` has not changed yet — the JS still adds `is-highlighted` to `.entry-card` elements, and the old M7 selector `.entry-card.is-highlighted` finds the element).
- [x] **Step 5: Commit user subrepo**
```bash
cd /home/mischa/Projects/travel-blog-intotheeast/user
git add themes/intotheeast/css/style.css themes/intotheeast/templates/partials/base.html.twig
git commit -m "feat: add journal-post CSS component and dot-sync JS; remove stale journal-card-only rules"
```
---
### Task 2: dailies.html.twig + T1/T2 test updates
Replace the journal entry card in `dailies.html.twig` with the new `.journal-post` inline block. Update T1 and T2 tests to match the new structure.
**Files:**
- Modify: `user/themes/intotheeast/templates/dailies.html.twig`
- Modify: `tests/ui/dailies.spec.js`
**Interfaces:**
- Consumes: `.journal-post` CSS from Task 1
- Produces: `/trips/japan-korea-2026/dailies` renders `.journal-post` blocks; T1 and T2 pass with new selectors
- [x] **Step 1: Update T1 and T2 tests to their new selectors**
In `tests/ui/dailies.spec.js`, make the following changes:
**T1** — change `.entry-card` to `.journal-post`:
```js
// OLD
await expect(page.locator('.entry-card').first()).toBeVisible();
// NEW
await expect(page.locator('.journal-post').first()).toBeVisible();
```
**T2** — replace the entire card locator + index block with id-based selectors:
Find:
```js
// Both fixture entries must be visible on the page
const newerCard = page.locator(`.entry-card[href*="${NEWER_SLUG}"]`);
const olderCard = page.locator(`.entry-card[href*="${OLDER_SLUG}"]`);
await expect(newerCard).toBeVisible();
await expect(olderCard).toBeVisible();
// The newer entry should appear higher in the DOM (lower index)
const newerIdx = await newerCard.evaluate(el => {
return [...document.querySelectorAll('.entry-card')].findIndex(c => c === el);
});
const olderIdx = await olderCard.evaluate(el => {
return [...document.querySelectorAll('.entry-card')].findIndex(c => c === el);
});
```
Replace with:
```js
// Both fixture entries must be visible on the page
const newerCard = page.locator(`#entry-${NEWER_SLUG}`);
const olderCard = page.locator(`#entry-${OLDER_SLUG}`);
await expect(newerCard).toBeVisible();
await expect(olderCard).toBeVisible();
// The newer entry should appear higher in the DOM (lower index)
const newerIdx = await newerCard.evaluate(el => {
return [...document.querySelectorAll('.journal-post')].findIndex(c => c.id === el.id);
});
const olderIdx = await olderCard.evaluate(el => {
return [...document.querySelectorAll('.journal-post')].findIndex(c => c.id === el.id);
});
```
- [x] **Step 2: Run T1 and T2 to verify they fail**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
npx playwright test --project=chromium tests/ui/dailies.spec.js -g "T1:|T2:"
```
Expected: FAIL — `.journal-post` selector finds no elements (the page still renders `.entry-card`).
- [x] **Step 3: Add `weather_icons` map and replace journal card in `dailies.html.twig`**
In `user/themes/intotheeast/templates/dailies.html.twig`, find the line:
```twig
{% if item.type == 'journal' %}
<a class="entry-card" id="entry-{{ entry.slug }}" data-type="journal" data-lat="{{ entry.header.lat }}" data-lng="{{ entry.header.lng }}" href="{{ entry.url }}">
```
This `{% if item.type == 'journal' %}` block ends at `</a>` before `{% else %}`. Replace the entire journal card block (from `{% if item.type == 'journal' %}` through the closing `</a>` of the journal branch, leaving the `{% else %}` story branch intact) with:
```twig
{% 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-type="journal" 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>
```
The exact text to find and replace is the old journal branch. The old branch starts with:
```twig
{% if item.type == 'journal' %}
<a class="entry-card" id="entry-{{ entry.slug }}" data-type="journal" data-lat="{{ entry.header.lat }}" data-lng="{{ entry.header.lng }}" href="{{ entry.url }}">
{% if hero %}
<div class="entry-card-photo">
<img src="{{ hero.cropResize(720, 405).url }}" alt="{{ entry.title }}" loading="lazy">
<div class="entry-card-photo-overlay">
<time class="entry-date-overlay" datetime="{{ entry.date|date('Y-m-d') }}">
{{ entry.date|date('d M Y')|upper }}
</time>
{% if entry.header.location_city or entry.header.location_country %}
<span class="entry-location-overlay">
📍
{% if entry.header.location_city %}{{ entry.header.location_city|slice(0,20) }}{% endif %}
{% if entry.header.location_city and entry.header.location_country %}, {% endif %}
{% if entry.header.location_country %}{{ entry.header.location_country }}{% endif %}
</span>
{% endif %}
</div>
</div>
{% else %}
<div class="entry-card-textmeta">
<time class="entry-date-plain" datetime="{{ entry.date|date('Y-m-d') }}">
{{ entry.date|date('d M Y')|upper }}
</time>
{% if entry.header.location_city or entry.header.location_country %}
<span class="entry-location-plain">
{%- 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 %}
</div>
{% endif %}
<div class="entry-card-body">
<h2 class="entry-title">{{ entry.title }}</h2>
<p class="entry-excerpt">{{ entry.summary|striptags|slice(0, 250)|trim }}</p>
<span class="entry-read-more">Read entry →</span>
</div>
</a>
```
- [x] **Step 4: Run T1 and T2 to verify they pass**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
npx playwright test --project=chromium tests/ui/dailies.spec.js -g "T1:|T2:"
```
Expected: PASS.
- [x] **Step 5: Run the full suite to check no regressions**
```bash
npx playwright test --project=chromium tests/ui/dailies.spec.js tests/ui/maps.spec.js tests/ui/trip-filter.spec.js tests/ui/stories.spec.js
```
Expected: all pass.
- [x] **Step 6: Commit**
```bash
cd /home/mischa/Projects/travel-blog-intotheeast/user
git add themes/intotheeast/templates/dailies.html.twig
git commit -m "feat: replace journal entry card with inline journal-post in dailies feed"
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
git add tests/ui/dailies.spec.js user
git commit -m "test: update T1/T2 selectors for inline journal-post structure"
```
---
### Task 3: trip.html.twig + M7 test update
Replace the journal entry card in `trip.html.twig` with the `.journal-post` block. Update M7 which currently tests `.entry-card.is-highlighted` on the trip page.
**Files:**
- Modify: `user/themes/intotheeast/templates/trip.html.twig`
- Modify: `tests/ui/maps.spec.js`
**Interfaces:**
- Consumes: `.journal-post` CSS and `.journal-post.is-highlighted` from Task 1; journal-post HTML pattern from Task 2
- Produces: `/trips/japan-korea-2026` renders `.journal-post` blocks; M7 passes with `.journal-post.is-highlighted`
- [x] **Step 1: Update M7 to the new selector**
In `tests/ui/maps.spec.js`, find:
```js
// Within 500ms of click + delay, one entry-card should have is-highlighted
await expect(page.locator('.entry-card.is-highlighted')).toBeVisible({ timeout: 1500 });
```
Replace with:
```js
// Within 500ms of click + delay, one journal-post should have is-highlighted
await expect(page.locator('.journal-post.is-highlighted')).toBeVisible({ timeout: 1500 });
```
- [x] **Step 2: Run M7 to verify it fails**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
npx playwright test --project=chromium tests/ui/maps.spec.js -g "M7:"
```
Expected: FAIL — `.journal-post.is-highlighted` not found (trip.html.twig still renders `<a class="entry-card">`).
- [x] **Step 3: Replace journal card in `trip.html.twig`**
In `user/themes/intotheeast/templates/trip.html.twig`, find and replace the journal branch of the `{% if item.type == 'journal' %}` block. The old branch to replace is:
```twig
{% if item.type == 'journal' %}
<a class="entry-card" id="entry-{{ entry.slug }}" data-type="journal" data-lat="{{ entry.header.lat }}" data-lng="{{ entry.header.lng }}" href="{{ entry.url }}">
{% if hero %}
<div class="entry-card-photo">
<img src="{{ hero.cropResize(720, 405).url }}" alt="{{ entry.title }}" loading="lazy">
<div class="entry-card-photo-overlay">
<time class="entry-date-overlay" datetime="{{ entry.date|date('Y-m-d') }}">
{{ entry.date|date('d M Y')|upper }}
</time>
{% if entry.header.location_city or entry.header.location_country %}
<span class="entry-location-overlay">
📍
{% if entry.header.location_city %}{{ entry.header.location_city|slice(0,20) }}{% endif %}
{% if entry.header.location_city and entry.header.location_country %}, {% endif %}
{% if entry.header.location_country %}{{ entry.header.location_country }}{% endif %}
</span>
{% endif %}
</div>
</div>
{% else %}
<div class="entry-card-textmeta">
<time class="entry-date-plain" datetime="{{ entry.date|date('Y-m-d') }}">
{{ entry.date|date('d M Y')|upper }}
</time>
{% if entry.header.location_city or entry.header.location_country %}
<span class="entry-location-plain">
{%- 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 %}
</div>
{% endif %}
<div class="entry-card-body">
<h2 class="entry-title">{{ entry.title }}</h2>
<p class="entry-excerpt">{{ entry.summary|striptags|slice(0, 250)|trim }}</p>
<span class="entry-read-more">Read entry →</span>
</div>
</a>
```
Replace with:
```twig
{% 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-type="journal" 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>
```
- [x] **Step 4: Run M7 to verify it passes**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
npx playwright test --project=chromium tests/ui/maps.spec.js -g "M7:"
```
Expected: PASS.
- [x] **Step 5: Run full suite**
```bash
npx playwright test --project=chromium tests/ui/dailies.spec.js tests/ui/maps.spec.js tests/ui/trip-filter.spec.js tests/ui/stories.spec.js
```
Expected: all pass.
- [x] **Step 6: Commit**
```bash
cd /home/mischa/Projects/travel-blog-intotheeast/user
git add themes/intotheeast/templates/trip.html.twig
git commit -m "feat: replace journal entry card with inline journal-post in trip feed"
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
git add tests/ui/maps.spec.js user
git commit -m "test: update M7 selector for journal-post.is-highlighted"
```
---
### Task 4: home.html.twig + H1 test
Replace the journal entry card in `home.html.twig` and add a minimal home page test.
**Files:**
- Modify: `user/themes/intotheeast/templates/home.html.twig`
- Create: `tests/ui/home.spec.js`
**Interfaces:**
- Consumes: `.journal-post` CSS from Task 1; journal-post HTML pattern from Task 2
- [x] **Step 1: Write the failing H1 test**
Create `tests/ui/home.spec.js`:
```js
// @ts-check
// Tests: H1 — home page journal feed
const { test, expect } = require('@playwright/test');
// ── H1: Home page renders inline journal posts ─────────────────────────────────
test('H1: home page shows at least one inline journal-post block', async ({ page }) => {
await page.goto('/');
await expect(page.locator('.journal-post').first()).toBeVisible();
await expect(page.locator('.site-header')).toBeVisible();
});
```
- [x] **Step 2: Run H1 to verify it fails**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
npx playwright test --project=chromium tests/ui/home.spec.js
```
Expected: FAIL — `.journal-post` not found (home page still renders `<a class="entry-card">`).
- [x] **Step 3: Replace journal card in `home.html.twig`**
In `user/themes/intotheeast/templates/home.html.twig`, find the journal branch:
```twig
{% if item.type == 'journal' %}
<a class="entry-card" id="entry-{{ entry.slug }}" data-lat="{{ entry.header.lat }}" data-lng="{{ entry.header.lng }}" href="{{ entry.url }}">
{% if hero %}
<div class="entry-card-photo">
<img src="{{ hero.cropResize(720, 405).url }}" alt="{{ entry.title }}" loading="lazy">
<div class="entry-card-photo-overlay">
<time class="entry-date-overlay" datetime="{{ entry.date|date('Y-m-d') }}">
{{ entry.date|date('d M Y')|upper }}
</time>
{% if entry.header.location_city or entry.header.location_country %}
<span class="entry-location-overlay">
📍
{% if entry.header.location_city %}{{ entry.header.location_city|slice(0,20) }}{% endif %}
{% if entry.header.location_city and entry.header.location_country %}, {% endif %}
{% if entry.header.location_country %}{{ entry.header.location_country }}{% endif %}
</span>
{% endif %}
</div>
</div>
{% else %}
<div class="entry-card-textmeta">
<time class="entry-date-plain" datetime="{{ entry.date|date('Y-m-d') }}">
{{ entry.date|date('d M Y')|upper }}
</time>
{% if entry.header.location_city or entry.header.location_country %}
<span class="entry-location-plain">
{%- 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 %}
</div>
{% endif %}
<div class="entry-card-body">
<h2 class="entry-title">{{ entry.title }}</h2>
<p class="entry-excerpt">{{ entry.summary|striptags|slice(0, 250)|trim }}</p>
<span class="entry-read-more">Read entry →</span>
</div>
</a>
```
Replace with:
```twig
{% 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>
```
Note: `home.html.twig` journal posts do **not** include `data-type` (the home page has no filter bar) — this matches the existing `<a class="entry-card">` on home which also had no `data-type`.
- [x] **Step 4: Run H1 to verify it passes**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
npx playwright test --project=chromium tests/ui/home.spec.js
```
Expected: PASS.
- [x] **Step 5: Run the full suite**
```bash
npx playwright test --project=chromium tests/ui/dailies.spec.js tests/ui/maps.spec.js tests/ui/trip-filter.spec.js tests/ui/stories.spec.js tests/ui/home.spec.js
```
Expected: all pass.
- [x] **Step 6: Commit**
```bash
cd /home/mischa/Projects/travel-blog-intotheeast/user
git add themes/intotheeast/templates/home.html.twig
git commit -m "feat: replace journal entry card with inline journal-post on home page"
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
git add tests/ui/home.spec.js user
git commit -m "test: add H1 home page journal-post test"
```
@@ -0,0 +1,546 @@
# Pixelfed Import & Demo Reorganisation 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:** Import 36 Pixelfed posts into three permanent trips and reorganise the demo system so Italy demo content moves to a clearly-labelled `italy-2026-demo` trip and Japan demo content is retired.
**Architecture:** Three independent tasks — demo cleanup first, then real trip scaffolding, then the Python import script that routes posts by year and downloads photos. All user-facing content lives in `user/pages/` and is committed to the `user/` git repo; the Makefile and import script are committed to the main repo.
**Tech Stack:** Bash (file operations), Python 3 stdlib only (json, os, urllib.request, datetime), Grav Flat-File CMS YAML frontmatter.
## Global Constraints
- Dev server: `http://localhost:8081` — must be running (`make start`) to test cache clears
- `user/` is a separate git repo — all commits to `user/pages/`, `user/docs/`, `user/themes/` use `git -C user commit`; Makefile and `scripts/` use the root `git commit`
- Never read `.env` directly
- All new trip pages use `template: trip` for `trip.md`, `template: dailies` for the dailies index, `template: map` for map, `template: stats` for stats, `template: stories` for stories — matching existing trips exactly
- Input JSON: `/home/mischa/Nextcloud/Downloads/pixelfed/pixelfed-statuses.json` (36 posts)
- Trip routing by `created_at` year: 2023 → `central-asia-2023`, 2024 → `us-canada-mex-2024`, 2025 → `italy-2025`
---
## File Map
| File | Change | Repo |
|---|---|---|
| `user/docs/demo/trips/italy-2026-demo/` | New — copy of italy-2025 demo source with updated `trip.md` | user |
| `user/pages/01.trips/italy-2026-demo/` | Not committed — created at runtime by `demo-load` | — |
| `user/pages/01.trips/italy-2025/trip.md` | Update title to `Cycling Tuscany 2025` | user |
| `user/pages/01.trips/italy-2025/01.dailies/dailies.md` | New — missing index page | user |
| `user/pages/01.trips/italy-2025/04.stories/01.val-dorcia-dawn/` | Delete demo story | user |
| `user/pages/01.trips/italy-2025/04.stories/02.long-climb-montalcino/` | Delete demo story | user |
| `user/pages/01.trips/italy-2025/04.stories/03.one-evening-siena/` | Delete demo story | user |
| `user/pages/01.trips/italy-2025/01.dailies/2025-09-*.entry/` | Delete 5 demo entries | user |
| `user/pages/01.trips/japan-korea-2026/01.dailies/2026-03-*.entry` + `2026-04-*.entry` | Delete 9 demo entries | user |
| `user/pages/01.trips/japan-korea-2026/04.stories/01.the-thousand-gates/` | Delete demo story | user |
| `user/pages/01.trips/central-asia-2023/` | New permanent trip page tree | user |
| `user/pages/01.trips/us-canada-mex-2024/` | New permanent trip page tree | user |
| `Makefile` | Replace `demo-load` and `demo-reset` targets | main |
| `scripts/pixelfed-import.py` | New one-time import script | main |
---
## Task 1: Demo reorganisation
**Files:**
- Create: `user/docs/demo/trips/italy-2026-demo/` (copy + edit)
- Modify: `user/pages/01.trips/italy-2025/trip.md`
- Create: `user/pages/01.trips/italy-2025/01.dailies/dailies.md`
- Delete: `user/pages/01.trips/italy-2025/04.stories/01.val-dorcia-dawn/`, `02.long-climb-montalcino/`, `03.one-evening-siena/`
- Delete: `user/pages/01.trips/italy-2025/01.dailies/2025-09-*.entry/` (5 demo entries)
- Delete: `user/pages/01.trips/japan-korea-2026/01.dailies/2026-03-*.entry` + `2026-04-*.entry` (9 demo entries)
- Delete: `user/pages/01.trips/japan-korea-2026/04.stories/01.the-thousand-gates/`
- Modify: `Makefile`
**Interfaces:**
- Produces: `demo-load` and `demo-reset` targets that only touch `italy-2026-demo`; `italy-2025` is clean and ready for real content; `japan-korea-2026` has only the real `2026-06-17.entry`
- [ ] **Step 1: Copy italy-2025 demo source to italy-2026-demo**
```bash
cp -r user/docs/demo/trips/italy-2025 user/docs/demo/trips/italy-2026-demo
```
- [ ] **Step 2: Update the trip.md in the new demo source**
Edit `user/docs/demo/trips/italy-2026-demo/trip.md` to read:
```yaml
---
title: 'Italy 2026 (Demo)'
template: trip
date: '2026-09-01'
date_start: '2026-09-01'
date_end: '2026-09-08'
cover_image: ''
---
```
- [ ] **Step 3: Remove italy-2025 demo stories from pages**
```bash
rm -rf user/pages/01.trips/italy-2025/04.stories/01.val-dorcia-dawn
rm -rf user/pages/01.trips/italy-2025/04.stories/02.long-climb-montalcino
rm -rf user/pages/01.trips/italy-2025/04.stories/03.one-evening-siena
```
- [ ] **Step 4: Remove italy-2025 demo dailies entries from pages**
```bash
rm -rf user/pages/01.trips/italy-2025/01.dailies/2025-09-05-0800-rolling-through-val-dorcia.entry
rm -rf user/pages/01.trips/italy-2025/01.dailies/2025-09-05-1900-siena-at-dusk.entry
rm -rf user/pages/01.trips/italy-2025/01.dailies/2025-09-06-1200-towers-of-san-gimignano.entry
rm -rf user/pages/01.trips/italy-2025/01.dailies/2025-09-06-1800-into-florence.entry
rm -rf user/pages/01.trips/italy-2025/01.dailies/2025-09-08-0900-tyrrhenian-coast.entry
```
Check actual folder names first in case any differ:
```bash
ls user/pages/01.trips/italy-2025/01.dailies/
```
Remove all folders listed (they are all demo content).
- [ ] **Step 5: Add missing dailies.md to italy-2025**
Create `user/pages/01.trips/italy-2025/01.dailies/dailies.md`:
```yaml
---
title: 'The Journey'
template: dailies
content:
items: '@self.children'
order:
by: date
dir: desc
filter:
published: true
---
```
- [ ] **Step 6: Update italy-2025 trip title**
Edit `user/pages/01.trips/italy-2025/trip.md`:
```yaml
---
title: 'Cycling Tuscany 2025'
template: trip
date: '2025-10-11'
date_start: '2025-10-11'
date_end: '2025-10-16'
cover_image: ''
---
```
- [ ] **Step 7: Remove japan demo content from pages**
```bash
rm -rf user/pages/01.trips/japan-korea-2026/01.dailies/2026-03-25-1540-wheels-down-narita.entry
rm -rf user/pages/01.trips/japan-korea-2026/01.dailies/2026-03-26-1000-sakura-in-ueno-park.entry
rm -rf user/pages/01.trips/japan-korea-2026/01.dailies/2026-03-27-0715-summit-clouds-and-snow.entry
rm -rf user/pages/01.trips/japan-korea-2026/01.dailies/2026-03-28-1130-thousand-torii-gates.entry
rm -rf user/pages/01.trips/japan-korea-2026/01.dailies/2026-03-29-1400-deer-of-nara.entry
rm -rf user/pages/01.trips/japan-korea-2026/01.dailies/2026-03-30-1800-dotonbori-after-dark.entry
rm -rf user/pages/01.trips/japan-korea-2026/01.dailies/2026-03-31-0730-last-morning-in-arashiyama.entry
rm -rf user/pages/01.trips/japan-korea-2026/01.dailies/2026-04-01-0900-seoul-calling.entry
rm -rf user/pages/01.trips/japan-korea-2026/01.dailies/2026-04-02-1100-gyeongbokgung-and-beyond.entry
rm -rf user/pages/01.trips/japan-korea-2026/04.stories/01.the-thousand-gates
```
- [ ] **Step 8: Update the Makefile demo targets**
Replace the entire `demo-load` and `demo-reset` blocks (lines 4264) with:
```makefile
demo-load:
# Load italy-2026-demo trip (create pages if absent)
mkdir -p user/pages/01.trips/italy-2026-demo/01.dailies user/pages/01.trips/italy-2026-demo/02.map user/pages/01.trips/italy-2026-demo/03.stats user/pages/01.trips/italy-2026-demo/04.stories
cp user/docs/demo/trips/italy-2026-demo/trip.md user/pages/01.trips/italy-2026-demo/trip.md 2>/dev/null || true
cp user/docs/demo/trips/italy-2026-demo/map.md user/pages/01.trips/italy-2026-demo/02.map/map.md 2>/dev/null || true
cp user/docs/demo/trips/italy-2026-demo/stats.md user/pages/01.trips/italy-2026-demo/03.stats/stats.md 2>/dev/null || true
cp user/docs/demo/trips/italy-2026-demo/stories.md user/pages/01.trips/italy-2026-demo/04.stories/stories.md 2>/dev/null || true
cp -r user/docs/demo/trips/italy-2026-demo/04.stories/. user/pages/01.trips/italy-2026-demo/04.stories/ 2>/dev/null || true
cp -r user/docs/demo/trips/italy-2026-demo/dailies/. user/pages/01.trips/italy-2026-demo/01.dailies/
cp user/docs/demo/trips/italy-2026-demo/*.gpx user/pages/01.trips/italy-2026-demo/ 2>/dev/null || true
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache"
demo-reset:
rm -rf user/pages/01.trips/italy-2026-demo
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache"
```
- [ ] **Step 9: Verify demo-load and demo-reset work**
```bash
make demo-load
```
Expected: `italy-2026-demo` appears at `http://localhost:8081` trips listing. Confirm stories and GPX map load.
```bash
make demo-reset
```
Expected: `italy-2026-demo` disappears from the trips listing. `italy-2025` and `japan-korea-2026` are unaffected.
- [ ] **Step 10: Commit user repo changes**
Includes the new demo source, cleaned pages, updated italy-2025 title, and new dailies.md:
```bash
git -C user add -A
git -C user commit -m "chore: move italy demo to italy-2026-demo; clean japan and italy-2025 demo content"
```
- [ ] **Step 11: Commit main repo changes (Makefile only)**
```bash
git add Makefile
git commit -m "chore: update demo-load/demo-reset for italy-2026-demo; retire japan demo"
```
---
## Task 2: Create real trip page trees
**Files:**
- Create: `user/pages/01.trips/central-asia-2023/` (trip.md + 4 subfolders with index pages)
- Create: `user/pages/01.trips/us-canada-mex-2024/` (trip.md + 4 subfolders with index pages)
**Interfaces:**
- Produces: `central-asia-2023` and `us-canada-mex-2024` trip folder trees with `01.dailies/dailies.md` present — required for the import script to write entries into them
- [ ] **Step 1: Create Central Asia 2023 trip tree**
```bash
mkdir -p user/pages/01.trips/central-asia-2023/01.dailies
mkdir -p user/pages/01.trips/central-asia-2023/02.map
mkdir -p user/pages/01.trips/central-asia-2023/03.stats
mkdir -p user/pages/01.trips/central-asia-2023/04.stories
```
Create `user/pages/01.trips/central-asia-2023/trip.md`:
```yaml
---
title: 'Central Asia 2023'
template: trip
date: '2023-08-28'
date_start: '2023-08-28'
date_end: '2023-10-18'
cover_image: ''
---
```
Create `user/pages/01.trips/central-asia-2023/01.dailies/dailies.md`:
```yaml
---
title: 'The Journey'
template: dailies
content:
items: '@self.children'
order:
by: date
dir: desc
filter:
published: true
---
```
Create `user/pages/01.trips/central-asia-2023/02.map/map.md`:
```yaml
---
title: 'Trip Map'
template: map
---
```
Create `user/pages/01.trips/central-asia-2023/03.stats/stats.md`:
```yaml
---
title: 'Trip Stats'
template: stats
---
```
Create `user/pages/01.trips/central-asia-2023/04.stories/stories.md`:
```yaml
---
title: Stories
template: stories
published: true
---
```
- [ ] **Step 2: Create Northern America 2024 trip tree**
```bash
mkdir -p user/pages/01.trips/us-canada-mex-2024/01.dailies
mkdir -p user/pages/01.trips/us-canada-mex-2024/02.map
mkdir -p user/pages/01.trips/us-canada-mex-2024/03.stats
mkdir -p user/pages/01.trips/us-canada-mex-2024/04.stories
```
Create `user/pages/01.trips/us-canada-mex-2024/trip.md`:
```yaml
---
title: 'Northern America 2024'
template: trip
date: '2024-05-28'
date_start: '2024-05-28'
date_end: '2024-08-07'
cover_image: ''
---
```
Create `user/pages/01.trips/us-canada-mex-2024/01.dailies/dailies.md`:
```yaml
---
title: 'The Journey'
template: dailies
content:
items: '@self.children'
order:
by: date
dir: desc
filter:
published: true
---
```
Create `user/pages/01.trips/us-canada-mex-2024/02.map/map.md`:
```yaml
---
title: 'Trip Map'
template: map
---
```
Create `user/pages/01.trips/us-canada-mex-2024/03.stats/stats.md`:
```yaml
---
title: 'Trip Stats'
template: stats
---
```
Create `user/pages/01.trips/us-canada-mex-2024/04.stories/stories.md`:
```yaml
---
title: Stories
template: stories
published: true
---
```
- [ ] **Step 3: Verify trips appear in the site**
Open `http://localhost:8081` — the Past Trips section should list Central Asia 2023 and Northern America 2024.
- [ ] **Step 4: Commit to user repo**
```bash
git -C user add user/pages/01.trips/central-asia-2023 user/pages/01.trips/us-canada-mex-2024
git -C user commit -m "feat: add central-asia-2023 and us-canada-mex-2024 trip page trees"
```
---
## Task 3: Pixelfed import script
**Files:**
- Create: `scripts/pixelfed-import.py`
- Modify: `Makefile` (add `pixelfed-import` target)
**Interfaces:**
- Consumes: `user/pages/01.trips/{trip}/01.dailies/` folders from Task 2 and existing `italy-2025`
- Produces: `{date}-pixelfed-{N}.entry/` folders with `entry.md` + downloaded photo files
- [ ] **Step 1: Write the import script**
Create `scripts/pixelfed-import.py`:
```python
#!/usr/bin/env python3
"""One-time import of Pixelfed statuses into Grav entry pages."""
import json
import os
import urllib.request
from datetime import datetime, timezone
INPUT_FILE = '/home/mischa/Nextcloud/Downloads/pixelfed/pixelfed-statuses.json'
USER_PAGES = 'user/pages/01.trips'
TRIP_MAP = {
'2023': 'central-asia-2023',
'2024': 'us-canada-mex-2024',
'2025': 'italy-2025',
}
EXT_MAP = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/gif': 'gif',
'image/webp': 'webp',
}
ENTRY_TEMPLATE = """\
---
title: '{title}'
date: '{date}'
template: entry
published: true
hero_image: '{hero_image}'
lat: ''
lng: ''
location_city: '{location_city}'
location_country: '{location_country}'
weather_temp_c: ''
weather_desc: ''
---
{body}
"""
def download(url, dest):
try:
urllib.request.urlretrieve(url, dest)
return True
except Exception as exc:
print(f' Warning: download failed {url}: {exc}')
return False
def main():
with open(INPUT_FILE) as f:
posts = json.load(f)
counters = {}
for post in posts:
year = post['created_at'][:4]
trip = TRIP_MAP.get(year)
if not trip:
print(f"Skip: no trip mapping for year {year} (post {post['id']})")
continue
counters[trip] = counters.get(trip, 0) + 1
n = counters[trip]
date_str = post['created_at'][:10] # YYYY-MM-DD
folder = f'{date_str}-pixelfed-{n}.entry'
path = os.path.join(USER_PAGES, trip, '01.dailies', folder)
if os.path.exists(path):
print(f'Skip: {folder} already exists')
continue
os.makedirs(path)
print(f'Creating {trip}/{folder}')
hero_image = ''
for i, att in enumerate(post.get('media_attachments', []), 1):
ext = EXT_MAP.get(att.get('mime', ''), 'jpg')
filename = f'photo-{i}.{ext}'
if download(att['url'], os.path.join(path, filename)) and i == 1:
hero_image = filename
place = post.get('place') or {}
dt = datetime.fromisoformat(post['created_at'].replace('Z', '+00:00'))
date_fmt = dt.strftime('%Y-%m-%d %H:%M')
entry_md = ENTRY_TEMPLATE.format(
title=f'Pixelfed Import {n}',
date=date_fmt,
hero_image=hero_image,
location_city=place.get('name', ''),
location_country=place.get('country', ''),
body=post.get('content_text', '').strip(),
)
with open(os.path.join(path, 'entry.md'), 'w') as f:
f.write(entry_md)
print(f'\nDone. Posts per trip: {counters}')
if __name__ == '__main__':
main()
```
- [ ] **Step 2: Add make target**
In `Makefile`, after the `demo-reset` block, add:
```makefile
pixelfed-import:
python3 scripts/pixelfed-import.py
```
- [ ] **Step 3: Run the import**
```bash
make pixelfed-import
```
Expected output (approximately):
```
Creating central-asia-2023/2023-08-28-pixelfed-1.entry
Creating central-asia-2023/2023-08-29-pixelfed-2.entry
...
Creating us-canada-mex-2024/2024-05-28-pixelfed-1.entry
...
Creating italy-2025/2025-10-11-pixelfed-1.entry
Creating italy-2025/2025-10-16-pixelfed-2.entry
Done. Posts per trip: {'central-asia-2023': 22, 'us-canada-mex-2024': 12, 'italy-2025': 2}
```
- [ ] **Step 4: Verify entries in the browser**
Open `http://localhost:8081/trips/central-asia-2023/dailies` — confirm entries appear in reverse-date order with photos.
Open one entry (e.g. the first Central Asia post) — confirm the hero image displays and the body text is readable.
- [ ] **Step 5: Verify entries in Admin2**
Log in at `http://localhost:8081/admin`. Navigate to Pages → find one of the new entry pages. Confirm the media tab shows the downloaded photos.
- [ ] **Step 6: Commit main repo (script + Makefile)**
```bash
git add scripts/pixelfed-import.py Makefile
git commit -m "feat: add pixelfed-import script and make target"
```
- [ ] **Step 7: Commit user repo (imported entries)**
```bash
git -C user add user/pages/01.trips/central-asia-2023/01.dailies
git -C user add user/pages/01.trips/us-canada-mex-2024/01.dailies
git -C user add user/pages/01.trips/italy-2025/01.dailies
git -C user commit -m "feat: import 36 Pixelfed posts into central-asia-2023, us-canada-mex-2024, italy-2025"
```
- [ ] **Step 8: Push to Gitea**
```bash
make content-push
```
Expected: push completes, production webhook fires.
@@ -0,0 +1,626 @@
# UI/UX Alignment 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) — also extended to story map markers (white diamond) and story card flash highlight.
**Goal:** Unify three micro-interaction patterns across the site: back navigation pills, card hover lift, and a map-to-card flash highlight.
**Architecture:** CSS-first — shared `.back-pill` class drives visual consistency; entry card markup is collapsed from a two-level `article > a` to a flat `<a>` to align hover targets across all three card types; map flash is a short CSS keyframe triggered by a JS-added class.
**Tech Stack:** Twig templates, vanilla CSS custom properties, vanilla JS, Playwright for tests.
## Global Constraints
- Dev server: `http://localhost:8081` — must be running (`make start`) before any Playwright run
- Playwright: `npx playwright test --project=chromium tests/ui/<file>.spec.js` — always run the affected spec after changes
- Demo data required for story/map tests: `make demo-load`
- All CSS uses design tokens from `user/themes/intotheeast/css/tokens.css` — never hard-code colours
- `--color-paper: #1A1814`, `--color-canvas: #22201B`, `--color-ink: #EDE8DF` — the site is dark-themed
- `--site-header-height: 60px` — fixed pills must clear the site nav
- Never read `.env` directly
---
## File Map
| File | What changes |
|---|---|
| `user/themes/intotheeast/css/style.css` | Add `.back-pill` class; remove duplicate `.story-escape` block; migrate `.entry-card-inner` hover rules to `.entry-card`; add uniform card hover lift; add `@keyframes card-highlight` |
| `user/themes/intotheeast/templates/story.html.twig` | Add `class="back-pill"` to story-footer back link (line 61) |
| `user/themes/intotheeast/templates/entry.html.twig` | Add fixed top back pill before `<article class="entry">`; replace footer teal link with `.back-pill`; add `.entry-back-fixed` CSS |
| `user/themes/intotheeast/templates/trip.html.twig` | Collapse `<article class="entry-card"><a class="entry-card-inner">` to `<a class="entry-card">` for both card variants; update marker click handler with flash delay |
| `tests/ui/dailies.spec.js` | Update T2 selectors from `.entry-card a[href*="..."]` to `.entry-card[href*="..."]`; add T6 (back pills on entry page) |
| `tests/ui/maps.spec.js` | Add M7 (marker click adds `is-highlighted` class) |
---
## Task 1: CSS foundation — `.back-pill`, card hover lift, flash keyframe, story-escape cleanup
**Files:**
- Modify: `user/themes/intotheeast/css/style.css`
**Interfaces:**
- Produces: `.back-pill` class (surface pill), `.entry-card.is-highlighted` animation, uniform hover lift on `.trip-card:hover`, `.entry-card:hover`, `.story-card:hover`
- [x] **Step 1: Add `.back-pill` surface pill class**
Find the `/* ── Back to top pill ──` section (around line 1217). Insert the following block immediately **before** it:
```css
/* ── Back pill (shared navigation pill component) ───────────────────── */
.back-pill {
display: inline-flex;
align-items: center;
font-family: var(--font-ui);
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-ink);
text-decoration: none;
background: var(--color-canvas);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
padding: 0.4rem 0.9rem;
transition: border-color 0.15s, color 0.15s;
cursor: pointer;
}
.back-pill:hover { border-color: var(--color-accent); color: var(--color-accent); }
```
- [x] **Step 2: Remove the duplicate `.story-escape` block**
Around line 958 there is a `/* ── Story page escape link ──` section with a `.story-escape` rule that is overridden later by the story-section block. Remove this entire section:
```css
/* ── Story page escape link ──────────────────────────────────────────────────── */
.story-escape {
position: fixed;
top: var(--space-5);
left: var(--space-5);
z-index: 200;
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-ink);
text-decoration: none;
background: rgba(0,0,0,0.6);
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-full);
backdrop-filter: blur(4px);
}
.story-escape:hover { color: var(--color-accent); }
```
The authoritative `.story-escape` definition remains in the `/* ── Story pages ──` section (~line 1056).
- [x] **Step 3: Add uniform card hover lift + fix story-card transition**
Find the `.trip-card:hover` rule (in `/* ── Past trips archive ──`). After the existing `.trip-card:hover` block, add:
```css
.trip-card:hover,
.entry-card:hover,
.story-card:hover {
background: var(--color-surface-raised);
}
```
Then find `.story-card` in the `/* ── Stories listing ──` section and add `background 0.15s` to its existing transition so the lift animates:
```css
/* Before: */
.story-card {
...
transition: box-shadow 0.2s;
}
/* After: */
.story-card {
...
transition: box-shadow 0.2s, background 0.15s;
}
```
- [x] **Step 4: Add map flash keyframe**
At the end of the `/* ── Feed ──` section (after `.entry-card` and related rules, around line 210), add:
```css
@keyframes card-highlight {
0% { background-color: color-mix(in srgb, var(--color-accent) 12%, transparent); }
100% { background-color: transparent; }
}
.entry-card.is-highlighted {
animation: card-highlight 0.7s ease-out forwards;
}
```
- [x] **Step 5: Verify no JS errors on the site**
```bash
npx playwright test --project=chromium tests/ui/maps.spec.js -g "M1:"
```
Expected: PASS (map page loads without errors — confirms CSS is valid).
- [x] **Step 6: Commit**
```bash
git add user/themes/intotheeast/css/style.css
git commit -m "feat: add back-pill class, card hover lift, flash keyframe; remove duplicate story-escape"
```
---
## Task 2: Story template — apply `.back-pill` to body back link
**Files:**
- Modify: `user/themes/intotheeast/templates/story.html.twig`
**Interfaces:**
- Consumes: `.back-pill` class from Task 1
- [x] **Step 1: Write the failing test**
Add to `tests/ui/stories.spec.js`:
```js
// ── S7: Story body back link is styled as a back-pill ────────────────────────
test('S7: story body back link has back-pill class', async ({ page }) => {
await page.goto('/trips/italy-2025/stories/val-dorcia-dawn');
await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
// Scroll past the hero to reveal the story body
await page.evaluate(() => window.scrollBy(0, window.innerHeight * 1.5));
await page.waitForTimeout(300);
const bodyBack = page.locator('.story-footer .back-pill');
await expect(bodyBack).toBeAttached();
await expect(bodyBack).toHaveText(/← Back/);
});
```
- [x] **Step 2: Run test to verify it fails**
```bash
npx playwright test --project=chromium tests/ui/stories.spec.js -g "S7:"
```
Expected: FAIL — "locator('.story-footer .back-pill')" found 0 elements.
- [x] **Step 3: Apply `.back-pill` to the story footer back link + fix `.story-footer a` conflict**
In `story.html.twig`, the story footer currently reads:
```twig
<footer class="story-footer">
<a href="{{ page.parent().url }}" onclick="if(history.length > 1){ history.back(); return false; }">← Back</a>
```
Change the `<a>` to:
```twig
<footer class="story-footer">
<a class="back-pill" href="{{ page.parent().url }}" onclick="if(history.length > 1){ history.back(); return false; }">← Back</a>
```
Then in `style.css`, find `.story-footer a` and add `:not(.back-pill)` so it no longer overrides the pill colour:
```css
/* Before: */
.story-footer a {
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--color-accent);
text-decoration: none;
}
/* After: */
.story-footer a:not(.back-pill) {
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--color-accent);
text-decoration: none;
}
```
- [x] **Step 4: Run test to verify it passes**
```bash
npx playwright test --project=chromium tests/ui/stories.spec.js -g "S7:"
```
Expected: PASS.
- [x] **Step 5: Run full stories suite to check no regressions**
```bash
npx playwright test --project=chromium tests/ui/stories.spec.js
```
Expected: All S1S7 pass.
- [x] **Step 6: Commit**
```bash
git add user/themes/intotheeast/templates/story.html.twig user/themes/intotheeast/css/style.css tests/ui/stories.spec.js
git commit -m "feat: apply back-pill class to story footer back link"
```
---
## Task 3: Entry page — fixed top back pill + footer back pill
**Files:**
- Modify: `user/themes/intotheeast/templates/entry.html.twig`
- Modify: `user/themes/intotheeast/css/style.css`
- Modify: `tests/ui/dailies.spec.js`
**Interfaces:**
- Consumes: `.back-pill` class from Task 1
- [x] **Step 1: Write the failing test**
Add to `tests/ui/dailies.spec.js`:
```js
const KNOWN_ENTRY = '/trips/japan-korea-2026/dailies/2026-03-25-1540-wheels-down-narita.entry';
// ── T6: Entry page has a fixed top back pill and a footer back pill ───────────
test('T6: entry page has fixed back pill at top and back pill in footer', async ({ page }) => {
await page.goto(KNOWN_ENTRY);
await expect(page.locator('article.entry')).toBeVisible();
// Fixed top pill (outside the article, before it)
const topPill = page.locator('.entry-back-fixed');
await expect(topPill).toBeVisible();
await expect(topPill).toHaveText(/← Back/);
// Footer pill
const footerPill = page.locator('.entry-footer .back-pill');
await expect(footerPill).toBeVisible();
await expect(footerPill).toHaveText(/← Back/);
});
```
- [x] **Step 2: Run test to verify it fails**
```bash
npx playwright test --project=chromium tests/ui/dailies.spec.js -g "T6:"
```
Expected: FAIL — `.entry-back-fixed` not found.
- [x] **Step 3: Add fixed top back pill to entry template**
In `entry.html.twig`, the content block currently starts with `<article class="entry">`. Add the fixed pill immediately before it:
```twig
<a class="back-pill entry-back-fixed" href="{{ page.parent().url }}" onclick="if(history.length > 1){ history.back(); return false; }">← Back</a>
<article class="entry">
```
- [x] **Step 4: Replace footer teal text link with `.back-pill`**
The current entry footer (around line 124 of entry.html.twig):
```twig
<footer class="entry-footer">
<a href="{{ page.parent().url }}" onclick="if(history.length>1){event.preventDefault();history.back()}">← Back</a>
</footer>
```
Replace with:
```twig
<footer class="entry-footer">
<a class="back-pill" href="{{ page.parent().url }}" onclick="if(history.length > 1){ history.back(); return false; }">← Back</a>
</footer>
```
- [x] **Step 5: Add `.entry-back-fixed` positioning to CSS**
In `style.css`, in the `/* ── Single entry ──` section, add after the existing `.entry-hero` rules:
```css
.entry-back-fixed {
position: fixed;
top: calc(var(--site-header-height) + var(--space-3));
left: var(--space-4);
z-index: 100;
}
```
- [x] **Step 6: Run test to verify it passes**
```bash
npx playwright test --project=chromium tests/ui/dailies.spec.js -g "T6:"
```
Expected: PASS.
- [x] **Step 7: Run full dailies suite to check no regressions**
```bash
npx playwright test --project=chromium tests/ui/dailies.spec.js
```
Expected: T1T6 all pass.
- [x] **Step 8: Commit**
```bash
git add user/themes/intotheeast/templates/entry.html.twig user/themes/intotheeast/css/style.css tests/ui/dailies.spec.js
git commit -m "feat: add fixed top and footer back pills to entry page"
```
---
## Task 4: Entry card structural refactor + CSS migration
Collapse the two-level `<article class="entry-card"><a class="entry-card-inner">` to a flat `<a class="entry-card">`, matching the structure of trip and story cards.
**Files:**
- Modify: `user/themes/intotheeast/templates/trip.html.twig`
- Modify: `user/themes/intotheeast/css/style.css`
- Modify: `tests/ui/dailies.spec.js`
**Interfaces:**
- Produces: `.entry-card` is now an `<a>` element; `id`, `data-type`, `data-lat`, `data-lng` attributes remain on the card root; `.entry-card-inner` class is eliminated
- [x] **Step 1: Update T2 test selectors before touching the templates**
In `tests/ui/dailies.spec.js`, find the T2 test and replace:
```js
// OLD — inner <a> is nested inside .entry-card
const newerCard = page.locator(`.entry-card a[href*="${NEWER_SLUG}"]`);
const olderCard = page.locator(`.entry-card a[href*="${OLDER_SLUG}"]`);
await expect(newerCard).toBeVisible();
await expect(olderCard).toBeVisible();
const newerIdx = await newerCard.evaluate(el => {
return [...document.querySelectorAll('.entry-card')].findIndex(c => c.contains(el));
});
const olderIdx = await olderCard.evaluate(el => {
return [...document.querySelectorAll('.entry-card')].findIndex(c => c.contains(el));
});
```
With:
```js
// NEW — .entry-card is itself the <a>
const newerCard = page.locator(`.entry-card[href*="${NEWER_SLUG}"]`);
const olderCard = page.locator(`.entry-card[href*="${OLDER_SLUG}"]`);
await expect(newerCard).toBeVisible();
await expect(olderCard).toBeVisible();
const newerIdx = await newerCard.evaluate(el => {
return [...document.querySelectorAll('.entry-card')].findIndex(c => c === el);
});
const olderIdx = await olderCard.evaluate(el => {
return [...document.querySelectorAll('.entry-card')].findIndex(c => c === el);
});
```
- [x] **Step 2: Run T2 to verify it fails (not yet refactored)**
```bash
npx playwright test --project=chromium tests/ui/dailies.spec.js -g "T2:"
```
Expected: FAIL — `.entry-card[href*="..."]` finds 0 elements (the `href` is on the inner `<a>`, not the article).
- [x] **Step 3: Refactor journal entry card markup in `trip.html.twig`**
Find the journal card block:
```twig
{% if item.type == 'journal' %}
<article class="entry-card" id="entry-{{ entry.slug }}" data-type="journal" data-lat="{{ entry.header.lat }}" data-lng="{{ entry.header.lng }}">
<a class="entry-card-inner" href="{{ entry.url }}">
```
Replace with:
```twig
{% if item.type == 'journal' %}
<a class="entry-card" id="entry-{{ entry.slug }}" data-type="journal" data-lat="{{ entry.header.lat }}" data-lng="{{ entry.header.lng }}" href="{{ entry.url }}">
```
And close the card with `</a>` instead of `</a></article>`. The closing tags currently are:
```twig
</a>
</article>
```
Replace with:
```twig
</a>
```
- [x] **Step 4: Refactor story-in-feed card markup in `trip.html.twig`**
Find the story-in-feed card block:
```twig
<article class="entry-card entry-card--story" id="entry-{{ entry.slug }}" data-type="story">
<a class="entry-card-inner" href="{{ entry.url }}">
```
Replace with:
```twig
<a class="entry-card entry-card--story" id="entry-{{ entry.slug }}" data-type="story" href="{{ entry.url }}">
```
And its closing tags (currently `</a></article>`) become:
```twig
</a>
```
- [x] **Step 5: Migrate `.entry-card-inner` CSS rules to `.entry-card`**
In `style.css`, find the `/* ── Feed ──` section. Currently:
```css
.entry-card { border-bottom: 1px solid var(--color-border); padding-bottom: var(--space-12); }
.entry-card-inner {
display: block;
text-decoration: none;
color: inherit;
}
```
Replace with (merge inner styles onto card, `.entry-card-inner` is eliminated):
```css
.entry-card {
display: block;
text-decoration: none;
color: inherit;
border-bottom: 1px solid var(--color-border);
padding-bottom: var(--space-12);
transition: background 0.15s;
}
```
Then find the two `.entry-card-inner:hover` rules and rename them to `.entry-card:hover`:
```css
/* Before: */
.entry-card-inner:hover .entry-card-photo img { transform: scale(1.04); }
/* After: */
.entry-card:hover .entry-card-photo img { transform: scale(1.04); }
```
```css
/* Before: */
.entry-card-inner:hover .entry-title { color: var(--color-accent); }
/* After: */
.entry-card:hover .entry-title { color: var(--color-accent); }
```
- [x] **Step 6: Run T2 to verify it passes**
```bash
npx playwright test --project=chromium tests/ui/dailies.spec.js -g "T2:"
```
Expected: PASS.
- [x] **Step 7: Run full test suites to check no regressions**
```bash
npx playwright test --project=chromium tests/ui/dailies.spec.js tests/ui/trip-filter.spec.js tests/ui/maps.spec.js
```
Expected: T1T6, F1F7, M1M6 all pass.
- [x] **Step 8: Commit**
```bash
git add user/themes/intotheeast/templates/trip.html.twig user/themes/intotheeast/css/style.css tests/ui/dailies.spec.js
git commit -m "refactor: collapse entry card article+a to flat <a>, unify hover targets across card types"
```
---
## Task 5: Map flash — JS update + test
**Files:**
- Modify: `user/themes/intotheeast/templates/trip.html.twig`
- Modify: `tests/ui/maps.spec.js`
**Interfaces:**
- Consumes: `.entry-card.is-highlighted` CSS animation from Task 1; `id="entry-{{ slug }}"` on `<a class="entry-card">` from Task 4
- [x] **Step 1: Write the failing test**
Add to `tests/ui/maps.spec.js`:
```js
// ── M7: Clicking a trip-page map marker adds is-highlighted to the entry card ──
test('M7: clicking map marker briefly highlights the corresponding entry card', async ({ page }) => {
await page.goto('/trips/japan-korea-2026');
// Wait for map canvas and at least one marker
await expect(page.locator('#trip-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.maplibregl-marker').first()).toBeVisible({ timeout: 15000 });
// Click the first marker
await page.locator('.maplibregl-marker').first().click();
// Within 500ms of click + delay, one entry-card should have is-highlighted
await expect(page.locator('.entry-card.is-highlighted')).toBeVisible({ timeout: 1500 });
});
```
- [x] **Step 2: Run test to verify it fails**
```bash
npx playwright test --project=chromium tests/ui/maps.spec.js -g "M7:"
```
Expected: FAIL — `.entry-card.is-highlighted` not found.
- [x] **Step 3: Update the marker click handler in `trip.html.twig`**
Find the existing marker click handler in `trip.html.twig`:
```js
el.addEventListener('click', function () {
var card = document.getElementById('entry-' + entry.slug);
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
```
Replace with:
```js
el.addEventListener('click', function () {
var card = document.getElementById('entry-' + entry.slug);
if (!card) return;
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
setTimeout(function () {
card.classList.add('is-highlighted');
setTimeout(function () { card.classList.remove('is-highlighted'); }, 700);
}, 350);
});
```
- [x] **Step 4: Run test to verify it passes**
```bash
npx playwright test --project=chromium tests/ui/maps.spec.js -g "M7:"
```
Expected: PASS.
- [x] **Step 5: Run full maps suite**
```bash
npx playwright test --project=chromium tests/ui/maps.spec.js
```
Expected: M1M7 all pass.
- [x] **Step 6: Run all affected suites for final check**
```bash
npx playwright test --project=chromium tests/ui/dailies.spec.js tests/ui/trip-filter.spec.js tests/ui/maps.spec.js tests/ui/stories.spec.js
```
Expected: All pass.
- [x] **Step 7: Commit**
```bash
git add user/themes/intotheeast/templates/trip.html.twig tests/ui/maps.spec.js
git commit -m "feat: add map-to-card flash highlight on marker click"
```
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,327 @@
# Entry Enrichment 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:** Enrich all real trip journal entries with location, GPS coordinates, and approximate weather by generating per-trip Markdown review docs, letting the user correct them, then applying changes to YAML frontmatter.
**Architecture:** Three round-trips (one per trip): Claude generates a review table → user edits the doc → Claude applies the approved values to `entry.md` frontmatter. No scripts — all edits via the Edit tool directly. Human review gates between each trip pair.
**Tech Stack:** Grav YAML frontmatter, Markdown tables, OpenStreetMap URLs for coordinates.
## Global Constraints
- Never read `.env`
- Only write to `user/` and `docs/` directories
- `lat`/`lng` must be decimal degree strings (e.g. `'48.8566'`) — not integers
- `weather_temp_c` must be a string integer (e.g. `'22'`)
- `weather_desc` must be a single short phrase (e.g. `sunny`, `partly cloudy`, `rainy`)
- Map Links use OSM format: `https://www.openstreetmap.org/#map=15/{lat}/{lng}`
- All edits committed to the `user/` git repo via `make content-push` after all three trips are done
---
## File Map
| File | Action | Purpose |
|---|---|---|
| `docs/enrichment/central-asia-2023.md` | Create | Review table, 22 rows |
| `docs/enrichment/us-canada-mex-2024.md` | Create | Review table, 12 rows |
| `docs/enrichment/italy-2025.md` | Create | Review table, 2 rows |
| `user/pages/01.trips/central-asia-2023/01.dailies/*/entry.md` | Modify | Update 6 frontmatter fields (22 files) |
| `user/pages/01.trips/us-canada-mex-2024/01.dailies/*/entry.md` | Modify | Update 6 frontmatter fields (12 files) |
| `user/pages/01.trips/italy-2025/01.dailies/*/entry.md` | Modify | Update 6 frontmatter fields (2 files) |
---
## Task 1: Generate central-asia-2023 review doc
**Files:**
- Create: `docs/enrichment/central-asia-2023.md`
- Read: `user/pages/01.trips/central-asia-2023/01.dailies/*/entry.md` (22 files)
- [ ] **Step 1: Read all 22 entry.md files**
Read each file at `user/pages/01.trips/central-asia-2023/01.dailies/{folder}/entry.md`. Extract: folder name, `date`, `title`, `location_city`, `location_country`, body text.
- [ ] **Step 2: Infer location for each entry**
For each entry:
1. Read the title first — most locations are explicit.
2. Fall back to body text if title is ambiguous.
3. If neither reveals a location clearly, leave City/Country blank and put `?` in the Map Link cell.
Known city → approximate coordinates mapping for this trip (use these; do not hallucinate unfamiliar coordinates):
| City | Country | Lat | Lng |
|---|---|---|---|
| Berlin | Germany | 52.5200 | 13.4050 |
| Astana (Nur-Sultan) | Kazakhstan | 51.1801 | 71.4460 |
| Almaty | Kazakhstan | 43.2220 | 76.8512 |
| Karakol | Kyrgyzstan | 42.4900 | 78.3936 |
| Dushanbe | Tajikistan | 38.5598 | 68.7870 |
| Samarkand | Uzbekistan | 39.6542 | 66.9597 |
| Tbilisi | Georgia | 41.6938 | 44.8015 |
- [ ] **Step 3: Estimate weather for each entry**
Use typical daytime high (°C) and one-word description for the city + month. Reference values:
| City | Aug | Sep | Oct |
|---|---|---|---|
| Berlin | 24, sunny | 19, partly cloudy | 13, cloudy |
| Astana | 26, sunny | 17, partly cloudy | 5, cold |
| Almaty | 28, sunny | 21, sunny | 12, partly cloudy |
| Karakol | 24, sunny | 16, partly cloudy | 8, cold |
| Dushanbe | 35, sunny | 28, sunny | 18, sunny |
| Samarkand | 33, sunny | 25, sunny | 16, partly cloudy |
| Tbilisi | 29, sunny | 23, sunny | 14, partly cloudy |
- [ ] **Step 4: Write the review doc**
Create `docs/enrichment/central-asia-2023.md` with this exact structure:
```markdown
# central-asia-2023 Enrichment Review
**Instructions:** Review each row. To correct coordinates, replace the Map Link with a new OSM link (`https://www.openstreetmap.org/#map=15/{lat}/{lng}`) or a Google Maps URL — coordinates are extracted from the link. Edit City, Country, Temp, and Weather cells directly. Leave Map Link blank if no location is known.
| Entry | Date | Title | City | Country | Map Link | Temp °C | Weather |
|---|---|---|---|---|---|---|---|
| 2023-08-28-pixelfed-1.entry | 2023-08-28 | Welcome to My Central Asian Picture Diary | Berlin | Germany | https://www.openstreetmap.org/#map=15/52.5200/13.4050 | 24 | sunny |
...
```
One row per entry, in date order.
- [ ] **Step 5: Commit the generated doc**
```bash
cd /home/mischa/Projects/travel-blog-intotheeast
git add docs/enrichment/central-asia-2023.md
git commit -m "docs: add central-asia-2023 enrichment review doc"
```
- [ ] **Step 6: Prompt user to review**
Tell the user: "Review doc generated at `docs/enrichment/central-asia-2023.md`. Please open it, correct any locations or weather values, and let me know when it's ready to apply."
---
## Task 2: Apply central-asia-2023 enrichment (after user approval)
**Files:**
- Read: `docs/enrichment/central-asia-2023.md`
- Modify: `user/pages/01.trips/central-asia-2023/01.dailies/*/entry.md` (22 files)
**Prerequisite:** User has reviewed and approved `docs/enrichment/central-asia-2023.md`.
- [ ] **Step 1: Read the reviewed doc**
Read `docs/enrichment/central-asia-2023.md`. Parse each data row of the table.
- [ ] **Step 2: Extract coordinates from Map Links**
For each row where Map Link is not blank:
- OSM format `https://www.openstreetmap.org/#map={zoom}/{lat}/{lng}` → split on `/`, take last two values as lat and lng.
- Google Maps format `https://www.google.com/maps/@{lat},{lng},{zoom}z` → extract the `@lat,lng` portion.
- If Map Link is blank or `?`, set lat and lng to `''`.
- [ ] **Step 3: Apply to each entry.md**
For each row, open `user/pages/01.trips/central-asia-2023/01.dailies/{entry}/entry.md` and update these six fields using the Edit tool:
```yaml
location_city: '{City}'
location_country: '{Country}'
lat: '{Lat}'
lng: '{Lng}'
weather_temp_c: '{Temp}'
weather_desc: '{Weather}'
```
Replace the existing (likely empty) values. Keep all other frontmatter untouched.
- [ ] **Step 4: Verify**
For the first 3 and last entry, read back the file and confirm the six fields are set correctly.
- [ ] **Step 5: Commit**
```bash
cd /home/mischa/Projects/travel-blog-intotheeast && git add user/pages/01.trips/central-asia-2023/01.dailies/
git commit -m "content: enrich central-asia-2023 entries with location and weather"
```
---
## Task 3: Generate us-canada-mex-2024 review doc
**Files:**
- Create: `docs/enrichment/us-canada-mex-2024.md`
- Read: `user/pages/01.trips/us-canada-mex-2024/01.dailies/*/entry.md` (12 files)
- [ ] **Step 1: Read all 12 entry.md files**
Read each file at `user/pages/01.trips/us-canada-mex-2024/01.dailies/{folder}/entry.md`. Extract: folder name, `date`, `title`, body text.
- [ ] **Step 2: Infer location for each entry**
Known cities from titles for this trip:
| Title hint | City | Country | Lat | Lng |
|---|---|---|---|---|
| Piran | Piran | Slovenia | 45.5285 | 13.5680 |
| Portland / Amtrak (destination) | Portland | USA | 45.5231 | -122.6765 |
| San Francisco / Golden Gate / Highway 1 | San Francisco | USA | 37.7749 | -122.4194 |
| Los Angeles / burrito / beach | Los Angeles | USA | 34.0522 | -118.2437 |
| Toronto | Toronto | Canada | 43.6532 | -79.3832 |
| Niagara Falls | Niagara Falls | Canada | 43.0896 | -79.0849 |
| Montreal | Montreal | Canada | 45.5017 | -73.5673 |
| Mexico City | Mexico City | Mexico | 19.4326 | -99.1332 |
| Twin Peaks / windmills / craft beer | San Francisco | USA | 37.7749 | -122.4194 |
| Amtrak eighteen hours | (train — use Portland as destination) | USA | 45.5231 | -122.6765 |
Use the title + body together to identify each city.
- [ ] **Step 3: Estimate weather**
Reference values (daytime high °C, description) for this trip's cities + months (MayAug 2024):
| City | May | Jul | Aug |
|---|---|---|---|
| Piran | 21, sunny | — | — |
| San Francisco | — | 18, partly cloudy | 18, partly cloudy |
| Los Angeles | — | 28, sunny | 29, sunny |
| Portland | — | 27, sunny | 27, sunny |
| Toronto | — | — | 27, sunny |
| Niagara Falls | — | — | 26, sunny |
| Montreal | — | — | 26, sunny |
| Mexico City | — | — | 22, partly cloudy |
- [ ] **Step 4: Write the review doc**
Create `docs/enrichment/us-canada-mex-2024.md` with the same structure as the central-asia doc:
```markdown
# us-canada-mex-2024 Enrichment Review
**Instructions:** Review each row. To correct coordinates, replace the Map Link with a new OSM link (`https://www.openstreetmap.org/#map=15/{lat}/{lng}`) or a Google Maps URL — coordinates are extracted from the link. Edit City, Country, Temp, and Weather cells directly. Leave Map Link blank if no location is known.
| Entry | Date | Title | City | Country | Map Link | Temp °C | Weather |
|---|---|---|---|---|---|---|---|
| 2024-05-28-pixelfed-1.entry | 2024-05-28 | Ice Cream and Old Walls in Piran | Piran | Slovenia | https://www.openstreetmap.org/#map=15/45.5285/13.5680 | 21 | sunny |
...
```
- [ ] **Step 5: Commit**
```bash
cd /home/mischa/Projects/travel-blog-intotheeast
git add docs/enrichment/us-canada-mex-2024.md
git commit -m "docs: add us-canada-mex-2024 enrichment review doc"
```
- [ ] **Step 6: Prompt user to review**
Tell the user: "Review doc generated at `docs/enrichment/us-canada-mex-2024.md`. Please open it, correct any locations or weather values, and let me know when it's ready to apply."
---
## Task 4: Apply us-canada-mex-2024 enrichment (after user approval)
**Files:**
- Read: `docs/enrichment/us-canada-mex-2024.md`
- Modify: `user/pages/01.trips/us-canada-mex-2024/01.dailies/*/entry.md` (12 files)
**Prerequisite:** User has reviewed and approved `docs/enrichment/us-canada-mex-2024.md`.
- [ ] **Step 1: Read the reviewed doc and parse rows** — same method as Task 2 Step 1.
- [ ] **Step 2: Extract coordinates from Map Links** — same method as Task 2 Step 2.
- [ ] **Step 3: Apply to each entry.md**
For each row, open `user/pages/01.trips/us-canada-mex-2024/01.dailies/{entry}/entry.md` and update the six frontmatter fields.
- [ ] **Step 4: Verify** — read back first 3 and last entry, confirm fields are set.
- [ ] **Step 5: Commit**
```bash
cd /home/mischa/Projects/travel-blog-intotheeast && git add user/pages/01.trips/us-canada-mex-2024/01.dailies/
git commit -m "content: enrich us-canada-mex-2024 entries with location and weather"
```
---
## Task 5: Generate italy-2025 review doc
**Files:**
- Create: `docs/enrichment/italy-2025.md`
- Read: `user/pages/01.trips/italy-2025/01.dailies/*/entry.md` (2 files)
- [ ] **Step 1: Read both entry.md files.**
- [ ] **Step 2: Infer location.**
Entry 1 (2025-10-11): "600km of Tuscany Begins with an Aperitif" — route starts at Venturina Terme (from GPX filename). City: Venturina Terme, Italy. Lat: 43.0183, Lng: 10.6059.
Entry 2 (2025-10-16): read title + body to determine.
- [ ] **Step 3: Estimate weather.**
Tuscany, October: 18°C, partly cloudy (typical autumn).
- [ ] **Step 4: Write the review doc.**
Create `docs/enrichment/italy-2025.md` with the same structure, 2 data rows.
- [ ] **Step 5: Commit.**
```bash
cd /home/mischa/Projects/travel-blog-intotheeast
git add docs/enrichment/italy-2025.md
git commit -m "docs: add italy-2025 enrichment review doc"
```
- [ ] **Step 6: Prompt user to review.**
Tell the user: "Review doc generated at `docs/enrichment/italy-2025.md`. Please open it, correct any values, and let me know when ready to apply."
---
## Task 6: Apply italy-2025 enrichment (after user approval)
**Files:**
- Read: `docs/enrichment/italy-2025.md`
- Modify: `user/pages/01.trips/italy-2025/01.dailies/*/entry.md` (2 files)
**Prerequisite:** User has reviewed and approved `docs/enrichment/italy-2025.md`.
- [ ] **Step 1: Read the reviewed doc and parse rows.**
- [ ] **Step 2: Extract coordinates from Map Links.**
- [ ] **Step 3: Apply to both entry.md files** — update six frontmatter fields.
- [ ] **Step 4: Verify** — read both files back, confirm fields are set.
- [ ] **Step 5: Commit and sync**
```bash
cd /home/mischa/Projects/travel-blog-intotheeast && git add user/pages/01.trips/italy-2025/01.dailies/
git commit -m "content: enrich italy-2025 entries with location and weather"
make content-push
```
---
## Self-Review Notes
- All 36 entries covered across 6 tasks (3 generate + 3 apply)
- Human review gates are explicit: each "apply" task has a **Prerequisite** line
- Coordinate extraction rules cover both OSM and Google Maps URL formats
- Weather reference tables provide concrete values — no vague "look it up"
- `make content-push` only runs after the final trip to avoid partial syncs
- No test files needed — this is data enrichment; verification steps replace TDD
@@ -0,0 +1,942 @@
# 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"
```