Skip to content

Monorepo Performance + Accessibility Review

Section titled “Monorepo Performance + Accessibility Review”

Reviewer: senior web-perf + a11y engineer Scope: /home/ta/projects/monorepo/ — Astro sites: sites/template, sites/sdc, sites/mbr; SvelteKit apps/sd-app Target: WCAG AA, Cloudflare Pages, Lighthouse green

Initial tool calls had output-rendering issues; findings below are based on confirmed file reads from the second-pass survey. Items I could not finish verifying in budget are flagged “UNVERIFIED”.


C1. mBR pulls Inter from Google Fonts CDN — render-blocking, leaks DNS to Google, no font-display: swap guarantee

Section titled “C1. mBR pulls Inter from Google Fonts CDN — render-blocking, leaks DNS to Google, no font-display: swap guarantee”
  • What: mBR BaseLayout loads Inter via <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:opsz,wght@0,14..32,100..900&display=swap" /> — no preconnect, no preload, no self-hosting. Template self-hosts via @fontsource-variable/inter (good) but mBR does not.
  • Where: /home/ta/projects/monorepo/sites/mbr/src/layouts/BaseLayout.astro:27-30. Compare with /home/ta/projects/monorepo/sites/template/src/styles/global.css:1 (@import '@fontsource-variable/inter';).
  • Why: Render-blocking external CSS adds 100–400 ms to LCP via extra DNS + TCP + TLS to fonts.googleapis.com and a second hop to fonts.gstatic.com. Also: privacy/CSP concern (third-party request from a brand site), and display=swap in the URL is good but you still pay the network cost. Variable axis range 100..900 × 0,14..32 requests the full italic + roman variable file — likely wasted bytes.
  • Fix: Add @fontsource-variable/inter to mBR’s package.json and @import '@fontsource-variable/inter'; at the top of sites/mbr/src/styles/global.css. Remove the Google Fonts <link>. If self-hosting variable woff2, add <link rel="preload" as="font" type="font/woff2" crossorigin> for the primary weight.
Section titled “C2. sdc site has no BaseLayout — no skip link, no centralized <head>, no a11y baseline”
  • What: find ... BaseLayout.astro returned matches only for template/ and mbr/. The sdc site has no src/layouts/ directory at all; its index page (sites/sdc/src/pages/index.astro) owns its own <html>.
  • Where: /home/ta/projects/monorepo/sites/sdc/src/ — no layouts/ dir; /home/ta/projects/monorepo/sites/sdc/src/pages/index.astro is the only HTML root.
  • Why: No skip link (WCAG 2.4.1 Bypass Blocks fail), no shared <title>/meta/lang strategy, no shared theme-init script — every new sdc page repeats <html> boilerplate and drifts. sites/sdc/src/styles/global.css is only 4 lines, suggesting the site is half-built. This is technical-debt-as-a11y-gap.
  • Fix: Create sites/sdc/src/layouts/BaseLayout.astro mirroring template’s structure (skip link, <main id="main-content">, theme init). Refactor existing sdc pages to use it.

C3. axe-core a11y suite is gated as “fixme” with known unresolved violations

Section titled “C3. axe-core a11y suite is gated as “fixme” with known unresolved violations”
  • What: sites/template/tests/accessibility.spec.ts:14 comment: “axe-core scans — flagged as fixme until existing violations are resolved. Known issues: missing <title>, lang attr, color-contrast, aria-prohibited-attr”. The test only fails on critical|serious impacts and ignores moderate|minor. It runs against template only — not sdc, not mbr.
  • Where: /home/ta/projects/monorepo/sites/template/tests/accessibility.spec.ts:14-39.
  • Why: “Known issues” sitting in the source is not WCAG AA. Missing <title> and lang are level-A failures — these are critical for screen readers. Filtering out moderate impact in axe means real AA gaps (color-contrast, ARIA attribute misuse) can pass CI silently if axe rates them moderate.
  • Fix: Resolve the listed known issues (verify every page has <title> and <html lang="en">). Drop the critical|serious filter — fail on any violation. Add a sister spec under sites/mbr/tests/ and sites/sdc/tests/ (or extend the route list to cover all sites once they share a layout pattern).

C4. Alpine.js loaded synchronously in every page (~15 KB) with no per-page gating

