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
4 changes: 2 additions & 2 deletions .agents/skills/frontend/information-architecture/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ column carries are in `docs/content/ui/navigation.md`, *Inside a project*.

Rules:

- **Annotate enters by job.** The project's `Annotate` control opens the chosen `in_annotation` batch's one job directly (`/jobs/:jobId`) when the batch has exactly one, and the batch gallery otherwise — the gallery's job panel is where a job is chosen, and it is the only door: the gallery header carries the `approved → in_annotation` transition and nothing that opens the editor. With several open batches the dropdown stays batch-level and the same rule applies to the pick. A panel's `Annotate` takes a `pending` job to `in_progress` before opening it; `Continue` and `View` only open. Panel controls are secondary — the header's transition is the page's one filled control.
- **A batch with jobs shows one job's frames at a time.** The gallery is an accordion of jobs with at most one panel open and every panel closable; the open panel's counts, timeline and frames are that job's, and the panel open on arrival is the first job with work left. A draft batch, having no jobs, keeps the flat grid. The rule is observability: a batch-wide grid beneath a per-job control puts two scopes for the same frames on one screen. Nothing about it changes an address — the gallery route is unchanged and the open panel is not in the URL.
- **Annotate enters by job.** The project's `Annotate` control opens the chosen `in_annotation` batch's one job directly (`/jobs/:jobId`) when the batch has exactly one, and the batch gallery otherwise — the gallery is where a job is chosen when there are several, and the job's own door is the only door: the gallery header carries the `approved → in_annotation` transition and nothing that opens the editor. With several open batches the dropdown stays batch-level and the same rule applies to the pick. A job's `Annotate` takes a `pending` job to `in_progress` before opening it; `Continue` and `View` only open. A job's controls are secondary — the header's transition is the page's one filled control while the batch has one, and the navigation column's Annotate once it is open.
- **A batch shows one job's frames at a time, and one job is the batch.** With exactly one job the gallery draws no accordion and no job-level bar: the job's controls — its door, Pre-label, and the assignee as an editable line — sit under the batch header, followed by that job's filter, order, strip and frames, and the batch bar is the page's one bar. From two jobs the gallery is an accordion with at most one panel open and every panel closable; the open panel's counts, timeline and frames are that job's, each header names its assignee, and the panel open on arrival is the first job with work left. A draft batch, having no jobs, keeps the flat grid. The rule is observability: a batch-wide grid beneath a per-job control puts two scopes for the same frames on one screen, and a one-row accordion is a choice with nothing to choose between. Nothing about it changes an address — the gallery route is unchanged and the open panel is not in the URL.
- **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 section. 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`.
- **The frames blocking a narrowing are a subsection of Schema, not a screen.** They are a *view of* the draft on the editor above them, the same relation version history has to the schema. A row links to **every** batch holding its frame rather than to one: an annotation carries an `asset_id` and no batch, so there is no single annotator address to prefer. The section is omitted entirely when the host wires no batch route, on the rule the Batches section already follows. It shows a window of the frames and states the total as text rather than a "see all": the destination that control would need is a project-wide asset view, and there is none — the count is a property of the proposal, not the length of a list somebody can open.
Expand Down
131 changes: 72 additions & 59 deletions docs/content/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -726,73 +726,86 @@ virtualizes **rows** (a row is what the browser lays out; virtualizing tiles ins
CSS grid means reimplementing the grid). The column count is measured with a
`ResizeObserver` rather than guessed from a second breakpoint list.

#### The jobs accordion

**Once a batch has jobs, its frames are shown per job, and at most one job is open at a
time.** A batch is partitioned, and a person works one part of it: a batch-wide grid beneath a
per-job control would put two scopes for the same frames on one screen, where the control
names a job and the grid, the counts and the timeline answer for everybody. One open panel is
one scope - its chips, its timeline and its tiles all count the same job - which is why
opening a panel closes the one that was open. **Every panel may be closed**: clicking the open
header collapses it, and an accordion with nothing open is the batch read as an index of its
jobs.

The panel open on arrival is **the first job with work left** - frames still unannotated
or only pre-labeled - and the first job otherwise, so landing on a batch lands on
something to do rather than on a job somebody has finished. Nothing is remembered across
reloads: the rule recomputes from counts that are read anyway, and a remembered panel is
stale the moment somebody else works the job.

**A collapsed header is the overview**, so a job is picked without opening it: ordinal,
frame count, state, `A of F annotated`, who is working it, and a thin progress bar. The
accordion is rendered only once jobs exist, the same `showsProgress` gate the progress bar
above it uses, so a draft needs no empty state of its own. The assignee is a plain editable
name, not an account: `JobService.assign` gates on nothing, so the control is always
live, and clearing it is the same operation with `null`. A failed read shows its
error instead of the accordion silently vanishing - an empty list and a failed one look
identical to the naive `undefined`-or-zero-items check, and only one of them means
there is nothing to assign.

**The open panel holds, in order:** the way into the annotator, `Pre-label` and the
assignee; the segment chips, counting *that job* from `GET /jobs/{id}/progress` rather
than the batch; the order select; that job's timeline; and only that job's frames, from
`GET /batches/{id}/assets?job=`. Frame numbers stay batch-wide, because a frame's number
is its place in the batch and renumbering per job would give one picture two names.

**Thumbnail size is one setting and is rendered outside the panels** - it is a property
of how a grid is read rather than of a job, and it is the same persisted preference
either way. It sits on the batch's own progress row in the header, right of the progress bar
and on the line of its `A of F annotated` readout: the last row above the accordion that is
about the batch rather than about one job. A draft, which has no progress row, keeps it in the
toolbar over its flat grid. The segment filter is the opposite: it belongs to the panel and **resets to
`All` when the open job changes**, because a filter carried across shows an empty panel
for a job with nothing in that state, which reads as a job with no frames.

**A draft batch has no accordion** - one flat grid, with the membership tools and
selection over it - because it has no jobs, and there is nothing to partition its frames
by.

Each header is a `<button>` carrying `aria-expanded` and naming the panel it controls,
and the panel is labelled by its header. **↑/↓ move between headers, Home/End reach the
first and last, Enter or Space opens the focused one - or closes it, when it is the open
one.** The arrow keys are what make the
accordion navigable at all: the open panel is a whole grid, so tabbing from one header to
the next crosses every tile in between.
#### One job, and several

**Once a batch has jobs, its frames are shown per job.** A batch is partitioned, and a person
works one part of it: a batch-wide grid beneath a per-job control would put two scopes for the
same frames on one screen, where the control names a job and the grid, the counts and the
timeline answer for everybody. The scope is always one job - its chips, its timeline and its
tiles all count the same job.

**The common batch has one job, and then there is no accordion.** A one-row accordion is a
header nobody can choose between, a bar repeating the batch's own, and a sentence naming a
job nobody else has. So with exactly one job the job's controls sit directly under the batch
header - the way into the annotator, `Pre-label`, and the assignee as an editable line
(*Assigned to Dana*, or *Unassigned*, the name itself the control) - followed by that job's
segment chips, order select, timeline and frames, inside one bordered panel. The batch's own
progress bar above is the page's one bar; the timeline is the breakdown.

**From two jobs the gallery is an accordion, and at most one job is open at a time.** One open
panel is one scope, which is why opening a panel closes the one that was open. **Every panel
may be closed**: clicking the open header collapses it, and an accordion with nothing open is
the batch read as an index of its jobs. The panel open on arrival is **the first job with work
left** - frames still unannotated or only pre-labeled - and the first job otherwise, so
landing on a batch lands on something to do rather than on a job somebody has finished.
Nothing is remembered across reloads: the rule recomputes from counts that are read anyway,
and a remembered panel is stale the moment somebody else works the job.

**A collapsed header is the overview**, so a job is picked without opening it: ordinal, frame
count, state, `A of F annotated`, a thin progress bar, and who is working it - *Unassigned*
when nobody is. The assignee is a plain editable name, not an account: `JobService.assign`
gates on nothing, so the control is always live, and clearing it is the same operation with
`null`. In the accordion the editor is a button in the open panel, because the header is an
overview and changing it is one of the things a panel is opened for. Jobs are rendered only
once they exist, the same `showsProgress` gate the progress bar above uses, so a draft needs
no empty state of its own. A failed read shows its error instead of the jobs silently
vanishing - an empty list and a failed one look identical to the naive `undefined`-or-zero-
items check, and only one of them means there is nothing to assign.

**The open panel holds, in order,** exactly what the one-job panel holds: the way into the
annotator, `Pre-label` and the assignee; the segment chips, counting *that job* from
`GET /jobs/{id}/progress` rather than the batch; the order select; that job's timeline; and
only that job's frames, from `GET /batches/{id}/assets?job=`. Frame numbers stay batch-wide,
because a frame's number is its place in the batch and renumbering per job would give one
picture two names.

**Thumbnail size is one setting and is rendered outside the jobs** - it is a property of how
a grid is read rather than of a job, and it is the same persisted preference either way. It
sits on the batch's own progress row in the header, right of the progress bar and on the line
of its `A of F annotated` readout: the last row above the frames that is about the batch
rather than about one job. A draft, which has no progress row, keeps it in the toolbar over
its flat grid. The segment filter, the order and the selection are the opposite: they belong
to the job, and **each job keeps its own** - a closed panel is unmounted, and reopening it
restores the filter, the order and the selection that were chosen in it, while the other job
starts from `All`. A filter carried across jobs would show an empty panel for a job with
nothing in that state, which reads as a job with no frames.

**A draft batch has no jobs** - one flat grid, with the membership tools and selection over
it - because there is nothing to partition its frames by.

Each accordion header is a `<button>` carrying `aria-expanded`; the open one names the panel
it controls, and a closed header carries no `aria-controls`, because its panel is unmounted
and an id pointing at nothing is a broken reference rather than a closed one. The panel is
labelled by its header. **↑/↓ move between headers, Home/End reach the first and last, Enter
or Space opens the focused one - or closes it, when it is the open one.** The arrow keys are
what make the accordion navigable at all: the open panel is a whole grid, so tabbing from one
header to the next crosses every tile in between.

#### The way in

**The gallery header carries `Start annotating` only for an `approved` batch, and there it is the
transition rather than a door.** That press is `POST /batches/{id}/start`, the batch's own next
step declared as `start` in its `allowed_actions` - the same mutation the Batches row sends, so
the table and the gallery are one spelling of it - and it navigates nowhere: landing back on the
gallery re-reads the batch as `in_annotation`, and the job panels are then what open it. There is
no icon on it; it is a state change, not a link.
gallery re-reads the batch as `in_annotation`, and the job's own door is then what opens it.
There is no icon on it; it is a state change, not a link.

**Once the batch is open, the way into the annotator is the job's own panel, because only a job
**Once the batch is open, the way into the annotator is the job's own door, because only a job
answers which frames.** A batch is partitioned, so a header control would have to pick a job
silently, and the panel is already where that choice is made.
Each panel carries one control, secondary rather than filled — the batch's own step in the header
is the page's one filled control — and its word is read from that job: **`Annotate`** where
silently; with one job the door sits under the header, and with several it is in the open panel,
where that choice is made. Each job carries one door, secondary rather than filled — the batch's
own step in the header is the page's one filled control while it has one, and the navigation
column's Annotate once the batch is open — and its word is read from that job: **`Annotate`** where
the job declares `start`, which takes the job (`POST /jobs/{id}/start`) and then opens it;
**`Continue`** while it is `in_progress`, the word for a job somebody is already inside; and
**`View`** otherwise, for a `completed` job or a `pending` one of a batch nobody has opened,
Expand Down Expand Up @@ -852,7 +865,7 @@ screen's whole subject has stopped existing.

#### Pre-labeling: the surface `text_detect` was declared for

**The control sits in a job's panel, gated on `pre_label` in that job's own `allowed_actions`** -
**The control sits with the job's controls, gated on `pre_label` in that job's own `allowed_actions`** -
never on the batch's state read locally, the same rule every control on this screen follows. A
batch's frames are partitioned into jobs and a run reaches one job's assets, so the job is what
declares the action and what the dialog is opened over; the dialog is titled for both, *Pre-label
Expand Down
37 changes: 19 additions & 18 deletions frontend/app/cycle/cycle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,23 +473,25 @@ test("the whole cycle, from opening the app to a downloaded export", async ({ pa
await expect(page.getByTestId("state-cycle-batch")).toHaveText("in progress");
});

await test.step("the job panel is the way in: start the one job, and it opens", async () => {
// Only the panel's door is driven here: the cycle server has no `text_detect`
// model, so the pre-label half of the panel is exercised against the server suite.
await test.step("the job's door is the way in: start the one job, and it opens", async () => {
// Only the door is driven here: the cycle server has no `text_detect` model,
// so the pre-label half of the job's controls is exercised against the
// server suite.
await openProject(page, PROJECT, "batches");
await page.getByTestId("open-batch-cycle-batch").click();
await expect(page.getByTestId("gallery")).toBeVisible();
// The header offers no door of its own any more; the job panel does.
// The header offers no door of its own; the job's controls do. Approved
// with no partition, the batch has one job, so there is no accordion to
// choose from — the job sits flat under the header, and the batch bar is
// the page's one bar.
await expect(page.getByTestId("start-annotating")).toHaveCount(0);
const panels = page.getByTestId("job-panels");
await expect(panels).toBeVisible();
// One job, with every frame still to do, so the accordion opens on it — and
// the collapsed row is the overview even so.
const header = panels.getByTestId(/^job-header-/);
await expect(header).toHaveAttribute("aria-expanded", "true");
await expect(header).toContainText("Job 1");
await expect(header).toContainText("pending");
const door = panels.getByTestId(/^job-panel-/).getByTestId(/^start-job-/);
await expect(page.getByTestId("job-panels")).toHaveCount(0);
await expect(page.getByTestId(/^job-header-/)).toHaveCount(0);
const workspace = page.getByTestId("job-workspace");
await expect(workspace).toBeVisible();
await expect(page.getByRole("progressbar")).toHaveCount(1);
await expect(workspace.getByTestId(/^assignee-/)).toContainText("Unassigned");
const door = workspace.getByTestId(/^start-job-/);
await expect(door).toHaveText("Annotate");
const jobId = (await door.getAttribute("data-testid"))!.replace("start-job-", "");
await door.click();
Expand All @@ -501,9 +503,8 @@ test("the whole cycle, from opening the app to a downloaded export", async ({ pa
await expect(page).toHaveURL(new RegExp(`/jobs/${jobId}(\\?asset=[0-9a-f-]+)?$`));
await page.getByTestId("back").click();
await expect(page.getByTestId("gallery")).toBeVisible();
// Started: the header says so, and the door now continues rather than starts.
await expect(panels.getByTestId(/^job-header-/)).toContainText("in progress");
await expect(panels.getByTestId(/^job-panel-/).getByTestId(/^start-job-/)).toHaveText(
// Started: the door now continues rather than starts.
await expect(page.getByTestId("job-workspace").getByTestId(/^start-job-/)).toHaveText(
"Continue",
);
});
Expand All @@ -526,9 +527,9 @@ test("the whole cycle, from opening the app to a downloaded export", async ({ pa
// visible rather than hover-gated, which a touch device would never reach.
// What matters is that the annotator is reachable **by clicking**, with no id
// read out of the API and no URL typed.
// Inside the open panel: the frames belong to a job now, and three is the
// Inside the job's workspace: the frames belong to a job, and three is the
// whole batch only because this batch was cut into one.
const tiles = page.getByTestId(/^job-panel-/).getByTestId(/^tile-/);
const tiles = page.getByTestId("job-workspace").getByTestId(/^tile-/);
await expect(tiles).toHaveCount(3);
const third = tiles.nth(2);
await expect(third).not.toHaveAttribute("data-pending", "true");
Expand Down
Loading
Loading