Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,139 @@ 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**.

## Fleet: editing a candidate's zones

The write half of that pane (`ui/zone_editor.mjs`, route `POST /v1/sites/<site>/
floors/<floor>/zones`, operator flow `docs/fleet/README.md` §11, contract
`fleet-api.md`): drag vertices, poses and whole zones on the map, double-click
an edge or vertex to add or remove one, and name the places. **The whole design
is one rule: editing is a derivation, never a mutation.** Saving re-packs *the revision under review* with the submitted zones
and accepts the result as an ordinary candidate — same `accept()` as a robot's
upload, inert until promoted — so a stored revision's bytes never change, which
is what the announced digests depend on, and promotion stays the only write that
moves a floor. Five things are load-bearing. **The edit names a source
revision**, which is what makes an *unpromoted* map editable: `segment-map`
hands over `zone_01`..`zone_07`, and deriving only from the canonical revision
meant promoting those placeholders in order to be allowed to fix them —
publishing a map because it was wrong — besides rebinding coordinates drawn on
one frame onto another's. **A derivation is held to `promote`'s bar, not the
upload's** (`accept(require_posegraph=…)`): a revision with no posegraph cannot
be extended, which is an error for a robot's upload where the session can be
re-run and a *warning* on something already stored, so the strict bar put an
`edit zones` button beside a `promotable` verdict that could only ever fail
(found in a browser; every sim bundle is such a revision). **It lives in the
review pane and nowhere else** — the operations canvas draws the *published*
basemap, so an editor there could only ever edit the published revision, and
"which map are these coordinates against" must have one answer. That also
retires the MVP's frozen-overlay workaround: after a save the pane selects the
new candidate and re-reads its zones, so what is on screen is the saved set from
the server rather than a held-over copy of what was typed. **`navigable` is
written only when it deviates from the kind**, because every zone arrives from
the server with the field filled in (`bundle.zone_term` defaults it) and 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.
And the editor **refuses client-side exactly what the robot's loader refuses**:
a non-dispatchable name, and two zones answering one query (`ambiguities`
mirrors `bundle.ambiguities` — names and aliases, not display names), since a
stored candidate no robot will load is worse than a rejected save. Editing is a
*mode*: the floor picker, the revision list and promote are disabled while it is
up, there is no autosave, and `cancel` discards. One pre-existing defect fell
out and is fixed: 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.

