Skip to content

feat(ui): navigation renders the whole ancestor chain, not one level - #574

Merged
JArmandoAnaya merged 1 commit into
mainfrom
feat/breadcrumb-navigation
Aug 13, 2026
Merged

feat(ui): navigation renders the whole ancestor chain, not one level#574
JArmandoAnaya merged 1 commit into
mainfrom
feat/breadcrumb-navigation

Conversation

@JArmandoAnaya

Copy link
Copy Markdown
Contributor

Closes #570.

Navigation renders the page's whole ancestor chain instead of the step above it.

What shipped

frontend/ui-core/src/patterns/Breadcrumb.tsx replaces patterns/BackLink.tsx, in the same
slot above the page header.

screen before after
project ← Projects Projects
ingest ← road-signs Projects / road-signs
batch gallery ← road-signs, landing on the Batches tab Projects / road-signs / Batches

The gallery row is the defect. The label named the project while the destination was the
project's Batches tab, so the control said one place and went to another. Both halves were
right on their own — the tab is where somebody leaving a batch belongs, and naming the project
is the most one level can say — and only the chain says both.

  • Ancestors only: the current page is the <h1>, so no crumb carries aria-current.
  • Below lg the same items array collapses to ← <immediate parent>, the old look. It is CSS
    on one DOM node per crumb, never a second list, so nothing is announced twice.
  • Each crumb truncates with its full label in title; the row never wraps at any width.
  • <nav aria-label="Breadcrumb">, real buttons, keyboard-reachable. The focus ring is the base
    layer's :focus-visible — a per-component ring would be a second answer to one question.
  • A screen omits a level it has no callback for rather than deadening it, so an empty chain
    still means nothing to offer and a host that wires nothing renders no control. Projects
    and Inference are unchanged: rail destinations get no breadcrumb.

DatasetScreen.onBack is deleted rather than migrated. The trunk is a project tab and no
mount has passed it since that move, so the prop — and the now-unused useProject query behind
its label — are gone.

Two premises in the dispatch that HEAD contradicted

  1. The batch page does not lose its tab. PARENT.batches already carried ?tab=batches,
    GalleryScreen already used it, and e2e/navigation.spec.ts already asserted it. The
    residual defect is the label/destination disagreement above. The step-8 audit for the same
    class of bug on other sub-views found nothing else: ingest's parent is the project page,
    which has no tab-specific home, and the project's is the list.
  2. The annotator top bar carries no project text and no date. Its left zone is the back
    arrow, PinBadge, an 8-char content hash and ● annotated · Saved, in a minmax(0,1fr)
    grid track that already truncates to hold the navigation cluster on the bar's geometric
    centre. Decision (Armando, 2026-08-12): leave it alone — the arrow already means up, and
    crumbs would be paid for out of the frame's identity readout. AnnotationPage.tsx is
    untouched.

Three deviations from the dispatch, deliberate

  • BreadcrumbItem.onNavigate is required, not optional. Nothing in this repo produces an
    ancestor it cannot navigate to; a screen omits such a level instead, which is what keeps the
    empty-list rule true. A dead-text crumb has no producer, so it would ship untested. Widening
    it later is one line.
  • No transitive walk of PARENT. A crumb needs a label as well as a URL, and a project's
    name is behind a query ui-core makes while routes.tsx holds ids and does not fetch (its
    own module rule). So the host spells every URL and the screen supplies every label. What can
    silently drift is a URL, and a URL still has exactly one spelling.
  • No separate app-level chain-derivation test. With the chain composed per screen there is
    no pure function to call; the destinations are what the browser suite asserts, which is also
    the only place a route change is a real thing.

Test plan

navigation.test.tsx rewritten (15 cases): the full chain in order with each crumb calling its
own destination; the collapsed slot naming the immediate parent, not the root; an empty list
rendering nothing; the truncation title; and per screen — chain drawn, parent callable, nothing
at all when the host wires nothing.

