Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion frontend/annotator/src/adapters/react/AnnotationLayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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 (
Expand All @@ -61,7 +70,7 @@ export const AnnotationLayer = memo(function AnnotationLayer({
// removed by the very press that hit it.
<g data-testid="annotation-layer" pointerEvents="none">
{shapes.map((shape) => (
<AnnotationShape key={shape.id} shape={shape} zoom={zoom} />
<AnnotationShape key={shape.id} shape={shape} zoom={zoom} handles={handles} />
))}
</g>
);
Expand Down
55 changes: 41 additions & 14 deletions frontend/annotator/src/adapters/react/AnnotatorCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -881,6 +907,7 @@ export function AnnotatorCanvas({
skipId={skipId}
hotId={hotBodyId}
zoom={view.zoom}
handles={!readOnly}
/>
<TransientLayer
edited={edited}
Expand Down
13 changes: 9 additions & 4 deletions frontend/annotator/src/adapters/react/Shapes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@ function labelAnchor(shape: PaintedAnnotation): Point {
interface ShapeProps {
readonly shape: PaintedAnnotation;
readonly zoom: number;
/** Whether selection grows grips and vertex dots — `false` in a viewer (#426). */
readonly handles: boolean;
}

/**
Expand Down Expand Up @@ -336,9 +338,11 @@ export function PolylineShape({ geometry, color, hot, selected }: {
* Grips and vertices are drawn only for a selected shape, which mirrors
* `resolveTarget` — it looks for a handle or a vertex only among the *selected*
* annotations, so painting them on an unselected one would offer a grip that
* cannot be taken.
* cannot be taken. `handles` is the same mirror for the read-only mode (#426):
* a viewer's press cannot take a grip either, so none is painted — selection
* there is the stroke and the label, nothing more.
*/
export function AnnotationShape({ shape, zoom }: ShapeProps): JSX.Element {
export function AnnotationShape({ shape, zoom, handles }: ShapeProps): JSX.Element {
return (
<g data-annotation-id={shape.id} data-label-class={shape.labelClass}>
{shape.geometry.type === "bbox" ? (
Expand All @@ -364,10 +368,11 @@ export function AnnotationShape({ shape, zoom }: ShapeProps): JSX.Element {
/>
)}
<ShapeLabel shape={shape} />
{shape.selected && shape.geometry.type === "bbox" && (
{handles && shape.selected && shape.geometry.type === "bbox" && (
<Grips geometry={shape.geometry} color={shape.color} zoom={zoom} hotHandle={null} />
)}
{shape.selected &&
{handles &&
shape.selected &&
(shape.geometry.type === "polygon" || shape.geometry.type === "polyline") && (
<Vertices
points={shape.geometry.points}
Expand Down
4 changes: 3 additions & 1 deletion frontend/annotator/src/adapters/react/TransientLayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ export function TransientLayer({
</g>
)}

{edited !== null && <AnnotationShape shape={edited} zoom={zoom} />}
{/* `handles` unconditionally: this layer only ever draws a shape a
gesture is holding, and no gesture exists in the read-only mode. */}
{edited !== null && <AnnotationShape shape={edited} zoom={zoom} handles={true} />}

{band !== null && (
<rect
Expand Down
51 changes: 50 additions & 1 deletion frontend/annotator/src/core/interaction/affordance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import {
sceneDocument,
worldIn,
} from "./_scene";
import { HANDLE_CURSORS, affordanceAt } from "./affordance";
import { HANDLE_CURSORS, affordanceAt, viewerAffordanceAt, viewerPressTarget } from "./affordance";
import type { Cursor } from "./affordance";
import { transition } from "./machine";
import { IDLE } from "./state";
Expand Down Expand Up @@ -439,3 +439,52 @@ describe("the cursor table", () => {
});
});
});

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);
});
});
44 changes: 42 additions & 2 deletions frontend/annotator/src/core/interaction/affordance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}
4 changes: 3 additions & 1 deletion frontend/app/cycle/cycle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading
Loading