Skip to content

Latest commit

 

History

History
456 lines (332 loc) · 34.2 KB

File metadata and controls

456 lines (332 loc) · 34.2 KB

Accessibility

OffOn is a platform for open source enthusiasts. We want everyone to be able to read, browse, and contribute, regardless of disability, assistive technology, or device. This document explains what we support today, how we test, and how to tell us when we get it wrong.


Our Commitment

  • Target: WCAG 2.2 Level AA across every page on offon.dev, with selected WCAG 2.2 Level AAA criteria applied wherever practical. AA is the floor, not the goal.
  • Both color modes: light and dark mode must meet contrast and focus requirements. We do not ship a feature that only works in one mode.
  • Keyboard first: every interactive element is reachable and operable from the keyboard alone.
  • No motion traps: we honor prefers-reduced-motion and avoid auto-playing animation that the user did not request.

AAA criteria in scope

The following WCAG 2.2 Level AAA criteria are actively targeted on this site:

Criterion What we do
1.4.6 Contrast (Enhanced) Body text targets 7:1; large text targets 4.5:1 in both modes.
2.4.9 Link Purpose (Link Only) Every link must make sense without surrounding context. Card links use aria-label with the specific item name. Ambiguous text like "via email" is rewritten or wrapped in a descriptive label.
2.4.10 Section Headings Headings are used to organize all content sections, including sidebar sub-sections which use <h3>.
2.4.13 Focus Appearance (Enhanced) Focus indicators target a minimum 2px perimeter, ≥3:1 contrast against the unfocused state, and a minimum enclosed area. Verified by full keyboard traversal in e2e/a11y.spec.ts (dark and light modes).
3.1.3 Unusual Words Technical terms (e.g. devcontainer) are defined on first use via <abbr title="…"> or inline expansion.
3.1.4 Abbreviations Abbreviations are expanded on first use per page: <abbr title="…"> for inline HTML; written out in full for plain-text contexts (e.g. "Site Reliability Engineers (SREs)").
3.1.5 Reading Level General copy targets plain language. Technical content is inherent to the subject; abbreviations and unusual words are expanded on first use. Challenge-specific content is authored by contributors and may be technical by nature.

What We Support Today

  • Skip-to-content link as the first focusable element on every page.
  • Visible focus rings on all interactive elements, in both light and dark mode.
  • Semantic landmarks: one <main id="main-content">, plus <nav>, <header>, <footer>, <section>, and <article> where appropriate. The site-wide <nav aria-label="Main"> in Navbar.astro is wrapped in <header> so it provides the banner landmark. Never place the primary nav outside a <header> element.
  • Heading rules: one <h1> per page with no skipped heading levels. Never place a heading element (<h1>-<h6>) inside a <summary> element (which has role="button"); the ARIA spec forbids heading semantics in a button role. Use a screen-reader-only heading before <details> for document outline, and a <span> inside <summary> for the visible label.
  • One <h1> per page with no skipped heading levels (see heading rules above).
  • Meaningful alt text on informational images, empty alt="" paired with aria-hidden="true" on decorative ones.
  • Screen reader announcement of links that open in a new tab.
  • Color contrast verified at 7:1 for body text and 4.5:1 for large text (both WCAG AAA), and 3:1 for UI controls, in both modes.
  • Tested with axe-core on every pull request preview, in both light and dark mode.
  • Self-hosted fonts so users on restricted networks are not locked out.
  • Google Analytics is opt-in only via the consent banner. No tracking runs until the user accepts.

Supported Environments

  • Modern evergreen browsers: Chrome, Edge, Firefox, Safari (current and previous major versions).
  • Mobile web on iOS Safari and Android Chrome.
  • Screen readers we test against during manual spot checks: VoiceOver on macOS and iOS, NVDA on Windows.
  • The site is fully static and served from GitHub Pages, so it works without JavaScript for reading content. Some interactive features (theme toggle, consent banner, filtering) require JavaScript.

Known Limitations

  • We do not currently provide captions or transcripts because the site does not host video or audio. If we add media, captions and transcripts will ship with it.
  • The community discussion content is hosted on a separate Discourse instance and follows its own accessibility status.

If you find a barrier that is not listed here, please report it using the link below. We treat this list as evidence-based, not aspirational.


How We Test

Automated