**What the operator can see is the other half of it**, and the first build of
this editor failed it in four ways an operator found in one sitting. **One
`hitTest` answers what a press will take** — the drag reads it, the cursor reads
it, and the hover highlight draws it — because three targets (vertex, pose, 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. Its highlight ring is *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 (measured: white moved 1.5% of the pixels around a
handle, ink moves 12%). **A row is a list, not a form** — it carries what is
compared *across* zones (name, kind, shape) and the rest of zone/v0
(`display_name`, `aliases`, `navigable`, `parent`, `tags`, `description`) edits
in a panel for the *selected* zone, so a new spec field costs no column, and a
2560 px monitor no longer stretches a twelve-character zone name into a text box
the size of a paragraph (rows cap at 640 px). **There is one list, not two**:
one renderer draws a revision's zones read-only and editable alike, `edit zones`
putting controls into the same cells — two renderers were two layouts that
drifted, and the read-only one had stranded the kind half a screen from the
name. Nothing in that column moves when editing opens (asserted in
`browser_check.mjs`), the editing surface has no border of its own, and a zone
is always selected: an empty panel needs a caption, and any caption for it
("select a zone to name it") names one of the several things it is for. And a control exists only where dragging cannot
reach — `⌖` (place a pose) appears **only** for a zone that has none, a
`segment-map` room being an outline with no `x`/`y` and so no cross to drag; a
row's name is a *button*, because in a list the name is what you select by and
an input there put a caret where a click meant "this one" (renaming moved into
the panel, beside the zone's other fields). Two controls were built and then cut
for failing that test: a `+ area`/`− area` cell, and a paragraph of instructions
standing in for the hover feedback before it existed. **A control sits at the
level of the thing it acts on**, which three buttons in one row denied: `save as
candidate` and `cancel` end the mode, so they take the place of `edit zones`
above the list rather than standing beside a control that adds one zone, and
`add zone` is the list's last line. The save's message moved with them, out
of the column of fields for one zone and under the list it is about — with no
height until there is something to say, since a strip reserved for the longest
message (the refusal quoting both zones that answer one query) is a gap over the
first zone that nothing explains. The room comes out of the list instead, the
one thing in that box which scrolls, so nothing above the message moves. It
carries what a save *did*, not that one is happening: the outcome used to be set
on the pane's own line in the far column while this one was cleared, so the only
thing appearing where the work was happening was `saving…`, lasting as long as
the request. The mode's banner is gone with it — `save as candidate` says what
saving does, and the one thing a banner was needed for, the shift modifier, is a
`title` on the surface it applies to.

**A place is named once.** While a zone's machine name is still one nobody
chose (`zone_03`), typing its display name sets it through `slugify` — "The
Kitchen" gives `the_kitchen`, "Café" gives `cafe` (the letter survives, not just
the accent). It is a proposal in a visible field, and it never rewrites a name
an operator has chosen, since `goto` takes that name and a fetch may be scripted
against it; a spelling that cannot become a name at all ("3rd floor") proposes
nothing rather than mangling one. The field also marks an invalid name *as it is
typed*: the rule is the loader's and the save enforces it, but a field that looks
like free text until a save fails does not look like a field with a rule. That
leaves three naming fields doing two jobs — an identifier, a label, and the
other spellings `zones.resolve` will also match.

A zone is drawn under `map.zoneLabel` — `display_name` if it has one, else the
machine name — by the operations map and the editor's own overlay alike, so a
place cannot answer to one name in the list and another while it is being
edited. `color-scheme` is declared per theme in the stylesheet for the same
class of reason: a `select`'s dropdown, a checkbox and a scrollbar are the
browser's to paint, and left to the *system* preference while the page follows
its own, a dark page grows a white dropdown list.

**Every coordinate an edit writes lands on a pixel centre** (`snapToPixel`;
shift is the way off it, chosen over alt because a desktop's window manager
takes alt-drag and a modifier the page never receives is no modifier at all).
The map's resolution is the precision available, so a free-hand vertex is digits
nothing can back — and two zones meant to share a wall land millimetres apart,
differently every time. Three consequences: a *body* drag snaps its delta rather
than each vertex (`snapDelta`), so a room traced onto its walls keeps its shape,
and it is measured from the grab rather than accumulated per move, which would
drift the zone behind the pointer by whatever each rounding threw away; the
outline `withKind` invents starts on the grid, while the **pose stays where it
was taught** — that number was measured by driving a robot there; and nothing
re-snaps a coordinate the operator did not touch.

**The kind decides whether a zone is a point or an area, and `withKind` makes
the geometry follow** (`bundle.POINT_KINDS`: dock, charger, pickup, dropoff,
home). Editing the two separately is what leaves a `dropoff` carrying an outline
nothing reads and a `room` with no extent `zones.containing` can never match —
so naming a bare pose an area gives it a square to drag onto the walls (which is
how an area is drawn in the UI at all), and naming an outlined zone a point
drops the outline and keeps the pose. Two things fall out. The classification is
**guidance, not validation**: `bundle` does not refuse an outline on a charger,
because that would refuse maps taught before the rule existed. And the
point-ward move is **refused** when the outline's centroid lies outside it (a
concave hallway) rather than putting the pose in a wall — `poseFor` returns null
and the select reverts.

## Fleet: the zone vocabulary