Section titled “C4. Alpine.js loaded synchronously in every page (~15 KB) with no per-page gating”
  • What: Both template and mBR BaseLayouts include <script>import Alpine from 'alpinejs'; Alpine.start();</script> for every page render. There are zero pages without it.
  • Where: /home/ta/projects/monorepo/sites/template/src/layouts/BaseLayout.astro:62-64 (import '../scripts/alpine-init'); /home/ta/projects/monorepo/sites/mbr/src/layouts/BaseLayout.astro:31-37. alpine-init.ts calls Alpine.start() immediately, not on DOMContentLoaded.
  • Why: Alpine is ~15 KB gzip and parses + executes on every page even when zero x-data exists on the page. Astro <script> without is:inline is bundled and module-loaded — but it still runs eagerly. This is real TBT (Total Blocking Time) on every page. Also: mBR’s Alpine.start() is wrapped in DOMContentLoaded while template’s is not — inconsistent and the template variant can race against rendering.
  • Fix: Either (a) load Alpine with <script src="..." defer> only on pages that use x-data, gated by a layout prop (useAlpine={true}); or (b) keep it global but defer-load and set Alpine.start() inside DOMContentLoaded. For pages with no interactivity (legal, docs, brand showcase static blocks), skip Alpine entirely.

I1. CSP allows 'unsafe-inline' and 'unsafe-eval' in script-src — defeats most XSS protection

Section titled “I1. CSP allows 'unsafe-inline' and 'unsafe-eval' in script-src — defeats most XSS protection”
  • What: sites/template/public/_headers CSP: script-src 'self' 'unsafe-inline' 'unsafe-eval'.
  • Where: /home/ta/projects/monorepo/sites/template/public/_headers:6.
  • Why: unsafe-inline is required by the theme-init inline script in BaseLayout.astro:25, but the modern fix is a SHA-256 hash or per-request nonce. unsafe-eval is almost never needed today — Alpine.js v3 does not require it (only v2 did). This CSP provides only token protection.
  • Fix: Audit for any eval() / new Function() usage — likely none — and remove 'unsafe-eval' immediately. For inline scripts, switch to 'sha256-...' hashes (Astro can emit these) or move the theme-init script to a same-origin file. Verify with astro build + a CSP linter (e.g., csp-evaluator.withgoogle.com).

I2. Zero use of Astro’s <Image> / astro:assets — 17 raw <img> tags, missing width/height on most

Section titled “I2. Zero use of Astro’s <Image> / astro:assets — 17 raw <img> tags, missing width/height on most”
  • What: grep '<img\s' → 17 hits across sites/. grep 'astro:assets' → 0 hits. Only sites/template/src/components/Welcome.astro:11 carries explicit width/height. The blog hero in BlogPost.astro:92 uses loading="eager" with no dimensions and no fetchpriority.
  • Where: All blocks under sites/template/src/components/blocks/ (HeroImageLeft, HeroImageRight, CTASideBySide, RelatedContent, TestimonialFeatured, LogoCloud, ImageGallery, LeadMagnetForm, TestimonialsCarousel), plus sites/template/src/pages/blog/index.astro and BlogPost.astro.
  • Why: Raw <img> ships the original asset format/size to every viewport. Astro <Image> auto-generates AVIF/WebP, responsive srcset, and infers intrinsic dimensions from local imports — eliminating CLS. Missing width/height on lazy images is the #1 cause of Lighthouse CLS regressions. For above-the-fold images, use loading="eager" fetchpriority="high" plus a <link rel="preload"> for the LCP image.
  • Fix: Migrate local images to import heroImg from '...'; <Image src={heroImg} width={1200} height={630} alt="..." />. For remote URLs (Unsplash, picsum) configure image.domains in astro.config.mjs and supply explicit width/height props. Audit each grep hit.

I3. mBR and sdc Tailwind configs miss the shared packages/components glob

