Application Navigation Material 3 Migration Plan¶
This plan executes the "application and project navigation visual states" item of the technical plan Wave 3. It is the authorized navigation owner that the ownership ledger deferred to "PR #2533 or its successor": PR #2533 is stale and conflicting and is not merged or rebased; its intent (signal-based shell state) is reauthored here on current main.
The migration is presentation and interaction-state work. Routes, permissions, route guards, feature-flag gates and the project navigation section model are unchanged. The rail layout contract (fixed rail footprint, hover/focus overlay, pinned content margin) is unchanged; the widths move to the M3 Expressive rail's 280px expanded and 96px collapsed under decision 2. Form-validation indicator semantics are out of scope (separate design-approval gate).
Inventory (audited 2026-09-15 at origin/main@f2bb1aa15)¶
| Surface | Files | State | Owner / conflicts |
|---|---|---|---|
| Top app bar (wide row, compact row, compact context row, breadcrumb, account/help/admin/theme menus) | core/nav/nav.component.{html,scss,ts,spec.ts}; chrome block in global-styles/styles.scss (app-nav mat-toolbar icon/button pins); dark toolbar override in syrf-theme.scss |
M2 mat-toolbar color="primary" on brand navy; hard-coded white/#fff/black; @angular/animations (collapseInOut, appearDisappear); FlexLayout (fxLayout, fxHide/fxShow.gt-md); Eager change detection; @Input; observables via | async; theme menu duplicated three times, admin menu twice |
PR #3468 (open, active) gates the theme toggle in nav.component.{html,ts,spec.ts}; this programme's app-bar slice lands after it |
| Project navigation drawer and rail | project/project.component.{html,scss,ts}, project/project-nav/* incl. _project-nav.component-theme.scss, project/project.animations.ts, core/services/layout/*, --project-nav-width in styles.scss, .navigationList/--project-drawer-surface dark overrides in syrf-theme.scss |
Stock mat-sidenav (ADR-013) with mat-nav-list; active rows via --syrf-brand-* washes; #f2f2f2 light drawer fallback; disabled rows painted --mat-sys-error red; section headers 12px; @angular/animations (navCollapsible, slideInOut, rotate90, rotate180); FlexLayout; ComponentStore + observables; rail expands on hover only (no keyboard expansion); E2E project-navigation-rail.spec.ts pins the 60px footprint and content-margin contract |
No active writer. Study Management PRs touched the section model only (merged). |
| Section shell (About, My Account, Admin Console) | shared/side-nav/* |
Already on --mat-sys-* roles with the adaptive wide-rail / sticky compact-selector hierarchy (merged 2026-08-31, bd3f11f46); E2E section-shell-responsive.spec.ts |
Supersedes stale PR #2992, which should be closed rather than merged. Remaining: active-item role differs from the global .mat-mdc-menu-panel a.active-menu-item rule (secondary vs primary container); literal 12px/72px sizes |
| Page shell | shared/page-shell/* |
M3 typescale and spacing tokens (Study Management) | Done; no change planned |
| Header banners and chip | core/components/environment-banner, environment-chip, impersonation-banner |
Inline hard-coded gradients and white; impersonation banner is the Wave 2 "impersonation colours" slice |
Environment identity colours become --syrf-env-* domain roles; impersonation colours follow the Wave 2 boundary |
| Footer | app.component.{html,scss} |
Literal #1976d2, #61a0eb, #9fcaef, black link colours |
No conflicts |
Skip link, loading bar, .nav-container sticky shell |
app.component.* |
Already token-based except footer links | No change |
| Review navigation, Dockview tabs, AF2 toolbars, Study/Stage end drawers | stage/stage-review/**, studies/study-table, stage/stage-studies |
Feature surfaces, not navigation chrome | Out of scope: owned by the stage-review / AF2 programme and the study-management owner |
Design decisions¶
- Brand navy chrome stays in both modes. The README records that mapping the chrome onto
--mat-sys-primaryis a redesign needing rendered review. The app bar keeps--syrf-brand-ink/--syrf-on-brand-ink, expressed throughmat.toolbar-overrides,mat.button-overridesandmat.icon-button-overrideswith M3 state layers instead of literal white. A surface-coloured app bar (as in the annotation-form redesign prototype) is a separate, user-approved design decision and is not made here. - Project navigation is the M3 Expressive navigation rail of the project navigation design
handoff, rev 2, which supersedes the rev 1
navigation drawer. It is the spec of record, and its reference implementation wins over its
README where they disagree. The rail is rebuilt on our own elements (
nav-pane,nav-row) with the emitted tokens, no longer onmat-nav-list: Material's list items setwidth: 100%, density heights and leading-icon spacing that fought every number below. Routes, permissions, feature-flag gates and the section model are unchanged. - Geometry. Two states: expanded 280px (
--project-nav-width) and collapsed 96px ($rail-width), each pinned with width, min-width and max-width,flex-shrink: 0, a0 16px 16px 0radius andoverflow: hidden; the width animates 250mscubic-bezier(.4, 0, .2, 1). The rail E2E constants areFULL_WIDTH280 andRAIL_WIDTH96. Rows are 48px (60px in the Stages section,rowHeight: 60), children 40px, 12px in from both edges with a full pill, padding-left 24px (children 60px). The icon column is centred 48px from the pane edge in both states and labels start at 72px. Rows are always laid out at the expanded width (280 − 24 = 256px) and the pane clips them, so collapsing and expanding move no icon and re-wrap no label; one DOM serves both states. In the rail each top-level row shows a 56×32 pill (radius 16, left edge 20px) behind its icon, open groups keep their children as 40px slots on a 2px connector line down the icon column (a filled dot marks the active child), and the Stages header keeps its 36px slot, centred in a fixed 72px box. Measured in Chromium against the rendered component: every top-level icon centre at 48px and every row at the same y in both states, including with a group open. - Type. Row labels 14/20/500 (600 when active), two lines then an ellipsis, a
titlewith the full name, breaking at spaces only; two lines fit the 48/40/60px rows. Section headers 11/14, 600, 0.5px tracking, uppercase, in a 36px slot. Row labels stay sentence case. - Stage state. A switched-off stage is
status: 'inactive', not disabled: an outline icon, a normal-contrast label and a "Stage off" badge (10/14, 700, 0.6px tracking, uppercase) on its own line beneath the name. It stays navigable and expandable, and shows its Overview and Settings children (Settings is how a stage is switched back on).disabledremains for rows that are unavailable (no permission, or Review while inclusion info is calculated): nohref, out of the tab order, outline icon, 38% ink, tooltip on hover. - Colour roles. Pane
--project-drawer-surface(--mat-sys-surface-container-low); inactive icon and label--mat-sys-on-surface-variant; dividers--mat-sys-outline-variant; actions (Create stage)--mat-sys-primary; disabled--mat-sys-on-surfaceat 38%. Selected--syrf-nav-selected-container/--syrf-on-nav-selected(the row in the expanded state, the pill in the rail), hover--syrf-nav-hover-tint, keyboard-only focus ringinset 0 0 0 2px var(--mat-sys-primary). "Stage off" badge on--mat-sys-surface-variantwith an--mat-sys-outline-variantborder. Rail tooltips areMatTooltip(project-nav-tooltip, rendered in the overlay container so no scroll region clips them), 12/16 on--mat-sys-inverse-surface/--mat-sys-inverse-on-surface. Icons are Material Symbols Outlined,FILL 1(FILL 0for switched-off stages and disabled rows). Every transition honoursprefers-reduced-motion. - Behaviour. Only the branch holding the active route opens, once, on load and on navigation
(matched by path segment through
isActiveChildRoute); the user may collapse it, and every other group keeps the user's toggled state. A group row toggles and never navigates; a leaf navigates. A collapsed group holding the active route keeps a reduced-emphasis cue (the hover state layer, brand ink, a 600 label) andaria-current="true"; in the rail its pill is filled and a 4px dot sits under it. Clicking a group row in the rail pins the pane open without toggling anything. - Collapse, peek and persistence. The collapse control is the last row, pinned below the
footer, always labelled ("Collapse navigation" / "Expand navigation",
keyboard_double_arrow_left/_right). Hovering the collapsed rail peeks the 280px pane open after 120ms and re-collapses it 200ms after the pointer leaves; the peeked pane floats over the content with the level-2 shadow while the 96px footprint stays (ADR-013), and its footer control reads "Expand navigation" and pins the pane open. Keyboard focus expands the rail immediately. The collapsed preference is persisted per browser profile inlocalStorage(syrf-project-nav-collapsed) and read on construction; storage failures fall back to expanded. Below 1240px the compact overlay drawer is unchanged. -
Setup checklist. The footer's Project setup is a collapsible group with an
n/8badge (11px, 600, pill) whose children are the eightProjectSetupComponenttasks as 44px single-line steps with their state icon in the icon column: done (completed;check_circle, filled,--syrf-nav-step-donegreen), next (the first incomplete step that can be done;radio_button_checked, accent, 600 label), todo (a later one;radio_button_unchecked), locked (blocked by a step it depends on, that is the task'sshowis false;lock, disabled, not focusable, with the reason in a tooltip). The imperative copy is the handoff demo's, mapped in order: Project title → "Name your project", Inclusion/exclusion criteria → "Set review criteria", Add a stage → "Add a stage" (opens the create-stage dialog), Enable stage in stage settings → "Enable your stage", Upload your systematic search → "Upload your search", Design annotation questions → "Design questions", Select annotation questions to be asked in a stage → "Assign questions to a stage", Start reviewing → "Start reviewing". In the rail, done steps show a small check and the next step an accent ring on the connector line. The footer scrolls on its own. -
Accessibility.
<nav aria-label="Project navigation">holds arole="tree"(aria-orientation="vertical") ofrole="treeitem"rows witharia-level; an open group owns its children'srole="group"througharia-owns, groups carryaria-expanded, the active rowaria-current="page"and a collapsed ancestoraria-current="true". The Stages section is a labelled group whose visual header isaria-hidden; rows carry anaria-label(their text isvisibility: hiddenin the rail). The setup checklist is its own tree, with each step's state in its name ("Enable your stage, next step"). The drawer element no longer repeats the navigation landmark. Roving tabindex gives exactly one tab stop, recomputed after every render: the active destination, else its nearest rendered ancestor, else the first enabled row, else the collapse control. Up/Down move between enabled rows (disabled rows are skipped), Home/End jump, Right opens a closed group, Left closes an open one or moves from a child to its group, Enter/Space activate. The focus ring is:focus-visibleonly. - Scroll regions. The destination tree and the checklist each scroll inside a clipping
region through the
syrfOverlayScrolldirective (shared/overlay-scroll): it measures the live scrollbar gutter (offsetWidth − clientWidth), publishes it as--overlay-scroll-gutterso the scroller widens by it and the pane clips the native bar, suppresses the native bar outright (scrollbar-width: noneplus the::-webkit-scrollbarreset) so an overlay scrollbar cannot paint beside the thumb, and draws a 4px overlay thumb at the region's right edge (only while overflowing; 28% opacity at rest, 55% on hover; draggable; a click in the track pages) plus 28px edge fades from the drawer surface. Its observers only schedule one animation frame and a measurement writes only when a metric changed, so it cannot loop. Measured in Chromium with 15px classic scrollbars: each scroller 295px wide with a 280px content box, rows still 256px, icons at 48px and labels at 72px, in both states.
Deviations from the handoff and decisions where it was silent:
- Section dividers clear the ink by 16px at Chris's request, instead of the handoff's 8px
margin + 8px padding: 16px from the last pill above to the line, and 16px from the line to the
next pill. A titled section's 36px header slot starts at the line (the header's 14px text line
box then sits 21px below it) so the rail keeps its row-for-row mirroring. The tree pads 16px
under its last row and the footer and collapse control sit 16px under their borders.
- The checklist footer is capped at min(38%, 50% − 77px) rather than a flat 38%: with the
77px collapse control pinned below it, a flat 38% leaves the destination tree 47% of a 560px
pane, failing the handoff's own acceptance check 7 (≥50%). The cap equals 38% on panes taller
than about 708px.
- The vertical rhythm is one step tighter than the handoff at Chris's request
(2026-09-16, "can the project nav be made slightly more compact"): rows 48px instead of 56px,
stage rows 60px instead of 72px, children 40px instead of 44px, section headers 36px instead of
56px, the tree's top padding 8px instead of 12px and the collapse slot 77px instead of 85px.
The 16px divider clearance, the 48px icon axis, the 72px label start and the 56×32 rail pill are
unchanged, so the rail still mirrors the expanded pane row for row.
- The native scrollbar is suppressed, not merely displaced. Widening the scroller by the
measured gutter hides a classic scrollbar only. An overlay scrollbar (Chrome on Linux and
Windows 11) occupies no width, so it painted beside the overlay thumb and read as two parallel
scrollbars (Chris, 2026-09-16). Both scrollers now also set scrollbar-width: none with a
::-webkit-scrollbar reset for older WebKit; the gutter measurement stays, and simply reads 0.
The drawer chrome (.mat-drawer and stock's .mat-drawer-inner-container) suppresses its bar
the same way: stock leaves both overflow: auto, so whenever the drawer itself overflows (a
preview banner shortens it, for instance) its native bar painted beside the overlay thumb. The
drawer still scrolls by wheel, touch and keyboard; only the bar is gone.
- A collapsed ancestor gets the hover state layer as the handoff README says, although the
reference implementation paints no fill for it (only brand ink and a 600 label).
- Checklist done marks use --syrf-nav-step-done, a green pair declared in both layers
(light mat.$green-palette tone 40, dark tone 80), standing in for the handoff's
--syrf-success-dark, which the app does not emit; measured at 3:1 or better on the drawer
surface as a graphic.
- The checklist stays visible at 8/8; the handoff only suggests hiding it, which is a product
decision left for later.
- A switched-off stage shows Overview and Settings; Review is hidden because its route guard
requires an active stage (the handoff's demo shows only Settings).
- Dark selected pair is --mat-sys-primary-container / --mat-sys-on-primary-container
(7.2:1): brand primary 100 glares on a dark pane. The dark hover wash is M3 primary tone 80 at
10%.
- Brand ink stays #203457 and primary 100 #bcc2cd, from the app palette, over the
handoff's #1c3053 / #c3cbd8. Every colour comes from the emitted theme.
- Tree semantics reverse rev 1's "no role="tree"" decision, as rev 2 requires; the rows
are our own elements, so no Material list roles conflict. The collapse control stays a
button (not a tree item) and is still reached with the arrow keys; the tab stop stays on
the active destination rather than following focus, as the handoff specifies.
- Rail tooltips show on hover, not on keyboard focus: keyboard focus expands the rail
immediately (kept from rev 1), which replaces the rail with the labelled pane before a tooltip
could matter.
- Section headers are uppercase (rev 2), reversing rev 1's sentence-case header; row labels
stay sentence case per the Material 3 style guide.
3. The rail's layout contract holds at the new widths. The fixed-footprint mechanism of ADR-013
is unchanged: the drawer element is pinned to the 96px rail so stock's updateContentMargins()
keeps the content margin at 96px, while the 280px pane expands over the content. Keyboard users
gain rail expansion on focus-within, which is additive.
4. Motion is off @angular/animations for the project navigation: the pane width, row fills and
chevron are CSS transitions with prefers-reduced-motion support, and the navCollapsibleDone
re-measure hook is the pane's transitionend. project.animations.ts is deleted. This matches
the zoneless programme's direction (ADR-013 step 10).
5. Modernise as touched. Components edited by a slice move to signal inputs/outputs,
selectSignal, OnPush, and drop FlexLayout in favour of CSS, scoped to the files the slice
already owns.
Slices¶
| # | Slice | Files | Acceptance |
|---|---|---|---|
| 1 | Project navigation: M3 Expressive rail per design handoff rev 2 (this PR, #3489) | project/project-nav/*, project/project-overview/project-setup/* (the footer group row), project/project.component.scss, styles.scss (--project-nav-width), syrf-theme.scss (drawer/list roles only), syrf-theme.spec.ts, e2e/tests/project-navigation-rail.spec.ts (FULL_WIDTH, RAIL_WIDTH), handoffs/project-navigation-drawer/, this doc |
Unit specs green; check:theme-migration + check:contrast green; rail E2E green; light/dark and wide/compact evidence attached |
| 2 | Top app bar chrome, breadcrumb and menus (after #3468 merges) | core/nav/*, styles.scss chrome block, syrf-theme.scss toolbar override, app.component.* footer |
Nav spec green; no literal colours in nav chrome; menus deduplicated; light/dark evidence at 320/600/905/1240/1440 |
| 3 | Header banners and environment chip roles | core/components/environment-banner, environment-chip; impersonation banner per Wave 2 boundary |
--syrf-env-* roles emitted in both modes and contrast-tested |
| 4 | Rail motion without @angular/animations (folded into slice 1 by the rev 2 rebuild) |
project/project.animations.ts, project-nav.component.*, project.component.* |
Rail E2E green; reduced-motion honoured; re-measure still fires |
| 5 | Section shell consistency and ledger cleanup | shared/side-nav/*, global active-menu rule, technical-plan ownership rows for #2533/#2992 |
Section-shell E2E green; one active-item role across rail, selector and menus |
Each slice is a ready-for-review PR with focused scope, its own tests and rendered evidence. Cloud review is requested once the applicable checks pass.
Validation¶
pnpm exec ng test --no-watchfor the touched specs plus the repo-wide guard specs (syrf-theme.spec.ts, hot-hook zoneless discipline, help-URL guard).pnpm run check:theme-migrationandpnpm run check:contrastfromsrc/services/web.bash e2e/run-local.sh --spec project-navigation-rail --iterativefor slices 1 and 4;section-shell-responsivefor slice 5.- Browser evidence: light and dark, wide rail (1440px), rail collapsed and hover/focus expanded, compact overlay (1024px) and narrow (390px); hover, focus-visible, selected, disabled states.