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>
32 KiB
title, type, date, origin, artifact_contract, artifact_readiness, product_contract_source, execution
| title | type | date | origin | artifact_contract | artifact_readiness | product_contract_source | execution |
|---|---|---|---|---|---|---|---|
| Post Form Location Override - Plan | feat | 2026-07-23 | docs/working/specs/2026-07-23-post-form-location-override-design.md | ce-unified-plan/v1 | implementation-ready | legacy-requirements | 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 → 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-glbreakspost-form.js's existing ESM code-splitting build (the same risk theheic-tolazy 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.jsplus 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"
<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()inuser/themes/intotheeast/js/src/post-form.js:341). - R2. The
lat/lngfields relocate into this panel with theirname="data[lat]"/name="data[lng]"attributes unchanged, socache-on-save.php'ssanitizeCoordinates()andpost-form.js'sfield('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'scountryfield, 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(noinnerHTML), matching every other dynamic-content construction already inpost-form.js. Clicking an entry setslat/lngand 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 shareduser/themes/intotheeast/js/src/map-style.jsmodule 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 underdisplay:nonewhile 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 existingheic-tolazy-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 ofpost-form.jsand bundled unconditionally intopost-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 (
dragendwrites 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/lngtext is never client-blocked — the visual mismatch flag (R11) is the only feedback. Final enforcement stays server-side incleanCoordinate(), which already throws on a non-blank, still-invalid value after cleaning. - R14. Geolocation permission denial keeps its existing, unmodified
#location-statuserror behavior.
Scope Boundaries
Out of scope
- Any change to
user/plugins/admin2/oruser/plugins/api/. - Any change to how coordinates are stored (still plain
lat/lngfloats in frontmatter) or to the already-shippedcleanCoordinate()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-accountMakefile 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'sinitEntryMap(used byentry-map.html.twigon 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 bypost-form.js(it is not a new esbuild entry point — see KTD5).MAP_STYLEitself is extracted into a tiny shared constants module,user/themes/intotheeast/js/src/map-style.js(a singleexport const MAP_STYLE = ..., no side effects), imported by bothlocation-map.jsand the existingjs/maplibre-utils.js— this removes the literal-duplication drift risk without pulling inmaplibre-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.jsexports a function (e.g.getOrCreateLocationMap(container)) that internally callsimport('maplibre-gl')the first time it runs, keyed off the panel's firsttoggleevent wheredetails.open === true— never eagerly at page load.maplibre-gl/dist/maplibre-gl.cssis a static top-of-file import inpost-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: readsfield('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'stoggle-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'sdragendhandler writes straight into the fields (rounded to 6 decimals) and clears any mismatch flag — it does not callsyncPinFromFields()back, avoiding a feedback loop. - KTD5. No new esbuild entry point. Unlike
trip-publish.js(its own bundle),location-map.jsis a plain ES module imported bypost-form.js's existing entry — esbuild inlines it into the same--splittingESM build already configured inuser/themes/intotheeast/package.json. Onlymaplibre-glitself needs to be the lazy chunk; the coordinator code around it loads normally, mirroring howheic-tois dynamically imported from directly inside the always-loadedpost-form.js. - KTD6. Panel construction is entirely JS-built, no template edit. Mirrors
initDisclosure()(line 341) and the photos<details>wrapper (line ~120): a newinitLocationDetails()creates the<details>/<summary>, the search button/results-list/hint elements, and the map container viadocument.createElement, then moves the existinglat/lng.form-fieldwrappers into it — the same relocate-via-JS approach already used for "More options," sopost-form.html.twigneeds no structural change (only the CSS hide-rule removal in R2).
High-Level Technical Design
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-detailspanel defaults closed even when editing an entry that already haslat/lngset — 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 !importantrule atstyle.css:893-895targetinginput[name="data[lat]"]/input[name="data[lng]"]. Add: a.location-detailsdisclosure look mirroring.more-options(user/themes/intotheeast/js/src/post-form.css:98); a.location-search-resultslist; a.location-mapcontainer with a fixed height andposition: relativeso the marker's DOM element (sized ≥44×44px) sits correctly; a.location-field--mismatchstate (red outline + inline note) for the type-mismatch flag; a disabled/"Searching…" look for the lookup button reusing the existing.btn-action/is-loadingconventions (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/lnginputs are visible only inside the new panel in the browser; no other page references the removed selector (confirmed during research — none found outsidestyle.css:894-895andpost-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/lngfields) 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 fromboot()afterinitDisclosure()andinitGeo()(so the relocated fields already reflect anyinitDraft()restore, andinitGeo()'sfield('lat')/field('lng')lookups still resolve by attribute selector regardless of DOM position). No-op iffield('lat')/field('lng')are absent. Create<details class="location-details">+<summary>More location details</summary>; append a search row (#lookup-coordsbutton,#location-search-resultslist,#location-search-hintinline hint), a#location-mapcontainer, then movefield('lat').closest('.form-field')andfield('lng').closest('.form-field')into the details. Insert the details element immediately afterfield('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/lnginputs. - No-op guard: if
lat/lngfields were ever absent from the DOM,initLocationDetails()does not throw.
- Happy path: on
- 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…", andfetchthe Open-Meteo geocoding URL (KTD2). On response: empty/missingresults→ no-match hint; otherwise stable-sort by whether each result'scountrycase-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 viacreateElement/.textContent("name, admin1, country") — keyboard-operable by default, matching the accessible-button convention already used elsewhere in this file (the photo-delete button'saria-label). Clicking (or activating via keyboard) a result button setslat/lng(not City/Country) and callssyncPinFromFields()(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 "noinnerHTMLanywhere in this file" convention for the results list; the existing accessible-button convention (photo-delete<button>witharia-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/lngand 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.
- Happy path: searching "Kyoto" (mocked response) renders a results list; clicking the first result sets
- 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 — importMAP_STYLEinstead of declaring it inline; no behavior change). - Approach: First, extract the existing
MAP_STYLEliteral out ofmaplibre-utils.js:5intomap-style.js(a singleexport const MAP_STYLE = ...) and updatemaplibre-utils.jsto import it instead of declaring it inline. Inlocation-map.js, import the same constant and exportgetOrCreateLocationMap(container, onDragEnd): on first call,import('maplibre-gl'), create amaplibregl.Mapagainstcontainerusing the sharedMAP_STYLEconstant (KTD1), create onemaplibregl.Marker({ draggable: true, element: <a ≥44×44px sized div> })(not yet added to the map until a pin is set), wire itsdragendto callonDragEnd(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.jsadds a static top-of-fileimport 'maplibre-gl/dist/maplibre-gl.css';(R10) and, ininitLocationDetails(), listens for the panel'stoggleevent: on every open wheredetails.openis true, callgetOrCreateLocationMap(...).resize()(creating it on the first call, per the lazy-import contract) and thensyncPinFromFields()(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-todynamic-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/lngboth 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-glchunk (asserted via a page network-request listener in Playwright).
- Happy path: opening the panel for the first time renders exactly one MapLibre canvas inside
- 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): parsefield('lat')/field('lng')values; if both are finite numbers within range, call the map handle'ssetPin, clear the mismatch flag/class from both fields, and cleararia-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 arole="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), settingaria-invalid="true"andaria-describedbypointing at that note so screen-reader users are told the value wasn't reflected on the map. Wire callers: (a) the marker'sdragend(from U4'sonDragEnd) writes rounded-to-6-decimal values directly into the fields and clears the mismatch flag — it does not callsyncPinFromFields()back; (b) the search-result click (U3) sets fields then callssyncPinFromFields(); (c) the existing GPS success handler (initGeo(),post-form.js:464-475) callssyncPinFromFields()after setting fields, but only if the location-details<details>is currently open; (d)lat/lngfieldblurand debouncedinputlisteners callsyncPinFromFields(); (e) the panel'stoggle-open handler (U4) callssyncPinFromFields()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/lngto 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 savedlat/lng.
U6. Playwright coverage (best-effort)
- Goal: Add automated coverage for the new search → pin → submit flow, accepting the known local test-harness risk (R4–R14 as observable behavior).
- Requirements: R4, R5, R6, R7, R8, R9, R10, R11, R12, R13.
- Dependencies: U1–U5.
- 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 setslat/lngfrom 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 expectedlat/lngin the entry's frontmatter (reuse the existing fixture/cleanup helpers fromtests/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.jsfor 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(fromtests/) passes. Known risk: the pre-existing, unrelatedmake test-accountMakefile 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 |
U1–U5 (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 |
U2–U5 |
| 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/lngdoes not crash the map or move the pin; a submit still round-trips through the existing backendcleanCoordinate()validation. make build-assetshas been run;js/post/*andcss-compiled/post-form.cssare 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/lnginputs 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-glfetches 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-accountissue, 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
resultsarray, 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-glfrom insidepost-form.js's existing--splittingESM build must not regress the already-workingheic-tolazy chunk. Mitigation: verify viamake build-assetsplus a browser network-tab check that both chunks split correctly. - Zero-size canvas on first open. MapLibre initializing against a
display:nonecontainer is a known gotcha; mitigated by the explicit.resize()call on every panel open (R9, U4). - Test-harness blocker. The pre-existing
make test-accountMakefile 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/lngare 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) andinitEditMode()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.