diff --git a/DESIGN.md b/DESIGN.md
index 562fd27e..59513fac 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -498,6 +498,14 @@ market segment and the domain does not hold one. The single map is
quietly out of a list; headings are non-selectable `SelectLabel`s and a category with
nothing under it renders none.
+**An option that is an identifier plus the facts about it takes two lines** (#472,
+2026-08-09) — the identifier at the label role, the facts beneath it at the meta role in
+`muted-foreground`. It is `SelectItem`'s `meta` prop, so the closed trigger shows the same
+two lines the open list does; the trigger is `min-h-9` rather than `h-9` and grows to fit,
+which leaves every one-line select on the contract's 36px. **Nothing truncates**: an
+identifier cut off in the middle is not an identifier, so a long one wraps. The specimen is
+on the styleguide page.
+
### Lists and filtering
Any list that can exceed ~20 rows carries a filter input. Filtering is client-side and
diff --git a/docs/ui.md b/docs/ui.md
index 93547bb9..51c9aa92 100644
--- a/docs/ui.md
+++ b/docs/ui.md
@@ -434,7 +434,18 @@ because the model reads the whole image once; refining after it is quick.
**The proposal is not an annotation until it is accepted.** It is drawn faintly
with a dashed outline, carries its class and the model's confidence beside it, and
is in neither the document nor the undo history. `Esc` is its undo. Switching
-tools, switching frames or leaving the page discards it, and nothing is written.
+class, switching frames or leaving the page discards it, and nothing is written.
+
+**The tool stays armed while you change class.** Arming it is a decision about how
+to work, and picking the class to work on is the next thing you do — so a class
+switch ends the proposal on screen and not the tool. The next click asks under the
+new class, in its geometry and its colour. Only pressing the button again, or
+moving to another frame, puts the tool away.
+
+Land on a class that can hold no proposal — a tag, a lane — and the tool **parks**
+rather than switching itself off: the button dims and says why, the panel says what
+to pick, and the canvas goes back to drawing that class normally. Choose a box or a
+polygon class again and the tool carries on, with nothing to press.
Accepting creates one ordinary annotation, in one undo step, carrying
`provenance: model`, the `model_ref` the answer named and its `confidence` — the
@@ -444,7 +455,10 @@ frame settles the same way.
**The tool is offered only for a class that can hold the answer.** The proposal
comes back as a polygon for a polygon class and as the shape's bounding box for a
box class; a schema whose classes are tag-only or lane-only gets no button at all,
-because there is no kind the answer could be expressed in.
+because there is no kind the answer could be expressed in. That is the *project*
+answered; the parked state above is the same question asked of the class you are
+holding, and it dims the button rather than removing it because the answer changes
+again the moment you pick another class.
Arming it with no usable connection shows an in-editor panel saying what is
missing — none configured, or configured with its weights not yet downloaded — and
diff --git a/frontend/annotator/src/adapters/react/paint.test.ts b/frontend/annotator/src/adapters/react/paint.test.ts
index 42021e6d..8e072c96 100644
--- a/frontend/annotator/src/adapters/react/paint.test.ts
+++ b/frontend/annotator/src/adapters/react/paint.test.ts
@@ -303,6 +303,14 @@ describe("a pending suggestion, drawn as a proposal (#424)", () => {
expect(paintSuggestion(cleared(shown()), SIGN)).toBeNull();
});
+ it("draws nothing for a parked session, which has no class to draw it in (#472)", () => {
+ // Constructed, because a parked session cannot reach `shown` — the same
+ // belt-and-braces as the kind check below, for a function exported from the
+ // package root that a caller could hand anything the type permits.
+ const parked: SuggestionState = { ...shown(), labelClass: null };
+ expect(paintSuggestion(parked, SIGN)).toBeNull();
+ });
+
it("draws nothing for a kind that is not one of the two suggestible ones", () => {
// Unreachable through the route, which narrows to `allowed_geometries` — and
// still refused here, because this function is exported from the package root
diff --git a/frontend/annotator/src/adapters/react/paint.ts b/frontend/annotator/src/adapters/react/paint.ts
index f2ded7b9..74d38342 100644
--- a/frontend/annotator/src/adapters/react/paint.ts
+++ b/frontend/annotator/src/adapters/react/paint.ts
@@ -291,12 +291,17 @@ export function paintSuggestion(
): PaintedSuggestion | null {
const suggestion = state.suggestion;
if (state.status !== "shown" || suggestion === null) return null;
+ // A parked session (#472) has no class, so there is no colour to draw it in and
+ // no label to write on it. It also cannot be `shown`, so this is the same kind
+ // of guard as the one above: what the type allows, not what the machine does.
+ const labelClass = state.labelClass;
+ if (labelClass === null) return null;
const geometry = suggestion.geometry;
if (geometry.type !== "bbox" && geometry.type !== "polygon") return null;
return {
geometry,
- color: classColor(declared, state.labelClass),
- label: confidenceLabel(state.labelClass, suggestion.confidence),
+ color: classColor(declared, labelClass),
+ label: confidenceLabel(labelClass, suggestion.confidence),
points: state.points,
};
}
diff --git a/frontend/annotator/src/core/interaction/suggestion.test.ts b/frontend/annotator/src/core/interaction/suggestion.test.ts
index a7e63521..d3e96a27 100644
--- a/frontend/annotator/src/core/interaction/suggestion.test.ts
+++ b/frontend/annotator/src/core/interaction/suggestion.test.ts
@@ -25,11 +25,14 @@ import {
cleared,
hasPending,
isAcceptable,
+ isParked,
isSuggestibleClass,
promptOf,
refused,
schemaCanSuggest,
suggestClassFor,
+ suggestibleClassIn,
+ withClass,
withPoint,
} from "./suggestion";
import type { Suggestion, SuggestionState } from "./suggestion";
@@ -118,6 +121,100 @@ describe("which classes the tool is offered for", () => {
expect(suggestClassFor(schemaOf(LANE, ROAD, CAR), "lane")).toBe("road");
expect(suggestClassFor(schemaOf(LANE, ROAD, CAR), null)).toBe("road");
});
+
+ it("reads the held class without the fallback, which is the parked question (#472)", () => {
+ const schema = schemaOf(LANE, ROAD, CAR);
+ expect(suggestibleClassIn(schema, "car")).toBe("car");
+ // The very case `suggestClassFor` answers `road` for. Moving somebody off the
+ // class they just picked is not this function's business.
+ expect(suggestibleClassIn(schema, "lane")).toBe(null);
+ expect(suggestibleClassIn(schema, null)).toBe(null);
+ expect(suggestibleClassIn(schema, "not-a-class")).toBe(null);
+ });
+});
+
+/**
+ * The class moving under an armed session (#472) — the behaviour #451 shipped, and
+ * the direction it was deliberately turned around in.
+ */
+describe("the active class moves and the session goes with it", () => {
+ it("returns the state by identity when the class did not really move", () => {
+ const state = showing();
+ // Not merely equal: the host folds this through on every render of the class
+ // it is already on, so a fresh object here would be a re-render per keystroke
+ // and, worse, a discarded preview.
+ expect(withClass(state, "car")).toBe(state);
+ });
+
+ it("stays armed on the new class, with nothing pending carried over", () => {
+ const state = withClass(armed("car"), "road");
+ expect(state.labelClass).toBe("road");
+ expect(state.status).toBe("idle");
+ expect(isParked(state)).toBe(false);
+ expect(hasPending(state)).toBe(false);
+ });
+
+ it("discards a preview the new class may not be able to hold", () => {
+ const shown = showing();
+ expect(shown.suggestion).not.toBe(null);
+
+ const moved = withClass(shown, "road");
+
+ // The shape was answered under `car`'s `allowed_geometries`; accepting it
+ // under `road` could write a kind that class does not admit.
+ expect(moved.suggestion).toBe(null);
+ expect(moved.points).toEqual([]);
+ expect(moved.status).toBe("idle");
+ expect(isAcceptable(moved)).toBe(false);
+ // And the tool is still armed, which is the whole of the change.
+ expect(moved.labelClass).toBe("road");
+ });
+
+ it("keeps the serial counting, so the answer in flight cannot repaint", () => {
+ const asked = withPoint(armed("car"), [10, 10], "positive");
+ const moved = withClass(asked, "road");
+
+ expect(moved.serial).toBe(asked.serial);
+ // The ask that was in flight lands under the old serial and is dropped whole.
+ expect(answered(moved, asked.serial - 1, proposal())).toBe(moved);
+
+ // And the next click on the new class cannot be answered by it either: the
+ // serial moves on rather than being handed back out.
+ const again = withPoint(moved, [20, 20], "positive");
+ expect(again.serial).toBe(asked.serial + 1);
+ });
+
+ it("parks on a class that can hold nothing, rather than ending", () => {
+ const state = withClass(showing(), null);
+ expect(isParked(state)).toBe(true);
+ expect(state.labelClass).toBe(null);
+ expect(state.suggestion).toBe(null);
+ expect(state.points).toEqual([]);
+ });
+
+ it("re-arms from parked on the class that unparked it, with no second press", () => {
+ const parked = withClass(armed("car"), null);
+ const back = withClass(parked, "road");
+
+ expect(isParked(back)).toBe(false);
+ // `road`, not `car`: a parked session resumes on the class the person has just
+ // picked, and nothing here remembers the one they left.
+ expect(back.labelClass).toBe("road");
+ expect(back.status).toBe("idle");
+ });
+
+ it("stays parked while the active class moves between classes that hold nothing", () => {
+ const parked = withClass(armed("car"), null);
+ expect(withClass(parked, null)).toBe(parked);
+ });
+
+ it("writes nothing while parked, whatever a caller believes about the status", () => {
+ const document = documentOf();
+ // Constructed, not reachable: a parked session cannot be `shown`. The guard is
+ // the guarantee — no class, no annotation — and not a formality.
+ const impossible: SuggestionState = { ...showing(), labelClass: null };
+ expect(acceptedAnnotation(document, impossible, () => "id-1")).toBe(null);
+ });
});
describe("the preview lifecycle", () => {
diff --git a/frontend/annotator/src/core/interaction/suggestion.ts b/frontend/annotator/src/core/interaction/suggestion.ts
index f37ffbc5..948212d1 100644
--- a/frontend/annotator/src/core/interaction/suggestion.ts
+++ b/frontend/annotator/src/core/interaction/suggestion.ts
@@ -61,6 +61,33 @@
* that can be true. `promptOf` is the projection, and it is here rather than in
* the host so that the ordering rule — positives and negatives in the order they
* were placed, each list on its own — has one owner.
+ *
+ * ## The class can move under a session, and the session survives it (#472)
+ *
+ * #451 held the mode *beside* the active class and discarded it whenever that
+ * class moved, on the reading that moving the active class is how this build
+ * spells switching tools. In use that is wrong: arming the tool is a decision
+ * about how to work, and picking the class to work on is the next thing somebody
+ * does. Directed (Armando, 2026-08-09): a session ends when the user puts the
+ * tool away or the asset changes, and a class switch is neither.
+ *
+ * {@link withClass} is that whole rule, and it is one function because the three
+ * readings are one transition:
+ *
+ * - **Nothing pending.** The class is swapped in; the next click asks under it.
+ * - **A preview showing.** It is discarded with the clicks that produced it. It
+ * was answered under the old class's `allowed_geometries`, so accepting it
+ * under the new one could write a shape that class does not admit — the same
+ * argument D3 makes about which classes may be asked in the first place.
+ * - **A class that can hold nothing** — a tag, a path. The session **parks**:
+ * `labelClass` is `null`, no click is diverted, and the armed intent is kept so
+ * that returning to a class which can hold an answer picks up where it left
+ * off. Parking rather than disarming is what stops a capability disappearing
+ * without a sentence (principle 9); the sentence itself is the host's.
+ *
+ * The serial keeps counting across all three, for `cleared`'s reason: an answer
+ * to the ask that was in flight when the class moved must not repaint a preview
+ * that belongs to a class nobody is on any more.
*/
import { classNamed } from "../state/document";
@@ -133,6 +160,28 @@ export function schemaCanSuggest(schema: AnnotationSchema): boolean {
return schema.classes.some(isSuggestibleClass);
}
+/**
+ * The active class if a suggestion could be labelled with it, and `null` if not.
+ *
+ * What {@link withClass} is fed when the active class moves, so `null` is the
+ * parked reading rather than an error: a schema that can suggest can still be
+ * sitting on a class that cannot, and that is a state to describe rather than one
+ * to prevent.
+ *
+ * Deliberately **not** {@link suggestClassFor}, which falls back to the schema's
+ * first suggestible class. That fallback is right when somebody presses the tool
+ * — they asked for it, so take them somewhere it works — and wrong here, where
+ * they asked for a *class*: moving the active class out from under a person who
+ * just picked one is the consequence `ToolPalette` refuses to have.
+ */
+export function suggestibleClassIn(
+ schema: AnnotationSchema,
+ activeClass: string | null,
+): string | null {
+ const held = schema.classes.find((declared) => declared.name === activeClass);
+ return held !== undefined && isSuggestibleClass(held) ? held.name : null;
+}
+
/** What a click told the model: this is the thing, or this is not the thing. */
export type Polarity = "positive" | "negative";
@@ -168,8 +217,14 @@ export type SuggestionStatus = "idle" | "asking" | "shown" | "none" | "refused";
/** The whole of a suggest session. `null`, in a host, is a tool that is not armed. */
export interface SuggestionState {
- /** The class the accepted annotation will carry. Fixed for the session. */
- readonly labelClass: string;
+ /**
+ * The class the accepted annotation will carry.
+ *
+ * `null` is a **parked** session: armed, but sitting on a class that can hold
+ * nothing a segmenter proposes, so there is nothing to ask about. It is not an
+ * absent value to be defaulted — see {@link withClass} and {@link isParked}.
+ */
+ readonly labelClass: string | null;
/** Every click so far, in the order they were placed. */
readonly points: readonly PromptPoint[];
readonly status: SuggestionStatus;
@@ -283,6 +338,37 @@ export function cleared(state: SuggestionState): SuggestionState {
return { ...state, points: [], status: "idle", suggestion: null, refusal: null };
}
+/**
+ * The active class moved. The tool stays armed; what was pending does not.
+ *
+ * One function for the three readings the module note sets out — swap, discard,
+ * park — because they are one transition over a different argument. A class that
+ * did not actually move returns the state **by identity**, so a host can fold
+ * this through unconditionally without a render or a discarded preview for a
+ * re-selection of the class already held.
+ *
+ * `null` parks. Re-arming is this same call with a class that can hold an answer,
+ * which is why nothing here remembers *which* class was parked from: the class a
+ * parked session resumes on is the one the user has just picked, not the one they
+ * left.
+ */
+export function withClass(state: SuggestionState, labelClass: string | null): SuggestionState {
+ if (labelClass === state.labelClass) return state;
+ return { ...cleared(state), labelClass };
+}
+
+/**
+ * Whether the session is armed over a class that can hold nothing.
+ *
+ * The one thing a host must not do with a parked session is divert a press into
+ * it: the class the user is on may still be drawable — a lane is a `polyline` —
+ * and a tool that swallowed those presses would have stopped being parked and
+ * started being broken.
+ */
+export function isParked(state: SuggestionState): boolean {
+ return state.labelClass === null;
+}
+
/** Whether there is anything for Escape to take back. */
export function hasPending(state: SuggestionState): boolean {
return state.points.length > 0 || state.status !== "idle";
@@ -318,6 +404,10 @@ export function acceptedAnnotation(
mint: IdFactory,
): Annotation | null {
if (!isAcceptable(state) || state.suggestion === null) return null;
+ // A parked session cannot reach `shown`, so this is unreachable by the state
+ // machine — and it is the guarantee, not a formality: nothing may be written
+ // without a class, whatever a caller believes about how it got here.
+ if (state.labelClass === null) return null;
if (classNamed(document, state.labelClass) === undefined) return null;
const drawn = draftAnnotation(document, state.labelClass, state.suggestion.geometry, mint);
return {
diff --git a/frontend/annotator/src/index.ts b/frontend/annotator/src/index.ts
index 04e449c9..1740c5ab 100644
--- a/frontend/annotator/src/index.ts
+++ b/frontend/annotator/src/index.ts
@@ -156,11 +156,14 @@ export {
cleared,
hasPending,
isAcceptable,
+ isParked,
isSuggestibleClass,
promptOf,
refused,
schemaCanSuggest,
suggestClassFor,
+ suggestibleClassIn,
+ withClass,
withPoint,
type Polarity,
type Prompt,
diff --git a/frontend/app/e2e/styleguide.spec.ts b/frontend/app/e2e/styleguide.spec.ts
index eadc51d5..b99dc8a9 100644
--- a/frontend/app/e2e/styleguide.spec.ts
+++ b/frontend/app/e2e/styleguide.spec.ts
@@ -177,3 +177,30 @@ test("the tab bar sits one rhythm step above its content", async ({ page }) => {
expect(panel).not.toBeNull();
expect(panel!.y - (list!.y + list!.height)).toBeCloseTo(12, 0);
});
+
+/**
+ * #472's claim, which is a claim about pixels.
+ *
+ * `primitives.test.tsx` asserts the *structure* — two elements, the meta in the
+ * muted role, the id keeping its own line — and jsdom computes no layout, so the
+ * one thing it cannot see is the thing that was reported: a two-line value inside
+ * a control measured for one line. A revert to `h-9` leaves every unit test green
+ * and fails here.
+ */
+test("a two-line option grows its trigger, and a one-line one stays on the contract's 36px", async ({
+ page,
+}) => {
+ const plain = await page.getByLabel("Geometry").boundingBox();
+ const stacked = await page.getByLabel("Model").boundingBox();
+ expect(plain).not.toBeNull();
+ expect(stacked).not.toBeNull();
+
+ // Unmoved: every select that shipped before the variant is still exactly 36px.
+ expect(plain!.height).toBeCloseTo(36, 0);
+ // Grown, not squashed: the second line is inside the box rather than over it.
+ expect(stacked!.height).toBeGreaterThan(plain!.height);
+
+ // And the identifier is whole — no ellipsis, no clipped end.
+ const id = page.getByLabel("Model").locator("span", { hasText: "facebook/sam2.1-hiera-base-plus" }).first();
+ await expect(id).toHaveCSS("text-overflow", "clip");
+});
diff --git a/frontend/app/src/styleguide/Styleguide.tsx b/frontend/app/src/styleguide/Styleguide.tsx
index 9348d140..0e27afd4 100644
--- a/frontend/app/src/styleguide/Styleguide.tsx
+++ b/frontend/app/src/styleguide/Styleguide.tsx
@@ -235,6 +235,44 @@ export function Styleguide(): JSX.Element {
Singular per class — picking a class picks a tool.
+ {/*
+ The two-line option (#472). Here because it is a primitive variant
+ rather than one screen's styling: an option that is an identifier
+ plus the facts about it stacks them, and the trigger shows the same
+ two lines the list does because Radix renders the selected item's
+ own text into it.
+ */}
+
+
+
+
+ The id at the label role, what it costs beneath it. Nothing truncates.
+
+
diff --git a/frontend/ui-core/src/annotator/AnnotationPage.tsx b/frontend/ui-core/src/annotator/AnnotationPage.tsx
index 73b94c4d..79b3aaca 100644
--- a/frontend/ui-core/src/annotator/AnnotationPage.tsx
+++ b/frontend/ui-core/src/annotator/AnnotationPage.tsx
@@ -97,14 +97,17 @@ import {
annotationsInDrawOrder,
documentFromWire,
hasPending,
+ isParked,
parseGeometry,
promptOf,
randomUuid,
refused,
selectOnly,
suggestClassFor,
+ suggestibleClassIn,
toolFor,
useAnnotatorSnapshot,
+ withClass,
withPoint,
type AnnotatorStore,
type AnnotatorView,
@@ -782,6 +785,8 @@ function Workspace({
* suggestion must not. D2 says switching assets discards, and the `key={asset.id}`
* remount is that rule enforced by construction rather than by an effect
* somebody has to remember to write.
+ *
+ * It survives a **class** switch, though, since #472 — see the effect below.
*/
const [session, setSession] = useState(null);
@@ -893,8 +898,26 @@ function Workspace({
}
/**
- * Switching tools discards (D2), and *switching tools* here means the active
- * class moving off the one the session captured.
+ * The class the session would run under: the active one, or `null` where it can
+ * hold nothing a segmenter proposes.
+ */
+ const suggestibleClass = suggestibleClassIn(store.document.schema, activeClass);
+
+ /**
+ * The active class moved, and the tool goes with it (#472).
+ *
+ * **This is the behaviour #451 shipped, deliberately reversed.** That slice
+ * discarded the session whenever the active class left the one it captured, on
+ * the reading that moving the active class is how this build spells switching
+ * tools (D2). Directed (Armando, 2026-08-09): arming is a decision about how to
+ * work and picking a class is the next thing somebody does, so a class switch
+ * ends what is *pending* and not the session. `withClass` is the whole rule —
+ * swap, discard the preview, or park — and it returns the state by identity when
+ * the class did not really move, so this can fold unconditionally.
+ *
+ * Keyed on the derived class rather than on `activeClass`, so a schema that
+ * changed under the session is answered too, and so the effect is a no-op for
+ * two different classes that both park.
*
* The strip's other buttons, the panel's list and every digit hotkey all end at
* `activateClass`, so this one effect covers all of them — where a handler on
@@ -903,8 +926,8 @@ function Workspace({
* two agree by the time this runs.
*/
useEffect(() => {
- setSession((live) => (live === null || live.labelClass === activeClass ? live : null));
- }, [activeClass]);
+ setSession((live) => (live === null ? live : withClass(live, suggestibleClass)));
+ }, [suggestibleClass]);
/**
* The one capability the canvas hands out rather than owning (#189).
@@ -1307,6 +1330,18 @@ function Workspace({
*/
const suggesting = readOnly ? null : session;
+ /**
+ * The session as the **canvas** sees it — which is `null` while parked (#472).
+ *
+ * `AnnotatorCanvas`'s prop is the instruction to divert every primary press
+ * into a prompt point, and a parked session has nothing to prompt. The class
+ * that parked it may still be drawable — a lane is a `polyline` — so a canvas
+ * that swallowed those presses would have stopped being parked and started
+ * being broken. The panel and the strip get the whole session, because being
+ * parked is the thing they are there to say.
+ */
+ const diverting = suggesting !== null && !isParked(suggesting) ? suggesting : null;
+
/**
* Why it is read-only, in the words a person can act on.
*
@@ -2231,8 +2266,10 @@ function Workspace({
}}
// The suggest mode (#424). Its presence diverts every primary
// press away from the interaction machine, which is what stops a
- // click meant for the model from drawing a box instead.
- suggestion={suggesting}
+ // click meant for the model from drawing a box instead — so it is
+ // `diverting`, which drops a parked session (#472), and not the
+ // whole of `suggesting`.
+ suggestion={diverting}
onSuggestPoint={suggestAt}
/>
)}
@@ -2294,7 +2331,20 @@ function Workspace({
// #424. The strip hides it on a schema no class of which could
// hold the answer; this page offers it because it has an API
// behind it, which the showcase does not.
- suggest={{ active: suggesting !== null, onToggle: toggleSuggest }}
+ //
+ // `unavailable` is the parked reading (#472): the schema can
+ // suggest, so the button is present, but the class the workspace is
+ // sitting on cannot hold one — which is a fact to state rather than
+ // a control that quietly stops working. Lit *and* dimmed, because
+ // both halves are true: the tool is still armed, and it cannot act.
+ suggest={{
+ active: suggesting !== null,
+ onToggle: toggleSuggest,
+ unavailable:
+ suggesting !== null && isParked(suggesting)
+ ? `Suggest is on, but “${activeClass ?? ""}” cannot hold a suggested shape`
+ : null,
+ }}
/>
)}
@@ -2311,6 +2361,9 @@ function Workspace({
{suggesting !== null && (
}>
+
+ {heldClass === null
+ ? "Nothing selected to suggest for"
+ : `“${heldClass}” cannot hold a suggestion`}
+
+
+ A suggestion comes back as a box or a polygon. Pick a class that holds one
+ and the tool carries on from here — it is still armed.
+
+ {/*
+ No `Esc` chip beside it, unlike the other take-backs on this card. The
+ chord is a substitution the canvas makes while something is pending, and
+ a parked session has nothing pending — so printing the key would be
+ printing one that does nothing. This button is the way out, and it is
+ why the dimmed strip button is not a trap.
+ */}
+
+
+ );
+ }
+
+ // The blocker outranks everything else: a session over a workspace with no
+ // usable connection has nothing to report about a request it never made.
if (blocker !== null && blocker !== undefined) {
const copy = BLOCKER_COPY[blocker];
return (
diff --git a/frontend/ui-core/src/annotator/ToolPalette.tsx b/frontend/ui-core/src/annotator/ToolPalette.tsx
index 39933c98..e959eeb5 100644
--- a/frontend/ui-core/src/annotator/ToolPalette.tsx
+++ b/frontend/ui-core/src/annotator/ToolPalette.tsx
@@ -58,6 +58,13 @@
* principle 9's terms, and "no class in this project could accept the answer" is
* a fact about the schema rather than a capability that is coming.
*
+ * The *class* is a different question from the schema, and gets the other
+ * treatment (#472). An armed tool over a class that can hold nothing is **dimmed
+ * with its reason**, not hidden: the capability is real in this project and comes
+ * back the moment the active class moves, so a button that vanished and returned
+ * as somebody worked down the class list would be describing a project that keeps
+ * changing. `unavailable` carries the sentence.
+ *
* ## Why this is a second component rather than the showcase's, moved
*
* `@visionset/app`'s `demo/ToolStrip.tsx` is the same rule with inline styles from
@@ -218,6 +225,18 @@ export interface ToolPaletteProps {
/** Whether the tool is armed. Held by the host, like the active class. */
readonly active: boolean;
readonly onToggle: () => void;
+ /**
+ * Why the armed tool cannot act right now, or `null` when it can (#472).
+ *
+ * The *class* half of what the schema check above is the project half of: a
+ * schema with no suggestible class hides the button, and a suggestible schema
+ * sitting on a class that can hold nothing dims it and says so. Disabled-with-
+ * reason rather than hidden, because unlike the schema case this is a
+ * capability that comes back the moment the active class moves — a control
+ * that vanished and reappeared as somebody worked down the class list would
+ * be describing a project that keeps changing.
+ */
+ readonly unavailable?: string | null;
};
/**
* Open the add-a-class dialog (#233), or absent where there is nowhere to add
@@ -297,8 +316,13 @@ export function ToolPalette({
{suggest !== undefined && schemaCanSuggest(schema) && (
diff --git a/frontend/ui-core/src/annotator/suggestFlow.test.tsx b/frontend/ui-core/src/annotator/suggestFlow.test.tsx
index a5440408..518e90c1 100644
--- a/frontend/ui-core/src/annotator/suggestFlow.test.tsx
+++ b/frontend/ui-core/src/annotator/suggestFlow.test.tsx
@@ -35,6 +35,8 @@ 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";
+/** The second frame, so "switching assets discards" has somewhere to switch to. */
+const ASSET_TWO = "55555555-5555-4555-8555-555555555555";
const CONNECTION = "66666666-6666-4666-8666-666666666666";
const MODEL_REF = "facebook/sam2-hiera-base-plus@main";
@@ -47,6 +49,11 @@ const SCHEMA = {
classes: [
{ name: "vehicle", geometry: "bbox", color: "#3355ff", attributes: [] },
{ name: "lane-area", geometry: "polygon", color: null, attributes: [] },
+ // Drawable, and not suggestible: a mask narrows to a region and a lane is an
+ // open path. It is what parks the tool (#472), and it is a `polyline` rather
+ // than a tag on purpose — a class that can still be drawn on is the case where
+ // a parked tool swallowing presses would be a bug rather than a nuisance.
+ { name: "lane", geometry: "polyline", color: null, attributes: [] },
],
};
@@ -81,6 +88,26 @@ function connectionRow(setup: "ready" | "not_set_up"): Record {
};
}
+function assetRow(id: string, hash: string): Record {
+ return {
+ id,
+ project_id: PROJECT,
+ modality: "image",
+ content_hash: hash.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" }),
+ };
+}
+
function answer(path: string): unknown {
if (path === "/inference/connections") {
return { items: connections, total: connections.length };
@@ -90,7 +117,7 @@ function answer(path: string): unknown {
id: JOB,
batch_id: BATCH,
state: "in_progress",
- asset_count: 1,
+ asset_count: 2,
allowed_actions: jobActions("in_progress", { settled: false }),
};
}
@@ -101,44 +128,23 @@ function answer(path: string): unknown {
name: "drive-01",
state: "in_annotation",
schema_version: 1,
- asset_count: 1,
+ asset_count: 2,
allowed_actions: batchActions("in_annotation"),
promoted_asset_count: 0,
parent_batch_id: null,
progress: {
- unannotated: 1,
+ unannotated: 2,
annotated: 0,
skipped: 0,
review_pending: 0,
accepted: 0,
- total: 1,
+ total: 2,
},
};
}
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: "abcdef0".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: [assetRow(ASSET, "abcdef0"), assetRow(ASSET_TWO, "1234560")], total: 2 };
}
return { items: [], total: 0 };
}
@@ -265,11 +271,123 @@ describe("arming the tool", () => {
expect(screen.getByTestId("class-row-vehicle").getAttribute("data-selected")).toBe("true");
});
- it("disarms when another tool moves the active class (D2)", async () => {
+ it("disarms when it is pressed again", async () => {
+ await open();
+ await arm();
+ await userEvent.click(screen.getByTestId("tool-suggest"));
+ expect(screen.queryByTestId("suggest-panel")).toBeNull();
+ });
+});
+
+/**
+ * The class moving under an armed tool (#472).
+ *
+ * **This describe block is the reversal.** #451 shipped "moving the active class
+ * disarms", and the test that encoded it lived where the first one below now
+ * does. Reverting `withClass` in the page's effect to `setSession(null)` turns
+ * every test here red, starting with the first.
+ */
+describe("the active class moves and the tool stays armed", () => {
+ it("stays armed on a class switch, and asks under the new class", async () => {
+ await open();
+ await arm();
+
+ await userEvent.click(screen.getByTestId("class-row-lane-area"));
+
+ // Armed still — the panel is the tool's one voice, so its presence is the
+ // armed state and its absence is the tool put away.
+ expect(screen.getByTestId("suggest-panel")).toBeTruthy();
+ clickCanvas();
+
+ await waitFor(() => expect(asks()).toHaveLength(1));
+ // The new class's geometry, not the one the session was armed with.
+ expect(asks()[0]["allowed_geometries"]).toEqual(["polygon"]);
+ });
+
+ it("discards a preview the new class may not be able to hold", async () => {
await open();
await arm();
+ clickCanvas();
+ await screen.findByTestId("suggestion-shape");
+
await userEvent.click(screen.getByTestId("class-row-lane-area"));
+
+ // The shape was answered under `vehicle`'s allowed kinds; accepting it as a
+ // `lane-area` could write a geometry that class does not admit.
+ expect(screen.queryByTestId("suggestion-shape")).toBeNull();
+ expect(screen.getByTestId("suggest-panel")).toBeTruthy();
+ // And nothing reached the document on the way past.
+ expect(screen.getByTestId("tool-undo").getAttribute("aria-disabled")).toBe("true");
+ expect(screen.getByTestId("object-total").textContent).toBe("0 objects");
+ });
+
+ it("parks on a class that can hold nothing, and says why", async () => {
+ await open();
+ await arm();
+
+ await userEvent.click(screen.getByTestId("class-row-lane"));
+
+ const parked = await screen.findByTestId("suggest-parked");
+ expect(parked.textContent).toContain("lane");
+ // Principle 9: dimmed with the reason readable, never a bare disabled state.
+ const button = screen.getByTestId("tool-suggest");
+ expect(button.getAttribute("aria-disabled")).toBe("true");
+ expect(button.getAttribute("aria-label")).toContain("lane");
+ // Still lit, because it is still armed. Both halves are true at once.
+ expect(button.getAttribute("data-active")).toBe("true");
+ });
+
+ it("leaves the canvas alone while parked, so the class can still be drawn", async () => {
+ await open();
+ await arm();
+ await userEvent.click(screen.getByTestId("class-row-lane"));
+ await screen.findByTestId("suggest-parked");
+
+ clickCanvas();
+
+ // The press reached the interaction machine — a lane is being drawn — and no
+ // request left for a suggestion nobody can hold.
+ expect(screen.getByTestId("pending-polygon")).toBeTruthy();
+ expect(asks()).toHaveLength(0);
+ });
+
+ it("re-arms on the way back, with no second press", async () => {
+ await open();
+ await arm();
+ await userEvent.click(screen.getByTestId("class-row-lane"));
+ await screen.findByTestId("suggest-parked");
+
+ await userEvent.click(screen.getByTestId("class-row-lane-area"));
+
+ // Nobody pressed the tool again; the armed intent was remembered.
+ await screen.findByTestId("suggest-idle");
+ expect(screen.getByTestId("tool-suggest").getAttribute("aria-disabled")).toBeNull();
+ });
+
+ it("offers a way out of the parked state, since the strip button is dimmed", async () => {
+ await open();
+ await arm();
+ await userEvent.click(screen.getByTestId("class-row-lane"));
+ await screen.findByTestId("suggest-parked");
+
+ await userEvent.click(screen.getByTestId("suggest-discard"));
+
expect(screen.queryByTestId("suggest-panel")).toBeNull();
+ expect(screen.getByTestId("tool-suggest").getAttribute("aria-disabled")).toBeNull();
+ });
+
+ it("still disarms and discards when the asset changes, which is unchanged", async () => {
+ await open();
+ await arm();
+ clickCanvas();
+ await screen.findByTestId("suggestion-shape");
+
+ await userEvent.click(screen.getByTestId("next-asset"));
+
+ // D2's other discard, enforced by the per-asset remount rather than by an
+ // effect — and the one a class switch was wrongly grouped with.
+ await waitFor(() => expect(screen.queryByTestId("suggest-panel")).toBeNull());
+ expect(screen.queryByTestId("suggestion-shape")).toBeNull();
});
});
diff --git a/frontend/ui-core/src/annotator/suggestPanel.test.tsx b/frontend/ui-core/src/annotator/suggestPanel.test.tsx
index 26f0cd13..f116322a 100644
--- a/frontend/ui-core/src/annotator/suggestPanel.test.tsx
+++ b/frontend/ui-core/src/annotator/suggestPanel.test.tsx
@@ -1,6 +1,6 @@
/**
- * The suggest tool's panel: the five things it can be saying, and the one rule
- * about its action (#424, D6).
+ * The suggest tool's panel: the six things it can be saying, and the one rule
+ * about its action (#424, D6; the sixth is #472's parked reading).
*
* The three *blocked* readings are the issue's own list — none configured, none
* ready, and the server refusing because this build cannot run the model — and
@@ -14,7 +14,7 @@ import { userEvent } from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { JSX } from "react";
-import { answered, armed, refused, withPoint } from "@visionset/annotator";
+import { answered, armed, refused, withClass, withPoint } from "@visionset/annotator";
import type { Suggestion, SuggestionState } from "@visionset/annotator";
import { SuggestPanel } from "./SuggestPanel";
@@ -39,6 +39,7 @@ function mount(overrides: Partial[0]> = {}): JSX
return (
{
});
});
+describe("parked over a class that can hold nothing (#472)", () => {
+ function parked(): SuggestionState {
+ return withClass(shown(), null);
+ }
+
+ it("names the class the person just picked, and says the tool is still on", () => {
+ render(mount({ session: parked(), heldClass: "lane" }));
+
+ expect(screen.getByTestId("suggest-parked").textContent).toContain("lane");
+ // The sentence principle 9 requires beside the strip's dimmed button: what to
+ // change, and that nothing needs turning back on.
+ const card = screen.getByTestId("suggest-panel");
+ expect(card.textContent).toContain("box or a polygon");
+ expect(card.textContent).toContain("still armed");
+ expect(card.getAttribute("data-tone")).toBe("calm");
+ });
+
+ it("offers the way out, which is the only one while the strip button is dimmed", async () => {
+ const onDiscard = vi.fn();
+ render(mount({ session: parked(), heldClass: "lane", onDiscard }));
+
+ await userEvent.click(screen.getByTestId("suggest-discard"));
+ expect(onDiscard).toHaveBeenCalledTimes(1);
+ // No `Esc` chip: the chord is a substitution the canvas makes while something
+ // is pending, and a parked session has nothing pending.
+ expect(screen.getByTestId("suggest-panel").textContent).not.toContain("Esc");
+ });
+
+ it("outranks the blocker, which is not the thing standing in the way", () => {
+ render(mount({ session: parked(), heldClass: "lane", blocker: "checking" }));
+
+ expect(screen.getByTestId("suggest-parked")).toBeTruthy();
+ // "Getting the model ready" would report progress towards something that is
+ // not going to happen, and hide the one choice the person can change.
+ expect(screen.queryByTestId("suggest-checking")).toBeNull();
+ });
+
+ it("has a sentence for a workspace sitting on no class at all", () => {
+ render(mount({ session: parked(), heldClass: null }));
+ expect(screen.getByTestId("suggest-parked").textContent).toContain("Nothing selected");
+ });
+});
+
describe("a refusal", () => {
/**
* The one that matters most: `LOCAL_INFERENCE_UNAVAILABLE` is
diff --git a/frontend/ui-core/src/annotator/toolPalette.test.tsx b/frontend/ui-core/src/annotator/toolPalette.test.tsx
index 493f0b6e..8a590d83 100644
--- a/frontend/ui-core/src/annotator/toolPalette.test.tsx
+++ b/frontend/ui-core/src/annotator/toolPalette.test.tsx
@@ -308,4 +308,42 @@ describe("the suggest tool (#424)", () => {
const press = fireEvent.mouseDown(screen.getByTestId("tool-suggest"));
expect(press).toBe(false);
});
+
+ /**
+ * The class half of what the schema check is the project half of (#472).
+ *
+ * Hidden is for a schema that could never hold an answer; dimmed-with-reason is
+ * for a class that cannot hold one *right now*, because that comes back the
+ * moment the active class moves.
+ */
+ it("is dimmed with its reason, not hidden, while the held class can hold nothing", async () => {
+ const onToggle = vi.fn();
+ render(
+ mount({
+ suggest: {
+ active: true,
+ onToggle,
+ unavailable: "Suggest is on, but “kerb” cannot hold a suggested shape",
+ },
+ }),
+ );
+
+ const button = screen.getByTestId("tool-suggest");
+ expect(button.getAttribute("aria-disabled")).toBe("true");
+ // The reason replaces the name, because the tooltip is where a refusal is
+ // readable — a dimmed button still labelled "Suggest (S)" is the bare
+ // disabled state principle 9 names.
+ expect(button.getAttribute("aria-label")).toContain("kerb");
+ // Lit and dimmed at once, both true: the tool is armed, and it cannot act.
+ expect(button.getAttribute("data-active")).toBe("true");
+
+ await userEvent.click(button);
+ expect(onToggle).not.toHaveBeenCalled();
+ });
+
+ it("is an ordinary button again when the reason is absent or null", () => {
+ render(mount({ suggest: { active: true, onToggle: vi.fn(), unavailable: null } }));
+ expect(screen.getByTestId("tool-suggest").getAttribute("aria-disabled")).toBeNull();
+ expect(screen.getByTestId("tool-suggest").getAttribute("aria-label")).toBe("Suggest (S)");
+ });
});
diff --git a/frontend/ui-core/src/primitives/Select.tsx b/frontend/ui-core/src/primitives/Select.tsx
index 796d33b1..a5c1d7a2 100644
--- a/frontend/ui-core/src/primitives/Select.tsx
+++ b/frontend/ui-core/src/primitives/Select.tsx
@@ -8,11 +8,35 @@
*
* `#126`'s rule rides on this component's *callers*, not on the component: every
* class control in VisionSet is a picker over the schema, never free text (#6).
+ *
+ * ## An option can be two lines (#472)
+ *
+ * Some options are an identifier plus the facts about it — a model id, then its
+ * download size and what it is for. On one line that is a sentence long enough to
+ * wrap inside a control measured for one line, which reads as squashed text
+ * rather than as a choice.
+ *
+ * So {@link SelectItem} takes an optional `meta`: the children stay the
+ * identifier, at the label role, and `meta` goes underneath at the meta role.
+ * Because Radix renders the *selected* item's `ItemText` into the trigger, the
+ * closed control and the open list are the same two lines by construction — there
+ * is no second place to keep in step, which is the whole reason this lives on the
+ * primitive rather than at a call site.
+ *
+ * The trigger grows to fit rather than clipping: `min-h-9` and vertical padding
+ * instead of a fixed `h-9`. A single-line option still measures exactly 36px, so
+ * every select that shipped before this is unmoved. Nothing truncates and nothing
+ * ellipsises — an identifier cut off in the middle is not an identifier.
*/
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown } from "lucide-react";
-import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from "react";
+import {
+ forwardRef,
+ type ComponentPropsWithoutRef,
+ type ElementRef,
+ type ReactNode,
+} from "react";
import { cn } from "../lib/cn";
@@ -29,8 +53,14 @@ export const SelectTrigger = forwardRef<
ref={ref}
className={cn(
// `card` and the neutral disabled skin, for `Input`'s reasons (#323).
- "flex h-9 w-full items-center justify-between gap-2 rounded-md border border-input " +
- "bg-card px-3 text-body text-foreground disabled:cursor-not-allowed " +
+ //
+ // `min-h-9` with `py-1` rather than `h-9`: a one-line value still lands on
+ // exactly 36px (22.4px of text plus 8px of padding plus the border is under
+ // the floor), and a two-line one grows the control instead of overflowing
+ // it. `text-left` because this is a