diff --git a/.agents/skills/frontend/information-architecture/SKILL.md b/.agents/skills/frontend/information-architecture/SKILL.md index 08242ed1..7e2aaeec 100644 --- a/.agents/skills/frontend/information-architecture/SKILL.md +++ b/.agents/skills/frontend/information-architecture/SKILL.md @@ -35,6 +35,7 @@ router, so it can only say what a value resolves to, never change the URL. Rules derived from the 2026-08 audit (§6): +- **A correction batch is reached from the batch that needs correcting**, never from a "new batch" form: the gallery header and the Batches row both offer it on a `completed` batch, capability-gated on `create_correction`. The annotator's read-only banner and the gallery's bulk bar *link* to it rather than duplicating it — creating a batch is a curation act, curation lives on the batch view, and a second place batches are made is a second place the rules can drift. - **Dataset is first-class.** It is the product's central object and must be reachable in ≤1 click from any project tab. It is never gated behind, or discoverable only through, onboarding UI. Promotion success links onward to it; the gallery links to it once a batch is `completed`. - **"Schema history" is not a sibling tab.** Version history lives inside the Schema tab, below the editor and beside the `VersionNavigator` seam. The two overlap on purpose: the navigator is the *reader* (one version, with what it changed), the history is the *ledger* (every version at once). `?tab=versions` remains as a redirect; it does not appear in the tab bar. - **The 4-step checklist is onboarding, not navigation.** It retires itself twice over: when the journey is finished (`hasReleases` makes `done` derivable) and when somebody dismisses it. Dismissal is **per project** and persisted — finishing one project does not teach you the pipeline for the next. It gates nothing and is never the sole path to a screen. diff --git a/frontend/app/cycle/cycle.spec.ts b/frontend/app/cycle/cycle.spec.ts index 4df79598..f9c30d19 100644 --- a/frontend/app/cycle/cycle.spec.ts +++ b/frontend/app/cycle/cycle.spec.ts @@ -408,6 +408,42 @@ test("the whole cycle, from opening the app to a downloaded export", async ({ pa ); }); + await test.step("correct the completed batch, forward-only", async () => { + /* + * **The end of the forward-only story** (audit G6), against the real kernel. + * + * A completed batch has no exit and none is coming, so the product's answer + * to "this frame is wrong" is a new batch over the same frames recording + * where it came from. Three surfaces had been saying so while nothing could + * create one; this is the control they were pointing at. + * + * Run here rather than against stubs because the two claims worth making are + * about the kernel: that the parent is genuinely untouched, and that the + * child pins the project's *active* schema at its own approval rather than + * inheriting the parent's. + */ + await page.getByTestId("correct-cycle-batch").click(); + await expect(page.getByTestId("correction-dialog")).toBeVisible(); + // The suggested name is the parent's, so the ordinary case costs no typing. + await expect(page.getByTestId("correction-name")).toHaveValue(/cycle-batch/); + await page.getByTestId("correction-submit").click(); + + // It navigates to the correction it just made, and that batch says what it + // corrects. One hop: the child names its parent, and a reader walks the + // chain for the origin. + await expect(page.getByTestId("gallery")).toBeVisible(); + await expect(page.getByTestId("correction-of")).toContainText("Correction of cycle-batch"); + await expect(page.getByTestId("batch-state")).toHaveText("pending approval"); + + // And the parent has not moved — which is the whole point of correcting + // forward instead of reopening. + await openProject(page, "batches"); + await expect(page.getByTestId("state-cycle-batch")).toHaveText("completed"); + await expect(page.getByTestId("promoted-count-cycle-batch")).toHaveText( + /3 of 3 in the dataset/, + ); + }); + await test.step("publish a release", async () => { // **A tab, reached in one press.** It was behind the header's overflow menu, // which is where a destination goes when the navigation has no room for it — diff --git a/frontend/app/e2e/_wire.ts b/frontend/app/e2e/_wire.ts index 2550ee0a..1bb84453 100644 --- a/frontend/app/e2e/_wire.ts +++ b/frontend/app/e2e/_wire.ts @@ -24,7 +24,7 @@ const BATCH_ACTIONS: Record = { draft: ["approve", "edit_membership", "delete"], approved: ["start", "repin", "delete"], in_annotation: ["complete", "repin", "delete"], - completed: ["promote"], + completed: ["promote", "create_correction"], }; const JOB_ACTIONS: Record = { diff --git a/frontend/app/src/routes.tsx b/frontend/app/src/routes.tsx index a08ce9a5..5fe2c812 100644 --- a/frontend/app/src/routes.tsx +++ b/frontend/app/src/routes.tsx @@ -243,6 +243,10 @@ function Gallery(): JSX.Element { // batch is finished, and it had no way to reach the one screen that shows // what finishing it produced — a tab of the project now, not a route. onOpenDataset={() => void navigate(PARENT.dataset(projectId))} + // A correction just cut, or this batch's own parent (audit G6). Same + // route the batch table's rows use — a batch is a batch, whichever screen + // named it. + onOpenBatch={(next) => void navigate(`/projects/${projectId}/batches/${next}`)} /> ); } diff --git a/frontend/ui-core/src/annotator/AnnotationPage.tsx b/frontend/ui-core/src/annotator/AnnotationPage.tsx index f1a2943c..7c72ef63 100644 --- a/frontend/ui-core/src/annotator/AnnotationPage.tsx +++ b/frontend/ui-core/src/annotator/AnnotationPage.tsx @@ -1032,6 +1032,35 @@ function Workspace({