diff --git a/.agents/skills/add-component/SKILL.md b/.agents/skills/add-component/SKILL.md index 5eebde7c..fffbb6bb 100644 --- a/.agents/skills/add-component/SKILL.md +++ b/.agents/skills/add-component/SKILL.md @@ -6,7 +6,12 @@ description: Use when adding, scaffolding, or vendoring a component into the des # Add a component The design system ships components per framework. Decide the scope first, then follow the -matching playbook(s) in this skill's subfiles: +matching playbook(s) in this skill's subfiles. + +**Already in the repo?** Do not re-run this add flow. Use +[`update-component`](../update-component/SKILL.md) so Curve design and accessibility +edits are merged, not overwritten. Changes can have an effect on accessibility — +see that playbook before restyling or re-vendoring. - **React only** → follow [`react.md`](react.md) (shadcn/Base UI flow for `@surfnet/curve-react`). - **Angular only** → follow [`angular.md`](angular.md) (Spartan flow for `@surfnet/curve-angular`). diff --git a/.agents/skills/add-component/angular.md b/.agents/skills/add-component/angular.md index 83fb035d..0ef5060b 100644 --- a/.agents/skills/add-component/angular.md +++ b/.agents/skills/add-component/angular.md @@ -117,6 +117,13 @@ pnpm format the contract object. - `pnpm build`, `pnpm format`, and `build-storybook` all pass. +## Updating an existing component + +Do not re-run `ng g @spartan-ng/cli:ui` on a component that is already vendored +— the generator overwrites helm. Follow +[`update-component/angular.md`](../update-component/angular.md). Restyling or +merging upstream can have an effect on accessibility. + ## Notes - The Spartan CLI always vendors cross-component imports through the `@spartan-ng/helm/*` diff --git a/.agents/skills/add-component/react.md b/.agents/skills/add-component/react.md index c46327a0..84c03025 100644 --- a/.agents/skills/add-component/react.md +++ b/.agents/skills/add-component/react.md @@ -136,6 +136,12 @@ pnpm format the contract object. - `pnpm build`, `pnpm lint`, `pnpm format`, and `build-storybook` all pass. +## Updating an existing component + +Do not re-run `shadcn add` (or `--overwrite`) on a component that is already +vendored. Follow [`update-component/react.md`](../update-component/react.md). +Restyling or merging upstream can have an effect on accessibility. + ## Notes - Icons: import from `@phosphor-icons/react` (components are suffixed `Icon`, e.g. diff --git a/.agents/skills/update-component/SKILL.md b/.agents/skills/update-component/SKILL.md new file mode 100644 index 00000000..ec71800b --- /dev/null +++ b/.agents/skills/update-component/SKILL.md @@ -0,0 +1,117 @@ +--- +name: update-component +description: >- + Merge upstream shadcn or Spartan changes into an already-vendored Curve + component without overwriting local design or accessibility edits. Use when + updating, upgrading, re-vendoring, re-adding, or refreshing an existing + component; when running shadcn add or ng g on a component that is already in + the repo; or when preserving CURVE customizations against upstream. +--- + +# Update a component + +Use this playbook for a component that **already lives in the repo**. For a +component that is not vendored yet, use [`add-component`](../add-component/SKILL.md) +instead. + +- **React only** → [`react.md`](react.md) (`@surfnet/curve-react` / shadcn). +- **Angular only** → [`angular.md`](angular.md) (`@surfnet/curve-angular` / Spartan). +- **Both** → do each playbook, then keep variants, sizes, stories, and a11y + behaviour in parity. + +**Never treat a re-vendor as an install.** The CLIs copy source into the package; +running them again on an existing component can wipe Curve edits. Git is the +source of truth. Diff, then merge. + +## Accessibility + +**Changes can have an effect on accessibility.** Styling, markup, ARIA, focus +order, contrast, and keyboard behaviour are coupled. A visual tweak (hiding a +label, restyling a focus ring, swapping an icon-only control, changing +hover-only state) can make the component unusable with a keyboard or screen +reader. An upstream merge can silently drop a Curve a11y fix. + +Before you finish any update or local edit: + +1. Re-read the component as assistive tech would: name, role, state, keyboard + path, visible focus, contrast. +2. Check the Storybook **Accessibility** addon (`@storybook/addon-a11y`) on the + Playground and on state stories (disabled, invalid, open, icon-only). +3. Keep or update story tags: `a11y-gap` (known WCAG gap) and `a11y-minor` + (smaller concern). They come from the shared config in + `@surfnet/curve-storybook-config`. If you **fix** an issue, remove or + downgrade the tag. If you **confirm** or **introduce** one, add the matching + tag. Do not drop these tags during a merge “to match upstream.” +4. Prefer putting durable a11y fixes in markup / ARIA / Brain-adjacent helm + behaviour, and covering them with a story or test so a later re-vendor + fails loudly. + +## Where to put the change + +| Kind of change | Put it here | Survives a re-vendor? | +| --- | --- | --- | +| Color, radius, type, spacing | Tokens (`packages/tokens`) + `@theme` mapping | Yes | +| Variant / size / axis **names** | `@surfnet/curve-contracts` + `satisfies` on both frameworks | Compile error if upstream reintroduces a stray name | +| Extra look on top of upstream | Compose via `cn()` / `hlm()`, keep the extra classes Curve-owned | Only if you re-apply them in the merge | +| Missing `aria-*`, roles, focus, names, keyboard | Vendored TS / templates (helm, not Brain) | No — mark with `CURVE:` and cover with a story/test | +| Headless behaviour (Angular) | Stay on `@spartan-ng/brain` via npm. Do **not** edit `node_modules`. Compensate in helm or wrap. | Brain updates with `pnpm update` + `healthcheck` | + +Design that only exists as “we rewrote 40 Tailwind classes in the vendored +file” will fight every upstream update. Design that lives in tokens will not. + +## Mark Curve deviations + +Every intentional difference from upstream gets a durable marker next to the +code, so a `--diff` / `git diff` review can keep it: + +```ts +// CURVE: a11y — icon-only buttons need an accessible name (do not drop on re-vendor) +// CURVE: design — focus ring uses ring-offset-background to match tokens +``` + +Use `a11y` or `design` (or both) and a short why. Same idea in `cva` maps and +Angular templates (``). + +Audit markers with: + +```bash +rg "CURVE:" packages/react/src/components/ui packages/angular/src/lib/ui +``` + +Do **not** add a `CURVE:` comment for contract `satisfies` wiring, barrels, +stories, or `fix-helm-imports` rewrites — those are standard Curve scaffolding, +not deviations. + +## Hard rules (agents and humans) + +1. **Do not** run `shadcn add --overwrite`, `shadcn add --all`, or + `ng g @spartan-ng/cli:ui` on an existing component as a refresh. Follow the + per-framework playbook. +2. **Do not** use the shadcn MCP `add` tool to update an existing component — it + writes files flat and skips the `--path` layout. +3. Preserve every `CURVE:` hunk unless the user explicitly drops that + customization. Preserve contract `satisfies` / `*Name` typing. +4. After Angular helm is regenerated, run + `pnpm --filter @surfnet/curve-angular fix-helm-imports` and confirm `dist` + has no `@spartan-ng/helm` imports. +5. Stop and ask if the upstream diff is large, renames the public API, or + disagrees with the contract. + +## After both frameworks (parity) + +If the component exists in both packages, confirm after the merge: + +- Variant / size / axis **names** still match the contract (`pnpm lint`). +- Story names still match across Storybooks. +- A11y behaviour matches: the same issue is not fixed on one side and left + tagged `a11y-gap` on the other without a documented reason. + +## Definition of done + +- Upstream hunks merged; Curve `CURVE:` edits and contract wiring kept. +- Story `a11y-gap` / `a11y-minor` tags match the current state. +- Storybook a11y addon checked for the stories you touched. +- `pnpm build`, `pnpm lint`, and `pnpm format` pass for the packages you + touched; rebuild Storybook if stories changed. +- A changeset added (`pnpm changeset`) if a publishable package changed. +- Any React/Angular gap left after the update is documented, not hidden. diff --git a/.agents/skills/update-component/angular.md b/.agents/skills/update-component/angular.md new file mode 100644 index 00000000..56e1780c --- /dev/null +++ b/.agents/skills/update-component/angular.md @@ -0,0 +1,124 @@ +# Update an Angular component (`@surfnet/curve-angular`) + +Part of the **update-component** skill — see [`SKILL.md`](SKILL.md) for shared +rules, `CURVE:` markers, and accessibility. + +Spartan splits each component in two: + +| Layer | Where | How it updates | +| --- | --- | --- | +| **Brain** | `@spartan-ng/brain/` in `node_modules` | `pnpm update` + `ng g @spartan-ng/cli:healthcheck --autoFix` | +| **Helm** | copied into `src/lib/ui//` | You own it. Re-running the `ui` generator **overwrites** helm. There is no `--diff`. | + +Do **not** edit Brain sources. Put accessibility and design fixes in helm (or a +wrapper). If the bug is in Brain itself, compensate in helm and document the +gap rather than patching `node_modules`. + +## Brain (npm) + +After bumping `@spartan-ng/brain` / `@spartan-ng/cli`: + +```bash +cd packages/angular +pnpm exec ng g @spartan-ng/cli:healthcheck +pnpm exec ng g @spartan-ng/cli:healthcheck --autoFix +``` + +Healthcheck reconciles deprecated Brain APIs. It is not a helm merge. Review +the diff; keep `CURVE:` helm edits. + +## Helm (copied source) + +The `ui` generator copies helm into `componentsPath` and may also pull +**dependent** components. Treat every already-vendored directory it would +touch as in-scope. + +### Steps + +1. **Confirm it is already vendored.** If `src/lib/ui//` does not exist, + stop and use [`add-component/angular.md`](../add-component/angular.md). + +2. **See what is installed:** + + ```bash + cd packages/angular + pnpm exec ng g @spartan-ng/cli:info --json + ``` + +3. **Working tree must be clean** for the helm dirs you are about to + regenerate (commit or stash Curve work first). You need `HEAD` as “ours.” + +4. **Regenerate, then immediately inspect — do not commit the dump:** + + ```bash + cd packages/angular + pnpm exec ng g @spartan-ng/cli:ui --defaults + git diff -- packages/angular/src/lib/ui// + ``` + + `git diff` is **ours (HEAD, Curve) vs theirs (new helm)**. Dependent + components the generator also copied show up in the same diff — merge those + too, or restore them with `git checkout -- ` if they were collateral. + +5. **Restore ours and merge by hand** (do not leave the generator output as + the new source): + + ```bash + # keep a copy of theirs, then restore Curve + cp -R src/lib/ui/ /tmp/curve-helm--theirs + git checkout -- src/lib/ui/ + ``` + + Apply upstream hunks from `/tmp/curve-helm--theirs` onto the restored + Curve files. Keep every `CURVE:` hunk, contract `satisfies` / typed inputs, + story files, and Phosphor / `NgIcon` conventions. + +6. **Always re-run import rewrite** after any generator write: + + ```bash + pnpm --filter @surfnet/curve-angular fix-helm-imports + ``` + + Confirm the published bundle will not leak the alias: + + ```bash + pnpm --filter @surfnet/curve-angular build + grep -r "@spartan-ng/helm" packages/angular/dist + ``` + + (`grep` must print nothing.) + +7. **Re-apply Curve scaffolding** the generator does not know about: + + - Contract import + `satisfies Record<*Name, string>` (or typed inputs). + - Export from `src/public-api.ts`. + - New runtime deps in `ng-package.json` → `allowedNonPeerDependencies`. + - Stories still source docs from the contract. Do not let the generator + delete `*.stories.ts`. + +8. **Accessibility pass** — see [SKILL.md](SKILL.md#accessibility). Check + `@storybook/addon-a11y`. Update `a11y-gap` / `a11y-minor` tags if the merge + fixed, confirmed, or reintroduced an issue. Put back any dropped + `CURVE: a11y` hunks. + +There is no approved `--overwrite` equivalent. If the user wants stock Spartan +helm back, say that explicitly, take the generator output, then still run +`fix-helm-imports` and re-apply contract wiring / stories / a11y markers. + +## Verify + +```bash +pnpm --filter @surfnet/curve-contracts lint +pnpm --filter @surfnet/curve-angular build +pnpm format +``` + +Rebuild Storybook if stories changed: +`pnpm --filter @surfnet/curve-angular build-storybook`. + +## Notes + +- Do not re-vendor `hlm-icon`. Phosphor + `NgIcon` sizing is a Curve + convention; see [add-component/angular.md](../add-component/angular.md). +- Theme CSS lives in `@surfnet/curve-tokens`, not in a `ui-theme` regenerate. + Do not run `ui-theme` as part of a component update. diff --git a/.agents/skills/update-component/react.md b/.agents/skills/update-component/react.md new file mode 100644 index 00000000..88c7a158 --- /dev/null +++ b/.agents/skills/update-component/react.md @@ -0,0 +1,84 @@ +# Update a React component (`@surfnet/curve-react`) + +Part of the **update-component** skill — see [`SKILL.md`](SKILL.md) for shared +rules, `CURVE:` markers, and accessibility. + +Components are vendored with the shadcn CLI into +`packages/react/src/components/ui//`. Updating means a **3-way-style +merge** of upstream registry source into that folder, not a re-install. + +Use the CLI from `packages/react` so `components.json` (Base UI, Phosphor, +`--path` layout) applies. **Never fetch raw files from GitHub** — the CLI +resolves the registry and CSS. + +## Steps + +1. **Confirm it is already vendored.** If `src/components/ui//` does not + exist, stop and use [`add-component/react.md`](../add-component/react.md). + +2. **List what the CLI would touch** (no writes): + + ```bash + cd packages/react + pnpm dlx shadcn@latest add --dry-run + ``` + + Note sibling files (the CLI may want to update dependencies of this + component). Each already-vendored sibling needs the same merge treatment — + do not overwrite them as collateral. + +3. **Diff each affected file against the local copy:** + + ```bash + pnpm dlx shadcn@latest add --diff + pnpm dlx shadcn@latest add --diff + ``` + + `--diff` implies `--dry-run`. Prefer `--diff` on a specific file when the + combined output is truncated. + +4. **Merge per file:** + + | Diff | Action | + | --- | --- | + | No local Curve edits (file matches a stock vendor copy aside from standard scaffolding) | Safe to take upstream, then **re-apply** contract `satisfies` / `*Name` typing, the barrel, and story docs sourcing. | + | Has `CURVE:` markers or other local edits | Keep those hunks. Apply upstream hunks around them by editing the local file. | + | User explicitly wants to discard Curve customizations | Only then `pnpm dlx shadcn@latest add --overwrite`, and say so. Re-apply contract wiring, barrel, and stories afterwards. | + + **Never pass `--overwrite` without explicit approval.** + +5. **Re-apply Curve scaffolding the CLI does not know about:** + + - Contract import + `satisfies Record<*Name, string>` (or typed inline-union + props) from `@surfnet/curve-contracts`. + - `index.ts` barrel and `src/index.ts` export (do not flatten the directory). + - Story still sources description / axes from the contract object. + - Phosphor icons (`*Icon` from `@phosphor-icons/react`), not whatever + upstream defaulted to. + +6. **Accessibility pass** — see [SKILL.md](SKILL.md#accessibility). Check + `@storybook/addon-a11y` on the component stories. Update `a11y-gap` / + `a11y-minor` tags if the merge fixed, confirmed, or reintroduced an issue. + If upstream dropped a `CURVE: a11y` hunk, put it back. + +## Verify + +```bash +pnpm --filter @surfnet/curve-contracts lint +pnpm --filter @surfnet/curve-react lint +pnpm --filter @surfnet/curve-react build +pnpm format +``` + +Rebuild Storybook if stories changed: +`pnpm --filter @surfnet/curve-react build-storybook`. + +## Notes + +- The published `styles.css` is compiled Tailwind. Consumers do not need to + re-run shadcn; only this package's source merge changes what they get after + the next Curve release. +- CSS entry updates (`src/index.css`, `@theme`) from `--diff globals.css` / + the dry-run CSS section: take token mappings only when they are new Curve + needs. **Do not** paste shadcn's `:root` / `.dark` blocks — tokens come from + `@surfnet/curve-tokens`. 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..6e381129 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,49 @@ 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.2 AA) + id: a11y_audit + 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() + env: + A11Y_AUDIT_OUTCOME: ${{ steps.a11y_audit.outcome }} + 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/AGENTS.md b/AGENTS.md index 22063288..645deb5a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,7 +87,11 @@ OpenCode): `npx shadcn@latest mcp init --client ` for shadcn, and add the - **One directory per component**: `src/components/ui//` holds `.tsx`, its story, an `index.ts` barrel, and (later) tests. The barrel keeps `@/components/ui/` imports resolving for other shadcn components. -- See the **add-component** skill (`react.md`) for the exact flow. +- See the **add-component** skill (`react.md`) for the exact flow. To refresh an + already-vendored component from shadcn, see **update-component** (`react.md`) — + never `shadcn add --overwrite`. To refresh an + already-vendored component from shadcn, see **update-component** (`react.md`) — + never `shadcn add --overwrite`. - Library build externalises bare imports; relative + `@/` aliased imports are bundled (`vite.config.ts`). `.d.ts` files land under `dist/src/` — that's why `package.json` `types` points at `dist/src/index.d.ts`. @@ -100,7 +104,11 @@ OpenCode): `npx shadcn@latest mcp init --client ` for shadcn, and add the import each other through it, and `ng-packagr` inlines those into the build. - Runtime deps of the library must be listed in `ng-package.json` → `allowedNonPeerDependencies`, or `ng-packagr` fails the build. -- See the **add-component** skill (`angular.md`) for the exact flow. +- See the **add-component** skill (`angular.md`) for the exact flow. To refresh an + already-vendored helm component, see **update-component** (`angular.md`) — never + re-run `ng g @spartan-ng/cli:ui` as an overwrite. To refresh an + already-vendored helm component, see **update-component** (`angular.md`) — never + re-run `ng g @spartan-ng/cli:ui` as an overwrite. ### Storybook @@ -200,12 +208,15 @@ Task-specific playbooks live in `.agents/skills/` (symlinked to `.claude/skills` the same files under `/downloads/accessibility/` and ships a zip at `packages/storybook-config/static/accessibility.zip`. Regenerate the zip after editing the skill: `pnpm --filter @surfnet/curve-storybook-config bundle:accessibility-skill`. +- **update-component** — (repo-authored) merge upstream shadcn / Spartan changes into an + already-vendored component without overwriting Curve design or accessibility edits. + Routes to `react.md` and `angular.md`. Never re-run the add CLIs as a refresh. - **shadcn** — (upstream, from `shadcn/ui`) deep reference for shadcn components, registries, presets, and Base-vs-Radix. - **spartan** — (upstream, from `spartan-ng/spartan`) deep reference for spartan/ui, the Brain/Helm layers, the CLI generators, and component APIs. The two upstream skills are vendored as plain files in `.agents/skills/` (the same place as -our own skill); they reach Claude Code through the `.claude/skills` symlink. To refresh them, +our own skills); they reach Claude Code through the `.claude/skills` symlink. To refresh them, re-fetch from their repos — do **not** use `skills add` without scoping it, as it scatters copies into ~20 unrelated agent directories. diff --git a/README.md b/README.md index ce1241fc..5865d7ce 100644 --- a/README.md +++ b/README.md @@ -252,8 +252,28 @@ In Claude Code, run `/mcp` to confirm both show `Connected`. For Cursor/Codex/Op above. The repo also vendors the upstream **`shadcn`** and **`spartan`** agent skills (deep -component/API references) in `.agents/skills/`, alongside the repo's own `add-component` -skill. They're exposed to Claude Code through the `.claude/skills` symlink. +component/API references) in `.agents/skills/`, alongside the repo's own +`add-component` and `update-component` skills. They're exposed to Claude Code +through the `.claude/skills` symlink. + +## Customizing and updating components + +Vendored shadcn (React) and Spartan helm (Angular) files are **source we own**. +The add CLIs copy them in once; running those CLIs again on an existing +component can overwrite local design and accessibility work. + +**Changes can have an effect on accessibility.** Restyling a component, hiding a +label, changing a focus ring, or merging upstream can break keyboard use, screen +readers, names, or contrast — including on stories tagged `a11y-gap` / +`a11y-minor` in Storybook. Check the Accessibility addon before you consider an +edit done. + +To refresh a component from upstream, follow +[`.agents/skills/update-component/SKILL.md`](.agents/skills/update-component/SKILL.md) +(React: `react.md`, Angular: `angular.md`): diff, merge, keep `CURVE:` markers. +Do not `shadcn add --overwrite` or re-run `ng g @spartan-ng/cli:ui` as a +shortcut. New components still use +[`.agents/skills/add-component/SKILL.md`](.agents/skills/add-component/SKILL.md). ## Theming diff --git a/docs/decision-log.md b/docs/decision-log.md index f07a2b6c..492934d2 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) @@ -347,7 +348,7 @@ apps smaller bundles. **Context.** The PoC needs a representative component set in both frameworks. **Decision.** Roughly **16 components each** in React and Angular, built in parity. React -additionally ships a **data table** (TanStack); both share the rest. Target **WCAG 2.1 AA**. +additionally ships a **data table** (TanStack); both share the rest. Target **WCAG 2.2 AA**. **Rationale.** A parity set proves the contract mechanism and tokens across both frameworks; the data table demonstrates React-only depth where Angular has no direct equivalent. @@ -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 fd61c374..19c46e8b 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "storybook:angular": "pnpm --filter @surfnet/curve-angular 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/main.ts b/packages/angular/.storybook/main.ts index 555569db..ba35f509 100644 --- a/packages/angular/.storybook/main.ts +++ b/packages/angular/.storybook/main.ts @@ -1,5 +1,6 @@ import type { StorybookConfig } from '@storybook/angular'; import remarkGfm from 'remark-gfm'; +import { a11yTagConfig } from '@surfnet/curve-storybook-config'; const config: StorybookConfig = { stories: [ @@ -30,6 +31,7 @@ const config: StorybookConfig = { { from: '../../../.agents/skills/accessibility', to: 'downloads/accessibility' }, ], framework: '@storybook/angular', + tags: a11yTagConfig, }; export default config; diff --git a/packages/angular/.storybook/preview.ts b/packages/angular/.storybook/preview.ts index 79cc474e..2952ac2a 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 a41af1e2..24d2c51c 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", @@ -64,16 +67,20 @@ "@storybook/addon-a11y": "10.4.5", "@storybook/addon-docs": "10.4.5", "@storybook/angular": "10.4.5", + "@storybook/test-runner": "0.24.5", "@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", "remark-gfm": "^4.0.1", "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 7b68d845..98d02bd9 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 @@ -15,7 +15,7 @@ const badgeVariantClasses = { 'bg-danger-subtle text-danger-subtle-foreground [a]:hover:bg-danger-subtle-hover focus-visible:ring-danger/20 dark:focus-visible:ring-danger/40', 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: ` -