From edb42c1cae31244d4d34936b6b0709843a1eb4dd Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Tue, 11 Aug 2026 21:31:44 +0100 Subject: [PATCH 1/3] Fleet UI: a dedicated pane for reviewing a candidate map before promoting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` - - + an operator promotes it. Promotion happens in the review pane, + where the candidate's own map is on screen — this is only the + signpost that says there is something to look at. --> + @@ -68,6 +67,50 @@

map

+ +
+
+

review

+ + +
+ +
+
+ +

+ published: +

+
+ +

why it is promotable

+

+
    +
    + + +

    +
    + +
    + +

    zones

    + +

    +
    +
    +
    +
    +

    no robot selected

    @@ -126,6 +169,7 @@

    task status

    roster + diff --git a/mote_fleet/server/ui/map.mjs b/mote_fleet/server/ui/map.mjs index f070c39..96fd4ce 100644 --- a/mote_fleet/server/ui/map.mjs +++ b/mote_fleet/server/ui/map.mjs @@ -134,8 +134,16 @@ export class MapView { // -- data ------------------------------------------------------------- - setMap(map, image) { - const changed = !this.map || this.map.site !== map.site || this.map.floor !== map.floor; + // `refit` overrides the default test for a caller that knows better. The + // fleet map changes basemap only when it changes floor, so that is the test; + // the review view swaps between revisions *of* one floor, where holding the + // viewport is how two candidates get compared — but a revision of a different + // size has to be re-fitted or it lands off screen. + setMap(map, image, refit = null) { + const changed = + refit === null + ? !this.map || this.map.site !== map.site || this.map.floor !== map.floor + : refit; this.map = map; this.image = image; if (changed) { diff --git a/mote_fleet/server/ui/review.mjs b/mote_fleet/server/ui/review.mjs new file mode 100644 index 0000000..ea00ce6 --- /dev/null +++ b/mote_fleet/server/ui/review.mjs @@ -0,0 +1,440 @@ +// Candidate review: see the map you are about to promote, then promote it. +// +// M4's rule is that uploading is not publishing, which puts a decision in front +// of an operator — and until this view existed, the only thing on screen to +// make it with was a timestamp in a `

    @@ -88,7 +89,10 @@

    review

    -

    why it is promotable

    + +

    can this be promoted?

    diff --git a/mote_fleet/server/ui/review.mjs b/mote_fleet/server/ui/review.mjs index ea00ce6..a1e9b51 100644 --- a/mote_fleet/server/ui/review.mjs +++ b/mote_fleet/server/ui/review.mjs @@ -73,22 +73,43 @@ export function defaultRevision(detail) { ); } -// Why this revision may or may not be promoted, said before the click rather -// than discovered by it. The verdict is the *validator's* — the same report the +// Whether this revision may be promoted, said before the click rather than +// discovered by it. The verdict is the *validator's* — the same report the // server re-runs at promotion — so this can never encourage a promotion the // server will refuse, nor discourage one it would accept. +// +// **The bar is exactly "no errors", and the verdict has to say so**, because +// the list underneath it is not the reason for the verdict: warnings are what +// is imperfect about a revision that passes anyway (a missing posegraph +// navigates perfectly and simply cannot be extended). A bare "valid, with +// warnings" over three complaints reads as a claim with its own evidence +// against it — which is how this was first written, and it left an operator +// asking what the answer actually was. So the verdict answers yes or no, names +// the criterion, and introduces the list as what it is. export function promotability(revision) { if (!revision) return { promotable: false, verdict: 'no revision selected', notes: [] }; const warnings = revision.warnings || []; if (!revision.ok) { - return { promotable: false, verdict: 'not promotable', notes: revision.errors || [] }; + return { + promotable: false, + verdict: 'no — the validator refuses it:', + notes: revision.errors || [], + }; } if (revision.canonical) { - return { promotable: false, verdict: 'already the published map', notes: warnings }; + return { + promotable: false, + verdict: warnings.length + ? 'this is already the floor’s published map. Warnings:' + : 'this is already the floor’s published map', + notes: warnings, + }; } return { promotable: true, - verdict: warnings.length ? 'valid, with warnings' : 'valid — promotable', + verdict: warnings.length + ? 'yes — no errors. These warnings do not block it:' + : 'yes — the validator found nothing wrong with it', notes: warnings, }; } diff --git a/mote_fleet/server/ui/style.css b/mote_fleet/server/ui/style.css index 24d8794..ded046d 100644 --- a/mote_fleet/server/ui/style.css +++ b/mote_fleet/server/ui/style.css @@ -705,6 +705,24 @@ main:has(.review-pane.active) > .pane:not(.review-pane) { border-radius: 6px; } +/* The picker carries a `site/floor` value and nothing else said so — with the + candidate count appended it read as a status line rather than a control. */ +.review-floors { + display: flex; + align-items: center; + gap: 8px; +} + +.review-floors label { + color: var(--dim); + flex: none; +} + +.review-floors select { + flex: 1; + min-width: 0; +} + .revision-rows { display: flex; flex-direction: column; diff --git a/mote_fleet/test/ui_test.mjs b/mote_fleet/test/ui_test.mjs index 7e400cc..4508dcf 100644 --- a/mote_fleet/test/ui_test.mjs +++ b/mote_fleet/test/ui_test.mjs @@ -471,6 +471,24 @@ test('the promote button follows the validator, not the view', () => { assert.equal(promotability(null).promotable, false); }); +test('the verdict answers the question and says what the bar is', () => { + // The heading asks "can this be promoted?". A verdict that only classifies + // the revision ("valid, with warnings") over a list of complaints answers a + // different question and reads as its own counter-evidence — an operator + // looking at the first build of this pane asked what the answer was. + assert.match(promotability(goodRevision).verdict, /^yes\b/); + assert.match(promotability(warnedRevision).verdict, /^yes\b/); + assert.match(promotability(brokenRevision).verdict, /^no\b/); + // The bar is "no errors", and warnings are explicitly not part of it. + assert.match(promotability(warnedRevision).verdict, /no errors/); + assert.match(promotability(warnedRevision).verdict, /do not block/); + // A list with nothing in it must not be introduced as though it had + // something in it. + assert.doesNotMatch(promotability(goodRevision).verdict, /:$/); + assert.match(promotability(warnedRevision).verdict, /:$/); + assert.match(promotability(brokenRevision).verdict, /:$/); +}); + test('provenance is read off the payload the registry already sends', () => { const rows = Object.fromEntries( provenanceRows({ From 9593934dd008e982a281984ec376569c01ff6f08 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Wed, 12 Aug 2026 17:06:23 +0100 Subject: [PATCH 3/3] Review pane: say the verdict as a state, not as a conversation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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.` 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 Claude-Session: https://claude.ai/code/session_01ArvMnoUP7kJbdnrZjeLftb --- mote_fleet/server/ui/app.mjs | 2 ++ mote_fleet/server/ui/index.html | 12 ++++--- mote_fleet/server/ui/review.mjs | 62 ++++++++++++++++++++++----------- mote_fleet/server/ui/style.css | 18 +++++++--- mote_fleet/test/ui_test.mjs | 60 ++++++++++++++++++++++--------- 5 files changed, 109 insertions(+), 45 deletions(-) diff --git a/mote_fleet/server/ui/app.mjs b/mote_fleet/server/ui/app.mjs index e0e931b..6d10675 100644 --- a/mote_fleet/server/ui/app.mjs +++ b/mote_fleet/server/ui/app.mjs @@ -607,6 +607,7 @@ function bind() { reviewRevisions: 'review-revisions', reviewVerdict: 'review-verdict', reviewVerdictNotes: 'review-verdict-notes', + reviewNotesLabel: 'review-notes-label', reviewProvenance: 'review-provenance', reviewZones: 'review-zones', reviewZoneSource: 'review-zone-source', @@ -652,6 +653,7 @@ export async function boot() { revisions: dom.reviewRevisions, verdict: dom.reviewVerdict, verdictNotes: dom.reviewVerdictNotes, + notesLabel: dom.reviewNotesLabel, provenance: dom.reviewProvenance, zones: dom.reviewZones, zoneSource: dom.reviewZoneSource, diff --git a/mote_fleet/server/ui/index.html b/mote_fleet/server/ui/index.html index 53312d5..1a7bcdf 100644 --- a/mote_fleet/server/ui/index.html +++ b/mote_fleet/server/ui/index.html @@ -89,12 +89,16 @@

    review

    - -

    can this be promoted?

    + +

    validation

    +
      + +

      provenance