diff --git a/.changeset/badge-link-primary-strong.md b/.changeset/badge-link-primary-strong.md new file mode 100644 index 00000000..c303d1ec --- /dev/null +++ b/.changeset/badge-link-primary-strong.md @@ -0,0 +1,6 @@ +--- +'@surfnet/curve-react': patch +'@surfnet/curve-angular': patch +--- + +Use `--primary-strong` for link-style text (Badge `link`, and inline links in Field, Empty, and Item) so it meets contrast on the page background. Filled Primary is unchanged. diff --git a/.changeset/combobox-trigger-aria.md b/.changeset/combobox-trigger-aria.md new file mode 100644 index 00000000..d72e28be --- /dev/null +++ b/.changeset/combobox-trigger-aria.md @@ -0,0 +1,6 @@ +--- +'@surfnet/curve-angular': patch +'@surfnet/curve-react': patch +--- + +Wire combobox input trigger button `aria-expanded`, `aria-haspopup="listbox"`, and `aria-controls` to the listbox id. Add default `aria-label="Clear"` on the React clear button. diff --git a/.changeset/command-accessible-label.md b/.changeset/command-accessible-label.md new file mode 100644 index 00000000..32cca34a --- /dev/null +++ b/.changeset/command-accessible-label.md @@ -0,0 +1,5 @@ +--- +'@surfnet/curve-react': patch +--- + +Give Command a default accessible label ("Type a command or search...") so cmdk no longer renders an empty visually hidden label. diff --git a/.changeset/command-empty-live-region.md b/.changeset/command-empty-live-region.md new file mode 100644 index 00000000..6cc62bce --- /dev/null +++ b/.changeset/command-empty-live-region.md @@ -0,0 +1,6 @@ +--- +'@surfnet/curve-angular': patch +'@surfnet/curve-react': patch +--- + +Keep Command empty state mounted in the DOM and announce it with `aria-live="polite"` and `aria-atomic`. Use `role="option"` (`aria-disabled`, `aria-selected="false"`) so the empty message is a valid listbox child, and mark separators as presentational so listboxes only expose `option` / `group` children. diff --git a/.changeset/command-input-aria-label.md b/.changeset/command-input-aria-label.md new file mode 100644 index 00000000..bdb09586 --- /dev/null +++ b/.changeset/command-input-aria-label.md @@ -0,0 +1,5 @@ +--- +'@surfnet/curve-angular': patch +--- + +Forward `aria-label` and `aria-labelledby` from Command Input onto the underlying combobox `` so the accessible name is on the field assistive technology uses, not the wrapper. diff --git a/.changeset/data-table-action-cell-aria.md b/.changeset/data-table-action-cell-aria.md new file mode 100644 index 00000000..8e48c1cb --- /dev/null +++ b/.changeset/data-table-action-cell-aria.md @@ -0,0 +1,5 @@ +--- +'@surfnet/curve-angular': patch +--- + +Give the data table action-cell icon button and each menu item accessible names so the icon-only trigger and actions are announced by screen readers. diff --git a/.changeset/input-otp-aria-label.md b/.changeset/input-otp-aria-label.md new file mode 100644 index 00000000..02ebc05b --- /dev/null +++ b/.changeset/input-otp-aria-label.md @@ -0,0 +1,5 @@ +--- +'@surfnet/curve-angular': patch +--- + +Forward `aria-label` from Input OTP onto the underlying input so unlabeled examples (and consumers) can name the field for assistive technology. diff --git a/.changeset/input-otp-complete-live-region.md b/.changeset/input-otp-complete-live-region.md new file mode 100644 index 00000000..15b9e2e9 --- /dev/null +++ b/.changeset/input-otp-complete-live-region.md @@ -0,0 +1,6 @@ +--- +'@surfnet/curve-angular': patch +'@surfnet/curve-react': patch +--- + +Announce Input OTP completion to screen readers with a polite live region so pasting a full code is perceivable. diff --git a/.changeset/resizable-handle-aria.md b/.changeset/resizable-handle-aria.md new file mode 100644 index 00000000..43471ddb --- /dev/null +++ b/.changeset/resizable-handle-aria.md @@ -0,0 +1,5 @@ +--- +'@surfnet/curve-angular': patch +--- + +Set `aria-grabbed` and `aria-valuenow` on Resizable handles, and expose an `isDragging` signal on the group and handle. diff --git a/.changeset/scroll-area-tabindex.md b/.changeset/scroll-area-tabindex.md new file mode 100644 index 00000000..a8ec68ca --- /dev/null +++ b/.changeset/scroll-area-tabindex.md @@ -0,0 +1,6 @@ +--- +'@surfnet/curve-angular': patch +'@surfnet/curve-react': patch +--- + +Always set `tabindex="0"` on ScrollArea so the scrollable region is keyboard-accessible, including before overflow is measured. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8916b24..e75fceb1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,13 @@ concurrency: jobs: checks: runs-on: ubuntu-latest + # The a11y steps write a PR comment and push violation screenshots to the + # a11y-screenshots branch. + permissions: + contents: write + pull-requests: write + env: + PR_NUMBER: ${{ github.event.pull_request.number }} steps: - uses: actions/checkout@v4 @@ -42,3 +49,46 @@ jobs: - run: pnpm lint - run: pnpm build + + # The audit runs @storybook/test-runner, which drives Playwright Chromium + # headless (no display needed on CI). Install it once, scoped to + # @surfnet/curve-storybook-config which pins playwright, so both framework + # runs share one browser. + - name: Install Playwright Chromium + run: pnpm --filter @surfnet/curve-storybook-config exec playwright install --with-deps chromium + + # Headless axe sweep of every story across all themes/modes. Fast: the + # test phase is a few seconds per framework once Storybook is built (the + # build-storybook step dominates). Report-only for now; flip to blocking by + # removing `continue-on-error`. + - name: Accessibility audit (WCAG 2.1 AA) + continue-on-error: true + run: pnpm test:a11y + + - name: Upload a11y reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: a11y-reports + path: packages/*/.a11y-report/** + if-no-files-found: ignore + + # Pushes violating-element screenshots to the `a11y-screenshots` branch + # so the PR comment below can embed them by raw.githubusercontent.com + # URL. PR-only: there's no PR to attach screenshots to on a push build. + - name: Publish a11y screenshots + if: always() && github.event_name == 'pull_request' + run: ./scripts/publish-a11y-screenshots.sh + + # Summarise the JSON reports into .a11y-report/comment.md. + - name: Summarise a11y findings + if: always() + run: pnpm exec jiti scripts/a11y-comment.ts + + # One sticky comment, updated in place. + - name: Comment a11y findings on PR + if: always() && github.event_name == 'pull_request' + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: a11y-audit + path: .a11y-report/comment.md diff --git a/.gitignore b/.gitignore index 7b3d4fd9..cf88cd62 100644 --- a/.gitignore +++ b/.gitignore @@ -1,31 +1,34 @@ -# dependencies -node_modules -.pnpm-store - -# secrets -.env -.env.local - -# build output -dist -storybook-static - -# turbo -.turbo - -# angular -.angular - -# logs -*.log -npm-debug.log* -pnpm-debug.log* - -# editor / os -.DS_Store -.vs -!.vscode -.vscode/* -!.vscode/extensions.json -!.vscode/mcp.json -.idea +# dependencies +node_modules +.pnpm-store + +# secrets +.env +.env.local + +# build output +dist +storybook-static + +# accessibility audit reports +.a11y-report + +# turbo +.turbo + +# angular +.angular + +# logs +*.log +npm-debug.log* +pnpm-debug.log* + +# editor / os +.DS_Store +.vs +\!.vscode +.vscode/* +\!.vscode/extensions.json +\!.vscode/mcp.json +.idea diff --git a/docs/decision-log.md b/docs/decision-log.md index f07a2b6c..bb7f6827 100644 --- a/docs/decision-log.md +++ b/docs/decision-log.md @@ -46,6 +46,7 @@ the replacement. | 17 | [Prove it in a real app](#adr-017--prove-it-in-a-real-app) | Proposed | 2026-06-30 | | 18 | [Relative imports for vendored helm cross-references](#adr-018--relative-imports-for-vendored-helm-cross-references) | Accepted | 2026-07-01 | | 19 | [Only add components available in both frameworks](#adr-019--only-add-components-available-in-both-frameworks) | Accepted | 2026-07-13 | +| 20 | [Keep Command off Storybook until we use it](#adr-020--keep-command-off-storybook-until-we-use-it) | Accepted | 2026-09-01 | ### Open questions (not yet decided) @@ -452,3 +453,32 @@ here than let per-framework drift creep in one component at a time. - **Typography** — decide whether this becomes a real component (e.g. `Heading`/`Text` wrapper components with contract-enforced size/weight scales) or stays documentation-only (a Storybook foundations page, like the existing Design Tokens stories) before building it. + +--- + +## ADR-020 — Keep Command off Storybook until we use it + +**Status:** Accepted · **Date:** 2026-09-01 + +**Context.** Command is vendored in both frameworks (`@surfnet/curve-react` and +`@surfnet/curve-angular`) because shadcn and Spartan ship it as a primitive. We don't +intend to offer it as a public, documented component yet — there is no product use for +a command palette — but ADR-016/013 would otherwise require a Storybook story for every +shipped component. Leaving those stories in place surfaces Command in the catalog and +the a11y audit as if it were ready for consumers. + +**Decision.** Keep the Command **implementation** in both packages (and still export it), +but **do not document it in Storybook**. The React and Angular story files were removed +so Command stays out of the sidebar, Docs, and the a11y sweep. + +**Rationale.** Storybook is the living catalog (ADR-013). Showing a component we are not +ready to stand behind is more confusing than keeping the code "under water" until there +is a real use. Deleting stories rather than deleting the component avoids re-vendoring +later. + +**Consequences.** Command is an explicit exception to "every component gets a story" +(ADR-016). Bring it back when we decide to use it: restore +`packages/react/src/components/ui/command/command.stories.tsx` and +`packages/angular/src/lib/ui/command/src/lib/hlm-command.stories.ts` (git history has the +last versions), covering the full surface the way the other components do. Until then, +treat Command as internal plumbing, not a documented Curve component. diff --git a/package.json b/package.json index 0cccfc8b..1bd2da79 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "storybook": "turbo run storybook", "build-storybook": "turbo run build-storybook", "lint": "turbo run lint", + "test:a11y": "turbo run test:a11y:ci", "format": "prettier --write \"**/*.{ts,tsx,js,mjs,cjs,json,md,css,html}\"", "format:check": "prettier --check \"**/*.{ts,tsx,js,mjs,cjs,json,md,css,html}\"", "changeset": "changeset", diff --git a/packages/angular/.storybook/preview.ts b/packages/angular/.storybook/preview.ts index e4bb95dd..042a5694 100644 --- a/packages/angular/.storybook/preview.ts +++ b/packages/angular/.storybook/preview.ts @@ -1,4 +1,5 @@ import { + a11yParameters, frameworkGlobalTypes, frameworkSwitcher, sharedParameters, @@ -16,6 +17,7 @@ export default { decorators: [frameworkSwitcher('angular'), themeSwitcher()], parameters: { ...sharedParameters, + ...a11yParameters, // Must be a literal (Storybook reads it via static analysis, not // execution). Keep in sync with packages/react/.storybook/preview.ts. options: { diff --git a/packages/angular/.storybook/test-runner.ts b/packages/angular/.storybook/test-runner.ts new file mode 100644 index 00000000..7e6cab72 --- /dev/null +++ b/packages/angular/.storybook/test-runner.ts @@ -0,0 +1,12 @@ +import type { TestRunnerConfig } from '@storybook/test-runner'; + +import { runStoryA11yAudit } from '@surfnet/curve-storybook-config/test-runner'; + +// Delegate to the shared audit so React and Angular stay in lockstep. +const config: TestRunnerConfig = { + async postVisit(page, context) { + await runStoryA11yAudit(page, context); + }, +}; + +export default config; diff --git a/packages/angular/package.json b/packages/angular/package.json index 7a303602..9dca4afc 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -26,7 +26,10 @@ "lint": "ngc --noEmit -p tsconfig.json", "storybook": "ng run angular:storybook", "build-storybook": "ng run angular:build-storybook", - "fix-helm-imports": "jiti scripts/rewrite-helm-imports.ts" + "fix-helm-imports": "jiti scripts/rewrite-helm-imports.ts", + "storybook:serve": "http-server storybook-static -p 6007 --silent", + "test:a11y": "test-storybook --url http://127.0.0.1:6007 --testTimeout 180000", + "test:a11y:ci": "start-server-and-test storybook:serve http://127.0.0.1:6007 test:a11y" }, "peerDependencies": { "@angular/common": "^22.0.0", @@ -63,15 +66,19 @@ "@storybook/addon-a11y": "10.4.5", "@storybook/addon-docs": "10.4.5", "@storybook/angular": "10.4.5", + "@storybook/test-runner": "0.24.4", "@surfnet/curve-contracts": "workspace:*", "@surfnet/curve-storybook-config": "workspace:*", "@surfnet/curve-tokens": "workspace:*", "@surfnet/curve-typescript-config": "workspace:*", "@tailwindcss/cli": "4.3.1", "@tailwindcss/postcss": "4.3.1", + "axe-playwright": "2.2.2", + "http-server": "14.1.1", "jiti": "2.7.0", "ng-packagr": "22.0.0", "rxjs": "7.8.2", + "start-server-and-test": "3.0.11", "storybook": "10.4.5", "tailwindcss": "4.3.1", "tw-animate-css": "1.4.0", diff --git a/packages/angular/src/lib/ui/badge/src/lib/hlm-badge.ts b/packages/angular/src/lib/ui/badge/src/lib/hlm-badge.ts index fcb62ba9..3864637c 100644 --- a/packages/angular/src/lib/ui/badge/src/lib/hlm-badge.ts +++ b/packages/angular/src/lib/ui/badge/src/lib/hlm-badge.ts @@ -10,7 +10,7 @@ const badgeVariantClasses = { 'bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20', outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground', ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50', - link: 'text-primary underline-offset-4 hover:underline', + link: 'text-primary-strong underline-offset-4 hover:underline', } satisfies Record; const badgeVariants = cva( diff --git a/packages/angular/src/lib/ui/breadcrumb/src/lib/hlm-breadcrumb-ellipsis.ts b/packages/angular/src/lib/ui/breadcrumb/src/lib/hlm-breadcrumb-ellipsis.ts index 3bddbd0d..d4456c20 100644 --- a/packages/angular/src/lib/ui/breadcrumb/src/lib/hlm-breadcrumb-ellipsis.ts +++ b/packages/angular/src/lib/ui/breadcrumb/src/lib/hlm-breadcrumb-ellipsis.ts @@ -11,14 +11,8 @@ import type { ClassValue } from 'clsx'; providers: [provideIcons({ phosphorDotsThree })], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -