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
10 changes: 9 additions & 1 deletion .agents/skills/frontend/information-architecture/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,15 @@ Rules derived from the 2026-08 audit (§6):

- **Single route definition site**: `frontend/app/src/routes.tsx`. No routes defined elsewhere.
- **`ui-core` stays router-free.** Screens receive navigation as callback props (`routes.tsx:113-121` pattern). Never import a router in `ui-core`.
- **Back-links are declared** in the routes parent map (`PARENT` in `routes.tsx`) and must point to the contextual parent: the gallery's back is the Batches tab. **A tab has no back-link** — its way out is the tab bar, and one inside a panel would be a second, contradictory answer to "where am I". That is why `DatasetScreen` takes `onBack` as optional and the tab mount passes none. **A rail destination has no back-link either**, for the same reason and with the rail in the tab bar's place: `InferenceScreen` takes no `onBack`, and `PARENT.inference` exists as the address other screens send people *to* (#424's D6 panel is the first) rather than as a parent anything returns from.
- **Navigation renders the whole ancestor chain, not one level.** Every destination in it is declared in the routes parent map (`PARENT` in `routes.tsx`); the labels come from the screen, because a project's name is behind a query `ui-core` makes and `routes.tsx` does not fetch. The chains, and the current page is never in its own — it is the `<h1>`:

| route | chain |
| --- | --- |
| `/projects/:id` | `Projects` |
| `/projects/:id/ingest` | `Projects / <project>` |
| `/projects/:id/batches/:batchId` | `Projects / <project> / Batches` |

**A tab in the query string is a level**, which is what the batch route's third crumb is: its ancestor is `PARENT.batches` (`/projects/:id?tab=batches`), not the project's default section — landing on Schema after leaving a batch is landing somewhere you were not. Below `lg` the same chain collapses to `← <immediate parent>`; one component, two presentations. **A tab has no chain of its own** — its way out is the tab bar, and one inside a panel would be a second, contradictory answer to "where am I". That is why `DatasetScreen` takes no navigation prop at all: it had a vestigial optional `onBack` that no mount passed after the move to a tab, and it is gone. **A rail destination has none either**, for the same reason with the rail in the tab bar's place: `InferenceScreen` takes no `onBack`, and `PARENT.inference` exists as the address other screens send people *to* (#424's D6 panel is the first) rather than as an ancestor anything returns from. **The annotator is the one sub-view with no chain**: its 44px bar keeps the ghost `ArrowLeft` meaning *up* to the batch, because the bar's left zone already truncates to hold its navigation cluster centred.
- Tab state lives in `?tab=` with `replace: true`; unknown values fall back to `overview` silently.

## Process rule
Expand Down
62 changes: 44 additions & 18 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -348,29 +348,55 @@ and without knowing the URL scheme. Before **#199** five of the six sub-views of
nothing at all and the sixth offered history, so this section exists to keep the rule
from being rediscovered one screen at a time.

- **Every sub-view declares a parent, and the back affordance goes there
structurally.** `navigate(-1)` is not a parent: it means the gallery when you
clicked a tile, nothing at all on a fresh tab, and one asset at a time after
walking forward through a job. The destination has to be the same however the page
was reached — clicked through, pasted, reloaded, or walked forward from a sibling.
The parents live in one `PARENT` table in `app/src/routes.tsx`, because a parent is
a fact about the route table and `ui-core` deliberately has no router.
- **The affordance names its destination.** "Back" alone is a promise about history;
- **Every sub-view declares its ancestor chain, and it is rendered in full.**
`navigate(-1)` is not an ancestor: it means the gallery when you clicked a tile,
nothing at all on a fresh tab, and one asset at a time after walking forward
through a job. Every destination has to be the same however the page was reached —
clicked through, pasted, reloaded, or walked forward from a sibling. The
destinations live in one `PARENT` table in `app/src/routes.tsx`, because a
destination is a fact about the route table and `ui-core` deliberately has no
router.

