Skip to content

mapping pipeline: candidate zone editing from the dashboard - #101

Merged
MJohnson459 merged 13 commits into
mainfrom
zone-editor-candidate-edit
Aug 22, 2026
Merged

mapping pipeline: candidate zone editing from the dashboard#101
MJohnson459 merged 13 commits into
mainfrom
zone-editor-candidate-edit

Conversation

@MJohnson459

Copy link
Copy Markdown
Contributor

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-map hands over a
map whose rooms are zone_01..zone_07, and renaming them required promoting
the 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>/zones takes a revision: the revision
    being 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_revision continues from the edited revision so a later
    carry-forward can tell which naming is newer, and the audit row names the
    floor and the revision edited.
  • The editor moved out of the operations map into the review pane, the only
    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.
  • Vocabulary editing: kind (a <select> of zone/v0's kinds) and aliases
    (one comma-separated field) beside name and display_name. navigable is
    written only when it deviates from the kind, or a keepout changed to room
    would 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 a
    segmented 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 with require_posegraph=True (the upload bar) while
    promote() and the review pane use False, so edit zones sat 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. accept
    now takes the flag; a derivation is held to its source's bar, uploads are
    unchanged (asserted both ways).
  • 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 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 four
new checks in browser_check.mjs — pixi run fleet-ui-check passes 30/30 against
a 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.json validator
warning visible on derived candidates is task 348.

MJohnson459 and others added 13 commits August 12, 2026 22:16
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>
@MJohnson459
MJohnson459 merged commit d8af25e into main Aug 22, 2026
6 checks passed
@MJohnson459
MJohnson459 deleted the zone-editor-candidate-edit branch August 22, 2026 19:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant