@owlmeans/web-consent 0.1.18-rc.24 → 0.1.18-rc.26

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/README.md CHANGED
@@ -6,7 +6,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
6
6
  your project's skill store (`.agents/skills/`):
7
7
 
8
8
  ```sh
9
- npx @owlmeans/agent-skills@^0.1.18-rc.31
9
+ npx @owlmeans/agent-skills@^0.1.18-rc.32
10
10
  ```
11
11
 
12
12
  The embedded files are version-matched to this package release. Do not edit them
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/web-consent",
4
- "version": "0.1.18-rc.24",
5
- "generatedAt": "2026-09-21T21:54:22.962Z",
4
+ "version": "0.1.18-rc.26",
5
+ "generatedAt": "2026-09-22T12:36:21.844Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -8,7 +8,7 @@ user-invocable: false
8
8
  # @owlmeans/web-consent
9
9
 
10
10
  **Layer:** Web (React)
11
- **Install:** `"@owlmeans/web-consent": "^0.1.18-rc.24"` in `dependencies`
11
+ **Install:** `"@owlmeans/web-consent": "^0.1.18-rc.26"` in `dependencies`
12
12
 
13
13
  The browser components of the consent set. The model — categories, storage, migration, the store,
14
14
  Consent Mode signalling — is `@owlmeans/consent`, and this package re-exports a **named selection**
@@ -96,6 +96,18 @@ Every control shows a 3px `outline-ring` focus outline 3px off the element (`out
96
96
  outline-offset-3`; the switch draws it on its track through `peer-focus-visible`). An outline, not
97
97
  a ring, so a host that zeroes shadows keeps it. Never reintroduce a decorative class here — the
98
98
  package tests fail on `bg-gradient`, `backdrop-`, `blur`, `shadow` or `ring-primary` in the dialog.
99
+
100
+ **Each switch is named by reference.** The `<label>` around a switch's checkbox holds only the
101
+ drawn track, and the category's words sit in a sibling column it cannot reach, so the checkbox
102
+ carries `aria-labelledby="<id>-label"` (the category label) and `aria-describedby` pointing at
103
+ `<id>-required` and `<id>-desc`. Drop those and axe reports three unnamed checkboxes (`label`,
104
+ critical). A required category stays `disabled` and keeps its name.
105
+
106
+ `tests/a11y.spec.ts` runs axe-core (a dev dependency) against the open dialog in both schemes, the
107
+ re-open button, and the policy page with services, and fails on any serious or critical
108
+ violation. It loads the harness with `?styled=1` (plus `&theme=dark`), which compiles the package
109
+ sources with a generated app's neutral tokens — contrast cannot be judged on the unstyled harness
110
+ the other specs need.
99
111
  - `silent` skips every `dataLayer` and global write. It is for tests and for an app that runs no
100
112
  tags at all.
101
113
  - The draft is **re-seeded from storage every time the dialog opens**, not from the last render — a