e2e/navigation.spec.ts gained three scenarios: every crumb of the gallery's chain reaching the
URL it names; the Batches crumb landing with tab-batches actually aria-selected (the
redirect-that-moved-only-the-URL failure); and the lg collapse at 900px wide. That last one
asserts through both locator kinds — getByRole reads the accessibility tree, which a
display: none crumb is not in, and locator reads the DOM, which every crumb is still in. The
pair is what says the collapse is one list presented twice rather than two lists.

Mutation verification

Committed first, each step unconditional, each revert by its recorded patch with a clean-tree
assertion between cases.

mutation result
collapsed slot takes index === 0 instead of index === last 3 red: collapses to the IMMEDIATE parent, not to the root, and the gallery's and ingest's puts its immediate parent in the collapsed slot. The project row stayed green, honestly — with one crumb, index 0 is last.
PARENT.batches drops ?tab=batches 2 red: the batch gallery returns to its parent from a cold open and the Batches crumb lands with the Batches tab actually selected.

Gate

bash scripts/check.sh run in stages against the harness's ~10-minute ceiling. Every stage's
exit code, verbatim:

FRONTEND_EXIT=0     84s   (build, 429+ vitest, lint, typecheck:e2e)
GENERATED_EXIT=0    20s
DOCS_EXIT=0         26s
BROWSER_EXIT=0     288s   259 passed (annotator + app e2e) · 1 passed (real-server cycle)
PYTHON_EXIT=0      219s   3318 passed, 13 skipped

docs first exited 2 on docs-site/node_modules is missing — a fresh worktree, fixed by
pnpm --dir docs-site install, not a code failure. #555 did not fire, so no baseline
reproduction was needed.

Documentation

  • DESIGN.mdNavigation rules rewritten: the chain replaces the single parent, plus the
    ancestors-only rule and why, tabs-are-levels, the lg collapse, the host/screen split, and
    the annotator's exemption. navigate(-1) stays banned. The project-header list now names the
    breadcrumb.
  • .agents/skills/frontend/information-architecture/SKILL.md: the back-link invariant becomes
    the chain model, with a per-route table recording the batch route's tab-qualified ancestor.
    Sitemap change: none — no screen moved, no tab or nav entry was added or removed; only the
    navigation contract between existing routes changed.
  • docs/ui.md describes tabs, ?asset= and the rail but never the back affordance, so it is
    untouched and uncontradicted.

Found, not fixed

Nothing.

Flags for Armando

  • The narrow presentation is untested below 900px in the cycle suite. The e2e scenario picks
    900 as a representative width below lg; the app has no declared minimum viewport and the
    rail's own behaviour at phone widths is out of this task's scope.
  • parentLabel's noun fallback now appears mid-chain, so a gallery whose project query is
    still in flight reads Projects / Project / Batches for one paint. That is the existing
    rule (never change width under a cursor) applied at a new position; if the doubled word reads
    badly in practice, the fix is a per-level noun rather than a spinner.

The single-level back link under-reported a product three levels deep, and on
the batch gallery its label and its destination disagreed outright: it 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.

`patterns/Breadcrumb.tsx` replaces `patterns/BackLink.tsx`. Ancestors only, so
the current page stays the `<h1>` and no crumb carries `aria-current`. Below
`lg` the same items array collapses to `← <immediate parent>` through CSS on one
DOM node per crumb — never a second list, so nothing is read twice aloud.

The host spells every URL and the screen supplies every label: `PARENT` holds
ids and does not fetch, while a project's name is behind a query `ui-core`
makes. A screen omits a level it has no callback for rather than deadening it,
which is what keeps an empty chain meaning "nothing to offer".

`DatasetScreen.onBack` is deleted rather than migrated — the trunk is a project
tab and no mount has passed it since the move.
@JArmandoAnaya
JArmandoAnaya merged commit be9a35d into main Aug 13, 2026
15 checks passed
@JArmandoAnaya
JArmandoAnaya deleted the feat/breadcrumb-navigation branch August 13, 2026 08:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Breadcrumb navigation: replace the single-level back link with the ancestor chain

1 participant