diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 20f4539..4508593 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -34,6 +34,15 @@ jobs:
- name: Test all packages
run: npm run test
+ - name: Install Playwright (chromium) for the a11y gate
+ run: npx playwright install --with-deps chromium
+
+ - name: Accessibility gate (Storybook + axe, WCAG 2.2 AA)
+ run: npm run test:a11y
+
+ - name: Keyboard-interaction gate (Storybook + Playwright)
+ run: npm run test:keyboard
+
security:
name: Security & SBOM
runs-on: ubuntu-latest
diff --git a/.gitignore b/.gitignore
index 15587cf..17de715 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,3 +24,6 @@ coverage/
*.log
npm-debug.log*
.DS_Store
+
+# Storybook build output
+storybook-static/
diff --git a/.storybook/a11y.mjs b/.storybook/a11y.mjs
new file mode 100644
index 0000000..ec31902
--- /dev/null
+++ b/.storybook/a11y.mjs
@@ -0,0 +1,200 @@
+/**
+ * Headless accessibility gate for Storybook (WCAG 2.2 AA / EN 301 549 — see
+ * ACCESSIBILITY.md). For every story in the static build it (1) confirms the
+ * story renders without error and (2) runs axe-core, failing on any violation.
+ *
+ * Deliberately does NOT use @storybook/test-runner: that pulls jest 30, which
+ * conflicts with the monorepo's jest 29 unit tests. This uses Playwright +
+ * @axe-core/playwright directly (no jest), so the two test stacks never clash.
+ *
+ * Per-story exceptions are read from the story's own `parameters.a11y`
+ * (disable, or config.rules: [{ id, enabled:false }]) — the same annotations
+ * the interactive addon-a11y panel honours — so there is a single source of
+ * truth in the story file.
+ *
+ * Usage: build Storybook first, then `node .storybook/a11y.mjs`
+ * (wrapped by `npm run test:a11y`).
+ */
+import { spawn } from 'node:child_process';
+import { readFileSync } from 'node:fs';
+import { chromium } from 'playwright';
+import { AxeBuilder } from '@axe-core/playwright';
+
+const STATIC_DIR = 'storybook-static';
+const PORT = 6006;
+const BASE = `http://127.0.0.1:${PORT}`;
+const WCAG_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'];
+
+function startServer() {
+ const proc = spawn(
+ 'npx',
+ ['http-server', STATIC_DIR, '-p', String(PORT), '-s', '-c-1'],
+ { stdio: 'ignore' }
+ );
+ return proc;
+}
+
+async function waitForServer(timeoutMs = 20000) {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ try {
+ const res = await fetch(`${BASE}/index.json`);
+ if (res.ok) return;
+ } catch {
+ /* not up yet */
+ }
+ await new Promise((r) => setTimeout(r, 300));
+ }
+ throw new Error(`Storybook static server did not start on ${BASE}`);
+}
+
+const server = startServer();
+let exitCode = 0;
+try {
+ await waitForServer();
+ const index = JSON.parse(readFileSync(`${STATIC_DIR}/index.json`, 'utf8'));
+ const stories = Object.values(index.entries).filter((e) => e.type === 'story');
+
+ const browser = await chromium.launch();
+ const context = await browser.newContext();
+ const page = await context.newPage();
+ const failures = [];
+
+ // Capture runtime errors per story — a story that throws still leaves an empty
+ // #storybook-root that would otherwise "pass" axe, so treat throws as failures.
+ // Ignore known, harmless noise (the router matching iframe.html; missing assets).
+ let pageErrors = [];
+ const IGNORE_ERR = /NG04002|404|Failed to load resource|favicon/i;
+ page.on('pageerror', (e) => pageErrors.push(e.message.split('\n')[0]));
+ page.on('console', (m) => {
+ if (m.type() === 'error') pageErrors.push(m.text().split('\n')[0]);
+ });
+
+ const renderErrors = [];
+ for (const story of stories) {
+ pageErrors = [];
+ // A single broken story must NOT abort the whole survey — capture and move on.
+ try {
+ // 'networkidle' is unreliable here — icon-font 404s and the router keep
+ // the network busy — so wait for DOM + actual rendered content instead.
+ await page.goto(`${BASE}/iframe.html?id=${story.id}&viewMode=story`, {
+ waitUntil: 'domcontentloaded',
+ });
+ // Rendered content can be inside #storybook-root OR an overlay portalled
+ // into
(modal, context menu, side panel).
+ await page.waitForFunction(
+ () => {
+ const r = document.querySelector('#storybook-root');
+ return (r && r.children.length > 0) ||
+ !!document.querySelector('.modal-overlay, [role="dialog"], .ngx-contextmenu, cdk-dialog-container, .cdk-overlay-container *');
+ },
+ { timeout: 8000 }
+ );
+ // Let change detection settle so any thrown runtime error surfaces.
+ await page.waitForTimeout(300);
+ const realErrors = pageErrors.filter((m) => !IGNORE_ERR.test(m));
+ if (realErrors.length) {
+ throw new Error('runtime: ' + realErrors[0].slice(0, 140));
+ }
+ // Detect EMPTY renders: the wrapper element can exist while the component
+ // rendered nothing (an internal @if is false → only a comment node), which
+ // still "passes" axe. Require actual laid-out content (or a body overlay).
+ // A story may legitimately render nothing (documenting a hidden/empty
+ // state); it opts out of the empty-render check with
+ // parameters.allowEmptyRender = true.
+ const allowEmpty = await page.evaluate(async (id) => {
+ try {
+ const s = await window.__STORYBOOK_PREVIEW__.storyStore.loadStory({ storyId: id });
+ return s?.parameters?.allowEmptyRender === true;
+ } catch {
+ return false;
+ }
+ }, story.id);
+ const empty = allowEmpty ? false : await page.evaluate(() => {
+ const root = document.querySelector('#storybook-root');
+ if (!root) return true;
+ const overlay = document.querySelector(
+ '.modal-overlay, [role="dialog"], .ngx-contextmenu, .cdk-overlay-container *'
+ );
+ if (overlay) return false;
+ // The mount root is always full-width; a component that rendered nothing
+ // (internal @if false → only a comment) leaves ALL descendants with a
+ // zero box. Consider it rendered only if some descendant is laid out.
+ for (const el of root.querySelectorAll('*')) {
+ if (el.offsetHeight > 0 || el.offsetWidth > 0) return false;
+ }
+ return true;
+ });
+ if (empty) {
+ throw new Error('rendered empty (component produced no visible content)');
+ }
+ } catch (e) {
+ renderErrors.push({ id: story.id, reason: e.message.split('\n')[0] });
+ console.error(` ✗ ${story.id} — DID NOT RENDER (${e.message.split('\n')[0]})`);
+ continue;
+ }
+
+ // Read the story's own a11y parameters (single source of truth with the
+ // interactive addon), so documented exceptions are honoured here too.
+ const a11y = await page.evaluate(async (id) => {
+ try {
+ const preview = window.__STORYBOOK_PREVIEW__;
+ const story = await preview.storyStore.loadStory({ storyId: id });
+ return story?.parameters?.a11y ?? null;
+ } catch {
+ return null;
+ }
+ }, story.id);
+
+ if (a11y?.disable) {
+ console.log(` ⊘ ${story.id} (a11y disabled)`);
+ continue;
+ }
+ const disabledRules = process.env.A11Y_NO_SUPPRESS
+ ? []
+ : (a11y?.config?.rules ?? [])
+ .filter((r) => r && r.enabled === false && r.id)
+ .map((r) => r.id);
+
+ try {
+ let builder = new AxeBuilder({ page }).withTags(WCAG_TAGS);
+ if (disabledRules.length) builder = builder.disableRules(disabledRules);
+ const results = await builder.analyze();
+ if (results.violations.length) {
+ failures.push({ id: story.id, violations: results.violations });
+ console.error(
+ ` ✗ ${story.id} — ${results.violations
+ .map((v) => `${v.id} (${v.impact}, ${v.nodes.length})`)
+ .join(', ')}`
+ );
+ } else {
+ const note = disabledRules.length ? ` [skipped: ${disabledRules.join(', ')}]` : '';
+ console.log(` ✓ ${story.id}${note}`);
+ }
+ } catch (e) {
+ renderErrors.push({ id: story.id, reason: 'axe: ' + e.message.split('\n')[0] });
+ console.error(` ✗ ${story.id} — axe error (${e.message.split('\n')[0]})`);
+ }
+ }
+ if (renderErrors.length) {
+ console.error(`\n${renderErrors.length} story/ies failed to render / axe-error:`);
+ for (const r of renderErrors) console.error(` - ${r.id}: ${r.reason}`);
+ failures.push(...renderErrors);
+ }
+
+ await browser.close();
+
+ if (failures.length) {
+ console.error(`\n${failures.length} story/ies with WCAG 2.2 AA violations.`);
+ exitCode = 1;
+ } else {
+ console.log(`\nAll ${stories.length} stories pass WCAG 2.2 AA (axe-core).`);
+ }
+} catch (err) {
+ console.error('a11y run failed:', err);
+ exitCode = 1;
+} finally {
+ server.kill();
+}
+
+process.exit(exitCode);
diff --git a/.storybook/assets/sidebar.json b/.storybook/assets/sidebar.json
new file mode 100644
index 0000000..0637a08
--- /dev/null
+++ b/.storybook/assets/sidebar.json
@@ -0,0 +1 @@
+[]
\ No newline at end of file
diff --git a/.storybook/contrast-audit.mjs b/.storybook/contrast-audit.mjs
new file mode 100644
index 0000000..1ccc43a
--- /dev/null
+++ b/.storybook/contrast-audit.mjs
@@ -0,0 +1,58 @@
+/**
+ * One-off contrast audit: runs axe color-contrast over every story (no per-story
+ * suppressions) and prints the unique failing foreground/background pairs with
+ * their ratio and the element, so the exact tokens to darken are known.
+ * Usage: node .storybook/contrast-audit.mjs (Storybook must be built)
+ */
+import { spawn } from 'node:child_process';
+import { readFileSync } from 'node:fs';
+import { chromium } from 'playwright';
+import { AxeBuilder } from '@axe-core/playwright';
+
+const PORT = 6023;
+const BASE = `http://127.0.0.1:${PORT}`;
+const server = spawn('npx', ['http-server', 'storybook-static', '-p', String(PORT), '-s', '-c-1'], { stdio: 'ignore' });
+
+async function waitUp(t = 20000) {
+ const end = Date.now() + t;
+ while (Date.now() < end) { try { if ((await fetch(`${BASE}/index.json`)).ok) return; } catch {} await new Promise(r => setTimeout(r, 300)); }
+ throw new Error('server not up');
+}
+
+try {
+ await waitUp();
+ const index = JSON.parse(readFileSync('storybook-static/index.json', 'utf8'));
+ const stories = Object.values(index.entries).filter(e => e.type === 'story');
+ const browser = await chromium.launch();
+ const page = await (await browser.newContext()).newPage();
+ const pairs = new Map(); // key: fg|bg -> {fg,bg,ratio,need,stories:Set,sample}
+
+ for (const s of stories) {
+ try {
+ await page.goto(`${BASE}/iframe.html?id=${s.id}&viewMode=story`, { waitUntil: 'domcontentloaded' });
+ await page.waitForTimeout(400);
+ const res = await new AxeBuilder({ page }).withTags(['wcag2aa', 'wcag21aa', 'wcag22aa']).options({ runOnly: ['color-contrast'] }).analyze();
+ for (const v of res.violations) {
+ for (const n of v.nodes) {
+ const d = n.any?.[0]?.data || {};
+ const key = `${d.fgColor}|${d.bgColor}`;
+ if (!pairs.has(key)) pairs.set(key, { fg: d.fgColor, bg: d.bgColor, ratio: d.contrastRatio, need: d.expectedContrastRatio, stories: new Set(), sample: (n.target || []).join(' ') });
+ pairs.get(key).stories.add(s.id.split('--')[0]);
+ }
+ }
+ } catch { /* skip */ }
+ }
+ await browser.close();
+
+ const rows = [...pairs.values()].sort((a, b) => (a.ratio || 0) - (b.ratio || 0));
+ console.log(`\n${rows.length} unique failing colour pairs:\n`);
+ for (const r of rows) {
+ console.log(`fg ${r.fg} on bg ${r.bg} = ${r.ratio}:1 (need ${r.need})`);
+ console.log(` components: ${[...r.stories].join(', ')}`);
+ console.log(` e.g. ${r.sample.slice(0, 80)}`);
+ }
+} catch (e) {
+ console.error('audit failed:', e.message);
+} finally {
+ server.kill();
+}
diff --git a/.storybook/host-main.ts b/.storybook/host-main.ts
new file mode 100644
index 0000000..504e4bd
--- /dev/null
+++ b/.storybook/host-main.ts
@@ -0,0 +1,4 @@
+// Storybook provides its own application bootstrap. This file only exists to
+// satisfy the Angular browser builder's `main` requirement for the build
+// context (tsconfig, styles, scripts) that Storybook reuses.
+export {};
diff --git a/.storybook/index.html b/.storybook/index.html
new file mode 100644
index 0000000..1e981d3
--- /dev/null
+++ b/.storybook/index.html
@@ -0,0 +1,10 @@
+
+
+
+
+ Storybook
+
+
+
+
+
diff --git a/.storybook/keyboard.mjs b/.storybook/keyboard.mjs
new file mode 100644
index 0000000..21c3c2d
--- /dev/null
+++ b/.storybook/keyboard.mjs
@@ -0,0 +1,287 @@
+/**
+ * Headless keyboard-interaction gate for Storybook (WCAG 2.1.1 keyboard,
+ * 2.1.2 no trap, 2.4.3/2.4.7 focus — see ACCESSIBILITY.md §4/§7).
+ *
+ * Unlike `.storybook/a11y.mjs` (structural axe rules run identically over
+ * every story), keyboard *behaviour* is control-specific: Tab/Enter/Esc/Arrow
+ * keys are expected to do different things on a switch vs. a combobox vs. a
+ * dialog. So this is a small, explicit table of {story, check(page)} cases —
+ * one (or a few) representative story per interaction pattern used across
+ * ca-ng-components / ca-ng-components-extra — rather than a generic sweep.
+ *
+ * Each check drives real keyboard events (`page.keyboard.press`, which
+ * dispatches trusted keydown/keyup — unlike `dispatchEvent`, this also
+ * triggers native behaviour on real form controls) and asserts the resulting
+ * DOM state (aria-*, focus, visibility). A case that throws is a failure.
+ *
+ * Same rationale as a11y.mjs for not using @storybook/test-runner: it pulls
+ * jest 30, which conflicts with the monorepo's jest 29 unit tests.
+ *
+ * Usage: build Storybook first, then `node .storybook/keyboard.mjs`
+ * (wrapped by `npm run test:keyboard`).
+ */
+import { spawn } from 'node:child_process';
+import { chromium } from 'playwright';
+
+const STATIC_DIR = 'storybook-static';
+const PORT = 6007;
+const BASE = `http://127.0.0.1:${PORT}`;
+
+function startServer() {
+ return spawn('npx', ['http-server', STATIC_DIR, '-p', String(PORT), '-s', '-c-1'], { stdio: 'ignore' });
+}
+
+async function waitForServer(timeoutMs = 20000) {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ try {
+ const res = await fetch(`${BASE}/index.json`);
+ if (res.ok) return;
+ } catch { /* not up yet */ }
+ await new Promise((r) => setTimeout(r, 300));
+ }
+ throw new Error(`Storybook static server did not start on ${BASE}`);
+}
+
+async function goto(page, id) {
+ await page.goto(`${BASE}/iframe.html?id=${id}&viewMode=story`, { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => {
+ const r = document.querySelector('#storybook-root');
+ return (r && r.children.length > 0) || !!document.querySelector('[role="dialog"], .cdk-overlay-container *');
+ }, { timeout: 8000 });
+ await page.waitForTimeout(300);
+}
+
+// `page.keyboard.press()` resolves once the DOM event is dispatched, but
+// Angular's change detection (zone flush) that reacts to it runs on a later
+// task — reading DOM state immediately after can observe stale attributes.
+// Every case presses through this helper instead of calling
+// `page.keyboard.press` directly, so state reads are never racing CD.
+async function press(page, key) {
+ await page.keyboard.press(key);
+ await page.waitForTimeout(150);
+}
+
+async function focus(locator) {
+ await locator.focus();
+ await locator.page().waitForTimeout(150);
+}
+
+/**
+ * One check per interaction *pattern*, not per component — e.g. sh-combo
+ * inherits sh-select's Arrow/Enter/Escape handling, so only sh-select is
+ * covered explicitly; a divergence would already be caught by axe/unit tests.
+ */
+const CASES = [
+ {
+ name: 'sh-toggle — Enter and Space both toggle the switch',
+ id: 'ng-components-toggle--default',
+ async run(page) {
+ const el = page.locator('.toggle[role="switch"]').first();
+ await focus(el);
+ const before = await el.getAttribute('aria-checked');
+ await press(page, 'Enter');
+ const afterEnter = await el.getAttribute('aria-checked');
+ if (afterEnter === before) throw new Error(`Enter did not toggle aria-checked (stayed ${before})`);
+ await press(page, ' ');
+ const afterSpace = await el.getAttribute('aria-checked');
+ if (afterSpace === afterEnter) throw new Error(`Space did not toggle aria-checked back (stayed ${afterEnter})`);
+ },
+ },
+ {
+ name: 'sh-checkbox — Space toggles the native checkbox',
+ id: 'ng-components-checkbox--default',
+ async run(page) {
+ const input = page.locator('input[type="checkbox"]').first();
+ await focus(input);
+ const before = await input.isChecked();
+ await press(page, ' ');
+ const after = await input.isChecked();
+ if (after === before) throw new Error(`Space did not toggle the checkbox (stayed ${before})`);
+ },
+ },
+ {
+ name: 'sh-radio — ArrowDown moves focus+selection, Space selects the focused option',
+ id: 'ng-components-radio--default',
+ async run(page) {
+ // The `.radio[role=radio]` divs carry no `id` (only their hidden native
+ // `` sibling does), so focus is tracked by element identity, not id.
+ const options = page.locator('.radio[role="radio"]');
+ const count = await options.count();
+ if (count < 2) throw new Error('story needs >=2 options to test Arrow navigation');
+ await focus(options.nth(0));
+ await press(page, 'ArrowDown');
+ const focusedSecond = await options.nth(1).evaluate((el) => el === document.activeElement);
+ if (!focusedSecond) throw new Error('ArrowDown did not move focus to the next option');
+ await press(page, ' ');
+ const checkedAfter = await options.nth(1).getAttribute('aria-checked');
+ if (checkedAfter !== 'true') throw new Error(`Space did not select the focused option (aria-checked=${checkedAfter})`);
+ },
+ },
+ {
+ name: 'sh-checkgroup — Space toggles a single item without affecting siblings',
+ id: 'ng-components-checkgroup--default',
+ async run(page) {
+ const items = page.locator('.checkbox[role="checkbox"]');
+ if (await items.count() < 1) throw new Error('no checkbox items rendered');
+ const first = items.nth(0);
+ await focus(first);
+ const before = await first.getAttribute('aria-checked');
+ await press(page, ' ');
+ const after = await first.getAttribute('aria-checked');
+ if (after === before) throw new Error(`Space did not toggle the item (stayed ${before})`);
+ },
+ },
+ {
+ name: 'sh-tabs — ArrowRight moves to and selects the next tab, Home jumps to the first',
+ id: 'ng-components-tabs--default',
+ async run(page) {
+ const tabs = page.locator('[role="tab"]');
+ const count = await tabs.count();
+ if (count < 2) throw new Error('story needs >=2 tabs to test Arrow navigation');
+ const focusable = page.locator('[role="tab"][tabindex="0"]').first();
+ await focus(focusable);
+ await press(page, 'ArrowRight');
+ const focusedId = await page.evaluate(() => document.activeElement?.id);
+ const selectedNow = await page.locator(`#${focusedId}`).getAttribute('aria-selected');
+ if (selectedNow !== 'true') throw new Error(`ArrowRight did not select the newly-focused tab (aria-selected=${selectedNow})`);
+ await press(page, 'Home');
+ const firstTabId = await tabs.nth(0).getAttribute('id');
+ const focusedAfterHome = await page.evaluate(() => document.activeElement?.id);
+ if (focusedAfterHome !== firstTabId) throw new Error(`Home did not move focus to the first tab (focus=${focusedAfterHome})`);
+ },
+ },
+ {
+ name: 'sh-select — closed ArrowDown cycles the value, Alt+ArrowDown opens the listbox, Enter commits, Escape closes',
+ id: 'ng-components-select--default',
+ async run(page) {
+ const combobox = page.locator('[role="combobox"]').first();
+ await focus(combobox);
+ // Closed + plain ArrowDown cycles the committed value directly (native
+ // OS