Compare commits

22 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
m038andClaude Opus 5 e79275a3ab build: give Grav's form-upload staging its own named volume
The image declares /var/www/html as a VOLUME, so tmp/forms/<session>/ —
where Grav parks FilePond uploads until the submit moves them into the page
folder — lived in an anonymous volume that any container-recreating
`docker compose up` discards, dropping the photos of a post that was filled
in but not yet submitted. grav_tmp gives the staging area its own lifecycle.

Also pins makefile.configureOnOpen off in the workspace, so the VS Code
Makefile extension stops trying to configure a Makefile whose `-include
.env` cannot be parsed as makefile syntax.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-24 23:27: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 cdae34a706 docs(solutions): capture the CLAUDE.md content-tiering convention
Four rounds of CLAUDE.md reduction (255 -> 305 -> 179 -> 74 lines) turned
up one consistent finding: every stale fact was a *description* of code or
config, never a rule. Two had been written by Claude days earlier.

Documents the operational test ("does this line change what Claude does on
a task where it wouldn't otherwise open the relevant file?"), the tiering
table, why gotchas are the one category that cannot move to a read-on-demand
docs/exceptions/, invariants-over-enumerations, and how to tell when a
reduction pass has hit the floor.

Also surfaces the docs/solutions frontmatter fields in CLAUDE.md's
entry-point table so the store is greppable by module, not just browsable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 22:26:25 +02:00
m038andClaude Opus 5 285e61573e docs: state the build-output rule as an invariant, not a file list
Enumerating the bundles meant adding a fifth one silently falsified
CLAUDE.md. "Everything in js/ is generated except js/src/,
maplibre-utils.js and nav.js" is exactly true today and stays true.
The source->output table lives in docs/reference/architecture.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 21:53:20 +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 9ec2349cd6 docs(working): add a human-facing index + plan status reference
CLAUDE.md keeps the status convention as a one-line rule (it has to be
loaded to be followed). This is the same convention written out for a
human reader, with the meanings the trim dropped, plus what each
subfolder of docs/working/ is for and a grep one-liner for "what's open".

Notes the two distinctions that matter in practice: Deferred is not
Abandoned, and a trailing note after "Complete" is normal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 21:48:00 +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 Opus 5 839a4d0e69 docs: cut CLAUDE.md to rules-only (179 → 74 lines)
CLAUDE.md now carries only what must be known *before* opening a file:
hard rules, gotchas, and an entry-point table. Everything descriptive
moved to the doc that lives next to the code.

Moved out:
- stack versions, plugin roles, asset pipeline, nav shape, user/ repo
  tracking rules → docs/reference/architecture.md
- Playwright layout, config facts, auth-setup project, test account
  → docs/reference/testing.md (new)
- folder map, full make command list (build/test/demo/worktree targets
  that only existed in CLAUDE.md) → README.md
- dev/prod Twig settings table → already in docs/guides/deploy-cycle.md

Fixed while verifying, all of them descriptions that had drifted:
- demo fixtures were listed as italy-2026-demo + no-photos-demo; the
  actual folders are italy-2025 + italy-2026-demo
- the map engine was cited at js/src/maplibre-utils.js; it is
  js/maplibre-utils.js, a hand-authored file beside the bundles
- the build-output list omitted fonts/ and the generated
  templates/partials/weather-icons.html.twig, and did not flag that
  js/maplibre-utils.js and js/nav.js are sources living in js/
- README called user/ a "standalone git repo" (it is a submodule)
- docs/README.md linked to a non-existent working/production-todo.md
- git-sync-notes.md pointed at "CLAUDE.md §1", a section number that
  no longer exists

Net: ~17.1k → ~8.5k chars of always-loaded context.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 21:14:34 +02:00
m038andClaude Opus 5 ed6e43ae51 docs: trim CLAUDE.md 305->179 lines; extract code descriptions to docs/
CLAUDE.md is loaded into context on every request, so every line has a
recurring cost. Applies one rule to decide what earns its place: keep what
changes behaviour (rules and gotchas Claude cannot discover before it acts);
extract what merely describes code (Claude reads the code anyway, and prose
about code silently drifts).

The four stale facts fixed in the previous commit were all in the
"describes code" class -- active_trip, the Admin2 version, demo-load's
scope, the gitignore list. None were rules. That is the argument for moving
this material next to what it documents.

Extracted (kept as pointers):
- entry-map + trip-feed-col parameter contracts (56 lines) -> reference/
  architecture.md "Shared partial contracts". CLAUDE.md keeps only the
  invariants: single map path, must assign window.tripMap/homeMap, keep
  trip-feed-col single-purpose, initTripStats depends on MapUtils.
- Prod override runbook (49 -> 9 lines) -> guides/deploy-cycle.md "The env
  override tree", incl. the Twig dev/prod table and WEB_HOST. CLAUDE.md
  keeps the two behavioural rules: never commit prod values, and Admin on
  the server writes to the env tree (so check both config paths, env wins).
- GPX API routes, session auth and the Blob/FormData upload gotcha ->
  guides/gpx-manager.md "How the manager is wired".
- Trip-switch procedure -> guides/trip-switching.md. CLAUDE.md keeps the
  one rule that matters: never re-add pageconfig.parent to post-form.md.
- Also trimmed the dev-command table and custom-plugin table added in the
  previous commit; both largely restated the Makefile and blueprints.

Fixed the guides being pointed into, so the pointers lead to truth:
- trip-switching.md instructed editing a pageconfig.parent that no longer
  exists -- its whole "two files must be updated together" premise was
  obsolete and would have reintroduced the desync it warned about.
- architecture.md: Grav 2.0.4->2.0.7, Admin2 2.0.10->2.0.12, corrected the
  posting pipeline to show cache-on-save injecting parent before the write,
  added entry-actions to the custom-plugin list.
- japan-korea-2026 -> denmark-2026 across guides/reference (docs/solutions
  keeps its historical references intact -- those are incident records).

Verified: every markdown link resolves, every referenced section heading
exists, and each extracted item was confirmed present in its new home.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 20:55:28 +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
27 changed files with 1563 additions and 400 deletions
+52 -281
View File
@@ -1,305 +1,76 @@
# CLAUDE.md # CLAUDE.md
## 0. Project specifics Rules, gotchas, and entry points — the things that must change what you do *before* you open a file. Everything descriptive lives next to the code:
**Only ever write changes in this folder (travel-blog-intotheeast/) or its subfolders.** | Need | Read |
### Folder explanation
- **./**: Grav CMS dev environment for intotheeast travel blog
- **scripts/**: Server install and maintenance scripts
- **user/**: Site content, config, pages, and theme — its own git repo (`intotheeast-com-content.git`), tracked by the outer repo as a **git submodule** (pinned commit). See "Dual-repo submodule structure" below and `docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md`
- **docs/**: All plans, specs, and project documentation (moved here from `user/docs/` on 2026-06-19)
- **docs/solutions/**: documented solutions to past problems (bugs, patterns, workflow gotchas), organized by category with YAML frontmatter (`module`, `tags`, `problem_type`). Relevant when implementing or debugging in a documented area
- **CONCEPTS.md** (repo root): shared domain vocabulary (Trip, Entry, Story, Active Trip). Relevant when orienting to the codebase or discussing domain concepts
### Current stack
- **Grav:** 2.0.7 stable (baked into the custom Docker image via `Dockerfile`; server upgrades in place via `bin/gpm self-upgrade`)
- **Admin:** Admin2 v2.0.12 (plugin slug: `admin2`, NOT `admin`)
- **GPM channel:** `stable` — set in `user/config/system.yaml``gpm.releases` (authoritative). `GRAV_CHANNEL=production` in `docker-compose.yml` is cosmetic/consistency only
- **Plugin management:** `admin2`, `api`, and `flex-objects` are now **GPM-managed via `plugins.txt`** (installed by `make install-plugins`), no longer hand-extracted from the core bundle. `git-sync` stays **remote-only** — never in `plugins.txt`
- **Docker image:** `getgrav/grav` with `GRAV_CHANNEL=production`
- **PHP session:** `session.save_path = /tmp` set in `php/php-local.ini`
### Dev server
The Docker dev server runs at **http://localhost:8081** (mapped from container port 80 in `docker-compose.yml`). A second service, `travel-memories`, runs at **http://localhost:8082**. Both ports and the container name are overridable via `GRAV_PORT` / `TM_PORT` / `GRAV_CONTAINER` — a worktree's `.worktree-env` sets these so isolated servers never collide (see "Dual-repo submodule structure").
### Local dev commands
| Command | What it does |
|---|---| |---|---|
| `make setup` | One-shot first run: `build``start``install-plugins``fix-perms` | | How the site hangs together — stack, plugin roles, templates, partial contracts, data flows | [`docs/reference/architecture.md`](docs/reference/architecture.md) |
| `make start` / `make stop` | Bring the compose stack up / down (`start-grav` = Grav service only) | | Domain vocabulary — Trip, Entry, Story, Active Trip | [`CONCEPTS.md`](CONCEPTS.md) |
| `make build` | Rebuild the custom Docker image (after `Dockerfile` changes) | | Doing something operational — posting, writing stories, GPX, switching trips, local setup, deploying | [`docs/guides/`](docs/guides/) |
| `make build-assets` | Rebuild theme JS/CSS bundles in a `node:20-alpine` container | | Test suite layout and conventions | [`docs/reference/testing.md`](docs/reference/testing.md) |
| `make install-plugins` | GPM-install everything in `plugins.txt`, then re-apply local patches | | 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 |
| `make fix-perms` | Fix ownership in the container after root-owned writes | | Folder map, prerequisites, the full `make` command list | [`README.md`](README.md) |
**`make build-assets` is mandatory after editing anything in `user/themes/intotheeast/js/src/`.** Sources live in `js/src/`; esbuild writes the committed bundles — `js/main.js`, `js/map.js`, `js/feed-actions.js`, `js/trip-publish.js`, `js/post/`, and the CSS extracted into `css-compiled/`. **Never hand-edit those.** By contrast `css/style.css` and `css/tokens.css` are hand-authored sources, not build outputs. `build-assets` runs as your host UID (`--user`) so the outputs in the bind-mounted `user/` tree are not root-owned. The site is Grav (flat-file PHP CMS, no database) in Docker, with content and theme in the `user/` submodule.
### Custom plugins ## Hard rules
Three plugins are site-owned and tracked in the `user/` repo (everything else under `user/plugins/` is GPM-managed and git-ignored): - **Only ever write inside `travel-blog-intotheeast/`** or its subfolders.
- **Never read `.env`, `.env.prod`, `.env.test`** — they hold credentials. Pass them to commands (`make`, `docker compose`) but never read them; ask the user if you need a value.
- **Never SSH to a server directly** — use the `make remote-*` targets, since credentials live in `.env`. If no target covers what you need, ask the user to run it or propose a new target.
- **Never hand-edit build output** — sources and outputs share folders under `user/themes/intotheeast/` (paths below are relative to it), so know which is which. Run `make build-assets` after editing any source.
- Everything in `js/` is **generated** *except* `js/src/`, `js/maplibre-utils.js` and `js/nav.js`.
- `css-compiled/` and `fonts/` are 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.
| Plugin | Role | ## Dev environment
|---|---|
| `cache-on-save` | Clears the page-tree cache on `new-entry` submits, and derives the write target from `site.active_trip` (`onFormValidationProcessed``setData('parent', …)`) |
| `story-blocks` | Storytelling shortcode blocks for long-form stories (depends on `shortcode-core`) |
| `entry-actions` | Owner-only, active-trip-scoped journal entry actions (delete) via the Grav API |
### Local plugin patches - 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.
- `user/config/system.yaml` is committed with **dev** values (`twig.cache: false`), so templates recompile per request and no cache flush is needed after editing a `.html.twig`. Prod values live in `deploy/env/prod/system.yaml` and **never** in `user/config/`.
- ⚠️ **Once `user/env/<hostname>/` exists on a server, Grav's Admin saves ALL config there** — system *and* plugin. So (a) config edited via Admin on the server is server-only and silently never reaches Gitea or local; (b) when reading or writing server config, check **both** `user/config/…` and `user/env/<host>/config/…`**env wins**, so look there first. Mechanics: [`docs/guides/deploy-cycle.md`](docs/guides/deploy-cycle.md).
- The Admin plugin slug is **`admin2`**, not `admin`.
- `plugins.txt` is maintained by hand — installing a plugin via Admin does **not** update it. `git-sync` is **remote-only** and must never appear in it.
- Everything under `user/plugins/` is git-ignored and gets overwritten by `make install-plugins`**except** the three site-owned plugins (`cache-on-save`, `story-blocks`, `entry-actions`). So a fix to a third-party plugin must be a tracked patch in `deploy/patches/`, never an in-place edit: [`deploy/patches/README.md`](deploy/patches/README.md).
Third-party plugins live in the **git-ignored** `user/plugins/`, so local fixes to them do not travel with the content repo and are **overwritten by `make install-plugins`** or a fresh image build. Keep the fix as a tracked patch in `deploy/patches/` instead: ## Content and trips
- `make apply-plugin-patches` — idempotent `git apply` (skips already-applied patches). `make install-plugins` runs it automatically as its last step - The active trip lives in **one** place: `user/config/site.yaml``active_trip`, and its value is a **route** (`/trips/denmark-2026`), not a bare slug.
- `make remote-apply-plugin-patches-test` / `-prod` — piped over SSH into `patch -p1 --forward`; also runs automatically after a remote plugin install - `cache-on-save` derives the post write target from `active_trip` at submit time. **Never re-add a `pageconfig.parent` to `post-form.md`** — a static parent would override it and reintroduce the old silent-desync bug. Switching trips: [`docs/guides/trip-switching.md`](docs/guides/trip-switching.md).
- Details and the current patch list: `deploy/patches/README.md` - The standalone `/dailies`, `/map`, `/stats` and `/stories` trip views were **deleted** (2026-07-04) — map, stats, and filtering all render inline on the trip page. Do not re-create them or link to them. `01.dailies/` and `04.stories/` are `routable:false` data containers whose children are aggregated by the trip page.
- GPX routes are page media on the trip page, auto-detected — no manual linking. Manage them at `/gpx-manager` (admin login): [`docs/guides/gpx-manager.md`](docs/guides/gpx-manager.md).
- `make content-push` commits and pushes `user/` to Gitea, which triggers the production pull; `make content-pull` is the reverse.
### Trip entity architecture ## Two shared partials — the rules
The site is structured around Trip entities. Key facts: 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:
- Active trip is set in `user/config/site.yaml``active_trip` (currently `/trips/denmark-2026`). The value is a **route**, not a bare slug
- Trip pages live at `user/pages/01.trips/<slug>/`
- Each trip has two content subfolders: `01.dailies/` (journal entries) and `04.stories/` (stories). The former `02.map/` and `03.stats/` standalone views were **removed** (2026-07-04, see `docs/working/plans/2026-07-04-standalone-page-cleanup.md`) — map and stats now render inline on the trip page
- `01.dailies/` and `04.stories/` are `routable:false` **data containers** — visiting `/trips/<slug>/dailies` or `/stories` directly 404s/redirects; their children (entries/stories) render at their own detail URLs and are aggregated by the trip page
- Site nav in `base.html.twig` has Home + Past Trips only — does not link to trip sub-sections
- New journal entries are written to the active trip's `dailies` — the write target is derived from `site.active_trip` at submit time by the `cache-on-save` plugin (post-form.md no longer hardcodes `pageconfig.parent`)
- The trip page (`trip.html.twig`) uses a **client-side filter bar** (All content / Journal / Stories). The standalone `/dailies`, `/map`, `/stats`, `/stories` view pages no longer exist — do NOT try to re-create them or link to them. This filter bar + stats chrome is shared with the home active-trip view via the `trip-feed-col` partial (see "Shared trip-feed-col partial" below)
- Stats are shown inline on the trip page via a toggle (the standalone `/stats` view was removed)
- GPX route files live as media on the trip page itself, parsed client-side via toGeoJSON (bundled into `js/map.js`) and drawn on the trip/home map
- Manage GPX files (view/upload/delete) at `/gpx-manager` — requires admin login; filenames are auto-slugified on upload
### One map path: `MapUtils.initEntryMap` + the `entry-map` partial - **`partials/entry-map.html.twig` is the only path for a *display* map** — the engine is `MapUtils.initEntryMap(opts)` in `js/maplibre-utils.js` (a hand-authored file, imported by `js/src/map.js`). Do not add another display-map implementation; an older three-variant setup was deliberately consolidated away.
- **One sanctioned exception: `js/src/location-map.js`**, the `/post` form's pin *editor* (one draggable marker, no popups/GPX/bounds-fitting, `maplibre-gl` lazy-imported so a GPS-only submit never fetches it). It shares exactly one thing with the display path — `MAP_STYLE` from `js/src/map-style.js`, imported by both so the basemap cannot drift. Do not fold it into `initEntryMap`, and do not add a *third* path.
- It must keep assigning **`window.tripMap` / `window.homeMap`** — the Playwright map specs assert those globals.
- **Keep `trip-feed-col.html.twig` single-purpose.** Its sibling `partials/home-predeparture.html.twig` is the home-only "Coming soon" state — do **not** fold the pre-departure branch back into it.
There is a **single** map code path on the site. The engine is `MapUtils.initEntryMap(opts)` in `js/src/maplibre-utils.js` (bundled into `js/map.js` via `make build-assets` — never hand-edit `js/map.js`). It builds the MapLibre map, places markers/popups, fits bounds, draws the GPX journey, and wires the fullscreen toggle. ## Dual-repo submodule structure
The map **markup + invocation** is shared via one partial: `user/` is a git submodule with its own Gitea remote and its own cadence; the outer repo pins an exact commit. Full workflow, worktree mechanics, teardown: [`docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md`](docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md).
- **Partial:** `user/themes/intotheeast/templates/partials/entry-map.html.twig` - **`M user` / `m user` is normal, not an error.** `M` = the pin differs from `user/` HEAD; `m` = the submodule working tree is dirty (e.g. a local-testing `site.yaml`). Do not "fix" either by committing the gitlink or that `site.yaml`.
- **Used by:** `trip.html.twig` and the active branch of `home.html.twig` (both via `{% include ... with {...} only %}`) - **Don't bump the pin for routine content changes.** Bump it once at the end of a cross-repo feature, to a commit reachable from `user/`'s published `main`, and **push `user/` before the outer repo**.
- **Use `make worktree-new NAME=<x>` / `make worktree-rm NAME=<x>`** — never a hand-rolled `git worktree add`. The targets initialise the submodule and an isolated dev server; skipping the deinit on teardown is what leaves orphaned `.worktrees/` dirs.
It renders the `.home-map-col` column (map div `#{{ map_id }}` + fullscreen button) and, when `entries` is non-empty, a thin `<script>` that assigns `window.{{ map_global }}` from `initEntryMap`. Callers resolve header values (use_gpx / autoconnect) and pass them in. ## Testing
**Parameters:** `make test` runs everything (`test-config``test-post``test-ui`). **The dev server must be running** — every suite drives the live site over HTTP. Layout, helpers, and per-suite commands: [`docs/reference/testing.md`](docs/reference/testing.md).
| Parameter | Type | Trip passes | Home passes | - **Auth is a dependency project.** `auth.setup.js` writes `tests/.auth/user.json`, which the `chromium` project reuses as `storageState`. Never add per-test logins.
|---|---|---|---| - The `testrunner` admin account is created automatically and is git-ignored — never commit it, and keep its password free of shell/Make/URL-special characters, since several consumers interpolate it.
| `map_id` | string | `'trip-map'` | `'home-map'` | - `retries: 0`, so a failing test is a real failure, not flake.
| `map_global` | string | `'tripMap'` | `'homeMap'` |
| `entries` | array | `[{lat, lng, slug, title, url, type?, force_connect, ...}]` | same |
| `card_prefix` | string | `'entry-'` | `'entry-'` |
| `story_markers` | bool | `true` (diamond markers) | `false` |
| `gpx_urls` | array | `gpx_urls` | `home_gpx_urls` |
| `use_gpx` | bool | `page.header.use_gpx ?? true` | derived from `trip.header` |
| `autoconnect` | string | `page.header.autoconnect ?? 'on'` | derived from `trip.header` |
| `gpx_source_prefix` | string | `'gpx'` | `'home-gpx'` |
| `journey_id` | string | `'trip-journey'` | `'home-journey'` |
The map globals `window.tripMap` / `window.homeMap` are asserted by the Playwright map specs, so any surface using this partial must keep assigning them. ## Working docs
> History: this replaced the old three-variant setup (a `feed-map.html.twig` partial with its own inline init, plus a full-page `map.html.twig`). Those were deleted in the 2026-07-04 standalone-page cleanup; the `2026-06-27-map-init-consolidation` plan had already moved trip + home onto `initEntryMap`. Specs go in `docs/working/specs/YYYY-MM-DD-<topic>-design.md`, plans in `docs/working/plans/YYYY-MM-DD-<topic>.md`. These paths override the `docs/superpowers/` default used by the brainstorming and writing-plans skills.
### Shared trip-feed-col partial Every plan needs a `**Status:**` line immediately after its title heading: `📋 Not started` · `🔄 In progress — <note>` · `⏸️ Deferred — <reason>` · `✅ Complete (YYYY-MM-DD)` · `❌ Abandoned — <reason>`.
The home page's active-trip view and the trip page render the **same feed-col chrome** (date-range header, filter bar, stats/cycling panels, feed loop) via one shared Twig partial. This is separate from the `entry-map` partial above — it is the column **beside** the map, not the map. - **When asked what's open:** surface `Not started` and `In progress`; show `Deferred` but label it clearly; omit `Complete` and `Abandoned` unless explicitly asked.
- **When finishing a plan:** set its status to `✅ Complete (YYYY-MM-DD)` before closing the session — whether you executed it directly or via the executing-plans / subagent-driven-development skills.
- **Partial:** `user/themes/intotheeast/templates/partials/trip-feed-col.html.twig`
- **Used by:** `trip.html.twig` and the active branch of `home.html.twig` (both via `{% include ... with {...} only %}`)
- **Sibling:** `partials/home-predeparture.html.twig` — the home-only "Coming soon" landing state. `home.html.twig` picks it with `{% if all_items|length == 0 %}``home-predeparture` `{% else %}``trip-feed-col`. Keep `trip-feed-col` single-purpose — do NOT fold the pre-departure branch back into it.
**Parameters (`trip-feed-col`):**
| Parameter | Type | Trip passes | Home-active passes |
|---|---|---|---|
| `trip_page` | Page | `page` | `trip` |
| `all_items` | array | sorted by date, flag 4 (oldest→newest) | sorted by date, flag 3 (newest→oldest) |
| `journal_entries` | array | dailies children | dailies children |
| `journal_count` / `story_count` | int | counts | counts |
| `has_gpx` | bool | `has_gpx` | `home_gpx_urls\|length > 0` |
| `gpx_urls` | array | `gpx_urls` | `home_gpx_urls` |
| `gps_points` | array | `gps_points` | `gps_points` |
| `show_sort` | bool | `true` | `false` (home keeps its own feed order, no sort button) |
| `trip_header_extras` | bool | `true` | not passed (defaults `false`) |
`trip_header_extras` gates the trip-page-only header block (one-liner `.home-trip-tagline`, expandable `.trip-header-desc`, and `.trip-header-banner` cover strip) that renders between the counts and the filter bar. `trip.html.twig` passes `true`; `home.html.twig` omits it so those extras never leak onto the home route (the `only` include keeps it off by default).
`home-predeparture` takes only `trip_page`.
**Stats/cycling JS glue:** the partial emits an inline `DOMContentLoaded` script calling `window.initTripStats({ gpxUrls, gpsPoints, hasGpx })` — one shared function in `js/src/main.js` (rebuild with `make build-assets`; never hand-edit `js/main.js`). It no-ops when `#stat-distance` is absent, populates exact distance + cycling stats from GPX, and falls back to a `~`-prefixed haversine estimate (or `—` for `<2` points) when there is no GPX. It depends on `window.MapUtils` from `map.js` (loaded in the `bottom` asset group on both pages).
### GPX file management
GPX files are stored as page media on the trip page (`user/pages/01.trips/<slug>/`). They are picked up automatically by `trip.html.twig` (and `home.html.twig`) via `trip_page.media.all`, filtered to `.gpx`, and passed to the shared `entry-map` partial.
The GPX manager page (`user/pages/03.gpx-manager/`) provides a browser UI at `/gpx-manager`:
- **Auth:** enforced by Login plugin via `access.admin.login: true` in frontmatter — shows login form if not authenticated
- **Template:** `user/themes/intotheeast/templates/gpx-manager.html.twig`
- **API:** uses Grav API v1 with session cookie auth (`session_enabled: true` in `user/plugins/api/api.yaml`)
- List: `GET /api/v1/pages{route}/media`
- Upload: `POST /api/v1/pages{route}/media` (multipart)
- Delete: `DELETE /api/v1/pages{route}/media/{filename}`
- **Slugification:** filenames are slugified client-side before upload (spaces/special chars → hyphens, lowercase); the file is sliced to a plain `Blob` so the third argument to `FormData.append` is always used as the filename
- **Media type:** `.gpx` is registered in `user/config/media.yaml` so Grav serves and tracks these files
To add GPX files without the browser UI, drop them directly into `user/pages/01.trips/<slug>/` and run `make content-push`.
### Switching to a new trip
The active trip lives in **one** place now: `site.active_trip`. The post form no longer hardcodes a `pageconfig.parent` — the `cache-on-save` plugin derives the write target from `site.active_trip` at submit time (`onFormValidationProcessed``setData('parent', …)`), so there is nothing to keep in sync.
| File | Key | Example value | How to edit |
|---|---|---|---|
| `user/config/site.yaml` | `active_trip` | `/trips/italy-2027` | Admin → Configuration → Site → **Active Trip** (page-picker rooted at `/trips`; blueprint at `user/blueprints/config/site.yaml`) |
Note: `system.yaml` `home.alias` is permanently set to `/home` (the real home page) and does **not** need to change when switching trips.
After updating, also create the new trip's page tree under `user/pages/01.trips/<new-slug>/` with the two content subfolders `01.dailies/` and `04.stories/` (each with an inert `routable:false` container `.md`), plus the trip's `trip.md`. Do **not** recreate `02.map/` or `03.stats/` — those standalone views were retired.
### Environment
**Never read `.env`, `.env.prod`, or `.env.test`** — they contain sensitive credentials. You may pass them to commands (e.g. `docker compose`, `make`) but never read their contents directly. Ask the user if you need environment-specific information.
### Remote operations
Always use `make` commands for anything on the production server (`make remote-install-plugins`, `make remote-clean`, etc.) — never SSH directly since credentials live in `.env`. If a remote operation isn't covered by an existing `make` command, either ask the user to run it manually or suggest adding a new `make` command if it seems reusable.
For a full upgrade/deploy through local → test → prod (ordered steps, smoke checklist, rollback), follow the runbook at [`docs/guides/deploy-cycle.md`](docs/guides/deploy-cycle.md).
### Content sync
- `make content-push` — commit and push `user/` to Gitea (triggers production pull via webhook)
- `make content-pull` — pull latest from Gitea to local
- `plugins.txt` is manually maintained — installing a plugin via Admin does NOT update it
- `make demo-load` — load **every** fixture trip under `user/docs/demo/trips/` into the pages tree (currently `italy-2026-demo` and `no-photos-demo`). Add a new fixture by dropping a trip folder there; no Makefile edit needed
- `make demo-reset` — remove the demo trips' pages folders and clear cache (full reset; re-run `demo-load` to restore)
- `make pixelfed-import` — import posts from Pixelfed via `scripts/pixelfed-import.py`
### User repo gitignore
Only these folders are tracked in the `user/` Git repo: `pages/`, `config/`, `accounts/`, `themes/`. The `plugins/` and `data/` folders are excluded — **except** the three site-owned plugins, which are un-ignored explicitly (see "Custom plugins" below). Also ignored: the test accounts, `italy-2026-demo` pages, secrets (`config/plugins/git-sync.yaml`, `config/security.yaml`, `api-private.php`), and the whole `env/` override tree.
### Dual-repo submodule structure
`user/` is a **git submodule** of the outer repo (`.gitmodules` at the root; git dir absorbed into `.git/modules/user`). Full workflow: `docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md`. The essentials:
- **Two repos, two cadences.** Outer repo = dev environment (tests/docs/scripts/Docker). `user/` = content + theme, with its own remote and `make content-push` cadence. The outer repo pins an exact `user/` commit via the `user` gitlink.
- **Pointer-bump convention.** Routine content changes → **do not** bump the pin (leave it stale; harmless). At the **end of a cross-repo feature** → bump the pin once to the finished `user/` commit. Pin a commit reachable from `user/`'s published `main` (prefer the merge-to-main commit, not a squash-away branch tip), and **push `user/` before the outer repo** (superproject references a child SHA that must already exist upstream). The pin is dev-side coordination only — production pulls `user/` via the content webhook independently.
- **`M user` / `m user` is normal.** `M` = pin differs from `user/` HEAD (bump pending/intentional). `m` = submodule working tree dirty (e.g. local-testing `config/site.yaml`). Neither is an error — do not "fix" them by committing the gitlink or the `site.yaml`.
- **Worktrees for parallel work — use the make targets, don't do it by hand.** `make worktree-new NAME=<feature>` (from the main checkout) creates the outer worktree off `main`, initialises its own `user/` submodule, branches both, and starts an **isolated** dev server (own container name + auto-assigned port `8090+`, persisted in a git-ignored `.worktree-env` so every `make`/compose command in that worktree targets its own server). `make worktree-rm NAME=<feature>` tears it down cleanly (compose down → `submodule deinit``worktree remove``prune`) — skipping the deinit is what leaves orphaned `.worktrees/` dirs. Worktrees live under `.worktrees/` (excluded via `.git/info/exclude`). A fresh worktree's `user/` is empty until the submodule init runs, and `M user`/`m user` is normal (see above) — do not "fix" either. To add a commit to `main` while the main checkout is on another branch, use a throwaway `main` worktree rather than `git checkout main`.
## 1. Environment modes
### Rule: do not switch modes during development
**Never toggle between development and production mode mid-session.** If a caching or config issue appears, fix it at the application level (plugin, template logic) rather than temporarily flipping a mode flag to work around it. Mode switches introduce inconsistent state and make bugs harder to reproduce.
### Development mode (current)
Active settings in `user/config/system.yaml`:
| Setting | Dev value | Why |
|---|---|---|
| `twig.cache` | `false` | Theme file edits take effect immediately; no stale compile errors |
With these settings, Grav rebuilds templates on every request. This is intentionally slower but means you never need to flush cache after editing a `.html.twig` file.
### Production mode (per-environment override)
Production needs different Twig settings than dev, but **never change the
committed `user/config/system.yaml`** — `twig.cache: false` (and `debug`/
`auto_reload: true`) are the *intended dev values*, and committing prod values
there breaks local development for everyone.
Instead, prod values are a **per-environment override** deployed to the server
only, via Grav's per-environment config (`environment://config`, keyed on the
request hostname):
| Setting | Dev (committed) | Prod (override) | Why prod differs |
|---|---|---|---|
| `twig.cache` | `false` | `true` | Compile templates once and reuse |
| `twig.debug` | `true` | `false` | No debug functions in prod |
| `twig.auto_reload` | `true` | `false` | Don't stat templates every request |
- **Source of truth:** `deploy/env/prod/system.yaml` (version-controlled).
- **Deploy:** `make remote-apply-env-prod` — writes it to
`<webroot>/user/env/<hostname>/config/system.yaml` and clears cache. It
deep-merges over the committed `system.yaml`.
- **Not synced by content:** `user/env/` is outside the content repo's tracked
folders, so `content-push` / git-sync / `remote-fetch-content` do **not**
restore it. **Re-run `make remote-apply-env-prod` after any fresh install.**
- The hostname segment defaults to `REMOTE_HOST`; override with `WEB_HOST` in
`.env.<env>` if Grav sees a different host than the SSH host.
> **⚠️ Once `user/env/<hostname>/` exists, Grav's Admin saves ALL config there.**
> Creating the env override dir has a site-wide side effect: Grav's Admin panel
> writes **every** config change (system *and* plugin) into the active
> environment's config tree — e.g. editing a plugin on prod saves to
> `user/env/intotheeast.com/config/plugins/<name>.yaml`, **not**
> `user/config/plugins/<name>.yaml`. Consequences you must remember:
> - Config edited via **Admin on the server is server-only**: `user/env/` is
> outside the content repo's tracked folders, so it is **not committed** and
> **not synced by git-sync** (which syncs only `pages`/`config`/`themes`).
> Good for secrets — `git-sync.yaml` (token) safely lives at the env path —
> but it means prod Admin config edits silently do **not** reach Gitea/local.
> - When reading/writing server config, check **both** `user/config/...` and
> `user/env/<host>/config/...` (env wins). Server tooling must search the env
> path first — see `scripts/git-sync-toggle.sh` and `make remote-diag`.
> - Repo-authored config (`user/config/...` via `make content-push`) still
> applies everywhere; the env tree only holds per-host overrides + Admin-on-
> server edits. Full details: `docs/working/git-sync-notes.md`.
**Pre-launch smoke test required:** with the prod override applied, submit one
post via `/post` and confirm the entry appears in the trip page feed
immediately. This verifies the cache-on-save plugin (BUG-001 fix) works
correctly with caching enabled.
### What the cache-on-save plugin handles
The custom plugin at `user/plugins/cache-on-save/` clears Grav's page-tree cache on every `new-entry` form submission. This ensures new posts appear in the tracker feed immediately in both modes — it does not depend on whether Twig caching is on or off.
## 2. Local development setup
Full setup guide: [`docs/guides/local-setup.md`](docs/guides/local-setup.md)
### Superpowers skill paths
Specs: `docs/working/specs/YYYY-MM-DD-<topic>-design.md`
Plans: `docs/working/plans/YYYY-MM-DD-<topic>.md`
The brainstorming and writing-plans skills default to `docs/superpowers/`; these lines override that default.
### Plan status convention
Every plan in `docs/working/plans/` must have a `**Status:**` line immediately after the title heading:
| Status | Meaning |
|---|---|
| `📋 Not started` | Plan written; work not yet begun |
| `🔄 In progress — <note>` | Actively being worked on |
| `⏸️ Deferred — <reason>` | Intentionally postponed |
| `✅ Complete (YYYY-MM-DD)` | Done |
| `❌ Abandoned — <reason>` | Won't implement |
**When asked what's open:** surface `Not started` and `In progress` plans. Show `Deferred` plans but label them clearly. Omit `Complete` and `Abandoned` unless explicitly asked.
**When finishing a plan:** update the `**Status:**` field in the plan file to `✅ Complete (YYYY-MM-DD)` before closing the session. This applies whether execution was done by Claude directly, via the superpowers:executing-plans skill, or via superpowers:subagent-driven-development.
## 3. Testing
**The dev server must be running** (`make start`) — every suite drives the live site over HTTP.
| Command | Scope |
|---|---|
| `make test` | Everything: `test-config``test-post``test-ui` |
| `make test-config` | Form/config sanity via `scripts/test-form-config.sh` |
| `make test-post` | End-to-end post submission via `scripts/test-post.sh` |
| `make test-ui` | Playwright suite (`npx playwright test`) |
- **Test account is automatic.** `test-post` and `test-ui` depend on `test-account`, which creates a `testrunner` admin (password `Testpass1234`) inside the container if absent. It is git-ignored — never commit it, and keep the password free of shell/Make/URL-special characters since several consumers interpolate it.
- **Playwright layout:** config at `playwright.config.js`, specs under `tests/ui/` (`a11y`, `auth`, `dailies`, `gpx`, `home`, `maps`, `nav`, `post`, `stories`, `trip`), shared helpers in `tests/ui/helpers.js`, global setup/teardown in `tests/`.
- **Auth is a dependency project.** `auth.setup.js` runs first and writes `tests/.auth/user.json`; the `chromium` project reuses it as `storageState`. Don't add per-test logins.
- **Base URL:** defaults to `http://localhost:8081`; override with `GRAV_BASE_URL` (required when testing a worktree's isolated server on `8090+`).
- Single spec / focused run: `npx playwright test tests/ui/maps` (add `--headed` to watch). `retries: 0` and screenshots-on-failure only, so a failure is a real failure.
- **`window.tripMap` / `window.homeMap` are asserted by the map specs** — any surface using the `entry-map` partial must keep assigning them (see "One map path" above).
+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"; \
+54 -6
View File
@@ -10,10 +10,29 @@ Two git repos:
| Repo | Contents | Location | | Repo | Contents | Location |
|------|----------|----------| |------|----------|----------|
| `intotheeast.com` (this repo) | Docker setup, Makefile, scripts, plugins.txt | `./` | | `intotheeast.com` (this repo) | Docker setup, Makefile, scripts, tests, docs, plugins.txt | `./` |
| `intotheeast.com-content` | Site config, pages, theme | `user/` (standalone git repo) | | `intotheeast.com-content` | Site config, pages, theme | `user/` (git submodule) |
The `user/` directory is a standalone git repo — its changes are pushed/pulled independently to Gitea. The Grav Sync plugin on the server automatically pulls from Gitea when content is pushed. `user/` is tracked by this repo as a **git submodule** — it has its own Gitea remote and its own push/pull cadence (`make content-push` / `make content-pull`), and this repo pins an exact `user/` commit. The Git Sync plugin on the server pulls from Gitea automatically when content is pushed. A persistent `M user` / `m user` in `git status` is normal, not a problem; see [`docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md`](docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md).
### Folder map
| Path | Contents |
|------|----------|
| `user/` | Site content, config, pages, theme (the content submodule) |
| `user/themes/intotheeast/js/src/` | JS sources — esbuild inputs; run `make build-assets` after editing. Note `js/maplibre-utils.js` and `js/nav.js` are *also* sources, despite sitting beside the generated bundles |
| `deploy/env/` | Per-environment Grav config overrides (e.g. prod Twig settings) |
| `deploy/patches/` | Tracked patches for third-party plugins, which are otherwise git-ignored |
| `scripts/` | Server install and maintenance scripts |
| `tests/` | Playwright suite — see [`docs/reference/testing.md`](docs/reference/testing.md) |
| `php/` | Local PHP ini overrides |
| `docs/` | All project documentation — start at [`docs/README.md`](docs/README.md) |
| `docs/guides/` | Operational how-tos (posting, story authoring, GPX, trip switching, setup, deploy cycle) |
| `docs/reference/` | Stable facts: architecture, design system, testing |
| `docs/solutions/` | Write-ups of bugs and workflow traps already hit, with YAML frontmatter (`module`, `tags`, `problem_type`) |
| `docs/working/` | Specs, plans, backlog, QA — work in flight |
| `CONCEPTS.md` | Shared domain vocabulary (Trip, Entry, Story, Active Trip) |
| `CLAUDE.md` | Rules and gotchas loaded into every Claude Code session |
--- ---
@@ -82,13 +101,42 @@ make content-push # push local user/ commits → Gitea
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
| `make setup` | First run: build → start → install plugins → fix perms |
| `make start` | Start the local Docker container | | `make start` | Start the local Docker container |
| `make stop` | Stop the local Docker container | | `make stop` | Stop the local Docker container |
| `make setup` | Start container and install all plugins from plugins.txt | | `make install-plugins` | (Re)install plugins from plugins.txt, then apply local plugin patches |
| `make install-plugins` | (Re)install plugins from plugins.txt in the local container | | `make apply-plugin-patches` | Idempotently re-apply the patches in `deploy/patches/` |
| `make content-push` | Push local `user/` commits to Gitea | | `make fix-perms` | Reset file ownership inside the container |
| `make build-assets` | Run esbuild over `user/themes/intotheeast/js/src/`**required** after editing any JS source |
| `make content-push` | Push local `user/` commits to Gitea (triggers the production pull) |
| `make content-pull` | Pull latest `user/` content from Gitea | | `make content-pull` | Pull latest `user/` content from Gitea |
### Testing
| Command | Description |
|---------|-------------|
| `make test` | Everything: `test-config``test-post``test-ui` |
| `make test-config` | Form/config sanity checks |
| `make test-post` | End-to-end post submission |
| `make test-ui` | Playwright suite |
Details and conventions: [`docs/reference/testing.md`](docs/reference/testing.md).
### Demo content and imports
| Command | Description |
|---------|-------------|
| `make demo-load` | Copy every fixture trip under `user/docs/demo/trips/` into the pages tree (add a fixture by dropping a folder there — no Makefile edit needed) |
| `make demo-reset` | Remove those demo trips from the pages tree and clear cache |
| `make pixelfed-import` | Import posts from Pixelfed via `scripts/pixelfed-import.py` |
### Parallel work
| Command | Description |
|---------|-------------|
| `make worktree-new NAME=<feature>` | Create a worktree with its own `user/` checkout and an isolated dev server on port `8090+` |
| `make worktree-rm NAME=<feature>` | Tear one down cleanly (compose down → submodule deinit → worktree remove → prune) |
### Remote credentials ### Remote credentials
| Command | Description | | Command | Description |
+10
View File
@@ -13,6 +13,13 @@ services:
volumes: volumes:
- ./user:/var/www/html/user - ./user:/var/www/html/user
- ./php/php-local.ini:/usr/local/etc/php/conf.d/php-local.ini - ./php/php-local.ini:/usr/local/etc/php/conf.d/php-local.ini
# Grav stages form uploads in tmp/forms/<session>/ before the submit moves
# them into the page folder. The image declares /var/www/html as a VOLUME,
# so without this it lives in an ANONYMOUS volume that is discarded on any
# `docker compose up` that recreates the container — dropping the photos of
# a post that was filled in but not yet submitted. Naming it gives the
# staging area its own lifecycle.
- grav_tmp:/var/www/html/tmp
restart: unless-stopped restart: unless-stopped
travel-memories: travel-memories:
@@ -24,3 +31,6 @@ services:
- ./user/pages:/app/pages - ./user/pages:/app/pages
env_file: .env env_file: .env
user: "${UID}:${GID}" user: "${UID}:${GID}"
volumes:
grav_tmp:
+3 -2
View File
@@ -8,14 +8,15 @@
- [Switching to a new trip](guides/trip-switching.md) - [Switching to a new trip](guides/trip-switching.md)
- [Rebuilding local dev from scratch](guides/local-setup.md) - [Rebuilding local dev from scratch](guides/local-setup.md)
**Checking project status?** → [`working/`](working/) **Checking project status?** → [`working/`](working/) — [what's in there + the plan status convention](working/README.md)
- [Backlog](working/backlog.md) - [Backlog](working/backlog.md)
- [Production todo](working/production-todo.md) - [Bugs and fixes](working/bugs-and-fixes.md)
- [QA results](working/qa/results.md) - [QA results](working/qa/results.md)
**Design or architecture decisions?** → [`reference/`](reference/) **Design or architecture decisions?** → [`reference/`](reference/)
- [Design system](reference/design-system.md) - [Design system](reference/design-system.md)
- [Architecture overview](reference/architecture.md) - [Architecture overview](reference/architecture.md)
- [Testing](reference/testing.md)
--- ---
+46
View File
@@ -41,6 +41,52 @@ servers use. See `docs/solutions/tooling-decisions/upgrade-local-grav-core-rebui
--- ---
## The env override tree (`user/env/<host>/`)
Prod needs different Twig settings than dev. These are **never** committed to
`user/config/system.yaml``twig.cache: false` and `debug`/`auto_reload: true`
are the *intended dev values*, and committing prod values there breaks local
development for everyone. Instead they ship as a per-environment override via
Grav's `environment://config`, keyed on the request hostname.
| Setting | Dev (committed) | Prod (override) | Why prod differs |
|---|---|---|---|
| `twig.cache` | `false` | `true` | Compile templates once and reuse |
| `twig.debug` | `true` | `false` | No debug functions in prod |
| `twig.auto_reload` | `true` | `false` | Don't stat templates every request |
- **Source of truth:** `deploy/env/prod/system.yaml` (version-controlled).
- **Deploy:** `make remote-apply-env-prod` — writes it to
`<webroot>/user/env/<hostname>/config/system.yaml` and clears cache. It
deep-merges over the committed `system.yaml`.
- **Hostname segment** defaults to `REMOTE_HOST`; override with `WEB_HOST` in
`.env.<env>` if Grav sees a different host than the SSH host.
- **Not restored by anything.** `user/env/` is outside the content repo's tracked
folders, so `content-push` / git-sync / `remote-fetch-content` do **not** bring
it back. **Re-run `make remote-apply-env-<env>` after any fresh install.**
### Side effect: Admin writes ALL config into the env tree
Once `user/env/<hostname>/` exists, Grav's Admin saves **every** config change
(system *and* plugin) there — e.g. editing a plugin on prod writes
`user/env/intotheeast.com/config/plugins/<name>.yaml`, **not**
`user/config/plugins/<name>.yaml`. Consequences:
- Config edited via **Admin on the server is server-only**: the env tree is not
committed and not synced by git-sync (which syncs only `pages`/`config`/
`themes`), so prod Admin edits silently never reach Gitea or local. This is
*good* for secrets — `git-sync.yaml` (token), the JWT and CSRF salt safely
live there — but it means config drift is invisible to the repo.
- When reading or writing server config, check **both** `user/config/…` and
`user/env/<host>/config/…` (env wins). Server tooling must search the env path
first — see `scripts/git-sync-toggle.sh` and `make remote-diag`.
- Repo-authored config (`user/config/…` via `make content-push`) still applies
everywhere; the env tree holds only per-host overrides + Admin-on-server edits.
Full details: `docs/working/git-sync-notes.md`.
---
## Phase 0 — Local (author + prove the change) ## Phase 0 — Local (author + prove the change)
1. Make the change in the repo: 1. Make the change in the repo:
+23 -1
View File
@@ -30,7 +30,7 @@ The GPX manager at `/gpx-manager` requires admin login (redirects to login form
Drop the file directly into the trip folder and push: Drop the file directly into the trip folder and push:
```bash ```bash
cp your-route.gpx /path/to/user/pages/01.trips/japan-korea-2026/ cp your-route.gpx /path/to/user/pages/01.trips/denmark-2026/
make content-push make content-push
``` ```
@@ -83,3 +83,25 @@ GPX files are registered as a valid media type in `user/config/media.yaml`, so G
``` ```
No manual linking is needed — upload and it appears. No manual linking is needed — upload and it appears.
---
## How the manager is wired
| Piece | Detail |
|---|---|
| Page | `user/pages/03.gpx-manager/` |
| Template | `user/themes/intotheeast/templates/gpx-manager.html.twig` |
| Auth | Login plugin, via `access.admin.login: true` in the page frontmatter — renders the login form when unauthenticated |
| API | Grav API v1 with **session cookie** auth (`session_enabled: true` in `user/plugins/api/api.yaml`) |
API calls the page makes:
```
GET /api/v1/pages{route}/media # list
POST /api/v1/pages{route}/media # upload (multipart)
DELETE /api/v1/pages{route}/media/{filename} # delete
```
**Upload gotcha:** the selected file is sliced into a plain `Blob` before `FormData.append`, so the third argument is always honoured as the filename. Appending the original `File` lets the browser keep the unslugified name and the slugification is silently ignored.
+3 -3
View File
@@ -39,8 +39,8 @@ Two ways to post: the **mobile form** at `/post` (quick, phone-friendly) or the
``` ```
Browser → /post (post-form.md) Browser → /post (post-form.md)
└─ Grav Form plugin validates fields └─ Grav Form plugin validates fields
└─ cache-on-save injects parent from site.active_trip
└─ add-page-by-form plugin └─ add-page-by-form plugin
├─ reads pageconfig.parent (/trips/<active_trip>/dailies)
├─ writes user/pages/01.trips/<active_trip>/01.dailies/<slug>/entry.md ├─ writes user/pages/01.trips/<active_trip>/01.dailies/<slug>/entry.md
└─ moves uploaded photos into the page folder └─ moves uploaded photos into the page folder
└─ cache-on-save plugin └─ cache-on-save plugin
@@ -53,7 +53,7 @@ Example: `2026-07-20-0930-first-day-in-kyoto.entry`
**Entry folder structure:** **Entry folder structure:**
``` ```
user/pages/01.trips/japan-korea-2026/01.dailies/ user/pages/01.trips/denmark-2026/01.dailies/
└─ 2026-07-20-0930-first-day-in-kyoto.entry/ └─ 2026-07-20-0930-first-day-in-kyoto.entry/
├─ entry.md ← frontmatter + markdown body ├─ entry.md ← frontmatter + markdown body
├─ temple.jpg ← hero image (or set hero_image in frontmatter) ├─ temple.jpg ← hero image (or set hero_image in frontmatter)
@@ -103,7 +103,7 @@ Every entry supports these frontmatter fields:
## Troubleshooting ## Troubleshooting
**Entry doesn't appear in feed after submit** **Entry doesn't appear in feed after submit**
→ Check that `active_trip` in `user/config/site.yaml` matches the parent in `user/pages/02.post/post-form.md` (`pageconfig.parent`). If they're out of sync, entries go to the wrong folder. See [trip switching guide](trip-switching.md). → Check `active_trip` in `user/config/site.yaml` — the write target is derived from it at submit time, so a wrong value sends entries to the wrong trip's dailies. See [trip switching guide](trip-switching.md).
**Get Weather button shows an error** **Get Weather button shows an error**
→ Fill in Lat/Lng first (tap Get Location or enter manually). Open-Meteo requires coordinates. → Fill in Lat/Lng first (tap Get Location or enter manually). Open-Meteo requires coordinates.
+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.
+16 -25
View File
@@ -1,13 +1,14 @@
# Switching to a New Trip # Switching to a New Trip
When you start a new trip, **two files must be updated together** — if only one is changed, new entries will be posted to the wrong folder silently (no error, wrong trip). The active trip lives in **one** place: `user/config/site.yaml``active_trip`. Set it, create the new page tree, push.
> **Changed 2026-07:** this used to require editing two files in lockstep (`site.yaml` **and** `post-form.md` → `pageconfig.parent`), and they silently desynced. The `cache-on-save` plugin now derives the write target from `site.active_trip` at submit time (`onFormValidationProcessed` → `setData('parent', …)`), so `post-form.md` no longer carries a `parent` at all. **Do not re-add one** — it would override the derived target and reintroduce the desync.
--- ---
## Checklist ## Checklist
- [ ] Update `user/config/site.yaml``active_trip` - [ ] Update `user/config/site.yaml``active_trip`
- [ ] Update `user/pages/02.post/post-form.md``pageconfig.parent`
- [ ] Create the new trip page tree (see below) - [ ] Create the new trip page tree (see below)
- [ ] Run `make content-push` to push the changes to production - [ ] Run `make content-push` to push the changes to production
@@ -15,38 +16,28 @@ When you start a new trip, **two files must be updated together** — if only on
## Step 1 — Update site.yaml ## Step 1 — Update site.yaml
In `user/config/site.yaml`, set `active_trip` to the new trip slug: In `user/config/site.yaml`, set `active_trip` to the new trip's **route**:
```yaml ```yaml
active_trip: japan-korea-2026 # ← change this active_trip: /trips/denmark-2026 # ← change this
``` ```
The slug must exactly match the folder name under `user/pages/01.trips/`. The final segment must exactly match the folder name under `user/pages/01.trips/`.
You can also set this from Admin → Configuration → Site → **Active Trip** (a page-picker rooted at `/trips`; blueprint at `user/blueprints/config/site.yaml`).
> `system.yaml` → `home.alias` is permanently `/home` (the real home page) and does **not** change when switching trips.
--- ---
## Step 2 — Update post-form.md ## Step 2 — Create the new trip page tree
In `user/pages/02.post/post-form.md`, set `pageconfig.parent` to the new dailies path:
```yaml
pageconfig:
parent: /trips/japan-korea-2026/dailies # ← change this
```
**Why both?** Grav's config and page frontmatter are static YAML — no variable substitution is possible, so `post-form.md` can't read from `site.yaml` automatically. They must match manually.
**What breaks if they're out of sync:** `active_trip` controls which trip page is featured on the home page and trip page. `pageconfig.parent` controls where new entries land. If they differ, new posts go to the old trip's dailies folder while the home page shows the new trip — entries appear to vanish.
---
## Step 3 — Create the new trip page tree
Create the two content subfolders under `user/pages/01.trips/<new-slug>/`: Create the two content subfolders under `user/pages/01.trips/<new-slug>/`:
``` ```
user/pages/01.trips/japan-korea-2026/ user/pages/01.trips/denmark-2026/
├─ trip.md ← title, date_start, date_end, cover_image, album_url ├─ trip.md ← title, date_start, date_end, cover_image, album_url
├─ *.gpx ← route files (optional; page media, auto-detected)
├─ 01.dailies/ ├─ 01.dailies/
│ └─ dailies.md ← inert container: template: default, routable: false, visible: false │ └─ dailies.md ← inert container: template: default, routable: false, visible: false
└─ 04.stories/ └─ 04.stories/
@@ -55,13 +46,13 @@ user/pages/01.trips/japan-korea-2026/
Copy these files from an existing trip and update the frontmatter (especially `title` and `date_start` in `trip.md`). Copy these files from an existing trip and update the frontmatter (especially `title` and `date_start` in `trip.md`).
> The `02.map/` and `03.stats/` standalone views were retired (2026-07-04) — the map and stats render inline on the trip page. The `01.dailies/` and `04.stories/` folders now exist only as data containers holding the entry/story children; their own routes are non-routable. > The `02.map/` and `03.stats/` standalone views were retired (2026-07-04) — the map and stats render inline on the trip page. The `01.dailies/` and `04.stories/` folders now exist only as data containers holding the entry/story children; their own routes are non-routable. Do **not** recreate `02.map/` or `03.stats/`.
Fields in `trip.md` to update: Fields in `trip.md` to update:
| Field | Example | Notes | | Field | Example | Notes |
|---|---|---| |---|---|---|
| `title` | `Japan & Korea 2026` | Displayed in nav and trip header | | `title` | `Denmark 2026` | Displayed in nav and trip header |
| `date_start` | `2026-07-15` | Used for "X days on the road" stat | | `date_start` | `2026-07-15` | Used for "X days on the road" stat |
| `date_end` | *(leave blank while travelling)* | Set when you return | | `date_end` | *(leave blank while travelling)* | Set when you return |
| `cover_image` | `cover.jpg` | Shown on the trips listing page | | `cover_image` | `cover.jpg` | Shown on the trips listing page |
@@ -69,7 +60,7 @@ Fields in `trip.md` to update:
--- ---
## Step 4 — Push ## Step 3 — Push
```bash ```bash
make content-push make content-push
+97 -21
View File
@@ -8,8 +8,8 @@ How the intotheeast site hangs together.
| Layer | Technology | Notes | | Layer | Technology | Notes |
|---|---|---| |---|---|---|
| CMS | Grav 2.0.4 stable | Flat-file PHP CMS; no database. Server upgrades in place via `bin/gpm self-upgrade` | | CMS | Grav 2.0.7 stable | Flat-file PHP CMS; no database. Server upgrades in place via `bin/gpm self-upgrade` |
| Admin | Admin2 v2.0.10 | Plugin slug: `admin2` (not `admin`) | | Admin | Admin2 v2.0.12 | Plugin slug: `admin2` (not `admin`) |
| GPM channel | `stable` | Authoritative in `user/config/system.yaml``gpm.releases`; `GRAV_CHANNEL=production` in compose is cosmetic | | GPM channel | `stable` | Authoritative in `user/config/system.yaml``gpm.releases`; `GRAV_CHANNEL=production` in compose is cosmetic |
| Container | Docker (`getgrav/grav` base + custom `Dockerfile`) | Grav 2.0 baked in at build time | | Container | Docker (`getgrav/grav` base + custom `Dockerfile`) | Grav 2.0 baked in at build time |
| PHP session | `session.save_path = /tmp` | Set in `php/php-local.ini` | | PHP session | `session.save_path = /tmp` | Set in `php/php-local.ini` |
@@ -29,15 +29,19 @@ Browser POST /post
├─ Grav Form plugin (built-in) ├─ Grav Form plugin (built-in)
│ └─ validates required fields; handles file uploads │ └─ validates required fields; handles file uploads
├─ add-page-by-form (third-party, patched) ├─ cache-on-save (custom) — onFormValidationProcessed, runs BEFORE the write
│ ├─ setData('parent', …) ← derived from site.active_trip
│ └─ sets pageconfig.overwrite_mode: edit when the hidden edit_path is filled,
│ false when empty (create a fresh dated folder)
├─ add-page-by-form (third-party, patched — see deploy/patches/)
│ └─ reads post-form.md config: │ └─ reads post-form.md config:
│ ├─ pageconfig.parent → target folder (e.g. /trips/japan-korea-2026/dailies)
│ ├─ pageconfig.slug_field → slug from date + title │ ├─ pageconfig.slug_field → slug from date + title
│ └─ pagefrontmatter → template: entry, published: true │ └─ pagefrontmatter → template: entry
│ └─ writes entry.md to user/pages/01.trips/<trip>/01.dailies/<slug>.entry/ │ └─ writes entry.md to user/pages/01.trips/<trip>/01.dailies/<slug>.entry/
│ └─ moves uploaded photos into the page folder │ └─ moves uploaded photos into the page folder
└─ cache-on-save (custom, user/plugins/cache-on-save/) └─ cache-on-save (again, post-write)
└─ calls $grav['cache']->deleteAll() on every new-entry form submission └─ calls $grav['cache']->deleteAll() on every new-entry form submission
└─ ensures entries appear in feed immediately in both dev and prod mode └─ ensures entries appear in feed immediately in both dev and prod mode
``` ```
@@ -49,17 +53,36 @@ Other notable plugins:
| `login` | Auth for /post and /gpx-manager | | `login` | Auth for /post and /gpx-manager |
| `api` (Grav API v1) | Used by /gpx-manager to list/upload/delete GPX files | | `api` (Grav API v1) | Used by /gpx-manager to list/upload/delete GPX files |
| `admin2` | Admin panel at /admin | | `admin2` | Admin panel at /admin |
| `story-blocks` (custom) | Storytelling shortcode blocks for long-form stories (needs `shortcode-core`) |
| `entry-actions` (custom) | Owner-only, active-trip-scoped journal entry actions (delete) via the Grav API |
### Plugin management model ### Plugin management model
Three categories, by how each plugin is installed and maintained: Three categories, by how each plugin is installed and maintained:
1. **GPM-managed** (`plugins.txt``make install-plugins`): the marketplace plugins, including `login`, `form`, `admin2`, `api`, `flex-objects`, shortcodes, etc. As of the 2.0.4 upgrade, `admin2`/`api`/`flex-objects` moved into this category — they were previously hand-extracted from the core bundle. Update with `bin/gpm update` (`make remote-update-plugins-<env>` on servers). 1. **GPM-managed** (`plugins.txt``make install-plugins`): the marketplace plugins, including `login`, `form`, `admin2`, `api`, `flex-objects`, shortcodes, etc. As of the 2.0.4 upgrade, `admin2`/`api`/`flex-objects` moved into this category — they were previously hand-extracted from the core bundle. Update with `bin/gpm update` (`make remote-update-plugins-<env>` on servers).
2. **Custom, in-repo** (`user/plugins/` allowlisted in `user/.gitignore`): `cache-on-save`, `story-blocks`. Versioned in the user repo. 2. **Custom, in-repo** (`user/plugins/` allowlisted in `user/.gitignore`): `cache-on-save`, `story-blocks`, `entry-actions`. Versioned in the user repo.
3. **Remote-only**: `git-sync` — installed and configured only on servers, **never** in `plugins.txt`, and disabled during upgrades. 3. **Remote-only**: `git-sync` — installed and configured only on servers, **never** in `plugins.txt`, and disabled during upgrades.
--- ---
## Asset pipeline
`make build-assets` runs the theme's `npm run build` (esbuild) in a throwaway `node:20-alpine` container, as the host uid so outputs land in the tracked theme tree owned by you rather than root.
| Source | → Output |
|---|---|
| `js/src/main.js` | `js/main.js` + `css-compiled/main.css` + `fonts/` (font files via the `woff2` loader) |
| `js/src/map.js` | `js/map.js` + `css-compiled/map.css` — bundles `maplibre-gl`, `@mapbox/togeojson`, and `js/maplibre-utils.js` |
| `js/src/feed-actions.js` | `js/feed-actions.js` |
| `js/src/trip-publish.js` | `js/trip-publish.js` |
| `js/src/post-form.js` | `js/post/` (ESM + code splitting) + `css-compiled/post-form.css` |
| `scripts/gen-weather-icons.js` | `templates/partials/weather-icons.html.twig` (Lucide SVGs inlined into a Twig map) |
**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.
---
## Template hierarchy ## Template hierarchy
All page templates extend `base.html.twig`: All page templates extend `base.html.twig`:
@@ -77,17 +100,64 @@ templates/
The standalone `dailies.html.twig`, `map.html.twig`, `stats.html.twig` and `stories.html.twig` view templates were **removed** in the 2026-07-04 standalone-page cleanup — the trip page (`trip.html.twig`) consolidated the feed, inline map, and inline stats. The standalone `dailies.html.twig`, `map.html.twig`, `stats.html.twig` and `stories.html.twig` view templates were **removed** in the 2026-07-04 standalone-page cleanup — the trip page (`trip.html.twig`) consolidated the feed, inline map, and inline stats.
Partials live in `templates/partials/` (plus macros in `templates/macros/`). Key partials: `base.html.twig` (site shell extended by all page templates), `entry-map.html.twig` (shared map column + `initEntryMap` call, used by trip + home), `trip-feed-col.html.twig` (feed column chrome, shared by trip + home), `home-predeparture.html.twig`, `entry-journal.html.twig` / `entry-story.html.twig` (feed cards), and `weather-icons.html.twig`. Site nav (in `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`.
### Shared partial contracts
Two partials are included by **both** `trip.html.twig` and the active branch of `home.html.twig`, via `{% include … with {…} only %}`. The `only` keyword means every value must be passed explicitly — the tables below are the contracts. The rules that govern them (single map path, required map globals, never hand-edit bundles) live in `CLAUDE.md`; these are the parameter details.
#### `entry-map.html.twig`
Renders the `.home-map-col` column (map div `#{{ map_id }}` + fullscreen button) and, when `entries` is non-empty, a thin `<script>` assigning `window.{{ map_global }}` from `initEntryMap`. Callers resolve header values (use_gpx / autoconnect) and pass them in.
| Parameter | Type | Trip passes | Home passes |
|---|---|---|---|
| `map_id` | string | `'trip-map'` | `'home-map'` |
| `map_global` | string | `'tripMap'` | `'homeMap'` |
| `entries` | array | `[{lat, lng, slug, title, url, type?, force_connect, ...}]` | same |
| `card_prefix` | string | `'entry-'` | `'entry-'` |
| `story_markers` | bool | `true` (diamond markers) | `false` |
| `gpx_urls` | array | `gpx_urls` | `home_gpx_urls` |
| `use_gpx` | bool | `page.header.use_gpx ?? true` | derived from `trip.header` |
| `autoconnect` | string | `page.header.autoconnect ?? 'on'` | derived from `trip.header` |
| `gpx_source_prefix` | string | `'gpx'` | `'home-gpx'` |
| `journey_id` | string | `'trip-journey'` | `'home-journey'` |
#### `trip-feed-col.html.twig`
The column **beside** the map: date-range header, filter bar, stats/cycling panels, feed loop.
| Parameter | Type | Trip passes | Home-active passes |
|---|---|---|---|
| `trip_page` | Page | `page` | `trip` |
| `all_items` | array | sorted by date, flag 4 (oldest→newest) | sorted by date, flag 3 (newest→oldest) |
| `journal_entries` | array | dailies children | dailies children |
| `journal_count` / `story_count` | int | counts | counts |
| `has_gpx` | bool | `has_gpx` | `home_gpx_urls\|length > 0` |
| `gpx_urls` | array | `gpx_urls` | `home_gpx_urls` |
| `gps_points` | array | `gps_points` | `gps_points` |
| `show_sort` | bool | `true` | `false` (home keeps its own feed order) |
| `trip_header_extras` | bool | `true` | not passed (defaults `false`) |
`trip_header_extras` gates the trip-page-only header block (one-liner `.home-trip-tagline`, expandable `.trip-header-desc`, `.trip-header-banner` cover strip) rendered between the counts and the filter bar. `home.html.twig` omits it so those extras never leak onto the home route.
**Sibling:** `home-predeparture.html.twig` is the home-only "Coming soon" landing state, taking only `trip_page`. `home.html.twig` picks it with `{% if all_items|length == 0 %}``home-predeparture` `{% else %}``trip-feed-col`. Keep `trip-feed-col` single-purpose — do **not** fold the pre-departure branch back into it.
**Stats/cycling JS glue:** the partial emits an inline `DOMContentLoaded` script calling `window.initTripStats({ gpxUrls, gpsPoints, hasGpx })` — one shared function in `js/src/main.js`. It no-ops when `#stat-distance` is absent, populates exact distance + cycling stats from GPX, and falls back to a `~`-prefixed haversine estimate (or `—` for `<2` points) when there is no GPX. It depends on `window.MapUtils` from `map.js` (loaded in the `bottom` asset group on both pages).
> History: the map setup replaced an older three-variant arrangement (a `feed-map.html.twig` partial with its own inline init, plus a full-page `map.html.twig`), deleted in the 2026-07-04 standalone-page cleanup.
--- ---
## Trip entity structure ## Trip entity structure
The site is organized around Trip entities. The active trip is set in `user/config/site.yaml``active_trip`. The site is organized around Trip entities. The active trip is set in `user/config/site.yaml``active_trip`, as a **route** (e.g. `/trips/denmark-2026`), not a bare slug.
``` ```
user/pages/01.trips/ user/pages/01.trips/
└─ japan-korea-2026/ └─ denmark-2026/
├─ trip.md ← template: trip; title, date_start, cover_image, album_url ├─ trip.md ← template: trip; title, date_start, cover_image, album_url
├─ *.gpx ← GPX route files (served as page media; auto-detected by trip.html.twig) ├─ *.gpx ← GPX route files (served as page media; auto-detected by trip.html.twig)
├─ 01.dailies/ ← journal entry children (container .md is routable:false) ├─ 01.dailies/ ← journal entry children (container .md is routable:false)
@@ -126,18 +196,20 @@ Rendered as route polyline on map
``` ```
1. User fills /post form and taps Submit 1. User fills /post form and taps Submit
2. Grav Form plugin validates: title and content required 2. Grav Form plugin validates: title and content required
3. add-page-by-form reads post-form.md: 3. cache-on-save (onFormValidationProcessed) injects the write target:
pageconfig.parent: /trips/japan-korea-2026/dailies parent ← derived from site.active_trip (e.g. /trips/denmark-2026/dailies)
pageconfig.slug: {date}-{title|slugify} overwrite_mode ← edit if edit_path filled, else false
pagefrontmatter: template: entry, published: true 4. add-page-by-form reads post-form.md:
4. New page written to: pageconfig.slug_field: date,title
user/pages/01.trips/japan-korea-2026/01.dailies/ pagefrontmatter: template: entry
5. New page written to:
user/pages/01.trips/denmark-2026/01.dailies/
└─ 2026-07-20-0930-first-day-in-kyoto.entry/ └─ 2026-07-20-0930-first-day-in-kyoto.entry/
└─ entry.md └─ entry.md
5. Photos moved into the same folder 6. Photos moved into the same folder
6. cache-on-save calls $grav['cache']->deleteAll() 7. cache-on-save calls $grav['cache']->deleteAll()
7. Browser: form shows success message 8. Browser: form shows success message
8. Feed at /trips/japan-korea-2026 immediately shows new entry 9. Feed at /trips/denmark-2026 immediately shows new entry
``` ```
--- ---
@@ -146,9 +218,13 @@ Rendered as route polyline on map
| File | Purpose | | File | Purpose |
|---|---| |---|---|
| `user/config/site.yaml` | `active_trip` slug; site title/description | | `user/config/site.yaml` | `active_trip` route; site title/description |
| `user/config/system.yaml` | Twig cache, flex accounts/pages, language prefix | | `user/config/system.yaml` | Twig cache, flex accounts/pages, language prefix |
| `user/config/media.yaml` | Registers `.gpx` as a valid media type | | `user/config/media.yaml` | Registers `.gpx` as a valid media type |
| `user/plugins/api/api.yaml` | `session_enabled: true` for GPX manager auth | | `user/plugins/api/api.yaml` | `session_enabled: true` for GPX manager auth |
| `user/themes/intotheeast/css/tokens.css` | Design tokens (colors, fonts, spacing) | | `user/themes/intotheeast/css/tokens.css` | Design tokens (colors, fonts, spacing) |
| `CLAUDE.md` | Project rules and always-loaded context for Claude | | `CLAUDE.md` | Project rules and always-loaded context for Claude |
### What the `user/` repo tracks
Only `pages/`, `config/`, `accounts/`, and `themes/` are versioned in the content repo. `plugins/` and `data/` are ignored — **except** the three custom plugins, un-ignored explicitly in `user/.gitignore`. Also ignored: the test accounts, the demo-trip pages, secrets (`config/plugins/git-sync.yaml`, `config/security.yaml`, `api-private.php`), and the whole `env/` override tree. Read `user/.gitignore` for the authoritative list.
+67
View File
@@ -0,0 +1,67 @@
# Testing
Every suite drives the **live site over HTTP**, so the dev server must be running (`make start`) before any of them.
---
## Commands
| Command | Scope |
|---|---|
| `make test` | Everything: `test-config``test-post``test-ui` |
| `make test-config` | Form/config sanity via `scripts/test-form-config.sh` |
| `make test-post` | End-to-end post submission via `scripts/test-post.sh` |
| `make test-ui` | Playwright suite (`npx playwright test`) |
| `make test-account` | Creates the `testrunner` admin if absent (a dependency of `test-post` and `test-ui`) |
Focused runs bypass `make`:
```bash
npx playwright test tests/ui/maps # one suite
npx playwright test tests/ui/maps --headed # watch it
```
---
## Layout
```
playwright.config.js ← config (testDir: ./tests/ui)
tests/
├─ global-setup.js ← runs once before all projects
├─ global-teardown.js ← runs once after
├─ fixtures/
└─ ui/
├─ helpers.js ← shared helpers; import from here rather than re-rolling
├─ auth/ ← includes auth.setup.js (see below)
├─ a11y/ dailies/ gpx/ home/
├─ maps/ nav/ post/ stories/ trip/
```
---
## Config facts
| Setting | Value | Why it matters |
|---|---|---|
| `baseURL` | `process.env.GRAV_BASE_URL \|\| 'http://localhost:8081'` | Set `GRAV_BASE_URL` to test a worktree's isolated server on `8090+` |
| `retries` | `0` | A failing test is a real failure, not flake — do not paper over it with retries |
| `timeout` | `30_000` | Per test |
| `screenshot` | `only-on-failure` | Video off; artifacts stay small |
| `reporter` | `line` | |
### Auth is a dependency project
Two Playwright projects, in order:
1. **`setup`** — matches `auth.setup.js`, logs in once, writes `tests/.auth/user.json`.
2. **`chromium`** — `dependencies: ['setup']`, consumes that file as `storageState`.
So every test in `chromium` starts already authenticated. **Never add a per-test login** — it duplicates the setup project and slows the suite.
### The test account
`make test-account` creates a `testrunner` admin (via `bin/plugin login new-user`, admin type `both`) inside the container if `user/accounts/testrunner.yaml` is missing. It is git-ignored.
- Never commit it.
- Keep the password free of shell/Make/URL-special characters — it is interpolated by the Makefile, `scripts/test-post.sh`, and the Playwright setup, and a special character breaks at least one of them.
@@ -0,0 +1,203 @@
---
title: CLAUDE.md content tiering — rules stay, descriptions move out
date: 2026-07-24
category: conventions
module: documentation
problem_type: convention
component: documentation
severity: medium
applies_when:
- "Deciding whether new content belongs in CLAUDE.md or a docs/ subfolder"
- "CLAUDE.md has grown and needs a reduction pass"
- "Writing a rule that references specific file paths, bundle names, or other enumerable facts"
- "Extracting descriptive content out of CLAUDE.md into docs/reference or docs/guides"
tags: [claude-md, documentation-conventions, context-management, staleness, tiering, agent-instructions]
---
# CLAUDE.md content tiering — rules stay, descriptions move out
## Context
`CLAUDE.md` at the root of this repo is loaded into every single session, before any file is opened. It had grown to 255 lines of mixed content: rules, stack version numbers, plugin role tables, `make` command tables, folder maps, template hierarchies, and descriptions of how the asset pipeline worked. Nobody had ever asked whether a line earned its place in permanent context.
Four rounds of work over one session took it to 74 lines. The interesting part was not the size reduction — it was what the audits revealed about *which kinds of sentences go stale*, and the fact that the first honest audit made the file **bigger**.
| Round | Commit | Lines | What happened |
|---|---|---|---|
| 1 | `2fbfc88` | 255 → **305** | Audit scored the file 76/100, fixed 4 stale facts, and *added* genuinely missing sections (testing, dev commands, plugin patches) |
| 2 | `ed6e43a` | 305 → **179** | Descriptive content extracted to `docs/` |
| 3 | `839a4d0` | 179 → **74** (17,057 → 8,544 chars) | Rules-only cut; created `docs/reference/testing.md`, grew `README.md` |
| — | `9ec2349` | +52 | `docs/working/README.md` added; the plan-status *rule* stayed in CLAUDE.md, the *explanation* moved out |
| 4 | `285e615` | 74 → **74** | Build-output rule restated as an invariant. 3 lines → 3 lines, 156 chars saved. Not a size change — a staleness fix |
The four stale facts from round 1, verbatim from `2fbfc88`'s commit body:
- `active_trip: japan-korea-2026` — the committed value was `/trips/denmark-2026` and **no `japan-korea` trip folder existed**
- `Admin2 v2.0.10` — installed version was `v2.0.12`
- `make demo-load` described as italy-only — the Makefile loops over every fixture under `user/docs/demo/trips/`
- the `user/` gitignore claim omitted the three un-ignored site-owned plugins and the secret/`env/` exclusions
## Guidance
### 1. Apply the operational test to every line
> **Does this line change what Claude does on a task where it wouldn't otherwise open the relevant file?**
If no, it is a *description* — move it to `docs/`. Claude reads the code anyway; prose about the code just drifts alongside it.
Corollary: **version numbers are pure drift with no behavioral payload.** `Grav 2.0.7`, `Admin2 v2.0.12`, and the GPM-channel paragraph were all dropped. What survived is version-free:
> The site is Grav (flat-file PHP CMS, no database) in Docker, with content and theme in the `user/` submodule.
"No database" stays because it *does* change behavior — an agent that believes there is a database goes looking for migrations, an ORM, and a query layer that do not exist.
### 2. Tier content by when it gets read
| Content | Home | Why |
|---|---|---|
| Rules, gotchas, invariants | `CLAUDE.md` | Worthless unless already in context |
| How the code works | `docs/reference/` | Claude reads the code anyway; prose drifts |
| How to do a task | `docs/guides/` | Read at task start, on demand |
| A trap already hit, with symptoms | `docs/solutions/` | Retrieved by symptom, indexed by frontmatter |
| Setup, folder map, commands | `README.md` | For humans; Claude has the Makefile |
CLAUDE.md keeps a six-row entry-point table pointing at each destination — the routing is a rule, the content behind it is not.
### 3. Gotchas are the one category that cannot be extracted
Every other content type has a natural trigger that opens the file:
| Type | Trigger that gets it read |
|---|---|
| Description | Agent opens the code |
| Procedure | Agent starts the task |
| Incident write-up | Agent recognizes a symptom |
| **Gotcha / exception** | **none — it must already be in context** |
A file you only open once you suspect an exception exists is a file you open **too late**. A proposed `docs/exceptions/` directory was therefore recommended against. Supporting arithmetic: the whole rules surface is ~40 lines / ~2,200 tokens, so a second file saves ~1k tokens while adding a lookup step, and `docs/solutions/` (indexed by `module` / `tags` / `problem_type`) already fills the read-on-demand role for "have we hit this before?".
### 4. State invariants, not enumerations
An enumerated list is falsified by the next addition, silently. An inverted statement of the same fact survives it. This is what `285e615` did — same three lines, no size change, but now staleness-proof.
### 5. Verify the destination before extracting
Every extraction target was confirmed to already exist and already cover the topic:
- pointer bumps and worktree mechanics → `docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md` (already covered them)
- the `user/env/<host>/` override tree → `docs/guides/deploy-cycle.md` (already covered it)
- source→output asset table → `docs/reference/architecture.md` → "Asset pipeline" (section added to receive it, lines 69-82)
- test-suite descriptions → `docs/reference/testing.md` (**created**, 67 lines — no destination existed)
- folder map + `make` tables → `README.md` (179 → 227 lines)
Nothing extracted became homeless. Related fix in the same pass: `docs/working/git-sync-notes.md` pointed at "CLAUDE.md §1", a section number that no longer existed after renumbering — **cross-references into an instruction file must point at stable headings, never numbers.**
### 6. Know when to stop
At 74 lines the section sizes were even — Hard rules 9, Dev environment 8, Content and trips 7, Two shared partials 7, Dual-repo submodule 7, Testing 7, Working docs 7, intro + entry-point table 15. No fat pocket remained. Roughly 8 more lines *could* have gone (the `travel-memories` :8082 port, a parenthetical Twig-recompile aside, tightening two bullets) for ~250 tokens out of ~2,200 — while deleting actual rules.
**The trim is strongly positive while what leaves is descriptions, and turns negative once only rules remain.** Round 3 therefore ended with a "we're at the floor" verdict plus one robustness fix (`285e615`), not another cut.
## Why This Matters
**Every stale fact found across all four rounds was a description of code or config. Not one was a rule.** Two of them had been written by Claude itself days earlier. Descriptions drift because the code moves and the prose does not; rules do not drift because they encode intent rather than state. The tiering above is not an aesthetic preference — it is the only conclusion the evidence supports.
**A wrong path in an always-loaded file is worse than an absent one.** CLAUDE.md claimed the map engine lived at `js/src/maplibre-utils.js`. That file does not exist. The real path is `user/themes/intotheeast/js/maplibre-utils.js` — a hand-authored source sitting *next to* the generated bundles in `js/`, imported by `js/src/map.js` as `../maplibre-utils.js`. The wrong path survived rounds 1 and 2 (`2fbfc88` line 76, `ed6e43a` line 64) and was only fixed in `839a4d0`.
An absent fact makes an agent go look. A wrong fact makes it act confidently in the wrong place. Here the wrong place was `js/map.js` — a minified esbuild bundle. The failure mode is a hand-edit that survives until the next `make build-assets` silently reverts it.
This is also the decisive argument against `docs/exceptions/`: **the maplibre-utils mistake happened because the path was wrong, not because it was missing.** Had that rule lived in `docs/exceptions/assets.md`, the bundle would have been hand-edited with the agent never knowing the file existed.
**What survived the cut is the sanity check on the criterion.** A rule stays when being wrong about it is expensive *and* the correct behavior is not derivable from reading a file:
- the Admin plugin slug is `admin2`, not `admin` — nothing in the tree announces this before you've already guessed wrong
- `plugins.txt` is hand-maintained; installing a plugin via Admin does **not** update it
- once `user/env/<hostname>/` exists on a server, Grav's Admin writes **all** config there — system *and* plugin — and env wins, so server config must be read from both trees
- `active_trip` is a **route** (`/trips/denmark-2026`), not a bare slug
- never re-add a `pageconfig.parent` to `post-form.md` — a static parent overrides the `active_trip`-derived write target and reintroduces a silent-desync bug
- the standalone `/dailies`, `/map`, `/stats`, `/stories` trip views were deleted 2026-07-04 and must not be re-created or linked
Each of those is a landmine an agent steps on *before* it has cause to open the relevant file.
## When to Apply
- Auditing or editing any always-loaded instruction file — `CLAUDE.md`, `AGENTS.md`, system prompts, agent definitions
- When a stale fact is found in an instruction file: fix it, then ask why that *category* of sentence was there at all
- Before adding a line to `CLAUDE.md` — run the operational test first, and route to the tiering table if it fails
- Before writing an enumerated list of files, paths, plugins, or bundles into an instruction file — try inverting it into an invariant and verify the inverted form against the actual directory listing
- Before extracting content out of an instruction file — confirm the destination exists and covers the topic, or create it in the same commit
- When tempted to create a new read-on-demand directory for exceptions or gotchas — don't; they only work in-context
- When a reduction pass stops finding descriptions and starts deleting rules — stop and record a floor verdict instead of cutting further
## Examples
### Enumerated list → invariant (`285e615`)
**Before** — 3 lines, falsified by adding a fifth bundle:
```markdown
- **Never hand-edit build output**, and know which files those are — sources and outputs
share folders under `user/themes/intotheeast/` (all paths below are relative to it).
`make build-assets` is mandatory after editing any source, and it writes:
- **Generated (never edit):** `js/main.js`, `js/map.js`, `js/feed-actions.js`,
`js/trip-publish.js`, `js/post/`, `css-compiled/`, `fonts/`, and
`templates/partials/weather-icons.html.twig`.
- **Hand-authored sources:** everything in `js/src/`, plus `js/maplibre-utils.js` and
`js/nav.js` (which sit *next to* the bundles in `js/`), `css/style.css`,
`css/tokens.css`, and `scripts/gen-weather-icons.js`.
```
**After** — 3 lines, 156 chars shorter, still true after the next bundle is added:
```markdown
- **Never hand-edit build output** — sources and outputs share folders under
`user/themes/intotheeast/` (paths below are relative to it), so know which is which.
Run `make build-assets` after editing any source.
- Everything in `js/` is **generated** *except* `js/src/`, `js/maplibre-utils.js` and `js/nav.js`.
- `css-compiled/` and `fonts/` are generated (sources: `css/style.css`, `css/tokens.css`);
so is `templates/partials/weather-icons.html.twig` (source: `scripts/gen-weather-icons.js`).
```
Verification that made this safe: `ls js/` returns exactly the 4 bundles + `post/` + `maplibre-utils.js` + `nav.js` + `src/`. The inverted form is exactly true today and stays true as bundles are added. The full enumerated source→output table now lives in `docs/reference/architecture.md` → "Asset pipeline", where drift is cheap because the table is read next to the code it describes.
### Description → extracted; rule → kept
**Before** (round 1 addition, later cut) — a description of the build, in permanent context:
```markdown
**`make build-assets` is mandatory after editing anything in
`user/themes/intotheeast/js/src/`.** Sources live in `js/src/`; esbuild writes the
committed bundles — `js/main.js`, `js/map.js`, `js/feed-actions.js`,
`js/trip-publish.js`, `js/post/`, and the CSS extracted into `css-compiled/`.
**Never hand-edit those.** By contrast `css/style.css` and `css/tokens.css` are
hand-authored sources, not build outputs. `build-assets` runs as your host UID
(`--user`) so the outputs in the bind-mounted `user/` tree are not root-owned.
```
**After** — the `--user` mechanism and the esbuild pipeline moved to `docs/reference/architecture.md` line 71; only the never-edit rule and the source/output discriminator remain in `CLAUDE.md`.
### Wrong path → right path (`839a4d0`)
```diff
-The engine is `MapUtils.initEntryMap(opts)` in `js/src/maplibre-utils.js`.
+the engine is `MapUtils.initEntryMap(opts)` in `js/maplibre-utils.js`
+(a hand-authored file, imported by `js/src/map.js`)
```
`js/src/maplibre-utils.js` never existed. The parenthetical is not padding — it is the whole reason the rule is in an always-loaded file: `js/` is the bundle directory, so a hand-authored source living there is exactly the fact an agent cannot infer.
### Rule stays, explanation leaves (`9ec2349`)
The plan-status convention needed both a machine-actionable rule and a human-readable explanation of the five states. They went to different files:
- `CLAUDE.md` keeps the one-line rule — every plan needs a `**Status:**` line immediately after its title, plus what to surface when asked what's open, plus set `✅ Complete (YYYY-MM-DD)` before closing a session
- `docs/working/README.md` (52 lines) holds the explanation of the states, the directory layout, and the human-facing reference
Same convention, split by *when each half needs to be in context*.
## Related
- [`docs/README.md`](../../README.md) — the existing "always-loaded rules → CLAUDE.md" vs "stable facts → reference/" split that this learning sharpens into an actionable test
- [`docs/working/plans/2026-06-21-documentation-restructure.md`](../../working/plans/2026-06-21-documentation-restructure.md) — the prior restructure that created the extraction destinations (`reference/architecture.md` and siblings) this pass relied on and re-applied
- [`docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md`](../integration-issues/stale-grav-version-blocks-api-plugin-install.md) — sibling instance of version numbers rotting, in the deploy-config domain rather than the instruction-file domain
- [`docs/reference/architecture.md`](../../reference/architecture.md) → "Asset pipeline" — where the enumerated source→output table now lives
+52
View File
@@ -0,0 +1,52 @@
# docs/working/ — work in flight
Everything here is a live working document: specs being built from, plans being executed, notes from sessions in progress. Once something is finished it stays (as a record) rather than being deleted — the `**Status:**` line is how you tell the difference.
Stable facts belong in [`../reference/`](../reference/); how-to procedures in [`../guides/`](../guides/); write-ups of bugs already solved in [`../solutions/`](../solutions/).
---
## What's in here
| Path | Contents |
|---|---|
| `specs/` | Design docs — the *what* and *why*, written before a plan. Named `YYYY-MM-DD-<topic>-design.md` |
| `plans/` | Implementation plans — the ordered *how*, with a status line. Named `YYYY-MM-DD-<topic>.md` |
| `milestones/` | Milestone scope documents (`milestone-1.md` … ) |
| `qa/` | Test plans, QA results, readiness audits |
| `handovers/` | Session handover notes — context for picking up unfinished work |
| `learnings/` | Retrospective notes worth keeping but not yet promoted to `../solutions/` |
| `backlog.md` | Unscheduled ideas and wishes |
| `bugs-and-fixes.md` | Running log of bugs found and what fixed them |
| `summary.md` | Project summary / current state |
| `pm-analysis.md`, `git-sync-notes.md`, dated one-offs | Standalone notes, kept for reference |
---
## Plan status convention
Every plan in `plans/` carries a `**Status:**` line immediately after its title heading. This is the single place a plan's state is recorded — there is no separate tracker.
| Status | Meaning |
|---|---|
| `📋 Not started` | Plan written and reviewed; no work begun yet |
| `🔄 In progress — <note>` | Actively being worked on. The note says where it stopped, so anyone (or any session) can resume |
| `⏸️ Deferred — <reason>` | Intentionally postponed. Still valid, just not now — the reason matters more than the status |
| `✅ Complete (YYYY-MM-DD)` | Done and shipped. The date is when it landed, not when the plan was written |
| `❌ Abandoned — <reason>` | Won't be implemented. Kept so the decision (and its reasoning) is not re-litigated later |
Notes on using it:
- **A trailing note after `✅ Complete` is normal and encouraged** for anything non-trivial — what actually shipped, what was deferred, which commit or environment it landed in. Several plans here carry a paragraph.
- **`Deferred` is not `Abandoned`.** Deferred means "still want this"; abandoned means "decided against it". Keeping them distinct is the whole point of having both.
- **Update the status when the work lands**, not later. A plan whose status lags reality is worse than no plan, because it is trusted.
### Asking Claude what's open
Claude reads these statuses directly (the convention is also in [`../../CLAUDE.md`](../../CLAUDE.md), so it applies without being asked). When asked what's open it will surface `Not started` and `In progress`, show `Deferred` items with the label made explicit, and leave out `Complete` and `Abandoned` unless you ask for them. It sets the status to `✅ Complete (YYYY-MM-DD)` on finishing a plan.
A quick manual sweep of the same thing:
```bash
grep -rH '^\*\*Status:\*\*' docs/working/plans/ | grep -v 'Complete\|Abandoned'
```
+2 -1
View File
@@ -3,7 +3,8 @@
## ⚠️ Config lives in the ENVIRONMENT tree, not `user/config/` (IMPORTANT) ## ⚠️ Config lives in the ENVIRONMENT tree, not `user/config/` (IMPORTANT)
Prod has a per-environment override directory `user/env/<hostname>/config/` Prod has a per-environment override directory `user/env/<hostname>/config/`
(created for Twig prod-mode — see CLAUDE.md §1). **A crucial Grav side effect: (created for Twig prod-mode — see [`../guides/deploy-cycle.md`](../guides/deploy-cycle.md) →
"The env override tree"). **A crucial Grav side effect:
once that env dir exists, the Admin panel saves ALL config changes — system and once that env dir exists, the Admin panel saves ALL config changes — system and
plugin — into the active environment's config tree**, not `user/config/`. plugin — into the active environment's config tree**, not `user/config/`.
@@ -11,7 +11,22 @@ execution: code
# Post Form Location Override - Plan # Post Form Location Override - Plan
**Status:** 📋 Not started **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
@@ -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
+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);
});
+4 -1
View File
@@ -3,5 +3,8 @@
{ {
"path": "." "path": "."
} }
] ],
"settings": {
"makefile.configureOnOpen": false
}
} }
+1 -1
Submodule user updated: 02fa4e94a7...1b9e51baf7