This browser is too old for this site. Some pages may not work. Update your phone, or try a different device.

Accessibility

WCAG A and AA

What the status words mean

WCAG 2.2 has 55 success criteria at levels A and AA. (Version 2.2 removed 4.1.1 Parsing, so it is not listed.) We use the status words these records usually use, with one of our own: Not Assessed. The usual templates keep a status like it for level AAA only, and their other words all claim a finding. We need it for any criterion nobody has checked yet.

Supports: 27 of 55
The website and the app meet this. The row says how we know, and whether a test protects it from breaking.
Partially Supports: 22 of 55
Mostly met, with a named gap: a problem listed on the statement page, or a part we cannot yet show evidence for.
Does Not Support: 0 of 55
A real problem that people will meet today.
Not Assessed: 0 of 55
Nobody has checked this yet. It is neither a pass nor a fail, and the row says what is still to do.
Not Applicable: 6 of 55
Nothing in the website or the app needs this.

Every success criterion, one by one

The 55 criteria are grouped under the four principles of WCAG. Each row shows the number, name, level and status, whether a test protects it, and a short summary. Open "How we know" on any row for the full finding and its evidence. Printing or saving the page opens every row.

An automated test in our code fails if a row cites a check that no longer exists or no longer covers what the row says. It also fails if a row claims a test protects it when none does, or claims a test must pass before a change can go in when it does not have to.

The same rows are also published as a data file (JSON), to check with your own tools.

Perceivable

