This site treats accessibility as a property of the rendered HTML and CSS, not as a layer bolted on later. The posture is documented here so future changes don't quietly regress it.
<html lang="en-US">declares the document language for screen readers and translation services. Set insrc/layouts/BaseLayout.astro.- Every route renders a single
<main id="main">. The skip link and analytics tools assume exactly one main landmark per page. - Headings start at
<h1>(page title or writeup title) and descend. Writeup bodies start at<h2>because the article shell already renders the title ash1. Seedocs/SEO.md.
BaseLayout.astro renders a <a class="skip-link" href="#main"> as the first focusable element on every page. The element is visually translated off-screen by default and slides into view when it receives focus, satisfying WCAG 2.4.1 (Bypass Blocks) without permanently consuming layout. Style in src/styles/base.css under .skip-link.
- Primary navigation:
<nav aria-label="Primary navigation">insrc/components/Header.astro. - Mobile navigation:
<nav aria-label="Mobile navigation">withpopover="auto"so the browser handles focus trap, escape-to-dismiss, and outside-click. - Active nav item:
aria-current="page"on the matching link. - Contact form:
role="status" aria-live="polite"on the submit-result region so screen readers announce success or failure without stealing focus. - Decorative SVGs (social icons, defs sheet):
aria-hidden="true" focusable="false". Each social link declares its destination viaaria-label, since the icon itself carries no accessible name.
Cover images carry cover_alt in writeup frontmatter. The site sync mirrors it to writeup.heroAlt, and both the ProjectCard listing and the article hero <figure> use that string as the <img alt>. When cover_alt is empty the title is used as a fallback so no image ever renders without alt text. prepare_writeup_publish in the vault MCP nags about missing cover_alt so drafts don't ship with duplicated-title alts.
Body images use the alt text from the markdown source. The ![|width|nocap] directive in src/lib/content.ts only modifies layout — the alt text itself is preserved verbatim into the rendered <img>.
Decorative-only images should use alt="", never omit the attribute. Currently no images on the site use this case.
Visible focus is preserved on every interactive element. Custom focus styles live next to their components in src/styles/base.css: .skip-link, .brand, .nav-link, .archive-tag, .page-actions a, and others. The site uses :focus rather than :focus-visible, meaning focus rings render on mouse interaction as well as keyboard — intentional, since the cost is cosmetic and the benefit is that low-vision mouse users still see the focused control.
Mobile navigation uses the native popover API. The browser handles focus restoration when the popover closes, eliminating a class of bugs around losing focus to an unmounted element.
A @media (prefers-reduced-motion: reduce) block at the bottom of src/styles/base.css collapses every animation and transition to 0.01ms and forces scroll-behavior: auto. These declarations use !important so component-level transition selectors cannot override the user preference. Users who set the OS preference get static visuals without per-element opt-outs.
The forced-colors: active block preserves visible borders and focus outlines for buttons, tags, form controls, cards, navigation, and tooltips. Backdrop blur is disabled because forced-colors surfaces should remain opaque and system-controlled. Chromium emulation verifies the contact form focus and control borders in Playwright.
Verified flows:
- Skip link → main content.
- Primary nav links → reachable in DOM order.
- Mobile nav toggle → opens the popover; Escape closes it; focus returns to the toggle.
- Contact form → submit triggers via Enter; status region announces result to screen readers.
- Tag pills on writeup footers → focusable, declare destination via link text.
- Mobile nav links → close the popover on activation so focus continues into the destination page.
Nothing on the site requires a pointer to operate.
The primary palette is documented in CSS custom properties at the top of src/styles/base.css. Body text and primary surfaces target WCAG 2.1 AA (4.5:1 for normal text, 3:1 for large text).
Spot-checked combinations on the live palette:
--color-texton--color-bg(default body text): ≈ 18:1--color-mutedon--color-bg(card metadata, captions, dates): ≈ 5.2:1--color-texton--color-soft(hero chip, card media surface): ≈ 17:1- Terminal label
#94a3b8on#111827: ≈ 6.4:1 - Success status
#166534onrgba(22,101,52,0.1)over white: ≈ 6:1
All current pairs meet AA. Update this list when a new component introduces a novel color-on-color combination.
- No accessibility statement page. This is a personal portfolio, not a public service; the documentation in this file is the statement.
- No high-contrast theme toggle. The single light palette is the design intent. OS-level inversion remains browser-controlled, while the explicit
forced-colors: activerules preserve control boundaries and focus. - No font-size scaler. Browsers handle zoom and reflow; the layout is responsive down to 320px without horizontal scroll.
Quick checks after layout or component changes:
npm run build:static
npm run lint:css
npm run check:css-vars
npm run check:contrast
CI=1 ASTRO_TELEMETRY_DISABLED=1 npm run test:e2e
CI=1 ASTRO_TELEMETRY_DISABLED=1 npm run test:e2e:visual -- --project=chromium-desktop
# Then in the built HTML:
# - exactly one <main id="main"> per page
# - every <img> has an alt attribute
# - every interactive element renders a visible focus state under keyboard tabPlaywright runs the functional accessibility and layout checks in Chromium,
Firefox, and WebKit desktop/mobile projects. Chromium on macOS owns the visual
baselines to avoid engine- and OS-specific font-rasterization noise. When an
intentional design change alters a baseline, inspect the expected, actual, and
diff images first, then run npm run test:e2e:visual:update -- --project=chromium-desktop. Commit the reviewed PNG changes with the frontend
change; GitHub's image diff becomes the version-to-version visual audit trail.
Never update snapshots only to make CI green. A manual keyboard pass through
the home, a portfolio article, the portfolio listing, and the contact form
remains useful before large interaction changes.
Three layers of this posture are machine-enforced on every gate run: static
WCAG contrast math over the color tokens (npm run check:contrast),
structural HTML over every built page — unique ids, alt on every image
(npm run check:html) — and an axe-core WCAG A/AA sweep over the key page
archetypes in a real browser
(tests/playwright/a11y.single.spec.ts).
docs/SEO.md— heading hierarchy and image alt strategydocs/Architecture.md— render modelSECURITY.md— contact form and form-field handling