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; the S12 Studies workspace and Searches page) puts syrf-expressive-buttons on its own host and keeps that look whatever the flag says. The Studies and Searches slice removes its markers.
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).
  • 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 stacked on #4769
Project the rest of project/ stacked
Stage and review stage/, screening/, pdf-viewer/, study-source-window/ stacked
Admin admin/ stacked
Account and public manage/, auth/, info/, project-index/ stacked

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): not buttons in Material's API. They keep their radio-group semantics and SyRF's m3x.connected-button-group mixin; moving them to mat-exp-button-group changes keyboard and ARIA semantics and is a separate decision (follow-up).
  • 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.