A single-level control is not enough, and the gallery is why. Its control read
`← road-signs` — the *project's* name — while landing on the project's **Batches
tab**. Both halves were right: the tab is where somebody leaving a batch belongs,
and naming the project is the most one level can say. Only the chain says both,
and it reads `Projects / road-signs / Batches`.
- **Ancestors only. The current page is the `<h1>`, never a crumb**, which is also
why no crumb carries `aria-current`. A breadcrumb repeating the heading beneath it
spends a line telling somebody what they are already reading.
- **A tab in the query string is a level.** #171 put tabs in the URL because
somebody links to one and returns to it — that makes it somewhere you were, so it
is somewhere you can be sent back to. It is what the gallery's third crumb *is*.
- **The affordance names its destinations.** "Back" alone is a promise about history;
"Projects", or a project's own name, is a promise about structure — the one the
control can keep. A name that has not loaded yet falls back to the noun
(`parentLabel`) rather than to nothing, so the control does not change width under
a cursor that is already aiming at it.
- **Placement follows the pane.** On a padded page it is `patterns/BackLink.tsx`
directly above the page header: meta-size, muted, a 14px `ArrowLeft`, pulled left
by the gutter (`-ml-1`) so its text aligns with the `<h1>` beneath it. On the
full-bleed editor it is the first control in the 44px top bar, as a 36px ghost icon
button — the shape that bar is already built from.
- **A screen takes it as an optional callback, never a route.** The same rule every
a cursor that is already aiming at it. Each crumb truncates with its full label in
`title`, and the row never wraps to a second line at any width.
- **Placement follows the pane.** On a padded page it is `patterns/Breadcrumb.tsx`
directly above the page header: meta-size, muted, `/` separators and no arrow,
pulled left by the gutter (`-ml-1`) so the first crumb aligns with the `<h1>`
beneath it. On the full-bleed editor there is **no chain** — the way out is the
first control in the 44px top bar, a 36px ghost `ArrowLeft` meaning *up*, because
that bar's left zone is a `minmax(0, 1fr)` track already truncating to hold the
navigation cluster on the bar's geometric centre, and crumbs there would be paid
for out of the frame's identity readout.
- **Below `lg` the same chain collapses to `← <immediate parent>`**, which is the
shape the single-level control had. One component, one items array, one set of
destinations, two presentations — and the collapse is CSS on one DOM node per
crumb, never a second list, so nothing is read twice by a screen reader and the
two presentations have nowhere to drift apart.
- **A screen takes it as optional callbacks, never a route.** The same rule every
forward edge follows: `ui-core` may not import a router, so a host that has nowhere
to send anybody renders no control rather than a dead one.
to send anybody renders no control rather than a dead one. A screen omits a level
it has no callback for rather than rendering dead text, which is what makes an
empty chain mean *nothing to offer*. The host spells every URL; the screen supplies
every label, because a project's name is behind a query `ui-core` makes and
`routes.tsx` does not fetch.
- **The rail is for top-level destinations only.** Per-screen return navigation never
lives on it — that is what lets it name where it goes, and what keeps the rail the
five things `## Layout` gives it. A rail destination therefore has no back-link of
five things `## Layout` gives it. A rail destination therefore has no breadcrumb of
its own, for the reason a tab has none: the rail *is* its way out, and a second
answer to "where am I" inside the pane would contradict it.
- **The browser's Back button stays correct, and is never the only way out.** Nothing
Expand Down Expand Up @@ -545,7 +571,7 @@ what #207–#213 build against.

Four lines and two buttons, in this order:

