Compare commits

16 Commits
Author SHA1 Message Date
m038andClaude Opus 5 bb22c368ba chore: bump user/ pin to 1b9e51b (story fixes, blueprints, travelling off)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 23:16:27 +02:00
m038andClaude Opus 5 143ec135c0 test(stories): cover scrolly step text and back-to-top; fix build-output note
S8 asserts scrolly-section splits its slot content into visible step panels
(the blank-text-column regression had no coverage: S3 only checked the image
column existed, and the silent early return threw nothing for S4 to catch).
S9 covers back-to-top now that main.js solely owns it, including the history
entry the removed inline copy lacked.

CLAUDE.md attributed css-compiled/ and fonts/ to css/style.css and
css/tokens.css. They are built from the js/src/ entrypoints' CSS and font
imports; css/ is hand-authored and served directly, needing no rebuild.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 23:16:22 +02:00
m038andClaude Opus 5 e106da0206 docs(guides): add story-authoring guide; list it in the README index
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 23:16:22 +02:00
m038andClaude Opus 5 cfe070efec fix(make): worktree-rm no longer unregisters user/ for the main checkout
`submodule deinit` is needed before `worktree remove` (a populated user/
blocks it), but worktrees share .git/config — so deiniting inside the
worktree stripped submodule.user.url globally. After any `make worktree-rm`
the main checkout's `git submodule status` reported `-` (not initialised)
while user/ sat there fully intact, and a later `submodule update` would
have had no URL to work from. Re-register with an idempotent `submodule
init` after the removal.

Found by tearing down the post-location-override worktree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-24 23:54:43 +02:00
m038andClaude Opus 5 3250ad366a docs(working): close the plan; retract the owner_username diagnosis
Merged state recorded (user/ dd19995, outer 4450bd6, pin bumped).

The "owner_username cluster" was wrong: on merged main only DEL4 fails, with
identical site.yaml and content, so auth was never the cause. The worktree's
extra five failures came from its incomplete git-ignored user/plugins/ set.

DEL4 itself is real and stays open — deleting an entry removes it from the
DOM and from disk, but a fresh trip-page load makes the server re-emit the
card, the same invalidation bug the spec's header says was fixed once before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-24 23:53:24 +02:00
m038andClaude Opus 5 4450bd6eec Merge feat/post-location-override into main
Post-form location override (U1-U6) plus the code-review hardening, the
maplibre-CSS lazy <link>, and the test-entry leak fix. Bumps the user pin to
dd19995, the corresponding user/ merge-to-main commit.

CLAUDE.md conflicted because both sides changed it deliberately: main cut it
to rules-only (839a4d0, ed6e43a) while this branch added the map-doctrine
carve-out (829325c). Resolved to main's rules-only structure with the
carve-out ported into it — without it CLAUDE.md would forbid the second map
engine this feature deliberately ships. The descriptive detail stays in
docs/reference/architecture.md, per main's content-tiering convention.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-24 23:49:36 +02:00
m038andClaude Opus 5 28bbd41868 docs(working): record the submodule-git-dir cause and the .env fix
~/Projects is a symlink to ~/Nextcloud/Projects — one directory, not two
clones. The differing user/main refs came from the worktree having its own
submodule git dir (.git/worktrees/<name>/modules/user), which is worth
knowing: submodule commits made from the main checkout stay invisible in a
worktree until fetched, and a local fetch moves them without a push.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-24 23:47:37 +02:00
m038andClaude Opus 5 d57041d316 test(post): retract the "these specs are red" notes — the merge fixed them
The warnings added in 6398542 were wrong. UG1, UG2 and LD1 were failing
because this branch predated e17a5dc, not because the behaviour they assert
was missing: merging user/main brought the FilePond upload gate and the
oriented-derivative slide dims, and all three pass with no product change.

Headers now point at e17a5dc for both mechanisms. Also corrects the plan's
.env note — the env layering is intentional (.env global, .env.<ENV> per
environment via the generated remote-*-<env> targets); the actual fault is
just that `-include .env` additionally requires makefile-valid syntax and
line 6 is not, which breaks make in both non-worktree clones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-24 23:40:48 +02:00
m038andClaude Opus 5 b02f27f559 docs(working): record three real defects behind the red post specs
Also corrects the green-run line: "test-post 6/6" is the scripts/test-post.sh
shell suite, not the Playwright specs under tests/ui/post/ — conflating the
two made the Playwright post specs look covered when they were never run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-24 23:28:49 +02:00
m038andClaude Opus 5 6398542845 test(post): resolve USER_DIR via helpers; record why UG1/UG2/LD1 are red
lightbox-dims.spec.js hardcoded ../../../user, so a run against a checkout
detached from the served tree planted its fixture in a different user/ than
Grav renders and LD1 failed as an opaque "card never appeared" timeout.
Take USER_DIR from helpers instead, which honours GRAV_USER_DIR.

The three specs in this folder that fail do so for real, pre-existing
reasons, and both files' headers implied otherwise:

- UG1/UG2 specify a submit gate that is not implemented. post-form.js's
  only create-form guard is `converting > 0` (pre-FilePond HEIC
  conversion); it never inspects FilePond item state at submit time, and
  .photo-convert-status is created lazily by photoStatusEl() only from the
  HEIC paths — so for a plain JPEG the element never exists and both
  expectations fail as "element(s) not found". UG2 is the one that matters:
  a failed upload keeping its thumbnail is unguarded silent data loss.

- LD1's header described its root cause in the past tense, reading as
  fixed. entry-journal.html.twig:48-49 still emits {{ img.width }} /
  {{ img.height }}, so EXIF-rotated photos still declare pre-rotation dims
  and PhotoSwipe still squeezes them.

