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) andcolors_and_type.css. The references are design intent, not production code, andcolors_and_type.csscites a_design-tokens.scssthat 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 infosections[1]—{ title: "Stages", rowHeight: 72, items: [...stages, createStage] }sections[2]— Data export, Project settingsfooter— 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 withflex-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 fixedrailWidth - 24box 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", notdisabled. 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.
disabledremains 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
titleattribute 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.
Setup checklist (footer)¶
- A collapsible group with a
4/8badge, 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-expandedon 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, positionedfixedfrom 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.
- Icon centres measure 48px from the pane's left edge in both states; top-level row
ypositions are identical collapsed vs expanded, including with a group open. - Toggling collapse causes no text re-wrap and no horizontal icon movement.
- Collapsing the group containing the current route still shows where you are.
- An inactive stage is clickable and its Settings child is reachable.
- A 40-character stage name wraps to two lines, keeps row height, and shows the full name on hover.
- Tab enters the collapsed rail and can reach the expand control; only one tab stop exists.
- With the setup checklist open at a 560px pane height, the destination tree keeps ≥50% of the height.
- Pane measures exactly 280 / 96 with rows at the full content width when a scrollbar is present.
- Console is clean — no ResizeObserver/
Maximum update depthloops.
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.