Skip to content

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

  1. Brand navy chrome stays in both modes. The README records that mapping the chrome onto --mat-sys-primary is a redesign needing rendered review. The app bar keeps --syrf-brand-ink / --syrf-on-brand-ink, expressed through mat.toolbar-overrides, mat.button-overrides and mat.icon-button-overrides with 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.
  2. 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 on mat-nav-list: Material's list items set width: 100%, density heights and leading-icon spacing that fought every number below. Routes, permissions, feature-flag gates and the section model are unchanged.
  3. 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, a 0 16px 16px 0 radius and overflow: hidden; the width animates 250ms cubic-bezier(.4, 0, .2, 1). The rail E2E constants are FULL_WIDTH 280 and RAIL_WIDTH 96. 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.
  4. Type. Row labels 14/20/500 (600 when active), two lines then an ellipsis, a title with 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.
  5. 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). disabled remains for rows that are unavailable (no permission, or Review while inclusion info is calculated): no href, out of the tab order, outline icon, 38% ink, tooltip on hover.
  6. 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-surface at 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 ring inset 0 0 0 2px var(--mat-sys-primary). "Stage off" badge on --mat-sys-surface-variant with an --mat-sys-outline-variant border. Rail tooltips are MatTooltip (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 0 for switched-off stages and disabled rows). Every transition honours prefers-reduced-motion.
  7. 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) and aria-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.
  8. 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 in localStorage (syrf-project-nav-collapsed) and read on construction; storage failures fall back to expanded. Below 1240px the compact overlay drawer is unchanged.
  9. Setup checklist. The footer's Project setup is a collapsible group with an n/8 badge (11px, 600, pill) whose children are the eight ProjectSetupComponent tasks as 44px single-line steps with their state icon in the icon column: done (completed; check_circle, filled, --syrf-nav-step-done green), 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's show is 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.

  10. Accessibility. <nav aria-label="Project navigation"> holds a role="tree" (aria-orientation="vertical") of role="treeitem" rows with aria-level; an open group owns its children's role="group" through aria-owns, groups carry aria-expanded, the active row aria-current="page" and a collapsed ancestor aria-current="true". The Stages section is a labelled group whose visual header is aria-hidden; rows carry an aria-label (their text is visibility: hidden in 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-visible only.

  11. Scroll regions. The destination tree and the checklist each scroll inside a clipping region through the syrfOverlayScroll directive (shared/overlay-scroll): it measures the live scrollbar gutter (offsetWidth − clientWidth), publishes it as --overlay-scroll-gutter so the scroller widens by it and the pane clips the native bar, suppresses the native bar outright (scrollbar-width: none plus the ::-webkit-scrollbar reset) 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-watch for the touched specs plus the repo-wide guard specs (syrf-theme.spec.ts, hot-hook zoneless discipline, help-URL guard).
  • pnpm run check:theme-migration and pnpm run check:contrast from src/services/web.
  • bash e2e/run-local.sh --spec project-navigation-rail --iterative for slices 1 and 4; section-shell-responsive for 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.