--- title: Post Form Location Override - Plan type: feat date: 2026-07-23 origin: docs/working/specs/2026-07-23-post-form-location-override-design.md artifact_contract: ce-unified-plan/v1 artifact_readiness: implementation-ready product_contract_source: legacy-requirements execution: code --- # Post Form Location Override - Plan **Status:** ✅ Complete (2026-07-24) — U1–U6 shipped, then hardened by a multi-agent code review the same day. The review found the design's stated server-side safety net (`cleanCoordinate()`) had never been committed, so it landed here; replaced a prefix-parsing coordinate check that accepted `48abc` / `48,85` / `35.0116S` (hemisphere silently flipped); closed three paths that bypassed the submit gate (draft restore, edit-mode prefill, map-load failure) because the gate read a CSS class no code set at init; added pin removal on blanked fields; made the geocode failure visible; and rewrote the U5 guard spec, which asserted only instantly-passing conditions and so could not fail. R8 and R13 above are revised accordingly. **Still open before merge:** the `user/` submodule commit is unpushed by choice (git-sync would deploy to prod), so the pin must be pushed and re-pointed at merge time; and the Playwright suite has never executed end-to-end (`make test-account` is broken by a shell-special value in `.env`), so every verification below is by inspection, not by a green run. ## Goal Capsule - **Objective:** Give the traveller a visual, mistake-catching way to set a journal entry's coordinates for a place other than their current GPS position — via a search-by-city lookup and a draggable map pin inside a new "More location details" disclosure on `/post` — without touching Admin2 or the API plugin. - **Authority hierarchy:** The design doc (`docs/working/specs/2026-07-23-post-form-location-override-design.md`) is authoritative for behavior; this plan is authoritative for sequencing and file-level implementation. Repo conventions (`CLAUDE.md`) and the cited existing patterns override any incidental detail here. - **Stop conditions:** Surface a blocker if the Open-Meteo geocoding endpoint's CORS or city-only-query behavior no longer matches what the design doc verified live, or if lazy-importing `maplibre-gl` breaks `post-form.js`'s existing ESM code-splitting build (the same risk the `heic-to` lazy import already carries safely). - **Execution profile:** Standard frontend feature confined to one theme (templates untouched — the panel is built entirely in JS, mirroring the existing "More options" pattern): CSS, JS additions to `post-form.js` plus one new small module, and a best-effort Playwright spec. Test-after is fine for the JS/CSS units; the Playwright unit (U6) is written test-after against the finished behavior. - **Tail ownership:** Rebuild theme assets (`make build-assets`) after U1–U5; run manual QA per the Definition of Done regardless of whether U6 can execute locally. --- ## Product Contract ### Summary Add a closed-by-default "More location details" disclosure to the `/post` form, placed directly below the City/Country fields. It holds a "🔍 Look up coordinates" button (geocodes the City field via Open-Meteo, ranked by Country when filled), a single-marker MapLibre preview map, and the existing `lat`/`lng` text fields relocated out of their current CSS-hidden position. Four ways to set a coordinate — GPS button, search-result pick, dragging the pin, typing raw numbers — stay in sync with each other. The GPS button's placement and behavior, and the City/Country fields' auto-fill-when-blank behavior, are unchanged. ### Problem Frame The only way to set a coordinate today is the GPS button (reads live position) or hand-typing/pasting raw decimal text into a CSS-hidden field — the latter is how an invisible Unicode bidi mark silently zeroed out a Denmark 2026 entry's coordinates before backend sanitization (`cleanCoordinate()` in `user/plugins/cache-on-save/cache-on-save.php`) was added. That backend fix stops silent corruption but does nothing for the underlying gap: there's still no visual, reliable way to set a location other than "here, right now," and no way to confirm a coordinate looks right before submitting. This plan closes that gap on the frontend only. ### Requirements **Disclosure & field relocation** - R1. A new "More location details" `
` 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=&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 `` 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 `
` wrapper (line ~120): a new `initLocationDetails()` creates the `
`/``, the search button/results-list/hint elements, and the map container via `document.createElement`, then moves the existing `lat`/`lng` `.form-field` wrappers into it — the same relocate-via-JS approach already used for "More options," so `post-form.html.twig` needs no structural change (only the CSS hide-rule removal in R2). ### High-Level Technical Design ```mermaid flowchart TB GPS["GPS button success\n(if panel open)"] --> SYNC["syncPinFromFields()"] SEARCH["Search result click"] --> FIELDS["lat/lng fields"] FIELDS --> SYNC TYPE["Type + blur/debounce"] --> SYNC SYNC --> PIN["Map pin"] DRAG["Drag pin (dragend)"] --> FIELDS SYNC -.invalid.-> FLAG["Mismatch flag on field\n(cleared once value parses)"] ``` Map lifecycle: first panel open → `import('maplibre-gl')` → create map + draggable marker, cache in module scope → subsequent opens call `.resize()` on the cached instance rather than recreating it. ### Assumptions - No existing Playwright fixture creates a "search API returns N results" scenario; U6 mocks the Open-Meteo response via `page.route()` rather than depending on the live third-party endpoint, keeping the spec hermetic (and avoiding flakiness/rate-limits from a real geocoding call). - The `location-details` panel defaults closed even when editing an entry that already has `lat`/`lng` set — see Open Questions. --- ## Implementation Units ### U1. CSS: unhide coordinate fields, style the new panel - **Goal:** Remove the CSS rule hiding `lat`/`lng`, and add styling for the new disclosure, search results list, map container, and mismatch-flag state (R2, R9). - **Requirements:** R2, R9. - **Dependencies:** none. - **Files:** `user/themes/intotheeast/css/style.css`. - **Approach:** Remove the `display: none !important` rule at `style.css:893-895` targeting `input[name="data[lat]"]`/`input[name="data[lng]"]`. Add: a `.location-details` disclosure look mirroring `.more-options` (`user/themes/intotheeast/js/src/post-form.css:98`); a `.location-search-results` list; a `.location-map` container with a fixed height and `position: relative` so the marker's DOM element (sized ≥44×44px) sits correctly; a `.location-field--mismatch` state (red outline + inline note) for the type-mismatch flag; a disabled/"Searching…" look for the lookup button reusing the existing `.btn-action`/`is-loading` conventions (`style.css:976-991`). - **Patterns to follow:** `.more-options`/`.more-options__summary` (`post-form.css:98-128`), `.btn-action`/`.form-status` (`style.css:970-1002`). - **Test scenarios:** Test expectation: none -- pure CSS; visual correctness is verified manually and indirectly by U2–U5's behavioral tests (elements exist and are visible/hidden as expected). - **Verification:** `lat`/`lng` inputs are visible only inside the new panel in the browser; no other page references the removed selector (confirmed during research — none found outside `style.css:894-895` and `post-form.js`'s own field reads). ### U2. JS: build the "More location details" panel shell - **Goal:** Construct the closed-by-default disclosure (search UI, map container, relocated `lat`/`lng` fields) entirely in JS, positioned after the City/Country fields (R1, R2, R3, KTD6). - **Requirements:** R1, R2, R3. - **Dependencies:** U1. - **Files:** `user/themes/intotheeast/js/src/post-form.js`. - **Approach:** New `initLocationDetails()`, called from `boot()` after `initDisclosure()` and `initGeo()` (so the relocated fields already reflect any `initDraft()` restore, and `initGeo()`'s `field('lat')`/`field('lng')` lookups still resolve by attribute selector regardless of DOM position). No-op if `field('lat')`/`field('lng')` are absent. Create `
` + `More location details`; 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 `
` wrapper (`post-form.js:~120`) for the create-via-JS + relocate-wrapper approach. - **Test scenarios:** - Happy path: on `/post`, "More location details" is present, closed by default, positioned immediately after the Country field, and contains the lookup button, an empty map container, and the (now-visible-only-inside-the-panel) `lat`/`lng` inputs. - No-op guard: if `lat`/`lng` fields were ever absent from the DOM, `initLocationDetails()` does not throw. - **Verification:** DOM inspection in-browser confirms structure and default-closed state. ### U3. JS: geocoding search + results list - **Goal:** Implement the "🔍 Look up coordinates" button: city-only query, client-side country ranking, results list, and all error/empty states (R4–R8). - **Requirements:** R4, R5, R6, R7, R8. - **Dependencies:** U2. - **Files:** `user/themes/intotheeast/js/src/post-form.js`. - **Approach:** Click handler on `#lookup-coords`: if City and Country are both blank, show the inline hint and return (no fetch). Otherwise disable the button, show "Searching…", and `fetch` the Open-Meteo geocoding URL (KTD2). On response: empty/missing `results` → no-match hint; otherwise stable-sort by whether each result's `country` case-insensitively contains the Country field's value (matches first, original order preserved otherwise), then render each as an `
  • ` containing a `