Compare commits

107 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 Opus 5 2fbfc884b9 docs(claude-md): fix four stale facts; document testing, dev commands, patches
Audit of the root CLAUDE.md (scored 76/100) found the architecture and
remote-ops coverage strong but the test workflow entirely undocumented and
several facts drifted from the tree.

Corrections (verified against the checkout):
- active_trip was japan-korea-2026; committed value is /trips/denmark-2026
  and no japan-korea trip folder exists. Also note the value is a route.
- Admin2 2.0.10 -> 2.0.12 (installed version).
- demo-load/demo-reset described as italy-only; the Makefile loops over every
  fixture trip under user/docs/demo/trips/.
- user/ gitignore claim omitted the three un-ignored site-owned plugins and
  the secret/env exclusions.

Additions:
- Section 3 "Testing": make test/test-config/test-post/test-ui, the
  auto-created testrunner account, Playwright layout, the auth.setup.js
  storageState dependency, and GRAV_BASE_URL for worktree servers.
- Local dev command table, plus which theme assets are build outputs
  (js/src -> bundles) versus hand-authored (css/style.css, css/tokens.css).
- Custom plugins: story-blocks and entry-actions alongside cache-on-save.
- Local plugin patches: install-plugins overwrites git-ignored third-party
  plugins; deploy/patches/ + apply-plugin-patches is the tracked fix path.
- travel-memories service on 8082; make pixelfed-import.

Every make target and file path referenced was verified to resolve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 20:28:53 +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
m038andClaude Sonnet 5 641b0c376e docs(working): plan — post form location override; doc-review fixes to spec
Adds the implementation plan for the location-override feature and folds in
ce-doc-review findings: a panel-open sync gap (pin didn't render on reopen
with pre-existing coordinates), keyboard/ARIA accessibility gaps in the
search-results list and mismatch flag, a shared MAP_STYLE module to remove
duplication drift risk, and a corrected Open-Meteo risk/mitigation split.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 19:06:50 +02:00
m038 2ab6575e4b docs(working): spec — post form location override (search + map + drag pin)
Addresses the actual root cause behind the Denmark 2026 corrupted-coordinate
bug: there was no supported way to set an entry's location to somewhere other
than the current GPS position, forcing hand-typed/pasted raw coordinates
through Admin2's fragile text field. Backend sanitization (cache-on-save)
already guards against silent corruption; this spec adds a frontend way to
avoid needing that path at all.
2026-07-23 20:39:30 +02:00
m038andClaude Fable 5 94bfc53b90 docs(working): overnight pre-trip readiness audit + product ideation report
Audit: posting pipeline / auth / API surface review with prioritized P1-P3
findings and a morning checklist; P1-1 (prod 2M upload limit) marked
resolved 2026-07-09 via the CGI->FPM switch in Webmin. Ideation: 7 ranked,
repo-grounded ideas (top pick: OG meta + RSS follow-along stack).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195b3cDdMeize2Mm1FgC2aU
2026-07-09 17:59:05 +02:00
m038andClaude Fable 5 0defa85f58 test(post): cover the upload-submit gate and lightbox EXIF-dims invariant
Regression specs for the two 2026-07-09 prod bugs (fixed in user/ e17a5dc):

- upload-gate.spec.js — UG1/UG2: create submit is blocked with a visible
  message while a photo upload is in flight or after it FAILED; nothing may
  land on disk. The form plugin's own guard misses LOADING and
  PROCESSING_ERROR, which silently dropped a photo on a fast save.
- lightbox-dims.spec.js — LD1: a slide's data-pswp-* must equal the
  browser-rendered natural size of the linked image. Fixture is an 800x600
  JPEG with EXIF Orientation=6 (renders 600x800 portrait), planted on disk
  in the demo trip (the active trip may be an unpublished draft that 404s).

New fixture: tests/fixtures/test-photo-exif-portrait.jpg.

Note: the suite currently needs GRAV_TEST_USER/GRAV_TEST_PASS overrides —
the .env GRAV_TEST_PASS contains shell-special chars that break `make
test-account` (see the Makefile comment requiring a plain password).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195b3cDdMeize2Mm1FgC2aU
2026-07-09 17:59:05 +02:00
m038andClaude Fable 5 9ffeb4d2d8 fix(make): build-assets drops privileges via --user; bump CLAUDE.md Grav to 2.0.7
Close the remaining root-owned bind-mount vector: build-assets (a docker
run, missed by the docker-exec fix in 209b804) now runs as the host
uid/gid with HOME=/tmp for npm's cache. Verified: build completes clean,
zero root-owned files under user/themes, bundles byte-identical.

Solution doc updated from "still open" to fixed; CLAUDE.md stack section
now matches the Dockerfile's Grav 2.0.7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195b3cDdMeize2Mm1FgC2aU
2026-07-08 23:41:10 +02:00
m038andClaude Opus 4.8 6cf50920df chore(user): bump pin — taglines, Denmark desc, Past Trips→Trips rename
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 23:39:42 +02:00
m038andClaude Opus 4.8 60e80c3e72 docs(future): plan — view unpublished trips on frontend when logged in
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 23:38:49 +02:00
m038andClaude Fable 5 24867524a1 docs(solutions): compound refresh — fix reference drift, grow CONCEPTS.md
Refresh audit of all 13 docs/solutions learnings against the current
codebase. Core guidance verified accurate everywhere; three docs had
reference drift:

- dual-repo-submodule-workflow: point worktree setup/teardown at the
  make worktree-new/worktree-rm targets (manual procedure misses
  .worktree-env isolation)
- docker-exec-root-owned-bind-mount-files: tracked-plugin list now
  includes entry-actions; fix-perms description matches actual target
- grav-plugin-config-without-code-wont-enable: 3-category model's
  custom-in-repo list now includes entry-actions

CONCEPTS.md: add Container, Content repo, Outer repo, Pin, Env tree,
Remote-only plugin; refresh Active Trip (switching is one setting now).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195b3cDdMeize2Mm1FgC2aU
2026-07-08 23:37:56 +02:00
m038andClaude Fable 5 f3816bfc3e docs(future): blueprint vetting research + recommendation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 23:22:45 +02:00
m038andClaude Opus 4.8 084f683e19 docs(plans): mark frontend-entry-edit + photo-editor-media-api complete
Both shipped with feat/journal-post-form (merged + deployed to prod) and
passed owner UI/touch-drag QA on 2026-07-08. Corrected the stale
"not merged / not deployed" language and fixed a duplicate Status marker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 23:08:20 +02:00
m038andClaude Opus 4.8 62f940f6ef chore(user): bump pin — feature entries + Denmark publish
Content repo f4ab730: featured: true on 3 entries (homepage highlights) and
Denmark 2026 published with its Vestkystruten GPX.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-08 22:30:14 +02:00
m038andClaude Opus 4.8 bc15f0b07d docs(solutions): extend root-owned bind-mount doc to docker run / build-assets
The install-plugins fix (209b804) only covered docker exec. build-assets
runs `docker run node:20-alpine` without --user, so it still writes
root-owned node_modules + esbuild bundles into user/themes/ — which is what
blocked `git worktree remove` at teardown. Broaden the doc and prevention
rule to cover docker run, with the --user fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-08 22:30:14 +02:00
m038 b205db0ea9 Merge feat/journal-post-form: trip publish toggle (tests + docs), bump user pin
Land the owner trip publish/unpublish toggle to local main: Playwright specs
(TP1-TP8), the ce-compound solution doc + CONCEPTS.md Published/Draft concept,
and the plan/spec docs. Bumps the user submodule pin to 543e8e3 (the merged
user/ main containing the feature + denmark-2026 cover content).

Local landing only — nothing pushed.
2026-07-08 17:48:32 +02:00
m038andClaude Opus 4.8 bb2b64bd78 docs(solutions): in-place header edit + APCu cache staleness (Part 3)
Document the trip publish-toggle cache-invalidation finding: an in-place
trip.md `published` edit under cache.check.method: folder + APCu driver stays
stale because the folder checksum is unchanged AND the web APCu store is
unreachable by a CLI clearcache — fixed with apcu_clear_cache() from the web
request. Cross-link the sibling grav-deleteall doc (the create/delete case) as
necessary-but-not-sufficient here, and add the Published/Draft trip status
concept to CONCEPTS.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-08 17:17:22 +02:00
m038andClaude Opus 4.8 2cdb435182 test(trip): cover publish-toggle failure/lock paths + missing-key 400
Add the three cases the code review flagged as uncovered:
- TP7 (R15): a failed POST reverts the switch and surfaces the visible toast.
- TP8 (R13): the in-flight lock suppresses a concurrent second submit (exactly
  one POST fires while the switch is aria-busy/disabled).
- TP5 leg: a MISSING published key -> 400 (the array_key_exists branch, distinct
  from the is_bool branch already covered).

All 10 trip-publish specs green (serial, worktree container).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-08 17:17:09 +02:00
m038andClaude Opus 4.8 500d59bdae chore: bump user submodule pin to 4cc0a18 (denmark cover)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDS6t8wcpbwKvvrxykVQ5K
2026-07-08 17:16:53 +02:00
m038andClaude Opus 4.8 3e1ddd8132 test(trip): publish-toggle Playwright specs + plan/spec docs; bump user pin
TP1/TP1b/TP2–TP6 cover the owner gate, coverless drafts, cache-correct
hide/restore, the active-trip confirm, backend authz (401/403/400), and
the home fallback. The suite pins site.owner_username to the authenticated
test user (restore on teardown) and runs serially — it mutates global
config and clears the shared cache, so it collides with parallel readers.

Bumps the user/ pin to the finished trip-publish-toggle content (064f0f0)
and marks the plan Complete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-08 14:50:20 +02:00
m038andClaude Opus 4.8 28e57f62c2 feat(makefile): add remote-warmup — clear + warm cache after deploy
A `reset --hard` content deploy leaves Grav's compiled-Twig/page cache
stale, so the first visitor pays the recompile. `remote-warmup` clears
the cache then crawls the public site (homepage + trips listing + every
trip page linked from it) to pre-render pages. Grav has no native warmup
command, so it's an HTTP crawl — which also doubles as a smoke test
(non-2xx pages flagged). Wired into REMOTE_TARGETS (-test/-prod variants)
and added as the final step in both deploy-cycle.md phases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDS6t8wcpbwKvvrxykVQ5K
2026-07-08 13:05:33 +02:00
m038andClaude Opus 4.8 3d9d3ecb85 docs(solutions): capture docker exec root-owned bind-mount fix
New learning: docker exec defaults to root, so make targets writing into
the ./user bind mount (esp. install-plugins -> gpm) created root-owned
files (11,624 accumulated), breaking worktree-rm. Fix: HOST_UID/HOST_GID +
`-u` on file-writing execs while the grav container still boots as root.

Cross-linked reciprocally with the sibling docker-dev-env upgrade doc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDS6t8wcpbwKvvrxykVQ5K
2026-07-08 12:15:01 +02:00
m038andClaude Opus 4.8 209b804423 fix(makefile): create container files as the host user, not root
`docker exec` defaults to root, so `make install-plugins` wrote plugins into
the ./user bind mount as root — un-removable on the host without a root
container (exactly what blocked the 2.0.4 worktree cleanup). The grav service
can't simply run `user: 1000` because the base image entrypoint needs root to
bind :80 and set up cron, so drop only the file-CREATING CLI to the host user:

- HOST_UID/HOST_GID from id -u / id -g
- install-plugins makes cache/tmp writable (container-internal, never touches
  the host) then runs gpm as the host user, so plugins land owned by you — no
  post-hoc chown, no root files, no root rm needed at teardown

Verified: gpm reinstall as uid 1000 leaves 0 root-owned files under ./user
(was 11624), site healthy (/ and /admin 200), plugin patches reapplied.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDS6t8wcpbwKvvrxykVQ5K
2026-07-08 11:35:48 +02:00
m038andClaude Opus 4.8 20df900188 docs(plans): refresh journal-post-form status — merged to main
The feat/journal-post-form branch is now merged into main; drop the stale
"not yet pushed / awaiting owner go-ahead" tail from the Status line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDS6t8wcpbwKvvrxykVQ5K
2026-07-08 10:56:07 +02:00
m038andClaude Opus 4.8 c4891d8f60 chore(makefile): add worktree-new/worktree-rm targets for isolated dev servers
Encode the dual-repo worktree SOP as make targets so no step is skipped:
worktree-new creates the outer worktree off main, inits its own user/
submodule, branches both repos, and starts an isolated Grav dev server on an
auto-picked free port (8090+) whose identity is persisted in a git-ignored
.worktree-env; worktree-rm tears it all down including the submodule deinit
that, when skipped by hand, leaves orphaned .worktrees/ dirs.

docker-compose.yml container_name + ports are parametrized as ${VAR:-default}
so the main checkout is byte-identical, and the 11 hardcoded intotheeast_grav
refs in local targets now use $(GRAV_CONTAINER). CLAUDE.md points at the
commands instead of the manual steps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDS6t8wcpbwKvvrxykVQ5K
2026-07-08 10:56:07 +02:00
m038andClaude Opus 4.8 793ca10d4d chore(user): bump pin — delete-index fix + gpx-manager listing
Points user/ at 55da834 (entry-actions delete cache fix + gpx-manager shows
unpublished trips newest-first).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-08 10:29:21 +02:00
m038andClaude Opus 4.8 2e96106c84 test(post): DEL4 — a deleted entry stays gone after a page reload
Guards the page-tree-index staleness fix. DEL1 only checked optimistic DOM
removal + disk; DEL4 reloads and asserts the server no longer renders the card.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-08 10:29:21 +02:00
m038andClaude Opus 4.8 ceb0570c86 docs(spec): trip publish/unpublish toggle design
Owner-gated publish switch on /trips cards + trip pages; extends
entry-actions with a scope-guarded route + page-tree cache invalidation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-08 09:16:38 +02:00
m038 877d29b2b4 Merge branch 'feat/journal-post-form'
# Conflicts:
#	Makefile
#	docs/working/backlog.md
#	user
2026-07-08 00:10:14 +02:00
m038 fa6550a232 Merge branch 'main' into worktree-content-fixes 2026-07-07 23:52:34 +02:00
m038andClaude Opus 4.8 838f237ef5 docs(solutions): capture Grav cropResize-vs-cropZoom cover-image gotcha
New ui-bugs learning: cropResize fits-inside (returns a source-aspect
sliver), cropZoom crops-to-fill. Using cropResize for a cover/banner
strip hands the browser a portrait sliver that object-fit:cover then
upscales into a blur. Documents the fix, the empirical op comparison,
and prevention (verify Medium op dimensions; guard retina upscaling;
regression-test composition, not just the URL).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDS6t8wcpbwKvvrxykVQ5K
2026-07-07 23:49:58 +02:00
m038andClaude Opus 4.8 5276768e12 test(trip-cover): cover-strip + retina-cap regression; bump user pin
- trip-header/trips-list specs now assert the cropZoom wide-strip aspect
  and the all-or-nothing retina rule (1x-only for sub-2w sources), and a
  new regression on us-canada-mex-2024 covers the reported portrait-blur
- remove the orphaned AE4 test (no-photos-demo fixture was deleted)
- backlog: full-res pixelfed reimport (luxury quality item)
- plan: record the cover-quality follow-up + AE4 fixture removal
- bump user pin -> 536ca20 (trip-description-hero cover fix)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDS6t8wcpbwKvvrxykVQ5K
2026-07-07 23:39:32 +02:00
m038andClaude Opus 4.8 44c3a1c32e docs(handover): mark photo-editor Playwright handover complete/superseded
Coverage goal delivered and verified (39 post specs pass on :8091);
superseded by 2026-07-07-journal-post-form-review-handover-and-qa.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-07 23:07:27 +02:00
m038andClaude Opus 4.8 b752178eb4 refactor(demo-load): iterate docs/demo/trips/* instead of per-trip blocks
demo-load / demo-reset now loop over every fixture under docs/demo/trips/
rather than hardcoding each trip, so new fixtures (no-photos-demo and any
future one) are picked up with no Makefile edit. The only per-trip specific —
the source `dailies/` folder mapping to `01.dailies/` in the pages tree — is
uniform across fixtures, so one loop covers all. chown is scoped per fixture
dir (never the whole 01.trips tree) and all copies stay `|| true`.

Verified on the worktree server: a full reset then reload restores both
italy-2026-demo (7 gpx + 12 entries + stories) and no-photos-demo; trip suite
green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDS6t8wcpbwKvvrxykVQ5K
2026-07-07 23:03:54 +02:00
m038andClaude Opus 4.8 af07ef403c test(trip): add AE4 no-banner coverage + record deferred review findings
- trip-header.spec.js: new U4/R9/AE4 test — a trip with no cover_image and no
  entry images renders its text header with no .trip-header-banner strip.
- Makefile: demo-load/demo-reset now also (un)load the no-photos-demo fixture,
  guarded with `|| true` so runs whose user/ predates the fixture skip it
  instead of failing globalSetup.
- plan: post-review follow-up notes the applied fixes and records the two
  intentionally-deferred findings (macro-owned resolution per KTD2; inline
  toggle script per U4's Approach) so they are not re-flagged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDS6t8wcpbwKvvrxykVQ5K
2026-07-07 09:13:59 +02:00
m038andClaude Opus 4.8 06f4c25631 docs(handover): review handover + owner QA checklist; mark edit-plan review-complete
Add a review handover (Claude→future Claude: branch state, commit map,
dual-session isolation facts, and the owner-triggered landing procedure) plus
an owner UI QA checklist for the flows the harness can't verify (touch-drag,
interactive photo add/delete/reorder). Update the frontend-entry-edit plan
status: implementation + code-review done; only owner QA + landing remain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-07 09:12:01 +02:00
m038andClaude Opus 4.8 6a73be3e49 test(trip): tighten description-clamp assertion + correct R11 coverage note
- trip-header.spec.js: replace the vacuous `toContainText('finally made
  sense')` (the tail text is in the DOM even while collapsed) with real
  clamp/un-clamp assertions — clientHeight < scrollHeight when collapsed,
  clientHeight >= scrollHeight once expanded — so the test actually proves the
  toggle changes visibility.
- trips-list.spec.js: the header comment claimed R11 coverage no fixture
  provided. Note that R11 (set-but-unresolvable cover_image) shares the exact
  else-branch the R7/AE3 fallback test exercises, so it's covered by
  construction in the shared cover macro.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDS6t8wcpbwKvvrxykVQ5K
2026-07-07 08:35:41 +02:00
m038andClaude Opus 4.8 e10496afe7 docs(solutions): BUG-001 part 2 — deleteAll doesn't rebuild page-tree index
Document why deleteAll() alone left a freshly-posted entry 404-ing on its
edit-prefill API lookup (regular-pages index keyed on config->checksum, which
survives a create under cache.check.method:folder) and why the fix adds
Cache::invalidateCache(). Note the H1/M8 travelling:true coverage gap as a
tracked residual. Cross-link from bugs-and-fixes.md.

Code review F8 (learnings) + F5 (residual-gap tracking).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-07 08:32:20 +02:00
m038andClaude Opus 4.8 d576487886 test(post): share createPhotoEntry helper and fix cleanup leak
Hoist the duplicated per-spec createEntry photo-fixture into a single
createPhotoEntry() in helpers.js (used by delete-flow, edit-mode, and the
anon-view draft). Register the tag for cleanup BEFORE the awaited 15s
success-toast assertion, so a create that lands on disk but whose toast
assertion times out no longer leaks an untracked entry. Add AE3b covering the
disclosure deviation branch (a non-default toggle auto-expands More options).

Code review F2 (leak), F3 (duplication), F6 (coverage).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-07 08:32:09 +02:00
m038andClaude Opus 4.8 86f9018f73 docs: document trip_header_extras param on trip-feed-col shared partial
Code review flagged the shared partial's CLAUDE.md contract table as stale:
trip-feed-col.html.twig gained a trip_header_extras flag (set at :254, wired
from trip.html.twig) but the parameter table the trip/home callers rely on to
stay in sync had no row for it. Add the row + a note on what it gates and why
home omits it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDS6t8wcpbwKvvrxykVQ5K
2026-07-06 07:35:18 +02:00
m038andClaude Opus 4.8 7ea90de12b test(trip): specs for trip one-liner, description, banner & home gating
- trips-list.spec.js: one-liner presence/absence, retina srcset, cover
  fallback, alt text (U3).
- trip-header.spec.js: HTD stacking order, expandable description, banner
  fallback, split intact (U4).
- home.spec.js: AE7 — the gated trip-page extras never leak onto the home
  route (asserted mode-independently so it can't race the home-highlights
  suite that toggles travelling in a parallel worker).

Also marks the plan complete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDS6t8wcpbwKvvrxykVQ5K
2026-07-06 00:02:48 +02:00
m038andClaude Opus 4.8 f4dbac6fc2 test(home,maps): skip travelling-gated H1/M8 with a stated reason
H1 (home journal feed) and M8 (home journey map source) only apply when
config.site.travelling is true — home.html.twig otherwise renders the
between-trips highlights grid, which has neither. They now detect that mode
(.home-highlights-title) and test.skip() with an explicit reason instead of
failing misleadingly, so they still run and validate whenever travelling is on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-05 23:42:55 +02:00
m038andClaude Opus 4.8 d3c17791b7 test(post-form): add edit/delete/anon coverage and fix stale photo-gate assumptions
New specs: edit-mode (ES1 save round-trip + ES2/ES3 prefill 404/500 states),
delete-flow (DEL1-3 happy/cancel/failed), anon-view (AN1 no owner controls,
AN2 draft hidden from anon), photo-editor (live add/delete/reorder). Existing:
P3-P8 now attach a photo to satisfy the create photo-gate; V3 picker cap 4->6.
Full post suite 38/38, stable across parallel (3-worker) runs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-05 23:42:45 +02:00
m038andClaude Opus 4.8 7534d7d178 test(post-form): expect zero-padded photo-01..NN filenames
The create path now routes through the shared PhotoRenumberer, which
zero-pads to photo-01..NN; update the Playwright assertions and title to
match the new naming.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 20:14:50 +02:00
m038andClaude Opus 4.8 9295914238 docs: capture history-rewrite-under-live-git-sync + secrets-audit tooling
Update the git-sync secret-exposure solution doc with today's operational
lesson: untracking an already-committed secret under a live bidirectional
sync. Covers the direction:both force-push-revert trap, the freeze-every-
server-first sequence, audit-before-reset (authoritative secret in env/),
the stale origin/main ref + sparse-checkout gotchas, and the ignore:-field
mechanism.

Add Makefile targets that supported the fix:
- remote-secrets-audit: secret-safe (existence + size + git ls-files, never
  contents) audit of config/ vs env/<host>/config secret locations
- remote-content-status: also show the .gitignore diff git-sync regenerates

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 19:25:32 +02:00
m038andClaude Opus 4.8 8441ce392d docs(plan): photo-editor implemented + server-logic verified; status update
Record implementation completion and the verification split: PhotoRenumberer
unit-verified, build/lint clean, /post + assets serve locally; owner-session UI
verification and on-device touch-drag reserved for the user. Note the server-side
SVG block deferred to the R6 add/delete fast-follow (config/security.yaml is
gitignored).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 19:25:04 +02:00
m038andClaude Opus 4.8 3cb7dfbd8b make: add remote-seed-api-salt + show HEAD in remote-content-status
remote-seed-api-salt writes a per-host popularity salt into the env
override tree (user/env/<host>/config/plugins/api.yaml) so the api plugin
reads it there instead of appending one to the git-tracked
config/plugins/api.yaml. That appended salt kept the content working tree
perpetually dirty, which aborted git-sync's auto-merge on webhook and
silently broke prod auto-deploy. Salt is generated server-side, never
committed, and re-runs are idempotent.

remote-content-status now prints HEAD so a webhook auto-pull can be
verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 16:53:30 +02:00
m038andClaude Opus 4.8 bb7f4a02ef docs: trip switching is one setting (site.active_trip), not three files
The post form no longer hardcodes pageconfig.parent — cache-on-save derives the
write target from site.active_trip at submit time. Update the "Switching to a new
trip" section and the Trip-architecture note accordingly, and point at the Admin
Site → Active Trip page-picker. Drops the stale post-form.md / system.yaml sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:55:05 +02:00
m038andClaude Opus 4.8 1cf2d12bc7 fix(add-page-by-form): patch Grav-2.0 edit-mode header fatal + deploy wiring
Adding a new photo while editing an entry 500s: add-page-by-form's edit branch
reads existing frontmatter via `(array)$page->header()`, but Grav 2.0's
Grav\Common\Page\Header keeps data in a protected `items`, so the cast mangles
keys and `$original_frontmatter['photos']` is never set → array_merge(null,…)
TypeError. Fix: use Header::toArray() (clean keys, stdClass fallback) + guard the
per-field merge. Grav 2.0.7 does not change this — only the plugin fix does.

add-page-by-form is abandoned upstream (last release 2023-09) and its dir is
git-ignored/GPM-managed, so the fix is tracked as deploy/patches/*.patch and
re-applied after any GPM install/update:
- make apply-plugin-patches (local) — chained into install-plugins
- make remote-apply-plugin-patches-{test,prod} — piped over SSH into
  `patch -p1 --forward`; chained into remote-install-plugins / remote-update-plugins
Content syncs don't touch user/plugins/, so the patch survives them; only a GPM
op wipes it (now auto-restored). Runbook + README document the step and a
verify check. Remove once the plugin is forked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 13:43:12 +02:00
m038andClaude Opus 4.8 0f6b1e69cd docs: capture local Grav core upgrade + refresh version-authority docs
New learning: docs/solutions/tooling-decisions/upgrade-local-grav-core-rebuild-docker-image.md
— the local Grav core is baked into the Docker image (only ./user is bind-mounted),
so it upgrades by a Dockerfile URL bump + image rebuild + `docker rm -f` recreate,
not the `gpm self-upgrade` the servers use (non-durable in-container).

Refreshed three docs this exposed as stale/incomplete:
- local-setup.md: rewrote the stale "newer Grav RC" section with the durable
  rebuild procedure (recreate gotcha, verify, plugin refresh, non-durability note).
- deploy-cycle.md: Phase 0 now upgrades the local core; state-model notes the
  image as a fourth surface beyond the three server layers.
- stale-grav-version-blocks-api-plugin-install.md: version-authority surfaces
  3 -> 4 (hardcoded Dockerfile URL); clarified .env* GRAV_VERSION governs fresh
  remote installs only, never the local Docker core.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-05 12:05:51 +02:00
m038 10f990e0e7 Merge remote-tracking branch 'origin/main' into feat/journal-post-form 2026-07-05 11:37:28 +02:00
m038andClaude Opus 4.8 fec6a475a2 deploy: bump Grav to 2.0.7 across build image + env pins
Local Docker image was still baked at 2.0.4 while test and prod had
self-upgraded to 2.0.7. Bump the Dockerfile release zip to 2.0.7 and
align the GRAV_VERSION pins so a fresh remote-install lands on 2.0.7
directly instead of installing an older base and needing a self-upgrade.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-05 11:34:21 +02:00
m038 7329852497 Merge remote-tracking branch 'origin/main' into feat/journal-post-form 2026-07-05 11:32:40 +02:00
m038andClaude Opus 4.8 c56265824b docs: entry-edit plan — M2 load/remove/reorder done, R9 (add) deferred
Update Status: M2 partially delivered (U7 load existing photos + remove +
reorder verified end-to-end on :8091, V9/V10 pass). R9 (add new photos on edit)
deferred to the form-to-page/image-upload rework — a new upload on edit fatals
in stock add-page-by-form on Grav 2.0 ((array)$header mangled keys →
array_merge(null) TypeError); that plugin is GPM/git-ignored so it can't be
forked here. Add affordance suppressed in edit mode meanwhile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 11:25:01 +02:00
m038andClaude Opus 4.8 58a8504a47 deploy: mirror prod env override to test (align environments)
test had no user/env/<host>/ tree because deploy/env/test/ never
existed, so `make remote-apply-env-test` hit its missing-source guard
and was silently skipped — test ran with twig cache off and without the
gzip/Content-Encoding fix, hiding the exact class of gotcha that only
surfaced in prod.

Make deploy/env/test/system.yaml a symlink to deploy/env/prod/system.yaml
so both deployed hosts share one override and can never drift; genericize
the prod file header to say it now serves test + prod.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-05 11:12:32 +02:00
m038andClaude Opus 4.8 0a7997c92d docs: refresh stale-grav-version doc — cross-link config-without-code sibling
ce-compound-refresh (scope: stale-grav-version-blocks-api-plugin-install).
Update (not consolidate — distinct triggers, moderate overlap): add a Related
back-link to the new grav-plugin-config-without-code-wont-enable sibling and
broaden the "verify ls user/plugins/<name>" prevention bullet to note the
config-without-code desync as a second non-functional-plugin case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-05 10:44:13 +02:00
m038andClaude Opus 4.8 cf21e199bc docs: add local→test→prod deploy-cycle runbook
Distills the 2026-07 Grav 2.0.4→2.0.7 cutover into a repeatable procedure:
the three-layer state model (plugin code / repo config / host env tree),
ordered per-phase make-target sequences, a smoke checklist that catches the
code-vs-config, stale-version, garbage-page, and git-sync-boomerang gotchas,
plus rollback and one-line invariants. Linked from CLAUDE.md Remote operations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-05 10:40:04 +02:00
m038andClaude Opus 4.8 b5fc43d208 docs: add integration-issue — Grav plugin won't enable when code missing but config persists in env tree
Documents the git-sync prod incident where the plugin was inert despite
`enabled: true`, because its CODE was missing from user/plugins/git-sync/
while its CONFIG survived in the per-host env tree. Fix: GPM reinstall
(make remote-gpm-install-prod PKG=git-sync) + clearcache. Captures the
diagnostic (ls the plugin dir before touching config) and the remote-only
plugin caveat (not in plugins.txt, not restored by the standard flows).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-05 10:34:39 +02:00
m038andClaude Opus 4.8 6dc6af6359 docs: correct git-sync secret-exposure model (gitignore is the boundary, not folder scope)
The prior model claimed git-sync's add-set is scoped to the configured
`folders` (pages/config/themes), so accounts/ and user/env/ were "safe by
construction." That was wrong: prod auto-commit 9337003 pushed the whole
user/env/<host>/config tree (JWT secret, CSRF salt, git-sync token + webhook
secret), accounts/mischa.yaml, and system.yaml to Gitea — all outside the
configured folders. git-sync stages everything under user/ not gitignored;
.gitignore is the only reliable exclusion.

- Rewrite the architecture-patterns doc around the corrected predicate and
  document the incident + remediation (gitignore /env/, rotate token+webhook).
- Correct git-sync-notes.md: env/ is NOT safe on folder scope; drop the
  "never reaches Gitea" claim; fix the secret-files table intro + add /env/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-05 10:19:54 +02:00
m038andClaude Opus 4.8 06d9629075 docs: drop stale form-9.1.10 filepond 'known issue' from CLAUDE.md
Disproven end-to-end on this branch (Grav 2.0.4 + form 9.1.10): file-upload
returns 200, photo persists to disk, POST /post returns 200 with no error
page. The form plugin's upload path is byte-identical 9.1.6->9.1.10, so the
version was never the cause. The original failures were a test artifact --
upload fixtures named as dotfiles (.real-photo.jpg) are rejected by the form
as 'Bad filename'.

The note was also mis-scoped: a 'we're working on it, don't touch' reminder
from the upgrade session belonged in that session's commit/plan, not as a
standing project-wide directive that then told THIS rework branch to stand
down.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1FrCYNq6RXdGYbn5PFrhM
2026-07-05 01:05:45 +02:00
m038andClaude Opus 4.8 7d7346305d docs: update entry-edit plan status — M1 done+integrated, held; M2 deferred
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1FrCYNq6RXdGYbn5PFrhM
2026-07-05 00:44:25 +02:00
m038 fb7b6db1b1 Merge remote-tracking branch 'origin/main' into feat/journal-post-form
# Conflicts:
#	user
2026-07-05 00:43:14 +02:00
m038andClaude Opus 4.8 f0a8895b78 docs: mark frontend-entry-edit plan M1 complete (U1-U6, V1-V7)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1FrCYNq6RXdGYbn5PFrhM
2026-07-05 00:20:51 +02:00
m038andClaude Opus 4.8 e0e2e1e7b5 docs: mark Grav 2.0.4 plan Phase 3 (prod) executed + outcome
Prod was actually deployed (LIVE at intotheeast.com, Grav 2.0.7); the
plan previously said Phase 3 was documentation-only/not-executed. Record
the three deploy gotchas (docs/solutions/) and remaining follow-ups.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-05 00:16:33 +02:00
m038andClaude Opus 4.8 39d42119b2 docs: capture Grav plugin-config-must-be-tracked convention (ce-compound)
Knowledge-track: functional Grav plugin config must live in the tracked
user/config/plugins/<name>.yaml override, not the gitignored (and
gpm-update-wiped) user/plugins/<name>/<name>.yaml. Secrets stay in
gitignored *-private.php. Root of the api plugin being unconfigured on
the fresh prod install. Cross-linked with the api/rc.10 install-failure
doc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-05 00:05:11 +02:00
m038andClaude Opus 4.8 66438836de docs: capture stale-GRAV_VERSION api-plugin-install failure (ce-compound)
Fresh prod install ran Grav rc.10 (stale .env.prod GRAV_VERSION), so GPM
would not serve the api plugin (needs >=2.0.4); admin2 (auth via /api/v1)
then 404'd login silently. Documents the dead ends (deploying api config,
gpm index -f, same-channel assumption) and the fix (self-upgrade core +
reinstall + bump .env.prod). Adds reciprocal 'same 2026-07-04 cutover'
cross-links across the three sibling deploy gotchas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-04 23:59:44 +02:00
m038andClaude Opus 4.8 3ad055d4a8 docs: capture Grav double Content-Encoding garbage-page fix (ce-compound)
Non-FastCGI host + Apache mod_deflate: Grav's shutdown early-close emits
Content-Encoding: identity while mod_deflate adds gzip -> two conflicting
headers -> browsers render raw gzip bytes. Fix: debugger.shutdown.close_
connection:false in the prod env override. Documents the dead-end
(cache.gzip/allow_webserver_gzip take the same code path) and the
browser-style curl + screenshot verification.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-04 23:53:11 +02:00
m038andClaude Opus 4.8 dcf9c13455 docs: document Grav env-tree config save behavior + git-sync env path
Once user/env/<host>/ exists, Grav Admin saves ALL config (system +
plugin) into that env tree, not user/config/ — so prod's git-sync.yaml
lives at the env path, and Admin-on-server config edits are server-only
(not committed, not synced). Documented the footgun in CLAUDE.md §1
(session-loaded) and rewrote git-sync-notes.md: env-path location,
v3.4.4 folders format, gitignore table (incl. versions.yaml +
security-private.php TODO), and fresh-server enable procedure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-04 23:40:11 +02:00
m038andClaude Opus 4.8 425c7b8e20 chore: bump user/ pin to versions.yaml untrack (4aa9ae9)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-04 23:35:11 +02:00
m038andClaude Opus 4.8 d61de6f3f7 build: make git-sync toggle + diag env-path aware
Grav Admin saves plugin config into the active environment's config tree
(user/env/<host>/config/plugins/) when an env override dir exists — so
git-sync.yaml landed there, not in user/config/plugins/. Update the
toggle script to take a WEBROOT and search both locations (env path
first), and update remote-git-sync-disable/enable to pass WEBROOT.
remote-diag now surfaces git-sync config (secrets redacted) from either
location.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-04 23:35:11 +02:00
m038andClaude Opus 4.8 cc40c23ea8 build: add remote-gpm-install (single out-of-plugins.txt package)
For installing remote-only plugins like git-sync that are deliberately
excluded from plugins.txt. Usage: make remote-gpm-install-prod PKG=git-sync

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-04 23:18:51 +02:00
m038andClaude Opus 4.8 4aeff39756 fix(prod): stop garbage page from double Content-Encoding header
On this non-FastCGI host Grav's shutdown early-connection-close can't
fastcgi_finish_request(), so it emits 'Content-Encoding: identity' to ask
the webserver not to compress — but Apache mod_deflate compresses anyway
and adds 'Content-Encoding: gzip'. Two conflicting headers => browsers
render raw gzip bytes (garbage page). Surfaced when prod flipped to
debug:false today (activated Grav's full output path).

Fix in the prod env override: debugger.shutdown.close_connection:false
skips the identity-header path so mod_deflate compresses cleanly (single
header). Verified: one 'content-encoding: gzip', page renders correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-04 23:12:27 +02:00
m038andClaude Opus 4.8 0e597c5329 chore: bump user/ pin to API plugin config fix (b8a7bed)
Pins the superproject to the tracked API plugin config (deployed to prod
to restore Admin2 login). Already published to content repo main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-04 23:01:36 +02:00
m038 4428ef6c42 Merge branch 'main' into feat/journal-post-form
# Conflicts:
#	scripts/test-form-config.sh
#	scripts/test-post.sh
2026-07-04 22:55:26 +02:00
m038andClaude Opus 4.8 41e61fc148 build: per-environment Grav config override (prod Twig prod-mode)
Prod needs twig.cache:true / debug:false / auto_reload:false, but those
values break local dev, so they must not live in the committed
system.yaml. Add a per-environment override deployed only to the server
via Grav's environment://config (user/env/<host>/config/system.yaml):

- deploy/env/prod/system.yaml — version-controlled source of truth.
- make remote-apply-env-prod — writes it to the server + clears cache;
  resolves the host in-recipe (WEB_HOST || REMOTE_HOST) to avoid the
  recursive-make empty-export trap.
- remote-diag now shows the deployed override + whether twig cache is
  populating, so prod-mode can be verified not assumed.
- CLAUDE.md §1 rewritten: never flip committed system.yaml; use the
  override. Backlog updated (twig prod-mode + /post login-gate done;
  note stale .env.prod GRAV_VERSION and pending git-sync).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-04 22:54:59 +02:00
m038andClaude Opus 4.8 b0cb67a079 docs(backlog): add journal detail-page retire + hero-image cleanup items
Surfaced during the front-end journal entry edit brainstorm; referenced by
the entry-edit plan's Scope Boundaries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1FrCYNq6RXdGYbn5PFrhM
2026-07-04 22:52:20 +02:00
m038andClaude Opus 4.8 553d9e4759 build: force GPM index refresh before install; add remote-diag
- remote-install-plugins now runs 'gpm index -f' before install so a
  fresh server doesn't rely on the stale package index bundled in the
  grav-admin zip (which can miss recently-published plugins).
- Add read-only remote-diag target: Grav version, installed plugin
  versions, GPM view of the api plugin, api override keys, and log tail.
  Surfaced the prod rc.10 vs 2.0.4 mismatch that blocked the api plugin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-04 22:49:06 +02:00
m038andClaude Opus 4.8 c4bee49fc3 docs: add implementation-ready plan for front-end journal entry edit
Two-milestone plan (M1 edit/delete/publish from feed cards, M2 editable
FilePond photos). Enriched from the ce-brainstorm Product Contract to
implementation-ready, then hardened through a ce-doc-review pass:

- KTD1 committed to patching add-page-by-form's edit branch (the
  overwrite_mode:edit create path is unreachable as-is; verified in code)
- owner-username server guards + shared scope-guard helper for both the
  edit save path and the new entry-actions delete route (traversal-safe,
  page-tree resolution)
- missing edit/delete UX states (loading lock, in-flight guards, focus
  management, save-failure preservation) filled in

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1FrCYNq6RXdGYbn5PFrhM
2026-07-04 22:43:09 +02:00
m038andClaude Opus 4.8 c76c16b06d test(post-form): AE3 no longer references the removed hero field
The hero_image blueprint field was dropped (journal heroes come from the
first uploaded photo), so AE3 now asserts the More-options disclosure
toggles open/closed rather than probing the hero input's visibility.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 21:06:08 +02:00
m038andClaude Opus 4.8 45c2d54d2b test(post-form): assert 1–6 photo rule
Add a test for the ≥1-photo requirement (submit blocked with a photo-section
error) and the max of 6 (FilePond maxFiles). Rework AE4: a corrupt HEIC is the
only "photo", so fail-closed now means submit is blocked rather than posting a
text-only entry. Give the success-CTA test a photo so it can post under the new
rule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 20:59:01 +02:00
m038andClaude Opus 4.8 edb1c7659c test(post-form): assert datetime-local picker, prefill, and required-date gate
Covers the theme datetime override: the date field renders as
<input type="datetime-local">, is prefilled with the current local time in
the native value format, and clearing it blocks submit client-side (no
success notice) — the guard that keeps an invalid date from wiping the
FilePond photo list on a server re-render.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 20:42:00 +02:00
m038andClaude Opus 4.8 0f88ec4694 test(post-form): assert photos are renamed photo-1..N with no frontmatter leak
Covers the reorder pipeline: two uploads produce photo-1.jpg/photo-2.jpg on the
posted entry, and the top-level photo_order POST key never appears in the entry
frontmatter. Adds a second image fixture so both slots are exercised.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 20:26:04 +02:00
m038andClaude Opus 4.8 d19a5802ae test(post-form): assert photo section auto-collapse + summary bar
Retarget AE1's completed-item assertion (the item is now hidden once the
section auto-collapses) to the "✓ N photo ready" summary, and add a spec
covering collapse-after-upload + re-expand on tap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 19:47:00 +02:00
m038 ab5db71f35 test(post-form): assert post-success confirmation + view-journal CTA 2026-07-04 19:23:44 +02:00
m038 b3d3a8e8b8 docs: mark journal-post-form plan complete (U1-U7) 2026-07-04 17:53:44 +02:00
m038 27a35a1db8 test(post-form): realign photo specs to FilePond after U4 rework
waitForPhotoUpload waits on FilePond processing-complete; AE1/AE4 use
input.filepond--browser + the .photo-convert-status error status; V3/V4 back
to FilePond limit + non-image rejection; reauth hint -> .photo-reauth-hint;
test-form-config asserts type:filepond. Verified against a live server via a
browser smoke run (HEIC->JPEG attach, corrupt fail-closed, draft restore).
2026-07-04 17:52:09 +02:00
m038 421c21345e test(post-form): retarget suite to redesigned /post + add UX coverage (U7)
- test-form-config.sh: assert parent is NOT hardcoded (injected server-side),
  active_trip set in site.yaml, and the new fields incl. custom 'photos' type.
- helpers.js: resolve active trip from site.active_trip (parent coupling gone);
  fillEditor() drives EasyMDE via window.postFormEditor; waitForPhotoUpload()
  waits on the new picker.
- post.spec / validation.spec: content via the editor, filepond selectors ->
  the photo picker, P8 checks editor value, V3/V4 exercise the picker cap +
  fail-closed non-image.
- post-form-ux.spec.js (new): AE3 disclosure, AE1 HEIC->JPEG, AE4 corrupt-HEIC
  fail-closed, R18 weather gating, R20 draft restore.
- fixtures: real + corrupt .heic.
- test-post.sh: resolve dailies dir from active_trip.

Refs AE1-AE4, R18, R20, U7.
2026-07-04 16:56:07 +02:00
81 changed files with 7598 additions and 493 deletions
+1 -1
View File
@@ -59,7 +59,7 @@ WEBROOT=/home/example.com/public_html
SITE_CONFIG_DIR=/home/example.com/site-config SITE_CONFIG_DIR=/home/example.com/site-config
# Grav version installed by scripts/server-install.sh (remote-install). # Grav version installed by scripts/server-install.sh (remote-install).
GRAV_VERSION=2.0.4 GRAV_VERSION=2.0.7
# Repos cloned/pulled on the server. # Repos cloned/pulled on the server.
USER_REPO=https://gitea.example.com/org/intotheeast-user.git USER_REPO=https://gitea.example.com/org/intotheeast-user.git
+2
View File
@@ -2,6 +2,8 @@
.env .env
.env.prod .env.prod
.env.test .env.test
# Per-worktree dev-server identity, written by `make worktree-new`
.worktree-env
# Grav CMS # Grav CMS
/user/ /user/
+70 -207
View File
@@ -1,213 +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.4 stable (baked into the custom Docker image via `Dockerfile`; server upgrades in place via `bin/gpm self-upgrade`)
- **Admin:** Admin2 v2.0.10 (plugin slug: `admin2`, NOT `admin`)
- **GPM channel:** `stable` — set in `user/config/system.yaml``gpm.releases` (authoritative). `GRAV_CHANNEL=production` in `docker-compose.yml` is cosmetic/consistency only
- **Plugin management:** `admin2`, `api`, and `flex-objects` are now **GPM-managed via `plugins.txt`** (installed by `make install-plugins`), no longer hand-extracted from the core bundle. `git-sync` stays **remote-only** — never in `plugins.txt`
- **Docker image:** `getgrav/grav` with `GRAV_CHANNEL=production`
- **PHP session:** `session.save_path = /tmp` set in `php/php-local.ini`
> Known issue (2026-07-04): Form 9.1.10 regressed the `filepond` upload field — on the post-submit re-render, `filepond.html.twig` runs `merge` on a string and 500s. The journal entry still saves correctly; only the browser re-render errors. This breaks the 6 `post.spec.js` UI specs. Being fixed separately in the form-to-page/image-upload rework — **do not** work around it here.
### Dev server
The Docker dev server runs at **http://localhost:8081** (mapped from container port 80 in `docker-compose.yml`).
### Trip entity architecture
The site is structured around Trip entities. Key facts:
- Active trip is set in `user/config/site.yaml``active_trip: japan-korea-2026`
- Trip pages live at `user/pages/01.trips/<slug>/`
- Each trip has two content subfolders: `01.dailies/` (journal entries) and `04.stories/` (stories). The former `02.map/` and `03.stats/` standalone views were **removed** (2026-07-04, see `docs/working/plans/2026-07-04-standalone-page-cleanup.md`) — map and stats now render inline on the trip page
- `01.dailies/` and `04.stories/` are `routable:false` **data containers** — visiting `/trips/<slug>/dailies` or `/stories` directly 404s/redirects; their children (entries/stories) render at their own detail URLs and are aggregated by the trip page
- Site nav in `base.html.twig` has Home + Past Trips only — does not link to trip sub-sections
- Post form parent (`post-form.md``pageconfig.parent`) **must be kept in sync** with `active_trip`
- The trip page (`trip.html.twig`) uses a **client-side filter bar** (All content / Journal / Stories). The standalone `/dailies`, `/map`, `/stats`, `/stories` view pages no longer exist — do NOT try to re-create them or link to them. This filter bar + stats chrome is shared with the home active-trip view via the `trip-feed-col` partial (see "Shared trip-feed-col partial" below)
- Stats are shown inline on the trip page via a toggle (the standalone `/stats` view was removed)
- GPX route files live as media on the trip page itself, parsed client-side via toGeoJSON (bundled into `js/map.js`) and drawn on the trip/home map
- Manage GPX files (view/upload/delete) at `/gpx-manager` — requires admin login; filenames are auto-slugified on upload
### One map path: `MapUtils.initEntryMap` + the `entry-map` partial
There is a **single** map code path on the site. The engine is `MapUtils.initEntryMap(opts)` in `js/src/maplibre-utils.js` (bundled into `js/map.js` via `make build-assets` — never hand-edit `js/map.js`). It builds the MapLibre map, places markers/popups, fits bounds, draws the GPX journey, and wires the fullscreen toggle.
The map **markup + invocation** is shared via one partial:
- **Partial:** `user/themes/intotheeast/templates/partials/entry-map.html.twig`
- **Used by:** `trip.html.twig` and the active branch of `home.html.twig` (both via `{% include ... with {...} only %}`)
It renders the `.home-map-col` column (map div `#{{ map_id }}` + fullscreen button) and, when `entries` is non-empty, a thin `<script>` that assigns `window.{{ map_global }}` from `initEntryMap`. Callers resolve header values (use_gpx / autoconnect) and pass them in.
**Parameters:**
| Parameter | Type | Trip passes | Home passes |
|---|---|---|---|
| `map_id` | string | `'trip-map'` | `'home-map'` |
| `map_global` | string | `'tripMap'` | `'homeMap'` |
| `entries` | array | `[{lat, lng, slug, title, url, type?, force_connect, ...}]` | same |
| `card_prefix` | string | `'entry-'` | `'entry-'` |
| `story_markers` | bool | `true` (diamond markers) | `false` |
| `gpx_urls` | array | `gpx_urls` | `home_gpx_urls` |
| `use_gpx` | bool | `page.header.use_gpx ?? true` | derived from `trip.header` |
| `autoconnect` | string | `page.header.autoconnect ?? 'on'` | derived from `trip.header` |
| `gpx_source_prefix` | string | `'gpx'` | `'home-gpx'` |
| `journey_id` | string | `'trip-journey'` | `'home-journey'` |
The map globals `window.tripMap` / `window.homeMap` are asserted by the Playwright map specs, so any surface using this partial must keep assigning them.
> History: this replaced the old three-variant setup (a `feed-map.html.twig` partial with its own inline init, plus a full-page `map.html.twig`). Those were deleted in the 2026-07-04 standalone-page cleanup; the `2026-06-27-map-init-consolidation` plan had already moved trip + home onto `initEntryMap`.
### Shared trip-feed-col partial
The home page's active-trip view and the trip page render the **same feed-col chrome** (date-range header, filter bar, stats/cycling panels, feed loop) via one shared Twig partial. This is separate from the `entry-map` partial above — it is the column **beside** the map, not the map.
- **Partial:** `user/themes/intotheeast/templates/partials/trip-feed-col.html.twig`
- **Used by:** `trip.html.twig` and the active branch of `home.html.twig` (both via `{% include ... with {...} only %}`)
- **Sibling:** `partials/home-predeparture.html.twig` — the home-only "Coming soon" landing state. `home.html.twig` picks it with `{% if all_items|length == 0 %}``home-predeparture` `{% else %}``trip-feed-col`. Keep `trip-feed-col` single-purpose — do NOT fold the pre-departure branch back into it.
**Parameters (`trip-feed-col`):**
| Parameter | Type | Trip passes | Home-active passes |
|---|---|---|---|
| `trip_page` | Page | `page` | `trip` |
| `all_items` | array | sorted by date, flag 4 (oldest→newest) | sorted by date, flag 3 (newest→oldest) |
| `journal_entries` | array | dailies children | dailies children |
| `journal_count` / `story_count` | int | counts | counts |
| `has_gpx` | bool | `has_gpx` | `home_gpx_urls\|length > 0` |
| `gpx_urls` | array | `gpx_urls` | `home_gpx_urls` |
| `gps_points` | array | `gps_points` | `gps_points` |
| `show_sort` | bool | `true` | `false` (home keeps its own feed order, no sort button) |
`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
Two places hardcode the active trip slug. Grav's config and page frontmatter are static YAML — no variable substitution is possible, so these cannot read from `site.yaml` automatically. **Both must be updated together** when starting a new trip, or entries will be posted to the wrong folder.
| File | Key | Example value |
|---|---|---|
| `user/config/site.yaml` | `active_trip` | `italy-2027` |
| `user/pages/02.post/post-form.md` | `pageconfig.parent` | `/trips/italy-2027/dailies` |
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.
### Content sync
- `make content-push` — commit and push `user/` to Gitea (triggers production pull via webhook)
- `make content-pull` — pull latest from Gitea to local
- `plugins.txt` is manually maintained — installing a plugin via Admin does NOT update it
- `make demo-load` — load demo content into `italy-2026-demo` trip (12 journal entries + 4 stories + 7 GPX files); source in `user/docs/demo/trips/italy-2026-demo/`
- `make demo-reset` — remove the entire `italy-2026-demo` pages folder and clear cache (full reset; re-run demo-load to restore)
### User repo gitignore
Only these folders are tracked in the `user/` Git repo: `pages/`, `config/`, `accounts/`, `themes/`. The `plugins/` and `data/` folders are excluded.
### Dual-repo submodule structure
`user/` is a **git submodule** of the outer repo (`.gitmodules` at the root; git dir absorbed into `.git/modules/user`). Full workflow: `docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md`. The essentials:
- **Two repos, two cadences.** Outer repo = dev environment (tests/docs/scripts/Docker). `user/` = content + theme, with its own remote and `make content-push` cadence. The outer repo pins an exact `user/` commit via the `user` gitlink.
- **Pointer-bump convention.** Routine content changes → **do not** bump the pin (leave it stale; harmless). At the **end of a cross-repo feature** → bump the pin once to the finished `user/` commit. Pin a commit reachable from `user/`'s published `main` (prefer the merge-to-main commit, not a squash-away branch tip), and **push `user/` before the outer repo** (superproject references a child SHA that must already exist upstream). The pin is dev-side coordination only — production pulls `user/` via the content webhook independently.
- **`M user` / `m user` is normal.** `M` = pin differs from `user/` HEAD (bump pending/intentional). `m` = submodule working tree dirty (e.g. local-testing `config/site.yaml`). Neither is an error — do not "fix" them by committing the gitlink or the `site.yaml`.
- **Worktrees for parallel work.** A worktree off `main` gets its own `user/` (`git submodule update --init user`) and can run its own dev server (`docker compose -p itte-<feature> up` — the `./user` mount is relative, so each worktree serves its own content). Tooling worktrees live under `.worktrees/` (excluded via `.git/info/exclude`). 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 (not yet configured)
Before going live, change in `user/config/system.yaml`:
| Setting | Prod value | Why |
|---|---|---|
| `twig.cache` | `true` | Templates compiled once and reused; safe because theme files don't change at runtime |
**Pre-launch smoke test required:** with `twig.cache: true`, submit one post via `/post` and confirm the entry appears in the trip page feed at `/trips/italy-2026-demo` immediately. This verifies the cache-on-save plugin (BUG-001 fix) works correctly with caching enabled.
### What the cache-on-save plugin handles
The custom plugin at `user/plugins/cache-on-save/` clears Grav's page-tree cache on every `new-entry` form submission. This ensures new posts appear in the tracker feed immediately in both modes — it does not depend on whether Twig caching is on or off.
## 2. Local development setup
Full setup guide: [`docs/guides/local-setup.md`](docs/guides/local-setup.md)
### Superpowers skill paths
Specs: `docs/working/specs/YYYY-MM-DD-<topic>-design.md`
Plans: `docs/working/plans/YYYY-MM-DD-<topic>.md`
The brainstorming and writing-plans skills default to `docs/superpowers/`; these lines override that default.
### Plan status convention
Every plan in `docs/working/plans/` must have a `**Status:**` line immediately after the title heading:
| Status | Meaning |
|---|---| |---|---|
| `📋 Not started` | Plan written; work not yet begun | | How the site hangs together — stack, plugin roles, templates, partial contracts, data flows | [`docs/reference/architecture.md`](docs/reference/architecture.md) |
| `🔄 In progress — <note>` | Actively being worked on | | Domain vocabulary — Trip, Entry, Story, Active Trip | [`CONCEPTS.md`](CONCEPTS.md) |
| `⏸️ Deferred — <reason>` | Intentionally postponed | | Doing something operational — posting, writing stories, GPX, switching trips, local setup, deploying | [`docs/guides/`](docs/guides/) |
| `✅ Complete (YYYY-MM-DD)` | Done | | Test suite layout and conventions | [`docs/reference/testing.md`](docs/reference/testing.md) |
| `❌ Abandoned<reason>` | Won't implement | | A bug or workflow trap already hit and written up | [`docs/solutions/`](docs/solutions/)grep the `module`/`tags`/`problem_type` frontmatter; check when working in a documented area |
| Folder map, prerequisites, the full `make` command list | [`README.md`](README.md) |
**When asked what's open:** surface `Not started` and `In progress` plans. Show `Deferred` plans but label them clearly. Omit `Complete` and `Abandoned` unless explicitly asked. The site is Grav (flat-file PHP CMS, no database) in Docker, with content and theme in the `user/` submodule.
**When finishing a plan:** update the `**Status:**` field in the plan file to `✅ Complete (YYYY-MM-DD)` before closing the session. This applies whether execution was done by Claude directly, via the superpowers:executing-plans skill, or via superpowers:subagent-driven-development. ## Hard rules
- **Only ever write inside `travel-blog-intotheeast/`** or its subfolders.
- **Never read `.env`, `.env.prod`, `.env.test`** — they hold credentials. Pass them to commands (`make`, `docker compose`) but never read them; ask the user if you need a value.
- **Never SSH to a server directly** — use the `make remote-*` targets, since credentials live in `.env`. If no target covers what you need, ask the user to run it or propose a new target.
- **Never hand-edit build output** — sources and outputs share folders under `user/themes/intotheeast/` (paths below are relative to it), so know which is which. Run `make build-assets` after editing any source.
- Everything in `js/` is **generated** *except* `js/src/`, `js/maplibre-utils.js` and `js/nav.js`.
- `css-compiled/` and `fonts/` are 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.
## Dev environment
- 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).
## Content and trips
- The active trip lives in **one** place: `user/config/site.yaml``active_trip`, and its value is a **route** (`/trips/denmark-2026`), not a bare slug.
- `cache-on-save` derives the post write target from `active_trip` at submit time. **Never re-add a `pageconfig.parent` to `post-form.md`** — a static parent would override it and reintroduce the old silent-desync bug. Switching trips: [`docs/guides/trip-switching.md`](docs/guides/trip-switching.md).
- The standalone `/dailies`, `/map`, `/stats` and `/stories` trip views were **deleted** (2026-07-04) — map, stats, and filtering all render inline on the trip page. Do not re-create them or link to them. `01.dailies/` and `04.stories/` are `routable:false` data containers whose children are aggregated by the trip page.
- GPX routes are page media on the trip page, auto-detected — no manual linking. Manage them at `/gpx-manager` (admin login): [`docs/guides/gpx-manager.md`](docs/guides/gpx-manager.md).
- `make content-push` commits and pushes `user/` to Gitea, which triggers the production pull; `make content-pull` is the reverse.
## Two shared partials — the rules
Trip and home render the same map and feed chrome through two shared partials, both included `with {…} only`. Parameter contracts: [`docs/reference/architecture.md`](docs/reference/architecture.md) → "Shared partial contracts". What must not break:
- **`partials/entry-map.html.twig` is the only path for a *display* map** — the engine is `MapUtils.initEntryMap(opts)` in `js/maplibre-utils.js` (a hand-authored file, imported by `js/src/map.js`). Do not add another display-map implementation; an older three-variant setup was deliberately consolidated away.
- **One sanctioned exception: `js/src/location-map.js`**, the `/post` form's pin *editor* (one draggable marker, no popups/GPX/bounds-fitting, `maplibre-gl` lazy-imported so a GPS-only submit never fetches it). It shares exactly one thing with the display path — `MAP_STYLE` from `js/src/map-style.js`, imported by both so the basemap cannot drift. Do not fold it into `initEntryMap`, and do not add a *third* path.
- It must keep assigning **`window.tripMap` / `window.homeMap`** — the Playwright map specs assert those globals.
- **Keep `trip-feed-col.html.twig` single-purpose.** Its sibling `partials/home-predeparture.html.twig` is the home-only "Coming soon" state — do **not** fold the pre-departure branch back into it.
## Dual-repo submodule structure
`user/` is a git submodule with its own Gitea remote and its own cadence; the outer repo pins an exact commit. Full workflow, worktree mechanics, teardown: [`docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md`](docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md).
- **`M user` / `m user` is normal, not an error.** `M` = the pin differs from `user/` HEAD; `m` = the submodule working tree is dirty (e.g. a local-testing `site.yaml`). Do not "fix" either by committing the gitlink or that `site.yaml`.
- **Don't bump the pin for routine content changes.** Bump it once at the end of a cross-repo feature, to a commit reachable from `user/`'s published `main`, and **push `user/` before the outer repo**.
- **Use `make worktree-new NAME=<x>` / `make worktree-rm NAME=<x>`** — never a hand-rolled `git worktree add`. The targets initialise the submodule and an isolated dev server; skipping the deinit on teardown is what leaves orphaned `.worktrees/` dirs.
## Testing
`make test` runs everything (`test-config``test-post``test-ui`). **The dev server must be running** — every suite drives the live site over HTTP. Layout, helpers, and per-suite commands: [`docs/reference/testing.md`](docs/reference/testing.md).
- **Auth is a dependency project.** `auth.setup.js` writes `tests/.auth/user.json`, which the `chromium` project reuses as `storageState`. Never add per-test logins.
- The `testrunner` admin account is created automatically and is git-ignored — never commit it, and keep its password free of shell/Make/URL-special characters, since several consumers interpolate it.
- `retries: 0`, so a failing test is a real failure, not flake.
## Working docs
Specs go in `docs/working/specs/YYYY-MM-DD-<topic>-design.md`, plans in `docs/working/plans/YYYY-MM-DD-<topic>.md`. These paths override the `docs/superpowers/` default used by the brainstorming and writing-plans skills.
Every plan needs a `**Status:**` line immediately after its title heading: `📋 Not started` · `🔄 In progress — <note>` · `⏸️ Deferred — <reason>` · `✅ Complete (YYYY-MM-DD)` · `❌ Abandoned — <reason>`.
- **When asked what's open:** surface `Not started` and `In progress`; show `Deferred` but label it clearly; omit `Complete` and `Abandoned` unless explicitly asked.
- **When finishing a plan:** set its status to `✅ Complete (YYYY-MM-DD)` before closing the session — whether you executed it directly or via the executing-plans / subagent-driven-development skills.
+26 -1
View File
@@ -12,7 +12,12 @@ A **Trip** owns its **Entries** and **Stories**. Exactly one Trip is the **Activ
A single journey the blog is organised around — the top-level content entity. A Trip aggregates its Entries and Stories and carries its own metadata (title, start/end dates, cover image, route GPX files). Each Trip renders as one consolidated **Trip page** showing an inline map, a filtered feed, and inline stats; the journal, map, stats, and story views are not separate pages. A single journey the blog is organised around — the top-level content entity. A Trip aggregates its Entries and Stories and carries its own metadata (title, start/end dates, cover image, route GPX files). Each Trip renders as one consolidated **Trip page** showing an inline map, a filtered feed, and inline stats; the journal, map, stats, and story views are not separate pages.
### Active Trip ### Active Trip
The one Trip currently featured — set in site config and read by the home page and the post form. Switching the Active Trip is a deliberate, multi-file change; if the post form's target and the featured Trip fall out of sync, new posts land under the wrong Trip. The one Trip currently featured — set in a single site-config value and read by the home page and the posting pipeline, which derives the write target for new Entries from it at submit time. Switching the Active Trip is that one setting; there is no separate post-form target to keep in sync.
### Published / Draft
A Trip's visibility state. A **Published** Trip is listed publicly and reachable by anyone; a **Draft** Trip is hidden from anonymous visitors in the public trip list, while the signed-in owner still sees it (marked "Draft") and can flip it back. The owner toggles this per Trip from the trip list.
Unpublishing the **Active Trip** additionally drops it from the public home page, which falls back to its between-trips landing. The toggle is owner-only; a Draft is a visibility control, not privacy — a Draft Trip's Entries, Stories, and media stay reachable by direct link.
### Entry ### Entry
A single dated journal post within a Trip — the atomic unit of the day-to-day travel log. A single dated journal post within a Trip — the atomic unit of the day-to-day travel log.
@@ -23,6 +28,26 @@ The Trip's journal section is labelled "Journal" and lives in the Trip's `dailie
### Story ### Story
A long-form, designed narrative piece within a Trip — hero image plus scrollytelling/gallery sections — distinct from the short, dated Entry. Stories are curated set pieces; Entries are the running log. A long-form, designed narrative piece within a Trip — hero image plus scrollytelling/gallery sections — distinct from the short, dated Entry. Stories are curated set pieces; Entries are the running log.
### Container
A Trip's non-routable holder of child pages — one for Entries, one for Stories. A Container's own URL is deliberately inert (it renders no page of its own), while its children stay individually reachable and are aggregated onto the Trip page. Retiring a view must never delete its Container: the folder half is load-bearing data even when the page half is gone.
## Repos & deployment
### Content repo
The repository holding everything the site serves — pages, configuration, accounts, the theme. It has its own remote and its own release cadence: pushing it triggers production to pull via webhook, independent of the Outer repo.
### Outer repo
The dev-environment repository — tests, docs, scripts, container build — that nests the Content repo and records a Pin to an exact Content-repo commit, expressing "this dev-env state expects this content/theme state."
### Pin
The Outer repo's recorded Content-repo commit (also "pointer bump" for the act of updating it). Routine content churn never moves it; it is bumped once at the end of a cross-repo feature, to a commit already published on the Content repo's main branch. A stale Pin during normal work is expected, not an error.
### Env tree
A server's per-host configuration overlay. Once it exists, Grav's Admin writes **all** config edits there rather than into the shared configuration — so server-side Admin edits are server-only, invisible to content sync, and can hold live secrets. Diagnosing config on a server means checking both the shared configuration and the Env tree, with the Env tree winning at runtime.
### Remote-only plugin
One of the project's three plugin-management categories, alongside GPM-managed (declared in the shared install list and restored by the standard install flow) and custom-in-repo (code tracked in the Content repo). A Remote-only plugin is installed explicitly on servers and restored by **no** standard flow — if its code goes missing it stays missing until someone reinstalls it deliberately, even while its configuration persists in the Env tree.
## Flagged ambiguities ## Flagged ambiguities
- "daily" / "entry" / "journal post" all refer to the same concept (a dated journal post). Canonical term: **Entry**. The section/folder is named "dailies" and the nav label is "Journal" — these name the *collection*, not a different entity. - "daily" / "entry" / "journal post" all refer to the same concept (a dated journal post). Canonical term: **Entry**. The section/folder is named "dailies" and the nav label is "Journal" — these name the *collection*, not a different entity.
+1 -1
View File
@@ -1,6 +1,6 @@
FROM getgrav/grav FROM getgrav/grav
RUN curl -sL 'https://github.com/getgrav/grav/releases/download/2.0.4/grav-admin-v2.0.4.zip' \ RUN curl -sL 'https://github.com/getgrav/grav/releases/download/2.0.7/grav-admin-v2.0.7.zip' \
-o /tmp/grav-admin.zip \ -o /tmp/grav-admin.zip \
&& unzip -q /tmp/grav-admin.zip -d /tmp \ && unzip -q /tmp/grav-admin.zip -d /tmp \
&& cp -rf /tmp/grav-admin/assets /var/www/html/ \ && cp -rf /tmp/grav-admin/assets /var/www/html/ \
+265 -28
View File
@@ -3,6 +3,12 @@
# for ${UID}/${GID} substitution and the travel-memories env_file.) # for ${UID}/${GID} substitution and the travel-memories env_file.)
-include .env -include .env
# Per-worktree dev-server identity, written by `make worktree-new` into the new
# worktree only (git-ignored). Absent in the main checkout, so the defaults below
# apply there. Loaded here so every local target + compose call in a worktree
# targets that worktree's own container and ports.
-include .worktree-env
# Remote config — loaded only when targeting an environment. ENV is set # Remote config — loaded only when targeting an environment. ENV is set
# automatically by the env-suffixed remote targets (e.g. `make remote-install-prod`); # automatically by the env-suffixed remote targets (e.g. `make remote-install-prod`);
# each .env.<ENV> holds a full, self-contained set of remote vars. # each .env.<ENV> holds a full, self-contained set of remote vars.
@@ -14,6 +20,10 @@ REMOTE_PORT ?= 22
SSH := ssh -p $(REMOTE_PORT) $(REMOTE_USER)@$(REMOTE_HOST) SSH := ssh -p $(REMOTE_PORT) $(REMOTE_USER)@$(REMOTE_HOST)
WEBROOT ?= $(REMOTE_HOME)/public_html WEBROOT ?= $(REMOTE_HOME)/public_html
SITE_CONFIG_DIR ?= $(REMOTE_HOME)/site-config SITE_CONFIG_DIR ?= $(REMOTE_HOME)/site-config
# Hostname Grav uses to pick its per-environment config (user/env/<host>/).
# Defaults to the SSH host; override in .env.<ENV> only if the web hostname
# Grav sees differs from the SSH host (e.g. an addon domain on a shared box).
WEB_HOST ?= $(REMOTE_HOST)
# ── Environment guard + generated per-env remote targets ────────────────────── # ── Environment guard + generated per-env remote targets ──────────────────────
# Every remote-* target below gains `-test` / `-prod` variants, e.g. # Every remote-* target below gains `-test` / `-prod` variants, e.g.
@@ -22,7 +32,10 @@ SITE_CONFIG_DIR ?= $(REMOTE_HOME)/site-config
REMOTE_TARGETS := remote-env-setup remote-env-remove remote-wipe remote-install \ REMOTE_TARGETS := remote-env-setup remote-env-remove remote-wipe remote-install \
remote-fetch remote-fetch-content remote-install-plugins remote-update-plugins \ remote-fetch remote-fetch-content remote-install-plugins remote-update-plugins \
remote-upgrade-grav remote-git-sync-disable remote-git-sync-enable \ remote-upgrade-grav remote-git-sync-disable remote-git-sync-enable \
remote-content-status remote-clean remote-maintenance-on remote-maintenance-off remote-content-status remote-clean remote-warmup remote-diag remote-apply-env \
remote-seed-api-salt remote-secrets-audit \
remote-gpm-install remote-maintenance-on remote-maintenance-off \
remote-apply-plugin-patches
ENVS := test prod ENVS := test prod
guard-env: guard-env:
@@ -41,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 intotheeast_grav 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:
@@ -52,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
@@ -59,17 +85,49 @@ test: test-config test-post test-ui
# ── Local dev ────────────────────────────────────────────────────────────────── # ── Local dev ──────────────────────────────────────────────────────────────────
# Dev-server identity. Defaults are the main checkout's canonical values; a
# worktree's .worktree-env (above) overrides them so servers never collide.
# Exported (via the top-of-file `export`) so `docker compose` picks them up.
GRAV_CONTAINER ?= intotheeast_grav
GRAV_PORT ?= 8081
TM_PORT ?= 8082
# The container boots as root (the base image entrypoint needs it to bind :80
# and set up cron), so a bare `docker exec` runs as root and any file it writes
# into the ./user bind mount is root-owned on the host. Run the file-CREATING
# CLI commands as the host user instead, so their output belongs to you.
HOST_UID := $(shell id -u)
HOST_GID := $(shell id -g)
build: build:
docker compose build docker compose build
build-assets: build-assets:
docker run --rm \ # --user: outputs (node_modules, js/ bundles, css-compiled/) land in the
# tracked theme tree owned by the host user, not root. HOME=/tmp gives npm
# a writable cache when running as a non-root uid.
docker run --rm --user $(HOST_UID):$(HOST_GID) -e HOME=/tmp \
-v $(PWD)/user/themes/intotheeast:/app \ -v $(PWD)/user/themes/intotheeast:/app \
-w /app node:20-alpine \ -w /app node:20-alpine \
sh -c "npm install && npm run build" sh -c "npm install && npm run build"
# In a worktree this degrades to start-grav. The travel-memories service declares
# `env_file: .env`, and worktree-new does not create a .env, so a plain
# `docker compose up -d` there dies with "env file ... not found" — leaving the
# worktree with no server at all, which is how test runs ended up silently
# targeting the main checkout.
start: start:
docker compose up -d @if [ -f .worktree-env ]; then \
echo "→ worktree: starting the grav service only (travel-memories needs a .env, which worktrees have none)"; \
docker compose up -d grav; \
else \
docker compose up -d; \
fi
# Grav service only — used by `make worktree-new` (a worktree rarely needs the
# travel-memories service, and this keeps its footprint minimal).
start-grav:
docker compose up -d grav
stop: stop:
docker compose down docker compose down
@@ -77,36 +135,113 @@ stop:
setup: build start install-plugins fix-perms setup: build start install-plugins fix-perms
fix-perms: fix-perms:
docker exec intotheeast_grav bash -c "getent passwd 1000 > /dev/null || useradd -u 1000 -M hostuser" docker exec $(GRAV_CONTAINER) bash -c "getent passwd 1000 > /dev/null || useradd -u 1000 -M hostuser"
docker exec intotheeast_grav chown -R 1000:1000 /var/www/html docker exec $(GRAV_CONTAINER) chown -R 1000:1000 /var/www/html
docker exec intotheeast_grav apachectl graceful docker exec $(GRAV_CONTAINER) apachectl graceful
install-plugins: install-plugins:
docker exec -w /var/www/html intotheeast_grav php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y # cache/ and tmp/ are root-owned in the image, so make them writable first
# (container-internal chown — never touches the host) so gpm can run AS YOU.
docker exec $(GRAV_CONTAINER) chown -R $(HOST_UID):$(HOST_GID) /var/www/html/cache /var/www/html/tmp
# gpm runs as the host user, so the plugins it writes into ./user/plugins are
# owned by you, not root — no post-hoc chown, no root files to clean up later.
docker exec -u $(HOST_UID):$(HOST_GID) -w /var/www/html $(GRAV_CONTAINER) php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y
$(MAKE) apply-plugin-patches
# Re-apply local fixes to git-ignored, GPM-managed third-party plugins. Run this
# AFTER install-plugins (which overwrites them). See deploy/patches/README.md.
apply-plugin-patches:
@for p in deploy/patches/*.patch; do \
[ -f "$$p" ] || continue; \
if git apply --check "$$p" >/dev/null 2>&1; then \
git apply "$$p" && echo "applied $$p"; \
else \
echo "skipped $$p (already applied or does not match)"; \
fi; \
done
# ── Worktrees ─────────────────────────────────────────────────────────────────
# Isolated outer-repo worktree + its own user/ submodule checkout + its own dev
# server (distinct container name & ports), for long-running feature work that
# runs in parallel with the main checkout without collisions. Encodes the full
# SOP from docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md
# so no step (submodule init, per-server isolation, clean teardown) is skipped.
#
# make worktree-new NAME=my-feature [PORT=8090] # create branch + start server
# make worktree-rm NAME=my-feature # tear down cleanly
#
# Run both from the MAIN checkout. After worktree-new, `cd .worktrees/<name>`
# and use make as normal — it targets that worktree's own server automatically.
WT_DIR = .worktrees/$(NAME)
guard-name:
@test -n "$(NAME)" || { echo "ERROR: set NAME=, e.g. 'make worktree-new NAME=my-feature'."; exit 1; }
worktree-new: guard-name
@test ! -e "$(WT_DIR)" || { echo "ERROR: $(WT_DIR) already exists."; exit 1; }
git worktree add "$(WT_DIR)" -b feat/$(NAME) main
git -C "$(WT_DIR)" submodule update --init user
git -C "$(WT_DIR)/user" checkout -b feat/$(NAME)
@port=$${PORT:-$$(for p in $$(seq 8090 8099); do \
docker ps --format '{{.Ports}}' | grep -q ":$$p->" || { echo $$p; break; }; \
done)}; \
test -n "$$port" || { echo "ERROR: no free port in 8090-8099; pass PORT= explicitly."; exit 1; }; \
printf 'COMPOSE_PROJECT_NAME=itte-%s\nGRAV_CONTAINER=itte_%s_grav\nGRAV_PORT=%s\nTM_PORT=%s\n' \
"$(NAME)" "$(NAME)" "$$port" "$$((port + 100))" > "$(WT_DIR)/.worktree-env"; \
echo "→ starting this worktree's Grav dev server on http://localhost:$$port"; \
$(MAKE) -C "$(WT_DIR)" start-grav
@echo "Worktree ready: $(WT_DIR) (outer + user/ on branch feat/$(NAME))"
worktree-rm: guard-name
@test -e "$(WT_DIR)" || { echo "ERROR: $(WT_DIR) does not exist."; exit 1; }
-$(MAKE) -C "$(WT_DIR)" stop
-git -C "$(WT_DIR)" submodule deinit -f user
git worktree remove --force "$(WT_DIR)"
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)"
# ── Demo content ────────────────────────────────────────────────────────────── # ── Demo content ──────────────────────────────────────────────────────────────
demo-load: demo-load:
# Load italy-2026-demo trip (create pages if absent) # Load every fixture trip under docs/demo/trips/ into the pages tree.
docker exec intotheeast_grav bash -c "\ # Source uses dailies/ + 04.stories/; dailies/ maps to 01.dailies/ on copy.
mkdir -p /var/www/html/user/pages/01.trips/italy-2026-demo/01.dailies /var/www/html/user/pages/01.trips/italy-2026-demo/04.stories && \ # All copies are `|| true` so a fixture absent from an older user/ is skipped.
cp /var/www/html/user/docs/demo/trips/italy-2026-demo/trip.md /var/www/html/user/pages/01.trips/italy-2026-demo/trip.md 2>/dev/null || true && \ #
cp /var/www/html/user/docs/demo/trips/italy-2026-demo/stories.md /var/www/html/user/pages/01.trips/italy-2026-demo/04.stories/stories.md 2>/dev/null || true && \ # ⚠️ A fixture whose folder name matches a REAL trip's slug is copied straight
cp -r /var/www/html/user/docs/demo/trips/italy-2026-demo/04.stories/. /var/www/html/user/pages/01.trips/italy-2026-demo/04.stories/ 2>/dev/null || true && \ # over that live page — docs/demo/trips/italy-2025/ collides with the real
cp -r /var/www/html/user/docs/demo/trips/italy-2026-demo/dailies/. /var/www/html/user/pages/01.trips/italy-2026-demo/01.dailies/ && \ # italy-2025 trip on purpose (the fixture supplies its GPX + dailies). So any
cp /var/www/html/user/docs/demo/trips/italy-2026-demo/*.gpx /var/www/html/user/pages/01.trips/italy-2026-demo/ 2>/dev/null || true && \ # field the fixture's trip.md omits gets silently deleted from real content on
chown -R 1000:1000 /var/www/html/user/pages/01.trips/italy-2026-demo && \ # every test run: it had been dropping the trip's tagline that way. Keep a
cd /var/www/html && php bin/grav clearcache" # 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 \
slug=$$(basename "$$src"); dst=/var/www/html/user/pages/01.trips/$$slug; \
mkdir -p "$$dst/01.dailies" "$$dst/04.stories"; \
cp "$$src/trip.md" "$$dst/trip.md" 2>/dev/null || true; \
cp "$$src/stories.md" "$$dst/04.stories/stories.md" 2>/dev/null || true; \
cp -r "$$src/04.stories/." "$$dst/04.stories/" 2>/dev/null || true; \
cp -r "$$src/dailies/." "$$dst/01.dailies/" 2>/dev/null || true; \
cp "$$src"/*.gpx "$$dst/" 2>/dev/null || true; \
chown -R 1000:1000 "$$dst"; \
done; cd /var/www/html && php bin/grav clearcache'
demo-reset: demo-reset:
docker exec intotheeast_grav bash -c "rm -rf /var/www/html/user/pages/01.trips/italy-2026-demo && cd /var/www/html && php bin/grav clearcache" docker exec $(GRAV_CONTAINER) bash -c 'for src in /var/www/html/user/docs/demo/trips/*/; do \
rm -rf /var/www/html/user/pages/01.trips/$$(basename "$$src"); \
done; cd /var/www/html && php bin/grav clearcache'
pixelfed-import: pixelfed-import:
docker exec intotheeast_grav bash -c "which python3 || apt-get install -y python3 --no-install-recommends -q" docker exec $(GRAV_CONTAINER) bash -c "which python3 || apt-get install -y python3 --no-install-recommends -q"
docker cp /home/mischa/Nextcloud/Downloads/pixelfed/pixelfed-statuses.json intotheeast_grav:/tmp/pixelfed-statuses.json docker cp /home/mischa/Nextcloud/Downloads/pixelfed/pixelfed-statuses.json $(GRAV_CONTAINER):/tmp/pixelfed-statuses.json
docker cp scripts/pixelfed-import.py intotheeast_grav:/tmp/pixelfed-import.py docker cp scripts/pixelfed-import.py $(GRAV_CONTAINER):/tmp/pixelfed-import.py
docker exec -w /var/www/html intotheeast_grav python3 /tmp/pixelfed-import.py docker exec -w /var/www/html $(GRAV_CONTAINER) python3 /tmp/pixelfed-import.py
# ── Content sync (user repo ↔ Gitea) ────────────────────────────────────────── # ── Content sync (user repo ↔ Gitea) ──────────────────────────────────────────
@@ -153,26 +288,128 @@ remote-fetch-content: guard-env
$(SSH) "git -C $(WEBROOT)/user fetch origin main && git -C $(WEBROOT)/user sparse-checkout disable && git -C $(WEBROOT)/user reset --hard origin/main" $(SSH) "git -C $(WEBROOT)/user fetch origin main && git -C $(WEBROOT)/user sparse-checkout disable && git -C $(WEBROOT)/user reset --hard origin/main"
remote-install-plugins: guard-env remote-install-plugins: guard-env
$(SSH) "cd $(WEBROOT) && php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y" $(SSH) "cd $(WEBROOT) && php bin/gpm index -f && php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y"
$(MAKE) remote-apply-plugin-patches
remote-update-plugins: guard-env remote-update-plugins: guard-env
$(SSH) "cd $(WEBROOT) && php bin/gpm update -y && php bin/grav cache" $(SSH) "cd $(WEBROOT) && php bin/gpm update -y && php bin/grav cache"
$(MAKE) remote-apply-plugin-patches
# Re-apply local fixes to git-ignored, GPM-managed third-party plugins on the
# remote (pristine after a GPM install/update). Piped over SSH like the git-sync
# scripts — no scp. `--forward` makes it a no-op when already applied. Runs
# automatically after remote-install-plugins / remote-update-plugins; safe to run
# standalone. See deploy/patches/README.md.
remote-apply-plugin-patches: guard-env
@for p in deploy/patches/*.patch; do \
[ -f "$$p" ] || continue; \
echo "remote-apply $$p"; \
$(SSH) "cd $(WEBROOT) && patch -p1 --forward -r - --no-backup-if-mismatch" < "$$p" || echo " (already applied or no-op)"; \
done
$(SSH) "cd $(WEBROOT) && php bin/grav clearcache"
remote-upgrade-grav: guard-env remote-upgrade-grav: guard-env
$(SSH) "cd $(WEBROOT) && php bin/gpm self-upgrade -y && php bin/grav cache" $(SSH) "cd $(WEBROOT) && php bin/gpm self-upgrade -y && php bin/grav cache"
remote-git-sync-disable: guard-env remote-git-sync-disable: guard-env
$(SSH) "bash -s -- '$(WEBROOT)/user/config/plugins/git-sync.yaml' false" < scripts/git-sync-toggle.sh $(SSH) "bash -s -- '$(WEBROOT)' false" < scripts/git-sync-toggle.sh
remote-git-sync-enable: guard-env remote-git-sync-enable: guard-env
$(SSH) "bash -s -- '$(WEBROOT)/user/config/plugins/git-sync.yaml' true" < scripts/git-sync-toggle.sh $(SSH) "bash -s -- '$(WEBROOT)' true" < scripts/git-sync-toggle.sh
remote-content-status: guard-env remote-content-status: guard-env
$(SSH) "cd $(WEBROOT)/user && git status --short && echo '--- config diff ---' && git diff -- config/" $(SSH) "cd $(WEBROOT)/user && echo '--- HEAD ---' && git log -1 --oneline && echo '--- working tree ---' && git status --short && echo '--- config diff ---' && git diff -- config/ && echo '--- .gitignore diff ---' && git diff -- .gitignore"
remote-clean: guard-env remote-clean: guard-env
$(SSH) "cd $(WEBROOT) && php bin/grav clearcache" $(SSH) "cd $(WEBROOT) && php bin/grav clearcache"
# Post-deploy cache refresh: clear, then WARM. A `reset --hard` content deploy
# leaves Grav's compiled-Twig/page cache stale, and the first real visitor pays
# the recompile cost — so clear it and pre-render the public pages ourselves.
# Grav has no native warmup command, so this is an HTTP crawl of the live site:
# homepage + trips listing + every trip page linked from it (no sitemap plugin
# installed, so we scrape the listing instead of /sitemap.xml). The crawl runs
# from here over public HTTPS, so it also doubles as a smoke test — a non-200 on
# `/` is surfaced loudly. Run after every content deploy: `make remote-warmup-prod`.
remote-warmup: guard-env
$(SSH) "cd $(WEBROOT) && php bin/grav clearcache" >/dev/null
@base="https://$${WEB_HOST:-$(REMOTE_HOST)}"; \
echo "warming $$base (clear done) ..."; \
trip_urls=$$(curl -s "$$base/trips" | grep -oE '/trips/[a-z0-9][a-z0-9-]*' | sort -u); \
fail=0; \
for u in / /trips $$trip_urls; do \
code=$$(curl -s -o /dev/null -w '%{http_code}' "$$base$$u"); \
printf ' %-40s %s\n' "$$u" "$$code"; \
case "$$code" in 2*|3*) ;; *) fail=1;; esac; \
done; \
if [ "$$fail" = 1 ]; then echo "WARNING: one or more pages returned a non-2xx/3xx status"; else echo "warmup OK — all pages 2xx/3xx"; fi
# Install a single GPM package on the server (e.g. git-sync, which is
# intentionally NOT in plugins.txt — it is remote-only).
# Usage: make remote-gpm-install-prod PKG=git-sync
remote-gpm-install: guard-env
@test -n "$(PKG)" || { echo "ERROR: set PKG=<plugin-slug>"; exit 1; }
$(SSH) "cd $(WEBROOT) && php bin/gpm index -f && php bin/gpm install $(PKG) -y && php bin/grav clearcache"
# Deploy per-environment Grav config overrides to the server's
# user/env/<WEB_HOST>/config/ tree (deep-merged over the committed config).
# Source of truth: deploy/env/$(ENV)/system.yaml (version-controlled). This
# tree is outside the content repo, so it is NOT restored by content sync —
# re-run after any fresh install.
remote-apply-env: guard-env
@test -f deploy/env/$(ENV)/system.yaml || { echo "ERROR: missing deploy/env/$(ENV)/system.yaml"; exit 1; }
@host="$${WEB_HOST:-$(REMOTE_HOST)}"; \
test -n "$$host" || { echo "ERROR: WEB_HOST/REMOTE_HOST unresolved"; exit 1; }; \
$(SSH) "mkdir -p $(WEBROOT)/user/env/$$host/config && cat > $(WEBROOT)/user/env/$$host/config/system.yaml && cd $(WEBROOT) && php bin/grav clearcache" < deploy/env/$(ENV)/system.yaml; \
echo "Applied deploy/env/$(ENV)/system.yaml -> $(WEBROOT)/user/env/$$host/config/system.yaml"
# Seed a per-host popularity salt into the env override tree so the api plugin
# reads it there instead of appending one to the git-tracked config/plugins/
# api.yaml. That appended salt kept the content working tree dirty, which broke
# git-sync's auto-merge on webhook. Salt is generated server-side and never
# committed (a committed salt would be globally known). Idempotent: an existing
# salt is kept, so re-running never rotates it.
remote-seed-api-salt: guard-env
@host="$${WEB_HOST:-$(REMOTE_HOST)}"; \
test -n "$$host" || { echo "ERROR: WEB_HOST/REMOTE_HOST unresolved"; exit 1; }; \
$(SSH) "set -e; \
envfile=$(WEBROOT)/user/env/$$host/config/plugins/api.yaml; \
mkdir -p \$$(dirname \"\$$envfile\"); \
if grep -qE '^[[:space:]]*salt:' \"\$$envfile\" 2>/dev/null; then \
echo \"salt already present in \$$envfile — keeping it\"; \
else \
salt=\$$(openssl rand -hex 32); \
printf 'popularity:\n salt: %s\n' \"\$$salt\" > \"\$$envfile\"; \
echo \"seeded new per-host salt into \$$envfile\"; \
fi; \
git -C $(WEBROOT)/user checkout -- config/plugins/api.yaml 2>/dev/null || true; \
cd $(WEBROOT) && php bin/grav clearcache >/dev/null 2>&1 || true; \
echo '--- base api.yaml status (expect clean) ---'; \
git -C $(WEBROOT)/user status --short config/plugins/api.yaml; \
echo '(if the line above is empty, the tree is clean)'"
# Read-only health check: plugin install state, versions, key config, log tail.
remote-diag: guard-env
$(SSH) "cd $(WEBROOT) && \
echo '=== Grav version ==='; php bin/grav --version 2>/dev/null; \
echo '=== installed plugin versions ==='; for p in login admin2 flex-objects form api; do printf '%s: ' \"\$$p\"; grep -m1 '^version:' user/plugins/\$$p/blueprints.yaml 2>/dev/null || echo '(NOT installed)'; done; \
echo '=== what does GPM say about api? ==='; php bin/gpm info api 2>&1 | head -12; \
echo '=== api override (enabled/route/session) ==='; grep -nE '^enabled:|^route:|session_enabled:' user/config/plugins/api.yaml 2>&1; \
echo '=== per-env override present? ==='; for f in user/env/*/config/system.yaml; do echo \"\$$f:\"; cat \"\$$f\" 2>/dev/null | grep -E 'cache:|debug:|auto_reload:'; done; \
echo '=== twig cache populating? (non-empty => cache on) ==='; ls cache/twig/ 2>/dev/null | head -1 || echo '(empty)'; \
echo '=== git-sync config (secrets redacted) ==='; grep -vaiE 'password|token|secret' user/config/plugins/git-sync.yaml user/env/*/config/plugins/git-sync.yaml 2>/dev/null; \
echo '=== grav.log tail ==='; tail -8 logs/grav.log 2>/dev/null"
# Secret-safe audit: lists WHERE per-host secret/config files live (config/ vs
# env/<host>/config/) and their sizes — never prints contents. Used to decide
# whether a `reset --hard` would clobber a live runtime secret.
remote-secrets-audit: guard-env
$(SSH) "cd $(WEBROOT)/user && \
echo '=== tracked in git? (git ls-files) ==='; git ls-files config/security-private.php config/security.yaml config/versions.yaml config/plugins/api-private.php config/plugins/git-sync.yaml; \
echo '=== config/ copies (size only) ==='; ls -la config/security.yaml config/security-private.php config/versions.yaml config/plugins/api-private.php config/plugins/git-sync.yaml 2>&1; \
echo '=== env/<host>/config copies (size only) ==='; ls -la env/*/config/security.yaml env/*/config/security-private.php env/*/config/plugins/api-private.php env/*/config/plugins/git-sync.yaml 2>&1; \
echo '=== does security.yaml reference the private php? (key names only) ==='; grep -aoE '^[a-z_]+:' config/security.yaml 2>/dev/null; for f in env/*/config/security.yaml; do echo \"\$$f:\"; grep -aoE '^[a-z_]+:' \"\$$f\" 2>/dev/null; done; true"
remote-maintenance-on: guard-env remote-maintenance-on: guard-env
$(SSH) "bash -s on $(WEBROOT)" < scripts/server-maintenance.sh $(SSH) "bash -s on $(WEBROOT)" < scripts/server-maintenance.sh
+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 |
+43
View File
@@ -0,0 +1,43 @@
# Deployed-environment Grav config overrides (test AND prod).
#
# Both server environments share this one file so test stays a faithful dress
# rehearsal of prod: deploy/env/test/system.yaml is a symlink to this file.
# Edit here and both environments move together — never let them drift.
#
# Deep-merged OVER the committed user/config/system.yaml via Grav's
# per-environment config mechanism: on the server this file is deployed to
# <webroot>/user/env/<hostname>/config/system.yaml
# and Grav's `environment://config` stream (keyed on the request hostname)
# layers it on top of `user://config`.
#
# These values are deliberately NOT in the committed system.yaml because they
# would break local development (see CLAUDE.md §1 — dev keeps twig.cache:false
# so theme edits take effect immediately). They apply only on the deployed
# hosts, never on a local dev checkout.
#
# Deploy with: make remote-apply-env-test / make remote-apply-env-prod
# The user/env/ tree is outside the content repo's tracked folders, so it is
# NOT restored by content-push / git-sync / remote-fetch-content — re-run the
# target above after any fresh install.
twig:
cache: true
debug: false
auto_reload: false
# Compression / connection handling.
#
# This host is not FastCGI (no fastcgi_finish_request()), so Grav's shutdown
# "early connection close" falls back to emitting `Content-Encoding: identity`
# to ask the webserver not to compress. But Apache's mod_deflate compresses
# anyway and adds `Content-Encoding: gzip`, giving TWO conflicting headers —
# the browser can't decode the body and renders raw gzip bytes (a garbage
# page). Note: allow_webserver_gzip:true takes the SAME identity branch, so it
# does not help. The real fix is to disable the early-close path, so Grav never
# emits the bogus header and mod_deflate compresses cleanly (single header).
debugger:
shutdown:
close_connection: false
# Let the webserver own gzip; Grav does not compress or double-label.
cache:
gzip: false
allow_webserver_gzip: false
+1
View File
@@ -0,0 +1 @@
../prod/system.yaml
+51
View File
@@ -0,0 +1,51 @@
# Local plugin patches
Patches for **third-party, GPM-managed plugins** that live under
`user/plugins/` — which is **git-ignored** (see `user/.gitignore`), so these
edits do **not** travel with the content repo and are **overwritten by
`make install-plugins`** / a fresh image build. Keep the fix here (tracked) and
re-apply it after any plugin (re)install, until the plugin is forked upstream.
### Local (dev)
```sh
make apply-plugin-patches # git apply, idempotent (skips if applied)
```
`make install-plugins` runs this automatically as its last step.
### Remote (test / prod)
```sh
make remote-apply-plugin-patches-test
make remote-apply-plugin-patches-prod
```
Each patch is piped over SSH into `patch -p1 --forward` at the webroot (no scp),
so it is a no-op when already applied. **Runs automatically** as the last step of
`remote-install-plugins-*` and `remote-update-plugins-*` — GPM lays down pristine
plugins, so the patch must follow every GPM install/update. Content pulls
(git-sync / `remote-fetch-content`) do **not** touch `user/plugins/`, so the patch
survives ordinary content syncs. Requires the `patch` tool on the server.
Verify a patch is live on a server:
`grep -c toArray user/plugins/add-page-by-form/add-page-by-form.php` (≥1 = applied).
## add-page-by-form-grav2-header.patch
Fixes a fatal when **adding a new photo while editing an entry** (front-end
journal edit, milestone M2 / R9).
- **Plugin:** `add-page-by-form` 3.3.0 (abandoned upstream — last release Sept 2023).
- **Bug:** the edit-mode branch reads existing frontmatter with
`(array)$pages->get($folder)->header()`. On Grav 2.0 `header()` returns a
`Grav\Common\Page\Header` object whose data sits in a **protected** `items`
property, so the `(array)` cast produces mangled keys (`\0*\0items`) and
`$original_frontmatter['photos']` is never set → `array_merge(null, …)`
throws a `TypeError` (PHP 8) on any edit that uploads a new file.
- **Fix:** use `Header::toArray()` (clean keys) with a fallback to the cast for
classic stdClass headers, and guard the per-field merge against a
missing/non-array original.
Remove this patch once `add-page-by-form` is forked and the fix lands in the
fork (then pin the fork instead of the GPM package).
@@ -0,0 +1,38 @@
--- a/b/user/plugins/add-page-by-form/add-page-by-form.php 2026-07-05 12:03:55.849015242 +0200
+++ b/user/plugins/add-page-by-form/add-page-by-form.php 2026-07-05 11:55:06.175609339 +0200
@@ -619,7 +619,19 @@
if ($overwrite_mode !== 'false') {
if (file_exists($new_page_folder)) {
if ($overwrite_mode === 'edit') {
- $original_frontmatter = (array)$pages->get($new_page_folder)->header();
+ // intotheeast patch (temporary, pending upstream fork):
+ // On Grav 2.0 header() returns a Grav\Common\Page\Header
+ // object whose data sits in a PROTECTED `items` property,
+ // so the original `(array)$header` yields mangled keys
+ // (\0*\0items) and every frontmatter lookup below misses —
+ // `array_merge($original_frontmatter['photos'], …)` then
+ // fatals under PHP 8. Use toArray() (clean keys) when the
+ // Header exposes it; fall back to the cast for a plain
+ // stdClass (classic pages).
+ $__header = $pages->get($new_page_folder)->header();
+ $original_frontmatter = (is_object($__header) && method_exists($__header, 'toArray'))
+ ? $__header->toArray()
+ : (array)$__header;
} else {
Folder::delete($new_page_folder);
}
@@ -708,7 +720,13 @@
$file_fields_updated = array();
foreach ($file_fields as $file_field => $uploads) {
- $file_fields_updated[$file_field] = array_merge($original_frontmatter[$file_field], $uploads);
+ // intotheeast patch: entries that render from folder-scanned
+ // media carry no matching frontmatter key, so fall back to []
+ // rather than fatal array_merge() on a missing/null original.
+ $existing = (isset($original_frontmatter[$file_field]) && is_array($original_frontmatter[$file_field]))
+ ? $original_frontmatter[$file_field]
+ : array();
+ $file_fields_updated[$file_field] = array_merge($existing, $uploads);
// Get any (uploaded and then) deleted files
foreach ($copy_files['deleted'] as $file_to_delete) {
+15 -3
View File
@@ -1,24 +1,36 @@
services: services:
grav: grav:
build: . build: .
container_name: intotheeast_grav # Overridable so a git worktree can run its own isolated dev server (see
# `make worktree-new`); unset → the canonical main-checkout values below.
container_name: ${GRAV_CONTAINER:-intotheeast_grav}
environment: environment:
- GRAV_CHANNEL=production - GRAV_CHANNEL=production
- APACHE_RUN_USER=#1000 - APACHE_RUN_USER=#1000
- APACHE_RUN_GROUP=#1000 - APACHE_RUN_GROUP=#1000
ports: ports:
- "8081:80" - "${GRAV_PORT:-8081}:80"
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:
build: ./services/travel-memories build: ./services/travel-memories
ports: ports:
- "8082:8082" - "${TM_PORT:-8082}:8082"
volumes: volumes:
- ./docs/immich-workflow:/app/state - ./docs/immich-workflow:/app/state
- ./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)
--- ---
@@ -0,0 +1,113 @@
# View unpublished trips (drafts) on the frontend when logged in
**Status:** 📋 Not started
## Motivation
An unpublished trip (`published: false`) currently returns a hard **404** on its own
route, even for the logged-in owner. Example: `http://localhost:8081/trips/denmark-2026`
`HTTP 404` (verified 2026-07-08, anonymous *and* authenticated). The owner should be
able to preview a draft trip page at its real URL before publishing, while the public
still gets a 404.
The rest of the site is **already owner-aware** — the trip template, the trips listing,
and the home page all render drafts to `grav.user.authenticated` (via `.published()`
filters + `is-draft`/Draft badges). The only missing piece is the **direct route** to a
draft's own page.
## Current behaviour — verified mechanism
Traced through the Grav core running in the container (Grav 2.0.x):
- `Page::routable()` (`system/src/Grav/Common/Page/Page.php`) returns:
```php
return $this->routable && $this->published();
```
So `published: false` ⇒ `routable()` is `false`, regardless of the `routable` flag.
- `PagesProcessor.php:67` gates the request on exactly that:
```php
if (!$page->routable()) {
// build 404, fire onPageNotFound...
}
```
- `PagesProcessor.php` ~line 80: after firing `onPageNotFound`, if a listener set
`$event->page`, Grav serves **that** page directly with no further routable check:
```php
if (isset($event->page)) {
unset($this->container['page']);
$this->container['page'] = $page = $event->page;
} else {
throw new RuntimeException('Page Not Found', 404);
}
```
That last hook is the clean insertion point.
## Proposed approach — small custom plugin (~40 lines)
Mirror the existing `user/plugins/cache-on-save/` custom-plugin pattern. Subscribe to
`onPageNotFound` and, for authenticated users only, resolve the requested route including
unpublished pages and hand it back:
```php
public function onPageNotFound(Event $e) {
$user = $this->grav['user'];
if (!$user->authenticated) {
return; // owners only — public still 404s
}
$route = $this->grav['uri']->path();
$page = $this->grav['pages']->find($route, true); // include unpublished
if ($page && !$page->published()) {
$e->page = $page; // serve the draft → 200
$e->stopPropagation();
}
}
```
The page then renders with its normal template. Because the theme is already owner-aware,
the trip page will display correctly for the logged-in owner.
## Decisions to make before building (brainstorm first)
1. **Scope of page types.** All unpublished pages, or just the trip tree
(`/trips/*`)? Entries and stories already show inline as drafts on the owner's trip
feed; do they also need standalone-route preview? Leaning: gate to trip/entry/story
templates to avoid unintentionally exposing every draft everywhere.
2. **Draft banner.** Add a trip-level "Draft — not published" banner when viewing an
unpublished trip (entry-level draft badges already exist; this is the trip equivalent).
3. **Non-existent vs. unpublished.** Ensure a genuinely missing route still 404s — the
`find(..., true)` + `!published()` check already distinguishes them, but cover it in a test.
## The one real risk — page-cache leak to the public
If Grav caches the 200 we serve to the owner and later hands it to an anonymous visitor,
the "owners only" gate is defeated. **Verify, don't assume:**
- Grav's Login plugin disables page caching for authenticated sessions by default.
- The theme already serves owner-only draft *content* inline today, so this exposure is
presumably mitigated somewhere already.
Add an explicit **anonymous-request assertion** (draft route → 404 for anon, even
after an authenticated hit warmed any cache).
## Testing
Playwright spec:
- Authenticated owner → `GET /trips/<draft-slug>` returns 200 and renders the trip page.
- Anonymous → same route returns 404.
- Anonymous after an authenticated hit → still 404 (cache-leak guard).
- Genuinely missing route → 404 for everyone.
## Effort
**Low** — roughly half a day including the Playwright spec. Single custom plugin plus an
optional small theme partial for the draft banner.
## References
- Investigation session: 2026-07-08 ("hotfixes").
- Pattern to copy: `user/plugins/cache-on-save/`.
- Related owner-aware theme logic: `templates/trip.html.twig` (`owner_can_edit`),
`templates/trips.html.twig` (`is_owner`), `templates/home.html.twig`.
@@ -0,0 +1,93 @@
# Blueprint Vetting — Research & Recommendation
**Status:** 📋 Not started
**Date:** 2026-07-08
**Scope:** All custom Grav blueprints (intotheeast theme page blueprints, theme blueprint, site-config extension). Stock Quark blueprints excluded.
## Files reviewed
| File | Purpose |
|---|---|
| `user/themes/intotheeast/blueprints/entry.yaml` | Daily journal entry (Admin form) |
| `user/themes/intotheeast/blueprints/story.yaml` | Story pages |
| `user/themes/intotheeast/blueprints/trip.yaml` | Trip pages |
| `user/themes/intotheeast/blueprints/home.yaml` | Home page |
| `user/themes/intotheeast/blueprints.yaml` | Theme blueprint (identity only) |
| `user/blueprints/config/site.yaml` | Site-config extension (`active_trip`, `travelling`) |
## What's already good
- Toggle idiom is correct and consistent everywhere: `options: {1: Yes, 0: No}` + `validate: type: bool`.
- `trip.yaml` `autoconnect` keys `'on'`/`'off'` are properly quoted — avoids the YAML 1.1 boolean footgun (`on:` parsing as `true:`). `default: 'on'` is quoted too.
- `user/blueprints/config/site.yaml` follows the standard Grav pattern for extending system site config (fields merge into Admin → Configuration → Site); `validation: loose` present; the `pages` field options (`start_route`, `show_root`, `show_slug`) are all real options.
- `entry.yaml` correctly uses `@extends: {type: default, context: blueprints://pages}` and adds its fields as a new tab, so entries keep the full standard Admin UI.
- Required-field validation on story/home titles and story content is in place.
- `weather_temp_c` has sensible min/max bounds (60…60).
## Findings
### F1 — Only `entry.yaml` extends the default page blueprint (structural)
`story.yaml`, `trip.yaml`, and `home.yaml` define `form.fields.tabs` from scratch (no `@extends`). In Admin2 those page types show **only** the declared fields — no Options/Advanced tabs, so no slug rename, no ordering, no visibility, no publish dates, no taxonomy from Admin. The custom `header.published` toggles in story/trip partially compensate.
If the locked-down UI is deliberate, entry is the inconsistent one; if not, story/trip lose real capabilities (they're created repeatedly and may need slug/ordering control).
**Implementation note if extending:** story/trip use a tab key `content`, which collides with the default blueprint's Content tab — fields merge by key, so the duplicate `header.title`/`content` definitions override rather than duplicate, but the merged result needs a visual check in Admin. Their custom `header.published` toggle also becomes redundant with the default Options-tab toggle — keep one.
### F2 — `lat`/`lng` are free-text with no validation (data integrity)
`entry.yaml:27-35` and `story.yaml:64-74` declare latitude/longitude as plain `type: text`. Templates pipe the values straight into `number_format(6, …)` (`user/themes/intotheeast/templates/trip.html.twig:62`, `templates/home.html.twig:56`). PHP casts silently:
- European decimal comma `"35,0116"``35.000000` (marker subtly wrong)
- non-numeric garbage → `0.000000` (marker in the Gulf of Guinea)
No error surfaces anywhere. Fix: `validate: { type: float, min: -90, max: 90 }` for lat, `±180` for lng.
### F3 — `transport_mode` option drift (copy-paste divergence)
Entry offers `plane` (`entry.yaml:77`); story doesn't (`story.yaml:80-86`). The field — along with lat/lng, location, `force_connect` — is duplicated between the two blueprints, which is how drift happens. Grav supports shared partials via `import@`; in-repo example: `user/themes/quark/blueprints/blog.yaml:90` importing `partials/blog-bits.yaml`.
### F4 — `hero_image` UX inconsistency
Trip uses `pagemediaselect` (dropdown of uploaded media, `trip.yaml:40-44`); entry and story use free-text filename fields (`entry.yaml:60-64`, `story.yaml:33-37`) where a typo silently breaks the hero. `pagemediaselect` keeps the "blank = first image" fallback while removing typo risk.
### F5 — Minor items
| Item | Location | Detail |
|---|---|---|
| `travelling` default mismatch | `user/blueprints/config/site.yaml:15` | `default: false` vs option keys `1`/`0`; works via loose comparison, but `default: 0` matches every other toggle |
| Date type drift | `story.yaml:20-31` vs `trip.yaml:28-38` | story: `datetime` + `format: 'Y-m-d'` (the deliberate Admin2 datepicker fix); trip: plain `date`. Pick one convention |
| `<br>` in help text | `trip.yaml:61,73` | If Admin2 escapes HTML in help tooltips, users see literal `<br>` tags |
| `weather_temp_c` step | `entry.yaml:52-58` | HTML number inputs default to step 1 → `19.5` may be rejected client-side; fine if whole degrees are intended |
| `pagemediaselect` accept filter | `trip.yaml:42` | Extension-style `accept: ['.jpg', …]` is the filepicker convention; unverified against Admin2's SPA implementation |
## Recommendation
Treat as one small milestone in three parts, in this order:
### Phase 1 — Data-integrity + drift fixes (no decisions needed, low risk)
1. **F2:** add `validate: { type: float, min/max }` to all four lat/lng fields (entry + story).
2. **F3:** extract a shared theme partial `user/themes/intotheeast/blueprints/partials/` (e.g. `location-bits.yaml`) holding location name/country, lat/lng (with the new validation), `transport_mode` (superset incl. `plane`), and `force_connect`; `import@` it from entry and story. Follow the Quark example.
3. **F5 quick fixes:** `travelling` default → `0`; standardize date fields on `datetime` + `format: 'Y-m-d'` (matches the established Admin2 datepicker fix).
### Phase 2 — Structural decision (needs Mischa's call)
4. **F1:** recommended: add `@extends: default` to **story and trip** (repeatedly-created content pages that benefit from slug/ordering/options control); leave **home** minimal (singleton whose slug must never change). Resolve the Content-tab merge and duplicate-published-toggle notes above. Verify each Admin form visually after the change.
5. **F4:** switch entry + story `hero_image` to `pagemediaselect` (naturally bundles with the Phase 2 Admin verification pass).
### Verify-once checklist (manual, 5 minutes in Admin2)
- [ ] Trip page → Cover Image dropdown: do `.gpx` files appear? (If yes, the `accept` filter isn't applying — F5.)
- [ ] `use_gpx` / `autoconnect` help tooltips: rendered line breaks or literal `<br>`?
- [ ] Decide: whole-degree temperatures OK, or add `step` to `weather_temp_c`?
### Out of scope
- Post form (`/post`) field parity — separate surface, not touched by this vetting.
- Theme blueprint (`blueprints.yaml`) — minimal but valid; no theme options exist yet, nothing to add.
## Open questions
1. **F1:** Is the locked-down Admin UI for story/trip/home deliberate? (Recommendation above assumes it isn't for story/trip.)
2. Should `transport_mode` for stories include `plane` (superset) or stay intentionally narrower?
+197
View File
@@ -0,0 +1,197 @@
# Upgrade & Deploy Cycle: local → test → prod
This runbook is the repeatable procedure for shipping a Grav upgrade or any
server-affecting change (core version, plugins, config, theme) through the three
environments. It was distilled from the 2026-07 Grav 2.0.4→2.0.7 cutover, where
every production surprise traced back to one of the desyncs this procedure now
forces you to check.
**Governing principle:** `test` is a **full dress rehearsal of `prod`** — same
config, same `-test`/`-prod` make targets, same order. A gotcha only gets caught
on test if test is a faithful mirror of prod. Do not shortcut test.
All server operations go through `make remote-*` targets (never raw SSH — the
targets build the SSH connection from `.env.<env>`, which must never be read
directly). Every `remote-*` target has `-test` and `-prod` variants; a bare
target fails via `guard-env`.
---
## The mental model: three places state lives
Every failure in the reference cutover was a desync between these three layers.
Before and after each deploy step, ask: *are they in sync?*
| Layer | Location | Synced by | Failure mode |
|---|---|---|---|
| Plugin **code** | `user/plugins/<name>/` | GPM only (gitignored `/plugins/*`) | can vanish while config remains → plugin won't enable |
| **Repo config** | `user/config/…` | `content-push` / git-sync | holds GPM channel + is where the version floor bites |
| **Host config** | `user/env/<host>/config/…` | nothing — server-only | not restored on fresh install; must be re-applied; **must be gitignored** |
Referenced gotcha docs:
- `docs/solutions/integration-issues/grav-plugin-config-without-code-wont-enable.md` — code-vs-config desync.
- `docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md` — stale `GRAV_VERSION` / version floor.
- `docs/solutions/architecture-patterns/git-sync-secret-exposure-and-tracked-file-boomerang.md` — gitignore is the sync boundary; env-tree leak.
- `docs/solutions/conventions/grav-plugin-config-must-be-tracked-override.md` — plugin config must live in the tracked override.
These three layers describe the **servers**. Locally there is a fourth: the Grav
**core** is baked into the Docker **image** (`Dockerfile`), not in any layer above —
so the local core upgrades by an image rebuild, never by the `gpm self-upgrade` the
servers use. See `docs/solutions/tooling-decisions/upgrade-local-grav-core-rebuild-docker-image.md`.
---
## 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)
1. Make the change in the repo:
- GPM channel: `gpm.releases: stable` in `user/config/system.yaml` (authoritative; reaches servers via content pull, so it must be right **before** any server GPM op).
- `plugins.txt` — the GPM-managed set only. **Never** add `git-sync` (it is remote-only).
- Prod-only overrides (Twig cache/debug, `debugger.shutdown.close_connection: false`) in `deploy/env/prod/system.yaml`**never** commit prod values into `user/config/system.yaml`.
- **Bump `GRAV_VERSION` in `.env.test` and `.env.prod`** to the target version. A stale value here installs the wrong core (an rc), which then blocks the `api` plugin and 404s admin. This governs fresh **remote** installs only.
- **If the core version is changing, upgrade the local dev core too** so you prove the change against the target version — bump the hardcoded `grav-admin-v<ver>.zip` URL in `Dockerfile`, `docker compose build grav`, then `docker rm -f intotheeast_grav && docker compose up -d grav`. The local core is baked into the image, so `.env GRAV_VERSION` does *not* touch it and an in-container `gpm self-upgrade` is non-durable. See `docs/solutions/tooling-decisions/upgrade-local-grav-core-rebuild-docker-image.md`.
2. `make build-assets` if you touched `js/src/*` (never hand-edit the bundled `js/*.js`).
3. Run the dev server (`docker compose … up`) and the Playwright suite.
4. Pre-flight assertions:
- `gpm.releases` is `stable`.
- `plugins.txt` is correct and does **not** contain `git-sync`.
- No prod Twig values leaked into the committed `system.yaml`.
5. Commit. `make content-push`.
---
## Phase 1 — Test (the rehearsal — catch things here)
### Pre-flight
- `make remote-git-sync-disable-test` **before any content reset.** This is the safety catch for the whole window: it stops a half-migrated state (e.g. a fresh install-time `versions.yaml`) from auto-committing and pushing on the first sync.
### Apply — in this fixed order
```
make remote-fetch-content-test # 1. clean-reset synced folders to repo state
make remote-upgrade-grav-test # 2. gpm self-upgrade (rewrites schema — expect drift)
make remote-update-plugins-test # 3. gpm update the plugins.txt set (auto-applies deploy/patches/)
make remote-gpm-install-test PKG=git-sync # 4. EXPLICITLY (re)install each remote-only plugin
make remote-apply-env-test # 5. re-deploy the env override (not synced; gone after install)
make remote-warmup-test # 6. clear + warm cache — a reset deploy leaves it stale
```
> **Always finish a deploy with `remote-warmup-<env>`** — even a content-only
> deploy. A `reset --hard` (step 1) changes files under Grav without going
> through it, so the compiled-Twig/page cache is stale and the first visitor
> eats the recompile. `remote-warmup` clears the cache, then crawls the public
> pages (homepage + trips listing + every trip page linked from it) to
> pre-render them. Grav has no native warmup command — this is an HTTP crawl, so
> it also doubles as a smoke test (a non-2xx on any page is flagged loudly).
Why each matters:
- **Step 3** re-applies `deploy/patches/*.patch` automatically (it chains `remote-apply-plugin-patches`). GPM install/update lays down **pristine** third-party plugins, wiping local fixes to git-ignored `user/plugins/` — the patch step restores them. Content pulls (step 1) do **not** touch `plugins/`, so the patch only needs re-applying after a GPM op, not after every sync. Run `make remote-apply-plugin-patches-test` standalone if you ever GPM-install outside this sequence. Requires the `patch` tool on the server. See `deploy/patches/README.md`.
- **Step 4** is non-optional even if git-sync "was already there" — remote-only plugins are not in `plugins.txt`, so nothing in steps 13 restores them. If the code is missing, the plugin is inert despite valid config.
- **Step 5** re-writes `user/env/<host>/config/…` from `deploy/env/<env>/`. The env tree is not synced by anything, so a fresh install loses it until you re-apply.
### Verify (smoke checklist — this is the payoff)
- **Code present, not just config:** `ls user/plugins/<name>/` for every expected plugin (especially `git-sync`). An empty/absent dir = reinstall (step 4). *(Do this via an ssh one-liner you run, or `make remote-diag-test`.)*
- **Plugin patches applied:** confirm the add-page-by-form fix survived the GPM op — `grep -c toArray user/plugins/add-page-by-form/add-page-by-form.php` should be ≥1 (0 = pristine, re-run `make remote-apply-plugin-patches-test`). Functional check: edit a journal entry and add a photo — a pristine plugin 500s on save.
- **HTTP:** `/` → 200, `/admin` → 200, `/api/v1/pages` → 401, `/gpx-manager` → 200. Watch for the double-`Content-Encoding` garbage page (fix: `debugger.shutdown.close_connection: false` in the env override — already in `deploy/env/prod/system.yaml`).
- **Post smoke test:** submit one entry via `/post` and confirm it appears in the trip feed immediately. This proves the `cache-on-save` plugin works with prod caching on.
- **Config drift:** `make remote-diag-test` — diff server config against the repo. Fold any *intended* schema migration (e.g. the Twig-3 `strict_mode` flags a `self-upgrade` writes) back into `user/config/system.yaml`, or the next `fetch-content` reverts it.
### Re-enable + prove sync
- `make remote-git-sync-enable-test`.
- Confirm a content push round-trips to the server, **and** that no secret/boomerang commit lands on Gitea. Verify `/env/` is gitignored so the env tree (which holds the token, JWT, CSRF salt) can never enter the sync add-set.
---
## Phase 2 — Prod (repeat identically — should be mechanical)
Run the **exact same sequence** with `-prod` targets. Because test rehearsed it,
prod holds no surprises. Differences to layer on:
- Optional: `make remote-maintenance-on-prod` at the start, `remote-maintenance-off-prod` at the end, for a clean window.
- Confirm secrets are valid/rotated and `/env/` is gitignored **before** `remote-git-sync-enable-prod`. Re-enable git-sync **last**.
- After a clean cutover, bump the outer-repo submodule pin to the finished `user/` commit — and **push `user/` before the outer repo** (the superproject references a child SHA that must already exist upstream).
```
make remote-git-sync-disable-prod
make remote-fetch-content-prod
make remote-upgrade-grav-prod
make remote-update-plugins-prod
make remote-gpm-install-prod PKG=git-sync
make remote-apply-env-prod
make remote-warmup-prod # clear + warm cache; also HTTP-smokes public pages
# ── smoke checklist (same as test) ──
make remote-git-sync-enable-prod
```
For a first-time / from-scratch prod bring-up, `make remote-install-prod` does the
full install; then still run `remote-apply-env-prod` and the smoke checklist, and
reinstall remote-only plugins explicitly.
---
## Rollback & safety
- **git-sync stays disabled through the whole apply window** on each host — it is the catch that prevents a half-migrated state from auto-pushing.
- **Content** is a git repo: a bad content deploy is recoverable with `make remote-fetch-content-<env>` back to a known commit.
- **Core + plugins** are GPM-reinstallable (`remote-upgrade-grav`, `remote-update-plugins`, `remote-gpm-install PKG=…`).
- The one thing tooling cannot regenerate is the un-synced `user/env/<host>/` tree — its source of truth is `deploy/env/<env>/`, so keep that current and re-apply with `remote-apply-env-<env>`.
---
## One-line invariants (the through-line)
1. `test` is config-identical to `prod`, run with the same targets in the same order.
2. Verify the **code layer** (`ls user/plugins/<name>/`), not just config, on every deploy.
3. Reinstall **remote-only** plugins (git-sync) explicitly — nothing else restores them.
4. `GRAV_VERSION` in `.env.<env>` and `gpm.releases: stable` are correct **before** any server GPM op.
5. Re-apply the **env override** after every install; keep `/env/` **gitignored**.
6. git-sync **off** during the window, **on** last; confirm the round-trip carries no secrets.
7. Diagnose actual state before changing config — an `ls` or `remote-diag` beats a guess.
+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.
+25 -4
View File
@@ -44,13 +44,34 @@ This creates uid 1000 in the container, chowns `/var/www/html` to 1000:1000, and
--- ---
## Upgrading to a newer Grav RC ## Upgrading the Grav core
Grav 2.0 is baked into the custom Docker image via `Dockerfile`. The base `getgrav/grav` image ships 1.7 — the `Dockerfile` downloads the 2.0 RC bundle from GitHub and overwrites the core files at build time. The Grav core is baked into the custom Docker image via `Dockerfile`. The base
`getgrav/grav` image ships 1.7 — the `Dockerfile` downloads the pinned stable bundle
(`grav-admin-v<version>.zip`) from GitHub and overwrites the core files at build time.
`docker-compose.yml` volume-mounts only `./user`, so the core lives in the **image
layer**. That means you upgrade the core by rebuilding the image, **not** by running
`gpm self-upgrade` inside the container — an in-container self-upgrade is lost on the
next rebuild. (The servers are the opposite: no image, so they self-upgrade in place.)
To upgrade: To upgrade:
1. Update the bundle URL in `Dockerfile` 1. Bump **both** occurrences of the version in the `Dockerfile` release URL (the
2. Run `make setup` — Docker rebuilds the image layer automatically `/download/<ver>/` path and the `grav-admin-v<ver>.zip` filename). Note: the
`GRAV_VERSION` in `.env*` does **not** drive this build — it only pins fresh
*remote* installs.
2. Rebuild: `docker compose build grav`.
3. Recreate the container. `docker compose up -d` won't replace an already-running
container with a fixed `container_name` (it errors `Conflict … name … already in
use`), so remove it first — safe because `./user` is a bind mount:
```bash
docker rm -f intotheeast_grav && docker compose up -d grav
```
4. Verify: `docker exec -w /var/www/html intotheeast_grav php bin/grav --version`.
5. Refresh plugins and clear cache: `make install-plugins` then
`docker exec -w /var/www/html intotheeast_grav php bin/grav cache`.
Full rationale and the server-vs-local contrast:
`docs/solutions/tooling-decisions/upgrade-local-grav-core-rebuild-docker-image.md`.
--- ---
+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.
@@ -56,33 +56,28 @@ Production is unaffected either way: prod pulls `user/` directly via the content
The payoff. Because `docker-compose.yml` mounts `./user` **relative to the compose file**, and a worktree is a full copy of the outer tree (compose file included), each worktree serves *its own* `user/`. Two worktrees = two independent sites, no gitlink collisions. The payoff. Because `docker-compose.yml` mounts `./user` **relative to the compose file**, and a worktree is a full copy of the outer tree (compose file included), each worktree serves *its own* `user/`. Two worktrees = two independent sites, no gitlink collisions.
Set up a feature worktree off `main`: **Use the make targets — don't do the steps by hand.** From the main checkout:
```bash ```bash
# outer worktree on a new feature branch make worktree-new NAME=<feature> # create + start its own dev server
git worktree add .worktrees/<feature> -b feat/<feature> main make worktree-rm NAME=<feature> # tear down cleanly
cd .worktrees/<feature>
# populate user/ at the pinned SHA, then branch it for the cross-repo work
git submodule update --init user
git -C user checkout -b feat/<feature>
# its own dev server — separate project name + port from the main checkout's :8081
docker compose -p itte-<feature> up -d
``` ```
`worktree-new` does, in order: `git worktree add .worktrees/<feature> -b feat/<feature> main`, `git submodule update --init user`, branches `user/` onto `feat/<feature>`, writes a git-ignored `.worktree-env` (own compose project name, container name, auto-assigned port `8090+`) so every `make`/compose command run inside that worktree targets its own server, and starts the Grav service. The manual equivalent misses `.worktree-env` — without it, make commands in the worktree hit the main checkout's container on `:8081`.
`.worktrees/` is kept out of git via `.git/info/exclude` (local, shared across worktrees — no committed `.gitignore` change needed). `.worktrees/` is kept out of git via `.git/info/exclude` (local, shared across worktrees — no committed `.gitignore` change needed).
### Teardown ### Teardown
A submodule inside a linked worktree stores its git dir under `.git/modules/user/worktrees/<name>`, so removing the outer worktree needs a second cleanup step: A submodule inside a linked worktree stores its git dir under `.git/modules/user/worktrees/<name>`, so teardown needs a submodule-deinit step before the worktree can be removed — skipping it is what leaves orphaned `.worktrees/` dirs. `make worktree-rm NAME=<feature>` runs the full sequence:
```bash ```bash
docker compose -p itte-<feature> down # what worktree-rm does internally
cd "$(git rev-parse --show-toplevel)" # back to the main checkout make -C .worktrees/<feature> stop # compose down (its own server)
git -C .worktrees/<feature> submodule deinit user # detach the submodule worktree git -C .worktrees/<feature> submodule deinit -f user # detach the submodule worktree
git worktree remove .worktrees/<feature> # remove the outer worktree git worktree remove --force .worktrees/<feature>
git branch -d feat/<feature> # if merged git worktree prune
git branch -d feat/<feature> # manual, if merged
``` ```
### Landing a commit on main without disturbing the main checkout ### Landing a commit on main without disturbing the main checkout
@@ -0,0 +1,319 @@
---
title: "Secret exposure under bidirectional Grav git-sync: gitignore is the only boundary (and the tracked-file boomerang trap)"
date: 2026-07-05
last_updated: 2026-07-05
module: git-sync
problem_type: architecture_pattern
component: tooling
severity: high
category: architecture-patterns
applies_when:
- "Enabling bidirectional Grav git-sync (direction: both, on_save: true) so prod can push content back to Gitea"
- "Auditing whether the server can leak secrets (tokens, password hashes, signing salts) off the production host"
- "A per-install runtime-generated value is being persisted into a tracked config file that also carries functional config"
- "Deciding where a per-install secret must live so it never round-trips"
- "A config value keeps ping-ponging or re-committing itself across environments after each sync"
tags:
- git-sync
- secret-exposure
- gitignore
- grav
- popularity-salt
- config-boundary
- bidirectional-sync
- per-install-secret
- env-tree-leak
---
# Secret exposure under bidirectional Grav git-sync: gitignore is the only boundary (and the tracked-file boomerang trap)
> **Correction (2026-07-05):** An earlier version of this doc claimed the sync
> add-set was *scoped to the configured `folders`*, and concluded that
> `accounts/` and `user/env/` were "safe by construction" because they sit
> outside `pages/config/themes`. **That model is wrong and caused a live secret
> leak.** git-sync's auto-commit stages files **outside** the configured folders;
> the only reliable exclusion is `.gitignore`. The corrected model is below.
## Context
The intotheeast.com Grav site runs the `git-sync` plugin on production in
**bidirectional** mode: `direction: both`, `on_save: true`, a webhook at
`/_git-sync`, and configured `folders: pages, config, themes`. Bidirectional
means the server both *pulls* content authored elsewhere **and** *pushes*
content authored on the server (Admin edits, `/post` submissions, uploads) back
to the shared Gitea repo.
The operator's question was: **"Can I safely enable bidirectional prod → Gitea
sync without leaking secrets?"** Production holds things that must never reach a
shared repo — API tokens, JWT signing secrets, CSRF salts, the git-sync token
itself.
The dangerous, tempting answer is "the plugin only syncs `pages/config/themes`,
so anything outside those folders is safe." **This is false, and acting on it
leaked secrets.** See the incident below.
## The incident (what actually happened)
Production's git-sync auto-commit `9337003` ("(Grav GitSync) Automatic Commit
...") pushed the **entire `user/env/intotheeast.com/config/` tree** to Gitea —
including `api-private.php` (JWT secret), `security-private.php` (CSRF salt), and
`git-sync.yaml` (the sync **token + webhook secret**) — plus `accounts/mischa.yaml`
(password hash), `system.yaml`, and `post-form.md`.
**None of those paths is under the configured `folders: pages, config, themes`.**
`user/env/` is a sibling of `user/config/`, not a subfolder of it. Yet git-sync
staged and pushed them anyway. That single fact refutes the "folders scope the
add-set" model empirically: **the `folders` setting does not scope what the
auto-commit stages.** Whatever git-sync's exact `git add` invocation, the
operational truth is that its commit sweeps the whole `user/` working tree.
Remediation: disable sync → `.gitignore` `/env/` and `git rm --cached` it →
`reset --hard` prod to the gitignored state → regenerate the leaked JWT/CSRF
salts (delete the `*-private.php` files; Grav regenerates them) → **rotate the
Gitea token and webhook secret** (they were exposed in `git-sync.yaml`). Rotation
is what actually neutralizes the leak; the history rewrite is optional for a
private repo.
## Guidance
The safety question reduces to one predicate — but **not** the one the original
doc used:
> **A file round-trips to the shared repo if and only if it is NOT gitignored.**
> The configured `folders` setting does **not** narrow this. Treat the
> round-trippable set as *everything under `user/` that git will track* — i.e.
> everything not matched by `user/.gitignore`.
Consequences, corrected:
1. **`.gitignore` is the only reliable boundary.** Do not rely on a file being
"outside the synced folders." If it is under `user/` and not gitignored, a
bidirectional sync can push it. Design exclusions with `.gitignore`, and
verify with `git -C user status` / `git -C user check-ignore <path>`.
2. **`accounts/` is NOT structurally excluded.** `user/accounts/*.yaml` (bcrypt
password hashes) is a *tracked* content folder and is not gitignored, so it
**does** round-trip — `accounts/mischa.yaml` was in the leak commit. If you
need an account file to stay server-local, it must be gitignored explicitly
(as `accounts/testrunner.yaml` already is). The earlier "password hashes never
leave the server" claim was wrong.
3. **The per-environment tree `user/env/<host>/` MUST be gitignored** — it is
**not** inherently safe. It holds the live git-sync token, JWT secret, and
CSRF salt (Grav writes all server-side Admin config there once the env dir
exists). Because it is not under `user/config/` people assumed it was outside
the sync scope; the incident proved it is not. It is now gitignored
(`/env/` in `user/.gitignore`, commit `6e8eadb`). Keep it that way.
> Side effect worth remembering: once `user/env/<host>/` exists, Grav's Admin
> writes **all** config changes there (system and plugin), not into
> `user/config/`. So server-side Admin edits are server-only — but "server-only"
> now depends entirely on `/env/` being gitignored, not on folder scope. When
> auditing, check **both** `user/config/...` and `user/env/<host>/config/...`
> (env wins at runtime). See `docs/working/git-sync-notes.md`.
4. **Per-install secrets go in gitignored companion files.** Grav's convention
splits a per-install secret out of the functional YAML into a sibling that is
gitignored: the JWT secret in `api-private.php`, the CSRF/nonce + rate-limit
salt in `security-private.php`, plus `security.yaml` and `versions.yaml`.
These are safe **because they are gitignored**, not because of where they sit.
Run every secret through predicate #1 (is it gitignored?) and the answer falls
out — but you must actually enumerate what is *not* gitignored, not what is
"outside the folders."
### The tracked-file boomerang (a separate trap)
Independently of the folder-scope error above, there is a second trap that the
original doc got right and that still holds: **a per-install value that a plugin
regenerates at runtime and persists into a tracked, functional config file.**
The concrete case: the `api` plugin's *popularity* feature generates
`popularity.salt` and writes it **into `user/config/plugins/api.yaml`** — a file
that also carries must-be-shared functional config. That file is tracked, so on
prod the popularity feature regenerates the salt, git-sync stages the change,
commits, and **pushes prod's salt back to the shared repo**. Another environment
pulls it, regenerates *its own* salt, pushes again. The value **ping-pongs across
installs**, producing endless noise commits.
The critical realization: **you cannot gitignore a single key inside a file that
also carries functional config.** `.gitignore` operates on whole files. `api.yaml`
must be tracked because the rest of it must be shared; therefore the salt inside
it is tracked too; therefore it boomerangs.
### The rules
For any per-install runtime-generated value, pick one of exactly three
resolutions — and do **not** reach for the fourth (stripping the line), which
cannot work under sync:
- **Isolate the value into a gitignored companion `<name>-private.php`** — the
pattern Grav uses for the JWT secret via `api-private.php`.
- **Disable the feature that generates it** (e.g. `popularity.enabled: false`).
- **Consciously accept the churn** when the value is genuinely low-stakes
(`popularity.salt` is an IP-hashing salt, not a credential).
Do **not** keep stripping the value from the tracked file — bidirectional sync
brings it right back on the next save.
### Before / after: what sticks and what doesn't
**A standalone file gitignored + untracked sticks.** For `security-private.php`
(a file that contains *only* the secret) or the whole `user/env/` tree:
```bash
git -C user rm --cached -r config/security-private.php # or: rm --cached -r env
printf '/config/security-private.php\n/env/\n' >> user/.gitignore
```
This works permanently. The path is no longer tracked, so the sync's add-set
skips it forever. The fix sticks because the secret owns its own gitignored path.
**A key inside a tracked functional file — stripping the line does NOT stick.**
For `popularity.salt` inside `api.yaml`, deleting just the `salt:` line and
committing looks clean locally, but Grav re-appends it at runtime and the next
sync re-commits and re-pushes it. The only durable fixes are the
companion-private-file pattern or disabling the feature.
### Untracking an already-committed secret under a live sync — freeze every server first
`.gitignore` (and git-sync's `ignore` field) only affects **untracked** files.
Once a secret has actually been *committed* to the shared repo, ignoring it does
nothing — removing it means rewriting history. Doing that while a bidirectional
sync is live has its own trap.
**A `direction: both` server silently reverts your force-push.** Concrete
incident (2026-07-05, a second occurrence of this doc's trap): `config/security-private.php`
and `config/versions.yaml` had been committed to Gitea `main` (auto-commit
`d1643a7`, merged at `f8c45fc`). Force-pushing `main` back to the clean commit
`32c3d8c` *looked* successful — and within seconds Gitea was back at `f8c45fc`.
Cause: prod's git-sync is `direction: both` and its local `HEAD` was still
`f8c45fc`; on its next sync it re-pushed the stale, secret-bearing commit and
undid the rewrite. The pull-only test server never fought back — **only
push-enabled servers do.**
**The rule: freeze git-sync on _every_ server (push *and* pull) before rewriting
shared history.** A pull server mid-rewrite can also resurrect a half-removed
state. The safe sequence that worked:
1. **Freeze all sync.** `make remote-git-sync-disable-{test,prod}` (flips
`enabled: false` in the env-path `git-sync.yaml`).
2. **Audit where the live secret actually lives — before any `reset --hard`.** A
destructive reset *deletes* working-tree files tracked now but absent in the
target commit. `config/security-private.php` was such a file — but it was a
**stray duplicate**; the authoritative 290-byte copy lives at
`env/intotheeast.com/config/security-private.php` (mode 600), which git-sync
had copied into `config/`. Because `env/` is gitignored and outside every
tracked folder, it survives the reset and *wins* Grav's config merge — so
dropping the `config/` copy is safe. **Verify this first** with a secret-safe
audit that lists existence + size + `git ls-files` tracking and **never prints
contents** (added as `make remote-secrets-audit`; it `ls` / `git ls-files`,
never `cat`).
3. **Force-push `main` to the clean commit.** It sticks now — no server is pushing.
4. **Reset each server** with `make remote-fetch-content-{test,prod}`
(`fetch``sparse-checkout disable``reset --hard origin/main`). This
deletes the stray tracked `config/` copies; the `env/` originals remain.
5. **Verify** `git ls-files` shows no secret tracked and the `env/` copy is intact
on every host.
6. **Re-enable sync** (`make remote-git-sync-enable-*`), preserving each server's
`direction`. Local `HEAD` now equals Gitea `main`, so there is nothing bad to
push.
Two gotchas inside step 4:
- **Stale remote-tracking ref.** `reset --hard origin/main` resets to the
server's *cached* `refs/remotes/origin/main`, not to Gitea directly. If that ref
is stale the reset lands on the wrong commit — confirm the `fetch` force-updated
it (`+ f8c45fc...32c3d8c main -> origin/main (forced update)`) before trusting
the reset.
- **`sparse-checkout disable` before `reset --hard`** — otherwise the reset only
touches paths inside the sparse pattern and can skip/wipe directories outside it.
**Durable exclusion goes in git-sync's `ignore:` config field, never a
hand-edited `.gitignore`.** git-sync owns `.gitignore`: on load it regenerates it
from `folders` (`/*`, `!/pages`, `!/config`, `!/themes`) and **appends** the
`ignore:` entries. Hand edits are clobbered on the next sync; `ignore:` entries
persist because git-sync writes them back every time. So the secret paths belong
in `ignore:` — but that only prevents *future* tracking. The history rewrite
(steps 15) is still required *in addition to* the ignore entries to remove a
secret that is already committed, not instead of them.
## Why This Matters
Two quiet, cross-environmental failure modes:
1. **The folder-scope illusion.** Assuming "only `pages/config/themes` sync" is a
security control leads you to leave secrets in `env/` or `accounts/` unignored
— and a single Admin save on prod pushes them to a shared repo. This actually
happened here. The only defensible mental model is *gitignore is the boundary*;
enumerate the un-ignored set, not the "un-foldered" set.
2. **The boomerang.** A "cleanup" commit that strips a secret from a *tracked*
file looks done locally but silently reappears upstream on the next content
save, because the plugin regenerates it and the sync re-commits it.
Getting both right is what lets you answer "is bidirectional sync safe?" honestly.
The answer is **yes, once `user/.gitignore` actually excludes every sensitive
path** — `env/`, the per-install `*-private.php` files, `security.yaml`,
`versions.yaml`, and any account file that must stay server-local — and once every
runtime-regenerated value either lives in its own gitignored file or is a
consciously-accepted low-stakes churn. It is emphatically **not** safe on the
strength of folder scoping alone.
## When to Apply
- **Enabling or auditing bidirectional git-sync** on a server that authors
content. Enumerate the round-trippable set as *everything under `user/` not
matched by `.gitignore`* — then confirm no secret is in it.
- **Deciding where a new secret or per-install generated value should live.**
Standalone gitignored file for anything sensitive; never a key inside a shared
functional YAML; never "outside the folders" as the sole justification.
- **Reviewing a "stop tracking this secret" cleanup** for whether it will stick:
is the secret in its own gitignored path (holds) or a line inside a tracked
functional file that something regenerates (boomerangs)?
- **Rewriting shared history (force-push, `filter-repo`, `reset --hard`) on a
git-sync-managed repo** — freeze sync on every server first, audit where the
live secret authoritatively lives before any destructive reset, then re-enable.
## Examples
**The leak (what "folder scope is safe" cost).** With `folders: pages, config,
themes` and `/env/` **not** gitignored, prod's auto-commit `9337003` pushed
`user/env/intotheeast.com/config/**` (JWT, CSRF salt, git-sync token + webhook
secret), `accounts/mischa.yaml`, `system.yaml`, and `post-form.md` to Gitea —
all outside the configured folders. Fix: gitignore + untrack `/env/`, regenerate
the JWT/CSRF salts, **rotate the token and webhook secret**.
**Safe after remediation.** Same bidirectional config, but now `user/.gitignore`
excludes `/env/`, `config/plugins/git-sync.yaml`, `config/plugins/api-private.php`,
`config/security.yaml`, `config/security-private.php`, `config/versions.yaml`.
Running each secret through *is-it-gitignored*: all sensitive paths are excluded →
none is in the round-trippable set. Verified: prod's `git status` shows only the
intended tracked content, and no boomerang/secret commit lands on the remote.
**Boomerang example.** `popularity.salt` in the tracked `api.yaml` regenerates
per-install and re-commits under sync. The fix that *sticks* is the
companion-private-file pattern or `popularity.enabled: false`**not** stripping
the `salt:` line.
**Force-push revert example.** With `config/security-private.php` +
`config/versions.yaml` already committed to Gitea `main` (`f8c45fc`), a
`git push --force origin main` back to the clean `32c3d8c` was undone within
seconds — prod's `direction: both` git-sync re-pushed its stale `f8c45fc` `HEAD`.
The rewrite only held after `make remote-git-sync-disable-{test,prod}` froze both
servers first; then force-push → `make remote-fetch-content-{test,prod}`
re-enable. Verified afterward: `git ls-files` on every host lists no secret, and
each host's `env/…/security-private.php` is intact.
## Related
- `docs/solutions/conventions/grav-plugin-config-must-be-tracked-override.md` — the
origin of the "functional plugin config in tracked `user/config/plugins/`,
secrets/per-install values in gitignored `*-private.php`" rule. That doc covers
*where config must live to deploy*; this doc covers *why gitignore — not folder
scope — is the sync boundary, and why a runtime-written tracked value boomerangs*.
- `docs/working/git-sync-notes.md` — operational notes on git-sync's synced
folders and the per-environment tree. Corrected in the same 2026-07-05 pass to
drop the "env/ is outside the sync scope so it's safe" claim.
- `docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md`
— adjacent context from the same Grav production cutover, different failure mode.
@@ -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
@@ -0,0 +1,86 @@
---
title: "Grav plugin config must live in the tracked user/config/plugins/ override, not the plugin folder"
date: 2026-07-04
category: docs/solutions/conventions
module: "grav / plugin configuration"
problem_type: convention
component: tooling
severity: high
applies_when:
- "Editing functional config for any GPM-managed Grav plugin"
- "user/plugins/ is gitignored and only pages/config/accounts/themes are tracked"
- "Preparing a fresh install or production cutover"
- "A plugin behaves correctly locally but ships with only default config on deploy"
related_components:
- "grav"
- "gpm"
- "api plugin"
- "content repo"
- "deployment"
tags:
- grav
- plugin-config
- gpm
- config-override
- gitignore
- deployment
- api-plugin
---
# Grav plugin config must live in the tracked user/config/plugins/ override, not the plugin folder
## Context
Grav resolves a plugin's config by deep-merging two layers: the plugin's own shipped file `user/plugins/<name>/<name>.yaml` (installed by GPM, part of the package) and the tracked override `user/config/plugins/<name>.yaml` (which wins). In this project the content repo tracks only `pages/`, `config/`, `accounts/`, `themes/`; `user/plugins/` and `user/data/` are gitignored (GPM manages plugin *code*). So any functional config a developer edits into a plugin's own `user/plugins/<name>/<name>.yaml` is invisible to version control.
It was this gap that left the `api` plugin unconfigured on the fresh prod install. Its `enabled`/`route`/`session_enabled`/cors/rate_limit config existed only in the untracked plugin folder locally, while the committed `user/config/plugins/api.yaml` held only a runtime `popularity.salt`. The local machine worked because the plugin folder had been hand-edited; every fresh environment got only the plugin's shipped defaults.
## Guidance
Put **functional** plugin configuration in the TRACKED override `user/config/plugins/<name>.yaml`. Grav deep-merges it over the plugin's shipped defaults, so it need only carry the keys that must differ (or the full config, for clarity). Keep **secrets and per-install generated values** OUT of the tracked file — JWT secrets, salts, encrypted tokens belong in gitignored `*-private.php` companion files (e.g. `api-private.php`, `security-private.php`) or should be regenerated per-install.
Never rely on edits to the plugin's own `user/plugins/<name>/<name>.yaml`: it is gitignored (won't deploy) and is overwritten on the next `php bin/gpm update`.
Concrete before/after, using the `api` plugin:
```yaml
# WRONG: user/plugins/api/api.yaml (gitignored, GPM-managed, wiped on update)
enabled: true
route: /api
auth:
session_enabled: true
```
```yaml
# RIGHT: user/config/plugins/api.yaml (tracked, deploys, survives gpm update)
enabled: true
route: /api
version_prefix: v1
auth:
session_enabled: true
# JWT secret intentionally NOT here — it lives in the gitignored api-private.php
```
## Why This Matters
Reproducible deploys: a fresh clone or `make remote-install-<env>` must produce a working site from the repo alone. Config stranded in the gitignored plugin folder silently yields a plugin with only its shipped defaults on every new environment — which, for a plugin whose behavior depends on non-default config, means it's misconfigured or effectively off. On prod the `api` plugin's route/auth simply didn't work.
The failure is silent and per-environment: it works on the developer's machine (where the plugin folder was hand-edited) and breaks everywhere else. `gpm update` compounds it by wiping the folder edit even locally, so the "working" state is not just unshared — it is also unstable on the one machine that had it.
## When to Apply
- Any time you configure a Grav plugin whose non-default settings must work on a server (prod/test) or survive a plugin update.
- Especially for plugins whose function depends on config: `api` (route/auth/cors), `admin2`, `flex-objects`, form/media settings, etc.
- When auditing a fresh-install failure: check whether the "working" local config actually lives in a tracked path (`git ls-files user/config/plugins/<name>.yaml`) or was stranded in `user/plugins/<name>/`.
## Examples
- **api plugin (this project):** the functional config was moved from the untracked `user/plugins/api/api.yaml` into the tracked `user/config/plugins/api.yaml`, then `make content-push` + `make remote-fetch-content-<env>` deployed it. The JWT secret stayed in the gitignored `api-private.php`.
- **Quick audit command:** `git -C user ls-files config/plugins/` shows exactly which plugin configs are tracked/deployable; anything you rely on that isn't listed is a latent fresh-install failure.
## Related
- `docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md` — the config gap documented here was the *other* latent problem surfaced in that same investigation: the `api` plugin also had to be *installed* first before any config could take effect. The install gap (GPM version floor) and this config-tracking gap compounded each other on the fresh prod environment.
- `docs/working/git-sync-notes.md` — the related third config location: on prod, Grav Admin saves config into the per-environment tree `user/env/<host>/config/`, which is *also* untracked. Same "config that doesn't reach the repo" family.
- `docs/solutions/architecture-patterns/git-sync-secret-exposure-and-tracked-file-boomerang.md` — the sync-boomerang consequence of this rule: a per-install value that a plugin regenerates into a *tracked* functional config file (e.g. `popularity.salt` in `api.yaml`) re-commits itself and ping-pongs across environments under bidirectional git-sync. The `*-private.php` companion pattern this doc establishes is exactly the durable fix.
- **CLAUDE.md §0 (plugin-management model):** only `pages/`, `config/`, `accounts/`, `themes/` are tracked in the `user/` repo; `plugins/` and `data/` are gitignored and GPM-managed. That tracking boundary is exactly why functional config must live under `config/plugins/`, not in the plugin's own folder.
@@ -0,0 +1,157 @@
---
title: docker exec/run defaults to root, writing root-owned files into the host bind mount
date: 2026-07-08
last_updated: 2026-07-08
problem_type: integration_issue
category: integration-issues
module: docker-dev-environment
component: development_workflow
severity: high
symptoms:
- "11,624 root-owned (uid 0) files accumulated under the host ./user bind mount"
- "make worktree-rm fails: cannot rm root-owned plugin files without sudo"
- "files stay root-owned even though UID/GID env vars were set to the host user"
- "install-plugins writes the entire plugin tree as root via php bin/gpm install"
- "build-assets (docker run node:20-alpine, no --user) writes root-owned node_modules + esbuild bundles into user/themes/intotheeast/, blocking git worktree remove and git merge"
root_cause: config_error
resolution_type: config_change
related_components:
- tooling
- docker-compose
- grav-cms
tags:
- docker
- docker-exec
- docker-run
- bind-mount
- file-permissions
- uid-gid
- makefile
- grav
- gpm
- build-assets
- esbuild
---
## Problem
In the Grav CMS travel-blog Docker dev environment, `make` targets that shelled into the `grav` container were silently creating **root-owned (uid 0)** files inside the host `./user` bind mount. The `grav` service (based on `getgrav/grav`) bind-mounts host `./user``/var/www/html/user`, so anything the container writes there lands on the host filesystem with whatever ownership the writing process had.
Over time this accumulated **11,624** root-owned files under `./user`. The immediate breakage: `make worktree-rm` could no longer delete a worktree's plugin tree, because a non-root host user cannot remove root-owned files without `sudo`. The working tree became unmanageable, and the per-worktree isolated-container workflow (which is what surfaced the accumulation) left root-owned debris behind on every teardown.
The root of the surprise: the developer had already set `UID`/`GID` env vars to their own user and reasonably assumed that covered container file ownership. It did not — those vars never reached the `grav` service.
## Symptoms
- `ls -la user/plugins/...` shows files owned by `root root` instead of the host user.
- `make worktree-rm` (and a plain `rm -rf` on a worktree) fails with `Permission denied` on plugin files.
- Thousands of root-owned files pile up under `./user``find ./user -uid 0` counted **11,624**.
- Confusing because `UID`/`GID` were already set to the developer's own user, yet ownership was still root.
## What Didn't Work
Several plausible fixes were tried or considered and rejected:
- **Setting `UID`/`GID` env vars.** These only reached the `travel-memories` service, which consumes them via its compose `user: "${UID}:${GID}"` directive. The `grav` service has no such directive, so it never consumed them.
- **`APACHE_RUN_USER=#1000` / `APACHE_RUN_GROUP=#1000` on the grav service.** These only affect the Apache **worker** processes. They do nothing for `docker exec` CLI invocations or for the entrypoint — which are what the make targets actually run.
- **Adding `user: "${UID}:${GID}"` to the grav service in compose.** Not viable. The `getgrav/grav` base-image entrypoint must boot as root to bind port `:80` and set up cron. Pinning the whole container to a non-root user breaks boot.
- **Hardening `worktree-rm` to delete root files via a throwaway root container.** Rejected by the user: no make command should require or use root privileges. The correct fix is to stop *creating* root-owned files, not to add a privileged cleanup step.
**The symptom was noticed for weeks before it was diagnosed.** (session history) During the earlier Grav 2.0.4/2.0.7 upgrade work, container-written files repeatedly surfaced as root-owned — the API plugin's generated `config/plugins/api-private.php` was flagged as "owned by the container, permission-denied to me", and worktree teardown already required `git worktree remove --force` to get past files it couldn't cleanly remove. Each instance was treated as a one-off annoyance rather than traced to `docker exec` defaulting to uid 0. Consolidating plugin management onto `make install-plugins` / `gpm install` during that upgrade actually *enlarged* the problem surface, because it increased how often the container writes into the host mount as root.
## Root Cause
Both `docker exec` **and** `docker run` default to running as root (uid 0). Because the grav container must boot as root, and neither inherits a non-root default unless `-u` / `--user` is passed explicitly, every make target that shelled into (or spun up) a container without dropping privileges wrote root-owned files into whatever host path it bind-mounted.
There are **two** offenders, on two different bind mounts:
- **`install-plugins`** — `docker exec … php bin/gpm install`, writing the entire plugin tree into `./user/plugins` as root. The worst by file count (11,624).
- **`build-assets`** — `docker run --rm node:20-alpine … "npm install && npm run build"`, bind-mounting `./user/themes/intotheeast``/app`, writing root-owned `node_modules/` and esbuild bundle outputs (`js/…`, `css-compiled/`) into the tracked theme tree. This one uses **`docker run`**, not `docker exec`, and has **no `--user`** — so the `install-plugins` fix below does *not* cover it.
## Solution
Derive the host identity once in the Makefile and drop privileges on the specific exec that writes to the bind mount (commit `209b804`).
Add host-user vars:
```makefile
HOST_UID := $(shell id -u)
HOST_GID := $(shell id -g)
```
Rewrite `install-plugins`.
**Before** (wrote root-owned plugins):
```makefile
install-plugins:
docker exec -w /var/www/html $(GRAV_CONTAINER) php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y
$(MAKE) apply-plugin-patches
```
**After** (plugins owned by host user):
```makefile
install-plugins:
# cache/ and tmp/ are root-owned in the image, so make them writable first
# (container-internal chown — never touches the host) so gpm can run AS YOU.
docker exec $(GRAV_CONTAINER) chown -R $(HOST_UID):$(HOST_GID) /var/www/html/cache /var/www/html/tmp
# gpm runs as the host user, so the plugins it writes into ./user/plugins are
# owned by you, not root — no post-hoc chown, no root files to clean up later.
docker exec -u $(HOST_UID):$(HOST_GID) -w /var/www/html $(GRAV_CONTAINER) php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y
$(MAKE) apply-plugin-patches
```
### The `build-assets` vector (same principle, `docker run`) — fixed 2026-07-08
The first 2026-07-08 fix (`209b804`) hardened `install-plugins` only. `build-assets` remained a root-writing target and surfaced later: `git worktree remove` aborted with `Permission denied` on root-owned esbuild bundles under `user/themes/intotheeast/js/post/`, and earlier a `build-assets` run had produced a root-owned `css-compiled/` dir that blocked a `git merge` on the main checkout. (session history)
The same drop-privileges principle applies — with `--user` on `docker run` (fixed later the same day):
```makefile
# Before — writes root-owned node_modules + bundles into the tracked theme tree
build-assets:
docker run --rm \
-v $(PWD)/user/themes/intotheeast:/app \
-w /app node:20-alpine \
sh -c "npm install && npm run build"
# After — outputs owned by the host user; HOME=/tmp gives npm a writable
# cache when running as a non-root uid
build-assets:
docker run --rm --user $(HOST_UID):$(HOST_GID) -e HOME=/tmp \
-v $(PWD)/user/themes/intotheeast:/app \
-w /app node:20-alpine \
sh -c "npm install && npm run build"
```
Verified: `make build-assets` with the fix completes clean (esbuild bundles emitted), `find user/themes/intotheeast -uid 0` counts zero, and the output bundles are byte-identical to the previously committed ones. Recovery for any pre-existing root-owned output is the same as anywhere else — `chown -R $(HOST_UID):$(HOST_GID)` from a container that already has root, then `rm`.
## Why This Works
The container still *boots* as root — which it needs, to bind `:80` and set up cron. But the individual `docker exec` that writes into the bind mount now runs as the host uid/gid via `-u $(HOST_UID):$(HOST_GID)`. Files that exec creates on the host are therefore owned by the developer, not root. No post-hoc chown, no cleanup debt.
The preliminary chown of `cache/` and `tmp/` is container-internal: those paths are root-owned in the base image and are not host-managed content in the same way. gpm needs them writable to run as a non-root user; without making them writable first, gpm exits 1. Chowning them inside the container never touches the host filesystem.
**Empirical validation.** A minimal touch/stat test isolates the mechanism: `docker exec -u 1000:1000 <container> touch /var/www/html/user/probe` produces a host file owned by `1000`, while the same command without `-u` produces one owned by `0`. After applying the fix, `make fix-perms` cleared the backlog (11,624 → 0) and a real `make install-plugins` ran clean: gpm exit 0, zero root-owned files created, `api`/`admin2` plugins owned by the host user, and the site healthy (`/` and `/admin` → 200).
## Prevention
The reusable principle, worth internalizing beyond this one repo:
- **Any make/CI target that writes files into a host bind mount must drop privileges — whether it uses `docker exec` (`-u $(HOST_UID):$(HOST_GID)`) or `docker run` (`--user $(HOST_UID):$(HOST_GID)`).** A container booting as root does *not* mean the commands you run in it must write as root. `build-assets` (a `docker run`) was the easy one to miss, because the original fix only patched the `docker exec` targets — so audit `docker run` invocations too, not just `docker exec`.
- **Derive host identity once in the Makefile and reuse it:** `HOST_UID := $(shell id -u)` / `HOST_GID := $(shell id -g)`.
- **Don't rely on `APACHE_RUN_USER` or compose-level `UID`/`GID` env vars to fix exec ownership** — they don't apply to `docker exec`. `APACHE_RUN_USER` only affects Apache workers; compose `user:`/env vars only affect services wired to consume them.
- **You can't just add `user:` to a service whose entrypoint needs root** (to bind privileged ports, set up cron, etc.). Drop privileges per-exec instead of per-container.
- **If a tool run as non-root needs writable scratch dirs that are root-owned in the image, chown them container-internally first.** That doesn't touch the host.
- **Root-owned files accumulate invisibly.** (session history) Plugin code under `user/plugins/<name>/` is gitignored by project convention (only `cache-on-save`, `story-blocks`, and `entry-actions` are tracked), so root-owned files pile up in the bind mount without ever appearing in `git status` — they only bite at worktree-removal time. Don't wait for `git status` to reveal them; `find ./user -uid 0 | wc -l` is the real detector.
- **Keep a `make fix-perms` escape hatch** (container-internal `chown -R 1000:1000 /var/www/html`) for residual root files — notably first-boot files the base-image entrypoint writes as root (`config/security.yaml`, `data/api-keys.yaml`), which no `-u` on a make target can reach. After this fix it's a rare mop-up, not a routine step.
- **Verification recipe:** `docker exec -u 1000:1000 <container> touch /mnt/f && stat -c '%u' host/f` should print your uid, not `0`.
This lives in the Makefile because make targets are the only sanctioned container interface in this project — the fix belongs there, not in ad-hoc docker commands.
## Related
- [`tooling-decisions/upgrade-local-grav-core-rebuild-docker-image.md`](../tooling-decisions/upgrade-local-grav-core-rebuild-docker-image.md) — the sibling docker-dev-env doc. It documents `make install-plugins``docker exec … php bin/gpm install` as a routine local step but never addresses *who* those execs run as. This doc is its complement: it explains why the exec must drop to the host user.
- [`architecture-patterns/dual-repo-submodule-workflow.md`](../architecture-patterns/dual-repo-submodule-workflow.md) — worktrees + the `./user` submodule/bind mount, including the persistent `M user` dirty-state warning. Root-owned files landing in `./user` from root-default execs are a concrete cause of unexpected permission/dirty state in worktree dev servers.
- `docs/guides/deploy-cycle.md` — the three-layer state model (plugin code / repo config / host env tree); the host env tree is the layer across which these root-owned files land.
@@ -0,0 +1,113 @@
---
title: "cache.deleteAll() doesn't rebuild the page-tree index — a freshly-posted entry 404s when opened for editing"
date: 2026-07-07
category: integration-issues
module: cache-on-save
problem_type: integration_issue
component: plugin
severity: high
symptoms:
- "A just-posted journal entry is written to disk but the API 404s on it (GET /api/v1/pages{route})"
- "Opening the entry you just created for editing shows 'This entry no longer exists — it may have been deleted'"
- "The entry DOES appear in the trip feed, but the edit prefill fetch can't find it until the next unrelated cache bump"
- "Intermittent — only bites when the page-tree index survives the create"
root_cause: incomplete_setup
resolution_type: code_fix
related_components:
- documentation
- development_workflow
tags:
- grav
- cache
- forms
- page-tree
---
# `cache.deleteAll()` doesn't rebuild the page-tree index
## Context — this is BUG-001 Part 2
[BUG-001](../../working/bugs-and-fixes.md) ("new entry not visible after form
submission") was fixed by wiring `$this->grav['cache']->deleteAll()` into the
`cache-on-save` plugin's `onFormProcessed` hook. That made new entries appear in
the trip feed immediately. It was **not the whole story**: `deleteAll()` drops
the Doctrine store (rendered-page cache, feed HTML, etc.) but does **not** force
Grav to rebuild its **regular-pages index**.
The gap only surfaced once the shared `/post` form gained an **edit mode**
(`?edit=<route>`), whose prefill does `GET /api/v1/pages{route}`. On a fresh
create that request would 404 — so the owner opening the entry they had *just*
posted saw "This entry no longer exists."
## Root cause
Grav's regular-pages index is keyed on:
```
md5(dirs + folderHash + config->checksum() + lang) // Pages::buildRegularPages
```
With `cache.check.method: folder` (our setting), the `folderHash` component does
not necessarily change when a new child folder is added inside an existing
tree — so the **index key stays the same** and the stale index (missing the new
entry) is reused. `deleteAll()` clears cache *stores* but does not change any of
the inputs to that key, so the tree is not rebuilt. The new page is on disk and
in the feed (which re-reads children), but the **API lookup by route** resolves
through the cached index and 404s.
## Fix
Add a second invalidation step alongside `deleteAll()`:
```php
use Grav\Common\Cache;
// ...
$this->grav['cache']->deleteAll();
Cache::invalidateCache(); // touch(system.yaml) → bumps config->checksum()
```
`Cache::invalidateCache()` is lightweight and idempotent — it `touch()`es
`system.yaml`, calls `clearstatcache()` and `opcache_reset()` (verified in Grav
core `Cache.php`). Touching `system.yaml` bumps `config->checksum()`, which
changes the index key, so the tree rebuilds on the next request and the new
entry becomes resolvable by route.
### Latch it — the hook fires 4× per submit
`onFormProcessed` fires once per `process:` action, and `post-form.md` has four
(`add_page`, `upload`, `message`, `reset`). Without a guard the
`deleteAll()` + `invalidateCache()` pair runs four times per post (a full store
wipe + `system.yaml` touch each time). Gate it with a once-per-request latch
(`$cacheInvalidated`), the same pattern already used for photo reconciliation
(`$photosReconciled`). See `user/plugins/cache-on-save/cache-on-save.php`.
## How to verify
1. Post a new entry via `/post`.
2. From the trip feed, click the new card's **Edit** link.
3. The form prefills with the entry's title/body — no "no longer exists" banner.
Regression test: `tests/ui/post/edit-mode.spec.js` **ES1** (create → open the
feed card's Edit link → change title + body → Save → assert on disk).
## Residual coverage gap (tracked, not fixed here)
`tests/ui/home/home.spec.js` **H1** and `tests/ui/maps/maps.spec.js` **M8**
require `site.travelling: true` to exercise the active-trip home feed + home GPX
map. The committed local `site.yaml` runs `travelling: false` (owner's testing
config, intentionally not committed as `true`), so both specs **skip loudly**
with a reason rather than fail misleadingly. They validate whenever the site is
in travelling mode. This is a known gap in this environment, not a silent hole —
provisioning `travelling: true` in a dedicated test config would close it.
## Related — Part 3: in-place edits + APCu
The `Cache::invalidateCache()` fix above completes `deleteAll()` for the
**create/delete** case, because a new or removed child folder advances
`folderHash` and the `system.yaml` touch bumps `config->checksum()`. It is
**necessary but not sufficient** for an **in-place frontmatter edit** (e.g. a
trip publish toggle) under `cache.driver: auto` (APCu): the folder structure is
unchanged, and APCu lives in web-server shared memory that a CLI `bin/grav
clearcache` cannot reach. That case additionally requires `apcu_clear_cache()`
called from the web request. See
[`grav-in-place-header-edit-apcu-cache-stale.md`](grav-in-place-header-edit-apcu-cache-stale.md).
@@ -0,0 +1,123 @@
---
title: "Grav garbage page from two conflicting Content-Encoding headers (identity + gzip)"
date: 2026-07-04
category: docs/solutions/integration-issues
module: "grav / production deploy"
problem_type: integration_issue
component: tooling
severity: high
symptoms:
- "Homepage and every dynamic Grav page render as full-screen binary/mojibake garbage in the browser"
- "curl without Accept-Encoding returns clean HTML, so the page looks fine from a naive curl"
- "curl -H \"Accept-Encoding: gzip\" (a browser-style request) returns raw gzip bytes"
- "Response carries two conflicting headers: content-encoding: identity AND content-encoding: gzip"
- "Only appeared after switching prod to production Twig mode (twig.debug: false)"
root_cause: config_error
resolution_type: config_change
related_components:
- "Apache mod_deflate"
- "DirectAdmin shared host"
- "Grav shutdown handler (system/src/Grav/Common/Grav.php)"
- "deploy/env/prod/system.yaml"
- "make remote-apply-env-prod"
tags:
- grav
- content-encoding
- gzip
- mod-deflate
- fastcgi-finish-request
- apache
- production-deploy
- twig-debug
---
# Grav garbage page from two conflicting Content-Encoding headers (identity + gzip)
## Problem
Grav renders every dynamic page as binary garbage in the browser because it emits two conflicting `Content-Encoding` headers. The response body is valid gzip, but because the server advertises both `content-encoding: identity` and `content-encoding: gzip`, the browser cannot decide how (or whether) to inflate it, and paints the raw compressed bytes to screen. Static assets are unaffected — only Grav's own dynamically generated pages are broken. The problem surfaced only after switching the site to production Twig mode (`twig.debug: false`).
## Symptoms
- The homepage and all dynamic Grav pages show a full screen of binary/mojibake characters in the browser (completely unreadable). The surrounding HTML shell and static assets are fine.
- A naive `curl https://site/` (with **no** `Accept-Encoding` header) returns clean, correct HTML — so a quick curl sanity check looks perfectly healthy and completely hides the bug.
- A browser-style request exposes it. `curl -H "Accept-Encoding: gzip" -D - -o /dev/null https://site/` shows **two** `Content-Encoding` response headers:
```
content-encoding: identity
content-encoding: gzip
```
The body is valid gzip and `gunzip`s to the correct HTML.
- A static asset served by the webserver alone (e.g. a CSS/JS file) shows a **single** clean `content-encoding: gzip` under the same request — confirming the webserver's gzip is fine and the duplication is Grav-originated.
- The bug only appeared after switching the site to production Twig mode (`twig.debug: false`), which activates Grav's full shutdown/output path.
## What Didn't Work
- **First fix attempt: `cache.gzip: false` + `allow_webserver_gzip: true`.** This was the key dead end. It had **no effect** — the duplicated headers were unchanged. Reading Grav's source explained why: the branch that emits the bogus header fires on `if ($config->get('system.cache.gzip') || $config->get('system.cache.allow_webserver_gzip'))`. Setting `allow_webserver_gzip: true` satisfies the **same** `||` condition, so Grav still takes the identical `header('Content-Encoding: identity')` code path. The two knobs that *look* like they control this are both on the wrong side of the problem.
- **Verifying with a naive `curl` (no `Accept-Encoding: gzip`).** This hid the problem entirely, because the server only compresses when the client advertises gzip support. Any healthcheck that omits `Accept-Encoding: gzip` reports a false "all clear." Reproduce it the way a browser does — send `Accept-Encoding: gzip`, or take a real headless-browser (Playwright) screenshot.
## Solution
Root the fix in Grav's shutdown handler, `system/src/Grav/Common/Grav.php` (~lines 615631):
```php
if ($config->get('system.debugger.shutdown.close_connection', true)) {
$success = function_exists('fastcgi_finish_request') ? @fastcgi_finish_request() : false;
if (!$success) {
if (!ini_get('zlib.output_compression')) {
if ($config->get('system.cache.gzip') || $config->get('system.cache.allow_webserver_gzip')) {
header('Content-Encoding: identity'); // <-- the bogus header
} elseif (function_exists('apache_setenv')) {
@apache_setenv('no-gzip', '1');
} else {
header('Content-Encoding: none');
}
header('Content-Length: ' . ob_get_length());
}
header('Connection: close');
ob_end_flush();
}
}
```
The entire problematic block is gated by `system.debugger.shutdown.close_connection` (default `true`). Disable it so the whole branch is skipped and Grav never touches `Content-Encoding` at all.
In this project it is applied as a **per-environment (prod-only) override** so local dev is untouched — `deploy/env/prod/system.yaml`, deployed via `make remote-apply-env-prod`:
```yaml
debugger:
shutdown:
close_connection: false
```
Verify — the response must show **exactly one** `content-encoding`:
```bash
curl -s -D - -o /dev/null -H "Accept-Encoding: gzip" https://site/ | grep -i content-encoding
# content-encoding: gzip
```
Then take a screenshot of the rendered page to confirm it displays correctly. A `curl`-without-gzip check is **not** sufficient proof — it would have passed even while the bug was live.
## Why This Works
`debugger.shutdown.close_connection` (default `true`) makes Grav flush the full response and close the connection to the browser **early**, so slow shutdown tasks (logging, debugger teardown) don't keep the visitor waiting. On a FastCGI/PHP-FPM host, Grav does this cleanly via `fastcgi_finish_request()` and never manipulates headers — which is why the bug is invisible on most stacks.
On a **non-FastCGI** host (LiteSpeed, suPHP, plain CGI), `fastcgi_finish_request()` does not exist, so `$success` is `false` and Grav falls back to closing the connection *manually*. To do that it must set an explicit `Content-Length`, and to keep that length honest it tries to tell the webserver "do not compress this body" — which, on the `cache.gzip`/`allow_webserver_gzip` branch, it expresses as `header('Content-Encoding: identity')`.
But `identity` is not a real content transformation and is **not** a recognized "suppress compression" signal to Apache `mod_deflate`. `mod_deflate` ignores it, compresses the body anyway, and appends its **own** `Content-Encoding: gzip`. The response now carries two contradictory `Content-Encoding` headers (`identity` and `gzip`). Browsers cannot reconcile the contradiction, fail to inflate the gzip stream, and render the raw compressed bytes — the "binary garbage" screen.
Setting `close_connection: false` means Grav never enters the manual connection-close path, never emits `Content-Encoding: identity`, and leaves the webserver as the **sole** authority on compression. The webserver then sends a single, correct `Content-Encoding: gzip`, and the browser inflates and renders normally.
## Prevention
- On **non-FastCGI PHP hosts with server-side gzip** (Apache `mod_deflate`, LiteSpeed), set `debugger.shutdown.close_connection: false` for that environment. Deliver it as a **per-environment override**, never by editing the committed `system.yaml` (which would silently change dev behavior too).
- **Reproduce compression bugs the way a browser sees them.** Always test with `curl -H "Accept-Encoding: gzip" -D -` and/or a headless-browser screenshot. A plain `curl` negotiates no compression and silently masks encoding bugs.
- **Health check:** dynamic pages must return **exactly one** `Content-Encoding` header. Two of them (`identity` + `gzip`) is the unambiguous signature of this bug. Add this assertion to any smoke test.
- **Know the trigger.** This can stay completely hidden in development mode and only appear once a site is switched to production mode (`twig.debug: false`), which activates Grav's full shutdown/output path. Re-run the browser-style compression check as part of any production cutover.
## Related
- **Grav per-environment override mechanism**`deploy/env/prod/system.yaml` applied via `make remote-apply-env-prod`, described in `CLAUDE.md` §1 ("Production mode — per-environment override"). The pattern that lets a prod-only setting like `debugger.shutdown.close_connection: false` ship without mutating the committed dev `system.yaml`.
- **`docs/working/git-sync-notes.md`** — documents the `user/env/<hostname>/config/` override tree (where this fix physically lives on the server) and the caveat that config saved via Admin on the server stays server-only.
- **`docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md`** — sibling from the same cutover: Admin2 login failed because a stale `GRAV_VERSION` installed an rc core and GPM wouldn't serve the `api` plugin. Different root cause, same deploy.
- **`docs/solutions/test-failures/new-user-grants-api-not-admin-on-admin2.md`** — a sibling gotcha from the same 2026-07-04 Grav 2.0.4 production cutover (account permission provisioning); different root cause, same deploy.
@@ -0,0 +1,112 @@
---
title: "In-place frontmatter edit stays stale under Grav folder-check + APCu cache"
date: 2026-07-08
category: integration-issues
module: entry-actions
problem_type: integration_issue
component: plugin
symptoms:
- "After unpublishing a trip via the API, anonymous visitors still saw it on /trips, home, and nav"
- "Playwright test TP2 failed: an unpublished trip stayed visible to logged-out users"
- "A prior owner-authenticated GET poisoned the page-tree cache before the toggle, making staleness sticky"
- "\"bin/grav clearcache\" from the CLI did not bust the stale index at all"
- "\"deleteAll()\" and \"pages->reset() + clearCache('standard')\" alone both left the listing stale"
root_cause: incomplete_setup
resolution_type: code_fix
related_components:
- cache-on-save
- testing_framework
- documentation
tags:
- grav
- apcu
- cache-invalidation
- folder-check
- in-place-edit
- publish-toggle
- page-tree-cache
- api-endpoint
severity: high
---
# In-place frontmatter edit stays stale under Grav folder-check + APCu cache
## Problem
An owner-only endpoint `POST /api/v1/trip/{slug}/publish` toggles a trip's visibility by mutating `trip.md`'s `published:` frontmatter **in place** — same folder, no folder create or delete — via a header mutation plus `$page->save()`. After saving it must invalidate Grav's page-tree cache so the `/trips` listing, the home render, and the nav all reflect the new visibility on the next load.
They don't. After the owner unpublishes a trip, anonymous visitors still see it in the `/trips` listing and it stays reachable. None of the usual cache-invalidation idioms fix it, and — critically — a CLI `bin/grav clearcache` cannot bust it at all.
The failure is the interaction of two facts specific to this project's Grav 2.0.4 setup:
- `cache.check.method: folder` derives the regular-pages index cache id from a **folder-structure** checksum. An in-place frontmatter edit leaves the folder structure identical, so the cache id is unchanged and the stale index is reused.
- `cache.driver: auto` resolves to **APCu** (baked into the project's Docker image). APCu lives in the **web server's** shared memory, so any process outside that web worker — a CLI, a cron job, a `docker exec` — flushes a *different* memory segment and cannot reach it.
## Symptoms
- Owner unpublishes a trip → reload the `/trips` listing as an anonymous visitor → the trip is **still present** and still reachable.
- The Playwright spec `tests/ui/trip/trip-publish.spec.js` (TP2) catches it: unpublish → reload as anon → trip still listed.
- Staleness is sticky, and worst after a preceding **owner-authenticated GET** has populated the cache.
- Running the test harness's `docker exec <container> php bin/grav clearcache` does **not** clear it — the trip stays visible.
## What Didn't Work
The investigation chain, in order:
1. **`$this->grav['cache']->deleteAll()` alone** (the first half of the sibling create/delete fix). Still stale.
2. **`Cache::clearCache()`, then `$this->grav['pages']->reset()` + `$this->grav['cache']->clearCache('standard')`.** Still stale.
3. **CLI `bin/grav clearcache`** (via `docker exec`, root, a separate PHP process). Could not bust it *at all* — this was the discriminator that pointed straight at APCu: a separate process owns a separate APCu segment.
Note the documented idiom `deleteAll() + Cache::invalidateCache()` — which touches `system.yaml` to bump `config->checksum()` and thus change the index key — is the correct fix for the **create/delete** case. It is not enough here: the deciding failure is that the cache **store** is APCu in web shared memory, unreachable by the CLI, so a key-bump alone leaves the poisoned store in play across the same web worker.
Prior create/delete work on this branch had already climbed most of an escalation ladder and stopped one rung short of this case *(session history)*:
- `deleteAll()` was found to clear only the Doctrine cache store, never rebuilding the compiled page-tree index — the original root cause for both the create (BUG-001) and delete flows.
- `touch`ing the `dailies/` folder mtime did **not** flip the stale lookup (suspected Docker bind-mount mtime not propagating), so the pure folder-mtime theory was dropped.
- A "clear only `cache/compiled/pages/`" hypothesis was a red herring: **there is no such directory** — the regular-pages index lives in the Doctrine cache keyed by `md5(json_encode(dirs) + folderHash + config->checksum() + lang)` (`Pages.php`).
- That work standardized on `deleteAll() + Cache::invalidateCache()` (i.e. `touch(system.yaml)` + opcache reset) as the canonical pattern — and it was **sufficient there because folder-level create/delete advances `folderHash`**. Those sessions never touched APCu at all; the in-place-edit + APCu escalation below is genuinely new.
## Solution
Flush APCu **from within the web request** that performed the edit, in `EntryActionsApiController::setTripPublished`, right after `$page->save()`:
```php
$header = $page->header();
$header->published = $published; // KTD1: mutate the HEADER, not $page->published($v) —
// save() serializes from the header
$page->save();
$this->grav['cache']->deleteAll();
if (function_exists('apcu_clear_cache')) {
apcu_clear_cache(); // flush the WEB server's APCu store directly —
// a CLI clearcache cannot reach it
}
$this->grav['pages']->reset(); // drop the in-memory tree so the next request
// rebuilds from disk
$this->grav['cache']->clearCache('standard');
```
Verified via curl against the running dev container: unpublish → anon listing count drops to 0; republish → back to 1.
## Why This Works
- `apcu_clear_cache()` runs inside the **same PHP web process** that owns the APCu segment, so it actually empties the store the frontend reads. This is the piece a CLI clearcache structurally cannot do.
- `deleteAll()` + `clearCache('standard')` drop the Doctrine/compiled stores.
- `$this->grav['pages']->reset()` forces a **rebuild from disk** on the next request, which re-reads the mutated `published` flag.
Because the mutation is **in place**, none of Grav's folder-checksum-based self-healing applies (a folder create/delete would change the checksum and self-heal — which is why new-post and delete flows never hit this). The invalidation must therefore be **explicit** *and* must **target the web APCu**. The earlier `Cache::invalidateCache()` fix leaned entirely on the `config->checksum()` term of the index key changing; that still leaves the poisoned APCu store live for the current web worker when the edit is in place.
## Prevention
- When an endpoint mutates page frontmatter **in place** (publish toggles, metadata edits) under `cache.check.method: folder`, do **not** rely on `deleteAll()` or on a folder-checksum bump. Explicitly flush APCu from the web request, guarded with `function_exists('apcu_clear_cache')`.
- **Never** invalidate web APCu from a CLI/cron/`docker exec` process — it hits a different memory segment. If a CLI must trigger invalidation, it has to go through a web request (curl the endpoint) or a shared driver (file/redis), not APCu.
- **Test-harness corollary:** a Playwright helper that clears cache via `docker exec ... bin/grav clearcache` will **not** flush web APCu. Fixture *folders* still appear (folder create bumps the checksum), but in-place/config changes may read stale. Prefer driving the real web endpoint. Cache-mutating E2E specs must run serially (`--workers=1`); mutating global config (`owner_username`, `active_trip`) also collides with parallel readers. See `tests/ui/trip/trip-publish.spec.js`.
- **On the divergence from the house idiom:** two independent code reviewers (reliability, maintainability) flagged that this 4-call sequence diverges from the codebase's documented `deleteAll() + Cache::invalidateCache()` idiom. The divergence is **intentional** and specific to in-place-edit + APCu. Pick by case:
- create/delete → `Cache::invalidateCache()` (bumps the folder checksum / index key)
- in-place edit under APCu → `apcu_clear_cache()` from the web request
A future improvement is to fold both into one documented helper so future call sites have a single idiom to copy.
## Related Issues
- `docs/solutions/integration-issues/grav-deleteall-doesnt-invalidate-page-tree-index.md` — the sibling **CREATE** case: `deleteAll()` doesn't rebuild the index; fix = `Cache::invalidateCache()`. This doc is its **in-place-edit + APCu** counterpart — effectively "Part 3" of that page-tree-cache thread, adding the APCu shared-memory dimension the folder-touch fix did not cover.
@@ -0,0 +1,100 @@
---
title: "Grav plugin won't enable because its code is missing while its config persists in the env tree"
date: 2026-07-05
category: integration-issues
module: git-sync
problem_type: integration_issue
component: tooling
severity: high
symptoms:
- "git-sync plugin will not enable on prod despite enabled: true in its config"
- "Plugin does not appear / cannot be toggled on in the Grav Admin UI"
- "Config-level fix attempts (editing plugin YAML) have no effect"
- "make remote-gpm-install-prod PKG=git-sync reports a FRESH install, not 'already installed'"
root_cause: incomplete_setup
resolution_type: dependency_update
related_components:
- documentation
- development_workflow
tags:
- grav
- git-sync
- gpm
- plugin-management
- env-config
- config-without-code
- troubleshooting-order
- remote-only-plugin
---
# Grav plugin won't enable because its code is missing while its config persists in the env tree
## Problem
After remediating an unrelated git-sync secret leak on prod (intotheeast.com, Grav 2.0.7 on a DirectAdmin/Apache shared host), the operator went to re-enable the `git-sync` plugin. Setting `enabled: true` in its config had no effect: the plugin would not appear as enabled in the Grav Admin plugins UI, and toggling it on manually in the browser did not take either. It looked fully "configured" — the config file was right there — but the plugin was inert and sync never ran.
The trap is that the plugin's config file existed (in the per-host env tree at `user/env/intotheeast.com/config/plugins/git-sync.yaml`), so any inspection that reads only config concluded the plugin was present and just needed enabling. The actual problem was one layer down: the plugin's **code** was missing from `user/plugins/git-sync/`. A Grav plugin cannot load or enable without its code on disk, no matter what its config says.
## Symptoms
- The git-sync plugin does not appear as enabled, and cannot be enabled, in the Grav Admin UI — despite `enabled: true` being present in its config.
- Toggling `enabled` in config, or flipping the toggle in the Admin UI, produces no working plugin. Sync does not run.
- The plugin's config file DOES exist (in the per-environment tree `user/env/intotheeast.com/config/plugins/git-sync.yaml`), so the plugin appears "present" whenever only the config is inspected — masking the real state.
## What Didn't Work
1. **Setting / ensuring `enabled: true` in the git-sync config.** No effect. Config was never the problem.
2. **Enabling the plugin manually in the Admin UI.** The toggle wouldn't take.
Both failed attempts operate on the **config** layer. But the plugin's **code** was absent from `user/plugins/git-sync/`, and Grav can't load a plugin without its code. Grav (and any tooling that reads the config tree) reports a plugin as "configured" purely from the presence of its config file, which masks the absence of code. Diagnosing and poking at the config layer could never fix a missing-code problem — and guessing at config changes before running a simple `ls` on the plugin directory cost real time here.
## Solution
First, run the decisive diagnostic on the server — confirm whether the plugin code actually exists *before* touching config:
```
ls -la $WEBROOT/user/plugins/git-sync/ # empty/absent => missing code, reinstall
```
(In this project, do that via a make target or an ssh one-liner the user runs — never raw SSH by the assistant. All server ops go through `make remote-*`.)
With the directory confirmed empty/absent, reinstall the plugin's code via GPM:
```
make remote-gpm-install-prod PKG=git-sync # GPM fresh-installs Git Sync v3.4.4
```
That make target runs, on the server:
```
php bin/gpm index -f && php bin/gpm install git-sync -y && php bin/grav clearcache
```
The install output read **"Preparing to install Git Sync [v3.4.4] ... Success!"** — a **fresh** install, not "already installed." That fresh-install line is exactly what confirmed the code had been absent all along. After the reinstall plus cache clear, the plugin enabled and sync worked.
## Why This Works
Grav resolves a plugin from **two independent locations**:
- **Code** at `user/plugins/<name>/` — installed by GPM. Note `user/plugins/` is gitignored (`/plugins/*`) and is NOT tracked by the content repo.
- **Config** — the tracked `user/config/plugins/<name>.yaml` and/or the per-host `user/env/<host>/config/plugins/<name>.yaml`.
These two can **desync**: config can exist with no code behind it. Config alone makes the plugin look present to any tool that only reads config, but the plugin stays inert until its code is on disk. GPM reinstall restores the code; `clearcache` makes Grav re-scan and pick it up.
A project-specific amplifier made this worse: `git-sync` is a **remote-only, GPM-managed** plugin. It is deliberately NOT in `plugins.txt`, so `make install-plugins` and the normal `make remote-install` flow do **not** restore it. Only an explicit `php bin/gpm install git-sync` (via `make remote-gpm-install-prod PKG=git-sync`) does. So when its code goes missing, it does not self-heal through the standard install path — you must reinstall it explicitly.
How the code went missing here is **unconfirmed**. It happened around the git-sync secret-leak remediation, but the exact step that wiped `user/plugins/git-sync/` was not established — don't assume a specific cause.
## Prevention
- **Check the code layer before the config layer.** When a Grav plugin "won't enable" and config toggles do nothing, FIRST verify the code exists: `ls user/plugins/<name>/` on the server. Config-without-code is the failure class; the empty directory is the tell.
- **Enumerate both layers in all locations when diagnosing.** Plugins have a code layer (`user/plugins/<name>/`) and a config layer, and on prod the config can live in the env tree (`user/env/<host>/config/plugins/<name>.yaml`) and persist completely independently of the code. Remember: once `user/env/<host>/` exists, Grav Admin writes ALL config there, so always check both `user/config/...` and the env path (env wins).
- **Know which plugins are remote-only.** The 3-category model: GPM-via-`plugins.txt` (admin2 / api / flex-objects), custom-in-repo (cache-on-save / story-blocks / entry-actions), and remote-only (git-sync — never in `plugins.txt`). Remote-only plugins are NOT restored by the standard install/content flows, so reinstall them explicitly via GPM after any operation that could have wiped `user/plugins/`.
- **Diagnose actual state before proposing config fixes.** An `ls` is cheaper than a guess. Establishing that the code was missing would have pointed straight at the reinstall instead of a round of config poking.
## Related
- `docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md`**closest sibling.** Same family (a plugin non-functional on prod, resolved by a GPM install + cache clear), same 2026-07-04/05 cutover context, same `plugins.txt` / `make remote-*` / GPM machinery. **Distinct trigger:** there, GPM refuses to *offer* the plugin because the installed core is below the version floor; here, the plugin's *code folder is simply missing* while its config persists (config-without-code desync). Two different ways a plugin ends up absent/inert on prod.
- `docs/solutions/architecture-patterns/git-sync-secret-exposure-and-tracked-file-boomerang.md` — same module (git-sync) and explains **why the config survived without code**: Grav Admin writes `git-sync.yaml` into the per-environment tree `user/env/<host>/config/plugins/`, which is untracked/gitignored and not part of the plugin package. The orphaned config here is the flip side of that env-tree behavior.
- `docs/solutions/conventions/grav-plugin-config-must-be-tracked-override.md` — establishes the plugin **code (GPM/gitignored) vs config (tracked override / env tree)** split that this bug exploits. This doc is a concrete failure of that split going the other way: config present (in the env tree), code absent.
- `docs/working/git-sync-notes.md` — operational notes on git-sync's per-environment tree, where `git-sync.yaml` lives server-only. Context for where the orphaned config resided.
@@ -0,0 +1,98 @@
---
title: "Admin2 login fails silently because a stale GRAV_VERSION installs an rc core below the api plugin's version floor"
date: 2026-07-04
category: docs/solutions/integration-issues
module: "grav / production deploy / plugin install"
problem_type: integration_issue
component: authentication
severity: high
symptoms:
- "Admin2 login at /admin silently fails: button disables then re-enables, no visible error, nothing written to grav.log"
- "admin2 SPA background login POST to /api/... returns 404"
- "/api/v1/pages returns 404 on prod but 401 locally (api plugin route not registered)"
- "user/plugins/api directory does not exist on prod (plugin never installed)"
- "gpm install api reports 'These packages were not found on Grav: api' even after gpm index -f"
root_cause: config_error
resolution_type: environment_setup
related_components:
- "gpm"
- "admin2 plugin"
- "api plugin"
- "scripts/server-install.sh"
- "Makefile remote targets"
- ".env.prod"
tags:
- grav
- gpm
- admin2
- api-plugin
- plugin-dependency
- version-compatibility
- production-deploy
- env-config
---
# Admin2 login fails silently because a stale GRAV_VERSION installs an rc core below the api plugin's version floor
## Problem
On a fresh Grav production install, Admin2 login fails silently because the `api` plugin — which Admin2 authenticates through — never installed. GPM refused to serve it: a stale `GRAV_VERSION` in `.env.prod` had installed Grav `2.0.0-rc.10`, and the `api` plugin requires Grav core `>=2.0.4`. GPM filters offered packages by the installed core version, so on an rc.10 core the `api` plugin was excluded from results entirely and reported as "not found." Admin2 was present (and depends on `api`), but its login POST hit an `/api/...` route that was never registered, so authentication silently 404'd before it ever reached Grav's auth layer.
## Symptoms
- Admin login at `/admin` silently fails: the login button disables briefly, re-enables, and shows no error. **Nothing appears in `logs/grav.log`** — a wrong password *would* log a failed-attempt warning, so its absence means auth was never reached.
- The Admin2 SPA's background login request (to an `/api/...` endpoint) returns **HTTP 404** with `content-type: application/json`.
- `GET /api/v1/pages` returns **404** on prod, but **401 Unauthorized** on the working local install — i.e. the api route isn't registered on prod at all.
- `ls user/plugins/api` on the server: **No such file or directory** — the plugin was never installed, even though `admin2` (which depends on it) was.
- `php bin/gpm install ... api -y``"These packages were not found on Grav: api"`, even after `php bin/gpm index -f`.
## What Didn't Work
- **Committing/deploying the api plugin config** (`enabled` / `route` / `session_enabled`, moved from the untracked `user/plugins/api/api.yaml` into the tracked `user/config/plugins/api.yaml`). This was a real, necessary fix for a *different* latent problem, but it did not fix login: you cannot configure a plugin that isn't installed. Still 404.
- **Forcing a GPM index refresh** (`php bin/gpm index -f`). No effect. "Package not found" here is not a stale-index problem — GPM filters the packages it offers by the installed Grav **core** version, and rc.10 is below the api plugin's `>=2.0.4` requirement, so `api` is excluded from results entirely.
- **Assuming "same channel = same availability."** Local (Grav 2.0.4, `stable` channel) found `api` via `gpm info api`; prod (also `stable`) reported it "not found." The channel was identical — the difference was the Grav **core** version, which silently filtered the plugin out.
## Solution
The real cause is that prod was running the wrong Grav core. `scripts/server-install.sh` downloads `grav-admin-v${GRAV_VERSION}.zip`, and `.env.prod` still carried the stale pre-upgrade `GRAV_VERSION=2.0.0-rc.10`.
1. Upgrade the Grav core in place to stable (rc.10 → 2.0.7):
```bash
make remote-upgrade-grav-prod # php bin/gpm self-upgrade -y && php bin/grav cache
```
2. Install the plugins now that a compatible core is present (the api plugin resolves):
```bash
make remote-install-plugins-prod # php bin/gpm index -f && php bin/gpm install <plugins.txt> -y
# => "Preparing to install API [v1.0.8] ... Success!"
```
3. Clear cache, then verify the api route is live and login works:
```bash
make remote-clean-prod
curl -s -o /dev/null -w '%{http_code}\n' https://site/api/v1/pages
# 401 (was 404) => plugin installed + routed
```
4. **Prevent recurrence:** update `.env.prod` to `GRAV_VERSION=2.0.4` so a future *fresh* install doesn't reinstall rc.10 (self-upgrade fixed the running server, not the env file). Keep the api plugin's functional config in the tracked `user/config/plugins/api.yaml` so it deploys on a clean clone.
## Why This Works
GPM (Grav Package Manager) only offers a plugin version whose declared Grav requirement is satisfied by the **installed core**. The `api` plugin requires Grav `>=2.0.4`; on a `2.0.0-rc.10` core there is no compatible version, so GPM reports the package as "not found" rather than a version conflict. Admin2 declares `api` as a hard dependency and performs all authentication over the api plugin's `/api/v1` JWT endpoints, so with `api` absent the login POST hits a route that doesn't exist (404) and never reaches Grav's auth layer — hence the silent failure with no `grav.log` entry. Upgrading the core to a stable `>=2.0.4` build makes GPM offer `api` again; installing it registers `/api/v1`, and Admin2's login flow succeeds.
## Prevention
- **Keep `.env.<env>` `GRAV_VERSION` current.** It is the version a *fresh* `make remote-install-<env>` bakes in; a stale value silently installs an old core. After any core upgrade, bump the env file too — self-upgrade only moves the running server. Note this is a *third* version-authority surface alongside `user/config/system.yaml` `gpm.releases` (channel) and `plugins.txt` — they must stay in sync. A **fourth** surface governs the *local Docker* core: the hardcoded `grav-admin-v<ver>.zip` URL in `Dockerfile`. `.env.<env> GRAV_VERSION` governs fresh **remote** installs only — it never touches the local Docker core (which upgrades by an image rebuild, not self-upgrade). See `docs/solutions/tooling-decisions/upgrade-local-grav-core-rebuild-docker-image.md`.
- **When GPM says "package not found" for a package you know is on your channel, check the target's Grav core version first** (`php bin/grav --version` on the server, or `make remote-diag-<env>`). GPM filters by core compatibility; "not found" often means "no version compatible with your core," not "missing from the index." `gpm index -f` will not help.
- **Don't trust a top-level install "Success" to mean dependencies installed.** A fresh install can leave a plugin's declared dependency unsatisfied (here `admin2` installed but its `api` dependency didn't). Verify with `ls user/plugins/<dependency>`. The same `ls` guards a *second*, distinct way a plugin ends up non-functional: its **code folder can be missing while its config persists** (e.g. in the per-host env tree), so it looks configured but never loads. Checking `ls user/plugins/<name>` catches both the missing-dependency and the config-without-code cases — see `grav-plugin-config-without-code-wont-enable.md`.
- **Know the Admin2 ⇄ api coupling.** Admin2 authenticates via the api plugin's `/api/v1` endpoints; a missing or unrouted api plugin makes admin login fail *silently* (login POST 404s, nothing logged). A quick `curl /api/v1/pages` expecting `401` (not `404`) is a good post-deploy smoke check.
## Related
This is one of three independent gotchas from the same **2026-07-04 Grav 2.0.4 production cutover**:
- `docs/solutions/integration-issues/grav-double-content-encoding-garbage-page.md` — sibling: garbage-rendered pages from a double `Content-Encoding` header on a non-FastCGI host. Different root cause (HTTP compression), same deploy.
- `docs/solutions/test-failures/new-user-grants-api-not-admin-on-admin2.md` — sibling: an authenticated account is denied an admin-gated page because `login new-user` auto-detect granted `api.*` but not `admin.*`. Different root cause (permission provisioning), same admin2/api area.
- `docs/working/plans/2026-07-04-grav-2.0.4-upgrade.md` — the upgrade plan whose Global Constraints spell out the GPM version floors (`grav >=2.0.4`, `api >=1.0.6`) that cause the "package not found" on an rc core.
- `docs/solutions/conventions/grav-plugin-config-must-be-tracked-override.md` — the *other* latent problem from this same investigation: the api plugin's functional config (`enabled` / `route` / `session_enabled`) must live in the tracked `user/config/plugins/api.yaml` to deploy at all. Necessary but not sufficient here (the plugin must be installed first), but a durable convention in its own right.
A closely-related **sibling in the "plugin absent/non-functional on prod" family** (from the 2026-07-05 follow-up, not one of the three cutover gotchas above):
- `docs/solutions/integration-issues/grav-plugin-config-without-code-wont-enable.md` — same outcome (a plugin inert on prod, fixed by a GPM install + cache clear), **different trigger**: there, GPM won't *offer* the plugin because the core is below the version floor; there, the plugin's *code folder is simply missing* while its config persists in the env tree (config-without-code desync). Same `ls user/plugins/<name>` smoke check flushes both out.
@@ -78,5 +78,6 @@ This site runs **Admin2 only** (the classic `admin` plugin is disabled), so auto
## Related Issues ## Related Issues
- `docs/working/plans/2026-07-04-grav-2.0.4-upgrade.md` — the self-contained test-account infrastructure shipped alongside the Grav 2.0.4 upgrade. - `docs/working/plans/2026-07-04-grav-2.0.4-upgrade.md` — the self-contained test-account infrastructure shipped alongside the Grav 2.0.4 upgrade.
- Sibling gotchas from the same 2026-07-04 Grav 2.0.4 production cutover (all surface around admin2/api but with distinct root causes): `docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md` (stale `GRAV_VERSION` → rc core → GPM won't serve the `api` plugin → login 404s) and `docs/solutions/integration-issues/grav-double-content-encoding-garbage-page.md` (double `Content-Encoding` header → garbage page).
- `docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md` — accounts live in the `user/` repo; the `testrunner` account is gitignored so it never reaches production. - `docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md` — accounts live in the `user/` repo; the `testrunner` account is gitignored so it never reaches production.
- GPX manager auth model (`access.admin.login: true` frontmatter + Login plugin) — see the project's GPX manager notes. - GPX manager auth model (`access.admin.login: true` frontmatter + Login plugin) — see the project's GPX manager notes.
@@ -0,0 +1,116 @@
---
title: Upgrade the local Grav core by rebuilding the Docker image, not gpm self-upgrade
date: 2026-07-05
category: docs/solutions/tooling-decisions/
module: docker-dev-env
problem_type: tooling_decision
component: tooling
severity: medium
applies_when:
- Upgrading the Grav core in the local Docker dev environment
- App core is baked into the image while only user content is bind-mounted
- Deciding between an image rebuild and an in-container package upgrade
- "docker compose up refuses to recreate a fixed container_name"
tags: [grav, docker, dockerfile, image-rebuild, gpm, upgrade, container-recreate]
---
# Upgrade the local Grav core by rebuilding the Docker image, not gpm self-upgrade
## Context
The test and prod servers had already self-upgraded to Grav 2.0.7 in place, but the local Docker dev environment was still on 2.0.4. The question was how to bring local to 2.0.7 **durably** — in a way that survives the next image rebuild and keeps local reproducible from the repo.
The instinct is to do what the servers do: `php bin/gpm self-upgrade` inside the running container. That is the wrong tool for the local box, and understanding why is the whole point of this note.
## Guidance
**The local Grav core is baked into the Docker image, so you upgrade it by editing the `Dockerfile` and rebuilding — never by upgrading inside a running container.**
The dev image (`Dockerfile`) `curl`s a specific release zip and copies its `system/`, `vendor/`, `bin/`, `index.php`, etc. into the image at build time:
```dockerfile
RUN curl -sL 'https://github.com/getgrav/grav/releases/download/2.0.7/grav-admin-v2.0.7.zip' ...
```
The version is **hardcoded in the URL** — there is no `ARG`, so the `GRAV_VERSION` variable in `.env*` does **not** feed the local build (it only pins the base zip for a *fresh remote install*). `docker-compose.yml` volume-mounts **only** `./user:/var/www/html/user` (plus a php.ini). Everything else — the entire core — lives in the immutable image layer.
The durable local upgrade sequence:
```bash
# 1. Bump BOTH occurrences of the version in the Dockerfile release URL
# (the /download/<ver>/ path and the grav-admin-v<ver>.zip filename)
# 2. Rebuild the image (the FROM getgrav/grav layer is cached; the RUN
# layer re-fetches the new zip in a few seconds)
docker compose build grav
# 3. Recreate the container. `up -d` may refuse (see gotcha below); if so:
docker rm -f intotheeast_grav && docker compose up -d grav
# 4. Verify the core version
docker exec -w /var/www/html intotheeast_grav php bin/grav --version # -> Grav CLI Application 2.0.7
# 5. Refresh plugins to match the servers, then clear cache
make install-plugins # docker exec ... php bin/gpm install <plugins.txt> -y
docker exec -w /var/www/html intotheeast_grav php bin/grav cache
```
**Gotcha — `docker compose up` won't replace a running fixed-name container.** The service pins `container_name: intotheeast_grav`, so `docker compose up -d` (and even `--force-recreate`) fails with `Conflict. The container name "/intotheeast_grav" is already in use`. Remove the old container first: `docker rm -f intotheeast_grav`, then `up -d`. This is **data-safe** because all persistent content lives in the `./user` bind mount, which is untouched by removing/recreating the container. (This same singleton collision bit an earlier upgrade session when the running container from the main checkout held the name+port. — session history)
## Why This Matters
**An in-container `gpm self-upgrade` is non-durable locally.** It writes into the image's filesystem layer, not the `./user` volume, so the upgraded core evaporates on the next `docker compose build` / container recreate. The image, not the running container, is the source of truth for the core — so the core version must be baked into the `Dockerfile` to persist and to stay reproducible from the repo.
**Local and server upgrade by deliberately different mechanisms:**
- **Servers** are native webroot installs with no image, so `bin/gpm self-upgrade` mutates the install in place and *is* durable there. (Note: `bin/grav upgrade` does **not** exist — the correct verb is `bin/gpm self-upgrade`. — session history)
- **Local** is rebuilt from an image, so only a `Dockerfile` bump persists.
A consequence worth remembering (accepted risk, flagged in the original upgrade session): the local gate never exercises the server's in-place `self-upgrade` path — a fresh image bakes a clean core and reinstalls plugins clean, whereas the server mutates an existing core in place. A green local build proves the clean-install path, not the in-place upgrade path; the remote upgrade is the first real test of that. (session history)
**Same mental model applies beyond version upgrades.** Because the core/runtime is baked and only `./user` is mounted, *any* runtime capability lives in the image. Adding server-side HEIC support (ImageMagick/libheif) would likewise require a custom image rebuild — which is why HEIC was handled client-side instead. "The core is in the image; only `./user` is a volume" is the reusable principle. (session history)
## When to Apply
- Any time the **local** Grav core version needs to change (upgrade or, rarely, a pinned downgrade — note `gpm self-upgrade` is forward-only and cannot downgrade).
- Whenever you catch yourself about to run `gpm self-upgrade` inside the dev container "to match the server" — stop and bump the `Dockerfile` instead.
- When `docker compose up`/`--force-recreate` reports a container-name conflict for a service with a fixed `container_name`.
## Examples
Concrete run from the 2.0.4 → 2.0.7 local upgrade (2026-07-05):
```
# Dockerfile line 3: .../download/2.0.4/grav-admin-v2.0.4.zip
# -> .../download/2.0.7/grav-admin-v2.0.7.zip
$ docker compose build grav
=> CACHED [1/2] FROM docker.io/getgrav/grav:latest
=> [2/2] RUN curl -sL '.../2.0.7/grav-admin-v2.0.7.zip' ... 3.4s
$ docker compose up -d grav
Error response from daemon: Conflict. The container name
"/intotheeast_grav" is already in use ...
$ docker rm -f intotheeast_grav && docker compose up -d grav
Container intotheeast_grav Started
$ docker exec -w /var/www/html intotheeast_grav php bin/grav --version
Grav CLI Application 2.0.7
# Smoke test from INSIDE the container (the host has no curl):
$ docker exec intotheeast_grav sh -c \
'for p in / /admin /gpx-manager; do curl -s -o /dev/null -w "%{http_code}\n" "http://localhost:80$p"; done'
200
200
200
```
**Config caveat:** a server-side `gpm self-upgrade` runs Grav's schema migration and rewrites `system.yaml` `strict_mode` flags (`twig_compat``twig2_compat`/`twig3_compat`). A fresh-image rebuild does **not** trigger that migration, so `user/config/system.yaml` in the repo must already carry the intended Twig-3 flags (it does, from an earlier reconciliation). If it didn't, local and server config would silently drift. This is another reason the image-rebuild path depends on the repo config being the source of truth.
## Related
- `docs/guides/local-setup.md` — "Upgrading to a newer Grav" section documents the same bump-and-rebuild procedure, but with stale "RC bundle" wording and without the `docker rm -f`, version-verify, plugin-refresh, or non-durability details. **Refresh candidate** — fold these operational steps in and drop the "RC" language (`Dockerfile` now pins stable `grav-admin-v2.0.7.zip`).
- `docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md` — the server/`.env`/fresh-install counterpart. That doc frames `GRAV_VERSION` as a version-authority surface for *remote* installs; this doc adds the **fourth** authority surface (the hardcoded release-zip URL in `Dockerfile`) and clarifies that `.env* GRAV_VERSION` never touches the local Docker core. Servers correctly self-upgrade because they have no image; local Docker cannot.
- `docs/guides/deploy-cycle.md` — the local→test→prod runbook. Its Phase 0 (Local) covers bumping `GRAV_VERSION` for remote installs but not upgrading the local Docker core; its "where state lives" table omits that the local core lives in the Docker image.
- `docs/solutions/integration-issues/docker-exec-root-owned-bind-mount-files.md` — the ownership counterpart to the `make install-plugins` step above (line 54). That `docker exec … php bin/gpm install` writes the plugin tree into the `./user` bind mount **as root** unless `-u $(HOST_UID):$(HOST_GID)` is passed; that doc explains the fix and why the container still boots as root.
@@ -0,0 +1,120 @@
---
title: "Grav cropResize fits-inside, not crop-to-fill — blurry cover/banner images"
date: 2026-07-07
category: ui-bugs
module: intotheeast-theme
problem_type: ui_bug
component: rails_view
symptoms:
- "Trip banner/cover renders blurry and badly cropped even though the source photo looks high-res in the post"
- "A portrait phone photo appears as a thin, upscaled horizontal sliver in a wide banner strip"
- "Cover derivative comes back at the source aspect ratio (e.g. 165x220 from a 1013x1350 portrait) instead of the requested strip"
root_cause: wrong_api
resolution_type: code_fix
severity: medium
tags: [grav, twig, medium, cropresize, cropzoom, srcset, retina, cover-image, object-fit]
---
# Grav cropResize fits-inside, not crop-to-fill — blurry cover/banner images
## Problem
The shared trip-cover macro produced a blurry, badly-composed banner/card image
for any trip whose cover fell back to a portrait journal photo. It looked like a
low-quality source, but the source was fine — the wrong Grav Medium operation was
turning it into a tiny sliver that CSS then upscaled.
## Symptoms
- Trip banner on `/trips/us-canada-mex-2024` looked "horrendous" — soft and
zoomed — while the same photo looked sharp inside the journal post.
- The rendered `<img>` derivative came back at the *source* aspect ratio, not the
requested strip: `cropResize(720, 220)` on a 1013×1350 portrait produced a
**165×220** image (0.75 ratio, matching the source), not a 720×220 strip.
- The banner box (`.trip-header-banner img { object-fit: cover; height: 200px }`)
then upscaled that ~165px-wide sliver ~4× to fill the column → blur.
## What Didn't Work
- **Assuming it was source/image quality.** The imported photos are only
~7001200px wide (pixelfed served downscaled web renditions), but that alone
did not explain the blur — the same file was sharp in the post.
- **Capping the derivative width to avoid upscaling (`min(w, source_width)`), as
a first pass.** This stopped Grav from re-encoding an upscaled JPEG, but the
derivative was *still* a portrait sliver because `cropResize` was still the
wrong operation — it emitted odd intermediate `srcset` widths (`1013w`,
`1200w`) without fixing the composition. It was treating a symptom.
## Solution
Switch the cover operation from `cropResize` (fit-inside) to `cropZoom`
(crop-to-fill / cover), and make retina all-or-nothing so a narrow source is
never upscaled.
```twig
{# BEFORE — cropResize fits the source INSIDE the box, preserving its aspect
ratio, so a portrait comes back as a narrow sliver #}
<img src="{{ cover.cropResize(w, h).url }}"
srcset="{{ cover.cropResize(w, h).url }} {{ w }}w,
{{ cover.cropResize(w * 2, h * 2).url }} {{ (w * 2) }}w">
{# AFTER — cropZoom crops-to-fill, returning an actual w×h cover strip; the 2x
descriptor is emitted only when the source is genuinely >= 2w wide #}
<img src="{{ cover.cropZoom(w, h).url }}"
srcset="{{ cover.cropZoom(w, h).url }} {{ w }}w{% if cover.width >= (w * 2) %}, {{ cover.cropZoom(w * 2, h * 2).url }} {{ (w * 2) }}w{% endif %}">
```
Verified empirically against the running container (do not trust the method
names from memory — Grav's op semantics are non-obvious):
| op | on a 1013×1350 portrait, target 720×220 | shape |
|----|------------------------------------------|-------|
| `cropResize(720, 220)` | **165×220** | fit-inside (source aspect kept) |
| `cropZoom(720, 220)` | **720×220** | crop-to-fill (cover) ✅ |
| `resize(720, 220)` | 720×220 | stretched/distorted ✗ |
## Why This Works
Grav's `Medium::cropResize($w, $h)` scales the image to **fit inside** the
`$w × $h` box while preserving the source aspect ratio — for a tall portrait it
is bound by height, yielding a narrow image far smaller than `$w`. `cropZoom`
instead scales to **cover** the box and crops the overflow, so it always returns
exactly `$w × $h` with no distortion. A banner/card strip wants cover behavior,
so `cropZoom` is correct. Capping widths at `cover.width` prevents Grav from
re-encoding an upscaled derivative; combined with `object-fit: cover` on the
element, the browser gets a sharp strip at (or below) native resolution.
Note the imported photos cap at ~1440px wide, so `cover.width >= 2w` is usually
false for the wide banner — auto-picked covers render 1x-only (sharp on standard
displays; retina only engages for an explicitly-set wide landscape `cover_image`).
## Prevention
- **Choose the Grav Medium op by intent, and verify the output dimensions.**
For a fixed-shape strip/thumbnail (banner, card, avatar) use `cropZoom`
(crop-to-fill). Use `cropResize` only when you actually want the whole image
fit inside a bounding box (aspect preserved, letterbox-friendly).
- **Confirm Medium API behavior empirically before shipping** rather than trusting
method names — a quick `php bin/grav` script that runs the op and calls
`getimagesize()` on the derivative catches fit-vs-fill surprises. (auto memory
[claude]: this repo's standing guidance is to look up / verify Grav + plugin
API behavior, never guess it.)
- **Guard retina descriptors against upscaling:** only add the 2x `srcset`
candidate when `cover.width >= 2 * targetWidth`; never emit a derivative wider
than the source.
- **Regression test the composition, not just the URL.** Assert the loaded
banner image's natural aspect ratio is the wide strip ratio (e.g. `nw/nh > 3`),
which fails if a future edit reverts to a fit-inside sliver. See
`tests/ui/trip/trip-header.spec.js` (portrait-source regression on
`us-canada-mex-2024`).
## Related Issues
- Feature that introduced the macro: `docs/working/plans/2026-07-05-trip-description-and-hero.md`
(see the 2026-07-07 follow-up note). Session history shows the retina cover
macro was built entirely with `cropResize` across the feature sessions and
`cropZoom` was never evaluated, so the bug was latent from inception and only
surfaced when real portrait content hit the banner. (session history)
- Backlog: full-resolution re-import of pixelfed photos — `docs/working/backlog.md`
(Content quality — luxury). The ~1440px source ceiling is why auto covers are
1x-only.
@@ -0,0 +1,142 @@
---
date: 2026-07-08
topic: travel-blog-reader-experience-and-road-workflow
focus: reader experience, story mode, on-the-road posting workflow — ahead of Denmark 2026 (departing ~mid-July)
mode: repo-grounded
---
# Ideation: Reader Experience, Story Mode & the Road Workflow
## Grounding Context
**Codebase context.** Grav 2.0.7 blog structured around Trips → Entries/Stories (CONCEPTS.md). Posting pipeline is mature and hardened as of the 2026-07-08 journal-post-form ship: `/post` create+edit, FilePond photos with client HEIC→JPEG, live photo editor, draft persistence, owner-scoped `entry-actions` API (delete / reorder / trip publish). Trip page renders inline map + filter-bar feed + stats. `main.js` already has a lightbox. Verified gaps: **no Open Graph / twitter:card meta anywhere in `user/themes/intotheeast/templates/partials/base.html.twig`**, **no RSS/feed plugin installed**, `transport_mode` is serialized into the map JSON (`trip.html.twig:66-69`) but **no JS or partial consumes it**, `entry.html.twig` detail view is a 12-line stub already slated for retirement (`docs/working/backlog.md`).
**Past learnings & open threads.** Curated-home brainstorm PAUSED mid-layout (hero+stats / map / latest entry / latest story / CTA; marker→popup preview). Per-photo captions deferred (`data-alt` uses filename placeholder). Transport-mode visualization deferred. Story-blocks authoring deferred until real stories are written. `travel-memories` Immich→Grav pipeline complete. Backlog: Komoot GPX pull, GPX-manager polish, full-res photo re-import.
**External context.** Polarsteps' most-loved follow feature: family views a shared trip link **without an account or app** ([Polarsteps vs FindPenguins](https://voluntouring.org/2025/07/04/polarsteps-vs-findpenguins/), [Polarsteps review](https://www.overlandsite.com/tools/polarsteps-review/)); both apps monetise post-trip printed travel books. RSS-to-email digests (Buttondown, MailerLite, Mailchimp RSS campaigns) are the standard low-friction "family inbox" channel ([RSS-to-email guide](https://www.wprssaggregator.com/rss-to-email/), [service comparison 2026](https://www.readless.app/blog/rss-to-email-services-2026)).
**Run notes.** Autonomous overnight run: no blocking questions asked; ideation frames applied inline by one agent instead of the parallel fleet (budget-lean), orchestrator-only basis verification. `direct:` bases were verified by grep/read against the working tree this night.
---
## Topic Axes
- Following along — how family & friends learn there's a new entry
- Reading the feed — arrival/dwell experience on the trip page
- Story mode — curated set pieces
- On-the-road posting — the owner's daily workflow
- After the trip — compounding, archive, keepsakes
---
## Ranked Ideas
1. [The follow-along stack](#1-the-follow-along-stack-og--share--rss--digest)
2. [The thirty-second post](#2-the-thirty-second-post-quick-log--auto-location--auto-weather)
3. [Transport-mode visualization](#3-transport-mode-visualization)
4. [The "Today" view](#4-the-today-view-resume-the-curated-home)
5. [Per-photo captions](#5-per-photo-captions)
6. [Trip Wrapped recap page](#6-trip-wrapped-recap-page)
7. [Komoot route pull](#7-komoot-route-pull-in-gpx-manager)
### 1. The follow-along stack (OG → share → RSS → digest)
**Description:** Make following the trip effortless for people who will never bookmark a blog. Four stages, each independently shippable, each building on the last:
```mermaid
flowchart TB
A[Stage 1: Open Graph + twitter:card meta\nper entry/trip/story] --> B[Stage 2: Share button on the\npost-success panel - Web Share API]
B --> C[Stage 3: RSS/Atom feed\nof the active trip]
C --> D[Stage 4: RSS-to-email digest\nfor family inboxes]
```
Stage 1 alone changes every link pasted into WhatsApp/Signal from a bare URL into a photo + title + location card. Stage 2 turns the existing post-success panel ("View your journal / Post another") into "…/ Share this entry" — one tap after every post, while the moment is fresh. Stage 3 gives the RSS-literate a subscription and is the substrate for Stage 4, where a Buttondown/MailerLite RSS campaign mails new entries to subscribed family on a daily/weekly cadence.
**Axis:** Following along
**Basis:** direct: grep confirms zero `og:` / `twitter:` meta tags in `partials/base.html.twig` and no feed plugin in `plugins.txt` or `user/plugins/`; the success panel exists in `post-form.js` (`initSuccessState`). external: Polarsteps' account-free share link is its most-cited family feature; RSS-to-email is a commodity integration.
**Rationale:** The site's readers are family and friends on phones in messaging apps — not blog visitors. Every entry already produces a perfect preview image (cover = first photo, by design). This is the highest leverage-to-effort ratio in the whole candidate set, and Stage 1 could ship before departure.
**Downsides:** Stage 4 introduces an external service and subscriber management; OG images should respect draft/unpublished state (don't leak draft covers to crawlers); feed must exclude unpublished entries.
**Confidence:** 90% (Stage 12), 75% (Stage 34)
**Complexity:** Low (Stage 12), Medium (Stage 34)
### 2. The thirty-second post (quick log + auto-location + auto-weather)
**Description:** A "quick log" posting mode for hard days: one photo + one sentence, no title required (derive it from date/location), plus removal of the two manual taps that remain in the flow — read GPS from the first photo's EXIF server-side to fill `lat`/`lng` when the fields are empty, and fetch weather server-side at submit time from coords + entry date (Open-Meteo archive API for backdated entries). The full form stays for real writing days; quick log keeps the streak alive on the days that produce none.
**Axis:** On-the-road posting
**Basis:** direct: `post-form.md` requires photos + title + content + date; location and weather are manual button taps in `post-form.js` (`initGeo`). reasoned: on a solo trip the binding constraint on journal completeness is end-of-day energy, not tooling; every removed field measurably raises the posting rate — the same logic that already removed the hero-image field and auto-collapsed the photo section.
**Rationale:** The blog's value compounds with consistency. Denmark is a cycling trip — many days will end tired. A 30-second floor means zero-entry days become one-photo entries instead of gaps.
**Downsides:** EXIF GPS may not survive the client-side HEIC→JPEG conversion (canvas-based converters typically strip metadata) — verify with a real iPhone photo first; if stripped, read EXIF client-side before conversion and post coords explicitly. Title-less entries need a rendering decision in the feed partials.
**Confidence:** 70%
**Complexity:** Medium
### 3. Transport-mode visualization
**Description:** Consume the already-serialized `transport_mode` field: style the map connector line per mode (e.g. dashed for train/bus/plane, solid for walking/cycling) and show the mode emoji/icon on entry cards and map popups. The data is being shipped to the client on every trip page load and rendered nowhere.
**Axis:** Reading the feed
**Basis:** direct: `trip.html.twig:68` serializes `transport_mode` into the map entries JSON; grep finds zero consumers in `maplibre-utils.js`, `main.js`, or any partial. The form select (walking/bicycle/bus/train/car/plane) shipped in the current post form.
**Rationale:** For a cycling-centric trip, *how you moved* is half the story the map tells. This closes a loop that was deliberately half-built: the capture side shipped, the display side was deferred. All data will exist from day one of Denmark — the earlier this ships, the more of the trip benefits.
**Downsides:** Connector styling interacts with the GPX-vs-connector suppression logic (`force_connect`, same-file proximity checks) — needs care in `MapUtils.initEntryMap`; `js/map.js` rebuild via `make build-assets`.
**Confidence:** 85%
**Complexity:** LowMedium
### 4. The "Today" view (resume the curated home)
**Description:** Resume the paused curated-home brainstorm with a sharper frame: the active-trip home is the page family checks daily, so lead with *now* — a pulsing last-position marker, "Day 12 · Aarhus · 340 km so far", the latest entry, the latest story, then the full feed/map below. Marker→popup preview (already sketched in the paused brainstorm) makes the map the navigation surface.
**Axis:** Following along / Reading the feed
**Basis:** direct: the curated-home brainstorm exists and is paused at the layout question (hero+stats / map / latest entry / latest story / CTA). external: Polarsteps' follow screen is exactly this — current position + day counter first, log second.
**Rationale:** The home page is the URL family will have. Today it renders the same feed chrome as the trip page; a "where is he *now*" lead answers the question every visitor actually arrives with, in one glance, and gives repeat visits a reason.
**Downsides:** It's a design decision as much as a build — the brainstorm needs finishing first; risks scope creep against the shared `trip-feed-col` partial (keep the partial single-purpose, add a curated lead above it rather than forking it).
**Confidence:** 65%
**Complexity:** Medium
### 5. Per-photo captions
**Description:** Give photos one-line captions: store per-image captions in Grav media metadata (`<file>.meta.yaml`), add a caption field to the edit-mode photo editor grid (tap a thumbnail → caption input, persisted via the media API), render as museum-style wall text in the feed and lightbox, and use it as real `alt` text (replacing the filename placeholder in `data-alt`).
**Axis:** Reading the feed
**Basis:** direct: `data-alt` currently carries the filename as a placeholder; per-image captions were explicitly deferred "pending Mischa's decision". reasoned: photos carry most of the feed's content weight; a single line of context ("the ferry that almost left without me") is the cheapest possible narrative upgrade and doubles as accessibility.
**Rationale:** Between a bare photo grid and a written story there is nothing today; captions are the missing middle register — and they make the eventual printed book/recap dramatically better.
**Downsides:** Captioning is one more thing to do on the road (keep it optional and editable later); `.meta.yaml` sidecars must survive the `photo-NN` renumber pipeline (`PhotoRenumberer` currently renames files — sidecars need to move with them, and `deleteUnlistedImages` already deletes them).
**Confidence:** 70%
**Complexity:** Medium
### 6. Trip Wrapped recap page
**Description:** An auto-generated end-of-trip recap: days on the road, total km (GPX-exact where available), entries written, photos taken, countries/towns visited, transport-mode split, biggest climbing day — rendered as a shareable, designed page per trip (`/trips/<slug>/recap` or an inline trip-page section that unlocks when the trip ends). Extension later: print-CSS → the Polarsteps-style trip book.
**Axis:** After the trip
**Basis:** external: Spotify Wrapped / Strava Year in Sport demonstrate the format's shareability; Polarsteps' printed travel book is its flagship post-trip product. direct: the stats machinery (per-file GPX aggregation, cycling stats, haversine fallback) already exists in `initTripStats`.
**Rationale:** The site already computes most of these numbers live; a recap reuses them as a keepsake and gives every finished trip a satisfying capstone that the trip page (an infinite feed) doesn't provide. Slovenia/Italy/US archives get retroactive value.
**Downsides:** Needs the full-res photo re-import (backlog) before a *printed* extension is worthwhile; design effort is the real cost — a half-designed recap undercuts the point.
**Confidence:** 65%
**Complexity:** Medium
### 7. Komoot route pull in gpx-manager
**Description:** Paste a Komoot tour URL into `/gpx-manager` and the server fetches the GPX (`api.komoot.de` returns GPX per tour ID) and saves it to the trip page — replacing the export→download→upload dance after each riding day.
**Axis:** On-the-road posting
**Basis:** direct: `docs/working/backlog.md` names this with the API endpoint; the gpx-manager UI, slugification, and media API plumbing all exist.
**Rationale:** On a cycling trip the GPX step is *daily* friction; this collapses it to a paste. Server-side fetch also sidesteps mobile-browser download/upload juggling.
**Downsides:** Auth requirements for non-public tours are unresearched (backlog says the same); Komoot's API is unofficial — could break mid-trip, so the manual upload path must remain first-class.
**Confidence:** 60%
**Complexity:** Medium
---
## Rejection Summary
| # | Idea | Reason Rejected |
|---|------|-----------------|
| 1 | Offline-first `/post` (service-worker queue) | Cost ≫ value: queued multipart uploads vs sessions/nonces is genuinely hard, Denmark coverage is good, and localStorage drafts already protect the text — too risky days before departure |
| 2 | Retire entry permalink + add `#anchor` deep links | Already a tracked backlog item; cleanup, not a product direction |
| 3 | Auto-story scaffold from a date range | Premature — story authoring tooling is deliberately deferred until real stories have been written; revisit with material in hand |
| 4 | No-account emoji reactions on entries | Adds the site's first anonymous public **write** endpoint (abuse/rate-limit/storage surface) right before departure; worth revisiting post-trip as the only "return channel" idea |
| 5 | Printed trip book (standalone) | Folded into idea 6 as its extension — the recap is the shippable first step and the book depends on the full-res re-import |
| 6 | Full-res pixelfed re-import + srcset | Enabler already tracked in the backlog, not an idea in itself; sequence it before any print/keepsake work |
| 7 | Distribution foundation (RSS+OG+sitemap bundle) | Duplicate of idea 1, which stages the same work |
| 8 | travel-memories on-trip cadence | Workflow practice with the existing app; nothing to build |
| — | axis: story mode | No survivors — deliberate gap: story tooling stays deferred until the first real stories exist (only candidate was rejection #3) |
+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'
```
+27 -3
View File
@@ -6,11 +6,35 @@ Ideas and improvements not yet planned or scheduled.
## Production — remaining items ## Production — remaining items
- [ ] Set `twig.cache: true` in `user/config/system.yaml` on the server (do not commit — breaks local dev) - [x] Prod Twig prod-mode (`cache: true`, `debug/auto_reload: false`) — applied as a per-environment override via `make remote-apply-env-prod` (source: `deploy/env/prod/system.yaml`); committed `system.yaml` stays dev
- [ ] Smoke test: submit one post via `/post`, confirm entry appears in dailies immediately (verifies cache-on-save with twig cache on) - [ ] Smoke test: submit one post via `/post`, confirm entry appears in dailies immediately (verifies cache-on-save with twig cache on)
- [ ] Confirm `/post` requires login — unauthenticated visitors must not be able to post - [x] Confirm `/post` requires login — verified on prod (returns the login gate to unauthenticated visitors)
- [ ] Register at carto.com and review terms for production traffic - [ ] Register at carto.com and review terms for production traffic
- [ ] Japan & Korea 2026 trip page: set `date_start`, add `cover_image`, upload GPX route file(s) - [ ] Update `GRAV_VERSION` in `.env.prod` to `2.0.4` (was stale `2.0.0-rc.10`; fixed on the running server via self-upgrade, but a future fresh install would repeat the RC)
- [ ] git-sync on prod: install, add encrypted token, apply `folders:` fix, enable after first content round-trip
---
## Hero-image cleanup (journal)
The `hero_image` field was removed from the post form (journal heroes now come
from the first uploaded photo). Follow-up: purge the now-unused field from the
journal entity end-to-end.
- [ ] **Remove hero from the journal entity** — drop `hero_image` from the entry blueprint/template so journal entries no longer carry or reference it (journal rendering already uses `entry.media.images|first`)
- [ ] **Remove hero from posts + demo content** — strip `hero_image` frontmatter from existing journal entries and the `italy-2026-demo` seed content (`user/docs/demo/`), then re-run `make demo-load`
---
## Journal entry detail page
- [ ] **Retire the journal-entry detail page** — the trip/home feed already renders each entry's full body inline (`entry.content|raw` in `partials/entry-journal.html.twig`), so the standalone `entry.html.twig` route per journal entry is largely redundant. Consider removing the route/permalink for journal entries. **Journal only** — stories are full standalone pages and keep their detail view. (Surfaced during the front-end edit brainstorm; unrelated to edit/delete itself.)
---
## Content quality — luxury improvements (much later)
- [ ] **Re-import pixelfed photos at full resolution** — the current import pulled pixelfed's optimised web renditions, so imported images cap at ~1440px on the long edge (portraits are 7001200px wide). This is fine for the feed and 1x banners, but the retina cover 2x only kicks in for genuinely wide (≥1440px) sources, so auto-picked trip banners are currently 1x-only. Find the original high-quality versions in the local filesystem and re-import them (or point the pipeline at the originals rather than the pixelfed web renditions). Purely a quality upgrade — no functional gap; future content shot/stored at full res won't have this ceiling.
--- ---
+6
View File
@@ -9,6 +9,12 @@ Backlog of confirmed bugs with root cause analysis and implementation spec for t
**Status:** fixed 2026-06-18 **Status:** fixed 2026-06-18
**Reported:** 2026-06-18 **Reported:** 2026-06-18
> **Follow-up (2026-07-07):** `deleteAll()` alone does not rebuild Grav's
> page-tree *index*, so once `/post` gained an edit mode a freshly-posted entry
> would 404 on its edit-prefill API lookup. Fixed by also calling
> `Cache::invalidateCache()`. See
> [`docs/solutions/integration-issues/grav-deleteall-doesnt-invalidate-page-tree-index.md`](../solutions/integration-issues/grav-deleteall-doesnt-invalidate-page-tree-index.md).
### Symptom ### Symptom
After submitting a new post via `/post`, the entry page file is created correctly on disk but does not appear in the `/trips/<active_trip>/dailies` feed or in the Grav Admin panel until the cache is manually flushed. After submitting a new post via `/post`, the entry page file is created correctly on disk but does not appear in the `/trips/<active_trip>/dailies` feed or in the Grav Admin panel until the cache is manually flushed.
+71 -10
View File
@@ -1,17 +1,47 @@
# Git Sync Plugin — Setup Notes # Git Sync Plugin — Setup Notes
## Folders YAML bug ## ⚠️ Config lives in the ENVIRONMENT tree, not `user/config/` (IMPORTANT)
The plugin UI always saves the folders field as a single comma-string: Prod has a per-environment override directory `user/env/<hostname>/config/`
(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
plugin — into the active environment's config tree**, not `user/config/`.
```yaml So on prod, `git-sync.yaml` (configured via Admin) lives at:
folders:
- 'pages,config,themes' ```
user/env/intotheeast.com/config/plugins/git-sync.yaml ← here (env tree)
user/config/plugins/git-sync.yaml ← NOT here
``` ```
But the plugin code iterates the array expecting separate items. This causes `git status pages,config,themes` to be passed as a single path, so git sees nothing to commit and sync silently does nothing. Why this matters:
**Fix:** Edit `user/config/plugins/git-sync.yaml` directly: - **⚠️ `user/env/` is NOT safe unless gitignored — it is NOT scoped out by the
`folders` setting.** An earlier version of this note claimed `user/env/`
"never reaches Gitea" because it is outside git-sync's synced folders. **That
is wrong and caused a live secret leak (2026-07-05).** git-sync's auto-commit
stages files *outside* the configured `folders`; on prod it pushed the whole
`user/env/intotheeast.com/config/` tree — JWT secret, CSRF salt, **and the
git-sync token + webhook secret** — to Gitea. The fix was to **gitignore
`/env/`** (commit `6e8eadb`). So: prod Admin config edits stay server-only
*only because `/env/` is now gitignored*, not because of folder scope. Author
durable config in the repo, not prod Admin. Full analysis:
`docs/solutions/architecture-patterns/git-sync-secret-exposure-and-tracked-file-boomerang.md`.
- **Look in both places.** When inspecting/toggling server config, check
`user/config/plugins/<name>.yaml` **and**
`user/env/<host>/config/plugins/<name>.yaml` (env wins).
- **Tooling is env-path-aware.** `scripts/git-sync-toggle.sh` takes a `WEBROOT`
and searches `user/env/*/config/plugins/git-sync.yaml` first, then
`user/config/plugins/git-sync.yaml`. `make remote-git-sync-disable/enable-<env>`
and `make remote-diag-<env>` use it.
## Folders format
Older plugin versions' UI saved the `folders` field as a single comma-string
(`- 'pages,config,themes'`), which the plugin iterated as one path, so sync
silently did nothing. **git-sync v3.4.4 (installed on prod 2026-07-04) saves it
correctly** as separate list items:
```yaml ```yaml
folders: folders:
@@ -20,8 +50,39 @@ folders:
- themes - themes
``` ```
Never use the Admin UI to change folders — it will rewrite the broken format. If you see the comma-string form on an older version, fix it by editing
`git-sync.yaml` directly (at whichever path it lives — see above); do not
re-save folders via the Admin UI on the buggy version.
## Files to gitignore ## Per-install / secret files — must be gitignored (gitignore is the boundary)
`user/config/plugins/git-sync.yaml` contains an encrypted token and is server-specific. `user/config/security.yaml` contains Grav nonces/salts, also server-specific. Both are in `.gitignore` and must never be committed. git-sync's auto-commit stages **everything under `user/` that is not
gitignored** — the `folders` setting does *not* scope the commit add-set (a
2026-07-05 leak proved this by pushing `user/env/**`, outside the configured
folders). So `.gitignore` — not folder scope — is the only thing keeping a
per-install or secret file off Gitea. Keep all of these gitignored in
`user/.gitignore`:
| Path | Why |
|---|---|
| `env/` | **whole per-host env tree** — holds the live git-sync token, JWT secret, CSRF salt + all server-side Admin config. Gitignored + untracked 2026-07-05 (commit `6e8eadb`) after it leaked to Gitea. NOT safe on folder scope alone. |
| `config/plugins/git-sync.yaml` | encrypted token; server-specific (also lives at env path on prod) |
| `config/plugins/api-private.php` | API JWT secret |
| `config/security.yaml` | Grav nonces/salts (legacy location) |
| `config/versions.yaml` | per-install Grav schema-migration state — differs per env (dev 2.0.4, prod 2.0.7); Grav regenerates it. Untracked 2026-07-04. |
| `config/security-private.php` | CSRF/nonce + admin rate-limit signing salt; gitignored + untracked 2026-07-05 (commit 2840018). Each env keeps its own; untracking regenerates prod's salt (one-time admin re-login). |
> **Why a key inside a *tracked* config file (e.g. `popularity.salt` in `api.yaml`) can't just be stripped** — it regenerates at runtime and boomerangs back via git-sync's `git add -A`. See `docs/solutions/architecture-patterns/git-sync-secret-exposure-and-tracked-file-boomerang.md` for the full round-trippable-set model.
## git-sync config summary (prod, 2026-07-04)
- `repository: https://git.gorinskat.nl/m038/intotheeast-com-content.git`,
`branch: main`, HTTPS + token auth (SSH is Tailscale-only).
- `sync.direction: both`, `on_save/on_delete/on_media: true` → prod Admin edits
and `/post` push to Gitea; content-repo pushes pull to prod **via webhook**
(`/_git-sync`). The webhook is configured in Gitea repo settings (same secret
as the test instance).
- **Before enabling on a fresh server**, reset the synced folders clean
(`make remote-fetch-content-<env>`) so no install-time drift (e.g. a stale
`versions.yaml`) gets pushed on the first sync. Toggle with
`make remote-git-sync-disable/enable-<env>`.
@@ -0,0 +1,123 @@
# Session handover — Playwright coverage for the edit-mode photo editor
> **✅ COMPLETE (2026-07-07) — SUPERSEDED by `2026-07-05``2026-07-07-journal-post-form-review-handover-and-qa.md`.**
> The requested coverage landed: `tests/ui/post/photo-editor.spec.js` + `edit-mode.spec.js` now
> cover the add/delete/reorder happy **and** failure paths (auth-expiry "sign in again" E5/E7,
> retry-able delete failure E4/DEL3, prefill-failure ES2/ES3). Verified green: `39 passed` on
> `:8091` (2026-07-07). All remaining work (owner UI QA + landing) is tracked in the 2026-07-07
> handover. This file is retained for history only — no further action.
**Date:** 2026-07-05
**Branch:** `feat/journal-post-form` (worktree: `.worktrees/journal-post-form`)
**Next session goal:** Add Playwright coverage for the edit-mode photo editor add / delete / reorder paths — **especially the failure paths** just implemented, which currently have zero automated coverage.
---
## TL;DR — where things stand
The photo-editor media-API feature is **code-complete and committed** but **not smoke-tested**. Three review follow-ups landed this session (commit `7ffd75e`) on the edit-mode add/delete/reorder **failure** paths. Those paths are exercised by **no** existing test, so nothing proves the behavioral changes work end-to-end. That's the whole reason for the next session.
**Do not** push, **do not** bump the submodule pin, and **do not** touch the other-session WIP (see Constraints) until the new tests pass and Mischa says go.
---
## Git state at handover
Outer repo (`.worktrees/journal-post-form`):
- `HEAD` = `7534d7d test(post-form): expect zero-padded photo-01..NN filenames`
- Status: only `M user` — the submodule pin is **intentionally stale** (not bumped mid-feature; per project convention bump once at feature end). **Leave it.**
`user/` submodule (branch `feat/journal-post-form`):
- `HEAD` = `7ffd75e fix(review): surface auth-expiry, harden add-batch rollback, add audit log`
- `361a6b4 fix(review): harden photo reorder against data loss + failure-path drift`
- `a4432d8 feat(post-form): live photo editor on entry edit (media API + SortableJS)`
- **Dirty (DO NOT COMMIT — belongs to a different session):**
- `config/plugins/api.yaml`
- `config/site.yaml`
- `themes/intotheeast/js/src/post-form.css` (a trailing FilePond CSS block)
- Nothing pushed on either repo.
---
## What commit `7ffd75e` changed (the code under test)
All in the edit-mode photo editor (the `initPhotoEditor` IIFE in
`user/themes/intotheeast/js/src/post-form.js`, bundled to
`user/themes/intotheeast/js/post/post-form.js`):
1. **Surfaced auth-expiry.** Replaced the boolean `apiOk` with `apiSend(url, opts, okStatuses)`, which rejects with an `Error` carrying `.status`. A lapsed owner login mid-edit (**401/403**) now shows *"Your login session expired — sign in again, then retry."* instead of a generic "try again". Applies to reorder, delete, and add paths (`editErrorMsg(err, fallback)` picks the copy).
2. **Hardened the add-batch rollback (review item #6).** When a post-upload reorder fails, the cleanup DELETEs no longer swallow individual failures. Each rollback DELETE resolves true/false (204/404 = truly gone); any `false` sets `rollbackIncomplete`, producing *"Couldn't finish adding photos and cleanup was incomplete — reload the page and check your photos."* instead of a false "rolled back cleanly". This closes the window where a surviving stock-named file steals the lexicographic cover slot (`media.images|first`).
3. **Audit log** on the two owner-only destructive routes in
`user/plugins/entry-actions/classes/EntryActionsApiController.php`
(`deleteEntry`, `reorderPhotos`) — behaviorally inert, logs owner + slug. Not worth a Playwright test.
**User-facing strings to assert against** (stable; survive minification):
- `login session expired` / `sign in again`
- `cleanup was incomplete`
- The N-photos-couldn't-be-added count message
---
## The API surface the editor talks to
- **Add photo:** `POST /api/v1/pages{route}/media` (stock media API, multipart)
- **Delete photo:** `DELETE /api/v1/pages{route}/media/{filename}` — editor treats **204 and 404** as success
- **Reorder:** `POST /api/v1/entry/{slug}/photos/order`, body `{ "order": ["photo-01.jpg", …] }` — custom scope-guarded route in the `entry-actions` plugin; returns **204**
- All requests use `credentials: 'include'` (session-cookie auth).
Server-side numbering invariant lives in `PhotoRenumberer` (shared by cache-on-save + entry-actions): every on-disk image is renamed `photo-01..NN` zero-padded; the manifest only supplies order, and any unlisted image is appended (never lost).
---
## Test harness facts (read before writing specs)
- **Runner:** Playwright, config at `playwright.config.js`. `testDir: ./tests/ui`. Specs are `*.spec.js`.
- **Auth is already solved.** The `setup` project (`tests/ui/auth/auth.setup.js`) logs in with `GRAV_TEST_USER` / `GRAV_TEST_PASS` (from `.env`) and saves `storageState` to `tests/.auth/user.json`; the `chromium` project loads it. **So every test already runs as the authenticated owner** — edit mode is reachable without extra login steps.
- **⚠️ Port:** `baseURL` defaults to `http://localhost:8081`, but **this worktree's dev container serves on `:8091`** (`itte_journal_grav`, mapped `8091->80`). Run with `GRAV_BASE_URL=http://localhost:8091` or the specs will hit the wrong container.
- **Helpers** (`tests/ui/helpers.js`, exported): `fillEditor`, `waitForPhotoUpload`, `postEntry`, `cleanupEntry`, `findEntry`, `readEntryMd`, `TRACKER_DIR`, `ACTIVE_TRIP_URL`. `findEntry(tag)`/`cleanupEntry(tag)` locate/remove an entry folder on disk — use them to build a fixture entry and to clean up.
- **Existing post specs** live in `tests/ui/post/` (`post-form-ux.spec.js`, `post.spec.js`, `validation.spec.js`). They cover the **create** form only — none open `/post?edit=…` or the photo editor. Mirror their style (fixtures at `tests/fixtures/test-photo*.jpg`).
- **Global setup/teardown:** `tests/global-setup.js` / `tests/global-teardown.js`.
---
## Suggested test plan for the next session
Edit mode is `GET /post?edit=<slug>` (verify the exact param against the template). Failure paths need **`page.route()` interception** to force API errors — that's the core technique here.
1. **Fixture:** post one entry via the create form (or drop a folder), capture its slug, open it in edit mode. Clean up with `cleanupEntry` in `afterAll`.
2. **Happy paths** (no interception): add a photo → persists (appears on disk / in grid); delete a photo → gone; drag-reorder → files renamed `photo-01..NN` in new order.
3. **Auth-expiry (item #1):** `page.route('**/api/v1/**', r => r.fulfill({ status: 401 }))` on a reorder/delete/add → assert the *"login session expired … sign in again"* copy appears.
4. **Incomplete rollback (#6):** let the uploads succeed but force the reorder to fail **and** at least one cleanup DELETE to fail (route-match `DELETE **/media/**` → 500). Assert the *"cleanup was incomplete — reload"* message. This is the highest-value, never-before-tested branch.
5. **Delete failure:** force a `DELETE` to 500 → assert *"Couldn't delete that photo. Try again."* and the photo stays in the grid.
Keep assertions on the **user-facing strings** above, not on minified identifiers.
### Also pending: manual smoke test
Independent of automation, the behavioral changes still want one **manual owner-session pass on `:8091`**: log in, open an entry in edit mode, add/delete/reorder and confirm each persists; then simulate a lapsed session and confirm the "sign in again" copy. If Playwright covers 25 above, this becomes a quick confidence check rather than the only verification.
---
## Constraints (carried from this session — still in force)
- **Other-session WIP is off-limits.** Do not stage/commit `config/plugins/api.yaml`, `config/site.yaml`, or the FilePond block in `themes/intotheeast/js/src/post-form.css`. If `make build-assets` recompiles `css-compiled/post-form.css` from that dirty source, **revert it**: `git checkout -- themes/intotheeast/css-compiled/post-form.css`.
- **Never** read `.env`, `.env.prod`, `.env.test` (pass them to `make`/`compose` only). `GRAV_TEST_USER`/`PASS` live there.
- **Only** write inside `travel-blog-intotheeast/` or subfolders.
- **Do not** bump the submodule pin or push until the feature is done and Mischa approves.
- **Do not** hand-edit the bundle (`js/post/post-form.js`) or `css-compiled/*` — edit `js/src/*` and rebuild with `make build-assets`.
- No dev/prod mode switching; fix issues at the app level.
- New test files go in the **outer repo** (`tests/` is outer-repo, not the `user/` submodule).
---
## Fast start for the next session
```
# worktree root
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/.worktrees/journal-post-form
# confirm the dev container is up on 8091
docker ps --format '{{.Names}}\t{{.Ports}}' | grep itte
# run existing post specs against THIS worktree's container
GRAV_BASE_URL=http://localhost:8091 npx playwright test tests/ui/post
```
@@ -0,0 +1,88 @@
# Journal Post Form — Review Handover & Owner QA
**Date:** 2026-07-07
**Branch:** `feat/journal-post-form` (worktree `.worktrees/journal-post-form`)
**State:** Implementation + code-review complete. **Remaining: owner UI QA (Part B) → then landing (Part A §Landing).**
This doc has two audiences:
- **Part A — Handover (Claude → future Claude):** exact branch state, what's committed where, the dual-session/worktree situation, and the landing procedure. Read this first in a fresh session before touching anything.
- **Part B — QA checklist (Mischa):** the owner-session UI pass the test harness cannot do (it can't obtain your login). Run on http://localhost:8091.
---
## Part A — Handover (Claude → future Claude)
### What this branch delivers
Front-end journal posting + editing, reusing `/post` + `add-page-by-form`:
- Create/edit/delete/unpublish entries from the feed (plans `2026-07-04-journal-post-form`, `2026-07-04-frontend-entry-edit`).
- In-form photo editor: add (HEIC→JPEG), inline-confirm delete, drag reorder, `photo-01..NN` renumber, first = cover (plan `2026-07-05-photo-editor-media-api`).
### Commits made in the 2026-07-07 review session (code-review F1F8)
All **local to this worktree's branch** — nothing pushed, no pin bump, no `content-push`.
**Submodule `user/`** (on `feat/journal-post-form`):
- `8db3ffe` — F1/F7: latch cache invalidation (`$cacheInvalidated`) to once-per-submit + info log — `plugins/cache-on-save/cache-on-save.php`
- `7f6bf9e` — F4: `initDisclosure` reads each toggle's default from the rendered `[checked]` attribute instead of a `/\[published\]$/` field-name regex; rebuilt bundle — `themes/intotheeast/js/src/post-form.js` + `js/post/post-form.js`
**Outer repo** (on `feat/journal-post-form`):
- `d576487` — F2/F3/F6: shared `createPhotoEntry()` helper; register cleanup **before** the awaited success toast (fixes slow-success entry leak); AE3b disclosure-deviation test — `tests/ui/helpers.js` + 4 specs
- `e10496a` — F8/F5: BUG-001 Part 2 solution doc + cross-link — `docs/solutions/integration-issues/grav-deleteall-doesnt-invalidate-page-tree-index.md`, `docs/working/bugs-and-fixes.md`
Earlier same-branch commits (prior sessions): outer `d3c1779`, `f4dbac6`; submodule `7775a4e`, `a7bda6e` — create→edit stale-cache fix (`Cache::invalidateCache()`) + H1/M8 skip-with-reason.
### DO NOT commit — off-limits WIP left dirty on purpose
- Submodule: `config/site.yaml` (owner's local `travelling:false` / `active_trip` testing config — `m` dirty is normal), `config/plugins/api.yaml`, `themes/intotheeast/js/src/post-form.css`, `themes/intotheeast/css-compiled/post-form.css` (the two CSS files get touched by `make build-assets` rebuilding from the in-progress `post-form.css` source — not part of this work).
- Outer: the `user` gitlink (`M user` — pin **intentionally not bumped**).
### Dual-session / worktree situation (verified 2026-07-07)
Two Claude sessions run in parallel. **Local isolation is real and proven:**
- This worktree's `user/` git dir: `.git/worktrees/journal-post-form/modules/user`, branch `feat/journal-post-form` — its **own object store**. The other session's branch (`feat/trip-description-hero`) is not visible here and its HEAD commit does not exist in this object store.
- Other checkouts: `content-fixes` worktree → `user/` on `feat/trip-description-hero`; main checkout → `user/` on `main`.
**The only shared resource is Gitea `origin`** (the `intotheeast-com-content.git` content repo) + the single outer pin + outer `main`. Collisions can *only* happen at push / merge-to-main / pin-bump. **Therefore: never push, never `content-push`, never bump the pin from a worktree mid-flight. Landing is a single deliberate step the owner triggers.**
### Landing procedure (owner-triggered, once QA passes) — do NOT run unprompted
1. **Owner UI QA** (Part B) passes.
2. **Submodule first.** Reconcile `user/` `feat/journal-post-form``user/` `main` (merge; prefer the merge commit, not the branch tip). Push `user/` to Gitea → this triggers the production content pull via webhook.
3. **Bump the pin.** In the outer repo, stage the `user` gitlink pointing at that `user/` `main` merge commit (must already be pushed). Commit.
4. **Outer.** Merge outer `feat/journal-post-form` → outer `main`, push.
5. **Plugin patch.** `add-page-by-form` is GPM-managed/git-ignored; the Grav-2.0 header fix lives at `deploy/patches/add-page-by-form-grav2-header.patch`. Re-apply with `make apply-plugin-patches` after any plugin (re)install on the server — R9 (add photos on edit) breaks without it.
6. **Env override.** Re-run `make remote-apply-env-prod` after any fresh install (prod Twig cache settings live only in `user/env/<host>/`, not synced by content).
7. **Pre-launch smoke** (CLAUDE.md): submit one post via `/post` on prod, confirm it appears in the trip feed immediately (verifies cache-on-save under `twig.cache:true`).
### Running the tests
- Full post suite: `GRAV_BASE_URL=http://localhost:8091 npx playwright test post/ --reporter=line` (20 pass as of 2026-07-07).
- After any `js/src/*` edit: `make build-assets` (never hand-edit `js/post/*` or `css-compiled/*`).
- `setup` project logs in → `tests/.auth/user.json`; specs run as the authenticated owner (anon-view clears storageState).
### Verified vs NOT verified
- **Verified (harness):** 20 post specs on :8091 incl. ES1 (create→edit round-trip, the cache fix), AE3b (disclosure deviation), delete flow, anon/draft visibility, HEIC convert, photo renumber; `PhotoRenumberer` unit tests.
- **NOT verifiable by harness (needs owner login / real device):** interactive photo add/delete/**drag** reorder in edit mode, on-device **touch**-drag, combined add+delete+reorder in one save. → **This is Part B.**
---
## Part B — Owner QA checklist (Mischa)
Run logged in as the owner on **http://localhost:8091** (worktree dev server). Check each box; if any fails, stop and note it — do not land.
### Create
- [ ] Post an entry with **1 photo** → success toast; entry appears in the active-trip feed **immediately**; that photo is the cover.
- [ ] Post an entry with **multiple photos including a HEIC** → HEIC converts to JPEG, all attach, first image is the cover.
- [ ] Post with **Published = No** (under "More options") → entry shows a **Draft badge** to you; open the same trip page in a **private/incognito window** → the draft is **absent**.
### Edit (open an entry's Edit link from the feed)
- [ ] Change **title + body**, Save → feed reflects the new title/body.
- [ ] Open the entry you *just* created for editing → **no "this entry no longer exists"** banner (the create→edit cache fix).
- [ ] **Add** a new photo on edit → attaches and renumbers; regressions don't drop existing photos.
- [ ] **Delete** a photo via the inline confirm → removed from disk; if you removed the first, the **cover updates** to the new first.
- [ ] **Reorder** photos by **mouse drag** → order persists after Save; first = cover on the feed.
- [ ] **Combined** in one save: add + delete + reorder → all three land correctly (cover=first, existing preserved, dropped removed).
### On-device
- [ ] On a **phone or tablet**, edit an entry and **touch-drag** to reorder photos → works and persists.
### Delete
- [ ] Delete an entry from the feed (Delete → Confirm) → card disappears and the folder leaves disk.
- [ ] Delete → **Cancel** → nothing removed.
When every box is checked, hand back to a fresh Claude session and point it at **Part A §Landing procedure**.
@@ -0,0 +1,342 @@
---
artifact_contract: ce-unified-plan/v1
artifact_readiness: implementation-ready
execution: code
product_contract_source: ce-brainstorm
title: Front-End Journal Entry Edit - Plan
date: 2026-07-04
---
# Front-End Journal Entry Edit - Plan
**Status:** ✅ Complete (2026-07-08) — M1 (U1U6) complete & verified (V1V7). M2 **partially delivered** (2026-07-05): **U7 (load existing photos into FilePond) + remove + reorder** are implemented and verified end-to-end on the :8091 container — V9 (photos load, cover-ordered) and V10 (remove a photo, reorder so a different image is the cover; on-disk `photo-1..N` renumber) both pass; reconcile helpers also covered by a reflection unit test (4 cases). One real bug found & fixed en route: `onFormProcessed` fires once per `process:` action (4×), so photo reconciliation is now latched to run **once** (a 2nd pass deleted the just-renamed `photo-N` files). Changes are in `cache-on-save.php` (edit-aware reconcile) + `post-form.js` (U7 load, D1 disable-sweep excludes the FilePond field). **R9 (add NEW photos on edit) now WORKS (2026-07-05)** via a local patch to add-page-by-form. Root cause: its edit-mode merge read existing frontmatter with `(array)$page->header()`, but Grav 2.0's `Grav\Common\Page\Header` keeps data in a protected `items`, so the cast mangled keys (`\0*\0items`) and `$original_frontmatter['photos']` was never set → `array_merge(null,…)` TypeError on any edit that uploads a file. Fix: use `Header::toArray()` (clean keys) + guard the per-field merge. add-page-by-form is abandoned upstream (last release Sept 2023) and its dir is **git-ignored/GPM-managed**, so the patch is tracked as `deploy/patches/add-page-by-form-grav2-header.patch` and re-applied via `make apply-plugin-patches` after any plugin reinstall — until the plugin is forked. Verified end-to-end on :8091: add a photo, remove one, reorder, and all three combined in one save (cover=first, existing preserved, dropped removed); create-with-photos and edit remove/reorder regressions still pass. (Grav 2.0.7 does **not** fix this on its own — the Header object is unchanged across the patch; only the plugin fix does.) **Code-review complete (2026-07-07)** — the multi-agent review of the branch ran and all findings (F1F8) were applied & verified (20/20 post specs on :8091); the review's own PERF finding confirmed and hardened the once-per-submit cache latch noted above. **Landed 2026-07-08:** merged to `main` in both repos with `feat/journal-post-form`, pin bumped, and content pushed to Gitea → prod (outer pin `f4ab730` == `user/` `main` == `origin/main`). Owner-session UI QA and on-device touch-drag (Part B of `docs/working/handovers/2026-07-07-journal-post-form-review-handover-and-qa.md`) both passed 2026-07-08.
## Goal Capsule
- **Objective:** Let the site owner edit, delete, and unpublish/publish journal entries directly from the front-end feed — reusing the existing `/post` form and the `add-page-by-form` plugin's native edit mode — without touching the Admin2 backend.
- **Product authority:** Mischa (site owner, sole author).
- **Open blockers:** None blocking. Two planning-time details flagged under Outstanding Questions.
---
## Product Contract
### Actors
- **Owner** (authenticated via the existing `site.login` gate) — the only actor who can edit, delete, or change publish state. Everything below is gated to this actor.
- **Public visitor** (unauthenticated) — sees only published entries; never sees edit/delete controls or drafts.
### Problem
Correcting a typo, fixing metadata, reordering photos, or shelving a half-written entry currently means logging into Admin2 and navigating the page tree. The owner wants to do all of it inline, from the same feed where the entries already live, on the same phone-friendly form used to post them.
### What we're building
Edit/delete/publish controls that live on the **journal feed cards of the active trip** (its trip page and the home active-trip feed, both rendered by the shared `partials/trip-feed-col.html.twig`). There is **no detail-page route** involved — the feed already renders each entry's full body inline, so the card is the surface. Delivered in two milestones.
---
### Milestone 1 — Edit, delete & publish-state from the feed cards
**Photos are untouched in M1** (the entry keeps its existing images exactly as-is).
- **R1 — Edit control.** Each journal card shows an **Edit** control when the owner is logged in. The Edit control **navigates to the post form** at `/post?edit=<entry-path>` (a query param carrying the entry's path) — a plain redirect to the existing full-page `/post` surface, not a modal or inline card expansion. The form loads prefilled with the entry's current values: title, date, content, lat, lng, location_city, location_country, weather_desc, weather_temp_c, transport_mode, featured, force_connect, published.
- **R2 — Save in place.** Saving writes back to the entry's **existing folder** (via the plugin's `overwrite_mode: edit` + a hidden path field). Editing the title or date does **not** rename the folder or change the URL — identity is stable by design. After saving, the form does a **full page reload** back to the feed (matching the existing post flow — no in-place card update).
- **R3 — Delete control.** Each journal card shows a **Delete** control (owner only). Deleting requires an explicit **confirmation step** — an inline button swap on the card (Delete → **Cancel** / **Confirm delete**), no browser dialog or modal — then removes the entry via the Grav API (session-auth `DELETE`, the pattern already used by `/gpx-manager`), clears the page-tree cache, and the card disappears from the feed.
- **R4 — Publish/unpublish toggle.** The form carries a publish-state toggle. The owner can unpublish an entry (to shelve it for later rewriting) or re-publish it. This sets the entry's `published` frontmatter. Publishing/unpublishing happens **only through the edit form** — there is no separate card-level publish control.
- **R5 — Drafts stay owner-visible.** An unpublished (draft) entry remains visible **to the logged-in owner** in the feed, marked with a **"Draft"** badge, and stays **editable** from its card (opening the edit form, where it can be re-published). It is **hidden from the public** feed entirely. Draft cards appear under **both** the "All content" and "Journal" filter tabs. Drafts are **excluded from the trip map and stats counts** — they render as a feed card only (no map marker, no stat contribution).
- **R6 — Server-side guard.** Edit, delete, and publish actions are enforced server-side, not just hidden in the UI: authenticated owner only, and only for entries inside the **active trip's** `dailies` container. The controls render **only on the active trip's** feed cards — past-trip feed pages (which share the same `trip-feed-col` partial) do **not** show them. Neither the `add-page-by-form` save path nor the Grav API delete path enforces trip-scope on its own (the plugin accepts a client-supplied `parent`/`edit_path`, and `PagesController::delete` checks only write-permission), so this guard must be a **custom server-side hook** on both the save and delete paths, validating the target route against `site.active_trip` before proceeding.
### Milestone 2 — Editable photos in the edit form (FilePond)
- **R7 — Load existing photos.** Opening an entry for edit loads its current photos into the FilePond field so they can be managed.
- **R8 — Remove photos.** The owner can delete any existing photo from the entry.
- **R9 — Add photos.** The owner can upload new photos, appended to the set, with the same HEIC→JPEG conversion used when posting.
- **R10 — Reorder.** Existing + new photos can be dragged into any order. The **first photo is the cover** — this reuses the live `photo-1..N` ordering convention, *not* the removed `hero_image` field.
---
### Scope Boundaries (non-goals)
- **Stories are untouched** by all of this — no edit/delete/publish changes to stories; they keep their standalone detail pages and `hero_image`.
- **No detail-page edit route** — edit is invoked from feed cards only (the Edit control redirects to `/post?edit=<path>`).
- **No editing of past-trip entries** — controls appear only on the active trip's cards; past trips are read-only through this UI (edit them via Admin2 if ever needed).
- **Retiring the journal detail page** is out of scope (tracked in `docs/working/backlog.md` → "Journal entry detail page"). It is cleanup unrelated to edit/delete.
- **No bulk operations** (multi-select edit/delete/publish).
### Success criteria
- The owner can fix a typo or metadata on an existing entry from the feed and see it update, with the entry's URL unchanged.
- The owner can delete an entry from the feed (after confirming) and it disappears.
- The owner can unpublish an entry, still see it (badged "Draft") and re-open it later to finish and publish — while the public never sees it.
- (M2) The owner can remove, add, and reorder an entry's photos and see the cover change to match the new first photo.
### Dependencies / Assumptions
- **`add-page-by-form` edit mode exists but needs a create-path patch** — `overwrite_mode: edit` saves to the existing folder "respecting any already present uploaded files," targeting it via a hidden **`edit_path`** field (the plugin checks `edit_path` first, then `file_path`, at `add-page-by-form.php:537-545` — standardize on `edit_path`). Note the edit branch does **not** fall through to `slug_field` when `edit_path` is empty, so the shared-form create path requires the plugin patch in KTD1/U1. This is the backbone of M1/M2 save-in-place.
- **Post-form field parity** — the `/post` form's fields already map 1:1 to entry frontmatter, so prefill is a matter of loading values, not redesigning the form.
- **Cover = first image** is an existing convention (`entry.media.images|first` in `partials/entry-journal.html.twig`); the `hero_image` field was removed and is not reintroduced.
- **Feed collection is `.published()`** — both `trip.html.twig` and `home.html.twig` collect dailies via `.children.published()`, which drops unpublished pages unconditionally. R5 (owner-visible drafts) requires replacing this with an **auth-aware collection** in both templates: include unpublished entries only when the owner is authenticated, then gate the Draft badge/controls by auth.
- **Delete + cache** — deleting an entry must clear the page-tree cache. Note cache-on-save only clears on the `new-entry` form submit, so it does **not** fire on an API delete; the Grav API's `PagesController::delete` clears the cache itself, so the delete path inherits cache-clearing from the API, not from cache-on-save.
- **Auth** reuses the existing `site.login` gate; no new auth system.
### Outstanding Questions (resolve in planning)
- **"Save as draft" on create?** The publish toggle is a shared form field, so it will also appear on the *new-entry* path — confirm whether the create form should let the owner save a brand-new entry directly as a draft (likely yes, near-zero extra cost) or always publish new entries.
- **Draft direct-URL access?** Confirm Grav returns a **404 at a draft's direct URL** for anonymous visitors (not merely hiding it from the feed collection) under the current Login plugin config — otherwise draft content is reachable by anyone who guesses the date-slug URL.
- **Auth-varying feed vs. output caching?** Once `twig.cache: true` at launch, the feed renders differently for the owner (drafts shown) vs. the public (drafts hidden). Confirm the draft branch is evaluated **per-request** (or the feed bypasses output cache for authenticated sessions) so a cached render can't leak drafts to the public or hide them from the owner. Add a launch smoke test: load the feed as owner, then anonymous, and confirm drafts don't leak.
**Planning resolutions (2026-07-04):**
- *Save as draft on create***Yes.** The `published` toggle is a shared field defaulting to Published; flipping it off on the create path saves a brand-new entry as a draft. Near-zero cost, falls out of the shared field (see KTD3).
- The *draft direct-URL* and *auth-vs-cache* questions are not planning blockers — they are **launch-time verifications** carried into the Verification Contract (V7, V8). Both are low-risk for a solo-owner blog but must be confirmed before `twig.cache: true` at launch.
---
## Product Contract preservation
Product Contract unchanged. Planning enriches this artifact in place (requirements-only → implementation-ready); all R1R10 IDs, scope boundaries, and success criteria are preserved verbatim. The only additions are the resolutions above and the Planning Contract below.
---
## Key Technical Decisions
- **KTD1 — Edit reuses the `new-entry` form via `overwrite_mode: edit` + a hidden `edit_path`; the plugin's edit branch is patched to preserve create.** Set `pageconfig.overwrite_mode: edit` on `post-form.md` unconditionally and add a hidden `edit_path` field that is **empty on create, populated on edit**. **Code check (feasibility + adversarial, confidence 100):** in `add-page-by-form.php` the `slug_field: date,title` computation lives *only* in the `else` (non-edit) branch (~lines 550-602); under `overwrite_mode === 'edit'` the slug is derived solely from `basename(dirname($form_data['edit_path']))` (line 541, guarded by `isset()`, not `!empty()`). So with an empty/absent `edit_path` the create path does **not** fall through to `slug_field` — it either writes into the dailies container itself (`basename(dirname(''))``.`) or aborts with a 'slug empty' error. The "one form for both" reuse is therefore **not implementable as written**. **Decision:** patch the plugin's edit branch so that when both `edit_path` and `file_path` are empty it falls through to the existing `slug_field` computation (restoring create behavior). This patch is a **required file of U1**, not a deferred contingency. V1 verifies both branches (empty `edit_path` → fresh dated folder; populated → in-place). *(Alternative considered and rejected for higher carrying cost: a separate edit-form page with its own `overwrite_mode: edit`.)*
- **KTD2 — Publish is folded into the edit save; no separate publish endpoint.** R4 specifies publish/unpublish happens only through the edit form, so the `published` toggle is a normal form field written to page frontmatter on save. This removes an entire endpoint from the surface — the only new server API is delete (KTD5).
- **KTD3 — `published` becomes a real form field, replacing the static `pagefrontmatter.published: true`.** Add a `published` toggle to the blueprint (default `1`). Remove the static `pagefrontmatter.published: true` so the field value is authoritative on every submit (create and edit). *Verification:* confirm the field value lands in frontmatter and the static default no longer overrides it (V2).
- **KTD4 — Prefill is client-side via the Grav API.** The Edit link opens `/post?edit=<entry-route>`; `post-form.js` reads the param, `GET /api/v1/pages<route>` (session-auth, `credentials: 'include'` — the gpx-manager pattern), and populates each field + the hidden `edit_path` + the `published` toggle. Reuses the JS layer we own and the already-configured session API. No server-side Twig form-default plumbing.
- **KTD5 — Delete is a purpose-built, active-trip-scoped API route in a new `entry-actions` plugin.** The stock `DELETE /api/v1/pages<route>` has no trip-scope guard (`PagesController::delete` checks only write-permission), which violates R6. A thin new plugin registers one route via `onApiRegisterRoutes` that: (a) requires the authenticated **owner**`grav.user.username == site.owner_username`, **not** merely any login (the super-admin `tester` account also authenticates — see KTD8); (b) resolves the delete target **through the page tree** via `$grav['pages']->find($dailiesRoute . '/' . $slug)` (never raw filesystem-path concatenation) and asserts the resolved page is non-null and `->parent()->route()` equals the active trip's dailies route — rejecting any slug containing `/` or `..` at the handler entry with 400; (c) deletes the page folder; (d) clears the page-tree cache. Rejects with 403 otherwise. **Shared guard (FYI A2):** the plugin exports the active-trip→dailies-parent resolution + "is direct child of active dailies" assertion as one helper; `cache-on-save` (KTD6) calls the *same* helper so the two R6 enforcement points cannot diverge. See the `grav-api-integration` skill for the `AbstractApiController` + `onApiRegisterRoutes` contract.
- **KTD6 — The save-path scope guard lives in `cache-on-save`'s existing `onFormValidationProcessed`.** That handler already runs for `new-entry`, resolves `site.active_trip`, and injects the parent. Extend it: when `edit_path` is present, **normalize it first** — resolve via `$grav['pages']->find($edit_path)` and assert the returned page is non-null and its `->parent()->route()` equals the active dailies route (using the KTD5 shared helper). A raw string-prefix check is insufficient: a value like `/trips/<active>/dailies/../other-slug/entry.md` passes a prefix test while `basename(dirname())` targets a *different* entry (security-lens, confidence 75). Also assert owner identity (KTD8), consistent with the delete route. Throw a `ValidationException` (fail closed) otherwise. Leave create (no `edit_path`) untouched. This is R6's enforcement point for edit/publish — no new plugin needed for the save side.
- **KTD7 — Auth-aware feed collection; map/stats stay published-only.** Replace `.children.published()` with an owner-aware collection: `grav.user.authenticated ? dailies_page.children : dailies_page.children.published()`. The feed (`all_items`) uses the owner-aware list so drafts show to the owner; the **map `entries` array and stats inputs continue to use `.published()` only**, so drafts never get a marker or a stat contribution (R5). The between-trips home grid stays `.published()` (past trips are public-only).
- **KTD8 — Controls are gated by `owner_can_edit`, computed once per surface and threaded through the feed-col partial.** **Owner identity, not just authentication (security-lens, confidence 100):** `grav.user.authenticated` is true for *any* login, including the super-admin `tester` account, so gating on it alone would grant edit/delete/draft-visibility to every account. Gate on the specific owner: `owner_can_edit = grav.user.authenticated and grav.user.username == site.owner_username and (trip.slug == site.active_trip)`. Add `owner_username` to `site.yaml` (single source of truth) so the same identity check backs the UI gate here **and** the server guards (KTD5/KTD6) — the UI gate is cosmetic; the server is authoritative. `trip.html.twig` and the home active-trip branch compute it and pass it into `trip-feed-col.html.twig`, which passes it into `entry-journal.html.twig`. Past-trip pages compute `false`, so no controls render there — satisfying R6's "active trip only" at the UI layer, matching the server guard.
- **KTD9 — M1 hides the photos field and relaxes the ≥1-photo rule in edit mode.** Photos are untouched in M1, and the create flow requires ≥1 photo (`post-form.js initValidation`). In edit mode (`?edit=` present) the photos section is hidden and the ≥1-photo check is skipped, so an edit submit with an empty FilePond leaves existing images intact (`overwrite_mode: edit` "respects already present uploaded files"). M2 replaces this by loading the real photos into FilePond.
---
## High-Level Technical Design
**Edit round-trip (M1):**
```mermaid
sequenceDiagram
participant U as Owner (browser)
participant C as Journal card
participant P as /post?edit=route
participant JS as post-form.js
participant API as Grav API (session auth)
participant APBF as add-page-by-form
participant COS as cache-on-save guard
U->>C: click Edit (owner + active trip only)
C->>P: navigate /post?edit=<entry-route>
P->>JS: page load, ?edit present
JS->>API: GET /api/v1/pages<route>
API-->>JS: frontmatter + content
JS->>P: fill fields, set hidden edit_path,<br/>set published toggle, hide photos, relax photo rule
U->>P: edit + Save
P->>COS: form submit (new-entry)
COS->>COS: assert edit_path ∈ active dailies (else ValidationException/fail closed)
COS->>APBF: proceed
APBF->>APBF: overwrite_mode:edit → write to existing folder
COS->>COS: clear page-tree cache
P-->>U: full reload → feed shows updated entry (URL unchanged)
```
**Delete flow (M1):**
```mermaid
sequenceDiagram
participant U as Owner (browser)
participant C as Journal card
participant EA as entry-actions plugin (API route)
U->>C: click Delete
C->>C: swap to Cancel / Confirm delete
U->>C: Confirm delete
C->>EA: DELETE /api/v1/entry/<slug> (credentials: include)
EA->>EA: authenticated? target ∈ active-trip dailies?
alt authorized
EA->>EA: delete folder + clear cache
EA-->>C: 200 → remove card from DOM
else rejected
EA-->>C: 403 → restore Delete control + inline error
end
```
---
## Implementation Units
### U1. Blueprint: `published` field + enable edit mode
- **Goal:** Make the post form capable of editing in place and carrying publish state.
- **Requirements:** R1, R2, R4; KTD1, KTD3.
- **Dependencies:** none.
- **Files:** `user/pages/02.post/post-form.md`; `user/plugins/add-page-by-form/add-page-by-form.php` (create-path patch, KTD1); `user/config/site.yaml` (`owner_username`, KTD8).
- **Approach:** Set `pageconfig.overwrite_mode: edit`. Add a hidden `edit_path` field (empty default). Add a `published` toggle field (default `1`, near the advanced fields). Remove the static `pagefrontmatter.published: true` so the field is authoritative (KTD3). **Patch the plugin's edit branch (KTD1):** in the `if ($overwrite_mode === 'edit')` block, when both `edit_path` and `file_path` are empty, fall through to the existing `slug_field: date,title` computation from the `else` branch (factor it into a shared code path or duplicate the slug build) so create still writes a fresh dated folder. Add `owner_username` to `site.yaml`.
- **Patterns to follow:** existing `force_connect`/`featured` toggle fields in the same blueprint; hidden field via `type: hidden`; the existing `slug_field` build in `add-page-by-form.php`'s non-edit branch.
- **Execution note:** characterization-first on the plugin patch — capture the current create-path slug output before changing the edit branch, so the patch is proven not to alter create.
- **Test scenarios:**
- Create path preserved under edit mode: submit a new entry with `overwrite_mode: edit` and an empty `edit_path` → a new dated folder is written (not the dailies container, not a 'slug empty' error), `published: true` in frontmatter. *Covers V1.*
- Publish field write: submit with `published` off → frontmatter shows `published: false` (assert the on-disk type is a real boolean/int, not the quoted string `'0'`). *Covers V2.*
- `Test expectation:` blueprint + plugin patch are behavior-bearing — covered by the two scenarios above plus U2/U5 integration.
- **Verification:** posting a brand-new entry still works exactly as before the blueprint flipped to edit mode; `published` value round-trips to frontmatter as a real boolean.
### U2. Save-path active-trip scope guard (cache-on-save)
- **Goal:** Enforce R6 on the edit/publish save path.
- **Requirements:** R6; KTD6.
- **Dependencies:** U1.
- **Files:** `user/plugins/cache-on-save/cache-on-save.php`, `tests/` (PHP or UI integration).
- **Approach:** In `onFormValidationProcessed` (already gated to `new-entry`), when `edit_path` is present **normalize it via `$grav['pages']->find($edit_path)`** and assert the resolved page is non-null and its `->parent()->route()` equals the active dailies route — using the KTD5 shared helper so save and delete share one scope check. Reject a `null` resolution or any `..`/traversal segment (a raw string-prefix check is insufficient — see KTD6). Also assert `grav.user.username == site.owner_username` (KTD8). Throw `ValidationException` (fail closed) otherwise. Leave create (no `edit_path`) untouched.
- **Execution note:** test-first — add failing tests asserting both an out-of-scope `edit_path` **and** a traversal `edit_path` (`/trips/<active>/dailies/../other/entry.md`) are rejected before writing the guard.
- **Patterns to follow:** the existing fail-closed `ValidationException` for a missing `active_trip` in the same method; the KTD5 shared scope-guard helper.
- **Test scenarios:**
- Edit within active trip's dailies → guard passes, save proceeds.
- Edit with `edit_path` pointing outside active dailies (e.g. another trip, or `/`) → `ValidationException`, no page write. *Covers V3.*
- Traversal `edit_path` that string-prefix-matches the active dailies but resolves elsewhere → `ValidationException`, no page write. *Covers V3 (traversal branch).*
- Non-owner authenticated session (e.g. `tester`) → `ValidationException`, no page write.
- Create (no `edit_path`) → guard is a no-op, entry posts normally.
- **Verification:** a forged out-of-scope or traversal `edit_path`, and a non-owner session, cannot write; in-scope owner edits and normal creates are unaffected.
### U3. Auth-aware feed collection; drafts excluded from map/stats
- **Goal:** Owner sees drafts in the feed; public and map/stats do not.
- **Requirements:** R5; KTD7, KTD8.
- **Dependencies:** none (parallel-safe with U1/U2).
- **Files:** `user/themes/intotheeast/templates/trip.html.twig`, `user/themes/intotheeast/templates/home.html.twig`.
- **Approach:** Swap `.children.published()``grav.user.authenticated ? dailies_page.children : dailies_page.children.published()` for the **feed** list only. Keep the map `entries` array and stats inputs on a `.published()`-only list. Compute `owner_can_edit` (KTD8) and pass it into `trip-feed-col`. Home active-trip branch: `owner_can_edit = grav.user.authenticated`. Between-trips grid stays `.published()`.
- **Patterns to follow:** existing `{% set journal_entries = ... %}` blocks at `trip.html.twig:12`, `home.html.twig:17`; the existing `{% include 'partials/trip-feed-col.html.twig' with { ... } only %}` param list.
- **Test scenarios:**
- Anonymous visitor: draft entry absent from feed, map, and stats. *Covers V4.*
- Authenticated owner: draft entry present in feed; still absent from map markers and stat counts.
- Published entries: unchanged for both audiences.
- **Verification:** draft visibility differs by auth in the feed only; map/stats identical for both.
### U4. Card UI: Draft badge + Edit/Delete controls
- **Goal:** Render the badge and the owner controls on the journal card.
- **Requirements:** R1, R3, R5, R6; KTD8.
- **Dependencies:** U3 (provides `owner_can_edit` and draft flag).
- **Files:** `user/themes/intotheeast/templates/partials/trip-feed-col.html.twig`, `user/themes/intotheeast/templates/partials/entry-journal.html.twig`, theme CSS (`user/themes/intotheeast/css/…` or the relevant partial styles).
- **Approach:** Thread `owner_can_edit` (owner-username gated per KTD8, not merely authenticated) through `trip-feed-col` into `entry-journal`. In `entry-journal.html.twig`: when `entry.published` is false, render a "Draft" badge in the header. When `owner_can_edit`, render an **Edit** link (`/post?edit={{ entry.route }}&return={{ page.url|url_encode }}` — the `return` param lets a save from the home feed reload back to home, not always the trip page; see U5/D5) and a **Delete** control with the inline Cancel/Confirm button-swap markup (no browser dialog). Carry `data-entry-route` for the delete JS. **Touch targets (D8):** Edit/Delete/Cancel/Confirm controls get a min 44×44px tap area (phone-first, field use) — add the sizing to the card-control CSS.
- **Patterns to follow:** the card header structure at `entry-journal.html.twig:3-22`; the filter/`data-*` attribute convention already on the `<article>`.
- **Test scenarios:**
- Anonymous: no Edit/Delete controls, no Draft badge visible (drafts absent anyway).
- Owner on active trip: Edit + Delete present on every journal card; Draft badge on unpublished ones. *Covers V5.*
- Non-owner authenticated (e.g. `tester`) on active trip: no controls (owner_can_edit false).
- Owner on a past-trip page: no controls (owner_can_edit false).
- `Test expectation:` markup/gating — covered by the above UI assertions.
- **Verification:** controls appear only for owner+active-trip; badge tracks publish state; controls meet the 44px tap-target minimum.
### U5. Edit prefill + edit-mode form behavior (post-form.js)
- **Goal:** Fill the form from the entry and adapt the form for editing.
- **Requirements:** R1, R2; KTD1, KTD4, KTD9.
- **Dependencies:** U1 (fields exist).
- **Files:** `user/themes/intotheeast/js/src/post-form.js` (rebuild via `make build-assets`; never hand-edit `js/post/post-form.js`).
- **Approach:** On `?edit=<route>` detection, **before the fetch fires, disable all form fields and swap the submit button to a "Loading entry…" state (D1)** — this prevents the owner typing into empty fields on a slow mobile connection and having that input silently overwritten when the prefill resolves. Then `GET /api/v1/pages<route>` (`credentials: 'include'`), populate title/date/content/lat/lng/city/country/weather/transport/featured/force_connect/published, set the hidden `edit_path`, hide the photos section, and skip the ≥1-photo validation (KTD9); re-enable fields + restore the submit button on success. **Edit-mode chrome (D6):** set the form `h1` to "Edit entry" and the submit button to "Save changes". **On fetch failure (D7):** show an inline error banner between the form heading and the first field, restore empty defaults, keep fields disabled (don't leave a half-filled form). **Save behavior:** keep the existing full-reload-on-success, redirecting to the `return` URL param when present, else the active trip page (D5). **Save-failure state (D3):** if the server guard rejects the submit, the error re-render must preserve the hidden `edit_path`, the `published` toggle, and the prefilled fields so the owner doesn't lose edit context (relevant given the Form 9.1.10 re-render path — see Risks/A3). **Pre-U5 check (A4):** confirm with one `curl` (session cookie) that `GET /api/v1/pages<route>` returns the required frontmatter keys + content and record the exact JSON path (`header.*` vs flat) before wiring field mapping — the gpx-manager reference only covers `/media`.
- **Patterns to follow:** the existing API-fetch + `credentials: 'include'` usage in `gpx-manager.html.twig`; the existing `initValidation` and field-setting helpers in `post-form.js`.
- **Test scenarios:**
- Loading state: on `?edit=`, fields are disabled and the button reads "Loading entry…" until the fetch resolves; typing is impossible before prefill lands. *Covers D1.*
- Edit load: `/post?edit=<route>` fills every field with the entry's values, sets `edit_path`, and shows the "Edit entry" heading. *Covers V6.*
- Edit save: change the title, submit → same folder/URL, title updated, photos intact; reload lands on the `return` surface. *Covers V1 (edit branch), D5.*
- Photos hidden + ≥1-photo rule relaxed in edit mode: submitting with empty FilePond succeeds and keeps existing images.
- API fetch failure: inline error banner shown between heading and first field; form not silently broken.
- **Verification:** editing round-trips values with a stable URL; the form is never editable before prefill lands; photos survive an M1 edit; a failed save preserves edit context.
### U6. Delete API route + card delete wiring (entry-actions plugin)
- **Goal:** Actually delete an entry, scoped to the active trip.
- **Requirements:** R3, R6; KTD5.
- **Dependencies:** U4 (delete control markup).
- **Files:** new plugin `user/plugins/entry-actions/` (`entry-actions.php`, `entry-actions.yaml`, `blueprints.yaml`); **delete JS in a small feed-scoped script** `user/themes/intotheeast/js/src/feed-actions.js` (rebuilt via `make build-assets`) — the delete control lives in `entry-journal.html.twig` (rendered by the feed partial, not the `/post` page), so it does **not** belong in `post-form.js` (C3); `plugins.txt` note only if GPM-managed (this is custom-in-repo, so **not** added to `plugins.txt`).
- **Approach:** Register `DELETE /api/v1/entry/<slug>` via `onApiRegisterRoutes`. Handler: require the authenticated **owner** (`grav.user.username == site.owner_username`, KTD8); reject any slug with `/` or `..` at entry (400); resolve the target through the page tree via `$grav['pages']->find($dailiesRoute . '/' . $slug)` (never raw filesystem-path concatenation); assert the resolved page is non-null and a direct child of the active dailies (KTD5 shared helper); delete the page folder; `cache->deleteAll()`. Return 200/400/403/404 as appropriate. **Frontend (feed-actions.js):** Delete → inline swap to Cancel/Confirm. **On Confirm-click (D2): immediately disable both buttons and set Confirm to "Deleting…", and announce via an `aria-live` region** — prevents a mobile double-tap firing two DELETEs (the second 500s on an already-removed folder). On 200: **capture the next-sibling journal card, remove the deleted card, then move focus to that sibling (or the feed heading if it was the last card) and announce "Entry deleted" via `aria-live` (D4)**. On 403/error: re-enable both buttons, restore labels, show a one-line inline message directly below the control, constrained to card width (D7).
- **Execution note:** test-first on the scope guard — out-of-scope, traversal, and non-owner deletes must be refused before the happy path is wired.
- **Patterns to follow:** `grav-api-integration` skill (`AbstractApiController`, `onApiRegisterRoutes`, response/exception helpers); `api.yaml` session-auth config; the gpx-manager delete fetch shape.
- **Test scenarios:**
- Owner deletes an active-trip entry → folder gone, cache cleared, card removed, focus moves to the next card. *Covers V5 (delete).*
- Delete targeting a non-active-trip / arbitrary page route → 403, nothing deleted. *Covers V3 (delete branch).*
- Traversal slug (`../`) or slug containing `/` → 400, nothing deleted.
- Non-owner authenticated session (`tester`) → 403, nothing deleted.
- Unauthenticated delete request → 401/403, nothing deleted.
- In-flight guard: double-tapping Confirm fires exactly one DELETE (buttons disabled after first click).
- Confirmation UX: Delete → Cancel restores original control; Delete → Confirm triggers the request.
- **Verification:** scoped delete works for the owner only; out-of-scope/traversal/non-owner/unauth requests are refused; no double-submit; focus is preserved after removal.
### U7. M2: Load existing photos into FilePond on edit
- **Goal:** Show the entry's current photos in the edit form so they can be managed.
- **Requirements:** R7; (M2).
- **Dependencies:** U5 (edit mode established). Milestone 2.
- **Files:** `user/themes/intotheeast/js/src/post-form.js`; possibly the `entry-actions` plugin or Grav media API for per-photo metadata.
- **Approach:** In edit mode, instead of hiding the photos section (KTD9's M1 behavior), pre-populate FilePond with the entry's existing images as remote/local items (FilePond `files` init pointing at the entry media URLs). Re-enable the photos section for edit.
- **Patterns to follow:** the existing FilePond init + `GravFilePond` usage in `post-form.js`; entry media URLs as rendered in `entry-journal.html.twig`.
- **Test scenarios:**
- Edit load: existing photos appear as FilePond items in current order. *Covers V9.*
- Entry with a single photo / many photos both render correctly.
- **Verification:** the edit form shows the real photos ready to manage.
### U8. M2: Persist add / remove / reorder (cover = first)
- **Goal:** Save photo changes back to the entry.
- **Requirements:** R8, R9, R10; (M2).
- **Dependencies:** U7.
- **Files:** `user/themes/intotheeast/js/src/post-form.js`, `user/plugins/cache-on-save/cache-on-save.php` (`reorderPhotos`), `user/plugins/add-page-by-form/add-page-by-form.php` (file-delete path).
- **Approach:** On save, reconcile FilePond state to the `photo-1..N` scheme (drag order = cover order, reusing the existing rename convention). Route removals through `add-page-by-form`'s existing deleted-files mechanism (`add-page-by-form.php:121, 715-718`) so dropped images are unlinked. New uploads get the same HEIC→JPEG conversion as create. Verify `reorderPhotos` is reachable from the edit path.
- **Execution note:** characterization-first — capture current `reorderPhotos` behavior before extending it to the edit path.
- **Patterns to follow:** existing `photo-1..N` rename + `reorderPhotos()` in `cache-on-save`; HEIC→JPEG `beforeAddFile` hook in `post-form.js`.
- **Test scenarios:**
- Remove a photo → file unlinked on disk; remaining renumbered; feed cover updates. *Covers V10.*
- Add a photo (incl. HEIC) → appended, converted, renamed into sequence.
- Reorder so a different image is first → that image becomes the feed cover.
- Mixed add+remove+reorder in one save → final on-disk set matches the FilePond order exactly.
- **Verification:** the on-disk photo set and cover match the FilePond state after save.
---
## Verification Contract
- **V1 — Create not regressed by edit mode.** With `overwrite_mode: edit` and no `edit_path`, posting a new entry writes a fresh dated folder identical to prior behavior — verified by the KTD1 plugin patch (empty `edit_path`/`file_path` falls through to `slug_field`). Assert on the **on-disk folder + feed**, not the re-rendered form (the Form 9.1.10 re-render may 500 — see Risks/A3).
- **V2 — Publish field round-trips.** The `published` toggle writes a real boolean `published: true/false` to frontmatter (not the quoted string `'0'`) and the removed static default no longer overrides it.
- **V3 — Scope guard rejects out-of-scope, traversal, and non-owner writes/deletes.** A forged out-of-scope `edit_path`/delete route, a traversal path that string-prefix-matches active dailies but resolves elsewhere, and a non-owner authenticated session (e.g. `tester`) are each refused server-side (edit → `ValidationException`; delete → 403/400), with no disk change. Both guards call one shared helper (KTD5).
- **V4 — Draft visibility is auth-scoped.** Anonymous: draft absent from feed/map/stats. Owner: draft present in feed only (still absent from map markers and stat counts).
- **V5 — Owner (only) can edit and delete from the card.** Active-trip cards expose working Edit and Delete (with confirm) to the owner username only; the edited entry keeps its URL; the deleted entry disappears and focus moves to the next card. Assert on disk/feed, not the re-render (A3).
- **V6 — Prefill loads all fields.** `/post?edit=<route>` populates every listed field plus `edit_path` and the publish toggle, and the form is not editable until prefill lands (D1).
- **V7 — (interim + launch) Draft direct-URL returns 404 to anonymous.** Confirm a `published: false` entry's URL 404s for anonymous visitors under the current Login config — not merely feed-hidden. **Run this in the dev container during M1** (added to DoD), not only as a launch gate — entry URLs follow a guessable date-slug pattern.
- **V8 — (launch) No draft leak under `twig.cache: true`.** With caching on, load the feed as owner then anonymous; drafts never leak to the public nor vanish for the owner.
- **V9 — (M2) Existing photos load into FilePond on edit.**
- **V10 — (M2) Add/remove/reorder persists; cover = first photo.**
Existing UI suite to extend: `tests/ui/post/post-form-ux.spec.js` and helpers in `tests/ui/helpers`. Standalone Playwright scripts run against the container per the session norm. **Given the Form 9.1.10 filepond regression (Risks/A3), M1 UI assertions target the on-disk entry and the re-rendered feed, not the post-submit form re-render.**
---
## Definition of Done
- All M1 units (U1U6) implemented; V1V6 pass, plus **V7 run in the dev container** as an M1 check (draft direct-URL 404s for anonymous). V8 recorded as a launch-gate check (not blocking M1 merge but tracked).
- Owner (owner-username, not merely any authenticated account) can edit, delete (with confirm), and unpublish/publish a journal entry entirely from the active-trip feed, with the entry URL stable and the public never seeing drafts.
- Server-side scope guard proven on both save and delete paths via the shared helper (V3), including traversal and non-owner rejection.
- Empty-`jwt_secret` risk resolved: confirmed the API does not accept empty-signed tokens on the new routes (see Risks/S1).
- M2 units (U7U8) implemented; V9V10 pass — may land as a separate follow-up PR after M1.
- No regression to the create flow (V1) or to stories.
- Assets rebuilt via `make build-assets`; no hand-edits to `js/post/post-form.js`.
---
## Risks & Dependencies
- **`overwrite_mode: edit` create-path behavior (KTD1)** was the load-bearing assumption and it **fails as originally written** (feasibility + adversarial, confidence 100) — the plan now resolves it with a required plugin patch in U1 (fall through to `slug_field` when `edit_path`/`file_path` empty). V1 verifies the patched create path. Contingency if the patch proves unworkable: a dedicated edit-form page.
- **Empty `jwt_secret` in `api.yaml` (S1, security-lens).** `jwt_secret: ''` alongside `jwt_enabled: true` — if the API plugin accepts tokens signed with the empty string, the "authenticated owner" guard on both new routes (delete, prefill GET) is forgeable by an unauthenticated attacker. **Pre-M1 check:** verify against the api plugin source (or empirically) that an empty secret means "JWT disabled" and does not accept empty-signed tokens; if it does, set a real secret before shipping. The plugin's own owner-identity assertion (KTD5/KTD8) is the primary control regardless.
- **Grav API session permission for the custom delete route** — confirm the `site.login` session carries sufficient permission for the plugin's delete action (page removal may need an elevated check); the plugin owns its own auth assertion regardless (KTD5).
- **Form 9.1.10 filepond regression** (flagged in project instructions: the post-submit re-render 500s on the filepond field) affects **M2** photo editing **and also M1's edit-save reload (A3, adversarial)** — every `new-entry` submit, including an M1 edit save, goes through the same re-render. It also already breaks the 6 post UI specs. **Mitigation for M1:** assert V1/V5/V6 on the on-disk entry + re-rendered feed rather than the post-submit form re-render (see Verification). M2 photo editing should land only once the regression is resolved in the form-to-page/image-upload rework; do not work around it here.
- **CSRF posture** — the delete route relies on the existing `cors.credentials: false` (blocks cross-origin credentialed fetch). The edit-save POST additionally depends on the PHP session cookie's `SameSite` attribute; confirm it is `Lax`/`Strict`. Document this dependency; revisit if CORS is ever loosened.
- **Owner account hygiene** — the super-admin `tester` account authenticates and, under a naive `grav.user.authenticated` gate, would gain full edit/delete rights; the owner-username gate (KTD8) closes this. The `tester` account should not ship to production.
---
## Sources & Research
- Codebase (grounding for every KTD): `user/plugins/add-page-by-form/add-page-by-form.php` (edit mode 537-545, delete path 121/715-718), `user/plugins/cache-on-save/cache-on-save.php` (parent injection + cache clear), `user/pages/02.post/post-form.md` (blueprint), `user/themes/intotheeast/templates/trip.html.twig` & `home.html.twig` (feed collection), `partials/trip-feed-col.html.twig` & `partials/entry-journal.html.twig` (card), `user/themes/intotheeast/templates/gpx-manager.html.twig` + `user/plugins/api/api.yaml` (session-auth API delete pattern), `user/themes/intotheeast/js/src/main.js` (filter bar).
- Skills: `grav-api-integration` (custom API route contract for the `entry-actions` delete endpoint).
- Upstream: this artifact's own Product Contract (ce-brainstorm) and the ce-doc-review pass of 2026-07-04.
@@ -2,7 +2,7 @@
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Status:** ✅ Complete (2026-07-04) — Phase 1 (local) and Phase 2 (remote test env) both validated and shipped; git-sync re-enabled on test. Phase 3 (prod) is documentation-only per design. See "Known issue" below re: Form 9.1.10 filepond. **Status:** ✅ Complete — Phase 1 (local), Phase 2 (test env), and **Phase 3 (production) all executed and shipped**. Phase 3 was run for real on 2026-07-05 (see the Phase 3 section for the execution outcome and the three `docs/solutions/` gotchas it produced). git-sync re-enabled on test and set up on prod. See "Known issue" below re: Form 9.1.10 filepond.
**Goal:** Upgrade Grav core `2.0.0-rc.10``2.0.4` stable and promote `admin2`/`api`/`flex-objects` to GPM management, validated on local then the remote test env (prod is documented-only). **Goal:** Upgrade Grav core `2.0.0-rc.10``2.0.4` stable and promote `admin2`/`api`/`flex-objects` to GPM management, validated on local then the remote test env (prod is documented-only).
@@ -539,11 +539,32 @@ Content, config, and accounts are in git, so no data restore is required — but
--- ---
## Phase 3 — Production (fresh install, NOT executed) ## Phase 3 — Production (fresh install) — EXECUTED 2026-07-05
Production is empty, so this is a **fresh install**, not an upgrade — and it is **documentation only**. Do not run it as part of this plan. > **Execution outcome (2026-07-05):** the fresh prod install was run for real
> (`make remote-install-prod`) and the site is live at `https://intotheeast.com`.
> The runbook below was followed, but three non-obvious gotchas surfaced — each
> now has its own learning in `docs/solutions/`:
> - **Stale `.env.prod GRAV_VERSION`** installed Grav rc.10, so GPM wouldn't
> serve the `api` plugin (needs ≥2.0.4) → Admin2 login 404'd silently. Fixed
> via `make remote-upgrade-grav-prod` (→ 2.0.7) + reinstall. See
> `docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md`.
> **TODO: bump `.env.prod GRAV_VERSION` to `2.0.4`** so a future fresh install
> doesn't repeat the RC.
> - **Double `Content-Encoding` header** (non-FastCGI host + mod_deflate)
> rendered a garbage page once prod switched to `twig.debug: false`. Fixed via
> `debugger.shutdown.close_connection: false` in the prod env override. See
> `docs/solutions/integration-issues/grav-double-content-encoding-garbage-page.md`.
> - **Plugin config stranded in the untracked `user/plugins/`** doesn't deploy.
> See `docs/solutions/conventions/grav-plugin-config-must-be-tracked-override.md`.
>
> Twig prod-mode is applied as a per-environment override (`deploy/env/prod/system.yaml`
> via `make remote-apply-env-prod`); git-sync is installed, configured, and enabled
> (see `docs/working/git-sync-notes.md`). Remaining minor follow-ups: gitignore
> `config/security-private.php` (committed salt); optional `popularity.salt` strip.
When prod is provisioned: The original runbook (production was empty, so this was a **fresh install**, not
an upgrade):
1. **Provision creds:** copy the REMOTE section of `.env.example` into `.env.prod` with production values (never commit it). Run `make remote-env-setup-prod`. 1. **Provision creds:** copy the REMOTE section of `.env.example` into `.env.prod` with production values (never commit it). Run `make remote-env-setup-prod`.
2. **Fresh install at 2.0.4:** `make remote-install-prod` with `GRAV_VERSION=2.0.4` in `.env.prod`. `scripts/server-install.sh` installs core, then all of `plugins.txt``admin2`/`api`/`flex-objects` now install purely via `php bin/gpm install` (no zip-stash; that special-casing was removed in Task 4). The `gpm.releases: stable` channel arrives with the `user/` content clone. 2. **Fresh install at 2.0.4:** `make remote-install-prod` with `GRAV_VERSION=2.0.4` in `.env.prod`. `scripts/server-install.sh` installs core, then all of `plugins.txt``admin2`/`api`/`flex-objects` now install purely via `php bin/gpm install` (no zip-stash; that special-casing was removed in Task 4). The `gpm.releases: stable` channel arrives with the `user/` content clone.
@@ -10,7 +10,7 @@ execution: code
# Journal Post Form Improvements — Plan # Journal Post Form Improvements — Plan
**Status:** 📋 Not started **Status:** ✅ Complete (2026-07-04) — implemented on `feat/journal-post-form` (U1U7). U4 changed course during execution: the "plain input + custom uploader" fallback uploaded to Grav's flash but couldn't attach photos to the entry without replicating FilePond's undocumented submit contract, so photos now stay on `type:filepond` with a `beforeAddFile` hook that converts HEIC→JPEG then re-adds via `pond.addFile()` (FilePond owns upload+attach). Verified end-to-end in a browser (HEIC→JPEG attach, corrupt-HEIC fail-closed, disclosure, weather gating, draft restore) and via curl (active-trip parent injection + empty-`active_trip` fail-closed). Merged into `main` on 2026-07-08 (outer-repo `feat/journal-post-form`).
> Plan type: `feat` · Depth: Deep — feature · Origin: `/ce-brainstorm` "improve the current php plugin that allows me to add a new journal page to the current active trip" (2026-07-04) > Plan type: `feat` · Depth: Deep — feature · Origin: `/ce-brainstorm` "improve the current php plugin that allows me to add a new journal page to the current active trip" (2026-07-04)
@@ -0,0 +1,112 @@
---
title: Photo Editor for Journal Entries (media-API) — Plan
date: 2026-07-05
---
# Photo Editor for Journal Entries (media-API) — Plan
**Status:** ✅ Complete (2026-07-08). Server (shared `PhotoRenumberer`, reorder route, guards) + client (own grid, SortableJS, FilePond decommission) landed; `PhotoRenumberer` unit-verified (pad/normalise/swap/gap/crafted-name-safety/10+/idempotent/ext), PHP lints clean, JS/CSS build clean. Shipped with `feat/journal-post-form`**merged to `main` in both repos and deployed** (outer pin `f4ab730` == `user/` `main` == `origin/main`; content pushed to Gitea → prod). Owner-session UI QA (add incl. HEIC, inline-confirm delete, mouse reorder, combined, feed cover=first, regressions a/b/c) and on-device touch-drag both passed 2026-07-08. Server-side SVG block deferred to the R6 add/delete fast-follow (see Deferred).
## Why this exists (the honest reason)
M2 tried to edit an entry's photos by reusing the `/post` **create** form + FilePond + the abandoned `add-page-by-form` plugin. Two distinct failure classes came out of that, and it matters not to blur them into one root cause:
- **FilePond-widget bugs**`text/html` previews and broken touch-drag. FilePond is built to upload new files to a fresh entry, not to load/preview/reorder existing server files; these are the widget used against its grain.
- **PHP-side bugs** — the header-cast fatal and the rename-reconcile gymnastics live in `add-page-by-form` / `cache-on-save`, **not** in FilePond. This plan **reuses that same rename-reconcile logic** (see the reorder route below), so it must be validated on its own merits — a "different foundation" does not make the carried-forward reconcile code automatically safe.
This plan replaces the **photo UI** with the **proven `gpx-manager` pattern**: our own UI talking straight to the Grav media API.
## Foundation status (what's proven vs still assumed)
**Proven on 2026-07-05 (not assumed):**
- `POST /api/v1/pages<entry-route>/media` (FormData `file`, owner session) → **201**, file on disk ✓
- `DELETE /api/v1/pages<entry-route>/media/<filename>`**204**, removed ✓
- Owner session auth works on entry routes ✓
**Still assumed (novel, load-bearing, NOT yet proven — this is where the 4-day risk lives):**
- The custom reorder route (rename to `photo-01..NN`) — no stock endpoint exists.
- Live reorder-rename behaviour under real add/delete ops.
- Three independent live mutations interacting cleanly with the edit session's text-field Save.
- HEIC→JPEG conversion at real photo sizes/counts on a phone.
## Design decisions
1. **Live, not on-submit.** Add / delete / reorder each persist **immediately** via the API — decoupled from the `/post` form's text-field Save. No flash, no submit-time reconcile. This sidesteps `add-page-by-form` for the photo path entirely (the text-field save still uses it + our committed patch). *(Edit-then-leave / no-undo behaviour for the destructive delete path is unresolved — see Open Questions.)*
2. **Inline on `/post?edit`.** In edit mode, hide the FilePond section and render the photo-editor component from the media list. **Create mode keeps FilePond, untouched** (out of scope). Hiding the section alone is **not** enough — see the FilePond decommission step in the Client section.
3. **Own thumbnail grid, SortableJS for drag.** Square `<img>` thumbnails in a grid. Reorder via **SortableJS** — exactly what FilePond couldn't do reliably here. SortableJS is **not yet a theme dependency**: install `sortablejs` and import it into `js/src/post-form.js` so esbuild bundles it into `js/post`. This is a task, not existing foundation.
4. **Cover = first.** After any add/delete/reorder, files are renumbered **`photo-01..NN`** (zero-padded, wide enough for the expected max) in display order; the client sorts thumbnails **numerically**, and the feed renders `media.images|first` as cover. Zero-padding is required so lexicographic media order equals numeric order past 10 photos (otherwise photo-1, photo-10, photo-2…). The shared renumber helper must also normalise any pre-existing un-padded `photo-N` files on first reorder. **This helper also runs on create-mode reconcile**, so create-mode entries will now emit `photo-01..NN` too — an intentional, accepted change (see Scope boundaries). Existing published entries keep their un-padded names harmlessly (they have <10 photos and the client sorts numerically).
5. **Add/delete via stock media API; reorder via one custom scope-guarded route.** Stock `POST`/`DELETE …/media` are already proven on entry routes, so add + delete use the **stock media API** (client-side, session-auth). Only the missing **reorder** (rename to `photo-01..NN`) is a custom route in the **`entry-actions`** plugin using `EntryScopeGuard` (owner-username + direct-child-of-active-dailies, the R6 guard). **Accepted tradeoff:** server-side scope enforcement on photo **add/delete** is a **known R6 gap** — any account with `api.media.write` can reach the un-scoped stock endpoint directly, and the client UI gate is **not** an access-control boundary. For a solo-owner blog this is accepted for launch and tracked as a **documented fast-follow** (promote add/delete onto scope-guarded custom routes later). HEIC→JPEG happens client-side before upload (reuse the existing converter).
## Server — `entry-actions` plugin, 1 custom route (+ stock media API for add/delete)
**Add / delete — stock media API (client-side, session-auth):**
- `POST /api/v1/pages<entry-route>/media` — upload (stock endpoint). **No SVG support for now:** add `svg` to `security.uploads_dangerous_extensions` (or reject `.svg` in the upload path) so SVGs are **blocked, not sanitized** — this removes the stored-XSS-via-SVG vector without depending on `security.sanitize_svg` staying enabled. Other executable types (html/js/php) are already blocked by Grav's default dangerous-extension denylist, which is the **actual** control on this stock path — there is no positive MIME allowlist or on-disk extension rewrite here. Allowed image types: **jpg/jpeg/png/webp**; the client file input accepts those **plus HEIC** (converted client-side to JPEG before upload) and excludes SVG. If stronger positive-MIME validation is ever wanted, it moves add onto the scope-guarded custom route (the same place the R6 add/delete fast-follow lands).
- `DELETE /api/v1/pages<entry-route>/media/<filename>` — remove.
- **After every stock add and every stock delete, immediately call the reorder route (below) to re-establish `photo-01..NN`.** Stock upload keeps the file's original (slugified) name — not the next `photo-N` — and stock delete leaves a numbering gap without renumbering; without a follow-up renumber, `cover = first` breaks until the next manual drag. The reorder route is the single owner of the `photo-N` invariant.
**Reorder — one custom route (owner + scope guarded):**
- `POST /api/v1/entry/<slug>/photos/order` — body: ordered filenames → two-phase rename to `photo-01..NN` (reuse the proven cache-on-save rename logic; factor it into a shared helper — and validate that helper on its own, per "Why this exists").
Handler: `EntryScopeGuard::isOwnerUser` + `resolveActiveDailyChild` (reject 403/400 otherwise), then filesystem op, then `cache->deleteAll()`. Reject filenames containing `/` or `..`. **Operate only on filenames that already exist as image media** in the entry folder — any name in the ordered list that isn't a current image file is ignored, so the entry `.md`, a `.gpx`, or a `.meta.yaml` can never be renamed or clobbered by a crafted order body.
**Deploy note:** the new `/entry/<slug>/photos/order` route only registers after the API route-map cache is rebuilt, so a cache clear must run on deploy. The existing `DELETE /entry/<slug>` route confirms the nested-static-after-param pattern registers fine.
## Client — new `photo-editor.js` (bundled into the post-form entry)
In edit mode only:
- **Decommission the FilePond photo path (hiding it is not enough).** Skip `editLoadPhotos()` and the FilePond `photo_order` submit-handler wiring entirely — do not initialise/populate FilePond. Otherwise the stale `photo_order` manifest posted on text Save drives `cache-on-save.reconcilePhotos()``deleteUnlistedImages()`, which **silently deletes any photo added live after page-open**. With an empty manifest the reconcile leaves the live-managed folder untouched.
- Hide the FilePond `.photos-collapse`; render `.photo-editor` from `GET …/media` (image files, numeric-sorted). Show a **loading placeholder** during the fetch and an **empty state** for zero-photo entries that keeps the "Add photos" button visible ("No photos yet — add some").
- Each cell: `<img>` thumbnail + ✕ delete. **Inline confirm:** ✕ swaps the cell to "Delete? [Confirm] [Cancel]" (Confirm disabled while the DELETE is in flight) → `DELETE …/media/<file>` → renumber → re-render; Cancel reverts.
- "Add photos" button → hidden file input → HEIC→JPEG → `POST …/media` (one per file) → renumber → re-render. **Upload progress:** disable the button while a batch is in flight and show "Uploading N of M…", clearing per file.
- **Add is a two-write op (stock upload, then reorder).** On a multi-file add, upload each file (stock `POST …/media`) and call the reorder route **once after the whole batch** — not per file — so there is one renumber pass and only the final numbering matters. The client passes the stock-uploaded basenames into that reorder manifest. If an upload succeeds (201) but the follow-up reorder fails, auto-retry the reorder — it is idempotent, since `renumberPhotos` skips files not on disk — or roll back by `DELETE`-ing the just-uploaded file(s), and surface a single inline error. Never leave an orphan stock-named file in the folder: it is a real image, so it would break `cover = first` and the numeric sort until the next successful drag.
- `Sortable` on the grid → on drop, `POST …/photos/order` with the new filename order → re-render. First cell = cover.
- **Failure path (every op).** On non-2xx / network error: show an inline error near the affected control (reuse gpx-manager's `.gpx-status.error`), keep the item in place — for reorder, **revert the SortableJS move to the last-known-good order** — re-enable the control for retry, and do **not** silently re-render. Displayed order/cover must never disagree with disk without an error shown.
- All live; independent of the form's Save button (which continues to handle title/date/content/etc.).
## Scope boundaries (non-goals)
- **Create flow (new-entry FilePond) untouched***except* that the shared renumber helper is now zero-padded, so create-mode entries also emit `photo-01..NN`. That is the only create-path side effect; the FilePond UI itself is unchanged. Two photo UIs for now (FilePond on create, this on edit); unifying them is a follow-up.
- **Text-field editing unchanged** (`/post` form + `add-page-by-form` + our patch).
- No captions, no crop/rotate, no bulk ops.
## Verification
- **I verify in-harness:** add (incl. HEIC), delete, reorder-by-**mouse**, and combined — each persists to disk + shows in the feed immediately; cover = first after reorder; the reorder route's owner/scope-guard rejects non-owner + out-of-scope.
- **Regression checks:** (a) a text-field Save *after* a live photo add does **not** delete the added photo (FilePond decommission); (b) an entry with **10+ photos** keeps arranged order and the correct cover (zero-padding); (c) each op's failure path shows an inline error and leaves UI and disk consistent.
- **You verify on-device (the one thing I can't simulate):** touch-drag reorder on a phone.
## Estimate
One focused implementation push — 1 custom reorder handler + stock add/delete reuse + one JS component + CSS + the SortableJS dependency (install + import). Not another multi-day cycle. Residual risk concentrated in the "still assumed" list above.
## Deferred / Open Questions
### From 2026-07-05 review
- **No undo / cancel model for destructive live edits (P1).** Add/delete/reorder persist immediately and delete is a destructive `unlink`; the Save button trains the user that leaving without saving discards changes, but live deletes are already gone with no undo and no "permanent" signal. Decide between: (a) accept live-is-permanent + add a "saves immediately" affordance and a real delete confirm (cheapest for the deadline); (b) soft-delete to a trash subfolder purged on Save/leave; (c) stage deletes client-side and commit on Save. Resolve before implementing the delete path.
### Deferred during implementation (2026-07-05)
- **Server-side SVG block deferred to the R6 add/delete fast-follow.** The plan
called for adding `svg` to `security.uploads_dangerous_extensions`, but
`user/config/security.yaml` is **gitignored** (a Grav 1.7-era rule from when the
HMAC `salt` lived there; obsolete in 2.0.7 where the secret moved to the
still-ignored `security-private.php`). Tracking it would mean un-ignoring a
security-namespace file from another work session's era — out of scope for this
push. Instead: **SVG is excluded client-side** in the photo-editor file input
`accept` (jpg/jpeg/png/webp + HEIC only). The **server-side** block is a
documented fast-follow that lands together with promoting photo add/delete onto
the scope-guarded custom route (the same R6 gap already accepted above) — both
concern the un-scoped stock media endpoint, which only the solo owner can reach.
### Resolved at review close (2026-07-05) — recorded for the implementer
- **Reorder-route filename safety***resolved:* the handler operates only on filenames already present as image media in the folder, so a crafted order body can't rename/clobber the entry `.md`, a `.gpx`, or a `.meta.yaml`. (Now in the Server reorder-route spec.)
- **Multi-photo add — reorder cadence***resolved:* call the reorder route **once after the whole batch** of uploads, not once per file. (Now in the Client "Add is a two-write op" spec.)
- **New route 404 until cache rebuild***resolved:* deploy must clear the API route-map cache so `/entry/<slug>/photos/order` registers; the existing `DELETE /entry/<slug>` proves the nested-route pattern works. (Now a deploy note in the Server section.)
- **`.meta.yaml` sidecars not renamed by `renumberPhotos`** — *deferred (genuine future work):* no effect today because per-image captions are deferred. When captions ship, the shared renumber helper must rename each image's `.meta.yaml` sidecar alongside it (and clean up orphans), or per-image metadata will drift on reorder/delete.
@@ -0,0 +1,309 @@
---
title: Trip Description, One-liner & Hero Image - Plan
type: feat
date: 2026-07-05
topic: trip-description-and-hero
artifact_contract: ce-unified-plan/v1
artifact_readiness: implementation-ready
product_contract_source: ce-brainstorm
execution: code
---
# Trip Description, One-liner & Hero Image - Plan
**Status:** ✅ Complete (2026-07-06)
## Goal Capsule
- **Objective:** Give each trip an optional one-liner and description, surface them on the trip list and trip page, and fix the low-resolution trip cover image — all editable from the admin panel.
- **Product authority:** Mischa (site owner).
- **Open blockers:** None. Ready for planning.
## Product Contract
### Summary
Add an optional one-liner and an optional description to trips, and render them where they help: the one-liner on both the trip-list cards and the trip page, the description on the trip page only. On the trip page, extend the **existing in-column header**`home-trip-header` in the shared `trip-feed-col` partial, which already shows title + dates/counts — with the one-liner (below the title) and the description, plus a thin banner image strip (~180220px) directly below the header text and above the filter bar. No new header is introduced above the map+journal split, and the split itself is unchanged. Because `trip-feed-col` is shared with the homepage active-trip view, these additions are gated to the trip-page caller so that view is unaffected. Fix the trip cover image so it renders sharp (larger derivative + retina `srcset`) and is chosen via an admin media picker, with the current auto-pick fallback retained.
### Problem Frame
Trips currently carry no human-readable summary anywhere the reader sees. The trip-list cards (`user/themes/intotheeast/templates/trips.html.twig`) show only title, dates, and counts; the trip page (`user/themes/intotheeast/templates/trip.html.twig`) shows the title, dates, and counts only *inside* the feed column — via the shared `trip-feed-col` partial's `home-trip-header` block — and no one-liner, description, or banner image. A `header.tagline` field already exists in the trip blueprint but is used only on homepage highlight cards, and a markdown `content` field (labeled "Description" in admin) exists but is never rendered. Separately, the trip-list cover image auto-picks the first journal entry's first photo and crops it to 720×240, which looks soft — especially on high-DPI screens — and the author has no easy way to choose a better shot.
### Key Decisions
- **Reuse `header.tagline` as the single one-liner.** The existing tagline field becomes the one source for the short subtitle across all three surfaces (homepage highlight cards, trip list, trip page). Rejected a separate new field: two fields to keep in sync for one concept.
- **Reuse the markdown `content` field as the description.** It is already editable in admin and labeled "Description"; it is simply not rendered on the trip page yet. Rejected adding a new short-text field.
- **Extend the existing in-column header; no new header above the split.** The one-liner and description are added to the existing `home-trip-header` block (in the shared `trip-feed-col` partial, which already renders title + dates/counts), with a thin banner strip (~180220px) directly below the header text and above the filter bar — not a full-bleed hero, and not a separate header above the map+journal split. A slim banner plus text is forgiving of source-photo quality; the full-bleed story-style hero was rejected for pushing primary content below the fold and making the page hostage to photo quality, and a new above-split header was rejected because it would duplicate the title/dates/counts the feed column already shows. Because `trip-feed-col` is shared with the homepage active-trip view, the additions are gated (via a partial parameter) to the trip-page caller so that view is unchanged.
- **One-liner on the list, one-liner + description on the page.** The list stays scannable (short subtitle only); the fuller description lives on the trip page.
### Requirements
**Trip data & admin**
- R1. A trip's one-liner is stored in the existing `header.tagline` field and remains editable in the admin trip form.
- R2. A trip's description is stored in the existing markdown `content` field and remains editable in the admin trip form.
- R3. The admin `header.cover_image` control is a media picker that lets the author select an uploaded image on the trip page, replacing the current type-in-a-filename text field. The picker selects from images uploaded to the trip page's own media; if a trip has none yet, the author uploads one there first, and the R7 auto-pick remains the fallback until a cover is chosen.
- R4. The one-liner and description are both optional.
**Trip list card**
- R5. When a trip's one-liner is set, the trip-list card displays it (between title and the dates/counts meta line); when unset, no one-liner line renders.
- R6. The trip-list card cover image renders sharply on standard and high-DPI displays via a larger derivative (rendered at 1440×480) plus a retina `srcset` (720w and 1440w candidates). Sharpness depends on adequate source resolution — see Dependencies / Assumptions.
- R7. The card cover image source is the author-selected `cover_image` when set; when unset, it falls back to the first journal entry's first image (current behavior).
**Trip page header**
- R8. On the trip page, the existing in-column header (`home-trip-header` in the shared `trip-feed-col` partial) renders — each only when set — the one-liner (directly below the title) and the description (below the dates/counts), in addition to the title, dates, and counts it already shows.
- R9. Directly below the header text and above the filter bar, the trip page renders a thin banner image strip (~180220px) using the same cover-image source and fallback as the list card (R7), rendered sharply per R6 as a fixed-height center-crop (no focal-point control); when no image is available, the header renders text-only with no banner strip.
- R10. No new header is added above the map+journal split, and the map + journal two-column split is unchanged in structure and position.
- R11. If a set `cover_image` no longer resolves (file deleted or moved), the trip-list card and the trip-page banner fall back to the R7 auto-pick rather than rendering a broken image.
- R12. The one-liner, description, and banner strip are added for the trip-page caller of `trip-feed-col` only (via a partial parameter); the homepage active-trip view's header is unchanged.
- R13. The one-liner is plain text (soft cap ~120 characters). The description is markdown; the header shows the first ~23 lines with the remainder collapsed behind an expand control, so the map+journal split stays above the fold by default while the full description remains readable on demand.
- R14. The cover/banner image's alt text is the trip title.
- R15. On narrow/mobile viewports the banner strip and header text reflow without pushing the map+journal split off-screen (e.g. reduced banner height); exact breakpoints are decided during planning.
### Acceptance Examples
- AE1. **Covers R4, R5, R8.** Given a trip with neither one-liner nor description set, when a reader views the trip list and the trip page, then no one-liner line and no description block render on either surface, and the in-column header still shows the title (and dates/counts if present).
- AE2. **Covers R8.** Given a trip with a one-liner but no description, when a reader views the trip page, then the in-column header shows the title, one-liner, and dates/counts, and renders no description block.
- AE3. **Covers R7, R9.** Given a trip with no `cover_image` set but at least one journal entry with an image, when a reader views the list card and the trip-page banner strip, then both show the first entry's first image (sharp per R6).
- AE4. **Covers R9.** Given a trip with no `cover_image` and no journal-entry images, when a reader views the trip page, then the header renders text-only with no banner strip.
- AE5. **Covers R6.** Given a trip cover image, when a reader views the trip-list card or the trip-page banner strip on a high-DPI (retina) display, then the larger derivative and retina `srcset` apply and the image renders sharply.
- AE6. **Covers R10.** Given any trip, when a reader views the trip page, then the map + journal two-column split renders unchanged in structure and position, with no new header inserted above it.
- AE7. **Covers R12.** Given the active trip, when a reader views the homepage active-trip view, then its in-column header is unchanged — no description block and no banner strip are added there.
### Scope Boundaries
- The full-bleed, story-style hero banner treatment for trips.
- A new header rendered above the map+journal split (the one-liner, description, and banner extend the existing in-column header instead).
- Any change to the map/journal two-column split (layout, columns, feed order, filter bar).
- Any change to the homepage active-trip view's header (the trip-page additions are gated to the trip-page caller of the shared `trip-feed-col` partial).
- A separate one-liner field distinct from `header.tagline`, or a separate description field distinct from the markdown `content`.
- Showing the full description on the trip-list cards.
- Author-adjustable crop / focal-point control for the banner (fixed center-crop only).
### Dependencies / Assumptions
- Confirmed (2026-07-05): `header.tagline` and the markdown `content` field are already present and editable in the admin trip form (`trip.yaml`), so R1/R2 need no new admin fields. `header.cover_image` is currently a plain `text` field.
- Resolved (2026-07-05): the media-picker for `cover_image` (R3) uses Grav core's `pagemediaselect` field type. Confirmed present in the Admin2 v2.0.11 compiled field-type registry (`app/_app/immutable/chunks/DzO1nmNX.js`), where `pagemediaselect`, `mediapicker`, and `filepicker` all route to the same picker component. It binds to the page's own media and stores the selected filename — the same value shape `header.cover_image` holds today — so the `trip.media[cover_image]` template lookups need no change and no text-field fallback is required.
- Grav's image derivative + `srcset` helpers are available in Twig for producing the larger and retina cover renditions.
- Cover source photos are assumed ≥1440px wide. A smaller source cannot be sharpened by a larger derivative (Grav upscales), so the R6 sharpness goal depends on adequate source resolution, not just a bigger render box.
- Before enabling description rendering, grep existing `user/pages/01.trips/*/trip.md` for non-empty `content` bodies and confirm each reads as a public description or is intentionally cleared. Verified empty across the four current `trip.md` files as of 2026-07-05; the check guards future/other trips.
- Reusing one `header.tagline` across the homepage highlight card, the trip-list card, and the trip-page header assumes the existing per-trip tagline copy reads acceptably on all three; per-surface opt-out is out of scope. Audit current taglines before shipping.
- Reusing the markdown `content` body as the description means a future long-form trip article distinct from the short summary would require splitting the field — accepted tradeoff.
### Follow-up (post-implementation)
- Backfill one-liners and descriptions for the active and past trips so the reader-facing summary goal is actually realized — the four current `trip.md` files have empty `content` bodies, so shipping the plumbing alone leaves existing trips showing title/dates only.
### Sources / Research
- `user/themes/intotheeast/templates/trips.html.twig` — current trip-list card markup and `cropResize(720, 240)` cover logic with first-entry fallback.
- `user/themes/intotheeast/templates/trip.html.twig` — current trip page (map+feed via `entry-map` and `trip-feed-col` partials; no dedicated header above the split).
- `user/themes/intotheeast/templates/partials/trip-feed-col.html.twig` — the shared feed-column header (`home-trip-header`: title, dates, counts, filter bar, panel toggles) that this plan extends with the one-liner, description, and banner; **also included by `home.html.twig`'s active-trip branch**, hence the trip-page gating in R12.
- `user/themes/intotheeast/blueprints/trip.yaml` — existing `header.tagline` (homepage-card copy) and markdown `content` ("Description") fields; `header.cover_image` as a text field.
- `user/themes/intotheeast/templates/story.html.twig` — existing hero pattern (the rejected full-bleed reference).
---
## Planning Contract
**Product Contract preservation:** changed — R13 (description is now expandable rather than a fixed clamp) and the `cover_image` picker assumption (resolved: `pagemediaselect` confirmed renderable in Admin2 v2.0.11, text-field fallback dropped), both per owner decision on 2026-07-05. All other Product Contract IDs unchanged.
### Key Technical Decisions
- KTD1. **`pagemediaselect` for the cover field, no fallback.** Change `header.cover_image` in `trip.yaml` from `type: text` to `type: pagemediaselect`. Confirmed renderable in Admin2 v2.0.11 (see the resolved dependency note above). Because it stores the selected filename — the value shape `cover_image` already holds — the existing `trip.media[trip.header.cover_image]` lookups in the templates are unchanged. Rejected the text-field fallback: unnecessary once the field type was verified to render.
- KTD2. **One shared cover macro, not duplicated resolution.** The trip-list card and the trip-page banner need the same three-step cover resolution (author-selected → first journal entry's first image → none, per R7/R11) and the same retina rendering (R6/R14). Put both in a new `macros/cover.html.twig` so the two surfaces cannot drift. Rejected copy-pasting the current inline `trips.html.twig` logic into the partial: two copies of R7/R11 to keep in sync.
- KTD3. **Retina via two explicit `cropResize` derivatives + `srcset`, not Grav's native helper.** Render a 1× and a 2× derivative with `cropResize` and emit an explicit `srcset` (e.g. `720w`, `1440w` for the card). This mirrors the existing working `cropResize(720, 240)` call and gives exact control, with no dependency on Grav's auto-`srcset`/`derivatives` config. The CSS crop (`object-fit: cover`, fixed `aspect-ratio`) is unchanged — only the derivative resolution and the `srcset` attribute change. Rejected `Medium.derivatives()`: adds a config dependency for no gain here.
- KTD4. **Gate the header extras with a partial parameter that defaults off.** Add a `trip_header_extras` parameter to `trip-feed-col.html.twig`, defaulted to `false`. `trip.html.twig` passes it `true`; `home.html.twig` is left untouched, so its `include ... only` omits the parameter and the active-trip header renders exactly as today (satisfies R12/AE7 with zero edits to the home template). Rejected a positive flag on the home caller: more edits, more regression surface, on the branch the plan must not change.
- KTD5. **Expandable description as inline progressive enhancement.** Render the 23-line preview and the full body in markup, and toggle an expanded class with a small inline `<script>` in the partial — the same pattern the partial already uses for `initTripStats`. Avoids touching `js/src/main.js` and the `make build-assets` step. Rejected a fixed CSS-only clamp: it would make the full description unreadable anywhere (owner decision). Rejected a `<details>`/`<summary>` element: harder to style the collapsed state as a clean N-line preview.
### High-Level Technical Design
The trip-page in-column header (`.home-trip-header`), when `trip_header_extras` is true, stacks in this order. Everything from the filter bar down is unchanged; the home active-trip caller renders only the unshaded rows.
```mermaid
flowchart TB
T["h1 title (existing)"]
O["one-liner — header.tagline (R8, new)"]
D["dates (existing)"]
C["counts (existing)"]
DESC["description — content, 2-3 line preview + expand (R8/R13, new)"]
B["banner strip ~180-220px — cover macro (R9, new)"]
F["filter bar (existing, unchanged)"]
P["panel toggles (existing, unchanged)"]
T --> O --> D --> C --> DESC --> B --> F --> P
SPLIT["map + journal two-column split — unchanged, stays above the fold (R10/R15)"]
P -.-> SPLIT
```
### Assumptions & Constraints
- Only `css/style.css` and the `.html.twig` templates are hand-edited; both are loaded directly (`base.html.twig` links `css/style.css`), so no build step is needed for this work. `css-compiled/main.css` is esbuild output and is not touched.
- Banner dimensions (1× render box and mobile height) are tunable during implementation within the R9 ~180220px envelope; the plan fixes the approach, not the exact pixel values.
- Source photos are assumed ≥1440px wide (Product Contract dependency); a smaller source cannot be sharpened by a larger derivative.
### Sequencing
U1 and U2 are independent and can land first in either order. U3 and U4 both consume the U2 macro. U5 (CSS) supports U3 and U4 and should land with them for meaningful visual verification. Order: U1 → U2 → (U3, U4) → U5.
---
## Implementation Units
### U1. Cover field → `pagemediaselect`
- **Goal:** Replace the type-in-a-filename cover control with an Admin2 media picker (R3).
- **Requirements:** R3.
- **Dependencies:** none.
- **Files:** `user/themes/intotheeast/blueprints/trip.yaml`
- **Approach:** Change `header.cover_image` from `type: text` to `type: pagemediaselect`. Keep the label, refresh the help text (pick from images uploaded to this trip page). The stored value stays a filename, so no template change is required here.
- **Patterns to follow:** existing field definitions in `trip.yaml`; field type verified against the Admin2 v2.0.11 registry.
- **Test scenarios:** Test expectation: none — admin-only blueprint config with no automated test surface. Verified manually in U-level verification: the picker renders in the Admin2 trip form, lists the page's uploaded images, and saves the chosen filename into `header.cover_image`.
- **Verification:** In Admin2, the trip form shows a media dropdown (not a text box); selecting an image and saving writes its filename to the page header.
### U2. Shared cover macro
- **Goal:** Centralize cover resolution + retina rendering for reuse by the list card and the trip-page banner (R6, R7, R11, R14).
- **Requirements:** R6, R7, R11, R14.
- **Dependencies:** none (U1 not required — resolution reads the same `header.cover_image` filename regardless of how it was set).
- **Files:** `user/themes/intotheeast/templates/macros/cover.html.twig` (new)
- **Approach:** Two macros.
- `resolve(trip_page)` → returns a Medium or null: if `trip_page.header.cover_image` is set and `trip_page.media[...]` resolves, return it; else look up `grav.pages.find(trip_page.route ~ '/dailies')`, take the first published entry's first image if present; else null. This encodes R7 (fallback) and R11 (a set-but-missing `cover_image` falls through to the auto-pick rather than returning a broken reference).
- `img(medium, alt, w, h)` → emits `<img src=cropResize(w,h).url srcset="…(w)w, …(2w)w" sizes=… alt=alt loading="lazy">` using `cropResize(w, h)` and `cropResize(w*2, h*2)` (R6, R14).
- **Patterns to follow:** the existing inline resolution in `trips.html.twig:16-29`; the existing `cropResize(...).url` calls in the theme; other macros under `user/themes/intotheeast/templates/macros/`.
- **Test scenarios:** the macro has no standalone harness; these are asserted through the rendered DOM in U3/U4 specs — `cover_image` set + resolvable returns that image; `cover_image` set but file missing falls back to the first-entry image (R11); no `cover_image` but an entry image exists returns the first-entry image (R7/AE3); no `cover_image` and no entry images returns null (drives AE4); rendered `<img>` carries both `srcset` candidates (R6/AE5) and `alt` equal to the trip title (R14).
- **Verification:** both U3 and U4 render covers through this macro with identical fallback behavior; no inline cover-resolution logic remains in either caller.
### U3. Trip-list card: one-liner + retina cover
- **Goal:** Show the one-liner on list cards and render the cover sharply, via the shared macro (R5, R6, R7).
- **Requirements:** R5, R6, R7, R11, R14.
- **Dependencies:** U2.
- **Files:** `user/themes/intotheeast/templates/trips.html.twig`, `tests/ui/trip/trips-list.spec.js` (new)
- **Approach:** Import `macros/cover.html.twig`. Replace the inline cover block (`trips.html.twig:16-29`) with `cover.resolve(trip)` + `cover.img(cover, trip.title, 720, 240)` inside the existing `.trip-card-cover` wrapper (keeps the 3:1 aspect + `object-fit: cover`). Add a one-liner line rendering `trip.header.tagline`, between `.trip-card-title` and `.trip-card-meta`, only when the tagline is set (R5).
- **Patterns to follow:** existing card markup and classes in `trips.html.twig`; `.trip-card-cover` CSS at `css/style.css:1089`.
- **Test scenarios:**
- Covers R5. A trip with a tagline renders a one-liner element between the title and the meta line.
- Covers R5/AE1. A trip with no tagline renders no one-liner element.
- Covers R6/AE5. The card cover `<img>` exposes a `srcset` with 720w and 1440w candidates.
- Covers R7/AE3. With no `cover_image` set, the card cover uses the first journal entry's first image.
- Covers R11. With a `cover_image` pointing at a missing file, the card falls back to the auto-pick and renders no broken image.
- Covers R14. The cover `alt` equals the trip title.
- **Verification:** the past-trips list shows one-liners where set and sharp covers on a 2× DPR emulation.
### U4. Trip-page header extras (gated)
- **Goal:** Extend the in-column header with the one-liner, expandable description, and banner strip — for the trip-page caller only (R8, R9, R10, R12, R13).
- **Requirements:** R8, R9, R10, R12, R13.
- **Dependencies:** U2.
- **Files:** `user/themes/intotheeast/templates/partials/trip-feed-col.html.twig`, `user/themes/intotheeast/templates/trip.html.twig`, `tests/ui/trip/trip-header.spec.js` (new), `tests/ui/home/home.spec.js` (extend for AE7)
- **Approach:** Add a `trip_header_extras` parameter to the partial, `|default(false)`. In `trip.html.twig`'s `include`, pass `trip_header_extras: true`; leave `home.html.twig` untouched (its `include ... only` omits the parameter → default false → unchanged, per KTD4/R12). Inside `.home-trip-header`, gated on the flag and on each value's presence, render in the HTD order: one-liner (`trip_page.header.tagline`) directly below the title (R8); description (`trip_page.content|raw`) below the counts as a 23-line preview plus an expand control (R8/R13); banner strip below the description and above the filter bar using `cover.resolve(trip_page)` + `cover.img(...)` at banner dimensions, omitted entirely when resolve returns null (R9/AE4). Add a small inline `<script>` (alongside the existing `initTripStats` script) that toggles the expanded class on the description. The map+journal split and everything from the filter bar down are not touched (R10).
- **Patterns to follow:** the existing `.home-trip-header` block and inline `<script>` in `trip-feed-col.html.twig`; the `include ... with {...} only` calls in `trip.html.twig` and `home.html.twig`.
- **Test scenarios:**
- Covers R8/AE2. Trip page with a tagline and no description shows the one-liner below the title and no description block.
- Covers R8/R13. Trip page with a description shows a clamped preview plus an expand control that reveals the full text.
- Covers R8/R9. Trip page with tagline + description + cover shows one-liner, description, and a banner strip positioned above the filter bar.
- Covers R9/AE3. Trip with no `cover_image` but an entry image shows the banner using the first-entry image.
- Covers R9/AE4. Trip with no cover and no entry images renders a text-only header with no banner element.
- Covers R10/AE6. The map + journal two-column split renders unchanged with no new header inserted above it.
- Covers R12/AE7. The homepage active-trip view renders no description block and no banner strip (assertion added to `home.spec.js`).
- **Verification:** trip page shows the extras in HTD order and expands the description; the homepage active-trip header is visually identical to before.
### U5. Header + banner CSS
- **Goal:** Style the one-liner, expandable description, and banner strip, and keep the split above the fold on narrow viewports (R6 display, R9, R13, R15).
- **Requirements:** R9, R13, R15.
- **Dependencies:** U3, U4 (styles the markup they add).
- **Files:** `user/themes/intotheeast/css/style.css`
- **Approach:** Add rules for the trip-card one-liner, the header one-liner, the description preview/expanded states, the expand control, and `.trip-header-banner` (full width, fixed height in the ~180220px envelope, `object-fit: cover`, matching radius/spacing of the header). Collapse the description preview with a fixed `max-height` + `overflow: hidden` (the expanded state lifts the cap), **not** `-webkit-line-clamp`: `content|raw` renders multi-paragraph markdown (multiple `<p>`), and line-clamp reliably clamps only a single block box, so it would not hold the 23-line preview across paragraphs. Add a mobile `@media` block that reduces banner height and reflows the header text so the map+journal split is not pushed off-screen (R15). The existing `.trip-card-cover` needs no change — `object-fit: cover` + `aspect-ratio: 3/1` already crop the larger derivative.
- **Patterns to follow:** existing `.home-trip-header`, `.trip-dates`, `.home-trip-counts` (`css/style.css:926-951`) and `.trip-card-cover` (`css/style.css:1089`); the theme's CSS custom properties (`--space-*`, `--text-*`, `--color-*`).
- **Test scenarios:** Test expectation: none — presentational CSS; structural correctness (element presence, expand toggle) is asserted by U3/U4 specs, and appearance/reflow is verified visually including a narrow-viewport check.
- **Verification:** on desktop and a mobile viewport, the banner and header text render cleanly and the map+journal split remains visible without scrolling past a wall of header content.
---
## Verification Contract
Dev server: the worktree's Docker dev server at `http://localhost:8081` (`docker compose ... up`). Playwright specs live in the outer repo under `tests/ui/` and run against that server.
| Gate | Command / action | Applies to |
|---|---|---|
| New + extended UI specs pass | `npx playwright test tests/ui/trip/trips-list.spec.js tests/ui/trip/trip-header.spec.js tests/ui/home/home.spec.js` | U3, U4 |
| No regression in related suites | `npx playwright test tests/ui/trip tests/ui/home tests/ui/maps` | U4 (shared partial), U5 |
| Admin picker renders + saves | Manual: Admin2 → trip form → cover field is a media picker → select → save → confirm filename stored | U1 |
| Retina sharpness | Manual: DevTools at 2× DPR on `/trips` and a trip page → cover/banner load the 1440w derivative | U2, U3, U4 |
| Acceptance examples | Manual walkthrough of AE1AE7 against a trip with/without tagline, description, and cover | all |
No lint/build step applies — the edited `css/style.css` and templates are served directly.
---
## Definition of Done
**Global**
- AE1AE7 all verified against real trip content (with and without tagline, description, and cover).
- New specs (`trips-list.spec.js`, `trip-header.spec.js`) and the `home.spec.js` AE7 assertion pass; existing `tests/ui/trip`, `tests/ui/home`, and `tests/ui/maps` suites still pass.
- Admin2 renders the `pagemediaselect` cover field and persists the selected filename.
- The homepage active-trip view is visually unchanged (no description block, no banner).
- No abandoned/experimental markup, CSS, or scripts left in the diff.
- Content backfill of one-liners and descriptions for existing trips remains a post-implementation follow-up (per the Product Contract) and is **not** required for done.
**Per unit**
| Unit | Done when |
|---|---|
| U1 | Cover field is a working Admin2 media picker storing a filename. |
| U2 | Both callers resolve and render covers through the macro; no inline cover logic remains. |
| U3 | List cards show one-liners where set and sharp retina covers with correct fallback; U3 specs pass. |
| U4 | Trip-page header shows one-liner, expandable description, and gated banner in HTD order; home view unchanged; U4 specs pass. |
| U5 | Header/banner styled; description expands; split stays above the fold on mobile. |
---
## Post-review follow-up (2026-07-07)
A structured code review of the finished diff produced fixes and two
intentionally-deferred findings.
**Applied**
- Cover picker restricted to images (`accept:` on the `cover_image`
`pagemediaselect` field) + macro resolves against `media.images`, so a
non-image selection (e.g. a `.gpx` from the trip page media) can no longer
route a non-image Medium into `cropResize`. Also hardens R11.
- Test quality: replaced a vacuous `toContainText` in the description-clamp
spec with real clamp/un-clamp assertions; corrected an R11 over-claim in the
trips-list spec header comment.
**Follow-up (2026-07-07)**
- **Banner/card cover quality fix.** The macro used `cropResize`, which
*fits-inside* preserving aspect ratio — so a portrait fallback source was
handed back as a ~165px sliver that the `object-fit:cover` box then upscaled
into a blur (reported on `us-canada-mex-2024`). Switched to **`cropZoom`**
(crop-to-fill → a real w×h cover strip). Retina is now **all-or-nothing**: the
2x `srcset` descriptor is emitted only when the source is genuinely ≥2×w
(`cover.width >= 2w`), else 1x-only — no upscaling, no intermediate widths.
Note: imported pixelfed photos cap at ~1440px wide, so auto-picked covers are
usually 1x-only; see `docs/working/backlog.md` (full-res re-import, luxury).
- **AE4 fixture removed.** The `no-photos-demo` fixture (and its browser test)
was deleted at the user's request — it surfaced as stray demo content in the
trip list. AE4 (no cover + no images → no banner) is a trivial else-branch of
the shared macro's `{% if cover %}` guard, covered by construction alongside
the R7/AE3 fallback tests. A regression test for the reported portrait-blur
bug now lives in `trip-header.spec.js` against `us-canada-mex-2024`.
**Intentionally deferred — explicit plan override (do not re-flag)**
- **Macro re-queries dailies/first-entry (reviewer: efficiency/maintainability).**
Deferred by design: **KTD2** puts cover resolution *inside* the shared macro
precisely so the list card and trip banner cannot drift. Moving resolution
out to callers reopens that drift; the extra `grav.pages.find()` is cached and
negligible.
- **Inline `<script>` for the description toggle should be bundled into
`js/src/main.js` (reviewer: convention).** Deferred by design: **U4's
Approach** explicitly specifies "a small inline `<script>` (alongside the
existing `initTripStats` script)." The inline placement is the plan's chosen
approach for a self-contained ~15-line toggle, not an oversight.
@@ -0,0 +1,266 @@
---
title: Trip Publish/Unpublish Toggle - Plan
type: feat
date: 2026-07-08
origin: docs/working/specs/2026-07-08-trip-publish-toggle-design.md
artifact_contract: ce-unified-plan/v1
artifact_readiness: implementation-ready
product_contract_source: legacy-requirements
execution: code
---
# Trip Publish/Unpublish Toggle - Plan
**Status:** ✅ Complete (2026-07-08)
## Goal Capsule
- **Objective:** Let the logged-in site owner publish/unpublish any trip from the `/trips` listing, with correct page-tree cache invalidation so the change is reflected everywhere on the next load. Anonymous/non-owner visitors see no change.
- **Authority hierarchy:** The design doc (`docs/working/specs/2026-07-08-trip-publish-toggle-design.md`) is authoritative for behavior; this plan is authoritative for sequencing and file-level implementation. Repo conventions (CLAUDE.md) and the cited existing patterns override any incidental detail here.
- **Stop conditions:** Surface a blocker if implementation reveals that Grav 2.0's `$page->save()` does not persist `published` from a mutated header (the pattern KTD1 depends on), or that `$pages->find()` refuses to resolve unpublished trips from the listing context — either contradicts the design doc's cited behavior.
- **Execution profile:** Standard feature — one owner-gated API write, one shared partial, two template edits, one new bundled JS file, CSS, and seven Playwright specs (TP1, TP1b, TP2TP6). Test-after is fine except U7, which is written against the finished surfaces.
- **Tail ownership:** Rebuild theme assets (`make build-assets`) after U6; run the trip Playwright suite after U7.
---
## Product Contract
### Summary
Add an owner-only publish/unpublish switch to each card on the `/trips` listing. The switch POSTs to a new `entry-actions` route that mutates the trip's `trip.md` frontmatter (`published: true|false`), then clears and invalidates Grav's page-tree cache. The trip detail page carries no publish UI — an unpublished trip's detail page 404s for everyone including the owner, so management is listing-only. When the active trip is unpublished, the home page falls back to its between-trips / pre-departure state.
### Problem Frame
Publishing a trip today means editing `trip.md` frontmatter by hand (or via Admin) and manually clearing cache. The owner wants a reversible in-UI toggle. The listing is the only viable surface: it already shows drafts to the owner and is fully reversible, whereas the detail page is unreachable while a trip is unpublished. The change is security-sensitive (an owner-only write) and cache-sensitive (publish state feeds `.published()` collections, routability, nav, and the home render, all keyed through the page-tree index — the exact class of bug fixed in `deleteEntry`).
### Requirements
**Owner gate & authorization**
- R1. The publish write control renders only for the owner: `grav.user.authenticated and grav.user.username == grav.config.site.owner_username`. This is broader than `owner_can_edit` in `trip.html.twig` (which also requires the active trip) — publishing must work on any trip.
- R2. The backend enforces the owner check independently of the UI (defense in depth): anonymous → 401, authenticated non-owner → 403, with frontmatter unchanged on disk.
- R3. The endpoint enforces the same `api.pages.write` scope cap as the stock media/page-write endpoints (owner already holds it).
**Publish write**
- R4. `POST /api/v1/trip/{slug}/publish` with body `{ "published": true|false }` sets the trip's published state and persists it to `trip.md` frontmatter.
- R5. A missing or non-boolean `published` value is rejected with 400 (no silent coercion).
- R6. The slug is validated as a safe single segment; the target must resolve through the page tree to a direct child of `/trips`, else 404.
- R7. `find()` resolves unpublished trips too, so the owner can republish a draft from the listing.
- R8. On success the endpoint clears the cache (`deleteAll()` + `Cache::invalidateCache()`) and returns 204, and writes an audit-log line.
**Listing surface**
- R9. The owner sees unpublished trips in the `/trips` listing (with a `Draft` badge); anonymous/non-owner listings are unchanged (published only).
- R10. Each owner-visible card carries a toggle switch overlaid on the cover image, top-right, that does not sit inside the card's navigating `<a>`. The switch is legible over arbitrary cover photos and carries a ≥44px touch target clear of the anchor hit area.
- R11. The switch is accessible: `role="switch"`, `aria-checked`, and a per-instance accessible name identifying the trip.
**Interaction & feedback**
- R12. Unpublishing the active trip prompts a `window.confirm` warning that the home page loses it; cancelling reverts the switch.
- R13. A toggle in flight is disabled (`aria-busy`, dimmed, wait cursor), ignoring further toggles until success or failure revert.
- R14. On success the UI updates optimistically in place (switch position/label, `Draft` badge, `data-published`) with no full reload; the card stays visible to the owner.
- R15. On failure the switch reverts and an error surfaces via a shared page-level `aria-live` toast (401/403 → "sign in again"; other → "Couldn't update — try again.").
**Home fallback**
- R16. When the resolved active trip is unpublished, `home.html.twig`'s active-trip branch does not render; home falls through to its between-trips / pre-departure state. `site.active_trip` is not modified.
### Scope Boundaries
**Out of scope (v1)**
- Bulk publish/unpublish.
- Scheduling / publish dates.
- Cascading child (dailies/stories) publish state — unpublishing a trip does not change its children.
- Reordering trips by publish state (order stays date desc).
- Any publish/unpublish write control or `Draft` indicator on the trip detail page (`trip.html.twig`) — management is listing-only by design.
**Non-goal clarification**
- This toggle governs only whether a trip appears in the `/trips` listing; it is not a content-privacy control. A story reachable by a direct link stays reachable while its parent trip is unpublished, which is acceptable.
---
## Planning Contract
### Key Technical Decisions
- KTD1. **Persist published state by mutating the page header before save, not `$page->published()`.** In Grav 2.0 `$page->published($v)` sets only the in-memory property (`Page.php:1714`), while `save()` serializes from the header object (`Page.php:1256`) and the flag is read one-way from the header at init (`Page.php:541`). Mirror `cache-on-save`'s header-mutation pattern: `$header = $page->header(); $header->published = $published; $page->save();`. Without this the on-disk `trip.md` is unchanged and the toggle silently no-ops.
- KTD2. **Reject non-boolean `published` explicitly; never `(bool)`-cast.** `array_key_exists('published', $body) && is_bool($body['published'])` or 400. A cast coerces `"false"`, `0`, `""`, or a missing key into a valid boolean and never rejects, contradicting R5.
- KTD3. **Clear the cache with `deleteAll()` + `Cache::invalidateCache()`.** `deleteAll()` alone drops cache stores but does not rebuild the page-tree index (keyed on folderHash under `cache.check.method: folder`), so the listing/nav/home render stale. This is the same fix as `deleteEntry` — see `docs/solutions/integration-issues/grav-deleteall-doesnt-invalidate-page-tree-index.md`.
- KTD4. **Resolve the trip through `$pages->find()` + a parent-route assertion, never raw path concatenation.** New guard `EntryScopeGuard::resolveTripChild($grav, $slug)` mirrors `resolveActiveDailyChild`: call `enablePages()` (guarded by `method_exists` — the API context lazily disables the tree), `find('/trips/' . $slug)`, then assert the resolved page's parent route is exactly `/trips`. Reuses `isSafeSegment` for traversal safety.
- KTD5. **The JS request must send `Content-Type: application/json`.** The API's `JsonBodyParserMiddleware` (`JsonBodyParserMiddleware.php:16`) only parses the body when that header is present; without it the body decodes to `[]`, the strict `is_bool` guard sees no key, and every toggle 400s. Model the send on `post-form.js`'s `apiSend` (JSON body + both headers + `credentials: 'include'`), **not** `feed-actions.js` (a body-less DELETE with no `Content-Type`).
- KTD6. **The card toggle is an overlay sibling of the cover, not a child of the card `<a>`.** A toggle inside the anchor would navigate on click. Restructure the card so the cover sits in a positioned wrapper and the toggle overlays it as a sibling. Reuse the existing `.journal-draft-badge` styling (Field Notes paper/teal) so the pill stays legible over any cover.
- KTD7. **Gate `home.html.twig`'s active-trip branch on `trip.published` as well as `config.site.travelling`.** `trip` is already resolved at `home.html.twig:10`; adding `and trip.published` to the branch condition is the whole home fallback — no need to touch `site.active_trip`.
- KTD8. **New JS file needs an esbuild build entry.** `js/src/trip-publish.js` does not build automatically — add an esbuild invocation to the theme's `package.json` `build` script (same `--bundle --minify --format=iife` shape as the `feed-actions.js` entry) so `make build-assets` emits `js/trip-publish.js`.
### High-Level Technical Design
Request flow for one toggle:
```mermaid
sequenceDiagram
participant U as Owner (listing card switch)
participant JS as trip-publish.js
participant API as entry-actions route
participant Ctl as setTripPublished
participant G as EntryScopeGuard
participant FS as trip.md + cache
U->>JS: change (with active-trip confirm if applicable)
JS->>JS: disable switch, aria-busy
JS->>API: POST /api/v1/trip/{slug}/publish {published}
API->>Ctl: dispatch
Ctl->>Ctl: getUser (401 anon) + requirePermission(api.pages.write)
Ctl->>G: isOwnerUser (else 403)
Ctl->>Ctl: isSafeSegment(slug) (else 400)
Ctl->>G: resolveTripChild(slug) (else 404)
Ctl->>Ctl: validate published is_bool (else 400)
Ctl->>FS: header.published = v; save(); deleteAll(); invalidateCache()
Ctl-->>JS: 204
JS->>U: optimistic UI (switch, Draft badge, data-published)
```
### Assumptions
- The Playwright harness runs as the owner because the local test setup treats `testrunner` as `owner_username` — the same setup the existing owner-only delete-flow specs rely on. The new specs inherit it rather than introducing a new override mechanism. Verify by mirroring `tests/ui/post/delete-flow.spec.js` (which already exercises the owner API gate).
- `css/style.css` is hand-authored (the theme has no active SCSS pipeline for it), so toggle/badge styling is added there directly, next to the existing `.journal-draft-badge` (line 283) and `.trip-card*` (line 1220+) rules.
---
## Implementation Units
### U1. Guard: resolve a trip as a direct child of `/trips`
- **Goal:** Add `EntryScopeGuard::resolveTripChild($grav, $slug): ?PageInterface`, the trip-scoped analogue of `resolveActiveDailyChild`, so the controller resolves the target safely (R6, R7, KTD4).
- **Requirements:** R6, R7.
- **Dependencies:** none.
- **Files:** `user/plugins/cache-on-save/classes/EntryScopeGuard.php`.
- **Approach:** New static method: reject via `isSafeSegment($slug)` → null; get `$pages = $grav['pages']`; if `method_exists($pages, 'enablePages')` call it; `$page = $pages->find('/trips/' . $slug)`; return null unless `$page !== null` and `$page->parent()?->route() === '/trips'`. No raw path concatenation beyond the `find()` argument, matching the sibling method's style. Do not filter on published state — `find()` returning drafts is required for republish (R7).
- **Patterns to follow:** `EntryScopeGuard::resolveActiveDailyChild` in the same file (lines 104129).
- **Test scenarios:** Covered end-to-end by U7 (TP2/TP3 exercise resolve-and-republish; TP5 exercises the reject paths). No standalone PHP unit-test harness exists in this repo.
- **Verification:** Method exists and returns a `PageInterface` for a real trip slug, `null` for an unsafe segment, a non-existent slug, and a page whose parent is not `/trips`.
### U2. API route + `setTripPublished` controller
- **Goal:** Register `POST /api/v1/trip/{slug}/publish` and implement the owner-gated write that persists published state and invalidates cache (R2R8).
- **Requirements:** R2, R3, R4, R5, R6, R7, R8.
- **Dependencies:** U1.
- **Files:** `user/plugins/entry-actions/entry-actions.php`, `user/plugins/entry-actions/classes/EntryActionsApiController.php`.
- **Approach:** In `onApiRegisterRoutes`, add `$routes->post('/trip/{slug}/publish', [EntryActions\EntryActionsApiController::class, 'setTripPublished'])`. In the controller, mirror `deleteEntry` step-for-step: `getUser` (401), `requirePermission($request, 'api.pages.write')`, `isOwnerUser` (else `ForbiddenException`), `isSafeSegment` (else 400), `resolveTripChild` (else `NotFoundException`). Read body via `getRequestBody`; enforce KTD2 (`array_key_exists` + `is_bool`, else 400); assign the raw boolean. Persist per KTD1 (mutate `$page->header()->published`, then `$page->save()`). Clear cache per KTD3. Log `owner "%s" set trip "%s" published=%s`. Return `ApiResponse::noContent()`.
- **Patterns to follow:** `EntryActionsApiController::deleteEntry` (guard chain, cache calls, audit log) and `reorderPhotos` (JSON body read) in the same file; `cache-on-save` header-mutation for the save.
- **Test scenarios:** Covered by U7 — TP2 (publish→off persists + hides for anon), TP3 (republish), TP5 (401 anon, 403 non-owner, frontmatter unchanged), plus the 400 non-boolean path asserted via a direct API call in TP5.
- **Verification:** `curl` (or the Playwright request context) as owner with `{"published":false}` returns 204 and `trip.md` on disk gains `published: false`; anon → 401; non-owner → 403; missing/`"false"`/`0` body → 400.
### U3. Shared toggle partial + styling
- **Goal:** Create `partials/trip-publish-toggle.html.twig` (the sliding switch + `Draft` badge) and its CSS, so both the markup and its legible-over-cover styling exist as one reusable unit (R10, R11, KTD6).
- **Requirements:** R10, R11.
- **Dependencies:** none (consumed by U4).
- **Files:** `user/themes/intotheeast/templates/partials/trip-publish-toggle.html.twig`, `user/themes/intotheeast/css/style.css`.
- **Approach:** Partial params: `trip` (Page), `is_active` (bool). Render a styled checkbox switch (`role="switch"`, `aria-checked` bound to `trip.published`, `aria-label="Published — {{ trip.title }}"`) plus a `Draft` badge when `not trip.published`. Emit `data-trip-slug`, `data-trip-route`, `data-published`, `data-active` for the JS. Wrap the control class `.trip-publish-toggle`. CSS: a solid pill/chip background reusing `.journal-draft-badge` colors so it stays legible on any cover; absolute positioning is applied by the card container in U4 (which must exist even for a coverless draft — see U4), but the switch's own visual (track/knob, ≥44px hit area, dimmed `[aria-busy]` + wait-cursor pending state per R13, and a legible keyboard focus ring that reads over a busy cover photo) lives here.
- **Visible failure toast (not sr-only):** the design's page-level toast (R15) is meant for the sighted owner, but `feed-actions.js`'s live region is `sr-only` (visually hidden) and the trip card — unlike the delete flow — has no inline message slot, so a straight reuse would leave a sighted owner seeing only a silent switch revert. Add CSS here for a **visible** page-level toast as a **new, separate DOM element and CSS class** (e.g. `#trip-publish-live` / a `.trip-publish-toast` class, with `role="status"`, `aria-live="polite"`, positioned so it does not depend on the cramped card overlay) that U6 populates. This element is distinct from `feed-actions.js`'s `#feed-actions-live` / `.sr-only` region: reuse the *copy* but do **not** modify the shared `.sr-only` utility (still used by `feed-actions.js` on `trip.html.twig`/`home.html.twig`) or make its live region visible. Toast behavior: auto-dismiss after ~5s, include a manual close control, and replace (not queue) the message if a new failure arrives before the previous one dismisses.
- **Patterns to follow:** existing `.journal-draft-badge` (style.css:283) and the `journal-draft-badge` span in `partials/entry-journal.html.twig:7`.
- **Test scenarios:** Rendered presence/absence is asserted by U7 TP1 (owner sees `.trip-publish-toggle`, anon does not); `Draft` badge presence by TP2. Accessible name/`role` are asserted structurally in TP1.
- **Verification:** Partial renders a switch with the correct `data-*` and `aria-*` for a published and an unpublished trip; the pill is legible over a cover image in the browser.
### U4. `/trips` listing — owner-aware collection, card restructure, JS load
- **Goal:** Make the listing owner-aware (drafts for owner), restructure each card so the toggle overlays the cover as a non-anchor sibling, render the toggle for the owner, and load the JS gated on owner (R9, R10, R12R15 wiring).
- **Requirements:** R1, R9, R10.
- **Dependencies:** U3, U6 (built `js/trip-publish.js`).
- **Files:** `user/themes/intotheeast/templates/trips.html.twig`.
- **Approach:** Compute `is_owner` (R1) at the top. Change the collection to `{% set trips = (is_owner ? page.children : page.children.published())|sort(...) %}`. Restructure the card: keep the navigating `<a class="trip-card">` for cover + title + meta, but wrap the cover in a positioned container so `{% if is_owner %}{% include 'partials/trip-publish-toggle.html.twig' with { trip: trip, is_active: is_active } only %}{% endif %}` sits as an overlay sibling outside the click-navigation path. **The positioned container must exist even when the cover macro emits nothing** — see the coverless-draft note below. Gate the asset: `{% if is_owner %}{% do assets.addJs('theme://js/trip-publish.js', {group: 'bottom'}) %}{% endif %}` (mirrors the `feed-actions.js` gate in `home.html.twig:27`). Compute `is_active` robustly — `site.active_trip` may be a full route (`/trips/x`) or a bare slug — by normalizing both sides before comparing, e.g. `{% set active = config.site.active_trip|trim('/') %}` then `{% set is_active = (active == trip.route|trim('/')) or (active == ('trips/' ~ trip.slug)) %}`. Comparing only against `trip.route`/`trip.url` (full-route form) would silently drop the R12 active-trip confirm if the config ever stores a bare slug (both forms are already supported in `helpers.js` and `cache-on-save`).
- **Coverless-draft state (blocks the primary use case):** the shared cover macro emits its wrapper + `<img>` only when a cover exists (an author-set `cover_image` or a published journal image), and **nothing at all** for a freshly-created draft trip with neither — which is exactly the most common publish-toggle target. The positioned container the toggle overlays must therefore be provided by the card itself (a min-height header strip or the card element), not by the cover wrapper, so the toggle has an anchor whether or not `cover.render` emits an image. Enumerate this state in U3's markup and assert it in U7 (a no-cover fixture trip still shows a working toggle).
- **Patterns to follow:** the owner-aware feed collection + gated `addJs` in `home.html.twig:23-27`; the existing card markup in `trips.html.twig:16-34`.
- **Test scenarios:** Covered by U7 — TP1 (owner sees toggle + draft trip; anon does not, and the draft trip is absent for anon), TP2/TP3 (draft badge on listing after toggle).
- **Verification:** Owner load of `/trips` shows `.trip-publish-toggle` on each card and includes unpublished fixture trips; anon load shows neither; clicking a card cover still navigates (toggle click does not).
### U5. Home fallback when the active trip is unpublished
- **Goal:** Gate the home active-trip branch on the active trip being published so an unpublished active trip falls through to the between-trips / pre-departure state (R16, KTD7).
- **Requirements:** R16.
- **Dependencies:** none.
- **Files:** `user/themes/intotheeast/templates/home.html.twig`.
- **Approach:** Change the branch condition at `home.html.twig:12` from `{% if config.site.travelling %}` to `{% if config.site.travelling and trip.published %}`. `trip` is already resolved at line 10. No change to `site.active_trip`.
- **Patterns to follow:** existing branch structure in `home.html.twig`.
- **Test scenarios:** Covered by U7 TP6 (active fixture trip unpublished → home renders between-trips/pre-departure, not the draft active-trip view).
- **Verification:** With `travelling: true` and the active trip unpublished, `/` renders the fallback branch; republishing restores the active-trip view.
### U6. `trip-publish.js` + esbuild build wiring
- **Goal:** Implement the toggle behavior (confirm, pending, POST, optimistic success, failure revert + toast) and wire it into the theme build so `make build-assets` emits `js/trip-publish.js` (R12R15, KTD5, KTD8).
- **Requirements:** R12, R13, R14, R15.
- **Dependencies:** U2 (endpoint), U3 (markup contract).
- **Files:** `user/themes/intotheeast/js/src/trip-publish.js`, `user/themes/intotheeast/package.json`.
- **Approach:** Bind each `.trip-publish-toggle`. On change: if turning **off** and `data-active` is true → `window.confirm('This is your active trip — unpublishing it also removes it from the home page. Unpublish anyway?')`; on cancel revert and stop (R12). Set pending: disable the switch, `aria-busy`, dim + wait cursor, ignore further toggles (R13). Send `POST /api/v1/trip/<slug>/publish` with `headers: { 'Content-Type': 'application/json', Accept: 'application/json' }`, `body: JSON.stringify({ published })`, `credentials: 'include'` — modeled on `post-form.js` `apiSend` (KTD5). Success: flip `data-published`, toggle the `Draft` badge, update switch position/label/`aria-checked` in place; re-enable (R14). Failure: revert switch to prior state, re-enable, surface an error via the **visible** shared page-level toast defined in U3 (`role="status"`, `aria-live="polite"` — reuse the `feed-actions.js` copy but not its `sr-only` region, so a sighted owner actually sees it: 401/403 → "sign in again"; other → "Couldn't update — try again.") (R15). Wire the build: add an esbuild entry for `js/src/trip-publish.js` to `package.json` `build`, same flags as the `feed-actions.js` entry.
- **Patterns to follow:** `post-form.js` `apiSend` (js/src/post-form.js:890) for the request; `feed-actions.js` for the live-region + error copy + double-tap lock; the `feed-actions.js` esbuild entry in `package.json` `build`.
- **Test scenarios:** Covered by U7 — TP2/TP3 (optimistic flip + persistence), TP4 (active-trip confirm dismiss leaves published). Failure/toast copy is exercised where practical in TP5.
- **Verification:** `make build-assets` produces `js/trip-publish.js`; in the browser, toggling a card updates it in place without reload; unpublishing the active trip prompts a confirm.
### U7. Playwright specs (TP1, TP1b, TP2TP6)
- **Goal:** Cover the owner gate, cache-correct hide/restore, active-trip confirm, authz, and home fallback (R1R16 as observable behavior).
- **Requirements:** R1R16.
- **Dependencies:** U1U6.
- **Files:** `user/themes/intotheeast/...` (none); `tests/ui/trip/trip-publish.spec.js`.
- **Approach:** Run as the owner (same setup as the delete-flow specs). **New scaffolding this unit must build (not mirrored from the entry helpers):** the existing `createPhotoEntry`/`cleanupEntry`/`findEntry` helpers create/clean *entry* folders inside the active trip's `dailies` (`TRACKER_DIR`) — none create a *trip*. This unit needs a small on-disk trip-fixture helper that writes `pages/01.trips/<fixture>/` with a `trip.md` (a `date` for the listing sort, `published` set per test), plus `01.dailies/` and `04.stories/` `routable:false` container `.md` files, and cleans it up. TP6 additionally repoints `site.active_trip` to the fixture with `travelling: true` — the cited home-suite specs only patch `travelling`, never override `active_trip`, so this override is also new (restore `site.yaml` on teardown). Use the DEL4 fixture-then-reload assertion shape from `delete-flow.spec.js`.
- **TP1 — gate:** owner load of `/trips` shows `.trip-publish-toggle`; anon (cleared storageState) does not, and an unpublished fixture trip is absent for anon.
- **TP1b — coverless draft:** a fixture trip with no `cover_image` and no published entry image still renders a working `.trip-publish-toggle` for the owner (guards the coverless-container state from U4).
- **TP2 — unpublish hides it (caching):** owner toggles a published fixture off → reload `/trips` as anon → trip absent; owner reload → `Draft` badge present on the listing (the detail page 404s for the owner too). Mirrors DEL4's page-tree-index assertion.
- **TP3 — republish restores it:** owner on `/trips` toggles a Draft fixture back on → anon reload sees it; assert on the listing, not the detail page.
- **TP4 — active-trip confirm:** unpublishing the active trip prompts a confirm; dismissing leaves it published.
- **TP5 — authz:** `POST /api/v1/trip/<slug>/publish` as anon → 401; as an authenticated non-owner → 403; frontmatter unchanged on disk. Include a non-boolean-body → 400 assertion. **The 403 leg needs a second, authenticated non-owner identity** — the harness authenticates only one account (`auth.setup.js` → one `storageState`), so this leg requires either a second account + storageState (e.g. a non-owner login) or an in-test override of `owner_username` to a value the logged-in test user does not match, then a restore on teardown. This is not provided by the delete-flow setup; pick one approach and wire it explicitly.
- **TP6 — active trip unpublished → home falls back:** with the fixture set as `site.active_trip` and `travelling: true`, unpublish it → reload `/` → home renders between-trips/pre-departure, not the draft active-trip view (needs the `active_trip` override on the fixture; mirror the home-suite setup).
- **Patterns to follow:** `tests/ui/post/delete-flow.spec.js` (owner fixture + reload + on-disk assertion), `tests/ui/trip/trips-list.spec.js` (listing selectors), `tests/ui/post/anon-view.spec.js` (anon storageState + draft-visibility).
- **Test scenarios:** the six specs above are the scenarios.
- **Verification:** `npm run test:ui -- tests/ui/trip/trip-publish.spec.js` (from `tests/`) passes all seven (TP1, TP1b, TP2TP6), with fixture folders cleaned up afterward.
---
## Verification Contract
| Gate | Command | Applies to |
|---|---|---|
| Rebuild theme assets | `make build-assets` | U6 (emits `js/trip-publish.js`) |
| Trip publish specs | `npm run test:ui -- tests/ui/trip/trip-publish.spec.js` (run from `tests/`) | U7 |
| Full trip suite (no regressions) | `npm run test:ui -- tests/ui/trip` | U4, U5, U7 |
| Backend contract (manual/spec) | owner POST → 204 + on-disk `published:` change; anon → 401; non-owner → 403; non-boolean → 400 | U2 |
Run the dev stack for tests via the worktree's own container (`docker compose -p itte-<feature> up`) per the worktree dev-server convention. Do **not** flip any dev/prod mode flags to work around caching — the cache-clear is handled in-code (KTD3).
---
## Definition of Done
**Global**
- All seven Playwright specs (TP1, TP1b, TP2TP6) pass; the broader `tests/ui/trip` suite shows no regressions.
- `make build-assets` emits `js/trip-publish.js`; `js/trip-publish.js` and `js/feed-actions.js` are both current (no hand-edits to built files).
- Anonymous and non-owner behavior is unchanged: no toggle rendered, listing shows published trips only, backend rejects with 401/403.
- No abandoned/experimental code left in the diff; the plan status line is updated to `✅ Complete (YYYY-MM-DD)`.
**Per unit**
- U1: `resolveTripChild` returns the trip page for a real slug and `null` for unsafe/nonexistent/wrong-parent inputs.
- U2: endpoint persists `published` to `trip.md`, invalidates cache, returns 204/400/401/403/404 correctly.
- U3: partial renders the accessible switch + `Draft` badge with correct `data-*`, legible over a cover.
- U4: owner listing includes drafts + toggles; anon listing unchanged; card cover still navigates.
- U5: unpublished active trip → home fallback; published → active-trip view.
- U6: JS confirm/pending/optimistic/revert behaviors work in the browser; build entry wired.
- U7: specs implemented, fixtures cleaned up.
---
## Risks & Dependencies
- **Grav 2.0 save semantics (KTD1).** If a mutated-header `save()` does not persist `published`, the toggle no-ops silently. Mitigation: TP2 asserts the on-disk frontmatter change, not just UI; the `cache-on-save` plugin already relies on this pattern.
- **Cache staleness (KTD3).** Omitting `invalidateCache()` reproduces the `deleteEntry` bug (stale listing/nav/home). Mitigation: TP2/TP3 assert visibility after a full reload as a fresh (anon) client.
- **Build step required (KTD8).** Editing `js/src/trip-publish.js` without adding the esbuild entry (or without running `make build-assets`) ships nothing. Mitigation: DoD requires the built file to be current; U6 owns the `package.json` edit.
- **Test-harness owner identity.** The specs assume `testrunner` acts as owner (as the delete-flow specs do). If that assumption is wrong, the owner-gated specs fail fast at the gate; resolve by matching the existing owner-only spec setup rather than inventing a new override.
- **Upstream dependency:** none external; this is self-contained within `user/` (theme + two custom plugins) and the `tests/` harness.
---
## Open Questions
Both are non-blocking (defense-in-depth / UX-copy) and do not hold up implementation, but resolve them before or during U2/U6.
- **CSRF boundary is implicit.** The endpoint is a session-cookie-authenticated write with `credentials: 'include'`. Its only cross-origin protection is incidental: KTD5's required `Content-Type: application/json` plus the strict `is_bool` guard force a CORS-preflighted request an attacker cannot forge — *unless* the `api` plugin emits permissive CORS headers. Verify the `api` plugin sends no `Access-Control-Allow-Origin`/`-Credentials` that would defeat the preflight, and state the preflight as the intended CSRF boundary in U2 (or add an explicit token check if it does).
- **Draft is not a privacy control (owner mental model).** Unpublishing hides the trip from the `/trips` listing but leaves every child URL (stories, dailies, media) publicly served (documented non-goal). An owner clicking a `Draft` switch may reasonably expect the content to go private. Decide whether the unpublish `confirm()` copy (R12) or toggle help text should say child content stays reachable by direct link, so `Draft` is not mistaken for a retract-content action.
### From 2026-07-08 doc review
- **Owner test-identity for the Playwright suite is unspecified and contradicts committed config (adversarial, P1 — blocking for U7).** The Assumptions block asserts the harness treats `testrunner` as `owner_username`, but committed `user/config/site.yaml` sets `owner_username: mischa`, and `EntryScopeGuard::isOwnerUser` is a strict username match with no super-admin bypass. So every owner-gated spec (TP1, TP1b, TP2, TP3, TP4, and TP5's owner leg) depends on untracked local state (a dirty `site.yaml` or a `.env` `GRAV_TEST_USER` override) that the new specs cannot reproducibly "inherit" — and TP5's non-owner override is described in the *inverted* direction (it only makes sense if `testrunner` were owner by default). **Resolve before writing U7:** confirm the worktree container's actual `GRAV_TEST_USER` / `owner_username` binding, then replace the "inherit testrunner-as-owner" assumption with an explicit tracked suite-setup step that pins `site.owner_username` to the authenticated test user (restore on teardown) and derives TP5's 403 leg from a value that user does not match. Do not rely on the committed `owner_username: mischa` or an untracked local `site.yaml`.
@@ -0,0 +1,254 @@
---
title: Post Form Location Override - Plan
type: feat
date: 2026-07-23
origin: docs/working/specs/2026-07-23-post-form-location-override-design.md
artifact_contract: ce-unified-plan/v1
artifact_readiness: implementation-ready
product_contract_source: legacy-requirements
execution: code
---
# Post Form Location Override - Plan
**Status:** ✅ Complete (2026-07-24) — 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
- **Objective:** Give the traveller a visual, mistake-catching way to set a journal entry's coordinates for a place other than their current GPS position — via a search-by-city lookup and a draggable map pin inside a new "More location details" disclosure on `/post` — without touching Admin2 or the API plugin.
- **Authority hierarchy:** The design doc (`docs/working/specs/2026-07-23-post-form-location-override-design.md`) is authoritative for behavior; this plan is authoritative for sequencing and file-level implementation. Repo conventions (`CLAUDE.md`) and the cited existing patterns override any incidental detail here.
- **Stop conditions:** Surface a blocker if the Open-Meteo geocoding endpoint's CORS or city-only-query behavior no longer matches what the design doc verified live, or if lazy-importing `maplibre-gl` breaks `post-form.js`'s existing ESM code-splitting build (the same risk the `heic-to` lazy import already carries safely).
- **Execution profile:** Standard frontend feature confined to one theme (templates untouched — the panel is built entirely in JS, mirroring the existing "More options" pattern): CSS, JS additions to `post-form.js` plus one new small module, and a best-effort Playwright spec. Test-after is fine for the JS/CSS units; the Playwright unit (U6) is written test-after against the finished behavior.
- **Tail ownership:** Rebuild theme assets (`make build-assets`) after U1U5; run manual QA per the Definition of Done regardless of whether U6 can execute locally.
---
## Product Contract
### Summary
Add a closed-by-default "More location details" disclosure to the `/post` form, placed directly below the City/Country fields. It holds a "🔍 Look up coordinates" button (geocodes the City field via Open-Meteo, ranked by Country when filled), a single-marker MapLibre preview map, and the existing `lat`/`lng` text fields relocated out of their current CSS-hidden position. Four ways to set a coordinate — GPS button, search-result pick, dragging the pin, typing raw numbers — stay in sync with each other. The GPS button's placement and behavior, and the City/Country fields' auto-fill-when-blank behavior, are unchanged.
### Problem Frame
The only way to set a coordinate today is the GPS button (reads live position) or hand-typing/pasting raw decimal text into a CSS-hidden field — the latter is how an invisible Unicode bidi mark silently zeroed out a Denmark 2026 entry's coordinates before backend sanitization (`cleanCoordinate()` in `user/plugins/cache-on-save/cache-on-save.php`) was added. That backend fix stops silent corruption but does nothing for the underlying gap: there's still no visual, reliable way to set a location other than "here, right now," and no way to confirm a coordinate looks right before submitting. This plan closes that gap on the frontend only.
### Requirements
**Disclosure & field relocation**
- R1. A new "More location details" `<details>` panel exists, closed by default, positioned directly after the City/Country fields — a separate disclosure from the existing "More options" advanced-fields panel (`initDisclosure()` in `user/themes/intotheeast/js/src/post-form.js:341`).
- R2. The `lat`/`lng` fields relocate into this panel with their `name="data[lat]"`/`name="data[lng]"` attributes unchanged, so `cache-on-save.php`'s `sanitizeCoordinates()` and `post-form.js`'s `field('lat')`/`field('lng')` helper keep working unmodified. The CSS rule hiding them (`user/themes/intotheeast/css/style.css:893-895`) is removed.
- R3. The GPS button (`#get-location`) and City/Country fields keep their current position and behavior in the main flow.
**Search**
- R4. "🔍 Look up coordinates" queries Open-Meteo's geocoding endpoint (`https://geocoding-api.open-meteo.com/v1/search?name=<city>&count=10&language=en&format=json`) by the City field alone — never concatenating Country into the query string, since that returns zero results or a silently degraded match. When Country is non-blank, results are ranked client-side by a case-insensitive substring match against each result's `country` field, matches first; all results still render.
- R5. Lookup is explicit-click only. While in flight, the button shows a disabled "Searching…" state that always re-enables on response, no-match, or network failure.
- R6. Clicking with both City and Country empty is treated as a no-match: an inline hint asks for a city or country first, and no request is sent.
- R7. Multiple matches render as a clickable list (place name, admin region, country), built via `document.createElement` + `.textContent` (no `innerHTML`), matching every other dynamic-content construction already in `post-form.js`. Clicking an entry sets `lat`/`lng` and the pin only — it never writes back to City/Country. The list hides again until the next lookup.
- R8. No matches renders an inline hint suggesting a country or manual pin drag; a network failure (or a non-2xx response) leaves the fields untouched and renders a *distinct* inline hint naming the connection as the problem. **Revised in code review 2026-07-24** from "degrades silently" — silence was indistinguishable from a broken button, and the two failure modes need different messages.
**Map preview & sync**
- R9. A single MapLibre GL map with one draggable marker (≥44×44px touch target) renders in the panel, reusing the site's existing style URL (`MAP_STYLE`, extracted to a shared `user/themes/intotheeast/js/src/map-style.js` module per KTD1). The map instance is created once, on the panel's first open, held in module scope, and reused (with an explicit `.resize()` call) on every subsequent open — the container sits under `display:none` while closed, so the first paint would otherwise get a zero-size canvas.
- R10. `maplibre-gl`'s JS is dynamically imported only when the panel is opened for the first time, mirroring the existing `heic-to` lazy-chunk pattern (`user/themes/intotheeast/js/src/post-form.js:281`) so ordinary GPS-only submits never fetch it. Its CSS is imported statically at the top of `post-form.js` and bundled unconditionally into `post-form.css`, since a dynamically-imported chunk's CSS is never linked automatically.
- R11. Four coordinate-setting paths stay mutually in sync: the GPS button (updates the pin live if the panel is already open, otherwise the pin reflects the new value whenever the panel is next opened); a search-result click; dragging the pin (`dragend` writes back to the fields, rounded to 6 decimal places, matching the GPS button's existing precision); and typing directly into the fields (on blur/debounced input, a valid in-range pair moves the pin; an unparseable or out-of-range value leaves the pin alone and visually flags the field until it parses again).
- R12. No pin is shown until one of the four paths above sets a value for the first time.
**Error handling & validation boundary**
- R13. Invalid manual `lat`/`lng` text raises the visual mismatch flag (R11), **and** an unresolved flag blocks submit. **Revised in code review 2026-07-24** from "never client-blocked". The original wording deferred all enforcement to a server-side `cleanCoordinate()` described as already shipped — it was not committed anywhere, so no layer validated coordinates. It now ships in `cache-on-save.php` (both the `/post` form and the Admin2/API save paths) and the client gate stays, giving real defence in depth. The client parse is intentionally stricter than the server's `is_numeric` (whole-value decimals only, so `48,85` / `35.0116S` / `48abc` are rejected rather than prefix-parsed).
- R14. Geolocation permission denial keeps its existing, unmodified `#location-status` error behavior.
### Scope Boundaries
**Out of scope**
- Any change to `user/plugins/admin2/` or `user/plugins/api/`.
- Any change to how coordinates are stored (still plain `lat`/`lng` floats in frontmatter) or to the already-shipped `cleanCoordinate()` sanitization.
- Offline/self-hosted geocoding, or integrity verification (pinning, response signing) for the third-party geocoding/tile responses beyond HTTPS.
**Deferred to Follow-Up Work**
- If the pre-existing `make test-account` Makefile quoting bug still blocks running the Playwright suite locally when U6 lands, fixing that bug is separate follow-up work, not part of this plan — U6's spec file is written and committed regardless, and manual QA is the accepted completion gate in the meantime.
---
## Planning Contract
### Key Technical Decisions
- KTD1. **A new dedicated map module, not an extension of `initEntryMap`.** `js/maplibre-utils.js`'s `initEntryMap` (used by `entry-map.html.twig` on the trip/home pages) is built for multi-marker, GPX-drawing, popup-bearing read-only maps — none of which this single-draggable-pin preview needs. Add a small new sibling source module, `user/themes/intotheeast/js/src/location-map.js`, imported statically by `post-form.js` (it is not a new esbuild entry point — see KTD5). `MAP_STYLE` itself is extracted into a tiny shared constants module, `user/themes/intotheeast/js/src/map-style.js` (a single `export const MAP_STYLE = ...`, no side effects), imported by both `location-map.js` and the existing `js/maplibre-utils.js` — this removes the literal-duplication drift risk without pulling in `maplibre-utils.js`'s whole multi-marker/GPX machinery or its window-global side effect, since the new module has neither.
- KTD2. **Search: city-only query + client-side country ranking**, exactly as verified live in the design doc — concatenating Country into the query string breaks the "Paris, Texas" disambiguation case this feature exists for.
- KTD3. **Lazy-load boundary.** `location-map.js` exports a function (e.g. `getOrCreateLocationMap(container)`) that internally calls `import('maplibre-gl')` the first time it runs, keyed off the panel's first `toggle` event where `details.open === true` — never eagerly at page load. `maplibre-gl/dist/maplibre-gl.css` is a static top-of-file import in `post-form.js` (the JS/CSS split from R10) since esbuild never emits a `<link>` for a code-split CSS chunk.
- KTD4. **Two small sync helpers, not four independent write paths.** `syncPinFromFields()` (fields → pin: reads `field('lat')`/`field('lng')`, moves the pin if both parse as finite in-range numbers, else sets the mismatch flag on the offending field without touching the pin) is called from the search-result click, from the GPS button's success handler when the panel is already open, from the lat/lng fields' blur/debounced-input listeners, and from the panel's `toggle`-open handler (U4) so a pin set while the panel was closed — via GPS capture, or pre-existing coordinates in edit mode — renders correctly the first time the panel opens. The marker's `dragend` handler writes straight into the fields (rounded to 6 decimals) and clears any mismatch flag — it does not call `syncPinFromFields()` back, avoiding a feedback loop.
- KTD5. **No new esbuild entry point.** Unlike `trip-publish.js` (its own bundle), `location-map.js` is a plain ES module imported by `post-form.js`'s existing entry — esbuild inlines it into the same `--splitting` ESM build already configured in `user/themes/intotheeast/package.json`. Only `maplibre-gl` itself needs to be the lazy chunk; the coordinator code around it loads normally, mirroring how `heic-to` is dynamically imported from directly inside the always-loaded `post-form.js`.
- KTD6. **Panel construction is entirely JS-built, no template edit.** Mirrors `initDisclosure()` (line 341) and the photos `<details>` wrapper (line ~120): a new `initLocationDetails()` creates the `<details>`/`<summary>`, the search button/results-list/hint elements, and the map container via `document.createElement`, then moves the existing `lat`/`lng` `.form-field` wrappers into it — the same relocate-via-JS approach already used for "More options," so `post-form.html.twig` needs no structural change (only the CSS hide-rule removal in R2).
### High-Level Technical Design
```mermaid
flowchart TB
GPS["GPS button success\n(if panel open)"] --> SYNC["syncPinFromFields()"]
SEARCH["Search result click"] --> FIELDS["lat/lng fields"]
FIELDS --> SYNC
TYPE["Type + blur/debounce"] --> SYNC
SYNC --> PIN["Map pin"]
DRAG["Drag pin (dragend)"] --> FIELDS
SYNC -.invalid.-> FLAG["Mismatch flag on field\n(cleared once value parses)"]
```
Map lifecycle: first panel open → `import('maplibre-gl')` → create map + draggable marker, cache in module scope → subsequent opens call `.resize()` on the cached instance rather than recreating it.
### Assumptions
- No existing Playwright fixture creates a "search API returns N results" scenario; U6 mocks the Open-Meteo response via `page.route()` rather than depending on the live third-party endpoint, keeping the spec hermetic (and avoiding flakiness/rate-limits from a real geocoding call).
- The `location-details` panel defaults closed even when editing an entry that already has `lat`/`lng` set — see Open Questions.
---
## Implementation Units
### U1. CSS: unhide coordinate fields, style the new panel
- **Goal:** Remove the CSS rule hiding `lat`/`lng`, and add styling for the new disclosure, search results list, map container, and mismatch-flag state (R2, R9).
- **Requirements:** R2, R9.
- **Dependencies:** none.
- **Files:** `user/themes/intotheeast/css/style.css`.
- **Approach:** Remove the `display: none !important` rule at `style.css:893-895` targeting `input[name="data[lat]"]`/`input[name="data[lng]"]`. Add: a `.location-details` disclosure look mirroring `.more-options` (`user/themes/intotheeast/js/src/post-form.css:98`); a `.location-search-results` list; a `.location-map` container with a fixed height and `position: relative` so the marker's DOM element (sized ≥44×44px) sits correctly; a `.location-field--mismatch` state (red outline + inline note) for the type-mismatch flag; a disabled/"Searching…" look for the lookup button reusing the existing `.btn-action`/`is-loading` conventions (`style.css:976-991`).
- **Patterns to follow:** `.more-options`/`.more-options__summary` (`post-form.css:98-128`), `.btn-action`/`.form-status` (`style.css:970-1002`).
- **Test scenarios:** Test expectation: none -- pure CSS; visual correctness is verified manually and indirectly by U2U5's behavioral tests (elements exist and are visible/hidden as expected).
- **Verification:** `lat`/`lng` inputs are visible only inside the new panel in the browser; no other page references the removed selector (confirmed during research — none found outside `style.css:894-895` and `post-form.js`'s own field reads).
### U2. JS: build the "More location details" panel shell
- **Goal:** Construct the closed-by-default disclosure (search UI, map container, relocated `lat`/`lng` fields) entirely in JS, positioned after the City/Country fields (R1, R2, R3, KTD6).
- **Requirements:** R1, R2, R3.
- **Dependencies:** U1.
- **Files:** `user/themes/intotheeast/js/src/post-form.js`.
- **Approach:** New `initLocationDetails()`, called from `boot()` after `initDisclosure()` and `initGeo()` (so the relocated fields already reflect any `initDraft()` restore, and `initGeo()`'s `field('lat')`/`field('lng')` lookups still resolve by attribute selector regardless of DOM position). No-op if `field('lat')`/`field('lng')` are absent. Create `<details class="location-details">` + `<summary>More location details</summary>`; append a search row (`#lookup-coords` button, `#location-search-results` list, `#location-search-hint` inline hint), a `#location-map` container, then move `field('lat').closest('.form-field')` and `field('lng').closest('.form-field')` into the details. Insert the details element immediately after `field('location_country').closest('.form-field')`.
- **Patterns to follow:** `initDisclosure()` (`post-form.js:341`) and the photos `<details>` wrapper (`post-form.js:~120`) for the create-via-JS + relocate-wrapper approach.
- **Test scenarios:**
- Happy path: on `/post`, "More location details" is present, closed by default, positioned immediately after the Country field, and contains the lookup button, an empty map container, and the (now-visible-only-inside-the-panel) `lat`/`lng` inputs.
- No-op guard: if `lat`/`lng` fields were ever absent from the DOM, `initLocationDetails()` does not throw.
- **Verification:** DOM inspection in-browser confirms structure and default-closed state.
### U3. JS: geocoding search + results list
- **Goal:** Implement the "🔍 Look up coordinates" button: city-only query, client-side country ranking, results list, and all error/empty states (R4R8).
- **Requirements:** R4, R5, R6, R7, R8.
- **Dependencies:** U2.
- **Files:** `user/themes/intotheeast/js/src/post-form.js`.
- **Approach:** Click handler on `#lookup-coords`: if City and Country are both blank, show the inline hint and return (no fetch). Otherwise disable the button, show "Searching…", and `fetch` the Open-Meteo geocoding URL (KTD2). On response: empty/missing `results` → no-match hint; otherwise stable-sort by whether each result's `country` case-insensitively contains the Country field's value (matches first, original order preserved otherwise), then render each as an `<li>` containing a `<button type="button">` built via `createElement`/`.textContent` ("name, admin1, country") — keyboard-operable by default, matching the accessible-button convention already used elsewhere in this file (the photo-delete button's `aria-label`). Clicking (or activating via keyboard) a result button sets `lat`/`lng` (not City/Country) and calls `syncPinFromFields()` (U5); the list then hides until the next lookup. Network failure: catch, degrade silently (matching the existing reverse-geocode/weather pattern), re-enable the button in both the success and failure paths.
- **Patterns to follow:** `reverseGeocode()`/`initGeo()`'s fetch + status-state handling (`post-form.js:433-511`) for the request/error shape; the "no `innerHTML` anywhere in this file" convention for the results list; the existing accessible-button convention (photo-delete `<button>` with `aria-label`) for keyboard-operable dynamically-created controls.
- **Test scenarios:**
- Happy path: searching "Kyoto" (mocked response) renders a results list; clicking the first result sets `lat`/`lng` and leaves City/Country untouched.
- Disambiguation: City "Paris", Country "Texas" (mocked multi-result payload matching the design doc's real API shape) — the Texas-tagged result renders first in the list.
- No match: mocked empty-results response shows the inline no-match hint; pin/fields untouched.
- Empty inputs: clicking lookup with City and Country both blank shows the hint and triggers no network request.
- In-flight state: a deliberately delayed mocked response shows the disabled "Searching…" button state until it resolves.
- Network failure: a mocked rejected/failed request degrades silently, leaves fields untouched, and re-enables the button.
- XSS safety: a mocked result containing markup in its name field (e.g. `<img onerror=...>`) renders as literal text in the list, not executed.
- **Verification:** All scenarios above pass in the browser against mocked responses; the live-API disambiguation case (Paris/Texas) is additionally spot-checked once manually per the Definition of Done.
### U4. JS: MapLibre preview module (lazy load, draggable marker, singleton)
- **Goal:** Implement the single-marker preview map as a dedicated module: lazy-imported on first panel open, reused (not recreated) on subsequent opens, with a resize fix for the zero-size-canvas-while-closed issue (R9, R10, R12, KTD1, KTD3, KTD5).
- **Requirements:** R9, R10, R12.
- **Dependencies:** U2.
- **Files:** `user/themes/intotheeast/js/src/map-style.js` (new), `user/themes/intotheeast/js/src/location-map.js` (new), `user/themes/intotheeast/js/src/post-form.js`, `user/themes/intotheeast/js/maplibre-utils.js` (modified — import `MAP_STYLE` instead of declaring it inline; no behavior change).
- **Approach:** First, extract the existing `MAP_STYLE` literal out of `maplibre-utils.js:5` into `map-style.js` (a single `export const MAP_STYLE = ...`) and update `maplibre-utils.js` to import it instead of declaring it inline. In `location-map.js`, import the same constant and export `getOrCreateLocationMap(container, onDragEnd)`: on first call, `import('maplibre-gl')`, create a `maplibregl.Map` against `container` using the shared `MAP_STYLE` constant (KTD1), create one `maplibregl.Marker({ draggable: true, element: <a ≥44×44px sized div> })` (not yet added to the map until a pin is set), wire its `dragend` to call `onDragEnd(lngLat)`, and cache the created map/marker in module scope keyed by container so a second call reuses them. Return a handle: `{ setPin(lat, lng), hasPin(), resize() }`. `post-form.js` adds a static top-of-file `import 'maplibre-gl/dist/maplibre-gl.css';` (R10) and, in `initLocationDetails()`, listens for the panel's `toggle` event: on every open where `details.open` is true, call `getOrCreateLocationMap(...).resize()` (creating it on the first call, per the lazy-import contract) and then `syncPinFromFields()` (U5), so a pin set while the panel was closed — via GPS capture, or pre-existing coordinates in edit mode — renders on this first paint.
- **Patterns to follow:** the `heic-to` dynamic-import shape (`post-form.js:281`) for the lazy-load mechanics; `js/maplibre-utils.js:452` (`new maplibregl.Map({...})`) and `:508` (`new maplibregl.Marker(...)`) for the underlying MapLibre API shape, without importing that file (KTD1).
- **Test scenarios:**
- Happy path: opening the panel for the first time renders exactly one MapLibre canvas inside `#location-map`.
- No initial pin: with `lat`/`lng` both empty, opening the panel shows no marker.
- Reopen does not duplicate: closing and reopening the panel (repeatedly) leaves exactly one canvas element, and the canvas has non-zero width/height after the reopen (guards the zero-size-while-closed case).
- Lazy import boundary: an ordinary GPS-only submit where the panel is never opened triggers no network request for the `maplibre-gl` chunk (asserted via a page network-request listener in Playwright).
- **Verification:** Browser + Playwright network-tab assertion confirm the chunk fetches once (not per-reopen) and never fetches when the panel stays closed.
### U5. JS: four-way coordinate sync + mismatch flag
- **Goal:** Keep the GPS button, search picks, pin drag, and typed values mutually in sync in both directions, including the visual mismatch flag for unparseable typed input (R11, R13, R14, KTD4).
- **Requirements:** R11, R13, R14.
- **Dependencies:** U3, U4.
- **Files:** `user/themes/intotheeast/js/src/post-form.js`.
- **Approach:** Implement `syncPinFromFields()` (KTD4): parse `field('lat')`/`field('lng')` values; if both are finite numbers within range, call the map handle's `setPin`, clear the mismatch flag/class from both fields, and clear `aria-invalid`/`aria-describedby`; if either fails to parse or is out of range, leave the pin untouched and add the mismatch flag/class (plus an inline "not reflected on map" note, rendered in a `role="status"`/`aria-live="polite"` element mirroring the existing dynamic-feedback pattern used elsewhere in this file, e.g. `#location-status`) to the offending field(s), setting `aria-invalid="true"` and `aria-describedby` pointing at that note so screen-reader users are told the value wasn't reflected on the map. Wire callers: (a) the marker's `dragend` (from U4's `onDragEnd`) writes rounded-to-6-decimal values directly into the fields and clears the mismatch flag — it does not call `syncPinFromFields()` back; (b) the search-result click (U3) sets fields then calls `syncPinFromFields()`; (c) the existing GPS success handler (`initGeo()`, `post-form.js:464-475`) calls `syncPinFromFields()` after setting fields, but only if the location-details `<details>` is currently open; (d) `lat`/`lng` field `blur` and debounced `input` listeners call `syncPinFromFields()`; (e) the panel's `toggle`-open handler (U4) calls `syncPinFromFields()` on every open, so a pin set while the panel was closed — covering the case (c) doesn't, and edit-mode entries with pre-existing coordinates — renders correctly on first paint.
- **Patterns to follow:** the GPS button's existing `toFixed(6)` rounding (`post-form.js:465-466`) for consistency; `setStatus()`'s idle/loading/success/error class pattern (`post-form.js:397`) as a model for the mismatch-flag class toggling.
- **Test scenarios:**
- GPS-first-then-open: capture GPS coordinates, then open the panel — the pin appears at the GPS coordinates on first paint.
- GPS-while-open: open the panel first, then click the GPS button — the pin updates live without needing to reopen the panel.
- Drag updates fields: dragging the marker to a new position updates `lat`/`lng` to the rounded 6-decimal values matching the drop location (within a small tolerance).
- Type valid values: typing a valid in-range pair and blurring moves the pin and shows no mismatch flag.
- Type invalid values: typing a non-numeric or out-of-range value and blurring leaves the pin in place and shows the mismatch flag; a subsequent valid edit clears the flag and moves the pin.
- Search doesn't clobber City/Country: after a search-result click, the City/Country field values are unchanged from what the traveller typed, even if the matched place's name differs in spelling/case.
- **Verification:** All six scenarios pass in the browser; a submit with a search-selected location round-trips through the existing backend `cleanCoordinate()` and produces the expected saved `lat`/`lng`.
### U6. Playwright coverage (best-effort)
- **Goal:** Add automated coverage for the new search → pin → submit flow, accepting the known local test-harness risk (R4R14 as observable behavior).
- **Requirements:** R4, R5, R6, R7, R8, R9, R10, R11, R12, R13.
- **Dependencies:** U1U5.
- **Files:** `tests/ui/post/location-override.spec.js` (new).
- **Approach:** Mock the Open-Meteo geocoding endpoint via `page.route()` so the suite is hermetic and doesn't depend on the live third-party API or rate limits. Cover: panel closed by default; empty-input lookup click sends no request and shows the hint; a mocked multi-result search sets `lat`/`lng` from a clicked result without touching City/Country; the Paris/Texas ranking case (mocked payload mirroring the design doc's verified real-API shape) renders the Texas-tagged result first; dragging the marker (Playwright mouse API) updates the fields; typing invalid values shows the mismatch flag without crashing; reopening the panel a second time leaves exactly one map canvas; a full submit with a search-picked location saves the expected `lat`/`lng` in the entry's frontmatter (reuse the existing fixture/cleanup helpers from `tests/ui/post/post.spec.js`).
- **Patterns to follow:** `tests/ui/post/post-form-ux.spec.js` (R18's `#get-location`/geolocation-mocking spec, line 186) for the geolocation/location-status assertions; `tests/ui/post/post.spec.js` for entry fixture creation, submit, and on-disk frontmatter assertions.
- **Test scenarios:** the bullet list under Approach is the scenario list.
- **Verification:** `npm run test:ui -- tests/ui/post/location-override.spec.js` (from `tests/`) passes. **Known risk:** the pre-existing, unrelated `make test-account` Makefile quoting bug may still block running the Playwright suite locally when this unit lands — if so, the spec file is still committed correct-and-ready, and the manual QA checklist in the Definition of Done is the actual completion gate for this plan.
---
## Verification Contract
| Gate | Command | Applies to |
|---|---|---|
| Rebuild theme assets | `make build-assets` | U1U5 (regenerates `js/post/*` and `css-compiled/post-form.css`) |
| New location-override spec | `npm run test:ui -- tests/ui/post/location-override.spec.js` (run from `tests/`) | U6 — may be blocked by the known `make test-account` issue; manual QA is the fallback gate |
| Full post-form suite (no regressions) | `npm run test:ui -- tests/ui/post` | U2U5 |
| Manual QA (per spec's Testing Plan) | see Definition of Done | All units |
Run the dev stack for manual QA via the worktree's own container per the worktree dev-server convention. Do not flip any dev/prod mode flags to work around anything encountered here.
---
## Definition of Done
**Global**
- All four coordinate-setting paths (GPS, search + pick, drag, type) verified in-browser to keep fields and pin in sync in both directions; a submitted entry's frontmatter has the expected `lat`/`lng`.
- The ambiguous-search case (City "Paris", Country "Texas") verified to rank the Texas result first over France/Tennessee/Kentucky/Illinois matches; the no-match case verified separately.
- Reopening "More location details" a second time does not duplicate the map canvas, and the pin still reflects the current `lat`/`lng`.
- Typing garbage into `lat`/`lng` does not crash the map or move the pin; a submit still round-trips through the existing backend `cleanCoordinate()` validation.
- `make build-assets` has been run; `js/post/*` and `css-compiled/post-form.css` are current; no hand-edits to built files.
- No abandoned/experimental code left in the diff; this plan's Status line is updated to `✅ Complete (YYYY-MM-DD)`.
**Per unit**
- U1: `lat`/`lng` inputs are visible only inside the new panel; new panel/results/map/mismatch styles render as designed.
- U2: panel exists, closed by default, positioned after Country, contains the expected child elements.
- U3: search happy path, disambiguation, no-match, empty-input, in-flight, network-failure, and XSS-safety scenarios all pass.
- U4: exactly one map canvas persists across repeated opens; no pin shown until first coordinate set; `maplibre-gl` fetches once, and never when the panel stays closed.
- U5: all four sync directions verified, including the mismatch-flag set/clear cycle.
- U6: spec file committed and passing where the test harness allows it; if blocked by the known `make test-account` issue, manual QA stands in as the completion gate.
---
## Risks & Dependencies
- **Third-party geocoding dependency — outright failure.** Open-Meteo's geocoding endpoint (CORS, city-only-query semantics) is external and was verified live only at design time; a future outage or breaking contract change could break requests outright. Mitigation: the existing graceful no-match/network-failure degrade paths (R8) already absorb this.
- **Third-party geocoding dependency — ranking/schema drift.** A subtler failure mode: the API keeps returning HTTP 200 with a non-empty `results` array, but a field the client-side ranking depends on (e.g. `country`) is renamed, emptied, or restructured — R8's no-match/network-failure paths don't fire in this case, since neither condition is met. Mitigation: R7 already renders the full, unranked result list regardless of ranking outcome, so the traveller can still manually pick the correct entry — this failure mode degrades disambiguation convenience, not correctness.
- **Build-chain risk.** Dynamically importing `maplibre-gl` from inside `post-form.js`'s existing `--splitting` ESM build must not regress the already-working `heic-to` lazy chunk. Mitigation: verify via `make build-assets` plus a browser network-tab check that both chunks split correctly.
- **Zero-size canvas on first open.** MapLibre initializing against a `display:none` container is a known gotcha; mitigated by the explicit `.resize()` call on every panel open (R9, U4).
- **Test-harness blocker.** The pre-existing `make test-account` Makefile quoting bug may prevent U6 from running locally at all. This plan does not fix that bug; manual QA is the accepted fallback per the design doc's own Out-of-scope note.
---
## Open Questions
- **Should the panel auto-open in edit mode when `lat`/`lng` are already set?** The design doc says "closed by default" without carving out an edit-mode exception, and this plan's default (U2) is to honor that literally — closed even on edit. The existing "More options" panel auto-opens under a narrower condition (a toggle value deviating from its blueprint default) and `initEditMode()` separately force-opens it for edit generally; whether "More location details" should follow either precedent for entries that already have a location is a plausible UX gap the design doc didn't explicitly rule out. Non-blocking — defer to whichever behavior feels right when the panel is actually used in edit mode, but flag it as a candidate small follow-up if closed-by-default proves surprising in practice.
@@ -0,0 +1,170 @@
# Pre-Trip Readiness Audit — 2026-07-08 (overnight)
**Scope:** everything the trip depends on from the road — the posting pipeline
(/post → cache-on-save → add-page-by-form), photo handling, edit mode, auth &
sessions, GPX manager, the custom API surface, and prod's anonymous exposure.
**Method:** read-only code audit of the current `main` + anonymous HTTP probes
against production. **No code was changed.** Findings are prioritized; a
10-minute morning checklist is at the bottom.
---
## What was verified and looks solid ✅
- **Prod anonymous surface holds.** Probed 2026-07-08 (UTC night): `GET
/api/v1/pages` → 401 with a clean JSON error; `/post` and `/gpx-manager`
render the login form; no `X-Powered-By` leak. API CORS is same-origin
(`origins: {}`), rate limiting on (120 req/60s), session auth enabled.
- **The custom API routes are properly hardened.** `entry-actions`
(DELETE entry / reorder photos / trip publish) all require the authenticated
**owner** (`site.owner_username`, not just any login), enforce
`api.pages.write`, validate slugs as safe single segments, and resolve
targets through the page tree via the shared `EntryScopeGuard` — no raw path
concatenation anywhere. The publish route handles the APCu/in-place-write
cache gotcha explicitly and never turns a cache-invalidation failure into a
fake 500. Audit logging on all three.
- **Text can't be lost while composing.** `post-form.js` mirrors every text
field to localStorage on each keystroke and clears the draft **only** on a
server-confirmed success notice. Any failure path (validation error, expired
session, network drop, closed tab) re-offers the text on the next visit.
- **HEIC handling fails closed.** Sniffed from bytes (not filename), converted
client-side, submit is gated while a conversion is in flight, and a failed
conversion skips the file with a visible message instead of uploading a
broken HEIC.
- **Photo reconcile is fail-safe.** Runs exactly once per submit (latched),
an empty/missing `photo_order` touches nothing, only image extensions are
ever deleted, and edit-mode targets resolve through the same scope guard.
- **Edit-mode photo editor has honest error paths.** Failed reorder → revert
to last-known-good; failed refresh after a successful save → keeps the saved
order; failed batch-add → rollback with an explicit warning when rollback
itself was incomplete; 404 on delete treated as convergent success.
- **Cache invalidation on post/edit is correct even under prod caching.**
cache-on-save does `deleteAll()` + `Cache::invalidateCache()` (config
checksum bump → new page-tree index key), so in-place edits appear without
needing APCu-specific clearing on that path.
---
## Findings — do before departure (P1)
### P1-1 · Prod PHP upload limits are unverified — could block photo posting entirely
`php/php-local.ini` (100M upload / 500M post) is **mounted only into the local
Docker container** (`docker-compose.yml`); nothing in `scripts/` or `deploy/`
ships PHP limits to the prod Apache server. If prod runs distro defaults
(`upload_max_filesize=2M` is common), a single modern phone photo (38 MB)
fails to upload — the exact core use case of the trip.
**Action:** `make remote-diag` (or a one-off phpinfo check) to read prod's
`upload_max_filesize` / `post_max_size` / `max_file_uploads`. If low, add a
`.user.ini` (FPM) or `.htaccess` `php_value` (mod_php) via a new make target.
The real proof is P1-2's live post with photos.
> **Resolved 2026-07-09.** Confirmed prod was at the 2M default. Fixed by
> Mischa via Webmin: PHP execution switched from CGI to **PHP-FPM** (package
> was already installed) and the upload limits raised in the FPM
> configuration. Because the setting lives in the server-side FPM config —
> not in the webroot — it survives fresh Grav installs, so no
> `deploy/`-versioned `.user.ini` / make target is needed. Side benefit: APCu
> now persists in shared memory, matching the assumptions in the
> entry-actions publish endpoint's cache invalidation.
> Config location for future reference: Webmin → PHP-FPM Configuration.
> Still owed: P1-2's live phone post is the end-to-end proof.
### P1-2 · One real end-to-end post from the actual phone, on prod, over cellular
The runbook's pre-launch smoke (handover step 7) calls for one `/post` submit
on prod. After the 2026-07-08 deploy, confirm this happened **from the phone
you'll travel with, on cellular, with 2+ HEIC photos** — that exercises HEIC
conversion, FilePond upload, prod PHP limits, cache-on-save under
`twig.cache:true`, and the feed render in one shot. Then edit that entry
(reorder + delete a photo), then delete it — the edit/delete paths shipped
today and deserve one prod rep.
### P1-3 · Session expiry mid-compose: test the 30-minute window once
`system.yaml` has `session.timeout: 1800` (30 min) and `form.yaml` has
`refresh_nonce: false`. A slow entry written on a train can easily outlive the
session; rememberme (enabled, 7-day cookie) should transparently re-auth the
next request, but the form **nonce** and the FilePond **flash uploads** were
created under the old session. The localStorage draft guarantees the text
survives whatever happens — but you should see the actual failure mode once
now, not first in a hostel.
**Test:** open `/post`, add a photo, wait 35+ minutes, submit.
**If it's ugly:** consider raising `session.timeout` in the prod env override
(`deploy/env/prod/system.yaml`, e.g. 412 h) — single-owner site, low risk,
big comfort. (Per-env override, not the committed dev `system.yaml`.)
### P1-4 · Make sure you can log back in from the road
The rememberme cookie lasts **7 days** — on a multi-week trip you *will* be
re-typing the password, possibly on hotel wifi after a cookie wipe. Login
throttling is 5 attempts / 10 min (easy to hit with phone typos).
**Action:** confirm the password is in the phone's password manager and test a
fresh login on the phone once. Know that after 5 typos you wait 10 minutes —
don't panic-retry.
---
## Findings — worth doing before departure (P2)
### P2-1 · Duplicate home page: `user/pages/home/` shadows `00.home/`
Both `user/pages/home/home.md` (old, committed in `a440583`, carries
`routes: default: /`) and `user/pages/00.home/home.md` (the real one) exist
with the same slug and near-identical content — which is exactly why a silent
mix-up would go unnoticed. Which page wins `/home` (the `home.alias` target)
depends on page-index ordering luck.
**Action:** delete `user/pages/home/` (verify `/` and `/home` still render the
context-aware home from `00.home` afterwards, incl. the pre-departure branch).
### P2-2 · Docs drift in CLAUDE.md
- `active_trip: japan-korea-2026` example — that trip doesn't exist; the real
upcoming trip is **`/trips/denmark-2026`** (local `site.yaml`, uncommitted).
- The `entry-actions` plugin (three owner-only API routes, shipped with the
journal-post-form feature) isn't mentioned in CLAUDE.md's plugin list or
architecture sections, and the "post form uses filepond via cache-on-save"
description predates the edit-mode photo editor.
### P2-3 · No HSTS header on prod
Apache serves without `Strict-Transport-Security`. One-line header addition;
the login form and session cookie deserve it (`secure_https: true` is already
set for the cookie).
### P2-4 · Shrink the unused API auth surface
`api.yaml` enables **api_keys + JWT + session** auth. The site only uses
session auth (gpx-manager, post-form edit, entry-actions). If no API keys are
in use (`user/config/plugins/api-private.php` is untracked/local — not
audited), disabling `api_keys_enabled`/`jwt_enabled` in config removes two
whole credential classes from the attack surface. Not urgent — the endpoints
behind them still enforce owner checks.
### P2-5 · Confirm the backup path is live
Every road post only exists on the prod disk until git-sync commits it to
Gitea. **Action:** `make remote-content-status` — confirm git-sync is enabled
on prod and the working tree is clean/pushed. (Photos live under `pages/`, so
they ride along in the content repo — the backup covers them too.)
---
## Known limitations — accepted, no action (P3)
- **Photos are not draft-persisted** (File/Blob can't go to localStorage); the
restore hint says so explicitly. Re-selecting photos after a failure is the
designed trade-off.
- **Location/weather helpers depend on free third-party APIs** (BigDataCloud
reverse-geocode, Open-Meteo). Both are best-effort with manual fallbacks —
fine.
- **No offline mode.** `/post` needs connectivity to load; composing offline
means the phone's notes app. (Logged as an ideation candidate, not a bug.)
- **Rate limit 120 req/60s** is generous for a single owner; a 6-photo edit
batch stays far below it.
---
## Morning checklist (~10 minutes + one coffee)
1. `make remote-diag` → check `upload_max_filesize` / `post_max_size` on prod
(P1-1). Fix limits first if they're at defaults.
2. From the phone, on cellular, on prod: log in fresh → post a test entry with
2 HEIC photos → verify it's in the feed immediately → edit it (reorder +
remove a photo) → delete it (P1-2, P1-4).
3. `make remote-content-status` → git-sync clean and pushing (P2-5).
4. Optional but cheap: start the 35-minute `/post` session-expiry test in a
background tab while doing the above (P1-3).
5. Queue the P2 cleanups (duplicate home folder, CLAUDE.md drift, HSTS) for a
normal dev session — none block departure.
@@ -0,0 +1,217 @@
# Trip publish/unpublish toggle — design
**Date:** 2026-07-08
**Status:** 📋 Design — awaiting plan
## Goal
Let the logged-in **owner** publish/unpublish any trip directly from the UI. The
**write control lives on one surface — the Past Trips listing** (`/trips`), which
already shows drafts and toggles both directions reversibly. The **trip detail
page** (`/trips/<slug>`) carries **no publish UI**: an unpublished trip's detail
page 404s (for everyone, owner included), so a control there could only strand the
owner and a `Draft` indicator there would be unreachable — see Surface 2.
Anonymous/non-owner visitors see no change. Toggling must correctly invalidate
Grav's page-tree cache so the change is reflected everywhere on the next load.
## Owner gate
A single rule, mirroring the post feed's owner logic:
```twig
{% set is_owner = grav.user.authenticated and grav.user.username == grav.config.site.owner_username %}
```
This is **broader** than `owner_can_edit` in `trip.html.twig` (which also
requires the page to be the active trip). Publishing must work on *any* trip, so
it gets its own `is_owner` flag, computed in `trips.html.twig` (the listing — the
only surface with the write control). The backend enforces the same owner check
independently (defense in depth) — the UI gate is not the security boundary.
## Backend — extend the `entry-actions` plugin
Reuses the plugin's existing owner-gate, API-key scope cap, and the
`deleteAll()` + `Cache::invalidateCache()` caching pattern.
### Route
`POST /api/v1/trip/{slug}/publish` — registered in `entry-actions.php`
`onApiRegisterRoutes`. Body: `{ "published": true | false }`.
### Controller: `setTripPublished(ServerRequestInterface): ResponseInterface`
In `EntryActionsApiController`, mirroring `deleteEntry`:
1. `$user = $this->getUser($request)` — 401 for anonymous.
2. `$this->requirePermission($request, 'api.pages.write')` — same scope cap as the
stock media/page-write endpoints (owner already holds it).
3. `EntryScopeGuard::isOwnerUser($this->grav, $user)` — else `ForbiddenException`.
4. Validate `slug` via `EntryScopeGuard::isSafeSegment` — else 400.
5. Resolve the page via a **new** guard `EntryScopeGuard::resolveTripChild($grav, $slug)`:
call `$pages->enablePages()` first (guarded by `method_exists` — the API request
context lazily disables the page tree, exactly as `resolveActiveDailyChild` does),
then `$pages->find('/trips/' ~ slug)`, assert the resolved page's parent route is
exactly `/trips` (no raw path concatenation — same style as
`resolveActiveDailyChild`). Return `null``NotFoundException`. (`find()` returns
unpublished trips too — verified against `Pages.php:966`/`1986` — so the owner can
republish a draft from the listing.)
6. Read desired state: reject a missing or non-boolean value with 400 —
`if (!array_key_exists('published', $body) || !is_bool($body['published'])) → 400`
— then assign the raw boolean (`$published = $body['published']`). Do **not**
`(bool)`-cast the value: a cast silently coerces anything (`"false"`, `0`, `""`,
a missing key) into a valid boolean and never rejects, contradicting the 400.
7. Set published + persist frontmatter by mutating the page **header** before
saving: `$header = $page->header(); $header->published = $published; $page->save();`
— mirroring `cache-on-save`'s `setOverwriteMode()` header-mutation pattern. Do
**not** rely on `$page->published($published)` alone: in Grav 2.0 that only sets
the in-memory property (`Page.php:1714`), while `save()` serializes from the
header object (`Page.php:1256`) and the flag is read one-way *from* the header at
init (`Page.php:541`) — so the on-disk `trip.md` would be unchanged and the
toggle would silently no-op. The write must land in `trip.md` frontmatter as
`published: true|false`.
8. **Caching:** `$this->grav['cache']->deleteAll(); Cache::invalidateCache();`
publish state feeds `.published()` collections and routability, both keyed
through the page-tree index; without `invalidateCache()` the listing/nav/home
render stale (the exact bug fixed in `deleteEntry`).
9. Audit log: `owner "%s" set trip "%s" published=%s`.
10. Return `ApiResponse::noContent()` (204).
## Frontend
### Shared toggle partial
`partials/trip-publish-toggle.html.twig` — renders a sliding on/off **switch**
(a styled checkbox that moves left↔right) plus a `Draft` badge when unpublished.
Params: `trip` (the trip Page), `is_active` (bool, whether this trip is
`site.active_trip`). Emits `data-trip-slug`, `data-trip-route`,
`data-published`, and `data-active` for the JS to read. Rendered only when
`is_owner`.
The switch carries `role="switch"` + `aria-checked` and a per-instance accessible
name — `aria-label="Published — {{ trip.title }}"` — so a screen-reader user on
the listing (where every card's switch is otherwise identical) can tell which trip
a toggle controls before triggering a destructive unpublish.
### Surface 1 — `/trips` listing (`trips.html.twig`)
- Make the collection owner-aware:
```twig
{% set trips = (is_owner ? page.children : page.children.published())
|sort((a, b) => a.date < b.date ? 1 : -1) %}
```
Owner sees unpublished trips too; anon unchanged.
- The trip card is currently a single `<a>` wrapping the cover + title. The
toggle must **not** be inside the anchor (a click would navigate). Restructure
the card so the cover image is in a positioned wrapper and the toggle sits as
an overlay sibling. Toggle placement: **absolutely positioned over the cover
image, top-right corner.** `Draft` badge on unpublished cards.
- **Legibility over arbitrary covers:** give the overlay toggle a solid pill/chip
background reusing the `Draft`-badge styling (Field Notes paper/teal) so it stays
legible on any cover photo, and a ≥44px touch target kept clear of the card `<a>`
hit area.
### Surface 2 — trip detail page (`trip.html.twig`)
**No publish UI in v1 — management is listing-only.** The detail page gets neither
a write toggle nor a `Draft` indicator, for a concrete reason: an unpublished trip
is not routable, and Grav's frontend serves a 404 for unpublished pages to
*everyone including the owner* (`Page::routable()` = `routable && published`, with
no published routable child to redirect to since `dailies`/`stories` are
`routable:false` — verified in `Pages::dispatch` / `Page.php:1772`). So a trip's
detail page only ever renders while it is **published** — which means a `Draft`
indicator there would be unreachable, and a write toggle could only *unpublish*,
immediately stranding the owner on a page that 404s on the next load with no in-UI
path back. All publish/unpublish therefore happens on the `/trips` listing
(Surface 1), which shows drafts and is fully reversible. `trip.html.twig` needs no
`is_owner` computation for this feature.
### JS — `js/src/trip-publish.js` → built to `js/trip-publish.js`
Loaded on `/trips` in the `bottom` group **only when `is_owner`** (gated like
`feed-actions.js` — but note the request shape below differs from it).
- Binds each `.trip-publish-toggle` control (listing cards only).
- On change:
- If turning **off** (unpublish) AND `data-active` is true → `window.confirm(
'This is your active trip — unpublishing it also removes it from the home page.
Unpublish anyway?')`; if cancelled, revert the switch and stop. (Home falls back
to its pre-departure state when the active trip is unpublished — see Edge cases.)
- **Pending:** disable the switch and set `aria-busy` for the duration of the
request, ignoring further toggles — guards against a double-tap, or a toggle
during the active-trip `confirm()`, firing a second contradictory POST and
racing the revert paths. Show it dimmed with a wait cursor while pending;
re-enable on success or after the failure revert.
- `POST /api/v1/trip/<slug>/publish` sending **`headers: { 'Content-Type':
'application/json', Accept: 'application/json' }` and `body: JSON.stringify({
published })`**, `credentials: 'include'`. Model this on `post-form.js`'s
`apiSend`, **not** `feed-actions.js` (which is a body-less DELETE with no
`Content-Type`). The `Content-Type: application/json` is load-bearing: the API's
`JsonBodyParserMiddleware` only parses the body when that header is present
(`JsonBodyParserMiddleware.php:16`); without it the body decodes to `[]`, the
strict `is_bool` guard (backend step 6) sees no `published` key, and **every
toggle 400s**.
- **Success:** optimistic UI — flip `data-published`, toggle the `Draft` badge,
update the switch position/label in place on the card. No full reload needed
(server state is persisted + cache invalidated for other surfaces). The card
stays visible to the owner either way (the owner-aware collection includes
drafts).
- **Failure:** revert the switch to its prior state and surface an error via one
shared page-level `aria-live` toast region (the listing's corner overlay has no
room for an inline message). Reuse the copy style from `feed-actions.js`:
401/403 → "sign in again"; other → "Couldn't update — try again."
## Edge cases
- **Active trip unpublish** → JS `confirm()` (above), allowed on confirm. **Home
then treats it as no active trip:** gate `home.html.twig`'s active-trip branch on
the resolved active trip being **published** as well as `config.site.travelling`
(`{% if config.site.travelling and trip.published %}``trip` is already resolved
at `home.html.twig:10`). When the active trip is unpublished, home falls through to
its between-trips / pre-departure state instead of rendering a draft trip. No need
to touch `site.active_trip`.
- **Anon / non-owner** → no toggle rendered; listing shows `.published()` only;
backend rejects with 401/403.
- **Unpublished trip visibility** → drops from the public `/trips` listing; its
detail page 404s for **everyone including the owner** (Grav default for
unpublished/unroutable — there is no owner-preview bypass). The owner still sees
the trip in the `/trips` listing (Draft badge) and re-publishes from there.
(Scope note: this toggle governs only whether the trip appears in the `/trips`
listing — it is not a content-privacy control. Child dailies are aggregated inline
by the trip page and are not individually linked; a story reachable by a direct
link stays reachable, which is acceptable.)
- **Child dailies/stories cascade** → out of scope for v1; unpublishing a trip
does not change its children's published state.
## Testing (Playwright, `tests/ui/trip/`)
Tests run as the owner (`testrunner` via `owner_username` override), mirroring
the post specs. Use a throwaway fixture trip folder (create/cleanup on disk).
1. **TP1 — owner sees the toggle; anon does not.** Owner load of `/trips` shows
`.trip-publish-toggle`; an anon (cleared storageState) load does not, and an
unpublished fixture trip is absent for anon.
2. **TP2 — unpublish hides it (caching).** Owner toggles a published fixture trip
off → **reload** `/trips` as anon → the trip is absent; owner reload of the
`/trips` listing → Draft badge present (asserted on the listing, since the detail
page 404s for the owner too). This is the page-tree-index assertion (mirrors DEL4).
3. **TP3 — republish restores it (from the listing).** As owner on `/trips`, toggle
a Draft fixture trip back on → anon reload sees it again. Republish is asserted on
the listing surface, not the detail page (which 404s while unpublished).
4. **TP4 — active-trip confirm.** Unpublishing the active trip prompts a confirm;
dismissing leaves it published.
5. **TP5 — authz.** `POST /api/v1/trip/<slug>/publish` as anon → 401; as a
non-owner authenticated user → 403; frontmatter unchanged on disk.
6. **TP6 — active trip unpublished → home falls back.** With the fixture trip set as
`site.active_trip` and `travelling: true`, unpublish it → reload `/` → home renders
its between-trips / pre-departure state, not the draft trip's active-trip view.
(Needs the `active_trip` override on the fixture; mirrors the home-suite setup.)
## Out of scope
- Bulk publish/unpublish.
- Scheduling / publish dates.
- Cascading child publish state.
- Reordering trips by publish state (order stays by date desc).
- A publish/unpublish write control on the trip detail page. Management is
listing-only by design (an unpublished trip's detail page 404s, so a detail-page
toggle could only strand the owner — see Surface 2).
@@ -0,0 +1,82 @@
# Post form: location override (search + map + drag)
**Status:** 📋 Not started
## Problem
The post form's `lat`/`lng` fields exist in the blueprint (`user/pages/02.post/post-form.md`) as plain `type: text` fields, but a theme CSS rule (`user/themes/intotheeast/css/style.css:893-895`) hides them, and the only way to populate them is the `📍 Get Location` button, which reads the browser's live GPS position via `navigator.geolocation`.
This breaks down whenever an entry describes a place the traveller isn't physically standing in when they write it up — the common case for journal entries written at the end of a day, from a shelter/hostel/train, about somewhere visited earlier. There is currently no supported way to set a coordinate for anywhere other than "here, right now."
The only workaround has been logging into Admin2 and hand-typing/pasting raw decimal coordinates directly into the page's frontmatter field. This is what produced the Denmark 2026 bug: a coordinate pasted from an external source carried an invisible Unicode bidi mark (U+200E), which PHP's `(float)` cast silently coerced to `0.0`, placing the entry's map marker at `(0, 0)` with no error or warning anywhere in the pipeline.
Backend sanitization has already been added (`user/plugins/cache-on-save/cache-on-save.php`: `cleanCoordinate()`, wired into both `onFormValidationProcessed` for the public form and `onAdminSave` for Admin2/API saves) to strip invisible characters and range-validate lat/lng before they ever reach a page's frontmatter. That fix is necessary but not sufficient: it prevents *silent corruption of whatever gets typed*, but does nothing to prevent the underlying problem — a fragile, invisible-to-the-eye, paste-prone raw text field is still the only way to set an arbitrary location, and there's no way to visually confirm the result before submitting. This spec addresses that gap directly, on the frontend post form, so the Admin2 round-trip is no longer needed for this at all.
## Goals
- Give the traveller a reliable, visual way to set an entry's coordinates for a location other than their current GPS position, without touching Admin2.
- Let any coordinate-setting mistake be caught *before* submit, via a live map preview, rather than relying solely on backend validation to catch it after the fact.
- Keep the common case (GPS, writing about where you currently are) exactly as fast and simple as it is today — no added friction for the 📍 Get Location button.
## Non-goals
- No changes to Admin2 or the `api` plugin. The backend sanitization already shipped there stays as-is, as defense-in-depth for the Admin2 edit path (which this spec doesn't touch).
- No change to how coordinates are stored (still plain `lat`/`lng` floats in frontmatter).
- No offline/self-hosted geocoding — this reuses free, no-key, CORS-enabled public APIs, consistent with the form's existing BigDataCloud (reverse geocode) and Open-Meteo (weather) integrations.
- No additional integrity verification (certificate pinning, response signing, etc.) for the geocoding/tile third-party responses beyond HTTPS. A compromised or MITM'd response could theoretically feed bogus coordinates or map tiles into the preview, but this is accepted as low-probability and already bounded by the unchanged server-side `cleanCoordinate()` range validator, which gates what actually reaches frontmatter regardless of what the preview displays.
## Design
### Placement
- **📍 Get Location** (GPS): unchanged. Stays in its current top-level `.form-action-row`, primary/always-visible action for "I'm posting from where I am right now."
- **City / Country**: unchanged position and behavior in the main field flow (still plain, always-visible text fields, still auto-filled by GPS reverse-geocode only when blank).
- **New "More location details" disclosure**, placed directly below the City/Country fields (a separate `<details>` block from the existing "More options" advanced-fields disclosure, which stays scoped to the unrelated `published`/`force_connect`/`featured` toggles). Closed by default. Contains:
- A **"🔍 Look up coordinates"** button.
- A small MapLibre preview map with a single, draggable marker.
- The raw `lat`/`lng` text fields, relocated here from their current CSS-hidden position in the main flow (the `display: none !important` rule in `user/themes/intotheeast/css/style.css:894-895`, which targets `input[name="data[lat]"]`/`input[name="data[lng]"]`, is removed; the fields simply live inside this disclosure instead). This is a pure DOM relocation — the `name="data[lat]"`/`name="data[lng]"` attributes are unchanged, so `cache-on-save.php`'s `sanitizeCoordinates()` (which keys off those exact field names) and `post-form.js`'s existing `field('lat')`/`field('lng')` helper both keep working unmodified. Checked the theme for other references to that CSS rule or those field names — none found outside `style.css:894-895` and `post-form.js`'s own read/write of the fields — so removing the rule has no other side effects.
### Search mechanics
- The lookup button geocodes the **City field alone** via Open-Meteo's free geocoding endpoint (`https://geocoding-api.open-meteo.com/v1/search?name=<city>&count=10&language=en&format=json`) — same provider the form already trusts for weather (`api.open-meteo.com`), no API key required. CORS is confirmed open on this endpoint independent of the weather endpoint (`access-control-allow-origin: *`, verified directly against `geocoding-api.open-meteo.com`).
- **The Country field is not concatenated into the query string.** Verified against the live API: a combined query like `name=Paris%2C%20Texas` or `name=Jerup%2C%20Denmark` either returns zero results or silently degrades to matching only the part before the comma — Open-Meteo's `name` param does fuzzy/substring matching on the place name, not a "name, country" filter syntax. Concatenating would silently break the lookup for exactly the disambiguation case (e.g. "Paris, Texas") this feature exists to handle.
- Instead: query by City name alone (returns all same-named places, e.g. all five "Paris" results worldwide), then — if the Country field is non-blank — rank results client-side by matching Country against each result's `country` field (case-insensitive substring), matching entries first. All results still render in the list below, just reordered.
- Explicit click, not live-as-you-type — matches the deliberate, single-action feel of the existing GPS button.
- While a lookup request is in flight, the button shows a brief "Searching…" state (disabled, consistent with how other in-flight actions in `post-form.js` guard against double-submission); it re-enables on response, whether that's results, no-match, or network failure.
- Clicking "🔍 Look up coordinates" with both City and Country empty is treated the same as a no-match: inline hint to fill in a city or country first, no request is sent.
- **The lookup only reads City/Country — it never writes back to them.** A geocode result sets `lat`/`lng` and moves the pin only. This avoids the earlier concern of an ambiguous or slightly-off match silently overwriting a name the traveller deliberately typed.
- Multiple matches → rendered as a small clickable list (place name, admin region, country), Country-matches ranked first per above, so the traveller can disambiguate (e.g. "Paris, Île-de-France, France" vs "Paris, Texas, United States"). Each list item is built via `document.createElement` + `.textContent` — the same convention used everywhere else in `post-form.js` for dynamic content (no `innerHTML` string-building exists in the file today) — since these are untrusted, API-sourced strings. Clicking an entry sets `lat`/`lng` and moves the pin; the list is not shown again until the next lookup.
- No matches → inline hint: try adding a country, or drag the pin manually.
- Network failure → degrades the same way the existing reverse-geocode/weather calls do: silent-ish failure, fields untouched, traveller can still fall back to manual entry or the pin.
### Map preview + sync
- Single MapLibre GL map instance, reusing the site's existing style (`https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json` — same as `maplibre-utils.js`, no new API key), with one draggable marker sized to at least a ~44×44px touch target (matching standard iOS/Android touch-target guidance), since this is a mobile-first form.
- `maplibre-gl`'s JS is dynamically imported (`import('maplibre-gl')`) only when the "More location details" `<details>` is opened for the first time — mirrors the existing HEIC-conversion lazy-chunk pattern in `post-form.js`, so the ~200KB library is never fetched for ordinary GPS-only submissions. Its CSS (`maplibre-gl/dist/maplibre-gl.css`, ~8KB minified) is imported statically at the top of `post-form.js` instead, bundled unconditionally into the always-loaded `css-compiled/post-form.css` — unlike the JS, the CSS chunk can't be split off a dynamic import without esbuild orphaning it (no `<link>` reference is ever emitted for a code-split CSS chunk), so only the JS half of the HEIC lazy-chunk pattern applies here.
- Four ways to set a coordinate, all kept in sync with each other:
1. **GPS button** (main flow) — writes `lat`/`lng` directly. If "More location details" is closed, the map/pin simply reflect the new values whenever the panel is next opened. If the panel is already open when GPS resolves, the same field→pin sync used by path 4 (typing) fires immediately, so the pin jumps to the new position live instead of requiring a re-open.
2. **Search-result click** — sets fields, moves/creates pin.
3. **Dragging the pin** — on `dragend`, reads the marker's `lngLat`, writes back into the `lat`/`lng` text fields (rounded to 6 decimal places, matching the GPS button's existing precision).
4. **Typing directly into lat/lng** — on blur/debounced input, if both values parse as valid finite numbers within range, move (or create) the pin. Invalid/unparseable input leaves the pin where it was, but visually flags the field (e.g. a red outline plus an inline "not reflected on map" note) so the traveller can tell the text and the pin disagree — this is a visual aid, not a blocking validator; final enforcement stays server-side in `cleanCoordinate()`. The flag clears once the field's value parses and the pin catches up.
- If the map is opened with no `lat`/`lng` set yet, no pin is shown until one of the four paths above sets a value.
- The map instance is created once, the first time "More location details" is opened, and held in module scope; reopening the `<details>` later reuses that instance rather than constructing a duplicate. Repeat `import('maplibre-gl')` calls resolve from the ES module cache with no extra network fetch — the same behavior the existing `heic-to` lazy import already relies on. Because the container sits under `display: none` while the `<details>` is closed, MapLibre initializes with a zero-size canvas the first time; the map calls `.resize()` on every subsequent open to pick up the container's real dimensions.
### Error handling
- No search results: inline message under the search box, map/pin untouched.
- Search network failure: fields untouched, and an inline hint says the lookup service could not be reached (distinct from the no-results message, which means the service answered). **Revised in code review 2026-07-24** — this originally said "silent-ish degrade", which in practice left the DOM byte-identical to the pre-click state, so a traveller on flaky mobile data could not tell a failed lookup from a broken button. A non-2xx response is also now treated as a failure rather than parsed as an empty result set.
- Invalid manual `lat`/`lng` text: the visual mismatch flag is the primary feedback, **and** an unresolved flag blocks submit. **Revised in code review 2026-07-24** — this originally said "no client-side hard block", on the stated grounds that server-side `cleanCoordinate()` was already the safety net. It was not: `cleanCoordinate()` had never been committed, so nothing validated coordinates anywhere. It now ships (`cache-on-save.php`, both the `/post` and Admin2 paths), so the two are genuine defence in depth rather than one imaginary net. Client-side parsing is deliberately *stricter* than the server's `is_numeric` (whole-value decimals only), which is the safe direction for a mismatch.
- Geolocation permission denied: unchanged existing behavior (`#location-status` error message).
## Out of scope / explicitly deferred
- No changes to `user/plugins/admin2/` or `user/plugins/api/` — confirmed and intentional.
- No removal of the existing backend `cleanCoordinate()` sanitization (`onFormValidationProcessed` + `onAdminSave` in `cache-on-save.php`) — it remains as defense-in-depth, especially for the still-possible Admin2 edit path.
- Automated Playwright coverage for the new search→pin→submit flow is desirable but currently blocked by a pre-existing, unrelated `make test-account` Makefile quoting bug — flagged as a follow-up, not a blocker for shipping this feature. Manual in-browser QA (per CLAUDE.md's UI-change testing guidance) is required before considering this done.
## Testing plan
- Manual QA in the dev browser: open `/post`, expand "More location details," exercise all four coordinate-setting paths (GPS, search + pick a result, drag the pin, type raw numbers) and confirm the pin and fields stay in sync in both directions. Submit and confirm the saved entry's frontmatter has the expected `lat`/`lng`.
- Exercise the ambiguous-search case: City "Paris" with Country "Texas" and confirm the Texas result ranks first over the France/Tennessee/Kentucky/Illinois matches — this is the specific case the City-only-query + client-side-rank fix targets, since concatenating "Paris, Texas" into a single query string returns zero results from Open-Meteo. Also exercise the no-match case.
- Reopen "More location details" a second time in the same session and confirm the map doesn't duplicate (still one canvas, correctly sized) and the pin still reflects the current `lat`/`lng`.
- Exercise the "type garbage into lat/lng" case and confirm the map simply doesn't move the pin (no crash), while a submit still round-trips through the existing backend `cleanCoordinate()` validation.
+15 -6
View File
@@ -1,13 +1,22 @@
#!/bin/bash #!/bin/bash
set -e set -e
FILE="$1" # Enable/disable the git-sync plugin by flipping `enabled:` in its config.
#
# git-sync.yaml may live in the per-environment config tree
# (user/env/<host>/config/plugins/) when an env override dir exists — Grav's
# Admin saves config there when an environment is active — otherwise in the
# standard user/config/plugins/. Search both, env path first.
WEBROOT="$1"
STATE="$2" STATE="$2"
: "${FILE:?usage: git-sync-toggle.sh <git-sync.yaml path> <true|false>}" : "${WEBROOT:?usage: git-sync-toggle.sh <webroot> <true|false>}"
: "${STATE:?usage: git-sync-toggle.sh <git-sync.yaml path> <true|false>}" : "${STATE:?usage: git-sync-toggle.sh <webroot> <true|false>}"
if [ ! -f "$FILE" ]; then FILE=$(ls "$WEBROOT"/user/env/*/config/plugins/git-sync.yaml \
echo "ERROR: $FILE not found — is git-sync installed on this server?" >&2 "$WEBROOT"/user/config/plugins/git-sync.yaml 2>/dev/null | head -1)
if [ -z "$FILE" ] || [ ! -f "$FILE" ]; then
echo "ERROR: git-sync.yaml not found under $WEBROOT — is git-sync installed/configured?" >&2
exit 1 exit 1
fi fi
@@ -17,4 +26,4 @@ else
printf 'enabled: %s\n' "$STATE" | cat - "$FILE" > "$FILE.tmp" && mv "$FILE.tmp" "$FILE" printf 'enabled: %s\n' "$STATE" | cat - "$FILE" > "$FILE.tmp" && mv "$FILE.tmp" "$FILE"
fi fi
echo "git-sync now: $(grep -E '^enabled:' "$FILE")" echo "git-sync now: $(grep -E '^enabled:' "$FILE") ($FILE)"
+42 -15
View File
@@ -4,6 +4,7 @@
set -euo pipefail set -euo pipefail
FORM="user/pages/02.post/post-form.md" FORM="user/pages/02.post/post-form.md"
SITE="user/config/site.yaml"
PASS=0 PASS=0
FAIL=0 FAIL=0
ERRORS=() ERRORS=()
@@ -12,8 +13,13 @@ ok() { echo " ✓ $1"; PASS=$((PASS+1)); }
fail() { echo "$1"; FAIL=$((FAIL+1)); ERRORS+=("$1"); } fail() { echo "$1"; FAIL=$((FAIL+1)); ERRORS+=("$1"); }
check_grep() { check_grep() {
local desc="$1"; local pattern="$2" local desc="$1"; local pattern="$2"; local file="${3:-$FORM}"
if grep -q "$pattern" "$FORM"; then ok "$desc"; else fail "$desc"; fi if grep -q "$pattern" "$file"; then ok "$desc"; else fail "$desc"; fi
}
check_absent() {
local desc="$1"; local pattern="$2"; local file="${3:-$FORM}"
if grep -q "$pattern" "$file"; then fail "$desc"; else ok "$desc"; fi
} }
echo "" echo ""
@@ -24,25 +30,46 @@ echo "────────────────────────
grep -q "add_page:\|addpage:" "$FORM" && ok "Process action is 'add_page' (plugin trigger)" \ grep -q "add_page:\|addpage:" "$FORM" && ok "Process action is 'add_page' (plugin trigger)" \
|| fail "Process action must be 'add_page: true' — 'add-page-by-form' is not handled by the plugin" || fail "Process action must be 'add_page: true' — 'add-page-by-form' is not handled by the plugin"
# Config must be in frontmatter, not in the process block # Parent is now injected server-side from site.active_trip by the cache-on-save
check_grep "pageconfig block exists in frontmatter" "^pageconfig:" # plugin (U1). The form must NOT hardcode pageconfig.parent — that coupling was
check_grep "parent set to /trips/italy-2026-demo/dailies" "parent: '/trips/italy-2026-demo/dailies'" # the silent-misfile bug this whole change removes.
check_grep "slug_field set (determines entry folder name)" "slug_field:" check_absent "pageconfig.parent is NOT hardcoded (injected server-side from active_trip)" "^\s*parent:"
check_grep "pagefrontmatter block exists in frontmatter" "^pagefrontmatter:" check_grep "pageconfig block exists in frontmatter" "^pageconfig:"
check_grep "template: entry (creates entry.md filename)" "template: entry" check_grep "slug_field set (determines entry folder name)" "slug_field:"
check_grep "pagefrontmatter block exists in frontmatter" "^pagefrontmatter:"
check_grep "template: entry (creates entry.md filename)" "template: entry"
# The active trip — the server-side injection source — must be set in site.yaml.
check_grep "active_trip set in site.yaml (injection source)" "^active_trip:\s*\S" "$SITE"
# Form name must stay 'new-entry' — cache-on-save plugin checks this exact string # Form name must stay 'new-entry' — cache-on-save plugin checks this exact string
check_grep "form name is 'new-entry' (required by cache-on-save plugin)" "name: new-entry" check_grep "form name is 'new-entry' (required by cache-on-save plugin)" "name: new-entry"
# Required form fields # Core form fields
check_grep "title field present" "name: title" check_grep "title field present" "name: title"
check_grep "date field present" "name: date" check_grep "date field present" "name: date"
check_grep "content field present" "name: content" check_grep "content field present" "name: content"
check_grep "lat field present" "name: lat" check_grep "photos field present" "name: photos"
check_grep "lng field present" "name: lng" check_grep "lat field present" "name: lat"
check_grep "location_city field present" "name: location_city" check_grep "lng field present" "name: lng"
check_grep "location_city field present" "name: location_city"
check_grep "location_country field present" "name: location_country" check_grep "location_country field present" "name: location_country"
# Fields exposed by U2 (weather picker + transport + advanced trio)
check_grep "weather_desc field present" "name: weather_desc"
check_grep "weather_temp_c field present" "name: weather_temp_c"
check_grep "transport_mode field present" "name: transport_mode"
# 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 "featured field present" "name: featured"
# Photos use Grav's filepond field; post-form.js hooks its beforeAddFile to
# convert HEIC->JPEG before FilePond uploads (U4).
check_grep "photos field uses the filepond type" "type: filepond"
echo "────────────────────────────────────────" echo "────────────────────────────────────────"
echo " $PASS passed, $FAIL failed" echo " $PASS passed, $FAIL failed"
+5 -1
View File
@@ -7,7 +7,11 @@ set -euo pipefail
BASE_URL="${GRAV_BASE_URL:-http://localhost:8081}" BASE_URL="${GRAV_BASE_URL:-http://localhost:8081}"
USER="${GRAV_TEST_USER:-}" USER="${GRAV_TEST_USER:-}"
PASS="${GRAV_TEST_PASS:-}" PASS="${GRAV_TEST_PASS:-}"
TRACKER="user/pages/01.trips/italy-2026-demo/01.dailies" # Parent is injected server-side from site.active_trip (U1), so resolve the
# dailies dir from site.yaml rather than hardcoding a trip slug.
ACTIVE_TRIP=$(grep -E '^active_trip:' user/config/site.yaml | head -1 | sed -E "s/^active_trip:[[:space:]]*['\"]?//; s/['\"]?[[:space:]]*\$//")
TRIP_SLUG=$(basename "${ACTIVE_TRIP%/}")
TRACKER="user/pages/01.trips/${TRIP_SLUG:-italy-2026-demo}/01.dailies"
COOKIE_JAR="$(mktemp /tmp/grav-test-cookies.XXXXXX)" COOKIE_JAR="$(mktemp /tmp/grav-test-cookies.XXXXXX)"
PASS_COUNT=0 PASS_COUNT=0
FAIL_COUNT=0 FAIL_COUNT=0
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 118 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
View File
Binary file not shown.
+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)}`
);
} }
}; };
+157 -35
View File
@@ -2,19 +2,34 @@
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');
const { expect } = require('@playwright/test');
// The shared photo fixture every create goes through (the post form gates submit
// on at least one uploaded photo).
const TEST_PHOTO = path.join(__dirname, '../fixtures/test-photo.jpg');
/** /**
* Resolve the Grav user directory. * Resolve the Grav user directory.
* *
* Resolution order: * Resolution order:
* 1. GRAV_USER_DIR env var (set in .env or shell) * 1. GRAV_USER_DIR env var (set in .env or shell)
* 2. docker inspect the running intotheeast_grav container * 2. Sibling `user/` directory authoritative for this repo's layout, where
* 3. Sibling `user/` directory (worktree fallback) * docker-compose always bind-mounts `./user` relative to the checkout. This
* is correct for BOTH the main checkout and a git worktree (each worktree
* serves its own `./user`), so it must be preferred over docker inspect.
* 3. `docker inspect intotheeast_grav` last-resort fallback for running the
* specs detached from the served checkout. NOTE: from a worktree this points
* at the MAIN checkout's container (a different `user/`), so it must never
* win over the sibling dir above, or disk assertions look in the wrong tree.
*/ */
function resolveUserDir() { function resolveUserDir() {
if (process.env.GRAV_USER_DIR) { if (process.env.GRAV_USER_DIR) {
return process.env.GRAV_USER_DIR; return process.env.GRAV_USER_DIR;
} }
const sibling = path.join(__dirname, '../../user');
if (fs.existsSync(path.join(sibling, 'config/site.yaml'))) {
return sibling;
}
try { try {
const raw = execSync( const raw = execSync(
"docker inspect intotheeast_grav --format '{{range .Mounts}}{{if eq .Destination \"/var/www/html/user\"}}{{.Source}}{{end}}{{end}}'", "docker inspect intotheeast_grav --format '{{range .Mounts}}{{if eq .Destination \"/var/www/html/user\"}}{{.Source}}{{end}}{{end}}'",
@@ -24,26 +39,35 @@ function resolveUserDir() {
} catch (_) { } catch (_) {
// docker not available or container not running // docker not available or container not running
} }
return path.join(__dirname, '../../user'); return sibling;
} }
/** /**
* Resolve the active dailies directory from the post-form.md pageconfig. * Resolve the active trip slug from site.yaml `active_trip`.
* *
* The post form stores `pageconfig.parent` as a Grav route such as * The post form no longer hardcodes `pageconfig.parent` the write target is
* `/trips/italy-2026-demo/dailies`. We map that to the filesystem by * injected server-side from `site.active_trip` (see the cache-on-save plugin).
* scanning for a folder whose name ends with the trip slug. * `active_trip` is a full route ("/trips/italy-2026-demo") or a bare slug; both
* reduce to the trip slug here.
*/
function resolveActiveTripSlug(userDir) {
const sitePath = path.join(userDir, 'config/site.yaml');
if (!fs.existsSync(sitePath)) return null;
const content = fs.readFileSync(sitePath, 'utf-8');
const m = content.match(/^active_trip:\s*['"]?(\S+?)['"]?\s*$/m);
if (!m) return null;
return m[1]
.replace(/^\/?trips\//, '') // strip a leading /trips/
.replace(/^\//, '')
.replace(/\/.*$/, ''); // keep only the slug segment
}
/**
* Resolve the active dailies directory on disk from the active trip slug.
*/ */
function resolveDailiesDir(userDir) { function resolveDailiesDir(userDir) {
const postFormPath = path.join(userDir, 'pages/02.post/post-form.md'); const tripSlug = resolveActiveTripSlug(userDir);
if (!fs.existsSync(postFormPath)) { if (!tripSlug) return null;
// fallback: search all trips for a dailies dir
return null;
}
const content = fs.readFileSync(postFormPath, 'utf-8');
const m = content.match(/parent:\s*['"]?\/trips\/([^/'"]+)\/dailies/);
if (!m) return null;
const tripSlug = m[1];
const tripsBase = path.join(userDir, 'pages/01.trips'); const tripsBase = path.join(userDir, 'pages/01.trips');
if (!fs.existsSync(tripsBase)) return null; if (!fs.existsSync(tripsBase)) return null;
@@ -62,31 +86,41 @@ const USER_DIR = resolveUserDir();
const TRACKER_DIR = resolveDailiesDir(USER_DIR) || path.join(USER_DIR, 'pages/01.trips/italy-2026-demo/01.dailies'); const TRACKER_DIR = resolveDailiesDir(USER_DIR) || path.join(USER_DIR, 'pages/01.trips/italy-2026-demo/01.dailies');
/** /**
* The Grav route to the active trip page, derived from the post-form.md * The Grav route to the active trip page, derived from site.yaml `active_trip`.
* pageconfig.parent value (the dailies container route, minus the trailing * Posted entries surface in this page's journal feed.
* `/dailies`). Posted entries surface in this page's journal feed.
* Falls back to '/trips/italy-2026-demo'. * Falls back to '/trips/italy-2026-demo'.
*/ */
function resolveActiveTripUrl() { function resolveActiveTripUrl() {
const postFormPath = path.join(USER_DIR, 'pages/02.post/post-form.md'); const slug = resolveActiveTripSlug(USER_DIR);
if (!fs.existsSync(postFormPath)) return '/trips/italy-2026-demo'; return slug ? '/trips/' + slug : '/trips/italy-2026-demo';
const content = fs.readFileSync(postFormPath, 'utf-8');
const m = content.match(/parent:\s*['"]?(\/trips\/[^'"]+)\/dailies['"]?/);
return m ? m[1] : '/trips/italy-2026-demo';
} }
const ACTIVE_TRIP_URL = resolveActiveTripUrl(); const ACTIVE_TRIP_URL = resolveActiveTripUrl();
/** /**
* Wait for all filepond items to finish XHR upload. * Type content into the EasyMDE editor. The underlying <textarea> is hidden by
* EasyMDE, so we set the value through the instance the bundle exposes on
* window.postFormEditor (which also syncs the textarea for submission).
*/ */
async function waitForFilePondUpload(page) { async function fillEditor(page, text) {
await page.waitForFunction(() => { await page.waitForFunction(() => window.postFormEditor != null, { timeout: 10_000 });
const items = document.querySelectorAll('.filepond--item[data-filepond-item-state]'); await page.evaluate((t) => window.postFormEditor.value(t), text);
return items.length > 0 && [...items].every( }
el => el.getAttribute('data-filepond-item-state') === 'processing-complete'
); /**
}, { timeout: 20_000 }); * Wait for photos to finish uploading. post-form.js converts HEIC->JPEG and
* hands files to FilePond via pond.addFile(); FilePond then uploads each, and a
* finished item reaches data-filepond-item-state="processing-complete".
*/
async function waitForPhotoUpload(page, count = 1) {
await page.waitForFunction(
(n) => {
const items = document.querySelectorAll('.filepond--item[data-filepond-item-state]');
return [...items].filter(el => el.getAttribute('data-filepond-item-state') === 'processing-complete').length >= n;
},
count,
{ timeout: 40_000 }
);
} }
/** /**
@@ -97,7 +131,7 @@ async function postEntry(page, { titleTag, content = 'Automated test. Safe to de
const title = `UI Test ${titleTag} ${Date.now()}`; const title = `UI Test ${titleTag} ${Date.now()}`;
await page.goto('/post'); await page.goto('/post');
await page.fill('input[name="data[title]"]', title); await page.fill('input[name="data[title]"]', title);
await page.fill('textarea[name="data[content]"]', content); await fillEditor(page, content);
if (city) await page.fill('input[name="data[location_city]"]', city); if (city) await page.fill('input[name="data[location_city]"]', city);
if (country) await page.fill('input[name="data[location_country]"]', country); if (country) await page.fill('input[name="data[location_country]"]', country);
await page.locator('.btn-post').evaluate(el => el.click()); await page.locator('.btn-post').evaluate(el => el.click());
@@ -105,6 +139,91 @@ async function postEntry(page, { titleTag, content = 'Automated test. Safe to de
return titleTag; return titleTag;
} }
/**
* Create a fresh journal entry through the /post create form, with a photo
* attached so the submit gate is satisfied. Shared by the specs that need a
* disposable feed card to act on (delete-flow, edit-mode, anon-view draft).
*
* Pass the spec's `created` array so the tag is registered for cleanup BEFORE
* the (slow, 15s) success-toast assertion a create that lands on disk but
* whose toast assertion times out would otherwise leak an entry the afterAll
* hook never sees. `publish:false` flips the Published toggle off to make a
* draft (the toggle is a visually-hidden radio pair behind "More options", so
* set state + fire `change` rather than fighting the visibility gate).
*/
async function createPhotoEntry(page, tag, { content, publish = true, created } = {}) {
await page.goto('/post');
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
await fillEditor(page, content || `Fixture for ${tag}. Safe to delete.`);
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
await waitForPhotoUpload(page);
if (!publish) {
await page.evaluate(() => {
const off = document.querySelector('input[name="data[published]"][value="0"]');
off.checked = true;
off.dispatchEvent(new Event('change', { bubbles: true }));
});
}
await page.locator('.btn-post').evaluate(el => el.click());
if (created) created.push(tag);
await expect(page.locator('.form-messages, .notices')).toContainText(
'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.
*/ */
@@ -114,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));
} }
} }
@@ -137,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 = { waitForFilePondUpload, postEntry, cleanupEntry, findEntry, readEntryMd, 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 };
+22 -1
View File
@@ -1,10 +1,31 @@
// @ts-check // @ts-check
// Tests: H1 — home page journal feed // Tests: H1 — home page journal feed; AE7 — active-trip header gating
const { test, expect } = require('@playwright/test'); const { test, expect } = require('@playwright/test');
// ── H1: Home page renders inline journal posts ───────────────────────────────── // ── H1: Home page renders inline journal posts ─────────────────────────────────
// Only meaningful when the site is in "travelling" mode: home.html.twig gates the
// active-trip feed on `config.site.travelling`. When it's false the home renders
// the between-trips highlights grid instead (no journal feed), so this test would
// fail misleadingly. We skip loudly with a reason rather than assert against the
// wrong view — the test still runs and validates whenever travelling is on.
test('H1: home page shows at least one inline journal-post block', async ({ page }) => { test('H1: home page shows at least one inline journal-post block', async ({ page }) => {
await page.goto('/'); await page.goto('/');
const betweenTrips = await page.locator('.home-highlights-title').count();
test.skip(betweenTrips > 0, 'home is in between-trips mode (site.travelling:false); H1 requires travelling:true');
await expect(page.locator('.journal-post').first()).toBeVisible(); await expect(page.locator('.journal-post').first()).toBeVisible();
await expect(page.locator('.site-header')).toBeVisible(); await expect(page.locator('.site-header')).toBeVisible();
}); });
// ── AE7: the trip-page header extras never leak onto the home route ────────────
// The trip-page one-liner/description/banner are gated to the trip.html.twig
// caller of the shared trip-feed-col partial (trip_header_extras, default off);
// home's include omits the flag, so its header is unchanged (R12/KTD4). Asserted
// as an absence on `/` so it holds whether home is in active-trip or
// between-trips mode — the sibling home-highlights suite toggles that mode in a
// parallel worker, so this test must not depend on it.
test('AE7: home never renders the trip-page header extras', async ({ page }) => {
await page.goto('/');
await expect(page.locator('.home-trip-tagline')).toHaveCount(0);
await expect(page.locator('.trip-header-desc')).toHaveCount(0);
await expect(page.locator('.trip-header-banner')).toHaveCount(0);
});
+7 -2
View File
@@ -40,12 +40,17 @@ test('M7: clicking map marker briefly highlights the corresponding entry card',
// ── M8: Home map has GPX journey source on active trip ──────────────────────── // ── M8: Home map has GPX journey source on active trip ────────────────────────
test('M8: home map has a journey source after GPX settles (active trip)', async ({ page }) => { test('M8: home map has a journey source after GPX settles (active trip)', async ({ page }) => {
// Requires travelling: true in user/config/site.yaml. // Requires travelling: true in user/config/site.yaml — home.html.twig only
// Requires GPX files attached to the active trip (italy-2026-demo has 7). // renders the active-trip journey map (home-journey / home-gpx-0 sources) in
// that mode. With travelling:false the home shows the between-trips highlights
// map, which has neither source, so we skip loudly rather than fail misleadingly.
// Also requires GPX files attached to the active trip (italy-2026-demo has 7).
const errors = []; const errors = [];
page.on('pageerror', e => errors.push(e.message)); page.on('pageerror', e => errors.push(e.message));
await page.goto('/'); await page.goto('/');
const betweenTrips = await page.locator('.home-highlights-title').count();
test.skip(betweenTrips > 0, 'home is in between-trips mode (site.travelling:false); M8 requires travelling:true');
await expect(page.locator('#home-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 }); await expect(page.locator('#home-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
await expect(page.locator('#home-map .maplibregl-marker').first()).toBeVisible({ timeout: 15000 }); await expect(page.locator('#home-map .maplibregl-marker').first()).toBeVisible({ timeout: 15000 });
+66
View File
@@ -0,0 +1,66 @@
// @ts-check
// Tests: AN1AN2 — the anonymous (logged-out) visitor's view of the active trip.
//
// Every other spec runs as the authenticated owner, so nothing guards the
// owner/anon boundary. These assert the two things that boundary must enforce
// (R5, KTD7):
// - AN1: owner-only controls (Edit/Delete, data-entry-route) never render for
// an anonymous visitor, even though published entries are visible.
// - AN2: an unpublished DRAFT is shown to the owner (with a badge) but is
// completely absent for an anonymous visitor.
//
// The whole file runs UNauthenticated by clearing storageState. AN2 spins up a
// short-lived authenticated context to create the draft fixture and confirm the
// owner-visible side.
const { test, expect } = require('@playwright/test');
const { createPhotoEntry, cleanupEntry, findEntry, ACTIVE_TRIP_URL } = require('../helpers');
const AUTH_STATE = 'tests/.auth/user.json';
const BASE = process.env.GRAV_BASE_URL || 'http://localhost:8081';
// Run this file with NO owner session.
test.use({ storageState: { cookies: [], origins: [] } });
const created = [];
test.afterAll(() => created.forEach(cleanupEntry));
// ── AN1: anonymous visitor sees content but no owner controls ─────────────────
test('AN1: an anonymous visitor sees published entries but no owner controls', async ({ page }) => {
await page.goto(ACTIVE_TRIP_URL);
// The demo trip has published journal cards — content is public.
await expect(page.locator('.journal-post').first()).toBeVisible();
// …but none of the owner-only affordances are present in the markup.
await expect(page.locator('.journal-post-actions')).toHaveCount(0);
await expect(page.locator('.entry-action--edit')).toHaveCount(0);
await expect(page.locator('.entry-action--delete')).toHaveCount(0);
await expect(page.locator('[data-entry-route]')).toHaveCount(0);
});
// ── AN2: a draft is owner-only ────────────────────────────────────────────────
test('AN2: a draft entry is shown to the owner but hidden from an anonymous visitor', async ({ page, browser }) => {
const tag = `draft-${Date.now()}`;
// Create an UNPUBLISHED entry as the owner, in a separate authed context.
const owner = await browser.newContext({ storageState: AUTH_STATE, baseURL: BASE });
const op = await owner.newPage();
await createPhotoEntry(op, tag, {
created,
publish: false,
content: `Draft body ${tag}. Safe to delete.`,
});
expect(findEntry(tag), 'draft fixture should exist on disk').not.toBeNull();
// Owner side: the draft appears in the feed WITH a Draft badge.
await op.goto(ACTIVE_TRIP_URL);
const ownerCard = op.locator('.journal-post', { hasText: tag });
await expect(ownerCard).toHaveCount(1);
await expect(ownerCard.locator('.journal-draft-badge')).toBeVisible();
await owner.close();
// Anonymous side (the default page fixture): the draft is nowhere to be seen.
await page.goto(ACTIVE_TRIP_URL);
await expect(page.locator('.journal-post', { hasText: tag })).toHaveCount(0);
await expect(page.locator('body')).not.toContainText(tag);
});
+100
View File
@@ -0,0 +1,100 @@
// @ts-check
// Tests: DEL1DEL3 — the owner entry-delete flow (feed-actions.js + the
// entry-actions `deleteEntry` route). Delete is a two-step inline confirm on a
// feed card: Delete → Cancel / Confirm delete → DELETE /api/v1/entry/<slug>.
//
// This flow — a destructive, owner-only action — had zero automated coverage.
// - DEL1: full happy path — the card vanishes AND the folder leaves disk.
// - DEL2: Cancel is a real escape hatch — nothing is deleted.
// - DEL3: a failed DELETE keeps the card and surfaces the inline error.
const { test, expect } = require('@playwright/test');
const {
createPhotoEntry, cleanupEntry, findEntry, ACTIVE_TRIP_URL,
} = require('../helpers');
const created = [];
// cleanupEntry is a no-op when the entry was already deleted by the test.
test.afterAll(() => created.forEach(cleanupEntry));
// ── DEL1: happy delete removes the card and the folder ────────────────────────
test('DEL1: owner deletes an entry — the card disappears and the folder is removed', async ({ page }) => {
const tag = `del1-${Date.now()}`;
await createPhotoEntry(page, tag, { created, content: `Delete-flow fixture ${tag}. Safe to delete.` });
await page.goto(ACTIVE_TRIP_URL);
const card = page.locator('.journal-post', { hasText: tag });
await expect(card).toHaveCount(1);
// The owner-only controls must be present — this also asserts the auth gate.
await card.locator('[data-delete-start]').click();
await card.locator('[data-delete-confirm]').click();
await expect(page.locator('.journal-post', { hasText: tag }))
.toHaveCount(0, { timeout: 15_000 });
await expect.poll(() => findEntry(tag), { timeout: 15_000 }).toBeNull();
});
// ── DEL4: a deleted entry stays gone after a full page reload ─────────────────
// Regression for the page-tree-index staleness bug: deleteEntry did
// cache.deleteAll() but not Cache::invalidateCache(), so with
// cache.check.method: folder the deleted child lingered in the pages index and
// the SERVER re-rendered the (now image-less) card on the next load — even
// though its folder was gone from disk. DEL1 only checks the optimistic DOM
// removal + disk, so it missed this. Here we reload and assert the server no
// longer emits the card.
test('DEL4: a deleted entry is absent from the feed after a fresh page load', async ({ page }) => {
const tag = `del4-${Date.now()}`;
await createPhotoEntry(page, tag, { created, content: `Delete-flow fixture ${tag}. Safe to delete.` });
await page.goto(ACTIVE_TRIP_URL);
const card = page.locator('.journal-post', { hasText: tag });
await expect(card).toHaveCount(1);
await card.locator('[data-delete-start]').click();
await card.locator('[data-delete-confirm]').click();
await expect(page.locator('.journal-post', { hasText: tag })).toHaveCount(0, { timeout: 15_000 });
await expect.poll(() => findEntry(tag), { timeout: 15_000 }).toBeNull();
// The real test: a fresh server render must not resurrect the entry.
await page.goto(ACTIVE_TRIP_URL);
await expect(page.locator('.journal-post', { hasText: tag })).toHaveCount(0);
});
// ── DEL2: Cancel keeps the entry ──────────────────────────────────────────────
test('DEL2: cancelling the confirm step keeps the entry on the page and on disk', async ({ page }) => {
const tag = `del2-${Date.now()}`;
await createPhotoEntry(page, tag, { created, content: `Delete-flow fixture ${tag}. Safe to delete.` });
await page.goto(ACTIVE_TRIP_URL);
const card = page.locator('.journal-post', { hasText: tag });
await expect(card).toHaveCount(1);
await card.locator('[data-delete-start]').click();
await expect(card.locator('.entry-delete-confirm')).toBeVisible();
await card.locator('[data-delete-cancel]').click();
await expect(card.locator('.entry-delete-confirm')).toBeHidden();
await expect(card).toHaveCount(1);
expect(findEntry(tag), 'a cancelled delete must not remove the folder').not.toBeNull();
});
// ── DEL3: a failed DELETE keeps the card and shows the inline error ───────────
test('DEL3: a failed delete keeps the card and surfaces the inline error', async ({ page }) => {
const tag = `del3-${Date.now()}`;
await createPhotoEntry(page, tag, { created, content: `Delete-flow fixture ${tag}. Safe to delete.` });
await page.goto(ACTIVE_TRIP_URL);
// Force the delete request to fail after the confirm.
await page.route('**/api/v1/entry/**', (route) => {
if (route.request().method() === 'DELETE') return route.fulfill({ status: 500, body: '' });
return route.continue();
});
const card = page.locator('.journal-post', { hasText: tag });
await expect(card).toHaveCount(1);
await card.locator('[data-delete-start]').click();
await card.locator('[data-delete-confirm]').click();
await expect(card.locator('.entry-delete-msg'))
.toContainText('Could not delete', { timeout: 15_000 });
await expect(card).toHaveCount(1); // the card survives a failed delete
expect(findEntry(tag), 'a failed delete must not remove the folder').not.toBeNull();
});
+82
View File
@@ -0,0 +1,82 @@
// @ts-check
// Tests: ES1ES3 — edit mode field SAVE round-trip + prefill error states.
//
// Complements photo-editor.spec.js (which covers the live photo add/delete/
// reorder inside edit mode). Here we cover the *text* side of edit mode:
// - ES1 drives a real end-to-end save: create → open the feed card's Edit link
// → change title + body → Save → assert the new values land back on disk.
// - ES2/ES3 mock the prefill fetch to force the two D7 failure branches
// (404 "no longer exists" vs a transient "couldn't be loaded") — the copy
// that tells the owner whether a retry is worthwhile. These had zero coverage.
const { test, expect } = require('@playwright/test');
const {
fillEditor, createPhotoEntry, cleanupEntry, findEntry, readEntryMd, ACTIVE_TRIP_URL,
} = require('../helpers');
// Synthetic route for the mocked error tests — never has to exist on disk.
const MISSING_ROUTE = '/trips/italy-2026-demo/dailies/does-not-exist';
const created = [];
test.afterAll(() => created.forEach(cleanupEntry));
// ── ES1: edit-mode save writes the changed title + body back to disk ──────────
test('ES1: editing an entry saves the changed title and body back in place', async ({ page }) => {
const tag = `es1-${Date.now()}`;
await createPhotoEntry(page, tag, { created });
// Reach edit mode the way the owner does: via the feed card's Edit link.
await page.goto(ACTIVE_TRIP_URL);
const card = page.locator('.journal-post', { hasText: tag });
await expect(card).toHaveCount(1);
const editHref = await card.locator('.entry-action--edit').getAttribute('href');
expect(editHref).toContain('/post?edit=');
await page.goto(editHref);
// Prefill is async (GET /api/v1/pages{route}); wait until it populates.
await expect(page.locator('input[name="data[title]"]'))
.toHaveValue(`UI Test ${tag}`, { timeout: 15_000 });
await page.fill('input[name="data[title]"]', `UI Test ${tag} EDITED`);
await fillEditor(page, `Edited body for ${tag}.`);
await page.locator('.btn-post').evaluate(el => el.click());
// overwrite_mode:edit writes back in place — assert both changes on disk.
await expect.poll(() => {
const dir = findEntry(tag);
return dir ? (readEntryMd(dir) || '') : '';
}, { timeout: 15_000 }).toContain('EDITED');
const md = readEntryMd(findEntry(tag));
expect(md, 'edited body should persist').toContain(`Edited body for ${tag}.`);
});
// ── ES2: prefill 404 → the "no longer exists" (deleted) branch ────────────────
test('ES2: opening a deleted entry for editing shows the "no longer exists" notice', async ({ page }) => {
await page.route('**/api/v1/pages/**', (route) => {
if (route.request().method() === 'GET') return route.fulfill({ status: 404, body: '' });
return route.continue();
});
await page.goto('/post?edit=' + encodeURIComponent(MISSING_ROUTE));
const banner = page.locator('.post-edit-error');
await expect(banner).toContainText('no longer exists', { timeout: 15_000 });
await expect(banner).toHaveAttribute('role', 'alert');
// D7: the form is left empty rather than half-filled.
await expect(page.locator('input[name="data[title]"]')).toHaveValue('');
});
// ── ES3: prefill 500 → the transient "couldn't be loaded" (retry) branch ──────
test('ES3: a transient prefill failure shows the retry-able "be loaded" notice', async ({ page }) => {
await page.route('**/api/v1/pages/**', (route) => {
if (route.request().method() === 'GET') return route.fulfill({ status: 500, body: '' });
return route.continue();
});
await page.goto('/post?edit=' + encodeURIComponent(MISSING_ROUTE));
// Copy differs from the 404 case so the owner knows a retry is worthwhile.
await expect(page.locator('.post-edit-error'))
.toContainText('be loaded for editing', { timeout: 15_000 });
await expect(page.locator('input[name="data[title]"]')).toHaveValue('');
});
+100
View File
@@ -0,0 +1,100 @@
// @ts-check
// Test: LD1 — the PhotoSwipe slide's declared dimensions must match what the
// browser actually renders for the linked image (BUG 2026-07-09: portrait
// iPhone JPEGs squeezed to landscape in the fullscreen lightbox).
//
// Root cause: entry-journal.html.twig fed `img.width`/`img.height` (raw
// getimagesize() of the ORIGINAL file — EXIF orientation ignored) into
// data-pswp-*, while the slide href pointed at that original, which browsers
// display EXIF-rotated. For a stored-landscape portrait photo the attrs said
// landscape while the pixels rendered portrait → PhotoSwipe squeezed them.
//
// Fixed in e17a5dc: slides now link a 2000px fit-within derivative and measure
// THAT file, and derivatives are re-encoded upright, so the attrs and the
// rendered pixels agree.
//
// The invariant tested here is environment-proof: whatever file the slide
// links to, its browser-rendered natural size must equal the data-pswp-*
// attrs. (Whether the photo ALSO displays upright depends on the server's
// php-exif extension feeding auto_fix_orientation — present on prod, absent
// in the local dev container — so upright-ness is deliberately not asserted.)
//
// The fixture entry is planted straight on disk in the DEMO trip (the active
// trip is whatever site.yaml says and may be an unpublished draft that 404s;
// this spec exercises template rendering, not the posting pipeline — that is
// upload-gate.spec.js's job). touch(system.yaml) bumps the config checksum so
// the page-tree index rebuilds — the same invalidation cache-on-save uses.
const { test, expect } = require('@playwright/test');
const path = require('path');
const fs = require('fs');
const { execSync } = require('child_process');
// USER_DIR comes from helpers so GRAV_USER_DIR is honoured — without it a run
// against a checkout detached from the served tree plants the fixture in a
// different user/ than Grav renders, and LD1 fails as an opaque "card never
// appeared" timeout.
const { USER_DIR } = require('../helpers');
// Stored 800x600 with EXIF Orientation=6: browsers render it 600x800 portrait.
const EXIF_PORTRAIT = path.join(__dirname, '../../fixtures/test-photo-exif-portrait.jpg');
const DEMO_DAILIES = path.join(USER_DIR, 'pages/01.trips/italy-2026-demo/01.dailies');
const DEMO_TRIP_URL = '/trips/italy-2026-demo';
const TAG = `ld1-fixture-${Date.now()}`;
const ENTRY_DIR = path.join(DEMO_DAILIES, `2026-09-30-1200-${TAG}.entry`);
function bumpPageTreeIndex() {
// mtime bump on system.yaml changes config->checksum(), which keys the
// pages index — next request rebuilds the tree from disk.
execSync(`touch "${path.join(USER_DIR, 'config/system.yaml')}"`);
}
test.beforeAll(() => {
fs.mkdirSync(ENTRY_DIR, { recursive: true });
fs.copyFileSync(EXIF_PORTRAIT, path.join(ENTRY_DIR, 'photo-01.jpg'));
fs.writeFileSync(path.join(ENTRY_DIR, 'entry.md'), [
'---',
`title: 'UI Test ${TAG}'`,
"date: '2026-09-30 12:00'",
'template: entry',
'published: true',
'---',
'',
`Lightbox dims fixture ${TAG}. Safe to delete.`,
'',
].join('\n'));
bumpPageTreeIndex();
});
test.afterAll(() => {
fs.rmSync(ENTRY_DIR, { recursive: true, force: true });
bumpPageTreeIndex();
});
test('LD1: lightbox slide dims match the rendered size of the linked image', async ({ page }) => {
const card = page.locator('.journal-post', { hasText: TAG });
const slide = card.locator('a.journal-photo-slide').first();
// The config-checksum bump has second-granularity mtimes; a goto in the
// same second can still be served the stale cached page. Reload until the
// planted card is in the rendered feed.
await expect(async () => {
await page.goto(DEMO_TRIP_URL);
await expect(slide).toBeAttached({ timeout: 1000 });
}).toPass({ timeout: 20_000 });
const attrW = Number(await slide.getAttribute('data-pswp-width'));
const attrH = Number(await slide.getAttribute('data-pswp-height'));
const href = await slide.getAttribute('href');
expect(attrW).toBeGreaterThan(0);
expect(attrH).toBeGreaterThan(0);
const natural = await page.evaluate((src) => new Promise((resolve, reject) => {
const i = new Image();
i.onload = () => resolve({ w: i.naturalWidth, h: i.naturalHeight });
i.onerror = () => reject(new Error('image failed to load: ' + src));
i.src = src;
}), href);
expect(natural.w, `data-pswp-width vs rendered width of ${href}`).toBe(attrW);
expect(natural.h, `data-pswp-height vs rendered height of ${href}`).toBe(attrH);
});
+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');
});
+251
View File
@@ -0,0 +1,251 @@
// @ts-check
// Tests: E1E7 — the edit-mode live photo editor (initPhotoEditor).
//
// These cover the add / delete / reorder paths of the photo editor reached at
// `/post?edit=<route>`, with an emphasis on the FAILURE branches added in commit
// 7ffd75e (auth-expiry copy + incomplete-rollback warning) which had zero
// automated coverage.
//
// Strategy: the media API is fully mocked with page.route(). This is deliberate:
// - the create form is now photo-gated (≥1 photo required), so a text-only
// fixture entry can't be posted programmatically; and
// - mutating a real demo entry on disk would be destructive.
// Mocking lets us drive every add/delete/reorder branch — including the ones
// that only fire on server errors — deterministically and non-destructively.
// Real end-to-end persistence stays covered by the manual owner-session smoke
// test in the handover.
//
// Assertions target the stable USER-FACING strings, never minified identifiers,
// so they survive the bundle build.
const { test, expect } = require('@playwright/test');
const path = require('path');
const TEST_PHOTO = path.join(__dirname, '../../fixtures/test-photo.jpg');
// A synthetic entry route. It never has to exist on disk — every API call the
// editor makes against it is intercepted below. The reorder route keys on the
// last path segment ("mock-entry").
const EDIT_ROUTE = '/trips/italy-2026-demo/dailies/mock-entry';
const EDIT_URL = '/post?edit=' + encodeURIComponent(EDIT_ROUTE);
/**
* Install a stateful mock of the Grav media API + entry-actions reorder route.
*
* cfg:
* photos: string[] initial filenames in the grid (default [])
* addName: string filename a successful POST /media "creates" (default 'stock-upload.jpg')
* status: { prefill, list, add, delete, reorder } HTTP status per op.
* Any value >= 400 makes that op fail. Success defaults:
* prefill 200, list 200, add 200, delete 204, reorder 204.
*
* Returns a `state` object the test can inspect: `state.photos` (current set)
* and `state.reorders` (array of the `order` arrays received by the reorder
* route, newest last).
*/
async function installMockApi(page, cfg = {}) {
const state = {
photos: (cfg.photos || []).slice(),
reorders: [],
};
const s = Object.assign(
{ prefill: 200, list: 200, add: 200, delete: 204, reorder: 204 },
cfg.status || {}
);
const addName = cfg.addName || 'stock-upload.jpg';
await page.route('**/api/v1/**', async (route) => {
const req = route.request();
const method = req.method();
const p = new URL(req.url()).pathname;
const json = (status, obj) =>
route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(obj) });
const empty = (status) => route.fulfill({ status, body: '' });
// Reorder: POST /api/v1/entry/{slug}/photos/order
if (/\/api\/v1\/entry\/[^/]+\/photos\/order$/.test(p)) {
if (s.reorder >= 400) return empty(s.reorder);
try { state.reorders.push(JSON.parse(req.postData() || '{}').order); } catch (_) {}
return empty(204);
}
// Delete: DELETE /api/v1/pages{route}/media/{filename}
const delMatch = p.match(/\/media\/([^/]+)$/);
if (delMatch && method === 'DELETE') {
if (s.delete >= 400) return empty(s.delete);
const fn = decodeURIComponent(delMatch[1]);
state.photos = state.photos.filter((x) => x !== fn);
return empty(204);
}
// List (GET) or Add (POST): /api/v1/pages{route}/media
if (/\/media$/.test(p)) {
if (method === 'POST') {
if (s.add >= 400) return empty(s.add);
state.photos.push(addName);
return json(200, { data: { filename: addName } });
}
if (s.list >= 400) return empty(s.list);
return json(200, { data: state.photos.map((f) => ({ filename: f })) });
}
// Prefill: GET /api/v1/pages{route}
if (/\/api\/v1\/pages\//.test(p)) {
if (s.prefill >= 400) return empty(s.prefill);
return json(200, {
data: {
header: {
title: 'Mock Entry',
date: '2026-09-01 07:00',
published: true,
lat: 43.5,
lng: 11.3,
},
content: 'Mock content for the editor test.',
published: true,
},
});
}
return route.continue();
});
return state;
}
/** Open the editor and wait for its first render to settle. */
async function openEditor(page) {
await page.goto(EDIT_URL);
await page.waitForSelector('.photo-editor__grid', { timeout: 15_000 });
// The grid starts on a "Loading photos…" placeholder; wait for the initial
// mediaList() to resolve into either cells or the empty-state message.
await page.waitForFunction(() => {
const g = document.querySelector('.photo-editor__grid');
return g && !g.querySelector('.photo-editor__loading');
}, { timeout: 15_000 });
}
const status = (page) => page.locator('.photo-editor__status');
const cells = (page) => page.locator('.photo-editor__cell');
/** Delete the Nth photo cell through the inline confirm dialog. */
async function deleteCell(page, index) {
await cells(page).nth(index).locator('.photo-editor__del').click();
await cells(page).nth(index).locator('.photo-editor__confirm-yes').click();
}
/**
* Drag cell at `from` onto cell at `to` using stepped mouse moves so SortableJS
* (which listens to native pointer events) picks it up.
*/
async function dragCell(page, from, to) {
const src = await cells(page).nth(from).boundingBox();
const dst = await cells(page).nth(to).boundingBox();
if (!src || !dst) throw new Error('cell not found for drag');
await page.mouse.move(src.x + src.width / 2, src.y + src.height / 2);
await page.mouse.down();
// A few intermediate steps are needed or SortableJS treats it as a click.
await page.mouse.move(src.x + src.width / 2 + 10, src.y + src.height / 2, { steps: 5 });
await page.mouse.move(dst.x + dst.width / 2, dst.y + dst.height / 2, { steps: 10 });
await page.mouse.move(dst.x + dst.width / 2 + 1, dst.y + dst.height / 2, { steps: 5 });
await page.mouse.up();
}
// ── E1: happy add — a photo added through the editor renders in the grid ──────
test('E1: adding a photo renders it in the grid and clears the status', async ({ page }) => {
await installMockApi(page, { photos: [], addName: 'photo-01.jpg' });
await openEditor(page);
await expect(page.locator('.photo-editor__empty')).toBeVisible();
await page.locator('.photo-editor__input').setInputFiles(TEST_PHOTO);
await expect(cells(page)).toHaveCount(1, { timeout: 15_000 });
await expect(cells(page).first()).toHaveAttribute('data-filename', 'photo-01.jpg');
await expect(status(page)).toHaveText('');
});
// ── E2: auth-expiry on add (commit 7ffd75e #1) ────────────────────────────────
test('E2: a 401 while adding surfaces the "sign in again" copy', async ({ page }) => {
await installMockApi(page, { photos: [], status: { add: 401 } });
await openEditor(page);
await page.locator('.photo-editor__input').setInputFiles(TEST_PHOTO);
await expect(status(page)).toContainText('login session expired', { timeout: 15_000 });
await expect(status(page)).toContainText('Sign in again');
await expect(status(page)).toHaveClass(/error/);
});
// ── E3: incomplete-rollback warning (commit 7ffd75e #6) — highest value ───────
// Upload succeeds, the post-upload reorder fails (twice), and the rollback
// DELETE also fails, so cleanup is incomplete and a stray file may remain.
test('E3: failed reorder + failed cleanup after add warns "cleanup was incomplete"', async ({ page }) => {
await installMockApi(page, {
photos: [],
addName: 'stray-stock.jpg',
status: { reorder: 500, delete: 500 },
});
await openEditor(page);
await page.locator('.photo-editor__input').setInputFiles(TEST_PHOTO);
await expect(status(page)).toContainText('cleanup was incomplete', { timeout: 20_000 });
await expect(status(page)).toContainText('reload the page');
await expect(status(page)).toHaveClass(/error/);
});
// ── E4: delete failure (500) leaves the photo in place ────────────────────────
test('E4: a 500 on delete keeps the photo and shows a retry-able error', async ({ page }) => {
await installMockApi(page, { photos: ['photo-01.jpg', 'photo-02.jpg'], status: { delete: 500 } });
await openEditor(page);
await expect(cells(page)).toHaveCount(2);
await deleteCell(page, 0);
await expect(status(page)).toContainText('Couldnt delete that photo', { timeout: 15_000 });
await expect(status(page)).toHaveClass(/error/);
// The photo must survive a failed delete.
await expect(cells(page)).toHaveCount(2);
});
// ── E5: auth-expiry on delete (commit 7ffd75e #1) ─────────────────────────────
test('E5: a 401 on delete surfaces the "sign in again" copy and keeps the photo', async ({ page }) => {
await installMockApi(page, { photos: ['photo-01.jpg', 'photo-02.jpg'], status: { delete: 401 } });
await openEditor(page);
await expect(cells(page)).toHaveCount(2);
await deleteCell(page, 0);
await expect(status(page)).toContainText('login session expired', { timeout: 15_000 });
await expect(status(page)).toContainText('sign in again');
await expect(cells(page)).toHaveCount(2);
});
// ── E6: happy reorder — a drag persists the new order via the reorder route ────
test('E6: dragging a photo saves the new order', async ({ page }) => {
const state = await installMockApi(page, { photos: ['photo-01.jpg', 'photo-02.jpg'] });
await openEditor(page);
await expect(cells(page)).toHaveCount(2);
await dragCell(page, 0, 1);
// The reorder route must have been called with the swapped order.
await expect.poll(() => state.reorders.length, { timeout: 15_000 }).toBeGreaterThan(0);
expect(state.reorders[state.reorders.length - 1]).toEqual(['photo-02.jpg', 'photo-01.jpg']);
await expect(status(page)).toHaveText('');
});
// ── E7: auth-expiry on reorder (commit 7ffd75e #1) reverts the drag ───────────
test('E7: a 401 on reorder surfaces the "sign in again" copy and reverts', async ({ page }) => {
await installMockApi(page, { photos: ['photo-01.jpg', 'photo-02.jpg'], status: { reorder: 401 } });
await openEditor(page);
await expect(cells(page)).toHaveCount(2);
await dragCell(page, 0, 1);
await expect(status(page)).toContainText('login session expired', { timeout: 15_000 });
await expect(status(page)).toContainText('sign in again');
// Reverted to the last-known-good order.
await expect(cells(page)).toHaveCount(2);
await expect(cells(page).first()).toHaveAttribute('data-filename', 'photo-01.jpg');
});
+240
View File
@@ -0,0 +1,240 @@
// @ts-check
// Tests: post-form redesign UX — disclosure, HEIC conversion + failure,
// weather-button gating, draft restore. Covers AE1, AE3, AE4 and R18/R20.
const { test, expect } = require('@playwright/test');
const path = require('path');
const fs = require('fs');
const { fillEditor, waitForPhotoUpload, cleanupEntry, findEntry } = require('../helpers');
const TEST_HEIC = path.join(__dirname, '../../fixtures/test-photo.heic');
const TEST_CORRUPT_HEIC = path.join(__dirname, '../../fixtures/test-corrupt.heic');
const TEST_JPG = path.join(__dirname, '../../fixtures/test-photo.jpg');
const TEST_JPG_B = path.join(__dirname, '../../fixtures/test-photo-b.jpg');
const DRAFT_KEY = 'intotheeast:new-entry-draft';
const created = [];
test.afterAll(() => { created.forEach(cleanupEntry); });
// ── AE3: advanced fields sit behind "More options" ────────────────────────────
test('AE3: advanced fields are hidden until "More options" is expanded', async ({ page }) => {
await page.goto('/post');
// The hero-image field was removed (journal heroes come from the first
// uploaded photo); force_connect/featured remain the advanced trio's members.
const details = page.locator('details.more-options');
await expect(details).toBeAttached();
await expect(details).toHaveJSProperty('open', false); // collapsed by default
await page.locator('.more-options__summary').click();
await expect(details).toHaveJSProperty('open', true);
});
// ── AE3b: a toggle that deviates from its default auto-opens "More options" ────
// AE3 covers the common non-deviating case (collapsed on a plain create). This
// covers the OTHER branch of initDisclosure: an advanced toggle whose value
// differs from its blueprint default force-opens the panel so a non-default
// setting is never hidden. It also guards the data-driven default detection —
// initDisclosure reads each toggle's default from the rendered `[checked]`
// attribute rather than a hardcoded field name, so this must hold for a
// default-OFF toggle (featured) flipped ON just as it does for published.
test('AE3b: a non-default advanced toggle auto-expands "More options" on load', async ({ page }) => {
await page.goto('/post');
// Seed a create draft whose `featured` toggle deviates from its OFF default,
// then reload so initDraft restores it before initDisclosure's auto-open check.
await page.evaluate((k) => {
localStorage.setItem(k, JSON.stringify({ 'data[featured]': '1' }));
}, DRAFT_KEY);
await page.reload();
const details = page.locator('details.more-options');
await expect(details).toBeAttached();
await expect(details).toHaveJSProperty('open', true);
// The restored deviation is reflected in the live toggle state.
await expect(page.locator('input[name="data[featured]"][value="1"]')).toBeChecked();
await page.evaluate((k) => localStorage.removeItem(k), DRAFT_KEY);
});
// ── AE1: HEIC → JPEG conversion, posted with a working thumbnail ───────────────
test('AE1: a HEIC photo is converted to JPEG client-side and posted', async ({ page }) => {
const tag = `heic-${Date.now()}`;
await page.goto('/post');
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
await fillEditor(page, 'HEIC conversion test. Safe to delete.');
await page.locator('input.filepond--browser').setInputFiles(TEST_HEIC);
await waitForPhotoUpload(page, 1); // converted (beforeAddFile) + uploaded via FilePond
// The photo section auto-collapses to a summary bar once the upload settles.
await expect(page.locator('.photos-collapse__summary')).toContainText('1 photo ready');
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 dir = findEntry(tag);
expect(dir, 'Entry folder should exist on disk').toBeTruthy();
const files = fs.readdirSync(dir);
expect(files.some(f => /\.jpe?g$/i.test(f)), 'a JPEG should be posted').toBe(true);
expect(files.some(f => /\.heic$/i.test(f)), 'the original HEIC must NOT be posted').toBe(false);
});
// ── AE4: corrupt HEIC fails closed — it is the only "photo", so submit blocks ──
test('AE4: a corrupt HEIC is blocked (fail-closed) and cannot be posted alone', async ({ page }) => {
await page.goto('/post');
await page.fill('input[name="data[title]"]', `UI Test heicfail-${Date.now()}`);
await fillEditor(page, 'HEIC failure test. Safe to delete.');
await page.locator('input.filepond--browser').setInputFiles(TEST_CORRUPT_HEIC);
// Conversion fails → inline error status, original HEIC never added to FilePond.
await expect(page.locator('.photo-convert-status.form-status--err')).toBeVisible({ timeout: 15_000 });
await expect(page.locator('.btn-post')).toBeEnabled(); // Submit still usable
// The corrupt file was never added, so there are zero photos — the ≥1-photo
// requirement blocks submit, which is exactly what keeps the corrupt HEIC
// (fail-closed) from ever being posted. No entry is created.
await page.locator('.btn-post').evaluate(el => el.click());
await expect(page.locator('.photos-collapse .field-error')).toBeVisible();
await expect(page.locator('.notices.success, .notices.green')).toHaveCount(0);
});
// ── Photo count: at least one is required; the picker caps at 6 ────────────────
test('a post requires at least one photo and the picker allows at most 6', async ({ page }) => {
await page.goto('/post');
// FilePond is configured from the blueprint limit (6).
await page.waitForFunction(
() => window.GravFilePond && window.GravFilePond.getInstances().length > 0,
{ timeout: 10_000 });
expect(await page.evaluate(() => window.GravFilePond.getInstances()[0].maxFiles)).toBe(6);
// Title + content filled, date prefilled, but no photo → submit is blocked
// with an error on the photo section and no success notice.
await page.fill('input[name="data[title]"]', `UI photoreq-${Date.now()}`);
await fillEditor(page, 'photo-required test');
await page.locator('.btn-post').evaluate(el => el.click());
await expect(page.locator('.photos-collapse .field-error')).toContainText('at least one photo');
await expect(page.locator('.notices.success, .notices.green')).toHaveCount(0);
});
// ── Photo section collapses to a summary after upload, re-expands on tap ──────
test('photo section auto-collapses to a summary after upload and re-expands on tap', async ({ page }) => {
await page.goto('/post');
const details = page.locator('details.photos-collapse');
await expect(details).toHaveJSProperty('open', true); // open while empty
await page.locator('input.filepond--browser').setInputFiles(TEST_HEIC);
await waitForPhotoUpload(page, 1);
// Settled → auto-collapsed, summary reflects the ready count.
await expect(details).toHaveJSProperty('open', false);
await expect(page.locator('.photos-collapse__summary')).toContainText('1 photo ready');
// Native <details>: clicking the summary re-expands for review.
await page.locator('.photos-collapse__summary').click();
await expect(details).toHaveJSProperty('open', true);
});
// ── Reorder: uploaded photos are renamed photo-01..NN; no order field leaks ────
test('uploaded photos are renamed photo-01..NN and the order field never hits frontmatter', async ({ page }) => {
const tag = `rename-${Date.now()}`;
await page.goto('/post');
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
await fillEditor(page, `photo rename test ${tag}`);
await page.locator('input.filepond--browser').setInputFiles([TEST_JPG, TEST_JPG_B]);
await waitForPhotoUpload(page, 2);
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 dir = findEntry(tag);
expect(dir, 'Entry folder should exist').toBeTruthy();
const files = fs.readdirSync(dir);
// Server renamed both uploads to the deterministic zero-padded photo-NN scheme (drag order).
expect(files).toContain('photo-01.jpg');
expect(files).toContain('photo-02.jpg');
// The order is sent as a top-level POST key, so it must not appear in frontmatter.
const mdName = files.find(f => /\.md$/.test(f));
const md = fs.readFileSync(path.join(dir, mdName), 'utf-8');
expect(md).not.toContain('photo_order');
});
// ── Date field is a native datetime-local picker, prefilled, and required ─────
test('date field renders as a datetime-local picker, prefilled with now and required', async ({ page }) => {
await page.goto('/post');
const date = page.locator('input[name="data[date]"]');
// Grav's deprecated datetime field used to fall back to a plain text box;
// the theme override renders a real picker instead.
await expect(date).toHaveAttribute('type', 'datetime-local');
// post-form.js prefills the current local time in the value format the
// native input expects (YYYY-MM-DDTHH:MM).
await expect(date).toHaveValue(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
// Clearing it and submitting must be blocked client-side (this is what keeps
// an invalid/empty date from round-tripping to the server and wiping the
// FilePond photo list on a re-render).
await date.fill('');
await page.fill('input[name="data[title]"]', `UI date-${Date.now()}`);
await fillEditor(page, 'datetime picker validation test');
await page.locator('.btn-post').evaluate(el => el.click());
await expect(date).toHaveClass(/field-invalid/);
await expect(page.locator('.notices.success, .notices.green')).toHaveCount(0);
});
// ── R18: Get Weather is gated on coordinates ──────────────────────────────────
test('R18: Get Weather is disabled until Get Location provides coordinates', async ({ page, context }) => {
await context.grantPermissions(['geolocation']);
await context.setGeolocation({ latitude: 35.6812, longitude: 139.7671 });
await page.goto('/post');
await expect(page.locator('#get-weather')).toBeDisabled();
await page.click('#get-location');
await expect(page.locator('input[name="data[lat]"]')).toHaveValue(/35\.68/, { timeout: 5_000 });
await expect(page.locator('#get-weather')).toBeEnabled();
});
// ── R20: text draft survives a reload; photos need re-selecting ────────────────
test('R20: in-progress text is restored after a reload, with a photos hint', async ({ page }) => {
await page.goto('/post');
await page.evaluate((k) => localStorage.removeItem(k), DRAFT_KEY);
const marker = `draft-${Date.now()}`;
await page.fill('input[name="data[title]"]', marker);
await fillEditor(page, `Draft body ${marker}`);
// Nudge an input event so the draft is written, then let it flush.
await page.locator('input[name="data[title]"]').press('End');
await page.waitForTimeout(300);
await page.reload();
await expect(page.locator('input[name="data[title]"]')).toHaveValue(marker);
expect(await page.evaluate(() => window.postFormEditor.value())).toContain(marker);
await expect(page.locator('.photo-reauth-hint')).toBeVisible();
await page.evaluate((k) => localStorage.removeItem(k), DRAFT_KEY);
});
// ── Success confirmation: after a post, show a clear CTA the owner can act on ──
test('post success shows a confirmation with a working "View your journal" link', async ({ page }) => {
const tag = `success-${Date.now()}`;
await page.goto('/post');
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
await fillEditor(page, `success cta test ${tag}`);
// A photo is required to post.
await page.locator('input.filepond--browser').setInputFiles(TEST_JPG);
await waitForPhotoUpload(page, 1);
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 panel = page.locator('.post-success');
await expect(panel).toBeVisible();
const view = panel.locator('.post-success__view');
await expect(view).toBeVisible();
// links into the active trip's journal (resolved from site.active_trip)
await expect(view).toHaveAttribute('href', /\/trips\//);
await expect(panel.locator('.post-success__again')).toBeVisible();
});
+34 -27
View File
@@ -4,7 +4,7 @@
const { test, expect } = require('@playwright/test'); const { test, expect } = require('@playwright/test');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const { waitForFilePondUpload, cleanupEntry, findEntry, readEntryMd, TRACKER_DIR, ACTIVE_TRIP_URL } = require('../helpers'); const { fillEditor, waitForPhotoUpload, cleanupEntry, findEntry, readEntryMd, TRACKER_DIR, ACTIVE_TRIP_URL } = require('../helpers');
const TEST_PHOTO = path.join(__dirname, '../../fixtures/test-photo.jpg'); const TEST_PHOTO = path.join(__dirname, '../../fixtures/test-photo.jpg');
@@ -15,47 +15,42 @@ test.afterAll(() => {
created.forEach(cleanupEntry); created.forEach(cleanupEntry);
}); });
// ── P1: Post without photo ───────────────────────────────────────────────────── // ── P1: A photo is required — a text-only submit is blocked, writing nothing ───
test('P1: post text-only entry → created on disk and visible in trip feed', async ({ page }) => { // Create mode requires ≥1 photo (post-form.js gate). The UX suite asserts the
// inline error + suppressed notice; P1 is the complementary DISK-level guarantee
// that a blocked submit never lands an entry on disk.
test('P1: text-only submit is blocked by the photo gate and creates no entry', async ({ page }) => {
const tag = `p1-${Date.now()}`; const tag = `p1-${Date.now()}`;
const title = `UI Test ${tag}`; const title = `UI Test ${tag}`;
await page.goto('/post'); await page.goto('/post');
await page.fill('input[name="data[title]"]', title); await page.fill('input[name="data[title]"]', title);
await page.fill('textarea[name="data[content]"]', 'Text-only test entry. Safe to delete.'); await fillEditor(page, 'Text-only entry. Should be rejected — no photo.');
await page.fill('input[name="data[location_city]"]', 'Testville'); await page.fill('input[name="data[location_city]"]', 'Testville');
await page.fill('input[name="data[location_country]"]', 'Testland'); await page.fill('input[name="data[location_country]"]', 'Testland');
await page.locator('.btn-post').evaluate(el => el.click()); await page.locator('.btn-post').evaluate(el => el.click());
await page.waitForSelector('.form-messages, .notices', { timeout: 15_000 });
const entryDir = findEntry(tag); // The photo gate fires an inline error and suppresses the success notice.
expect(entryDir, 'Entry folder should exist on disk').toBeTruthy(); await expect(page.locator('.photos-collapse .field-error')).toContainText('at least one photo');
created.push(tag); await expect(page.locator('.notices.success, .notices.green')).toHaveCount(0);
const md = readEntryMd(entryDir); // The crucial guarantee: nothing was written to disk.
expect(md).toContain(tag); expect(findEntry(tag), 'a blocked submit must not create an entry').toBeNull();
// No photo expected
const photos = fs.readdirSync(entryDir).filter(f => /\.(jpg|jpeg|png|webp|heic)$/i.test(f));
expect(photos.length, 'Text-only entry should have no photos').toBe(0);
await page.goto(ACTIVE_TRIP_URL);
await expect(page.locator('body')).toContainText(tag);
}); });
// ── P2: Post with photo ──────────────────────────────────────────────────────── // ── P2: Post with photo ────────────────────────────────────────────────────────
test.skip('P2: post entry with photo → photo saved in entry folder and visible in trip feed', async ({ page }) => { test('P2: post entry with photo → photo saved in entry folder and visible in trip feed', async ({ page }) => {
const tag = `p2-${Date.now()}`; const tag = `p2-${Date.now()}`;
const title = `UI Test ${tag}`; const title = `UI Test ${tag}`;
await page.goto('/post'); await page.goto('/post');
await page.fill('input[name="data[title]"]', title); await page.fill('input[name="data[title]"]', title);
await page.fill('textarea[name="data[content]"]', 'Photo test entry. Safe to delete.'); await fillEditor(page, 'Photo test entry. Safe to delete.');
await page.fill('input[name="data[location_city]"]', 'Testville'); await page.fill('input[name="data[location_city]"]', 'Testville');
await page.fill('input[name="data[location_country]"]', 'Testland'); await page.fill('input[name="data[location_country]"]', 'Testland');
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO); await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
await waitForFilePondUpload(page); await waitForPhotoUpload(page);
await page.locator('.btn-post').evaluate(el => el.click()); await page.locator('.btn-post').evaluate(el => el.click());
await page.waitForSelector('.form-messages, .notices', { timeout: 15_000 }); await page.waitForSelector('.form-messages, .notices', { timeout: 15_000 });
@@ -81,9 +76,11 @@ test('P3: post entry with city/country → frontmatter contains location', async
await page.goto('/post'); await page.goto('/post');
await page.fill('input[name="data[title]"]', title); await page.fill('input[name="data[title]"]', title);
await page.fill('textarea[name="data[content]"]', 'Location test. Safe to delete.'); await fillEditor(page, 'Location test. Safe to delete.');
await page.fill('input[name="data[location_city]"]', 'Kyoto'); await page.fill('input[name="data[location_city]"]', 'Kyoto');
await page.fill('input[name="data[location_country]"]', 'Japan'); await page.fill('input[name="data[location_country]"]', 'Japan');
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
await waitForPhotoUpload(page);
await page.locator('.btn-post').evaluate(el => el.click()); await page.locator('.btn-post').evaluate(el => el.click());
await page.waitForSelector('.form-messages, .notices', { timeout: 15_000 }); await page.waitForSelector('.form-messages, .notices', { timeout: 15_000 });
@@ -103,13 +100,15 @@ test('P4: post entry with lat/lng → coordinates saved in frontmatter', async (
await page.goto('/post'); await page.goto('/post');
await page.fill('input[name="data[title]"]', title); await page.fill('input[name="data[title]"]', title);
await page.fill('textarea[name="data[content]"]', 'GPS test. Safe to delete.'); await fillEditor(page, 'GPS test. Safe to delete.');
// lat/lng fields are CSS-hidden (designed to be filled by the Get Location button); // lat/lng fields are CSS-hidden (designed to be filled by the Get Location button);
// set values directly via JS to simulate what the button would do. // set values directly via JS to simulate what the button would do.
await page.evaluate(() => { await page.evaluate(() => {
document.querySelector('input[name="data[lat]"]').value = '35.6762'; document.querySelector('input[name="data[lat]"]').value = '35.6762';
document.querySelector('input[name="data[lng]"]').value = '139.6503'; document.querySelector('input[name="data[lng]"]').value = '139.6503';
}); });
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
await waitForPhotoUpload(page);
await page.locator('.btn-post').evaluate(el => el.click()); await page.locator('.btn-post').evaluate(el => el.click());
await page.waitForSelector('.form-messages, .notices', { timeout: 15_000 }); await page.waitForSelector('.form-messages, .notices', { timeout: 15_000 });
@@ -142,7 +141,9 @@ test('P6: successful submit shows "Entry posted successfully!" message', async (
const tag = `p6-${Date.now()}`; const tag = `p6-${Date.now()}`;
await page.goto('/post'); await page.goto('/post');
await page.fill('input[name="data[title]"]', `UI Test ${tag}`); await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
await page.fill('textarea[name="data[content]"]', 'P6 test. Safe to delete.'); await fillEditor(page, 'P6 test. 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 page.locator('.btn-post').evaluate(el => el.click());
await expect(page.locator('.form-messages, .notices')).toContainText( await expect(page.locator('.form-messages, .notices')).toContainText(
'Entry posted successfully!', { timeout: 15_000 } 'Entry posted successfully!', { timeout: 15_000 }
@@ -158,7 +159,9 @@ test('P7: submitted entry is saved with a date within 5 minutes of now', async (
const tag = `p7-${Date.now()}`; const tag = `p7-${Date.now()}`;
await page.goto('/post'); await page.goto('/post');
await page.fill('input[name="data[title]"]', `UI Test ${tag}`); await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
await page.fill('textarea[name="data[content]"]', 'P7 date test. Safe to delete.'); await fillEditor(page, 'P7 date test. 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 page.locator('.btn-post').evaluate(el => el.click());
await expect(page.locator('.form-messages, .notices')).toContainText( await expect(page.locator('.form-messages, .notices')).toContainText(
'Entry posted successfully!', { timeout: 15_000 } 'Entry posted successfully!', { timeout: 15_000 }
@@ -184,13 +187,17 @@ test('P8: title and content fields are empty after a successful submit', async (
const tag = `p8-${Date.now()}`; const tag = `p8-${Date.now()}`;
await page.goto('/post'); await page.goto('/post');
await page.fill('input[name="data[title]"]', `UI Test ${tag}`); await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
await page.fill('textarea[name="data[content]"]', 'P8 reset test. Safe to delete.'); await fillEditor(page, 'P8 reset test. 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 page.locator('.btn-post').evaluate(el => el.click());
await expect(page.locator('.form-messages, .notices')).toContainText( await expect(page.locator('.form-messages, .notices')).toContainText(
'Entry posted successfully!', { timeout: 15_000 } 'Entry posted successfully!', { timeout: 15_000 }
); );
// After reset, the form fields should be empty // After reset, the form fields should be empty. The content textarea is
// hidden by EasyMDE, so check the editor value via the exposed instance.
await expect(page.locator('input[name="data[title]"]')).toHaveValue(''); await expect(page.locator('input[name="data[title]"]')).toHaveValue('');
await expect(page.locator('textarea[name="data[content]"]')).toHaveValue(''); await page.waitForFunction(() => window.postFormEditor != null, { timeout: 10_000 });
expect(await page.evaluate(() => window.postFormEditor.value())).toBe('');
created.push(tag); created.push(tag);
}); });
+80
View File
@@ -0,0 +1,80 @@
// @ts-check
// Tests: UG1UG2 — the create form must never submit while a photo is not
// fully uploaded (BUG 2026-07-09: a fast save after adding a picture posted a
// text-only entry; the photo was silently dropped).
//
// The form plugin's own submit guard (filepond-handler.js) only blocks the
// PROCESSING / PROCESSING_QUEUED states. Two states slip through it:
// - UG1: LOADING — the moment between picking a file and it entering the
// upload queue (the "too quick" click). Guarded here with a slowed upload.
// - UG2: PROCESSING_ERROR — a failed upload keeps its thumbnail, passes the
// ≥1-photo validation, and the form posts without the file. This is the
// silent-data-loss path.
// post-form.js owns the complete gate (theme code; the form plugin is
// GPM-managed and not patchable in-repo).
//
// The gate lives in e17a5dc: submit is blocked unless EVERY FilePond item is
// processing-complete, with distinct messages for the failed and still-uploading
// cases. Both assert on .photo-convert-status, which post-form.js's setStatus()
// creates via photoStatusEl() — so a passing expectation here proves the THEME
// gate fired, not the form plugin's, whose own guard only raises alert().
const { test, expect } = require('@playwright/test');
const { fillEditor, findEntry, cleanupEntry, TEST_PHOTO } = require('../helpers');
// FilePond uploads go to the form route with .json + the file-upload task
// (Form.php:1183: withExtension('json')->withGravParam('task','file-upload')),
// i.e. /post.json/task:file-upload — the task is a PATH segment, so a glob
// with a non-slash-crossing `*` misses it; match by regex instead.
const UPLOAD_URL = /\/post\.json\//;
const created = [];
test.afterAll(() => created.forEach(cleanupEntry));
async function fillCreateForm(page, tag) {
await page.goto('/post');
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
await fillEditor(page, `Upload-gate fixture ${tag}. Safe to delete.`);
}
// ── UG1: submit while the upload is still in flight is blocked ────────────────
test('UG1: submitting while a photo upload is in flight is blocked with a message', async ({ page }) => {
const tag = `ug1-${Date.now()}`;
// Slow the upload down so the submit click lands mid-flight.
await page.route(UPLOAD_URL, async (route) => {
await new Promise((r) => setTimeout(r, 6000));
await route.continue();
});
await fillCreateForm(page, tag);
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
// The item exists but cannot have finished uploading (route is held).
await page.waitForSelector('.filepond--item');
await page.locator('.btn-post').evaluate((el) => el.click());
created.push(tag);
// Blocked: visible feedback, no success notice, nothing written to disk.
await expect(page.locator('.photo-convert-status')).toContainText(/uploading/i);
await expect(page.locator('.notices.success')).toHaveCount(0);
expect(findEntry(tag), 'no entry may be created mid-upload').toBeNull();
});
// ── UG2: submit with a FAILED upload is blocked, not silently posted ──────────
test('UG2: submitting after a photo upload failed is blocked with an error', async ({ page }) => {
const tag = `ug2-${Date.now()}`;
// Make the upload fail server-side (transient network/limit failure).
await page.route(UPLOAD_URL, (route) => route.fulfill({ status: 500, body: 'nope' }));
await fillCreateForm(page, tag);
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
// Wait for FilePond to mark the item as failed.
await page.waitForSelector('.filepond--item[data-filepond-item-state*="error"]', { timeout: 20_000 });
await page.locator('.btn-post').evaluate((el) => el.click());
created.push(tag);
// Blocked: the error is surfaced, the form did not post, no disk write.
await expect(page.locator('.photo-convert-status')).toContainText(/failed/i);
await expect(page.locator('.notices.success')).toHaveCount(0);
expect(findEntry(tag), 'a failed upload must never produce a photo-less entry').toBeNull();
});
+17 -20
View File
@@ -2,6 +2,7 @@
// Tests: V1V4 — form validation and input constraints // Tests: V1V4 — form validation and input constraints
const { test, expect } = require('@playwright/test'); const { test, expect } = require('@playwright/test');
const path = require('path'); const path = require('path');
const { fillEditor } = require('../helpers');
const TEST_PHOTO = path.join(__dirname, '../../fixtures/test-photo.jpg'); const TEST_PHOTO = path.join(__dirname, '../../fixtures/test-photo.jpg');
const TEST_NONIMAGE = path.join(__dirname, '../../fixtures/test-nonimage.txt'); const TEST_NONIMAGE = path.join(__dirname, '../../fixtures/test-nonimage.txt');
@@ -10,7 +11,7 @@ const TEST_NONIMAGE = path.join(__dirname, '../../fixtures/test-nonimage.txt');
test('V1: submit without title shows a validation error or stays on /post', async ({ page }) => { test('V1: submit without title shows a validation error or stays on /post', async ({ page }) => {
await page.goto('/post'); await page.goto('/post');
// Leave title empty, fill only content // Leave title empty, fill only content
await page.fill('textarea[name="data[content]"]', 'Content without a title.'); await fillEditor(page, 'Content without a title.');
await page.locator('.btn-post').evaluate(el => el.click()); await page.locator('.btn-post').evaluate(el => el.click());
// Grav either shows an error message OR re-renders the form (stays on /post). // Grav either shows an error message OR re-renders the form (stays on /post).
@@ -24,7 +25,7 @@ test('V1: submit without title shows a validation error or stays on /post', asyn
test('V2: submit without content shows a validation error or stays on /post', async ({ page }) => { test('V2: submit without content shows a validation error or stays on /post', async ({ page }) => {
await page.goto('/post'); await page.goto('/post');
await page.fill('input[name="data[title]"]', 'V2 title no content'); await page.fill('input[name="data[title]"]', 'V2 title no content');
// Leave content (textarea) empty // Leave content (editor) empty
await page.locator('.btn-post').evaluate(el => el.click()); await page.locator('.btn-post').evaluate(el => el.click());
await page.waitForTimeout(2_000); await page.waitForTimeout(2_000);
@@ -32,30 +33,27 @@ test('V2: submit without content shows a validation error or stays on /post', as
expect(bodyText).not.toContain('Entry posted successfully'); expect(bodyText).not.toContain('Entry posted successfully');
}); });
// ── V3: Photo limit (max 4) ─────────────────────────────────────────────────── // ── V3: Photo limit (max 6) ───────────────────────────────────────────────────
test('V3: filepond rejects a 5th photo when limit is 4', async ({ page }) => { // The blueprint limit is 6 (asserted directly as maxFiles===6 in the UX suite);
// this is the behavioral counterpart — the picker must refuse a 7th attachment.
test('V3: FilePond caps attachments at the limit (6)', async ({ page }) => {
await page.goto('/post'); await page.goto('/post');
const browser = page.locator('input.filepond--browser');
// Upload 4 photos (all the same fixture — we just need 4 items) // Attach 6 photos (same fixture — we only need six items).
const fourPhotos = [TEST_PHOTO, TEST_PHOTO, TEST_PHOTO, TEST_PHOTO]; await browser.setInputFiles([TEST_PHOTO, TEST_PHOTO, TEST_PHOTO, TEST_PHOTO, TEST_PHOTO, TEST_PHOTO]);
await page.locator('input.filepond--browser').setInputFiles(fourPhotos);
// Wait for all 4 items to appear
await page.waitForFunction(() => await page.waitForFunction(() =>
document.querySelectorAll('.filepond--item').length === 4, document.querySelectorAll('.filepond--item').length === 6, { timeout: 10_000 });
{ timeout: 10_000 }
);
// Attempt a 5th — filepond should ignore it once the limit is reached // A 7th is ignored once the limit is reached.
await page.locator('input.filepond--browser').setInputFiles([TEST_PHOTO]); await browser.setInputFiles([TEST_PHOTO]);
await page.waitForTimeout(500); await page.waitForTimeout(500);
const itemCount = await page.locator('.filepond--item').count(); expect(await page.locator('.filepond--item').count()).toBe(6);
expect(itemCount).toBe(4);
}); });
// ── V4: Non-image file rejected ─────────────────────────────────────────────── // ── V4: Non-image file rejected ───────────────────────────────────────────────
test('V4: filepond rejects non-image files', async ({ page }) => { test('V4: FilePond rejects a non-image file', async ({ page }) => {
await page.goto('/post'); await page.goto('/post');
await page.locator('input.filepond--browser').setInputFiles(TEST_NONIMAGE); await page.locator('input.filepond--browser').setInputFiles(TEST_NONIMAGE);
@@ -63,13 +61,12 @@ test('V4: filepond rejects non-image files', async ({ page }) => {
const items = page.locator('.filepond--item'); const items = page.locator('.filepond--item');
const count = await items.count(); const count = await items.count();
if (count > 0) { if (count > 0) {
// If filepond added it, it must show an error state — not processing-complete // If added, it must not reach processing-complete.
const state = await items.first().getAttribute('data-filepond-item-state'); const state = await items.first().getAttribute('data-filepond-item-state');
expect(state).not.toBe('processing-complete'); expect(state).not.toBe('processing-complete');
} else { } else {
// Silently rejected before adding — also a pass // Silently rejected before adding — also a pass.
expect(count).toBe(0); expect(count).toBe(0);
} }
}); });
+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);
});
+109
View File
@@ -0,0 +1,109 @@
// @ts-check
// Tests: U4 — trip-page in-column header extras (R8, R9, R10, R13)
const { test, expect } = require('@playwright/test');
const TRIP_URL = '/trips/italy-2026-demo';
const topOf = async (locator) => (await locator.boundingBox()).y;
// ── R8/R9: extras render in HTD order, banner sits above the filter bar ────────
test('U4/R8+R9: one-liner, description and banner stack in order above the filter bar', async ({ page }) => {
await page.goto(TRIP_URL);
const header = page.locator('.home-trip-header');
await expect(header.locator('.home-trip-tagline')).toHaveText(/southern Tuscany by bike/);
const yTitle = await topOf(page.locator('.home-trip-name'));
const yTag = await topOf(page.locator('.home-trip-tagline'));
const yCounts = await topOf(page.locator('.home-trip-counts'));
const yDesc = await topOf(page.locator('.trip-header-desc'));
const yBanner = await topOf(page.locator('.trip-header-banner'));
const yFilter = await topOf(page.locator('.trip-filter-bar'));
expect(yTitle).toBeLessThan(yTag); // one-liner directly below the title
expect(yTag).toBeLessThan(yCounts);
expect(yCounts).toBeLessThan(yDesc); // description below the counts
expect(yDesc).toBeLessThan(yBanner); // banner below the description
expect(yBanner).toBeLessThan(yFilter); // ...and above the filter bar (R9)
});
// ── R8/R13: description shows a collapsed preview and expands on demand ────────
test('U4/R13: description is clamped to a preview and expands to full text', async ({ page }) => {
await page.goto(TRIP_URL);
const desc = page.locator('.trip-header-desc');
const body = page.locator('.trip-header-desc-body');
const btn = page.locator('.trip-header-desc-toggle');
await expect(desc).toHaveAttribute('data-collapsed', 'true');
await expect(btn).toBeVisible();
// Collapsed: the body is genuinely clamped — visible height is shorter than
// its full content (the max-height:4.8em preview actually hides overflow).
const clampedWhenCollapsed = await body.evaluate((el) => el.clientHeight < el.scrollHeight);
expect(clampedWhenCollapsed).toBe(true);
const collapsedH = (await body.boundingBox()).height;
await btn.click();
await expect(desc).toHaveAttribute('data-collapsed', 'false');
await expect(btn).toHaveText('Show less');
const expandedH = (await body.boundingBox()).height;
expect(expandedH).toBeGreaterThan(collapsedH);
// Expanded: the clamp is gone — the full text is now actually visible, not
// merely present in the DOM (which it was even while collapsed).
const unclampedWhenExpanded = await body.evaluate((el) => el.clientHeight >= el.scrollHeight - 1);
expect(unclampedWhenExpanded).toBe(true);
});
// ── R9/AE3: banner uses the first journal image (no cover_image set) ───────────
test('U4/R9/AE3: banner falls back to the first journal entry image', async ({ page }) => {
await page.goto(TRIP_URL);
const img = page.locator('.trip-header-banner img');
await expect(img).toBeVisible();
const srcset = await img.getAttribute('srcset');
expect(srcset).toContain('720w');
// The 1200px landscape source can't supply a non-upscaled 2x (needs ≥1440),
// so the retina descriptor is omitted entirely — 1x only, no blurry upscale
// and no odd intermediate width.
expect(srcset).not.toContain('1440w');
expect(srcset).not.toContain('1200w');
// cropZoom hands over a wide cover strip (~3.27:1); the old cropResize gave
// a fit-inside image the object-fit:cover box then upscaled into a blur.
const ratio = await img.evaluate((el) => new Promise((res) => {
const done = () => res(el.naturalWidth / el.naturalHeight);
el.complete && el.naturalWidth ? done() : el.addEventListener('load', done, { once: true });
}));
expect(ratio).toBeGreaterThan(3);
await expect(img).toHaveAttribute('alt', 'Tuscany 2026');
});
// ── Regression: a portrait fallback source (the reported us-canada-mex-2024
// banner) must render a sharp wide cover strip, not a blurry upscaled sliver ─
test('U4/R9: a portrait fallback source renders a wide cover strip, not a sliver', async ({ page }) => {
await page.goto('/trips/us-canada-mex-2024');
const img = page.locator('.trip-header-banner img');
await expect(img).toBeVisible();
const srcset = await img.getAttribute('srcset');
expect(srcset).toContain('720w');
// 1013px portrait source — nowhere near ≥1440 — so no upscaled 2x candidate.
expect(srcset).not.toContain('1440w');
// cropZoom crops the tall portrait into a wide cover strip (~3.27:1); the old
// cropResize fit it inside as a ~165px sliver the box then upscaled to a blur.
const ratio = await img.evaluate((el) => new Promise((res) => {
const done = () => res(el.naturalWidth / el.naturalHeight);
el.complete && el.naturalWidth ? done() : el.addEventListener('load', done, { once: true });
}));
expect(ratio).toBeGreaterThan(3);
});
// ── R10/AE6: the map+journal split is intact with no header above it ───────────
test('U4/R10/AE6: map+journal split renders with the extras inside the feed column', async ({ page }) => {
await page.goto(TRIP_URL);
await expect(page.locator('.home-layout')).toBeVisible();
await expect(page.locator('.home-layout > .home-map-col')).toBeVisible();
await expect(page.locator('.home-layout > .home-feed-col')).toBeVisible();
// extras live inside the feed column, not as a new header above the split
await expect(page.locator('.home-feed-col .trip-header-banner')).toHaveCount(1);
await expect(page.locator('.home-feed-col .home-trip-tagline')).toHaveCount(1);
// the header extras never appear outside the two-column layout
await expect(page.locator('body > .trip-header-banner, .home-layout ~ .trip-header-banner')).toHaveCount(0);
});
+353
View File
@@ -0,0 +1,353 @@
// @ts-check
// Tests: TP1, TP1b, TP2TP8 — the owner trip publish/unpublish toggle on the
// /trips listing (U7). Covers the owner gate, coverless drafts, cache-correct
// hide/restore, the active-trip confirm, backend authz, the home fallback, the
// client failure/toast path (R15), and the in-flight double-submit lock (R13).
//
// Owner identity (doc-review P1): the harness authenticates as GRAV_TEST_USER,
// but committed site.yaml sets owner_username: mischa, and EntryScopeGuard is a
// strict username match. So this suite PINS site.owner_username to the
// authenticated test user (restore on teardown) rather than assuming the
// committed value. TP5's 403 leg derives a non-owner by briefly overriding
// owner_username to a value the test user does not match.
//
// Config is read fresh per request (twig.cache:false), but a NEW page folder is
// only picked up after a page-tree cache clear (the folder-hash staleness class
// of bug fixed in deleteEntry) — so createFixtureTrip / config writes clear the
// cache of the container serving THIS worktree's user dir.
//
// RUN THIS SUITE SERIALLY (`--workers=1` for tests/ui/trip, or run the file on
// its own). It mutates GLOBAL state — site.owner_username / active_trip and the
// shared page-tree cache (the publish endpoint flushes APCu site-wide) — so a
// spec reading the active trip or a trip page in a PARALLEL worker can transiently
// observe the mutated config or a mid-rebuild page. On its own, or serially, it
// is deterministic. This mirrors how home-highlights.spec.js mutates `travelling`
// and coexists only because the home/maps specs skip when it does.
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const { test, expect } = require('@playwright/test');
const BASE = process.env.GRAV_BASE_URL || 'http://localhost:8081';
const OWNER = process.env.GRAV_TEST_USER || 'testrunner';
// ── user dir + container (worktree-safe; two grav containers can run at once) ──
const USER_DIR = process.env.GRAV_USER_DIR
? path.resolve(process.env.GRAV_USER_DIR)
: path.resolve(__dirname, '../../../user');
const SITE_YAML = path.join(USER_DIR, 'config/site.yaml');
const TRIPS_DIR = path.join(USER_DIR, 'pages/01.trips');
function resolveContainer() {
if (process.env.GRAV_CONTAINER) return process.env.GRAV_CONTAINER;
const want = fs.realpathSync(USER_DIR);
const names = execSync("docker ps --format '{{.Names}}'", { encoding: 'utf-8' })
.trim().split(/\r?\n/).filter(Boolean);
for (const c of names) {
try {
const src = execSync(
`docker inspect ${c} --format '{{range .Mounts}}{{if eq .Destination "/var/www/html/user"}}{{.Source}}{{end}}{{end}}'`,
{ encoding: 'utf-8' }
).trim();
if (src && fs.realpathSync(src) === want) return c;
} catch (_) { /* container went away mid-scan */ }
}
return 'intotheeast_grav';
}
const CONTAINER = resolveContainer();
function clearCache() {
execSync(`docker exec ${CONTAINER} sh -c 'cd /var/www/html && php bin/grav clearcache'`, { stdio: 'ignore' });
}
// ── site.yaml patch/restore ───────────────────────────────────────────────────
let originalSite = null; // committed/working-tree state, restored on teardown
let basePatched = null; // originalSite + owner_username pinned to OWNER
function setKey(content, key, val) {
const re = new RegExp(`^${key}:.*$`, 'm');
const line = `${key}: ${val}`;
return re.test(content) ? content.replace(re, line) : `${content.replace(/\n*$/, '')}\n${line}\n`;
}
function writeSite(content) {
fs.writeFileSync(SITE_YAML, content);
clearCache();
}
// ── fixture trips ─────────────────────────────────────────────────────────────
const fixtures = [];
function createFixtureTrip(slug, { published = true } = {}) {
const dir = path.join(TRIPS_DIR, slug);
fs.mkdirSync(path.join(dir, '01.dailies'), { recursive: true });
fs.mkdirSync(path.join(dir, '04.stories'), { recursive: true });
// Coverless by design (no cover_image, no entries) — the most common publish
// target and the state TP1b guards.
fs.writeFileSync(path.join(dir, 'trip.md'),
`---\ntitle: '${slug} fixture'\ntemplate: trip\ndate: '2020-01-01'\ncover_image: ''\npublished: ${published}\n---\n`);
fs.writeFileSync(path.join(dir, '01.dailies/dailies.md'),
'---\ntitle: Journal\ntemplate: default\nroutable: false\nvisible: false\n---\n');
fs.writeFileSync(path.join(dir, '04.stories/stories.md'),
'---\ntitle: Stories\ntemplate: default\nroutable: false\nvisible: false\n---\n');
if (!fixtures.includes(slug)) fixtures.push(slug);
clearCache();
return dir;
}
function readTripPublished(slug) {
const p = path.join(TRIPS_DIR, slug, 'trip.md');
if (!fs.existsSync(p)) return null;
const m = fs.readFileSync(p, 'utf-8').match(/^published:\s*(\S+)/m);
return m ? m[1] : null;
}
function cleanupFixtures() {
let removed = false;
for (const slug of fixtures) {
const dir = path.join(TRIPS_DIR, slug);
if (fs.existsSync(dir)) { fs.rmSync(dir, { recursive: true, force: true }); removed = true; }
}
if (removed) clearCache();
}
// Locators
const cardWrap = (page, slug) => page.locator(`.trip-card-wrap:has(a.trip-card[href="/trips/${slug}"])`);
const toggleFor = (page, slug) => cardWrap(page, slug).locator('.trip-publish-toggle');
// This file mutates shared global config; keep its own tests ordered and reset
// config after each so a per-test override never leaks into the next.
test.describe.configure({ mode: 'serial' });
test.beforeAll(() => {
originalSite = fs.readFileSync(SITE_YAML, 'utf-8');
basePatched = setKey(originalSite, 'owner_username', OWNER);
writeSite(basePatched);
});
test.afterEach(() => { writeSite(basePatched); });
test.afterAll(() => {
if (originalSite != null) writeSite(originalSite);
cleanupFixtures();
});
// ── TP1: owner gate ───────────────────────────────────────────────────────────
test('TP1: owner sees the toggle + drafts; anon sees neither', async ({ page, browser }) => {
const pub = `tp1pub-${Date.now()}`;
const draft = `tp1draft-${Date.now()}`;
createFixtureTrip(pub, { published: true });
createFixtureTrip(draft, { published: false });
// Owner: toggle present, draft trip visible + badged.
await page.goto('/trips');
await expect(toggleFor(page, pub)).toHaveCount(1);
await expect(toggleFor(page, pub)).toHaveAttribute('aria-checked', 'true');
await expect(cardWrap(page, draft)).toHaveCount(1);
await expect(cardWrap(page, draft).locator('.trip-draft-badge')).toBeVisible();
// The switch is an accessible switch identifying the trip.
await expect(toggleFor(page, draft)).toHaveAttribute('role', 'switch');
await expect(toggleFor(page, draft)).toHaveAttribute('aria-label', /fixture/);
// Anon: no toggle anywhere, draft absent, published still visible.
const anon = await browser.newContext({ storageState: { cookies: [], origins: [] }, baseURL: BASE });
const ap = await anon.newPage();
await ap.goto('/trips');
await expect(ap.locator('.trip-publish-toggle')).toHaveCount(0);
await expect(ap.locator(`a.trip-card[href="/trips/${draft}"]`)).toHaveCount(0);
await expect(ap.locator(`a.trip-card[href="/trips/${pub}"]`)).toHaveCount(1);
await anon.close();
});
// ── TP1b: a coverless draft still renders a working toggle ─────────────────────
test('TP1b: a coverless draft still renders a working toggle', async ({ page }) => {
const slug = `tp1b-${Date.now()}`;
createFixtureTrip(slug, { published: false }); // no cover, no entries
await page.goto('/trips');
// No cover image emitted…
await expect(cardWrap(page, slug).locator('.trip-card-cover')).toHaveCount(0);
// …but the toggle still has an anchor and is usable.
const toggle = toggleFor(page, slug);
await expect(toggle).toBeVisible();
await expect(toggle).toHaveAttribute('aria-checked', 'false');
});
// ── TP2: unpublish hides the trip for anon after a fresh load (cache-correct) ──
test('TP2: unpublishing hides the trip for anon after reload', async ({ page, browser }) => {
const slug = `tp2-${Date.now()}`;
createFixtureTrip(slug, { published: true });
await page.goto('/trips');
const toggle = toggleFor(page, slug);
await expect(toggle).toHaveAttribute('aria-checked', 'true');
await toggle.click();
// Optimistic in-place flip + Draft badge, no reload.
await expect(toggle).toHaveAttribute('aria-checked', 'false');
await expect(cardWrap(page, slug).locator('.trip-draft-badge')).toBeVisible();
// Persisted to disk.
await expect.poll(() => readTripPublished(slug), { timeout: 15_000 }).toBe('false');
// Anon fresh load: absent (the endpoint invalidated the page-tree index).
const anon = await browser.newContext({ storageState: { cookies: [], origins: [] }, baseURL: BASE });
const ap = await anon.newPage();
await ap.goto('/trips');
await expect(ap.locator(`a.trip-card[href="/trips/${slug}"]`)).toHaveCount(0);
await anon.close();
// Owner fresh load: still visible, badged as Draft.
await page.goto('/trips');
await expect(cardWrap(page, slug).locator('.trip-draft-badge')).toBeVisible();
});
// ── TP3: republish restores the trip for anon ─────────────────────────────────
test('TP3: republishing a draft restores it for anon', async ({ page, browser }) => {
const slug = `tp3-${Date.now()}`;
createFixtureTrip(slug, { published: false });
await page.goto('/trips');
const toggle = toggleFor(page, slug);
await expect(toggle).toHaveAttribute('aria-checked', 'false');
await toggle.click();
await expect(toggle).toHaveAttribute('aria-checked', 'true');
await expect.poll(() => readTripPublished(slug), { timeout: 15_000 }).toBe('true');
const anon = await browser.newContext({ storageState: { cookies: [], origins: [] }, baseURL: BASE });
const ap = await anon.newPage();
await ap.goto('/trips');
await expect(ap.locator(`a.trip-card[href="/trips/${slug}"]`)).toHaveCount(1);
await anon.close();
});
// ── TP4: dismissing the active-trip confirm leaves it published ───────────────
test('TP4: dismissing the active-trip confirm leaves it published', async ({ page }) => {
const slug = `tp4-${Date.now()}`;
createFixtureTrip(slug, { published: true });
writeSite(setKey(basePatched, 'active_trip', `/trips/${slug}`));
await page.goto('/trips');
const toggle = toggleFor(page, slug);
await expect(toggle).toHaveAttribute('data-active', 'true');
// Dismiss the confirm → no request, stays published.
page.once('dialog', (d) => d.dismiss());
await toggle.click();
await expect(toggle).toHaveAttribute('aria-checked', 'true');
expect(readTripPublished(slug)).toBe('true');
});
// ── TP5: backend authz + non-boolean rejection ────────────────────────────────
test('TP5: publish endpoint enforces 401/403 and rejects a non-boolean body', async ({ page, browser }) => {
const slug = `tp5-${Date.now()}`;
createFixtureTrip(slug, { published: true });
const url = `/api/v1/trip/${slug}/publish`;
// Anonymous → 401, frontmatter unchanged.
const anon = await browser.newContext({ storageState: { cookies: [], origins: [] }, baseURL: BASE });
let r = await anon.request.post(url, { data: { published: false } });
expect(r.status()).toBe(401);
await anon.close();
expect(readTripPublished(slug)).toBe('true');
// Authenticated NON-owner → 403 (briefly make the logged-in user not the owner).
writeSite(setKey(basePatched, 'owner_username', `not-${OWNER}-xyz`));
r = await page.request.post(url, { data: { published: false } });
expect(r.status()).toBe(403);
expect(readTripPublished(slug)).toBe('true');
writeSite(basePatched); // back to owner for the 400 check
// Owner, non-boolean published → 400, frontmatter unchanged.
r = await page.request.post(url, { data: { published: 'false' } });
expect(r.status()).toBe(400);
expect(readTripPublished(slug)).toBe('true');
// Owner, MISSING published key → 400 (the array_key_exists branch, distinct
// from the is_bool branch above), frontmatter unchanged.
r = await page.request.post(url, { data: {} });
expect(r.status()).toBe(400);
expect(readTripPublished(slug)).toBe('true');
});
// ── TP6: an unpublished active trip makes home fall back ───────────────────────
test('TP6: an unpublished active trip falls back to between-trips on home', async ({ page }) => {
const slug = `tp6-${Date.now()}`;
createFixtureTrip(slug, { published: true });
writeSite(setKey(setKey(basePatched, 'active_trip', `/trips/${slug}`), 'travelling', 'true'));
// Published active trip → active-trip mode. The fixture has no entries, so
// active mode renders the pre-departure partial (a between-trips-only
// .home-highlights-header is absent; the predeparture divider is present).
// Both branches carry a .home-highlights-cta, so it is not a discriminator.
// Reload-poll so a config/cache settle after the fixture write can't flake it.
await expect(async () => {
await page.goto('/');
await expect(page.locator('.home-predeparture-divider')).toBeVisible({ timeout: 2_000 });
await expect(page.locator('.home-highlights-header')).toHaveCount(0);
}).toPass({ timeout: 15_000 });
// Unpublish it via the owner endpoint (clears cache).
const r = await page.request.post(`/api/v1/trip/${slug}/publish`, { data: { published: false } });
expect(r.status()).toBe(204);
// Home now falls through to the between-trips highlights state.
await expect(async () => {
await page.goto('/');
await expect(page.locator('.home-highlights-header')).toBeVisible({ timeout: 2_000 });
await expect(page.locator('.home-predeparture-divider')).toHaveCount(0);
}).toPass({ timeout: 15_000 });
});
// ── TP7: a failed publish reverts the switch and surfaces a visible toast ──────
test('TP7: a failed publish reverts the switch and shows a toast (R15)', async ({ page }) => {
const slug = `tp7-${Date.now()}`;
createFixtureTrip(slug, { published: true });
await page.goto('/trips');
const toggle = toggleFor(page, slug);
await expect(toggle).toHaveAttribute('aria-checked', 'true');
// Force the mutation to fail server-side; the request is intercepted so it
// never reaches the endpoint (a generic 5xx → generic "couldn't update" copy).
await page.route('**/api/v1/trip/*/publish', (route) =>
route.fulfill({ status: 500, contentType: 'application/json', body: '{}' }));
await toggle.click();
// The switch never flipped (the optimistic flip only happens on success), so
// "revert" is just re-enabling it; the visible page-level toast appears.
await expect(page.locator('#trip-publish-live')).toBeVisible();
await expect(page.locator('#trip-publish-live')).toContainText("Couldn't update");
await expect(toggle).toHaveAttribute('aria-checked', 'true');
await expect(toggle).toBeEnabled();
// Never persisted (the request was intercepted before the server).
expect(readTripPublished(slug)).toBe('true');
await page.unroute('**/api/v1/trip/*/publish');
});
// ── TP8: the in-flight lock suppresses a concurrent second submit ──────────────
test('TP8: the pending lock suppresses a concurrent second submit (R13)', async ({ page }) => {
const slug = `tp8-${Date.now()}`;
createFixtureTrip(slug, { published: true });
await page.goto('/trips');
const toggle = toggleFor(page, slug);
await expect(toggle).toHaveAttribute('aria-checked', 'true');
// Count and DELAY the mutation so the switch stays in-flight while we click
// again. Fulfilled locally (204), so the server/disk is never touched.
let posts = 0;
await page.route('**/api/v1/trip/*/publish', async (route) => {
posts += 1;
await new Promise((r) => setTimeout(r, 1_000));
route.fulfill({ status: 204, body: '' });
});
await toggle.click();
// In flight: locked (aria-busy + disabled).
await expect(toggle).toHaveAttribute('aria-busy', 'true');
await expect(toggle).toBeDisabled();
// A second click during the in-flight window must NOT fire a second POST.
await toggle.click({ force: true });
// First request settles → optimistic flip + unlock; exactly one POST fired.
await expect(toggle).toHaveAttribute('aria-checked', 'false');
await expect(toggle).toBeEnabled();
expect(posts).toBe(1);
await page.unroute('**/api/v1/trip/*/publish');
});
+69
View File
@@ -0,0 +1,69 @@
// @ts-check
// Tests: U3 — trip-list card one-liner + retina cover (R5, R6, R7, R14)
// R11 (set-but-unresolvable cover_image falls back) shares the exact else-branch
// exercised by the R7/AE3 fallback test below; it is covered by construction in
// the shared cover macro rather than by a dedicated fixture here.
const { test, expect } = require('@playwright/test');
const DEMO_HREF = '/trips/italy-2026-demo'; // has a tagline, no cover_image (entry-image fallback)
const NO_TAGLINE_HREF = '/trips/slovenia-2024'; // a real trip with no tagline
const demoCard = (page) => page.locator(`.trip-card[href="${DEMO_HREF}"]`);
// ── R5: one-liner renders between the title and the meta line ──────────────────
test('U3/R5: card with a tagline shows a one-liner between title and meta', async ({ page }) => {
await page.goto('/trips');
const card = demoCard(page);
const tagline = card.locator('.trip-card-tagline');
await expect(tagline).toBeVisible();
await expect(tagline).toHaveText(/southern Tuscany by bike/);
const order = await card.evaluate((el) =>
Array.from(el.children).map((c) => c.className.split(' ')[0])
);
expect(order.indexOf('trip-card-title')).toBeLessThan(order.indexOf('trip-card-tagline'));
expect(order.indexOf('trip-card-tagline')).toBeLessThan(order.indexOf('trip-card-meta'));
});
// ── R5/AE1: a card with no tagline renders no one-liner element ────────────────
test('U3/R5/AE1: card without a tagline renders no one-liner element', async ({ page }) => {
await page.goto('/trips');
const card = page.locator(`.trip-card[href="${NO_TAGLINE_HREF}"]`);
await expect(card).toBeVisible();
await expect(card.locator('.trip-card-tagline')).toHaveCount(0);
});
// ── R6/AE5: card cover exposes a 1x srcset; 2x only when the source is ≥2×w ────
test('U3/R6/AE5: card cover img carries a 720w srcset, 2x omitted for a narrow source', async ({ page }) => {
await page.goto('/trips');
const img = demoCard(page).locator('.trip-card-cover img');
const srcset = await img.getAttribute('srcset');
expect(srcset).toContain('720w');
// The 1200px source can't supply a non-upscaled 2x (needs ≥1440), so the
// retina descriptor is omitted — 1x only, no upscale, no intermediate width.
expect(srcset).not.toContain('1440w');
expect(srcset).not.toContain('1200w');
// cropZoom cover strip (~3:1), not a cropResize fit-inside sliver.
const ratio = await img.evaluate((el) => new Promise((res) => {
const done = () => res(el.naturalWidth / el.naturalHeight);
el.complete && el.naturalWidth ? done() : el.addEventListener('load', done, { once: true });
}));
expect(ratio).toBeGreaterThan(2.5);
});
// ── R7/AE3: with no cover_image set, the card falls back to a journal image ────
test('U3/R7/AE3: card with no cover_image uses the first journal entry image', async ({ page }) => {
await page.goto('/trips');
// The demo trip sets cover_image: '' so the cover comes from the fallback.
const img = demoCard(page).locator('.trip-card-cover img');
await expect(img).toBeVisible();
const src = await img.getAttribute('src');
expect(src).toMatch(/\/images\/.+\.(jpg|jpeg|png|webp)/i);
});
// ── R14: cover alt text equals the trip title ─────────────────────────────────
test('U3/R14: card cover alt equals the trip title', async ({ page }) => {
await page.goto('/trips');
const img = demoCard(page).locator('.trip-card-cover img');
await expect(img).toHaveAttribute('alt', 'Tuscany 2026');
});
+4 -1
View File
@@ -3,5 +3,8 @@
{ {
"path": "." "path": "."
} }
] ],
"settings": {
"makefile.configureOnOpen": false
}
} }
+1 -1
Submodule user updated: 2e32a8559c...1b9e51baf7