dashboard: promote picker cannot preview the candidate it promotes - #100
Merged
Conversation
…ting it
M4's rule is that uploading is not publishing, which leaves an operator a
decision to make — and the only thing on screen to make it with was a
timestamp in a `<select>`. Selecting a candidate showed nothing: the canvas
beside the picker was always the *canonical* basemap, so promotion was an act
of faith in a filename.
Three GETs answer "what am I promoting", mirroring `/v1/maps/<site>/<floor>/…`
for a revision that is not canonical: `…/revisions/<rev>/{map.json,map.png,
zones.json}`. Three things there are load-bearing. `read_map`'s **`image_url`
is now revision-aware** — a transform from the revision and pixels from
`/v1/maps` would have drawn the published map under the candidate's label,
which looks entirely convincing and is the exact failure being removed. The
zones read is revision-scoped and deliberately **not gated on a published
map** where `read_zones` is, because the review that matters most is the first
candidate on a floor with nothing published; that does not loosen the
vocabulary/binding split, since naming a revision is naming a map frame and
these stay off `/v1/zones`. And it reports **`source: revision|floor`**:
`_zones_file` falls back to the floor's `zones.yaml`, and inherited zones were
taught in a previous session's frame — they draw perfectly over the new map and
are wrong by however far the two origins differ, which no coordinate can say.
The browser gets a fourth pane (`ui/review.mjs`) rather than a mode on the map
pane. That pane is fleet operations: live robots on the published basemap,
follow, fit, dispatch. A candidate is a different map frame with no robots in
it, and the view has to grow zone editing, a carry-forward report and a build
report. So review is a *mode* — opening it stands the operations panes down at
every width, because two canvases (one canonical with robots, one a candidate
without) is the confusion a dedicated view avoids. It addresses a floor
directly from `/v1/sites`, not through the selected robot: the floor worth
reviewing is often one no robot is reporting. It shows every revision newest
first (invalid ones included, with their reason), the candidate's own map and
zones, why it is promotable, its provenance, and the promote button. The map
pane keeps only an `N candidates — review` signpost.
**The bootstrap deadlock is fixed.** A floor's revisions were fetched only
after its basemap loaded, behind an early return, so a floor whose only
revisions were candidates listed none of them — and the first promotion on any
floor could never be made from a browser. The two fetches are now independent.
Verified: `pytest mote_fleet/test` 250 passed (6 skipped without a broker;
`test_e2e_map_registry.py` passes against a real mosquitto), `ui_test.mjs` 42
passed under node, and `pixi run fleet-ui-check` 25/25 in a real headless
Chrome — including that the review canvas paints from a *revision's* image URL
rather than the canonical one, that a phone reaches the pane and its canvas
fits itself, and that the first promotion on a floor with nothing published
goes through end to end. The browser run caught two defects fixed here: the
promotion result note was wiped by the reload that followed it, and the phone
layout squeezed the review body's grid rows so the canvas landed on top of the
provenance.
Editing a candidate's zones is task 346, which lands in this pane's zone rows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ArvMnoUP7kJbdnrZjeLftb
… asking about Three things an operator hit on the first real look at the pane. **"why it is promotable" promised a reason and gave a verdict.** The heading asked a question; underneath it sat "valid, with warnings" and then three bullets, every one of which is a complaint. Read top to bottom that is a claim followed by its own counter-evidence, with nothing saying why the complaints do not count. They do not count because the bar is exactly one thing — the validator found no *errors* — and nothing on screen said so. The heading is now "can this be promoted?", which the next line answers: "yes — no errors. These warnings do not block it:", or "no — the validator refuses it:". The list is introduced as what it is, and only when it has something in it. **Two warnings with word-for-word identical text.** `CONTINUABLE` is `(map.posegraph, map.data)` and `bundle.validate` emitted one message per missing file, so a revision without a posegraph produced the same sentence twice and read as two problems. slam_toolbox writes the pair together and either half missing means the same thing, so it is one message naming whichever halves are absent. This is the shared validator, so `save-map` and the upload path get the same collapse. **The floor picker had no label.** Its value is a `site/floor` with the candidate count appended, which reads as a status line rather than a control. It now says what it is. Verified: bundle 50 passed (two new cases — both halves missing, and one), mote_fleet 250 passed, ui_test 43 passed with the verdict's shape pinned (answers yes/no, names the "no errors" bar, and only ends in a colon when a list follows). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ArvMnoUP7kJbdnrZjeLftb
The previous commit fixed a real problem — "valid, with warnings" over three
complaints never said why the complaints did not count — but fixed it in the
wrong register. A heading phrased as a question ("can this be promoted?")
answered by "yes — no errors. These warnings do not block it:" reads as chat,
not as a control panel: every other heading on this page is a noun, and the
answer wrapped onto a second line and dangled on a colon.
The answer is a **state**. This page already has an idiom for one — a coloured
dot and a word, as the roster and the subsystem list say it — so the verdict is
`promotable` / `not promotable` / `already published` beside a dot, with the
colour on the dot rather than on the text. The part that was genuinely missing,
that the bar is "no errors" and warnings do not reach it, becomes a caption on
the list: "warnings — these do not block promotion", or "errors — these block
promotion". That is where it belongs, since it says what the list *is*.
It also supplies a break the pane needed: the bullets ran straight into
`revision 20260802T145731` with nothing between them, so the warnings and the
provenance blurred into one block. The facts now sit under their own
`provenance` heading.
`promotability()` returns `state` and `notesLabel` alongside the verdict, so all
of this stays a pure function with the DOM work downstream of it. The test that
pinned the question/answer shape is replaced by one that pins the opposite: at
most three words, no leading yes/no, no trailing punctuation.
One gap fell out of writing that test: it asserted a `.dot.<state>` rule for
every state, and `unknown` has none — it is the base `.dot` colour, expressed by
the absence of a modifier. The assertion was wrong rather than the stylesheet,
so it now describes that design instead of adding a redundant rule to satisfy
itself.
Verified: ui_test 45 passed, fleet-ui-check 25/25 in a real browser.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ArvMnoUP7kJbdnrZjeLftb
MJohnson459
added a commit
that referenced
this pull request
Aug 22, 2026
* Fleet UI: name a candidate's zones before promoting it The zone editor shipped against the *canonical* revision, which left the case the mapping pipeline is actually built around unreachable. `segment-map` hands over a map whose rooms are called `zone_01`..`zone_07`; renaming them meant deriving from the published revision, so the placeholders had to be **promoted in order to be allowed to fix them** — publishing a map because it was wrong — and the coordinates were then rebound onto a frame they had not been drawn on. So the edit now names the revision it edits (`POST …/floors/<floor>/zones` takes a `revision`; omitted, it is the canonical one as before), and the editor moves into the review pane that #100 built, which is the only place a candidate's own map is on screen. Editing stays a *derivation*: the named revision's map bytes are re-packed with the submitted zones and accepted as an ordinary candidate, inert until promoted, so nothing stored is ever written and promotion stays the only write that moves a floor. Five things are load-bearing. **A derivation is held to `promote`'s bar, not the upload's.** A revision with no posegraph cannot be extended — an error for a robot's upload, where the session can be re-run, and a *warning* on something already stored, which navigates perfectly and which `promote` accepts. `accept` therefore takes `require_posegraph`. Found in a browser, not by reading: every sim site bundle is such a revision, so `edit zones` sat beside a `promotable` verdict and could only ever fail. **The vocabulary half is editable, and `navigable` is not written when the kind already implies it.** Kind and aliases join name and display_name in each row. Every zone arrives from the server with `navigable` filled in (`zone_term` defaults it from the kind), so writing it back verbatim would carry a `keepout`'s `false` onto a zone just changed to `room` — a room nothing can be dispatched to, with nothing on screen to say why. Empty lists are dropped for the same reason: they say nothing the default does not. **The editor refuses exactly what the robot's loader refuses.** A name a dispatcher cannot type, and two zones answering one query — `ambiguities` mirrors `bundle.ambiguities` (names and aliases, not display names), because the loader *refuses* an ambiguous vocabulary rather than resolving it by dict order, and a stored candidate no robot will load is worse than a rejected save. **A pose can be placed, not only dragged.** A segmented room is a polygon with no `x`/`y`, so it drew no cross to drag and the robot derived a centroid — the middle of the outline rather than where you would send a robot. `⌖` arms the next map click. **After a save the pane selects the new candidate and re-reads it**, so the zones on screen are the saved ones from the server. That retires the MVP's frozen overlay, which existed only because the operations map had nothing to re-render but the stale set the edit was made from (read as data loss, 2026-08-02). Editing is a mode: floor picker, revision list and promote are locked while it is up, there is no autosave, `cancel` discards. One pre-existing defect fell out. The map pane's review button was hidden unless the floor on screen had candidates, and above 760 px the tab bar is hidden too — so the pane built for floors no robot is reporting was reachable only through a floor a robot *was* reporting. It is now always there, and says how many candidates the floor has when it knows. Verified: 13 API tests (`test_zone_edit.py`, incl. deriving from a candidate, a floor with nothing published, the vocabulary revision continuing from the edited revision, an unknown/traversing revision, and the posegraph bar), 50 node tests (the kind list read out of `bundle.py` so it cannot drift, alias round-trip, ambiguity, the `navigable` rule, the editor's home), and four new checks in `browser_check.mjs` — `pixi run fleet-ui-check` passes 30/30 against a real broker, server, fleet and chrome, at desk width and on an emulated phone. Still open on the task: carry-forward accept/reject (needs task 345), and grid snapping / shared vertices (task 350). Claude-Session: https://claude.ai/code/session_01LUGSpEmdmC68oBaWp6B5Qz Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Zone editor: say what a drag will do, and stop the row being a form First use of the editor, by the operator it was built for. Four of the five things wrong with it were the same thing: the canvas showed the zones but never what the *pointer* was about to take. **One `hitTest` now answers that**, and the drag, the cursor and the hover highlight all read it — three targets (a vertex, a pose cross, a zone body) plus a fourth meaning "this drag pans the map" is unguessable from a static canvas, and three copies of that ordering would eventually disagree with each other. The cursor is a crosshair over a vertex, a move cursor over a pose or a body, and the map's own grab everywhere else. The hovered handle grows and takes a ring — in ink, not white: a canvas gets no cascade, and the surface under it is not the theme's background but the basemap, whose free space is white in both themes. A white ring moved 1.5% of the pixels around a handle where ink moves 12%, which is the difference between a highlight and a rumour. **A row is a list, not a form.** It carried name, display name, kind, aliases, footprint and two buttons, so on a 2560 px monitor each of those stretched into a text box the size of a paragraph — a form expecting an essay for a field holding "kitchen". The row is now what you compare *across* zones (name, kind) and the rest of zone/v0 — display name, aliases, navigable, parent, tags, description — edits in a panel for the *selected* zone, beside the list rather than under it. That is also the answer to "what happens when zones grow another field": a column costs every row width, a field in the panel costs nothing. Three controls were built and then cut, each for the same reason — a control must earn its place against dragging, or against the tool that already does the job: - **`+ area` / `− area`**, which turned a taught waypoint into an outline and back. `save-zone --radius` teaches an outline from the robot, `segment-map` gives every room one, and `add zone` draws a new one here; the inverse existed only to undo the thing that should not have been there. Its first form was worse: a cell reading `4 vertices` that silently added an outline when clicked — information and action in one place, with no way back. - **`⌖` on every row.** A pose you can see is a pose you can drag, so it now appears only for a zone that has none: a `segment-map` room is an outline with no `x`/`y` and draws no cross to take hold of. - **A paragraph of instructions** above the panel, which is what a UI writes when it has not answered the question. The hover feedback answers the drag half; a placeholder answers the rest. One defect fell out and is fixed: `placed` was read before its declaration in the row renderer, which emptied the editor. `browser_check.mjs`'s new checks catch it (they assert the editor opens with rows), which is what they are for — the row renderer needs a DOM and so has no unit test. Verified: 52 node tests (`hitTest`'s ordering including the map-pan case, the cursor for each target, the list round-trip), 256 pytest in `mote_fleet/test`, pre-commit clean, and `pixi run fleet-ui-check` 31/31 against a real broker, server, fleet and chrome — including a new check that selecting a zone opens its own fields beside a four-cell row. The hover was measured in the browser rather than assumed: a sweep of the canvas returns crosshair only over vertices, move over bodies and poses, and the ring is drawn exactly when the target changes. Claude-Session: https://claude.ai/code/session_01LUGSpEmdmC68oBaWp6B5Qz Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Zone editor: the kind decides point or area, and the row is a list Two corrections from the operator, both of which the previous round had backwards. **A zone's kind and its shape are one decision, not two.** A `charger` is a pose to dock at; a `room` is a place with walls whose whole job is answering "am I in it". Edited separately they drift into a `dropoff` carrying a seven-vertex outline nothing reads, and a room with no extent `zones.containing` can never match — and the previous answer to "how do I add an area here", a `+ area` button beside the kind, treated them as unrelated. It is now the kind that says which a place is, and `withKind` makes the geometry follow: name a taught waypoint a `room` and it gets an outline to drag onto the walls, name an outlined zone a `charger` and the outline goes, leaving the pose. That is how an area is drawn in this UI at all, which is what was asked for; pointing at `save-zone --radius` was answering a UI question with an on-robot workflow. `bundle.POINT_KINDS` holds the split beside `ZONE_KINDS` and `CONSTRAINT_KINDS` so the robot and the editor cannot disagree about it (`ui_test.mjs` reads the python and compares). It is deliberately **guidance, not validation**: a bundle carrying an outline on a `charger` still loads, because a rule that refused one would refuse maps taught before the rule existed. The one refusal is in the editor, where the point-ward move needs a pose to fall back on: an outline whose centroid lies outside it (a concave hallway) is refused rather than putting the pose in a wall, and the select reverts. **A row is a list you pick from, so the name is a button.** It was a text input, which put a caret where a click was meant to select — the row drives the panel and the map highlight, and neither is where the pointer is. The name now selects (and takes the accent when its row is the selected one), the row shows a hover and a selected state, and renaming moved into the panel beside the zone's other fields, where it is a deliberate act rather than a side effect of choosing a zone to look at. Verified: 54 node tests (the point/area split read out of `bundle.py`, `withKind` in both directions including the concave refusal, the selectable-row stylesheet seam), 256 pytest in `mote_fleet/test`, pre-commit clean, and `pixi run fleet-ui-check` 32/32 — with a new end-to-end check that a bare waypoint called a `room` in the browser comes back from the server as a polygon. Claude-Session: https://claude.ai/code/session_01LUGSpEmdmC68oBaWp6B5Qz Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Zone editor: snap edits to the map's pixels, and stop the map resizing **Every coordinate an edit writes now lands on a pixel centre**, with shift as the way off the grid. The map's resolution is the precision available: a vertex anywhere inside a pixel covers exactly the same cells as one at its centre, so a free-hand coordinate is digits nothing can back — and two zones meant to share a wall end up millimetres apart, differently every time. Half of task 350. Three parts of that are decisions rather than mechanics: - **A body drag snaps its delta, not its vertices** (`snapDelta`), so a room traced onto its walls keeps its shape when it is nudged; and the delta is measured from where the drag started against the zone as it was then, because rounding each move's increment instead leaves the zone drifting behind the pointer by whatever each rounding threw away. - **The pose stays where it was taught.** `withKind`'s invented outline starts on the grid, but a pose measured by driving a robot to it is data, and moving it two centimetres to tidy a number would be inventing some. Nothing re-snaps a coordinate the operator did not touch, either — an edit changes what you edited. - **Shift, not alt**, for free movement: a desktop's window manager takes alt-drag for moving windows, and a modifier the page never receives is not a modifier. It is the one thing here nobody could discover, so the note the pane already prints when editing opens says it, and goes away with it. **And the map no longer resizes when the editor opens.** The editing surface is taller than the read-only list it replaces, and with the canvas taking whatever height was left over, clicking `edit zones` shrank the thing being edited. The zones half of the pane is now one reserved box that either mode sits in, so the canvas height is the same before, during and after — measured 577 px through all three at 1700x1000, and asserted in `browser_check.mjs`, which is the only place a layout that reflows can be caught. Verified: 55 node tests (snapping is idempotent, never moves a point more than half a pixel, and keeps a dragged shape rigid), 256 pytest, pre-commit clean, `pixi run fleet-ui-check` 34/34 — with new checks that the outline the editor draws sits on the map's grid and that the canvas does not move. Confirmed by hand in a browser against a real revision: a dragged vertex saved to a pixel centre, the same drag with shift held saved off-grid, and the outline invented for a pose at (0, 0) — which is not itself a pixel centre on this map — came back on the grid at (±1.010, ±0.983-ish) with the pose left at (0, 0). Claude-Session: https://claude.ai/code/session_01LUGSpEmdmC68oBaWp6B5Qz Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Fleet UI: theme the browser's own controls, and label a zone by its display name **A `select` was the one control on the page wearing the browser's clothes.** Two halves to that. The shared `input, button, .button` rule never named `select`, so it kept the UA's background and border; and the parts of a control the browser draws for us — a dropdown's list, a checkbox, a scrollbar — follow the *system* colour preference unless the page says otherwise, so a dark page grew a white dropdown. `color-scheme` is now declared in each of the two theme blocks, which are the things that decide the theme, rather than as a `light dark` that would defer to the same system preference the palette is not using. **A zone with a display name is drawn under it.** `map.mjs` already labelled the fleet map that way; the zone editor's overlay was still drawing the machine name, so naming `zone_01` "The Kitchen" changed the list and the panel while the map went on calling it `zone_01`. The rule moves into `map.zoneLabel` and both drawers read it — a place should not answer to one name in the list and another while it is being edited. One defect fell out of testing that in a browser: the detail panel's fields updated the zone and never redrew the canvas, so the new label appeared only when something else happened to repaint. Every field there belongs to a zone that is drawn, so each change redraws — a repaint of one canvas on a change event, against a map that quietly disagrees with the panel beside it. Verified: 57 node tests (the label rule, and both stylesheet seams read out of the file), `pixi run fleet-ui-check` 34/34, pre-commit clean. In a browser at `prefers-color-scheme: dark`: the kind select computes to the page's own background and text colours, and the map label follows a display name being set and reverts to the machine name when it is cleared. Claude-Session: https://claude.ai/code/session_01LUGSpEmdmC68oBaWp6B5Qz Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Zone editor: one list either way, and a place named once **Two renderers were two layouts that drifted.** The review pane drew the revision's zones and the editor drew its own list beside the same map, so `edit zones` swapped one for the other: the rows moved, the columns changed, and a bordered box appeared around a part of the page that had not gone anywhere. The read-only one had also stretched across the whole pane, stranding a zone's kind half a screen from its name. There is now one renderer and one row shape — name, kind, shape, place-pose, delete — and `edit zones` puts controls into the cells that already held text. Measured at 1500x950: the first row's top, left, width and cell count are identical before and after, and so is the canvas above it. What changes is what is in the cells, and a column of fields opening beside them. **A zone is always selected**, so that panel always has something in it. The empty state needed a caption, and every caption for it named one of the several things the panel is for ("select a zone to name it" — you can also set its kind, its aliases, whether it is navigable…). Deleting the selected zone selects its neighbour rather than emptying the panel again. **And a place is named once.** `name`, `display_name` and `aliases` are three fields doing two jobs — an identifier, a label, and the other spellings `zones.resolve` will match — but typing the same room's name twice is a chore whatever the justification. So while the machine name is still one nobody chose (`zone_03`, as `segment-map` and `add zone` mint them), typing a display name sets it: "The Kitchen" gives `the_kitchen`, "Café" gives `cafe` — the letter survives, not just the accent. It is a proposal in a visible field, never rewrites a name an operator has chosen (`goto` takes that name, and a fetch may be scripted against it), and proposes nothing at all for a spelling that cannot become a name ("3rd floor") rather than mangling one. That the machine name has a strict format was also true and invisible: the rule is the robot loader's, the save enforced it, and until then the field looked like free text. It now marks an invalid name as it is typed. Verified: 59 node tests (`slugify` including accents and the unusable case, which names it may replace, and the stylesheet seams for the one-list row shape and the invalid-name mark), 256 pytest, pre-commit clean, `pixi run fleet-ui-check` 34/34. In a browser: a new zone went from `zone_01` to `the_kitchen` on its display name being typed, kept that name when the display name changed again, and `Drop Off` was marked while it was typed. Claude-Session: https://claude.ai/code/session_01LUGSpEmdmC68oBaWp6B5Qz Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Review pane: say where zones came from as a state, not a caption "taught in this revision's own frame" is a label for the absence of a problem, written in words that only mean something to a reader who already knows the problem — and it sat under the heading on every revision, which is where a caption goes and not where a fact goes. Its counterpart was a sentence and a half of justification. The ordinary case now says nothing: zones that belong to the map they are drawn on are what "zones" means. The exception gets a word — an `inherited` pill beside the heading, in the idiom the roster and the verdict already use, with the reason in its title: a revision carrying no zones of its own is drawn with the floor's, which were taught on another map and line up only as far as the two frames do. `zoneSource` returns null for "nothing to say" rather than a reassurance, and the pill lives in the heading row so that having nothing to say does not move the list. Verified: 59 node tests, `pixi run fleet-ui-check` 34/34, pre-commit clean. In a browser: the pill is hidden on a revision carrying its own zones and reads `inherited` on the one that does not. Claude-Session: https://claude.ai/code/session_01LUGSpEmdmC68oBaWp6B5Qz Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Zone editor: a row is the same height either way, and details gets a heading Two pixels per row, and every row: the selection border was added when the list became editable, so `edit zones` grew the whole list slightly. A row reserves it in both modes now — the border is transparent until a row is selected, which is what it was always for. Measured at 1500x950: rows 37 px and the first row's top unchanged, before and after. The details column also gets a `details` heading in the same style as `zones`, so the second column is announced the way the first one is and their headings sit on the same line (both at y=638 in that measurement). `browser_check.mjs` now takes the list's whole shape — canvas height, every row height, the first row's top, and the cell count — and asserts it is identical either side of `edit zones`. Each part of that has been wrong at least once: a second list with its own columns, a box drawn round the editing one, and now this border. Verified: 59 node tests, `pixi run fleet-ui-check` 34/34, pre-commit clean. Claude-Session: https://claude.ai/code/session_01LUGSpEmdmC68oBaWp6B5Qz Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Zone editor: the zone list fills the box it was given The list carried a `max-height: 180px` from when it lived inside the editing panel, and the reserved zones area is 300 px — so it stopped a third of the way up, leaving an empty strip beneath it, and started scrolling at four rows on a screen with room for seven. It now takes the height it is given rather than a number of its own: the grid row it sits in, down to the bottom of the reserved area, scrolling inside that when there are more zones than fit. On a phone there is no reserved box — the pane scrolls — so the list is simply as long as it is. Measured at 1500x950: the list is 257 px and its bottom edge is the box's, in both modes; six zones show without a scrollbar, and fourteen scroll inside it without moving anything around them. Claude-Session: https://claude.ai/code/session_01LUGSpEmdmC68oBaWp6B5Qz Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Zone editor: an action sits at the level of the thing it acts on `add zone`, `save as candidate` and `cancel` stood in one row in the details column, which said they were three of a kind — and `edit zones`, which begins the mode two of them end, sat somewhere else entirely. The two that begin and end the whole edit are now one control in one place: `save as candidate` and `cancel` take the place of `edit zones` above the list. `add zone` acts on one item, so it is the list's last line, inside the scroll box — a floor with twenty zones and one with two put the same geometry on screen. The save's message moved with the buttons, onto a line under them: it reports on the whole set, not on the zone whose fields it was sitting beneath. Two lines are reserved whether or not there is anything in them, because the longest message is the refusal quoting both zones that answer one query (89 characters, 3 px past a single 640 px line), and a line that arrived or wrapped would push the list down as it appeared. The panel grew 36 px to pay for it, off a canvas that had 527. Measured at 1500x950, read-only and editing: heading 31 px at y=638, note 36 px at y=675, list 257 px at y=681, canvas 491 px — every number identical either side of `edit zones`, and unchanged again with the longest refusal in the note. `browser_check.mjs` holds the swap and the placement; `ui_test.mjs` holds which part of the markup each control is in. Verified: 60 node tests, `fleet-ui-check` 35/35, 30 server tests, pre-commit clean. Claude-Session: https://claude.ai/code/session_016ZiaFUjoK8dB2U8mMrjL3X Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Zone editor: the save's message costs nothing until there is one The line under the buttons reserved two lines whether or not there was anything in them, so an empty one read as a gap over the first zone that nothing explains. Reserving it was to stop a message pushing the list down as it arrived. It moves under the list instead and reserves nothing. The room comes out of the list — the one thing in the box that scrolls — so the heading, the buttons and every row stay where they are, and an empty message occupies no pixels at all. Measured at 1500x950: heading 31 px at y=638 and the first row at y=675 in both modes, with the note 0 px at the foot of the panel; with the longest refusal in it (both zones that answer one query, 89 characters) the note is 36 px, the list gives up 36 px of its 257, and nothing above it moves. The panel goes back to 300 px, so the canvas has its 527 px again. `browser_check.mjs` asserts it on the refusal it already provokes: the note is 0 px before the save and non-zero after, the first row's top is the same either way, and the message is not clipped. Verified: 60 node tests, `fleet-ui-check` 36/36, pre-commit clean. Claude-Session: https://claude.ai/code/session_016ZiaFUjoK8dB2U8mMrjL3X Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Zone editor: the line under the zones says what the save did It said `saving…` and then nothing: the outcome was set on the pane's own line in the far column, and the editor's line was cleared as the mode closed. So the only message that ever appeared where the work was happening lasted as long as the request. The outcome goes there instead — "candidate <new> saved from <source>; promote it when it looks right" — and stays until another revision is opened. The line now carries what an operator has to read: a refusal, or what a save produced. The mode's banner is gone with it. It occupied that line for the whole edit, which took 36 px off the list on entering the mode, and said what `save as candidate` says by name. The one thing it was needed for — that shift moves freely off the pixel grid — is a `title` on the canvas the modifier applies to. Verified: 60 node tests, `fleet-ui-check` 36/36 (the post-save check now reads the editor's line, so it holds that the message outlives the save), pre-commit clean. Claude-Session: https://claude.ai/code/session_016ZiaFUjoK8dB2U8mMrjL3X Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Zone editor: messages state what happened, and stop there Every message in the pane carried a clause telling the operator what to think about it. They say the fact now: candidate <new> saved from <source>; promote it when it looks right -> candidate <new> saved from <source> <site>/<floor> is on <rev>; robots will pull it -> <site>/<floor> is on <rev> "a" and "b" both answer to "x" — a query matching both cannot be answered -> "a" and "b" both answer to "x" no zones — cancel to leave this revision's zones as they are -> no zones to save <zone> is an outline with no pose inside it — place one with ⌖ first -> <zone> has no pose inside its outline; place one with ⌖ <zone> is a <kind>: a pose, so its outline is gone -> <zone> is a <kind>: outline dropped Same for the tooltips on the kind select, the ⌖ button and the editing canvas. The kind select was also assigning its title twice, the first one dead. A refusal now fits on one line rather than two (measured: the ambiguity message is 18 px where it was 36). Verified: 60 node tests, `fleet-ui-check` 36/36, pre-commit clean. Claude-Session: https://claude.ai/code/session_016ZiaFUjoK8dB2U8mMrjL3X Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Built the dedicated candidate review view (read half). Branch fleet-candidate-review-view, commit edb42c1.
Server (mote_fleet/server/)
Three GETs on REVISION_RE's extended leaf alternation, mirroring /v1/maps///… for a revision that is not canonical: …/revisions//{map.json,map.png,zones.json}. Names go through _names()/NAME_RE, errors through _store/StoreError. _map's json/png tail was factored into _send_map() so canonical and revision reads cannot diverge.
Browser (mote_fleet/server/ui/)
New review.mjs plus a fourth pane. Review is a mode, not a fourth column:
main:has(.review-pane.active)stands the operations panes down at every width, because two canvases — one canonical with robots, one a candidate without — is the confusion a dedicated view exists to remove. It addresses a floor directly from /v1/sites; shows every revision newest first (invalid ones included, with their reason), the candidate's own map and zones on a second MapView, the validator's verdict, provenance from detail() (no new payload), a zone row list structured for 346, and the promote button. The map pane keeps only an "N candidates — review" signpost; its promote picker is gone. MapView.setMap gained an optional refit override so switching between two candidates of one floor keeps the viewport unless their sizes differ. shown() is wired through setupPanes({onShow}) for both canvases.The bootstrap deadlock
Fixed. loadFloor() no longer sits behind the basemap fetch's early return, so a floor whose only revisions are candidates lists them and its first promotion can be made in a browser.
Verification
pytest mote_fleet/test: 250 passed. 13 new tests in test_map_registry.py cover all three routes, candidate-vs-canonical bytes, the zones source field and the floor fallback, 404 on an unknown revision, 400 outside NAME_RE, no operator token required, and the first-promotion case end to end. test_e2e_map_registry.py passes against a real mosquitto. (The 6 skips are the broker-backed ones; test_e2e_fleet/test_fleet_outage additionally fail in this sandbox because install/ is a python3.12 tree and the dev env is 3.11 — confirmed identical on the unmodified checkout, so unrelated to this change. Worth one run underpixi run -e dev test-fleet.)node --test ui_test.mjs: 42 passed. The pane list is updated to ['roster','map','review','detail'], every-pane-has-a-tab and one-pane-active are kept, and review.mjs's route builders are covered so a regression to the canonical path fails a test rather than showing the wrong map — plus list ordering, the promotable verdict, provenance, the inherited-zones wording and the mode-swap CSS rule.pixi run fleet-ui-check: 25/25 in a real headless Chrome (was 21/21). ui_check.py now seeds two candidates — one beside the published revision, one as a whole floor with nothing published — derived from the committed office_world bundle and mirrored, so a pane drawing the canonical map would be visibly caught. New checks: the map-pane signpost, the pane opening on a candidate, 438k painted pixels on the review canvas, the fetched image URL being a revision's rather than the canonical one, the zone list, the verdict plus promote being offered, the phone reaching the pane with its canvas fitting itself, and the first promotion on a floor with nothing published going through. Screenshots: fleet-ui.png, -review.png, -review-phone.png, -phone.png.Docs
fleet-api.md: the three routes in the table and a section under the map registry, including why image_url and source are load-bearing, plus a line in "What the browser is allowed to do" that review is all GETs. README.md: a new "Reviewing one before you promote it" under §11, and §9 updated for the fourth pane and the signpost that replaced the picker. CLAUDE.md: the fourth pane in the M3 paragraph, the three routes and the deadlock fix in the M4 paragraph.
Out of scope as specified: no writes to a candidate's zones.yaml (346), no carry-forward report (345), no build report, no pixel editing. Not pushed — remote operations are yours.
Suggested retitle:
dashboard: a dedicated candidate review view.