Compare commits

...
5 Commits
Author SHA1 Message Date
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 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
8 changed files with 855 additions and 6 deletions
+56 -6
View File
@@ -16,7 +16,7 @@
### Current stack
- **Grav:** 2.0.7 stable (baked into the custom Docker image via `Dockerfile`; server upgrades in place via `bin/gpm self-upgrade`)
- **Admin:** Admin2 v2.0.10 (plugin slug: `admin2`, NOT `admin`)
- **Admin:** Admin2 v2.0.12 (plugin slug: `admin2`, NOT `admin`)
- **GPM channel:** `stable` — set in `user/config/system.yaml``gpm.releases` (authoritative). `GRAV_CHANNEL=production` in `docker-compose.yml` is cosmetic/consistency only
- **Plugin management:** `admin2`, `api`, and `flex-objects` are now **GPM-managed via `plugins.txt`** (installed by `make install-plugins`), no longer hand-extracted from the core bundle. `git-sync` stays **remote-only** — never in `plugins.txt`
- **Docker image:** `getgrav/grav` with `GRAV_CHANNEL=production`
@@ -24,12 +24,43 @@
### Dev server
The Docker dev server runs at **http://localhost:8081** (mapped from container port 80 in `docker-compose.yml`).
The Docker dev server runs at **http://localhost:8081** (mapped from container port 80 in `docker-compose.yml`). A second service, `travel-memories`, runs at **http://localhost:8082**. Both ports and the container name are overridable via `GRAV_PORT` / `TM_PORT` / `GRAV_CONTAINER` — a worktree's `.worktree-env` sets these so isolated servers never collide (see "Dual-repo submodule structure").
### Local dev commands
| Command | What it does |
|---|---|
| `make setup` | One-shot first run: `build``start``install-plugins``fix-perms` |
| `make start` / `make stop` | Bring the compose stack up / down (`start-grav` = Grav service only) |
| `make build` | Rebuild the custom Docker image (after `Dockerfile` changes) |
| `make build-assets` | Rebuild theme JS/CSS bundles in a `node:20-alpine` container |
| `make install-plugins` | GPM-install everything in `plugins.txt`, then re-apply local patches |
| `make fix-perms` | Fix ownership in the container after root-owned writes |
**`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.
### Custom plugins
Three plugins are site-owned and tracked in the `user/` repo (everything else under `user/plugins/` is GPM-managed and git-ignored):
| Plugin | Role |
|---|---|
| `cache-on-save` | Clears the page-tree cache on `new-entry` submits, and derives the write target from `site.active_trip` (`onFormValidationProcessed``setData('parent', …)`) |
| `story-blocks` | Storytelling shortcode blocks for long-form stories (depends on `shortcode-core`) |
| `entry-actions` | Owner-only, active-trip-scoped journal entry actions (delete) via the Grav API |
### Local plugin patches
Third-party plugins live in the **git-ignored** `user/plugins/`, so local fixes to them do not travel with the content repo and are **overwritten by `make install-plugins`** or a fresh image build. Keep the fix as a tracked patch in `deploy/patches/` instead:
- `make apply-plugin-patches` — idempotent `git apply` (skips already-applied patches). `make install-plugins` runs it automatically as its last step
- `make remote-apply-plugin-patches-test` / `-prod` — piped over SSH into `patch -p1 --forward`; also runs automatically after a remote plugin install
- Details and the current patch list: `deploy/patches/README.md`
### 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`
- Active trip is set in `user/config/site.yaml``active_trip` (currently `/trips/denmark-2026`). The value is a **route**, not a bare slug
- Trip pages live at `user/pages/01.trips/<slug>/`
- Each trip has two content subfolders: `01.dailies/` (journal entries) and `04.stories/` (stories). The former `02.map/` and `03.stats/` standalone views were **removed** (2026-07-04, see `docs/working/plans/2026-07-04-standalone-page-cleanup.md`) — map and stats now render inline on the trip page
- `01.dailies/` and `04.stories/` are `routable:false` **data containers** — visiting `/trips/<slug>/dailies` or `/stories` directly 404s/redirects; their children (entries/stories) render at their own detail URLs and are aggregated by the trip page
@@ -141,12 +172,13 @@ For a full upgrade/deploy through local → test → prod (ordered steps, smoke
- `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)
- `make demo-load` — load **every** fixture trip under `user/docs/demo/trips/` into the pages tree (currently `italy-2026-demo` and `no-photos-demo`). Add a new fixture by dropping a trip folder there; no Makefile edit needed
- `make demo-reset` — remove the demo trips' pages folders and clear cache (full reset; re-run `demo-load` to restore)
- `make pixelfed-import` — import posts from Pixelfed via `scripts/pixelfed-import.py`
### User repo gitignore
Only these folders are tracked in the `user/` Git repo: `pages/`, `config/`, `accounts/`, `themes/`. The `plugins/` and `data/` folders are excluded.
Only these folders are tracked in the `user/` Git repo: `pages/`, `config/`, `accounts/`, `themes/`. The `plugins/` and `data/` folders are excluded**except** the three site-owned plugins, which are un-ignored explicitly (see "Custom plugins" below). Also ignored: the test accounts, `italy-2026-demo` pages, secrets (`config/plugins/git-sync.yaml`, `config/security.yaml`, `api-private.php`), and the whole `env/` override tree.
### Dual-repo submodule structure
@@ -253,3 +285,21 @@ Every plan in `docs/working/plans/` must have a `**Status:**` line immediately a
**When asked what's open:** surface `Not started` and `In progress` plans. Show `Deferred` plans but label them clearly. Omit `Complete` and `Abandoned` unless explicitly asked.
**When finishing a plan:** update the `**Status:**` field in the plan file to `✅ Complete (YYYY-MM-DD)` before closing the session. This applies whether execution was done by Claude directly, via the superpowers:executing-plans skill, or via superpowers:subagent-driven-development.
## 3. Testing
**The dev server must be running** (`make start`) — every suite drives the live site over HTTP.
| Command | Scope |
|---|---|
| `make test` | Everything: `test-config``test-post``test-ui` |
| `make test-config` | Form/config sanity via `scripts/test-form-config.sh` |
| `make test-post` | End-to-end post submission via `scripts/test-post.sh` |
| `make test-ui` | Playwright suite (`npx playwright test`) |
- **Test account is automatic.** `test-post` and `test-ui` depend on `test-account`, which creates a `testrunner` admin (password `Testpass1234`) inside the container if absent. It is git-ignored — never commit it, and keep the password free of shell/Make/URL-special characters since several consumers interpolate it.
- **Playwright layout:** config at `playwright.config.js`, specs under `tests/ui/` (`a11y`, `auth`, `dailies`, `gpx`, `home`, `maps`, `nav`, `post`, `stories`, `trip`), shared helpers in `tests/ui/helpers.js`, global setup/teardown in `tests/`.
- **Auth is a dependency project.** `auth.setup.js` runs first and writes `tests/.auth/user.json`; the `chromium` project reuses it as `storageState`. Don't add per-test logins.
- **Base URL:** defaults to `http://localhost:8081`; override with `GRAV_BASE_URL` (required when testing a worktree's isolated server on `8090+`).
- Single spec / focused run: `npx playwright test tests/ui/maps` (add `--headed` to watch). `retries: 0` and screenshots-on-failure only, so a failure is a real failure.
- **`window.tripMap` / `window.homeMap` are asserted by the map specs** — any surface using the `entry-map` partial must keep assigning them (see "One map path" above).
@@ -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) |
@@ -0,0 +1,239 @@
---
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:** 📋 Not started
## 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 degrades silently (fields untouched), consistent with the existing reverse-geocode/weather error handling in `post-form.js`.
**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 is never client-blocked — the visual mismatch flag (R11) is the only feedback. Final enforcement stays server-side in `cleanCoordinate()`, which already throws on a non-blank, still-invalid value after cleaning.
- 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,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: silent-ish degrade (consistent with existing weather/reverse-geocode error handling in `post-form.js`), fields untouched.
- Invalid manual `lat`/`lng` text: no client-side hard block (the map preview and eventual server-side `cleanCoordinate()` are the safety nets); this UI's whole point is to make that failure mode rare in practice, not to duplicate the backend validator client-side.
- 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+92
View File
@@ -0,0 +1,92 @@
// @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.
//
// 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');
// Stored 800x600 with EXIF Orientation=6: browsers render it 600x800 portrait.
const EXIF_PORTRAIT = path.join(__dirname, '../../fixtures/test-photo-exif-portrait.jpg');
const USER_DIR = path.join(__dirname, '../../../user');
const DEMO_DAILIES = path.join(USER_DIR, 'pages/01.trips/italy-2026-demo/01.dailies');
const DEMO_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);
});
+74
View File
@@ -0,0 +1,74 @@
// @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).
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();
});