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)¶
-
- 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 twoMatDrawer*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", defaulthasBackdrop).
- Repoint the six TypeScript import sites from the fork to
-
- 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 theapp-project-navbindings exactly as they are —collapsible/sidenavCollapsedremain the app-level rail state and project.component.ts:132-158 needs no logic change.
- project.component.html: replace the three-way mode with
-
- 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/.navigationListabsolutely positioned inside it so the existingnavCollapsible255px↔60px tween (project.animations.ts:19-25) expands OVER the content. Also override the container'soverflow: hiddenon 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.
- Add a rail class binding on
-
- Take
readonly sidenavContainer = viewChild(MatSidenavContainer)in ProjectComponent and, in aneffect()that reads the rail state, scheduleafterNextRender(() => this.sidenavContainer()?.updateContentMargins())so stock re-measures on the ONE genuine footprint change (255↔60 when the user toggles collapsible). Do not enableautosize.
- Take
-
- Replace the
.mat-drawer-collapsibleselectors 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-sidedivider border and the elevation on non-side drawers, _sidenav-theme.scss) as page-scoped CSS.
- Replace the
-
- 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'smat.all-component-themesat :67 and :146 already themes sidenav.
- Delete
-
- 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 wholetesting/harness dir, and the 5 placeholder spec files. Confirm no exclude-list entry needs removing (there is none — verified).
- Delete the entire
-
- 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.
- Verify the 5px correction on shared/side-nav: it declares
-
- In side-nav.component.spec.ts, drop the now-redundant
MatSidenavModule/BrowserAnimationsModuleTestBed imports (lines 6-7, 15) and addprovideZonelessChangeDetection()per the Phase 5 rule; do the same for project/about/manage/admin specs.
- In side-nav.component.spec.ts, drop the now-redundant
-
- Optionally (same PR, low risk) convert
navCollapsible(project.animations.ts:19-25) from an@angular/animationstrigger to a plain CSS width transition — it is a single-property tween and removing it drops another animations-engine dependency ahead of wave 3.
- Optionally (same PR, low risk) convert
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
_contentMarginspublication path — assert the inlinestyle="margin-left: 60px"onmat-sidenav-content, not just the visual). - Hover the collapsed rail: nav expands to 255px OVER the content and the content's inline
margin-leftSTAYS 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 onviewportRuler.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
overwith a backdrop, opens/closes from ProjectNavigationService, closes on NavigationEnd (project.component.ts:83-92) and on backdrop click; verify the content now carriesinertwhile 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 sameProjectComponentinstance — it is the parent of those child routes — so the rail state correctly survives that; only leaving/projects/:projectIdrecreates 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 (overdefault 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-watchgreen, with the fork's 5 placeholder specs gone and the converted consumer specs carryingprovideZonelessChangeDetection().- 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>.