# Post form: location override (search + map + drag) **Status:** πŸ“‹ Not started ## Problem The post form's `lat`/`lng` fields exist in the blueprint (`user/pages/02.post/post-form.md`) as plain `type: text` fields, but a theme CSS rule (`user/themes/intotheeast/css/style.css:893-895`) hides them, and the only way to populate them is the `πŸ“ Get Location` button, which reads the browser's live GPS position via `navigator.geolocation`. This breaks down whenever an entry describes a place the traveller isn't physically standing in when they write it up β€” the common case for journal entries written at the end of a day, from a shelter/hostel/train, about somewhere visited earlier. There is currently no supported way to set a coordinate for anywhere other than "here, right now." The only workaround has been logging into Admin2 and hand-typing/pasting raw decimal coordinates directly into the page's frontmatter field. This is what produced the Denmark 2026 bug: a coordinate pasted from an external source carried an invisible Unicode bidi mark (U+200E), which PHP's `(float)` cast silently coerced to `0.0`, placing the entry's map marker at `(0, 0)` with no error or warning anywhere in the pipeline. Backend sanitization has already been added (`user/plugins/cache-on-save/cache-on-save.php`: `cleanCoordinate()`, wired into both `onFormValidationProcessed` for the public form and `onAdminSave` for Admin2/API saves) to strip invisible characters and range-validate lat/lng before they ever reach a page's frontmatter. That fix is necessary but not sufficient: it prevents *silent corruption of whatever gets typed*, but does nothing to prevent the underlying problem β€” a fragile, invisible-to-the-eye, paste-prone raw text field is still the only way to set an arbitrary location, and there's no way to visually confirm the result before submitting. This spec addresses that gap directly, on the frontend post form, so the Admin2 round-trip is no longer needed for this at all. ## Goals - Give the traveller a reliable, visual way to set an entry's coordinates for a location other than their current GPS position, without touching Admin2. - Let any coordinate-setting mistake be caught *before* submit, via a live map preview, rather than relying solely on backend validation to catch it after the fact. - Keep the common case (GPS, writing about where you currently are) exactly as fast and simple as it is today β€” no added friction for the πŸ“ Get Location button. ## Non-goals - No changes to Admin2 or the `api` plugin. The backend sanitization already shipped there stays as-is, as defense-in-depth for the Admin2 edit path (which this spec doesn't touch). - No change to how coordinates are stored (still plain `lat`/`lng` floats in frontmatter). - No offline/self-hosted geocoding β€” this reuses free, no-key, CORS-enabled public APIs, consistent with the form's existing BigDataCloud (reverse geocode) and Open-Meteo (weather) integrations. - No additional integrity verification (certificate pinning, response signing, etc.) for the geocoding/tile third-party responses beyond HTTPS. A compromised or MITM'd response could theoretically feed bogus coordinates or map tiles into the preview, but this is accepted as low-probability and already bounded by the unchanged server-side `cleanCoordinate()` range validator, which gates what actually reaches frontmatter regardless of what the preview displays. ## Design ### Placement - **πŸ“ Get Location** (GPS): unchanged. Stays in its current top-level `.form-action-row`, primary/always-visible action for "I'm posting from where I am right now." - **City / Country**: unchanged position and behavior in the main field flow (still plain, always-visible text fields, still auto-filled by GPS reverse-geocode only when blank). - **New "More location details" disclosure**, placed directly below the City/Country fields (a separate `
` block from the existing "More options" advanced-fields disclosure, which stays scoped to the unrelated `published`/`force_connect`/`featured` toggles). Closed by default. Contains: - A **"πŸ” Look up coordinates"** button. - A small MapLibre preview map with a single, draggable marker. - The raw `lat`/`lng` text fields, relocated here from their current CSS-hidden position in the main flow (the `display: none !important` rule in `user/themes/intotheeast/css/style.css:894-895`, which targets `input[name="data[lat]"]`/`input[name="data[lng]"]`, is removed; the fields simply live inside this disclosure instead). This is a pure DOM relocation β€” the `name="data[lat]"`/`name="data[lng]"` attributes are unchanged, so `cache-on-save.php`'s `sanitizeCoordinates()` (which keys off those exact field names) and `post-form.js`'s existing `field('lat')`/`field('lng')` helper both keep working unmodified. Checked the theme for other references to that CSS rule or those field names β€” none found outside `style.css:894-895` and `post-form.js`'s own read/write of the fields β€” so removing the rule has no other side effects. ### Search mechanics - The lookup button geocodes the **City field alone** via Open-Meteo's free geocoding endpoint (`https://geocoding-api.open-meteo.com/v1/search?name=&count=10&language=en&format=json`) β€” same provider the form already trusts for weather (`api.open-meteo.com`), no API key required. CORS is confirmed open on this endpoint independent of the weather endpoint (`access-control-allow-origin: *`, verified directly against `geocoding-api.open-meteo.com`). - **The Country field is not concatenated into the query string.** Verified against the live API: a combined query like `name=Paris%2C%20Texas` or `name=Jerup%2C%20Denmark` either returns zero results or silently degrades to matching only the part before the comma β€” Open-Meteo's `name` param does fuzzy/substring matching on the place name, not a "name, country" filter syntax. Concatenating would silently break the lookup for exactly the disambiguation case (e.g. "Paris, Texas") this feature exists to handle. - Instead: query by City name alone (returns all same-named places, e.g. all five "Paris" results worldwide), then β€” if the Country field is non-blank β€” rank results client-side by matching Country against each result's `country` field (case-insensitive substring), matching entries first. All results still render in the list below, just reordered. - Explicit click, not live-as-you-type β€” matches the deliberate, single-action feel of the existing GPS button. - While a lookup request is in flight, the button shows a brief "Searching…" state (disabled, consistent with how other in-flight actions in `post-form.js` guard against double-submission); it re-enables on response, whether that's results, no-match, or network failure. - Clicking "πŸ” Look up coordinates" with both City and Country empty is treated the same as a no-match: inline hint to fill in a city or country first, no request is sent. - **The lookup only reads City/Country β€” it never writes back to them.** A geocode result sets `lat`/`lng` and moves the pin only. This avoids the earlier concern of an ambiguous or slightly-off match silently overwriting a name the traveller deliberately typed. - Multiple matches β†’ rendered as a small clickable list (place name, admin region, country), Country-matches ranked first per above, so the traveller can disambiguate (e.g. "Paris, Île-de-France, France" vs "Paris, Texas, United States"). Each list item is built via `document.createElement` + `.textContent` β€” the same convention used everywhere else in `post-form.js` for dynamic content (no `innerHTML` string-building exists in the file today) β€” since these are untrusted, API-sourced strings. Clicking an entry sets `lat`/`lng` and moves the pin; the list is not shown again until the next lookup. - No matches β†’ inline hint: try adding a country, or drag the pin manually. - Network failure β†’ degrades the same way the existing reverse-geocode/weather calls do: silent-ish failure, fields untouched, traveller can still fall back to manual entry or the pin. ### Map preview + sync - Single MapLibre GL map instance, reusing the site's existing style (`https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json` β€” same as `maplibre-utils.js`, no new API key), with one draggable marker sized to at least a ~44Γ—44px touch target (matching standard iOS/Android touch-target guidance), since this is a mobile-first form. - `maplibre-gl`'s JS is dynamically imported (`import('maplibre-gl')`) only when the "More location details" `
` is opened for the first time — mirrors the existing HEIC-conversion lazy-chunk pattern in `post-form.js`, so the ~200KB library is never fetched for ordinary GPS-only submissions. Its CSS (`maplibre-gl/dist/maplibre-gl.css`, ~8KB minified) is imported statically at the top of `post-form.js` instead, bundled unconditionally into the always-loaded `css-compiled/post-form.css` — unlike the JS, the CSS chunk can't be split off a dynamic import without esbuild orphaning it (no `` reference is ever emitted for a code-split CSS chunk), so only the JS half of the HEIC lazy-chunk pattern applies here. - Four ways to set a coordinate, all kept in sync with each other: 1. **GPS button** (main flow) — writes `lat`/`lng` directly. If "More location details" is closed, the map/pin simply reflect the new values whenever the panel is next opened. If the panel is already open when GPS resolves, the same field→pin sync used by path 4 (typing) fires immediately, so the pin jumps to the new position live instead of requiring a re-open. 2. **Search-result click** — sets fields, moves/creates pin. 3. **Dragging the pin** — on `dragend`, reads the marker's `lngLat`, writes back into the `lat`/`lng` text fields (rounded to 6 decimal places, matching the GPS button's existing precision). 4. **Typing directly into lat/lng** — on blur/debounced input, if both values parse as valid finite numbers within range, move (or create) the pin. Invalid/unparseable input leaves the pin where it was, but visually flags the field (e.g. a red outline plus an inline "not reflected on map" note) so the traveller can tell the text and the pin disagree — this is a visual aid, not a blocking validator; final enforcement stays server-side in `cleanCoordinate()`. The flag clears once the field's value parses and the pin catches up. - If the map is opened with no `lat`/`lng` set yet, no pin is shown until one of the four paths above sets a value. - The map instance is created once, the first time "More location details" is opened, and held in module scope; reopening the `
` later reuses that instance rather than constructing a duplicate. Repeat `import('maplibre-gl')` calls resolve from the ES module cache with no extra network fetch β€” the same behavior the existing `heic-to` lazy import already relies on. Because the container sits under `display: none` while the `
` is closed, MapLibre initializes with a zero-size canvas the first time; the map calls `.resize()` on every subsequent open to pick up the container's real dimensions. ### Error handling - No search results: inline message under the search box, map/pin untouched. - Search network failure: silent-ish degrade (consistent with existing weather/reverse-geocode error handling in `post-form.js`), fields untouched. - Invalid manual `lat`/`lng` text: no client-side hard block (the map preview and eventual server-side `cleanCoordinate()` are the safety nets); this UI's whole point is to make that failure mode rare in practice, not to duplicate the backend validator client-side. - Geolocation permission denied: unchanged existing behavior (`#location-status` error message). ## Out of scope / explicitly deferred - No changes to `user/plugins/admin2/` or `user/plugins/api/` — confirmed and intentional. - No removal of the existing backend `cleanCoordinate()` sanitization (`onFormValidationProcessed` + `onAdminSave` in `cache-on-save.php`) — it remains as defense-in-depth, especially for the still-possible Admin2 edit path. - Automated Playwright coverage for the new search→pin→submit flow is desirable but currently blocked by a pre-existing, unrelated `make test-account` Makefile quoting bug — flagged as a follow-up, not a blocker for shipping this feature. Manual in-browser QA (per CLAUDE.md's UI-change testing guidance) is required before considering this done. ## Testing plan - Manual QA in the dev browser: open `/post`, expand "More location details," exercise all four coordinate-setting paths (GPS, search + pick a result, drag the pin, type raw numbers) and confirm the pin and fields stay in sync in both directions. Submit and confirm the saved entry's frontmatter has the expected `lat`/`lng`. - Exercise the ambiguous-search case: City "Paris" with Country "Texas" and confirm the Texas result ranks first over the France/Tennessee/Kentucky/Illinois matches — this is the specific case the City-only-query + client-side-rank fix targets, since concatenating "Paris, Texas" into a single query string returns zero results from Open-Meteo. Also exercise the no-match case. - Reopen "More location details" a second time in the same session and confirm the map doesn't duplicate (still one canvas, correctly sized) and the pin still reflects the current `lat`/`lng`. - Exercise the "type garbage into lat/lng" case and confirm the map simply doesn't move the pin (no crash), while a submit still round-trips through the existing backend `cleanCoordinate()` validation.