You can take the information in, whatever you use to read a screen. 20 criteria.

  • 1.1.1 Non-text Content Level A Partially Supports Test cannot block

    Pictures that carry meaning have a written description, called alt text, that a screen reader reads out, and decorative shapes are hidden from it. This is partly met, because no person has yet checked whether those descriptions are any good.

    How we know: 1.1.1 Non-text Content

    Every photograph carries alt text, decorative artwork is hidden from screen readers, and every icon-only button has a name. Automated checks run on both the website and the app. Since 16 August 2026 every decorative icon on the website carries the attribute that hides it from screen readers, and since 25 August 2026 so does every decorative icon in the app, so no shape without a meaning is read out in either. The two icons that do carry meaning are named instead of hidden. What automation cannot do is judge whether alt text is any good.

    A test checks this, but it cannot stop a change going in.

    Evidence

    • Automated check, advisory only: Runs the WCAG 2.2 A and AA rule set over 34 public routes and requires zero violations. That set carries image-alt, button-name and link-name. apps/marketing/e2e/a11y.spec.ts (advisory check, cannot block a merge: Marketing a11y (axe))
    • Automated check, run on demand: Same rules over the 21 scanned app screens, out of the 72 routes the surface registry knows about. Every project runs post-merge and on demand; since 2026-08-20 the dark and forced-colours reruns of this same spec also run per pull request, but only on the narrow filter of the token layer and the preference plumbing published in ADVISORY_CHECKS, so a change outside that filter is still covered post-merge only. apps/parent/e2e/a11y-nav.spec.ts
    • Automated check, run on demand: Since 28 September 2026 the same axe rules also run every night on each app screen our journey map can reach with test data, one screen per journey step rather than one per route, in light and dark and at 320 pixels wide. A finding not written into a named list, with a reason and a ticket, turns that night's run red. It runs after changes are in, so it cannot stop a merge, and it does not stop a release either. It carries image-alt and button-name like the scan above, over more screen states. apps/parent/e2e-blueprint/blueprint-capture.spec.ts
    • Code review, 2026-08-16: Every icon the website renders through its icon component now carries aria-hidden. Counted on the built site rather than the source, because the component is what emits the tag: after the build, no rendered icon is left without it. The only other drawings in the pages are the two logos, which are labelled on purpose, one tick already inside a hidden wrapper, and the example-profile progress rings, which each carry their own title.
    • Code review, 2026-08-25: The app had the same pass on 25 August 2026 (ASM-1136). `bun scripts/count-icon-aria.ts` audits the 107 places the app's code writes an icon, across apps/parent, ui-primitives, profiler-components, parent-components and report-components, and reports how many are hidden, named, handed to a wrapper that hides them, or left unhidden. It went from 26 unhidden to 0. This is a count of CALL SITES, not of the icons a user meets on a screen: one call site inside a list renders once per row. It is still the unit that settles the question, because the app's icon library adds no aria-hidden of its own and passes on whatever the call site gives it, so a hidden call site is hidden on every one of its renders. Of the 107, 94 are hidden where they are written and 2 are named; the remaining 11 are handed to one of two components that hide the slot they render into, and each of those two has a test holding it there, so the audit is not taking a wrapper's word for it. The audit refuses to under-count in silence: it stops if a file imports icons it cannot find rendered. One limitation is left and stated in the script rather than fixed, because closing it means writing a parser: in the five files that pass icons around as values rather than writing them as tags, a genuinely new way of rendering one could still be missed. Two icons are named rather than hidden: the profile-complete tick, and the finished-dimension tick on the progress card, whose row drops its status line so the tick is the only thing that says done.
    • Not verified: Alt text quality has never been reviewed by a person, on the website or in the app.
  • 1.2.1 Audio-only and Video-only (Prerecorded) Level A Not Applicable

    This is about recorded video and audio. It does not apply, because the website and the app have no video or audio at all.

    How we know: 1.2.1 Audio-only and Video-only (Prerecorded)

    We publish no video and no audio anywhere in the website or the app, so there is nothing this criterion applies to.

    Evidence

    • Code review, 2026-08-08: Two independent sweeps for video, audio, media embeds, media file types and autoplay found nothing. No media files exist in either app's assets.
  • 1.2.2 Captions (Prerecorded) Level A Not Applicable

    Captions are the words of a video shown on screen. This does not apply, because we publish no recorded video or audio.

    How we know: 1.2.2 Captions (Prerecorded)

    No prerecorded video or audio. See 1.2.1.

    Evidence

    • Code review, 2026-08-08: As 1.2.1: no time-based media exists in scope.
  • 1.2.3 Audio Description or Media Alternative (Prerecorded) Level A Not Applicable

    This asks for a spoken or written description of what a video shows. It does not apply, because we publish no recorded video or audio.

    How we know: 1.2.3 Audio Description or Media Alternative (Prerecorded)

    No prerecorded video or audio. See 1.2.1.

    Evidence

    • Code review, 2026-08-08: As 1.2.1: no time-based media exists in scope.
  • 1.2.4 Captions (Live) Level AA Not Applicable

    Live captions are on-screen words for a live broadcast. This does not apply, because nothing on the website or in the app streams live.

    How we know: 1.2.4 Captions (Live)

    Nothing on the website or in the app streams live.

    Evidence

    • Code review, 2026-08-08: As 1.2.1: no time-based media exists in scope.
  • 1.2.5 Audio Description (Prerecorded) Level AA Not Applicable

    Audio description is a spoken account of what a video shows, for people who cannot see it. This does not apply, because we publish no recorded video.

    How we know: 1.2.5 Audio Description (Prerecorded)

    No prerecorded video. See 1.2.1.

    Evidence

    • Code review, 2026-08-08: As 1.2.1: no time-based media exists in scope.
  • 1.3.1 Info and Relationships Level A Partially Supports Test protects it

    A screen reader should hear the same structure you see, such as headings, labels and groups of choices. This is built in and held by tests, but no screen reader has yet listened to most of the app to confirm it.

    How we know: 1.3.1 Info and Relationships

    Structure is built in rather than added afterwards: groups of choices are real fieldsets with legends, fields have real labels, and the report tabs use proper tab semantics. The first screen-reader walk of the app, on 16 August 2026, found two defects in the parent profiler, both confirmed in the markup and both fixed on 25 August 2026. On the screener, each statement was announced twice, once as a checkbox with its state and its place in the list and once as a bare repeat of the same words, and the card's title was read three times in five stops; now each statement is announced once, as the checkbox's own name, and the title once (ASM-1153, commit 20f982798). The question, capture and screener cards began their heading structure at level 2 with no level 1 above it, on screens that are each their own page; every card page now has exactly one top-level heading (ASM-1154, commit 5bf480efa). Both fixes are held by tests. What keeps this row at Partially Supports is that no screen reader has been back over those screens since, and none has listened to the structure of the report, sign-in or the school staff screens, so structure there is asserted from markup and automated rules. What was fixed earlier still holds: the neurodivergence checkboxes moved into a real fieldset on 16 August 2026, and on 15 August 2026 the report page's chat box got a label and the school staff screens got headings, each pinned by a test.

    A test protects this, and it has to pass before any change can go in.

    Evidence

    • Automated gate, required to merge: Resolves the checkbox group by its accessible name (the route's own heading text) and asserts every preset checkbox sits inside it, and that the unrelated 'Other' text row does not. apps/parent/src/routes/children/neurodivergence.component.test.tsx (required check: test)
    • Automated check, advisory only: axe structural rules over 34 public routes. apps/marketing/e2e/a11y.spec.ts (advisory check, cannot block a merge: Marketing a11y (axe))
    • Automated check, run on demand: Same rules over the 21 scanned app screens, all five school staff screens among them. Every project runs post-merge and on demand; since 2026-08-20 the dark and forced-colours reruns also run per pull request, but only on the narrow filter published in ADVISORY_CHECKS, so a change outside that filter is still covered post-merge only. Each school staff scan also asserts the page's heading outline, which no axe rule in the gated set does. apps/parent/e2e/a11y-nav.spec.ts
    • Automated check, run on demand: Since 28 September 2026 the same axe rules also run every night on each app screen our journey map can reach with test data, one screen per journey step rather than one per route, in light and dark and at 320 pixels wide. A finding not written into a named list, with a reason and a ticket, turns that night's run red. It runs after changes are in, so it cannot stop a merge, and it does not stop a release either. Like the scan above it cannot judge whether a heading outline makes sense, only whether the markup is well formed. apps/parent/e2e-blueprint/blueprint-capture.spec.ts
    • Code review, 2026-08-15: The five school staff screens were read one by one and given a descending heading outline whose section headings name their section rather than repeat the page title, and the roster's styled paragraph became a real heading. Each screen's outline, words included, is pinned by apps/parent/src/routes/heading-outline.test.tsx, which runs with the unit tests rather than on demand.
    • Code review, 2026-08-17: Agent-driven, and prompted by the VoiceOver transcript of 16 August 2026 rather than by a search: the two things the reader announced oddly were traced to the markup that causes them. StatementChecklistCard.tsx:136-156 wraps the checkbox and a sibling span of the statement text in one label, which is why every statement is announced a second time with no role, no state and no position. The same file duplicates the visible h2 (:110-112) into a visually hidden legend (:129), which is the third reading of the title. The missing top-level heading was read across the card components that render the question, capture and checklist screens, plus the profiler layout, which renders main with no heading of its own. Every one of those opens at h2: pills-comment-card.tsx:209 behind the two capture cards, StatementChecklistCard.tsx:110 behind the screener, and TextareaCard.tsx:100, YesNoCard.tsx:87, NumberCard.tsx:114, TextCard.tsx:114 and DateCard.tsx:232 behind card types this walk never reached. This is a gap in those card types and not across the profiler: the intro card (IntroCard.tsx:176) and the safety notice (overview-card.tsx:414) carry an h1, and so do cards the walk never got to, DimensionProgressCard.tsx:324 and ProfileCompleteCard.tsx:33 among them. The record of the walk itself is agents-docs/design-system/audits/2026-08-16-voiceover-profiler-walk.md, cited as evidence on SC 2.4.6 and SC 4.1.2 rather than here, since a dated walkthrough can never be part of what a regression gate holds.
    • Automated gate, required to merge: Renders every overview card the manifest can emit and asserts exactly one level 1 heading on each, plus which element it is: the frame's hidden heading on the capture and screener cards, the card's own visible title on the intro, safety-notice and priority-areas interstitials. The card list is derived from the manifest rather than written down, so a card type added to THAT scope cannot join the flow without a heading. The profiler's other two scopes are covered by their own route tests and neither is manifest-derived, so this file's coverage should not be read as standing for them: dimension-card.component.test.tsx holds the four dimension-scope card types (question, comments, intro, progress) and final-card.component.test.tsx holds the three final-scope cards (safety, final-comments, done). The frame seam itself is held in packages/profiler-components, ProfilerLayout.test.tsx for the flow variant and ProfilerLayout.page.test.tsx for the page variant, since both frames implement the prop. apps/parent/src/routes/profiler/overview-card.component.test.tsx (required check: test)
    • Code review, 2026-08-25: ASM-1154 closed. The frame owns the level 1 (ProfilerLayout's `pageHeading`, rendered sr-only as the first thing inside main) on the card types whose visible heading is a question, and the interstitials keep the visible title they already had. Promoting each card's own title was rejected: those titles are questions, and a per-card level 1 would have every screen of one journey claim to be a different document. Reading the routes rather than the ticket found the finding had been both over- and under-stated: five of the seven card components it named were deleted with the apps/web flow engine in ASM-1546, while two card types it cleared, the dimension intro (IntroCard's dimension branch) and the priority-areas interstitial (DimensionsOverviewCard), also opened at h2 and are fixed here by promoting their own titles.
    • Automated gate, required to merge: ASM-1153 closed on 25 August 2026 (commit 20f982798). Asserts each screener checkbox is named by its statement exactly once, that the visible statement text is hidden from assistive technology so it is not announced a second time, emphasised rows included, and that the card's title reaches the accessibility tree once, naming the group from the visible heading rather than from a repeated legend. packages/profiler-components/src/components/cards/__tests__/StatementChecklistCard.test.tsx (required check: test)
    • Not verified: The walk that found these two reached five of the profiler's 108 screens. Since then an agent-driven VoiceOver run on 27 August 2026 read the questionnaire's help chat, and nothing with a written record has listened to the report, to sign-in or to the school staff screens, on any reader, so structure there is asserted from markup and from axe rules rather than from anything that read it aloud. The ASM-1153 and ASM-1154 fixes are held by component tests reading the rendered DOM; no screen reader has been back over those screens since.
  • 1.3.2 Meaningful Sequence Level A Partially Supports Test protects it

    Content should be read out in an order that makes sense, even where the layout moves things around. This is partly met: a software check of every app screen found two problems, both fixed, but no person has checked them yet.

    How we know: 1.3.2 Meaningful Sequence

    Six places in the source set a visual order different from the order the content is written in, and on 2026-08-27 a probe measured what each one actually does at every screen width band the app's stylesheets define, seven of them. Only one of the six reorders anything a user meets in the parent app, which is the product that probe can reach: the comment composer's Cancel and Save pair swaps below the sm breakpoint. That pair is two buttons in one group with no sequence between them, so the written order remains a meaningful one, and it is the better order for a keyboard, putting Cancel first so the destructive action is never the default. Three of the remaining five cannot reorder anything in the app at any width, because the element they would move is not rendered, the screen that would use them is never built, or nothing consumes the component. One is used only by the professionals prototype, which does not ship. The last, the website's alternating how-it-works rows, is live and user-facing, but it moves only the picture: every row's written order is prose then illustration, so what a reader hears is the same on every row and the alternation is decoration. The inventory gate holds that reason against the file. No positive tabindex value exists anywhere in either product. The human half is still open: 62 files now record every parent-facing screen in the order a screen reader would read it. An agent read all of them on 2026-09-25 and found two screens whose order misled, both since fixed, but no person has read them yet.

    A test protects this, and it has to pass before any change can go in.

    Evidence

    • Automated gate, required to merge: The inventory. Sweeps 1,254 files across 11 trees for Tailwind order utilities, the three reverse flex directions, raw CSS order declarations, right-to-left overrides and positive tabindex values, and fails unless every hit is registered with a written reason. It fails in the other direction too, on a registered entry whose site has gone, which is the drift that made the old count wrong. Chained onto packages/brand's test script, which CI force-includes on every pull request, so it sits on the required path; turbo.json gives brand's task $TURBO_ROOT$ inputs over the swept trees so a violation in another package cannot replay a cached green. Mutation-tested both ways: adding md:order-last to a component fails with the file, line and the two remedies; removing a registered reordering fails with the stale-entry message. scripts/meaningful-sequence-gate.mjs (required check: test)
    • Automated gate, required to merge: The linearised read. Renders 62 parent-facing screens and states in jsdom and writes each one's content spine, in document order, to a committed text file under __snapshots__/reading-order/. This is what makes the human judgement cheap: reading a page in document order becomes reading a file, and any later change to that order arrives as a diff in review. It also asserts each spine's heading levels equal the outline that heading-outline.test.tsx pins, so the two files cannot describe the same render differently. Being jsdom it has no layout, so it is blind to CSS reordering by construction; that half is the probe below. Mutation-tested by swapping two sibling paragraphs, which fails naming the route and showing both moved lines. apps/parent/src/routes/reading-order.test.tsx (required check: test)
    • Automated check, run on demand: The geometric probe, and the only check here that can see CSS reordering at all. For 18 surface instances it reads every rendered element's box at 360, 704, 900, 1100, 1200, 1280 and 1600 pixels wide and fails where DOM-consecutive siblings are painted in the opposite order. The seven widths are not hand-picked: a test in the file reads every min-width threshold out of the app's own stylesheets, cuts the width axis into bands at them, and fails unless a width sits inside each, which is what stops a single-viewport run reporting green over a defect that only appears on a phone. The band rule replaced a weaker one (a width on each side of every threshold) on review, and finding a band nothing sampled between 1180 and 1280 is what it did first. Runs post-merge on push to main and on a /e2e comment via parent-e2e.yml, NOT on every pull request, so it is reporting rather than a gate. apps/parent/e2e/meaningful-sequence.spec.ts
    • Code review, 2026-08-27: Correcting the 2026-08-09 entry below, which was wrong in both directions and had drifted unnoticed for 18 days, which is the reason an inventory gate now exists. Its count of four missed two live sites in the profiler's comment composer, and its 'report dimension row' matched nothing: packages/report-components contains no reordering utility at all. The dormancy findings were read at the source rather than inferred from the probe: buildDimensionsOverviewAfterPrioritySection in packages/profiler-components/src/utils/flow-builder.ts is exported and called from nowhere, and apps/parent/src/routes/profiler/overview-card.tsx hard-codes phase 'intro', so DimensionsOverviewCard's sm:flex-row-reverse is unreachable; ui-primitives' DialogFooter is exported but consumed by no shipped app; Button's items-first option is passed only by three apps/professionals-prototype pages; and the composer's outer reversal moves a paragraph that is hidden below sm and un-reversed at sm and up. Four of those are dead code carrying a live accessibility question, and deleting them would be a better answer than registering them.
    • Code review, 2026-08-09: SUPERSEDED by the 2026-08-27 entry above; kept because the public record should show the count moving rather than quietly settling. This entry itself corrected an earlier one that said no CSS reordering existed. Re-run on 2026-08-09, four places reorder: the website's alternating how-it-works step rows, a report dimension row, a dialog's footer buttons and a button's icon placement. None is known to break the reading order and none has been looked at. No positive tabindex value exists anywhere in either product, which is the other usual cause and really is absent.
    • Not verified: Two things are open, and the status stays Partially Supports rather than Supports because of them. First, no person has read the 62 reading-order snapshots. They pin that the order will not change, not that the order is good. An agent read every one on 2026-09-25. It found two screens where content sat under the wrong heading: on the meeting sheet, the key to the dimensions was heard inside the parent's recognised statements, and on the report's key findings tab, the section about the child was heard under Key findings. It also found three smaller cases where a hint or warning came after the control it explained. All five were fixed under ASB-433. An agent's read is not the judgement this criterion asks a person to make, so it does not move the status. Second, no screen reader has been over any of this; the snapshots are the accessibility tree as jsdom builds it, which is not the same as what a reader announces. The website item that stood here, its alternating how-it-works rows, is closed: those rows move only a picture and the written order is the same on every one.
  • 1.3.3 Sensory Characteristics Level A Partially Supports No test

    Instructions should not point at things only by their shape, place or sound. This is partly met: one page about deleting your data still says 'the link below' without naming the link.

    How we know: 1.3.3 Sensory Characteristics

    This is a rule about wording: an instruction must not point at something by shape, position or sound alone. One instance is live, on the page about deleting your data, where a link is identified only as 'the link below'. The near miss in the app gets it right: the profiler's help text names the Get help button as well as saying where it sits.

    No test protects this.

    Evidence

    • Code review, 2026-08-09: A phrase search across the website's pages and components, the app's routes and the interface message catalogue, for instructions naming a control by position, shape or colour alone. One hit that relies on position alone: 'The link below pre-fills the subject line' on the delete-your-data page. The other hit names its button as well as its position, so it does not rely on the position.
    • Not verified: A phrase search finds the patterns we thought to search for. No editorial pass has been run over the whole product against this criterion, and no tool can run one.
  • 1.3.4 Orientation Level AA Supports Test cannot block

    You can use the website and the app with your phone or tablet held either way up. Nothing locks the screen to portrait or landscape, and checks at a landscape phone size found no problems.

    How we know: 1.3.4 Orientation

    Both products work whichever way you hold the device. Nothing anywhere locks the screen to portrait or landscape, and the on-demand sweep that can see this found no violation across 284 page states. Since 16 August 2026 a probe also renders every public route, and all sixteen signed-in app screens, at a real landscape phone size and asserts the main landmark is there and nothing runs off the side: it found nothing wrong on either product.

    A test checks this, but it cannot stop a change going in. For the app, the same test runs again once the change is in, and a failure stops that change reaching the live app unless we override it and record why.

    Evidence

    • Code review, 2026-08-09: No orientation lock of any kind exists: no CSS orientation media query used to restrict content, no call to lock the screen orientation, and no web app manifest setting one. Searched across both apps and the shared styles. The axe rule for this is tagged experimental, so it does not run under our normal tag filter, which is why the sweep below rather than the gate is what measured it.
    • Automated check, run on demand: The full-catalogue sweep enables every rule including the experimental ones, and returned no violation of this rule across 284 page states on 2026-08-08. It gates nothing. apps/marketing/e2e/a11y-full-audit.spec.ts
    • Automated check, advisory only: Renders all 24 scanned public routes at 844x390, a real landscape phone size, alongside the existing 320x800 portrait-shaped coverage in a11y.spec.ts's chromium-narrow project. Asserts the main landmark is visible and the document does not overflow sideways. Clean everywhere on 2026-08-16. apps/marketing/e2e/resize-spacing-orientation.spec.ts (advisory check, cannot block a merge: Marketing a11y (axe))
    • Automated check, advisory only: The same landscape check over all sixteen of the app's scanned screens, widened from eight on 16 August 2026 (ASM-1130). Not a hand-kept list any more: the spec declares which registry route each case stands for and a parity test in the same file fails if that set is not exactly the registry's scanned parent set, so a screen cannot join the product without joining this probe. Clean on every screen on 2026-08-16. It now also runs per pull request, but only on the narrow filter of the token layer and the preference plumbing published in ADVISORY_CHECKS, so a change outside that filter is still covered post-merge only. Advisory either way: it cannot refuse a merge until the ASM-1193 promotion lands (ASM-1298), 2026-08-27 at the earliest. apps/parent/e2e/resize-spacing-orientation.spec.ts (advisory check, cannot block a merge: Parent a11y probes)
  • 1.3.5 Identify Input Purpose Level AA Supports Test protects it

    Fields that ask for your name, organisation, role or email tell your browser what they are, so it can fill them in for you. All six such fields do this, and a test checks them.

    How we know: 1.3.5 Identify Input Purpose

    Every field that asks for information about you carries the standard autofill hint, so your browser or password manager can fill it in: your name, your organisation, your role and your email address. Six fields, six correct. The hidden fields that catch spam opt out on purpose, which is also correct. A test now holds all six, so a refactor that drops one fails the required checks rather than shipping unnoticed.

    A test protects this, and it has to pass before any change can go in.

    Evidence

    • Automated gate, required to merge: Asserts the four website enquiry fields (name/organisation/role/email) carry their exact autocomplete tokens from the shared enquiryFields() source, and that the early-access email field's markup keeps name="email" paired with autocomplete="email" (source-pinned: no rendering harness exists for .astro in this repo). apps/marketing/tests/form-errors.test.ts (required check: test)
    • Automated gate, required to merge: Asserts the sign-in email field keeps autoComplete="email" on the rendered DOM. The sixth field, the sign-in code, is outside this criterion (a one-time value, not information about the signer-in), but its autoComplete="one-time-code" is pinned in the same file for the separate SC 3.3.8/3.3.9 claim. apps/parent/src/routes/login.render.test.tsx (required check: test)
    • Code review, 2026-08-09: Six of six fields collecting the user's own information carry a correct autocomplete token: four on the website's enquiry form (name, organisation, role, email), one on the early access field (email) and one on the app's sign-in (email). The two honeypot fields opt out. The one remaining email box, where a member of school staff types a parent's address to invite them, collects information about somebody else, which is outside what this criterion asks for. The sign-in code field is outside it too, being a one-time value rather than information about you, and it still carries the one-time-code hint so a device can fill it.
  • 1.4.1 Use of Color Level A Supports No test

    Colour is never the only way we show something. Links in text are underlined, and results show their meaning in words or shapes as well as colour.

    How we know: 1.4.1 Use of Color

    Colour is never the only thing carrying a message. Links in body text are underlined, the four result bands always show their name in words beside the colour, and the report's per-area indicator changes shape as well as colour: a smile, a flat line or a frown.

    No test protects this.

    Evidence

    • Code review, 2026-08-09: Read rather than assumed, in three places. The app underlines links inside paragraphs and inside alert and status regions through a zero-specificity base rule. The website underlines links in article prose through the same kind of rule, and a search for anchors on the website carrying neither that rule nor an explicit underline returned only the skip links, which are not body text. The band chip renders the band name as text and hides its coloured dot from screen readers. The report indicator draws a different curve per state as well as a different colour, and carries an accessible name. Neither product draws a chart.
  • 1.4.2 Audio Control Level A Not Applicable

    This is about sound that plays by itself. It does not apply, because nothing on the website or in the app plays audio.

    How we know: 1.4.2 Audio Control

    Nothing plays audio. See 1.2.1.

    Evidence

    • Code review, 2026-08-08: As 1.2.1: no audio exists in scope.
  • 1.4.3 Contrast (Minimum) Level AA Partially Supports Test protects it

    Text needs enough contrast to be easy to read. Words in brand blue use a shade made for text, and a test keeps it that way. This is partly met: our check on each website page reports problems but cannot stop them.

    How we know: 1.4.3 Contrast (Minimum)

    Text contrast is checked by two automated checks, and they do not have the same force. The check over the colour pairs in our design system, in light and dark, is one of the checks that must pass before a change can be merged. The check over every page of the website, also in light and dark, runs by itself on any pull request that touches the website, and goes red, but it is not on the required list, so it reports rather than refuses. Two places on the home page did fail in dark mode, because they used hardcoded colours sitting outside the design system; both were fixed on 8 August 2026 and the page-level check now covers the theme they failed in. A quarantine stood in the design-system check until 22 August 2026, holding colour pairs out of the gate while one colour was split into a fill version and an ink version; an earlier version of this remark said four pairs when the register had grown to nine entries, and that undercount is recorded here rather than tidied away. The split is finished: separate ink colours now exist for those surfaces, every place that painted the old pairs moved onto them, the register is empty, and the worst of the quarantined pairs went from 2.5:1 to better than 9:1. One exclusion remains, an element excluded from the page-level check by name, which is the live measurement table on our colour contrast lab page: it shows each of our colours at the size and weight we actually use it and reports the threshold that applies there, while the page-level check holds everything to the ordinary body-text bar. It is set out below, because an exclusion nobody can see is worth less than no exclusion at all. There was a second exclusion until 15 August 2026, and it hid a real failure: a miniature Download PDF pill in an illustration, disclosed as a live failure while it stood. Its colour was deepened until it passes, and the exclusion was removed in the same change. A third stood until 23 August 2026: a demonstration further down the accessibility page that rendered our old failing button on purpose. Our call-to-action moved to the brandmark blue on 22 August 2026, where white text passes at any size, so the demonstration was about a button that no longer exists and both it and its exclusion were deleted. Nothing on the site is now excused from this check except the measurement table. One thing about that pill is worth writing down. This criterion does not reach text that is part of a picture carrying significant other visual content, and the pill is a drawing of a button inside a drawing of the app, so it was arguably never in scope at all. We treated it as a failure and fixed it anyway, and the deeper colour stays: reopening something that now passes, to argue it never had to, would leave the record worse than it is. The exemption is recorded here so the next reader does not spend the effort we did on text drawn inside an illustration.

    A test protects this, and it has to pass before any change can go in.

    Evidence

    • Automated gate, required to merge: Asserts the WCAG ratio and the APCA perceptual score for every semantic colour pair, in both themes, at the size and weight each pair is used at. Since 15 August 2026 that includes the small drawing of our call-to-action button on the how it works page, gated at the 4.5:1 normal-text bar because its label is drawn at 13 pixels, which is what stops the drawing quietly taking a colour that only passes at button size. Since 25 September 2026 the drawing uses the same blue as the real button. The named quarantine register this check carried is empty as of 22 August 2026. It existed because one colour was doing two jobs, the tinted fill of a chip and the ink sitting on that fill, and a colour light enough to be a fill cannot also be dark enough to be ink; in dark the quarantined pairs measured between 2.5:1 and 3:1. Separate ink tokens were minted for those surfaces, darker than the fill in light and lighter in dark, every place that painted the old pairs moved onto them, and the same elements now measure between 8.7:1 and 10.5:1 in dark, so the entries were deleted rather than kept as history the check no longer needs. The register's machinery stays in the test, so a future defect can be quarantined with its measurement, the files it renders in and the intended fix, rather than merged silently. The timing matters: the app gained a user-facing dark theme on the same day the register emptied, so a dark-only failure is no longer unreachable by definition, and the check's dark half now guards a theme people can actually turn on. packages/brand/src/contrast.test.ts (required check: test)
    • Automated gate, required to merge: Fails if any source file in either app or a shared package paints with the flat brand blue, through the primary text class or a colour rule reading the primary colour, outside a short allowlist. Every allowed use paints an icon, the wordmark, a decorative marker or a checkbox tick, each listed with what it paints, and the count per file is fixed, so a new use in an allowed file fails too. The flat blue measures 3.06:1 to 3.40:1 on the dark surfaces: enough for those non-text uses at 3:1, not enough for words at 4.5:1, so words in brand blue have to take the ink token instead. Added 27 September 2026. packages/compliance/src/brand-blue-text.test.ts (required check: test)
    • Automated check, advisory only: axe color-contrast over 34 public routes, requiring zero violations, with exactly one named exclusion: the live contrast table on our colour contrast lab page, which renders each of our colours as a sample so its numbers can be measured in front of you, at the threshold that applies at the size and weight we use it. Scoring a measurement exhibit against the body-text bar would be a false positive, and it hides nothing, because showing the numbers is the whole point of that section. There were three exclusions at different times. The second was the miniature Download PDF pill in the product illustration on the how it works page, white bold text at 13 pixels measuring 3.17:1 against a 4.5:1 bar, which was a real failure and was disclosed as one for as long as it was excluded; its background was deepened until white measures 4.96:1 there and the exclusion was deleted in the same change, on 15 August 2026, so the pill is now measured on every run. The third was a demonstration of our old failing button, and it went on 23 August 2026 with the section that carried it, because the call-to-action it argued about had moved to the brandmark blue where white passes at any size. The remaining exclusion is written into the suite by name and this manifest names it back, so removing it without saying so breaks this claim. apps/marketing/e2e/a11y.spec.ts (advisory check, cannot block a merge: Marketing a11y (axe))
    • Automated check, advisory only: That scan runs three times over the same build: light and dark at desktop width, and light at 320 pixels. Until 8 August 2026 it ran in light only, which is how two dark-mode failures on the home page stayed invisible to it; the narrow run was added on 9 August 2026. Contrast is measured in all three. apps/marketing/playwright.config.ts (advisory check, cannot block a merge: Marketing a11y (axe))
    • Automated check, run on demand: The on-demand sweep that found those two failures on 2026-08-08, by measuring the dark theme for the first time. Reporting only, gates nothing. apps/marketing/e2e/a11y-full-audit.spec.ts
    • Automated check, run on demand: Since 28 September 2026 the same axe rules also run every night on each app screen our journey map can reach with test data, one screen per journey step rather than one per route, in light and dark and at 320 pixels wide. A finding not written into a named list, with a reason and a ticket, turns that night's run red. It runs after changes are in, so it cannot stop a merge, and it does not stop a release either. It measures text contrast on the app's own screens in the dark theme, screen by screen, where the design-system check measures colour pairs. apps/parent/e2e-blueprint/blueprint-capture.spec.ts
    • Not verified: One residual gap keeps this short of a full claim: nothing about the page-level check can refuse a merge, so a contrast regression on the website goes red without stopping anybody. The primary colour used flat measures between 3.06:1 and 3.40:1 against the dark surfaces, and it paints only icons, the wordmark, decorative markers and a checkbox tick, where the 3:1 non-text bar applies and it passes. Words in brand blue use the ink token, which the design-system check holds at the text bar in both themes, and the flat-colour check above keeps it that way. There was another gap until 15 August 2026, a live failing element excluded from the page-level check by name; it was fixed and the exclusion removed together, and it is part of why this row reads as short of full support rather than as never having had a failure.
  • 1.4.4 Resize Text Level AA Partially Supports Test cannot block

    You can zoom in to 200% and pages still work. This is partly met: when you make only the text bigger, seven website pages leave too little room for words to wrap.

    How we know: 1.4.4 Resize Text

    You can zoom to 200% and beyond: we never block zoom, and the type scale is set in relative units. Since 16 August 2026 a probe measures the exposure the old row could only describe: it sets the ROOT font-size to 200%, which is what a browser or assistive tool's 'increase text size only' setting does, as distinct from full page zoom (measured elsewhere, and clean). Writing it found one bug that reached every page, a footer column with no floor under its grid track width, fixed the same day. It also found something the type scale being relative does not protect against: most of the site's SPACING (padding, gaps) is set in the same relative unit as the text, `rem`, so a text-only zoom that changes the root font-size grows the gaps around a heading right along with the heading itself, and on seven routes plus the home page's testimonial column that combination leaves too little room for one line to wrap. Those are named, dated and excluded from the probe rather than hidden, tracked under ASM-993: closing them is a typography decision (fixed-px gaps at this size, a narrower label style, or something else), not a mechanical fix this ticket should make unasked. Widening the app's half of that probe from eight screens to all sixteen on 16 August 2026 found a real failure in the app too, of the same shape as the footer one: the answer slider capped each of its middle labels at a fixed pixel width while sizing the label's own text in relative units, so at 200% the single word 'Somewhat' ran past its own edge with nowhere to wrap and was cut off. The cap is relative now, and every question card is measured.

    A test checks this, but it cannot stop a change going in. For the app, the same test runs again once the change is in, and a failure stops that change reaching the live app unless we override it and record why.

    Evidence

    • Code review, 2026-08-08: Neither app blocks zoom. The shared type scale is fully relative. Roughly sixty fixed pixel sizes bypass it, including a primary button and the website header.
    • Code review, 2026-09-16: The profiler's screen frame now grows its text on large windows, in steps keyed on window width and height (packages/screens/src/frame.css, 'THE BIG-SCREEN STEP'). Page zoom narrows the window, so zooming in can drop a step: on a 2000x1137 window the question is 44px at 100% page zoom and renders 56px at 200% (the window is then 1000 CSS px wide, below the desktop step and the 1024px breakpoint), 1.27 times rather than 2. It passes twice its original size (88px) by 400% page zoom, where the window is 500 CSS px wide, the question takes the 24px phone size and renders 96px (2.18 times), inside the browser's 500% range. The breakpoint half of that is older than the step: before it, the same question went 32px to 56px at 200%, and the text-size preference is a separate multiplier that no window size changes. The step and the preference are capped together at 1.75 so neither pushes the question block off a large screen.
    • Automated check, run on demand: Loading at 640 CSS pixels, the 200% zoom equivalent of a 1280 desktop, produced no horizontal overflow in either app on 2026-08-08. A full PAGE zoom measurement, not a text-only one: everything scales together at 640, which is why it could not have found the padding/gap exposure below. apps/marketing/e2e/a11y-full-audit.spec.ts
    • Automated check, advisory only: Sets html { font-size: 200% }, then asserts no horizontal document overflow and no text-bearing element clips its own text. Found and fixed on 2026-08-16: the footer's link columns used a literal `1.5fr_1fr_1fr_1fr_1fr` grid-cols with no minmax(0, ...) floor, so at 200% every column's own min-content width could force the whole grid, and with it the page body, wider than the viewport on all 24 routes; `min-w-0` on each column plus `break-words` on the one heading a doubled letter-spacing still overran is the fix. A second, different-shaped exposure remains, disclosed rather than fixed: seven routes and the home page's testimonial column pair fixed-pixel-equivalent rem PADDING (which also doubles under a root font-size override) with rem TEXT, and on those specific elements there isn't room left to wrap. `KNOWN_ZOOM_CLIP_DEBT` and `KNOWN_ZOOM_DOCUMENT_OVERFLOW_PX` name each one, dated, with the exact text or amount measured, so a copy change or a real fix makes the entry stop matching rather than silently keep passing. apps/marketing/e2e/resize-spacing-orientation.spec.ts (advisory check, cannot block a merge: Marketing a11y (axe))
    • Automated check, advisory only: The same font-size-only zoom check, over all sixteen of the app's scanned screens since 16 August 2026 (ASM-1130) rather than the eight it started with, held against the surface registry by a parity test in the spec. Widening it found one real failure the narrower set never reached: the answer slider's middle labels were capped at a fixed 140 pixels while their text was sized in rem, so at 200% the word 'Somewhat' overran its own box by 22 pixels and was clipped. Fixed the same day by making the cap relative as well (packages/profiler-components/src/components/RadioSlider.components.tsx). Clean on all sixteen after that, with no equivalent of the marketing footer or padding exposure anywhere in the app. It now also runs per pull request, but only on the narrow filter of the token layer and the preference plumbing published in ADVISORY_CHECKS, so a change outside that filter is still covered post-merge only. Advisory either way: it cannot refuse a merge until the ASM-1193 promotion lands (ASM-1298), 2026-08-27 at the earliest. apps/parent/e2e/resize-spacing-orientation.spec.ts (advisory check, cannot block a merge: Parent a11y probes)
    • Not verified: No real browser or assistive-technology 'increase text size only' feature has driven this, only a root font-size override that approximates it. And neither probe has looked at whether two adjacent, independently-sized text runs can visually OVERLAP without either one's own box registering as clipped, which is not something a scrollWidth/clientWidth comparison can see.
  • 1.4.5 Images of Text Level AA Supports Test protects it

    Words appear as real text you can zoom, select and have read aloud. Two tables that were pictures have been rebuilt as text, and a test keeps them that way.

    How we know: 1.4.5 Images of Text

    The two articles that carried a whole table as a picture now carry the table itself, transcribed cell by cell into markup you can select, zoom and read with a screen reader, and the pictures are deleted. The three images left with words in them are labelled diagrams, where the words label parts of an illustration, which the standard allows. A required check fails if either table becomes a picture again, or if any article embeds an image whose filename says it is a table.

    A test protects this, and it has to pass before any change can go in.

    Evidence

    • Automated gate, required to merge: Asserts both transcribed tables are still markup, with a caption, the expected first row header and the expected number of rows, and that no resource article embeds an image whose filename reads as a table, infographic, chart, matrix or comparison. Where it stops, precisely: it matches filenames and reads the article source rather than the rendered pixels, so a screenshot of text called something innocent would pass it. No check anywhere measures whether an image contains text, and the standing exposure is a person importing an image nobody opens. That is the exposure this row previously carried as an unverified line, kept here because it is a limit of the check rather than a gap in the finding. apps/marketing/tests/no-images-of-text.test.ts (required check: test)
    • Code review, 2026-08-15: Both images were opened, read and transcribed row by row into HTML tables in the MDX, then the built pages were re-read to confirm the markup survived MDX: 6 row headers and 38 list items in the classroom table, 12 row headers and 36 cells in the therapy table, each with a caption. Both routes were then loaded at 320 CSS pixels: no horizontal overflow on the document, sideways scrolling confined to the table's own region, and axe clean at wcag2a, wcag2aa, wcag21a, wcag21aa and wcag22aa. One transcription departs from the picture by a single character: a bullet reading 'Strong reactions to "no' in the screenshot, where the closing quotation mark had been lost, is written with the quotation closed.
    • Code review, 2026-08-15: The three images resource articles still embed were re-opened in this change and are labelled diagrams: a linear autism spectrum arrow, an autism spectrum wheel chart, and a low self-esteem and withdrawal loop. Each is an illustration whose words label its parts, which is the exception this criterion grants. This is a re-check of those three only. The claim that everything else the site renders is a photograph, decorative shape, branded icon or logo rests on the sweep of 9 August 2026 recorded below, not on a fresh pass.
    • Code review, 2026-08-09: Every image the live website renders was traced from the page that references it and then opened and read. This sweep is why the row can say anything at all about images nobody has re-opened since: it replaced a claim that the only text-bearing images were logotypes and award badges, which was wrong and had never been checked against the images themselves. Two images were photographs of a data table, both in resource articles, and those two are what NC-14 disclosed and this change fixes. Three were labelled diagrams. The rest are photographs, decorative shapes, branded icons and logos. The comparison tables and outcome infographics elsewhere in the asset folder are left over from the previous website and no page references them. The app renders no bitmap images at all: its illustrations are drawn as inline vectors.
  • 1.4.10 Reflow Level AA Partially Supports Test cannot block

    Pages should fit a narrow phone screen without scrolling sideways. Automatic checks measure this on both products, but no person has yet checked the website on a real phone at the narrowest width.

    How we know: 1.4.10 Reflow

    Since 15 August 2026 the routine scan of the website measures, on every page and at the 320 pixel width this criterion names, whether the page is wider than the screen it is being read on. It runs by itself on any change that touches the site and goes red, though it is not on the short list of checks that can refuse a merge. When it fails it names the elements sticking out, because a reflow fault is usually one stubborn element rather than a broken page. An agent-driven walkthrough judged the website's narrow layouts on 16 August 2026 and named three rough edges, none severe enough to call the layouts unusable. On 21 August 2026 two of the three stopped standing. The leftover blank space between numbered steps did not survive a re-measure at 320 pixels under reduced motion: the gaps are filled with product-mock and quote content, not reserved height from a collapsed desktop layout, so that finding was a capture artifact rather than a fault. The long reference page with no way to jump to a term was answered by the glossary rebuild of 21 August 2026 (ASM-1321), which regrouped its 257 terms by letter behind a sticky letter rail and added a search box and a nation filter. That rebuild landed separately from this row's own work, which is why the mechanism is a letter rail rather than the category jump list first proposed. The third, a comparison table that scrolled with no visual cue that it did, was answered on 24 August 2026: both of the site's sideways-scrolling table wrappers now draw a shadow at whichever edge still has content behind it, and a check reads the background the browser actually painted rather than trusting the rule to be there. What is left of the judgement half is not a fault we have found but a way we have not looked: a person has still not sat with a real phone and done the same pass. The signed-in app is now measured too, on every push to main rather than on every pull request: its accessibility navigation gate runs at 320 and at 640 pixels and applies the same width comparison to every screen it scans. On 21 August 2026 that measurement found and closed its first real fault, a one pixel overhang on the school-facing roster caused by a visually hidden label escaping the sideways-scrolling area the table sits in. That measurement is no longer the only thing that has looked: on 25 August 2026 a person walked the app's narrow layouts by hand for the first time, going through onboarding and the profiler end to end at 375 pixels wide in a desktop browser and filing 31 findings, several of them layout faults that appear only on a narrow screen (a footer overlapping the Continue button, a progress pill wrapping to two lines, a call to action with no safe-area padding). Two things that pass is NOT: it was 375 pixels rather than the 320 this criterion names, and it covered the app rather than the website, so it discharges neither of the outstanding human passes. It is recorded as TS-4 in the testing log.

    A test checks this, but it cannot stop a change going in.

    Evidence

    • Automated check, advisory only: Compares the document's scroll width against the viewport on the 34 public routes the suite scans, in every project the suite runs, including the 320 pixel one. Added on 15 August 2026 because no rule in the axe catalogue measures this at any width, which is why running the scan at 320 since 9 August 2026 had not closed it: the suite was at the right width with nothing that could fail there. It reports the widest offending elements by tag, id and class, and it is a soft assertion paired with the axe one so that a contrast failure on the same page cannot stop the reflow result being reported. Confirmed to fail on a deliberately overflowing element before it was reverted. apps/marketing/e2e/a11y.spec.ts (advisory check, cannot block a merge: Marketing a11y (axe))
    • Automated check, run on demand: Zero horizontal overflow across 284 page states at 320, 640, 768 and 1280 pixels, in light and dark. Wider than the routine check in viewports and themes, and reporting only, on no CI path. apps/marketing/e2e/a11y-full-audit.spec.ts
    • Automated check, run on demand: The same probe over the signed-in app screens, comparing the document's scroll width against the viewport at 320 pixels. Reporting only, on no CI path. It is no longer the app's only narrow measurement: the entry below is the routine one. apps/parent/e2e/a11y-full-audit.spec.ts
    • Automated check, run on demand: The app's routine narrow measurement. The accessibility navigation gate compares the document's scroll width against the viewport before every scan, and apps/parent/playwright.config.ts reruns that whole gate in two extra viewports, chromium-narrow at 320 pixels and chromium-640 at 640. It runs on every push to main and on request, not on the ordinary pull request, so it reports a reflow regression within a day rather than the moment one is written. Proved to fail red and then pass: on 21 August 2026 the school-facing roster measured 641 pixels wide in both viewports, and 320 and 640 after the fix. apps/parent/e2e/a11y-nav.spec.ts
    • Automated check, advisory only: The scroll affordance on both of the site's horizontally-scrolling table wrappers, added 24 August 2026 (ASM-1295) to answer the third rough edge the walkthrough named. The shadow is CSS only, four background layers with no script behind it, so nothing adds or removes a class that a probe could look for. The check therefore reads the computed background the browser actually painted: a gradient in `backgroundImage` says the layers exist, and `local` in `backgroundAttachment` says at least one scrolls with the content, which is what makes the shadow hide itself at each scroll end. It measures the /accessibility/contrast table; the shared DataTable wrapper carries the same rule and is not separately probed, which is where this evidence stops. apps/marketing/e2e/a11y.spec.ts (advisory check, cannot block a merge: Marketing a11y (axe))
    • Recorded audit, 2026-08-16: The judgement pass this row was missing: 23 public routes walked at 320x812 and read for reading order, buried content, table/figure legibility and control reachability (the scan set grew to 24 the same day; /early-access is the one scanned route not yet walked, named in the record). Agent-driven, not a person. 18 of 23 routes were clean. Three rough edges were named; the 2026-08-21 addendum on the same record rules the stepper blank-space finding out under reduced motion and records that the /glossary buried-by-volume finding was closed by the letter-grouped rebuild of the same date (ASM-1321), not by work on this row. The contrast-lab table scroll affordance closed on 2026-08-24 (ASM-1295), which retired the last of the three. None of the original three made a layout unusable. This stays a Partial rather than closing outright because the pass itself was agent-driven and light mode only, and a person has not repeated it. agents-docs/design-system/audits/2026-08-16-320px-walkthrough.md
    • Not verified: The judgement pass above was agent-driven and covered light mode only. Nobody has done the same walkthrough with a real phone, in dark mode, or with the FAQ disclosures and tabbed panels opened rather than left at their default state. The signed-in app has no equivalent judgement pass at all, only the on-demand overflow sweep.
  • 1.4.11 Non-text Contrast Level AA Supports Test protects it

    The keyboard focus outline and the border of a form field stand out clearly from their background. An automatic test checks this in both light and dark mode.

    How we know: 1.4.11 Non-text Contrast

    The things that are not text but still tell you something, the focus ring around whatever you have selected and the border around a form field, are held to the 3:1 minimum in both light and dark by a check over our colour tokens, and that one really is on the list of checks a change has to pass before it can be merged.

    A test protects this, and it has to pass before any change can go in.

    Evidence

    • Automated gate, required to merge: Asserts 3:1 for the focus ring against page, card and muted surfaces, and for the input border against card and page, in both themes. This gate caught and fixed a real failure where the default focus outline rendered at half opacity and measured 2.08:1. packages/brand/src/contrast.test.ts (required check: test)
    • Automated check, run on demand: Turns on every rule axe knows about, read from axe's own catalogue rather than from a list we maintain, across 284 page states on 2026-08-08, and reported nothing here. Named for what it does not add as much as for what it does: no rule in that catalogue measures the contrast of a focus ring or a field border, so this sweep corroborates nothing on this row. The check over our colour tokens is the whole automated basis for it. apps/marketing/e2e/a11y-full-audit.spec.ts
  • 1.4.12 Text Spacing Level AA Supports Test cannot block

    If you widen the spacing between lines, letters and words with your own settings, no text is cut off. Automatic checks cover every scanned page, and a sample of screens was checked for overlapping text.

    How we know: 1.4.12 Text Spacing

    If you increase line height, letter spacing and word spacing with your own stylesheet, nothing is cut off. Two different kinds of evidence sit behind that, and neither is enough on its own. The first is a probe: since 16 August 2026 it injects the criterion's own values (1.5 line height, 2em paragraph spacing, 0.12em letter spacing, 0.16em word spacing) on every public route and on all sixteen scanned app screens, and asserts that no text-bearing element clips its own box, horizontally or vertically. Every route and every screen is clean, and it runs by itself on any pull request that touches the website, advisory rather than required. What it cannot see is two runs of text overlapping while each stays inside its own box, which is still loss of content under this criterion: measuring a box against its own contents can never detect that. The second is a person-equivalent look, because that is the only thing that can. On 16 August 2026 an agent applied the same override and read the rendered screens as images, going first to the places where text is positioned rather than flowed, which is the only place two runs can meet: the profiler's answer slider above all, then the dense tables and the badges and pills on both products. Nothing overlapped anywhere it looked. The slider, the likeliest offender in either product, keeps 43 pixels of clear space between its closest pair of labels at the narrowest layout that shows them. That look was a sample, not a sweep: eight of the app's sixteen screens and twelve of the website's twenty-four routes, light mode, English, one state per screen. It was an agent reading screenshots, not a person using the product and not a screen-reader session. The full method, the surfaces it skipped and why, and two measurement traps that looked like failures and were not, are in the record below.

    A test checks this, but it cannot stop a change going in. For the app, the same test runs again once the change is in, and a failure stops that change reaching the live app unless we override it and record why.

    Evidence

    • Automated check, run on demand: Injects the standard text-spacing override into every page state and compares against a baseline. Zero new clipping in either app, at all four widths, in every colour scheme. apps/marketing/e2e/a11y-full-audit.spec.ts
    • Automated check, advisory only: Injects the SC 1.4.12 override values as a style tag on all 24 scanned routes and asserts no text-bearing element's scrollWidth or scrollHeight exceeds its own client box, deliberately excluding elements that already truncate on purpose (overflow hidden, nowrap, ellipsis) since the override did not create that choice. Clean on every route on 2026-08-16. Where it stops: a box that clips its own text is what this measures, and two text runs that overlap while each stays inside its own box is not something a scrollWidth against clientWidth comparison can see. Nothing we have found detects that, and it is the reason the remark above says clipped rather than obscured. apps/marketing/e2e/resize-spacing-orientation.spec.ts (advisory check, cannot block a merge: Marketing a11y (axe))
    • Automated check, advisory only: The same override over all sixteen of the app's scanned screens, widened from eight on 16 August 2026 (ASM-1130) and held against the surface registry by a parity test in the spec, so a screen cannot join the product without joining this probe. Clean on all sixteen, including the report, the review index, the done card and the four school-staff screens the narrower set never reached. The same limit as the marketing probe applies: it sees clipping, not overlap. It now also runs per pull request, but only on the narrow filter of the token layer and the preference plumbing published in ADVISORY_CHECKS, so a change outside that filter is still covered post-merge only. Advisory either way: it cannot refuse a merge until the ASM-1193 promotion lands (ASM-1298), 2026-08-27 at the earliest. apps/parent/e2e/resize-spacing-orientation.spec.ts (advisory check, cannot block a merge: Parent a11y probes)
    • Recorded audit, 2026-08-16: The overlap half, which no probe on this row can reach. The same override applied by hand and the rendered screens read as images: 8 of the app's 16 scanned screens and 12 of the website's 24 scanned routes, at 768 to 1280 pixels wide, prioritised by where text is positioned or transformed rather than flowed, since text in normal flow cannot overlap a sibling. No overlap found anywhere. The profiler's answer slider, whose middle labels are absolutely positioned next to each other on one track and are the likeliest place in either product for this to happen, keeps 43 pixels between its closest pair at 768 pixels wide, growing with the viewport from there. Agent-driven and honest about it: an agent reading captured screenshots, not a person using the product, not a screen reader, light mode and English only, one state per screen, and 30 of the 194 captured frames read as images rather than all of them. The record names every surface it skipped. It also names two ways of asking a browser where an element is that each manufactured a convincing false finding, which is why this stayed a written walkthrough rather than becoming another automated check. agents-docs/design-system/audits/2026-08-16-text-spacing-overlap-walkthrough.md
  • 1.4.13 Content on Hover or Focus Level AA Supports Test protects it

    Tips that pop up when you point at or select something can be dismissed, can be pointed at, and stay until you move away. The one tip that broke this rule was removed.

    How we know: 1.4.13 Content on Hover or Focus

    Tooltips behave properly: you can dismiss them, move your pointer onto them, and they stay put. The one exception, a speech bubble on the app's Get help button that appeared on hover only and could not be hovered or dismissed, was removed on 15 August 2026 rather than rebuilt, because its text repeated the button's own label and nothing was lost with it. What holds this now is a test that the bubble has not come back; a hover tooltip built somewhere new would be caught by review rather than by that test.

    A test protects this, and it has to pass before any change can go in.

    Evidence

    • Code review, 2026-08-08: Shared tooltips are dismissable, hoverable and persistent, including a deliberate wrapper that makes a disabled button's tooltip reachable. A read of one date: nothing re-reads them since.
    • Automated gate, required to merge: Fails if the removed hover bubble comes back, or if anything else inside that button gains a hover or focus reveal class. It checks an ABSENCE on purpose, and that is where it stops: a CSS reveal cannot be exercised in jsdom, so no unit test anywhere can prove that a tooltip which DOES exist behaves. Only that one does not. packages/profiler-components/src/components/__tests__/ProfilerChatWidget.test.tsx (required check: test)

Operable

You can drive it, with a keyboard, a mouse, a finger or a voice. 20 criteria.

  • 2.1.1 Keyboard Level A Partially Supports Test cannot block

    Everything should work with a keyboard alone. We use standard controls that do, but there is no keyboard test yet for the report tabs, the chat or the school staff screens.

    How we know: 2.1.1 Keyboard

    We build with plain HTML controls wherever we can, so keyboard support comes for free: the answer control is five real radio buttons, the review list is a native disclosure, and each profiler step is a real page. The gap that mattered here has closed. Until 9 August 2026 our routine scan of the website only ever loaded pages at desktop width, where nothing scrolls, so a scrolling area that appears only on a narrow screen and that a keyboard cannot reach was something the scan was structurally unable to see. It now loads all 21 pages at 320 pixels as well, which is the width where that condition exists, so the rule that catches it finally has something to fire on. What is left: that scan reports rather than blocks, it does not run on a change that touches only the app, and there is still no keyboard walkthrough test for the report tabs, the chat or the school staff screens.

    A test checks this, but it cannot stop a change going in.

    Evidence

    • Automated check, advisory only: Drives the skip links with real key presses and reads the computed focus outline rather than the markup. apps/marketing/e2e/keyboard-focus.spec.ts (advisory check, cannot block a merge: Marketing a11y (axe))
    • Automated check, advisory only: Added 9 August 2026. A third pass over the same build at 320 CSS pixels, covering the 34 public routes the suite scans. The axe rule that catches an unreachable scrolling area only fires where a container actually overflows, which at desktop width none does, so this is what turned that rule from passing by absence into passing on a measurement. It found the two tables on our own accessibility pages already fixed, and no other route failing. apps/marketing/playwright.config.ts (advisory check, cannot block a merge: Marketing a11y (axe))
    • Automated check, run on demand: One keyboard contract asserted end to end in the app, on the post-merge tier. apps/parent/e2e/a11y-nav.spec.ts
    • Code review, 2026-08-09: Traced from the route rather than taken on trust, because our internal map had this wrong. The live 75-question route renders the shared answer control, which is a radio group of five real radio stops, each named with the option it stands for. The similarly named control in the app's own folder is reachable only from an internal component gallery and is not what anybody answers questions with. Neither this row nor any other in this file cited that one.
    • Not verified: No keyboard walkthrough exists for the 75 question flow, the report tabs, the chat or the school staff screens.
  • 2.1.2 No Keyboard Trap Level A Partially Supports Test cannot block

    Your keyboard should never get stuck on one part of the page. Automatic checks found no place where it sticks, but pop-up windows and the open help chat have not been tested yet.

    How we know: 2.1.2 No Keyboard Trap

    Escape closes the app's menu and returns you to the button that opened it, and that is asserted by a test. A behavioural probe (ASM-984) now tabs all the way through every scanned public marketing route and a representative set of app routes, bounded by twice each route's own focusable-element count, and confirms focus never sticks on one control and Shift+Tab can always retreat from wherever the walk stops. Two places remain genuinely untested: the shared dialog's own trap behaviour, and the get-help chat panel's OPEN state (since ASM-1090 its CLOSED state is inert, so it puts nothing into the tab order for the walk to reach, see the SC 2.4.11 row).

    A test checks this, but it cannot stop a change going in. For the app, the same test runs again once the change is in, and a failure stops that change reaching the live app unless we override it and record why.

    Evidence

    • Automated check, run on demand: Asserts Escape closes the navigation sheet and returns focus to its trigger. Post-merge tier. apps/parent/e2e/a11y-nav.spec.ts
    • Automated check, advisory only: Tabs through every scanned public route until the tab order laps its own first stop or runs out, bounded by twice the route's own focusable-element count, and asserts no element holds focus across two consecutive Tab presses and that Shift+Tab can always move back from wherever the walk stops. Clean across all 24 scanned routes. apps/marketing/e2e/keyboard-focus.spec.ts (advisory check, cannot block a merge: Marketing a11y (axe))
    • Automated check, advisory only: Same walk over home, privacy, create-child, a profiler overview card, a profiler slider card and the SENCO org roster. No stall on any of the six. Where it stops: those six screens, so the shared dialog and the report/chat surfaces are not walked, and it now also runs per pull request, but only on the narrow filter of the token layer and the preference plumbing published in ADVISORY_CHECKS, so an ordinary apps/parent change is still covered post-merge only. Advisory either way: it cannot refuse a merge until the ASM-1193 promotion lands, 2026-08-27 at the earliest. apps/parent/e2e/keyboard-focus.spec.ts (advisory check, cannot block a merge: Parent a11y probes)
    • Not verified: The hand-written focus trap in the shared modal, and the get-help chat panel's OPEN state, carry no assertion.
  • 2.1.4 Character Key Shortcuts Level A Supports Test cannot block

    No single key press, such as a letter, sets off an action by surprise. Number keys pick an answer only when the answer control is already selected, which the standard allows.

    How we know: 2.1.4 Character Key Shortcuts

    No single-character keyboard shortcut exists in either product, which is the thing this criterion is about. Two bindings need saying rather than glossing. A number key jumps straight to an answer position, and it works only while the answer control already has focus, which the criterion allows explicitly. A Ctrl or Command plus B combination collapses a sidebar, and the criterion covers shortcuts made of a character alone, not ones that need a modifier held down. Nothing else listens for a character key. A behavioural probe (ASM-984) now samples a set of printable keys with nothing focused and confirms none of them move the page, on both products.

    A test checks this, but it cannot stop a change going in. For the app, the same test runs again once the change is in, and a failure stops that change reaching the live app unless we override it and record why.

    Evidence

    • Code review, 2026-08-09: Every keyboard listener in both apps and the shared component packages was read, and re-counted on 2026-08-09 because the previous count here was wrong. Listeners attached to the document, the window or the global object: six, not three. Four are Escape only, on the website's mobile menu, the app's help bubble, the app's chat panel and the comments sheet. One, in the shared modal, handles Escape and also Tab, to keep focus inside the dialog. The sixth is the sidebar's Ctrl or Command plus B toggle, which lives in the shared component library and is rendered only by the school-facing prototype and our component gallery. Everything else is bound to the control that has focus and uses arrow keys, Home, End, or the digits 1 to 5 on the answer control. Neither Escape nor Tab is a character key, and a modifier combination is outside what this criterion covers, so the verdict does not turn on the corrected count.
    • Automated check, advisory only: With focus on the page body, presses a sample of ten printable keys on two routes and asserts the URL and any open dialog stay unchanged. A sample, not a proof: it cannot rule out a shortcut bound to a character this list does not try. apps/marketing/e2e/keyboard-focus.spec.ts (advisory check, cannot block a merge: Marketing a11y (axe))
    • Automated check, advisory only: Same sample on the app's home screen. It now also runs per pull request, but only on the narrow filter of the token layer and the preference plumbing published in ADVISORY_CHECKS, so an ordinary apps/parent change is still covered post-merge only. Advisory either way: it cannot refuse a merge until the ASM-1193 promotion lands, 2026-08-27 at the earliest. apps/parent/e2e/keyboard-focus.spec.ts (advisory check, cannot block a merge: Parent a11y probes)
  • 2.2.1 Timing Adjustable Level A Supports No test

    Nothing makes you race a clock. Sign-in links and codes expire for security, and if one runs out you can simply ask for another.

    How we know: 2.2.1 Timing Adjustable

    Nothing in either product puts you under a clock you have to beat. Your session lasts 7 days and extends as you use it, which is well past the point this criterion starts to apply. The three things we email that do expire, the sign-in link at 15 minutes, the sign-in code at 10 minutes and a school's invitation link at 14 days, are all covered by the standard's own exceptions: two are security limits, the third is longer than 20 hours. If a sign-in link or code runs out you simply ask for another one.

    No test protects this.

    Evidence

    • Code review, 2026-08-15: Read from the auth configuration and the invitation code: session 7 days with a rolling extension after a day's use, magic link 15 minutes, sign-in code 10 minutes, parent invitation 14 days. No countdown, no idle timeout, no auto-submitting form and no timed redirect exists anywhere in either app. What we have not walked is the screen you land on after an expired link, which is a question about how good the recovery is rather than about the time limit.
  • 2.2.2 Pause, Stop, Hide Level A Partially Supports No test

    Moving content should be something you can pause or stop. There are no slideshows or autoplay, but one demo in the app's welcome tour changes its text on a loop with no pause button.

    How we know: 2.2.2 Pause, Stop, Hide

    There are no carousels, tickers or autoplaying anything, and every looping animation stops if you have asked your device to reduce motion. One demonstration in the app's welcome tour changes its own text on a loop with no pause button.

    No test protects this.

    Evidence

    • Code review, 2026-08-08: No carousels, marquees, tickers, timers or autoplay exist in scope. Every infinite animation sits behind a reduced-motion guard. The welcome tour demo auto-cycles visible text every 3.9 seconds with no in-page control.
    • Not verified: The public website has no site-wide reduced-motion guard, only four per-component ones, so a new animation is unguarded by default.
  • 2.3.1 Three Flashes or Below Threshold Level A Supports No test

    Flashing content can cause seizures. Nothing in the website or the app flashes, and every animation moves or fades slowly, well under three times a second.

    How we know: 2.3.1 Three Flashes or Below Threshold

    Nothing flashes. The looping animations that do exist take 900 milliseconds or longer to complete a cycle, which is comfortably under three flashes a second even if you count every cycle as a flash, and they move or fade rather than switching brightness.

    No test protects this.

    Evidence

    • Code review, 2026-08-09: Every endlessly looping animation in both products was read: a scroll cue bobbing on a 2.2 second cycle, a form field pulse at 900 milliseconds, two background motifs at 3 and 5 seconds, and the loading placeholder's 2 second fade. The fastest of those is around 1 hertz against a threshold of 3. No blink, no strobe, no rapid colour inversion and no video exists in scope. One-shot transitions run 120 to 220 milliseconds and do not repeat.
  • 2.4.1 Bypass Blocks Level A Partially Supports Test cannot block

    Skip links let keyboard users jump past the menu to the main content. Every website page has them, but the app's skip links are not tested and its error screen has none.

    How we know: 2.4.1 Bypass Blocks

    Every page on the website starts with skip links, and a test drives them with real key presses. The app has skip links on every signed-in screen but no test for them, and the error screen you see after a wrong link has none at all.

    A test checks this, but it cannot stop a change going in.

    Evidence

    • Automated check, advisory only: Asserts both skip links behaviourally on the public website. apps/marketing/e2e/keyboard-focus.spec.ts (advisory check, cannot block a merge: Marketing a11y (axe))
    • Not verified: No app test asserts the skip link. The app's error screen renders no skip link and no landmark id. Three internal design-lab pages carry a skip link whose target does not exist.
  • 2.4.2 Page Titled Level A Supports Test protects it

    Every page has a title of its own, so you can tell pages and browser tabs apart. A test fails if a new app screen has no title.

    How we know: 2.4.2 Page Titled

    Every page of the public website has a title, through a single shared component. The app has a default title at its root, so no screen can emit none, and every screen that a parent or a member of school staff can reach names itself: the last two that did not, an individual chat conversation sharing the chat list's title and the report titling itself in English regardless of the language you chose, were fixed on 15 August 2026. A test walks the app's real route list and fails if a screen ships without a title of its own, which is the part that was missing before: until then a route could ship untitled and nothing would notice.

    A test protects this, and it has to pass before any change can go in.

    Evidence

    • Automated check, advisory only: Every public route passes through one title component. apps/marketing/e2e/a11y.spec.ts (advisory check, cannot block a merge: Marketing a11y (axe))
    • Automated gate, required to merge: Reads the app's real route graph and fails if a route that renders a screen has no title of its own, if the chat list and a conversation converge on one title again, or if a title is written as an English literal rather than a translatable message. It is a source scan, so it checks that a title EXISTS and cannot judge whether the words are any good. apps/parent/src/routes.titled.test.ts (required check: test)
    • Automated gate, required to merge: Holds the root default, including the two error screens, so the floor under every route cannot be removed silently. apps/parent/src/root.meta.test.ts (required check: test)
  • 2.4.3 Focus Order Level A Partially Supports Test protects it

    Moving through a page by keyboard should follow a sensible order. In the questionnaire your place now moves to each new question, but the report tabs, the chat and the school staff screens are untested.

    How we know: 2.4.3 Focus Order

    In the profiler, the card moves on by itself just over a second (1.2 seconds) after your first answer. Your keyboard position now moves with it, onto the new question's heading, and the new question is announced. That was a real failure until 8 August 2026, when a keyboard or screen reader user was dropped back to the top of the page on every one of the 75 questions; it is now fixed and held by tests. Focus order elsewhere in the product, the report tabs, the chat and the school staff screens, has no walkthrough test.

    A test protects this, and it has to pass before any change can go in.

    Evidence

    • Automated gate, required to merge: Asserts that when the card advances, focus lands on the new question's title rather than falling to the page body, that the title is the new prompt and stays out of the tab order, and that a direct or bookmarked load does not steal focus. Going back is covered only by where the Back link points, not by a focus assertion: the focus move is the same code in both directions, but this test does not drive the back step. apps/parent/src/routes/profiler/dimension-card.component.test.tsx (required check: test)
    • Automated check, run on demand: The same contract driven end to end with real key presses: tab to the answer control, select with the space bar, press Enter, then assert focus sits on the new question's title, that the title's text is the new prompt, and that the focus outline shows after a keyboard advance but not after a tap. Post-merge tier, so it reports rather than gates. apps/parent/e2e/a11y-nav.spec.ts
    • Not verified: No automated rule in the axe catalogue can see a focus-order problem, so nothing sweeps for the next one. There is no keyboard walkthrough for the report tabs, the chat or the school staff screens.
  • 2.4.4 Link Purpose (In Context) Level A Supports Test cannot block

    Every link says where it goes, in its own words or in the sentence around it. A search found no vague links such as 'click here'.

    How we know: 2.4.4 Link Purpose (In Context)

    Every link has a name. That is checked automatically on the website whenever a change touches it, and on the app's screens after merge, though neither check is one of the three that can actually stop a merge. A search across both products for the vague link text this criterion exists to stop, 'click here', 'read more', 'learn more', 'more', 'here', returned nothing. We know of no link whose purpose is unclear from where it sits.

    A test checks this, but it cannot stop a change going in.

    Evidence

    • Automated check, advisory only: The WCAG 2.2 A and AA rule set, which carries link-name, over 34 public routes. apps/marketing/e2e/a11y.spec.ts (advisory check, cannot block a merge: Marketing a11y (axe))
    • Automated check, run on demand: The same rule set, which is what carries link-name, over the 21 scanned app screens. Post-merge and on-demand tier, plus the dark and forced-colours reruns on the narrow pull request filter published in ADVISORY_CHECKS, so it reports rather than gates either way. apps/parent/e2e/a11y-nav.spec.ts
    • Code review, 2026-08-09: A search for vague link text across the website, the app and the report components, re-run on 2026-08-09, returned nothing. This row was published as 'Partially Supports' until that date purely because the old rule in this file required a gate for anything else, which it had. The gate was never the issue.
  • 2.4.5 Multiple Ways Level AA Partially Supports Test cannot block

    There should be more than one way to find a page, such as a menu and a site map. The website has several, but the school staff area in the app has only one.

    How we know: 2.4.5 Multiple Ways

    The public website gives you a header menu, a four-column footer, breadcrumbs and a sitemap. The profiler is a step-by-step process, which the standard exempts. The school staff area has only one way to get anywhere.

    A test checks this, but it cannot stop a change going in.

    Evidence

    • Automated check, advisory only: Crawls the built site's own nav, footer and hub-page content (the /trust, /glossary and /accessibility index pages linking their own children, plus the sitewide header CTA) and fetches the real generated sitemap, then asserts every registered marketing route is reachable by at least two of those mechanisms. The school staff area is app-side and out of this probe's scope; its single-mechanism gap is the code-review finding below. Runs on the Marketing a11y (axe) workflow, which is plain `playwright test` with no project filter, so it picks this spec up whenever a pull request touches the site. It goes red on a regression and cannot refuse a merge, being no part of lint, test or type-check. apps/marketing/e2e/consistency.spec.ts (advisory check, cannot block a merge: Marketing a11y (axe))
    • Code review, 2026-08-08: Website: header nav, footer, breadcrumbs and sitemap. App school staff area: one navigation affordance and no second route to anything.
  • 2.4.6 Headings and Labels Level AA Partially Supports Test cannot block

    Headings and labels should say clearly what follows them. Labels are good and headings are in the right order, but no person has yet judged whether every heading is well worded.

    How we know: 2.4.6 Headings and Labels

    Labels are in good shape throughout. The school staff screens were the failure here, and were fixed on 15 August 2026: each of the five now has one top-level heading and named sections, pinned by a test that reads each screen's outline word for word. On 16 August 2026 a screen reader read part of the parent journey aloud for the first time, and three of the five screens it reached announced their first heading at level 2 with nothing above it; that was tracked as ASM-1154 and fixed on 25 August 2026, so every profiler card page now has exactly one top-level heading, held by a test over every card the flow can emit. Whether a heading elsewhere in the app or on the website is well worded is a judgement no tool makes, and nobody has made it.

    A test checks this, but it cannot stop a change going in.

    Evidence

    • Automated check, advisory only: Structural checks over 34 public routes. apps/marketing/e2e/a11y.spec.ts (advisory check, cannot block a merge: Marketing a11y (axe))
    • Automated check, run on demand: All five school staff screens are scanned and their heading outline asserted: one top-level heading, first, and no level skipped. Three are scanned in both render branches; the roster's empty state and the assisted session's code step are held by a unit test instead, because no browser run can reach them. Post-merge tier. apps/parent/e2e/a11y-nav.spec.ts
    • Code review, 2026-08-15: The wording of the new section headings was chosen against the screen they head rather than the page title, which is the part of this criterion no rule measures. The same review is what found that the disclosure it retired had miscounted the screens and missed that all of them already had a top-level heading.
    • Recorded audit, 2026-08-16: The first time anything read this app's headings aloud, and it turns the unverified entry below from a blank into a partial answer. VoiceOver walked five of the parent profiler's 108 screens on a hosted macOS runner and the transcript was kept: the intro card and the safety notice announced a level 1 heading, and the two capture cards and the screener announced level 2 with nothing above it (ASM-1154). Agent-driven, and nobody listened: the phrases are read out of VoiceOver's own log. The level finding was fixed after the walk, on 25 August 2026 (ASM-1154, commit 5bf480efa), and no reader has been back since. Where it stops, in three ways that matter here. It reached the top of each card only, since the reader is capped at 25 moves per screen and about ten of those are spent on the banner. It covered four of the itinerary's nine card types and none of the 76 question cards. And a transcript records what was announced, not whether a heading describes what follows it, which is the judgement this row is actually waiting on. agents-docs/design-system/audits/2026-08-16-voiceover-profiler-walk.md
    • Code review, 2026-08-25: ASM-1154, the level finding this row picked up from the walk, is closed. Every profiler card page now carries exactly one top-level heading: the frame supplies a visually hidden one on the card types whose visible heading is a question, and the interstitials keep the visible title they already had. Held by component tests that render every card the flow can emit and count the level 1 headings, listed in full under SC 1.3.1 rather than repeated here. This changes the LEVELS only. The wording judgement the not-verified entry below describes is untouched by it, which is why this row does not move.
    • Not verified: No one has read the headings on the parent journey or the public website and judged whether each describes what follows it. The walk of 16 August 2026 recorded what a reader announced on five profiler screens, which establishes the levels rather than the wording, and the website has still had no equivalent look of any kind.
  • 2.4.7 Focus Visible Level AA Supports Test cannot block

    When you use a keyboard, a visible outline shows which item you are on. This works on the website and in the app, and a test checks the outlines on eight app screens.

    How we know: 2.4.7 Focus Visible

    Every focusable thing on the public website draws a visible outline, and since 15 August 2026 so does everything in the app. Before that the app's site-wide rule was only a default: 56 controls switched it off, and most of them looked from their code as though they had not, because the ring they switched to was painting fully transparent. All 56 were fixed, and a test now walks the entire keyboard path of eight screens in the app, reads back the outline the browser really drew on each stop, and measures it at 3 to 1 against the surface behind it. It runs on request and on the push to main rather than on every pull request, so it reports a regression within a day rather than the moment one is written. That is not a hypothetical: the site footer, added later, brought the same switched-off pattern back on eight links that appear on every screen of the app, and they drew nothing at all on keyboard focus until 21 August 2026. The test above caught it on the day the footer landed and stayed red about it, because nothing was reading what it said.

    A test checks this, but it cannot stop a change going in. For the app, the same test runs again once the change is in, and a failure stops that change reaching the live app unless we override it and record why.

    Evidence

    • Automated check, advisory only: Reads computed styles, so a rule that exists but does not render would still fail. Covers the website only. apps/marketing/e2e/keyboard-focus.spec.ts (advisory check, cannot block a merge: Marketing a11y (axe))
    • Automated check, advisory only: Tabs through the whole keyboard path of home, the privacy page, the create-child form, the neurodivergence step, a profiler overview card, a profiler slider card, the review index and the SENCO roster, and for every stop requires an indicator that focus itself added, at 3 to 1 against its backdrop. Each control is read again unfocused, so an existing drop shadow cannot pass as a focus style. Where it stops: those eight screens in Chromium, so the report reading surface, the meeting sheet, chat and the welcome tour are not walked, and it now also runs per pull request, but only on the narrow filter of the token layer and the preference plumbing published in ADVISORY_CHECKS, so an ordinary apps/parent change is still covered post-merge only. Advisory either way: it cannot refuse a merge until the ASM-1193 promotion lands, 2026-08-27 at the earliest. apps/parent/e2e/keyboard-focus.spec.ts (advisory check, cannot block a merge: Parent a11y probes)
    • Automated check, run on demand: Asserts that following the app's skip link lands focus somewhere that draws a real outline, read from the rendered style. Covers that one target rather than the app generally. apps/parent/e2e/a11y-nav.spec.ts
  • 2.4.11 Focus Not Obscured (Minimum) Level AA Partially Supports Test cannot block

    When you move by keyboard, the item you are on should not be hidden behind a fixed header or footer. Checks found nothing hidden, but several app screens, including the report and chat, are not checked yet.

    How we know: 2.4.11 Focus Not Obscured (Minimum)

    New in WCAG 2.2. No axe rule checks it, so a behavioural probe (ASM-984) now does: for every stop in a route's tab order it resolves the element's own centre point through the browser (elementFromPoint) and requires it to land on the element itself, a descendant, an ancestor or its label, rather than under a fixed header, footer or overlay. The public website is clean across all 24 scanned routes, and so are the six app screens the probe walks. The one live gap it had found closed on 2026-08-25 (ASM-1090): on every real dimension question card the get-help chat panel used to slide off-screen with a CSS transform while keeping its text input and its Close and Clear buttons in the tab order, three real Tab stops that landed nowhere a sighted keyboard user could see. The closed panel is inert now, so those controls leave the tab order and the accessibility tree without the panel unmounting, and the carve-out that had disclosed them came out of the probe in the same change. What keeps this row short of full support is coverage rather than a known failure: the report's sticky tab strip, the mobile report bar, chat threads, the welcome tour and the org meeting, invite and assisted screens are still not walked.

    A test checks this, but it cannot stop a change going in. For the app, the same test runs again once the change is in, and a failure stops that change reaching the live app unless we override it and record why.

    Evidence

    • Automated check, advisory only: For every stop in every scanned route's tab order, resolves the element's own bounding-box centre point through elementFromPoint and requires it to land on the element itself, a descendant, an ancestor or its label. Clean across all 24 scanned routes. apps/marketing/e2e/keyboard-focus.spec.ts (advisory check, cannot block a merge: Marketing a11y (axe))
    • Automated check, advisory only: Same check over home, privacy, create-child, a profiler overview card, a profiler slider card and the SENCO org roster. It found the get-help chat panel's three closed-state controls obscured on the slider card, the route where the panel actually renders, and nothing else obscured across the six screens. That finding was fixed on 2026-08-25 (ASM-1090) by making the closed panel inert, and the named carve-out that had disclosed it (KNOWN_OFFSCREEN_PANEL_SELECTOR) came out of the file in the same change, so every obscured stop the walk finds now fails the test outright, with no exceptions. Where it stops: those six screens, so the report's sticky tab strip, the mobile report bar, chat threads, the welcome tour and the org meeting/invite/assisted screens are not walked, and it now also runs per pull request, but only on the narrow filter of the token layer and the preference plumbing published in ADVISORY_CHECKS, so an ordinary apps/parent change is still covered post-merge only. Advisory either way: it cannot refuse a merge until the ASM-1193 promotion lands, 2026-08-27 at the earliest. apps/parent/e2e/keyboard-focus.spec.ts (advisory check, cannot block a merge: Parent a11y probes)
    • Not verified: No axe rule exists for this criterion, verified against the pinned axe-core 4.12.1. The probe above covers 34 marketing routes and 6 app screens; the report's sticky tab strip, the mobile report bar, chat threads, the welcome tour and the org meeting/invite/assisted screens are not yet walked by it.
  • 2.5.1 Pointer Gestures Level A Supports Test protects it

    Every control works with a single tap or click. Nothing needs a pinch, a swipe or more than one finger, and an automatic check stops those being added.

    How we know: 2.5.1 Pointer Gestures

    Nothing in either product needs a pinch, a swipe along a path or more than one finger. Every control works with a single tap or click, and that is now a running check rather than a dated read of the source: packages/brand's test script absence-gates the multipoint/path-gesture symbols and the known gesture library names (ASM-985).

    A test protects this, and it has to pass before any change can go in.

    Evidence

    • Code review, 2026-08-09: No multipoint or path-based gesture handler exists anywhere in scope: no pointer-move or touch-move listener, no draggable element, no range input and no gesture library, across both apps and the shared component packages. The single wheel listener is a passive scroll shadow, not a control. Superseded by the automated-gate row below; kept as history.
    • Automated gate, required to merge: The absence gate fails on any use of a second simultaneous touch point (touches[1]) or coalesced-event path reconstruction (getCoalescedEvents), and on any known multipoint/path-gesture library (hammerjs, @use-gesture/react, interactjs and near neighbours) becoming a dependency of apps/marketing, apps/parent or the shared component packages. It rides packages/brand's test script (ASM-923 wiring), which turbo's required test job runs unconditionally, so a regression here cannot merge. scripts/a11y-absence-gate.mjs (required check: test)
  • 2.5.2 Pointer Cancellation Level A Supports Test cannot block

    Actions happen when you let go of a tap or click, so you can slide off a button to change your mind. A test checks this on two buttons.

    How we know: 2.5.2 Pointer Cancellation

    Everything happens on click, or on submit, never the instant you press down, so you can always slide off a control to change your mind. Held behaviourally now on the header CTA and the app's create-child submit: press down, drag off, release, and nothing fires (ASM-985).

    A test checks this, but it cannot stop a change going in.

    Evidence

    • Code review, 2026-08-09: No down-event handler performs an action anywhere in scope. Two press-down listeners exist and neither acts: one notes that a form has been touched, for spam detection, and the other is a comment recording that a redundant binding was deliberately dropped in favour of click alone. Kept as history; the rows below hold the claim behaviourally.
    • Automated check, advisory only: Mousedown on the header CTA, drag off, mouseup elsewhere: asserts the page never navigates. Runs on pull requests that touch the site, on the same path-filtered advisory workflow as SC 2.5.3 and 2.5.8. apps/marketing/e2e/pointer-cancellation.spec.ts (advisory check, cannot block a merge: Marketing a11y (axe))
    • Automated check, run on demand: Same probe against the app's create-child submit button: mousedown, drag off, mouseup, asserts no POST fires and the page never leaves /children/new. On-demand/post-merge tier, so it reports rather than gates. apps/parent/e2e/pointer-cancellation.spec.ts
  • 2.5.3 Label in Name Level A Partially Supports Test cannot block

    A control's spoken name should include the words you can see on it, so voice control works. The website is checked for this, but most app screens are not checked yet.

    How we know: 2.5.3 Label in Name

    Where a control has a visible label, its spoken name should contain that label, so speech control works. The website's scan now checks this by itself on any change that touches the site, though it reports rather than blocking. The tool marks this rule experimental and our normal filter skips experimental rules even when they carry a WCAG tag, so we turn it on by name rather than relying on the filter. The app's chat screens run the same rule after merge; the rest of the app's screens do not yet.

    A test checks this, but it cannot stop a change going in.

    Evidence

    • Automated check, advisory only: The rule is turned on by name over 34 public routes, which is the only way to run it: the tag filter drops it for being experimental. Zero violations. apps/marketing/e2e/a11y.spec.ts (advisory check, cannot block a merge: Marketing a11y (axe))
    • Automated check, run on demand: The same rule, turned on the same way, over the app's chat screens. Post-merge tier, so it reports rather than gates. apps/parent/e2e/chat.spec.ts
    • Automated check, run on demand: The full-catalogue sweep enables every rule. Zero violations across 284 page states on 2026-08-08. Reporting only, gates nothing. apps/marketing/e2e/a11y-full-audit.spec.ts
    • Not verified: The app's main screen sweep does not turn this rule on yet, so all 21 scanned app screens are unchecked for it. The control worth naming there is the answer slider: it takes its spoken name from an attribute, and its visible label is hidden at some screen sizes. Those are not two strings kept in step by hand. Both are the same caption, read from one list of captions through one shared lookup and handed to the name and to the label together, so they cannot say different things today. What is left is the risk of a later edit: that lookup is written out in three places, so changing one and not the others would split them, and where a stop carries no caption at all the spoken name falls back to the stop's position while the visible row shows nothing. Read by hand on 2026-08-25 (ASM-1692), and no rule watches it.
  • 2.5.4 Motion Actuation Level A Supports Test protects it

    Nothing responds to tilting, shaking or moving your device. An automatic check stops features like that being added.

    How we know: 2.5.4 Motion Actuation

    Nothing responds to tilting, shaking or moving your device, so there is nothing you need to be able to turn off. That rests on a running check now rather than a dated read of the source: packages/brand's test script absence-gates the device-motion and device-orientation API names (ASM-985).

    A test protects this, and it has to pass before any change can go in.

    Evidence

    • Code review, 2026-08-09: Zero uses of the device motion, device orientation, accelerometer and gyroscope APIs, across both apps and the shared component packages. Superseded by the automated-gate row below; kept as history.
    • Automated gate, required to merge: The absence gate fails on the device motion and device orientation event names, the DeviceMotionEvent/DeviceOrientationEvent constructors and the Generic Sensor API's Accelerometer/Gyroscope classes appearing anywhere in apps/marketing, apps/parent or the shared component packages. Rides packages/brand's test script (ASM-923 wiring), which turbo's required test job runs unconditionally, so a regression here cannot merge. scripts/a11y-absence-gate.mjs (required check: test)
  • 2.5.7 Dragging Movements Level AA Supports Test protects it

    Nothing needs dragging. Each answer is picked with one tap or one key press, and a check makes sure this stays true.

    How we know: 2.5.7 Dragging Movements

    Nothing has to be dragged. The answer control is where this would normally go wrong, and it is built on plain radio buttons: one click, or one key press. Both the source symbols and the behaviour are held now (ASM-985): the absence gate fails on drag/pointer-path symbols, and a browser probe presses one radio, drags across the row and confirms release on a different stop changes nothing. The slider drag affordance question is deliberately out of scope for this wave (ASM-991); if it is answered yes, this row's gate and probe are the ones that have to change first.

    A test protects this, and it has to pass before any change can go in.

    Evidence

    • Code review, 2026-08-09: No drag path exists in scope: no range input, no draggable attribute, no drag-and-drop library and no pointer-move or touch-move listener, in either app or the shared component packages. Superseded by the automated-gate row below; kept as history.
    • Automated gate, required to merge: The absence gate fails on a range input, draggable/ondragstart in either casing, a drag-and-drop library, or a pointermove/setPointerCapture listener (the API a hand-rolled drag handler needs to keep tracking the pointer once it leaves the element) anywhere in apps/marketing, apps/parent or the shared component packages. Rides packages/brand's test script (ASM-923 wiring), which turbo's required test job runs unconditionally, so a regression here cannot merge. scripts/a11y-absence-gate.mjs (required check: test)
    • Automated check, run on demand: Answers a dimension question, presses down on that stop, drags the pointer across the row and releases on a different one: asserts the original stop is still the only one checked and no save POST fired. On-demand/post-merge tier, so it reports rather than gates. Documents the ASM-991 flip condition inline so the probe itself is where a future drag-affordance change has to reckon with SC 2.5.7's actual requirement. apps/parent/e2e/pointer-dragging.spec.ts
  • 2.5.8 Target Size (Minimum) Level AA Supports Test cannot block

    Buttons and links are big enough to tap, or have enough space around them. Automatic checks found no problems on the pages they visit.

    How we know: 2.5.8 Target Size (Minimum)

    Every button and link is either at least 24 by 24 pixels, or small enough to need the standard's spacing exception and far enough from its neighbours to get it. This is the one new WCAG 2.2 criterion a machine can check, and both the website and the app run that check with zero violations. That check is real but narrow: it covers the routes the two suites visit, it judges size and spacing only, and the standard's other exceptions, for targets inside a sentence and for targets that are essential, still need a person. The app's smallest targets were measured by hand on 2026-08-09 rather than left to the tool.

    A test checks this, but it cannot stop a change going in.

    Evidence

    • Automated check, advisory only: The WCAG 2.2 AA tag carries the target-size rule. Zero violations over 34 public routes. apps/marketing/e2e/a11y.spec.ts (advisory check, cannot block a merge: Marketing a11y (axe))
    • Automated check, run on demand: Same rule over the app screens, at 1280 and at 320 pixels wide, on the post-merge and on-demand tier. The dark and forced-colours reruns that also fire on the narrow pull request filter published in ADVISORY_CHECKS run at desktop width only, so the 320 pixel pass is post-merge and on-demand only. The home scan asserts the multi-child pill row is actually present before it measures anything, so trimming the dev roster can no longer turn this into a green scan of a screen that lost the surface. apps/parent/e2e/a11y-nav.spec.ts
    • Code review, 2026-08-09: Hand measurement of the app's smallest targets. The smallest is the 'Change an answer' link on the profiler done screen at 118 by 21 CSS pixels: under 24, so it needs the spacing exception, and it has room to spare, because a 24 pixel circle centred on it reaches 1.5 pixels past its own edge and the nearest other control is 12 pixels away. The home screen's multi-child pill row, the case a tightly wrapping row would be expected to break, cannot fail: an explicit line height fixes each pill at 35 pixels tall and 12 pixels of padding on each side put a 26 pixel floor under its width, whatever the child is called. Checked at 5, 8, 12, 16, 22 and 67 children, at 1280, 640, 480, 390 and 320 pixels wide, with no failure at any combination.

Understandable

It behaves predictably and says what it means. 13 criteria.

  • 3.1.1 Language of Page Level A Supports Test protects it

    Each page tells your screen reader which language it is in, so words are spoken correctly. Assembly is in UK English only, and this is checked automatically.

    How we know: 3.1.1 Language of Page

    Every page declares its language, checked automatically on both products. Assembly is delivered in English (United Kingdom) only, so the language a page declares is always the language it is written in.

    A test protects this, and it has to pass before any change can go in.

    Evidence

    • Automated check, advisory only: axe html-has-lang over 34 public routes. apps/marketing/e2e/a11y.spec.ts (advisory check, cannot block a merge: Marketing a11y (axe))
    • Automated gate, required to merge: This row used to disclose a real defect: a page could declare Polish while its questionnaire, report and one heading rendered English. It is closed by removal rather than by translation. A second locale is now offered only on an allowlist of internal deployment tiers (development, pull-request previews, and a stakeholder showcase tier that is not currently provisioned), and never on staging, production or beta. In every tier outside that allowlist a locale-prefixed address redirects to the unprefixed route, so no page of the live service this statement covers declares a language it does not render. The test asserts that redirect directly, including that a stored preference for the withdrawn locale is ignored rather than acted on. It also covers the case a review of this change found open: a form submission is not a page request, cannot be redirected without discarding what was typed, and so is refused outright rather than answered in the withdrawn language. apps/parent/src/lib/locale-continuity.descope.test.ts (required check: test)
    • Automated gate, required to merge: Iterates the closed deployment-tier enum and asserts staging and production are English-only, so a tier added later fails this test until somebody decides which side of the line it belongs on rather than silently inheriting the permissive answer. apps/parent/src/i18n/multilingual.test.ts (required check: test)
  • 3.1.2 Language of Parts Level AA Supports Test protects it

    A screen reader needs to know when a passage switches language. Our interface is all in UK English, so it has none, though this does not cover what parents type in.

    How we know: 3.1.2 Language of Parts

    The Assembly interface on the live service is written in English (United Kingdom) throughout, so no passage of the interface is in a different language for a screen reader to mispronounce. This covers the interface we write. It is not a claim about what a parent types into a free-text field: their own words are stored and shown back to them as they wrote them, and we do not label the language of anything a person enters.

    A test protects this, and it has to pass before any change can go in.

    Evidence

    • Automated gate, required to merge: This row used to disclose the language switcher showing the word 'Polski' inside an English page without marking it as Polish. Both halves are now held. Where more than one language is offered, each option carries the language its own label is written in, so a screen reader pronounces it with the right engine. Where only one is offered, which is staging, production and beta, the control does not render at all. apps/parent/src/components/locale-switcher.test.tsx (required check: test)
    • Code review, 2026-08-20: Superseded evidence, kept for the trail: the earlier code review dated 8 August 2026 said the language attribute appeared exactly twice in the codebase and that the switcher button carried none. That stopped being true when the attribute was added to each option, and the assertion above now holds it.
  • 3.2.1 On Focus Level A Supports Test cannot block

    Moving onto something with the keyboard never changes the page by itself. Nothing opens, sends or moves you on just because you reached it.

    How we know: 3.2.1 On Focus

    Tabbing onto something never changes the page around you. Nothing opens, submits or navigates because focus arrived. A behavioural probe (ASM-984) now tabs through every scanned public marketing route and a representative set of app routes and confirms the URL never moves and no dialog appears from focus alone.

    A test checks this, but it cannot stop a change going in. For the app, the same test runs again once the change is in, and a failure stops that change reaching the live app unless we override it and record why.

    Evidence

    • Code review, 2026-08-09: One focus handler exists in scope and it selects the text of a read-only link field so it can be copied, which is not a change of context. Two fields set autofocus when their page loads, which places focus rather than changing anything once you are there.
    • Automated check, advisory only: Tabs through every scanned route and asserts the URL stays the same and no [role=dialog] appears from focus alone. Clean across all 24 scanned routes. apps/marketing/e2e/keyboard-focus.spec.ts (advisory check, cannot block a merge: Marketing a11y (axe))
    • Automated check, advisory only: Same walk over home, privacy, create-child, a profiler overview card, a profiler slider card and the SENCO org roster. Clean on all six. Where it stops: those six screens, and it now also runs per pull request, but only on the narrow filter of the token layer and the preference plumbing published in ADVISORY_CHECKS, so an ordinary apps/parent change is still covered post-merge only. Advisory either way: it cannot refuse a merge until the ASM-1193 promotion lands, 2026-08-27 at the earliest. apps/parent/e2e/keyboard-focus.spec.ts (advisory check, cannot block a merge: Parent a11y probes)
  • 3.2.2 On Input Level A Supports Test protects it

    In the questionnaire, choosing an answer moves you to the next question by itself. We tell you this on the first question, and a 'Keep moving on' switch lets you turn it off.

    How we know: 3.2.2 On Input

    In the profiler, choosing an answer still moves you to the next question by itself about a second later. Since 15 August 2026 we tell you so first, in the words "When you tap an answer, we move you on to the next question. With a keyboard, your answer waits until you press Enter. You can go back and change any answer later." Since 18 September 2026 it appears on the first question you are asked, immediately above the slider it describes, rather than on a welcome screen earlier in the journey. It used to sit on that welcome screen, which a parent who had been through the welcome tour never saw, so the notice reached almost nobody; that screen has now been retired. There is also a switch you can turn off to stop the moving on, labelled 'Keep moving on', at the top right of the screen; since 15 September 2026 it sits in the header bar rather than under the Continue button, and it is shown on a question you have come back to, which is where turning it off changes what happens next. Your keyboard position moves with the card, which is a separate criterion and is also fixed.

    A test protects this, and it has to pass before any change can go in.

    Evidence

    • Automated gate, required to merge: Asserts the notice on the FIRST question a parent meets, that it carries real copy rather than an empty element the layout happens to render, and that it is absent on a later question so it is told once rather than 75 times. What it cannot check is that the wording is clear enough to do its job, which is a human read. apps/parent/src/routes/profiler/dimension-card.component.test.tsx (required check: test)
    • Code review, 2026-08-27: The auto-advance itself is unchanged and deliberate: it keeps 75 questions moving, and it is already suppressed on edits and revisits. Nothing else in either product changes context on input; that search is recorded on the SC 3.2.1 row above. On 2026-08-27 (ASM-1871) the notice stopped repeating on the eight dimension intros, on the product judgement that eight repeats of a grey line above Continue cost more in clutter than they bought once the Auto switch (ANSWER-35) put the behaviour in front of the parent at the point of use. On 2026-09-18 (ASM-2096) the notice MOVED onto the first question, which widens the criterion rather than narrowing it: the welcome card it used to sit on was skipped for every parent who had done the welcome tour, so the written telling was reaching almost nobody, and it now sits on a screen every parent passes through, at the moment the behaviour first applies.
  • 3.2.3 Consistent Navigation Level AA Partially Supports Test cannot block

    Menus should stay in the same place and order on every page. They do on the website, but in the app the school staff area and the sign-in page have no menu.

    How we know: 3.2.3 Consistent Navigation

    On the website, one shared source feeds the desktop menu, the mobile menu and the footer, so the order never diverges. In the app the menu is fixed and consistent, but the school staff area uses a different shell with no menu, and the sign-in page has none.

    A test checks this, but it cannot stop a change going in.

    Evidence

    • Automated check, advisory only: Reads the primary nav, mobile nav and footer columns on every registered marketing route and asserts an identical ordered link list on all of them, plus that the primary/mobile nav really are the nav.ts source arrays rather than a drifted copy. Runs on the Marketing a11y (axe) workflow, which is plain `playwright test` with no project filter, so it picks this spec up whenever a pull request touches the site. It goes red on a regression and cannot refuse a merge, being no part of lint, test or type-check. apps/marketing/e2e/consistency.spec.ts (advisory check, cannot block a merge: Marketing a11y (axe))
    • Automated check, run on demand: Opens the shell nav sheet on every route it renders (home, privacy, help, create-child, a freshly created child's first profiler card, the neurodivergence step, an overview card, the welcome tour) and asserts an identical ordered item list, then asserts the SENCO surface renders no such menu at all on any of its four routes. Post-merge tier, so it reports rather than gates. apps/parent/e2e/consistency.spec.ts
    • Code review, 2026-08-08: The website has one navigation source with the current-page marker computed once and applied identically to both modes. The app menu is four entries in a fixed order and position.
  • 3.2.4 Consistent Identification Level AA Partially Supports No test

    Things that do the same job should have the same name everywhere. This is mostly fixed, but one help page still tells parents to look for a 'Get help' button that has gone.

    How we know: 3.2.4 Consistent Identification

    The conflict this row disclosed until 15 September 2026 is fixed. 'Get help' used to name two different things in the app, a menu entry that opens a help page and a floating button that opens a chat; the floating button is gone and a question now carries one help control, a disclosure named for what it does. What keeps this short of full support is thinner: only one repeated control, the language switcher, has a check that re-runs, and one sentence on the help page still tells a parent to look for a 'Get help' button on every question, which is a name no question card shows any more.

    No test protects this.

    Evidence

    • Automated check, run on demand: Asserts, in a tier that offers more than one language, that the language switcher's group name and per-option labels are identical across its two real call sites, the signed-in menu's Display panel and the signed-out /login page. That control does not render at all where only one language is offered, which is staging, production and beta. Post-merge tier, so it reports rather than gates. This file also carried a second probe pinning the 'Get help' conflict; it was RETIRED on 2026-09-15 (ASM-2039) because #1407 deleted the floating button it compared against, and a probe whose premise the product has dropped fails for the wrong reason rather than catching anything. The retirement and its reason are recorded where the probe was. apps/parent/e2e/consistency.spec.ts
    • Code review, 2026-09-15: Read after #1407 replaced the floating help button with an in-question disclosure (apps/parent/src/components/profiler/question-screen.tsx): the question card's one help control reads 'Not sure? Ask about this question' and reveals the area's guidance and the chat below the slider, so no control in the parent app now shows the nav entry's visible word while doing something else. Two things this read did NOT do, and the reason the row stays Partially Supports: nothing swept the app's other repeated controls for same-label-different-function, and /help's 'Stuck on a question?' copy (messages/en-GB.json, help_stuck_body_before/after) still names the deleted 'Get help' button and its old position, which is a copy decision left open rather than fixed here.
  • 3.2.6 Consistent Help Level A Partially Supports Test cannot block

    Help should be in the same place on every page. It mostly is, but the app's help link is missing from sign-in, the school staff screens and the error screen.

    How we know: 3.2.6 Consistent Help

    New in WCAG 2.2, and no tool can check it. On the website, Contact is always last in the mobile menu and the Support column is always third in the footer, though there is no help link in the desktop header at all. In the app the Get help entry is always second in the menu but is absent on sign-in, on the school staff screens and on the error screen.

    A test checks this, but it cannot stop a change going in.

    Evidence

    • Automated check, advisory only: Asserts the desktop primary nav stays free of any help/contact/support entry, Contact stays last in the mobile panel and Support stays the third of the footer's four columns, on every route checked. Runs on the Marketing a11y (axe) workflow, which is plain `playwright test` with no project filter, so it picks this spec up whenever a pull request touches the site. It goes red on a regression and cannot refuse a merge, being no part of lint, test or type-check. apps/marketing/e2e/consistency.spec.ts (advisory check, cannot block a merge: Marketing a11y (axe))
    • Automated check, run on demand: Asserts 'Get help' sits second in the nav's primary section wherever the nav renders, and that it is absent, with the per-question help control absent too, on the SENCO surface and on the root error boundary (reached via a hide-existence 404). The two states this probe cannot reach live (pre-auth /login, unauthenticated /privacy) are held instead by apps/parent/src/routes/privacy.test.tsx's own unauthenticated-frame assertion, named in a comment rather than faked here. Post-merge tier, so it reports rather than gates. apps/parent/e2e/consistency.spec.ts
    • Code review, 2026-08-08: Consistently thin on the website, which technically satisfies the criterion. In the app the entry is also removable by partner configuration.
  • 3.3.1 Error Identification Level A Supports Test protects it

    When you make a mistake in a form, the message says which field is wrong, and a screen reader reads the two together. Tests check this on the website and in the app.

    How we know: 3.3.1 Error Identification

    On the website this is met and checked: both of its forms mark the failing field as invalid, attach the message to that field so a screen reader reads the two together, and the longer form opens with a summary of what went wrong that takes your keyboard position with it. The app caught up on 16 August 2026 (ASM-990): every route form that renders an inline error now attaches it to the field it belongs to (aria-describedby) and marks that field aria-invalid. That was a wider fix than the two forms first disclosed here (sign-in and the parent's own add-a-child form): the same sweep also found the SENCO-assisted sign-in and the invite-a-parent form doing the same thing, and fixed both. The one place an error still renders unbound to a single field is the neurodivergence disclosure step, and it stays that way on purpose: a rejected condition code is a whole-submission parse failure, not one erroring field among several, so there is no single item for aria-describedby to name.

    A test protects this, and it has to pass before any change can go in.

    Evidence

    • Automated gate, required to merge: Holds the website's error text: every message must name the field it belongs to. A generic message fails this on the required check, which is where the old wording would have been caught. apps/marketing/tests/form-errors.test.ts (required check: test)
    • Automated check, advisory only: Submits both real forms in a browser and asserts the accessible description each field ends up with, plus aria-invalid and the focus move into the summary. Attribute-level assertions would pass on an id pointing nowhere, so it reads the accessibility tree instead. Advisory and path filtered. apps/marketing/e2e/form-errors.spec.ts (advisory check, cannot block a merge: Marketing a11y (axe))
    • Automated gate, required to merge: Renders sign-in with a mocked error result on each step (the magic-link email step and the code step) and asserts the field's accessible description resolves to the alert text, plus aria-invalid. Sign-in's forms are plain document POSTs by design, so this mounts the component with the actionData a submit would have produced rather than driving a click through jsdom's unimplemented form navigation. apps/parent/src/routes/login.render.test.tsx (required check: test)
    • Automated gate, required to merge: Submits the parent's add-a-child form with a blank name and asserts the field's accessible description resolves to the rendered alert text, plus aria-invalid. apps/parent/src/routes/children/new.component.test.tsx (required check: test)
    • Automated gate, required to merge: Same check across both steps of the SENCO-assisted sign-in screen (the parent-email step and the code step), found unbound during the ASM-990 sweep and fixed alongside sign-in and add-a-child. apps/parent/src/routes/org/assisted.render.test.tsx (required check: test)
    • Automated gate, required to merge: Adds the same binding check to the invite-a-parent screen, alongside its existing live-region announcement tests, found unbound during the ASM-990 sweep and fixed alongside sign-in and add-a-child. apps/parent/src/routes/org/invite.render.test.tsx (required check: test)
    • Code review, 2026-08-15: The website half was fixed on this date and NC-13 retired with it. The app was re-read rather than assumed: apps/parent/src/routes/org/child-new.tsx binds its error and marks the field invalid, and apps/parent/src/routes/login.tsx and children/new.tsx render an error block bound to nothing. Neither of the checks above visits the app, so nothing holds that half either way.
    • Code review, 2026-08-16: ASM-990: every `role="alert"` error block under apps/parent/src/routes was found and read (login.tsx x2, children/new.tsx, org/child-new.tsx already bound, org/assisted.tsx x2, org/invite.tsx). All now attach via aria-describedby and set aria-invalid, error copy unchanged. Two renders were read and left as-is because there is no single field to bind: children/pilot-consent.tsx and claim.tsx render a write-failure alert next to two decision BUTTONS, not a text field, and children/neurodivergence.tsx's alert is the whole-submission condition-disclosure failure described above. Scope was apps/parent/src/routes; components outside that directory (e.g. packages/parent-components) were not swept.
  • 3.3.2 Labels or Instructions Level A Supports Test protects it

    Every form field has a clear label. The chat boxes have a label that a screen reader reads out, and a test checks each one.

    How we know: 3.3.2 Labels or Instructions

    Labelling is strong throughout. The one exception, the chat box on the report page having placeholder text only and no real label, was fixed on 15 August 2026: all three of the app's chat boxes now carry a real label, hidden visually and read by screen readers, and a test resolves each of them by the name a screen reader would announce.

    A test protects this, and it has to pass before any change can go in.

    Evidence

    • Code review, 2026-08-08: Real labels tied to their inputs throughout both apps. Found one placeholder-only field, live on the report page, which is the exception now closed.
    • Automated gate, required to merge: Resolves the report page's chat box by its accessible name, which a placeholder alone cannot supply, including its unnamed-child fallback. The app's two other chat boxes are held the same way by apps/parent/src/components/chat/chat-conversation.test.tsx and apps/parent/src/components/profiler/help-chat-panel.test.tsx. packages/report-components/src/interactive/ProfileChatInput.test.tsx (required check: test)
  • 3.3.3 Error Suggestion Level AA Partially Supports Test protects it

    Error messages should say how to fix the mistake. Website messages do, but the app's messages still say something is wrong without saying what or how to fix it.

    How we know: 3.3.3 Error Suggestion

    The website's messages now name the field and say what to do with it, and an address that is the wrong shape gets an example of a right one. The app's are still the older kind, which report that something is wrong without saying which thing or what would fix it. That is a wording gap, separate from whether the message reaches the field at all (SC 3.3.1, fixed in the app on 16 August 2026): ASM-990 wired every app error up to the field it describes, but left every message's WORDS exactly as they were, so this row's gap survives that fix untouched.

    A test protects this, and it has to pass before any change can go in.

    Evidence

    • Automated gate, required to merge: Asserts the shape of the sentence, not just that a sentence exists: it must name the field as that page labels it, say what to do, and carry an example address where the address is what went wrong. This is the check the old 'Please fill in the fields marked above' would have failed. apps/marketing/tests/form-errors.test.ts (required check: test)
    • Code review, 2026-08-15: The website wording was rewritten on this date and moved to apps/marketing/src/lib/form-errors.ts so a test can hold it. The app was not touched and its messages were not re-read line by line, so this row stays short of support on the app's account alone.
    • Code review, 2026-08-16: ASM-990 fixed how the app's errors reach the field (SC 3.3.1), scoped deliberately to the binding and not the copy: every message cited on the SC 3.3.1 row above is the same Paraglide string it was before, unchanged. This row's gap, that those messages still don't name the field or suggest a fix in their own words, was read and confirmed to remain exactly as described above.
  • 3.3.4 Error Prevention (Legal, Financial, Data) Level AA Supports Test protects it

    There are no payments or legal agreements, and you can change your answers for a while after giving them. Going back in the add-a-child form now corrects that child, and a test keeps it so.

    How we know: 3.3.4 Error Prevention (Legal, Financial, Data)

    There are no payments and no legal commitments in the product, and answers stay editable for a period after you give them, so most of this criterion has nothing to bite on. The one place it did bite was the create-child form: going back from the neurodivergence step and submitting again created a second child record rather than editing the first. Since 15 August 2026 it edits the child you came back from, and a test that has to pass before any change can be merged holds it there. If you already have two records from before that, you can delete one yourself from that child's page, the reversal route the privacy page now describes; before 22 August 2026 that page could only tell you to ask us.

    A test protects this, and it has to pass before any change can go in.

    Evidence

    • Automated gate, required to merge: Drives the real create-child action against a real database. Asserts that a resubmit carrying a corrected value amends the child the form was seeded from, leaving one record and not two, and that an unchanged resubmit writes nothing and simply resumes. The domain function underneath is held by packages/domain/src/children/children.test.ts on the same check, and apps/parent/e2e/back-nav.spec.ts walks the whole path in a browser. Where it stops: this covers the one flow where a mistake could cost you data. Nothing automated watches the rest of the criterion, because the rest of the criterion has nothing in this product to watch. apps/parent/src/routes/children/new.test.ts (required check: test)
    • Code review, 2026-08-15: A thin surface by design: nothing in scope is a financial transaction, a legal commitment or a deletion of data you control. The editable grace window makes profiler answers reversible. Re-checked on the day the duplicate-child path was fixed, which is what moved this row off a disclosed live failure.
  • 3.3.7 Redundant Entry Level A Supports Test protects it

    You are not asked to type the same thing twice. If you go back in the add-a-child form, your answers are still there, and a test checks this.

    How we know: 3.3.7 Redundant Entry

    New in WCAG 2.2, and largely designed for: a parent invited by their school never re-enters what the school already gave us, and the neurodivergence step remembers earlier answers. Going back from that step used to give you an empty form and make you retype your child's name, age, gender and pronouns. Since 8 August 2026 it gives you back what you typed, and since 15 August 2026 changing something there corrects that child rather than starting a second one. A test that has to pass before any change can be merged holds both.

    A test protects this, and it has to pass before any change can go in.

    Evidence

    • Automated gate, required to merge: Asserts that arriving back at the create-child form from the neurodivergence step re-seeds every field from the child already created, and that resubmitting it, changed or unchanged, leaves one child record rather than two. apps/parent/e2e/back-nav.spec.ts walks the same path in a browser, back link and all. Where it stops: this holds the one multi-step form in the product where a value could have to be entered twice. No automated check sweeps the product for a new one, and no axe rule can, because none exists for this criterion. apps/parent/src/routes/children/new.test.ts (required check: test)
    • Code review, 2026-08-08: The claim path deliberately skips already-known details, and email is never asked twice: the magic link carries the session, so there is no second password or code to re-enter.
  • 3.3.8 Accessible Authentication (Minimum) Level AA Supports Test protects it

    Signing in needs no password, puzzle or memory test. You click a link we email you, or paste in an emailed code, and a test keeps it that way.

    How we know: 3.3.8 Accessible Authentication (Minimum)

    There is nothing to remember. You sign in by clicking a link we email you, or, if you would rather type something, with a short code we email instead: no password, no puzzle and no CAPTCHA anywhere in either product. The code goes in one ordinary field you can paste into, or let your device fill, so there is nothing to copy out by hand either. A test that has to pass before any change can be merged holds it.

    A test protects this, and it has to pass before any change can go in.

    Evidence

    • Automated gate, required to merge: Written for the stricter AAA claim (SC 3.3.9, agents-docs/design-system/WCAG-2.2-AAA-ADOPTION.md), which this AA criterion sits inside: asserts the resolved Better Auth config registers exactly magic-link and email-otp and nothing password/passkey/2FA/social-shaped, that emailOTP keeps disableSignUp so the code can never create an account, and that the rendered code field keeps autoComplete="one-time-code" with no maxLength, pattern or paste handler that would turn it into a transcription task. What it cannot see is CAPTCHA, which is a repo-wide absence claim held separately: scripts/a11y-absence-gate.mjs greps for turnstile/hcaptcha/recaptcha/password fields, chained into packages/brand's own "test" script (turbo.json), so it runs on the same required check. apps/parent/src/lib/auth.server.test.ts (required check: test)
    • Code review, 2026-08-15: The auth configuration registers two sign-in methods, both passwordless and both delivered to the same inbox: an emailed magic link (primary, always offered) and an emailed one-time code, which is sign-in only and cannot create an account. Email and password is off, no social provider is configured, and no two-factor, passkey or username plugin is present; the account table's password column is documented as null for us by design. The code field is a single text input with autocomplete set to one-time-code, no length or pattern restriction, no per-character boxes and nothing blocking paste, so entering it needs no transcription. A search of both apps for CAPTCHA, reCAPTCHA, Turnstile and hCaptcha returns one comment, explaining why there is none.

Robust

It works with assistive technology now and as that technology changes. 2 criteria.

  • 4.1.2 Name, Role, Value Level A Partially Supports Test cannot block

    Screen readers should be told what each control is, what it is called and what state it is in. Standard controls do this, but two gaps remain, including a progress bar with no name.

    How we know: 4.1.2 Name, Role, Value

    We use plain HTML controls wherever possible, so their name, role and state come for free, and automated checks cover the rest on both products. Two known gaps: a progress bar has no name, and on the answer control a position you set by clicking the bar rather than choosing one of the five options is announced as a percentage, 'Custom position at 63%', which tells you nothing about what you have said. A third, the report page chat box having no name, was fixed on 15 August 2026 and is now held by a test. On 16 August 2026 a real screen reader named the controls on five profiler screens for the first time, and every control it met came out right: the suggestion chips as toggle buttons with their state, the screener's checkboxes with their state and their place in the list, the language switcher as a group with its selected option, and the emergency links named by the numbers they dial. The one defect that walk found beside a control, a bare static-text repeat of each screener statement, was recorded under SC 1.3.1 as ASM-1153 rather than here, because the repeat is not a user interface component and the checkbox it shadowed announced its own name, role and state correctly. It was fixed on 25 August 2026 (commit 20f982798): the statement is now the checkbox's name and is announced once.

    A test checks this, but it cannot stop a change going in.

    Evidence

    • Automated check, advisory only: The axe name, role and value rule family over 34 public routes. apps/marketing/e2e/a11y.spec.ts (advisory check, cannot block a merge: Marketing a11y (axe))
    • Automated check, run on demand: Same family over the 21 scanned app screens, all five school staff screens among them. Post-merge and on-demand tier, plus the dark and forced-colours reruns on the narrow pull request filter published in ADVISORY_CHECKS. 27 test files assert controls by role. apps/parent/e2e/a11y-nav.spec.ts
    • Automated check, run on demand: Since 28 September 2026 the same axe rules also run every night on each app screen our journey map can reach with test data, one screen per journey step rather than one per route, in light and dark and at 320 pixels wide. A finding not written into a named list, with a reason and a ticket, turns that night's run red. It runs after changes are in, so it cannot stop a merge, and it does not stop a release either. It carries the name, role and value rules over those screen states. apps/parent/e2e-blueprint/blueprint-capture.spec.ts
    • Code review, 2026-08-09: The shipped answer control was read from the live route down. Its bones are right: a radio group of five real radio stops, each named with the option it stands for, which is the path a keyboard or screen reader user takes. Two things sit alongside it. The invisible full-width button over the bar, which lets a pointer set a position between the stops, is named 'Click to set custom position', an instruction rather than a purpose. When that button is used, the extra stop it creates is named by percentage rather than in the words of the question.
    • Code review, 2026-08-23: ASM-1639 revisited the finding above, and the first half of it no longer holds. The invisible button over the bar is not badly named any more; it is not named at all, because it is now out of the accessibility tree and out of the tab order. It was doing more harm than reading oddly. It was an extra tab stop inside the radio group, and its Enter and Space handler measured the track, took the midpoint and recorded the middle option, so a keyboard or screen reader user could answer a question about their child without choosing anything, and on a question they had not answered yet that counted as their first answer and moved them on to the next one. Pointer clicks on the bar work exactly as before, and keyboard users lose no reach, because the middle option is one of the five stops the arrows, Home, End and the number keys already select. THE SECOND HALF OF THAT FINDING STANDS UNCHANGED: when a pointer does set a position between the stops, the extra stop is still announced by percentage rather than in the words of the question. That fix is decided and not yet built, so nothing here closes it. What now stops the keyboard defect coming back is a test in the profiler component package, RadioSlider.bar-overlay.test.tsx, five of whose seven cases were run against the unfixed code first and failed there, so the fabricated answer is something we reproduced rather than reasoned about. It is not claimed as a gate on this row, because the two gaps this row still discloses have no check of their own and a gate badge here would read as covering them.
    • Recorded audit, 2026-08-16: The first check of this criterion by the thing it is written for. VoiceOver walked five of the parent profiler's 108 screens and the transcript was kept, agent-driven with nobody listening. What it named correctly: the suggestion chips on both capture cards as toggle buttons with their state, the screener's checkboxes with their state and their place in the list, the language switcher as a group with its selected option, the emergency links by their numbers, and the banner and main landmarks on every screen. No control it met failed this criterion. The defect the walk did find, ASM-1153, sits under SC 1.3.1 and not here: because the screener's checkbox and its statement text sit inside one label and the text also survives as its own node, every statement is followed by a static-text stop repeating the same words, while the checkbox itself named its role, its state and its position correctly. A stray text node is a structure problem, and this criterion is about user interface components. That defect was fixed after the walk, on 25 August 2026 (commit 20f982798), and no reader has been back since. Where it stops on this row in particular: it never reached the answer control or the progress bar, which are the two gaps this row already discloses, so neither is confirmed or cleared by it. agents-docs/design-system/audits/2026-08-16-voiceover-profiler-walk.md
    • Not verified: No scan visits sign-in, the invitation claim page or the help page. Nothing asserts the answer control's names either: the rule that would catch a meaningless one is a judgement, not a rule any tool has, and the walk of 16 August 2026 stopped before it reached that control.
  • 4.1.3 Status Messages Level AA Partially Supports Test protects it

    Messages such as a new chat reply should be read out without moving your place. An AI agent has recorded the NVDA screen reader reading out the chat's reply notice, but no person has listened, and VoiceOver and JAWS are not yet checked.

    How we know: 4.1.3 Status Messages

    Failures are announced, success messages on both website forms are announced politely, and the invitation screen re-announces correctly on a repeat. The chat used to say nothing at all when a reply arrived; since 15 August 2026 both chat surfaces mark the message list as a log that announces politely and the thinking indicator as a status region, and since 16 August 2026 a completed reply also announces its own arrival from a dedicated status region that does not depend on the log's `aria-busy` hold. What is not settled is whether either announcement is actually heard: the hold is followed properly by some screen readers and not others, and the arrival notice, while marked up correctly, has not yet been confirmed on real assistive technology either.

    A test protects this, and it has to pass before any change can go in.

    Evidence

    • Code review, 2026-08-08: Considered work, not accidental: live regions are pre-rendered and cleared so a repeat re-announces, and progress uses a native output element. Found the chat message list carrying no live region and the thinking indicator a plain paragraph, which is the gap now closed.
    • Automated gate, required to merge: Pins the log role, the polite setting, the hold that stops a streaming reply being re-announced on every token, and the rule that the thinking and error regions are never nested inside the log. The persistent chat is held identically by apps/parent/src/components/chat/chat-conversation.test.tsx. apps/parent/src/components/profiler/help-chat-panel.test.tsx (required check: test)
    • Automated gate, required to merge: Pins the arrival notice added for ASM-988: it renders into its own role="status" region, sibling to the log and never nested inside it, only once a reply has settled (never while submitted or streaming), and clears the moment the next send starts so a repeat reply re-announces. The persistent chat is held identically by apps/parent/src/components/chat/chat-conversation.test.tsx. apps/parent/src/components/profiler/help-chat-panel.test.tsx (required check: test)
    • Recorded audit, 2026-08-27: The first time a screen reader was pointed at either chat surface, and it did not settle this criterion. On the profiler's help panel the arrival notice rendered while VoiceOver was listening and the expected wording appears nowhere in what VoiceOver was recorded saying; on the per-child chat thread a selector fault landed the walk on the thread list, so that surface is still unlistened-to. The first result is not yet a finding because the harness cannot separate it from its own method: speech is captured only inside a VoiceOver command's own poll, so the walk keeps moving the cursor across the arrival, and a cursor move can itself drop a polite announcement. A stationary listening method compared against this one is what would settle it. agents-docs/design-system/audits/2026-08-27-voiceover-chat-walk.md
    • Automated gate, required to merge: Pins the fix from 28 September 2026: the arrival and thinking regions are in the page, empty, before anything is said into them, and the same element later holds the text. A region added already holding its text is not announced, which is why neither notice was spoken before. The help panel is held identically by apps/parent/src/components/profiler/help-chat-panel.test.tsx. apps/parent/src/components/chat/chat-conversation.test.tsx (required check: test)
    • Recorded audit, 2026-09-28: Agent-driven, on NVDA, read from NVDA's own speech log during a ten-second wait with no key presses, so the harness could not have caused or cut off what was said. Before the fix neither 'Thinking' nor 'Assembly has replied.' was spoken on either chat screen, because both status regions were added to the page already holding their text. After the fix NVDA spoke both, on both chat screens. No person listened. agents-docs/design-system/audits/2026-09-28-nvda-speech-log-chat-notice.md
    • Not verified: No person has listened to either announcement. VoiceOver and JAWS have not been checked since the fix, and VoiceOver has no speech log to read, so its check still depends on a listening method that does not move its place. Whether a reader speaks the finished reply itself once the log's hold lifts is also unconfirmed on every reader. The tests assert the markup, which is not the same as hearing it.