@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.
@@ -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 backend accepted the registration.
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":"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"}
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) => e.message.includes("productSlug") && !e.message.includes("metadata"));
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, !0);
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;AAWD,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,CA+BlC;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,CAKlD;AAED,gFAAgF;AAChF,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,OAAO,CAAC,CAQ3D;AAED,0EAA0E;AAC1E,wBAAgB,oBAAoB,IAAI,IAAI,CAG3C"}
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
- async function d() {
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 f() {
44
- let e = await d();
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 p(e = {}) {
66
+ async function m(e = {}) {
53
67
  if (!r() || Notification.permission !== "granted") return null;
54
- let n = await d();
68
+ let n = await f();
55
69
  if (!n) return null;
56
- y();
70
+ b();
57
71
  try {
58
- let r = await n.pushManager.getSubscription();
59
- if (r) return t(u(r), "web"), r;
60
- let i = await c(e.vapidPublicKey);
61
- if (!i) return null;
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(i)
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 m(e = {}) {
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 p(e) !== null;
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 h = "BURDENOFF_NOTIFICATION_CLICK", g = "BURDENOFF_PUSH_SUBSCRIPTION_CHANGE", _ = !1;
80
- function v(n) {
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 y() {
91
- _ || typeof navigator > "u" || !("serviceWorker" in navigator) || (navigator.serviceWorker.addEventListener("message", v), _ = !0);
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 b() {
94
- let e = await f();
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 x() {
103
- i = null, _ = !1;
121
+ function S() {
122
+ i = null, v = !1;
104
123
  }
105
124
  //#endregion
106
- export { h as SW_NOTIFICATION_CLICK_MESSAGE, g as SW_PUSH_SUBSCRIPTION_CHANGE_MESSAGE, n as WEB_PUSH_MANIFEST_KEY, s as getWebPushManifestConfig, f as getWebPushSubscription, y as installWebPushMessageBridge, r as isWebPushSupported, m as requestWebPushPermission, x as resetWebPushForTests, c as resolveVapidPublicKey, u as serializeWebPushSubscription, p as subscribeWebPush, b as unsubscribeWebPush, l as urlBase64ToUint8Array };
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.1",
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
+ }