From d853c654592d5506a623c9fb5d938ece31e897ea Mon Sep 17 00:00:00 2001 From: Jesus Armando Anaya Date: Sat, 8 Aug 2026 00:24:42 -0700 Subject: [PATCH] =?UTF-8?q?feat(annotator):=20the=20read-only=20mode=20sto?= =?UTF-8?q?ps=20advertising=20edits=20=E2=80=94=20no=20classes=20region,?= =?UTF-8?q?=20no=20handles,=20no=20move=20cursor,=20and=20selection=20refl?= =?UTF-8?q?ected=20in=20the=20panel=20(#426)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DESIGN.md | 35 ++++ .../src/adapters/react/AnnotationLayer.tsx | 11 +- .../src/adapters/react/AnnotatorCanvas.tsx | 55 ++++-- .../annotator/src/adapters/react/Shapes.tsx | 13 +- .../src/adapters/react/TransientLayer.tsx | 4 +- .../src/core/interaction/affordance.test.ts | 51 +++++- .../src/core/interaction/affordance.ts | 44 ++++- frontend/app/cycle/cycle.spec.ts | 4 +- frontend/app/e2e/annotate.spec.ts | 161 ++++++++++++++++-- .../ui-core/src/annotator/AnnotationPage.tsx | 31 +--- .../ui-core/src/annotator/AnnotatorPanel.tsx | 96 ++++++----- .../ui-core/src/annotator/ClassRegion.tsx | 29 +--- .../src/annotator/classRegion.test.tsx | 41 +---- frontend/ui-core/src/annotator/panel.test.tsx | 38 +++++ .../ui-core/src/annotator/topBar.test.tsx | 16 +- 15 files changed, 454 insertions(+), 175 deletions(-) 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. */} -