Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -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` |
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user