All automated checks run in e2e/a11y.spec.ts against the production build via Playwright on every pull request. The PR preview workflow blocks on these scans.

Check WCAG Notes
axe-core (dark mode) Full tag set: wcag2a, wcag2aa, wcag21a, wcag21aa, wcag22aa, best-practice Never reduce this tag set.
axe-core (light mode) same .light class set via localStorage before navigation.
axe-core (forced colors) same minus color-contrast Emulates Windows High Contrast Mode. color-contrast excluded: emulation fires the media query but does not remap computed colors, producing false positives.
Touch target minimum size 2.5.8 Every non-inline interactive element in the viewport is ≥24×24px.
Focus ring traversal (dark + light) 2.4.7, 2.4.13 Tabs through every focusable element on every page; fails any element with no outline or box-shadow on :focus-visible.
Skip link 2.4.1 First Tab stop is the skip link; activating it moves focus to #main-content. Tested on a representative route sample, both on a direct load and after arriving by a real link click. The link-click cases matter: nothing may move focus on load, or the skip link stops being the first Tab stop on every in-site navigation while the direct-load tests still pass.
Keyboard trap detection 2.1.2 Tabs through every page; detects repeating focus patterns (cycle length 1–5) that exclude the page's first focusable element, indicating focus is stuck.
Context change on focus 3.2.1 Tabs through every page; fails if the URL changes after a Tab press (navigation triggered by focus).
Zoom/reflow 1.4.10 Viewport set to 384px (equivalent to 200% zoom on 768px); asserts no horizontal scrollWidth overflow.
Very small text — No visible text node below 10px (WAVE "very small text" threshold).

Automated axe passes are necessary but not sufficient. Automated tools catch roughly 30–40% of real-world accessibility issues. Manual testing is required for every interactive component.

Manual

For every UI change, step through each persona below and verify the listed checks. These are the failure modes axe-core cannot catch. A change is not done until all six personas pass.

1. Screen Reader Navigator (non-visual)

Simulates a user who navigates entirely by audio output.

  • Spot-check the changed flow with VoiceOver (macOS/iOS) or NVDA (Windows). See the screen reader quick reference below.
  • Every interactive element is announced with its role and accessible name.
  • Dynamic content updates (filters, route changes, consent state) are announced without forcibly moving focus.
  • Heading order matches the visual reading order with no skipped levels.
  • aria-current="page" is set on the active nav link, paired with a visible indicator (color change or underline). Never rely on aria-current alone — it is invisible to sighted users.
  • No vague or duplicate link text ("view", "read more", "click here" alone).

2. Power Keyboard User (motor limit)

Simulates a user who operates the page with Tab, Shift+Tab, Enter, Space, and arrow keys only.

  • Tab through the entire changed flow without a mouse. Every interactive element is reachable.
  • Tab order follows a logical visual reading order.
  • No focus traps. Every trap-like UI (modal, dropdown) has a working Escape exit.
  • Focus ring is visible on every interactive element in both light and dark mode.
  • After dynamic changes (filter apply, modal close, route change), focus lands on a logical element — not reset to the top of the page.
  • Skip link is the first focusable element and is visible on focus.

3. Magnification Expert (low vision)

Simulates a user navigating with browser zoom at 200% and 400%.

  • At 400% zoom: no horizontal scrolling required. Content reflows into a single column (WCAG 1.4.10).
  • Text is not truncated or clipped at high zoom.
  • No sticky/fixed elements that consume more than half the viewport height at 400%.
  • Focus ring remains clearly visible at high zoom.
  • Verify at 375px, 768px, and 1280px widths against the production build.
  • Windows High Contrast Mode: interactive states (hover, focus, disabled) are visible. Do not rely solely on background-color or semi-transparent borders.

4. Cognitive Strategist (neurodivergent)

Simulates a user sensitive to clutter, inconsistent UI, and unpredictable behaviour.

  • Page title clearly reflects where the user is in the site.
  • Multi-step flows (e.g. challenge instructions) have a visible indication of progress or position.
  • UI controls are consistent: the same action uses the same element and label across all pages.
  • No auto-advancing UI (carousels, auto-dismiss toasts) that interrupts reading without user intent.
  • CTA labels clearly state the outcome ("Start the Beginner level" not "Go").
  • Error states identify the problem in plain language and suggest a fix — no technical "fail" messages.