1. The back affordance (`← Projects`), per **Navigation rules**.
1. The breadcrumb (`Projects`, this page's whole chain), per **Navigation rules**.
2. The project name, at the page-title role.
3. The description **if there is one**. If there is not, render *nothing* — the string "No
description." spends a line telling somebody about a field rather than about their
Expand Down
100 changes: 89 additions & 11 deletions frontend/app/e2e/navigation.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* The way back out of every sub-view.
* Where you are, and the way back out of every sub-view.
*
* ## Every scenario navigates by URL, and that is the whole method
*
Expand All @@ -10,12 +10,18 @@
*
* So each one does `page.goto` straight to the sub-view, signs in there, and
* presses the control. With an empty history there is nowhere for `navigate(-1)`
* to go, so only a **structural** parent can satisfy these.
* to go, so only a **structural** ancestor can satisfy these.
*
* The parents themselves are `routes.tsx`'s `PARENT` table, and `DESIGN.md`'s
* The destinations themselves are `routes.tsx`'s `PARENT` table, and `DESIGN.md`'s
* **Navigation rules** is the prose. `ui-core`'s `navigation.test.tsx` holds the
* other half — that each screen draws the control and calls back — which a
* component test can see and a URL cannot.
* half a component test can see — that each screen draws its chain and calls back.
*
* ## Two claims live here and nowhere else
*
* That a crumb reaches the right **URL**, which `ui-core` cannot know because it
* imports no router; and **which crumbs are visible below `lg`**, because the
* collapse is a media query and both presentations are in the DOM either way. A
* jsdom assertion about the second would pass whatever the CSS said.
*/

import { expect, test, type Page } from "@playwright/test";
Expand Down Expand Up @@ -174,7 +180,11 @@ async function openCold(page: Page, url: string): Promise<void> {
await page.getByTestId("token-submit").click();
}

/** Every sub-view and its parent, as data. One testid on every padded screen. */
/**
* Every sub-view and its **immediate** parent, as data — the crumb the collapsed
* presentation keeps, and the one every scenario below presses. One testid on
* every padded screen.
*/
const SUBVIEWS = [
{
name: "the project",
Expand All @@ -197,8 +207,8 @@ const SUBVIEWS = [
parent: new RegExp(`/projects/${PROJECT}\\?tab=batches$`),
},
// **The dataset is not here any more, and its absence is the change.** It was a
// route with a back-link; it is a project *tab* now, so its way out is the tab
// bar and the back-link on that page belongs to the project. Its old URL still
// route with its own way out; it is a project *tab* now, so its way out is the
// tab bar and the crumbs above it belong to the project. Its old URL still
// works — see the redirect scenario below.
] as const;

Expand All @@ -207,7 +217,7 @@ for (const view of SUBVIEWS) {
await openCold(page, view.url);
await expect(page.getByTestId(view.ready)).toBeVisible();

await page.getByTestId("back-link").click();
await page.getByTestId("breadcrumb-parent").click();
await expect(page).toHaveURL(view.parent);
});
}
Expand Down Expand Up @@ -269,14 +279,82 @@ test("the way out names the project it goes to", async ({ page }) => {
// "Back" alone is a promise about history. Naming the destination is a promise
// about structure, which is the one the control can keep.
await openCold(page, `/projects/${PROJECT}/ingest`);
await expect(page.getByTestId("back-link")).toContainText("road-signs");
await expect(page.getByTestId("breadcrumb-parent")).toContainText("road-signs");
});

test("the project's own way out names the list, not a project", async ({ page }) => {
// One level up from a project is `Projects`, and it is the one sub-view whose
// parent has a fixed name rather than one that has to load.
await openCold(page, `/projects/${PROJECT}`);
await expect(page.getByTestId("back-link")).toContainText("Projects");
await expect(page.getByTestId("breadcrumb-parent")).toContainText("Projects");
});

/**
* The gallery's whole chain, and the disagreement it settles.
*
* The single-level control here read `← road-signs` — the *project's* name — while
* landing on the project's **Batches tab**. Both halves were right on their own;
* only the chain says both, and this is the scenario that holds it to that.
*/
test("the batch gallery names its whole chain, and every crumb goes where it says", async ({
page,
}) => {
await openCold(page, `/projects/${PROJECT}/batches/${BATCH}`);
await expect(page.getByTestId("gallery")).toBeVisible();

const crumbs = page.getByTestId("breadcrumb").getByRole("button");
await expect(crumbs).toHaveText(["Projects", "road-signs", "Batches"]);

// The middle crumb: the project itself, which is not where the immediate parent
// goes and never had a control of its own.
await crumbs.nth(1).click();
await expect(page).toHaveURL(new RegExp(`/projects/${PROJECT}$`));

await page.goBack();
await expect(page.getByTestId("gallery")).toBeVisible();

// The root, two levels up, which the single-level control could not reach at all.
await page.getByTestId("breadcrumb").getByRole("button").first().click();
await expect(page).toHaveURL(/\/projects$/);
});

test("the Batches crumb lands with the Batches tab actually selected", async ({ page }) => {
// A `?tab=` in the URL and a tab bar showing something else is the failure this
// asserts against — the redirect-that-moved-only-the-URL shape. A tab in the
// query string is a place, which is what makes it a legitimate crumb level.
await openCold(page, `/projects/${PROJECT}/batches/${BATCH}`);
await page.getByTestId("breadcrumb-parent").click();

await expect(page).toHaveURL(new RegExp(`/projects/${PROJECT}\\?tab=batches$`));
await expect(page.getByTestId("tab-batches")).toHaveAttribute("aria-selected", "true");
});

