mapping pipeline: candidate zone editing from the dashboard - #101
Merged
Conversation
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>
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>
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>
**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>
…isplay 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>
**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>
"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>
…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>
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>
`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>
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>
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>
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>
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.
Fleet UI: name a candidate's zones before promoting it (commit 262dc44, branch
zone-editor-candidate-edit, worktree .claude/worktrees/zone-editor-346; branched
off origin/main 6464f57 so it sits on top of #100's review pane. Unpushed.)
The MVP (PR #95, merged) edited the canonical revision only, which left the
case the mapping pipeline is built around unreachable:
segment-maphands over amap whose rooms are
zone_01..zone_07, and renaming them required promotingthe placeholders first — publishing a map because it was wrong — besides
rebinding coordinates onto a frame they were not drawn on.
What changed:
POST /v1/sites/<site>/floors/<floor>/zonestakes arevision: the revisionbeing edited (omitted = canonical, as before). Still a derivation — that
revision's map bytes re-packed with the submitted zones, accepted as an
ordinary candidate, inert until promoted; nothing stored is written.
vocabulary_revisioncontinues from the edited revision so a latercarry-forward can tell which naming is newer, and the audit row names the
floor and the revision edited.
place a candidate's own map is on screen. Editing is a mode there (floor
picker, revision list and promote locked; no autosave). After a save the pane
selects the new candidate and re-reads its zones, which retires the MVP's
frozen-overlay workaround — what is on screen is the saved set from the server.
kind(a<select>of zone/v0's kinds) andaliases(one comma-separated field) beside name and display_name.
navigableiswritten only when it deviates from the kind, or a
keepoutchanged toroomwould silently stay undispatchable. The editor refuses client-side exactly
what the robot's loader refuses: a non-dispatchable name, and two zones
answering one query (mirrors
bundle.ambiguities).⌖arms the next map click as a zone's pose — the only way to give one to asegmented room (a polygon with no x/y, whose pose the robot otherwise derives
as a centroid).
Two defects found and fixed on the way, both invisible without a browser:
accept()validated withrequire_posegraph=True(the upload bar) whilepromote()and the review pane use False, soedit zonessat beside a"promotable" verdict and could only ever fail for any revision without a
posegraph — which is every sim bundle and every rsync-seeded floor.
acceptnow takes the flag; a derivation is held to its source's bar, uploads are
unchanged (asserted both ways).
candidates, and above 760 px the tab bar is hidden too — so at desk width the
pane built for floors no robot is reporting was reachable only via a floor a
robot was reporting. Now always present.
Verified: 13 API tests in test_zone_edit.py (derive from a candidate and prove
the candidate's map bytes travel; a floor with nothing published; vocabulary
revision; unknown and path-traversing revision; the posegraph bar both ways),
50 node tests in ui_test.mjs (kind list read out of bundle.py so it cannot
drift, alias round-trip, ambiguity, the navigable rule, editor-lives-in-review,
.zone-rows[hidden]), 256 pytest in mote_fleet/test, pre-commit clean, and fournew checks in browser_check.mjs —
pixi run fleet-ui-checkpasses 30/30 againsta real broker, server, three fake robots and chrome, desk width and emulated
phone. Screenshots from that run are in the job tmp dir (fleet-ui-zones.png
shows the saved candidate selected with its own zones).
Out of scope, still open: carry-forward accept/reject needs task 345 (not built
yet); grid snapping + shared vertices is task 350. The
upload.jsonvalidatorwarning visible on derived candidates is task 348.