Skip to content

feat(annotator): a verb for finishing a frame — Save and next takes the filled slot - #384

Merged
JArmandoAnaya merged 1 commit into
mainfrom
feat/top-bar-flow-verbs
Aug 6, 2026
Merged

feat(annotator): a verb for finishing a frame — Save and next takes the filled slot#384
JArmandoAnaya merged 1 commit into
mainfrom
feat/top-bar-flow-verbs

Conversation

@JArmandoAnaya

Copy link
Copy Markdown
Contributor

Closes #383.

The bar #368 left had no control for the commonest move in the product. After annotating a frame the right action is store this and show me the next one, and the only thing that advanced was the navigator's chevron — chrome, not a verb — so Skip was the most prominent thing to press on work somebody had just done, and the explicit save was two clicks deep in the overflow.

What the right zone renders now

n/m annotated · Save and stay (ghost, ⌘S) · the review move (outline) · Skip/Un-skip (outline, X) · Finish job · Save and next (filled, ) · overflow .

  • Save and next is go(1) — the navigator's own save-first advance, reused rather than reimplemented, so there is one save pipeline and one place principle 10 is enforced. The settle to annotated is not sent from here either: progress_after_annotating makes that move in the same transaction as the write.
  • It reads Next when no save will happen, so the button never promises one it will not perform.
  • On the last frame it is not rendered and Finish job takes the filled slot. Everywhere else Finish job keeps WS2's outline-and-disabled-with-reason treatment.
  • The review move (submit_for_review, else accept) is outline and carries decision 6's tooltip. Save and stay is back as a ghost button.
  • Left zone: the progress dot gains its word — one microtext reading ● annotated · Saved.

Stop-and-flag: enumerated, and it did not fire

The condition was two filled candidates co-declaring. Enumerated from capabilities.py rather than reasoned about — asset_actions and job_actions, batch in_annotation (nothing declares anything in any other batch state):

frame progress asset declares job complete declared? filled control, frame 1 of 2 filled control, last frame
unannotated annotate, skip no (unsettled) Save and next Finish job (disabled, reason)
annotated annotate, skip, submit_for_review yes when every frame is settled Save and next Finish job
skipped restore yes when every frame is settled Save and next Finish job
review_pending accept, return_to_annotator no (review_pending blocks) Save and next (as Next) Finish job (disabled, reason)
accepted yes when every frame is settled Save and next (as Next) Finish job

Nothing contends. The filled slot is chosen by assetIndex >= assetCount - 1, which is this page's own arithmetic and not a declaration anything can co-claim; every wire-declared control on the bar is outline or ghost. test("leaves exactly one filled control in every progress, crossed with the last frame") sweeps all ten cells against bg-primary.

The second stop-and-flag — advancing past the last frame has no defined behaviour — also did not fire: go() clamps and returns without committing when the move is a no-op, and decision 3 removes the button there anyway.

Three findings, each a departure worth naming

1. enter was already taken, and the decision record could not have known. DEFAULT_BINDINGS binds enter to send commit — v1's polygon ring close, the one close a keyboard can always reach, asserted by six e2e scenarios. Binding the flow verb to enter in that table would have shadowed it, because the fold is last-wins.

The resolution keeps both: enter means finish — finish the shape while one is being drawn, finish the frame otherwise. The two never overlap, because outside drawing-polygon the machine has no row for a commit and the press is silently swallowed today. The substitution is in AnnotatorCanvas.handleKeyDown, before the read-only branch, because the deciding fact is the interaction state and that is the adapter's — core's table stays honest and the host kind is already in READ_ONLY_KINDS, so advances a frame nobody may edit exactly as the button beside it does. Mutation-verified: dropping the idle predicate makes the ring close stop working and turns the named scenario red.

2. X was bound to nothing at all. The brief says the chips are visual and "the bindings themselves stay in the existing input layers" — mod+s was there, x was not, so the chip would have named a key that does nothing. So x is a new row in DEFAULT_BINDINGS (a bare letter on c's and v's terms), reaching a new SKIP_FRAME host action. That is a second core/ touch beyond the brief's literal carve-out, taken because the alternative was a chip that lies.

3. Decision 3 read as a promotion, not an appearance. "Finish job takes the filled slot on the last frame" could mean it renders only there; it does not, and the deciding evidence is the shipped contract — an existing e2e scenario finishes a job from frame 1 of 2, and a job whose every frame is settled can legitimately be finished from anywhere in it. So Finish job stays visible on every frame with the WS2 amendment's disabled-with-reason, and what changes on the last frame is its variant. The parenthetical "(still disabled-with-reason otherwise, per the WS2 amendment)" reads as confirming exactly that.

