Skip to content

Monorepo Performance + Accessibility Review — Delta (2026-06-25)

Section titled “Monorepo Performance + Accessibility Review — Delta (2026-06-25)”

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.

  • 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] [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 across sites/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, strong in .invest-other-note).
  • Evidence (axe-core, built page): color-contrast serious, 63 nodes @390px / 123 @1280px. #777069 yields 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-subtitle 3.89, th 3.45, .col-note 3.62, .section-badge/.section-note/.invest strong 3.03, .stat-chip__label 3.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 --muted and card greys; verify against the darkest background it lands on (#d1cbc7, the section-badge bg → needs ~38% or lower). One-line change in light.css:23; re-run axe to confirm 0 contrast nodes. Also check .col-rate--debt (#966f2c, 3.39–3.64:1) — see M2.

[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,230 emit <span class="ft" tabindex="0">term<span class="ft-tip" role="tooltip">def</span></span>; CSS/JS in sites/mbr/src/pages/focus.astro:212-360. Same on SDC.
    • Component: packages/components/src/ui/Tooltip.astro:12-18.
  • Gaps (against WCAG 1.4.13 Content on Hover or Focus + 4.1.2):
    1. No Esc-to-dismiss. 1.4.13 requires hoverable content be dismissable without moving the pointer/focus. Neither .ft nor Tooltip.astro listens for Escape.
    2. No programmatic association. The tabindex="0" trigger has no aria-describedby pointing at the role="tooltip" bubble, so the tooltip text is not reliably announced. The .ft trigger also has no role/name beyond its visible text.
    3. No touch support. Show is hover/:focus/:focus-within only. 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.)
  • Fix: Give each bubble an id; add aria-describedby on the trigger. Add a document-level keydown Escape handler that hides the active tooltip (Focus pages already have a keydown listener at focus.astro:408 — extend it). For touch, toggle on click/pointerup and dismiss on outside-tap. Tooltip.astro already has role="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-13 and sites/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:1 self-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 add media/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) and sites/mbr/src/components/RateTable.astro:24-28. Built output dist/rates/index.html: 56 <th>, 0 with scope.
  • 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.
Section titled “[IMPORTANT] [effort: 0.5h] I4 — Inline links not distinguishable without color (link-in-text-block)”
  • Where: rates.astro .invest-other-note and .prime-note inline links (a[href$="#heloc-section"], #mortgage-section). CSS rates.astro a { text-decoration: none } + a:hover { text-decoration: underline }.
  • Evidence: axe link-in-text-block serious, 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_url links are arguably fine as cell-level, but the prose links in .invest-other-note/.prime-note need 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 the visibilitychange 5-min gate. Also reload() (non-forced) may still serve from HTTP cache — though the no-cache, must-revalidate header (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 (#ad9038 on #e9e2d7 = 2.39:1), .col-rate--debt (#966f2c, 3.39–3.64:1), .section-banner__sub on debt banner (#efe9df on #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:700 22.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 __sub clears 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:17 set:html={text}.
  • Why: Not a live exploit (content is build-time, from a trusted local Glossary.md), but set:html with 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.

  • Viewport clamp fix (3b54a12) is correct. positionTooltip() resets to CSS defaults before measuring, flips vertically only when overflowing top, then clamps left/right via tip.style.left = calc(50% + Npx) while tracking the caret with --caret-left so the arrow still points at the trigger. axe confirms 0 horizontal overflow at 390px on the built rates page. Tooltip.astro’s reposition() (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) and BrandLogo.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:520px scrolls horizontally inside the wrapper without overflowing the page. No page-level overflow at 390px (verified).
  • Cache-Control / freshness correctness (2c4913d). no-cache, must-revalidate on /focus.html (+ .htaccess for the FTP host where the PWA installs) is the right call — revalidate-via-ETag, not no-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-eval was intentionally restored for Alpine (commit d2b686d); revisit only if Alpine is removed from a page.
  • No global :focus-visible ring / global reduced-motion fallback in mBR global.css (prior I5/I6) — unchanged.