feat: trip entity restructure + Grav 2.0 upgrade

- Switch to getgrav/grav Docker image with GRAV_CHANNEL=beta (Grav 1.7.53)
- Apache runs as host UID 1000; fix-perms target handles container setup
- Rename tracker → dailies throughout (URL slug, templates, tests)
- Trip entity: /trips/<slug>/{dailies,map,stats,stories} hierarchy
- Nav driven by active_trip in site.yaml
- GPX route rendering on map via leaflet-gpx CDN
- Italy 2025 demo trip with real Tuscany GPS routes
- Admin blueprint for trip pages (date range, cover image, album URL)
- Dark mode + visual polish plan ready at docs/superpowers/plans/2026-06-19-dark-mode.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 09:09:19 +02:00
16 changed files with 1788 additions and 78 deletions
+3 -1
View File
@@ -3,10 +3,12 @@
# Grav CMS # Grav CMS
/user/ /user/
!/user/
!/user/plugins/
!/user/plugins/cache-on-save/
user/accounts/ user/accounts/
user/data/ user/data/
user/cache/ user/cache/
user/plugins/
# Claude # Claude
.claude/ .claude/
+4 -5
View File
@@ -72,11 +72,10 @@ Then run `make setup` (starts Docker + installs plugins).
### After make install-plugins: fix cache permissions ### After make install-plugins: fix cache permissions
If the site returns a 500 error after plugin installation, the cache/logs/tmp directories may have wrong ownership (gpm runs as root inside the container). Fix with: If the site returns a 500 error after plugin installation or after recreating the container,
run `make fix-perms`. This creates uid 1000 in the container, chowns `/var/www/html` to 1000:1000,
```bash and reloads Apache. Always run `make setup` (not just `make start`) after `docker compose down && up`
docker exec intotheeast_grav chown -R abc:users /app/www/public/cache /app/www/public/logs /app/www/public/tmp to ensure permissions are correct.
```
### Language URL prefix ### Language URL prefix
+22 -7
View File
@@ -27,23 +27,38 @@ start:
stop: stop:
docker compose down docker compose down
setup: start install-plugins setup: start install-plugins fix-perms
fix-perms:
docker exec intotheeast_grav bash -c "getent passwd 1000 > /dev/null || useradd -u 1000 -M hostuser"
docker exec intotheeast_grav chown -R 1000:1000 /var/www/html
docker exec intotheeast_grav apachectl graceful
install-plugins: install-plugins:
docker exec -w /app/www/public intotheeast_grav php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y docker exec -w /var/www/html intotheeast_grav php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y
# ── Demo content ────────────────────────────────────────────────────────────── # ── Demo content ──────────────────────────────────────────────────────────────
demo-load: demo-load:
cp -r user/docs/demo/tracker/. user/pages/01.tracker/ # Load japan-korea-2026 dailies
docker exec intotheeast_grav bash -c "cd /app/www/public && php bin/grav clearcache" cp -r user/docs/demo/trips/japan-korea-2026/dailies/. user/pages/01.trips/japan-korea-2026/01.dailies/
# Load italy-2025 trip (create pages if absent)
mkdir -p user/pages/01.trips/italy-2025/01.dailies user/pages/01.trips/italy-2025/02.map user/pages/01.trips/italy-2025/03.stats user/pages/01.trips/italy-2025/04.stories
cp user/docs/demo/trips/italy-2025/trip.md user/pages/01.trips/italy-2025/trip.md 2>/dev/null || true
cp user/docs/demo/trips/italy-2025/map.md user/pages/01.trips/italy-2025/02.map/map.md 2>/dev/null || true
cp user/docs/demo/trips/italy-2025/stats.md user/pages/01.trips/italy-2025/03.stats/stats.md 2>/dev/null || true
cp user/docs/demo/trips/italy-2025/stories.md user/pages/01.trips/italy-2025/04.stories/stories.md 2>/dev/null || true
cp -r user/docs/demo/trips/italy-2025/dailies/. user/pages/01.trips/italy-2025/01.dailies/
cp user/docs/demo/trips/italy-2025/*.gpx user/pages/01.trips/italy-2025/ 2>/dev/null || true
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache"
demo-reset: demo-reset:
@for dir in user/docs/demo/tracker/*/; do \ @for dir in user/docs/demo/trips/japan-korea-2026/dailies/*/; do \
folder=$$(basename "$$dir"); \ folder=$$(basename "$$dir"); \
rm -rf "user/pages/01.tracker/$$folder"; \ rm -rf "user/pages/01.trips/japan-korea-2026/01.dailies/$$folder"; \
done done
docker exec intotheeast_grav bash -c "cd /app/www/public && php bin/grav clearcache" rm -rf user/pages/01.trips/italy-2025
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache"
# ── Content sync (user repo ↔ Gitea) ────────────────────────────────────────── # ── Content sync (user repo ↔ Gitea) ──────────────────────────────────────────
+6 -5
View File
@@ -1,13 +1,14 @@
services: services:
grav: grav:
image: lscr.io/linuxserver/grav:latest image: getgrav/grav
container_name: intotheeast_grav container_name: intotheeast_grav
environment: environment:
- PUID=1000 - GRAV_CHANNEL=beta
- PGID=1000 - APACHE_RUN_USER=#1000
- APACHE_RUN_GROUP=#1000
ports: ports:
- "8081:80" - "8081:80"
volumes: volumes:
- ./user:/config/www/user - ./user:/var/www/html/user
- ./php/php-local.ini:/config/php/php-local.ini - ./php/php-local.ini:/usr/local/etc/php/conf.d/php-local.ini
restart: unless-stopped restart: unless-stopped
@@ -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.
@@ -0,0 +1,316 @@
# 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.
**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)
- Stadia Maps tile URL: `https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/{z}/{x}/{y}{r}.png`
- Stadia attribution (exact): `© <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`
---
### 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
- [ ] **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`.
- [ ] **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.
- [ ] **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`
- [ ] **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.
- [ ] **Step 5: Run test suite**
```bash
make test-ui
```
Expected: 24/25 pass (P2 FilePond is pre-existing failure, all others pass).
- [ ] **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
- [ ] **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).
- [ ] **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;
}
```
- [ ] **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; }
```
- [ ] **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.
- [ ] **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`.
- [ ] **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.
- [ ] **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.
- [ ] **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.
- [ ] **Step 9: Run test suite**
```bash
make test-ui
```
Expected: 24/25 (P2 pre-existing).
- [ ] **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
- [ ] **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.
- [ ] **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);
```
- [ ] **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).
- [ ] **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
- [ ] **Step 5: Check mini-map on dailies page**
```bash
curl -s http://localhost:8081/trips/japan-korea-2026/dailies | grep -o 'stadiamaps'
```
Expected: `stadiamaps`.
- [ ] **Step 6: Run test suite**
```bash
make test-ui
```
Expected: 24/25 (P2 pre-existing).
- [ ] **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,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,168 @@
# Grav 2.0 Upgrade — Design Spec
**Goal:** Upgrade the intotheeast travel blog from Grav 1.7.x to Grav 2.0 RC on a feature branch, validate full Milestone 1 functionality, and prepare a clean production fresh-install path.
**Context:** Departure date is 2026-07-15. The production server has never been deployed, so production gets a fresh Grav 2.0 install — no in-place migration required. Local dev uses Docker; production uses PHP 8.4 directly.
---
## Scope
Two tracks:
1. **Local dev track** — swap Docker image to Grav 2.0, validate all functionality
2. **Production track** — update `server-install.sh` and `.env` so `make remote-install` deploys Grav 2.0 fresh
All work on branch `update-to-2.0`.
---
## Compatibility Assessment
| Component | Status | Action required |
|---|---|---|
| `form`, `login`, `email`, `error`, `problems`, `flex-objects` | ✅ First-party | Auto-updated to 2.0 versions via GPM |
| `shortcode-core` | ✅ First-party | Same |
| `cache-on-save` (custom) | ✅ Should work | Add Grav 2.0 compat flag to `blueprints.yaml`; uses `onFormProcessed` which is unchanged |
| `shortcode-gallery-plusplus` | ✅ Likely works | Plugin arch unchanged; test and confirm |
| `add-page-by-form` | ⚠️ Archived Aug 2024 | Try as-is (plugin arch unchanged, may work); if broken, write a custom replacement |
| Custom `intotheeast` theme | ✅ Should work | Twig 3 compat mode covers existing templates; test rendering |
| `linuxserver/grav` Docker image | ❌ Not supported | Replace with `getgrav/grav` + `GRAV_CHANNEL=beta` |
---
## Track 1 — Local Dev
### Changes
**`docker-compose.yml`**
Replace:
```yaml
image: lscr.io/linuxserver/grav:latest
environment:
- PUID=1000
- PGID=1000
volumes:
- ./user:/config/www/user
```
With:
```yaml
image: getgrav/grav
environment:
- GRAV_CHANNEL=beta
volumes:
- ./user:/var/www/html/user
```
**`Makefile`** — three targets reference the linuxserver internal path `/app/www/public`; replace with `/var/www/html`:
- `install-plugins`: `docker exec -w /app/www/public``docker exec -w /var/www/html`
- `demo-load` clear cache: `/app/www/public``/var/www/html`
- `demo-reset` clear cache: same
**`user/plugins/cache-on-save/blueprints.yaml`** (create — does not exist yet) — minimal blueprint with Grav 2.0 compat flag:
```yaml
name: Cache On Save
version: 1.0.0
description: Clears Grav cache on new-entry form submission
author:
name: Mischa
license: MIT
dependencies:
- { name: grav, version: '>=1.6.0' }
grav:
version: ['1.7', '2.0']
```
**`user/config/system.yaml`** — switch GPM to testing channel so `make setup` resolves 2.0-compatible plugin versions:
```yaml
gpm:
releases: testing
```
### Validation Checklist (smoke test after `make setup`)
Run in order — stop and investigate if any step fails:
1. **Site loads**`http://localhost:8081` returns the tracker page (200, no PHP errors)
2. **Admin2 loads**`/admin` renders the new SPA admin (not the old Twig admin)
3. **Login works** — log in via Admin2 with existing credentials
4. **Posting form** — submit `/post` form with title + text; entry appears immediately in `/tracker`
5. **Photo upload** — submit `/post` form with a photo; image renders in the entry
6. **Gallery** — visit an entry with multiple photos; `shortcode-gallery-plusplus` renders gallery with lightbox
7. **Cache invalidation** — submit a second post; it appears without a manual cache clear (validates `cache-on-save`)
8. **Theme rendering** — check tracker, entry, map, post-form, and stats templates for layout/CSS regressions
9. **Playwright suite**`make test-ui` passes all 25 tests. If any tests fail, investigate whether the failure is a genuine regression (blocker) or a test that needs updating for Admin2's new DOM structure (acceptable — update the test)
### If `add-page-by-form` fails
If step 4 fails due to `add-page-by-form` incompatibility, the fallback is to write a custom replacement plugin. The existing `cache-on-save` plugin is a good template — it hooks `onFormProcessed` and that API is unchanged. The replacement would use the same hook to:
1. Build the page path and slug from form fields
2. Create the page file on disk (same logic `add-page-by-form` does in PHP)
3. Clear cache (merging `cache-on-save` functionality)
This is ~1 day of work and should be planned as a follow-up task if needed.
---
## Track 2 — Production (Fresh Install)
Production has PHP 8.4 (compatible with Grav 2.0's PHP 8.3+ requirement) and has never been deployed.
### Changes
**`server-install.sh`** — the download URL for Grav 2.0 RC requires a `?testing` query parameter:
Current:
```bash
wget --no-verbose "https://getgrav.org/download/core/grav-admin/$GRAV_VERSION" -O grav-admin.zip
```
Updated (conditionally append `?testing` for pre-release versions, or accept a full URL suffix via env var):
```bash
wget --no-verbose "https://getgrav.org/download/core/grav-admin/${GRAV_VERSION}${GRAV_CHANNEL_SUFFIX}" -O grav-admin.zip
```
Where `GRAV_CHANNEL_SUFFIX` is `?testing` for RC versions and empty for stable.
**`.env`** (not committed — edit on the server directly or locally before `make remote-install`) — update:
```
GRAV_VERSION=2.0.0-rc.9
GRAV_CHANNEL_SUFFIX=?testing
```
When Grav 2.0 goes stable, remove `GRAV_CHANNEL_SUFFIX` and update `GRAV_VERSION` to the stable version number.
**`user/config/system.yaml`** — keep `gpm.releases: testing` (already set in Track 1) so production also installs 2.0-compatible plugin versions.
### Production deploy
When local validation passes:
```bash
make remote-install
```
That's it — fresh Grav 2.0 install from scratch with all plugins, content from Gitea, and the existing `user/` config.
---
## Out of Scope
- MCP server setup (`grav-mcp` Node.js binary) — a separate task after Grav 2.0 is stable on production
- Admin2 theming or customization
- Grav 2.0 REST API integration
- Switching `add-page-by-form` to the API-based approach (only if the plugin breaks)
---
## Go/No-Go Criteria
Ship to production before departure (2026-07-15) **only if**:
- All 9 smoke test steps pass
- Playwright suite passes
- `add-page-by-form` posting workflow works end-to-end (or a custom replacement is in place and tested)
If any of these fail and cannot be resolved with time to spare before departure, stay on Grav 1.7 for the trip and revisit post-trip.
@@ -0,0 +1,157 @@
# Dark Mode & Visual Polish Design Spec
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task.
**Goal:** Replace the existing warm-paper light theme with a warm-dark "notebook/sketchbook at night" aesthetic — dark-only, no toggle, no system preference detection. Add paper grain texture, switch to dark terrain map tiles, and tighten typography.
**Architecture:** All changes are CSS and one Twig template update. Color tokens live in `tokens.css` (swap values, keep names). Grain texture is a pure-CSS SVG noise layer on `body::after`. Map tiles swap in `map.html.twig`. No new dependencies, no JS changes.
**Approach chosen:** B — color token swap + paper grain + typography refinements. Card/hero treatment (Approach C) deferred to a future visual polish pass.
**Tech Stack:** CSS custom properties, inline SVG data URI for grain, Stadia Maps tile CDN for dark terrain.
---
## Global Constraints
- Dark-only — no light mode, no `prefers-color-scheme` media query, no toggle
- All changes in `user/` — commit with `git -C user`
- No new npm/JS dependencies
- Existing token names (`--color-paper`, `--color-ink`, etc.) must not change — only values
- Teal accent `#1F6B5A` lightens to `#2A8C73` for dark-background contrast
- Map tile provider: Stadia Maps Alidade Smooth Dark (free tier; API key needed for production — see Task 2)
- `make test-ui` must pass after implementation (25/25 or pre-existing P2 exception)
---
## 1. Color System
Replace all values in `user/themes/intotheeast/css/tokens.css`. Token names are unchanged.
### Dark palette
| Token | Old value | New value | Role |
|---|---|---|---|
| `--color-paper` | `#F7F5F2` | `#1A1814` | Page background — warm near-black |
| `--color-canvas` | `#FFFFFF` | `#22201B` | Card surfaces, form backgrounds |
| `--color-ink` | `#17171A` | `#EDE8DF` | Primary text — warm cream |
| `--color-ink-2` | `#4A4850` | `#B8B0A4` | Body text — muted warm |
| `--color-ink-muted` | `#9896A0` | `#7A7268` | Labels, timestamps, captions |
| `--color-border` | `#E8E6E3` | `#2E2B25` | Standard dividers |
| `--color-border-soft` | `#F0EDEA` | `#252219` | Subtle dividers |
| `--color-accent` | `#1F6B5A` | `#2A8C73` | Teal — lightened for dark contrast |
| `--color-accent-hover` | `#185647` | `#236655` | Hover/pressed teal |
| `--color-accent-light` | `#EBF5F2` | `#1A2E29` | Pale teal tint backgrounds |
| `--color-accent-on` | `#FFFFFF` | `#FFFFFF` | Text on accent surfaces (unchanged) |
### Additional dark-only tokens (add to tokens.css)
```css
--color-surface-raised: #2A2720; /* elevated surfaces: tooltips, hover states */
--color-ink-inverse: #17171A; /* text on accent-colored buttons */
```
---
## 2. Paper Grain Texture
Add to `style.css`, in the `body` section:
```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;
}
```
This overlays a fixed noise texture across the entire viewport. `pointer-events: none` ensures it never blocks clicks. `z-index: 9998` keeps it below any modals or dropdowns (which should use z-index 9999+). Opacity 3.5% — subtle enough to feel like paper texture without being distracting on photography.
---
## 3. Map Tiles — Stadia Alidade Smooth Dark
Replace the tile layer in `user/themes/intotheeast/templates/map.html.twig`.
**Old:**
```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);
```
**New:**
```javascript
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);
```
**Production note:** Stadia Maps requires a free API key for production domains. Add the key as a query param when ready: `?api_key=YOUR_KEY`. During development on localhost no key is needed. Add a `<!-- TODO: add Stadia API key before launch -->` comment above the tile layer call so it's not forgotten.
Also update the mini-map tile layer in `dailies.html.twig` (same swap — same tile URL, same attribution).
---
## 4. Typography Refinements
Targeted improvements to `style.css` — not a full type system rewrite.
### 4a. Entry body readability
The entry body text (`--text-md` / 1.125rem) already uses `--leading-normal` (1.65) which is good. Increase the paragraph bottom margin slightly for breathing room:
```css
/* current */
.entry-body p { margin-bottom: 1.1em; ... }
/* new */
.entry-body p { margin-bottom: 1.4em; ... }
```
### 4b. Heading tracking
DM Serif Display at large sizes benefits from slightly tighter tracking. Find heading rules that currently have `letter-spacing: -0.01em` and tighten to `-0.02em`. Only apply to `h1` and `h2` — smaller headings keep current tracking.
### 4c. Login form dark surface
The login form currently hardcodes `background: #f0f0f0; color: #333` on the secondary button (line ~497 in style.css). Replace with tokens:
```css
/* current */
.login-form .button.secondary { background: #f0f0f0; color: #333; ... }
/* new */
.login-form .button.secondary { background: var(--color-canvas); color: var(--color-ink); ... }
```
### 4d. Stats numbers
On the stats page, numeric values should feel deliberate. Add `font-variant-numeric: tabular-nums` to the stat value elements so columns of numbers align cleanly.
---
## 5. Incidental dark-mode fixes
Some existing styles use hardcoded light colors that will look wrong in dark mode. Audit and fix these in `style.css`:
- Any `background: #fff` or `background: white``var(--color-canvas)`
- Any `color: #333` or similar hardcoded dark text → `var(--color-ink)` or `var(--color-ink-2)`
- Any `border: 1px solid #eee` or similar → `var(--color-border)`
- Focus outline: currently likely a light-mode color — ensure `outline-color` uses `var(--color-accent)`
Run a grep for literal hex values after implementation: `grep -n '#[0-9a-fA-F]\{3,6\}' user/themes/intotheeast/css/style.css` — every hit is a candidate to tokenize.
---
## Verification
After implementation:
1. `make test-ui` — all tests pass
2. Visual check at `http://localhost:8081/trips/japan-korea-2026/dailies` — warm dark background, cream text, teal accents visible, subtle grain
3. Visual check at `http://localhost:8081/trips/japan-korea-2026/map` — dark terrain tiles load, GPX polyline visible, entry pins visible
4. Check the post form at `/post` — form fields readable on dark canvas, no white-on-white or black-on-black surfaces
5. Run the hardcoded-hex grep and confirm any remaining literals are intentional
+4 -1
View File
@@ -11,6 +11,9 @@ set -e
: "${GITEA_USER:?GITEA_USER is not set}" : "${GITEA_USER:?GITEA_USER is not set}"
: "${GITEA_TOKEN:?GITEA_TOKEN is not set}" : "${GITEA_TOKEN:?GITEA_TOKEN is not set}"
# 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.
trap 'rm -f ~/.netrc' EXIT trap 'rm -f ~/.netrc' EXIT
echo "==> Setting up credentials (temporary)" echo "==> Setting up credentials (temporary)"
@@ -19,7 +22,7 @@ chmod 600 ~/.netrc
echo "==> Downloading Grav $GRAV_VERSION" echo "==> Downloading Grav $GRAV_VERSION"
cd "$WEBROOT" cd "$WEBROOT"
wget --no-verbose "https://getgrav.org/download/core/grav-admin/$GRAV_VERSION" -O grav-admin.zip wget --no-verbose "https://getgrav.org/download/core/grav-admin/${GRAV_VERSION}${GRAV_CHANNEL_SUFFIX:-}" -O grav-admin.zip
unzip -oq grav-admin.zip unzip -oq grav-admin.zip
cp -rf grav-admin/. . cp -rf grav-admin/. .
rm -rf grav-admin grav-admin.zip rm -rf grav-admin grav-admin.zip
+1 -1
View File
@@ -26,7 +26,7 @@ grep -q "add_page:\|addpage:" "$FORM" && ok "Process action is 'add_page' (plugi
# Config must be in frontmatter, not in the process block # Config must be in frontmatter, not in the process block
check_grep "pageconfig block exists in frontmatter" "^pageconfig:" check_grep "pageconfig block exists in frontmatter" "^pageconfig:"
check_grep "parent set to /tracker" "parent: '/tracker'" check_grep "parent set to /trips/japan-korea-2026/dailies" "parent: '/trips/japan-korea-2026/dailies'"
check_grep "slug_field set (determines entry folder name)" "slug_field:" check_grep "slug_field set (determines entry folder name)" "slug_field:"
check_grep "pagefrontmatter block exists in frontmatter" "^pagefrontmatter:" check_grep "pagefrontmatter block exists in frontmatter" "^pagefrontmatter:"
check_grep "template: entry (creates entry.md filename)" "template: entry" check_grep "template: entry (creates entry.md filename)" "template: entry"
+31 -28
View File
@@ -7,7 +7,7 @@ set -euo pipefail
BASE_URL="${GRAV_BASE_URL:-http://localhost:8081}" BASE_URL="${GRAV_BASE_URL:-http://localhost:8081}"
USER="${GRAV_TEST_USER:-}" USER="${GRAV_TEST_USER:-}"
PASS="${GRAV_TEST_PASS:-}" PASS="${GRAV_TEST_PASS:-}"
TRACKER="user/pages/01.tracker" TRACKER="user/pages/01.trips/japan-korea-2026/01.dailies"
COOKIE_JAR="$(mktemp /tmp/grav-test-cookies.XXXXXX)" COOKIE_JAR="$(mktemp /tmp/grav-test-cookies.XXXXXX)"
PASS_COUNT=0 PASS_COUNT=0
FAIL_COUNT=0 FAIL_COUNT=0
@@ -16,8 +16,13 @@ TEST_SLUG=""
cleanup() { cleanup() {
rm -f "$COOKIE_JAR" rm -f "$COOKIE_JAR"
if [ -n "$TEST_SLUG" ] && [ -d "$TRACKER/$TEST_SLUG" ]; then if [ -n "$TEST_SLUG" ] && [ -d "$TRACKER/$TEST_SLUG" ]; then
rm -rf "$TRACKER/$TEST_SLUG" # Entry files are created by www-data inside Docker; use docker exec to remove
if docker exec intotheeast_grav rm -rf "/var/www/html/$TRACKER/$TEST_SLUG" 2>/dev/null; then
echo " [cleanup] Removed test entry: $TEST_SLUG" echo " [cleanup] Removed test entry: $TEST_SLUG"
else
rm -rf "$TRACKER/$TEST_SLUG" 2>/dev/null || \
echo " [cleanup] Warning: could not remove $TEST_SLUG (permission denied — remove manually)"
fi
fi fi
} }
trap cleanup EXIT trap cleanup EXIT
@@ -37,27 +42,27 @@ echo "────────────────────────
LOGIN_HTML=$(curl -sf -c "$COOKIE_JAR" -b "$COOKIE_JAR" "$BASE_URL/login") \ LOGIN_HTML=$(curl -sf -c "$COOKIE_JAR" -b "$COOKIE_JAR" "$BASE_URL/login") \
|| die "Could not reach $BASE_URL/login" || die "Could not reach $BASE_URL/login"
LOGIN_NONCE=$(echo "$LOGIN_HTML" | grep -o 'name="form-nonce" value="[^"]*"' | head -1 | sed 's/.*value="\([^"]*\)".*/\1/') LOGIN_NONCE=$(echo "$LOGIN_HTML" | grep -o 'name="login-form-nonce" value="[^"]*"' | head -1 | sed 's/.*value="\([^"]*\)".*/\1/')
[ -n "$LOGIN_NONCE" ] || die "Could not extract login form nonce — is the site running?" [ -n "$LOGIN_NONCE" ] || die "Could not extract login form nonce — is the site running?"
# ── Step 2: log in ─────────────────────────────────────────────────────────── # ── Step 2: log in ───────────────────────────────────────────────────────────
LOGIN_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \ LOGIN_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \
-c "$COOKIE_JAR" -b "$COOKIE_JAR" \ -c "$COOKIE_JAR" -b "$COOKIE_JAR" \
-L \ -L \
-d "username=${USER}&password=${PASS}&form-nonce=${LOGIN_NONCE}&task=login" \ -d "username=${USER}&password=${PASS}&login-form-nonce=${LOGIN_NONCE}&task=login.login" \
"$BASE_URL/login") "$BASE_URL/login")
# After login, check we can access /post (302 → 200 means logged in) # After login, fetch /post and verify we see the post form (not the login form)
POST_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \ # /post returns 200 for both auth and unauth users — check for form-nonce to confirm login
-c "$COOKIE_JAR" -b "$COOKIE_JAR" \ POST_CHECK_HTML=$(curl -sf -c "$COOKIE_JAR" -b "$COOKIE_JAR" "$BASE_URL/post") \
"$BASE_URL/post") || die "Could not reach $BASE_URL/post"
[ "$POST_STATUS" = "200" ] && ok "Login succeeded and /post is accessible" \ POST_STATUS=$(echo "$POST_CHECK_HTML" | grep -c 'name="form-nonce"' || true)
|| die "Login failed or /post returned $POST_STATUS — check GRAV_TEST_USER / GRAV_TEST_PASS" [ "$POST_STATUS" -gt 0 ] && ok "Login succeeded and /post is accessible" \
|| die "Login failed (post form not visible) — check GRAV_TEST_USER / GRAV_TEST_PASS"
# ── Step 3: get post form + nonce ──────────────────────────────────────────── # ── Step 3: extract post form nonce from already-fetched HTML ────────────────
POST_HTML=$(curl -sf -c "$COOKIE_JAR" -b "$COOKIE_JAR" "$BASE_URL/post") \ POST_HTML="$POST_CHECK_HTML"
|| die "Could not fetch post form"
POST_NONCE=$(echo "$POST_HTML" | grep -o 'name="form-nonce" value="[^"]*"' | head -1 | sed 's/.*value="\([^"]*\)".*/\1/') POST_NONCE=$(echo "$POST_HTML" | grep -o 'name="form-nonce" value="[^"]*"' | head -1 | sed 's/.*value="\([^"]*\)".*/\1/')
[ -n "$POST_NONCE" ] || die "Could not extract post form nonce" [ -n "$POST_NONCE" ] || die "Could not extract post form nonce"
@@ -84,33 +89,31 @@ ok "Form submitted"
# ── Step 5: verify entry exists on disk ───────────────────────────────────── # ── Step 5: verify entry exists on disk ─────────────────────────────────────
sleep 1 # give Grav a moment to write the file sleep 1 # give Grav a moment to write the file
# Look for the entry — slug might have slight timestamp variation # Find an entry containing the test title — search all .md and .en.md files
FOUND=$(find "$TRACKER" -name "entry.md" -newer "$TRACKER/2026-06-17.entry/entry.md" \ # add-page-by-form may produce date-based slugs in various formats
-not -path "*/2026-*" 2>/dev/null | head -1) ENTRY_FILE=$(grep -rl "$TEST_TITLE" "$TRACKER" --include="*.md" 2>/dev/null | head -1)
# Also look for today's dated entries if [ -n "$ENTRY_FILE" ]; then
FOUND_TODAY=$(find "$TRACKER" -maxdepth 1 -type d -name "$(date '+%Y-%m-%d')*" 2>/dev/null | head -1) TEST_SLUG=$(basename "$(dirname "$ENTRY_FILE")")
if [ -n "$FOUND_TODAY" ]; then
TEST_SLUG=$(basename "$FOUND_TODAY")
ok "Entry created on disk: $TEST_SLUG" ok "Entry created on disk: $TEST_SLUG"
# Verify it has an entry.md inside # Verify file name is entry.md or entry.en.md (template-named file)
if [ -f "$TRACKER/$TEST_SLUG/entry.md" ]; then ENTRY_BASENAME=$(basename "$ENTRY_FILE")
ok "entry.md exists inside the entry folder" if [ "$ENTRY_BASENAME" = "entry.md" ] || [ "$ENTRY_BASENAME" = "entry.en.md" ]; then
ok "Entry file exists: $ENTRY_BASENAME"
else else
fail "Entry folder exists but entry.md is missing" fail "Entry file has unexpected name: $ENTRY_BASENAME (expected entry.md or entry.en.md)"
fi fi
# Verify the title is in the frontmatter # Verify the title is in the frontmatter
if grep -q "$TEST_TITLE" "$TRACKER/$TEST_SLUG/entry.md"; then if grep -q "$TEST_TITLE" "$ENTRY_FILE"; then
ok "Title appears in entry frontmatter" ok "Title appears in entry frontmatter"
else else
fail "Title not found in entry.md — frontmatter may be malformed" fail "Title not found in $ENTRY_BASENAME — frontmatter may be malformed"
fi fi
else else
fail "No entry created on disk — form processing failed silently" fail "No entry created on disk — form processing failed silently"
echo " Expected a folder matching: $TRACKER/$(date '+%Y-%m-%d')-*/" echo " Searched $TRACKER for files containing: $TEST_TITLE"
fi fi
# ── Result ─────────────────────────────────────────────────────────────────── # ── Result ───────────────────────────────────────────────────────────────────
@@ -1,5 +1,5 @@
// @ts-check // @ts-check
// Tests: T1T5 — tracker feed and individual entry pages // Tests: T1T5 — dailies feed and individual entry pages
const { test, expect } = require('@playwright/test'); const { test, expect } = require('@playwright/test');
// Known fixture entries that always exist in the repo // Known fixture entries that always exist in the repo
@@ -12,18 +12,18 @@ const KNOWN_COUNTRY = 'Japan';
const NEWER_SLUG = '2026-06-17.entry'; // most recent fixture (June 17) const NEWER_SLUG = '2026-06-17.entry'; // most recent fixture (June 17)
const OLDER_SLUG = '2026-03-25-1540-wheels-down-narita.entry'; // oldest fixture (March 25) const OLDER_SLUG = '2026-03-25-1540-wheels-down-narita.entry'; // oldest fixture (March 25)
// ── T1: Tracker page loads ───────────────────────────────────────────────────── // ── T1: Dailies page loads ─────────────────────────────────────────────────────
test('T1: /tracker loads and shows at least one entry card', async ({ page }) => { test('T1: /trips/japan-korea-2026/dailies loads and shows at least one entry card', async ({ page }) => {
await page.goto('/tracker'); await page.goto('/trips/japan-korea-2026/dailies');
await expect(page.locator('.entry-card').first()).toBeVisible(); await expect(page.locator('.entry-card').first()).toBeVisible();
await expect(page.locator('.site-header')).toBeVisible(); await expect(page.locator('.site-header')).toBeVisible();
}); });
// ── T2: Entries are newest-first ────────────────────────────────────────────── // ── T2: Entries are newest-first ──────────────────────────────────────────────
// Verify using two known fixture entries rather than all entries // Verify using two known fixture entries rather than all entries
// (the tracker may contain noisy test-run debris with inconsistent dates). // (the dailies may contain noisy test-run debris with inconsistent dates).
test('T2: tracker shows newer entries before older entries', async ({ page }) => { test('T2: dailies shows newer entries before older entries', async ({ page }) => {
await page.goto('/tracker'); await page.goto('/trips/japan-korea-2026/dailies');
// Both fixture entries must be visible on the page // Both fixture entries must be visible on the page
const newerCard = page.locator(`.entry-card a[href*="${NEWER_SLUG}"]`); const newerCard = page.locator(`.entry-card a[href*="${NEWER_SLUG}"]`);
@@ -44,15 +44,15 @@ test('T2: tracker shows newer entries before older entries', async ({ page }) =>
}); });
// ── T3: Individual entry page loads ─────────────────────────────────────────── // ── T3: Individual entry page loads ───────────────────────────────────────────
test('T3: individual entry page loads at /tracker/{slug}', async ({ page }) => { test('T3: individual entry page loads at /trips/japan-korea-2026/dailies/{slug}', async ({ page }) => {
await page.goto(`/tracker/${KNOWN_SLUG}`); await page.goto(`/trips/japan-korea-2026/dailies/${KNOWN_SLUG}`);
await expect(page.locator('article.entry')).toBeVisible(); await expect(page.locator('article.entry')).toBeVisible();
await expect(page.locator('.site-header')).toBeVisible(); await expect(page.locator('.site-header')).toBeVisible();
}); });
// ── T4: Entry page shows title, date, and content ───────────────────────────── // ── T4: Entry page shows title, date, and content ─────────────────────────────
test('T4: entry page shows title and body content', async ({ page }) => { test('T4: entry page shows title and body content', async ({ page }) => {
await page.goto(`/tracker/${KNOWN_SLUG}`); await page.goto(`/trips/japan-korea-2026/dailies/${KNOWN_SLUG}`);
await expect(page.locator('.entry-title')).toContainText(KNOWN_TITLE); await expect(page.locator('.entry-title')).toContainText(KNOWN_TITLE);
await expect(page.locator('.entry-body')).not.toBeEmpty(); await expect(page.locator('.entry-body')).not.toBeEmpty();
await expect(page.locator('time.entry-date')).toBeVisible(); await expect(page.locator('time.entry-date')).toBeVisible();
@@ -60,7 +60,7 @@ test('T4: entry page shows title and body content', async ({ page }) => {
// ── T5: Entry page shows location when present ──────────────────────────────── // ── T5: Entry page shows location when present ────────────────────────────────
test('T5: entry page shows city and country when set', async ({ page }) => { test('T5: entry page shows city and country when set', async ({ page }) => {
await page.goto(`/tracker/${KNOWN_SLUG}`); await page.goto(`/trips/japan-korea-2026/dailies/${KNOWN_SLUG}`);
await expect(page.locator('.entry-location')).toContainText(KNOWN_CITY); await expect(page.locator('.entry-location')).toContainText(KNOWN_CITY);
await expect(page.locator('.entry-location')).toContainText(KNOWN_COUNTRY); await expect(page.locator('.entry-location')).toContainText(KNOWN_COUNTRY);
}); });
+1 -1
View File
@@ -2,7 +2,7 @@
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const TRACKER_DIR = path.join(__dirname, '../../user/pages/01.tracker'); const TRACKER_DIR = path.join(__dirname, '../../user/pages/01.trips/japan-korea-2026/01.dailies');
/** /**
* Wait for all filepond items to finish XHR upload. * Wait for all filepond items to finish XHR upload.
+13 -13
View File
@@ -2,42 +2,42 @@
// Tests: N1N5 — page loads and navigation links // Tests: N1N5 — page loads and navigation links
const { test, expect } = require('@playwright/test'); const { test, expect } = require('@playwright/test');
// ── N1: /tracker renders ────────────────────────────────────────────────────── // ── N1: /trips/japan-korea-2026/dailies renders ───────────────────────────────
test('N1: /tracker page loads with site header', async ({ page }) => { test('N1: /trips/japan-korea-2026/dailies page loads with site header', async ({ page }) => {
const errors = []; const errors = [];
page.on('pageerror', e => errors.push(e.message)); page.on('pageerror', e => errors.push(e.message));
await page.goto('/tracker'); await page.goto('/trips/japan-korea-2026/dailies');
await expect(page.locator('.site-header')).toBeVisible(); await expect(page.locator('.site-header')).toBeVisible();
await expect(page).toHaveTitle(/Into the East/i); await expect(page).toHaveTitle(/Into the East/i);
expect(errors).toHaveLength(0); expect(errors).toHaveLength(0);
}); });
// ── N2: /map renders without JS errors ─────────────────────────────────────── // ── N2: /trips/japan-korea-2026/map renders without JS errors ─────────────────
test('N2: /map page loads without JS errors', async ({ page }) => { test('N2: /trips/japan-korea-2026/map page loads without JS errors', async ({ page }) => {
const errors = []; const errors = [];
page.on('pageerror', e => errors.push(e.message)); page.on('pageerror', e => errors.push(e.message));
await page.goto('/map'); await page.goto('/trips/japan-korea-2026/map');
await expect(page.locator('.site-header')).toBeVisible(); await expect(page.locator('.site-header')).toBeVisible();
expect(errors).toHaveLength(0); expect(errors).toHaveLength(0);
}); });
// ── N3: /stats renders ─────────────────────────────────────────────────────── // ── N3: /trips/japan-korea-2026/stats renders ─────────────────────────────────
test('N3: /stats page loads with site header', async ({ page }) => { test('N3: /trips/japan-korea-2026/stats page loads with site header', async ({ page }) => {
const errors = []; const errors = [];
page.on('pageerror', e => errors.push(e.message)); page.on('pageerror', e => errors.push(e.message));
await page.goto('/stats'); await page.goto('/trips/japan-korea-2026/stats');
await expect(page.locator('.site-header')).toBeVisible(); await expect(page.locator('.site-header')).toBeVisible();
expect(errors).toHaveLength(0); expect(errors).toHaveLength(0);
}); });
// ── N4: "Journal" nav link goes to /tracker ─────────────────────────────────── // ── N4: "Journal" nav link goes to /dailies ───────────────────────────────────
test('N4: Journal nav link navigates to /tracker', async ({ page }) => { test('N4: Journal nav link navigates to /dailies', async ({ page }) => {
await page.goto('/'); await page.goto('/');
await page.click('nav a[href*="tracker"]'); await page.click('nav a[href*="dailies"]');
await expect(page).toHaveURL(/\/tracker/); await expect(page).toHaveURL(/\/dailies/);
}); });
// ── N5: "Map" nav link goes to /map ────────────────────────────────────────── // ── N5: "Map" nav link goes to /map ──────────────────────────────────────────
+4 -4
View File
@@ -16,7 +16,7 @@ test.afterAll(() => {
}); });
// ── P1: Post without photo ───────────────────────────────────────────────────── // ── P1: Post without photo ─────────────────────────────────────────────────────
test('P1: post text-only entry → created on disk and visible on /tracker', async ({ page }) => { test('P1: post text-only entry → created on disk and visible on /dailies', async ({ page }) => {
const tag = `p1-${Date.now()}`; const tag = `p1-${Date.now()}`;
const title = `UI Test ${tag}`; const title = `UI Test ${tag}`;
@@ -39,12 +39,12 @@ test('P1: post text-only entry → created on disk and visible on /tracker', asy
const photos = fs.readdirSync(entryDir).filter(f => /\.(jpg|jpeg|png|webp|heic)$/i.test(f)); const photos = fs.readdirSync(entryDir).filter(f => /\.(jpg|jpeg|png|webp|heic)$/i.test(f));
expect(photos.length, 'Text-only entry should have no photos').toBe(0); expect(photos.length, 'Text-only entry should have no photos').toBe(0);
await page.goto('/tracker'); await page.goto('/trips/japan-korea-2026/dailies');
await expect(page.locator('body')).toContainText(tag); await expect(page.locator('body')).toContainText(tag);
}); });
// ── P2: Post with photo ──────────────────────────────────────────────────────── // ── P2: Post with photo ────────────────────────────────────────────────────────
test('P2: post entry with photo → photo saved in entry folder and visible on /tracker', async ({ page }) => { test('P2: post entry with photo → photo saved in entry folder and visible on /dailies', async ({ page }) => {
const tag = `p2-${Date.now()}`; const tag = `p2-${Date.now()}`;
const title = `UI Test ${tag}`; const title = `UI Test ${tag}`;
@@ -70,7 +70,7 @@ test('P2: post entry with photo → photo saved in entry folder and visible on /
const photos = fs.readdirSync(entryDir).filter(f => /\.(jpg|jpeg|png|webp|heic)$/i.test(f)); const photos = fs.readdirSync(entryDir).filter(f => /\.(jpg|jpeg|png|webp|heic)$/i.test(f));
expect(photos.length, 'At least one photo should be saved').toBeGreaterThan(0); expect(photos.length, 'At least one photo should be saved').toBeGreaterThan(0);
await page.goto('/tracker'); await page.goto('/trips/japan-korea-2026/dailies');
await expect(page.locator('body')).toContainText(tag); await expect(page.locator('body')).toContainText(tag);
}); });