Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

+203
View File
@@ -0,0 +1,203 @@
# CLI ops (`toolctrl ops`)
The operator CLI under `packages/cli/` ships three ops sub-commands
for day-to-day operational work:
- `toolctrl ops doctor` — local environment health check
- `toolctrl ops migrate-tenant` — resumable tenant migration between envs
- `toolctrl ops impersonate` — audit-logged debug magic-link for a tenant user
All three commands accept the `--env` flag to target a named env file
under `~/.toolctrl/envs/`. The default is the current working-dir's
`.env` (i.e. whatever is already loaded into `process.env`).
## Env-file layout
Named envs live under:
```
~/.toolctrl/envs/<name>.env
```
Example layout:
```
~/.toolctrl/envs/
├── staging.env # chmod 600
├── staging-backup.env
└── dev.env
```
- File format: standard `KEY=VALUE` `.env` syntax with `#` comments.
No shell interpolation, no multi-line values.
- File permissions must be `0600` (owner-only). The CLI emits a warning
when world-readable bits are set; rotate the file with
`chmod 600 ~/.toolctrl/envs/<name>.env`.
- Production env files **must not** live in CI. The `impersonate`
command refuses any env named `prod`, `production`, or `live`
regardless of file content.
## `toolctrl ops doctor`
Runs eight probes against the resolved env and emits an ok/warn/fail
table:
| Probe | Description | Fail hint |
|---|---|---|
| `node.version` | Process Node version >=20 | `nvm use` |
| `pnpm.version` | `pnpm --version` >=9 | `corepack enable` |
| `docker.reachable` | `docker info` succeeds | `docker compose up -d` |
| `postgres.reachable` | DATABASE_URL ping (injected) | Check `docker compose ps postgres` |
| `redis.reachable` | REDIS_URL ping (injected) | `docker compose up -d redis` |
| `sentry.dsn` | HEAD against SENTRY_DSN origin | Check Coolify Sentry container |
| `env.required` | Required env keys present | Update your `.env` |
| `prisma.migrations` | `prisma migrate status` (injected) | `pnpm db:migrate dev` |
Examples:
```bash
# Default — read local .env
node packages/cli/bin/toolctrl-cli.mjs ops doctor
# Read ~/.toolctrl/envs/staging.env
node packages/cli/bin/toolctrl-cli.mjs ops doctor --env=staging
# JSON output for CI
node packages/cli/bin/toolctrl-cli.mjs ops doctor --json
```
**Exit codes:** `0` when every probe is `ok` or `warn`; `1` when any
probe reports `fail`.
### Troubleshooting
| Symptom | Likely cause |
|---|---|
| `postgres.reachable: fail (DATABASE_URL not set)` | Forgot to export `.env` before running CLI |
| `docker.reachable: warn` | Docker Desktop not running |
| `sentry.dsn: warn` | SENTRY_DSN intentionally unset — error reporting disabled |
| `env.required: fail Missing required env vars: SESSION_SECRET` | Bootstrap your `.env` from `.env.example` |
| `prisma.migrations: warn N pending` | Run `pnpm db:migrate dev` |
## `toolctrl ops migrate-tenant`
End-to-end tenant migration between named envs. Reuses the
`packages/cli/src/commands/tenant/snapshot.ts` infrastructure from
phase 03 (#44). Resumable via a state file under
`~/.toolctrl/migrations/<tenantId>-<timestamp>.state.json`.
Steps (each is an atomic resume-point):
1. `preflight` — both envs resolve, names validated
2. `snapshot` — `tenant snapshot` exports source tenant to a local archive
3. `restore` — archive is restored into the target env
4. `verify` — row-counts compared source vs target
5. `cleanup` — snapshot kept on disk for audit
Examples:
```bash
# Plan a migration (no writes)
node packages/cli/bin/toolctrl-cli.mjs ops migrate-tenant tenant-abc \
--from=staging --to=staging-backup --dry-run
# Real migration
node packages/cli/bin/toolctrl-cli.mjs ops migrate-tenant tenant-abc \
--from=staging --to=staging-backup
# Resume after a crash
node packages/cli/bin/toolctrl-cli.mjs ops migrate-tenant tenant-abc \
--from=staging --to=staging-backup \
--resume ~/.toolctrl/migrations/tenant-abc-2026-05-17T120000000Z.state.json
```
The CLI emits a per-step status line:
```
state-file=/home/op/.toolctrl/migrations/tenant-abc-2026-05-17T120000000Z.state.json
preflight: ok — from=staging to=staging-backup
snapshot: ok — /home/op/.toolctrl/migrations/tenant-abc-….tar.gz
restore: ok — restored 12345 rows
verify: ok — row-counts match
cleanup: ok — snapshot kept on disk for audit
```
The Prisma + S3 backends are injected at the `bin/toolctrl-cli.mjs`
entrypoint. The standalone subcommand prints a warning if invoked
without that injection (no destructive operations happen).
## `toolctrl ops impersonate`
Generates a 15-minute HMAC-signed magic-link the engineer can paste
into a browser to debug a specific tenant user. Every issued link
writes an `AuditEvent` so the action is reviewable.
```bash
node packages/cli/bin/toolctrl-cli.mjs ops impersonate \
tenant-abc user-123 \
--env=staging \
--reason="support-ticket-456"
```
**Safety guarantees:**
- Production env names (`prod`, `production`, `live`) are hard-blocked.
No `--force` bypass exists. Use the admin self-service impersonate
UI for production.
- TTL is fixed at 15 minutes. Tokens cannot be longer-lived.
- HMAC signing key must be at least 16 characters (the CLI throws
otherwise).
- The audit-event captures:
- `actorType = admin`
- `action = ops.impersonate.issued`
- `targetType = user`, `targetId = <userId>`
- `regulatoryScope = [soc2]`
- `dataSensitivity = pii`
- `outcome = success`
- `metadata = { envName, tokenId, expiresAt, ttlMinutes, reason, userEmail }`
The audit-event is written before the link is returned, so even a
crashing operator workflow leaves a trail.
### Backend route
The corresponding loader at
`apps/web/app/routes/api.auth.impersonate.tsx` (to be wired in a
follow-up phase) is responsible for:
1. Verifying the HMAC signature against the same secret
2. Looking up the audit-event by `tokenId` and refusing if absent
3. Refusing if the audit-event's `outcome ≠ success`
4. Setting a session cookie scoped to the impersonated user with a
short lifetime
## Where things live
```
packages/cli/src/commands/ops/
├── _env.ts # env-file resolver (local vs named)
├── _env.test.ts
├── doctor.ts # doctor command + 8 probes
├── doctor.test.ts
├── migrate-tenant.ts # 5-step resumable migration
├── migrate-tenant.test.ts
├── impersonate.ts # HMAC magic-link + audit-event
├── impersonate.test.ts
└── index.ts # citty subcommand group
```
The bin entrypoint in `packages/cli/bin/toolctrl-cli.mjs` is responsible
for injecting the live Prisma client + S3 client + URL base into the
commands. The standalone files keep zero dependencies on `@prisma/client`
or AWS SDK so the package builds without optional peers.
## Security considerations
- **Never commit `~/.toolctrl/envs/*.env`.** The path is outside the
repo on purpose.
- The `migrate-tenant` state files contain tenant IDs but no secrets.
They are safe to retain for audit.
- Snapshot archives may contain PII. Apply the standard PII-retention
policy (chmod 600, delete after migration audit window).
- The `impersonate` audit-event is admin-visible and forms part of
the soc2 audit trail. Do not redact it after the fact.
@@ -0,0 +1,163 @@
# CLI scaffolding (`toolctrl create` / `toolctrl add`)
The operator CLI under `packages/cli/` ships scaffolding generators that
emit minimal, opinionated skeletons for the most common boilerplate
chores. Each generator writes to **new paths only** and supports a
`--dry-run` flag for testability.
## Quickstart
```bash
# from the repo root, after `pnpm install`
pnpm --filter @toolctrl/cli build
# show the full command tree
node packages/cli/bin/toolctrl-cli.mjs --help
```
## Command reference
### `toolctrl create module <name>`
Scaffolds a feature-module skeleton under `apps/web/app/modules/<name>/`:
- `db/<name>.db.server.ts` — tenant-scoped CRUD helpers
- `services/<Name>Service.ts` — service layer with tenant guards
- `components/<Name>List.tsx` — list UI stub
- `<name>.db.server.test.ts` — vitest smoke test
- `README.md` — manual follow-up checklist
- `apps/web/app/routes/app.$tenant.<name>.tsx` — React-Router-v7 tenant route
Flags:
- `--permission-prefix <prefix>` — override the permission key prefix.
- `--dry-run` — print the would-be writes without touching the filesystem.
Example:
```bash
node packages/cli/bin/toolctrl-cli.mjs create module inventory
```
Manual follow-up (the README in the generated module repeats this):
1. Add a Prisma model and run `pnpm prisma migrate dev`.
2. Replace the `TODO` markers in the db layer.
3. Add the permission keys to
`packages/db/src/permissions-seed.server.ts` (see
`toolctrl add permission` below).
4. Run `pnpm typecheck` to generate the route types.
### `toolctrl create route <filename>`
Scaffolds a single React-Router-v7 route file at
`apps/web/app/routes/<filename>.tsx` plus a co-located vitest stub.
Flags:
- `--top-level` — generate a route without the `:tenant` param.
- `--dry-run` — print the would-be writes without touching the filesystem.
Example:
```bash
node packages/cli/bin/toolctrl-cli.mjs create route app.\$tenant.dashboard
```
The generated file imports `Route` types from `./+types/<filename>`
(NEVER relative paths) and contains loader, action, default component
and an `ErrorBoundary` export. Run `pnpm typecheck` after generation
to populate the `./+types/` directory.
### `toolctrl create entity <name>`
Emits a Prisma model snippet plus a timestamped migration directory:
- `prisma/_generated/<name>.prisma` — model snippet to paste into
`prisma/schema.prisma`
- `prisma/migrations/<timestamp>_create_entity_<name>/migration.sql` —
placeholder SQL
- `prisma/migrations/<timestamp>_create_entity_<name>/README.md` —
step-by-step apply instructions
The generator does NOT modify `prisma/schema.prisma` automatically.
After generation:
```bash
# paste the model from prisma/_generated/<name>.prisma into prisma/schema.prisma
pnpm prisma migrate dev --name create_entity_<name>
pnpm prisma generate
```
### `toolctrl add hook <name>`
Scaffolds a plugin-hook registration file under
`apps/web/app/hooks/_generated/`. The output is a **forward-compatible
stub** for generated hook registration. The plugin runtime and
`@toolctrl/plugin-sdk` now exist; keep generated hooks on the scaffolded
no-op shape until a focused task wires that specific hook into the runtime.
Example:
```bash
node packages/cli/bin/toolctrl-cli.mjs add hook onContactCreated
```
### `toolctrl add event-type <name>`
Scaffolds an EntityWebhook event-type definition with a zod schema, an
example payload, and an async handler stub under
`apps/web/app/modules/workflowEngine/eventTypes/_generated/`. Event
names MUST be dotted lowercase, e.g. `crm.contact.created`.
Example:
```bash
node packages/cli/bin/toolctrl-cli.mjs add event-type crm.contact.created
```
### `toolctrl add permission <name>`
Scaffolds two fragments for a new permission key:
- `packages/db/src/_generated-permissions/<key>.ts` — seed fragment
(paste into `defaultPermissionSeeds`).
- `apps/web/app/utils/permissions/_generated/<key>.ts` — frontend
permission-mapper stub.
The generator intentionally does NOT patch the hand-curated
`defaultPermissionSeeds` array in `permissions-seed.server.ts`. The
README notes spell out the manual paste step.
Example:
```bash
node packages/cli/bin/toolctrl-cli.mjs add permission app.inventory.view
```
## Safety guarantees
- Generators **never** overwrite existing files. Re-running a generator
with the same name throws and exits non-zero.
- All generators support `--dry-run`, which prints the would-be writes
without touching the filesystem.
- Generators only produce new paths inside the repo (or
`prisma/_generated/` snippets that you paste in by hand). The
hand-curated source-of-truth files (`schema.prisma`,
`permissions-seed.server.ts`, `routes.ts`) are never patched
automatically.
## Adding a new generator
1. Create `packages/cli/src/commands/{create,add}/<command>.ts` with a
`generate<Name>` function and a `defineCommand` export.
2. Re-use the helpers from `_lib/scaffold.ts`:
- `validateIdentifier`, `pascalCase`, `kebabCase`, `snakeCase`
- `createScaffoldResult` + `writeScaffoldFile`
3. Add a vitest suite next to the command file that asserts:
- The expected file paths (in dry-run mode).
- Content invariants (e.g. correct import paths).
- Refuses to overwrite existing files.
4. Register the command in the relevant `create/index.ts` or
`add/index.ts` and update the root `index.test.ts` to keep the
registry shape pinned.
@@ -0,0 +1,190 @@
# i18n Audit — Baseline 2026-05-25
> Generated by `scripts/i18n-audit.mjs`. Counts hardcoded user-facing
> strings in `apps/web/app/{routes,modules,routeModules,components}/**/*.{ts,tsx}`.
> Baseline only — not yet
> enforced. See `docs/contributing/i18n.md` for the migration playbook.
>
> Historical baseline note: this file is a point-in-time audit artifact from
> 2026-05-25. Re-run the audit script before using counts as current status.
>
> Current re-run note: `node scripts/i18n-audit.mjs --json` on 2026-05-29
> reports `0` hardcoded strings and `0` locale-formatting signals.
## Current Re-Run Snapshot
| Metric | Value |
| ---------------------------------------------- | ----: |
| Files scanned | 1774 |
| Files with hardcoded strings | 0 |
| Total hardcoded JSX text fragments | 0 |
| Files with locale-sensitive formatting signals | 0 |
| Total locale-sensitive formatting signals | 0 |
## Historical Baseline Summary
| Metric | Value |
| ---------------------------------------------- | ----: |
| Files scanned | 1752 |
| Files with hardcoded strings | 4 |
| Total hardcoded JSX text fragments | 26 |
| Files with locale-sensitive formatting signals | 91 |
| Total locale-sensitive formatting signals | 213 |
## Historical Top Offenders
| File | Hits |
| ----------------------------------------------------------------------- | ---: |
| `apps/web/app/modules/knowledgeBase/utils/DefaultKbsTemplate.ts` | 22 |
| `apps/web/app/routes/__test.client-throw.tsx` | 2 |
| `apps/web/app/components/ai-elements/open-in-chat.tsx` | 1 |
| `apps/web/app/modules/workflowEngine/utils/DefaultWorkflowTemplates.ts` | 1 |
## Historical Disposition
The 26 hardcoded text fragments from the 2026-05-25 baseline are no longer
reported by the current audit. The historical dispositions below explain why
they were not treated as active product UI debt at the time:
| File | Disposition |
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `apps/web/app/modules/knowledgeBase/utils/DefaultKbsTemplate.ts` | Seed/template article content. Migrate only if knowledge-base templates gain locale variants. |
| `apps/web/app/routes/__test.client-throw.tsx` | Test-only Sentry smoke route. Keep diagnostic copy literal. |
| `apps/web/app/components/ai-elements/open-in-chat.tsx` | Provider/brand name (`Scira AI`). Proper noun, do not translate. |
| `apps/web/app/modules/workflowEngine/utils/DefaultWorkflowTemplates.ts` | English-scoped default workflow payload. Migrate only if workflow templates gain locale variants. |
## Detail (first 100 hits)
| File:Line | Hardcoded text |
| --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `apps/web/app/components/ai-elements/open-in-chat.tsx:45` | Scira AI |
| `apps/web/app/modules/knowledgeBase/utils/DefaultKbsTemplate.ts:120` | Watch the demo video: |
| `apps/web/app/modules/knowledgeBase/utils/DefaultKbsTemplate.ts:122` | Watch the demo video: |
| `apps/web/app/modules/knowledgeBase/utils/DefaultKbsTemplate.ts:142` | Static Knowledge Base |
| `apps/web/app/modules/knowledgeBase/utils/DefaultKbsTemplate.ts:142` | And the knowledge base |
| `apps/web/app/modules/knowledgeBase/utils/DefaultKbsTemplate.ts:142` | Dynamic Knowledge Bases |
| `apps/web/app/modules/knowledgeBase/utils/DefaultKbsTemplate.ts:142` | For all other knowledge bases, the |
| `apps/web/app/modules/knowledgeBase/utils/DefaultKbsTemplate.ts:142` | You can change the hard-coded |
| `apps/web/app/modules/knowledgeBase/utils/DefaultKbsTemplate.ts:144` | Static Knowledge Base |
| `apps/web/app/modules/knowledgeBase/utils/DefaultKbsTemplate.ts:144` | And the knowledge base |
| `apps/web/app/modules/knowledgeBase/utils/DefaultKbsTemplate.ts:144` | Dynamic Knowledge Bases |
| `apps/web/app/modules/knowledgeBase/utils/DefaultKbsTemplate.ts:144` | For all other knowledge bases, the |
| `apps/web/app/modules/knowledgeBase/utils/DefaultKbsTemplate.ts:144` | You can change the hard-coded |
| `apps/web/app/modules/knowledgeBase/utils/DefaultKbsTemplate.ts:163` | Beispiel 2 |
| `apps/web/app/modules/knowledgeBase/utils/DefaultKbsTemplate.ts:164` | Beispiel 2 |
| `apps/web/app/modules/knowledgeBase/utils/DefaultKbsTemplate.ts:182` | Sample 2 |
| `apps/web/app/modules/knowledgeBase/utils/DefaultKbsTemplate.ts:183` | Sample 2 |
| `apps/web/app/modules/knowledgeBase/utils/DefaultKbsTemplate.ts:227` | Beispiel 1 |
| `apps/web/app/modules/knowledgeBase/utils/DefaultKbsTemplate.ts:228` | Beispiel 1 |
| `apps/web/app/modules/knowledgeBase/utils/DefaultKbsTemplate.ts:246` | Beispiel 3 |
| `apps/web/app/modules/knowledgeBase/utils/DefaultKbsTemplate.ts:247` | Beispiel 3 |
| `apps/web/app/modules/knowledgeBase/utils/DefaultKbsTemplate.ts:265` | Sample 3 |
| `apps/web/app/modules/knowledgeBase/utils/DefaultKbsTemplate.ts:266` | Sample 3 |
| `apps/web/app/modules/workflowEngine/utils/DefaultWorkflowTemplates.ts:760` | It works! |
| `apps/web/app/routes/__test.client-throw.tsx:33` | Sentry client smoke test |
| `apps/web/app/routes/__test.client-throw.tsx:34` | Triggering a synchronous client-side error. The root ErrorBoundary should pick it up and report to Sentry. |
## Formatting migration signals (first 100 hits)
| File:Line | Signal |
| ----------------------------------------------------------------------------------------------- | ---------------------------- |
| `apps/web/app/components/aceternity/infinite-moving-cards.tsx:214` | `DateUtils.dateYMDHMSMS` |
| `apps/web/app/components/aceternity/infinite-moving-cards.tsx:215` | `DateUtils.dateAgo` |
| `apps/web/app/components/analytics/AnalyticsOverview.tsx:25` | `NumberUtils.intFormat` |
| `apps/web/app/components/analytics/AnalyticsOverview.tsx:32` | `NumberUtils.intFormat` |
| `apps/web/app/components/analytics/AnalyticsOverview.tsx:64` | `NumberUtils.intFormat` |
| `apps/web/app/components/analytics/AnalyticsOverview.tsx:132` | `NumberUtils.intFormat` |
| `apps/web/app/components/analytics/AnalyticsOverviewChart.tsx:8` | `NumberUtils.intFormat` |
| `apps/web/app/components/app/events/LogsTable.tsx:92` | `DateUtils.dateAgo` |
| `apps/web/app/components/app/events/LogsTable.tsx:95` | `DateUtils.dateYMD` |
| `apps/web/app/components/app/events/LogsTable.tsx:96` | `DateUtils.dateAgo` |
| `apps/web/app/components/core/apiKeys/ApiKeyLogsTable.tsx:30` | `DateUtils.dateYMDHMS` |
| `apps/web/app/components/core/apiKeys/ApiKeyLogsTable.tsx:31` | `DateUtils.dateYMDHMS` |
| `apps/web/app/components/core/apiKeys/ApiKeysTable.tsx:162` | `NumberUtils.intFormat` |
| `apps/web/app/components/core/apiKeys/ApiKeysTable.tsx:188` | `DateUtils.dateYMDHMS` |
| `apps/web/app/components/core/apiKeys/ApiKeysTable.tsx:192` | `DateUtils.dateAgo` |
| `apps/web/app/components/core/apiKeys/ApiKeysTable.tsx:197` | `DateUtils.dateYMDHMS` |
| `apps/web/app/components/core/apiKeys/ApiKeysTable.tsx:198` | `DateUtils.dateYMDHMS` |
| `apps/web/app/components/core/pricing/UsageBasedPricesUnit.tsx:109` | `NumberUtils.numberFormat` |
| `apps/web/app/components/core/settings/members/MembersListAndTable.tsx:48` | `DateUtils.dateYMDHMS` |
| `apps/web/app/components/core/settings/members/MembersListAndTable.tsx:49` | `DateUtils.dateAgo` |
| `apps/web/app/components/core/settings/subscription/ChangeSubscription.tsx:51` | `NumberUtils.intFormat` |
| `apps/web/app/components/core/settings/subscription/MyInvoices.tsx:28` | `DateUtils.dateYMD` |
| `apps/web/app/components/core/settings/subscription/MyInvoices.tsx:31` | `DateUtils.dateYMD` |
| `apps/web/app/components/core/settings/subscription/MyInvoices.tsx:32` | `DateUtils.dateAgo` |
| `apps/web/app/components/core/settings/subscription/MyPayments.tsx:31` | `DateUtils.dateYMD` |
| `apps/web/app/components/core/settings/subscription/MyPayments.tsx:32` | `DateUtils.dateAgo` |
| `apps/web/app/components/core/settings/subscription/MyPayments.tsx:42` | `NumberUtils.decimalFormat` |
| `apps/web/app/components/core/settings/subscription/MyPayments.tsx:60` | `DateUtils.dateYMD` |
| `apps/web/app/components/core/settings/subscription/MyPayments.tsx:63` | `DateUtils.dateYMD` |
| `apps/web/app/components/core/settings/subscription/MyPayments.tsx:64` | `DateUtils.dateAgo` |
| `apps/web/app/components/core/settings/subscription/MyPlanFeatureUsage.tsx:38` | `DateUtils.dateYMD` |
| `apps/web/app/components/core/settings/subscription/MyUpcomingInvoice.tsx:30` | `DateUtils.dateMonthDayYear` |
| `apps/web/app/components/core/settings/subscription/Plan.tsx:138` | `NumberUtils.numberFormat` |
| `apps/web/app/components/core/settings/subscription/PlanFeatureDescription.tsx:86` | `NumberUtils.intFormat` |
| `apps/web/app/components/core/settings/subscription/TenantProduct.tsx:43` | `DateUtils.dateMonthDayYear` |
| `apps/web/app/components/core/settings/subscription/TenantProduct.tsx:76` | `DateUtils.dateYMDHMSMS` |
| `apps/web/app/components/core/settings/subscription/TenantProduct.tsx:77` | `DateUtils.dateMonthDayYear` |
| `apps/web/app/components/core/settings/subscription/TenantProduct.tsx:78` | `DateUtils.dateMonthDayYear` |
| `apps/web/app/components/core/settings/subscription/TenantProduct.tsx:85` | `DateUtils.dateMonthDayYear` |
| `apps/web/app/components/core/settings/subscription/TenantProduct.tsx:89` | `DateUtils.dateMonthDayYear` |
| `apps/web/app/components/core/tenants/TenantsTable.tsx:104` | `NumberUtils.decimalFormat` |
| `apps/web/app/components/core/tenants/TenantsTable.tsx:115` | `DateUtils.dateMonthDayYear` |
| `apps/web/app/components/core/tenants/TenantsTable.tsx:119` | `DateUtils.dateMonthDayYear` |
| `apps/web/app/components/core/tenants/TenantsTable.tsx:176` | `NumberUtils.decimalFormat` |
| `apps/web/app/components/core/tenants/TenantsTable.tsx:214` | `DateUtils.dateYMD` |
| `apps/web/app/components/core/tenants/TenantsTable.tsx:215` | `DateUtils.dateAgo` |
| `apps/web/app/components/core/tenants/TenantsTable.tsx:244` | `DateUtils.dateYMD` |
| `apps/web/app/components/core/tenants/TenantsTable.tsx:246` | `NumberUtils.decimalFormat` |
| `apps/web/app/components/core/tenants/TenantsTable.tsx:251` | `DateUtils.dateAgo` |
| `apps/web/app/components/core/users/UsersTable.tsx:118` | `DateUtils.dateDM` |
| `apps/web/app/components/core/users/UsersTable.tsx:120` | `DateUtils.dateYMDHMS` |
| `apps/web/app/components/core/users/UsersTable.tsx:121` | `DateUtils.dateAgo` |
| `apps/web/app/components/core/users/UsersTable.tsx:224` | `DateUtils.dateAgo` |
| `apps/web/app/components/entities/properties/FormulaControl.tsx:39` | `NumberUtils.decimalFormat` |
| `apps/web/app/components/entities/relationships/EntityRelationshipsTable.tsx:63` | `NumberUtils.intFormat` |
| `apps/web/app/components/entities/rows/cells/RowDateCell.tsx:8` | `DateUtils.dateYMD` |
| `apps/web/app/components/entities/rows/cells/RowDateCell.tsx:12` | `DateUtils.dateYMD` |
| `apps/web/app/components/entities/rows/cells/RowDateCell.tsx:14` | `DateUtils.dateDMY` |
| `apps/web/app/components/entities/rows/cells/RowDateCell.tsx:16` | `DateUtils.dateMDY` |
| `apps/web/app/components/entities/rows/cells/RowDateCell.tsx:18` | `DateUtils.dateAgo` |
| `apps/web/app/components/entities/rows/cells/RowDateCell.tsx:20` | `DateUtils.dateYMD` |
| `apps/web/app/components/entities/rows/cells/RowFolioCell.tsx:6` | `NumberUtils.pad` |
| `apps/web/app/components/entities/rows/cells/RowNumberCell.tsx:11` | `NumberUtils.intFormat` |
| `apps/web/app/components/entities/rows/cells/RowNumberCell.tsx:13` | `NumberUtils.decimalFormat` |
| `apps/web/app/components/entities/rows/cells/RowNumberCell.tsx:15` | `NumberUtils.decimalFormat` |
| `apps/web/app/components/entities/rows/cells/RowNumberCell.tsx:15` | `NumberUtils.currencyFormat` |
| `apps/web/app/components/entities/rows/cells/RowNumberCell.tsx:17` | `NumberUtils.percentFormat` |
| `apps/web/app/components/entities/rows/RowActivity.tsx:132` | `DateUtils.dateYMDHMS` |
| `apps/web/app/components/entities/rows/RowActivity.tsx:132` | `DateUtils.dateAgo` |
| `apps/web/app/components/entities/rows/RowLogComment.tsx:88` | `DateUtils.dateAgo` |
| `apps/web/app/components/entities/rows/RowLogComment.tsx:89` | `DateUtils.dateYMDHMS` |
| `apps/web/app/components/entities/rows/RowLogComment.tsx:89` | `DateUtils.dateAgo` |
| `apps/web/app/components/entities/rows/RowLogs.tsx:24` | `DateUtils.dateDM` |
| `apps/web/app/components/entities/rows/RowLogs.tsx:79` | `DateUtils.dateYMDHMS` |
| `apps/web/app/components/entities/rows/RowLogs.tsx:80` | `DateUtils.dateHMS` |
| `apps/web/app/components/entities/views/EntityViewsTable.tsx:188` | `NumberUtils.intFormat` |
| `apps/web/app/components/ui/input/InputDate.tsx:172` | `DateUtils.dateYMD` |
| `apps/web/app/components/ui/multi-select.tsx:312` | `NumberUtils.intFormat` |
| `apps/web/app/components/ui/tables/PreviewTableSimple.tsx:45` | `NumberUtils.decimalFormat` |
| `apps/web/app/components/ui/tables/PreviewTableSimple.tsx:52` | `DateUtils.dateYMD` |
| `apps/web/app/modules/api/components/ApiKeyLogsDetails.tsx:96` | `DateUtils.dateYMDHMS` |
| `apps/web/app/modules/api/components/ApiKeyLogsDetails.tsx:98` | `DateUtils.dateYMDHMS` |
| `apps/web/app/modules/api/components/ApiKeyLogsDetails.tsx:166` | `NumberUtils.custom` |
| `apps/web/app/modules/api/components/ApiKeyLogsSummary.tsx:123` | `NumberUtils.intFormat` |
| `apps/web/app/modules/api/components/ApiKeyLogsSummary.tsx:136` | `NumberUtils.custom` |
| `apps/web/app/modules/chatbot/components/chat-list.tsx:52` | `DateUtils.dateAgo` |
| `apps/web/app/modules/codeGenerator/service/CodeGeneratorService.ts:243` | `DateUtils.dateYMD` |
| `apps/web/app/modules/codeGenerator/templates/routes/api/CodeTemplate.Routes.Edit.Api.ts:59` | `NumberUtils.pad` |
| `apps/web/app/modules/codeGenerator/templates/routes/views/CodeTemplate.Routes.Edit.View.ts:69` | `NumberUtils.pad` |
| `apps/web/app/modules/emailMarketing/components/EmailMarketingSummaryRoute.tsx:21` | `NumberUtils.decimalFormat` |
| `apps/web/app/modules/emailMarketing/components/EmailMarketingSummaryRoute.tsx:27` | `NumberUtils.decimalFormat` |
| `apps/web/app/modules/emailMarketing/components/EmailMarketingSummaryRoute.tsx:36` | `NumberUtils.numberFormat` |
| `apps/web/app/modules/emailMarketing/components/OutboundEmailsTable.tsx:177` | `DateUtils.dateYMDHMS` |
| `apps/web/app/modules/emailMarketing/components/OutboundEmailsTable.tsx:178` | `DateUtils.dateYMD` |
| `apps/web/app/modules/emailMarketing/components/OutboundEmailsTable.tsx:179` | `DateUtils.dateAgo` |
| `apps/web/app/modules/emailMarketing/components/OutboundEmailsTable.tsx:213` | `DateUtils.dateYMDHMS` |
| `apps/web/app/modules/events/components/EventDetails.tsx:26` | `DateUtils.dateYMDHMS` |
| `apps/web/app/modules/events/components/EventDetails.tsx:62` | `DateUtils.dateYMDHMS` |
| `apps/web/app/modules/events/components/EventDetails.tsx:68` | `DateUtils.dateYMDHMS` |
| `apps/web/app/modules/events/components/EventsTable.tsx:103` | `DateUtils.dateAgo` |
+213
View File
@@ -0,0 +1,213 @@
# i18n Contribution Guide
> Foundation work for #72 i18n-foundation. Targets contributors adding new
> UI surfaces or extending existing copy. The infrastructure is in place
> (ICU plurals, Intl helpers, RTL Tailwind variants, audit script, locale
> picker, Storybook toolbar) — what follows is the playbook.
## When to translate
Translate every user-facing string that:
- Renders in a React component that ships to the browser
- Renders in a transactional email body or subject (see
`packages/mail/src/templates-react/`)
- Renders in an error/help/empty-state surface accessible to a tenant user
Do **not** translate:
- Brand names (`ToolCTRL`, `Stripe`, `GitHub`, `Scira AI` etc.) — they're
proper nouns and stay constant across locales
- Developer-only tooling labels (Storybook globals, CLI prompts, log
messages, internal admin operator screens marked as such)
- Protocol identifiers (Authentik group names, SDK constants, etc.)
## Where translations live
```
apps/web/public/locales/
├── de/
│ ├── translations.json # Legacy single-file namespace (4000+ keys)
│ ├── errors.json
│ ├── mail.json
│ ├── common.json # Shared UI (buttons, generic labels) + ICU plurals
│ ├── settings.json # Per-area: settings surfaces
│ ├── billing.json # Per-area: billing surfaces
│ ├── crm.json # Per-area: CRM surfaces
│ ├── auth.json # Per-area: authentication surfaces
│ └── emailMarketing.json # Per-area: email marketing surfaces
└── en/ (same structure)
```
`translations.json` is the historical bag-of-keys. **New work should land
in the most specific existing namespace**: `common`, `settings`, `billing`,
`crm`, `auth`, or `emailMarketing`. Use namespace names exactly as registered
in `packages/i18n/src/index.ts`; the email marketing namespace is camelCase
(`emailMarketing`), not kebab-case.
When you add a brand-new namespace:
1. Create `apps/web/public/locales/{de,en}/<namespace>.json`
2. Add the namespace to `i18nNamespaces` in
`packages/i18n/src/index.ts`
3. Run `pnpm verify:i18n` so the registered namespace is checked for
locale/file parity
## Key naming
- Scope-first, dotted: `locale.title`, `action.save`,
`impersonation.actingAs`
- Keep keys stable; if copy changes drastically, mint a new key rather
than reusing the old one (helps with translation memory)
- Avoid embedding tokens in keys: prefer `memberCount` (plural-aware) over
`oneMember` + `manyMembers`
## ICU plurals
Use the ICU-MessageFormat syntax for pluralisation. The CLDR ruleset
covers 60+ languages; never re-implement.
```json
{
"itemsSelected": "{count, plural, =0 {No items selected} one {# item selected} other {# items selected}}"
}
```
In components:
```tsx
import { useTranslation } from "react-i18next";
import { formatIcu } from "@toolctrl/i18n";
function SelectionToolbar({ count }: { count: number }) {
const { t, i18n } = useTranslation("common");
return <span>{formatIcu(t("itemsSelected"), { count }, { locale: i18n.language })}</span>;
}
```
A `createUseTranslate()` factory in `@toolctrl/i18n` returns a hook that
auto-detects ICU syntax and routes to FormatJS — useful when most of your
keys are ICU-shaped. Plain (non-ICU) messages keep working through
i18next's `{{name}}` interpolation, so a gradual migration is safe.
### Pluralisation per language
| Locale | Cases used by CLDR |
| ------ | -------------------------------- |
| en | one, other |
| de | one, other |
| pl | one, few, many, other |
| ar | zero, one, two, few, many, other |
| ja, zh | other (no plural distinction) |
Polish and Arabic are the canonical test cases for "is the ICU pipeline
actually wired up correctly?" — see
`packages/i18n/src/icu/plural.test.ts`.
## Intl helpers
The `@toolctrl/i18n` package exports locale-aware Intl-API wrappers:
```tsx
import { formatCurrency, formatDate, formatNumber, formatPercent, formatList, formatRelativeTime } from "@toolctrl/i18n";
formatCurrency(1234.5, { locale, currency: "EUR" }); // "1.234,50 €" / "$1,234.50"
formatDate(new Date(), { locale, dateStyle: "medium" });
formatNumber(0.42, { locale, style: "decimal" });
formatPercent(0.25, { locale }); // "25%" / "25 %"
formatList(["A", "B", "C"], { locale }); // Oxford-comma aware
formatRelativeTime(earlier, { locale, relativeTo: new Date() });
```
All helpers accept an explicit `locale` so they're SSR-safe and produce
identical output on server + client.
## Locale resolution
When a request arrives, the server picks a locale in this order:
1. `User.locale` (set via the profile locale-picker)
2. `Tenant.defaultLocale`
3. `Accept-Language` header (browser default)
4. App fallback (`de`, see `packages/i18n/src/index.ts#defaultLocale`)
The resolved locale is stored in the i18n cookie (`_i18n`) and surfaced
to React via `useTranslation().i18n.language`. The picker UI lives in
`apps/web/app/modules/users/components/UserProfileSettings.tsx` (rendered
from `/app/<tenant>/settings/profile`) and POSTs to `/preferences`, which
calls the root `setLocale` action.
## RTL considerations
- `<html dir={getDirection(locale)}>` is set server-side in `root.tsx`
- Tailwind ships `rtl:` and `ltr:` modifier variants (registered in
`tailwind.config.ts`); use them where direction matters:
```tsx
<div className="ml-4 rtl:ml-0 rtl:mr-4">…</div>
<ChevronRightIcon className="rtl:rotate-180" />
```
- For text, prefer logical CSS properties (`ps-4` / `pe-4`) when adding
new layouts — even simpler than `rtl:` overrides
- Bidi-spoofing: never trust user-controlled strings to be LTR. The
display layer must strip Unicode bidi-override characters
(`U+202A`–`U+202E`, `U+2066`–`U+2069`) — see `compliance-audit-export`
and the security notes in the foundation spec
## Adding a new locale
1. Add the locale code to `supportedLocales` in
`packages/i18n/src/index.ts`. If it's RTL, also add it to
`rtlLocales`
2. Create `apps/web/public/locales/<locale>/{translations,errors,mail,common,settings,billing,crm,auth,emailMarketing}.json`
by copying the English files
3. Run `pnpm verify:i18n` — the parity check enforces all namespaces have
matching keys
4. Have the translation reviewed before merging
## Auditing hardcoded strings
The `scripts/i18n-audit.mjs` script scans web routes, modules,
route modules, and components for hardcoded English/German JSX text, then
records locale-sensitive formatting signals that should move to the
`@toolctrl/i18n` helpers over time. Run it before starting an extraction
sprint to baseline the count, and again after to measure progress:
```bash
node scripts/i18n-audit.mjs
# → docs/contributing/i18n-audit-<YYYY-MM-DD>.md
```
The current audit snapshot is `docs/contributing/i18n-audit-2026-05-25.md`.
Its re-run section is the active product-UI baseline: 0 hardcoded
route/component strings and 0 locale-formatting signals as of 2026-05-29.
The 2026-05-25 findings remain in that file as historical disposition only.
The repo-wide `pnpm verify:i18n` script enforces three guards:
- `scripts/i18n-check.ts` — per-locale namespace key parity
- `scripts/verify-hardcoded-strings.mjs` — repo-wide hardcoded-string
ratchet (active historical baseline: 14 brand/protocol/package
identifiers; JSX text, prop literals, and response literals are 0)
- `eslint --max-warnings=0` on the design-system packages
## Storybook
The Storybook `preview.tsx` exposes a `Locale` toolbar global. Switching
the value re-renders all stories with `<html lang/dir>` updated, so
`rtl:` Tailwind variants light up immediately. The default value is `en`;
`ar` is included as an RTL preview even though we don't ship Arabic
translations yet.
## Reference checklist for a new translated component
- [ ] Strings live in a per-area namespace (`common`, `settings`, …)
- [ ] DE and EN are both filled in (parity test enforces this)
- [ ] Plural-aware copy uses ICU syntax
- [ ] Currency/date/number rendering uses `@toolctrl/i18n` helpers,
passing the active locale
- [ ] Direction-sensitive Tailwind classes use `rtl:` / `ltr:` modifiers
(or logical CSS properties)
- [ ] No hardcoded Latin-alphabet copy in JSX text (audit clean for that
file)
@@ -0,0 +1,242 @@
# WCAG 2.2 AA — Internal Audit & Fix Backlog
**Status:** infrastructure shipped (Phase 10 #73), fix-sprint deferred
**Spec:** [`docs/improvement-plan/10-compliance-polish/wcag-internal.md`](../improvement-plan/10-compliance-polish/wcag-internal.md)
**Last updated:** 2026-05-26
This document is the operator-facing companion to the WCAG-internal task. It
explains:
1. What automation now guards accessibility on every PR (the "infrastructure"
that just shipped).
2. The current baseline of pre-existing violations and their severity.
3. The top-10 prioritized fix backlog.
4. How to add a new component with a11y coverage from day-1.
5. Manual screen-reader testing setup for NVDA (Windows) and VoiceOver (macOS).
## 1. What's wired today
| Surface | Tool | Trigger |
| -------------------------------- | ------------------------------------------------------------------- | ---------------------- |
| Component unit tests (jsdom) | `vitest-axe` via `@toolctrl/ui-app/test-setup/axe.ts` | `pnpm test:a11y` |
| Static heuristic checks | `apps/web/app/utils/test/a11y.tsx` (`expectNoA11yViolations`) | `*.a11y.test.tsx` |
| Live design feedback | Storybook `@storybook/addon-a11y` (WCAG-2.2-AA preset) | `pnpm --filter storybook dev` |
| Lint rules | `eslint-plugin-jsx-a11y` (`plugin:jsx-a11y/recommended`) | `pnpm lint` |
| Brand-token contrast | `scripts/verify-color-contrast.mjs` (zero-dep, fails CI on regress) | `pnpm verify:color-contrast` |
| Browser-level WCAG sweep | `@axe-core/playwright` | `pnpm verify:e2e-a11y` |
| CI gate | `.github/workflows/a11y.yml` + `.gitea/workflows/a11y.yml` | every PR + push to main |
| Keyboard "skip to content" | `apps/web/app/components/core/SkipToContent.tsx` | rendered in `AppLayout` |
Browser-level mobile public coverage includes landing, legal pages, error
pages, the feature-disabled page, footer menus, narrow viewports, and a compact
320px viewport. These checks assert no horizontal overflow and no critical axe
violations for the public mobile surface.
The six high-traffic components have an explicit axe-core spec:
- `apps/web/app/components/ui/Button.axe.test.tsx`
- `apps/web/app/components/ui/Input.axe.test.tsx`
- `apps/web/app/components/ui/Card.axe.test.tsx`
- `apps/web/app/components/ui/Dialog.axe.test.tsx`
- `apps/web/app/components/ui/Alert.axe.test.tsx`
- `apps/web/app/components/ui/Form.axe.test.tsx`
Focus-visible was added to the top-5 custom components missing it:
| Component | Change |
| ---------------------------------------------------------------------- | ------ |
| `apps/web/app/components/ui/card.tsx` | New `focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2` when the card is wrapped as a link / button. |
| `apps/web/app/components/ui/dialog.tsx` (DialogClose) | Migrated legacy `focus:` ring to `focus-visible:` (keyboard-only ring). |
| `apps/web/app/components/layouts/buttons/ProfileButton.tsx` | Same migration. |
| `apps/web/app/components/layouts/buttons/SearchButton.tsx` | Same migration. |
| `apps/web/app/components/layouts/buttons/NotificationsButton.tsx` | Same migration. |
## 2. Baseline of pre-existing violations
`eslint-plugin-jsx-a11y/recommended` is now applied as errors; rules with
existing violations are listed in
[`.eslintrc.a11y-baseline.json`](../../.eslintrc.a11y-baseline.json) and
temporarily disabled until each count reaches zero. The current totals:
| Rule | Count | Severity |
| --------------------------------------------- | ----- | -------- |
| `jsx-a11y/no-autofocus` | 81 | P2 |
Both P0 rules (`anchor-has-content`, `heading-has-content`) and all four P1
rules (`label-has-associated-control`, `click-events-have-key-events`,
`no-static-element-interactions`, `no-noninteractive-element-interactions`)
reached zero on 2026-05-20 and have been promoted back to `error` — see the
changelog below. `anchor-is-valid` and `no-noninteractive-tabindex` reached
zero on 2026-05-25 and have also been promoted. `media-has-caption` reached
zero on 2026-05-28 and has also been promoted. Baseline total: **81**
(P2-only remaining).
**Reduction policy** (also encoded in the baseline file):
- The baseline is monotonically non-increasing. `scripts/verify-a11y-baseline.mjs`
compares current counts to baseline and fails CI on regression.
- When a rule's violation count reaches **zero**, remove the override from
`.eslintrc`, drop the entry from `.eslintrc.a11y-baseline.json`, and add a
changelog line below.
### Promotion changelog
- **2026-05-20** — `jsx-a11y/anchor-has-content` and
`jsx-a11y/heading-has-content` reached **0** violations and were
promoted back to `error` (the `off` overrides were removed from
`.eslintrc`). The two reusable wrappers that tripped the rules —
`PaginationLink` (`apps/web/app/components/ui/pagination.tsx`) and
`StepperTitle` (`apps/web/app/components/ui/stepper.tsx`) — now
destructure `children` and render it explicitly inside the `<a>` /
`<h3>` so the rules can statically verify content instead of seeing
a bare `{...props}` spread.
- **2026-05-20** — all four **P1** rules
(`label-has-associated-control` 15, `click-events-have-key-events` 13,
`no-static-element-interactions` 11, `no-noninteractive-element-interactions`
2) reached **0** violations and were promoted back to `error` (the `off`
overrides were removed from `.eslintrc`) as part of the #73 WCAG sweep.
Fix patterns: form `<label>`s were associated with their controls via
`htmlFor`+`id` or had nested accessible text flattened to within depth-2;
decorative/empty labels captioning non-form widgets (Monaco/TipTap editors,
options tables) became `<div>` / `role="group"` captions, and `ConfirmModal`
inputs gained an `aria-label`; genuine div-buttons became `<button>`; rows
with nested interactive children gained `role="button"` + `tabIndex` +
`onKeyDown`; dead `onClick={e => e.preventDefault()}` handlers on plain
divs were removed; the `HoverEffect` `<img>` lightbox trigger was wrapped
in a `<button>`. Four genuine mouse-only click-to-focus hit-area
enhancements — `input-group`, `InputRichTextEditor`, the Novel editor
wrapper, and the `SiteTypeCard` wrapper — keep a justified inline
`eslint-disable` because adding a focusable role there would create a
redundant tab stop ahead of an already-keyboard-accessible control.
- **2026-05-25** — `jsx-a11y/anchor-is-valid` and
`jsx-a11y/no-noninteractive-tabindex` reached **0** reproduced
violations and were promoted back to `error`. The Storybook Popover
demo now uses real button controls for menu actions, and the stale
tabindex baseline entry was removed after the verifier reproduced zero
hits across the tracked app + Storybook scope.
- **2026-05-28** — `jsx-a11y/media-has-caption` reached **0** violations
and was promoted back to `error`. `AudioOrVideoPlayer`
(`apps/web/app/components/ui/media/AudioOrVideoPlayer.tsx`) now renders
a captions track for both audio and video media by default, while still
allowing callers to pass real VTT caption metadata.
- **2026-05-28** — `jsx-a11y/no-autofocus` dropped from **86** to **81**
violations. `ButtonPrimary` and `LinkOrAhref` now preserve their
`autoFocus` caller contract through programmatic focus instead of passing
`autoFocus` to DOM nodes.
## 3. Top-10 prioritized fix backlog
Severity tiers are deliberately small so a single PR can usually clear one.
| # | Severity | File | Rule / issue | Recommended fix |
| - | -------- | ---------------------------------------------------------------------- | --------------------------------------- | ---------------- |
| ~~1~~ | ~~P0~~ | ~~`pagination.tsx`~~ | ~~`anchor-has-content`~~ | ✅ Fixed 2026-05-20 — see promotion changelog. |
| ~~2~~ | ~~P0~~ | ~~`stepper.tsx`~~ | ~~`heading-has-content`~~ | ✅ Fixed 2026-05-20 — see promotion changelog. |
| ~~3~~ | ~~P1~~ | ~~`apps/web/app/components/**`~~ | ~~`label-has-associated-control` (15x)~~ | ✅ Fixed 2026-05-20 — see promotion changelog. |
| ~~4~~ | ~~P1~~ | ~~Custom div-buttons in editors~~ | ~~`click-events-have-key-events` (13x)~~ | ✅ Fixed 2026-05-20 — see promotion changelog. |
| ~~5~~ | ~~P1~~ | ~~Same set~~ | ~~`no-static-element-interactions` (11x)~~ | ✅ Fixed 2026-05-20 — see promotion changelog. |
| ~~6~~ | ~~P1~~ | ~~List-row click handlers~~ | ~~`no-noninteractive-element-interactions` (2x)~~ | ✅ Fixed 2026-05-20 — see promotion changelog. |
| ~~7~~ | ~~P2~~ | ~~Marketing demo videos~~ | ~~`media-has-caption` (2x)~~ | ✅ Fixed 2026-05-28 — reusable media player renders captions tracks. |
| 8 | P2 | Modal/search/onboarding inputs | `no-autofocus` (81x) | Replace with programmatic focus on mount so screen-reader hears modal title first. |
| ~~9~~ | ~~P2~~ | ~~Single tabindex container~~ | ~~`no-noninteractive-tabindex`~~ | ✅ Promoted 2026-05-25 — stale baseline entry reproduced as 0. |
| ~~10~~| ~~P2~~ | ~~`packages/ui/src/stories/Popover.stories.tsx`~~ | ~~`anchor-is-valid`~~ | ✅ Fixed 2026-05-25 — demo menu actions use buttons. |
Track per-fix PRs in the [Issue tracker](https://github.com/toolctrl-labs/boilerplate/issues) with
label `area:accessibility`.
## 4. Adding a new component with a11y coverage from day-1
```tsx
// packages/ui-app/src/MyWidget.tsx
export function MyWidget({ label }: { label: string }) {
return <button aria-label={label}>...</button>;
}
```
```tsx
// packages/ui-app/src/MyWidget.a11y.test.tsx
import { MyWidget } from "./MyWidget";
import { generateA11yTest } from "../test-utils/a11y-template";
generateA11yTest(MyWidget, { label: "Close" });
```
That's it — the helper renders the component, runs axe-core with the
project's WCAG-2.2-AA preset, and fails if any rule is violated.
For interactive components (Modal, Combobox, DataTable), add manual tests for:
- Focus trap (Tab cycles within, Escape closes).
- Keyboard activation (Enter/Space).
- Screen-reader-announced state changes (`aria-live`).
Storybook stories are picked up automatically by `addon-a11y` so designers
see the same violations live.
## 5. Manual screen-reader testing
WCAG 2.2 AA cannot be fully verified by axe-core — roughly 57 % is automatable
[per Deque](https://www.deque.com/axe/#what-can-axe-catch). The remaining
~43 % needs an actual screen reader.
### NVDA on Windows (Chrome)
1. Install [NVDA](https://www.nvaccess.org/download/) (free, open-source).
2. Start NVDA before opening Chrome (`Insert + N` opens the menu).
3. Navigate the staging site keyboard-only:
- `Tab` advances focus, `Shift+Tab` reverses.
- `H` jumps between headings; `K` between links; `F` between form fields.
- `Insert + F7` opens the elements list (headings/links/landmarks).
4. Validate flows: Login, Magic-Link, CRM contact create, DSGVO export.
5. Capture findings in `docs/accessibility/screen-reader-smoke-tests-<date>.md`
(one section per flow; format: `Pass / Findings`).
### VoiceOver on macOS (Safari)
1. Enable: `System Settings → Accessibility → VoiceOver → On` (or `⌘+F5`).
2. Navigate with `VO` modifier (`Control+Option` by default):
- `VO+→` next item; `VO+←` previous.
- `VO+Space` activate.
- `VO+U` opens the rotor; `VO+H` toggles per-heading navigation.
3. Same flows + findings template as NVDA.
### Keyboard-only without screen reader
1. Disconnect / disable the mouse (or `System Settings → Accessibility →
Pointer Control → Mouse Keys` to fall back to keyboard pointer).
2. Login → Dashboard → Settings → Sign out — every action must be reachable.
3. Critical: `Skip to main content` link appears when you press Tab on a
fresh page load; pressing Enter moves focus inside `<main>`.
## 6. Useful commands
```bash
pnpm verify:color-contrast # brand token contrast audit
pnpm test:a11y # vitest + axe-core suites
pnpm lint # incl. jsx-a11y/recommended (baseline-respecting)
pnpm verify:a11y-coverage # component + browser-level a11y coverage ratchet
pnpm verify:a11y-baseline # jsx-a11y baseline regression gate
pnpm verify:e2e-a11y # SKIP-safe playwright + axe-core route sweep
pnpm --filter @toolctrl/storybook dev # designer-facing live a11y feedback
```
`pnpm verify:e2e-a11y` exits successfully with a SKIP message when Playwright is
not installed or no target URL is reachable. Staging/CI lanes that must prove the
browser sweep should set `REQUIRE_E2E_A11Y=1`; that converts missing Playwright
or an unreachable `E2E_BASE_URL` into a failing gate. Authenticated browser specs
require `E2E_TENANT_EMAIL` and `E2E_TENANT_PASSWORD`; set `REQUIRE_AUTH_E2E=1`
when those specs must fail instead of silently skipping without credentials.
Set `REQUIRE_E2E_READINESS=1` for the non-mutating staging/readiness lane. That
requires tenant credentials plus `E2E_TENANT_DASHBOARD_PATH`,
`E2E_TENANT_SUBSCRIPTION_PATH`, API overview/keys/docs/logs paths,
`E2E_TENANT_POLICIES_PATH`, `E2E_PLUGIN_SETTINGS_PATH`,
`E2E_WORKFLOW_BUILDER_PATH`, `E2E_WORKFLOW_RUN_API_PATH`,
`E2E_WORKFLOW_RUN_MANUAL_PATH`, `E2E_WORKFLOW_WEBHOOK_RUN_MANUAL_PATH`, and
`PLUGIN_BINARY_BASE_PATH`, and turns
missing inputs or unavailable 400/403/404/login responses into failures.
Scheduled GitHub/Gitea readiness workflows run this contract against staging
with `E2E_BROWSER_MATRIX=1`, `E2E_A11Y_STRICT=1`, and
`E2E_ALLOW_WORKFLOW_RUN=1`.
Stripe/provider-live paths and mutating account/workflow actions stay separately
opt-in through their own env vars. Set `SKIP_E2E_A11Y=1` when a local or CI lane
should explicitly bypass the browser sweep.