Accessibility
Pulse enforces a 100 Lighthouse Accessibility score as the baseline. The foundations — skip link, focus styles, and focus management on navigation — are provided by the framework on every page. There is nothing to configure and nothing to forget.
What the framework provides
| Feature | How |
|---|---|
| Skip link | Injected on every page as the first focusable element. Targets #main-content. Visible only on focus. |
| Focus styles | :focus-visible outline applied globally — purple, 3px, offset 2px. Suppressed for mouse users via :focus:not(:focus-visible). |
| Navigation focus | After client-side navigation, focus moves to #main-content, <main>, or <h1> — whichever is found first. Screen reader users hear the new page heading without a full reload. |
#main-content. Pages use <main id="main-content"> as the content wrapper so the link resolves correctly.Page structure
Each page view is wrapped in <main id="main-content"> with a single <h1> that describes the current page. Landmark elements communicate structure to assistive technology:
view: (state) => `
<main id="main-content">
<h1>Page title</h1>
<!-- page content -->
</main>
`
| Element | Purpose |
|---|---|
<header> | Site header, logo, primary nav |
<nav> | Navigation links — aria-label distinguishes multiple navs |
<main id="main-content"> | Primary page content — one per page |
<aside> | Supplementary content (sidebars, related links) |
<footer> | Site footer |
Interactive elements
Actions are expressed as <button> elements, navigation as <a href> links. Both are keyboard-accessible by default. <div> and <span> elements with click handlers are not reachable by keyboard:
<!-- Keyboard accessible -->
<"tok-fn">class="tok-kw">button "tok-fn">data-event="toggle">Open menu</"tok-fn">class="tok-kw">button>
<"tok-fn">class="tok-kw">a "tok-fn">href="/about">About</"tok-fn">class="tok-kw">a>
<!-- Not keyboard accessible — avoid -->
<"tok-fn">class="tok-kw">div "tok-fn">data-event="toggle">Open menu</"tok-fn">class="tok-kw">div>
<"tok-fn">class="tok-kw">span "tok-fn">onclick="...">About</"tok-fn">class="tok-kw">span>
Buttons that toggle state carry aria-expanded or aria-pressed to communicate the current state to screen readers:
view: (state) => `
<button data-event="toggleMenu" aria-expanded="${state.menuOpen}">
Menu
</button>
${state.menuOpen ? `<nav>...</nav>` : ''}
`
Focus management
When a modal or dialog opens, focus moves inside it. When it closes, focus returns to the element that opened it. Since Pulse updates the DOM via morphing rather than a full replacement, the triggering element stays in the DOM and receives focus back naturally.
The autofocus attribute on the first interactive element inside newly revealed content moves focus there after the DOM update — no JavaScript required:
view: (state) => `
<button data-event="openDialog">Delete item</button>
${state.dialogOpen ? `
<div role="dialog" aria-modal="true" aria-labelledby="dialog-title">
<h2 id="dialog-title">Confirm deletion</h2>
<p>This cannot be undone.</p>
<button autofocus data-event="confirmDelete">Delete</button>
<button data-event="closeDialog">Cancel</button>
</div>
` : ''}
`
Dynamic content announcements
State changes that produce status messages — loading indicators, confirmations, validation errors — are wrapped in live regions so screen readers announce them without a page reload:
| Role | Politeness | Used for |
|---|---|---|
role="status" | Polite — waits for the user to finish | Loading states, success messages, counts |
role="alert" | Assertive — interrupts immediately | Validation errors, destructive confirmations |
view: (state) => `
<form data-action="submit">
<!-- fields -->
<button type="submit" ${state.status === 'loading' ? 'disabled' : ''}>
${state.status === 'loading' ? 'Saving…' : 'Save'}
</button>
</form>
${state.status === 'loading' ? `
<p role="status">Saving…</p>
` : ''}
${state.errors.length ? `
<div role="alert">
${state.errors.map(e => `<p>${e.message}</p>`).join('')}
</div>
` : ''}
`
Forms
Form controls are paired with <label> elements. Error messages are linked to their input via aria-describedby so screen readers announce them when the field receives focus:
<"tok-fn">class="tok-kw">form "tok-fn">data-action="submit">
<"tok-fn">class="tok-kw">div "tok-fn">class="field">
<"tok-fn">class="tok-kw">label "tok-fn">for="email">Email</"tok-fn">class="tok-kw">label>
<"tok-fn">class="tok-kw">input
"tok-fn">id="email"
"tok-fn">name="email"
"tok-fn">type="email"
required
"tok-fn">aria-describedby="${state.emailError ? 'email-error' : ''}"
>
${state.emailError
? `<"tok-fn">class="tok-kw">p "tok-fn">id="email-error" "tok-fn">role="alert">${state.emailError}</"tok-fn">class="tok-kw">p>`
: ''}
</"tok-fn">class="tok-kw">div>
<"tok-fn">class="tok-kw">fieldset>
<"tok-fn">class="tok-kw">legend>Notification preferences</"tok-fn">class="tok-kw">legend>
<"tok-fn">class="tok-kw">label><"tok-fn">class="tok-kw">input "tok-fn">type="checkbox" "tok-fn">name="email-notifs"> Email</"tok-fn">class="tok-kw">label>
<"tok-fn">class="tok-kw">label><"tok-fn">class="tok-kw">input "tok-fn">type="checkbox" "tok-fn">name="sms-notifs"> SMS</"tok-fn">class="tok-kw">label>
</"tok-fn">class="tok-kw">fieldset>
<"tok-fn">class="tok-kw">button "tok-fn">type="submit">Submit</"tok-fn">class="tok-kw">button>
</"tok-fn">class="tok-kw">form>
Images
Decorative images carry alt="" so screen readers skip them. Informative images have descriptive alt text. Icon-only buttons are labelled with aria-label, with the icon marked aria-hidden="true":
<!-- Informative image -->
<"tok-fn">class="tok-kw">img "tok-fn">src="/team.jpg" "tok-fn">alt="The Pulse team at the 2025 offsite" "tok-fn">width="800" "tok-fn">height="450">
<!-- Decorative image -->
<"tok-fn">class="tok-kw">img "tok-fn">src="/divider.svg" "tok-fn">alt="" "tok-fn">width="600" "tok-fn">height="4">
<!-- Icon-only button -->
<"tok-fn">class="tok-kw">button "tok-fn">aria-label="Close">
<"tok-fn">class="tok-kw">svg "tok-fn">aria-hidden="true" "tok-fn">focusable="false">...</"tok-fn">class="tok-kw">svg>
</"tok-fn">class="tok-kw">button>
Lighthouse score
Every page is expected to score 100 on Lighthouse Accessibility. Run /pulse-report after every new page or significant change. Regressions — contrast failures, missing labels, unreachable controls — are caught before they reach users.