Files
intotheeast-com/docs/working/plans/2026-07-23-post-form-location-override.md
T
m038andClaude Opus 5 8202d2a257 docs: reconcile documentation against the code; add a supersession ledger
Five weeks of undocumented evolution left the docs describing a site that
partly no longer exists, with nothing marking which documents were historical.
The code is treated as the source of truth throughout; every claim below was
verified against code, config or the Makefile rather than inferred.

Two mechanisms, following patterns the repo already used:

- docs/reference/superseded-decisions.md (new) — one authoritative table of all
  14 reversals: what was planned, where, what is true now, when, and why. Plus
  a short list of decisions that were NOT reversed, since their planning docs
  are old enough to look suspect.
- Inline "> **Superseded ...**" notes at each stale claim, so a claim can never
  be read un-corrected. This mirrors the existing "> History:" notes in
  architecture.md and "> **Changed 2026-07:**" in trip-switching.md.

Scope split by tense: present-tense docs (CLAUDE.md, reference/, guides/,
README.md, CONCEPTS.md) are corrected; past-tense records (plans/, specs/,
milestones/, summary.md, pm-analysis.md) are annotated only, never rewritten —
their staleness is what makes them records.

Present-tense corrections:

- CLAUDE.md asserted css-compiled/ is generated from css/style.css and
  css/tokens.css. That source relationship does not exist: css/ is hand-authored
  and served directly via assets.addCss in partials/base.html.twig, while
  css-compiled/ is esbuild output from the CSS imports inside js/src/*.js.
  Highest-severity finding — an always-loaded file inviting a hand-edit of a
  generated bundle.
- README.md documented every remote-* target without the -test/-prod suffix
  guard-env requires, so its entire server runbook was unrunnable, and listed
  7 of ~20 targets while CLAUDE.md designates it authoritative for the full
  list. Rewritten with all targets, grouped, and the suffix rule stated.
- README.md told readers to "git clone" into user/, which is a submodule.
- architecture.md: nav is Home + Trips + (authenticated) New Post, not
  "Home + Past Trips only"; template tree omitted trips.html.twig,
  post-form.html.twig and forms/, and placed base.html.twig at templates/ root
  rather than in partials/; entry-actions has three API routes, not just delete;
  added the undocumented css-compiled/maplibre-gl.css output and a section on
  the /post pin editor as the one sanctioned non-entry-map map.
- design-system.md: documented 13 colour tokens against 19 in tokens.css
  (missing --color-error, --color-draft-accent and four glass overlays); claimed
  "all 3 map templates"; and described --color-canvas as "white".
- design-system-light.md documented a light palette in present tense. No light
  mode exists — tokens.css has a single :root block, no prefers-color-scheme or
  data-theme switch, and no light hex appears in css/. Banner added.
- posting.md said photos were optional; they are required, 1-6. It documented
  hero_image, which was removed for entries (stories keep it). It had no mention
  of the frontend edit flow or photo editor, both shipped 2026-07-08. Photo
  files are photo-01..NN, zero-padded.
- working/README.md advertised summary.md as the project's "current state" while
  summary.md describes Leaflet, /tracker, /map and /stats. Most misleading line
  in the tree.
- CLAUDE.md: recorded js/src/location-map.js as the one sanctioned exception to
  the single-map-path rule, and documented that make start/setup fail on a clean
  checkout because docker-compose.yml still builds a travel-memories service
  whose source moved out in a80b0a9.

Also: 2026-07-23-post-form-location-override.md read "Not started" while merged
in user/ as dd19995; status corrected.

Findings that are not documentation problems — the compose breakage, a
repeatable drift check, the unused shortcode-gallery-plusplus, and demo fixtures
for retired views — are collected in
docs/working/2026-07-25-doc-drift-recommendations.md and deliberately not acted
on. Design and verification method: docs/working/specs/2026-07-25-docs-reconciliation-design.md

The submodule gitlink is deliberately not bumped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 00:13:37 +02:00

240 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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) — merged to `user/` `main` as `dd19995` ("Merge feat/post-location-override into main"). Delivered `js/src/location-map.js` (the lazy-imported single-draggable-marker pin editor) and `js/src/map-style.js` (`MAP_STYLE`, now the single source of the basemap URL for both map paths), plus the "More location details" disclosure with city/country search-by-lookup in `js/src/post-form.js`. The status line lagged the merge and was corrected during the 2026-07-25 documentation reconciliation; the second map engine is now recorded as the one sanctioned exception to the single-map-path rule in `CLAUDE.md` and in [`../../reference/superseded-decisions.md`](../../reference/superseded-decisions.md) → R13.
## 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.