Skip to content

Button migration: Angular Material buttons to m3e-angular

What this is: the delivery plan for moving every SyRF button onto m3e-angular's Material 3 Expressive button family (ADR-027), started in #4769. This page is temporary; delete it when the flag retires.

Design

m3e-angular decorates Angular Material's own buttons. matExpButton goes on a matButton, legacy mat-*-button or matFab host, and matExpIconButton on a matIconButton/mat-icon-button host. The directives add a class (mat-exp-button, mat-exp-icon-button, mat-exp-fab), data-size/data-shape/data-state attributes, aria-pressed from their toggle input, and a click hook for a parent mat-exp-button-group. Everything visual is CSS.

Piece Role
expressiveButtons (Release flag, default off) The one switch. ExpressiveButtonsService (shared/m3e/) puts syrf-expressive-buttons on <html> while it is on, so the page and its CDK overlays switch together, live, without a reload.
global-styles/m3e-angular.scss Emits the library's button, icon button, FAB, split button and button group styles only under .syrf-expressive-buttons. With the flag off, a button carrying the directive renders exactly as the stock Material button (measured against main: zero computed-style differences on every button of four public pages, light and dark). The dialog surface stays global, so expressiveDialogs is independent.
Host marker A component whose buttons were Expressive before the flag (notifications Inbox, panel, cards, Email preferences, digest preview; conversations; the project needs-attention card) puts syrf-expressive-buttons on its own host and keeps that look whatever the flag says. The S12 Studies workspace and Searches page had markers in #4769; #4815 removed them, so their look follows the flag.
testing/m3e-button-inventory.ts Finds every Material button host in a template with Angular's own template parser (multi-line tags, control flow, *ngIf, bound [matButton]). Shared by the codemod and the guard.
tools/m3e-buttons/migrate-buttons.ts The codemod: report prints the inventory, write <paths> adds the directives and the component imports. Idempotent. Tests: pnpm run test:m3e-buttons, part of pnpm run lint (CI).
shared/m3e/expressive-buttons-coverage.spec.ts CI guard: every Material button has its directive, except in templates listed in unmigrated-buttons.allowlist.json (shrink-only, each row names its slice) and justified exceptions. It also fails an m3e host that binds aria-pressed directly.

M3 button guidance

The M3 all buttons guidance decides how buttons are used, both in new code (rules in the web CLAUDE.md) and in the gate:

Emphasis M3 component In SyRF
High: the screen's primary action, one per screen FAB, extended FAB, FAB menu; filled button; split button; standard button group matButton="filled" (or mat-flat-button); matFab for a page's main create action
Medium: important, not distracting tonal; outlined (secondary actions, changing your mind, escaping a flow); elevated only on a patterned background matButton="tonal", matButton="outlined"; no new elevated
Low: optional or supplementary text (also dialog actions, trailing-aligned); connected button group (switching views); icon button matButton="text", matIconButton; connected groups per #4817

Applied in the gate, under the flag only (no markup change, so the flag-off look is untouched):

  • Elevated. M3 uses the elevated style "only when a button requires visual separation from a patterned background". SyRF has none, and mat-raised-button (63 buttons) mostly marks a screen's main action, so a raised button drops its shadow: with a colour variant it reads as filled in that colour, without one as tonal.
  • Colour roles. color="primary|accent|warn" maps to the M3 role pair on every appearance, so a warn button is error red whether filled, raised, outlined or text, and a primary button is the same primary everywhere. Measured: every variant clears 4.5:1 label/container in light and dark (light warn improves from 3.68:1 to 6.46:1).
  • Outlined boundary uses outline (3:1 or better), not the library's outline-variant.
  • Labels lose Material's forced capitals and letter-spacing (the library's label-large type).
  • Toggles take the library's selected shape and colour (round to square when selected, as M3 specifies).

