Performance & Accessibility
Accessibility
Accessibility on this site is enforced by a handful of concrete, code-level conventions rather than a plugin or audit gate: a WCAG 2.5.8 hit-area shim on standalone touch targets, a fixed set of screen-reader chrome strings that intentionally stay hardcoded, real <label>s on every input, a skip link as the first focusable element, and a prefers-reduced-motion path that renders everything static.
The site is a lead-generation marketing site, so a11y is treated as a correctness floor, not a feature — the Accessibility Floor in global-rules.md is non-negotiable, and a May/June 2026 Playwright + vision-agent responsive audit verified the structural fundamentals across 8 breakpoints. This page covers the touch-target pattern, the chrome exceptions, reduced motion, and the audit methodology and findings. Performance budgets live on the Performance page.
Touch targets — WCAG 2.5.8 Target Size
Any standalone interactive control — a bare text link, an icon-only button, a link styled as inline text (e.g. the gold arrow-link in StickyScrollSection.astro) — must present a ≥48×48px hit area on touch devices, even when its visible text is shorter. Button and button-styled <a>s usually clear this already through their padding (px-5.5 py-3.5 ≈ 48px tall), so the rule mostly applies to the standalone links that don't.
The fix is not to inflate the visible element (that would change the design). Instead overlay an invisible, transparent hit-expander span, gated to coarse pointers. Here is the real shim from StickyScrollSection.astro:
<a
class="group relative inline-flex items-center gap-1.5 font-sans font-bold text-gold hover:text-gold-deep transition-colors"
href={g.buttonHref ?? '#'}
>
{g.buttonLabel}
<span class="absolute top-1/2 left-1/2 size-full min-h-12 min-w-12 -translate-1/2 pointer-fine:hidden"></span>
<span class="text-lg leading-none ..." aria-hidden="true">→</span>
</a>
- The
<a>(or<button>) isrelative; the span isabsolute, centered (top-1/2 left-1/2 -translate-1/2),size-fullso it never shrinks the area below the visible bounds, andmin-h-12 min-w-12(48px) so it grows it past them when the text is small. pointer-fine:hiddenremoves the span on fine pointers (mouse/trackpad) — those already hit small targets accurately and don't need the WCAG floor, and an invisible overlay there would become a stray focus/hover target.- The span carries no text and no
aria— it's a pure hit-area shim, transparent to the accessibility tree.
In-text hyperlinks inside a paragraph are exempt under the WCAG inline exception — don't shim those.
Stacked/clustered targets: don't just overlay harder
A 48px shim on 24px-tall text reaches 12px past each edge, so when neighbors sit <24px apart the shims overlap and the lower one (later in DOM) steals taps meant for the upper. The overlay alone is only safe for isolated links. For dense groups, widen spacing on touch instead — the header and footer use all three patterns below.
Three clustering patterns
| Cluster shape | Touch fix | Why not the plain overlay |
|---|---|---|
| Tight vertical list (footer link columns, mobile-nav children) | Keep the shim and add pointer-coarse:gap-6 so the 48px boxes tile without overlap | Fine pointers keep the original compact gap (the shim is pointer-fine:hidden anyway), so desktop is pixel-identical |
| A link beside its own control (nav link + dropdown caret) | Give the link a height-only shim (min-h-12, drop min-w-12) so it can't grow sideways into the control, and size the icon button up on touch (pointer-coarse:h-12 pointer-coarse:w-12) | Two overlays in a 4px-gap cluster would fight for the same taps |
| Rows that already tile with no gap (dropdown panel items) | Grow the row's own padding on touch (pointer-coarse:py-3) to reach 48px | No overlay needed; padding already tiles seamlessly |
Header.astro shows all three at once — the primary nav link gets the height-only shim (min-h-12, no min-w-12) beside a pointer-coarse:h-12 pointer-coarse:w-12 caret button, the mobile-nav children list carries pointer-coarse:gap-6, and dropdown rows use pointer-coarse:py-3. Footer.astro adds pointer-coarse:gap-6 to its link columns.
Accessibility chrome that stays hardcoded
The site's governing principle is Sanity is the single source of truth for copy; an empty field renders nothing. Accessibility chrome is one of the narrow, deliberate exceptions to that rule (documented under "Exceptions to 'no hardcoded copy'" in website/CLAUDE.md).
The hardcoded a11y strings are: aria-labels, "Skip to content", "Open menu" / "Close menu", and section landmark names. These are screen-reader landmarks, not display copy — making them editor-controlled would invite i18n complexity that isn't on the roadmap. So when you find these literals in source, they are intentional, not a missed extraction:
- Every
*Section.astrocarries a staticaria-label="<purpose>"on its<section>giving the section a screen-reader name (see Components for the section shape). - The header's primary nav is
<nav aria-label="Primary">; dropdown toggles getaria-label={Open ${item.label} submenu}. - The audit island root is
aria-label="Free website audit".
Other hardcoded exceptions in the same list that touch a11y: SVG icon markup (arrow icons, dropdown carets, menu glyphs, the / marquee separator — all marked aria-hidden="true" where decorative) and structural URLs (the / home-link on the Header/Footer wordmark anchors).
Skip link as the first focusable element
The skip link is rendered in Layout.astro as the very first focusable node inside <body>, before the <Header>:
<a href="#main" class="skip-link">Skip to content</a>
<Header siteSettings={siteSettings} />
<main id="main" class="flex-1">
<slot />
</main>
It's parked off-screen until focused, then snaps to the top-left corner — pure CSS in global.css:
.skip-link {
position: absolute;
left: -9999px;
top: 0;
z-index: 100;
padding: 0.75rem 1rem;
background: var(--color-ink);
color: var(--color-paper);
}
.skip-link:focus { left: 0; }
The target is <main id="main">, so the first Tab on any page lets a keyboard user jump the nav. (The audit's programmatic probe filters .skip-link out as a false positive precisely because it's parked at left:-9999px for a11y.)
The mobile nav is a vanilla <dialog> with a focus trap and inert on <main> while open (see Components), so focus can't leak behind the open menu.
Real <label>s on inputs
Form inputs get a real associated <label>, even when it's visually hidden. The audit tool's URL field, from WebsiteAudit.tsx:
<label htmlFor="audit-url" className="sr-only">
Your website URL
</label>
<input
id="audit-url"
name="url"
type="text"
inputMode="url"
autoComplete="url"
required
aria-invalid={!!urlError}
aria-describedby={urlError ? 'audit-url-error' : undefined}
...
/>
The pattern across the audit island: a sr-only <label htmlFor> wired to the input id, semantic type/inputMode/autoComplete, aria-invalid + aria-describedby pointing at the error node only when an error exists, an aria-live="polite" region for progress and results, and aria-label on the progress bar and results panel. Placeholders are decoration, never a label substitute. Lead capture is documented on the HubSpot & lead capture page; the audit tool on Audit overview.
Reduced motion
Every animation on the site has a prefers-reduced-motion off-ramp, handled in two coordinated places — CSS strips the visual, and the JS runtime keeps internal state consistent.
CSS in global.css forces every reveal unit visible and kills its transition:
@media (prefers-reduced-motion: reduce) {
.js [data-section-anim] [data-reveal] {
opacity: 1 !important;
transform: none !important;
transition: none !important;
}
}
The runtime (Animations.astro) checks the media query and, when reduced motion is set, marks every unit visible immediately and returns before any observer is wired:
if (prefersReducedMotion) {
// Mark ALL units (incl. nested), not just observed top-level ones, so
// internal state matches the CSS (which forces them visible via !important).
document
.querySelectorAll('[data-section-anim] [data-reveal]:not([data-reveal-visible])')
.forEach((el) => el.setAttribute('data-reveal-visible', ''));
underlines.forEach((el) => el.classList.add('is-drawn'));
return;
}
Mark nested units, not just observed ones
The reduced-motion branch must mark all [data-reveal] descendants, not just the top-level observed ones, or internal state drifts from the CSS — which forces every nested unit visible with !important. Marking only the observed roots leaves nested units flagged hidden in JS even though they paint visible.
The same gate is honored everywhere motion lives:
- Hero headline (GSAP) is wrapped in
gsap.matchMedia('(prefers-reduced-motion: no-preference)'), so reduced motion / no-JS keep the static SSR headline. See Hero headline animation. - Cursor grid glow (
GridGlow.astro) skips attaching listeners entirely on touch or reduced motion — no work is queued on those devices. - Marquee pauses on hover/focus and honors
prefers-reduced-motion(its CSS animation is gated on.js+no-preference). - Sticky scroll / image comparison keep their functional open/close and a static resting card under reduced motion; only the decorative scrub/slide is dropped, and keyboard nudges are eased unless reduced motion is set.
- Hero explorer keeps a brief opacity-only fade instead of a hard image swap.
The whole reveal system is also gated on a .js class added synchronously by an inline <head> script — without JS the hidden-state CSS never matches, so everything renders visible. See Scroll-in reveals.
The responsive audit
A one-time Responsiveness Audit (dated 2026-06-01) lives at website/_reference/MOBILE-AUDIT/RESPONSIVE-AUDIT.md. It's a historical artifact — a record of findings and what shipped, not a living config.
Methodology
Playwright-driven capture (real Chromium) against the local dev server in two layers:
- Programmatic per-breakpoint DOM probe (
window.__audit()injected per page) measuring page horizontal overflow (scrollWidthvsinnerWidth), root-cause overflowing elements, off-screen-left content, sub-12px text with real content, media exceeding the viewport, and interactive elements under 40px (touch targets). - A fan-out of vision agents reviewing per-section crops + full-page screenshots against the Astro component source — one agent per section, one per page, each grounded in the source.
It ran at 8 Tailwind breakpoints (320 / 375 / 640 / 767 / 768 / 1024 / 1280 / 1536 px) across /seed, /services, /blog, and /blog/post-1. Scroll-in animations were forced visible so screenshots aren't blank, and two dev-only elements (.skip-link at left:-9999px, and the AnimDebug toggle) were filtered from the probe as false positives.
Findings (ranked)
The objective layer was clean everywhere: no horizontal scroll, no off-screen/clipped content, no sub-12px text, and no image/SVG/video overflow on any of the 4 pages × 8 breakpoints. The findings were about polish, fit, and a couple of content gaps:
| # | Severity | Issue | Status |
|---|---|---|---|
| 1 | Critical | /services closing CTA renders as an empty green band (empty Sanity fields, not a code bug) | Content to-do (R2) |
| 2 | Major (systemic) | SectionHead h2 fixed at 48px → oversized headings & orphan words on mobile across ~7 sections | Done |
| 3 | Major | hero-explorer H2 (48px) bigger than the H1 (44px) on phones → inverted hierarchy | Done |
| 4 | Major | Centered blog-post body centered while header left-aligned → stair-stepped masthead | Done |
| 5 | Major | Blog index: 2 posts in a 3-col grid → sparse; empty cover placeholders top-heavy | Done (code); covers = content |
| 6 | Minor | Touch targets below 44px (FAQ toggle 28px; wordmark links 32px) | Done |
| 7 | Minor | 768–1023 "tablet" band stays single-column (columns only break at lg) | Deferred (design choice) |
The highest-leverage fix was #2: one shared component edit (SectionHead.astro) plus three bespoke headings resolved most of the mobile-heading orphan findings at once, using a moderate step-down (text-3xl/9 sm:text-4xl/11 lg:text-5xl/12 text-balance) on the Tailwind scale with /N line-height modifiers — no new arbitrary font sizes. The touch-target fixes (#6) added vertical padding to the FAQ <summary> and the header/footer wordmark links to reach ≥44px; the standalone-link shim pattern above is the general-purpose mechanism that superseded ad-hoc padding.
What's still open
The remaining items are mostly content (owner-supplied in Studio): the /services closing CTA needs its ctaSection heading + primary CTA populated (the component correctly renders nothing when fields are blank), plus client logos, post covers, case studies, and the founder portrait. The lg-only column break (#7) is a defensible design choice, kept on purpose. One coverage caveat: prose horizontal overflow from a fenced code block, <pre>, a long unbroken URL, or a wide table is unverified — the seeded posts don't contain those.
Where this lives
| File | What it holds |
|---|---|
website/CLAUDE.md (§ "Touch targets — standalone links") | The WCAG 2.5.8 shim spec and the three clustering patterns |
website/CLAUDE.md (§ "Exceptions to 'no hardcoded copy'") | Why the a11y chrome strings stay hardcoded |
website/src/layouts/Layout.astro | Skip link as first focusable; <main id="main"> target |
website/src/styles/global.css | .skip-link styles; the prefers-reduced-motion reveal off-ramp |
website/src/components/Animations.astro | Reduced-motion branch in the reveal runtime |
website/src/components/layout/Header.astro | All three touch-cluster patterns; nav aria-labels; mobile <dialog> |
website/src/components/layout/Footer.astro | pointer-coarse:gap-6 on link columns; wordmark shim |
website/src/components/sections/StickyScrollSection.astro | The canonical standalone-link hit-area shim |
website/src/components/audit/WebsiteAudit.tsx | sr-only <label htmlFor>, aria-live, aria-describedby patterns |
website/_reference/MOBILE-AUDIT/RESPONSIVE-AUDIT.md | The full responsive audit: methodology, ranked findings, round status |
Related: Scroll-in reveals · Hero headline animation · Components · Performance · Design best-practices.