5. Vestibular User (motion sensitivity)

Simulates a user for whom animations can trigger motion sickness.

  • Every animation and transition in the changed code is gated by @media (prefers-reduced-motion: no-preference) in CSS, or by window.matchMedia('(prefers-reduced-motion: reduce)') in JS.
  • No parallax or scroll-linked motion effects.
  • No auto-playing animations triggered without user intent.
  • No content that flashes more than three times per second (WCAG 2.3.1).

6. Distracted / Fatigued User (situational limit)

Simulates a user navigating under high cognitive load.

  • System status is clear after every action: the user knows whether something succeeded, is loading, or failed.
  • Consent and theme state are visually obvious at a glance (the banner or the toggle reflect the current state).
  • No session timeouts on this static site — confirm no new timed behaviour has been introduced.
  • The purpose of every CTA is unambiguous without surrounding context.

Screen reader quick reference

Use these commands to run through a changed flow without a full-session setup.

VoiceOver on macOS (VO = Caps Lock or Ctrl+Option)

Task Keys
Toggle VoiceOver on/off Cmd+F5
Read next / previous item VO+→ / VO+←
Navigate to next heading VO+Cmd+H (and Shift to go back)
List all headings VO+U, then select Headings
List all landmarks VO+U, then select Landmarks
List all links VO+U, then select Links
Activate a link or button VO+Space
Enter / exit a web area VO+Shift+↓ / VO+Shift+↑
Stop speaking Ctrl

NVDA on Windows (NVDA key = Insert by default)

Task Keys
Toggle NVDA on/off Ctrl+Alt+N (installer default)
Read next / previous item ↓ / ↑ (browse mode)
Navigate to next heading H (and Shift+H to go back)
Navigate to next landmark D (and Shift+D to go back)
List elements dialog NVDA+F7
Activate a link or button Enter
Toggle browse/focus mode NVDA+Space
Stop speaking Ctrl

What to verify in every changed flow:

  1. Every interactive element is announced with its role (button, link, etc.) and accessible name.
  2. Dynamic content updates (state changes, filter results) are announced without forcibly moving focus.
  3. No phantom tab stops appear (e.g. from SVGs missing focusable="false" or decorative elements missing aria-hidden).
  4. Heading order matches the visual reading order.

Reporting an Accessibility Barrier

If something on offon.dev blocks you or is hard to use, please tell us.

We aim to acknowledge accessibility reports within five working days and to provide a workaround or fix timeline in the same response.

Severity

Severity Definition
Critical Blocks a user from completing a core task (reading content, navigating to a challenge, accepting consent).
High Significant difficulty, but a workaround exists.
Medium Inconsistent or annoying experience that does not block the task.
Low Minor issue with minimal impact on usability.

For Contributors

Every UI change must pass the checklist below before the PR is submitted. See AGENTS.md for project conventions.


Contributor Checklist (Required for Every New Component)

Apply this to every component you write or modify.