The API served the roster, the basemaps and dispatch, but not the one thing a
Expand Down
136 changes: 123 additions & 13 deletions docs/fleet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -663,12 +663,15 @@ outline for a `polygon`, a cross for a bare waypoint, each labelled — so the
`goto <zone>` targets you can type are the ones you can see. They come from the
canonical revision, in that revision's map frame.

Beside the map's floor label is the **canonical revision** it is showing, and,
when the floor has candidates waiting, a button into the **review** pane —
which is where a candidate is looked at and promoted (§11). The map pane keeps
no promote control of its own: this canvas draws robots on the *published*
basemap, so promoting from beside it would mean promoting a map you have not
seen.
Beside the map's floor label is the **canonical revision** it is showing, and a
button into the **review** pane — which is where a candidate is looked at, its
zones named, and the map promoted (§11). It says how many candidates the floor
on screen has when it knows, and it is there either way: above 760 px the tab
bar is hidden, so a button that appeared only for a floor with candidates was
the sole door to a pane whose whole point is the floors *no robot is reporting*.
The map pane keeps no promote control of its own: this canvas draws robots on
the *published* basemap, so promoting from beside it would mean promoting a map
you have not seen.

![The dashboard on a phone](../images/fleet-ui-phone.webp)

Expand Down Expand Up @@ -893,16 +896,123 @@ which appears whenever the floor on screen has something waiting. It shows:
revision came from, when it was mapped, its size and resolution, the free/
occupied/unknown split, whether it carries a posegraph (i.e. whether mapping
can be continued in this frame), its bytes and digest.
- **The zones in it**, and — the part that is easy to miss — whether they are
the revision's own or **inherited from the floor**. 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. The pane says so in words,
because the canvas cannot.
- **The zones in it**, and — the part that is easy to miss — an `inherited`
mark beside the heading when they are not the revision's own. A revision that
carries no zones is drawn with the floor's, taught in a previous session's
frame: they draw perfectly over the new map and are out by however far the two
origins differ, which the canvas cannot show. Zones that belong to the map
they are drawn on are marked nothing at all — that is what "zones" means.
- **The promote button**, which is the same audited flip `fleetctl promote`
makes.

Everything except that button is a read. Nothing you do here changes any floor
until you promote.
Everything except that button and the zone editor below is a read. Nothing you
do here changes any floor until you promote.

### Naming the rooms on it, before you promote it

`pixi run segment-map` finds the rooms of a map but cannot know what they are
called, so a fresh revision arrives with `zone_01`..`zone_07`. **`edit zones`**,
beside the zone list, is where they get their names — on the candidate's own map,
where you can see which room is which.

The controls are the map and the list together:

- **On the map**: drag a vertex to follow a wall, drag a zone to move footprint
and pose together, drag a pose cross to move where the robot is sent,
double-click an edge to add a vertex or a vertex to remove it. A polygon needs
three, so the last removal is refused rather than quietly making a line.
**Whatever the next press would take is highlighted under the pointer**, and
the cursor says which it is: a crosshair over a vertex, a move cursor over a
pose or a zone body, and the map's own grab cursor everywhere else — where a
drag pans instead of editing.
- **Everything you drag lands on a pixel centre** — the map's own grid, so two
zones meant to share a wall share the same numbers, and a coordinate never
claims precision the map does not have. A whole zone moves by whole pixels, so
a traced room keeps its shape. **Hold shift to move freely**, for the rare
case that wants a coordinate between two pixels. Only what you drag is
snapped: a pose taught by driving a robot there is a measurement, and it is
left exactly where the robot said, while an outline this editor invents starts
on the grid.
- **It is the same list either way.** The zones of a revision are listed under
the map whether or not you are editing them — name, kind, shape — and
`edit zones` puts controls into those rows rather than replacing them with a
second list. Nothing moves when you click it: the rows stay where they are and
the fields open beside them.
- **A row is a list you pick from**: the name selects that zone, beside it the
**kind**, then the shape it has. `⌖` appears only for a zone with no pose at
all (a segmented room is an outline, so there is no cross to drag) and arms
the next map click as its pose. `×` deletes the zone; **`add zone`**, the last
line of the list, drops a square at the view centre to be dragged into shape
and named.
- **Where a control sits says what it acts on.** `save as candidate` and
`cancel` take the place of `edit zones` above the list, because they end the
edit that button began; `add zone` is in the list, because that is what it
adds to. What the save says — a refusal, or the candidate it wrote — appears
under the list, in room the list gives up as the message arrives, and stays
until another revision is opened.
- **Selecting a row** opens that zone's own fields beside the list — and a zone
is always selected, so they are always showing something: its **name**
(renaming is a deliberate act, not a side effect of clicking the list), the
**display name** an operator reads — and which the map is labelled with, here
and in the operations view, as soon as it is set — **also called** (the other spellings
`goto` should accept — an MCP dispatcher turning "the galley" into a command
matches these), **navigable**, the zone it is **inside**, **tags**, and a
**description**. They live here rather than in the row because they belong to
one zone at a time, and because a column each would make the list unreadable
long before zone/v0 ran out of fields.

