diff --git a/tests/ui/post/location-override.spec.js b/tests/ui/post/location-override.spec.js
new file mode 100644
index 0000000..25667cb
--- /dev/null
+++ b/tests/ui/post/location-override.spec.js
@@ -0,0 +1,304 @@
+// @ts-check
+// Tests: post form "More location details" — search-by-city lookup + draggable
+// map pin preview for setting an entry's coordinates without live GPS.
+// Covers R4-R14. The Open-Meteo geocoding endpoint is mocked via page.route()
+// so this suite is hermetic (no live third-party call, no rate-limit flakiness).
+const { test, expect } = require('@playwright/test');
+const path = require('path');
+const { fillEditor, waitForPhotoUpload, cleanupEntry, findEntry, readEntryMd, TEST_PHOTO } = require('../helpers');
+
+const GEOCODE_URL = '**/geocoding-api.open-meteo.com/v1/search**';
+
+const created = [];
+test.afterAll(() => { created.forEach(cleanupEntry); });
+
+// Real-API-shaped fixtures (verified live against geocoding-api.open-meteo.com).
+const KYOTO_RESULTS = {
+ results: [
+ { name: 'Kyoto', latitude: 35.0116, longitude: 135.7681, admin1: 'Kyoto Prefecture', country: 'Japan' }
+ ]
+};
+
+// Mirrors the design doc's verified live Paris query: Île-de-France (France)
+// first from the API, then five US states — Texas among them, in admin1 (the
+// API's `country` field is "United States" for all of the US matches, so the
+// ranking must also check admin1 to disambiguate on a US state name).
+const PARIS_RESULTS = {
+ results: [
+ { name: 'Paris', latitude: 48.85341, longitude: 2.3488, admin1: 'Île-de-France Region', country: 'France' },
+ { name: 'Paris', latitude: 33.66094, longitude: -95.55551, admin1: 'Texas', country: 'United States' },
+ { name: 'Paris', latitude: 36.302, longitude: -88.32671, admin1: 'Tennessee', country: 'United States' },
+ { name: 'Paris', latitude: 38.2098, longitude: -84.2529, admin1: 'Kentucky', country: 'United States' },
+ { name: 'Paris', latitude: 39.6112, longitude: -87.6961, admin1: 'Illinois', country: 'United States' }
+ ]
+};
+
+function mockGeocode(page, body) {
+ return page.route(GEOCODE_URL, (route) => route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify(body)
+ }));
+}
+
+async function openLocationDetails(page) {
+ await page.locator('.location-details__summary').click();
+ await expect(page.locator('.location-details')).toHaveJSProperty('open', true);
+}
+
+// ── Panel closed by default (R1) ────────────────────────────────────────────
+test('More location details is closed by default and holds the relocated lat/lng fields', async ({ page }) => {
+ await page.goto('/post');
+ const details = page.locator('.location-details');
+ await expect(details).toBeAttached();
+ await expect(details).toHaveJSProperty('open', false);
+ await expect(page.locator('.location-details input[name="data[lat]"]')).toBeAttached();
+ await expect(page.locator('.location-details input[name="data[lng]"]')).toBeAttached();
+});
+
+// ── R6: empty City + Country sends no request ───────────────────────────────
+test('R6: clicking lookup with City and Country both empty sends no request', async ({ page }) => {
+ await page.goto('/post');
+ let requested = false;
+ await page.route(GEOCODE_URL, (route) => { requested = true; route.abort(); });
+ await openLocationDetails(page);
+ await page.click('#lookup-coords');
+ await expect(page.locator('#location-search-hint')).toContainText(/city or country/i);
+ expect(requested).toBe(false);
+});
+
+// ── R7: a search result sets lat/lng only, never City/Country ──────────────
+test('R7: clicking a search result sets lat/lng and leaves City/Country untouched', async ({ page }) => {
+ await page.goto('/post');
+ await mockGeocode(page, KYOTO_RESULTS);
+ await page.fill('input[name="data[location_city]"]', 'Kyoto');
+ await openLocationDetails(page);
+ await page.click('#lookup-coords');
+
+ const results = page.locator('.location-search-results li button');
+ await expect(results).toHaveCount(1);
+ await results.first().click();
+
+ await expect(page.locator('input[name="data[lat]"]')).toHaveValue('35.011600');
+ await expect(page.locator('input[name="data[lng]"]')).toHaveValue('135.768100');
+ await expect(page.locator('input[name="data[location_city]"]')).toHaveValue('Kyoto');
+ await expect(page.locator('input[name="data[location_country]"]')).toHaveValue('');
+ // R7: the list hides again until the next lookup.
+ await expect(page.locator('.location-search-results li')).toHaveCount(0);
+});
+
+// ── R4/KTD2: Paris/Texas disambiguation ranks the Texas match first ─────────
+test('disambiguation: City "Paris" + Country "Texas" ranks the Texas match first', async ({ page }) => {
+ await page.goto('/post');
+ let requestedUrl = null;
+ await page.route(GEOCODE_URL, (route) => {
+ requestedUrl = route.request().url();
+ route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(PARIS_RESULTS) });
+ });
+ await page.fill('input[name="data[location_city]"]', 'Paris');
+ await page.fill('input[name="data[location_country]"]', 'Texas');
+ await openLocationDetails(page);
+ await page.click('#lookup-coords');
+
+ const results = page.locator('.location-search-results li button');
+ await expect(results).toHaveCount(5);
+ await expect(results.first()).toContainText('Texas');
+
+ // R4: Country is never concatenated into the query string.
+ expect(requestedUrl).toContain('name=Paris');
+ expect(requestedUrl).not.toContain('Texas');
+});
+
+// ── R8: no matches shows the inline hint, fields untouched ─────────────────
+test('R8: no matches shows the no-match hint and leaves fields untouched', async ({ page }) => {
+ await page.goto('/post');
+ await mockGeocode(page, { results: [] });
+ await page.fill('input[name="data[location_city]"]', 'Nowheresville');
+ await openLocationDetails(page);
+ await page.click('#lookup-coords');
+
+ await expect(page.locator('#location-search-hint')).toContainText(/no matches/i);
+ await expect(page.locator('input[name="data[lat]"]')).toHaveValue('');
+ await expect(page.locator('input[name="data[lng]"]')).toHaveValue('');
+});
+
+// ── R5: in-flight state shows "Searching…" and always re-enables ───────────
+test('R5: the lookup button shows a disabled "Searching…" state while in flight', async ({ page }) => {
+ await page.goto('/post');
+ await page.route(GEOCODE_URL, async (route) => {
+ await new Promise((r) => setTimeout(r, 400));
+ route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(KYOTO_RESULTS) });
+ });
+ await page.fill('input[name="data[location_city]"]', 'Kyoto');
+ await openLocationDetails(page);
+ await page.click('#lookup-coords');
+
+ const btn = page.locator('#lookup-coords');
+ await expect(btn).toBeDisabled();
+ await expect(btn).toHaveText('Searching…');
+ await expect(btn).toBeEnabled({ timeout: 5_000 });
+ await expect(btn).toContainText('Look up coordinates');
+});
+
+// ── R8: a network failure degrades silently and re-enables the button ──────
+test('a network failure degrades silently, leaves fields untouched, and re-enables the button', async ({ page }) => {
+ await page.goto('/post');
+ await page.route(GEOCODE_URL, (route) => route.abort('failed'));
+ await page.fill('input[name="data[location_city]"]', 'Kyoto');
+ await openLocationDetails(page);
+ await page.click('#lookup-coords');
+
+ await expect(page.locator('#lookup-coords')).toBeEnabled();
+ await expect(page.locator('input[name="data[lat]"]')).toHaveValue('');
+ await expect(page.locator('input[name="data[lng]"]')).toHaveValue('');
+});
+
+// ── XSS safety: an API-sourced name containing markup renders as literal text ──
+test('a result name containing markup renders as literal text, not executed', async ({ page }) => {
+ await page.goto('/post');
+ await mockGeocode(page, {
+ results: [{ name: '
', latitude: 1, longitude: 2, country: 'Nowhere' }]
+ });
+ await page.fill('input[name="data[location_city]"]', 'Test');
+ await openLocationDetails(page);
+ await page.click('#lookup-coords');
+
+ const btn = page.locator('.location-search-results li button').first();
+ await expect(btn).toContainText('
');
+ expect(await btn.evaluate((el) => el.querySelector('img'))).toBeNull();
+ expect(await page.evaluate(() => window.__xss)).toBeUndefined();
+});
+
+// ── U4: map renders exactly one canvas, no pin until a coordinate is set ───
+test('opening the panel renders exactly one map canvas with no initial pin', async ({ page }) => {
+ await page.goto('/post');
+ await openLocationDetails(page);
+ await expect(page.locator('#location-map canvas.maplibregl-canvas')).toHaveCount(1, { timeout: 10_000 });
+ await expect(page.locator('#location-map .maplibregl-marker')).toHaveCount(0);
+});
+
+// ── U4: reopening does not duplicate the canvas; resize keeps it non-zero ──
+test('reopening the panel a second time leaves exactly one canvas with non-zero size', async ({ page }) => {
+ await page.goto('/post');
+ await openLocationDetails(page);
+ await page.locator('.location-details__summary').click(); // close
+ await expect(page.locator('.location-details')).toHaveJSProperty('open', false);
+ await openLocationDetails(page); // reopen
+
+ const canvases = page.locator('#location-map canvas.maplibregl-canvas');
+ await expect(canvases).toHaveCount(1, { timeout: 10_000 });
+ const box = await canvases.first().boundingBox();
+ expect(box && box.width).toBeGreaterThan(0);
+ expect(box && box.height).toBeGreaterThan(0);
+});
+
+// ── R11: a search pick shows a pin on the map ───────────────────────────────
+test('a search-result pick renders a pin on the map', async ({ page }) => {
+ await page.goto('/post');
+ await mockGeocode(page, KYOTO_RESULTS);
+ await page.fill('input[name="data[location_city]"]', 'Kyoto');
+ await openLocationDetails(page);
+ await page.click('#lookup-coords');
+ await page.locator('.location-search-results li button').first().click();
+
+ await expect(page.locator('#location-map .maplibregl-marker')).toHaveCount(1, { timeout: 10_000 });
+});
+
+// ── R11: dragging the marker updates lat/lng (rounded to 6dp) ──────────────
+test('dragging the pin updates lat/lng to the drop location', async ({ page }) => {
+ await page.goto('/post');
+ await mockGeocode(page, KYOTO_RESULTS);
+ await page.fill('input[name="data[location_city]"]', 'Kyoto');
+ await openLocationDetails(page);
+ await page.click('#lookup-coords');
+ await page.locator('.location-search-results li button').first().click();
+
+ const marker = page.locator('#location-map .maplibregl-marker');
+ await expect(marker).toHaveCount(1, { timeout: 10_000 });
+ const before = await page.locator('input[name="data[lat]"]').inputValue();
+
+ // setPin()'s map.panTo() animates the marker into view — wait for it to
+ // settle so the bounding box grabbed below matches where the marker will
+ // actually be when the mouse events land.
+ await page.waitForTimeout(800);
+ const box = await marker.boundingBox();
+ if (!box) throw new Error('marker has no bounding box');
+ const startX = box.x + box.width / 2;
+ const startY = box.y + box.height / 2;
+ await page.mouse.move(startX, startY);
+ await page.mouse.down();
+ await page.mouse.move(startX + 40, startY + 30, { steps: 5 });
+ await page.mouse.up();
+
+ await expect(async () => {
+ const after = await page.locator('input[name="data[lat]"]').inputValue();
+ expect(after).not.toBe(before);
+ expect(after).toMatch(/^-?\d+\.\d{6}$/);
+ }).toPass({ timeout: 5_000 });
+});
+
+// ── R11/R13: typing an invalid value flags the field without crashing ──────
+test('typing an invalid lat value shows the mismatch flag and clears once fixed', async ({ page }) => {
+ await page.goto('/post');
+ await openLocationDetails(page);
+
+ const latEl = page.locator('input[name="data[lat]"]');
+ const lngEl = page.locator('input[name="data[lng]"]');
+ await latEl.fill('not-a-number');
+ await lngEl.fill('135.7681');
+ await lngEl.blur();
+
+ await expect(latEl).toHaveClass(/location-field--mismatch/);
+ await expect(latEl).toHaveAttribute('aria-invalid', 'true');
+ await expect(page.locator('#location-map .maplibregl-marker')).toHaveCount(0);
+
+ await latEl.fill('35.0116');
+ await latEl.blur();
+ await expect(latEl).not.toHaveClass(/location-field--mismatch/);
+ await expect(page.locator('#location-map .maplibregl-marker')).toHaveCount(1);
+});
+
+// ── U4: lazy-load boundary — an ordinary GPS-only submit never fetches maplibre-gl ──
+test('an ordinary submit without opening the panel never fetches the maplibre-gl chunk', async ({ page }) => {
+ const chunkRequests = [];
+ page.on('request', (req) => {
+ if (/maplibre-gl/.test(req.url())) chunkRequests.push(req.url());
+ });
+
+ const tag = `loc-nomap-${Date.now()}`;
+ await page.goto('/post');
+ await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
+ await fillEditor(page, 'Location-override lazy-load guard. Safe to delete.');
+ await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
+ await waitForPhotoUpload(page);
+ await page.locator('.btn-post').evaluate((el) => el.click());
+ await expect(page.locator('.notices')).toContainText('Entry posted successfully!', { timeout: 15_000 });
+ created.push(tag);
+
+ expect(chunkRequests, 'maplibre-gl must not be fetched when the panel is never opened').toHaveLength(0);
+});
+
+// ── Full submit: a search-picked location round-trips into the frontmatter ──
+test('a full submit with a search-picked location saves the expected lat/lng', async ({ page }) => {
+ const tag = `loc-submit-${Date.now()}`;
+ await page.goto('/post');
+ await mockGeocode(page, KYOTO_RESULTS);
+ await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
+ await fillEditor(page, 'Location-override submit test. Safe to delete.');
+ await page.fill('input[name="data[location_city]"]', 'Kyoto');
+ await openLocationDetails(page);
+ await page.click('#lookup-coords');
+ await page.locator('.location-search-results li button').first().click();
+
+ await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
+ await waitForPhotoUpload(page);
+ await page.locator('.btn-post').evaluate((el) => el.click());
+ await expect(page.locator('.notices')).toContainText('Entry posted successfully!', { timeout: 15_000 });
+ created.push(tag);
+
+ const entryDir = findEntry(tag);
+ expect(entryDir, 'Entry folder should exist on disk').toBeTruthy();
+ const md = readEntryMd(entryDir);
+ expect(md).toContain('35.0116');
+ expect(md).toContain('135.7681');
+});