test("below lg the chain collapses to the immediate parent, on one line", async ({ page }) => {
// The claim jsdom structurally cannot make: every crumb is in the DOM at every
// width, and which ones are *shown* is a media query. A component test asserting
// this would pass whatever the CSS said.
await openCold(page, `/projects/${PROJECT}/batches/${BATCH}`);
await expect(page.getByTestId("gallery")).toBeVisible();

const row = page.getByTestId("breadcrumb");
const tall = await row.boundingBox();

await page.setViewportSize({ width: 900, height: 800 });

// `getByRole` reads the **accessibility tree**, which a `display: none` crumb is
// not in — so this is the whole claim in one assertion: below `lg` a screen
// reader is offered the immediate parent and nothing above it.
await expect(row.getByRole("button")).toHaveText(["Batches"]);

// And `locator` reads the **DOM**, which every crumb is still in. The two
// together are what says the collapse is one list presented twice rather than
// two lists — a duplicated chain would count six here and read twice aloud.
await expect(row.locator("button")).toHaveCount(3);

// Still one line. The row never wraps at any width, which is the other half of
// "collapses" — a chain that merely reflowed would be taller here.
const short = await row.boundingBox();
expect(short?.height).toBe(tall?.height);
});


Expand Down
4 changes: 2 additions & 2 deletions frontend/app/e2e/shell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,9 +198,9 @@ test("the Inference entry goes to the section, and is current once you are on it
await expect(page).toHaveURL(/\/inference$/);
await expect(page.getByTestId("rail-inference")).toHaveAttribute("aria-current", "page");
await expect(page.getByTestId("rail-projects")).not.toHaveAttribute("aria-current", "page");
// A rail destination has no back-link: the rail is its way out, and a second
// A rail destination has no breadcrumb: the rail is its way out, and a second
// answer to "where am I" inside the pane would contradict it.
await expect(page.getByTestId("back-link")).toHaveCount(0);
await expect(page.getByTestId("breadcrumb")).toHaveCount(0);
});

test("navigation is real links, and the active one is the one you are on", async ({ page }) => {
Expand Down
27 changes: 20 additions & 7 deletions frontend/app/src/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,21 +173,29 @@ function Home(): JSX.Element {
}

/**
* Every sub-view's parent, in one place.
* Every ancestor of every sub-view, in one place.
*
* A back affordance navigates to its **declared parent**, never `navigate(-1)`:
* A breadcrumb crumb navigates to its **declared ancestor**, never `navigate(-1)`:
* the destination has to be the same whether the page was reached by clicking
* through, by pasting a URL, by reloading, or by walking forward from a sibling.
* History cannot promise that, and on a fresh tab it leaves the application
* entirely.
*
* The parents live here rather than in the screens because a parent is a fact
* about the *route table*, and `ui-core` deliberately does not have one — the note
* on `Projects` above is the same rule from the other side. `DESIGN.md`'s
* The ancestors live here rather than in the screens because a destination is a
* fact about the *route table*, and `ui-core` deliberately does not have one — the
* note on `Projects` above is the same rule from the other side. `DESIGN.md`'s
* **Navigation rules** is the prose half of this table.
*
* The gallery's parent carries `?tab=batches`, because landing on the project's
* default Schema tab after leaving a batch is landing somewhere you were not.
* **This table is walked by hand rather than transitively, and the reason is
* labels.** A crumb needs a name as well as a URL, and a project's name is behind
* a query `ui-core` makes — this file holds ids and does not fetch, by the rule at
* the top of it. So the split is: the host spells every URL, the screen supplies
* every label and composes the chain from the callbacks it was handed. What could
* silently drift is a URL, and a URL still has exactly one spelling.
*
* The gallery's chain ends at `?tab=batches`, because landing on the project's
* default Schema tab after leaving a batch is landing somewhere you were not — a
* tab in the query string (#171) is a place, so it is a level.
*/
const PARENT = {
//: A rail destination, like `inference` below, so nothing declares it as a
Expand Down Expand Up @@ -276,6 +284,10 @@ function Gallery(): JSX.Element {
projectId={projectId}
batchId={batchId}
onBack={() => void navigate(PARENT.batches(projectId))}
// The two levels above the Batches tab. The gallery is the product's
// deepest padded page, so it is the one whose chain is three long.
onOpenProject={() => void navigate(PARENT.project(projectId))}
onOpenProjects={() => void navigate(PARENT.projects)}
onOpenAsset={(asset) => {
if (asset.job_id === null || asset.job_id === undefined) return;
void navigate(`/jobs/${asset.job_id}?asset=${asset.id}`);
Expand Down Expand Up @@ -384,6 +396,7 @@ function Ingest(): JSX.Element {
<IngestScreen
projectId={projectId}
onBack={() => void navigate(PARENT.project(projectId))}
onOpenProjects={() => void navigate(PARENT.projects)}
onOpenBatch={(batchId) => void navigate(`/projects/${projectId}/batches/${batchId}`)}
// The foreshadowing banner's link: the schema section is a `?tab=`
// on the project page, and spelling that URL is this file's job.
Expand Down
Loading
Loading