Left failing rather than skipped, per retries:0 — a red test here is a real
defect, and hiding these would lose both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-24 23:28:18 +02:00
m038 f9ab3b1561 docs(working): record the green run, the lazy-link, and what's left before merge 2026-07-24 22:34:20 +02:00
m038 1f4e2aeba5 fix(test): close the test-entry leak into real trip content
A ui-test entry had survived into the active trip's dailies. Three independent
failures had to line up for that, and all three were real:

1. cleanupEntry() used host-side fs.rmSync. Grav's Apache workers run as root,
   so every entry the form creates is root-owned and recursive removal needs
   write permission on that directory — which the host user lacks. Cleanup had
   never worked for form-created entries; it just threw inside a path nothing
   checked. It now falls back to `docker exec … rm -rf` in the container that
   actually serves USER_DIR.

2. globalTeardown's dailies sweep keyed off a `parent:` in post-form.md — a key
   deliberately removed (the write target comes from site.yaml active_trip, and
   CLAUDE.md forbids re-adding a static parent). The regex could never match, so
   dailiesDir was always null and the sweep silently did nothing. It now reuses
   helpers' own resolution instead of keeping a divergent copy.

3. Nothing pinned the suite to this checkout's server. playwright.config.js
   defaults to :8081, so a worktree run hit the MAIN checkout — entries created
   in one content tree while the specs asserted and cleaned up in another.
   test-ui now passes GRAV_BASE_URL from GRAV_PORT, and globalSetup hard-fails
   when the server's bind mount disagrees with the tree the specs read.

Also fixed, found on the way to a green run:

- test-account interpolated the password into an `sh -c` string, so a password
  containing a shell metacharacter was re-parsed by the container's shell
  (`sh: 2: <fragment>: not found`, no account, every UI run dead). It now
  travels via `docker exec -e`, making the recipe indifferent to its contents.
- `make start` in a worktree always failed: travel-memories declares
  `env_file: .env` and worktree-new creates none. It degrades to start-grav
  there — a worktree with no server is what sent runs to :8081 in the first
  place.
- test-form-config asserted a hero_image field that 8cf1145 deliberately
  removed; it had been failing ever since.

Verified: config 22/22, post 6/6, location-override 20/20, and a full UI run
now leaves zero ui-test entries behind. The remaining UI failures are
pre-existing on main — site.yaml pins owner_username to a real account while
the suite logs in as testrunner, so owner-only controls never render for it.
Only trip-publish.spec.js patches that; delete-flow, edit-mode and anon-view
do not. Left for a separate branch.
2026-07-24 22:33:46 +02:00
m038andClaude Opus 5 5edaf3ee1e docs(review): correct R8/R13 and the plan status; bump user pin to the review fixes
R8 and R13 both described behaviour that changed in review, and R13 rested on a
server-side cleanCoordinate() that had never been committed. Both now describe
what actually ships, with the revision called out inline rather than silently
rewritten. The plan's Status keeps  Complete but now records what the review
changed and the two things still open before merge.

