diff --git a/FOLLOWUPS.md b/FOLLOWUPS.md index 37fe54b..594473e 100644 --- a/FOLLOWUPS.md +++ b/FOLLOWUPS.md @@ -86,6 +86,49 @@ Optional polish (not blocking — a future "toast polish" pass): --- +## 6. Command — combobox missing `aria-controls` (axe baseline) + +- **[P1] `role="combobox"` on the command search input lacks `aria-controls`.** + The WAI-ARIA combobox pattern requires `aria-controls` to point at the popup + listbox element when `aria-expanded="true"`. The `command_input/1` component + renders `role="combobox" aria-expanded="true"` but omits `aria-controls`. + Axe rule: `aria-required-attr` (critical). Fix: add `aria-controls={"#{@id}-list"}` + to the input, and ensure `command_list/1` carries a matching `id`. + + Affects: `lib/shadix/components/command.ex`. + Tracked in: `test/e2e/a11y/baseline.spec.mjs` (`FIXME_SLUGS`). + +--- + +## 7. Alert — destructive description text contrast too low (axe baseline) + +- **[P1] `alert-description` in the `destructive` variant uses `text-muted-foreground` + on a white (`bg-card`) background, yielding a contrast ratio of 4.49:1 — just below + the WCAG AA minimum of 4.5:1.** The description slot explicitly applies + `text-muted-foreground` regardless of variant; the destructive variant should use a + colour with sufficient contrast against `bg-card`. + Axe rule: `color-contrast` (serious). + + Affects: `lib/shadix/components/alert.ex`, `priv/styles/*/alert.css`. + Tracked in: `test/e2e/a11y/baseline.spec.mjs` (`FIXME_SLUGS`). + +--- + +## 8. Kbd — `text-muted-foreground` on `bg-muted` contrast too low (axe baseline) + +- **[P1] `` elements render `text-muted-foreground` (#737373) on `bg-muted` + (#f5f5f5), yielding a contrast ratio of 4.34:1 — below the WCAG AA minimum of + 4.5:1 for normal-weight text at 12px.** The component's colocated CSS assigns + these token values; both tokens need to be adjusted so that `muted-foreground` + on `muted` meets 4.5:1, or the kbd component needs a dedicated higher-contrast + token pair. + Axe rule: `color-contrast` (serious). + + Affects: `lib/shadix/components/kbd.ex`, `priv/styles/*/kbd.css`. + Tracked in: `test/e2e/a11y/baseline.spec.mjs` (`FIXME_SLUGS`). + +--- + ## Process reminder After editing any `lib/shadix/components/*.ex` or `assets/ts/*.ts`, run diff --git a/test/e2e/a11y/baseline.spec.mjs b/test/e2e/a11y/baseline.spec.mjs new file mode 100644 index 0000000..b4fab28 --- /dev/null +++ b/test/e2e/a11y/baseline.spec.mjs @@ -0,0 +1,37 @@ +import { test, expect } from "../support/axe-fixture.mjs"; +import { slugs } from "../support/slugs.mjs"; +import { unexpected } from "../support/allowlist.mjs"; + +// Slugs with genuine a11y defects that require component-level fixes. +// Each entry is tracked in FOLLOWUPS.md at the repo root. +const FIXME_SLUGS = new Set([ + // command: role="combobox" is missing the required aria-controls attribute + // pointing to the listbox. (rule: aria-required-attr, critical) + "command", + // alert: destructive-variant description text uses text-muted-foreground on a + // white card background — contrast ratio 4.49, below the 4.5:1 AA minimum. + // (rule: color-contrast, serious) + "alert", + // kbd: text-muted-foreground (#737373) on bg-muted (#f5f5f5) yields 4.34:1, + // below the 4.5:1 AA minimum. (rule: color-contrast, serious) + "kbd", +]); + +for (const slug of slugs) { + const fn = FIXME_SLUGS.has(slug) ? test.fixme : test; + fn(`${slug}: a11y (baseline render)`, async ({ page, makeAxeBuilder }, testInfo) => { + await page.goto(`/components/${slug}`); + await page.locator("[data-shadix-preview]").first().waitFor(); + + const { violations } = await makeAxeBuilder().analyze(); + const bad = unexpected(slug, violations); + + // Attach the full result so CI logs pinpoint the rule + element on failure. + await testInfo.attach("axe-violations", { + body: JSON.stringify(bad.map((v) => ({ id: v.id, impact: v.impact, help: v.help, nodes: v.nodes.map((n) => n.target) })), null, 2), + contentType: "application/json", + }); + + expect(bad, bad.map((v) => `${v.id} (${v.impact}): ${v.help}`).join("\n")).toEqual([]); + }); +} diff --git a/test/e2e/support/allowlist.mjs b/test/e2e/support/allowlist.mjs index 44fdd94..36c0df9 100644 --- a/test/e2e/support/allowlist.mjs +++ b/test/e2e/support/allowlist.mjs @@ -2,6 +2,74 @@ // defects. Each entry MUST carry a comment justifying it. Any violation whose id // is not listed here for the given slug fails the test. export const ALLOW = { + // The accordion configurator preview renders h3 accordion-headers (correct ARIA + // semantic). The docs-site layout puts h4 sidebar labels before a h1 page title, + // so the first heading inside [data-shadix-preview] is an h3, which axe flags as + // a heading-order violation. This is a docs-page layout artifact, not a component + // defect — accordion_trigger correctly uses h3 as the accordion header element. + accordion: ["heading-order"], + + // The "Sizes" demo shows an icon-only button with a gear SVG (aria-hidden) and no + // aria-label to keep the snippet focused on size variants. The button component + // accepts aria-label via @rest; the demo simply omits it. Real icon buttons must + // carry aria-label — this gap belongs to the demo copy, not the component. + button: ["button-name"], + + // Two carousels appear on the docs page: the configurator preview (#preview-carousel) + // and the "Basic" demo (#basic-carousel). Both render role="region" without an + // aria-label, so axe reports them as duplicate unlabeled landmarks. In a real app a + // page has one carousel (or authors add aria-label via @rest). The component accepts + // aria-label through @rest; the docs demos omit it. + carousel: ["landmark-unique"], + + // Demos use for the label text beside the checkbox instead of