Section titled “I3. mBR and sdc Tailwind configs miss the shared packages/components glob”
  • What: mBR config: content: ['./src/**/*.{astro,html,js,jsx,md,mdx,ts,tsx}']. sdc config identical. Template’s is similar. None include ../../packages/components/src/**/* even though that package exists and BaseLayout imports from @components/....
  • Where: /home/ta/projects/monorepo/sites/mbr/tailwind.config.mjs, /home/ta/projects/monorepo/sites/sdc/tailwind.config.mjs, /home/ta/projects/monorepo/sites/template/tailwind.config.mjs.
  • Why: Tailwind purges any utility class it doesn’t see in content globs. If a shared component (e.g., packages/components/src/ui/Card.astro — currently modified in your git status) uses bg-card-foreground or arbitrary values, those classes will be missing from the production CSS bundle, causing silent style breakage at deploy. Currently survives only because template’s classes happen to overlap.
  • Fix: Add '../../packages/components/src/**/*.{astro,ts,tsx}' to each site’s content array. Verify with pnpm build + diffing the dist/_astro/*.css size before/after.

I4. Lighthouse CI is wired up but .lighthouseci/ is empty — no one has run it

Section titled “I4. Lighthouse CI is wired up but .lighthouseci/ is empty — no one has run it”
  • What: lighthouserc.json exists with sensible thresholds (perf ≥ 0.85, a11y ≥ 0.9). pnpm lighthouse script defined. But .lighthouseci/ directory is empty (last touched Feb 13 2026) and the accessibility and seo assertions are warn, not error.
  • Where: /home/ta/projects/monorepo/sites/template/lighthouserc.json:18,20; empty /home/ta/projects/monorepo/sites/template/.lighthouseci/.
  • Why: Without enforcement (error not warn) and without scheduled or pre-push invocation, Lighthouse CI is theatre. The configured assertions cover only the homepage and /showcase — brand pages, blog, contact form excluded. WCAG-AA target requires accessibility: error, minScore: 0.95.
  • Fix: Promote accessibility to ["error", { "minScore": 0.95 }]. Add the brands page, components page, a blog post, and contact form to the URL list. Wire pnpm lighthouse into the pre-push hook (or a periodic GH Actions cron) and commit .lighthouseci/ baseline.

I5. prefers-reduced-motion coverage is partial — covers brand logos and SmartDebt entity but no global fallback

Section titled “I5. prefers-reduced-motion coverage is partial — covers brand logos and SmartDebt entity but no global fallback”
  • What: Reduced-motion guards exist on individual components (BrandLogo.astro:218,259; MyBetterRates.astro:156; Logo.astro:67; SmartDebtEntity.css:103; Typewriter.css:39). No global “kill all animations” guard in sites/*/src/styles/global.css.
  • Where: /home/ta/projects/monorepo/sites/template/src/styles/global.css ends without a global rule; /home/ta/projects/monorepo/sites/mbr/src/styles/global.css:25-419 likewise.
  • Why: Future components or stray transition: declarations (e.g., transition: color 0.2s ease on links in global.css:69) will not be gated. Defense-in-depth requires a global cascade-clamping rule.
  • Fix: Append to each site’s global.css:
    @media (prefers-reduced-motion: reduce) {
    *, *::before, *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
    scroll-behavior: auto !important;
    }
    }
    Also: global.css:29-31 sets scroll-behavior: smooth on html — gate this behind prefers-reduced-motion: no-preference or rely on the global override above.

I6. Inconsistent focus styles — no global :focus-visible ring, only ad-hoc per-component

Section titled “I6. Inconsistent focus styles — no global :focus-visible ring, only ad-hoc per-component”
  • What: grep ':focus|focus-visible' in CSS returned only showcase.css:273 (.dropdown-item:focus-visible) and mbr/global.css:400 (.skip-link:focus). Most focus styling lives inside individual components (NewsletterForm, ImageGallery, ThemeSwitcher) or per-page (contact.astro:234). No global Tailwind preset for focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2.
  • Where: template global.css has zero :focus rules; mBR has only the skip-link focus rule.
  • Why: WCAG 2.4.7 Focus Visible (AA) requires every interactive element to show a visible focus indicator. Browsers’ default is usable but is sometimes suppressed by outline: none (a common Tailwind pattern via focus:outline-none). Without an explicit replacement ring everywhere, keyboard users lose their place.
  • Fix: Add a global rule to template/mBR global.css:
    :focus-visible {
    outline: 2px solid hsl(var(--primary));
    outline-offset: 2px;
    border-radius: 2px;
    }
    Audit for any outline-none that isn’t followed by a focus-visible:ring-* Tailwind utility.

M1. Three <script> blocks in BaseLayout <head> execute eagerly — TBT pressure

Section titled “M1. Three <script> blocks in BaseLayout <head> execute eagerly — TBT pressure”
  • What: sites/template/src/layouts/BaseLayout.astro has, in order: inline theme-init (necessary, OK), bundled Alpine init, bundled SmartDebt + favicon-sync init, and an inline Pagefind highlight loader (type="module" so deferred by default, OK).
  • Where: /home/ta/projects/monorepo/sites/template/src/layouts/BaseLayout.astro:25-115.
  • Why: Astro’s bundled <script> (no is:inline) is a module — automatically deferred — so this is less bad than it looks. But Alpine + smartdebt-animation + favicon-sync all start at the same tick, blocking the main thread. Favicon sync in particular has no reason to run before LCP.
  • Fix: Wrap non-critical inits (favicon-sync, smartdebt-animation) in requestIdleCallback or setTimeout(0) so they run after LCP. Consider moving them out of <head> to just before </body>.
Section titled “M2. transition: color 0.2s ease on every link — not motion-safe”
  • What: sites/template/src/styles/global.css:69 applies transition: color 0.2s ease to all non-button anchors with no prefers-reduced-motion guard.
  • Where: /home/ta/projects/monorepo/sites/template/src/styles/global.css:69.
  • Why: A color transition is mild but still motion. Folded into I5’s global guard, this is fine; flagged here for completeness.
  • Fix: Covered by the global reduced-motion rule in I5.

