ultra11y 5.0.0 → 5.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.opencode/plugins/ultra11y.js +1 -1
- package/README.md +2 -2
- package/dist/cypress-plugin.d.ts +1 -1
- package/dist/cypress.d.ts +2 -2
- package/dist/cypress.mjs +19 -0
- package/dist/payload-B6NDkLYJ.d.ts +51 -0
- package/dist/playwright.d.ts +1 -1
- package/dist/playwright.mjs +19 -0
- package/package.json +1 -1
- package/scripts/ultra11y.mjs +18 -4
- package/skills/review-a11y/SKILL.md +1 -1
- package/skills/review-a11y/scripts/ultra11y.mjs +18 -4
- package/skills/ultra11y/SKILL.md +1 -1
- package/skills/ultra11y/references/ci.md +8 -8
- package/skills/ultra11y/references/e2e.md +21 -0
- package/skills/ultra11y/scripts/ultra11y.mjs +18 -4
- package/src/integrations/cypress.ts +9 -1
- package/src/integrations/payload.ts +24 -0
- package/src/integrations/playwright.ts +11 -1
- package/src/orchestrate-templates.ts +18 -3
- package/src/types.ts +1 -1
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"name": "ultra11y",
|
|
11
11
|
"source": "./",
|
|
12
12
|
"description": "Automatic accessibility review of the code under change before every commit, push and pull request, plus full WCAG 2.2 AA / RGAA audits and conformance deliverables on demand.",
|
|
13
|
-
"version": "5.
|
|
13
|
+
"version": "5.1.0",
|
|
14
14
|
"author": { "name": "maxgfr" },
|
|
15
15
|
"homepage": "https://github.com/maxgfr/ultra11y",
|
|
16
16
|
"repository": "https://github.com/maxgfr/ultra11y",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ultra11y",
|
|
3
3
|
"description": "Accessibility for WCAG 2.2 AA and country standards (RGAA…): an automatic review of the code under change before every commit, push and pull request, plus full audits and conformance deliverables on demand.",
|
|
4
|
-
"version": "5.
|
|
4
|
+
"version": "5.1.0",
|
|
5
5
|
"author": { "name": "maxgfr" },
|
|
6
6
|
"homepage": "https://github.com/maxgfr/ultra11y",
|
|
7
7
|
"repository": "https://github.com/maxgfr/ultra11y",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ultra11y",
|
|
3
3
|
"description": "Accessibility for WCAG 2.2 AA and country standards (RGAA…): an automatic review of the code under change before every commit, push and pull request, plus full audits and conformance deliverables on demand.",
|
|
4
|
-
"version": "5.
|
|
4
|
+
"version": "5.1.0",
|
|
5
5
|
"author": { "name": "maxgfr", "url": "https://github.com/maxgfr" },
|
|
6
6
|
"homepage": "https://github.com/maxgfr/ultra11y",
|
|
7
7
|
"repository": "https://github.com/maxgfr/ultra11y",
|
|
@@ -27,7 +27,7 @@ import { fileURLToPath } from "node:url";
|
|
|
27
27
|
// Ownership marker: `ultra11y install --opencode` refuses to overwrite a plugin file that
|
|
28
28
|
// does not carry it, so a user's own ultra11y.js is never clobbered.
|
|
29
29
|
const ULTRA11Y_OPENCODE_PLUGIN = "ULTRA11Y_OPENCODE_PLUGIN";
|
|
30
|
-
const ULTRA11Y_PLUGIN_VERSION = "5.
|
|
30
|
+
const ULTRA11Y_PLUGIN_VERSION = "5.1.0";
|
|
31
31
|
|
|
32
32
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
33
33
|
|
package/README.md
CHANGED
|
@@ -125,7 +125,7 @@ claude mcp add ultra11y -- npx -y ultra11y mcp
|
|
|
125
125
|
**As a GitHub Action** — audits the PR diff and, optionally, the served pages:
|
|
126
126
|
|
|
127
127
|
```yaml
|
|
128
|
-
- uses: maxgfr/ultra11y@
|
|
128
|
+
- uses: maxgfr/ultra11y@v5
|
|
129
129
|
with: { since: auto, standard: rgaa, fail-on: blocking }
|
|
130
130
|
```
|
|
131
131
|
|
|
@@ -383,7 +383,7 @@ a test). See [`CONTRIBUTING.md`](CONTRIBUTING.md) and `skills/ultra11y/reference
|
|
|
383
383
|
block. It re-decides nothing and invents no format, so a page sheet and the compliance report
|
|
384
384
|
cannot disagree; `check` gates it against invented criteria like any other report.
|
|
385
385
|
See `references/pages.md`.
|
|
386
|
-
- **A shipped GitHub Action** — `uses: maxgfr/ultra11y@
|
|
386
|
+
- **A shipped GitHub Action** — `uses: maxgfr/ultra11y@v5` audits the **code** (PR diff) and,
|
|
387
387
|
optionally, the **pages** (it can start your app, wait for it, then scan real URLs or your
|
|
388
388
|
declared sample). The engine ships inside the action, so there is nothing to install and no
|
|
389
389
|
`setup-node`. The gate runs **last**, after SARIF, annotations, the summary, the sticky PR
|
package/dist/cypress-plugin.d.ts
CHANGED
package/dist/cypress.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { C as CheckOptions } from './payload-
|
|
2
|
-
export { s as slugify } from './payload-
|
|
1
|
+
import { C as CheckOptions } from './payload-B6NDkLYJ.js';
|
|
2
|
+
export { s as slugify } from './payload-B6NDkLYJ.js';
|
|
3
3
|
|
|
4
4
|
interface CypressCheckOptions extends CheckOptions {
|
|
5
5
|
/** Capture a viewport screenshot so the pixel tier can run. On by default — the plugin
|
package/dist/cypress.mjs
CHANGED
|
@@ -120,6 +120,20 @@ function slugify(url) {
|
|
|
120
120
|
const slug = path.normalize("NFD").replace(new RegExp("\\p{Diacritic}", "gu"), "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
121
121
|
return slug || (path === "/" || path === "" ? "accueil" : "page");
|
|
122
122
|
}
|
|
123
|
+
function stayedOnPage(expected, actual) {
|
|
124
|
+
if (!expected || !actual) return true;
|
|
125
|
+
const path = (u) => {
|
|
126
|
+
try {
|
|
127
|
+
return new URL(u, "http://x.invalid").pathname.replace(/\/+$/, "");
|
|
128
|
+
} catch {
|
|
129
|
+
return void 0;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
const a = path(expected);
|
|
133
|
+
const b = path(actual);
|
|
134
|
+
if (a === void 0 || b === void 0) return true;
|
|
135
|
+
return a === b;
|
|
136
|
+
}
|
|
123
137
|
function buildPayload(collected, url, runner, opts, screenshot) {
|
|
124
138
|
const id = opts.as || slugify(url);
|
|
125
139
|
return {
|
|
@@ -151,6 +165,11 @@ function registerUltra11yCommand() {
|
|
|
151
165
|
return cy.window({ log: false }).then((win) => {
|
|
152
166
|
const collected = win.eval(COLLECT_SNAPSHOT);
|
|
153
167
|
const url = collected.url || win.location.href;
|
|
168
|
+
if (opts.expectPath && !stayedOnPage(opts.expectPath, url)) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`ultra11y: ${opts.expectPath} landed on ${url} \u2014 not recording it as "${opts.as ?? opts.name ?? "this page"}". The state that opens this route is not the one the test built; seed it first, or drop the page from the sample.`
|
|
171
|
+
);
|
|
172
|
+
}
|
|
154
173
|
const payload = {
|
|
155
174
|
...buildPayload(collected, url, "cypress", opts),
|
|
156
175
|
failOn: opts.failOn,
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
interface FindingLike {
|
|
2
|
+
ruleId: string;
|
|
3
|
+
criteriaId: string;
|
|
4
|
+
file: string;
|
|
5
|
+
line: number;
|
|
6
|
+
severity: string;
|
|
7
|
+
message: string;
|
|
8
|
+
advisory?: boolean;
|
|
9
|
+
origin?: {
|
|
10
|
+
sourceFile?: string;
|
|
11
|
+
sourceLine?: number;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
interface AuditLike {
|
|
15
|
+
findings?: FindingLike[];
|
|
16
|
+
}
|
|
17
|
+
interface SnapshotPayload {
|
|
18
|
+
meta: Record<string, unknown>;
|
|
19
|
+
dom: string;
|
|
20
|
+
styles?: unknown;
|
|
21
|
+
boxes?: unknown;
|
|
22
|
+
css?: unknown;
|
|
23
|
+
screenshot?: string;
|
|
24
|
+
}
|
|
25
|
+
/** Same slug rule as the engine (src/snapshot.ts slugifyPageId): accent-folded URL path. */
|
|
26
|
+
declare function slugify(url: string): string;
|
|
27
|
+
interface CheckOptions {
|
|
28
|
+
/** The page id (directory name). Defaults to the slugified URL path. */
|
|
29
|
+
as?: string;
|
|
30
|
+
/** Human page name for the report. Defaults to the document title. */
|
|
31
|
+
name?: string;
|
|
32
|
+
/** Severity at which the check fails, or `false` to record without ever failing. */
|
|
33
|
+
failOn?: "blocking" | "major" | "minor" | false;
|
|
34
|
+
/** Mark the page as sitting behind authentication. */
|
|
35
|
+
auth?: boolean;
|
|
36
|
+
/** Source files that rendered it — findings are then reported against YOUR code. */
|
|
37
|
+
sources?: string[];
|
|
38
|
+
/** Reproduction notes carried into the auditor ticket. */
|
|
39
|
+
notes?: string;
|
|
40
|
+
/** Capture a viewport screenshot for the pixel tier (Playwright: on by default). */
|
|
41
|
+
screenshot?: boolean;
|
|
42
|
+
/** The path you NAVIGATED to. Given it, the fixture refuses to record a page the browser
|
|
43
|
+
* did not stay on — a guarded route that redirected, a session that expired — instead of
|
|
44
|
+
* filing that other screen under this page's `as`/`name`. Nothing about such a snapshot
|
|
45
|
+
* looks wrong, which is what makes it the worst thing an accessibility report can carry.
|
|
46
|
+
* Compared by path (a query or fragment the app appends to its own route is the same
|
|
47
|
+
* page); a full URL works too. `scan --sample` applies the same rule. */
|
|
48
|
+
expectPath?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export { type AuditLike as A, type CheckOptions as C, type FindingLike as F, type SnapshotPayload as S, slugify as s };
|
package/dist/playwright.d.ts
CHANGED
package/dist/playwright.mjs
CHANGED
|
@@ -143,6 +143,20 @@ function slugify(url) {
|
|
|
143
143
|
const slug = path.normalize("NFD").replace(new RegExp("\\p{Diacritic}", "gu"), "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
144
144
|
return slug || (path === "/" || path === "" ? "accueil" : "page");
|
|
145
145
|
}
|
|
146
|
+
function stayedOnPage(expected, actual) {
|
|
147
|
+
if (!expected || !actual) return true;
|
|
148
|
+
const path = (u) => {
|
|
149
|
+
try {
|
|
150
|
+
return new URL(u, "http://x.invalid").pathname.replace(/\/+$/, "");
|
|
151
|
+
} catch {
|
|
152
|
+
return void 0;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
const a = path(expected);
|
|
156
|
+
const b = path(actual);
|
|
157
|
+
if (a === void 0 || b === void 0) return true;
|
|
158
|
+
return a === b;
|
|
159
|
+
}
|
|
146
160
|
function buildPayload(collected, url, runner, opts, screenshot) {
|
|
147
161
|
const id = opts.as || slugify(url);
|
|
148
162
|
return {
|
|
@@ -221,6 +235,11 @@ async function checkA11y(page, opts = {}) {
|
|
|
221
235
|
}
|
|
222
236
|
}
|
|
223
237
|
const url = collected.url || page.url();
|
|
238
|
+
if (opts.expectPath && !stayedOnPage(opts.expectPath, url)) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`ultra11y: ${opts.expectPath} landed on ${url} \u2014 not recording it as "${opts.as ?? opts.name ?? "this page"}". The state that opens this route is not the one the test built; seed it first, or drop the page from the sample.`
|
|
241
|
+
);
|
|
242
|
+
}
|
|
224
243
|
const payload = buildPayload(collected, url, "playwright", opts, shot);
|
|
225
244
|
const result = auditSnapshot(payload);
|
|
226
245
|
if (opts.report) writePagesReport(typeof opts.report === "object" ? opts.report : {});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ultra11y",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.1.0",
|
|
4
4
|
"description": "Audit HTML/CSS/JSX against WCAG 2.2 AA and produce a dated compliance report, or author/review accessible markup (native-HTML-first). A skills.sh agent skill: an install-free static engine (bundled, no npm install) plus the agent's judgment, with check/verify gates against hallucinated non-conformities, and pluggable in-repo standards packs (RGAA France first; Section 508 & EN 301 549 next).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@10.33.0",
|
package/scripts/ultra11y.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { join as join50, relative as relative5, resolve as resolve15, sep as sep
|
|
|
6
6
|
import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
|
|
7
7
|
|
|
8
8
|
// src/types.ts
|
|
9
|
-
var VERSION = "5.
|
|
9
|
+
var VERSION = "5.1.0";
|
|
10
10
|
var SCHEMA_VERSION = 2;
|
|
11
11
|
|
|
12
12
|
// src/audit.ts
|
|
@@ -61923,6 +61923,20 @@ var ADJUDICATE_SCHEMA = {
|
|
|
61923
61923
|
criteriaId: { type: "string" },
|
|
61924
61924
|
verdict: { enum: ["C", "NC", "NA", "manual"] },
|
|
61925
61925
|
justification: { type: "string", description: "REQUIRED for C and NA" },
|
|
61926
|
+
// REQUIRED by the fold alongside `justification`, for exactly the same reason
|
|
61927
|
+
// `normativeRef` is required on an NC: a cleared criterion has to name what it
|
|
61928
|
+
// cleared. Omitting it here (and from the adjudicator contract) made every
|
|
61929
|
+
// adjudication produce C/NA verdicts that `verify --apply` then refused wholesale —
|
|
61930
|
+
// 63 rejections out of 97 verdicts on the run that surfaced this.
|
|
61931
|
+
citations: {
|
|
61932
|
+
type: "array",
|
|
61933
|
+
description: "REQUIRED for C, and for NA when evidence was presented: the evidence items you cleared, `file`/`line` copied verbatim from this criterion's own evidence[]",
|
|
61934
|
+
items: {
|
|
61935
|
+
type: "object",
|
|
61936
|
+
required: ["file", "line"],
|
|
61937
|
+
properties: { file: { type: "string" }, line: { type: "number" }, selector: { type: "string" }, snippet: { type: "string" } }
|
|
61938
|
+
}
|
|
61939
|
+
},
|
|
61926
61940
|
reason: { type: ["string", "null"], enum: ["needs-rendered-dom", "undecidable", null], description: "REQUIRED for a still-manual verdict" },
|
|
61927
61941
|
findings: {
|
|
61928
61942
|
type: "array",
|
|
@@ -62048,13 +62062,13 @@ For EACH of your criteria:
|
|
|
62048
62062
|
|
|
62049
62063
|
1. Read its worklist entry. \`evidence[]\` holds source-anchored excerpts (\`file\`, \`line\`, \`selector\`, \`snippet\`) harvested from the audited code \u2014 open the cited files at the cited lines whenever the snippet alone cannot decide.
|
|
62050
62064
|
2. Rule it (the apply gate is FAIL-CLOSED \u2014 a verdict missing its required field does not fold):
|
|
62051
|
-
- \`C\` (conforming) \u2014 REQUIRES \`justification\` explaining why the evidence satisfies the criterion.
|
|
62065
|
+
- \`C\` (conforming) \u2014 REQUIRES \`justification\` explaining why the evidence satisfies the criterion, AND \`citations[]\` naming the evidence you cleared (\`file\`/\`line\` copied VERBATIM from this item's own \`evidence[]\`; an anchor that is not in that list is treated as fabricated). A criterion whose \`evidence[]\` is empty cannot be \`C\` at all \u2014 it is \`manual\` (\`undecidable\`), or \`NA\` if nothing in scope is concerned.
|
|
62052
62066
|
- \`NC\` (non-conforming) \u2014 REQUIRES \`findings\`: at least one groundable \`{ file, line, selector?, message, snippet?, severity?, normativeRef }\` pointing at REAL source. The fold re-grounds every finding; an invented file:line is rejected. \`normativeRef\` MUST cite the precise failed test \u2014 under a country standard, one of the item's OWN tests, which the worklist lists for you under \xAB tests to rule on \xBB.
|
|
62053
|
-
- \`NA\` (not applicable) \u2014 REQUIRES \`justification
|
|
62067
|
+
- \`NA\` (not applicable) \u2014 REQUIRES \`justification\`, AND \`citations[]\` whenever evidence WAS presented, to say which of those items fall outside the criterion's scope.
|
|
62054
62068
|
- \`manual\` (still undecidable) \u2014 REQUIRES \`reason\`: \`needs-rendered-dom\` (only a rendered DOM can decide, e.g. computed contrast) or \`undecidable\` (the evidence cannot settle it either way).
|
|
62055
62069
|
3. Never guess. A criterion you cannot decide from real evidence stays \`manual\` with a reason \u2014 that is a valid, honest verdict; the scan tier or a human picks it up.
|
|
62056
62070
|
|
|
62057
|
-
Return (structured output): \`{ "verdicts": [{ "criteriaId", "verdict", "justification", "reason", "findings" }] }\` \u2014 your ITEMS only, every field grounded in what you actually read, every NC finding carrying its \`normativeRef\`.
|
|
62071
|
+
Return (structured output): \`{ "verdicts": [{ "criteriaId", "verdict", "justification", "citations", "reason", "findings" }] }\` \u2014 your ITEMS only, every field grounded in what you actually read, every NC finding carrying its \`normativeRef\`, and every C (plus every evidenced NA) carrying its \`citations\`. A verdict that clears a criterion without naming what it cleared is refused by the fold, and one refusal discards the WHOLE adjudication \u2014 including every other verdict you got right.
|
|
62058
62072
|
${footer}`,
|
|
62059
62073
|
refuter: `# Contract: refuter
|
|
62060
62074
|
|
|
@@ -5,7 +5,7 @@ when_to_use: "Invoke WITHOUT being asked whenever changed front-end code should
|
|
|
5
5
|
allowed-tools: Bash(node ${CLAUDE_SKILL_DIR}/scripts/ultra11y.mjs *), Read, Grep, Glob
|
|
6
6
|
license: MIT
|
|
7
7
|
metadata:
|
|
8
|
-
version: 5.
|
|
8
|
+
version: 5.1.0
|
|
9
9
|
---
|
|
10
10
|
|
|
11
11
|
# review-a11y — accessibility review of changed code
|
|
@@ -6,7 +6,7 @@ import { join as join50, relative as relative5, resolve as resolve15, sep as sep
|
|
|
6
6
|
import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
|
|
7
7
|
|
|
8
8
|
// src/types.ts
|
|
9
|
-
var VERSION = "5.
|
|
9
|
+
var VERSION = "5.1.0";
|
|
10
10
|
var SCHEMA_VERSION = 2;
|
|
11
11
|
|
|
12
12
|
// src/audit.ts
|
|
@@ -61923,6 +61923,20 @@ var ADJUDICATE_SCHEMA = {
|
|
|
61923
61923
|
criteriaId: { type: "string" },
|
|
61924
61924
|
verdict: { enum: ["C", "NC", "NA", "manual"] },
|
|
61925
61925
|
justification: { type: "string", description: "REQUIRED for C and NA" },
|
|
61926
|
+
// REQUIRED by the fold alongside `justification`, for exactly the same reason
|
|
61927
|
+
// `normativeRef` is required on an NC: a cleared criterion has to name what it
|
|
61928
|
+
// cleared. Omitting it here (and from the adjudicator contract) made every
|
|
61929
|
+
// adjudication produce C/NA verdicts that `verify --apply` then refused wholesale —
|
|
61930
|
+
// 63 rejections out of 97 verdicts on the run that surfaced this.
|
|
61931
|
+
citations: {
|
|
61932
|
+
type: "array",
|
|
61933
|
+
description: "REQUIRED for C, and for NA when evidence was presented: the evidence items you cleared, `file`/`line` copied verbatim from this criterion's own evidence[]",
|
|
61934
|
+
items: {
|
|
61935
|
+
type: "object",
|
|
61936
|
+
required: ["file", "line"],
|
|
61937
|
+
properties: { file: { type: "string" }, line: { type: "number" }, selector: { type: "string" }, snippet: { type: "string" } }
|
|
61938
|
+
}
|
|
61939
|
+
},
|
|
61926
61940
|
reason: { type: ["string", "null"], enum: ["needs-rendered-dom", "undecidable", null], description: "REQUIRED for a still-manual verdict" },
|
|
61927
61941
|
findings: {
|
|
61928
61942
|
type: "array",
|
|
@@ -62048,13 +62062,13 @@ For EACH of your criteria:
|
|
|
62048
62062
|
|
|
62049
62063
|
1. Read its worklist entry. \`evidence[]\` holds source-anchored excerpts (\`file\`, \`line\`, \`selector\`, \`snippet\`) harvested from the audited code \u2014 open the cited files at the cited lines whenever the snippet alone cannot decide.
|
|
62050
62064
|
2. Rule it (the apply gate is FAIL-CLOSED \u2014 a verdict missing its required field does not fold):
|
|
62051
|
-
- \`C\` (conforming) \u2014 REQUIRES \`justification\` explaining why the evidence satisfies the criterion.
|
|
62065
|
+
- \`C\` (conforming) \u2014 REQUIRES \`justification\` explaining why the evidence satisfies the criterion, AND \`citations[]\` naming the evidence you cleared (\`file\`/\`line\` copied VERBATIM from this item's own \`evidence[]\`; an anchor that is not in that list is treated as fabricated). A criterion whose \`evidence[]\` is empty cannot be \`C\` at all \u2014 it is \`manual\` (\`undecidable\`), or \`NA\` if nothing in scope is concerned.
|
|
62052
62066
|
- \`NC\` (non-conforming) \u2014 REQUIRES \`findings\`: at least one groundable \`{ file, line, selector?, message, snippet?, severity?, normativeRef }\` pointing at REAL source. The fold re-grounds every finding; an invented file:line is rejected. \`normativeRef\` MUST cite the precise failed test \u2014 under a country standard, one of the item's OWN tests, which the worklist lists for you under \xAB tests to rule on \xBB.
|
|
62053
|
-
- \`NA\` (not applicable) \u2014 REQUIRES \`justification
|
|
62067
|
+
- \`NA\` (not applicable) \u2014 REQUIRES \`justification\`, AND \`citations[]\` whenever evidence WAS presented, to say which of those items fall outside the criterion's scope.
|
|
62054
62068
|
- \`manual\` (still undecidable) \u2014 REQUIRES \`reason\`: \`needs-rendered-dom\` (only a rendered DOM can decide, e.g. computed contrast) or \`undecidable\` (the evidence cannot settle it either way).
|
|
62055
62069
|
3. Never guess. A criterion you cannot decide from real evidence stays \`manual\` with a reason \u2014 that is a valid, honest verdict; the scan tier or a human picks it up.
|
|
62056
62070
|
|
|
62057
|
-
Return (structured output): \`{ "verdicts": [{ "criteriaId", "verdict", "justification", "reason", "findings" }] }\` \u2014 your ITEMS only, every field grounded in what you actually read, every NC finding carrying its \`normativeRef\`.
|
|
62071
|
+
Return (structured output): \`{ "verdicts": [{ "criteriaId", "verdict", "justification", "citations", "reason", "findings" }] }\` \u2014 your ITEMS only, every field grounded in what you actually read, every NC finding carrying its \`normativeRef\`, and every C (plus every evidenced NA) carrying its \`citations\`. A verdict that clears a criterion without naming what it cleared is refused by the fold, and one refusal discards the WHOLE adjudication \u2014 including every other verdict you got right.
|
|
62058
62072
|
${footer}`,
|
|
62059
62073
|
refuter: `# Contract: refuter
|
|
62060
62074
|
|
package/skills/ultra11y/SKILL.md
CHANGED
|
@@ -4,7 +4,7 @@ description: "Use to AUDIT existing HTML/CSS/JSX against WCAG 2.2 AA accessibili
|
|
|
4
4
|
when_to_use: "Invoke when the user asks for an accessibility AUDIT or a formal deliverable: audit a repo, site or page against WCAG 2.2 AA or a country standard (RGAA, Section 508, EN 301 549); produce a dated conformance report, a criterion grid, a PRD backlog, or tickets (GitHub/GitLab/Jira); author accessible markup from scratch; wire the repo gate (init --hook/--ci) or a rendered-DOM capture pipeline. For a review of the code UNDER CHANGE (diff, branch, PR), use the `review-a11y` skill instead."
|
|
5
5
|
license: MIT
|
|
6
6
|
metadata:
|
|
7
|
-
version: 5.
|
|
7
|
+
version: 5.1.0
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# ultra11y — audit WCAG 2.2 AA and write accessible markup
|
|
@@ -15,7 +15,7 @@ permissions:
|
|
|
15
15
|
steps:
|
|
16
16
|
- uses: actions/checkout@v4
|
|
17
17
|
with: { fetch-depth: 0 } # the diff gate needs the base ref
|
|
18
|
-
- uses: maxgfr/ultra11y@
|
|
18
|
+
- uses: maxgfr/ultra11y@v5 # or pin the exact version, as `init --ci` does
|
|
19
19
|
with:
|
|
20
20
|
since: auto # the PR's base branch
|
|
21
21
|
standard: rgaa
|
|
@@ -27,7 +27,7 @@ steps:
|
|
|
27
27
|
Page by page, with a real browser, in the same step:
|
|
28
28
|
|
|
29
29
|
```yaml
|
|
30
|
-
- uses: maxgfr/ultra11y@
|
|
30
|
+
- uses: maxgfr/ultra11y@v5
|
|
31
31
|
with:
|
|
32
32
|
standard: rgaa
|
|
33
33
|
start: npm run start
|
|
@@ -37,7 +37,7 @@ Page by page, with a real browser, in the same step:
|
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
`ultra11y init --ci` writes a workflow using it, **pinned to the exact engine version that
|
|
40
|
-
generated the file** (`@v<that version>`) so a CI run stays reproducible. `@v<major>` — `@
|
|
40
|
+
generated the file** (`@v<that version>`) so a CI run stays reproducible. `@v<major>` — `@v5`
|
|
41
41
|
today — is a moving alias the release workflow repoints at each release, for teams who would
|
|
42
42
|
rather take fixes automatically. It moves **within** a major only: when a breaking change cuts
|
|
43
43
|
the next one, the old alias freezes where it is, so a pipeline pinned to it keeps working and
|
|
@@ -66,7 +66,7 @@ partial by construction. `adjudicate` closes that — opt-in, in two modes.
|
|
|
66
66
|
env:
|
|
67
67
|
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} # the job's env, never an input
|
|
68
68
|
steps:
|
|
69
|
-
- uses: maxgfr/ultra11y@
|
|
69
|
+
- uses: maxgfr/ultra11y@v5
|
|
70
70
|
with:
|
|
71
71
|
standard: rgaa
|
|
72
72
|
adjudicate: api # or `agent`
|
|
@@ -273,7 +273,7 @@ The action audits the **code** and, when you point it at a served app, the **pag
|
|
|
273
273
|
list can come from three places, and none of them has to be written by hand:
|
|
274
274
|
|
|
275
275
|
```yaml
|
|
276
|
-
- uses: maxgfr/ultra11y@
|
|
276
|
+
- uses: maxgfr/ultra11y@v5
|
|
277
277
|
with:
|
|
278
278
|
standard: rgaa
|
|
279
279
|
start: npm run start
|
|
@@ -312,7 +312,7 @@ the setup project their E2E suite depends on — and hand the action its path:
|
|
|
312
312
|
|
|
313
313
|
```yaml
|
|
314
314
|
- run: npx playwright test --project=setup # your existing login, writing the session file
|
|
315
|
-
- uses: maxgfr/ultra11y@
|
|
315
|
+
- uses: maxgfr/ultra11y@v5
|
|
316
316
|
with:
|
|
317
317
|
standard: rgaa
|
|
318
318
|
sample: 'true'
|
|
@@ -467,9 +467,9 @@ one job — the code diff, then the served pages — died on a `409 Conflict`, w
|
|
|
467
467
|
written and never uploaded. Pass `artifact-name` on each invocation:
|
|
468
468
|
|
|
469
469
|
```yaml
|
|
470
|
-
- uses: maxgfr/ultra11y@
|
|
470
|
+
- uses: maxgfr/ultra11y@v5
|
|
471
471
|
with: { since: auto, artifact-name: a11y-code }
|
|
472
|
-
- uses: maxgfr/ultra11y@
|
|
472
|
+
- uses: maxgfr/ultra11y@v5
|
|
473
473
|
with: { crawl: http://localhost:3000, artifact-name: a11y-pages }
|
|
474
474
|
```
|
|
475
475
|
|
|
@@ -33,6 +33,27 @@ than restating them, and a test evaluates the generated `failingFindings` alongs
|
|
|
33
33
|
one over the same findings. A fixture that gated differently from the published plugin would
|
|
34
34
|
fail two projects differently while claiming to be the same tool.
|
|
35
35
|
|
|
36
|
+
|
|
37
|
+
## A page the browser did not stay on (`expectPath`)
|
|
38
|
+
|
|
39
|
+
`as`/`name` is the identity the report speaks, and the fixture applies it to whatever is on
|
|
40
|
+
screen. So a route your test could not open — a guarded step, an expired session — gets filed
|
|
41
|
+
under the requested page's name, and its sheet, screenshot and rate all describe another
|
|
42
|
+
screen. Nothing about the document looks wrong.
|
|
43
|
+
|
|
44
|
+
Pass the path you navigated to and the fixture refuses instead:
|
|
45
|
+
|
|
46
|
+
```js
|
|
47
|
+
await checkA11y(page, { as: "declaration-etape-4", expectPath: "/declaration/etape/4" });
|
|
48
|
+
// throws: /declaration/etape/4 landed on /declaration/etape/1 — not recording it as
|
|
49
|
+
// "declaration-etape-4". The state that opens this route is not the one the test built.
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Opt-in, and path-only: a query or fragment the app appends to its own route is the same page.
|
|
53
|
+
`scan --sample` applies the same rule without being asked, because there the URL *is* the
|
|
54
|
+
instruction. A page reported missing is a bug in your seeding; a page reported under the wrong
|
|
55
|
+
name is a false conformance claim.
|
|
56
|
+
|
|
36
57
|
## Playwright
|
|
37
58
|
|
|
38
59
|
```js
|
|
@@ -6,7 +6,7 @@ import { join as join50, relative as relative5, resolve as resolve15, sep as sep
|
|
|
6
6
|
import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
|
|
7
7
|
|
|
8
8
|
// src/types.ts
|
|
9
|
-
var VERSION = "5.
|
|
9
|
+
var VERSION = "5.1.0";
|
|
10
10
|
var SCHEMA_VERSION = 2;
|
|
11
11
|
|
|
12
12
|
// src/audit.ts
|
|
@@ -61923,6 +61923,20 @@ var ADJUDICATE_SCHEMA = {
|
|
|
61923
61923
|
criteriaId: { type: "string" },
|
|
61924
61924
|
verdict: { enum: ["C", "NC", "NA", "manual"] },
|
|
61925
61925
|
justification: { type: "string", description: "REQUIRED for C and NA" },
|
|
61926
|
+
// REQUIRED by the fold alongside `justification`, for exactly the same reason
|
|
61927
|
+
// `normativeRef` is required on an NC: a cleared criterion has to name what it
|
|
61928
|
+
// cleared. Omitting it here (and from the adjudicator contract) made every
|
|
61929
|
+
// adjudication produce C/NA verdicts that `verify --apply` then refused wholesale —
|
|
61930
|
+
// 63 rejections out of 97 verdicts on the run that surfaced this.
|
|
61931
|
+
citations: {
|
|
61932
|
+
type: "array",
|
|
61933
|
+
description: "REQUIRED for C, and for NA when evidence was presented: the evidence items you cleared, `file`/`line` copied verbatim from this criterion's own evidence[]",
|
|
61934
|
+
items: {
|
|
61935
|
+
type: "object",
|
|
61936
|
+
required: ["file", "line"],
|
|
61937
|
+
properties: { file: { type: "string" }, line: { type: "number" }, selector: { type: "string" }, snippet: { type: "string" } }
|
|
61938
|
+
}
|
|
61939
|
+
},
|
|
61926
61940
|
reason: { type: ["string", "null"], enum: ["needs-rendered-dom", "undecidable", null], description: "REQUIRED for a still-manual verdict" },
|
|
61927
61941
|
findings: {
|
|
61928
61942
|
type: "array",
|
|
@@ -62048,13 +62062,13 @@ For EACH of your criteria:
|
|
|
62048
62062
|
|
|
62049
62063
|
1. Read its worklist entry. \`evidence[]\` holds source-anchored excerpts (\`file\`, \`line\`, \`selector\`, \`snippet\`) harvested from the audited code \u2014 open the cited files at the cited lines whenever the snippet alone cannot decide.
|
|
62050
62064
|
2. Rule it (the apply gate is FAIL-CLOSED \u2014 a verdict missing its required field does not fold):
|
|
62051
|
-
- \`C\` (conforming) \u2014 REQUIRES \`justification\` explaining why the evidence satisfies the criterion.
|
|
62065
|
+
- \`C\` (conforming) \u2014 REQUIRES \`justification\` explaining why the evidence satisfies the criterion, AND \`citations[]\` naming the evidence you cleared (\`file\`/\`line\` copied VERBATIM from this item's own \`evidence[]\`; an anchor that is not in that list is treated as fabricated). A criterion whose \`evidence[]\` is empty cannot be \`C\` at all \u2014 it is \`manual\` (\`undecidable\`), or \`NA\` if nothing in scope is concerned.
|
|
62052
62066
|
- \`NC\` (non-conforming) \u2014 REQUIRES \`findings\`: at least one groundable \`{ file, line, selector?, message, snippet?, severity?, normativeRef }\` pointing at REAL source. The fold re-grounds every finding; an invented file:line is rejected. \`normativeRef\` MUST cite the precise failed test \u2014 under a country standard, one of the item's OWN tests, which the worklist lists for you under \xAB tests to rule on \xBB.
|
|
62053
|
-
- \`NA\` (not applicable) \u2014 REQUIRES \`justification
|
|
62067
|
+
- \`NA\` (not applicable) \u2014 REQUIRES \`justification\`, AND \`citations[]\` whenever evidence WAS presented, to say which of those items fall outside the criterion's scope.
|
|
62054
62068
|
- \`manual\` (still undecidable) \u2014 REQUIRES \`reason\`: \`needs-rendered-dom\` (only a rendered DOM can decide, e.g. computed contrast) or \`undecidable\` (the evidence cannot settle it either way).
|
|
62055
62069
|
3. Never guess. A criterion you cannot decide from real evidence stays \`manual\` with a reason \u2014 that is a valid, honest verdict; the scan tier or a human picks it up.
|
|
62056
62070
|
|
|
62057
|
-
Return (structured output): \`{ "verdicts": [{ "criteriaId", "verdict", "justification", "reason", "findings" }] }\` \u2014 your ITEMS only, every field grounded in what you actually read, every NC finding carrying its \`normativeRef\`.
|
|
62071
|
+
Return (structured output): \`{ "verdicts": [{ "criteriaId", "verdict", "justification", "citations", "reason", "findings" }] }\` \u2014 your ITEMS only, every field grounded in what you actually read, every NC finding carrying its \`normativeRef\`, and every C (plus every evidenced NA) carrying its \`citations\`. A verdict that clears a criterion without naming what it cleared is refused by the fold, and one refusal discards the WHOLE adjudication \u2014 including every other verdict you got right.
|
|
62058
62072
|
${footer}`,
|
|
62059
62073
|
refuter: `# Contract: refuter
|
|
62060
62074
|
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// must be wired in `cypress.config.js` too — Cypress test code cannot write to disk, so the
|
|
11
11
|
// collected page round-trips through a task.
|
|
12
12
|
import { COLLECT_SNAPSHOT } from "../collector.js";
|
|
13
|
-
import { type CheckOptions, buildPayload, slugify } from "./payload.js";
|
|
13
|
+
import { type CheckOptions, buildPayload, slugify, stayedOnPage } from "./payload.js";
|
|
14
14
|
|
|
15
15
|
// Cypress's globals are not a dependency of this package; they exist at run time only.
|
|
16
16
|
declare const Cypress: {
|
|
@@ -45,6 +45,14 @@ export function registerUltra11yCommand(): void {
|
|
|
45
45
|
return cy.window({ log: false }).then((win: { eval(s: string): unknown; location: { href: string } }) => {
|
|
46
46
|
const collected = win.eval(COLLECT_SNAPSHOT) as Parameters<typeof buildPayload>[0];
|
|
47
47
|
const url = collected.url || win.location.href;
|
|
48
|
+
// Same refusal as the Playwright fixture: a page the browser did not stay on must not
|
|
49
|
+
// be filed under the requested page's identity. See CheckOptions.expectPath.
|
|
50
|
+
if (opts.expectPath && !stayedOnPage(opts.expectPath, url)) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`ultra11y: ${opts.expectPath} landed on ${url} — not recording it as "${opts.as ?? opts.name ?? "this page"}". ` +
|
|
53
|
+
`The state that opens this route is not the one the test built; seed it first, or drop the page from the sample.`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
48
56
|
const payload = {
|
|
49
57
|
...buildPayload(collected, url, "cypress", opts),
|
|
50
58
|
failOn: opts.failOn,
|
|
@@ -98,6 +98,30 @@ export interface CheckOptions {
|
|
|
98
98
|
notes?: string;
|
|
99
99
|
/** Capture a viewport screenshot for the pixel tier (Playwright: on by default). */
|
|
100
100
|
screenshot?: boolean;
|
|
101
|
+
/** The path you NAVIGATED to. Given it, the fixture refuses to record a page the browser
|
|
102
|
+
* did not stay on — a guarded route that redirected, a session that expired — instead of
|
|
103
|
+
* filing that other screen under this page's `as`/`name`. Nothing about such a snapshot
|
|
104
|
+
* looks wrong, which is what makes it the worst thing an accessibility report can carry.
|
|
105
|
+
* Compared by path (a query or fragment the app appends to its own route is the same
|
|
106
|
+
* page); a full URL works too. `scan --sample` applies the same rule. */
|
|
107
|
+
expectPath?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Did the browser stay on the page the caller asked for? Path-only, trailing slash folded;
|
|
111
|
+
* anything unparseable passes — this catches a redirect, it does not invent one. */
|
|
112
|
+
export function stayedOnPage(expected: string, actual: string): boolean {
|
|
113
|
+
if (!expected || !actual) return true;
|
|
114
|
+
const path = (u: string): string | undefined => {
|
|
115
|
+
try {
|
|
116
|
+
return new URL(u, "http://x.invalid").pathname.replace(/\/+$/, "");
|
|
117
|
+
} catch {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
const a = path(expected);
|
|
122
|
+
const b = path(actual);
|
|
123
|
+
if (a === undefined || b === undefined) return true;
|
|
124
|
+
return a === b;
|
|
101
125
|
}
|
|
102
126
|
|
|
103
127
|
/** Build the snapshot payload from a collected page. Shared so every runner stamps the
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// criteria without booting the app, and how the report speaks page by page. Commit it.
|
|
19
19
|
import { createRequire } from "node:module";
|
|
20
20
|
import { COLLECT_SNAPSHOT } from "../collector.js";
|
|
21
|
-
import { type AuditLike, type CheckOptions, auditSnapshot, buildPayload, gate, writePagesReport } from "./core.js";
|
|
21
|
+
import { type AuditLike, type CheckOptions, auditSnapshot, buildPayload, gate, stayedOnPage, writePagesReport } from "./core.js";
|
|
22
22
|
|
|
23
23
|
// Playwright's own types are not a dependency of this package (it is a peer of YOUR repo),
|
|
24
24
|
// so the page object is structurally typed to exactly what is used.
|
|
@@ -52,6 +52,16 @@ export async function checkA11y(page: PlaywrightPage, opts: PlaywrightCheckOptio
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
const url = collected.url || page.url();
|
|
55
|
+
// Refuse a page the browser did not stay on, when the caller said where it went. The
|
|
56
|
+
// snapshot's identity is `as`/`name`, applied to whatever is on screen — so a guarded route
|
|
57
|
+
// that redirected would be filed under the requested page's name, and the resulting sheet,
|
|
58
|
+
// screenshot and rate would all describe another screen. Skipping loudly beats that.
|
|
59
|
+
if (opts.expectPath && !stayedOnPage(opts.expectPath, url)) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`ultra11y: ${opts.expectPath} landed on ${url} — not recording it as "${opts.as ?? opts.name ?? "this page"}". ` +
|
|
62
|
+
`The state that opens this route is not the one the test built; seed it first, or drop the page from the sample.`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
55
65
|
const payload = buildPayload(collected, url, "playwright", opts, shot);
|
|
56
66
|
const result = auditSnapshot(payload);
|
|
57
67
|
if (opts.report) writePagesReport(typeof opts.report === "object" ? opts.report : {});
|
|
@@ -34,6 +34,21 @@ const ADJUDICATE_SCHEMA = {
|
|
|
34
34
|
criteriaId: { type: "string" },
|
|
35
35
|
verdict: { enum: ["C", "NC", "NA", "manual"] },
|
|
36
36
|
justification: { type: "string", description: "REQUIRED for C and NA" },
|
|
37
|
+
// REQUIRED by the fold alongside `justification`, for exactly the same reason
|
|
38
|
+
// `normativeRef` is required on an NC: a cleared criterion has to name what it
|
|
39
|
+
// cleared. Omitting it here (and from the adjudicator contract) made every
|
|
40
|
+
// adjudication produce C/NA verdicts that `verify --apply` then refused wholesale —
|
|
41
|
+
// 63 rejections out of 97 verdicts on the run that surfaced this.
|
|
42
|
+
citations: {
|
|
43
|
+
type: "array",
|
|
44
|
+
description:
|
|
45
|
+
"REQUIRED for C, and for NA when evidence was presented: the evidence items you cleared, `file`/`line` copied verbatim from this criterion's own evidence[]",
|
|
46
|
+
items: {
|
|
47
|
+
type: "object",
|
|
48
|
+
required: ["file", "line"],
|
|
49
|
+
properties: { file: { type: "string" }, line: { type: "number" }, selector: { type: "string" }, snippet: { type: "string" } },
|
|
50
|
+
},
|
|
51
|
+
},
|
|
37
52
|
reason: { type: ["string", "null"], enum: ["needs-rendered-dom", "undecidable", null], description: "REQUIRED for a still-manual verdict" },
|
|
38
53
|
findings: {
|
|
39
54
|
type: "array",
|
|
@@ -176,13 +191,13 @@ For EACH of your criteria:
|
|
|
176
191
|
|
|
177
192
|
1. Read its worklist entry. \`evidence[]\` holds source-anchored excerpts (\`file\`, \`line\`, \`selector\`, \`snippet\`) harvested from the audited code — open the cited files at the cited lines whenever the snippet alone cannot decide.
|
|
178
193
|
2. Rule it (the apply gate is FAIL-CLOSED — a verdict missing its required field does not fold):
|
|
179
|
-
- \`C\` (conforming) — REQUIRES \`justification\` explaining why the evidence satisfies the criterion.
|
|
194
|
+
- \`C\` (conforming) — REQUIRES \`justification\` explaining why the evidence satisfies the criterion, AND \`citations[]\` naming the evidence you cleared (\`file\`/\`line\` copied VERBATIM from this item's own \`evidence[]\`; an anchor that is not in that list is treated as fabricated). A criterion whose \`evidence[]\` is empty cannot be \`C\` at all — it is \`manual\` (\`undecidable\`), or \`NA\` if nothing in scope is concerned.
|
|
180
195
|
- \`NC\` (non-conforming) — REQUIRES \`findings\`: at least one groundable \`{ file, line, selector?, message, snippet?, severity?, normativeRef }\` pointing at REAL source. The fold re-grounds every finding; an invented file:line is rejected. \`normativeRef\` MUST cite the precise failed test — under a country standard, one of the item's OWN tests, which the worklist lists for you under « tests to rule on ».
|
|
181
|
-
- \`NA\` (not applicable) — REQUIRES \`justification
|
|
196
|
+
- \`NA\` (not applicable) — REQUIRES \`justification\`, AND \`citations[]\` whenever evidence WAS presented, to say which of those items fall outside the criterion's scope.
|
|
182
197
|
- \`manual\` (still undecidable) — REQUIRES \`reason\`: \`needs-rendered-dom\` (only a rendered DOM can decide, e.g. computed contrast) or \`undecidable\` (the evidence cannot settle it either way).
|
|
183
198
|
3. Never guess. A criterion you cannot decide from real evidence stays \`manual\` with a reason — that is a valid, honest verdict; the scan tier or a human picks it up.
|
|
184
199
|
|
|
185
|
-
Return (structured output): \`{ "verdicts": [{ "criteriaId", "verdict", "justification", "reason", "findings" }] }\` — your ITEMS only, every field grounded in what you actually read, every NC finding carrying its \`normativeRef\`.
|
|
200
|
+
Return (structured output): \`{ "verdicts": [{ "criteriaId", "verdict", "justification", "citations", "reason", "findings" }] }\` — your ITEMS only, every field grounded in what you actually read, every NC finding carrying its \`normativeRef\`, and every C (plus every evidenced NA) carrying its \`citations\`. A verdict that clears a criterion without naming what it cleared is refused by the fold, and one refusal discards the WHOLE adjudication — including every other verdict you got right.
|
|
186
201
|
${footer}`,
|
|
187
202
|
refuter: `# Contract: refuter
|
|
188
203
|
|
package/src/types.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Single source of truth for shared types + the embedded version. sync-version.mjs
|
|
2
2
|
// rewrites VERSION at release time (kept in lockstep with package.json + SKILL.md).
|
|
3
|
-
export const VERSION = "5.
|
|
3
|
+
export const VERSION = "5.1.0";
|
|
4
4
|
export const SCHEMA_VERSION = 2;
|
|
5
5
|
|
|
6
6
|
// Lang = the UI FRAME's language — the `L` localization tables each module (report,
|