affora 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/CHECKS.md +40 -0
- package/LICENSE +21 -0
- package/README.md +116 -0
- package/agent.md +70 -0
- package/checks/cli.mjs +44 -0
- package/checks/substrate.mjs +269 -0
- package/cli/affora.mjs +131 -0
- package/design.md +113 -0
- package/package.json +71 -0
- package/registry.json +83 -0
- package/rules/antd.js +173 -0
- package/rules/generic.js +299 -0
- package/rules/hidden-radio.js +94 -0
- package/rules/magento.js +325 -0
- package/rules/mui.js +230 -0
- package/rules/shadcn-baseui.js +169 -0
- package/rules/shadcn-radix.js +289 -0
- package/src/components/combobox.tsx +279 -0
- package/src/components/datatable.tsx +454 -0
- package/src/components/dialog.tsx +548 -0
- package/src/components/productcard.tsx +551 -0
- package/src/components/settingsform.tsx +583 -0
- package/src/patterns/confirmundo.tsx +134 -0
- package/src/patterns/errremedy.tsx +155 -0
- package/src/patterns/flowform.tsx +179 -0
- package/src/patterns/gatedaction.tsx +157 -0
- package/src/patterns/persistentfeedback.tsx +106 -0
- package/src/primitives/actionbutton.tsx +64 -0
- package/src/primitives/textfield.tsx +57 -0
- package/src/tokens/themes.css +1068 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — 2026-09-24
|
|
4
|
+
|
|
5
|
+
Initial public release.
|
|
6
|
+
|
|
7
|
+
- Copy-in CLI with listing, dependency resolution, dry runs, collision safety,
|
|
8
|
+
custom destinations, and complete-registry installation.
|
|
9
|
+
- Two native primitives: Action button and Text field.
|
|
10
|
+
- Five compositions: Combobox, Dialog, Product card, Data table, and Settings
|
|
11
|
+
form.
|
|
12
|
+
- Five flow patterns: Gated action, Error remedy, Confirm and undo, Flow form,
|
|
13
|
+
and Persistent feedback.
|
|
14
|
+
- Sixteen token themes, executable substrate checks, and rewrite rules for
|
|
15
|
+
common component libraries.
|
|
16
|
+
- Human-facing `design.md` and coding-agent-facing `agent.md` contracts.
|
package/CHECKS.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# The eight component checks
|
|
2
|
+
|
|
3
|
+
Each check is a predicate over a rendered component. Each returns a verdict and
|
|
4
|
+
the evidence it read, and each has an input that must fail it.
|
|
5
|
+
|
|
6
|
+
| # | check | how it is decided |
|
|
7
|
+
|---|---|---|
|
|
8
|
+
| S1 | every interactive element is a real control or carries a role | every element that behaves as a control is an element with a role |
|
|
9
|
+
| S2 | every control has a non-empty accessible name | name computation over each control |
|
|
10
|
+
| S3 | every task-relevant fact is present as text | the task's facts occur in the rendered text |
|
|
11
|
+
| S4 | selection or expansion state is exposed semantically | state is on the element, not in a class |
|
|
12
|
+
| S5 | options are enumerable without interacting | the action space at rest holds at least the task's options, counting a native select, an ARIA listbox, and a radio or checkbox group |
|
|
13
|
+
| S6 | the operable target coincides with the visible one | no control is sized to zero or transparent behind a proxy, and a hit test at the visible centre lands inside |
|
|
14
|
+
| S7 | the committed value is readable back as text | after a commit, the value occurs in the rendered text |
|
|
15
|
+
| S8 | meaning never rests on colour or position alone | no element is distinguished by appearance alone |
|
|
16
|
+
|
|
17
|
+
Three verdicts, not two: a check that cannot be decided from what it was given
|
|
18
|
+
returns **undecided**. S3, S5 and S7 need the task's facts, and given none they
|
|
19
|
+
say so rather than passing.
|
|
20
|
+
|
|
21
|
+
## Running them
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
node checks/cli.mjs <url> [selector] [fact,fact,...]
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
For example, against the demo:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm run demo &
|
|
31
|
+
node checks/cli.mjs http://localhost:5280/ "section:nth-of-type(1)" "Small,Medium,Large"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## The three page checks
|
|
35
|
+
|
|
36
|
+
A rewrite of a page you did not author has to be checked in the other direction
|
|
37
|
+
as well: it must not destroy what was there. C1 asks whether the page still says
|
|
38
|
+
everything it said; C2 whether it still has every affordance; C3 whether using a
|
|
39
|
+
control still causes the same effect outside the page. Two defects in the rules
|
|
40
|
+
under `rules/` were found by C2 and C3 and one from an anomalous success rate.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 The Affora authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Affora
|
|
2
|
+
|
|
3
|
+
A component library for interfaces with two readers: a person, and an agent that
|
|
4
|
+
operates the page by reading it.
|
|
5
|
+
|
|
6
|
+
Affora is not a runtime. It is source you copy into your own repository, the way
|
|
7
|
+
`shadcn/ui` is, plus a token vocabulary you own and a compliance test you can run
|
|
8
|
+
in CI. One rule holds it together, at three scales:
|
|
9
|
+
|
|
10
|
+
> **What an interface declares is fixed and checked. What it paints is free.**
|
|
11
|
+
|
|
12
|
+
At component scale that reads *substrate invariant, skin variable*: a component
|
|
13
|
+
is authored once, and every visual decision is a token.
|
|
14
|
+
|
|
15
|
+
## What is here
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
src/primitives/ native actions and fields with names, constraints, and state
|
|
19
|
+
src/components/ five compositions, authored once, every visual value a token
|
|
20
|
+
src/patterns/ flow-pattern sources; publishable patterns are listed by the CLI
|
|
21
|
+
src/tokens/ 45 style tokens and 7 layout tokens, and 16 themes that set them
|
|
22
|
+
checks/ the eight component checks, as a command
|
|
23
|
+
rules/ rewrite rules that repair a page you did not author
|
|
24
|
+
demo/ every component under every theme, in a browser
|
|
25
|
+
design.md the human-facing substrate and skin contract
|
|
26
|
+
agent.md the operating contract a coding agent reads before editing
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Using a component
|
|
30
|
+
|
|
31
|
+
Use the copy-in CLI. A component has no Affora import and no runtime dependency
|
|
32
|
+
beyond React; its token dependency is copied with it:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npx affora add product-card
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
This writes `src/components/affora/product-card.tsx` and
|
|
39
|
+
`src/styles/affora/themes.css`. Run `npx affora list` to see the registry, use
|
|
40
|
+
`--path <directory>` to choose another root, and use `--dry-run` to inspect a
|
|
41
|
+
copy plan. Run `npx affora add --all` to install the complete registry. The CLI
|
|
42
|
+
refuses to overwrite files unless you pass `--force`.
|
|
43
|
+
|
|
44
|
+
Requires Node.js 18 or later. Copied React components support React 18 and later.
|
|
45
|
+
|
|
46
|
+
Then set a theme by attribute, and the component follows:
|
|
47
|
+
|
|
48
|
+
```html
|
|
49
|
+
<html data-flagship="swiss" data-layout="saas">
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
A theme may redefine all 45 style tokens and all 7 layout tokens. It may not
|
|
53
|
+
reach the component's markup, which is the point: the reader that enumerates
|
|
54
|
+
sees the same document under every theme.
|
|
55
|
+
|
|
56
|
+
## Running the checks
|
|
57
|
+
|
|
58
|
+
The eight component checks (S1–S8) decide, from the rendered document, whether a
|
|
59
|
+
component is legible to a reader that enumerates an action space:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
npm run check -- https://example.test '#checkout' 'Cart total,Place order'
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Arguments are the page URL, an optional component selector, and an optional
|
|
66
|
+
comma-separated list of task-relevant facts. The runner launches Chromium and
|
|
67
|
+
prints every verdict with the evidence it read.
|
|
68
|
+
|
|
69
|
+
Each check reports a verdict and the evidence it read. A check that cannot be
|
|
70
|
+
decided from what it was given returns *undecided* rather than *pass*. The CLI
|
|
71
|
+
exits non-zero when a check fails; callers that require full conformance should
|
|
72
|
+
also treat an *undecided* result as missing evidence.
|
|
73
|
+
|
|
74
|
+
**What passing means.** The document contains, for every component on the page, a
|
|
75
|
+
real control, an accessible name, state exposed as state, options enumerable
|
|
76
|
+
without interacting, and an operable target where it visibly sits. That is a
|
|
77
|
+
property of the artefact, decided without running a model. It is not a
|
|
78
|
+
prediction that an agent will succeed: failures also come from transient
|
|
79
|
+
feedback, portalled menus and flow state, which a component-scoped check does not
|
|
80
|
+
see. Treat it the way you treat a type checker.
|
|
81
|
+
|
|
82
|
+
## Repairing a page you did not author
|
|
83
|
+
|
|
84
|
+
`rules/` holds the rewrite rules used in the paper's evaluation. They detect
|
|
85
|
+
common library idioms — a combobox whose options mount only when open, an
|
|
86
|
+
icon-only control, a radio group hidden by CSS behind styled labels — and
|
|
87
|
+
re-express them so the options and names are in the document.
|
|
88
|
+
|
|
89
|
+
The rule files are browser-evaluated rule objects consumed by Affora's rewrite
|
|
90
|
+
harness; they are shipped as auditable source, not presented as a standalone
|
|
91
|
+
command. A consumer must run the generic rules first, then the matching library
|
|
92
|
+
adapter, and apply the page gates below around every rewrite.
|
|
93
|
+
|
|
94
|
+
A rewrite can damage a page it did not author. Before measuring anything, run the
|
|
95
|
+
three page gates: the page must still **say** everything (C1), still **have**
|
|
96
|
+
every affordance (C2), and using it must still **cause** the same effect (C3).
|
|
97
|
+
Two defects in these rules were found that way and one from an anomalous success
|
|
98
|
+
rate; all three are described in the paper.
|
|
99
|
+
|
|
100
|
+
## Licence
|
|
101
|
+
|
|
102
|
+
MIT.
|
|
103
|
+
|
|
104
|
+
## Reproducing the paper's figures from this library
|
|
105
|
+
|
|
106
|
+
The five components here are the ones the paper measures, byte for byte, and the
|
|
107
|
+
sixteen themes are the ones its theme axis varies. `npm run demo` renders every
|
|
108
|
+
component under every theme and layout, with the committed value shown beside the
|
|
109
|
+
controls, which is the probe every episode in the paper reads.
|
|
110
|
+
|
|
111
|
+
## What is not here
|
|
112
|
+
|
|
113
|
+
The measurement harness, the task sets and the episode traces are not part of the
|
|
114
|
+
library; they are research code and they are described in the paper. What ships
|
|
115
|
+
here is what a team would adopt: the components, the tokens, the patterns, the
|
|
116
|
+
checks and the rewrite rules.
|
package/agent.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Affora operating contract for coding agents
|
|
2
|
+
|
|
3
|
+
Read this file before changing an interface that uses Affora.
|
|
4
|
+
|
|
5
|
+
## Goal
|
|
6
|
+
|
|
7
|
+
Preserve one semantic document for two readers: a person who sees the rendered
|
|
8
|
+
page and an agent that enumerates its controls, names, relationships, and state.
|
|
9
|
+
Visual design is free to change; the task-relevant substrate is not.
|
|
10
|
+
|
|
11
|
+
## Required behaviour
|
|
12
|
+
|
|
13
|
+
- Use native HTML controls before simulated controls.
|
|
14
|
+
- Give every interactive element a stable accessible name. Prefer visible text.
|
|
15
|
+
- Keep task-relevant options and facts in the DOM without requiring interaction.
|
|
16
|
+
- Express selected, current, expanded, invalid, busy, and completed state with
|
|
17
|
+
native state, ARIA where appropriate, and persistent text when the fact matters
|
|
18
|
+
to the task.
|
|
19
|
+
- State constraints before submission. A disabled action must name its blocker
|
|
20
|
+
and the action or control that removes it.
|
|
21
|
+
- Keep action results and errors readable. An error states what failed, why, and
|
|
22
|
+
the named corrective action. Do not make a transient toast the only record.
|
|
23
|
+
- Make every step self-describing. Restate facts and named progress needed after
|
|
24
|
+
navigation or context loss.
|
|
25
|
+
- Keep visible and operable targets coincident. Do not place a tiny hidden input
|
|
26
|
+
behind a larger visual control unless the actual hit target covers that control.
|
|
27
|
+
- Mark destructive consequences in text. Prefer undo for reversible operations.
|
|
28
|
+
- Preserve semantic element order and relationships across themes and layouts.
|
|
29
|
+
|
|
30
|
+
## Vocabulary
|
|
31
|
+
|
|
32
|
+
- **primitive**: one native interaction
|
|
33
|
+
- **composition**: primitives expressing one object
|
|
34
|
+
- **flow pattern**: state and actions across time
|
|
35
|
+
- **substrate**: semantic document and task-relevant state
|
|
36
|
+
- **skin**: tokens, layout, typography, colour, motion, and effects
|
|
37
|
+
|
|
38
|
+
Do not introduce alternate layer names or unexplained check identifiers into
|
|
39
|
+
user-facing documentation.
|
|
40
|
+
|
|
41
|
+
## Adding a registry component
|
|
42
|
+
|
|
43
|
+
1. Add one self-contained React source file under `src/primitives/`,
|
|
44
|
+
`src/components/`, or `src/patterns/`.
|
|
45
|
+
2. Depend only on React and Affora tokens. Do not import experiment shims or use
|
|
46
|
+
the `@/` alias.
|
|
47
|
+
3. Export the component and its token-only CSS. Do not bake a theme into it.
|
|
48
|
+
4. Add the item to `registry.json`; declare `themes` in `requires`.
|
|
49
|
+
5. Add or update CLI tests when dependency or destination behaviour changes.
|
|
50
|
+
6. Run `npm test`, then install the output of `npm pack` into a temporary project
|
|
51
|
+
and execute the installed `affora` binary.
|
|
52
|
+
|
|
53
|
+
Never add a file to the registry while it imports control-condition shims,
|
|
54
|
+
experiment types, or a transient-feedback dependency. Registry items are
|
|
55
|
+
standalone reference implementations.
|
|
56
|
+
|
|
57
|
+
## Safe changes
|
|
58
|
+
|
|
59
|
+
Safe changes alter tokens, styling, or composition without changing controls,
|
|
60
|
+
names, option presence, state exposure, or effects. A refactor is not safe merely
|
|
61
|
+
because the pixels are unchanged.
|
|
62
|
+
|
|
63
|
+
For any substrate change, compare before and after and answer:
|
|
64
|
+
|
|
65
|
+
1. Does the page still say every relevant fact and state?
|
|
66
|
+
2. Does it still have every named affordance?
|
|
67
|
+
3. Does each affordance still cause the same effect?
|
|
68
|
+
|
|
69
|
+
Run the executable checks after answering those questions. Treat `undecided` as
|
|
70
|
+
missing evidence, not a pass.
|
package/checks/cli.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Run the eight component checks against a page.
|
|
3
|
+
//
|
|
4
|
+
// node checks/cli.mjs <url> [selector] [fact,fact,...]
|
|
5
|
+
//
|
|
6
|
+
// The selector picks the component to check; it defaults to the whole document
|
|
7
|
+
// body. Every verdict carries the evidence the check read, and a check that
|
|
8
|
+
// cannot be decided from what it was given says so rather than passing.
|
|
9
|
+
import { RUN } from "./substrate.mjs"
|
|
10
|
+
|
|
11
|
+
// Two checks — every task-relevant fact present as text, and options
|
|
12
|
+
// enumerable — need to know which facts the task depends on. Given none they
|
|
13
|
+
// return undecided rather than pass, which is the point of the third verdict.
|
|
14
|
+
const [url, selector = "body", factsArg = ""] = process.argv.slice(2)
|
|
15
|
+
const facts = factsArg ? factsArg.split(",").map((f) => f.trim()).filter(Boolean) : []
|
|
16
|
+
if (!url) {
|
|
17
|
+
console.error("usage: node checks/cli.mjs <url> [selector] [fact,fact,...]")
|
|
18
|
+
process.exit(2)
|
|
19
|
+
}
|
|
20
|
+
const { chromium } = await import("playwright")
|
|
21
|
+
const browser = await chromium.launch()
|
|
22
|
+
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } })
|
|
23
|
+
await page.goto(url, { waitUntil: "networkidle" })
|
|
24
|
+
await page.waitForTimeout(1200)
|
|
25
|
+
|
|
26
|
+
// RUN addresses its subject through a data attribute, so the page under test is
|
|
27
|
+
// marked once rather than the predicate being rewritten per host.
|
|
28
|
+
await page.evaluate((sel) => {
|
|
29
|
+
const el = document.querySelector(sel) || document.body
|
|
30
|
+
el.setAttribute("data-component", "subject")
|
|
31
|
+
}, selector)
|
|
32
|
+
|
|
33
|
+
const results = await page.evaluate(RUN, { comp: "subject", facts, expected: null, arm: "subject" })
|
|
34
|
+
await browser.close()
|
|
35
|
+
|
|
36
|
+
let failed = 0
|
|
37
|
+
for (const r of results) {
|
|
38
|
+
const verdict = r.pass === true ? "pass" : r.pass === false ? "FAIL" : "undecided"
|
|
39
|
+
if (r.pass === false) failed += 1
|
|
40
|
+
console.log(`${r.id} ${verdict.padEnd(9)} ${r.label}`)
|
|
41
|
+
if (r.evidence) console.log(` ${String(r.evidence).replace(/\s+/g, " ").slice(0, 96)}`)
|
|
42
|
+
}
|
|
43
|
+
console.log(`\n${results.filter((r) => r.pass === true).length}/${results.length} pass, ${failed} fail`)
|
|
44
|
+
process.exit(failed ? 1 : 0)
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// The substrate checklist, S1-S8, as executable predicates.
|
|
2
|
+
//
|
|
3
|
+
// Extracted from the figure script so the checker is an artifact rather than
|
|
4
|
+
// figure-generation code. The paper claims Affora's component checks are
|
|
5
|
+
// executed rather than judged — that expert disagreement, which Brajnik et al.
|
|
6
|
+
// measure for WCAG conformance, is not a variable for this layer because no
|
|
7
|
+
// human adjudicates it. A claim like that has to be a module somebody can
|
|
8
|
+
// import and run, not a paragraph.
|
|
9
|
+
//
|
|
10
|
+
// RUN is passed to page.evaluate(), so it must be self-contained: no imports,
|
|
11
|
+
// no closure over module scope. It takes the component id, the task's facts and
|
|
12
|
+
// its expected value, and returns one record per check carrying a verdict AND
|
|
13
|
+
// the evidence the check read. A check that reports only a verdict cannot be
|
|
14
|
+
// argued with; one that reports what it saw can.
|
|
15
|
+
//
|
|
16
|
+
// Scope, stated because the paper states it: these are the COMPONENT-level
|
|
17
|
+
// checks. The flow checklist (L1-L8) needs a task path rather than a screen and
|
|
18
|
+
// the site checklist (K1-K3) needs several routes; neither is here.
|
|
19
|
+
|
|
20
|
+
// Each check is a predicate over the rendered component plus the task's own
|
|
21
|
+
// facts, and each returns the evidence it read.
|
|
22
|
+
export const RUN = ({ comp, facts, expected, arm }) => {
|
|
23
|
+
const cell = document.querySelector(`[data-component="${comp}"]`)
|
|
24
|
+
const SEL = "a,button,input,select,textarea,summary,[role]"
|
|
25
|
+
const vis = (e) => {
|
|
26
|
+
const r = e.getBoundingClientRect(), cs = getComputedStyle(e)
|
|
27
|
+
return r.width > 0 && r.height > 0 && cs.visibility !== "hidden" && cs.display !== "none"
|
|
28
|
+
}
|
|
29
|
+
const raw = [...cell.querySelectorAll(SEL)]
|
|
30
|
+
const els = raw.filter(vis)
|
|
31
|
+
|
|
32
|
+
// S6 has to look at the elements the visibility filter throws away, because
|
|
33
|
+
// the defect it names IS invisibility: a real control sized to zero or made
|
|
34
|
+
// transparent behind a styled proxy. Filtering first made the check blind to
|
|
35
|
+
// its only subject — the falsification case returned "no operable target"
|
|
36
|
+
// instead of failing.
|
|
37
|
+
const hidden = raw.filter((e) => {
|
|
38
|
+
if (vis(e)) return false
|
|
39
|
+
if (e.disabled || e.getAttribute("aria-hidden") === "true") return false
|
|
40
|
+
const r = e.getBoundingClientRect(), cs = getComputedStyle(e)
|
|
41
|
+
return cs.display !== "none" && (r.width < 2 || r.height < 2 || Number(cs.opacity) === 0)
|
|
42
|
+
})
|
|
43
|
+
const NATIVE = ["A", "BUTTON", "INPUT", "SELECT", "TEXTAREA", "SUMMARY", "OPTION"]
|
|
44
|
+
const nameOf = (e) => (e.getAttribute("aria-label") || e.textContent || e.getAttribute("placeholder") || e.value || "").trim().replace(/\s+/g, " ")
|
|
45
|
+
// A component with no real control at all is the strongest S1 failure there
|
|
46
|
+
// is, and the checker used to throw on it: els[0] was undefined and every
|
|
47
|
+
// check dereferenced it. Four components in our own corpus hit this, so the
|
|
48
|
+
// instrument was silently unable to score its worst cases.
|
|
49
|
+
if (els.length === 0 && hidden.length) {
|
|
50
|
+
// real controls exist and none of them is visible: that is S6's defect in
|
|
51
|
+
// its purest form, not an absence of controls
|
|
52
|
+
const R1 = (id, label, pass, evidence, wcag) => ({ id, label, pass, evidence, wcag })
|
|
53
|
+
return [
|
|
54
|
+
R1("S1", "every interactive element is a real control or carries a role", true,
|
|
55
|
+
`${hidden.length} real control(s) present`, "4.1.2"),
|
|
56
|
+
R1("S2", "every control has a non-empty accessible name", null, "no visible control to name", "4.1.2"),
|
|
57
|
+
R1("S3", "every task-relevant fact is present as text", null, "no visible control to scope to", null),
|
|
58
|
+
R1("S4", "selection or expansion state is exposed semantically", null, "no visible control", "4.1.2"),
|
|
59
|
+
R1("S5", "options are enumerable without interacting", null, "no visible control", null),
|
|
60
|
+
R1("S6", "the operable target coincides with the visible one", false,
|
|
61
|
+
`${hidden.length} operable control(s) sized to zero or transparent`, null),
|
|
62
|
+
R1("S7", "the committed value is readable back as text", null, "no visible control", null),
|
|
63
|
+
R1("S8", "meaning never rests on colour or position alone", null, "no visible control", "1.4.1"),
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
if (els.length === 0) {
|
|
67
|
+
const R0 = (id, label, pass, evidence, wcag) => ({ id, label, pass, evidence, wcag })
|
|
68
|
+
return [
|
|
69
|
+
R0("S1", "every interactive element is a real control or carries a role", false,
|
|
70
|
+
"no real control and no role in the component", "4.1.2"),
|
|
71
|
+
R0("S2", "every control has a non-empty accessible name", null, "no control to name", "4.1.2"),
|
|
72
|
+
R0("S3", "every task-relevant fact is present as text", null, "no control to scope the text to", null),
|
|
73
|
+
R0("S4", "selection or expansion state is exposed semantically", null, "no control to carry state", "4.1.2"),
|
|
74
|
+
R0("S5", "options are enumerable without interacting", null, "no control to enumerate from", null),
|
|
75
|
+
R0("S6", "the operable target coincides with the visible one", null, "no operable target", null),
|
|
76
|
+
R0("S7", "the committed value is readable back as text", null, "no control to commit", null),
|
|
77
|
+
R0("S8", "meaning never rests on colour or position alone", null, "no control to judge", "1.4.1"),
|
|
78
|
+
]
|
|
79
|
+
}
|
|
80
|
+
const ctl = els[0]
|
|
81
|
+
// Everything a reader can choose from without opening anything: a native
|
|
82
|
+
// select's options, an ARIA listbox's options, and a native radio or checkbox
|
|
83
|
+
// group, which is the form this system prescribes and which this check did
|
|
84
|
+
// not count — it failed the library's own compliant product card, whose sizes
|
|
85
|
+
// are three labelled radios.
|
|
86
|
+
const groupLabel = (e) => {
|
|
87
|
+
const byFor = e.id && cell.querySelector(`label[for="${CSS.escape(e.id)}"]`)
|
|
88
|
+
const wrap = e.closest("label")
|
|
89
|
+
return ((byFor && byFor.textContent) || (wrap && wrap.textContent) ||
|
|
90
|
+
e.getAttribute("aria-label") || e.value || "").replace(/\s+/g, " ").trim()
|
|
91
|
+
}
|
|
92
|
+
const options = [
|
|
93
|
+
...[...cell.querySelectorAll("select")].flatMap((s) => [...s.options].map((o) => (o.label || "").trim())),
|
|
94
|
+
...[...cell.querySelectorAll("[role=option]")].map((o) => o.textContent.trim()),
|
|
95
|
+
...[...cell.querySelectorAll('input[type="radio"], input[type="checkbox"]')].map(groupLabel),
|
|
96
|
+
].filter((o) => o && o !== "Select a country")
|
|
97
|
+
// The component's own text, and all of it.
|
|
98
|
+
//
|
|
99
|
+
// This was scoped to the control's field wrapper, to keep the gallery's arm
|
|
100
|
+
// label and harness probe out of the figure's evidence strings. That is the
|
|
101
|
+
// wrong scope for S7, which asks whether a committed value is readable back
|
|
102
|
+
// as DOCUMENT text: a value echoed in a sibling paragraph is readable, and
|
|
103
|
+
// the field-scoped version called it missing. Falsifying S7 is what surfaced
|
|
104
|
+
// it — the case built to make the check PASS failed instead.
|
|
105
|
+
//
|
|
106
|
+
// So the scope is the whole component minus the harness's own instrumentation:
|
|
107
|
+
// the `output[data-expected]` probe the runner reads, and the arm-label header
|
|
108
|
+
// the gallery draws. Both are ours, neither is the component.
|
|
109
|
+
// Walked in place rather than cloned: innerText needs layout, and a detached
|
|
110
|
+
// clone silently falls back to textContent, which runs block elements together
|
|
111
|
+
// ("CountrySelect a country") and made the figure's evidence look mangled.
|
|
112
|
+
const isChrome = (el) =>
|
|
113
|
+
el.matches?.("output[data-expected]") ||
|
|
114
|
+
(arm && (el.textContent || "").trim().toUpperCase().startsWith(arm.toUpperCase()))
|
|
115
|
+
const text = [...cell.children]
|
|
116
|
+
.filter((el) => !isChrome(el))
|
|
117
|
+
.map((el) => el.innerText || "")
|
|
118
|
+
.join(" ")
|
|
119
|
+
.replace(/\s+/g, " ")
|
|
120
|
+
.trim()
|
|
121
|
+
|
|
122
|
+
const box = ctl.getBoundingClientRect()
|
|
123
|
+
// the element the browser would actually deliver a click on the control to
|
|
124
|
+
const hit = document.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2)
|
|
125
|
+
// control kinds that HAVE a state a reader could need: everything else has
|
|
126
|
+
// nothing for S4 to ask about
|
|
127
|
+
const STATEFUL_ROLES = ["checkbox", "radio", "switch", "tab", "option", "slider",
|
|
128
|
+
"combobox", "menuitemcheckbox", "menuitemradio", "treeitem", "spinbutton"]
|
|
129
|
+
const stateful = els.filter((e) =>
|
|
130
|
+
e.tagName === "SELECT" || e.tagName === "SUMMARY" ||
|
|
131
|
+
(e.tagName === "INPUT" && ["checkbox", "radio", "range"].includes(e.type)) ||
|
|
132
|
+
STATEFUL_ROLES.includes(e.getAttribute("role") || "") ||
|
|
133
|
+
e.hasAttribute("aria-expanded") || e.hasAttribute("aria-checked") ||
|
|
134
|
+
e.hasAttribute("aria-selected") || e.hasAttribute("aria-pressed"))
|
|
135
|
+
const hits = ctl.contains(hit) || hit === ctl || ctl.contains(hit?.parentElement)
|
|
136
|
+
const stateAttr = ["aria-expanded", "aria-checked", "aria-selected", "aria-valuenow", "aria-pressed"]
|
|
137
|
+
.find((a) => els.some((e) => e.hasAttribute(a)))
|
|
138
|
+
const stateEl = els.find((e) => stateAttr && e.hasAttribute(stateAttr))
|
|
139
|
+
|
|
140
|
+
// pass is true, false, or null for "this check cannot be decided from what it
|
|
141
|
+
// was given". Null is NOT a pass. Two checks need to know which facts the task
|
|
142
|
+
// involves, and called with an empty fact list they were vacuously true —
|
|
143
|
+
// `facts.every(...)` on nothing, `options.length >= 0` — so the standalone
|
|
144
|
+
// checker reported a clean bill of health for a component it had not examined.
|
|
145
|
+
// A gate that cannot fail is not a gate; this is the shape that failure takes
|
|
146
|
+
// in a checklist rather than in a benchmark.
|
|
147
|
+
// the value a control is currently holding, if any — what S7 looks for
|
|
148
|
+
const heldBy = (e) => {
|
|
149
|
+
if (e.tagName === "SELECT") { const o = e.selectedOptions[0]; return o && o.value ? (o.label || o.textContent || "").trim() : null }
|
|
150
|
+
if (e.tagName === "INPUT" && ["checkbox", "radio"].includes(e.type)) return e.checked ? (e.value || "on") : null
|
|
151
|
+
if (e.tagName === "INPUT" || e.tagName === "TEXTAREA") return e.value ? String(e.value).trim() : null
|
|
152
|
+
const sel = e.getAttribute("aria-checked") === "true" || e.getAttribute("aria-selected") === "true"
|
|
153
|
+
if (sel) return (e.textContent || "").trim() || null
|
|
154
|
+
const now = e.getAttribute("aria-valuenow")
|
|
155
|
+
return now != null ? String(now) : null
|
|
156
|
+
}
|
|
157
|
+
const held = els.map(heldBy).find((v) => v) ?? null
|
|
158
|
+
|
|
159
|
+
// elements distinguished by appearance with nothing a reader could read
|
|
160
|
+
const colourOnly = [...cell.querySelectorAll("span,div,i,b")].filter((e) => {
|
|
161
|
+
const r = e.getBoundingClientRect()
|
|
162
|
+
if (r.width <= 0 || r.height <= 0 || r.width > 64 || r.height > 64) return false
|
|
163
|
+
if (e.children.length) return false
|
|
164
|
+
if ((e.textContent || "").trim()) return false
|
|
165
|
+
if (e.getAttribute("aria-label") || e.getAttribute("title") || e.getAttribute("role")) return false
|
|
166
|
+
if (e.getAttribute("aria-hidden") === "true") return false
|
|
167
|
+
const cs = getComputedStyle(e)
|
|
168
|
+
const painted = (cs.backgroundColor && cs.backgroundColor !== "rgba(0, 0, 0, 0)") ||
|
|
169
|
+
(cs.borderTopWidth !== "0px" && cs.borderTopStyle !== "none")
|
|
170
|
+
return painted
|
|
171
|
+
}).map((e) => `${e.tagName.toLowerCase()}.${(e.className || "").toString().split(" ")[0] || "-"}`)
|
|
172
|
+
|
|
173
|
+
const R = (id, label, pass, evidence, wcag) => ({ id, label, pass, evidence, wcag })
|
|
174
|
+
const needsFacts = facts.length === 0
|
|
175
|
+
return [
|
|
176
|
+
R("S1", "every interactive element is a real control or carries a role",
|
|
177
|
+
els.every((e) => NATIVE.includes(e.tagName) || e.getAttribute("role")),
|
|
178
|
+
`${ctl.tagName.toLowerCase()}${ctl.getAttribute("role") ? `[role=${ctl.getAttribute("role")}]` : ""}`,
|
|
179
|
+
"4.1.2"),
|
|
180
|
+
R("S2", "every control has a non-empty accessible name",
|
|
181
|
+
els.every((e) => nameOf(e)), `"${nameOf(ctl)}"`, "4.1.2"),
|
|
182
|
+
R("S3", "every task-relevant fact is present as text",
|
|
183
|
+
needsFacts ? null : facts.every((f) => text.includes(f)),
|
|
184
|
+
`rendered text: "${text.length > 46 ? text.slice(0, 46) + "\u2026" : text}"`,
|
|
185
|
+
// Not 1.3.1. That criterion governs how PRESENTED content is marked up so
|
|
186
|
+
// its structure is programmatically determinable; a collapsed control
|
|
187
|
+
// presents no options, so it does not engage the criterion at all. Mapping
|
|
188
|
+
// this row to 1.3.1 would have overstated what conformance asks for.
|
|
189
|
+
null),
|
|
190
|
+
// S4 asks whether state is exposed, and asked it of components that have no
|
|
191
|
+
// state to expose: run over 120 corpus cells it failed 55 of 60 native ones,
|
|
192
|
+
// because a plain button carries no aria-expanded and is not a select. A
|
|
193
|
+
// check that fires on components it does not apply to is a false-positive
|
|
194
|
+
// machine, and its correlation with success was partly noise. It is scoped
|
|
195
|
+
// to components that contain a stateful control kind; where none does, the
|
|
196
|
+
// check is undecidable rather than failing.
|
|
197
|
+
R("S4", "selection or expansion state is exposed semantically",
|
|
198
|
+
stateful.length === 0 ? null
|
|
199
|
+
: (Boolean(stateAttr) || stateful.some((e) =>
|
|
200
|
+
e.tagName === "SELECT" || e.tagName === "SUMMARY" ||
|
|
201
|
+
(e.tagName === "INPUT" && ["checkbox", "radio", "range"].includes(e.type)))),
|
|
202
|
+
stateful.length === 0 ? "no stateful control in this component"
|
|
203
|
+
: (!stateAttr && stateful.some((e) => ["SELECT", "SUMMARY", "INPUT"].includes(e.tagName)))
|
|
204
|
+
? `${stateful[0].tagName.toLowerCase()} carries its state natively`
|
|
205
|
+
: stateAttr ? `${stateEl.tagName.toLowerCase()} ${stateAttr}="${stateEl.getAttribute(stateAttr)}"`
|
|
206
|
+
: `${ctl.tagName.toLowerCase()} carries its own value`,
|
|
207
|
+
"4.1.2"),
|
|
208
|
+
R("S5", "options are enumerable without interacting",
|
|
209
|
+
needsFacts ? null : options.length >= facts.length,
|
|
210
|
+
needsFacts ? "needs the task's facts to decide"
|
|
211
|
+
: options.length ? `action space: ${options.slice(0, 3).join(", ")}${options.length > 3 ? `, +${options.length - 3}` : ""}`
|
|
212
|
+
: "action space: the trigger only",
|
|
213
|
+
null),
|
|
214
|
+
R("S6", "the operable target coincides with the visible one",
|
|
215
|
+
hidden.length === 0 && hits,
|
|
216
|
+
// The evidence has to say WHICH condition decided it. This printed
|
|
217
|
+
// "hit test lands inside" on cells it had just marked failing, because the
|
|
218
|
+
// string was chosen by hidden.length alone — so a hit-test miss was
|
|
219
|
+
// reported with the words for a pass, and reading the corpus output nearly
|
|
220
|
+
// turned two of those into a claim about zero-sized controls.
|
|
221
|
+
hidden.length
|
|
222
|
+
? `${hidden.length} operable control(s) sized to zero or transparent`
|
|
223
|
+
: hits
|
|
224
|
+
? `${Math.round(box.width)}×${Math.round(box.height)} px, hit test lands inside`
|
|
225
|
+
: `${Math.round(box.width)}×${Math.round(box.height)} px, but the point at its centre belongs to <${(hit?.tagName || "nothing").toLowerCase()}>`,
|
|
226
|
+
null),
|
|
227
|
+
// S7 and S8 were both hardcoded `true` — literally `R("S7", ..., true, ...)`.
|
|
228
|
+
// They were written while building the figure, where both arms pass, so two
|
|
229
|
+
// constants sat in the middle of a checklist looking like checks. Run over
|
|
230
|
+
// 120 corpus cells neither ever fired, which is how they were caught. A gate
|
|
231
|
+
// that cannot fail is not a gate, and this project keeps relearning it.
|
|
232
|
+
//
|
|
233
|
+
// S7 now asks the question it names: does a value a control is holding
|
|
234
|
+
// appear in the component's rendered text? Undecidable when no control holds
|
|
235
|
+
// one yet, because a static snapshot of an untouched component has no
|
|
236
|
+
// committed value to look for.
|
|
237
|
+
// The failure this names is a value that lives in a control's `value`
|
|
238
|
+
// property and nowhere in the document — the case that moved a memoryless
|
|
239
|
+
// arm from 0% to 33% once the extractor was taught to read it. So the
|
|
240
|
+
// question is whether the committed value is in the rendered TEXT. It needs
|
|
241
|
+
// a value to have been committed: given one (the caller drives the
|
|
242
|
+
// component first) it is decidable, and a static snapshot of an untouched
|
|
243
|
+
// component honestly cannot decide it.
|
|
244
|
+
R("S7", "the committed value is readable back as text",
|
|
245
|
+
expected ? text.includes(expected) : (held === null ? null : text.includes(held)),
|
|
246
|
+
expected
|
|
247
|
+
? `committed "${expected}"; rendered text ${text.includes(expected) ? "contains" : "does NOT contain"} it`
|
|
248
|
+
: held === null
|
|
249
|
+
? "no value has been committed in this state"
|
|
250
|
+
: `control holds "${held}"; rendered text ${text.includes(held) ? "contains" : "does NOT contain"} it`,
|
|
251
|
+
null),
|
|
252
|
+
// S8's mechanical proxy: an element that is visually distinguished and
|
|
253
|
+
// carries no text, no accessible name and no ARIA state is conveying
|
|
254
|
+
// whatever it conveys by appearance alone. This is a proxy and not the
|
|
255
|
+
// principle — the principle needs judgement, which COMPLIANCE.md now says
|
|
256
|
+
// rather than claiming every line is mechanically decidable.
|
|
257
|
+
R("S8", "meaning never rests on colour or position alone",
|
|
258
|
+
colourOnly.length === 0,
|
|
259
|
+
colourOnly.length ? `${colourOnly.length} element(s) distinguished only by appearance: ${colourOnly.slice(0, 2).join(", ")}`
|
|
260
|
+
: "no element is distinguished by appearance alone",
|
|
261
|
+
"1.4.1"),
|
|
262
|
+
]
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Runnable on its own, so the checklist is something a team can point at a
|
|
266
|
+
// component rather than something they read about:
|
|
267
|
+
//
|
|
268
|
+
// The library ships this predicate; checks/cli.mjs is the executable entry
|
|
269
|
+
// point that runs it against a page and prints each verdict with its evidence.
|