From 378da83796416e1511318d8cef4864edfc822be6 Mon Sep 17 00:00:00 2001 From: Valentin PLANES Date: Sun, 2 Aug 2026 14:52:37 +0200 Subject: [PATCH] frontend: line every centred element up on the window's centre The active-filter banner sat visibly right of the search field above it. Auditing every centred element across ten widths and four dock states turned up two independent causes. The map's overlay layer (hints, selection bar, filter banner) insetted by each dock separately, so it centred on the VISIBLE map rather than the window -- 109px out from the search field with just the layers dock open, and worse with a tool dock. Insetting symmetrically fixed the centre but then squeezed the layer narrower than a wide bar, at which point the auto margins collapsed and it drifted again. The layer now spans the full window exactly like #map and never changes size, so its centre is the window's centre unconditionally. A bar wider than the gap between the docks runs under one; the docks paint above it, so it is clipped rather than overlapping, and at any normal width there is room to spare. The editor toolbar was separately a quarter of the window out, from a leftover alignToolbar() that measured the search box and wrote an inline `left` in VIEWPORT coordinates. That was correct when the top bar was a row of floating islands over a full-window map; once the toolbar moved into an insetted overlay layer its containing block was no longer the viewport, so the value was wrong by exactly the layer's offset. CSS centring does the job now, so the function is gone. While in here: overlay children are centred by grid `justify-self` rather than `left: 50%` + a translate -- no percentage to resolve, and it lands on whole pixels so the text stays crisp. And the app bar's pill labels now drop at 1150px rather than 980px: the bar centres its search field by giving both side clusters an equal share of the leftover space, which only holds while each side's content fits its share, and with labels the right-hand cluster stopped fitting at about 1150. ui_behaviour.py gains a check that every centred element agrees with the window centre across dock states, plus one for the altitude rail's 4px track. Verified over 40 width/dock combinations. Co-Authored-By: Claude Opus 5 (1M context) --- map/static/map/editor.js | 24 +++++-------- map/static/map/map.css | 76 +++++++++++++++++++++------------------- tools/ui_behaviour.py | 62 ++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 53 deletions(-) diff --git a/map/static/map/editor.js b/map/static/map/editor.js index 8cddd7b..040db3e 100644 --- a/map/static/map/editor.js +++ b/map/static/map/editor.js @@ -395,25 +395,18 @@ var EditorTool = (function() { // ---- Toolbar (edit count / undo / redo) --------------------------------------- - // The top bar centers the search box between its two flex side sections - // (equal-width while there's room, but content-floored on narrow - // windows), so "centered" for top notifications means the SEARCH BOX's - // center, not the viewport's -- align to it directly. - function alignToolbar() { - var searchBox = document.getElementById("searchBox"); - if (!searchBox || !toolbar) { - return; - } - var box = searchBox.getBoundingClientRect(); - if (box.width > 0) { - toolbar.style.left = (box.left + box.width / 2) + "px"; - } - } + // No JS centring here any more. This used to measure the search box and set + // an inline `left`, because the top bar was a row of floating islands over + // the map and "centred" could not be expressed in CSS. The app bar and the + // map's overlay layer are now both window-width, so plain CSS centring lines + // the toolbar up with the search field for free -- and the inline `left` it + // used to write was in VIEWPORT coordinates while the element's containing + // block is the overlay layer, which is exactly what threw the toolbar off + // centre once that layer stopped being the whole viewport. function updateToolbar() { var any = actions.length > 0 || redoStack.length > 0; toolbar.style.display = any ? "flex" : "none"; - alignToolbar(); editCountEl.textContent = actions.length + " edit" + (actions.length === 1 ? "" : "s"); undoBtn.disabled = applyInFlight || actions.length === 0; redoBtn.disabled = applyInFlight || redoStack.length === 0; @@ -1193,7 +1186,6 @@ var EditorTool = (function() { e.returnValue = ""; } }); - window.addEventListener("resize", alignToolbar); }); return { diff --git a/map/static/map/map.css b/map/static/map/map.css index 083362e..c624f23 100644 --- a/map/static/map/map.css +++ b/map/static/map/map.css @@ -31,6 +31,7 @@ --dock-left-inset: var(--dock-left-width); --dock-right-inset: 0px; + /* One shared feel for anything that moves, and the lifted shadow the genuinely floating things (hints, popovers, tooltip) share. Docked surfaces get a border instead -- they are attached, not hovering. */ @@ -386,22 +387,40 @@ body.dockResizing { the part of it you can actually SEE, so this layer -- unlike #map -- insets by whatever the docks are covering. It is the only thing that needs to know the dock widths, and it costs the map nothing to tell it. */ +/* The layer the still-floating things live in: the hint bars, the selection + action bar, the active-filter banner. + + It spans the FULL window, exactly like #map, and never changes size for any + reason but a window resize. That is what makes its centre the window's + centre -- the same thing the app bar centres its search field on -- so a + hint always lines up with the search field above it, whatever the docks are + doing. Two earlier versions insetted it by the docks so hints would centre + on the *visible* map: that put them 109px out of line with the search field, + and, once a dock made the layer narrower than a wide bar, the auto margins + collapsed and the bar drifted further still. + + Trade-off: a bar wider than the gap between the docks runs underneath one. + The docks paint above this layer (z-index 20 vs 15), so it is clipped rather + than overlapping, and at any normal window width there is room to spare. + + Children are centred by `justify-self`, NOT by `left`/`right`/margins. That + is not a style preference: an absolutely positioned child that is + display:none while this layer's width changes can come back with a stale + USED value for a percentage or resolved `left` -- measured as declared + `left: 0px` but used `left: 800px`, which put the editor toolbar a quarter + of the window off-centre until something forced it to reflow. Grid alignment + never computes a `left` at all, so there is nothing to go stale. Children + still place themselves vertically with top/bottom. */ #mapOverlays { position: fixed; - top: var(--appbar-height); - left: var(--dock-left-inset); - right: var(--dock-right-inset); - bottom: 0; + inset: var(--appbar-height) 0 0 0; + display: grid; pointer-events: none; z-index: 15; - transition: left 0.12s var(--panel-ease), right 0.12s var(--panel-ease); -} - -@media (prefers-reduced-motion: reduce) { - #mapOverlays { transition: none; } } #mapOverlays > * { + justify-self: center; pointer-events: auto; } @@ -1344,8 +1363,6 @@ button:focus-visible, select:focus-visible, input[type="checkbox"]:focus-visible #selectionPanel { position: absolute; bottom: 22px; - left: 50%; - transform: translateX(-50%); z-index: 600; display: flex; align-items: center; @@ -1375,8 +1392,6 @@ button:focus-visible, select:focus-visible, input[type="checkbox"]:focus-visible #editorToolbar { position: absolute; top: 58px; - left: 50%; - transform: translateX(-50%); z-index: 600; display: flex; align-items: center; @@ -1401,8 +1416,6 @@ button:focus-visible, select:focus-visible, input[type="checkbox"]:focus-visible #editorHint { position: absolute; bottom: 64px; - left: 50%; - transform: translateX(-50%); z-index: 1401; padding: 7px 14px; background: var(--surface-2); @@ -1503,8 +1516,6 @@ button:focus-visible, select:focus-visible, input[type="checkbox"]:focus-visible #activeFilterBanner { position: absolute; top: 68px; /* Just below the floating search pill. */ - left: 50%; - transform: translateX(-50%); z-index: 500; display: flex; align-items: center; @@ -2605,8 +2616,6 @@ button:focus-visible, select:focus-visible, input[type="checkbox"]:focus-visible #networkHint { position: absolute; bottom: 22px; - left: 50%; - transform: translateX(-50%); z-index: 600; padding: 8px 16px; background: var(--surface-2); @@ -2958,8 +2967,13 @@ button:focus-visible, select:focus-visible, input[type="checkbox"]:focus-visible } /* Below this the pills lose their labels -- the icons are unambiguous next to - each other, and the search field is worth more than the words. */ -@media (max-width: 980px) { + each other, and the search field is worth more than the words. + The threshold is set by geometry, not taste: the app bar centres its search + field by giving both side sections an equal share of the leftover space, and + that only holds while each side's content FITS its share. With labels the + right-hand cluster needs ~260px, and it stops fitting at about this width -- + below which the search field would start drifting off-centre. */ +@media (max-width: 1150px) { #mainSearchWrap { flex: 1 1 auto; } @@ -2991,14 +3005,6 @@ button:focus-visible, select:focus-visible, input[type="checkbox"]:focus-visible width: min(var(--dock-right-width), 78vw); } - /* At this size a dock covers most of the map, so insetting the hints inside - what is left would squeeze them to nothing -- let them use the whole - window and sit under the dock instead. */ - #mapOverlays { - left: 0; - right: 0; - } - /* Dragging a width is a pointer-and-space affordance; neither applies here. */ .dockResizeHandle { display: none; @@ -3017,18 +3023,14 @@ button:focus-visible, select:focus-visible, input[type="checkbox"]:focus-visible } } -/* Leaflet's controls live inside #map, which now runs under the tool dock -- - push the right-hand ones (zoom, attribution) clear of it. */ +/* Leaflet's controls live inside #map, which runs under the docks -- push them + clear. Each side insets by its own dock (unlike #mapOverlays above): these + hug an edge rather than centring on anything, so there is nothing to keep + symmetric. Untransitioned for the same reason as #mapOverlays. */ .leaflet-right { right: var(--dock-right-inset); - transition: right 0.12s var(--panel-ease); } .leaflet-left { left: var(--dock-left-inset); - transition: left 0.12s var(--panel-ease); -} - -@media (prefers-reduced-motion: reduce) { - .leaflet-right, .leaflet-left { transition: none; } } diff --git a/tools/ui_behaviour.py b/tools/ui_behaviour.py index 94f464f..8a182bb 100644 --- a/tools/ui_behaviour.py +++ b/tools/ui_behaviour.py @@ -249,6 +249,68 @@ def holdsStill(label, action): check("map never moves: dragging the dock's width", not bad, "%d/%d frames" % (len(bad), len(rows))) + # ---- Everything centred agrees on one centre ------------------------------ + # + # The app bar centres its search field on the window; the floating hints, + # the selection bar and the dialogs must line up with it. They have drifted + # apart twice: once when the map's overlay layer insetted by each dock + # separately (109px out), and once from a leftover JS hack that measured the + # search box and wrote an inline `left` in VIEWPORT coordinates onto an + # element whose containing block was the overlay layer (a quarter of the + # window out). Both were invisible to every other check. + page.evaluate("""() => { + const show = (i, how) => { const e = document.getElementById(i); if (e) e.style.display = how; }; + show('activeFilterBanner', 'flex'); show('selectionPanel', 'flex'); + show('editorToolbar', 'flex'); show('editorHint', 'block'); show('networkHint', 'block'); + }""") + page.wait_for_timeout(400) + CENTRES = """() => { + const c = el => { if (!el) return null; const r = el.getBoundingClientRect(); + return r.width ? Math.round(r.left + r.width / 2) : null; }; + const id = s => document.getElementById(s); + return { window: Math.round(window.innerWidth / 2), + searchPill: c(id('searchBox')), + filterBanner: c(id('activeFilterBanner')), + selectionBar: c(id('selectionPanel')), + editorToolbar: c(id('editorToolbar')), + editorHint: c(id('editorHint')), + networkHint: c(id('networkHint')) }; + }""" + + def centred(label): + c = page.evaluate(CENTRES) + mid = c.pop("window") + off = ["%s %+d" % (k, v - mid) for k, v in c.items() + if v is not None and abs(v - mid) > 1] + check("centred on the window: " + label, not off, ", ".join(off)) + + centred("dock open") + page.evaluate("NetworkTool.open()") + page.wait_for_timeout(700) + centred("tool dock open") + page.evaluate("NetworkTool.close()") + page.evaluate("document.getElementById('menuButton').click()") + page.wait_for_timeout(700) + centred("dock hidden") + page.evaluate("document.getElementById('menuButton').click()") + page.wait_for_timeout(500) + # The altitude rail's 4px track has its own centring inside the 64px rail. + track = page.evaluate("""() => { + const rail = document.getElementById('altitudePanel').getBoundingClientRect(); + const bg = document.querySelector('.altitudeTrackBg').getBoundingClientRect(); + return [Math.round(bg.width), Math.round((bg.left + bg.width/2) - (rail.left + rail.width/2))]; + }""") + check("altitude track is 4px and centred in the rail", + track[0] == 4 and abs(track[1]) <= 1, track) + + # These were forced visible; leave them hidden or they sit over the map and + # swallow the clicks the checks below need. + page.evaluate("""() => { + ['activeFilterBanner','selectionPanel','editorToolbar','editorHint','networkHint'] + .forEach(i => { const e = document.getElementById(i); if (e) e.style.display = 'none'; }); + }""") + page.wait_for_timeout(300) + # ---- Search -------------------------------------------------------------- page.click("#mainSearchInput") page.fill("#mainSearchInput", "constructor")