**The kind decides whether a zone is a point or an area, and the geometry
follows it.** A `charger`, `dock`, `pickup`, `dropoff` or `home` is a pose to
drive to; everything else — `room`, `corridor`, `keepout`, `slow`, a plain
`area` — is a place with extent, and "am I in it" is the question it exists to
answer. So changing the kind changes the shape: call a taught waypoint a `room`
and it gets an outline to drag onto the walls; call an outlined zone a `charger`
and the outline goes, leaving the pose. That is how an area is drawn here, and
it is one decision rather than two that can contradict each other.

The one refusal: an outline whose centre falls outside it (a concave hallway)
cannot become a point on its own, because there is no pose to fall back on —
place one with `⌖` first.

A zone taught by driving reads as an `area` until you say otherwise: `save-zone`
writes no kind, and `bundle.zone_term` defaults a missing one to `area` rather
than inventing one. Beyond geometry, three kinds change what a robot does today
— `keepout` and `slow` are not destinations (`goto` and `fetch` both refuse
them), and `segment-map` writes `room` — the rest are vocabulary a planner may
read over `/v1/zones`.

**You should not have to name a place twice.** A machine name is what `goto`
takes (lowercase, digits, `_`, and the field says so as you type rather than at
save), and a display name is what a person reads — 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`. It is a proposal, in the field, editable; a name you have already chosen
is never rewritten, because `goto` takes it and a `fetch` may be scripted
against it. **Aliases** are the third naming field and a different job: other
spellings a dispatcher may *say* for the same place, which `goto` also matches.

Two names the same is refused before it is saved — the robot's loader refuses a
vocabulary where one query answers to two zones rather than picking by luck, so
the editor must not produce one. A name a dispatcher cannot type (`Café`, `Drop
Off`) is refused the same way.

**Saving derives a new candidate**: `save as candidate` sends the edited set,
and the server re-packs the revision you were editing with those zones in place
of its own. The revision you edited is untouched — including when it is the
published one — and the new candidate is selected in the pane, so the zones on
screen afterwards are the saved ones read back from the server. Promote it when
it looks right. Two consequences worth knowing:

- **Iterating costs a candidate each save.** Editing a candidate derives from
*it*, so a floor's list grows while you work; the registry keeps the canonical
revision plus the five newest candidates, so the intermediates fall off on
their own.
- **A revision that inherited the floor's zones stops inheriting.** The saved
candidate carries them, which is what you want: inherited zones were taught in
another session's frame, and dragging them onto this map is the correction.

`cancel` discards the edit. There is no autosave and nothing is written until
you save, so an edit you are unsure about costs nothing to abandon.

**A floor with nothing published yet works the same way** — which was not always
true: the dashboard used to fetch a floor's revisions only after its basemap had
Expand Down
Loading