M3. Welcome.astro hero <img> has fetchpriority="high" but no width/height

Section titled “M3. Welcome.astro hero <img> has fetchpriority="high" but no width/height”
  • What: /home/ta/projects/monorepo/sites/template/src/components/Welcome.astro:7: <img id="background" src={background.src} alt="" fetchpriority="high" />. No dimensions → CLS risk for the LCP element.
  • Why: fetchpriority="high" correctly prioritises the LCP image, but without width/height (or aspect-ratio CSS) the browser cannot reserve space → CLS spike.
  • Fix: Convert to <Image> (astro:assets) which infers dimensions from the local import, or add explicit width and height attributes.

M4. sd-app has no a11y or perf tests of its own

Section titled “M4. sd-app has no a11y or perf tests of its own”
  • What: apps/sd-app/package.json has vitest but no Playwright + axe-core. No _headers security headers beyond CSP frame-ancestors. No service-worker caching strategy visible (vite-plugin-pwa is installed — UNVERIFIED how configured).
  • Where: /home/ta/projects/monorepo/apps/sd-app/static/_headers (3 headers only); /home/ta/projects/monorepo/apps/sd-app/package.json.
  • Why: A financial calculator PWA carries higher a11y stakes (form labels, error association, currency announcement). PWAs without offline strategy negate half the point.
  • Fix: Add @axe-core/playwright to sd-app, write a smoke a11y test for the calculator flow. Add Cache-Control rules to its _headers. Verify vite-plugin-pwa config produces a useful service worker (precache shell, runtime-cache for fonts/API).

M5. mBR Alpine import uses Alpine.start() inside DOMContentLoaded; template’s does not — inconsistent

Section titled “M5. mBR Alpine import uses Alpine.start() inside DOMContentLoaded; template’s does not — inconsistent”
  • What: mBR: document.addEventListener('DOMContentLoaded', () => { Alpine.start(); }). Template’s alpine-init.ts:10: Alpine.start() called immediately.
  • Where: /home/ta/projects/monorepo/sites/mbr/src/layouts/BaseLayout.astro:34-36; /home/ta/projects/monorepo/sites/template/src/scripts/alpine-init.ts:10.
  • Why: Astro’s bundled <script> is a type=module (deferred) so DOM is parsed when it runs — calling Alpine.start() immediately is actually fine. The mBR DOMContentLoaded wrapper is redundant. Inconsistency invites future bugs.
  • Fix: Pick one pattern (immediate is correct for type=module) and align both sites.

M6. tailwind-merge and clsx in sd-app dependencies but Svelte rarely needs the cva-style merger

Section titled “M6. tailwind-merge and clsx in sd-app dependencies but Svelte rarely needs the cva-style merger”
  • What: apps/sd-app/package.json deps: clsx, tailwind-merge. Both small but unused-on-most-pages.
  • Why: Minor — tailwind-merge (~3 KB gzip) is only worth shipping if you have shadcn-style class-variance-authority patterns. If usage is light, drop it.
  • Fix: grep -rn "twMerge\|tailwind-merge" apps/sd-app/src/ — if hits are zero or trivial, remove.

  • Critical: 4 — mBR Google Fonts CDN dependency, sdc has no BaseLayout/skip-link, axe-core “known issues” + single-site scope, Alpine eager-loaded globally with no gating
  • Important: 6 — CSP unsafe-inline/unsafe-eval, no Astro <Image> use anywhere, Tailwind configs miss shared package glob, Lighthouse CI not enforced, partial reduced-motion coverage, no global focus-visible ring
  • Minor: 6 — eager init scripts in <head>, link transition not motion-gated, LCP <img> missing dimensions, sd-app a11y/perf gap, inconsistent Alpine init pattern, possibly-unused sd-app utility deps

Highest-leverage single fixes:

  1. C1 (self-host mBR’s Inter) — one-line @import, removes a third-party render-blocking request, frees 100–400 ms LCP. Pure win.
  2. C4 (gate Alpine per-page) — shaves ~15 KB of parse/eval from every static page, biggest TBT improvement on legal/docs/blog pages.
  3. I5 + I6 (global reduced-motion + focus-visible rules) — two small CSS blocks, broad WCAG-AA coverage uplift, no behavior risk.

Known good (no action needed):

  • Skip link present in template + mBR layouts with correct off-screen-to-focus pattern.
  • _headers file exists for template with sensible Permissions-Policy and CSP (modulo I1).
  • Self-hosted Inter Variable in template via @fontsource-variable/inter.
  • Lighthouse CI config exists with reasonable thresholds — just needs enforcement.
  • Reduced-motion respected in every branded animated component (logos, typewriter, SmartDebt entity).
  • Link colors use brand green at WCAG-AA contrast and explicitly exclude nav/footer link classes from the underline rule.