Domphy

Static a11y (auditA11y)

auditA11y checks Domphy element trees (plain objects) for common accessibility violations without a browser or Playwright. It runs in Node, at build time, or in Vitest — anywhere you have access to the element tree before it is rendered.

import { auditA11y } from "@domphy/audit"

const App = {
  div: [
    { img: null, src: "logo.png" },        // missing alt → flagged
    { input: null, type: "text" },          // no label → flagged
    { h1: "Dashboard" },
    { h3: "Overview" },                     // skipped h2 → flagged
  ],
}

const result = auditA11y(App)
console.log(result.ok)      // false
console.log(result.issues)  // A11yIssue[]

This is the static pre-render complement to checkContrast / checkLayout (which run on live pages via Playwright).

auditA11ycheckLayout
WhenBefore render (static)After render (Playwright)
InputDomphy element treeLive page
Playwright neededNoYes
Checksalt, label, heading, langcontrast, overlap, geometry, theme, overlay

Signature

function auditA11y(element: unknown): A11yResult

Pass any Domphy element, array of elements, or a full document tree. Returns an A11yResult synchronously.

interface A11yResult {
  ok: boolean        // false if issues.length > 0
  issues: A11yIssue[]
}

interface A11yIssue {
  rule: A11yRule     // which rule fired
  message: string    // human-readable description
  path: string       // dot-path through the element tree
}

type A11yRule =
  | "missing-alt"
  | "missing-label"
  | "heading-hierarchy"
  | "missing-lang"

Rules

missing-alt — images must have alt

Every { img: ... } must have an alt attribute. Screen readers read the filename aloud when alt is absent, which is never useful.

// Bad — no alt
{ img: null, src: "/hero.jpg" }

// OK — decorative image (alt="" tells screen readers to skip it)
{ img: null, src: "/hero.jpg", alt: "" }

// OK — meaningful image
{ img: null, src: "/avatar.jpg", alt: "Profile photo of Jane Smith" }

missing-label — form controls need accessible names

Every <input> (except type="hidden", type="submit", type="reset", type="button", type="image"), <textarea>, and <select> must have an accessible name. Accepted sources (any one is sufficient):

SourceExample
Wrapped in <label>{ label: [{ input: null }] }
<label for="id"> elsewhere in tree{ label: "Name", for: "name-field" } + { input: null, id: "name-field" }
aria-label attribute{ input: null, "aria-label": "Search" }
aria-labelledby attribute{ input: null, "aria-labelledby": "heading-id" }
title attribute (WCAG fallback){ input: null, title: "Search" }

placeholder is not an accessible name — screen readers do not expose it reliably and it disappears on focus.

// Bad — no label
{ input: null, type: "text", placeholder: "Search..." }

// OK — aria-label
{ input: null, type: "text", "aria-label": "Search" }

// OK — nested in label
{ label: [
  { span: "Email" },
  { input: null, type: "email" },
]}

// OK — label[for]
[
  { label: "Email", for: "email-field" },
  { input: null, type: "email", id: "email-field" },
]

heading-hierarchy — no level skips

Heading levels must increase by at most one step at a time. Screen reader users navigate by headings; a skip (h1 → h3) creates a hole in the outline that implies content is missing.

// Bad — skips h2
[
  { h1: "Dashboard" },
  { h3: "Overview" },    // flagged: missing h2
]

// OK — sequential levels
[
  { h1: "Dashboard" },
  { h2: "Overview" },
  { h3: "Details" },
]

// OK — jumping back up is always valid
[
  { h1: "Dashboard" },
  { h2: "Section A" },
  { h3: "Subsection" },
  { h2: "Section B" },  // fine: h3 → h2 is going up, not down
]

missing-lang<html> needs a lang attribute

Only fires when the element tree includes an { html: ... } element (full-document SSR trees). Screen readers use lang to select the correct speech-synthesis language.

// Bad
{ html: [{ head: null }, { body: [...] }] }

// OK
{ html: [{ head: null }, { body: [...] }], lang: "en" }

Usage in Vitest

// src/components/header.test.ts
import { describe, it, expect } from "vitest"
import { auditA11y } from "@domphy/audit"

const Header = {
  header: [
    { img: null, src: "/logo.svg", alt: "Acme logo" },
    { nav: [
      { h2: "Navigation" },
      { ul: [
        { li: { a: "Home", href: "/" } },
        { li: { a: "About", href: "/about" } },
      ]},
    ]},
  ],
}

describe("Header a11y", () => {
  it("has no a11y violations", () => {
    const result = auditA11y(Header)
    expect(result.issues).toHaveLength(0)
  })
})

Combining with @domphy/doctor

auditA11y and @domphy/doctor's diagnose cover different rules — run both for thorough static analysis before rendering:

import { diagnose, format } from "@domphy/doctor"
import { auditA11y } from "@domphy/audit"

function checkElement(element: unknown) {
  // Doctor: Domphy-specific rules (inline typography, raw theme values, tone grammar, etc.)
  const doctorIssues = diagnose(element)
  if (doctorIssues.length > 0) {
    console.warn("Doctor issues:\n" + format(doctorIssues))
  }

  // Audit: WCAG-aligned a11y rules (alt, labels, heading order, lang)
  const a11yResult = auditA11y(element)
  if (!a11yResult.ok) {
    console.warn("A11y issues:")
    for (const issue of a11yResult.issues) {
      console.warn(`  [${issue.rule}] ${issue.path}: ${issue.message}`)
    }
  }

  return doctorIssues.length === 0 && a11yResult.ok
}

Limitations

  • Reactive content is not traversable. Any child tree returned by a (listener) => ... function is skipped. The element's own props (alt, aria-label, etc.) are still checked — only the reactive children are invisible to the static walker.

  • Dynamic label associations. If a <label for="..."> is built with a reactive for value — { label: "Name", for: (l) => state.get(l) } — the association cannot be resolved statically. Use a literal string for the for / id pair.

  • Only four rules. Compared to axe-core's 90+ rules, auditA11y covers the highest-impact structural a11y patterns: alt text, accessible labels, heading order, and document language. For contrast checking, use checkContrast(page) (Playwright-based, checks computed colors at runtime).

  • No ARIA role or attribute validation. aria-* attribute correctness (valid roles, required owned elements, allowed states) is not currently checked.

Types

import type { A11yIssue, A11yResult, A11yRule } from "@domphy/audit"

type A11yRule =
  | "missing-alt"
  | "missing-label"
  | "heading-hierarchy"
  | "missing-lang"

interface A11yIssue {
  rule: A11yRule
  message: string
  path: string        // e.g. "root[0]>div>img" or "root>form[2]>input"
}

interface A11yResult {
  ok: boolean
  issues: A11yIssue[]
}