Skip to content

SyRF Design System Consolidation - Implementation Specification

Status: Draft - Pending Review (v23 - Storybook v10, TurboSnap warm-up/trigger, non-Bootstrap progress-bar cleanup, dialog button casing, AssignTab interface rename, FlexLayout audit wording) Date: 2026-02-07


Context

The SyRF Angular frontend (~198 standalone components, Angular Material v21, M2 theme API) has a solid foundation - comprehensive design tokens, well-structured theme mixins, and an M3 migration plan. However, adoption is the gap: tokens exist but hundreds of components still use hardcoded values, legacy Bootstrap classes, and inconsistent styling. This plan addresses that gap through systematic cleanup, component consolidation, and new infrastructure (Storybook + Chromatic) to prevent regression.

What already exists on this branch: _design-tokens.scss, theming-guide.md, material-3-migration-plan.md

Decisions made:

  • Cleanup first, M3 later
  • Delete custom app-select — confirmed dead code
  • Defer FlexLayout migration — document scope only
  • Merge 2 confirmation dialogs
  • Document annotation controls architecture
  • SCSS tokens for now
  • Preserve dark theme, don't invest
  • Tiered Storybook + Chromatic free tier
  • AI-assisted comprehensive pixel replacement
  • Remove dead MDC selectors

