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¶
- Create audit script at
src/services/web/scripts/design-audit.shwith 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).
-
Run audit and save baseline to
docs/features/design-system-baseline.mdwith 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" -
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¶
- 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/).
-
Remove Font Awesome CDN import from
src/services/web/src/global-styles/styles.scss(line ~22): -
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:13 — fa-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
categoryUiThemeobject (no separate interface exists) to replacefontSet: string | null/fontIcon: string | nullwithicon: string | null - "Risk of Bias" has
fontIcon: ''and "Hidden" hasfontIcon: null— preserve asicon: ''/icon: nullrespectively - 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@ifguard) - Update all TS code that reads
fontSet/fontIconfromcategoryUiTheme:core/services/config/config.service.ts:12-13— type definition:fontSet: string | null; fontIcon: string | null;→icon: string | nullcore/services/config/config.service.ts:21-98— all 9 category entries: replacefontSet/fontIconwithiconshared/annotation/annotation-form/annotation-form.component.ts:228-229— local interface:fontIcon: string | null; fontSet: string | null;→icon: string | nullcore/services/annotation-form/annotation-form.service.ts:342-343— reads.fontIcon/.fontSet→ change to.iconproject/project-admin/question-management/assign/assign.store.ts:759-761— reads.fontSet/.fontIconto buildtitleFontSet/titleFontIcon→ change totitleIconusing.iconproject/project-admin/question-management/assign/assign.store.ts:143-144—AssignTabinterface type definition:titleFontSet: string; titleFontIcon: string;→titleIcon: string
-
Note:
app.component.ts:253referencesfontSetClassbut 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=productionsucceedsng test --no-watchpasses- 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— deadmat-input-*classes
NOT dead — keep:
- Line 138:
mat-form-field.radio-form-field { &:not(.mat-form-field-disabled) { ... } }—.mat-form-field-disabledis 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 buildsucceeds- 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) andapp-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-loadingsection) - Checkboxes (
.mat-checkbox-layoutreplacement) - 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¶
- 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/angularuses@storybook/builder-viteand is compatible. Do NOT use@8— Storybook 8's@storybook/angularlistsAngular >= 18.0 < 21.0as its peer dependency range and will fail on Angular 21. Pin the resolved version inpackage.jsonfor reproducibility. - Configure
.storybook/: - Import global styles and theme
- Set up Material decorators
- Configure path aliases
- Chromatic setup: Add
chromaticnpm package, add GitHub Action for PRs touchingsrc/services/web/ -
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
pushevent trigger (notpull_request). TurboSnap compares the current commit against the previous commit on the same branch — withpush, the base commit is the previous push to that branch, which Chromatic can resolve. Withpull_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:Activation: Set
onlyChanged: trueinchromatic.config.jsonfrom 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.jsonto be committed (already the case in this repo). -
Fallback: if TurboSnap produces unreliable dependency detection (e.g., false negatives missing changed stories), use
--only-story-filesto limit snapshots to stories in changed directories as a simpler alternative - 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
- Add scripts to
package.json:
Verification¶
npm run storybooklaunches, all Tier 1 stories render correctlynpm run build-storybooksucceeds- Chromatic captures baseline snapshots (CI uses
CHROMATIC_PROJECT_TOKENenv 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
pushtrigger (notpull_request) — check that Chromatic build logs show valid baseline resolution - Free tier usage within 5,000 snapshots/month (TurboSnap +
pushtrigger 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¶
- Rewrite
src/services/web/src/app/shared/dialogs/confirmation-dialog/to support all features from both dialogs: - Add
InformationBoxComponentimport - Support
text,listItems,informationBoxalongside existingcontentHtml - Resolve button label:
actionText ?? okText ?? 'OK' - Use
@ifcontrol flow,ChangeDetectionStrategy.OnPush,inject() - Use design tokens for all styling
-
Auto-disable backdrop/Escape when cancel is hidden: In the component constructor or
ngOnInit, ifdata.hideCancelistrue, setthis.dialogRef.disableClose = true. This prevents users from dismissing the dialog via backdrop click or Escape when no cancel button is visible — without this,hideCancelcreates confusing UX where the only visible action is "OK" but the dialog can still be dismissed invisibly. This covers all 6 existinghideCancel: trueconsumers without requiring individualdisableClose: trueat 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; withoutdisableClose, user can escape and keep using stale version)shared/annotation/annotation-form/annotation-form.component.ts:727— validation errors (navigate to first error)
-
Export compatibility alias:
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.ts—default: throw Error(...)— crashes at runtimestage/stage-admin/stage-admin.component.ts—default: throw Error(...)— crashes at runtimescreening/screening-settings/screening-settings.component.ts—default: 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.
- 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/
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).
-
Delete
src/services/web/src/app/shared/dialogs/info-confirmation-dialog/entirely -
Remove info-confirmation-dialog theme mixin registration from
syrf-theme.scss(if present) -
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 }"inannotation-tree.component.html— replace withmat-cardconditional or component-scoped CSS class[ngClass]="{ 'form-group': condition }"inannotation-experiment-question.component.html— this is app-local styling (defined instyles.scss:133andcreate-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 patternproject/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:129has duplicateclassattributes on the same element (class="col-xs-12" class="abstract preline"); only the second is applied by the browser, so migrating the firstclassaway without fixing the duplicate would silently dropcol-xs-12. Fix: merge into a singleclassattribute 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 deadclass="progress-bar"from<mat-progress-bar>elements (not Bootstrap usage, but will fail zero-match grep afterlegacy-bootstrap/progress-bars.scssis 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-*, orcloseBootstrap classes
Final Cleanup¶
- Delete
src/services/web/src/global-styles/legacy-bootstrap/buttons.scss - Delete
src/services/web/src/global-styles/legacy-bootstrap/panel.scss - Delete
src/services/web/src/global-styles/legacy-bootstrap/progress-bars.scss - Remove imports from
styles.scss - Delete empty
legacy-bootstrap/directory
Verification¶
ng build+ng test- Static class grep — anchored inside
class="..."attribute values, exclude comments and.oldfiles:Should return zero matches. Usesgrep -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 '<!--'class="([^"]*[ ])?anchor — the token must appear inside aclass="..."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 usemat-color-*/mat-dense/mat-compactprefixes, so they won't collide. Note:form-groupis excluded from this check — it's app-local styling, not Bootstrap debt. Thetabletoken usestable[" ]|table-(responsive|striped|bordered|hover|condensed)instead of the broadertable[" -]— the latter false-matched app-local classes liketable-menu(study-table.component.html:69) andtable-cell(stage-studies.component.html:209). The grep coversclass="..."(double-quoted) attributes only — this is the Angular template convention. The one single-quotedclass='...'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 aclass='variant of the anchor. - Dynamic class bindings grep — three patterns for
[class.token], quoted[ngClass], and unquoted[ngClass]keys:All three should return zero matches. Pattern 1 catches# 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 '<!--'[class.btn-primary]="expr"style bindings — the[class.prefix ensures only class property bindings match (nothidden-row,example-expanded-row). Fortableandclose, 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 liketable-menu,table-cell, orcloseDialog. The v14 approach of excludingtable/closeentirely from dynamic patterns was overly conservative — the boundary anchors prevent the prefix false-matches that motivated the exclusion ([class.table-menu]instudy-table.component.html:69,[class.table-cell]instage-studies.component.html:209,closeDialog($event)inproject-setup.component.html:65). Table compound classes (table-responsive,table-striped, etc.) remain as explicit alternations for prefix matching.tableandcloseremain 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 likerowin{ 'hidden-row': row.showLastActivity }via the space before the variable. Pattern 2 includes*.tsfiles (for TS-side ngClass usage) and filters both HTML comments (<!--) and TS comments (grep -vE ':[0-9]+:\s*//'— path-aware filter matching thepath:line:prefix fromgrep -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.htmluses{ panel: !cleanInterface }. Interpolated patterns likeclass="btn-{{theme}}"are already caught by the static class grep above (theclass="([^"]*[ ])?anchor matchesclass="btndirectly). Excludesform-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:
Should return zero matches. Uses explicit class alternatives matching the same Bootstrap class inventory as the HTML template grep — buttons (
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*//'.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— themafter.table-is in[a-zA-Z], so it doesn't match) while also catching selectors at end-of-line (e.g.,.btnas 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-barfollowed by[^-a-z]), which failed to match compound Bootstrap classes:.col-xs-12(next charxis[a-z]),.pull-right(next charris[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:
Should return zero matches. Catches Bootstrap JS attributes (
grep -rn --include='*.html' --exclude='*.old*' \ -E 'data-(toggle|target|placement|dismiss)=' src/app/ \ | grep -v '<!--'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.htmlfiles that contain only commented/dead Bootstrap markup - Chromatic: no visual regressions against Tier 1 baselines
- Re-run
npm run audit:designand 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¶
- Move
style=""attributes from HTML files into component SCSS using tokens (exact count from baseline audit) - Remove/fix
!importantdeclarations (fix specificity with better selectors; exact count from baseline audit) - Replace remaining hardcoded
box-shadow,z-indexvalues with tokens
Verification (all Phase 6 PRs)¶
ng buildsucceeds- Re-run
npm run audit:designafter 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
- Tier 2 stories (~170 AI-generated, shallow stories): render each domain component with reasonable mock data
- 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.OnPushto 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/*ngForwith@if/@forblock syntax - Replace constructor DI with
inject()where applicable - Remove/replace
::ng-deep(12 instances) with:hostselectors or component theming
Verification¶
ng build+ng testafter 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¶
- Create
docs/architecture/annotation-controls.md: - 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] - Document the Adapter/Wrapper pattern
- Map each annotation control to its wrapped Material component
- Explain
AbstractAnnotationControl, focus management, type coercion -
Conclusion: architecture is sound, no refactoring needed
-
Create
docs/features/flexlayout-migration-scope.md: - 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] - Scope: ~930 directive usages across ~130 files (all
fx*andgd*directives — exact count from Phase 0 auditFlexLayout directive usagesmetric, which usesgrep -rnwith comment filter thengrep -oEwith\b(fx[A-Z][A-Za-z0-9.-]*|gd[A-Z][A-Za-z0-9.-]*)pattern to count individual directive tokens including responsive suffixes likefxHide.lt-mdandgdAreas.gt-sm, excluding directives inside HTML comments) - Strategy: Replace
fxLayout/fxFlex/fxLayoutAlignetc. with CSS Flexbox/Grid - Testing approach: visual regression via Chromatic (now available)
- Priority: Low urgency (
@ngbracket/ngx-layoutv20 works on Angular 21) -
Recommend as separate multi-PR initiative
-
Update
docs/architecture/theming-guide.md— reflect completed token adoption - Update
docs/features/material-3-migration-plan.md— mark Bootstrap removal and prerequisites as complete - Delete
docs/planning/DESIGN-SYSTEM-Q&A.md— decisions incorporated into implementation PRs; temporary planning doc no longer needed - 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.shto regenerate doc indexes ./docs/scripts/validate-docs.sh --verbosepasses (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 |
— | — |