Two smaller readings, stated rather than buried:

  • The Next degradation also fires on a read-only frame. Decision 2's key is no annotations and no unsaved changes; a settled frame has annotations, cannot be dirty, and cannot be written to by anyone — so the stated key alone would put Save and next where no save is reachable at all, which is the thing decision 2 exists to prevent. One extra disjunct, !readOnly.
  • Responsive reabsorption is implemented on viewport breakpoints, xl for Save and stay and lg for the review move, each overflow item carrying the exact inverse class so the control exists in exactly one place at any width. The trigger decision 4 names is a container collision with the class field, which would need a ResizeObserver on the bar; the breakpoints are a proxy, and the order is the one the decision fixes. Both thresholds are reachable above ANNOTATOR_MIN_VIEWPORT_PX (768).

WORKFLOW_PRIMARIES is renamed REVIEW_ACTIONS — the brief's "or its successor" — because the list is no longer the primary and a name that says otherwise is the kind of thing a reader trusts.

Found, not fixed

Nothing. No unrelated defect surfaced.

Test plan

New: 12 vitest cases in topBar.test.tsx (right-zone composition across all five progress states crossed with last-frame, the filled-slot sweep, the Next degradation, the ↵ and X chords with their focus and wire gates, the microtext) and 4 Playwright scenarios in annotate.spec.ts — the save-first ordering, the label's two halves, enter's two meanings in one test, and the last frame's slot handover. The ordering claim is in the browser deliberately: making a document dirty means drawing, and jsdom's getBoundingClientRect returns all zeros, so a component test clicking the button over a clean document would pass with the commit deleted.

Eight mutations, each confirmed to turn a named test red, each applied with its anchor asserted present-exactly-once before and its replacement asserted present after, each reverted by its own file with the tree verified clean:

mutation test that went red
{!lastFrame && ( → always render leaves exactly one filled control …
variant={lastFrame ? …} → always secondary leaves exactly one filled control …
flowLabel → always "Save and next" reads Next on a frame nobody has drawn on
drop declares(asset, skip) from the X row does not skip on X from a frame the wire will not let go
drop {word} from the progress dot says the word beside the dot …
change the submit tooltip's asserted half says what submitting means …
disable the SAVE_AND_NEXT host row advances on ↵ …
drop interactionNow.current.type === "idle" Enter closes a ring while one is open … (e2e)

One of those runs came back green on the first attempt — the tooltip mutation changed a half of the string the test does not assert. Re-run against the asserted half, it turned red. Recorded because a green mutation run that is really a bad anchor is exactly the false calm the protocol's harness rules are about.

Gate run in stages under the harness ceiling, exit codes verbatim:

stage exit notes
check.sh python 0 pytest 441s, ruff, mypy, 3 import contracts
check.sh frontend 0 build; annotator 821, ui-core 633; lint + 3 typechecks
pnpm test:scripts 0 68 node gates, including docs_links and wire_rosters
check.sh generated 0 openapi drift, client drift, MCP reference, version sync
check.sh browser 0 202 e2e (198 + 4 new) in 2.2m; browser cycle, real server, 45.7s

No CI job added, renamed or removed, so the main ruleset is untouched. No kernel change, no migration, no wire change — openapi.json and the generated client are byte-still.

DESIGN.md's top-bar spec and CHANGELOG.md are updated in this PR; _frame.ts's expectProgress docstring no longer claims the word lives in an accessible name.

…he filled slot

The top bar #368 left had no control for the commonest move in the product:
after annotating a frame, store it and go to the next one. The navigator's `›`
is chrome rather than a verb, so Skip inherited prominence by vacuum on exactly
the frames somebody had just done work on, and the explicit save lived two
clicks deep in the overflow.

Save and next is the one filled control now, carrying ↵, with Skip beside it
carrying X — two ways to resolve a frame, both advancing, neither collapsible.
It is `go(1)`, the navigator's own save-first advance, so there is one save
pipeline; it reads `Next` when no save will happen. On the last frame it is not
rendered and Finish job takes the filled slot, which is the only place the two
could have contended. The review move becomes outline and gains a tooltip; Save
and stay returns as a ghost button; the progress dot gains its word.

`enter` now means *finish*: the ring close while a shape is in progress, the
flow verb otherwise — substituted in the adapter, which is the only layer
holding the interaction state. `x` is a new row in the default binding table.
@JArmandoAnaya
JArmandoAnaya enabled auto-merge (squash) August 6, 2026 09:48
@JArmandoAnaya
JArmandoAnaya merged commit 75ee22c into main Aug 6, 2026
14 checks passed
@JArmandoAnaya
JArmandoAnaya deleted the feat/top-bar-flow-verbs branch August 6, 2026 09:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Annotation top bar: flow verbs rework — Save and next primary, resolution pair visible

1 participant