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
14 changes: 14 additions & 0 deletions .agents/skills/frontend/information-architecture/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ description: The canonical sitemap and navigation rules for the VisionSet app. C
Navigation maps 1:1 to domain objects. This is the target structure; if implementation differs, implementation is what's wrong.

```
/ Home — the workspace dashboard (rail destination)
└─ deep-links out to: /jobs/:jobId?asset= (resume), /projects/:id/batches/:id
(review rows and the resume fallback), /projects/:id, /projects
/projects Projects list
/inference Inference — model connections (workspace-scoped)
/projects/:id Project — tabs, in this order:
Expand Down Expand Up @@ -42,6 +45,17 @@ Rules derived from the 2026-08 audit (§6):
- **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.
`hasReleases` is derived in `useProjectReadiness` from the two-hop read (project → dataset → releases) rather than added to the project-stats wire model: the Overview dashboard already makes both requests for its own cards, so a third spelling of the fact on the server would be the drift this audit was about.
- **Inference is a rail destination, not a project tab.** Model connections carry no `project_id`: one workspace is one SQLite file, every project uses the same connections, and navigation maps 1:1 to domain objects — so a project tab would state a scope the object does not have. The decision is recorded on #421 (2026-08-08) and **supersedes #58's rail rule** ("logo, collapse toggle, Home, Projects, account avatar — nothing else"); the rail now carries Home, Projects, Inference and the account control, and `DESIGN.md` carries the same membership in both places it states it. What earns a rail entry is a workspace-level object with nowhere else to live, never mere frequency of use.
- **Home is the workspace's dashboard, and Overview is the project's.** They do not
overlap, because they answer different questions: Home asks *what is waiting on me,
anywhere*, which no single project can answer, and Overview asks *what does this
project hold*. So Home carries nothing project-scoped — no class distribution, no
samples, no schema state — and every row on it is a deep link into the screen that
owns the thing. It earns its rail entry on the same test Inference passes: a
workspace-level object with nowhere else to live.
**Its resume target is derived, never persisted**, and ranked by progress rather
than recency because no timestamp exists on a batch, an annotation or an asset's
progress. The one visible consequence is the CTA label: with no unlabeled frame
left it reads *Open batch* and goes to the gallery instead of the editor.
- **Overview is a dashboard**: pipeline state of batches, trunk size, latest release, active schema version — each card links to its tab (`StatCard`'s `onGo`, which renders the card as a **button** so it is keyboard-reachable and announced as an action). Overview never duplicates a tab's full function: every number on it is a *pointer* at the section that owns it, and a section with nothing yet says so in words rather than showing a zero.

## Structural invariants
Expand Down
78 changes: 78 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,84 @@ Focus is **not** styled here at all: `styles.css`'s base layer gives every
so it never depended on the segmented chip's fill. The trigger adds
`focus-visible:bg-muted` only so the ring encloses a fill rather than the page.

## Home

The workspace's front page, and the rail's first entry. It was a redirect to the project
list until there were numbers worth showing; what makes it a page rather than a second
list is that it answers a different question. A list answers *what exists*. Home answers
**what is waiting on me, and where do I carry on** — which is a question no single project
can answer, because the state that matters is spread across all of them.

It carries nothing project-scoped. No class distribution, no samples, no schema state:
those belong to the project's own Overview, and a second copy here is the drift this
document exists to prevent. Every row is a *pointer*, and pressing it goes to the screen
that owns the thing.

**Two states, and the first one is gone for good.** A workspace with no projects renders
one invitation and nothing else — a headline, a line of body, the filled **Create
project**, and three quiet cards naming the cycle (ingest, annotate, release) with no
controls on them. The cards are there so somebody can see what the tool does before
committing to it; a button on each would be three more things competing with the one that
matters. Once a project exists this state never returns, and the dashboard renders with
real zeros wherever the numbers are genuinely zero.

**Layout** is the two-column detail contract, `1fr / 320px`, stacking below `lg`. The main
column is the resume card, then what needs attention, then a short list of recent
projects. The 320px aside is four stat cards in a 2×2 grid — projects, images,
annotations, releases — and beneath them the activity feed.

**A section with nothing in it is not rendered.** Not as a placeholder, not as a zero, not
as "nothing here yet" — absent. A dashboard whose every section is an apology has taught
the reader nothing and taken a screen to do it. The stat cards are the deliberate
exception: a count of zero is a *measurement*, and four cards that came and went would
make the aside jump on every visit.

### Continue where you left off

The card names a project, a batch, `n / m annotated` in tabular figures, and a picture of
the frame it would open. It holds the view's one filled button.

**It is ranked by progress, not by recency, and that is forced rather than chosen.** There
is no timestamp anywhere on a batch, an annotation, or an asset's progress, so *the batch
I touched last* has no source in the storage format and deriving one would mean a
migration. What the rows can answer is which batch is furthest through and not yet
finished, which is what the card offers. It degrades correctly — one open batch is offered
whatever its progress — and the accepted limit is that two batches part-way through
resolve to the further-along one rather than the more recent.

**The label is load-bearing.** With a frame left to label the control reads **Continue
annotating** and opens the editor at that frame. With none left it reads **Open batch** and
goes to the gallery, because there is no frame to open and a button claiming otherwise
would land somewhere empty. Same card, same slot, different promise.

### Needs your attention, and activity

Attention rows are one line each: a batch holding frames awaiting review, a background job
that failed, a background job still running. A review row links to its batch. **A job row
links nowhere and says so by not being a link** — a background job names an ingest run or a
release in its payload, never a project, and no screen shows one. That is the rule for a
section whose consuming surface does not exist yet, and inventing a destination would be
principle 9's dead button with a friendlier label.

Activity is a projection over timestamps that already exist, never an event log, capped at
about eight and newest first. Two of its four kinds say less than they might, and the copy
must not overstate them: an ingest row reports *the last data that arrived in this
project*, not one run finishing, because an ingest records no time at all; and a schema row
reports a version being **created**, because which version is active is derived — it is the
highest — so there is no activation to date.

### The one filled button, in all three states

Principle 8 is a count, so Home answers it in every state and there is never a fourth:
first run offers **Create project**, a workspace with somewhere to carry on offers
**Continue annotating**, and one with nothing open offers **New project** — because when
every batch is finished, starting the next piece of work genuinely is what comes next.
When the resume card renders, the header's own New project steps back to `secondary`.

The first-run CTA opens the **same dialog** the project list's button opens, rather than
navigating to the screen that carries it. A filled button labelled *Create project* that
only moved somewhere else would promise an action it does not perform.

## Project surfaces

The project view is the face of a project, and principle 6 is the rule it kept failing: it
Expand Down
15 changes: 15 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,21 @@ have to fake.
written out field by field, so a field reaches a client because somebody published it and never
because somebody added it to an entity.

**One endpoint is a projection rather than a resource, and it is the exception that states the
rule.** `GET /home` composes the workspace's front page - totals across every project, the batch
to carry on with, what is waiting, and a feed derived from timestamps that already exist. It takes
no path parameters, has no verb but `GET`, and carries **no `allowed_actions`**: there is nothing
here to act on, only rows pointing at resources that declare their own capabilities. It exists
because the page it answers asks four questions that each span every project, and answering them
as separate resources would be a request per project per question.

Two of its fields are honest about limits the storage format imposes, and a client should render
them as they are described rather than as it might wish them. `resume` is ranked by **progress,
not recency** - nothing records when a batch was last worked on - and a null `next_asset_id` means
the batch has no unlabeled frame left, so the caller opens its gallery rather than the editor. An
`ingest` activity entry is the newest asset arrival in a project rather than one run finishing,
because an ingest job records no times.

## Where the UI lives

The compiled application is mounted at **`/app`** and `/` redirects to it. `visionset server` starts
Expand Down
34 changes: 33 additions & 1 deletion docs/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ handler is not an operation.

| route | what | behind the token gate |
| --- | --- | --- |
| `/` | Home | yes |
| `/` | Home - the workspace dashboard | yes |
| `/projects`, `/projects/:id` (`?tab=overview\|schema\|batches\|dataset`), `/projects/:id/ingest`, `/projects/:id/batches/:id`, `/projects/:id/dataset` | the product | yes |
| `/inference` | model connections, workspace-scoped | yes |
| `/jobs/:jobId` (`?asset=<id>`) | the annotation page | yes |
Expand Down Expand Up @@ -119,6 +119,38 @@ Query keys are hierarchical - `["projects"]` → `["projects", id]` →
invalidating `["projects", id]` after a rename refreshes the project, its schema and
its version list, and the mutation never has to enumerate what it affected.

### Home, and the one screen the server composes

`/` is the workspace dashboard. It was a redirect to the project list until there
were numbers worth showing, and what makes it a page rather than a second list is
that it answers a different question: a list says what exists, this says what is
waiting and where to carry on - which spans every project, so no project-scoped
screen can answer it.

**One query, `useHome`, over one endpoint.** `GET /home` returns the whole page in
one response: totals, the batch to resume, what needs attention, a short list of
recent projects and a derived activity feed. Composed on the server because the
alternative is a request per project per question with the browser doing the
joining, which is slower and renders in pieces as they land.

The summary is a **read-only projection**. It declares no `allowed_actions` and no
mutation takes it as input; every row deep-links to a resource whose own wire shape
says what may be done to it. A second copy of those declarations here would be the
hand-mirrored table the capabilities contract forbids, one layer up.

Two things about it are consequences of the storage format rather than choices, and
both are stated on the endpoint as well as here. The resume target is ranked by
**progress, not recency** - no timestamp exists on a batch, an annotation or an
asset's progress row, so "most recently worked on" has no source - and when its
`next_asset_id` is null the batch has no unlabeled frame left, which is the client's
signal to open the gallery and to say *Open batch* rather than *Continue annotating*.
The activity feed's `ingest` entry is the newest `Asset.ingested_at` in a project
rather than a run finishing, because an ingest job records no times at all.

A workspace with no projects reads zeros, nulls and empty lists. That is the
first-run state, and `totals.projects` is how the screen recognises it - not a flag,
which would be a second spelling of a fact the response already carries.

### The project view, and the one screen whose section is in the URL

A project has three sections - its schema, its batches, its version history - and
Expand Down
8 changes: 7 additions & 1 deletion frontend/app/cycle/cycle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,13 @@ test("the whole cycle, from opening the app to a downloaded export", async ({ pa
});

await test.step("create a project", async () => {
await page.getByTestId("new-project").click();
// From Home's first-run invitation rather than from the project list, and
// that is the honest route here: this workspace was created seconds ago, so
// `/` is exactly the state that invitation exists for. It opens the same
// dialog the list's own button does — one component, two callers — which is
// why every field below is unchanged.
await expect(page.getByTestId("home-first-run")).toBeVisible();
await page.getByTestId("home-create-project").click();
await page.getByTestId("project-name").fill(PROJECT);
await page.getByTestId("project-description").fill("Driven by #59");
await page.getByTestId("create-submit").click();
Expand Down
16 changes: 16 additions & 0 deletions frontend/app/e2e/inference.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,22 @@ function connection(
* it the way it reads a server.
*/
async function serveApi(page: Page, next: () => unknown): Promise<void> {
// These scenarios reach Inference by signing in at `/`, which is a real page
// now rather than a redirect to the project list — so it makes this request on
// the way past. Empty totals: nothing here is about the dashboard, and an
// unrouted request would leave the page waiting on a network that is not there.
await page.route("**/api/home", (route) =>
route.fulfill({
status: 200,
json: {
totals: { projects: 0, assets: 0, annotations: 0, releases: 0 },
resume: null,
attention: [],
projects: [],
activity: [],
},
}),
);
await page.route("**/api/session", (route) => route.fulfill({ json: { issued: false } }));
await page.route("**/api/inference/connections*", (route) =>
route.fulfill({ status: 200, json: { items: [next()], total: 1 } }),
Expand Down
44 changes: 34 additions & 10 deletions frontend/app/e2e/shell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,22 @@ async function serveApi(page: Page, { session = false } = {}): Promise<void> {
// because the gate and the sign-out button are what it is about and both are
// only reachable when the server declines to sign the browser in by itself.
await page.route("**/api/session", (route) => route.fulfill({ json: { issued: session } }));
// Home reads the workspace summary on every page load now, because the rail's
// Home entry lands on a real page rather than redirecting. Empty totals: this
// suite is about the rail and the router, and what the dashboard does with
// rows belongs to `ui-core`'s `home.test.tsx`.
await page.route("**/api/home", (route) =>
route.fulfill({
status: 200,
json: {
totals: { projects: 0, assets: 0, annotations: 0, releases: 0 },
resume: null,
attention: [],
projects: [],
activity: [],
},
}),
);
// The Inference section's one read. Empty, because this suite is about the
// rail and the router: what the screen does with rows is `ui-core`'s
// `inference.test.tsx`, and an unrouted request here would leave the page
Expand Down Expand Up @@ -190,15 +206,21 @@ test("the Inference entry goes to the section, and is current once you are on it
test("navigation is real links, and the active one is the one you are on", async ({ page }) => {
await signIn(page);

// Signing in at `/` lands on `/projects`, because Home redirects there — so
// **Projects** is the current page and Home is not. Asserting Home first would
// be racing the redirect, which is exactly what it did until this comment
// existed: the scenario passed alone and failed under parallel load.
// Signing in at `/` stays at `/`, because Home is a page now rather than a
// redirect to the project list — so **Home** is the current page and Projects
// is not. This assertion used to read the other way round, and had a comment
// warning that asserting Home first raced the redirect. There is no redirect
// to race any more, which is the whole of what changed.
await expect(page).toHaveURL(/\/$/);
await expect(page.getByTestId("rail-home")).toHaveAttribute("aria-current", "page");
await expect(page.getByTestId("rail-projects")).not.toHaveAttribute("aria-current", "page");

// `end` on the Home link is what keeps *the other direction* true: without it
// `NavLink` treats `/` as a prefix of every route and Home stays active
// everywhere. So the claim is checked from a page that is not Home.
await page.getByTestId("rail-projects").click();
await expect(page).toHaveURL(/\/projects$/);
await expect(page.getByTestId("rail-projects")).toHaveAttribute("aria-current", "page");

// `end` on the Home link is what keeps this true: without it `NavLink` treats
// `/` as a prefix of every route and Home is active on every page.
await expect(page.getByTestId("rail-home")).not.toHaveAttribute("aria-current", "page");

// A real `<a href>`, so middle-click and "open in new tab" work — which on a
Expand Down Expand Up @@ -284,9 +306,11 @@ test("a client route nobody defined answers inside the shell, not with a blank p
// a broken application.
await expect(page.getByTestId("app-rail")).toBeVisible();
await page.getByRole("link", { name: "Back to Home" }).click();
// Home redirects to the project list: there is nothing else a workspace's front
// page could honestly be until a dashboard has numbers to show.
await expect(page).toHaveURL(/\/projects$/);
// The link keeps its own promise now. It used to land on the project list,
// because Home was a redirect there; Home is a page, so "Back to Home" goes to
// Home and the dashboard is what answers.
await expect(page).toHaveURL(/\/$/);
await expect(page.getByTestId("home-first-run")).toBeVisible();
});

/**
Expand Down
Loading
Loading