/*{ "parent": "utilities", "description": "EXPERIMENTAL accessibility audit over the agent surface's own map — anonymous affordances, unnameable actions, missing roles, WCAG contrast, target size." }*/
/*#
# audit (EXPERIMENTAL)
`auditAccessibility(agent.describe({ styles: true }))` turns the affordance
map into findings. It is the lint the map made obvious: **every element with
handlers should have a name, a role, and enough contrast and size to use** —
and because the map is the framework's own record of its wiring, a finding
here is a real defect, not a scraper's guess.
import { enableAgentInterface, auditAccessibility } from 'tosijs'
const agent = enableAgentInterface()
const report = auditAccessibility(agent.describe({ styles: true }))
report.failed // 0 when clean
report.findings // [{ rule, severity, message, record, index }]
Pure over the description — no DOM, so it runs on a map that arrived over a
wire, in a test, or in CI. Pass `styles: true` to `describe()` or the
contrast rule has nothing to measure and skips itself (it says so).
## The rules
| rule | what it catches | why |
| --- | --- | --- |
| `anonymous-affordance` | a wired element with no accessible name, text, or value | a screen reader announces "button"; an agent sees `
` |
| `unnameable-action` | a handler that is an anonymous function (`ƒ`) | nothing can invoke or describe it but a click |
| `missing-role` | a non-semantic tag (div/span) wired to act | assistive tech has no idea it is a control |
| `contrast` | text/background below the WCAG AA ratio | measured with tosijs's own `Color`; needs `styles: true` |
| `target-size` | interactive element below 24×24 (WCAG 2.5.8) | too small to hit reliably; toggles exempt |
| `label-hidden-by-placeholder` | a control whose only name is its placeholder | the name vanishes the moment the user types |
Findings carry the record and its index, so a caller can jump straight to
the element — or hand the pair to `schematicSVG`'s `flags` to *draw* them.
> **The divergence is closed, and as of 1.10.2 there is no carve-out either**
> (tosijs-floorplan #4, then #7/#8/#9 and #13). `target-size` and "is this
> interactive" were once implemented *twice* — here and in the vendored
> renderer — and had drifted into contradicting each other on real elements.
> 0.4.0 shared the implementation; **0.5.0 removed the need for this module to
> adjust it.**
>
> Three adjustments used to live here, as `auditView`: strip producer `flags`
> (a lint never drew them), exempt `0×0` (hidden is not small), and treat a
> list-bound element carrying its own evidence as a control. All three are now
> in the rule itself, so `auditView` is **deleted** and this module contains no
> definition of what evidence *is* — not a copy, not an adjustment, nothing.
> The audit suite passes unchanged across the deletion, which is the only
> evidence that the retirement is real rather than nominal.
>
> What this module still owns is what it uniquely knows: the accessible name,
> the contrast math, and the wording of each finding.
> **EXPERIMENTAL.** Ships with the agent surface; rules and shapes may change.
*/
import { AgentDescription, AgentWiringRecord } from './agent'
import { Color } from './color'
// ONE implementation of "can I act here" and "is this big enough", shared with
// the renderer that draws the same map (tosijs-floorplan#4). Importing them is
// the whole point — a local copy is what drifted last time.
import {
isInteractive,
targetSizeFinding,
TARGET_SIZE_DEFAULT,
} from './schematic'
export type AuditSeverity = 'error' | 'warn' | 'info'
export interface AuditFinding {
/** stable rule id — see the table above */
rule: string
severity: AuditSeverity
/** one line, phrased as what to fix */
message: string
/** index into description.wiring — the same key the schematic stamps */
index: number
record: AgentWiringRecord
}
export interface AuditReport {
findings: AuditFinding[]
/** count of severity: 'error' findings */
failed: number
/** rules that could not run, and why (an audit must not fail silently) */
skipped: string[]
}
export interface AuditOptions {
/** minimum interactive size, px (default 24 — WCAG 2.5.8 AA; 44/48 is the
* platform touch bar). 0 disables the rule. */
targetSize?: number
/** WCAG contrast floor (default 4.5 — AA for body text) */
contrastRatio?: number
/** rule ids to skip */
exclude?: string[]
}
const accessibleName = (w: AgentWiringRecord): string =>
String(w.label ?? w.text ?? '').trim()
const SEMANTIC_TAGS = new Set([
'button',
'a',
'input',
'select',
'textarea',
'summary',
'label',
'option',
])
// relative luminance per WCAG 2.x, via tosijs's own Color parser
const luminance = (css: string): number | null => {
try {
const { r, g, b } = Color.fromCss(css)
const channel = (v: number): number => {
const c = v / 255
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
}
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
} catch (_e) {
return null
}
}
export const contrastRatio = (
foreground: string,
background: string
): number | null => {
const a = luminance(foreground)
const b = luminance(background)
if (a == null || b == null) return null
const [light, dark] = a > b ? [a, b] : [b, a]
return (light + 0.05) / (dark + 0.05)
}
/**
* Audit an agent-surface description for accessibility defects. Pure over
* plain data — no DOM — so it runs anywhere the map travels.
*/
export const auditAccessibility = (
description: AgentDescription,
options: AuditOptions = {}
): AuditReport => {
const {
targetSize = TARGET_SIZE_DEFAULT,
contrastRatio: floor = 4.5,
exclude = [],
} = options
const findings: AuditFinding[] = []
const skipped: string[] = []
const enabled = (rule: string): boolean => !exclude.includes(rule)
// AN EMPTY MAP IS NOT A CLEAN BILL OF HEALTH. Since 1.9.0 the default
// posture exposes nothing, so `describe()` over a bare surface returns no
// wiring at all — and auditing it examined ZERO elements while reporting no
// findings, which reads exactly like "your page is accessible". Same
// philosophy as the contrast skip below: say what was not checked.
if (description.wiring.length === 0) {
skipped.push(
description.exposure === 'closed'
? 'everything: the agent surface exposes nothing, so there was no ' +
'wiring to audit — enable it with expose: { roots } or ' +
"expose: 'all'"
: 'everything: the map contains no wired elements, so nothing was ' +
'examined'
)
}
// the contrast rule needs computed colors; say so rather than passing
// silently, which would read as "no contrast problems"
const anyStyles = description.wiring.some((w) => w.style != null)
if (enabled('contrast') && !anyStyles) {
skipped.push(
'contrast: no computed styles in the map — call describe({ styles: true })'
)
}
description.wiring.forEach((w, index) => {
const add = (
rule: string,
severity: AuditSeverity,
message: string
): void => {
if (enabled(rule))
findings.push({ rule, severity, message, index, record: w })
}
// the two shared rules see the audit's view; every MESSAGE and the
// `record` on each finding keep the original, so a caller still gets back
// exactly what it handed in
const interactive = isInteractive(w)
const name = accessibleName(w)
/*
* A REDACTED RECORD IS NOT A DEFECTIVE ELEMENT.
*
* `secret: true` means the producer WITHHELD facts — so an empty `text`
* says "you may not see this", not "there is nothing here". Three rules
* read exactly the fields secrecy removes, and each turned a redaction
* into a confident accusation: `a({href}, 'Forgot password?')` at 120×18,
* correct and inline-exempt, reported BOTH `anonymous-affordance` (an
* `error`, so it inflates `report.failed`, which is what a CI gate
* asserts on) and `target-size` — while the identical link outside the
* region reported neither. The remedy it printed was "add visible text",
* to an element that has visible text.
*
* So they are SKIPPED and SAID, which is this module's existing answer to
* "I could not measure that" (see the contrast and empty-map skips). The
* honest cost, stated because it is real: a genuinely unnamed control
* inside a secret region is no longer reported. That is a false negative
* traded for a false positive, and it is the right way round — a lint
* nobody trusts gets turned off, and `aria-label` deliberately survives
* secrecy precisely so an author can keep these rules working.
*/
/*
* ASK THE DECISION, NOT THE FLAG — again, and this time it was this
* module making the mistake.
*
* The first cut of this gate read `w.secret === true`. That is the
* REDACTION ORDER, not the question "was this record's text withheld",
* and the two differ in both directions:
*
* - a record suppressed by CONTAINMENT has its text stripped and NO
* `secret` flag (deliberately — nothing about it is secret), so the
* gate missed it and `anonymous-affordance` fired, as an `error`,
* on a button whose visible text reads "Sign in";
* - `bounds` are NEVER redacted, so abstaining from `target-size` on
* every secret record hid genuinely undersized secret controls.
*
* `textWithheld` is the decision, set by `suppressHarvest` for every
* reason it suppresses. And the gate is now applied PER RULE, because
* each depends on a different withheld field:
*/
const nameWithheld = w.textWithheld === true || w.secret === true
if (nameWithheld && interactive) {
const note =
'anonymous-affordance: skipped for records whose text was withheld ' +
'(secret controls, and elements containing one) — "unnamed" cannot ' +
'be distinguished from "name not shown". An aria-label survives ' +
'redaction and restores this rule. `target-size` still runs except ' +
'where the withheld text is what would have exempted it (a wider- ' +
'than-tall link), because geometry is never redacted.'
if (!skipped.includes(note)) skipped.push(note)
}
// ONLY where the missing text is what would have exempted this element:
// WCAG 2.5.8's inline exception is `` + text + wider-than-tall. A
// secret 16×16 button is measurably undersized and must still report.
const sizeExemptionUnknowable =
nameWithheld &&
w.tag === 'a' &&
w.bounds != null &&
w.bounds.width > w.bounds.height
if (!nameWithheld && interactive && name === '' && w.value === undefined) {
add(
'anonymous-affordance',
'error',
`<${w.tag}> is interactive but has no accessible name — a screen ` +
`reader announces it as "${w.role ?? w.tag}" and nothing else. Add ` +
`aria-label, a