@@ -17,6 +17,12 @@ export interface ConsentToggleProps {
17
17
  * switch's hit area is 44px square around the visible 24×44 track, and it shows the dialog's 3px
18
18
  * focus ring when reached from the keyboard (the real checkbox is visually hidden, so the ring is
19
19
  * drawn on the track through `peer-focus-visible`).
20
+ *
21
+ * The checkbox takes its NAME from the category label and its description from the `Required`
22
+ * marker and the description text, by id. The `<label>` around it holds only the drawn track, so
23
+ * without those references a screen reader announces an unnamed checkbox three times over — the
24
+ * visible words sit in a sibling column the label cannot reach. A required category keeps its name
25
+ * while disabled: it is still disclosed, only not a choice.
20
26
  */
21
27
  export declare const ConsentToggle: FC<ConsentToggleProps>;
22
28
  //# sourceMappingURL=toggle.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"toggle.d.ts","sourceRoot":"","sources":["../../src/consent/toggle.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,OAAO,CAAA;AAG/B,MAAM,WAAW,kBAAkB;IACjC,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,EAAE,MAAM,CAAA;IACnB,OAAO,EAAE,OAAO,CAAA;IAChB,0FAA0F;IAC1F,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,aAAa,EAAE,MAAM,CAAA;IACrB,QAAQ,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAA;CACnC;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,aAAa,EAAE,EAAE,CAAC,kBAAkB,CAoChD,CAAA"}
1
+ {"version":3,"file":"toggle.d.ts","sourceRoot":"","sources":["../../src/consent/toggle.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,OAAO,CAAA;AAG/B,MAAM,WAAW,kBAAkB;IACjC,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,EAAE,MAAM,CAAA;IACnB,OAAO,EAAE,OAAO,CAAA;IAChB,0FAA0F;IAC1F,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,aAAa,EAAE,MAAM,CAAA;IACrB,QAAQ,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAA;CACnC;AAED;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,aAAa,EAAE,EAAE,CAAC,kBAAkB,CA+ChD,CAAA"}
@@ -8,6 +8,18 @@ import { cn } from '../lib/utils.js';
8
8
  * switch's hit area is 44px square around the visible 24×44 track, and it shows the dialog's 3px
9
9
  * focus ring when reached from the keyboard (the real checkbox is visually hidden, so the ring is
10
10
  * drawn on the track through `peer-focus-visible`).
11
+ *
12
+ * The checkbox takes its NAME from the category label and its description from the `Required`
13
+ * marker and the description text, by id. The `<label>` around it holds only the drawn track, so
14
+ * without those references a screen reader announces an unnamed checkbox three times over — the
15
+ * visible words sit in a sibling column the label cannot reach. A required category keeps its name
16
+ * while disabled: it is still disclosed, only not a choice.
11
17
  */
12
- export const ConsentToggle = ({ id, label, description, checked, required, requiredLabel, onChange }) => (_jsxs("div", { className: "flex items-center justify-between gap-4 rounded-2xl border border-border p-4", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("div", { className: "mb-0.5 flex flex-wrap items-center gap-x-2 text-sm font-bold text-foreground", children: [label, required === true && _jsx("span", { className: "text-xs font-bold uppercase tracking-wide text-muted-foreground", children: requiredLabel })] }), _jsx("p", { className: "text-pretty text-xs leading-relaxed text-muted-foreground", children: description })] }), _jsxs("label", { className: cn('relative inline-flex h-11 w-14 flex-shrink-0 items-center justify-center', required === true ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'), children: [_jsx("input", { id: id, type: "checkbox", className: "peer sr-only", checked: checked || required === true, disabled: required === true, onChange: event => onChange(event.target.checked) }), _jsx("span", { className: cn('relative h-6 w-11 rounded-full transition-colors duration-300 peer-focus-visible:outline-3 peer-focus-visible:outline-offset-3 peer-focus-visible:outline-ring', checked || required === true ? 'bg-primary' : 'bg-muted-foreground/40'), children: _jsx("span", { className: cn('absolute top-0.5 left-0 h-5 w-5 rounded-full bg-white transition-transform duration-300', checked || required === true ? 'translate-x-5' : 'translate-x-0.5') }) })] })] }));
18
+ export const ConsentToggle = ({ id, label, description, checked, required, requiredLabel, onChange }) => {
19
+ const described = [
20
+ ...(required === true ? [`${id}-required`] : []),
21
+ ...(description !== '' ? [`${id}-desc`] : []),
22
+ ];
23
+ return (_jsxs("div", { className: "flex items-center justify-between gap-4 rounded-2xl border border-border p-4", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("div", { className: "mb-0.5 flex flex-wrap items-center gap-x-2 text-sm font-bold text-foreground", children: [_jsx("span", { id: `${id}-label`, children: label }), required === true && _jsx("span", { id: `${id}-required`, className: "text-xs font-bold uppercase tracking-wide text-muted-foreground", children: requiredLabel })] }), description !== '' && _jsx("p", { id: `${id}-desc`, className: "text-pretty text-xs leading-relaxed text-muted-foreground", children: description })] }), _jsxs("label", { className: cn('relative inline-flex h-11 w-14 flex-shrink-0 items-center justify-center', required === true ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'), children: [_jsx("input", { id: id, type: "checkbox", "aria-labelledby": `${id}-label`, ...(described.length > 0 ? { 'aria-describedby': described.join(' ') } : {}), className: "peer sr-only", checked: checked || required === true, disabled: required === true, onChange: event => onChange(event.target.checked) }), _jsx("span", { className: cn('relative h-6 w-11 rounded-full transition-colors duration-300 peer-focus-visible:outline-3 peer-focus-visible:outline-offset-3 peer-focus-visible:outline-ring', checked || required === true ? 'bg-primary' : 'bg-muted-foreground/40'), children: _jsx("span", { className: cn('absolute top-0.5 left-0 h-5 w-5 rounded-full bg-white transition-transform duration-300', checked || required === true ? 'translate-x-5' : 'translate-x-0.5') }) })] })] }));
24
+ };
13
25
  //# sourceMappingURL=toggle.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"toggle.js","sourceRoot":"","sources":["../../src/consent/toggle.tsx"],"names":[],"mappings":";AACA,OAAO,EAAE,EAAE,EAAE,MAAM,iBAAiB,CAAA;AAapC;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,aAAa,GAA2B,CACnD,EAAE,EAAE,EAAE,KAAK,EAAE,WAAW,EAAE,OAAO,EAAE,QAAQ,EAAE,aAAa,EAAE,QAAQ,EAAE,EACtE,EAAE,CAAC,CACH,eAAK,SAAS,EAAC,8EAA8E,aAC3F,eAAK,SAAS,EAAC,gBAAgB,aAC7B,eAAK,SAAS,EAAC,8EAA8E,aAC1F,KAAK,EACL,QAAQ,KAAK,IAAI,IAAI,eAAM,SAAS,EAAC,iEAAiE,YACpG,aAAa,GACT,IACH,EACN,YAAG,SAAS,EAAC,2DAA2D,YAAE,WAAW,GAAK,IACtF,EACN,iBAAO,SAAS,EAAE,EAAE,CAClB,0EAA0E,EAC1E,QAAQ,KAAK,IAAI,CAAC,CAAC,CAAC,+BAA+B,CAAC,CAAC,CAAC,gBAAgB,CACvE,aACC,gBACE,EAAE,EAAE,EAAE,EACN,IAAI,EAAC,UAAU,EACf,SAAS,EAAC,cAAc,EACxB,OAAO,EAAE,OAAO,IAAI,QAAQ,KAAK,IAAI,EACrC,QAAQ,EAAE,QAAQ,KAAK,IAAI,EAC3B,QAAQ,EAAE,KAAK,CAAC,EAAE,CAAC,QAAQ,CAAC,KAAK,CAAC,MAAM,CAAC,OAAO,CAAC,GACjD,EACF,eAAM,SAAS,EAAE,EAAE,CACjB,gKAAgK,EAChK,OAAO,IAAI,QAAQ,KAAK,IAAI,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,wBAAwB,CACvE,YACC,eAAM,SAAS,EAAE,EAAE,CACjB,yFAAyF,EACzF,OAAO,IAAI,QAAQ,KAAK,IAAI,CAAC,CAAC,CAAC,eAAe,CAAC,CAAC,CAAC,iBAAiB,CACnE,GAAI,GACA,IACD,IACJ,CACP,CAAA"}
1
+ {"version":3,"file":"toggle.js","sourceRoot":"","sources":["../../src/consent/toggle.tsx"],"names":[],"mappings":";AACA,OAAO,EAAE,EAAE,EAAE,MAAM,iBAAiB,CAAA;AAapC;;;;;;;;;;;;;;GAcG;AACH,MAAM,CAAC,MAAM,aAAa,GAA2B,CACnD,EAAE,EAAE,EAAE,KAAK,EAAE,WAAW,EAAE,OAAO,EAAE,QAAQ,EAAE,aAAa,EAAE,QAAQ,EAAE,EACtE,EAAE;IACF,MAAM,SAAS,GAAG;QAChB,GAAG,CAAC,QAAQ,KAAK,IAAI,CAAC,CAAC,CAAC,CAAC,GAAG,EAAE,WAAW,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QAChD,GAAG,CAAC,WAAW,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;KAC9C,CAAA;IAED,OAAO,CACL,eAAK,SAAS,EAAC,8EAA8E,aAC3F,eAAK,SAAS,EAAC,gBAAgB,aAC7B,eAAK,SAAS,EAAC,8EAA8E,aAC3F,eAAM,EAAE,EAAE,GAAG,EAAE,QAAQ,YAAG,KAAK,GAAQ,EACtC,QAAQ,KAAK,IAAI,IAAI,eACpB,EAAE,EAAE,GAAG,EAAE,WAAW,EAAE,SAAS,EAAC,iEAAiE,YACjG,aAAa,GAAQ,IACnB,EACL,WAAW,KAAK,EAAE,IAAI,YACrB,EAAE,EAAE,GAAG,EAAE,OAAO,EAAE,SAAS,EAAC,2DAA2D,YACvF,WAAW,GAAK,IACd,EACN,iBAAO,SAAS,EAAE,EAAE,CAClB,0EAA0E,EAC1E,QAAQ,KAAK,IAAI,CAAC,CAAC,CAAC,+BAA+B,CAAC,CAAC,CAAC,gBAAgB,CACvE,aACC,gBACE,EAAE,EAAE,EAAE,EACN,IAAI,EAAC,UAAU,qBACE,GAAG,EAAE,QAAQ,KAC1B,CAAC,SAAS,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,kBAAkB,EAAE,SAAS,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAC7E,SAAS,EAAC,cAAc,EACxB,OAAO,EAAE,OAAO,IAAI,QAAQ,KAAK,IAAI,EACrC,QAAQ,EAAE,QAAQ,KAAK,IAAI,EAC3B,QAAQ,EAAE,KAAK,CAAC,EAAE,CAAC,QAAQ,CAAC,KAAK,CAAC,MAAM,CAAC,OAAO,CAAC,GACjD,EACF,eAAM,SAAS,EAAE,EAAE,CACjB,gKAAgK,EAChK,OAAO,IAAI,QAAQ,KAAK,IAAI,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,wBAAwB,CACvE,YACC,eAAM,SAAS,EAAE,EAAE,CACjB,yFAAyF,EACzF,OAAO,IAAI,QAAQ,KAAK,IAAI,CAAC,CAAC,CAAC,eAAe,CAAC,CAAC,CAAC,iBAAiB,CACnE,GAAI,GACA,IACD,IACJ,CACP,CAAA;AACH,CAAC,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owlmeans/web-consent",
3
- "version": "0.1.18-rc.24",
3
+ "version": "0.1.18-rc.26",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -33,12 +33,13 @@
33
33
  },
34
34
  "devDependencies": {
35
35
  "@owlmeans/dep-config": "workspace:*",
36
- "@owlmeans/test-ui": "^0.1.18-rc.32",
36
+ "@owlmeans/test-ui": "^0.1.18-rc.33",
37
37
  "@tailwindcss/vite": "^4.0.0",
38
38
  "@types/bun": "^1.4.0",
39
39
  "@types/react": "^19.2.17",
40
40
  "@types/react-dom": "^19.2.3",
41
41
  "@vitejs/plugin-react": "^6.0.3",
42
+ "axe-core": "^4.13.0",
42
43
  "clsx": "^2.1.1",
43
44
  "lucide-react": "^0.550.0",
44
45
  "nodemon": "^3.1.14",
@@ -20,41 +20,58 @@ export interface ConsentToggleProps {
20
20
  * switch's hit area is 44px square around the visible 24×44 track, and it shows the dialog's 3px
21
21
  * focus ring when reached from the keyboard (the real checkbox is visually hidden, so the ring is
22
22
  * drawn on the track through `peer-focus-visible`).
23
+ *
24
+ * The checkbox takes its NAME from the category label and its description from the `Required`
25
+ * marker and the description text, by id. The `<label>` around it holds only the drawn track, so
26
+ * without those references a screen reader announces an unnamed checkbox three times over — the
27
+ * visible words sit in a sibling column the label cannot reach. A required category keeps its name
28
+ * while disabled: it is still disclosed, only not a choice.
23
29
  */
24
30
  export const ConsentToggle: FC<ConsentToggleProps> = (
25
31
  { id, label, description, checked, required, requiredLabel, onChange }
26
- ) => (
27
- <div className="flex items-center justify-between gap-4 rounded-2xl border border-border p-4">
28
- <div className="min-w-0 flex-1">
29
- <div className="mb-0.5 flex flex-wrap items-center gap-x-2 text-sm font-bold text-foreground">
30
- {label}
31
- {required === true && <span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
32
- {requiredLabel}
33
- </span>}
32
+ ) => {
33
+ const described = [
34
+ ...(required === true ? [`${id}-required`] : []),
35
+ ...(description !== '' ? [`${id}-desc`] : []),
36
+ ]
37
+
38
+ return (
39
+ <div className="flex items-center justify-between gap-4 rounded-2xl border border-border p-4">
40
+ <div className="min-w-0 flex-1">
41
+ <div className="mb-0.5 flex flex-wrap items-center gap-x-2 text-sm font-bold text-foreground">
42
+ <span id={`${id}-label`}>{label}</span>
43
+ {required === true && <span
44
+ id={`${id}-required`} className="text-xs font-bold uppercase tracking-wide text-muted-foreground"
45
+ >{requiredLabel}</span>}
46
+ </div>
47
+ {description !== '' && <p
48
+ id={`${id}-desc`} className="text-pretty text-xs leading-relaxed text-muted-foreground"
49
+ >{description}</p>}
34
50
  </div>
35
- <p className="text-pretty text-xs leading-relaxed text-muted-foreground">{description}</p>
36
- </div>
37
- <label className={cn(
38
- 'relative inline-flex h-11 w-14 flex-shrink-0 items-center justify-center',
39
- required === true ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'
40
- )}>
41
- <input
42
- id={id}
43
- type="checkbox"
44
- className="peer sr-only"
45
- checked={checked || required === true}
46
- disabled={required === true}
47
- onChange={event => onChange(event.target.checked)}
48
- />
49
- <span className={cn(
50
- 'relative h-6 w-11 rounded-full transition-colors duration-300 peer-focus-visible:outline-3 peer-focus-visible:outline-offset-3 peer-focus-visible:outline-ring',
51
- checked || required === true ? 'bg-primary' : 'bg-muted-foreground/40'
51
+ <label className={cn(
52
+ 'relative inline-flex h-11 w-14 flex-shrink-0 items-center justify-center',
53
+ required === true ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'
52
54
  )}>
55
+ <input
56
+ id={id}
57
+ type="checkbox"
58
+ aria-labelledby={`${id}-label`}
59
+ {...(described.length > 0 ? { 'aria-describedby': described.join(' ') } : {})}
60
+ className="peer sr-only"
61
+ checked={checked || required === true}
62
+ disabled={required === true}
63
+ onChange={event => onChange(event.target.checked)}
64
+ />
53
65
  <span className={cn(
54
- 'absolute top-0.5 left-0 h-5 w-5 rounded-full bg-white transition-transform duration-300',
55
- checked || required === true ? 'translate-x-5' : 'translate-x-0.5'
56
- )} />
57
- </span>
58
- </label>
59
- </div>
60
- )
66
+ 'relative h-6 w-11 rounded-full transition-colors duration-300 peer-focus-visible:outline-3 peer-focus-visible:outline-offset-3 peer-focus-visible:outline-ring',
67
+ checked || required === true ? 'bg-primary' : 'bg-muted-foreground/40'
68
+ )}>
69
+ <span className={cn(
70
+ 'absolute top-0.5 left-0 h-5 w-5 rounded-full bg-white transition-transform duration-300',
71
+ checked || required === true ? 'translate-x-5' : 'translate-x-0.5'
72
+ )} />
73
+ </span>
74
+ </label>
75
+ </div>
76
+ )
77
+ }
@@ -0,0 +1,117 @@
1
+ import { afterAll, describe, expect, test } from 'bun:test'
2
+ import { createRequire } from 'node:module'
3
+ import type { Page } from 'playwright'
4
+ import { closeBrowser, mountComponent } from '@owlmeans/test-ui'
5
+ import { CONSENT_KEY, CONSENT_SCHEMA_VERSION } from '@owlmeans/consent'
6
+ import { HARNESS_URL } from './context.js'
7
+
8
+ const TIMEOUT = 60_000
9
+
10
+ afterAll(async () => {
11
+ await closeBrowser()
12
+ })
13
+
14
+ const base = HARNESS_URL.replace(/\/$/, '')
15
+ const AXE = createRequire(import.meta.url).resolve('axe-core/axe.min.js')
16
+
17
+ interface Violation { id: string, impact: string | null, help: string, targets: string[] }
18
+
19
+ /**
20
+ * Every serious or critical axe violation on the page as it stands.
21
+ *
22
+ * Run against the THEMED harness (`styled=1`), because the rule that matters most after naming —
23
+ * colour contrast — means nothing on an unstyled page, where every text is black on white.
24
+ */
25
+ const serious = async (page: Page): Promise<Violation[]> => {
26
+ await page.addScriptTag({ path: AXE })
27
+ const violations = await page.evaluate(async () => {
28
+ const axe = (window as never as {
29
+ axe: { run: (context: Document) => Promise<{ violations: {
30
+ id: string, impact: string | null, help: string, nodes: { target: string[] }[]
31
+ }[] }> }
32
+ }).axe
33
+ const result = await axe.run(document)
34
+
35
+ return result.violations.map(violation => ({
36
+ id: violation.id,
37
+ impact: violation.impact,
38
+ help: violation.help,
39
+ targets: violation.nodes.map(node => node.target.join(' ')),
40
+ }))
41
+ })
42
+
43
+ return violations.filter(violation => violation.impact === 'serious' || violation.impact === 'critical')
44
+ }
45
+
46
+ describe('@owlmeans/web-consent — accessibility', () => {
47
+ for (const theme of ['light', 'dark']) {
48
+ test(`the open dialog has no serious or critical axe violation (${theme})`, async () => {
49
+ // A first visit, so the dialog is open — which is what every new visitor of a generated app
50
+ // meets before anything else on the page.
51
+ const { page, close } = await mountComponent({ url: `${base}/?styled=1&theme=${theme}` })
52
+ try {
53
+ await page.waitForSelector('[data-consent-dialog]')
54
+
55
+ expect(await serious(page)).toEqual([])
56
+ } finally {
57
+ await close()
58
+ }
59
+ }, TIMEOUT)
60
+ }
61
+
62
+ test('each switch is named by its category and described by its text', async () => {
63
+ // The label element around a switch holds only the drawn track, so its NAME has to come from
64
+ // the category label by reference — the defect this pins was three unnamed checkboxes.
65
+ const { page, close } = await mountComponent({ url: `${base}/?styled=1` })
66
+ try {
67
+ await page.waitForSelector('[data-consent-dialog]')
68
+
69
+ const essential = page.getByRole('checkbox', { name: 'Essential Cookies' })
70
+ expect(await essential.isDisabled()).toBe(true)
71
+ expect(await essential.isChecked()).toBe(true)
72
+ expect(await essential.getAttribute('aria-describedby')).toBe('cc-essential-required cc-essential-desc')
73
+
74
+ const analytics = page.getByRole('checkbox', { name: 'Analytics Cookies' })
75
+ expect(await analytics.isDisabled()).toBe(false)
76
+ expect(await analytics.getAttribute('aria-describedby')).toBe('cc-analytics-desc')
77
+ await page.getByRole('checkbox', { name: 'Marketing Cookies' }).waitFor({ state: 'attached' })
78
+ } finally {
79
+ await close()
80
+ }
81
+ }, TIMEOUT)
82
+
83
+ test('the re-open button has no serious or critical axe violation', async () => {
84
+ const { page, close } = await mountComponent({ url: `${base}/?styled=1`, waitUntil: 'commit' })
85
+ try {
86
+ await page.evaluate(([key, value]) => window.localStorage.setItem(key, value), [
87
+ CONSENT_KEY,
88
+ JSON.stringify({ essential: true, analytics: false, marketing: false, v: CONSENT_SCHEMA_VERSION }),
89
+ ] as [string, string])
90
+ await page.goto(`${base}/?styled=1`, { waitUntil: 'domcontentloaded' })
91
+ await page.waitForSelector('[data-consent-reopen]')
92
+
93
+ expect(await serious(page)).toEqual([])
94
+ } finally {
95
+ await close()
96
+ }
97
+ }, TIMEOUT)
98
+
99
+ for (const theme of ['light', 'dark']) {
100
+ test(`the policy page with services has no serious or critical axe violation (${theme})`, async () => {
101
+ const { page, close } = await mountComponent({ url: `${base}/?styled=1&theme=${theme}`, waitUntil: 'commit' })
102
+ try {
103
+ // With a decision stored, so the dialog does not cover the page being checked.
104
+ await page.evaluate(([key, value]) => window.localStorage.setItem(key, value), [
105
+ CONSENT_KEY,
106
+ JSON.stringify({ essential: true, analytics: true, marketing: false, v: CONSENT_SCHEMA_VERSION }),
107
+ ] as [string, string])
108
+ await page.goto(`${base}/?view=policy&services=1&styled=1&theme=${theme}`, { waitUntil: 'domcontentloaded' })
109
+ await page.waitForSelector('[data-cookie-policy-service]')
110
+
111
+ expect(await serious(page)).toEqual([])
112
+ } finally {
113
+ await close()
114
+ }
115
+ }, TIMEOUT)
116
+ }
117
+ })
@@ -12,9 +12,18 @@ import type { ConsentCategory, ConsentService } from '../../src/index.js'
12
12
  * - `?categories=custom` — a category set that is NOT the default, with its own global var.
13
13
  * - `?view=policy` — the cookie-policy page instead of the dialog.
14
14
  * - `?services=1` — with `view=policy`, disclose {@link SERVICES} on it.
15
+ * - `?styled=1` — compile a real theme (`styled.css`), for the accessibility spec only.
16
+ * - `?theme=dark` — with `styled=1`, the dark scheme.
15
17
  */
16
18
  const params = new URLSearchParams(window.location.search)
17
19
 
20
+ if (params.get('styled') != null) {
21
+ await import('./styled.css')
22
+ if (params.get('theme') === 'dark') {
23
+ document.documentElement.classList.add('dark')
24
+ }
25
+ }
26
+
18
27
  /**
19
28
  * A deliberately non-default set: a different key, a different global var, and a signal list that
20
29
  * does not match the built-in one. A component that quietly fell back to the defaults would still
@@ -0,0 +1,54 @@
1
+ /*
2
+ A real theme for the accessibility spec, loaded only when the URL asks for it (`?styled=1`).
3
+
4
+ Every other spec asserts on class lists and needs the page unstyled: a styled dialog is a fixed,
5
+ full-screen overlay, and the specs that click past it would be clicking through it. Contrast,
6
+ on the other hand, cannot be judged on an unstyled page — so this sheet compiles the package's
7
+ own sources with the neutral tokens a generated app ships (ground, ink, muted, hairline, tile,
8
+ one accent) in both schemes.
9
+ */
10
+ @import "tailwindcss";
11
+
12
+ @source "../../src";
13
+
14
+ @custom-variant dark (&:where(.dark, .dark *));
15
+
16
+ :root {
17
+ --background: #ffffff;
18
+ --foreground: #0a2540;
19
+ --muted: #f5f7fa;
20
+ --muted-foreground: #51606f;
21
+ --border: #e6e6ea;
22
+ --primary: #8a3f1c;
23
+ --primary-foreground: #ffffff;
24
+ --ring: #0a2540;
25
+ color-scheme: light;
26
+ }
27
+
28
+ .dark {
29
+ --background: #000000;
30
+ --foreground: #f5f7fa;
31
+ --muted: #0e1318;
32
+ --muted-foreground: #9aa7b4;
33
+ --border: #1f2630;
34
+ --primary: #d9895f;
35
+ --primary-foreground: #000000;
36
+ --ring: #f5f7fa;
37
+ color-scheme: dark;
38
+ }
39
+
40
+ @theme inline {
41
+ --color-background: var(--background);
42
+ --color-foreground: var(--foreground);
43
+ --color-muted: var(--muted);
44
+ --color-muted-foreground: var(--muted-foreground);
45
+ --color-border: var(--border);
46
+ --color-primary: var(--primary);
47
+ --color-primary-foreground: var(--primary-foreground);
48
+ --color-ring: var(--ring);
49
+ }
50
+
51
+ body {
52
+ background: var(--background);
53
+ color: var(--foreground);
54
+ }