# 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 `