From daa5d4c8ef7d01893b00e5a2d73de454a40ca1e4 Mon Sep 17 00:00:00 2001
From: Jesus Armando Anaya
Date: Sun, 9 Aug 2026 20:56:09 -0700
Subject: [PATCH] feat(annotator): one notice surface top-right, and the
forward actions become a pair
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Every sentence the editor floats over the stage now comes out of one anchor
(`EditorNotice`), inset 16px from the stage's top and right edges. It replaces
four placements in three treatments: two destructive badges inside the top bar's
microtext, a full-bleed strip under the header, and the suggest card
bottom-right, which had been clearing the zoom cluster with a hard-coded offset.
The column is a stack, most-blocking first, because a suggest session and a
refused save can both be true at once.
Its body wraps mid-token and its width is `max-w-md`. A model reference is one
unbroken string, so no fixed width guarantees the next one fits: wrapping is the
invariant and the width is comfort.
`Save and stay` moves to the resolve group, immediately after the primary
next-action, filled in `success`. Advance and persist-in-place are two halves of
one gesture and were a bar apart, the second of them the quietest control on the
row. `DESIGN.md` records the two-fill exception, and the count test now sweeps
both fills rather than one. The `⌘S` keycap goes — a chip inverts into a smudge
inside a fill — and the chord is taught by the button's tooltip instead.
The top bar's save state keeps three readings, not four: it says where the work
is, and after a refused save the honest answer there is `unsaved`.
---
DESIGN.md | 87 ++++-
frontend/app/e2e/annotate.spec.ts | 128 ++++++-
.../ui-core/src/annotator/AnnotationPage.tsx | 313 ++++++++++--------
.../ui-core/src/annotator/EditorNotice.tsx | 132 ++++++++
.../ui-core/src/annotator/SuggestPanel.tsx | 85 ++---
.../src/annotator/editorNotice.test.tsx | 233 +++++++++++++
.../ui-core/src/annotator/topBar.test.tsx | 104 +++++-
frontend/ui-core/src/primitives/Button.tsx | 13 +
frontend/ui-core/src/styles.css | 10 +-
frontend/ui-core/src/tokens.ts | 5 +
10 files changed, 880 insertions(+), 230 deletions(-)
create mode 100644 frontend/ui-core/src/annotator/EditorNotice.tsx
create mode 100644 frontend/ui-core/src/annotator/editorNotice.test.tsx
diff --git a/DESIGN.md b/DESIGN.md
index 59513fac..2539a02e 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -219,8 +219,22 @@ Sibling actions are the outlined `secondary` variant — `card` fill, `input` bo
so those are `secondary` too.
The known exceptions, all legitimate: a modal `Dialog`'s confirm button (it overlays the
-page, so it is its own view), and the steps of the ingest stepper (only the active step
-renders).
+page, so it is its own view), the steps of the ingest stepper (only the active step
+renders), and **the annotation editor's paired forward actions**.
+
+That last one is the only place two filled buttons sit side by side, and it is recorded
+rather than tolerated. The editor's top bar carries the primary next-action (`Save and
+next` / `Next`, or `Finish job` on the last frame) in `primary`, and **Save and stay** in
+`success`, immediately to its right. Two filled controls compete when they are two
+answers to *what do I do next*; these are two halves of **one** answer — having finished
+with this frame, advance or persist in place — and a person choosing between them is not
+choosing between two calls to action. Colour is what separates their intent: a second
+near-black beside the first would contend with it rather than pair with it, and the two
+would read as a bar that could not decide. `success` is the only other fill in the
+product, and this is its only filled use.
+
+The count test therefore reads the bar as `bg-primary` exactly once and `bg-success`
+exactly once — still a count, still asserted from both sides.
**The rule is a count, so it is tested as one — in both directions.** A test asserting that
*the* CTA is present, or that a sibling is `secondary`, passes just as happily with two
@@ -239,7 +253,7 @@ annotator and near-black in the gallery, and a *skipped* frame was painted `dest
| Family | Token |
|---|---|
-| Done, settled, succeeded — `annotated`, `accepted`, batch `completed`, ingest `completed`, export `succeeded` | `success` |
+| Done, settled, succeeded — `annotated`, `accepted`, batch `completed`, ingest `completed`, export `succeeded` — **and the annotation editor's `Save and stay` fill** | `success` |
| Waiting on a person — `review_pending` | `warning` |
| Failed — ingest `failed`, export `failed`, a corrupt file, a refusal | `destructive` |
| Nothing has happened, or a decision was taken and nothing is wrong — `unannotated`, `skipped`, batch `draft`, export `queued`/`cancelled` | neutral |
@@ -273,6 +287,14 @@ sixth colour is a compile error rather than a diff nobody notices.
sanctioned hardcoded colour anywhere in the frontend, and `design_tokens.test.mjs` was
already refusing one.
+It gained two companions when the annotation editor's **Save and stay** became a filled
+control: `success-foreground` (`#ffffff`, the ink on that fill) and `success-hover`
+(`#3a896b`). The hover is derived rather than picked — `primary-hover` lifts `primary` by
+(+12, +12, +16) per channel, and the same deltas applied to `success` give this — so the
+bar's two filled controls brighten by the same amount under a pointer instead of each
+having its own feel. They exist for that one control; a status *ink* still uses plain
+`success`.
+
## Typography
System font stack (`-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, …`), **14px
@@ -532,8 +554,8 @@ The page the reference design shows (#56), with measurements verified in v1's so
| Zone | Contents |
| --- | --- |
| Left | back · pinned `v{n}` badge · the frame's identity as a label (the content-hash head — there is no filename on the wire) · the frame microtext `● annotated · Saved` |
- | Centre | the **navigation cluster**: `[⊞] [‹] n/m [›] │ [Skip] [Save and next]` |
- | Right | `n / m annotated` · **Save and stay** (ghost, ⌘S) · the review move (outline) · overflow `⋯` |
+ | Centre | the **navigation cluster**: `[⊞] [‹] n/m [›] │ [Skip] [Save and next] [Save and stay]` |
+ | Right | `n / m annotated` · the review move (outline) · overflow `⋯` |
**Everything that changes the picture on screen is in the centre cluster, and nothing
else is** (#416). The gallery and `‹` `›` used to sit at the far left beside the back
@@ -605,11 +627,21 @@ The page the reference design shows (#56), with measurements verified in v1's so
tooltip saying what it means, because this product has **no annotator identity**
(cf. #282) — a submitted frame is marked for a review pass, not routed to a person.
- **Save is a ghost button again** (#383). #368 removed it on the grounds that it
- duplicated an automatic behaviour — ⌘S saves, navigating saves, settling saves — and
- dogfooding showed what that missed: the chord is invisible, and the overflow put the one
- press meaning *store this now, without going anywhere* two clicks from the work. Ghost
- is the honest weight for a control most people never need.
+ **Save and stay is the second half of the forward gesture, and it sits beside the
+ first.** #368 removed the explicit save on the grounds that it duplicated an automatic
+ behaviour — ⌘S saves, navigating saves, settling saves — and dogfooding showed what that
+ missed: the chord is invisible, and the overflow put the one press meaning *store this
+ now, without going anywhere* two clicks from the work. #383 brought it back as a ghost
+ in the right zone, which fixed the reachability and left the reading wrong: *advance*
+ and *persist in place* are one decision read two ways, and they were a bar apart with
+ the second of them the quietest control on the row.
+
+ It is now the third member of the resolve group — `Skip · [primary next-action] · Save
+ and stay` — **filled in `success`**, which is the recorded exception to *one filled
+ button per view* above. It keeps the frame verbs' lifetime rather than the mode's: a
+ closed batch or a finished job has nothing to save on any frame, so it leaves with Skip
+ and the flow verb; inside a working job it holds its slot, disabled, so the cluster does
+ not change width as somebody walks a mixed job.
**Reabsorption order when the bar runs out of room**: `Save and stay` first (below
`xl`), the review move second (below `lg`), into the overflow; the Skip/Save-and-next
@@ -618,14 +650,33 @@ The page the reference design shows (#56), with measurements verified in v1's so
readout has no overflow row and needs none — it truncates, which is what a sentence may
do and a button may not.
- **Hotkey chips go on the ghost and outline controls and on nothing else** — `⌘S` on
- Save and stay, `X` on Skip, both rows in `core/input/bindings.ts`. A chip is a muted
- box on a bordered ground, which is a *lighter-than-the-surface* treatment: on the one
- filled control it inverts into a dark box inside a dark button and reads as a smudge
- rather than as a key, so the flow verb carries none (#385). Its chord is not the loser
- — `enter` is the one key with two meanings, the polygon ring close while a shape is in
- progress and *finish the frame* otherwise, and both are in the shortcut sheet, which
- derives its rows from the live registry rather than from a hand-written table.
+ **Hotkey chips go on the ghost and outline controls and on nothing else** — `X` on
+ Skip, from `core/input/bindings.ts`. A chip is a muted box on a bordered ground, which
+ is a *lighter-than-the-surface* treatment: inside a filled control it inverts into a
+ dark box on a dark ground and reads as a smudge rather than as a key, so neither the
+ flow verb (#385) nor Save and stay carries one. Neither chord is the loser. `⌘S` is
+ taught by Save and stay's tooltip — the tool strip's own pattern (`Box (B)`) — and
+ `enter` is the one key with two meanings, the polygon ring close while a shape is in
+ progress and *finish the frame* otherwise. Both are in the shortcut sheet, which derives
+ its rows from the live registry rather than from a hand-written table.
+
+ **In-editor messages have one surface, top-right of the stage** (`EditorNotice`). Every
+ sentence the editor floats over the picture goes into one column inset 16px from the
+ stage's top and right edges: a suggest session, a refused save, a refused progress move,
+ and a batch or job that could not be opened. They were four placements in three
+ treatments — two destructive badges inside the top bar's microtext, a full-bleed strip
+ under the header, and the suggest card bottom-right — so *where* a sentence appeared
+ depended on which mutation produced it. Top-right is the corner nothing else occupies:
+ the tool strip is top-left, the object counter bottom-left, and the zoom cluster
+ bottom-right, which the suggest card had been clearing with a hard-coded offset. The
+ column is a stack, most-blocking first, because more than one of those can be true at
+ once. Its width is `max-w-md` and its body wraps mid-token — a model reference is one
+ unbroken string and **no fixed width guarantees the next one fits**, so wrapping is the
+ invariant and the width is comfort.
+
+ The top bar keeps the frame microtext `● annotated · Saved`, which says *where the work
+ is* and has three readings, not four: after a refused save the honest answer there is
+ `unsaved`, and the reason is a sentence in the notice column.
The frame microtext replaces the dot-with-a-tooltip: the word is on the bar beside the
save state, because **status is never colour alone** and a tooltip is a place a word
diff --git a/frontend/app/e2e/annotate.spec.ts b/frontend/app/e2e/annotate.spec.ts
index f9bde407..845ecfbc 100644
--- a/frontend/app/e2e/annotate.spec.ts
+++ b/frontend/app/e2e/annotate.spec.ts
@@ -108,6 +108,16 @@ interface Lifecycle {
refuseJobStart?: string;
/** When set, every `PUT .../progress` refuses 409 with this code instead. */
refuseProgress?: string;
+ /**
+ * When set, every write to `/annotations` refuses 409 with this code and this
+ * message.
+ *
+ * The message is the interesting half: a code with no entry in `REFUSAL_PROSE`
+ * falls through to the server's own wording, which is how an install command —
+ * or a model reference — reaches a person verbatim. It is also the only way to
+ * put an arbitrarily long unbroken token on screen.
+ */
+ refuseSave?: { code: string; message: string };
/** When set, `POST /jobs/{id}/complete` refuses 409 with this code instead. */
refuseJobComplete?: string;
/**
@@ -258,6 +268,9 @@ async function serveApi(
const mine = stored.filter((one) => one.asset_id === assetId);
return route.fulfill({ json: { items: mine, total: mine.length } });
}
+ if (path.endsWith("/annotations") && request.method() !== "GET" && lifecycle.refuseSave !== undefined) {
+ return route.fulfill({ status: 409, json: lifecycle.refuseSave });
+ }
if (path.endsWith("/annotations") && request.method() === "POST") {
// Kept, and stamped with a server id — the kernel mints its own and the page
// refetches to learn them (`jobQueries.ts`). A stub that answered an empty
@@ -2372,8 +2385,9 @@ test("a refusal on the bar is a sentence now, not a kernel identifier", async ({
});
// A raw code rendered as a destructive badge is a kernel identifier in front of
- // a user. This is an *opening* refusal, which has its own badge beside the save
- // state. Both render through `refusalProse`, so both carry the prose.
+ // a user. This is an *opening* refusal, which takes its own notice at the top
+ // of the stage's column. Every refusal renders through `refusalProse`, so all
+ // of them carry the prose and keep the code in `title`.
const state = page.getByTestId("opening-refusal");
await expect(state).toContainText(/not open for annotation/i);
await expect(state).not.toContainText("BATCH_NOT_IN_ANNOTATION");
@@ -2707,3 +2721,113 @@ test("the no-connection panel now has somewhere to send you (#424 D6)", async ({
await expect(page).toHaveURL(/\/inference$/);
await expect(page.getByTestId("inference-screen")).toBeVisible();
});
+
+/**
+ * The notice surface, measured — which is the only way any of it can be claimed.
+ *
+ * Every assertion below is about geometry, and jsdom has none:
+ * `getBoundingClientRect` answers all zeros, and `scrollWidth` / `clientWidth`
+ * answer zero with it. A component test asserting "the notice does not overflow"
+ * would pass with the wrap rule deleted, the width halved and the anchor moved
+ * back to the corner the zoom widget lives in.
+ *
+ * A save refusal is the vehicle because it is the one refusal whose *text* the
+ * stub controls: a code with no entry in `REFUSAL_PROSE` falls through to the
+ * server's own message, so an arbitrarily long unbroken token can be put on
+ * screen the way a real model reference arrives.
+ */
+
+/** 120 characters, no break opportunity anywhere in it. */
+const LONG_MODEL_REF = `IDEA-Research/grounding-dino-tiny@${"0123456789abcdef".repeat(6).slice(0, 86)}`;
+
+/** A job whose every write refuses, carrying `message` back verbatim. */
+async function openRefusingSave(page: Page, sent: Request[], message: string): Promise {
+ await openJob(page, sent, undefined, {
+ ...openedWorld(),
+ refuseSave: { code: "STUB_UNMAPPED_REFUSAL", message },
+ });
+ await drawOneUnsavedBox(page);
+ await page.getByTestId("save-and-stay").click();
+ await expect(page.getByTestId("save-refusal")).toBeVisible();
+}
+
+test("an in-editor notice anchors top-right of the stage, clear of both corners", async ({
+ page,
+}) => {
+ const sent: Request[] = [];
+ await openRefusingSave(page, sent, "the kernel's own wording");
+
+ const stage = (await page.getByTestId("canvas-stage").boundingBox())!;
+ const card = (await page.getByTestId("save-refusal").boundingBox())!;
+ // The stage's hairline, subtracted rather than absorbed into the number: an
+ // absolutely positioned child is offset from the padding edge, so a bare
+ // `boundingBox` comparison reads 17 and would have to be explained as 16 + 1
+ // every time somebody changed the border.
+ const border = await page
+ .getByTestId("canvas-stage")
+ .evaluate((element) => Number.parseFloat(getComputedStyle(element).borderTopWidth));
+
+ // 16px — the `md` step — in from the stage's top and right edges. Measured
+ // against the *stage*, which is the surface the notice floats over; the top bar
+ // is above it and is not what the inset is relative to.
+ expect(Math.round(card.y - stage.y - border)).toBe(16);
+ expect(Math.round(stage.x + stage.width - border - (card.x + card.width))).toBe(16);
+
+ // The two occupied corners, and the whole reason this one was chosen. The
+ // suggest card used to clear the zoom widget with a hard-coded `bottom-16`,
+ // and before that sat *under* it, where the widget's subtree swallowed the
+ // presses meant for the card's own buttons.
+ const strip = (await page.getByTestId("tool-palette").boundingBox())!;
+ expect(card.x).toBeGreaterThan(strip.x + strip.width);
+ const zoom = (await page.getByTestId("zoom-widget").boundingBox())!;
+ expect(card.y + card.height).toBeLessThan(zoom.y);
+});
+
+test("a notice wraps a model reference no fixed width could have fitted", async ({ page }) => {
+ const sent: Request[] = [];
+ await openRefusingSave(page, sent, `Could not reach ${LONG_MODEL_REF} on this machine.`);
+
+ const notice = page.getByTestId("save-refusal");
+ // The whole token is on screen, not an elided prefix.
+ await expect(notice).toContainText(LONG_MODEL_REF);
+
+ // Nothing to scroll sideways: the token broke mid-word rather than pushing the
+ // card's content past its own edge. Widening alone would not have done this —
+ // `wrap-anywhere` is the invariant and the width is comfort.
+ const overflow = await notice.evaluate((element) => element.scrollWidth - element.clientWidth);
+ expect(overflow).toBeLessThanOrEqual(0);
+
+ // And the card itself did not grow out of the stage to make room.
+ const stage = (await page.getByTestId("canvas-stage").boundingBox())!;
+ const card = (await notice.boundingBox())!;
+ expect(card.x).toBeGreaterThanOrEqual(stage.x);
+ expect(card.x + card.width).toBeLessThanOrEqual(stage.x + stage.width);
+ // The page never scrolls sideways for a message.
+ expect(
+ await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
+ ).toBe(true);
+});
+
+/**
+ * The chip's replacement, and it is only provable here.
+ *
+ * Radix opens a tooltip on hover and on focus, and jsdom runs neither for real —
+ * so "the chord is still taught" is a claim about a real pointer over a real
+ * button, on the one control that used to print `⌘S` inside itself.
+ */
+test("Save and stay teaches its chord in a tooltip now the keycap is gone", async ({ page }) => {
+ const sent: Request[] = [];
+ await openJob(page, sent);
+ await drawOneUnsavedBox(page);
+
+ const stay = page.getByTestId("save-and-stay");
+ await expect(stay.locator("kbd")).toHaveCount(0);
+
+ await stay.hover();
+
+ const tip = page.getByTestId("save-and-stay-shortcut").first();
+ await expect(tip).toBeVisible();
+ // `modKey()` spells the platform's own modifier, so the assertion is on the
+ // half that does not move.
+ await expect(tip).toContainText(/Save and stay \((⌘|Ctrl)S\)/);
+});
diff --git a/frontend/ui-core/src/annotator/AnnotationPage.tsx b/frontend/ui-core/src/annotator/AnnotationPage.tsx
index ad5cfd8a..bd4c0291 100644
--- a/frontend/ui-core/src/annotator/AnnotationPage.tsx
+++ b/frontend/ui-core/src/annotator/AnnotationPage.tsx
@@ -171,6 +171,7 @@ import {
import { Eye } from "lucide-react";
import { AnnotatorPanel } from "./AnnotatorPanel";
import { CanvasReassign } from "./CanvasReassign";
+import { EditorNotice, EditorNotices } from "./EditorNotice";
import { ShortcutSheet, modKey } from "./ShortcutSheet";
import { ToolPalette } from "./ToolPalette";
import { ZoomWidget } from "./ZoomWidget";
@@ -1639,11 +1640,10 @@ function Workspace({
is now on screen rather than in a tooltip: **status is never colour
alone** (`DESIGN.md`), and prose is the strongest form of that.
*/}
- ·
-
+
@@ -1949,6 +1949,59 @@ function Workspace({
)}
+
+ {/*
+ The other half of the forward gesture, and the reason it is here
+ rather than a zone away on the right.
+
+ *Advance* and *persist in place* are one decision read two ways —
+ having finished with this frame, do you move on or stay on it — and
+ they were a bar apart, the second of them a ghost behind a `⌘S` chip
+ in the zone that also holds the progress readout and the overflow.
+ Adjacency is what says they are alternatives; a ghost at the far end
+ said the quieter thing, that saving without moving is a convenience.
+
+ **Filled `success`, which is the recorded exception to one filled
+ button per view** (`DESIGN.md`). Two filled controls compete when
+ they are two answers to *what do I do next*; these are two halves of
+ one answer, and colour is what separates their intent where a second
+ near-black would simply contend with the primary.
+
+ **No hotkey chip.** `Chip` is a muted box on a bordered ground,
+ which inverts into a smudge inside a filled control — the same
+ finding that keeps one off the flow verb. The chord is unchanged and
+ is taught by the tooltip instead, which is the tool strip's own
+ pattern (`Box (B)`). Native `disabled` rather than `aria-disabled`,
+ so the tooltip opens exactly while there is something to save — the
+ moment the chord is worth learning — and the shortcut sheet, which
+ derives its rows from the live registry, carries it unconditionally.
+
+ It keeps the frame verbs' lifetime rather than `readOnly`'s: a
+ closed batch or a finished job has nothing to save on any frame, and
+ inside a working job the slot holds so the cluster does not change
+ width as somebody walks a mixed job. Reabsorption is unchanged — it
+ is still the first control the bar gives up below `xl`.
+ */}
+ {frameVerbs && (
+
+
+
+
+
+ Save and stay ({modKey()}S)
+
+
+ )}
@@ -1973,41 +2026,6 @@ function Workspace({
: `${Math.max(0, counts.total - counts.unannotated)} / ${counts.total} annotated`}
- {/*
- The explicit save, on the bar.
-
- Removing it on the grounds that it duplicates an automatic
- behaviour misses this: ⌘S is
- invisible, and the overflow put the one press meaning *store this now,
- without going anywhere* two clicks from the work. It is a **ghost**,
- which is the honest weight — most people never need it, because
- navigating and settling both save.
-
- First to be reabsorbed when the bar runs out of room (decision 4): it
- is the one control on the right whose job the keyboard and every other
- exit already do, and the overflow carries it below `xl`.
-
- Reabsorbed at `xl`.
- That move was what the centred cluster cost while the class field
- still held 192px in the middle: the grid hands each side exactly half
- of what is left, and the right zone is the heavier of the two. With
- the field in the side panel the halves cover the demand again, so the
- patch is reverted rather than kept — measured, not assumed, in
- `e2e/annotate.spec.ts`.
- */}
-
-
{/*
The review move, when the frame declares one — outline,
because the filled slot belongs to the flow verb. Rendered rather than
@@ -2071,16 +2089,21 @@ function Workspace({
{/* `Save and stay`, reabsorbed — `xl:hidden` is the exact inverse of
the button's `hidden xl:inline-flex`, so the control exists once
- at every width. */}
- attempt()}
- >
-
- Save and stay
-
+ at every width. Gated on `frameVerbs` for the same reason the
+ button is: a closed batch or a finished job has nothing to save
+ on any frame, and a reabsorbed copy that outlived its button
+ would be the control existing in two states rather than one. */}
+ {frameVerbs && (
+ attempt()}
+ >
+
+ Save and stay
+
+ )}
{/* The review move, reabsorbed one breakpoint later. */}
{reviewAction !== undefined && (
- {/*
- Why the counter did not move, said where the work is happening.
- Rendered whenever the asset is skipped rather than only after a save: the
- user who is about to draw deserves it more than the one who already has.
- */}
- {/*
- The refusals that had nowhere to go (audit F3 and F4).
-
- `setProgress.isError` and `finishJob.isError` were read **nowhere in this
- file**: pressing Skip, Un-skip, Accept or Finish job against a refusal did
- nothing at all and said nothing about it — the button came back enabled,
- the badge did not move, and the page looked like it had ignored the click.
- Three of those four are one-press actions with no other feedback surface,
- which is what made this the quietest failure in the product.
-
- One line rather than four, because they are mutually exclusive in
- practice — each is a single press and react-query clears the error on the
- next attempt — and because a toolbar with four empty error slots in it is
- a toolbar nobody can read. It sits with the banners rather than in the bar
- for the same reason: the bar is full, and a refusal is a sentence.
- */}
- {actionRefusal !== null && (
-
-
- {refusalProse(actionRefusal)}
-
- )}
-
{/*
Read-only, said out loud and at the top (F2). The `ui-capabilities` rule is
that read-only is a *mode*, not an accident: "open it and let the saves
@@ -2345,30 +2336,108 @@ function Workspace({
)}
{/*
- The suggest tool's own voice — a sibling of the canvas for
- `ToolPalette`'s reason, and in the one corner the editor does not
- already occupy.
-
- Rendered for the whole session rather than only for its refusals: the
- asking state, the found-nothing state and the accept affordance are
- the same question answered differently, and scattering them would
- leave a person assembling one answer from three places.
+ Everything the editor floats over the picture, in one column.
+
+ The order is *how much it stops you*, read downwards. An opening
+ refusal says nothing on this page can be written at all; a save
+ refusal says this frame's work did not land; an action refusal is one
+ button that did not fire; the suggest session is the tool talking
+ about itself. On the ordinary path only the last of the four is ever
+ present, so the ordering only decides what happens on the rare frame
+ where two things are true at once — and there, the one that is not
+ recoverable goes first.
+
+ All four used to live somewhere else: two as badges inside the top
+ bar's microtext, one as a full-bleed strip under the header, and the
+ suggest card bottom-right over the zoom cluster. Four placements for
+ one class of message meant *where* a sentence appeared depended on
+ which mutation produced it.
*/}
- {suggesting !== null && (
-
- )}
+
+ {openingRefusal !== null && openingRefusal !== undefined && (
+ }
+ title={asApiError(openingRefusal).code}
+ >
+ {refusalProse(openingRefusal)}
+
+ )}
+
+ {/*
+ Why the save did not happen. It left the top bar's `● annotated ·
+ Saved` microtext with the rest of the refusals — that readout says
+ *where the work is*, and after a failed save the honest answer there
+ is `unsaved`, which is what it now shows. The reason is a sentence
+ and a sentence needs room; a destructive badge in a 44px row had
+ neither.
+ */}
+ {save.isError && (
+ }
+ title={asApiError(save.error).code}
+ >
+
This frame could not be saved
+
{refusalProse(save.error)}
+
+ Your work is still here — nothing has been discarded.
+
+
+ )}
+
+ {/*
+ The refusals that had nowhere to go (audit F3 and F4).
+
+ `setProgress.isError` and `finishJob.isError` were read **nowhere in
+ this file**: pressing Skip, Un-skip, Accept or Finish job against a
+ refusal did nothing at all and said nothing about it — the button
+ came back enabled, the badge did not move, and the page looked like
+ it had ignored the click. Three of those four are one-press actions
+ with no other feedback surface, which is what made this the quietest
+ failure in the product.
+
+ One notice rather than four, because they are mutually exclusive in
+ practice: each is a single press, and react-query clears the error
+ on the next attempt.
+ */}
+ {actionRefusal !== null && (
+ }
+ title={asApiError(actionRefusal).code}
+ >
+ {refusalProse(actionRefusal)}
+
+ )}
+
+ {/*
+ The suggest tool's own voice.
+
+ Rendered for the whole session rather than only for its refusals:
+ the asking state, the found-nothing state and the accept affordance
+ are the same question answered differently, and scattering them
+ would leave a person assembling one answer from three places.
+ */}
+ {suggesting !== null && (
+
+ )}
+
{drawn} object{drawn === 1 ? "" : "s"}
@@ -2684,39 +2753,22 @@ function AssetProgressDot({ progress }: { readonly progress: string }): JSX.Elem
}
/**
- * Why the page could not open the batch or the job it was asked to open.
- *
- * Nothing at all when there is nothing to say — the common case by far. It renders beside the
- * save state rather than inside it: the two answer different questions, and the
- * one that outlives every save must not be overwritten by the next one, nor
- * overwrite it.
- */
-function OpeningRefusal({ error }: { readonly error: unknown }): JSX.Element | null {
- if (error === null || error === undefined) return null;
- return (
-
- {refusalProse(error)}
-
- );
-}
-
-/**
- * `DESIGN.md`'s save-state indicator: saving, saved, or why it did not.
+ * `DESIGN.md`'s save-state indicator: saving, saved, or not yet stored.
*
- * It must not render the raw kernel `code` — `BATCH_NOT_IN_ANNOTATION` as a
- * destructive badge. A kernel identifier is what a bug report should
- * quote, not what a person should read, so the badge carries the sentence and the
- * code goes in the `title` where somebody filing that report can still find it.
+ * **Three readings, not four.** It used to carry a fourth — the refusal itself,
+ * as a destructive badge — and that reading has moved to the stage's notice
+ * column, where a sentence has room. What is left is the question this readout
+ * actually answers, *where is the work*, and after a refused save the honest
+ * answer is the one it now gives: `unsaved`. The two are not the same fact, and a
+ * readout that swapped one for the other left the person who had just been
+ * refused with no statement at all about whether their boxes still existed.
*/
function SaveState({
dirty,
pending,
- error,
}: {
readonly dirty: boolean;
readonly pending: boolean;
- /** The refusal itself, or `null`. Prose is derived; the code is not the message. */
- readonly error: unknown;
}): JSX.Element {
if (pending) {
return (
@@ -2725,13 +2777,6 @@ function SaveState({
);
}
- if (error !== null && error !== undefined) {
- return (
-
- {refusalProse(error)}
-
- );
- }
if (dirty) {
return (
diff --git a/frontend/ui-core/src/annotator/EditorNotice.tsx b/frontend/ui-core/src/annotator/EditorNotice.tsx
new file mode 100644
index 00000000..01ce3a41
--- /dev/null
+++ b/frontend/ui-core/src/annotator/EditorNotice.tsx
@@ -0,0 +1,132 @@
+/**
+ * The annotation editor's one notice surface: everything the editor has to say
+ * about itself, in one corner, in one treatment.
+ *
+ * ## Why one surface at all
+ *
+ * The editor used to answer in four places. A suggest refusal was a card
+ * bottom-right, a save refusal was a destructive badge inside the top bar's
+ * `● annotated · Saved` microtext, an opening refusal was a second badge beside
+ * it, and a refused Skip / Un-skip / Accept / Finish job was a full-bleed strip
+ * under the header. Four placements and three treatments for one class of
+ * message, so *where* a sentence appeared depended on which mutation produced
+ * it — which is a fact about this file's history and not about anything a person
+ * could learn.
+ *
+ * ## Top-right, and the bottom-right corner is why
+ *
+ * `ZoomWidget` sits at `bottom-3 right-3` and the suggest card cleared it with an
+ * explicit `bottom-16` — one component holding a constant about another, and a
+ * card that had already been observed *under* the widget, where the widget's
+ * subtree swallowed the presses meant for the card's own buttons. Top-right is
+ * unoccupied: the tool strip is top-left, the object counter bottom-left, the
+ * zoom cluster bottom-right.
+ *
+ * The inset is `4` (16px, the `md` step) from the stage's top and right edges.
+ * The stage already begins below the 44px top bar, so this is measured against
+ * the surface the notice floats over rather than against the header.
+ *
+ * ## A stack, not a slot
+ *
+ * More than one thing can be true at once — a suggest session is live and the
+ * save just refused — so the surface is a column, most-blocking first. A single
+ * slot would have to choose, and choosing means hiding a refusal behind a
+ * spinner.
+ *
+ * `pointer-events-none` on the column with `pointer-events-auto` on each card:
+ * the column spans a fixed width down the side of the picture, and an invisible
+ * strip that ate drags along the right edge of the canvas would be a worse defect
+ * than the one this replaces.
+ *
+ * ## Wrapping is the invariant; width is comfort
+ *
+ * A model reference is a single unbroken token —
+ * `IDEA-Research/grounding-dino-tiny@<40 hex>` — and **no fixed width guarantees
+ * the next one fits**, so the body wraps mid-token (`wrap-anywhere`) and the
+ * width is chosen so the common case never has to. `max-w-md` (448px) leaves the
+ * `w-12` tool strip roughly 190px of clearance at the narrowest supported
+ * desktop; see `MAX_WIDTH` below.
+ */
+
+import type { JSX, ReactNode } from "react";
+
+/**
+ * The surface's width, stated here because the number is a measurement rather
+ * than a taste.
+ *
+ * At a 1280px viewport the rail takes 240px, the editor's row spends 24px of
+ * padding and a 12px gap, and the side panel is a fixed `w-72` (288px) — so the
+ * stage is 716px. Inset 16px from its right edge, a 448px card's left edge lands
+ * at 252px, and the tool strip ends at 60px. Roughly 190px of clearance, which
+ * is what "clear of the tool strip" has to mean for a surface that may grow a
+ * line.
+ *
+ * 448px also fits a full `owner/model@revision` reference on one line at
+ * `text-meta`, which is the width the previous 320px could not manage.
+ */
+const MAX_WIDTH = "max-w-md";
+
+/**
+ * The stage's notice column. Rendered once, whether or not it holds anything.
+ *
+ * `z-20` puts it over the tool strip and the zoom widget — both of which sit in
+ * the stage's own stacking order with no `z` of their own — and leaves the
+ * header's `z-50` popover untouched.
+ */
+export function EditorNotices({ children }: { readonly children: ReactNode }): JSX.Element {
+ return (
+
+ {children}
+
+ );
+}
+
+export interface EditorNoticeProps {
+ /** The card's own handle, so a test names the message and not the column. */
+ readonly testId: string;
+ /**
+ * `warn` is the destructive-alert skin — a `destructive` hairline over a 5%
+ * fill, the treatment the suggest card shipped with. `calm` is for the states
+ * where nothing is wrong and a red card would teach somebody to distrust a
+ * working tool.
+ */
+ readonly tone: "calm" | "warn";
+ readonly icon: ReactNode;
+ readonly children: ReactNode;
+ /** The kernel's identifier, where a bug report can quote it. Never the message. */
+ readonly title?: string;
+}
+
+export function EditorNotice({
+ testId,
+ tone,
+ icon,
+ children,
+ title,
+}: EditorNoticeProps): JSX.Element {
+ return (
+
+
+ {icon}
+
+ {/* `min-w-0` lets the column shrink below its content's intrinsic width,
+ which is the half of the wrap rule flexbox owns — without it a long
+ token widens the flex item instead of breaking. */}
+
{children}
+
+ );
+}
diff --git a/frontend/ui-core/src/annotator/SuggestPanel.tsx b/frontend/ui-core/src/annotator/SuggestPanel.tsx
index a6499db5..fa90c1dd 100644
--- a/frontend/ui-core/src/annotator/SuggestPanel.tsx
+++ b/frontend/ui-core/src/annotator/SuggestPanel.tsx
@@ -11,6 +11,10 @@
* not a redirect (which loses the frame), and not a modal (which stops the
* gesture the page exists for).
*
+ * **Where that card sits is `EditorNotice`'s business and not this component's.**
+ * Every message the editor floats over the stage goes to one top-right column, so
+ * this file chooses the sentence and the tone and nothing about the geometry.
+ *
* ## One panel for six states, because they are one question
*
* "What is the suggest tool doing" has six honest answers, and each of them is a
@@ -51,6 +55,7 @@ import {
import { Check, Loader2, Sparkles, TriangleAlert, X } from "lucide-react";
import type { JSX, ReactNode } from "react";
+import { EditorNotice } from "./EditorNotice";
import { Button } from "../primitives/Button";
import type { SuggestBlocker } from "../data/inferenceQueries";
@@ -145,7 +150,7 @@ export function SuggestPanel({
*/
if (isParked(session)) {
return (
- }>
+ }>
{heldClass === null
? "Nothing selected to suggest for"
@@ -172,7 +177,7 @@ export function SuggestPanel({
Put the tool away
-
+
);
}
@@ -181,7 +186,7 @@ export function SuggestPanel({
if (blocker !== null && blocker !== undefined) {
const copy = BLOCKER_COPY[blocker];
return (
-
)}
-
+
);
}
if (session.status === "refused") {
return (
- }>
+ }>
That suggestion could not be made
{/* The server's sentence, verbatim. It is the one that carries the
install command when the cause is a missing extra. */}
@@ -225,13 +230,13 @@ export function SuggestPanel({
Your clicks are still here — press Esc to clear them, or click again to retry.
@@ -293,7 +298,7 @@ export function SuggestPanel({
that is not part of it.
{hasPending(session) && }
-
+
);
}
@@ -322,55 +327,3 @@ function Chip({ children }: { readonly children: ReactNode }): JSX.Element {
);
}
-
-/**
- * The card itself: bottom-right of the stage, clear of the tool strip.
- *
- * Bottom-**right** rather than beside the strip, because the strip is top-left
- * and the object counter is bottom-left: this is the one corner the editor does
- * not already occupy, and a panel that covered the tools would hide the button
- * that arms it.
- */
-function Card({
- testId,
- tone,
- icon,
- children,
-}: {
- readonly testId: string;
- readonly tone: "calm" | "warn";
- readonly icon: ReactNode;
- readonly children: ReactNode;
-}): JSX.Element {
- return (
-
-
- {icon}
-
-
{children}
-
- );
-}
diff --git a/frontend/ui-core/src/annotator/editorNotice.test.tsx b/frontend/ui-core/src/annotator/editorNotice.test.tsx
new file mode 100644
index 00000000..ab4e5a74
--- /dev/null
+++ b/frontend/ui-core/src/annotator/editorNotice.test.tsx
@@ -0,0 +1,233 @@
+/**
+ * The editor's one notice surface, asserted as a *unification* rather than as a
+ * card.
+ *
+ * The claim is not "a refusal renders" — three suites already say that, and they
+ * said it while the refusals were scattered across four placements in three
+ * treatments. The claim is that every sentence the editor floats over the stage
+ * comes out of one anchor, so a message's position stops depending on which
+ * mutation produced it. That is a containment check, which is why it is written
+ * here and not inside whichever component happens to own a given error.
+ *
+ * Geometry is deliberately **not** asserted in jsdom: `getBoundingClientRect`
+ * answers all zeros there, so "top-right, clear of the tool strip, wrapping a
+ * 120-character token" is `e2e/annotate.spec.ts`'s to prove in a real browser.
+ * What this file can prove is *which element contains which*, and that survives
+ * a layout engine's absence.
+ */
+
+import { QueryClient } from "@tanstack/react-query";
+import { render, screen, waitFor } from "@testing-library/react";
+import { userEvent } from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { JSX, ReactNode } from "react";
+
+import { ApiProvider } from "../data/ApiProvider";
+import { writeToken } from "../data/session";
+import { AnnotationPage } from "./AnnotationPage";
+import { TooltipProvider } from "../primitives/Menu";
+import { assetActions, batchActions, jobActions } from "../testing/wire.fixtures.js";
+
+const API = "http://visionset.test";
+const PROJECT = "11111111-1111-4111-8111-111111111111";
+const BATCH = "22222222-2222-4222-8222-222222222222";
+const JOB = "33333333-3333-4333-8333-333333333333";
+const ASSET = "44444444-4444-4444-8444-444444444444";
+
+const SCHEMA = {
+ project_id: PROJECT,
+ version: 1,
+ description: null,
+ created_at: null,
+ provenance: "curated",
+ classes: [{ name: "vehicle", geometry: "bbox", color: "#3355ff", attributes: [] }],
+};
+
+/** The batch's state, which decides whether the page tries to open it. */
+let batchState: "approved" | "in_annotation" = "in_annotation";
+/** The code every write is refused with, or `null` for a wire that accepts. */
+let refuseWith: string | null = null;
+
+function answer(path: string): unknown {
+ if (path === `/jobs/${JOB}`) {
+ return {
+ id: JOB,
+ batch_id: BATCH,
+ state: "in_progress",
+ asset_count: 1,
+ allowed_actions: jobActions("in_progress", { settled: false }),
+ };
+ }
+ if (path === `/batches/${BATCH}`) {
+ return {
+ id: BATCH,
+ project_id: PROJECT,
+ name: "drive-01",
+ state: batchState,
+ schema_version: 1,
+ asset_count: 1,
+ allowed_actions: batchActions(batchState),
+ promoted_asset_count: 0,
+ parent_batch_id: null,
+ progress: {
+ unannotated: 1,
+ annotated: 0,
+ skipped: 0,
+ review_pending: 0,
+ accepted: 0,
+ total: 1,
+ },
+ };
+ }
+ if (path.endsWith("/schema/versions/1") || path.endsWith("/schema")) return SCHEMA;
+ if (path.endsWith("/assets")) {
+ return {
+ items: [
+ {
+ id: ASSET,
+ project_id: PROJECT,
+ modality: "image",
+ content_hash: "abcdef00".padEnd(64, "0"),
+ width: 640,
+ height: 480,
+ format: "png",
+ thumbnail_hash: null,
+ frame_index: null,
+ frame_timestamp: null,
+ source_id: null,
+ ingested_at: null,
+ job_id: JOB,
+ progress: "unannotated",
+ allowed_actions: assetActions("unannotated", { batchState: "in_annotation" }),
+ },
+ ],
+ total: 1,
+ };
+ }
+ return { items: [], total: 0 };
+}
+
+beforeEach(() => {
+ batchState = "in_annotation";
+ refuseWith = null;
+ writeToken("a-token");
+ vi.stubGlobal("matchMedia", (query: string) => ({
+ media: query,
+ matches: true,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ }));
+ vi.stubGlobal("fetch", async (request: Request) => {
+ const path = new URL(request.url).pathname;
+ if (request.method !== "GET") {
+ if (refuseWith !== null) {
+ return new Response(JSON.stringify({ code: refuseWith, message: "refused" }), {
+ status: 409,
+ headers: { "content-type": "application/json" },
+ });
+ }
+ return new Response(JSON.stringify({}), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ });
+ }
+ return new Response(JSON.stringify(answer(path)), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ });
+ });
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ globalThis.sessionStorage.clear();
+});
+
+function mount(node: ReactNode): JSX.Element {
+ return (
+
+ {node}
+
+ );
+}
+
+async function open(): Promise {
+ render(mount());
+ await screen.findByTestId("annotation-page");
+}
+
+describe("the notice column", () => {
+ it("floats over the stage rather than over the bar or the side panel", async () => {
+ // The anchor is the stage, which is what makes "over the picture" true and
+ // what keeps a notice off the tool strip and the object list. It exists
+ // whether or not it is holding anything: an anchor that appeared with its
+ // first message would be a second thing that can be absent.
+ await open();
+
+ const column = screen.getByTestId("editor-notices");
+ expect(screen.getByTestId("canvas-stage").contains(column)).toBe(true);
+ expect(screen.getByTestId("annotation-page").querySelector("header")?.contains(column)).toBe(
+ false,
+ );
+ });
+
+ it("is where a refused progress move lands, not a strip under the header", async () => {
+ // `Skip` against a refusal. It used to be a full-bleed `
` between the
+ // header and the workspace — a fourth placement for the same class of
+ // message.
+ refuseWith = "ASSET_NOT_WRITABLE";
+ await open();
+
+ await userEvent.click(screen.getByTestId("skip"));
+
+ const said = await screen.findByTestId("action-refusal");
+ expect(screen.getByTestId("editor-notices").contains(said)).toBe(true);
+ // Prose, with the kernel's identifier kept where a bug report can quote it.
+ expect(said.textContent).toContain("labeling is settled");
+ expect(said.textContent).not.toContain("ASSET_NOT_WRITABLE");
+ expect(said.getAttribute("title")).toBe("ASSET_NOT_WRITABLE");
+ });
+
+ it("is where a refused opening lands, not a badge in the top bar", async () => {
+ // An `approved` batch declares `start`, so the page sends one — and a
+ // refusal that is not `INVALID_TRANSITION` is a real one and is surfaced.
+ batchState = "approved";
+ refuseWith = "BATCH_NOT_IN_ANNOTATION";
+ await open();
+
+ const said = await screen.findByTestId("opening-refusal");
+ expect(screen.getByTestId("editor-notices").contains(said)).toBe(true);
+ expect(said.textContent).toContain("not open for annotation");
+ expect(said.getAttribute("title")).toBe("BATCH_NOT_IN_ANNOTATION");
+ });
+
+ it("is where the suggest tool speaks, whatever it has to say", async () => {
+ // The card that moved. It is the whole session's voice — an asking state and
+ // a refusal are the same question answered differently — so the surface has
+ // to hold the calm readings as well, or arming the tool would make a panel
+ // jump corners as its state changed.
+ await open();
+ await userEvent.click(screen.getByTestId("class-row-vehicle"));
+ await userEvent.click(screen.getByTestId("tool-suggest"));
+
+ const panel = await screen.findByTestId("suggest-panel");
+ expect(screen.getByTestId("editor-notices").contains(panel)).toBe(true);
+ });
+
+ it("leaves the top bar's microtext saying where the work is, not why it refused", async () => {
+ // The save state used to carry a fourth reading — the refusal itself, as a
+ // destructive badge in a 44px row. It answers *where is the work*, and after
+ // a refused save the honest answer there is `unsaved`.
+ await open();
+
+ await waitFor(() => expect(screen.getByTestId("save-state")).toBeDefined());
+ expect(screen.getByTestId("save-state").textContent).toBe("Saved");
+ // And there is nowhere else on the bar a refusal could still be hiding.
+ const header = screen.getByTestId("annotation-page").querySelector("header");
+ expect(header?.querySelector("[data-testid='opening-refusal']")).toBeNull();
+ expect(header?.querySelector("[data-testid='save-refusal']")).toBeNull();
+ });
+});
diff --git a/frontend/ui-core/src/annotator/topBar.test.tsx b/frontend/ui-core/src/annotator/topBar.test.tsx
index c1fdb352..a1510272 100644
--- a/frontend/ui-core/src/annotator/topBar.test.tsx
+++ b/frontend/ui-core/src/annotator/topBar.test.tsx
@@ -76,6 +76,12 @@ let jobCounts: {
/** Whether the frames arrive carrying a box — what `drawn > 0` reads. */
let annotated = false;
+/**
+ * Whether the batch has closed — the dimension that withholds every move on
+ * every frame at once, which is what the frame verbs' own lifetime keys on.
+ */
+let closedBatch = false;
+
/** The nth asset's id, distinct enough to read in a failure message. */
function assetId(index: number): string {
return `4444444${index}-4444-4444-8444-444444444444`;
@@ -101,7 +107,10 @@ function answer(path: string): unknown {
batch_id: BATCH,
state: "in_progress",
asset_count: 1,
- allowed_actions: jobActions("in_progress", { settled: jobSettled }),
+ allowed_actions: jobActions("in_progress", {
+ batchState: closedBatch ? "completed" : "in_annotation",
+ settled: jobSettled,
+ }),
};
}
if (path === `/batches/${BATCH}`) {
@@ -109,10 +118,10 @@ function answer(path: string): unknown {
id: BATCH,
project_id: PROJECT,
name: "drive-01",
- state: "in_annotation",
+ state: closedBatch ? "completed" : "in_annotation",
schema_version: 1,
asset_count: 1,
- allowed_actions: batchActions("in_annotation"),
+ allowed_actions: batchActions(closedBatch ? "completed" : "in_annotation"),
promoted_asset_count: 0,
parent_batch_id: null,
progress: {
@@ -142,7 +151,9 @@ function answer(path: string): unknown {
ingested_at: null,
job_id: JOB,
progress,
- allowed_actions: assetActions(progress, { batchState: "in_annotation" }),
+ allowed_actions: assetActions(progress, {
+ batchState: closedBatch ? "completed" : "in_annotation",
+ }),
}));
return { items, total: items.length };
}
@@ -175,6 +186,7 @@ beforeEach(() => {
jobSettled = false;
assetCount = 1;
annotated = false;
+ closedBatch = false;
writeToken("a-token");
vi.stubGlobal("matchMedia", (query: string) => ({
media: query,
@@ -623,15 +635,15 @@ describe("the flow verb", () => {
);
});
- it("carries no hotkey chip, unlike its two neighbours on the bar", async () => {
- // `Chip` is a muted box on a bordered ground — right on the ghost and
- // the outline controls, a smudge on the only filled one. The chord is
- // unchanged, which is what the next test asserts; this is about the pixels.
+ it("carries no hotkey chip, and neither does the filled control beside it", async () => {
+ // `Chip` is a muted box on a bordered ground — right on the outline `Skip`,
+ // a smudge inside either fill. Both chords are unchanged, which is what the
+ // next test and the tooltip assert; this is about the pixels.
assetCount = 2;
await open();
expect(screen.getByTestId("save-and-next").querySelector("kbd")).toBeNull();
- expect(screen.getByTestId("save-and-stay").querySelector("kbd")?.textContent).toContain("S");
+ expect(screen.getByTestId("save-and-stay").querySelector("kbd")).toBeNull();
expect(screen.getByTestId("skip").querySelector("kbd")?.textContent).toBe("X");
});
@@ -687,6 +699,80 @@ describe("the flow verb", () => {
});
});
+/**
+ * The forward pair: two filled controls, and the exception that lets them be two.
+ *
+ * `DESIGN.md`'s *one filled button per view* is a count and is tested as one, so
+ * the recorded exception has to be a count too — otherwise "two fills are allowed
+ * here" degrades into "any number of fills are allowed here", which is the rule
+ * with nothing left of it. The sweep below is `filled()`'s twin over `bg-success`,
+ * and both are asserted as whole sets rather than as memberships.
+ */
+describe("the forward-action pair", () => {
+ /** Every `success`-filled control on the bar. `filled()`'s counterpart. */
+ function successFilled(): HTMLElement[] {
+ // `classList.contains`, for `filled()`'s reason: the substring form also
+ // matches `hover:bg-success-hover`.
+ return [...document.querySelectorAll("header button")].filter((button) =>
+ button.classList.contains("bg-success"),
+ );
+ }
+
+ it("puts Save and stay in the resolve group, immediately after the primary", async () => {
+ // Adjacency is the whole claim: *advance* and *persist in place* are one
+ // decision read two ways, and they used to be a zone apart. Document order
+ // inside the cluster is what a reader gets.
+ assetCount = 2;
+ await open();
+
+ const cluster = screen.getByTestId("frame-navigation");
+ const order = [...cluster.querySelectorAll("button[data-testid]")]
+ .map((button) => button.getAttribute("data-testid"))
+ .filter((id) => id === "skip" || id === "save-and-next" || id === "save-and-stay");
+ expect(order).toEqual(["skip", "save-and-next", "save-and-stay"]);
+ });
+
+ it("leaves exactly one primary fill and exactly one success fill", async () => {
+ assetCount = 2;
+ await open();
+
+ expect(filled().map((button) => button.getAttribute("data-testid"))).toEqual(["save-and-next"]);
+ expect(successFilled().map((button) => button.getAttribute("data-testid"))).toEqual([
+ "save-and-stay",
+ ]);
+ });
+
+ it("keeps the pair a pair on the last frame, where Finish job holds the primary", async () => {
+ // The filled slot is contended by arithmetic rather than by a declaration,
+ // and the success half is not part of that contention: you can still save
+ // without leaving the frame you are finishing on.
+ assetCount = 1;
+ jobSettled = true;
+ progress = "annotated";
+ await open();
+
+ expect(filled().map((button) => button.getAttribute("data-testid"))).toEqual(["finish-job"]);
+ expect(successFilled().map((button) => button.getAttribute("data-testid"))).toEqual([
+ "save-and-stay",
+ ]);
+ });
+
+ it("leaves with the frame verbs once the job is closed, in both its places", async () => {
+ // A closed batch has nothing to save on any frame, so a filled control that
+ // could never fire would be a fill with nothing behind it — principle 9 at
+ // the loudest weight the bar has. It goes with Skip and the flow verb, and
+ // the overflow copy goes with it, or the control would exist in two states
+ // rather than one.
+ closedBatch = true;
+ assetCount = 2;
+ await open();
+
+ expect(screen.queryByTestId("save-and-stay")).toBeNull();
+ expect(screen.queryByTestId("menu-save")).toBeNull();
+ expect(successFilled()).toEqual([]);
+ });
+});
+
describe("the frame's state, in prose", () => {
it("says the word beside the dot rather than keeping it in a tooltip", async () => {
progress = "annotated";
diff --git a/frontend/ui-core/src/primitives/Button.tsx b/frontend/ui-core/src/primitives/Button.tsx
index 661096b4..1568a158 100644
--- a/frontend/ui-core/src/primitives/Button.tsx
+++ b/frontend/ui-core/src/primitives/Button.tsx
@@ -41,6 +41,19 @@ export const buttonVariants = cva(
variant: {
primary: `bg-primary text-primary-foreground hover:bg-primary-hover ${DISABLED_FILLED}`,
secondary: `border border-input bg-card text-foreground hover:bg-muted ${DISABLED_FILLED}`,
+ /**
+ * The second filled weight, and it has exactly one sanctioned caller:
+ * the annotation editor's `Save and stay` (`DESIGN.md`, *One filled
+ * button per view*). Colour is what separates *advance* from
+ * *persist in place* where a second near-black would compete with the
+ * primary rather than pair with it.
+ *
+ * Not a general "confirm" variant. A view whose forward action is a
+ * success is still `primary` — this exists because two halves of one
+ * gesture sit side by side, which is a shape the rest of the product
+ * does not have.
+ */
+ success: `bg-success text-success-foreground hover:bg-success-hover ${DISABLED_FILLED}`,
ghost:
"text-muted-foreground hover:bg-muted hover:text-foreground " +
// No fill: a ghost has none to grey out, and giving it one on the way
diff --git a/frontend/ui-core/src/styles.css b/frontend/ui-core/src/styles.css
index c8893ecc..44c5f126 100644
--- a/frontend/ui-core/src/styles.css
+++ b/frontend/ui-core/src/styles.css
@@ -109,9 +109,17 @@
/*
* Status, desaturated to sit on a near-monochrome page. Each is used as ink
- * and as a 10%-alpha fill behind its own border, never as a saturated block.
+ * and as a 10%-alpha fill behind its own border, never as a saturated block —
+ * with one exception, the annotation editor's `Save and stay`, which is a
+ * filled `success` control. Its two companions exist for that fill and for
+ * nothing else: `success-foreground` is the ink on it, and `success-hover`
+ * lifts the fill by the same channel deltas `primary-hover` lifts `primary`
+ * (+12, +12, +16), so the two filled variants brighten by the same amount
+ * under a pointer instead of each inventing a feel.
*/
--color-success: #2e7d5b;
+ --color-success-hover: #3a896b;
+ --color-success-foreground: #ffffff;
--color-warning: #b98217;
--color-destructive: #c93b3b;
--color-destructive-foreground: #ffffff;
diff --git a/frontend/ui-core/src/tokens.ts b/frontend/ui-core/src/tokens.ts
index f74dde5e..d24f5c9a 100644
--- a/frontend/ui-core/src/tokens.ts
+++ b/frontend/ui-core/src/tokens.ts
@@ -62,7 +62,12 @@ export const COLOR = {
// `tokens.test.ts` — a solid near-black ring on a near-black button is a smudge.
ring: "rgba(30, 33, 48, 0.35)",
+ // The one status colour that is also a fill: the annotation editor's
+ // `Save and stay`. `success-hover` lifts it by `primary-hover`'s own channel
+ // deltas (+12, +12, +16), so the bar's two filled controls brighten alike.
success: "#2e7d5b",
+ "success-hover": "#3a896b",
+ "success-foreground": "#ffffff",
warning: "#b98217",
destructive: "#c93b3b",
"destructive-foreground": "#ffffff",