Skip to content

ADR-013: Retire the syrf-material sidenav fork

AF2 Phase 5 (zoneless) wave 2 evaluated the forked syrf-material/sidenav (which added a bespoke collapsible drawer mode) against stock @angular/material/sidenav 22, and retired the fork, rebuilding the project navigation rail on stock's public surface (PR #2770). This record carries the full evaluation behind the summary comment on that PR.

Decision

Retire the fork.

Implementation steps (as executed on PR #2770)

    1. Repoint the six TypeScript import sites from the fork to @angular/material/sidenav: about.component.ts:11, manage.component.ts:11, admin.component.ts:15, project.component.ts:20-24, shared/side-nav/side-nav.component.ts:10-14, and the two MatDrawer* importers stage/stage-studies/stage-studies.component.ts:111-115 and studies/study-table/study-table.component.ts:116-120. The five non-project consumers need NO other change — they use only stock features (mode="side", opened, position="end", default hasBackdrop).
    1. project.component.html: replace the three-way mode with [mode]="compactProjectNavigation() ? 'over' : 'side'" (line 7-9), the three-way opened with [opened]="compactProjectNavigation() ? projectNavigation.open() : true" (line 10-16), and DELETE [collapsibleWidth]="60" (line 6). Keep (openedChange), the mouseenter/mouseleave pair (lines 22-23), [style.top.px], [style.max-height.px], [hasBackdrop], and the app-project-nav bindings exactly as they are — collapsible/sidenavCollapsed remain the app-level rail state and project.component.ts:132-158 needs no logic change.
    1. Add a rail class binding on <mat-sidenav> (e.g. [class.sideNavigation--rail]="!compactProjectNavigation() && collapsible") and in project.component.scss give that class the FIXED 60px footprint: width: 60px; overflow: visible; on the drawer, with #sidenav-container/.navigationList absolutely positioned inside it so the existing navCollapsible 255px↔60px tween (project.animations.ts:19-25) expands OVER the content. Also override the container's overflow: hidden on that page so the expanded nav is not clipped (about.component.scss:29-34 is the precedent). This is what keeps stock's _getWidth() at 60 and the content margin pinned.
    1. Take readonly sidenavContainer = viewChild(MatSidenavContainer) in ProjectComponent and, in an effect() that reads the rail state, schedule afterNextRender(() => this.sidenavContainer()?.updateContentMargins()) so stock re-measures on the ONE genuine footprint change (255↔60 when the user toggles collapsible). Do not enable autosize.
    1. Replace the .mat-drawer-collapsible selectors at project.component.scss:6-7 with the new rail class, and re-add the two visual rules the forked theme was contributing (.mat-drawer-side divider border and the elevation on non-side drawers, _sidenav-theme.scss) as page-scoped CSS.
    1. Delete @use '../app/core/syrf-material/sidenav/sidenav-theme' as new-sidenav; (global-styles/syrf-theme.scss:6) and both @include new-sidenav.theme(...) calls (:68 light, :147 dark). Stock's mat.all-component-themes at :67 and :146 already themes sidenav.
    1. Delete the entire src/app/core/syrf-material/sidenav/ directory — drawer.ts, sidenav.ts, drawer-animations.ts, the three templates, drawer.scss, _sidenav-theme.scss, _sidenav-theme.import.scss, _sidenav-legacy-index.scss, sidenav.md, both BUILD.bazel files, the whole testing/ harness dir, and the 5 placeholder spec files. Confirm no exclude-list entry needs removing (there is none — verified).
    1. Verify the 5px correction on shared/side-nav: it declares width: 250px (side-nav.component.scss:15) but was getting a hardcoded 255px content margin; under stock it gets a measured 250px. Adjust the stylesheet if the 5px shift is visible.
    1. In side-nav.component.spec.ts, drop the now-redundant MatSidenavModule/BrowserAnimationsModule TestBed imports (lines 6-7, 15) and add provideZonelessChangeDetection() per the Phase 5 rule; do the same for project/about/manage/admin specs.
    1. Optionally (same PR, low risk) convert navCollapsible (project.animations.ts:19-25) from an @angular/animations trigger to a plain CSS width transition — it is a single-property tween and removing it drops another animations-engine dependency ahead of wave 3.

Parity proof

  • Project page, wide viewport, rail OFF (default): drawer 255px, content margin-left 255px, no shadow — matches pre-change screenshot.
  • Click the collapse button (project-nav.component.html:183): drawer footprint animates to 60px over 400ms AND the content margin animates 255→60 in step (this is the _contentMargins publication path — assert the inline style="margin-left: 60px" on mat-sidenav-content, not just the visual).
  • Hover the collapsed rail: nav expands to 255px OVER the content and the content's inline margin-left STAYS 60px (this is the single most important parity assertion — it is the only fork behaviour stock cannot express by default).
  • Mouseleave: nav collapses back to 60px, margin still 60px, no content reflow at any point during either transition.
  • Resize the window while the rail is hover-expanded: content margin must remain 60px (regression guard for the _getWidth()-during-hover failure mode; stock re-measures on viewportRuler.change(), stock drawer.ts:842-845).
  • Toggle the rail back OFF: footprint returns to 255px and margin animates 60→255 (proves the explicit updateContentMargins() call fires).
  • Narrow to the compact breakpoint (LayoutService.compactApplicationShell): drawer switches to over with a backdrop, opens/closes from ProjectNavigationService, closes on NavigationEnd (project.component.ts:83-92) and on backdrop click; verify the content now carries inert while open (new stock behaviour) and that the nav is not clipped.
  • Leave the project (e.g. back to /projects) and return with the rail collapsed and confirm it resets to expanded (the state is deliberately not persisted — project.component.ts:62-63 — so do not accidentally 'fix' it). Navigating BETWEEN sub-routes inside a project (detail, admin, studies, stage) reuses the same ProjectComponent instance — it is the parent of those child routes — so the rail state correctly survives that; only leaving /projects/:projectId recreates the component.
  • About, Manage, Admin: side drawer opens at 255px with content margin 255px, no visual delta (screenshot diff).
  • shared/side-nav consumers: 250px drawer, content margin now 250px (was 255px) — confirm the 5px correction looks right rather than broken.
  • Study Table and Stage Studies: position="end" drawer opens/closes from the right with the backdrop, and the content is not pushed (over default mode) — confirms the end-position projection still works through stock's combined <ng-content select="mat-drawer, mat-sidenav"> slot.
  • Dark theme: check the drawer border/elevation on all of the above after the forked M2 theme mixin is removed.
  • pnpm exec ng test --no-watch green, with the fork's 5 placeholder specs gone and the converted consumer specs carrying provideZonelessChangeDetection().
  • A targeted Playwright spec covering rail open/close/hover + the margin assertion, since no unit test can cover layout (the plan's wave-2 exit criterion already names 'sidenav open/close/resize').

Stock rebuild

Feasible: True

Mechanism: Keep opened permanently true and use stock side mode for the non-compact case ([mode]="compact() ? 'over' : 'side'", [opened]="compact() ? projectNavigation.open() : true"), and reproduce the rail with a FIXED-FOOTPRINT drawer + overlaying content: bind a rail class on <mat-sidenav> that pins the drawer element itself to width: 60px with overflow: visible, and absolutely position the inner #sidenav-container/.navigationList so the existing 400ms navCollapsible 255px↔60px tween expands OVER the content instead of widening the drawer. This is the load-bearing trick: stock's updateContentMargins() measures this._left._getWidth() (stock drawer.ts:917), so if the drawer's own offsetWidth never leaves 60px during hover, _contentMargins.left stays 60px — byte-for-byte the behaviour the fork hardcoded via collapsibleWidth (drawer.ts:924-925). Do NOT bind [style.width.px] directly to a rail signal on the drawer: that reproduces the width but lets a stray re-measure (viewport resize → stock drawer.ts:842-845) push the content out to 255px mid-hover, which is a parity break. For the one genuine margin change — the user toggling collapsible on/off, i.e. footprint 255px↔60px — take a viewChild(MatSidenavContainer) and call its PUBLIC updateContentMargins() once inside an afterNextRender/effect after the class flips, so stock re-measures; the margin then animates because stock adds .mat-drawer-transition unconditionally 200ms after container init (stock drawer.ts:833-840) and its CSS transitions margin-left. Stock's MatSidenavContent still publishes the margin (it inherits MatDrawerContent's [style.margin-left.px] host binding — stock drawer.ts:87-88), so the _contentMargins publication path is preserved without any fork. Explicitly avoid autosize (stock drawer.ts:753-760 carries its own layout-thrash warning and runs a measure on every CD cycle).

Visual differences

(1) Animation curve/engine: the fork animates the drawer via @angular/animations 400ms cubic-bezier(0.25,0.8,0.25,1) (drawer-animations.ts:46-50); stock uses CSS transitions at Material's $private-swift-ease-out-duration. In side mode with opened permanently true neither ever animates the drawer, so this only shows on the compact over-mode overlay — expect a slightly different open/close feel there. (2) Margin sync TIMING: fork adds mat-drawer-transition only on the FIRST animation start (drawer.ts:991-1000), so the very first margin change on a freshly-loaded page snaps; stock enables it 200ms after container init unconditionally, so the first change animates. Stock is the better behaviour but it IS a visible difference. (3) side-mode margin becomes MEASURED rather than the hardcoded 255 (drawer.ts:928) — about/manage/admin are already 255px so unchanged, but shared/side-nav (250px, side-nav.component.scss:15) shifts 5px, and any mid-transition measurement now tracks reality. (4) Stock adds inert on the content whenever a backdrop is showing (stock drawer.ts:110-146) — the compact overlay becomes correctly non-interactive/screen-reader-inert, a behaviour change users can feel. (5) Focus trapping moves from mode-based to backdrop-based (stock drawer.ts:638-644); for the compact overlay (hasBackdrop true, project.component.html:1) both trap, so no practical change. (6) Dropping the forked M2 theme mixin (syrf-theme.scss:68, :147) removes the collapsible-specific divider border and elevation(16); stock's token theme is already applied at :67, so expect a subtle shadow/border delta on the drawer that must be re-added as app CSS. (7) The stray always-rendered hidden backdrop div (fork drawer.ts:759-772) disappears in side-only containers — invisible today, no user-visible change.

Unknowns at evaluation time

(a) Whether overflow: visible on the drawer survives stock's compiled drawer.css (stock sets overflow-y: auto on .mat-drawer and overflow: hidden on .mat-drawer-container) — the container's overflow: hidden will CLIP the overlaying expanded nav unless it is overridden per-page; about.component.scss:29-34 already does this dance, so it is known-doable but needs a real-browser check for the fixed-position project drawer. (b) Exact stacking: the fork gives .mat-drawer-side z-index 2 vs content 1 (drawer.scss:14-18, 144-146); stock's compiled CSS must be confirmed to preserve that ordering for the overlay. © project.component.scss:5 sets position: fixed on .mat-drawer with [style.top.px]="sidenavTop()" from the template — this already fights stock MatSidenav's own [style.top.px] host binding (only active when fixedInViewport, which is never set anywhere in src); needs a rendered check that the template binding still wins. (d) Whether updateContentMargins() remains public/unchanged in future Material minors — it is not marked @docs-private in 22.1.0 but is not a documented API either; a small wrapper + a spec pinning it is the mitigation. (e) The compact over-mode path is exercised only by the LayoutService breakpoint signal and has no automated coverage.

Rail usage (pre-retirement baseline)

This section describes the FORK-ERA wiring as it was evaluated, before the retirement landed — it is the baseline the parity work was measured against, not the current design. The current stock-based flow (side mode, permanently opened, railActive class + explicit updateContentMargins() re-measure) is the one described under Stock rebuild → Mechanism above.

Only ONE consumer uses collapsible mode: ProjectComponent. Drive chain: the collapse button in project-nav (project-nav.component.html:183 (click)="setCollapsible(!collapsible)") calls project-nav.component.ts:670-673 which sets its own collapsible and emits collapsibleChange; project.component.html:28 binds that to project.component.ts:132-137 collapsibleChanged(), which sets this.collapsible and forces sidenavCollapsed = true on enable. collapsible selects the drawer MODE (project.component.html:7-9: compact ? 'over' : collapsible ? 'collapsible' : 'side'); sidenavCollapsed drives opened (project.component.html:10-16: collapsible ? !sidenavCollapsed : true). Hover is the rail's expand/collapse: (mouseenter)="expandCollapsibleNavigation()" / (mouseleave)="collapseCollapsibleNavigation()" at project.component.html:22-23 → project.component.ts:148-158 flip sidenavCollapsed; (openedChange) at project.component.html:17 → project.component.ts:139-146 mirrors it back. There is NO breakpoint input to collapsible — the breakpoint (LayoutService.compactApplicationShell, project.component.ts:49-50) instead overrides the whole thing to over mode driven by ProjectNavigationService.open(). NOT PERSISTED: collapsible = false; sidenavCollapsed = false are plain fields initialised on construction (project.component.ts:62-63) and there is no localStorage/sessionStorage anywhere under src/app/project — the rail resets to full width on every navigation that re-creates ProjectComponent. The visible width change is NOT the drawer's own animation: the drawer's @transform state is transform:none for both 'open' and 'collapsed' (drawer-animations.ts:31-37); the 255px↔60px tween is the navCollapsible trigger on the inner nav list (project.animations.ts:19-25, applied at project-nav.component.html:127 and :175), sized by .navigationList { width: var(--project-nav-width) } (project-nav.component.scss:15-17, --project-nav-width: 255px at global-styles/styles.scss:16). The drawer element takes its width from that content, so on hover it grows to 255px while the content margin stays pinned at 60px by the fork's collapsibleWidth branch — i.e. the expanded rail OVERLAYS the content. Content CSS keyed on the collapsed state: project.component.scss:6-7 (box-shadow on .mat-drawer-collapsible.mat-drawer-opened), project-nav.component.scss:34 (.nav-collapsed &.hasIcon mat-icon { opacity: 1 } via the nav-collapsed class at project-nav.component.html:128), and [collapsed] on app-project-setup (project-nav.component.html:168). Content projection is a single default <ng-content> per drawer/content pair — project.component.html projects one <div fxLayout="column" id="sidenav-container"> holding <app-project-nav>.