Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
086c36d157 | ||
|
|
7c9c140a1b | ||
|
|
d946eaaa7e | ||
|
|
8202d2a257 | ||
|
|
cfe070efec | ||
|
|
3250ad366a | ||
|
|
4450bd6eec | ||
|
|
28bbd41868 | ||
|
|
d57041d316 | ||
|
|
b02f27f559 | ||
|
|
6398542845 | ||
|
|
e79275a3ab | ||
|
|
f9ab3b1561 | ||
|
|
1f4e2aeba5 | ||
|
|
cdae34a706 | ||
|
|
285e61573e | ||
|
|
5edaf3ee1e | ||
|
|
9ec2349cd6 | ||
|
|
829325c9c7 | ||
|
|
839a4d0e69 | ||
|
|
ed6e43ae51 | ||
|
|
2fbfc884b9 | ||
|
|
a517331d1b | ||
|
|
01c3e72c8f | ||
|
|
641b0c376e | ||
|
|
2ab6575e4b | ||
|
|
94bfc53b90 | ||
|
|
0defa85f58 | ||
|
|
9ffeb4d2d8 | ||
|
|
6cf50920df | ||
|
|
60e80c3e72 | ||
|
|
24867524a1 | ||
|
|
f3816bfc3e | ||
|
|
084f683e19 | ||
|
|
62f940f6ef | ||
|
|
bc15f0b07d | ||
|
|
b205db0ea9 | ||
|
|
bb2b64bd78 | ||
|
|
2cdb435182 | ||
|
|
3e1ddd8132 |
@@ -1,255 +1,77 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
## 0. Project specifics
|
Rules, gotchas, and entry points — the things that must change what you do *before* you open a file. Everything descriptive lives next to the code:
|
||||||
|
|
||||||
**Only ever write changes in this folder (travel-blog-intotheeast/) or its subfolders.**
|
| Need | Read |
|
||||||
|
|
||||||
### Folder explanation
|
|
||||||
|
|
||||||
- **./**: Grav CMS dev environment for intotheeast travel blog
|
|
||||||
- **scripts/**: Server install and maintenance scripts
|
|
||||||
- **user/**: Site content, config, pages, and theme — its own git repo (`intotheeast-com-content.git`), tracked by the outer repo as a **git submodule** (pinned commit). See "Dual-repo submodule structure" below and `docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md`
|
|
||||||
- **docs/**: All plans, specs, and project documentation (moved here from `user/docs/` on 2026-06-19)
|
|
||||||
- **docs/solutions/**: documented solutions to past problems (bugs, patterns, workflow gotchas), organized by category with YAML frontmatter (`module`, `tags`, `problem_type`). Relevant when implementing or debugging in a documented area
|
|
||||||
- **CONCEPTS.md** (repo root): shared domain vocabulary (Trip, Entry, Story, Active Trip). Relevant when orienting to the codebase or discussing domain concepts
|
|
||||||
|
|
||||||
### Current stack
|
|
||||||
|
|
||||||
- **Grav:** 2.0.4 stable (baked into the custom Docker image via `Dockerfile`; server upgrades in place via `bin/gpm self-upgrade`)
|
|
||||||
- **Admin:** Admin2 v2.0.10 (plugin slug: `admin2`, NOT `admin`)
|
|
||||||
- **GPM channel:** `stable` — set in `user/config/system.yaml` → `gpm.releases` (authoritative). `GRAV_CHANNEL=production` in `docker-compose.yml` is cosmetic/consistency only
|
|
||||||
- **Plugin management:** `admin2`, `api`, and `flex-objects` are now **GPM-managed via `plugins.txt`** (installed by `make install-plugins`), no longer hand-extracted from the core bundle. `git-sync` stays **remote-only** — never in `plugins.txt`
|
|
||||||
- **Docker image:** `getgrav/grav` with `GRAV_CHANNEL=production`
|
|
||||||
- **PHP session:** `session.save_path = /tmp` set in `php/php-local.ini`
|
|
||||||
|
|
||||||
### Dev server
|
|
||||||
|
|
||||||
The Docker dev server runs at **http://localhost:8081** (mapped from container port 80 in `docker-compose.yml`).
|
|
||||||
|
|
||||||
### Trip entity architecture
|
|
||||||
|
|
||||||
The site is structured around Trip entities. Key facts:
|
|
||||||
- Active trip is set in `user/config/site.yaml` → `active_trip: japan-korea-2026`
|
|
||||||
- Trip pages live at `user/pages/01.trips/<slug>/`
|
|
||||||
- Each trip has two content subfolders: `01.dailies/` (journal entries) and `04.stories/` (stories). The former `02.map/` and `03.stats/` standalone views were **removed** (2026-07-04, see `docs/working/plans/2026-07-04-standalone-page-cleanup.md`) — map and stats now render inline on the trip page
|
|
||||||
- `01.dailies/` and `04.stories/` are `routable:false` **data containers** — visiting `/trips/<slug>/dailies` or `/stories` directly 404s/redirects; their children (entries/stories) render at their own detail URLs and are aggregated by the trip page
|
|
||||||
- Site nav in `base.html.twig` has Home + Past Trips only — does not link to trip sub-sections
|
|
||||||
- New journal entries are written to the active trip's `dailies` — the write target is derived from `site.active_trip` at submit time by the `cache-on-save` plugin (post-form.md no longer hardcodes `pageconfig.parent`)
|
|
||||||
- The trip page (`trip.html.twig`) uses a **client-side filter bar** (All content / Journal / Stories). The standalone `/dailies`, `/map`, `/stats`, `/stories` view pages no longer exist — do NOT try to re-create them or link to them. This filter bar + stats chrome is shared with the home active-trip view via the `trip-feed-col` partial (see "Shared trip-feed-col partial" below)
|
|
||||||
- Stats are shown inline on the trip page via a toggle (the standalone `/stats` view was removed)
|
|
||||||
- GPX route files live as media on the trip page itself, parsed client-side via toGeoJSON (bundled into `js/map.js`) and drawn on the trip/home map
|
|
||||||
- Manage GPX files (view/upload/delete) at `/gpx-manager` — requires admin login; filenames are auto-slugified on upload
|
|
||||||
|
|
||||||
### One map path: `MapUtils.initEntryMap` + the `entry-map` partial
|
|
||||||
|
|
||||||
There is a **single** map code path on the site. The engine is `MapUtils.initEntryMap(opts)` in `js/src/maplibre-utils.js` (bundled into `js/map.js` via `make build-assets` — never hand-edit `js/map.js`). It builds the MapLibre map, places markers/popups, fits bounds, draws the GPX journey, and wires the fullscreen toggle.
|
|
||||||
|
|
||||||
The map **markup + invocation** is shared via one partial:
|
|
||||||
|
|
||||||
- **Partial:** `user/themes/intotheeast/templates/partials/entry-map.html.twig`
|
|
||||||
- **Used by:** `trip.html.twig` and the active branch of `home.html.twig` (both via `{% include ... with {...} only %}`)
|
|
||||||
|
|
||||||
It renders the `.home-map-col` column (map div `#{{ map_id }}` + fullscreen button) and, when `entries` is non-empty, a thin `<script>` that assigns `window.{{ map_global }}` from `initEntryMap`. Callers resolve header values (use_gpx / autoconnect) and pass them in.
|
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
|
|
||||||
| Parameter | Type | Trip passes | Home passes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `map_id` | string | `'trip-map'` | `'home-map'` |
|
|
||||||
| `map_global` | string | `'tripMap'` | `'homeMap'` |
|
|
||||||
| `entries` | array | `[{lat, lng, slug, title, url, type?, force_connect, ...}]` | same |
|
|
||||||
| `card_prefix` | string | `'entry-'` | `'entry-'` |
|
|
||||||
| `story_markers` | bool | `true` (diamond markers) | `false` |
|
|
||||||
| `gpx_urls` | array | `gpx_urls` | `home_gpx_urls` |
|
|
||||||
| `use_gpx` | bool | `page.header.use_gpx ?? true` | derived from `trip.header` |
|
|
||||||
| `autoconnect` | string | `page.header.autoconnect ?? 'on'` | derived from `trip.header` |
|
|
||||||
| `gpx_source_prefix` | string | `'gpx'` | `'home-gpx'` |
|
|
||||||
| `journey_id` | string | `'trip-journey'` | `'home-journey'` |
|
|
||||||
|
|
||||||
The map globals `window.tripMap` / `window.homeMap` are asserted by the Playwright map specs, so any surface using this partial must keep assigning them.
|
|
||||||
|
|
||||||
> History: this replaced the old three-variant setup (a `feed-map.html.twig` partial with its own inline init, plus a full-page `map.html.twig`). Those were deleted in the 2026-07-04 standalone-page cleanup; the `2026-06-27-map-init-consolidation` plan had already moved trip + home onto `initEntryMap`.
|
|
||||||
|
|
||||||
### Shared trip-feed-col partial
|
|
||||||
|
|
||||||
The home page's active-trip view and the trip page render the **same feed-col chrome** (date-range header, filter bar, stats/cycling panels, feed loop) via one shared Twig partial. This is separate from the `entry-map` partial above — it is the column **beside** the map, not the map.
|
|
||||||
|
|
||||||
- **Partial:** `user/themes/intotheeast/templates/partials/trip-feed-col.html.twig`
|
|
||||||
- **Used by:** `trip.html.twig` and the active branch of `home.html.twig` (both via `{% include ... with {...} only %}`)
|
|
||||||
- **Sibling:** `partials/home-predeparture.html.twig` — the home-only "Coming soon" landing state. `home.html.twig` picks it with `{% if all_items|length == 0 %}` → `home-predeparture` `{% else %}` → `trip-feed-col`. Keep `trip-feed-col` single-purpose — do NOT fold the pre-departure branch back into it.
|
|
||||||
|
|
||||||
**Parameters (`trip-feed-col`):**
|
|
||||||
|
|
||||||
| Parameter | Type | Trip passes | Home-active passes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `trip_page` | Page | `page` | `trip` |
|
|
||||||
| `all_items` | array | sorted by date, flag 4 (oldest→newest) | sorted by date, flag 3 (newest→oldest) |
|
|
||||||
| `journal_entries` | array | dailies children | dailies children |
|
|
||||||
| `journal_count` / `story_count` | int | counts | counts |
|
|
||||||
| `has_gpx` | bool | `has_gpx` | `home_gpx_urls\|length > 0` |
|
|
||||||
| `gpx_urls` | array | `gpx_urls` | `home_gpx_urls` |
|
|
||||||
| `gps_points` | array | `gps_points` | `gps_points` |
|
|
||||||
| `show_sort` | bool | `true` | `false` (home keeps its own feed order, no sort button) |
|
|
||||||
| `trip_header_extras` | bool | `true` | not passed (defaults `false`) |
|
|
||||||
|
|
||||||
`trip_header_extras` gates the trip-page-only header block (one-liner `.home-trip-tagline`, expandable `.trip-header-desc`, and `.trip-header-banner` cover strip) that renders between the counts and the filter bar. `trip.html.twig` passes `true`; `home.html.twig` omits it so those extras never leak onto the home route (the `only` include keeps it off by default).
|
|
||||||
|
|
||||||
`home-predeparture` takes only `trip_page`.
|
|
||||||
|
|
||||||
**Stats/cycling JS glue:** the partial emits an inline `DOMContentLoaded` script calling `window.initTripStats({ gpxUrls, gpsPoints, hasGpx })` — one shared function in `js/src/main.js` (rebuild with `make build-assets`; never hand-edit `js/main.js`). It no-ops when `#stat-distance` is absent, populates exact distance + cycling stats from GPX, and falls back to a `~`-prefixed haversine estimate (or `—` for `<2` points) when there is no GPX. It depends on `window.MapUtils` from `map.js` (loaded in the `bottom` asset group on both pages).
|
|
||||||
|
|
||||||
### GPX file management
|
|
||||||
|
|
||||||
GPX files are stored as page media on the trip page (`user/pages/01.trips/<slug>/`). They are picked up automatically by `trip.html.twig` (and `home.html.twig`) via `trip_page.media.all`, filtered to `.gpx`, and passed to the shared `entry-map` partial.
|
|
||||||
|
|
||||||
The GPX manager page (`user/pages/03.gpx-manager/`) provides a browser UI at `/gpx-manager`:
|
|
||||||
- **Auth:** enforced by Login plugin via `access.admin.login: true` in frontmatter — shows login form if not authenticated
|
|
||||||
- **Template:** `user/themes/intotheeast/templates/gpx-manager.html.twig`
|
|
||||||
- **API:** uses Grav API v1 with session cookie auth (`session_enabled: true` in `user/plugins/api/api.yaml`)
|
|
||||||
- List: `GET /api/v1/pages{route}/media`
|
|
||||||
- Upload: `POST /api/v1/pages{route}/media` (multipart)
|
|
||||||
- Delete: `DELETE /api/v1/pages{route}/media/{filename}`
|
|
||||||
- **Slugification:** filenames are slugified client-side before upload (spaces/special chars → hyphens, lowercase); the file is sliced to a plain `Blob` so the third argument to `FormData.append` is always used as the filename
|
|
||||||
- **Media type:** `.gpx` is registered in `user/config/media.yaml` so Grav serves and tracks these files
|
|
||||||
|
|
||||||
To add GPX files without the browser UI, drop them directly into `user/pages/01.trips/<slug>/` and run `make content-push`.
|
|
||||||
|
|
||||||
### Switching to a new trip
|
|
||||||
|
|
||||||
The active trip lives in **one** place now: `site.active_trip`. The post form no longer hardcodes a `pageconfig.parent` — the `cache-on-save` plugin derives the write target from `site.active_trip` at submit time (`onFormValidationProcessed` → `setData('parent', …)`), so there is nothing to keep in sync.
|
|
||||||
|
|
||||||
| File | Key | Example value | How to edit |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `user/config/site.yaml` | `active_trip` | `/trips/italy-2027` | Admin → Configuration → Site → **Active Trip** (page-picker rooted at `/trips`; blueprint at `user/blueprints/config/site.yaml`) |
|
|
||||||
|
|
||||||
Note: `system.yaml` `home.alias` is permanently set to `/home` (the real home page) and does **not** need to change when switching trips.
|
|
||||||
|
|
||||||
After updating, also create the new trip's page tree under `user/pages/01.trips/<new-slug>/` with the two content subfolders `01.dailies/` and `04.stories/` (each with an inert `routable:false` container `.md`), plus the trip's `trip.md`. Do **not** recreate `02.map/` or `03.stats/` — those standalone views were retired.
|
|
||||||
|
|
||||||
### Environment
|
|
||||||
|
|
||||||
**Never read `.env`, `.env.prod`, or `.env.test`** — they contain sensitive credentials. You may pass them to commands (e.g. `docker compose`, `make`) but never read their contents directly. Ask the user if you need environment-specific information.
|
|
||||||
|
|
||||||
### Remote operations
|
|
||||||
|
|
||||||
Always use `make` commands for anything on the production server (`make remote-install-plugins`, `make remote-clean`, etc.) — never SSH directly since credentials live in `.env`. If a remote operation isn't covered by an existing `make` command, either ask the user to run it manually or suggest adding a new `make` command if it seems reusable.
|
|
||||||
|
|
||||||
For a full upgrade/deploy through local → test → prod (ordered steps, smoke checklist, rollback), follow the runbook at [`docs/guides/deploy-cycle.md`](docs/guides/deploy-cycle.md).
|
|
||||||
|
|
||||||
### Content sync
|
|
||||||
|
|
||||||
- `make content-push` — commit and push `user/` to Gitea (triggers production pull via webhook)
|
|
||||||
- `make content-pull` — pull latest from Gitea to local
|
|
||||||
- `plugins.txt` is manually maintained — installing a plugin via Admin does NOT update it
|
|
||||||
- `make demo-load` — load demo content into `italy-2026-demo` trip (12 journal entries + 4 stories + 7 GPX files); source in `user/docs/demo/trips/italy-2026-demo/`
|
|
||||||
- `make demo-reset` — remove the entire `italy-2026-demo` pages folder and clear cache (full reset; re-run demo-load to restore)
|
|
||||||
|
|
||||||
### User repo gitignore
|
|
||||||
|
|
||||||
Only these folders are tracked in the `user/` Git repo: `pages/`, `config/`, `accounts/`, `themes/`. The `plugins/` and `data/` folders are excluded.
|
|
||||||
|
|
||||||
### Dual-repo submodule structure
|
|
||||||
|
|
||||||
`user/` is a **git submodule** of the outer repo (`.gitmodules` at the root; git dir absorbed into `.git/modules/user`). Full workflow: `docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md`. The essentials:
|
|
||||||
|
|
||||||
- **Two repos, two cadences.** Outer repo = dev environment (tests/docs/scripts/Docker). `user/` = content + theme, with its own remote and `make content-push` cadence. The outer repo pins an exact `user/` commit via the `user` gitlink.
|
|
||||||
- **Pointer-bump convention.** Routine content changes → **do not** bump the pin (leave it stale; harmless). At the **end of a cross-repo feature** → bump the pin once to the finished `user/` commit. Pin a commit reachable from `user/`'s published `main` (prefer the merge-to-main commit, not a squash-away branch tip), and **push `user/` before the outer repo** (superproject references a child SHA that must already exist upstream). The pin is dev-side coordination only — production pulls `user/` via the content webhook independently.
|
|
||||||
- **`M user` / `m user` is normal.** `M` = pin differs from `user/` HEAD (bump pending/intentional). `m` = submodule working tree dirty (e.g. local-testing `config/site.yaml`). Neither is an error — do not "fix" them by committing the gitlink or the `site.yaml`.
|
|
||||||
- **Worktrees for parallel work — use the make targets, don't do it by hand.** `make worktree-new NAME=<feature>` (from the main checkout) creates the outer worktree off `main`, initialises its own `user/` submodule, branches both, and starts an **isolated** dev server (own container name + auto-assigned port `8090+`, persisted in a git-ignored `.worktree-env` so every `make`/compose command in that worktree targets its own server). `make worktree-rm NAME=<feature>` tears it down cleanly (compose down → `submodule deinit` → `worktree remove` → `prune`) — skipping the deinit is what leaves orphaned `.worktrees/` dirs. Worktrees live under `.worktrees/` (excluded via `.git/info/exclude`). A fresh worktree's `user/` is empty until the submodule init runs, and `M user`/`m user` is normal (see above) — do not "fix" either. To add a commit to `main` while the main checkout is on another branch, use a throwaway `main` worktree rather than `git checkout main`.
|
|
||||||
|
|
||||||
## 1. Environment modes
|
|
||||||
|
|
||||||
### Rule: do not switch modes during development
|
|
||||||
|
|
||||||
**Never toggle between development and production mode mid-session.** If a caching or config issue appears, fix it at the application level (plugin, template logic) rather than temporarily flipping a mode flag to work around it. Mode switches introduce inconsistent state and make bugs harder to reproduce.
|
|
||||||
|
|
||||||
### Development mode (current)
|
|
||||||
|
|
||||||
Active settings in `user/config/system.yaml`:
|
|
||||||
|
|
||||||
| Setting | Dev value | Why |
|
|
||||||
|---|---|---|
|
|
||||||
| `twig.cache` | `false` | Theme file edits take effect immediately; no stale compile errors |
|
|
||||||
|
|
||||||
With these settings, Grav rebuilds templates on every request. This is intentionally slower but means you never need to flush cache after editing a `.html.twig` file.
|
|
||||||
|
|
||||||
### Production mode (per-environment override)
|
|
||||||
|
|
||||||
Production needs different Twig settings than dev, but **never change the
|
|
||||||
committed `user/config/system.yaml`** — `twig.cache: false` (and `debug`/
|
|
||||||
`auto_reload: true`) are the *intended dev values*, and committing prod values
|
|
||||||
there breaks local development for everyone.
|
|
||||||
|
|
||||||
Instead, prod values are a **per-environment override** deployed to the server
|
|
||||||
only, via Grav's per-environment config (`environment://config`, keyed on the
|
|
||||||
request hostname):
|
|
||||||
|
|
||||||
| Setting | Dev (committed) | Prod (override) | Why prod differs |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `twig.cache` | `false` | `true` | Compile templates once and reuse |
|
|
||||||
| `twig.debug` | `true` | `false` | No debug functions in prod |
|
|
||||||
| `twig.auto_reload` | `true` | `false` | Don't stat templates every request |
|
|
||||||
|
|
||||||
- **Source of truth:** `deploy/env/prod/system.yaml` (version-controlled).
|
|
||||||
- **Deploy:** `make remote-apply-env-prod` — writes it to
|
|
||||||
`<webroot>/user/env/<hostname>/config/system.yaml` and clears cache. It
|
|
||||||
deep-merges over the committed `system.yaml`.
|
|
||||||
- **Not synced by content:** `user/env/` is outside the content repo's tracked
|
|
||||||
folders, so `content-push` / git-sync / `remote-fetch-content` do **not**
|
|
||||||
restore it. **Re-run `make remote-apply-env-prod` after any fresh install.**
|
|
||||||
- The hostname segment defaults to `REMOTE_HOST`; override with `WEB_HOST` in
|
|
||||||
`.env.<env>` if Grav sees a different host than the SSH host.
|
|
||||||
|
|
||||||
> **⚠️ Once `user/env/<hostname>/` exists, Grav's Admin saves ALL config there.**
|
|
||||||
> Creating the env override dir has a site-wide side effect: Grav's Admin panel
|
|
||||||
> writes **every** config change (system *and* plugin) into the active
|
|
||||||
> environment's config tree — e.g. editing a plugin on prod saves to
|
|
||||||
> `user/env/intotheeast.com/config/plugins/<name>.yaml`, **not**
|
|
||||||
> `user/config/plugins/<name>.yaml`. Consequences you must remember:
|
|
||||||
> - Config edited via **Admin on the server is server-only**: `user/env/` is
|
|
||||||
> outside the content repo's tracked folders, so it is **not committed** and
|
|
||||||
> **not synced by git-sync** (which syncs only `pages`/`config`/`themes`).
|
|
||||||
> Good for secrets — `git-sync.yaml` (token) safely lives at the env path —
|
|
||||||
> but it means prod Admin config edits silently do **not** reach Gitea/local.
|
|
||||||
> - When reading/writing server config, check **both** `user/config/...` and
|
|
||||||
> `user/env/<host>/config/...` (env wins). Server tooling must search the env
|
|
||||||
> path first — see `scripts/git-sync-toggle.sh` and `make remote-diag`.
|
|
||||||
> - Repo-authored config (`user/config/...` via `make content-push`) still
|
|
||||||
> applies everywhere; the env tree only holds per-host overrides + Admin-on-
|
|
||||||
> server edits. Full details: `docs/working/git-sync-notes.md`.
|
|
||||||
|
|
||||||
**Pre-launch smoke test required:** with the prod override applied, submit one
|
|
||||||
post via `/post` and confirm the entry appears in the trip page feed
|
|
||||||
immediately. This verifies the cache-on-save plugin (BUG-001 fix) works
|
|
||||||
correctly with caching enabled.
|
|
||||||
|
|
||||||
### What the cache-on-save plugin handles
|
|
||||||
|
|
||||||
The custom plugin at `user/plugins/cache-on-save/` clears Grav's page-tree cache on every `new-entry` form submission. This ensures new posts appear in the tracker feed immediately in both modes — it does not depend on whether Twig caching is on or off.
|
|
||||||
|
|
||||||
## 2. Local development setup
|
|
||||||
|
|
||||||
Full setup guide: [`docs/guides/local-setup.md`](docs/guides/local-setup.md)
|
|
||||||
|
|
||||||
### Superpowers skill paths
|
|
||||||
|
|
||||||
Specs: `docs/working/specs/YYYY-MM-DD-<topic>-design.md`
|
|
||||||
Plans: `docs/working/plans/YYYY-MM-DD-<topic>.md`
|
|
||||||
|
|
||||||
The brainstorming and writing-plans skills default to `docs/superpowers/`; these lines override that default.
|
|
||||||
|
|
||||||
### Plan status convention
|
|
||||||
|
|
||||||
Every plan in `docs/working/plans/` must have a `**Status:**` line immediately after the title heading:
|
|
||||||
|
|
||||||
| Status | Meaning |
|
|
||||||
|---|---|
|
|---|---|
|
||||||
| `📋 Not started` | Plan written; work not yet begun |
|
| How the site hangs together — stack, plugin roles, templates, partial contracts, data flows | [`docs/reference/architecture.md`](docs/reference/architecture.md) |
|
||||||
| `🔄 In progress — <note>` | Actively being worked on |
|
| Domain vocabulary — Trip, Entry, Story, Active Trip | [`CONCEPTS.md`](CONCEPTS.md) |
|
||||||
| `⏸️ Deferred — <reason>` | Intentionally postponed |
|
| Doing something operational — posting, GPX, switching trips, local setup, deploying | [`docs/guides/`](docs/guides/) |
|
||||||
| `✅ Complete (YYYY-MM-DD)` | Done |
|
| Test suite layout and conventions | [`docs/reference/testing.md`](docs/reference/testing.md) |
|
||||||
| `❌ Abandoned — <reason>` | Won't implement |
|
| A bug or workflow trap already hit and written up | [`docs/solutions/`](docs/solutions/) — grep the `module`/`tags`/`problem_type` frontmatter; check when working in a documented area |
|
||||||
|
| Why an old plan describes something that no longer exists | [`docs/reference/superseded-decisions.md`](docs/reference/superseded-decisions.md) — check before re-creating anything found in `docs/working/` |
|
||||||
|
| Folder map, prerequisites, the full `make` command list | [`README.md`](README.md) |
|
||||||
|
|
||||||
**When asked what's open:** surface `Not started` and `In progress` plans. Show `Deferred` plans but label them clearly. Omit `Complete` and `Abandoned` unless explicitly asked.
|
The site is Grav (flat-file PHP CMS, no database) in Docker, with content and theme in the `user/` submodule.
|
||||||
|
|
||||||
**When finishing a plan:** update the `**Status:**` field in the plan file to `✅ Complete (YYYY-MM-DD)` before closing the session. This applies whether execution was done by Claude directly, via the superpowers:executing-plans skill, or via superpowers:subagent-driven-development.
|
## Hard rules
|
||||||
|
|
||||||
|
- **Only ever write inside `travel-blog-intotheeast/`** or its subfolders.
|
||||||
|
- **Never read `.env`, `.env.prod`, `.env.test`** — they hold credentials. Pass them to commands (`make`, `docker compose`) but never read them; ask the user if you need a value.
|
||||||
|
- **Never SSH to a server directly** — use the `make remote-*` targets, since credentials live in `.env`. If no target covers what you need, ask the user to run it or propose a new target.
|
||||||
|
- **Never hand-edit build output** — sources and outputs share folders under `user/themes/intotheeast/` (paths below are relative to it), so know which is which. Run `make build-assets` after editing any source.
|
||||||
|
- Everything in `js/` is **generated** *except* `js/src/`, `js/maplibre-utils.js` and `js/nav.js`.
|
||||||
|
- `css-compiled/` and `fonts/` are **esbuild output from the imports inside `js/src/`** — *not* from `css/`. Everything in `css/` is hand-authored and served directly (`assets.addCss` in `partials/base.html.twig`), never compiled. So `templates/partials/weather-icons.html.twig` is also generated (source: `scripts/gen-weather-icons.js`).
|
||||||
|
- **Never toggle dev↔prod mode mid-session.** If a caching or config issue appears, fix it at the application level (plugin, template logic) rather than flipping a mode flag — mode switches leave inconsistent state and make bugs harder to reproduce.
|
||||||
|
|
||||||
|
## Dev environment
|
||||||
|
|
||||||
|
- Dev server: **http://localhost:8081** (`make setup` on a first run, `make start` / `make stop` after). A worktree gets its own container and port `8090+` from its `.worktree-env` — pass `GRAV_BASE_URL` when pointing tests at one.
|
||||||
|
- ⚠️ **`make start` / `make setup` fail on a clean checkout** — `docker compose up -d` still tries to build a `travel-memories` service whose source was moved out of this repo, so the build context is missing. Use **`make start-grav`** (Grav only). Existing containers keep working from a cached image, which is why this hides until a rebuild.
|
||||||
|
- `user/config/system.yaml` is committed with **dev** values (`twig.cache: false`), so templates recompile per request and no cache flush is needed after editing a `.html.twig`. Prod values live in `deploy/env/prod/system.yaml` and **never** in `user/config/`.
|
||||||
|
- ⚠️ **Once `user/env/<hostname>/` exists on a server, Grav's Admin saves ALL config there** — system *and* plugin. So (a) config edited via Admin on the server is server-only and silently never reaches Gitea or local; (b) when reading or writing server config, check **both** `user/config/…` and `user/env/<host>/config/…` — **env wins**, so look there first. Mechanics: [`docs/guides/deploy-cycle.md`](docs/guides/deploy-cycle.md).
|
||||||
|
- The Admin plugin slug is **`admin2`**, not `admin`.
|
||||||
|
- `plugins.txt` is maintained by hand — installing a plugin via Admin does **not** update it. `git-sync` is **remote-only** and must never appear in it.
|
||||||
|
- Everything under `user/plugins/` is git-ignored and gets overwritten by `make install-plugins` — **except** the three site-owned plugins (`cache-on-save`, `story-blocks`, `entry-actions`). So a fix to a third-party plugin must be a tracked patch in `deploy/patches/`, never an in-place edit: [`deploy/patches/README.md`](deploy/patches/README.md).
|
||||||
|
|
||||||
|
## Content and trips
|
||||||
|
|
||||||
|
- The active trip lives in **one** place: `user/config/site.yaml` → `active_trip`, and its value is a **route** (`/trips/denmark-2026`), not a bare slug.
|
||||||
|
- `cache-on-save` derives the post write target from `active_trip` at submit time. **Never re-add a `pageconfig.parent` to `post-form.md`** — a static parent would override it and reintroduce the old silent-desync bug. Switching trips: [`docs/guides/trip-switching.md`](docs/guides/trip-switching.md).
|
||||||
|
- The standalone `/dailies`, `/map`, `/stats` and `/stories` trip views were **deleted** (2026-07-04) — map, stats, and filtering all render inline on the trip page. Do not re-create them or link to them. `01.dailies/` and `04.stories/` are `routable:false` data containers whose children are aggregated by the trip page.
|
||||||
|
- GPX routes are page media on the trip page, auto-detected — no manual linking. Manage them at `/gpx-manager` (admin login): [`docs/guides/gpx-manager.md`](docs/guides/gpx-manager.md).
|
||||||
|
- `make content-push` commits and pushes `user/` to Gitea, which triggers the production pull; `make content-pull` is the reverse.
|
||||||
|
|
||||||
|
## Two shared partials — the rules
|
||||||
|
|
||||||
|
Trip and home render the same map and feed chrome through two shared partials, both included `with {…} only`. Parameter contracts: [`docs/reference/architecture.md`](docs/reference/architecture.md) → "Shared partial contracts". What must not break:
|
||||||
|
|
||||||
|
- **`partials/entry-map.html.twig` is the only path for a *display* map** — the engine is `MapUtils.initEntryMap(opts)` in `js/maplibre-utils.js` (a hand-authored file, imported by `js/src/map.js`). Do not add another display-map implementation; an older three-variant setup was deliberately consolidated away.
|
||||||
|
- **One sanctioned exception: `js/src/location-map.js`**, the `/post` form's pin *editor* (one draggable marker, no popups/GPX/bounds-fitting, `maplibre-gl` lazy-imported so a GPS-only submit never fetches it). It shares exactly one thing with the display path — `MAP_STYLE` from `js/src/map-style.js`, imported by both so the basemap cannot drift. Do not fold it into `initEntryMap`, and do not add a *third* path.
|
||||||
|
- It must keep assigning **`window.tripMap` / `window.homeMap`** — the Playwright map specs assert those globals.
|
||||||
|
- **Keep `trip-feed-col.html.twig` single-purpose.** Its sibling `partials/home-predeparture.html.twig` is the home-only "Coming soon" state — do **not** fold the pre-departure branch back into it.
|
||||||
|
|
||||||
|
## Dual-repo submodule structure
|
||||||
|
|
||||||
|
`user/` is a git submodule with its own Gitea remote and its own cadence; the outer repo pins an exact commit. Full workflow, worktree mechanics, teardown: [`docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md`](docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md).
|
||||||
|
|
||||||
|
- **`M user` / `m user` is normal, not an error.** `M` = the pin differs from `user/` HEAD; `m` = the submodule working tree is dirty (e.g. a local-testing `site.yaml`). Do not "fix" either by committing the gitlink or that `site.yaml`.
|
||||||
|
- **Don't bump the pin for routine content changes.** Bump it once at the end of a cross-repo feature, to a commit reachable from `user/`'s published `main`, and **push `user/` before the outer repo**.
|
||||||
|
- **Use `make worktree-new NAME=<x>` / `make worktree-rm NAME=<x>`** — never a hand-rolled `git worktree add`. The targets initialise the submodule and an isolated dev server; skipping the deinit on teardown is what leaves orphaned `.worktrees/` dirs.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
`make test` runs everything (`test-config` → `test-post` → `test-ui`). **The dev server must be running** — every suite drives the live site over HTTP. Layout, helpers, and per-suite commands: [`docs/reference/testing.md`](docs/reference/testing.md).
|
||||||
|
|
||||||
|
- **Auth is a dependency project.** `auth.setup.js` writes `tests/.auth/user.json`, which the `chromium` project reuses as `storageState`. Never add per-test logins.
|
||||||
|
- The `testrunner` admin account is created automatically and is git-ignored — never commit it, and keep its password free of shell/Make/URL-special characters, since several consumers interpolate it.
|
||||||
|
- `retries: 0`, so a failing test is a real failure, not flake.
|
||||||
|
|
||||||
|
## Working docs
|
||||||
|
|
||||||
|
Specs go in `docs/working/specs/YYYY-MM-DD-<topic>-design.md`, plans in `docs/working/plans/YYYY-MM-DD-<topic>.md`. These paths override the `docs/superpowers/` default used by the brainstorming and writing-plans skills.
|
||||||
|
|
||||||
|
Every plan needs a `**Status:**` line immediately after its title heading: `📋 Not started` · `🔄 In progress — <note>` · `⏸️ Deferred — <reason>` · `✅ Complete (YYYY-MM-DD)` · `❌ Abandoned — <reason>`.
|
||||||
|
|
||||||
|
- **When asked what's open:** surface `Not started` and `In progress`; show `Deferred` but label it clearly; omit `Complete` and `Abandoned` unless explicitly asked.
|
||||||
|
- **When finishing a plan:** set its status to `✅ Complete (YYYY-MM-DD)` before closing the session — whether you executed it directly or via the executing-plans / subagent-driven-development skills.
|
||||||
|
|||||||
+44
-1
@@ -12,7 +12,12 @@ A **Trip** owns its **Entries** and **Stories**. Exactly one Trip is the **Activ
|
|||||||
A single journey the blog is organised around — the top-level content entity. A Trip aggregates its Entries and Stories and carries its own metadata (title, start/end dates, cover image, route GPX files). Each Trip renders as one consolidated **Trip page** showing an inline map, a filtered feed, and inline stats; the journal, map, stats, and story views are not separate pages.
|
A single journey the blog is organised around — the top-level content entity. A Trip aggregates its Entries and Stories and carries its own metadata (title, start/end dates, cover image, route GPX files). Each Trip renders as one consolidated **Trip page** showing an inline map, a filtered feed, and inline stats; the journal, map, stats, and story views are not separate pages.
|
||||||
|
|
||||||
### Active Trip
|
### Active Trip
|
||||||
The one Trip currently featured — set in site config and read by the home page and the post form. Switching the Active Trip is a deliberate, multi-file change; if the post form's target and the featured Trip fall out of sync, new posts land under the wrong Trip.
|
The one Trip currently featured — set in a single site-config value and read by the home page and the posting pipeline, which derives the write target for new Entries from it at submit time. Switching the Active Trip is that one setting; there is no separate post-form target to keep in sync.
|
||||||
|
|
||||||
|
### Published / Draft
|
||||||
|
A Trip's visibility state. A **Published** Trip is listed publicly and reachable by anyone; a **Draft** Trip is hidden from anonymous visitors in the public trip list, while the signed-in owner still sees it (marked "Draft") and can flip it back. The owner toggles this per Trip from the trip list.
|
||||||
|
|
||||||
|
Unpublishing the **Active Trip** additionally drops it from the public home page, which falls back to its between-trips landing. The toggle is owner-only; a Draft is a visibility control, not privacy — a Draft Trip's Entries, Stories, and media stay reachable by direct link.
|
||||||
|
|
||||||
### Entry
|
### Entry
|
||||||
A single dated journal post within a Trip — the atomic unit of the day-to-day travel log.
|
A single dated journal post within a Trip — the atomic unit of the day-to-day travel log.
|
||||||
@@ -23,6 +28,44 @@ The Trip's journal section is labelled "Journal" and lives in the Trip's `dailie
|
|||||||
### Story
|
### Story
|
||||||
A long-form, designed narrative piece within a Trip — hero image plus scrollytelling/gallery sections — distinct from the short, dated Entry. Stories are curated set pieces; Entries are the running log.
|
A long-form, designed narrative piece within a Trip — hero image plus scrollytelling/gallery sections — distinct from the short, dated Entry. Stories are curated set pieces; Entries are the running log.
|
||||||
|
|
||||||
|
### Container
|
||||||
|
A Trip's non-routable holder of child pages — one for Entries, one for Stories. A Container's own URL is deliberately inert (it renders no page of its own), while its children stay individually reachable and are aggregated onto the Trip page. Retiring a view must never delete its Container: the folder half is load-bearing data even when the page half is gone.
|
||||||
|
|
||||||
|
## Repos & deployment
|
||||||
|
|
||||||
|
### Content repo
|
||||||
|
The repository holding everything the site serves — pages, configuration, accounts, the theme. It has its own remote and its own release cadence: pushing it triggers production to pull via webhook, independent of the Outer repo.
|
||||||
|
|
||||||
|
### Outer repo
|
||||||
|
The dev-environment repository — tests, docs, scripts, container build — that nests the Content repo and records a Pin to an exact Content-repo commit, expressing "this dev-env state expects this content/theme state."
|
||||||
|
|
||||||
|
### Pin
|
||||||
|
The Outer repo's recorded Content-repo commit (also "pointer bump" for the act of updating it). Routine content churn never moves it; it is bumped once at the end of a cross-repo feature, to a commit already published on the Content repo's main branch. A stale Pin during normal work is expected, not an error.
|
||||||
|
|
||||||
|
### Env tree
|
||||||
|
A server's per-host configuration overlay. Once it exists, Grav's Admin writes **all** config edits there rather than into the shared configuration — so server-side Admin edits are server-only, invisible to content sync, and can hold live secrets. Diagnosing config on a server means checking both the shared configuration and the Env tree, with the Env tree winning at runtime.
|
||||||
|
|
||||||
|
### Remote-only plugin
|
||||||
|
One of the project's three plugin-management categories, alongside GPM-managed (declared in the shared install list and restored by the standard install flow) and custom-in-repo (code tracked in the Content repo). A Remote-only plugin is installed explicitly on servers and restored by **no** standard flow — if its code goes missing it stays missing until someone reinstalls it deliberately, even while its configuration persists in the Env tree.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
### Historical record
|
||||||
|
A document that states what was decided or built at a past moment, not what is true now — plans, specs, milestone scopes, and session write-ups. Its going out of date is expected and is what makes it a record; it is corrected only by annotation, never by rewriting, because the value is the reasoning at the time.
|
||||||
|
|
||||||
|
Distinguished from *current documentation*, which asserts how the system is today and is simply wrong when it drifts. A Historical record often reads in present tense, so the distinction is carried by an explicit marker rather than by tone.
|
||||||
|
|
||||||
|
### Superseded decision
|
||||||
|
Something the project planned or built and then deliberately reversed, recorded so the reversal is discoverable from the document that still describes the original. Each one names what was planned, what replaced it, when, and why.
|
||||||
|
|
||||||
|
The record exists because a reversal is otherwise invisible: the old document keeps asserting the old thing, and the reasoning that killed it lives only in whoever remembers. A Superseded decision is the standing answer to "may I re-create this?" — usually no, and often the prohibition is also a hard rule.
|
||||||
|
|
||||||
|
### Plan status
|
||||||
|
The single recorded state of a plan, carried on the plan itself rather than in a separate tracker. **Deferred** and **Abandoned** are deliberately distinct: Deferred means still wanted but not now, Abandoned means decided against, kept so the decision is not re-litigated.
|
||||||
|
|
||||||
|
A status that lags reality is worse than no status, because it is trusted — so it moves when the work lands, not later.
|
||||||
|
|
||||||
## Flagged ambiguities
|
## Flagged ambiguities
|
||||||
|
|
||||||
- "daily" / "entry" / "journal post" all refer to the same concept (a dated journal post). Canonical term: **Entry**. The section/folder is named "dailies" and the nav label is "Journal" — these name the *collection*, not a different entity.
|
- "daily" / "entry" / "journal post" all refer to the same concept (a dated journal post). Canonical term: **Entry**. The section/folder is named "dailies" and the nav label is "Journal" — these name the *collection*, not a different entity.
|
||||||
|
- A **Historical record** written in present tense is **not** a claim about the current system. Staleness there is correct; staleness in current documentation is a defect. When the two disagree, the code decides, and the gap is recorded as a **Superseded decision**.
|
||||||
|
|||||||
@@ -54,9 +54,15 @@ $(foreach t,$(REMOTE_TARGETS),$(foreach e,$(ENVS),$(eval $(call make-env-target,
|
|||||||
GRAV_TEST_USER ?= testrunner
|
GRAV_TEST_USER ?= testrunner
|
||||||
GRAV_TEST_PASS ?= Testpass1234
|
GRAV_TEST_PASS ?= Testpass1234
|
||||||
|
|
||||||
|
# The password is handed to the container through `docker exec -e` (the bare
|
||||||
|
# form, which forwards the already-exported variable) rather than interpolated
|
||||||
|
# into the `sh -c` string. Interpolating it meant any shell-special character in
|
||||||
|
# GRAV_TEST_PASS was re-parsed by the container's shell — a `.env` password
|
||||||
|
# containing one produced `sh: 2: <fragment>: not found` and no test account.
|
||||||
|
# The recipe is now indifferent to the password's contents.
|
||||||
test-account:
|
test-account:
|
||||||
@docker exec $(GRAV_CONTAINER) sh -c 'test -f /var/www/html/user/accounts/$(GRAV_TEST_USER).yaml \
|
@docker exec -e GRAV_TEST_PASS $(GRAV_CONTAINER) sh -c 'test -f /var/www/html/user/accounts/$(GRAV_TEST_USER).yaml \
|
||||||
|| php bin/plugin login new-user -u $(GRAV_TEST_USER) -p "$(GRAV_TEST_PASS)" \
|
|| php bin/plugin login new-user -u $(GRAV_TEST_USER) -p "$$GRAV_TEST_PASS" \
|
||||||
-e $(GRAV_TEST_USER)@example.test -N "Test Runner" -P b --admin-type both -s enabled -n'
|
-e $(GRAV_TEST_USER)@example.test -N "Test Runner" -P b --admin-type both -s enabled -n'
|
||||||
|
|
||||||
test-config:
|
test-config:
|
||||||
@@ -65,6 +71,13 @@ test-config:
|
|||||||
test-post: test-account
|
test-post: test-account
|
||||||
@bash scripts/test-post.sh
|
@bash scripts/test-post.sh
|
||||||
|
|
||||||
|
# Pinned to THIS checkout's port, not playwright.config.js's :8081 default. In a
|
||||||
|
# worktree that default silently pointed the suite at the main checkout's server,
|
||||||
|
# so entries were created in main's user/ while the specs asserted and cleaned up
|
||||||
|
# in the worktree's — leaving ui-test entries behind in real trip content.
|
||||||
|
# tests/global-setup.js now also hard-fails on that mismatch.
|
||||||
|
GRAV_BASE_URL ?= http://localhost:$(GRAV_PORT)
|
||||||
|
|
||||||
test-ui: test-account
|
test-ui: test-account
|
||||||
@npx playwright test
|
@npx playwright test
|
||||||
|
|
||||||
@@ -90,13 +103,26 @@ build:
|
|||||||
docker compose build
|
docker compose build
|
||||||
|
|
||||||
build-assets:
|
build-assets:
|
||||||
docker run --rm \
|
# --user: outputs (node_modules, js/ bundles, css-compiled/) land in the
|
||||||
|
# tracked theme tree owned by the host user, not root. HOME=/tmp gives npm
|
||||||
|
# a writable cache when running as a non-root uid.
|
||||||
|
docker run --rm --user $(HOST_UID):$(HOST_GID) -e HOME=/tmp \
|
||||||
-v $(PWD)/user/themes/intotheeast:/app \
|
-v $(PWD)/user/themes/intotheeast:/app \
|
||||||
-w /app node:20-alpine \
|
-w /app node:20-alpine \
|
||||||
sh -c "npm install && npm run build"
|
sh -c "npm install && npm run build"
|
||||||
|
|
||||||
|
# In a worktree this degrades to start-grav. The travel-memories service declares
|
||||||
|
# `env_file: .env`, and worktree-new does not create a .env, so a plain
|
||||||
|
# `docker compose up -d` there dies with "env file ... not found" — leaving the
|
||||||
|
# worktree with no server at all, which is how test runs ended up silently
|
||||||
|
# targeting the main checkout.
|
||||||
start:
|
start:
|
||||||
docker compose up -d
|
@if [ -f .worktree-env ]; then \
|
||||||
|
echo "→ worktree: starting the grav service only (travel-memories needs a .env, which worktrees have none)"; \
|
||||||
|
docker compose up -d grav; \
|
||||||
|
else \
|
||||||
|
docker compose up -d; \
|
||||||
|
fi
|
||||||
|
|
||||||
# Grav service only — used by `make worktree-new` (a worktree rarely needs the
|
# Grav service only — used by `make worktree-new` (a worktree rarely needs the
|
||||||
# travel-memories service, and this keeps its footprint minimal).
|
# travel-memories service, and this keeps its footprint minimal).
|
||||||
@@ -174,6 +200,12 @@ worktree-rm: guard-name
|
|||||||
-git -C "$(WT_DIR)" submodule deinit -f user
|
-git -C "$(WT_DIR)" submodule deinit -f user
|
||||||
git worktree remove --force "$(WT_DIR)"
|
git worktree remove --force "$(WT_DIR)"
|
||||||
git worktree prune
|
git worktree prune
|
||||||
|
# The deinit above is required (a populated user/ blocks `worktree remove`),
|
||||||
|
# but worktrees SHARE .git/config — so it also strips submodule.user.url for
|
||||||
|
# the MAIN checkout, leaving `git submodule status` there showing `-` (not
|
||||||
|
# initialised) even though user/ is intact. Re-register it; init is
|
||||||
|
# idempotent and touches config only, never the working tree.
|
||||||
|
git submodule init
|
||||||
@echo "Removed $(WT_DIR). If feat/$(NAME) is merged, drop it: git branch -d feat/$(NAME)"
|
@echo "Removed $(WT_DIR). If feat/$(NAME) is merged, drop it: git branch -d feat/$(NAME)"
|
||||||
|
|
||||||
# ── Demo content ──────────────────────────────────────────────────────────────
|
# ── Demo content ──────────────────────────────────────────────────────────────
|
||||||
@@ -182,6 +214,13 @@ demo-load:
|
|||||||
# Load every fixture trip under docs/demo/trips/ into the pages tree.
|
# Load every fixture trip under docs/demo/trips/ into the pages tree.
|
||||||
# Source uses dailies/ + 04.stories/; dailies/ maps to 01.dailies/ on copy.
|
# Source uses dailies/ + 04.stories/; dailies/ maps to 01.dailies/ on copy.
|
||||||
# All copies are `|| true` so a fixture absent from an older user/ is skipped.
|
# All copies are `|| true` so a fixture absent from an older user/ is skipped.
|
||||||
|
#
|
||||||
|
# ⚠️ A fixture whose folder name matches a REAL trip's slug is copied straight
|
||||||
|
# over that live page — docs/demo/trips/italy-2025/ collides with the real
|
||||||
|
# italy-2025 trip on purpose (the fixture supplies its GPX + dailies). So any
|
||||||
|
# field the fixture's trip.md omits gets silently deleted from real content on
|
||||||
|
# every test run: it had been dropping the trip's tagline that way. Keep a
|
||||||
|
# colliding fixture's trip.md byte-identical to the live page.
|
||||||
docker exec $(GRAV_CONTAINER) bash -c 'for src in /var/www/html/user/docs/demo/trips/*/; do \
|
docker exec $(GRAV_CONTAINER) bash -c 'for src in /var/www/html/user/docs/demo/trips/*/; do \
|
||||||
slug=$$(basename "$$src"); dst=/var/www/html/user/pages/01.trips/$$slug; \
|
slug=$$(basename "$$src"); dst=/var/www/html/user/pages/01.trips/$$slug; \
|
||||||
mkdir -p "$$dst/01.dailies" "$$dst/04.stories"; \
|
mkdir -p "$$dst/01.dailies" "$$dst/04.stories"; \
|
||||||
|
|||||||
@@ -10,10 +10,29 @@ Two git repos:
|
|||||||
|
|
||||||
| Repo | Contents | Location |
|
| Repo | Contents | Location |
|
||||||
|------|----------|----------|
|
|------|----------|----------|
|
||||||
| `intotheeast.com` (this repo) | Docker setup, Makefile, scripts, plugins.txt | `./` |
|
| `intotheeast.com` (this repo) | Docker setup, Makefile, scripts, tests, docs, plugins.txt | `./` |
|
||||||
| `intotheeast.com-content` | Site config, pages, theme | `user/` (standalone git repo) |
|
| `intotheeast.com-content` | Site config, pages, theme | `user/` (git submodule) |
|
||||||
|
|
||||||
The `user/` directory is a standalone git repo — its changes are pushed/pulled independently to Gitea. The Grav Sync plugin on the server automatically pulls from Gitea when content is pushed.
|
`user/` is tracked by this repo as a **git submodule** — it has its own Gitea remote and its own push/pull cadence (`make content-push` / `make content-pull`), and this repo pins an exact `user/` commit. The Git Sync plugin on the server pulls from Gitea automatically when content is pushed. A persistent `M user` / `m user` in `git status` is normal, not a problem; see [`docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md`](docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md).
|
||||||
|
|
||||||
|
### Folder map
|
||||||
|
|
||||||
|
| Path | Contents |
|
||||||
|
|------|----------|
|
||||||
|
| `user/` | Site content, config, pages, theme (the content submodule) |
|
||||||
|
| `user/themes/intotheeast/js/src/` | JS sources — esbuild inputs; run `make build-assets` after editing. Note `js/maplibre-utils.js` and `js/nav.js` are *also* sources, despite sitting beside the generated bundles |
|
||||||
|
| `deploy/env/` | Per-environment Grav config overrides (e.g. prod Twig settings) |
|
||||||
|
| `deploy/patches/` | Tracked patches for third-party plugins, which are otherwise git-ignored |
|
||||||
|
| `scripts/` | Server install and maintenance scripts |
|
||||||
|
| `tests/` | Playwright suite — see [`docs/reference/testing.md`](docs/reference/testing.md) |
|
||||||
|
| `php/` | Local PHP ini overrides |
|
||||||
|
| `docs/` | All project documentation — start at [`docs/README.md`](docs/README.md) |
|
||||||
|
| `docs/guides/` | Operational how-tos (posting, GPX, trip switching, setup, deploy cycle) |
|
||||||
|
| `docs/reference/` | Stable facts: architecture, design system, testing |
|
||||||
|
| `docs/solutions/` | Write-ups of bugs and workflow traps already hit, with YAML frontmatter (`module`, `tags`, `problem_type`) |
|
||||||
|
| `docs/working/` | Specs, plans, backlog, QA — work in flight |
|
||||||
|
| `CONCEPTS.md` | Shared domain vocabulary (Trip, Entry, Story, Active Trip) |
|
||||||
|
| `CLAUDE.md` | Rules and gotchas loaded into every Claude Code session |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -30,16 +49,22 @@ The `user/` directory is a standalone git repo — its changes are pushed/pulled
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env # fill in your values — never commit this file
|
cp .env.example .env # fill in your values — never commit this file
|
||||||
make setup # start Docker container and install plugins
|
git submodule update --init user
|
||||||
|
mkdir -p user/plugins user/data
|
||||||
|
make build && make start-grav && make install-plugins && make fix-perms
|
||||||
```
|
```
|
||||||
|
|
||||||
Site runs at http://localhost:8081.
|
Site runs at http://localhost:8081.
|
||||||
|
|
||||||
Clone the user content repo into `user/` if not already present:
|
`user/` is a **git submodule** — initialise it with `git submodule update --init user`. Do not
|
||||||
|
`git clone` into `user/` by hand; that detaches it from the pin the outer repo tracks.
|
||||||
|
|
||||||
```bash
|
> ⚠️ **Use `make start-grav`, not `make setup`, on a clean checkout.** `make setup` runs `make start`
|
||||||
git clone $USER_REPO user/
|
> (`docker compose up -d`), which still tries to build the `travel-memories` service — but its source
|
||||||
```
|
> was moved to a separate project (`a80b0a9`) and `services/` is gitignored, so the build context is
|
||||||
|
> missing and the command fails. `make start-grav` brings up Grav only. Machines with a cached
|
||||||
|
> `travel-memories` image will not see this until their next rebuild. See
|
||||||
|
> [`docs/reference/superseded-decisions.md`](docs/reference/superseded-decisions.md) → R11.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -50,12 +75,12 @@ git clone $USER_REPO user/
|
|||||||
**2. Run the install:**
|
**2. Run the install:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make remote-install
|
make remote-install-prod # or -test
|
||||||
```
|
```
|
||||||
|
|
||||||
This SSHes into the server, downloads Grav, clones both repos (user content + this config repo), installs plugins, and prints the server's SSH public key.
|
This SSHes into the server, downloads Grav, clones both repos (user content + this config repo), installs plugins, and prints the server's SSH public key.
|
||||||
|
|
||||||
**3. Add the SSH key to Gitea** — copy the printed public key and add it as a read-only deploy key to both Gitea repos. After this, `make remote-fetch` works without credentials.
|
**3. Add the SSH key to Gitea** — copy the printed public key and add it as a read-only deploy key to both Gitea repos. After this, `make remote-fetch-prod` works without credentials.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -82,42 +107,104 @@ make content-push # push local user/ commits → Gitea
|
|||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `make start` | Start the local Docker container |
|
| `make setup` | First run: build → start → install plugins → fix perms. ⚠️ Currently fails on a clean checkout — see the setup note above; use the `start-grav` sequence instead |
|
||||||
|
| `make start` | Start **all** compose services. ⚠️ Fails where `services/travel-memories` is absent |
|
||||||
|
| `make start-grav` | Start the Grav service only — the reliable option |
|
||||||
| `make stop` | Stop the local Docker container |
|
| `make stop` | Stop the local Docker container |
|
||||||
| `make setup` | Start container and install all plugins from plugins.txt |
|
| `make install-plugins` | (Re)install plugins from plugins.txt, then apply local plugin patches |
|
||||||
| `make install-plugins` | (Re)install plugins from plugins.txt in the local container |
|
| `make apply-plugin-patches` | Idempotently re-apply the patches in `deploy/patches/` |
|
||||||
| `make content-push` | Push local `user/` commits to Gitea |
|
| `make fix-perms` | Reset file ownership inside the container |
|
||||||
|
| `make build-assets` | Run esbuild over `user/themes/intotheeast/js/src/` — **required** after editing any JS source |
|
||||||
|
| `make content-push` | Push local `user/` commits to Gitea (triggers the production pull) |
|
||||||
| `make content-pull` | Pull latest `user/` content from Gitea |
|
| `make content-pull` | Pull latest `user/` content from Gitea |
|
||||||
|
|
||||||
### Remote credentials
|
### Testing
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `make remote-env-setup` | Write Gitea credentials to `~/.env-intotheeast` on the server |
|
| `make test` | Everything: `test-config` → `test-post` → `test-ui` |
|
||||||
| `make remote-env-remove` | Delete `~/.env-intotheeast` from the server |
|
| `make test-config` | Form/config sanity checks |
|
||||||
|
| `make test-post` | End-to-end post submission |
|
||||||
|
| `make test-ui` | Playwright suite |
|
||||||
|
|
||||||
Always run `make remote-env-remove` when done. Credentials must not persist on the server.
|
Details and conventions: [`docs/reference/testing.md`](docs/reference/testing.md).
|
||||||
|
|
||||||
### Remote server management
|
### Demo content and imports
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `make remote-install` | First-time install: download Grav, clone both repos, install plugins |
|
| `make demo-load` | Copy every fixture trip under `user/docs/demo/trips/` into the pages tree (add a fixture by dropping a folder there — no Makefile edit needed) |
|
||||||
| `make remote-fetch` | Pull latest config repo (Makefile, scripts, plugins.txt) on the server |
|
| `make demo-reset` | Remove those demo trips from the pages tree and clear cache |
|
||||||
| `make remote-install-plugins` | Install/update plugins from local plugins.txt on the server |
|
| `make pixelfed-import` | Import posts from Pixelfed via `scripts/pixelfed-import.py` |
|
||||||
| `make remote-upgrade-grav` | Upgrade Grav core on the server |
|
|
||||||
| `make remote-clean` | Clear Grav cache on the server |
|
### Parallel work
|
||||||
| `make remote-maintenance-on` | Enable maintenance mode (visitors see offline page) |
|
|
||||||
| `make remote-maintenance-off` | Disable maintenance mode |
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `make worktree-new NAME=<feature>` | Create a worktree with its own `user/` checkout and an isolated dev server on port `8090+` |
|
||||||
|
| `make worktree-rm NAME=<feature>` | Tear one down cleanly (compose down → submodule deinit → worktree remove → prune) |
|
||||||
|
|
||||||
|
### Remote targets — every one needs an environment suffix
|
||||||
|
|
||||||
|
> **All `remote-*` targets require `-test` or `-prod`.** A bare `make remote-fetch` fails via
|
||||||
|
> `guard-env` with *"no environment. Use an env-suffixed target"*. The suffixed variants are generated
|
||||||
|
> by a macro in the `Makefile`, so they will not show up in a grep for literal target names.
|
||||||
|
|
||||||
|
The runbook for shipping a change through test → prod is
|
||||||
|
[`docs/guides/deploy-cycle.md`](docs/guides/deploy-cycle.md). The tables below are the inventory.
|
||||||
|
|
||||||
|
**Credentials** — always run `remote-env-remove-<env>` when done; credentials must not persist on the server.
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `make remote-env-setup-<env>` | Write Gitea credentials to `~/.env-intotheeast` on the server |
|
||||||
|
| `make remote-env-remove-<env>` | Delete `~/.env-intotheeast` from the server |
|
||||||
|
| `make remote-secrets-audit-<env>` | Check the server for exposed secrets |
|
||||||
|
| `make remote-seed-api-salt-<env>` | Generate the API/CSRF salt on the server |
|
||||||
|
|
||||||
|
**Install and sync**
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `make remote-install-<env>` | First-time install: download Grav, clone both repos, install plugins |
|
||||||
|
| `make remote-fetch-<env>` | Pull latest config repo (Makefile, scripts, plugins.txt) on the server |
|
||||||
|
| `make remote-fetch-content-<env>` | Pull latest `user/` content on the server |
|
||||||
|
| `make remote-content-status-<env>` | Show the server's content-repo state |
|
||||||
|
| `make remote-apply-env-<env>` | Apply `deploy/env/<env>/` config into the server's env tree — **re-run after any fresh install** |
|
||||||
|
| `make remote-apply-plugin-patches-<env>` | Re-apply `deploy/patches/` on the server |
|
||||||
|
|
||||||
|
**Plugins and core**
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `make remote-install-plugins-<env>` | Install plugins from local plugins.txt on the server |
|
||||||
|
| `make remote-update-plugins-<env>` | Update installed plugins via GPM |
|
||||||
|
| `make remote-gpm-install-<env>` | Install a single plugin via GPM |
|
||||||
|
| `make remote-upgrade-grav-<env>` | Upgrade Grav core on the server (in place — servers have no image) |
|
||||||
|
|
||||||
|
**Operations**
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `make remote-clean-<env>` | Clear Grav cache on the server |
|
||||||
|
| `make remote-warmup-<env>` | Clear **and warm** the cache after a deploy |
|
||||||
|
| `make remote-maintenance-on-<env>` | Enable maintenance mode (visitors see offline page) |
|
||||||
|
| `make remote-maintenance-off-<env>` | Disable maintenance mode |
|
||||||
|
| `make remote-diag-<env>` | Diagnostics on the server |
|
||||||
|
| `make remote-git-sync-enable-<env>` / `-disable-<env>` | Toggle the remote-only git-sync plugin |
|
||||||
|
| `make remote-wipe-<env>` | ⚠️ Destroy the server install |
|
||||||
|
|
||||||
### Typical upgrade workflow
|
### Typical upgrade workflow
|
||||||
|
|
||||||
|
Run against `test` first — it is a full dress rehearsal of prod.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make remote-maintenance-on
|
make remote-maintenance-on-prod
|
||||||
make remote-upgrade-grav
|
make remote-upgrade-grav-prod
|
||||||
make remote-install-plugins
|
make remote-install-plugins-prod
|
||||||
make remote-clean
|
make remote-apply-env-prod # env tree is not restored by anything else
|
||||||
make remote-maintenance-off
|
make remote-warmup-prod
|
||||||
|
make remote-maintenance-off-prod
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./user:/var/www/html/user
|
- ./user:/var/www/html/user
|
||||||
- ./php/php-local.ini:/usr/local/etc/php/conf.d/php-local.ini
|
- ./php/php-local.ini:/usr/local/etc/php/conf.d/php-local.ini
|
||||||
|
# Grav stages form uploads in tmp/forms/<session>/ before the submit moves
|
||||||
|
# them into the page folder. The image declares /var/www/html as a VOLUME,
|
||||||
|
# so without this it lives in an ANONYMOUS volume that is discarded on any
|
||||||
|
# `docker compose up` that recreates the container — dropping the photos of
|
||||||
|
# a post that was filled in but not yet submitted. Naming it gives the
|
||||||
|
# staging area its own lifecycle.
|
||||||
|
- grav_tmp:/var/www/html/tmp
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
travel-memories:
|
travel-memories:
|
||||||
@@ -24,3 +31,6 @@ services:
|
|||||||
- ./user/pages:/app/pages
|
- ./user/pages:/app/pages
|
||||||
env_file: .env
|
env_file: .env
|
||||||
user: "${UID}:${GID}"
|
user: "${UID}:${GID}"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
grav_tmp:
|
||||||
|
|||||||
+9
-3
@@ -8,14 +8,20 @@
|
|||||||
- [Switching to a new trip](guides/trip-switching.md)
|
- [Switching to a new trip](guides/trip-switching.md)
|
||||||
- [Rebuilding local dev from scratch](guides/local-setup.md)
|
- [Rebuilding local dev from scratch](guides/local-setup.md)
|
||||||
|
|
||||||
**Checking project status?** → [`working/`](working/)
|
**Checking project status?** → [`working/`](working/) — [what's in there + the plan status convention](working/README.md)
|
||||||
- [Backlog](working/backlog.md)
|
- [Backlog](working/backlog.md)
|
||||||
- [Production todo](working/production-todo.md)
|
- [Bugs and fixes](working/bugs-and-fixes.md)
|
||||||
- [QA results](working/qa/results.md)
|
- [QA results](working/qa/results.md)
|
||||||
|
|
||||||
**Design or architecture decisions?** → [`reference/`](reference/)
|
**Design or architecture decisions?** → [`reference/`](reference/)
|
||||||
- [Design system](reference/design-system.md)
|
- [Design system](reference/design-system.md)
|
||||||
- [Architecture overview](reference/architecture.md)
|
- [Architecture overview](reference/architecture.md) — the site as it actually is
|
||||||
|
- [Superseded decisions](reference/superseded-decisions.md) — what was planned, then reversed, and why
|
||||||
|
- [Testing](reference/testing.md)
|
||||||
|
|
||||||
|
> Documents under [`working/`](working/) are historical records. If one describes something that no
|
||||||
|
> longer exists, [`reference/superseded-decisions.md`](reference/superseded-decisions.md) says what
|
||||||
|
> replaced it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# View unpublished trips (drafts) on the frontend when logged in
|
||||||
|
|
||||||
|
**Status:** 📋 Not started
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
An unpublished trip (`published: false`) currently returns a hard **404** on its own
|
||||||
|
route, even for the logged-in owner. Example: `http://localhost:8081/trips/denmark-2026`
|
||||||
|
→ `HTTP 404` (verified 2026-07-08, anonymous *and* authenticated). The owner should be
|
||||||
|
able to preview a draft trip page at its real URL before publishing, while the public
|
||||||
|
still gets a 404.
|
||||||
|
|
||||||
|
The rest of the site is **already owner-aware** — the trip template, the trips listing,
|
||||||
|
and the home page all render drafts to `grav.user.authenticated` (via `.published()`
|
||||||
|
filters + `is-draft`/Draft badges). The only missing piece is the **direct route** to a
|
||||||
|
draft's own page.
|
||||||
|
|
||||||
|
## Current behaviour — verified mechanism
|
||||||
|
|
||||||
|
Traced through the Grav core running in the container (Grav 2.0.x):
|
||||||
|
|
||||||
|
- `Page::routable()` (`system/src/Grav/Common/Page/Page.php`) returns:
|
||||||
|
```php
|
||||||
|
return $this->routable && $this->published();
|
||||||
|
```
|
||||||
|
So `published: false` ⇒ `routable()` is `false`, regardless of the `routable` flag.
|
||||||
|
|
||||||
|
- `PagesProcessor.php:67` gates the request on exactly that:
|
||||||
|
```php
|
||||||
|
if (!$page->routable()) {
|
||||||
|
// build 404, fire onPageNotFound...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `PagesProcessor.php` ~line 80: after firing `onPageNotFound`, if a listener set
|
||||||
|
`$event->page`, Grav serves **that** page directly with no further routable check:
|
||||||
|
```php
|
||||||
|
if (isset($event->page)) {
|
||||||
|
unset($this->container['page']);
|
||||||
|
$this->container['page'] = $page = $event->page;
|
||||||
|
} else {
|
||||||
|
throw new RuntimeException('Page Not Found', 404);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
That last hook is the clean insertion point.
|
||||||
|
|
||||||
|
## Proposed approach — small custom plugin (~40 lines)
|
||||||
|
|
||||||
|
Mirror the existing `user/plugins/cache-on-save/` custom-plugin pattern. Subscribe to
|
||||||
|
`onPageNotFound` and, for authenticated users only, resolve the requested route including
|
||||||
|
unpublished pages and hand it back:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function onPageNotFound(Event $e) {
|
||||||
|
$user = $this->grav['user'];
|
||||||
|
if (!$user->authenticated) {
|
||||||
|
return; // owners only — public still 404s
|
||||||
|
}
|
||||||
|
$route = $this->grav['uri']->path();
|
||||||
|
$page = $this->grav['pages']->find($route, true); // include unpublished
|
||||||
|
if ($page && !$page->published()) {
|
||||||
|
$e->page = $page; // serve the draft → 200
|
||||||
|
$e->stopPropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The page then renders with its normal template. Because the theme is already owner-aware,
|
||||||
|
the trip page will display correctly for the logged-in owner.
|
||||||
|
|
||||||
|
## Decisions to make before building (brainstorm first)
|
||||||
|
|
||||||
|
1. **Scope of page types.** All unpublished pages, or just the trip tree
|
||||||
|
(`/trips/*`)? Entries and stories already show inline as drafts on the owner's trip
|
||||||
|
feed; do they also need standalone-route preview? Leaning: gate to trip/entry/story
|
||||||
|
templates to avoid unintentionally exposing every draft everywhere.
|
||||||
|
2. **Draft banner.** Add a trip-level "Draft — not published" banner when viewing an
|
||||||
|
unpublished trip (entry-level draft badges already exist; this is the trip equivalent).
|
||||||
|
3. **Non-existent vs. unpublished.** Ensure a genuinely missing route still 404s — the
|
||||||
|
`find(..., true)` + `!published()` check already distinguishes them, but cover it in a test.
|
||||||
|
|
||||||
|
## The one real risk — page-cache leak to the public
|
||||||
|
|
||||||
|
If Grav caches the 200 we serve to the owner and later hands it to an anonymous visitor,
|
||||||
|
the "owners only" gate is defeated. **Verify, don't assume:**
|
||||||
|
|
||||||
|
- Grav's Login plugin disables page caching for authenticated sessions by default.
|
||||||
|
- The theme already serves owner-only draft *content* inline today, so this exposure is
|
||||||
|
presumably mitigated somewhere already.
|
||||||
|
|
||||||
|
Add an explicit **anonymous-request assertion** (draft route → 404 for anon, even
|
||||||
|
after an authenticated hit warmed any cache).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Playwright spec:
|
||||||
|
- Authenticated owner → `GET /trips/<draft-slug>` returns 200 and renders the trip page.
|
||||||
|
- Anonymous → same route returns 404.
|
||||||
|
- Anonymous after an authenticated hit → still 404 (cache-leak guard).
|
||||||
|
- Genuinely missing route → 404 for everyone.
|
||||||
|
|
||||||
|
## Effort
|
||||||
|
|
||||||
|
**Low** — roughly half a day including the Playwright spec. Single custom plugin plus an
|
||||||
|
optional small theme partial for the draft banner.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Investigation session: 2026-07-08 ("hotfixes").
|
||||||
|
- Pattern to copy: `user/plugins/cache-on-save/`.
|
||||||
|
- Related owner-aware theme logic: `templates/trip.html.twig` (`owner_can_edit`),
|
||||||
|
`templates/trips.html.twig` (`is_owner`), `templates/home.html.twig`.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Blueprint Vetting — Research & Recommendation
|
||||||
|
|
||||||
|
**Status:** 📋 Not started
|
||||||
|
**Date:** 2026-07-08
|
||||||
|
**Scope:** All custom Grav blueprints (intotheeast theme page blueprints, theme blueprint, site-config extension). Stock Quark blueprints excluded.
|
||||||
|
|
||||||
|
## Files reviewed
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `user/themes/intotheeast/blueprints/entry.yaml` | Daily journal entry (Admin form) |
|
||||||
|
| `user/themes/intotheeast/blueprints/story.yaml` | Story pages |
|
||||||
|
| `user/themes/intotheeast/blueprints/trip.yaml` | Trip pages |
|
||||||
|
| `user/themes/intotheeast/blueprints/home.yaml` | Home page |
|
||||||
|
| `user/themes/intotheeast/blueprints.yaml` | Theme blueprint (identity only) |
|
||||||
|
| `user/blueprints/config/site.yaml` | Site-config extension (`active_trip`, `travelling`) |
|
||||||
|
|
||||||
|
## What's already good
|
||||||
|
|
||||||
|
- Toggle idiom is correct and consistent everywhere: `options: {1: Yes, 0: No}` + `validate: type: bool`.
|
||||||
|
- `trip.yaml` `autoconnect` keys `'on'`/`'off'` are properly quoted — avoids the YAML 1.1 boolean footgun (`on:` parsing as `true:`). `default: 'on'` is quoted too.
|
||||||
|
- `user/blueprints/config/site.yaml` follows the standard Grav pattern for extending system site config (fields merge into Admin → Configuration → Site); `validation: loose` present; the `pages` field options (`start_route`, `show_root`, `show_slug`) are all real options.
|
||||||
|
- `entry.yaml` correctly uses `@extends: {type: default, context: blueprints://pages}` and adds its fields as a new tab, so entries keep the full standard Admin UI.
|
||||||
|
- Required-field validation on story/home titles and story content is in place.
|
||||||
|
- `weather_temp_c` has sensible min/max bounds (−60…60).
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### F1 — Only `entry.yaml` extends the default page blueprint (structural)
|
||||||
|
|
||||||
|
`story.yaml`, `trip.yaml`, and `home.yaml` define `form.fields.tabs` from scratch (no `@extends`). In Admin2 those page types show **only** the declared fields — no Options/Advanced tabs, so no slug rename, no ordering, no visibility, no publish dates, no taxonomy from Admin. The custom `header.published` toggles in story/trip partially compensate.
|
||||||
|
|
||||||
|
If the locked-down UI is deliberate, entry is the inconsistent one; if not, story/trip lose real capabilities (they're created repeatedly and may need slug/ordering control).
|
||||||
|
|
||||||
|
**Implementation note if extending:** story/trip use a tab key `content`, which collides with the default blueprint's Content tab — fields merge by key, so the duplicate `header.title`/`content` definitions override rather than duplicate, but the merged result needs a visual check in Admin. Their custom `header.published` toggle also becomes redundant with the default Options-tab toggle — keep one.
|
||||||
|
|
||||||
|
### F2 — `lat`/`lng` are free-text with no validation (data integrity)
|
||||||
|
|
||||||
|
`entry.yaml:27-35` and `story.yaml:64-74` declare latitude/longitude as plain `type: text`. Templates pipe the values straight into `number_format(6, …)` (`user/themes/intotheeast/templates/trip.html.twig:62`, `templates/home.html.twig:56`). PHP casts silently:
|
||||||
|
|
||||||
|
- European decimal comma `"35,0116"` → `35.000000` (marker subtly wrong)
|
||||||
|
- non-numeric garbage → `0.000000` (marker in the Gulf of Guinea)
|
||||||
|
|
||||||
|
No error surfaces anywhere. Fix: `validate: { type: float, min: -90, max: 90 }` for lat, `±180` for lng.
|
||||||
|
|
||||||
|
### F3 — `transport_mode` option drift (copy-paste divergence)
|
||||||
|
|
||||||
|
Entry offers `plane` (`entry.yaml:77`); story doesn't (`story.yaml:80-86`). The field — along with lat/lng, location, `force_connect` — is duplicated between the two blueprints, which is how drift happens. Grav supports shared partials via `import@`; in-repo example: `user/themes/quark/blueprints/blog.yaml:90` importing `partials/blog-bits.yaml`.
|
||||||
|
|
||||||
|
### F4 — `hero_image` UX inconsistency
|
||||||
|
|
||||||
|
Trip uses `pagemediaselect` (dropdown of uploaded media, `trip.yaml:40-44`); entry and story use free-text filename fields (`entry.yaml:60-64`, `story.yaml:33-37`) where a typo silently breaks the hero. `pagemediaselect` keeps the "blank = first image" fallback while removing typo risk.
|
||||||
|
|
||||||
|
### F5 — Minor items
|
||||||
|
|
||||||
|
| Item | Location | Detail |
|
||||||
|
|---|---|---|
|
||||||
|
| `travelling` default mismatch | `user/blueprints/config/site.yaml:15` | `default: false` vs option keys `1`/`0`; works via loose comparison, but `default: 0` matches every other toggle |
|
||||||
|
| Date type drift | `story.yaml:20-31` vs `trip.yaml:28-38` | story: `datetime` + `format: 'Y-m-d'` (the deliberate Admin2 datepicker fix); trip: plain `date`. Pick one convention |
|
||||||
|
| `<br>` in help text | `trip.yaml:61,73` | If Admin2 escapes HTML in help tooltips, users see literal `<br>` tags |
|
||||||
|
| `weather_temp_c` step | `entry.yaml:52-58` | HTML number inputs default to step 1 → `19.5` may be rejected client-side; fine if whole degrees are intended |
|
||||||
|
| `pagemediaselect` accept filter | `trip.yaml:42` | Extension-style `accept: ['.jpg', …]` is the filepicker convention; unverified against Admin2's SPA implementation |
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
Treat as one small milestone in three parts, in this order:
|
||||||
|
|
||||||
|
### Phase 1 — Data-integrity + drift fixes (no decisions needed, low risk)
|
||||||
|
|
||||||
|
1. **F2:** add `validate: { type: float, min/max }` to all four lat/lng fields (entry + story).
|
||||||
|
2. **F3:** extract a shared theme partial `user/themes/intotheeast/blueprints/partials/` (e.g. `location-bits.yaml`) holding location name/country, lat/lng (with the new validation), `transport_mode` (superset incl. `plane`), and `force_connect`; `import@` it from entry and story. Follow the Quark example.
|
||||||
|
3. **F5 quick fixes:** `travelling` default → `0`; standardize date fields on `datetime` + `format: 'Y-m-d'` (matches the established Admin2 datepicker fix).
|
||||||
|
|
||||||
|
### Phase 2 — Structural decision (needs Mischa's call)
|
||||||
|
|
||||||
|
4. **F1:** recommended: add `@extends: default` to **story and trip** (repeatedly-created content pages that benefit from slug/ordering/options control); leave **home** minimal (singleton whose slug must never change). Resolve the Content-tab merge and duplicate-published-toggle notes above. Verify each Admin form visually after the change.
|
||||||
|
5. **F4:** switch entry + story `hero_image` to `pagemediaselect` (naturally bundles with the Phase 2 Admin verification pass).
|
||||||
|
|
||||||
|
### Verify-once checklist (manual, 5 minutes in Admin2)
|
||||||
|
|
||||||
|
- [ ] Trip page → Cover Image dropdown: do `.gpx` files appear? (If yes, the `accept` filter isn't applying — F5.)
|
||||||
|
- [ ] `use_gpx` / `autoconnect` help tooltips: rendered line breaks or literal `<br>`?
|
||||||
|
- [ ] Decide: whole-degree temperatures OK, or add `step` to `weather_temp_c`?
|
||||||
|
|
||||||
|
### Out of scope
|
||||||
|
|
||||||
|
- Post form (`/post`) field parity — separate surface, not touched by this vetting.
|
||||||
|
- Theme blueprint (`blueprints.yaml`) — minimal but valid; no theme options exist yet, nothing to add.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
1. **F1:** Is the locked-down Admin UI for story/trip/home deliberate? (Recommendation above assumes it isn't for story/trip.)
|
||||||
|
2. Should `transport_mode` for stories include `plane` (superset) or stay intentionally narrower?
|
||||||
@@ -41,6 +41,52 @@ servers use. See `docs/solutions/tooling-decisions/upgrade-local-grav-core-rebui
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## The env override tree (`user/env/<host>/`)
|
||||||
|
|
||||||
|
Prod needs different Twig settings than dev. These are **never** committed to
|
||||||
|
`user/config/system.yaml` — `twig.cache: false` and `debug`/`auto_reload: true`
|
||||||
|
are the *intended dev values*, and committing prod values there breaks local
|
||||||
|
development for everyone. Instead they ship as a per-environment override via
|
||||||
|
Grav's `environment://config`, keyed on the request hostname.
|
||||||
|
|
||||||
|
| Setting | Dev (committed) | Prod (override) | Why prod differs |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `twig.cache` | `false` | `true` | Compile templates once and reuse |
|
||||||
|
| `twig.debug` | `true` | `false` | No debug functions in prod |
|
||||||
|
| `twig.auto_reload` | `true` | `false` | Don't stat templates every request |
|
||||||
|
|
||||||
|
- **Source of truth:** `deploy/env/prod/system.yaml` (version-controlled).
|
||||||
|
- **Deploy:** `make remote-apply-env-prod` — writes it to
|
||||||
|
`<webroot>/user/env/<hostname>/config/system.yaml` and clears cache. It
|
||||||
|
deep-merges over the committed `system.yaml`.
|
||||||
|
- **Hostname segment** defaults to `REMOTE_HOST`; override with `WEB_HOST` in
|
||||||
|
`.env.<env>` if Grav sees a different host than the SSH host.
|
||||||
|
- **Not restored by anything.** `user/env/` is outside the content repo's tracked
|
||||||
|
folders, so `content-push` / git-sync / `remote-fetch-content` do **not** bring
|
||||||
|
it back. **Re-run `make remote-apply-env-<env>` after any fresh install.**
|
||||||
|
|
||||||
|
### Side effect: Admin writes ALL config into the env tree
|
||||||
|
|
||||||
|
Once `user/env/<hostname>/` exists, Grav's Admin saves **every** config change
|
||||||
|
(system *and* plugin) there — e.g. editing a plugin on prod writes
|
||||||
|
`user/env/intotheeast.com/config/plugins/<name>.yaml`, **not**
|
||||||
|
`user/config/plugins/<name>.yaml`. Consequences:
|
||||||
|
|
||||||
|
- Config edited via **Admin on the server is server-only**: the env tree is not
|
||||||
|
committed and not synced by git-sync (which syncs only `pages`/`config`/
|
||||||
|
`themes`), so prod Admin edits silently never reach Gitea or local. This is
|
||||||
|
*good* for secrets — `git-sync.yaml` (token), the JWT and CSRF salt safely
|
||||||
|
live there — but it means config drift is invisible to the repo.
|
||||||
|
- When reading or writing server config, check **both** `user/config/…` and
|
||||||
|
`user/env/<host>/config/…` (env wins). Server tooling must search the env path
|
||||||
|
first — see `scripts/git-sync-toggle.sh` and `make remote-diag`.
|
||||||
|
- Repo-authored config (`user/config/…` via `make content-push`) still applies
|
||||||
|
everywhere; the env tree holds only per-host overrides + Admin-on-server edits.
|
||||||
|
|
||||||
|
Full details: `docs/working/git-sync-notes.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Phase 0 — Local (author + prove the change)
|
## Phase 0 — Local (author + prove the change)
|
||||||
|
|
||||||
1. Make the change in the repo:
|
1. Make the change in the repo:
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ The GPX manager at `/gpx-manager` requires admin login (redirects to login form
|
|||||||
Drop the file directly into the trip folder and push:
|
Drop the file directly into the trip folder and push:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp your-route.gpx /path/to/user/pages/01.trips/japan-korea-2026/
|
cp your-route.gpx /path/to/user/pages/01.trips/denmark-2026/
|
||||||
make content-push
|
make content-push
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -83,3 +83,25 @@ GPX files are registered as a valid media type in `user/config/media.yaml`, so G
|
|||||||
```
|
```
|
||||||
|
|
||||||
No manual linking is needed — upload and it appears.
|
No manual linking is needed — upload and it appears.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How the manager is wired
|
||||||
|
|
||||||
|
| Piece | Detail |
|
||||||
|
|---|---|
|
||||||
|
| Page | `user/pages/03.gpx-manager/` |
|
||||||
|
| Template | `user/themes/intotheeast/templates/gpx-manager.html.twig` |
|
||||||
|
| Auth | Login plugin, via `access.admin.login: true` in the page frontmatter — renders the login form when unauthenticated |
|
||||||
|
| API | Grav API v1 with **session cookie** auth (`session_enabled: true` in `user/plugins/api/api.yaml`) |
|
||||||
|
|
||||||
|
API calls the page makes:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/pages{route}/media # list
|
||||||
|
POST /api/v1/pages{route}/media # upload (multipart)
|
||||||
|
DELETE /api/v1/pages{route}/media/{filename} # delete
|
||||||
|
```
|
||||||
|
|
||||||
|
**Upload gotcha:** the selected file is sliced into a plain `Blob` before `FormData.append`, so the third argument is always honoured as the filename. Appending the original `File` lets the browser keep the unslugified name and the slugification is silently ignored.
|
||||||
|
|
||||||
|
|||||||
+75
-20
@@ -1,18 +1,23 @@
|
|||||||
# Posting a Journal Entry
|
# Posting a Journal Entry
|
||||||
|
|
||||||
Two ways to post: the **mobile form** at `/post` (quick, phone-friendly) or the **Admin panel** at `/admin` (drafts, scheduling, editing).
|
Two ways to post: the **mobile form** at `/post` (quick, phone-friendly) or the **Admin panel** at `/admin` (scheduling, bulk edits). The `/post` form also **edits** existing entries — see below.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick start — mobile form
|
## Quick start — mobile form
|
||||||
|
|
||||||
1. Open `/post` on your phone (login required)
|
1. Open `/post` on your phone (login required)
|
||||||
2. Fill in **Title** and **Content** (required)
|
2. **Attach 1–6 photos** — photos come first because they anchor what you write. **At least one is required**; the form collapses them into a summary bar once uploaded
|
||||||
3. Tap **Get Location** → fills Lat/Lng automatically
|
3. Fill in **Title** and **Content** (required)
|
||||||
4. Tap **Get Weather** → fills weather fields using your coordinates
|
4. Tap **Get Location** → fills Lat/Lng, then reverse-geocodes City + Country for you
|
||||||
5. Type **City** and **Country** (optional but nice)
|
5. Tap **Get Weather** → fills weather fields using those coordinates
|
||||||
6. Attach photos (optional) — first photo becomes the hero image
|
6. Optional: open **More location details** to search for a place by name, or drag the pin on the map to place it exactly
|
||||||
7. Tap **Submit** → entry appears in the feed immediately
|
7. Optional: open **More options** for transport mode, publish state, connector and highlight toggles
|
||||||
|
8. Tap **Submit** → entry appears in the feed immediately
|
||||||
|
|
||||||
|
> **Photos are mandatory (1–6).** This changed during the 2026-07 post-form work — an entry with no
|
||||||
|
> photo will not submit. The first photo in the grid is the hero; reorder by dragging to change it.
|
||||||
|
> See [`../reference/superseded-decisions.md`](../reference/superseded-decisions.md) → R7, R8.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -20,14 +25,20 @@ Two ways to post: the **mobile form** at `/post` (quick, phone-friendly) or the
|
|||||||
|
|
||||||
| Field | Required | Notes |
|
| Field | Required | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| Photos | ✅ | **1–6 per entry.** HEIC is converted to JPEG in the browser. First photo = hero; drag to reorder |
|
||||||
| Title | ✅ | Entry headline |
|
| Title | ✅ | Entry headline |
|
||||||
| Content | ✅ | Markdown body |
|
| Content | ✅ | Markdown body |
|
||||||
| Date | ✅ | Defaults to now — adjust if posting later |
|
| Date | ✅ | Defaults to now — adjust if posting later |
|
||||||
| Lat / Lng | — | Filled by Get Location; used for map marker |
|
| Lat / Lng | — | Filled by Get Location, by place search, or by dragging the map pin |
|
||||||
| City | — | Shown as `📍 Kyoto, Japan` on feed cards |
|
| City | — | Auto-filled by reverse geocoding after Get Location; shown as `📍 Kyoto, Japan` on feed cards |
|
||||||
| Country | — | Combined with City in location badge |
|
| Country | — | Combined with City in the location badge |
|
||||||
| Weather | — | Filled by Get Weather (Open-Meteo, free, no key) |
|
| Weather | — | Filled by Get Weather (Open-Meteo, free, no key) |
|
||||||
| Photos | — | All uploaded files appear in the gallery; first = hero |
|
| How I got here | — | `transport_mode`: walking · bicycle · bus · train · car · plane |
|
||||||
|
| Published | — | Advanced. Default **Yes**. Set No to keep a draft, or to unpublish on edit |
|
||||||
|
| Force connector line | — | Advanced. Default No. Forces a map connector to this entry even when suppressed |
|
||||||
|
| Featured highlight | — | Advanced. Default No. Opts the entry into the home highlights grid |
|
||||||
|
|
||||||
|
The advanced three sit behind **More options**. There is **no `hero_image` field** — see the note above.
|
||||||
|
|
||||||
**Weather descriptions** (must be one of these if entered manually):
|
**Weather descriptions** (must be one of these if entered manually):
|
||||||
`Sunny` · `Partly cloudy` · `Cloudy` · `Foggy` · `Drizzle` · `Rain` · `Snow` · `Thunderstorm`
|
`Sunny` · `Partly cloudy` · `Cloudy` · `Foggy` · `Drizzle` · `Rain` · `Snow` · `Thunderstorm`
|
||||||
@@ -39,9 +50,10 @@ Two ways to post: the **mobile form** at `/post` (quick, phone-friendly) or the
|
|||||||
```
|
```
|
||||||
Browser → /post (post-form.md)
|
Browser → /post (post-form.md)
|
||||||
└─ Grav Form plugin validates fields
|
└─ Grav Form plugin validates fields
|
||||||
└─ add-page-by-form plugin
|
└─ cache-on-save injects parent from site.active_trip
|
||||||
├─ reads pageconfig.parent (/trips/<active_trip>/dailies)
|
└─ and sets overwrite_mode: edit when edit_path is filled, else false
|
||||||
├─ writes user/pages/01.trips/<active_trip>/01.dailies/<slug>/entry.md
|
└─ add-page-by-form plugin (patched — see deploy/patches/)
|
||||||
|
├─ writes user/pages/01.trips/<active_trip>/01.dailies/<slug>.entry/entry.md
|
||||||
└─ moves uploaded photos into the page folder
|
└─ moves uploaded photos into the page folder
|
||||||
└─ cache-on-save plugin
|
└─ cache-on-save plugin
|
||||||
└─ calls $grav['cache']->deleteAll() → entry visible immediately
|
└─ calls $grav['cache']->deleteAll() → entry visible immediately
|
||||||
@@ -53,18 +65,24 @@ Example: `2026-07-20-0930-first-day-in-kyoto.entry`
|
|||||||
|
|
||||||
**Entry folder structure:**
|
**Entry folder structure:**
|
||||||
```
|
```
|
||||||
user/pages/01.trips/japan-korea-2026/01.dailies/
|
user/pages/01.trips/denmark-2026/01.dailies/
|
||||||
└─ 2026-07-20-0930-first-day-in-kyoto.entry/
|
└─ 2026-07-20-0930-first-day-in-kyoto.entry/
|
||||||
├─ entry.md ← frontmatter + markdown body
|
├─ entry.md ← frontmatter + markdown body
|
||||||
├─ temple.jpg ← hero image (or set hero_image in frontmatter)
|
├─ photo-01.jpg ← first in order, so this is the hero
|
||||||
└─ market.jpg ← additional gallery image
|
└─ photo-02.jpg ← additional gallery image
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Photos are stored as `photo-01…NN` in display order — the numbering *is* the order, so reordering in
|
||||||
|
the form renames files on disk, and `photo-01` is always the hero. Names are **zero-padded** so
|
||||||
|
lexical sort matches numeric order (otherwise `photo-1, photo-10, photo-2…`); the pad width grows for
|
||||||
|
100+ photos. `PhotoRenumberer` in `cache-on-save` is the single source of truth for this invariant and
|
||||||
|
is shared with `entry-actions`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Admin panel — drafts and scheduling
|
## Admin panel — drafts and scheduling
|
||||||
|
|
||||||
Use the Admin panel at `/admin` for drafts, scheduled posts, or editing existing entries.
|
Use the Admin panel at `/admin` for **scheduling** (`publish_date`) and bulk or structural edits. For ordinary edits — text, photos, location, publish state — the `/post` form is quicker; see [Editing an entry](#editing-an-entry).
|
||||||
|
|
||||||
1. Log in at `/admin`
|
1. Log in at `/admin`
|
||||||
2. **Pages → Add Page**
|
2. **Pages → Add Page**
|
||||||
@@ -96,14 +114,43 @@ Every entry supports these frontmatter fields:
|
|||||||
| `location_country` | string | e.g. `Japan` |
|
| `location_country` | string | e.g. `Japan` |
|
||||||
| `weather_desc` | string | One of the allowed values above |
|
| `weather_desc` | string | One of the allowed values above |
|
||||||
| `weather_temp_c` | number | Celsius, displayed rounded |
|
| `weather_temp_c` | number | Celsius, displayed rounded |
|
||||||
| `hero_image` | string | Filename to pin as hero (e.g. `temple.jpg`); auto-selects first image if blank |
|
| `transport_mode` | string | `walking` · `bicycle` · `bus` · `train` · `car` · `plane` |
|
||||||
|
| `force_connect` | bool | Force a map connector line to this entry even where it would be suppressed |
|
||||||
|
| `featured` | bool | Opt into the home page highlights grid |
|
||||||
|
|
||||||
|
> **No `hero_image` on journal entries.** The hero is whichever photo sorts first
|
||||||
|
> (`entry-journal.html.twig` uses `entry.media.images|first`), which the owner controls by
|
||||||
|
> reordering photos. **Stories still use `hero_image`** — they are not posted through this form.
|
||||||
|
> See [`../reference/superseded-decisions.md`](../reference/superseded-decisions.md) → R7.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Editing an entry
|
||||||
|
|
||||||
|
The `/post` form doubles as the editor — you do not need Admin for ordinary edits.
|
||||||
|
|
||||||
|
1. Open the entry (or find it in the feed) while logged in
|
||||||
|
2. Use the entry's **Edit** action → `/post` opens pre-filled, with the hidden `edit_path` set to that
|
||||||
|
entry's path
|
||||||
|
3. Existing photos load into the grid. You can **add**, **remove**, and **drag to reorder** them
|
||||||
|
4. Submit → `cache-on-save` sets `overwrite_mode: edit`, so the entry is rewritten **in place**
|
||||||
|
rather than creating a new dated folder
|
||||||
|
|
||||||
|
Photo files on disk are renumbered to `photo-1…N` to match the displayed order, so the first photo is
|
||||||
|
always the hero. Reordering is a real file rename, handled server-side by `PhotoRenumberer` in the
|
||||||
|
`entry-actions` plugin via `POST /api/v1/entry/{slug}/photos/order`.
|
||||||
|
|
||||||
|
To **unpublish** an entry, edit it and set **Published** to No under *More options*.
|
||||||
|
|
||||||
|
Deleting an entry is also an entry action (`DELETE /api/v1/entry/{slug}`), owner-only and scoped to
|
||||||
|
the active trip.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
**Entry doesn't appear in feed after submit**
|
**Entry doesn't appear in feed after submit**
|
||||||
→ Check that `active_trip` in `user/config/site.yaml` matches the parent in `user/pages/02.post/post-form.md` (`pageconfig.parent`). If they're out of sync, entries go to the wrong folder. See [trip switching guide](trip-switching.md).
|
→ Check `active_trip` in `user/config/site.yaml` — the write target is derived from it at submit time, so a wrong value sends entries to the wrong trip's dailies. See [trip switching guide](trip-switching.md).
|
||||||
|
|
||||||
**Get Weather button shows an error**
|
**Get Weather button shows an error**
|
||||||
→ Fill in Lat/Lng first (tap Get Location or enter manually). Open-Meteo requires coordinates.
|
→ Fill in Lat/Lng first (tap Get Location or enter manually). Open-Meteo requires coordinates.
|
||||||
@@ -111,5 +158,13 @@ Every entry supports these frontmatter fields:
|
|||||||
**Photos not showing in gallery**
|
**Photos not showing in gallery**
|
||||||
→ Verify files were uploaded (check the entry folder in Admin → Media). Only jpg, jpeg, png, webp, gif are rendered.
|
→ Verify files were uploaded (check the entry folder in Admin → Media). Only jpg, jpeg, png, webp, gif are rendered.
|
||||||
|
|
||||||
|
**Submit button does nothing**
|
||||||
|
→ Check you have at least one photo attached, and that every upload has finished. The form blocks
|
||||||
|
submit while an upload is still in flight, and requires 1–6 photos.
|
||||||
|
|
||||||
**500 error after posting**
|
**500 error after posting**
|
||||||
→ Run `make fix-perms` to restore container file ownership.
|
→ Run `make fix-perms` to restore container file ownership.
|
||||||
|
|
||||||
|
**Edits create a new entry instead of updating**
|
||||||
|
→ The hidden `edit_path` was empty, so `overwrite_mode` fell back to `false`. Re-enter via the entry's
|
||||||
|
Edit action rather than opening `/post` directly.
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
# Switching to a New Trip
|
# Switching to a New Trip
|
||||||
|
|
||||||
When you start a new trip, **two files must be updated together** — if only one is changed, new entries will be posted to the wrong folder silently (no error, wrong trip).
|
The active trip lives in **one** place: `user/config/site.yaml` → `active_trip`. Set it, create the new page tree, push.
|
||||||
|
|
||||||
|
> **Changed 2026-07:** this used to require editing two files in lockstep (`site.yaml` **and** `post-form.md` → `pageconfig.parent`), and they silently desynced. The `cache-on-save` plugin now derives the write target from `site.active_trip` at submit time (`onFormValidationProcessed` → `setData('parent', …)`), so `post-form.md` no longer carries a `parent` at all. **Do not re-add one** — it would override the derived target and reintroduce the desync.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
- [ ] Update `user/config/site.yaml` → `active_trip`
|
- [ ] Update `user/config/site.yaml` → `active_trip`
|
||||||
- [ ] Update `user/pages/02.post/post-form.md` → `pageconfig.parent`
|
|
||||||
- [ ] Create the new trip page tree (see below)
|
- [ ] Create the new trip page tree (see below)
|
||||||
- [ ] Run `make content-push` to push the changes to production
|
- [ ] Run `make content-push` to push the changes to production
|
||||||
|
|
||||||
@@ -15,38 +16,28 @@ When you start a new trip, **two files must be updated together** — if only on
|
|||||||
|
|
||||||
## Step 1 — Update site.yaml
|
## Step 1 — Update site.yaml
|
||||||
|
|
||||||
In `user/config/site.yaml`, set `active_trip` to the new trip slug:
|
In `user/config/site.yaml`, set `active_trip` to the new trip's **route**:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
active_trip: japan-korea-2026 # ← change this
|
active_trip: /trips/denmark-2026 # ← change this
|
||||||
```
|
```
|
||||||
|
|
||||||
The slug must exactly match the folder name under `user/pages/01.trips/`.
|
The final segment must exactly match the folder name under `user/pages/01.trips/`.
|
||||||
|
|
||||||
|
You can also set this from Admin → Configuration → Site → **Active Trip** (a page-picker rooted at `/trips`; blueprint at `user/blueprints/config/site.yaml`).
|
||||||
|
|
||||||
|
> `system.yaml` → `home.alias` is permanently `/home` (the real home page) and does **not** change when switching trips.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 2 — Update post-form.md
|
## Step 2 — Create the new trip page tree
|
||||||
|
|
||||||
In `user/pages/02.post/post-form.md`, set `pageconfig.parent` to the new dailies path:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
pageconfig:
|
|
||||||
parent: /trips/japan-korea-2026/dailies # ← change this
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why both?** Grav's config and page frontmatter are static YAML — no variable substitution is possible, so `post-form.md` can't read from `site.yaml` automatically. They must match manually.
|
|
||||||
|
|
||||||
**What breaks if they're out of sync:** `active_trip` controls which trip page is featured on the home page and trip page. `pageconfig.parent` controls where new entries land. If they differ, new posts go to the old trip's dailies folder while the home page shows the new trip — entries appear to vanish.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 3 — Create the new trip page tree
|
|
||||||
|
|
||||||
Create the two content subfolders under `user/pages/01.trips/<new-slug>/`:
|
Create the two content subfolders under `user/pages/01.trips/<new-slug>/`:
|
||||||
|
|
||||||
```
|
```
|
||||||
user/pages/01.trips/japan-korea-2026/
|
user/pages/01.trips/denmark-2026/
|
||||||
├─ trip.md ← title, date_start, date_end, cover_image, album_url
|
├─ trip.md ← title, date_start, date_end, cover_image, album_url
|
||||||
|
├─ *.gpx ← route files (optional; page media, auto-detected)
|
||||||
├─ 01.dailies/
|
├─ 01.dailies/
|
||||||
│ └─ dailies.md ← inert container: template: default, routable: false, visible: false
|
│ └─ dailies.md ← inert container: template: default, routable: false, visible: false
|
||||||
└─ 04.stories/
|
└─ 04.stories/
|
||||||
@@ -55,13 +46,13 @@ user/pages/01.trips/japan-korea-2026/
|
|||||||
|
|
||||||
Copy these files from an existing trip and update the frontmatter (especially `title` and `date_start` in `trip.md`).
|
Copy these files from an existing trip and update the frontmatter (especially `title` and `date_start` in `trip.md`).
|
||||||
|
|
||||||
> The `02.map/` and `03.stats/` standalone views were retired (2026-07-04) — the map and stats render inline on the trip page. The `01.dailies/` and `04.stories/` folders now exist only as data containers holding the entry/story children; their own routes are non-routable.
|
> The `02.map/` and `03.stats/` standalone views were retired (2026-07-04) — the map and stats render inline on the trip page. The `01.dailies/` and `04.stories/` folders now exist only as data containers holding the entry/story children; their own routes are non-routable. Do **not** recreate `02.map/` or `03.stats/`.
|
||||||
|
|
||||||
Fields in `trip.md` to update:
|
Fields in `trip.md` to update:
|
||||||
|
|
||||||
| Field | Example | Notes |
|
| Field | Example | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `title` | `Japan & Korea 2026` | Displayed in nav and trip header |
|
| `title` | `Denmark 2026` | Displayed in nav and trip header |
|
||||||
| `date_start` | `2026-07-15` | Used for "X days on the road" stat |
|
| `date_start` | `2026-07-15` | Used for "X days on the road" stat |
|
||||||
| `date_end` | *(leave blank while travelling)* | Set when you return |
|
| `date_end` | *(leave blank while travelling)* | Set when you return |
|
||||||
| `cover_image` | `cover.jpg` | Shown on the trips listing page |
|
| `cover_image` | `cover.jpg` | Shown on the trips listing page |
|
||||||
@@ -69,7 +60,7 @@ Fields in `trip.md` to update:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 4 — Push
|
## Step 3 — Push
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make content-push
|
make content-push
|
||||||
|
|||||||
+125
-24
@@ -8,13 +8,14 @@ How the intotheeast site hangs together.
|
|||||||
|
|
||||||
| Layer | Technology | Notes |
|
| Layer | Technology | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| CMS | Grav 2.0.4 stable | Flat-file PHP CMS; no database. Server upgrades in place via `bin/gpm self-upgrade` |
|
| CMS | Grav 2.0.7 stable | Flat-file PHP CMS; no database. Server upgrades in place via `bin/gpm self-upgrade` |
|
||||||
| Admin | Admin2 v2.0.10 | Plugin slug: `admin2` (not `admin`) |
|
| Admin | Admin2 v2.0.12 | Plugin slug: `admin2` (not `admin`) |
|
||||||
| GPM channel | `stable` | Authoritative in `user/config/system.yaml` → `gpm.releases`; `GRAV_CHANNEL=production` in compose is cosmetic |
|
| GPM channel | `stable` | Authoritative in `user/config/system.yaml` → `gpm.releases`; `GRAV_CHANNEL=production` in compose is cosmetic |
|
||||||
| Container | Docker (`getgrav/grav` base + custom `Dockerfile`) | Grav 2.0 baked in at build time |
|
| Container | Docker (`getgrav/grav` base + custom `Dockerfile`) | Grav 2.0 baked in at build time |
|
||||||
| PHP session | `session.save_path = /tmp` | Set in `php/php-local.ini` |
|
| PHP session | `session.save_path = /tmp` | Set in `php/php-local.ini` |
|
||||||
| Dev URL | http://localhost:8081 | Mapped from container port 80 |
|
| Dev URL | http://localhost:8081 | Mapped from container port 80 |
|
||||||
| Maps | MapLibre GL JS | Replaced Leaflet; one shared map path (`MapUtils.initEntryMap`) on trip + home |
|
| Maps | MapLibre GL JS | Replaced Leaflet. One shared *display* path (`MapUtils.initEntryMap`) on trip + home, plus one sanctioned *editor* (`js/src/location-map.js`) for the `/post` pin picker |
|
||||||
|
| Basemap | CartoDB dark-matter | Style URL single-sourced as `MAP_STYLE` in `js/src/map-style.js`, imported by both map paths so they cannot drift |
|
||||||
| GPX rendering | toGeoJSON (bundled in `js/map.js`) | Parses GPX → GeoJSON route layers client-side; no CDN |
|
| GPX rendering | toGeoJSON (bundled in `js/map.js`) | Parses GPX → GeoJSON route layers client-side; no CDN |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -29,15 +30,19 @@ Browser POST /post
|
|||||||
├─ Grav Form plugin (built-in)
|
├─ Grav Form plugin (built-in)
|
||||||
│ └─ validates required fields; handles file uploads
|
│ └─ validates required fields; handles file uploads
|
||||||
│
|
│
|
||||||
├─ add-page-by-form (third-party, patched)
|
├─ cache-on-save (custom) — onFormValidationProcessed, runs BEFORE the write
|
||||||
|
│ ├─ setData('parent', …) ← derived from site.active_trip
|
||||||
|
│ └─ sets pageconfig.overwrite_mode: edit when the hidden edit_path is filled,
|
||||||
|
│ false when empty (create a fresh dated folder)
|
||||||
|
│
|
||||||
|
├─ add-page-by-form (third-party, patched — see deploy/patches/)
|
||||||
│ └─ reads post-form.md config:
|
│ └─ reads post-form.md config:
|
||||||
│ ├─ pageconfig.parent → target folder (e.g. /trips/japan-korea-2026/dailies)
|
|
||||||
│ ├─ pageconfig.slug_field → slug from date + title
|
│ ├─ pageconfig.slug_field → slug from date + title
|
||||||
│ └─ pagefrontmatter → template: entry, published: true
|
│ └─ pagefrontmatter → template: entry
|
||||||
│ └─ writes entry.md to user/pages/01.trips/<trip>/01.dailies/<slug>.entry/
|
│ └─ writes entry.md to user/pages/01.trips/<trip>/01.dailies/<slug>.entry/
|
||||||
│ └─ moves uploaded photos into the page folder
|
│ └─ moves uploaded photos into the page folder
|
||||||
│
|
│
|
||||||
└─ cache-on-save (custom, user/plugins/cache-on-save/)
|
└─ cache-on-save (again, post-write)
|
||||||
└─ calls $grav['cache']->deleteAll() on every new-entry form submission
|
└─ calls $grav['cache']->deleteAll() on every new-entry form submission
|
||||||
└─ ensures entries appear in feed immediately in both dev and prod mode
|
└─ ensures entries appear in feed immediately in both dev and prod mode
|
||||||
```
|
```
|
||||||
@@ -49,45 +54,135 @@ Other notable plugins:
|
|||||||
| `login` | Auth for /post and /gpx-manager |
|
| `login` | Auth for /post and /gpx-manager |
|
||||||
| `api` (Grav API v1) | Used by /gpx-manager to list/upload/delete GPX files |
|
| `api` (Grav API v1) | Used by /gpx-manager to list/upload/delete GPX files |
|
||||||
| `admin2` | Admin panel at /admin |
|
| `admin2` | Admin panel at /admin |
|
||||||
|
| `story-blocks` (custom) | Storytelling shortcode blocks for long-form stories (needs `shortcode-core`) |
|
||||||
|
| `entry-actions` (custom) | Owner-only, active-trip-scoped actions via the Grav API. Three routes: `DELETE /entry/{slug}`, `POST /entry/{slug}/photos/order`, `POST /trip/{slug}/publish`. Exists because stock `DELETE /api/v1/pages<route>` checks only write-permission (no trip scoping) and cannot renumber media to the `photo-NN` cover order |
|
||||||
|
|
||||||
### Plugin management model
|
### Plugin management model
|
||||||
|
|
||||||
Three categories, by how each plugin is installed and maintained:
|
Three categories, by how each plugin is installed and maintained:
|
||||||
|
|
||||||
1. **GPM-managed** (`plugins.txt` → `make install-plugins`): the marketplace plugins, including `login`, `form`, `admin2`, `api`, `flex-objects`, shortcodes, etc. As of the 2.0.4 upgrade, `admin2`/`api`/`flex-objects` moved into this category — they were previously hand-extracted from the core bundle. Update with `bin/gpm update` (`make remote-update-plugins-<env>` on servers).
|
1. **GPM-managed** (`plugins.txt` → `make install-plugins`): the marketplace plugins, including `login`, `form`, `admin2`, `api`, `flex-objects`, shortcodes, etc. As of the 2.0.4 upgrade, `admin2`/`api`/`flex-objects` moved into this category — they were previously hand-extracted from the core bundle. Update with `bin/gpm update` (`make remote-update-plugins-<env>` on servers).
|
||||||
2. **Custom, in-repo** (`user/plugins/` allowlisted in `user/.gitignore`): `cache-on-save`, `story-blocks`. Versioned in the user repo.
|
2. **Custom, in-repo** (`user/plugins/` allowlisted in `user/.gitignore`): `cache-on-save`, `story-blocks`, `entry-actions`. Versioned in the user repo.
|
||||||
3. **Remote-only**: `git-sync` — installed and configured only on servers, **never** in `plugins.txt`, and disabled during upgrades.
|
3. **Remote-only**: `git-sync` — installed and configured only on servers, **never** in `plugins.txt`, and disabled during upgrades.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Asset pipeline
|
||||||
|
|
||||||
|
`make build-assets` runs the theme's `npm run build` (esbuild) in a throwaway `node:20-alpine` container, as the host uid so outputs land in the tracked theme tree owned by you rather than root.
|
||||||
|
|
||||||
|
| Source | → Output |
|
||||||
|
|---|---|
|
||||||
|
| `js/src/main.js` | `js/main.js` + `css-compiled/main.css` + `fonts/` (font files via the `woff2` loader) |
|
||||||
|
| `js/src/map.js` | `js/map.js` + `css-compiled/map.css` — bundles `maplibre-gl`, `@mapbox/togeojson`, and `js/maplibre-utils.js` |
|
||||||
|
| `js/src/feed-actions.js` | `js/feed-actions.js` |
|
||||||
|
| `js/src/trip-publish.js` | `js/trip-publish.js` |
|
||||||
|
| `js/src/post-form.js` | `js/post/` (ESM + code splitting) + `css-compiled/post-form.css` — also pulls in `location-map.js` and `map-style.js` |
|
||||||
|
| `node_modules/maplibre-gl/dist/maplibre-gl.css` | `css-compiled/maplibre-gl.css` — built standalone so `location-map.js` can inject it on demand without a static import defeating its lazy load |
|
||||||
|
| `scripts/gen-weather-icons.js` | `templates/partials/weather-icons.html.twig` (Lucide SVGs inlined into a Twig map) |
|
||||||
|
|
||||||
|
The table lists esbuild **entry points**. Other files in `js/src/` (`api-utils.js`, `location-map.js`, `map-style.js`, `post-form.css`) are sources too — they are imported into a bundle rather than being built directly.
|
||||||
|
|
||||||
|
**The trap:** `js/` holds both bundles *and* hand-authored sources. `js/maplibre-utils.js` (the `MapUtils` map engine, a plain IIFE imported by `js/src/map.js`) and `js/nav.js` are sources despite sitting beside the minified bundles.
|
||||||
|
|
||||||
|
**The second trap:** `css/` is **not** the source of `css-compiled/`. `css/style.css` and `css/tokens.css` are hand-authored and served *directly* via `assets.addCss('theme://css/…')` in `partials/base.html.twig` — they are never compiled. `css-compiled/` is esbuild output from the CSS imports inside `js/src/*.js` (fontsource + PhotoSwipe → `main.css`; maplibre → `map.css`) plus the standalone maplibre build above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Template hierarchy
|
## Template hierarchy
|
||||||
|
|
||||||
All page templates extend `base.html.twig`:
|
All page templates extend `base.html.twig`:
|
||||||
|
|
||||||
```
|
```
|
||||||
templates/
|
templates/
|
||||||
├─ base.html.twig ← site shell: nav, fonts, CSS tokens
|
|
||||||
├─ default.html.twig ← extends base; generic page
|
├─ default.html.twig ← extends base; generic page
|
||||||
├─ home.html.twig ← extends base; context-aware two-column layout
|
├─ home.html.twig ← extends base; context-aware two-column layout
|
||||||
|
├─ trips.html.twig ← extends base; trip list (with the owner's publish toggle)
|
||||||
├─ trip.html.twig ← extends base; trip page with filter bar (All/Journal/Stories)
|
├─ trip.html.twig ← extends base; trip page with filter bar (All/Journal/Stories)
|
||||||
├─ entry.html.twig ← extends base; single journal entry (gallery, badges, map)
|
├─ entry.html.twig ← extends base; single journal entry (gallery, badges, map)
|
||||||
├─ story.html.twig ← extends base; single story (Ken Burns hero, shortcodes)
|
├─ story.html.twig ← extends base; single story (Ken Burns hero, shortcodes)
|
||||||
└─ gpx-manager.html.twig ← extends base; admin UI for GPX file management
|
├─ post-form.html.twig ← extends base; the /post journal form
|
||||||
|
├─ gpx-manager.html.twig ← extends base; admin UI for GPX file management
|
||||||
|
├─ forms/ ← field overrides (e.g. forms/fields/datetime/datetime.html.twig)
|
||||||
|
├─ macros/ ← cover, cycling, date-range, stats
|
||||||
|
└─ partials/ ← base.html.twig lives HERE, not at templates/ root
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**`base.html.twig` is a partial** (`templates/partials/base.html.twig`), despite being the shell every page template extends.
|
||||||
|
|
||||||
The standalone `dailies.html.twig`, `map.html.twig`, `stats.html.twig` and `stories.html.twig` view templates were **removed** in the 2026-07-04 standalone-page cleanup — the trip page (`trip.html.twig`) consolidated the feed, inline map, and inline stats.
|
The standalone `dailies.html.twig`, `map.html.twig`, `stats.html.twig` and `stories.html.twig` view templates were **removed** in the 2026-07-04 standalone-page cleanup — the trip page (`trip.html.twig`) consolidated the feed, inline map, and inline stats.
|
||||||
|
|
||||||
Partials live in `templates/partials/` (plus macros in `templates/macros/`). Key partials: `base.html.twig` (site shell extended by all page templates), `entry-map.html.twig` (shared map column + `initEntryMap` call, used by trip + home), `trip-feed-col.html.twig` (feed column chrome, shared by trip + home), `home-predeparture.html.twig`, `entry-journal.html.twig` / `entry-story.html.twig` (feed cards), and `weather-icons.html.twig`.
|
Site nav (in `partials/base.html.twig`) is deliberately minimal — **Home + Trips**, plus **New Post** when `grav.user.authenticated`. It does not link to trip sub-sections, because those standalone views no longer exist.
|
||||||
|
|
||||||
|
Partials live in `templates/partials/` (plus macros in `templates/macros/`). Key partials: `base.html.twig` (site shell extended by all page templates), `entry-map.html.twig` (shared map column + `initEntryMap` call, used by trip + home), `trip-feed-col.html.twig` (feed column chrome, shared by trip + home), `home-predeparture.html.twig`, `entry-journal.html.twig` / `entry-story.html.twig` (feed cards), `trip-publish-toggle.html.twig`, and `weather-icons.html.twig`.
|
||||||
|
|
||||||
|
### Shared partial contracts
|
||||||
|
|
||||||
|
Two partials are included by **both** `trip.html.twig` and the active branch of `home.html.twig`, via `{% include … with {…} only %}`. The `only` keyword means every value must be passed explicitly — the tables below are the contracts. The rules that govern them (single map path, required map globals, never hand-edit bundles) live in `CLAUDE.md`; these are the parameter details.
|
||||||
|
|
||||||
|
#### `entry-map.html.twig`
|
||||||
|
|
||||||
|
Renders the `.home-map-col` column (map div `#{{ map_id }}` + fullscreen button) and, when `entries` is non-empty, a thin `<script>` assigning `window.{{ map_global }}` from `initEntryMap`. Callers resolve header values (use_gpx / autoconnect) and pass them in.
|
||||||
|
|
||||||
|
| Parameter | Type | Trip passes | Home passes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `map_id` | string | `'trip-map'` | `'home-map'` |
|
||||||
|
| `map_global` | string | `'tripMap'` | `'homeMap'` |
|
||||||
|
| `entries` | array | `[{lat, lng, slug, title, url, type?, force_connect, ...}]` | same |
|
||||||
|
| `card_prefix` | string | `'entry-'` | `'entry-'` |
|
||||||
|
| `story_markers` | bool | `true` (diamond markers) | `false` |
|
||||||
|
| `gpx_urls` | array | `gpx_urls` | `home_gpx_urls` |
|
||||||
|
| `use_gpx` | bool | `page.header.use_gpx ?? true` | derived from `trip.header` |
|
||||||
|
| `autoconnect` | string | `page.header.autoconnect ?? 'on'` | derived from `trip.header` |
|
||||||
|
| `gpx_source_prefix` | string | `'gpx'` | `'home-gpx'` |
|
||||||
|
| `journey_id` | string | `'trip-journey'` | `'home-journey'` |
|
||||||
|
|
||||||
|
#### `trip-feed-col.html.twig`
|
||||||
|
|
||||||
|
The column **beside** the map: date-range header, filter bar, stats/cycling panels, feed loop.
|
||||||
|
|
||||||
|
| Parameter | Type | Trip passes | Home-active passes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `trip_page` | Page | `page` | `trip` |
|
||||||
|
| `all_items` | array | sorted by date, flag 4 (oldest→newest) | sorted by date, flag 3 (newest→oldest) |
|
||||||
|
| `journal_entries` | array | dailies children | dailies children |
|
||||||
|
| `journal_count` / `story_count` | int | counts | counts |
|
||||||
|
| `has_gpx` | bool | `has_gpx` | `home_gpx_urls\|length > 0` |
|
||||||
|
| `gpx_urls` | array | `gpx_urls` | `home_gpx_urls` |
|
||||||
|
| `gps_points` | array | `gps_points` | `gps_points` |
|
||||||
|
| `show_sort` | bool | `true` | `false` (home keeps its own feed order) |
|
||||||
|
| `trip_header_extras` | bool | `true` | not passed (defaults `false`) |
|
||||||
|
|
||||||
|
`trip_header_extras` gates the trip-page-only header block (one-liner `.home-trip-tagline`, expandable `.trip-header-desc`, `.trip-header-banner` cover strip) rendered between the counts and the filter bar. `home.html.twig` omits it so those extras never leak onto the home route.
|
||||||
|
|
||||||
|
**Sibling:** `home-predeparture.html.twig` is the home-only "Coming soon" landing state, taking only `trip_page`. `home.html.twig` picks it with `{% if all_items|length == 0 %}` → `home-predeparture` `{% else %}` → `trip-feed-col`. Keep `trip-feed-col` single-purpose — do **not** fold the pre-departure branch back into it.
|
||||||
|
|
||||||
|
**Stats/cycling JS glue:** the partial emits an inline `DOMContentLoaded` script calling `window.initTripStats({ gpxUrls, gpsPoints, hasGpx })` — one shared function in `js/src/main.js`. It no-ops when `#stat-distance` is absent, populates exact distance + cycling stats from GPX, and falls back to a `~`-prefixed haversine estimate (or `—` for `<2` points) when there is no GPX. It depends on `window.MapUtils` from `map.js` (loaded in the `bottom` asset group on both pages).
|
||||||
|
|
||||||
|
> History: the map setup replaced an older three-variant arrangement (a `feed-map.html.twig` partial with its own inline init, plus a full-page `map.html.twig`), deleted in the 2026-07-04 standalone-page cleanup. See [`superseded-decisions.md`](superseded-decisions.md) → R12.
|
||||||
|
|
||||||
|
#### The one non-`entry-map` map: the `/post` pin editor
|
||||||
|
|
||||||
|
`js/src/location-map.js` (`getOrCreateLocationMap()`) is a deliberately separate, minimal engine for the post form's "More location details" panel — **an editor, not a display map**, so it shares none of `initEntryMap`'s concerns:
|
||||||
|
|
||||||
|
| | `initEntryMap` (display) | `location-map.js` (editor) |
|
||||||
|
|---|---|---|
|
||||||
|
| Markers | many, from entries | exactly one, **draggable** |
|
||||||
|
| Popups / GPX / bounds-fitting | yes | none |
|
||||||
|
| `maplibre-gl` | bundled into `js/map.js` | **lazy-imported** on first open, so a GPS-only submit never fetches it |
|
||||||
|
| Stylesheet | via `js/src/map.js`'s CSS import | injects `css-compiled/maplibre-gl.css` on demand (a static import would defeat the lazy load) |
|
||||||
|
|
||||||
|
The two share exactly one thing: `MAP_STYLE` from `js/src/map-style.js`. Adding a *third* map path is forbidden — see `CLAUDE.md`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Trip entity structure
|
## Trip entity structure
|
||||||
|
|
||||||
The site is organized around Trip entities. The active trip is set in `user/config/site.yaml` → `active_trip`.
|
The site is organized around Trip entities. The active trip is set in `user/config/site.yaml` → `active_trip`, as a **route** (e.g. `/trips/denmark-2026`), not a bare slug.
|
||||||
|
|
||||||
```
|
```
|
||||||
user/pages/01.trips/
|
user/pages/01.trips/
|
||||||
└─ japan-korea-2026/
|
└─ denmark-2026/
|
||||||
├─ trip.md ← template: trip; title, date_start, cover_image, album_url
|
├─ trip.md ← template: trip; title, date_start, cover_image, album_url
|
||||||
├─ *.gpx ← GPX route files (served as page media; auto-detected by trip.html.twig)
|
├─ *.gpx ← GPX route files (served as page media; auto-detected by trip.html.twig)
|
||||||
├─ 01.dailies/ ← journal entry children (container .md is routable:false)
|
├─ 01.dailies/ ← journal entry children (container .md is routable:false)
|
||||||
@@ -126,18 +221,20 @@ Rendered as route polyline on map
|
|||||||
```
|
```
|
||||||
1. User fills /post form and taps Submit
|
1. User fills /post form and taps Submit
|
||||||
2. Grav Form plugin validates: title and content required
|
2. Grav Form plugin validates: title and content required
|
||||||
3. add-page-by-form reads post-form.md:
|
3. cache-on-save (onFormValidationProcessed) injects the write target:
|
||||||
pageconfig.parent: /trips/japan-korea-2026/dailies
|
parent ← derived from site.active_trip (e.g. /trips/denmark-2026/dailies)
|
||||||
pageconfig.slug: {date}-{title|slugify}
|
overwrite_mode ← edit if edit_path filled, else false
|
||||||
pagefrontmatter: template: entry, published: true
|
4. add-page-by-form reads post-form.md:
|
||||||
4. New page written to:
|
pageconfig.slug_field: date,title
|
||||||
user/pages/01.trips/japan-korea-2026/01.dailies/
|
pagefrontmatter: template: entry
|
||||||
|
5. New page written to:
|
||||||
|
user/pages/01.trips/denmark-2026/01.dailies/
|
||||||
└─ 2026-07-20-0930-first-day-in-kyoto.entry/
|
└─ 2026-07-20-0930-first-day-in-kyoto.entry/
|
||||||
└─ entry.md
|
└─ entry.md
|
||||||
5. Photos moved into the same folder
|
6. Photos moved into the same folder
|
||||||
6. cache-on-save calls $grav['cache']->deleteAll()
|
7. cache-on-save calls $grav['cache']->deleteAll()
|
||||||
7. Browser: form shows success message
|
8. Browser: form shows success message
|
||||||
8. Feed at /trips/japan-korea-2026 immediately shows new entry
|
9. Feed at /trips/denmark-2026 immediately shows new entry
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -146,9 +243,13 @@ Rendered as route polyline on map
|
|||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `user/config/site.yaml` | `active_trip` slug; site title/description |
|
| `user/config/site.yaml` | `active_trip` route; site title/description |
|
||||||
| `user/config/system.yaml` | Twig cache, flex accounts/pages, language prefix |
|
| `user/config/system.yaml` | Twig cache, flex accounts/pages, language prefix |
|
||||||
| `user/config/media.yaml` | Registers `.gpx` as a valid media type |
|
| `user/config/media.yaml` | Registers `.gpx` as a valid media type |
|
||||||
| `user/plugins/api/api.yaml` | `session_enabled: true` for GPX manager auth |
|
| `user/plugins/api/api.yaml` | `session_enabled: true` for GPX manager auth |
|
||||||
| `user/themes/intotheeast/css/tokens.css` | Design tokens (colors, fonts, spacing) |
|
| `user/themes/intotheeast/css/tokens.css` | Design tokens (colors, fonts, spacing) |
|
||||||
| `CLAUDE.md` | Project rules and always-loaded context for Claude |
|
| `CLAUDE.md` | Project rules and always-loaded context for Claude |
|
||||||
|
|
||||||
|
### What the `user/` repo tracks
|
||||||
|
|
||||||
|
Only `pages/`, `config/`, `accounts/`, and `themes/` are versioned in the content repo. `plugins/` and `data/` are ignored — **except** the three custom plugins, un-ignored explicitly in `user/.gitignore`. Also ignored: the test accounts, the demo-trip pages, secrets (`config/plugins/git-sync.yaml`, `config/security.yaml`, `api-private.php`), and the whole `env/` override tree. Read `user/.gitignore` for the authoritative list.
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
# Design System — Light Mode Color Palette
|
# Design System — Light Mode Color Palette
|
||||||
|
|
||||||
Light-mode counterpart to `design-system.md`. Only color tokens differ between themes — typography, spacing, radius, shadows, and layout are identical.
|
> **Superseded — light mode is not implemented, and this palette is not in the code.**
|
||||||
|
>
|
||||||
|
> The site is **dark only**. `css/tokens.css` has a single `:root` block; there is no
|
||||||
|
> `prefers-color-scheme` query, no `data-theme` switch, and none of the light hex values below appear
|
||||||
|
> anywhere in `css/`. Dark mode shipped as *the* theme rather than as one of two
|
||||||
|
> (`../working/plans/2026-06-19-dark-mode.md`, 2026-06-20).
|
||||||
|
>
|
||||||
|
> Keep this file as the record of the pre-dark-mode palette and as the starting point if a light
|
||||||
|
> theme is ever built — but do not read the "Light" column as describing the running site. See
|
||||||
|
> [`superseded-decisions.md`](superseded-decisions.md) → R9.
|
||||||
|
|
||||||
|
Light-mode counterpart to `design-system.md`, as originally specified. Only color tokens were intended to differ between themes — typography, spacing, radius, shadows, and layout are identical.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,13 @@
|
|||||||
|
|
||||||
### Palette (dark theme — as implemented)
|
### Palette (dark theme — as implemented)
|
||||||
|
|
||||||
|
**Dark is the only theme.** `css/tokens.css` has a single `:root` block; there is no
|
||||||
|
`prefers-color-scheme` query and no `data-theme` switch. `design-system-light.md` records the
|
||||||
|
pre-dark-mode palette, which was never implemented as a switchable theme — see
|
||||||
|
[`superseded-decisions.md`](superseded-decisions.md) → R9.
|
||||||
|
|
||||||
|
The authoritative list is `user/themes/intotheeast/css/tokens.css`.
|
||||||
|
|
||||||
| Token | Hex | Usage |
|
| Token | Hex | Usage |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `--color-paper` | `#1A1814` | Page background — warm near-black |
|
| `--color-paper` | `#1A1814` | Page background — warm near-black |
|
||||||
@@ -46,6 +53,20 @@
|
|||||||
| `--color-accent-on` | `#FFFFFF` | Text on accent surfaces |
|
| `--color-accent-on` | `#FFFFFF` | Text on accent surfaces |
|
||||||
| `--color-surface-raised` | `#2A2720` | Elevated surfaces: tooltips, hover |
|
| `--color-surface-raised` | `#2A2720` | Elevated surfaces: tooltips, hover |
|
||||||
| `--color-ink-inverse` | `#17171A` | Text on accent-coloured buttons |
|
| `--color-ink-inverse` | `#17171A` | Text on accent-coloured buttons |
|
||||||
|
| `--color-error` | `#c0392b` | Validation errors, form error status |
|
||||||
|
| `--color-draft-accent` | `#E0A458` | Warm amber — draft/unpublished badges |
|
||||||
|
|
||||||
|
#### Glass overlays
|
||||||
|
|
||||||
|
Paper colour at opacity, used by the story components. Computed with `color-mix()` rather than fixed
|
||||||
|
hex, so they track `--color-paper` automatically.
|
||||||
|
|
||||||
|
| Token | Value | Usage |
|
||||||
|
|---|---|---|
|
||||||
|
| `--color-paper-glass-low` | `color-mix(in srgb, var(--color-paper) 8%, transparent)` | Faintest scrim |
|
||||||
|
| `--color-paper-glass-mid` | `color-mix(in srgb, var(--color-paper) 25%, transparent)` | Standard overlay |
|
||||||
|
| `--color-paper-glass-high` | `color-mix(in srgb, var(--color-paper) 55%, transparent)` | Heavy scrim over imagery |
|
||||||
|
| `--color-paper-glass-hover` | `color-mix(in srgb, var(--color-paper) 80%, transparent)` | Hover state on a glass surface |
|
||||||
|
|
||||||
### Rationale for accent color
|
### Rationale for accent color
|
||||||
|
|
||||||
@@ -150,7 +171,7 @@ DM Serif Display has a calligraphic quality — slightly editorial, authoritativ
|
|||||||
- Nav links: DM Sans, `--text-sm`, weight 500, `--color-ink-2`
|
- Nav links: DM Sans, `--text-sm`, weight 500, `--color-ink-2`
|
||||||
- Active nav link: `--color-accent`, weight 600
|
- Active nav link: `--color-accent`, weight 600
|
||||||
- Mobile: same layout, title slightly smaller, nav links compact
|
- Mobile: same layout, title slightly smaller, nav links compact
|
||||||
- Background: `--color-canvas` (white), bottom border `1px solid var(--color-border)`
|
- Background: `--color-canvas` (`#22201B` in the dark theme), bottom border `1px solid var(--color-border)`
|
||||||
|
|
||||||
### 5.2 Entry Feed Card — With Photo
|
### 5.2 Entry Feed Card — With Photo
|
||||||
|
|
||||||
@@ -342,7 +363,7 @@ Minimal changes — the map itself is good. Style improvements:
|
|||||||
| JS | Vanilla JS — unchanged | Current JS is well-structured, scope doesn't justify a framework |
|
| JS | Vanilla JS — unchanged | Current JS is well-structured, scope doesn't justify a framework |
|
||||||
| Icons | Unicode + emoji (current) | No dependency, works everywhere |
|
| Icons | Unicode + emoji (current) | No dependency, works everywhere |
|
||||||
| Fonts | Google Fonts via CDN | Two fonts, display-swap, negligible impact |
|
| Fonts | Google Fonts via CDN | Two fonts, display-swap, negligible impact |
|
||||||
| Maps | MapLibre GL JS | Replaced Leaflet; all 3 map templates use it |
|
| Maps | MapLibre GL JS | Replaced Leaflet. One shared display-map partial (`partials/entry-map.html.twig`), not three templates — see [`superseded-decisions.md`](superseded-decisions.md) → R12 |
|
||||||
| Build | None — no build pipeline | Grav's asset pipeline handles minification if needed |
|
| Build | None — no build pipeline | Grav's asset pipeline handles minification if needed |
|
||||||
|
|
||||||
**No Alpine.js, no TypeScript, no Tailwind.** The site has clean vanilla JS and CSS today; a redesign is about visual quality, not framework migration. Introducing a build pipeline on a 3-week timeline is a distraction.
|
**No Alpine.js, no TypeScript, no Tailwind.** The site has clean vanilla JS and CSS today; a redesign is about visual quality, not framework migration. Introducing a build pipeline on a 3-week timeline is a distraction.
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Superseded decisions
|
||||||
|
|
||||||
|
Things this project planned, built, and then deliberately reversed. One row per reversal.
|
||||||
|
|
||||||
|
**Why this file exists.** The plans and milestones under `docs/working/` are historical records — they
|
||||||
|
say what was decided *then*, and they are correct as records. But a reader who opens
|
||||||
|
`milestones/milestone-2.md` finds a confident present-tense description of a Leaflet `/map` page that
|
||||||
|
has not existed since 2026-07-04. This file is the changelog of "what did we change our mind about",
|
||||||
|
so that question has one answer instead of requiring a re-derivation from the code.
|
||||||
|
|
||||||
|
**How to use it.** Each superseded section in the old docs carries a `> **Superseded …**` note
|
||||||
|
pointing back here. If you are about to re-create something you found in an old plan, check here
|
||||||
|
first — the reversal is usually deliberate, and several are load-bearing rules in
|
||||||
|
[`CLAUDE.md`](../../CLAUDE.md).
|
||||||
|
|
||||||
|
**Keep it current.** When a decision is reversed, add a row *in the same commit as the reversal*. A
|
||||||
|
ledger that lags is worse than no ledger, because it is trusted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The reversals
|
||||||
|
|
||||||
|
| # | Originally planned | Planned in | True now | Changed | Why |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| R1 | A standalone `/map` page — full-height Leaflet map, marker per entry, popups | `milestones/milestone-2.md` (whole doc); `summary.md` | No `/map` route. The map renders **inline on the trip page** through the single shared `partials/entry-map.html.twig` | 2026-07-04 | `plans/2026-07-04-standalone-page-cleanup.md`. One consolidated trip page beat four thin views; a separate map page meant a second map implementation to keep in sync |
|
||||||
|
| R2 | A standalone `/stats` page — days on the road, entries, countries, distance | `milestones/milestone-3.md` (whole doc); `summary.md` | No `/stats` route. Stats render **inline on the trip page** behind a toggle, via `initTripStats()` | 2026-07-04 | Same cleanup. The numbers are trip context, not a destination |
|
||||||
|
| R3 | A `/tracker` feed route as the entry list | `milestones/milestone-1.md` §1.6; `milestone-2.md`; `milestone-3.md`; `summary.md` | No `/tracker`. The feed is the **trip page** plus the home active-trip view, sharing `partials/trip-feed-col.html.twig` | Restructured 2026-06-19 (`plans/2026-06-19-trip-entity.md`), fully retired 2026-07-04 | The Trip entity became the organising unit, so a global tracker had nothing to track |
|
||||||
|
| R4 | Leaflet.js with OpenStreetMap tiles | `milestone-2.md`; `milestone-4.md`; `summary.md`; `pm-analysis.md` | **MapLibre GL JS**, CartoDB dark-matter basemap. Style URL is single-sourced as `MAP_STYLE` in `js/src/map-style.js` | 2026-06-20 | `plans/2026-06-19-maplibre-migration.md`. Vector tiles, GPU rendering, and a dark basemap that suits the dark theme |
|
||||||
|
| R5 | A standalone `/dailies` journal view and `/stories` story view | `plans/2026-06-19-trip-entity.md` era | Both routes retired. `01.dailies/` and `04.stories/` survive as `routable:false` **data containers** whose children the trip page aggregates | 2026-07-04 | Same cleanup. **The folders are load-bearing** — retiring a view must never delete its container (see [`CONCEPTS.md`](../../CONCEPTS.md) → Container) |
|
||||||
|
| R6 | Site nav "Journal · Map · Stats" | `summary.md` | **Home · Trips**, plus **New Post** when authenticated (`partials/base.html.twig:27-31`) | Sub-views retired 2026-07-04; "Past Trips" renamed "Trips" 2026-07 (`6cf5092`) | Nav should not link to views that no longer exist |
|
||||||
|
| R7 | `hero_image` frontmatter on entries, to pin a feed-card hero | `milestone-1.md` §1.6; `summary.md`; `pm-analysis.md` | **No `hero_image` field on journal entries.** The hero is the first uploaded photo (`entry-journal.html.twig` uses `entry.media.images\|first`). Photo order is owner-controlled, so an explicit filename was redundant. **Stories still use `hero_image`** | 2026-07 | `plans/2026-07-05-photo-editor-media-api.md` gave the owner drag-reorder over photos, which made "first photo" a deliberate choice rather than an accident |
|
||||||
|
| R8 | Photos optional on an entry | `milestone-1.md` §1.5; `guides/posting.md` (pre-2026-07-25) | Photos are **required — minimum 1, maximum 6** (`post-form.md:35-46`, enforced in `post-form.js` `initValidation`) | 2026-07 | `plans/2026-07-04-journal-post-form.md`. Photos come first in the form because they anchor what you write |
|
||||||
|
| R9 | A light-mode colour palette alongside dark | `reference/design-system-light.md` (whole doc); `plans/2026-06-19-dark-mode.md` | **Dark only.** `css/tokens.css` has a single `:root` block; there is no `prefers-color-scheme` or `data-theme` switch, and no light-palette hex appears in `css/` | 2026-06-20 | Dark mode shipped as *the* theme, not as one of two. The light palette was the pre-dark-mode original and was never re-implemented as a switchable theme |
|
||||||
|
| R10 | `shortcode-gallery-plusplus` as the entry photo gallery | `pm-analysis.md` | Galleries are **PhotoSwipe**, wired in `js/src/main.js` against `.pswp-gallery` markup emitted by `partials/entry-journal.html.twig`. No `[gallery]` shortcode is used anywhere in `templates/` or `pages/` | 2026-06-21 (`30c8937`, "replace custom lightbox with PhotoSwipe v5") | A lightbox the theme controls beat a plugin's markup. ⚠️ The plugin is **still listed in `plugins.txt`** with no consumer — see recommendations |
|
||||||
|
| R11 | `travel-memories` as an in-repo service on :8082, built from `./services/travel-memories` | `plans/2026-06-21-travel-memories.md`; `specs/2026-06-21-travel-memories-design.md`; `working/2026-06-21-travel-memories-handover.md` | **Extracted to a separate project.** `services/` is gitignored and the source is absent from this repo | `a80b0a9` — "remove travel-memories service from repo (moved to separate project)" | It was an independent Flask app with its own lifecycle. ⚠️ `docker-compose.yml` **still declares the service**, so `make start` fails on a clean checkout — see recommendations |
|
||||||
|
| R12 | Three map template variants (`feed-map.html.twig` partial with inline init, plus full-page `map.html.twig`) | pre-2026-06-27 templates | **One display map path** — `MapUtils.initEntryMap()` in `js/maplibre-utils.js`, invoked through `partials/entry-map.html.twig` | Consolidated 2026-06-27, variants deleted 2026-07-04 | `plans/2026-06-27-map-init-consolidation.md`. Three implementations drifted apart |
|
||||||
|
| R13 | A single map code path, no exceptions | `CLAUDE.md` (pre-2026-07-24 wording) | One **display** path (R12) **plus one sanctioned editor** — `js/src/location-map.js` for the `/post` pin picker: one draggable marker, no popups/GPX/bounds, `maplibre-gl` lazy-imported. Shares only `MAP_STYLE` with the display path | 2026-07-24 — `user/` `dd19995`, outer `4450bd6`; the rule was carved out in `829325c` | `plans/2026-07-23-post-form-location-override.md`. An editor map has none of a display map's concerns; folding them together would have compromised both |
|
||||||
|
| R14 | `post-form.md` carries a static `pageconfig.parent` naming the write target | pre-2026-07 form config | **No `parent` in `post-form.md`.** `cache-on-save` derives it from `site.active_trip` at submit time | 2026-07 | The two settings silently desynced. **Never re-add it** — this is a hard rule in [`CLAUDE.md`](../../CLAUDE.md) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisions that were *not* reversed
|
||||||
|
|
||||||
|
Worth stating, because their planning docs are old enough to look suspect:
|
||||||
|
|
||||||
|
- **The SKIP list in [`pm-analysis.md`](../working/pm-analysis.md) still holds.** Background GPS
|
||||||
|
tracking, followers, comments, social discovery, reactions, trip reels, 3D flyover, printed books,
|
||||||
|
and AI itinerary building were all deliberately rejected for a solo flat-file blog. That reasoning
|
||||||
|
has not changed — only some of the *BUILD* items' delivery mechanisms did (R1, R2, R4, R7, R10).
|
||||||
|
- **Weather via Open-Meteo**, no API key, with the eight allowed `weather_desc` values — still exactly
|
||||||
|
as planned in `milestone-1.md` §1.2, and still matching the blueprint and the post form.
|
||||||
|
- **Location badge** (`📍 City, Country`) on cards and entry pages — as planned.
|
||||||
|
- **Distance/stats computation from frontmatter and GPX** — the numbers survived; only their
|
||||||
|
*location* moved from a `/stats` page to the trip page (R2).
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Testing
|
||||||
|
|
||||||
|
Every suite drives the **live site over HTTP**, so the dev server must be running (`make start`) before any of them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Scope |
|
||||||
|
|---|---|
|
||||||
|
| `make test` | Everything: `test-config` → `test-post` → `test-ui` |
|
||||||
|
| `make test-config` | Form/config sanity via `scripts/test-form-config.sh` |
|
||||||
|
| `make test-post` | End-to-end post submission via `scripts/test-post.sh` |
|
||||||
|
| `make test-ui` | Playwright suite (`npx playwright test`) |
|
||||||
|
| `make test-account` | Creates the `testrunner` admin if absent (a dependency of `test-post` and `test-ui`) |
|
||||||
|
|
||||||
|
Focused runs bypass `make`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test tests/ui/maps # one suite
|
||||||
|
npx playwright test tests/ui/maps --headed # watch it
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
playwright.config.js ← config (testDir: ./tests/ui)
|
||||||
|
tests/
|
||||||
|
├─ global-setup.js ← runs once before all projects
|
||||||
|
├─ global-teardown.js ← runs once after
|
||||||
|
├─ fixtures/
|
||||||
|
└─ ui/
|
||||||
|
├─ helpers.js ← shared helpers; import from here rather than re-rolling
|
||||||
|
├─ auth/ ← includes auth.setup.js (see below)
|
||||||
|
├─ a11y/ dailies/ gpx/ home/
|
||||||
|
├─ maps/ nav/ post/ stories/ trip/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Config facts
|
||||||
|
|
||||||
|
| Setting | Value | Why it matters |
|
||||||
|
|---|---|---|
|
||||||
|
| `baseURL` | `process.env.GRAV_BASE_URL \|\| 'http://localhost:8081'` | Set `GRAV_BASE_URL` to test a worktree's isolated server on `8090+` |
|
||||||
|
| `retries` | `0` | A failing test is a real failure, not flake — do not paper over it with retries |
|
||||||
|
| `timeout` | `30_000` | Per test |
|
||||||
|
| `screenshot` | `only-on-failure` | Video off; artifacts stay small |
|
||||||
|
| `reporter` | `line` | |
|
||||||
|
|
||||||
|
### Auth is a dependency project
|
||||||
|
|
||||||
|
Two Playwright projects, in order:
|
||||||
|
|
||||||
|
1. **`setup`** — matches `auth.setup.js`, logs in once, writes `tests/.auth/user.json`.
|
||||||
|
2. **`chromium`** — `dependencies: ['setup']`, consumes that file as `storageState`.
|
||||||
|
|
||||||
|
So every test in `chromium` starts already authenticated. **Never add a per-test login** — it duplicates the setup project and slows the suite.
|
||||||
|
|
||||||
|
### The test account
|
||||||
|
|
||||||
|
`make test-account` creates a `testrunner` admin (via `bin/plugin login new-user`, admin type `both`) inside the container if `user/accounts/testrunner.yaml` is missing. It is git-ignored.
|
||||||
|
|
||||||
|
- Never commit it.
|
||||||
|
- Keep the password free of shell/Make/URL-special characters — it is interpolated by the Makefile, `scripts/test-post.sh`, and the Playwright setup, and a special character breaks at least one of them.
|
||||||
@@ -56,33 +56,28 @@ Production is unaffected either way: prod pulls `user/` directly via the content
|
|||||||
|
|
||||||
The payoff. Because `docker-compose.yml` mounts `./user` **relative to the compose file**, and a worktree is a full copy of the outer tree (compose file included), each worktree serves *its own* `user/`. Two worktrees = two independent sites, no gitlink collisions.
|
The payoff. Because `docker-compose.yml` mounts `./user` **relative to the compose file**, and a worktree is a full copy of the outer tree (compose file included), each worktree serves *its own* `user/`. Two worktrees = two independent sites, no gitlink collisions.
|
||||||
|
|
||||||
Set up a feature worktree off `main`:
|
**Use the make targets — don't do the steps by hand.** From the main checkout:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# outer worktree on a new feature branch
|
make worktree-new NAME=<feature> # create + start its own dev server
|
||||||
git worktree add .worktrees/<feature> -b feat/<feature> main
|
make worktree-rm NAME=<feature> # tear down cleanly
|
||||||
cd .worktrees/<feature>
|
|
||||||
|
|
||||||
# populate user/ at the pinned SHA, then branch it for the cross-repo work
|
|
||||||
git submodule update --init user
|
|
||||||
git -C user checkout -b feat/<feature>
|
|
||||||
|
|
||||||
# its own dev server — separate project name + port from the main checkout's :8081
|
|
||||||
docker compose -p itte-<feature> up -d
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`worktree-new` does, in order: `git worktree add .worktrees/<feature> -b feat/<feature> main`, `git submodule update --init user`, branches `user/` onto `feat/<feature>`, writes a git-ignored `.worktree-env` (own compose project name, container name, auto-assigned port `8090+`) so every `make`/compose command run inside that worktree targets its own server, and starts the Grav service. The manual equivalent misses `.worktree-env` — without it, make commands in the worktree hit the main checkout's container on `:8081`.
|
||||||
|
|
||||||
`.worktrees/` is kept out of git via `.git/info/exclude` (local, shared across worktrees — no committed `.gitignore` change needed).
|
`.worktrees/` is kept out of git via `.git/info/exclude` (local, shared across worktrees — no committed `.gitignore` change needed).
|
||||||
|
|
||||||
### Teardown
|
### Teardown
|
||||||
|
|
||||||
A submodule inside a linked worktree stores its git dir under `.git/modules/user/worktrees/<name>`, so removing the outer worktree needs a second cleanup step:
|
A submodule inside a linked worktree stores its git dir under `.git/modules/user/worktrees/<name>`, so teardown needs a submodule-deinit step before the worktree can be removed — skipping it is what leaves orphaned `.worktrees/` dirs. `make worktree-rm NAME=<feature>` runs the full sequence:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -p itte-<feature> down
|
# what worktree-rm does internally
|
||||||
cd "$(git rev-parse --show-toplevel)" # back to the main checkout
|
make -C .worktrees/<feature> stop # compose down (its own server)
|
||||||
git -C .worktrees/<feature> submodule deinit user # detach the submodule worktree
|
git -C .worktrees/<feature> submodule deinit -f user # detach the submodule worktree
|
||||||
git worktree remove .worktrees/<feature> # remove the outer worktree
|
git worktree remove --force .worktrees/<feature>
|
||||||
git branch -d feat/<feature> # if merged
|
git worktree prune
|
||||||
|
git branch -d feat/<feature> # manual, if merged
|
||||||
```
|
```
|
||||||
|
|
||||||
### Landing a commit on main without disturbing the main checkout
|
### Landing a commit on main without disturbing the main checkout
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
---
|
||||||
|
title: CLAUDE.md content tiering — rules stay, descriptions move out
|
||||||
|
date: 2026-07-24
|
||||||
|
category: conventions
|
||||||
|
module: documentation
|
||||||
|
problem_type: convention
|
||||||
|
component: documentation
|
||||||
|
severity: medium
|
||||||
|
applies_when:
|
||||||
|
- "Deciding whether new content belongs in CLAUDE.md or a docs/ subfolder"
|
||||||
|
- "CLAUDE.md has grown and needs a reduction pass"
|
||||||
|
- "Writing a rule that references specific file paths, bundle names, or other enumerable facts"
|
||||||
|
- "Extracting descriptive content out of CLAUDE.md into docs/reference or docs/guides"
|
||||||
|
tags: [claude-md, documentation-conventions, context-management, staleness, tiering, agent-instructions]
|
||||||
|
---
|
||||||
|
|
||||||
|
# CLAUDE.md content tiering — rules stay, descriptions move out
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`CLAUDE.md` at the root of this repo is loaded into every single session, before any file is opened. It had grown to 255 lines of mixed content: rules, stack version numbers, plugin role tables, `make` command tables, folder maps, template hierarchies, and descriptions of how the asset pipeline worked. Nobody had ever asked whether a line earned its place in permanent context.
|
||||||
|
|
||||||
|
Four rounds of work over one session took it to 74 lines. The interesting part was not the size reduction — it was what the audits revealed about *which kinds of sentences go stale*, and the fact that the first honest audit made the file **bigger**.
|
||||||
|
|
||||||
|
| Round | Commit | Lines | What happened |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `2fbfc88` | 255 → **305** | Audit scored the file 76/100, fixed 4 stale facts, and *added* genuinely missing sections (testing, dev commands, plugin patches) |
|
||||||
|
| 2 | `ed6e43a` | 305 → **179** | Descriptive content extracted to `docs/` |
|
||||||
|
| 3 | `839a4d0` | 179 → **74** (17,057 → 8,544 chars) | Rules-only cut; created `docs/reference/testing.md`, grew `README.md` |
|
||||||
|
| — | `9ec2349` | +52 | `docs/working/README.md` added; the plan-status *rule* stayed in CLAUDE.md, the *explanation* moved out |
|
||||||
|
| 4 | `285e615` | 74 → **74** | Build-output rule restated as an invariant. 3 lines → 3 lines, 156 chars saved. Not a size change — a staleness fix |
|
||||||
|
|
||||||
|
The four stale facts from round 1, verbatim from `2fbfc88`'s commit body:
|
||||||
|
|
||||||
|
- `active_trip: japan-korea-2026` — the committed value was `/trips/denmark-2026` and **no `japan-korea` trip folder existed**
|
||||||
|
- `Admin2 v2.0.10` — installed version was `v2.0.12`
|
||||||
|
- `make demo-load` described as italy-only — the Makefile loops over every fixture under `user/docs/demo/trips/`
|
||||||
|
- the `user/` gitignore claim omitted the three un-ignored site-owned plugins and the secret/`env/` exclusions
|
||||||
|
|
||||||
|
## Guidance
|
||||||
|
|
||||||
|
### 1. Apply the operational test to every line
|
||||||
|
|
||||||
|
> **Does this line change what Claude does on a task where it wouldn't otherwise open the relevant file?**
|
||||||
|
|
||||||
|
If no, it is a *description* — move it to `docs/`. Claude reads the code anyway; prose about the code just drifts alongside it.
|
||||||
|
|
||||||
|
Corollary: **version numbers are pure drift with no behavioral payload.** `Grav 2.0.7`, `Admin2 v2.0.12`, and the GPM-channel paragraph were all dropped. What survived is version-free:
|
||||||
|
|
||||||
|
> The site is Grav (flat-file PHP CMS, no database) in Docker, with content and theme in the `user/` submodule.
|
||||||
|
|
||||||
|
"No database" stays because it *does* change behavior — an agent that believes there is a database goes looking for migrations, an ORM, and a query layer that do not exist.
|
||||||
|
|
||||||
|
### 2. Tier content by when it gets read
|
||||||
|
|
||||||
|
| Content | Home | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| Rules, gotchas, invariants | `CLAUDE.md` | Worthless unless already in context |
|
||||||
|
| How the code works | `docs/reference/` | Claude reads the code anyway; prose drifts |
|
||||||
|
| How to do a task | `docs/guides/` | Read at task start, on demand |
|
||||||
|
| A trap already hit, with symptoms | `docs/solutions/` | Retrieved by symptom, indexed by frontmatter |
|
||||||
|
| Setup, folder map, commands | `README.md` | For humans; Claude has the Makefile |
|
||||||
|
|
||||||
|
CLAUDE.md keeps a six-row entry-point table pointing at each destination — the routing is a rule, the content behind it is not.
|
||||||
|
|
||||||
|
### 3. Gotchas are the one category that cannot be extracted
|
||||||
|
|
||||||
|
Every other content type has a natural trigger that opens the file:
|
||||||
|
|
||||||
|
| Type | Trigger that gets it read |
|
||||||
|
|---|---|
|
||||||
|
| Description | Agent opens the code |
|
||||||
|
| Procedure | Agent starts the task |
|
||||||
|
| Incident write-up | Agent recognizes a symptom |
|
||||||
|
| **Gotcha / exception** | **none — it must already be in context** |
|
||||||
|
|
||||||
|
A file you only open once you suspect an exception exists is a file you open **too late**. A proposed `docs/exceptions/` directory was therefore recommended against. Supporting arithmetic: the whole rules surface is ~40 lines / ~2,200 tokens, so a second file saves ~1k tokens while adding a lookup step, and `docs/solutions/` (indexed by `module` / `tags` / `problem_type`) already fills the read-on-demand role for "have we hit this before?".
|
||||||
|
|
||||||
|
### 4. State invariants, not enumerations
|
||||||
|
|
||||||
|
An enumerated list is falsified by the next addition, silently. An inverted statement of the same fact survives it. This is what `285e615` did — same three lines, no size change, but now staleness-proof.
|
||||||
|
|
||||||
|
### 5. Verify the destination before extracting
|
||||||
|
|
||||||
|
Every extraction target was confirmed to already exist and already cover the topic:
|
||||||
|
|
||||||
|
- pointer bumps and worktree mechanics → `docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md` (already covered them)
|
||||||
|
- the `user/env/<host>/` override tree → `docs/guides/deploy-cycle.md` (already covered it)
|
||||||
|
- source→output asset table → `docs/reference/architecture.md` → "Asset pipeline" (section added to receive it, lines 69-82)
|
||||||
|
- test-suite descriptions → `docs/reference/testing.md` (**created**, 67 lines — no destination existed)
|
||||||
|
- folder map + `make` tables → `README.md` (179 → 227 lines)
|
||||||
|
|
||||||
|
Nothing extracted became homeless. Related fix in the same pass: `docs/working/git-sync-notes.md` pointed at "CLAUDE.md §1", a section number that no longer existed after renumbering — **cross-references into an instruction file must point at stable headings, never numbers.**
|
||||||
|
|
||||||
|
### 6. Know when to stop
|
||||||
|
|
||||||
|
At 74 lines the section sizes were even — Hard rules 9, Dev environment 8, Content and trips 7, Two shared partials 7, Dual-repo submodule 7, Testing 7, Working docs 7, intro + entry-point table 15. No fat pocket remained. Roughly 8 more lines *could* have gone (the `travel-memories` :8082 port, a parenthetical Twig-recompile aside, tightening two bullets) for ~250 tokens out of ~2,200 — while deleting actual rules.
|
||||||
|
|
||||||
|
**The trim is strongly positive while what leaves is descriptions, and turns negative once only rules remain.** Round 3 therefore ended with a "we're at the floor" verdict plus one robustness fix (`285e615`), not another cut.
|
||||||
|
|
||||||
|
## Why This Matters
|
||||||
|
|
||||||
|
**Every stale fact found across all four rounds was a description of code or config. Not one was a rule.** Two of them had been written by Claude itself days earlier. Descriptions drift because the code moves and the prose does not; rules do not drift because they encode intent rather than state. The tiering above is not an aesthetic preference — it is the only conclusion the evidence supports.
|
||||||
|
|
||||||
|
**A wrong path in an always-loaded file is worse than an absent one.** CLAUDE.md claimed the map engine lived at `js/src/maplibre-utils.js`. That file does not exist. The real path is `user/themes/intotheeast/js/maplibre-utils.js` — a hand-authored source sitting *next to* the generated bundles in `js/`, imported by `js/src/map.js` as `../maplibre-utils.js`. The wrong path survived rounds 1 and 2 (`2fbfc88` line 76, `ed6e43a` line 64) and was only fixed in `839a4d0`.
|
||||||
|
|
||||||
|
An absent fact makes an agent go look. A wrong fact makes it act confidently in the wrong place. Here the wrong place was `js/map.js` — a minified esbuild bundle. The failure mode is a hand-edit that survives until the next `make build-assets` silently reverts it.
|
||||||
|
|
||||||
|
This is also the decisive argument against `docs/exceptions/`: **the maplibre-utils mistake happened because the path was wrong, not because it was missing.** Had that rule lived in `docs/exceptions/assets.md`, the bundle would have been hand-edited with the agent never knowing the file existed.
|
||||||
|
|
||||||
|
**What survived the cut is the sanity check on the criterion.** A rule stays when being wrong about it is expensive *and* the correct behavior is not derivable from reading a file:
|
||||||
|
|
||||||
|
- the Admin plugin slug is `admin2`, not `admin` — nothing in the tree announces this before you've already guessed wrong
|
||||||
|
- `plugins.txt` is hand-maintained; installing a plugin via Admin does **not** update it
|
||||||
|
- once `user/env/<hostname>/` exists on a server, Grav's Admin writes **all** config there — system *and* plugin — and env wins, so server config must be read from both trees
|
||||||
|
- `active_trip` is a **route** (`/trips/denmark-2026`), not a bare slug
|
||||||
|
- never re-add a `pageconfig.parent` to `post-form.md` — a static parent overrides the `active_trip`-derived write target and reintroduces a silent-desync bug
|
||||||
|
- the standalone `/dailies`, `/map`, `/stats`, `/stories` trip views were deleted 2026-07-04 and must not be re-created or linked
|
||||||
|
|
||||||
|
Each of those is a landmine an agent steps on *before* it has cause to open the relevant file.
|
||||||
|
|
||||||
|
## When to Apply
|
||||||
|
|
||||||
|
- Auditing or editing any always-loaded instruction file — `CLAUDE.md`, `AGENTS.md`, system prompts, agent definitions
|
||||||
|
- When a stale fact is found in an instruction file: fix it, then ask why that *category* of sentence was there at all
|
||||||
|
- Before adding a line to `CLAUDE.md` — run the operational test first, and route to the tiering table if it fails
|
||||||
|
- Before writing an enumerated list of files, paths, plugins, or bundles into an instruction file — try inverting it into an invariant and verify the inverted form against the actual directory listing
|
||||||
|
- Before extracting content out of an instruction file — confirm the destination exists and covers the topic, or create it in the same commit
|
||||||
|
- When tempted to create a new read-on-demand directory for exceptions or gotchas — don't; they only work in-context
|
||||||
|
- When a reduction pass stops finding descriptions and starts deleting rules — stop and record a floor verdict instead of cutting further
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Enumerated list → invariant (`285e615`)
|
||||||
|
|
||||||
|
**Before** — 3 lines, falsified by adding a fifth bundle:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- **Never hand-edit build output**, and know which files those are — sources and outputs
|
||||||
|
share folders under `user/themes/intotheeast/` (all paths below are relative to it).
|
||||||
|
`make build-assets` is mandatory after editing any source, and it writes:
|
||||||
|
- **Generated (never edit):** `js/main.js`, `js/map.js`, `js/feed-actions.js`,
|
||||||
|
`js/trip-publish.js`, `js/post/`, `css-compiled/`, `fonts/`, and
|
||||||
|
`templates/partials/weather-icons.html.twig`.
|
||||||
|
- **Hand-authored sources:** everything in `js/src/`, plus `js/maplibre-utils.js` and
|
||||||
|
`js/nav.js` (which sit *next to* the bundles in `js/`), `css/style.css`,
|
||||||
|
`css/tokens.css`, and `scripts/gen-weather-icons.js`.
|
||||||
|
```
|
||||||
|
|
||||||
|
**After** — 3 lines, 156 chars shorter, still true after the next bundle is added:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- **Never hand-edit build output** — sources and outputs share folders under
|
||||||
|
`user/themes/intotheeast/` (paths below are relative to it), so know which is which.
|
||||||
|
Run `make build-assets` after editing any source.
|
||||||
|
- Everything in `js/` is **generated** *except* `js/src/`, `js/maplibre-utils.js` and `js/nav.js`.
|
||||||
|
- `css-compiled/` and `fonts/` are generated (sources: `css/style.css`, `css/tokens.css`);
|
||||||
|
so is `templates/partials/weather-icons.html.twig` (source: `scripts/gen-weather-icons.js`).
|
||||||
|
```
|
||||||
|
|
||||||
|
Verification that made this safe: `ls js/` returns exactly the 4 bundles + `post/` + `maplibre-utils.js` + `nav.js` + `src/`. The inverted form is exactly true today and stays true as bundles are added. The full enumerated source→output table now lives in `docs/reference/architecture.md` → "Asset pipeline", where drift is cheap because the table is read next to the code it describes.
|
||||||
|
|
||||||
|
### Description → extracted; rule → kept
|
||||||
|
|
||||||
|
**Before** (round 1 addition, later cut) — a description of the build, in permanent context:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
**`make build-assets` is mandatory after editing anything in
|
||||||
|
`user/themes/intotheeast/js/src/`.** Sources live in `js/src/`; esbuild writes the
|
||||||
|
committed bundles — `js/main.js`, `js/map.js`, `js/feed-actions.js`,
|
||||||
|
`js/trip-publish.js`, `js/post/`, and the CSS extracted into `css-compiled/`.
|
||||||
|
**Never hand-edit those.** By contrast `css/style.css` and `css/tokens.css` are
|
||||||
|
hand-authored sources, not build outputs. `build-assets` runs as your host UID
|
||||||
|
(`--user`) so the outputs in the bind-mounted `user/` tree are not root-owned.
|
||||||
|
```
|
||||||
|
|
||||||
|
**After** — the `--user` mechanism and the esbuild pipeline moved to `docs/reference/architecture.md` line 71; only the never-edit rule and the source/output discriminator remain in `CLAUDE.md`.
|
||||||
|
|
||||||
|
### Wrong path → right path (`839a4d0`)
|
||||||
|
|
||||||
|
```diff
|
||||||
|
-The engine is `MapUtils.initEntryMap(opts)` in `js/src/maplibre-utils.js`.
|
||||||
|
+the engine is `MapUtils.initEntryMap(opts)` in `js/maplibre-utils.js`
|
||||||
|
+(a hand-authored file, imported by `js/src/map.js`)
|
||||||
|
```
|
||||||
|
|
||||||
|
`js/src/maplibre-utils.js` never existed. The parenthetical is not padding — it is the whole reason the rule is in an always-loaded file: `js/` is the bundle directory, so a hand-authored source living there is exactly the fact an agent cannot infer.
|
||||||
|
|
||||||
|
### Rule stays, explanation leaves (`9ec2349`)
|
||||||
|
|
||||||
|
The plan-status convention needed both a machine-actionable rule and a human-readable explanation of the five states. They went to different files:
|
||||||
|
|
||||||
|
- `CLAUDE.md` keeps the one-line rule — every plan needs a `**Status:**` line immediately after its title, plus what to surface when asked what's open, plus set `✅ Complete (YYYY-MM-DD)` before closing a session
|
||||||
|
- `docs/working/README.md` (52 lines) holds the explanation of the states, the directory layout, and the human-facing reference
|
||||||
|
|
||||||
|
Same convention, split by *when each half needs to be in context*.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [`docs/README.md`](../../README.md) — the existing "always-loaded rules → CLAUDE.md" vs "stable facts → reference/" split that this learning sharpens into an actionable test
|
||||||
|
- [`docs/working/plans/2026-06-21-documentation-restructure.md`](../../working/plans/2026-06-21-documentation-restructure.md) — the prior restructure that created the extraction destinations (`reference/architecture.md` and siblings) this pass relied on and re-applied
|
||||||
|
- [`docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md`](../integration-issues/stale-grav-version-blocks-api-plugin-install.md) — sibling instance of version numbers rotting, in the deploy-config domain rather than the instruction-file domain
|
||||||
|
- [`docs/reference/architecture.md`](../../reference/architecture.md) → "Asset pipeline" — where the enumerated source→output table now lives
|
||||||
+253
@@ -0,0 +1,253 @@
|
|||||||
|
---
|
||||||
|
title: Reconciling drifted docs — tier by tense, and record reversals in a ledger
|
||||||
|
date: 2026-07-25
|
||||||
|
category: conventions
|
||||||
|
module: documentation
|
||||||
|
problem_type: convention
|
||||||
|
component: documentation
|
||||||
|
severity: high
|
||||||
|
applies_when:
|
||||||
|
- Auditing documentation against the code after a period of undocumented change
|
||||||
|
- Deciding whether a stale document should be corrected, annotated, or deleted
|
||||||
|
- A plan or milestone describes a feature that was later dropped or replaced
|
||||||
|
- Writing or reviewing an index that describes what another document is for
|
||||||
|
- Asked whether the docs would pass a review, or to make them pass one
|
||||||
|
- A decision is being reversed and the old rationale needs to survive the reversal
|
||||||
|
tags: [documentation-conventions, staleness, tiering, drift, supersession, decision-log, audit, verification, indexes]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Reconciling drifted docs — tier by tense, and record reversals in a ledger
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Five weeks after the last documentation pass, this repo's docs described a site that partly no longer
|
||||||
|
existed. `/map`, `/stats`, `/tracker`, Leaflet, a light theme, and `hero_image` on entries had all been
|
||||||
|
deliberately removed — but several documents still presented them in confident present tense, and
|
||||||
|
nothing marked those documents as historical.
|
||||||
|
|
||||||
|
The trigger question was *"straighten this out so a repeat review returns ok."* The answer depended on
|
||||||
|
a distinction the tree did not encode.
|
||||||
|
|
||||||
|
[`claude-md-content-tiering.md`](claude-md-content-tiering.md) established that **descriptions drift
|
||||||
|
and rules do not**, and tiered content by *type* (rules stay in `CLAUDE.md`, descriptions move to
|
||||||
|
`docs/`). This pass confirmed that thesis again — every one of 20 verified defects was a description
|
||||||
|
of code, config, or a command; not one was a rule that had gone wrong on its own. But content-type
|
||||||
|
tiering alone did not answer what to *do* with 41 completed plans and 4 milestone specs, because those
|
||||||
|
are neither rules nor current descriptions.
|
||||||
|
|
||||||
|
The missing axis was **tense**.
|
||||||
|
|
||||||
|
## Guidance
|
||||||
|
|
||||||
|
### 1. Tier by tense, then treat the halves oppositely
|
||||||
|
|
||||||
|
| Kind | Files here | Claims | Staleness is | Treatment |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **Present-tense** | `CLAUDE.md`, `docs/reference/`, `docs/guides/`, `README.md`, `CONCEPTS.md` | "this is how it *is*" | a **defect** | correct against the code |
|
||||||
|
| **Past-tense** | `docs/working/plans/`, `specs/`, `milestones/`, `summary.md`, `pm-analysis.md` | "this is what we decided *then*" | **correct and expected** | annotate only, never rewrite |
|
||||||
|
|
||||||
|
A completed plan *should* be stale — that is what makes it a record. Rewriting 41 plans to match
|
||||||
|
today's code would destroy the audit trail of *why* each thing changed, and the work is unbounded.
|
||||||
|
The defect was never their staleness; it was that nothing told a reader they were history.
|
||||||
|
|
||||||
|
`docs/solutions/` straddles the split deliberately: past-tense incident, present-tense guidance. That
|
||||||
|
is why its `applies_when` frontmatter matters more than its narrative — the frontmatter is the part
|
||||||
|
that must stay true.
|
||||||
|
|
||||||
|
### 2. Ledger plus inline notes — neither alone is enough
|
||||||
|
|
||||||
|
Two mechanisms, because each covers the other's failure:
|
||||||
|
|
||||||
|
- **A supersession ledger** (`docs/reference/superseded-decisions.md`) — one table: what was planned,
|
||||||
|
where it was planned, what is true now, when it changed, why. This is the only thing that answers
|
||||||
|
*"what did I change my mind about?"* in one place, which is the question a review actually asks.
|
||||||
|
Alone, it has an indirection problem: a pointer you might not follow.
|
||||||
|
- **Inline `> **Superseded …**` notes** at each stale claim, so the claim cannot be read
|
||||||
|
un-corrected. Alone, it has a completeness problem: no changelog view, and coverage is only as good
|
||||||
|
as the annotation pass was.
|
||||||
|
|
||||||
|
**Prefer the annotation patterns the repo already uses.** Here, `architecture.md` already carried
|
||||||
|
`> History:` notes and `trip-switching.md` already carried `> **Changed 2026-07:**`. Inventing a third
|
||||||
|
convention would have been worse than adopting either.
|
||||||
|
|
||||||
|
**Add the ledger row in the same commit as the reversal.** A ledger that lags is worse than no ledger,
|
||||||
|
because it is trusted — the same failure mode as a lagging plan `Status:` line.
|
||||||
|
|
||||||
|
### 3. Also record what was *not* reversed
|
||||||
|
|
||||||
|
A ledger of only reversals makes every old document look suspect. This one ends with a short
|
||||||
|
"decisions that were *not* reversed" section — the `pm-analysis.md` SKIP list still stands, the
|
||||||
|
weather integration shipped exactly as specified, the stats computation survived and only *moved*.
|
||||||
|
Without it, a future reader re-litigates settled decisions because the surrounding docs looked old.
|
||||||
|
|
||||||
|
### 4. Separate "the docs are wrong" from "the code is wrong"
|
||||||
|
|
||||||
|
An audit against code finds both. Mixing them makes the diff unreviewable and stalls the documentation
|
||||||
|
fix behind a behaviour decision. Route code-side findings to a separate recommendations document and
|
||||||
|
**explicitly do not act on them**. Here that kept a 300-line docs diff clean while still capturing that
|
||||||
|
`make start` is broken on any clean checkout.
|
||||||
|
|
||||||
|
Documenting a trap is not the same as fixing it — and is the right move when the fix is someone else's
|
||||||
|
call. Per the tiering doc, a gotcha has no natural trigger that opens a file, so a live trap belongs in
|
||||||
|
`CLAUDE.md` even while its fix stays unscheduled.
|
||||||
|
|
||||||
|
### 5. Verify against the artifact that decides behaviour, not the prose about it
|
||||||
|
|
||||||
|
Every finding must come from the thing that actually determines behaviour:
|
||||||
|
|
||||||
|
| To check | Read |
|
||||||
|
|---|---|
|
||||||
|
| What a command does | the `Makefile` — including macro-generated targets, which a grep for literal target names will miss |
|
||||||
|
| What a build produces | the build script (`package.json`), not a prose asset table |
|
||||||
|
| Whether a file is a source or an output | which file *imports* it, and how it reaches the page |
|
||||||
|
| Whether a feature exists | the absence of its mechanism, not the absence of a mention |
|
||||||
|
| Whether a plan shipped | the branch history, not the plan's own `Status:` line |
|
||||||
|
|
||||||
|
This is also where an audit catches *itself*. One draft finding here claimed the asset table was
|
||||||
|
missing four source files; reading `package.json` showed the table lists esbuild **entry points**, so
|
||||||
|
imported-only sources were correctly absent. The finding was withdrawn. **An audit that never
|
||||||
|
withdraws a finding has not been checking itself.**
|
||||||
|
|
||||||
|
### 6. Re-check the baseline before publishing, not only before starting
|
||||||
|
|
||||||
|
A long audit **races the work it is auditing**. This one had its baseline move twice, and each time the
|
||||||
|
convenient state was the wrong one:
|
||||||
|
|
||||||
|
- **The submodule pin lagged.** A fresh worktree checks out the commit the outer repo pins, not the
|
||||||
|
submodule's real HEAD. Auditing the pin would have reported a shipped feature as unbuilt. Move to the
|
||||||
|
real HEAD first, and keep the gitlink out of the commit (see
|
||||||
|
[`dual-repo-submodule-workflow.md`](../architecture-patterns/dual-repo-submodule-workflow.md) —
|
||||||
|
`M user` is normal and must not be "fixed").
|
||||||
|
- **The base branch advanced 13 commits mid-audit**, independently fixing two findings. Merging the
|
||||||
|
base branch in before opening the PR is what surfaced that. Without it, the branch would have
|
||||||
|
**reverted** work that was already correct — the worst possible outcome for a cleanup pass, because it
|
||||||
|
arrives disguised as an improvement.
|
||||||
|
|
||||||
|
Two habits fall out of this. **Merge the base branch in before publishing, and read the conflicts as
|
||||||
|
findings rather than chores** — each conflict is the codebase telling you someone else already reasoned
|
||||||
|
about this line. And **when the incoming version is better, take it wholesale**: here the base branch's
|
||||||
|
map-doctrine wording and plan status were both more informed than the replacements drafted during the
|
||||||
|
audit, so they were kept in full and the audit's own notes were corrected to match. An audit has no
|
||||||
|
special authority over the work it audits.
|
||||||
|
|
||||||
|
## Why This Matters
|
||||||
|
|
||||||
|
**An index describing another document's role makes a factual claim that can rot — and it is worse
|
||||||
|
than the stale document itself.** The single most misleading line in this tree was
|
||||||
|
`docs/working/README.md` advertising `summary.md` as *"Project summary / current state"*, while
|
||||||
|
`summary.md` described Leaflet, `/tracker`, `/map` and `/stats`. A stale document is survivable — a
|
||||||
|
reader may notice the date, the tone, the odd claim. An index that vouches for it as authoritative
|
||||||
|
**defeats that judgement before it engages.** When writing an index, treat every "what this file is
|
||||||
|
for" phrase as an assertion with an expiry date.
|
||||||
|
|
||||||
|
**Wrong beats absent, again — now for commands.** The tiering doc found this for paths: an absent fact
|
||||||
|
makes an agent go look; a wrong one makes it act confidently in the wrong place. The same held for
|
||||||
|
`README.md`'s server runbook, where every `remote-*` command was documented without the `-test`/`-prod`
|
||||||
|
suffix `guard-env` requires. Every documented command failed on the first line. `deploy-cycle.md` had
|
||||||
|
the rule right the whole time — the defect was a **second copy** of the knowledge drifting from the
|
||||||
|
first. Fewer copies would have prevented it outright.
|
||||||
|
|
||||||
|
**Promoting a doc to "the authoritative list of X" creates a completeness obligation it did not have
|
||||||
|
as prose.** `CLAUDE.md` pointed at `README.md` for "the full `make` command list"; README then held 7
|
||||||
|
of ~20 `remote-*` targets. The pointer was added by a well-intentioned earlier tiering pass. Routing
|
||||||
|
content out of an always-loaded file is right, but **the destination inherits a duty to be complete**,
|
||||||
|
and nothing enforces that.
|
||||||
|
|
||||||
|
**Deliberate removals leak.** `travel-memories` was extracted to its own project, its source deleted
|
||||||
|
and `services/` gitignored — but `docker-compose.yml` still declared the service, and `CLAUDE.md` still
|
||||||
|
claimed it ran on :8082. `make start` has therefore been broken on every clean checkout since, hidden
|
||||||
|
only because a pre-removal Docker image stayed cached locally. **A removal is not finished when the
|
||||||
|
code is gone; it is finished when every consumer and every description of it is gone too.** The cached
|
||||||
|
image is the general lesson: local state can mask a breakage indefinitely, so "it works here" is not
|
||||||
|
evidence.
|
||||||
|
|
||||||
|
## When to Apply
|
||||||
|
|
||||||
|
- After any stretch of change that outpaced its documentation, or when asked whether the docs would
|
||||||
|
survive a review
|
||||||
|
- Before rewriting a stale plan, spec, or milestone — annotate it instead; the record is the value
|
||||||
|
- When reversing a decision: add the ledger row and the inline note in the reversal's own commit
|
||||||
|
- When writing an index, a folder README, or any "read X for Y" pointer — that pointer is a claim
|
||||||
|
- When removing a service, route, feature, or dependency: sweep for consumers *and* for prose that
|
||||||
|
describes it, including compose files, always-loaded instruction files, and demo fixtures
|
||||||
|
- When promoting any document to authoritative for a list — decide who keeps it complete
|
||||||
|
- Before auditing a repo with submodules: confirm you are on the state that actually runs
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Tense-marking a historical spec, without rewriting it
|
||||||
|
|
||||||
|
`milestones/milestone-2.md` still opens with its original goal — that is the record. The banner sits
|
||||||
|
directly beneath it, so the stale claim cannot be read alone:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
**Goal:** A `/map` page shows all entries as markers on an interactive Leaflet.js map, …
|
||||||
|
|
||||||
|
> **Superseded — written 2026-06-21. Neither the `/map` page nor Leaflet exists.**
|
||||||
|
>
|
||||||
|
> - **No `/map` route.** The map renders inline on the trip page via the single shared partial
|
||||||
|
> `templates/partials/entry-map.html.twig` (R1, retired 2026-07-04).
|
||||||
|
> - **Leaflet + OpenStreetMap tiles → MapLibre GL JS** (R4, 2026-06-20).
|
||||||
|
>
|
||||||
|
> The *substance* of this spec survived — markers per entry, chronological route line, popups,
|
||||||
|
> bounds fitting — it all lives in `MapUtils.initEntryMap()`. Only the page and the library changed.
|
||||||
|
```
|
||||||
|
|
||||||
|
Separating "the idea won" from "this implementation lost" is what stops a future reader concluding the
|
||||||
|
whole spec was a dead end.
|
||||||
|
|
||||||
|
### An index that vouched for a stale document
|
||||||
|
|
||||||
|
```diff
|
||||||
|
-| `summary.md` | Project summary / current state |
|
||||||
|
+| `summary.md` | **Historical** wrap-up of the original four-milestone branch (2026-06-21).
|
||||||
|
+ *Not* the current state — for that read [`../reference/architecture.md`](../reference/architecture.md) |
|
||||||
|
```
|
||||||
|
|
||||||
|
### A source relationship that never existed
|
||||||
|
|
||||||
|
`CLAUDE.md` asserted a build dependency between two unrelated things. `css/` is hand-authored and
|
||||||
|
served *directly*; `css-compiled/` is esbuild output from the CSS imports inside `js/src/*.js`:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
-- `css-compiled/` and `fonts/` are generated (sources: `css/style.css`, `css/tokens.css`)
|
||||||
|
+- `css-compiled/` and `fonts/` are **esbuild output from the imports inside `js/src/`** — *not*
|
||||||
|
+ from `css/`. Everything in `css/` is hand-authored and served directly (`assets.addCss` in
|
||||||
|
+ `partials/base.html.twig`), never compiled.
|
||||||
|
```
|
||||||
|
|
||||||
|
The failure this invited: an agent wanting to change a font edits `css-compiled/main.css` — a
|
||||||
|
generated bundle — because the rule named `css/style.css` as its source and that file does not contain
|
||||||
|
it. The next `make build-assets` silently reverts the edit.
|
||||||
|
|
||||||
|
### Proving a breakage instead of inferring it
|
||||||
|
|
||||||
|
Reasoning that a missing directory *would* break a build is not evidence. Running it is:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ docker compose build travel-memories
|
||||||
|
unable to prepare context: path ".../services/travel-memories" not found
|
||||||
|
```
|
||||||
|
|
||||||
|
The follow-up mattered more than the failure: a cached `travel-blog-intotheeast-travel-memories:latest`
|
||||||
|
image explained why `make start` still worked on the main checkout but failed in every new worktree.
|
||||||
|
Without that check the finding would have been reported as "broken everywhere" and been wrong.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [`claude-md-content-tiering.md`](claude-md-content-tiering.md) — the content-type tiering axis and
|
||||||
|
the "descriptions drift, rules don't" thesis this learning extends with a tense axis. **Consolidation
|
||||||
|
candidate:** the two overlap on root cause and on the files they touch; if a third documentation
|
||||||
|
learning appears, consider merging all three into one documentation-maintenance doc.
|
||||||
|
- [`../architecture-patterns/retiring-a-consolidated-grav-sub-page.md`](../architecture-patterns/retiring-a-consolidated-grav-sub-page.md)
|
||||||
|
— the mechanics of the retirement that produced ledger rows R1, R2 and R5. That doc covers removing
|
||||||
|
the *page*; this one covers removing the *claims about* the page.
|
||||||
|
- [`../architecture-patterns/dual-repo-submodule-workflow.md`](../architecture-patterns/dual-repo-submodule-workflow.md)
|
||||||
|
— why a fresh worktree's `user/` sits at the pin rather than at HEAD, which is the audit-baseline trap
|
||||||
|
in §6.
|
||||||
|
- [`../integration-issues/stale-grav-version-blocks-api-plugin-install.md`](../integration-issues/stale-grav-version-blocks-api-plugin-install.md)
|
||||||
|
— the same rot in the deploy-config domain: a version number that went stale and broke an install.
|
||||||
|
- `docs/working/specs/2026-07-25-docs-reconciliation-design.md` — the design and the verification
|
||||||
|
table for this pass.
|
||||||
|
- `docs/working/2026-07-25-doc-drift-recommendations.md` — the code-side findings deliberately not
|
||||||
|
acted on, including the compose breakage and a proposed repeatable `make docs-check`.
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
title: docker exec defaults to root, writing root-owned files into the host bind mount
|
title: docker exec/run defaults to root, writing root-owned files into the host bind mount
|
||||||
date: 2026-07-08
|
date: 2026-07-08
|
||||||
|
last_updated: 2026-07-08
|
||||||
problem_type: integration_issue
|
problem_type: integration_issue
|
||||||
category: integration-issues
|
category: integration-issues
|
||||||
module: docker-dev-environment
|
module: docker-dev-environment
|
||||||
@@ -11,6 +12,7 @@ symptoms:
|
|||||||
- "make worktree-rm fails: cannot rm root-owned plugin files without sudo"
|
- "make worktree-rm fails: cannot rm root-owned plugin files without sudo"
|
||||||
- "files stay root-owned even though UID/GID env vars were set to the host user"
|
- "files stay root-owned even though UID/GID env vars were set to the host user"
|
||||||
- "install-plugins writes the entire plugin tree as root via php bin/gpm install"
|
- "install-plugins writes the entire plugin tree as root via php bin/gpm install"
|
||||||
|
- "build-assets (docker run node:20-alpine, no --user) writes root-owned node_modules + esbuild bundles into user/themes/intotheeast/, blocking git worktree remove and git merge"
|
||||||
root_cause: config_error
|
root_cause: config_error
|
||||||
resolution_type: config_change
|
resolution_type: config_change
|
||||||
related_components:
|
related_components:
|
||||||
@@ -20,12 +22,15 @@ related_components:
|
|||||||
tags:
|
tags:
|
||||||
- docker
|
- docker
|
||||||
- docker-exec
|
- docker-exec
|
||||||
|
- docker-run
|
||||||
- bind-mount
|
- bind-mount
|
||||||
- file-permissions
|
- file-permissions
|
||||||
- uid-gid
|
- uid-gid
|
||||||
- makefile
|
- makefile
|
||||||
- grav
|
- grav
|
||||||
- gpm
|
- gpm
|
||||||
|
- build-assets
|
||||||
|
- esbuild
|
||||||
---
|
---
|
||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
@@ -56,9 +61,12 @@ Several plausible fixes were tried or considered and rejected:
|
|||||||
|
|
||||||
## Root Cause
|
## Root Cause
|
||||||
|
|
||||||
`docker exec` defaults to running as root (uid 0). Because the grav container must boot as root, and `docker exec` inherits that default unless `-u` is passed explicitly, every make target that did `docker exec <container> <cmd>` without `-u` wrote root-owned files into the `./user` bind mount.
|
Both `docker exec` **and** `docker run` default to running as root (uid 0). Because the grav container must boot as root, and neither inherits a non-root default unless `-u` / `--user` is passed explicitly, every make target that shelled into (or spun up) a container without dropping privileges wrote root-owned files into whatever host path it bind-mounted.
|
||||||
|
|
||||||
The worst offender was `install-plugins`, which runs `php bin/gpm install` and writes the entire plugin tree into `./user/plugins`.
|
There are **two** offenders, on two different bind mounts:
|
||||||
|
|
||||||
|
- **`install-plugins`** — `docker exec … php bin/gpm install`, writing the entire plugin tree into `./user/plugins` as root. The worst by file count (11,624).
|
||||||
|
- **`build-assets`** — `docker run --rm node:20-alpine … "npm install && npm run build"`, bind-mounting `./user/themes/intotheeast` → `/app`, writing root-owned `node_modules/` and esbuild bundle outputs (`js/…`, `css-compiled/`) into the tracked theme tree. This one uses **`docker run`**, not `docker exec`, and has **no `--user`** — so the `install-plugins` fix below does *not* cover it.
|
||||||
|
|
||||||
## Solution
|
## Solution
|
||||||
|
|
||||||
@@ -94,6 +102,31 @@ install-plugins:
|
|||||||
$(MAKE) apply-plugin-patches
|
$(MAKE) apply-plugin-patches
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### The `build-assets` vector (same principle, `docker run`) — fixed 2026-07-08
|
||||||
|
|
||||||
|
The first 2026-07-08 fix (`209b804`) hardened `install-plugins` only. `build-assets` remained a root-writing target and surfaced later: `git worktree remove` aborted with `Permission denied` on root-owned esbuild bundles under `user/themes/intotheeast/js/post/`, and earlier a `build-assets` run had produced a root-owned `css-compiled/` dir that blocked a `git merge` on the main checkout. (session history)
|
||||||
|
|
||||||
|
The same drop-privileges principle applies — with `--user` on `docker run` (fixed later the same day):
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
# Before — writes root-owned node_modules + bundles into the tracked theme tree
|
||||||
|
build-assets:
|
||||||
|
docker run --rm \
|
||||||
|
-v $(PWD)/user/themes/intotheeast:/app \
|
||||||
|
-w /app node:20-alpine \
|
||||||
|
sh -c "npm install && npm run build"
|
||||||
|
|
||||||
|
# After — outputs owned by the host user; HOME=/tmp gives npm a writable
|
||||||
|
# cache when running as a non-root uid
|
||||||
|
build-assets:
|
||||||
|
docker run --rm --user $(HOST_UID):$(HOST_GID) -e HOME=/tmp \
|
||||||
|
-v $(PWD)/user/themes/intotheeast:/app \
|
||||||
|
-w /app node:20-alpine \
|
||||||
|
sh -c "npm install && npm run build"
|
||||||
|
```
|
||||||
|
|
||||||
|
Verified: `make build-assets` with the fix completes clean (esbuild bundles emitted), `find user/themes/intotheeast -uid 0` counts zero, and the output bundles are byte-identical to the previously committed ones. Recovery for any pre-existing root-owned output is the same as anywhere else — `chown -R $(HOST_UID):$(HOST_GID)` from a container that already has root, then `rm`.
|
||||||
|
|
||||||
## Why This Works
|
## Why This Works
|
||||||
|
|
||||||
The container still *boots* as root — which it needs, to bind `:80` and set up cron. But the individual `docker exec` that writes into the bind mount now runs as the host uid/gid via `-u $(HOST_UID):$(HOST_GID)`. Files that exec creates on the host are therefore owned by the developer, not root. No post-hoc chown, no cleanup debt.
|
The container still *boots* as root — which it needs, to bind `:80` and set up cron. But the individual `docker exec` that writes into the bind mount now runs as the host uid/gid via `-u $(HOST_UID):$(HOST_GID)`. Files that exec creates on the host are therefore owned by the developer, not root. No post-hoc chown, no cleanup debt.
|
||||||
@@ -106,13 +139,13 @@ The preliminary chown of `cache/` and `tmp/` is container-internal: those paths
|
|||||||
|
|
||||||
The reusable principle, worth internalizing beyond this one repo:
|
The reusable principle, worth internalizing beyond this one repo:
|
||||||
|
|
||||||
- **Any make/CI target that writes files into a host bind mount via `docker exec` must pass `-u $(HOST_UID):$(HOST_GID)`.** A container booting as root does *not* mean your exec commands must run as root. Drop privileges per-exec.
|
- **Any make/CI target that writes files into a host bind mount must drop privileges — whether it uses `docker exec` (`-u $(HOST_UID):$(HOST_GID)`) or `docker run` (`--user $(HOST_UID):$(HOST_GID)`).** A container booting as root does *not* mean the commands you run in it must write as root. `build-assets` (a `docker run`) was the easy one to miss, because the original fix only patched the `docker exec` targets — so audit `docker run` invocations too, not just `docker exec`.
|
||||||
- **Derive host identity once in the Makefile and reuse it:** `HOST_UID := $(shell id -u)` / `HOST_GID := $(shell id -g)`.
|
- **Derive host identity once in the Makefile and reuse it:** `HOST_UID := $(shell id -u)` / `HOST_GID := $(shell id -g)`.
|
||||||
- **Don't rely on `APACHE_RUN_USER` or compose-level `UID`/`GID` env vars to fix exec ownership** — they don't apply to `docker exec`. `APACHE_RUN_USER` only affects Apache workers; compose `user:`/env vars only affect services wired to consume them.
|
- **Don't rely on `APACHE_RUN_USER` or compose-level `UID`/`GID` env vars to fix exec ownership** — they don't apply to `docker exec`. `APACHE_RUN_USER` only affects Apache workers; compose `user:`/env vars only affect services wired to consume them.
|
||||||
- **You can't just add `user:` to a service whose entrypoint needs root** (to bind privileged ports, set up cron, etc.). Drop privileges per-exec instead of per-container.
|
- **You can't just add `user:` to a service whose entrypoint needs root** (to bind privileged ports, set up cron, etc.). Drop privileges per-exec instead of per-container.
|
||||||
- **If a tool run as non-root needs writable scratch dirs that are root-owned in the image, chown them container-internally first.** That doesn't touch the host.
|
- **If a tool run as non-root needs writable scratch dirs that are root-owned in the image, chown them container-internally first.** That doesn't touch the host.
|
||||||
- **Root-owned files accumulate invisibly.** (session history) Plugin code under `user/plugins/<name>/` is gitignored by project convention (only `cache-on-save` and `story-blocks` are tracked), so root-owned files pile up in the bind mount without ever appearing in `git status` — they only bite at worktree-removal time. Don't wait for `git status` to reveal them; `find ./user -uid 0 | wc -l` is the real detector.
|
- **Root-owned files accumulate invisibly.** (session history) Plugin code under `user/plugins/<name>/` is gitignored by project convention (only `cache-on-save`, `story-blocks`, and `entry-actions` are tracked), so root-owned files pile up in the bind mount without ever appearing in `git status` — they only bite at worktree-removal time. Don't wait for `git status` to reveal them; `find ./user -uid 0 | wc -l` is the real detector.
|
||||||
- **Keep a `make fix-perms` escape hatch** (`find ./user -uid 0 ... chown`) for residual root files — notably first-boot files the base-image entrypoint writes as root (`config/security.yaml`, `data/api-keys.yaml`), which no `-u` on a make target can reach. After this fix it's a rare mop-up, not a routine step.
|
- **Keep a `make fix-perms` escape hatch** (container-internal `chown -R 1000:1000 /var/www/html`) for residual root files — notably first-boot files the base-image entrypoint writes as root (`config/security.yaml`, `data/api-keys.yaml`), which no `-u` on a make target can reach. After this fix it's a rare mop-up, not a routine step.
|
||||||
- **Verification recipe:** `docker exec -u 1000:1000 <container> touch /mnt/f && stat -c '%u' host/f` should print your uid, not `0`.
|
- **Verification recipe:** `docker exec -u 1000:1000 <container> touch /mnt/f && stat -c '%u' host/f` should print your uid, not `0`.
|
||||||
|
|
||||||
This lives in the Makefile because make targets are the only sanctioned container interface in this project — the fix belongs there, not in ad-hoc docker commands.
|
This lives in the Makefile because make targets are the only sanctioned container interface in this project — the fix belongs there, not in ad-hoc docker commands.
|
||||||
|
|||||||
@@ -99,3 +99,15 @@ config, intentionally not committed as `true`), so both specs **skip loudly**
|
|||||||
with a reason rather than fail misleadingly. They validate whenever the site is
|
with a reason rather than fail misleadingly. They validate whenever the site is
|
||||||
in travelling mode. This is a known gap in this environment, not a silent hole —
|
in travelling mode. This is a known gap in this environment, not a silent hole —
|
||||||
provisioning `travelling: true` in a dedicated test config would close it.
|
provisioning `travelling: true` in a dedicated test config would close it.
|
||||||
|
|
||||||
|
## Related — Part 3: in-place edits + APCu
|
||||||
|
|
||||||
|
The `Cache::invalidateCache()` fix above completes `deleteAll()` for the
|
||||||
|
**create/delete** case, because a new or removed child folder advances
|
||||||
|
`folderHash` and the `system.yaml` touch bumps `config->checksum()`. It is
|
||||||
|
**necessary but not sufficient** for an **in-place frontmatter edit** (e.g. a
|
||||||
|
trip publish toggle) under `cache.driver: auto` (APCu): the folder structure is
|
||||||
|
unchanged, and APCu lives in web-server shared memory that a CLI `bin/grav
|
||||||
|
clearcache` cannot reach. That case additionally requires `apcu_clear_cache()`
|
||||||
|
called from the web request. See
|
||||||
|
[`grav-in-place-header-edit-apcu-cache-stale.md`](grav-in-place-header-edit-apcu-cache-stale.md).
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
---
|
||||||
|
title: "In-place frontmatter edit stays stale under Grav folder-check + APCu cache"
|
||||||
|
date: 2026-07-08
|
||||||
|
category: integration-issues
|
||||||
|
module: entry-actions
|
||||||
|
problem_type: integration_issue
|
||||||
|
component: plugin
|
||||||
|
symptoms:
|
||||||
|
- "After unpublishing a trip via the API, anonymous visitors still saw it on /trips, home, and nav"
|
||||||
|
- "Playwright test TP2 failed: an unpublished trip stayed visible to logged-out users"
|
||||||
|
- "A prior owner-authenticated GET poisoned the page-tree cache before the toggle, making staleness sticky"
|
||||||
|
- "\"bin/grav clearcache\" from the CLI did not bust the stale index at all"
|
||||||
|
- "\"deleteAll()\" and \"pages->reset() + clearCache('standard')\" alone both left the listing stale"
|
||||||
|
root_cause: incomplete_setup
|
||||||
|
resolution_type: code_fix
|
||||||
|
related_components:
|
||||||
|
- cache-on-save
|
||||||
|
- testing_framework
|
||||||
|
- documentation
|
||||||
|
tags:
|
||||||
|
- grav
|
||||||
|
- apcu
|
||||||
|
- cache-invalidation
|
||||||
|
- folder-check
|
||||||
|
- in-place-edit
|
||||||
|
- publish-toggle
|
||||||
|
- page-tree-cache
|
||||||
|
- api-endpoint
|
||||||
|
severity: high
|
||||||
|
---
|
||||||
|
|
||||||
|
# In-place frontmatter edit stays stale under Grav folder-check + APCu cache
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
An owner-only endpoint `POST /api/v1/trip/{slug}/publish` toggles a trip's visibility by mutating `trip.md`'s `published:` frontmatter **in place** — same folder, no folder create or delete — via a header mutation plus `$page->save()`. After saving it must invalidate Grav's page-tree cache so the `/trips` listing, the home render, and the nav all reflect the new visibility on the next load.
|
||||||
|
|
||||||
|
They don't. After the owner unpublishes a trip, anonymous visitors still see it in the `/trips` listing and it stays reachable. None of the usual cache-invalidation idioms fix it, and — critically — a CLI `bin/grav clearcache` cannot bust it at all.
|
||||||
|
|
||||||
|
The failure is the interaction of two facts specific to this project's Grav 2.0.4 setup:
|
||||||
|
|
||||||
|
- `cache.check.method: folder` derives the regular-pages index cache id from a **folder-structure** checksum. An in-place frontmatter edit leaves the folder structure identical, so the cache id is unchanged and the stale index is reused.
|
||||||
|
- `cache.driver: auto` resolves to **APCu** (baked into the project's Docker image). APCu lives in the **web server's** shared memory, so any process outside that web worker — a CLI, a cron job, a `docker exec` — flushes a *different* memory segment and cannot reach it.
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- Owner unpublishes a trip → reload the `/trips` listing as an anonymous visitor → the trip is **still present** and still reachable.
|
||||||
|
- The Playwright spec `tests/ui/trip/trip-publish.spec.js` (TP2) catches it: unpublish → reload as anon → trip still listed.
|
||||||
|
- Staleness is sticky, and worst after a preceding **owner-authenticated GET** has populated the cache.
|
||||||
|
- Running the test harness's `docker exec <container> php bin/grav clearcache` does **not** clear it — the trip stays visible.
|
||||||
|
|
||||||
|
## What Didn't Work
|
||||||
|
|
||||||
|
The investigation chain, in order:
|
||||||
|
|
||||||
|
1. **`$this->grav['cache']->deleteAll()` alone** (the first half of the sibling create/delete fix). Still stale.
|
||||||
|
2. **`Cache::clearCache()`, then `$this->grav['pages']->reset()` + `$this->grav['cache']->clearCache('standard')`.** Still stale.
|
||||||
|
3. **CLI `bin/grav clearcache`** (via `docker exec`, root, a separate PHP process). Could not bust it *at all* — this was the discriminator that pointed straight at APCu: a separate process owns a separate APCu segment.
|
||||||
|
|
||||||
|
Note the documented idiom `deleteAll() + Cache::invalidateCache()` — which touches `system.yaml` to bump `config->checksum()` and thus change the index key — is the correct fix for the **create/delete** case. It is not enough here: the deciding failure is that the cache **store** is APCu in web shared memory, unreachable by the CLI, so a key-bump alone leaves the poisoned store in play across the same web worker.
|
||||||
|
|
||||||
|
Prior create/delete work on this branch had already climbed most of an escalation ladder and stopped one rung short of this case *(session history)*:
|
||||||
|
|
||||||
|
- `deleteAll()` was found to clear only the Doctrine cache store, never rebuilding the compiled page-tree index — the original root cause for both the create (BUG-001) and delete flows.
|
||||||
|
- `touch`ing the `dailies/` folder mtime did **not** flip the stale lookup (suspected Docker bind-mount mtime not propagating), so the pure folder-mtime theory was dropped.
|
||||||
|
- A "clear only `cache/compiled/pages/`" hypothesis was a red herring: **there is no such directory** — the regular-pages index lives in the Doctrine cache keyed by `md5(json_encode(dirs) + folderHash + config->checksum() + lang)` (`Pages.php`).
|
||||||
|
- That work standardized on `deleteAll() + Cache::invalidateCache()` (i.e. `touch(system.yaml)` + opcache reset) as the canonical pattern — and it was **sufficient there because folder-level create/delete advances `folderHash`**. Those sessions never touched APCu at all; the in-place-edit + APCu escalation below is genuinely new.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Flush APCu **from within the web request** that performed the edit, in `EntryActionsApiController::setTripPublished`, right after `$page->save()`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$header = $page->header();
|
||||||
|
$header->published = $published; // KTD1: mutate the HEADER, not $page->published($v) —
|
||||||
|
// save() serializes from the header
|
||||||
|
$page->save();
|
||||||
|
|
||||||
|
$this->grav['cache']->deleteAll();
|
||||||
|
if (function_exists('apcu_clear_cache')) {
|
||||||
|
apcu_clear_cache(); // flush the WEB server's APCu store directly —
|
||||||
|
// a CLI clearcache cannot reach it
|
||||||
|
}
|
||||||
|
$this->grav['pages']->reset(); // drop the in-memory tree so the next request
|
||||||
|
// rebuilds from disk
|
||||||
|
$this->grav['cache']->clearCache('standard');
|
||||||
|
```
|
||||||
|
|
||||||
|
Verified via curl against the running dev container: unpublish → anon listing count drops to 0; republish → back to 1.
|
||||||
|
|
||||||
|
## Why This Works
|
||||||
|
|
||||||
|
- `apcu_clear_cache()` runs inside the **same PHP web process** that owns the APCu segment, so it actually empties the store the frontend reads. This is the piece a CLI clearcache structurally cannot do.
|
||||||
|
- `deleteAll()` + `clearCache('standard')` drop the Doctrine/compiled stores.
|
||||||
|
- `$this->grav['pages']->reset()` forces a **rebuild from disk** on the next request, which re-reads the mutated `published` flag.
|
||||||
|
|
||||||
|
Because the mutation is **in place**, none of Grav's folder-checksum-based self-healing applies (a folder create/delete would change the checksum and self-heal — which is why new-post and delete flows never hit this). The invalidation must therefore be **explicit** *and* must **target the web APCu**. The earlier `Cache::invalidateCache()` fix leaned entirely on the `config->checksum()` term of the index key changing; that still leaves the poisoned APCu store live for the current web worker when the edit is in place.
|
||||||
|
|
||||||
|
## Prevention
|
||||||
|
|
||||||
|
- When an endpoint mutates page frontmatter **in place** (publish toggles, metadata edits) under `cache.check.method: folder`, do **not** rely on `deleteAll()` or on a folder-checksum bump. Explicitly flush APCu from the web request, guarded with `function_exists('apcu_clear_cache')`.
|
||||||
|
- **Never** invalidate web APCu from a CLI/cron/`docker exec` process — it hits a different memory segment. If a CLI must trigger invalidation, it has to go through a web request (curl the endpoint) or a shared driver (file/redis), not APCu.
|
||||||
|
- **Test-harness corollary:** a Playwright helper that clears cache via `docker exec ... bin/grav clearcache` will **not** flush web APCu. Fixture *folders* still appear (folder create bumps the checksum), but in-place/config changes may read stale. Prefer driving the real web endpoint. Cache-mutating E2E specs must run serially (`--workers=1`); mutating global config (`owner_username`, `active_trip`) also collides with parallel readers. See `tests/ui/trip/trip-publish.spec.js`.
|
||||||
|
- **On the divergence from the house idiom:** two independent code reviewers (reliability, maintainability) flagged that this 4-call sequence diverges from the codebase's documented `deleteAll() + Cache::invalidateCache()` idiom. The divergence is **intentional** and specific to in-place-edit + APCu. Pick by case:
|
||||||
|
- create/delete → `Cache::invalidateCache()` (bumps the folder checksum / index key)
|
||||||
|
- in-place edit under APCu → `apcu_clear_cache()` from the web request
|
||||||
|
|
||||||
|
A future improvement is to fold both into one documented helper so future call sites have a single idiom to copy.
|
||||||
|
|
||||||
|
## Related Issues
|
||||||
|
|
||||||
|
- `docs/solutions/integration-issues/grav-deleteall-doesnt-invalidate-page-tree-index.md` — the sibling **CREATE** case: `deleteAll()` doesn't rebuild the index; fix = `Cache::invalidateCache()`. This doc is its **in-place-edit + APCu** counterpart — effectively "Part 3" of that page-tree-cache thread, adding the APCu shared-memory dimension the folder-touch fix did not cover.
|
||||||
@@ -89,7 +89,7 @@ How the code went missing here is **unconfirmed**. It happened around the git-sy
|
|||||||
|
|
||||||
- **Check the code layer before the config layer.** When a Grav plugin "won't enable" and config toggles do nothing, FIRST verify the code exists: `ls user/plugins/<name>/` on the server. Config-without-code is the failure class; the empty directory is the tell.
|
- **Check the code layer before the config layer.** When a Grav plugin "won't enable" and config toggles do nothing, FIRST verify the code exists: `ls user/plugins/<name>/` on the server. Config-without-code is the failure class; the empty directory is the tell.
|
||||||
- **Enumerate both layers in all locations when diagnosing.** Plugins have a code layer (`user/plugins/<name>/`) and a config layer, and on prod the config can live in the env tree (`user/env/<host>/config/plugins/<name>.yaml`) and persist completely independently of the code. Remember: once `user/env/<host>/` exists, Grav Admin writes ALL config there, so always check both `user/config/...` and the env path (env wins).
|
- **Enumerate both layers in all locations when diagnosing.** Plugins have a code layer (`user/plugins/<name>/`) and a config layer, and on prod the config can live in the env tree (`user/env/<host>/config/plugins/<name>.yaml`) and persist completely independently of the code. Remember: once `user/env/<host>/` exists, Grav Admin writes ALL config there, so always check both `user/config/...` and the env path (env wins).
|
||||||
- **Know which plugins are remote-only.** The 3-category model: GPM-via-`plugins.txt` (admin2 / api / flex-objects), custom-in-repo (cache-on-save / story-blocks), and remote-only (git-sync — never in `plugins.txt`). Remote-only plugins are NOT restored by the standard install/content flows, so reinstall them explicitly via GPM after any operation that could have wiped `user/plugins/`.
|
- **Know which plugins are remote-only.** The 3-category model: GPM-via-`plugins.txt` (admin2 / api / flex-objects), custom-in-repo (cache-on-save / story-blocks / entry-actions), and remote-only (git-sync — never in `plugins.txt`). Remote-only plugins are NOT restored by the standard install/content flows, so reinstall them explicitly via GPM after any operation that could have wiped `user/plugins/`.
|
||||||
- **Diagnose actual state before proposing config fixes.** An `ls` is cheaper than a guess. Establishing that the code was missing would have pointed straight at the reinstall instead of a round of config poking.
|
- **Diagnose actual state before proposing config fixes.** An `ls` is cheaper than a guess. Establishing that the code was missing would have pointed straight at the reinstall instead of a round of config poking.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
---
|
||||||
|
date: 2026-07-08
|
||||||
|
topic: travel-blog-reader-experience-and-road-workflow
|
||||||
|
focus: reader experience, story mode, on-the-road posting workflow — ahead of Denmark 2026 (departing ~mid-July)
|
||||||
|
mode: repo-grounded
|
||||||
|
---
|
||||||
|
|
||||||
|
# Ideation: Reader Experience, Story Mode & the Road Workflow
|
||||||
|
|
||||||
|
## Grounding Context
|
||||||
|
|
||||||
|
**Codebase context.** Grav 2.0.7 blog structured around Trips → Entries/Stories (CONCEPTS.md). Posting pipeline is mature and hardened as of the 2026-07-08 journal-post-form ship: `/post` create+edit, FilePond photos with client HEIC→JPEG, live photo editor, draft persistence, owner-scoped `entry-actions` API (delete / reorder / trip publish). Trip page renders inline map + filter-bar feed + stats. `main.js` already has a lightbox. Verified gaps: **no Open Graph / twitter:card meta anywhere in `user/themes/intotheeast/templates/partials/base.html.twig`**, **no RSS/feed plugin installed**, `transport_mode` is serialized into the map JSON (`trip.html.twig:66-69`) but **no JS or partial consumes it**, `entry.html.twig` detail view is a 12-line stub already slated for retirement (`docs/working/backlog.md`).
|
||||||
|
|
||||||
|
**Past learnings & open threads.** Curated-home brainstorm PAUSED mid-layout (hero+stats / map / latest entry / latest story / CTA; marker→popup preview). Per-photo captions deferred (`data-alt` uses filename placeholder). Transport-mode visualization deferred. Story-blocks authoring deferred until real stories are written. `travel-memories` Immich→Grav pipeline complete. Backlog: Komoot GPX pull, GPX-manager polish, full-res photo re-import.
|
||||||
|
|
||||||
|
**External context.** Polarsteps' most-loved follow feature: family views a shared trip link **without an account or app** ([Polarsteps vs FindPenguins](https://voluntouring.org/2025/07/04/polarsteps-vs-findpenguins/), [Polarsteps review](https://www.overlandsite.com/tools/polarsteps-review/)); both apps monetise post-trip printed travel books. RSS-to-email digests (Buttondown, MailerLite, Mailchimp RSS campaigns) are the standard low-friction "family inbox" channel ([RSS-to-email guide](https://www.wprssaggregator.com/rss-to-email/), [service comparison 2026](https://www.readless.app/blog/rss-to-email-services-2026)).
|
||||||
|
|
||||||
|
**Run notes.** Autonomous overnight run: no blocking questions asked; ideation frames applied inline by one agent instead of the parallel fleet (budget-lean), orchestrator-only basis verification. `direct:` bases were verified by grep/read against the working tree this night.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Topic Axes
|
||||||
|
|
||||||
|
- Following along — how family & friends learn there's a new entry
|
||||||
|
- Reading the feed — arrival/dwell experience on the trip page
|
||||||
|
- Story mode — curated set pieces
|
||||||
|
- On-the-road posting — the owner's daily workflow
|
||||||
|
- After the trip — compounding, archive, keepsakes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ranked Ideas
|
||||||
|
|
||||||
|
1. [The follow-along stack](#1-the-follow-along-stack-og--share--rss--digest)
|
||||||
|
2. [The thirty-second post](#2-the-thirty-second-post-quick-log--auto-location--auto-weather)
|
||||||
|
3. [Transport-mode visualization](#3-transport-mode-visualization)
|
||||||
|
4. [The "Today" view](#4-the-today-view-resume-the-curated-home)
|
||||||
|
5. [Per-photo captions](#5-per-photo-captions)
|
||||||
|
6. [Trip Wrapped recap page](#6-trip-wrapped-recap-page)
|
||||||
|
7. [Komoot route pull](#7-komoot-route-pull-in-gpx-manager)
|
||||||
|
|
||||||
|
### 1. The follow-along stack (OG → share → RSS → digest)
|
||||||
|
|
||||||
|
**Description:** Make following the trip effortless for people who will never bookmark a blog. Four stages, each independently shippable, each building on the last:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
A[Stage 1: Open Graph + twitter:card meta\nper entry/trip/story] --> B[Stage 2: Share button on the\npost-success panel - Web Share API]
|
||||||
|
B --> C[Stage 3: RSS/Atom feed\nof the active trip]
|
||||||
|
C --> D[Stage 4: RSS-to-email digest\nfor family inboxes]
|
||||||
|
```
|
||||||
|
|
||||||
|
Stage 1 alone changes every link pasted into WhatsApp/Signal from a bare URL into a photo + title + location card. Stage 2 turns the existing post-success panel ("View your journal / Post another") into "…/ Share this entry" — one tap after every post, while the moment is fresh. Stage 3 gives the RSS-literate a subscription and is the substrate for Stage 4, where a Buttondown/MailerLite RSS campaign mails new entries to subscribed family on a daily/weekly cadence.
|
||||||
|
|
||||||
|
**Axis:** Following along
|
||||||
|
**Basis:** direct: grep confirms zero `og:` / `twitter:` meta tags in `partials/base.html.twig` and no feed plugin in `plugins.txt` or `user/plugins/`; the success panel exists in `post-form.js` (`initSuccessState`). external: Polarsteps' account-free share link is its most-cited family feature; RSS-to-email is a commodity integration.
|
||||||
|
**Rationale:** The site's readers are family and friends on phones in messaging apps — not blog visitors. Every entry already produces a perfect preview image (cover = first photo, by design). This is the highest leverage-to-effort ratio in the whole candidate set, and Stage 1 could ship before departure.
|
||||||
|
**Downsides:** Stage 4 introduces an external service and subscriber management; OG images should respect draft/unpublished state (don't leak draft covers to crawlers); feed must exclude unpublished entries.
|
||||||
|
**Confidence:** 90% (Stage 1–2), 75% (Stage 3–4)
|
||||||
|
**Complexity:** Low (Stage 1–2), Medium (Stage 3–4)
|
||||||
|
|
||||||
|
### 2. The thirty-second post (quick log + auto-location + auto-weather)
|
||||||
|
|
||||||
|
**Description:** A "quick log" posting mode for hard days: one photo + one sentence, no title required (derive it from date/location), plus removal of the two manual taps that remain in the flow — read GPS from the first photo's EXIF server-side to fill `lat`/`lng` when the fields are empty, and fetch weather server-side at submit time from coords + entry date (Open-Meteo archive API for backdated entries). The full form stays for real writing days; quick log keeps the streak alive on the days that produce none.
|
||||||
|
|
||||||
|
**Axis:** On-the-road posting
|
||||||
|
**Basis:** direct: `post-form.md` requires photos + title + content + date; location and weather are manual button taps in `post-form.js` (`initGeo`). reasoned: on a solo trip the binding constraint on journal completeness is end-of-day energy, not tooling; every removed field measurably raises the posting rate — the same logic that already removed the hero-image field and auto-collapsed the photo section.
|
||||||
|
**Rationale:** The blog's value compounds with consistency. Denmark is a cycling trip — many days will end tired. A 30-second floor means zero-entry days become one-photo entries instead of gaps.
|
||||||
|
**Downsides:** EXIF GPS may not survive the client-side HEIC→JPEG conversion (canvas-based converters typically strip metadata) — verify with a real iPhone photo first; if stripped, read EXIF client-side before conversion and post coords explicitly. Title-less entries need a rendering decision in the feed partials.
|
||||||
|
**Confidence:** 70%
|
||||||
|
**Complexity:** Medium
|
||||||
|
|
||||||
|
### 3. Transport-mode visualization
|
||||||
|
|
||||||
|
**Description:** Consume the already-serialized `transport_mode` field: style the map connector line per mode (e.g. dashed for train/bus/plane, solid for walking/cycling) and show the mode emoji/icon on entry cards and map popups. The data is being shipped to the client on every trip page load and rendered nowhere.
|
||||||
|
|
||||||
|
**Axis:** Reading the feed
|
||||||
|
**Basis:** direct: `trip.html.twig:68` serializes `transport_mode` into the map entries JSON; grep finds zero consumers in `maplibre-utils.js`, `main.js`, or any partial. The form select (walking/bicycle/bus/train/car/plane) shipped in the current post form.
|
||||||
|
**Rationale:** For a cycling-centric trip, *how you moved* is half the story the map tells. This closes a loop that was deliberately half-built: the capture side shipped, the display side was deferred. All data will exist from day one of Denmark — the earlier this ships, the more of the trip benefits.
|
||||||
|
**Downsides:** Connector styling interacts with the GPX-vs-connector suppression logic (`force_connect`, same-file proximity checks) — needs care in `MapUtils.initEntryMap`; `js/map.js` rebuild via `make build-assets`.
|
||||||
|
**Confidence:** 85%
|
||||||
|
**Complexity:** Low–Medium
|
||||||
|
|
||||||
|
### 4. The "Today" view (resume the curated home)
|
||||||
|
|
||||||
|
**Description:** Resume the paused curated-home brainstorm with a sharper frame: the active-trip home is the page family checks daily, so lead with *now* — a pulsing last-position marker, "Day 12 · Aarhus · 340 km so far", the latest entry, the latest story, then the full feed/map below. Marker→popup preview (already sketched in the paused brainstorm) makes the map the navigation surface.
|
||||||
|
|
||||||
|
**Axis:** Following along / Reading the feed
|
||||||
|
**Basis:** direct: the curated-home brainstorm exists and is paused at the layout question (hero+stats / map / latest entry / latest story / CTA). external: Polarsteps' follow screen is exactly this — current position + day counter first, log second.
|
||||||
|
**Rationale:** The home page is the URL family will have. Today it renders the same feed chrome as the trip page; a "where is he *now*" lead answers the question every visitor actually arrives with, in one glance, and gives repeat visits a reason.
|
||||||
|
**Downsides:** It's a design decision as much as a build — the brainstorm needs finishing first; risks scope creep against the shared `trip-feed-col` partial (keep the partial single-purpose, add a curated lead above it rather than forking it).
|
||||||
|
**Confidence:** 65%
|
||||||
|
**Complexity:** Medium
|
||||||
|
|
||||||
|
### 5. Per-photo captions
|
||||||
|
|
||||||
|
**Description:** Give photos one-line captions: store per-image captions in Grav media metadata (`<file>.meta.yaml`), add a caption field to the edit-mode photo editor grid (tap a thumbnail → caption input, persisted via the media API), render as museum-style wall text in the feed and lightbox, and use it as real `alt` text (replacing the filename placeholder in `data-alt`).
|
||||||
|
|
||||||
|
**Axis:** Reading the feed
|
||||||
|
**Basis:** direct: `data-alt` currently carries the filename as a placeholder; per-image captions were explicitly deferred "pending Mischa's decision". reasoned: photos carry most of the feed's content weight; a single line of context ("the ferry that almost left without me") is the cheapest possible narrative upgrade and doubles as accessibility.
|
||||||
|
**Rationale:** Between a bare photo grid and a written story there is nothing today; captions are the missing middle register — and they make the eventual printed book/recap dramatically better.
|
||||||
|
**Downsides:** Captioning is one more thing to do on the road (keep it optional and editable later); `.meta.yaml` sidecars must survive the `photo-NN` renumber pipeline (`PhotoRenumberer` currently renames files — sidecars need to move with them, and `deleteUnlistedImages` already deletes them).
|
||||||
|
**Confidence:** 70%
|
||||||
|
**Complexity:** Medium
|
||||||
|
|
||||||
|
### 6. Trip Wrapped recap page
|
||||||
|
|
||||||
|
**Description:** An auto-generated end-of-trip recap: days on the road, total km (GPX-exact where available), entries written, photos taken, countries/towns visited, transport-mode split, biggest climbing day — rendered as a shareable, designed page per trip (`/trips/<slug>/recap` or an inline trip-page section that unlocks when the trip ends). Extension later: print-CSS → the Polarsteps-style trip book.
|
||||||
|
|
||||||
|
**Axis:** After the trip
|
||||||
|
**Basis:** external: Spotify Wrapped / Strava Year in Sport demonstrate the format's shareability; Polarsteps' printed travel book is its flagship post-trip product. direct: the stats machinery (per-file GPX aggregation, cycling stats, haversine fallback) already exists in `initTripStats`.
|
||||||
|
**Rationale:** The site already computes most of these numbers live; a recap reuses them as a keepsake and gives every finished trip a satisfying capstone that the trip page (an infinite feed) doesn't provide. Slovenia/Italy/US archives get retroactive value.
|
||||||
|
**Downsides:** Needs the full-res photo re-import (backlog) before a *printed* extension is worthwhile; design effort is the real cost — a half-designed recap undercuts the point.
|
||||||
|
**Confidence:** 65%
|
||||||
|
**Complexity:** Medium
|
||||||
|
|
||||||
|
### 7. Komoot route pull in gpx-manager
|
||||||
|
|
||||||
|
**Description:** Paste a Komoot tour URL into `/gpx-manager` and the server fetches the GPX (`api.komoot.de` returns GPX per tour ID) and saves it to the trip page — replacing the export→download→upload dance after each riding day.
|
||||||
|
|
||||||
|
**Axis:** On-the-road posting
|
||||||
|
**Basis:** direct: `docs/working/backlog.md` names this with the API endpoint; the gpx-manager UI, slugification, and media API plumbing all exist.
|
||||||
|
**Rationale:** On a cycling trip the GPX step is *daily* friction; this collapses it to a paste. Server-side fetch also sidesteps mobile-browser download/upload juggling.
|
||||||
|
**Downsides:** Auth requirements for non-public tours are unresearched (backlog says the same); Komoot's API is unofficial — could break mid-trip, so the manual upload path must remain first-class.
|
||||||
|
**Confidence:** 60%
|
||||||
|
**Complexity:** Medium
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rejection Summary
|
||||||
|
|
||||||
|
| # | Idea | Reason Rejected |
|
||||||
|
|---|------|-----------------|
|
||||||
|
| 1 | Offline-first `/post` (service-worker queue) | Cost ≫ value: queued multipart uploads vs sessions/nonces is genuinely hard, Denmark coverage is good, and localStorage drafts already protect the text — too risky days before departure |
|
||||||
|
| 2 | Retire entry permalink + add `#anchor` deep links | Already a tracked backlog item; cleanup, not a product direction |
|
||||||
|
| 3 | Auto-story scaffold from a date range | Premature — story authoring tooling is deliberately deferred until real stories have been written; revisit with material in hand |
|
||||||
|
| 4 | No-account emoji reactions on entries | Adds the site's first anonymous public **write** endpoint (abuse/rate-limit/storage surface) right before departure; worth revisiting post-trip as the only "return channel" idea |
|
||||||
|
| 5 | Printed trip book (standalone) | Folded into idea 6 as its extension — the recap is the shippable first step and the book depends on the full-res re-import |
|
||||||
|
| 6 | Full-res pixelfed re-import + srcset | Enabler already tracked in the backlog, not an idea in itself; sequence it before any print/keepsake work |
|
||||||
|
| 7 | Distribution foundation (RSS+OG+sitemap bundle) | Duplicate of idea 1, which stages the same work |
|
||||||
|
| 8 | travel-memories on-trip cadence | Workflow practice with the existing app; nothing to build |
|
||||||
|
| — | axis: story mode | No survivors — deliberate gap: story tooling stays deferred until the first real stories exist (only candidate was rejection #3) |
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# Recommendations from the 2026-07-25 documentation reconciliation
|
||||||
|
|
||||||
|
**Status:** 📋 Proposed — nothing here has been acted on. Decide per item.
|
||||||
|
|
||||||
|
The reconciliation pass (see [`specs/2026-07-25-docs-reconciliation-design.md`](specs/2026-07-25-docs-reconciliation-design.md))
|
||||||
|
corrected the documentation. It also surfaced problems that are **not** documentation problems, plus
|
||||||
|
process changes that would stop this drift recurring. Those are collected here rather than mixed into
|
||||||
|
a docs diff.
|
||||||
|
|
||||||
|
Ordered by what I would do first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P1 — `make start` and `make setup` are broken on any clean checkout
|
||||||
|
|
||||||
|
**What.** `docker-compose.yml` still declares a `travel-memories` service with
|
||||||
|
`build: ./services/travel-memories`. That source was removed in `a80b0a9` ("moved to separate
|
||||||
|
project") and `services/` is gitignored, so the build context does not exist. `make start` is
|
||||||
|
`docker compose up -d` (all services), and `make setup` calls it.
|
||||||
|
|
||||||
|
**Proof.**
|
||||||
|
```
|
||||||
|
$ docker compose build travel-memories
|
||||||
|
unable to prepare context: path ".../services/travel-memories" not found
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why it has stayed hidden.** A machine that built the image before `a80b0a9` still has
|
||||||
|
`travel-blog-intotheeast-travel-memories:latest` cached, so `docker compose up -d` reuses it and never
|
||||||
|
rebuilds. It breaks for a fresh clone, for every new worktree (different `COMPOSE_PROJECT_NAME` →
|
||||||
|
different image name → forced rebuild), and on the main checkout after any `docker image prune`. This
|
||||||
|
is why `make worktree-new` calls `start-grav`, not `start`.
|
||||||
|
|
||||||
|
**Options.**
|
||||||
|
1. **Delete the service from `docker-compose.yml`** (recommended). It lives in another project now. If
|
||||||
|
that project needs to run alongside Grav, it can carry its own compose file.
|
||||||
|
2. Move it into a compose profile (`profiles: [tools]`) so `docker compose up -d` skips it by default.
|
||||||
|
3. Keep it and point `build` at the new location — only if you actually want the two coupled again.
|
||||||
|
|
||||||
|
Until this is decided, `CLAUDE.md` and `README.md` now warn to use `make start-grav`. That is a
|
||||||
|
signpost around a bug, not a fix.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P2 — A repeatable drift check
|
||||||
|
|
||||||
|
Deliberately out of scope for the one-time pass; this is the item that stops the whole problem
|
||||||
|
recurring. Every defect found was mechanically checkable — a route, a path, a token name, a make
|
||||||
|
target, a field rule.
|
||||||
|
|
||||||
|
**Proposal.** A `make docs-check` target that fails loudly when the present-tense docs assert
|
||||||
|
something the code contradicts:
|
||||||
|
|
||||||
|
- Grep `CLAUDE.md`, `docs/reference/`, `docs/guides/`, `README.md`, `CONCEPTS.md` for references to
|
||||||
|
retired routes (`/map`, `/stats`, `/tracker`, `/dailies`, `/stories`) and dead tech (`Leaflet`).
|
||||||
|
These are already forbidden by `CLAUDE.md`, so any hit is a defect.
|
||||||
|
- Assert every `templates/*.html.twig` and `templates/partials/*.html.twig` named in
|
||||||
|
`architecture.md` exists, and flag templates that exist but are undocumented. Both directions of
|
||||||
|
drift were present this pass.
|
||||||
|
- Diff the `--color-*` token names in `design-system.md` against `css/tokens.css`. Six were missing.
|
||||||
|
- Assert every `make <target>` mentioned in `README.md` is a real target, **and** that no bare
|
||||||
|
`remote-*` target is documented without an env suffix. This alone would have caught P4.
|
||||||
|
- Assert file paths cited in `CLAUDE.md` exist. A prior pass shipped a path to
|
||||||
|
`js/src/maplibre-utils.js`, which never existed.
|
||||||
|
|
||||||
|
Deliberately **excluded**: `docs/working/`. Those documents are records and are supposed to drift;
|
||||||
|
scanning them would produce permanent noise.
|
||||||
|
|
||||||
|
Sequence this after P1 — otherwise the first thing the check reports is P1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P3 — Plan status can silently lag a merge
|
||||||
|
|
||||||
|
`plans/2026-07-23-post-form-location-override.md` read `📋 Not started` while the feature was merged
|
||||||
|
in `user/` as `dd19995`. Nothing connects a plan's status line to the commit that lands it, so the
|
||||||
|
convention depends entirely on remembering.
|
||||||
|
|
||||||
|
**Options.**
|
||||||
|
1. **Add the plan path to the feature's commit or PR body**, so `git log --grep` can find plans whose
|
||||||
|
work landed but whose status never moved. Cheapest, no tooling.
|
||||||
|
2. Extend the P2 check: for each plan not `✅ Complete`/`❌ Abandoned`, look for a merged branch whose
|
||||||
|
name matches the plan slug and warn. Catches it automatically; some false positives.
|
||||||
|
3. Accept it and rely on the convention. Reasonable — this was one miss across 41 plans.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P4 — `README.md` was designated authoritative for a list it did not hold
|
||||||
|
|
||||||
|
`CLAUDE.md`'s entry-point table sends readers to `README.md` for "the full `make` command list".
|
||||||
|
Before this pass, README documented 7 of ~20 `remote-*` targets, and documented all of them **without
|
||||||
|
the `-test`/`-prod` suffix that `guard-env` requires** — so its server runbook was not executable.
|
||||||
|
|
||||||
|
Corrected now, but the structural point stands: **a doc promoted to "the authoritative list of X"
|
||||||
|
acquires a completeness obligation it did not have as prose.** The `Makefile` is the real source of
|
||||||
|
truth. Consider either generating the command tables from `Makefile` comments, or softening the
|
||||||
|
CLAUDE.md pointer to "common commands" and letting `make help` be authoritative.
|
||||||
|
|
||||||
|
Related: `docs/guides/deploy-cycle.md` had the env-suffix rule right the whole time. The defect was
|
||||||
|
README duplicating the same knowledge and drifting. Fewer copies would have prevented it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P5 — `shortcode-gallery-plusplus` is installed with no consumer
|
||||||
|
|
||||||
|
`plugins.txt` lists it, but there is no `[gallery]` shortcode anywhere in `templates/` or `pages/`.
|
||||||
|
Entry galleries are PhotoSwipe, wired in `js/src/main.js` against `.pswp-gallery` markup from
|
||||||
|
`partials/entry-journal.html.twig`.
|
||||||
|
|
||||||
|
**Careful before removing it.** `plugins.txt` does **not** list `shortcode-core`, which is present as
|
||||||
|
a GPM dependency — and `story-blocks` needs `shortcode-core`. Dropping
|
||||||
|
`shortcode-gallery-plusplus` could take `shortcode-core` with it and break stories.
|
||||||
|
|
||||||
|
**Recommendation.** Add `shortcode-core` to `plugins.txt` as an explicit, first-class dependency
|
||||||
|
*first*, then remove `shortcode-gallery-plusplus` and verify a story page still renders. Do not do
|
||||||
|
these in one step.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P6 — Demo fixtures still contain retired views
|
||||||
|
|
||||||
|
`user/docs/demo/trips/italy-2025/` ships `map.md`, `stats.md` and `stories.md` — pages for views
|
||||||
|
retired on 2026-07-04. The newer `italy-2026-demo` fixture has no `map.md`/`stats.md`, so the fixtures
|
||||||
|
disagree with each other.
|
||||||
|
|
||||||
|
Low impact (demo trips are gitignored in the pages tree and loaded on demand), but `make demo-load`
|
||||||
|
copies them in, so a demo trip can materialise pages for views that no longer exist. Delete
|
||||||
|
`map.md` and `stats.md` from `italy-2025`; keep `stories.md` only if the container is still needed.
|
||||||
|
|
||||||
|
This is a `user/` submodule change, which is why it was left out of this pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P7 — Structural notes worth a decision
|
||||||
|
|
||||||
|
**`design-system-light.md` is a record, not a reference.** It documents an unimplemented palette and
|
||||||
|
now carries a banner saying so, but it still sits in `reference/` — the "stable facts" tier. Moving it
|
||||||
|
to `docs/working/` would make its status structural rather than dependent on a reader seeing the
|
||||||
|
banner. Counter-argument: it is the natural starting point if a light theme is ever built, and
|
||||||
|
`reference/` is where someone would look. Either is defensible; the banner makes it safe for now.
|
||||||
|
|
||||||
|
**`milestone2-template-refactor-brief.md` sits loose in `docs/working/`** while the milestone docs live
|
||||||
|
in `working/milestones/`. Cosmetic, but it is the kind of thing that makes a folder stop being
|
||||||
|
self-explanatory.
|
||||||
|
|
||||||
|
**The `summary.md` lesson generalises.** The single most misleading line in the tree was
|
||||||
|
`working/README.md` advertising `summary.md` as "current state". A stale document is survivable; an
|
||||||
|
*index* that points at a stale document as authoritative is not, because it defeats the reader's
|
||||||
|
judgement. Worth remembering the next time an index gets written: **describing a document's role is
|
||||||
|
itself a factual claim that can rot.**
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# docs/working/ — work in flight
|
||||||
|
|
||||||
|
Everything here is a live working document: specs being built from, plans being executed, notes from sessions in progress. Once something is finished it stays (as a record) rather than being deleted — the `**Status:**` line is how you tell the difference.
|
||||||
|
|
||||||
|
Stable facts belong in [`../reference/`](../reference/); how-to procedures in [`../guides/`](../guides/); write-ups of bugs already solved in [`../solutions/`](../solutions/).
|
||||||
|
|
||||||
|
> ⚠️ **Everything here is written in the past tense, even when it reads present-tense.** A completed
|
||||||
|
> plan describes the code *as it was when the plan landed* — that is what makes it a useful record,
|
||||||
|
> and it is not a defect when it no longer matches. Several documents here describe features that were
|
||||||
|
> later deliberately reversed: there is no `/map` page, no `/stats` page, no `/tracker`, no Leaflet, no
|
||||||
|
> light theme, and no `hero_image` on entries.
|
||||||
|
>
|
||||||
|
> **Before re-creating anything you find in this folder, check
|
||||||
|
> [`../reference/superseded-decisions.md`](../reference/superseded-decisions.md).** Superseded sections
|
||||||
|
> also carry an inline `> **Superseded …**` note pointing there. For the site as it is, read
|
||||||
|
> [`../reference/architecture.md`](../reference/architecture.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's in here
|
||||||
|
|
||||||
|
| Path | Contents |
|
||||||
|
|---|---|
|
||||||
|
| `specs/` | Design docs — the *what* and *why*, written before a plan. Named `YYYY-MM-DD-<topic>-design.md` |
|
||||||
|
| `plans/` | Implementation plans — the ordered *how*, with a status line. Named `YYYY-MM-DD-<topic>.md` |
|
||||||
|
| `milestones/` | Milestone scope documents (`milestone-1.md` … ) |
|
||||||
|
| `qa/` | Test plans, QA results, readiness audits |
|
||||||
|
| `handovers/` | Session handover notes — context for picking up unfinished work |
|
||||||
|
| `learnings/` | Retrospective notes worth keeping but not yet promoted to `../solutions/` |
|
||||||
|
| `backlog.md` | Unscheduled ideas and wishes |
|
||||||
|
| `bugs-and-fixes.md` | Running log of bugs found and what fixed them |
|
||||||
|
| `summary.md` | **Historical** wrap-up of the original four-milestone branch (2026-06-21). *Not* the current state — for that read [`../reference/architecture.md`](../reference/architecture.md) |
|
||||||
|
| `pm-analysis.md`, `git-sync-notes.md`, dated one-offs | Standalone notes, kept for reference |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plan status convention
|
||||||
|
|
||||||
|
Every plan in `plans/` carries a `**Status:**` line immediately after its title heading. This is the single place a plan's state is recorded — there is no separate tracker.
|
||||||
|
|
||||||
|
| Status | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `📋 Not started` | Plan written and reviewed; no work begun yet |
|
||||||
|
| `🔄 In progress — <note>` | Actively being worked on. The note says where it stopped, so anyone (or any session) can resume |
|
||||||
|
| `⏸️ Deferred — <reason>` | Intentionally postponed. Still valid, just not now — the reason matters more than the status |
|
||||||
|
| `✅ Complete (YYYY-MM-DD)` | Done and shipped. The date is when it landed, not when the plan was written |
|
||||||
|
| `❌ Abandoned — <reason>` | Won't be implemented. Kept so the decision (and its reasoning) is not re-litigated later |
|
||||||
|
|
||||||
|
Notes on using it:
|
||||||
|
|
||||||
|
- **A trailing note after `✅ Complete` is normal and encouraged** for anything non-trivial — what actually shipped, what was deferred, which commit or environment it landed in. Several plans here carry a paragraph.
|
||||||
|
- **`Deferred` is not `Abandoned`.** Deferred means "still want this"; abandoned means "decided against it". Keeping them distinct is the whole point of having both.
|
||||||
|
- **Update the status when the work lands**, not later. A plan whose status lags reality is worse than no plan, because it is trusted.
|
||||||
|
|
||||||
|
### Asking Claude what's open
|
||||||
|
|
||||||
|
Claude reads these statuses directly (the convention is also in [`../../CLAUDE.md`](../../CLAUDE.md), so it applies without being asked). When asked what's open it will surface `Not started` and `In progress`, show `Deferred` items with the label made explicit, and leave out `Complete` and `Abandoned` unless you ask for them. It sets the status to `✅ Complete (YYYY-MM-DD)` on finishing a plan.
|
||||||
|
|
||||||
|
A quick manual sweep of the same thing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rH '^\*\*Status:\*\*' docs/working/plans/ | grep -v 'Complete\|Abandoned'
|
||||||
|
```
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
## ⚠️ Config lives in the ENVIRONMENT tree, not `user/config/` (IMPORTANT)
|
## ⚠️ Config lives in the ENVIRONMENT tree, not `user/config/` (IMPORTANT)
|
||||||
|
|
||||||
Prod has a per-environment override directory `user/env/<hostname>/config/`
|
Prod has a per-environment override directory `user/env/<hostname>/config/`
|
||||||
(created for Twig prod-mode — see CLAUDE.md §1). **A crucial Grav side effect:
|
(created for Twig prod-mode — see [`../guides/deploy-cycle.md`](../guides/deploy-cycle.md) →
|
||||||
|
"The env override tree"). **A crucial Grav side effect:
|
||||||
once that env dir exists, the Admin panel saves ALL config changes — system and
|
once that env dir exists, the Admin panel saves ALL config changes — system and
|
||||||
plugin — into the active environment's config tree**, not `user/config/`.
|
plugin — into the active environment's config tree**, not `user/config/`.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
# Documentation Reconciliation — Handover
|
||||||
|
|
||||||
|
**Date:** 2026-07-25
|
||||||
|
**Branch:** `feat/docs-reconcile` (worktree `.worktrees/docs-reconcile`, dev server :8091)
|
||||||
|
**State:** Work **complete and pushed**. Remaining: **open the PR** (needs one interactive command) and **decide on 7 logged recommendations**. No code was changed; no `user/` commits were made.
|
||||||
|
|
||||||
|
Two audiences:
|
||||||
|
- **Part A — Claude → future Claude:** exact state, the one trap that nearly caused a regression, and what must not be "tidied up".
|
||||||
|
- **Part B — Mischa:** the two things only you can do.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part A — Handover (Claude → future Claude)
|
||||||
|
|
||||||
|
### What this branch delivers
|
||||||
|
|
||||||
|
A whole-repo reconciliation of the documentation against the code, after five weeks in which the app
|
||||||
|
changed and the docs did not. **The code was treated as the source of truth throughout.**
|
||||||
|
|
||||||
|
Three deliverables:
|
||||||
|
|
||||||
|
1. **`docs/reference/superseded-decisions.md`** (new) — the supersession ledger. 14 reversals, each with
|
||||||
|
what was planned, where, what is true now, when it changed, and why. Plus a "decisions that were
|
||||||
|
*not* reversed" section so old planning docs don't all read as suspect.
|
||||||
|
2. **Inline `> **Superseded …**` notes** at each stale claim, in the 4 milestone docs, `summary.md`,
|
||||||
|
`pm-analysis.md`, and `design-system-light.md`. Reuses the repo's existing `> History:` /
|
||||||
|
`> **Changed 2026-07:**` patterns — do not invent a third convention.
|
||||||
|
3. **Corrections to the nine present-tense docs** (`CLAUDE.md`, `README.md`, `CONCEPTS.md`,
|
||||||
|
`docs/README.md`, `docs/working/README.md`, `reference/architecture.md`,
|
||||||
|
`reference/design-system.md`, `reference/design-system-light.md`, `guides/posting.md`).
|
||||||
|
|
||||||
|
Plus the compounded learning (`docs/solutions/conventions/reconciling-drifted-docs-tense-tiering-and-a-supersession-ledger.md`),
|
||||||
|
the design/verification record (`docs/working/specs/2026-07-25-docs-reconciliation-design.md`), and the
|
||||||
|
unacted findings (`docs/working/2026-07-25-doc-drift-recommendations.md`).
|
||||||
|
|
||||||
|
### The governing idea — do not undo this
|
||||||
|
|
||||||
|
Scope was split by **tense**, because the halves need opposite treatment:
|
||||||
|
|
||||||
|
| Kind | Staleness is | Treatment |
|
||||||
|
|---|---|---|
|
||||||
|
| Present-tense: `CLAUDE.md`, `reference/`, `guides/`, `README.md`, `CONCEPTS.md` | a **defect** | corrected against the code |
|
||||||
|
| Past-tense: `plans/`, `specs/`, `milestones/`, `summary.md`, `pm-analysis.md` | **correct and expected** | annotated only, **never rewritten** |
|
||||||
|
|
||||||
|
**A completed plan is supposed to be stale — that is what makes it a record.** If a future session is
|
||||||
|
tempted to "finish the job" by rewriting the milestone docs or the 41 completed plans to match today's
|
||||||
|
code, that is the wrong instinct and destroys the audit trail. The banners are the fix.
|
||||||
|
|
||||||
|
### Commits (all on `feat/docs-reconcile`, all pushed)
|
||||||
|
|
||||||
|
`origin/feat/docs-reconcile` == local `HEAD` == `7c9c140`.
|
||||||
|
|
||||||
|
- `8202d2a` — the reconciliation: ledger + inline notes + the nine present-tense corrections
|
||||||
|
- `d946eaa` — compounded learning into `docs/solutions/conventions/` + new `CONCEPTS.md` Documentation cluster
|
||||||
|
- `7c9c140` — **merge of `main`** (see the trap below)
|
||||||
|
|
||||||
|
Net diff vs `main` is 19 files, +897/−60, **no deletions**, and the submodule gitlink is byte-identical
|
||||||
|
to `main`.
|
||||||
|
|
||||||
|
### ⚠️ The trap — `main` moved 13 commits mid-audit
|
||||||
|
|
||||||
|
This is the most important thing on this page.
|
||||||
|
|
||||||
|
While the audit ran, the location-override work was merged into the outer repo, advancing `main` by 13
|
||||||
|
commits. **It independently fixed two of the audit's own findings:**
|
||||||
|
|
||||||
|
- `829325c` — carved out the single-map-path exception for `js/src/location-map.js` in `CLAUDE.md`
|
||||||
|
- `a517331` — set `2026-07-23-post-form-location-override.md` to `✅ Complete`
|
||||||
|
|
||||||
|
Had this branch been merged without first merging `main` in, it would have **reverted both**. Both
|
||||||
|
conflicts were resolved **in `main`'s favour** (its wording was better informed in each case), and the
|
||||||
|
audit's own notes were then corrected to stop claiming credit for fixes it did not make.
|
||||||
|
|
||||||
|
**If you pick this up on 2026-07-26 or later, `main` may have moved again. Do this first:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd .worktrees/docs-reconcile
|
||||||
|
git fetch origin
|
||||||
|
git log --oneline HEAD..origin/main # anything here? then merge before touching the PR
|
||||||
|
git merge origin/main # read each conflict as a finding, not a chore
|
||||||
|
```
|
||||||
|
|
||||||
|
Two rules that came out of this, now recorded in the learning doc §6:
|
||||||
|
- **Re-check the baseline before publishing, not only before starting.** A long audit races the work it audits.
|
||||||
|
- **When the incoming version is better, take it wholesale.** An audit has no special authority over the work it audits.
|
||||||
|
|
||||||
|
### Submodule situation
|
||||||
|
|
||||||
|
- `user/` in this worktree is on branch `feat/docs-reconcile` at **`dd19995`** — I moved it off the
|
||||||
|
outer repo's older pin (`02fa4e9`) so the audit ran against the state that actually runs. Auditing
|
||||||
|
the pin would have reported a shipped feature as unbuilt.
|
||||||
|
- **No commits were made inside `user/`.** `git -C user status` is clean. Nothing to push there.
|
||||||
|
- The merge commit **preserves `main`'s pin bump to `dd19995`**. The no-gitlink-commit rule in
|
||||||
|
`CLAUDE.md` is about not bumping the pin as a side effect of routine work — not about discarding a
|
||||||
|
bump `main` already made. An earlier `git reset -- user` here had silently reverted it to the old pin;
|
||||||
|
that was caught and fixed. **Verify before any future commit on this branch:**
|
||||||
|
`git diff main..HEAD -- user` must be empty.
|
||||||
|
|
||||||
|
### What was verified, and how
|
||||||
|
|
||||||
|
Nothing was inferred from prose. Full table in the spec doc; the load-bearing ones:
|
||||||
|
|
||||||
|
| Claim | Verified against |
|
||||||
|
|---|---|
|
||||||
|
| Nav labels | `partials/base.html.twig:27-31` |
|
||||||
|
| Asset sources → outputs | the theme's `package.json` build script |
|
||||||
|
| `css-compiled/` provenance | CSS imports in `js/src/*.js`; `assets.addCss` in `base.html.twig:7-8` |
|
||||||
|
| No light mode | absence of `prefers-color-scheme` / `data-theme` **and** of light hex values in `css/` |
|
||||||
|
| Photo rules (1–6, required) | `user/pages/02.post/post-form.md:35-46` |
|
||||||
|
| `entry-actions` routes | `entry-actions.php:63-73` |
|
||||||
|
| Env-suffix rule | `Makefile` `guard-env:41-43` + the `make-env-target` macro at `:45-46` |
|
||||||
|
| `travel-memories` removal | `git log -- services/` → `a80b0a9`, then a real `docker compose build` failure |
|
||||||
|
|
||||||
|
**One finding was withdrawn** after reading `package.json`: the asset table lists esbuild *entry
|
||||||
|
points*, so imported-only sources (`api-utils.js`, `location-map.js`, `map-style.js`, `post-form.css`)
|
||||||
|
are correctly absent from it. If a future pass "fixes" that table by adding them, it is reintroducing a
|
||||||
|
non-defect.
|
||||||
|
|
||||||
|
Checks run before pushing: no conflict markers anywhere; `ce-compound`'s frontmatter validator exits 0;
|
||||||
|
all relative links resolve. **One link check hit is a known false positive** —
|
||||||
|
`../reference/architecture.md` inside a ```diff fence in the learning doc, quoting
|
||||||
|
`docs/working/README.md`'s literal content, where that path is correct.
|
||||||
|
|
||||||
|
### Not done, deliberately
|
||||||
|
|
||||||
|
- **The PR is not open.** `tea` requires an interactive TTY for the SSH passphrase. Command in Part B.
|
||||||
|
- **None of the 7 recommendations were acted on**, per instruction. They are decisions, not chores —
|
||||||
|
several are behaviour changes that would have made this diff unreviewable as documentation.
|
||||||
|
- **No `user/` changes**, including the `italy-2025` demo fixtures (recommendation P6).
|
||||||
|
|
||||||
|
### Do not, without being asked
|
||||||
|
|
||||||
|
- Rewrite any past-tense doc to match current code — annotate instead.
|
||||||
|
- Act on `docs/working/2026-07-25-doc-drift-recommendations.md`. **P5 in particular is booby-trapped:**
|
||||||
|
removing `shortcode-gallery-plusplus` may take `shortcode-core` with it (it is a GPM dependency and is
|
||||||
|
*not* in `plugins.txt`), which would break stories. Add `shortcode-core` explicitly first, in its own
|
||||||
|
step, then remove and verify a story page renders.
|
||||||
|
- Bump the submodule pin beyond preserving `main`'s.
|
||||||
|
- `content-push` — nothing here touches content.
|
||||||
|
|
||||||
|
### Environment
|
||||||
|
|
||||||
|
Worktree dev server on **http://localhost:8091** (`itte_docs-reconcile_grav`, from `.worktree-env`).
|
||||||
|
Nothing in this branch needs a running server — it is documentation only — so tearing it down is safe:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make worktree-rm NAME=docs-reconcile # compose down → submodule deinit → worktree remove → prune
|
||||||
|
```
|
||||||
|
|
||||||
|
The branch is pushed, so removing the worktree loses nothing. Note `main`'s `cfe070e` fixed
|
||||||
|
`worktree-rm` so it no longer unregisters `user/` for the main checkout — that fix is in this branch via
|
||||||
|
the merge.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part B — For Mischa
|
||||||
|
|
||||||
|
### 1. Open the PR (one command)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/.worktrees/docs-reconcile
|
||||||
|
tea pr create --login git.gorinskat.nl --repo m038/intotheeast-com \
|
||||||
|
--head feat/docs-reconcile --base main \
|
||||||
|
--title "docs: reconcile documentation against the code; add a supersession ledger" \
|
||||||
|
--description "$(cat /home/mischa/.claude-work/jobs/18e4d444/tmp/pr-body.md)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Or in the browser: https://git.gorinskat.nl/m038/intotheeast-com/pulls/new/feat/docs-reconcile
|
||||||
|
|
||||||
|
⚠️ The body file lives in a Claude job directory and disappears when that job is deleted. If it is
|
||||||
|
already gone, the PR description is reconstructable from
|
||||||
|
`docs/working/specs/2026-07-25-docs-reconciliation-design.md` plus the recommendations doc.
|
||||||
|
|
||||||
|
### 2. Decide on the recommendations
|
||||||
|
|
||||||
|
`docs/working/2026-07-25-doc-drift-recommendations.md`, ordered. The first is a live bug:
|
||||||
|
|
||||||
|
| | What | Why it needs you |
|
||||||
|
|---|---|---|
|
||||||
|
| **P1** | `make start` / `make setup` fail on any clean checkout — `docker-compose.yml` still builds `travel-memories`, whose source you removed in `a80b0a9` | Three options (delete the service / put it behind a compose profile / re-point `build`). It is your call whether that project ever runs alongside Grav again. **It works on your machine only because a pre-removal Docker image is cached** — it breaks in every new worktree and after any `docker image prune` |
|
||||||
|
| **P2** | A repeatable `make docs-check` | The half you deferred. Every defect this pass found was mechanically checkable, so this is what stops the drift recurring. Sequence it *after* P1, or the first thing it reports is P1 |
|
||||||
|
| **P3** | Plan status can silently lag a merge | Three options, cheapest is naming the plan path in the feature commit |
|
||||||
|
| **P4** | `README.md` was designated authoritative for a list it did not hold | Structural: either generate the command tables from the `Makefile`, or soften the `CLAUDE.md` pointer |
|
||||||
|
| **P5** | `shortcode-gallery-plusplus` has no consumer | ⚠️ See the booby-trap warning in Part A before touching it |
|
||||||
|
| **P6** | `italy-2025` demo fixtures still ship `map.md` / `stats.md` for retired views | A `user/` submodule change, so it was out of scope here |
|
||||||
|
| **P7** | Structural notes — e.g. whether `design-system-light.md` should move out of `reference/`, since it documents a theme that does not exist | Judgement calls, both defensible |
|
||||||
|
|
||||||
|
### 3. Worth knowing
|
||||||
|
|
||||||
|
The three findings most likely to have bitten you in practice:
|
||||||
|
|
||||||
|
- **`posting.md` would have failed if followed** — it said photos were optional; they are required, 1–6.
|
||||||
|
- **Every `make remote-*` command in `README.md` was unrunnable** — all documented without the
|
||||||
|
`-test`/`-prod` suffix `guard-env` requires. `deploy-cycle.md` had it right the whole time; the defect
|
||||||
|
was a second copy of the knowledge drifting from the first.
|
||||||
|
- **`docs/working/README.md` advertised `summary.md` as "current state"** while `summary.md` describes
|
||||||
|
Leaflet, `/tracker`, `/map` and `/stats`. An index that vouches for a stale doc is worse than the
|
||||||
|
stale doc, because it defeats your judgement before it engages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- `docs/working/specs/2026-07-25-docs-reconciliation-design.md` — design, scope rationale, and the full verification table
|
||||||
|
- `docs/reference/superseded-decisions.md` — the ledger itself
|
||||||
|
- `docs/working/2026-07-25-doc-drift-recommendations.md` — the 7 unacted findings
|
||||||
|
- `docs/solutions/conventions/reconciling-drifted-docs-tense-tiering-and-a-supersession-ledger.md` — the compounded learning
|
||||||
|
- `docs/solutions/conventions/claude-md-content-tiering.md` — the prior learning this extends; flagged as a consolidation candidate if a third documentation learning appears
|
||||||
@@ -2,6 +2,18 @@
|
|||||||
|
|
||||||
**Goal:** Every entry is richer out of the box — location name shown, weather auto-captured, photos in a proper gallery, hero image visible on the feed.
|
**Goal:** Every entry is richer out of the box — location name shown, weather auto-captured, photos in a proper gallery, hero image visible on the feed.
|
||||||
|
|
||||||
|
> **Historical — written 2026-06-21. Mostly shipped as specified; three details reversed.**
|
||||||
|
>
|
||||||
|
> Still true: the location badge, Open-Meteo weather auto-fetch with its eight `weather_desc` values,
|
||||||
|
> and the entry photo gallery. Reversed since:
|
||||||
|
> - **§1.5 gallery** is PhotoSwipe, not `shortcode-gallery-plusplus` (R10).
|
||||||
|
> - **§1.6 `hero_image`** no longer exists on entries — the hero is the first uploaded photo, and the
|
||||||
|
> owner controls photo order by drag-reorder (R7). Stories still use `hero_image`.
|
||||||
|
> - **Photos are now required** (1–6 per entry), not optional (R8).
|
||||||
|
> - **"Tracker feed"** is the trip page and the home active-trip view; there is no `/tracker` (R3).
|
||||||
|
>
|
||||||
|
> Details: [`../../reference/superseded-decisions.md`](../../reference/superseded-decisions.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## User Stories
|
## User Stories
|
||||||
|
|||||||
@@ -2,6 +2,21 @@
|
|||||||
|
|
||||||
**Goal:** A `/map` page shows all entries as markers on an interactive Leaflet.js map, connected by a chronological route line, with popups linking to entries.
|
**Goal:** A `/map` page shows all entries as markers on an interactive Leaflet.js map, connected by a chronological route line, with popups linking to entries.
|
||||||
|
|
||||||
|
> **Superseded — written 2026-06-21. Neither the `/map` page nor Leaflet exists.**
|
||||||
|
>
|
||||||
|
> - **No `/map` route.** The map renders inline on the trip page via the single shared partial
|
||||||
|
> `templates/partials/entry-map.html.twig` (R1, retired 2026-07-04). `CLAUDE.md` forbids
|
||||||
|
> re-creating it or linking to it.
|
||||||
|
> - **Leaflet + OpenStreetMap tiles → MapLibre GL JS** with a CartoDB dark-matter basemap (R4,
|
||||||
|
> 2026-06-20).
|
||||||
|
> - **§2.6 nav link** is gone with the page (R6).
|
||||||
|
>
|
||||||
|
> The *substance* of this spec survived — markers per entry, chronological route line, popups linking
|
||||||
|
> to entries, bounds fitting, mobile touch handling — it all lives in `MapUtils.initEntryMap()` in
|
||||||
|
> `user/themes/intotheeast/js/maplibre-utils.js`. Only the page and the library changed.
|
||||||
|
>
|
||||||
|
> Details: [`../../reference/superseded-decisions.md`](../../reference/superseded-decisions.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## User Stories
|
## User Stories
|
||||||
|
|||||||
@@ -2,6 +2,17 @@
|
|||||||
|
|
||||||
**Goal:** A `/stats` page showing key trip numbers: days on the road, entries posted, countries visited, and approximate distance traveled.
|
**Goal:** A `/stats` page showing key trip numbers: days on the road, entries posted, countries visited, and approximate distance traveled.
|
||||||
|
|
||||||
|
> **Superseded — written 2026-06-21. There is no `/stats` page.**
|
||||||
|
>
|
||||||
|
> The stats themselves shipped and still work — days on the road, entries posted, countries visited,
|
||||||
|
> distance (exact from GPX, or a `~`-prefixed haversine estimate without it). They render **inline on
|
||||||
|
> the trip page** behind a toggle, computed by `window.initTripStats()` in `js/src/main.js`
|
||||||
|
> (R2, retired 2026-07-04). `CLAUDE.md` forbids re-creating the standalone view.
|
||||||
|
>
|
||||||
|
> Also reversed: **§3.7 nav link** (R6), and the `/tracker` references (R3).
|
||||||
|
>
|
||||||
|
> Details: [`../../reference/superseded-decisions.md`](../../reference/superseded-decisions.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## User Stories
|
## User Stories
|
||||||
|
|||||||
@@ -2,6 +2,20 @@
|
|||||||
|
|
||||||
**Goal:** Embed a compact interactive map above the entry feed on the tracker page, showing recent entry positions and the current location, giving readers immediate spatial context.
|
**Goal:** Embed a compact interactive map above the entry feed on the tracker page, showing recent entry positions and the current location, giving readers immediate spatial context.
|
||||||
|
|
||||||
|
> **Superseded — written 2026-06-21. The idea won; this implementation did not.**
|
||||||
|
>
|
||||||
|
> A map beside the feed is exactly what the site does now — but not as a separate "mini-map":
|
||||||
|
> - **No `/tracker` page** to embed it above (R3). The map sits in a column on the trip page and the
|
||||||
|
> home active-trip view.
|
||||||
|
> - **No second map implementation.** This spec's `feed-map` variant with its own inline init was
|
||||||
|
> deleted; everything goes through the one shared `partials/entry-map.html.twig` +
|
||||||
|
> `MapUtils.initEntryMap()` path (R12, consolidated 2026-06-27). Adding a second display map is
|
||||||
|
> forbidden by `CLAUDE.md`.
|
||||||
|
> - **Leaflet → MapLibre GL JS** (R4), so §4.1's `if (typeof L === 'undefined')` guard is obsolete.
|
||||||
|
> - **No "View full map →" link** — there is no full map page to link to (R1).
|
||||||
|
>
|
||||||
|
> Details: [`../../reference/superseded-decisions.md`](../../reference/superseded-decisions.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## User Stories
|
## User Stories
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ date: 2026-07-04
|
|||||||
|
|
||||||
# Front-End Journal Entry Edit - Plan
|
# Front-End Journal Entry Edit - Plan
|
||||||
|
|
||||||
**Status:** 🔄 In progress — M1 (U1–U6) complete & verified (V1–V7). M2 **partially delivered** (2026-07-05): **U7 (load existing photos into FilePond) + remove + reorder** are implemented and verified end-to-end on the :8091 container — V9 (photos load, cover-ordered) and V10 (remove a photo, reorder so a different image is the cover; on-disk `photo-1..N` renumber) both pass; reconcile helpers also covered by a reflection unit test (4 cases). One real bug found & fixed en route: `onFormProcessed` fires once per `process:` action (4×), so photo reconciliation is now latched to run **once** (a 2nd pass deleted the just-renamed `photo-N` files). Changes are in `cache-on-save.php` (edit-aware reconcile) + `post-form.js` (U7 load, D1 disable-sweep excludes the FilePond field). **R9 (add NEW photos on edit) now WORKS (2026-07-05)** via a local patch to add-page-by-form. Root cause: its edit-mode merge read existing frontmatter with `(array)$page->header()`, but Grav 2.0's `Grav\Common\Page\Header` keeps data in a protected `items`, so the cast mangled keys (`\0*\0items`) and `$original_frontmatter['photos']` was never set → `array_merge(null,…)` TypeError on any edit that uploads a file. Fix: use `Header::toArray()` (clean keys) + guard the per-field merge. add-page-by-form is abandoned upstream (last release Sept 2023) and its dir is **git-ignored/GPM-managed**, so the patch is tracked as `deploy/patches/add-page-by-form-grav2-header.patch` and re-applied via `make apply-plugin-patches` after any plugin reinstall — until the plugin is forked. Verified end-to-end on :8091: add a photo, remove one, reorder, and all three combined in one save (cover=first, existing preserved, dropped removed); create-with-photos and edit remove/reorder regressions still pass. (Grav 2.0.7 does **not** fix this on its own — the Header object is unchanged across the patch; only the plugin fix does.) **Code-review complete (2026-07-07)** — the multi-agent review of the branch ran and all findings (F1–F8) were applied & verified (20/20 post specs on :8091); the review's own PERF finding confirmed and hardened the once-per-submit cache latch noted above. **Implementation + review are done; the only remaining items are (1) owner-session UI QA and (2) the deliberate landing step (merge `user/`→main, pin bump, `content-push`, deploy).** Both are captured in `docs/working/handovers/2026-07-07-journal-post-form-review-handover-and-qa.md`. Still **not merged / not deployed** — held for owner QA.
|
**Status:** ✅ Complete (2026-07-08) — M1 (U1–U6) complete & verified (V1–V7). M2 **partially delivered** (2026-07-05): **U7 (load existing photos into FilePond) + remove + reorder** are implemented and verified end-to-end on the :8091 container — V9 (photos load, cover-ordered) and V10 (remove a photo, reorder so a different image is the cover; on-disk `photo-1..N` renumber) both pass; reconcile helpers also covered by a reflection unit test (4 cases). One real bug found & fixed en route: `onFormProcessed` fires once per `process:` action (4×), so photo reconciliation is now latched to run **once** (a 2nd pass deleted the just-renamed `photo-N` files). Changes are in `cache-on-save.php` (edit-aware reconcile) + `post-form.js` (U7 load, D1 disable-sweep excludes the FilePond field). **R9 (add NEW photos on edit) now WORKS (2026-07-05)** via a local patch to add-page-by-form. Root cause: its edit-mode merge read existing frontmatter with `(array)$page->header()`, but Grav 2.0's `Grav\Common\Page\Header` keeps data in a protected `items`, so the cast mangled keys (`\0*\0items`) and `$original_frontmatter['photos']` was never set → `array_merge(null,…)` TypeError on any edit that uploads a file. Fix: use `Header::toArray()` (clean keys) + guard the per-field merge. add-page-by-form is abandoned upstream (last release Sept 2023) and its dir is **git-ignored/GPM-managed**, so the patch is tracked as `deploy/patches/add-page-by-form-grav2-header.patch` and re-applied via `make apply-plugin-patches` after any plugin reinstall — until the plugin is forked. Verified end-to-end on :8091: add a photo, remove one, reorder, and all three combined in one save (cover=first, existing preserved, dropped removed); create-with-photos and edit remove/reorder regressions still pass. (Grav 2.0.7 does **not** fix this on its own — the Header object is unchanged across the patch; only the plugin fix does.) **Code-review complete (2026-07-07)** — the multi-agent review of the branch ran and all findings (F1–F8) were applied & verified (20/20 post specs on :8091); the review's own PERF finding confirmed and hardened the once-per-submit cache latch noted above. **Landed 2026-07-08:** merged to `main` in both repos with `feat/journal-post-form`, pin bumped, and content pushed to Gitea → prod (outer pin `f4ab730` == `user/` `main` == `origin/main`). Owner-session UI QA and on-device touch-drag (Part B of `docs/working/handovers/2026-07-07-journal-post-form-review-handover-and-qa.md`) both passed 2026-07-08.
|
||||||
|
|
||||||
## Goal Capsule
|
## Goal Capsule
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ date: 2026-07-05
|
|||||||
|
|
||||||
# Photo Editor for Journal Entries (media-API) — Plan
|
# Photo Editor for Journal Entries (media-API) — Plan
|
||||||
|
|
||||||
**Status:** 🔄 In progress — implemented & server-logic verified (2026-07-05). Server (shared `PhotoRenumberer`, reorder route, guards) + client (own grid, SortableJS, FilePond decommission) landed; `PhotoRenumberer` unit-verified (pad/normalise/swap/gap/crafted-name-safety/10+/idempotent/ext), PHP lints clean, JS/CSS build clean, `/post` + assets serve on :8091. Server-side SVG block deferred to the R6 add/delete fast-follow (see Deferred). **Pending owner-session UI verification** (add incl. HEIC, inline-confirm delete, mouse reorder, combined; feed cover=first; regressions a/b/c) and **on-device touch-drag** — both need the owner login the harness can't obtain.
|
**Status:** ✅ Complete (2026-07-08). Server (shared `PhotoRenumberer`, reorder route, guards) + client (own grid, SortableJS, FilePond decommission) landed; `PhotoRenumberer` unit-verified (pad/normalise/swap/gap/crafted-name-safety/10+/idempotent/ext), PHP lints clean, JS/CSS build clean. Shipped with `feat/journal-post-form` — **merged to `main` in both repos and deployed** (outer pin `f4ab730` == `user/` `main` == `origin/main`; content pushed to Gitea → prod). Owner-session UI QA (add incl. HEIC, inline-confirm delete, mouse reorder, combined, feed cover=first, regressions a/b/c) and on-device touch-drag both passed 2026-07-08. Server-side SVG block deferred to the R6 add/delete fast-follow (see Deferred).
|
||||||
|
|
||||||
## Why this exists (the honest reason)
|
## Why this exists (the honest reason)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
---
|
||||||
|
title: Trip Publish/Unpublish Toggle - Plan
|
||||||
|
type: feat
|
||||||
|
date: 2026-07-08
|
||||||
|
origin: docs/working/specs/2026-07-08-trip-publish-toggle-design.md
|
||||||
|
artifact_contract: ce-unified-plan/v1
|
||||||
|
artifact_readiness: implementation-ready
|
||||||
|
product_contract_source: legacy-requirements
|
||||||
|
execution: code
|
||||||
|
---
|
||||||
|
|
||||||
|
# Trip Publish/Unpublish Toggle - Plan
|
||||||
|
|
||||||
|
**Status:** ✅ Complete (2026-07-08)
|
||||||
|
|
||||||
|
## Goal Capsule
|
||||||
|
|
||||||
|
- **Objective:** Let the logged-in site owner publish/unpublish any trip from the `/trips` listing, with correct page-tree cache invalidation so the change is reflected everywhere on the next load. Anonymous/non-owner visitors see no change.
|
||||||
|
- **Authority hierarchy:** The design doc (`docs/working/specs/2026-07-08-trip-publish-toggle-design.md`) is authoritative for behavior; this plan is authoritative for sequencing and file-level implementation. Repo conventions (CLAUDE.md) and the cited existing patterns override any incidental detail here.
|
||||||
|
- **Stop conditions:** Surface a blocker if implementation reveals that Grav 2.0's `$page->save()` does not persist `published` from a mutated header (the pattern KTD1 depends on), or that `$pages->find()` refuses to resolve unpublished trips from the listing context — either contradicts the design doc's cited behavior.
|
||||||
|
- **Execution profile:** Standard feature — one owner-gated API write, one shared partial, two template edits, one new bundled JS file, CSS, and seven Playwright specs (TP1, TP1b, TP2–TP6). Test-after is fine except U7, which is written against the finished surfaces.
|
||||||
|
- **Tail ownership:** Rebuild theme assets (`make build-assets`) after U6; run the trip Playwright suite after U7.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Product Contract
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
Add an owner-only publish/unpublish switch to each card on the `/trips` listing. The switch POSTs to a new `entry-actions` route that mutates the trip's `trip.md` frontmatter (`published: true|false`), then clears and invalidates Grav's page-tree cache. The trip detail page carries no publish UI — an unpublished trip's detail page 404s for everyone including the owner, so management is listing-only. When the active trip is unpublished, the home page falls back to its between-trips / pre-departure state.
|
||||||
|
|
||||||
|
### Problem Frame
|
||||||
|
|
||||||
|
Publishing a trip today means editing `trip.md` frontmatter by hand (or via Admin) and manually clearing cache. The owner wants a reversible in-UI toggle. The listing is the only viable surface: it already shows drafts to the owner and is fully reversible, whereas the detail page is unreachable while a trip is unpublished. The change is security-sensitive (an owner-only write) and cache-sensitive (publish state feeds `.published()` collections, routability, nav, and the home render, all keyed through the page-tree index — the exact class of bug fixed in `deleteEntry`).
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
**Owner gate & authorization**
|
||||||
|
- R1. The publish write control renders only for the owner: `grav.user.authenticated and grav.user.username == grav.config.site.owner_username`. This is broader than `owner_can_edit` in `trip.html.twig` (which also requires the active trip) — publishing must work on any trip.
|
||||||
|
- R2. The backend enforces the owner check independently of the UI (defense in depth): anonymous → 401, authenticated non-owner → 403, with frontmatter unchanged on disk.
|
||||||
|
- R3. The endpoint enforces the same `api.pages.write` scope cap as the stock media/page-write endpoints (owner already holds it).
|
||||||
|
|
||||||
|
**Publish write**
|
||||||
|
- R4. `POST /api/v1/trip/{slug}/publish` with body `{ "published": true|false }` sets the trip's published state and persists it to `trip.md` frontmatter.
|
||||||
|
- R5. A missing or non-boolean `published` value is rejected with 400 (no silent coercion).
|
||||||
|
- R6. The slug is validated as a safe single segment; the target must resolve through the page tree to a direct child of `/trips`, else 404.
|
||||||
|
- R7. `find()` resolves unpublished trips too, so the owner can republish a draft from the listing.
|
||||||
|
- R8. On success the endpoint clears the cache (`deleteAll()` + `Cache::invalidateCache()`) and returns 204, and writes an audit-log line.
|
||||||
|
|
||||||
|
**Listing surface**
|
||||||
|
- R9. The owner sees unpublished trips in the `/trips` listing (with a `Draft` badge); anonymous/non-owner listings are unchanged (published only).
|
||||||
|
- R10. Each owner-visible card carries a toggle switch overlaid on the cover image, top-right, that does not sit inside the card's navigating `<a>`. The switch is legible over arbitrary cover photos and carries a ≥44px touch target clear of the anchor hit area.
|
||||||
|
- R11. The switch is accessible: `role="switch"`, `aria-checked`, and a per-instance accessible name identifying the trip.
|
||||||
|
|
||||||
|
**Interaction & feedback**
|
||||||
|
- R12. Unpublishing the active trip prompts a `window.confirm` warning that the home page loses it; cancelling reverts the switch.
|
||||||
|
- R13. A toggle in flight is disabled (`aria-busy`, dimmed, wait cursor), ignoring further toggles until success or failure revert.
|
||||||
|
- R14. On success the UI updates optimistically in place (switch position/label, `Draft` badge, `data-published`) with no full reload; the card stays visible to the owner.
|
||||||
|
- R15. On failure the switch reverts and an error surfaces via a shared page-level `aria-live` toast (401/403 → "sign in again"; other → "Couldn't update — try again.").
|
||||||
|
|
||||||
|
**Home fallback**
|
||||||
|
- R16. When the resolved active trip is unpublished, `home.html.twig`'s active-trip branch does not render; home falls through to its between-trips / pre-departure state. `site.active_trip` is not modified.
|
||||||
|
|
||||||
|
### Scope Boundaries
|
||||||
|
|
||||||
|
**Out of scope (v1)**
|
||||||
|
- Bulk publish/unpublish.
|
||||||
|
- Scheduling / publish dates.
|
||||||
|
- Cascading child (dailies/stories) publish state — unpublishing a trip does not change its children.
|
||||||
|
- Reordering trips by publish state (order stays date desc).
|
||||||
|
- Any publish/unpublish write control or `Draft` indicator on the trip detail page (`trip.html.twig`) — management is listing-only by design.
|
||||||
|
|
||||||
|
**Non-goal clarification**
|
||||||
|
- This toggle governs only whether a trip appears in the `/trips` listing; it is not a content-privacy control. A story reachable by a direct link stays reachable while its parent trip is unpublished, which is acceptable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Planning Contract
|
||||||
|
|
||||||
|
### Key Technical Decisions
|
||||||
|
|
||||||
|
- KTD1. **Persist published state by mutating the page header before save, not `$page->published()`.** In Grav 2.0 `$page->published($v)` sets only the in-memory property (`Page.php:1714`), while `save()` serializes from the header object (`Page.php:1256`) and the flag is read one-way from the header at init (`Page.php:541`). Mirror `cache-on-save`'s header-mutation pattern: `$header = $page->header(); $header->published = $published; $page->save();`. Without this the on-disk `trip.md` is unchanged and the toggle silently no-ops.
|
||||||
|
- KTD2. **Reject non-boolean `published` explicitly; never `(bool)`-cast.** `array_key_exists('published', $body) && is_bool($body['published'])` or 400. A cast coerces `"false"`, `0`, `""`, or a missing key into a valid boolean and never rejects, contradicting R5.
|
||||||
|
- KTD3. **Clear the cache with `deleteAll()` + `Cache::invalidateCache()`.** `deleteAll()` alone drops cache stores but does not rebuild the page-tree index (keyed on folderHash under `cache.check.method: folder`), so the listing/nav/home render stale. This is the same fix as `deleteEntry` — see `docs/solutions/integration-issues/grav-deleteall-doesnt-invalidate-page-tree-index.md`.
|
||||||
|
- KTD4. **Resolve the trip through `$pages->find()` + a parent-route assertion, never raw path concatenation.** New guard `EntryScopeGuard::resolveTripChild($grav, $slug)` mirrors `resolveActiveDailyChild`: call `enablePages()` (guarded by `method_exists` — the API context lazily disables the tree), `find('/trips/' . $slug)`, then assert the resolved page's parent route is exactly `/trips`. Reuses `isSafeSegment` for traversal safety.
|
||||||
|
- KTD5. **The JS request must send `Content-Type: application/json`.** The API's `JsonBodyParserMiddleware` (`JsonBodyParserMiddleware.php:16`) only parses the body when that header is present; without it the body decodes to `[]`, the strict `is_bool` guard sees no key, and every toggle 400s. Model the send on `post-form.js`'s `apiSend` (JSON body + both headers + `credentials: 'include'`), **not** `feed-actions.js` (a body-less DELETE with no `Content-Type`).
|
||||||
|
- KTD6. **The card toggle is an overlay sibling of the cover, not a child of the card `<a>`.** A toggle inside the anchor would navigate on click. Restructure the card so the cover sits in a positioned wrapper and the toggle overlays it as a sibling. Reuse the existing `.journal-draft-badge` styling (Field Notes paper/teal) so the pill stays legible over any cover.
|
||||||
|
- KTD7. **Gate `home.html.twig`'s active-trip branch on `trip.published` as well as `config.site.travelling`.** `trip` is already resolved at `home.html.twig:10`; adding `and trip.published` to the branch condition is the whole home fallback — no need to touch `site.active_trip`.
|
||||||
|
- KTD8. **New JS file needs an esbuild build entry.** `js/src/trip-publish.js` does not build automatically — add an esbuild invocation to the theme's `package.json` `build` script (same `--bundle --minify --format=iife` shape as the `feed-actions.js` entry) so `make build-assets` emits `js/trip-publish.js`.
|
||||||
|
|
||||||
|
### High-Level Technical Design
|
||||||
|
|
||||||
|
Request flow for one toggle:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as Owner (listing card switch)
|
||||||
|
participant JS as trip-publish.js
|
||||||
|
participant API as entry-actions route
|
||||||
|
participant Ctl as setTripPublished
|
||||||
|
participant G as EntryScopeGuard
|
||||||
|
participant FS as trip.md + cache
|
||||||
|
|
||||||
|
U->>JS: change (with active-trip confirm if applicable)
|
||||||
|
JS->>JS: disable switch, aria-busy
|
||||||
|
JS->>API: POST /api/v1/trip/{slug}/publish {published}
|
||||||
|
API->>Ctl: dispatch
|
||||||
|
Ctl->>Ctl: getUser (401 anon) + requirePermission(api.pages.write)
|
||||||
|
Ctl->>G: isOwnerUser (else 403)
|
||||||
|
Ctl->>Ctl: isSafeSegment(slug) (else 400)
|
||||||
|
Ctl->>G: resolveTripChild(slug) (else 404)
|
||||||
|
Ctl->>Ctl: validate published is_bool (else 400)
|
||||||
|
Ctl->>FS: header.published = v; save(); deleteAll(); invalidateCache()
|
||||||
|
Ctl-->>JS: 204
|
||||||
|
JS->>U: optimistic UI (switch, Draft badge, data-published)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Assumptions
|
||||||
|
|
||||||
|
- The Playwright harness runs as the owner because the local test setup treats `testrunner` as `owner_username` — the same setup the existing owner-only delete-flow specs rely on. The new specs inherit it rather than introducing a new override mechanism. Verify by mirroring `tests/ui/post/delete-flow.spec.js` (which already exercises the owner API gate).
|
||||||
|
- `css/style.css` is hand-authored (the theme has no active SCSS pipeline for it), so toggle/badge styling is added there directly, next to the existing `.journal-draft-badge` (line 283) and `.trip-card*` (line 1220+) rules.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Units
|
||||||
|
|
||||||
|
### U1. Guard: resolve a trip as a direct child of `/trips`
|
||||||
|
|
||||||
|
- **Goal:** Add `EntryScopeGuard::resolveTripChild($grav, $slug): ?PageInterface`, the trip-scoped analogue of `resolveActiveDailyChild`, so the controller resolves the target safely (R6, R7, KTD4).
|
||||||
|
- **Requirements:** R6, R7.
|
||||||
|
- **Dependencies:** none.
|
||||||
|
- **Files:** `user/plugins/cache-on-save/classes/EntryScopeGuard.php`.
|
||||||
|
- **Approach:** New static method: reject via `isSafeSegment($slug)` → null; get `$pages = $grav['pages']`; if `method_exists($pages, 'enablePages')` call it; `$page = $pages->find('/trips/' . $slug)`; return null unless `$page !== null` and `$page->parent()?->route() === '/trips'`. No raw path concatenation beyond the `find()` argument, matching the sibling method's style. Do not filter on published state — `find()` returning drafts is required for republish (R7).
|
||||||
|
- **Patterns to follow:** `EntryScopeGuard::resolveActiveDailyChild` in the same file (lines 104–129).
|
||||||
|
- **Test scenarios:** Covered end-to-end by U7 (TP2/TP3 exercise resolve-and-republish; TP5 exercises the reject paths). No standalone PHP unit-test harness exists in this repo.
|
||||||
|
- **Verification:** Method exists and returns a `PageInterface` for a real trip slug, `null` for an unsafe segment, a non-existent slug, and a page whose parent is not `/trips`.
|
||||||
|
|
||||||
|
### U2. API route + `setTripPublished` controller
|
||||||
|
|
||||||
|
- **Goal:** Register `POST /api/v1/trip/{slug}/publish` and implement the owner-gated write that persists published state and invalidates cache (R2–R8).
|
||||||
|
- **Requirements:** R2, R3, R4, R5, R6, R7, R8.
|
||||||
|
- **Dependencies:** U1.
|
||||||
|
- **Files:** `user/plugins/entry-actions/entry-actions.php`, `user/plugins/entry-actions/classes/EntryActionsApiController.php`.
|
||||||
|
- **Approach:** In `onApiRegisterRoutes`, add `$routes->post('/trip/{slug}/publish', [EntryActions\EntryActionsApiController::class, 'setTripPublished'])`. In the controller, mirror `deleteEntry` step-for-step: `getUser` (401), `requirePermission($request, 'api.pages.write')`, `isOwnerUser` (else `ForbiddenException`), `isSafeSegment` (else 400), `resolveTripChild` (else `NotFoundException`). Read body via `getRequestBody`; enforce KTD2 (`array_key_exists` + `is_bool`, else 400); assign the raw boolean. Persist per KTD1 (mutate `$page->header()->published`, then `$page->save()`). Clear cache per KTD3. Log `owner "%s" set trip "%s" published=%s`. Return `ApiResponse::noContent()`.
|
||||||
|
- **Patterns to follow:** `EntryActionsApiController::deleteEntry` (guard chain, cache calls, audit log) and `reorderPhotos` (JSON body read) in the same file; `cache-on-save` header-mutation for the save.
|
||||||
|
- **Test scenarios:** Covered by U7 — TP2 (publish→off persists + hides for anon), TP3 (republish), TP5 (401 anon, 403 non-owner, frontmatter unchanged), plus the 400 non-boolean path asserted via a direct API call in TP5.
|
||||||
|
- **Verification:** `curl` (or the Playwright request context) as owner with `{"published":false}` returns 204 and `trip.md` on disk gains `published: false`; anon → 401; non-owner → 403; missing/`"false"`/`0` body → 400.
|
||||||
|
|
||||||
|
### U3. Shared toggle partial + styling
|
||||||
|
|
||||||
|
- **Goal:** Create `partials/trip-publish-toggle.html.twig` (the sliding switch + `Draft` badge) and its CSS, so both the markup and its legible-over-cover styling exist as one reusable unit (R10, R11, KTD6).
|
||||||
|
- **Requirements:** R10, R11.
|
||||||
|
- **Dependencies:** none (consumed by U4).
|
||||||
|
- **Files:** `user/themes/intotheeast/templates/partials/trip-publish-toggle.html.twig`, `user/themes/intotheeast/css/style.css`.
|
||||||
|
- **Approach:** Partial params: `trip` (Page), `is_active` (bool). Render a styled checkbox switch (`role="switch"`, `aria-checked` bound to `trip.published`, `aria-label="Published — {{ trip.title }}"`) plus a `Draft` badge when `not trip.published`. Emit `data-trip-slug`, `data-trip-route`, `data-published`, `data-active` for the JS. Wrap the control class `.trip-publish-toggle`. CSS: a solid pill/chip background reusing `.journal-draft-badge` colors so it stays legible on any cover; absolute positioning is applied by the card container in U4 (which must exist even for a coverless draft — see U4), but the switch's own visual (track/knob, ≥44px hit area, dimmed `[aria-busy]` + wait-cursor pending state per R13, and a legible keyboard focus ring that reads over a busy cover photo) lives here.
|
||||||
|
- **Visible failure toast (not sr-only):** the design's page-level toast (R15) is meant for the sighted owner, but `feed-actions.js`'s live region is `sr-only` (visually hidden) and the trip card — unlike the delete flow — has no inline message slot, so a straight reuse would leave a sighted owner seeing only a silent switch revert. Add CSS here for a **visible** page-level toast as a **new, separate DOM element and CSS class** (e.g. `#trip-publish-live` / a `.trip-publish-toast` class, with `role="status"`, `aria-live="polite"`, positioned so it does not depend on the cramped card overlay) that U6 populates. This element is distinct from `feed-actions.js`'s `#feed-actions-live` / `.sr-only` region: reuse the *copy* but do **not** modify the shared `.sr-only` utility (still used by `feed-actions.js` on `trip.html.twig`/`home.html.twig`) or make its live region visible. Toast behavior: auto-dismiss after ~5s, include a manual close control, and replace (not queue) the message if a new failure arrives before the previous one dismisses.
|
||||||
|
- **Patterns to follow:** existing `.journal-draft-badge` (style.css:283) and the `journal-draft-badge` span in `partials/entry-journal.html.twig:7`.
|
||||||
|
- **Test scenarios:** Rendered presence/absence is asserted by U7 TP1 (owner sees `.trip-publish-toggle`, anon does not); `Draft` badge presence by TP2. Accessible name/`role` are asserted structurally in TP1.
|
||||||
|
- **Verification:** Partial renders a switch with the correct `data-*` and `aria-*` for a published and an unpublished trip; the pill is legible over a cover image in the browser.
|
||||||
|
|
||||||
|
### U4. `/trips` listing — owner-aware collection, card restructure, JS load
|
||||||
|
|
||||||
|
- **Goal:** Make the listing owner-aware (drafts for owner), restructure each card so the toggle overlays the cover as a non-anchor sibling, render the toggle for the owner, and load the JS gated on owner (R9, R10, R12–R15 wiring).
|
||||||
|
- **Requirements:** R1, R9, R10.
|
||||||
|
- **Dependencies:** U3, U6 (built `js/trip-publish.js`).
|
||||||
|
- **Files:** `user/themes/intotheeast/templates/trips.html.twig`.
|
||||||
|
- **Approach:** Compute `is_owner` (R1) at the top. Change the collection to `{% set trips = (is_owner ? page.children : page.children.published())|sort(...) %}`. Restructure the card: keep the navigating `<a class="trip-card">` for cover + title + meta, but wrap the cover in a positioned container so `{% if is_owner %}{% include 'partials/trip-publish-toggle.html.twig' with { trip: trip, is_active: is_active } only %}{% endif %}` sits as an overlay sibling outside the click-navigation path. **The positioned container must exist even when the cover macro emits nothing** — see the coverless-draft note below. Gate the asset: `{% if is_owner %}{% do assets.addJs('theme://js/trip-publish.js', {group: 'bottom'}) %}{% endif %}` (mirrors the `feed-actions.js` gate in `home.html.twig:27`). Compute `is_active` robustly — `site.active_trip` may be a full route (`/trips/x`) or a bare slug — by normalizing both sides before comparing, e.g. `{% set active = config.site.active_trip|trim('/') %}` then `{% set is_active = (active == trip.route|trim('/')) or (active == ('trips/' ~ trip.slug)) %}`. Comparing only against `trip.route`/`trip.url` (full-route form) would silently drop the R12 active-trip confirm if the config ever stores a bare slug (both forms are already supported in `helpers.js` and `cache-on-save`).
|
||||||
|
- **Coverless-draft state (blocks the primary use case):** the shared cover macro emits its wrapper + `<img>` only when a cover exists (an author-set `cover_image` or a published journal image), and **nothing at all** for a freshly-created draft trip with neither — which is exactly the most common publish-toggle target. The positioned container the toggle overlays must therefore be provided by the card itself (a min-height header strip or the card element), not by the cover wrapper, so the toggle has an anchor whether or not `cover.render` emits an image. Enumerate this state in U3's markup and assert it in U7 (a no-cover fixture trip still shows a working toggle).
|
||||||
|
- **Patterns to follow:** the owner-aware feed collection + gated `addJs` in `home.html.twig:23-27`; the existing card markup in `trips.html.twig:16-34`.
|
||||||
|
- **Test scenarios:** Covered by U7 — TP1 (owner sees toggle + draft trip; anon does not, and the draft trip is absent for anon), TP2/TP3 (draft badge on listing after toggle).
|
||||||
|
- **Verification:** Owner load of `/trips` shows `.trip-publish-toggle` on each card and includes unpublished fixture trips; anon load shows neither; clicking a card cover still navigates (toggle click does not).
|
||||||
|
|
||||||
|
### U5. Home fallback when the active trip is unpublished
|
||||||
|
|
||||||
|
- **Goal:** Gate the home active-trip branch on the active trip being published so an unpublished active trip falls through to the between-trips / pre-departure state (R16, KTD7).
|
||||||
|
- **Requirements:** R16.
|
||||||
|
- **Dependencies:** none.
|
||||||
|
- **Files:** `user/themes/intotheeast/templates/home.html.twig`.
|
||||||
|
- **Approach:** Change the branch condition at `home.html.twig:12` from `{% if config.site.travelling %}` to `{% if config.site.travelling and trip.published %}`. `trip` is already resolved at line 10. No change to `site.active_trip`.
|
||||||
|
- **Patterns to follow:** existing branch structure in `home.html.twig`.
|
||||||
|
- **Test scenarios:** Covered by U7 TP6 (active fixture trip unpublished → home renders between-trips/pre-departure, not the draft active-trip view).
|
||||||
|
- **Verification:** With `travelling: true` and the active trip unpublished, `/` renders the fallback branch; republishing restores the active-trip view.
|
||||||
|
|
||||||
|
### U6. `trip-publish.js` + esbuild build wiring
|
||||||
|
|
||||||
|
- **Goal:** Implement the toggle behavior (confirm, pending, POST, optimistic success, failure revert + toast) and wire it into the theme build so `make build-assets` emits `js/trip-publish.js` (R12–R15, KTD5, KTD8).
|
||||||
|
- **Requirements:** R12, R13, R14, R15.
|
||||||
|
- **Dependencies:** U2 (endpoint), U3 (markup contract).
|
||||||
|
- **Files:** `user/themes/intotheeast/js/src/trip-publish.js`, `user/themes/intotheeast/package.json`.
|
||||||
|
- **Approach:** Bind each `.trip-publish-toggle`. On change: if turning **off** and `data-active` is true → `window.confirm('This is your active trip — unpublishing it also removes it from the home page. Unpublish anyway?')`; on cancel revert and stop (R12). Set pending: disable the switch, `aria-busy`, dim + wait cursor, ignore further toggles (R13). Send `POST /api/v1/trip/<slug>/publish` with `headers: { 'Content-Type': 'application/json', Accept: 'application/json' }`, `body: JSON.stringify({ published })`, `credentials: 'include'` — modeled on `post-form.js` `apiSend` (KTD5). Success: flip `data-published`, toggle the `Draft` badge, update switch position/label/`aria-checked` in place; re-enable (R14). Failure: revert switch to prior state, re-enable, surface an error via the **visible** shared page-level toast defined in U3 (`role="status"`, `aria-live="polite"` — reuse the `feed-actions.js` copy but not its `sr-only` region, so a sighted owner actually sees it: 401/403 → "sign in again"; other → "Couldn't update — try again.") (R15). Wire the build: add an esbuild entry for `js/src/trip-publish.js` to `package.json` `build`, same flags as the `feed-actions.js` entry.
|
||||||
|
- **Patterns to follow:** `post-form.js` `apiSend` (js/src/post-form.js:890) for the request; `feed-actions.js` for the live-region + error copy + double-tap lock; the `feed-actions.js` esbuild entry in `package.json` `build`.
|
||||||
|
- **Test scenarios:** Covered by U7 — TP2/TP3 (optimistic flip + persistence), TP4 (active-trip confirm dismiss leaves published). Failure/toast copy is exercised where practical in TP5.
|
||||||
|
- **Verification:** `make build-assets` produces `js/trip-publish.js`; in the browser, toggling a card updates it in place without reload; unpublishing the active trip prompts a confirm.
|
||||||
|
|
||||||
|
### U7. Playwright specs (TP1, TP1b, TP2–TP6)
|
||||||
|
|
||||||
|
- **Goal:** Cover the owner gate, cache-correct hide/restore, active-trip confirm, authz, and home fallback (R1–R16 as observable behavior).
|
||||||
|
- **Requirements:** R1–R16.
|
||||||
|
- **Dependencies:** U1–U6.
|
||||||
|
- **Files:** `user/themes/intotheeast/...` (none); `tests/ui/trip/trip-publish.spec.js`.
|
||||||
|
- **Approach:** Run as the owner (same setup as the delete-flow specs). **New scaffolding this unit must build (not mirrored from the entry helpers):** the existing `createPhotoEntry`/`cleanupEntry`/`findEntry` helpers create/clean *entry* folders inside the active trip's `dailies` (`TRACKER_DIR`) — none create a *trip*. This unit needs a small on-disk trip-fixture helper that writes `pages/01.trips/<fixture>/` with a `trip.md` (a `date` for the listing sort, `published` set per test), plus `01.dailies/` and `04.stories/` `routable:false` container `.md` files, and cleans it up. TP6 additionally repoints `site.active_trip` to the fixture with `travelling: true` — the cited home-suite specs only patch `travelling`, never override `active_trip`, so this override is also new (restore `site.yaml` on teardown). Use the DEL4 fixture-then-reload assertion shape from `delete-flow.spec.js`.
|
||||||
|
- **TP1 — gate:** owner load of `/trips` shows `.trip-publish-toggle`; anon (cleared storageState) does not, and an unpublished fixture trip is absent for anon.
|
||||||
|
- **TP1b — coverless draft:** a fixture trip with no `cover_image` and no published entry image still renders a working `.trip-publish-toggle` for the owner (guards the coverless-container state from U4).
|
||||||
|
- **TP2 — unpublish hides it (caching):** owner toggles a published fixture off → reload `/trips` as anon → trip absent; owner reload → `Draft` badge present on the listing (the detail page 404s for the owner too). Mirrors DEL4's page-tree-index assertion.
|
||||||
|
- **TP3 — republish restores it:** owner on `/trips` toggles a Draft fixture back on → anon reload sees it; assert on the listing, not the detail page.
|
||||||
|
- **TP4 — active-trip confirm:** unpublishing the active trip prompts a confirm; dismissing leaves it published.
|
||||||
|
- **TP5 — authz:** `POST /api/v1/trip/<slug>/publish` as anon → 401; as an authenticated non-owner → 403; frontmatter unchanged on disk. Include a non-boolean-body → 400 assertion. **The 403 leg needs a second, authenticated non-owner identity** — the harness authenticates only one account (`auth.setup.js` → one `storageState`), so this leg requires either a second account + storageState (e.g. a non-owner login) or an in-test override of `owner_username` to a value the logged-in test user does not match, then a restore on teardown. This is not provided by the delete-flow setup; pick one approach and wire it explicitly.
|
||||||
|
- **TP6 — active trip unpublished → home falls back:** with the fixture set as `site.active_trip` and `travelling: true`, unpublish it → reload `/` → home renders between-trips/pre-departure, not the draft active-trip view (needs the `active_trip` override on the fixture; mirror the home-suite setup).
|
||||||
|
- **Patterns to follow:** `tests/ui/post/delete-flow.spec.js` (owner fixture + reload + on-disk assertion), `tests/ui/trip/trips-list.spec.js` (listing selectors), `tests/ui/post/anon-view.spec.js` (anon storageState + draft-visibility).
|
||||||
|
- **Test scenarios:** the six specs above are the scenarios.
|
||||||
|
- **Verification:** `npm run test:ui -- tests/ui/trip/trip-publish.spec.js` (from `tests/`) passes all seven (TP1, TP1b, TP2–TP6), with fixture folders cleaned up afterward.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Contract
|
||||||
|
|
||||||
|
| Gate | Command | Applies to |
|
||||||
|
|---|---|---|
|
||||||
|
| Rebuild theme assets | `make build-assets` | U6 (emits `js/trip-publish.js`) |
|
||||||
|
| Trip publish specs | `npm run test:ui -- tests/ui/trip/trip-publish.spec.js` (run from `tests/`) | U7 |
|
||||||
|
| Full trip suite (no regressions) | `npm run test:ui -- tests/ui/trip` | U4, U5, U7 |
|
||||||
|
| Backend contract (manual/spec) | owner POST → 204 + on-disk `published:` change; anon → 401; non-owner → 403; non-boolean → 400 | U2 |
|
||||||
|
|
||||||
|
Run the dev stack for tests via the worktree's own container (`docker compose -p itte-<feature> up`) per the worktree dev-server convention. Do **not** flip any dev/prod mode flags to work around caching — the cache-clear is handled in-code (KTD3).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
**Global**
|
||||||
|
- All seven Playwright specs (TP1, TP1b, TP2–TP6) pass; the broader `tests/ui/trip` suite shows no regressions.
|
||||||
|
- `make build-assets` emits `js/trip-publish.js`; `js/trip-publish.js` and `js/feed-actions.js` are both current (no hand-edits to built files).
|
||||||
|
- Anonymous and non-owner behavior is unchanged: no toggle rendered, listing shows published trips only, backend rejects with 401/403.
|
||||||
|
- No abandoned/experimental code left in the diff; the plan status line is updated to `✅ Complete (YYYY-MM-DD)`.
|
||||||
|
|
||||||
|
**Per unit**
|
||||||
|
- U1: `resolveTripChild` returns the trip page for a real slug and `null` for unsafe/nonexistent/wrong-parent inputs.
|
||||||
|
- U2: endpoint persists `published` to `trip.md`, invalidates cache, returns 204/400/401/403/404 correctly.
|
||||||
|
- U3: partial renders the accessible switch + `Draft` badge with correct `data-*`, legible over a cover.
|
||||||
|
- U4: owner listing includes drafts + toggles; anon listing unchanged; card cover still navigates.
|
||||||
|
- U5: unpublished active trip → home fallback; published → active-trip view.
|
||||||
|
- U6: JS confirm/pending/optimistic/revert behaviors work in the browser; build entry wired.
|
||||||
|
- U7: specs implemented, fixtures cleaned up.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks & Dependencies
|
||||||
|
|
||||||
|
- **Grav 2.0 save semantics (KTD1).** If a mutated-header `save()` does not persist `published`, the toggle no-ops silently. Mitigation: TP2 asserts the on-disk frontmatter change, not just UI; the `cache-on-save` plugin already relies on this pattern.
|
||||||
|
- **Cache staleness (KTD3).** Omitting `invalidateCache()` reproduces the `deleteEntry` bug (stale listing/nav/home). Mitigation: TP2/TP3 assert visibility after a full reload as a fresh (anon) client.
|
||||||
|
- **Build step required (KTD8).** Editing `js/src/trip-publish.js` without adding the esbuild entry (or without running `make build-assets`) ships nothing. Mitigation: DoD requires the built file to be current; U6 owns the `package.json` edit.
|
||||||
|
- **Test-harness owner identity.** The specs assume `testrunner` acts as owner (as the delete-flow specs do). If that assumption is wrong, the owner-gated specs fail fast at the gate; resolve by matching the existing owner-only spec setup rather than inventing a new override.
|
||||||
|
- **Upstream dependency:** none external; this is self-contained within `user/` (theme + two custom plugins) and the `tests/` harness.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
Both are non-blocking (defense-in-depth / UX-copy) and do not hold up implementation, but resolve them before or during U2/U6.
|
||||||
|
|
||||||
|
- **CSRF boundary is implicit.** The endpoint is a session-cookie-authenticated write with `credentials: 'include'`. Its only cross-origin protection is incidental: KTD5's required `Content-Type: application/json` plus the strict `is_bool` guard force a CORS-preflighted request an attacker cannot forge — *unless* the `api` plugin emits permissive CORS headers. Verify the `api` plugin sends no `Access-Control-Allow-Origin`/`-Credentials` that would defeat the preflight, and state the preflight as the intended CSRF boundary in U2 (or add an explicit token check if it does).
|
||||||
|
- **Draft is not a privacy control (owner mental model).** Unpublishing hides the trip from the `/trips` listing but leaves every child URL (stories, dailies, media) publicly served (documented non-goal). An owner clicking a `Draft` switch may reasonably expect the content to go private. Decide whether the unpublish `confirm()` copy (R12) or toggle help text should say child content stays reachable by direct link, so `Draft` is not mistaken for a retract-content action.
|
||||||
|
|
||||||
|
### From 2026-07-08 doc review
|
||||||
|
|
||||||
|
- **Owner test-identity for the Playwright suite is unspecified and contradicts committed config (adversarial, P1 — blocking for U7).** The Assumptions block asserts the harness treats `testrunner` as `owner_username`, but committed `user/config/site.yaml` sets `owner_username: mischa`, and `EntryScopeGuard::isOwnerUser` is a strict username match with no super-admin bypass. So every owner-gated spec (TP1, TP1b, TP2, TP3, TP4, and TP5's owner leg) depends on untracked local state (a dirty `site.yaml` or a `.env` `GRAV_TEST_USER` override) that the new specs cannot reproducibly "inherit" — and TP5's non-owner override is described in the *inverted* direction (it only makes sense if `testrunner` were owner by default). **Resolve before writing U7:** confirm the worktree container's actual `GRAV_TEST_USER` / `owner_username` binding, then replace the "inherit testrunner-as-owner" assumption with an explicit tracked suite-setup step that pins `site.owner_username` to the authenticated test user (restore on teardown) and derives TP5's 403 leg from a value that user does not match. Do not rely on the committed `owner_username: mischa` or an untracked local `site.yaml`.
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
---
|
||||||
|
title: Post Form Location Override - Plan
|
||||||
|
type: feat
|
||||||
|
date: 2026-07-23
|
||||||
|
origin: docs/working/specs/2026-07-23-post-form-location-override-design.md
|
||||||
|
artifact_contract: ce-unified-plan/v1
|
||||||
|
artifact_readiness: implementation-ready
|
||||||
|
product_contract_source: legacy-requirements
|
||||||
|
execution: code
|
||||||
|
---
|
||||||
|
|
||||||
|
# Post Form Location Override - Plan
|
||||||
|
|
||||||
|
**Status:** ✅ Complete (2026-07-24) — U1–U6 shipped, then hardened by a multi-agent code review the same day. The review found the design's stated server-side safety net (`cleanCoordinate()`) had never been committed, so it landed here; replaced a prefix-parsing coordinate check that accepted `48abc` / `48,85` / `35.0116S` (hemisphere silently flipped); closed three paths that bypassed the submit gate (draft restore, edit-mode prefill, map-load failure) because the gate read a CSS class no code set at init; added pin removal on blanked fields; made the geocode failure visible; and rewrote the U5 guard spec, which asserted only instantly-passing conditions and so could not fail. R8 and R13 above are revised accordingly.
|
||||||
|
|
||||||
|
**Verified by a green run (2026-07-24).** The suite now executes end-to-end: `test-config` 22/22, `test-post` 6/6 (the `scripts/test-post.sh` shell suite — *not* the Playwright specs under `tests/ui/post/`, which is a separate set), and `location-override.spec.js` **20/20** — so the verifications below are no longer by inspection alone. Reaching that took fixing `make test-account` (the password was interpolated into an `sh -c` string, so a shell metacharacter in it killed every UI run), pinning `test-ui` to this checkout's own port, and repairing test cleanup, which had never been able to delete the root-owned entries Grav's Apache creates. See the commit `fix(test): close the test-entry leak into real trip content`.
|
||||||
|
|
||||||
|
Also landed after the review: maplibre's stylesheet is now lazy-`<link>`ed at panel-open instead of statically bundled, cutting `post-form.css` from 92,244 to 26,784 raw bytes (14,528 → 5,631 gzip) on every `/post` load, with a new spec asserting both halves of that boundary.
|
||||||
|
|
||||||
|
**Merged to `main` 2026-07-24** — `user/` at `dd19995`, outer at `4450bd6`, pin bumped. On merged `main`: `test-config` **22/22** and `tests/ui/post/` + `tests/ui/map` **69 passed / 1 failed** (DEL4 only, a pre-existing regression unrelated to this feature — see below). `user/` is still **unpushed by choice**; push `user/` first, then the outer repo.
|
||||||
|
|
||||||
|
**Notes carried forward:**
|
||||||
|
- The `user/` submodule commits remain **unpushed by choice** (git-sync would deploy to prod). Merged to `main` locally on 2026-07-24 and the pin bumped; pushing `user/` — then the outer repo, in that order — is the remaining step and is deliberately left to the user to time.
|
||||||
|
- **DEL4 is a real, pre-existing regression and the one thing still red on `main`** (`tests/ui/post/delete-flow.spec.js:44`, reproducible in isolation). Deleting an entry works: the card leaves the DOM and the folder leaves disk (both asserted and both pass). But a fresh load of the trip page makes the server re-emit the card — an image-less ghost of a page whose content is gone. That is precisely the bug the spec's own header says was already fixed once, so the invalidation has regressed. `cache-on-save` clears the page-tree cache on form *submit*; the delete path evidently does not do the equivalent. Practical impact: delete a bad post from the road, reload, and it is back. Worth its own branch.
|
||||||
|
- ~~This worktree's `user/` branch has diverged from `user/`'s `main`~~ **Done** — `user/main` merged in (`7903432`). It was ahead on both content and theme fixes; `denmark-2026 published: true` came with it, so the local testing flip is gone. The one conflict was `js/post/post-form.js`, a generated bundle, resolved by rebuilding rather than hand-merging minified output.
|
||||||
|
- ~~The `~/Projects` clone's `user/` carries two commits this clone cannot see~~ **Done** — merged in (`8a5cc52`). There is no second clone: `~/Projects` is a symlink to `~/Nextcloud/Projects`. What differs is the **submodule git dir** — a worktree gets `.git/worktrees/<name>/modules/user`, not the checkout's `.git/modules/user` — so `user/main` read `4721af6` here while the checkout's read `285ae37`, and the leg-connection map fix and U+200E strip were unreachable until a local `git fetch` between the two paths. Worth remembering: submodule commits made from the main checkout do not appear in a worktree until fetched, and a local fetch carries them without a push, so git-sync never fires.
|
||||||
|
- **Retracted: the "`owner_username` cluster" diagnosis was wrong.** The worktree showed 6 failures (AN2, DEL1–4, ES1) and they were attributed to `site.yaml` pinning `owner_username: mischa` while the suite authenticates as `testrunner`. On merged `main` only DEL4 fails, with byte-identical `site.yaml` and content — so auth was not the cause. The difference is environmental: the isolated worktree's `user/plugins/` was incomplete (missing `admin`, `markdown-notices`, `migrate-grav`, since `plugins/` is git-ignored and populated per-checkout by `make install-plugins`). Lesson: treat a worktree's UI failures as suspect until reproduced in the main checkout, because the worktree's plugin set is not guaranteed to match.
|
||||||
|
- ~~Every `make` target aborts with `.env:6: *** missing separator`~~ **Fixed by the user (2026-07-24)** — `make` now parses in the checkout. Worth keeping in mind: the env layering is intentional (`.env` global, `-include .env.$(ENV)` per-environment, `ENV` set by the generated env-suffixed remote targets like `make remote-install-prod`), but because `.env` is pulled in with `-include` it must be valid **makefile** syntax as well as valid dotenv — so a leading tab, a multi-line value, or a line without `=` takes down every target at once. Worktrees mask it, since `worktree-new` creates no `.env` and the include silently skips.
|
||||||
|
- **UG1, UG2 and LD1 under `tests/ui/post/` now pass** — they had been failing only because this branch predated `e17a5dc` ("block submit on unfinished photo uploads; un-squeeze EXIF portraits in lightbox"). Merging `user/main` in brought the upload gate and the oriented-derivative slide dims those specs assert, and all three went green with no product change. A first pass mistook them for live defects; the lesson is to check the submodule branch point before reading a red spec on a feature branch as a real bug.
|
||||||
|
|
||||||
|
## Goal Capsule
|
||||||
|
|
||||||
|
- **Objective:** Give the traveller a visual, mistake-catching way to set a journal entry's coordinates for a place other than their current GPS position — via a search-by-city lookup and a draggable map pin inside a new "More location details" disclosure on `/post` — without touching Admin2 or the API plugin.
|
||||||
|
- **Authority hierarchy:** The design doc (`docs/working/specs/2026-07-23-post-form-location-override-design.md`) is authoritative for behavior; this plan is authoritative for sequencing and file-level implementation. Repo conventions (`CLAUDE.md`) and the cited existing patterns override any incidental detail here.
|
||||||
|
- **Stop conditions:** Surface a blocker if the Open-Meteo geocoding endpoint's CORS or city-only-query behavior no longer matches what the design doc verified live, or if lazy-importing `maplibre-gl` breaks `post-form.js`'s existing ESM code-splitting build (the same risk the `heic-to` lazy import already carries safely).
|
||||||
|
- **Execution profile:** Standard frontend feature confined to one theme (templates untouched — the panel is built entirely in JS, mirroring the existing "More options" pattern): CSS, JS additions to `post-form.js` plus one new small module, and a best-effort Playwright spec. Test-after is fine for the JS/CSS units; the Playwright unit (U6) is written test-after against the finished behavior.
|
||||||
|
- **Tail ownership:** Rebuild theme assets (`make build-assets`) after U1–U5; run manual QA per the Definition of Done regardless of whether U6 can execute locally.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Product Contract
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
Add a closed-by-default "More location details" disclosure to the `/post` form, placed directly below the City/Country fields. It holds a "🔍 Look up coordinates" button (geocodes the City field via Open-Meteo, ranked by Country when filled), a single-marker MapLibre preview map, and the existing `lat`/`lng` text fields relocated out of their current CSS-hidden position. Four ways to set a coordinate — GPS button, search-result pick, dragging the pin, typing raw numbers — stay in sync with each other. The GPS button's placement and behavior, and the City/Country fields' auto-fill-when-blank behavior, are unchanged.
|
||||||
|
|
||||||
|
### Problem Frame
|
||||||
|
|
||||||
|
The only way to set a coordinate today is the GPS button (reads live position) or hand-typing/pasting raw decimal text into a CSS-hidden field — the latter is how an invisible Unicode bidi mark silently zeroed out a Denmark 2026 entry's coordinates before backend sanitization (`cleanCoordinate()` in `user/plugins/cache-on-save/cache-on-save.php`) was added. That backend fix stops silent corruption but does nothing for the underlying gap: there's still no visual, reliable way to set a location other than "here, right now," and no way to confirm a coordinate looks right before submitting. This plan closes that gap on the frontend only.
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
**Disclosure & field relocation**
|
||||||
|
- R1. A new "More location details" `<details>` panel exists, closed by default, positioned directly after the City/Country fields — a separate disclosure from the existing "More options" advanced-fields panel (`initDisclosure()` in `user/themes/intotheeast/js/src/post-form.js:341`).
|
||||||
|
- R2. The `lat`/`lng` fields relocate into this panel with their `name="data[lat]"`/`name="data[lng]"` attributes unchanged, so `cache-on-save.php`'s `sanitizeCoordinates()` and `post-form.js`'s `field('lat')`/`field('lng')` helper keep working unmodified. The CSS rule hiding them (`user/themes/intotheeast/css/style.css:893-895`) is removed.
|
||||||
|
- R3. The GPS button (`#get-location`) and City/Country fields keep their current position and behavior in the main flow.
|
||||||
|
|
||||||
|
**Search**
|
||||||
|
- R4. "🔍 Look up coordinates" queries Open-Meteo's geocoding endpoint (`https://geocoding-api.open-meteo.com/v1/search?name=<city>&count=10&language=en&format=json`) by the City field alone — never concatenating Country into the query string, since that returns zero results or a silently degraded match. When Country is non-blank, results are ranked client-side by a case-insensitive substring match against each result's `country` field, matches first; all results still render.
|
||||||
|
- R5. Lookup is explicit-click only. While in flight, the button shows a disabled "Searching…" state that always re-enables on response, no-match, or network failure.
|
||||||
|
- R6. Clicking with both City and Country empty is treated as a no-match: an inline hint asks for a city or country first, and no request is sent.
|
||||||
|
- R7. Multiple matches render as a clickable list (place name, admin region, country), built via `document.createElement` + `.textContent` (no `innerHTML`), matching every other dynamic-content construction already in `post-form.js`. Clicking an entry sets `lat`/`lng` and the pin only — it never writes back to City/Country. The list hides again until the next lookup.
|
||||||
|
- R8. No matches renders an inline hint suggesting a country or manual pin drag; a network failure (or a non-2xx response) leaves the fields untouched and renders a *distinct* inline hint naming the connection as the problem. **Revised in code review 2026-07-24** from "degrades silently" — silence was indistinguishable from a broken button, and the two failure modes need different messages.
|
||||||
|
|
||||||
|
**Map preview & sync**
|
||||||
|
- R9. A single MapLibre GL map with one draggable marker (≥44×44px touch target) renders in the panel, reusing the site's existing style URL (`MAP_STYLE`, extracted to a shared `user/themes/intotheeast/js/src/map-style.js` module per KTD1). The map instance is created once, on the panel's first open, held in module scope, and reused (with an explicit `.resize()` call) on every subsequent open — the container sits under `display:none` while closed, so the first paint would otherwise get a zero-size canvas.
|
||||||
|
- R10. `maplibre-gl`'s JS is dynamically imported only when the panel is opened for the first time, mirroring the existing `heic-to` lazy-chunk pattern (`user/themes/intotheeast/js/src/post-form.js:281`) so ordinary GPS-only submits never fetch it. Its CSS is imported statically at the top of `post-form.js` and bundled unconditionally into `post-form.css`, since a dynamically-imported chunk's CSS is never linked automatically.
|
||||||
|
- R11. Four coordinate-setting paths stay mutually in sync: the GPS button (updates the pin live if the panel is already open, otherwise the pin reflects the new value whenever the panel is next opened); a search-result click; dragging the pin (`dragend` writes back to the fields, rounded to 6 decimal places, matching the GPS button's existing precision); and typing directly into the fields (on blur/debounced input, a valid in-range pair moves the pin; an unparseable or out-of-range value leaves the pin alone and visually flags the field until it parses again).
|
||||||
|
- R12. No pin is shown until one of the four paths above sets a value for the first time.
|
||||||
|
|
||||||
|
**Error handling & validation boundary**
|
||||||
|
- R13. Invalid manual `lat`/`lng` text raises the visual mismatch flag (R11), **and** an unresolved flag blocks submit. **Revised in code review 2026-07-24** from "never client-blocked". The original wording deferred all enforcement to a server-side `cleanCoordinate()` described as already shipped — it was not committed anywhere, so no layer validated coordinates. It now ships in `cache-on-save.php` (both the `/post` form and the Admin2/API save paths) and the client gate stays, giving real defence in depth. The client parse is intentionally stricter than the server's `is_numeric` (whole-value decimals only, so `48,85` / `35.0116S` / `48abc` are rejected rather than prefix-parsed).
|
||||||
|
- R14. Geolocation permission denial keeps its existing, unmodified `#location-status` error behavior.
|
||||||
|
|
||||||
|
### Scope Boundaries
|
||||||
|
|
||||||
|
**Out of scope**
|
||||||
|
- Any change to `user/plugins/admin2/` or `user/plugins/api/`.
|
||||||
|
- Any change to how coordinates are stored (still plain `lat`/`lng` floats in frontmatter) or to the already-shipped `cleanCoordinate()` sanitization.
|
||||||
|
- Offline/self-hosted geocoding, or integrity verification (pinning, response signing) for the third-party geocoding/tile responses beyond HTTPS.
|
||||||
|
|
||||||
|
**Deferred to Follow-Up Work**
|
||||||
|
- If the pre-existing `make test-account` Makefile quoting bug still blocks running the Playwright suite locally when U6 lands, fixing that bug is separate follow-up work, not part of this plan — U6's spec file is written and committed regardless, and manual QA is the accepted completion gate in the meantime.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Planning Contract
|
||||||
|
|
||||||
|
### Key Technical Decisions
|
||||||
|
|
||||||
|
- KTD1. **A new dedicated map module, not an extension of `initEntryMap`.** `js/maplibre-utils.js`'s `initEntryMap` (used by `entry-map.html.twig` on the trip/home pages) is built for multi-marker, GPX-drawing, popup-bearing read-only maps — none of which this single-draggable-pin preview needs. Add a small new sibling source module, `user/themes/intotheeast/js/src/location-map.js`, imported statically by `post-form.js` (it is not a new esbuild entry point — see KTD5). `MAP_STYLE` itself is extracted into a tiny shared constants module, `user/themes/intotheeast/js/src/map-style.js` (a single `export const MAP_STYLE = ...`, no side effects), imported by both `location-map.js` and the existing `js/maplibre-utils.js` — this removes the literal-duplication drift risk without pulling in `maplibre-utils.js`'s whole multi-marker/GPX machinery or its window-global side effect, since the new module has neither.
|
||||||
|
- KTD2. **Search: city-only query + client-side country ranking**, exactly as verified live in the design doc — concatenating Country into the query string breaks the "Paris, Texas" disambiguation case this feature exists for.
|
||||||
|
- KTD3. **Lazy-load boundary.** `location-map.js` exports a function (e.g. `getOrCreateLocationMap(container)`) that internally calls `import('maplibre-gl')` the first time it runs, keyed off the panel's first `toggle` event where `details.open === true` — never eagerly at page load. `maplibre-gl/dist/maplibre-gl.css` is a static top-of-file import in `post-form.js` (the JS/CSS split from R10) since esbuild never emits a `<link>` for a code-split CSS chunk.
|
||||||
|
- KTD4. **Two small sync helpers, not four independent write paths.** `syncPinFromFields()` (fields → pin: reads `field('lat')`/`field('lng')`, moves the pin if both parse as finite in-range numbers, else sets the mismatch flag on the offending field without touching the pin) is called from the search-result click, from the GPS button's success handler when the panel is already open, from the lat/lng fields' blur/debounced-input listeners, and from the panel's `toggle`-open handler (U4) so a pin set while the panel was closed — via GPS capture, or pre-existing coordinates in edit mode — renders correctly the first time the panel opens. The marker's `dragend` handler writes straight into the fields (rounded to 6 decimals) and clears any mismatch flag — it does not call `syncPinFromFields()` back, avoiding a feedback loop.
|
||||||
|
- KTD5. **No new esbuild entry point.** Unlike `trip-publish.js` (its own bundle), `location-map.js` is a plain ES module imported by `post-form.js`'s existing entry — esbuild inlines it into the same `--splitting` ESM build already configured in `user/themes/intotheeast/package.json`. Only `maplibre-gl` itself needs to be the lazy chunk; the coordinator code around it loads normally, mirroring how `heic-to` is dynamically imported from directly inside the always-loaded `post-form.js`.
|
||||||
|
- KTD6. **Panel construction is entirely JS-built, no template edit.** Mirrors `initDisclosure()` (line 341) and the photos `<details>` wrapper (line ~120): a new `initLocationDetails()` creates the `<details>`/`<summary>`, the search button/results-list/hint elements, and the map container via `document.createElement`, then moves the existing `lat`/`lng` `.form-field` wrappers into it — the same relocate-via-JS approach already used for "More options," so `post-form.html.twig` needs no structural change (only the CSS hide-rule removal in R2).
|
||||||
|
|
||||||
|
### High-Level Technical Design
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
GPS["GPS button success\n(if panel open)"] --> SYNC["syncPinFromFields()"]
|
||||||
|
SEARCH["Search result click"] --> FIELDS["lat/lng fields"]
|
||||||
|
FIELDS --> SYNC
|
||||||
|
TYPE["Type + blur/debounce"] --> SYNC
|
||||||
|
SYNC --> PIN["Map pin"]
|
||||||
|
DRAG["Drag pin (dragend)"] --> FIELDS
|
||||||
|
SYNC -.invalid.-> FLAG["Mismatch flag on field\n(cleared once value parses)"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Map lifecycle: first panel open → `import('maplibre-gl')` → create map + draggable marker, cache in module scope → subsequent opens call `.resize()` on the cached instance rather than recreating it.
|
||||||
|
|
||||||
|
### Assumptions
|
||||||
|
|
||||||
|
- No existing Playwright fixture creates a "search API returns N results" scenario; U6 mocks the Open-Meteo response via `page.route()` rather than depending on the live third-party endpoint, keeping the spec hermetic (and avoiding flakiness/rate-limits from a real geocoding call).
|
||||||
|
- The `location-details` panel defaults closed even when editing an entry that already has `lat`/`lng` set — see Open Questions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Units
|
||||||
|
|
||||||
|
### U1. CSS: unhide coordinate fields, style the new panel
|
||||||
|
|
||||||
|
- **Goal:** Remove the CSS rule hiding `lat`/`lng`, and add styling for the new disclosure, search results list, map container, and mismatch-flag state (R2, R9).
|
||||||
|
- **Requirements:** R2, R9.
|
||||||
|
- **Dependencies:** none.
|
||||||
|
- **Files:** `user/themes/intotheeast/css/style.css`.
|
||||||
|
- **Approach:** Remove the `display: none !important` rule at `style.css:893-895` targeting `input[name="data[lat]"]`/`input[name="data[lng]"]`. Add: a `.location-details` disclosure look mirroring `.more-options` (`user/themes/intotheeast/js/src/post-form.css:98`); a `.location-search-results` list; a `.location-map` container with a fixed height and `position: relative` so the marker's DOM element (sized ≥44×44px) sits correctly; a `.location-field--mismatch` state (red outline + inline note) for the type-mismatch flag; a disabled/"Searching…" look for the lookup button reusing the existing `.btn-action`/`is-loading` conventions (`style.css:976-991`).
|
||||||
|
- **Patterns to follow:** `.more-options`/`.more-options__summary` (`post-form.css:98-128`), `.btn-action`/`.form-status` (`style.css:970-1002`).
|
||||||
|
- **Test scenarios:** Test expectation: none -- pure CSS; visual correctness is verified manually and indirectly by U2–U5's behavioral tests (elements exist and are visible/hidden as expected).
|
||||||
|
- **Verification:** `lat`/`lng` inputs are visible only inside the new panel in the browser; no other page references the removed selector (confirmed during research — none found outside `style.css:894-895` and `post-form.js`'s own field reads).
|
||||||
|
|
||||||
|
### U2. JS: build the "More location details" panel shell
|
||||||
|
|
||||||
|
- **Goal:** Construct the closed-by-default disclosure (search UI, map container, relocated `lat`/`lng` fields) entirely in JS, positioned after the City/Country fields (R1, R2, R3, KTD6).
|
||||||
|
- **Requirements:** R1, R2, R3.
|
||||||
|
- **Dependencies:** U1.
|
||||||
|
- **Files:** `user/themes/intotheeast/js/src/post-form.js`.
|
||||||
|
- **Approach:** New `initLocationDetails()`, called from `boot()` after `initDisclosure()` and `initGeo()` (so the relocated fields already reflect any `initDraft()` restore, and `initGeo()`'s `field('lat')`/`field('lng')` lookups still resolve by attribute selector regardless of DOM position). No-op if `field('lat')`/`field('lng')` are absent. Create `<details class="location-details">` + `<summary>More location details</summary>`; append a search row (`#lookup-coords` button, `#location-search-results` list, `#location-search-hint` inline hint), a `#location-map` container, then move `field('lat').closest('.form-field')` and `field('lng').closest('.form-field')` into the details. Insert the details element immediately after `field('location_country').closest('.form-field')`.
|
||||||
|
- **Patterns to follow:** `initDisclosure()` (`post-form.js:341`) and the photos `<details>` wrapper (`post-form.js:~120`) for the create-via-JS + relocate-wrapper approach.
|
||||||
|
- **Test scenarios:**
|
||||||
|
- Happy path: on `/post`, "More location details" is present, closed by default, positioned immediately after the Country field, and contains the lookup button, an empty map container, and the (now-visible-only-inside-the-panel) `lat`/`lng` inputs.
|
||||||
|
- No-op guard: if `lat`/`lng` fields were ever absent from the DOM, `initLocationDetails()` does not throw.
|
||||||
|
- **Verification:** DOM inspection in-browser confirms structure and default-closed state.
|
||||||
|
|
||||||
|
### U3. JS: geocoding search + results list
|
||||||
|
|
||||||
|
- **Goal:** Implement the "🔍 Look up coordinates" button: city-only query, client-side country ranking, results list, and all error/empty states (R4–R8).
|
||||||
|
- **Requirements:** R4, R5, R6, R7, R8.
|
||||||
|
- **Dependencies:** U2.
|
||||||
|
- **Files:** `user/themes/intotheeast/js/src/post-form.js`.
|
||||||
|
- **Approach:** Click handler on `#lookup-coords`: if City and Country are both blank, show the inline hint and return (no fetch). Otherwise disable the button, show "Searching…", and `fetch` the Open-Meteo geocoding URL (KTD2). On response: empty/missing `results` → no-match hint; otherwise stable-sort by whether each result's `country` case-insensitively contains the Country field's value (matches first, original order preserved otherwise), then render each as an `<li>` containing a `<button type="button">` built via `createElement`/`.textContent` ("name, admin1, country") — keyboard-operable by default, matching the accessible-button convention already used elsewhere in this file (the photo-delete button's `aria-label`). Clicking (or activating via keyboard) a result button sets `lat`/`lng` (not City/Country) and calls `syncPinFromFields()` (U5); the list then hides until the next lookup. Network failure: catch, degrade silently (matching the existing reverse-geocode/weather pattern), re-enable the button in both the success and failure paths.
|
||||||
|
- **Patterns to follow:** `reverseGeocode()`/`initGeo()`'s fetch + status-state handling (`post-form.js:433-511`) for the request/error shape; the "no `innerHTML` anywhere in this file" convention for the results list; the existing accessible-button convention (photo-delete `<button>` with `aria-label`) for keyboard-operable dynamically-created controls.
|
||||||
|
- **Test scenarios:**
|
||||||
|
- Happy path: searching "Kyoto" (mocked response) renders a results list; clicking the first result sets `lat`/`lng` and leaves City/Country untouched.
|
||||||
|
- Disambiguation: City "Paris", Country "Texas" (mocked multi-result payload matching the design doc's real API shape) — the Texas-tagged result renders first in the list.
|
||||||
|
- No match: mocked empty-results response shows the inline no-match hint; pin/fields untouched.
|
||||||
|
- Empty inputs: clicking lookup with City and Country both blank shows the hint and triggers no network request.
|
||||||
|
- In-flight state: a deliberately delayed mocked response shows the disabled "Searching…" button state until it resolves.
|
||||||
|
- Network failure: a mocked rejected/failed request degrades silently, leaves fields untouched, and re-enables the button.
|
||||||
|
- XSS safety: a mocked result containing markup in its name field (e.g. `<img onerror=...>`) renders as literal text in the list, not executed.
|
||||||
|
- **Verification:** All scenarios above pass in the browser against mocked responses; the live-API disambiguation case (Paris/Texas) is additionally spot-checked once manually per the Definition of Done.
|
||||||
|
|
||||||
|
### U4. JS: MapLibre preview module (lazy load, draggable marker, singleton)
|
||||||
|
|
||||||
|
- **Goal:** Implement the single-marker preview map as a dedicated module: lazy-imported on first panel open, reused (not recreated) on subsequent opens, with a resize fix for the zero-size-canvas-while-closed issue (R9, R10, R12, KTD1, KTD3, KTD5).
|
||||||
|
- **Requirements:** R9, R10, R12.
|
||||||
|
- **Dependencies:** U2.
|
||||||
|
- **Files:** `user/themes/intotheeast/js/src/map-style.js` (new), `user/themes/intotheeast/js/src/location-map.js` (new), `user/themes/intotheeast/js/src/post-form.js`, `user/themes/intotheeast/js/maplibre-utils.js` (modified — import `MAP_STYLE` instead of declaring it inline; no behavior change).
|
||||||
|
- **Approach:** First, extract the existing `MAP_STYLE` literal out of `maplibre-utils.js:5` into `map-style.js` (a single `export const MAP_STYLE = ...`) and update `maplibre-utils.js` to import it instead of declaring it inline. In `location-map.js`, import the same constant and export `getOrCreateLocationMap(container, onDragEnd)`: on first call, `import('maplibre-gl')`, create a `maplibregl.Map` against `container` using the shared `MAP_STYLE` constant (KTD1), create one `maplibregl.Marker({ draggable: true, element: <a ≥44×44px sized div> })` (not yet added to the map until a pin is set), wire its `dragend` to call `onDragEnd(lngLat)`, and cache the created map/marker in module scope keyed by container so a second call reuses them. Return a handle: `{ setPin(lat, lng), hasPin(), resize() }`. `post-form.js` adds a static top-of-file `import 'maplibre-gl/dist/maplibre-gl.css';` (R10) and, in `initLocationDetails()`, listens for the panel's `toggle` event: on every open where `details.open` is true, call `getOrCreateLocationMap(...).resize()` (creating it on the first call, per the lazy-import contract) and then `syncPinFromFields()` (U5), so a pin set while the panel was closed — via GPS capture, or pre-existing coordinates in edit mode — renders on this first paint.
|
||||||
|
- **Patterns to follow:** the `heic-to` dynamic-import shape (`post-form.js:281`) for the lazy-load mechanics; `js/maplibre-utils.js:452` (`new maplibregl.Map({...})`) and `:508` (`new maplibregl.Marker(...)`) for the underlying MapLibre API shape, without importing that file (KTD1).
|
||||||
|
- **Test scenarios:**
|
||||||
|
- Happy path: opening the panel for the first time renders exactly one MapLibre canvas inside `#location-map`.
|
||||||
|
- No initial pin: with `lat`/`lng` both empty, opening the panel shows no marker.
|
||||||
|
- Reopen does not duplicate: closing and reopening the panel (repeatedly) leaves exactly one canvas element, and the canvas has non-zero width/height after the reopen (guards the zero-size-while-closed case).
|
||||||
|
- Lazy import boundary: an ordinary GPS-only submit where the panel is never opened triggers no network request for the `maplibre-gl` chunk (asserted via a page network-request listener in Playwright).
|
||||||
|
- **Verification:** Browser + Playwright network-tab assertion confirm the chunk fetches once (not per-reopen) and never fetches when the panel stays closed.
|
||||||
|
|
||||||
|
### U5. JS: four-way coordinate sync + mismatch flag
|
||||||
|
|
||||||
|
- **Goal:** Keep the GPS button, search picks, pin drag, and typed values mutually in sync in both directions, including the visual mismatch flag for unparseable typed input (R11, R13, R14, KTD4).
|
||||||
|
- **Requirements:** R11, R13, R14.
|
||||||
|
- **Dependencies:** U3, U4.
|
||||||
|
- **Files:** `user/themes/intotheeast/js/src/post-form.js`.
|
||||||
|
- **Approach:** Implement `syncPinFromFields()` (KTD4): parse `field('lat')`/`field('lng')` values; if both are finite numbers within range, call the map handle's `setPin`, clear the mismatch flag/class from both fields, and clear `aria-invalid`/`aria-describedby`; if either fails to parse or is out of range, leave the pin untouched and add the mismatch flag/class (plus an inline "not reflected on map" note, rendered in a `role="status"`/`aria-live="polite"` element mirroring the existing dynamic-feedback pattern used elsewhere in this file, e.g. `#location-status`) to the offending field(s), setting `aria-invalid="true"` and `aria-describedby` pointing at that note so screen-reader users are told the value wasn't reflected on the map. Wire callers: (a) the marker's `dragend` (from U4's `onDragEnd`) writes rounded-to-6-decimal values directly into the fields and clears the mismatch flag — it does not call `syncPinFromFields()` back; (b) the search-result click (U3) sets fields then calls `syncPinFromFields()`; (c) the existing GPS success handler (`initGeo()`, `post-form.js:464-475`) calls `syncPinFromFields()` after setting fields, but only if the location-details `<details>` is currently open; (d) `lat`/`lng` field `blur` and debounced `input` listeners call `syncPinFromFields()`; (e) the panel's `toggle`-open handler (U4) calls `syncPinFromFields()` on every open, so a pin set while the panel was closed — covering the case (c) doesn't, and edit-mode entries with pre-existing coordinates — renders correctly on first paint.
|
||||||
|
- **Patterns to follow:** the GPS button's existing `toFixed(6)` rounding (`post-form.js:465-466`) for consistency; `setStatus()`'s idle/loading/success/error class pattern (`post-form.js:397`) as a model for the mismatch-flag class toggling.
|
||||||
|
- **Test scenarios:**
|
||||||
|
- GPS-first-then-open: capture GPS coordinates, then open the panel — the pin appears at the GPS coordinates on first paint.
|
||||||
|
- GPS-while-open: open the panel first, then click the GPS button — the pin updates live without needing to reopen the panel.
|
||||||
|
- Drag updates fields: dragging the marker to a new position updates `lat`/`lng` to the rounded 6-decimal values matching the drop location (within a small tolerance).
|
||||||
|
- Type valid values: typing a valid in-range pair and blurring moves the pin and shows no mismatch flag.
|
||||||
|
- Type invalid values: typing a non-numeric or out-of-range value and blurring leaves the pin in place and shows the mismatch flag; a subsequent valid edit clears the flag and moves the pin.
|
||||||
|
- Search doesn't clobber City/Country: after a search-result click, the City/Country field values are unchanged from what the traveller typed, even if the matched place's name differs in spelling/case.
|
||||||
|
- **Verification:** All six scenarios pass in the browser; a submit with a search-selected location round-trips through the existing backend `cleanCoordinate()` and produces the expected saved `lat`/`lng`.
|
||||||
|
|
||||||
|
### U6. Playwright coverage (best-effort)
|
||||||
|
|
||||||
|
- **Goal:** Add automated coverage for the new search → pin → submit flow, accepting the known local test-harness risk (R4–R14 as observable behavior).
|
||||||
|
- **Requirements:** R4, R5, R6, R7, R8, R9, R10, R11, R12, R13.
|
||||||
|
- **Dependencies:** U1–U5.
|
||||||
|
- **Files:** `tests/ui/post/location-override.spec.js` (new).
|
||||||
|
- **Approach:** Mock the Open-Meteo geocoding endpoint via `page.route()` so the suite is hermetic and doesn't depend on the live third-party API or rate limits. Cover: panel closed by default; empty-input lookup click sends no request and shows the hint; a mocked multi-result search sets `lat`/`lng` from a clicked result without touching City/Country; the Paris/Texas ranking case (mocked payload mirroring the design doc's verified real-API shape) renders the Texas-tagged result first; dragging the marker (Playwright mouse API) updates the fields; typing invalid values shows the mismatch flag without crashing; reopening the panel a second time leaves exactly one map canvas; a full submit with a search-picked location saves the expected `lat`/`lng` in the entry's frontmatter (reuse the existing fixture/cleanup helpers from `tests/ui/post/post.spec.js`).
|
||||||
|
- **Patterns to follow:** `tests/ui/post/post-form-ux.spec.js` (R18's `#get-location`/geolocation-mocking spec, line 186) for the geolocation/location-status assertions; `tests/ui/post/post.spec.js` for entry fixture creation, submit, and on-disk frontmatter assertions.
|
||||||
|
- **Test scenarios:** the bullet list under Approach is the scenario list.
|
||||||
|
- **Verification:** `npm run test:ui -- tests/ui/post/location-override.spec.js` (from `tests/`) passes. **Known risk:** the pre-existing, unrelated `make test-account` Makefile quoting bug may still block running the Playwright suite locally when this unit lands — if so, the spec file is still committed correct-and-ready, and the manual QA checklist in the Definition of Done is the actual completion gate for this plan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Contract
|
||||||
|
|
||||||
|
| Gate | Command | Applies to |
|
||||||
|
|---|---|---|
|
||||||
|
| Rebuild theme assets | `make build-assets` | U1–U5 (regenerates `js/post/*` and `css-compiled/post-form.css`) |
|
||||||
|
| New location-override spec | `npm run test:ui -- tests/ui/post/location-override.spec.js` (run from `tests/`) | U6 — may be blocked by the known `make test-account` issue; manual QA is the fallback gate |
|
||||||
|
| Full post-form suite (no regressions) | `npm run test:ui -- tests/ui/post` | U2–U5 |
|
||||||
|
| Manual QA (per spec's Testing Plan) | see Definition of Done | All units |
|
||||||
|
|
||||||
|
Run the dev stack for manual QA via the worktree's own container per the worktree dev-server convention. Do not flip any dev/prod mode flags to work around anything encountered here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
**Global**
|
||||||
|
- All four coordinate-setting paths (GPS, search + pick, drag, type) verified in-browser to keep fields and pin in sync in both directions; a submitted entry's frontmatter has the expected `lat`/`lng`.
|
||||||
|
- The ambiguous-search case (City "Paris", Country "Texas") verified to rank the Texas result first over France/Tennessee/Kentucky/Illinois matches; the no-match case verified separately.
|
||||||
|
- Reopening "More location details" a second time does not duplicate the map canvas, and the pin still reflects the current `lat`/`lng`.
|
||||||
|
- Typing garbage into `lat`/`lng` does not crash the map or move the pin; a submit still round-trips through the existing backend `cleanCoordinate()` validation.
|
||||||
|
- `make build-assets` has been run; `js/post/*` and `css-compiled/post-form.css` are current; no hand-edits to built files.
|
||||||
|
- No abandoned/experimental code left in the diff; this plan's Status line is updated to `✅ Complete (YYYY-MM-DD)`.
|
||||||
|
|
||||||
|
**Per unit**
|
||||||
|
- U1: `lat`/`lng` inputs are visible only inside the new panel; new panel/results/map/mismatch styles render as designed.
|
||||||
|
- U2: panel exists, closed by default, positioned after Country, contains the expected child elements.
|
||||||
|
- U3: search happy path, disambiguation, no-match, empty-input, in-flight, network-failure, and XSS-safety scenarios all pass.
|
||||||
|
- U4: exactly one map canvas persists across repeated opens; no pin shown until first coordinate set; `maplibre-gl` fetches once, and never when the panel stays closed.
|
||||||
|
- U5: all four sync directions verified, including the mismatch-flag set/clear cycle.
|
||||||
|
- U6: spec file committed and passing where the test harness allows it; if blocked by the known `make test-account` issue, manual QA stands in as the completion gate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks & Dependencies
|
||||||
|
|
||||||
|
- **Third-party geocoding dependency — outright failure.** Open-Meteo's geocoding endpoint (CORS, city-only-query semantics) is external and was verified live only at design time; a future outage or breaking contract change could break requests outright. Mitigation: the existing graceful no-match/network-failure degrade paths (R8) already absorb this.
|
||||||
|
- **Third-party geocoding dependency — ranking/schema drift.** A subtler failure mode: the API keeps returning HTTP 200 with a non-empty `results` array, but a field the client-side ranking depends on (e.g. `country`) is renamed, emptied, or restructured — R8's no-match/network-failure paths don't fire in this case, since neither condition is met. Mitigation: R7 already renders the full, unranked result list regardless of ranking outcome, so the traveller can still manually pick the correct entry — this failure mode degrades disambiguation convenience, not correctness.
|
||||||
|
- **Build-chain risk.** Dynamically importing `maplibre-gl` from inside `post-form.js`'s existing `--splitting` ESM build must not regress the already-working `heic-to` lazy chunk. Mitigation: verify via `make build-assets` plus a browser network-tab check that both chunks split correctly.
|
||||||
|
- **Zero-size canvas on first open.** MapLibre initializing against a `display:none` container is a known gotcha; mitigated by the explicit `.resize()` call on every panel open (R9, U4).
|
||||||
|
- **Test-harness blocker.** The pre-existing `make test-account` Makefile quoting bug may prevent U6 from running locally at all. This plan does not fix that bug; manual QA is the accepted fallback per the design doc's own Out-of-scope note.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- **Should the panel auto-open in edit mode when `lat`/`lng` are already set?** The design doc says "closed by default" without carving out an edit-mode exception, and this plan's default (U2) is to honor that literally — closed even on edit. The existing "More options" panel auto-opens under a narrower condition (a toggle value deviating from its blueprint default) and `initEditMode()` separately force-opens it for edit generally; whether "More location details" should follow either precedent for entries that already have a location is a plausible UX gap the design doc didn't explicitly rule out. Non-blocking — defer to whichever behavior feels right when the panel is actually used in edit mode, but flag it as a candidate small follow-up if closed-by-default proves surprising in practice.
|
||||||
@@ -2,6 +2,15 @@
|
|||||||
|
|
||||||
*Role: Senior Product Manager. Audience: one solo traveler (Mischa), platform: Grav CMS flat-file PHP, no native app.*
|
*Role: Senior Product Manager. Audience: one solo traveler (Mischa), platform: Grav CMS flat-file PHP, no native app.*
|
||||||
|
|
||||||
|
> **Historical — written 2026-06-21. The verdicts still hold; some delivery mechanisms do not.**
|
||||||
|
>
|
||||||
|
> The **SKIP** column is still the standing decision and has not been revisited — background GPS,
|
||||||
|
> followers, comments, social discovery, reactions, reels, 3D flyover, print, and AI itineraries
|
||||||
|
> remain deliberately out of scope. What changed is *how* some **BUILD** items shipped: the map and
|
||||||
|
> stats render inline on the trip page rather than as `/map` and `/stats`, MapLibre replaced Leaflet,
|
||||||
|
> galleries use PhotoSwipe rather than `shortcode-gallery-plusplus`, and `hero_image` was dropped for
|
||||||
|
> entries. See [`../reference/superseded-decisions.md`](../reference/superseded-decisions.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Starting position
|
## Starting position
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# Pre-Trip Readiness Audit — 2026-07-08 (overnight)
|
||||||
|
|
||||||
|
**Scope:** everything the trip depends on from the road — the posting pipeline
|
||||||
|
(/post → cache-on-save → add-page-by-form), photo handling, edit mode, auth &
|
||||||
|
sessions, GPX manager, the custom API surface, and prod's anonymous exposure.
|
||||||
|
**Method:** read-only code audit of the current `main` + anonymous HTTP probes
|
||||||
|
against production. **No code was changed.** Findings are prioritized; a
|
||||||
|
10-minute morning checklist is at the bottom.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What was verified and looks solid ✅
|
||||||
|
|
||||||
|
- **Prod anonymous surface holds.** Probed 2026-07-08 (UTC night): `GET
|
||||||
|
/api/v1/pages` → 401 with a clean JSON error; `/post` and `/gpx-manager`
|
||||||
|
render the login form; no `X-Powered-By` leak. API CORS is same-origin
|
||||||
|
(`origins: {}`), rate limiting on (120 req/60s), session auth enabled.
|
||||||
|
- **The custom API routes are properly hardened.** `entry-actions`
|
||||||
|
(DELETE entry / reorder photos / trip publish) all require the authenticated
|
||||||
|
**owner** (`site.owner_username`, not just any login), enforce
|
||||||
|
`api.pages.write`, validate slugs as safe single segments, and resolve
|
||||||
|
targets through the page tree via the shared `EntryScopeGuard` — no raw path
|
||||||
|
concatenation anywhere. The publish route handles the APCu/in-place-write
|
||||||
|
cache gotcha explicitly and never turns a cache-invalidation failure into a
|
||||||
|
fake 500. Audit logging on all three.
|
||||||
|
- **Text can't be lost while composing.** `post-form.js` mirrors every text
|
||||||
|
field to localStorage on each keystroke and clears the draft **only** on a
|
||||||
|
server-confirmed success notice. Any failure path (validation error, expired
|
||||||
|
session, network drop, closed tab) re-offers the text on the next visit.
|
||||||
|
- **HEIC handling fails closed.** Sniffed from bytes (not filename), converted
|
||||||
|
client-side, submit is gated while a conversion is in flight, and a failed
|
||||||
|
conversion skips the file with a visible message instead of uploading a
|
||||||
|
broken HEIC.
|
||||||
|
- **Photo reconcile is fail-safe.** Runs exactly once per submit (latched),
|
||||||
|
an empty/missing `photo_order` touches nothing, only image extensions are
|
||||||
|
ever deleted, and edit-mode targets resolve through the same scope guard.
|
||||||
|
- **Edit-mode photo editor has honest error paths.** Failed reorder → revert
|
||||||
|
to last-known-good; failed refresh after a successful save → keeps the saved
|
||||||
|
order; failed batch-add → rollback with an explicit warning when rollback
|
||||||
|
itself was incomplete; 404 on delete treated as convergent success.
|
||||||
|
- **Cache invalidation on post/edit is correct even under prod caching.**
|
||||||
|
cache-on-save does `deleteAll()` + `Cache::invalidateCache()` (config
|
||||||
|
checksum bump → new page-tree index key), so in-place edits appear without
|
||||||
|
needing APCu-specific clearing on that path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Findings — do before departure (P1)
|
||||||
|
|
||||||
|
### P1-1 · Prod PHP upload limits are unverified — could block photo posting entirely
|
||||||
|
`php/php-local.ini` (100M upload / 500M post) is **mounted only into the local
|
||||||
|
Docker container** (`docker-compose.yml`); nothing in `scripts/` or `deploy/`
|
||||||
|
ships PHP limits to the prod Apache server. If prod runs distro defaults
|
||||||
|
(`upload_max_filesize=2M` is common), a single modern phone photo (3–8 MB)
|
||||||
|
fails to upload — the exact core use case of the trip.
|
||||||
|
**Action:** `make remote-diag` (or a one-off phpinfo check) to read prod's
|
||||||
|
`upload_max_filesize` / `post_max_size` / `max_file_uploads`. If low, add a
|
||||||
|
`.user.ini` (FPM) or `.htaccess` `php_value` (mod_php) via a new make target.
|
||||||
|
The real proof is P1-2's live post with photos.
|
||||||
|
|
||||||
|
> **Resolved 2026-07-09.** Confirmed prod was at the 2M default. Fixed by
|
||||||
|
> Mischa via Webmin: PHP execution switched from CGI to **PHP-FPM** (package
|
||||||
|
> was already installed) and the upload limits raised in the FPM
|
||||||
|
> configuration. Because the setting lives in the server-side FPM config —
|
||||||
|
> not in the webroot — it survives fresh Grav installs, so no
|
||||||
|
> `deploy/`-versioned `.user.ini` / make target is needed. Side benefit: APCu
|
||||||
|
> now persists in shared memory, matching the assumptions in the
|
||||||
|
> entry-actions publish endpoint's cache invalidation.
|
||||||
|
> Config location for future reference: Webmin → PHP-FPM Configuration.
|
||||||
|
> Still owed: P1-2's live phone post is the end-to-end proof.
|
||||||
|
|
||||||
|
### P1-2 · One real end-to-end post from the actual phone, on prod, over cellular
|
||||||
|
The runbook's pre-launch smoke (handover step 7) calls for one `/post` submit
|
||||||
|
on prod. After the 2026-07-08 deploy, confirm this happened **from the phone
|
||||||
|
you'll travel with, on cellular, with 2+ HEIC photos** — that exercises HEIC
|
||||||
|
conversion, FilePond upload, prod PHP limits, cache-on-save under
|
||||||
|
`twig.cache:true`, and the feed render in one shot. Then edit that entry
|
||||||
|
(reorder + delete a photo), then delete it — the edit/delete paths shipped
|
||||||
|
today and deserve one prod rep.
|
||||||
|
|
||||||
|
### P1-3 · Session expiry mid-compose: test the 30-minute window once
|
||||||
|
`system.yaml` has `session.timeout: 1800` (30 min) and `form.yaml` has
|
||||||
|
`refresh_nonce: false`. A slow entry written on a train can easily outlive the
|
||||||
|
session; rememberme (enabled, 7-day cookie) should transparently re-auth the
|
||||||
|
next request, but the form **nonce** and the FilePond **flash uploads** were
|
||||||
|
created under the old session. The localStorage draft guarantees the text
|
||||||
|
survives whatever happens — but you should see the actual failure mode once
|
||||||
|
now, not first in a hostel.
|
||||||
|
**Test:** open `/post`, add a photo, wait 35+ minutes, submit.
|
||||||
|
**If it's ugly:** consider raising `session.timeout` in the prod env override
|
||||||
|
(`deploy/env/prod/system.yaml`, e.g. 4–12 h) — single-owner site, low risk,
|
||||||
|
big comfort. (Per-env override, not the committed dev `system.yaml`.)
|
||||||
|
|
||||||
|
### P1-4 · Make sure you can log back in from the road
|
||||||
|
The rememberme cookie lasts **7 days** — on a multi-week trip you *will* be
|
||||||
|
re-typing the password, possibly on hotel wifi after a cookie wipe. Login
|
||||||
|
throttling is 5 attempts / 10 min (easy to hit with phone typos).
|
||||||
|
**Action:** confirm the password is in the phone's password manager and test a
|
||||||
|
fresh login on the phone once. Know that after 5 typos you wait 10 minutes —
|
||||||
|
don't panic-retry.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Findings — worth doing before departure (P2)
|
||||||
|
|
||||||
|
### P2-1 · Duplicate home page: `user/pages/home/` shadows `00.home/`
|
||||||
|
Both `user/pages/home/home.md` (old, committed in `a440583`, carries
|
||||||
|
`routes: default: /`) and `user/pages/00.home/home.md` (the real one) exist
|
||||||
|
with the same slug and near-identical content — which is exactly why a silent
|
||||||
|
mix-up would go unnoticed. Which page wins `/home` (the `home.alias` target)
|
||||||
|
depends on page-index ordering luck.
|
||||||
|
**Action:** delete `user/pages/home/` (verify `/` and `/home` still render the
|
||||||
|
context-aware home from `00.home` afterwards, incl. the pre-departure branch).
|
||||||
|
|
||||||
|
### P2-2 · Docs drift in CLAUDE.md
|
||||||
|
- `active_trip: japan-korea-2026` example — that trip doesn't exist; the real
|
||||||
|
upcoming trip is **`/trips/denmark-2026`** (local `site.yaml`, uncommitted).
|
||||||
|
- The `entry-actions` plugin (three owner-only API routes, shipped with the
|
||||||
|
journal-post-form feature) isn't mentioned in CLAUDE.md's plugin list or
|
||||||
|
architecture sections, and the "post form uses filepond via cache-on-save"
|
||||||
|
description predates the edit-mode photo editor.
|
||||||
|
|
||||||
|
### P2-3 · No HSTS header on prod
|
||||||
|
Apache serves without `Strict-Transport-Security`. One-line header addition;
|
||||||
|
the login form and session cookie deserve it (`secure_https: true` is already
|
||||||
|
set for the cookie).
|
||||||
|
|
||||||
|
### P2-4 · Shrink the unused API auth surface
|
||||||
|
`api.yaml` enables **api_keys + JWT + session** auth. The site only uses
|
||||||
|
session auth (gpx-manager, post-form edit, entry-actions). If no API keys are
|
||||||
|
in use (`user/config/plugins/api-private.php` is untracked/local — not
|
||||||
|
audited), disabling `api_keys_enabled`/`jwt_enabled` in config removes two
|
||||||
|
whole credential classes from the attack surface. Not urgent — the endpoints
|
||||||
|
behind them still enforce owner checks.
|
||||||
|
|
||||||
|
### P2-5 · Confirm the backup path is live
|
||||||
|
Every road post only exists on the prod disk until git-sync commits it to
|
||||||
|
Gitea. **Action:** `make remote-content-status` — confirm git-sync is enabled
|
||||||
|
on prod and the working tree is clean/pushed. (Photos live under `pages/`, so
|
||||||
|
they ride along in the content repo — the backup covers them too.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known limitations — accepted, no action (P3)
|
||||||
|
|
||||||
|
- **Photos are not draft-persisted** (File/Blob can't go to localStorage); the
|
||||||
|
restore hint says so explicitly. Re-selecting photos after a failure is the
|
||||||
|
designed trade-off.
|
||||||
|
- **Location/weather helpers depend on free third-party APIs** (BigDataCloud
|
||||||
|
reverse-geocode, Open-Meteo). Both are best-effort with manual fallbacks —
|
||||||
|
fine.
|
||||||
|
- **No offline mode.** `/post` needs connectivity to load; composing offline
|
||||||
|
means the phone's notes app. (Logged as an ideation candidate, not a bug.)
|
||||||
|
- **Rate limit 120 req/60s** is generous for a single owner; a 6-photo edit
|
||||||
|
batch stays far below it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Morning checklist (~10 minutes + one coffee)
|
||||||
|
|
||||||
|
1. `make remote-diag` → check `upload_max_filesize` / `post_max_size` on prod
|
||||||
|
(P1-1). Fix limits first if they're at defaults.
|
||||||
|
2. From the phone, on cellular, on prod: log in fresh → post a test entry with
|
||||||
|
2 HEIC photos → verify it's in the feed immediately → edit it (reorder +
|
||||||
|
remove a photo) → delete it (P1-2, P1-4).
|
||||||
|
3. `make remote-content-status` → git-sync clean and pushing (P2-5).
|
||||||
|
4. Optional but cheap: start the 35-minute `/post` session-expiry test in a
|
||||||
|
background tab while doing the above (P1-3).
|
||||||
|
5. Queue the P2 cleanups (duplicate home folder, CLAUDE.md drift, HSTS) for a
|
||||||
|
normal dev session — none block departure.
|
||||||
@@ -5,11 +5,14 @@
|
|||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
Let the logged-in **owner** publish/unpublish any trip directly from the UI, on
|
Let the logged-in **owner** publish/unpublish any trip directly from the UI. The
|
||||||
two surfaces: the **Past Trips listing** (`/trips`) and each **trip detail page**
|
**write control lives on one surface — the Past Trips listing** (`/trips`), which
|
||||||
(`/trips/<slug>`). Anonymous/non-owner visitors see no change. Toggling must
|
already shows drafts and toggles both directions reversibly. The **trip detail
|
||||||
correctly invalidate Grav's page-tree cache so the change is reflected
|
page** (`/trips/<slug>`) carries **no publish UI**: an unpublished trip's detail
|
||||||
everywhere on the next load.
|
page 404s (for everyone, owner included), so a control there could only strand the
|
||||||
|
owner and a `Draft` indicator there would be unreachable — see Surface 2.
|
||||||
|
Anonymous/non-owner visitors see no change. Toggling must correctly invalidate
|
||||||
|
Grav's page-tree cache so the change is reflected everywhere on the next load.
|
||||||
|
|
||||||
## Owner gate
|
## Owner gate
|
||||||
|
|
||||||
@@ -21,9 +24,9 @@ A single rule, mirroring the post feed's owner logic:
|
|||||||
|
|
||||||
This is **broader** than `owner_can_edit` in `trip.html.twig` (which also
|
This is **broader** than `owner_can_edit` in `trip.html.twig` (which also
|
||||||
requires the page to be the active trip). Publishing must work on *any* trip, so
|
requires the page to be the active trip). Publishing must work on *any* trip, so
|
||||||
it gets its own `is_owner` flag. `is_owner` is computed in both `trips.html.twig`
|
it gets its own `is_owner` flag, computed in `trips.html.twig` (the listing — the
|
||||||
and `trip.html.twig`. The backend enforces the same owner check independently
|
only surface with the write control). The backend enforces the same owner check
|
||||||
(defense in depth) — the UI gate is not the security boundary.
|
independently (defense in depth) — the UI gate is not the security boundary.
|
||||||
|
|
||||||
## Backend — extend the `entry-actions` plugin
|
## Backend — extend the `entry-actions` plugin
|
||||||
|
|
||||||
@@ -45,15 +48,27 @@ In `EntryActionsApiController`, mirroring `deleteEntry`:
|
|||||||
3. `EntryScopeGuard::isOwnerUser($this->grav, $user)` — else `ForbiddenException`.
|
3. `EntryScopeGuard::isOwnerUser($this->grav, $user)` — else `ForbiddenException`.
|
||||||
4. Validate `slug` via `EntryScopeGuard::isSafeSegment` — else 400.
|
4. Validate `slug` via `EntryScopeGuard::isSafeSegment` — else 400.
|
||||||
5. Resolve the page via a **new** guard `EntryScopeGuard::resolveTripChild($grav, $slug)`:
|
5. Resolve the page via a **new** guard `EntryScopeGuard::resolveTripChild($grav, $slug)`:
|
||||||
`$pages->find('/trips/' ~ slug)`, assert the resolved page's parent route is
|
call `$pages->enablePages()` first (guarded by `method_exists` — the API request
|
||||||
|
context lazily disables the page tree, exactly as `resolveActiveDailyChild` does),
|
||||||
|
then `$pages->find('/trips/' ~ slug)`, assert the resolved page's parent route is
|
||||||
exactly `/trips` (no raw path concatenation — same style as
|
exactly `/trips` (no raw path concatenation — same style as
|
||||||
`resolveActiveDailyChild`). Return `null` → `NotFoundException`.
|
`resolveActiveDailyChild`). Return `null` → `NotFoundException`. (`find()` returns
|
||||||
6. Read desired state: `$published = (bool) ($body['published'] ?? …)`; reject a
|
unpublished trips too — verified against `Pages.php:966`/`1986` — so the owner can
|
||||||
missing/non-bool value with 400.
|
republish a draft from the listing.)
|
||||||
7. Set published + persist frontmatter. Use Grav's page API (verify exact call
|
6. Read desired state: reject a missing or non-boolean value with 400 —
|
||||||
against `add-page-by-form` / `cache-on-save` savers before coding — likely
|
`if (!array_key_exists('published', $body) || !is_bool($body['published'])) → 400`
|
||||||
`$page->published($published); $page->save();`). The write must land in
|
— then assign the raw boolean (`$published = $body['published']`). Do **not**
|
||||||
`trip.md` frontmatter as `published: true|false`.
|
`(bool)`-cast the value: a cast silently coerces anything (`"false"`, `0`, `""`,
|
||||||
|
a missing key) into a valid boolean and never rejects, contradicting the 400.
|
||||||
|
7. Set published + persist frontmatter by mutating the page **header** before
|
||||||
|
saving: `$header = $page->header(); $header->published = $published; $page->save();`
|
||||||
|
— mirroring `cache-on-save`'s `setOverwriteMode()` header-mutation pattern. Do
|
||||||
|
**not** rely on `$page->published($published)` alone: in Grav 2.0 that only sets
|
||||||
|
the in-memory property (`Page.php:1714`), while `save()` serializes from the
|
||||||
|
header object (`Page.php:1256`) and the flag is read one-way *from* the header at
|
||||||
|
init (`Page.php:541`) — so the on-disk `trip.md` would be unchanged and the
|
||||||
|
toggle would silently no-op. The write must land in `trip.md` frontmatter as
|
||||||
|
`published: true|false`.
|
||||||
8. **Caching:** `$this->grav['cache']->deleteAll(); Cache::invalidateCache();` —
|
8. **Caching:** `$this->grav['cache']->deleteAll(); Cache::invalidateCache();` —
|
||||||
publish state feeds `.published()` collections and routability, both keyed
|
publish state feeds `.published()` collections and routability, both keyed
|
||||||
through the page-tree index; without `invalidateCache()` the listing/nav/home
|
through the page-tree index; without `invalidateCache()` the listing/nav/home
|
||||||
@@ -72,6 +87,11 @@ Params: `trip` (the trip Page), `is_active` (bool, whether this trip is
|
|||||||
`data-published`, and `data-active` for the JS to read. Rendered only when
|
`data-published`, and `data-active` for the JS to read. Rendered only when
|
||||||
`is_owner`.
|
`is_owner`.
|
||||||
|
|
||||||
|
The switch carries `role="switch"` + `aria-checked` and a per-instance accessible
|
||||||
|
name — `aria-label="Published — {{ trip.title }}"` — so a screen-reader user on
|
||||||
|
the listing (where every card's switch is otherwise identical) can tell which trip
|
||||||
|
a toggle controls before triggering a destructive unpublish.
|
||||||
|
|
||||||
### Surface 1 — `/trips` listing (`trips.html.twig`)
|
### Surface 1 — `/trips` listing (`trips.html.twig`)
|
||||||
|
|
||||||
- Make the collection owner-aware:
|
- Make the collection owner-aware:
|
||||||
@@ -85,39 +105,80 @@ Params: `trip` (the trip Page), `is_active` (bool, whether this trip is
|
|||||||
the card so the cover image is in a positioned wrapper and the toggle sits as
|
the card so the cover image is in a positioned wrapper and the toggle sits as
|
||||||
an overlay sibling. Toggle placement: **absolutely positioned over the cover
|
an overlay sibling. Toggle placement: **absolutely positioned over the cover
|
||||||
image, top-right corner.** `Draft` badge on unpublished cards.
|
image, top-right corner.** `Draft` badge on unpublished cards.
|
||||||
|
- **Legibility over arbitrary covers:** give the overlay toggle a solid pill/chip
|
||||||
|
background reusing the `Draft`-badge styling (Field Notes paper/teal) so it stays
|
||||||
|
legible on any cover photo, and a ≥44px touch target kept clear of the card `<a>`
|
||||||
|
hit area.
|
||||||
|
|
||||||
### Surface 2 — trip detail page (`trip.html.twig`)
|
### Surface 2 — trip detail page (`trip.html.twig`)
|
||||||
|
|
||||||
- Compute `is_owner` (separate from `owner_can_edit`).
|
**No publish UI in v1 — management is listing-only.** The detail page gets neither
|
||||||
- Render the shared toggle in the header area, **top-right corner near the trip
|
a write toggle nor a `Draft` indicator, for a concrete reason: an unpublished trip
|
||||||
header**, with the `Draft` badge when unpublished.
|
is not routable, and Grav's frontend serves a 404 for unpublished pages to
|
||||||
|
*everyone including the owner* (`Page::routable()` = `routable && published`, with
|
||||||
|
no published routable child to redirect to since `dailies`/`stories` are
|
||||||
|
`routable:false` — verified in `Pages::dispatch` / `Page.php:1772`). So a trip's
|
||||||
|
detail page only ever renders while it is **published** — which means a `Draft`
|
||||||
|
indicator there would be unreachable, and a write toggle could only *unpublish*,
|
||||||
|
immediately stranding the owner on a page that 404s on the next load with no in-UI
|
||||||
|
path back. All publish/unpublish therefore happens on the `/trips` listing
|
||||||
|
(Surface 1), which shows drafts and is fully reversible. `trip.html.twig` needs no
|
||||||
|
`is_owner` computation for this feature.
|
||||||
|
|
||||||
### JS — `js/src/trip-publish.js` → built to `js/trip-publish.js`
|
### JS — `js/src/trip-publish.js` → built to `js/trip-publish.js`
|
||||||
|
|
||||||
Loaded in the `bottom` group **only when `is_owner`** (like `feed-actions.js`).
|
Loaded on `/trips` in the `bottom` group **only when `is_owner`** (gated like
|
||||||
|
`feed-actions.js` — but note the request shape below differs from it).
|
||||||
|
|
||||||
- Binds each `.trip-publish-toggle` control.
|
- Binds each `.trip-publish-toggle` control (listing cards only).
|
||||||
- On change:
|
- On change:
|
||||||
- If turning **off** (unpublish) AND `data-active` is true → `window.confirm(
|
- If turning **off** (unpublish) AND `data-active` is true → `window.confirm(
|
||||||
'This is your active trip — unpublish it anyway?')`; if cancelled, revert the
|
'This is your active trip — unpublishing it also removes it from the home page.
|
||||||
switch and stop.
|
Unpublish anyway?')`; if cancelled, revert the switch and stop. (Home falls back
|
||||||
- `POST /api/v1/trip/<slug>/publish` with `{ published }`,
|
to its pre-departure state when the active trip is unpublished — see Edge cases.)
|
||||||
`credentials: 'include'`.
|
- **Pending:** disable the switch and set `aria-busy` for the duration of the
|
||||||
|
request, ignoring further toggles — guards against a double-tap, or a toggle
|
||||||
|
during the active-trip `confirm()`, firing a second contradictory POST and
|
||||||
|
racing the revert paths. Show it dimmed with a wait cursor while pending;
|
||||||
|
re-enable on success or after the failure revert.
|
||||||
|
- `POST /api/v1/trip/<slug>/publish` sending **`headers: { 'Content-Type':
|
||||||
|
'application/json', Accept: 'application/json' }` and `body: JSON.stringify({
|
||||||
|
published })`**, `credentials: 'include'`. Model this on `post-form.js`'s
|
||||||
|
`apiSend`, **not** `feed-actions.js` (which is a body-less DELETE with no
|
||||||
|
`Content-Type`). The `Content-Type: application/json` is load-bearing: the API's
|
||||||
|
`JsonBodyParserMiddleware` only parses the body when that header is present
|
||||||
|
(`JsonBodyParserMiddleware.php:16`); without it the body decodes to `[]`, the
|
||||||
|
strict `is_bool` guard (backend step 6) sees no `published` key, and **every
|
||||||
|
toggle 400s**.
|
||||||
- **Success:** optimistic UI — flip `data-published`, toggle the `Draft` badge,
|
- **Success:** optimistic UI — flip `data-published`, toggle the `Draft` badge,
|
||||||
update the switch position/label. No full reload needed (server state is
|
update the switch position/label in place on the card. No full reload needed
|
||||||
persisted + cache invalidated for other surfaces).
|
(server state is persisted + cache invalidated for other surfaces). The card
|
||||||
- **Failure:** revert the switch to its prior state and show an inline,
|
stays visible to the owner either way (the owner-aware collection includes
|
||||||
`aria-live` error (reuse the copy style from `feed-actions.js`:
|
drafts).
|
||||||
401/403 → "sign in again"; other → "Couldn't update — try again.").
|
- **Failure:** revert the switch to its prior state and surface an error via one
|
||||||
|
shared page-level `aria-live` toast region (the listing's corner overlay has no
|
||||||
|
room for an inline message). Reuse the copy style from `feed-actions.js`:
|
||||||
|
401/403 → "sign in again"; other → "Couldn't update — try again."
|
||||||
|
|
||||||
## Edge cases
|
## Edge cases
|
||||||
|
|
||||||
- **Active trip unpublish** → JS `confirm()` (above). Allowed on confirm.
|
- **Active trip unpublish** → JS `confirm()` (above), allowed on confirm. **Home
|
||||||
|
then treats it as no active trip:** gate `home.html.twig`'s active-trip branch on
|
||||||
|
the resolved active trip being **published** as well as `config.site.travelling`
|
||||||
|
(`{% if config.site.travelling and trip.published %}` — `trip` is already resolved
|
||||||
|
at `home.html.twig:10`). When the active trip is unpublished, home falls through to
|
||||||
|
its between-trips / pre-departure state instead of rendering a draft trip. No need
|
||||||
|
to touch `site.active_trip`.
|
||||||
- **Anon / non-owner** → no toggle rendered; listing shows `.published()` only;
|
- **Anon / non-owner** → no toggle rendered; listing shows `.published()` only;
|
||||||
backend rejects with 401/403.
|
backend rejects with 401/403.
|
||||||
- **Unpublished trip visibility** → drops from the public `/trips` listing; its
|
- **Unpublished trip visibility** → drops from the public `/trips` listing; its
|
||||||
detail page 404s for anon (Grav default for unpublished/unroutable). Owner
|
detail page 404s for **everyone including the owner** (Grav default for
|
||||||
still sees it in the listing (Draft badge) and can re-publish.
|
unpublished/unroutable — there is no owner-preview bypass). The owner still sees
|
||||||
|
the trip in the `/trips` listing (Draft badge) and re-publishes from there.
|
||||||
|
(Scope note: this toggle governs only whether the trip appears in the `/trips`
|
||||||
|
listing — it is not a content-privacy control. Child dailies are aggregated inline
|
||||||
|
by the trip page and are not individually linked; a story reachable by a direct
|
||||||
|
link stays reachable, which is acceptable.)
|
||||||
- **Child dailies/stories cascade** → out of scope for v1; unpublishing a trip
|
- **Child dailies/stories cascade** → out of scope for v1; unpublishing a trip
|
||||||
does not change its children's published state.
|
does not change its children's published state.
|
||||||
|
|
||||||
@@ -130,13 +191,20 @@ the post specs. Use a throwaway fixture trip folder (create/cleanup on disk).
|
|||||||
`.trip-publish-toggle`; an anon (cleared storageState) load does not, and an
|
`.trip-publish-toggle`; an anon (cleared storageState) load does not, and an
|
||||||
unpublished fixture trip is absent for anon.
|
unpublished fixture trip is absent for anon.
|
||||||
2. **TP2 — unpublish hides it (caching).** Owner toggles a published fixture trip
|
2. **TP2 — unpublish hides it (caching).** Owner toggles a published fixture trip
|
||||||
off → **reload** `/trips` as anon → the trip is absent; owner reload → Draft
|
off → **reload** `/trips` as anon → the trip is absent; owner reload of the
|
||||||
badge present. This is the page-tree-index assertion (mirrors DEL4).
|
`/trips` listing → Draft badge present (asserted on the listing, since the detail
|
||||||
3. **TP3 — republish restores it.** Toggle back on → anon reload sees it again.
|
page 404s for the owner too). This is the page-tree-index assertion (mirrors DEL4).
|
||||||
|
3. **TP3 — republish restores it (from the listing).** As owner on `/trips`, toggle
|
||||||
|
a Draft fixture trip back on → anon reload sees it again. Republish is asserted on
|
||||||
|
the listing surface, not the detail page (which 404s while unpublished).
|
||||||
4. **TP4 — active-trip confirm.** Unpublishing the active trip prompts a confirm;
|
4. **TP4 — active-trip confirm.** Unpublishing the active trip prompts a confirm;
|
||||||
dismissing leaves it published.
|
dismissing leaves it published.
|
||||||
5. **TP5 — authz.** `POST /api/v1/trip/<slug>/publish` as anon → 401; as a
|
5. **TP5 — authz.** `POST /api/v1/trip/<slug>/publish` as anon → 401; as a
|
||||||
non-owner authenticated user → 403; frontmatter unchanged on disk.
|
non-owner authenticated user → 403; frontmatter unchanged on disk.
|
||||||
|
6. **TP6 — active trip unpublished → home falls back.** With the fixture trip set as
|
||||||
|
`site.active_trip` and `travelling: true`, unpublish it → reload `/` → home renders
|
||||||
|
its between-trips / pre-departure state, not the draft trip's active-trip view.
|
||||||
|
(Needs the `active_trip` override on the fixture; mirrors the home-suite setup.)
|
||||||
|
|
||||||
## Out of scope
|
## Out of scope
|
||||||
|
|
||||||
@@ -144,3 +212,6 @@ the post specs. Use a throwaway fixture trip folder (create/cleanup on disk).
|
|||||||
- Scheduling / publish dates.
|
- Scheduling / publish dates.
|
||||||
- Cascading child publish state.
|
- Cascading child publish state.
|
||||||
- Reordering trips by publish state (order stays by date desc).
|
- Reordering trips by publish state (order stays by date desc).
|
||||||
|
- A publish/unpublish write control on the trip detail page. Management is
|
||||||
|
listing-only by design (an unpublished trip's detail page 404s, so a detail-page
|
||||||
|
toggle could only strand the owner — see Surface 2).
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Post form: location override (search + map + drag)
|
||||||
|
|
||||||
|
**Status:** 📋 Not started
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The post form's `lat`/`lng` fields exist in the blueprint (`user/pages/02.post/post-form.md`) as plain `type: text` fields, but a theme CSS rule (`user/themes/intotheeast/css/style.css:893-895`) hides them, and the only way to populate them is the `📍 Get Location` button, which reads the browser's live GPS position via `navigator.geolocation`.
|
||||||
|
|
||||||
|
This breaks down whenever an entry describes a place the traveller isn't physically standing in when they write it up — the common case for journal entries written at the end of a day, from a shelter/hostel/train, about somewhere visited earlier. There is currently no supported way to set a coordinate for anywhere other than "here, right now."
|
||||||
|
|
||||||
|
The only workaround has been logging into Admin2 and hand-typing/pasting raw decimal coordinates directly into the page's frontmatter field. This is what produced the Denmark 2026 bug: a coordinate pasted from an external source carried an invisible Unicode bidi mark (U+200E), which PHP's `(float)` cast silently coerced to `0.0`, placing the entry's map marker at `(0, 0)` with no error or warning anywhere in the pipeline.
|
||||||
|
|
||||||
|
Backend sanitization has already been added (`user/plugins/cache-on-save/cache-on-save.php`: `cleanCoordinate()`, wired into both `onFormValidationProcessed` for the public form and `onAdminSave` for Admin2/API saves) to strip invisible characters and range-validate lat/lng before they ever reach a page's frontmatter. That fix is necessary but not sufficient: it prevents *silent corruption of whatever gets typed*, but does nothing to prevent the underlying problem — a fragile, invisible-to-the-eye, paste-prone raw text field is still the only way to set an arbitrary location, and there's no way to visually confirm the result before submitting. This spec addresses that gap directly, on the frontend post form, so the Admin2 round-trip is no longer needed for this at all.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Give the traveller a reliable, visual way to set an entry's coordinates for a location other than their current GPS position, without touching Admin2.
|
||||||
|
- Let any coordinate-setting mistake be caught *before* submit, via a live map preview, rather than relying solely on backend validation to catch it after the fact.
|
||||||
|
- Keep the common case (GPS, writing about where you currently are) exactly as fast and simple as it is today — no added friction for the 📍 Get Location button.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No changes to Admin2 or the `api` plugin. The backend sanitization already shipped there stays as-is, as defense-in-depth for the Admin2 edit path (which this spec doesn't touch).
|
||||||
|
- No change to how coordinates are stored (still plain `lat`/`lng` floats in frontmatter).
|
||||||
|
- No offline/self-hosted geocoding — this reuses free, no-key, CORS-enabled public APIs, consistent with the form's existing BigDataCloud (reverse geocode) and Open-Meteo (weather) integrations.
|
||||||
|
- No additional integrity verification (certificate pinning, response signing, etc.) for the geocoding/tile third-party responses beyond HTTPS. A compromised or MITM'd response could theoretically feed bogus coordinates or map tiles into the preview, but this is accepted as low-probability and already bounded by the unchanged server-side `cleanCoordinate()` range validator, which gates what actually reaches frontmatter regardless of what the preview displays.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Placement
|
||||||
|
|
||||||
|
- **📍 Get Location** (GPS): unchanged. Stays in its current top-level `.form-action-row`, primary/always-visible action for "I'm posting from where I am right now."
|
||||||
|
- **City / Country**: unchanged position and behavior in the main field flow (still plain, always-visible text fields, still auto-filled by GPS reverse-geocode only when blank).
|
||||||
|
- **New "More location details" disclosure**, placed directly below the City/Country fields (a separate `<details>` block from the existing "More options" advanced-fields disclosure, which stays scoped to the unrelated `published`/`force_connect`/`featured` toggles). Closed by default. Contains:
|
||||||
|
- A **"🔍 Look up coordinates"** button.
|
||||||
|
- A small MapLibre preview map with a single, draggable marker.
|
||||||
|
- The raw `lat`/`lng` text fields, relocated here from their current CSS-hidden position in the main flow (the `display: none !important` rule in `user/themes/intotheeast/css/style.css:894-895`, which targets `input[name="data[lat]"]`/`input[name="data[lng]"]`, is removed; the fields simply live inside this disclosure instead). This is a pure DOM relocation — the `name="data[lat]"`/`name="data[lng]"` attributes are unchanged, so `cache-on-save.php`'s `sanitizeCoordinates()` (which keys off those exact field names) and `post-form.js`'s existing `field('lat')`/`field('lng')` helper both keep working unmodified. Checked the theme for other references to that CSS rule or those field names — none found outside `style.css:894-895` and `post-form.js`'s own read/write of the fields — so removing the rule has no other side effects.
|
||||||
|
|
||||||
|
### Search mechanics
|
||||||
|
|
||||||
|
- The lookup button geocodes the **City field alone** via Open-Meteo's free geocoding endpoint (`https://geocoding-api.open-meteo.com/v1/search?name=<city>&count=10&language=en&format=json`) — same provider the form already trusts for weather (`api.open-meteo.com`), no API key required. CORS is confirmed open on this endpoint independent of the weather endpoint (`access-control-allow-origin: *`, verified directly against `geocoding-api.open-meteo.com`).
|
||||||
|
- **The Country field is not concatenated into the query string.** Verified against the live API: a combined query like `name=Paris%2C%20Texas` or `name=Jerup%2C%20Denmark` either returns zero results or silently degrades to matching only the part before the comma — Open-Meteo's `name` param does fuzzy/substring matching on the place name, not a "name, country" filter syntax. Concatenating would silently break the lookup for exactly the disambiguation case (e.g. "Paris, Texas") this feature exists to handle.
|
||||||
|
- Instead: query by City name alone (returns all same-named places, e.g. all five "Paris" results worldwide), then — if the Country field is non-blank — rank results client-side by matching Country against each result's `country` field (case-insensitive substring), matching entries first. All results still render in the list below, just reordered.
|
||||||
|
- Explicit click, not live-as-you-type — matches the deliberate, single-action feel of the existing GPS button.
|
||||||
|
- While a lookup request is in flight, the button shows a brief "Searching…" state (disabled, consistent with how other in-flight actions in `post-form.js` guard against double-submission); it re-enables on response, whether that's results, no-match, or network failure.
|
||||||
|
- Clicking "🔍 Look up coordinates" with both City and Country empty is treated the same as a no-match: inline hint to fill in a city or country first, no request is sent.
|
||||||
|
- **The lookup only reads City/Country — it never writes back to them.** A geocode result sets `lat`/`lng` and moves the pin only. This avoids the earlier concern of an ambiguous or slightly-off match silently overwriting a name the traveller deliberately typed.
|
||||||
|
- Multiple matches → rendered as a small clickable list (place name, admin region, country), Country-matches ranked first per above, so the traveller can disambiguate (e.g. "Paris, Île-de-France, France" vs "Paris, Texas, United States"). Each list item is built via `document.createElement` + `.textContent` — the same convention used everywhere else in `post-form.js` for dynamic content (no `innerHTML` string-building exists in the file today) — since these are untrusted, API-sourced strings. Clicking an entry sets `lat`/`lng` and moves the pin; the list is not shown again until the next lookup.
|
||||||
|
- No matches → inline hint: try adding a country, or drag the pin manually.
|
||||||
|
- Network failure → degrades the same way the existing reverse-geocode/weather calls do: silent-ish failure, fields untouched, traveller can still fall back to manual entry or the pin.
|
||||||
|
|
||||||
|
### Map preview + sync
|
||||||
|
|
||||||
|
- Single MapLibre GL map instance, reusing the site's existing style (`https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json` — same as `maplibre-utils.js`, no new API key), with one draggable marker sized to at least a ~44×44px touch target (matching standard iOS/Android touch-target guidance), since this is a mobile-first form.
|
||||||
|
- `maplibre-gl`'s JS is dynamically imported (`import('maplibre-gl')`) only when the "More location details" `<details>` is opened for the first time — mirrors the existing HEIC-conversion lazy-chunk pattern in `post-form.js`, so the ~200KB library is never fetched for ordinary GPS-only submissions. Its CSS (`maplibre-gl/dist/maplibre-gl.css`, ~8KB minified) is imported statically at the top of `post-form.js` instead, bundled unconditionally into the always-loaded `css-compiled/post-form.css` — unlike the JS, the CSS chunk can't be split off a dynamic import without esbuild orphaning it (no `<link>` reference is ever emitted for a code-split CSS chunk), so only the JS half of the HEIC lazy-chunk pattern applies here.
|
||||||
|
- Four ways to set a coordinate, all kept in sync with each other:
|
||||||
|
1. **GPS button** (main flow) — writes `lat`/`lng` directly. If "More location details" is closed, the map/pin simply reflect the new values whenever the panel is next opened. If the panel is already open when GPS resolves, the same field→pin sync used by path 4 (typing) fires immediately, so the pin jumps to the new position live instead of requiring a re-open.
|
||||||
|
2. **Search-result click** — sets fields, moves/creates pin.
|
||||||
|
3. **Dragging the pin** — on `dragend`, reads the marker's `lngLat`, writes back into the `lat`/`lng` text fields (rounded to 6 decimal places, matching the GPS button's existing precision).
|
||||||
|
4. **Typing directly into lat/lng** — on blur/debounced input, if both values parse as valid finite numbers within range, move (or create) the pin. Invalid/unparseable input leaves the pin where it was, but visually flags the field (e.g. a red outline plus an inline "not reflected on map" note) so the traveller can tell the text and the pin disagree — this is a visual aid, not a blocking validator; final enforcement stays server-side in `cleanCoordinate()`. The flag clears once the field's value parses and the pin catches up.
|
||||||
|
- If the map is opened with no `lat`/`lng` set yet, no pin is shown until one of the four paths above sets a value.
|
||||||
|
- The map instance is created once, the first time "More location details" is opened, and held in module scope; reopening the `<details>` later reuses that instance rather than constructing a duplicate. Repeat `import('maplibre-gl')` calls resolve from the ES module cache with no extra network fetch — the same behavior the existing `heic-to` lazy import already relies on. Because the container sits under `display: none` while the `<details>` is closed, MapLibre initializes with a zero-size canvas the first time; the map calls `.resize()` on every subsequent open to pick up the container's real dimensions.
|
||||||
|
|
||||||
|
### Error handling
|
||||||
|
|
||||||
|
- No search results: inline message under the search box, map/pin untouched.
|
||||||
|
- Search network failure: fields untouched, and an inline hint says the lookup service could not be reached (distinct from the no-results message, which means the service answered). **Revised in code review 2026-07-24** — this originally said "silent-ish degrade", which in practice left the DOM byte-identical to the pre-click state, so a traveller on flaky mobile data could not tell a failed lookup from a broken button. A non-2xx response is also now treated as a failure rather than parsed as an empty result set.
|
||||||
|
- Invalid manual `lat`/`lng` text: the visual mismatch flag is the primary feedback, **and** an unresolved flag blocks submit. **Revised in code review 2026-07-24** — this originally said "no client-side hard block", on the stated grounds that server-side `cleanCoordinate()` was already the safety net. It was not: `cleanCoordinate()` had never been committed, so nothing validated coordinates anywhere. It now ships (`cache-on-save.php`, both the `/post` and Admin2 paths), so the two are genuine defence in depth rather than one imaginary net. Client-side parsing is deliberately *stricter* than the server's `is_numeric` (whole-value decimals only), which is the safe direction for a mismatch.
|
||||||
|
- Geolocation permission denied: unchanged existing behavior (`#location-status` error message).
|
||||||
|
|
||||||
|
## Out of scope / explicitly deferred
|
||||||
|
|
||||||
|
- No changes to `user/plugins/admin2/` or `user/plugins/api/` — confirmed and intentional.
|
||||||
|
- No removal of the existing backend `cleanCoordinate()` sanitization (`onFormValidationProcessed` + `onAdminSave` in `cache-on-save.php`) — it remains as defense-in-depth, especially for the still-possible Admin2 edit path.
|
||||||
|
- Automated Playwright coverage for the new search→pin→submit flow is desirable but currently blocked by a pre-existing, unrelated `make test-account` Makefile quoting bug — flagged as a follow-up, not a blocker for shipping this feature. Manual in-browser QA (per CLAUDE.md's UI-change testing guidance) is required before considering this done.
|
||||||
|
|
||||||
|
## Testing plan
|
||||||
|
|
||||||
|
- Manual QA in the dev browser: open `/post`, expand "More location details," exercise all four coordinate-setting paths (GPS, search + pick a result, drag the pin, type raw numbers) and confirm the pin and fields stay in sync in both directions. Submit and confirm the saved entry's frontmatter has the expected `lat`/`lng`.
|
||||||
|
- Exercise the ambiguous-search case: City "Paris" with Country "Texas" and confirm the Texas result ranks first over the France/Tennessee/Kentucky/Illinois matches — this is the specific case the City-only-query + client-side-rank fix targets, since concatenating "Paris, Texas" into a single query string returns zero results from Open-Meteo. Also exercise the no-match case.
|
||||||
|
- Reopen "More location details" a second time in the same session and confirm the map doesn't duplicate (still one canvas, correctly sized) and the pin still reflects the current `lat`/`lng`.
|
||||||
|
- Exercise the "type garbage into lat/lng" case and confirm the map simply doesn't move the pin (no crash), while a submit still round-trips through the existing backend `cleanCoordinate()` validation.
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# Docs Reconciliation — Design
|
||||||
|
|
||||||
|
**Date:** 2026-07-25
|
||||||
|
**Status:** Implemented
|
||||||
|
|
||||||
|
Reconcile the documentation against the code after five weeks of undocumented evolution, so that a
|
||||||
|
repeat review returns "ok".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Documentation for this project began as thoughts and plans. The app then changed — features were
|
||||||
|
built differently, some were dropped, and the owner changed his mind about what he needed. Those
|
||||||
|
decisions were recorded ad hoc or not at all. The result is a tree where some docs describe a site
|
||||||
|
that no longer exists, and nothing marks them as historical.
|
||||||
|
|
||||||
|
Concretely, before this pass:
|
||||||
|
|
||||||
|
- `docs/working/README.md` advertised `summary.md` as the project's **current state**, while
|
||||||
|
`summary.md` described Leaflet, a `/tracker` feed, a `/map` page, a `/stats` page, and a
|
||||||
|
"Journal · Map · Stats" nav — none of which exist.
|
||||||
|
- `docs/reference/design-system-light.md` documented a light-mode palette in present tense. No light
|
||||||
|
mode is implemented anywhere: `tokens.css` has a single `:root` block and no
|
||||||
|
`prefers-color-scheme` / `data-theme` mechanism.
|
||||||
|
- `CLAUDE.md` — the always-loaded file — asserted a source relationship that does not exist
|
||||||
|
(`css-compiled/` generated from `css/style.css` + `css/tokens.css`).
|
||||||
|
- `README.md`'s server runbook documented every `make remote-*` command without the `-test`/`-prod`
|
||||||
|
suffix that `guard-env` requires, so the documented commands cannot run.
|
||||||
|
- `docker-compose.yml` still defines a `travel-memories` service whose source was deleted in
|
||||||
|
`a80b0a9` ("moved to separate project"), so `make start` fails on any clean checkout.
|
||||||
|
|
||||||
|
## Root cause
|
||||||
|
|
||||||
|
Per [`docs/solutions/conventions/claude-md-content-tiering.md`](../../solutions/conventions/claude-md-content-tiering.md),
|
||||||
|
descriptions drift because the code moves and the prose does not; rules do not drift, because they
|
||||||
|
encode intent rather than state. This pass confirms that finding again: every defect found was a
|
||||||
|
description of code, config, or a command — not one was a rule that had become wrong on its own.
|
||||||
|
|
||||||
|
The compounding factor is **tense**. The tree mixes two kinds of document with no marker
|
||||||
|
distinguishing them:
|
||||||
|
|
||||||
|
| Kind | Files | Staleness is |
|
||||||
|
|---|---|---|
|
||||||
|
| Present-tense — "this is how it is" | `CLAUDE.md`, `reference/`, `guides/`, `README.md`, `CONCEPTS.md` | a defect |
|
||||||
|
| Past-tense — "this is what we decided then" | `working/plans/`, `working/specs/`, `working/milestones/`, `summary.md`, `pm-analysis.md` | correct and expected |
|
||||||
|
|
||||||
|
A completed plan *should* be stale — it is a record. It only becomes a problem when nothing tells a
|
||||||
|
reader it is a record. `milestones/milestone-2.md` opens by describing a Leaflet `/map` page in
|
||||||
|
confident present tense with no date qualifier.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
Two mechanisms, combined:
|
||||||
|
|
||||||
|
**A — one authoritative supersession ledger.** `docs/reference/superseded-decisions.md` records every
|
||||||
|
reversal in one table: what was planned, where it was planned, what is true now, when it changed, and
|
||||||
|
why. This answers "what did I change my mind about?" in a single place, which is the question a
|
||||||
|
review actually asks.
|
||||||
|
|
||||||
|
**B — inline notes at the point of staleness.** Every superseded section carries a
|
||||||
|
`> **Superseded …**` blockquote where the stale claim sits, so the claim can never be read
|
||||||
|
un-corrected. This pattern is not invented here — `docs/reference/architecture.md` and
|
||||||
|
`docs/guides/trip-switching.md` already use `> History:` and `> **Changed 2026-07:**` notes.
|
||||||
|
|
||||||
|
A alone has an indirection problem (a pointer you may not follow). B alone has a completeness problem
|
||||||
|
(no changelog view, and coverage is only as good as the annotation pass). Together each covers the
|
||||||
|
other's gap.
|
||||||
|
|
||||||
|
### Scope, split by tense
|
||||||
|
|
||||||
|
- **Present-tense docs are corrected against the code.** The code is the source of truth. Every
|
||||||
|
factual claim was verified by reading the code, config, or `Makefile` — not inferred.
|
||||||
|
- **Past-tense docs are annotated only, never rewritten.** 41 plans and 25 specs, ~30k lines. Their
|
||||||
|
`✅ Complete` trailing notes are good records; rewriting them would destroy the audit trail and is
|
||||||
|
unbounded work.
|
||||||
|
- **Code-side inconsistencies are logged, not fixed.** Mixing behaviour changes into a documentation
|
||||||
|
diff would make it unreviewable. They go to
|
||||||
|
`docs/working/2026-07-25-doc-drift-recommendations.md` for a separate decision.
|
||||||
|
|
||||||
|
### Out of scope
|
||||||
|
|
||||||
|
- A repeatable drift check (script with an exit code). Deliberately deferred — the owner asked for the
|
||||||
|
one-time reconciliation first. It is the lead recommendation in the recommendations doc.
|
||||||
|
- Fixing the `travel-memories` / `docker-compose.yml` breakage, the unused
|
||||||
|
`shortcode-gallery-plusplus`, and the `italy-2025` demo fixtures. All logged as recommendations.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Claims were checked against, not assumed from:
|
||||||
|
|
||||||
|
| Claim area | Verified against |
|
||||||
|
|---|---|
|
||||||
|
| Nav labels | `templates/partials/base.html.twig:27-31` |
|
||||||
|
| Template + partial inventory | `ls templates/`, `ls templates/partials/` |
|
||||||
|
| Asset sources → outputs | `user/themes/intotheeast/package.json` build script |
|
||||||
|
| `css-compiled/` provenance | CSS imports in `js/src/*.js`; `assets.addCss` in `base.html.twig:7-8` |
|
||||||
|
| Design tokens | `css/tokens.css` |
|
||||||
|
| Light mode | absence of `prefers-color-scheme` / `data-theme` and of light hex values in `css/` |
|
||||||
|
| Photo field rules | `user/pages/02.post/post-form.md:35-46` |
|
||||||
|
| `hero_image` removal | `post-form.md:149-151` |
|
||||||
|
| `entry-actions` routes | `user/plugins/entry-actions/entry-actions.php:63-73` |
|
||||||
|
| `make` targets + env guard | `Makefile` (`guard-env:41-43`, `make-env-target:45-46`) |
|
||||||
|
| `travel-memories` removal | `git log -- services/` → `a80b0a9`; `docker compose build` failure |
|
||||||
|
|
||||||
|
## The audit baseline moved twice
|
||||||
|
|
||||||
|
Both times, auditing the convenient state rather than the real one would have produced wrong findings.
|
||||||
|
|
||||||
|
**The submodule pin lagged.** A fresh worktree checks out the `user/` commit the outer repo pins, not
|
||||||
|
`user/`'s real HEAD. The pin predated the merged location-override work, so auditing it would have
|
||||||
|
reported a feature as unbuilt and missed two new source files. `user/` was moved to its real HEAD
|
||||||
|
(`dd19995`) before auditing, and the gitlink deliberately not committed.
|
||||||
|
|
||||||
|
**The outer `main` advanced 13 commits mid-audit.** The location-override branch was merged into the
|
||||||
|
outer repo while this pass was running, which independently fixed two of the findings — the
|
||||||
|
single-map-path carve-out (`829325c`) and the plan's `Status:` line (`a517331`). Merging `main` in
|
||||||
|
before opening the PR was what surfaced that; without it this branch would have **reverted** both.
|
||||||
|
`main`'s wording was better than the replacement drafted here and was kept in full. `main` touched none
|
||||||
|
of the other nine corrected documents, so the remaining findings stand unchanged.
|
||||||
|
|
||||||
|
The general rule: **re-check the baseline before publishing, not only before starting.** A long audit
|
||||||
|
races the work it is auditing.
|
||||||
@@ -2,6 +2,16 @@
|
|||||||
|
|
||||||
*Branch: `experimental-polar-steps`. Ready for morning review.*
|
*Branch: `experimental-polar-steps`. Ready for morning review.*
|
||||||
|
|
||||||
|
> **Historical — written 2026-06-21. This is not the current state of the site.**
|
||||||
|
>
|
||||||
|
> This was the wrap-up of the four-milestone experimental branch. Much of what it describes has since
|
||||||
|
> been deliberately reversed: there is no `/map` page, no `/stats` page, no `/tracker` feed, no
|
||||||
|
> Leaflet, and the nav is not "Journal · Map · Stats". Every reversal is listed in
|
||||||
|
> [`../reference/superseded-decisions.md`](../reference/superseded-decisions.md).
|
||||||
|
>
|
||||||
|
> For the site as it actually is, read
|
||||||
|
> [`../reference/architecture.md`](../reference/architecture.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What Was Done
|
## What Was Done
|
||||||
|
|||||||
@@ -59,7 +59,10 @@ check_grep "location_country field present" "name: location_country"
|
|||||||
check_grep "weather_desc field present" "name: weather_desc"
|
check_grep "weather_desc field present" "name: weather_desc"
|
||||||
check_grep "weather_temp_c field present" "name: weather_temp_c"
|
check_grep "weather_temp_c field present" "name: weather_temp_c"
|
||||||
check_grep "transport_mode field present" "name: transport_mode"
|
check_grep "transport_mode field present" "name: transport_mode"
|
||||||
check_grep "hero_image field present" "name: hero_image"
|
# No hero_image assertion: the field was deliberately dropped in 8cf1145 —
|
||||||
|
# entries render their hero from the first photo, so an explicit filename was
|
||||||
|
# redundant (see the comment at that spot in post-form.md). This check outlived
|
||||||
|
# the field and had been failing ever since.
|
||||||
check_grep "force_connect field present" "name: force_connect"
|
check_grep "force_connect field present" "name: force_connect"
|
||||||
check_grep "featured field present" "name: featured"
|
check_grep "featured field present" "name: featured"
|
||||||
|
|
||||||
|
|||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -2,6 +2,58 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { execSync } = require('child_process');
|
const { execSync } = require('child_process');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fail fast if the server under test does not serve the `user/` tree the specs
|
||||||
|
* read from disk.
|
||||||
|
*
|
||||||
|
* This mismatch is silent and destructive. Every post spec submits through the
|
||||||
|
* live form (the write target is derived server-side from site.yaml
|
||||||
|
* `active_trip`, so there is no per-request override), then asserts and cleans up
|
||||||
|
* on disk via helpers' USER_DIR. Run the specs from a worktree whose own
|
||||||
|
* container is down and baseURL falls back to localhost:8081 — the MAIN
|
||||||
|
* checkout — so entries get created in one content tree while cleanup deletes
|
||||||
|
* from another. The entries are then left behind in real trip content, which is
|
||||||
|
* exactly what happened on 2026-07-24.
|
||||||
|
*
|
||||||
|
* Docker is the only thing that knows the mapping, so this is best-effort: if we
|
||||||
|
* cannot determine it we warn and continue rather than blocking non-Docker runs.
|
||||||
|
* But when we CAN determine it and it disagrees, that is always a bug.
|
||||||
|
*/
|
||||||
|
function assertServerServesUserDir(baseURL, userDir) {
|
||||||
|
const port = new URL(baseURL).port || '80';
|
||||||
|
let mountedUserDir;
|
||||||
|
try {
|
||||||
|
const container = execSync("docker ps --format '{{.Names}}\t{{.Ports}}'", { encoding: 'utf-8' })
|
||||||
|
.split('\n').filter(Boolean)
|
||||||
|
.find(l => l.includes(`:${port}->`));
|
||||||
|
if (!container) {
|
||||||
|
console.warn(`[setup] no running container publishes port ${port} — is the dev server up? (make start)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const name = container.split('\t')[0];
|
||||||
|
mountedUserDir = execSync(
|
||||||
|
`docker inspect ${name} --format '{{range .Mounts}}{{if eq .Destination "/var/www/html/user"}}{{.Source}}{{end}}{{end}}'`,
|
||||||
|
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }
|
||||||
|
).trim();
|
||||||
|
if (!mountedUserDir) return; // no bind mount to compare against
|
||||||
|
} catch (_) {
|
||||||
|
return; // docker unavailable — nothing to check
|
||||||
|
}
|
||||||
|
|
||||||
|
const served = fs.realpathSync(mountedUserDir);
|
||||||
|
const asserted = fs.realpathSync(userDir);
|
||||||
|
if (served !== asserted) {
|
||||||
|
throw new Error(
|
||||||
|
`Test target mismatch — refusing to run.\n` +
|
||||||
|
` baseURL ${baseURL} is served from: ${served}\n` +
|
||||||
|
` but the specs read/clean up: ${asserted}\n` +
|
||||||
|
`Entries would be created in one tree and cleanup would miss them, leaving\n` +
|
||||||
|
`test entries behind in real content. Start this checkout's own server\n` +
|
||||||
|
`(make start) and point the run at it, e.g. GRAV_BASE_URL=http://localhost:<port>.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = async function globalSetup() {
|
module.exports = async function globalSetup() {
|
||||||
const envFile = path.join(__dirname, '../.env');
|
const envFile = path.join(__dirname, '../.env');
|
||||||
if (fs.existsSync(envFile)) {
|
if (fs.existsSync(envFile)) {
|
||||||
@@ -23,4 +75,9 @@ module.exports = async function globalSetup() {
|
|||||||
|
|
||||||
// Ensure demo content is loaded (italy-2026-demo trip + stories + GPX files)
|
// Ensure demo content is loaded (italy-2026-demo trip + stories + GPX files)
|
||||||
execSync('make demo-load', { cwd: path.join(__dirname, '..'), stdio: 'inherit' });
|
execSync('make demo-load', { cwd: path.join(__dirname, '..'), stdio: 'inherit' });
|
||||||
|
|
||||||
|
// Required last: helpers.js resolves USER_DIR at require time, and the .env
|
||||||
|
// load above can supply GRAV_USER_DIR.
|
||||||
|
const { USER_DIR } = require('./ui/helpers');
|
||||||
|
assertServerServesUserDir(process.env.GRAV_BASE_URL || 'http://localhost:8081', USER_DIR);
|
||||||
};
|
};
|
||||||
|
|||||||
+32
-45
@@ -1,57 +1,44 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { execSync } = require('child_process');
|
|
||||||
|
|
||||||
function resolveUserDir() {
|
// Reuse the specs' own resolution rather than reimplementing it. The previous
|
||||||
if (process.env.GRAV_USER_DIR) return process.env.GRAV_USER_DIR;
|
// version of this file derived the dailies directory from a `parent:` key in
|
||||||
try {
|
// pages/02.post/post-form.md — a key that was deliberately removed (the write
|
||||||
const raw = execSync(
|
// target is injected server-side from site.yaml `active_trip`, and CLAUDE.md
|
||||||
"docker inspect intotheeast_grav --format '{{range .Mounts}}{{if eq .Destination \"/var/www/html/user\"}}{{.Source}}{{end}}{{end}}'",
|
// forbids re-adding a static parent). The regex therefore never matched,
|
||||||
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }
|
// dailiesDir was always null, and the dailies sweep below silently did nothing.
|
||||||
).trim();
|
// That is how ui-test entries survived into the active trip's content.
|
||||||
if (raw) return raw;
|
// removeEntryDir handles the root-owned case by deleting through the container —
|
||||||
} catch (_) {}
|
// see its comment. Plain fs.rmSync cannot remove what Grav's Apache wrote.
|
||||||
return path.join(__dirname, '../user');
|
const { USER_DIR, TRACKER_DIR, removeEntryDir } = require('./ui/helpers');
|
||||||
}
|
|
||||||
|
|
||||||
function sweepUiTestEntries(dir) {
|
function sweepUiTestEntries(dir) {
|
||||||
if (!fs.existsSync(dir)) return 0;
|
if (!dir || !fs.existsSync(dir)) return 0;
|
||||||
const entries = fs.readdirSync(dir).filter(e => e.includes('ui-test'));
|
const found = fs.readdirSync(dir).filter(e => e.includes('ui-test'));
|
||||||
entries.forEach(e => fs.rmSync(path.join(dir, e), { recursive: true, force: true }));
|
let removed = 0;
|
||||||
return entries.length;
|
found.forEach(e => {
|
||||||
|
const target = path.join(dir, e);
|
||||||
|
try {
|
||||||
|
removeEntryDir(target);
|
||||||
|
removed++;
|
||||||
|
} catch (err) {
|
||||||
|
// Loud, not silent — a swallowed failure here is exactly what let a
|
||||||
|
// ui-test entry survive into the active trip's content.
|
||||||
|
console.error(`[teardown] COULD NOT REMOVE ${target}: ${err.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return removed;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = async function globalTeardown() {
|
module.exports = async function globalTeardown() {
|
||||||
const userDir = resolveUserDir();
|
// Sweep both the post inbox and the active trip's dailies.
|
||||||
|
const n1 = sweepUiTestEntries(path.join(USER_DIR, 'pages/02.post'));
|
||||||
// Read active trip slug from post-form.md
|
const n2 = sweepUiTestEntries(TRACKER_DIR);
|
||||||
const postFormPath = path.join(userDir, 'pages/02.post/post-form.md');
|
|
||||||
let dailiesDir = null;
|
|
||||||
if (fs.existsSync(postFormPath)) {
|
|
||||||
const content = fs.readFileSync(postFormPath, 'utf-8');
|
|
||||||
const m = content.match(/parent:\s*['"]?\/trips\/([^/'"]+)\/dailies/);
|
|
||||||
if (m) {
|
|
||||||
const tripSlug = m[1];
|
|
||||||
const tripsBase = path.join(userDir, 'pages/01.trips');
|
|
||||||
const tripFolder = fs.readdirSync(tripsBase).find(
|
|
||||||
f => f === tripSlug || f.endsWith('.' + tripSlug) || f.includes(tripSlug)
|
|
||||||
);
|
|
||||||
if (tripFolder) {
|
|
||||||
const dailiesBase = path.join(tripsBase, tripFolder);
|
|
||||||
const dailiesFolder = fs.readdirSync(dailiesBase).find(
|
|
||||||
f => f === 'dailies' || f === '01.dailies' || f.endsWith('.dailies')
|
|
||||||
);
|
|
||||||
if (dailiesFolder) dailiesDir = path.join(dailiesBase, dailiesFolder);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sweep both the post inbox and the active trip's dailies
|
|
||||||
const postInbox = path.join(userDir, 'pages/02.post');
|
|
||||||
const n1 = sweepUiTestEntries(postInbox);
|
|
||||||
const n2 = dailiesDir ? sweepUiTestEntries(dailiesDir) : 0;
|
|
||||||
|
|
||||||
if (n1 + n2 > 0) {
|
if (n1 + n2 > 0) {
|
||||||
console.log(`[teardown] removed ${n1} ui-test entries from 02.post, ${n2} from dailies`);
|
console.log(
|
||||||
|
`[teardown] removed ${n1} ui-test entries from 02.post, ` +
|
||||||
|
`${n2} from ${path.relative(USER_DIR, TRACKER_DIR)}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+59
-2
@@ -170,6 +170,60 @@ async function createPhotoEntry(page, tag, { content, publish = true, created }
|
|||||||
'Entry posted successfully!', { timeout: 15_000 });
|
'Entry posted successfully!', { timeout: 15_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the Grav container that serves USER_DIR, so cleanup can delete as root.
|
||||||
|
* Prefers GRAV_CONTAINER (set by .worktree-env / .env), else matches on the bind
|
||||||
|
* mount so a worktree never picks the main checkout's container.
|
||||||
|
*/
|
||||||
|
function resolveGravContainer() {
|
||||||
|
if (process.env.GRAV_CONTAINER) return process.env.GRAV_CONTAINER;
|
||||||
|
try {
|
||||||
|
const want = fs.realpathSync(USER_DIR);
|
||||||
|
const names = execSync("docker ps --format '{{.Names}}'", { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] })
|
||||||
|
.split('\n').filter(Boolean);
|
||||||
|
return names.find((n) => {
|
||||||
|
const src = execSync(
|
||||||
|
`docker inspect ${n} --format '{{range .Mounts}}{{if eq .Destination "/var/www/html/user"}}{{.Source}}{{end}}{{end}}'`,
|
||||||
|
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }
|
||||||
|
).trim();
|
||||||
|
return src && fs.realpathSync(src) === want;
|
||||||
|
}) || null;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an entry directory, falling back to the container when the host cannot.
|
||||||
|
*
|
||||||
|
* Grav's Apache workers run as root, so every entry the form creates is
|
||||||
|
* root-owned. Removing one recursively needs write permission on that directory,
|
||||||
|
* which the host user does not have — so a plain fs.rmSync throws EACCES and the
|
||||||
|
* entry survives. That is how a ui-test entry ended up committed-adjacent in the
|
||||||
|
* active trip's content on 2026-07-24: cleanup had never actually worked for
|
||||||
|
* form-created entries, it just failed inside a path nothing checked.
|
||||||
|
*
|
||||||
|
* `docker exec … rm -rf` runs as root in the container, which can remove them.
|
||||||
|
*/
|
||||||
|
function removeEntryDir(dir) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(dir, { recursive: true });
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== 'EACCES' && err.code !== 'EPERM') throw err;
|
||||||
|
}
|
||||||
|
const container = resolveGravContainer();
|
||||||
|
if (!container) {
|
||||||
|
throw new Error(
|
||||||
|
`Cannot remove ${dir}: it is root-owned (written by Grav in the container) and no ` +
|
||||||
|
`matching container was found to delete it as root. Set GRAV_CONTAINER or remove it manually.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
execSync(`docker exec ${container} rm -rf '/var/www/html/user/${path.relative(USER_DIR, dir)}'`,
|
||||||
|
{ stdio: ['pipe', 'pipe', 'pipe'] });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find a tracker entry folder by a unique slug fragment, then delete it.
|
* Find a tracker entry folder by a unique slug fragment, then delete it.
|
||||||
*/
|
*/
|
||||||
@@ -179,7 +233,7 @@ function cleanupEntry(slugFragment) {
|
|||||||
const entries = fs.readdirSync(TRACKER_DIR);
|
const entries = fs.readdirSync(TRACKER_DIR);
|
||||||
const match = entries.find(e => e.includes(slugFragment));
|
const match = entries.find(e => e.includes(slugFragment));
|
||||||
if (match) {
|
if (match) {
|
||||||
fs.rmSync(path.join(TRACKER_DIR, match), { recursive: true });
|
removeEntryDir(path.join(TRACKER_DIR, match));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,4 +256,7 @@ function readEntryMd(entryDir) {
|
|||||||
return fs.readFileSync(path.join(entryDir, name), 'utf-8');
|
return fs.readFileSync(path.join(entryDir, name), 'utf-8');
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { fillEditor, waitForPhotoUpload, postEntry, createPhotoEntry, cleanupEntry, findEntry, readEntryMd, TEST_PHOTO, TRACKER_DIR, ACTIVE_TRIP_URL };
|
// USER_DIR is exported so global-setup/global-teardown resolve the same tree the
|
||||||
|
// specs assert against, instead of keeping their own (previously divergent) copy
|
||||||
|
// of this logic.
|
||||||
|
module.exports = { fillEditor, waitForPhotoUpload, postEntry, createPhotoEntry, cleanupEntry, removeEntryDir, findEntry, readEntryMd, TEST_PHOTO, USER_DIR, TRACKER_DIR, ACTIVE_TRIP_URL };
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// @ts-check
|
||||||
|
// Test: LD1 — the PhotoSwipe slide's declared dimensions must match what the
|
||||||
|
// browser actually renders for the linked image (BUG 2026-07-09: portrait
|
||||||
|
// iPhone JPEGs squeezed to landscape in the fullscreen lightbox).
|
||||||
|
//
|
||||||
|
// Root cause: entry-journal.html.twig fed `img.width`/`img.height` (raw
|
||||||
|
// getimagesize() of the ORIGINAL file — EXIF orientation ignored) into
|
||||||
|
// data-pswp-*, while the slide href pointed at that original, which browsers
|
||||||
|
// display EXIF-rotated. For a stored-landscape portrait photo the attrs said
|
||||||
|
// landscape while the pixels rendered portrait → PhotoSwipe squeezed them.
|
||||||
|
//
|
||||||
|
// Fixed in e17a5dc: slides now link a 2000px fit-within derivative and measure
|
||||||
|
// THAT file, and derivatives are re-encoded upright, so the attrs and the
|
||||||
|
// rendered pixels agree.
|
||||||
|
//
|
||||||
|
// The invariant tested here is environment-proof: whatever file the slide
|
||||||
|
// links to, its browser-rendered natural size must equal the data-pswp-*
|
||||||
|
// attrs. (Whether the photo ALSO displays upright depends on the server's
|
||||||
|
// php-exif extension feeding auto_fix_orientation — present on prod, absent
|
||||||
|
// in the local dev container — so upright-ness is deliberately not asserted.)
|
||||||
|
//
|
||||||
|
// The fixture entry is planted straight on disk in the DEMO trip (the active
|
||||||
|
// trip is whatever site.yaml says and may be an unpublished draft that 404s;
|
||||||
|
// this spec exercises template rendering, not the posting pipeline — that is
|
||||||
|
// upload-gate.spec.js's job). touch(system.yaml) bumps the config checksum so
|
||||||
|
// the page-tree index rebuilds — the same invalidation cache-on-save uses.
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
// USER_DIR comes from helpers so GRAV_USER_DIR is honoured — without it a run
|
||||||
|
// against a checkout detached from the served tree plants the fixture in a
|
||||||
|
// different user/ than Grav renders, and LD1 fails as an opaque "card never
|
||||||
|
// appeared" timeout.
|
||||||
|
const { USER_DIR } = require('../helpers');
|
||||||
|
|
||||||
|
// Stored 800x600 with EXIF Orientation=6: browsers render it 600x800 portrait.
|
||||||
|
const EXIF_PORTRAIT = path.join(__dirname, '../../fixtures/test-photo-exif-portrait.jpg');
|
||||||
|
const DEMO_DAILIES = path.join(USER_DIR, 'pages/01.trips/italy-2026-demo/01.dailies');
|
||||||
|
const DEMO_TRIP_URL = '/trips/italy-2026-demo';
|
||||||
|
|
||||||
|
const TAG = `ld1-fixture-${Date.now()}`;
|
||||||
|
const ENTRY_DIR = path.join(DEMO_DAILIES, `2026-09-30-1200-${TAG}.entry`);
|
||||||
|
|
||||||
|
function bumpPageTreeIndex() {
|
||||||
|
// mtime bump on system.yaml changes config->checksum(), which keys the
|
||||||
|
// pages index — next request rebuilds the tree from disk.
|
||||||
|
execSync(`touch "${path.join(USER_DIR, 'config/system.yaml')}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeAll(() => {
|
||||||
|
fs.mkdirSync(ENTRY_DIR, { recursive: true });
|
||||||
|
fs.copyFileSync(EXIF_PORTRAIT, path.join(ENTRY_DIR, 'photo-01.jpg'));
|
||||||
|
fs.writeFileSync(path.join(ENTRY_DIR, 'entry.md'), [
|
||||||
|
'---',
|
||||||
|
`title: 'UI Test ${TAG}'`,
|
||||||
|
"date: '2026-09-30 12:00'",
|
||||||
|
'template: entry',
|
||||||
|
'published: true',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
`Lightbox dims fixture ${TAG}. Safe to delete.`,
|
||||||
|
'',
|
||||||
|
].join('\n'));
|
||||||
|
bumpPageTreeIndex();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(() => {
|
||||||
|
fs.rmSync(ENTRY_DIR, { recursive: true, force: true });
|
||||||
|
bumpPageTreeIndex();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('LD1: lightbox slide dims match the rendered size of the linked image', async ({ page }) => {
|
||||||
|
const card = page.locator('.journal-post', { hasText: TAG });
|
||||||
|
const slide = card.locator('a.journal-photo-slide').first();
|
||||||
|
|
||||||
|
// The config-checksum bump has second-granularity mtimes; a goto in the
|
||||||
|
// same second can still be served the stale cached page. Reload until the
|
||||||
|
// planted card is in the rendered feed.
|
||||||
|
await expect(async () => {
|
||||||
|
await page.goto(DEMO_TRIP_URL);
|
||||||
|
await expect(slide).toBeAttached({ timeout: 1000 });
|
||||||
|
}).toPass({ timeout: 20_000 });
|
||||||
|
|
||||||
|
const attrW = Number(await slide.getAttribute('data-pswp-width'));
|
||||||
|
const attrH = Number(await slide.getAttribute('data-pswp-height'));
|
||||||
|
const href = await slide.getAttribute('href');
|
||||||
|
expect(attrW).toBeGreaterThan(0);
|
||||||
|
expect(attrH).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const natural = await page.evaluate((src) => new Promise((resolve, reject) => {
|
||||||
|
const i = new Image();
|
||||||
|
i.onload = () => resolve({ w: i.naturalWidth, h: i.naturalHeight });
|
||||||
|
i.onerror = () => reject(new Error('image failed to load: ' + src));
|
||||||
|
i.src = src;
|
||||||
|
}), href);
|
||||||
|
|
||||||
|
expect(natural.w, `data-pswp-width vs rendered width of ${href}`).toBe(attrW);
|
||||||
|
expect(natural.h, `data-pswp-height vs rendered height of ${href}`).toBe(attrH);
|
||||||
|
});
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
// @ts-check
|
||||||
|
// Tests: post form "More location details" — search-by-city lookup + draggable
|
||||||
|
// map pin preview for setting an entry's coordinates without live GPS.
|
||||||
|
// Covers R4-R14. The Open-Meteo geocoding endpoint is mocked via page.route()
|
||||||
|
// so this suite is hermetic (no live third-party call, no rate-limit flakiness).
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const path = require('path');
|
||||||
|
const { fillEditor, waitForPhotoUpload, cleanupEntry, findEntry, readEntryMd, TEST_PHOTO } = require('../helpers');
|
||||||
|
|
||||||
|
const GEOCODE_URL = '**/geocoding-api.open-meteo.com/v1/search**';
|
||||||
|
|
||||||
|
const created = [];
|
||||||
|
test.afterAll(() => { created.forEach(cleanupEntry); });
|
||||||
|
|
||||||
|
// Real-API-shaped fixtures (verified live against geocoding-api.open-meteo.com).
|
||||||
|
const KYOTO_RESULTS = {
|
||||||
|
results: [
|
||||||
|
{ name: 'Kyoto', latitude: 35.0116, longitude: 135.7681, admin1: 'Kyoto Prefecture', country: 'Japan' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mirrors the design doc's verified live Paris query: Île-de-France (France)
|
||||||
|
// first from the API, then five US states — Texas among them, in admin1 (the
|
||||||
|
// API's `country` field is "United States" for all of the US matches, so the
|
||||||
|
// ranking must also check admin1 to disambiguate on a US state name).
|
||||||
|
const PARIS_RESULTS = {
|
||||||
|
results: [
|
||||||
|
{ name: 'Paris', latitude: 48.85341, longitude: 2.3488, admin1: 'Île-de-France Region', country: 'France' },
|
||||||
|
{ name: 'Paris', latitude: 33.66094, longitude: -95.55551, admin1: 'Texas', country: 'United States' },
|
||||||
|
{ name: 'Paris', latitude: 36.302, longitude: -88.32671, admin1: 'Tennessee', country: 'United States' },
|
||||||
|
{ name: 'Paris', latitude: 38.2098, longitude: -84.2529, admin1: 'Kentucky', country: 'United States' },
|
||||||
|
{ name: 'Paris', latitude: 39.6112, longitude: -87.6961, admin1: 'Illinois', country: 'United States' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
function mockGeocode(page, body) {
|
||||||
|
return page.route(GEOCODE_URL, (route) => route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openLocationDetails(page) {
|
||||||
|
await page.locator('.location-details__summary').click();
|
||||||
|
await expect(page.locator('.location-details')).toHaveJSProperty('open', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Panel closed by default (R1) ────────────────────────────────────────────
|
||||||
|
test('More location details is closed by default and holds the relocated lat/lng fields', async ({ page }) => {
|
||||||
|
await page.goto('/post');
|
||||||
|
const details = page.locator('.location-details');
|
||||||
|
await expect(details).toBeAttached();
|
||||||
|
await expect(details).toHaveJSProperty('open', false);
|
||||||
|
await expect(page.locator('.location-details input[name="data[lat]"]')).toBeAttached();
|
||||||
|
await expect(page.locator('.location-details input[name="data[lng]"]')).toBeAttached();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── R6: empty City + Country sends no request ───────────────────────────────
|
||||||
|
test('R6: clicking lookup with City and Country both empty sends no request', async ({ page }) => {
|
||||||
|
await page.goto('/post');
|
||||||
|
let requested = false;
|
||||||
|
await page.route(GEOCODE_URL, (route) => { requested = true; route.abort(); });
|
||||||
|
await openLocationDetails(page);
|
||||||
|
await page.click('#lookup-coords');
|
||||||
|
await expect(page.locator('#location-search-hint')).toContainText(/city or country/i);
|
||||||
|
expect(requested).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── R7: a search result sets lat/lng only, never City/Country ──────────────
|
||||||
|
test('R7: clicking a search result sets lat/lng and leaves City/Country untouched', async ({ page }) => {
|
||||||
|
await page.goto('/post');
|
||||||
|
await mockGeocode(page, KYOTO_RESULTS);
|
||||||
|
await page.fill('input[name="data[location_city]"]', 'Kyoto');
|
||||||
|
await openLocationDetails(page);
|
||||||
|
await page.click('#lookup-coords');
|
||||||
|
|
||||||
|
const results = page.locator('.location-search-results li button');
|
||||||
|
await expect(results).toHaveCount(1);
|
||||||
|
await results.first().click();
|
||||||
|
|
||||||
|
await expect(page.locator('input[name="data[lat]"]')).toHaveValue('35.011600');
|
||||||
|
await expect(page.locator('input[name="data[lng]"]')).toHaveValue('135.768100');
|
||||||
|
await expect(page.locator('input[name="data[location_city]"]')).toHaveValue('Kyoto');
|
||||||
|
await expect(page.locator('input[name="data[location_country]"]')).toHaveValue('');
|
||||||
|
// R7: the list hides again until the next lookup.
|
||||||
|
await expect(page.locator('.location-search-results li')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── R4/KTD2: Paris/Texas disambiguation ranks the Texas match first ─────────
|
||||||
|
test('disambiguation: City "Paris" + Country "Texas" ranks the Texas match first', async ({ page }) => {
|
||||||
|
await page.goto('/post');
|
||||||
|
let requestedUrl = null;
|
||||||
|
await page.route(GEOCODE_URL, (route) => {
|
||||||
|
requestedUrl = route.request().url();
|
||||||
|
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(PARIS_RESULTS) });
|
||||||
|
});
|
||||||
|
await page.fill('input[name="data[location_city]"]', 'Paris');
|
||||||
|
await page.fill('input[name="data[location_country]"]', 'Texas');
|
||||||
|
await openLocationDetails(page);
|
||||||
|
await page.click('#lookup-coords');
|
||||||
|
|
||||||
|
const results = page.locator('.location-search-results li button');
|
||||||
|
await expect(results).toHaveCount(5);
|
||||||
|
await expect(results.first()).toContainText('Texas');
|
||||||
|
|
||||||
|
// R4: Country is never concatenated into the query string.
|
||||||
|
expect(requestedUrl).toContain('name=Paris');
|
||||||
|
expect(requestedUrl).not.toContain('Texas');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── R8: no matches shows the inline hint, fields untouched ─────────────────
|
||||||
|
test('R8: no matches shows the no-match hint and leaves fields untouched', async ({ page }) => {
|
||||||
|
await page.goto('/post');
|
||||||
|
await mockGeocode(page, { results: [] });
|
||||||
|
await page.fill('input[name="data[location_city]"]', 'Nowheresville');
|
||||||
|
await openLocationDetails(page);
|
||||||
|
await page.click('#lookup-coords');
|
||||||
|
|
||||||
|
await expect(page.locator('#location-search-hint')).toContainText(/no matches/i);
|
||||||
|
await expect(page.locator('input[name="data[lat]"]')).toHaveValue('');
|
||||||
|
await expect(page.locator('input[name="data[lng]"]')).toHaveValue('');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── R5: in-flight state shows "Searching…" and always re-enables ───────────
|
||||||
|
test('R5: the lookup button shows a disabled "Searching…" state while in flight', async ({ page }) => {
|
||||||
|
await page.goto('/post');
|
||||||
|
await page.route(GEOCODE_URL, async (route) => {
|
||||||
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
|
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(KYOTO_RESULTS) });
|
||||||
|
});
|
||||||
|
await page.fill('input[name="data[location_city]"]', 'Kyoto');
|
||||||
|
await openLocationDetails(page);
|
||||||
|
await page.click('#lookup-coords');
|
||||||
|
|
||||||
|
const btn = page.locator('#lookup-coords');
|
||||||
|
await expect(btn).toBeDisabled();
|
||||||
|
await expect(btn).toHaveText('Searching…');
|
||||||
|
await expect(btn).toBeEnabled({ timeout: 5_000 });
|
||||||
|
await expect(btn).toContainText('Look up coordinates');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── R8: a network failure degrades silently and re-enables the button ──────
|
||||||
|
test('a network failure degrades silently, leaves fields untouched, and re-enables the button', async ({ page }) => {
|
||||||
|
await page.goto('/post');
|
||||||
|
await page.route(GEOCODE_URL, (route) => route.abort('failed'));
|
||||||
|
await page.fill('input[name="data[location_city]"]', 'Kyoto');
|
||||||
|
await openLocationDetails(page);
|
||||||
|
await page.click('#lookup-coords');
|
||||||
|
|
||||||
|
await expect(page.locator('#lookup-coords')).toBeEnabled();
|
||||||
|
await expect(page.locator('input[name="data[lat]"]')).toHaveValue('');
|
||||||
|
await expect(page.locator('input[name="data[lng]"]')).toHaveValue('');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── XSS safety: an API-sourced name containing markup renders as literal text ──
|
||||||
|
test('a result name containing markup renders as literal text, not executed', async ({ page }) => {
|
||||||
|
await page.goto('/post');
|
||||||
|
await mockGeocode(page, {
|
||||||
|
results: [{ name: '<img src=x onerror="window.__xss=true">', latitude: 1, longitude: 2, country: 'Nowhere' }]
|
||||||
|
});
|
||||||
|
await page.fill('input[name="data[location_city]"]', 'Test');
|
||||||
|
await openLocationDetails(page);
|
||||||
|
await page.click('#lookup-coords');
|
||||||
|
|
||||||
|
const btn = page.locator('.location-search-results li button').first();
|
||||||
|
await expect(btn).toContainText('<img src=x onerror="window.__xss=true">');
|
||||||
|
expect(await btn.evaluate((el) => el.querySelector('img'))).toBeNull();
|
||||||
|
expect(await page.evaluate(() => window.__xss)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── U4: map renders exactly one canvas, no pin until a coordinate is set ───
|
||||||
|
test('opening the panel renders exactly one map canvas with no initial pin', async ({ page }) => {
|
||||||
|
await page.goto('/post');
|
||||||
|
await openLocationDetails(page);
|
||||||
|
await expect(page.locator('#location-map canvas.maplibregl-canvas')).toHaveCount(1, { timeout: 10_000 });
|
||||||
|
await expect(page.locator('#location-map .maplibregl-marker')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── U4: reopening does not duplicate the canvas; resize keeps it non-zero ──
|
||||||
|
test('reopening the panel a second time leaves exactly one canvas with non-zero size', async ({ page }) => {
|
||||||
|
await page.goto('/post');
|
||||||
|
await openLocationDetails(page);
|
||||||
|
await page.locator('.location-details__summary').click(); // close
|
||||||
|
await expect(page.locator('.location-details')).toHaveJSProperty('open', false);
|
||||||
|
await openLocationDetails(page); // reopen
|
||||||
|
|
||||||
|
const canvases = page.locator('#location-map canvas.maplibregl-canvas');
|
||||||
|
await expect(canvases).toHaveCount(1, { timeout: 10_000 });
|
||||||
|
const box = await canvases.first().boundingBox();
|
||||||
|
expect(box && box.width).toBeGreaterThan(0);
|
||||||
|
expect(box && box.height).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── R11: a search pick shows a pin on the map ───────────────────────────────
|
||||||
|
test('a search-result pick renders a pin on the map', async ({ page }) => {
|
||||||
|
await page.goto('/post');
|
||||||
|
await mockGeocode(page, KYOTO_RESULTS);
|
||||||
|
await page.fill('input[name="data[location_city]"]', 'Kyoto');
|
||||||
|
await openLocationDetails(page);
|
||||||
|
await page.click('#lookup-coords');
|
||||||
|
await page.locator('.location-search-results li button').first().click();
|
||||||
|
|
||||||
|
await expect(page.locator('#location-map .maplibregl-marker')).toHaveCount(1, { timeout: 10_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── R11: dragging the marker updates lat/lng (rounded to 6dp) ──────────────
|
||||||
|
test('dragging the pin updates lat/lng to the drop location', async ({ page }) => {
|
||||||
|
await page.goto('/post');
|
||||||
|
await mockGeocode(page, KYOTO_RESULTS);
|
||||||
|
await page.fill('input[name="data[location_city]"]', 'Kyoto');
|
||||||
|
await openLocationDetails(page);
|
||||||
|
await page.click('#lookup-coords');
|
||||||
|
await page.locator('.location-search-results li button').first().click();
|
||||||
|
|
||||||
|
const marker = page.locator('#location-map .maplibregl-marker');
|
||||||
|
await expect(marker).toHaveCount(1, { timeout: 10_000 });
|
||||||
|
const before = await page.locator('input[name="data[lat]"]').inputValue();
|
||||||
|
|
||||||
|
// setPin()'s map.panTo() animates the marker into view — wait for it to
|
||||||
|
// settle so the bounding box grabbed below matches where the marker will
|
||||||
|
// actually be when the mouse events land.
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
const box = await marker.boundingBox();
|
||||||
|
if (!box) throw new Error('marker has no bounding box');
|
||||||
|
const startX = box.x + box.width / 2;
|
||||||
|
const startY = box.y + box.height / 2;
|
||||||
|
await page.mouse.move(startX, startY);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(startX + 40, startY + 30, { steps: 5 });
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
await expect(async () => {
|
||||||
|
const after = await page.locator('input[name="data[lat]"]').inputValue();
|
||||||
|
expect(after).not.toBe(before);
|
||||||
|
expect(after).toMatch(/^-?\d+\.\d{6}$/);
|
||||||
|
}).toPass({ timeout: 5_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── R11/R13: typing an invalid value flags the field without crashing ──────
|
||||||
|
test('typing an invalid lat value shows the mismatch flag and clears once fixed', async ({ page }) => {
|
||||||
|
await page.goto('/post');
|
||||||
|
await openLocationDetails(page);
|
||||||
|
|
||||||
|
const latEl = page.locator('input[name="data[lat]"]');
|
||||||
|
const lngEl = page.locator('input[name="data[lng]"]');
|
||||||
|
await latEl.fill('not-a-number');
|
||||||
|
await lngEl.fill('135.7681');
|
||||||
|
await lngEl.blur();
|
||||||
|
|
||||||
|
await expect(latEl).toHaveClass(/location-field--mismatch/);
|
||||||
|
await expect(latEl).toHaveAttribute('aria-invalid', 'true');
|
||||||
|
await expect(page.locator('#location-map .maplibregl-marker')).toHaveCount(0);
|
||||||
|
|
||||||
|
await latEl.fill('35.0116');
|
||||||
|
await latEl.blur();
|
||||||
|
await expect(latEl).not.toHaveClass(/location-field--mismatch/);
|
||||||
|
await expect(page.locator('#location-map .maplibregl-marker')).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── U4: rapid close/reopen while the maplibre-gl chunk is still in flight must
|
||||||
|
// not build two Map instances against the same container (code-review fix) ──
|
||||||
|
test('rapid close/reopen before the maplibre-gl chunk resolves still leaves exactly one canvas', async ({ page }) => {
|
||||||
|
await page.route('**/*maplibre-gl*.js', async (route) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
await page.goto('/post');
|
||||||
|
|
||||||
|
// Open, then immediately close and reopen — both toggles land while the
|
||||||
|
// delayed chunk request above is still pending.
|
||||||
|
await page.locator('.location-details__summary').click();
|
||||||
|
await page.locator('.location-details__summary').click();
|
||||||
|
await page.locator('.location-details__summary').click();
|
||||||
|
await expect(page.locator('.location-details')).toHaveJSProperty('open', true);
|
||||||
|
|
||||||
|
await expect(page.locator('#location-map canvas.maplibregl-canvas')).toHaveCount(1, { timeout: 10_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── U5: blanking both fields after a mismatch was flagged clears the flag ──
|
||||||
|
test('blanking both lat/lng fields after a mismatch clears the flag', async ({ page }) => {
|
||||||
|
await page.goto('/post');
|
||||||
|
await openLocationDetails(page);
|
||||||
|
|
||||||
|
const latEl = page.locator('input[name="data[lat]"]');
|
||||||
|
const lngEl = page.locator('input[name="data[lng]"]');
|
||||||
|
await latEl.fill('not-a-number');
|
||||||
|
await lngEl.blur();
|
||||||
|
await expect(latEl).toHaveClass(/location-field--mismatch/);
|
||||||
|
|
||||||
|
await latEl.fill('');
|
||||||
|
await lngEl.fill('');
|
||||||
|
await lngEl.blur();
|
||||||
|
await expect(latEl).not.toHaveClass(/location-field--mismatch/);
|
||||||
|
await expect(lngEl).not.toHaveClass(/location-field--mismatch/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── U5: a flagged, unresolved lat/lng must block submit (code-review fix) ──
|
||||||
|
test('submitting with an unresolved lat/lng mismatch is blocked', async ({ page }) => {
|
||||||
|
const tag = `loc-mismatch-${Date.now()}`;
|
||||||
|
await page.goto('/post');
|
||||||
|
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
|
||||||
|
await fillEditor(page, 'Location-override mismatch-blocks-submit guard. Safe to delete.');
|
||||||
|
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
|
||||||
|
await waitForPhotoUpload(page);
|
||||||
|
|
||||||
|
await openLocationDetails(page);
|
||||||
|
const latEl = page.locator('input[name="data[lat]"]');
|
||||||
|
const lngEl = page.locator('input[name="data[lng]"]');
|
||||||
|
await latEl.fill('999');
|
||||||
|
await lngEl.fill('999');
|
||||||
|
await lngEl.blur();
|
||||||
|
await expect(latEl).toHaveClass(/location-field--mismatch/);
|
||||||
|
|
||||||
|
// Register for cleanup BEFORE the click: if the gate ever regresses, the
|
||||||
|
// entry lands on disk and the afterAll hook must still see the tag.
|
||||||
|
created.push(tag);
|
||||||
|
await page.locator('.btn-post').evaluate((el) => el.click());
|
||||||
|
|
||||||
|
// `.notices` toHaveCount(0) and toHaveURL(/\/post/) both pass instantly and
|
||||||
|
// both also hold for a SUCCESSFUL submit (the form posts to /post and only
|
||||||
|
// renders its notice after the round trip), so neither can distinguish a
|
||||||
|
// working gate from a regressed one. Prove the negative on disk instead,
|
||||||
|
// after giving a regressed submit time to actually write.
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
expect(findEntry(tag), 'a flagged coordinate must never reach the server').toBeFalsy();
|
||||||
|
// And prove the block was the gate's doing: still flagged, value untouched.
|
||||||
|
await expect(latEl).toHaveClass(/location-field--mismatch/);
|
||||||
|
await expect(latEl).toHaveValue('999');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── U4: lazy-load boundary — an ordinary GPS-only submit never fetches maplibre-gl ──
|
||||||
|
// The URL pattern deliberately covers BOTH halves of the lazy boundary: the JS
|
||||||
|
// chunk (js/post/maplibre-gl-*.js) and the stylesheet
|
||||||
|
// (css-compiled/maplibre-gl.css, <link>ed by location-map.js at panel-open —
|
||||||
|
// see its ensureMaplibreCss). Neither may be requested when the panel stays shut.
|
||||||
|
test('an ordinary submit without opening the panel never fetches the maplibre-gl chunk', async ({ page }) => {
|
||||||
|
const chunkRequests = [];
|
||||||
|
page.on('request', (req) => {
|
||||||
|
if (/maplibre-gl/.test(req.url())) chunkRequests.push(req.url());
|
||||||
|
});
|
||||||
|
|
||||||
|
const tag = `loc-nomap-${Date.now()}`;
|
||||||
|
await page.goto('/post');
|
||||||
|
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
|
||||||
|
await fillEditor(page, 'Location-override lazy-load guard. Safe to delete.');
|
||||||
|
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
|
||||||
|
await waitForPhotoUpload(page);
|
||||||
|
await page.locator('.btn-post').evaluate((el) => el.click());
|
||||||
|
await expect(page.locator('.notices')).toContainText('Entry posted successfully!', { timeout: 15_000 });
|
||||||
|
created.push(tag);
|
||||||
|
|
||||||
|
expect(chunkRequests, 'neither the maplibre-gl chunk nor its stylesheet may be fetched when the panel is never opened').toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── The other half of that boundary: opening the panel DOES apply the vendor CSS ──
|
||||||
|
// Without this, the guard above could keep passing while the stylesheet silently
|
||||||
|
// stopped loading at all (a broken href, a missed build step), leaving the map
|
||||||
|
// unstyled with nothing to catch it. Asserts the <link> exists AND parsed —
|
||||||
|
// link.sheet is null until the browser has actually applied it.
|
||||||
|
test('opening the panel lazily links maplibre\'s stylesheet and applies it', async ({ page }) => {
|
||||||
|
await page.goto('/post');
|
||||||
|
|
||||||
|
const hrefBefore = await page.evaluate(() => Array.from(document.styleSheets)
|
||||||
|
.map((s) => s.href || '').filter((h) => /maplibre-gl\.css/.test(h)));
|
||||||
|
expect(hrefBefore, 'the vendor stylesheet must not be present before the panel opens').toHaveLength(0);
|
||||||
|
|
||||||
|
await openLocationDetails(page);
|
||||||
|
await expect(page.locator('#location-map canvas.maplibregl-canvas')).toHaveCount(1, { timeout: 10_000 });
|
||||||
|
|
||||||
|
await expect.poll(
|
||||||
|
() => page.evaluate(() => {
|
||||||
|
const link = Array.from(document.querySelectorAll('link[rel="stylesheet"]'))
|
||||||
|
.find((l) => /maplibre-gl\.css/.test(l.href));
|
||||||
|
return link ? link.sheet !== null : false;
|
||||||
|
}),
|
||||||
|
{ message: 'maplibre\'s stylesheet must be linked and applied once the panel opens', timeout: 10_000 }
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Full submit: a search-picked location round-trips into the frontmatter ──
|
||||||
|
test('a full submit with a search-picked location saves the expected lat/lng', async ({ page }) => {
|
||||||
|
const tag = `loc-submit-${Date.now()}`;
|
||||||
|
await page.goto('/post');
|
||||||
|
await mockGeocode(page, KYOTO_RESULTS);
|
||||||
|
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
|
||||||
|
await fillEditor(page, 'Location-override submit test. Safe to delete.');
|
||||||
|
await page.fill('input[name="data[location_city]"]', 'Kyoto');
|
||||||
|
await openLocationDetails(page);
|
||||||
|
await page.click('#lookup-coords');
|
||||||
|
await page.locator('.location-search-results li button').first().click();
|
||||||
|
|
||||||
|
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
|
||||||
|
await waitForPhotoUpload(page);
|
||||||
|
await page.locator('.btn-post').evaluate((el) => el.click());
|
||||||
|
await expect(page.locator('.notices')).toContainText('Entry posted successfully!', { timeout: 15_000 });
|
||||||
|
created.push(tag);
|
||||||
|
|
||||||
|
const entryDir = findEntry(tag);
|
||||||
|
expect(entryDir, 'Entry folder should exist on disk').toBeTruthy();
|
||||||
|
const md = readEntryMd(entryDir);
|
||||||
|
expect(md).toContain('35.0116');
|
||||||
|
expect(md).toContain('135.7681');
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// @ts-check
|
||||||
|
// Tests: UG1–UG2 — the create form must never submit while a photo is not
|
||||||
|
// fully uploaded (BUG 2026-07-09: a fast save after adding a picture posted a
|
||||||
|
// text-only entry; the photo was silently dropped).
|
||||||
|
//
|
||||||
|
// The form plugin's own submit guard (filepond-handler.js) only blocks the
|
||||||
|
// PROCESSING / PROCESSING_QUEUED states. Two states slip through it:
|
||||||
|
// - UG1: LOADING — the moment between picking a file and it entering the
|
||||||
|
// upload queue (the "too quick" click). Guarded here with a slowed upload.
|
||||||
|
// - UG2: PROCESSING_ERROR — a failed upload keeps its thumbnail, passes the
|
||||||
|
// ≥1-photo validation, and the form posts without the file. This is the
|
||||||
|
// silent-data-loss path.
|
||||||
|
// post-form.js owns the complete gate (theme code; the form plugin is
|
||||||
|
// GPM-managed and not patchable in-repo).
|
||||||
|
//
|
||||||
|
// The gate lives in e17a5dc: submit is blocked unless EVERY FilePond item is
|
||||||
|
// processing-complete, with distinct messages for the failed and still-uploading
|
||||||
|
// cases. Both assert on .photo-convert-status, which post-form.js's setStatus()
|
||||||
|
// creates via photoStatusEl() — so a passing expectation here proves the THEME
|
||||||
|
// gate fired, not the form plugin's, whose own guard only raises alert().
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const { fillEditor, findEntry, cleanupEntry, TEST_PHOTO } = require('../helpers');
|
||||||
|
|
||||||
|
// FilePond uploads go to the form route with .json + the file-upload task
|
||||||
|
// (Form.php:1183: withExtension('json')->withGravParam('task','file-upload')),
|
||||||
|
// i.e. /post.json/task:file-upload — the task is a PATH segment, so a glob
|
||||||
|
// with a non-slash-crossing `*` misses it; match by regex instead.
|
||||||
|
const UPLOAD_URL = /\/post\.json\//;
|
||||||
|
|
||||||
|
const created = [];
|
||||||
|
test.afterAll(() => created.forEach(cleanupEntry));
|
||||||
|
|
||||||
|
async function fillCreateForm(page, tag) {
|
||||||
|
await page.goto('/post');
|
||||||
|
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
|
||||||
|
await fillEditor(page, `Upload-gate fixture ${tag}. Safe to delete.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UG1: submit while the upload is still in flight is blocked ────────────────
|
||||||
|
test('UG1: submitting while a photo upload is in flight is blocked with a message', async ({ page }) => {
|
||||||
|
const tag = `ug1-${Date.now()}`;
|
||||||
|
|
||||||
|
// Slow the upload down so the submit click lands mid-flight.
|
||||||
|
await page.route(UPLOAD_URL, async (route) => {
|
||||||
|
await new Promise((r) => setTimeout(r, 6000));
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
await fillCreateForm(page, tag);
|
||||||
|
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
|
||||||
|
// The item exists but cannot have finished uploading (route is held).
|
||||||
|
await page.waitForSelector('.filepond--item');
|
||||||
|
await page.locator('.btn-post').evaluate((el) => el.click());
|
||||||
|
created.push(tag);
|
||||||
|
|
||||||
|
// Blocked: visible feedback, no success notice, nothing written to disk.
|
||||||
|
await expect(page.locator('.photo-convert-status')).toContainText(/uploading/i);
|
||||||
|
await expect(page.locator('.notices.success')).toHaveCount(0);
|
||||||
|
expect(findEntry(tag), 'no entry may be created mid-upload').toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── UG2: submit with a FAILED upload is blocked, not silently posted ──────────
|
||||||
|
test('UG2: submitting after a photo upload failed is blocked with an error', async ({ page }) => {
|
||||||
|
const tag = `ug2-${Date.now()}`;
|
||||||
|
|
||||||
|
// Make the upload fail server-side (transient network/limit failure).
|
||||||
|
await page.route(UPLOAD_URL, (route) => route.fulfill({ status: 500, body: 'nope' }));
|
||||||
|
|
||||||
|
await fillCreateForm(page, tag);
|
||||||
|
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
|
||||||
|
// Wait for FilePond to mark the item as failed.
|
||||||
|
await page.waitForSelector('.filepond--item[data-filepond-item-state*="error"]', { timeout: 20_000 });
|
||||||
|
await page.locator('.btn-post').evaluate((el) => el.click());
|
||||||
|
created.push(tag);
|
||||||
|
|
||||||
|
// Blocked: the error is surfaced, the form did not post, no disk write.
|
||||||
|
await expect(page.locator('.photo-convert-status')).toContainText(/failed/i);
|
||||||
|
await expect(page.locator('.notices.success')).toHaveCount(0);
|
||||||
|
expect(findEntry(tag), 'a failed upload must never produce a photo-less entry').toBeNull();
|
||||||
|
});
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
// @ts-check
|
||||||
|
// Tests: TP1, TP1b, TP2–TP8 — the owner trip publish/unpublish toggle on the
|
||||||
|
// /trips listing (U7). Covers the owner gate, coverless drafts, cache-correct
|
||||||
|
// hide/restore, the active-trip confirm, backend authz, the home fallback, the
|
||||||
|
// client failure/toast path (R15), and the in-flight double-submit lock (R13).
|
||||||
|
//
|
||||||
|
// Owner identity (doc-review P1): the harness authenticates as GRAV_TEST_USER,
|
||||||
|
// but committed site.yaml sets owner_username: mischa, and EntryScopeGuard is a
|
||||||
|
// strict username match. So this suite PINS site.owner_username to the
|
||||||
|
// authenticated test user (restore on teardown) rather than assuming the
|
||||||
|
// committed value. TP5's 403 leg derives a non-owner by briefly overriding
|
||||||
|
// owner_username to a value the test user does not match.
|
||||||
|
//
|
||||||
|
// Config is read fresh per request (twig.cache:false), but a NEW page folder is
|
||||||
|
// only picked up after a page-tree cache clear (the folder-hash staleness class
|
||||||
|
// of bug fixed in deleteEntry) — so createFixtureTrip / config writes clear the
|
||||||
|
// cache of the container serving THIS worktree's user dir.
|
||||||
|
//
|
||||||
|
// RUN THIS SUITE SERIALLY (`--workers=1` for tests/ui/trip, or run the file on
|
||||||
|
// its own). It mutates GLOBAL state — site.owner_username / active_trip and the
|
||||||
|
// shared page-tree cache (the publish endpoint flushes APCu site-wide) — so a
|
||||||
|
// spec reading the active trip or a trip page in a PARALLEL worker can transiently
|
||||||
|
// observe the mutated config or a mid-rebuild page. On its own, or serially, it
|
||||||
|
// is deterministic. This mirrors how home-highlights.spec.js mutates `travelling`
|
||||||
|
// and coexists only because the home/maps specs skip when it does.
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
const BASE = process.env.GRAV_BASE_URL || 'http://localhost:8081';
|
||||||
|
const OWNER = process.env.GRAV_TEST_USER || 'testrunner';
|
||||||
|
|
||||||
|
// ── user dir + container (worktree-safe; two grav containers can run at once) ──
|
||||||
|
const USER_DIR = process.env.GRAV_USER_DIR
|
||||||
|
? path.resolve(process.env.GRAV_USER_DIR)
|
||||||
|
: path.resolve(__dirname, '../../../user');
|
||||||
|
const SITE_YAML = path.join(USER_DIR, 'config/site.yaml');
|
||||||
|
const TRIPS_DIR = path.join(USER_DIR, 'pages/01.trips');
|
||||||
|
|
||||||
|
function resolveContainer() {
|
||||||
|
if (process.env.GRAV_CONTAINER) return process.env.GRAV_CONTAINER;
|
||||||
|
const want = fs.realpathSync(USER_DIR);
|
||||||
|
const names = execSync("docker ps --format '{{.Names}}'", { encoding: 'utf-8' })
|
||||||
|
.trim().split(/\r?\n/).filter(Boolean);
|
||||||
|
for (const c of names) {
|
||||||
|
try {
|
||||||
|
const src = execSync(
|
||||||
|
`docker inspect ${c} --format '{{range .Mounts}}{{if eq .Destination "/var/www/html/user"}}{{.Source}}{{end}}{{end}}'`,
|
||||||
|
{ encoding: 'utf-8' }
|
||||||
|
).trim();
|
||||||
|
if (src && fs.realpathSync(src) === want) return c;
|
||||||
|
} catch (_) { /* container went away mid-scan */ }
|
||||||
|
}
|
||||||
|
return 'intotheeast_grav';
|
||||||
|
}
|
||||||
|
const CONTAINER = resolveContainer();
|
||||||
|
function clearCache() {
|
||||||
|
execSync(`docker exec ${CONTAINER} sh -c 'cd /var/www/html && php bin/grav clearcache'`, { stdio: 'ignore' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── site.yaml patch/restore ───────────────────────────────────────────────────
|
||||||
|
let originalSite = null; // committed/working-tree state, restored on teardown
|
||||||
|
let basePatched = null; // originalSite + owner_username pinned to OWNER
|
||||||
|
|
||||||
|
function setKey(content, key, val) {
|
||||||
|
const re = new RegExp(`^${key}:.*$`, 'm');
|
||||||
|
const line = `${key}: ${val}`;
|
||||||
|
return re.test(content) ? content.replace(re, line) : `${content.replace(/\n*$/, '')}\n${line}\n`;
|
||||||
|
}
|
||||||
|
function writeSite(content) {
|
||||||
|
fs.writeFileSync(SITE_YAML, content);
|
||||||
|
clearCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── fixture trips ─────────────────────────────────────────────────────────────
|
||||||
|
const fixtures = [];
|
||||||
|
function createFixtureTrip(slug, { published = true } = {}) {
|
||||||
|
const dir = path.join(TRIPS_DIR, slug);
|
||||||
|
fs.mkdirSync(path.join(dir, '01.dailies'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(dir, '04.stories'), { recursive: true });
|
||||||
|
// Coverless by design (no cover_image, no entries) — the most common publish
|
||||||
|
// target and the state TP1b guards.
|
||||||
|
fs.writeFileSync(path.join(dir, 'trip.md'),
|
||||||
|
`---\ntitle: '${slug} fixture'\ntemplate: trip\ndate: '2020-01-01'\ncover_image: ''\npublished: ${published}\n---\n`);
|
||||||
|
fs.writeFileSync(path.join(dir, '01.dailies/dailies.md'),
|
||||||
|
'---\ntitle: Journal\ntemplate: default\nroutable: false\nvisible: false\n---\n');
|
||||||
|
fs.writeFileSync(path.join(dir, '04.stories/stories.md'),
|
||||||
|
'---\ntitle: Stories\ntemplate: default\nroutable: false\nvisible: false\n---\n');
|
||||||
|
if (!fixtures.includes(slug)) fixtures.push(slug);
|
||||||
|
clearCache();
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
function readTripPublished(slug) {
|
||||||
|
const p = path.join(TRIPS_DIR, slug, 'trip.md');
|
||||||
|
if (!fs.existsSync(p)) return null;
|
||||||
|
const m = fs.readFileSync(p, 'utf-8').match(/^published:\s*(\S+)/m);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
function cleanupFixtures() {
|
||||||
|
let removed = false;
|
||||||
|
for (const slug of fixtures) {
|
||||||
|
const dir = path.join(TRIPS_DIR, slug);
|
||||||
|
if (fs.existsSync(dir)) { fs.rmSync(dir, { recursive: true, force: true }); removed = true; }
|
||||||
|
}
|
||||||
|
if (removed) clearCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locators
|
||||||
|
const cardWrap = (page, slug) => page.locator(`.trip-card-wrap:has(a.trip-card[href="/trips/${slug}"])`);
|
||||||
|
const toggleFor = (page, slug) => cardWrap(page, slug).locator('.trip-publish-toggle');
|
||||||
|
|
||||||
|
// This file mutates shared global config; keep its own tests ordered and reset
|
||||||
|
// config after each so a per-test override never leaks into the next.
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
test.beforeAll(() => {
|
||||||
|
originalSite = fs.readFileSync(SITE_YAML, 'utf-8');
|
||||||
|
basePatched = setKey(originalSite, 'owner_username', OWNER);
|
||||||
|
writeSite(basePatched);
|
||||||
|
});
|
||||||
|
test.afterEach(() => { writeSite(basePatched); });
|
||||||
|
test.afterAll(() => {
|
||||||
|
if (originalSite != null) writeSite(originalSite);
|
||||||
|
cleanupFixtures();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── TP1: owner gate ───────────────────────────────────────────────────────────
|
||||||
|
test('TP1: owner sees the toggle + drafts; anon sees neither', async ({ page, browser }) => {
|
||||||
|
const pub = `tp1pub-${Date.now()}`;
|
||||||
|
const draft = `tp1draft-${Date.now()}`;
|
||||||
|
createFixtureTrip(pub, { published: true });
|
||||||
|
createFixtureTrip(draft, { published: false });
|
||||||
|
|
||||||
|
// Owner: toggle present, draft trip visible + badged.
|
||||||
|
await page.goto('/trips');
|
||||||
|
await expect(toggleFor(page, pub)).toHaveCount(1);
|
||||||
|
await expect(toggleFor(page, pub)).toHaveAttribute('aria-checked', 'true');
|
||||||
|
await expect(cardWrap(page, draft)).toHaveCount(1);
|
||||||
|
await expect(cardWrap(page, draft).locator('.trip-draft-badge')).toBeVisible();
|
||||||
|
// The switch is an accessible switch identifying the trip.
|
||||||
|
await expect(toggleFor(page, draft)).toHaveAttribute('role', 'switch');
|
||||||
|
await expect(toggleFor(page, draft)).toHaveAttribute('aria-label', /fixture/);
|
||||||
|
|
||||||
|
// Anon: no toggle anywhere, draft absent, published still visible.
|
||||||
|
const anon = await browser.newContext({ storageState: { cookies: [], origins: [] }, baseURL: BASE });
|
||||||
|
const ap = await anon.newPage();
|
||||||
|
await ap.goto('/trips');
|
||||||
|
await expect(ap.locator('.trip-publish-toggle')).toHaveCount(0);
|
||||||
|
await expect(ap.locator(`a.trip-card[href="/trips/${draft}"]`)).toHaveCount(0);
|
||||||
|
await expect(ap.locator(`a.trip-card[href="/trips/${pub}"]`)).toHaveCount(1);
|
||||||
|
await anon.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── TP1b: a coverless draft still renders a working toggle ─────────────────────
|
||||||
|
test('TP1b: a coverless draft still renders a working toggle', async ({ page }) => {
|
||||||
|
const slug = `tp1b-${Date.now()}`;
|
||||||
|
createFixtureTrip(slug, { published: false }); // no cover, no entries
|
||||||
|
|
||||||
|
await page.goto('/trips');
|
||||||
|
// No cover image emitted…
|
||||||
|
await expect(cardWrap(page, slug).locator('.trip-card-cover')).toHaveCount(0);
|
||||||
|
// …but the toggle still has an anchor and is usable.
|
||||||
|
const toggle = toggleFor(page, slug);
|
||||||
|
await expect(toggle).toBeVisible();
|
||||||
|
await expect(toggle).toHaveAttribute('aria-checked', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── TP2: unpublish hides the trip for anon after a fresh load (cache-correct) ──
|
||||||
|
test('TP2: unpublishing hides the trip for anon after reload', async ({ page, browser }) => {
|
||||||
|
const slug = `tp2-${Date.now()}`;
|
||||||
|
createFixtureTrip(slug, { published: true });
|
||||||
|
|
||||||
|
await page.goto('/trips');
|
||||||
|
const toggle = toggleFor(page, slug);
|
||||||
|
await expect(toggle).toHaveAttribute('aria-checked', 'true');
|
||||||
|
await toggle.click();
|
||||||
|
// Optimistic in-place flip + Draft badge, no reload.
|
||||||
|
await expect(toggle).toHaveAttribute('aria-checked', 'false');
|
||||||
|
await expect(cardWrap(page, slug).locator('.trip-draft-badge')).toBeVisible();
|
||||||
|
// Persisted to disk.
|
||||||
|
await expect.poll(() => readTripPublished(slug), { timeout: 15_000 }).toBe('false');
|
||||||
|
|
||||||
|
// Anon fresh load: absent (the endpoint invalidated the page-tree index).
|
||||||
|
const anon = await browser.newContext({ storageState: { cookies: [], origins: [] }, baseURL: BASE });
|
||||||
|
const ap = await anon.newPage();
|
||||||
|
await ap.goto('/trips');
|
||||||
|
await expect(ap.locator(`a.trip-card[href="/trips/${slug}"]`)).toHaveCount(0);
|
||||||
|
await anon.close();
|
||||||
|
|
||||||
|
// Owner fresh load: still visible, badged as Draft.
|
||||||
|
await page.goto('/trips');
|
||||||
|
await expect(cardWrap(page, slug).locator('.trip-draft-badge')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── TP3: republish restores the trip for anon ─────────────────────────────────
|
||||||
|
test('TP3: republishing a draft restores it for anon', async ({ page, browser }) => {
|
||||||
|
const slug = `tp3-${Date.now()}`;
|
||||||
|
createFixtureTrip(slug, { published: false });
|
||||||
|
|
||||||
|
await page.goto('/trips');
|
||||||
|
const toggle = toggleFor(page, slug);
|
||||||
|
await expect(toggle).toHaveAttribute('aria-checked', 'false');
|
||||||
|
await toggle.click();
|
||||||
|
await expect(toggle).toHaveAttribute('aria-checked', 'true');
|
||||||
|
await expect.poll(() => readTripPublished(slug), { timeout: 15_000 }).toBe('true');
|
||||||
|
|
||||||
|
const anon = await browser.newContext({ storageState: { cookies: [], origins: [] }, baseURL: BASE });
|
||||||
|
const ap = await anon.newPage();
|
||||||
|
await ap.goto('/trips');
|
||||||
|
await expect(ap.locator(`a.trip-card[href="/trips/${slug}"]`)).toHaveCount(1);
|
||||||
|
await anon.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── TP4: dismissing the active-trip confirm leaves it published ───────────────
|
||||||
|
test('TP4: dismissing the active-trip confirm leaves it published', async ({ page }) => {
|
||||||
|
const slug = `tp4-${Date.now()}`;
|
||||||
|
createFixtureTrip(slug, { published: true });
|
||||||
|
writeSite(setKey(basePatched, 'active_trip', `/trips/${slug}`));
|
||||||
|
|
||||||
|
await page.goto('/trips');
|
||||||
|
const toggle = toggleFor(page, slug);
|
||||||
|
await expect(toggle).toHaveAttribute('data-active', 'true');
|
||||||
|
|
||||||
|
// Dismiss the confirm → no request, stays published.
|
||||||
|
page.once('dialog', (d) => d.dismiss());
|
||||||
|
await toggle.click();
|
||||||
|
await expect(toggle).toHaveAttribute('aria-checked', 'true');
|
||||||
|
expect(readTripPublished(slug)).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── TP5: backend authz + non-boolean rejection ────────────────────────────────
|
||||||
|
test('TP5: publish endpoint enforces 401/403 and rejects a non-boolean body', async ({ page, browser }) => {
|
||||||
|
const slug = `tp5-${Date.now()}`;
|
||||||
|
createFixtureTrip(slug, { published: true });
|
||||||
|
const url = `/api/v1/trip/${slug}/publish`;
|
||||||
|
|
||||||
|
// Anonymous → 401, frontmatter unchanged.
|
||||||
|
const anon = await browser.newContext({ storageState: { cookies: [], origins: [] }, baseURL: BASE });
|
||||||
|
let r = await anon.request.post(url, { data: { published: false } });
|
||||||
|
expect(r.status()).toBe(401);
|
||||||
|
await anon.close();
|
||||||
|
expect(readTripPublished(slug)).toBe('true');
|
||||||
|
|
||||||
|
// Authenticated NON-owner → 403 (briefly make the logged-in user not the owner).
|
||||||
|
writeSite(setKey(basePatched, 'owner_username', `not-${OWNER}-xyz`));
|
||||||
|
r = await page.request.post(url, { data: { published: false } });
|
||||||
|
expect(r.status()).toBe(403);
|
||||||
|
expect(readTripPublished(slug)).toBe('true');
|
||||||
|
writeSite(basePatched); // back to owner for the 400 check
|
||||||
|
|
||||||
|
// Owner, non-boolean published → 400, frontmatter unchanged.
|
||||||
|
r = await page.request.post(url, { data: { published: 'false' } });
|
||||||
|
expect(r.status()).toBe(400);
|
||||||
|
expect(readTripPublished(slug)).toBe('true');
|
||||||
|
|
||||||
|
// Owner, MISSING published key → 400 (the array_key_exists branch, distinct
|
||||||
|
// from the is_bool branch above), frontmatter unchanged.
|
||||||
|
r = await page.request.post(url, { data: {} });
|
||||||
|
expect(r.status()).toBe(400);
|
||||||
|
expect(readTripPublished(slug)).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── TP6: an unpublished active trip makes home fall back ───────────────────────
|
||||||
|
test('TP6: an unpublished active trip falls back to between-trips on home', async ({ page }) => {
|
||||||
|
const slug = `tp6-${Date.now()}`;
|
||||||
|
createFixtureTrip(slug, { published: true });
|
||||||
|
writeSite(setKey(setKey(basePatched, 'active_trip', `/trips/${slug}`), 'travelling', 'true'));
|
||||||
|
|
||||||
|
// Published active trip → active-trip mode. The fixture has no entries, so
|
||||||
|
// active mode renders the pre-departure partial (a between-trips-only
|
||||||
|
// .home-highlights-header is absent; the predeparture divider is present).
|
||||||
|
// Both branches carry a .home-highlights-cta, so it is not a discriminator.
|
||||||
|
// Reload-poll so a config/cache settle after the fixture write can't flake it.
|
||||||
|
await expect(async () => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('.home-predeparture-divider')).toBeVisible({ timeout: 2_000 });
|
||||||
|
await expect(page.locator('.home-highlights-header')).toHaveCount(0);
|
||||||
|
}).toPass({ timeout: 15_000 });
|
||||||
|
|
||||||
|
// Unpublish it via the owner endpoint (clears cache).
|
||||||
|
const r = await page.request.post(`/api/v1/trip/${slug}/publish`, { data: { published: false } });
|
||||||
|
expect(r.status()).toBe(204);
|
||||||
|
|
||||||
|
// Home now falls through to the between-trips highlights state.
|
||||||
|
await expect(async () => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('.home-highlights-header')).toBeVisible({ timeout: 2_000 });
|
||||||
|
await expect(page.locator('.home-predeparture-divider')).toHaveCount(0);
|
||||||
|
}).toPass({ timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── TP7: a failed publish reverts the switch and surfaces a visible toast ──────
|
||||||
|
test('TP7: a failed publish reverts the switch and shows a toast (R15)', async ({ page }) => {
|
||||||
|
const slug = `tp7-${Date.now()}`;
|
||||||
|
createFixtureTrip(slug, { published: true });
|
||||||
|
|
||||||
|
await page.goto('/trips');
|
||||||
|
const toggle = toggleFor(page, slug);
|
||||||
|
await expect(toggle).toHaveAttribute('aria-checked', 'true');
|
||||||
|
|
||||||
|
// Force the mutation to fail server-side; the request is intercepted so it
|
||||||
|
// never reaches the endpoint (a generic 5xx → generic "couldn't update" copy).
|
||||||
|
await page.route('**/api/v1/trip/*/publish', (route) =>
|
||||||
|
route.fulfill({ status: 500, contentType: 'application/json', body: '{}' }));
|
||||||
|
|
||||||
|
await toggle.click();
|
||||||
|
|
||||||
|
// The switch never flipped (the optimistic flip only happens on success), so
|
||||||
|
// "revert" is just re-enabling it; the visible page-level toast appears.
|
||||||
|
await expect(page.locator('#trip-publish-live')).toBeVisible();
|
||||||
|
await expect(page.locator('#trip-publish-live')).toContainText("Couldn't update");
|
||||||
|
await expect(toggle).toHaveAttribute('aria-checked', 'true');
|
||||||
|
await expect(toggle).toBeEnabled();
|
||||||
|
// Never persisted (the request was intercepted before the server).
|
||||||
|
expect(readTripPublished(slug)).toBe('true');
|
||||||
|
|
||||||
|
await page.unroute('**/api/v1/trip/*/publish');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── TP8: the in-flight lock suppresses a concurrent second submit ──────────────
|
||||||
|
test('TP8: the pending lock suppresses a concurrent second submit (R13)', async ({ page }) => {
|
||||||
|
const slug = `tp8-${Date.now()}`;
|
||||||
|
createFixtureTrip(slug, { published: true });
|
||||||
|
|
||||||
|
await page.goto('/trips');
|
||||||
|
const toggle = toggleFor(page, slug);
|
||||||
|
await expect(toggle).toHaveAttribute('aria-checked', 'true');
|
||||||
|
|
||||||
|
// Count and DELAY the mutation so the switch stays in-flight while we click
|
||||||
|
// again. Fulfilled locally (204), so the server/disk is never touched.
|
||||||
|
let posts = 0;
|
||||||
|
await page.route('**/api/v1/trip/*/publish', async (route) => {
|
||||||
|
posts += 1;
|
||||||
|
await new Promise((r) => setTimeout(r, 1_000));
|
||||||
|
route.fulfill({ status: 204, body: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await toggle.click();
|
||||||
|
// In flight: locked (aria-busy + disabled).
|
||||||
|
await expect(toggle).toHaveAttribute('aria-busy', 'true');
|
||||||
|
await expect(toggle).toBeDisabled();
|
||||||
|
|
||||||
|
// A second click during the in-flight window must NOT fire a second POST.
|
||||||
|
await toggle.click({ force: true });
|
||||||
|
|
||||||
|
// First request settles → optimistic flip + unlock; exactly one POST fired.
|
||||||
|
await expect(toggle).toHaveAttribute('aria-checked', 'false');
|
||||||
|
await expect(toggle).toBeEnabled();
|
||||||
|
expect(posts).toBe(1);
|
||||||
|
|
||||||
|
await page.unroute('**/api/v1/trip/*/publish');
|
||||||
|
});
|
||||||
@@ -3,5 +3,8 @@
|
|||||||
{
|
{
|
||||||
"path": "."
|
"path": "."
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"settings": {
|
||||||
|
"makefile.configureOnOpen": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+1
-1
Submodule user updated: 4cc0a18aaf...dd19995973
Reference in New Issue
Block a user