Skip to content

Rev 2 supersedes rev 1. Rev 1 (a 360px M3 navigation drawer) is replaced by this handoff for the M3 Expressive navigation rail, committed verbatim below this note with its reference files: reference-ProjectDrawer.jsx.txt, reference-ProjectDrawer.d.ts.txt, projectdrawer.html (which loads a bundle from the design-system project, so it does not run from this folder) and colors_and_type.css. The references are design intent, not production code, and colors_and_type.css cites a _design-tokens.scss that does not exist in this repository. How the handoff maps onto the app's tokens, and where the implementation deviates, is recorded in decision 2 of the navigation plan.

Project navigation — implementation handoff

Rebuild of the SyRF project side navigation as an M3 Expressive navigation rail with two states (expanded 280px / collapsed 96px). This folder is the spec of record.

File What it is
reference-ProjectDrawer.jsx.txt Reference implementation, React 18, plain JS. .txt so the design-system compiler ignores it. Rename to .jsx when copying into the app.
reference-ProjectDrawer.d.ts.txt Props and data model.
projectdrawer.html Runnable demo (3 frames: collapsed rail, static route state, interactive). It loads _ds_bundle.js from the design-system project, so open it there, not from this folder.
colors_and_type.css Token source for every colour/type value referenced below.

Everything below is normative. Where the reference file and this document disagree, the reference file wins — it is the thing that was reviewed.


1. Why it changed

The previous nav was a 255px drawer. Two M3 facts drove the rebuild:

  • M3 Expressive replaces the navigation drawer with the expanded navigation rail. This is a rail in two states, not a drawer that collapses.
  • Published expressive rail widths are 96dp collapsed, 220–360dp expanded.

We picked 280 / 96. 280 rather than 220 because stage names are user-authored and two levels of nesting eat indent; rather than 360 because horizontal space is scarce in this app. 280 is +25px on production for a nav that now carries stage state, a setup checklist and two-line names.


2. Geometry (exact)

Thing Value
Expanded pane 280px — width, min-width, max-width all set, flex-shrink: 0
Collapsed rail 96px, same pinning
Pane radius 0 16px 16px 0
Pane overflow hidden (it clips row overhang during the width animation)
Top-level row height 56px (72px for sections that set rowHeight, e.g. Stages)
Child row height 44px
Row horizontal margin 12px each side → row width width - 24
Row radius 28px (full pill)
Row padding-left 24px top-level, 60px children, 24px checklist steps (their state icon occupies the icon column)
Icon column centre 48px from the pane's left edge, in BOTH states
Label x 72px in both states
Rail pill 56×32, radius 16, left edge at 20px (centres on 48)
Section header 56px tall, same slot in both states
Footer block pinned, max-height: 38%, own scroll
Collapse control pinned below the footer, flex-shrink: 0

Do not change the 48px icon centre in one state without the other. It is what makes collapse and expand produce zero horizontal movement, and it is the single most-reported defect if broken.

Vertical mirroring

The rail reserves a slot for everything the expanded state renders:

  • section headers → a 56px header slot (rail shows the label, centred)
  • open groups' children → 44px slots each, on a connector line

Consequence: top-level icons sit at identical y coordinates in both states. Any new row type must declare a fixed height in both branches or the guarantee breaks.


3. Type and colour

All values come from colors_and_type.css. No literals.

Element Spec
Row label 14px / 20px, 0.1px tracking, weight 500 (600 when active, active-ancestor, or the next setup step)
Section header 11px / 14px, weight 600, 0.5px tracking, uppercase, --syrf-on-surface-variant
"Stage off" badge 10px / 14px, weight 700, 0.6px tracking, uppercase, --syrf-surface-variant fill, --syrf-divider border, pill radius
Tooltip 12px / 16px on --syrf-inverse-surface / --syrf-inverse-on-surface
Inactive ink --syrf-on-surface-variant for both icon and label — icons and labels share one colour
Active ink --syrf-brand-ink
Active indicator --syrf-primary-100 pill
Hover state layer --syrf-primary-tint
Action items (kind:"action") --syrf-accent
Disabled --syrf-text-disabled

--syrf-inverse-surface / --syrf-inverse-on-surface were added to the design system for the tooltip; they exist in the light :root and the [data-theme="dark"] scope.


4. Data model

DrawerSection { title?: string; rowHeight?: number; items: DrawerItem[] }
DrawerItem {
  id: string; label: string; icon?: string;       // Material Symbols ligature, top-level only
  children?: DrawerItem[];                         // one level of nesting is what's designed
  kind?: "page" | "action";
  status?: "complete" | "inactive";
  state?: "done" | "next" | "todo" | "locked";     // setup-checklist children only
  disabled?: boolean; badge?: number | string;
}

Mapping from the current app:

  • sections[0] — Project overview, Study management, Screening info
  • sections[1]{ title: "Stages", rowHeight: 72, items: [...stages, createStage] }
  • sections[2] — Data export, Project settings
  • footer — Project setup (the checklist)

5. Behaviour

Expand / collapse

  • Collapse control is the last row, always visible, always labelled ("Collapse navigation" / "Expand navigation").
  • Width animates 250ms cubic-bezier(.4,0,.2,1).
  • Rows are laid out at the expanded width (width - 24) at all times with flex-shrink: 0; the pane clips them. Text must never re-wrap or re-flow while the pane animates — this was a specific review finding. Same for the rail's section header: it sits in a fixed railWidth - 24 box so it doesn't drift toward the midpoint as the pane narrows.
  • Persist the collapsed state per user. Auto-collapse below ~1240px viewport (not yet implemented).

