@burdenoff/fe-libs 2026.1022.2 → 2026.1023.1
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/chrome/LanguageSwitcher.d.ts +10 -0
- package/dist/chrome/LanguageSwitcher.js +39 -35
- package/dist/chrome/Tabs.js +1 -1
- package/dist/shared/providers/shell/ThemeProvider.js +1 -1
- package/package.json +2 -2
- package/scripts/a11y-audit/README.md +63 -0
- package/scripts/a11y-audit/audit.ts +120 -22
- package/scripts/a11y-audit/cli.ts +10 -3
- package/scripts/a11y-audit/config.ts +123 -0
- package/scripts/a11y-audit/fixtures.ts +277 -0
- package/scripts/a11y-audit/index.ts +17 -1
- package/scripts/a11y-audit/report.ts +36 -5
- package/scripts/a11y-audit/types.ts +48 -1
- package/src/shared/tokens/build/brands/fluidgrids.css +22 -3
|
@@ -12,6 +12,16 @@ export interface LanguageSwitcherProps {
|
|
|
12
12
|
/** Show as icon only (for small screens) */
|
|
13
13
|
compact?: boolean;
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Accessible name of the switcher's trigger button.
|
|
17
|
+
*
|
|
18
|
+
* WCAG 2.5.3 (Label in Name; axe `label-content-name-mismatch`): when the
|
|
19
|
+
* trigger shows the current language code, its accessible name must contain
|
|
20
|
+
* that visible text, so a speech-input user who says "click EN" hits it. The
|
|
21
|
+
* name therefore leads with the code ("EN, Change language"). The compact
|
|
22
|
+
* trigger shows only a globe icon, so its name stays the plain action label.
|
|
23
|
+
*/
|
|
24
|
+
export declare function languageTriggerLabel(currentCode: string | undefined, changeLabel: string, compact: boolean): string;
|
|
15
25
|
/**
|
|
16
26
|
* LanguageSwitcher Component
|
|
17
27
|
*
|
|
@@ -3,46 +3,50 @@ import { useAssistantControl as t } from "../shared/assistant/appControls/useAss
|
|
|
3
3
|
import { useEffect as n, useMemo as r, useRef as i, useState as a } from "react";
|
|
4
4
|
import { Fragment as o, jsx as s, jsxs as c } from "react/jsx-runtime";
|
|
5
5
|
//#region src/chrome/LanguageSwitcher.tsx
|
|
6
|
-
|
|
7
|
-
let
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
function l(e, t, n) {
|
|
7
|
+
let r = e?.trim().toUpperCase();
|
|
8
|
+
return n || !r ? t : `${r}, ${t}`;
|
|
9
|
+
}
|
|
10
|
+
var u = ({ languages: u, currentLanguage: d, onLanguageChange: f, compact: p = !1 }) => {
|
|
11
|
+
let m = e(), [h, g] = a(!1), _ = i(null), v = u.find((e) => e.code === d) || u[0], y = i({
|
|
12
|
+
languages: u,
|
|
13
|
+
currentLanguage: d,
|
|
14
|
+
onLanguageChange: f
|
|
11
15
|
});
|
|
12
|
-
|
|
13
|
-
languages:
|
|
14
|
-
currentLanguage:
|
|
15
|
-
onLanguageChange:
|
|
16
|
+
y.current = {
|
|
17
|
+
languages: u,
|
|
18
|
+
currentLanguage: d,
|
|
19
|
+
onLanguageChange: f
|
|
16
20
|
};
|
|
17
|
-
let
|
|
18
|
-
t("language", r(() =>
|
|
19
|
-
languages: () =>
|
|
21
|
+
let b = f !== void 0;
|
|
22
|
+
t("language", r(() => b ? {
|
|
23
|
+
languages: () => y.current.languages.map((e) => ({
|
|
20
24
|
code: e.code,
|
|
21
25
|
label: e.nativeName || e.name
|
|
22
26
|
})),
|
|
23
|
-
current: () =>
|
|
27
|
+
current: () => y.current.currentLanguage,
|
|
24
28
|
set: (e) => {
|
|
25
|
-
let { languages: t, currentLanguage: n, onLanguageChange: r } =
|
|
29
|
+
let { languages: t, currentLanguage: n, onLanguageChange: r } = y.current, i = t.find((t) => t.code.toLowerCase() === e.toLowerCase());
|
|
26
30
|
return !i || !r ? { status: "failed" } : i.code === n ? { status: "already" } : (r(i), { status: "done" });
|
|
27
31
|
}
|
|
28
|
-
} : null, [
|
|
32
|
+
} : null, [b])), n(() => {
|
|
29
33
|
let e = (e) => {
|
|
30
|
-
|
|
34
|
+
_.current && !_.current.contains(e.target) && g(!1);
|
|
31
35
|
};
|
|
32
|
-
if (
|
|
33
|
-
}, [
|
|
34
|
-
let
|
|
35
|
-
|
|
36
|
+
if (h) return document.addEventListener("mousedown", e), () => document.removeEventListener("mousedown", e);
|
|
37
|
+
}, [h]);
|
|
38
|
+
let x = (e) => {
|
|
39
|
+
f && f(e), g(!1);
|
|
36
40
|
};
|
|
37
41
|
return /* @__PURE__ */ c("div", {
|
|
38
42
|
className: "relative",
|
|
39
|
-
ref:
|
|
43
|
+
ref: _,
|
|
40
44
|
children: [/* @__PURE__ */ s("button", {
|
|
41
|
-
onClick: () =>
|
|
42
|
-
className: `flex items-center gap-2 px-3 py-2 rounded-button hover:bg-bg-sunken transition-colors ${
|
|
43
|
-
"aria-label":
|
|
44
|
-
"aria-expanded":
|
|
45
|
-
children:
|
|
45
|
+
onClick: () => g(!h),
|
|
46
|
+
className: `flex items-center gap-2 px-3 py-2 rounded-button hover:bg-bg-sunken transition-colors ${h ? "bg-bg-sunken" : ""}`,
|
|
47
|
+
"aria-label": l(v?.code, m("languageSwitcher.change", "Change language"), p),
|
|
48
|
+
"aria-expanded": h,
|
|
49
|
+
children: p ? /* @__PURE__ */ s("svg", {
|
|
46
50
|
className: "size-5 text-text-primary",
|
|
47
51
|
fill: "none",
|
|
48
52
|
stroke: "currentColor",
|
|
@@ -55,9 +59,9 @@ var l = ({ languages: l, currentLanguage: u, onLanguageChange: d, compact: f = !
|
|
|
55
59
|
})
|
|
56
60
|
}) : /* @__PURE__ */ c(o, { children: [/* @__PURE__ */ s("span", {
|
|
57
61
|
className: "text-body-sm font-medium text-text-primary uppercase",
|
|
58
|
-
children:
|
|
62
|
+
children: v?.code.toUpperCase()
|
|
59
63
|
}), /* @__PURE__ */ s("svg", {
|
|
60
|
-
className: `w-4 h-4 text-text-secondary transition-transform ${
|
|
64
|
+
className: `w-4 h-4 text-text-secondary transition-transform ${h ? "rotate-180" : ""}`,
|
|
61
65
|
fill: "none",
|
|
62
66
|
stroke: "currentColor",
|
|
63
67
|
viewBox: "0 0 24 24",
|
|
@@ -68,19 +72,19 @@ var l = ({ languages: l, currentLanguage: u, onLanguageChange: d, compact: f = !
|
|
|
68
72
|
d: "M19 9l-7 7-7-7"
|
|
69
73
|
})
|
|
70
74
|
})] })
|
|
71
|
-
}),
|
|
75
|
+
}), h && /* @__PURE__ */ c("div", {
|
|
72
76
|
className: "absolute right-0 mt-2 w-[calc(100vw-1.5rem)] sm:w-64 max-w-64 bg-bg-elevated border border-border-subtle rounded-card shadow-[var(--shadow-elevation-4)] z-50 py-2 overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-200",
|
|
73
77
|
children: [/* @__PURE__ */ s("div", {
|
|
74
78
|
className: "px-4 py-2 border-b border-border-subtle",
|
|
75
79
|
children: /* @__PURE__ */ s("p", {
|
|
76
80
|
className: "text-[11px] font-medium text-text-muted uppercase tracking-wider",
|
|
77
|
-
children:
|
|
81
|
+
children: m("languageSwitcher.title", "Select Language")
|
|
78
82
|
})
|
|
79
83
|
}), /* @__PURE__ */ s("div", {
|
|
80
84
|
className: "py-1 max-h-96 overflow-y-auto",
|
|
81
|
-
children:
|
|
82
|
-
onClick: () =>
|
|
83
|
-
className: `w-full flex items-center gap-3 px-4 py-2.5 text-body-sm transition-colors ${e.code ===
|
|
85
|
+
children: u.map((e) => /* @__PURE__ */ c("button", {
|
|
86
|
+
onClick: () => x(e),
|
|
87
|
+
className: `w-full flex items-center gap-3 px-4 py-2.5 text-body-sm transition-colors ${e.code === d ? "bg-[var(--color-accent-soft)] text-text-primary font-medium" : "text-text-primary hover:bg-bg-sunken"}`,
|
|
84
88
|
children: [
|
|
85
89
|
e.flag && /* @__PURE__ */ s("span", {
|
|
86
90
|
className: "text-xl flex-shrink-0",
|
|
@@ -96,7 +100,7 @@ var l = ({ languages: l, currentLanguage: u, onLanguageChange: d, compact: f = !
|
|
|
96
100
|
children: e.name
|
|
97
101
|
})]
|
|
98
102
|
}),
|
|
99
|
-
e.code ===
|
|
103
|
+
e.code === d && /* @__PURE__ */ s("svg", {
|
|
100
104
|
className: "size-5 text-action-primary-bg flex-shrink-0",
|
|
101
105
|
fill: "currentColor",
|
|
102
106
|
viewBox: "0 0 20 20",
|
|
@@ -113,4 +117,4 @@ var l = ({ languages: l, currentLanguage: u, onLanguageChange: d, compact: f = !
|
|
|
113
117
|
});
|
|
114
118
|
};
|
|
115
119
|
//#endregion
|
|
116
|
-
export {
|
|
120
|
+
export { u as LanguageSwitcher };
|
package/dist/chrome/Tabs.js
CHANGED
|
@@ -14,7 +14,7 @@ var i = ({ items: i, activeTabId: a, onTabChange: o, variant: s = "default", siz
|
|
|
14
14
|
md: "px-4 py-2 text-body-md",
|
|
15
15
|
lg: "px-6 py-3 text-body-lg"
|
|
16
16
|
}[c], l && "flex-1 justify-center");
|
|
17
|
-
return e.disabled ? t(r, "text-text-disabled cursor-not-allowed opacity-50") : s === "underline" ? t("flex shrink-0 items-center gap-1.5 whitespace-nowrap transition-colors", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-action-primary-border rounded-t", c === "sm" && "px-3 py-2 text-xs sm:gap-2 sm:px-4 sm:text-sm", c === "md" && "px-3 py-2.5 text-xs sm:gap-2 sm:px-4 sm:text-sm", c === "lg" && "px-4 py-3 text-sm sm:gap-2 sm:px-5 sm:text-body-md", "font-medium border-b-2", l && "flex-1 justify-center", e.disabled && "text-text-disabled cursor-not-allowed opacity-50", !e.disabled && (n ? "border-action-primary-bg text-
|
|
17
|
+
return e.disabled ? t(r, "text-text-disabled cursor-not-allowed opacity-50") : s === "underline" ? t("flex shrink-0 items-center gap-1.5 whitespace-nowrap transition-colors", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-action-primary-border rounded-t", c === "sm" && "px-3 py-2 text-xs sm:gap-2 sm:px-4 sm:text-sm", c === "md" && "px-3 py-2.5 text-xs sm:gap-2 sm:px-4 sm:text-sm", c === "lg" && "px-4 py-3 text-sm sm:gap-2 sm:px-5 sm:text-body-md", "font-medium border-b-2", l && "flex-1 justify-center", e.disabled && "text-text-disabled cursor-not-allowed opacity-50", !e.disabled && (n ? "border-action-primary-bg text-text-primary" : "border-transparent text-text-secondary hover:text-text-primary hover:border-border-default")) : s === "pills" ? t(r, "rounded-button", n ? "bg-action-primary-bg text-action-primary-text font-medium" : "text-text-secondary hover:text-text-primary hover:bg-action-ghost-bg-hover") : t(r, "border border-border-default", n ? "bg-bg-elevated text-text-primary font-medium border-border-strong" : "bg-bg-surface text-text-secondary hover:text-text-primary hover:bg-bg-elevated");
|
|
18
18
|
};
|
|
19
19
|
return /* @__PURE__ */ n("div", {
|
|
20
20
|
role: "tablist",
|
|
@@ -83,7 +83,7 @@ function g({ children: n, defaultMode: r = "system", defaultBrand: l = "default"
|
|
|
83
83
|
let n = document.documentElement;
|
|
84
84
|
if (n.setAttribute("data-theme", P), n.setAttribute("data-brand", t(x)), n.setAttribute("data-density", L), n.setAttribute("data-typography", R), n.setAttribute("data-glass", z), n.toggleAttribute("data-reduce-motion", B), I === "dark" ? n.classList.add("dark") : n.classList.remove("dark"), N) {
|
|
85
85
|
let e = N.accent;
|
|
86
|
-
n.style.setProperty("--color-action-primary-bg", e), n.style.setProperty("--color-action-primary-bgHover", `color-mix(in oklab, ${e} 84%, black)`), n.style.setProperty("--color-action-primary-bgActive", `color-mix(in oklab, ${e} 70%, black)`),
|
|
86
|
+
n.style.setProperty("--color-action-primary-bg", e), n.style.setProperty("--color-action-primary-bgHover", `color-mix(in oklab, ${e} 84%, black)`), n.style.setProperty("--color-action-primary-bgActive", `color-mix(in oklab, ${e} 70%, black)`), n.style.setProperty("--color-action-primary-text", p(e) ? "#0a0a0a" : "#ffffff");
|
|
87
87
|
} else n.style.removeProperty("--color-action-primary-bg"), n.style.removeProperty("--color-action-primary-bgHover"), n.style.removeProperty("--color-action-primary-bgActive"), n.style.removeProperty("--color-action-primary-text");
|
|
88
88
|
let r = () => {
|
|
89
89
|
let t = getComputedStyle(n).getPropertyValue("--color-bg-canvas").trim();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@burdenoff/fe-libs",
|
|
3
|
-
"version": "2026.
|
|
3
|
+
"version": "2026.1023.1",
|
|
4
4
|
"description": "Burdenoff frontend primitives and domain libraries",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -330,7 +330,7 @@
|
|
|
330
330
|
"test:package-livekit-optional": "node scripts/validate-livekit-optional.mjs",
|
|
331
331
|
"verify:package": "node scripts/verify-package-contents.mjs",
|
|
332
332
|
"test:handsfree-browser": "node scripts/handsfree-browser-run.mjs",
|
|
333
|
-
"test": "bun test scripts/graphql-schema-drift/__tests__ scripts/a11y-audit/__tests__ scripts/i18n/__tests__ src/credentials src/connections src/storage src/shared/assistant src/shared/feature-flags src/platform-pages src/admin-overview src/shared/components src/shared/graphql/links/sse-link.test.ts src/shared/graphql/fetch.test.ts src/shared/graphql/prepareWorkspaceRequest.test.ts src/shared/graphql/client.test.ts src/shared/utils/apollo.test.ts src/shared/utils/canvasTokens.test.ts src/shared/graphql/tourWorkspaceAuth.test.ts src/shared/config/gatewayUrls.test.ts src/shared/config/authBridgeUrls.test.ts src/shared/config/shellRoutes.test.ts src/shared/config/gatewayFallback.test.ts scripts/__tests__ src/shared/config/products.test.ts src/shared/config/docsUrls.test.ts src/shared/config/productSlug.test.ts src/shared/hooks/shell/useProducts.test.ts src/shared/hooks/shell/useGlobalSearch.test.ts src/shared/providers/shell/brandAttribute.test.ts src/chrome/ProductSwitcher.test.ts src/shared/utils/backendErrors.test.ts src/shared/utils/gatewayFetchRewrite.test.ts src/shared/utils/authCookie.test.ts src/shared/utils/redirectAllowlist.test.ts src/shared/providers/shell/ProfileStorageManager.test.ts src/shared/providers/AppShellProvider.test.ts src/shared/providers/shell/interpolate.test.ts src/shared/providers/shell/resolveTranslation.test.ts src/shared/providers/shell/I18nProvider.render.test.tsx src/shared/providers/shell/I18nProvider.client.test.tsx src/shared/providers/shell/translationStore.test.ts src/shared/providers/shell/translationLayers.test.ts src/shared/providers/shell/directionMode.test.ts src/shared/providers/shell/authProvider/ssoRelay.test.ts src/shared/providers/shell/authProvider/useAuthActions.signOut.test.tsx src/shared/providers/shell/personaSurfaceRemote.test.ts src/shared/providers/shell/workspaceSwitch.client.test.tsx src/shared/events/sinks/RybbitSink.test.ts src/shared/events/sinks/RybbitSink.client.test.tsx src/shell-native src/shared/native src/shared/navigation src/shared/tours src/chrome/__tests__ src/ui/__tests__",
|
|
333
|
+
"test": "bun test scripts/graphql-schema-drift/__tests__ scripts/a11y-audit/__tests__ scripts/i18n/__tests__ src/credentials src/connections src/storage src/shared/assistant src/shared/feature-flags src/platform-pages src/admin-overview src/shared/components src/shared/graphql/links/sse-link.test.ts src/shared/graphql/fetch.test.ts src/shared/graphql/prepareWorkspaceRequest.test.ts src/shared/graphql/client.test.ts src/shared/utils/apollo.test.ts src/shared/utils/canvasTokens.test.ts src/shared/utils/brandContrast.test.ts src/shared/graphql/tourWorkspaceAuth.test.ts src/shared/config/gatewayUrls.test.ts src/shared/config/authBridgeUrls.test.ts src/shared/config/shellRoutes.test.ts src/shared/config/gatewayFallback.test.ts scripts/__tests__ src/shared/config/products.test.ts src/shared/config/docsUrls.test.ts src/shared/config/productSlug.test.ts src/shared/hooks/shell/useProducts.test.ts src/shared/hooks/shell/useGlobalSearch.test.ts src/shared/providers/shell/brandAttribute.test.ts src/chrome/ProductSwitcher.test.ts src/shared/utils/backendErrors.test.ts src/shared/utils/gatewayFetchRewrite.test.ts src/shared/utils/authCookie.test.ts src/shared/utils/redirectAllowlist.test.ts src/shared/providers/shell/ProfileStorageManager.test.ts src/shared/providers/AppShellProvider.test.ts src/shared/providers/shell/interpolate.test.ts src/shared/providers/shell/resolveTranslation.test.ts src/shared/providers/shell/I18nProvider.render.test.tsx src/shared/providers/shell/I18nProvider.client.test.tsx src/shared/providers/shell/translationStore.test.ts src/shared/providers/shell/translationLayers.test.ts src/shared/providers/shell/directionMode.test.ts src/shared/providers/shell/authProvider/ssoRelay.test.ts src/shared/providers/shell/authProvider/useAuthActions.signOut.test.tsx src/shared/providers/shell/personaSurfaceRemote.test.ts src/shared/providers/shell/workspaceSwitch.client.test.tsx src/shared/events/sinks/RybbitSink.test.ts src/shared/events/sinks/RybbitSink.client.test.tsx src/shell-native src/shared/native src/shared/navigation src/shared/tours src/chrome/__tests__ src/ui/__tests__",
|
|
334
334
|
"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 && bun run verify:package'",
|
|
335
335
|
"switch:status": "bun scripts/deps-mode.ts status",
|
|
336
336
|
"switch:remote": "bun scripts/deps-mode.ts remote",
|
|
@@ -110,6 +110,69 @@ how you point the same config at alpha or prod.
|
|
|
110
110
|
|
|
111
111
|
Per route you can also set `waitForSelector` and a route-specific `settleMs`.
|
|
112
112
|
|
|
113
|
+
### Signed-in routes: `fixtures`
|
|
114
|
+
|
|
115
|
+
External requests are blocked, so a signed-in page cannot reach its gateway and
|
|
116
|
+
the shell bounces to `/auth/login`. A **fixture** gives a route a deterministic
|
|
117
|
+
session instead of a live one:
|
|
118
|
+
|
|
119
|
+
```jsonc
|
|
120
|
+
// a11y-audit.config.json
|
|
121
|
+
"routes": [
|
|
122
|
+
{ "path": "/vibecontrols", "name": "signed-in-overview", "fixture": "signed-in",
|
|
123
|
+
"waitForSelector": "main#main-content h1", "minTextLength": 1500 }
|
|
124
|
+
],
|
|
125
|
+
"fixtures": { "signed-in": "a11y/fixtures/signed-in.json" } // or the object inline
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
```jsonc
|
|
129
|
+
// a11y/fixtures/signed-in.json
|
|
130
|
+
{
|
|
131
|
+
"localStorage": {
|
|
132
|
+
"bf-p-<profile>-tokens": {
|
|
133
|
+
"accessToken": { "$jwt": { "sub": "<user>", "exp": { "$fromNow": 3600 } } },
|
|
134
|
+
"expiresAt": { "$fromNowMs": 3600000 }
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
"cookies": { "bf-sso": { "id": "<profile>", "origin": null } },
|
|
138
|
+
"graphql": {
|
|
139
|
+
"endpoints": ["/global/graphql", "/workspaces/graphql"],
|
|
140
|
+
"operations": { "ValidateAuthToken": { "data": { "validateTokens": { "valid": true } } } }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
| Field | Meaning |
|
|
146
|
+
| --- | --- |
|
|
147
|
+
| `localStorage` | Seeded before any page script runs (same-origin documents only). Strings are stored as-is, anything else as JSON. |
|
|
148
|
+
| `cookies` | Set on the app origin before the page opens. Strings as-is, anything else as URL-encoded JSON. |
|
|
149
|
+
| `graphql.endpoints` | Path suffixes that identify a GraphQL endpoint on **any** origin. Matching requests never leave the browser. |
|
|
150
|
+
| `graphql.operations` | Response body per operation name. Batched requests get a batched answer in order. |
|
|
151
|
+
|
|
152
|
+
Markers, resolved when each page opens:
|
|
153
|
+
|
|
154
|
+
- `{ "$jwt": { ...claims } }` becomes an **unsigned** (`alg: none`) token. Never
|
|
155
|
+
put a real or real-looking credential in a fixture.
|
|
156
|
+
- `{ "$fromNow": seconds }` becomes unix seconds from now (JWT `exp`/`iat`);
|
|
157
|
+
`{ "$fromNowMs": ms }` becomes epoch milliseconds from now (`expiresAt`). Do
|
|
158
|
+
not use a fixed far-future expiry: shells schedule refresh timers off it, and
|
|
159
|
+
a delay past setTimeout's 2^31-1 ms ceiling fires immediately.
|
|
160
|
+
|
|
161
|
+
Guarantees:
|
|
162
|
+
|
|
163
|
+
- Every route runs in its own browser context, so a fixture's session never
|
|
164
|
+
leaks into the signed-out routes (or the other way round).
|
|
165
|
+
- A fixture route that ends anywhere other than its `path` (the query string is
|
|
166
|
+
ignored) is a failed attempt: landing on `/auth/login` means the fixture did
|
|
167
|
+
not sign in, and auditing the login page again would be a false pass. Set
|
|
168
|
+
`allowRedirect` on the route only if the redirect is the page.
|
|
169
|
+
- An operation the fixture has no answer for gets a GraphQL error response
|
|
170
|
+
(code `A11Y_FIXTURE_UNMOCKED`), never a live call, and is listed under the
|
|
171
|
+
route in the console and step-summary report. Keep that list empty: an
|
|
172
|
+
unmocked query usually means part of the page rendered its error state.
|
|
173
|
+
- Fixture data is synthetic. Use obviously fake ids and names, never an export
|
|
174
|
+
of a real account.
|
|
175
|
+
|
|
113
176
|
### `failOn`
|
|
114
177
|
|
|
115
178
|
Defaults to `["critical", "serious"]`. It may be **widened** (adding `moderate`),
|
|
@@ -9,9 +9,16 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { partitionNodes, flattenViolations } from './allowlist';
|
|
12
|
+
import {
|
|
13
|
+
materializeCookies,
|
|
14
|
+
materializeLocalStorage,
|
|
15
|
+
matchesGraphqlEndpoint,
|
|
16
|
+
respondToGraphql,
|
|
17
|
+
seedLocalStorageScript,
|
|
18
|
+
} from './fixtures';
|
|
12
19
|
import type { AxeViolationLike } from './allowlist';
|
|
13
20
|
import type { RunningApp } from './server';
|
|
14
|
-
import type { AuditReport, ResolvedConfig, RouteResult } from './types';
|
|
21
|
+
import type { AuditReport, FixtureSpec, ResolvedConfig, RouteResult } from './types';
|
|
15
22
|
|
|
16
23
|
/* eslint-disable @typescript-eslint/no-explicit-any -- dynamically imported
|
|
17
24
|
third-party modules have no static types available inside this script. */
|
|
@@ -124,14 +131,73 @@ export async function runAudit(
|
|
|
124
131
|
const usedEntries = new Set<number>();
|
|
125
132
|
const navigationErrors: string[] = [];
|
|
126
133
|
|
|
127
|
-
/**
|
|
128
|
-
|
|
134
|
+
/**
|
|
135
|
+
* Fresh page with request routing applied — used for the first try and every
|
|
136
|
+
* retry. With a fixture, its session is seeded before any page script runs
|
|
137
|
+
* and its GraphQL endpoints are answered from the fixture (recording any
|
|
138
|
+
* operation it has no answer for in `unmocked`).
|
|
139
|
+
*/
|
|
140
|
+
const openPage = async (
|
|
141
|
+
context: AnyRecord,
|
|
142
|
+
fixture: FixtureSpec | undefined,
|
|
143
|
+
unmocked: Set<string>
|
|
144
|
+
): Promise<AnyRecord> => {
|
|
145
|
+
if (fixture?.cookies) {
|
|
146
|
+
await context.addCookies(
|
|
147
|
+
materializeCookies(fixture).map((cookie) => ({
|
|
148
|
+
...cookie,
|
|
149
|
+
url: baseUrl,
|
|
150
|
+
}))
|
|
151
|
+
);
|
|
152
|
+
}
|
|
129
153
|
const page = await context.newPage();
|
|
130
|
-
if (
|
|
154
|
+
if (fixture?.localStorage) {
|
|
155
|
+
await page.addInitScript(seedLocalStorageScript, {
|
|
156
|
+
origin: new URL(baseUrl).origin,
|
|
157
|
+
entries: materializeLocalStorage(fixture),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
if (config.blockExternalRequests || resolveAsset || fixture?.graphql) {
|
|
131
161
|
await page.route('**/*', (route: AnyRecord) => {
|
|
132
|
-
const
|
|
162
|
+
const request = route.request();
|
|
163
|
+
const requestUrl: string = request.url();
|
|
133
164
|
const isOwnOrigin = requestUrl.startsWith(baseUrl);
|
|
134
165
|
|
|
166
|
+
// Fixture GraphQL first: on any origin, a matching endpoint is answered
|
|
167
|
+
// from the fixture and never reaches a live gateway.
|
|
168
|
+
if (fixture?.graphql && matchesGraphqlEndpoint(requestUrl, fixture)) {
|
|
169
|
+
if (request.method() === 'OPTIONS') {
|
|
170
|
+
void route.fulfill({
|
|
171
|
+
status: 204,
|
|
172
|
+
headers: {
|
|
173
|
+
'access-control-allow-origin': new URL(baseUrl).origin,
|
|
174
|
+
'access-control-allow-credentials': 'true',
|
|
175
|
+
'access-control-allow-methods': 'GET, POST, OPTIONS',
|
|
176
|
+
'access-control-allow-headers': '*',
|
|
177
|
+
},
|
|
178
|
+
body: '',
|
|
179
|
+
});
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const answer = respondToGraphql(
|
|
183
|
+
fixture,
|
|
184
|
+
request.method() as string,
|
|
185
|
+
requestUrl,
|
|
186
|
+
(request.postData() as string | null) ?? null
|
|
187
|
+
);
|
|
188
|
+
answer.unmocked.forEach((name) => unmocked.add(name));
|
|
189
|
+
void route.fulfill({
|
|
190
|
+
status: answer.status,
|
|
191
|
+
contentType: 'application/json',
|
|
192
|
+
headers: {
|
|
193
|
+
'access-control-allow-origin': new URL(baseUrl).origin,
|
|
194
|
+
'access-control-allow-credentials': 'true',
|
|
195
|
+
},
|
|
196
|
+
body: answer.body,
|
|
197
|
+
});
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
135
201
|
// Serve the build's own files straight from disk. Chromium never opens
|
|
136
202
|
// a socket for them, so a spurious network-change notification cannot
|
|
137
203
|
// cancel a hundred in-flight chunk loads and leave the shell blank.
|
|
@@ -146,7 +212,11 @@ export async function runAudit(
|
|
|
146
212
|
});
|
|
147
213
|
return;
|
|
148
214
|
}
|
|
149
|
-
void route.fulfill({
|
|
215
|
+
void route.fulfill({
|
|
216
|
+
status: 404,
|
|
217
|
+
contentType: 'text/plain',
|
|
218
|
+
body: 'Not found',
|
|
219
|
+
});
|
|
150
220
|
return;
|
|
151
221
|
}
|
|
152
222
|
|
|
@@ -162,27 +232,34 @@ export async function runAudit(
|
|
|
162
232
|
return page;
|
|
163
233
|
};
|
|
164
234
|
|
|
235
|
+
// Every route gets its own browser context, so a fixture's seeded session
|
|
236
|
+
// can never leak into the signed-out routes audited after it (or the other
|
|
237
|
+
// way round), and route order cannot change what axe sees.
|
|
238
|
+
const newContext = async (viewport: { width: number; height: number }): Promise<AnyRecord> =>
|
|
239
|
+
browser.newContext({
|
|
240
|
+
viewport: { width: viewport.width, height: viewport.height },
|
|
241
|
+
// Deterministic rendering: no OS locale/timezone drift between the dev
|
|
242
|
+
// machine and the CI runner changing what axe sees.
|
|
243
|
+
locale: 'en-US',
|
|
244
|
+
timezoneId: 'UTC',
|
|
245
|
+
reducedMotion: 'reduce',
|
|
246
|
+
// A service worker caching the shell across routes would make the audit
|
|
247
|
+
// depend on which route ran first.
|
|
248
|
+
serviceWorkers: 'block',
|
|
249
|
+
});
|
|
250
|
+
|
|
165
251
|
try {
|
|
166
252
|
for (const viewport of config.viewports) {
|
|
167
|
-
const context = await browser.newContext({
|
|
168
|
-
viewport: { width: viewport.width, height: viewport.height },
|
|
169
|
-
// Deterministic rendering: no OS locale/timezone drift between the dev
|
|
170
|
-
// machine and the CI runner changing what axe sees.
|
|
171
|
-
locale: 'en-US',
|
|
172
|
-
timezoneId: 'UTC',
|
|
173
|
-
reducedMotion: 'reduce',
|
|
174
|
-
// A service worker caching the shell across routes would make the audit
|
|
175
|
-
// depend on which route ran first.
|
|
176
|
-
serviceWorkers: 'block',
|
|
177
|
-
});
|
|
178
|
-
|
|
179
253
|
for (const route of config.routes) {
|
|
180
254
|
const url = `${baseUrl}${route.path}`;
|
|
181
255
|
const attemptFailures: string[] = [];
|
|
182
256
|
let result: RouteResult | null = null;
|
|
257
|
+
const fixture = route.fixture ? config.fixtures[route.fixture] : undefined;
|
|
183
258
|
|
|
184
259
|
for (let attempt = 1; attempt <= config.routeAttempts && result === null; attempt += 1) {
|
|
185
|
-
const
|
|
260
|
+
const context = await newContext(viewport);
|
|
261
|
+
const unmocked = new Set<string>();
|
|
262
|
+
const page = await openPage(context, fixture, unmocked);
|
|
186
263
|
try {
|
|
187
264
|
const response = await page.goto(url, {
|
|
188
265
|
waitUntil: 'load',
|
|
@@ -203,6 +280,18 @@ export async function runAudit(
|
|
|
203
280
|
config.domQuietMs,
|
|
204
281
|
config.navigationTimeoutMs
|
|
205
282
|
);
|
|
283
|
+
if (fixture && !route.allowRedirect) {
|
|
284
|
+
const landed = new URL(page.url() as string).pathname.replace(/\/+$/, '') || '/';
|
|
285
|
+
const expected = new URL(url).pathname.replace(/\/+$/, '') || '/';
|
|
286
|
+
if (landed !== expected) {
|
|
287
|
+
throw new Error(
|
|
288
|
+
`fixture "${route.fixture}" route ended on ${landed} instead of ${expected} — ` +
|
|
289
|
+
'the fixture did not produce the page, so auditing where it landed would be ' +
|
|
290
|
+
'a false pass' +
|
|
291
|
+
(unmocked.size > 0 ? ` (unmocked operations: ${[...unmocked].join(', ')})` : '')
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
206
295
|
const floor = route.minTextLength ?? config.minTextLength;
|
|
207
296
|
if (stability.snapshot.textLength < floor) {
|
|
208
297
|
throw new Error(
|
|
@@ -237,11 +326,18 @@ export async function runAudit(
|
|
|
237
326
|
renderedTextLength: stability.snapshot.textLength,
|
|
238
327
|
domSettled: stability.settled,
|
|
239
328
|
attempts: attempt,
|
|
329
|
+
...(route.fixture
|
|
330
|
+
? {
|
|
331
|
+
fixture: route.fixture,
|
|
332
|
+
unmockedOperations: [...unmocked].sort(),
|
|
333
|
+
}
|
|
334
|
+
: {}),
|
|
240
335
|
};
|
|
241
336
|
} catch (error) {
|
|
242
337
|
attemptFailures.push(`attempt ${attempt}: ${(error as Error).message}`);
|
|
243
338
|
} finally {
|
|
244
339
|
await page.close();
|
|
340
|
+
await context.close();
|
|
245
341
|
}
|
|
246
342
|
}
|
|
247
343
|
|
|
@@ -259,8 +355,6 @@ export async function runAudit(
|
|
|
259
355
|
}
|
|
260
356
|
results.push(result);
|
|
261
357
|
}
|
|
262
|
-
|
|
263
|
-
await context.close();
|
|
264
358
|
}
|
|
265
359
|
} finally {
|
|
266
360
|
await browser.close();
|
|
@@ -279,7 +373,11 @@ export async function runAudit(
|
|
|
279
373
|
.filter((entry) => !usedEntries.has(entry.index));
|
|
280
374
|
|
|
281
375
|
const expiredAllowlistEntries = config.allowlist
|
|
282
|
-
.map((entry, index) => ({
|
|
376
|
+
.map((entry, index) => ({
|
|
377
|
+
index,
|
|
378
|
+
rule: entry.rule,
|
|
379
|
+
expires: entry.expires ?? '',
|
|
380
|
+
}))
|
|
283
381
|
.filter((entry) => {
|
|
284
382
|
if (!entry.expires) return false;
|
|
285
383
|
return now.getTime() > new Date(`${entry.expires}T23:59:59.999Z`).getTime();
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
27
27
|
import { dirname, resolve } from 'node:path';
|
|
28
28
|
import { runAudit } from './audit';
|
|
29
|
-
import { ConfigError, resolveConfig } from './config';
|
|
29
|
+
import { ConfigError, inlineFixtureFiles, resolveConfig } from './config';
|
|
30
30
|
import { renderConsole, renderMarkdown } from './report';
|
|
31
31
|
import { findUnreachableRouteScopes } from './allowlist';
|
|
32
32
|
import { startApp } from './server';
|
|
@@ -40,7 +40,11 @@ interface CliArgs {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
export function parseArgs(argv: string[]): CliArgs {
|
|
43
|
-
const args: CliArgs = {
|
|
43
|
+
const args: CliArgs = {
|
|
44
|
+
configPath: 'a11y-audit.config.json',
|
|
45
|
+
routes: [],
|
|
46
|
+
quiet: false,
|
|
47
|
+
};
|
|
44
48
|
let positionalSeen = false;
|
|
45
49
|
for (const arg of argv) {
|
|
46
50
|
if (arg.startsWith('--base-url=')) args.baseUrl = arg.slice('--base-url='.length);
|
|
@@ -77,7 +81,10 @@ async function main(): Promise<number> {
|
|
|
77
81
|
|
|
78
82
|
let config;
|
|
79
83
|
try {
|
|
80
|
-
|
|
84
|
+
const raw: unknown = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
85
|
+
config = resolveConfig(
|
|
86
|
+
inlineFixtureFiles(raw, dirname(configPath), (path) => readFileSync(path, 'utf8'))
|
|
87
|
+
);
|
|
81
88
|
} catch (error) {
|
|
82
89
|
if (error instanceof ConfigError) console.error(`[a11y-audit] ${error.message}`);
|
|
83
90
|
else console.error(`[a11y-audit] Failed to read ${configPath}: ${(error as Error).message}`);
|
|
@@ -11,6 +11,7 @@ import { IMPACT_LEVELS } from './types';
|
|
|
11
11
|
import type {
|
|
12
12
|
A11yAuditConfig,
|
|
13
13
|
AllowlistEntry,
|
|
14
|
+
FixtureSpec,
|
|
14
15
|
ImpactLevel,
|
|
15
16
|
ResolvedConfig,
|
|
16
17
|
RouteSpec,
|
|
@@ -187,6 +188,73 @@ function validateRoute(route: unknown, index: number, issues: string[]): void {
|
|
|
187
188
|
}
|
|
188
189
|
}
|
|
189
190
|
|
|
191
|
+
function validateFixture(name: string, fixture: unknown, issues: string[]): void {
|
|
192
|
+
const where = `fixtures.${name}`;
|
|
193
|
+
if (typeof fixture === 'string') {
|
|
194
|
+
issues.push(
|
|
195
|
+
`${where} is still a file reference ("${fixture}") — load the config with ` +
|
|
196
|
+
'inlineFixtureFiles() before resolving it'
|
|
197
|
+
);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (!isPlainObject(fixture)) {
|
|
201
|
+
issues.push(`${where} must be an object`);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
for (const key of Object.keys(fixture)) {
|
|
205
|
+
if (key !== 'localStorage' && key !== 'cookies' && key !== 'graphql') {
|
|
206
|
+
issues.push(
|
|
207
|
+
`${where}.${key} is not a fixture field (allowed: localStorage, cookies, graphql)`
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (
|
|
212
|
+
fixture.localStorage === undefined &&
|
|
213
|
+
fixture.cookies === undefined &&
|
|
214
|
+
fixture.graphql === undefined
|
|
215
|
+
) {
|
|
216
|
+
issues.push(
|
|
217
|
+
`${where} must set localStorage, cookies and/or graphql — an empty fixture changes nothing`
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
for (const key of ['localStorage', 'cookies'] as const) {
|
|
221
|
+
if (fixture[key] !== undefined && !isPlainObject(fixture[key])) {
|
|
222
|
+
issues.push(`${where}.${key} must be an object of name → value`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (fixture.graphql !== undefined) {
|
|
226
|
+
const graphql = fixture.graphql;
|
|
227
|
+
if (!isPlainObject(graphql)) {
|
|
228
|
+
issues.push(`${where}.graphql must be { endpoints, operations }`);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (
|
|
232
|
+
!Array.isArray(graphql.endpoints) ||
|
|
233
|
+
graphql.endpoints.length === 0 ||
|
|
234
|
+
!graphql.endpoints.every(isNonEmptyString)
|
|
235
|
+
) {
|
|
236
|
+
issues.push(`${where}.graphql.endpoints must be a non-empty array of path suffixes`);
|
|
237
|
+
} else {
|
|
238
|
+
graphql.endpoints.forEach((endpoint, i) => {
|
|
239
|
+
if (!endpoint.startsWith('/')) {
|
|
240
|
+
issues.push(`${where}.graphql.endpoints[${i}] "${endpoint}" must start with "/"`);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
if (!isPlainObject(graphql.operations) || Object.keys(graphql.operations).length === 0) {
|
|
245
|
+
issues.push(`${where}.graphql.operations must be a non-empty object of operation → response`);
|
|
246
|
+
} else {
|
|
247
|
+
for (const [operation, result] of Object.entries(graphql.operations)) {
|
|
248
|
+
if (!isPlainObject(result) || (!('data' in result) && !('errors' in result))) {
|
|
249
|
+
issues.push(
|
|
250
|
+
`${where}.graphql.operations.${operation} must be a GraphQL response ({ data } or { errors })`
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
190
258
|
function validateServe(serve: unknown, issues: string[]): void {
|
|
191
259
|
if (!isPlainObject(serve)) {
|
|
192
260
|
issues.push('serve is required and must be an object');
|
|
@@ -286,6 +354,28 @@ export function resolveConfig(raw: unknown): ResolvedConfig {
|
|
|
286
354
|
}
|
|
287
355
|
}
|
|
288
356
|
|
|
357
|
+
const fixtureNames = new Set<string>();
|
|
358
|
+
if (raw.fixtures !== undefined) {
|
|
359
|
+
if (!isPlainObject(raw.fixtures)) {
|
|
360
|
+
issues.push('fixtures must be an object of name → fixture when present');
|
|
361
|
+
} else {
|
|
362
|
+
for (const [name, fixture] of Object.entries(raw.fixtures)) {
|
|
363
|
+
fixtureNames.add(name);
|
|
364
|
+
validateFixture(name, fixture, issues);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (Array.isArray(raw.routes)) {
|
|
369
|
+
raw.routes.forEach((route, i) => {
|
|
370
|
+
if (!isPlainObject(route) || route.fixture === undefined) return;
|
|
371
|
+
if (!isNonEmptyString(route.fixture)) {
|
|
372
|
+
issues.push(`routes[${i}].fixture must be a non-empty fixture name when present`);
|
|
373
|
+
} else if (!fixtureNames.has(route.fixture)) {
|
|
374
|
+
issues.push(`routes[${i}].fixture "${route.fixture}" is not defined in fixtures`);
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
289
379
|
if (issues.length > 0) throw new ConfigError(issues);
|
|
290
380
|
|
|
291
381
|
const config = raw as unknown as A11yAuditConfig;
|
|
@@ -315,5 +405,38 @@ export function resolveConfig(raw: unknown): ResolvedConfig {
|
|
|
315
405
|
domQuietMs: config.domQuietMs ?? 1500,
|
|
316
406
|
minTextLength: config.minTextLength ?? 100,
|
|
317
407
|
routeAttempts: config.routeAttempts ?? 3,
|
|
408
|
+
// Validated above: no string (file) entries survive to this point.
|
|
409
|
+
fixtures: (config.fixtures ?? {}) as Record<string, FixtureSpec>,
|
|
318
410
|
};
|
|
319
411
|
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Replace every fixture given as a file path with the parsed file. Paths
|
|
415
|
+
* resolve against `baseDir` (the config file's directory). `readFile` is
|
|
416
|
+
* injected so this stays pure and testable.
|
|
417
|
+
*
|
|
418
|
+
* @throws ConfigError when a referenced file is missing or is not JSON.
|
|
419
|
+
*/
|
|
420
|
+
export function inlineFixtureFiles(
|
|
421
|
+
raw: unknown,
|
|
422
|
+
baseDir: string,
|
|
423
|
+
readFile: (absolutePath: string) => string
|
|
424
|
+
): unknown {
|
|
425
|
+
if (!isPlainObject(raw) || !isPlainObject(raw.fixtures)) return raw;
|
|
426
|
+
const issues: string[] = [];
|
|
427
|
+
const fixtures: Record<string, unknown> = {};
|
|
428
|
+
for (const [name, fixture] of Object.entries(raw.fixtures)) {
|
|
429
|
+
if (typeof fixture !== 'string') {
|
|
430
|
+
fixtures[name] = fixture;
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
const path = fixture.startsWith('/') ? fixture : `${baseDir.replace(/\/+$/, '')}/${fixture}`;
|
|
434
|
+
try {
|
|
435
|
+
fixtures[name] = JSON.parse(readFile(path));
|
|
436
|
+
} catch (error) {
|
|
437
|
+
issues.push(`fixtures.${name}: cannot load "${fixture}" (${(error as Error).message})`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
if (issues.length > 0) throw new ConfigError(issues);
|
|
441
|
+
return { ...raw, fixtures };
|
|
442
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route fixtures: audit a signed-in page without a live backend.
|
|
3
|
+
*
|
|
4
|
+
* The gate blocks every request that leaves the app's own origin so the DOM it
|
|
5
|
+
* audits is reproducible. That is exactly why signed-in pages were never
|
|
6
|
+
* audited: with the gateway unreachable the shell bounces to /auth/login and
|
|
7
|
+
* the audit measures the login page a second time.
|
|
8
|
+
*
|
|
9
|
+
* A fixture fixes both halves deterministically:
|
|
10
|
+
*
|
|
11
|
+
* - `localStorage` is seeded before any page script runs, so the shell boots
|
|
12
|
+
* with a session. Values may embed `{ "$jwt": { ...claims } }`, which is
|
|
13
|
+
* encoded here into an UNSIGNED token. Nothing in a repo ever holds a real
|
|
14
|
+
* or real-looking credential, and the claims (exp, workspaceId, ...) stay
|
|
15
|
+
* readable in the fixture file. Expiries use `{ "$fromNow": seconds }` so
|
|
16
|
+
* a session is always valid for a realistic window when the page opens.
|
|
17
|
+
* - `cookies` are set on the app origin, for shells that cross-check the
|
|
18
|
+
* session against a cookie marker.
|
|
19
|
+
* - `graphql.operations` answers GraphQL requests to the configured
|
|
20
|
+
* endpoints by operation name, batched or not. An operation the fixture
|
|
21
|
+
* does not cover gets a GraphQL error response (never a live call) and is
|
|
22
|
+
* recorded in the report, so fixture drift is visible instead of silent.
|
|
23
|
+
*
|
|
24
|
+
* Everything in this file is pure, so it is unit-tested without a browser.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { FixtureSpec, GraphqlResult } from './types';
|
|
28
|
+
|
|
29
|
+
/** Marker key: `{ "$jwt": { ...claims } }` becomes an unsigned JWT string. */
|
|
30
|
+
export const JWT_MARKER = '$jwt';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Time markers, resolved when the page is opened:
|
|
34
|
+
* `{ "$fromNow": 3600 }` → unix SECONDS one hour from now (JWT `exp`/`iat`),
|
|
35
|
+
* `{ "$fromNowMs": 3600000 }` → epoch MILLISECONDS one hour from now
|
|
36
|
+
* (`expiresAt` fields). A fixed far-future expiry is not a safe substitute:
|
|
37
|
+
* shells schedule refresh timers off it, and a delay past setTimeout's 2^31-1
|
|
38
|
+
* ms ceiling fires immediately — the session "expires" on load.
|
|
39
|
+
*/
|
|
40
|
+
export const FROM_NOW_MARKER = '$fromNow';
|
|
41
|
+
export const FROM_NOW_MS_MARKER = '$fromNowMs';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Signature segment of every fixture token. It is not a signature; it says so,
|
|
45
|
+
* and it keeps the three-segment shape clients check for.
|
|
46
|
+
*/
|
|
47
|
+
export const UNSIGNED_SIGNATURE = 'a11y-fixture-unsigned';
|
|
48
|
+
|
|
49
|
+
/** Error code carried by the response for an operation the fixture lacks. */
|
|
50
|
+
export const UNMOCKED_CODE = 'A11Y_FIXTURE_UNMOCKED';
|
|
51
|
+
|
|
52
|
+
function base64Url(value: string): string {
|
|
53
|
+
return Buffer.from(value, 'utf8')
|
|
54
|
+
.toString('base64')
|
|
55
|
+
.replace(/\+/g, '-')
|
|
56
|
+
.replace(/\//g, '_')
|
|
57
|
+
.replace(/=+$/, '');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Encode claims as an unsigned JWT (`alg: none`). For fixtures only. */
|
|
61
|
+
export function encodeUnsignedJwt(claims: Record<string, unknown>): string {
|
|
62
|
+
const header = base64Url(JSON.stringify({ alg: 'none', typ: 'JWT' }));
|
|
63
|
+
const payload = base64Url(JSON.stringify(claims));
|
|
64
|
+
return `${header}.${payload}.${UNSIGNED_SIGNATURE}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
68
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function soleKey(value: unknown): string | null {
|
|
72
|
+
if (!isPlainObject(value)) return null;
|
|
73
|
+
const keys = Object.keys(value);
|
|
74
|
+
return keys.length === 1 ? keys[0] : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Replace every marker in a JSON value: `{ "$jwt": {...} }` with its encoded
|
|
79
|
+
* token (claims are expanded first, so `exp` may itself be a `$fromNow`), and
|
|
80
|
+
* `{ "$fromNow": s }` / `{ "$fromNowMs": ms }` with a timestamp relative to
|
|
81
|
+
* `nowMs`.
|
|
82
|
+
*/
|
|
83
|
+
export function expandMarkers(value: unknown, nowMs: number = Date.now()): unknown {
|
|
84
|
+
const key = soleKey(value);
|
|
85
|
+
if (key !== null && isPlainObject(value)) {
|
|
86
|
+
const inner = value[key];
|
|
87
|
+
if (key === JWT_MARKER && isPlainObject(inner)) {
|
|
88
|
+
return encodeUnsignedJwt(expandMarkers(inner, nowMs) as Record<string, unknown>);
|
|
89
|
+
}
|
|
90
|
+
if (key === FROM_NOW_MARKER && typeof inner === 'number') {
|
|
91
|
+
return Math.floor(nowMs / 1000) + inner;
|
|
92
|
+
}
|
|
93
|
+
if (key === FROM_NOW_MS_MARKER && typeof inner === 'number') {
|
|
94
|
+
return nowMs + inner;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (Array.isArray(value)) return value.map((item) => expandMarkers(item, nowMs));
|
|
98
|
+
if (isPlainObject(value)) {
|
|
99
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, expandMarkers(v, nowMs)]));
|
|
100
|
+
}
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The exact strings written to localStorage. A string value is stored as-is;
|
|
106
|
+
* anything else is stored as JSON, which is how persisted stores read it back.
|
|
107
|
+
*/
|
|
108
|
+
export function materializeLocalStorage(
|
|
109
|
+
spec: FixtureSpec,
|
|
110
|
+
nowMs: number = Date.now()
|
|
111
|
+
): Record<string, string> {
|
|
112
|
+
const entries = Object.entries(spec.localStorage ?? {});
|
|
113
|
+
return Object.fromEntries(
|
|
114
|
+
entries.map(([key, value]) => {
|
|
115
|
+
const expanded = expandMarkers(value, nowMs);
|
|
116
|
+
return [key, typeof expanded === 'string' ? expanded : JSON.stringify(expanded)];
|
|
117
|
+
})
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Cookies set on the app origin before the page opens. A string value is used
|
|
123
|
+
* as-is; anything else is stored as URL-encoded JSON, the encoding shells use
|
|
124
|
+
* for their own JSON cookies (e.g. the `bf-sso` session marker).
|
|
125
|
+
*/
|
|
126
|
+
export function materializeCookies(
|
|
127
|
+
spec: FixtureSpec,
|
|
128
|
+
nowMs: number = Date.now()
|
|
129
|
+
): Array<{ name: string; value: string }> {
|
|
130
|
+
return Object.entries(spec.cookies ?? {}).map(([name, value]) => {
|
|
131
|
+
const expanded = expandMarkers(value, nowMs);
|
|
132
|
+
return {
|
|
133
|
+
name,
|
|
134
|
+
value: typeof expanded === 'string' ? expanded : encodeURIComponent(JSON.stringify(expanded)),
|
|
135
|
+
};
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** True when `url` is a request to one of the fixture's GraphQL endpoints. */
|
|
140
|
+
export function matchesGraphqlEndpoint(url: string, spec: FixtureSpec): boolean {
|
|
141
|
+
const endpoints = spec.graphql?.endpoints ?? [];
|
|
142
|
+
if (endpoints.length === 0) return false;
|
|
143
|
+
let pathname: string;
|
|
144
|
+
try {
|
|
145
|
+
pathname = new URL(url).pathname.replace(/\/+$/, '');
|
|
146
|
+
} catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
return endpoints.some((endpoint) => pathname.endsWith(endpoint.replace(/\/+$/, '')));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
interface GraphqlRequestLike {
|
|
153
|
+
operationName?: unknown;
|
|
154
|
+
query?: unknown;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const OPERATION_NAME = /\b(?:query|mutation|subscription)\s+([A-Za-z_][A-Za-z0-9_]*)/;
|
|
158
|
+
|
|
159
|
+
/** Operation name of one GraphQL request, from `operationName` or the document. */
|
|
160
|
+
export function operationNameOf(request: GraphqlRequestLike): string {
|
|
161
|
+
if (typeof request.operationName === 'string' && request.operationName.length > 0) {
|
|
162
|
+
return request.operationName;
|
|
163
|
+
}
|
|
164
|
+
if (typeof request.query === 'string') {
|
|
165
|
+
const match = OPERATION_NAME.exec(request.query);
|
|
166
|
+
if (match) return match[1];
|
|
167
|
+
}
|
|
168
|
+
return '(anonymous)';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface FixtureResponse {
|
|
172
|
+
status: number;
|
|
173
|
+
body: string;
|
|
174
|
+
/** Operations in this request the fixture had no answer for. */
|
|
175
|
+
unmocked: string[];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function unmockedResult(name: string): GraphqlResult {
|
|
179
|
+
return {
|
|
180
|
+
data: null,
|
|
181
|
+
errors: [
|
|
182
|
+
{
|
|
183
|
+
message: `a11y fixture has no response for operation "${name}"`,
|
|
184
|
+
extensions: { code: UNMOCKED_CODE },
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Parse the request parameters of a GraphQL call: a POST body (single or
|
|
192
|
+
* batched), or a GET query string.
|
|
193
|
+
*/
|
|
194
|
+
export function parseGraphqlRequest(
|
|
195
|
+
method: string,
|
|
196
|
+
url: string,
|
|
197
|
+
body: string | null
|
|
198
|
+
): GraphqlRequestLike | GraphqlRequestLike[] | null {
|
|
199
|
+
if (method.toUpperCase() === 'GET') {
|
|
200
|
+
try {
|
|
201
|
+
const params = new URL(url).searchParams;
|
|
202
|
+
return {
|
|
203
|
+
operationName: params.get('operationName') ?? undefined,
|
|
204
|
+
query: params.get('query'),
|
|
205
|
+
};
|
|
206
|
+
} catch {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (!body) return null;
|
|
211
|
+
try {
|
|
212
|
+
const parsed: unknown = JSON.parse(body);
|
|
213
|
+
if (Array.isArray(parsed)) return parsed.filter(isPlainObject) as GraphqlRequestLike[];
|
|
214
|
+
return isPlainObject(parsed) ? (parsed as GraphqlRequestLike) : null;
|
|
215
|
+
} catch {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Answer a GraphQL request from the fixture. Batched requests get a batched
|
|
222
|
+
* response in the same order. A request that cannot be parsed is answered with
|
|
223
|
+
* a 400, never forwarded.
|
|
224
|
+
*/
|
|
225
|
+
export function respondToGraphql(
|
|
226
|
+
spec: FixtureSpec,
|
|
227
|
+
method: string,
|
|
228
|
+
url: string,
|
|
229
|
+
body: string | null,
|
|
230
|
+
nowMs: number = Date.now()
|
|
231
|
+
): FixtureResponse {
|
|
232
|
+
const operations = spec.graphql?.operations ?? {};
|
|
233
|
+
const parsed = parseGraphqlRequest(method, url, body);
|
|
234
|
+
if (parsed === null) {
|
|
235
|
+
return {
|
|
236
|
+
status: 400,
|
|
237
|
+
body: JSON.stringify({
|
|
238
|
+
errors: [{ message: 'a11y fixture could not parse the request' }],
|
|
239
|
+
}),
|
|
240
|
+
unmocked: ['(unparseable request)'],
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const unmocked: string[] = [];
|
|
245
|
+
const answer = (request: GraphqlRequestLike): GraphqlResult => {
|
|
246
|
+
const name = operationNameOf(request);
|
|
247
|
+
const result = Object.prototype.hasOwnProperty.call(operations, name)
|
|
248
|
+
? operations[name]
|
|
249
|
+
: undefined;
|
|
250
|
+
if (result === undefined) {
|
|
251
|
+
unmocked.push(name);
|
|
252
|
+
return unmockedResult(name);
|
|
253
|
+
}
|
|
254
|
+
return expandMarkers(result, nowMs) as GraphqlResult;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const response = Array.isArray(parsed) ? parsed.map(answer) : answer(parsed);
|
|
258
|
+
return { status: 200, body: JSON.stringify(response), unmocked };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export interface SeedArgs {
|
|
262
|
+
/** Only documents on this origin are seeded (not about:blank or iframes). */
|
|
263
|
+
origin: string;
|
|
264
|
+
entries: Record<string, string>;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Script injected before every page script of a fixture route. Self-contained
|
|
269
|
+
* (no closure over module scope) so Playwright can serialize it into the page.
|
|
270
|
+
* It seeds once per document load, before the shell reads its session.
|
|
271
|
+
*/
|
|
272
|
+
export function seedLocalStorageScript(args: SeedArgs): void {
|
|
273
|
+
if (window.location.origin !== args.origin) return;
|
|
274
|
+
for (const [key, value] of Object.entries(args.entries)) {
|
|
275
|
+
window.localStorage.setItem(key, value);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
@@ -3,7 +3,23 @@
|
|
|
3
3
|
* that needs to embed the audit rather than shell out to the CLI.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
export {
|
|
6
|
+
export {
|
|
7
|
+
resolveConfig,
|
|
8
|
+
inlineFixtureFiles,
|
|
9
|
+
ConfigError,
|
|
10
|
+
DEFAULT_FAIL_ON,
|
|
11
|
+
DEFAULT_STANDARD,
|
|
12
|
+
} from './config';
|
|
13
|
+
export {
|
|
14
|
+
encodeUnsignedJwt,
|
|
15
|
+
expandMarkers,
|
|
16
|
+
materializeCookies,
|
|
17
|
+
materializeLocalStorage,
|
|
18
|
+
matchesGraphqlEndpoint,
|
|
19
|
+
operationNameOf,
|
|
20
|
+
parseGraphqlRequest,
|
|
21
|
+
respondToGraphql,
|
|
22
|
+
} from './fixtures';
|
|
7
23
|
export {
|
|
8
24
|
entryCovers,
|
|
9
25
|
findUnreachableRouteScopes,
|
|
@@ -5,12 +5,22 @@
|
|
|
5
5
|
|
|
6
6
|
import type { AuditReport, FlatNode } from './types';
|
|
7
7
|
|
|
8
|
-
function ruleSummary(
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
function ruleSummary(nodes: FlatNode[]): Array<{
|
|
9
|
+
rule: string;
|
|
10
|
+
impact: string;
|
|
11
|
+
count: number;
|
|
12
|
+
helpUrl: string;
|
|
13
|
+
help: string;
|
|
14
|
+
}> {
|
|
11
15
|
const byRule = new Map<
|
|
12
16
|
string,
|
|
13
|
-
{
|
|
17
|
+
{
|
|
18
|
+
rule: string;
|
|
19
|
+
impact: string;
|
|
20
|
+
count: number;
|
|
21
|
+
helpUrl: string;
|
|
22
|
+
help: string;
|
|
23
|
+
}
|
|
14
24
|
>();
|
|
15
25
|
for (const node of nodes) {
|
|
16
26
|
const existing = byRule.get(node.rule);
|
|
@@ -49,7 +59,12 @@ export function renderConsole(report: AuditReport): string {
|
|
|
49
59
|
`\n ${route.blocking.length} blocking · ${route.allowed.length} allowlisted · ` +
|
|
50
60
|
`${route.advisory.length} advisory · ${route.passCount} checks passed · ` +
|
|
51
61
|
`${route.incompleteCount} needs review · ${route.renderedTextLength} chars rendered` +
|
|
52
|
-
`${route.domSettled ? '' : ' (DOM never went quiet)'}`
|
|
62
|
+
`${route.domSettled ? '' : ' (DOM never went quiet)'}` +
|
|
63
|
+
(route.fixture ? `\n fixture: ${route.fixture}` : '') +
|
|
64
|
+
(route.unmockedOperations && route.unmockedOperations.length > 0
|
|
65
|
+
? ` · unmocked GraphQL operations (answered with an error): ` +
|
|
66
|
+
route.unmockedOperations.join(', ')
|
|
67
|
+
: '')
|
|
53
68
|
);
|
|
54
69
|
for (const entry of ruleSummary(route.blocking)) {
|
|
55
70
|
lines.push(` x ${entry.rule} (${entry.impact}) x${entry.count} — ${entry.help}`);
|
|
@@ -114,6 +129,22 @@ export function renderMarkdown(report: AuditReport): string {
|
|
|
114
129
|
);
|
|
115
130
|
}
|
|
116
131
|
|
|
132
|
+
const unmocked = report.routes.filter(
|
|
133
|
+
(r) => r.unmockedOperations && r.unmockedOperations.length > 0
|
|
134
|
+
);
|
|
135
|
+
if (unmocked.length > 0) {
|
|
136
|
+
lines.push('');
|
|
137
|
+
lines.push('### Fixture coverage');
|
|
138
|
+
lines.push('');
|
|
139
|
+
lines.push('Operations these fixture routes sent that the fixture has no answer for:');
|
|
140
|
+
for (const route of unmocked) {
|
|
141
|
+
lines.push(
|
|
142
|
+
`- \`${route.routeName}\` @ ${route.viewport} (fixture \`${route.fixture}\`): ` +
|
|
143
|
+
(route.unmockedOperations ?? []).map((name) => `\`${name}\``).join(', ')
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
117
148
|
const allBlocking = report.routes.flatMap((r) => r.blocking);
|
|
118
149
|
if (allBlocking.length > 0) {
|
|
119
150
|
lines.push('');
|
|
@@ -66,6 +66,39 @@ export interface RouteSpec {
|
|
|
66
66
|
* "clean" result on an unexpected error page cannot be mistaken for a pass.
|
|
67
67
|
*/
|
|
68
68
|
allowRedirect?: boolean;
|
|
69
|
+
/**
|
|
70
|
+
* Name of an entry in the top-level `fixtures` map to load this route with —
|
|
71
|
+
* typically a signed-in session plus canned GraphQL answers. A fixture route
|
|
72
|
+
* that ends anywhere other than `path` fails the attempt (unless
|
|
73
|
+
* `allowRedirect` is set): landing on /auth/login means the fixture did not
|
|
74
|
+
* sign in, and auditing the login page again would be a false pass.
|
|
75
|
+
*/
|
|
76
|
+
fixture?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A GraphQL response body, as the gateway would send it. */
|
|
80
|
+
export interface GraphqlResult {
|
|
81
|
+
data?: unknown;
|
|
82
|
+
errors?: Array<{ message: string; [key: string]: unknown }>;
|
|
83
|
+
extensions?: Record<string, unknown>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Deterministic page state for a route. See fixtures.ts for the semantics.
|
|
88
|
+
* Never put a real credential in one: `{ "$jwt": { ...claims } }` is encoded
|
|
89
|
+
* into an unsigned token at run time.
|
|
90
|
+
*/
|
|
91
|
+
export interface FixtureSpec {
|
|
92
|
+
/** Seeded into localStorage before the first page script runs. */
|
|
93
|
+
localStorage?: Record<string, unknown>;
|
|
94
|
+
/** Cookies set on the app origin before the page opens. */
|
|
95
|
+
cookies?: Record<string, unknown>;
|
|
96
|
+
graphql?: {
|
|
97
|
+
/** Path suffixes that identify a GraphQL endpoint, on any origin. */
|
|
98
|
+
endpoints: string[];
|
|
99
|
+
/** Response body per operation name. */
|
|
100
|
+
operations: Record<string, GraphqlResult>;
|
|
101
|
+
};
|
|
69
102
|
}
|
|
70
103
|
|
|
71
104
|
export interface ViewportSpec {
|
|
@@ -152,6 +185,11 @@ export interface A11yAuditConfig {
|
|
|
152
185
|
* still a hard failure — this retries a flake, it does not tolerate one.
|
|
153
186
|
*/
|
|
154
187
|
routeAttempts?: number;
|
|
188
|
+
/**
|
|
189
|
+
* Named route fixtures. A value may be the fixture itself or a path, relative
|
|
190
|
+
* to the config file, of a JSON file holding it.
|
|
191
|
+
*/
|
|
192
|
+
fixtures?: Record<string, FixtureSpec | string>;
|
|
155
193
|
}
|
|
156
194
|
|
|
157
195
|
/** Config with every default resolved. */
|
|
@@ -171,6 +209,7 @@ export interface ResolvedConfig {
|
|
|
171
209
|
domQuietMs: number;
|
|
172
210
|
minTextLength: number;
|
|
173
211
|
routeAttempts: number;
|
|
212
|
+
fixtures: Record<string, FixtureSpec>;
|
|
174
213
|
}
|
|
175
214
|
|
|
176
215
|
/** A single failing DOM node, flattened out of an axe violation. */
|
|
@@ -212,6 +251,10 @@ export interface RouteResult {
|
|
|
212
251
|
domSettled: boolean;
|
|
213
252
|
/** Navigation attempts this route needed. >1 means the runner hit a flake. */
|
|
214
253
|
attempts: number;
|
|
254
|
+
/** Fixture the route was loaded with, if any. */
|
|
255
|
+
fixture?: string;
|
|
256
|
+
/** GraphQL operations the page sent that the fixture had no answer for. */
|
|
257
|
+
unmockedOperations?: string[];
|
|
215
258
|
}
|
|
216
259
|
|
|
217
260
|
export interface AuditReport {
|
|
@@ -224,7 +267,11 @@ export interface AuditReport {
|
|
|
224
267
|
blockExternalRequests: boolean;
|
|
225
268
|
routes: RouteResult[];
|
|
226
269
|
staleAllowlistEntries: Array<{ index: number; rule: string; reason: string }>;
|
|
227
|
-
expiredAllowlistEntries: Array<{
|
|
270
|
+
expiredAllowlistEntries: Array<{
|
|
271
|
+
index: number;
|
|
272
|
+
rule: string;
|
|
273
|
+
expires: string;
|
|
274
|
+
}>;
|
|
228
275
|
totals: {
|
|
229
276
|
blocking: number;
|
|
230
277
|
allowed: number;
|
|
@@ -1,6 +1,25 @@
|
|
|
1
|
-
/* FluidGrids brand overrides
|
|
1
|
+
/* FluidGrids brand overrides
|
|
2
|
+
*
|
|
3
|
+
* WCAG 2.1 AA (1.4.3), parity G23. The accent is both the primary-button fill
|
|
4
|
+
* (white label) and, through --color-accent-primary, small accent text on the
|
|
5
|
+
* canvas, so one cyan cannot serve both themes:
|
|
6
|
+
* - light: cyan-700 #0E7490 carries a white label at 5.36:1. The former
|
|
7
|
+
* cyan-600 #0891B2 measured 3.68:1 and failed axe color-contrast on every
|
|
8
|
+
* primary button of the landing page.
|
|
9
|
+
* - dark: cyan-600 stays (5.2:1 as accent text on the #0A0E16 canvas), and
|
|
10
|
+
* the button label turns dark (5.7:1); hover/active step LIGHTER so the
|
|
11
|
+
* dark label keeps its contrast in every state.
|
|
12
|
+
*/
|
|
2
13
|
[data-brand="fluidgrids"] {
|
|
14
|
+
--color-action-primary-bg: #0E7490;
|
|
15
|
+
--color-action-primary-bgHover: #155E75;
|
|
16
|
+
--color-action-primary-bgActive: #164E63;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
:root.dark[data-brand="fluidgrids"],
|
|
20
|
+
[data-brand="fluidgrids"].dark {
|
|
3
21
|
--color-action-primary-bg: #0891B2;
|
|
4
|
-
--color-action-primary-bgHover: #
|
|
5
|
-
--color-action-primary-bgActive: #
|
|
22
|
+
--color-action-primary-bgHover: #06B6D4;
|
|
23
|
+
--color-action-primary-bgActive: #22D3EE;
|
|
24
|
+
--color-action-primary-text: #0A0E16;
|
|
6
25
|
}
|