Needing a per-screen decision rather than a mechanical change (tracked as follow-ups, audited in this PR's description): screens with more than one high-emphasis button, label copy written in capitals, split-button candidates (a main action next to its own menu trigger), toggle icons that should switch outlined/filled, and dialog action rows (owned by the dialog migration).

Behaviour the migration preserves

  • Click, submit, anchors, router links, menu triggers, tooltips, disabled, color, type: untouched; the directive is added next to the Material attribute and nothing else in the tag changes. matExpButton passes disabled through to Material's button. Menu triggers gain data-menu-open (keyboard: Enter opens, Escape closes and returns focus, checked in a browser).
  • Toggle state: the directives own aria-pressed (rendered from toggle, removed when toggle is unset), so a direct [attr.aria-pressed]="x" would be overwritten. The codemod rewrites it to [toggle]="(x) ? 'selected' : 'unselected'"; the guard keeps it that way. Standalone buttons never flip toggle themselves: the component's own click handler still owns the state.
  • Loading, permissions: spinners inside buttons and *ngIf/@if permission gates are template structure around or inside the host and are unchanged.
  • Reduced motion: m3e-angular 0.2.0-alpha.9 removes transitions and animations on buttons, icon buttons and FABs under prefers-reduced-motion: reduce (pinned by m3e-buttons-gate.spec.ts; checked in a browser).
  • Control boundaries: the gate draws outlined buttons' outline in --mat-sys-outline (3.84:1 or more on every surface, light and dark), not the library's outline-variant (1.46:1 to 2.21:1), as WCAG 1.4.11 and the M3 programme require.
  • Theme and density: colours come from --mat-sys-* roles in both the light and .global-dark-theme layers; the library's sizes replace Material's density on migrated buttons only while the flag is on.

Slices

Slice Scope (src/app/...) PR
Foundation dependency, flag, gate, codemod, guard; shared/, core/, the app shell, notifications/, study-attention/, study-presence/ #4769
Studies and Searches studies/, project/project-admin/systematic-searches/, project/study-management/: S12's per-component m3x buttons move onto expressiveButtons; the components keep only phone targets, SyRF's focus ring (m3x.button-accessibility) and their own colour roles #4815
Project the rest of project/ #4811
Stage and review stage/, screening/, pdf-viewer/, study-source-window/ #4812
Admin admin/ #4813
Account and public manage/, auth/, info/, project-index/ #4814

Run pnpm exec tsx tools/m3e-buttons/migrate-buttons.ts write <paths> (from src/services/web), remove the slice's rows from the allow-list, and run the slice's specs, pnpm run build and the guard.

Exceptions

  • Mini FAB (manage/profile/profile.component.html, profile picture): m3e-angular 0.2.0-alpha.9 decorates MatButton and MatFabButton only; MatMiniFabButton extends MatButtonBase directly, so matExpButton there cannot resolve its host and throws. M3 Expressive has no small FAB; replacing it is a design decision (follow-up).
  • Button toggle groups (mat-button-toggle-group): behind expressiveButtons (owner, 11 November 2026). With the flag on, the pick-one controls are mat-exp-button-group with the chosen option in primary container and a tick (owner decisions, 10 November 2026; #4901); with it off, each renders its old control unchanged, so the m3x.connected-button-group mixin stays for those off branches. The developer debugger keeps its legacy toggle.
  • Tab links (mat-tab-link, also when they carry mat-button, as the stage page's do): tabs, not action buttons; the inventory skips them (#4812).
  • Menu items (mat-menu-item) and Bootstrap .btn buttons are outside the button family (menus have their own m3e family; the Bootstrap layer belongs to the M3 programme).

Flag decision

Flagged (expressiveButtons, Release, default off everywhere). It changes the look of every button in the app, the M3 programme rules out early production drift (technical plan), and a kill switch is wanted while the library is pre-release. There is no data, so on, off, on is a pure restyle; expressive-buttons.service.spec.ts covers it.