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
1 change: 1 addition & 0 deletions .agents/skills/frontend/information-architecture/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
36 changes: 36 additions & 0 deletions frontend/app/cycle/cycle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/e2e/_wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const BATCH_ACTIONS: Record<BatchState, readonly string[]> = {
draft: ["approve", "edit_membership", "delete"],
approved: ["start", "repin", "delete"],
in_annotation: ["complete", "repin", "delete"],
completed: ["promote"],
completed: ["promote", "create_correction"],
};

const JOB_ACTIONS: Record<JobState, readonly string[]> = {
Expand Down
4 changes: 4 additions & 0 deletions frontend/app/src/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)}
/>
);
}
Expand Down
29 changes: 29 additions & 0 deletions frontend/ui-core/src/annotator/AnnotationPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1032,6 +1032,35 @@ function Workspace({
<Eye className="size-3.5 shrink-0" aria-hidden="true" />
<span className="font-medium">Viewing only.</span>
{closedBecause ?? settledBecause}
{/*
The sentence names a correction batch, and now it can reach one — the
last link in the forward-only story (audit G6). #306 wrote that
sentence deliberately pointing at something that did not exist yet,
on the grounds that naming the route onward beats a friendlier lie.
This is what it was waiting for.

It goes to the **gallery** rather than opening a dialog here, and that
is a product call rather than a shortcut: 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. The annotator
says which way is forward and hands the person to the screen that
owns it, with the batch already in view.

Only for a batch the wire says can be corrected, so it is absent on a
frame that is merely settled inside an open batch — there the remedy
is on this toolbar and the banner already names the control.
*/}
{onOpenGallery !== undefined &&
declares({ allowed_actions: batchActions }, BATCH_ACTION.createCorrection) && (
<Button
variant="link"
className="h-auto p-0 text-meta"
data-testid="banner-create-correction"
onClick={onOpenGallery}
>
Correct this batch
</Button>
)}
</p>
)}

Expand Down
11 changes: 10 additions & 1 deletion frontend/ui-core/src/data/capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,16 @@ describe("the action names the client imports", () => {
// action arriving on the wire with no constant here is a rename nobody can
// perform, because the screens would have to spell it as a free string.
expect(Object.values(BATCH_ACTION).sort()).toEqual(
["approve", "complete", "delete", "edit_membership", "promote", "repin", "start"].sort(),
[
"approve",
"complete",
"create_correction",
"delete",
"edit_membership",
"promote",
"repin",
"start",
].sort(),
);
expect(Object.values(JOB_ACTION).sort()).toEqual(["complete", "start"].sort());
expect(Object.values(ASSET_ACTION).sort()).toEqual(
Expand Down
1 change: 1 addition & 0 deletions frontend/ui-core/src/data/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export const BATCH_ACTION = {
complete: "complete",
repin: "repin",
promote: "promote",
createCorrection: "create_correction",
editMembership: "edit_membership",
delete: "delete",
} as const satisfies Record<string, BatchAction>;
Expand Down
7 changes: 7 additions & 0 deletions frontend/ui-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,13 @@ export { BatchesScreen, type BatchesScreenProps } from "./screens/BatchesScreen.
export { GalleryScreen, type GalleryScreenProps } from "./screens/GalleryScreen.js";
export { ApproveDialog, BatchProgressBar } from "./screens/BatchLifecycle.js";
export { PromoteButton, promotionSummary, type PromoteButtonProps } from "./screens/PromoteButton.js";
export {
CorrectionButton,
CorrectionOf,
defaultCorrectionName,
type CorrectionButtonProps,
type CorrectionScope,
} from "./screens/CorrectionBatch.js";
export {
batchStateLabel,
segmentCounts,
Expand Down
63 changes: 50 additions & 13 deletions frontend/ui-core/src/screens/BatchesScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from ".
import { ApproveDialog, BatchProgressBar, CompleteBatchButton } from "./BatchLifecycle";
import { BATCH_STATE_VARIANT, batchStateLabel } from "./batchState";
import { SchemaForeshadow } from "./SchemaForeshadow";
import { CorrectionButton, CorrectionOf } from "./CorrectionBatch";
import { PromoteButton } from "./PromoteButton";
import { useBatchTransition, useBatches, type Batch } from "./queries";

Expand Down Expand Up @@ -112,14 +113,27 @@ export function BatchesScreen({
{page.items.map((batch) => (
<TableRow key={batch.id} data-testid={`batch-${batch.name}`}>
<TableCell>
<Button
variant="link"
className="h-auto p-0"
data-testid={`open-batch-${batch.name}`}
onClick={() => onOpenBatch(batch.id)}
>
{batch.name}
</Button>
<div className="flex flex-col items-start">
<Button
variant="link"
className="h-auto p-0"
data-testid={`open-batch-${batch.name}`}
onClick={() => onOpenBatch(batch.id)}
>
{batch.name}
</Button>
{/* Lineage in the listing, where a chain is actually
readable: the row says what it corrects, so the order
survives a sort by anything else. */}
<CorrectionOf
parentName={
page.items.find((one) => one.id === batch.parent_batch_id)?.name
}
{...(batch.parent_batch_id == null
? {}
: { onOpenParent: () => onOpenBatch(batch.parent_batch_id as string) })}
/>
</div>
</TableCell>
<TableCell>
{/* The label the gallery header already uses (#292) — the
Expand All @@ -143,7 +157,11 @@ export function BatchesScreen({
<TableCell className="text-right">
<Lifecycle
batch={{ ...batch, projectId }}
corrections={
page.items.filter((one) => one.parent_batch_id === batch.id).length
}
onApprove={() => setApproving(batch)}
onOpenBatch={onOpenBatch}
{...(onOpenDataset === undefined ? {} : { onOpenDataset })}
/>
</TableCell>
Expand Down Expand Up @@ -181,12 +199,17 @@ export function BatchesScreen({
*/
function Lifecycle({
batch,
corrections,
onApprove,
onOpenDataset,
onOpenBatch,
}: {
readonly batch: Batch & { readonly projectId?: string };
/** How many corrections of this batch already exist, for the suggested name. */
readonly corrections: number;
readonly onApprove: () => void;
readonly onOpenDataset?: () => void;
readonly onOpenBatch?: (batchId: string) => void;
}): JSX.Element | null {
const start = useBatchTransition(batch.id, "start");

Expand All @@ -199,11 +222,25 @@ function Lifecycle({
// "safe to press twice" and "you cannot tell whether it worked" were the
// same button until #307's successor. See `PromoteButton`.
return (
<PromoteButton
batch={batch}
projectId={batch.projectId ?? ""}
{...(onOpenDataset === undefined ? {} : { onOpenDataset })}
/>
<div className="flex flex-col items-end gap-1">
<PromoteButton
batch={batch}
projectId={batch.projectId ?? ""}
{...(onOpenDataset === undefined ? {} : { onOpenDataset })}
/>
{/*
Beside promote rather than in an overflow menu, and both are offered
because a completed batch has exactly two things left to do: put its
work in the trunk, and correct it. A menu would hide the second, which
is the one somebody is hunting for when a frame turns out wrong.
*/}
<CorrectionButton
batch={batch}
projectId={batch.projectId ?? ""}
existingCorrections={corrections}
{...(onOpenBatch === undefined ? {} : { onOpenBatch })}
/>
</div>
);
}
if (declares(batch, BATCH_ACTION.approve)) {
Expand Down
Loading
Loading