diff --git a/DESIGN.md b/DESIGN.md
index ab5030ed..e27cf941 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -774,6 +774,41 @@ The page the reference design shows (#56), with measurements verified in v1's so
`−`/`+` carry `aria-disabled` and a tooltip naming the limit, never a press that silently
does nothing. `docs/annotations.md` carries the argument.
+### The read-only mode
+
+The workspace opens as a **viewer** whenever the wire withholds `annotate` on the
+frame — a completed batch, or a settled frame inside an open one. The mode is the
+frame's own declaration (`allowed_actions`), never this page's arithmetic; it was
+made a mode at all by audit F2, which found "open it and let the saves fail"
+shipping as the behaviour.
+
+What a viewer is (decision of 2026-08-07, #426):
+
+- **One explanation surface.** The banner under the top bar says `Viewing only.`
+ with the cause, and — when the wire declares `create_correction` — the route
+ onward: `Correct this batch`. It renders on every frame of a closed batch,
+ including skipped ones (#423), where the skipped notice would otherwise promise
+ an Un-skip the wire withholds.
+- **No classes region.** The side panel is the objects region alone, at full
+ height — the region, its filter, its quick-create and its hotkey badges are
+ absent, not disabled, and `C` and the digits do nothing. This supersedes #420's
+ render-classes-as-information direction: *what may I draw* is not a question a
+ viewer can ask.
+- **Selection highlights; it does not advertise.** A selected shape renders the
+ selected treatment — stroke 3, the label — with **no grips and no vertex
+ dots**, and the cursor is the **default arrow everywhere**: no `move`, no
+ resize keywords, because no such gesture exists. The tool strip is not
+ rendered at all, for the same reason it never was.
+- **Selection is one state, reflected everywhere.** A press on a shape selects
+ it — the one pointer gesture a viewer keeps, resolved by the same hit rule the
+ right-click menu uses — and the objects panel's row highlights and scrolls
+ into view. That reflection is both modes' behaviour, not the viewer's alone.
+ DOM focus stays with the canvas, which reads its chords off its own root.
+- **Reads stay live.** Zoom, pan, fullscreen, the frame gallery, `‹` `›`,
+ visibility toggles, the object filter, and copy (`⌘C`) — the road a box takes
+ into a correction batch — all work; paste and every other write is refused at
+ the engine (`readOnly` on the canvas, `READ_ONLY_KINDS` for the keyboard).
+
### The canvas surround
The area around the asset is **`stage`** (`#e1e6eb`), never `muted` and never a dark
diff --git a/frontend/annotator/src/adapters/react/AnnotationLayer.tsx b/frontend/annotator/src/adapters/react/AnnotationLayer.tsx
index 846653bf..c6bfa9ed 100644
--- a/frontend/annotator/src/adapters/react/AnnotationLayer.tsx
+++ b/frontend/annotator/src/adapters/react/AnnotationLayer.tsx
@@ -43,6 +43,14 @@ export interface AnnotationLayerProps {
readonly skipId: string | null;
readonly hotId: string | null;
readonly zoom: number;
+ /**
+ * Whether a selected shape grows grips and vertex dots. `false` in the
+ * read-only mode (#426): selection there highlights — stroke and label — and
+ * must not advertise a resize or a vertex drag that no press can start. A
+ * boolean constant per mode, so it never moves mid-gesture and the `memo`
+ * above keeps its bail-out.
+ */
+ readonly handles: boolean;
}
export const AnnotationLayer = memo(function AnnotationLayer({
@@ -51,6 +59,7 @@ export const AnnotationLayer = memo(function AnnotationLayer({
skipId,
hotId,
zoom,
+ handles,
}: AnnotationLayerProps): JSX.Element {
const shapes = paintDocument(committed, selection, skipId, hotId);
return (
@@ -61,7 +70,7 @@ export const AnnotationLayer = memo(function AnnotationLayer({
// removed by the very press that hit it.
{shapes.map((shape) => (
-
+
))}
);
diff --git a/frontend/annotator/src/adapters/react/AnnotatorCanvas.tsx b/frontend/annotator/src/adapters/react/AnnotatorCanvas.tsx
index a2cf4736..b3607c28 100644
--- a/frontend/annotator/src/adapters/react/AnnotatorCanvas.tsx
+++ b/frontend/annotator/src/adapters/react/AnnotatorCanvas.tsx
@@ -128,7 +128,7 @@ import type {
import { topmostAnnotationAt } from "../../core/geometry/hitTest";
import { assetTolerances } from "../../core/geometry/tolerance";
-import { affordanceAt } from "../../core/interaction/affordance";
+import { affordanceAt, viewerAffordanceAt } from "../../core/interaction/affordance";
import { transition } from "../../core/interaction/machine";
import { runEffects } from "../../core/interaction/runEffects";
import { IDLE } from "../../core/interaction/state";
@@ -155,6 +155,7 @@ import type { Clipboard } from "../../core/interaction/clipboard";
import type { IdFactory } from "../../core/ids";
import { annotationsInDrawOrder } from "../../core/state/document";
import type { AnnotationDocument } from "../../core/state/document";
+import { clearSelection, selectOnly } from "../../core/state/selection";
import type { Selection } from "../../core/state/selection";
import type { AnnotatorStore } from "../../core/state/store";
import type { Point } from "../../core/types";
@@ -662,11 +663,28 @@ export function AnnotatorCanvas({
// was clicked. Pressing on it is the click.
rootRef.current?.focus({ preventScroll: true });
- // Read-only: a primary press is the start of every document change this
- // component can make — a draw, a move, a resize, a vertex drag — so it is
- // the one that stops. Non-primary is a pan, which changes nothing, and
- // falls through to the branch below.
- if (readOnly && button === "primary") return;
+ // Read-only: a primary press *selects* and does nothing else (#426). It
+ // never reaches the machine, so no drag state — a draw, a move, a resize, a
+ // vertex drag — is reachable at all, which is a stronger guarantee than
+ // gating each one. Selection is a read: it is what lets the panel name the
+ // shape somebody is looking at, and it is the first half of the copy that
+ // carries a box into a correction batch. The hit rule below is
+ // `topmostAnnotationAt` with the body tolerance over the hidden-filtered
+ // document — the same rule `viewerAffordanceAt` highlights with and the
+ // same one the right-click menu resolves, so the highlight, the press and
+ // the menu cannot disagree about what is "under" a point. Non-primary is a
+ // pan, which changes nothing, and falls through to the branch below.
+ if (readOnly && button === "primary") {
+ const point = imagePoint(event);
+ if (point === null) return;
+ const hit = topmostAnnotationAt(
+ annotationsInDrawOrder(withoutHidden(store.document, hiddenNow.current)),
+ point,
+ assetTolerances(viewNow.current.zoom).shape,
+ );
+ store.select(hit === null ? clearSelection() : selectOnly(hit.id));
+ return;
+ }
if (button !== "primary") {
// `state.ts`'s written contract: while panning the adapter forwards nothing,
@@ -757,14 +775,22 @@ export function AnnotatorCanvas({
const affordance =
hover === null
? { cursor: "default" as const, hot: NO_TARGET }
- : affordanceAt(
- interaction,
- // Built from what is **rendered**, where the machine's context is the
- // committed document — `affordance.ts` states that asymmetry.
- { document: visibleRendered, selection: snapshot.selection, tolerances },
- tool,
- hover,
- );
+ : readOnly
+ ? // The viewer's answer (#426): `default` everywhere — no cursor may
+ // promise a move that cannot happen — with the hot body kept, because
+ // a highlight aids the one gesture a viewer has, which is selecting.
+ viewerAffordanceAt(
+ { document: visibleRendered, selection: snapshot.selection, tolerances },
+ hover,
+ )
+ : affordanceAt(
+ interaction,
+ // Built from what is **rendered**, where the machine's context is the
+ // committed document — `affordance.ts` states that asymmetry.
+ { document: visibleRendered, selection: snapshot.selection, tolerances },
+ tool,
+ hover,
+ );
const hotBodyId = affordance.hot.kind === "body" ? affordance.hot.id : null;
const skipId = editedId(interaction);
@@ -881,6 +907,7 @@ export function AnnotatorCanvas({
skipId={skipId}
hotId={hotBodyId}
zoom={view.zoom}
+ handles={!readOnly}
/>
{shape.geometry.type === "bbox" ? (
@@ -364,10 +368,11 @@ export function AnnotationShape({ shape, zoom }: ShapeProps): JSX.Element {
/>
)}
- {shape.selected && shape.geometry.type === "bbox" && (
+ {handles && shape.selected && shape.geometry.type === "bbox" && (
)}
- {shape.selected &&
+ {handles &&
+ shape.selected &&
(shape.geometry.type === "polygon" || shape.geometry.type === "polyline") && (
)}
- {edited !== null && }
+ {/* `handles` unconditionally: this layer only ever draws a shape a
+ gesture is holding, and no gesture exists in the read-only mode. */}
+ {edited !== null && }
{band !== null && (
{
});
});
});
+
+describe("the viewer's affordance (#426)", () => {
+ // A scene with the box selected, which is the sharpest case: the resolver
+ // would offer its grips, and a viewer must not.
+ function viewerScene(selection: Selection = selectOnly(BOX_ID)): Scene {
+ return { document: sceneDocument(), selection, tolerances: assetTolerances(1) };
+ }
+
+ it("answers default over a selected box's grip, because no resize exists to promise", () => {
+ const grip = bboxHandlePositions(
+ annotationById(sceneDocument(), BOX_ID)!.geometry as never,
+ ).nw;
+ const affordance = viewerAffordanceAt(viewerScene(), grip);
+
+ expect(affordance.cursor).toBe("default");
+ // The grip position still sits on the shape, so the hot target is the body:
+ // the highlight survives, the offer does not.
+ expect(affordance.hot).toEqual({ kind: "body", id: BOX_ID });
+ });
+
+ it("answers default over a body, where the editor's select tool says move", () => {
+ expect(affordanceAt(IDLE, viewerScene(), "select", BOX_BODY).cursor).toBe("move");
+ expect(viewerAffordanceAt(viewerScene(), BOX_BODY).cursor).toBe("default");
+ expect(viewerAffordanceAt(viewerScene(), BOX_BODY).hot).toEqual({ kind: "body", id: BOX_ID });
+ });
+
+ it("answers default and no target over empty canvas", () => {
+ expect(viewerAffordanceAt(viewerScene(), EMPTY_POINT)).toEqual({
+ cursor: "default",
+ hot: NO_TARGET,
+ });
+ });
+
+ it("presses with the same rule it highlights with", () => {
+ // One function under both — the module's cursor-and-press contract restated
+ // for the mode. A point whose hover lights a shape selects that shape; one
+ // whose hover lights nothing clears.
+ expect(viewerPressTarget(viewerScene(), BOX_BODY)).toBe(BOX_ID);
+ expect(viewerAffordanceAt(viewerScene(), BOX_BODY).hot).toEqual({
+ kind: "body",
+ id: viewerPressTarget(viewerScene(), BOX_BODY),
+ });
+ expect(viewerPressTarget(viewerScene(), EMPTY_POINT)).toBeNull();
+ });
+
+ it("hits the topmost shape, which is the right-click menu's own rule", () => {
+ expect(viewerPressTarget(viewerScene(EMPTY_SELECTION), POLY_BODY)).toBe(POLY_ID);
+ });
+});
diff --git a/frontend/annotator/src/core/interaction/affordance.ts b/frontend/annotator/src/core/interaction/affordance.ts
index b1d6a97a..044c9555 100644
--- a/frontend/annotator/src/core/interaction/affordance.ts
+++ b/frontend/annotator/src/core/interaction/affordance.ts
@@ -59,9 +59,9 @@
import { bboxHandlePositions } from "../geometry/bbox";
import type { BboxHandle } from "../geometry/bbox";
-import { polygonCloseAttempt } from "../geometry/hitTest";
+import { polygonCloseAttempt, topmostAnnotationAt } from "../geometry/hitTest";
import { clampPoint } from "../geometry/primitives";
-import { annotationById } from "../state/document";
+import { annotationById, annotationsInDrawOrder } from "../state/document";
import type { Point } from "../types";
import type { InteractionState } from "./state";
import { NO_TARGET, resolveTarget } from "./target";
@@ -254,3 +254,43 @@ export function affordanceAt(
return unreachable(state);
}
}
+
+/**
+ * The affordance a **viewer** answers, where selection is the only gesture
+ * (#426).
+ *
+ * The cursor is `default` everywhere — decision (b): a read-only page never
+ * shows `move`, because no move exists to promise. What survives is the hot
+ * body, so hovering still says *this is the shape a press would pick*: a
+ * highlight aids selection, which is a read, where a cursor change advertises
+ * an edit.
+ *
+ * It deliberately does not call `resolveTarget`: that resolver offers grips and
+ * vertices on the selected shape, and a viewer draws none (the same mirror as
+ * `AnnotationShape` — a target that cannot be painted must not be resolvable).
+ * Instead it answers from `topmostAnnotationAt` with the body tolerance — the
+ * **same rule the viewer's press uses**, so the highlight and the selection
+ * cannot disagree, which is this module's whole contract restated for the mode.
+ */
+export function viewerAffordanceAt(scene: Scene, point: Point): Affordance {
+ const id = viewerPressTarget(scene, point);
+ return id === null
+ ? { cursor: "default", hot: NO_TARGET }
+ : { cursor: "default", hot: { kind: "body", id } };
+}
+
+/**
+ * What a viewer's press selects: the topmost shape under the point, or nothing.
+ *
+ * One function for the press and the hover above — and it is also the rule the
+ * right-click menu already resolves with, so every pointer gesture a viewer has
+ * agrees about what is "under" a point.
+ */
+export function viewerPressTarget(scene: Scene, point: Point): string | null {
+ const hit = topmostAnnotationAt(
+ annotationsInDrawOrder(scene.document),
+ point,
+ scene.tolerances.shape,
+ );
+ return hit === null ? null : hit.id;
+}
diff --git a/frontend/app/cycle/cycle.spec.ts b/frontend/app/cycle/cycle.spec.ts
index 82abfccc..2b2420d7 100644
--- a/frontend/app/cycle/cycle.spec.ts
+++ b/frontend/app/cycle/cycle.spec.ts
@@ -610,7 +610,9 @@ test("the whole cycle, from opening the app to a downloaded export", async ({ pa
await expect(page.getByTestId("readonly-banner")).toContainText(/viewing only/i);
await expect(page.getByTestId("banner-create-correction")).toBeVisible();
await expect(page.getByTestId("tool-palette")).toHaveCount(0);
- await expect(page.getByTestId("class-add")).toBeDisabled();
+ // The classes region leaves the viewer entirely (#426) — with it go the
+ // add-a-class doors #423 first closed by disabling.
+ await expect(page.getByTestId("class-region")).toHaveCount(0);
// A full draw gesture writes nothing and dirties nothing.
const canvas = page.getByTestId("annotator-canvas");
diff --git a/frontend/app/e2e/annotate.spec.ts b/frontend/app/e2e/annotate.spec.ts
index 3a343782..9afadd38 100644
--- a/frontend/app/e2e/annotate.spec.ts
+++ b/frontend/app/e2e/annotate.spec.ts
@@ -149,8 +149,9 @@ async function serveApi(
progress: Map = progressStore({ "asset-1": "unannotated", "asset-2": "annotated" }),
lifecycle: Lifecycle = openedWorld(),
size?: SchemaSize,
+ seeded: readonly Record[] = [],
): Promise {
- const stored: Record[] = [];
+ const stored: Record[] = [...seeded];
const batchBody = (): Record => ({
id: BATCH,
project_id: PROJECT,
@@ -322,8 +323,9 @@ async function openJob(
progress?: Map,
lifecycle?: Lifecycle,
size?: SchemaSize,
+ seeded?: readonly Record[],
): Promise {
- await serveApi(page, sent, progress, lifecycle, size);
+ await serveApi(page, sent, progress, lifecycle, size, seeded);
await page.goto(`/jobs/${JOB}`);
await page.getByTestId("token-input").fill("a-token");
await page.getByTestId("token-submit").click();
@@ -1285,28 +1287,151 @@ test("a completed batch's canvas cannot be drawn on, however hard it is asked",
});
/**
- * The doors #422 left open (#423). The classes region renders in the read-only
- * mode — which classes exist stays true there — but its create paths are writes:
- * each one opens the add-a-class dialog, and the dialog publishes a real schema
- * version from a page that has just said it is a viewer.
+ * The doors #422 left open (#423), closed since by absence (#426): the classes
+ * region does not render in the read-only mode at all, so no create path into
+ * the add-a-class dialog exists — the region, its filter, its quick-create and
+ * its hotkey badges are gone, and the objects region takes the whole panel.
*/
-test("a completed batch's viewer leaves no door into the add-a-class dialog", async ({ page }) => {
+test("a completed batch's viewer renders no classes region, and the objects region takes the panel", async ({
+ page,
+}) => {
const sent: Request[] = [];
await openJob(page, sent, undefined, { batch: "completed", job: "completed" });
await expect(page.getByTestId("readonly-banner")).toBeVisible();
- // The `+` stays on screen — hiding it would be a control that comes and goes —
- // but it is a refusal, and it says why.
- const add = page.getByTestId("class-add");
- await expect(add).toBeDisabled();
- await expect(add).toHaveAttribute("title", /completed/i);
-
- // Nothing matches what was typed: the `Create class` row must not appear, and
- // the Enter fallthrough that would have created it must be dead too.
- await page.getByTestId("class-filter").fill("a-class-nobody-declared");
- await expect(page.getByTestId("class-create")).toHaveCount(0);
- await page.getByTestId("class-filter").press("Enter");
+ await expect(page.getByTestId("class-region")).toHaveCount(0);
+ await expect(page.getByTestId("class-add")).toHaveCount(0);
+ await expect(page.getByTestId("class-filter")).toHaveCount(0);
+ await expect(page.getByTestId("panel-split")).toHaveCount(0);
await expect(page.getByTestId("add-class-dialog")).toHaveCount(0);
+
+ // `c` reaches nothing: the chord is still claimed, and there is no filter for
+ // it to focus, so the keyboard stays on the canvas root.
+ await page.getByTestId("annotator-root").focus();
+ await page.keyboard.press("c");
+ await expect(page.getByTestId("annotator-root")).toBeFocused();
+
+ // The layout half of decision (a): the objects region is handed the whole
+ // panel by the same flex rule that always sized it. Measured, not assumed —
+ // the region's top sits where the classes region used to, at the panel's
+ // padding edge.
+ const panel = (await page.getByTestId("annotator-panel").boundingBox())!;
+ const objects = (await page.getByTestId("objects-region").boundingBox())!;
+ expect(objects.y - panel.y).toBeLessThanOrEqual(12);
+ expect(panel.y + panel.height - (objects.y + objects.height)).toBeLessThanOrEqual(12);
+});
+
+/** A stored `vehicle` box on the given asset, in the wire mirror's exact shape. */
+function storedBox(assetId: string): Record {
+ return {
+ id: "seeded-1",
+ asset_id: assetId,
+ label_class: "vehicle",
+ schema_version: 3,
+ geometry: { type: "bbox", x: 40, y: 40, width: 44, height: 34 },
+ attributes: {},
+ provenance: "human",
+ model_ref: null,
+ confidence: null,
+ job_id: null,
+ };
+}
+
+/**
+ * Decisions (b) and (c) of #426: read-only selection highlights — stroke and
+ * label — and advertises nothing. No move cursor anywhere, no grips or vertex
+ * dots on the selected shape. The editor is asserted beside it, so the claim is
+ * about the mode and not about the fixture.
+ */
+test("read-only selection shows no move cursor and no handles; the editor shows both", async ({
+ page,
+}) => {
+ const sent: Request[] = [];
+ // A stored box, seeded at the stub: a completed batch's viewer cannot draw
+ // one, which is the point of the mode.
+ await openJob(page, sent, undefined, { batch: "completed", job: "completed" }, undefined, [
+ storedBox("asset-1"),
+ ]);
+ await expect(page.getByTestId("object-row-0")).toBeVisible();
+
+ // Select on the canvas — the viewer's one pointer gesture (#426 d).
+ const shape = page.locator("[data-annotation-id]").first();
+ const box = (await shape.boundingBox())!;
+ await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
+ await expect(page.getByTestId("object-row-0")).toHaveAttribute("data-selected", "true");
+
+ // (c) Selection did not grow handles…
+ await expect(page.locator("[data-handle]")).toHaveCount(0);
+ await expect(page.locator("[data-vertex]")).toHaveCount(0);
+
+ // (b) …and hovering the body promises nothing: the pane's cursor is the
+ // default arrow, not `move`.
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ const viewing = await page
+ .getByTestId("annotator-pane")
+ .evaluate((node) => getComputedStyle(node).cursor);
+ expect(viewing).toBe("default");
+});
+
+test("the editor still offers what the viewer withholds — move cursor and grips", async ({
+ page,
+}) => {
+ const sent: Request[] = [];
+ await openJob(page, sent, undefined, undefined, undefined, [storedBox("asset-1")]);
+ await expect(page.getByTestId("object-row-0")).toBeVisible();
+
+ const shape = page.locator("[data-annotation-id]").first();
+ const box = (await shape.boundingBox())!;
+ await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
+ await expect(page.getByTestId("object-row-0")).toHaveAttribute("data-selected", "true");
+
+ await expect(page.locator("[data-handle]").first()).toBeVisible();
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ const editing = await page
+ .getByTestId("annotator-pane")
+ .evaluate((node) => getComputedStyle(node).cursor);
+ expect(editing).toBe("move");
+});
+
+/**
+ * Decision (d) of #426: selection is one state, reflected everywhere. A shape
+ * picked on the canvas selects its panel row and scrolls it into view — here
+ * with enough objects that the row genuinely starts outside the scroller.
+ */
+test("selecting on the canvas scrolls the object's row into view", async ({ page }) => {
+ const sent: Request[] = [];
+ await openJob(page, sent);
+
+ // Draw a column of boxes — enough that the first row scrolls out once the
+ // last is drawn and selected.
+ const canvas = page.getByTestId("annotator-canvas");
+ const frame = (await canvas.boundingBox())!;
+ await page.getByTestId("annotator-root").focus();
+ await page.keyboard.press("1");
+ const drawn = 14;
+ for (let index = 0; index < drawn; index += 1) {
+ const left = frame.x + frame.width * (0.05 + 0.9 * (index / drawn));
+ const top = frame.y + frame.height * 0.1;
+ await page.mouse.move(left, top);
+ await page.mouse.down();
+ await page.mouse.move(left + frame.width * 0.04, top + frame.height * 0.5, { steps: 4 });
+ await page.mouse.up();
+ }
+ await expect(page.getByTestId("object-total")).toHaveText(`${drawn} objects`);
+
+ // Select the *first* box on the canvas while the list sits scrolled to the
+ // bottom (drawing kept appending). Its row must come back into the scroller.
+ await page.keyboard.press("v");
+ const first = page.locator("[data-annotation-id]").first();
+ const target = (await first.boundingBox())!;
+ await page.mouse.click(target.x + target.width / 2, target.y + target.height / 2);
+
+ const row = page.getByTestId("object-row-0");
+ await expect(row).toHaveAttribute("data-selected", "true");
+ const scroller = (await page.getByTestId("objects-scroller").boundingBox())!;
+ const where = (await row.boundingBox())!;
+ expect(where.y).toBeGreaterThanOrEqual(scroller.y - 1);
+ expect(where.y + where.height).toBeLessThanOrEqual(scroller.y + scroller.height + 1);
});
/**
diff --git a/frontend/ui-core/src/annotator/AnnotationPage.tsx b/frontend/ui-core/src/annotator/AnnotationPage.tsx
index 60de28c3..5d3c92ef 100644
--- a/frontend/ui-core/src/annotator/AnnotationPage.tsx
+++ b/frontend/ui-core/src/annotator/AnnotationPage.tsx
@@ -704,15 +704,11 @@ function Workspace({
setHelpOpen((open) => !open);
return true;
}
- // `c` (#368). Refused while read-only for the reason the palette is hidden
- // there: picking a drawing class on a canvas that cannot be drawn on offers a
- // choice with no consequence.
+ // `c` (#368). Still claimed while read-only, where it does nothing: the
+ // classes region is absent there (#426), so the ref holds null and the
+ // focus call is a no-op — which is exactly what "C does nothing" means,
+ // with no second spelling of the mode to keep in step.
if (name === FOCUS_CLASS_FIELD) {
- // Claimed even while read-only now, and that is the #420 change: the
- // classes list renders on a settled frame — which classes exist stays
- // true — so focusing its filter is a legitimate thing to do there. What
- // is refused is *arming* one, on the rows themselves, with the reason
- // attached.
classFilterRef.current?.focus();
return true;
}
@@ -1951,13 +1947,10 @@ function Workspace({
renders it and reports a choice, so the canvas, the tool strip, a digit
and this list all land on the one `activateClass`.
- `classRefusal` rather than reusing `readOnly` for the rows: the list
- renders on a settled frame — which classes exist stays true there — and
- what it owes is the sentence, which the page has already computed for
- its own banner. `withheld` speaks for a closed batch and
- `settledBecause` for a settled frame; the fallback covers `skipped`,
- where `settledBecause` deliberately answers null because the notice
- below says it better and carries the Un-skip.
+ In the read-only mode the panel renders no classes region at all —
+ decision (a) of #426, superseding #420's render-as-information
+ direction — so this page no longer owes it a refusal sentence; the
+ banner above is the one surface that says why the frame is a viewer.
*/}
diff --git a/frontend/ui-core/src/annotator/AnnotatorPanel.tsx b/frontend/ui-core/src/annotator/AnnotatorPanel.tsx
index 15bdc8c2..913ce3bb 100644
--- a/frontend/ui-core/src/annotator/AnnotatorPanel.tsx
+++ b/frontend/ui-core/src/annotator/AnnotatorPanel.tsx
@@ -89,7 +89,7 @@ import {
type LabelClass,
} from "@visionset/annotator";
import { Check, Eye, EyeOff, Tag, Trash2 } from "lucide-react";
-import { useState, type JSX, type RefObject } from "react";
+import { useEffect, useRef, useState, type JSX, type RefObject } from "react";
import { classColor } from "../palette";
import { Button } from "../primitives/Button";
@@ -129,16 +129,6 @@ export interface AnnotatorPanelProps {
readonly classFilterRef?: RefObject;
/** Open the add-a-class dialog, or absent where there is nowhere to add one. */
readonly onAddClass?: (name: string) => void;
- /**
- * Why no class can be armed on this frame, or absent when one can.
- *
- * Separate from `readOnly` and not derived from it, because the two answer
- * different questions: `readOnly` says the *document* cannot be written, and
- * this says *why* in the words the page already computed for its banner. The
- * classes list renders either way — which classes exist stays true on a
- * settled frame — and every row is disabled carrying this sentence.
- */
- readonly classRefusal?: string;
}
export function AnnotatorPanel({
@@ -150,7 +140,6 @@ export function AnnotatorPanel({
onActivateClass,
classFilterRef,
onAddClass,
- classRefusal,
}: AnnotatorPanelProps): JSX.Element {
const snapshot = useAnnotatorSnapshot(store);
const [filter, setFilter] = useState("");
@@ -207,21 +196,31 @@ export function AnnotatorPanel({
data-testid="annotator-panel"
aria-label="Classes and annotations"
>
- {/* Upper region: the ontology. `shrink-0`, and it sizes itself in rows —
- see `ClassRegion` for the rule and for why it is computed from the
- schema's count rather than from the filtered one. */}
-
+ {/* Upper region: the ontology — absent, not disabled, in the read-only
+ mode (#426): what may I draw is not a question a viewer can ask, so
+ rendering the list there was information about nothing. The decision
+ supersedes #420's render-as-information direction. The objects region
+ below takes the whole panel by the same rule that always sized it —
+ it is `flex-1` and there is nothing else left.
- {/* The split. A rule, not a handle — `ClassRegion` decides its own height
- and everything below takes the rest. */}
-
+ `shrink-0`, and it sizes itself in rows — see `ClassRegion` for the
+ rule and for why it is computed from the schema's count rather than
+ from the filtered one. */}
+ {!readOnly && (
+ <>
+
+
+ {/* The split. A rule, not a handle — `ClassRegion` decides its own
+ height and everything below takes the rest. */}
+
+ >
+ )}
{/* Lower region: what is on this asset. `min-h-0` is what lets it be
shorter than its content so the list inside can scroll — without it a
@@ -380,18 +379,7 @@ function TagStrip({
);
}
-function ObjectRow({
- annotation,
- index,
- declared,
- schema,
- selected,
- hidden,
- onSelect,
- onToggleVisible,
- onRemove,
- onReassign,
-}: {
+interface ObjectRowProps {
readonly annotation: Annotation;
readonly index: number;
readonly declared: LabelClass | undefined;
@@ -405,9 +393,39 @@ function ObjectRow({
readonly onRemove?: () => void;
/** Absent in read-only for the same reason — every item on it is a write. */
readonly onReassign?: (labelClass: string) => void;
-}): JSX.Element {
+}
+
+function ObjectRow({
+ annotation,
+ index,
+ declared,
+ schema,
+ selected,
+ hidden,
+ onSelect,
+ onToggleVisible,
+ onRemove,
+ onReassign,
+}: ObjectRowProps): JSX.Element {
+ const row = useRef(null);
+ /**
+ * Selection is one state, reflected everywhere (#426): a shape picked on the
+ * canvas selects this row too, and a row a filter or a long list has pushed
+ * out of the scroller scrolls into view. Each row watches its own `selected`,
+ * so the rule costs nothing to the rows it does not concern; `nearest` keeps
+ * an already-visible row exactly where it is, which is what makes the same
+ * effect harmless when the selection came from a click on this very row.
+ *
+ * DOM focus deliberately does not move: the keyboard stays where the gesture
+ * happened — the canvas reads its chords off its own root, and a selection
+ * that stole focus would kill them (`PinBadge`'s reason, one surface over).
+ */
+ useEffect(() => {
+ if (selected) row.current?.scrollIntoView({ block: "nearest" });
+ }, [selected]);
return (
void;
- /**
- * Why no class can be armed, or absent when one can.
- *
- * The list still renders — which classes exist is information, and a frame
- * being settled does not make it untrue. What changes is that every row is
- * disabled *and says why*, which is principle 9's whole distinction between a
- * refusal and a grey box.
- */
- readonly refusal?: string;
}
export function ClassRegion({
@@ -104,7 +95,6 @@ export function ClassRegion({
onActivateClass,
filterRef,
onAddClass,
- refusal,
}: ClassRegionProps): JSX.Element {
const [filter, setFilter] = useState("");
@@ -120,16 +110,9 @@ export function ClassRegion({
* create row would put a schema change one stray Enter away from somebody who
* was picking a class. An exact match suppresses it too, so the row never sits
* under the very class it offers to add.
- *
- * Null while refusing, with the header's `+`: the create paths are writes the
- * same way arming is — each ends in a published schema version — so a region
- * that refuses its rows and still offers to create is a read-only mode with a
- * hole in it (#423).
*/
const creatable =
- onAddClass === undefined || refusal !== undefined || query === "" || shown.length > 0
- ? null
- : filter.trim();
+ onAddClass === undefined || query === "" || shown.length > 0 ? null : filter.trim();
/**
* Enter takes the first match, which is the typeahead the top-bar field had.
@@ -151,7 +134,6 @@ export function ClassRegion({
if (creatable !== null) onAddClass?.(creatable);
return;
}
- if (refusal !== undefined) return;
onActivateClass(first.name);
}
@@ -174,10 +156,7 @@ export function ClassRegion({
className="size-6"
aria-label="Add a class"
data-testid="class-add"
- // Refusing closes this door too, with the rows' own sentence on it —
- // hiding it would be a control that comes and goes between frames.
- disabled={onAddClass === undefined || refusal !== undefined}
- {...(refusal === undefined ? {} : { title: refusal })}
+ disabled={onAddClass === undefined}
onClick={() => onAddClass?.("")}
>
@@ -233,7 +212,6 @@ export function ClassRegion({
schema={schema}
selected={declared.name === activeClass}
onSelect={() => onActivateClass(declared.name)}
- {...(refusal === undefined ? {} : { refusal })}
/>
))
)}
@@ -259,13 +237,11 @@ function ClassRow({
schema,
selected,
onSelect,
- refusal,
}: {
readonly declared: LabelClass;
readonly schema: AnnotationSchema;
readonly selected: boolean;
readonly onSelect: () => void;
- readonly refusal?: string;
}): JSX.Element {
return (
);
}
diff --git a/frontend/ui-core/src/annotator/classRegion.test.tsx b/frontend/ui-core/src/annotator/classRegion.test.tsx
index b31112ba..2fc9db52 100644
--- a/frontend/ui-core/src/annotator/classRegion.test.tsx
+++ b/frontend/ui-core/src/annotator/classRegion.test.tsx
@@ -1,5 +1,5 @@
/**
- * The classes region (#420): the height rule, the hotkeys, and the refusal.
+ * The classes region (#420): the height rule and the hotkeys.
*
* Driven through `ClassRegion` directly rather than through `AnnotationPage`,
* unlike `topBar.test.tsx` — every claim here is about this component's own
@@ -125,45 +125,14 @@ describe("the hotkeys the rows advertise", () => {
});
});
-describe("the region's refusals", () => {
- it("still lists the classes on a frame nothing can be armed on", () => {
- // Which classes exist stays true on a settled frame. The row is disabled and
- // says why — principle 9's distinction between a refusal and a grey box.
- render(mount(3, { refusal: "This frame has been accepted." }));
-
- const row = screen.getByTestId("class-row-class-1");
- expect(row.hasAttribute("disabled")).toBe(true);
- expect(row.getAttribute("title")).toBe("This frame has been accepted.");
- });
-
- it("does not arm anything on Enter while it is refusing", async () => {
- const onActivateClass = vi.fn();
- render(mount(3, { refusal: "This frame has been accepted.", onActivateClass }));
-
- await userEvent.type(screen.getByTestId("class-filter"), "class-1{Enter}");
-
- expect(onActivateClass).not.toHaveBeenCalled();
- });
-
+describe("the empty schema", () => {
+ // The refusal machinery this region carried until #426 is gone with the
+ // reason for it: the read-only mode renders no classes region at all, so a
+ // region that exists is always armable. `panel.test.tsx` holds the absence.
it("invites a first class rather than showing an empty list", () => {
render(mount(0, { onAddClass: vi.fn() }));
expect(screen.getByTestId("classes-empty")).toBeDefined();
expect(screen.queryByTestId("class-list")).toBeNull();
});
-
- it("closes every door into the add-a-class dialog while it is refusing", async () => {
- // The create paths are writes the same way arming is: a viewer that can
- // publish a schema version is a read-only mode with a hole in it (#423).
- const onAddClass = vi.fn();
- render(mount(3, { refusal: "This batch is completed.", onAddClass }));
-
- const add = screen.getByTestId("class-add");
- expect(add.hasAttribute("disabled")).toBe(true);
- expect(add.getAttribute("title")).toBe("This batch is completed.");
-
- await userEvent.type(screen.getByTestId("class-filter"), "nothing-declared{Enter}");
- expect(screen.queryByTestId("class-create")).toBeNull();
- expect(onAddClass).not.toHaveBeenCalled();
- });
});
diff --git a/frontend/ui-core/src/annotator/panel.test.tsx b/frontend/ui-core/src/annotator/panel.test.tsx
index 63e6f5ef..d5cf4218 100644
--- a/frontend/ui-core/src/annotator/panel.test.tsx
+++ b/frontend/ui-core/src/annotator/panel.test.tsx
@@ -431,4 +431,42 @@ describe("what the panel offers when the document cannot be written", () => {
expect(screen.queryByTestId("object-reclass-0")).not.toBeNull();
expect(screen.getByTestId("tag-chip-daytime")).toHaveProperty("disabled", false);
});
+
+ it("renders no classes region at all — absent, not disabled", () => {
+ // Decision (a) of #426, superseding #420's render-as-information direction:
+ // *what may I draw* is not a question a viewer can ask, so the region, its
+ // filter, its quick-create and its hotkey badges all leave the panel. The
+ // split rule goes with it — a divider between one region and nothing is a
+ // line about nothing. What the region's absence buys — the objects region
+ // taking the whole panel — is a layout fact, held in chromium.
+ const store = storeWith([annotation("a", "vehicle", "bbox")]);
+ render(mount(store, { readOnly: true }));
+
+ expect(screen.queryByTestId("class-region")).toBeNull();
+ expect(screen.queryByTestId("class-add")).toBeNull();
+ expect(screen.queryByTestId("class-filter")).toBeNull();
+ expect(screen.queryByTestId("panel-split")).toBeNull();
+ expect(screen.getByTestId("objects-region")).toBeDefined();
+ });
+});
+
+describe("selection is one state, reflected everywhere (#426 d)", () => {
+ it("scrolls the selected row into view when the selection arrives from outside", async () => {
+ // The canvas selects through `store.select`, never through this panel — so
+ // the row has to notice its own `selected` moving. jsdom has no layout, so
+ // what a unit test can hold is that the row *asks* to be scrolled; whether
+ // it then is visible is `annotate.spec.ts`'s, in chromium.
+ const store = storeWith([
+ annotation("a", "vehicle", "bbox"),
+ annotation("b", "lane", "polygon"),
+ ]);
+ const scrolled = vi.fn();
+ Element.prototype.scrollIntoView = scrolled;
+ render(mount(store));
+
+ store.select(selectOnly("b"));
+
+ expect(await screen.findByTestId("object-row-1")).toHaveProperty("dataset.selected", "true");
+ expect(scrolled).toHaveBeenCalled();
+ });
});
diff --git a/frontend/ui-core/src/annotator/topBar.test.tsx b/frontend/ui-core/src/annotator/topBar.test.tsx
index 8555a125..e56ed7b8 100644
--- a/frontend/ui-core/src/annotator/topBar.test.tsx
+++ b/frontend/ui-core/src/annotator/topBar.test.tsx
@@ -289,17 +289,17 @@ describe("the class list, now in the panel (#420)", () => {
expect(screen.queryByTestId("class-create")).toBeNull();
});
- it("renders the classes on a settled frame, refused with a reason (#420)", async () => {
- // Principle 9, and the change from the top-bar field it replaces: that one
- // was **not rendered at all** while read-only. Which classes exist stays true
- // on an accepted frame, so the list is information there — what it owes is
- // the sentence, on rows nobody can press.
+ it("renders no classes region at all on a settled frame (#426)", async () => {
+ // Decision (a) of #426, superseding #420's render-as-information direction:
+ // an accepted frame opens in the read-only mode, and there the region — its
+ // rows, its filter, its quick-create — is absent, not disabled. The banner
+ // above the stage is the one surface that says why.
progress = "accepted";
await open();
- const row = screen.getByTestId("class-row-vehicle");
- expect(row.hasAttribute("disabled")).toBe(true);
- expect(row.getAttribute("title")).toMatch(/accepted/i);
+ expect(screen.queryByTestId("class-region")).toBeNull();
+ expect(screen.queryByTestId("class-row-vehicle")).toBeNull();
+ expect(screen.getByTestId("objects-region")).toBeDefined();
});
});