# Accessibility checks
Canvas Toolchain runs **automated accessibility checks** on every Canvas page it
generates, validates, or publishes. The goal is to catch the accessibility mistakes
that most often slip into LMS content — low-contrast text, missing alt text, broken
heading order, uncaptioned video — *before* a page goes live to students.
This page documents **every check**: what it catches, how it works, where it runs,
whether it blocks publishing, and its limitations.
> **Standard:** [WCAG 2.1 Level AA](https://www.w3.org/WAI/WCAG21/quickref/?levels=aaa).
> **Philosophy:** when publishing to Canvas, clear failures at the required level block until
> the professor acknowledges each named criterion. Nothing is ever permanently blocked — the professor can always
> acknowledge and proceed. The missing-`alt` validation rule (see [Layer 2](#layer-2--the-blocking-alt-rule)) is a separate hard validation failure.
> For details on the acknowledgment workflow, see [The publishing gate (Phase 2)](#the-publishing-gate-phase-2-2026-07) below.
---
## At a glance
| Layer | What it is | Severity |
|---|---|---|
| [1. WCAG content audit](#layer-1--the-wcag-content-audit) | Six heuristic checks over the page HTML (contrast, alt, headings, links, tables, captions) | Gated on publish (acknowledgeable) / advisory elsewhere |
| [2. Blocking `alt` rule](#layer-2--the-blocking-alt-rule) | Every `
` must have an `alt` attribute | **Blocking** (validation fails) |
| [3. Widget scaffolding](#layer-3--interactive-widget-scaffolding) | Accessibility built into every interactive widget by construction | Always-on (not a check) |
Source of truth (for developers):
- `packages/canvas-design-studio/src/tools/accessibility.ts` — the WCAG audit (`auditAccessibility`)
- `packages/canvas-design-studio/src/tools/contrast.ts` — the contrast-ratio math
- `packages/canvas-design-studio/src/tools/validate.ts` — Canvas RCE validation + the blocking `alt` rule
- `packages/canvas-design-studio/src/tools/widget/a11y.ts` — widget accessibility scaffolding
- `packages/command-and-control/src/tools/publish/scan_warnings.ts` — runs the audit during the publish flow
---
## Engine architecture (Phase 1, 2026-07)
Checks now run through pluggable engines that all emit one canonical model
(`AccessibilityFinding` in `@canvas-toolchain/shared-types`), normalized to
WCAG 2.2 success criteria:
| Engine | What it covers | Notes |
|---|---|---|
| `inhouse` | The six Canvas-aware heuristics (contrast with measured margin, empty alt, heading skips, vague links, table headers, Panopto captions) | Authoritative for 1.4.3 contrast |
| `axe` | axe-core 4.x WCAG 2.0/2.1/2.2 A+AA rules in jsdom (ARIA, roles, structure, labels, and more) | `color-contrast` and `target-size` disabled — jsdom has no layout |
Every check produces a `ConformanceReport`: a verdict (`pass` / `borderline` /
`fail`) against the required conformance level (default **WCAG 2.1 AA**),
findings with severity and margins, forward-looking advisories beyond the
required level, and an honest per-criterion status — `pass`, `fail`,
`needs-human-review` (automation cannot judge ~half of WCAG; use the WAVE
browser extension or MS Accessibility Insights,
https://accessibilityinsights.io/downloads/), or `not-applicable` (Canvas
owns the page chrome and login).
Phase 1 is fully advisory: nothing blocks publishing yet. The publish gate,
acknowledgments, and the borderline review queue are Phase 2 of
`packages/command-and-control/docs/superpowers/specs/2026-07-01-wcag22-conformance-gate-design.md`.
---
## The publishing gate (Phase 2, 2026-07)
Accessibility now gates both publish paths — with the professor as final arbiter.
Every gate has an acknowledgment path; every acknowledgment is recorded.
**Two tiers (evaluated against the required level, default WCAG 2.1 AA):**
| Verdict | What it takes to publish anyway |
| --- | --- |
| `pass` | Nothing — publishes normally. |
| `borderline` (only moderate/minor findings, or contrast within 85% of the threshold) | `acknowledgeAccessibility: true` after reviewing the findings. |
| `fail` (any serious/critical finding) | An array naming **every** clear-failure criterion, e.g. `acknowledgeAccessibility: ["1.4.3", "1.3.1"]`. `true` is rejected; missing or extra criteria are rejected. |
Single pages: `publish_to_canvas` (blocked calls return `ACCESSIBILITY_ACK_REQUIRED` with the
required criteria). Whole courses: `publish_course` takes `a11yAcknowledgments: { "": true | ["", …] }`
per file; FERPA and Canvas-HTML validation blocks remain absolute and cannot be acknowledged away.
**The paper trail** lives in the course project under `.a11y/`:
- `acknowledgments.json` — append-only record of every acknowledged publish (when, page, tier, criteria, level).
- `review-queue.json` — the "near the edge" worklist of pages a human should verify with real eyes.
**Two new Command & Control tools:**
- `accessibility_review_queue` — list the queue worst-margin first (live Canvas URLs, criteria, margins) or mark a page `reviewed-by-human` with a note.
- `audit_course_accessibility` — run the full engine stack over every generated page, get one course-level summary, and refresh the queue. The regular between-semesters check. It queues any non-pass page it finds — including clear failures, not just the borderline ones publish_course's gate queues — because a page a course audit flags as failing deserves human eyes too.
The manual generate-and-paste workflow remains ungated. Institution policy anchoring
(configured required level, re-verification cadence), the WCAG 3 advisory toggle, and the
WAVE API deep-check adapter are **Phase 3** — see the next section.
---
## Phase 3 — institution policy, WCAG 3 advisories, WAVE deep check (2026-07)
Phase 3 lets an institution's own accessibility policy drive the gate instead of the
hard-coded default, adds an opt-in advisory layer for the still-draft WCAG 3, and adds an
opt-in deep-check adapter for the paid WAVE API. None of this changes what the engines
check: **every check still runs the full WCAG 2.2 audit every time.** Policy only moves
where the pass/borderline/fail line falls, and WCAG 3 is advisory-only by construction — it
can never gate. The professor remains the final arbiter throughout.
### The institution accessibility policy
An optional `accessibilityPolicy` block lives in the institution config (never in this
repo — it's per-professor local data):
| Field | Meaning | Default |
|---|---|---|
| `urls` | Institution accessibility policy / guidance URLs, re-read on cadence | `[]` |
| `requiredConformance` | The gate level — `{ version, level }` | `{ version: '2.1', level: 'AA' }` (ADA Title II baseline) |
| `recheckWeeks` | How often the professor should re-read the policy | `4` (fits back-to-back 5-week summer sessions) |
| `lastVerifiedAt` | `YYYY-MM-DD` the professor last confirmed they'd re-read it | unset — no policy configured yet means no nudge |
| `wcag3Advisory` | Turns on the WCAG 3 draft advisory section | `false` |
With no policy configured, every conformance check behaves exactly as it did in Phase 1/2
— required level WCAG 2.1 AA, no nudge, no WCAG 3 section. Configuring a policy is opt-in.
### `review_accessibility_policy` — view / confirm / update
One Command & Control tool covers the whole workflow:
```text
View only: review_accessibility_policy({})
Update fields: review_accessibility_policy({ urls: ["https://example.edu/accessibility-policy"], recheckWeeks: 6 })
Change the gate level: review_accessibility_policy({ requiredConformance: { version: "2.1", level: "AA" } })
Toggle WCAG 3 advisories: review_accessibility_policy({ wcag3Advisory: true })
Confirm you re-read it: review_accessibility_policy({ confirm: true })
```
`confirm: true` stamps `lastVerifiedAt` to today — that's the *only* way the cadence nudge
ever gets armed (see below). Any call with no arguments just prints the current policy
(or "not configured; defaults in effect" if none exists yet). `requiredConformance` is
validated to a real WCAG 2.x version/level pair; `recheckWeeks` must be a whole number
≥ 1. Requires `setup_institution` to have run first — the policy lives inside the
institution config, so there's nowhere to save it before that.
### How the required level moves the gate — engines always run full 2.2
Every conformance run always executes the complete WCAG 2.2 A/AA check catalog, regardless
of policy. What the policy's `requiredConformance` changes is only the **line that decides
verdict**: findings at or below the required level count toward `pass`/`borderline`/`fail`
and the publish gate; findings above it fall into `advisories` and never gate, exactly as
before. Raising the bar (e.g. to WCAG 2.2 AA) pulls more findings into the gated set;
lowering it does the opposite — but nothing is ever hidden, since the full report (findings
+ advisories) is always returned.
### The cadence nudge
`review_accessibility_policy` (and every conformance report run through the policy-aware
path — `generate`, `redesign`, `publish`, `render`, `validate`, `scan_warnings`,
`audit_course_accessibility`) surfaces a one-line reminder once `lastVerifiedAt` has aged
past `recheckWeeks`:
```text
⏰ Institution accessibility policy last verified 2026-06-01 — re-read: https://example.edu/accessibility-policy
```
The nudge only exists once the professor has confirmed at least once — an institution with
`lastVerifiedAt` unset (never confirmed) never nags, because there's no baseline date to
measure a cadence against. Confirming re-arms the clock for another `recheckWeeks`.
### WCAG 3 draft advisories — never gates
W3C's WCAG 3.0 is still an early working draft (mapping built against the
**2024-12-12** draft — revisit when the W3C advances the spec). When
`wcag3Advisory: true`, every conformance report gains a `wcag3` section that maps the
2.x findings with a clear draft-3.0 analogue to the corresponding draft outcome name (a
small, honest table — only success criteria with an unambiguous WCAG 3 outcome are
mapped; the rest are simply not surfaced here rather than guessed at):
```text
WCAG 3.0 draft advisories (2024-12-12 draft — informational only, never gates):
1. Text and visual contrast (maps from 1.4.3): #777 on #fff: 4.48:1 — fails WCAG AA for body text (requires 4.5:1)
```
This section is purely informational. It is structurally incapable of affecting the
publish verdict — it lives alongside `findings`/`advisories` on the `ConformanceReport`
but is never consulted by the gate.
### The WAVE routes
Two ways to bring WebAIM's WAVE engine into the loop, for two different situations:
| Route | Use for | Cost |
|---|---|---|
| **Free WAVE browser extension** ([wave.webaim.org/extension](https://wave.webaim.org/extension/)) or MS Accessibility Insights for Web ([accessibilityinsights.io](https://accessibilityinsights.io/downloads/)) | Login-gated Canvas pages — you're already logged in, so the extension sees the real rendered page | Free |
| **`wave_deep_check` (C&C tool, paid WAVE API)** | Publicly reachable pages only — the API fetches by URL and cannot log into Canvas | ~2 WAVE credits per run |
If the institution's policy `urls` include a link matching "wave", the conformance report
adds a `recommendedChecker` line pointing at it — a nudge, not a requirement.
**`wave_deep_check` is a two-call spend gate** — nothing runs, and no credit is spent,
until the professor explicitly confirms:
```text
Preview (no spend): wave_deep_check({ url: "https://example.edu/courses/101/pages/syllabus" })
Run it (spends ~2 credits): wave_deep_check({ url: "...", confirm: true, apiKey: "" })
```
The API key is validated only by the WAVE API call itself; when supplied it's saved to the
institution config so later calls don't need to repeat it.
**Auth-gate refusal happens before any credit is spent.** Before calling the WAVE API,
`wave_deep_check` probes the target URL itself. A `401`/`403` response, or a redirect to
something that looks like a login page, returns `AUTH_GATED_URL` immediately — with a
`fix` pointing at the free browser extension / Accessibility Insights instead — so a
professor who accidentally points it at a gated Canvas page never burns credits on a run
that could never have worked. Other failure modes: `WAVE_API_ERROR` (the WAVE API itself
reported failure — bad key, out of credits) and `WAVE_UNREACHABLE` (network failure,
retry). WAVE's own item ids are mapped to WCAG success criteria through a small, honest
table (only unambiguous ids are mapped; the rest are returned as `unmapped` items with a
prompt to check them in the extension) — `creditsRemaining` is echoed back when WAVE
reports it.
---
## Layer 1 — the WCAG content audit
`auditAccessibility(html)` runs six independent checks and returns a list of warnings.
Each warning has a `check` id, a plain-English `message`, and a `context` snippet
showing the offending markup. HTML comments are stripped before analysis.
### 1. Contrast ratio (`contrast-ratio`)
**Catches:** body or large text whose colour does not contrast enough with its
background to be readable.
**How it works:** for each element with an inline `style` attribute, the check reads
an inline `color` (foreground) and `background-color`/`background` (background) given
as **hex** values on the *same element*. It computes the WCAG contrast ratio from each
colour's relative luminance:
```
ratio = (Llighter + 0.05) / (Ldarker + 0.05)
```
(luminance via the [`color`](https://www.npmjs.com/package/color) library, in
`contrast.ts`). It then compares against the WCAG AA threshold:
| Text size | Required ratio |
|---|---|
| Normal ("body") text | **4.5 : 1** |
| Large text — `font-size ≥ 24px`, or `≥ 18px` when `font-weight: bold`/`700` | **3.0 : 1** |
A warning fires when the measured ratio is below the threshold, e.g.
`#777 on #fff: 4.48:1 — fails WCAG AA for body text (requires 4.5:1)`.
**Limitations:** only sees inline **hex** colours declared on the *same* element. It
does **not** evaluate named colours (`gray`), `rgb()`/`hsl()`, gradients, colours set
via CSS classes, or colours inherited from a parent. When it can't find both a
foreground and a background hex on an element, it skips that element silently.
### 2. Meaningful alt text (`empty-alt`)
**Catches:** content images with an empty `alt=""` that are probably *not* decorative.
**How it works:** for each `
` that has both `src` and `alt`, if `alt` is empty and
the `src` does **not** look decorative (it doesn't match `spacer`, `pixel`, `blank`,
`transparent`, or `1x1`), it warns that the image needs descriptive alt text — or
explicit confirmation that it's decorative.
**Note:** an image with *no* `alt` attribute at all is caught by the **blocking** rule
in [Layer 2](#layer-2--the-blocking-alt-rule), not here. This check is specifically
about `alt=""`.
### 3. Heading hierarchy (`heading-skip`)
**Catches:** skipped heading levels that break screen-reader navigation.
**How it works:** it collects the page's ``–`` in document order and flags the
first place a level jumps by more than one (e.g. an `` followed directly by an
``). `` is intentionally excluded — Canvas reserves H1 for the page title, so
body content starts at H2.
### 4. Descriptive link text (`vague-link`)
**Catches:** links whose visible text doesn't say where they go.
**How it works:** it strips inner tags from each `…`, lowercases the text, and
warns if it exactly matches a known vague phrase: *click here, here, read more, more,
link, this link, learn more*.
### 5. Table headers (`table-no-headers`)
**Catches:** data tables with no header cells.
**How it works:** for each `
`, if it contains no `| ` element at all, it warns
that screen readers can't associate data cells with row/column headers.
### 6. Video captions (`video-no-captions`)
**Catches:** Panopto video embeds that don't have captions switched on.
**How it works:** for each ` |