Bumps the `user` gitlink to e873a9c (the review fixes). The submodule is
deliberately NOT pushed: git-sync would propagate it to production. So this pin
still references a commit that exists only locally — push `user/` and re-point
before this branch merges.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-24 21:49:02 +02:00
m038andClaude Opus 5 829325c9c7 fix(review): make the mismatch-blocks-submit test able to fail; carve out the map doctrine
The U5 guard asserted only `.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, and the click
is dispatched via evaluate(el => el.click()), which skips Playwright's
navigation-aware waiting. So the one test standing between a bad coordinate
and the server could not fail. It now proves the negative on disk via
findEntry() after a settling interval, registers the tag for cleanup before
the click, and asserts the flag and value survived.

CLAUDE.md's "one map path" section stated flatly that a single map code path
exists, which location-map.js now contradicts. Recorded it as the one
sanctioned exception (an editor, not a display map; lazy-imported; shares only
MAP_STYLE) rather than leaving the doctrine wrong.

The `user` gitlink is deliberately NOT bumped here: its pin already points at
an unpushed submodule commit, which must be pushed and re-pointed before this
branch merges.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-24 21:34:17 +02:00
m038andClaude Sonnet 5 a517331d1b fix(post): cover the code-review fixes; mark location-override plan complete
Adds Playwright coverage for the four cross-reviewer-confirmed bugs
fixed in the user/ submodule (map-load race on rapid reopen, mismatch
flag clearing on blank, and submit blocked on unresolved mismatch),
and bumps the user/ pointer to the commit with those fixes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 20:03:19 +02:00
m038 01c3e72c8f test(post): add location-override Playwright coverage
Mocks the Open-Meteo geocoding endpoint via page.route() so the suite is
hermetic. Covers the panel's closed-by-default state, search happy path,
Paris/Texas disambiguation ranking, no-match/network-failure/in-flight
states, XSS-safe rendering, map canvas singleton behavior, drag sync, the
mismatch flag, the maplibre-gl lazy-load boundary, and a full submit
round-tripping lat/lng into the entry's frontmatter.
2026-07-24 19:38:39 +02:00
32 changed files with 997 additions and 935 deletions
+6 -7
View File
@@ -6,10 +6,9 @@ Rules, gotchas, and entry points — the things that must change what you do *be
|---|---| |---|---|
| How the site hangs together — stack, plugin roles, templates, partial contracts, data flows | [`docs/reference/architecture.md`](docs/reference/architecture.md) | | How the site hangs together — stack, plugin roles, templates, partial contracts, data flows | [`docs/reference/architecture.md`](docs/reference/architecture.md) |
| Domain vocabulary — Trip, Entry, Story, Active Trip | [`CONCEPTS.md`](CONCEPTS.md) | | Domain vocabulary — Trip, Entry, Story, Active Trip | [`CONCEPTS.md`](CONCEPTS.md) |
| Doing something operational — posting, GPX, switching trips, local setup, deploying | [`docs/guides/`](docs/guides/) | | Doing something operational — posting, writing stories, GPX, switching trips, local setup, deploying | [`docs/guides/`](docs/guides/) |
| Test suite layout and conventions | [`docs/reference/testing.md`](docs/reference/testing.md) | | Test suite layout and conventions | [`docs/reference/testing.md`](docs/reference/testing.md) |
| 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 | | 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) | | Folder map, prerequisites, the full `make` command list | [`README.md`](README.md) |
The site is Grav (flat-file PHP CMS, no database) in Docker, with content and theme in the `user/` submodule. The site is Grav (flat-file PHP CMS, no database) in Docker, with content and theme in the `user/` submodule.
@@ -21,13 +20,13 @@ The site is Grav (flat-file PHP CMS, no database) in Docker, with content and th
- **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 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. - **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`. - 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`). - `css-compiled/` and `fonts/` are generated by esbuild from the `js/src/` entrypoints' CSS and font imports (fontsource, photoswipe, maplibre-gl) — **not** from `css/`. `css/style.css` and `css/tokens.css` are hand-authored and served directly (`partials/base.html.twig`), so editing them needs no rebuild.
- `templates/partials/weather-icons.html.twig` is 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. - **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 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. - Dev server: **http://localhost:8081** (`make setup` on a first run, `make start` / `make stop` after). A second service, `travel-memories`, runs on :8082. 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/`. - `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). - ⚠️ **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`. - The Admin plugin slug is **`admin2`**, not `admin`.
@@ -46,8 +45,8 @@ The site is Grav (flat-file PHP CMS, no database) in Docker, with content and th
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: 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 a third; an older three-variant setup was deliberately consolidated away. - **`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` is the `/post` pin *editor* one draggable marker, no popups/GPX/bounds, `maplibre-gl` lazy-imported so a GPS-only submit never fetches it. It shares exactly one thing with the display path, the style URL in `js/src/map-style.js`. Do not fold them together. - **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. - 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. - **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.
-18
View File
@@ -48,24 +48,6 @@ A server's per-host configuration overlay. Once it exists, Grav's Admin writes *
### Remote-only plugin ### 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. 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**.
+39 -3
View File
@@ -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
@@ -98,8 +111,18 @@ build-assets:
-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).
@@ -177,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 ──────────────────────────────────────────────────────────────
@@ -185,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"; \
+28 -67
View File
@@ -27,7 +27,7 @@ Two git repos:
| `tests/` | Playwright suite — see [`docs/reference/testing.md`](docs/reference/testing.md) | | `tests/` | Playwright suite — see [`docs/reference/testing.md`](docs/reference/testing.md) |
| `php/` | Local PHP ini overrides | | `php/` | Local PHP ini overrides |
| `docs/` | All project documentation — start at [`docs/README.md`](docs/README.md) | | `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/guides/` | Operational how-tos (posting, story authoring, GPX, trip switching, setup, deploy cycle) |
| `docs/reference/` | Stable facts: architecture, design system, testing | | `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/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 | | `docs/working/` | Specs, plans, backlog, QA — work in flight |
@@ -49,22 +49,16 @@ Two git repos:
```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
git submodule update --init user make setup # start Docker container and install plugins
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.
`user/` is a **git submodule** — initialise it with `git submodule update --init user`. Do not Clone the user content repo into `user/` if not already present:
`git clone` into `user/` by hand; that detaches it from the pin the outer repo tracks.
> ⚠️ **Use `make start-grav`, not `make setup`, on a clean checkout.** `make setup` runs `make start` ```bash
> (`docker compose up -d`), which still tries to build the `travel-memories` service — but its source git clone $USER_REPO user/
> 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.
--- ---
@@ -75,12 +69,12 @@ Site runs at http://localhost:8081.
**2. Run the install:** **2. Run the install:**
```bash ```bash
make remote-install-prod # or -test make remote-install
``` ```
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-prod` 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` works without credentials.
--- ---
@@ -107,9 +101,8 @@ make content-push # push local user/ commits → Gitea
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
| `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 setup` | First run: build → start → install plugins → fix perms |
| `make start` | Start **all** compose services. ⚠️ Fails where `services/travel-memories` is absent | | `make start` | Start the local Docker container |
| `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 install-plugins` | (Re)install plugins from plugins.txt, then apply local plugin patches | | `make install-plugins` | (Re)install plugins from plugins.txt, then apply local plugin patches |
| `make apply-plugin-patches` | Idempotently re-apply the patches in `deploy/patches/` | | `make apply-plugin-patches` | Idempotently re-apply the patches in `deploy/patches/` |
@@ -144,67 +137,35 @@ Details and conventions: [`docs/reference/testing.md`](docs/reference/testing.md
| `make worktree-new NAME=<feature>` | Create a worktree with its own `user/` checkout and an isolated dev server on port `8090+` | | `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) | | `make worktree-rm NAME=<feature>` | Tear one down cleanly (compose down → submodule deinit → worktree remove → prune) |
### Remote targets — every one needs an environment suffix ### Remote credentials
> **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 | | Command | Description |
|---------|-------------| |---------|-------------|
| `make remote-env-setup-<env>` | Write Gitea credentials to `~/.env-intotheeast` on the server | | `make remote-env-setup` | Write Gitea credentials to `~/.env-intotheeast` on the server |
| `make remote-env-remove-<env>` | Delete `~/.env-intotheeast` from the server | | `make remote-env-remove` | 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** Always run `make remote-env-remove` when done. Credentials must not persist on the server.
### Remote server management
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
| `make remote-install-<env>` | First-time install: download Grav, clone both repos, install plugins | | `make remote-install` | 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` | 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-install-plugins` | Install/update plugins from local plugins.txt on the server |
| `make remote-content-status-<env>` | Show the server's content-repo state | | `make remote-upgrade-grav` | Upgrade Grav core on the server |
| `make remote-apply-env-<env>` | Apply `deploy/env/<env>/` config into the server's env tree — **re-run after any fresh install** | | `make remote-clean` | Clear Grav cache on the server |
| `make remote-apply-plugin-patches-<env>` | Re-apply `deploy/patches/` on the server | | `make remote-maintenance-on` | Enable maintenance mode (visitors see offline page) |
| `make remote-maintenance-off` | Disable maintenance mode |
**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-prod make remote-maintenance-on
make remote-upgrade-grav-prod make remote-upgrade-grav
make remote-install-plugins-prod make remote-install-plugins
make remote-apply-env-prod # env tree is not restored by anything else make remote-clean
make remote-warmup-prod make remote-maintenance-off
make remote-maintenance-off-prod
``` ```
--- ---
+1 -6
View File
@@ -15,14 +15,9 @@
**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) — the site as it actually is - [Architecture overview](reference/architecture.md)
- [Superseded decisions](reference/superseded-decisions.md) — what was planned, then reversed, and why
- [Testing](reference/testing.md) - [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.
--- ---
## If you're Claude ## If you're Claude
+17 -72
View File
@@ -1,23 +1,18 @@
# 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` (scheduling, bulk edits). The `/post` form also **edits** existing entries — see below. Two ways to post: the **mobile form** at `/post` (quick, phone-friendly) or the **Admin panel** at `/admin` (drafts, scheduling, editing).
--- ---
## 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. **Attach 16 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 2. Fill in **Title** and **Content** (required)
3. Fill in **Title** and **Content** (required) 3. Tap **Get Location** → fills Lat/Lng automatically
4. Tap **Get Location** → fills Lat/Lng, then reverse-geocodes City + Country for you 4. Tap **Get Weather** → fills weather fields using your coordinates
5. Tap **Get Weather** → fills weather fields using those coordinates 5. Type **City** and **Country** (optional but nice)
6. Optional: open **More location details** to search for a place by name, or drag the pin on the map to place it exactly 6. Attach photos (optional) — first photo becomes the hero image
7. Optional: open **More options** for transport mode, publish state, connector and highlight toggles 7. Tap **Submit** → entry appears in the feed immediately
8. Tap **Submit** → entry appears in the feed immediately
> **Photos are mandatory (16).** 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.
--- ---
@@ -25,20 +20,14 @@ Two ways to post: the **mobile form** at `/post` (quick, phone-friendly) or the
| Field | Required | Notes | | Field | Required | Notes |
|---|---|---| |---|---|---|
| Photos | ✅ | **16 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, by place search, or by dragging the map pin | | Lat / Lng | — | Filled by Get Location; used for map marker |
| City | — | Auto-filled by reverse geocoding after Get Location; shown as `📍 Kyoto, Japan` on feed cards | | City | — | Shown as `📍 Kyoto, Japan` on feed cards |
| Country | — | Combined with City in the location badge | | Country | — | Combined with City in location badge |
| Weather | — | Filled by Get Weather (Open-Meteo, free, no key) | | Weather | — | Filled by Get Weather (Open-Meteo, free, no key) |
| How I got here | — | `transport_mode`: walking · bicycle · bus · train · car · plane | | Photos | — | All uploaded files appear in the gallery; first = hero |
| 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`
@@ -51,9 +40,8 @@ The advanced three sit behind **More options**. There is **no `hero_image` field
Browser → /post (post-form.md) Browser → /post (post-form.md)
└─ Grav Form plugin validates fields └─ Grav Form plugin validates fields
└─ cache-on-save injects parent from site.active_trip └─ cache-on-save injects parent from site.active_trip
└─ and sets overwrite_mode: edit when edit_path is filled, else false └─ add-page-by-form plugin
└─ add-page-by-form plugin (patched — see deploy/patches/) ├─ writes user/pages/01.trips/<active_trip>/01.dailies/<slug>/entry.md
├─ 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
@@ -68,21 +56,15 @@ Example: `2026-07-20-0930-first-day-in-kyoto.entry`
user/pages/01.trips/denmark-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
├─ photo-01.jpg ← first in order, so this is the hero ├─ temple.jpg hero image (or set hero_image in frontmatter)
└─ photo-02.jpg ← additional gallery image └─ market.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 **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). Use the Admin panel at `/admin` for drafts, scheduled posts, or editing existing entries.
1. Log in at `/admin` 1. Log in at `/admin`
2. **Pages → Add Page** 2. **Pages → Add Page**
@@ -114,36 +96,7 @@ 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 |
| `transport_mode` | string | `walking` · `bicycle` · `bus` · `train` · `car` · `plane` | | `hero_image` | string | Filename to pin as hero (e.g. `temple.jpg`); auto-selects first image if blank |
| `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.
--- ---
@@ -158,13 +111,5 @@ the active trip.
**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 16 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.
+228
View File
@@ -0,0 +1,228 @@
# Writing a Story
A Story is a long-form, hand-crafted piece with an immersive layout — distinct from an Entry, which is a quick dated post from the road (see [`CONCEPTS.md`](../../CONCEPTS.md)). Stories get a Ken Burns hero, scroll-driven sections, galleries and pull quotes.
The admin editor gives you a **plain markdown textarea** for the body. There is no block picker — the layout vocabulary is a set of shortcodes you type by hand. This guide is that vocabulary; the story edit form also carries a condensed copy of it on its **Blocks** tab, so you don't need this file open while writing.
Admin lives at **`/admin`** (the plugin slug is `admin2`, but the route is `/admin`). A story's edit URL looks like `/admin/pages/edit/trips/<trip>/stories/<slug>`.
Stories live at:
```
user/pages/01.trips/<trip>/04.stories/<slug>/story.md
```
`04.stories/stories.md` is a `routable: false` container — its children are aggregated onto the trip page. There is no standalone `/stories` view; don't create one.
---
## 1. Create the page
1. Admin → **Pages** → add a page under the trip's **Stories** folder
2. Set page template to **story** — this loads [`user/themes/intotheeast/blueprints/story.yaml`](../../user/themes/intotheeast/blueprints/story.yaml). You get the story-specific **Content / Blocks / Location / Publishing** tabs plus the inherited **Options / Advanced / Security** tabs
3. Fill in Title and Start Date (both required)
---
## 2. Upload the images first
Upload every image the story needs via the **Images** field at the bottom of the Content tab, before writing the body.
> There is no separate *Media* tab — the uploader is a field on the Content tab, labelled **Images**. It arrives via inheritance: `story.yaml` declares `'@extends': {type: default, context: blueprints://pages}`, which merges in Grav's default page form. `story.yaml` and `trip.yaml` did not originally extend it, so neither form could upload anything, which is why the demo story images had to be placed on the filesystem. Both now inherit, matching [`entry.yaml`](../../user/themes/intotheeast/blueprints/entry.yaml). `home.yaml` is still standalone, deliberately — the home page has no per-page media.
Every shortcode refers to images by **bare filename** — the `story-blocks` plugin prefixes the page URL at render time ([`story-blocks.php:22`](../../user/plugins/story-blocks/story-blocks.php)), so you write:
```
image="photo-1.jpg" ✅
image="/images/photo-1.jpg" ❌ don't path it
```
The demo stories use a `hero.jpg` / `photo-1.jpg` / `photo-2.jpg` naming convention. Worth copying — it keeps the shortcodes readable.
---
## 3. Frontmatter fields
All of these come from the form tabs, so you rarely type them by hand. Listed here because the body shortcodes are *not* the whole story — the hero in particular is frontmatter, not a tag.
| Field | Tab | Notes |
|---|---|---|
| `title` | Content | Required |
| `date` | Content | Required. Start date |
| `end_date` | Content | Optional — leave blank for a single-day story |
| `hero_image` | Content | **The hero. Filename only**, from the Images field. Missing → grey placeholder, story still renders |
| `hero_alt` | Content | Falls back to the title if empty |
| `location_name`, `location_country` | Location | Shown in the hero meta line and the opener |
| `lat`, `lng` | Location | Decimal degrees — places the story marker on the trip map |
| `transport_mode` | Location | walking / bicycle / bus / train / car |
| `force_connect` | Location | Always draw a connector line from the previous marker |
| `published` | Options | Grav's standard toggle, from the inherited form |
| `featured` | Publishing | Show as a homepage highlight when not travelling |
The Ken Burns pan on the hero is automatic — no parameter for it.
If you hand-write frontmatter, use `date: '2026-09-03'`. Admin2 saves its own serialization (`29-07-2026 19:51`); both parse fine.
---
## 4. The body: six shortcodes
Defined in [`user/plugins/story-blocks/shortcodes/`](../../user/plugins/story-blocks/shortcodes/). Plain prose between them renders as a normal reading column — you don't need a shortcode to write paragraphs.
### Wrapping tags
**`scrolly-section`** — text panels scroll over a pinned, slowly panning image. The centrepiece block.
```
[scrolly-section image="hero.jpg" alt="Description of the image" caption="Optional caption"]
The first panel. Scrolls into view over the image.
---
The second panel. A markdown `---` starts a new panel.
[/scrolly-section]
```
Panels are split on the `<hr>` that `---` produces ([`story.html.twig:234`](../../user/themes/intotheeast/templates/story.html.twig)). `caption` is optional. Under `prefers-reduced-motion` all panels render active with no pinning.
**`pull-quote`** — large extracted quote, optionally over a background image.
```
[pull-quote image="photo-1.jpg" alt="Description of the image"]
The quote itself. Markdown works in here.
[/pull-quote]
```
Drop `image`/`alt` entirely for the plain no-image variant.
### Self-closing tags
Note the ` /]` — these take no content.
**`chapter-break`** — full-width section transition over a background image.
```
[chapter-break image="photo-1.jpg" title="After Dark" number="II" alt="Description" /]
```
`number` is optional; the demo stories use roman numerals.
**`snap-gallery`** — swipeable multi-image carousel with dots.
```
[snap-gallery images="hero.jpg,photo-1.jpg" captions="First caption,Second caption" alts="First alt,Second alt" /]
```
⚠️ See the comma gotcha below.
**`full-bleed`** — single image edge-to-edge, as a visual pause.
```
[full-bleed image="photo-2.jpg" alt="Description" caption="Optional" credit="Optional" /]
```
**`image-caption`** — photo at a chosen width with caption beneath.
```
[image-caption image="photo-2.jpg" alt="Description" caption="Optional" credit="Optional" width="column" /]
```
`width` accepts `column` (default), `full`, `bleed`. Anything else falls back to `column`.
`full-bleed` and `image-caption` are implemented but not yet used by any story — the demos only exercise the other four.
---
## Gotchas
### snap-gallery splits on commas — captions cannot contain them
`images`, `captions` and `alts` are each split on `,` and zipped by index. **A comma inside a caption shifts every caption after it.**
This is already live in the demo content. `04.stories/04.florence-without-a-map/story.md` has:
```
captions="The Arno at noon — greener than expected, the bridges older than you remember,Via dei Servi: …"
```
Two images, but three comma-separated pieces — so slide 1 gets "The Arno at noon — greener than expected", slide 2 gets " the bridges older than you remember", and the Via dei Servi text is silently dropped.
Use em dashes or semicolons in gallery captions. There is no escaping mechanism.
### Self-closing tags need the space before `/]`
`[chapter-break … /]` — not `[chapter-break …/]` or `[chapter-break …]`.
### A typo'd shortcode fails silently
An unrecognised tag name or a malformed parameter list renders as literal text or vanishes — no error, no warning. Preview the page after every block; there is no in-editor validation.
### `---` outside a scrolly-section is just a horizontal rule
The panel-splitting behaviour only applies *inside* `[scrolly-section]`.
---
## Worked example
The best reference to copy from is [`user/pages/01.trips/italy-2026-demo/04.stories/01.sorano-rock-and-time/story.md`](../../user/pages/01.trips/italy-2026-demo/04.stories/01.sorano-rock-and-time/story.md) — it combines `scrolly-section`, `chapter-break` and `pull-quote` in one story.
```markdown
---
title: 'Sorano: Rock and Time'
date: '2026-09-03'
location_name: Sorano
location_country: Italy
lat: 42.683
lng: 11.715
hero_image: hero.jpg
hero_alt: Medieval town of Sorano clinging to pale tufa cliffs at dusk
published: true
---
Opening prose. Renders as a normal reading column.
[scrolly-section image="hero.jpg" alt="Sorano seen from the approach road" caption="Sorano — tufa cliff town"]
First panel over the pinned image.
---
Second panel.
[/scrolly-section]
More prose between blocks.
[chapter-break image="photo-1.jpg" title="After Dark" number="II" alt="Narrow medieval alley at dusk" /]
[pull-quote image="photo-1.jpg" alt="Stone alley lit by a single lantern"]
A town built on rock, carved from rock, returning slowly to rock.
[/pull-quote]
```
---
## Publish
1. Set **Published** on the Publishing tab
2. Optionally set **Featured highlight** to surface it on the homepage between trips
3. Push the content:
```bash
make content-push
```
That commits and pushes `user/` to Gitea, which triggers the production pull. See [`deploy-cycle.md`](deploy-cycle.md).
---
## The Blocks tab
The story edit form has a **Blocks** tab holding a paste-ready example of each shortcode plus the comma warning. It's built from `type: spacer` fields in `story.yaml`, whose `text` admin2 renders as HTML (confirmed against admin2 2.0.12 — see its CHANGELOG entry for issue #91).
If you edit those fields, note two things: the `text` values are YAML double-quoted scalars, so HTML attribute quotes must be escaped (`\"`) — and `<h4>` is flattened by admin2's CSS reset, which is why the headings use `<strong>` in a styled `<p>` instead. Long `<pre>` content needs `white-space:pre-wrap`, or it overflows underneath the Page Info sidebar.
---
## Why it's a raw textarea
The admin2 story editor is a plain markdown field by design-so-far, not by limitation. Background and the options considered: [`docs/research/story-editing.md`](../research/story-editing.md). Note that its conclusion that admin2 cannot host a custom editor is **out of date** — admin-next ships a plugin field-component surface (`admin-next/fields/{type}.js` + `onApiBlueprintResolved`) that the installed api 1.0.9 / admin2 2.0.12 support. Nobody has built it yet.
+8 -33
View File
@@ -14,8 +14,7 @@ How the intotheeast site hangs together.
| 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 *display* path (`MapUtils.initEntryMap`) on trip + home, plus one sanctioned *editor* (`js/src/location-map.js`) for the `/post` pin picker | | Maps | MapLibre GL JS | Replaced Leaflet; one shared map path (`MapUtils.initEntryMap`) on trip + home |
| 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 |
--- ---
@@ -55,7 +54,7 @@ Other notable plugins:
| `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`) | | `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 | | `entry-actions` (custom) | Owner-only, active-trip-scoped journal entry actions (delete) via the Grav API |
### Plugin management model ### Plugin management model
@@ -77,15 +76,10 @@ Three categories, by how each plugin is installed and maintained:
| `js/src/map.js` | `js/map.js` + `css-compiled/map.css` — bundles `maplibre-gl`, `@mapbox/togeojson`, and `js/maplibre-utils.js` | | `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/feed-actions.js` | `js/feed-actions.js` |
| `js/src/trip-publish.js` | `js/trip-publish.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` | | `js/src/post-form.js` | `js/post/` (ESM + code splitting) + `css-compiled/post-form.css` |
| `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) | | `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. `css/style.css` and `css/tokens.css` are hand-authored too — only `css-compiled/` is generated.
**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.
--- ---
@@ -95,24 +89,18 @@ 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)
post-form.html.twig ← extends base; the /post journal form gpx-manager.html.twig ← extends base; admin UI for GPX file management
├─ 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.
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. Site nav (in `base.html.twig`) is deliberately minimal — **Home + Past Trips only**. 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`. 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`.
@@ -159,20 +147,7 @@ The column **beside** the map: date-range header, filter bar, stats/cycling pane
**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). **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. > 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.
#### 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`.
--- ---
+1 -12
View File
@@ -1,17 +1,6 @@
# Design System — Light Mode Color Palette # Design System — Light Mode Color Palette
> **Superseded — light mode is not implemented, and this palette is not in the code.** Light-mode counterpart to `design-system.md`. Only color tokens differ between themes — typography, spacing, radius, shadows, and layout are identical.
>
> 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.
--- ---
+2 -23
View File
@@ -31,13 +31,6 @@
### 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 |
@@ -53,20 +46,6 @@ The authoritative list is `user/themes/intotheeast/css/tokens.css`.
| `--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
@@ -171,7 +150,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` (`#22201B` in the dark theme), bottom border `1px solid var(--color-border)` - Background: `--color-canvas` (white), bottom border `1px solid var(--color-border)`
### 5.2 Entry Feed Card — With Photo ### 5.2 Entry Feed Card — With Photo
@@ -363,7 +342,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. One shared display-map partial (`partials/entry-map.html.twig`), not three templates — see [`superseded-decisions.md`](superseded-decisions.md) → R12 | | Maps | MapLibre GL JS | Replaced Leaflet; all 3 map templates use it |
| 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.
-54
View File
@@ -1,54 +0,0 @@
# 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-25 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 (`dd19995`) | `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).
@@ -1,241 +0,0 @@
---
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. This is the same failure mode as a lagging plan `Status:` line — and one was
found during this pass: a plan read `📋 Not started` while its feature was merged.
### 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. Audit against the state that is actually current
The outer repo pinned a `user/` commit that lagged the submodule's real HEAD, and that pin was the
default checkout in a fresh worktree. Auditing it would have produced findings against a state nobody
runs — and would have missed a whole merged feature. 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").
## 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,149 +0,0 @@
# 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.**
+1 -12
View File
@@ -4,17 +4,6 @@ Everything here is a live working document: specs being built from, plans being
Stable facts belong in [`../reference/`](../reference/); how-to procedures in [`../guides/`](../guides/); write-ups of bugs already solved in [`../solutions/`](../solutions/). 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 ## What's in here
@@ -29,7 +18,7 @@ Stable facts belong in [`../reference/`](../reference/); how-to procedures in [`
| `learnings/` | Retrospective notes worth keeping but not yet promoted to `../solutions/` | | `learnings/` | Retrospective notes worth keeping but not yet promoted to `../solutions/` |
| `backlog.md` | Unscheduled ideas and wishes | | `backlog.md` | Unscheduled ideas and wishes |
| `bugs-and-fixes.md` | Running log of bugs found and what fixed them | | `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) | | `summary.md` | Project summary / current state |
| `pm-analysis.md`, `git-sync-notes.md`, dated one-offs | Standalone notes, kept for reference | | `pm-analysis.md`, `git-sync-notes.md`, dated one-offs | Standalone notes, kept for reference |
--- ---
-12
View File
@@ -2,18 +2,6 @@
**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** (16 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
-15
View File
@@ -2,21 +2,6 @@
**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
-11
View File
@@ -2,17 +2,6 @@
**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
-14
View File
@@ -2,20 +2,6 @@
**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
@@ -11,7 +11,22 @@ execution: code
# Post Form Location Override - Plan # Post Form Location Override - Plan
**Status:** ✅ Complete (2026-07-24) — merged to `user/` `main` as `dd19995` ("Merge feat/post-location-override into main"). Delivered `js/src/location-map.js` (the lazy-imported single-draggable-marker pin editor) and `js/src/map-style.js` (`MAP_STYLE`, now the single source of the basemap URL for both map paths), plus the "More location details" disclosure with city/country search-by-lookup in `js/src/post-form.js`. The status line lagged the merge and was corrected during the 2026-07-25 documentation reconciliation; the second map engine is now recorded as the one sanctioned exception to the single-map-path rule in `CLAUDE.md` and in [`../../reference/superseded-decisions.md`](../../reference/superseded-decisions.md) → R13. **Status:** ✅ Complete (2026-07-24) — U1U6 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, DEL14, 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 ## Goal Capsule
@@ -45,7 +60,7 @@ The only way to set a coordinate today is the GPS button (reads live position) o
- 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. - 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. - 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. - 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 degrades silently (fields untouched), consistent with the existing reverse-geocode/weather error handling in `post-form.js`. - 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** **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. - 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.
@@ -54,7 +69,7 @@ The only way to set a coordinate today is the GPS button (reads live position) o
- R12. No pin is shown until one of the four paths above sets a value for the first time. - R12. No pin is shown until one of the four paths above sets a value for the first time.
**Error handling & validation boundary** **Error handling & validation boundary**
- R13. Invalid manual `lat`/`lng` text is never client-blocked — the visual mismatch flag (R11) is the only feedback. Final enforcement stays server-side in `cleanCoordinate()`, which already throws on a non-blank, still-invalid value after cleaning. - 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. - R14. Geolocation permission denial keeps its existing, unmodified `#location-status` error behavior.
### Scope Boundaries ### Scope Boundaries
-9
View File
@@ -2,15 +2,6 @@
*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
@@ -64,8 +64,8 @@ Backend sanitization has already been added (`user/plugins/cache-on-save/cache-o
### Error handling ### Error handling
- No search results: inline message under the search box, map/pin untouched. - No search results: inline message under the search box, map/pin untouched.
- Search network failure: silent-ish degrade (consistent with existing weather/reverse-geocode error handling in `post-form.js`), fields 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: no client-side hard block (the map preview and eventual server-side `cleanCoordinate()` are the safety nets); this UI's whole point is to make that failure mode rare in practice, not to duplicate the backend validator client-side. - 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). - Geolocation permission denied: unchanged existing behavior (`#location-status` error message).
## Out of scope / explicitly deferred ## Out of scope / explicitly deferred
@@ -1,109 +0,0 @@
# 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 |
| Plan status vs reality | `user/` HEAD `dd19995` |
The `user/` submodule was moved off the outer repo's pin to its real HEAD (`dd19995`) before
auditing, because the pin lagged and would have produced findings against a state that is no longer
current.
-10
View File
@@ -2,16 +2,6 @@
*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
+4 -1
View File
@@ -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"
+57
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
+9 -1
View File
@@ -9,6 +9,10 @@
// display EXIF-rotated. For a stored-landscape portrait photo the attrs said // display EXIF-rotated. For a stored-landscape portrait photo the attrs said
// landscape while the pixels rendered portrait → PhotoSwipe squeezed them. // 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 // The invariant tested here is environment-proof: whatever file the slide
// links to, its browser-rendered natural size must equal the data-pswp-* // links to, its browser-rendered natural size must equal the data-pswp-*
// attrs. (Whether the photo ALSO displays upright depends on the server's // attrs. (Whether the photo ALSO displays upright depends on the server's
@@ -24,10 +28,14 @@ const { test, expect } = require('@playwright/test');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const { execSync } = require('child_process'); 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. // Stored 800x600 with EXIF Orientation=6: browsers render it 600x800 portrait.
const EXIF_PORTRAIT = path.join(__dirname, '../../fixtures/test-photo-exif-portrait.jpg'); const EXIF_PORTRAIT = path.join(__dirname, '../../fixtures/test-photo-exif-portrait.jpg');
const USER_DIR = path.join(__dirname, '../../../user');
const DEMO_DAILIES = path.join(USER_DIR, 'pages/01.trips/italy-2026-demo/01.dailies'); const DEMO_DAILIES = path.join(USER_DIR, 'pages/01.trips/italy-2026-demo/01.dailies');
const DEMO_TRIP_URL = '/trips/italy-2026-demo'; const DEMO_TRIP_URL = '/trips/italy-2026-demo';
+404
View File
@@ -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');
});
+6
View File
@@ -12,6 +12,12 @@
// silent-data-loss path. // silent-data-loss path.
// post-form.js owns the complete gate (theme code; the form plugin is // post-form.js owns the complete gate (theme code; the form plugin is
// GPM-managed and not patchable in-repo). // 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 { test, expect } = require('@playwright/test');
const { fillEditor, findEntry, cleanupEntry, TEST_PHOTO } = require('../helpers'); const { fillEditor, findEntry, cleanupEntry, TEST_PHOTO } = require('../helpers');
+72 -1
View File
@@ -1,5 +1,5 @@
// @ts-check // @ts-check
// Tests: S1S7 — story mode rendering and navigation // Tests: S1S9 — story mode rendering and navigation
// Requires demo data: run `make demo-load` before this suite. // Requires demo data: run `make demo-load` before this suite.
const { test, expect } = require('@playwright/test'); const { test, expect } = require('@playwright/test');
@@ -85,3 +85,74 @@ test('S7: story body back link has back-pill class', async ({ page }) => {
await expect(bodyBack).toBeAttached(); await expect(bodyBack).toBeAttached();
await expect(bodyBack).toHaveText(/← Back/); await expect(bodyBack).toHaveText(/← Back/);
}); });
// ── S8: Scrolly-section text panels actually render beside the pinned image ───
// The server ships the panel text inside .scrolly__steps-content, which CSS hides
// (style.css: `display: none`). Only the inline Scrollama block in story.html.twig
// splits it into visible .scrolly-step divs — and it early-returns silently if the
// main.js bundle (which sets window.scrollama) hasn't executed yet. S3 asserted the
// image column exists; nothing asserted the text column was non-empty.
test('S8: scrolly-section builds visible step panels from its slot content', async ({ page }) => {
await page.goto(STORY_SCROLLY);
await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
// The bundle must have published scrollama before the inline block ran
expect(
await page.evaluate(() => typeof window.scrollama !== 'undefined'),
'window.scrollama published by main.js bundle'
).toBe(true);
// Every scrolly-section must have produced at least one step
const sections = page.locator('.scrolly');
const sectionCount = await sections.count();
expect(sectionCount, 'Two scrolly-sections').toBe(2);
for (let i = 0; i < sectionCount; i++) {
const section = sections.nth(i);
const steps = section.locator('.scrolly-step');
expect(
await steps.count(),
`scrolly-section ${i} split its slot content into steps`
).toBeGreaterThan(0);
}
// …and the text must be readable, not left hidden in the raw slot.
// Scroll each step into view so its reveal transition completes.
const firstStep = page.locator('.scrolly').first().locator('.scrolly-step').first();
await firstStep.scrollIntoViewIfNeeded();
await page.waitForTimeout(800);
await expect(firstStep.locator('.scrolly-step__inner')).toBeVisible();
const text = (await firstStep.innerText()).trim();
expect(text.length, 'First step panel renders non-empty text').toBeGreaterThan(20);
});
// ── S9: Back-to-top is wired once, by main.js, and pushes a history entry ─────
// The inline duplicate in story.html.twig was removed; initBackToTop() in
// js/src/main.js now solely owns #story-totop. That makes the button depend on
// the bundle having loaded, so assert the observable behaviour end to end.
test('S9: story back-to-top reveals on scroll, returns to top, and pushes history', async ({ page }) => {
await page.goto(STORY_SCROLLY);
await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
const btn = page.locator('#story-totop');
await expect(btn).toBeAttached();
// Hidden until scrolled past the 0.8 * viewport threshold
await expect(btn).not.toHaveClass(/is-visible/);
const historyBefore = await page.evaluate(() => history.length);
await page.evaluate(() => window.scrollTo(0, window.innerHeight * 2));
await expect(btn).toHaveClass(/is-visible/, { timeout: 3000 });
await btn.click();
await expect
.poll(() => page.evaluate(() => window.scrollY), { timeout: 3000 })
.toBeLessThan(10);
// main.js's variant pushes a history entry; the removed inline copy did not
expect(
await page.evaluate(() => history.length),
'Back-to-top pushed a history entry'
).toBeGreaterThan(historyBefore);
});
+1 -1
Submodule user updated: 02fa4e94a7...1b9e51baf7