GitHub

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

FeatureHow
Skip linkInjected 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 focusAfter 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.
The skip link targets #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>
`
ElementPurpose
<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:

RolePolitenessUsed for
role="status"Polite — waits for the user to finishLoading states, success messages, counts
role="alert"Assertive — interrupts immediatelyValidation 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.