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”.
Critical
Section titled “Critical”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" />— nopreconnect, nopreload, 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.comand a second hop tofonts.gstatic.com. Also: privacy/CSP concern (third-party request from a brand site), anddisplay=swapin the URL is good but you still pay the network cost. Variable axis range100..900 × 0,14..32requests the full italic + roman variable file — likely wasted bytes. - Fix: Add
@fontsource-variable/interto mBR’spackage.jsonand@import '@fontsource-variable/inter';at the top ofsites/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.
C2. sdc site has no BaseLayout — no skip link, no centralized <head>, no a11y baseline
Section titled “C2. sdc site has no BaseLayout — no skip link, no centralized <head>, no a11y baseline”- What:
find ... BaseLayout.astroreturned matches only fortemplate/andmbr/. The sdc site has nosrc/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/— nolayouts/dir;/home/ta/projects/monorepo/sites/sdc/src/pages/index.astrois 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.cssis only 4 lines, suggesting the site is half-built. This is technical-debt-as-a11y-gap. - Fix: Create
sites/sdc/src/layouts/BaseLayout.astromirroring 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:14comment: “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 oncritical|seriousimpacts and ignoresmoderate|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>andlangare level-A failures — these are critical for screen readers. Filtering outmoderateimpact 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 thecritical|seriousfilter — fail on any violation. Add a sister spec undersites/mbr/tests/andsites/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.tscallsAlpine.start()immediately, not onDOMContentLoaded. - Why: Alpine is ~15 KB gzip and parses + executes on every page even when zero
x-dataexists on the page. Astro<script>withoutis:inlineis bundled and module-loaded — but it still runs eagerly. This is real TBT (Total Blocking Time) on every page. Also: mBR’sAlpine.start()is wrapped inDOMContentLoadedwhile 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 usex-data, gated by a layout prop (useAlpine={true}); or (b) keep it global but defer-load and setAlpine.start()insideDOMContentLoaded. For pages with no interactivity (legal, docs, brand showcase static blocks), skip Alpine entirely.
Important
Section titled “Important”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/_headersCSP:script-src 'self' 'unsafe-inline' 'unsafe-eval'. - Where:
/home/ta/projects/monorepo/sites/template/public/_headers:6. - Why:
unsafe-inlineis required by the theme-init inline script inBaseLayout.astro:25, but the modern fix is a SHA-256 hash or per-request nonce.unsafe-evalis 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 withastro 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 acrosssites/.grep 'astro:assets'→ 0 hits. Onlysites/template/src/components/Welcome.astro:11carries explicitwidth/height. The blog hero inBlogPost.astro:92usesloading="eager"with no dimensions and nofetchpriority. - Where: All blocks under
sites/template/src/components/blocks/(HeroImageLeft, HeroImageRight, CTASideBySide, RelatedContent, TestimonialFeatured, LogoCloud, ImageGallery, LeadMagnetForm, TestimonialsCarousel), plussites/template/src/pages/blog/index.astroandBlogPost.astro. - Why: Raw
<img>ships the original asset format/size to every viewport. Astro<Image>auto-generates AVIF/WebP, responsivesrcset, and infers intrinsic dimensions from local imports — eliminating CLS. Missingwidth/heighton lazy images is the #1 cause of Lighthouse CLS regressions. For above-the-fold images, useloading="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) configureimage.domainsinastro.config.mjsand supply explicitwidth/heightprops. 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 andBaseLayoutimports 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
contentglobs. If a shared component (e.g.,packages/components/src/ui/Card.astro— currently modified in your git status) usesbg-card-foregroundor 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’scontentarray. Verify withpnpm build+ diffing thedist/_astro/*.csssize 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.jsonexists with sensible thresholds (perf ≥ 0.85, a11y ≥ 0.9).pnpm lighthousescript defined. But.lighthouseci/directory is empty (last touched Feb 13 2026) and theaccessibilityandseoassertions arewarn, noterror. - Where:
/home/ta/projects/monorepo/sites/template/lighthouserc.json:18,20; empty/home/ta/projects/monorepo/sites/template/.lighthouseci/. - Why: Without enforcement (
errornotwarn) 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 requiresaccessibility: error, minScore: 0.95. - Fix: Promote
accessibilityto["error", { "minScore": 0.95 }]. Add the brands page, components page, a blog post, and contact form to the URL list. Wirepnpm lighthouseinto 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 insites/*/src/styles/global.css. - Where:
/home/ta/projects/monorepo/sites/template/src/styles/global.cssends without a global rule;/home/ta/projects/monorepo/sites/mbr/src/styles/global.css:25-419likewise. - Why: Future components or stray
transition:declarations (e.g.,transition: color 0.2s easeon links inglobal.css:69) will not be gated. Defense-in-depth requires a global cascade-clamping rule. - Fix: Append to each site’s
global.css:Also:@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;}}global.css:29-31setsscroll-behavior: smoothonhtml— gate this behindprefers-reduced-motion: no-preferenceor 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 onlyshowcase.css:273(.dropdown-item:focus-visible) andmbr/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 forfocus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2. - Where: template
global.csshas zero:focusrules; 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 viafocus:outline-none). Without an explicit replacement ring everywhere, keyboard users lose their place. - Fix: Add a global rule to template/mBR
global.css:Audit for any:focus-visible {outline: 2px solid hsl(var(--primary));outline-offset: 2px;border-radius: 2px;}outline-nonethat isn’t followed by afocus-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.astrohas, 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>(nois: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) inrequestIdleCallbackorsetTimeout(0)so they run after LCP. Consider moving them out of<head>to just before</body>.
M2. transition: color 0.2s ease on every link — not motion-safe
Section titled “M2. transition: color 0.2s ease on every link — not motion-safe”- What:
sites/template/src/styles/global.css:69appliestransition: color 0.2s easeto all non-button anchors with noprefers-reduced-motionguard. - 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 withoutwidth/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 explicitwidthandheightattributes.
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.jsonhasvitestbut no Playwright + axe-core. No_headerssecurity headers beyond CSP frame-ancestors. No service-worker caching strategy visible (vite-plugin-pwais 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/playwrightto sd-app, write a smoke a11y test for the calculator flow. AddCache-Controlrules to its_headers. Verifyvite-plugin-pwaconfig 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’salpine-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 atype=module(deferred) so DOM is parsed when it runs — callingAlpine.start()immediately is actually fine. The mBRDOMContentLoadedwrapper 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.jsondeps: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.
Summary tally
Section titled “Summary tally”- 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:
- C1 (self-host mBR’s Inter) — one-line
@import, removes a third-party render-blocking request, frees 100–400 ms LCP. Pure win. - C4 (gate Alpine per-page) — shaves ~15 KB of parse/eval from every static page, biggest TBT improvement on legal/docs/blog pages.
- 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.
_headersfile 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.