Color contrast

  • Normal text (under 18px / non-bold under 14px): minimum 7:1 (WCAG AAA).
  • Large text (18px+ or bold 14px+): minimum 4.5:1 (WCAG AAA).
  • UI components and focus indicators: minimum 3:1 against adjacent colors.
  • Focus indicators (WCAG 2.4.11): the focus indicator area must be at least as large as a 2px perimeter outline of the component, and the focused/unfocused contrast ratio must be at least 3:1.
  • Never use hsl(41 100% 60%) (#ffc034 yellow) as text in light mode. Fails contrast.
  • Never place text on bg-primary without verifying light mode contrast.
  • Never use opacity-* on an element that contains visible text. Use an explicit CSS color token instead (e.g. text-[hsl(var(--text-faint))]).
  • Always verify contrast in both light and dark mode.
  • Never rely on color alone to convey meaning. Always pair with text, icon, or pattern.
  • Hover state contrast in light mode: hover:text-primary resolves to amber (#ffc034) on a light surface, which fails contrast. Never use hover:text-primary on its own. Use hover:text-foreground dark:hover:text-primary so light mode gets a dark, accessible color and dark mode gets the amber accent. Apply the same logic to any interactive element whose hover color differs by mode.
  • Icon/indicator colors in light mode: CSS variables like --difficulty-builder are set to a pale tint in light mode (hsl(85 48% 75%)) and will be near-invisible on light surfaces. Do not use these variables for icon foreground colors. Use a hardcoded accessible value (e.g. #15803d for green) that passes contrast in both modes.

Focus rings

  • Pattern: focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-sm. Inline elements: ring-offset-1.
  • Always use ring-ring, never ring-primary/xx.
  • Hover states must not change layout properties (padding, border, font-weight, width). Use color and opacity only.

Keyboard navigation

Use the correct keys for each control type:

Control Required keys
Button Enter, Space
Link Enter
Checkbox Space to toggle
Radio group Arrow keys to move; Space to select
Dialog Escape to close; focus trapped inside while open
Tab widget Arrow keys between tabs; Enter/Space to activate
Combobox Arrow keys in list; Enter to select; Escape to collapse
  • Every interactive element must be reachable and operable via keyboard.
  • Tab order must follow a logical reading order.
  • Never remove focus outlines.
  • Modals must trap focus while open and return focus to the trigger on close. Apply inert (and aria-hidden="true" for older AT) to all direct <body> children except the modal host. Escape must close the modal and restore focus to the trigger. Focus must move to the first interactive element on open. No modal component is currently installed; implement the contract directly rather than pulling in a library speculatively.
  • Dropdown menus must use the Disclosure pattern: <button aria-expanded="false" aria-controls="submenu-id"> toggling a <ul id="submenu-id" hidden>. Escape must close the dropdown and return focus to the trigger. Hover-triggered dropdowns must not vanish immediately — use a CSS transition-delay of at least 200ms.
  • aria-haspopup rule: only use aria-haspopup when the controlled element has one of the five ARIA popup roles: menu (or true), listbox, tree, grid, dialog. For a disclosure button controlling a role="group" panel, aria-haspopup must be omitted entirely. aria-expanded + aria-controls is the complete and correct pattern. Using any aria-haspopup value on a role="group" disclosure misleads AT about the expected interaction. Source: WAI-ARIA APG Disclosure Button pattern and WAI-ARIA 1.2 spec.
  • Focus management when closing by navigation vs. Escape: closing a menu because the user pressed Escape must restore focus to the trigger (WAI-ARIA APG Disclosure Navigation keyboard table). Closing because a navigation link was clicked must NOT restore focus to the trigger — the router/page owns focus after navigation. Passing restoreFocus: false (or equivalent) from the link's onClick handler prevents the trigger from stealing focus from the incoming page. Never conflate the two close paths.
  • Inert coverage: when an overlay or mobile menu opens, apply inert (and aria-hidden="true" for older AT) to ALL direct children of <body> except the element hosting the overlay. Targeting only named landmarks (#main-content, footer) leaves the consent banner, skip-nav, and any other body-level sibling reachable by AT. Iterate document.body.children and exclude the overlay's nearest body-child ancestor. Source: W3C APG Modal Dialog pattern; MDN inert attribute.
  • Composite widgets (toolbars, radio groups, tab lists) must use roving tabindex so only one item is in the tab stop at a time and arrow keys move within the group. See WAI-ARIA APG: Roving tabindex.
  • If sticky headers or floating elements exist, ensure focused elements are not fully hidden behind them (WCAG 2.4.12 Focus Not Obscured). Add to CSS: :focus-visible { scroll-margin-top: var(--sticky-header-height, 4rem); }

Touch targets

  • Interactive elements must have a minimum touch target of 24x24px (WCAG 2.5.8). Prefer 44x44px for primary actions.
  • Never rely on padding alone when the visible element is smaller than 24px.

Motion

  • All animations and transitions must respect prefers-reduced-motion.
  • Wrap motion in @media (prefers-reduced-motion: no-preference) in CSS, or check window.matchMedia('(prefers-reduced-motion: reduce)') before triggering JS-driven animation.
  • Also respect prefers-contrast: more (users requiring higher contrast) and forced-colors: active (Windows High Contrast Mode) in any component that communicates state through color or opacity alone. See the Windows High Contrast Mode section.

Semantic HTML

  • Use the correct element for the job (<button> for actions, <a> for navigation, <nav>, <main>, <header>, <footer>, <article>, <section>).
  • Never use a <div> or <span> as an interactive element.
  • Never use role="menu" or role="menuitem" on site navigation. These roles put screen readers into application mode where arrow keys replace Tab, breaking web navigation expectations. Use native <a> elements inside <nav> instead.
  • One <h1> per page. No skipped heading levels.
  • A <section> becomes a landmark (role="region") only when it has an accessible name, and it then appears in the screen reader's landmarks menu. Name it only when it is a genuinely distinct region worth a navigation shortcut, and name it with aria-labelledby pointing to a real visible heading. Never invent a synthetic aria-label string that no one sees — a generically named region is noise in the landmarks menu. If a block has no heading and is not a distinct region, use a plain <div>, not a <section>. Name an <article> by its heading via aria-labelledby the same way.
  • Never apply overline/label typography (text-sm uppercase tracking-widest) to a heading tag. If the text is a genuine section heading, give it heading-appropriate typography. If it is purely decorative, use <span> or <p>.
  • Never use a non-heading tag for text visually styled as a heading. Promote it to the correct heading level.
  • Every page's primary content must live inside a single <main id="main-content">. Do not split content across multiple <main> elements.
  • <html lang="en"> is set in src/layouts/Layout.astro. Never remove or change it. If a page includes content in another language, add lang to that element.
  • Tables must include <caption> or aria-label, and header cells must use scope="col" or scope="row".

Images and media

  • Every <img> must have an alt attribute. No exceptions.
  • Meaningful images: alt describes the content or purpose.
  • Decorative images: alt="" AND aria-hidden="true" together.
  • Never use aria-hidden="true" when alt text is present.
  • Set explicit width and height on every <img> to prevent layout shift.

External links

  • Every <a target="_blank"> must reference the shared new-tab hint with aria-describedby="new-tab-hint". A single hidden <span id="new-tab-hint" hidden>opens in a new tab</span> is rendered once in Layout.astro. Do not fold "opens in a new tab" into the link text or aria-label, and do not add a per-link sr-only span — the hint is an accessible description, not part of the name (padding the name breaks voice control, WCAG 2.5.3 Label in Name).
  • Never render a non-navigable URL as a link. Loopback / localhost / single-label hosts (e.g. http://localhost:8080/) must be plain text — on the deployed site a link there points at the visitor's own machine. The adventure generator (annotateExternalLinks) unwraps these automatically; in JSX, write them as text, not <a>.

Links

  • Every link's text must describe its destination. Never use "click here", "read more", or "more" alone as link text.
  • When link text is ambiguous in isolation (e.g. "View" repeated on every card), add aria-label on the <a> to provide a full, descriptive accessible name.
  • Every <a> element must have an accessible name (text content, aria-label, or aria-labelledby). An <a> with no accessible name is invisible to screen readers and cannot be operated by voice control.
  • When using aria-label on a link, include the visible link text in the label. Voice control users speak visible text to activate links; if the accessible name does not contain that text, the link cannot be activated by voice (WCAG 2.5.3 Label in Name).

Icons and special characters

  • Decorative icons paired with visible text: aria-hidden="true".
  • Icon-only interactive elements: aria-label on the parent, no aria-hidden.
  • Never use raw Unicode characters (→, ♥, ✓) to convey meaning.
  • Decorative separators between pill segments: use an empty <span aria-hidden="true" className="inline-block w-px h-3 bg-current opacity-40" /> instead of a text character.

SVG

  • Always add focusable="false" to inline <svg> elements. Without it, Internet Explorer and some Edge versions make every SVG tab-focusable, creating phantom tab stops.

  • Decorative SVGs (icons next to text, or purely visual): aria-hidden="true" and focusable="false". No <title>.

  • Meaningful SVGs conveying information without adjacent text (e.g. standalone infographics): add role="img", a <title> as the first child with a unique id, and aria-labelledby pointing to that id.

    <svg role="img" aria-labelledby="chart-title" focusable="false">
      <title id="chart-title">Monthly active contributors: 142</title>
      ...
    </svg>
  • Never use aria-label directly on <svg>. Support across assistive technologies is inconsistent. Use the <title> + aria-labelledby pattern instead.

  • For icons from unplugin-icons (lucide set): always pass aria-hidden={true} when the icon is decorative (next to visible text). For icon-only buttons, put aria-label on the parent <button> or <a>, not on the <svg>.

  • Brand SVGs (e.g. LinkedIn in Footer.astro): set aria-hidden="true" on the <svg> and aria-label on the parent interactive element. Use fill="currentColor" so hover and theme color changes apply. See the Icons section of styleguide.md.

ARIA

  • Only add ARIA attributes when semantic HTML is not enough.
  • Never use ARIA to paper over bad markup. Fix the markup first.
  • Use role="status" (implicit aria-live="polite") for non-urgent updates like form success messages.
  • Use role="alert" (implicit aria-live="assertive") only for errors requiring immediate attention. Never use aria-live="assertive" for informational updates.
  • Use aria-expanded on toggles that open/close UI. Keep the toggle's accessible name constant across states: name it for what it controls (e.g. aria-label="Menu") and let aria-expanded carry open/closed. Never swap the name between "Open menu" / "Close menu" — that announces the state twice and can contradict aria-expanded.
  • Always add aria-label or aria-labelledby to icon-only buttons.
  • Never use the title attribute to convey information. It is not exposed on touch, is unreliable for keyboard users, and is announced inconsistently across screen readers. The only acceptable use is title on <iframe>. Use visible text, aria-label, or aria-describedby instead.
  • Disabled controls: use native disabled on form fields (<input>, <select>, <textarea>) — it correctly governs whether the value submits. For buttons, prefer aria-disabled="true" over native disabled when the control must stay discoverable in the focus order (a submit button, or an inactive-but-important control), so keyboard and screen reader users can still find it and learn why it is unavailable. Do not blanket-replace disabled with aria-disabled. When you use aria-disabled, suppress the action in JavaScript and style the state explicitly (it gets no user-agent dimming). See the /keyboard command for detail.
  • Preference toggles (theme, consent): announce state changes to screen readers with a role="status" live region. Clear the region then set new text after a 50ms delay so screen readers detect the change as a mutation: region.textContent = '' → setTimeout(() => { region.textContent = 'Theme switched to dark mode'; }, 50).

Tooltips

The tooltip uses a custom abbr[data-title] implementation — at build time, src/lib/markdown-pipeline.mjs rewrites <abbr title="..."> elements to use data-title attributes, and src/layouts/Layout.astro includes a position:fixed JS portal that displays the tooltip text on hover/focus, clamped to the viewport.

WCAG 1.4.13 requirements for all tooltips (JS portal and CSS)

All tooltip implementations on this site must satisfy three conditions:

  1. Dismissible: pressing Escape must close the tooltip without moving keyboard focus. The position:fixed JS portal handles this via a keydown listener. CSS-only tooltips (.md-inline abbr) cannot dismiss on Escape; this is a known limitation of the CSS path.
  2. Hoverable: the cursor must be able to move from the trigger onto the tooltip without the tooltip closing. The JS portal satisfies this. Never add pointer-events: none to a tooltip element that users are expected to read.
  3. Persistent: the tooltip must remain open as long as pointer or focus is within its bounds.

Forms

  • Every <input>, <select>, and <textarea> must have an associated <label> via for/id pairing or aria-label.
  • Never use placeholder text as a substitute for a label.

Skip navigation

  • Every page must have a skip link as the first focusable element targeting #main-content.
  • The skip link uses the .skip-nav class in src/styles/index.css. Never remove this class or its focus rules.
  • When adding a new page, always add id="main-content" tabIndex={-1} to its <main> element. Without tabIndex={-1}, activating the skip link scrolls the page but does not move keyboard focus -- the link is broken for keyboard users in Chromium and Safari.

Keyboard-scrollable overflow blocks

Wide <pre> blocks (code, ASCII diagrams, command output) that may overflow horizontally must be keyboard-reachable so users who cannot use a mouse can scroll them:

// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- makes this scrollable block keyboard-reachable per WCAG 2.1 SC 2.1.1
<pre tabIndex={0} aria-label="Architecture diagram" className="overflow-x-auto ...">
  • Add tabIndex={0} so the block enters the tab order.
  • Add aria-label describing the content type (e.g. "Architecture diagram", "Terminal output").
  • Suppress the no-noninteractive-tabindex lint rule with a comment citing WCAG 2.1 SC 2.1.1.
  • Do not add role — <pre> has no implicit ARIA role, and adding one (e.g. role="region") would create a spurious landmark in the document outline.

Hidden until found

  • For collapsible sections that should remain discoverable by browser find-in-page and screen readers, use hidden="until-found" instead of display: none or visibility: hidden. The browser auto-expands the section when the user searches for content inside it.
  • Do not use hidden="until-found" for content that must be genuinely hidden until an explicit interaction (e.g. a confirmation dialog). Use display: none for those cases.

Accessibility overlays

  • Never install third-party JavaScript "accessibility overlay" widgets that claim to auto-remediate WCAG issues at runtime. They do not reliably fix issues, frequently create new barriers for screen-reader users, and are not a substitute for genuine compliance work.

Windows High Contrast Mode

  • Test all interactive components with forced colors enabled.

  • Never rely solely on background-color or border-color with opacity to communicate interactive state.

  • Use @media (forced-colors: active) to restore visible borders where needed:

    @media (forced-colors: active) {
      .your-component { border: 1px solid ButtonText; }
    }

Minimum text size

  • Minimum visible text size is 12px (text-xs). Do not use text-[0.6rem] or smaller for any visible text.
  • Avatar initials and rank numbers that are aria-hidden are exempt.

Page content structure

  • All page content (including PageHero and BottomCTA) must be inside <main id="main-content">.

WCAG Principle Reference

Use this to identify which criterion applies before writing or reviewing code.

Principle Criterion Level What to verify
Perceivable (1.x) 1.1.1 Non-text Content A Every image, icon, and non-text element has a text alternative or aria-hidden.
1.3.1 Info and Relationships A Structure conveyed visually is also in markup (headings, lists, tables).
1.4.3 Contrast AA Normal text 4.5:1, large text 3:1 against background.
1.4.4 Resize Text AA Text readable at 200% zoom. Never user-scalable=no.
1.4.6 Contrast (Enhanced) AAA Body text 7:1, large text 4.5:1 in both modes.
1.4.10 Reflow AA No horizontal scroll at 400% zoom (320px viewport).
1.4.11 Non-text Contrast AA UI components and focus indicators: 3:1 against adjacent colors.
1.4.13 Content on Hover or Focus AA Tooltip content triggered by hover or focus must be dismissible (Escape), hoverable (cursor can move onto the tooltip without it closing), and persistent (stays open while hovered or focused).
Operable (2.x) 2.1.1 Keyboard A All functionality is keyboard-accessible.
2.1.2 No Keyboard Trap A Focus is never permanently trapped; Escape exits modals and dropdowns.
2.3.1 Three Flashes A No content flashes more than 3 times per second.
2.4.1 Bypass Blocks A Skip link is the first focusable element on every page.
2.4.3 Focus Order A Tab order follows logical reading order.
2.4.7 Focus Visible AA All focusable elements have a visible focus indicator.
2.4.9 Link Purpose (Link Only) AAA Every link is unambiguous without surrounding context. Card and CTA links use aria-label.
2.4.10 Section Headings AAA Headings organize all content sections, including sidebar sub-sections.
2.4.11 Focus Appearance AA Focus indicator is at least 2px, with 3:1 contrast against adjacent colors.
2.4.12 Focus Not Obscured AA Focused elements are not fully hidden by sticky headers or overlays.
2.5.3 Label in Name A Accessible name contains the visible text (required for voice control).
2.5.8 Target Size Minimum AA Touch targets are at least 24×24px.
Understandable (3.x) 3.1.1 Language of Page A <html lang="en"> is set.
3.1.3 Unusual Words AAA Technical terms defined on first use via <abbr title="…"> or inline expansion.
3.1.4 Abbreviations AAA Abbreviations expanded on first use per page.
3.1.5 Reading Level AAA General copy targets plain language; technical terms expanded on first use.
3.2.1 On Focus A Focusing an element must not trigger a context change (navigation, form submission, or any other automatic change).
3.3.1 Error Identification A Error messages identify the field and describe the error.
3.3.2 Labels or Instructions A Form fields have labels; placeholders are not substitutes.
Robust (4.x) 4.1.2 Name, Role, Value A ARIA roles and attributes are valid. Dynamic state (aria-expanded, aria-current) is kept in sync.

Acknowledgements

Portions of this document are adapted from The Website Specification by Joost de Valk and contributors, licensed under CC BY 4.0. Changes have been made.

Structure and approach informed by ACCESSIBILITY.md by Mike Gifford and contributors, licensed under MIT.