diff --git a/DESIGN.md b/DESIGN.md index e27cf941..c5ddf125 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -565,8 +565,10 @@ The page the reference design shows (#56), with measurements verified in v1's so `Save and next` is `go(1)`: the same save-first advance the navigator has always used, so there is one save pipeline and one place principle 10 is enforced. It reads **`Next` - when no save will happen** — an untouched frame, or one that cannot be written to at - all — because the button never promises a save it will not perform. + when no save will happen** — an untouched frame — because the button never promises a + save it will not perform, and once the *job* is closed it is **not rendered at all** + (#439): there is no save-first advance to offer on any frame of it, and `›` is what + moves there. See *The read-only mode* below. **On the last frame `Finish job` takes the filled slot**, in place: `Save and next` is not rendered there, and Finish job is not rendered anywhere else (#416). There is @@ -777,18 +779,36 @@ The page the reference design shows (#56), with measurements verified in v1's so ### 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): +frame — a completed batch, a **finished job**, 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. + +**It is also a transition, not only an entry state** (decision of 2026-08-08, +#439). Pressing `Finish job` turns the workspace into a viewer **in place** — +same window, no navigation away, no reload — across every frame of the job and +not only the last. That works for one reason and it is worth stating: the +mutation invalidates the frames' declarations, the wire's answer moves because +the *kernel's* did (`asset_actions` reads the job's state), and the page +re-derives. Nothing here mirrors a rule. A job completing does not complete its +batch, so before #439 the wire's answer did not move and the workspace stayed an +editor over work it had just been told was over. + +The press says so out loud — a toast, the add-a-class chain's idiom — because +everything else it does is a subtraction, and a screen with less on it is not an +explanation. + +What a viewer is (decisions of 2026-08-07, #426, and 2026-08-08, #439): - **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. + onward: `Correct this batch`. It renders on every frame of a closed batch or a + finished job, including skipped ones (#423, #439), where the skipped notice + would otherwise promise an Un-skip the wire withholds. Three causes, three + sentences, ranked: a **closed batch** carries the correction route; a + **finished job** names itself and offers nothing, because `JOB_TRANSITIONS` has + no way back and the batch is still open; a **settled frame** in an open batch + points at the control on this very toolbar. - **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 @@ -804,10 +824,32 @@ What a viewer is (decision of 2026-08-07, #426): 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). +- **Once the job is closed, the frame's own verbs go with it — and the job's + does not.** `Skip` / `Un-skip` and the flow verb (`Save and next` / `Next`) + stop rendering on every frame of a completed batch or a finished job: the tool + strip's rule applied to the bar, since all three only ever move *this frame* + and there is no move behind any of them there. The `browse | resolve` divider + goes with them. + + **The gate is the job, never the frame**, and both halves of that are load + bearing. A merely *settled* frame inside a working job keeps the pair, greyed — + because the cluster is measured to one width (above) and a slot that emptied + and refilled as somebody walked a mixed job would move the arrows under their + cursor, and because `Un-skip` is the one way back out of a skipped frame, which + is itself read-only. A closed job withholds every move on every frame alike, so + the cluster is uniformly narrower and nothing jitters. + + **`Finish job` stays** on the last frame: `complete` is the *job's* declaration + rather than the frame's, and a job whose last frame happens to be `accepted` + would otherwise have no way to be finished at all. Once it is finished it reads + `Finished`, which is this page's standing statement that the work is over. +- **Navigation stays whole.** `‹` `›`, the `n/m` counter and the frame gallery + all work, and no save-first guard engages — there is nothing to save. Only + editing dies; moving between pictures does not. +- **Reads stay live.** Zoom, pan, fullscreen, 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 diff --git a/docs/api.md b/docs/api.md index f6c2dda5..9730e4e0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -499,7 +499,7 @@ argument for branching on `code`. | **404** | `PROJECT_NOT_FOUND` · `SCHEMA_NOT_FOUND` · `BATCH_NOT_FOUND` · `JOB_NOT_FOUND` · `INGEST_JOB_NOT_FOUND` · `ASSET_NOT_FOUND` · `SOURCE_NOT_FOUND` · `DATASET_NOT_FOUND` · `ANNOTATION_NOT_FOUND` · `RELEASE_NOT_FOUND` · `ASSET_NOT_IN_JOB` · `NO_SPLIT_RECIPE` · `EXPORT_FORMAT_NOT_FOUND` · `THUMBNAIL_NOT_CACHED` · `NOT_FOUND` (no such route) | | **405** | `METHOD_NOT_ALLOWED` | | **401** | `UNAUTHORIZED` — with a `WWW-Authenticate: Bearer` challenge | -| **409** | `PROJECT_NAME_TAKEN` · `RELEASE_TAG_TAKEN` · `WORKSPACE_ALREADY_EXISTS` · `WORKSPACE_NOT_EMPTY` · `SCHEMA_VERSION_CONFLICT` · `INVALID_TRANSITION` · `STALE_WRITE` · `BATCH_NOT_EDITABLE` · `BATCH_NOT_IN_ANNOTATION` · `BATCH_NOT_COMPLETE` · `JOB_NOT_COMPLETE` · `EMPTY_BATCH` · `EMPTY_RELEASE` · `CONFIRMATION_REQUIRED` · `DESTRUCTIVE_SCHEMA_CHANGE` · `SCHEMA_CHANGE_WOULD_ORPHAN` · `UNSERIALIZABLE_MANIFEST` · `LOSSY_EXPORT_NOT_CONSENTED` | +| **409** | `PROJECT_NAME_TAKEN` · `RELEASE_TAG_TAKEN` · `WORKSPACE_ALREADY_EXISTS` · `WORKSPACE_NOT_EMPTY` · `SCHEMA_VERSION_CONFLICT` · `INVALID_TRANSITION` · `STALE_WRITE` · `BATCH_NOT_EDITABLE` · `BATCH_NOT_IN_ANNOTATION` · `JOB_FINISHED` · `BATCH_NOT_COMPLETE` · `JOB_NOT_COMPLETE` · `EMPTY_BATCH` · `EMPTY_RELEASE` · `CONFIRMATION_REQUIRED` · `DESTRUCTIVE_SCHEMA_CHANGE` · `SCHEMA_CHANGE_WOULD_ORPHAN` · `UNSERIALIZABLE_MANIFEST` · `LOSSY_EXPORT_NOT_CONSENTED` | | **422** | `VALIDATION_ERROR` · `INVALID_NAME` · `INVALID_SCHEMA` · `UNSUPPORTED_GEOMETRY` · `INVALID_ANNOTATION` · `LABEL_CLASS_NOT_IN_SCHEMA` · `DISALLOWED_GEOMETRY` · `MISSING_REQUIRED_ATTRIBUTE` · `UNKNOWN_ATTRIBUTE` · `INVALID_ATTRIBUTE_VALUE` · `INVALID_PARTITION` · `MEDIA_ERROR` · `UNSUPPORTED_MEDIA` · `CORRUPT_MEDIA` | | **503** | `WORKSPACE_BUSY` | | **500** | `WORKSPACE_CORRUPT` · `NOT_A_WORKSPACE` · `WORKSPACE_FORMAT_TOO_NEW` · `WORKSPACE_SCHEMA_MISMATCH` · `ENTITY_NOT_FOUND` · `ENTITY_ALREADY_EXISTS` · `CONSTRAINT_VIOLATED` · `MEDIA_TOOL_UNAVAILABLE` · `INTERNAL_ERROR` | diff --git a/docs/jobs.md b/docs/jobs.md index 3876ab35..de84b948 100644 --- a/docs/jobs.md +++ b/docs/jobs.md @@ -83,8 +83,31 @@ The remedy is the transition table. `skipped → unannotated` is the take-it-bac reversed while the job is open makes the asset writable again. `accepted` has no exit at all, by design, which is why correcting accepted work means a new batch rather than a progress move. -Two gates, two questions: `BatchNotInAnnotation` says nobody opened this batch, and its remedy is -to start it. `AssetNotWritable` says this asset inside an open batch is done being labeled. +### …and so does a job that has already finished + +```python +OPEN_JOB_STATES # {pending, in_progress} +``` + +The third gate, and the one that arrived last (#439). `JobService.complete` does **not** complete +the batch — `BatchService` derives that separately when asked — so the ordinary state of a +finished job is *inside a batch that is still open*. The batch gate therefore had nothing to say +about it, and until this existed a completed job went on accepting labels and progress moves: the +word "finished" describing nothing, and work landing where nobody would look for it. + +`AnnotationService.add`, `update` and `delete` consult it, and so does `JobService.mark`; all four +answer `JobFinished` (409 `JOB_FINISHED`). Reads are untouched — `AnnotationService.for_asset` +passes no gate but membership, because a viewer over finished work has to be able to show it. + +The remedy is not a move. `JOB_TRANSITIONS` gives `completed` no way back, by the same +forward-only argument a completed batch is immutable by, so correcting finished work means a +correction batch. + +Three gates, three questions: `BatchNotInAnnotation` says nobody opened this batch, and its +remedy is to start it. `JobFinished` says this job inside an open batch is over, and it has no +remedy — a batch is partitioned into jobs that finish at different times, and the first to finish +freezes its own frames while its neighbours carry on. `AssetNotWritable` says this asset inside an +open job is done being labeled. ### Marking a state it is already in is a no-op diff --git a/frontend/app/cycle/cycle.spec.ts b/frontend/app/cycle/cycle.spec.ts index 2b2420d7..25a081f8 100644 --- a/frontend/app/cycle/cycle.spec.ts +++ b/frontend/app/cycle/cycle.spec.ts @@ -580,6 +580,34 @@ test("the whole cycle, from opening the app to a downloaded export", async ({ pa await expect(page.getByTestId("job-progress")).toHaveText("3 / 3 annotated"); await page.getByTestId("finish-job").click(); await expect(page.getByTestId("finish-job")).toHaveText("Finished"); + + /* + * #439, against the real kernel and in place — no reload, no navigation. + * + * The batch is still `in_annotation` here (the next step is what completes + * it), which is exactly the case the batch gate cannot cover: `JobService` + * does not cascade upward. What moves is `asset_actions` reading the job's + * state, so the declarations this mutation invalidates come back empty and + * the page re-derives its mode from them. A stub can be made to say that; + * only this walk proves the kernel does. + * + * Worth stating about the frame we are standing on: it was already a viewer + * a moment ago, because `accepted` is not writable — and `Finish job` was + * still on the bar, which is why it could be pressed at all. That is the + * rule kept deliberately when the frame's own verbs left the read-only mode: + * `complete` is the job's declaration, not the frame's. + */ + await expect(page.getByTestId("readonly-banner")).toContainText(/this job is finished/i); + await expect(page.getByTestId("tool-palette")).toHaveCount(0); + await expect(page.getByTestId("class-region")).toHaveCount(0); + await expect(page.getByTestId("save-and-next")).toHaveCount(0); + + // Every frame, not only the one it was pressed on — and navigation is what + // proves it, which is also half the decision: only editing dies. + await page.getByTestId("prev-asset").click(); + await expect(page.getByTestId("asset-position")).toHaveText("2/3"); + await expect(page.getByTestId("readonly-banner")).toContainText(/this job is finished/i); + await expect(page.getByTestId("tool-palette")).toHaveCount(0); }); await test.step("complete the batch", async () => { diff --git a/frontend/app/e2e/_wire.ts b/frontend/app/e2e/_wire.ts index 3cb59db8..461bad48 100644 --- a/frontend/app/e2e/_wire.ts +++ b/frontend/app/e2e/_wire.ts @@ -71,12 +71,21 @@ export function jobActions( ); } -/** An asset's actions. `progress` is null exactly while the batch is a draft. */ +/** + * An asset's actions. `progress` is null exactly while the batch is a draft. + * + * `jobState` is the third dimension (#439) and defaults to `in_progress`, the + * state a job is in while somebody is working it — which is what every caller + * here means. A `completed` job declares nothing on any of its frames, in an + * open batch as much as in a closed one: completing a job does not complete its + * batch, so the batch dimension cannot cover this. + */ export function assetActions( progress: string | null, - options: { batchState?: string } = {}, + options: { batchState?: string; jobState?: string } = {}, ): string[] { - const { batchState = "in_annotation" } = options; + const { batchState = "in_annotation", jobState = "in_progress" } = options; if (batchState !== "in_annotation" || progress === null) return []; + if (jobState === "completed") return []; return [...(ASSET_ACTIONS[progress as Progress] ?? [])]; } diff --git a/frontend/app/e2e/annotate.spec.ts b/frontend/app/e2e/annotate.spec.ts index dd3ac6a1..708bb1bc 100644 --- a/frontend/app/e2e/annotate.spec.ts +++ b/frontend/app/e2e/annotate.spec.ts @@ -37,6 +37,7 @@ function asset( index: number, progress: string, batchState = "in_annotation", + jobState = "in_progress", ): Record { return { id: `asset-${index}`, @@ -53,12 +54,14 @@ function asset( ingested_at: null, job_id: JOB, progress, - // Threaded from the batch, because that is what the server does: - // `asset_actions` returns `[]` for every frame of a batch that is not - // `in_annotation`, whatever the frame's own progress is. Without it a mock - // would declare `annotate` on a completed batch and the read-only mode this - // suite is about would never be exercised. - allowed_actions: assetActions(progress, { batchState }), + // Threaded from the batch **and from the job**, because that is what the + // server does: `asset_actions` returns `[]` for every frame of a batch that + // is not `in_annotation` and for every frame of a job that has been + // completed, whatever the frame's own progress is. Without the first a mock + // would declare `annotate` on a completed batch; without the second it would + // declare it on a finished job — and since the job's state is what the + // Finish press moves, that is the whole of the live transition below (#439). + allowed_actions: assetActions(progress, { batchState, jobState }), }; } @@ -239,8 +242,8 @@ async function serveApi( return route.fulfill({ json: { items: [ - asset(1, progress.get("asset-1") ?? "unannotated", lifecycle.batch), - asset(2, progress.get("asset-2") ?? "annotated", lifecycle.batch), + asset(1, progress.get("asset-1") ?? "unannotated", lifecycle.batch, lifecycle.job), + asset(2, progress.get("asset-2") ?? "annotated", lifecycle.batch, lifecycle.job), ], total: 2, }, @@ -1266,9 +1269,12 @@ test("a completed batch opens as a viewer, and says so", async ({ page }) => { await expect(banner).toContainText(/correction batch/i); // Every control that writes is out, and the palette is gone entirely — a tool - // palette over a canvas that cannot be drawn on explains nothing. + // palette over a canvas that cannot be drawn on explains nothing. `skip` is + // absent rather than disabled since #439: the pair keeps its slot inside a + // working job and loses it once the job is closed, which a completed batch's + // is. await expectNothingToSave(page); - await expect(page.getByTestId("skip")).toBeDisabled(); + await expect(page.getByTestId("skip")).toHaveCount(0); await expect(page.getByTestId("accept")).toHaveCount(0); await expect(page.getByTestId("tool-palette")).toHaveCount(0); }); @@ -1443,6 +1449,111 @@ test("selecting on the canvas scrolls the object's row into view", async ({ page expect(where.y + where.height).toBeLessThanOrEqual(scroller.y + scroller.height + 1); }); +/** + * #439: **the transition itself**, which is the part that had never worked. + * + * Pressing Finish completed the job and left the workspace a live editor — tool + * strip, classes panel, Skip, Save and next, on every frame — because the + * declaration the page reads did not move. Completing a job does not complete + * its batch (`BatchService` derives that separately), so the batch dimension + * could not cover it; `asset_actions` now reads the job's state too, and the + * invalidation the mutation already performed does the rest. + * + * Asserted **in place**: a sentinel written on `window` before the press is read + * back after it, so a reload — which would hide the whole defect by rebuilding + * the page from a fresh fetch — fails the test rather than passing it. + */ +test("finishing the job turns the workspace into a viewer in place, on every frame", async ({ + page, +}) => { + const sent: Request[] = []; + // Both frames settled, so the job declares `complete` and Finish is live; a + // stored box on the last frame so the post-transition selection rules have + // something to select. + await openJob( + page, + sent, + progressStore({ "asset-1": "annotated", "asset-2": "annotated" }), + openedWorld(), + undefined, + [storedBox("asset-2")], + ); + + // Frame 2 of 2 — where Finish job renders, and only there (#416). + await page.getByTestId("next-asset").click(); + await expect(page.getByTestId("asset-position")).toHaveText("2/2"); + + // The editor, before: the state the defect left behind afterwards. + await expect(page.getByTestId("tool-palette")).toBeVisible(); + await expect(page.getByTestId("class-region")).toBeVisible(); + await expect(page.getByTestId("readonly-banner")).toHaveCount(0); + const finish = page.getByTestId("finish-job"); + await expect(finish).toHaveAttribute("data-withheld", "false"); + + await page.evaluate(() => { + (window as unknown as { __sameDocument?: number }).__sameDocument = 439; + }); + await finish.click(); + + // The mode flipped, and the banner names the cause the batch cannot: this + // batch is still `in_annotation`, so there is no correction route to offer and + // the sentence stops at the cause. + const banner = page.getByTestId("readonly-banner"); + await expect(banner).toBeVisible(); + await expect(banner).toContainText(/viewing only/i); + await expect(banner).toContainText(/this job is finished/i); + await expect(page.getByTestId("banner-create-correction")).toHaveCount(0); + + // Same document — no navigation, no reload. + expect( + await page.evaluate(() => (window as unknown as { __sameDocument?: number }).__sameDocument), + ).toBe(439); + + // Visible success, in the vocabulary the add-a-class chain already uses. + await expect(page.getByText(/job finished/i).first()).toBeVisible(); + + // Everything that only ever performed an edit is **absent**, not disabled. + await expect(page.getByTestId("tool-palette")).toHaveCount(0); + await expect(page.getByTestId("class-region")).toHaveCount(0); + await expect(page.getByTestId("panel-split")).toHaveCount(0); + await expect(page.getByTestId("skip")).toHaveCount(0); + await expect(page.getByTestId("save-and-next")).toHaveCount(0); + await expectNothingToSave(page); + // The job's own control keeps its slot and states the outcome. + await expect(finish).toHaveText(/finished/i); + await expect(finish).toBeDisabled(); + + // The objects region takes the whole panel (#426 a), measured after the flip + // rather than assumed from the completed-batch scenario. + 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); + + // #426 (c) and (d) hold on the far side of the transition: a press selects, + // the row follows, and the selection grows no handles. + const shape = page.locator("[data-annotation-id]").first(); + const where = (await shape.boundingBox())!; + await page.mouse.click(where.x + where.width / 2, where.y + where.height / 2); + await expect(page.getByTestId("object-row-0")).toHaveAttribute("data-selected", "true"); + await expect(page.locator("[data-handle]")).toHaveCount(0); + await expect(page.locator("[data-vertex]")).toHaveCount(0); + + // **Every frame, not only the one Finish was pressed on.** Navigation is what + // proves it, and navigation still working is half the decision. + await page.getByTestId("prev-asset").click(); + await expect(page.getByTestId("asset-position")).toHaveText("1/2"); + await expect(page.getByTestId("readonly-banner")).toBeVisible(); + await expect(page.getByTestId("tool-palette")).toHaveCount(0); + await expect(page.getByTestId("class-region")).toHaveCount(0); + + // The other half: the gallery still opens, and no save-first guard engages — + // there is nothing to save. + await page.getByTestId("open-gallery").click(); + await expect(page.getByTestId("frame-gallery")).toBeVisible(); + expect(sent.filter((r) => r.method() === "POST" && r.url().includes("/annotations"))).toEqual([]); +}); + /** * The one frame where the read-only mode said nothing at all (#423): the banner * rendered only while the frame was not skipped — a guard older than the @@ -1468,7 +1579,10 @@ test("a skipped frame in a completed batch still says viewing only, and names th // that speaks — two banners saying different things about one frame is how a // person learns to trust neither. await expect(page.getByTestId("skipped-notice")).toHaveCount(0); - await expect(page.getByTestId("unskip")).toBeDisabled(); + // Absent rather than disabled since #439 — the frame's own verbs leave once + // the job is closed, and a completed batch's is. Inside an *open* batch this + // same frame keeps its Un-skip, which is the distinction that rule turns on. + await expect(page.getByTestId("unskip")).toHaveCount(0); }); /** diff --git a/frontend/ui-core/src/annotator/AnnotationPage.tsx b/frontend/ui-core/src/annotator/AnnotationPage.tsx index f7142b42..9f04142e 100644 --- a/frontend/ui-core/src/annotator/AnnotationPage.tsx +++ b/frontend/ui-core/src/annotator/AnnotationPage.tsx @@ -1071,14 +1071,38 @@ function Workspace({ /** * Why it is read-only, in the words a person can act on. * - * Two different causes, and running them together is what would make this + * Three different causes, and running them together is what would make this * banner useless: a **closed batch** is about the workflow and its remedy is a - * correction batch, while a **settled frame** in an open batch is about this - * one picture and its remedy is on this very toolbar. `withheldBecause` - * answering null is how the first is told from the second — it speaks only for - * the states that close a batch. + * correction batch; a **finished job** is about this sitting of work and has no + * remedy at all, because nothing re-opens a job; a **settled frame** in an open + * batch is about this one picture and its remedy is on this very toolbar. + * `withheldBecause` answering null is how the first is told from the rest — it + * speaks only for the states that close a batch. */ const closedBecause = withheldBecause(batchState); + /** + * The middle cause (#439), and it is copy rather than legality: whether the + * frame is a viewer is `allowed_actions`' answer and is already decided above. + * This only picks the sentence, the way `withheldBecause` picks one from the + * batch's state. + * + * It names no route onward on purpose. `JOB_TRANSITIONS` has no way back from + * `completed`, and the batch is still open, so there is no correction to offer + * either — the honest sentence stops at the cause. When the batch does close, + * `closedBecause` outranks this and brings `Correct this batch` with it. + */ + const finishedBecause = + jobState === "completed" + ? "This job is finished, so its frames can no longer be edited." + : null; + /** + * The two causes that are about the *workflow* rather than about this picture, + * held as one value because three places have to agree on them: the banner + * renders for either, the skipped notice yields to either, and the sentence is + * whichever spoke. Kept as one derivation so a fourth cause cannot be added to + * two of the three. + */ + const workflowBecause = closedBecause ?? finishedBecause; /** The tooltip a withheld control carries. Null when the batch is not the cause. */ const withheld = closedBecause; /** @@ -1180,6 +1204,25 @@ function Workspace({ * reachable at all. */ const flowLabel = !readOnly && (dirty || drawn > 0) ? "Save and next" : "Next"; + /** + * Whether the frame's own verbs render at all (#439). + * + * **A job-level question, deliberately, and not `readOnly`.** Two invariants + * meet here and only this reading keeps both. #416 measured the navigation + * cluster to a constant width so that walking a job does not move the arrows + * under a cursor — which is why `Skip` is `min-w-27` and disabled rather than + * absent on a frame that cannot take it. And #423 made `Un-skip` the one way + * back out of a skipped frame, which is a *read-only* frame in an open batch. + * + * Gating on the mode would break both: the slot would empty and refill frame + * by frame as somebody walked a mixed job, and a skipped frame would lose the + * control its own notice promises. Gating on whether **the whole job is + * closed** breaks neither — a closed batch and a finished job withhold every + * move on every frame alike, so the cluster is uniformly narrower and nothing + * jitters, and that is exactly the state where a greyed-out pair was repeating + * the banner's sentence with no move behind it. + */ + const frameVerbs = workflowBecause === null; const progressWord = PROGRESS_LABEL[asset.progress ?? "unannotated"] ?? asset.progress ?? ""; /** @@ -1418,7 +1461,15 @@ function Workspace({ - + {/* + The hairline between *look at another frame* and *finish this one*, + and it renders only while there is a second group to divide from + (#439). Everything on its right can now be absent at once — a middle + frame of a closed batch or a finished job offers no resolution move + and no save-first advance — and a divider drawn around an absence is + a rule with nothing on one side of it. + */} + {(frameVerbs || lastFrame) && } {/* --- resolve: finish with this frame ------------------------ */}
@@ -1433,15 +1484,22 @@ function Workspace({ collapses into the overflow, which is what stopped Skip inheriting prominence from a bar where nothing else advanced. #416 put them beside the arrows they were always the counterpart of. + + **Absent once the job is closed** (#439) — see `frameVerbs`, which + is a question about the job rather than about this frame. Inside a + working job the pair keeps its slot and its disabled state, which + is what holds the cluster still and what keeps `Un-skip` reachable + on a skipped frame; once a batch or a job has closed there is no + move behind either label on any frame, and a greyed pair there was + repeating the banner's sentence with nothing attached. */} - {skipped ? ( + {!frameVerbs ? null : skipped ? (