Monorepo Performance + Accessibility Review — Delta (2026-06-25)
Section titled “Monorepo Performance + Accessibility Review — Delta (2026-06-25)”Verdict
Section titled “Verdict”The delta is net positive on perf-correctness and mostly clean on layout, but introduces one
serious, broad WCAG-AA color-contrast regression on the new MBR rates dashboard that axe flags on
63 nodes (390px) / 123 nodes (1280px), and ships new interactive tooltips that are not fully
keyboard/touch accessible (no Esc-to-dismiss, no aria-describedby, no touch support). The
viewport-clamp fix (3b54a12) is correct and well-engineered — verified zero horizontal overflow
at 390px. Brand animations are properly prefers-reduced-motion-gated. Prior C1 (mBR Google Fonts
CDN) is resolved on the main site but still present on both Focus PWA pages, which bypass
BaseLayout. Tables are responsive (overflow-x scroll, keyboard-focusable region) but every <th>
lacks scope (56 on the rates page).
The dominant fix is a one-line token change (--muted-foreground) that clears the bulk of the
contrast violations. Everything else is small, bounded effort.
Severity counts
Section titled “Severity counts”- Critical: 1 (C1 — rates dashboard color-contrast, 63–123 nodes)
- Important: 4 (I1 tooltip keyboard/touch a11y, I2 Focus PWA render-blocking Google Fonts, I3 missing
<th scope>, I4 link-in-text-block) - Minor: 3 (M1 bfcache reload defeats instant back-nav, M2 amber chip value 2.39:1, M3 tooltip set:html XSS surface)
- Well-handled: 4 (verified — see bottom)
Critical
Section titled “Critical”[CRITICAL] [effort: 1h] C1 — Rates dashboard fails WCAG-AA contrast on ~all secondary text (root cause: --muted-foreground too light)
Section titled “[CRITICAL] [effort: 1h] C1 — Rates dashboard fails WCAG-AA contrast on ~all secondary text (root cause: --muted-foreground too light)”- Where: token def
sites/mbr/src/styles/themes/light.css:23→--muted-foreground: 28 6% 44%(=#777069). Consumed acrosssites/mbr/src/pages/rates.astro(.page-subtitle,.table-footnote,.stat-chip__label,.stat-chip__sub,.section-note,.section-badge,.col-note,th,.prime-note,.invest-intro,strongin.invest-other-note). - Evidence (axe-core, built page):
color-contrastserious, 63 nodes @390px / 123 @1280px.#777069yields 3.03–3.89:1 on the light grey card/muted backgrounds it sits on — below the 4.5:1 AA threshold for normal text. Representative failures:.page-subtitle3.89,th3.45,.col-note3.62,.section-badge/.section-note/.invest strong3.03,.stat-chip__label3.58. - Why it matters: This is nearly all explanatory/secondary copy on the flagship active site’s most important page. WCAG-AA target (per CLAUDE.md “WCAG AA contrast minimum”) is not met.
- Fix: Darken the token.
28 6% 44%→ ~28 6% 38%clears 4.5:1 on--mutedand card greys; verify against the darkest background it lands on (#d1cbc7, the section-badge bg → needs ~38% or lower). One-line change inlight.css:23; re-run axe to confirm 0 contrast nodes. Also check.col-rate--debt(#966f2c, 3.39–3.64:1) — see M2.
Important
Section titled “Important”[IMPORTANT] [effort: 2h] I1 — New tooltips (.ft on Focus pages + Tooltip.astro) are not keyboard/touch-complete
Section titled “[IMPORTANT] [effort: 2h] I1 — New tooltips (.ft on Focus pages + Tooltip.astro) are not keyboard/touch-complete”- Where:
- Focus
.ft:sites/mbr/src/utils/load-focus-content.ts:77,226,230emit<span class="ft" tabindex="0">term<span class="ft-tip" role="tooltip">def</span></span>; CSS/JS insites/mbr/src/pages/focus.astro:212-360. Same on SDC. - Component:
packages/components/src/ui/Tooltip.astro:12-18.
- Focus
- Gaps (against WCAG 1.4.13 Content on Hover or Focus + 4.1.2):
- No Esc-to-dismiss. 1.4.13 requires hoverable content be dismissable without moving the pointer/focus. Neither
.ftnorTooltip.astrolistens for Escape. - No programmatic association. The
tabindex="0"trigger has noaria-describedbypointing at therole="tooltip"bubble, so the tooltip text is not reliably announced. The.fttrigger also has no role/name beyond its visible text. - No touch support. Show is hover/
:focus/:focus-withinonly. On touch devices a tap neither focuses an inline<span>reliably nor fires hover — the definition is unreachable. (The Focus pages are PWAs installed on phones — this is the primary device.)
- No Esc-to-dismiss. 1.4.13 requires hoverable content be dismissable without moving the pointer/focus. Neither
- Fix: Give each bubble an
id; addaria-describedbyon the trigger. Add a document-levelkeydownEscape handler that hides the active tooltip (Focus pages already have a keydown listener atfocus.astro:408— extend it). For touch, toggle onclick/pointerupand dismiss on outside-tap.Tooltip.astroalready hasrole="group"/role="tooltip"(better than.ft) but shares the Esc + describedby + touch gaps.
[IMPORTANT] [effort: 0.5h] I2 — Both Focus PWA pages load Inter from Google Fonts CDN (render-blocking; prior C1 not propagated)
Section titled “[IMPORTANT] [effort: 0.5h] I2 — Both Focus PWA pages load Inter from Google Fonts CDN (render-blocking; prior C1 not propagated)”- Where:
sites/mbr/src/pages/focus.astro:11-13andsites/sdc/src/pages/focus.astro:11-13—<link rel="preconnect" ...googleapis> + <link ...css2?family=Inter...>. - Why: The 2026-05-14 review’s C1 was fixed on the main mBR site (
sites/mbr/src/styles/global.css:1self-hosts via@fontsource-variable/inter), but the Focus pages are standalone<html>documents that bypass BaseLayout and still pull a render-blocking external stylesheet (extra DNS+TLS to googleapis + gstatic, ~100–400ms LCP cost, plus a third-party request from a PWA). Violates the repo “defer-load / no external render-blocking” default. - Fix: Self-host. Inline a minimal
@font-face(woff2, weights 400/600/800) served from the same origin, or preload +font-display: swap. Since Focus is content-light, even a system-font fallback stack would be defensible. At minimum addmedia/preload so the font isn’t blocking first paint.
[IMPORTANT] [effort: 1h] I3 — Every rates table <th> lacks scope (56 headers on the page)
Section titled “[IMPORTANT] [effort: 1h] I3 — Every rates table <th> lacks scope (56 headers on the page)”- Where:
sites/mbr/src/pages/rates.astro(8 tables: Savings, GICs, Credit Cards, Mortgages, Auto Loans, HELOCs, Reverse Mortgages, Investment Loans, Margin Accounts) andsites/mbr/src/components/RateTable.astro:24-28. Built outputdist/rates/index.html: 56<th>, 0 withscope. - Why: WCAG 1.3.1. For multi-column data tables, screen readers infer column association better with explicit
scope="col". axe didn’t flag these as a violation (simple single-row headers), but it’s a best-practice gap and cheap insurance for the larger/denser new tables. - Fix: Add
scope="col"to every header cell. One attribute per<th>; mechanical.
[IMPORTANT] [effort: 0.5h] I4 — Inline links not distinguishable without color (link-in-text-block)
Section titled “[IMPORTANT] [effort: 0.5h] I4 — Inline links not distinguishable without color (link-in-text-block)”- Where:
rates.astro.invest-other-noteand.prime-noteinline links (a[href$="#heloc-section"],#mortgage-section). CSSrates.astroa { text-decoration: none }+a:hover { text-decoration: underline }. - Evidence: axe
link-in-text-blockserious, 3 nodes (both viewports). Inline links inside paragraph text rely on color alone (no underline at rest) — fails WCAG 1.4.1 for users who can’t perceive the color difference. - Fix: Underline inline body links at rest (the table-cell
apply_urllinks are arguably fine as cell-level, but the prose links in.invest-other-note/.prime-noteneed a persistent underline). Scope a rule:.invest-other-note a, .prime-note a { text-decoration: underline; }.
[MINOR] [effort: 0.5h] M1 — bfcache pageshow.persisted → unconditional reload() defeats instant back-navigation
Section titled “[MINOR] [effort: 0.5h] M1 — bfcache pageshow.persisted → unconditional reload() defeats instant back-navigation”- Where:
sites/mbr/src/pages/focus.astro:474-481(and SDC equivalent), commit 42d76a3. - Why:
if (e.persisted) window.location.reload()fires on every bfcache restore, including a normal Back-button return — discarding the instant-restore perf win bfcache exists to provide. The intent (fresh content after Android evicts the PWA) is legitimate, but it’s broader than needed and pairs redundantly with thevisibilitychange5-min gate. Alsoreload()(non-forced) may still serve from HTTP cache — though theno-cache, must-revalidateheader (commit 2c4913d) covers that correctly via ETag/304. - Fix: Gate the persisted reload behind the same staleness check (
Date.now() - lastLoadTime > STALE_MS) so a quick Back doesn’t pay a full reload, while a long-backgrounded resume still refreshes. Low risk; the header already guarantees correctness.
[MINOR] [effort: 0.25h] M2 — Amber stat-chip value 2.39:1; debt rate / debt banner-sub also sub-AA
Section titled “[MINOR] [effort: 0.25h] M2 — Amber stat-chip value 2.39:1; debt rate / debt banner-sub also sub-AA”- Where:
rates.astro.stat-chip--amber .stat-chip__value(#ad9038on#e9e2d7= 2.39:1),.col-rate--debt(#966f2c, 3.39–3.64:1),.section-banner__subon debt banner (#efe9dfon#966f2c= 3.77:1). - Why: Large-text leniency (3:1) saves the 22px chip value only if it’s ≥18.66px bold — it’s
font-weight:70022.4px, so 3:1 applies and 2.39 still fails. The amber/gold brand hue is inherently low-contrast on light. WCAG 1.4.3/1.4.11. - Fix: Darken the amber foreground (e.g.
hsl(38 55% 30%)) for the value and.col-rate--debt; or lighten the debt-banner background so its__subclears 4.5:1 (it’s normal-size text).
[MINOR] [effort: 0.5h] M3 — Tooltip definitions injected via set:html from Glossary.md — sanitization surface
Section titled “[MINOR] [effort: 0.5h] M3 — Tooltip definitions injected via set:html from Glossary.md — sanitization surface”- Where:
load-focus-content.ts:77(...${def}...) and the bubble rendered with raw HTML;Tooltip.astro:17set:html={text}. - Why: Not a live exploit (content is build-time, from a trusted local Glossary.md), but
set:htmlwith no allow-list means any future stray markup/script-like content in the glossary lands verbatim in the DOM. Perf-adjacent only via DOM weight; flagged for hygiene. - Fix: Constrain glossary definitions to a known inline-tag allow-list at parse time, or document that Glossary.md term bodies are trusted HTML. No change if the trust boundary is acceptable.
Well-handled (verified — no action)
Section titled “Well-handled (verified — no action)”- Viewport clamp fix (3b54a12) is correct.
positionTooltip()resets to CSS defaults before measuring, flips vertically only when overflowing top, then clamps left/right viatip.style.left = calc(50% + Npx)while tracking the caret with--caret-leftso the arrow still points at the trigger. axe confirms 0 horizontal overflow at 390px on the built rates page.Tooltip.astro’sreposition()(tries preferred→opposite→all-4, restores preferred if none fit) is a clean, layout-forcing approach. - Brand animations are
prefers-reduced-motion-gated.MyBetterRates.css:70(animation: none !important; opacity: 0 !important) andBrandLogo.astro:213,254,274. The SSOT extraction (342db93) removed the frozen inline copy in the PWA — good for both maintainability and consistent motion-gating. - Tables are responsive + keyboard-reachable.
.table-wrap { overflow-x: auto }+tabindex="0" role="region" aria-label="..."on every wrapper;min-width:520pxscrolls horizontally inside the wrapper without overflowing the page. No page-level overflow at 390px (verified). - Cache-Control / freshness correctness (2c4913d).
no-cache, must-revalidateon/focus.html(+.htaccessfor the FTP host where the PWA installs) is the right call — revalidate-via-ETag, notno-store. Heading order on rates is clean (h1 → h2 → h3, no skips).
Carry-over from 2026-05-14 still open (not delta, noted for completeness)
Section titled “Carry-over from 2026-05-14 still open (not delta, noted for completeness)”- CSP
script-src 'unsafe-inline' 'unsafe-eval'remains (prior I1) —unsafe-evalwas intentionally restored for Alpine (commit d2b686d); revisit only if Alpine is removed from a page. - No global
:focus-visiblering / global reduced-motion fallback in mBRglobal.css(prior I5/I6) — unchanged.