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 iserrorred 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'soutline-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.matExpButtonpassesdisabledthrough to Material's button. Menu triggers gaindata-menu-open(keyboard: Enter opens, Escape closes and returns focus, checked in a browser). - Toggle state: the directives own
aria-pressed(rendered fromtoggle, removed whentoggleis 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 fliptogglethemselves: the component's own click handler still owns the state. - Loading, permissions: spinners inside buttons and
*ngIf/@ifpermission 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 bym3e-buttons-gate.spec.ts; checked in a browser). - Theme and density: colours come from
--mat-sys-*roles in both the light and.global-dark-themelayers; 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 decoratesMatButtonandMatFabButtononly;MatMiniFabButtonextendsMatButtonBasedirectly, somatExpButtonthere 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'sm3x.connected-button-groupmixin; moving them tomat-exp-button-groupchanges keyboard and ARIA semantics and is a separate decision (follow-up). - Menu items (
mat-menu-item) and Bootstrap.btnbuttons 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.