Hover peek

  • Hovering the collapsed rail expands it after 120ms; leaving re-collapses after 200ms.
  • The peeked pane is absolutely positioned and floats over page content with a shadow; the rail's 80/96px slot is retained so page content never reflows.
  • While peeked, the footer control reads "Expand navigation" and pins the pane open.
  • hoverExpand={false} disables this.

Groups

  • A group's chevron is > collapsed, open (matches the existing app convention). Leaf items that navigate to a sub-page get no trailing glyph — an arrow was tried and read as a second chevron.
  • The branch containing the current route auto-opens once on load or route change, and can then be collapsed by the user. Never force it open.
  • When a group holding the current route is collapsed, the parent row carries a reduced-emphasis indicator (state layer + bold label + brand ink) and aria-current="true"; in the rail it shows a small dot. Otherwise the user loses all trace of where they are.
  • Clicking a group in the rail pins the pane open without toggling any group, so nothing shifts.

Stage state

  • A switched-off stage uses status: "inactive", not disabled. It stays navigable and expandable — the only way to re-enable a stage is its own settings page, so a disabled row locks the user out. This was a real trap in the production design.
  • Inactive renders as: outline icon, normal-contrast label, and a "STAGE OFF" badge on its own line beneath the name (never inline — it stole ~48px of a 217px row).
  • Stage rows are 72px so a two-line name and the badge both fit.
  • disabled remains for genuinely unreachable items (no permission). It is not tabbable.

Long, user-named stages

  • Labels wrap to two lines then clamp with an ellipsis; the full name is in the title attribute and in the rail tooltip.
  • Break at spaces only — never overflow-wrap: anywhere (it broke "Completed screening" mid-word at narrow widths).
  • Two lines fit inside 56/44px rows, so wrapping never changes row height and never breaks the rail/expanded alignment.
  • A collapsible group with a 4/8 badge, so progress is visible without opening it.
  • One icon vocabulary: filled check = done, filled ring = the next action, empty ring = later, lock = blocked. No pencils, no mixed link colours — the production version used three.
  • Copy is imperative ("Enable your stage"), single-line, 44px rows.
  • Pinned to the bottom, capped at 38% height with its own scroll, so eight steps can never squeeze the destination list.
  • Consider hiding it entirely at 8/8.

Scrolling

  • The native scrollbar must not consume label width. Each scroll region is widened by the measured gutter (offsetWidth - clientWidth, measured from the live element in an effect — an import-time probe returns 0 in some environments) and the pane clips that overhang.
  • Because the native bar is then off-screen, each region draws its own 4px overlay thumb at the right edge: visible only while overflowing, 28% opacity at rest / 55% on hover, draggable, with click-in-track paging.
  • Edge fades (28px, surface → transparent) appear at top and/or bottom whenever content extends past the view — the at-a-glance "there's more" cue.

Rail children

Open groups render a 2px connector line down the icon column with meaningful marks only: filled dot = active child, accent ring = next setup step, small check = done step, nothing otherwise. A column of undifferentiated dots was noise at eight children.


6. Accessibility

  • <nav aria-label="Project navigation">role="tree" (aria-orientation="vertical") › role="treeitem" rows.
  • aria-current="page" on the active row; aria-current="true" on a collapsed ancestor of it.
  • aria-expanded on every group.
  • Roving tabindex: exactly one tab stop (the active destination, or its nearest rendered ancestor when collapsed — otherwise the rail is a keyboard dead end). ↑/↓ move between rows, Home/End jump, Enter/Space activate.
  • Focus ring is keyboard-only (shown after Tab/arrow input, suppressed on mouse-down) and is a 2px inset ring, so it can't be mistaken for selection.
  • Rail rows carry aria-label; tooltips appear on hover and keyboard focus, positioned fixed from the row's rect so no scroll container clips them.
  • Every transition honours prefers-reduced-motion.
  • Contrast: all inactive ink is on-surface-variant on surface-container-low (passes 4.5:1); never use alpha-muted text for a navigable row.

7. Acceptance checks

Run these after implementing; each corresponds to a defect found in review.

  1. Icon centres measure 48px from the pane's left edge in both states; top-level row y positions are identical collapsed vs expanded, including with a group open.
  2. Toggling collapse causes no text re-wrap and no horizontal icon movement.
  3. Collapsing the group containing the current route still shows where you are.
  4. An inactive stage is clickable and its Settings child is reachable.
  5. A 40-character stage name wraps to two lines, keeps row height, and shows the full name on hover.
  6. Tab enters the collapsed rail and can reach the expand control; only one tab stop exists.
  7. With the setup checklist open at a 560px pane height, the destination tree keeps ≥50% of the height.
  8. Pane measures exactly 280 / 96 with rows at the full content width when a scrollbar is present.
  9. Console is clean — no ResizeObserver/Maximum update depth loops.

8. Known gaps

  • No auto-collapse breakpoint yet (recommended: rail below ~1240px, modal rail with scrim on narrow).
  • Setup checklist doesn't self-dismiss at 8/8.
  • One nesting level is designed; deeper trees are untested.
  • Stage names are unbounded — worth a soft length hint (~40 chars) in the create-stage dialog with a live nav preview.