Phase 0: Baseline Audit (PR #0)

PR title: chore(web): add design-system baseline audit script and snapshot

Rationale: Establish measurable starting point so every subsequent PR can show concrete progress.

Tasks

  1. Create audit script at src/services/web/scripts/design-audit.sh with targeted patterns:
#!/usr/bin/env bash
set -euo pipefail
# Design System Audit — run from src/services/web/
SRC="src/app"

# Wrapper: suppress grep exit-1 (no match) but propagate exit-2 (error)
safe_grep() { grep "$@" || [[ $? == 1 ]]; }

echo "=== SyRF Design System Audit ($(date -I)) ==="
echo ""

# Colours: SCSS property values, catches multi-value (e.g. border: 1px solid #ddd)
echo -n "Hardcoded hex colours (SCSS values): "
safe_grep -rn --include='*.scss' -E ':\s*[^;]*#[0-9a-fA-F]{3,8}\b' "$SRC" \
  | safe_grep -vE ':[0-9]+:\s*//' | wc -l

echo -n "Hardcoded rgba values (SCSS values): "
safe_grep -rn --include='*.scss' -E ':\s*[^;]*rgba\(' "$SRC" \
  | safe_grep -vE ':[0-9]+:\s*//' | wc -l

# Spacing: only margin/padding/gap property values with px
echo -n "Hardcoded px in margin/padding/gap: "
safe_grep -rn --include='*.scss' -E '(margin|padding|gap)\s*(-\w+\s*)?:\s*[^;]*[0-9]+px' "$SRC" \
  | safe_grep -vE ':[0-9]+:\s*//' | wc -l

echo -n "Hardcoded font-size (px): "
safe_grep -rn --include='*.scss' -E 'font-size\s*:\s*[^;]*[0-9]+px' "$SRC" \
  | safe_grep -vE ':[0-9]+:\s*//' | wc -l

echo -n "Hardcoded border-radius (px): "
safe_grep -rn --include='*.scss' -E 'border-radius\s*(-\w+\s*)?:\s*[^;]*[0-9]+px' "$SRC" \
  | safe_grep -vE ':[0-9]+:\s*//' | wc -l

echo -n "Hardcoded box-shadow (px): "
safe_grep -rn --include='*.scss' -E 'box-shadow\s*:\s*[^;]*[0-9]+px' "$SRC" \
  | safe_grep -vE ':[0-9]+:\s*//' | safe_grep -v 'none' | wc -l

echo -n "Hardcoded z-index (numeric): "
safe_grep -rn --include='*.scss' -E 'z-index\s*:\s*[0-9]' "$SRC" \
  | safe_grep -vE ':[0-9]+:\s*//' | wc -l

echo -n "!important declarations: "
safe_grep -rn --include='*.scss' '!important' "$SRC" | wc -l

echo -n "Inline styles in templates: "
safe_grep -rn --include='*.html' 'style="' "$SRC" | wc -l

# Bootstrap: anchor inside class="..." attribute value to avoid matching
# non-class text (tooltips, prose, aria labels). The pattern class="([^"]*[ ])?
# ensures the token starts at either the opening quote or after a space within
# the attribute value. Avoids compound-class false matches (expansion-panel,
# progress-text, database-warning-btn) that plagued \b word boundaries.
echo -n "Bootstrap classes in class attributes: "
safe_grep -rn --include='*.html' --exclude='*.old*' \
  -E 'class="([^"]*[ ])?(btn[" -]|panel[" -]|progress-bar|progress[" ]|row[" ]|pull-(right|left)|clearfix[" ]|col-(xs|sm|md)-|alert[" -]|well[" ]|table[" ]|table-(responsive|striped|bordered|hover|condensed)|text-(center|left|right|muted|success|info|warning|primary|danger)[" ]|modal[" -]|close[" ])' "$SRC" \
  | safe_grep -v '<!--' | wc -l

# Bootstrap data-* attributes: Bootstrap JS relies on data-toggle, data-target,
# data-placement, data-dismiss. These are inert without Bootstrap JS but are dead
# code that should be replaced with Angular equivalents (matTooltip, (click), etc.)
echo -n "Bootstrap data-* attributes: "
safe_grep -rn --include='*.html' --exclude='*.old*' \
  -E 'data-(toggle|target|placement|dismiss)=' "$SRC" \
  | safe_grep -v '<!--' | wc -l

# Font Awesome: match FA base class in class attributes, or fontSet/fontIcon bindings
echo -n "Font Awesome references (html+ts): "
safe_grep -rn --include='*.html' --include='*.ts' --exclude='*.spec.ts' \
  -E "(class=\"[^\"]*\bfa\b|fontSet.*['\"]fa|fontIcon.*fa-)" "$SRC" \
  | safe_grep -v '<!--' | safe_grep -vE ':[0-9]+:\s*//' | wc -l

echo -n "FlexLayout directive usages: "
safe_grep -rn --include='*.html' -E '\b(fx[A-Z][A-Za-z0-9.-]*|gd[A-Z][A-Za-z0-9.-]*)' "$SRC" \
  | safe_grep -v '<!--' \
  | safe_grep -oE '\b(fx[A-Z][A-Za-z0-9.-]*|gd[A-Z][A-Za-z0-9.-]*)' | wc -l

echo -n "::ng-deep usage: "
safe_grep -rn --include='*.scss' '::ng-deep' "$SRC" | wc -l

echo -n "Components with OnPush: "
safe_grep -rln --include='*.component.ts' 'ChangeDetectionStrategy.OnPush' "$SRC" | wc -l

echo -n "Total components: "
find "$SRC" -name '*.component.ts' -not -name '*.spec.*' | wc -l

echo ""
echo "--- Global styles ---"
echo -n "Global !important declarations: "
safe_grep -rn --include='*.scss' '!important' "src/global-styles" | wc -l

echo -n "Global dead MDC structural selectors: "
safe_grep -rn --include='*.scss' \
  -E '\.(mat-form-field|mat-input|mat-button|mat-checkbox)-(wrapper|flex|infix|underline|ripple|layout|subscript-wrapper)' \
  "src/global-styles" | safe_grep -vE ':[0-9]+:\s*//' | wc -l

echo -n "Global MDC state selectors (verify at runtime): "
safe_grep -rn --include='*.scss' \
  -E '\.mat-form-field-(disabled|invalid)' \
  "src/global-styles" | safe_grep -vE ':[0-9]+:\s*//' | wc -l

Design choices: set -euo pipefail with safe_grep wrapper — safe_grep() { grep "$@" || [[ $? == 1 ]]; } suppresses grep's exit-1 (no match) but propagates exit-2 (real errors), so pipefail catches genuine pipeline failures without aborting when a count reaches zero. Every grep in a pipeline uses safe_grep, including grep -v filter stages (which also return exit-1 when all input lines match the exclusion). Bootstrap detection uses class="([^"]*[ ])? anchor — this requires the token to appear inside a class="..." attribute value, starting either right after the opening quote or after a space. Previous iterations used \b word boundaries (falsely matched compound classes like expansion-panel\bpanel\b) and (class="|[ ]) prefix (the [ ] branch leaked to match any space on the line — e.g., matTooltip="Save progress" matched via progress, prose "header row" matched via row). The class="([^"]*[ ])? anchor eliminates both classes of false positives. The table token is further narrowed to table[" ]|table-(responsive|striped|bordered|hover|condensed) — the broad table[" -] pattern false-matched app-local classes like table-menu and table-cell (v14 fix). The anchor covers class="..." (double-quoted) only — this is the Angular template convention; the one single-quoted occurrence in the codebase is inside a comment block deleted in Phase 1 (v16 note). The text- token covers both alignment utilities (text-center, text-left, text-right, text-muted) and semantic color classes (text-success, text-info, text-warning, text-primary, text-danger) — the latter appear in 11 places across auth and project wizard components (v15 fix). Bootstrap data-attribute detection (data-(toggle|target|placement|dismiss)=) catches Bootstrap JS attributes that are inert without the Bootstrap JS bundle. In the codebase: annotation-question-designer.component.html has 3× data-toggle="tooltip" + 3× data-placement="right" (replace with matTooltip), and systematic-searches.component.html has data-toggle="modal" + data-target="#systematicSearchModal" (dead — the button already uses (click) to open a Material dialog). These are separate from Bootstrap CSS classes and not caught by the class-attribute grep (v22 fix). Hex/rgba patterns use :\s*[^;]* for multi-value properties. Font Awesome uses \bfa\b to avoid matching GUIDs/data strings. Comment filters (grep -v '<!--') are single-line only — counts may include a few commented-out lines; the verification step requires deleting all commented-out markup. SCSS/TS comment filter uses grep -vE ':[0-9]+:\s*//' — the :[0-9]+: prefix matches the path:line: output of grep -rn, then \s*// matches lines where the actual content starts with optional whitespace then //. Previous iterations used ^\s*// which anchored to the start of the full grep -rn output line (e.g., src/app/foo.scss:42: // comment), so ^\s* never matched because lines start with a file path, not whitespace — causing the filter to be a no-op (v18 fix; user-validated: spacing count 556→541, hex 268→263, z-index 24→21). The v15 \s// filter had a different bug: it incorrectly removed lines with trailing inline comments (e.g., margin: 8px; // hardcoded) and missed comments with no leading whitespace (v16 fix, then superseded by v18). FlexLayout metric uses a two-stage pipeline to count individual directive tokens rather than lines — a single template line can have multiple directives (e.g., <div fxLayout="row" fxLayoutAlign="center center">), so the token count (~930) is higher than the line count (~751) (v19 fix). Stage 1: grep -rn produces full lines with file/line context, piped through grep -v '<!--' to exclude HTML comment lines. Stage 2: grep -oE extracts individual tokens from the filtered lines (the -o flag outputs each match on its own line, giving an accurate per-token count). Previous versions used a single-stage grep -roh (output only matching parts, suppress filenames) which couldn't filter comments because the output contained only tokens with no line context (v21 fix). The character class [A-Za-z0-9.-]* after the initial fx[A-Z]/gd[A-Z] match captures the full directive name including responsive breakpoint suffixes with hyphens (e.g., gdAreas.gt-sm, fxHide.lt-md). Previous versions used [a-zA-Z.]* which stopped at the hyphen, truncating gdAreas.gt-sm to gdAreas.gt — the count was unaffected (the truncated token still matches once) but token fidelity was incorrect (v20 fix). The two-stage approach excludes FlexLayout directives inside HTML comments (e.g., app.component.html, deactivate-account.component.html) which inflated the raw count by ~13 tokens. MDC audit is split into two metrics: 'structural selectors' (definitely dead DOM classes — wrapper, flex, infix, underline, ripple, layout, subscript-wrapper) and 'state selectors' (disabled, invalid — may be live as host state classes; see Phase 2 Tier classification). Both MDC metrics include the :[0-9]+:\s*// comment filter. Previous versions used a single metric that included both types and had no comment filter (v19 fix).

  1. Run audit and save baseline to docs/features/design-system-baseline.md with timestamped counts. Include YAML front matter: doc-type: "Reference", status: "Draft", author: "Claude", created: "YYYY-MM-DD", updated: "YYYY-MM-DD", zenhub-ticket: "N/A"

  2. Add npm script: "audit:design": "bash scripts/design-audit.sh"

Verification

  • Script runs from src/services/web/ and produces consistent counts
  • Manual spot-check: verify a few lines of each count match expectations
  • Baseline document committed for future comparison

Phase 1: Dead Code & Icon Cleanup (PR #1)

PR title: chore(web): remove custom select, replace Font Awesome icons, remove Bootstrap assets

Tasks

  1. Delete custom select component (confirmed dead code - both imports are unused):

Verified dead imports: - annotation-experiment-question.component.ts:38 imports SelectComponent — used only as a @ViewChild('cohortSelect') type (line 100-101) but no <app-select> element exists in the template. Dead reference. - annotation-form.service.ts:74 imports Option class — never used anywhere in the service body.

Steps: a. Remove the dead import { SelectComponent } from annotation-experiment-question.component.ts and the unused @ViewChild declaration. b. Remove the dead import { Option } from annotation-form.service.ts. c. Delete entire src/services/web/src/app/shared/form-controls/select/ directory (10 files). d. Verify: ng build succeeds, grep -rn 'form-controls/select/' src/ returns zero matches (trailing slash avoids false-matching select-all/ and select-multiple-trigger/).

  1. Remove Font Awesome CDN import from src/services/web/src/global-styles/styles.scss (line ~22):

    // DELETE THIS LINE:
    @import url('https://maxcdn.bootstrapcdn.com/font-awesome/4.6.3/css/font-awesome.min.css');
    

  2. Replace all Font Awesome icons — three usage patterns:

Pattern A: Active <i class="fa fa-*"> tags (6 instances):

File (relative to src/app/) FA Icon Material Icon
shared/annotation/annotation-form/outcome-data/outcome-data.component.html fa-pencil-square-o edit
project/project-admin/annotation-question-designer/annotation-question-designer.component.html (3x) fa-plus add
project/project-admin/annotation-question-designer/question-details/question-details.component.html:18 fa-times close
stage/stage-review/stage-review.component.html:136 fa-book menu_book

Commented-out FA markup (delete, don't replace): - question-details.component.html:13fa-pencil (inside HTML comment) - project/project-overview/risk-of-bias-panel/risk-of-bias-panel.component.html:1-26 — entire section with fa-gears is commented out

Pattern B: [fontSet]/[fontIcon] property bindings (3 templates) — data sourced from categoryUiTheme config:

Template (relative to src/app/) Lines
shared/annotation/annotation-form/annotation-form.component.html 264-265
project/project-admin/question-management/assign/stage-assign/stage-assign.component.html 157-158
project/project-admin/question-management/design/design.component.html 21-22

Replace [fontSet]/[fontIcon] with a simple mat-icon name binding. E.g.:

<!-- BEFORE -->
<mat-icon [fontSet]="aq.fontSet" [fontIcon]="aq.fontIcon"></mat-icon>
<!-- AFTER -->
<mat-icon>{{ aq.icon }}</mat-icon>

Pattern C: categoryUiTheme config in core/services/config/config.service.ts (lines 7-100): - 8 categories define fontSet: 'fa' and fontIcon: 'fa-*' - Also fix trailing-space bugs: fontSet: 'fa ' on lines 66, 76 - Replace fontSet/fontIcon with a single icon property using Material icon names:

Category Current fontIcon Material Icon
Study fa-file-text description
Disease Model Induction fa-eyedropper colorize
Treatment fa-medkit medical_services
Outcome Assessment fa-eye visibility
Cohort fa-paw pets
Animal Husbandry fa-paw pets
Risk of Bias (empty) (leave as empty string — no icon)
Experiment fa-flask science
  • Update inline type on categoryUiTheme object (no separate interface exists) to replace fontSet: string | null/fontIcon: string | null with icon: string | null
  • "Risk of Bias" has fontIcon: '' and "Hidden" has fontIcon: null — preserve as icon: '' / icon: null respectively
  • Update template bindings: wrap <mat-icon> in @if (aq.icon) guard (annotation-form currently has no null guard; stage-assign and design use ?? '' fallbacks that should be replaced with the @if guard)
  • Update all TS code that reads fontSet/fontIcon from categoryUiTheme:
    • core/services/config/config.service.ts:12-13 — type definition: fontSet: string | null; fontIcon: string | null;icon: string | null
    • core/services/config/config.service.ts:21-98 — all 9 category entries: replace fontSet/fontIcon with icon
    • shared/annotation/annotation-form/annotation-form.component.ts:228-229 — local interface: fontIcon: string | null; fontSet: string | null;icon: string | null
    • core/services/annotation-form/annotation-form.service.ts:342-343 — reads .fontIcon/.fontSet → change to .icon
    • project/project-admin/question-management/assign/assign.store.ts:759-761 — reads .fontSet/.fontIcon to build titleFontSet/titleFontIcon → change to titleIcon using .icon
    • project/project-admin/question-management/assign/assign.store.ts:143-144AssignTab interface type definition: titleFontSet: string; titleFontIcon: string;titleIcon: string
  • Note: app.component.ts:253 references fontSetClass but this is a Material icon registry filter unrelated to FA/categoryUiTheme — no change needed

  • Delete unused Bootstrap asset bundle if present at src/services/web/src/assets/bootstrap/

Verification

  • ng build --configuration=production succeeds
  • ng test --no-watch passes
  • Font Awesome zero-match checks (two separate greps to avoid fa-[a-z] false-matching GUIDs/data strings in TS):
# HTML: FA base class in class attributes + Angular property bindings
grep -rn --include='*.html' --exclude='*.old*' \
  -E '(class="[^"]*\bfa\b|\[fontSet\]|\[fontIcon\])' src/app/ \
  | grep -v '<!--'
# TS: config/component fontSet/fontIcon properties
grep -rn --include='*.ts' --exclude='*.spec.ts' \
  -E "(fontSet.*['\"]fa|fontIcon.*fa-)" src/app/ \
  | grep -vE ':[0-9]+:\s*//'

Both should return zero matches. If non-zero, manually verify matches are not inside multiline HTML comments or other false positives. - Visual spot-check: annotation form, question designer, stage review, category icons in annotation tree and design tabs


Phase 2: Dead MDC Selectors (PR #2)

PR title: chore(web): remove inert pre-MDC selector overrides from global styles

Tasks

Tier 1: Definitely dead structural selectors — internal DOM classes that do not exist in MDC. Delete immediately:

  • .mat-form-field-wrapper (lines 446, 454, 480)
  • .mat-form-field-flex (lines 447, 476)
  • .mat-form-field-infix (lines 441, 448, 472)
  • .mat-form-field-underline (line 456)
  • .mat-form-field-ripple (lines 462, 488, 495, 502, 509)
  • .mat-form-field-subscript-wrapper (lines 461, 466)
  • .mat-input-wrapper (lines 513, 517)
  • .mat-input-underline (line 513)
  • .mat-button-wrapper (line 195 — already marked // DEPRECATED)
  • .mat-checkbox-layout (line 211 — already marked // DEPRECATED)

Tier 2: Rule blocks mixing valid MDC hosts with dead structural descendants — the outer host selector (.mat-mdc-form-field) and host state classes (.mat-form-field-invalid, .mat-focused) are still applied by MDC Angular Material, but the inner descendant selectors target pre-MDC DOM that no longer exists, making the entire rule blocks inert. Confirm at runtime with DevTools (inspect app-input-array nested form fields), then delete:

  • Lines 441-470: app-create-question div.mat-form-field-infix, .options > .mat-form-field-wrapper > ..., .mat-mdc-form-field .mat-form-field-wrapper { ... } — all target dead structural descendants
  • Lines 472-483: app-input-array .mat-form-field-infix, app-input-array div.mat-form-field-flex, app-input-array .mat-form-field-wrapper — dead structural classes
  • Lines 485-511: .mat-mdc-form-field.mat-focused app-input-array .mat-form-field:not(...) .mat-form-field-ripple (×4 rule blocks) — outer MDC host + state class is valid, but inner .mat-form-field (non-MDC host class, should be .mat-mdc-form-field) and .mat-form-field-ripple (dead structural) make these inert
  • Lines 513-519: .mat-input-wrapper > .mat-input-underline, .input-array .mat-input-wrapper — dead mat-input-* classes

NOT dead — keep:

  • Line 138: mat-form-field.radio-form-field { &:not(.mat-form-field-disabled) { ... } }.mat-form-field-disabled is a host state class still applied by MDC to the <mat-form-field> element. This rule uses the element selector + state class with no dead structural descendants, so it IS live.

Verification

  • ng build succeeds
  • Tier 1: delete and verify — no visual change expected since DOM classes don't exist
  • Tier 2: before deleting, open DevTools on app-input-array (annotation form) and app-create-question (question designer), confirm that none of the Tier 2 selectors match any elements in the current MDC DOM. Then delete and verify:
  • Annotation form: input-array fields (add/remove rows, validation states)
  • Create-question form: field layout and spacing
  • Buttons with loading spinners (.mat-loading section)
  • Checkboxes (.mat-checkbox-layout replacement)
  • If any layout shift, add targeted fix using current .mat-mdc-* selectors

Phase 3: Storybook + Chromatic Setup (PR #3)

PR title: feat(web): set up Storybook with Chromatic CI and Tier 1 stories

Rationale: Moved earlier (was Phase 6/7) and merged Storybook + Chromatic into one PR to provide automated visual regression safety net BEFORE the large Bootstrap removal and token adoption sweeps.

Tasks

  1. Install (run from src/services/web/): npx storybook@latest init --type angular — resolves to Storybook 10.x (current latest: 10.2.7), which supports Angular 21 with the Vite builder natively. The repo uses Angular 21 (@angular/core: ^21.0.0) which defaults to Vite/Vitest — Storybook 10's @storybook/angular uses @storybook/builder-vite and is compatible. Do NOT use @8 — Storybook 8's @storybook/angular lists Angular >= 18.0 < 21.0 as its peer dependency range and will fail on Angular 21. Pin the resolved version in package.json for reproducibility.
  2. Configure .storybook/:
  3. Import global styles and theme
  4. Set up Material decorators
  5. Configure path aliases
  6. Chromatic setup: Add chromatic npm package, add GitHub Action for PRs touching src/services/web/
  7. TurboSnap strategy — TurboSnap uses Vite dependency graphs to snapshot only stories whose dependencies changed, reducing snapshot count from ~195 (all stories) to typically 5-30 per run. This is critical for staying under the free tier (5,000 snapshots/month). However, TurboSnap has prerequisites and trigger constraints:

    Warm-up period: Chromatic requires 10 successful CI builds on the project before TurboSnap is unlocked. During this warm-up, all stories are captured on every build. Plan for ~10 initial builds × ~25 Tier 1 stories = ~250 snapshots consumed before TurboSnap activates. (Tier 2 stories from PR #10 should NOT be added until after TurboSnap is active — adding ~170 stories during warm-up would burn ~1,700 snapshots.)

    CI trigger: Use push event trigger (not pull_request). TurboSnap compares the current commit against the previous commit on the same branch — with push, the base commit is the previous push to that branch, which Chromatic can resolve. With pull_request, the base is a synthetic merge commit that Chromatic may not have a baseline for, causing full rebuilds that defeat TurboSnap. Configure the GitHub Action:

    on:
      push:
        branches: ['main', 'claude/**']
        paths: ['src/services/web/**']
    

    Activation: Set onlyChanged: true in chromatic.config.json from the start — Chromatic ignores this flag during the warm-up period and enables it automatically after 10 builds. No manual switch needed.

    Lockfile requirement: TurboSnap requires package-lock.json to be committed (already the case in this repo).

  8. Fallback: if TurboSnap produces unreliable dependency detection (e.g., false negatives missing changed stories), use --only-story-files to limit snapshots to stories in changed directories as a simpler alternative

  9. Tier 1 stories (~25 hand-crafted, deep stories):

Dialogs: ConfirmationDialog, InfoConfirmationDialog (pre-merge), ErrorDialog, DeleteModal, PermissionsDialog, VersionInfoDialog

Form Controls: AutocompleteChiplist, Checklist, InputArray, RadioGroup, SelectAll, TimepointArray

UI Components: InformationBox, ChipLabel, ChipInput, AvatarIcon, EditableTextDisplay, SearchBar, Page, SideNav, PartitionFilter

Core: EnvironmentBanner, EnvironmentChip

  1. Add scripts to package.json:
    "storybook": "storybook dev -p 6006",
    "build-storybook": "storybook build"
    

Verification

  • npm run storybook launches, all Tier 1 stories render correctly
  • npm run build-storybook succeeds
  • Chromatic captures baseline snapshots (CI uses CHROMATIC_PROJECT_TOKEN env var from GitHub Secrets — never pass token on command line)
  • TurboSnap warm-up: First 10 builds will capture all stories (full snapshots). Monitor Chromatic dashboard for "TurboSnap enabled" status after build #10. During warm-up, only Tier 1 stories (~25) exist — total warm-up cost ~250 snapshots.
  • TurboSnap active: After build #10, verify with a test PR that only changed stories are captured (check Chromatic build log for "TurboSnap enabled" and reduced snapshot count)
  • Trigger check: Verify CI uses push trigger (not pull_request) — check that Chromatic build logs show valid baseline resolution
  • Free tier usage within 5,000 snapshots/month (TurboSnap + push trigger is critical for staying under this limit)

Phase 4: Dialog Consolidation (PR #4)

PR title: refactor(web): merge confirmation and info-confirmation dialogs

Unified Interface

export interface IConfirmationDialogData<V = any> {
  title: string;

  // Content (choose one)
  contentHtml?: string;          // HTML via [innerHTML]
  text?: string;                 // Plain text
  listItems?: string[];          // Optional bullet list

  // Optional info/warning box
  informationBox?: { warning?: boolean; text: string; };

  // Button config — accept BOTH field names for backward compat
  okText?: string;               // From ConfirmationDialog (default: 'OK')
  actionText?: string;           // From InfoConfirmationDialog
  // Runtime: use actionText ?? okText ?? 'OK'
  hideCancel?: boolean;

  // Button styling
  affirmativeOkButton?: boolean; // Primary flat button
  negativeAction?: boolean;      // Warn colour
  finalAction?: boolean;         // Raised button

  // Third button
  thirdButton?: { text: string; value: V; };
}

Key: Both okText and actionText are accepted. The component template resolves the button label as actionText ?? okText ?? 'OK'. This means zero consumer changes for the button text field — all existing call sites work as-is.

Button text casing: The current InfoConfirmationDialog applies | uppercase pipe to action button text (template lines 36, 44: {{ data.actionText ?? 'OK' | uppercase }}), rendering e.g. "Remove" as "REMOVE". The current ConfirmationDialog does NOT uppercase — it renders {{ okText }} as-is. The unified dialog must apply | uppercase to all action/OK button text (matching InfoConfirmation's convention). This is the safer default: (a) ConfirmationDialog consumers pass okText values like 'OK', 'Yes', 'Delete' — uppercasing these is either a no-op (OK, YES) or consistent with Material Design guidance (DELETE); (b) InfoConfirmationDialog consumers already expect uppercase and would regress if casing were removed. The cancel button text remains un-uppercased (both current dialogs render cancel text as-is).

Close-Result Contract

The unified dialog must standardize the dialogRef.close() return values so consumers using .afterClosed().pipe(filter(Boolean)) or truthiness checks continue working:

Action Return Value Notes
OK / Action button true Consistent across both current dialogs
Cancel button null ConfirmationDialog uses [mat-dialog-close]="null"; 3 consumers use case null strict equality
Backdrop click / Escape undefined Default MatDialog behavior; 2 strict-switch consumers throw on unknown values (see step 2b below); 1 silently treats as "save"
Third button thirdButton.value Generic <V> — consumer decides type

Pre-merge audit: Before rewriting, verify exact close return values. Current state: ConfirmationDialog cancel returns null ([mat-dialog-close]="null"), InfoConfirmationDialog cancel returns false ([mat-dialog-close]="false"). Three consumers use strict switch(dialogResult): review-guard.service.ts, stage-admin.component.ts, screening-settings.component.ts. Of these, 2 throw on unknown values (review-guard line 64: default: throw Error(...), stage-admin line 113: default: throw Error(...)) and 1 silently falls through (screening-settings line 275: default: return true — backdrop/Escape returns undefined, which hits default and silently acts as "save confirmed"). The unified dialog must return null for cancel (matching ConfirmationDialog), and the 10 InfoConfirmationDialog consumers (which check for truthiness, not === false) will work unchanged. Also fix: screening-settings.component.ts has fall-through between case agreementModeDirty: and case criteriaDirty: with no break/return — clean up during migration. All consumers using .afterClosed().pipe(filter(Boolean)) or truthiness checks work correctly with both null and false being falsy.

Migration Steps

  1. Rewrite src/services/web/src/app/shared/dialogs/confirmation-dialog/ to support all features from both dialogs:
  2. Add InformationBoxComponent import
  3. Support text, listItems, informationBox alongside existing contentHtml
  4. Resolve button label: actionText ?? okText ?? 'OK'
  5. Use @if control flow, ChangeDetectionStrategy.OnPush, inject()
  6. Use design tokens for all styling
  7. Auto-disable backdrop/Escape when cancel is hidden: In the component constructor or ngOnInit, if data.hideCancel is true, set this.dialogRef.disableClose = true. This prevents users from dismissing the dialog via backdrop click or Escape when no cancel button is visible — without this, hideCancel creates confusing UX where the only visible action is "OK" but the dialog can still be dismissed invisibly. This covers all 6 existing hideCancel: true consumers without requiring individual disableClose: true at each call site:

    • project-index/project-index.component.ts:518 — "Join Request Sent" (info acknowledgement)
    • stage/stage-review/stage-review.component.ts:328,339,350 — pool switch status dialogs (3 variants: limit reached, no saved sessions, no new studies)
    • core/services/signal-r/signal-r.service.ts:265 — "Update Required" (must acknowledge to trigger page refresh; without disableClose, user can escape and keep using stale version)
    • shared/annotation/annotation-form/annotation-form.component.ts:727 — validation errors (navigate to first error)
  8. Export compatibility alias:

    /** @deprecated Use IConfirmationDialogData */
    export type IInfoConfirmationDialogData = IConfirmationDialogData;
    

2b. Add disableClose: true to all 3 strict-switch consumers. Without this, backdrop click or Escape returns undefined, which is unhandled by any case branch:

  • core/services/stage/review-guard.service.tsdefault: throw Error(...) — crashes at runtime
  • stage/stage-admin/stage-admin.component.tsdefault: throw Error(...) — crashes at runtime
  • screening/screening-settings/screening-settings.component.tsdefault: return true — silently treats backdrop dismiss as "save confirmed" (incorrect UX, should cancel)

Adding disableClose: true forces dismissal via the Cancel or OK buttons only, which return null or true respectively — both handled by explicit case branches. This prevents crashes in the 2 throwers and prevents silent mis-saves in screening-settings.

  1. Update all 17 production consumer files to import from the unified dialog:

ConfirmationDialog consumers (7 — use okText): - shared/annotation/annotation-form/annotation-form.component.ts - shared/annotation/annotation-form/annotation-unit/annotation-unit.component.ts - screening/screening-settings/screening-settings.component.ts - stage/stage-admin/stage-admin.component.ts - project/project-admin/annotation-question-designer/question-details/question-details.component.ts - info/contact-us/contact-us.component.ts - core/services/stage/review-guard.service.ts

InfoConfirmationDialog consumers (10 — use actionText): - stage/stage-review/stage-review.component.ts - project/project-admin/project-members/bulk-confirmation-dialog/bulk-confirmation-dialog.component.ts - project/project-admin/project-members/edit-entity-dialog/edit-entity-dialog.component.ts - project/project-admin/project-members/invite-members-dialog/invite-members-dialog.component.ts - project/project-admin/project-members/project-members.component.ts - project-index/project-index.component.ts - project-index/dialogs/join-project-dialog/join-project-dialog.component.ts - manage/profile/profile.component.ts - core/services/signal-r/signal-r.service.ts - project/project-admin/question-management/design/question-node/question-node.component.ts

3c. Update dialog result type annotations on InfoConfirmationDialog consumers that explicitly type the result as boolean via the R generic on MatDialog.open<Component, Data, R>(). Since cancel now returns null instead of false, these must be widened to boolean | null (or the explicit R generic removed to let TypeScript infer the correct union type). All 9 consumers that open InfoConfirmationDialogComponent directly have explicit boolean typing:

  • stage/stage-review/stage-review.component.ts (line 357 — 1 call)
  • project/project-admin/question-management/design/question-node/question-node.component.ts (lines 161, 183 — 2 calls)
  • project/project-admin/project-members/bulk-confirmation-dialog/bulk-confirmation-dialog.component.ts (1 call)
  • project/project-admin/project-members/edit-entity-dialog/edit-entity-dialog.component.ts (1 call)
  • project/project-admin/project-members/invite-members-dialog/invite-members-dialog.component.ts (1 call)
  • project-index/dialogs/join-project-dialog/join-project-dialog.component.ts (1 call)
  • project-index/project-index.component.ts (lines 511-514 — 1 call)
  • manage/profile/profile.component.ts (lines 161, 182 — 2 calls)
  • core/services/signal-r/signal-r.service.ts (line 255 — 1 call)

Total: 11 open<..., boolean>() calls across 9 files. Files with multiple calls (profile.component.ts, question-node.component.ts) must have ALL calls updated, not just the first.

The 10th consumer (project/project-admin/project-members/project-members.component.ts) imports IInfoConfirmationDialogData for data construction but does not open InfoConfirmationDialogComponent directly — it delegates to BulkConfirmationDialogComponent and EditEntityDialogComponent, so no type change is needed there.

Verification: after updating imports and types, ng build must pass with strict type checking. Search for remaining boolean generics on the unified dialog using a multiline-aware pattern (the TypeScript generics span multiple lines):

rg -U --type ts --glob '!*.spec.ts' \
  'ConfirmationDialogComponent,\s+I\w+DialogData,\s+boolean\s*>' src/app/
Should return zero matches (all 11 calls across 9 files should use boolean | null or omit the R generic). The pattern uses I\w+DialogData instead of the exact type name to catch both IConfirmationDialogData (standard) and IInfoConfirmationDialogData (deprecated alias) — consumers that import the alias during incremental migration would otherwise be missed. The boolean\s*> anchor ensures only bare boolean result types match, not boolean | null>. The -U flag enables multiline matching so \s+ spans the newlines between the three generic type parameters. A single-line grep would miss these because ConfirmationDialogComponent and boolean are on different lines (v21 fix; pattern broadened in v22 fix).

  1. Delete src/services/web/src/app/shared/dialogs/info-confirmation-dialog/ entirely

  2. Remove info-confirmation-dialog theme mixin registration from syrf-theme.scss (if present)

  3. Update Storybook: Replace pre-merge dialog stories with unified ConfirmationDialog stories covering all input combinations

Verification

  • ng build + ng test
  • Storybook: unified dialog stories render correctly
  • Chromatic: no visual regressions
  • Manually test: delete project, join project, leave review, bulk member ops, signal-r update prompt, screening settings save, question node delete/operations, project members actions

Phase 5: Bootstrap Removal (PR #5)

PR title: refactor(web): replace Bootstrap classes with Material components and delete legacy-bootstrap

Strategy - Complete Mapping

Buttons:

Bootstrap Class Material Replacement
btn btn-primary mat-raised-button color="primary"
btn btn-success mat-raised-button + .mat-color-success utility using tokens.$color-success
btn btn-danger mat-raised-button color="warn"
btn btn-warning mat-raised-button + .mat-color-warning utility using tokens.$color-warning
btn btn-info mat-raised-button + .mat-color-info utility using tokens.$color-info
btn btn-default mat-stroked-button
btn btn-link mat-button
btn-sm Custom .mat-dense utility (smaller padding/font-size via tokens)
btn-xs Custom .mat-compact utility (minimal padding/font-size via tokens)
btn-block CSS class .full-width { width: 100%; } in component SCSS (not inline style)

Dynamic classes (via ngClass / interpolation):

  • btn-{{theme}} patterns in templates — replace with [color] binding on Material buttons
  • [ngClass]="'btn-' + theme" patterns — refactor to Material color input
  • [ngClass]="{ panel: !cleanInterface }" in annotation-tree.component.html — replace with mat-card conditional or component-scoped CSS class
  • [ngClass]="{ 'form-group': condition }" in annotation-experiment-question.component.html — this is app-local styling (defined in styles.scss:133 and create-stage.component.scss), not Bootstrap debt; rename to a component-scoped class to avoid confusion with Bootstrap

Panels:

Bootstrap Class Material Replacement
panel mat-card
panel-heading mat-card-header
panel-body mat-card-content
panel-info / panel-{{theme}} mat-card + themed CSS class

Progress Bars:

The Bootstrap .progress / .progress-bar classes are used for stacked segmented bars with labels, tooltips, and click handlers — not simple linear progress. mat-progress-bar is a single-track linear bar and is NOT equivalent. The existing annotation-progress and review-progress components use segments for Completed/Incomplete/Remaining/Unavailable status, with per-segment [style.width.%], [matTooltip], (click) handlers, and dynamic [style.background-color]. review-progress additionally renders SVG funnel polygons and an expanded detail table.

Bootstrap Class Replacement
progress (container) Component SCSS: flexbox container with display: flex; height: tokens.$spacing-6; border-radius: tokens.$radius-sm; overflow: hidden;
progress-bar (segment) Component SCSS: flex segment with [style.width.%] binding, label <span>, and color token
progress-bar-success tokens.$color-success via [style.background-color] or component class
progress-bar-primary Theme primary via tokens.$color-primary-500
progress-bar-info tokens.$color-info
progress-bar-warning tokens.$color-warning

The replacement is purely CSS — the HTML structure (stacked <div> segments inside a container) stays the same. Only the Bootstrap class names are swapped for component-scoped SCSS classes using design tokens. review-progress already uses dynamic [style.background-color]="column.color" for its segments, so it only needs the .progress container class replaced.

Non-Bootstrap progress-bar class on <mat-progress-bar> elements: Three dialog files use class="progress-bar" on Angular Material's <mat-progress-bar> component — this is NOT Bootstrap usage but will be caught by the zero-match grep. No component SCSS targets this class; it is only styled by legacy-bootstrap/progress-bars.scss. After Phase 5 deletes that file, these become dead classes. Remove class="progress-bar" from all three during Phase 5 to pass the zero-match check:

  • project-index/dialogs/join-project-dialog/join-project-dialog.component.html:2<mat-progress-bar mode="indeterminate" color="primary" class="progress-bar" />
  • project/project-admin/project-members/bulk-confirmation-dialog/bulk-confirmation-dialog.component.html:2 — same pattern
  • project/project-admin/project-members/edit-entity-dialog/edit-entity-dialog.component.html:2 — same pattern

Grid & Utilities (assess scope before migrating):

Bootstrap Class Replacement
col-xs-*, col-sm-*, col-md-* CSS Grid or Flexbox (or leave if already handled by FlexLayout)
row CSS Flexbox container
pull-right, pull-left margin-left: auto / flexbox alignment
clearfix Remove (unnecessary with modern layout)

Alerts:

Bootstrap Class Material Replacement
alert, alert-* InformationBoxComponent with warning: true (2 instances: delete-search, delete-living-search)

Wells:

Bootstrap Class Material Replacement
well mat-card appearance="outlined" with muted background token

Tables:

Bootstrap Class Material Replacement
table Native <table> + component SCSS (keep mat-table for data tables)
table-responsive Component SCSS: overflow-x: auto wrapper
table-striped, table-bordered Component SCSS: :nth-child + border tokens

Text Utilities:

Bootstrap Class Material Replacement
text-center, text-left, text-right Component SCSS: text-align directly
text-muted tokens.$color-text-secondary
text-success tokens.$color-success
text-info tokens.$color-info
text-warning tokens.$color-warning
text-primary tokens.$color-primary-500
text-danger tokens.$color-error

Modals (residual Bootstrap classes inside Material dialogs):

Bootstrap Class Material Replacement
modal-header mat-dialog-title (directive, not class)
modal-body mat-dialog-content
modal-footer mat-dialog-actions
close (dismiss button) <button mat-icon-button><mat-icon>close</mat-icon></button>

Bootstrap data-* attributes (inert without Bootstrap JS — dead code):

Attribute Location Replacement
data-toggle="tooltip" + data-placement="right" (3×) annotation-question-designer.component.html:27-28, 48-49, 69-70 Replace with matTooltip + matTooltipPosition="right" (the title="..." attribute already holds the tooltip text)
data-toggle="modal" + data-target="#systematicSearchModal" systematic-searches.component.html:84-85 Delete — the button already uses (click)="openDeleteSearchDialog(search)" to open a Material dialog; these attributes are completely dead
data-dismiss="modal" create-search.component.old.html:72 In .old.html file — delete the whole file if it only contains dead markup

Note: Grid classes may overlap with FlexLayout directives. If a template uses both col-* and fxLayout, prioritise removing Bootstrap and keep FlexLayout (deferred to separate initiative).

Key Files to Update

  • Annotation components (annotation-form.component.html, annotation-tree.component.html, outcome-data.component.html, annotation-experiment-question.component.html)
  • Stage review components (screening.component.html, stage-review.component.html) — note: stage-review.component.html:129 has duplicate class attributes on the same element (class="col-xs-12" class="abstract preline"); only the second is applied by the browser, so migrating the first class away without fixing the duplicate would silently drop col-xs-12. Fix: merge into a single class attribute during migration.
  • Progress bars (annotation-progress.component.html ×2, review-progress.component.html, expanded-detail.component.html) — stacked segmented bars with labels/tooltips/click handlers; replace Bootstrap classes with component SCSS, keep HTML structure
  • Non-Bootstrap progress-bar cleanup (join-project-dialog.component.html, bulk-confirmation-dialog.component.html, edit-entity-dialog.component.html) — remove dead class="progress-bar" from <mat-progress-bar> elements (not Bootstrap usage, but will fail zero-match grep after legacy-bootstrap/progress-bars.scss is deleted)
  • Project overview panels (risk-of-bias-panel, stages-panel, register-panel)
  • Search deletion dialogs (delete-search.component.html, delete-living-search.component.html) — alerts, modal classes
  • Question designer (question-details.component.html) — modal-header, close button
  • Info pages (faq.component.html)
  • Any file with dynamic btn-{{theme}} or [ngClass] Bootstrap patterns
  • Auth/manage components with text-info/text-warning (password.component.html, external-account.component.html, complete-profile-info.component.html)
  • Project wizard with text-success (create-project-wizard.component.html)
  • Download button with text-warning (download-button.component.html)
  • Outcome data with text-primary (outcome-data.component.html)
  • Any file with alert, well, table, modal-*, text-*, or close Bootstrap classes

Final Cleanup

  1. Delete src/services/web/src/global-styles/legacy-bootstrap/buttons.scss
  2. Delete src/services/web/src/global-styles/legacy-bootstrap/panel.scss
  3. Delete src/services/web/src/global-styles/legacy-bootstrap/progress-bars.scss
  4. Remove imports from styles.scss
  5. Delete empty legacy-bootstrap/ directory

Verification

  • ng build + ng test
  • Static class grep — anchored inside class="..." attribute values, exclude comments and .old files:
    grep -rn --include='*.html' --exclude='*.old.*' \
      -E 'class="([^"]*[ ])?(btn[" -]|panel[" -]|progress-bar|progress[" ]|row[" ]|pull-(right|left)|clearfix[" ]|col-(xs|sm|md)-|alert[" -]|well[" ]|table[" ]|table-(responsive|striped|bordered|hover|condensed)|text-(center|left|right|muted|success|info|warning|primary|danger)[" ]|modal[" -]|close[" ])' src/app/ \
      | grep -v '<!--'
    
    Should return zero matches. Uses class="([^"]*[ ])? anchor — the token must appear inside a class="..." attribute value, starting either at the opening quote or after a space. This avoids: (a) compound-class false matches (expansion-panel, progress-text, database-warning-btn) that plagued \b, and (b) non-class-attribute matches (matTooltip="Save progress", prose "header row") that leaked through the earlier (class="|[ ]) prefix. Token set covers buttons, panels, progress bars, grid/utilities, alerts, wells, tables, text utilities (alignment + semantic color: text-success, text-info, text-warning, text-primary, text-danger), modals, and close buttons — the full Bootstrap 3 class inventory found in the codebase. New utility classes use mat-color-* / mat-dense / mat-compact prefixes, so they won't collide. Note: form-group is excluded from this check — it's app-local styling, not Bootstrap debt. The table token uses table[" ]|table-(responsive|striped|bordered|hover|condensed) instead of the broader table[" -] — the latter false-matched app-local classes like table-menu (study-table.component.html:69) and table-cell (stage-studies.component.html:209). The grep covers class="..." (double-quoted) attributes only — this is the Angular template convention. The one single-quoted class='...' occurrence in the codebase (question-details.component.html:13) is inside a comment block and will be deleted in Phase 1 (commented-out FA markup cleanup). If single-quoted class attributes are introduced in the future, add a class=' variant of the anchor.
  • Dynamic class bindings grep — three patterns for [class.token], quoted [ngClass], and unquoted [ngClass] keys:
    # Pattern 1: [class.btn-*], [class.panel-*], etc. property bindings
    grep -rn --include='*.html' --exclude='*.old.*' \
      -E '\[class\.(btn|panel|progress-bar|progress|row|pull-|clearfix|col-|alert|well|table\]|table-(responsive|striped|bordered|hover|condensed)|close\]|text-|modal)' src/app/ \
      | grep -v '<!--'
    # Pattern 2: ngClass with Bootstrap tokens as quoted string keys
    grep -rn --include='*.html' --include='*.ts' --exclude='*.spec.ts' --exclude='*.old.*' \
      -E "ngClass.*['\"](btn|panel|progress-bar|progress|row|pull-|clearfix|col-|alert|well|table['\" ]|table-(responsive|striped|bordered|hover|condensed)|close['\" ]|text-|modal)" src/app/ \
      | grep -v '<!--' | grep -vE ':[0-9]+:\s*//'
    # Pattern 3: ngClass with Bootstrap tokens as unquoted object keys
    grep -rn --include='*.html' --exclude='*.old.*' \
      -E "ngClass.*[{,]\s*(btn|panel|progress|row|clearfix|alert|well|modal)\s*:" src/app/ \
      | grep -v '<!--'
    
    All three should return zero matches. Pattern 1 catches [class.btn-primary]="expr" style bindings — the [class. prefix ensures only class property bindings match (not hidden-row, example-expanded-row). For table and close, Patterns 1 and 2 use boundary-aware variants (table\] / close\] in Pattern 1, table['\" ] / close['\" ] in Pattern 2) to recover recall while maintaining precision. Pattern 1's \] suffix requires the closing bracket immediately after the token — since Angular class bindings bind exactly one class, [class.table] is the only valid form. Pattern 2's ['\" ] suffix allows the token to be followed by a closing quote (end of string, e.g., 'table') OR a space (more classes follow, e.g., 'table text-center'), catching multi-class strings that the v15 quote-only anchor missed (v16 fix). Neither matches prefix-extensions like table-menu, table-cell, or closeDialog. The v14 approach of excluding table/close entirely from dynamic patterns was overly conservative — the boundary anchors prevent the prefix false-matches that motivated the exclusion ([class.table-menu] in study-table.component.html:69, [class.table-cell] in stage-studies.component.html:209, closeDialog($event) in project-setup.component.html:65). Table compound classes (table-responsive, table-striped, etc.) remain as explicit alternations for prefix matching. table and close remain excluded from Pattern 3 (unquoted object keys) — as bare words they are too generic in JS object key position and could match app-local variable names or config keys. Pattern 2 catches [ngClass]="{'btn-primary': expr}" style quoted string bindings — the ['"] character class requires the token to start immediately after a quote, ensuring it's a CSS class string literal, not a variable name. Previous iteration included space in [' "] which falsely matched variable names like row in { 'hidden-row': row.showLastActivity } via the space before the variable. Pattern 2 includes *.ts files (for TS-side ngClass usage) and filters both HTML comments (<!--) and TS comments (grep -vE ':[0-9]+:\s*//' — path-aware filter matching the path:line: prefix from grep -rn, then \s*// to identify lines where actual content is a full-line comment). Pattern 3 catches [ngClass]="{ panel: expr }" style unquoted object keys — the [{,]\s* prefix requires the token to appear right after { or , (object key position), and \s*: suffix confirms it's a key (not a value). Only single-word tokens (btn, panel, progress, row, clearfix, alert, well, modal) can be unquoted JS identifiers; hyphenated tokens (btn-primary, col-sm-6, pull-right, progress-bar, table-responsive, text-center, modal-header) are syntactically invalid as unquoted keys and must be quoted, so they're already caught by Pattern 2. Known match: annotation-tree.component.html uses { panel: !cleanInterface }. Interpolated patterns like class="btn-{{theme}}" are already caught by the static class grep above (the class="([^"]*[ ])? anchor matches class="btn directly). Excludes form-group — app-local, not Bootstrap (and contains a hyphen, so cannot be an unquoted key anyway).
  • SCSS selectors referencing Bootstrap classes — these become dead after template migration:
    grep -rn --include='*.scss' -E '\.(btn|btn-(primary|success|danger|warning|info|default|link|sm|xs|block)|panel|panel-(heading|body|info)|progress|progress-bar|progress-bar-(success|primary|info|warning)|alert|alert-(success|info|warning|danger)|well|table|table-(responsive|striped|bordered|hover|condensed)|row|col-(xs|sm|md)-[0-9]+|pull-(right|left)|clearfix|text-(center|left|right|muted|success|info|warning|primary|danger)|modal|modal-(header|body|footer)|close)($|[^a-zA-Z0-9_-])' src/app/ \
      | grep -vE ':[0-9]+:\s*//'
    
    Should return zero matches. Uses explicit class alternatives matching the same Bootstrap class inventory as the HTML template grep — buttons (.btn, .btn-primary, .btn-success, etc.), panels (.panel-heading, .panel-body, .panel-info), progress bars (.progress-bar-success, etc.), alerts, wells, tables (.table-responsive, etc.), grid (.col-xs-*, .col-sm-*, .col-md-*), utilities (.pull-right, .pull-left, .clearfix), text utilities (alignment + semantic color), modals (.modal-header, .modal-body, .modal-footer), and .close. The ($|[^a-zA-Z0-9_-]) boundary requires the class name to be followed by either end-of-line ($) or a non-CSS-identifier character (not a letter, digit, underscore, or hyphen), preventing false-matching app-local compound classes (e.g., .table-menu — the m after .table- is in [a-zA-Z], so it doesn't match) while also catching selectors at end-of-line (e.g., .btn as the last token on a line — the previous [^a-zA-Z0-9_-] required a trailing character, so end-of-line positions were missed; v20 fix). Previous iterations used prefix tokens with [^-a-z] boundary (e.g., col-, pull-, progress-bar followed by [^-a-z]), which failed to match compound Bootstrap classes: .col-xs-12 (next char x is [a-z]), .pull-right (next char r is [a-z]), .progress-bar-warning (next char - is [-]) — all were false negatives. The fix uses explicit alternatives for every Bootstrap compound class, eliminating the need for prefix matching entirely (v19 fix). Known pre-migration matches: project-overview.component.scss:68 (.btn), stages-panel.component.scss:1 (div.panel), library.component.scss:1 (.table), protocols.component.scss:1 (.table). After Bootstrap classes are removed from templates, these SCSS selectors target nothing — replace with component-scoped class names or delete if unused.
  • Bootstrap data-* attributes grep:
    grep -rn --include='*.html' --exclude='*.old*' \
      -E 'data-(toggle|target|placement|dismiss)=' src/app/ \
      | grep -v '<!--'
    
    Should return zero matches. Catches Bootstrap JS attributes (data-toggle, data-target, data-placement, data-dismiss) that are inert without the Bootstrap JS bundle. These are separate from Bootstrap CSS classes and are not caught by the class-attribute grep — previous plan versions missed these entirely (v22 fix).
  • Also delete or rename any .old.html files that contain only commented/dead Bootstrap markup
  • Chromatic: no visual regressions against Tier 1 baselines
  • Re-run npm run audit:design and compare Bootstrap count to baseline

Phase 6: Token Adoption (PRs #6-#9)

PR #6: style(web): replace hardcoded colours with design tokens

Replace all hardcoded hex/rgba values across component SCSS files (exact count established by Phase 0 baseline audit).

Mapping strategy:

Hardcoded Value Token
rgba(0,0,0,0.87) tokens.$color-text-primary
rgba(0,0,0,0.6) tokens.$color-text-secondary
rgba(0,0,0,0.38) tokens.$color-text-disabled
rgba(0,0,0,0.12) tokens.$color-divider
rgba(0,0,0,0.04) tokens.$color-surface-hover
#ffffff / white tokens.$color-surface
#fafafa tokens.$color-background
#f5f5f5 tokens.$color-surface-variant
#f2f2f2 tokens.$color-disabled-bg
#203457 tokens.$color-primary-500
#4caf50 / #5cb85c tokens.$color-success
#f44336 / #d9534f tokens.$color-error
#ff9800 tokens.$color-warning
#2196f3 / #5bc0de tokens.$color-info

Each file gets @use 'global-styles/design-tokens' as tokens; at the top.

If a value has no token match, add a new semantic token to _design-tokens.scss.

PR #7: style(web): replace hardcoded spacing with tokens -- shared and core

Replace pixel values in src/app/shared/ and src/app/core/ components.

Mapping (4px grid):

Pixel Token
4px tokens.$spacing-1
8px tokens.$spacing-2
12px tokens.$spacing-3
16px tokens.$spacing-4
20px tokens.$spacing-5
24px tokens.$spacing-6
32px tokens.$spacing-8
48px tokens.$spacing-12

Off-grid values (10px, 15px) — snap to nearest token or leave with // TODO: snap to 4px grid.

Also replace font-size — typography tokens and border-radius — radius tokens.

PR #8: style(web): replace hardcoded spacing with tokens -- domain components

Same as PR #7 but for src/app/project/, src/app/stage/, src/app/screening/, src/app/studies/, src/app/manage/, src/app/admin/, src/app/auth/, src/app/info/, src/app/about/.

PR #9: style(web): move inline styles to SCSS and remove !important declarations

  1. Move style="" attributes from HTML files into component SCSS using tokens (exact count from baseline audit)
  2. Remove/fix !important declarations (fix specificity with better selectors; exact count from baseline audit)
  3. Replace remaining hardcoded box-shadow, z-index values with tokens

Verification (all Phase 6 PRs)

  • ng build succeeds
  • Re-run npm run audit:design after each PR — counts should monotonically decrease
  • Chromatic: no visual regressions
  • Visual spot-check of highest-impact files

Phase 7: Tier 2 Stories (PR #10)

PR title: feat(web): add Tier 2 AI-generated stories for domain components

  1. Tier 2 stories (~170 AI-generated, shallow stories): render each domain component with reasonable mock data
  2. Chromatic baseline expands to cover full component catalogue

Verification

  • All new stories render in Storybook
  • Chromatic snapshot count within 5,000/month free tier

Phase 8: Modernisation (PRs #11-#12)

Rationale: Separated from style PRs to isolate risk. OnPush and control flow changes can introduce subtle runtime bugs and should not be mixed with pure-CSS changes.

PR #11: refactor(web): adopt OnPush change detection for shared components

Scope note: This is behaviour-changing work outside core design-system cleanup. Scope to shared components first; domain components are a stretch goal.

  • Add ChangeDetectionStrategy.OnPush to shared components not yet using it
  • Currently 20/~198 use OnPush — target: all ~52 shared components first
  • Each component must be tested to ensure no change-detection-dependent behaviour breaks
  • Domain components (remaining ~146) deferred to a follow-up PR if shared rollout is stable

PR #12: refactor(web): modernise templates with @if/@for control flow and inject()

  • Replace *ngIf / *ngFor with @if / @for block syntax
  • Replace constructor DI with inject() where applicable
  • Remove/replace ::ng-deep (12 instances) with :host selectors or component theming

Verification

  • ng build + ng test after each PR
  • Chromatic regression check

Phase 9: Documentation (PR #13)

PR title: docs: document annotation controls architecture and FlexLayout migration scope

Rationale: This PR depends only on Phases 0-6 (design-system cleanup and token adoption), NOT on Phases 7-8 (modernisation). The annotation controls investigation, FlexLayout scope documentation, and theming guide updates are all informed by the cleanup and token work. Phase 8 (OnPush, control flow, ::ng-deep) is behaviour-changing work that may take longer and should not gate documentation closure (v22 fix — previously this was sequenced after Phase 8, unnecessarily delaying design-system completion).

Tasks

  1. Create docs/architecture/annotation-controls.md:
  2. Include YAML front matter: doc-type: "Reference", status: "Approved", author: "Claude", created: "YYYY-MM-DD", updated: "YYYY-MM-DD", zenhub-ticket: "N/A", tags: [annotation, architecture, angular-material]
  3. Document the Adapter/Wrapper pattern
  4. Map each annotation control to its wrapped Material component
  5. Explain AbstractAnnotationControl, focus management, type coercion
  6. Conclusion: architecture is sound, no refactoring needed

  7. Create docs/features/flexlayout-migration-scope.md:

  8. Include YAML front matter: doc-type: "Feature", status: "Draft", priority: "Low", author: "Claude", created: "YYYY-MM-DD", updated: "YYYY-MM-DD", zenhub-ticket: "N/A", tags: [flexlayout, css, migration, frontend]
  9. Scope: ~930 directive usages across ~130 files (all fx* and gd* directives — exact count from Phase 0 audit FlexLayout directive usages metric, which uses grep -rn with comment filter then grep -oE with \b(fx[A-Z][A-Za-z0-9.-]*|gd[A-Z][A-Za-z0-9.-]*) pattern to count individual directive tokens including responsive suffixes like fxHide.lt-md and gdAreas.gt-sm, excluding directives inside HTML comments)
  10. Strategy: Replace fxLayout/fxFlex/fxLayoutAlign etc. with CSS Flexbox/Grid
  11. Testing approach: visual regression via Chromatic (now available)
  12. Priority: Low urgency (@ngbracket/ngx-layout v20 works on Angular 21)
  13. Recommend as separate multi-PR initiative

  14. Update docs/architecture/theming-guide.md — reflect completed token adoption

  15. Update docs/features/material-3-migration-plan.md — mark Bootstrap removal and prerequisites as complete
  16. Delete docs/planning/DESIGN-SYSTEM-Q&A.md — decisions incorporated into implementation PRs; temporary planning doc no longer needed
  17. Update CLAUDE.md — update Web Service section to reflect design token system, Storybook infrastructure, and any new npm scripts added during this initiative

Verification

  • Run ./docs/scripts/generate-indexes.sh to regenerate doc indexes
  • ./docs/scripts/validate-docs.sh --verbose passes (all new/updated docs have correct YAML front matter)

PR Dependency Graph

PR #0  Baseline audit
PR #1  Dead code & icon cleanup
PR #2  Dead MDC selectors
PR #3  Storybook + Chromatic setup      ← provides visual regression BEFORE major sweeps
PR #4  Dialog merge
PR #5  Bootstrap removal
  ├──→ PR #6  Colour tokens             PR #10  Tier 2 stories
  │      ↓
  │    PR #7  Spacing (shared/core)
  │      ↓
  │    PR #8  Spacing (domain)
  │      ↓
  │    PR #9  Inline styles + !important
  │      ↓
  │    PR #13 Documentation             ← depends on Phases 0-6 only, NOT on modernisation
  └──→ (PRs #6-9 and #10 can run in parallel)

PR #11 OnPush adoption       ← separated from style PRs, independent of docs
PR #12 Template modernisation

Verification Summary

PR Build Tests Visual / Audit Check
#0 Baseline ng build Script runs Baseline counts captured
#1 Dead code + icons ng build ng test Zero FA grep matches, category icons work
#2 MDC selectors ng build ng test Forms, checkboxes, loading buttons
#3 Storybook + Chromatic npm run build-storybook All Tier 1 stories render Chromatic baseline captured
#4 Dialog merge ng build ng test All 17 dialog consumers, Chromatic
#5 Bootstrap ng build ng test Zero Bootstrap grep, audit counts, Chromatic
#6 Colours ng build Audit counts Chromatic regression check
#7-8 Spacing ng build Audit counts Chromatic regression check
#9 Inline/important ng build Audit counts Chromatic regression check
#10 Tier 2 stories npm run build-storybook All stories render Expanded Chromatic baseline
#11 OnPush ng build ng test Chromatic regression check
#12 Modernisation ng build ng test Chromatic regression check
#13 Docs generate-indexes.sh + validate-docs.sh