@burdenoff/fe-libs 2026.825.1 → 2026.825.3
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/dist/shared/native/push-registration.d.ts +6 -1
- package/dist/shared/native/push-registration.d.ts.map +1 -1
- package/dist/shared/native/push-registration.js +7 -4
- package/dist/shell-native/web-push.d.ts.map +1 -1
- package/dist/shell-native/web-push.js +41 -22
- package/package.json +6 -3
- package/scripts/a11y-audit/README.md +172 -0
- package/scripts/a11y-audit/allowlist.ts +178 -0
- package/scripts/a11y-audit/audit.ts +306 -0
- package/scripts/a11y-audit/cli.ts +161 -0
- package/scripts/a11y-audit/config.ts +319 -0
- package/scripts/a11y-audit/index.ts +19 -0
- package/scripts/a11y-audit/report.ts +144 -0
- package/scripts/a11y-audit/server.ts +237 -0
- package/scripts/a11y-audit/types.ts +234 -0
|
@@ -83,7 +83,12 @@ export interface RegisterPushDeviceOptions {
|
|
|
83
83
|
* Register (or refresh) this device. Idempotent on the backend, and additionally
|
|
84
84
|
* de-duplicated here so a remount storm cannot hammer the mutation.
|
|
85
85
|
*
|
|
86
|
-
* Resolves `true` when the
|
|
86
|
+
* Resolves `true` only when the device is registered AND routable — i.e. the
|
|
87
|
+
* backend stamped a Firebase project on it. It resolves `false` both when the
|
|
88
|
+
* mutation failed and when the row had to be written through the legacy,
|
|
89
|
+
* project-less shape during a gateway schema-skew window: that row exists but
|
|
90
|
+
* nothing can be delivered to it, and reporting it as success is what turns a
|
|
91
|
+
* deliberate server-side rejection into a device that silently never receives.
|
|
87
92
|
*/
|
|
88
93
|
export declare function registerPushDevice(options: RegisterPushDeviceOptions): Promise<boolean>;
|
|
89
94
|
export interface UnregisterPushDeviceOptions {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"push-registration.d.ts","sourceRoot":"","sources":["../../../src/shared/native/push-registration.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAC;AAEpD;;;GAGG;AACH,eAAO,MAAM,gBAAgB,wBAAwB,CAAC;AAEtD;;;;GAIG;AACH,eAAO,MAAM,0BAA0B,uBAAuB,CAAC;AAE/D,MAAM,MAAM,kBAAkB,GAAG,oBAAoB,CAAC,UAAU,CAAC,CAAC;AAElE;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,8BAA8B,4ZAqB1C,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,qCAAqC,gWAmBjD,CAAC;AAEF,eAAO,MAAM,gCAAgC,oHAI5C,CAAC;AAEF,0DAA0D;AAC1D,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,QAAQ,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,wBAAwB,EAAE,IAAI,CAAC;CAChC;AAED,MAAM,WAAW,wBAAwB;IACvC,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,MAAM,CAAC,EAAE,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;CAC3E;AAED,MAAM,MAAM,yBAAyB,GAAG,CACtC,OAAO,EAAE,uBAAuB,KAC7B,OAAO,CAAC,wBAAwB,CAAC,CAAC;AAgCvC;;;;;GAKG;AACH,wBAAgB,uBAAuB,IAAI,MAAM,CAuBhD;
|
|
1
|
+
{"version":3,"file":"push-registration.d.ts","sourceRoot":"","sources":["../../../src/shared/native/push-registration.ts"],"names":[],"mappings":"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"}
|
|
@@ -25,7 +25,10 @@ function l() {
|
|
|
25
25
|
return e;
|
|
26
26
|
}
|
|
27
27
|
function u(e) {
|
|
28
|
-
return (e.errors ?? []).some((e) =>
|
|
28
|
+
return (e.errors ?? []).some((e) => {
|
|
29
|
+
let t = typeof e.extensions?.code == "string" ? e.extensions.code : "";
|
|
30
|
+
return t && t !== "GRAPHQL_VALIDATION_FAILED" ? !1 : /unknown\s+argument\s+"?productSlug"?/i.test(e.message);
|
|
31
|
+
});
|
|
29
32
|
}
|
|
30
33
|
var d = null;
|
|
31
34
|
function f(e, t) {
|
|
@@ -62,16 +65,16 @@ async function p(e) {
|
|
|
62
65
|
variables: p,
|
|
63
66
|
...e.authToken ? { authToken: e.authToken } : {},
|
|
64
67
|
suppressGlobalErrorEvent: !0
|
|
65
|
-
}, h = await c(m);
|
|
68
|
+
}, h = await c(m), g = !0;
|
|
66
69
|
if (u(h)) {
|
|
67
70
|
let e = { ...p };
|
|
68
71
|
delete e.productSlug, h = await c({
|
|
69
72
|
...m,
|
|
70
73
|
query: r,
|
|
71
74
|
variables: e
|
|
72
|
-
});
|
|
75
|
+
}), g = !1;
|
|
73
76
|
}
|
|
74
|
-
return h.errors?.length ? !1 : (d = s,
|
|
77
|
+
return h.errors?.length ? !1 : (g && (d = s), g);
|
|
75
78
|
}
|
|
76
79
|
async function m(e = {}) {
|
|
77
80
|
d = null;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"web-push.d.ts","sourceRoot":"","sources":["../../src/shell-native/web-push.ts"],"names":[],"mappings":"AA8CA,mFAAmF;AACnF,eAAO,MAAM,qBAAqB,mBAAmB,CAAC;AAEtD,MAAM,WAAW,qBAAqB;IACpC,mEAAmE;IACnE,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,4EAA4E;IAC5E,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,+DAA+D;AAC/D,wBAAgB,kBAAkB,IAAI,OAAO,CAQ5C;AAqBD;;;GAGG;AACH,wBAAsB,wBAAwB,IAAI,OAAO,CAAC,qBAAqB,GAAG,IAAI,CAAC,CAOtF;AAED,sFAAsF;AACtF,wBAAsB,qBAAqB,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAIrF;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,GAAG,UAAU,CAAC,WAAW,CAAC,CAOhF;AAID;;;;;GAKG;AACH,wBAAgB,4BAA4B,CAAC,YAAY,EAAE,gBAAgB,GAAG,MAAM,CAEnF;
|
|
1
|
+
{"version":3,"file":"web-push.d.ts","sourceRoot":"","sources":["../../src/shell-native/web-push.ts"],"names":[],"mappings":"AA8CA,mFAAmF;AACnF,eAAO,MAAM,qBAAqB,mBAAmB,CAAC;AAEtD,MAAM,WAAW,qBAAqB;IACpC,mEAAmE;IACnE,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,4EAA4E;IAC5E,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,+DAA+D;AAC/D,wBAAgB,kBAAkB,IAAI,OAAO,CAQ5C;AAqBD;;;GAGG;AACH,wBAAsB,wBAAwB,IAAI,OAAO,CAAC,qBAAqB,GAAG,IAAI,CAAC,CAOtF;AAED,sFAAsF;AACtF,wBAAsB,qBAAqB,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAIrF;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,GAAG,UAAU,CAAC,WAAW,CAAC,CAOhF;AAID;;;;;GAKG;AACH,wBAAgB,4BAA4B,CAAC,YAAY,EAAE,gBAAgB,GAAG,MAAM,CAEnF;AAuCD,iEAAiE;AACjE,wBAAsB,sBAAsB,IAAI,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAQ/E;AAED,MAAM,WAAW,uBAAuB;IACtC,2CAA2C;IAC3C,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;GAIG;AACH,wBAAsB,gBAAgB,CACpC,OAAO,GAAE,uBAA4B,GACpC,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CA8ClC;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,wBAAwB,CAC5C,OAAO,GAAE,uBAA4B,GACpC,OAAO,CAAC,OAAO,CAAC,CAclB;AAID,mEAAmE;AACnE,eAAO,MAAM,6BAA6B,iCAAiC,CAAC;AAC5E,gFAAgF;AAChF,eAAO,MAAM,mCAAmC,uCAAuC,CAAC;AAyBxF;;;;GAIG;AACH,wBAAgB,2BAA2B,IAAI,IAAI,CAclD;AAED,gFAAgF;AAChF,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,OAAO,CAAC,CAQ3D;AAED,0EAA0E;AAC1E,wBAAgB,oBAAoB,IAAI,IAAI,CAG3C"}
|
|
@@ -32,7 +32,21 @@ function l(e) {
|
|
|
32
32
|
function u(e) {
|
|
33
33
|
return JSON.stringify(e.toJSON());
|
|
34
34
|
}
|
|
35
|
-
|
|
35
|
+
function d(e, t) {
|
|
36
|
+
let n = e.options?.applicationServerKey;
|
|
37
|
+
if (!n) return !1;
|
|
38
|
+
let r;
|
|
39
|
+
try {
|
|
40
|
+
r = l(t);
|
|
41
|
+
} catch {
|
|
42
|
+
return !1;
|
|
43
|
+
}
|
|
44
|
+
let i = new Uint8Array(n);
|
|
45
|
+
if (i.length !== r.length) return !1;
|
|
46
|
+
for (let e = 0; e < i.length; e += 1) if (i[e] !== r[e]) return !1;
|
|
47
|
+
return !0;
|
|
48
|
+
}
|
|
49
|
+
async function f() {
|
|
36
50
|
if (!r()) return null;
|
|
37
51
|
try {
|
|
38
52
|
return await navigator.serviceWorker.ready;
|
|
@@ -40,8 +54,8 @@ async function d() {
|
|
|
40
54
|
return null;
|
|
41
55
|
}
|
|
42
56
|
}
|
|
43
|
-
async function
|
|
44
|
-
let e = await
|
|
57
|
+
async function p() {
|
|
58
|
+
let e = await f();
|
|
45
59
|
if (!e) return null;
|
|
46
60
|
try {
|
|
47
61
|
return await e.pushManager.getSubscription();
|
|
@@ -49,35 +63,40 @@ async function f() {
|
|
|
49
63
|
return null;
|
|
50
64
|
}
|
|
51
65
|
}
|
|
52
|
-
async function
|
|
66
|
+
async function m(e = {}) {
|
|
53
67
|
if (!r() || Notification.permission !== "granted") return null;
|
|
54
|
-
let n = await
|
|
68
|
+
let n = await f();
|
|
55
69
|
if (!n) return null;
|
|
56
|
-
|
|
70
|
+
b();
|
|
57
71
|
try {
|
|
58
|
-
let r = await
|
|
59
|
-
if (r) return
|
|
60
|
-
let i = await
|
|
61
|
-
if (
|
|
72
|
+
let r = await c(e.vapidPublicKey);
|
|
73
|
+
if (!r) return null;
|
|
74
|
+
let i = await n.pushManager.getSubscription();
|
|
75
|
+
if (i) {
|
|
76
|
+
if (d(i, r)) return t(u(i), "web"), i;
|
|
77
|
+
try {
|
|
78
|
+
await i.unsubscribe();
|
|
79
|
+
} catch {}
|
|
80
|
+
}
|
|
62
81
|
let a = await n.pushManager.subscribe({
|
|
63
82
|
userVisibleOnly: !0,
|
|
64
|
-
applicationServerKey: l(
|
|
83
|
+
applicationServerKey: l(r)
|
|
65
84
|
});
|
|
66
85
|
return t(u(a), "web"), a;
|
|
67
86
|
} catch {
|
|
68
87
|
return null;
|
|
69
88
|
}
|
|
70
89
|
}
|
|
71
|
-
async function
|
|
90
|
+
async function h(e = {}) {
|
|
72
91
|
if (!r()) return !1;
|
|
73
92
|
try {
|
|
74
|
-
return Notification.permission === "denied" || Notification.permission !== "granted" && await Notification.requestPermission() !== "granted" ? !1 : await
|
|
93
|
+
return Notification.permission === "denied" || Notification.permission !== "granted" && await Notification.requestPermission() !== "granted" ? !1 : await m(e) !== null;
|
|
75
94
|
} catch {
|
|
76
95
|
return !1;
|
|
77
96
|
}
|
|
78
97
|
}
|
|
79
|
-
var
|
|
80
|
-
function
|
|
98
|
+
var g = "BURDENOFF_NOTIFICATION_CLICK", _ = "BURDENOFF_PUSH_SUBSCRIPTION_CHANGE", v = !1;
|
|
99
|
+
function y(n) {
|
|
81
100
|
let r = n.data;
|
|
82
101
|
if (!r || typeof r != "object") return;
|
|
83
102
|
let i = r;
|
|
@@ -87,11 +106,11 @@ function v(n) {
|
|
|
87
106
|
}
|
|
88
107
|
i.type === "BURDENOFF_PUSH_SUBSCRIPTION_CHANGE" && i.subscription && t(JSON.stringify(i.subscription), "web");
|
|
89
108
|
}
|
|
90
|
-
function
|
|
91
|
-
|
|
109
|
+
function b() {
|
|
110
|
+
v || typeof navigator > "u" || !("serviceWorker" in navigator) || (navigator.serviceWorker.addEventListener("message", y), typeof navigator.serviceWorker.startMessages == "function" && navigator.serviceWorker.startMessages(), v = !0);
|
|
92
111
|
}
|
|
93
|
-
async function
|
|
94
|
-
let e = await
|
|
112
|
+
async function x() {
|
|
113
|
+
let e = await p();
|
|
95
114
|
if (!e) return !1;
|
|
96
115
|
try {
|
|
97
116
|
return await e.unsubscribe();
|
|
@@ -99,8 +118,8 @@ async function b() {
|
|
|
99
118
|
return !1;
|
|
100
119
|
}
|
|
101
120
|
}
|
|
102
|
-
function
|
|
103
|
-
i = null,
|
|
121
|
+
function S() {
|
|
122
|
+
i = null, v = !1;
|
|
104
123
|
}
|
|
105
124
|
//#endregion
|
|
106
|
-
export {
|
|
125
|
+
export { g as SW_NOTIFICATION_CLICK_MESSAGE, _ as SW_PUSH_SUBSCRIPTION_CHANGE_MESSAGE, n as WEB_PUSH_MANIFEST_KEY, s as getWebPushManifestConfig, p as getWebPushSubscription, b as installWebPushMessageBridge, r as isWebPushSupported, h as requestWebPushPermission, S as resetWebPushForTests, c as resolveVapidPublicKey, u as serializeWebPushSubscription, m as subscribeWebPush, x as unsubscribeWebPush, l as urlBase64ToUint8Array };
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@burdenoff/fe-libs",
|
|
3
|
-
"version": "2026.825.
|
|
3
|
+
"version": "2026.825.3",
|
|
4
4
|
"description": "Burdenoff frontend primitives and domain libraries",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"graphql-schema-drift": "./scripts/graphql-schema-drift/cli.ts"
|
|
7
|
+
"graphql-schema-drift": "./scripts/graphql-schema-drift/cli.ts",
|
|
8
|
+
"a11y-audit": "./scripts/a11y-audit/cli.ts"
|
|
8
9
|
},
|
|
9
10
|
"imports": {
|
|
10
11
|
"#lib/*": "./src/lib/*",
|
|
@@ -283,6 +284,8 @@
|
|
|
283
284
|
"src/vite/**/*",
|
|
284
285
|
"scripts/graphql-schema-drift/*.ts",
|
|
285
286
|
"scripts/graphql-schema-drift/README.md",
|
|
287
|
+
"scripts/a11y-audit/*.ts",
|
|
288
|
+
"scripts/a11y-audit/README.md",
|
|
286
289
|
"README.md",
|
|
287
290
|
"LICENSE"
|
|
288
291
|
],
|
|
@@ -293,7 +296,7 @@
|
|
|
293
296
|
"lint:sanity": "if [ \"${CI:-}\" = \"true\" ]; then bun run lint; else bun run lint -- --cache --cache-location .eslintcache; fi",
|
|
294
297
|
"format": "prettier --config shared/config/prettier.config.mjs --write \"src/**/*.{ts,tsx}\" \"!src/config/*.config.ts\"",
|
|
295
298
|
"type:check": "bash scripts/build-limits.sh tsc --noEmit",
|
|
296
|
-
"test": "bun test scripts/graphql-schema-drift/__tests__ src/credentials src/storage src/shared/assistant src/shared/providers/AppShellProvider.test.ts src/shared/events/sinks/RybbitSink.test.ts src/shell-native src/shared/native src/chrome/__tests__ src/ui/__tests__",
|
|
299
|
+
"test": "bun test scripts/graphql-schema-drift/__tests__ scripts/a11y-audit/__tests__ src/credentials src/storage src/shared/assistant src/shared/providers/AppShellProvider.test.ts src/shared/events/sinks/RybbitSink.test.ts src/shell-native src/shared/native src/chrome/__tests__ src/ui/__tests__",
|
|
297
300
|
"sanity": "bash scripts/build-limits.sh --gate bash -c 'bun run lint:sanity && bun run format && bun run type:check && bun run test && bun run build'",
|
|
298
301
|
"switch:status": "bun scripts/deps-mode.ts status",
|
|
299
302
|
"switch:remote": "bun scripts/deps-mode.ts remote",
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# a11y-audit — the fleet WCAG 2.1 AA gate
|
|
2
|
+
|
|
3
|
+
A real axe-core run against a built app shell, microfrontend or website, wired to
|
|
4
|
+
fail a build on `serious` **and** `critical` violations, with an explicit
|
|
5
|
+
allowlist for the exceptions you have decided to live with.
|
|
6
|
+
|
|
7
|
+
It exists because the thing it replaces did not work. Several repos had an
|
|
8
|
+
`@axe-core/playwright` spec checked in, but:
|
|
9
|
+
|
|
10
|
+
- no CI workflow ever executed it, and
|
|
11
|
+
- where it did assert, it asserted `impact === 'critical'` only, through
|
|
12
|
+
`expect.soft(...)`, which records a failure and then lets the run pass.
|
|
13
|
+
|
|
14
|
+
So the fleet's accessibility posture was "not assessed" while looking assessed.
|
|
15
|
+
|
|
16
|
+
## What it does
|
|
17
|
+
|
|
18
|
+
For every configured route × viewport:
|
|
19
|
+
|
|
20
|
+
1. serves the built app (or points at a running one),
|
|
21
|
+
2. navigates, waits for the DOM to stop changing, and refuses to audit a page
|
|
22
|
+
that never painted,
|
|
23
|
+
3. runs axe-core with the WCAG 2.1 A + AA tag set,
|
|
24
|
+
4. splits every failing DOM node into **blocking** / **allowlisted** /
|
|
25
|
+
**advisory**,
|
|
26
|
+
5. writes a JSON report and a GitHub step summary,
|
|
27
|
+
6. exits non-zero when anything blocking survives.
|
|
28
|
+
|
|
29
|
+
Exit codes:
|
|
30
|
+
|
|
31
|
+
| Code | Meaning |
|
|
32
|
+
| --- | --- |
|
|
33
|
+
| `0` | no blocking violations |
|
|
34
|
+
| `1` | blocking violations (or stale allowlist entries with `failOnStaleAllowlist`) |
|
|
35
|
+
| `2` | the audit could not run — invalid config, app never came up, a route failed to load or never rendered |
|
|
36
|
+
|
|
37
|
+
Exit `2` matters as much as exit `1`. An audit that could not look at a page is
|
|
38
|
+
reported as a failure to audit, never as a pass.
|
|
39
|
+
|
|
40
|
+
## Install in a repo
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
bun add -d @playwright/test @axe-core/playwright
|
|
44
|
+
bunx playwright install --with-deps chromium
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`@burdenoff/fe-libs` ships the runner as a package `bin`, so either of these works
|
|
48
|
+
from the repo root:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
a11y-audit
|
|
52
|
+
bun run node_modules/@burdenoff/fe-libs/scripts/a11y-audit/cli.ts
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Add the script to `package.json`:
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
"a11y": "bun run node_modules/@burdenoff/fe-libs/scripts/a11y-audit/cli.ts"
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Playwright and axe are imported dynamically, so fe-libs itself does not drag a
|
|
62
|
+
browser automation stack into every consumer of the component library.
|
|
63
|
+
|
|
64
|
+
## Config
|
|
65
|
+
|
|
66
|
+
`a11y-audit.config.json` at the repo root (or pass a path as the first argument).
|
|
67
|
+
|
|
68
|
+
```jsonc
|
|
69
|
+
{
|
|
70
|
+
"name": "vibecontrols-app",
|
|
71
|
+
"serve": { "staticDir": "dist", "spa": true },
|
|
72
|
+
"routes": [
|
|
73
|
+
{ "path": "/", "name": "landing" },
|
|
74
|
+
{ "path": "/auth/login", "name": "auth-login" }
|
|
75
|
+
],
|
|
76
|
+
"viewports": [
|
|
77
|
+
{ "name": "desktop", "width": 1280, "height": 800 },
|
|
78
|
+
{ "name": "mobile", "width": 390, "height": 844 }
|
|
79
|
+
],
|
|
80
|
+
"settleMs": 1000,
|
|
81
|
+
"domQuietMs": 2000,
|
|
82
|
+
"allowlist": []
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### `serve` — exactly one mode
|
|
87
|
+
|
|
88
|
+
| Mode | Use for | Example |
|
|
89
|
+
| --- | --- | --- |
|
|
90
|
+
| `staticDir` | app shells and websites, which build to a servable `dist/` | `{ "staticDir": "dist", "spa": true }` |
|
|
91
|
+
| `command` + `port` | microfrontends, which build to a library and need their Vite harness to render | `{ "command": "bun run dev --port 4180", "port": 4180 }` |
|
|
92
|
+
| `baseUrl` | an already-running target (preview deploy, alpha, prod) | `{ "baseUrl": "https://alphaapp.vibecontrols.com" }` |
|
|
93
|
+
|
|
94
|
+
`spa: true` (the default) serves `index.html` for extension-less paths so
|
|
95
|
+
client-routed URLs resolve. Missing *assets* still 404 — a broken bundle must not
|
|
96
|
+
be laundered into a page load.
|
|
97
|
+
|
|
98
|
+
`--base-url=URL` on the command line overrides whatever the config says, which is
|
|
99
|
+
how you point the same config at alpha or prod.
|
|
100
|
+
|
|
101
|
+
### Determinism knobs
|
|
102
|
+
|
|
103
|
+
| Key | Default | Why |
|
|
104
|
+
| --- | --- | --- |
|
|
105
|
+
| `blockExternalRequests` | `true` | Aborts every request leaving the app's own origin. Without it, axe races backend latency: the same route audits a splash screen on one run and a rendered page on the next. Set `false` when auditing a live deployment where the backend is part of the subject. |
|
|
106
|
+
| `settleMs` | `1500` | Fixed delay after `load` before quiescence polling starts. |
|
|
107
|
+
| `domQuietMs` | `1500` | The DOM must stop changing (text length and element count) for this long before axe runs. |
|
|
108
|
+
| `minTextLength` | `100` | A route that renders less than this much text is reported as **not audited** (exit 2), never as a pass. |
|
|
109
|
+
| `navigationTimeoutMs` | `45000` | Per-navigation and per-quiescence ceiling. |
|
|
110
|
+
|
|
111
|
+
Per route you can also set `waitForSelector` and a route-specific `settleMs`.
|
|
112
|
+
|
|
113
|
+
### `failOn`
|
|
114
|
+
|
|
115
|
+
Defaults to `["critical", "serious"]`. It may be **widened** (adding `moderate`),
|
|
116
|
+
never narrowed — a config that drops `critical` or `serious` is rejected. That is
|
|
117
|
+
the fleet floor and the gate refuses to run below it.
|
|
118
|
+
|
|
119
|
+
## The allowlist
|
|
120
|
+
|
|
121
|
+
There is deliberately **no** way to disable a rule. The only suppression
|
|
122
|
+
mechanism is an allowlist entry, and every entry must say why.
|
|
123
|
+
|
|
124
|
+
```jsonc
|
|
125
|
+
{
|
|
126
|
+
"rule": "color-contrast",
|
|
127
|
+
"reason": "Stripe's hosted card iframe; contrast is fixed upstream and we cannot restyle it.",
|
|
128
|
+
"routes": ["/billing/*"],
|
|
129
|
+
"selectors": ["#stripe-card-frame"],
|
|
130
|
+
"expires": "2026-12-31",
|
|
131
|
+
"ticket": "BOFF-1234"
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
| Field | Required | Rules |
|
|
136
|
+
| --- | --- | --- |
|
|
137
|
+
| `rule` | yes | one axe rule id. Wildcards (`*`, `**`, `.*`) are rejected. |
|
|
138
|
+
| `reason` | yes | ≥ 20 characters of actual prose. `TODO`, `TBD`, `known issue`, `legacy`, `n/a` and "placeholder + nothing substantive" (`TODO: fix later`) are rejected. |
|
|
139
|
+
| `routes` | no | exact path or `/prefix/*` subtree. A scope naming a route that is not audited fails the run as a typo. |
|
|
140
|
+
| `selectors` | no | matches the node's own axe target, or an ancestor prefix of it on a combinator boundary. `#billing` does **not** match `#billing-panel`. |
|
|
141
|
+
| `expires` | no | ISO `YYYY-MM-DD`. Past its end-of-day the entry suppresses nothing and the violations under it start failing again. |
|
|
142
|
+
| `ticket` | no | Linear id, surfaced in the report. |
|
|
143
|
+
|
|
144
|
+
Omitting both `routes` and `selectors` is allowed but it is a fleet-wide
|
|
145
|
+
exemption for that rule, and the report labels it as unscoped so it is visible in
|
|
146
|
+
review.
|
|
147
|
+
|
|
148
|
+
Entries that matched nothing in a run are reported as **stale**. Set
|
|
149
|
+
`failOnStaleAllowlist: true` once a repo's list is clean to keep it that way.
|
|
150
|
+
|
|
151
|
+
## Output
|
|
152
|
+
|
|
153
|
+
- console report (route by route, rule by rule, with the failing selectors),
|
|
154
|
+
- `a11y-report.json` (path configurable via `reportPath` or `--json=`), which is
|
|
155
|
+
what you attach to a CI artifact,
|
|
156
|
+
- a markdown table appended to `$GITHUB_STEP_SUMMARY` when running in Actions.
|
|
157
|
+
|
|
158
|
+
## CLI flags
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
a11y-audit [configPath]
|
|
162
|
+
[--base-url=URL] audit a running deployment instead of serving dist
|
|
163
|
+
[--json=PATH] where to write the JSON report
|
|
164
|
+
[--route=/path] repeatable; audit only these routes
|
|
165
|
+
[--quiet] suppress the console report, keep the verdict
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Wiring it into CI
|
|
169
|
+
|
|
170
|
+
Copy `a11y-check.yml.tmpl` from
|
|
171
|
+
`~/products/dev/platform/context/cicd/templates/` into `.github/workflows/`.
|
|
172
|
+
Rollout guide: `~/products/dev/platform/context/ui-simplification/HOW_TO_ADD_A11Y_CI.md`.
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Allowlist matching and violation partitioning.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions only — this is the part of the gate that decides what is
|
|
5
|
+
* allowed to not fail a build, so it is unit-tested directly rather than
|
|
6
|
+
* inferred from a green CI run.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { AllowedNode, AllowlistEntry, FlatNode, ImpactLevel, RouteSpec } from './types';
|
|
10
|
+
|
|
11
|
+
/** axe targets nest one level for iframes; flatten to plain selector strings. */
|
|
12
|
+
export function flattenTarget(target: unknown): string[] {
|
|
13
|
+
if (typeof target === 'string') return [target];
|
|
14
|
+
if (!Array.isArray(target)) return [];
|
|
15
|
+
return target.flatMap((t) => flattenTarget(t));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function normalizeSelector(selector: string): string {
|
|
19
|
+
return selector.trim().replace(/\s+/g, ' ');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A configured selector matches an axe target when it is the target itself or
|
|
24
|
+
* an ancestor prefix of it. `#billing-panel` therefore covers
|
|
25
|
+
* `#billing-panel > table > tr:nth-child(2) > td`, but `#billing` does not —
|
|
26
|
+
* the boundary must fall on a combinator, so prefixes never match by accident.
|
|
27
|
+
*/
|
|
28
|
+
export function selectorMatches(allowSelector: string, target: string): boolean {
|
|
29
|
+
const allow = normalizeSelector(allowSelector);
|
|
30
|
+
const actual = normalizeSelector(target);
|
|
31
|
+
if (allow.length === 0) return false;
|
|
32
|
+
if (allow === actual) return true;
|
|
33
|
+
if (!actual.startsWith(allow)) return false;
|
|
34
|
+
const boundary = actual.charAt(allow.length);
|
|
35
|
+
return boundary === ' ' || boundary === '>' || boundary === '+' || boundary === '~';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A configured route matches either exactly, or as a `/prefix/*` subtree.
|
|
40
|
+
* `/settings/*` covers `/settings/profile` and `/settings` itself.
|
|
41
|
+
*/
|
|
42
|
+
export function routeMatches(allowRoute: string, routePath: string): boolean {
|
|
43
|
+
const allow = allowRoute.trim();
|
|
44
|
+
const actual = routePath.trim();
|
|
45
|
+
if (allow === actual) return true;
|
|
46
|
+
if (!allow.endsWith('/*')) return false;
|
|
47
|
+
const prefix = allow.slice(0, -2);
|
|
48
|
+
if (prefix.length === 0) return false;
|
|
49
|
+
return actual === prefix || actual.startsWith(`${prefix}/`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** An entry with an `expires` date in the past suppresses nothing. */
|
|
53
|
+
export function isExpired(entry: AllowlistEntry, now: Date): boolean {
|
|
54
|
+
if (!entry.expires) return false;
|
|
55
|
+
const expiryEnd = new Date(`${entry.expires}T23:59:59.999Z`).getTime();
|
|
56
|
+
return now.getTime() > expiryEnd;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Does this entry cover this specific failing node? */
|
|
60
|
+
export function entryCovers(entry: AllowlistEntry, node: FlatNode, now: Date): boolean {
|
|
61
|
+
if (entry.rule !== node.rule) return false;
|
|
62
|
+
if (isExpired(entry, now)) return false;
|
|
63
|
+
if (entry.routes && !entry.routes.some((r) => routeMatches(r, node.route))) return false;
|
|
64
|
+
if (
|
|
65
|
+
entry.selectors &&
|
|
66
|
+
!entry.selectors.some((s) => node.target.some((t) => selectorMatches(s, t)))
|
|
67
|
+
) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Shape of the subset of an axe violation this module reads. */
|
|
74
|
+
export interface AxeViolationLike {
|
|
75
|
+
id: string;
|
|
76
|
+
impact?: string | null;
|
|
77
|
+
help?: string;
|
|
78
|
+
helpUrl?: string;
|
|
79
|
+
tags?: string[];
|
|
80
|
+
nodes: Array<{
|
|
81
|
+
target: unknown;
|
|
82
|
+
html?: string;
|
|
83
|
+
impact?: string | null;
|
|
84
|
+
failureSummary?: string;
|
|
85
|
+
}>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Flatten axe violations into one record per failing DOM node. */
|
|
89
|
+
export function flattenViolations(
|
|
90
|
+
violations: AxeViolationLike[],
|
|
91
|
+
context: { route: string; routeName: string; viewport: string }
|
|
92
|
+
): FlatNode[] {
|
|
93
|
+
const flat: FlatNode[] = [];
|
|
94
|
+
for (const violation of violations) {
|
|
95
|
+
for (const node of violation.nodes) {
|
|
96
|
+
flat.push({
|
|
97
|
+
rule: violation.id,
|
|
98
|
+
impact: (node.impact ?? violation.impact ?? 'minor') as ImpactLevel,
|
|
99
|
+
help: violation.help ?? '',
|
|
100
|
+
helpUrl: violation.helpUrl ?? '',
|
|
101
|
+
tags: violation.tags ?? [],
|
|
102
|
+
route: context.route,
|
|
103
|
+
routeName: context.routeName,
|
|
104
|
+
viewport: context.viewport,
|
|
105
|
+
target: flattenTarget(node.target),
|
|
106
|
+
html: (node.html ?? '').slice(0, 400),
|
|
107
|
+
failureSummary: node.failureSummary ?? '',
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return flat;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface PartitionResult {
|
|
115
|
+
blocking: FlatNode[];
|
|
116
|
+
allowed: AllowedNode[];
|
|
117
|
+
advisory: FlatNode[];
|
|
118
|
+
/** Indexes into the allowlist that actually suppressed something. */
|
|
119
|
+
usedEntries: Set<number>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Split flattened nodes into build-failing, explicitly-allowed and advisory
|
|
124
|
+
* (below the failure threshold, reported but not blocking).
|
|
125
|
+
*
|
|
126
|
+
* When several entries cover the same node the first one wins, so the report
|
|
127
|
+
* attributes it to a single, nameable reason.
|
|
128
|
+
*/
|
|
129
|
+
export function partitionNodes(
|
|
130
|
+
nodes: FlatNode[],
|
|
131
|
+
allowlist: AllowlistEntry[],
|
|
132
|
+
failOn: ImpactLevel[],
|
|
133
|
+
now: Date
|
|
134
|
+
): PartitionResult {
|
|
135
|
+
const blocking: FlatNode[] = [];
|
|
136
|
+
const allowed: AllowedNode[] = [];
|
|
137
|
+
const advisory: FlatNode[] = [];
|
|
138
|
+
const usedEntries = new Set<number>();
|
|
139
|
+
|
|
140
|
+
for (const node of nodes) {
|
|
141
|
+
const index = allowlist.findIndex((entry) => entryCovers(entry, node, now));
|
|
142
|
+
if (index >= 0) {
|
|
143
|
+
usedEntries.add(index);
|
|
144
|
+
const entry = allowlist[index];
|
|
145
|
+
allowed.push({
|
|
146
|
+
...node,
|
|
147
|
+
allowlistIndex: index,
|
|
148
|
+
allowlistReason: entry.reason,
|
|
149
|
+
allowlistTicket: entry.ticket,
|
|
150
|
+
});
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (failOn.includes(node.impact)) blocking.push(node);
|
|
154
|
+
else advisory.push(node);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return { blocking, allowed, advisory, usedEntries };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Entries whose `routes` name a path that is not in the audited route list.
|
|
162
|
+
* Almost always a typo, and a typo here silently widens or voids the scope.
|
|
163
|
+
*/
|
|
164
|
+
export function findUnreachableRouteScopes(
|
|
165
|
+
allowlist: AllowlistEntry[],
|
|
166
|
+
routes: RouteSpec[]
|
|
167
|
+
): Array<{ index: number; route: string }> {
|
|
168
|
+
const unreachable: Array<{ index: number; route: string }> = [];
|
|
169
|
+
allowlist.forEach((entry, index) => {
|
|
170
|
+
if (!entry.routes) return;
|
|
171
|
+
for (const scope of entry.routes) {
|
|
172
|
+
if (!routes.some((route) => routeMatches(scope, route.path))) {
|
|
173
|
+
unreachable.push({ index, route: scope });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
return unreachable;
|
|
178
|
+
}
|